src/markdown.rs (20964B)
1 //! comrak 기반 마크다운 → HTML 변환, heading anchor, 목차 수집.
2 //!
3 //! 링크 재작성·anchor·TOC는 전부 comrak의 어댑터/AST 경로로 처리한다.
4 //! 최종 HTML에 정규식을 돌리는 방식은 쓰지 않는다 — 속성을 홑따옴표로 쓰거나
5 //! 따옴표를 빼면 조용히 처리에서 빠지는, 버그를 양산하는 구조다.
6
7 use crate::config::{HeadingAnchors, Markdown as MarkdownConfig};
8 use comrak::adapters::{HeadingAdapter, HeadingMeta};
9 use comrak::nodes::{AstNode, NodeValue, Sourcepos};
10 use comrak::options::Plugins;
11 use comrak::{Anchorizer, Arena, Options, format_html_with_plugins, parse_document};
12 use serde::Serialize;
13 use std::fmt;
14 use std::sync::{Arc, Mutex};
15
16 /// 목차 항목. 템플릿에는 중첩된 형태로 나간다.
17 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18 pub struct TocEntry {
19 pub level: u8,
20 pub id: String,
21 pub title: String,
22 pub children: Vec<TocEntry>,
23 }
24
25 pub struct Rendered {
26 pub html: String,
27 pub toc: Vec<TocEntry>,
28 /// 검색 색인이 먹을 본문 평문. HTML을 벗겨 만드는 게 아니라 AST에서 모은다 —
29 /// 완성된 HTML에 정규식을 돌리지 않는다는 원칙은 여기에도 적용된다.
30 pub text: String,
31 /// 해석하지 못한 `@/` 링크. 비어 있지 않으면 빌드를 멈춰야 한다 —
32 /// 깨진 링크를 조용히 배포하는 건 이 도구가 막겠다고 한 바로 그 부류다.
33 pub unresolved: Vec<String>,
34 /// 잘못된 코드 펜스 옵션. 링크와 같은 계약이다 — 모르는 키·틀린 범위를
35 /// 조용히 무시하면 오타가 강조 없는 채로 배포된다.
36 pub bad_fences: Vec<String>,
37 }
38
39 pub struct Renderer {
40 cfg: MarkdownConfig,
41 highlighter: Option<crate::highlight::Highlighter>,
42 /// 블록 하이라이트 캐시. 하이라이터 인스턴스는 페이지마다 새로 만들지만
43 /// (상태 격리), 캐시는 빌드 전체가 공유한다 — 반복되는 코드 블록을 두 번
44 /// 하이라이트하지 않는 게 목적이라서다.
45 highlight_cache: crate::highlight::BlockCache,
46 // 링크 표와 URL 집합은 `Arc`로 들고 페이지마다 포인터만 나눠 준다. 값으로 들면
47 // `render_in`이 페이지마다 O(n) 복사를 하게 되어 빌드 전체가 O(n²)가 된다.
48 links: Option<Arc<crate::links::LinkIndex>>,
49 default_language: String,
50 known: Arc<crate::links::KnownUrls>,
51 }
52
53 impl Renderer {
54 pub fn new(cfg: &MarkdownConfig) -> Self {
55 Self {
56 cfg: cfg.clone(),
57 highlighter: None,
58 highlight_cache: Arc::default(),
59 links: None,
60 default_language: "en".into(),
61 known: Arc::default(),
62 }
63 }
64
65 /// 루트 절대 경로 링크(`/start/install/`)와 이미지도 존재를 검사하게 한다.
66 /// 비워 두면 검사하지 않는다.
67 pub fn with_known_urls(mut self, known: crate::links::KnownUrls) -> Self {
68 self.known = Arc::new(known);
69 self
70 }
71
72 /// 구문 강조를 켠다. 없으면 코드 블록은 `<code class="language-x">`로만 나간다.
73 pub fn with_highlighter(mut self, h: crate::highlight::Highlighter) -> Self {
74 self.highlighter = Some(h);
75 self
76 }
77
78 /// `@/path.md` 내부 링크 해석을 켠다.
79 pub fn with_links(mut self, index: crate::links::LinkIndex, default_language: &str) -> Self {
80 self.links = Some(Arc::new(index));
81 self.default_language = default_language.to_string();
82 self
83 }
84
85 fn options<'p>(&self) -> Options<'p> {
86 let mut o = Options::default();
87 let c = &self.cfg;
88
89 o.extension.footnotes = c.footnotes;
90 o.extension.table = c.tables;
91 o.extension.tasklist = c.tasklist;
92 o.extension.strikethrough = c.strikethrough;
93 o.extension.autolink = c.autolink;
94 o.extension.alerts = c.alerts;
95 // ⚠️ 한국어에 필수. 끄면 `**강조**한다` 같은 구성이 강조로 파싱되지 않는다.
96 o.extension.cjk_friendly_emphasis = c.cjk_friendly_emphasis;
97
98 // 우리 콘텐츠는 git에 들어있는 신뢰된 소스이므로 raw HTML을 허용한다.
99 // 신뢰되지 않은 소스를 다루게 되면 소스별 신뢰 등급으로 나눌 것.
100 o.render.r#unsafe = true;
101 // false여야 `<code class="language-rust">`가 나온다. true면 `<pre lang="rust">`다.
102 o.render.github_pre_lang = false;
103 // 펜스 정보 문자열의 언어 뒤쪽(hl_lines 등)을 어댑터가 data-meta로 받게 한다.
104 // 하이라이터가 있을 때만 — 강조를 끈 사이트에서는 검증 없는 메타가 출력에
105 // 그대로 새므로, 펜스 옵션 자체를 강조 기능의 일부로 묶는다.
106 o.render.full_info_string = self.highlighter.is_some();
107 o
108 }
109
110 pub fn render(&self, body: &str) -> Rendered {
111 self.render_in("en", body)
112 }
113
114 /// `language`는 `@/` 링크를 어느 언어판으로 보낼지 정한다.
115 pub fn render_in(&self, language: &str, body: &str) -> Rendered {
116 // 어댑터는 `Options`가 아니라 `Plugins`에 붙는다. 페이지마다 새 collector를
117 // 쓰는 게 중요하다 — heading id는 사이트가 아니라 **페이지 안에서** 유일해야
118 // 하므로 anchorizer의 dedupe 카운터도 페이지마다 초기화되어야 한다.
119 let headings = HeadingCollector::new(self.cfg.heading_anchors);
120 let mut options = self.options();
121
122 // 리졸버는 페이지마다 새로(unresolved가 페이지별 상태), 표는 Arc 포인터 복사만.
123 let resolver = self.links.as_ref().map(|index| {
124 Arc::new(
125 crate::links::Resolver::new(Arc::clone(index), language, &self.default_language)
126 .with_known_urls(Arc::clone(&self.known)),
127 )
128 });
129 if let Some(r) = &resolver {
130 options.extension.link_url_rewriter = Some(r.clone());
131 options.extension.image_url_rewriter = Some(r.clone());
132 }
133
134 // 하이라이터도 호출마다 새로 만든다. pending(펜스 옵션 핸드오프)과
135 // errors가 페이지별 상태라, 인스턴스를 페이지끼리 공유하면 병렬 렌더에서
136 // 서로의 옵션과 에러가 섞인다. SyntaxSet은 정적 공유라 비용은 Mutex 두 개.
137 // 블록 캐시만은 빌드 공유다 — 페이지별 상태가 아니라 순수 결과라서.
138 let highlighter = self.highlighter.as_ref().map(|_| {
139 crate::highlight::Highlighter::new().with_cache(Arc::clone(&self.highlight_cache))
140 });
141
142 // 한 번만 파싱해서 HTML과 평문을 같은 트리에서 뽑는다. 링크 재작성기는
143 // 렌더 단계에서 도므로 평문 수집이 그 결과에 영향을 받지 않는다.
144 let arena = Arena::new();
145 let root = parse_document(&arena, body, &options);
146 let text = plain_text(root);
147
148 let html = {
149 let mut plugins = Plugins::default();
150 plugins.render.heading_adapter = Some(&headings);
151 if let Some(h) = &highlighter {
152 plugins.render.codefence_syntax_highlighter = Some(h);
153 }
154 let mut out = String::new();
155 format_html_with_plugins(root, &options, &mut out, &plugins)
156 .expect("String에 쓰는 건 실패하지 않는다");
157 out
158 };
159
160 let unresolved = resolver
161 .as_ref()
162 .map(|r| r.take_unresolved())
163 .unwrap_or_default();
164 let bad_fences = highlighter
165 .as_ref()
166 .map(|h| h.take_errors())
167 .unwrap_or_default();
168
169 Rendered {
170 unresolved,
171 bad_fences,
172 html,
173 text,
174 toc: headings.into_toc(),
175 }
176 }
177 }
178
179 /// AST에서 검색용 평문을 모은다.
180 ///
181 /// 코드 블록은 넣는다 — 문서 사이트에서 사람들은 명령어 이름으로 검색한다.
182 /// front matter와 raw HTML은 뺀다: 전자는 메타데이터고 후자는 태그라 본문이 아니다.
183 fn plain_text<'a>(root: &'a AstNode<'a>) -> String {
184 let mut out = String::new();
185 for node in root.descendants() {
186 match &node.data.borrow().value {
187 NodeValue::Text(t) => out.push_str(t),
188 // 인라인 코드는 앞뒤 Text가 이미 띄어쓰기를 갖고 있으므로 그대로 붙인다.
189 NodeValue::Code(c) => out.push_str(&c.literal),
190 NodeValue::CodeBlock(c) => {
191 out.push(' ');
192 out.push_str(&c.literal);
193 }
194 // 블록이 시작하는 자리마다 공백을 넣는다. 없으면 앞 블록의 끝과 다음
195 // 블록의 시작이 붙어 `빌드git` 같은, 문서에 없는 단어가 색인에 생긴다.
196 NodeValue::Paragraph
197 | NodeValue::Heading(_)
198 | NodeValue::Item(_)
199 | NodeValue::TableCell
200 | NodeValue::SoftBreak
201 | NodeValue::LineBreak => out.push(' '),
202 _ => {}
203 }
204 }
205 // 공백을 하나로 접는다. 인덱스가 커질 이유가 없다.
206 out.split_whitespace().collect::<Vec<_>>().join(" ")
207 }
208
209 /// heading에 id를 붙이고 anchor 링크를 그리면서, 동시에 목차를 모은다.
210 ///
211 /// **Anchorizer 인스턴스를 하나만 쓰는 게 핵심이다.** 같은 제목이 두 번 나오면
212 /// `설치`, `설치-1`처럼 접미사가 붙는데, TOC와 heading이 서로 다른 인스턴스를 쓰면
213 /// 두 쪽의 id가 어긋나 목차 링크가 죽는다.
214 struct HeadingCollector {
215 anchors: HeadingAnchors,
216 state: Mutex<CollectorState>,
217 }
218
219 #[derive(Default)]
220 struct CollectorState {
221 anchorizer: Anchorizer,
222 flat: Vec<(u8, String, String)>, // (level, id, title)
223 /// `enter`에서 만든 id를 `exit`가 다시 써야 한다. 다시 anchorize하면
224 /// dedupe 카운터가 올라가 서로 다른 id가 나온다.
225 current_id: Option<String>,
226 }
227
228 impl HeadingCollector {
229 fn new(anchors: HeadingAnchors) -> Self {
230 Self {
231 anchors,
232 state: Mutex::new(CollectorState::default()),
233 }
234 }
235
236 fn into_toc(self) -> Vec<TocEntry> {
237 let flat = self
238 .state
239 .into_inner()
240 .unwrap_or_else(|e| e.into_inner())
241 .flat;
242 nest(&flat)
243 }
244 }
245
246 impl HeadingAdapter for HeadingCollector {
247 fn enter(
248 &self,
249 out: &mut dyn fmt::Write,
250 heading: &HeadingMeta,
251 _sourcepos: Option<Sourcepos>,
252 ) -> fmt::Result {
253 let id = {
254 let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
255 let id = st.anchorizer.anchorize(&heading.content);
256 st.flat
257 .push((heading.level, id.clone(), heading.content.clone()));
258 st.current_id = Some(id.clone());
259 id
260 };
261
262 // anchor 표시가 꺼져 있어도 id는 항상 넣는다 — 목차와 딥링크가 이걸 쓴다.
263 write!(out, "<h{} id=\"{}\">", heading.level, attr_escape(&id))?;
264 if self.anchors == HeadingAnchors::Left {
265 write_anchor(out, &id)?;
266 }
267 Ok(())
268 }
269
270 fn exit(&self, out: &mut dyn fmt::Write, heading: &HeadingMeta) -> fmt::Result {
271 if self.anchors == HeadingAnchors::Right {
272 let id = self
273 .state
274 .lock()
275 .unwrap_or_else(|e| e.into_inner())
276 .current_id
277 .clone()
278 .unwrap_or_default();
279 write_anchor(out, &id)?;
280 }
281 write!(out, "</h{}>", heading.level)
282 }
283 }
284
285 fn write_anchor(out: &mut dyn fmt::Write, id: &str) -> fmt::Result {
286 // aria-hidden + tabindex=-1: 스크린리더와 키보드 탐색에 잡음을 더하지 않는다.
287 write!(
288 out,
289 "<a class=\"anchor\" href=\"#{}\" aria-hidden=\"true\" tabindex=\"-1\">#</a>",
290 attr_escape(id)
291 )
292 }
293
294 /// 속성값 이스케이프. id는 Anchorizer가 만들지만 방어적으로 처리한다.
295 fn attr_escape(s: &str) -> String {
296 let mut out = String::with_capacity(s.len());
297 for ch in s.chars() {
298 match ch {
299 '&' => out.push_str("&"),
300 '<' => out.push_str("<"),
301 '>' => out.push_str(">"),
302 '"' => out.push_str("""),
303 '\'' => out.push_str("'"),
304 _ => out.push(ch),
305 }
306 }
307 out
308 }
309
310 /// 평면 heading 목록을 레벨에 따라 중첩시킨다.
311 ///
312 /// 문서가 h2에서 h4로 건너뛰는 건 흔한 일이므로, 레벨이 연속이라고 가정하지 않고
313 /// "직전보다 깊으면 자식"으로만 판단한다.
314 fn nest(flat: &[(u8, String, String)]) -> Vec<TocEntry> {
315 let mut roots: Vec<TocEntry> = Vec::new();
316 // 현재 열려 있는 조상들의 경로 (인덱스 스택)
317 let mut stack: Vec<u8> = Vec::new();
318
319 for (level, id, title) in flat {
320 let entry = TocEntry {
321 level: *level,
322 id: id.clone(),
323 title: title.clone(),
324 children: Vec::new(),
325 };
326
327 while stack.last().is_some_and(|&l| l >= *level) {
328 stack.pop();
329 }
330
331 if stack.is_empty() {
332 roots.push(entry);
333 } else {
334 // 스택 깊이만큼 내려가 마지막 자식에 붙인다
335 let mut cur = roots.last_mut().expect("스택이 비지 않았으면 루트가 있다");
336 for _ in 1..stack.len() {
337 cur = cur.children.last_mut().expect("경로가 유효해야 한다");
338 }
339 cur.children.push(entry);
340 }
341 stack.push(*level);
342 }
343
344 roots
345 }
346
347 #[cfg(test)]
348 mod tests {
349 use super::*;
350
351 fn renderer() -> Renderer {
352 Renderer::new(&MarkdownConfig::default())
353 }
354
355 #[test]
356 fn renders_basic_markdown() {
357 let r = renderer().render("# Hello\n\nWorld.");
358 assert!(r.html.contains("Hello"), "실제: {}", r.html);
359 assert!(r.html.contains("<p>World.</p>"), "실제: {}", r.html);
360 }
361
362 #[test]
363 fn korean_emphasis_attached_to_particle_parses() {
364 // cjk_friendly_emphasis가 꺼져 있으면 이게 <em>으로 파싱되지 않는다.
365 // 한국어 문서에서 매우 흔한 형태라 회귀하면 바로 알아야 한다.
366 let r = renderer().render("**정적 사이트 생성기**를 만들었다.");
367 assert!(
368 r.html.contains("<strong>정적 사이트 생성기</strong>"),
369 "실제: {}",
370 r.html
371 );
372 }
373
374 #[test]
375 fn code_fence_carries_language_class() {
376 let r = renderer().render("```rust\nfn main() {}\n```");
377 assert!(r.html.contains("language-rust"), "실제: {}", r.html);
378 }
379
380 #[test]
381 fn gfm_table_renders() {
382 let r = renderer().render("| a | b |\n|---|---|\n| 1 | 2 |");
383 assert!(r.html.contains("<table>"), "실제: {}", r.html);
384 }
385
386 #[test]
387 fn raw_html_passes_through() {
388 let r = renderer().render("<details><summary>x</summary>y</details>");
389 assert!(r.html.contains("<details>"), "실제: {}", r.html);
390 }
391
392 #[test]
393 fn headings_get_ids_and_anchors() {
394 let r = renderer().render("## Getting started");
395 assert!(
396 r.html.contains(r#"<h2 id="getting-started">"#),
397 "실제: {}",
398 r.html
399 );
400 assert!(
401 r.html.contains(r##"href="#getting-started""##),
402 "실제: {}",
403 r.html
404 );
405 }
406
407 #[test]
408 fn korean_headings_keep_hangul_ids() {
409 let r = renderer().render("## 시작하기\n\n### 설치");
410 assert!(r.html.contains(r#"<h2 id="시작하기">"#), "실제: {}", r.html);
411 assert!(r.html.contains(r#"<h3 id="설치">"#), "실제: {}", r.html);
412 }
413
414 #[test]
415 fn duplicate_headings_get_distinct_ids_shared_with_toc() {
416 // heading과 TOC가 서로 다른 Anchorizer를 쓰면 여기서 어긋난다.
417 let r = renderer().render("## 설치\n\n## 설치");
418 assert!(r.html.contains(r#"id="설치""#), "실제: {}", r.html);
419 assert!(r.html.contains(r#"id="설치-1""#), "실제: {}", r.html);
420 let ids: Vec<&str> = r.toc.iter().map(|e| e.id.as_str()).collect();
421 assert_eq!(ids, vec!["설치", "설치-1"]);
422 }
423
424 #[test]
425 fn toc_nests_by_level() {
426 let r = renderer().render("# A\n\n## A1\n\n### A1a\n\n## A2\n\n# B\n");
427 assert_eq!(r.toc.len(), 2, "최상위는 A와 B: {:?}", r.toc);
428 assert_eq!(r.toc[0].title, "A");
429 assert_eq!(r.toc[0].children.len(), 2);
430 assert_eq!(r.toc[0].children[0].title, "A1");
431 assert_eq!(r.toc[0].children[0].children[0].title, "A1a");
432 assert_eq!(r.toc[0].children[1].title, "A2");
433 assert_eq!(r.toc[1].title, "B");
434 }
435
436 #[test]
437 fn toc_survives_skipped_levels() {
438 // h2 다음에 h4로 뛰는 문서는 흔하다. 레벨이 연속이라고 가정하면 여기서 깨진다.
439 let r = renderer().render("## Top\n\n#### Deep\n");
440 assert_eq!(r.toc.len(), 1);
441 assert_eq!(r.toc[0].children.len(), 1);
442 assert_eq!(r.toc[0].children[0].title, "Deep");
443 }
444
445 #[test]
446 fn heading_content_is_flattened_for_the_toc() {
447 let r = renderer().render("## This is **bold**");
448 assert_eq!(r.toc[0].title, "This is bold");
449 }
450
451 #[test]
452 fn plain_text_separates_blocks() {
453 // 블록 경계에 공백이 없으면 `빌드git` 같은, 문서 어디에도 없는 단어가
454 // 검색 색인에 생긴다. 부분 문자열로 찾는 색인에서는 그게 곧 오검색이다.
455 let r = renderer().render("## 소스에서 빌드\n\n```bash\ngit clone x\n```\n\n다음 단락.");
456 assert!(!r.text.contains("빌드git"), "블록이 붙었다: {}", r.text);
457 assert!(
458 r.text.contains("소스에서 빌드 git clone x"),
459 "실제: {}",
460 r.text
461 );
462 assert!(r.text.contains("다음 단락."), "실제: {}", r.text);
463 }
464
465 #[test]
466 fn plain_text_keeps_inline_code_attached() {
467 // 인라인 코드는 앞뒤 Text가 띄어쓰기를 갖고 있다. 여기에 공백을 더 넣으면
468 // 한국어 조사가 코드에서 떨어져 나가 `sqzass 에`가 된다.
469 let r = renderer().render("바이너리는 `target/release/sqzass`에 생긴다.");
470 assert!(
471 r.text.contains("target/release/sqzass에"),
472 "실제: {}",
473 r.text
474 );
475 }
476
477 #[test]
478 fn plain_text_has_no_markup() {
479 let r = renderer().render("## Title\n\nSome **bold** and a [link](https://example.com).");
480 assert!(!r.text.contains('<'), "태그가 남았다: {}", r.text);
481 assert!(r.text.contains("Some bold and a link."), "실제: {}", r.text);
482 }
483
484 #[test]
485 fn concurrent_renders_do_not_share_state() {
486 // 하이라이터의 pending/errors가 호출 간에 공유되면 병렬 렌더에서 다른
487 // 페이지의 펜스 옵션과 에러가 서로 섞인다. 단일 스레드 결과를 기준으로
488 // 여러 스레드가 반복 렌더해도 같은 결과인지 본다.
489 let r = Renderer::new(&MarkdownConfig::default())
490 .with_highlighter(crate::highlight::Highlighter::new());
491 let a = "```rust hl_lines=1 name=a.rs\nlet a = 1;\nlet b = 2;\n```";
492 let b = "```rust linenos=oops\nlet c = 3;\n```";
493 let expect_a = r.render(a).html;
494 std::thread::scope(|s| {
495 for _ in 0..8 {
496 s.spawn(|| {
497 for _ in 0..50 {
498 let ra = r.render(a);
499 assert_eq!(ra.html, expect_a);
500 assert!(ra.bad_fences.is_empty(), "실제: {:?}", ra.bad_fences);
501 let rb = r.render(b);
502 assert_eq!(rb.bad_fences.len(), 1, "실제: {:?}", rb.bad_fences);
503 assert!(!rb.html.contains("data-name"), "옵션이 샜다: {}", rb.html);
504 }
505 });
506 }
507 });
508 }
509
510 #[test]
511 fn anchors_can_be_disabled_but_ids_remain() {
512 let cfg = MarkdownConfig {
513 heading_anchors: HeadingAnchors::None,
514 ..MarkdownConfig::default()
515 };
516 let r = Renderer::new(&cfg).render("## Title");
517 assert!(r.html.contains(r#"<h2 id="title">"#), "실제: {}", r.html);
518 assert!(
519 !r.html.contains("class=\"anchor\""),
520 "anchor를 껐는데 나왔다: {}",
521 r.html
522 );
523 // 목차는 여전히 동작해야 한다
524 assert_eq!(r.toc[0].id, "title");
525 }
526 }