1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum Ctx {
40 Text,
42 Tag,
44 Raw,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum RawKind {
51 None,
52 Script,
53 Style,
54}
55
56pub 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; let mut i = 0usize;
68 let mut ctx = Ctx::Text;
69 let mut pending = RawKind::None;
71 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 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 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 None => i += 2,
123 }
124 }
125 _ => i += 1,
126 }
127 }
128 out.push_str(&html[seg..]);
129 out
130}
131
132fn tag_open(rest: &str, name: &str) -> bool {
137 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
150fn parse_marker(html: &str, start: usize, ctx: Ctx) -> Option<(usize, String)> {
154 let after = &html[start + 2..]; 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 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 let end = start + 2 + (after.len() - body.len()) + close + 2;
182
183 let replacement = if is_fill {
184 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 Ctx::Tag => IMG_SLOT_PLACEHOLDER_URI.to_string(),
212 Ctx::Raw => desc.to_string(),
214 Ctx::Text => {
215 let alt = desc.replace('"', """);
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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 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 #[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 assert_eq!(resolve("tail \u{ab}IMG: x"), "tail \u{ab}IMG: x");
325 assert_eq!(resolve("tail \u{ab}"), "tail \u{ab}");
326 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 #[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 & entities</p>".to_string();
340 assert_eq!(resolve_slot_markers(plain.clone()), plain);
341 assert_eq!(resolve("<p>\u{ab}FILL: a | B | C\u{bb}</p>"), "<p>B</p>");
343 }
344
345 #[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 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 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 assert_eq!(
365 resolve("<p>\u{ab}FILL: phone \u{2014} \u{bb}</p>"),
366 "<p>phone</p>"
367 );
368 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}