Skip to main content

moss_core/render/
image.rs

1//! Image HTML synthesizer — the single entry point for emitting `<img>` /
2//! `<picture>` markup in moss output.
3//!
4//! See [`docs/architecture/structural-html-emission.md`](../../../../docs/architecture/structural-html-emission.md)
5//! for the architectural principle: structural HTML decisions are made at the
6//! typed-data layer (pulldown-cmark events, shortcode AST, typed component
7//! props), with all three call sites converging on the function in this
8//! module. Regex post-passes are reserved for non-markdown-origin attribute
9//! injection only.
10//!
11//! # Migration state (post-Step-7, 2026-05-16)
12//!
13//! Steps 1-7 of the structural-html-emission migration are complete:
14//! - Step 1: extracted `synthesize_image_html`
15//! - Step 2: routed markdown `Tag::Image` events
16//! - Step 3: routed `:::hero` shortcode image
17//! - Step 4: routed link-preview favicon
18//! - Step 6: routed every cover image path (folder cards, child summary
19//!   cards, folder index hero, photo/video gallery thumbnails) through
20//!   `render_cover_html`
21//! - Step 7: retired `wrap_img_in_picture` (the structural part of the
22//!   legacy regex post-pass). The synthesizer now owns every `<picture>`
23//!   wrap in moss output. `add_image_placeholder_attributes` survives as
24//!   the attribute-injection seam for the documented carve-outs (see
25//!   below).
26//!
27//! The byte-shape contract is captured by snapshot tests at the bottom of
28//! this file. They are the line of defense against accidental output
29//! drift; any future change to attribute order, quoting, or whitespace
30//! must update them deliberately.
31//!
32//! Later steps will:
33//! - Step 8: switch `MarkdownStandalone` to a `<figure class="moss-image">`
34//!   wrapper (breaking change for user themes — staged separately)
35//! - Add AVIF `<source>` lines once the image pipeline produces AVIF
36//! - Drop the inline LQIP `style=` in favor of a wrapper CSS custom prop
37//!
38//! # Step-8 contract: synthesizer owns the outer `<figure>` (planned)
39//!
40//! `transform_events` currently wraps the synthesizer's `MarkdownStandalone`
41//! output in its own `<figure>` for the three caption-pattern branches
42//! (image+emphasis, separate-emphasis, implicit-figure). After Step 8 the
43//! synthesizer emits `<figure class="moss-image">` itself; if `transform_events`
44//! still wraps, the output will be `<figure><figure class="moss-image">...</figure>
45//! <figcaption>...</figcaption></figure>` — invalid double-wrap.
46//!
47//! The Step-8 contract: caption flows into the synthesizer via
48//! `MarkdownStandalone { caption: Option<&str> }` (or a richer
49//! `CaptionMarkdown` for emphasis-in-caption support), and the three
50//! caption-pattern branches collapse into a single `Event::Html(
51//! synthesize_image_html(..., MarkdownStandalone { caption }))` emission.
52//! The `<figcaption>` becomes the synthesizer's responsibility, NOT
53//! `transform_events`. Captures the spec at
54//! `docs/architecture/structural-html-emission.md#output-shape`.
55//!
56//! # Carve-outs: bare `<img>` emitters not routed through the synthesizer
57//!
58//! Four emission paths land bare `<img>` HTML in the output stream that
59//! does NOT flow through `synthesize_image_html`. They rely on the regex
60//! post-pass (`build/media/placeholder.rs::add_image_placeholder_attributes`)
61//! for attribute injection (dims/loading/decoding/LQIP). None of them
62//! are bugs — each has a documented architectural reason to stay outside
63//! the synthesizer:
64//!
65//! - **Site logo** (`build/components/nav.rs::render_logo`) — themed UI
66//!   affordance, not content. The logo has its own CSS sizing
67//!   (`.site-logo { height: 1.8em }`) and does not participate in
68//!   LQIP/dims/WebP-variant rendering.
69//! - **RSS read-tracking pixel** (`build/feeds/rss.rs`) — 1×1 `<img>` not
70//!   rendered visibly; the synthesizer's dims fallback (800×600) and LQIP
71//!   would be wrong for this case.
72//! - **Email body images** (`infra/newsletter.rs`) — email clients (Gmail,
73//!   Outlook, Apple Mail) do not consistently support `<picture>` or
74//!   `data-placeholder-src`-driven hydration. Keep flat for cross-client
75//!   degradation.
76//! - **Raw HTML `<img>` in markdown source** — author-written
77//!   `<img src="...">` literally embedded in `.md` files. pulldown-cmark
78//!   emits these as `Event::Html` opaque pass-through, so they never reach
79//!   `Tag::Image` and are not a moss-controlled emitter. Treated as user
80//!   input, the markdown HTML is opaque to the synthesizer and gets only
81//!   the additive attribute injection pass.
82//!
83//! Photography/video gallery thumbnails — previously a carve-out — were
84//! folded into the synthesizer in Step 7's commit. The **review colophon
85//! cover** (`build/features/review.rs::render_colophon`) — also previously
86//! a carve-out — was folded in 2026-05-16. Both use
87//! `ImageContext::FolderCardCover` (container-bounded thumbnail semantics).
88//!
89//! These four remaining carve-outs are flagged here so future maintenance
90//! does not drop their attribute injection. Step 7 retired the structural
91//! part of the regex (`wrap_img_in_picture`); the surviving
92//! `add_image_placeholder_attributes` provides additive attrs only for
93//! these bare-img paths.
94
95use crate::asset_paths::to_webp;
96use crate::asset_snapshot::{AssetSnapshot, FALLBACK_HEIGHT, FALLBACK_WIDTH};
97// Same XML-safe escaping used everywhere else in moss for attribute values.
98use crate::media::html_escape;
99use std::collections::BTreeMap;
100use std::path::PathBuf;
101
102/// Where the image lives in the document, which determines the wrapper
103/// element and attribute set.
104///
105/// Step-1 implementation supports only `MarkdownInline` (the default emission
106/// shape from pulldown-cmark's serializer plus the legacy regex pair's added
107/// attributes). Other variants are scaffolded so the call sites in Steps 3-6
108/// can pass them without breaking the byte-shape contract; the synthesizer
109/// produces the same output for all variants until the wrapper-change step.
110///
111/// Step 8 (2026-05-17) made these contexts diverge structurally:
112/// - `MarkdownStandalone { caption }` → `<figure class="moss-image">…[<figcaption>]…</figure>` wrapper
113/// - `MarkdownInline` → bare `<img>` (or `<picture><img></picture>`)
114/// - `Hero` → bare `<img>` (the hero shortcode wraps with `<header>`)
115/// - `FolderCardCover` → bare `<img>` (`.moss-card-cover > ` wraps)
116/// - `LinkPreview` → bare `<img>` (link-preview anchor wraps)
117/// - `Favicon` → bare 16×16 `<img>` with no `<picture>`, no LQIP
118///
119/// Not `Copy` (the embedded `&str` caption would force a lifetime on
120/// every consumer); cloning is cheap (borrow) and the call sites pass by
121/// value through the synthesizer.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum ImageContext<'a> {
124    /// Image-only paragraph in markdown — emits `<figure class="moss-image">`
125    /// around the synthesized `<picture>`/`<img>` output. Used by
126    /// `transform_events`'s three caption-pattern branches:
127    ///
128    /// - **image+emphasis**: `![alt](src) *caption*` — `caption = Some(emphasis_text)`
129    /// - **separate-emphasis**: image-only paragraph followed by emphasis-
130    ///   only paragraph — `caption = Some(emphasis_text)`
131    /// - **implicit figure**: image-only paragraph with non-empty alt and
132    ///   `[site].implicit_figure = true` — `caption = Some(alt_text)`
133    ///
134    /// `caption = None` means "figure wrap but no `<figcaption>`" (reserved
135    /// for future callers that want the wrapper structurally without prose).
136    ///
137    /// `width = Some("body|wide|page|screen")` emits `data-width="..."` on
138    /// the outer `<figure>` element per spec § P9. `None` omits the
139    /// attribute entirely so themes can target the absence via
140    /// `:not([data-width])`. `full` aliases to `screen` upstream — values
141    /// reaching this struct are already in canonical value-space.
142    MarkdownStandalone {
143        caption: Option<&'a str>,
144        width: Option<&'a str>,
145        /// Editorial runaround alignment, surfaced as a CSS class on the
146        /// outer `<figure>`. Phase 1 C1 (2026-05-25): Stage 2 dispatcher
147        /// passes `Some("moss-align-left")` / `Some("moss-align-right")`
148        /// when the markdown image's title carries `moss:align=left|right`.
149        /// `None` omits the class.
150        align: Option<&'a str>,
151        /// Arbitrary CSS class names from `moss:classes="foo bar"` title
152        /// params (Phase 1 C1). Each entry is appended to the figure's
153        /// `class="moss-image …"` attribute, space-separated. Empty slice
154        /// leaves the class list at its default (`moss-image`).
155        class_names: &'a [String],
156        /// Arbitrary `key="value"` HTML attributes from leftover
157        /// `moss:` title params (Phase 1 C1). The dispatcher passes
158        /// every param that isn't a known field (kind/width/align/classes)
159        /// through here so future params propagate without code changes.
160        /// Keys are emitted in BTreeMap order for stable byte shape.
161        /// Empty map emits no extra attributes.
162        extra_attrs: &'a BTreeMap<String, String>,
163    },
164    /// Image inside prose, a list, a table cell, or a callout body.
165    /// Always emits bare `<img>` or `<picture><img></picture>`.
166    MarkdownInline,
167    /// `:::hero` shortcode body image. The hero wrapper handles layout.
168    Hero,
169    /// Folder-card cover or child-summary cover image.
170    FolderCardCover,
171    /// External-link preview thumbnail image.
172    LinkPreview,
173    /// Favicon for a link-preview card. Bare 16×16 `<img>`, no `<picture>`,
174    /// no LQIP, no responsive variants.
175    Favicon,
176
177    /// Phase 2 scaffold (filled by Phase 2B agent): site-header / nav logo.
178    /// Bare `<img>` with `class="site-logo"`, no LQIP, no `<picture>`, no
179    /// `loading="lazy"` (logo is above-the-fold). CSS handles sizing
180    /// (`.site-logo { height: 1.8em }`).
181    SiteLogo,
182    /// Phase 2 scaffold (filled by Phase 2C agent): RSS read-tracking pixel.
183    /// 1×1 invisible `<img>`. No `loading="lazy"` (the pixel must fire on
184    /// read for tracking). No LQIP, no `<picture>`.
185    TrackingPixel,
186    /// Phase 2 scaffold (filled by Phase 2D agent): newsletter email body
187    /// image. Email-client-safe HTML subset: no `<picture>`, no `data-*`,
188    /// inline `style="display:block;max-width:100%;height:auto"` for
189    /// responsive email layouts. Explicit dims when known (callers pass
190    /// `Option<u32>` for width/height; missing → omit per email-client
191    /// tolerance).
192    EmailBody {
193        width: Option<u32>,
194        height: Option<u32>,
195    },
196
197    /// Phase 2E v5 PR3 (2026-05-26): `:::gallery` body image. Below-the-
198    /// fold thumbnail (`loading="lazy"`); same `<picture>`/dims/LQIP byte
199    /// shape as `MarkdownInline`. The outer `.moss-gallery-item` wrapper
200    /// is owned by the gallery shortcode's `DefaultHooks` impl in
201    /// `crates/moss-core/src/ast/hooks.rs`; this variant emits just the
202    /// inner `<picture><img></picture>` / `<img>` shape so the wrapper
203    /// can sit around it.
204    ///
205    /// Distinct from `MarkdownInline` so the gallery's per-item style
206    /// passthrough (object-position from `MediaAttrs`) has a typed home
207    /// to evolve into; today both contexts produce the same inner byte
208    /// shape via `synthesize_inner`.
209    GalleryThumb,
210
211    /// Phase 2E PR2 (2026-05-26): bare `<img>` emission with no `<picture>`
212    /// wrap, no `<source>`, no LQIP, no width/height, no `loading` attr.
213    /// Used by the hero typed-renderer fallback path
214    /// (`typed_renderers.rs::render_hero_html_typed`) when no manifest
215    /// (`MediaDimensionLookup`) is in scope — i.e. test / fragment-render
216    /// paths where `AssetRegistry::set_pending` has NOT been called for
217    /// the source's `.webp` companion.
218    ///
219    /// The asset-publish invariant (see `.claude/CLAUDE.md` § "Asset
220    /// publish invariant") requires us to NEVER emit a
221    /// `<source srcset="*.webp">` for an unregistered variant — the
222    /// preview server cannot return placeholder bytes for an URL that
223    /// AssetRegistry doesn't know about, and `<picture>` does not recover
224    /// from a chosen-source 404. `HeroBare` is the explicit opt-out for
225    /// code paths that run before set_pending.
226    ///
227    /// The byte shape mirrors the pre-Phase-2E fallback exactly:
228    /// `<img src="X" alt="Y"[ STYLE] />` where `STYLE` is the inline
229    /// style fragment (already escaped) threaded through
230    /// [`ImageRenderOptions::extra_attrs`] by the caller. The `class` and
231    /// `eager` options are ignored — the hero `<header>` wraps and CSS
232    /// handles loading priority.
233    HeroBare,
234}
235
236/// Optional rendering attributes a caller may pass.
237///
238/// The defaults preserve the current regex-pass byte shape:
239/// - `loading="lazy"` unless `eager=true` (which switches to `loading="eager"
240///   fetchpriority="high"`)
241/// - No extra inline style
242/// - No extra CSS classes on the inner `<img>`
243#[derive(Debug, Default, Clone)]
244pub struct ImageRenderOptions<'a> {
245    /// Above-the-fold loading hint. When true, emits
246    /// `loading="eager" fetchpriority="high"` instead of `loading="lazy"`.
247    pub eager: bool,
248    /// CSS classes to add to the inner `<img>`. Used by link-preview
249    /// favicons (`link-preview-favicon`) and similar UI affordances.
250    pub class: Option<&'a str>,
251    /// Raw extra HTML attribute fragment appended after the standard
252    /// attribute block (e.g., `style="object-fit:cover;object-position:50% 50%"`
253    /// for `:::hero` covers carrying `MediaAttrs`). The caller is responsible
254    /// for HTML-escaping values inside this fragment.
255    pub extra_attrs: Option<&'a str>,
256}
257
258/// Synthesize the HTML for an image reference.
259///
260/// `src` is the resolved URL as it should appear in `<img src=>` (already
261/// passed through the link resolver and dir_overrides for CJK sites).
262///
263/// `alt` is the accessible name. Empty string is permitted for decorative
264/// images; callers in WCAG-sensitive contexts should pass meaningful text.
265///
266/// `assets` is the [`AssetSnapshot`] holding pre-fetched per-path dimensions,
267/// LQIP data URIs, dominant colors, and registered variant kinds (WebP/AVIF).
268/// Phase 1 of the unified-image-emission migration (2026-05-25) replaced the
269/// prior `Option<&MediaDimensionLookup>` parameter with this typed contract —
270/// `MediaDimensionLookup` still populates the snapshot in `pipeline.rs`'s
271/// `build_asset_snapshot` boundary, but the synthesizer no longer probes it
272/// directly. Callers that don't have a populated snapshot (test/fragment-
273/// render paths) pass `&AssetSnapshot::new()`; the synthesizer then emits
274/// fallback dims (800×600) and no LQIP/color style.
275///
276/// `context` and `options` describe the call site. `Favicon` short-circuits
277/// to a 16×16 bare `<img>` (no manifest, no LQIP, no `<picture>`).
278///
279/// Byte-shape contract (preserved through Phase 1's data-source switch):
280///
281/// - With no `<picture>` wrap (non-raster):
282///   `<img src="X" width="W" height="H" loading="lazy" style="…" alt="Y" />`
283/// - Raster originals (png/jpg/jpeg):
284///   `<picture><source srcset="X.webp" type="image/webp"><img src="X" width="W" height="H" loading="lazy" style="…" alt="Y" /></picture>`
285/// - Inline style is `background-image:url(LQIP);background-size:cover` when
286///   the snapshot has LQIP; `background-color:#RRGGBB` when only dominant
287///   color is available; absent when neither.
288/// - For `eager: true`: `loading="eager" fetchpriority="high"` replaces
289///   `loading="lazy"`.
290pub fn synthesize_image_html(
291    src: &str,
292    alt: &str,
293    assets: &AssetSnapshot,
294    context: ImageContext<'_>,
295    options: &ImageRenderOptions<'_>,
296) -> String {
297    // Favicon short-circuit: hardcoded 16×16, no snapshot lookup, no <picture>.
298    // Matches the current emission shape in
299    // `build/markdown/typed_renderers.rs::render_link_preview`. `assets` is
300    // intentionally unused — favicons are UI affordances that never
301    // participate in the variant manifest.
302    if matches!(context, ImageContext::Favicon) {
303        let class_attr = options
304            .class
305            .map(|c| format!(r#" class="{}""#, html_escape(c)))
306            .unwrap_or_default();
307        return format!(
308            r#"<img{} src="{}" width="16" height="16" alt="{}">"#,
309            class_attr,
310            html_escape(src),
311            html_escape(alt),
312        );
313    }
314
315    // Phase 2 scaffold (filled by Phase 2 carve-out agents): the three former
316    // bare-<img> carve-outs become first-class synthesizer contexts. Each
317    // short-circuits before synthesize_inner (which assumes the standard
318    // <picture>/LQIP/dims pipeline that's wrong for these elements).
319    // Site-logo short-circuit (Phase 2B carve-out): bare `<img>` with
320    // `class="site-logo"`, no `<picture>`, no LQIP, no `loading="lazy"` —
321    // the logo is above-the-fold; CSS handles sizing
322    // (`.site-logo { height: 1.8em }`). Attribute order
323    // (`class`, `src`, `alt`, `aria-hidden`) preserves the pre-Phase-2
324    // byte shape emitted by `nav.rs::generate_navigation`.
325    if matches!(context, ImageContext::SiteLogo) {
326        return format!(
327            r#"<img class="site-logo" src="{}" alt="{}" aria-hidden="true">"#,
328            html_escape(src),
329            html_escape(alt),
330        );
331    }
332    if matches!(context, ImageContext::TrackingPixel) {
333        // Phase 2C: RSS read-tracking pixel. 1×1 invisible <img>.
334        // NO loading="lazy" — the pixel must fire on read for tracking.
335        // NO LQIP, NO <picture>, NO alt text (empty alt for invisible
336        // decoration). Self-closing form because this commonly lands in
337        // RSS feed XML (CDATA-wrapped <description>).
338        return format!(
339            r#"<img src="{}" alt="" width="1" height="1" />"#,
340            html_escape(src),
341        );
342    }
343    if matches!(context, ImageContext::HeroBare) {
344        // Phase 2E PR2 (2026-05-26): no-snapshot hero fallback. Byte
345        // shape matches the pre-PR2 emission at
346        // `typed_renderers.rs::render_hero_html_typed` lines 554-557:
347        // `<img src="X" alt="Y"[ STYLE] />`. `assets` is intentionally
348        // unused (the caller passes an empty snapshot via the no-
349        // manifest branch); the snapshot is part of the signature only
350        // for symmetry with the other contexts. `options.class` and
351        // `options.eager` are ignored — hero chrome (CSS / `<header>`)
352        // handles styling and loading priority.
353        //
354        // The inline `style=` fragment that the legacy fallback baked
355        // directly into `format!` is now threaded through
356        // `options.extra_attrs` (the caller pre-escapes the value and
357        // omits the leading space, matching the existing extra_attrs
358        // contract — the synthesizer prepends a single space).
359        let extra = options
360            .extra_attrs
361            .map(|s| format!(" {}", s))
362            .unwrap_or_default();
363        return format!(
364            r#"<img src="{}" alt="{}"{} />"#,
365            html_escape(src),
366            html_escape(alt),
367            extra,
368        );
369    }
370    if let ImageContext::EmailBody { width, height } = context {
371        // Phase 2D (2026-05-25): email-client-safe <img> for newsletter body
372        // images. Email clients do NOT support <picture>, do NOT support
373        // data-* attributes, and frequently strip <style> blocks — inline
374        // `style=` on <img> plus explicit width/height attrs is the
375        // cross-client minimum for responsive layouts. width/height are
376        // Option<u32>: omitted when unknown (e.g. remote URLs that aren't in
377        // AssetSnapshot). Email clients tolerate missing dims at the cost of
378        // a small layout shift.
379        let mut dims = String::new();
380        if let Some(w) = width {
381            dims.push_str(&format!(r#" width="{}""#, w));
382        }
383        if let Some(h) = height {
384            dims.push_str(&format!(r#" height="{}""#, h));
385        }
386        return format!(
387            r#"<img src="{}" alt="{}"{} style="display:block;max-width:100%;height:auto;" />"#,
388            html_escape(src),
389            html_escape(alt),
390            dims,
391        );
392    }
393
394    // Step 8: the inner `<img>` / `<picture>` is shape-equivalent across
395    // every non-favicon context. The wrapping `<figure class="moss-image">`
396    // and optional `<figcaption>` are the only context-dependent
397    // structure. Compute the inner first, then wrap if requested.
398    let inner = synthesize_inner(src, alt, assets, options);
399
400    match context {
401        ImageContext::MarkdownStandalone {
402            caption,
403            width,
404            align,
405            class_names,
406            extra_attrs,
407        } => wrap_in_figure_full(&inner, caption, width, align, class_names, extra_attrs),
408        // All other variants are "no outer wrapper" — caller-owned chrome
409        // (hero `<header>`, folder card container, link preview anchor)
410        // surrounds the bare img/picture output.
411        _ => inner,
412    }
413}
414
415/// Wrap an already-shaped image fragment in the standalone-image
416/// figure container.
417///
418/// `inner_html` is either:
419/// - The output of `synthesize_inner` (markdown `Tag::Image` flowing
420///   through `synthesize_image_html`), or
421/// - A resolve-phase `<img …>` / `<video …>` HTML string emitted by
422///   moss-core's wikilink-lowering (`![[file|display-params]]`).
423///
424/// Output shape:
425///
426/// - `caption = Some(text)`, `width = None`:
427///   ```html
428///   <figure class="moss-image"><picture>…<img …></picture>
429///   <figcaption>text</figcaption></figure>
430///   ```
431/// - `caption = None`, `width = Some("screen")`:
432///   ```html
433///   <figure class="moss-image" data-width="screen"><picture>…<img …></picture></figure>
434///   ```
435/// - `width = None` omits the attribute entirely so themes can target
436///   the absence via `:not([data-width])`. Per spec § P9, `data-width`
437///   sits on the wrapper element (here, `<figure>`) rather than the
438///   inner `<img>`.
439///
440/// Caption text is HTML-escaped at the boundary. Future work: allow
441/// markdown formatting inside the caption via an explicit
442/// `CaptionMarkdown` variant on `ImageContext`.
443///
444/// `pub(super)` so `build/markdown/pipeline.rs` can call this directly
445/// for the raw-HTML media branch of `emit_standalone_figure_image`
446/// without duplicating the wrapper byte shape. The synthesizer is the
447/// single source of truth for `<figure class="moss-image">` — when the
448/// wrapper class evolves (e.g. `moss-image moss-image--auto` per the
449/// Step-8 spec), only this function changes.
450pub fn wrap_in_figure(
451    inner_html: &str,
452    caption: Option<&str>,
453    width: Option<&str>,
454) -> String {
455    // 3-arg shorthand kept for the raw-HTML media branch in
456    // pipeline.rs::emit_standalone_figure_image (wikilink display-keyword
457    // images that don't carry moss: title params — no align / extra
458    // classes / extra attrs). Delegates to the canonical wrapper so the
459    // byte shape stays defined in exactly one place.
460    let empty_classes: &[String] = &[];
461    let empty_attrs: BTreeMap<String, String> = BTreeMap::new();
462    wrap_in_figure_full(inner_html, caption, width, None, empty_classes, &empty_attrs)
463}
464
465/// Canonical `<figure>`-wrapping function consumed by both `synthesize_image_html`
466/// for `MarkdownStandalone` and the 3-arg compatibility shim `wrap_in_figure`.
467///
468/// Class list assembly: `class="moss-image{ align_class?}{ class_names…}"`.
469/// Extra attrs render as `key="escaped_value"` in BTreeMap order, after
470/// `data-width=` and before the inner content.
471pub(super) fn wrap_in_figure_full(
472    inner_html: &str,
473    caption: Option<&str>,
474    width: Option<&str>,
475    align: Option<&str>,
476    class_names: &[String],
477    extra_attrs: &BTreeMap<String, String>,
478) -> String {
479    // `width` here is a closed-set &'static str from `match_width_token`
480    // ("body" | "wide" | "page" | "screen"). The `html_escape` call is
481    // defensive belt-and-braces — it never actually substitutes — and is
482    // kept for symmetry with the embed-renderer side's `html_escape_attr`.
483    let width_attr = width
484        .map(|w| format!(r#" data-width="{}""#, html_escape(w)))
485        .unwrap_or_default();
486
487    // Compose the class attribute: `moss-image` first (the structural
488    // hook), then the optional align class, then any author-supplied
489    // class names. Single space separator keeps the byte shape stable
490    // across the empty / align-only / class-only / both permutations.
491    let mut class_value = String::from("moss-image");
492    if let Some(a) = align {
493        class_value.push(' ');
494        class_value.push_str(a);
495    }
496    for cn in class_names {
497        if cn.is_empty() {
498            continue;
499        }
500        class_value.push(' ');
501        class_value.push_str(cn);
502    }
503    let class_attr = format!(r#" class="{}""#, html_escape(&class_value));
504
505    // Extra attrs are emitted in BTreeMap order (deterministic byte shape).
506    let mut extra = String::new();
507    for (k, v) in extra_attrs {
508        extra.push(' ');
509        extra.push_str(k);
510        extra.push_str(r#"=""#);
511        extra.push_str(&html_escape(v));
512        extra.push('"');
513    }
514
515    match caption {
516        Some(text) => format!(
517            r#"<figure{class}{w}{extra}>{inner}<figcaption>{cap}</figcaption></figure>"#,
518            class = class_attr,
519            w = width_attr,
520            extra = extra,
521            inner = inner_html,
522            cap = html_escape(text),
523        ),
524        None => format!(
525            r#"<figure{class}{w}{extra}>{inner}</figure>"#,
526            class = class_attr,
527            w = width_attr,
528            extra = extra,
529            inner = inner_html,
530        ),
531    }
532}
533
534/// Synthesize the inner `<img>` (or `<picture><img></picture>`) without
535/// the standalone-figure wrapper. Shared by every non-favicon context.
536fn synthesize_inner(
537    src: &str,
538    alt: &str,
539    assets: &AssetSnapshot,
540    options: &ImageRenderOptions<'_>,
541) -> String {
542    let img_tag = render_img_tag(src, alt, assets, options);
543
544    // For raster originals, always emit <picture><source srcset=X.webp>.
545    // This markup is MODE-INDEPENDENT — the on-disk HTML is identical in
546    // preview and publish. The webp is encoded in the BACKGROUND for ALL modes
547    // (blocking.rs registers the variant Pending; a BackgroundHandle worker
548    // runs the encode). Two mechanisms keep the webp URL live without a 404:
549    //   • Preview: the server serves the FULL ORIGINAL source bytes (source
550    //     passthrough, preview/server/router.rs) for the not-yet-encoded
551    //     variant URL, so the first paint is sharp; a Failed encode instead
552    //     surfaces a warning SVG (preview/server/placeholder.rs).
553    //   • Publish: the seal/persist task AWAITS the background drain barrier
554    //     before sealing, so the sealed/deployed generation always contains the
555    //     encoded .webp on disk (ADR-013 by construction).
556    // So the URL is always live in both modes.
557    //
558    // We must never emit a <source> that might 404 because a chosen <source>
559    // 404 is non-recoverable inside <picture> per HTML spec §
560    // "update-the-source-set" + § "update-the-image-data": browser commits to
561    // the chosen URL, fetch fails, image state goes to broken, error fires —
562    // browser does NOT walk back to the inner <img>.
563    //
564    // For non-raster sources (svg, favicons via Favicon context), no variant
565    // exists; emit the bare <img>.
566    //
567    // Pattern: explicit promise model. See
568    // docs/plans/2026-05-20-image-variant-honest-mirror.md (Layer 3).
569    if is_raster_original(src) {
570        // to_webp(src) inherits the dir_overrides + relative-prefix already
571        // applied to `src` by the upstream renderer. Swapping the extension
572        // on `src` is what keeps the synthesizer's emitted URL aligned with
573        // the AssetRegistry's registered key (blocking.rs's set_pending loop
574        // uses the same to_webp(mapped) derivation).
575        let srcset_path = to_webp(src);
576        format!(
577            r#"<picture><source srcset="{}" type="image/webp">{}</picture>"#,
578            html_escape(&srcset_path),
579            img_tag,
580        )
581    } else {
582        img_tag
583    }
584}
585
586/// Returns true when `src` is a raster original that always gets a webp
587/// variant from moss's image pipeline. Restricted to the three extensions
588/// (png, jpg, jpeg) that the synthesizer emits `<picture><source srcset>` for.
589/// Gif is excluded because animated GIFs skip webp encoding (image.rs §
590/// should_skip, `is_animated_gif`); SVG is excluded as a vector format; webp
591/// originals are excluded because the variant URL would equal the src URL.
592///
593/// Note: this check is extension-only. `collect_images_for_conversion` applies
594/// additional content-based filters (e.g. `SkipReason::NotAnImage` for files
595/// whose magic bytes don't match the declared format). A file that passes
596/// `is_raster_original` here but is filtered by `NotAnImage` will NOT have
597/// `set_pending` called for it — the synthesizer will emit a `<picture>` but
598/// the registry will serve the LQIP placeholder until the build completes
599/// without a webp. In practice this only occurs for genuinely corrupt files
600/// (e.g. an HTML 404 page saved as .png) that are not referenced from content.
601fn is_raster_original(src: &str) -> bool {
602    let lower = src.to_ascii_lowercase();
603    lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg")
604}
605
606/// Try several path normalizations against `AssetSnapshot.dimensions` so the
607/// synthesizer matches the same set of input forms the prior
608/// `MediaDimensionLookup::get` handled. `src` may arrive as the resolved URL
609/// with a leading `/` (review colophon covers, cover.rs absolute paths) or
610/// with a `./` / `../` relative prefix (CJK dir_overrides); scan stores keys
611/// in plain relative form. The probe order mirrors the lookup's:
612///
613/// 1. exact match
614/// 2. leading-`/` stripped (absolute-to-relative)
615/// 3. leading `./` / `../` stripped (relative normalization)
616///
617/// Returns `None` when none of the variants is in the snapshot. The caller
618/// supplies the fallback (800×600 for dims, no style for LQIP / color).
619fn lookup_dims(assets: &AssetSnapshot, src: &str) -> Option<(u32, u32)> {
620    probe_paths(src, |p| assets.dims(&p))
621}
622
623fn lookup_lqip<'a>(assets: &'a AssetSnapshot, src: &str) -> Option<&'a str> {
624    probe_paths(src, |p| assets.lqip(&p))
625}
626
627fn lookup_color<'a>(assets: &'a AssetSnapshot, src: &str) -> Option<&'a String> {
628    probe_paths(src, |p| assets.dominant_color.get(&p))
629}
630
631fn probe_paths<T>(src: &str, mut probe: impl FnMut(PathBuf) -> Option<T>) -> Option<T> {
632    if let Some(v) = probe_normalized(src, &mut probe) {
633        return Some(v);
634    }
635    // BUG 6.2 (belt-and-suspenders): body/wikilink images arrive percent-encoded
636    // (`Europe%20-%20A%20Prophecy`), but snapshot keys are the RAW source path.
637    // Decode `%XX` and re-probe so the encoded URL reverses to the source key.
638    // Pure + zero-I/O; on invalid/lone `%` `percent_decode` returns the input
639    // unchanged, so we only re-probe when decoding actually changed something.
640    let decoded = percent_decode(src);
641    if decoded != src {
642        if let Some(v) = probe_normalized(&decoded, &mut probe) {
643            return Some(v);
644        }
645    }
646    None
647}
648
649/// Probe `src` plus its leading-`/` and leading-`./`/`../`-stripped forms.
650fn probe_normalized<T>(src: &str, probe: &mut impl FnMut(PathBuf) -> Option<T>) -> Option<T> {
651    if let Some(v) = probe(PathBuf::from(src)) {
652        return Some(v);
653    }
654    let stripped = src.strip_prefix('/').unwrap_or(src);
655    if stripped != src {
656        if let Some(v) = probe(PathBuf::from(stripped)) {
657            return Some(v);
658        }
659    }
660    let mut s: &str = src;
661    while let Some(rest) = s.strip_prefix("./").or_else(|| s.strip_prefix("../")) {
662        s = rest;
663    }
664    if s != src {
665        if let Some(v) = probe(PathBuf::from(s)) {
666            return Some(v);
667        }
668    }
669    None
670}
671
672/// Percent-decode `%XX` byte sequences in a URL path (pure, zero-I/O).
673/// Mirrors `html_post::percent_decode_path` semantics: a lone/invalid `%` is
674/// passed through verbatim, and non-UTF-8 decode results fall back to the raw
675/// input. Returns the input unchanged when there is nothing to decode.
676fn percent_decode(path: &str) -> String {
677    if !path.contains('%') {
678        return path.to_string();
679    }
680    let bytes = path.as_bytes();
681    let mut out = Vec::with_capacity(bytes.len());
682    let mut i = 0;
683    while i < bytes.len() {
684        if bytes[i] == b'%' && i + 2 < bytes.len() {
685            if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
686                out.push((hi << 4) | lo);
687                i += 3;
688                continue;
689            }
690        }
691        out.push(bytes[i]);
692        i += 1;
693    }
694    String::from_utf8(out).unwrap_or_else(|_| path.to_string())
695}
696
697fn hex_val(b: u8) -> Option<u8> {
698    match b {
699        b'0'..=b'9' => Some(b - b'0'),
700        b'a'..=b'f' => Some(b - b'a' + 10),
701        b'A'..=b'F' => Some(b - b'A' + 10),
702        _ => None,
703    }
704}
705
706/// Emit just the `<img>` tag with all attributes. Internal helper for
707/// `synthesize_image_html` — exposed as `pub(crate)` only for snapshot tests
708/// that want to assert against the bare img output without the optional
709/// `<picture>` wrapper.
710pub(crate) fn render_img_tag(
711    src: &str,
712    alt: &str,
713    assets: &AssetSnapshot,
714    options: &ImageRenderOptions<'_>,
715) -> String {
716    // AssetSnapshot's `dims` is keyed by PathBuf; the src arrives as the
717    // resolved URL the upstream renderer baked (potentially absolute, e.g.
718    // `/image/cover.jpg`). Scan stores relative keys (`image/cover.jpg`),
719    // so the synthesizer probes both forms via `lookup_dims`. Snapshot
720    // lookups absent → fall back to the legacy 800×600 (matches the prior
721    // `MediaDimensionLookup::get` semantics before the Phase 1 B1 migration).
722    // Stem fallback (extension-mismatch — e.g. `.mov` vs `.mp4`) was
723    // previously handled in MediaDimensionLookup::get; since AssetSnapshot
724    // exposes only exact-path access, that case will need follow-up if
725    // production sites rely on it (likely only for video posters, which
726    // are out of this Tag::Image path).
727    let (width, height) = lookup_dims(assets, src).unwrap_or((FALLBACK_WIDTH, FALLBACK_HEIGHT));
728
729    let class_attr = options
730        .class
731        .map(|c| format!(r#" class="{}""#, html_escape(c)))
732        .unwrap_or_default();
733
734    let (loading_attr, fetchpriority_attr) = if options.eager {
735        (r#" loading="eager""#, r#" fetchpriority="high""#)
736    } else {
737        (r#" loading="lazy""#, "")
738    };
739
740    // Suppress LQIP / dominant-color style when extra_attrs already carries
741    // a style= attribute (e.g., `:::hero {attrs="cover-fit=contain"}` passes
742    // `style="object-fit:contain"` through extra_attrs). The browser would
743    // honor the LAST style= it sees and drop the LQIP, so emitting both
744    // produces malformed HTML and loses the placeholder. The legacy regex
745    // pass (`placeholder.rs:413-422`) had the same has_style guard; this
746    // preserves parity. Future work: merge the two declarations into a
747    // single style= via a typed `ImageRenderOptions::media_attrs` field so
748    // the synthesizer owns escaping end-to-end (impl-review item 9).
749    let extra_has_style = options
750        .extra_attrs
751        .map(|s| s.contains("style="))
752        .unwrap_or(false);
753
754    let style_attr = if extra_has_style {
755        String::new()
756    } else if let Some(lqip) = lookup_lqip(assets, src) {
757        format!(
758            r#" style="background-image:url({});background-size:cover""#,
759            lqip
760        )
761    } else if let Some(color) = lookup_color(assets, src) {
762        format!(r#" style="background-color:{}""#, color)
763    } else {
764        String::new()
765    };
766
767    let extra = options
768        .extra_attrs
769        .map(|s| format!(" {}", s))
770        .unwrap_or_default();
771
772    // `data-placeholder-src` removed 2026-05-20: the iframe-bridge handler
773    // now matches by URL substring against `src` / `srcset` (see
774    // frontend/bridge/iframe-bridge.ts, moss-asset-ready branch). The
775    // AssetRegistry's promise model + the preview server's URL-keyed lookup
776    // make the attribute redundant. See
777    // docs/plans/2026-05-20-image-variant-honest-mirror.md (Layer 3).
778    //
779    // Inline LQIP via `background-image: url(data:image/jpeg;base64,…)` is
780    // kept — legitimate production technique (cf. Vercel `blurDataURL`,
781    // nextjs.org/docs/app/api-reference/components/image). Shows a blurred
782    // preview instantly while the actual bytes are being decoded.
783    format!(
784        r#"<img{class_attr} src="{src_esc}" width="{w}" height="{h}"{loading}{fetch}{style} alt="{alt}"{extra} />"#,
785        class_attr = class_attr,
786        src_esc = html_escape(src),
787        w = width,
788        h = height,
789        loading = loading_attr,
790        fetch = fetchpriority_attr,
791        style = style_attr,
792        alt = html_escape(alt),
793        extra = extra,
794    )
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800    use crate::asset_snapshot::VariantKindSet;
801
802    /// Build an AssetSnapshot with a single entry — the per-test fixture
803    /// shape after the Phase 1 B1 migration (2026-05-25). Replaces the prior
804    /// `MediaDimensionLookup`-based `lookup(vec![img_meta(...)])` builder.
805    /// The snapshot keys are `PathBuf` per `AssetSnapshot`'s contract; the
806    /// stem-derived `variants` entry mirrors what
807    /// `AssetRegistry::iter_registered_variants` would populate when a WebP
808    /// variant is registered.
809    fn snapshot_with(
810        path: &str,
811        dims: Option<(u32, u32)>,
812        color: Option<&str>,
813        lqip: Option<&str>,
814        webp: bool,
815    ) -> AssetSnapshot {
816        let mut s = AssetSnapshot::new();
817        let key = PathBuf::from(path);
818        if let Some(d) = dims {
819            s.dimensions.insert(key.clone(), d);
820        }
821        if let Some(c) = color {
822            s.dominant_color.insert(key.clone(), c.to_string());
823        }
824        if let Some(l) = lqip {
825            s.lqip.insert(key.clone(), l.to_string());
826        }
827        if webp {
828            let stem = crate::asset_snapshot::path_strip_extension(&key);
829            s.variants.insert(
830                stem,
831                VariantKindSet {
832                    webp: true,
833                    avif: false,
834                },
835            );
836        }
837        s
838    }
839
840    fn snapshot_dims(path: &str, w: u32, h: u32) -> AssetSnapshot {
841        snapshot_with(path, Some((w, h)), None, None, false)
842    }
843
844    // --- BUG 6: output-URL-form lookups must hit real dims, not 800x600 ---
845
846    /// A body/wikilink image arrives percent-encoded (`Europe%20-%20A%20Prophecy`).
847    /// `probe_paths` must percent-decode and reverse `../` so it hits the RAW
848    /// source dims key, instead of missing → 800x600 fallback.
849    #[test]
850    fn probe_paths_percent_decodes_src() {
851        let snap = snapshot_dims("assets/Europe - A Prophecy/e-006.jpg", 4515, 6158);
852        assert_eq!(
853            lookup_dims(&snap, "../../assets/Europe%20-%20A%20Prophecy/e-006.jpg"),
854            Some((4515, 6158))
855        );
856    }
857
858    /// A cover arrives as a slugified output URL (`/assets/europe-a-prophecy/...`).
859    /// With the snapshot additively indexed under the slug key (Bug6.1), the
860    /// synthesized `<img>` must carry the real portrait dims, NOT the fallback.
861    #[test]
862    fn cover_slug_url_emits_real_dimensions_not_fallback() {
863        let mut snap = snapshot_dims("assets/Europe - A Prophecy/e-006.jpg", 4515, 6158);
864        snap.dimensions.insert(
865            PathBuf::from("assets/europe-a-prophecy/e-006.jpg"),
866            (4515, 6158),
867        );
868        let html = synthesize_image_html(
869            "/assets/europe-a-prophecy/e-006.jpg",
870            "cover",
871            &snap,
872            ImageContext::MarkdownInline,
873            &ImageRenderOptions::default(),
874        );
875        assert!(
876            html.contains(r#"width="4515" height="6158""#),
877            "expected real dims, got: {html}"
878        );
879        assert!(
880            !html.contains(r#"width="800" height="600""#),
881            "800x600 fallback fired: {html}"
882        );
883    }
884
885    // --- <picture>-wrapped shape (raster originals always wrapped 2026-05-20) ---
886
887    #[test]
888    fn picture_wrap_for_raster_original_no_lqip() {
889        // After 2026-05-20: synthesizer always emits <picture> for png/jpg/jpeg
890        // originals. The preview server's AssetRegistry intercept ensures the
891        // webp URL resolves (LQIP bytes for Pending, real bytes for Ready);
892        // publish-mode synchronous encoding ensures it never 404s in
893        // production. data-placeholder-src is gone — iframe-bridge matches
894        // by URL substring now.
895        let s = snapshot_dims("photo.jpg", 800, 600);
896        let html = synthesize_image_html(
897            "photo.jpg",
898            "A cat",
899            &s,
900            ImageContext::MarkdownInline,
901            &ImageRenderOptions::default(),
902        );
903        assert_eq!(
904            html,
905            r#"<picture><source srcset="photo.webp" type="image/webp"><img src="photo.jpg" width="800" height="600" loading="lazy" alt="A cat" /></picture>"#
906        );
907    }
908
909    #[test]
910    fn picture_wrap_for_raster_original_with_lqip() {
911        // LQIP inline style is preserved (legitimate production technique;
912        // cf. Vercel `blurDataURL`). Shown to the user instantly while the
913        // actual bytes are being decoded.
914        let s = snapshot_with(
915            "photo.jpg",
916            Some((800, 600)),
917            None,
918            Some("data:image/jpeg;base64,abc"),
919            false,
920        );
921        let html = synthesize_image_html(
922            "photo.jpg",
923            "A cat",
924            &s,
925            ImageContext::MarkdownInline,
926            &ImageRenderOptions::default(),
927        );
928        assert_eq!(
929            html,
930            r#"<picture><source srcset="photo.webp" type="image/webp"><img src="photo.jpg" width="800" height="600" loading="lazy" style="background-image:url(data:image/jpeg;base64,abc);background-size:cover" alt="A cat" /></picture>"#
931        );
932    }
933
934    #[test]
935    fn bare_img_with_dominant_color_no_lqip() {
936        let s = snapshot_with(
937            "photo.jpg",
938            Some((800, 600)),
939            Some("#aabbcc"),
940            None,
941            false,
942        );
943        let html = synthesize_image_html(
944            "photo.jpg",
945            "Cat",
946            &s,
947            ImageContext::MarkdownInline,
948            &ImageRenderOptions::default(),
949        );
950        assert!(
951            html.contains(r#"style="background-color:#aabbcc""#),
952            "Got: {html}"
953        );
954    }
955
956    // --- <picture> wrap (WebP variant present) ----------------------------
957
958    #[test]
959    fn picture_wrap_when_webp_exists() {
960        let s = snapshot_with("photo.jpg", Some((800, 600)), None, None, true);
961        let html = synthesize_image_html(
962            "photo.jpg",
963            "Cat",
964            &s,
965            ImageContext::MarkdownInline,
966            &ImageRenderOptions::default(),
967        );
968        // Outer: <picture>...</picture> with <source> first
969        assert!(
970            html.starts_with(r#"<picture><source srcset="photo.webp" type="image/webp"><img"#),
971            "Got: {html}"
972        );
973        assert!(html.ends_with(r#"alt="Cat" /></picture>"#), "Got: {html}");
974    }
975
976    #[test]
977    fn picture_srcset_uses_to_webp_of_src_not_stored_variant() {
978        // CJK-path case: <img src> already carries dir_overrides + relative
979        // prefix (e.g. ../assets/photo.jpg). srcset must derive from src
980        // (../assets/photo.webp), not the manifest's stored value.
981        // See wrap_img_in_picture's rationale at placeholder.rs:660.
982        let s = snapshot_with("../assets/photo.jpg", Some((800, 600)), None, None, true);
983        let html = synthesize_image_html(
984            "../assets/photo.jpg",
985            "",
986            &s,
987            ImageContext::MarkdownInline,
988            &ImageRenderOptions::default(),
989        );
990        assert!(
991            html.contains(r#"srcset="../assets/photo.webp""#),
992            "srcset should inherit src's prefix; got: {html}"
993        );
994    }
995
996    // --- eager loading ----------------------------------------------------
997
998    #[test]
999    fn eager_swaps_loading_attr_and_adds_fetchpriority() {
1000        let s = snapshot_dims("photo.jpg", 1, 1);
1001        let html = synthesize_image_html(
1002            "photo.jpg",
1003            "Hero",
1004            &s,
1005            ImageContext::Hero,
1006            &ImageRenderOptions {
1007                eager: true,
1008                ..Default::default()
1009            },
1010        );
1011        assert!(
1012            html.contains(r#"loading="eager" fetchpriority="high""#),
1013            "Got: {html}"
1014        );
1015        assert!(!html.contains(r#"loading="lazy""#), "Got: {html}");
1016    }
1017
1018    // --- favicon short-circuit --------------------------------------------
1019
1020    #[test]
1021    fn favicon_is_bare_16x16_with_class() {
1022        // No manifest data for the favicon URL — synthesizer must NOT probe
1023        // the snapshot (favicons are not registered as moss assets).
1024        let s = AssetSnapshot::new();
1025        let html = synthesize_image_html(
1026            "https://example.com/favicon.ico",
1027            "",
1028            &s,
1029            ImageContext::Favicon,
1030            &ImageRenderOptions {
1031                class: Some("link-preview-favicon"),
1032                ..Default::default()
1033            },
1034        );
1035        assert_eq!(
1036            html,
1037            r#"<img class="link-preview-favicon" src="https://example.com/favicon.ico" width="16" height="16" alt="">"#
1038        );
1039    }
1040
1041    // --- site-logo short-circuit (Phase 2B carve-out) --------------------
1042
1043    #[test]
1044    fn synthesize_site_logo_basic_shape() {
1045        let html = synthesize_image_html(
1046            "assets/logo.png",
1047            "",
1048            &AssetSnapshot::new(),
1049            ImageContext::SiteLogo,
1050            &ImageRenderOptions::default(),
1051        );
1052        assert_eq!(
1053            html,
1054            r#"<img class="site-logo" src="assets/logo.png" alt="" aria-hidden="true">"#
1055        );
1056    }
1057
1058    #[test]
1059    fn synthesize_site_logo_escapes_src() {
1060        let html = synthesize_image_html(
1061            r#"logo "with quotes".png"#,
1062            "",
1063            &AssetSnapshot::new(),
1064            ImageContext::SiteLogo,
1065            &ImageRenderOptions::default(),
1066        );
1067        assert!(
1068            html.contains(r#"logo &quot;with quotes&quot;.png"#),
1069            "got: {html}"
1070        );
1071    }
1072
1073    #[test]
1074    fn synthesize_site_logo_does_not_emit_picture_or_lqip() {
1075        // Even when the snapshot would normally drive LQIP/<picture> for a
1076        // png, SiteLogo must short-circuit and emit a bare <img>.
1077        let mut snap = AssetSnapshot::new();
1078        snap.lqip
1079            .insert("logo.png".into(), "data:image/jpeg;base64,xxx".into());
1080        let html = synthesize_image_html(
1081            "logo.png",
1082            "moss",
1083            &snap,
1084            ImageContext::SiteLogo,
1085            &ImageRenderOptions::default(),
1086        );
1087        assert!(!html.contains("<picture"), "logo should not get picture wrap: {html}");
1088        assert!(
1089            !html.contains("background-image"),
1090            "logo should not get LQIP: {html}"
1091        );
1092        assert!(
1093            !html.contains("loading="),
1094            "logo above-the-fold, no lazy-load: {html}"
1095        );
1096    }
1097
1098    // --- HTML-escape contract --------------------------------------------
1099
1100    #[test]
1101    fn alt_with_quotes_is_escaped() {
1102        let s = snapshot_dims("photo.jpg", 1, 1);
1103        let html = synthesize_image_html(
1104            "photo.jpg",
1105            r#"Quote: "hi""#,
1106            &s,
1107            ImageContext::MarkdownInline,
1108            &ImageRenderOptions::default(),
1109        );
1110        // moss_core::media::html_escape escapes " → &quot; — never leave a
1111        // raw quote inside an attribute value.
1112        assert!(html.contains(r#"alt="Quote: &quot;hi&quot;""#), "Got: {html}");
1113    }
1114
1115    #[test]
1116    fn extra_attrs_passed_through_verbatim() {
1117        // :::hero passes `style="object-fit:cover;object-position:50% 50%"`
1118        // via MediaAttrs::to_inline_style — the caller pre-escapes, so we
1119        // just append.
1120        let s = snapshot_dims("hero.jpg", 1920, 1080);
1121        let html = synthesize_image_html(
1122            "hero.jpg",
1123            "",
1124            &s,
1125            ImageContext::Hero,
1126            &ImageRenderOptions {
1127                eager: true,
1128                extra_attrs: Some(r#"data-cover-fit="cover""#),
1129                ..Default::default()
1130            },
1131        );
1132        assert!(html.contains(r#"data-cover-fit="cover""#), "Got: {html}");
1133        // Extra attrs come AFTER alt — matches the current regex-pass order
1134        // where the regex preserves the original post-src attributes.
1135        let alt_pos = html.find("alt=").unwrap();
1136        let extra_pos = html.find("data-cover-fit=").unwrap();
1137        assert!(extra_pos > alt_pos, "extra attrs should come after alt");
1138    }
1139
1140    /// Regression test for impl-review finding 2026-05-16: when extra_attrs
1141    /// already carries a `style=` attribute (the `:::hero {attrs=...}` case
1142    /// when MediaAttrs::to_inline_style() returns Some), the synthesizer
1143    /// must NOT also emit its own LQIP-derived `style=`. The legacy regex
1144    /// pass had a `has_style` guard at `placeholder.rs:413-422` that did the
1145    /// same suppression. Without this, browsers see two `style=` attributes
1146    /// on one element, honor the last one, and drop the LQIP placeholder.
1147    #[test]
1148    fn lqip_style_suppressed_when_extra_attrs_has_style() {
1149        let s = snapshot_with(
1150            "hero.jpg",
1151            Some((1920, 1080)),
1152            None,
1153            Some("data:image/jpeg;base64,abc"),
1154            false,
1155        );
1156        let html = synthesize_image_html(
1157            "hero.jpg",
1158            "",
1159            &s,
1160            ImageContext::Hero,
1161            &ImageRenderOptions {
1162                eager: true,
1163                extra_attrs: Some(r#"style="object-fit:cover;object-position:50% 50%""#),
1164                ..Default::default()
1165            },
1166        );
1167        // Exactly one `style=` substring — the one the caller passed.
1168        assert_eq!(
1169            html.matches("style=").count(),
1170            1,
1171            "expected exactly one style= attribute; got: {html}"
1172        );
1173        // Confirm the caller's style is what survived (not the LQIP).
1174        assert!(
1175            html.contains(r#"style="object-fit:cover"#),
1176            "caller-supplied style must survive; got: {html}"
1177        );
1178        assert!(
1179            !html.contains(r#"background-image:url(data:"#),
1180            "LQIP must be suppressed when extra_attrs carries style=; got: {html}"
1181        );
1182    }
1183
1184    // --- Step 8: figure wrapper for MarkdownStandalone --------------------
1185
1186    /// Phase 1 C1 test helper: build an empty extras BTreeMap.
1187    fn empty_extras() -> std::collections::BTreeMap<String, String> {
1188        std::collections::BTreeMap::new()
1189    }
1190
1191    #[test]
1192    fn markdown_standalone_no_caption_wraps_in_figure() {
1193        // No caption → `<figure class="moss-image">…</figure>` around the
1194        // synthesized `<picture>`/`<img>` with no `<figcaption>`.
1195        let s = snapshot_dims("photo.jpg", 800, 600);
1196        let extras = empty_extras();
1197        let html = synthesize_image_html(
1198            "photo.jpg",
1199            "Alt text",
1200            &s,
1201            ImageContext::MarkdownStandalone {
1202                caption: None,
1203                width: None,
1204                align: None,
1205                class_names: &[],
1206                extra_attrs: &extras,
1207            },
1208            &ImageRenderOptions::default(),
1209        );
1210        assert!(html.starts_with(r#"<figure class="moss-image">"#));
1211        assert!(html.ends_with("</figure>"));
1212        assert!(!html.contains("<figcaption>"));
1213        assert!(html.contains("<img"));
1214    }
1215
1216    #[test]
1217    fn markdown_standalone_with_caption_adds_figcaption() {
1218        let s = snapshot_dims("photo.jpg", 800, 600);
1219        let extras = empty_extras();
1220        let html = synthesize_image_html(
1221            "photo.jpg",
1222            "Alt text",
1223            &s,
1224            ImageContext::MarkdownStandalone {
1225                caption: Some("A nice photo"),
1226                width: None,
1227                align: None,
1228                class_names: &[],
1229                extra_attrs: &extras,
1230            },
1231            &ImageRenderOptions::default(),
1232        );
1233        assert!(html.starts_with(r#"<figure class="moss-image">"#));
1234        assert!(html.contains("<figcaption>A nice photo</figcaption>"));
1235        assert!(html.ends_with("</figure>"));
1236    }
1237
1238    #[test]
1239    fn markdown_standalone_caption_is_html_escaped() {
1240        let s = snapshot_dims("photo.jpg", 800, 600);
1241        let extras = empty_extras();
1242        let html = synthesize_image_html(
1243            "photo.jpg",
1244            "",
1245            &s,
1246            ImageContext::MarkdownStandalone {
1247                caption: Some(r#"Q&A "best" of <em>2024</em>"#),
1248                width: None,
1249                align: None,
1250                class_names: &[],
1251                extra_attrs: &extras,
1252            },
1253            &ImageRenderOptions::default(),
1254        );
1255        assert!(
1256            html.contains("<figcaption>Q&amp;A &quot;best&quot; of &lt;em&gt;2024&lt;/em&gt;</figcaption>"),
1257            "caption text must be HTML-escaped at the boundary; got: {html}"
1258        );
1259    }
1260
1261    #[test]
1262    fn markdown_standalone_wraps_picture_when_webp_present() {
1263        // When the manifest carries a WebP variant, the synthesizer emits
1264        // a `<picture>` wrap inside the figure: the structural figure
1265        // and the responsive picture compose without conflict.
1266        let s = snapshot_with("photo.jpg", Some((1200, 800)), None, None, true);
1267        let extras = empty_extras();
1268        let html = synthesize_image_html(
1269            "photo.jpg",
1270            "Alt",
1271            &s,
1272            ImageContext::MarkdownStandalone {
1273                caption: Some("Cap"),
1274                width: None,
1275                align: None,
1276                class_names: &[],
1277                extra_attrs: &extras,
1278            },
1279            &ImageRenderOptions::default(),
1280        );
1281        // Structural order: figure > picture > source + img > figcaption
1282        let fig_idx = html.find(r#"<figure class="moss-image">"#).expect("figure");
1283        let pic_idx = html.find("<picture>").expect("picture");
1284        let src_idx = html.find("<source").expect("source");
1285        let img_idx = html.find("<img").expect("img");
1286        let cap_idx = html.find("<figcaption>").expect("figcaption");
1287        let fig_close = html.find("</figure>").expect("figure close");
1288        assert!(fig_idx < pic_idx, "<picture> must be inside <figure>");
1289        assert!(pic_idx < src_idx);
1290        assert!(src_idx < img_idx);
1291        assert!(img_idx < cap_idx, "<figcaption> follows <picture>");
1292        assert!(cap_idx < fig_close);
1293    }
1294
1295    #[test]
1296    fn markdown_inline_does_not_wrap_in_figure() {
1297        // Inline images NEVER get a figure wrapper — they sit in prose.
1298        let s = snapshot_dims("photo.jpg", 800, 600);
1299        let html = synthesize_image_html(
1300            "photo.jpg",
1301            "Alt",
1302            &s,
1303            ImageContext::MarkdownInline,
1304            &ImageRenderOptions::default(),
1305        );
1306        assert!(!html.contains("<figure"));
1307        assert!(!html.contains("<figcaption>"));
1308    }
1309
1310    // --- spec § P9 width: `data-width` on the figure wrapper -------------
1311
1312    #[test]
1313    fn markdown_standalone_width_screen_emits_data_width_on_figure() {
1314        // Width pipe-alias `![[photo.jpg|full]]` → `screen` lands on the
1315        // figure wrapper, not the inner img. The image-side test below
1316        // pins the "absent by default" half of the contract.
1317        let s = snapshot_dims("photo.jpg", 800, 600);
1318        let extras = empty_extras();
1319        let html = synthesize_image_html(
1320            "photo.jpg",
1321            "",
1322            &s,
1323            ImageContext::MarkdownStandalone {
1324                caption: None,
1325                width: Some("screen"),
1326                align: None,
1327                class_names: &[],
1328                extra_attrs: &extras,
1329            },
1330            &ImageRenderOptions::default(),
1331        );
1332        assert!(
1333            html.starts_with(r#"<figure class="moss-image" data-width="screen">"#),
1334            "data-width must sit on the figure wrapper; got: {html}"
1335        );
1336        // The inner img must NOT carry data-width — the attribute is the
1337        // wrapper's responsibility per spec.
1338        assert!(
1339            !html.contains(r#"<img"#) || !html[html.find("<img").unwrap()..].contains("data-width="),
1340            "inner <img> must not carry data-width; got: {html}"
1341        );
1342    }
1343
1344    #[test]
1345    fn markdown_standalone_width_wide_with_caption() {
1346        // width + caption compose: both attributes / children appear in
1347        // the wrapper.
1348        let s = snapshot_dims("photo.jpg", 800, 600);
1349        let extras = empty_extras();
1350        let html = synthesize_image_html(
1351            "photo.jpg",
1352            "Alt",
1353            &s,
1354            ImageContext::MarkdownStandalone {
1355                caption: Some("A nice photo"),
1356                width: Some("wide"),
1357                align: None,
1358                class_names: &[],
1359                extra_attrs: &extras,
1360            },
1361            &ImageRenderOptions::default(),
1362        );
1363        assert!(html.contains(r#"data-width="wide""#), "got: {html}");
1364        assert!(
1365            html.contains("<figcaption>A nice photo</figcaption>"),
1366            "got: {html}"
1367        );
1368    }
1369
1370    #[test]
1371    fn markdown_standalone_width_none_omits_data_width() {
1372        // Negative test: the default (no width) must not emit the attribute,
1373        // so theme authors can target the absence via `:not([data-width])`.
1374        let s = snapshot_dims("photo.jpg", 800, 600);
1375        let extras = empty_extras();
1376        let html = synthesize_image_html(
1377            "photo.jpg",
1378            "",
1379            &s,
1380            ImageContext::MarkdownStandalone {
1381                caption: None,
1382                width: None,
1383                align: None,
1384                class_names: &[],
1385                extra_attrs: &extras,
1386            },
1387            &ImageRenderOptions::default(),
1388        );
1389        assert!(!html.contains("data-width="), "got: {html}");
1390    }
1391
1392    #[test]
1393    fn wrap_in_figure_width_emits_data_width_attribute() {
1394        // Direct-call contract test: `wrap_in_figure` is the single source
1395        // of truth for the figure wrapper byte shape; the raw-HTML branch
1396        // of `emit_standalone_figure_image` calls it with the lifted width.
1397        let html = wrap_in_figure(r#"<img src="x" />"#, None, Some("page"));
1398        assert_eq!(
1399            html,
1400            r#"<figure class="moss-image" data-width="page"><img src="x" /></figure>"#
1401        );
1402    }
1403
1404    #[test]
1405    fn wrap_in_figure_width_with_caption() {
1406        let html = wrap_in_figure(r#"<img src="x" />"#, Some("hello"), Some("screen"));
1407        assert_eq!(
1408            html,
1409            r#"<figure class="moss-image" data-width="screen"><img src="x" /><figcaption>hello</figcaption></figure>"#
1410        );
1411    }
1412
1413    #[test]
1414    fn wrap_in_figure_no_width_no_attribute() {
1415        let html = wrap_in_figure(r#"<img src="x" />"#, None, None);
1416        assert_eq!(
1417            html,
1418            r#"<figure class="moss-image"><img src="x" /></figure>"#
1419        );
1420    }
1421
1422    #[test]
1423    fn markdown_standalone_no_manifest_still_wraps_in_figure() {
1424        // The wrapper is structural identity (Step 8 contract), not
1425        // manifest-dependent. Even with an empty AssetSnapshot (test/
1426        // fragment-render path), `<figure class="moss-image">` still wraps
1427        // the synthesized `<img>`. The Phase 1 B1 migration (2026-05-25)
1428        // replaced the `Option<&MediaDimensionLookup>` parameter with
1429        // `&AssetSnapshot`; the empty-snapshot path is now the test
1430        // equivalent of the prior `None` lookup.
1431        let s = AssetSnapshot::new();
1432        let extras = empty_extras();
1433        let html = synthesize_image_html(
1434            "photo.jpg",
1435            "Alt",
1436            &s,
1437            ImageContext::MarkdownStandalone {
1438                caption: Some("Cap"),
1439                width: None,
1440                align: None,
1441                class_names: &[],
1442                extra_attrs: &extras,
1443            },
1444            &ImageRenderOptions::default(),
1445        );
1446        assert!(html.starts_with(r#"<figure class="moss-image">"#));
1447        assert!(html.contains("<img"));
1448        assert!(html.contains("<figcaption>Cap</figcaption>"));
1449        assert!(html.ends_with("</figure>"));
1450    }
1451
1452    // --- size fallback ----------------------------------------------------
1453
1454    #[test]
1455    fn missing_dimensions_fall_back_to_800x600() {
1456        let s = AssetSnapshot::new();
1457        let html = synthesize_image_html(
1458            "ghost.jpg",
1459            "",
1460            &s,
1461            ImageContext::MarkdownInline,
1462            &ImageRenderOptions::default(),
1463        );
1464        // FALLBACK_WIDTH / FALLBACK_HEIGHT (still 800×600, sourced from
1465        // `moss_core::asset_snapshot::FALLBACK_WIDTH` so the synthesizer and
1466        // the surviving regex pass agree on the absent-dims default).
1467        assert!(html.contains(r#"width="800" height="600""#), "Got: {html}");
1468    }
1469
1470    // --- regex-pass idempotency on synthesizer output ----------------------
1471    //
1472    // Phase 2E v5 PR5 (2026-05-26) retired the Stage 3 regex post-pass; the
1473    // image synthesizer in this module is now the sole emitter of width /
1474    // height / loading / LQIP / dominant-color attributes for moss-emitted
1475    // <img> tags. The three idempotency tests at
1476    // `src-tauri/tests/image_synth_regex_parity.rs` that guarded the
1477    // regex+synth byte-shape parity were deleted alongside the regex.
1478
1479    // --- TrackingPixel (Phase 2C, 2026-05-25) ---
1480    //
1481    // The RSS read-tracking pixel is a 1×1 invisible <img>. It must fire on
1482    // read (so NO loading="lazy"), carry empty alt (decorative), and never
1483    // be wrapped in <picture> / decorated with LQIP. Self-closing form
1484    // because the call site embeds it in CDATA-wrapped RSS XML.
1485
1486    #[test]
1487    fn synthesize_tracking_pixel_basic() {
1488        let html = synthesize_image_html(
1489            "https://api.mosspub.com/pixel.gif?u=abc",
1490            "",
1491            &AssetSnapshot::new(),
1492            ImageContext::TrackingPixel,
1493            &ImageRenderOptions::default(),
1494        );
1495        assert_eq!(
1496            html,
1497            r#"<img src="https://api.mosspub.com/pixel.gif?u=abc" alt="" width="1" height="1" />"#
1498        );
1499    }
1500
1501    #[test]
1502    fn synthesize_tracking_pixel_escapes_url() {
1503        let html = synthesize_image_html(
1504            r#"x.gif?u=a&b="c""#,
1505            "",
1506            &AssetSnapshot::new(),
1507            ImageContext::TrackingPixel,
1508            &ImageRenderOptions::default(),
1509        );
1510        assert!(html.contains("&amp;"), "& must be escaped");
1511        assert!(html.contains("&quot;"), "\" must be escaped");
1512    }
1513
1514    #[test]
1515    fn synthesize_tracking_pixel_no_lazy_no_lqip() {
1516        // Even when the snapshot contains LQIP / dimensions for the pixel
1517        // path, the TrackingPixel short-circuit must ignore them — pixels
1518        // are tracking beacons, not images.
1519        let mut snap = AssetSnapshot::new();
1520        snap.lqip.insert(
1521            "pixel.gif".into(),
1522            "data:image/jpeg;base64,xxx".into(),
1523        );
1524        let html = synthesize_image_html(
1525            "pixel.gif",
1526            "",
1527            &snap,
1528            ImageContext::TrackingPixel,
1529            &ImageRenderOptions::default(),
1530        );
1531        assert!(
1532            !html.contains("loading=\"lazy\""),
1533            "must NOT lazy-load (must fire on read)"
1534        );
1535        assert!(
1536            !html.contains("background-image"),
1537            "must NOT carry LQIP"
1538        );
1539        assert!(
1540            !html.contains("<picture"),
1541            "must NOT wrap in picture"
1542        );
1543    }
1544
1545    // --- ImageContext::EmailBody (Phase 2D, 2026-05-25) -------------------
1546    //
1547    // Email-client-safe carve-out: no <picture>, no data-*, no loading=lazy.
1548    // Inline `style="display:block;max-width:100%;height:auto;"` is the
1549    // cross-client responsive pattern. width/height attrs are Option<u32>:
1550    // emitted when known, omitted when None.
1551
1552    #[test]
1553    fn synthesize_email_body_with_dims() {
1554        let html = synthesize_image_html(
1555            "https://media.example.com/photo.jpg",
1556            "Cover",
1557            &AssetSnapshot::new(),
1558            ImageContext::EmailBody {
1559                width: Some(600),
1560                height: Some(400),
1561            },
1562            &ImageRenderOptions::default(),
1563        );
1564        assert!(html.contains(r#"width="600""#));
1565        assert!(html.contains(r#"height="400""#));
1566        assert!(html.contains(r#"style="display:block;max-width:100%;height:auto;""#));
1567    }
1568
1569    #[test]
1570    fn synthesize_email_body_without_dims() {
1571        let html = synthesize_image_html(
1572            "x.jpg",
1573            "alt",
1574            &AssetSnapshot::new(),
1575            ImageContext::EmailBody {
1576                width: None,
1577                height: None,
1578            },
1579            &ImageRenderOptions::default(),
1580        );
1581        assert!(!html.contains("width="), "width should be omitted when None");
1582        assert!(!html.contains("height="), "height should be omitted when None");
1583    }
1584
1585    #[test]
1586    fn synthesize_email_body_no_picture_no_data() {
1587        let html = synthesize_image_html(
1588            "photo.jpg",
1589            "alt",
1590            &AssetSnapshot::new(),
1591            ImageContext::EmailBody {
1592                width: None,
1593                height: None,
1594            },
1595            &ImageRenderOptions::default(),
1596        );
1597        assert!(!html.contains("<picture"), "email images must not use <picture>");
1598        assert!(!html.contains("<source"), "no <source>");
1599        assert!(!html.contains("data-"), "no data-* (email clients strip)");
1600        assert!(!html.contains("loading="), "email clients ignore loading attr");
1601        assert!(
1602            !html.contains("background-image"),
1603            "email clients strip inline style URLs"
1604        );
1605    }
1606
1607    #[test]
1608    fn synthesize_email_body_escapes() {
1609        let html = synthesize_image_html(
1610            r#"https://x.com/photo.jpg?a=1&b=2"#,
1611            r#"alt with "quotes""#,
1612            &AssetSnapshot::new(),
1613            ImageContext::EmailBody {
1614                width: None,
1615                height: None,
1616            },
1617            &ImageRenderOptions::default(),
1618        );
1619        assert!(html.contains("&amp;"), "& must be escaped");
1620        assert!(html.contains("&quot;"), "\" must be escaped");
1621    }
1622
1623    // --- ImageContext::GalleryThumb (Phase 2E v5 PR3, 2026-05-26) ---------
1624    //
1625    // Gallery body images: below-the-fold thumbnail, same inner byte
1626    // shape as MarkdownInline (`<picture><source srcset=*.webp><img
1627    // loading="lazy" ...></picture>` for raster, bare `<img>` for
1628    // non-raster). The outer `.moss-gallery-item` wrapper is owned by
1629    // `DefaultHooks::render_shortcode`'s Gallery arm; this variant
1630    // emits only the inner image. Distinguishing it from
1631    // MarkdownInline at the type level keeps per-item passthrough
1632    // attributes (object-position from MediaAttrs) typed for future
1633    // evolution.
1634
1635    #[test]
1636    fn synthesize_gallery_thumb_emits_picture_with_lazy() {
1637        let p = ImageRenderOptions::default();
1638        let mut snap = AssetSnapshot::new();
1639        snap.dimensions
1640            .insert(PathBuf::from("photo.jpg"), (1200, 800));
1641        let out = synthesize_image_html(
1642            "photo.jpg",
1643            "alt",
1644            &snap,
1645            ImageContext::GalleryThumb,
1646            &p,
1647        );
1648        assert!(out.contains("<picture"), "{out}");
1649        assert!(out.contains(r#"srcset="photo.webp""#), "{out}");
1650        assert!(out.contains(r#"loading="lazy""#), "{out}");
1651        assert!(out.contains(r#"width="1200""#), "{out}");
1652        assert!(out.contains(r#"height="800""#), "{out}");
1653        assert!(out.contains(r#"alt="alt""#), "{out}");
1654    }
1655
1656    #[test]
1657    fn synthesize_gallery_thumb_non_raster_bare_img() {
1658        // SVG / .webp originals don't trigger the <picture> wrap (no
1659        // variant exists). Falls back to bare <img> with lazy loading +
1660        // dims from the snapshot.
1661        let p = ImageRenderOptions::default();
1662        let mut snap = AssetSnapshot::new();
1663        snap.dimensions
1664            .insert(PathBuf::from("icon.svg"), (64, 64));
1665        let out = synthesize_image_html(
1666            "icon.svg",
1667            "",
1668            &snap,
1669            ImageContext::GalleryThumb,
1670            &p,
1671        );
1672        assert!(!out.contains("<picture"), "{out}");
1673        assert!(!out.contains("<source"), "{out}");
1674        assert!(out.contains(r#"loading="lazy""#), "{out}");
1675        assert!(out.contains(r#"width="64""#), "{out}");
1676    }
1677
1678    #[test]
1679    fn synthesize_gallery_thumb_threads_extra_attrs() {
1680        // The Gallery hook builds a `style="object-position:..."` fragment
1681        // from MediaAttrs and passes it via extra_attrs. The synthesizer
1682        // suppresses its own LQIP-derived style= when extra_attrs already
1683        // carries one — verify that suppression engages here too
1684        // (parity with Hero / MarkdownInline).
1685        let snap = snapshot_with(
1686            "photo.jpg",
1687            Some((1200, 800)),
1688            None,
1689            Some("data:image/jpeg;base64,abc"),
1690            false,
1691        );
1692        let opts = ImageRenderOptions {
1693            extra_attrs: Some(r#"style="object-position:50% 50%""#),
1694            ..Default::default()
1695        };
1696        let out = synthesize_image_html(
1697            "photo.jpg",
1698            "",
1699            &snap,
1700            ImageContext::GalleryThumb,
1701            &opts,
1702        );
1703        assert_eq!(
1704            out.matches("style=").count(),
1705            1,
1706            "expected exactly one style= attribute; got: {out}"
1707        );
1708        assert!(
1709            out.contains(r#"style="object-position:50% 50%""#),
1710            "caller-supplied style must survive; got: {out}"
1711        );
1712    }
1713
1714    // --- ImageContext::HeroBare (Phase 2E PR2, 2026-05-26) ----------------
1715    //
1716    // The no-snapshot hero fallback. Emits a bare `<img>` with no
1717    // `<picture>`, no `<source>`, no LQIP, no dims, no `loading` attr.
1718    // The asset-publish invariant rules out emitting a
1719    // `<source srcset="*.webp">` for an unregistered variant — this
1720    // variant is the explicit opt-out for code paths that run before
1721    // `AssetRegistry::set_pending` has been called for the source's
1722    // `.webp` companion (test/fragment-render paths). The byte shape
1723    // mirrors the pre-PR2 fallback at
1724    // `typed_renderers.rs::render_hero_html_typed` lines 554-557.
1725
1726    #[test]
1727    fn synthesize_hero_bare_basic_shape() {
1728        let out = synthesize_image_html(
1729            "cover.jpg",
1730            "",
1731            &AssetSnapshot::new(),
1732            ImageContext::HeroBare,
1733            &ImageRenderOptions::default(),
1734        );
1735        // No <picture>, no <source>, no class, no loading, no LQIP, no
1736        // width/height attrs. Exact byte shape with empty alt.
1737        assert_eq!(out, r#"<img src="cover.jpg" alt="" />"#, "got: {}", out);
1738    }
1739
1740    #[test]
1741    fn synthesize_hero_bare_with_style_via_extra_attrs() {
1742        // The legacy fallback passed an inline `style="..."` fragment
1743        // built from MediaAttrs::to_inline_style(). PR2 threads that
1744        // fragment through `ImageRenderOptions::extra_attrs` — the
1745        // synthesizer prepends a single space, matching the legacy byte
1746        // shape.
1747        let out = synthesize_image_html(
1748            "cover.jpg",
1749            "",
1750            &AssetSnapshot::new(),
1751            ImageContext::HeroBare,
1752            &ImageRenderOptions {
1753                extra_attrs: Some(r#"style="object-fit:cover;object-position:50% 50%""#),
1754                ..Default::default()
1755            },
1756        );
1757        assert_eq!(
1758            out,
1759            r#"<img src="cover.jpg" alt="" style="object-fit:cover;object-position:50% 50%" />"#,
1760            "got: {}",
1761            out
1762        );
1763    }
1764
1765    #[test]
1766    fn synthesize_hero_bare_with_lqip_in_snapshot_still_bare() {
1767        // Even when the snapshot has LQIP / dims for the source path,
1768        // the HeroBare variant must ignore them — the no-snapshot signal
1769        // is structural (this variant exists precisely because no
1770        // AssetRegistry has been primed), not data-driven.
1771        let mut snap = AssetSnapshot::new();
1772        snap.lqip
1773            .insert("cover.jpg".into(), "data:image/jpeg;base64,xxx".into());
1774        snap.dimensions.insert("cover.jpg".into(), (1920, 1080));
1775        let out = synthesize_image_html(
1776            "cover.jpg",
1777            "",
1778            &snap,
1779            ImageContext::HeroBare,
1780            &ImageRenderOptions::default(),
1781        );
1782        assert!(!out.contains("<picture"), "got: {}", out);
1783        assert!(!out.contains("<source"), "got: {}", out);
1784        assert!(!out.contains("background-image"), "got: {}", out);
1785        assert!(!out.contains("width="), "got: {}", out);
1786        assert!(!out.contains("height="), "got: {}", out);
1787        assert!(!out.contains("loading="), "got: {}", out);
1788    }
1789
1790    #[test]
1791    fn synthesize_hero_bare_escapes_url() {
1792        // The synthesizer's html_escape covers `&` and `"`; the legacy
1793        // fallback used the same `html_escape` from build::media::cover.
1794        let out = synthesize_image_html(
1795            r#"x.jpg?a=1&b="c""#,
1796            "",
1797            &AssetSnapshot::new(),
1798            ImageContext::HeroBare,
1799            &ImageRenderOptions::default(),
1800        );
1801        assert!(out.contains("&amp;"), "& must be escaped; got: {}", out);
1802        assert!(out.contains("&quot;"), "\" must be escaped; got: {}", out);
1803    }
1804
1805    #[test]
1806    fn synthesize_hero_bare_byte_shape_matches_legacy_fallback() {
1807        // Pin the byte shape against a literal reconstruction of the
1808        // pre-PR2 emission so a future "tidy" of the synthesizer's
1809        // HeroBare branch can't drift away from the legacy fallback
1810        // without flipping this assertion deliberately.
1811        //
1812        // Legacy line:
1813        //   format!("<img src=\"{}\" alt=\"\"{} />", html_escape(href), style)
1814        // where `style` was `""` or ` style="..."` (with leading space).
1815        let href = "covers/img.jpg";
1816        let style_fragment = r#" style="object-fit:contain""#;
1817        let legacy = format!(
1818            "<img src=\"{}\" alt=\"\"{} />",
1819            html_escape(href),
1820            style_fragment,
1821        );
1822
1823        // PR2 path: thread the style through extra_attrs minus the leading
1824        // space (matches typed_renderers.rs migration).
1825        let pr2 = synthesize_image_html(
1826            href,
1827            "",
1828            &AssetSnapshot::new(),
1829            ImageContext::HeroBare,
1830            &ImageRenderOptions {
1831                extra_attrs: Some(style_fragment.trim_start()),
1832                ..Default::default()
1833            },
1834        );
1835        assert_eq!(pr2, legacy, "byte shape divergence: pr2={} legacy={}", pr2, legacy);
1836    }
1837}