sqzass.git

sqzass

~ static site generator in Rust
git clone https://github.com/debuman/sqzass.git
commit 553e0b1f0f6147216ef60e3811166c9376ef1be1
parent b9df84fe33ce8d3d49a8e141cbd8c9d2ef1bc7fb
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 18:27:33 +0900

Add an asset pipeline with content-hashed filenames

static/ is copied into the output and CSS/JS get a blake3 content hash spliced
before the extension, with an asset-manifest.json mapping logical names to real
ones. Templates look assets up with {{ asset("css/main.css") }}, and an unknown
name is an error rather than a silent passthrough, so a typo surfaces with the
template name and line instead of shipping a 404 link.

Hashing goes in the filename, not a query string: some CDNs and proxies drop
the query from the cache key, and the common "one build stamp on every file"
variant invalidates all twenty stylesheets when one changes.

Only CSS and JS are hashed. Images and fonts are referenced by literal path
from markdown and CSS, and files like CNAME have a name that *is* the contract
-- hashing those breaks them.

The generated highlight stylesheet goes through the same pipeline instead of
having its own hardcoded path.

This also removes the workflow's temporary static-copy step, which existed only
because the pipeline did not.

Diffstat:
M.github/workflows/pages.yml | 8++++----
MCargo.lock | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCargo.toml | 2++
Asrc/assets.rs | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 30+++++++++++++++---------------
Msrc/render.rs | 24++++++++++++++++++++++++
6 files changed, 314 insertion(+), 20 deletion(-)

diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
@@ -33,10 +33,10 @@ jobs:
       - name: Build the site
         run: cargo run --quiet -- build -i docs
 
-      # 커스텀 도메인. sqzass의 에셋 파이프라인(M1)이 들어오면 docs/static/ 아래
-      # 파일이 자동으로 복사되므로 이 단계는 그때 삭제한다.
-      - name: Copy static files
-        run: cp -r docs/static/. docs/public/
+      # docs/static/ 아래 파일은 sqzass가 직접 출력에 넣는다. CNAME은 이름이 곧
+      # 계약이므로 콘텐츠 해시를 붙이지 않는다.
+      - name: Check the custom domain made it into the output
+        run: test -f docs/public/CNAME && cat docs/public/CNAME
 
       - uses: actions/upload-pages-artifact@v5
         with:
diff --git a/Cargo.lock b/Cargo.lock
@@ -73,6 +73,18 @@ version = "1.0.104"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
 
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
 [[package]]
 name = "atomic-waker"
 version = "1.1.2"
@@ -170,6 +182,20 @@ version = "2.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
 
+[[package]]
+name = "blake3"
+version = "1.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+ "cpufeatures 0.3.0",
+]
+
 [[package]]
 name = "block-buffer"
 version = "0.10.4"
@@ -194,6 +220,16 @@ dependencies = [
  "unicode-normalization",
 ]
 
+[[package]]
+name = "cc"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
 [[package]]
 name = "cfg-if"
 version = "1.0.4"
@@ -274,6 +310,12 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
 [[package]]
 name = "cpufeatures"
 version = "0.2.17"
@@ -283,6 +325,15 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "crc32fast"
 version = "1.5.0"
@@ -378,6 +429,12 @@ dependencies = [
  "windows-sys 0.60.2",
 ]
 
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
 [[package]]
 name = "finl_unicode"
 version = "1.4.0"
@@ -1097,10 +1154,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
 dependencies = [
  "cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
  "digest",
 ]
 
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
 [[package]]
 name = "signal-hook-registry"
 version = "1.4.8"
@@ -1157,6 +1220,7 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "axum",
+ "blake3",
  "clap",
  "comrak",
  "insta",
