Files
suaveplan/scripts/lib/doc-tree.ts
2026-05-27 23:47:53 +01:00

261 lines
9.2 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = "<!-- begin:auto-docs -->";
export const AUTO_MARKER_END = "<!-- end:auto-docs -->";
/**
* 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 `<https://...>` 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<typeof readdirSync>;
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 `<!-- begin:auto-docs -->...<!-- end:auto-docs -->` 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 `<!-- en…` fragments — defensive cleanup
// against historical generator-output corruption.
after = after.replace(
/(?:[ \t]*<!-- end:auto-docs -->[ \t]*\n?)+/g,
""
);
after = after.replace(/[ \t]*<!--\s*en…[ \t]*\n?/g, "");
const next = `${before}${block}${after}`;
return { next, changed: next !== content };
}
const headingMatch = content.match(/^##\s.*$/m);
if (headingMatch?.index !== undefined) {
const insertAt = headingMatch.index;
const before = content.slice(0, insertAt);
const after = content.slice(insertAt);
const sep = before.endsWith("\n\n")
? ""
: before.endsWith("\n")
? "\n"
: "\n\n";
const trailing = after.startsWith("\n") ? "" : "\n\n";
const next = `${before}${sep}${block}${trailing}${after}`;
return { next, changed: true };
}
const trimmed = content.replace(/\s+$/u, "");
const next = `${trimmed}\n\n${block}\n`;
return { next, changed: next !== content };
}
/**
* Pull every relative `.md` link target out of markdown content. Returns
* absolute filesystem paths resolved against `sourcePath`'s directory.
* Non-relative links (`http(s)://`, anchors, absolute URLs) are skipped.
*/
export function extractRelativeMdLinks(
content: string,
sourcePath: string
): string[] {
const out: string[] = [];
const base = dirname(sourcePath);
const matches = content.matchAll(/\[(?:[^\]]*)\]\(([^)]+)\)/g);
for (const match of matches) {
const raw = (match[1] ?? "").split("#")[0]?.trim() ?? "";
if (raw?.endsWith(".md") && !/^(?:https?:|mailto:|\/)/.test(raw)) {
out.push(join(base, raw));
}
}
return out;
}
/**
* For a directory, return the canonical `.md` doc. Priority order:
* 1. `<dirName>.md` (matches directory name)
* 2. `README.md`
* 3. The single non-`.types.md` `.md` file if exactly one exists
* 4. `undefined`
*/
export function findCanonicalMd(
dir: string,
dirName: string
): string | undefined {
const candidates: string[] = [];
let entries: ReturnType<typeof readdirSync>;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return undefined;
}
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith(".md")) continue;
if (entry.name.endsWith(".types.md")) continue;
candidates.push(entry.name);
}
const exact = `${dirName}.md`;
if (candidates.includes(exact)) return join(dir, exact);
if (candidates.includes("README.md")) return join(dir, "README.md");
if (candidates.length === 1) return join(dir, candidates[0] ?? "");
return undefined;
}
export function isDirectorySafe(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
export function tryRead(path: string): string | undefined {
try {
return readFileSync(path, "utf-8");
} catch {
return undefined;
}
}
export function relForLink(from: string, to: string): string {
const rel = relative(dirname(from), to).replace(/\\/g, "/");
return rel.startsWith(".") ? rel : `./${rel}`;
}