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
`--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
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
+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 mut found: std::collections::HashMap<String, String> = 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::<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);
}
// 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())
}
/// 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
/// 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();