sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git
commit 715d0e3d69d101c6823871577a6f4793fb3cdbb8
parent 4d4a1d859738f0c549b172b25a3e90d1746dde6a
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 23:45:53 +0900

Emit 404.html and redirect stubs, and check absolute links too

Three things a static host will not do for you, which is the same reason pretty
URLs and cache invalidation are done here.

`templates/404.html` now renders to `/404.html`. GitHub Pages, Netlify,
Cloudflare and Codeberg all serve that path by name, and `content/404.md`
produces `/404/index.html`, which none of them look for. A hand-written
`static/404.html` was the only alternative and it gets no layout, no `t()` and
no way to name the hashed stylesheet. The tool renders it only when the
template exists — the same contract as the highlight stylesheet — and it stays
one named branch rather than a general render-any-template mechanism, because
the moment that is general it stops being obvious who owns an output URL.

`aliases` in front matter emits a meta-refresh stub per old URL, with a
canonical pointing at the real page and `noindex` so the stub never competes
with it in search results. Renaming a page is the most ordinary event on a live
site and it silently kills every inbound link and bookmark; on a host with no
redirect rules there is nothing else to reach for. An alias that collides with
a real page or another alias is an error naming both, and an alias that is an
external URL or not root-absolute is rejected rather than quietly written
somewhere surprising.

"Broken references stop the build" was, until now, true only of `@/`. The link
a writer most naturally types is `[install](/start/install/)`, and that was
never checked at all — no build error, nothing from doctor, a 404 in
production. Both sides were already in memory at render time: the page URL set
and the asset manifest. Anchors, queries, external URLs, protocol-relative URLs
and bare fragments all pass; only a path this site does not emit fails.

That check also catches `/css/main.css`, correctly — fingerprinting means no
file is at that path once the site is built. The message says so, and points at
`asset()`, because the reader can see the file in `static/` and would otherwise
conclude the checker is wrong.

`llms.txt` comes out of data we already had. The build also stopped counting
redirect stubs and 404.html as pages; reporting "5 pages" for a two-page site
was a small lie with an easy fix.

Diffstat:
Mdocs/i18n/en.toml | 3+++
Mdocs/i18n/ko.toml | 3+++
Adocs/templates/404.html | 17+++++++++++++++++
Mdocs/templates/base.html | 3++-
Msrc/content.rs | 2++
Asrc/emit.rs | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 124++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/links.rs | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/markdown.rs | 18+++++++++++++-----
Msrc/serve.rs | 2+-
10 files changed, 416 insertion(+), 17 deletion(-)

diff --git a/docs/i18n/en.toml b/docs/i18n/en.toml
@@ -14,3 +14,6 @@ built_with      = "Built with sqzass"
 pagination      = "Pagination"
 previous        = "Previous"
 next            = "Next"
+
+not_found_title = "Page not found"
+not_found_body  = "That page does not exist, or it moved and left no forwarding address. Everything in this documentation is below, and Ctrl+/ searches it."
diff --git a/docs/i18n/ko.toml b/docs/i18n/ko.toml
@@ -14,3 +14,6 @@ built_with      = "sqzass로 빌드됨"
 pagination      = "이어 보기"
 previous        = "이전"
 next            = "다음"
