sqzass.git

sqzass

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

src/highlight.rs (28548B)

      1 //! 빌드 타임 구문 강조.
      2 //!
      3 //! **인라인 `style=`을 쓰지 않는다.** syntect의 편의 함수(`highlighted_html_for_string`)는
      4 //! 색을 HTML에 직접 박는데, 그러면 테마 하나가 문서에 영구히 고정되어 다크 모드가
      5 //! 불가능해지고, 페이지마다 색 문자열이 중복되며, CSP의 `style-src` 강화도 막힌다.
      6 //! 대신 의미론적 CSS 클래스를 뽑고 테마는 스타일시트로 분리한다 — 그래서 같은 HTML이
      7 //! 라이트/다크 양쪽에서 동작한다.
      8 
      9 use crate::config::Highlight as HighlightConfig;
     10 use anyhow::{Context, Result};
     11 use comrak::adapters::SyntaxHighlighterAdapter;
     12 use std::borrow::Cow;
     13 use std::collections::HashMap;
     14 use std::fmt;
     15 use std::sync::{Arc, Mutex, OnceLock};
     16 use syntect::highlighting::ThemeSet;
     17 use syntect::html::{
     18     ClassStyle, ClassedHTMLGenerator, css_for_theme_with_class_style, line_tokens_to_classed_spans,
     19 };
     20 use syntect::parsing::{ParseState, Scope, ScopeStack, SyntaxReference, SyntaxSet};
     21 use syntect::util::LinesWithEndings;
     22 
     23 /// 클래스 접두사. 접두사가 없으면 syntect가 `source`, `keyword`, `string` 같은
     24 /// 일반적인 단어를 그대로 클래스로 쓰기 때문에 사이트 CSS와 충돌한다.
     25 /// syntect API가 `&'static str`을 요구하므로 상수여야 한다.
     26 const CLASS_PREFIX: &str = "hl-";
     27 const CLASS_STYLE: ClassStyle = ClassStyle::SpacedPrefixed {
     28     prefix: CLASS_PREFIX,
     29 };
     30 
     31 /// 문법 덤프는 프로세스당 한 번만 역직렬화한다.
     32 ///
     33 /// two-face의 bincode 덤프 역직렬화는 수십 ms급 고정비다. 단일 `build`에서는 어차피
     34 /// 한 번이지만, `serve`는 저장할 때마다 빌드를 새로 돌므로 이게 저장→반영 지연에
     35 /// 매번 얹혔다. 문법 집합은 콘텐츠와 무관하니 재사용해도 결정성에 영향이 없다.
     36 static SYNTAXES: OnceLock<SyntaxSet> = OnceLock::new();
     37 
     38 /// 블록 하이라이트 결과 캐시. 같은 (문법, hl_lines, 코드) 블록은 빌드 안에서
     39 /// 한 번만 하이라이트한다 — 문서 사이트는 설치 명령·보일러플레이트가 페이지마다
     40 /// 반복되고, syntect 파싱이 렌더 비용의 사실상 전부라서다.
     41 ///
     42 /// 키가 해시가 아니라 튜플 그 자체인 이유: 64비트 해시 충돌은 **다른 코드 블록을
     43 /// 조용히 바꿔치기**한다. 이 도구가 막겠다고 한 부류의 실패라, 코드 사본 하나를
     44 /// 더 드는 쪽을 택한다. 값이 Arc인 건 조회가 복사 없이 나가게 하기 위해서다.
     45 pub type BlockCache = Arc<Mutex<HashMap<(String, Vec<(usize, usize)>, String), Arc<String>>>>;
     46 
     47 pub struct Highlighter {
     48     syntaxes: &'static SyntaxSet,
     49     /// 파싱된 펜스 옵션을 `write_code_tag` → `write_highlighted` 사이에 건네는
     50     /// 보관함. comrak은 한 블록을 pre → code → highlighted 순서로 부르고, 한
     51     /// 페이지의 렌더는 단일 스레드다. 페이지들은 병렬로 렌더되므로 인스턴스는
     52     /// **호출(페이지)마다 새로 만든다** — markdown.rs의 render_in이 그렇게 한다.
     53     pending: Mutex<FenceOpts>,
     54     /// 잘못된 펜스 옵션. 렌더가 끝난 뒤 빌드를 실패시키는 데 쓴다 — 모르는 키를
     55     /// 조용히 무시하면 `hl_line=` 오타가 강조 없는 채로 배포된다.
     56     errors: Mutex<Vec<String>>,
     57     /// 빌드 전체가 공유하는 블록 캐시. 인스턴스는 페이지마다지만 캐시는 하나다.
     58     cache: Option<BlockCache>,
     59 }
     60 
     61 impl Highlighter {
     62     pub fn new() -> Self {
     63         // `extra_newlines` 여야 한다. ClassedHTMLGenerator는 줄 끝의 개행을 요구하고,
     64         // no-newlines 덤프와 섞으면 조용히 잘못된 결과가 나온다.
     65         Self {
     66             syntaxes: SYNTAXES.get_or_init(two_face::syntax::extra_newlines),
     67             pending: Mutex::default(),
     68             errors: Mutex::default(),
     69             cache: None,
     70         }
     71     }
     72 
     73     /// 빌드 수준 블록 캐시를 붙인다.
     74     pub fn with_cache(mut self, cache: BlockCache) -> Self {
     75         self.cache = Some(cache);
     76         self
     77     }
     78 
     79     /// 잘못된 펜스 옵션들. 비어 있지 않으면 빌드를 멈춰야 한다.
     80     pub fn take_errors(&self) -> Vec<String> {
     81         self.errors
     82             .lock()
     83             .unwrap_or_else(|e| e.into_inner())
     84             .split_off(0)
     85     }
     86 
     87     fn record_error(&self, message: String) {
     88         self.errors
     89             .lock()
     90             .unwrap_or_else(|e| e.into_inner())
     91             .push(message);
     92     }
     93 }
     94 
     95 /// 코드 펜스 옵션: ```` ```rust hl_lines=2-4,7 name=main.rs ```` 처럼 **언어 뒤에
     96 /// 공백으로 구분한 key=value**로 적는다.
     97 ///
     98 /// 이 문법을 고른 근거: comrak이 info 문자열을 첫 공백에서 언어/메타로 가르므로
     99 /// 공백 뒤 key=value가 자연스럽고, Zola식 `rust,hl_lines=2-4`는 콤마까지 언어
    100 /// 토큰에 붙어 문법 조회와 `language-` 클래스를 오염시킨다. hwaro식 `{}`는 같은
    101 /// 위치에서 괄호만 더한다. 값에 공백은 못 쓴다 — 필요해지면 따옴표를 그때 더한다.
    102 #[derive(Debug, Default, PartialEq)]
    103 struct FenceOpts {
    104     /// 강조할 줄들, 1부터 시작하는 닫힌 구간.
    105     hl_lines: Vec<(usize, usize)>,
    106     /// 파일명 라벨. `data-name` 속성으로만 나가고, 보여줄지는 사이트 CSS가 정한다.
    107     name: Option<String>,
    108 }
    109 
    110 impl FenceOpts {
    111     fn parse(meta: &str) -> Result<Self, String> {
    112         let mut opts = Self::default();
    113         for token in meta.split_whitespace() {
    114             let Some((key, value)) = token.split_once('=') else {
    115                 return Err(format!(
    116                     "펜스 옵션 `{token}`: key=value 꼴이어야 합니다 (예: hl_lines=2-4,7 name=main.rs)"
    117                 ));
    118             };
    119             match key {
    120                 // 같은 키가 두 번 오면 앞의 것이 소리 없이 버려진다 — 편집
    121                 // 잔재가 살아남는 정확히 그 경로라서 에러다.
    122                 "hl_lines" if !opts.hl_lines.is_empty() => {
    123                     return Err(format!("펜스 옵션 `{key}` 가 두 번 왔습니다"));
    124                 }
    125                 "name" if opts.name.is_some() => {
    126                     return Err(format!("펜스 옵션 `{key}` 가 두 번 왔습니다"));
    127                 }
    128                 "hl_lines" => opts.hl_lines = parse_ranges(value)?,
    129                 "name" => opts.name = Some(value.to_string()),
    130                 _ => {
    131                     return Err(format!(
    132                         "펜스 옵션 `{key}` 는 없습니다. 사용 가능: hl_lines, name"
    133                     ));
    134                 }
    135             }
    136         }
    137         Ok(opts)
    138     }
    139 }
    140 
    141 /// `2-4,7` → `[(2,4), (7,7)]`
    142 fn parse_ranges(value: &str) -> Result<Vec<(usize, usize)>, String> {
    143     let mut out = Vec::new();
    144     for part in value.split(',') {
    145         let (a, b) = part.split_once('-').unwrap_or((part, part));
    146         let (a, b) = (parse_line_no(a, part)?, parse_line_no(b, part)?);
    147         if a > b {
    148             return Err(format!("hl_lines `{part}`: 시작이 끝보다 큽니다"));
    149         }
    150         out.push((a, b));
    151     }
    152     Ok(out)
    153 }
    154 
    155 fn parse_line_no(s: &str, part: &str) -> Result<usize, String> {
    156     match s.parse::<usize>() {
    157         Ok(n) if n >= 1 => Ok(n),
    158         _ => Err(format!(
    159             "hl_lines `{part}`: 줄 번호는 1부터 시작하는 정수여야 합니다"
    160         )),
    161     }
    162 }
    163 
    164 /// 줄 시작 시점에 열려 있는 스코프를 그 줄 안에서 다시 연다.
    165 ///
    166 /// syntect의 `scope_to_classes`는 비공개라, 공개 API인 `Scope::build_string()`의
    167 /// atom들로 같은 클래스 목록을 재구성한다. atom에는 `.`이 들어갈 수 없으므로
    168 /// split이 정확히 역연산이다. 재구성이 어긋나면 아래 여러 줄 주석 테스트가 깨진다.
    169 fn open_scope_span(out: &mut String, scope: Scope) {
    170     out.push_str("<span class=\"");
    171     let dotted = scope.build_string();
    172     for (i, atom) in dotted.split('.').enumerate() {
    173         if i != 0 {
    174             out.push(' ');
    175         }
    176         out.push_str(CLASS_PREFIX);
    177         out.push_str(atom);
    178     }
    179     out.push_str("\">");
    180 }
    181 
    182 impl Default for Highlighter {
    183     fn default() -> Self {
    184         Self::new()
    185     }
    186 }
    187 
    188 impl Highlighter {
    189     /// 옵션 없는 펜스의 경로. 기존 출력과 바이트 단위로 같아야 한다.
    190     fn write_block(
    191         &self,
    192         out: &mut dyn fmt::Write,
    193         syntax: &SyntaxReference,
    194         code: &str,
    195     ) -> fmt::Result {
    196         let mut generator =
    197             ClassedHTMLGenerator::new_with_class_style(syntax, self.syntaxes, CLASS_STYLE);
    198         for line in LinesWithEndings::from(code) {
    199             // 강조에 실패해도 문서 빌드를 통째로 죽이지 않는다. 이 블록만 포기하고
    200             // 이스케이프된 원문을 내보낸다.
    201             if generator
    202                 .parse_html_for_line_which_includes_newline(line)
    203                 .is_err()
    204             {
    205                 return write_plain(out, code);
    206             }
    207         }
    208         out.write_str(&generator.finalize())
    209     }
    210 
    211     /// `hl_lines`가 있을 때의 경로. 표시할 줄을 `<mark>`로 감싸려면 각 줄이 span
    212     /// 균형이 맞아야 하는데, `ClassedHTMLGenerator`는 span을 줄 경계 너머로 열어
    213     /// 둔다(마지막 `finalize`에서만 닫는다). 그래서 파스 상태는 줄을 넘겨 잇되
    214     /// (여러 줄 주석·문자열이 올바르게 칠해진다), 각 줄은 시작에 현재 열려 있는
    215     /// 스코프를 다시 열고 끝에서 전부 닫아 자립시킨다.
    216     ///
    217     /// `<mark>`인 이유: CSS 없이도 브라우저 기본 스타일로 표시가 보이고, 사이트는
    218     /// `.highlight mark`로 덮어쓰면 된다.
    219     fn write_lines(
    220         &self,
    221         out: &mut dyn fmt::Write,
    222         syntax: &SyntaxReference,
    223         code: &str,
    224         hl: &[(usize, usize)],
    225     ) -> fmt::Result {
    226         let total = LinesWithEndings::from(code).count();
    227         if let Some(&(a, b)) = hl.iter().find(|&&(_, b)| b > total) {
    228             self.record_error(format!(
    229                 "hl_lines {a}-{b}: 이 코드 블록은 {total}줄뿐입니다"
    230             ));
    231             return self.write_block(out, syntax, code);
    232         }
    233 
    234         let mut parse_state = ParseState::new(syntax);
    235         let mut stack = ScopeStack::new();
    236         let mut html = String::new();
    237         for (i, line) in LinesWithEndings::from(code).enumerate() {
    238             let n = i + 1;
    239             let Ok(ops) = parse_state.parse_line(line, self.syntaxes) else {
    240                 return write_plain(out, code);
    241             };
    242             let marked = hl.iter().any(|&(a, b)| (a..=b).contains(&n));
    243             if marked {
    244                 html.push_str("<mark class=\"hl-line\">");
    245             }
    246             for &scope in stack.as_slice() {
    247                 open_scope_span(&mut html, scope);
    248             }
    249             let Ok((body, _)) = line_tokens_to_classed_spans(line, &ops, CLASS_STYLE, &mut stack)
    250             else {
    251                 return write_plain(out, code);
    252             };
    253             html.push_str(&body);
    254             // 이 줄에서 열린 span 수 = 줄 시작에 다시 연 것 + Push − Pop = 스택 크기.
    255             for _ in 0..stack.len() {
    256                 html.push_str("</span>");
    257             }
    258             if marked {
    259                 html.push_str("</mark>");
    260             }
    261         }
    262         out.write_str(&html)
    263     }
    264 }
    265 
    266 impl SyntaxHighlighterAdapter for Highlighter {
    267     fn write_highlighted(
    268         &self,
    269         out: &mut dyn fmt::Write,
    270         lang: Option<&str>,
    271         code: &str,
    272     ) -> fmt::Result {
    273         let opts = std::mem::take(&mut *self.pending.lock().unwrap_or_else(|e| e.into_inner()));
    274         let syntax = lang
    275             .and_then(|l| {
    276                 let l = l.trim();
    277                 self.syntaxes
    278                     .find_syntax_by_token(l)
    279                     .or_else(|| self.syntaxes.find_syntax_by_extension(l))
    280             })
    281             .unwrap_or_else(|| self.syntaxes.find_syntax_plain_text());
    282 
    283         let key = (syntax.name.clone(), opts.hl_lines.clone(), code.to_string());
    284         if let Some(cache) = &self.cache {
    285             let hit = cache
    286                 .lock()
    287                 .unwrap_or_else(|e| e.into_inner())
    288                 .get(&key)
    289                 .cloned();
    290             if let Some(html) = hit {
    291                 return out.write_str(&html);
    292             }
    293         }
    294 
    295         // 캐시 미스는 버퍼에 만들고, **에러 없이 만들어진 결과만** 캐시한다.
    296         // hl_lines 범위 초과 같은 에러는 그 블록이 나오는 페이지마다 다시
    297         // 기록되어야 한다 — 캐시가 에러를 삼키면 두 번째 페이지부터 조용해진다.
    298         let before = self.errors.lock().unwrap_or_else(|e| e.into_inner()).len();
    299         let mut buf = String::new();
    300         if opts.hl_lines.is_empty() {
    301             self.write_block(&mut buf, syntax, code)?;
    302         } else {
    303             self.write_lines(&mut buf, syntax, code, &opts.hl_lines)?;
    304         }
    305         let clean = self.errors.lock().unwrap_or_else(|e| e.into_inner()).len() == before;
    306         out.write_str(&buf)?;
    307         if clean && let Some(cache) = &self.cache {
    308             cache
    309                 .lock()
    310                 .unwrap_or_else(|e| e.into_inner())
    311                 .insert(key, Arc::new(buf));
    312         }
    313         Ok(())
    314     }
    315 
    316     fn write_pre_tag(
    317         &self,
    318         out: &mut dyn fmt::Write,
    319         _attributes: HashMap<&'static str, Cow<'_, str>>,
    320     ) -> fmt::Result {
    321         out.write_str("<pre class=\"highlight\">")
    322     }
    323 
    324     fn write_code_tag(
    325         &self,
    326         out: &mut dyn fmt::Write,
    327         attributes: HashMap<&'static str, Cow<'_, str>>,
    328     ) -> fmt::Result {
    329         // comrak이 `class="language-rust"`를 넘겨준다. 언어를 data 속성으로도 복제해
    330         // 복사 버튼이나 라벨이 클래스 문자열을 파싱하지 않아도 되게 한다.
    331         let class = attributes.get("class").map(|c| c.as_ref()).unwrap_or("");
    332         let lang = class.strip_prefix("language-").unwrap_or("");
    333 
    334         // 언어 이름에 `=`는 없다 — 언어 자리의 `=`는 언어를 빼먹고 옵션부터
    335         // 적었다는 확실한 증거다. comrak이 첫 토큰을 언어로 삼아 버려 아래
    336         // 옵션 파서에는 도달하지 않으므로, 여기서 잡지 않으면 옵션이 조용히
    337         // 증발한 페이지가 배포된다.
    338         if lang.contains('=') {
    339             self.record_error(format!(
    340                 "펜스 언어 자리에 `{lang}`: 옵션은 언어 뒤에 옵니다 (언어가 없으면 ```text {lang})"
    341             ));
    342         }
    343 
    344         // 펜스 옵션은 comrak이 `data-meta`로 넘겨준다(언어 뒤 공백 이후 전부,
    345         // `full_info_string`). 여기서 파싱해 두면 바로 다음의 write_highlighted가
    346         // 꺼내 쓴다. 잘못된 옵션은 기록만 하고 계속 간다 — 렌더가 끝난 뒤 빌드가
    347         // 페이지 경로와 함께 실패한다.
    348         let meta = attributes
    349             .get("data-meta")
    350             .map(|m| m.as_ref())
    351             .unwrap_or("");
    352         let opts = if meta.is_empty() {
    353             FenceOpts::default()
    354         } else {
    355             match FenceOpts::parse(meta) {
    356                 Ok(o) => o,
    357                 Err(e) => {
    358                     self.record_error(e);
    359                     FenceOpts::default()
    360                 }
    361             }
    362         };
    363 
    364         if lang.is_empty() {
    365             *self.pending.lock().unwrap_or_else(|e| e.into_inner()) = opts;
    366             return out.write_str("<code>");
    367         }
    368         write!(
    369             out,
    370             "<code class=\"{}\" data-lang=\"{}\"",
    371             escape_attr(class),
    372             escape_attr(lang)
    373         )?;
    374         if let Some(name) = &opts.name {
    375             write!(out, " data-name=\"{}\"", escape_attr(name))?;
    376         }
    377         *self.pending.lock().unwrap_or_else(|e| e.into_inner()) = opts;
    378         out.write_str(">")
    379     }
    380 }
    381 
    382 fn write_plain(out: &mut dyn fmt::Write, code: &str) -> fmt::Result {
    383     for ch in code.chars() {
    384         match ch {
    385             '&' => out.write_str("&amp;")?,
    386             '<' => out.write_str("&lt;")?,
    387             '>' => out.write_str("&gt;")?,
    388             _ => out.write_char(ch)?,
    389         }
    390     }
    391     Ok(())
    392 }
    393 
    394 fn escape_attr(s: &str) -> String {
    395     s.replace('&', "&amp;")
    396         .replace('<', "&lt;")
    397         .replace('>', "&gt;")
    398         .replace('"', "&quot;")
    399 }
    400 
    401 /// 라이트/다크 두 테마의 CSS를 하나의 스타일시트로 합친다.
    402 ///
    403 /// 다크 규칙을 두 번 내보낸다: OS 설정을 따르는 `prefers-color-scheme` 안에 한 번,
    404 /// 수동 토글용 `[data-theme="dark"]`에 한 번. 네이티브 CSS 중첩을 써서 생성된
    405 /// 규칙을 문자열로 파싱하지 않고 그대로 감싼다.
    406 pub fn stylesheet(cfg: &HighlightConfig) -> Result<String> {
    407     // 테마 집합도 문법 덤프와 같은 이유로 프로세스당 한 번만 로드한다 —
    408     // serve의 리빌드마다 이 함수가 다시 불린다.
    409     static THEMES: OnceLock<ThemeSet> = OnceLock::new();
    410     let themes = THEMES.get_or_init(ThemeSet::load_defaults);
    411 
    412     let get = |name: &str| -> Result<String> {
    413         let theme = themes.themes.get(name).with_context(|| {
    414             format!(
    415                 "'{name}' 테마를 찾을 수 없습니다. 사용 가능: {}",
    416                 themes.themes.keys().cloned().collect::<Vec<_>>().join(", ")
    417             )
    418         })?;
    419         css_for_theme_with_class_style(theme, CLASS_STYLE)
    420             .with_context(|| format!("'{name}' 테마의 CSS 생성 실패"))
    421     };
    422 
    423     let light = get(&cfg.theme_light)?;
    424     let dark = get(&cfg.theme_dark)?;
    425 
    426     Ok(format!(
    427         "/* generated by sqzass — do not edit */\n\
    428          /* light: {} */\n{}\n\
    429          /* dark: {} */\n\
    430          @media (prefers-color-scheme: dark) {{\n  :root:not([data-theme=\"light\"]) {{\n{}\n  }}\n}}\n\
    431          :root[data-theme=\"dark\"] {{\n{}\n}}\n",
    432         cfg.theme_light, light, cfg.theme_dark, dark, dark
    433     ))
    434 }
    435 
    436 #[cfg(test)]
    437 mod tests {
    438     use super::*;
    439     use crate::config::Markdown as MarkdownConfig;
    440     use crate::markdown::Renderer;
    441 
    442     fn render(md: &str) -> String {
    443         Renderer::new(&MarkdownConfig::default())
    444             .with_highlighter(Highlighter::new())
    445             .render(md)
    446             .html
    447     }
    448 
    449     #[test]
    450     fn emits_css_classes_not_inline_styles() {
    451         let html = render("```rust\nfn main() { let x = 1; }\n```");
    452         assert!(
    453             !html.contains("style=\""),
    454             "인라인 style이 나왔다 — 다크모드가 불가능해진다: {html}"
    455         );
    456         assert!(html.contains("hl-"), "하이라이트 클래스가 없다: {html}");
    457     }
    458 
    459     #[test]
    460     fn tags_carry_language_metadata() {
    461         let html = render("```rust\nfn main() {}\n```");
    462         assert!(html.contains(r#"class="highlight""#), "실제: {html}");
    463         assert!(html.contains(r#"data-lang="rust""#), "실제: {html}");
    464     }
    465 
    466     #[test]
    467     fn unknown_language_falls_back_to_plain_text() {
    468         let html = render("```notalanguage\nhello\n```");
    469         assert!(html.contains("hello"), "본문이 사라졌다: {html}");
    470     }
    471 
    472     #[test]
    473     fn fence_without_language_still_renders() {
    474         let html = render("```\nplain code\n```");
    475         assert!(html.contains("plain code"), "실제: {html}");
    476     }
    477 
    478     #[test]
    479     fn code_is_escaped_not_executed() {
    480         let html = render("```html\n<script>alert(1)</script>\n```");
    481         assert!(
    482             !html.contains("<script>alert"),
    483             "코드 블록 안의 스크립트가 이스케이프되지 않았다: {html}"
    484         );
    485     }
    486 
    487     #[test]
    488     fn hl_lines_wraps_only_the_marked_lines() {
    489         let html = render("```rust hl_lines=2\nlet a = 1;\nlet b = 2;\nlet c = 3;\n```");
    490         assert_eq!(
    491             html.matches("<mark class=\"hl-line\">").count(),
    492             1,
    493             "실제: {html}"
    494         );
    495         let marked = html
    496             .split("<mark class=\"hl-line\">")
    497             .nth(1)
    498             .unwrap()
    499             .split("</mark>")
    500             .next()
    501             .unwrap();
    502         // 문자로 판정하면 클래스명(hl-source, hl-constant)의 글자에 걸린다 —
    503         // 클래스명에 없는 숫자 리터럴로 어느 줄이 감싸였는지 본다.
    504         assert!(marked.contains('2'), "2번 줄이 아니다: {marked}");
    505         assert!(!marked.contains('1'), "1번 줄까지 감쌌다: {marked}");
    506         assert!(!marked.contains('3'), "3번 줄까지 감쌌다: {marked}");
    507     }
    508 
    509     #[test]
    510     fn hl_lines_reopens_scopes_across_line_boundaries() {
    511         // 여러 줄 주석의 가운데 줄만 표시해도 주석 스코프가 그 줄 안에서 다시
    512         // 열려야 한다. syntect의 scope_to_classes가 비공개라 Scope::build_string()
    513         // 으로 재구성하는데, 그 재구성이 어긋나면 이 테스트가 깨진다.
    514         let html = render("```rust hl_lines=2\n/* first\nsecond\nthird */\n```");
    515         let marked = html
    516             .split("<mark class=\"hl-line\">")
    517             .nth(1)
    518             .unwrap()
    519             .split("</mark>")
    520             .next()
    521             .unwrap();
    522         assert!(marked.contains("second"), "실제: {marked}");
    523         assert!(
    524             marked.contains("hl-comment"),
    525             "주석 스코프가 안 열렸다: {marked}"
    526         );
    527         assert_eq!(
    528             marked.matches("<span").count(),
    529             marked.matches("</span>").count(),
    530             "표시 줄의 span이 균형이 안 맞는다: {marked}"
    531         );
    532     }
    533 
    534     #[test]
    535     fn name_lands_as_an_escaped_attribute() {
    536         let html = render("```rust name=main.rs\nfn main() {}\n```");
    537         assert!(html.contains(r#"data-name="main.rs""#), "실제: {html}");
    538     }
    539 
    540     #[test]
    541     fn a_fence_without_options_is_byte_identical_to_before() {
    542         let html = render("```rust\nlet x = 1;\n```");
    543         assert!(!html.contains("<mark"), "옵션 없는 펜스에 mark: {html}");
    544         assert!(!html.contains("data-name"), "실제: {html}");
    545         assert!(
    546             !html.contains("data-meta"),
    547             "메타 속성이 새어 나왔다: {html}"
    548         );
    549     }
    550 
    551     #[test]
    552     fn unknown_fence_option_is_an_error_not_a_silent_noop() {
    553         let r = Renderer::new(&MarkdownConfig::default()).with_highlighter(Highlighter::new());
    554         let rendered = r.render("```rust linenos=true\nfn main() {}\n```");
    555         assert_eq!(
    556             rendered.bad_fences.len(),
    557             1,
    558             "실제: {:?}",
    559             rendered.bad_fences
    560         );
    561         assert!(rendered.bad_fences[0].contains("linenos"));
    562     }
    563 
    564     #[test]
    565     fn hl_lines_beyond_the_block_is_an_error() {
    566         let r = Renderer::new(&MarkdownConfig::default()).with_highlighter(Highlighter::new());
    567         let rendered = r.render("```rust hl_lines=9\nfn main() {}\n```");
    568         assert_eq!(
    569             rendered.bad_fences.len(),
    570             1,
    571             "실제: {:?}",
    572             rendered.bad_fences
    573         );
    574     }
    575 
    576     #[test]
    577     fn options_in_the_language_slot_are_an_error() {
    578         // 언어를 빼먹으면 comrak이 첫 토큰을 언어로 삼아 옵션 파서에 도달하지
    579         // 않는다. 그대로 두면 "오타는 빌드 에러" 계약이 이 입력에서만 샌다.
    580         let r = Renderer::new(&MarkdownConfig::default()).with_highlighter(Highlighter::new());
    581         let rendered = r.render("```hl_lines=2\nlet a = 1;\nlet b = 2;\n```");
    582         assert_eq!(
    583             rendered.bad_fences.len(),
    584             1,
    585             "실제: {:?}",
    586             rendered.bad_fences
    587         );
    588         assert!(rendered.bad_fences[0].contains("hl_lines=2"));
    589 
    590         // 반쪽 적용 변형: hl_lines=2가 언어로 먹히고 name만 파싱되는 경우도
    591         // 같은 에러여야 한다 — data-name이 붙은 출력이 성공처럼 보이기 때문.
    592         let rendered = r.render("```hl_lines=2 name=a.rs\nlet a = 1;\nlet b = 2;\n```");
    593         assert!(!rendered.bad_fences.is_empty());
    594     }
    595 
    596     #[test]
    597     fn a_repeated_fence_option_is_an_error_not_last_wins() {
    598         let r = Renderer::new(&MarkdownConfig::default()).with_highlighter(Highlighter::new());
    599         let rendered = r.render("```rust hl_lines=2 hl_lines=1\nlet a = 1;\nlet b = 2;\n```");
    600         assert_eq!(
    601             rendered.bad_fences.len(),
    602             1,
    603             "실제: {:?}",
    604             rendered.bad_fences
    605         );
    606         assert!(rendered.bad_fences[0].contains("두 번"));
    607     }
    608 
    609     #[test]
    610     fn cached_blocks_render_byte_identical() {
    611         // 같은 Renderer(=같은 빌드)의 두 번째 렌더는 블록 캐시를 탄다. 결과가
    612         // 비캐시 경로와 다르면 캐시가 출력을 바꾼 것이다.
    613         let r = Renderer::new(&MarkdownConfig::default()).with_highlighter(Highlighter::new());
    614         let doc = "```rust hl_lines=2 name=a.rs\nlet a = 1;\nlet b = 2;\n```";
    615         let first = r.render(doc).html;
    616         let second = r.render(doc).html;
    617         assert_eq!(first, second);
    618     }
    619 
    620     #[test]
    621     fn cache_keys_include_hl_lines() {
    622         // 같은 코드, 다른 hl_lines. 키에 hl_lines가 빠지면 첫 블록의 결과가
    623         // 재사용돼 두 번째 블록이 엉뚱한 줄을 표시한다.
    624         let r = Renderer::new(&MarkdownConfig::default()).with_highlighter(Highlighter::new());
    625         let html = r
    626             .render(
    627                 "```rust hl_lines=1\nlet a = 1;\nlet b = 2;\n```\n\n\
    628                  ```rust hl_lines=2\nlet a = 1;\nlet b = 2;\n```",
    629             )
    630             .html;
    631         let marks: Vec<&str> = html
    632             .split("<mark class=\"hl-line\">")
    633             .skip(1)
    634             .map(|s| s.split("</mark>").next().unwrap())
    635             .collect();
    636         assert_eq!(marks.len(), 2, "실제: {html}");
    637         assert!(
    638             marks[0].contains('1') && !marks[0].contains('2'),
    639             "실제: {:?}",
    640             marks[0]
    641         );
    642         assert!(
    643             marks[1].contains('2') && !marks[1].contains('1'),
    644             "실제: {:?}",
    645             marks[1]
    646         );
    647     }
    648 
    649     #[test]
    650     fn errors_recur_on_every_render_not_just_the_first() {
    651         // 에러가 난 블록이 캐시되면 같은 블록을 쓰는 두 번째 페이지부터 빌드가
    652         // 조용히 통과한다 — 에러 경로는 캐시하지 않는다.
    653         let r = Renderer::new(&MarkdownConfig::default()).with_highlighter(Highlighter::new());
    654         let doc = "```rust hl_lines=9\nfn main() {}\n```";
    655         assert_eq!(r.render(doc).bad_fences.len(), 1);
    656         assert_eq!(r.render(doc).bad_fences.len(), 1, "캐시가 에러를 삼켰다");
    657     }
    658 
    659     #[test]
    660     fn fence_option_parser_accepts_ranges_and_rejects_nonsense() {
    661         assert_eq!(
    662             FenceOpts::parse("hl_lines=2-4,7").unwrap().hl_lines,
    663             vec![(2, 4), (7, 7)]
    664         );
    665         assert!(FenceOpts::parse("hl_lines=0").is_err(), "0번 줄은 없다");
    666         assert!(FenceOpts::parse("hl_lines=4-2").is_err(), "역순 범위");
    667         assert!(FenceOpts::parse("no-equals-sign").is_err());
    668     }
    669 
    670     #[test]
    671     fn two_face_provides_syntaxes_syntect_lacks() {
    672         // two-face를 넣은 이유가 이것이다 — 개발 도구 문서에 반드시 필요한 언어들이
    673         // syntect 기본 세트에 없다.
    674         let h = Highlighter::new();
    675         for lang in ["toml", "typescript", "dockerfile"] {
    676             assert!(
    677                 h.syntaxes.find_syntax_by_token(lang).is_some(),
    678                 "'{lang}' 문법이 없다"
    679             );
    680         }
    681     }
    682 
    683     #[test]
    684     fn stylesheet_contains_both_themes_and_a_manual_toggle() {
    685         let css = stylesheet(&HighlightConfig::default()).unwrap();
    686         assert!(
    687             css.contains("prefers-color-scheme: dark"),
    688             "OS 다크 대응이 없다"
    689         );
    690         assert!(css.contains("[data-theme=\"dark\"]"), "수동 토글 훅이 없다");
    691         assert!(css.contains(".hl-"), "접두사 붙은 클래스가 없다");
    692     }
    693 
    694     #[test]
    695     fn stylesheet_reports_unknown_theme_names() {
    696         let cfg = HighlightConfig {
    697             theme_dark: "NoSuchTheme".into(),
    698             ..HighlightConfig::default()
    699         };
    700         let err = stylesheet(&cfg).unwrap_err().to_string();
    701         assert!(err.contains("NoSuchTheme"), "실제: {err}");
    702     }
    703 }