sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git

src/assets.rs (10280B)

      1 //! 정적 에셋 수집과 콘텐츠 해시 기반 파일명.
      2 //!
      3 //! 파일명에 해시를 넣는다. 쿼리 문자열(`?v=…`) 방식은 두 가지가 나쁘다: 일부 CDN과
      4 //! 프록시가 쿼리를 캐시 키에서 빼버리고, 실무에서 흔히 보이는 "빌드마다 하나의 스탬프를
      5 //! 전 파일에 붙이는" 형태는 CSS 한 줄만 고쳐도 나머지 전부를 무효화한다.
      6 //! 파일별 콘텐츠 해시는 바뀐 파일만 무효화되고, 모든 호스트에서 동일하게 동작한다.
      7 
      8 use crate::config::Assets as AssetsConfig;
      9 use anyhow::{Context, Result};
     10 use std::collections::BTreeMap;
     11 use std::path::Path;
     12 use walkdir::WalkDir;
     13 
     14 /// 해시를 붙일 확장자. 이미지·폰트는 대개 마크다운이나 CSS에서 문자열 경로로 직접
     15 /// 참조되므로 이름을 바꾸면 그 참조가 깨진다. `CNAME`처럼 이름 자체가 계약인 파일도
     16 /// 있어서, 해시는 `asset()`으로 조회하는 것들에만 적용한다.
     17 const FINGERPRINTED: &[&str] = &["css", "js"];
     18 
     19 pub const MANIFEST_PATH: &str = "asset-manifest.json";
     20 
     21 #[derive(Debug, Default)]
     22 pub struct Assets {
     23     /// 출력 경로(상대) → 내용
     24     pub files: BTreeMap<String, Vec<u8>>,
     25     /// 논리 경로(`css/main.css`) → 출력 URL(`/css/main.a1b2c3d4.css`)
     26     pub manifest: BTreeMap<String, String>,
     27     /// 사이트가 도메인 루트가 아닐 때의 접두사. 페이지 URL과 같은 규칙을 따라야
     28     /// `<link href>`와 내비게이션 링크가 같은 곳을 가리킨다.
     29     base_path: String,
     30 }
     31 
     32 impl Assets {
     33     /// `static/` 아래를 전부 읽어들인다.
     34     pub fn collect(root: &Path, cfg: &AssetsConfig, base_path: &str) -> Result<Self> {
     35         let mut out = Self {
     36             base_path: base_path.to_string(),
     37             ..Self::default()
     38         };
     39         let dir = root.join(&cfg.source_dir);
     40         if !dir.is_dir() {
     41             return Ok(out);
     42         }
     43 
     44         for entry in WalkDir::new(&dir).sort_by_file_name() {
     45             let entry = entry.with_context(|| format!("{} 순회 실패", dir.display()))?;
     46             if !entry.file_type().is_file() {
     47                 continue;
     48             }
     49             let path = entry.path();
     50             let logical = path
     51                 .strip_prefix(&dir)
     52                 .unwrap_or(path)
     53                 .to_string_lossy()
     54                 .replace('\\', "/");
     55             let bytes = std::fs::read(path)
     56                 .with_context(|| format!("{}을(를) 읽을 수 없습니다", path.display()))?;
     57             out.insert(&logical, bytes, cfg.fingerprint);
     58         }
     59         Ok(out)
     60     }
     61 
     62     /// 빌드가 생성한 에셋(예: 하이라이트 스타일시트)을 같은 규칙으로 넣는다.
     63     pub fn insert(&mut self, logical: &str, bytes: Vec<u8>, fingerprint: bool) {
     64         let ext = Path::new(logical)
     65             .extension()
     66             .and_then(|e| e.to_str())
     67             .unwrap_or("");
     68         let out_path = if fingerprint && FINGERPRINTED.contains(&ext) {
     69             hashed_name(logical, &bytes)
     70         } else {
     71             logical.to_string()
     72         };
     73         let base = &self.base_path;
     74         self.manifest
     75             .insert(logical.to_string(), format!("{base}/{out_path}"));
     76         self.files.insert(out_path, bytes);
     77     }
     78 
     79     /// 논리 경로로 출력 URL을 찾는다.
     80     pub fn url(&self, logical: &str) -> Option<&str> {
     81         self.manifest
     82             .get(logical.trim_start_matches('/'))
     83             .map(|s| s.as_str())
     84     }
     85 
     86     /// 매니페스트를 출력에 포함시킨다. 서비스 워커나 외부 도구가 논리 이름과
     87     /// 실제 파일을 맞출 수 있어야 한다.
     88     pub fn write_manifest(&mut self) {
     89         let json = serde_json::to_string_pretty(&self.manifest).unwrap_or_else(|_| "{}".into());
     90         self.files
     91             .insert(MANIFEST_PATH.to_string(), json.into_bytes());
     92     }
     93 }
     94 
     95 /// `css/main.css` + 내용 → `css/main.a1b2c3d4.css`
     96 ///
     97 /// blake3를 쓴다. 캐시 무효화가 목적이라 암호학적 강도는 필요 없고, 속도가 중요하다.
     98 fn hashed_name(logical: &str, bytes: &[u8]) -> String {
     99     let hash = blake3::hash(bytes);
    100     let short = &hash.to_hex()[..8];
    101 
    102     let path = Path::new(logical);
    103     let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
    104     let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(logical);
    105     let parent = path.parent().map(|p| p.to_string_lossy().into_owned());
    106 
    107     let name = if ext.is_empty() {
    108         format!("{stem}.{short}")
    109     } else {
    110         format!("{stem}.{short}.{ext}")
    111     };
    112     match parent.as_deref() {
    113         Some("") | None => name,
    114         Some(p) => format!("{p}/{name}"),
    115     }
    116 }
    117 
    118 #[cfg(test)]
    119 mod tests {
    120     use super::*;
    121 
    122     fn cfg() -> AssetsConfig {
    123         AssetsConfig::default()
    124     }
    125 
    126     #[test]
    127     fn hashes_go_before_the_extension() {
    128         let n = hashed_name("css/main.css", b"body{}");
    129         assert!(n.starts_with("css/main."), "실제: {n}");
    130         assert!(n.ends_with(".css"), "실제: {n}");
    131         // css/main.<8자>.css
    132         assert_eq!(n.matches('.').count(), 2, "실제: {n}");
    133     }
    134 
    135     #[test]
    136     fn different_content_gives_a_different_name() {
    137         assert_ne!(
    138             hashed_name("a.css", b"one"),
    139             hashed_name("a.css", b"two"),
    140             "내용이 다른데 이름이 같으면 캐시가 안 바뀐다"
    141         );
    142     }
    143 
    144     #[test]
    145     fn same_content_is_stable_across_builds() {
    146         // 빌드마다 이름이 달라지면 재현 가능한 빌드가 깨진다.
    147         assert_eq!(hashed_name("a.css", b"same"), hashed_name("a.css", b"same"));
    148     }
    149 
    150     #[test]
    151     fn root_level_files_keep_a_flat_name() {
    152         let n = hashed_name("main.css", b"x");
    153         assert!(!n.contains('/'), "실제: {n}");
    154     }
    155 
    156     #[test]
    157     fn only_css_and_js_are_fingerprinted() {
    158         let mut a = Assets::default();
    159         a.insert("css/main.css", b"body{}".to_vec(), true);
    160         a.insert("js/app.js", b"//x".to_vec(), true);
    161         a.insert("images/logo.png", b"\x89PNG".to_vec(), true);
    162         // 이름 자체가 계약인 파일들 — 해시가 붙으면 기능이 깨진다
    163         a.insert("CNAME", b"example.com".to_vec(), true);
    164         a.insert("robots.txt", b"User-agent: *".to_vec(), true);
    165 
    166         assert_ne!(a.url("css/main.css"), Some("/css/main.css"));
    167         assert_ne!(a.url("js/app.js"), Some("/js/app.js"));
    168         assert_eq!(a.url("images/logo.png"), Some("/images/logo.png"));
    169         assert_eq!(a.url("CNAME"), Some("/CNAME"));
    170         assert_eq!(a.url("robots.txt"), Some("/robots.txt"));
    171     }
    172 
    173     #[test]
    174     fn fingerprinting_can_be_disabled() {
    175         let mut a = Assets::default();
    176         a.insert("css/main.css", b"body{}".to_vec(), false);
    177         assert_eq!(a.url("css/main.css"), Some("/css/main.css"));
    178     }
    179 
    180     #[test]
    181     fn lookup_tolerates_a_leading_slash() {
    182         let mut a = Assets::default();
    183         a.insert("css/main.css", b"x".to_vec(), false);
    184         assert_eq!(a.url("/css/main.css"), a.url("css/main.css"));
    185     }
    186 
    187     #[test]
    188     fn collects_from_disk_and_maps_logical_names() {
    189         let root = std::env::temp_dir().join(format!("sqzass-assets-{}", std::process::id()));
    190         let _ = std::fs::remove_dir_all(&root);
    191         let sdir = root.join("static/css");
    192         std::fs::create_dir_all(&sdir).unwrap();
    193         std::fs::write(sdir.join("main.css"), "body{color:red}").unwrap();
    194         std::fs::write(root.join("static/CNAME"), "example.com").unwrap();
    195 
    196         let a = Assets::collect(&root, &cfg(), "").unwrap();
    197         assert!(a.url("css/main.css").unwrap().starts_with("/css/main."));
    198         assert_eq!(a.url("CNAME"), Some("/CNAME"));
    199         assert_eq!(a.files.len(), 2);
    200 
    201         // 서브경로에 올라가는 사이트는 에셋 URL도 같은 접두사를 가져야 한다.
    202         // 페이지 링크만 접두사를 갖고 스타일시트는 안 갖는 상태가 가장 나쁘다:
    203         // 사이트는 뜨는데 아무 스타일도 안 먹는다.
    204         let sub = Assets::collect(&root, &cfg(), "/repo").unwrap();
    205         assert!(
    206             sub.url("css/main.css")
    207                 .unwrap()
    208                 .starts_with("/repo/css/main."),
    209             "실제: {:?}",
    210             sub.url("css/main.css")
    211         );
    212         assert_eq!(sub.url("CNAME"), Some("/repo/CNAME"));
    213 
    214         std::fs::remove_dir_all(&root).ok();
    215     }
    216 
    217     /// 호스트별 설정 파일은 **이름이 곧 계약**이다. `_headers.9f8e7d6c`는 Netlify가
    218     /// 영영 찾지 않을 파일이고, `.domains.abc`도 Codeberg가 찾지 않는다. 이게
    219     /// 조용히 깨지면 배포는 성공하면서 도메인만 안 붙는다.
    220     #[test]
    221     fn host_config_files_pass_through_with_their_names() {
    222         let root =
    223             std::env::temp_dir().join(format!("sqzass-assets-passthru-{}", std::process::id()));
    224         let _ = std::fs::remove_dir_all(&root);
    225         let s = root.join("static");
    226         std::fs::create_dir_all(s.join(".well-known")).unwrap();
    227         for (name, body) in [
    228             ("_headers", "/*\n  X-Frame-Options: DENY\n"),
    229             ("_redirects", "/old /new 301\n"),
    230             (".domains", "example.com\n"),
    231             ("CNAME", "example.com\n"),
    232             ("robots.txt", "User-agent: *\n"),
    233         ] {
    234             std::fs::write(s.join(name), body).unwrap();
    235         }
    236         std::fs::write(s.join(".well-known/security.txt"), "Contact: x\n").unwrap();
    237 
    238         let a = Assets::collect(&root, &cfg(), "").unwrap();
    239         for name in [
    240             "_headers",
    241             "_redirects",
    242             ".domains",
    243             "CNAME",
    244             "robots.txt",
    245             ".well-known/security.txt",
    246         ] {
    247             assert_eq!(
    248                 a.url(name),
    249                 Some(format!("/{name}").as_str()),
    250                 "{name} 의 이름이 바뀌었다"
    251             );
    252             assert!(a.files.contains_key(name), "{name} 이 출력에 없다");
    253         }
    254         let _ = std::fs::remove_dir_all(&root);
    255     }
    256 
    257     #[test]
    258     fn missing_static_dir_is_not_an_error() {
    259         let root = std::env::temp_dir().join("sqzass-assets-nonexistent-xyz");
    260         let a = Assets::collect(&root, &cfg(), "").unwrap();
    261         assert!(a.files.is_empty());
    262     }
    263 }