From 16fed122732c19e87d6b212581d0c076230932bf Mon Sep 17 00:00:00 2001 From: Timothy DeHerrera Date: Sat, 14 Feb 2026 06:50:35 -0700 Subject: [PATCH] feat!: replace YAML frontmatter with TOML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hand-rolled YAML parser (70 lines) with serde-backed toml::from_str (6 lines). Frontmatter delimiter changes from --- to +++ (Hugo TOML convention). New Frontmatter fields: - draft: bool (#[serde(default)]) for draft filtering - aliases: Vec (#[serde(default)]) for URL redirects Date field upgraded from Option to Option with custom deserializer for TOML native dates. Parse, don't validate: invalid dates now fail at deserialization time. Add chrono dependency with serde feature. Update cascade in sitemap.rs (NaiveDate→String at boundary), template_engine.rs (FrontmatterContext gains draft/aliases), and all 14 tests. BREAKING CHANGE: Content files must use +++ TOML frontmatter instead of --- YAML frontmatter. --- Cargo.lock | 2 + Cargo.toml | 7 +- docs/plans/api-stabilization.md | 14 +-- src/content.rs | 155 +++++++++++++------------------- src/sitemap.rs | 6 +- src/template_engine.rs | 14 ++- 6 files changed, 91 insertions(+), 107 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85ad857..af142f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,6 +200,7 @@ checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "num-traits", + "serde", "windows-link", ] @@ -1558,6 +1559,7 @@ dependencies = [ name = "sukr" version = "0.1.0" dependencies = [ + "chrono", "katex-rs", "lightningcss", "mermaid-rs-renderer", diff --git a/Cargo.toml b/Cargo.toml index 37d3e21..c5904fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,9 +64,10 @@ lightningcss = "1.0.0-alpha.70" # tera: Jinja2-style templates, runtime-loaded (no recompilation needed). # serde/toml: Configuration parsing. Standard choices, no lighter alternatives. # ───────────────────────────────────────────────────────────────────────────── -serde = { version = "1", features = ["derive"] } -tera = "1" -toml = "0.8" +chrono = { version = "0.4", default-features = false, features = ["serde"] } +serde = { version = "1", features = ["derive"] } +tera = "1" +toml = "0.8" # ───────────────────────────────────────────────────────────────────────────── # Patches diff --git a/docs/plans/api-stabilization.md b/docs/plans/api-stabilization.md index be9b28d..bea95f5 100644 --- a/docs/plans/api-stabilization.md +++ b/docs/plans/api-stabilization.md @@ -25,7 +25,7 @@ Implement the pre-1.0 API changes required to stabilize sukr's public contract: | Frontmatter format | **TOML** (replacing hand-rolled YAML) | `toml` crate + serde already in deps. Eliminates the fragile hand-rolled parser. Every future field is just a struct field with `#[derive(Deserialize)]`. | | Frontmatter delimiter | `+++` | Hugo convention for TOML frontmatter. Unambiguous — no risk of confusion with YAML `---` or Markdown horizontal rules. | | Template naming | Mirror site.toml structure (`config.nav.nested` not `config.nested_nav`) | Consistency between config and templates; pre-1.0 is only window for this break | -| Date type | Validate as YYYY-MM-DD string via `chrono::NaiveDate`, keep as `String` in template context | Validate at parse time; reject invalid formats. Feed generation already assumes this format. | +| Date type | `Option` with custom `deserialize_date` fn for TOML native dates | Parse, don't validate. Custom serde deserializer accepts `toml::Datetime`, extracts date, constructs `NaiveDate`. Invalid dates fail at deser. | | Draft filtering | `draft: bool` (`#[serde(default)]`) in `Frontmatter`, filter in `collect_items()` and `discover()` | Filter early so drafts don't appear in nav, listings, sitemap, or feed. | | Feed/sitemap config | `[feed]` and `[sitemap]` tables with `enabled` boolean in `SiteConfig` | Users need opt-out. Default `true` preserves backward compat. | | Tag listing pages | Generate `/tags/.html` using a new `tags/default.html` template | Minimal approach — one template, one generation loop. No pagination. | @@ -94,12 +94,12 @@ Items validated by codebase investigation: ## Phases 1. **Phase 1: TOML Frontmatter & Config Normalization** — replace the parser, migrate content, fix naming - - [ ] Replace `Frontmatter` struct with `#[derive(Deserialize)]` - - [ ] Add new fields: `draft: bool` (`#[serde(default)]`), `aliases: Vec` (`#[serde(default)]`), keep all existing fields - - [ ] Replace `parse_frontmatter()` with `toml::from_str::()` - - [ ] Update `extract_frontmatter()` to detect `+++` delimiters instead of `---` - - [ ] Add date validation: custom deserializer or post-parse check for YYYY-MM-DD via `chrono::NaiveDate` - - [ ] Change `tags` from `taxonomies.tags` nesting to flat `tags = ["..."]` (direct TOML array) + - [x] Replace `Frontmatter` struct with `#[derive(Deserialize)]` + - [x] Add new fields: `draft: bool` (`#[serde(default)]`), `aliases: Vec` (`#[serde(default)]`), keep all existing fields + - [x] Replace `parse_frontmatter()` with `toml::from_str::()` + - [x] Update `extract_frontmatter()` to detect `+++` delimiters instead of `---` + - [x] Add date validation: custom `deserialize_date` fn for TOML native dates → `chrono::NaiveDate` + - [x] Change `tags` from `taxonomies.tags` nesting to flat `tags = ["..."]` (direct TOML array) - [ ] Migrate all 17 content files from YAML (`---`) to TOML (`+++`) frontmatter - [ ] Update embedded frontmatter examples in documentation pages (7 files) - [ ] Add `FeedConfig` and `SitemapConfig` structs to `config.rs` with `enabled: bool` (default `true`) diff --git a/src/content.rs b/src/content.rs index d0cb4e7..03d3fd5 100644 --- a/src/content.rs +++ b/src/content.rs @@ -1,8 +1,8 @@ //! Content discovery and frontmatter parsing. use crate::error::{Error, Result}; -use serde::Serialize; -use std::collections::HashMap; +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; @@ -40,24 +40,39 @@ pub struct NavItem { } /// Parsed frontmatter from a content file. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] pub struct Frontmatter { pub title: String, + #[serde(default)] pub description: Option, - pub date: Option, + #[serde(default, deserialize_with = "deserialize_date")] + pub date: Option, + #[serde(default)] pub tags: Vec, /// Sort order for nav and listings + #[serde(default)] pub weight: Option, /// For project cards: external link + #[serde(default)] pub link_to: Option, /// Custom navigation label (defaults to title) + #[serde(default)] pub nav_label: Option, /// Section type for template dispatch (e.g., "blog", "projects") + #[serde(default)] pub section_type: Option, /// Override template for this content item + #[serde(default)] pub template: Option, /// Enable table of contents (anchor nav in sidebar) + #[serde(default)] pub toc: Option, + /// Whether this content is a draft (excluded from output) + #[serde(default)] + pub draft: bool, + /// Alternative URL paths that redirect to this content + #[serde(default)] + pub aliases: Vec, } /// A content item ready for rendering. @@ -71,7 +86,7 @@ pub struct Content { } impl Content { - /// Load and parse a markdown file with YAML frontmatter. + /// Load and parse a markdown file with TOML frontmatter. pub fn from_path(path: impl AsRef, kind: ContentKind) -> Result { Self::from_path_inner(path.as_ref(), kind) } @@ -82,8 +97,8 @@ impl Content { source: e, })?; - let (yaml_block, body) = extract_frontmatter(&raw, path)?; - let frontmatter = parse_frontmatter(path, &yaml_block)?; + let (toml_block, body) = extract_frontmatter(&raw, path)?; + let frontmatter = parse_frontmatter(path, &toml_block)?; // Derive slug from filename (without extension) let slug = path @@ -124,105 +139,59 @@ impl Content { } } -/// Extract YAML frontmatter block and body from raw content. -/// Frontmatter must be delimited by `---` at start and end. +/// Extract TOML frontmatter block and body from raw content. +/// Frontmatter must be delimited by `+++` at start and end. fn extract_frontmatter(raw: &str, path: &Path) -> Result<(String, String)> { let trimmed = raw.trim_start(); - if !trimmed.starts_with("---") { + if !trimmed.starts_with("+++") { return Err(Error::Frontmatter { path: path.to_path_buf(), message: "missing frontmatter delimiter".to_string(), }); } - // Find the closing --- + // Find the closing +++ let after_first = &trimmed[3..].trim_start_matches(['\r', '\n']); let end_idx = after_first - .find("\n---") + .find("\n+++") .ok_or_else(|| Error::Frontmatter { path: path.to_path_buf(), message: "missing closing frontmatter delimiter".to_string(), })?; - let yaml_block = after_first[..end_idx].to_string(); + let toml_block = after_first[..end_idx].to_string(); let body = after_first[end_idx + 4..].trim_start().to_string(); - Ok((yaml_block, body)) + Ok((toml_block, body)) } -/// Parse simple YAML frontmatter into structured fields. -/// Supports: key: value, key: "quoted value", and nested taxonomies.tags -fn parse_frontmatter(path: &Path, yaml: &str) -> Result { - let mut map: HashMap = HashMap::new(); - let mut tags: Vec = Vec::new(); - let mut in_taxonomies = false; - let mut in_tags = false; - - for line in yaml.lines() { - let trimmed = line.trim(); - - // Skip empty lines and comments - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - - // Handle nested structure for taxonomies.tags - if trimmed == "taxonomies:" { - in_taxonomies = true; - continue; - } - - if in_taxonomies && trimmed == "tags:" { - in_tags = true; - continue; - } - - // Collect tag list items - if in_tags && trimmed.starts_with("- ") { - let tag = trimmed[2..].trim().trim_matches('"').to_string(); - tags.push(tag); - continue; - } - - // Exit nested context on non-indented line - if !line.starts_with(' ') && !line.starts_with('\t') { - in_taxonomies = false; - in_tags = false; - } - - // Parse key: value - if let Some((key, value)) = trimmed.split_once(':') { - let key = key.trim().to_string(); - let value = value.trim().trim_matches('"').to_string(); - if !value.is_empty() { - map.insert(key, value); - } - } - } - - let title = map - .get("title") - .cloned() - .ok_or_else(|| Error::Frontmatter { - path: path.to_path_buf(), - message: "missing required 'title' field".to_string(), - })?; - - Ok(Frontmatter { - title, - description: map.get("description").cloned(), - date: map.get("date").cloned(), - tags, - weight: map.get("weight").and_then(|v| v.parse().ok()), - link_to: map.get("link_to").cloned(), - nav_label: map.get("nav_label").cloned(), - section_type: map.get("section_type").cloned(), - template: map.get("template").cloned(), - toc: map.get("toc").and_then(|v| v.parse().ok()), +/// Parse TOML frontmatter into structured fields. +fn parse_frontmatter(path: &Path, toml_str: &str) -> Result { + toml::from_str(toml_str).map_err(|e| Error::Frontmatter { + path: path.to_path_buf(), + message: e.to_string(), }) } +/// Deserialize a TOML native date into `Option`. +/// +/// TOML native dates (`date = 2026-01-15`) are parsed by the `toml` crate as +/// `toml::value::Datetime`. This deserializer accepts that type, extracts the +/// date component, and constructs a validated `chrono::NaiveDate`. +fn deserialize_date<'de, D>(deserializer: D) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let dt = toml::value::Datetime::deserialize(deserializer)?; + let date = dt + .date + .ok_or_else(|| serde::de::Error::custom("datetime must include a date component"))?; + NaiveDate::from_ymd_opt(date.year.into(), date.month.into(), date.day.into()) + .ok_or_else(|| serde::de::Error::custom("invalid calendar date")) + .map(Some) +} + /// Discover navigation items from the content directory structure. /// /// Rules: @@ -498,14 +467,14 @@ mod tests { } fn write_frontmatter(path: &Path, title: &str, weight: Option, nav_label: Option<&str>) { - let mut content = format!("---\ntitle: \"{}\"\n", title); + let mut content = format!("+++\ntitle = \"{}\"\n", title); if let Some(w) = weight { - content.push_str(&format!("weight: {}\n", w)); + content.push_str(&format!("weight = {}\n", w)); } if let Some(label) = nav_label { - content.push_str(&format!("nav_label: \"{}\"\n", label)); + content.push_str(&format!("nav_label = \"{}\"\n", label)); } - content.push_str("---\n\nBody content."); + content.push_str("+++\n\nBody content."); fs::write(path, content).expect("failed to write test file"); } @@ -629,14 +598,14 @@ mod tests { section_type: Option<&str>, weight: Option, ) { - let mut content = format!("---\ntitle: \"{}\"\n", title); + let mut content = format!("+++\ntitle = \"{}\"\n", title); if let Some(st) = section_type { - content.push_str(&format!("section_type: \"{}\"\n", st)); + content.push_str(&format!("section_type = \"{}\"\n", st)); } if let Some(w) = weight { - content.push_str(&format!("weight: {}\n", w)); + content.push_str(&format!("weight = {}\n", w)); } - content.push_str("---\nSection content.\n"); + content.push_str("+++\nSection content.\n"); fs::write(path, content).expect("failed to write section index"); } @@ -820,8 +789,8 @@ mod tests { ); // Create blog posts with dates - let post1 = "---\ntitle: \"Post 1\"\ndate: \"2026-01-15\"\n---\nContent.".to_string(); - let post2 = "---\ntitle: \"Post 2\"\ndate: \"2026-01-20\"\n---\nContent.".to_string(); + let post1 = "+++\ntitle = \"Post 1\"\ndate = 2026-01-15\n+++\nContent.".to_string(); + let post2 = "+++\ntitle = \"Post 2\"\ndate = 2026-01-20\n+++\nContent.".to_string(); fs::write(content_dir.join("blog/post1.md"), &post1).unwrap(); fs::write(content_dir.join("blog/post2.md"), &post2).unwrap(); diff --git a/src/sitemap.rs b/src/sitemap.rs index 9acbac2..cc3242f 100644 --- a/src/sitemap.rs +++ b/src/sitemap.rs @@ -39,7 +39,7 @@ pub fn generate_sitemap( // Section index entries.push(SitemapEntry { loc: format!("{}/{}/index.html", base_url, section.name), - lastmod: section.index.frontmatter.date.clone(), + lastmod: section.index.frontmatter.date.map(|d| d.to_string()), }); // Section items @@ -48,7 +48,7 @@ pub fn generate_sitemap( let relative_path = item.output_path(content_root); entries.push(SitemapEntry { loc: format!("{}/{}", base_url, relative_path.display()), - lastmod: item.frontmatter.date.clone(), + lastmod: item.frontmatter.date.map(|d| d.to_string()), }); } } @@ -59,7 +59,7 @@ pub fn generate_sitemap( let relative_path = page.output_path(content_root); entries.push(SitemapEntry { loc: format!("{}/{}", base_url, relative_path.display()), - lastmod: page.frontmatter.date.clone(), + lastmod: page.frontmatter.date.map(|d| d.to_string()), }); } diff --git a/src/template_engine.rs b/src/template_engine.rs index 1524f7b..ec328f3 100644 --- a/src/template_engine.rs +++ b/src/template_engine.rs @@ -166,6 +166,10 @@ pub struct FrontmatterContext { pub link_to: Option, /// Enable table of contents (anchor nav in sidebar) pub toc: bool, + /// Whether this content is a draft + pub draft: bool, + /// Alternative URL paths + pub aliases: Vec, } impl FrontmatterContext { @@ -174,11 +178,13 @@ impl FrontmatterContext { Self { title: fm.title.clone(), description: fm.description.clone(), - date: fm.date.clone(), + date: fm.date.map(|d| d.to_string()), tags: fm.tags.clone(), weight: fm.weight, link_to: fm.link_to.clone(), toc: fm.toc.unwrap_or(config.nav.toc), + draft: fm.draft, + aliases: fm.aliases.clone(), } } } @@ -278,6 +284,8 @@ mod tests { section_type: None, template: None, toc: Some(true), + draft: false, + aliases: vec![], }; // Frontmatter with explicit toc: false @@ -292,6 +300,8 @@ mod tests { section_type: None, template: None, toc: Some(false), + draft: false, + aliases: vec![], }; // Frontmatter with no toc specified (None) @@ -306,6 +316,8 @@ mod tests { section_type: None, template: None, toc: None, + draft: false, + aliases: vec![], }; // Explicit true overrides config false