//! The emitted document: HTML shell, inline SVG table, inline JavaScript. //! //! ADR-0007 Decision 1. Marginal AM-4a cost zero — this is string //! formatting, and the browser draws it. //! //! ## What the JavaScript is allowed to be //! //! ADR-0007 Decision 5 bars it from constructing commands. [`SCRIPT`] is //! therefore the whole of it, it is a constant, and it does one thing: //! record which element the pointer went down on, which it came up on, and //! POST the pair. It contains no game vocabulary — no action names, no //! seats, no rules. If a rule ever needs to appear in it, the decision is //! wrong and ADR-0007 says to revisit it rather than widen the control. use std::fmt::Write as _; use cb_kernel::PlayerId; use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView}; use crate::input::action_id; /// Escape for text and attribute contexts alike. /// /// Everything interpolated into the document goes through this. Card /// suits and seat numbers cannot currently carry a `<`, but "cannot /// currently" is how injection bugs are written. pub fn esc(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(c), } } out } fn seat_name(p: PlayerId) -> String { format!("P{}", p.0 + 1) } fn cards(list: &[games_ground::SolutionCard]) -> String { if list.is_empty() { return "none".to_string(); } list.iter() .map(|c| format!("{:?}", c.suit)) .collect::>() .join(" ") } /// The only JavaScript in the project. See the module docs. pub const SCRIPT: &str = r#" (function () { var down = null, held = null, marked = [], ghost = null, ghostLabel = ''; // What is actually under the pointer, not what the browser decided the // event belongs to. CB-WP-0020 T03: for touch and pen a browser // implicitly captures the pointer to the pointerdown target, so // `e.target` on pointerup can be the element you STARTED on wherever // you release. That makes a drop look like a drop-on-itself, and the // "nothing droppable" message unreachable. elementFromPoint is correct // under both behaviours. function under(e) { if (document.elementFromPoint && e.clientX !== undefined) { return document.elementFromPoint(e.clientX, e.clientY) || e.target; } return e.target; } function node(e) { var n = under(e); while (n && !(n.getAttribute && n.getAttribute('data-drop'))) { n = n.parentNode; } return n; } function key(n) { return n ? n.getAttribute('data-drop') : null; } function status(t) { document.getElementById('cb-status').textContent = t; } // ADR-0010 D1: the destinations come from `data-targets`, which Rust // wrote. This matches on them. It does not compute, infer, filter or // default one -- a script that pattern-matched ids to guess what is // legal would be forbidden even though the visible result is identical. // ADR-0010 D1 again: `data-descs` is written by Rust, in step with // `data-targets`. The script pairs them by index and renders one. It // does not compose a description from an id. function describeOf(held, key) { if (!held) { return null; } var t = held.getAttribute('data-targets'); var d = held.getAttribute('data-descs'); if (!t || !d) { return null; } var i = t.split(' ').indexOf(key); var all = d.split('|'); return i >= 0 && i < all.length ? all[i] : null; } function mark(n) { var spec = n.getAttribute('data-targets'); if (!spec) { return; } var want = spec.split(' '); var all = document.querySelectorAll('[data-drop]'); for (var i = 0; i < all.length; i++) { if (want.indexOf(all[i].getAttribute('data-drop')) >= 0) { all[i].classList.add('dropok'); marked.push(all[i]); } } } function clear() { for (var i = 0; i < marked.length; i++) { marked[i].classList.remove('dropok'); } marked = []; if (held) { held.classList.remove('held'); held = null; } if (ghost && ghost.parentNode) { ghost.parentNode.removeChild(ghost); } ghost = null; ghostLabel = ''; down = null; } document.addEventListener('pointerdown', function (e) { clear(); var n = node(e); down = key(n); if (!n || !down) { return; } held = n; n.classList.add('held'); mark(n); if (n.getAttribute('data-targets')) { ghost = document.createElement('div'); ghost.id = 'cb-ghost'; // Keep the label as markup, so the explanation can be appended // rather than replacing it (CB-WP-0020 T02). The first version set // textContent, which collapsed the card's line break and then got // overwritten by the explanation -- losing the only sign of what // was being carried, exactly when it was needed. ghostLabel = (n.getAttribute('data-drop') || '').replace('action-', ''); ghost.innerHTML = '' + ghostLabel + ''; ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px'; document.body.appendChild(ghost); } }); document.addEventListener('pointermove', function (e) { if (!ghost) { return; } ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px'; // The explanation, beside the pointer and therefore beside the // target it is over (CB-WP-0018 T03). var over = describeOf(held, key(node(e))); ghost.innerHTML = '' + ghostLabel + '' + (over ? '' + over + '' : ''); ghost.className = over ? 'over' : ''; }); document.addEventListener('pointercancel', clear); document.addEventListener('pointerup', function (e) { var up = key(node(e)); var grabbed = down; clear(); if (!grabbed || !up) { // Refusing is right; refusing SILENTLY is what let a broken drop // target survive a human sitting in front of it (CB-WP-0016). status(grabbed ? 'took ' + grabbed + ', let go over nothing droppable' : 'nothing droppable under the pointer'); return; } var body = 'down=' + encodeURIComponent(grabbed) + '&up=' + encodeURIComponent(up); fetch(window.CB_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body }).then(function (r) { return r.text(); }).then(function (t) { status(t); if (t.indexOf('ok') === 0) { window.location.reload(); } }); }); })(); "#; const STYLE: &str = " body{font:14px/1.5 ui-monospace,monospace;margin:1.5rem;background:#12141a;color:#dde} h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf} .row{display:flex;flex-wrap:wrap;gap:.5rem;align-items:flex-start} .card{border:1px solid #445;border-radius:6px;padding:.5rem .7rem;background:#1b1e26} .card[data-viewer=true]{border-color:#9cf} .act{cursor:grab;user-select:none;background:#243;border-color:#5a7} .btn{cursor:pointer;background:#332a3a;border-color:#a7d} /* CB-WP-0017. Interactive and inert must not look identical: `.pick` is the resting affordance on anything that can be picked up. ADR-0010 D5 claims no evidence that this READS as pickable -- that is a human check, and stage 1 is open on it. */ .pick{cursor:grab;user-select:none;box-shadow:0 2px 0 #0006,0 0 0 1px #5a7a inset; transition:transform .08s,box-shadow .08s} .pick:hover{box-shadow:0 3px 8px #000a,0 0 0 1px #7ca inset;transform:translateY(-1px)} /* A legal destination for the thing currently held -- and ONLY for that thing. The set is written by Rust into data-targets; the script matches it and never derives it (ADR-0010 D1). CB-WP-0020 T01, at the maintainer's instruction: change the EXISTING border, do not draw a new box. `outline` + `outline-offset` drew a second rectangle outside the element, which an SVG viewport clips (the reported missing top and left edges) and which made a seat's highlight the size of a whole card. Restyling the border in place cannot move anything, because the border is already in the layout. */ .dropok{border-style:dashed;border-color:#9cf;background:#1d2a33} .dropok circle,.dropok rect{stroke:#9cf;stroke-dasharray:5 3} .dropok text{fill:#cfe} /* The ghost that follows the pointer, so a drag is not invisible. */ #cb-ghost.over{background:#1d3347;border-color:#9cf;color:#cfe} /* The thing in your hand. Deliberately NOT card-shaped: it used to be a textContent copy of the card, so the line break collapsed and it read as a second card sitting next to the first (CB-WP-0020 T02). */ #cb-ghost{position:fixed;pointer-events:none;z-index:9;padding:.25rem .55rem; border-radius:999px;background:#2b4a3a;border:1px solid #7ca; color:#dfe;font:12px ui-monospace,monospace; box-shadow:0 6px 16px #000b;transform:translate(-50%,-160%); white-space:nowrap} /* The explanation is ADDITIONAL, never a replacement: losing the label is losing the only sign of what you are carrying. */ #cb-ghost b{color:#cfe} #cb-ghost .why{color:#9cf;margin-left:.4rem} /* What you picked up, left visibly behind so there are not two cards. */ .held{cursor:grabbing;opacity:.35;border-style:dashed} .k{color:#89a} .nil{color:#c88} .eff{color:#8c9} #cb-log{max-height:16rem;overflow-y:auto;font-size:13px} #cb-status{margin-top:1rem;color:#fc9;min-height:1.2em} svg{background:#1b1e26;border:1px solid #445;border-radius:6px} "; /// Render drop keys as something a player can read. /// /// The keys are what the page posts; these are what it shows. Both come /// from the same list, so they cannot disagree about which moves exist. fn target_names(targets: &[String]) -> String { let mut out: Vec = Vec::new(); for t in targets { out.push(match t.split_once('-') { Some(("seat", n)) => n .parse::() .map(|n| seat_name(PlayerId(n))) .unwrap_or_else(|_| t.clone()), Some(("problem", n)) => format!("problem {n}"), _ if t == "table" => "the table".to_string(), _ => t.clone(), }); } out.join(", ") } fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) { let (label, sub, fill) = match p { ProblemView::FaceDown => ("face down".to_string(), String::new(), "#2a2f3a"), ProblemView::FaceUp { suit, value, denied, claimed_by, protected_this_round, } => { let mut sub = String::new(); if *denied { sub.push_str("denied "); } if *protected_this_round { sub.push_str("protected "); } if let Some(c) = claimed_by { let _ = write!(sub, "claimed by {}", seat_name(*c)); } ( format!("{suit:?} {value}"), sub.trim_end().to_string(), "#26303a", ) } }; let _ = write!( out, "\ {label}\ priority {priority}\ {sub}", x = x, tx = x + 10, label = esc(&label), sub = esc(&sub), ); } /// The relationship graph — the one element every Rust 2D toolkit would /// have left us to hand-roll, and the reason SVG earns its place here /// rather than merely fitting the budget (CB-RES-0006 §5). fn relations_svg(view: &GroundView) -> String { let n = view.players.len().max(1); let (cx, cy, r) = (200.0f64, 150.0f64, 110.0f64); let pos: Vec<(PlayerId, f64, f64)> = view .players .keys() .enumerate() .map(|(i, p)| { let a = std::f64::consts::TAU * (i as f64) / (n as f64) - std::f64::consts::FRAC_PI_2; (*p, cx + r * a.cos(), cy + r * a.sin()) }) .collect(); let find = |p: PlayerId| { pos.iter() .find(|(q, _, _)| *q == p) .map(|(_, x, y)| (*x, *y)) }; let mut s = String::from( "", ); for (pair, rel) in &view.relations { if let (Some((x1, y1)), Some((x2, y2))) = (find(pair.0), find(pair.1)) { let colour = match rel { games_ground::Relation::Bond => "#5c9", games_ground::Relation::Rivalry => "#c66", }; let _ = write!( s, "\ {rel:?}", mx = (x1 + x2) / 2.0, my = (y1 + y2) / 2.0 - 3.0, ); } } for (p, x, y) in &pos { let is_viewer = view.viewer == Some(*p); let focus = view .focus .get(p) .map(|f| format!(" \u{2192}{}", seat_name(*f))); let _ = write!( s, "\ {name}\ {focus}", raw = p.0, stroke = if is_viewer { "#9cf" } else { "#5a6b7a" }, ty = y + 2.0, ty2 = y + 16.0, name = seat_name(*p), focus = esc(focus.as_deref().unwrap_or("")), ); } s.push_str(""); s } /// A revealed selection, phrased the way the log phrases the command. /// /// Deliberately the same shape as `event_line`'s `ActionSelected` arm — /// a second vocabulary for the same fact drifts from the first, which is /// exactly what `{:?}` was doing here. fn selection_words(s: &games_ground::Selection) -> String { // Every field that is set is named. The first draft matched on // `(target, problem)` and showed only the target when both were // present — the coverage gate caught it, because a field the document // never shows is a field a player never sees. The aggregate does not // currently produce both, but a renderer that silently drops one is // the omission class this crate exists to guard against. let mut out = format!("{:?}", s.action); if let Some(t) = s.target { let _ = write!(out, " on {}", seat_name(t)); } if let Some(n) = s.problem { let _ = write!(out, " for problem {n}"); } out } fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView) { let is_viewer = view.viewer == Some(id); let _ = write!( out, // CB-WP-0016 T01: the seat card is a drop target. It could not be // while drop keys were `id`s — the graph node had already taken // `seat-{n}` and ids must be unique, so the card the instruction // text points at silently had none. "
\ {name}{you}
", raw = id.0, name = seat_name(id), you = if is_viewer { " (you)" } else { "" }, ); let _ = write!( out, "stress {} protect {} \ darvo {:?}
", p.stress, p.protection, p.darvo ); let _ = write!( out, "freedom \ {ready}{lifted}
", raw = id.0, ready = if p.freedom_ready { "READY" } else { "spent" }, lifted = if p.freedom_gate_lifted { " gate lifted" } else { "" }, ); let blame = if p.blame_from.is_empty() { "none".to_string() } else { p.blame_from .iter() .map(|b| seat_name(*b)) .collect::>() .join(" ") }; let _ = write!( out, "blamed by {}
", esc(&blame) ); match &p.hand { Some(h) => { let _ = write!( out, "hand {} ({} cards)
", esc(&cards(h)), p.hand_size ); } None => { let _ = write!( out, "hand {} card(s), hidden
", p.hand_size ); } } if let Some(sel) = view.selections.get(&id) { let _ = write!( out, "selected {}
", match sel { SelectionView::Hidden => "face down".to_string(), // CB-WP-0020 T04: in words, not `Selection { action: // Solve, target: None, problem: Some(1) }`. After Reveal // this is how a player follows what everyone else did, // and it was the same Debug-on-a-player-surface defect // the log fixed in CB-WP-0018 and this did not. SelectionView::Shown(s) => esc(&selection_words(s)), } ); } if let Some(m) = view.ground_modes.get(&id) { let _ = write!(out, "ground mode {m:?}
"); } if let Some(c) = view.ground_choices.get(&id) { let _ = write!(out, "ground choice {c:?}
"); } if let Some(r) = view.support_responses.get(&id) { let _ = write!(out, "support {r:?}
"); } if let Some(t) = view.darvo_targets.get(&id) { let _ = write!(out, "darvo target {t:?}
"); } out.push_str("
"); } /// Render the whole table as a standalone document. /// /// `may_pass` adds the one affordance that is not a command: declining to /// act where the driver allows it. Like every other element it carries an /// id and nothing else — the page still reports only that the pointer went /// down and up on `pass`. /// /// `legal` is the list the aggregate offered; the buttons carry indices /// into it and nothing else (ADR-0007 control 5). `endpoint` carries the /// per-process token (control 1) — the page cannot mint one. pub fn document( view: &GroundView, legal: &[games_ground::GroundCommand], endpoint: &str, seat: Option, may_pass: bool, ) -> String { document_with_log(view, legal, endpoint, seat, may_pass, &[]) } /// The table, plus the game log (CB-WP-0018 T02). pub fn document_with_log( view: &GroundView, legal: &[games_ground::GroundCommand], endpoint: &str, seat: Option, may_pass: bool, log: &[LogLine], ) -> String { let mut s = String::with_capacity(8192); let _ = write!( s, "\ \ GROUND \u{2014} round {round}", round = view.round ); let _ = write!( s, "

