sqzass.git

sqzass

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

Deliver page.extra, and make the site work under a URL subpath

Two things our own documentation promised and the code did not do.

`page.extra` was documented in two places, one of them with a copy-pasteable
`{% if page.extra.badge %}` example, and was never put into the template
context. Following our own docs produced `[SQZASS_E_TEMPLATE] undefined value`.
It is also the escape hatch every per-page need routes through — og:image, a
badge, an API version — so nothing per-page could be built on top of it either.

The second is worse because it failed silently. `base_url` with a path, as in
`https://user.github.io/repo`, built a site that looked fine and was entirely
broken: links and stylesheet hrefs came out root-absolute at `/start/` and
`/css/main.hash.css` while the sitemap and canonical said `/repo/…`. Nothing
failed, doctor said nothing, and the reader got a 404 on every page and an
unstyled one on the rest. A project page is the default outcome for anyone who
has not bought a domain, so this was the one class of broken reference we
neither resolved nor rejected.

The subpath now runs through URL generation, so page URLs, section URLs,
translation links, prev/next, `@/` resolution and the asset manifest all carry
it, together or not at all. Output paths deliberately do not: the directory we
write is the root the host serves, and where that root sits on the domain is a
separate question.

Absolute URLs are built from the origin rather than from `base_url`, since the
page URL already contains the path and concatenating the configured value would
double it. `site.base_url` is gone from templates and replaced by `site.origin`
and `site.base_path` — one that never contains a path and one that is only a
path. Keeping a `site.base_url` that is sometimes safe to concatenate and
sometimes not is precisely the kind of trap this project exists to avoid.

Diffstat:
Mdocs/templates/base.html | 8+++++---
Msrc/assets.rs | 30+++++++++++++++++++++++++-----
Msrc/config.rs | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/content.rs | 26++++++++++++++++++++++++--
Msrc/lib.rs | 16++++++++++------
Msrc/render.rs | 9++++++++-
Msrc/seo.rs | 13+++++++------
7 files changed, 138 insertion(+), 26 deletion(-)

diff --git a/docs/templates/base.html b/docs/templates/base.html
@@ -1,7 +1,9 @@
 {% from "partials/sidebar.html" import nav %}
 {% from "partials/toc.html" import toc_list %}
 {%- set ko = page.language == "ko" %}
