|
| 1 | +#!/usr/bin/env node |
| 2 | +// Sync repo docs into Starlight's content collection. |
| 3 | +// |
| 4 | +// Starlight wants Markdown under site/src/content/docs/. Our canonical docs |
| 5 | +// live at docs/**/*.md and colony/**/*.md so they stay co-located with the |
| 6 | +// code. Rather than duplicating, this script copies the canonical files into |
| 7 | +// the content dir before `astro build` runs. Never edit site/src/content/docs |
| 8 | +// by hand (except index.mdx, which is a Starlight landing page). |
| 9 | + |
| 10 | +import { readFile, writeFile, mkdir, rm, readdir, stat } from "node:fs/promises"; |
| 11 | +import { dirname, join, resolve, relative } from "node:path"; |
| 12 | +import { fileURLToPath } from "node:url"; |
| 13 | + |
| 14 | +const scriptDir = dirname(fileURLToPath(import.meta.url)); |
| 15 | +const siteDir = resolve(scriptDir, ".."); |
| 16 | +const repoRoot = resolve(siteDir, ".."); |
| 17 | +const contentDir = resolve(siteDir, "src/content/docs"); |
| 18 | + |
| 19 | +// Map: { src: path relative to repoRoot, dest: path relative to contentDir } |
| 20 | +// Keep paths aligned with site/astro.config.mjs sidebar entries. |
| 21 | +const mappings = [ |
| 22 | + { src: "docs/why-skdd.md", dest: "why-skdd.md" }, |
| 23 | + { src: "docs/configuration.md", dest: "configuration.md" }, |
| 24 | + { src: "docs/skill-colony.md", dest: "skill-colony.md" }, |
| 25 | + { src: "docs/forging-skills.md", dest: "forging-skills.md" }, |
| 26 | + { src: "docs/specification-alignment.md", dest: "specification-alignment.md" }, |
| 27 | + { src: "docs/schemastore-submission.md", dest: "schemastore-submission.md" }, |
| 28 | + { src: "colony/discovery.md", dest: "colony/discovery.md" }, |
| 29 | + { src: "colony/evolution.md", dest: "colony/evolution.md" }, |
| 30 | + { src: "docs/spec/agent-skills-v1.md", dest: "spec/agent-skills-v1.md" }, |
| 31 | + // Integrations — autogenerated by the sidebar; all markdown files that |
| 32 | + // aren't README.md (Starlight wants index.md, not README.md). |
| 33 | + { src: "docs/integrations/claude-code.md", dest: "integrations/claude-code.md" }, |
| 34 | + { src: "docs/integrations/codex.md", dest: "integrations/codex.md" }, |
| 35 | + { src: "docs/integrations/cursor.md", dest: "integrations/cursor.md" }, |
| 36 | + { src: "docs/integrations/github-copilot.md", dest: "integrations/github-copilot.md" }, |
| 37 | + { src: "docs/integrations/gemini-cli.md", dest: "integrations/gemini-cli.md" }, |
| 38 | + { src: "docs/integrations/opencode.md", dest: "integrations/opencode.md" }, |
| 39 | + { src: "docs/integrations/goose.md", dest: "integrations/goose.md" }, |
| 40 | + { src: "docs/integrations/amp.md", dest: "integrations/amp.md" }, |
| 41 | + { src: "docs/integrations/vscode.md", dest: "integrations/vscode.md" }, |
| 42 | + { src: "docs/integrations/junie.md", dest: "integrations/junie.md" }, |
| 43 | + { src: "docs/integrations/roo-code.md", dest: "integrations/roo-code.md" }, |
| 44 | +]; |
| 45 | + |
| 46 | +function extractTitle(body) { |
| 47 | + const match = body.match(/^#\s+(.+?)\s*$/m); |
| 48 | + return match ? match[1].trim() : null; |
| 49 | +} |
| 50 | + |
| 51 | +function extractDescription(body) { |
| 52 | + const match = body.match(/^>\s+(.+?)\s*$/m); |
| 53 | + if (!match) return null; |
| 54 | + return match[1].replace(/\*+/g, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim(); |
| 55 | +} |
| 56 | + |
| 57 | +function stripLeadingH1(body) { |
| 58 | + return body.replace(/^#\s+.+?\s*\n+/, ""); |
| 59 | +} |
| 60 | + |
| 61 | +function rewriteRelativeLinks(body) { |
| 62 | + // Internal .md links should resolve as Starlight slugs. Drop the .md |
| 63 | + // extension so Starlight's routing picks them up. |
| 64 | + return body.replace( |
| 65 | + /(\]\()([^)]+?)\.md(#[^)]*)?(\))/g, |
| 66 | + (_, pre, path, hash = "", post) => `${pre}${path}/${hash}${post}`, |
| 67 | + ); |
| 68 | +} |
| 69 | + |
| 70 | +function ensureFrontmatter(body, fallbackTitle) { |
| 71 | + if (body.startsWith("---\n")) return body; |
| 72 | + const title = extractTitle(body) || fallbackTitle; |
| 73 | + const description = extractDescription(body); |
| 74 | + const fm = ["---", `title: ${JSON.stringify(title)}`]; |
| 75 | + if (description) fm.push(`description: ${JSON.stringify(description)}`); |
| 76 | + fm.push("---", ""); |
| 77 | + return `${fm.join("\n")}\n${stripLeadingH1(body)}`; |
| 78 | +} |
| 79 | + |
| 80 | +async function cleanStaleGenerated() { |
| 81 | + // Remove everything under contentDir *except* index.mdx so old generated |
| 82 | + // files don't linger after a mapping changes. |
| 83 | + const entries = await readdir(contentDir); |
| 84 | + for (const entry of entries) { |
| 85 | + if (entry === "index.mdx") continue; |
| 86 | + await rm(join(contentDir, entry), { recursive: true, force: true }); |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +async function writeColonySchemaPage() { |
| 91 | + // The sidebar links /spec/colony-v1/ which isn't a markdown file — |
| 92 | + // it's a JSON schema. Generate a thin wrapper page so the link works. |
| 93 | + const schemaPath = resolve(repoRoot, "docs/spec/colony-v1.json"); |
| 94 | + const schema = await readFile(schemaPath, "utf8"); |
| 95 | + const body = `--- |
| 96 | +title: "Colony v1 schema" |
| 97 | +description: "JSON Schema for .colony.json (the manifest every SkDD colony ships)." |
| 98 | +--- |
| 99 | +
|
| 100 | +The \`.colony.json\` file at a colony root conforms to this schema. It's the |
| 101 | +machine-readable entry point for marketplaces, indexers, and \`skdd doctor\`. |
| 102 | +
|
| 103 | +See [\`schemastore-submission\`](../schemastore-submission/) for the draft PR |
| 104 | +body we'll use to register this schema with SchemaStore.org (so VS Code and |
| 105 | +JetBrains auto-complete it for every user). |
| 106 | +
|
| 107 | +\`\`\`json |
| 108 | +${schema.trimEnd()} |
| 109 | +\`\`\` |
| 110 | +`; |
| 111 | + const dest = resolve(contentDir, "spec/colony-v1.md"); |
| 112 | + await mkdir(dirname(dest), { recursive: true }); |
| 113 | + await writeFile(dest, body); |
| 114 | +} |
| 115 | + |
| 116 | +async function syncOne({ src, dest }) { |
| 117 | + const srcPath = resolve(repoRoot, src); |
| 118 | + const destPath = resolve(contentDir, dest); |
| 119 | + let body; |
| 120 | + try { |
| 121 | + body = await readFile(srcPath, "utf8"); |
| 122 | + } catch (err) { |
| 123 | + if (err.code === "ENOENT") { |
| 124 | + console.warn(`skip (missing): ${src}`); |
| 125 | + return; |
| 126 | + } |
| 127 | + throw err; |
| 128 | + } |
| 129 | + const fallbackTitle = dest.replace(/\.md$/, "").replace(/\//g, " · "); |
| 130 | + const rewritten = rewriteRelativeLinks(body); |
| 131 | + const withFm = ensureFrontmatter(rewritten, fallbackTitle); |
| 132 | + await mkdir(dirname(destPath), { recursive: true }); |
| 133 | + await writeFile(destPath, withFm); |
| 134 | + console.log(`wrote ${relative(repoRoot, destPath)}`); |
| 135 | +} |
| 136 | + |
| 137 | +async function main() { |
| 138 | + await cleanStaleGenerated(); |
| 139 | + for (const mapping of mappings) { |
| 140 | + await syncOne(mapping); |
| 141 | + } |
| 142 | + await writeColonySchemaPage(); |
| 143 | + console.log(`\n✓ synced ${mappings.length + 1} pages into ${relative(repoRoot, contentDir)}`); |
| 144 | +} |
| 145 | + |
| 146 | +main().catch((err) => { |
| 147 | + console.error(err); |
| 148 | + process.exit(1); |
| 149 | +}); |
0 commit comments