Initial Commit
This commit is contained in:
294
scripts/lib/catalog-resolver.ts
Normal file
294
scripts/lib/catalog-resolver.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Catalog Resolver
|
||||
*
|
||||
* Provides utilities for loading and resolving Bun workspace catalog references.
|
||||
* Handles both default catalog (catalog:) and named catalogs (catalog:testing).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const resolver = await CatalogResolver.create();
|
||||
* const version = resolver.resolve('react', 'catalog:react');
|
||||
* // Returns: '19.0.0'
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { CONFIG } from "./config.js";
|
||||
import { discoverPackageDirs } from "./discover-packages.js";
|
||||
|
||||
export type ExportsField =
|
||||
| string
|
||||
| { [key: string]: ExportsField }
|
||||
| ExportsField[];
|
||||
|
||||
export interface PackageJson {
|
||||
name?: string;
|
||||
version?: string;
|
||||
private?: boolean;
|
||||
main?: string;
|
||||
module?: string;
|
||||
types?: string;
|
||||
files?: string[];
|
||||
exports?: ExportsField;
|
||||
scripts?: Record<string, string>;
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
workspaces?: {
|
||||
catalog?: Record<string, string>;
|
||||
catalogs?: Record<string, Record<string, string>>;
|
||||
};
|
||||
[extra: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CatalogEntry {
|
||||
catalogName: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves catalog references to actual versions
|
||||
*/
|
||||
export class CatalogResolver {
|
||||
private catalogs: Map<string, Record<string, string>> = new Map();
|
||||
private workspaceVersions: Map<string, string> = new Map();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Create a CatalogResolver by loading catalogs from root package.json
|
||||
* and discovering workspace package versions.
|
||||
*
|
||||
* @returns Initialized CatalogResolver instance
|
||||
* @throws Error if root package.json cannot be read
|
||||
*/
|
||||
static async create(): Promise<CatalogResolver> {
|
||||
const resolver = new CatalogResolver();
|
||||
await resolver.loadCatalogs();
|
||||
await resolver.loadWorkspaceVersions();
|
||||
return resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all @suaveplan/* workspace packages and their current versions.
|
||||
* Walks `packages/<plugin>/<pkg>/` via `discoverPackageDirs`.
|
||||
*/
|
||||
private async loadWorkspaceVersions(): Promise<void> {
|
||||
const pkgDirs = await discoverPackageDirs();
|
||||
for (const pkgDir of pkgDirs) {
|
||||
const pkgPath = join(pkgDir, "package.json");
|
||||
try {
|
||||
const content = await readFile(pkgPath, "utf-8");
|
||||
const pkg: PackageJson = JSON.parse(content);
|
||||
if (pkg.name && pkg.version) {
|
||||
this.workspaceVersions.set(pkg.name, pkg.version);
|
||||
}
|
||||
} catch {
|
||||
// Skip entries without a readable package.json
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load catalogs from root package.json
|
||||
*
|
||||
* Loads both the default catalog (workspaces.catalog) and named catalogs
|
||||
* (workspaces.catalogs) into memory for fast lookups.
|
||||
*/
|
||||
private async loadCatalogs(): Promise<void> {
|
||||
const rootPkgPath = join(CONFIG.paths.root, "package.json");
|
||||
const content = await readFile(rootPkgPath, "utf-8");
|
||||
const rootPkg: PackageJson = JSON.parse(content);
|
||||
|
||||
// Load default catalog
|
||||
if (rootPkg.workspaces?.catalog) {
|
||||
this.catalogs.set("", rootPkg.workspaces.catalog);
|
||||
}
|
||||
|
||||
// Load named catalogs
|
||||
if (rootPkg.workspaces?.catalogs) {
|
||||
for (const [name, versions] of Object.entries(
|
||||
rootPkg.workspaces.catalogs
|
||||
)) {
|
||||
this.catalogs.set(name, versions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of loaded catalogs
|
||||
*/
|
||||
getCatalogCount(): number {
|
||||
return this.catalogs.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all catalog names
|
||||
*/
|
||||
getCatalogNames(): string[] {
|
||||
return Array.from(this.catalogs.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a catalog reference to an actual version
|
||||
*
|
||||
* @param packageName - The package name to resolve (e.g., 'react')
|
||||
* @param catalogRef - The catalog reference (e.g., 'catalog:react' or 'catalog:')
|
||||
* @returns The resolved version string, or null if not found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* resolver.resolve('react', 'catalog:react') // Returns: '19.0.0'
|
||||
* resolver.resolve('vitest', 'catalog:testing') // Returns: '2.1.0'
|
||||
* resolver.resolve('typescript', 'catalog:') // Returns: '5.9.2'
|
||||
* ```
|
||||
*/
|
||||
resolve(packageName: string, catalogRef: string): string | null {
|
||||
const match = catalogRef.match(/^catalog:(.*)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const catalogName = match[1] || "";
|
||||
const catalog = this.catalogs.get(catalogName);
|
||||
|
||||
if (!catalog) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return catalog[packageName] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find which catalog contains a package
|
||||
*
|
||||
* @param packageName - The package name to search for
|
||||
* @returns CatalogEntry with catalog name and version, or null if not found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* resolver.findInCatalogs('react')
|
||||
* // Returns: { catalogName: 'react', version: '19.0.0' }
|
||||
* ```
|
||||
*/
|
||||
findInCatalogs(packageName: string): CatalogEntry | null {
|
||||
for (const [catalogName, catalog] of this.catalogs.entries()) {
|
||||
const version = catalog[packageName];
|
||||
if (version) {
|
||||
return { catalogName, version };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve dependencies object, converting catalog refs to actual versions
|
||||
*
|
||||
* @param deps - Dependencies object from package.json
|
||||
* @param currentVersion - Current package version (for workspace:* refs)
|
||||
* @returns Resolved dependencies with actual versions
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const resolved = resolver.resolveDependencies({
|
||||
* 'react': 'catalog:react',
|
||||
* '@suaveplan/utils': 'workspace:*',
|
||||
* 'lodash': '^4.17.21'
|
||||
* }, '1.0.0');
|
||||
* // Returns: {
|
||||
* // 'react': '19.0.0',
|
||||
* // '@suaveplan/utils': '^1.0.0',
|
||||
* // 'lodash': '^4.17.21'
|
||||
* // }
|
||||
* ```
|
||||
*/
|
||||
resolveDependencies(
|
||||
deps: Record<string, string> | undefined,
|
||||
_currentVersion: string
|
||||
): Record<string, string> | undefined {
|
||||
if (!deps) return undefined;
|
||||
|
||||
const resolved: Record<string, string> = {};
|
||||
|
||||
for (const [name, version] of Object.entries(deps)) {
|
||||
let resolvedVersion = version;
|
||||
|
||||
// First, resolve catalog references
|
||||
if (version.startsWith("catalog:")) {
|
||||
const catalogResolved = this.resolve(name, version);
|
||||
if (catalogResolved) {
|
||||
resolvedVersion = catalogResolved;
|
||||
} else {
|
||||
continue; // Skip unresolved catalog refs
|
||||
}
|
||||
}
|
||||
|
||||
// Then, resolve workspace references (including those from catalogs)
|
||||
if (resolvedVersion.startsWith("workspace:")) {
|
||||
if (name.startsWith("@suaveplan/")) {
|
||||
// Use the actual workspace package version so consumers
|
||||
// pick up the correct minimum (prevents bun from resolving
|
||||
// to stale nested versions from the registry).
|
||||
const wsVersion = this.workspaceVersions.get(name);
|
||||
resolved[name] = wsVersion ? `^${wsVersion}` : "^1.0.0";
|
||||
} else {
|
||||
resolved[name] = "*";
|
||||
}
|
||||
} else {
|
||||
resolved[name] = resolvedVersion;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a regular version string to a catalog reference
|
||||
*
|
||||
* @param packageName - The package name
|
||||
* @param version - The version string
|
||||
* @returns Catalog reference string, or null if package not in any catalog
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* resolver.toCatalogRef('react', '19.0.0')
|
||||
* // Returns: 'catalog:react'
|
||||
* ```
|
||||
*/
|
||||
toCatalogRef(packageName: string, version: string): string | null {
|
||||
const entry = this.findInCatalogs(packageName);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only convert if versions match
|
||||
if (entry.version === version) {
|
||||
return entry.catalogName
|
||||
? `catalog:${entry.catalogName}`
|
||||
: "catalog:";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all packages in a specific catalog
|
||||
*
|
||||
* @param catalogName - The catalog name (empty string for default catalog)
|
||||
* @returns Record of package names to versions
|
||||
*/
|
||||
getCatalog(catalogName: string): Record<string, string> | undefined {
|
||||
return this.catalogs.get(catalogName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a package exists in any catalog
|
||||
*
|
||||
* @param packageName - The package name to check
|
||||
* @returns true if package exists in at least one catalog
|
||||
*/
|
||||
hasPackage(packageName: string): boolean {
|
||||
return this.findInCatalogs(packageName) !== null;
|
||||
}
|
||||
}
|
||||
72
scripts/lib/config.ts
Normal file
72
scripts/lib/config.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Centralized configuration for scripts
|
||||
*
|
||||
* All registry URLs, paths, and validation thresholds are defined here
|
||||
* to avoid hardcoding values across multiple scripts.
|
||||
*/
|
||||
|
||||
export const CONFIG = {
|
||||
/**
|
||||
* Registry configurations
|
||||
*/
|
||||
registry: {
|
||||
verdaccio: {
|
||||
url: process.env.VERDACCIO_URL || "https://npmjs.fq.io/",
|
||||
webUrl: "https://npmjs.fq.io/-/web/detail",
|
||||
},
|
||||
gitlab: {
|
||||
url:
|
||||
process.env.GITLAB_REGISTRY ||
|
||||
"https://gitlab.fq.io/api/v4/projects/2/packages/npm/",
|
||||
apiKey: process.env.GITLAB_API_KEY,
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Monorepo paths
|
||||
*/
|
||||
paths: {
|
||||
root: process.env.MONOREPO_ROOT || process.cwd(),
|
||||
|
||||
/**
|
||||
* Single root for all genesis workspace packages.
|
||||
* Layout is `packages/<category>/<plugin>/<pkg>/` — see
|
||||
* `discoverPackageDirs` in `./discover-packages.ts` for the 3-level
|
||||
* walk (with 2-level and flat fallbacks).
|
||||
*/
|
||||
packagesRoot: "packages",
|
||||
},
|
||||
|
||||
/**
|
||||
* Quality and validation thresholds
|
||||
*/
|
||||
validation: {
|
||||
minCoverage: 95,
|
||||
minDocsCoverage: 90,
|
||||
maxComplexity: 10,
|
||||
maxMethodLines: 20,
|
||||
maxClassLines: 200,
|
||||
},
|
||||
|
||||
/**
|
||||
* Build and test settings
|
||||
*/
|
||||
build: {
|
||||
cacheDir: "node_modules/.vite",
|
||||
distDir: "dist",
|
||||
},
|
||||
|
||||
/**
|
||||
* Exit codes for consistent error handling
|
||||
*/
|
||||
exitCodes: {
|
||||
success: 0,
|
||||
failure: 1,
|
||||
partialSuccess: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Type-safe access to config values
|
||||
*/
|
||||
export type Config = typeof CONFIG;
|
||||
78
scripts/lib/discover-packages.ts
Normal file
78
scripts/lib/discover-packages.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Walk `packages/<category>/<plugin>/<pkg>/` and return absolute directory
|
||||
* paths for every workspace package (the level containing a `package.json`).
|
||||
*
|
||||
* Supports the canonical 3-level layout (`packages/<category>/<plugin>/<pkg>/`)
|
||||
* and falls back to the legacy 2-level (`packages/<plugin>/<pkg>/`) or flat
|
||||
* (`packages/<pkg>/`) layouts so experimental or transitional packages keep
|
||||
* working.
|
||||
*/
|
||||
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { CONFIG } from "./config.js";
|
||||
|
||||
export interface DiscoverOptions {
|
||||
/** Override the monorepo root. Defaults to `CONFIG.paths.root`. */
|
||||
root?: string;
|
||||
/** Override the packages base. Defaults to `CONFIG.paths.packagesRoot`. */
|
||||
packagesRoot?: string;
|
||||
}
|
||||
|
||||
async function hasPackageJson(dir: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(join(dir, "package.json"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function listDirs(parent: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const entries = await readdir(parent).catch((): string[] => []);
|
||||
for (const name of entries) {
|
||||
const path = join(parent, name);
|
||||
const s = await stat(path).catch(() => null);
|
||||
if (s?.isDirectory()) out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function discoverPackageDirs(
|
||||
options: DiscoverOptions = {}
|
||||
): Promise<string[]> {
|
||||
const root = options.root ?? CONFIG.paths.root;
|
||||
const base = join(root, options.packagesRoot ?? CONFIG.paths.packagesRoot);
|
||||
|
||||
const dirs: string[] = [];
|
||||
|
||||
const level1 = await listDirs(base);
|
||||
for (const lvl1 of level1) {
|
||||
// Flat: packages/<pkg>/package.json
|
||||
if (await hasPackageJson(lvl1)) {
|
||||
dirs.push(lvl1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const level2 = await listDirs(lvl1);
|
||||
for (const lvl2 of level2) {
|
||||
// Legacy 2-level: packages/<plugin>/<pkg>/package.json
|
||||
if (await hasPackageJson(lvl2)) {
|
||||
dirs.push(lvl2);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Canonical 3-level: packages/<category>/<plugin>/<pkg>/package.json
|
||||
const level3 = await listDirs(lvl2);
|
||||
for (const lvl3 of level3) {
|
||||
if (await hasPackageJson(lvl3)) {
|
||||
dirs.push(lvl3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dirs.sort();
|
||||
return dirs;
|
||||
}
|
||||
260
scripts/lib/doc-tree.ts
Normal file
260
scripts/lib/doc-tree.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user