@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
598 lines (595 loc) • 21.3 kB
JavaScript
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`;
}