ontoref-outreach/scripts/decks/lib/project-assets.mjs
2026-07-17 01:00:32 +01:00

113 lines
6 KiB
JavaScript

// 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(/"|&#0*34;/gi, '"')
.replace(/'|&#0*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 (`<img width="120">`) → 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(/<img\b[^>]*>/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(/<img\b/i, `<img style="${merged}"`)
})
html = html
.replace(/(<img[^>]+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 (&quot;), 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(&quot;${p}&quot;)` : 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) }
}