9.8 KB
raw
use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct Frontmatter {
pub meta: HashMap<String, String>,
pub body: String,
}
#[derive(Debug, Default)]
pub struct ListItems {
pub bullets: Vec<String>,
pub prose: Vec<String>,
}
pub fn parse_frontmatter(text: &str) -> Frontmatter {
if let Some(rest) = text.strip_prefix("---\n") {
if let Some(end) = rest.find("\n---\n") {
let meta_str = &rest[..end];
let body = &rest[end + 5..];
let mut meta = HashMap::new();
for line in meta_str.split('\n') {
if let Some((k, v)) = line.split_once(':') {
meta.insert(k.trim().to_string(), v.trim().to_string());
}
}
return Frontmatter {
meta,
body: body.trim().to_string(),
};
}
}
Frontmatter {
meta: HashMap::new(),
body: text.to_string(),
}
}
/// Walk lines, splitting `- ` bullets from free-form prose paragraphs.
/// Mirrors python `parse_list_items` exactly.
pub fn parse_list_items(body: &str) -> ListItems {
let mut bullets = Vec::new();
let mut prose = Vec::new();
let mut in_prose = false;
let mut current = String::new();
for line in body.split('\n') {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("- ") {
if in_prose && !current.is_empty() {
prose.push(std::mem::take(&mut current).trim().to_string());
in_prose = false;
}
bullets.push(rest.to_string());
} else if trimmed.is_empty() {
if in_prose && !current.is_empty() {
prose.push(std::mem::take(&mut current).trim().to_string());
in_prose = false;
}
} else {
in_prose = true;
if !current.is_empty() {
current.push(' ');
}
current.push_str(trimmed);
}
}
if in_prose && !current.is_empty() {
prose.push(current.trim().to_string());
}
ListItems { bullets, prose }
}
/// Parse a body that contains multiple named bullet lists, each introduced by
/// a `## name` heading. Order is preserved so callers can render sections in
/// the same sequence the file declared them.
pub fn parse_named_lists(body: &str) -> Vec<(String, Vec<String>)> {
let mut sections: Vec<(String, Vec<String>)> = Vec::new();
let mut current: Option<(String, Vec<String>)> = None;
for line in body.split('\n') {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("## ") {
if let Some(prev) = current.take() {
sections.push(prev);
}
current = Some((rest.trim().to_string(), Vec::new()));
} else if let Some(rest) = trimmed.strip_prefix("- ") {
if let Some((_, items)) = current.as_mut() {
items.push(rest.to_string());
}
}
}
if let Some(prev) = current.take() {
sections.push(prev);
}
sections
}
#[derive(Debug, Clone)]
pub struct Season {
pub name: String,
pub label: String,
pub start: (u32, u32),
pub end: (u32, u32),
pub note: String,
}
#[derive(Debug, Clone)]
pub struct MoonTip {
pub lo: f64,
pub hi: f64,
pub text: String,
}
#[derive(Deserialize, Debug)]
pub struct ManifestEntry {
pub path: String,
}
pub struct SiteData {
pub files: HashMap<String, String>,
pub seasons: Vec<Season>,
/// canonical-order map (insertion order preserved) so the nav reads
/// winter -> early spring -> ... -> late fall.
pub seasons_order: Vec<String>,
pub seasons_by_name: HashMap<String, Season>,
pub haiku: HashMap<String, Vec<[String; 3]>>,
pub moods: HashMap<String, HashMap<String, String>>,
pub moon_tips: Vec<MoonTip>,
}
fn parse_md_date(s: &str) -> Result<(u32, u32)> {
let (m, d) = s.split_once('/').context("expected m/d")?;
Ok((m.parse()?, d.parse()?))
}
pub fn load_data(data_dir: &Path) -> Result<SiteData> {
let manifest_path = data_dir.join("manifest.json");
let manifest_text = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("read manifest: {manifest_path:?}"))?;
let manifest: Vec<ManifestEntry> = serde_json::from_str(&manifest_text)?;
let mut files = HashMap::new();
for entry in &manifest {
let p = data_dir.join(&entry.path);
if let Ok(text) = std::fs::read_to_string(&p) {
files.insert(entry.path.clone(), text);
}
}
let (seasons, seasons_order, seasons_by_name) = load_seasons(data_dir)?;
let haiku = load_haiku(data_dir)?;
let moods = load_moods(data_dir)?;
let moon_tips = load_moon_tips(data_dir)?;
Ok(SiteData {
files,
seasons,
seasons_order,
seasons_by_name,
haiku,
moods,
moon_tips,
})
}
fn load_seasons(
data_dir: &Path,
) -> Result<(Vec<Season>, Vec<String>, HashMap<String, Season>)> {
let dir = data_dir.join("seasons");
let mut entries: Vec<_> = std::fs::read_dir(&dir)?
.filter_map(Result::ok)
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("md"))
.collect();
entries.sort_by_key(|e| e.file_name());
let mut seasons: Vec<Season> = Vec::new();
for entry in entries {
let text = std::fs::read_to_string(entry.path())?;
let parsed = parse_frontmatter(&text);
let name = parsed
.meta
.get("name")
.cloned()
.context("season missing name")?;
let label = parsed
.meta
.get("label")
.cloned()
.context("season missing label")?;
let start = parse_md_date(parsed.meta.get("start").context("season missing start")?)?;
let end = parse_md_date(parsed.meta.get("end").context("season missing end")?)?;
let note = parsed.body.trim().to_string();
seasons.push(Season {
name: name.clone(),
label: label.clone(),
start,
end,
note: note.clone(),
});
if let (Some(sa), Some(ea)) = (parsed.meta.get("start-alt"), parsed.meta.get("end-alt")) {
seasons.push(Season {
name,
label,
start: parse_md_date(sa)?,
end: parse_md_date(ea)?,
note,
});
}
}
seasons.sort_by_key(|s| (s.start.0, s.start.1));
let mut seasons_order = Vec::new();
let mut seasons_by_name: HashMap<String, Season> = HashMap::new();
for s in &seasons {
if !seasons_by_name.contains_key(&s.name) {
seasons_order.push(s.name.clone());
seasons_by_name.insert(s.name.clone(), s.clone());
}
}
Ok((seasons, seasons_order, seasons_by_name))
}
fn load_haiku(data_dir: &Path) -> Result<HashMap<String, Vec<[String; 3]>>> {
let dir = data_dir.join("haiku");
let mut out: HashMap<String, Vec<[String; 3]>> = HashMap::new();
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
if entry.path().extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let text = std::fs::read_to_string(entry.path())?;
let parsed = parse_frontmatter(&text);
let season = parsed
.meta
.get("season")
.cloned()
.context("haiku missing season")?;
let mut poems = Vec::new();
for block in parsed.body.split("---") {
let lines: Vec<String> = block
.trim()
.split('\n')
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
if lines.len() == 3 {
poems.push([lines[0].clone(), lines[1].clone(), lines[2].clone()]);
}
}
out.insert(season, poems);
}
Ok(out)
}
fn load_moods(data_dir: &Path) -> Result<HashMap<String, HashMap<String, String>>> {
let dir = data_dir.join("moods");
let mut out: HashMap<String, HashMap<String, String>> = HashMap::new();
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
if entry.path().extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let text = std::fs::read_to_string(entry.path())?;
let parsed = parse_frontmatter(&text);
let season = parsed
.meta
.get("season")
.cloned()
.context("mood missing season")?;
let mut by_time = HashMap::new();
for line in parsed.body.split('\n') {
let line = line.trim();
if let Some(rest) = line.strip_prefix("- ") {
if let Some((time, mood)) = rest.split_once(':') {
by_time.insert(time.trim().to_string(), mood.trim().to_string());
}
}
}
out.insert(season, by_time);
}
Ok(out)
}
fn load_moon_tips(data_dir: &Path) -> Result<Vec<MoonTip>> {
let path = data_dir.join("moon-tips.md");
let text = std::fs::read_to_string(&path)?;
let parsed = parse_frontmatter(&text);
let mut tips = Vec::new();
for line in parsed.body.split('\n') {
let line = line.trim();
let Some(rest) = line.strip_prefix("- ") else {
continue;
};
let Some((range_str, tip_text)) = rest.split_once(':') else {
continue;
};
let Some((lo, hi)) = range_str.trim().split_once('-') else {
continue;
};
tips.push(MoonTip {
lo: lo.parse()?,
hi: hi.parse()?,
text: tip_text.trim().to_string(),
});
}
Ok(tips)
}