Files
hackerspace.sh/lib/markdown-anchors.ts
Moheeb Zara 70da0c3752 feat(markdown): KB/process render + working in-document anchor links
KB/process content was shown via a bare unsanitized ReactMarkdown in the
ops view dialog (the /ops/[id] route is the editor, not a view). Route
it through the shared SafeMarkdown instead. New dependency-free
lib/markdown-anchors.ts (heading-text flatten, GitHub-ish slug,
per-render dedupe, in-doc href test; unit-tested) is wired into both
SafeMarkdown and MarkdownBody so headings get stable ids and a
[link](#heading) resolves. In-document links no longer force a new tab,
which previously broke the anchor jump for policies/proposals too.
rehype-slug was not added (no offline install); the helper is equivalent
and tested.
2026-05-16 19:11:49 -07:00

48 lines
1.9 KiB
TypeScript

// Dependency-free heading anchors for the markdown renderers, so an
// in-document link like [Back to top](#overview) resolves to the matching
// heading. (We can't add rehype-slug as a dependency here.) Pure and
// unit-tested; no React/Next imports beyond the ReactNode type.
import type { ReactNode } from 'react'
// Flatten a React markdown heading's children to its plain text.
export function headingText(node: ReactNode): string {
if (node == null || typeof node === 'boolean') return ''
if (typeof node === 'string' || typeof node === 'number') return String(node)
if (Array.isArray(node)) return node.map(headingText).join('')
if (typeof node === 'object' && 'props' in (node as { props?: { children?: ReactNode } })) {
return headingText((node as { props?: { children?: ReactNode } }).props?.children)
}
return ''
}
// GitHub-ish slug: lowercase, drop punctuation, spaces to hyphens. Matches
// what an author would naturally type as the link target.
export function slugifyHeading(text: string): string {
return text
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
}
// Per-render slugger that de-duplicates repeated headings (foo, foo-1, ...)
// the same way GitHub does, so links stay unambiguous within one document.
export function makeHeadingSlugger(): (node: ReactNode) => string {
const seen = new Map<string, number>()
return (node: ReactNode) => {
const base = slugifyHeading(headingText(node)) || 'section'
const count = seen.get(base) ?? 0
seen.set(base, count + 1)
return count === 0 ? base : `${base}-${count}`
}
}
// True for an in-document target ("#section"): such a link must navigate in
// place, NOT open a new tab, or the anchor jump is lost.
export function isInDocumentHref(href: string | undefined): boolean {
return typeof href === 'string' && href.startsWith('#')
}