113 lines
6 KiB
JavaScript
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(/"|�*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 (`<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 ("), 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) }
|
|
}
|