Commit 0870c645 by PLN (Algolia)

blog: heading anchors, TOC builder, footnote margin clones in the chain

parent 54fc66b7
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import matter from 'gray-matter'; import matter from 'gray-matter';
import { renderMarkdown } from './markdown'; import { buildTocFromHtml, renderMarkdown } from './markdown';
import { draftsVisible, parseShareId, shareTokenOf } from './content'; import { draftsVisible, parseShareId, shareTokenOf } from './content';
/** /**
...@@ -117,6 +117,7 @@ export async function getBlogPost(slug, lang = DEFAULT_LANG) { ...@@ -117,6 +117,7 @@ export async function getBlogPost(slug, lang = DEFAULT_LANG) {
const { data, content } = matter(fs.readFileSync(langFile(slug, lang_), 'utf8')); const { data, content } = matter(fs.readFileSync(langFile(slug, lang_), 'utf8'));
const meta = readMeta(slug); const meta = readMeta(slug);
const contentHtml = await renderMarkdown(content);
return { return {
slug, slug,
...meta, ...meta,
...@@ -126,7 +127,36 @@ export async function getBlogPost(slug, lang = DEFAULT_LANG) { ...@@ -126,7 +127,36 @@ export async function getBlogPost(slug, lang = DEFAULT_LANG) {
lang: lang_, lang: lang_,
untranslated: lang_ === lang ? null : lang, untranslated: lang_ === lang ? null : lang,
languages, languages,
contentHtml: await renderMarkdown(content), contentHtml,
toc: buildTocFromHtml(contentHtml),
};
}
/* ------------------------------------------------------------------ *
* Neighbours — prev/next by date
* ------------------------------------------------------------------ */
/**
* Adjacent posts by date around `slug`, from the visible listing (drafts
* follow the prod/dev rule). `older` is the previous post by date, `newer`
* the next. Links point at the requested language when the target post has
* it, else at the canonical URL. Related posts (post↔project threads) are a
* separate future surface (DESIGN.md /projects) — not folded in here.
*/
export function getBlogNeighbors(slug, lang = DEFAULT_LANG) {
const posts = getBlogPosts(lang);
const index = posts.findIndex((post) => post.slug === slug);
if (index === -1) return { older: null, newer: null };
const hrefFor = (post) =>
lang !== DEFAULT_LANG && post.languages.includes(lang)
? `/blog/${post.slug}/${lang}`
: `/blog/${post.slug}`;
const shape = (post) => ({ slug: post.slug, title: post.title, href: hrefFor(post) });
return {
older: index < posts.length - 1 ? shape(posts[index + 1]) : null,
newer: index > 0 ? shape(posts[index - 1]) : null,
}; };
} }
......
...@@ -92,6 +92,191 @@ export function rehypeFigure() { ...@@ -92,6 +92,191 @@ export function rehypeFigure() {
}; };
} }
/* ------------------------------------------------------------------ *
* Heading anchors + TOC
* ------------------------------------------------------------------ */
/** Recursively collect the text of a hast node (inline markup included). */
function nodeText(node: any): string {
if (node.type === 'text') return node.value;
if (node.type !== 'element') return '';
return node.children.map(nodeText).join('');
}
/** GitHub-style slug: ASCII-folded, lowercased, hyphenated, deduped. */
function headingSlug(text: string, seen: Map<string, number>): string {
const base =
text
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // fold diacritics: é → e
.toLowerCase()
.replace(/[^a-z0-9\s_-]/g, '')
.trim()
.replace(/\s+/g, '-') || 'section';
const count = seen.get(base) ?? 0;
seen.set(base, count + 1);
return count === 0 ? base : `${base}-${count}`;
}
/**
* rehype plugin: give h1–h4 stable ids (GitHub-slug style) so headings are
* deep-linkable and the TOC can be read back from the rendered HTML.
*/
export function rehypeHeadingIds() {
return (tree: any) => {
const seen = new Map<string, number>();
visit(tree, 'element', (node: any) => {
if (!/^h[1-4]$/.test(node.tagName)) return;
node.properties = node.properties || {};
node.properties.id = headingSlug(nodeText(node), seen);
});
};
}
const ENTITIES: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&#x26;': '&',
'&#x27;': "'",
'&nbsp;': ' ',
};
/**
* TOC from rendered HTML — the rendered headings are the single truth, so
* the builder and the page can never drift apart. Reads back the h2/h3 the
* rehypeHeadingIds pass minted; strips inline markup and decodes entities.
*/
export function buildTocFromHtml(html: string) {
const toc: { id: string; text: string; depth: number }[] = [];
const re = /<h([23])([^>]*\bid="([^"]*)"[^>]*)>([\s\S]*?)<\/h\1>/g;
let match: RegExpExecArray | null;
while ((match = re.exec(html)) !== null) {
const text = match[4]
.replace(/<[^>]+>/g, '')
.replace(/&(amp|lt|gt|quot|#39|#x26|#x27|nbsp);/g, (e) => ENTITIES[e] ?? e)
.trim();
toc.push({ id: match[3], text, depth: Number(match[1]) });
}
return toc;
}
/* ------------------------------------------------------------------ *
* Footnote margin clones — Tufte-style notes, zero client JS
* ------------------------------------------------------------------ */
/**
* Clone a footnote definition's hast children into margin-note-safe inline
* shape: block elements (p, lists, headings, pre…) become styled spans so
* the clone can live inside a paragraph without the browser's HTML parser
* re-nesting it out. Backref arrows are dropped (meaningless off the
* endnote list); links, emphasis and images pass through.
*/
function inlineSafe(node: any): any {
if (node.type === 'text') return node;
if (node.type === 'comment') return null;
if (node.type !== 'element') return node;
if (node.tagName === 'a' && node.properties?.dataFootnoteBackref != null) {
return null;
}
const span = (className: string, children: any[]) => ({
type: 'element',
tagName: 'span',
properties: { className: [className] },
children,
});
const flat = (children: any[]) =>
children.flatMap((child) => {
const converted = inlineSafe(child);
return converted === null ? [] : [converted];
});
if (/^(p|div|blockquote|h[1-6])$/.test(node.tagName)) {
return span('prose-fn-block', flat(node.children));
}
if (node.tagName === 'ul' || node.tagName === 'ol') {
return span(
'prose-fn-block',
node.children.flatMap((child: any) =>
child.type === 'element' && child.tagName === 'li'
? [span('prose-fn-item', flat(child.children))]
: flat([child]),
),
);
}
if (node.tagName === 'li') {
return span('prose-fn-item', flat(node.children));
}
if (node.tagName === 'pre') {
const code = {
type: 'element',
tagName: 'code',
properties: { className: ['prose-fn-code'] },
children: flat(node.children),
};
return span('prose-fn-block', [code]);
}
return { ...node, children: flat(node.children) };
}
/**
* rehype plugin: after remark-rehype has minted the GFM footnote markup,
* clone each definition inline next to its reference. Structure:
* `<span class="prose-fn"><sup>…ref…</sup><span class="prose-fn-body"
* aria-hidden="true">…note…</span></span>`. The body span is display:none by
* default; CSS promotes it to a true margin note on wide screens and a
* hover/focus popover in between. The endnote section stays — it is the
* no-JS and mobile target, and the screen-reader source of truth (the
* inline clones are aria-hidden).
*/
export function rehypeMarginNotes() {
return (tree: any) => {
const defs = new Map<string, any>();
visit(tree, 'element', (node: any) => {
if (node.tagName !== 'li') return;
const id = node.properties?.id;
if (typeof id === 'string' && id.startsWith('user-content-fn-')) {
defs.set(id, node);
}
});
if (defs.size === 0) return;
visit(tree, 'element', (node: any, index: number, parent: any) => {
if (node.tagName !== 'sup' || index === null || !parent) return;
const anchor = node.children.find(
(child: any) =>
child.type === 'element' &&
child.tagName === 'a' &&
child.properties?.dataFootnoteRef != null,
);
if (!anchor) return;
const def = defs.get(String(anchor.properties.href).slice(1));
if (!def) return;
parent.children[index] = {
type: 'element',
tagName: 'span',
properties: { className: ['prose-fn'] },
children: [
node,
{
type: 'element',
tagName: 'span',
properties: { className: ['prose-fn-body'], ariaHidden: 'true' },
children: def.children.flatMap((child: any) => {
const converted = inlineSafe(child);
return converted === null ? [] : [converted];
}),
},
],
};
});
};
}
const processor = unified() const processor = unified()
.use(remarkParse) .use(remarkParse)
.use(remarkGfm) .use(remarkGfm)
...@@ -99,6 +284,8 @@ const processor = unified() ...@@ -99,6 +284,8 @@ const processor = unified()
.use(remarkDirectiveTags) .use(remarkDirectiveTags)
.use(remarkRehype) .use(remarkRehype)
.use(rehypeFigure) .use(rehypeFigure)
.use(rehypeHeadingIds)
.use(rehypeMarginNotes)
// Dark theme on a dark pre (background comes from the blog CSS module — // Dark theme on a dark pre (background comes from the blog CSS module —
// keepBackground: false keeps the pipeline's HTML free of hardcoded hex). // keepBackground: false keeps the pipeline's HTML free of hardcoded hex).
.use(rehypePrettyCode, { theme: 'vesper', keepBackground: false }) .use(rehypePrettyCode, { theme: 'vesper', keepBackground: false })
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment