@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
176 lines (175 loc) • 6.41 kB
JavaScript
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { docsJsonSchema, hasReservedSegment, slugify } from "./schema.js";
const ROOT_WALK_SKIP = new Set([
".git",
"node_modules",
"dist",
".turbo",
".next",
".deploy",
".generated",
"coverage",
".portal",
".vercel",
".wrangler",
]);
export function routePathFor(relPath) {
const withoutExt = relPath.replace(/\.md$/i, "");
const parts = withoutExt.split("/").map(slugify);
if (parts[parts.length - 1] === "index")
parts.pop();
return parts.join("/");
}
function walkMarkdown(absDir, relDir, errors, rootDir) {
const out = [];
let entries;
try {
entries = readdirSync(absDir, { withFileTypes: true });
}
catch {
return out;
}
for (const entry of entries) {
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
if (hasReservedSegment(entry.name)) {
continue;
}
const abs = path.join(absDir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name.startsWith("."))
continue;
out.push(...walkMarkdown(abs, rel, errors, rootDir));
}
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
if (entry.name.toLowerCase() === "readme.md")
continue;
out.push(rel);
}
else if (entry.isFile() &&
entry.name === "docs.json" &&
relDir !== "") {
errors.push(`nested doc root: ${rootDir}/.../${rel} — a docs.json inside another doc root is ambiguous; give it its own sibling root or remove it`);
}
}
return out;
}
export function discoverDocRoots(repoRoot) {
const errors = [];
const roots = [];
const findRoots = (absDir, relDir, insideReserved) => {
let entries;
try {
entries = readdirSync(absDir, { withFileTypes: true });
}
catch {
return;
}
const hasDocsJson = entries.some((e) => e.isFile() && e.name === "docs.json");
if (hasDocsJson) {
const rel = relDir || ".";
if (insideReserved || (relDir && hasReservedSegment(relDir))) {
errors.push(`doc root ${rel}/ is under a reserved segment — it publishes nothing. Move the content out of the reserved directory instead.`);
}
else {
const root = loadRoot(absDir, rel, errors);
if (root)
roots.push(root);
}
return;
}
for (const entry of entries) {
if (!entry.isDirectory())
continue;
if (ROOT_WALK_SKIP.has(entry.name))
continue;
if (entry.name.startsWith("."))
continue;
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
findRoots(path.join(absDir, entry.name), rel, insideReserved || hasReservedSegment(entry.name));
}
};
findRoots(repoRoot, "", false);
const pages = [];
for (const root of roots) {
const absRoot = path.join(repoRoot, root.dir);
const relFiles = walkMarkdown(absRoot, "", errors, root.dir);
for (const relPath of relFiles.sort()) {
pages.push({
source: root.dir === "." ? relPath : `${root.dir}/${relPath}`,
root,
routePath: routePathFor(relPath),
included: false,
});
}
for (const includePath of root.config.include) {
const normalized = includePath
.replace(/\\/g, "/")
.replace(/^\.\//, "")
.replace(/\/+$/, "");
if (hasReservedSegment(normalized)) {
errors.push(`${root.dir}/docs.json includes ${normalized}, which is under a reserved segment — include[] cannot pull internal material into the site`);
continue;
}
if (!normalized.toLowerCase().endsWith(".md")) {
errors.push(`${root.dir}/docs.json includes ${normalized}, which is not a markdown file`);
continue;
}
if (!existsSync(path.join(repoRoot, normalized))) {
errors.push(`${root.dir}/docs.json includes ${normalized}, which does not exist`);
continue;
}
if (pages.some((p) => p.source === normalized))
continue;
const stem = path.posix.basename(normalized).replace(/\.md$/i, "");
pages.push({
source: normalized,
root,
routePath: stem.toLowerCase() === "readme" ? "" : slugify(stem),
included: true,
});
}
}
const seenRoutes = new Map();
for (const page of pages) {
const route = [page.root.slug, page.routePath].filter(Boolean).join("/");
const existing = seenRoutes.get(route);
if (existing) {
errors.push(`route collision: ${page.source} and ${existing} both map to /${route || "(site root)"}`);
}
seenRoutes.set(route, page.source);
}
roots.sort((a, b) => a.config.order - b.config.order ||
a.config.title.localeCompare(b.config.title));
return { roots, pages, errors };
}
function loadRoot(absDir, relDir, errors) {
const configPath = path.join(absDir, "docs.json");
let raw;
try {
raw = JSON.parse(readFileSync(configPath, "utf-8"));
}
catch (error) {
errors.push(`${relDir}/docs.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
const parsed = docsJsonSchema.safeParse(raw);
if (!parsed.success) {
for (const issue of parsed.error.issues) {
errors.push(`${relDir}/docs.json: ${issue.path.join(".") || "(root)"}: ${issue.message}`);
}
return null;
}
const config = parsed.data;
const slug = config.slug ?? slugify(config.title);
return { dir: relDir, config, slug };
}
export function pathKind(candidate) {
try {
const stat = statSync(candidate);
return stat.isDirectory() ? "dir" : "file";
}
catch {
return null;
}
}