mono/packages/ui/src/lib/toc.ts
2026-03-21 20:18:25 +01:00

115 lines
3.7 KiB
TypeScript

import { marked } from 'marked';
import { PageLayout, LayoutContainer } from '@/modules/layout/LayoutManager';
export interface MarkdownHeading {
depth: number;
slug: string;
text: string;
}
export interface TocItem extends MarkdownHeading {
children: TocItem[];
}
interface TocOpts {
minHeadingLevel?: number;
maxHeadingLevel?: number;
title?: string;
}
/**
* Basic slugify function to match simple regex used in renderer
* Note: For production with non-latin chars, consider github-slugger
*/
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Strip inline markdown (links, bold, italic, code) down to plain text.
* e.g. "[Mini Shredder](/user/...)" → "Mini Shredder"
*/
function stripInlineMarkdown(text: string): string {
return text
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [text](url) → text
.replace(/[*_]{1,3}(.+?)[*_]{1,3}/g, '$1') // bold / italic
.replace(/`([^`]+)`/g, '$1'); // inline code
}
/** Extract headings from markdown content */
export function extractHeadings(content: string): MarkdownHeading[] {
const tokens = marked.lexer(content);
const headings: MarkdownHeading[] = [];
marked.walkTokens(tokens, (token) => {
if (token.type === 'heading') {
const plain = stripInlineMarkdown(token.text);
headings.push({
depth: token.depth,
text: plain,
slug: slugify(plain)
});
}
});
return headings;
}
/** Extract headings from all widgets in a page layout */
export function extractHeadingsFromLayout(layout: PageLayout): MarkdownHeading[] {
let allHeadings: MarkdownHeading[] = [];
const processContainer = (container: any) => {
// Process widgets in this container
container.widgets.forEach((widget: any) => {
if (widget.props && typeof widget.props.content === 'string') {
const widgetHeadings = extractHeadings(widget.props.content);
allHeadings = [...allHeadings, ...widgetHeadings];
}
});
// Process nested containers (only LayoutContainer has children)
if (container.children) {
container.children.forEach(processContainer);
}
};
layout.containers?.forEach(processContainer);
return allHeadings;
}
/** Convert the flat headings array into a nested tree structure. */
export function generateToC(
headings: MarkdownHeading[],
{ minHeadingLevel, maxHeadingLevel, title }: TocOpts
) {
headings = headings.filter(({ depth }) => depth >= (minHeadingLevel || 2) && depth <= (maxHeadingLevel || 4));
const toc: Array<TocItem> = [];
if (title) {
toc.push({ depth: 2, slug: '_top', text: title, children: [] });
}
for (const heading of headings) injectChild(toc, { ...heading, children: [] });
return toc;
}
/** Inject a ToC entry as deep in the tree as its `depth` property requires. */
function injectChild(items: TocItem[], item: TocItem): void {
const lastItem = items.at(-1);
if (!lastItem || lastItem.depth >= item.depth) {
items.push(item);
} else {
// If the last item is lesser depth (e.g. 2 vs 3), we try to put it in children.
// However, if the gap is too large (e.g. 2 vs 4), simple recursion handles it
// by putting it in the children of the level 2 item.
injectChild(lastItem.children, item);
}
}