Skip to main content

surf_parse/
slots.rs

1//! Generation slot markers (`«FILL: …»` / `«IMG: …»`) — render-time resolution.
2//!
3//! Mako's app generator marks brand-identity fields it cannot know as fill
4//! slots (`«FILL: what it is | a realistic default»`) and references images
5//! only as slots (`«IMG: description of the image»`). The markers stay RAW in
6//! the committed `site/index.surf` so they remain editable; this module
7//! resolves them in the FINAL rendered HTML only (called from
8//! [`crate::render_html::render_site_page`] and
9//! [`crate::render_html::render_site_single_file`]).
10//!
11//! The pass is a linear scanner over the rendered document with a small
12//! context state machine (text / inside-tag / script-style raw text). The
13//! output HTML is machine-generated and well-formed — `escape_html` bans raw
14//! `<` in text and attribute values — so the classification is sound:
15//!
16//! - `«FILL: label | default»` → the default text (label when the default
17//!   segment is missing or empty), in every context. `|` is the primary
18//!   separator; when it is absent a spaced em-dash (`label — default`) is
19//!   accepted as a fallback separator.
20//! - `«IMG: description»` in **text** → `<img class="surfdoc-img-slot"
21//!   src=[`IMG_SLOT_PLACEHOLDER_URI`] alt="description" loading="lazy">`.
22//! - `«IMG: description»` **inside a tag** (`src="…"`, `style="…url('…')…"`)
23//!   → the bare placeholder URI.
24//! - `«IMG: description»` **inside `<script>`/`<style>` content** (JSON-LD
25//!   etc.) → the description text only; never a tag.
26//!
27//! Unterminated or malformed markers are left verbatim.
28
29/// Placeholder image for unresolved `«IMG:»` slots: a self-contained SVG data
30/// URI (neutral surface, sun + mountains glyph).
31///
32/// Guaranteed free of `"`, `'`, `&`, and `<` so it splices verbatim into
33/// `src="…"`, `url('…')`, and `url("…")` contexts. Style chunks may swap the
34/// SVG body but MUST keep that guarantee (percent-encode everything).
35pub const IMG_SLOT_PLACEHOLDER_URI: &str = "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20600%20400%22%3E%3Crect%20width%3D%22600%22%20height%3D%22400%22%20fill%3D%22%23e5e9f0%22%2F%3E%3Ccircle%20cx%3D%22410%22%20cy%3D%22130%22%20r%3D%2242%22%20fill%3D%22%23c9d2e0%22%2F%3E%3Cpath%20d%3D%22M0%20400%20210%20180%20330%20310%20420%20230%20600%20400z%22%20fill%3D%22%23c9d2e0%22%2F%3E%3C%2Fsvg%3E";
36
37/// Rendering context at the current scan position.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum Ctx {
40    /// Regular element content (escaped text between tags).
41    Text,
42    /// Between a raw `<` and its closing `>` (tag name + attributes).
43    Tag,
44    /// Raw text content of a `<script>` or `<style>` element.
45    Raw,
46}
47
48/// Which raw-text element the tag being scanned opens (if any).
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum RawKind {
51    None,
52    Script,
53    Style,
54}
55
56/// Resolve generation slot markers in rendered HTML. See the module docs for
57/// the marker grammar and per-context behavior. Returns the input unchanged
58/// (no allocation beyond the move) when it contains no `«`.
59pub fn resolve_slot_markers(html: String) -> String {
60    if !html.contains('\u{00AB}') {
61        return html;
62    }
63    let bytes = html.as_bytes();
64    let len = bytes.len();
65    let mut out = String::with_capacity(len + 128);
66    let mut seg = 0usize; // start of the pending verbatim slice
67    let mut i = 0usize;
68    let mut ctx = Ctx::Text;
69    // What the tag currently being scanned opens once its `>` closes it.
70    let mut pending = RawKind::None;
71    // Which raw element we are inside (valid while ctx == Ctx::Raw).
72    let mut raw_kind = RawKind::None;
73
74    while i < len {
75        match bytes[i] {
76            b'<' if ctx == Ctx::Text => {
77                pending = if tag_open(&html[i..], "script") {
78                    RawKind::Script
79                } else if tag_open(&html[i..], "style") {
80                    RawKind::Style
81                } else {
82                    RawKind::None
83                };
84                ctx = Ctx::Tag;
85                i += 1;
86            }
87            b'<' if ctx == Ctx::Raw => {
88                // Only the matching close tag ends script/style raw text.
89                let closes = match raw_kind {
90                    RawKind::Script => tag_open(&html[i..], "/script"),
91                    RawKind::Style => tag_open(&html[i..], "/style"),
92                    RawKind::None => false,
93                };
94                if closes {
95                    ctx = Ctx::Tag;
96                    pending = RawKind::None;
97                    raw_kind = RawKind::None;
98                }
99                i += 1;
100            }
101            b'>' if ctx == Ctx::Tag => {
102                if pending == RawKind::None {
103                    ctx = Ctx::Text;
104                } else {
105                    ctx = Ctx::Raw;
106                    raw_kind = pending;
107                    pending = RawKind::None;
108                }
109                i += 1;
110            }
111            // `«` is U+00AB = bytes C2 AB.
112            0xC2 if i + 1 < len && bytes[i + 1] == 0xAB => {
113                match parse_marker(&html, i, ctx) {
114                    Some((end, replacement)) => {
115                        out.push_str(&html[seg..i]);
116                        out.push_str(&replacement);
117                        i = end;
118                        seg = end;
119                    }
120                    // Not a well-formed marker: leave the `«` verbatim and
121                    // keep scanning after it (an inner marker may still parse).
122                    None => i += 2,
123                }
124            }
125            _ => i += 1,
126        }
127    }
128    out.push_str(&html[seg..]);
129    out
130}
131
132/// Does `rest` (which starts at a `<`) open the given tag name? Pass
133/// `"/script"` / `"/style"` for close tags. Case-insensitive; the name must be
134/// followed by whitespace, `>`, `/`, or end-of-input (so `<style>` matches but
135/// `<styleguide>` does not).
136fn tag_open(rest: &str, name: &str) -> bool {
137    // Byte-wise: `rest` may contain multibyte chars right after the name
138    // position, so `&str` slicing could split a char boundary.
139    let r = &rest.as_bytes()[1..];
140    let n = name.as_bytes();
141    if r.len() < n.len() || !r[..n.len()].eq_ignore_ascii_case(n) {
142        return false;
143    }
144    matches!(
145        r.get(n.len()),
146        None | Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r') | Some(b'>') | Some(b'/')
147    )
148}
149
150/// Try to parse a slot marker starting at byte `start` (which points at `«`).
151/// Returns `(end_byte_index_past_the_closing_guillemet, replacement)` or
152/// `None` when the text at `start` is not a well-formed, same-context marker.
153fn parse_marker(html: &str, start: usize, ctx: Ctx) -> Option<(usize, String)> {
154    let after = &html[start + 2..]; // past the 2-byte `«`
155    let (is_fill, body) = if let Some(rest) = after.strip_prefix("FILL:") {
156        (true, rest)
157    } else if let Some(rest) = after.strip_prefix("IMG:") {
158        (false, rest)
159    } else {
160        return None;
161    };
162
163    // Find the closing `»` (C2 BB). A raw `<`, `>`, or a nested `«` before it
164    // means the marker crosses a context boundary or is malformed — leave it
165    // verbatim rather than risk splicing across markup.
166    let b = body.as_bytes();
167    let mut j = 0usize;
168    let close = loop {
169        if j >= b.len() {
170            return None;
171        }
172        match b[j] {
173            b'<' | b'>' => return None,
174            0xC2 if j + 1 < b.len() && b[j + 1] == 0xBB => break j,
175            0xC2 if j + 1 < b.len() && b[j + 1] == 0xAB => return None,
176            _ => j += 1,
177        }
178    };
179    let content = &body[..close];
180    // end = « (2 bytes) + "FILL:"/"IMG:" prefix + content + » (2 bytes)
181    let end = start + 2 + (after.len() - body.len()) + close + 2;
182
183    let replacement = if is_fill {
184        // «FILL: label | default» — first `|` splits; a second `|` truncates
185        // the default (trained-prompt grammar uses `|` as the sole separator).
186        // Fallback: with NO `|` at all, a spaced em-dash (` — `) separates
187        // label from default — models imitate the trained prompt's em-dashes
188        // (`«FILL: brand tag — Skate or Die»` must render the default, not
189        // the label + dash).
190        let (label, default) = if content.contains('|') {
191            let mut parts = content.split('|');
192            (
193                parts.next().unwrap_or("").trim(),
194                parts.next().map(str::trim).unwrap_or(""),
195            )
196        } else {
197            match content.split_once(" \u{2014} ") {
198                Some((l, d)) => (l.trim(), d.trim()),
199                None => (content.trim(), ""),
200            }
201        };
202        if !default.is_empty() {
203            default.to_string()
204        } else {
205            label.to_string()
206        }
207    } else {
208        let desc = content.trim();
209        match ctx {
210            // Attribute position (src="…", style="…url('…')…"): the URI only.
211            Ctx::Tag => IMG_SLOT_PLACEHOLDER_URI.to_string(),
212            // Script/style content (JSON-LD, CSS): never emit a tag.
213            Ctx::Raw => desc.to_string(),
214            Ctx::Text => {
215                // desc arrived through escape_html in any legit text context,
216                // so `&`/`<`/`>` are already entity-escaped; only raw `"`
217                // needs (re-)escaping for the alt attribute.
218                let alt = desc.replace('"', "&quot;");
219                format!(
220                    "<img class=\"surfdoc-img-slot\" src=\"{IMG_SLOT_PLACEHOLDER_URI}\" alt=\"{alt}\" loading=\"lazy\">"
221                )
222            }
223        }
224    };
225    Some((end, replacement))
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn resolve(s: &str) -> String {
233        resolve_slot_markers(s.to_string())
234    }
235
236    // 1. The plan's exact H1 case: FILL resolves to its default.
237    #[test]
238    fn fill_resolves_default() {
239        let html = "<h1 class=\"surfdoc-hero-headline\">\u{ab}FILL: brand tag/headline | Skate or Die in One Piece\u{bb}</h1>";
240        let out = resolve(html);
241        assert_eq!(
242            out,
243            "<h1 class=\"surfdoc-hero-headline\">Skate or Die in One Piece</h1>"
244        );
245        assert!(!out.contains('\u{ab}') && !out.contains('\u{bb}'));
246    }
247
248    // 2. Missing or empty default falls back to the label.
249    #[test]
250    fn fill_missing_or_empty_default_uses_label() {
251        assert_eq!(resolve("<p>\u{ab}FILL: phone\u{bb}</p>"), "<p>phone</p>");
252        assert_eq!(resolve("<p>\u{ab}FILL: phone | \u{bb}</p>"), "<p>phone</p>");
253        // FILL resolves inside attributes and <script> content too.
254        assert_eq!(
255            resolve("<meta content=\"\u{ab}FILL: tagline | Ride fast\u{bb}\">"),
256            "<meta content=\"Ride fast\">"
257        );
258        assert_eq!(
259            resolve("<script>{\"name\":\"\u{ab}FILL: name | Harbor\u{bb}\"}</script>"),
260            "<script>{\"name\":\"Harbor\"}</script>"
261        );
262    }
263
264    // 3. IMG in text context becomes a placeholder <img> carrying the
265    //    description as alt text and the styling hook class.
266    #[test]
267    fn img_text_context_emits_placeholder_img() {
268        let out = resolve("<p>\u{ab}IMG: storefront at dusk\u{bb}</p>");
269        assert!(out.contains("<img class=\"surfdoc-img-slot\""));
270        assert!(out.contains(&format!("src=\"{IMG_SLOT_PLACEHOLDER_URI}\"")));
271        assert!(out.contains("alt=\"storefront at dusk\""));
272        assert!(out.contains("loading=\"lazy\""));
273        assert!(!out.contains('\u{ab}'));
274    }
275
276    // 4. IMG inside a tag (src attribute) splices the bare URI — no nested tag.
277    #[test]
278    fn img_attr_context_emits_uri_only() {
279        let out = resolve("<img src=\"\u{ab}IMG: storefront\u{bb}\" alt=\"Storefront\">");
280        assert_eq!(
281            out,
282            format!("<img src=\"{IMG_SLOT_PLACEHOLDER_URI}\" alt=\"Storefront\">")
283        );
284        assert_eq!(out.matches("<img").count(), 1, "no <img> nested inside a tag");
285    }
286
287    // 5. IMG inside a style attribute's url('…') — the hero cover case.
288    #[test]
289    fn img_style_url_context() {
290        let out = resolve(
291            "<section class=\"surfdoc-hero\" style=\"background-image:url('\u{ab}IMG: hero skateboard\u{bb}')\">",
292        );
293        assert!(out.contains(&format!("background-image:url('{IMG_SLOT_PLACEHOLDER_URI}')")));
294        assert!(!out.contains('\u{ab}'));
295        // The URI is safe for both quote styles by construction.
296        assert!(!IMG_SLOT_PLACEHOLDER_URI.contains('\''));
297        assert!(!IMG_SLOT_PLACEHOLDER_URI.contains('"'));
298        assert!(!IMG_SLOT_PLACEHOLDER_URI.contains('&'));
299        assert!(!IMG_SLOT_PLACEHOLDER_URI.contains('<'));
300    }
301
302    // 6. IMG inside <script> (JSON-LD) emits the description text only.
303    #[test]
304    fn img_script_context_emits_text_only() {
305        let out = resolve(
306            "<script type=\"application/ld+json\">{\"image\":\"\u{ab}IMG: shop photo\u{bb}\"}</script>",
307        );
308        assert!(out.contains("{\"image\":\"shop photo\"}"));
309        assert!(!out.contains("surfdoc-img-slot"), "no tag inside script");
310        // Same guarantee for <style> raw text.
311        let css = resolve("<style>.x{/*\u{ab}IMG: swatch\u{bb}*/}</style>");
312        assert!(css.contains("/*swatch*/"));
313        assert!(!css.contains("<img"));
314    }
315
316    // 7. Unterminated / malformed markers stay verbatim; no panic.
317    #[test]
318    fn unterminated_or_malformed_markers_verbatim() {
319        assert_eq!(resolve("<p>\u{ab}FILL: oops</p>"), "<p>\u{ab}FILL: oops</p>");
320        assert_eq!(resolve("<p>\u{bb} alone</p>"), "<p>\u{bb} alone</p>");
321        assert_eq!(resolve("<p>\u{ab}\u{ab}</p>"), "<p>\u{ab}\u{ab}</p>");
322        assert_eq!(resolve("<p>\u{ab}NOTE: hi\u{bb}</p>"), "<p>\u{ab}NOTE: hi\u{bb}</p>");
323        // Marker at EOF, cut mid-grammar.
324        assert_eq!(resolve("tail \u{ab}IMG: x"), "tail \u{ab}IMG: x");
325        assert_eq!(resolve("tail \u{ab}"), "tail \u{ab}");
326        // A stray « before a real marker doesn't swallow it.
327        assert_eq!(
328            resolve("<p>\u{ab} and \u{ab}FILL: a | b\u{bb}</p>"),
329            "<p>\u{ab} and b</p>"
330        );
331    }
332
333    // 8. Multiple markers on one line all resolve; marker-free input takes the
334    //    fast path unchanged.
335    #[test]
336    fn multiple_markers_and_fast_path() {
337        let out = resolve("<p>\u{ab}FILL: a | A\u{bb} — \u{ab}FILL: b | B\u{bb}</p>");
338        assert_eq!(out, "<p>A — B</p>");
339        let plain = "<p>no markers, just prose &amp; entities</p>".to_string();
340        assert_eq!(resolve_slot_markers(plain.clone()), plain);
341        // FILL default with a second `|` truncates at it (grammar risk pin).
342        assert_eq!(resolve("<p>\u{ab}FILL: a | B | C\u{bb}</p>"), "<p>B</p>");
343    }
344
345    // 9. p3-copy-sweep: spaced em-dash as a fallback FILL separator when the
346    //    trained `|` is absent (models imitate the prompt's em-dashes).
347    #[test]
348    fn fill_emdash_separator_falls_back_to_default() {
349        assert_eq!(
350            resolve("<p>\u{ab}FILL: brand tag \u{2014} Skate or Die\u{bb}</p>"),
351            "<p>Skate or Die</p>"
352        );
353        // Only the FIRST spaced em-dash splits.
354        assert_eq!(
355            resolve("<p>\u{ab}FILL: tagline \u{2014} Fast \u{2014} and loud\u{bb}</p>"),
356            "<p>Fast \u{2014} and loud</p>"
357        );
358        // `|` still wins when present, even with an em-dash inside the default.
359        assert_eq!(
360            resolve("<p>\u{ab}FILL: tagline | Ride hard \u{2014} rest later\u{bb}</p>"),
361            "<p>Ride hard \u{2014} rest later</p>"
362        );
363        // Em-dash with an empty right side falls back to the label.
364        assert_eq!(
365            resolve("<p>\u{ab}FILL: phone \u{2014} \u{bb}</p>"),
366            "<p>phone</p>"
367        );
368        // Unspaced em-dash is NOT a separator (compound words stay whole).
369        assert_eq!(
370            resolve("<p>\u{ab}FILL: well\u{2014}known\u{bb}</p>"),
371            "<p>well\u{2014}known</p>"
372        );
373    }
374}