/* RFC-0172: pure harvester + formatter for the post-build image sitemap. Reads rendered HTML (supplied by the caller) and extracts the page's single lead/content image, then formats a Google image sitemap. Framework-agnostic — the site-OS dist.sitemap.images.generate command walks dist/client and writes the result; this module touches no filesystem. Do not read files or fetch HTML — the caller supplies the HTML string. Do not construct /_astro or /cdn-cgi/image URLs — only read what the render emitted (RFC-0152). Do not apply policy/severity — the command decides pass/fail from the harvest result. RFC-0172: initial implementation. */ /** One resolved image entry harvested from rendered HTML. */ export interface SitemapImageEntry { /** Absolute page URL (canonical, from the document). */ loc: string; /** Absolute image URL; provider-emitted (/_astro/ or /cdn-cgi/image/...). */ imageUrl: string; /** From the `` / `og:image:alt`, when present. */ title?: string; } /** Result of harvesting one rendered HTML document. */ export interface ContentImageHarvest { /** Canonical absolute page URL, or null when no canonical link is present. */ loc: string | null; /** All distinct content-image signals found, absolutized. >1 is a uniqueness violation. */ imageUrls: string[]; /** Best-effort image title (alt text), when discoverable. */ title?: string; } /** * True when the HTML is a meta-refresh redirect stub (no real content) — e.g. the * RFC-0160 prefixed-default-language `/de/…` → `/…` stubs. * * Note: we deliberately do NOT treat `window.location` as a stub signal. Full * content pages (the root home, RFC-0159) carry a *soft* client-side language * redirect for non-default browser locales while still serving complete HTML; * those must be harvested, not skipped. */ export function isHtmlRedirectPage(html: string): boolean { return /]+http-equiv=["']refresh["']/i.test(html); } /** RFC-0150 preview screenshots and the og-image fallback are NOT content images. */ export function isSyntheticPreviewUrl(url: string): boolean { return /\/preview\/[^"']*\.png(\?|$)/i.test(url) || /\/og-image\.png(\?|$)/i.test(url); } function extractCanonical(html: string, siteUrl: string): string | null { const m = html.match(/]+rel=["']canonical["'][^>]*href=["']([^"']+)["']/i); if (!m?.[1]) return null; return absolutize(m[1], siteUrl); } /** Absolutize a possibly-relative URL against the site origin. Returns null if unresolvable. */ function absolutize(url: string, siteUrl: string): string | null { try { return new URL(url, ensureTrailingSlash(siteUrl)).toString(); } catch { return null; } } function ensureTrailingSlash(origin: string): string { return origin.endsWith("/") ? origin : `${origin}/`; } /** Pull the value of a single attribute from an HTML tag string. */ function attr(tag: string, name: string): string | undefined { const m = tag.match(new RegExp(`${name}=["']([^"']*)["']`, "i")); return m?.[1]; } /** * Harvest the page's content image(s) from one rendered HTML document. * * Two signal sources, unified (RFC-0172): * 1. `` — render-resolved hero `leadImage` (hashed URL). * 2. `` — authored `output.image` (absolute). * * Returns every distinct, absolutized URL found so the caller can enforce the * one-per-page contract. Synthetic preview/og-image URLs are excluded here. */ export function harvestContentImage(html: string, siteUrl: string): ContentImageHarvest { const loc = extractCanonical(html, siteUrl); const urls = new Set(); let title: string | undefined; // 1. const imgRe = /]*\bdata-content-image\b[^>]*>/gi; let im: RegExpExecArray | null; while ((im = imgRe.exec(html)) !== null) { const tag = im[0]; const src = attr(tag, "src"); if (!src) continue; const abs = absolutize(src, siteUrl); if (!abs || isSyntheticPreviewUrl(abs)) continue; urls.add(abs); if (!title) title = attr(tag, "alt") || undefined; } // 2. const metaRe = /]+name=["']x-content-image["'][^>]*content=["']([^"']+)["'][^>]*>/gi; let mm: RegExpExecArray | null; while ((mm = metaRe.exec(html)) !== null) { const abs = absolutize(mm[1], siteUrl); if (!abs || isSyntheticPreviewUrl(abs)) continue; urls.add(abs); } // Fallback title from og:image:alt when an alt was unavailable. if (!title) { const altMeta = html.match( /]+property=["']og:image:alt["'][^>]*content=["']([^"']+)["']/i, ); if (altMeta?.[1]) title = altMeta[1]; } return { loc, imageUrls: [...urls], ...(title ? { title } : {}) }; } function escapeXml(value: string): string { return value .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } /** * Render the image sitemap XML. Each entry becomes one `` with an * `` child. An empty entry list yields a valid empty urlset. * The caller is responsible for prepending the GENERATED marker if desired. */ export function generateImageSitemapXml(entries: SitemapImageEntry[]): string { const urls = entries .map((e) => { const titleXml = e.title ? `\n ${escapeXml(e.title)}` : ""; return ` \n ${escapeXml(e.loc)}\n \n ${escapeXml( e.imageUrl, )}${titleXml}\n \n `; }) .join("\n"); return `\n\n${urls}\n`; }