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 {
|