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 그대로, 정렬 표시와 셀 안의 마크업까지 살려서 낸다. 허용 스킴일 때만). 다른 채널의 `![alt](url)` 은 텔레그램·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 @@ +

[[[[[[[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/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 @@ +사진 ![**cat** & `x` "q"](https://e.test/p.png) diff --git a/corpus/cases/image-alt-format/html.txt b/corpus/cases/image-alt-format/html.txt new file mode 100644 index 0000000..10505fb --- /dev/null +++ b/corpus/cases/image-alt-format/html.txt @@ -0,0 +1 @@ +

사진 cat & x<y> "q"

diff --git a/corpus/cases/image-alt-format/input.md b/corpus/cases/image-alt-format/input.md new file mode 100644 index 0000000..1de360e --- /dev/null +++ b/corpus/cases/image-alt-format/input.md @@ -0,0 +1 @@ +사진 ![**cat** & `x` "q"](https://e.test/p.png) diff --git a/corpus/cases/image-alt-format/notion-markdown.txt b/corpus/cases/image-alt-format/notion-markdown.txt new file mode 100644 index 0000000..1de360e --- /dev/null +++ b/corpus/cases/image-alt-format/notion-markdown.txt @@ -0,0 +1 @@ +사진 ![**cat** & `x` "q"](https://e.test/p.png) diff --git a/corpus/cases/image-alt-format/plain.txt b/corpus/cases/image-alt-format/plain.txt new file mode 100644 index 0000000..48074e4 --- /dev/null +++ b/corpus/cases/image-alt-format/plain.txt @@ -0,0 +1 @@ +사진 cat & x "q" (https://e.test/p.png) diff --git a/corpus/cases/image-alt-format/slack-markdown.txt b/corpus/cases/image-alt-format/slack-markdown.txt new file mode 100644 index 0000000..1de360e --- /dev/null +++ b/corpus/cases/image-alt-format/slack-markdown.txt @@ -0,0 +1 @@ +사진 ![**cat** & `x` "q"](https://e.test/p.png) diff --git a/corpus/cases/image-alt-format/telegram-html.txt b/corpus/cases/image-alt-format/telegram-html.txt new file mode 100644 index 0000000..85704ac --- /dev/null +++ b/corpus/cases/image-alt-format/telegram-html.txt @@ -0,0 +1 @@ +사진 cat & x<y> "q" diff --git a/corpus/cases/image-alt-format/why.md b/corpus/cases/image-alt-format/why.md new file mode 100644 index 0000000..2048245 --- /dev/null +++ b/corpus/cases/image-alt-format/why.md @@ -0,0 +1,7 @@ +리뷰에서 `html.images = load`로 `![**cat**](https://e.test/p.png)`를 렌더하면 +`alt="cat"`가 나왔다. 대체 글에 태그가 비치고, npm 이벤트 토크나이저는 +속성 안의 첫 `>`에서 태그를 끊어 React에서 이미지 대신 `](https://outer.test) + +[![**cat**](https://inner.test/p.png)](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)

+ +

https://inner.test

+ +

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) + +[](https://outer.test) + +[![**cat**](https://inner.test/p.png)](https://outer.test) diff --git a/corpus/cases/nested-link-label/notion-markdown.txt b/corpus/cases/nested-link-label/notion-markdown.txt new file mode 100644 index 0000000..0a682b6 --- /dev/null +++ b/corpus/cases/nested-link-label/notion-markdown.txt @@ -0,0 +1,5 @@ +[[**cat**](https://inner.test)](https://outer.test) + +[[https://inner.test](https://inner.test)](https://outer.test) + +[![**cat**](https://inner.test/p.png)](https://outer.test) diff --git a/corpus/cases/nested-link-label/plain.txt b/corpus/cases/nested-link-label/plain.txt new file mode 100644 index 0000000..01c0e3e --- /dev/null +++ b/corpus/cases/nested-link-label/plain.txt @@ -0,0 +1,5 @@ +cat (https://inner.test) (https://outer.test) + +https://inner.test (https://outer.test) + +cat (https://inner.test/p.png) (https://outer.test) diff --git a/corpus/cases/nested-link-label/slack-markdown.txt b/corpus/cases/nested-link-label/slack-markdown.txt new file mode 100644 index 0000000..44be968 --- /dev/null +++ b/corpus/cases/nested-link-label/slack-markdown.txt @@ -0,0 +1,5 @@ +[[**cat**](https://inner.test)](https://outer.test) + +[](https://outer.test) + +[![**cat**](https://inner.test/p.png)](https://outer.test) diff --git a/corpus/cases/nested-link-label/telegram-html.txt b/corpus/cases/nested-link-label/telegram-html.txt new file mode 100644 index 0000000..51ca35c --- /dev/null +++ b/corpus/cases/nested-link-label/telegram-html.txt @@ -0,0 +1,5 @@ +cat (https://inner.test) + +https://inner.test + +cat (https://inner.test/p.png) diff --git a/corpus/cases/nested-link-label/why.md b/corpus/cases/nested-link-label/why.md new file mode 100644 index 0000000..dc7b730 --- /dev/null +++ b/corpus/cases/nested-link-label/why.md @@ -0,0 +1,7 @@ +리뷰에서 링크 라벨의 인라인 파싱이 안쪽 링크도 ``로 냈다. HTML에서는 ``를 +중첩할 수 없어 브라우저가 바깥 링크를 중간에서 닫으며, React도 잘못된 중첩을 받는다. +기본 옵션의 이미지도 링크로 내려가므로 링크 안의 이미지가 같은 경로를 탄다. + +HTML 출력에서는 안쪽 라벨의 서식과 주소를 보존하되 링크 태그는 만들지 않는다. +오토링크는 주소를 중복하지 않는다. 명시적으로 불러오는 이미지는 ``로 유지한다 +(Rust·Go 옵션 시험). 마크다운 출력은 기존 표기를 유지한다. diff --git a/corpus/cases/unclosed-link-brackets/github-markdown.txt b/corpus/cases/unclosed-link-brackets/github-markdown.txt new file mode 100644 index 0000000..93cff08 --- /dev/null +++ b/corpus/cases/unclosed-link-brackets/github-markdown.txt @@ -0,0 +1 @@ +[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[broken [**ok**](https://e.test) diff --git a/corpus/cases/unclosed-link-brackets/html.txt b/corpus/cases/unclosed-link-brackets/html.txt new file mode 100644 index 0000000..3268210 --- /dev/null +++ b/corpus/cases/unclosed-link-brackets/html.txt @@ -0,0 +1 @@ +

[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[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, /// 지금 몇 번째 줄인가. 막힌 여는 마커는 같은 줄에서만 짝을 맺는다. line: u32, @@ -88,6 +90,7 @@ pub(crate) struct Inline { impl Inline { pub fn new() -> Self { Self { + target_depth: 0, open: Vec::new(), line: 0, prev: None, @@ -163,6 +166,8 @@ impl Inline { /// 열기/닫기가 갈리기 때문이고, 그 판단은 호출자(`block::Engine`)가 한다. pub fn render(&mut self, line: &[char], out: &mut String, v: &Vocab) { let mut i = 0; + // 대괄호가 없는 산문 경로에는 할당을 더하지 않는다. 필요한 줄만 한 번 색인한다. + let mut links = None; while i < line.len() { // 코드 스팬 안에서는 강조 마커가 글자다. if let Some(top) = self.open.last() { @@ -194,7 +199,7 @@ impl Inline { // 이미지 `![alt](url)`. 대체 글은 링크 텍스트처럼 인라인으로 읽는다. if c == '!' && line.get(i + 1) == Some(&'[') { - if let Some((text, url_from, url_to)) = find_link(line, i + 1) { + if let Some((text, url_from, url_to)) = find_link(line, i + 1, links.get_or_insert_with(|| index_links(line))) { self.render_image(&line[text.0..text.1], &line[url_from..url_to], out, v); i = url_to + 1; continue; @@ -202,7 +207,7 @@ impl Inline { } if c == '[' { - if let Some((text, url_from, url_to)) = find_link(line, i) { + if let Some((text, url_from, url_to)) = find_link(line, i, links.get_or_insert_with(|| index_links(line))) { self.render_link(&line[text.0..text.1], &line[url_from..url_to], out, v); i = url_to + 1; continue; @@ -720,7 +725,11 @@ impl Inline { if !bare && !v.html_out() && !v.is_plain() { self.repairs.converted_marker += 1; } - v.link(&text, &href, out); + if self.target_depth > 0 && v.html_out() { + v.unlinked(&text, &href, out); + } else { + v.link(&text, &href, out); + } self.scratch = text; self.prev = Some('>'); return Some(close + 1); @@ -846,15 +855,28 @@ impl Inline { fn render_target(&mut self, text: &[char], url: &[char], out: &mut String, v: &Vocab, image: bool) { let mut scratch = std::mem::take(&mut self.scratch); scratch.clear(); - // 링크 텍스트는 자기만의 인라인 상태로 렌더한다. 바깥 강조와 섞이지 않는다. - let mut nested = Inline::new(); - nested.render(text, &mut scratch, v); - nested.finish_block(&mut scratch, v); - self.repairs.add(nested.repairs); - let mut href = String::with_capacity(url.len()); href.extend(url.iter()); - if image { + // 의 alt는 HTML 본문이 아니라 글자다. 링크로 내리는 이미지의 라벨은 서식을 유지한다. + let plain = Vocab::new(crate::Channel::Plain); + let label_vocab = if image && v.loads_image(&href) { &plain } else { v }; + // 링크 텍스트는 자기만의 인라인 상태로 렌더한다. 바깥 강조와 섞이지 않는다. + if self.target_depth < MAX_TARGET_DEPTH { + let mut nested = Inline::new(); + nested.target_depth = self.target_depth + 1; + nested.render(text, &mut scratch, label_vocab); + nested.finish_block(&mut scratch, label_vocab); + self.repairs.add(nested.repairs); + } else { + // 한도를 넘은 라벨은 원문 글자로 낸다 — 내용은 보존하고 재귀만 멈춘다. + for &c in text { + self.text_char(c, &mut scratch, label_vocab); + } + } + // 안에 를 둘 수 없다. 안쪽 링크와 이미지의 링크 폴백은 주소를 글자로 보존한다. + if self.target_depth > 0 && v.html_out() && !(image && v.loads_image(&href)) { + v.unlinked(&scratch, &href, out); + } else if image { v.image(&scratch, &href, out); } else { v.link(&scratch, &href, out); @@ -957,24 +979,39 @@ fn insert_marker(out: &mut String, at: usize, c: char, run: usize, v: &Vocab, re } } -/// `[텍스트](url)` 을 찾는다. **한 줄 안에서만** 본다(`SPEC.md` 8절). -fn find_link(line: &[char], at: usize) -> Option<((usize, usize), usize, usize)> { - let mut j = at + 1; - let mut depth = 0usize; - while j < line.len() { - match line[j] { - '[' => depth += 1, - ']' if depth == 0 => break, - ']' => depth -= 1, +const MAX_TARGET_DEPTH: u8 = 32; + +/// 대괄호 짝과 그 뒤 주소의 끝을 역순 한 번으로 찾는다. 짝 없는 `[`마다 줄 끝까지 다시 +/// 훑으면 `[[[[…`에서 O(n²)이 된다. `]` 자리의 첫 값으로 다음 `]`를 이어 별도 스택도 피한다. +fn index_links(line: &[char]) -> Vec<(usize, usize)> { + let missing = line.len(); + let mut links = vec![(missing, missing); line.len()]; + let mut close = missing; + let mut paren = missing; + for i in (0..line.len()).rev() { + match line[i] { + ')' => paren = i, + ']' => { + links[i] = (close, paren); + close = i; + } + '[' if close < missing => { + let end = close; + close = links[end].0; + if line.get(end + 1) == Some(&'(') && links[end].1 < missing { + links[i] = (end, links[end].1); + } + } _ => {} } - j += 1; } - if j >= line.len() || line.get(j + 1) != Some(&'(') { - return None; - } - let k = line[j + 2..].iter().position(|&c| c == ')')? + j + 2; - Some(((at + 1, j), j + 2, k)) + links +} + +/// `[텍스트](url)`을 색인에서 찾는다. 한 줄 안에서만 본다(`SPEC.md` 8절). +fn find_link(line: &[char], at: usize, links: &[(usize, usize)]) -> Option<((usize, usize), usize, usize)> { + let (j, k) = links[at]; + (j < line.len()).then_some(((at + 1, j), j + 2, k)) } fn starts_with(chars: &[char], s: &str) -> bool { diff --git a/crates/mdwire-core/src/vocab.rs b/crates/mdwire-core/src/vocab.rs index a5e2c02..02dcfd6 100644 --- a/crates/mdwire-core/src/vocab.rs +++ b/crates/mdwire-core/src/vocab.rs @@ -265,6 +265,16 @@ impl Vocab { } } + /// 링크를 만들 수 없는 자리에서도 라벨과 주소는 보존한다. 텍스트는 이미 렌더된 본문이다. + pub fn unlinked(&self, text: &str, url: &str, out: &mut String) { + out.push_str(text); + if !url.is_empty() && !escaped_eq(text, url) { + out.push_str(" ("); + self.escape(url, out); + out.push(')'); + } + } + /// 링크를 적는다. 텍스트는 이미 렌더된 것을 받는다. pub fn link(&self, text: &str, url: &str, out: &mut String) { match self.channel { @@ -274,12 +284,7 @@ impl Vocab { // 주소는 괄호에 넣어 글로 내보낸다 — 링크는 죽어도 내용은 산다. let markup = escaped_len(url) + "".len(); if markup >= self.limit { - out.push_str(text); - if !url.is_empty() && !escaped_eq(text, url) { - out.push_str(" ("); - self.escape(url, out); - out.push(')'); - } + self.unlinked(text, url, out); return; } out.push_str("` 로 내면 누르는 순간 스크립트가 돈다. 안전한 스킴이 아니면 // 링크 없이 글과 주소만 낸다 — 내용은 살린다. if !self.allowed(url) { - out.push_str(text); - if !url.is_empty() && !escaped_eq(text, url) { - out.push_str(" ("); - self.escape(url, out); - out.push(')'); - } + self.unlinked(text, url, out); return; } out.push_str("로 나가는가. 그때만 라벨을 일반 텍스트로 읽는다. + pub fn loads_image(&self, url: &str) -> bool { + self.is_html() && self.load_images && self.allowed(url) + } + + /// 이미지 `![alt](url)`을 적는다. 의 대체 글은 일반 텍스트, 나머지는 렌더된 본문이다. /// /// 브라우저는 옵션이 `Load` 이고 주소가 허용 스킴일 때만 `` 로 불러온다 — 기본은 /// 링크다(누르기 전에는 아무것도 안 불러온다). 텔레그램·plain 은 이미지 구문이 없어 링크로, /// 마크다운 채널은 `![alt](url)` 그대로 둔다(GitHub 은 그린다). pub fn image(&self, alt: &str, url: &str, out: &mut String) { match self.channel { - Channel::Html if self.load_images && self.allowed(url) => { + Channel::Html if self.loads_image(url) => { out.push_str("\"");"); } Channel::Html | Channel::TelegramHtml | Channel::Plain => self.link(alt, url, out), diff --git a/crates/mdwire-core/tests/render.rs b/crates/mdwire-core/tests/render.rs index 19aa0f5..18c21bf 100644 --- a/crates/mdwire-core/tests/render.rs +++ b/crates/mdwire-core/tests/render.rs @@ -1269,6 +1269,86 @@ fn html_policy_options() { assert_eq!(acc, render("사진 ![고양이](https://a.com/c.png) 끝!", Channel::Html).join("")); } +/// 대체 글에 서식이 있어도 속성은 글자다. 엔티티는 한 번만 escape하고, 차단된 이미지는 +/// 링크 폴백의 본문 서식을 유지한다. 완성본·스트리밍·미리보기가 모두 같은 속성을 낸다. +#[test] +fn loaded_image_alt_is_plain_text() { + use mdwire::{HtmlOptions, Images, Options}; + let opts = Options { html: HtmlOptions { images: Images::Load, ..Default::default() }, ..Default::default() }; + for (input, alt) in [ + ("![**cat**](https://e.test/p.png)", "cat"), + ("![a & bold \"q\" `x`](https://e.test/p.png)", "a & bold "q" x<y>"), + ("![<](https://e.test/p.png)", "&lt;"), + ("![](https://e.test/p.png)", "<script>cat</script>"), + ] { + let want = format!("

\"{alt}\"

"); + 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("![**cat**](javascript:x)", 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)"), + ("[](https://outer.test)", "https://inner.test"), + ("[![**cat**](https://inner.test/p.png)](https://outer.test)", "cat (https://inner.test/p.png)"), + ] { + let want = format!("

{label}

"); + assert_eq!(one(input, Channel::Html), want); + let mut s = Streamer::new(Channel::Html); + let mut acc = String::new(); + for c in input.chars() { s.push_into(&c.to_string(), &mut acc); } + s.finish_into(&mut acc); + assert_eq!(acc, want); + assert_eq!(tg(input).matches("\"cat\"

" + ); +} + +/// 닫히지 않은 대괄호마다 같은 꼬리를 다시 훑던 입력. 큰 줄을 완성본과 스트리밍 끝에서 본다. +#[test] +fn long_unclosed_brackets_preserve_text() { + let input = "[".repeat(100_000); + let want = format!("

{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] + `

` + if got := strings.Join(RenderWith(c[0], HTML, o).Parts, ""); got != want { + t.Fatalf("%q: got %q, want %q", c[0], got, want) + } + s := NewStreamerWith(HTML, o) + var acc []byte + for _, r := range c[0] { + s.PushTo(string(r), &acc) + } + preview := string(acc) + s.Preview() + s.FinishTo(&acc) + if string(acc) != want || preview != want { + t.Fatalf("%q: stream %q, preview %q, want %q", c[0], acc, preview, want) + } + } + if got := strings.Join(RenderWith("![**cat**](javascript:x)", HTML, o).Parts, ""); got != "

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) { // 이미지 `![alt](url)`. 대체 글은 링크 텍스트처럼 인라인으로 읽는다. 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)"}, + {"[](https://outer.test)", "https://inner.test"}, + {"[![**cat**](https://inner.test/p.png)](https://outer.test)", "cat (https://inner.test/p.png)"}, + } { + want := `

` + c[1] + `

` + if got := strings.Join(Render(c[0], HTML), ""); got != want { + t.Fatalf("%q: got %q, want %q", c[0], got, want) + } + s := NewStreamer(HTML) + var acc []byte + for _, r := range c[0] { + s.PushTo(string(r), &acc) + } + s.FinishTo(&acc) + if string(acc) != want { + t.Fatal(string(acc)) + } + if got := strings.Join(Render(c[0], TelegramHTML), ""); strings.Count(got, "cat

` { + 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