GROUND \u{2014} round {round}, step {step:?}

\
lead {lead} \ scoring {mode:?} \ viewing as {who}
", round = view.round, step = view.step, lead = seat_name(view.lead), mode = view.mode, who = match view.viewer { Some(p) => format!("{} (their hand only)", seat_name(p)), None => "a spectator (no hands)".to_string(), }, ); body(&mut s, view); move_section(&mut s, legal, seat, may_pass); log_section(&mut s, log); let _ = write!( s, "
ready
\ \ ", endpoint = json_string(endpoint), ); s } /// The table itself: problems, relationships, seats, solutions, outcome. /// /// Factored out of [`document`] so [`ending`] shows the SAME table rather /// than a second rendering of it — two renderings of one state is how /// they drift. fn body(s: &mut String, view: &GroundView) { s.push_str( "

problems

", ); if view.problems.is_empty() { s.push_str( "no problems in play", ); } for (i, (priority, p)) in view.problems.iter().enumerate() { problem_svg(s, *priority, p, 10 + (i as i32) * 130); } s.push_str(""); s.push_str("

relationships

"); s.push_str(&relations_svg(view)); s.push_str("

seats

"); for (id, p) in &view.players { player_card(s, *id, p, view); } s.push_str("
"); let _ = write!( s, "

