Build: more thorough processing of spec links

Deals with duplicate anchors in the one-page spec.
This commit is contained in:
Ben Edgington
2025-06-24 22:10:05 +01:00
parent 59bd21ed10
commit 72e9e4152e
3 changed files with 113 additions and 6 deletions

View File

@@ -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(),

View File

@@ -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 <a id="..."> 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 }]],
},
});
},
},
};
}

View File

@@ -8,7 +8,7 @@ const reEnd = /^# .*<!-- \/part4\/ -->$/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');
}