/** * Shared helpers for the documentation-tree scripts: * - `firstBodyParagraph` — extract a truncated first paragraph from a `.md`. * - `walkMd` — recursive `.md` walker honoring an ignore list. * - `replaceMarkedSection` — idempotent in-place section replacement. * - `extractRelativeMdLinks` — pull `.md` link targets out of markdown content. * - `findCanonicalMd` — locate the dominant `.md` for a directory. */ import { readdirSync, readFileSync, statSync } from "node:fs"; import { dirname, join, relative } from "node:path"; export const AUTO_MARKER_BEGIN = ""; export const AUTO_MARKER_END = ""; /** * Strip a leading `# Header` line and any frontmatter, then return the first * non-empty paragraph (text up to the next blank line). Inline markdown * formatting is preserved; whitespace is collapsed; the result is truncated * to `maxChars` characters (with a trailing ellipsis when it overflows). */ export function firstBodyParagraph(content: string, maxChars = 120): string { let body = content.replace(/^/, ""); if (body.startsWith("---\n")) { const end = body.indexOf("\n---", 4); if (end !== -1) body = body.slice(end + 4); } const lines = body.split("\n"); let i = 0; // Skip leading whitespace + every heading line so the first prose // paragraph is grabbed regardless of whether the doc starts with // `# Title\n## Purpose\n\nprose…` or `# Title\n\nprose…`. const isHeading = (line: string): boolean => /^#{1,6}\s/.test(line); while (i < lines.length) { const line = (lines[i] ?? "").trim(); if (line === "") { i++; continue; } if (isHeading(lines[i] ?? "")) { i++; continue; } break; } const paragraph: string[] = []; while (i < lines.length) { const line = lines[i] ?? ""; if (line.trim() === "") break; if (/^(#{1,6}\s|```|---|>\s)/.test(line)) break; paragraph.push(line); i++; } // Strip inline markdown link syntax — `[text](url)` becomes `text` — // so the truncation can never land inside a URL or break MDX autolink // parsing downstream. Bare `` autolinks are stripped to // their text form too. Descriptions are display-only; the clickable // links live elsewhere in the doc tree. const stripped = paragraph .join(" ") .replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1") .replace(/<(https?:\/\/[^>]+)>/gu, "$1") .replace(/\s+/g, " ") .trim(); if (!stripped) return ""; if (stripped.length <= maxChars) return stripped; let cut = stripped.slice(0, maxChars - 1).trimEnd(); // Belt-and-braces: if anything URL-shaped still extends past the cut, // walk back to the last whitespace before it so the truncated string // never trails a partial URL. const lastHttpIdx = Math.max( cut.lastIndexOf("https://"), cut.lastIndexOf("http://") ); if (lastHttpIdx >= 0 && !/\s/.test(cut.slice(lastHttpIdx))) { cut = cut.slice(0, lastHttpIdx).trimEnd(); } return `${cut}…`; } export interface WalkOptions { /** Directory names (segments) to skip entirely. */ ignoreDirs?: readonly string[]; /** File names to skip. */ ignoreFiles?: readonly string[]; /** Path segments — if any segment matches, the file is skipped. */ ignoreSegments?: readonly string[]; } const DEFAULT_IGNORE_DIRS: readonly string[] = [ "node_modules", "dist", "build", "coverage", ".turbo", ".vite", ".next", ]; export function walkMd(root: string, options: WalkOptions = {}): string[] { const ignoreDirs = new Set([ ...DEFAULT_IGNORE_DIRS, ...(options.ignoreDirs ?? []), ]); const ignoreFiles = new Set(options.ignoreFiles ?? []); const ignoreSegments = options.ignoreSegments ?? []; const out: string[] = []; const stack: string[] = [root]; while (stack.length > 0) { const dir = stack.pop() as string; let entries: ReturnType; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const name = entry.name; const full = join(dir, name); if (entry.isDirectory()) { if (ignoreDirs.has(name)) continue; if (name.startsWith(".") && name !== ".") continue; if (ignoreSegments.some((s) => full.includes(`${s}/`))) continue; stack.push(full); } else if (entry.isFile()) { if (!name.endsWith(".md")) continue; if (ignoreFiles.has(name)) continue; if (ignoreSegments.some((s) => full.includes(`${s}/`))) continue; out.push(full); } } } out.sort(); return out; } /** * Find `...` in `content` and * replace the body between the markers with `replacement`. If markers are * absent, the markered block is inserted before the first `## ` heading * (so the autotable sits above hand-authored sections) or appended at the * end of the file when no heading is found. Returns `{ next, changed }`. */ export function replaceMarkedSection( content: string, replacement: string ): { next: string; changed: boolean } { const beginIdx = content.indexOf(AUTO_MARKER_BEGIN); const endIdx = content.indexOf(AUTO_MARKER_END); const block = `${AUTO_MARKER_BEGIN}\n${replacement.trim()}\n${AUTO_MARKER_END}`; if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) { const before = content.slice(0, beginIdx); let after = content.slice(endIdx + AUTO_MARKER_END.length); // Strip any stacked trailing end markers (with optional surrounding // whitespace) and truncated `[ \t]*\n?)+/g, "" ); after = after.replace(/[ \t]*