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**: ` *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 // The preview server's AssetRegistry intercept (preview/server/placeholder.rs)
546 // ensures the webp URL never 404s in preview mode — it returns LQIP bytes
547 // for Pending entries — and publish mode encodes variants synchronously
548 // before HTML ships (PluginMode::Blocking). So the URL is always live in
549 // both modes.
550 //
551 // We must never emit a <source> that might 404 because a chosen <source>
552 // 404 is non-recoverable inside <picture> per HTML spec §
553 // "update-the-source-set" + § "update-the-image-data": browser commits to
554 // the chosen URL, fetch fails, image state goes to broken, error fires —
555 // browser does NOT walk back to the inner <img>.
556 //
557 // For non-raster sources (svg, favicons via Favicon context), no variant
558 // exists; emit the bare <img>.
559 //
560 // Pattern: explicit promise model. See
561 // docs/plans/2026-05-20-image-variant-honest-mirror.md (Layer 3).
562 if is_raster_original(src) {
563 // to_webp(src) inherits the dir_overrides + relative-prefix already
564 // applied to `src` by the upstream renderer. Swapping the extension
565 // on `src` is what keeps the synthesizer's emitted URL aligned with
566 // the AssetRegistry's registered key (blocking.rs's set_pending loop
567 // uses the same to_webp(mapped) derivation).
568 let srcset_path = to_webp(src);
569 format!(
570 r#"<picture><source srcset="{}" type="image/webp">{}</picture>"#,
571 html_escape(&srcset_path),
572 img_tag,
573 )
574 } else {
575 img_tag
576 }
577}
578
579/// Returns true when `src` is a raster original that always gets a webp
580/// variant from moss's image pipeline. Restricted to the three extensions
581/// (png, jpg, jpeg) that the synthesizer emits `<picture><source srcset>` for.
582/// Gif is excluded because animated GIFs skip webp encoding (image.rs §
583/// should_skip, `is_animated_gif`); SVG is excluded as a vector format; webp
584/// originals are excluded because the variant URL would equal the src URL.
585///
586/// Note: this check is extension-only. `collect_images_for_conversion` applies
587/// additional content-based filters (e.g. `SkipReason::NotAnImage` for files
588/// whose magic bytes don't match the declared format). A file that passes
589/// `is_raster_original` here but is filtered by `NotAnImage` will NOT have
590/// `set_pending` called for it — the synthesizer will emit a `<picture>` but
591/// the registry will serve the LQIP placeholder until the build completes
592/// without a webp. In practice this only occurs for genuinely corrupt files
593/// (e.g. an HTML 404 page saved as .png) that are not referenced from content.
594fn is_raster_original(src: &str) -> bool {
595 let lower = src.to_ascii_lowercase();
596 lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg")
597}
598
599/// Try several path normalizations against `AssetSnapshot.dimensions` so the
600/// synthesizer matches the same set of input forms the prior
601/// `MediaDimensionLookup::get` handled. `src` may arrive as the resolved URL
602/// with a leading `/` (review colophon covers, cover.rs absolute paths) or
603/// with a `./` / `../` relative prefix (CJK dir_overrides); scan stores keys
604/// in plain relative form. The probe order mirrors the lookup's:
605///
606/// 1. exact match
607/// 2. leading-`/` stripped (absolute-to-relative)
608/// 3. leading `./` / `../` stripped (relative normalization)
609///
610/// Returns `None` when none of the variants is in the snapshot. The caller
611/// supplies the fallback (800×600 for dims, no style for LQIP / color).
612fn lookup_dims(assets: &AssetSnapshot, src: &str) -> Option<(u32, u32)> {
613 probe_paths(src, |p| assets.dims(&p))
614}
615
616fn lookup_lqip<'a>(assets: &'a AssetSnapshot, src: &str) -> Option<&'a str> {
617 probe_paths(src, |p| assets.lqip(&p))
618}
619
620fn lookup_color<'a>(assets: &'a AssetSnapshot, src: &str) -> Option<&'a String> {
621 probe_paths(src, |p| assets.dominant_color.get(&p))
622}
623
624fn probe_paths<T>(src: &str, mut probe: impl FnMut(PathBuf) -> Option<T>) -> Option<T> {
625 if let Some(v) = probe(PathBuf::from(src)) {
626 return Some(v);
627 }
628 let stripped = src.strip_prefix('/').unwrap_or(src);
629 if stripped != src {
630 if let Some(v) = probe(PathBuf::from(stripped)) {
631 return Some(v);
632 }
633 }
634 let mut s: &str = src;
635 while let Some(rest) = s.strip_prefix("./").or_else(|| s.strip_prefix("../")) {
636 s = rest;
637 }
638 if s != src {
639 if let Some(v) = probe(PathBuf::from(s)) {
640 return Some(v);
641 }
642 }
643 None
644}
645
646/// Emit just the `<img>` tag with all attributes. Internal helper for
647/// `synthesize_image_html` — exposed as `pub(crate)` only for snapshot tests
648/// that want to assert against the bare img output without the optional
649/// `<picture>` wrapper.
650pub(crate) fn render_img_tag(
651 src: &str,
652 alt: &str,
653 assets: &AssetSnapshot,
654 options: &ImageRenderOptions<'_>,
655) -> String {
656 // AssetSnapshot's `dims` is keyed by PathBuf; the src arrives as the
657 // resolved URL the upstream renderer baked (potentially absolute, e.g.
658 // `/image/cover.jpg`). Scan stores relative keys (`image/cover.jpg`),
659 // so the synthesizer probes both forms via `lookup_dims`. Snapshot
660 // lookups absent → fall back to the legacy 800×600 (matches the prior
661 // `MediaDimensionLookup::get` semantics before the Phase 1 B1 migration).
662 // Stem fallback (extension-mismatch — e.g. `.mov` vs `.mp4`) was
663 // previously handled in MediaDimensionLookup::get; since AssetSnapshot
664 // exposes only exact-path access, that case will need follow-up if
665 // production sites rely on it (likely only for video posters, which
666 // are out of this Tag::Image path).
667 let (width, height) = lookup_dims(assets, src).unwrap_or((FALLBACK_WIDTH, FALLBACK_HEIGHT));
668
669 let class_attr = options
670 .class
671 .map(|c| format!(r#" class="{}""#, html_escape(c)))
672 .unwrap_or_default();
673
674 let (loading_attr, fetchpriority_attr) = if options.eager {
675 (r#" loading="eager""#, r#" fetchpriority="high""#)
676 } else {
677 (r#" loading="lazy""#, "")
678 };
679
680 // Suppress LQIP / dominant-color style when extra_attrs already carries
681 // a style= attribute (e.g., `:::hero {attrs="cover-fit=contain"}` passes
682 // `style="object-fit:contain"` through extra_attrs). The browser would
683 // honor the LAST style= it sees and drop the LQIP, so emitting both
684 // produces malformed HTML and loses the placeholder. The legacy regex
685 // pass (`placeholder.rs:413-422`) had the same has_style guard; this
686 // preserves parity. Future work: merge the two declarations into a
687 // single style= via a typed `ImageRenderOptions::media_attrs` field so
688 // the synthesizer owns escaping end-to-end (impl-review item 9).
689 let extra_has_style = options
690 .extra_attrs
691 .map(|s| s.contains("style="))
692 .unwrap_or(false);
693
694 let style_attr = if extra_has_style {
695 String::new()
696 } else if let Some(lqip) = lookup_lqip(assets, src) {
697 format!(
698 r#" style="background-image:url({});background-size:cover""#,
699 lqip
700 )
701 } else if let Some(color) = lookup_color(assets, src) {
702 format!(r#" style="background-color:{}""#, color)
703 } else {
704 String::new()
705 };
706
707 let extra = options
708 .extra_attrs
709 .map(|s| format!(" {}", s))
710 .unwrap_or_default();
711
712 // `data-placeholder-src` removed 2026-05-20: the iframe-bridge handler
713 // now matches by URL substring against `src` / `srcset` (see
714 // frontend/bridge/iframe-bridge.ts, moss-asset-ready branch). The
715 // AssetRegistry's promise model + the preview server's URL-keyed lookup
716 // make the attribute redundant. See
717 // docs/plans/2026-05-20-image-variant-honest-mirror.md (Layer 3).
718 //
719 // Inline LQIP via `background-image: url(data:image/jpeg;base64,…)` is
720 // kept — legitimate production technique (cf. Vercel `blurDataURL`,
721 // nextjs.org/docs/app/api-reference/components/image). Shows a blurred
722 // preview instantly while the actual bytes are being decoded.
723 format!(
724 r#"<img{class_attr} src="{src_esc}" width="{w}" height="{h}"{loading}{fetch}{style} alt="{alt}"{extra} />"#,
725 class_attr = class_attr,
726 src_esc = html_escape(src),
727 w = width,
728 h = height,
729 loading = loading_attr,
730 fetch = fetchpriority_attr,
731 style = style_attr,
732 alt = html_escape(alt),
733 extra = extra,
734 )
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740 use crate::asset_snapshot::VariantKindSet;
741
742 /// Build an AssetSnapshot with a single entry — the per-test fixture
743 /// shape after the Phase 1 B1 migration (2026-05-25). Replaces the prior
744 /// `MediaDimensionLookup`-based `lookup(vec![img_meta(...)])` builder.
745 /// The snapshot keys are `PathBuf` per `AssetSnapshot`'s contract; the
746 /// stem-derived `variants` entry mirrors what
747 /// `AssetRegistry::iter_registered_variants` would populate when a WebP
748 /// variant is registered.
749 fn snapshot_with(
750 path: &str,
751 dims: Option<(u32, u32)>,
752 color: Option<&str>,
753 lqip: Option<&str>,
754 webp: bool,
755 ) -> AssetSnapshot {
756 let mut s = AssetSnapshot::new();
757 let key = PathBuf::from(path);
758 if let Some(d) = dims {
759 s.dimensions.insert(key.clone(), d);
760 }
761 if let Some(c) = color {
762 s.dominant_color.insert(key.clone(), c.to_string());
763 }
764 if let Some(l) = lqip {
765 s.lqip.insert(key.clone(), l.to_string());
766 }
767 if webp {
768 let stem = crate::asset_snapshot::path_strip_extension(&key);
769 s.variants.insert(
770 stem,
771 VariantKindSet {
772 webp: true,
773 avif: false,
774 },
775 );
776 }
777 s
778 }
779
780 fn snapshot_dims(path: &str, w: u32, h: u32) -> AssetSnapshot {
781 snapshot_with(path, Some((w, h)), None, None, false)
782 }
783
784 // --- <picture>-wrapped shape (raster originals always wrapped 2026-05-20) ---
785
786 #[test]
787 fn picture_wrap_for_raster_original_no_lqip() {
788 // After 2026-05-20: synthesizer always emits <picture> for png/jpg/jpeg
789 // originals. The preview server's AssetRegistry intercept ensures the
790 // webp URL resolves (LQIP bytes for Pending, real bytes for Ready);
791 // publish-mode synchronous encoding ensures it never 404s in
792 // production. data-placeholder-src is gone — iframe-bridge matches
793 // by URL substring now.
794 let s = snapshot_dims("photo.jpg", 800, 600);
795 let html = synthesize_image_html(
796 "photo.jpg",
797 "A cat",
798 &s,
799 ImageContext::MarkdownInline,
800 &ImageRenderOptions::default(),
801 );
802 assert_eq!(
803 html,
804 r#"<picture><source srcset="photo.webp" type="image/webp"><img src="photo.jpg" width="800" height="600" loading="lazy" alt="A cat" /></picture>"#
805 );
806 }
807
808 #[test]
809 fn picture_wrap_for_raster_original_with_lqip() {
810 // LQIP inline style is preserved (legitimate production technique;
811 // cf. Vercel `blurDataURL`). Shown to the user instantly while the
812 // actual bytes are being decoded.
813 let s = snapshot_with(
814 "photo.jpg",
815 Some((800, 600)),
816 None,
817 Some("data:image/jpeg;base64,abc"),
818 false,
819 );
820 let html = synthesize_image_html(
821 "photo.jpg",
822 "A cat",
823 &s,
824 ImageContext::MarkdownInline,
825 &ImageRenderOptions::default(),
826 );
827 assert_eq!(
828 html,
829 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>"#
830 );
831 }
832
833 #[test]
834 fn bare_img_with_dominant_color_no_lqip() {
835 let s = snapshot_with(
836 "photo.jpg",
837 Some((800, 600)),
838 Some("#aabbcc"),
839 None,
840 false,
841 );
842 let html = synthesize_image_html(
843 "photo.jpg",
844 "Cat",
845 &s,
846 ImageContext::MarkdownInline,
847 &ImageRenderOptions::default(),
848 );
849 assert!(
850 html.contains(r#"style="background-color:#aabbcc""#),
851 "Got: {html}"
852 );
853 }
854
855 // --- <picture> wrap (WebP variant present) ----------------------------
856
857 #[test]
858 fn picture_wrap_when_webp_exists() {
859 let s = snapshot_with("photo.jpg", Some((800, 600)), None, None, true);
860 let html = synthesize_image_html(
861 "photo.jpg",
862 "Cat",
863 &s,
864 ImageContext::MarkdownInline,
865 &ImageRenderOptions::default(),
866 );
867 // Outer: <picture>...</picture> with <source> first
868 assert!(
869 html.starts_with(r#"<picture><source srcset="photo.webp" type="image/webp"><img"#),
870 "Got: {html}"
871 );
872 assert!(html.ends_with(r#"alt="Cat" /></picture>"#), "Got: {html}");
873 }
874
875 #[test]
876 fn picture_srcset_uses_to_webp_of_src_not_stored_variant() {
877 // CJK-path case: <img src> already carries dir_overrides + relative
878 // prefix (e.g. ../assets/photo.jpg). srcset must derive from src
879 // (../assets/photo.webp), not the manifest's stored value.
880 // See wrap_img_in_picture's rationale at placeholder.rs:660.
881 let s = snapshot_with("../assets/photo.jpg", Some((800, 600)), None, None, true);
882 let html = synthesize_image_html(
883 "../assets/photo.jpg",
884 "",
885 &s,
886 ImageContext::MarkdownInline,
887 &ImageRenderOptions::default(),
888 );
889 assert!(
890 html.contains(r#"srcset="../assets/photo.webp""#),
891 "srcset should inherit src's prefix; got: {html}"
892 );
893 }
894
895 // --- eager loading ----------------------------------------------------
896
897 #[test]
898 fn eager_swaps_loading_attr_and_adds_fetchpriority() {
899 let s = snapshot_dims("photo.jpg", 1, 1);
900 let html = synthesize_image_html(
901 "photo.jpg",
902 "Hero",
903 &s,
904 ImageContext::Hero,
905 &ImageRenderOptions {
906 eager: true,
907 ..Default::default()
908 },
909 );
910 assert!(
911 html.contains(r#"loading="eager" fetchpriority="high""#),
912 "Got: {html}"
913 );
914 assert!(!html.contains(r#"loading="lazy""#), "Got: {html}");
915 }
916
917 // --- favicon short-circuit --------------------------------------------
918
919 #[test]
920 fn favicon_is_bare_16x16_with_class() {
921 // No manifest data for the favicon URL — synthesizer must NOT probe
922 // the snapshot (favicons are not registered as moss assets).
923 let s = AssetSnapshot::new();
924 let html = synthesize_image_html(
925 "https://example.com/favicon.ico",
926 "",
927 &s,
928 ImageContext::Favicon,
929 &ImageRenderOptions {
930 class: Some("link-preview-favicon"),
931 ..Default::default()
932 },
933 );
934 assert_eq!(
935 html,
936 r#"<img class="link-preview-favicon" src="https://example.com/favicon.ico" width="16" height="16" alt="">"#
937 );
938 }
939
940 // --- site-logo short-circuit (Phase 2B carve-out) --------------------
941
942 #[test]
943 fn synthesize_site_logo_basic_shape() {
944 let html = synthesize_image_html(
945 "assets/logo.png",
946 "",
947 &AssetSnapshot::new(),
948 ImageContext::SiteLogo,
949 &ImageRenderOptions::default(),
950 );
951 assert_eq!(
952 html,
953 r#"<img class="site-logo" src="assets/logo.png" alt="" aria-hidden="true">"#
954 );
955 }
956
957 #[test]
958 fn synthesize_site_logo_escapes_src() {
959 let html = synthesize_image_html(
960 r#"logo "with quotes".png"#,
961 "",
962 &AssetSnapshot::new(),
963 ImageContext::SiteLogo,
964 &ImageRenderOptions::default(),
965 );
966 assert!(
967 html.contains(r#"logo "with quotes".png"#),
968 "got: {html}"
969 );
970 }
971
972 #[test]
973 fn synthesize_site_logo_does_not_emit_picture_or_lqip() {
974 // Even when the snapshot would normally drive LQIP/<picture> for a
975 // png, SiteLogo must short-circuit and emit a bare <img>.
976 let mut snap = AssetSnapshot::new();
977 snap.lqip
978 .insert("logo.png".into(), "data:image/jpeg;base64,xxx".into());
979 let html = synthesize_image_html(
980 "logo.png",
981 "moss",
982 &snap,
983 ImageContext::SiteLogo,
984 &ImageRenderOptions::default(),
985 );
986 assert!(!html.contains("<picture"), "logo should not get picture wrap: {html}");
987 assert!(
988 !html.contains("background-image"),
989 "logo should not get LQIP: {html}"
990 );
991 assert!(
992 !html.contains("loading="),
993 "logo above-the-fold, no lazy-load: {html}"
994 );
995 }
996
997 // --- HTML-escape contract --------------------------------------------
998
999 #[test]
1000 fn alt_with_quotes_is_escaped() {
1001 let s = snapshot_dims("photo.jpg", 1, 1);
1002 let html = synthesize_image_html(
1003 "photo.jpg",
1004 r#"Quote: "hi""#,
1005 &s,
1006 ImageContext::MarkdownInline,
1007 &ImageRenderOptions::default(),
1008 );
1009 // moss_core::media::html_escape escapes " → " — never leave a
1010 // raw quote inside an attribute value.
1011 assert!(html.contains(r#"alt="Quote: "hi"""#), "Got: {html}");
1012 }
1013
1014 #[test]
1015 fn extra_attrs_passed_through_verbatim() {
1016 // :::hero passes `style="object-fit:cover;object-position:50% 50%"`
1017 // via MediaAttrs::to_inline_style — the caller pre-escapes, so we
1018 // just append.
1019 let s = snapshot_dims("hero.jpg", 1920, 1080);
1020 let html = synthesize_image_html(
1021 "hero.jpg",
1022 "",
1023 &s,
1024 ImageContext::Hero,
1025 &ImageRenderOptions {
1026 eager: true,
1027 extra_attrs: Some(r#"data-cover-fit="cover""#),
1028 ..Default::default()
1029 },
1030 );
1031 assert!(html.contains(r#"data-cover-fit="cover""#), "Got: {html}");
1032 // Extra attrs come AFTER alt — matches the current regex-pass order
1033 // where the regex preserves the original post-src attributes.
1034 let alt_pos = html.find("alt=").unwrap();
1035 let extra_pos = html.find("data-cover-fit=").unwrap();
1036 assert!(extra_pos > alt_pos, "extra attrs should come after alt");
1037 }
1038
1039 /// Regression test for impl-review finding 2026-05-16: when extra_attrs
1040 /// already carries a `style=` attribute (the `:::hero {attrs=...}` case
1041 /// when MediaAttrs::to_inline_style() returns Some), the synthesizer
1042 /// must NOT also emit its own LQIP-derived `style=`. The legacy regex
1043 /// pass had a `has_style` guard at `placeholder.rs:413-422` that did the
1044 /// same suppression. Without this, browsers see two `style=` attributes
1045 /// on one element, honor the last one, and drop the LQIP placeholder.
1046 #[test]
1047 fn lqip_style_suppressed_when_extra_attrs_has_style() {
1048 let s = snapshot_with(
1049 "hero.jpg",
1050 Some((1920, 1080)),
1051 None,
1052 Some("data:image/jpeg;base64,abc"),
1053 false,
1054 );
1055 let html = synthesize_image_html(
1056 "hero.jpg",
1057 "",
1058 &s,
1059 ImageContext::Hero,
1060 &ImageRenderOptions {
1061 eager: true,
1062 extra_attrs: Some(r#"style="object-fit:cover;object-position:50% 50%""#),
1063 ..Default::default()
1064 },
1065 );
1066 // Exactly one `style=` substring — the one the caller passed.
1067 assert_eq!(
1068 html.matches("style=").count(),
1069 1,
1070 "expected exactly one style= attribute; got: {html}"
1071 );
1072 // Confirm the caller's style is what survived (not the LQIP).
1073 assert!(
1074 html.contains(r#"style="object-fit:cover"#),
1075 "caller-supplied style must survive; got: {html}"
1076 );
1077 assert!(
1078 !html.contains(r#"background-image:url(data:"#),
1079 "LQIP must be suppressed when extra_attrs carries style=; got: {html}"
1080 );
1081 }
1082
1083 // --- Step 8: figure wrapper for MarkdownStandalone --------------------
1084
1085 /// Phase 1 C1 test helper: build an empty extras BTreeMap.
1086 fn empty_extras() -> std::collections::BTreeMap<String, String> {
1087 std::collections::BTreeMap::new()
1088 }
1089
1090 #[test]
1091 fn markdown_standalone_no_caption_wraps_in_figure() {
1092 // No caption → `<figure class="moss-image">…</figure>` around the
1093 // synthesized `<picture>`/`<img>` with no `<figcaption>`.
1094 let s = snapshot_dims("photo.jpg", 800, 600);
1095 let extras = empty_extras();
1096 let html = synthesize_image_html(
1097 "photo.jpg",
1098 "Alt text",
1099 &s,
1100 ImageContext::MarkdownStandalone {
1101 caption: None,
1102 width: None,
1103 align: None,
1104 class_names: &[],
1105 extra_attrs: &extras,
1106 },
1107 &ImageRenderOptions::default(),
1108 );
1109 assert!(html.starts_with(r#"<figure class="moss-image">"#));
1110 assert!(html.ends_with("</figure>"));
1111 assert!(!html.contains("<figcaption>"));
1112 assert!(html.contains("<img"));
1113 }
1114
1115 #[test]
1116 fn markdown_standalone_with_caption_adds_figcaption() {
1117 let s = snapshot_dims("photo.jpg", 800, 600);
1118 let extras = empty_extras();
1119 let html = synthesize_image_html(
1120 "photo.jpg",
1121 "Alt text",
1122 &s,
1123 ImageContext::MarkdownStandalone {
1124 caption: Some("A nice photo"),
1125 width: None,
1126 align: None,
1127 class_names: &[],
1128 extra_attrs: &extras,
1129 },
1130 &ImageRenderOptions::default(),
1131 );
1132 assert!(html.starts_with(r#"<figure class="moss-image">"#));
1133 assert!(html.contains("<figcaption>A nice photo</figcaption>"));
1134 assert!(html.ends_with("</figure>"));
1135 }
1136
1137 #[test]
1138 fn markdown_standalone_caption_is_html_escaped() {
1139 let s = snapshot_dims("photo.jpg", 800, 600);
1140 let extras = empty_extras();
1141 let html = synthesize_image_html(
1142 "photo.jpg",
1143 "",
1144 &s,
1145 ImageContext::MarkdownStandalone {
1146 caption: Some(r#"Q&A "best" of <em>2024</em>"#),
1147 width: None,
1148 align: None,
1149 class_names: &[],
1150 extra_attrs: &extras,
1151 },
1152 &ImageRenderOptions::default(),
1153 );
1154 assert!(
1155 html.contains("<figcaption>Q&A "best" of <em>2024</em></figcaption>"),
1156 "caption text must be HTML-escaped at the boundary; got: {html}"
1157 );
1158 }
1159
1160 #[test]
1161 fn markdown_standalone_wraps_picture_when_webp_present() {
1162 // When the manifest carries a WebP variant, the synthesizer emits
1163 // a `<picture>` wrap inside the figure: the structural figure
1164 // and the responsive picture compose without conflict.
1165 let s = snapshot_with("photo.jpg", Some((1200, 800)), None, None, true);
1166 let extras = empty_extras();
1167 let html = synthesize_image_html(
1168 "photo.jpg",
1169 "Alt",
1170 &s,
1171 ImageContext::MarkdownStandalone {
1172 caption: Some("Cap"),
1173 width: None,
1174 align: None,
1175 class_names: &[],
1176 extra_attrs: &extras,
1177 },
1178 &ImageRenderOptions::default(),
1179 );
1180 // Structural order: figure > picture > source + img > figcaption
1181 let fig_idx = html.find(r#"<figure class="moss-image">"#).expect("figure");
1182 let pic_idx = html.find("<picture>").expect("picture");
1183 let src_idx = html.find("<source").expect("source");
1184 let img_idx = html.find("<img").expect("img");
1185 let cap_idx = html.find("<figcaption>").expect("figcaption");
1186 let fig_close = html.find("</figure>").expect("figure close");
1187 assert!(fig_idx < pic_idx, "<picture> must be inside <figure>");
1188 assert!(pic_idx < src_idx);
1189 assert!(src_idx < img_idx);
1190 assert!(img_idx < cap_idx, "<figcaption> follows <picture>");
1191 assert!(cap_idx < fig_close);
1192 }
1193
1194 #[test]
1195 fn markdown_inline_does_not_wrap_in_figure() {
1196 // Inline images NEVER get a figure wrapper — they sit in prose.
1197 let s = snapshot_dims("photo.jpg", 800, 600);
1198 let html = synthesize_image_html(
1199 "photo.jpg",
1200 "Alt",
1201 &s,
1202 ImageContext::MarkdownInline,
1203 &ImageRenderOptions::default(),
1204 );
1205 assert!(!html.contains("<figure"));
1206 assert!(!html.contains("<figcaption>"));
1207 }
1208
1209 // --- spec § P9 width: `data-width` on the figure wrapper -------------
1210
1211 #[test]
1212 fn markdown_standalone_width_screen_emits_data_width_on_figure() {
1213 // Width pipe-alias `![[photo.jpg|full]]` → `screen` lands on the
1214 // figure wrapper, not the inner img. The image-side test below
1215 // pins the "absent by default" half of the contract.
1216 let s = snapshot_dims("photo.jpg", 800, 600);
1217 let extras = empty_extras();
1218 let html = synthesize_image_html(
1219 "photo.jpg",
1220 "",
1221 &s,
1222 ImageContext::MarkdownStandalone {
1223 caption: None,
1224 width: Some("screen"),
1225 align: None,
1226 class_names: &[],
1227 extra_attrs: &extras,
1228 },
1229 &ImageRenderOptions::default(),
1230 );
1231 assert!(
1232 html.starts_with(r#"<figure class="moss-image" data-width="screen">"#),
1233 "data-width must sit on the figure wrapper; got: {html}"
1234 );
1235 // The inner img must NOT carry data-width — the attribute is the
1236 // wrapper's responsibility per spec.
1237 assert!(
1238 !html.contains(r#"<img"#) || !html[html.find("<img").unwrap()..].contains("data-width="),
1239 "inner <img> must not carry data-width; got: {html}"
1240 );
1241 }
1242
1243 #[test]
1244 fn markdown_standalone_width_wide_with_caption() {
1245 // width + caption compose: both attributes / children appear in
1246 // the wrapper.
1247 let s = snapshot_dims("photo.jpg", 800, 600);
1248 let extras = empty_extras();
1249 let html = synthesize_image_html(
1250 "photo.jpg",
1251 "Alt",
1252 &s,
1253 ImageContext::MarkdownStandalone {
1254 caption: Some("A nice photo"),
1255 width: Some("wide"),
1256 align: None,
1257 class_names: &[],
1258 extra_attrs: &extras,
1259 },
1260 &ImageRenderOptions::default(),
1261 );
1262 assert!(html.contains(r#"data-width="wide""#), "got: {html}");
1263 assert!(
1264 html.contains("<figcaption>A nice photo</figcaption>"),
1265 "got: {html}"
1266 );
1267 }
1268
1269 #[test]
1270 fn markdown_standalone_width_none_omits_data_width() {
1271 // Negative test: the default (no width) must not emit the attribute,
1272 // so theme authors can target the absence via `:not([data-width])`.
1273 let s = snapshot_dims("photo.jpg", 800, 600);
1274 let extras = empty_extras();
1275 let html = synthesize_image_html(
1276 "photo.jpg",
1277 "",
1278 &s,
1279 ImageContext::MarkdownStandalone {
1280 caption: None,
1281 width: None,
1282 align: None,
1283 class_names: &[],
1284 extra_attrs: &extras,
1285 },
1286 &ImageRenderOptions::default(),
1287 );
1288 assert!(!html.contains("data-width="), "got: {html}");
1289 }
1290
1291 #[test]
1292 fn wrap_in_figure_width_emits_data_width_attribute() {
1293 // Direct-call contract test: `wrap_in_figure` is the single source
1294 // of truth for the figure wrapper byte shape; the raw-HTML branch
1295 // of `emit_standalone_figure_image` calls it with the lifted width.
1296 let html = wrap_in_figure(r#"<img src="x" />"#, None, Some("page"));
1297 assert_eq!(
1298 html,
1299 r#"<figure class="moss-image" data-width="page"><img src="x" /></figure>"#
1300 );
1301 }
1302
1303 #[test]
1304 fn wrap_in_figure_width_with_caption() {
1305 let html = wrap_in_figure(r#"<img src="x" />"#, Some("hello"), Some("screen"));
1306 assert_eq!(
1307 html,
1308 r#"<figure class="moss-image" data-width="screen"><img src="x" /><figcaption>hello</figcaption></figure>"#
1309 );
1310 }
1311
1312 #[test]
1313 fn wrap_in_figure_no_width_no_attribute() {
1314 let html = wrap_in_figure(r#"<img src="x" />"#, None, None);
1315 assert_eq!(
1316 html,
1317 r#"<figure class="moss-image"><img src="x" /></figure>"#
1318 );
1319 }
1320
1321 #[test]
1322 fn markdown_standalone_no_manifest_still_wraps_in_figure() {
1323 // The wrapper is structural identity (Step 8 contract), not
1324 // manifest-dependent. Even with an empty AssetSnapshot (test/
1325 // fragment-render path), `<figure class="moss-image">` still wraps
1326 // the synthesized `<img>`. The Phase 1 B1 migration (2026-05-25)
1327 // replaced the `Option<&MediaDimensionLookup>` parameter with
1328 // `&AssetSnapshot`; the empty-snapshot path is now the test
1329 // equivalent of the prior `None` lookup.
1330 let s = AssetSnapshot::new();
1331 let extras = empty_extras();
1332 let html = synthesize_image_html(
1333 "photo.jpg",
1334 "Alt",
1335 &s,
1336 ImageContext::MarkdownStandalone {
1337 caption: Some("Cap"),
1338 width: None,
1339 align: None,
1340 class_names: &[],
1341 extra_attrs: &extras,
1342 },
1343 &ImageRenderOptions::default(),
1344 );
1345 assert!(html.starts_with(r#"<figure class="moss-image">"#));
1346 assert!(html.contains("<img"));
1347 assert!(html.contains("<figcaption>Cap</figcaption>"));
1348 assert!(html.ends_with("</figure>"));
1349 }
1350
1351 // --- size fallback ----------------------------------------------------
1352
1353 #[test]
1354 fn missing_dimensions_fall_back_to_800x600() {
1355 let s = AssetSnapshot::new();
1356 let html = synthesize_image_html(
1357 "ghost.jpg",
1358 "",
1359 &s,
1360 ImageContext::MarkdownInline,
1361 &ImageRenderOptions::default(),
1362 );
1363 // FALLBACK_WIDTH / FALLBACK_HEIGHT (still 800×600, sourced from
1364 // `moss_core::asset_snapshot::FALLBACK_WIDTH` so the synthesizer and
1365 // the surviving regex pass agree on the absent-dims default).
1366 assert!(html.contains(r#"width="800" height="600""#), "Got: {html}");
1367 }
1368
1369 // --- regex-pass idempotency on synthesizer output ----------------------
1370 //
1371 // Phase 2E v5 PR5 (2026-05-26) retired the Stage 3 regex post-pass; the
1372 // image synthesizer in this module is now the sole emitter of width /
1373 // height / loading / LQIP / dominant-color attributes for moss-emitted
1374 // <img> tags. The three idempotency tests at
1375 // `src-tauri/tests/image_synth_regex_parity.rs` that guarded the
1376 // regex+synth byte-shape parity were deleted alongside the regex.
1377
1378 // --- TrackingPixel (Phase 2C, 2026-05-25) ---
1379 //
1380 // The RSS read-tracking pixel is a 1×1 invisible <img>. It must fire on
1381 // read (so NO loading="lazy"), carry empty alt (decorative), and never
1382 // be wrapped in <picture> / decorated with LQIP. Self-closing form
1383 // because the call site embeds it in CDATA-wrapped RSS XML.
1384
1385 #[test]
1386 fn synthesize_tracking_pixel_basic() {
1387 let html = synthesize_image_html(
1388 "https://api.mosspub.com/pixel.gif?u=abc",
1389 "",
1390 &AssetSnapshot::new(),
1391 ImageContext::TrackingPixel,
1392 &ImageRenderOptions::default(),
1393 );
1394 assert_eq!(
1395 html,
1396 r#"<img src="https://api.mosspub.com/pixel.gif?u=abc" alt="" width="1" height="1" />"#
1397 );
1398 }
1399
1400 #[test]
1401 fn synthesize_tracking_pixel_escapes_url() {
1402 let html = synthesize_image_html(
1403 r#"x.gif?u=a&b="c""#,
1404 "",
1405 &AssetSnapshot::new(),
1406 ImageContext::TrackingPixel,
1407 &ImageRenderOptions::default(),
1408 );
1409 assert!(html.contains("&"), "& must be escaped");
1410 assert!(html.contains("""), "\" must be escaped");
1411 }
1412
1413 #[test]
1414 fn synthesize_tracking_pixel_no_lazy_no_lqip() {
1415 // Even when the snapshot contains LQIP / dimensions for the pixel
1416 // path, the TrackingPixel short-circuit must ignore them — pixels
1417 // are tracking beacons, not images.
1418 let mut snap = AssetSnapshot::new();
1419 snap.lqip.insert(
1420 "pixel.gif".into(),
1421 "data:image/jpeg;base64,xxx".into(),
1422 );
1423 let html = synthesize_image_html(
1424 "pixel.gif",
1425 "",
1426 &snap,
1427 ImageContext::TrackingPixel,
1428 &ImageRenderOptions::default(),
1429 );
1430 assert!(
1431 !html.contains("loading=\"lazy\""),
1432 "must NOT lazy-load (must fire on read)"
1433 );
1434 assert!(
1435 !html.contains("background-image"),
1436 "must NOT carry LQIP"
1437 );
1438 assert!(
1439 !html.contains("<picture"),
1440 "must NOT wrap in picture"
1441 );
1442 }
1443
1444 // --- ImageContext::EmailBody (Phase 2D, 2026-05-25) -------------------
1445 //
1446 // Email-client-safe carve-out: no <picture>, no data-*, no loading=lazy.
1447 // Inline `style="display:block;max-width:100%;height:auto;"` is the
1448 // cross-client responsive pattern. width/height attrs are Option<u32>:
1449 // emitted when known, omitted when None.
1450
1451 #[test]
1452 fn synthesize_email_body_with_dims() {
1453 let html = synthesize_image_html(
1454 "https://media.example.com/photo.jpg",
1455 "Cover",
1456 &AssetSnapshot::new(),
1457 ImageContext::EmailBody {
1458 width: Some(600),
1459 height: Some(400),
1460 },
1461 &ImageRenderOptions::default(),
1462 );
1463 assert!(html.contains(r#"width="600""#));
1464 assert!(html.contains(r#"height="400""#));
1465 assert!(html.contains(r#"style="display:block;max-width:100%;height:auto;""#));
1466 }
1467
1468 #[test]
1469 fn synthesize_email_body_without_dims() {
1470 let html = synthesize_image_html(
1471 "x.jpg",
1472 "alt",
1473 &AssetSnapshot::new(),
1474 ImageContext::EmailBody {
1475 width: None,
1476 height: None,
1477 },
1478 &ImageRenderOptions::default(),
1479 );
1480 assert!(!html.contains("width="), "width should be omitted when None");
1481 assert!(!html.contains("height="), "height should be omitted when None");
1482 }
1483
1484 #[test]
1485 fn synthesize_email_body_no_picture_no_data() {
1486 let html = synthesize_image_html(
1487 "photo.jpg",
1488 "alt",
1489 &AssetSnapshot::new(),
1490 ImageContext::EmailBody {
1491 width: None,
1492 height: None,
1493 },
1494 &ImageRenderOptions::default(),
1495 );
1496 assert!(!html.contains("<picture"), "email images must not use <picture>");
1497 assert!(!html.contains("<source"), "no <source>");
1498 assert!(!html.contains("data-"), "no data-* (email clients strip)");
1499 assert!(!html.contains("loading="), "email clients ignore loading attr");
1500 assert!(
1501 !html.contains("background-image"),
1502 "email clients strip inline style URLs"
1503 );
1504 }
1505
1506 #[test]
1507 fn synthesize_email_body_escapes() {
1508 let html = synthesize_image_html(
1509 r#"https://x.com/photo.jpg?a=1&b=2"#,
1510 r#"alt with "quotes""#,
1511 &AssetSnapshot::new(),
1512 ImageContext::EmailBody {
1513 width: None,
1514 height: None,
1515 },
1516 &ImageRenderOptions::default(),
1517 );
1518 assert!(html.contains("&"), "& must be escaped");
1519 assert!(html.contains("""), "\" must be escaped");
1520 }
1521
1522 // --- ImageContext::GalleryThumb (Phase 2E v5 PR3, 2026-05-26) ---------
1523 //
1524 // Gallery body images: below-the-fold thumbnail, same inner byte
1525 // shape as MarkdownInline (`<picture><source srcset=*.webp><img
1526 // loading="lazy" ...></picture>` for raster, bare `<img>` for
1527 // non-raster). The outer `.moss-gallery-item` wrapper is owned by
1528 // `DefaultHooks::render_shortcode`'s Gallery arm; this variant
1529 // emits only the inner image. Distinguishing it from
1530 // MarkdownInline at the type level keeps per-item passthrough
1531 // attributes (object-position from MediaAttrs) typed for future
1532 // evolution.
1533
1534 #[test]
1535 fn synthesize_gallery_thumb_emits_picture_with_lazy() {
1536 let p = ImageRenderOptions::default();
1537 let mut snap = AssetSnapshot::new();
1538 snap.dimensions
1539 .insert(PathBuf::from("photo.jpg"), (1200, 800));
1540 let out = synthesize_image_html(
1541 "photo.jpg",
1542 "alt",
1543 &snap,
1544 ImageContext::GalleryThumb,
1545 &p,
1546 );
1547 assert!(out.contains("<picture"), "{out}");
1548 assert!(out.contains(r#"srcset="photo.webp""#), "{out}");
1549 assert!(out.contains(r#"loading="lazy""#), "{out}");
1550 assert!(out.contains(r#"width="1200""#), "{out}");
1551 assert!(out.contains(r#"height="800""#), "{out}");
1552 assert!(out.contains(r#"alt="alt""#), "{out}");
1553 }
1554
1555 #[test]
1556 fn synthesize_gallery_thumb_non_raster_bare_img() {
1557 // SVG / .webp originals don't trigger the <picture> wrap (no
1558 // variant exists). Falls back to bare <img> with lazy loading +
1559 // dims from the snapshot.
1560 let p = ImageRenderOptions::default();
1561 let mut snap = AssetSnapshot::new();
1562 snap.dimensions
1563 .insert(PathBuf::from("icon.svg"), (64, 64));
1564 let out = synthesize_image_html(
1565 "icon.svg",
1566 "",
1567 &snap,
1568 ImageContext::GalleryThumb,
1569 &p,
1570 );
1571 assert!(!out.contains("<picture"), "{out}");
1572 assert!(!out.contains("<source"), "{out}");
1573 assert!(out.contains(r#"loading="lazy""#), "{out}");
1574 assert!(out.contains(r#"width="64""#), "{out}");
1575 }
1576
1577 #[test]
1578 fn synthesize_gallery_thumb_threads_extra_attrs() {
1579 // The Gallery hook builds a `style="object-position:..."` fragment
1580 // from MediaAttrs and passes it via extra_attrs. The synthesizer
1581 // suppresses its own LQIP-derived style= when extra_attrs already
1582 // carries one — verify that suppression engages here too
1583 // (parity with Hero / MarkdownInline).
1584 let snap = snapshot_with(
1585 "photo.jpg",
1586 Some((1200, 800)),
1587 None,
1588 Some("data:image/jpeg;base64,abc"),
1589 false,
1590 );
1591 let opts = ImageRenderOptions {
1592 extra_attrs: Some(r#"style="object-position:50% 50%""#),
1593 ..Default::default()
1594 };
1595 let out = synthesize_image_html(
1596 "photo.jpg",
1597 "",
1598 &snap,
1599 ImageContext::GalleryThumb,
1600 &opts,
1601 );
1602 assert_eq!(
1603 out.matches("style=").count(),
1604 1,
1605 "expected exactly one style= attribute; got: {out}"
1606 );
1607 assert!(
1608 out.contains(r#"style="object-position:50% 50%""#),
1609 "caller-supplied style must survive; got: {out}"
1610 );
1611 }
1612
1613 // --- ImageContext::HeroBare (Phase 2E PR2, 2026-05-26) ----------------
1614 //
1615 // The no-snapshot hero fallback. Emits a bare `<img>` with no
1616 // `<picture>`, no `<source>`, no LQIP, no dims, no `loading` attr.
1617 // The asset-publish invariant rules out emitting a
1618 // `<source srcset="*.webp">` for an unregistered variant — this
1619 // variant is the explicit opt-out for code paths that run before
1620 // `AssetRegistry::set_pending` has been called for the source's
1621 // `.webp` companion (test/fragment-render paths). The byte shape
1622 // mirrors the pre-PR2 fallback at
1623 // `typed_renderers.rs::render_hero_html_typed` lines 554-557.
1624
1625 #[test]
1626 fn synthesize_hero_bare_basic_shape() {
1627 let out = synthesize_image_html(
1628 "cover.jpg",
1629 "",
1630 &AssetSnapshot::new(),
1631 ImageContext::HeroBare,
1632 &ImageRenderOptions::default(),
1633 );
1634 // No <picture>, no <source>, no class, no loading, no LQIP, no
1635 // width/height attrs. Exact byte shape with empty alt.
1636 assert_eq!(out, r#"<img src="cover.jpg" alt="" />"#, "got: {}", out);
1637 }
1638
1639 #[test]
1640 fn synthesize_hero_bare_with_style_via_extra_attrs() {
1641 // The legacy fallback passed an inline `style="..."` fragment
1642 // built from MediaAttrs::to_inline_style(). PR2 threads that
1643 // fragment through `ImageRenderOptions::extra_attrs` — the
1644 // synthesizer prepends a single space, matching the legacy byte
1645 // shape.
1646 let out = synthesize_image_html(
1647 "cover.jpg",
1648 "",
1649 &AssetSnapshot::new(),
1650 ImageContext::HeroBare,
1651 &ImageRenderOptions {
1652 extra_attrs: Some(r#"style="object-fit:cover;object-position:50% 50%""#),
1653 ..Default::default()
1654 },
1655 );
1656 assert_eq!(
1657 out,
1658 r#"<img src="cover.jpg" alt="" style="object-fit:cover;object-position:50% 50%" />"#,
1659 "got: {}",
1660 out
1661 );
1662 }
1663
1664 #[test]
1665 fn synthesize_hero_bare_with_lqip_in_snapshot_still_bare() {
1666 // Even when the snapshot has LQIP / dims for the source path,
1667 // the HeroBare variant must ignore them — the no-snapshot signal
1668 // is structural (this variant exists precisely because no
1669 // AssetRegistry has been primed), not data-driven.
1670 let mut snap = AssetSnapshot::new();
1671 snap.lqip
1672 .insert("cover.jpg".into(), "data:image/jpeg;base64,xxx".into());
1673 snap.dimensions.insert("cover.jpg".into(), (1920, 1080));
1674 let out = synthesize_image_html(
1675 "cover.jpg",
1676 "",
1677 &snap,
1678 ImageContext::HeroBare,
1679 &ImageRenderOptions::default(),
1680 );
1681 assert!(!out.contains("<picture"), "got: {}", out);
1682 assert!(!out.contains("<source"), "got: {}", out);
1683 assert!(!out.contains("background-image"), "got: {}", out);
1684 assert!(!out.contains("width="), "got: {}", out);
1685 assert!(!out.contains("height="), "got: {}", out);
1686 assert!(!out.contains("loading="), "got: {}", out);
1687 }
1688
1689 #[test]
1690 fn synthesize_hero_bare_escapes_url() {
1691 // The synthesizer's html_escape covers `&` and `"`; the legacy
1692 // fallback used the same `html_escape` from build::media::cover.
1693 let out = synthesize_image_html(
1694 r#"x.jpg?a=1&b="c""#,
1695 "",
1696 &AssetSnapshot::new(),
1697 ImageContext::HeroBare,
1698 &ImageRenderOptions::default(),
1699 );
1700 assert!(out.contains("&"), "& must be escaped; got: {}", out);
1701 assert!(out.contains("""), "\" must be escaped; got: {}", out);
1702 }
1703
1704 #[test]
1705 fn synthesize_hero_bare_byte_shape_matches_legacy_fallback() {
1706 // Pin the byte shape against a literal reconstruction of the
1707 // pre-PR2 emission so a future "tidy" of the synthesizer's
1708 // HeroBare branch can't drift away from the legacy fallback
1709 // without flipping this assertion deliberately.
1710 //
1711 // Legacy line:
1712 // format!("<img src=\"{}\" alt=\"\"{} />", html_escape(href), style)
1713 // where `style` was `""` or ` style="..."` (with leading space).
1714 let href = "covers/img.jpg";
1715 let style_fragment = r#" style="object-fit:contain""#;
1716 let legacy = format!(
1717 "<img src=\"{}\" alt=\"\"{} />",
1718 html_escape(href),
1719 style_fragment,
1720 );
1721
1722 // PR2 path: thread the style through extra_attrs minus the leading
1723 // space (matches typed_renderers.rs migration).
1724 let pr2 = synthesize_image_html(
1725 href,
1726 "",
1727 &AssetSnapshot::new(),
1728 ImageContext::HeroBare,
1729 &ImageRenderOptions {
1730 extra_attrs: Some(style_fragment.trim_start()),
1731 ..Default::default()
1732 },
1733 );
1734 assert_eq!(pr2, legacy, "byte shape divergence: pr2={} legacy={}", pr2, legacy);
1735 }
1736}