src/content.rs (17209B)
1 //! 콘텐츠 발견과 front matter 파싱.
2 //!
3 //! front matter는 TOML(`+++`)이 정본이다. `serde_yaml`이 아카이브된 상태라
4 //! YAML을 정본으로 삼으면 살아있는 파서를 고르는 문제부터 떠안게 된다.
5
6 use crate::config::Config;
7 use anyhow::{Context, Result, bail};
8 use serde::Deserialize;
9 use std::path::{Path, PathBuf};
10 use walkdir::WalkDir;
11
12 pub const CONTENT_DIR: &str = "content";
13 const FENCE: &str = "+++";
14
15 #[derive(Debug, Clone, Default, Deserialize)]
16 #[serde(default, deny_unknown_fields)]
17 pub struct FrontMatter {
18 /// 유일한 필수 필드. 없으면 좋은 에러를 내기 위해 Option으로 받는다.
19 pub title: Option<String>,
20 pub description: String,
21 pub weight: i64,
22 pub draft: bool,
23 /// 발행 날짜. `sort_by = "date"`와 피드가 쓴다.
24 /// TOML 날짜라 `2026-07-26` 도, `2026-07-26T09:30:00Z` 도 된다.
25 pub date: Option<toml::value::Datetime>,
26 /// 기본은 파일명 stem.
27 pub slug: Option<String>,
28 pub template: Option<String>,
29 pub toc: bool,
30 /// 기본은 파일명 stem 추론.
31 pub translation_key: Option<String>,
32 /// 이 페이지로 보낼 예전 URL들. 루트 절대 경로여야 한다.
33 pub aliases: Vec<String>,
34
35 // --- 섹션(`_index.md`) 전용 ---
36 pub sort_by: Option<crate::config::SortBy>,
37 pub page_template: Option<String>,
38
39 #[serde(default)]
40 pub extra: toml::Table,
41 }
42
43 #[derive(Debug, Clone)]
44 pub struct Page {
45 /// 소스 파일 절대 경로.
46 pub source: PathBuf,
47 /// `content/` 기준 상대 경로.
48 pub rel: PathBuf,
49 pub front: FrontMatter,
50 pub title: String,
51 /// front matter를 제거한 마크다운 본문.
52 pub body: String,
53 /// `body`의 첫 줄이 원본 파일에서 몇 번째 줄인지(1-based). 에러 보고에 쓴다.
54 pub body_line_offset: usize,
55 pub language: String,
56 /// 번역 연결 키. 기본은 파일명 stem 추론.
57 pub translation_key: String,
58 /// `/`, `/about/`, `/ko/start/installation/`
59 pub url: String,
60 /// 출력 디렉터리 기준 상대 경로. `index.html`, `about/index.html`
61 pub out_path: PathBuf,
62 /// `_index.md` 여부 (섹션 인덱스).
63 pub is_section: bool,
64 }
65
66 /// `content/` 아래의 모든 마크다운을 읽어 `Page`로 만든다.
67 ///
68 /// 드래프트는 `drafts`가 false면 제외한다.
69 pub fn discover(root: &Path, cfg: &Config, drafts: bool) -> Result<Vec<Page>> {
70 let content_root = root.join(CONTENT_DIR);
71 if !content_root.is_dir() {
72 bail!("{} 디렉터리가 없습니다", content_root.display());
73 }
74
75 let mut pages = Vec::new();
76 for entry in WalkDir::new(&content_root).sort_by_file_name() {
77 let entry = entry.context("content/ 순회 실패")?;
78 if !entry.file_type().is_file() {
79 continue;
80 }
81 let path = entry.path();
82 if path.extension().and_then(|e| e.to_str()) != Some("md") {
83 continue;
84 }
85
86 let page = parse_page(path, &content_root, cfg)?;
87 if page.front.draft && !drafts {
88 continue;
89 }
90 pages.push(page);
91 }
92
93 check_url_collisions(&pages)?;
94 Ok(pages)
95 }
96
97 fn parse_page(path: &Path, content_root: &Path, cfg: &Config) -> Result<Page> {
98 let raw = std::fs::read_to_string(path)
99 .with_context(|| format!("{}을(를) 읽을 수 없습니다", path.display()))?;
100
101 let (fm_text, body, body_line_offset) = split_front_matter(&raw, path)?;
102
103 // 빈 줄 하나를 앞에 붙여 toml이 세는 줄 번호를 원본 파일의 줄 번호에 맞춘다.
104 // 여는 `+++`가 항상 1번 줄이므로 front matter 본문은 2번 줄부터 시작한다.
105 // 이 한 줄이 없으면 에러가 가리키는 줄이 항상 하나씩 위를 가리킨다.
106 let front: FrontMatter = toml::from_str(&format!("\n{fm_text}"))
107 .with_context(|| format!("{}: front matter 파싱 실패", path.display()))?;
108
109 // TOML은 `10:30:00` 같은 시각도 날짜 타입으로 받는다. 그걸 그대로 두면
110 // 피드에도 정렬에도 쓰이지 못한 채 조용히 사라지고, 저자는 날짜를 적었다고
111 // 믿는다. 조용히 버리는 대신 멈춘다.
112 if let Some(dt) = &front.date
113 && dt.date.is_none()
114 {
115 anyhow::bail!(
116 "{}: date = {dt} 에 날짜가 없습니다 (시각만 있습니다).\n\
117 `2026-07-26` 이나 `2026-07-26T09:30:00+09:00` 처럼 적으세요.",
118 path.display()
119 );
120 }
121
122 let title = front.title.clone().ok_or_else(|| {
123 anyhow::anyhow!(
124 "{}: front matter에 필수 필드 `title`이 없습니다",
125 path.display()
126 )
127 })?;
128
129 let rel = path
130 .strip_prefix(content_root)
131 .unwrap_or(path)
132 .to_path_buf();
133
134 let name = NameParts::parse(&rel, cfg);
135 let slug = front.slug.clone().unwrap_or_else(|| name.stem.clone());
136 let translation_key = front
137 .translation_key
138 .clone()
139 .unwrap_or_else(|| name.translation_key.clone());
140
141 let (url, out_path) = build_url(&name, &slug, cfg);
142
143 Ok(Page {
144 source: path.to_path_buf(),
145 rel,
146 front,
147 title,
148 body,
149 body_line_offset,
150 language: name.language,
151 translation_key,
152 url,
153 out_path,
154 is_section: name.is_section,
155 })
156 }
157
158 /// 파일 경로에서 stem / 언어 / 섹션 여부를 뽑는다.
159 ///
160 /// `start/installation.md` → stem "installation", lang = default
161 /// `start/installation.ko.md` → stem "installation", lang = "ko"
162 /// `start/_index.md` → 섹션, stem "start"
163 struct NameParts {
164 /// URL의 마지막 세그먼트가 될 이름.
165 stem: String,
166 /// `content/` 기준 부모 디렉터리 세그먼트들.
167 dirs: Vec<String>,
168 language: String,
169 is_section: bool,
170 translation_key: String,
171 }
172
173 impl NameParts {
174 fn parse(rel: &Path, cfg: &Config) -> Self {
175 let dirs: Vec<String> = rel
176 .parent()
177 .map(|p| {
178 p.components()
179 .map(|c| c.as_os_str().to_string_lossy().into_owned())
180 .collect()
181 })
182 .unwrap_or_default();
183
184 // `foo.ko.md` → file_stem은 "foo.ko" 이므로 한 번 더 벗긴다.
185 let full_stem = rel
186 .file_stem()
187 .map(|s| s.to_string_lossy().into_owned())
188 .unwrap_or_default();
189
190 let (base, language) = match full_stem.rsplit_once('.') {
191 // 마지막 조각이 설정에 선언된 언어 코드일 때만 언어로 인정한다.
192 // 그렇지 않으면 `my.file.md` 같은 이름이 언어로 오인된다.
193 Some((base, maybe_lang)) if cfg.languages.contains_key(maybe_lang) => {
194 (base.to_string(), maybe_lang.to_string())
195 }
196 _ => (full_stem.clone(), cfg.default_language.clone()),
197 };
198
199 let is_section = base == "_index";
200 // 섹션의 이름은 자기 디렉터리 이름이다. 루트 섹션이면 빈 문자열.
201 let stem = if is_section {
202 dirs.last().cloned().unwrap_or_default()
203 } else {
204 base.clone()
205 };
206
207 // 번역 키는 언어를 뗀 경로. `start/installation`, `start/_index`
208 let mut key_parts = dirs.clone();
209 key_parts.push(base);
210 let translation_key = key_parts.join("/");
211
212 Self {
213 stem,
214 dirs,
215 language,
216 is_section,
217 translation_key,
218 }
219 }
220 }
221
222 /// URL과 출력 경로를 만든다. 항상 `path/index.html` 형태로 뽑는다 —
223 /// GitHub Pages처럼 rewrite 규칙이 없는 호스트에서 예쁜 URL을 내는 유일한 방법이다.
224 fn build_url(name: &NameParts, slug: &str, cfg: &Config) -> (String, PathBuf) {
225 let mut segments: Vec<String> = Vec::new();
226
227 // 기본 언어는 루트, 나머지는 `/<code>/` 아래.
228 if name.language != cfg.default_language {
229 segments.push(name.language.clone());
230 }
231
232 if name.is_section {
233 // 섹션의 URL은 자기 디렉터리 경로 그 자체다.
234 segments.extend(name.dirs.iter().cloned());
235 } else {
236 segments.extend(name.dirs.iter().cloned());
237 segments.push(slug.to_string());
238 }
239
240 // URL에는 서브경로가 들어가고 출력 경로에는 들어가지 않는다. 호스트가 서빙하는
241 // 루트가 곧 출력 디렉터리이고, 서브경로는 그 루트가 도메인 어디에 놓이는지의
242 // 문제이기 때문이다.
243 let base = cfg.base_path();
244 let url = if segments.is_empty() {
245 format!("{base}/")
246 } else {
247 format!("{base}/{}/", segments.join("/"))
248 };
249
250 let mut out = PathBuf::new();
251 for s in &segments {
252 out.push(s);
253 }
254 out.push("index.html");
255
256 (url, out)
257 }
258
259 /// 두 페이지가 같은 URL을 주장하면 하드 에러. 조용히 덮어쓰면 어느 쪽이 남는지가
260 /// 파일 순회 순서에 의존하게 되고, 그건 재현 불가능한 버그가 된다.
261 fn check_url_collisions(pages: &[Page]) -> Result<()> {
262 use std::collections::HashMap;
263 let mut seen: HashMap<&str, &Page> = HashMap::new();
264 for page in pages {
265 if let Some(prev) = seen.insert(&page.url, page) {
266 bail!(
267 "URL 충돌: '{}' 을(를) 두 파일이 주장합니다\n {}\n {}\n\
268 front matter의 `slug`로 한쪽을 바꾸세요.",
269 page.url,
270 prev.source.display(),
271 page.source.display()
272 );
273 }
274 }
275 Ok(())
276 }
277
278 /// `+++` 펜스로 front matter를 분리한다.
279 ///
280 /// 직접 자르는 이유: 파싱 에러를 **원본 파일의 실제 줄 번호**로 보고하려면 front matter가
281 /// 몇 줄을 먹었는지 알아야 하는데, 대부분의 크레이트가 그 정보를 잃는다.
282 fn split_front_matter(raw: &str, path: &Path) -> Result<(String, String, usize)> {
283 let text = raw.strip_prefix('\u{feff}').unwrap_or(raw);
284 let mut lines = text.lines();
285
286 let Some(first) = lines.next() else {
287 bail!("{}: 파일이 비어 있습니다", path.display());
288 };
289
290 if first.trim_end() != FENCE {
291 if first.trim_end() == "---" {
292 bail!(
293 "{}: front matter가 `---`로 시작합니다. sqzass는 TOML front matter를 쓰므로 \
294 `+++`로 감싸주세요.",
295 path.display()
296 );
297 }
298 bail!(
299 "{}: 파일이 `{}` front matter로 시작해야 합니다",
300 path.display(),
301 FENCE
302 );
303 }
304
305 let mut fm = String::new();
306 let mut closed_at = None;
307 // 1번 줄은 여는 펜스이므로 본문 후보는 2번 줄부터.
308 for (idx, line) in lines.enumerate() {
309 if line.trim_end() == FENCE {
310 closed_at = Some(idx + 2); // 닫는 펜스의 1-based 줄 번호
311 break;
312 }
313 fm.push_str(line);
314 fm.push('\n');
315 }
316
317 let Some(closed_at) = closed_at else {
318 bail!(
319 "{}: front matter를 닫는 `{}`를 찾지 못했습니다",
320 path.display(),
321 FENCE
322 );
323 };
324
325 // 닫는 펜스 다음 줄부터가 본문.
326 let body: String = text.lines().skip(closed_at).collect::<Vec<_>>().join("\n");
327
328 Ok((fm, body, closed_at + 1))
329 }
330
331 #[cfg(test)]
332 mod tests {
333 use super::*;
334
335 fn cfg() -> Config {
336 toml::from_str(
337 r#"
338 title = "t"
339 base_url = "https://example.com"
340 default_language = "en"
341 [languages.en]
342 name = "English"
343 [languages.ko]
344 name = "한국어"
345 "#,
346 )
347 .unwrap()
348 }
349
350 /// front matter 오타도 조용히 무시하지 않는다. 그리고 에러가 가리키는 줄은
351 /// **원본 파일의** 줄이어야 한다 — 펜스를 손으로 자른 이유가 그것이다.
352 /// URL에는 서브경로가 들어가고 출력 경로에는 들어가지 않는다. 호스트가 서빙하는
353 /// 루트가 곧 출력 디렉터리이기 때문이다.
354 #[test]
355 fn base_path_reaches_urls_but_not_output_paths() {
356 let cfg: Config =
357 toml::from_str("title = \"t\"\nbase_url = \"https://user.github.io/repo\"\n").unwrap();
358
359 let parts = NameParts::parse(Path::new("start/install.md"), &cfg);
360 let (url, out) = build_url(&parts, &parts.stem, &cfg);
361 assert_eq!(url, "/repo/start/install/");
362 assert_eq!(out, Path::new("start/install/index.html"));
363
364 let root = NameParts::parse(Path::new("_index.md"), &cfg);
365 let (url, out) = build_url(&root, &root.stem, &cfg);
366 assert_eq!(url, "/repo/");
367 assert_eq!(out, Path::new("index.html"));
368 }
369
370 #[test]
371 fn unknown_front_matter_key_errors_at_the_real_line() {
372 let dir = std::env::temp_dir().join(format!("sqzass-fm-{}", std::process::id()));
373 let _ = std::fs::remove_dir_all(&dir);
374 std::fs::create_dir_all(dir.join("content")).unwrap();
375 let path = dir.join("content/_index.md");
376 std::fs::write(&path, "+++\ntitle = \"x\"\nweigth = 10\n+++\n\nbody\n").unwrap();
377
378 let cfg: Config =
379 toml::from_str("title = \"t\"\nbase_url = \"https://example.com\"\n").unwrap();
380 // anyhow는 `{}`에 최상위 컨텍스트만 낸다. 원인 사슬은 `{:#}`이라야 보인다.
381 let err = format!(
382 "{:#}",
383 parse_page(&path, &dir.join("content"), &cfg).unwrap_err()
384 );
385 let _ = std::fs::remove_dir_all(&dir);
386
387 assert!(err.contains("weigth"), "실제: {err}");
388 assert!(err.contains("weight"), "후보를 제시해야 한다: {err}");
389 // `weigth`는 파일의 3번 줄이다. 펜스를 세지 않으면 2번 줄이라고 말한다.
390 assert!(err.contains("line 3"), "원본 파일 줄 번호가 아니다: {err}");
391 }
392
393 #[test]
394 fn splits_front_matter_and_tracks_line_offset() {
395 let raw = "+++\ntitle = \"Hi\"\n+++\n\n# Body\n";
396 let (fm, body, offset) = split_front_matter(raw, Path::new("x.md")).unwrap();
397 assert_eq!(fm.trim(), "title = \"Hi\"");
398 assert_eq!(body, "\n# Body");
399 // `+++`(1) `title`(2) `+++`(3) → 본문은 4번째 줄부터
400 assert_eq!(offset, 4);
401 }
402
403 #[test]
404 fn rejects_yaml_fence_with_a_useful_message() {
405 let raw = "---\ntitle: Hi\n---\n";
406 let err = split_front_matter(raw, Path::new("x.md"))
407 .unwrap_err()
408 .to_string();
409 assert!(err.contains("+++"), "실제 메시지: {err}");
410 }
411
412 #[test]
413 fn rejects_unterminated_front_matter() {
414 let raw = "+++\ntitle = \"Hi\"\n\n# Body\n";
415 assert!(split_front_matter(raw, Path::new("x.md")).is_err());
416 }
417
418 #[test]
419 fn url_for_root_index() {
420 let cfg = cfg();
421 let parts = NameParts::parse(Path::new("_index.md"), &cfg);
422 let (url, out) = build_url(&parts, &parts.stem, &cfg);
423 assert_eq!(url, "/");
424 assert_eq!(out, PathBuf::from("index.html"));
425 }
426
427 #[test]
428 fn url_for_nested_page() {
429 let cfg = cfg();
430 let parts = NameParts::parse(Path::new("start/installation.md"), &cfg);
431 let (url, out) = build_url(&parts, &parts.stem, &cfg);
432 assert_eq!(url, "/start/installation/");
433 assert_eq!(out, PathBuf::from("start/installation/index.html"));
434 }
435
436 #[test]
437 fn url_for_section_index() {
438 let cfg = cfg();
439 let parts = NameParts::parse(Path::new("start/_index.md"), &cfg);
440 assert!(parts.is_section);
441 let (url, out) = build_url(&parts, &parts.stem, &cfg);
442 assert_eq!(url, "/start/");
443 assert_eq!(out, PathBuf::from("start/index.html"));
444 }
445
446 #[test]
447 fn korean_pages_go_under_language_prefix() {
448 let cfg = cfg();
449 let parts = NameParts::parse(Path::new("start/installation.ko.md"), &cfg);
450 assert_eq!(parts.language, "ko");
451 assert_eq!(parts.stem, "installation");
452 // 번역 키는 언어를 뗀 경로여서 영어판과 짝이 맞는다
453 assert_eq!(parts.translation_key, "start/installation");
454 let (url, out) = build_url(&parts, &parts.stem, &cfg);
455 assert_eq!(url, "/ko/start/installation/");
456 assert_eq!(out, PathBuf::from("ko/start/installation/index.html"));
457 }
458
459 #[test]
460 fn translation_keys_match_across_languages() {
461 let cfg = cfg();
462 let en = NameParts::parse(Path::new("start/installation.md"), &cfg);
463 let ko = NameParts::parse(Path::new("start/installation.ko.md"), &cfg);
464 assert_eq!(en.translation_key, ko.translation_key);
465 }
466
467 #[test]
468 fn unknown_dotted_suffix_is_not_treated_as_a_language() {
469 let cfg = cfg();
470 // "backup"은 선언된 언어가 아니므로 파일명 일부로 남아야 한다
471 let parts = NameParts::parse(Path::new("notes.backup.md"), &cfg);
472 assert_eq!(parts.language, "en");
473 assert_eq!(parts.stem, "notes.backup");
474 }
475 }