diff --git a/SPEC.md b/SPEC.md
index a07587e..3d48159 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -364,6 +364,18 @@ GFM 그대로, 정렬 표시와 셀 안의 마크업까지 살려서 낸다.
허용 스킴일 때만). 다른 채널의 `` 은 텔레그램·plain 은 링크, 마크다운 채널은 그대로
- 스킴 — 허용 목록. 주면 **그것만** 받는다(기본값에 더하지 않는다). React 컴포넌트도 같은 목록으로
한 번 더 본다
+- ` docs (javascript:x) https://in.test (javascript:x) cat (https://inner.test/p.png) [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[broken ok cat (javascript:x)`의 `alt`는 인라인 서식을 제거한 일반 텍스트를 속성값으로 이스케이프한다. 이미지가
+ 링크로 내려갈 때는 라벨의 본문 서식을 유지한다. 렌더된 HTML을 `alt`에 넣으면 태그가
+ 대체 글에 비치고, 이벤트 토크나이저가 속성 안의 `>`를 태그 끝으로 읽는다(리뷰에서 재현).
+- 링크·이미지 라벨의 인라인 파싱은 32단계까지 재귀한다. 그보다 깊은 라벨은 원문을 해당
+ 출력 문맥의 글자로 이스케이프해 낸다 — 내용은 남기고 호출 스택 고갈을 막는다. 대괄호
+ 짝과 주소 끝은 `[`가 든 렌더 구간(스트리밍 조각, 링크 라벨)마다 한 번 역순으로 색인하고,
+ 본문 색인 버퍼는 조각 사이에 재사용한다. 닫히지 않은 `[`마다 줄 끝까지 다시 훑으면 입력
+ 크기의 제곱만큼 걸리므로, 실패한 후보도 색인에서 바로 판정한다.
+- HTML 출력에서 실제로 ``가 되는 링크 라벨 안의 링크·오토링크·이미지 링크 폴백은 라벨과
+ 주소를 글자로 보존한다. 바깥 링크가 차단된 스킴이거나 한도를 넘어 글자로 나가면 안쪽
+ 링크는 그대로 링크다. `` 안에 ``를 만들면 브라우저가 바깥 링크를 중간에서 닫으며,
+ 텔레그램도 중첩 링크를 받지 않는다. 명시적으로 불러오는 `
`는 링크 안에도 둔다.
- 원문 태그는 강조와 같은 스택에 올라 짝·중첩이 맞는다 — 짝 없는 닫는 태그는 버리고, 안 닫힌
여는 태그는 블록 끝에서 닫는다(GitHub 도 같다). 퍼즈가 `` 만 열고 끝난 입력에서 잡았다
- 한도가 없어 나누지 않는다. **스트리밍은 텔레그램 편집과 같은 계약이다** — 누적본에
diff --git a/corpus/cases/blocked-outer-link-label/github-markdown.txt b/corpus/cases/blocked-outer-link-label/github-markdown.txt
new file mode 100644
index 0000000..0e0dbf1
--- /dev/null
+++ b/corpus/cases/blocked-outer-link-label/github-markdown.txt
@@ -0,0 +1,3 @@
+[[docs](https://ok.test)](javascript:x)
+
+[
y](https://e.test) | z |
diff --git a/corpus/cases/cell-link-label-break/html.txt b/corpus/cases/cell-link-label-break/html.txt
new file mode 100644
index 0000000..0972432
--- /dev/null
+++ b/corpus/cases/cell-link-label-break/html.txt
@@ -0,0 +1,4 @@
+
+
diff --git a/corpus/cases/cell-link-label-break/input.md b/corpus/cases/cell-link-label-break/input.md
new file mode 100644
index 0000000..159a144
--- /dev/null
+++ b/corpus/cases/cell-link-label-break/input.md
@@ -0,0 +1,3 @@
+| a | b |
+|---|---|
+| [x
+a b
+x
yz
y](https://e.test) | z |
diff --git a/corpus/cases/cell-link-label-break/notion-markdown.txt b/corpus/cases/cell-link-label-break/notion-markdown.txt
new file mode 100644
index 0000000..2e1ca30
--- /dev/null
+++ b/corpus/cases/cell-link-label-break/notion-markdown.txt
@@ -0,0 +1,10 @@
+
+
diff --git a/corpus/cases/cell-link-label-break/plain.txt b/corpus/cases/cell-link-label-break/plain.txt
new file mode 100644
index 0000000..88bda45
--- /dev/null
+++ b/corpus/cases/cell-link-label-break/plain.txt
@@ -0,0 +1,3 @@
+a | b
+-------------------- | -
+x y (https://e.test) | z
diff --git a/corpus/cases/cell-link-label-break/slack-markdown.txt b/corpus/cases/cell-link-label-break/slack-markdown.txt
new file mode 100644
index 0000000..8305bff
--- /dev/null
+++ b/corpus/cases/cell-link-label-break/slack-markdown.txt
@@ -0,0 +1,3 @@
+| a | b |
+| --- | --- |
+| [x y](https://e.test) | z |
diff --git a/corpus/cases/cell-link-label-break/telegram-html.txt b/corpus/cases/cell-link-label-break/telegram-html.txt
new file mode 100644
index 0000000..757697c
--- /dev/null
+++ b/corpus/cases/cell-link-label-break/telegram-html.txt
@@ -0,0 +1,3 @@
+
+
+a
+b
+
+
+[x
+
y](https://e.test)z
+a | b
+-------------------- | -
+x y (https://e.test) | z
diff --git a/corpus/cases/cell-link-label-break/why.md b/corpus/cases/cell-link-label-break/why.md
new file mode 100644
index 0000000..d7f0557
--- /dev/null
+++ b/corpus/cases/cell-link-label-break/why.md
@@ -0,0 +1,2 @@
+리뷰에서 재현했다. 링크 라벨을 렌더하는 안쪽 인라인 상태가 표 칸 안이라는 것을 이어받지
+않아, 라벨의 `
`이 실제 줄바꿈이 됐다. 표 행이 둘로 갈라지고 링크도 깨졌다.
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 @@
+
+
+
+
+
`로 유지한다
+(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 @@
+
의 alt는 HTML 본문이 아니라 글자다. 링크로 내리는 이미지의 라벨은 서식을 유지한다.
+ let plain;
+ let label_vocab = if loads {
+ plain = Vocab::new(crate::Channel::Plain);
+ &plain
+ } else {
+ v
+ };
+ // 링크 텍스트는 자기만의 인라인 상태로 렌더한다. 바깥 강조와 섞이지 않는다.
+ if self.target_depth < MAX_TARGET_DEPTH {
+ let mut nested = Inline::new();
+ nested.target_depth = self.target_depth + 1;
+ // 라벨이 들어갈 자리가 실제 ``일 때만 안쪽 링크를 글자로 내린다. 바깥이 차단되거나
+ // 한도를 넘어 글자로 나가면 안쪽 링크는 살아도 된다.
+ nested.in_anchor = self.in_anchor || (!loads && v.makes_anchor(&href));
+ // 표 칸 안의 라벨도 줄을 바꿀 수 없다.
+ nested.in_cell = self.in_cell;
+ 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.in_anchor && !loads {
+ v.unlinked(&scratch, &href, out);
+ } else if image {
v.image(&scratch, &href, out);
} else {
v.link(&scratch, &href, out);
@@ -957,24 +1008,40 @@ 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;
+
+/// 대괄호 짝과 그 뒤 주소의 끝을 역순 한 번으로 찾아 `links`에 채운다. 짝 없는 `[`마다 줄
+/// 끝까지 다시 훑으면 `[[[[…`에서 O(n²)이 된다. `]` 자리의 첫 값으로 다음 `]`를 이어 별도
+/// 스택도 피한다.
+fn index_links(line: &[char], links: &mut Vec<(usize, usize)>) {
+ let missing = line.len();
+ links.clear();
+ links.resize(line.len(), (missing, missing));
+ 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))
+}
+
+/// `[텍스트](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..c53917c 100644
--- a/crates/mdwire-core/src/vocab.rs
+++ b/crates/mdwire-core/src/vocab.rs
@@ -265,6 +265,25 @@ 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(')');
+ }
+ }
+
+ /// `link`가 이 주소로 ``를 내는가. 차단된 스킴·한도를 넘는 주소는 글자로 내려간다.
+ pub(crate) fn makes_anchor(&self, url: &str) -> bool {
+ match self.channel {
+ Channel::TelegramHtml => escaped_len(url) + "".len() < self.limit,
+ Channel::Html => self.allowed(url),
+ _ => false,
+ }
+ }
+
/// 링크를 적는다. 텍스트는 이미 렌더된 것을 받는다.
pub fn link(&self, text: &str, url: &str, out: &mut String) {
match self.channel {
@@ -272,14 +291,8 @@ 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(')');
- }
+ if !self.makes_anchor(url) {
+ 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(')');
- }
+ if !self.makes_anchor(url) {
+ 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)
+ }
+
+ /// 이미지 ``을 적는다.
의 대체 글은 일반 텍스트, 나머지는 렌더된 본문이다.
///
/// 브라우저는 옵션이 `Load` 이고 주소가 허용 스킴일 때만 `
` 로 불러온다 — 기본은
/// 링크다(누르기 전에는 아무것도 안 불러온다). 텔레그램·plain 은 이미지 구문이 없어 링크로,
/// 마크다운 채널은 `` 그대로 둔다(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..3ef36ee 100644
--- a/crates/mdwire-core/tests/render.rs
+++ b/crates/mdwire-core/tests/render.rs
@@ -1269,6 +1269,105 @@ fn html_policy_options() {
assert_eq!(acc, render("사진  끝!", 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"),
+ ("

docs (javascript:x)
"); + assert_eq!(one("[https://in.test (javascript:x)
"); + // 텔레그램에서 바깥 주소가 한도를 넘어 글자로 내려갈 때도 같다. + let long = format!("https://e.test/{}", "x".repeat(5000)); + let got = render(&format!("[[docs](https://ok.test)]({long})"), Channel::TelegramHtml).join(""); + assert!(got.starts_with("docs ("), "{}", &got[..80]); +} + +/// 표 칸 안 링크 라벨의 `{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..cfd9fae 100644 --- a/go/inline.go +++ b/go/inline.go @@ -2,6 +2,7 @@ package mdwire import ( "bytes" + "slices" "strings" "unicode" "unicode/utf8" @@ -45,7 +46,13 @@ type openMark struct { } type inline struct { - open []openMark + // 링크·이미지 라벨의 재귀 깊이. 러스트와 같은 한도로 호출 스택을 보호한다. + targetDepth uint8 + // 실제로 내보낼 안의 라벨을 렌더하는가. 안에 를 만들 수 없다. + inAnchor bool + // `[`가 든 줄의 대괄호·주소 색인(indexLinks). 비우기만 하고 재사용한다. + links []linkBounds + open []openMark // 지금 몇 번째 줄인가. 막힌 여는 마커는 같은 줄에서만 짝을 맺는다. line uint32 // 줄을 넘어온 직전 글자. 블록 안에서 줄이 바뀌면 '\n' 이다. @@ -119,6 +126,11 @@ func (in *inline) isOpen() bool { return len(in.open) > 0 } // 끝에 걸친 채로 들어오면 안 된다. 마커는 다음 글자를 봐야 열기/닫기가 갈리기 때문이고, 그 // 판단은 호출자(블록 층)가 한다. func (in *inline) render(line []rune, out *[]byte, v vocab) { + // 대괄호가 없는 산문은 색인하지 않는다. 버퍼는 빌려 와 재사용한다 — 되돌린 코드 스팬을 다시 + // 읽는 render 가 같은 배열을 덮어쓰지 않게 비워 둔다. + links := in.links[:0] + in.links = nil + indexed := false i := 0 for i < len(line) { // 코드 스팬 안에서는 강조 마커가 글자다. @@ -155,7 +167,11 @@ 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 !indexed { + links = indexLinks(line, links) + indexed = true + } + 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 +179,11 @@ func (in *inline) render(line []rune, out *[]byte, v vocab) { } if c == '[' { - if t0, t1, u0, u1, ok := findLink(line, i); ok { + if !indexed { + links = indexLinks(line, links) + indexed = true + } + 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 @@ -317,6 +337,7 @@ func (in *inline) render(line []rune, out *[]byte, v vocab) { } i += take } + in.links = links[:0] if len(line) > 0 { // 다음 호출의 첫 글자에게 앞 글자를 남긴다. 한 줄을 나눠 넣어도 flanking 판정이 이어진다. in.prev = line[len(line)-1] @@ -735,7 +756,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.inAnchor { + 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 +876,78 @@ 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) + loads := image && v.loadsImage(href) + labelVocab := v + if loads { + 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.inAnchor = in.inAnchor || (!loads && v.makesAnchor(href)) + // 표 칸 안의 라벨도 줄을 바꿀 수 없다. + nested.inCell = in.inCell + 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.inAnchor && !loads { + 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 } + +// 대괄호 짝과 주소 끝을 역순 한 번으로 links 에 색인한다. 짝 없는 `[`마다 다시 훑지 않는다. +// `]`의 bracket으로 다음 `]`를 이어 별도 스택 없이 짝을 맞춘다. +func indexLinks(line []rune, links []linkBounds) []linkBounds { + missing := len(line) + links = slices.Grow(links[:0], len(line))[:len(line)] + closeAt, 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{closeAt, paren} + closeAt = i + case '[': + if closeAt < missing { + end := closeAt + closeAt = 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..0c69575 --- /dev/null +++ b/go/targets_test.go @@ -0,0 +1,118 @@ +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)
+ }
+}
+
+// 바깥 링크가 가 되지 못하면 안쪽 링크는 산다 — 러스트 inner_links_survive_when_the_outer_link_is_not_an_anchor.
+func TestInnerLinksSurviveWhenTheOuterLinkIsNotAnAnchor(t *testing.T) {
+ for _, c := range [][2]string{
+ {"[[docs](https://ok.test)](javascript:x)", `docs (javascript:x)
`}, + {"[https://in.test (javascript:x)
`}, + } { + if got := strings.Join(Render(c[0], HTML), ""); got != c[1] { + t.Fatalf("%q: got %q, want %q", c[0], got, c[1]) + } + } + long := "https://e.test/" + strings.Repeat("x", 5000) + got := strings.Join(Render("[[docs](https://ok.test)]("+long+")", TelegramHTML), "") + if !strings.HasPrefix(got, `docs (`) { + t.Fatal(got[:80]) + } +} + +// 표 칸 안 링크 라벨의" + 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