1use crate::ThemeId;
4use anyhow::{Context, Result};
5use chrono::NaiveDate;
6use serde::{Deserialize, Deserializer};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use walkdir::WalkDir;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ContentFormat {
14 Typst,
16 Markdown,
18}
19
20#[derive(Debug)]
22pub struct Content {
23 pub path: PathBuf,
25 pub meta: ContentMeta,
27 pub content_file: PathBuf,
29 pub source_format: ContentFormat,
31 pub slides_file: Option<PathBuf>,
33 pub assets: Vec<PathBuf>,
35}
36
37fn deserialize_date<'de, D>(deserializer: D) -> std::result::Result<NaiveDate, D::Error>
39where
40 D: Deserializer<'de>,
41{
42 use serde::de::Error;
43
44 let value = toml::Value::deserialize(deserializer)?;
46
47 match value {
48 toml::Value::Datetime(dt) => {
49 let date = dt
51 .date
52 .ok_or_else(|| D::Error::custom("datetime missing date"))?;
53 NaiveDate::from_ymd_opt(date.year as i32, date.month as u32, date.day as u32)
54 .ok_or_else(|| D::Error::custom("invalid date"))
55 }
56 toml::Value::String(s) => {
57 NaiveDate::parse_from_str(&s, "%Y-%m-%d")
59 .map_err(|e| D::Error::custom(format!("invalid date string: {}", e)))
60 }
61 _ => Err(D::Error::custom("expected date or string")),
62 }
63}
64
65fn deserialize_optional_date<'de, D>(
67 deserializer: D,
68) -> std::result::Result<Option<NaiveDate>, D::Error>
69where
70 D: Deserializer<'de>,
71{
72 use serde::de::Error;
73
74 let value: Option<toml::Value> = Option::deserialize(deserializer)?;
75
76 match value {
77 None => Ok(None),
78 Some(toml::Value::Datetime(dt)) => {
79 let date = dt
80 .date
81 .ok_or_else(|| D::Error::custom("datetime missing date"))?;
82 Ok(NaiveDate::from_ymd_opt(
83 date.year as i32,
84 date.month as u32,
85 date.day as u32,
86 ))
87 }
88 Some(toml::Value::String(s)) => Ok(NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
89 _ => Err(D::Error::custom("expected date or string")),
90 }
91}
92
93#[derive(Debug, Deserialize)]
95pub struct ContentMeta {
96 pub title: String,
97 #[serde(deserialize_with = "deserialize_date")]
98 pub created: NaiveDate,
99 #[serde(default, deserialize_with = "deserialize_optional_date")]
100 pub updated: Option<NaiveDate>,
101 #[serde(default)]
102 pub tags: Vec<String>,
103 #[serde(default)]
104 pub categories: Vec<String>,
105 #[serde(default)]
107 pub published: Option<bool>,
108 #[serde(default)]
110 pub theme: Option<ThemeId>,
111 #[serde(default)]
114 pub internal_link_target: Option<String>,
115 #[serde(default)]
117 pub preamble: Option<String>,
118 #[serde(default)]
119 pub platforms: HashMap<String, PostPlatformConfig>,
120}
121
122#[derive(Debug, Clone, Deserialize)]
124pub struct PostPlatformConfig {
125 #[serde(default)]
127 pub published: Option<bool>,
128 #[serde(default)]
130 pub internal_link_target: Option<String>,
131 #[serde(flatten)]
132 pub extra: HashMap<String, toml::Value>,
133}
134
135impl PostPlatformConfig {
136 pub fn get_str(&self, key: &str) -> Option<String> {
142 self.extra
143 .get(key)
144 .and_then(|v| v.as_str())
145 .map(|s| subst::substitute(s, &subst::Env).unwrap_or_else(|_| s.to_string()))
146 }
147
148 pub fn get_str_raw(&self, key: &str) -> Option<&str> {
150 self.extra.get(key).and_then(|v| v.as_str())
151 }
152
153 pub fn get_int(&self, key: &str) -> Option<i64> {
155 self.extra.get(key).and_then(|v| v.as_integer())
156 }
157}
158
159#[derive(Debug, Clone)]
164pub struct PostInfo {
165 pub path: PathBuf,
167 pub title: String,
169 pub slug: String,
171 pub created: NaiveDate,
173 pub updated: NaiveDate,
175 pub tags: Vec<String>,
177 pub status: HashMap<String, (bool, Option<String>)>,
179}
180
181impl PostInfo {
182 pub fn from_content(
184 content: &Content,
185 status: HashMap<String, (bool, Option<String>)>,
186 ) -> Self {
187 Self {
188 path: content.path.clone(),
189 title: content.meta.title.clone(),
190 slug: content.slug().to_string(),
191 created: content.meta.created,
192 updated: content.meta.updated.unwrap_or(content.meta.created),
193 tags: content.meta.tags.clone(),
194 status,
195 }
196 }
197}
198
199#[derive(Debug)]
204pub struct DiscoverResult {
205 pub contents: Vec<Content>,
207 pub errors: Vec<(PathBuf, anyhow::Error)>,
209}
210
211impl Content {
212 pub fn load(path: &Path) -> Result<Self> {
214 let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
215
216 let meta_path = path.join("meta.toml");
218 let meta_content = std::fs::read_to_string(&meta_path)
219 .with_context(|| format!("Failed to read meta.toml at {}", meta_path.display()))?;
220 let meta: ContentMeta = toml::from_str(&meta_content)
221 .with_context(|| format!("Failed to parse meta.toml at {}", meta_path.display()))?;
222
223 let typ_file = path.join("content.typ");
225 let md_file = path.join("content.md");
226
227 let (content_file, source_format) = if typ_file.exists() {
228 (typ_file, ContentFormat::Typst)
229 } else if md_file.exists() {
230 (md_file, ContentFormat::Markdown)
231 } else {
232 anyhow::bail!(
233 "No content file found at {} (expected content.typ or content.md)",
234 path.display()
235 );
236 };
237
238 let slides_file = path.join("slides.typ");
240 let slides_file = if slides_file.exists() {
241 Some(slides_file)
242 } else {
243 None
244 };
245
246 let assets_dir = path.join("assets");
248 let assets = if assets_dir.exists() {
249 Self::discover_assets(&assets_dir)?
250 } else {
251 Vec::new()
252 };
253
254 Ok(Self {
255 path,
256 meta,
257 content_file,
258 source_format,
259 slides_file,
260 assets,
261 })
262 }
263
264 pub fn discover_all(content_dir: &Path) -> Result<DiscoverResult> {
270 let mut contents = Vec::new();
271 let mut errors = Vec::new();
272
273 if !content_dir.exists() {
274 return Ok(DiscoverResult { contents, errors });
275 }
276
277 for entry in std::fs::read_dir(content_dir)? {
278 let entry = entry?;
279 let path = entry.path();
280
281 if path.is_dir() {
282 if path.join("meta.toml").exists() {
284 match Self::load(&path) {
285 Ok(content) => contents.push(content),
286 Err(e) => errors.push((path, e)),
287 }
288 }
289 }
290 }
291
292 contents.sort_by_key(|item| std::cmp::Reverse(item.meta.created));
294
295 Ok(DiscoverResult { contents, errors })
296 }
297
298 fn discover_assets(assets_dir: &Path) -> Result<Vec<PathBuf>> {
300 let mut assets = Vec::new();
301
302 for entry in WalkDir::new(assets_dir).follow_links(true) {
303 let entry = entry?;
304 if entry.file_type().is_file() {
305 assets.push(entry.path().to_path_buf());
306 }
307 }
308
309 Ok(assets)
310 }
311
312 pub fn slug(&self) -> &str {
314 self.path
315 .file_name()
316 .and_then(|s| s.to_str())
317 .unwrap_or("unknown")
318 }
319
320 pub fn platform_config(&self, platform: &str) -> Option<&PostPlatformConfig> {
322 self.meta.platforms.get(platform)
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 #![allow(clippy::expect_used)]
329 use super::*;
330
331 #[test]
332 fn test_content_meta_parsing() {
333 let toml_str = r#"
334title = "Test Post"
335created = 2024-01-15
336tags = ["rust", "test"]
337categories = ["engineering", "rust"]
338
339[platforms.astro]
340slug = "test-post"
341"#;
342 let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
343 assert_eq!(meta.title, "Test Post");
344 assert_eq!(meta.tags, vec!["rust", "test"]);
345 assert_eq!(meta.categories, vec!["engineering", "rust"]);
346 assert!(meta.platforms.contains_key("astro"));
347 }
348
349 #[test]
350 fn test_content_meta_with_updated_date() {
351 let toml_str = r#"
352title = "Updated Post"
353created = 2024-01-15
354updated = 2024-06-01
355tags = []
356"#;
357 let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
358 assert_eq!(
359 meta.updated,
360 Some(NaiveDate::from_ymd_opt(2024, 6, 1).expect("valid date"))
361 );
362 }
363
364 #[test]
365 fn test_content_meta_with_root_preamble() {
366 let toml_str = r##"
367title = "Preamble Post"
368created = 2024-01-15
369preamble = "#set text(size: 11pt)"
370"##;
371 let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
372 assert_eq!(meta.preamble.as_deref(), Some("#set text(size: 11pt)"));
373 }
374
375 #[test]
376 fn test_content_meta_minimal() {
377 let toml_str = r#"
378title = "Minimal"
379created = 2024-01-01
380"#;
381 let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
382 assert_eq!(meta.title, "Minimal");
383 assert!(meta.updated.is_none());
384 assert!(meta.tags.is_empty());
385 assert!(meta.categories.is_empty());
386 assert!(meta.platforms.is_empty());
387 }
388
389 #[test]
390 fn test_content_meta_categories_only() {
391 let toml_str = r#"
392title = "Categories"
393created = 2024-01-01
394categories = ["backend", "api"]
395"#;
396 let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
397 assert_eq!(meta.categories, vec!["backend", "api"]);
398 assert!(meta.tags.is_empty());
399 }
400
401 #[test]
402 fn test_content_meta_string_date() {
403 let toml_str = r#"
404title = "String Date"
405created = "2024-03-15"
406"#;
407 let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
408 assert_eq!(
409 meta.created,
410 NaiveDate::from_ymd_opt(2024, 3, 15).expect("valid date")
411 );
412 }
413
414 #[test]
415 fn test_content_meta_invalid_date() {
416 let toml_str = r#"
417title = "Bad Date"
418created = "not-a-date"
419"#;
420 assert!(toml::from_str::<ContentMeta>(toml_str).is_err());
421 }
422
423 #[test]
424 fn test_content_meta_missing_title() {
425 let toml_str = r#"
426created = 2024-01-01
427"#;
428 assert!(toml::from_str::<ContentMeta>(toml_str).is_err());
429 }
430
431 #[test]
432 fn test_post_platform_config_accessors() {
433 let toml_str = r#"
434title = "Test"
435created = 2024-01-01
436
437[platforms.notion]
438database_id = "abc"
439priority = 5
440"#;
441 let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
442 let notion = meta.platforms.get("notion").expect("key should exist");
443 assert_eq!(notion.get_str("database_id"), Some("abc".to_string()));
444 assert_eq!(notion.get_int("priority"), Some(5));
445 assert_eq!(notion.get_str("nonexistent"), None);
446 assert_eq!(notion.get_int("database_id"), None); }
448
449 #[test]
450 fn test_get_str_no_env_var() {
451 let toml_str = r#"
452title = "Test"
453created = 2024-01-01
454
455[platforms.test]
456key = "plain-value"
457"#;
458 let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
459 let config = meta.platforms.get("test").expect("platform config");
460 assert_eq!(config.get_str("key"), Some("plain-value".to_string()));
461 }
462
463 #[test]
464 fn test_get_str_with_env_var() {
465 unsafe {
467 std::env::set_var("TYPUB_TEST_VAR", "expanded-value");
468 }
469 let toml_str = r#"
470title = "Test"
471created = 2024-01-01
472
473[platforms.test]
474key = "$TYPUB_TEST_VAR"
475"#;
476 let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
477 let config = meta.platforms.get("test").expect("platform config");
478 assert_eq!(config.get_str("key"), Some("expanded-value".to_string()));
479 unsafe {
481 std::env::remove_var("TYPUB_TEST_VAR");
482 }
483 }
484
485 #[test]
486 fn test_get_str_raw_no_expansion() {
487 unsafe {
489 std::env::set_var("TYPUB_RAW_TEST", "should-not-appear");
490 }
491 let toml_str = r#"
492title = "Test"
493created = 2024-01-01
494
495[platforms.test]
496key = "$TYPUB_RAW_TEST"
497"#;
498 let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
499 let config = meta.platforms.get("test").expect("platform config");
500 assert_eq!(config.get_str_raw("key"), Some("$TYPUB_RAW_TEST"));
502 unsafe {
504 std::env::remove_var("TYPUB_RAW_TEST");
505 }
506 }
507
508 #[test]
509 fn test_content_load_typ() {
510 let dir = tempfile::tempdir().expect("create temp dir");
511 let post_dir = dir.path().join("test-post");
512 std::fs::create_dir_all(&post_dir).expect("create dir");
513
514 std::fs::write(
515 post_dir.join("meta.toml"),
516 "title = \"Test\"\ncreated = 2024-01-01\n",
517 )
518 .expect("write file");
519 std::fs::write(post_dir.join("content.typ"), "#set text(size: 12pt)").expect("write file");
520
521 let content = Content::load(&post_dir).expect("load content");
522 assert_eq!(content.source_format, ContentFormat::Typst);
523 assert_eq!(content.meta.title, "Test");
524 assert!(content.slides_file.is_none());
525 assert!(content.assets.is_empty());
526 }
527
528 #[test]
529 fn test_content_load_md() {
530 let dir = tempfile::tempdir().expect("create temp dir");
531 let post_dir = dir.path().join("my-md-post");
532 std::fs::create_dir_all(&post_dir).expect("create dir");
533
534 std::fs::write(
535 post_dir.join("meta.toml"),
536 "title = \"MD\"\ncreated = 2024-02-01\n",
537 )
538 .expect("write file");
539 std::fs::write(post_dir.join("content.md"), "# Hello").expect("write file");
540
541 let content = Content::load(&post_dir).expect("load content");
542 assert_eq!(content.source_format, ContentFormat::Markdown);
543 }
544
545 #[test]
546 fn test_content_load_prefers_typ_over_md() {
547 let dir = tempfile::tempdir().expect("create temp dir");
548 let post_dir = dir.path().join("dual-post");
549 std::fs::create_dir_all(&post_dir).expect("create dir");
550
551 std::fs::write(
552 post_dir.join("meta.toml"),
553 "title = \"Dual\"\ncreated = 2024-01-01\n",
554 )
555 .expect("write file");
556 std::fs::write(post_dir.join("content.typ"), "typst content").expect("write file");
557 std::fs::write(post_dir.join("content.md"), "markdown content").expect("write file");
558
559 let content = Content::load(&post_dir).expect("load content");
560 assert_eq!(content.source_format, ContentFormat::Typst);
561 }
562
563 #[test]
564 fn test_content_load_no_content_file() {
565 let dir = tempfile::tempdir().expect("create temp dir");
566 let post_dir = dir.path().join("empty-post");
567 std::fs::create_dir_all(&post_dir).expect("create dir");
568
569 std::fs::write(
570 post_dir.join("meta.toml"),
571 "title = \"Empty\"\ncreated = 2024-01-01\n",
572 )
573 .expect("write file");
574
575 assert!(Content::load(&post_dir).is_err());
576 }
577
578 #[test]
579 fn test_content_load_no_meta() {
580 let dir = tempfile::tempdir().expect("create temp dir");
581 let post_dir = dir.path().join("no-meta");
582 std::fs::create_dir_all(&post_dir).expect("create dir");
583 std::fs::write(post_dir.join("content.typ"), "hello").expect("write file");
584
585 assert!(Content::load(&post_dir).is_err());
586 }
587
588 #[test]
589 fn test_content_load_with_assets() {
590 let dir = tempfile::tempdir().expect("create temp dir");
591 let post_dir = dir.path().join("asset-post");
592 let assets_dir = post_dir.join("assets");
593 std::fs::create_dir_all(&assets_dir).expect("create dir");
594
595 std::fs::write(
596 post_dir.join("meta.toml"),
597 "title = \"Assets\"\ncreated = 2024-01-01\n",
598 )
599 .expect("write file");
600 std::fs::write(post_dir.join("content.typ"), "content").expect("write file");
601 std::fs::write(assets_dir.join("photo.png"), "fake png").expect("write file");
602 std::fs::write(assets_dir.join("diagram.svg"), "fake svg").expect("write file");
603
604 let content = Content::load(&post_dir).expect("load content");
605 assert_eq!(content.assets.len(), 2);
606 }
607
608 #[test]
609 fn test_content_load_with_slides() {
610 let dir = tempfile::tempdir().expect("create temp dir");
611 let post_dir = dir.path().join("slides-post");
612 std::fs::create_dir_all(&post_dir).expect("create dir");
613
614 std::fs::write(
615 post_dir.join("meta.toml"),
616 "title = \"Slides\"\ncreated = 2024-01-01\n",
617 )
618 .expect("write file");
619 std::fs::write(post_dir.join("content.typ"), "content").expect("write file");
620 std::fs::write(post_dir.join("slides.typ"), "slide content").expect("write file");
621
622 let content = Content::load(&post_dir).expect("load content");
623 assert!(content.slides_file.is_some());
624 }
625
626 #[test]
627 fn test_slug() {
628 let dir = tempfile::tempdir().expect("create temp dir");
629 let post_dir = dir.path().join("2024-01-15-my-great-post");
630 std::fs::create_dir_all(&post_dir).expect("create dir");
631
632 std::fs::write(
633 post_dir.join("meta.toml"),
634 "title = \"Great\"\ncreated = 2024-01-15\n",
635 )
636 .expect("write file");
637 std::fs::write(post_dir.join("content.typ"), "x").expect("write file");
638
639 let content = Content::load(&post_dir).expect("load content");
640 assert_eq!(content.slug(), "2024-01-15-my-great-post");
641 }
642
643 #[test]
644 fn test_platform_config() {
645 let dir = tempfile::tempdir().expect("create temp dir");
646 let post_dir = dir.path().join("pc-post");
647 std::fs::create_dir_all(&post_dir).expect("create dir");
648
649 std::fs::write(
650 post_dir.join("meta.toml"),
651 r#"
652title = "PC"
653created = 2024-01-01
654
655[platforms.astro]
656slug = "custom-slug"
657"#,
658 )
659 .expect("write file");
660 std::fs::write(post_dir.join("content.typ"), "x").expect("write file");
661
662 let content = Content::load(&post_dir).expect("load content");
663 assert!(content.platform_config("astro").is_some());
664 assert!(content.platform_config("notion").is_none());
665 }
666
667 #[test]
668 fn test_discover_all() {
669 let dir = tempfile::tempdir().expect("create temp dir");
670
671 for name in ["2024-01-01-first", "2024-02-01-second"] {
673 let post = dir.path().join(name);
674 std::fs::create_dir_all(&post).expect("create dir");
675 std::fs::write(
676 post.join("meta.toml"),
677 format!("title = \"{name}\"\ncreated = {}\n", &name[..10]),
678 )
679 .expect("write file");
680 std::fs::write(post.join("content.typ"), "x").expect("write file");
681 }
682
683 let invalid = dir.path().join("not-a-post");
685 std::fs::create_dir_all(&invalid).expect("create dir");
686
687 let result = Content::discover_all(dir.path()).expect("discover all");
688 assert_eq!(result.contents.len(), 2);
689 assert!(result.errors.is_empty());
690 assert_eq!(result.contents[0].slug(), "2024-02-01-second");
692 assert_eq!(result.contents[1].slug(), "2024-01-01-first");
693 }
694
695 #[test]
696 fn test_discover_all_with_errors() {
697 let dir = tempfile::tempdir().expect("create temp dir");
698
699 let valid = dir.path().join("valid-post");
701 std::fs::create_dir_all(&valid).expect("create dir");
702 std::fs::write(
703 valid.join("meta.toml"),
704 "title = \"Valid\"\ncreated = 2024-01-01\n",
705 )
706 .expect("write file");
707 std::fs::write(valid.join("content.typ"), "x").expect("write file");
708
709 let invalid = dir.path().join("invalid-post");
711 std::fs::create_dir_all(&invalid).expect("create dir");
712 std::fs::write(invalid.join("meta.toml"), "invalid toml {{{{").expect("write file");
713
714 let result = Content::discover_all(dir.path()).expect("discover all");
715 assert_eq!(result.contents.len(), 1);
716 assert_eq!(result.errors.len(), 1);
717 assert!(result.errors[0].0.ends_with("invalid-post"));
718 }
719
720 #[test]
721 fn test_discover_all_empty_dir() {
722 let dir = tempfile::tempdir().expect("create temp dir");
723 let result = Content::discover_all(dir.path()).expect("discover all");
724 assert!(result.contents.is_empty());
725 assert!(result.errors.is_empty());
726 }
727
728 #[test]
729 fn test_discover_all_nonexistent_dir() {
730 let result = Content::discover_all(Path::new("/tmp/nonexistent-contents-dir"))
731 .expect("discover all");
732 assert!(result.contents.is_empty());
733 assert!(result.errors.is_empty());
734 }
735}