Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 | 49x 1x 16x 16x 4x 3x 4x 47x 68x 68x 68x 513x 85x 85x 47x 513x 513x 47x 47x 24x 24x 24x 12x 12x 11x 11x 10x 10x 10x 24x 29x 29x 7x 7x 29x 68x 68x 4x 4x 4x 4x | import fs from 'node:fs/promises';
import matter from 'gray-matter';
import type { Element, ElementContent, Root as HastRoot } from 'hast';
import rehypeHighlight from 'rehype-highlight';
import rehypeStringify from 'rehype-stringify';
import remarkDirective from 'remark-directive';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';
import { visit } from 'unist-util-visit';
import { remarkSldsDirectives } from './markdown-directives';
import { renderDataDirectives } from './markdown-legacy';
export type Frontmatter = {
title?: string;
[key: string]: unknown;
};
export type RenderedMarkdown = {
frontmatter: Frontmatter;
html: string;
};
export interface TocItem {
id: string;
text: string;
level: 2 | 3;
}
/** Flatten a hast node's text content (skips element tags). */
function hastNodeText(node: ElementContent): string {
if (node.type === 'text') return node.value;
Eif (node.type === 'element') return node.children.map(hastNodeText).join('');
return '';
}
/** Decode the character references `rehype-stringify` emits (`&`, `<`, `&`...). */
function decodeEntities(text: string): string {
const named: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' };
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (ref, body: string) => {
if (body[0] !== '#') return named[body.toLowerCase()] ?? ref;
const code = body[1].toLowerCase() === 'x' ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
return code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : ref;
});
}
/** GitHub-style slug from heading text. */
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
/**
* Assign stable `id`s to headings (h1–h6) so they can be deep-linked and driven
* by the in-page "On This Page" nav. Dedupes collisions with a numeric suffix.
* Skips headings that already carry an id or a class (card / component-example
* titles set their own).
*/
export function rehypeSlugHeadings() {
return (tree: HastRoot) => {
const used = new Set<string>();
visit(tree, 'element', (node: Element) => {
if (!/^h[1-6]$/.test(node.tagName)) return;
Iif (node.properties.id) used.add(String(node.properties.id));
if (node.properties.id || node.properties.className) return;
const base = slugify(node.children.map(hastNodeText).join('')) || 'section';
let id = base;
for (let n = 1; used.has(id); n++) id = `${base}-${n}`;
used.add(id);
node.properties.id = id;
});
};
}
/**
* Extract the in-page table of contents (h2/h3) from rendered content HTML.
* Only prose headings are included — card / component-example titles carry a
* `class` and are skipped, matching {@link rehypeSlugHeadings}.
*/
export function extractHeadings(html: string): TocItem[] {
const items: TocItem[] = [];
const re = /<(h[23])\b([^>]*)>([\s\S]*?)<\/\1>/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(html)) !== null) {
const attrs = m[2];
if (/\bclass=/.test(attrs)) continue;
const idMatch = attrs.match(/\bid="([^"]+)"/);
if (!idMatch) continue;
const text = decodeEntities(m[3].replace(/<[^>]+>/g, ''))
.replace(/\s+/g, ' ')
.trim();
Iif (!text) continue;
items.push({ id: idMatch[1], text, level: m[1].toLowerCase() === 'h3' ? 3 : 2 });
}
return items;
}
/**
* Split a leading hero banner out of rendered content HTML so the page template
* can rebuild it as a compact banner or a side image. The hero's children are
* only `<h1>` / `<p>` (no nested `<div>`), so the first `</div>` closes it.
* `background` is the CSS `background-image` value (`url(...)` or a gradient).
*/
export function extractLeadingHero(html: string): {
hero: string | null;
rest: string;
background: string | null;
} {
const match = html.match(/^\s*(<div class="hero-banner"[\s\S]*?<\/div>)/);
if (!match) return { hero: null, rest: html, background: null };
const styleMatch = match[1].match(/style="background-image:([^"]*)"/);
// The attribute is HTML-escaped; React's `style` prop needs the raw CSS value.
const background = styleMatch ? decodeEntities(styleMatch[1]) : null;
return { hero: match[1], rest: html.slice(match[0].length), background };
}
export async function renderMarkdownString(content: string): Promise<string> {
const file = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkDirective)
.use(remarkSldsDirectives)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeHighlight, { detect: true })
.use(rehypeSlugHeadings)
.use(rehypeStringify, { allowDangerousHtml: true })
.process(content);
return renderDataDirectives(String(file));
}
export async function renderMarkdown(filePath: string): Promise<RenderedMarkdown> {
const raw = await fs.readFile(filePath, 'utf8');
const { data, content } = matter(raw);
const html = await renderMarkdownString(content);
return {
frontmatter: data as Frontmatter,
html,
};
}
|