From 713f8ff3a057862345e26aca6f670c39eb09e6aa Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 23 Sep 2026 22:01:55 +0200 Subject: [PATCH] build: words follow a card across pages when a site is restructured `--words-from` matched page by page, so flattening Tomter Vel lost every heading its door pages carried. Now a second pass pools every alternative in the repo and hands a rendered card the pooled card with the same strong identity (bucket, own page, invite group; never a bare action like `/`). Two things stood in the way and are fixed: needs.yaml is no longer a source of words (its `via` named the door), and a live feature's `name` no longer blocks a positional match, so placeholders and guidance lines come along too. Rebuilding the vel flat from the live repo now lifts all ten headings and every hint. Co-Authored-By: Claude Fable 5.1 --- README.md | 5 ++- src/build.rs | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6f1d52f..fba5209 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,10 @@ default. ### Rebuilding a live site `--words-from ` lifts the words a repo already has back into the -words map by structure, so a rebuild keeps hand-edited wording and +words map by what each card is (its bucket, the page it leads to, the +group it invites into), across the whole repo, so a rebuild keeps +hand-edited wording even when a card moves page (a door taken away, a +need brought onto the front door) and adds only the new slots. Cards are matched by what they are (the bucket they record into, the page they lead to, a field's name, the group they invite into), not by position, so a rebuild that reorders diff --git a/src/build.rs b/src/build.rs index 972f728..596acfd 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1812,6 +1812,12 @@ pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result = std::collections::HashMap::new(); for (path, text) in &rendered { + // The spec is derived from the words (a need's `via` is its + // heading), never a source of them: behind a door it named the + // door, and a positional match would hand that to the form. + if path == "needs.yaml" { + continue; + } let Ok(live) = std::fs::read_to_string(repo.join(path)) else { continue }; let (Ok(a), Ok(b)) = ( serde_yaml::from_str::(text), @@ -1821,9 +1827,79 @@ pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result = Vec::new(); + for live in yaml_files(&repo.join("questions")) { + let Ok(page) = serde_yaml::from_str::(&live) else { continue }; + if let Some(alts) = page.get("alternatives").and_then(|a| a.as_sequence()) { + pool.extend(alts.iter().cloned()); + } + } + let mut used = vec![false; pool.len()]; + for text in rendered.values() { + let Ok(page) = serde_yaml::from_str::(text) else { continue }; + let Some(alts) = page.get("alternatives").and_then(|a| a.as_sequence()) else { continue }; + for alt in alts { + let ida: Vec<(&str, String)> = identity(alt).into_iter().filter(|(k, v)| strong_identity(k, v)).collect(); + if ida.is_empty() || tokens_in(alt).iter().any(|t| found.contains_key(t)) { + continue; + } + if let Some(j) = (0..pool.len()).find(|&j| !used[j] && identity(&pool[j]).iter().any(|k| ida.contains(k))) { + used[j] = true; + collect_words(alt, &pool[j], &mut found); + } + } + } Ok(fallback.iter().map(|(path, v)| (path.clone(), untokenize(v, path, "", &found))).collect()) } +/// An identity that survives a move between pages: what a card does, +/// not where a nav link points. `/received/` and `/status/…` +/// are one need's own pages; `/`, `/owners` and the like are shared +/// by every "back" and every desk card. +fn strong_identity(key: &str, value: &str) -> bool { + match key { + "action" => value.starts_with("/received/") || value.starts_with("/status/") || value.starts_with("/decide/"), + "name" => false, + _ => true, + } +} + +/// Every token slot inside a rendered item. +fn tokens_in(v: &serde_yaml::Value) -> Vec { + let mut out = Vec::new(); + match v { + serde_yaml::Value::String(s) if s.starts_with('\u{1}') => out.push(s.clone()), + serde_yaml::Value::Mapping(m) => out.extend(m.values().flat_map(tokens_in)), + serde_yaml::Value::Sequence(s) => out.extend(s.iter().flat_map(tokens_in)), + _ => {} + } + out +} + +/// The text of every `.yaml` under `dir`, any depth. +fn yaml_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { return out }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + out.extend(yaml_files(&path)); + } else if path.extension().is_some_and(|e| e == "yaml") { + if let Ok(text) = std::fs::read_to_string(&path) { + out.push(text); + } + } + } + out +} + /// What identifies an item of a page regardless of its position: the /// structural strings portal itself keys on. Word slots (tokens on /// the rendered side) are never an identity. @@ -1903,7 +1979,9 @@ fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std:: for (i, va) in x.iter().enumerate() { let ida = identity(va); let hit = if ida.is_empty() { - (i < y.len() && !used[i] && identity(&y[i]).is_empty()).then_some(i) + // A name is a word, never an identity that blocks a + // positional match: a feature always carries one. + (i < y.len() && !used[i] && identity(&y[i]).iter().all(|(k, _)| *k == "name")).then_some(i) } else { (0..y.len()).find(|&j| !used[j] && identity(&y[j]).iter().any(|k| ida.contains(k))) }; @@ -1928,6 +2006,27 @@ mod invite_tests { const PLAN: &str = "business: Vel\ncontact: { name: S, email: s@v.example }\ngroups:\n - { id: trafikk, label: Trafikk }\ninvites_by: [styret]\nneeds:\n - id: melde\n who: a neighbour\n wants: a hazard seen to\n lands_in: farer\n handled_by: trafikk\n stages: [open, ordnet]\n done_when: [ordnet]\n max_fields: 3\n collects:\n - { name: hvor }\n - id: utlegg\n who: a committee member\n wants: money back\n enters_as: trafikk\n lands_in: utlegg\n handled_by: styret\n stages: [open, betalt, avvist]\n done_when: [betalt, avvist]\n max_fields: 3\n collects:\n - { name: belop }\n"; + #[test] + fn words_follow_a_card_that_moves_to_another_page() { + // Built once behind a door and hand-worded; rebuilt flat, the + // form's words come along even though its page is gone. + let doored = "business: Vel\ncontact: { name: S, email: s@v.example }\ndoors:\n - { id: melde-fra, who: someone who saw something }\nneeds:\n - id: melde\n who: a neighbour\n wants: a hazard seen to\n door: melde-fra\n lands_in: farer\n handled_by: trafikk\n stages: [open, ordnet]\n done_when: [ordnet]\n max_fields: 3\n collects:\n - { name: hvor }\n"; + let plan: Plan = serde_yaml::from_str(doored).unwrap(); + let mut words = fallback_words(&plan); + let door_page = words.get_mut("questions/melde-fra/index.yaml").unwrap(); + door_page["needs"]["melde"]["heading"] = json!("Meld fra om en trafikkfare"); + door_page["needs"]["melde"]["hints"]["hvor"] = json!("Svingen ved skolen"); + let repo = std::env::temp_dir().join(format!("trainer-lift-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&repo); + write_repo(&repo, render(&plan, &words).unwrap()).unwrap(); + + let flat: Plan = serde_yaml::from_str(&doored.replace("doors:\n - { id: melde-fra, who: someone who saw something }\n", "").replace(" door: melde-fra\n", "")).unwrap(); + let lifted = lift_words(&flat, &repo).unwrap(); + assert_eq!(lifted["questions/index.yaml"]["needs"]["melde"]["heading"], "Meld fra om en trafikkfare"); + assert_eq!(lifted["questions/index.yaml"]["needs"]["melde"]["hints"]["hvor"], "Svingen ved skolen"); + let _ = std::fs::remove_dir_all(&repo); + } + #[test] fn inviting_desks_get_a_form_per_group_and_a_list_of_invites_sent() { let plan: Plan = serde_yaml::from_str(PLAN).unwrap();