// Read each slide's declared ANCHOR from the deck source — with Slidev's own parser. // // An anchor lets a link land ON a slide instead of at the top of the deck: the glossary // tooltip over `NCL` goes to the Nickel slide, not to the cover. Without one the link // cannot be written at all, and until now it could not: the slide ids were assigned by // deck.js at runtime, so the served HTML carried none and the browser resolved the hash // before they existed. // // WHY THE ANCHOR LIVES IN THE SLIDE'S FRONTMATTER, and not in the registry that links to it: // a `#slide-7` is a POSITIONAL pointer into a document that reorders. Insert one slide and // every link points one slide off — at 200, in silence, with nothing to fail. That is the // drift this whole projection exists to refuse. Declared in the slide, the anchor travels // with it when it moves, and dies loudly with it when it is deleted (see the gate). // // WHY SLIDEV'S PARSER AND NOT A `---` SPLIT: this file's neighbours say it plainly — the deck // is not re-parsed, because Slidev's markdown is not standard markdown. A `---` inside a code // fence would silently shift every anchor onto the wrong slide, which is the exact failure the // anchors exist to prevent. Slidev already ships the parser that draws these boundaries; the // only correct number of implementations of it is one, and it is not this one. import { createRequire } from 'node:module' import { realpathSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { resolve } from 'node:path' import { PRESENTATION } from './paths.mjs' // pnpm links @slidev/parser under the CLI, never at the top level, and createRequire does not // resolve the symlink for you: pointed at the link path it looks for a node_modules that is not // there. Realpath first, and resolution happens in the tree that actually has the package. const parserPromise = (async () => { const cli = realpathSync(resolve(PRESENTATION, 'node_modules/@slidev/cli/package.json')) return import(createRequire(cli).resolve('@slidev/parser')) })() // The anchor is a URL fragment, so it must survive being one: lowercase, no spaces, no accents. // Rejected here rather than normalised — an anchor that is not what the author typed is an // anchor the author cannot link to from memory, and they would find out in the browser. const SHAPE = /^[a-z0-9][a-z0-9-]*$/ /** * @param {string} source filename relative to outreach/presentation/ * @returns {Promise>} one entry per slide, index-parallel to the capture */ export async function readAnchors(source) { const { parse } = await parserPromise const file = resolve(PRESENTATION, source) const data = await parse(await readFile(file, 'utf8'), file) const seen = new Map() return data.slides.map((s, i) => { const a = s.frontmatter?.anchor if (a == null || a === '') return null if (typeof a !== 'string' || !SHAPE.test(a)) { throw new Error(`${source} slide ${i + 1}: anchor "${a}" no vale como fragmento de URL — minúsculas, dígitos y guiones (empezando por letra o dígito)`) } // Two slides claiming one anchor is a link with two destinations: the browser takes the // first and the author never learns which one they were pointing at. if (seen.has(a)) { throw new Error(`${source}: el ancla "${a}" está declarada en las slides ${seen.get(a) + 1} y ${i + 1} — un ancla nombra UNA slide`) } seen.set(a, i) return a }) }