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)
}
|