sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git
commit 0f378562efa1880f57c9960971f3ec6a6af21421
parent 70d7c2c01c68b9ba072a44443d083672f6f8ac3b
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Tue, 28 Jul 2026 15:40:29 +0900

코드 펜스에 hl_lines= 와 name= 옵션을 단다

문법은 언어 뒤 공백 구분 key=value다: ```rust hl_lines=2-4,7 name=main.rs.
comrak이 info 문자열을 첫 공백에서 언어/메타로 가르므로 거기가 자연스러운
자리고, Zola식 `rust,hl_lines=…`는 콤마까지 언어 토큰에 붙어 문법 조회와
language- 클래스를 오염시킨다.

hl_lines 줄은 <mark class="hl-line">로 감싼다 — CSS가 하나도 없어도
브라우저 기본 스타일로 표시가 보이는 요소라서다. ClassedHTMLGenerator는
span을 줄 경계 너머로 열어 두므로 그대로는 줄을 감쌀 수 없다. 파스 상태는
줄을 넘겨 잇되(여러 줄 주석이 올바르게 칠해진다) 각 줄 시작에 열려 있는
스코프를 다시 열고 끝에서 전부 닫아 자립시킨다. 스코프→클래스 변환은
syntect가 비공개라 Scope::build_string()의 atom으로 재구성한다. name은
data-name 속성으로만 나가고 보여줄지는 사이트 CSS가 정한다.

잘못된 옵션은 빌드를 멈춘다. 모르는 키를 조용히 무시하면 hl_line= 오타가
강조 없는 채로 배포된다 — 깨진 링크와 같은 계약이다. 적대적 검증이 이
계약의 구멍 셋을 찾아 같이 막았다:

- 언어 없이 옵션부터 쓴 펜스(```hl_lines=2)는 comrak이 첫 토큰을 언어로
삼아 옵션 파서에 도달조차 안 했고, name과 같이 쓰면 반쪽만 적용됐다.
언어 이름에 =는 없으므로 언어 자리의 =를 에러로 잡는다 — 오탐이 있을
수 없는 구분이다.
- 같은 키가 두 번 오면 앞의 것이 소리 없이 버려졌다. 에러로 바꾼다.
- 표시 줄의 배경과 라벨 바가 auto 폭이라, 스크롤되는 긴 줄에서 보이는 폭
너머로 넘긴 순간 표시가 사라진 채 줄의 나머지가 보였다 — 어느 줄이
강조인지 반대로 읽게 만든다. code를 max-content로 넓혀 가장 긴 줄까지
칠하고, 문서의 CSS 레시피에도 같은 규칙을 넣었다.

Diffstat:
Mdocs/content/features/highlighting.ko.md | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mdocs/content/features/highlighting.md | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mdocs/static/css/main.css | 32++++++++++++++++++++++++++++++++
Msrc/highlight.rs | 399++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/lib.rs | 7+++++++
Msrc/markdown.rs | 13+++++++++++++
6 files changed, 572 insertion(+), 47 deletion(-)

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(),