UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

598 lines (595 loc) 21.3 kB
import { execFileSync } from "node:child_process"; import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { parse as parseYaml } from "yaml"; import { pathKind } from "./discover.js"; import { hasReservedSegment } from "./schema.js"; const DEFAULT_REPO_URL = "https://github.com/mesh-tech/mesh-platform"; const DEFAULT_IGNORE_LINK_PREFIXES = ["node_modules/"]; export function splitFrontMatter(markdown) { const normalized = markdown.replace(/^\uFEFF/, ""); if (!normalized.startsWith("---\n") && !normalized.startsWith("---\r\n")) { return { data: {}, body: markdown, hasFrontMatter: false }; } const end = normalized.indexOf("\n---", 4); if (end === -1) return { data: {}, body: markdown, hasFrontMatter: false }; const rawBlock = normalized.slice(4, end); const bodyStart = normalized.indexOf("\n", end + 1); const body = (bodyStart === -1 ? "" : normalized.slice(bodyStart + 1)).replace(/^\r?\n/, ""); try { const data = parseYaml(rawBlock); if (data && typeof data === "object" && !Array.isArray(data)) { return { data: data, body, hasFrontMatter: true, }; } } catch { } return { data: {}, body, hasFrontMatter: true }; } function joinFrontMatter(data, body) { const lines = Object.entries(data).map(([key, value]) => { if (typeof value === "number" || typeof value === "boolean") return `${key}: ${value}`; const text = String(value); return /^[\w .,'’()/-]+$/.test(text) ? `${key}: ${text}` : `${key}: ${JSON.stringify(text)}`; }); return `---\n${lines.join("\n")}\n---\n\n${body.replace(/^\s*\n/, "")}`; } function firstHeading(body) { let inFence = false; for (const line of body.split("\n")) { if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue; } if (inFence) continue; const match = /^#\s+(.+?)\s*#*\s*$/.exec(line); if (match) return match[1].trim(); } return undefined; } function titleizeStem(stem) { const words = stem.replace(/[-_]+/g, " ").trim(); return words.charAt(0).toUpperCase() + words.slice(1); } export function deriveTitle(frontMatter, body, source) { const fmTitle = frontMatter.title; if (typeof fmTitle === "string" && fmTitle.trim()) return fmTitle.trim(); const heading = firstHeading(body); if (heading) return heading; const stem = path.posix.basename(source).replace(/\.md$/i, ""); return titleizeStem(stem); } function scanLinks(markdown) { const hits = []; const lines = markdown.split("\n"); let offset = 0; let inFence = null; for (const line of lines) { const fenceMatch = /^(\s{0,3})(`{3,}|~{3,})/.exec(line); if (fenceMatch) { if (!inFence) { inFence = { marker: fenceMatch[2][0] }; } else if (fenceMatch[2].startsWith(inFence.marker)) { inFence = null; } offset += line.length + 1; continue; } if (inFence) { offset += line.length + 1; continue; } const refDef = /^ {0,3}\[[^\]]+\]:\s*(\S+)/.exec(line); if (refDef) { const href = refDef[1]; const start = offset + line.indexOf(href); hits.push({ start, end: start + href.length, href }); offset += line.length + 1; continue; } let i = 0; while (i < line.length) { const ch = line[i]; if (ch === "`") { const run = /^`+/.exec(line.slice(i))[0]; const close = line.indexOf(run, i + run.length); i = close === -1 ? line.length : close + run.length; continue; } if (ch === "<") { const close = line.indexOf(">", i); i = close === -1 ? line.length : close + 1; continue; } if (ch === "!" && line[i + 1] === "[") { i = scanBracketLink(line, i + 1, offset, hits); continue; } if (ch === "[") { i = scanBracketLink(line, i, offset, hits); continue; } i++; } offset += line.length + 1; } return hits; } function scanBracketLink(line, open, lineOffset, hits) { const closeBracket = line.indexOf("]", open); if (closeBracket === -1) return line.length; if (line[closeBracket + 1] !== "(") return closeBracket + 1; let j = closeBracket + 2; while (j < line.length && /\s/.test(line[j])) j++; if (line[j] === "<") { const end = line.indexOf(">", j); if (end === -1) return line.length; hits.push({ start: lineOffset + j + 1, end: lineOffset + end, href: line.slice(j + 1, end), }); return end + 1; } const closeParen = line.indexOf(")", j); if (closeParen === -1) return line.length; const inner = line.slice(j, closeParen).trim(); const space = inner.search(/\s/); const href = space === -1 ? inner : inner.slice(0, space); if (href) { const start = lineOffset + line.indexOf(href, j); hits.push({ start, end: start + href.length, href }); } return closeParen + 1; } export function stripHtmlComments(body) { const lines = body.split("\n"); const out = []; let inFence = false; let inComment = false; for (const line of lines) { if (/^\s*(```|~~~)/.test(line)) { if (!inComment) inFence = !inFence; if (!inComment) out.push(line); continue; } if (inFence) { out.push(line); continue; } let rest = line; let rebuilt = ""; while (rest.length > 0) { if (inComment) { const end = rest.indexOf("-->"); if (end === -1) { rest = ""; break; } inComment = false; rest = rest.slice(end + 3); continue; } const start = rest.indexOf("<!--"); if (start === -1) { rebuilt += rest; break; } rebuilt += rest.slice(0, start); inComment = true; rest = rest.slice(start + 4); } if (rebuilt.trim() !== "" || !inComment) out.push(rebuilt.replace(/\s+$/, "")); } return out.join("\n").replace(/\n{3,}/g, "\n\n"); } function isAbsoluteHref(href) { return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(href); } function normalizeRepoPath(value) { return path.posix .normalize(value.replace(/\\/g, "/")) .replace(/^\.\//, "") .replace(/\/+$/, ""); } function splitHash(href) { const index = href.indexOf("#"); return index === -1 ? [href, ""] : [href.slice(0, index), href.slice(index + 1)]; } export function resolveLink(args) { const { href, pageRoute, sourceDir, sourceIndex, routes, repoUrl } = args; if (href.startsWith("#") || isAbsoluteHref(href)) return { href }; if (args.ignoreLinkPrefixes.some((prefix) => href.startsWith(prefix))) return { href }; const [rawTarget, rawHash] = splitHash(href); const hash = rawHash ? `#${rawHash}` : ""; if (rawTarget === "") return { href }; if (rawTarget.startsWith("/")) { const route = rawTarget.replace(/\/$/, "") || "/"; return routes.has(route) ? { href: `${route}${hash}` } : { href, issue: { kind: "broken", page: pageRoute, href } }; } const repoPath = normalizeRepoPath(path.posix.join(sourceDir, rawTarget)); const page = sourceIndex.get(repoPath); if (page) return { href: `${page.route}${hash}` }; for (const candidate of [`${repoPath}/index.md`]) { const indexPage = sourceIndex.get(normalizeRepoPath(candidate)); if (indexPage) return { href: `${indexPage.route}${hash}` }; } const kind = args.pathKind(repoPath); if (kind !== null) { const base = repoUrl.replace(/\/$/, "").replace(/\/(blob|tree)$/, ""); return { href: `${base}/${kind === "dir" ? "tree" : "blob"}/main/${repoPath}${hash}`, issue: { kind: "external", page: pageRoute, href }, }; } return { href, issue: { kind: "broken", page: pageRoute, href } }; } export function rewriteLinks(args) { const issues = []; const hits = scanLinks(args.body); if (hits.length === 0) return { body: args.body, issues }; const sourceDir = path.posix.dirname(args.page.source); let out = ""; let cursor = 0; for (const hit of hits) { const resolution = resolveLink({ href: hit.href, pageRoute: args.page.route, sourceDir, sourceIndex: args.sourceIndex, routes: args.routes, ignoreLinkPrefixes: args.ignoreLinkPrefixes, pathKind: args.pathKind, repoUrl: args.repoUrl, }); out += args.body.slice(cursor, hit.start) + resolution.href; cursor = hit.end; if (resolution.issue) issues.push(resolution.issue); } out += args.body.slice(cursor); return { body: out, issues }; } export function pageRoute(page) { const joined = [page.root.slug, page.routePath].filter(Boolean).join("/"); return `/${joined}`.replace(/\/$/, "") || "/"; } export function pageFilePath(route) { return route === "/" ? "index.md" : `${route.slice(1)}.md`; } export function assemblePortal(options) { const { repoRoot, discovery, outDir } = options; const repoUrl = options.repoUrl ?? DEFAULT_REPO_URL; const ignoreLinkPrefixes = options.ignoreLinkPrefixes ?? DEFAULT_IGNORE_LINK_PREFIXES; const pages = discovery.pages.map((page) => { const route = pageRoute(page); return { source: page.source, route, filePath: pageFilePath(route), title: "", sectionTitle: page.root.config.title, sectionOrder: page.root.config.order, sectionSlug: page.root.slug, }; }); const sourceIndex = new Map(pages.map((p) => [normalizeRepoPath(p.source), p])); const routes = new Set(pages.map((p) => p.route)); const brokenLinks = []; const externalLinks = []; rmSync(outDir, { recursive: true, force: true }); mkdirSync(outDir, { recursive: true }); for (const page of pages) { const raw = readFileSync(path.join(repoRoot, page.source), "utf-8"); const frontMatter = splitFrontMatter(raw); const title = deriveTitle(frontMatter.data, frontMatter.body, page.source); page.title = title; const order = frontMatter.data.order; if (typeof order === "number" && Number.isFinite(order)) page.order = order; const rewritten = rewriteLinks({ body: stripHtmlComments(frontMatter.body), page, sourceIndex, routes, ignoreLinkPrefixes, pathKind: (rel) => pathKind(path.join(repoRoot, rel)), repoUrl, }); for (const issue of rewritten.issues) { (issue.kind === "broken" ? brokenLinks : externalLinks).push(issue); } const outData = { ...frontMatter.data, title }; const blobBase = repoUrl.replace(/\/$/, "").replace(/\/(blob|tree)$/, ""); const sourceNote = `\n\n---\n\n<sub>Source: [\`${page.source}\`](${blobBase}/blob/main/${page.source}) — edit that file, not this page.</sub>\n`; const outFile = path.join(outDir, page.filePath); mkdirSync(path.dirname(outFile), { recursive: true }); writeFileSync(outFile, joinFrontMatter(outData, rewritten.body.trimEnd() + sourceNote)); } const navigation = buildNavigation(pages); const reservedViolations = pages .map((p) => p.filePath) .filter((filePath) => hasReservedSegment(filePath)); const publishManifest = { version: 1, count: pages.length, sources: pages.map((p) => p.source).sort(), }; return { outDir, pages, navigation, brokenLinks, externalLinks, publishManifest, reservedViolations, }; } function titleizeDir(name) { return titleizeStem(name); } export function buildNavigation(pages) { const sections = new Map(); for (const page of pages) { const key = page.sectionSlug; if (!sections.has(key)) { sections.set(key, { title: page.sectionTitle, order: page.sectionOrder, slug: page.sectionSlug, pages: [], }); } sections.get(key).pages.push(page); } const sortedSections = [...sections.values()].sort((a, b) => a.order - b.order || a.title.localeCompare(b.title)); const sortPages = (list) => [...list].sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER) || a.filePath.localeCompare(b.filePath)); const docRef = (page) => { const file = page.filePath.replace(/\.md$/, ""); return page.route === `/${file}` ? file : { type: "doc", file, path: page.route }; }; const items = []; for (const section of sortedSections) { const indexPage = section.pages.find((p) => p.route === `/${section.slug}` || (section.slug === "" && p.route === "/")); const rest = section.pages.filter((p) => p !== indexPage); const rootPages = []; const dirs = new Map(); for (const page of rest) { const rel = page.route.replace(/^\//, ""); const sectionPrefix = section.slug ? `${section.slug}/` : ""; const relParts = (sectionPrefix && rel.startsWith(sectionPrefix) ? rel.slice(sectionPrefix.length) : rel) .split("/") .filter(Boolean); if (relParts.length <= 1) { rootPages.push(page); continue; } const dirKey = relParts.slice(0, -1).join("/"); if (!dirs.has(dirKey)) { dirs.set(dirKey, { label: titleizeDir(relParts[relParts.length - 2]), routePath: relParts.slice(0, -1).join("/"), pages: [], dirs: new Map(), }); } dirs.get(dirKey).pages.push(page); } const byRoute = new Map(section.pages.map((p) => [p.route, p])); const childItems = sortPages(rootPages).map(docRef); for (const dir of [...dirs.values()].sort((a, b) => a.routePath.localeCompare(b.routePath))) { const dirRoute = `/${[section.slug, dir.routePath].filter(Boolean).join("/")}`.replace(/\/$/, "") || "/"; const dirIndex = byRoute.get(dirRoute); const grandchildren = sortPages(dir.pages.filter((p) => p !== dirIndex)).map(docRef); if (grandchildren.length === 0 && dirIndex) { childItems.push(docRef(dirIndex)); continue; } childItems.push({ type: "category", label: dir.label, ...(dirIndex ? { link: docRef(dirIndex) } : {}), items: grandchildren, }); } items.push({ type: "category", label: section.title, collapsible: false, ...(indexPage ? { link: docRef(indexPage) } : {}), items: childItems, }); } return [ { type: "category", label: "Documentation", link: { type: "doc", file: "index", path: "/" }, items, }, ]; } export function renderZudokuConfig(args) { const versionLine = args.baseline ? `Documents @mesh-tech/* ${args.baseline} · built from ${args.commit ?? "unknown"}` : `Local build · ${args.commit ?? "unknown"}`; const config = { metadata: { title: args.title, description: args.description, }, site: { title: args.title, logoUrl: "/", footer: { copyright: `${versionLine} · /version.json`, }, }, theme: "THEME_IMPORT", navigation: args.navigation, docs: { files: ["/content/**/*.{md,mdx}"], defaultOptions: { toc: true, showLastModified: false, }, }, redirects: [], }; const json = JSON.stringify(config, null, 2).replace(`"THEME_IMPORT"`, "portalTheme"); return `/** * GENERATED by \`mesh docs portal\` — do not edit. * * The navigation array is derived from the repo's docs.json roots and the * markdown tree beneath them. To change what the site publishes, change the * tree; to change section labels or order, change the root's docs.json. * Regenerate: pnpm exec mesh docs portal --assemble-only */ import type { ZudokuConfig } from "zudoku"; import { portalTheme } from "./theme.js"; const config: ZudokuConfig = ${json}; export default config; `; } export function renderVersionJson(args) { return `${JSON.stringify({ meshBaseline: args.baseline ?? null, commit: args.commit, builtAt: args.builtAt, }, null, 2)}\n`; } export function currentCommit(repoRoot) { try { return execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: repoRoot, encoding: "utf-8", }).trim(); } catch { return "unknown"; } } export function currentBaseline(repoRoot) { try { const pkg = JSON.parse(readFileSync(path.join(repoRoot, "libs/app-kit/package.json"), "utf-8")); return pkg.version; } catch { return undefined; } } export function publishSetAtRef(repoRoot, ref) { const git = (gitArgs) => execFileSync("git", gitArgs, { cwd: repoRoot, encoding: "utf-8", maxBuffer: 64 * 1024 * 1024, }); const allFiles = git(["ls-tree", "-r", "--name-only", ref]) .split("\n") .filter(Boolean); const docsJsonPaths = allFiles.filter((file) => file === "docs.json" || file.endsWith("/docs.json")); const published = new Set(); for (const docsJsonPath of docsJsonPaths) { const rootDir = docsJsonPath === "docs.json" ? "" : docsJsonPath.slice(0, -"/docs.json".length); if (rootDir && hasReservedSegment(rootDir)) continue; let config; try { config = JSON.parse(git(["show", `${ref}:${docsJsonPath}`])); } catch { continue; } const prefix = rootDir ? `${rootDir}/` : ""; for (const file of allFiles) { if (!file.startsWith(prefix) || !file.toLowerCase().endsWith(".md")) continue; const rel = file.slice(prefix.length); if (hasReservedSegment(rel)) continue; if (path.posix.basename(file).toLowerCase() === "readme.md") continue; published.add(file); } for (const include of config.include ?? []) { const normalized = include.replace(/\\/g, "/").replace(/^\.\//, ""); if (hasReservedSegment(normalized)) continue; if (allFiles.includes(normalized)) published.add(normalized); } } return [...published].sort(); } export function diffPublishSets(base, head) { const baseSet = new Set(base); const headSet = new Set(head); return { added: head.filter((source) => !baseSet.has(source)), removed: base.filter((source) => !headSet.has(source)), }; } export function renderPublishSetSummary(manifest, diff) { const lines = [ `## Docs portal publish set`, ``, `**${manifest.count} pages publish** from this tree.`, ``, ]; if (diff && (diff.added.length > 0 || diff.removed.length > 0)) { lines.push(`### Changes vs the merge base`, ``); for (const source of diff.added) lines.push(`- ➕ \`${source}\``); for (const source of diff.removed) lines.push(`- ➖ \`${source}\``); lines.push(``, `Growing the public surface is a deliberate act — a reviewer should see this list.`); } else if (diff) { lines.push(`No change vs the merge base.`); } lines.push(``, `<details><summary>Full publish set</summary>`, ``); for (const source of manifest.sources) lines.push(`- \`${source}\``); lines.push(``, `</details>`); return `${lines.join("\n")}\n`; }