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("&")?,
386 '<' => out.write_str("<")?,
387 '>' => out.write_str(">")?,
388 _ => out.write_char(ch)?,
389 }
390 }
391 Ok(())
392 }
393
394 fn escape_attr(s: &str) -> String {
395 s.replace('&', "&")
396 .replace('<', "<")
397 .replace('>', ">")
398 .replace('"', """)
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 }