solutions

deck {} remaining \ discard {}
", view.solution_deck_len, esc(&cards(&view.solution_discard)), ); if let Some(o) = &view.outcome { let personal = o .personal .iter() .map(|(p, v)| format!("{} {v:+}", seat_name(*p))) .collect::>() .join(" "); let winners = if o.winners.is_empty() { "nobody".to_string() } else { o.winners .iter() .map(|w| seat_name(*w)) .collect::>() .join(" ") }; let coalitions = if o.coalitions.is_empty() { "none".to_string() } else { o.coalitions .iter() .map(|c| format!("{c:?}")) .collect::>() .join(" ") }; let _ = write!( s, "

outcome

\ total {total} of {threshold} \ group {group}
\ personal {personal}
\ coalitions {coalitions}
\ mastery {mastery}
\ winners {winners}
", total = o.total, threshold = o.threshold, group = if o.group_success { "success" } else { "failure" }, personal = esc(&personal), coalitions = esc(&coalitions), mastery = match o.mastery { Some(m) => format!("{m:+}"), None => "none".to_string(), }, winners = esc(&winners), ); } } /// The "your move" section: action cards, numbered fallbacks, the table, /// and pass. Emits nothing when the seat has nothing legal to do. fn move_section( s: &mut String, legal: &[games_ground::GroundCommand], seat: Option, may_pass: bool, ) { if !legal.is_empty() { s.push_str("

