// Re-point every asset reference at the site's origin, and refuse to emit anything that // would 404. A silently-missing image is precisely how the page goes back to lying about // its own content — which is the failure this whole projection exists to end. import { writeFile, mkdir, copyFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { basename, join } from 'node:path' import { PRESENTATION, postcss, publicBase, publicDir } from './paths.mjs' export async function projectAssets(html, css, id, { apply = true, dist } = {}) { const DIST = dist const base = `${publicBase(id)}/assets` const wanted = new Map() const unresolved = [] // Where a deck asset can live, in resolution order: // 1. the BUILD dir — everything Vite bundled + the root of the Slidev publicDir; // 2. presentation/public — the configured publicDir, whose subtrees (images/) Slidev does // NOT always copy into the build; // 3. presentation/ — the deck's own dir, because a per-slide `background: ./images/foo.jpg` // is relative to the MARKDOWN FILE, and `./images/` there means presentation/images/. // Miss any of these and the background vanishes with no error — the projection just emits a // dead path. Measured on Rustikon: 9 of 11 backgrounds lived at (1), 2 only at (3). const ROOTS = [DIST, join(PRESENTATION, 'public'), PRESENTATION] // A ref lifted from a serialized DOM attribute is HTML-encoded: a Vue-computed // `background: url('/arch-diag-v2.svg')` comes back as url("/arch-diag-v2.svg"). // Decode the entities BEFORE stripping quotes, or the ref starts with `&` instead of a path. const decode = (s) => s .replace(/"|�*34;/gi, '"') .replace(/'|�*39;/gi, "'") .replace(/&/gi, '&') const claim = (ref) => { const clean = decode(ref.trim()).trim().replace(/^["']|["']$/g, '') if (/^(data:|https?:|#|mailto:)/i.test(clean)) return null // external / inline: not ours // Slidev backgrounds arrive as `/foo.jpg`, `./foo.jpg`, `./images/foo.jpg` or bare `foo.jpg` // — all rooted at the deck's public dir. Normalize to a single relative path and look for it. const rel = clean.replace(/^\.?\//, '') if (!rel || rel.includes('..')) { unresolved.push({ ref: clean, why: 'ref inválida' }); return null } let onDisk = null for (const root of ROOTS) { const cand = join(root, rel) if (existsSync(cand)) { onDisk = cand; break } } if (!onDisk) { unresolved.push({ ref: clean, why: 'no está en el build ni en presentation/public' }); return null } const pub = `${base}/${basename(rel)}` wanted.set(clean, { onDisk, pub }) return pub } // Presentational width/height attributes (``) → inline style. They are the // author's sizing intent, but they are AUTHOR-origin presentational HINTS, and the scoped // bundle's `all: revert` guard wipes the whole author origin — so the hint vanishes and the // image balloons to its intrinsic size. Measured: ferris-heart.svg at width="120" rendered at // 4417px and bled the slide to 1169px; failed.png at width="100" filled the frame. The deck's // real styles survive the guard because they are `.deck-read …` rules that re-apply after it; // an attribute is not a rule and nothing restores it. An INLINE style does — it outranks any // non-important rule, `all: revert` included. Utility classes (w-40, -mt-5) are untouched: they // are rules in the bundle, not attributes. html = html.replace(/]*>/gi, (tag) => { const w = tag.match(/\bwidth\s*=\s*["']?([\d.]+%?)["']?/i)?.[1] const h = tag.match(/\bheight\s*=\s*["']?([\d.]+%?)["']?/i)?.[1] if (!w && !h) return tag const sm = tag.match(/\bstyle\s*=\s*"([^"]*)"/i) const existing = sm ? sm[1] : '' const add = [] if (w && !/\bwidth\s*:/i.test(existing)) add.push(`width:${w.endsWith('%') ? w : w + 'px'}`) if (h && !/\bheight\s*:/i.test(existing)) add.push(`height:${h.endsWith('%') ? h : h + 'px'}`) if (!add.length) return tag const merged = [add.join(';'), existing].filter(Boolean).join(';') // attr first, existing style wins return sm ? tag.replace(sm[0], `style="${merged}"`) : tag.replace(/]+src=")([^"]+)(")/g, (m, a, ref, z) => { const p = claim(ref); return p ? a + p + z : m }) // ALL url(...), not only the one glued to `background-image:` — Slidev emits // `background-image: linear-gradient(...), url(...)`, where the url is after the gradient, // so an anchored regex missed every per-slide background. Anchored at `url(` so the // gradient's own parens are never touched, and the whole inner is handed to claim(), which // already decodes entities and strips quotes. // // Re-emit with HTML-ENCODED quotes ("), the exact form Slidev used. This is inside a // double-quoted style="" attribute: writing literal url("…") closes the attribute at the // first inner " and the browser parses the path as empty — the served HTML looked right in // curl and computed to url("") in the browser. Measured on every Rustikon background. .replace(/url\(([^)]*)\)/gi, (m, inner) => { const p = claim(inner); return p ? `url("${p}")` : m }) const root = postcss.parse(css) root.walkDecls(d => { if (!d.value.includes('url(')) return d.value = d.value.replace(/url\(([^)]+)\)/g, (m, ref) => { const p = claim(ref) return p ? `url("${p}")` : m }) }) css = root.toString() if (unresolved.length) { const lines = unresolved.map(u => ` ${u.ref} — ${u.why}`).join('\n') throw new Error(`${unresolved.length} referencia(s) sin resolver; no se emite un deck con assets rotos:\n${lines}`) } if (apply) { const dest = join(publicDir(id), 'assets') await mkdir(dest, { recursive: true }) for (const [, v] of wanted) await copyFile(v.onDisk, join(dest, basename(v.pub))) } return { html, css, copied: [...wanted.values()].map(v => v.pub) } }