From 5f5cc3fce8c9c58cbc17bbedc5d879c8eafe00fb Mon Sep 17 00:00:00 2001
From: Minjun Kim
Date: Mon, 5 Oct 2026 22:35:54 +0900
Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=A7=81=ED=81=AC=20=ED=8C=8C?=
=?UTF-8?q?=EC=84=9C=EC=9D=98=20=EA=B2=BD=EA=B3=84=20=EC=9E=85=EB=A0=A5?=
=?UTF-8?q?=EA=B3=BC=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=8C=80=EC=B2=B4=20?=
=?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
SPEC.md | 10 ++
.../deep-target-label/github-markdown.txt | 1 +
corpus/cases/deep-target-label/html.txt | 1 +
corpus/cases/deep-target-label/input.md | 1 +
.../deep-target-label/notion-markdown.txt | 1 +
corpus/cases/deep-target-label/plain.txt | 1 +
.../deep-target-label/slack-markdown.txt | 1 +
.../cases/deep-target-label/telegram-html.txt | 1 +
corpus/cases/deep-target-label/why.md | 6 +
.../image-alt-format/github-markdown.txt | 1 +
corpus/cases/image-alt-format/html.txt | 1 +
corpus/cases/image-alt-format/input.md | 1 +
.../image-alt-format/notion-markdown.txt | 1 +
corpus/cases/image-alt-format/plain.txt | 1 +
.../cases/image-alt-format/slack-markdown.txt | 1 +
.../cases/image-alt-format/telegram-html.txt | 1 +
corpus/cases/image-alt-format/why.md | 7 ++
.../nested-link-label/github-markdown.txt | 5 +
corpus/cases/nested-link-label/html.txt | 5 +
corpus/cases/nested-link-label/input.md | 5 +
.../nested-link-label/notion-markdown.txt | 5 +
corpus/cases/nested-link-label/plain.txt | 5 +
.../nested-link-label/slack-markdown.txt | 5 +
.../cases/nested-link-label/telegram-html.txt | 5 +
corpus/cases/nested-link-label/why.md | 7 ++
.../github-markdown.txt | 1 +
corpus/cases/unclosed-link-brackets/html.txt | 1 +
corpus/cases/unclosed-link-brackets/input.md | 1 +
.../notion-markdown.txt | 1 +
corpus/cases/unclosed-link-brackets/plain.txt | 1 +
.../unclosed-link-brackets/slack-markdown.txt | 1 +
.../unclosed-link-brackets/telegram-html.txt | 1 +
corpus/cases/unclosed-link-brackets/why.md | 6 +
crates/mdwire-core/src/inline.rs | 87 ++++++++++-----
crates/mdwire-core/src/vocab.rs | 42 ++++---
crates/mdwire-core/tests/render.rs | 80 ++++++++++++++
go/html_test.go | 29 +++++
go/inline.go | 103 +++++++++++++-----
go/targets_test.go | 90 +++++++++++++++
go/vocab.go | 42 ++++---
scripts/smoke.mjs | 25 +++++
41 files changed, 491 insertions(+), 99 deletions(-)
create mode 100644 corpus/cases/deep-target-label/github-markdown.txt
create mode 100644 corpus/cases/deep-target-label/html.txt
create mode 100644 corpus/cases/deep-target-label/input.md
create mode 100644 corpus/cases/deep-target-label/notion-markdown.txt
create mode 100644 corpus/cases/deep-target-label/plain.txt
create mode 100644 corpus/cases/deep-target-label/slack-markdown.txt
create mode 100644 corpus/cases/deep-target-label/telegram-html.txt
create mode 100644 corpus/cases/deep-target-label/why.md
create mode 100644 corpus/cases/image-alt-format/github-markdown.txt
create mode 100644 corpus/cases/image-alt-format/html.txt
create mode 100644 corpus/cases/image-alt-format/input.md
create mode 100644 corpus/cases/image-alt-format/notion-markdown.txt
create mode 100644 corpus/cases/image-alt-format/plain.txt
create mode 100644 corpus/cases/image-alt-format/slack-markdown.txt
create mode 100644 corpus/cases/image-alt-format/telegram-html.txt
create mode 100644 corpus/cases/image-alt-format/why.md
create mode 100644 corpus/cases/nested-link-label/github-markdown.txt
create mode 100644 corpus/cases/nested-link-label/html.txt
create mode 100644 corpus/cases/nested-link-label/input.md
create mode 100644 corpus/cases/nested-link-label/notion-markdown.txt
create mode 100644 corpus/cases/nested-link-label/plain.txt
create mode 100644 corpus/cases/nested-link-label/slack-markdown.txt
create mode 100644 corpus/cases/nested-link-label/telegram-html.txt
create mode 100644 corpus/cases/nested-link-label/why.md
create mode 100644 corpus/cases/unclosed-link-brackets/github-markdown.txt
create mode 100644 corpus/cases/unclosed-link-brackets/html.txt
create mode 100644 corpus/cases/unclosed-link-brackets/input.md
create mode 100644 corpus/cases/unclosed-link-brackets/notion-markdown.txt
create mode 100644 corpus/cases/unclosed-link-brackets/plain.txt
create mode 100644 corpus/cases/unclosed-link-brackets/slack-markdown.txt
create mode 100644 corpus/cases/unclosed-link-brackets/telegram-html.txt
create mode 100644 corpus/cases/unclosed-link-brackets/why.md
create mode 100644 go/targets_test.go
diff --git a/SPEC.md b/SPEC.md
index a07587e..be0984a 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -364,6 +364,16 @@ GFM 그대로, 정렬 표시와 셀 안의 마크업까지 살려서 낸다.
허용 스킴일 때만). 다른 채널의 `` 은 텔레그램·plain 은 링크, 마크다운 채널은 그대로
- 스킴 — 허용 목록. 주면 **그것만** 받는다(기본값에 더하지 않는다). React 컴포넌트도 같은 목록으로
한 번 더 본다
+- ``의 `alt`는 인라인 서식을 제거한 일반 텍스트를 속성값으로 이스케이프한다. 이미지가
+ 링크로 내려갈 때는 라벨의 본문 서식을 유지한다. 렌더된 HTML을 `alt`에 넣으면 태그가
+ 대체 글에 비치고, 이벤트 토크나이저가 속성 안의 `>`를 태그 끝으로 읽는다(리뷰에서 재현).
+- 링크·이미지 라벨의 인라인 파싱은 32단계까지 재귀한다. 그보다 깊은 라벨은 원문을 해당
+ 출력 문맥의 글자로 이스케이프해 낸다 — 내용은 남기고 호출 스택 고갈을 막는다. 대괄호
+ 짝과 주소 끝은 줄마다 한 번 역순으로 색인한다. 닫히지 않은 `[`마다 줄 끝까지 다시
+ 훑으면 입력 크기의 제곱만큼 걸리므로, 실패한 후보도 색인에서 바로 판정한다.
+- HTML 출력의 링크 라벨 안에 있는 링크·오토링크·이미지 링크 폴백은 라벨과 주소를
+ 글자로 보존한다. `` 안에 ``를 만들면 브라우저가 바깥 링크를 중간에서 닫으며,
+ 텔레그램도 중첩 링크를 받지 않는다. 명시적으로 불러오는 ``는 링크 안에도 둔다.
- 원문 태그는 강조와 같은 스택에 올라 짝·중첩이 맞는다 — 짝 없는 닫는 태그는 버리고, 안 닫힌
여는 태그는 블록 끝에서 닫는다(GitHub 도 같다). 퍼즈가 `` 만 열고 끝난 입력에서 잡았다
- 한도가 없어 나누지 않는다. **스트리밍은 텔레그램 편집과 같은 계약이다** — 누적본에
diff --git a/corpus/cases/deep-target-label/github-markdown.txt b/corpus/cases/deep-target-label/github-markdown.txt
new file mode 100644
index 0000000..3a8639e
--- /dev/null
+++ b/corpus/cases/deep-target-label/github-markdown.txt
@@ -0,0 +1 @@
+[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[cat](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)
diff --git a/corpus/cases/deep-target-label/html.txt b/corpus/cases/deep-target-label/html.txt
new file mode 100644
index 0000000..5a3e762
--- /dev/null
+++ b/corpus/cases/deep-target-label/html.txt
@@ -0,0 +1 @@
+
diff --git a/corpus/cases/deep-target-label/input.md b/corpus/cases/deep-target-label/input.md
new file mode 100644
index 0000000..3a8639e
--- /dev/null
+++ b/corpus/cases/deep-target-label/input.md
@@ -0,0 +1 @@
+[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[cat](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)
diff --git a/corpus/cases/deep-target-label/notion-markdown.txt b/corpus/cases/deep-target-label/notion-markdown.txt
new file mode 100644
index 0000000..3a8639e
--- /dev/null
+++ b/corpus/cases/deep-target-label/notion-markdown.txt
@@ -0,0 +1 @@
+[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[cat](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)
diff --git a/corpus/cases/deep-target-label/plain.txt b/corpus/cases/deep-target-label/plain.txt
new file mode 100644
index 0000000..f0acaa8
--- /dev/null
+++ b/corpus/cases/deep-target-label/plain.txt
@@ -0,0 +1 @@
+[[[[[[[cat](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test)
diff --git a/corpus/cases/deep-target-label/slack-markdown.txt b/corpus/cases/deep-target-label/slack-markdown.txt
new file mode 100644
index 0000000..3a8639e
--- /dev/null
+++ b/corpus/cases/deep-target-label/slack-markdown.txt
@@ -0,0 +1 @@
+[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[cat](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)
diff --git a/corpus/cases/deep-target-label/telegram-html.txt b/corpus/cases/deep-target-label/telegram-html.txt
new file mode 100644
index 0000000..9673420
--- /dev/null
+++ b/corpus/cases/deep-target-label/telegram-html.txt
@@ -0,0 +1 @@
+[[[[[[[cat](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test)](https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test) (https://e.test)
diff --git a/corpus/cases/deep-target-label/why.md b/corpus/cases/deep-target-label/why.md
new file mode 100644
index 0000000..e9b99cc
--- /dev/null
+++ b/corpus/cases/deep-target-label/why.md
@@ -0,0 +1,6 @@
+리뷰에서 링크 라벨 안의 링크를 재귀해 읽는 깊이가 제한되지 않았다.
+Rust CLI는 1만 단계 입력에서 호출 스택을 고갈시키고 프로세스를 중단했다.
+
+라벨 파싱은 32단계 뒤에 원문 글자로 내려간다. 이 케이스는 한도를 넘었을 때의
+출력을 채널마다 지키며, 더 큰 Rust·Go 회귀 시험은 완성본과 스트리밍을 대조한다.
+HTML 출력은 안쪽 주소를 글자로 내려 중첩 를 만들지 않는다.
diff --git a/corpus/cases/image-alt-format/github-markdown.txt b/corpus/cases/image-alt-format/github-markdown.txt
new file mode 100644
index 0000000..1de360e
--- /dev/null
+++ b/corpus/cases/image-alt-format/github-markdown.txt
@@ -0,0 +1 @@
+사진 `를 렌더하면
+`alt="cat"`가 나왔다. 대체 글에 태그가 비치고, npm 이벤트 토크나이저는
+속성 안의 첫 `>`에서 태그를 끊어 React에서 이미지 대신 `](https://outer.test)
+
+[](https://outer.test)
diff --git a/corpus/cases/nested-link-label/html.txt b/corpus/cases/nested-link-label/html.txt
new file mode 100644
index 0000000..f8a3822
--- /dev/null
+++ b/corpus/cases/nested-link-label/html.txt
@@ -0,0 +1,5 @@
+
+
+
+
+cat (https://inner.test/p.png)
diff --git a/corpus/cases/nested-link-label/input.md b/corpus/cases/nested-link-label/input.md new file mode 100644 index 0000000..44be968 --- /dev/null +++ b/corpus/cases/nested-link-label/input.md @@ -0,0 +1,5 @@ +[[**cat**](https://inner.test)](https://outer.test) + +[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[broken ok
diff --git a/corpus/cases/unclosed-link-brackets/input.md b/corpus/cases/unclosed-link-brackets/input.md new file mode 100644 index 0000000..93cff08 --- /dev/null +++ b/corpus/cases/unclosed-link-brackets/input.md @@ -0,0 +1 @@ +[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[broken [**ok**](https://e.test) diff --git a/corpus/cases/unclosed-link-brackets/notion-markdown.txt b/corpus/cases/unclosed-link-brackets/notion-markdown.txt new file mode 100644 index 0000000..93cff08 --- /dev/null +++ b/corpus/cases/unclosed-link-brackets/notion-markdown.txt @@ -0,0 +1 @@ +[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[broken [**ok**](https://e.test) diff --git a/corpus/cases/unclosed-link-brackets/plain.txt b/corpus/cases/unclosed-link-brackets/plain.txt new file mode 100644 index 0000000..55d743e --- /dev/null +++ b/corpus/cases/unclosed-link-brackets/plain.txt @@ -0,0 +1 @@ +[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[broken ok (https://e.test) diff --git a/corpus/cases/unclosed-link-brackets/slack-markdown.txt b/corpus/cases/unclosed-link-brackets/slack-markdown.txt new file mode 100644 index 0000000..93cff08 --- /dev/null +++ b/corpus/cases/unclosed-link-brackets/slack-markdown.txt @@ -0,0 +1 @@ +[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[broken [**ok**](https://e.test) diff --git a/corpus/cases/unclosed-link-brackets/telegram-html.txt b/corpus/cases/unclosed-link-brackets/telegram-html.txt new file mode 100644 index 0000000..0737101 --- /dev/null +++ b/corpus/cases/unclosed-link-brackets/telegram-html.txt @@ -0,0 +1 @@ +[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[broken ok diff --git a/corpus/cases/unclosed-link-brackets/why.md b/corpus/cases/unclosed-link-brackets/why.md new file mode 100644 index 0000000..159f96a --- /dev/null +++ b/corpus/cases/unclosed-link-brackets/why.md @@ -0,0 +1,6 @@ +리뷰에서 닫히지 않은 `[`를 반복한 한 줄은 각 대괄호에서 남은 꼬리를 다시 훑었다. +Rust CLI의 1만·2만·4만 자 입력이 약 0.19초·0.74초·3.00초로 늘어났다. +스트리밍의 붙들기 탐색은 이어서 훑어도, 마지막 인라인 파싱에서 제곱 비용이 발생했다. + +대괄호 짝과 주소 끝을 한 번 색인한다. 짝 없는 대괄호 뒤의 유효한 링크도 찾아야 한다. +큰 입력의 Rust·Go 회귀 시험은 완성본과 스트리밍 결과가 같은지 확인한다. diff --git a/crates/mdwire-core/src/inline.rs b/crates/mdwire-core/src/inline.rs index 00326e7..774c794 100644 --- a/crates/mdwire-core/src/inline.rs +++ b/crates/mdwire-core/src/inline.rs @@ -52,6 +52,8 @@ struct Open { #[derive(Clone)] pub(crate) struct Inline { + /// 링크·이미지 라벨의 재귀 깊이. 외부 입력이 호출 스택을 다 쓰지 못하게 제한한다. + target_depth: u8, open: Vec"); + assert_eq!(mdwire::render_with(input, Channel::Html, opts.clone()).parts.join(""), want); + let mut s = Streamer::with_options(Channel::Html, opts.clone()); + let mut acc = String::new(); + for c in input.chars() { s.push_into(&c.to_string(), &mut acc); } + let preview = format!("{acc}{}", s.preview()); + s.finish_into(&mut acc); + assert_eq!(acc, want); + assert_eq!(preview, want); + } + assert_eq!( + mdwire::render_with("", Channel::Html, opts).parts.join(""), + "

cat (javascript:x)
" + ); +} + +/// 링크 라벨 안의 링크·오토링크·이미지 폴백은 주소를 남기되 를 겹치지 않는다. +#[test] +fn html_link_labels_never_nest_anchors() { + for (input, label) in [ + ("[[**cat**](https://inner.test)](https://outer.test)", "cat (https://inner.test)"), + ("[
{input}
"); + assert_eq!(one(&input, Channel::Html), want); + let mut s = Streamer::new(Channel::Html); + let mut acc = String::new(); + for chunk in input.as_bytes().chunks(4096) { s.push_into(std::str::from_utf8(chunk).unwrap(), &mut acc); } + s.finish_into(&mut acc); + assert_eq!(acc, want); + assert_eq!(one("[[broken [**ok**](https://e.test)", Channel::Html), "[[broken ok
"); +} + +/// 라벨 재귀가 프로세스를 죽이던 입력. 한도 뒤의 원문도 HTML 본문으로 escape해야 한다. +#[test] +fn deeply_nested_targets_do_not_exhaust_the_stack() { + let input = "[".repeat(10_000) + "x ](https://e.test/p.png)", "<script>cat</script>"}, + } { + want := `![` + c[1] + `](https://e.test/p.png)
cat (javascript:x)
" { + t.Fatal(got) + } +} + // 스킴 판정은 링크마다 불린다 — 목록을 순회만 하고 할당하지 않는다. func TestAllowedSchemeDoesNotAllocate(t *testing.T) { def := newVocab(HTML, Options{}) diff --git a/go/inline.go b/go/inline.go index 5fe248e..d61483c 100644 --- a/go/inline.go +++ b/go/inline.go @@ -45,7 +45,9 @@ type openMark struct { } type inline struct { - open []openMark + // 링크·이미지 라벨의 재귀 깊이. 러스트와 같은 한도로 호출 스택을 보호한다. + targetDepth uint8 + open []openMark // 지금 몇 번째 줄인가. 막힌 여는 마커는 같은 줄에서만 짝을 맺는다. line uint32 // 줄을 넘어온 직전 글자. 블록 안에서 줄이 바뀌면 '\n' 이다. @@ -119,6 +121,7 @@ func (in *inline) isOpen() bool { return len(in.open) > 0 } // 끝에 걸친 채로 들어오면 안 된다. 마커는 다음 글자를 봐야 열기/닫기가 갈리기 때문이고, 그 // 판단은 호출자(블록 층)가 한다. func (in *inline) render(line []rune, out *[]byte, v vocab) { + var links []linkBounds // 대괄호가 없는 산문에서는 할당하지 않는다. i := 0 for i < len(line) { // 코드 스팬 안에서는 강조 마커가 글자다. @@ -155,7 +158,10 @@ func (in *inline) render(line []rune, out *[]byte, v vocab) { // 이미지 ``. 대체 글은 링크 텍스트처럼 인라인으로 읽는다. if c == '!' && i+1 < len(line) && line[i+1] == '[' { - if t0, t1, u0, u1, ok := findLink(line, i+1); ok { + if links == nil { + links = indexLinks(line) + } + if t0, t1, u0, u1, ok := findLink(line, i+1, links); ok { in.renderImage(line[t0:t1], line[u0:u1], out, v) i = u1 + 1 continue @@ -163,7 +169,10 @@ func (in *inline) render(line []rune, out *[]byte, v vocab) { } if c == '[' { - if t0, t1, u0, u1, ok := findLink(line, i); ok { + if links == nil { + links = indexLinks(line) + } + if t0, t1, u0, u1, ok := findLink(line, i, links); ok { in.renderLink(line[t0:t1], line[u0:u1], out, v) i = u1 + 1 continue @@ -735,7 +744,11 @@ func (in *inline) angle(line []rune, i int, out *[]byte, v vocab) int { if !bare && !v.htmlOut() && !v.isPlain() { in.repairs.ConvertedMarker++ } - v.link(string(text), string(url), out) + if in.targetDepth > 0 && v.htmlOut() { + v.unlinked(string(text), string(url), out) + } else { + v.link(string(text), string(url), out) + } in.scratch = text in.prev = '>' return closeAt + 1 @@ -851,43 +864,73 @@ func (in *inline) renderImage(alt, url []rune, out *[]byte, v vocab) { func (in *inline) renderTarget(text, url []rune, out *[]byte, v vocab, image bool) { scratch := in.scratch[:0] + href := string(url) + labelVocab := v + if image && v.loadsImage(href) { + labelVocab = newVocab(Plain, Options{}) + } // 링크 텍스트는 자기만의 인라인 상태로 렌더한다. 바깥 강조와 섞이지 않는다. - nested := newInline() - nested.render(text, &scratch, v) - nested.finishBlock(&scratch, v) - in.repairs.add(nested.repairs) - if image { - v.image(string(scratch), string(url), out) + if in.targetDepth < maxTargetDepth { + nested := newInline() + nested.targetDepth = in.targetDepth + 1 + nested.render(text, &scratch, labelVocab) + nested.finishBlock(&scratch, labelVocab) + in.repairs.add(nested.repairs) + } else { + // 한도 아래와 같은 글자 escape를 쓰되, 다시 파싱하지 않는다. + for _, c := range text { + in.textChar(c, &scratch, labelVocab) + } + } + // 안의 링크와 이미지 링크 폴백은 주소를 글자로 보존한다. + if in.targetDepth > 0 && v.htmlOut() && !(image && v.loadsImage(href)) { + v.unlinked(string(scratch), href, out) + } else if image { + v.image(string(scratch), href, out) } else { - v.link(string(scratch), string(url), out) + v.link(string(scratch), href, out) } in.scratch = scratch } -// findLink 는 `[텍스트](url)` 을 찾는다. 한 줄 안에서만 본다(SPEC 8절). -// 돌려주는 것은 텍스트 구간, 주소 구간, 찾았는가. -func findLink(line []rune, at int) (t0, t1, u0, u1 int, ok bool) { - j := at + 1 - depth := 0 - for j < len(line) { - if line[j] == '[' { - depth++ - } else if line[j] == ']' { - if depth == 0 { - break +const maxTargetDepth uint8 = 32 + +type linkBounds struct{ bracket, paren int } + +// 대괄호 짝과 주소 끝을 역순 한 번으로 색인한다. 짝 없는 `[`마다 다시 훑지 않는다. +// `]`의 bracket으로 다음 `]`를 이어 별도 스택 없이 짝을 맞춘다. +func indexLinks(line []rune) []linkBounds { + missing := len(line) + links := make([]linkBounds, len(line)) + close, paren := missing, missing + for i := len(line) - 1; i >= 0; i-- { + links[i] = linkBounds{missing, missing} + switch line[i] { + case ')': + paren = i + case ']': + links[i] = linkBounds{close, paren} + close = i + case '[': + if close < missing { + end := close + close = links[end].bracket + if end+1 < len(line) && line[end+1] == '(' && links[end].paren < missing { + links[i] = linkBounds{end, links[end].paren} + } } - depth-- } - j++ } - if j+1 >= len(line) || line[j+1] != '(' { - return 0, 0, 0, 0, false - } - k := indexRune(line[j+2:], ')') - if k < 0 { + return links +} + +// findLink 는 한 줄 안의 `[텍스트](url)`을 색인에서 찾는다(SPEC 8절). +func findLink(line []rune, at int, links []linkBounds) (t0, t1, u0, u1 int, ok bool) { + b := links[at] + if b.bracket >= len(line) { return 0, 0, 0, 0, false } - return at + 1, j, j + 2, j + 2 + k, true + return at + 1, b.bracket, b.bracket + 2, b.paren, true } // isKnownTag 는 벗겨도 되는 HTML 태그다. 마크다운이 못 적는 표현을 LLM 이 HTML 로 메울 때 diff --git a/go/targets_test.go b/go/targets_test.go new file mode 100644 index 0000000..55b3df8 --- /dev/null +++ b/go/targets_test.go @@ -0,0 +1,90 @@ +package mdwire + +import ( + "strconv" + "strings" + "testing" +) + +// 링크 라벨의 안쪽 주소는 글자로 보존하고 를 겹치지 않는다. +func TestHTMLLinkLabelsNeverNestAnchors(t *testing.T) { + for _, c := range [][2]string{ + {"[[**cat**](https://inner.test)](https://outer.test)", "cat (https://inner.test)"}, + {"[
` {
+ t.Fatal(got)
+ }
+}
+
+// 큰 대괄호 줄은 내용을 보존하고 완성본·스트리밍이 같은 답을 낸다.
+func TestLongUnclosedBracketsPreserveText(t *testing.T) {
+ input := strings.Repeat("[", 100_000)
+ want := "" + input + "
" + if got := strings.Join(Render(input, HTML), ""); got != want { + t.Fatal("대괄호 내용이 달라졌다") + } + s := NewStreamer(HTML) + var acc []byte + for i := 0; i < len(input); i += 4096 { + s.PushTo(input[i:min(i+4096, len(input))], &acc) + } + s.FinishTo(&acc) + if string(acc) != want { + t.Fatal("스트리밍이 완성본과 달라졌다") + } + if got := strings.Join(Render("[[broken [**ok**](https://e.test)", HTML), ""); got != `[[broken ok
` { + t.Fatal(got) + } +} + +// 라벨 재귀는 제한하지만 한도 뒤의 내용과 HTML escape는 보존한다. +func TestDeeplyNestedTargetsDoNotExhaustTheStack(t *testing.T) { + input := strings.Repeat("[", 10_000) + "x