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:
co-authored by
Claude Fable 5.1
parent
a8e9d38a6a
commit
713f8ff3a0
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user