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 }