your move

"); for a in [ games_ground::Action::Investigate, games_ground::Action::Solve, games_ground::Action::Support, games_ground::Action::Attack, games_ground::Action::Ground, ] { // ADR-0010 D1: the legal targets come from `legal` and are // written into the page as data. The script matches on them; // it never derives them. The old text was a CONSTANT — // "onto a seat, a problem, or the table" — emitted whenever // any legal command used this action, and it was wrong // wherever the real target set was narrower, which is almost // everywhere: Investigate is legal on problems 2 and 3 but // not 1 (CB-WP-0017). // CB-WP-0018 T03: targets and their meanings, in step, both // written by Rust. ADR-0010 D1 forbids the page composing the // second from the first. let offered: Vec<(String, String)> = seat .map(|seat| { legal .iter() .filter_map(|c| { crate::input::affordance(c, seat) .filter(|(f, _)| *f == action_id(a)) .map(|(_, t)| (t, crate::input::describe(c, seat))) }) .collect() }) .unwrap_or_default(); let targets: Vec = offered.iter().map(|(t, _)| t.clone()).collect(); let descs: Vec = offered.iter().map(|(_, d)| d.clone()).collect(); if !targets.is_empty() { let _ = write!( s, "
{a:?}
\ onto {names}
", id = action_id(a), targets = esc(&targets.join(" ")), descs = esc(&descs.join("|")), names = esc(&target_names(&targets)), ); } } s.push_str("
"); for (i, c) in legal.iter().enumerate() { let spatial = seat.is_some_and(|seat| crate::input::affordance(c, seat).is_some()); if !spatial { let _ = write!( s, "
{}
", esc(&format!("{c:?}")) ); } } s.push_str( "
the table \u{2014} drop here for an \ untargeted action
", ); } if may_pass { s.push_str( "
pass \u{2014} decline to act
", ); } } /// Quote a string as a JSON literal, so a token can never end the script. fn json_string(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '<' => out.push_str("\\u003c"), '>' => out.push_str("\\u003e"), '&' => out.push_str("\\u0026"), c if (c as u32) < 0x20 => { let _ = write!(out, "\\u{:04x}", c as u32); } c => out.push(c), } } out.push('"'); out } /// Extract the document's visible text and element ids. /// /// ADR-0007 control 6 requires the coverage gate to assert over the /// **parsed emitted document**, not over the Rust that emits it. This is /// that parse: it drops markup and returns what a reader would see, plus /// the ids a pointer can address. A substring search over the raw source /// would happily find a token inside a comment or a style rule. /// Every `data-drop="…"` value in the document. /// /// CB-WP-0016. A real parse of the attribute rather than a substring /// search: `html.contains("seat-1")` would be satisfied by the *text* /// "seat-1" and by `data-drop="seat-10"`, and the point of the check this /// feeds is that an affordance can name a target that is not there. /// /// **Drop keys are `data-drop`, not `id`, and that is the fix for /// CB-WP-0016.** An `id` must be unique in a document, so exactly one /// element could ever be `seat-0` — the relationship-graph circle took it /// and the seat card the instruction text points at went without. A seat /// is drawn twice and both drawings are the seat. /// One line of the game log: what was done, and what it produced. /// /// Built by the caller from `bot::Applied` so this crate stays free of /// the driver, and phrased in the **recorder's** vocabulary via /// `record::to_step` — CB-WP-0018 T02 forbids a fourth phrasing, because /// what the player reads should be what the scenario file will say. pub struct LogLine { pub who: String, pub what: String, /// Rendered effects. **Empty is the case that matters:** a command /// that produced no events is the one the player cannot otherwise /// account for. pub effects: Vec, } /// The game log, newest last, with the empty case spelled out. fn log_section(s: &mut String, log: &[LogLine]) { s.push_str("

