pub 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";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Ctx {
Text,
Tag,
Raw,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RawKind {
None,
Script,
Style,
}
pub fn resolve_slot_markers(html: String) -> String {
if !html.contains('\u{00AB}') {
return html;
}
let bytes = html.as_bytes();
let len = bytes.len();
let mut out = String::with_capacity(len + 128);
let mut seg = 0usize; let mut i = 0usize;
let mut ctx = Ctx::Text;
let mut pending = RawKind::None;
let mut raw_kind = RawKind::None;
while i < len {
match bytes[i] {
b'<' if ctx == Ctx::Text => {
pending = if tag_open(&html[i..], "script") {
RawKind::Script
} else if tag_open(&html[i..], "style") {
RawKind::Style
} else {
RawKind::None
};
ctx = Ctx::Tag;
i += 1;
}
b'<' if ctx == Ctx::Raw => {
let closes = match raw_kind {
RawKind::Script => tag_open(&html[i..], "/script"),
RawKind::Style => tag_open(&html[i..], "/style"),
RawKind::None => false,
};
if closes {
ctx = Ctx::Tag;
pending = RawKind::None;
raw_kind = RawKind::None;
}
i += 1;
}
b'>' if ctx == Ctx::Tag => {
if pending == RawKind::None {
ctx = Ctx::Text;
} else {
ctx = Ctx::Raw;
raw_kind = pending;
pending = RawKind::None;
}
i += 1;
}
0xC2 if i + 1 < len && bytes[i + 1] == 0xAB => {
match parse_marker(&html, i, ctx) {
Some((end, replacement)) => {
out.push_str(&html[seg..i]);
out.push_str(&replacement);
i = end;
seg = end;
}
None => i += 2,
}
}
_ => i += 1,
}
}
out.push_str(&html[seg..]);
out
}
fn tag_open(rest: &str, name: &str) -> bool {
let r = &rest.as_bytes()[1..];
let n = name.as_bytes();
if r.len() < n.len() || !r[..n.len()].eq_ignore_ascii_case(n) {
return false;
}
matches!(
r.get(n.len()),
None | Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r') | Some(b'>') | Some(b'/')
)
}
fn parse_marker(html: &str, start: usize, ctx: Ctx) -> Option<(usize, String)> {
let after = &html[start + 2..]; let (is_fill, body) = if let Some(rest) = after.strip_prefix("FILL:") {
(true, rest)
} else if let Some(rest) = after.strip_prefix("IMG:") {
(false, rest)
} else {
return None;
};
let b = body.as_bytes();
let mut j = 0usize;
let close = loop {
if j >= b.len() {
return None;
}
match b[j] {
b'<' | b'>' => return None,
0xC2 if j + 1 < b.len() && b[j + 1] == 0xBB => break j,
0xC2 if j + 1 < b.len() && b[j + 1] == 0xAB => return None,
_ => j += 1,
}
};
let content = &body[..close];
let end = start + 2 + (after.len() - body.len()) + close + 2;
let replacement = if is_fill {
let (label, default) = if content.contains('|') {
let mut parts = content.split('|');
(
parts.next().unwrap_or("").trim(),
parts.next().map(str::trim).unwrap_or(""),
)
} else {
match content.split_once(" \u{2014} ") {
Some((l, d)) => (l.trim(), d.trim()),
None => (content.trim(), ""),
}
};
if !default.is_empty() {
default.to_string()
} else {
label.to_string()
}
} else {
let desc = content.trim();
match ctx {
Ctx::Tag => IMG_SLOT_PLACEHOLDER_URI.to_string(),
Ctx::Raw => desc.to_string(),
Ctx::Text => {
let alt = desc.replace('"', """);
format!(
"<img class=\"surfdoc-img-slot\" src=\"{IMG_SLOT_PLACEHOLDER_URI}\" alt=\"{alt}\" loading=\"lazy\">"
)
}
}
};
Some((end, replacement))
}
#[cfg(test)]
mod tests {
use super::*;
fn resolve(s: &str) -> String {
resolve_slot_markers(s.to_string())
}
#[test]
fn fill_resolves_default() {
let html = "<h1 class=\"surfdoc-hero-headline\">\u{ab}FILL: brand tag/headline | Skate or Die in One Piece\u{bb}</h1>";
let out = resolve(html);
assert_eq!(
out,
"<h1 class=\"surfdoc-hero-headline\">Skate or Die in One Piece</h1>"
);
assert!(!out.contains('\u{ab}') && !out.contains('\u{bb}'));
}
#[test]
fn fill_missing_or_empty_default_uses_label() {
assert_eq!(resolve("<p>\u{ab}FILL: phone\u{bb}</p>"), "<p>phone</p>");
assert_eq!(resolve("<p>\u{ab}FILL: phone | \u{bb}</p>"), "<p>phone</p>");
assert_eq!(
resolve("<meta content=\"\u{ab}FILL: tagline | Ride fast\u{bb}\">"),
"<meta content=\"Ride fast\">"
);
assert_eq!(
resolve("<script>{\"name\":\"\u{ab}FILL: name | Harbor\u{bb}\"}</script>"),
"<script>{\"name\":\"Harbor\"}</script>"
);
}
#[test]
fn img_text_context_emits_placeholder_img() {
let out = resolve("<p>\u{ab}IMG: storefront at dusk\u{bb}</p>");
assert!(out.contains("<img class=\"surfdoc-img-slot\""));
assert!(out.contains(&format!("src=\"{IMG_SLOT_PLACEHOLDER_URI}\"")));
assert!(out.contains("alt=\"storefront at dusk\""));
assert!(out.contains("loading=\"lazy\""));
assert!(!out.contains('\u{ab}'));
}
#[test]
fn img_attr_context_emits_uri_only() {
let out = resolve("<img src=\"\u{ab}IMG: storefront\u{bb}\" alt=\"Storefront\">");
assert_eq!(
out,
format!("<img src=\"{IMG_SLOT_PLACEHOLDER_URI}\" alt=\"Storefront\">")
);
assert_eq!(out.matches("<img").count(), 1, "no <img> nested inside a tag");
}
#[test]
fn img_style_url_context() {
let out = resolve(
"<section class=\"surfdoc-hero\" style=\"background-image:url('\u{ab}IMG: hero skateboard\u{bb}')\">",
);
assert!(out.contains(&format!("background-image:url('{IMG_SLOT_PLACEHOLDER_URI}')")));
assert!(!out.contains('\u{ab}'));
assert!(!IMG_SLOT_PLACEHOLDER_URI.contains('\''));
assert!(!IMG_SLOT_PLACEHOLDER_URI.contains('"'));
assert!(!IMG_SLOT_PLACEHOLDER_URI.contains('&'));
assert!(!IMG_SLOT_PLACEHOLDER_URI.contains('<'));
}
#[test]
fn img_script_context_emits_text_only() {
let out = resolve(
"<script type=\"application/ld+json\">{\"image\":\"\u{ab}IMG: shop photo\u{bb}\"}</script>",
);
assert!(out.contains("{\"image\":\"shop photo\"}"));
assert!(!out.contains("surfdoc-img-slot"), "no tag inside script");
let css = resolve("<style>.x{/*\u{ab}IMG: swatch\u{bb}*/}</style>");
assert!(css.contains("/*swatch*/"));
assert!(!css.contains("<img"));
}
#[test]
fn unterminated_or_malformed_markers_verbatim() {
assert_eq!(resolve("<p>\u{ab}FILL: oops</p>"), "<p>\u{ab}FILL: oops</p>");
assert_eq!(resolve("<p>\u{bb} alone</p>"), "<p>\u{bb} alone</p>");
assert_eq!(resolve("<p>\u{ab}\u{ab}</p>"), "<p>\u{ab}\u{ab}</p>");
assert_eq!(resolve("<p>\u{ab}NOTE: hi\u{bb}</p>"), "<p>\u{ab}NOTE: hi\u{bb}</p>");
assert_eq!(resolve("tail \u{ab}IMG: x"), "tail \u{ab}IMG: x");
assert_eq!(resolve("tail \u{ab}"), "tail \u{ab}");
assert_eq!(
resolve("<p>\u{ab} and \u{ab}FILL: a | b\u{bb}</p>"),
"<p>\u{ab} and b</p>"
);
}
#[test]
fn multiple_markers_and_fast_path() {
let out = resolve("<p>\u{ab}FILL: a | A\u{bb} — \u{ab}FILL: b | B\u{bb}</p>");
assert_eq!(out, "<p>A — B</p>");
let plain = "<p>no markers, just prose & entities</p>".to_string();
assert_eq!(resolve_slot_markers(plain.clone()), plain);
assert_eq!(resolve("<p>\u{ab}FILL: a | B | C\u{bb}</p>"), "<p>B</p>");
}
#[test]
fn fill_emdash_separator_falls_back_to_default() {
assert_eq!(
resolve("<p>\u{ab}FILL: brand tag \u{2014} Skate or Die\u{bb}</p>"),
"<p>Skate or Die</p>"
);
assert_eq!(
resolve("<p>\u{ab}FILL: tagline \u{2014} Fast \u{2014} and loud\u{bb}</p>"),
"<p>Fast \u{2014} and loud</p>"
);
assert_eq!(
resolve("<p>\u{ab}FILL: tagline | Ride hard \u{2014} rest later\u{bb}</p>"),
"<p>Ride hard \u{2014} rest later</p>"
);
assert_eq!(
resolve("<p>\u{ab}FILL: phone \u{2014} \u{bb}</p>"),
"<p>phone</p>"
);
assert_eq!(
resolve("<p>\u{ab}FILL: well\u{2014}known\u{bb}</p>"),
"<p>well\u{2014}known</p>"
);
}
}