#!/usr/bin/env node const fs = require("node:fs"); const fsp = require("node:fs/promises"); const path = require("node:path"); const DOCS_ROOT = path.resolve(__dirname, "..", "..", "docs"); const OUTPUT_ROOT = path.resolve(__dirname, "dist"); const TEMPLATE_PATH = path.resolve(__dirname, "template", "SKILL.md"); const DOCS_BASE_URL = "https://sandboxagent.dev/docs"; async function main() { if (!fs.existsSync(DOCS_ROOT)) { throw new Error(`Docs directory not found at ${DOCS_ROOT}`); } await fsp.rm(OUTPUT_ROOT, { recursive: true, force: true }); await fsp.mkdir(path.join(OUTPUT_ROOT, "references"), { recursive: true }); const docFiles = await listDocFiles(DOCS_ROOT); const references = []; for (const filePath of docFiles) { const relPath = normalizePath(path.relative(DOCS_ROOT, filePath)); const raw = await fsp.readFile(filePath, "utf8"); const { data, body } = parseFrontmatter(raw); const slug = toSlug(relPath); const canonicalUrl = slug ? `${DOCS_BASE_URL}/${slug}` : DOCS_BASE_URL; const title = data.title || titleFromSlug(slug || relPath); const description = data.description || ""; const markdown = convertDocToMarkdown(body); const referenceRelPath = `${stripExtension(relPath)}.md`; const outputPath = path.join(OUTPUT_ROOT, "references", referenceRelPath); await fsp.mkdir(path.dirname(outputPath), { recursive: true }); const referenceFile = buildReferenceFile({ title, description, canonicalUrl, sourcePath: `docs/${relPath}`, body: markdown, }); await fsp.writeFile(outputPath, referenceFile, "utf8"); references.push({ slug, title, description, canonicalUrl, referencePath: `references/${referenceRelPath}`, }); } const quickstart = references.find((ref) => ref.slug === "quickstart"); if (!quickstart) { throw new Error("Quickstart doc not found. Expected docs/quickstart.mdx"); } const quickstartPath = path.join(DOCS_ROOT, "quickstart.mdx"); const quickstartRaw = await fsp.readFile(quickstartPath, "utf8"); const { body: quickstartBody } = parseFrontmatter(quickstartRaw); const quickstartContent = convertDocToMarkdown(quickstartBody); const referenceMap = buildReferenceMap(references); const template = await fsp.readFile(TEMPLATE_PATH, "utf8"); const skillFile = template.replace("{{QUICKSTART}}", quickstartContent).replace("{{REFERENCE_MAP}}", referenceMap); await fsp.writeFile( path.join(OUTPUT_ROOT, "SKILL.md"), `${skillFile.trim()} `, "utf8", ); console.log(`Generated skill files in ${OUTPUT_ROOT}`); } async function listDocFiles(dir) { const entries = await fsp.readdir(dir, { withFileTypes: true }); const files = []; for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...(await listDocFiles(fullPath))); continue; } if (!entry.isFile()) continue; if (!/\.mdx?$/.test(entry.name)) continue; files.push(fullPath); } return files; } function parseFrontmatter(content) { if (!content.startsWith("---")) { return { data: {}, body: content.trim() }; } const match = content.match(/^---\n([\s\S]*?)\n---\n?/); if (!match) { return { data: {}, body: content.trim() }; } const frontmatter = match[1]; const body = content.slice(match[0].length); const data = {}; for (const line of frontmatter.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const idx = trimmed.indexOf(":"); if (idx === -1) continue; const key = trimmed.slice(0, idx).trim(); let value = trimmed.slice(idx + 1).trim(); value = value.replace(/^"|"$/g, "").replace(/^'|'$/g, ""); data[key] = value; } return { data, body: body.trim() }; } function toSlug(relPath) { const withoutExt = stripExtension(relPath); const normalized = withoutExt.replace(/\\/g, "/"); if (normalized.endsWith("/index")) { return normalized.slice(0, -"/index".length); } return normalized; } function stripExtension(value) { return value.replace(/\.mdx?$/i, ""); } function titleFromSlug(value) { const cleaned = value.replace(/\.mdx?$/i, "").replace(/\\/g, "/"); const parts = cleaned.split("/").filter(Boolean); const last = parts[parts.length - 1] || "index"; return formatSegment(last); } function buildReferenceFile({ title, description, canonicalUrl, sourcePath, body }) { const lines = [ `# ${title}`, "", `> Source: \`${sourcePath}\``, `> Canonical URL: ${canonicalUrl}`, `> Description: ${description || ""}`, "", "---", body.trim(), ]; return `${lines.join("\n").trim()}\n`; } function buildReferenceMap(references) { const grouped = new Map(); const groupRoots = new Set(); for (const ref of references) { const segments = (ref.slug || "").split("/").filter(Boolean); if (segments.length > 1) { groupRoots.add(segments[0]); } } for (const ref of references) { const segments = (ref.slug || "").split("/").filter(Boolean); let group = "general"; if (segments.length > 1) { group = segments[0]; } else if (segments.length === 1 && groupRoots.has(segments[0])) { group = segments[0]; } if (!grouped.has(group)) grouped.set(group, []); grouped.get(group).push(ref); } const lines = []; const sortedGroups = [...grouped.keys()].sort((a, b) => a.localeCompare(b)); for (const group of sortedGroups) { lines.push(`### ${formatSegment(group)}`, ""); const items = grouped .get(group) .slice() .sort((a, b) => a.title.localeCompare(b.title)); for (const item of items) { lines.push(`- [${item.title}](${item.referencePath})`); } lines.push(""); } return lines.join("\n").trim(); } function formatSegment(value) { if (!value) return "General"; const special = { ai: "AI", sdks: "SDKs", }; if (special[value]) return special[value]; if (value === "general") return "General"; return value .split("-") .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); } function normalizePath(value) { return value.replace(/\\/g, "/"); } function convertDocToMarkdown(body) { const { replaced, restore } = extractCodeBlocks(body ?? ""); let text = replaced; text = text.replace(/^[ \t]*import\s+[^;]+;?\s*$/gm, ""); text = text.replace(/^[ \t]*export\s+[^;]+;?\s*$/gm, ""); text = text.replace(/\{\/\*[\s\S]*?\*\/\}/g, ""); text = stripWrapperTags(text, "Steps"); text = stripWrapperTags(text, "Tabs"); text = stripWrapperTags(text, "CardGroup"); text = stripWrapperTags(text, "CodeGroup"); text = stripWrapperTags(text, "AccordionGroup"); text = stripWrapperTags(text, "Frame"); text = formatHeadingBlocks(text, "Step", "Step", 3); text = formatHeadingBlocks(text, "Tab", "Tab", 4); text = formatHeadingBlocks(text, "Accordion", "Details", 4); text = formatCards(text); text = applyCallouts(text, "Tip"); text = applyCallouts(text, "Note"); text = applyCallouts(text, "Warning"); text = applyCallouts(text, "Info"); text = applyCallouts(text, "Callout"); text = replaceImages(text); text = text.replace(/]*>/gi, "").replace(/<\/Card>/gi, ""); text = text.replace(/]*>/gi, "").replace(/<\/Steps>/gi, ""); text = text.replace(/]*>/gi, "").replace(/<\/Tabs>/gi, ""); text = text.replace(/]*>/gi, "").replace(/<\/Step>/gi, ""); text = text.replace(/]*>/gi, "").replace(/<\/Tab>/gi, ""); text = text.replace(/]*>/gi, "").replace(/<\/Accordion>/gi, ""); text = text.replace(/]*>/gi, "").replace(/<\/Frame>/gi, ""); text = text.replace(/<[A-Z][A-Za-z0-9]*[^>]*>/g, "").replace(/<\/[A-Z][A-Za-z0-9]*>/g, ""); text = stripIndentation(text); text = text.replace(/\n{3,}/g, "\n\n"); return restore(text).trim(); } function extractCodeBlocks(input) { const blocks = []; const replaced = input.replace(/```[\s\S]*?```/g, (match) => { const token = `@@CODE_BLOCK_${blocks.length}@@`; blocks.push(normalizeCodeBlock(match)); return token; }); return { replaced, restore: (value) => value.replace(/@@CODE_BLOCK_(\d+)@@/g, (_, index) => blocks[Number(index)] ?? ""), }; } function normalizeCodeBlock(block) { const lines = block.split("\n"); if (lines.length < 2) return block.trim(); const opening = lines[0].trim(); const closing = lines[lines.length - 1].trim(); const contentLines = lines.slice(1, -1); const indents = contentLines.filter((line) => line.trim() !== "").map((line) => line.match(/^\s*/)?.[0].length ?? 0); const minIndent = indents.length ? Math.min(...indents) : 0; const normalizedContent = contentLines.map((line) => line.slice(minIndent)); return [opening, ...normalizedContent, closing].join("\n"); } function stripWrapperTags(input, tag) { const open = new RegExp(`<${tag}[^>]*>`, "gi"); const close = new RegExp(``, "gi"); return input.replace(open, "\n").replace(close, "\n"); } function formatHeadingBlocks(input, tag, fallback, level) { const heading = "#".repeat(level); const withTitles = input.replace( new RegExp(`<${tag}[^>]*title=(?:\"([^\"]+)\"|'([^']+)')[^>]*>`, "gi"), (_, doubleQuoted, singleQuoted) => `\n${heading} ${(doubleQuoted ?? singleQuoted ?? fallback).trim()}\n\n`, ); const withFallback = withTitles.replace(new RegExp(`<${tag}[^>]*>`, "gi"), `\n${heading} ${fallback}\n\n`); return withFallback.replace(new RegExp(``, "gi"), "\n"); } function formatCards(input) { return input.replace(/]*)>([\s\S]*?)<\/Card>/gi, (_, attrs, content) => { const title = getAttributeValue(attrs, "title") ?? "Resource"; const href = getAttributeValue(attrs, "href"); const summary = collapseWhitespace(stripHtml(content)); const link = href ? `[${title}](${href})` : title; const suffix = summary ? ` — ${summary}` : ""; return `\n- ${link}${suffix}\n\n`; }); } function applyCallouts(input, tag) { const regex = new RegExp(`<${tag}[^>]*>([\s\S]*?)`, "gi"); return input.replace(regex, (_, content) => { const label = tag.toUpperCase(); const text = collapseWhitespace(stripHtml(content)); return `\n> **${label}:** ${text}\n\n`; }); } function replaceImages(input) { return input.replace(/]+?)\s*\/?>(?:\s*<\/img>)?/gi, (_, attrs) => { const src = getAttributeValue(attrs, "src") ?? ""; const alt = getAttributeValue(attrs, "alt") ?? ""; if (!src) return ""; const url = src.startsWith("/") ? `${DOCS_BASE_URL}${src}` : src; return `![${alt}](${url})`; }); } function getAttributeValue(attrs, name) { const regex = new RegExp(`${name}=(?:\"([^\"]+)\"|'([^']+)')`, "i"); const match = attrs.match(regex); if (!match) return undefined; return (match[1] ?? match[2] ?? "").trim(); } function stripHtml(value) { return value .replace(/<[^>]+>/g, " ") .replace(/\s+/g, " ") .trim(); } function collapseWhitespace(value) { return value.replace(/\s+/g, " ").trim(); } function stripIndentation(input) { return input .split("\n") .map((line) => line.replace(/^\t+/, "").replace(/^ {2,}/, "")) .join("\n"); } main().catch((error) => { console.error(error); process.exit(1); });