diff --git a/docs/templates/base.html b/docs/templates/base.html
@@ -1,4 +1,5 @@
{% from "partials/sidebar.html" import nav %}
+{% from "partials/toc.html" import toc_list %}
<!doctype html>
<html lang="{{ page.language }}">
<head>
@@ -24,6 +25,12 @@
<main id="content">
{% block content %}{% endblock %}
</main>
+
+ {%- if page.toc and page.toc_entries %}
+ <aside class="toc" aria-label="On this page">
+ {{ toc_list(page.toc_entries) }}
+ </aside>
+ {%- endif %}
</div>
{% include "partials/footer.html" %}
diff --git a/docs/templates/partials/toc.html b/docs/templates/partials/toc.html
@@ -0,0 +1,11 @@
+{# 목차. page.toc_entries는 항상 채워지고, 보일지 말지는 front matter의 toc가 정한다. #}
+{% macro toc_list(entries) %}
+<ul>
+ {%- for e in entries %}
+ <li>
+ <a href="#{{ e.id }}">{{ e.title }}</a>
+ {%- if e.children %}{{ toc_list(e.children) }}{% endif %}
+ </li>
+ {%- endfor %}
+</ul>
+{% endmacro %}
diff --git a/src/lib.rs b/src/lib.rs
@@ -102,15 +102,19 @@ fn render_page(
sections: site::section_ctx(site.sections(&page.language), site.pages),
};
+ let rendered = md.render(&page.body);
+
let page_ctx = PageCtx {
title: page.title.clone(),
description: page.front.description.clone(),
url: page.url.clone(),
permalink: format!("{}{}", cfg.base_url_trimmed(), page.url),
- content: md.to_html(&page.body),
+ content: rendered.html,
weight: page.front.weight,
draft: page.front.draft,
+ // `toc`는 "목차를 보여줄지"라는 저자의 의사, `toc_entries`는 실제 데이터다.
toc: page.front.toc,
+ toc_entries: rendered.toc,
language: page.language.clone(),
// 번역이 실제로 있는 언어만 담긴다 — 템플릿은 이게 비었는지로
// 언어 전환 UI를 보일지 결정하면 된다.
diff --git a/src/markdown.rs b/src/markdown.rs
@@ -1,88 +1,323 @@
-//! comrak 기반 마크다운 → HTML 변환.
+//! comrak 기반 마크다운 → HTML 변환, heading anchor, 목차 수집.
//!
-//! M1에서 syntect 하이라이터와 heading anchor는 `Options.render`의
-//! `codefence_syntax_highlighter` / `heading_adapter` 자리에 붙는다.
+//! 링크 재작성·anchor·TOC는 전부 comrak의 어댑터/AST 경로로 처리한다.
//! 최종 HTML에 정규식을 돌리는 방식은 쓰지 않는다 — 속성을 홑따옴표로 쓰거나
//! 따옴표를 빼면 조용히 처리에서 빠지는, 버그를 양산하는 구조다.
-use crate::config::Markdown as MarkdownConfig;
-use comrak::{Options, markdown_to_html};
+use crate::config::{HeadingAnchors, Markdown as MarkdownConfig};
+use comrak::adapters::{HeadingAdapter, HeadingMeta};
+use comrak::nodes::Sourcepos;
+use comrak::options::Plugins;
+use comrak::{Anchorizer, Options, markdown_to_html_with_plugins};
+use serde::Serialize;
+use std::fmt;
+use std::sync::Mutex;
-pub struct Renderer<'a> {
- options: Options<'a>,
+/// 목차 항목. 템플릿에는 중첩된 형태로 나간다.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct TocEntry {
+ pub level: u8,
+ pub id: String,
+ pub title: String,
+ pub children: Vec<TocEntry>,
}
-impl<'a> Renderer<'a> {
+pub struct Rendered {
+ pub html: String,
+ pub toc: Vec<TocEntry>,
+}
+
+pub struct Renderer {
+ cfg: MarkdownConfig,
+}
+
+impl Renderer {
pub fn new(cfg: &MarkdownConfig) -> Self {
+ Self { cfg: cfg.clone() }
+ }
+
+ fn options<'p>(&self) -> Options<'p> {
let mut o = Options::default();
+ let c = &self.cfg;
- o.extension.footnotes = cfg.footnotes;
- o.extension.table = cfg.tables;
- o.extension.tasklist = cfg.tasklist;
- o.extension.strikethrough = cfg.strikethrough;
- o.extension.autolink = cfg.autolink;
- o.extension.alerts = cfg.alerts;
+ o.extension.footnotes = c.footnotes;
+ o.extension.table = c.tables;
+ o.extension.tasklist = c.tasklist;
+ o.extension.strikethrough = c.strikethrough;
+ o.extension.autolink = c.autolink;
+ o.extension.alerts = c.alerts;
// ⚠️ 한국어에 필수. 끄면 `**강조**한다` 같은 구성이 강조로 파싱되지 않는다.
- o.extension.cjk_friendly_emphasis = cfg.cjk_friendly_emphasis;
+ o.extension.cjk_friendly_emphasis = c.cjk_friendly_emphasis;
// 우리 콘텐츠는 git에 들어있는 신뢰된 소스이므로 raw HTML을 허용한다.
- // (`<details>`, 임베드 등이 문서 사이트에 필요하다.)
// 신뢰되지 않은 소스를 다루게 되면 소스별 신뢰 등급으로 나눌 것.
o.render.r#unsafe = true;
// false여야 `<code class="language-rust">`가 나온다. true면 `<pre lang="rust">`다.
- // 전자가 Prism/highlight.js/Pagefind가 기대하는 관례이고, M1에서 syntect가
- // 붙기 전까지의 기본 출력도 이쪽이어야 CSS를 다시 안 쓴다.
o.render.github_pre_lang = false;
+ o
+ }
+
+ pub fn render(&self, body: &str) -> Rendered {
+ // 어댑터는 `Options`가 아니라 `Plugins`에 붙는다. 페이지마다 새 collector를
+ // 쓰는 게 중요하다 — heading id는 사이트가 아니라 **페이지 안에서** 유일해야
+ // 하므로 anchorizer의 dedupe 카운터도 페이지마다 초기화되어야 한다.
+ let headings = HeadingCollector::new(self.cfg.heading_anchors);
+ let options = self.options();
+
+ let html = {
+ let mut plugins = Plugins::default();
+ plugins.render.heading_adapter = Some(&headings);
+ markdown_to_html_with_plugins(body, &options, &plugins)
+ };
+
+ Rendered { html, toc: headings.into_toc() }
+ }
+}
+
+/// heading에 id를 붙이고 anchor 링크를 그리면서, 동시에 목차를 모은다.
+///
+/// **Anchorizer 인스턴스를 하나만 쓰는 게 핵심이다.** 같은 제목이 두 번 나오면
+/// `설치`, `설치-1`처럼 접미사가 붙는데, TOC와 heading이 서로 다른 인스턴스를 쓰면
+/// 두 쪽의 id가 어긋나 목차 링크가 죽는다.
+struct HeadingCollector {
+ anchors: HeadingAnchors,
+ state: Mutex<CollectorState>,
+}
+
+#[derive(Default)]
+struct CollectorState {
+ anchorizer: Anchorizer,
+ flat: Vec<(u8, String, String)>, // (level, id, title)
+ /// `enter`에서 만든 id를 `exit`가 다시 써야 한다. 다시 anchorize하면
+ /// dedupe 카운터가 올라가 서로 다른 id가 나온다.
+ current_id: Option<String>,
+}
+
+impl HeadingCollector {
+ fn new(anchors: HeadingAnchors) -> Self {
+ Self { anchors, state: Mutex::new(CollectorState::default()) }
+ }
+
+ fn into_toc(self) -> Vec<TocEntry> {
+ let flat = self.state.into_inner().unwrap_or_else(|e| e.into_inner()).flat;
+ nest(&flat)
+ }
+}
- Self { options: o }
+impl HeadingAdapter for HeadingCollector {
+ fn enter(
+ &self,
+ out: &mut dyn fmt::Write,
+ heading: &HeadingMeta,
+ _sourcepos: Option<Sourcepos>,
+ ) -> fmt::Result {
+ let id = {
+ let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
+ let id = st.anchorizer.anchorize(&heading.content);
+ st.flat.push((heading.level, id.clone(), heading.content.clone()));
+ st.current_id = Some(id.clone());
+ id
+ };
+
+ // anchor 표시가 꺼져 있어도 id는 항상 넣는다 — 목차와 딥링크가 이걸 쓴다.
+ write!(out, "<h{} id=\"{}\">", heading.level, attr_escape(&id))?;
+ if self.anchors == HeadingAnchors::Left {
+ write_anchor(out, &id)?;
+ }
+ Ok(())
}
- pub fn to_html(&self, body: &str) -> String {
- markdown_to_html(body, &self.options)
+ fn exit(&self, out: &mut dyn fmt::Write, heading: &HeadingMeta) -> fmt::Result {
+ if self.anchors == HeadingAnchors::Right {
+ let id = self
+ .state
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .current_id
+ .clone()
+ .unwrap_or_default();
+ write_anchor(out, &id)?;
+ }
+ write!(out, "</h{}>", heading.level)
+ }
+}
+
+fn write_anchor(out: &mut dyn fmt::Write, id: &str) -> fmt::Result {
+ // aria-hidden + tabindex=-1: 스크린리더와 키보드 탐색에 잡음을 더하지 않는다.
+ write!(
+ out,
+ "<a class=\"anchor\" href=\"#{}\" aria-hidden=\"true\" tabindex=\"-1\">#</a>",
+ attr_escape(id)
+ )
+}
+
+/// 속성값 이스케이프. id는 Anchorizer가 만들지만 방어적으로 처리한다.
+fn attr_escape(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for ch in s.chars() {
+ match ch {
+ '&' => out.push_str("&"),
+ '<' => out.push_str("<"),
+ '>' => out.push_str(">"),
+ '"' => out.push_str("""),
+ '\'' => out.push_str("'"),
+ _ => out.push(ch),
+ }
+ }
+ out
+}
+
+/// 평면 heading 목록을 레벨에 따라 중첩시킨다.
+///
+/// 문서가 h2에서 h4로 건너뛰는 건 흔한 일이므로, 레벨이 연속이라고 가정하지 않고
+/// "직전보다 깊으면 자식"으로만 판단한다.
+fn nest(flat: &[(u8, String, String)]) -> Vec<TocEntry> {
+ let mut roots: Vec<TocEntry> = Vec::new();
+ // 현재 열려 있는 조상들의 경로 (인덱스 스택)
+ let mut stack: Vec<u8> = Vec::new();
+
+ for (level, id, title) in flat {
+ let entry = TocEntry {
+ level: *level,
+ id: id.clone(),
+ title: title.clone(),
+ children: Vec::new(),
+ };
+
+ while stack.last().is_some_and(|&l| l >= *level) {
+ stack.pop();
+ }
+
+ if stack.is_empty() {
+ roots.push(entry);
+ } else {
+ // 스택 깊이만큼 내려가 마지막 자식에 붙인다
+ let mut cur = roots.last_mut().expect("스택이 비지 않았으면 루트가 있다");
+ for _ in 1..stack.len() {
+ cur = cur.children.last_mut().expect("경로가 유효해야 한다");
+ }
+ cur.children.push(entry);
+ }
+ stack.push(*level);
}
+
+ roots
}
#[cfg(test)]
mod tests {
use super::*;
- fn renderer() -> Renderer<'static> {
+ fn renderer() -> Renderer {
Renderer::new(&MarkdownConfig::default())
}
#[test]
fn renders_basic_markdown() {
- let html = renderer().to_html("# Hello\n\nWorld.");
- assert!(html.contains("<h1>Hello</h1>"), "실제: {html}");
- assert!(html.contains("<p>World.</p>"), "실제: {html}");
+ let r = renderer().render("# Hello\n\nWorld.");
+ assert!(r.html.contains("Hello"), "실제: {}", r.html);
+ assert!(r.html.contains("<p>World.</p>"), "실제: {}", r.html);
}
#[test]
fn korean_emphasis_attached_to_particle_parses() {
// cjk_friendly_emphasis가 꺼져 있으면 이게 <em>으로 파싱되지 않는다.
// 한국어 문서에서 매우 흔한 형태라 회귀하면 바로 알아야 한다.
- let html = renderer().to_html("**정적 사이트 생성기**를 만들었다.");
+ let r = renderer().render("**정적 사이트 생성기**를 만들었다.");
assert!(
- html.contains("<strong>정적 사이트 생성기</strong>"),
- "실제: {html}"
+ r.html.contains("<strong>정적 사이트 생성기</strong>"),
+ "실제: {}",
+ r.html
);
}
#[test]
fn code_fence_carries_language_class() {
- let html = renderer().to_html("```rust\nfn main() {}\n```");
- assert!(html.contains("language-rust"), "실제: {html}");
+ let r = renderer().render("```rust\nfn main() {}\n```");
+ assert!(r.html.contains("language-rust"), "실제: {}", r.html);
}
#[test]
fn gfm_table_renders() {
- let html = renderer().to_html("| a | b |\n|---|---|\n| 1 | 2 |");
- assert!(html.contains("<table>"), "실제: {html}");
+ let r = renderer().render("| a | b |\n|---|---|\n| 1 | 2 |");
+ assert!(r.html.contains("<table>"), "실제: {}", r.html);
}
#[test]
fn raw_html_passes_through() {
- let html = renderer().to_html("<details><summary>x</summary>y</details>");
- assert!(html.contains("<details>"), "실제: {html}");
+ let r = renderer().render("<details><summary>x</summary>y</details>");
+ assert!(r.html.contains("<details>"), "실제: {}", r.html);
+ }
+
+ #[test]
+ fn headings_get_ids_and_anchors() {
+ let r = renderer().render("## Getting started");
+ assert!(
+ r.html.contains(r#"<h2 id="getting-started">"#),
+ "실제: {}",
+ r.html
+ );
+ assert!(r.html.contains(r##"href="#getting-started""##), "실제: {}", r.html);
+ }
+
+ #[test]
+ fn korean_headings_keep_hangul_ids() {
+ let r = renderer().render("## 시작하기\n\n### 설치");
+ assert!(r.html.contains(r#"<h2 id="시작하기">"#), "실제: {}", r.html);
+ assert!(r.html.contains(r#"<h3 id="설치">"#), "실제: {}", r.html);
+ }
+
+ #[test]
+ fn duplicate_headings_get_distinct_ids_shared_with_toc() {
+ // heading과 TOC가 서로 다른 Anchorizer를 쓰면 여기서 어긋난다.
+ let r = renderer().render("## 설치\n\n## 설치");
+ assert!(r.html.contains(r#"id="설치""#), "실제: {}", r.html);
+ assert!(r.html.contains(r#"id="설치-1""#), "실제: {}", r.html);
+ let ids: Vec<&str> = r.toc.iter().map(|e| e.id.as_str()).collect();
+ assert_eq!(ids, vec!["설치", "설치-1"]);
+ }
+
+ #[test]
+ fn toc_nests_by_level() {
+ let r = renderer().render(
+ "# A\n\n## A1\n\n### A1a\n\n## A2\n\n# B\n",
+ );
+ assert_eq!(r.toc.len(), 2, "최상위는 A와 B: {:?}", r.toc);
+ assert_eq!(r.toc[0].title, "A");
+ assert_eq!(r.toc[0].children.len(), 2);
+ assert_eq!(r.toc[0].children[0].title, "A1");
+ assert_eq!(r.toc[0].children[0].children[0].title, "A1a");
+ assert_eq!(r.toc[0].children[1].title, "A2");
+ assert_eq!(r.toc[1].title, "B");
+ }
+
+ #[test]
+ fn toc_survives_skipped_levels() {
+ // h2 다음에 h4로 뛰는 문서는 흔하다. 레벨이 연속이라고 가정하면 여기서 깨진다.
+ let r = renderer().render("## Top\n\n#### Deep\n");
+ assert_eq!(r.toc.len(), 1);
+ assert_eq!(r.toc[0].children.len(), 1);
+ assert_eq!(r.toc[0].children[0].title, "Deep");
+ }
+
+ #[test]
+ fn heading_content_is_flattened_for_the_toc() {
+ let r = renderer().render("## This is **bold**");
+ assert_eq!(r.toc[0].title, "This is bold");
+ }
+
+ #[test]
+ fn anchors_can_be_disabled_but_ids_remain() {
+ let mut cfg = MarkdownConfig::default();
+ cfg.heading_anchors = HeadingAnchors::None;
+ let r = Renderer::new(&cfg).render("## Title");
+ assert!(r.html.contains(r#"<h2 id="title">"#), "실제: {}", r.html);
+ assert!(
+ !r.html.contains("class=\"anchor\""),
+ "anchor를 껐는데 나왔다: {}",
+ r.html
+ );
+ // 목차는 여전히 동작해야 한다
+ assert_eq!(r.toc[0].id, "title");
}
}
diff --git a/src/render.rs b/src/render.rs
@@ -175,7 +175,10 @@ pub struct PageCtx {
pub content: String,
pub weight: i64,
pub draft: bool,
+ /// front matter의 `toc` — 목차를 렌더할지에 대한 저자의 의사.
pub toc: bool,
+ /// 실제 목차 데이터. `toc = false`여도 채워진다(템플릿이 판단하게 둔다).
+ pub toc_entries: Vec<crate::markdown::TocEntry>,
pub language: String,
/// 다른 언어판. **번역이 실제로 있을 때만** 채워진다.
pub translations: Vec<crate::site::LanguageLink>,
|