-{%- set home = "/ko/" if ko else "/" %}
+{#- 서브경로에 올라갈 수도 있으므로 홈 링크에 site.base_path를 붙인다.
+    페이지 URL들은 이미 이 접두사를 품고 있어서 손댈 필요가 없다. #}
+{%- set home = site.base_path ~ ("/ko/" if ko else "/") %}
 <!doctype html>
 {#- The default is written here rather than left to `prefers-color-scheme`, so
     a reader with a light OS still gets the dark page the design assumes — and,
@@ -17,7 +19,7 @@
 {#- `lang`이지 `t`가 아니다. `t`는 번역 함수이고, 루프 변수가 그 이름을 가리면
     이 블록 안에서 라벨을 하나 넣는 순간 조용히 깨진다. #}
 {%- for lang in page.translations %}
-<link rel="alternate" hreflang="{{ lang.code }}" href="{{ site.base_url }}{{ lang.url }}">
+<link rel="alternate" hreflang="{{ lang.code }}" href="{{ site.origin }}{{ lang.url }}">
 {%- endfor %}
 <link rel="stylesheet" href="{{ asset("css/main.css") }}">
 {%- if site.highlight_css %}
@@ -47,7 +49,7 @@
 
   <div class="tools">
     <button id="search-trigger" class="search-trigger" type="button"
-            data-index="/search-{{ page.language }}.json">
+            data-index="{{ site.base_path }}/search-{{ page.language }}.json">
       {#- The label is replaced with the reader's own modifier by search.js;
           this is the value someone with JavaScript off sees. #}
       <span>{{ t("search") }}</span><kbd>Ctrl /</kbd>
diff --git a/src/assets.rs b/src/assets.rs
@@ -24,12 +24,18 @@ pub struct Assets {
     pub files: BTreeMap<String, Vec<u8>>,
     /// 논리 경로(`css/main.css`) → 출력 URL(`/css/main.a1b2c3d4.css`)
     pub manifest: BTreeMap<String, String>,
+    /// 사이트가 도메인 루트가 아닐 때의 접두사. 페이지 URL과 같은 규칙을 따라야
+    /// `<link href>`와 내비게이션 링크가 같은 곳을 가리킨다.
+    base_path: String,
 }
 
 impl Assets {
     /// `static/` 아래를 전부 읽어들인다.
-    pub fn collect(root: &Path, cfg: &AssetsConfig) -> Result<Self> {
-        let mut out = Self::default();
+    pub fn collect(root: &Path, cfg: &AssetsConfig, base_path: &str) -> Result<Self> {
+        let mut out = Self {
+            base_path: base_path.to_string(),
+            ..Self::default()
+        };
         let dir = root.join(&cfg.source_dir);
         if !dir.is_dir() {
             return Ok(out);
@@ -64,8 +70,9 @@ impl Assets {
         } else {
             logical.to_string()
         };
+        let base = &self.base_path;
         self.manifest
-            .insert(logical.to_string(), format!("/{out_path}"));
+            .insert(logical.to_string(), format!("{base}/{out_path}"));
         self.files.insert(out_path, bytes);
     }
 
@@ -186,18 +193,31 @@ mod tests {
         std::fs::write(sdir.join("main.css"), "body{color:red}").unwrap();
         std::fs::write(root.join("static/CNAME"), "example.com").unwrap();
 
-        let a = Assets::collect(&root, &cfg()).unwrap();
+        let a = Assets::collect(&root, &cfg(), "").unwrap();
         assert!(a.url("css/main.css").unwrap().starts_with("/css/main."));
         assert_eq!(a.url("CNAME"), Some("/CNAME"));
         assert_eq!(a.files.len(), 2);
 
+        // 서브경로에 올라가는 사이트는 에셋 URL도 같은 접두사를 가져야 한다.
+        // 페이지 링크만 접두사를 갖고 스타일시트는 안 갖는 상태가 가장 나쁘다:
+        // 사이트는 뜨는데 아무 스타일도 안 먹는다.
+        let sub = Assets::collect(&root, &cfg(), "/repo").unwrap();
+        assert!(
+            sub.url("css/main.css")
+                .unwrap()
+                .starts_with("/repo/css/main."),
+            "실제: {:?}",
+            sub.url("css/main.css")
+        );
+        assert_eq!(sub.url("CNAME"), Some("/repo/CNAME"));
+
         std::fs::remove_dir_all(&root).ok();
     }
 
     #[test]
     fn missing_static_dir_is_not_an_error() {
         let root = std::env::temp_dir().join("sqzass-assets-nonexistent-xyz");
-        let a = Assets::collect(&root, &cfg()).unwrap();
+        let a = Assets::collect(&root, &cfg(), "").unwrap();
         assert!(a.files.is_empty());
     }
 }
diff --git a/src/config.rs b/src/config.rs
@@ -9,8 +9,6 @@ use std::path::Path;
 
 pub const CONFIG_FILE: &str = "sqzass.toml";
 
-/// `sqzass.toml`이 가질 수 있는 최상위 키. 오타 감지에 쓴다.
-
 #[derive(Debug, Clone, Deserialize)]
 #[serde(deny_unknown_fields)]
 pub struct Config {
@@ -189,16 +187,74 @@ impl Config {
         Ok(())
     }
 
-    /// `base_url`에서 뒤쪽 슬래시를 제거한 형태. URL 조립에 쓴다.
+    /// `base_url`에서 뒤쪽 슬래시를 제거한 형태.
     pub fn base_url_trimmed(&self) -> &str {
         self.base_url.trim_end_matches('/')
     }
+
+    /// 스킴과 호스트만. `https://user.github.io/repo` → `https://user.github.io`
+    ///
+    /// 절대 URL(canonical, sitemap, OpenGraph)을 조립할 때 쓴다. 페이지 URL이 이미
+    /// 서브경로를 품고 있으므로 여기에 경로가 또 들어가면 두 번 붙는다.
+    pub fn origin(&self) -> &str {
+        let trimmed = self.base_url_trimmed();
+        // `://` 다음의 첫 슬래시가 경로의 시작이다. 스킴이 없으면 경로도 없다고 본다.
+        match trimmed.find("://") {
+            Some(i) => match trimmed[i + 3..].find('/') {
+                Some(j) => &trimmed[..i + 3 + j],
+                None => trimmed,
+            },
+            None => trimmed,
+        }
+    }
+
+    /// 사이트가 도메인 루트가 아닌 곳에 놓일 때의 경로. 루트면 빈 문자열.
+    ///
+    /// `https://user.github.io/repo` → `/repo`
+    ///
+    /// GitHub·GitLab·Codeberg의 **프로젝트 페이지가 기본으로 이 모양**이다. 도메인을
+    /// 따로 사지 않은 사람이 가장 먼저 만나는 형태이므로, 여기서 URL이 어긋나면
+    /// 사이트 전체가 404가 된다 — 빌드는 성공한 채로.
+    pub fn base_path(&self) -> &str {
+        let trimmed = self.base_url_trimmed();
+        let origin = self.origin();
+        trimmed.strip_prefix(origin).unwrap_or("")
+    }
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
 
+    /// 프로젝트 페이지(`user.github.io/repo`)는 도메인을 사지 않은 사람의 기본
+    /// 상황이다. 여기서 origin과 경로를 잘못 나누면 사이트 전체가 404가 된다.
+    #[test]
+    fn splits_origin_from_base_path() {
+        let cfg = |url: &str| -> Config {
+            toml::from_str(&format!("title = \"t\"\nbase_url = \"{url}\"\n")).unwrap()
+        };
+
+        let root = cfg("https://sqzass.sqzer.com");
+        assert_eq!(root.origin(), "https://sqzass.sqzer.com");
+        assert_eq!(root.base_path(), "", "루트 사이트에는 접두사가 없다");
+
+        let trailing = cfg("https://sqzass.sqzer.com/");
+        assert_eq!(trailing.origin(), "https://sqzass.sqzer.com");
+        assert_eq!(trailing.base_path(), "");
+
+        let project = cfg("https://user.github.io/repo");
+        assert_eq!(project.origin(), "https://user.github.io");
+        assert_eq!(project.base_path(), "/repo");
+
+        let nested = cfg("https://example.com/a/b/");
+        assert_eq!(nested.origin(), "https://example.com");
+        assert_eq!(nested.base_path(), "/a/b");
+
+        let port = cfg("http://localhost:3000/site");
+        assert_eq!(port.origin(), "http://localhost:3000");
+        assert_eq!(port.base_path(), "/site");
+    }
+
     /// 조용히 무시되는 설정은 "깨진 참조는 빌드를 멈춘다"의 정확한 위반이다.
     #[test]
     fn unknown_keys_are_an_error() {
diff --git a/src/content.rs b/src/content.rs
@@ -219,10 +219,14 @@ fn build_url(name: &NameParts, slug: &str, cfg: &Config) -> (String, PathBuf) {
         segments.push(slug.to_string());
     }
 
+    // URL에는 서브경로가 들어가고 출력 경로에는 들어가지 않는다. 호스트가 서빙하는
+    // 루트가 곧 출력 디렉터리이고, 서브경로는 그 루트가 도메인 어디에 놓이는지의
+    // 문제이기 때문이다.
+    let base = cfg.base_path();
     let url = if segments.is_empty() {
-        "/".to_string()
+        format!("{base}/")
     } else {
-        format!("/{}/", segments.join("/"))
+        format!("{base}/{}/", segments.join("/"))
     };
 
     let mut out = PathBuf::new();
@@ -327,6 +331,24 @@ mod tests {
 
     /// front matter 오타도 조용히 무시하지 않는다. 그리고 에러가 가리키는 줄은
     /// **원본 파일의** 줄이어야 한다 — 펜스를 손으로 자른 이유가 그것이다.
+    /// URL에는 서브경로가 들어가고 출력 경로에는 들어가지 않는다. 호스트가 서빙하는
+    /// 루트가 곧 출력 디렉터리이기 때문이다.
+    #[test]
+    fn base_path_reaches_urls_but_not_output_paths() {
+        let cfg: Config =
+            toml::from_str("title = \"t\"\nbase_url = \"https://user.github.io/repo\"\n").unwrap();
+
+        let parts = NameParts::parse(Path::new("start/install.md"), &cfg);
+        let (url, out) = build_url(&parts, &parts.stem, &cfg);
+        assert_eq!(url, "/repo/start/install/");
+        assert_eq!(out, Path::new("start/install/index.html"));
+
+        let root = NameParts::parse(Path::new("_index.md"), &cfg);
+        let (url, out) = build_url(&root, &root.stem, &cfg);
+        assert_eq!(url, "/repo/");
+        assert_eq!(out, Path::new("index.html"));
+    }
+
     #[test]
     fn unknown_front_matter_key_errors_at_the_real_line() {
         let dir = std::env::temp_dir().join(format!("sqzass-fm-{}", std::process::id()));
diff --git a/src/lib.rs b/src/lib.rs
@@ -89,7 +89,8 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
     let site = Site::build(&pages, &cfg);
 
     // 에셋을 먼저 처리해야 템플릿이 해시가 붙은 최종 URL을 조회할 수 있다.
-    let mut assets = assets::Assets::collect(root, &cfg.assets).map_err(Kind::Io.tag())?;
+    let mut assets =
+        assets::Assets::collect(root, &cfg.assets, cfg.base_path()).map_err(Kind::Io.tag())?;
 
     let mut md =
         markdown::Renderer::new(&cfg.markdown).with_links(site.link_index(), &cfg.default_language);
@@ -140,13 +141,13 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
 
     // `static/`에 같은 이름이 있으면 그쪽이 이긴다. 직접 넣은 robots.txt가
     // 조용히 무시당하는 것보다는 생성을 건너뛰는 쪽이 낫다.
-    let base = cfg.base_url_trimmed();
+    let origin = cfg.origin();
     out.files
         .entry(seo::SITEMAP_PATH.into())
-        .or_insert_with(|| seo::sitemap(&pages, &site, base).into_bytes());
+        .or_insert_with(|| seo::sitemap(&pages, &site, origin).into_bytes());
     out.files
         .entry(seo::ROBOTS_PATH.into())
-        .or_insert_with(|| seo::robots(base).into_bytes());
+        .or_insert_with(|| seo::robots(origin, cfg.base_path()).into_bytes());
 
     // GitHub Pages는 이 파일이 없으면 출력을 Jekyll로 한 번 더 굴려서
     // `_`로 시작하는 디렉터리를 통째로 삼킨다.
@@ -217,7 +218,8 @@ fn render_page(
     let site_ctx = SiteCtx {
         title: cfg.title.clone(),
         description: cfg.description.clone(),
-        base_url: cfg.base_url_trimmed().to_string(),
+        origin: cfg.origin().to_string(),
+        base_path: cfg.base_path().to_string(),
         language: page.language.clone(),
         sections: site::section_ctx(site.sections(&page.language), site.pages),
         highlight_css: highlight_css.map(str::to_string),
@@ -257,7 +259,8 @@ fn render_page(
         title: page.title.clone(),
         description: page.front.description.clone(),
         url: page.url.clone(),
-        permalink: format!("{}{}", cfg.base_url_trimmed(), page.url),
+        // page.url이 서브경로를 품고 있으므로 origin만 붙인다.
+        permalink: format!("{}{}", cfg.origin(), page.url),
         content: rendered.html,
         weight: page.front.weight,
         draft: page.front.draft,
@@ -273,6 +276,7 @@ fn render_page(
         prev,
         next,
         is_section: page.is_section,
+        extra: page.front.extra.clone(),
     };
 
     let html = templates
diff --git a/src/render.rs b/src/render.rs
@@ -201,7 +201,12 @@ fn format_value(out: &mut Output, state: &State, value: &Value) -> Result<(), mi
 pub struct SiteCtx {
     pub title: String,
     pub description: String,
-    pub base_url: String,
+    /// 스킴과 호스트만. 절대 URL은 `site.origin ~ page.url`로 만든다 — 페이지 URL이
+    /// 이미 서브경로를 품고 있어서, 설정한 `base_url`을 그대로 붙이면 두 번 들어간다.
+    pub origin: String,
+    /// 사이트가 도메인 루트가 아닐 때의 경로 접두사. 루트면 빈 문자열.
+    /// 템플릿이 직접 쓰는 URL(홈 링크 등)에만 필요하다.
+    pub base_path: String,
     /// 지금 렌더 중인 페이지의 언어.
     pub language: String,
     /// 이 언어의 최상위 섹션들. 사이드바는 여기서 만든다.
@@ -236,6 +241,8 @@ pub struct PageCtx {
     pub next: Option<crate::site::PageRefCtx>,
     /// 섹션 인덱스인지. 템플릿이 목록을 그릴지 판단한다.
     pub is_section: bool,
+    /// front matter의 `[extra]`. sqzass는 여기를 읽지 않는다 — 템플릿이 읽는다.
+    pub extra: toml::Table,
 }
 
 #[cfg(test)]
diff --git a/src/seo.rs b/src/seo.rs
@@ -24,7 +24,7 @@ pub const ROBOTS_PATH: &str = "robots.txt";
 /// `actions/checkout`은 기본이 얕은 클론이라, 조용히 모든 페이지가 같은 날짜를
 /// 갖게 된다. 틀린 `lastmod`는 없는 것보다 나쁘다 — 구글은 부정확한 값을 보면
 /// 그 사이트의 `lastmod`를 통째로 무시한다.
-pub fn sitemap(pages: &[Page], site: &Site, base_url: &str) -> String {
+pub fn sitemap(pages: &[Page], site: &Site, origin: &str) -> String {
     let mut out = String::from(
         "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
          <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" \
@@ -33,7 +33,7 @@ pub fn sitemap(pages: &[Page], site: &Site, base_url: &str) -> String {
 
     for page in pages {
         out.push_str("  <url>\n    <loc>");
-        escape_into(&mut out, &format!("{base_url}{}", page.url));
+        escape_into(&mut out, &format!("{origin}{}", page.url));
         out.push_str("</loc>\n");
 
         // 번역이 있는 페이지만 대체 링크를 낸다. 혼자인 페이지에 자기 자신만
@@ -44,7 +44,7 @@ pub fn sitemap(pages: &[Page], site: &Site, base_url: &str) -> String {
                 out.push_str("    <xhtml:link rel=\"alternate\" hreflang=\"");
                 escape_into(&mut out, &code);
                 out.push_str("\" href=\"");
-                escape_into(&mut out, &format!("{base_url}{url}"));
+                escape_into(&mut out, &format!("{origin}{url}"));
                 out.push_str("\"/>\n");
             }
         }
@@ -58,8 +58,9 @@ pub fn sitemap(pages: &[Page], site: &Site, base_url: &str) -> String {
 /// 전부 허용하고 sitemap을 가리킨다.
 ///
 /// 크롤러를 막고 싶으면 `static/robots.txt`에 직접 쓰면 된다.
-pub fn robots(base_url: &str) -> String {
-    format!("User-agent: *\nAllow: /\n\nSitemap: {base_url}/{SITEMAP_PATH}\n")
+pub fn robots(origin: &str, base_path: &str) -> String {
+    // sitemap은 출력 루트에 있고, 출력 루트는 도메인의 `base_path` 아래에 놓인다.
+    format!("User-agent: *\nAllow: {base_path}/\n\nSitemap: {origin}{base_path}/{SITEMAP_PATH}\n")
 }
 
 /// XML 텍스트/속성값 이스케이프.
@@ -85,7 +86,7 @@ mod tests {
 
     #[test]
     fn robots_points_at_the_sitemap() {
-        let r = robots("https://example.com");
+        let r = robots("https://example.com", "");
         assert!(
             r.contains("Sitemap: https://example.com/sitemap.xml"),
             "{r}"