UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

176 lines (175 loc) 6.41 kB
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; } }