A living almanac of seasons, soil, and the quiet knowledge that used to be common. Rust axum with minijinja and Vite.
agriculturealmanacaxumfolk-knowledgegardeningminijinjarustseasonalvite
1use anyhow::{Context, Result};
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::path::Path;
5
6#[derive(Debug, Clone)]
7pub struct Frontmatter {
8 pub meta: HashMap<String, String>,
9 pub body: String,
10}
11
12#[derive(Debug, Default)]
13pub struct ListItems {
14 pub bullets: Vec<String>,
15 pub prose: Vec<String>,
16}
17
18pub fn parse_frontmatter(text: &str) -> Frontmatter {
19 if let Some(rest) = text.strip_prefix("---\n") {
20 if let Some(end) = rest.find("\n---\n") {
21 let meta_str = &rest[..end];
22 let body = &rest[end + 5..];
23 let mut meta = HashMap::new();
24 for line in meta_str.split('\n') {
25 if let Some((k, v)) = line.split_once(':') {
26 meta.insert(k.trim().to_string(), v.trim().to_string());
27 }
28 }
29 return Frontmatter {
30 meta,
31 body: body.trim().to_string(),
32 };
33 }
34 }
35 Frontmatter {
36 meta: HashMap::new(),
37 body: text.to_string(),
38 }
39}
40
41/// Walk lines, splitting `- ` bullets from free-form prose paragraphs.
42/// Mirrors python `parse_list_items` exactly.
43pub fn parse_list_items(body: &str) -> ListItems {
44 let mut bullets = Vec::new();
45 let mut prose = Vec::new();
46 let mut in_prose = false;
47 let mut current = String::new();
48
49 for line in body.split('\n') {
50 let trimmed = line.trim();
51 if let Some(rest) = trimmed.strip_prefix("- ") {
52 if in_prose && !current.is_empty() {
53 prose.push(std::mem::take(&mut current).trim().to_string());
54 in_prose = false;
55 }
56 bullets.push(rest.to_string());
57 } else if trimmed.is_empty() {
58 if in_prose && !current.is_empty() {
59 prose.push(std::mem::take(&mut current).trim().to_string());
60 in_prose = false;
61 }
62 } else {
63 in_prose = true;
64 if !current.is_empty() {
65 current.push(' ');
66 }
67 current.push_str(trimmed);
68 }
69 }
70
71 if in_prose && !current.is_empty() {
72 prose.push(current.trim().to_string());
73 }
74
75 ListItems { bullets, prose }
76}
77
78/// Parse a body that contains multiple named bullet lists, each introduced by
79/// a `## name` heading. Order is preserved so callers can render sections in
80/// the same sequence the file declared them.
81pub fn parse_named_lists(body: &str) -> Vec<(String, Vec<String>)> {
82 let mut sections: Vec<(String, Vec<String>)> = Vec::new();
83 let mut current: Option<(String, Vec<String>)> = None;
84 for line in body.split('\n') {
85 let trimmed = line.trim();
86 if let Some(rest) = trimmed.strip_prefix("## ") {
87 if let Some(prev) = current.take() {
88 sections.push(prev);
89 }
90 current = Some((rest.trim().to_string(), Vec::new()));
91 } else if let Some(rest) = trimmed.strip_prefix("- ") {
92 if let Some((_, items)) = current.as_mut() {
93 items.push(rest.to_string());
94 }
95 }
96 }
97 if let Some(prev) = current.take() {
98 sections.push(prev);
99 }
100 sections
101}
102
103#[derive(Debug, Clone)]
104pub struct Season {
105 pub name: String,
106 pub label: String,
107 pub start: (u32, u32),
108 pub end: (u32, u32),
109 pub note: String,
110}
111
112#[derive(Debug, Clone)]
113pub struct MoonTip {
114 pub lo: f64,
115 pub hi: f64,
116 pub text: String,
117}
118
119#[derive(Deserialize, Debug)]
120pub struct ManifestEntry {
121 pub path: String,
122}
123
124pub struct SiteData {
125 pub files: HashMap<String, String>,
126 pub seasons: Vec<Season>,
127 /// canonical-order map (insertion order preserved) so the nav reads
128 /// winter -> early spring -> ... -> late fall.
129 pub seasons_order: Vec<String>,
130 pub seasons_by_name: HashMap<String, Season>,
131 pub haiku: HashMap<String, Vec<[String; 3]>>,
132 pub moods: HashMap<String, HashMap<String, String>>,
133 pub moon_tips: Vec<MoonTip>,
134}
135
136fn parse_md_date(s: &str) -> Result<(u32, u32)> {
137 let (m, d) = s.split_once('/').context("expected m/d")?;
138 Ok((m.parse()?, d.parse()?))
139}
140
141pub fn load_data(data_dir: &Path) -> Result<SiteData> {
142 let manifest_path = data_dir.join("manifest.json");
143 let manifest_text = std::fs::read_to_string(&manifest_path)
144 .with_context(|| format!("read manifest: {manifest_path:?}"))?;
145 let manifest: Vec<ManifestEntry> = serde_json::from_str(&manifest_text)?;
146
147 let mut files = HashMap::new();
148 for entry in &manifest {
149 let p = data_dir.join(&entry.path);
150 if let Ok(text) = std::fs::read_to_string(&p) {
151 files.insert(entry.path.clone(), text);
152 }
153 }
154
155 let (seasons, seasons_order, seasons_by_name) = load_seasons(data_dir)?;
156 let haiku = load_haiku(data_dir)?;
157 let moods = load_moods(data_dir)?;
158 let moon_tips = load_moon_tips(data_dir)?;
159
160 Ok(SiteData {
161 files,
162 seasons,
163 seasons_order,
164 seasons_by_name,
165 haiku,
166 moods,
167 moon_tips,
168 })
169}
170
171fn load_seasons(
172 data_dir: &Path,
173) -> Result<(Vec<Season>, Vec<String>, HashMap<String, Season>)> {
174 let dir = data_dir.join("seasons");
175 let mut entries: Vec<_> = std::fs::read_dir(&dir)?
176 .filter_map(Result::ok)
177 .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("md"))
178 .collect();
179 entries.sort_by_key(|e| e.file_name());
180
181 let mut seasons: Vec<Season> = Vec::new();
182 for entry in entries {
183 let text = std::fs::read_to_string(entry.path())?;
184 let parsed = parse_frontmatter(&text);
185 let name = parsed
186 .meta
187 .get("name")
188 .cloned()
189 .context("season missing name")?;
190 let label = parsed
191 .meta
192 .get("label")
193 .cloned()
194 .context("season missing label")?;
195 let start = parse_md_date(parsed.meta.get("start").context("season missing start")?)?;
196 let end = parse_md_date(parsed.meta.get("end").context("season missing end")?)?;
197 let note = parsed.body.trim().to_string();
198
199 seasons.push(Season {
200 name: name.clone(),
201 label: label.clone(),
202 start,
203 end,
204 note: note.clone(),
205 });
206
207 if let (Some(sa), Some(ea)) = (parsed.meta.get("start-alt"), parsed.meta.get("end-alt")) {
208 seasons.push(Season {
209 name,
210 label,
211 start: parse_md_date(sa)?,
212 end: parse_md_date(ea)?,
213 note,
214 });
215 }
216 }
217
218 seasons.sort_by_key(|s| (s.start.0, s.start.1));
219
220 let mut seasons_order = Vec::new();
221 let mut seasons_by_name: HashMap<String, Season> = HashMap::new();
222 for s in &seasons {
223 if !seasons_by_name.contains_key(&s.name) {
224 seasons_order.push(s.name.clone());
225 seasons_by_name.insert(s.name.clone(), s.clone());
226 }
227 }
228
229 Ok((seasons, seasons_order, seasons_by_name))
230}
231
232fn load_haiku(data_dir: &Path) -> Result<HashMap<String, Vec<[String; 3]>>> {
233 let dir = data_dir.join("haiku");
234 let mut out: HashMap<String, Vec<[String; 3]>> = HashMap::new();
235 for entry in std::fs::read_dir(&dir)? {
236 let entry = entry?;
237 if entry.path().extension().and_then(|s| s.to_str()) != Some("md") {
238 continue;
239 }
240 let text = std::fs::read_to_string(entry.path())?;
241 let parsed = parse_frontmatter(&text);
242 let season = parsed
243 .meta
244 .get("season")
245 .cloned()
246 .context("haiku missing season")?;
247 let mut poems = Vec::new();
248 for block in parsed.body.split("---") {
249 let lines: Vec<String> = block
250 .trim()
251 .split('\n')
252 .map(|l| l.trim().to_string())
253 .filter(|l| !l.is_empty())
254 .collect();
255 if lines.len() == 3 {
256 poems.push([lines[0].clone(), lines[1].clone(), lines[2].clone()]);
257 }
258 }
259 out.insert(season, poems);
260 }
261 Ok(out)
262}
263
264fn load_moods(data_dir: &Path) -> Result<HashMap<String, HashMap<String, String>>> {
265 let dir = data_dir.join("moods");
266 let mut out: HashMap<String, HashMap<String, String>> = HashMap::new();
267 for entry in std::fs::read_dir(&dir)? {
268 let entry = entry?;
269 if entry.path().extension().and_then(|s| s.to_str()) != Some("md") {
270 continue;
271 }
272 let text = std::fs::read_to_string(entry.path())?;
273 let parsed = parse_frontmatter(&text);
274 let season = parsed
275 .meta
276 .get("season")
277 .cloned()
278 .context("mood missing season")?;
279 let mut by_time = HashMap::new();
280 for line in parsed.body.split('\n') {
281 let line = line.trim();
282 if let Some(rest) = line.strip_prefix("- ") {
283 if let Some((time, mood)) = rest.split_once(':') {
284 by_time.insert(time.trim().to_string(), mood.trim().to_string());
285 }
286 }
287 }
288 out.insert(season, by_time);
289 }
290 Ok(out)
291}
292
293fn load_moon_tips(data_dir: &Path) -> Result<Vec<MoonTip>> {
294 let path = data_dir.join("moon-tips.md");
295 let text = std::fs::read_to_string(&path)?;
296 let parsed = parse_frontmatter(&text);
297 let mut tips = Vec::new();
298 for line in parsed.body.split('\n') {
299 let line = line.trim();
300 let Some(rest) = line.strip_prefix("- ") else {
301 continue;
302 };
303 let Some((range_str, tip_text)) = rest.split_once(':') else {
304 continue;
305 };
306 let Some((lo, hi)) = range_str.trim().split_once('-') else {
307 continue;
308 };
309 tips.push(MoonTip {
310 lo: lo.parse()?,
311 hi: hi.parse()?,
312 text: tip_text.trim().to_string(),
313 });
314 }
315 Ok(tips)
316}