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

63 lines
3.4 KiB
JavaScript

// 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<Array<string|null>>} 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
})
}