log

"); if log.is_empty() { s.push_str("nothing has happened yet"); } for line in log { let _ = write!( s, "
{who} {what}", who = esc(&line.who), what = esc(&line.what), ); if line.effects.is_empty() { // The silence CB-WP-0018 was reported for, said out loud. s.push_str(" \u{2014} no effect"); } else { for e in &line.effects { let _ = write!(s, "
\u{2192} {}", esc(e)); } } s.push_str("
"); } s.push_str("
"); } /// The page a finished game leaves behind (CB-WP-0018 T01). /// /// `view` is `None` when the game ended badly: then there is no result to /// draw and saying so is the whole point. Drawing a table for a game that /// crashed would be the same lie the empty page told, dressed up. /// /// **This page does not auto-reload.** The old one reloaded on `ok` and /// the reload was refused, which is how a completed game became a blank /// tab. pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[LogLine]) -> String { let mut s = String::with_capacity(4096); let _ = write!( s, "\ \ GROUND — game over\

game over

{msg}
", msg = esc(message), ); match view { Some(v) => body(&mut s, v), None => { s.push_str( "
The game ended without a result, so there \ is no final table to show. The reason is above.
", ); } } log_section(&mut s, log); let _ = write!( s, "
\
play again
\
close \u{2014} I have read this
\
\
the game is over
\ ", endpoint = json_string(endpoint), ); s } pub fn drop_keys(html: &str) -> std::collections::BTreeSet { let mut out = std::collections::BTreeSet::new(); let mut rest = html; while let Some(i) = rest.find("data-drop=\"") { let after = &rest[i + 11..]; match after.find('"') { Some(j) => { out.insert(after[..j].to_string()); rest = &after[j..]; } None => break, } } out } pub fn text_of(html: &str) -> String { let mut out = String::with_capacity(html.len() / 2); let bytes: Vec = html.chars().collect(); let mut i = 0; let mut skip_to: Option<&str> = None; while i < bytes.len() { if bytes[i] == '<' { // Find the tag name. let start = i + 1; let mut j = start; while j < bytes.len() && bytes[j] != '>' { j += 1; } let tag: String = bytes[start..j.min(bytes.len())].iter().collect(); let lower = tag.to_ascii_lowercase(); let name = lower .trim_start_matches('/') .split([' ', '\t', '\n', '>']) .next() .unwrap_or("") .to_string(); if let Some(want) = skip_to { if lower.starts_with('/') && name == want { skip_to = None; } } else if name == "script" || name == "style" { // Their contents are not text a reader sees. if !lower.starts_with('/') && !lower.ends_with('/') { skip_to = Some(if name == "script" { "script" } else { "style" }); } } else { // Ids are addressable surface, so they count as rendered. if let Some(k) = lower.find("id=\"") { let rest = &tag[k + 4..]; if let Some(end) = rest.find('"') { out.push(' '); out.push_str(&rest[..end]); } } } i = j + 1; continue; } if skip_to.is_none() { out.push(bytes[i]); } i += 1; } // Unescape the entities esc() introduced, so a test looks for the text // a reader sees rather than its encoding. out.replace("<", "<") .replace(">", ">") .replace(""", "\"") .replace("'", "'") .replace("&", "&") }