decks: the slides stop being pixels — Slidev's own render, captured and re-homed
The published deck was a hand-written carousel of exported PNGs: the words of every
slide were an IMAGE of words. Nothing indexed them, no glossary tooltip could reach
them, no screen reader read them, and the page drifted from the source the moment a
slide changed.
The deck is NOT re-parsed. Slidev's markdown is not standard markdown, and its meaning
does not live in the markdown anyway — it lives in a compiled bundle of UnoCSS
utilities, theme rules and <style scoped> blocks. Re-implementing that is
re-implementing a renderer, in any language. So Slidev renders (its own /print route,
the one `slidev export` drives) and we capture what it produced: the DOM, and the CSS
the browser actually applied.
Measured on the served page: 0 → 2152 indexable words, 16/16 slides reachable, 0
third-party origins added, 0 broken references.
Twelve traps, none of which raised an error:
· The CSS must be asked of the BROWSER (17 stylesheets; index.html links 2).
· Collapsing :root/html/body imports the SPA's viewport cage — it clipped the deck
at 900px and swallowed 14 of 16 slides, silently.
· UnoCSS's preflight, scoped, kills the glossary underline it was supposed to enable.
· :not() inherits its argument's specificity — the boundary guard grew with every
exemption until it reverted the slide scaling. :not(:where(…)) contributes zero.
· scale(calc(100cqw / 980)) is invalid CSS and is dropped in silence; on a phone the
box was 328px while the content stayed 980px and two thirds of every slide was cut.
· The site's markdown renderer destroys an inline <script>: curly quotes, injected
<p>, &&. CSS and JS go to external files — correctness, not optimisation.
· The 980x552 frame CUT 12 of 16 slides (Slidev cuts them too; nobody had seen it,
because the slides were PNGs of themselves).
· transform: translateY(0) — an IDENTITY transform — makes <main> the containing
block for position:fixed, and the zoom modal landed at top = -221px.
Two views over one DOM (slider default, stacked reads with Ctrl+F), an index derived
from each slide's own H1, a sticky rail, zoom, and Slidev's own copy buttons revived —
they shipped in the captured DOM at 0x0, unclickable by any human.
Twelve gates (A–L), and IN THE CHAIN: `just decks` in sync-full, `decks-check` in
`check` (on disk), `decks-served` in `check-live` (the page). A generator outside the
chain is a generator that does not run.
2026-07-14 19:41:51 +01:00
|
|
|
// 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'
|
2026-07-17 01:00:32 +01:00
|
|
|
import { PRESENTATION, postcss, publicBase, publicDir } from './paths.mjs'
|
decks: the slides stop being pixels — Slidev's own render, captured and re-homed
The published deck was a hand-written carousel of exported PNGs: the words of every
slide were an IMAGE of words. Nothing indexed them, no glossary tooltip could reach
them, no screen reader read them, and the page drifted from the source the moment a
slide changed.
The deck is NOT re-parsed. Slidev's markdown is not standard markdown, and its meaning
does not live in the markdown anyway — it lives in a compiled bundle of UnoCSS
utilities, theme rules and <style scoped> blocks. Re-implementing that is
re-implementing a renderer, in any language. So Slidev renders (its own /print route,
the one `slidev export` drives) and we capture what it produced: the DOM, and the CSS
the browser actually applied.
Measured on the served page: 0 → 2152 indexable words, 16/16 slides reachable, 0
third-party origins added, 0 broken references.
Twelve traps, none of which raised an error:
· The CSS must be asked of the BROWSER (17 stylesheets; index.html links 2).
· Collapsing :root/html/body imports the SPA's viewport cage — it clipped the deck
at 900px and swallowed 14 of 16 slides, silently.
· UnoCSS's preflight, scoped, kills the glossary underline it was supposed to enable.
· :not() inherits its argument's specificity — the boundary guard grew with every
exemption until it reverted the slide scaling. :not(:where(…)) contributes zero.
· scale(calc(100cqw / 980)) is invalid CSS and is dropped in silence; on a phone the
box was 328px while the content stayed 980px and two thirds of every slide was cut.
· The site's markdown renderer destroys an inline <script>: curly quotes, injected
<p>, &&. CSS and JS go to external files — correctness, not optimisation.
· The 980x552 frame CUT 12 of 16 slides (Slidev cuts them too; nobody had seen it,
because the slides were PNGs of themselves).
· transform: translateY(0) — an IDENTITY transform — makes <main> the containing
block for position:fixed, and the zoom modal landed at top = -221px.
Two views over one DOM (slider default, stacked reads with Ctrl+F), an index derived
from each slide's own H1, a sticky rail, zoom, and Slidev's own copy buttons revived —
they shipped in the captured DOM at 0x0, unclickable by any human.
Twelve gates (A–L), and IN THE CHAIN: `just decks` in sync-full, `decks-check` in
`check` (on disk), `decks-served` in `check-live` (the page). A generator outside the
chain is a generator that does not run.
2026-07-14 19:41:51 +01:00
|
|
|
|
2026-07-17 01:00:32 +01:00
|
|
|
export async function projectAssets(html, css, id, { apply = true, dist } = {}) {
|
|
|
|
|
const DIST = dist
|
decks: the slides stop being pixels — Slidev's own render, captured and re-homed
The published deck was a hand-written carousel of exported PNGs: the words of every
slide were an IMAGE of words. Nothing indexed them, no glossary tooltip could reach
them, no screen reader read them, and the page drifted from the source the moment a
slide changed.
The deck is NOT re-parsed. Slidev's markdown is not standard markdown, and its meaning
does not live in the markdown anyway — it lives in a compiled bundle of UnoCSS
utilities, theme rules and <style scoped> blocks. Re-implementing that is
re-implementing a renderer, in any language. So Slidev renders (its own /print route,
the one `slidev export` drives) and we capture what it produced: the DOM, and the CSS
the browser actually applied.
Measured on the served page: 0 → 2152 indexable words, 16/16 slides reachable, 0
third-party origins added, 0 broken references.
Twelve traps, none of which raised an error:
· The CSS must be asked of the BROWSER (17 stylesheets; index.html links 2).
· Collapsing :root/html/body imports the SPA's viewport cage — it clipped the deck
at 900px and swallowed 14 of 16 slides, silently.
· UnoCSS's preflight, scoped, kills the glossary underline it was supposed to enable.
· :not() inherits its argument's specificity — the boundary guard grew with every
exemption until it reverted the slide scaling. :not(:where(…)) contributes zero.
· scale(calc(100cqw / 980)) is invalid CSS and is dropped in silence; on a phone the
box was 328px while the content stayed 980px and two thirds of every slide was cut.
· The site's markdown renderer destroys an inline <script>: curly quotes, injected
<p>, &&. CSS and JS go to external files — correctness, not optimisation.
· The 980x552 frame CUT 12 of 16 slides (Slidev cuts them too; nobody had seen it,
because the slides were PNGs of themselves).
· transform: translateY(0) — an IDENTITY transform — makes <main> the containing
block for position:fixed, and the zoom modal landed at top = -221px.
Two views over one DOM (slider default, stacked reads with Ctrl+F), an index derived
from each slide's own H1, a sticky rail, zoom, and Slidev's own copy buttons revived —
they shipped in the captured DOM at 0x0, unclickable by any human.
Twelve gates (A–L), and IN THE CHAIN: `just decks` in sync-full, `decks-check` in
`check` (on disk), `decks-served` in `check-live` (the page). A generator outside the
chain is a generator that does not run.
2026-07-14 19:41:51 +01:00
|
|
|
const base = `${publicBase(id)}/assets`
|
|
|
|
|
const wanted = new Map()
|
|
|
|
|
const unresolved = []
|
|
|
|
|
|
2026-07-17 01:00:32 +01:00
|
|
|
// 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, '&')
|
|
|
|
|
|
decks: the slides stop being pixels — Slidev's own render, captured and re-homed
The published deck was a hand-written carousel of exported PNGs: the words of every
slide were an IMAGE of words. Nothing indexed them, no glossary tooltip could reach
them, no screen reader read them, and the page drifted from the source the moment a
slide changed.
The deck is NOT re-parsed. Slidev's markdown is not standard markdown, and its meaning
does not live in the markdown anyway — it lives in a compiled bundle of UnoCSS
utilities, theme rules and <style scoped> blocks. Re-implementing that is
re-implementing a renderer, in any language. So Slidev renders (its own /print route,
the one `slidev export` drives) and we capture what it produced: the DOM, and the CSS
the browser actually applied.
Measured on the served page: 0 → 2152 indexable words, 16/16 slides reachable, 0
third-party origins added, 0 broken references.
Twelve traps, none of which raised an error:
· The CSS must be asked of the BROWSER (17 stylesheets; index.html links 2).
· Collapsing :root/html/body imports the SPA's viewport cage — it clipped the deck
at 900px and swallowed 14 of 16 slides, silently.
· UnoCSS's preflight, scoped, kills the glossary underline it was supposed to enable.
· :not() inherits its argument's specificity — the boundary guard grew with every
exemption until it reverted the slide scaling. :not(:where(…)) contributes zero.
· scale(calc(100cqw / 980)) is invalid CSS and is dropped in silence; on a phone the
box was 328px while the content stayed 980px and two thirds of every slide was cut.
· The site's markdown renderer destroys an inline <script>: curly quotes, injected
<p>, &&. CSS and JS go to external files — correctness, not optimisation.
· The 980x552 frame CUT 12 of 16 slides (Slidev cuts them too; nobody had seen it,
because the slides were PNGs of themselves).
· transform: translateY(0) — an IDENTITY transform — makes <main> the containing
block for position:fixed, and the zoom modal landed at top = -221px.
Two views over one DOM (slider default, stacked reads with Ctrl+F), an index derived
from each slide's own H1, a sticky rail, zoom, and Slidev's own copy buttons revived —
they shipped in the captured DOM at 0x0, unclickable by any human.
Twelve gates (A–L), and IN THE CHAIN: `just decks` in sync-full, `decks-check` in
`check` (on disk), `decks-served` in `check-live` (the page). A generator outside the
chain is a generator that does not run.
2026-07-14 19:41:51 +01:00
|
|
|
const claim = (ref) => {
|
2026-07-17 01:00:32 +01:00
|
|
|
const clean = decode(ref.trim()).trim().replace(/^["']|["']$/g, '')
|
decks: the slides stop being pixels — Slidev's own render, captured and re-homed
The published deck was a hand-written carousel of exported PNGs: the words of every
slide were an IMAGE of words. Nothing indexed them, no glossary tooltip could reach
them, no screen reader read them, and the page drifted from the source the moment a
slide changed.
The deck is NOT re-parsed. Slidev's markdown is not standard markdown, and its meaning
does not live in the markdown anyway — it lives in a compiled bundle of UnoCSS
utilities, theme rules and <style scoped> blocks. Re-implementing that is
re-implementing a renderer, in any language. So Slidev renders (its own /print route,
the one `slidev export` drives) and we capture what it produced: the DOM, and the CSS
the browser actually applied.
Measured on the served page: 0 → 2152 indexable words, 16/16 slides reachable, 0
third-party origins added, 0 broken references.
Twelve traps, none of which raised an error:
· The CSS must be asked of the BROWSER (17 stylesheets; index.html links 2).
· Collapsing :root/html/body imports the SPA's viewport cage — it clipped the deck
at 900px and swallowed 14 of 16 slides, silently.
· UnoCSS's preflight, scoped, kills the glossary underline it was supposed to enable.
· :not() inherits its argument's specificity — the boundary guard grew with every
exemption until it reverted the slide scaling. :not(:where(…)) contributes zero.
· scale(calc(100cqw / 980)) is invalid CSS and is dropped in silence; on a phone the
box was 328px while the content stayed 980px and two thirds of every slide was cut.
· The site's markdown renderer destroys an inline <script>: curly quotes, injected
<p>, &&. CSS and JS go to external files — correctness, not optimisation.
· The 980x552 frame CUT 12 of 16 slides (Slidev cuts them too; nobody had seen it,
because the slides were PNGs of themselves).
· transform: translateY(0) — an IDENTITY transform — makes <main> the containing
block for position:fixed, and the zoom modal landed at top = -221px.
Two views over one DOM (slider default, stacked reads with Ctrl+F), an index derived
from each slide's own H1, a sticky rail, zoom, and Slidev's own copy buttons revived —
they shipped in the captured DOM at 0x0, unclickable by any human.
Twelve gates (A–L), and IN THE CHAIN: `just decks` in sync-full, `decks-check` in
`check` (on disk), `decks-served` in `check-live` (the page). A generator outside the
chain is a generator that does not run.
2026-07-14 19:41:51 +01:00
|
|
|
if (/^(data:|https?:|#|mailto:)/i.test(clean)) return null // external / inline: not ours
|
|
|
|
|
|
2026-07-17 01:00:32 +01:00
|
|
|
// 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 }
|
decks: the slides stop being pixels — Slidev's own render, captured and re-homed
The published deck was a hand-written carousel of exported PNGs: the words of every
slide were an IMAGE of words. Nothing indexed them, no glossary tooltip could reach
them, no screen reader read them, and the page drifted from the source the moment a
slide changed.
The deck is NOT re-parsed. Slidev's markdown is not standard markdown, and its meaning
does not live in the markdown anyway — it lives in a compiled bundle of UnoCSS
utilities, theme rules and <style scoped> blocks. Re-implementing that is
re-implementing a renderer, in any language. So Slidev renders (its own /print route,
the one `slidev export` drives) and we capture what it produced: the DOM, and the CSS
the browser actually applied.
Measured on the served page: 0 → 2152 indexable words, 16/16 slides reachable, 0
third-party origins added, 0 broken references.
Twelve traps, none of which raised an error:
· The CSS must be asked of the BROWSER (17 stylesheets; index.html links 2).
· Collapsing :root/html/body imports the SPA's viewport cage — it clipped the deck
at 900px and swallowed 14 of 16 slides, silently.
· UnoCSS's preflight, scoped, kills the glossary underline it was supposed to enable.
· :not() inherits its argument's specificity — the boundary guard grew with every
exemption until it reverted the slide scaling. :not(:where(…)) contributes zero.
· scale(calc(100cqw / 980)) is invalid CSS and is dropped in silence; on a phone the
box was 328px while the content stayed 980px and two thirds of every slide was cut.
· The site's markdown renderer destroys an inline <script>: curly quotes, injected
<p>, &&. CSS and JS go to external files — correctness, not optimisation.
· The 980x552 frame CUT 12 of 16 slides (Slidev cuts them too; nobody had seen it,
because the slides were PNGs of themselves).
· transform: translateY(0) — an IDENTITY transform — makes <main> the containing
block for position:fixed, and the zoom modal landed at top = -221px.
Two views over one DOM (slider default, stacked reads with Ctrl+F), an index derived
from each slide's own H1, a sticky rail, zoom, and Slidev's own copy buttons revived —
they shipped in the captured DOM at 0x0, unclickable by any human.
Twelve gates (A–L), and IN THE CHAIN: `just decks` in sync-full, `decks-check` in
`check` (on disk), `decks-served` in `check-live` (the page). A generator outside the
chain is a generator that does not run.
2026-07-14 19:41:51 +01:00
|
|
|
|
2026-07-17 01:00:32 +01:00
|
|
|
const pub = `${base}/${basename(rel)}`
|
decks: the slides stop being pixels — Slidev's own render, captured and re-homed
The published deck was a hand-written carousel of exported PNGs: the words of every
slide were an IMAGE of words. Nothing indexed them, no glossary tooltip could reach
them, no screen reader read them, and the page drifted from the source the moment a
slide changed.
The deck is NOT re-parsed. Slidev's markdown is not standard markdown, and its meaning
does not live in the markdown anyway — it lives in a compiled bundle of UnoCSS
utilities, theme rules and <style scoped> blocks. Re-implementing that is
re-implementing a renderer, in any language. So Slidev renders (its own /print route,
the one `slidev export` drives) and we capture what it produced: the DOM, and the CSS
the browser actually applied.
Measured on the served page: 0 → 2152 indexable words, 16/16 slides reachable, 0
third-party origins added, 0 broken references.
Twelve traps, none of which raised an error:
· The CSS must be asked of the BROWSER (17 stylesheets; index.html links 2).
· Collapsing :root/html/body imports the SPA's viewport cage — it clipped the deck
at 900px and swallowed 14 of 16 slides, silently.
· UnoCSS's preflight, scoped, kills the glossary underline it was supposed to enable.
· :not() inherits its argument's specificity — the boundary guard grew with every
exemption until it reverted the slide scaling. :not(:where(…)) contributes zero.
· scale(calc(100cqw / 980)) is invalid CSS and is dropped in silence; on a phone the
box was 328px while the content stayed 980px and two thirds of every slide was cut.
· The site's markdown renderer destroys an inline <script>: curly quotes, injected
<p>, &&. CSS and JS go to external files — correctness, not optimisation.
· The 980x552 frame CUT 12 of 16 slides (Slidev cuts them too; nobody had seen it,
because the slides were PNGs of themselves).
· transform: translateY(0) — an IDENTITY transform — makes <main> the containing
block for position:fixed, and the zoom modal landed at top = -221px.
Two views over one DOM (slider default, stacked reads with Ctrl+F), an index derived
from each slide's own H1, a sticky rail, zoom, and Slidev's own copy buttons revived —
they shipped in the captured DOM at 0x0, unclickable by any human.
Twelve gates (A–L), and IN THE CHAIN: `just decks` in sync-full, `decks-check` in
`check` (on disk), `decks-served` in `check-live` (the page). A generator outside the
chain is a generator that does not run.
2026-07-14 19:41:51 +01:00
|
|
|
wanted.set(clean, { onDisk, pub })
|
|
|
|
|
return pub
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 01:00:32 +01:00
|
|
|
// 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}"`)
|
|
|
|
|
})
|
|
|
|
|
|
decks: the slides stop being pixels — Slidev's own render, captured and re-homed
The published deck was a hand-written carousel of exported PNGs: the words of every
slide were an IMAGE of words. Nothing indexed them, no glossary tooltip could reach
them, no screen reader read them, and the page drifted from the source the moment a
slide changed.
The deck is NOT re-parsed. Slidev's markdown is not standard markdown, and its meaning
does not live in the markdown anyway — it lives in a compiled bundle of UnoCSS
utilities, theme rules and <style scoped> blocks. Re-implementing that is
re-implementing a renderer, in any language. So Slidev renders (its own /print route,
the one `slidev export` drives) and we capture what it produced: the DOM, and the CSS
the browser actually applied.
Measured on the served page: 0 → 2152 indexable words, 16/16 slides reachable, 0
third-party origins added, 0 broken references.
Twelve traps, none of which raised an error:
· The CSS must be asked of the BROWSER (17 stylesheets; index.html links 2).
· Collapsing :root/html/body imports the SPA's viewport cage — it clipped the deck
at 900px and swallowed 14 of 16 slides, silently.
· UnoCSS's preflight, scoped, kills the glossary underline it was supposed to enable.
· :not() inherits its argument's specificity — the boundary guard grew with every
exemption until it reverted the slide scaling. :not(:where(…)) contributes zero.
· scale(calc(100cqw / 980)) is invalid CSS and is dropped in silence; on a phone the
box was 328px while the content stayed 980px and two thirds of every slide was cut.
· The site's markdown renderer destroys an inline <script>: curly quotes, injected
<p>, &&. CSS and JS go to external files — correctness, not optimisation.
· The 980x552 frame CUT 12 of 16 slides (Slidev cuts them too; nobody had seen it,
because the slides were PNGs of themselves).
· transform: translateY(0) — an IDENTITY transform — makes <main> the containing
block for position:fixed, and the zoom modal landed at top = -221px.
Two views over one DOM (slider default, stacked reads with Ctrl+F), an index derived
from each slide's own H1, a sticky rail, zoom, and Slidev's own copy buttons revived —
they shipped in the captured DOM at 0x0, unclickable by any human.
Twelve gates (A–L), and IN THE CHAIN: `just decks` in sync-full, `decks-check` in
`check` (on disk), `decks-served` in `check-live` (the page). A generator outside the
chain is a generator that does not run.
2026-07-14 19:41:51 +01:00
|
|
|
html = html
|
|
|
|
|
.replace(/(<img[^>]+src=")([^"]+)(")/g, (m, a, ref, z) => { const p = claim(ref); return p ? a + p + z : m })
|
2026-07-17 01:00:32 +01:00
|
|
|
// 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 })
|
decks: the slides stop being pixels — Slidev's own render, captured and re-homed
The published deck was a hand-written carousel of exported PNGs: the words of every
slide were an IMAGE of words. Nothing indexed them, no glossary tooltip could reach
them, no screen reader read them, and the page drifted from the source the moment a
slide changed.
The deck is NOT re-parsed. Slidev's markdown is not standard markdown, and its meaning
does not live in the markdown anyway — it lives in a compiled bundle of UnoCSS
utilities, theme rules and <style scoped> blocks. Re-implementing that is
re-implementing a renderer, in any language. So Slidev renders (its own /print route,
the one `slidev export` drives) and we capture what it produced: the DOM, and the CSS
the browser actually applied.
Measured on the served page: 0 → 2152 indexable words, 16/16 slides reachable, 0
third-party origins added, 0 broken references.
Twelve traps, none of which raised an error:
· The CSS must be asked of the BROWSER (17 stylesheets; index.html links 2).
· Collapsing :root/html/body imports the SPA's viewport cage — it clipped the deck
at 900px and swallowed 14 of 16 slides, silently.
· UnoCSS's preflight, scoped, kills the glossary underline it was supposed to enable.
· :not() inherits its argument's specificity — the boundary guard grew with every
exemption until it reverted the slide scaling. :not(:where(…)) contributes zero.
· scale(calc(100cqw / 980)) is invalid CSS and is dropped in silence; on a phone the
box was 328px while the content stayed 980px and two thirds of every slide was cut.
· The site's markdown renderer destroys an inline <script>: curly quotes, injected
<p>, &&. CSS and JS go to external files — correctness, not optimisation.
· The 980x552 frame CUT 12 of 16 slides (Slidev cuts them too; nobody had seen it,
because the slides were PNGs of themselves).
· transform: translateY(0) — an IDENTITY transform — makes <main> the containing
block for position:fixed, and the zoom modal landed at top = -221px.
Two views over one DOM (slider default, stacked reads with Ctrl+F), an index derived
from each slide's own H1, a sticky rail, zoom, and Slidev's own copy buttons revived —
they shipped in the captured DOM at 0x0, unclickable by any human.
Twelve gates (A–L), and IN THE CHAIN: `just decks` in sync-full, `decks-check` in
`check` (on disk), `decks-served` in `check-live` (the page). A generator outside the
chain is a generator that does not run.
2026-07-14 19:41:51 +01:00
|
|
|
|
|
|
|
|
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) }
|
|
|
|
|
}
|