From 72e9e4152e030846dc84870d669e5c906631837f Mon Sep 17 00:00:00 2001 From: Ben Edgington Date: Tue, 24 Jun 2025 22:10:05 +0100 Subject: [PATCH] Build: more thorough processing of spec links Deals with duplicate anchors in the one-page spec. --- astro.config.mjs | 2 + integrations/my_spec_links.js | 108 ++++++++++++++++++++++++++++++++++ src/loaders/spec-loader.js | 9 +-- 3 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 integrations/my_spec_links.js diff --git a/astro.config.mjs b/astro.config.mjs index aa40cba..85ca322 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -12,6 +12,7 @@ import myAutoLinkHeadings from './integrations/my_autolink_headings'; import mySvgInline from './integrations/my_svg_inline'; import mySearchIndex from './integrations/my_search_index'; import myAddTooltips from './integrations/my_add_tooltips'; +import mySpecLinks from './integrations/my_spec_links'; import myFixupLinks from './integrations/my_fixup_links'; import myCleanupHtml from './integrations/my_cleanup_html'; import myHtaccess from './integrations/my_htaccess'; @@ -27,6 +28,7 @@ export default defineConfig({ mySvgInline({ filePath: 'src/', cachePath: './.svg_cache/' }), mySearchIndex(searchOptions), myAddTooltips({ constantsFile: 'src/include/constants.json' }), + mySpecLinks(), myFixupLinks(), myCleanupHtml(), myHtaccess(), diff --git a/integrations/my_spec_links.js b/integrations/my_spec_links.js new file mode 100644 index 0000000..72a2865 --- /dev/null +++ b/integrations/my_spec_links.js @@ -0,0 +1,108 @@ +import { visit, CONTINUE, SKIP } from 'unist-util-visit'; +import GithubSlugger from 'github-slugger'; + +// Fix up internal links in the one-page annotated spec. +// Must be configured to run after myAutoLinkHeadings, and before myFixupLinks and myCleanupHtml. +// The one-page spec is excluded from search index processing, so no need to worry about that. + +// Ignore SVGs and anything to do with footnotes (which should be fine without help) +function isIgnoredElement(node) { + return ( + node.tagName === 'svg' || + (node.tagName === 'a' && node.properties.dataFootnoteRef !== undefined) || + (node.tagName === 'section' && node.properties.dataFootnotes !== undefined) + ); +} + +// Only headings and are of interest +function isTargetElement(node) { + return ( + node.properties?.id !== undefined && + ['a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) + ); +} + +// We look at all links, but '/part3/' should point to the main book, not the one-page spec +function isLinkElement(node) { + return ( + node.tagName === 'a' && + node.properties?.href !== undefined && + node.properties.href !== '/part3/' + ); +} + +// New pages are indicated by comments attached to certain headings in the Markdown +function isNewPage(node) { + return ( + ['h1', 'h2', 'h3'].includes(node.tagName) && + node.children[node.children.length - 1].type === 'comment' + ); +} + +function specLinks({ logger }) { + return function (tree, file) { + if (file.data.astro.frontmatter.path !== '/annotated-spec/') return; + + // We re-slug the slug to handle duplicates + const slugger = new GithubSlugger(); + + // Pass 1: Build a map of pages and ids/slugs + const map = {}; + let page = ''; + visit(tree, 'element', (node) => { + if (isIgnoredElement(node)) return SKIP; + + if (isTargetElement(node)) { + const oldSlug = node.properties.id; + const newSlug = slugger.slug(oldSlug); + node.properties.id = newSlug; + if (isNewPage(node)) { + page = node.children[node.children.length - 1].value.trim(); + map[page] = newSlug; + } + page || logger.warn('Page is not set when processing ' + oldSlug); + map[page + '#' + oldSlug] = newSlug; + } + }); + + // Pass 2: Adjust hrefs - we need two passes in case any references are forward-looking + page = ''; + visit(tree, 'element', (node) => { + if (isIgnoredElement(node)) return SKIP; + + if (isNewPage(node)) { + page = node.children[node.children.length - 1].value.trim(); + return CONTINUE; + } + + if (isLinkElement(node)) { + const oldHref = node.properties.href; + let newHref; + if (oldHref.startsWith('#')) { + newHref = map[page + oldHref]; + } else if (oldHref.startsWith('/part3/')) { + newHref = map[oldHref]; + } else { + return CONTINUE; + } + newHref || logger.warn('Failed to fix spec link: ' + oldHref); + node.properties.href = '#' + newHref; + } + }); + }; +} + +export default function () { + return { + name: 'mySpecLinks', + hooks: { + 'astro:config:setup': ({ updateConfig, logger }) => { + updateConfig({ + markdown: { + rehypePlugins: [[specLinks, { logger: logger }]], + }, + }); + }, + }, + }; +} diff --git a/src/loaders/spec-loader.js b/src/loaders/spec-loader.js index bfcf529..faf5cbb 100644 --- a/src/loaders/spec-loader.js +++ b/src/loaders/spec-loader.js @@ -8,7 +8,7 @@ const reEnd = /^# .*$/dm; const preamble = '# One Page Annotated Spec\n\n' + '**Note:** This page is automatically generated from the chapters ' + - 'in [Part 3](/part3/). You may find that some internal links are broken.'; + 'in [Part 3](/part3/).'; export function specLoader(fileName) { return { @@ -37,13 +37,10 @@ export function specLoader(fileName) { let markdown = preamble; if (startMatch && endMatch) { - // Remove the title - we will replace it + // Extract the markdown, removing the title and replacing it with the preamble const start = startMatch.indices[0][1] + 1; const end = endMatch.indices[0][0]; - // Extract the spec, add the preamble, and rewrite internal links - markdown += allMarkdown - .substring(start, end) - .replace(/]\(\/part3\/[^#)]*/g, ']('); + markdown += allMarkdown.substring(start, end); } else { logger.warn('Creating empty annotated spec'); }