sqzass.git

sqzass

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

src/config.rs (11702B)

      1 //! `sqzass.toml` 파싱.
      2 //!
      3 //! 설계 원칙: 작고 직교하게. 아무 일도 하지 않는 설정 키는 넣지 않는다.
      4 
      5 use anyhow::{Context, Result};
      6 use serde::Deserialize;
      7 use std::collections::BTreeMap;
      8 use std::path::Path;
      9 
     10 pub const CONFIG_FILE: &str = "sqzass.toml";
     11 
     12 #[derive(Debug, Clone, Deserialize)]
     13 #[serde(deny_unknown_fields)]
     14 pub struct Config {
     15     pub title: String,
     16     #[serde(default)]
     17     pub description: String,
     18     pub base_url: String,
     19     #[serde(default = "default_language")]
     20     pub default_language: String,
     21 
     22     #[serde(default)]
     23     pub languages: BTreeMap<String, Language>,
     24     #[serde(default)]
     25     pub build: Build,
     26     #[serde(default)]
     27     pub markdown: Markdown,
     28     #[serde(default)]
     29     pub highlight: Highlight,
     30     #[serde(default)]
     31     pub assets: Assets,
     32     #[serde(default)]
     33     pub nav: Nav,
     34     #[serde(default)]
     35     pub search: Search,
     36 }
     37 
     38 #[derive(Debug, Clone, Deserialize)]
     39 #[serde(deny_unknown_fields)]
     40 pub struct Language {
     41     pub name: String,
     42     #[serde(default)]
     43     pub weight: i64,
     44 }
     45 
     46 #[derive(Debug, Clone, Deserialize)]
     47 #[serde(default, deny_unknown_fields)]
     48 pub struct Build {
     49     pub output_dir: String,
     50     pub drafts: bool,
     51 }
     52 
     53 impl Default for Build {
     54     fn default() -> Self {
     55         Self {
     56             output_dir: "public".into(),
     57             drafts: false,
     58         }
     59     }
     60 }
     61 
     62 #[derive(Debug, Clone, Deserialize)]
     63 #[serde(default, deny_unknown_fields)]
     64 pub struct Markdown {
     65     pub footnotes: bool,
     66     pub tables: bool,
     67     pub tasklist: bool,
     68     pub strikethrough: bool,
     69     pub autolink: bool,
     70     /// GitHub `> [!NOTE]` 스타일 admonition.
     71     pub alerts: bool,
     72     /// ⚠️ 한국어에 필수. 끄면 `**강조**한다` 같은 구성이 파싱되지 않는다.
     73     pub cjk_friendly_emphasis: bool,
     74     /// `none` | `left` | `right`
     75     pub heading_anchors: HeadingAnchors,
     76 }
     77 
     78 impl Default for Markdown {
     79     fn default() -> Self {
     80         Self {
     81             footnotes: true,
     82             tables: true,
     83             tasklist: true,
     84             strikethrough: true,
     85             autolink: true,
     86             alerts: true,
     87             cjk_friendly_emphasis: true,
     88             heading_anchors: HeadingAnchors::Right,
     89         }
     90     }
     91 }
     92 
     93 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
     94 #[serde(rename_all = "lowercase")]
     95 pub enum HeadingAnchors {
     96     None,
     97     Left,
     98     Right,
     99 }
    100 
    101 #[derive(Debug, Clone, Deserialize)]
    102 #[serde(default, deny_unknown_fields)]
    103 pub struct Highlight {
    104     pub enabled: bool,
    105     pub theme_light: String,
    106     pub theme_dark: String,
    107 }
    108 
    109 impl Default for Highlight {
    110     fn default() -> Self {
    111         Self {
    112             enabled: true,
    113             theme_light: "InspiredGitHub".into(),
    114             theme_dark: "base16-ocean.dark".into(),
    115         }
    116     }
    117 }
    118 
    119 #[derive(Debug, Clone, Deserialize)]
    120 #[serde(default, deny_unknown_fields)]
    121 pub struct Assets {
    122     pub source_dir: String,
    123     pub fingerprint: bool,
    124 }
    125 
    126 impl Default for Assets {
    127     fn default() -> Self {
    128         Self {
    129             source_dir: "static".into(),
    130             fingerprint: true,
    131         }
    132     }
    133 }
    134 
    135 #[derive(Debug, Clone, Deserialize)]
    136 #[serde(default, deny_unknown_fields)]
    137 pub struct Search {
    138     /// 검색 색인(`search-<언어>.json`) 생성 여부.
    139     ///
    140     /// 색인은 전 페이지의 본문 평문을 담으므로 코퍼스 크기에 비례한다(이 문서
    141     /// 사이트에서 언어당 ~80KB, 5000페이지 실측에서 16MB). 검색 UI가 없는
    142     /// 사이트가 그 바이트를 강제로 지불할 이유가 없다.
    143     pub enabled: bool,
    144 }
    145 
    146 impl Default for Search {
    147     fn default() -> Self {
    148         Self { enabled: true }
    149     }
    150 }
    151 
    152 #[derive(Debug, Clone, Deserialize)]
    153 #[serde(default, deny_unknown_fields)]
    154 pub struct Nav {
    155     /// `weight` | `title`
    156     pub sort_by: SortBy,
    157 }
    158 
    159 impl Default for Nav {
    160     fn default() -> Self {
    161         Self {
    162             sort_by: SortBy::Weight,
    163         }
    164     }
    165 }
    166 
    167 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
    168 #[serde(rename_all = "lowercase")]
    169 pub enum SortBy {
    170     Weight,
    171     Title,
    172     /// **최신이 먼저.** weight와 title은 오름차순인데 이것만 내림차순인 건
    173     /// 날짜순 목록에서 사람이 기대하는 게 최신 글이기 때문이다. 날짜가 없는
    174     /// 페이지는 뒤로 간다.
    175     Date,
    176 }
    177 
    178 fn default_language() -> String {
    179     "en".into()
    180 }
    181 
    182 impl Config {
    183     /// 사이트 루트에서 `sqzass.toml`을 읽는다.
    184     ///
    185     /// 모르는 키는 **에러**다. 조용히 무시되는 설정은 "깨진 참조는 빌드를 멈춘다"는
    186     /// 원칙의 정확한 위반이다 — `theme_ligth`라고 쓴 사람은 테마를 바꾼 줄 알고 있고,
    187     /// 바뀌지 않은 이유를 찾느라 시간을 쓴다.
    188     ///
    189     /// 후보 목록과 줄 번호는 serde와 toml이 만들어 준다. 유효한 키 목록을 손으로
    190     /// 관리하면 필드를 추가할 때마다 같이 고쳐야 하고, 언젠가 잊는다.
    191     pub fn load(root: &Path) -> Result<Self> {
    192         let path = root.join(CONFIG_FILE);
    193         let raw = std::fs::read_to_string(&path)
    194             .with_context(|| format!("{}을(를) 읽을 수 없습니다", path.display()))?;
    195 
    196         let cfg: Config =
    197             toml::from_str(&raw).with_context(|| format!("{} 파싱 실패", path.display()))?;
    198         cfg.validate()?;
    199         Ok(cfg)
    200     }
    201 
    202     fn validate(&self) -> Result<()> {
    203         // canonical URL, sitemap `<loc>`, Atom의 `id`, OpenGraph의 `og:url`이
    204         // 전부 여기서 나온다. 스킴이 없으면 그것들이 전부 상대 참조가 되는데,
    205         // RFC 4287은 상대 참조를 아예 금지하고 나머지도 조용히 틀린다.
    206         if !self.base_url.contains("://") {
    207             anyhow::bail!(
    208                 "base_url = \"{}\" 에 스킴이 없습니다. 절대 URL이어야 합니다 \
    209                  (예: https://example.com). canonical·sitemap·피드가 전부 이 값에서 나옵니다.",
    210                 self.base_url
    211             );
    212         }
    213         if !self.languages.is_empty() && !self.languages.contains_key(&self.default_language) {
    214             anyhow::bail!(
    215                 "default_language = \"{}\" 인데 [languages.{}] 가 정의되어 있지 않습니다",
    216                 self.default_language,
    217                 self.default_language
    218             );
    219         }
    220         Ok(())
    221     }
    222 
    223     /// `base_url`에서 뒤쪽 슬래시를 제거한 형태.
    224     pub fn base_url_trimmed(&self) -> &str {
    225         self.base_url.trim_end_matches('/')
    226     }
    227 
    228     /// 스킴과 호스트만. `https://user.github.io/repo` → `https://user.github.io`
    229     ///
    230     /// 절대 URL(canonical, sitemap, OpenGraph)을 조립할 때 쓴다. 페이지 URL이 이미
    231     /// 서브경로를 품고 있으므로 여기에 경로가 또 들어가면 두 번 붙는다.
    232     pub fn origin(&self) -> &str {
    233         let trimmed = self.base_url_trimmed();
    234         // `://` 다음의 첫 슬래시가 경로의 시작이다. 스킴이 없으면 경로도 없다고 본다.
    235         match trimmed.find("://") {
    236             Some(i) => match trimmed[i + 3..].find('/') {
    237                 Some(j) => &trimmed[..i + 3 + j],
    238                 None => trimmed,
    239             },
    240             None => trimmed,
    241         }
    242     }
    243 
    244     /// 사이트가 도메인 루트가 아닌 곳에 놓일 때의 경로. 루트면 빈 문자열.
    245     ///
    246     /// `https://user.github.io/repo` → `/repo`
    247     ///
    248     /// GitHub·GitLab·Codeberg의 **프로젝트 페이지가 기본으로 이 모양**이다. 도메인을
    249     /// 따로 사지 않은 사람이 가장 먼저 만나는 형태이므로, 여기서 URL이 어긋나면
    250     /// 사이트 전체가 404가 된다 — 빌드는 성공한 채로.
    251     pub fn base_path(&self) -> &str {
    252         let trimmed = self.base_url_trimmed();
    253         let origin = self.origin();
    254         trimmed.strip_prefix(origin).unwrap_or("")
    255     }
    256 }
    257 
    258 #[cfg(test)]
    259 mod tests {
    260     use super::*;
    261 
    262     /// 프로젝트 페이지(`user.github.io/repo`)는 도메인을 사지 않은 사람의 기본
    263     /// 상황이다. 여기서 origin과 경로를 잘못 나누면 사이트 전체가 404가 된다.
    264     #[test]
    265     fn splits_origin_from_base_path() {
    266         let cfg = |url: &str| -> Config {
    267             toml::from_str(&format!("title = \"t\"\nbase_url = \"{url}\"\n")).unwrap()
    268         };
    269 
    270         let root = cfg("https://sqzass.sqzer.com");
    271         assert_eq!(root.origin(), "https://sqzass.sqzer.com");
    272         assert_eq!(root.base_path(), "", "루트 사이트에는 접두사가 없다");
    273 
    274         let trailing = cfg("https://sqzass.sqzer.com/");
    275         assert_eq!(trailing.origin(), "https://sqzass.sqzer.com");
    276         assert_eq!(trailing.base_path(), "");
    277 
    278         let project = cfg("https://user.github.io/repo");
    279         assert_eq!(project.origin(), "https://user.github.io");
    280         assert_eq!(project.base_path(), "/repo");
    281 
    282         let nested = cfg("https://example.com/a/b/");
    283         assert_eq!(nested.origin(), "https://example.com");
    284         assert_eq!(nested.base_path(), "/a/b");
    285 
    286         let port = cfg("http://localhost:3000/site");
    287         assert_eq!(port.origin(), "http://localhost:3000");
    288         assert_eq!(port.base_path(), "/site");
    289     }
    290 
    291     /// 조용히 무시되는 설정은 "깨진 참조는 빌드를 멈춘다"의 정확한 위반이다.
    292     #[test]
    293     fn base_url_must_be_absolute() {
    294         let cfg: Config = toml::from_str("title = \"t\"\nbase_url = \"example.com\"\n").unwrap();
    295         let err = cfg.validate().unwrap_err().to_string();
    296         assert!(err.contains("스킴이 없습니다"), "실제: {err}");
    297         let ok: Config =
    298             toml::from_str("title = \"t\"\nbase_url = \"https://example.com\"\n").unwrap();
    299         assert!(ok.validate().is_ok());
    300     }
    301 
    302     #[test]
    303     fn unknown_keys_are_an_error() {
    304         let err = toml::from_str::<Config>(
    305             r#"
    306             title = "t"
    307             base_url = "https://example.com"
    308             [highlight]
    309             theme_ligth = "InspiredGitHub"
    310             "#,
    311         )
    312         .unwrap_err()
    313         .to_string();
    314         assert!(err.contains("theme_ligth"), "실제: {err}");
    315         // 후보 목록은 serde가 만들어 준다. 손으로 관리하는 목록은 언젠가 어긋난다.
    316         assert!(err.contains("theme_light"), "실제: {err}");
    317     }
    318 
    319     #[test]
    320     fn unknown_top_level_keys_are_an_error() {
    321         let err = toml::from_str::<Config>(
    322             r#"
    323             title = "t"
    324             base_url = "https://example.com"
    325             [markdwon]
    326             tables = false
    327             "#,
    328         )
    329         .unwrap_err()
    330         .to_string();
    331         assert!(err.contains("markdwon"), "실제: {err}");
    332         assert!(err.contains("markdown"), "실제: {err}");
    333     }
    334 
    335     #[test]
    336     fn defaults_apply() {
    337         let cfg: Config = toml::from_str(
    338             r#"
    339             title = "t"
    340             base_url = "https://example.com"
    341             "#,
    342         )
    343         .unwrap();
    344         assert_eq!(cfg.default_language, "en");
    345         assert_eq!(cfg.build.output_dir, "public");
    346         // 한국어 필수 항목이 기본으로 켜져 있어야 한다
    347         assert!(cfg.markdown.cjk_friendly_emphasis);
    348         assert_eq!(cfg.markdown.heading_anchors, HeadingAnchors::Right);
    349     }
    350 
    351     #[test]
    352     fn rejects_default_language_without_definition() {
    353         let cfg: Result<Config> = toml::from_str::<Config>(
    354             r#"
    355             title = "t"
    356             base_url = "https://example.com"
    357             default_language = "en"
    358             [languages.ko]
    359             name = "한국어"
    360             "#,
    361         )
    362         .map_err(Into::into);
    363         assert!(cfg.unwrap().validate().is_err());
    364     }
    365 }