+
+not_found_title = "페이지를 찾을 수 없습니다"
+not_found_body  = "그런 페이지가 없거나, 옮겨 가면서 안내를 남기지 않았습니다. 이 문서의 전부가 아래에 있고, Ctrl+/ 로 검색할 수 있습니다."
diff --git a/docs/templates/404.html b/docs/templates/404.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+
+{#- 호스트가 이름으로 찾는 파일이라 URL이 /404.html로 고정돼 있다. 어느 깊이의
+    경로에서 이 페이지가 뜰지 모르므로 안의 링크는 전부 루트 절대 경로여야 한다 —
+    site.sections가 주는 URL이 이미 그 형태다. #}
+{% block heading %}{{ t("not_found_title") }}{% endblock %}
+
+{% block content %}
+<div class="prose">
+<p>{{ t("not_found_body") }}</p>
+<ul>
+  {%- for s in site.sections %}
+  <li><a href="{{ s.url }}">{{ s.title }}</a></li>
+  {%- endfor %}
+</ul>
+</div>
+{% endblock %}
diff --git a/docs/templates/base.html b/docs/templates/base.html
@@ -77,7 +77,8 @@
         <a href="{{ page.section.url }}">{{ page.section.title }}</a>
       </nav>
       {%- endif %}
-      <h1>{{ page.title }}</h1>
+      {#- 페이지가 아닌 템플릿(404)은 자기 제목을 갖는다. #}
+      <h1>{% block heading %}{{ page.title }}{% endblock %}</h1>
       {%- if page.description %}
       <p class="lede">{{ page.description }}</p>
       {%- endif %}
diff --git a/src/content.rs b/src/content.rs
@@ -26,6 +26,8 @@ pub struct FrontMatter {
     pub toc: bool,
     /// 기본은 파일명 stem 추론.
     pub translation_key: Option<String>,
+    /// 이 페이지로 보낼 예전 URL들. 루트 절대 경로여야 한다.
+    pub aliases: Vec<String>,
 
     // --- 섹션(`_index.md`) 전용 ---
     pub sort_by: Option<crate::config::SortBy>,
diff --git a/src/emit.rs b/src/emit.rs
@@ -0,0 +1,157 @@
+//! 페이지 말고도 빌드가 내보내는 것들 — 리다이렉트 스텁, 404, llms.txt.
+//!
+//! 전부 "호스트가 안 해 주니 우리가 한다"는 같은 이유에서 나온다. GitHub Pages에는
+//! 리다이렉트 규칙이 없고, 404 페이지는 이름으로 찾으며, 그 둘 다 빌드가 만들지
+//! 않으면 아무도 만들어 주지 않는다.
+
+use crate::content::Page;
+use anyhow::{Result, bail};
+
+/// 호스트들이 이름으로 찾는 파일. `/404/index.html`은 아무도 찾지 않는다.
+pub const NOT_FOUND_PATH: &str = "404.html";
+pub const LLMS_PATH: &str = "llms.txt";
+
+/// `aliases` 항목 하나를 출력 경로로 바꾼다.
+///
+/// 스킴이 붙었거나 루트 절대 경로가 아니면 에러다. 상대 경로를 허용하면 "무엇에
+/// 상대적인가"라는 질문이 생기고, 그 답이 파일 위치인지 URL인지 사람마다 다르게
+/// 읽는다.
+pub fn alias_output_path(alias: &str, page: &Page) -> Result<String> {
+    if alias.contains("://") || alias.starts_with("//") {
+        bail!(
+            "{}: alias `{alias}` 는 외부 URL입니다. alias는 이 사이트 안의 \
+             루트 절대 경로여야 합니다 (예: `/old-name/`).",
+            page.source.display()
+        );
+    }
+    if !alias.starts_with('/') {
+        bail!(
+            "{}: alias `{alias}` 가 `/`로 시작하지 않습니다. \
+             alias는 루트 절대 경로여야 합니다 (예: `/old-name/`).",
+            page.source.display()
+        );
+    }
+
+    let trimmed = alias.trim_matches('/');
+    if trimmed.is_empty() {
+        bail!(
+            "{}: alias `{alias}` 는 사이트 루트를 가리킵니다. \
+             루트는 `_index.md`가 이미 차지하고 있습니다.",
+            page.source.display()
+        );
+    }
+    Ok(format!("{trimmed}/index.html"))
+}
+
+/// meta-refresh 리다이렉트 스텁.
+///
+/// 정적 호스트에서 쓸 수 있는 유일한 수단이다. canonical로 원본을 가리키고
+/// `noindex`를 붙여, 검색 엔진이 스텁 자체를 색인하지 않게 한다. 본문에 링크를
+/// 두는 건 자동 이동이 막힌 환경(스크린리더 설정, 확장 프로그램)을 위한 것이다.
+pub fn redirect_stub(target_url: &str, permalink: &str) -> String {
+    format!(
+        "<!doctype html>\n\
+         <html lang=\"en\">\n\
+         <head>\n\
+         <meta charset=\"utf-8\">\n\
+         <title>Redirecting…</title>\n\
+         <link rel=\"canonical\" href=\"{permalink}\">\n\
+         <meta name=\"robots\" content=\"noindex\">\n\
+         <meta http-equiv=\"refresh\" content=\"0; url={target_url}\">\n\
+         </head>\n\
+         <body><p>이 페이지는 <a href=\"{target_url}\">{target_url}</a> 로 옮겨졌습니다.</p></body>\n\
+         </html>\n"
+    )
+}
+
+/// `llms.txt` — 언어 모델이 사이트 전체를 훑지 않고도 구조를 알 수 있는 목록.
+///
+/// [제안 규격](https://llmstxt.org)은 제목, 요약, 그리고 링크 목록이다. 우리는 이미
+/// 제목·설명·URL을 전부 갖고 있으므로 새로 만들 데이터가 없다.
+pub fn llms_txt(title: &str, description: &str, origin: &str, pages: &[Page]) -> String {
+    let mut out = format!("# {title}\n");
+    if !description.is_empty() {
+        out.push_str(&format!("\n> {description}\n"));
+    }
+    out.push_str("\n## Pages\n\n");
+    for page in pages {
+        out.push_str(&format!("- [{}]({origin}{})", page.title, page.url));
+        if !page.front.description.is_empty() {
+            out.push_str(&format!(": {}", page.front.description));
+        }
+        out.push('\n');
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn page() -> Page {
+        Page {
+            source: "content/new.md".into(),
+            rel: "new.md".into(),
+            front: crate::content::FrontMatter::default(),
+            title: "New".into(),
+            body: String::new(),
+            body_line_offset: 1,
+            language: "en".into(),
+            translation_key: "new".into(),
+            url: "/new/".into(),
+            out_path: "new/index.html".into(),
+            is_section: false,
+        }
+    }
+
+    #[test]
+    fn alias_becomes_a_directory_index() {
+        assert_eq!(
+            alias_output_path("/old/", &page()).unwrap(),
+            "old/index.html"
+        );
+        assert_eq!(
+            alias_output_path("/a/b", &page()).unwrap(),
+            "a/b/index.html"
+        );
+    }
+
+    #[test]
+    fn alias_must_be_a_local_root_absolute_path() {
+        for bad in ["https://elsewhere.example/x", "//cdn.example/x"] {
+            let err = alias_output_path(bad, &page()).unwrap_err().to_string();
+            assert!(err.contains("외부 URL"), "{bad}: {err}");
+        }
+        let err = alias_output_path("old/", &page()).unwrap_err().to_string();
+        assert!(err.contains("`/`로 시작"), "{err}");
+        let err = alias_output_path("/", &page()).unwrap_err().to_string();
+        assert!(err.contains("루트"), "{err}");
+    }
+
+    /// 스텁이 색인되면 검색 결과에 옛 URL이 뜨고, 원본과 중복 콘텐츠가 된다.
+    #[test]
+    fn the_stub_points_home_and_refuses_indexing() {
+        let html = redirect_stub("/new/", "https://example.com/new/");
+        assert!(html.contains(r#"content="noindex""#), "{html}");
+        assert!(
+            html.contains(r#"rel="canonical" href="https://example.com/new/""#),
+            "{html}"
+        );
+        assert!(html.contains(r#"content="0; url=/new/""#), "{html}");
+        // 자동 이동이 막힌 환경을 위해 본문에도 링크가 있어야 한다.
+        assert!(html.contains(r#"<a href="/new/""#), "{html}");
+    }
+
+    #[test]
+    fn llms_txt_lists_every_page_with_an_absolute_url() {
+        let mut p = page();
+        p.front.description = "A page".into();
+        let txt = llms_txt("Site", "About it", "https://example.com", &[p]);
+        assert!(txt.starts_with("# Site\n"), "{txt}");
+        assert!(txt.contains("> About it"), "{txt}");
+        assert!(
+            txt.contains("- [New](https://example.com/new/): A page"),
+            "{txt}"
+        );
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -4,6 +4,7 @@ pub mod assets;
 pub mod config;
 pub mod content;
 pub mod doctor;
+pub mod emit;
 pub mod error;
 pub mod highlight;
 pub mod i18n;
@@ -56,12 +57,12 @@ pub struct BuildStats {
 pub struct BuildOutput {
     /// 출력 디렉터리 기준 상대 경로 → 내용
     pub files: BTreeMap<String, Vec<u8>>,
-}
-
-impl BuildOutput {
-    pub fn pages(&self) -> usize {
-        self.files.keys().filter(|k| k.ends_with(".html")).count()
-    }
+    /// 실제 콘텐츠 페이지 수.
+    ///
+    /// `.html`을 세지 않는다. 리다이렉트 스텁과 404도 `.html`이지만 사람이 쓴
+    /// 페이지가 아니고, 그걸 세면 "5 pages"라고 보고하면서 콘텐츠는 두 개인
+    /// 상태가 된다.
+    pub pages: usize,
 }
 
 /// 사이트를 빌드해 메모리에 담는다. 디스크는 건드리지 않는다.
@@ -105,13 +106,27 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
         None
     };
 
+    // 사이트가 실제로 내보내는 URL 전체. 이게 있어야 `[설치](/start/install/)` 같은
+    // 평범한 절대 경로 링크도 빌드가 검증할 수 있다 — 지금까지는 `@/`만 봤다.
+    let known = links::KnownUrls {
+        urls: pages
+            .iter()
+            .map(|p| p.url.clone())
+            .chain(assets.manifest.values().cloned())
+            .collect(),
+    };
+    md = md.with_known_urls(known);
+
     assets.write_manifest();
     let templates = Templates::load(root)
         .map_err(Kind::Template.tag())?
         .with_assets(assets.manifest.clone())
         .with_i18n(i18n::load(root).map_err(Kind::Template.tag())?);
 
-    let mut out = BuildOutput::default();
+    let mut out = BuildOutput {
+        pages: pages.len(),
+        ..BuildOutput::default()
+    };
     out.files.extend(assets.files);
 
     // 언어 코드 → 그 언어의 검색 색인 행들.
@@ -139,6 +154,42 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
         out.files.insert(search::path(&language), json);
     }
 
+    // 옛 URL을 새 URL로 보내는 스텁. GitHub Pages에는 리다이렉트 규칙이 없으므로
+    // 이걸 빌드가 만들지 않으면 아무도 만들어 주지 않는다.
+    for page in &pages {
+        for alias in &page.front.aliases {
+            let out_path = emit::alias_output_path(alias, page).map_err(Kind::Content.tag())?;
+            let stub = emit::redirect_stub(&page.url, &format!("{}{}", cfg.origin(), page.url));
+            if let Some(prev) = out.files.insert(out_path.clone(), stub.into_bytes()) {
+                let _ = prev;
+                return Err(Kind::Content.tag()(anyhow::anyhow!(
+                    "{}: alias `{alias}` 가 이미 존재하는 출력 `{out_path}` 를 덮어씁니다.\n\
+                     다른 페이지나 다른 alias가 같은 URL을 주장하고 있습니다.",
+                    page.source.display()
+                )));
+            }
+        }
+    }
+
+    // 호스트들이 이름으로 찾는 404. 템플릿이 있을 때만 만든다 — 하이라이트
+    // 스타일시트와 같은 계약이다.
+    if templates.has(emit::NOT_FOUND_PATH) {
+        let html = render_standalone(
+            emit::NOT_FOUND_PATH,
+            &cfg,
+            &site,
+            &templates,
+            highlight_css_url.as_deref(),
+        )
+        .map_err(Kind::Template.tag())?;
+        out.files
+            .insert(emit::NOT_FOUND_PATH.into(), html.into_bytes());
+    }
+
+    out.files.entry(emit::LLMS_PATH.into()).or_insert_with(|| {
+        emit::llms_txt(&cfg.title, &cfg.description, cfg.origin(), &pages).into_bytes()
+    });
+
     // `static/`에 같은 이름이 있으면 그쪽이 이긴다. 직접 넣은 robots.txt가
     // 조용히 무시당하는 것보다는 생성을 건너뛰는 쪽이 낫다.
     let origin = cfg.origin();
@@ -190,7 +241,7 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
     }
 
     Ok(BuildStats {
-        pages_written: output.pages(),
+        pages_written: output.pages,
         output_dir: out_dir,
     })
 }
@@ -228,8 +279,12 @@ fn render_page(
     let rendered = md.render_in(&page.language, &page.body);
     if !rendered.unresolved.is_empty() {
         return Err(Kind::Content.tag()(anyhow::anyhow!(
-            "{}: 해석할 수 없는 내부 링크가 있습니다:\n  {}\n\
-             `@/` 는 content/ 기준 소스 경로를 가리켜야 합니다 (예: `@/start/installation.md`).",
+            "{}: 어디도 가리키지 않는 링크가 있습니다:\n  {}\n\n\
+             `@/`로 시작하는 링크는 content/ 기준 소스 경로여야 합니다 \
+             (예: `@/start/installation.md`).\n\
+             `/`로 시작하는 링크는 이 사이트가 실제로 내보내는 URL이어야 합니다.\n\
+             CSS와 JS는 파일명에 해시가 붙으므로 마크다운에서 `/css/main.css`로 \
+             가리킬 수 없습니다 — 템플릿에서 `asset(\"css/main.css\")`를 쓰세요.",
             page.source.display(),
             rendered.unresolved.join("\n  ")
         )));
@@ -287,6 +342,55 @@ fn render_page(
     Ok((html, entry))
 }
 
+/// 페이지에 매이지 않은 템플릿 하나를 렌더한다. 지금은 404뿐이다.
+///
+/// 일부러 이름이 붙은 단일 분기로 둔다. "아무 템플릿이나 아무 경로로 렌더한다"는
+/// 일반 기구를 만들면 그 순간 출력 URL 소유권을 누가 갖는지가 흐려진다.
+fn render_standalone(
+    name: &str,
+    cfg: &Config,
+    site: &Site,
+    templates: &Templates,
+    highlight_css: Option<&str>,
+) -> Result<String> {
+    let language = cfg.default_language.clone();
+    let url = format!("{}/{}", cfg.base_path(), name);
+
+    let site_ctx = SiteCtx {
+        title: cfg.title.clone(),
+        description: cfg.description.clone(),
+        origin: cfg.origin().to_string(),
+        base_path: cfg.base_path().to_string(),
+        language: language.clone(),
+        sections: site::section_ctx(site.sections(&language), site.pages),
+        highlight_css: highlight_css.map(str::to_string),
+    };
+
+    let page_ctx = PageCtx {
+        title: "404".into(),
+        description: String::new(),
+        permalink: format!("{}{url}", cfg.origin()),
+        url,
+        content: String::new(),
+        weight: 0,
+        draft: false,
+        toc: false,
+        toc_entries: Vec::new(),
+        language,
+        translations: Vec::new(),
+        children: Vec::new(),
+        section: None,
+        prev: None,
+        next: None,
+        is_section: false,
+        extra: toml::Table::new(),
+    };
+
+    templates
+        .render(name, context! { site => site_ctx, page => page_ctx })
+        .with_context(|| format!("{name} 렌더 중"))
+}
+
 /// 템플릿 선택은 **명시적**이다. 순서는 딱 넷:
 ///
 /// 1. front matter `template`
diff --git a/src/links.rs b/src/links.rs
@@ -16,12 +16,21 @@ pub const PREFIX: &str = "@/";
 /// 언어별 URL 표. `translation_key` → (언어 → URL)
 pub type LinkIndex = BTreeMap<String, BTreeMap<String, String>>;
 
+/// 이 사이트가 실제로 내보내는 URL들. 루트 절대 경로 링크를 검증하는 데 쓴다.
+#[derive(Debug, Clone, Default)]
+pub struct KnownUrls {
+    /// 페이지 URL (`/start/install/`)과 에셋 URL (`/css/main.abc.css`)
+    pub urls: std::collections::BTreeSet<String>,
+}
+
 pub struct Resolver {
     index: LinkIndex,
     language: String,
     default_language: String,
     /// 해석하지 못한 링크. 렌더가 끝난 뒤 빌드를 실패시키는 데 쓴다.
     unresolved: Mutex<Vec<String>>,
+    /// 이 사이트가 내보내는 URL 전체. 비어 있으면 절대 경로 검사를 건너뛴다.
+    known: KnownUrls,
 }
 
 impl Resolver {
@@ -31,7 +40,39 @@ impl Resolver {
             language: language.to_string(),
             default_language: default_language.to_string(),
             unresolved: Mutex::new(Vec::new()),
+            known: KnownUrls::default(),
+        }
+    }
+
+    /// 루트 절대 경로 링크를 검증할 수 있게 사이트가 내보내는 URL 목록을 준다.
+    ///
+    /// `@/`만 검사하면 "깨진 참조는 빌드를 멈춘다"가 반쪽이 된다. 사람이 가장
+    /// 자연스럽게 쓰는 건 `[설치](/start/install/)`인데, 그건 지금까지 아무도
+    /// 검사하지 않았고 프로덕션에서 404가 됐다.
+    pub fn with_known_urls(mut self, known: KnownUrls) -> Self {
+        self.known = known;
+        self
+    }
+
+    /// 사이트 안을 가리키는 루트 절대 경로인지. 스킴·프로토콜 상대·`#`·`?`는 제외.
+    fn is_local_absolute(url: &str) -> bool {
+        url.starts_with('/') && !url.starts_with("//")
+    }
+
+    /// 링크가 실제로 존재하는 곳을 가리키는지.
+    fn known_target(&self, url: &str) -> bool {
+        // 프래그먼트와 쿼리를 떼고 본다. `/start/#설치`는 `/start/`를 가리킨다.
+        let path = url.split(['#', '?']).next().unwrap_or(url);
+        if path.is_empty() {
+            return true; // `#anchor` 단독 — 같은 페이지 안이다.
         }
+        if self.known.urls.contains(path) {
+            return true;
+        }
+        // 디렉터리 URL은 슬래시가 있으나 없으나 같은 곳을 가리킨다. 호스트가
+        // 리다이렉트해 주므로 죽은 링크는 아니다.
+        let with_slash = format!("{path}/");
+        self.known.urls.contains(&with_slash)
     }
 
     /// 해석에 실패한 링크들. 비어 있지 않으면 빌드를 멈춰야 한다.
@@ -66,6 +107,16 @@ impl Resolver {
 impl URLRewriter for Resolver {
     fn to_html(&self, url: &str) -> String {
         if !url.starts_with(PREFIX) {
+            // `@/`가 아니어도 사이트 안을 가리키는 절대 경로라면 존재를 확인한다.
+            if !self.known.urls.is_empty()
+                && Self::is_local_absolute(url)
+                && !self.known_target(url)
+            {
+                self.unresolved
+                    .lock()
+                    .unwrap_or_else(|e| e.into_inner())
+                    .push(url.to_string());
+            }
             return url.to_string();
         }
         match self.resolve(url) {
@@ -102,6 +153,59 @@ fn translation_key(path: &str) -> String {
     }
 }
 
+#[cfg(test)]
+mod absolute_link_tests {
+    use super::*;
+    use comrak::options::URLRewriter;
+    use std::collections::BTreeSet;
+
+    fn resolver(urls: &[&str]) -> Resolver {
+        Resolver::new(LinkIndex::new(), "en", "en").with_known_urls(KnownUrls {
+            urls: urls
+                .iter()
+                .map(|s| (*s).to_string())
+                .collect::<BTreeSet<_>>(),
+        })
+    }
+
+    /// `@/`만 검사하면 "깨진 참조는 빌드를 멈춘다"가 반쪽이다. 사람이 가장
+    /// 자연스럽게 쓰는 건 `[설치](/start/install/)`이고, 그건 검사된 적이 없었다.
+    #[test]
+    fn a_dead_absolute_path_is_unresolved() {
+        let r = resolver(&["/about/"]);
+        r.to_html("/nope/");
+        assert_eq!(r.take_unresolved(), vec!["/nope/".to_string()]);
+    }
+
+    #[test]
+    fn live_paths_anchors_and_external_urls_pass() {
+        let r = resolver(&["/about/", "/images/x.png"]);
+        for url in [
+            "/about/",
+            "/about/#section",
+            "/about/?q=1",
+            "/images/x.png",
+            "#same-page",
+            "https://example.com/x",
+            // 프로토콜 상대 URL은 남의 호스트다. 우리 사이트가 아니다.
+            "//cdn.example.com/x",
+            "mailto:a@b.c",
+        ] {
+            r.to_html(url);
+        }
+        assert!(r.take_unresolved().is_empty());
+    }
+
+    /// 알려진 URL이 없으면(에셋 수집 전 등) 검사를 아예 하지 않는다.
+    /// 반쯤 아는 상태로 판정하면 멀쩡한 링크를 죽었다고 말하게 된다.
+    #[test]
+    fn no_known_urls_means_no_checking() {
+        let r = Resolver::new(LinkIndex::new(), "en", "en");
+        r.to_html("/anything/");
+        assert!(r.take_unresolved().is_empty());
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/src/markdown.rs b/src/markdown.rs
@@ -38,6 +38,7 @@ pub struct Renderer {
     highlighter: Option<crate::highlight::Highlighter>,
     links: Option<crate::links::LinkIndex>,
     default_language: String,
+    known: crate::links::KnownUrls,
 }
 
 impl Renderer {
@@ -47,9 +48,17 @@ impl Renderer {
             highlighter: None,
             links: None,
             default_language: "en".into(),
+            known: crate::links::KnownUrls::default(),
         }
     }
 
+    /// 루트 절대 경로 링크(`/start/install/`)와 이미지도 존재를 검사하게 한다.
+    /// 비워 두면 검사하지 않는다.
+    pub fn with_known_urls(mut self, known: crate::links::KnownUrls) -> Self {
+        self.known = known;
+        self
+    }
+
     /// 구문 강조를 켠다. 없으면 코드 블록은 `<code class="language-x">`로만 나간다.
     pub fn with_highlighter(mut self, h: crate::highlight::Highlighter) -> Self {
         self.highlighter = Some(h);
@@ -97,11 +106,10 @@ impl Renderer {
         let mut options = self.options();
 
         let resolver = self.links.as_ref().map(|index| {
-            std::sync::Arc::new(crate::links::Resolver::new(
-                index.clone(),
-                language,
-                &self.default_language,
-            ))
+            std::sync::Arc::new(
+                crate::links::Resolver::new(index.clone(), language, &self.default_language)
+                    .with_known_urls(self.known.clone()),
+            )
         });
         if let Some(r) = &resolver {
             options.extension.link_url_rewriter = Some(r.clone());
diff --git a/src/serve.rs b/src/serve.rs
@@ -74,7 +74,7 @@ pub async fn serve(opts: ServeOptions) -> Result<()> {
 
     let initial = build_to_memory(&build_opts)
         .with_context(|| format!("{}의 첫 빌드 실패", opts.input.display()))?;
-    println!("{} pages", initial.pages());
+    println!("{} pages", initial.pages);
 
     let (tx, _) = broadcast::channel(64);
     let shared: SharedState = Arc::new(Shared {