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 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-23 22:01:55 +02:00
co-authored by Claude Fable 5.1
parent a8e9d38a6a
commit 713f8ff3a0
2 changed files with 104 additions and 2 deletions
+4 -1
View File
@@ -44,7 +44,10 @@ default.
### Rebuilding a live site ### Rebuilding a live site
`--words-from <repo>` lifts the words a repo already has back into the `--words-from <repo>` 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 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 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 group they invite into), not by position, so a rebuild that reorders
+100 -1
View File
@@ -1812,6 +1812,12 @@ pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result<BTreeMap<String, V
let rendered = render(plan, &tokened)?; let rendered = render(plan, &tokened)?;
let mut found: std::collections::HashMap<String, String> = std::collections::HashMap::new(); let mut found: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for (path, text) in &rendered { 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(live) = std::fs::read_to_string(repo.join(path)) else { continue };
let (Ok(a), Ok(b)) = ( let (Ok(a), Ok(b)) = (
serde_yaml::from_str::<serde_yaml::Value>(text), serde_yaml::from_str::<serde_yaml::Value>(text),
@@ -1821,9 +1827,79 @@ pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result<BTreeMap<String, V
}; };
collect_words(&a, &b, &mut found); collect_words(&a, &b, &mut found);
} }
// A second pass across the whole repo: a card that moved page (a
// door taken away, a need brought onto the front door) is still
// the same card, and keeps its words. Every alternative the repo
// has goes into one pool; a rendered card none of whose slots
// were found on its own page takes the pool's card with the same
// strong identity - the bucket it records into, the page it leads
// to, the group it invites into - never a bare `action` like `/`.
let mut pool: Vec<serde_yaml::Value> = Vec::new();
for live in yaml_files(&repo.join("questions")) {
let Ok(page) = serde_yaml::from_str::<serde_yaml::Value>(&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::<serde_yaml::Value>(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()) 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/<need>` 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<String> {
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<String> {
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 /// What identifies an item of a page regardless of its position: the
/// structural strings portal itself keys on. Word slots (tokens on /// structural strings portal itself keys on. Word slots (tokens on
/// the rendered side) are never an identity. /// 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() { for (i, va) in x.iter().enumerate() {
let ida = identity(va); let ida = identity(va);
let hit = if ida.is_empty() { 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 { } else {
(0..y.len()).find(|&j| !used[j] && identity(&y[j]).iter().any(|k| ida.contains(k))) (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"; 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] #[test]
fn inviting_desks_get_a_form_per_group_and_a_list_of_invites_sent() { fn inviting_desks_get_a_form_per_group_and_a_list_of_invites_sent() {
let plan: Plan = serde_yaml::from_str(PLAN).unwrap(); let plan: Plan = serde_yaml::from_str(PLAN).unwrap();