diff --git a/docs/content/features/highlighting.ko.md b/docs/content/features/highlighting.ko.md
@@ -52,25 +52,83 @@ syntect 기본 세트의 테마 이름이면 무엇이든 됩니다. 없는 이
`string` 같은 이름을 내보내는데, 프로그래밍을 다루는 사이트에서 당신의 스타일시트와
부딪히기 딱 좋은 일반적인 단어들입니다.
+## 줄 표시와 파일명
+
+옵션은 언어 뒤에, 공백으로 구분해, `key=value`로 적습니다.
+
+````markdown
+```rust hl_lines=2-3 name=src/main.rs
+fn main() {
+ let marked = 2;
+ let also_marked = 3;
+}
+```
+````
+
+이 사이트의 스타일시트로 렌더하면 이렇게 됩니다.
+
+```rust hl_lines=2-3 name=src/main.rs
+fn main() {
+ let marked = 2;
+ let also_marked = 3;
+}
+```
+
+`hl_lines`는 1부터 세는 줄 번호와 닫힌 구간을 쉼표로 나열합니다:
+`hl_lines=2-4,7`. 지목된 줄은 `<mark class="hl-line">`로 감싸집니다.
+`<mark>`는 브라우저가 기본으로 칠해 주므로 CSS가 하나도 없어도 표시가 보이고,
+사이트는 `.highlight mark`로 다시 꾸미면 됩니다. 강조는 줄 경계를 건너 이어집니다.
+여러 줄 주석의 가운데 줄만 표시해도 그 줄은 여전히 주석입니다.
+
+`name`은 블록에 파일명을 답니다. 마크업이 아니라 속성으로 나가므로 —
+`<code … data-name="src/main.rs">` — 보여줄지는 당신의 CSS가 정합니다.
+
+```css
+.prose pre code { display: block; width: max-content; min-width: 100%; }
+.prose pre code[data-name]::before {
+ content: attr(data-name);
+ display: block;
+}
+```
+
+첫 규칙은 `code`를 가장 긴 줄까지 넓힙니다. 블록이 옆으로 스크롤될 때 라벨과
+`hl_lines` 표시가 보이는 폭에서 끊기지 않고 줄 끝까지 닿는 것은 이 규칙
+덕입니다.
+
+Zola식 `rust,hl_lines=2-4`가 아닌 이유: 쉼표는 옵션을 언어 토큰에 붙여 버려서
+문법 조회가 실패하고 `class="language-…"`가 오염됩니다. 첫 공백 뒤는 자유롭고,
+comrak 자신이 정확히 거기서 정보 문자열을 가릅니다.
+
+오타는 조용한 무시가 아니라 빌드 에러입니다. `hl_line=3`도, `linenos=true`도,
+세 줄짜리 블록의 `hl_lines=9`도, 두 번 적은 키도 전부 파일명과 함께 빌드를
+멈춥니다 — 그 반대는 멀쩡해 보이는 채로 강조가 빠진 페이지가 배포되는
+것입니다. 언어 없이 옵션부터 시작한 펜스도 마찬가지입니다. 언어 이름에 `=`가
+들어가는 일은 없으므로, `` ```hl_lines=2 ``는 모르는 언어가 아니라 언어 자리에
+온 옵션입니다. 옵션은 하이라이터의 일부이므로 `enabled = false`면 적용도 검사도
+되지 않습니다.
+
## 줄 번호와 복사 버튼은 당신 몫입니다
둘 다 설정 키가 아니고, `line_numbers`는 아무 일도 안 하는 채로 남기느니 **삭제**했습니다.
아무것도 하지 않는 설정은 없는 것보다 나쁩니다. 누군가 그걸 켜 놓고 기다리게 되니까요.
-줄 번호는 하이라이터가 이미 내보내는 줄에 CSS 카운터를 얹는 것입니다.
+보통의 블록에는 CSS 카운터를 얹을 줄 단위 마크업이 없습니다 — 모든 블록의 모든
+줄에 래퍼를 감으면, 대부분의 블록이 안 쓰는 기능의 값을 모든 페이지가 치르게
+됩니다. 줄 번호 거터는 줄 수를 세는 JS 몇 줄입니다.
+
+```js
+document.querySelectorAll(".prose pre > code").forEach(function (code) {
+ var lines = code.textContent.split("\n").length - 1;
+ var gutter = document.createElement("span");
+ gutter.className = "linenos";
+ for (var i = 1; i <= lines; i++) gutter.textContent += i + "\n";
+ code.parentElement.prepend(gutter);
+});
+```
```css
-.prose pre code { counter-reset: line; }
-.prose pre code > .line::before {
- counter-increment: line;
- content: counter(line);
- display: inline-block;
- width: 2.5em;
- text-align: right;
- margin-right: 1em;
- color: var(--ink-3);
- user-select: none; /* 블록을 복사할 때 번호까지 딸려가지 않게 */
-}
+.prose pre { display: flex; gap: 1em; }
+.prose pre .linenos { text-align: right; color: var(--ink-3); user-select: none; }
```
복사 버튼은 열 줄 남짓이고, 언어는 이미 엘리먼트에 붙어 있습니다.
diff --git a/docs/content/features/highlighting.md b/docs/content/features/highlighting.md
@@ -53,26 +53,88 @@ Classes are prefixed `hl-`. Without a prefix, syntect emits class names like
`source`, `keyword` and `string` — words general enough to collide with your
own stylesheet on a site about programming.
+## Marking lines and naming files
+
+Options go after the language, space-separated, as `key=value`:
+
+````markdown
+```rust hl_lines=2-3 name=src/main.rs
+fn main() {
+ let marked = 2;
+ let also_marked = 3;
+}
+```
+````
+
+And rendered, on this site's stylesheet:
+
+```rust hl_lines=2-3 name=src/main.rs
+fn main() {
+ let marked = 2;
+ let also_marked = 3;
+}
+```
+
+`hl_lines` takes 1-based line numbers and closed ranges, comma-separated:
+`hl_lines=2-4,7`. Each listed line is wrapped in `<mark class="hl-line">` —
+`<mark>` is visible with no CSS at all, because browsers style it out of the
+box, and a site restyles it with `.highlight mark`. Highlighting continues
+across the boundary: marking the middle line of a block comment keeps it a
+comment.
+
+`name` labels the block with a file name. It ships as an attribute, not as
+markup — `<code … data-name="src/main.rs">` — and your CSS decides whether to
+show it:
+
+```css
+.prose pre code { display: block; width: max-content; min-width: 100%; }
+.prose pre code[data-name]::before {
+ content: attr(data-name);
+ display: block;
+}
+```
+
+The first rule widens `code` to the longest line. It is what keeps the label —
+and `hl_lines` marks — running to the end of a block that scrolls sideways,
+instead of stopping at the visible width.
+
+Why this syntax and not Zola's `rust,hl_lines=2-4`: the comma glues the options
+onto the language token, which then fails syntax lookup and pollutes
+`class="language-…"`. After the first space the info string is free, and that
+is where comrak itself splits it.
+
+A typo is a build error, not a silent no-op. `hl_line=3`, `linenos=true`,
+`hl_lines=9` on a three-line block, a key given twice — each stops the build
+and names the file; the alternative is a page that ships unhighlighted while
+looking finished. So does a fence that starts with options instead of a
+language: `=` never appears in a language name, so `` ```hl_lines=2 `` is an
+option in the language slot, not an unknown language. The options belong to
+the highlighter, so with `enabled = false` they are neither applied nor
+checked.
+
## Line numbers and a copy button are yours
Neither is a configuration key, and `line_numbers` was deleted rather than
shipped inert — a setting that does nothing is worse than an absent one, because
someone sets it and waits.
-Line numbers are a CSS counter over the lines the highlighter already emits:
+Ordinary blocks carry no per-line markup to hang a CSS counter on — a wrapper
+around every line of every block would tax all pages for a feature most blocks
+never use. A gutter is a few lines of JS over the line count:
+
+```js
+document.querySelectorAll(".prose pre > code").forEach(function (code) {
+ var lines = code.textContent.split("\n").length - 1;
+ var gutter = document.createElement("span");
+ gutter.className = "linenos";
+ for (var i = 1; i <= lines; i++) gutter.textContent += i + "\n";
+ code.parentElement.prepend(gutter);
+});
+```
```css
-.prose pre code { counter-reset: line; }
-.prose pre code > .line::before {
- counter-increment: line;
- content: counter(line);
- display: inline-block;
- width: 2.5em;
- text-align: right;
- margin-right: 1em;
- color: var(--ink-3);
- user-select: none; /* so copying the block does not copy the numbers */
-}
+.prose pre { display: flex; gap: 1em; }
+.prose pre .linenos { text-align: right; color: var(--ink-3); user-select: none; }
```
A copy button is about ten lines, and the language is already on the element:
diff --git a/docs/static/css/main.css b/docs/static/css/main.css
@@ -557,12 +557,44 @@ h4:hover > .anchor, h5:hover > .anchor, h6:hover > .anchor,
.prose pre.highlight .copy { opacity: 1; }
}
.prose pre.highlight code {
+ /* 블록으로 넓혀 두는 이유: hl-line 표시와 data-name 라벨이 이 박스의 폭을
+ 물려받는다. auto 폭이면 스크롤되는 블록에서 보이는 폭까지만 칠해져서,
+ 옆으로 넘긴 순간 강조가 사라진 채로 줄의 나머지가 보인다. */
+ display: block;
+ width: max-content;
+ min-width: 100%;
background: none;
padding: 0;
border-radius: 0;
font-size: inherit;
}
+/* 펜스 옵션 `hl_lines=`가 감싼 줄. <mark>의 브라우저 기본(노란 배경, 검은 글자)은
+ 두 테마 어느 쪽과도 안 맞으므로 악센트 기운의 면으로 바꾼다. 음수 마진으로
+ pre의 좌우 패딩까지 칠해 "줄 전체"가 표시된 것으로 읽히게 한다. */
+.prose pre.highlight mark.hl-line {
+ display: block;
+ margin: 0 -1.1rem;
+ padding: 0 1.1rem;
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
+ box-shadow: inset 2px 0 0 var(--accent);
+ color: inherit;
+}
+
+/* 펜스 옵션 `name=`. 빌드는 data-name 속성만 내보내고 보여주는 건 사이트 몫이다 —
+ 여기가 그 몫을 하는 자리. */
+.prose pre.highlight code[data-name]::before {
+ content: attr(data-name);
+ display: block;
+ margin: -0.9rem -1.1rem 0.9rem;
+ padding: 0.4rem 1.1rem;
+ font-size: 0.75rem;
+ color: var(--ink-3);
+ background: var(--paper);
+ border-bottom: 1px solid var(--rule);
+ border-radius: var(--radius) var(--radius) 0 0;
+}
+
/* ---------- tables ---------- */
/* `display: block`이었다. 넓은 표를 가로로 굴리려던 것인데, 그러면 표가 내부
diff --git a/src/highlight.rs b/src/highlight.rs
@@ -12,10 +12,12 @@ use comrak::adapters::SyntaxHighlighterAdapter;
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt;
-use std::sync::OnceLock;
+use std::sync::{Mutex, OnceLock};
use syntect::highlighting::ThemeSet;
-use syntect::html::{ClassStyle, ClassedHTMLGenerator, css_for_theme_with_class_style};
-use syntect::parsing::SyntaxSet;
+use syntect::html::{
+ ClassStyle, ClassedHTMLGenerator, css_for_theme_with_class_style, line_tokens_to_classed_spans,
+};
+use syntect::parsing::{ParseState, Scope, ScopeStack, SyntaxReference, SyntaxSet};
use syntect::util::LinesWithEndings;
/// 클래스 접두사. 접두사가 없으면 syntect가 `source`, `keyword`, `string` 같은
@@ -35,6 +37,13 @@ static SYNTAXES: OnceLock<SyntaxSet> = OnceLock::new();
pub struct Highlighter {
syntaxes: &'static SyntaxSet,
+ /// 파싱된 펜스 옵션을 `write_code_tag` → `write_highlighted` 사이에 건네는
+ /// 보관함. comrak은 한 블록을 pre → code → highlighted 순서로 부르고 렌더는
+ /// 단일 스레드다. 병렬 렌더를 도입하면 이 상태는 렌더 단위별로 분리해야 한다.
+ pending: Mutex<FenceOpts>,
+ /// 잘못된 펜스 옵션. 렌더가 끝난 뒤 빌드를 실패시키는 데 쓴다 — 모르는 키를
+ /// 조용히 무시하면 `hl_line=` 오타가 강조 없는 채로 배포된다.
+ errors: Mutex<Vec<String>>,
}
impl Highlighter {
@@ -43,16 +52,198 @@ impl Highlighter {
// no-newlines 덤프와 섞으면 조용히 잘못된 결과가 나온다.
Self {
syntaxes: SYNTAXES.get_or_init(two_face::syntax::extra_newlines),
+ pending: Mutex::default(),
+ errors: Mutex::default(),
+ }
+ }
+
+ /// 잘못된 펜스 옵션들. 비어 있지 않으면 빌드를 멈춰야 한다.
+ pub fn take_errors(&self) -> Vec<String> {
+ self.errors
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .split_off(0)
+ }
+
+ fn record_error(&self, message: String) {
+ self.errors
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .push(message);
+ }
+}
+
+/// 코드 펜스 옵션: ```` ```rust hl_lines=2-4,7 name=main.rs ```` 처럼 **언어 뒤에
+/// 공백으로 구분한 key=value**로 적는다.
+///
+/// 이 문법을 고른 근거: comrak이 info 문자열을 첫 공백에서 언어/메타로 가르므로
+/// 공백 뒤 key=value가 자연스럽고, Zola식 `rust,hl_lines=2-4`는 콤마까지 언어
+/// 토큰에 붙어 문법 조회와 `language-` 클래스를 오염시킨다. hwaro식 `{}`는 같은
+/// 위치에서 괄호만 더한다. 값에 공백은 못 쓴다 — 필요해지면 따옴표를 그때 더한다.
+#[derive(Debug, Default, PartialEq)]
+struct FenceOpts {
+ /// 강조할 줄들, 1부터 시작하는 닫힌 구간.
+ hl_lines: Vec<(usize, usize)>,
+ /// 파일명 라벨. `data-name` 속성으로만 나가고, 보여줄지는 사이트 CSS가 정한다.
+ name: Option<String>,
+}
+
+impl FenceOpts {
+ fn parse(meta: &str) -> Result<Self, String> {
+ let mut opts = Self::default();
+ for token in meta.split_whitespace() {
+ let Some((key, value)) = token.split_once('=') else {
+ return Err(format!(
+ "펜스 옵션 `{token}`: key=value 꼴이어야 합니다 (예: hl_lines=2-4,7 name=main.rs)"
+ ));
+ };
+ match key {
+ // 같은 키가 두 번 오면 앞의 것이 소리 없이 버려진다 — 편집
+ // 잔재가 살아남는 정확히 그 경로라서 에러다.
+ "hl_lines" if !opts.hl_lines.is_empty() => {
+ return Err(format!("펜스 옵션 `{key}` 가 두 번 왔습니다"));
+ }
+ "name" if opts.name.is_some() => {
+ return Err(format!("펜스 옵션 `{key}` 가 두 번 왔습니다"));
+ }
+ "hl_lines" => opts.hl_lines = parse_ranges(value)?,
+ "name" => opts.name = Some(value.to_string()),
+ _ => {
+ return Err(format!(
+ "펜스 옵션 `{key}` 는 없습니다. 사용 가능: hl_lines, name"
+ ));
+ }
+ }
}
+ Ok(opts)
}
}
+/// `2-4,7` → `[(2,4), (7,7)]`
+fn parse_ranges(value: &str) -> Result<Vec<(usize, usize)>, String> {
+ let mut out = Vec::new();
+ for part in value.split(',') {
+ let (a, b) = part.split_once('-').unwrap_or((part, part));
+ let (a, b) = (parse_line_no(a, part)?, parse_line_no(b, part)?);
+ if a > b {
+ return Err(format!("hl_lines `{part}`: 시작이 끝보다 큽니다"));
+ }
+ out.push((a, b));
+ }
+ Ok(out)
+}
+
+fn parse_line_no(s: &str, part: &str) -> Result<usize, String> {
+ match s.parse::<usize>() {
+ Ok(n) if n >= 1 => Ok(n),
+ _ => Err(format!(
+ "hl_lines `{part}`: 줄 번호는 1부터 시작하는 정수여야 합니다"
+ )),
+ }
+}
+
+/// 줄 시작 시점에 열려 있는 스코프를 그 줄 안에서 다시 연다.
+///
+/// syntect의 `scope_to_classes`는 비공개라, 공개 API인 `Scope::build_string()`의
+/// atom들로 같은 클래스 목록을 재구성한다. atom에는 `.`이 들어갈 수 없으므로
+/// split이 정확히 역연산이다. 재구성이 어긋나면 아래 여러 줄 주석 테스트가 깨진다.
+fn open_scope_span(out: &mut String, scope: Scope) {
+ out.push_str("<span class=\"");
+ let dotted = scope.build_string();
+ for (i, atom) in dotted.split('.').enumerate() {
+ if i != 0 {
+ out.push(' ');
+ }
+ out.push_str(CLASS_PREFIX);
+ out.push_str(atom);
+ }
+ out.push_str("\">");
+}
+
impl Default for Highlighter {
fn default() -> Self {
Self::new()
}
}
+impl Highlighter {
+ /// 옵션 없는 펜스의 경로. 기존 출력과 바이트 단위로 같아야 한다.
+ fn write_block(
+ &self,
+ out: &mut dyn fmt::Write,
+ syntax: &SyntaxReference,
+ code: &str,
+ ) -> fmt::Result {
+ let mut generator =
+ ClassedHTMLGenerator::new_with_class_style(syntax, self.syntaxes, CLASS_STYLE);
+ for line in LinesWithEndings::from(code) {
+ // 강조에 실패해도 문서 빌드를 통째로 죽이지 않는다. 이 블록만 포기하고
+ // 이스케이프된 원문을 내보낸다.
+ if generator
+ .parse_html_for_line_which_includes_newline(line)
+ .is_err()
+ {
+ return write_plain(out, code);
+ }
+ }
+ out.write_str(&generator.finalize())
+ }
+
+ /// `hl_lines`가 있을 때의 경로. 표시할 줄을 `<mark>`로 감싸려면 각 줄이 span
+ /// 균형이 맞아야 하는데, `ClassedHTMLGenerator`는 span을 줄 경계 너머로 열어
+ /// 둔다(마지막 `finalize`에서만 닫는다). 그래서 파스 상태는 줄을 넘겨 잇되
+ /// (여러 줄 주석·문자열이 올바르게 칠해진다), 각 줄은 시작에 현재 열려 있는
+ /// 스코프를 다시 열고 끝에서 전부 닫아 자립시킨다.
+ ///
+ /// `<mark>`인 이유: CSS 없이도 브라우저 기본 스타일로 표시가 보이고, 사이트는
+ /// `.highlight mark`로 덮어쓰면 된다.
+ fn write_lines(
+ &self,
+ out: &mut dyn fmt::Write,
+ syntax: &SyntaxReference,
+ code: &str,
+ hl: &[(usize, usize)],
+ ) -> fmt::Result {
+ let total = LinesWithEndings::from(code).count();
+ if let Some(&(a, b)) = hl.iter().find(|&&(_, b)| b > total) {
+ self.record_error(format!(
+ "hl_lines {a}-{b}: 이 코드 블록은 {total}줄뿐입니다"
+ ));
+ return self.write_block(out, syntax, code);
+ }
+
+ let mut parse_state = ParseState::new(syntax);
+ let mut stack = ScopeStack::new();
+ let mut html = String::new();
+ for (i, line) in LinesWithEndings::from(code).enumerate() {
+ let n = i + 1;
+ let Ok(ops) = parse_state.parse_line(line, self.syntaxes) else {
+ return write_plain(out, code);
+ };
+ let marked = hl.iter().any(|&(a, b)| (a..=b).contains(&n));
+ if marked {
+ html.push_str("<mark class=\"hl-line\">");
+ }
+ for &scope in stack.as_slice() {
+ open_scope_span(&mut html, scope);
+ }
+ let Ok((body, _)) = line_tokens_to_classed_spans(line, &ops, CLASS_STYLE, &mut stack)
+ else {
+ return write_plain(out, code);
+ };
+ html.push_str(&body);
+ // 이 줄에서 열린 span 수 = 줄 시작에 다시 연 것 + Push − Pop = 스택 크기.
+ for _ in 0..stack.len() {
+ html.push_str("</span>");
+ }
+ if marked {
+ html.push_str("</mark>");
+ }
+ }
+ out.write_str(&html)
+ }
+}
+
impl SyntaxHighlighterAdapter for Highlighter {
fn write_highlighted(
&self,
@@ -60,6 +251,7 @@ impl SyntaxHighlighterAdapter for Highlighter {
lang: Option<&str>,
code: &str,
) -> fmt::Result {
+ let opts = std::mem::take(&mut *self.pending.lock().unwrap_or_else(|e| e.into_inner()));
let syntax = lang
.and_then(|l| {
let l = l.trim();
@@ -69,19 +261,11 @@ impl SyntaxHighlighterAdapter for Highlighter {
})
.unwrap_or_else(|| self.syntaxes.find_syntax_plain_text());
- let mut generator =
- ClassedHTMLGenerator::new_with_class_style(syntax, self.syntaxes, CLASS_STYLE);
- for line in LinesWithEndings::from(code) {
- // 강조에 실패해도 문서 빌드를 통째로 죽이지 않는다. 이 줄만 포기하고
- // 이스케이프된 원문을 내보낸다.
- if generator
- .parse_html_for_line_which_includes_newline(line)
- .is_err()
- {
- return write_plain(out, code);
- }
+ if opts.hl_lines.is_empty() {
+ self.write_block(out, syntax, code)
+ } else {
+ self.write_lines(out, syntax, code, &opts.hl_lines)
}
- out.write_str(&generator.finalize())
}
fn write_pre_tag(
@@ -101,16 +285,52 @@ impl SyntaxHighlighterAdapter for Highlighter {
// 복사 버튼이나 라벨이 클래스 문자열을 파싱하지 않아도 되게 한다.
let class = attributes.get("class").map(|c| c.as_ref()).unwrap_or("");
let lang = class.strip_prefix("language-").unwrap_or("");
- if lang.is_empty() {
- out.write_str("<code>")
+
+ // 언어 이름에 `=`는 없다 — 언어 자리의 `=`는 언어를 빼먹고 옵션부터
+ // 적었다는 확실한 증거다. comrak이 첫 토큰을 언어로 삼아 버려 아래
+ // 옵션 파서에는 도달하지 않으므로, 여기서 잡지 않으면 옵션이 조용히
+ // 증발한 페이지가 배포된다.
+ if lang.contains('=') {
+ self.record_error(format!(
+ "펜스 언어 자리에 `{lang}`: 옵션은 언어 뒤에 옵니다 (언어가 없으면 ```text {lang})"
+ ));
+ }
+
+ // 펜스 옵션은 comrak이 `data-meta`로 넘겨준다(언어 뒤 공백 이후 전부,
+ // `full_info_string`). 여기서 파싱해 두면 바로 다음의 write_highlighted가
+ // 꺼내 쓴다. 잘못된 옵션은 기록만 하고 계속 간다 — 렌더가 끝난 뒤 빌드가
+ // 페이지 경로와 함께 실패한다.
+ let meta = attributes
+ .get("data-meta")
+ .map(|m| m.as_ref())
+ .unwrap_or("");
+ let opts = if meta.is_empty() {
+ FenceOpts::default()
} else {
- write!(
- out,
- "<code class=\"{}\" data-lang=\"{}\">",
- escape_attr(class),
- escape_attr(lang)
- )
+ match FenceOpts::parse(meta) {
+ Ok(o) => o,
+ Err(e) => {
+ self.record_error(e);
+ FenceOpts::default()
+ }
+ }
+ };
+
+ if lang.is_empty() {
+ *self.pending.lock().unwrap_or_else(|e| e.into_inner()) = opts;
+ return out.write_str("<code>");
+ }
+ write!(
+ out,
+ "<code class=\"{}\" data-lang=\"{}\"",
+ escape_attr(class),
+ escape_attr(lang)
+ )?;
+ if let Some(name) = &opts.name {
+ write!(out, " data-name=\"{}\"", escape_attr(name))?;
}
+ *self.pending.lock().unwrap_or_else(|e| e.into_inner()) = opts;
+ out.write_str(">")
}
}
@@ -219,6 +439,139 @@ mod tests {
);
}
+ #[test]
+ fn hl_lines_wraps_only_the_marked_lines() {
+ let html = render("```rust hl_lines=2\nlet a = 1;\nlet b = 2;\nlet c = 3;\n```");
+ assert_eq!(
+ html.matches("<mark class=\"hl-line\">").count(),
+ 1,
+ "실제: {html}"
+ );
+ let marked = html
+ .split("<mark class=\"hl-line\">")
+ .nth(1)
+ .unwrap()
+ .split("</mark>")
+ .next()
+ .unwrap();
+ // 문자로 판정하면 클래스명(hl-source, hl-constant)의 글자에 걸린다 —
+ // 클래스명에 없는 숫자 리터럴로 어느 줄이 감싸였는지 본다.
+ assert!(marked.contains('2'), "2번 줄이 아니다: {marked}");
+ assert!(!marked.contains('1'), "1번 줄까지 감쌌다: {marked}");
+ assert!(!marked.contains('3'), "3번 줄까지 감쌌다: {marked}");
+ }
+
+ #[test]
+ fn hl_lines_reopens_scopes_across_line_boundaries() {
+ // 여러 줄 주석의 가운데 줄만 표시해도 주석 스코프가 그 줄 안에서 다시
+ // 열려야 한다. syntect의 scope_to_classes가 비공개라 Scope::build_string()
+ // 으로 재구성하는데, 그 재구성이 어긋나면 이 테스트가 깨진다.
+ let html = render("```rust hl_lines=2\n/* first\nsecond\nthird */\n```");
+ let marked = html
+ .split("<mark class=\"hl-line\">")
+ .nth(1)
+ .unwrap()
+ .split("</mark>")
+ .next()
+ .unwrap();
+ assert!(marked.contains("second"), "실제: {marked}");
+ assert!(
+ marked.contains("hl-comment"),
+ "주석 스코프가 안 열렸다: {marked}"
+ );
+ assert_eq!(
+ marked.matches("<span").count(),
+ marked.matches("</span>").count(),
+ "표시 줄의 span이 균형이 안 맞는다: {marked}"
+ );
+ }
+
+ #[test]
+ fn name_lands_as_an_escaped_attribute() {
+ let html = render("```rust name=main.rs\nfn main() {}\n```");
+ assert!(html.contains(r#"data-name="main.rs""#), "실제: {html}");
+ }
+
+ #[test]
+ fn a_fence_without_options_is_byte_identical_to_before() {
+ let html = render("```rust\nlet x = 1;\n```");
+ assert!(!html.contains("<mark"), "옵션 없는 펜스에 mark: {html}");
+ assert!(!html.contains("data-name"), "실제: {html}");
+ assert!(
+ !html.contains("data-meta"),
+ "메타 속성이 새어 나왔다: {html}"
+ );
+ }
+
+ #[test]
+ fn unknown_fence_option_is_an_error_not_a_silent_noop() {
+ let r = Renderer::new(&MarkdownConfig::default()).with_highlighter(Highlighter::new());
+ let rendered = r.render("```rust linenos=true\nfn main() {}\n```");
+ assert_eq!(
+ rendered.bad_fences.len(),
+ 1,
+ "실제: {:?}",
+ rendered.bad_fences
+ );
+ assert!(rendered.bad_fences[0].contains("linenos"));
+ }
+
+ #[test]
+ fn hl_lines_beyond_the_block_is_an_error() {
+ let r = Renderer::new(&MarkdownConfig::default()).with_highlighter(Highlighter::new());
+ let rendered = r.render("```rust hl_lines=9\nfn main() {}\n```");
+ assert_eq!(
+ rendered.bad_fences.len(),
+ 1,
+ "실제: {:?}",
+ rendered.bad_fences
+ );
+ }
+
+ #[test]
+ fn options_in_the_language_slot_are_an_error() {
+ // 언어를 빼먹으면 comrak이 첫 토큰을 언어로 삼아 옵션 파서에 도달하지
+ // 않는다. 그대로 두면 "오타는 빌드 에러" 계약이 이 입력에서만 샌다.
+ let r = Renderer::new(&MarkdownConfig::default()).with_highlighter(Highlighter::new());
+ let rendered = r.render("```hl_lines=2\nlet a = 1;\nlet b = 2;\n```");
+ assert_eq!(
+ rendered.bad_fences.len(),
+ 1,
+ "실제: {:?}",
+ rendered.bad_fences
+ );
+ assert!(rendered.bad_fences[0].contains("hl_lines=2"));
+
+ // 반쪽 적용 변형: hl_lines=2가 언어로 먹히고 name만 파싱되는 경우도
+ // 같은 에러여야 한다 — data-name이 붙은 출력이 성공처럼 보이기 때문.
+ let rendered = r.render("```hl_lines=2 name=a.rs\nlet a = 1;\nlet b = 2;\n```");
+ assert!(!rendered.bad_fences.is_empty());
+ }
+
+ #[test]
+ fn a_repeated_fence_option_is_an_error_not_last_wins() {
+ let r = Renderer::new(&MarkdownConfig::default()).with_highlighter(Highlighter::new());
+ let rendered = r.render("```rust hl_lines=2 hl_lines=1\nlet a = 1;\nlet b = 2;\n```");
+ assert_eq!(
+ rendered.bad_fences.len(),
+ 1,
+ "실제: {:?}",
+ rendered.bad_fences
+ );
+ assert!(rendered.bad_fences[0].contains("두 번"));
+ }
+
+ #[test]
+ fn fence_option_parser_accepts_ranges_and_rejects_nonsense() {
+ assert_eq!(
+ FenceOpts::parse("hl_lines=2-4,7").unwrap().hl_lines,
+ vec![(2, 4), (7, 7)]
+ );
+ assert!(FenceOpts::parse("hl_lines=0").is_err(), "0번 줄은 없다");
+ assert!(FenceOpts::parse("hl_lines=4-2").is_err(), "역순 범위");
+ assert!(FenceOpts::parse("no-equals-sign").is_err());
+ }
+
#[test]
fn two_face_provides_syntaxes_syntect_lacks() {
// two-face를 넣은 이유가 이것이다 — 개발 도구 문서에 반드시 필요한 언어들이
diff --git a/src/lib.rs b/src/lib.rs
@@ -415,6 +415,13 @@ fn render_page(
rendered.unresolved.join("\n ")
)));
}
+ if !rendered.bad_fences.is_empty() {
+ return Err(Kind::Content.tag()(anyhow::anyhow!(
+ "{}: 잘못된 코드 펜스 옵션이 있습니다:\n {}",
+ page.source.display(),
+ rendered.bad_fences.join("\n ")
+ )));
+ }
// 섹션 인덱스는 자기 자신이 섹션이라 라벨과 제목이 겹친다. 그때는 비워 둔다.
let section = if page.is_section {
diff --git a/src/markdown.rs b/src/markdown.rs
@@ -31,6 +31,9 @@ pub struct Rendered {
/// 해석하지 못한 `@/` 링크. 비어 있지 않으면 빌드를 멈춰야 한다 —
/// 깨진 링크를 조용히 배포하는 건 이 도구가 막겠다고 한 바로 그 부류다.
pub unresolved: Vec<String>,
+ /// 잘못된 코드 펜스 옵션. 링크와 같은 계약이다 — 모르는 키·틀린 범위를
+ /// 조용히 무시하면 오타가 강조 없는 채로 배포된다.
+ pub bad_fences: Vec<String>,
}
pub struct Renderer {
@@ -92,6 +95,10 @@ impl Renderer {
o.render.r#unsafe = true;
// false여야 `<code class="language-rust">`가 나온다. true면 `<pre lang="rust">`다.
o.render.github_pre_lang = false;
+ // 펜스 정보 문자열의 언어 뒤쪽(hl_lines 등)을 어댑터가 data-meta로 받게 한다.
+ // 하이라이터가 있을 때만 — 강조를 끈 사이트에서는 검증 없는 메타가 출력에
+ // 그대로 새므로, 펜스 옵션 자체를 강조 기능의 일부로 묶는다.
+ o.render.full_info_string = self.highlighter.is_some();
o
}
@@ -141,9 +148,15 @@ impl Renderer {
.as_ref()
.map(|r| r.take_unresolved())
.unwrap_or_default();
+ let bad_fences = self
+ .highlighter
+ .as_ref()
+ .map(|h| h.take_errors())
+ .unwrap_or_default();
Rendered {
unresolved,
+ bad_fences,
html,
text,
toc: headings.into_toc(),
|