sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git

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("&amp;"),
    300             '<' => out.push_str("&lt;"),
    301             '>' => out.push_str("&gt;"),
    302             '"' => out.push_str("&quot;"),
    303             '\'' => out.push_str("&#x27;"),
    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 }