@@ -1164,6 +1228,7 @@ dependencies = [
  "notify",
  "notify-debouncer-full",
  "serde",
+ "serde_json",
  "syntect",
  "tokio",
  "toml",
diff --git a/Cargo.toml b/Cargo.toml
@@ -23,6 +23,7 @@ path = "src/main.rs"
 # --- M0 ---
 anyhow    = "1"
 axum = { version = "0.8", features = ["ws"] }
+blake3 = "1"
 clap      = { version = "4.6", features = ["derive"] }
 # ⚠️ comrak의 default = ["cli", "syntect-onig", "bon"] 이다. 그대로 쓰면
 #    (1) comrak 자체 CLI 의존성(clap/shell-words/xdg/fmt2io)이 딸려오고
@@ -35,6 +36,7 @@ minijinja = "2.21"
 notify = "8.2"
 notify-debouncer-full = "0.7"
 serde     = { version = "1", features = ["derive"] }
+serde_json = "1"
 syntect = { version = "5.3", default-features = false, features = ["default-fancy"] }
 tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal"] }
 toml      = "1.1"
diff --git a/src/assets.rs b/src/assets.rs
@@ -0,0 +1,203 @@
+//! 정적 에셋 수집과 콘텐츠 해시 기반 파일명.
+//!
+//! 파일명에 해시를 넣는다. 쿼리 문자열(`?v=…`) 방식은 두 가지가 나쁘다: 일부 CDN과
+//! 프록시가 쿼리를 캐시 키에서 빼버리고, 실무에서 흔히 보이는 "빌드마다 하나의 스탬프를
+//! 전 파일에 붙이는" 형태는 CSS 한 줄만 고쳐도 나머지 전부를 무효화한다.
+//! 파일별 콘텐츠 해시는 바뀐 파일만 무효화되고, 모든 호스트에서 동일하게 동작한다.
+
+use crate::config::Assets as AssetsConfig;
+use anyhow::{Context, Result};
+use std::collections::BTreeMap;
+use std::path::Path;
+use walkdir::WalkDir;
+
+/// 해시를 붙일 확장자. 이미지·폰트는 대개 마크다운이나 CSS에서 문자열 경로로 직접
+/// 참조되므로 이름을 바꾸면 그 참조가 깨진다. `CNAME`처럼 이름 자체가 계약인 파일도
+/// 있어서, 해시는 `asset()`으로 조회하는 것들에만 적용한다.
+const FINGERPRINTED: &[&str] = &["css", "js"];
+
+const MANIFEST_PATH: &str = "asset-manifest.json";
+
+#[derive(Debug, Default)]
+pub struct Assets {
+    /// 출력 경로(상대) → 내용
+    pub files: BTreeMap<String, Vec<u8>>,
+    /// 논리 경로(`css/main.css`) → 출력 URL(`/css/main.a1b2c3d4.css`)
+    pub manifest: BTreeMap<String, String>,
+}
+
+impl Assets {
+    /// `static/` 아래를 전부 읽어들인다.
+    pub fn collect(root: &Path, cfg: &AssetsConfig) -> Result<Self> {
+        let mut out = Self::default();
+        let dir = root.join(&cfg.source_dir);
+        if !dir.is_dir() {
+            return Ok(out);
+        }
+
+        for entry in WalkDir::new(&dir).sort_by_file_name() {
+            let entry = entry.with_context(|| format!("{} 순회 실패", dir.display()))?;
+            if !entry.file_type().is_file() {
+                continue;
+            }
+            let path = entry.path();
+            let logical = path
+                .strip_prefix(&dir)
+                .unwrap_or(path)
+                .to_string_lossy()
+                .replace('\\', "/");
+            let bytes = std::fs::read(path)
+                .with_context(|| format!("{}을(를) 읽을 수 없습니다", path.display()))?;
+            out.insert(&logical, bytes, cfg.fingerprint);
+        }
+        Ok(out)
+    }
+
+    /// 빌드가 생성한 에셋(예: 하이라이트 스타일시트)을 같은 규칙으로 넣는다.
+    pub fn insert(&mut self, logical: &str, bytes: Vec<u8>, fingerprint: bool) {
+        let ext = Path::new(logical)
+            .extension()
+            .and_then(|e| e.to_str())
+            .unwrap_or("");
+        let out_path = if fingerprint && FINGERPRINTED.contains(&ext) {
+            hashed_name(logical, &bytes)
+        } else {
+            logical.to_string()
+        };
+        self.manifest
+            .insert(logical.to_string(), format!("/{out_path}"));
+        self.files.insert(out_path, bytes);
+    }
+
+    /// 논리 경로로 출력 URL을 찾는다.
+    pub fn url(&self, logical: &str) -> Option<&str> {
+        self.manifest
+            .get(logical.trim_start_matches('/'))
+            .map(|s| s.as_str())
+    }
+
+    /// 매니페스트를 출력에 포함시킨다. 서비스 워커나 외부 도구가 논리 이름과
+    /// 실제 파일을 맞출 수 있어야 한다.
+    pub fn write_manifest(&mut self) {
+        let json = serde_json::to_string_pretty(&self.manifest).unwrap_or_else(|_| "{}".into());
+        self.files
+            .insert(MANIFEST_PATH.to_string(), json.into_bytes());
+    }
+}
+
+/// `css/main.css` + 내용 → `css/main.a1b2c3d4.css`
+///
+/// blake3를 쓴다. 캐시 무효화가 목적이라 암호학적 강도는 필요 없고, 속도가 중요하다.
+fn hashed_name(logical: &str, bytes: &[u8]) -> String {
+    let hash = blake3::hash(bytes);
+    let short = &hash.to_hex()[..8];
+
+    let path = Path::new(logical);
+    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
+    let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(logical);
+    let parent = path.parent().map(|p| p.to_string_lossy().into_owned());
+
+    let name = if ext.is_empty() {
+        format!("{stem}.{short}")
+    } else {
+        format!("{stem}.{short}.{ext}")
+    };
+    match parent.as_deref() {
+        Some("") | None => name,
+        Some(p) => format!("{p}/{name}"),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn cfg() -> AssetsConfig {
+        AssetsConfig::default()
+    }
+
+    #[test]
+    fn hashes_go_before_the_extension() {
+        let n = hashed_name("css/main.css", b"body{}");
+        assert!(n.starts_with("css/main."), "실제: {n}");
+        assert!(n.ends_with(".css"), "실제: {n}");
+        // css/main.<8자>.css
+        assert_eq!(n.matches('.').count(), 2, "실제: {n}");
+    }
+
+    #[test]
+    fn different_content_gives_a_different_name() {
+        assert_ne!(
+            hashed_name("a.css", b"one"),
+            hashed_name("a.css", b"two"),
+            "내용이 다른데 이름이 같으면 캐시가 안 바뀐다"
+        );
+    }
+
+    #[test]
+    fn same_content_is_stable_across_builds() {
+        // 빌드마다 이름이 달라지면 재현 가능한 빌드가 깨진다.
+        assert_eq!(hashed_name("a.css", b"same"), hashed_name("a.css", b"same"));
+    }
+
+    #[test]
+    fn root_level_files_keep_a_flat_name() {
+        let n = hashed_name("main.css", b"x");
+        assert!(!n.contains('/'), "실제: {n}");
+    }
+
+    #[test]
+    fn only_css_and_js_are_fingerprinted() {
+        let mut a = Assets::default();
+        a.insert("css/main.css", b"body{}".to_vec(), true);
+        a.insert("js/app.js", b"//x".to_vec(), true);
+        a.insert("images/logo.png", b"\x89PNG".to_vec(), true);
+        // 이름 자체가 계약인 파일들 — 해시가 붙으면 기능이 깨진다
+        a.insert("CNAME", b"example.com".to_vec(), true);
+        a.insert("robots.txt", b"User-agent: *".to_vec(), true);
+
+        assert_ne!(a.url("css/main.css"), Some("/css/main.css"));
+        assert_ne!(a.url("js/app.js"), Some("/js/app.js"));
+        assert_eq!(a.url("images/logo.png"), Some("/images/logo.png"));
+        assert_eq!(a.url("CNAME"), Some("/CNAME"));
+        assert_eq!(a.url("robots.txt"), Some("/robots.txt"));
+    }
+
+    #[test]
+    fn fingerprinting_can_be_disabled() {
+        let mut a = Assets::default();
+        a.insert("css/main.css", b"body{}".to_vec(), false);
+        assert_eq!(a.url("css/main.css"), Some("/css/main.css"));
+    }
+
+    #[test]
+    fn lookup_tolerates_a_leading_slash() {
+        let mut a = Assets::default();
+        a.insert("css/main.css", b"x".to_vec(), false);
+        assert_eq!(a.url("/css/main.css"), a.url("css/main.css"));
+    }
+
+    #[test]
+    fn collects_from_disk_and_maps_logical_names() {
+        let root = std::env::temp_dir().join(format!("sqzass-assets-{}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&root);
+        let sdir = root.join("static/css");
+        std::fs::create_dir_all(&sdir).unwrap();
+        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();
+        assert!(a.url("css/main.css").unwrap().starts_with("/css/main."));
+        assert_eq!(a.url("CNAME"), Some("/CNAME"));
+        assert_eq!(a.files.len(), 2);
+
+        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();
+        assert!(a.files.is_empty());
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -1,5 +1,6 @@
 //! sqzass — Rust로 만든 정적 사이트 생성기.
 
+pub mod assets;
 pub mod config;
 pub mod content;
 pub mod highlight;
@@ -29,9 +30,8 @@ pub struct BuildOptions {
     pub base_url: Option<String>,
 }
 
-/// 생성된 하이라이트 스타일시트의 출력 경로. 에셋 파이프라인이 들어오면
-/// 콘텐츠 해시가 붙은 이름으로 바뀐다.
-const HIGHLIGHT_CSS_URL: &str = "/assets/highlight.css";
+/// 생성된 하이라이트 스타일시트의 논리 경로. 다른 에셋과 같은 규칙으로 해시가 붙는다.
+const HIGHLIGHT_ASSET: &str = "assets/highlight.css";
 
 #[derive(Debug, Default)]
 pub struct BuildStats {
@@ -67,27 +67,27 @@ pub fn build_to_memory(opts: &BuildOptions) -> Result<BuildOutput> {
 
     let pages = content::discover(root, &cfg, drafts)?;
     let site = Site::build(&pages, &cfg);
-    let templates = Templates::load(root)?;
+
+    // 에셋을 먼저 처리해야 템플릿이 해시가 붙은 최종 URL을 조회할 수 있다.
+    let mut assets = assets::Assets::collect(root, &cfg.assets)?;
 
     let mut md = markdown::Renderer::new(&cfg.markdown);
     let highlight_css_url = if cfg.highlight.enabled {
         md = md.with_highlighter(highlight::Highlighter::new());
-        Some(HIGHLIGHT_CSS_URL.to_string())
+        // 하이라이트 스타일시트는 테마에서 생성한다. HTML은 클래스만 담고 있으므로
+        // 이 파일 하나를 바꾸면 사이트 전체의 코드 색이 바뀐다.
+        let css = highlight::stylesheet(&cfg.highlight)?;
+        assets.insert(HIGHLIGHT_ASSET, css.into_bytes(), cfg.assets.fingerprint);
+        assets.url(HIGHLIGHT_ASSET).map(str::to_string)
     } else {
         None
     };
 
-    let mut out = BuildOutput::default();
+    assets.write_manifest();
+    let templates = Templates::load(root)?.with_assets(assets.manifest.clone());
 
-    // 하이라이트 스타일시트는 테마에서 생성한다. HTML은 클래스만 담고 있으므로
-    // 이 파일 하나를 바꾸면 사이트 전체의 코드 색이 바뀐다.
-    if cfg.highlight.enabled {
-        let css = highlight::stylesheet(&cfg.highlight)?;
-        out.files.insert(
-            HIGHLIGHT_CSS_URL.trim_start_matches('/').to_string(),
-            css.into_bytes(),
-        );
-    }
+    let mut out = BuildOutput::default();
+    out.files.extend(assets.files);
 
     for page in &pages {
         let html = render_page(
diff --git a/src/render.rs b/src/render.rs
@@ -93,6 +93,30 @@ impl Templates {
         Ok(Self { env, names })
     }
 
+    /// 템플릿에서 `{{ asset("css/main.css") }}`로 해시가 붙은 실제 URL을 얻게 한다.
+    ///
+    /// 없는 에셋은 **에러**다. 조용히 원래 경로를 돌려주면 오타가 404 링크로 배포되고,
+    /// minijinja 에러가 템플릿 이름과 줄 번호를 달고 나오므로 찾기도 쉽다.
+    pub fn with_assets(mut self, manifest: std::collections::BTreeMap<String, String>) -> Self {
+        self.env.add_function("asset", move |path: String| {
+            manifest
+                .get(path.trim_start_matches('/'))
+                .cloned()
+                .ok_or_else(|| {
+                    let mut known: Vec<&str> = manifest.keys().map(|s| s.as_str()).collect();
+                    known.sort_unstable();
+                    minijinja::Error::new(
+                        minijinja::ErrorKind::InvalidOperation,
+                        format!(
+                            "에셋 '{path}'이(가) 없습니다. 사용 가능: {}",
+                            known.join(", ")
+                        ),
+                    )
+                })
+        });
+        self
+    }
+
     pub fn has(&self, name: &str) -> bool {
         self.names.iter().any(|n| n == name)
     }