chore: migrate skill generator to TypeScript

This commit is contained in:
Nathan Flurry 2026-02-12 15:29:14 -08:00
parent 783ea1086a
commit 3545139cd3
2 changed files with 84 additions and 45 deletions

View file

@ -16,7 +16,7 @@ jobs:
node-version: 20 node-version: 20
- name: Generate skill artifacts - name: Generate skill artifacts
run: node scripts/skill-generator/generate.js run: npx --yes tsx@4.21.0 scripts/skill-generator/generate.ts
- name: Sync to skills repo - name: Sync to skills repo
env: env:

View file

@ -1,24 +1,51 @@
#!/usr/bin/env node #!/usr/bin/env node
const fs = require("node:fs"); import fs from "node:fs";
const fsp = require("node:fs/promises"); import fsp from "node:fs/promises";
const path = require("node:path"); import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DOCS_ROOT = path.resolve(__dirname, "..", "..", "docs"); const DOCS_ROOT = path.resolve(__dirname, "..", "..", "docs");
const OUTPUT_ROOT = path.resolve(__dirname, "dist"); const OUTPUT_ROOT = path.resolve(__dirname, process.env.SKILL_GENERATOR_OUTPUT_ROOT ?? "dist");
const TEMPLATE_PATH = path.resolve(__dirname, "template", "SKILL.md"); const TEMPLATE_PATH = path.resolve(__dirname, "template", "SKILL.md");
const DOCS_BASE_URL = "https://sandboxagent.dev/docs"; const DOCS_BASE_URL = "https://sandboxagent.dev/docs";
async function main() { type Reference = {
slug: string;
title: string;
description: string;
canonicalUrl: string;
referencePath: string;
};
async function main(): Promise<void> {
if (!fs.existsSync(DOCS_ROOT)) { if (!fs.existsSync(DOCS_ROOT)) {
throw new Error(`Docs directory not found at ${DOCS_ROOT}`); throw new Error(`Docs directory not found at ${DOCS_ROOT}`);
} }
await fsp.rm(OUTPUT_ROOT, { recursive: true, force: true }); try {
await fsp.rm(OUTPUT_ROOT, { recursive: true, force: true });
} catch (error: any) {
if (error?.code === "EACCES") {
throw new Error(
[
`Failed to delete skill output directory due to permissions: ${OUTPUT_ROOT}`,
"",
"If this directory was created by a different user (for example via Docker), either fix ownership/permissions",
"or rerun with a different output directory:",
"",
' SKILL_GENERATOR_OUTPUT_ROOT="dist-dev" npx --yes tsx@4.21.0 scripts/skill-generator/generate.ts',
].join("\n"),
);
}
throw error;
}
await fsp.mkdir(path.join(OUTPUT_ROOT, "references"), { recursive: true }); await fsp.mkdir(path.join(OUTPUT_ROOT, "references"), { recursive: true });
const docFiles = await listDocFiles(DOCS_ROOT); const docFiles = await listDocFiles(DOCS_ROOT);
const references = []; const references: Reference[] = [];
for (const filePath of docFiles) { for (const filePath of docFiles) {
const relPath = normalizePath(path.relative(DOCS_ROOT, filePath)); const relPath = normalizePath(path.relative(DOCS_ROOT, filePath));
@ -78,9 +105,9 @@ async function main() {
console.log(`Generated skill files in ${OUTPUT_ROOT}`); console.log(`Generated skill files in ${OUTPUT_ROOT}`);
} }
async function listDocFiles(dir) { async function listDocFiles(dir: string): Promise<string[]> {
const entries = await fsp.readdir(dir, { withFileTypes: true }); const entries = await fsp.readdir(dir, { withFileTypes: true });
const files = []; const files: string[] = [];
for (const entry of entries) { for (const entry of entries) {
const fullPath = path.join(dir, entry.name); const fullPath = path.join(dir, entry.name);
@ -96,19 +123,19 @@ async function listDocFiles(dir) {
return files; return files;
} }
function parseFrontmatter(content) { function parseFrontmatter(content: string): { data: Record<string, string>; body: string } {
if (!content.startsWith("---")) { if (!content.startsWith("---")) {
return { data: {}, body: content.trim() }; return { data: {} as Record<string, string>, body: content.trim() };
} }
const match = content.match(/^---\n([\s\S]*?)\n---\n?/); const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
if (!match) { if (!match) {
return { data: {}, body: content.trim() }; return { data: {} as Record<string, string>, body: content.trim() };
} }
const frontmatter = match[1]; const frontmatter = match[1];
const body = content.slice(match[0].length); const body = content.slice(match[0].length);
const data = {}; const data: Record<string, string> = {};
for (const line of frontmatter.split("\n")) { for (const line of frontmatter.split("\n")) {
const trimmed = line.trim(); const trimmed = line.trim();
@ -124,7 +151,7 @@ function parseFrontmatter(content) {
return { data, body: body.trim() }; return { data, body: body.trim() };
} }
function toSlug(relPath) { function toSlug(relPath: string): string {
const withoutExt = stripExtension(relPath); const withoutExt = stripExtension(relPath);
const normalized = withoutExt.replace(/\\/g, "/"); const normalized = withoutExt.replace(/\\/g, "/");
if (normalized.endsWith("/index")) { if (normalized.endsWith("/index")) {
@ -133,18 +160,25 @@ function toSlug(relPath) {
return normalized; return normalized;
} }
function stripExtension(value) { function stripExtension(value: string): string {
return value.replace(/\.mdx?$/i, ""); return value.replace(/\.mdx?$/i, "");
} }
function titleFromSlug(value) { function titleFromSlug(value: string): string {
const cleaned = value.replace(/\.mdx?$/i, "").replace(/\\/g, "/"); const cleaned = value.replace(/\.mdx?$/i, "").replace(/\\/g, "/");
const parts = cleaned.split("/").filter(Boolean); const parts = cleaned.split("/").filter(Boolean);
const last = parts[parts.length - 1] || "index"; const last = parts[parts.length - 1] || "index";
return formatSegment(last); return formatSegment(last);
} }
function buildReferenceFile({ title, description, canonicalUrl, sourcePath, body }) { function buildReferenceFile(args: {
title: string;
description: string;
canonicalUrl: string;
sourcePath: string;
body: string;
}): string {
const { title, description, canonicalUrl, sourcePath, body } = args;
const lines = [ const lines = [
`# ${title}`, `# ${title}`,
"", "",
@ -159,9 +193,9 @@ function buildReferenceFile({ title, description, canonicalUrl, sourcePath, body
return `${lines.join("\n").trim()}\n`; return `${lines.join("\n").trim()}\n`;
} }
function buildReferenceMap(references) { function buildReferenceMap(references: Reference[]): string {
const grouped = new Map(); const grouped = new Map<string, Reference[]>();
const groupRoots = new Set(); const groupRoots = new Set<string>();
for (const ref of references) { for (const ref of references) {
const segments = (ref.slug || "").split("/").filter(Boolean); const segments = (ref.slug || "").split("/").filter(Boolean);
@ -179,11 +213,15 @@ function buildReferenceMap(references) {
group = segments[0]; group = segments[0];
} }
if (!grouped.has(group)) grouped.set(group, []); const bucket = grouped.get(group);
grouped.get(group).push(ref); if (bucket) {
bucket.push(ref);
} else {
grouped.set(group, [ref]);
}
} }
const lines = []; const lines: string[] = [];
const sortedGroups = [...grouped.keys()].sort((a, b) => a.localeCompare(b)); const sortedGroups = [...grouped.keys()].sort((a, b) => a.localeCompare(b));
for (const group of sortedGroups) { for (const group of sortedGroups) {
@ -198,9 +236,9 @@ function buildReferenceMap(references) {
return lines.join("\n").trim(); return lines.join("\n").trim();
} }
function formatSegment(value) { function formatSegment(value: string): string {
if (!value) return "General"; if (!value) return "General";
const special = { const special: Record<string, string> = {
ai: "AI", ai: "AI",
sdks: "SDKs", sdks: "SDKs",
}; };
@ -212,11 +250,11 @@ function formatSegment(value) {
.join(" "); .join(" ");
} }
function normalizePath(value) { function normalizePath(value: string): string {
return value.replace(/\\/g, "/"); return value.replace(/\\/g, "/");
} }
function convertDocToMarkdown(body) { function convertDocToMarkdown(body: string): string {
const { replaced, restore } = extractCodeBlocks(body ?? ""); const { replaced, restore } = extractCodeBlocks(body ?? "");
let text = replaced; let text = replaced;
@ -260,8 +298,8 @@ function convertDocToMarkdown(body) {
return restore(text).trim(); return restore(text).trim();
} }
function extractCodeBlocks(input) { function extractCodeBlocks(input: string): { replaced: string; restore: (value: string) => string } {
const blocks = []; const blocks: string[] = [];
const replaced = input.replace(/```[\s\S]*?```/g, (match) => { const replaced = input.replace(/```[\s\S]*?```/g, (match) => {
const token = `@@CODE_BLOCK_${blocks.length}@@`; const token = `@@CODE_BLOCK_${blocks.length}@@`;
blocks.push(normalizeCodeBlock(match)); blocks.push(normalizeCodeBlock(match));
@ -274,7 +312,7 @@ function extractCodeBlocks(input) {
}; };
} }
function normalizeCodeBlock(block) { function normalizeCodeBlock(block: string): string {
const lines = block.split("\n"); const lines = block.split("\n");
if (lines.length < 2) return block.trim(); if (lines.length < 2) return block.trim();
@ -290,24 +328,25 @@ function normalizeCodeBlock(block) {
return [opening, ...normalizedContent, closing].join("\n"); return [opening, ...normalizedContent, closing].join("\n");
} }
function stripWrapperTags(input, tag) { function stripWrapperTags(input: string, tag: string): string {
const open = new RegExp(`<${tag}[^>]*>`, "gi"); const open = new RegExp(`<${tag}[^>]*>`, "gi");
const close = new RegExp(`</${tag}>`, "gi"); const close = new RegExp(`</${tag}>`, "gi");
return input.replace(open, "\n").replace(close, "\n"); return input.replace(open, "\n").replace(close, "\n");
} }
function formatHeadingBlocks(input, tag, fallback, level) { function formatHeadingBlocks(input: string, tag: string, fallback: string, level: number): string {
const heading = "#".repeat(level); const heading = "#".repeat(level);
const withTitles = input.replace( const withTitles = input.replace(
new RegExp(`<${tag}[^>]*title=(?:\"([^\"]+)\"|'([^']+)')[^>]*>`, "gi"), new RegExp(`<${tag}[^>]*title=(?:\"([^\"]+)\"|'([^']+)')[^>]*>`, "gi"),
(_, doubleQuoted, singleQuoted) => `\n${heading} ${(doubleQuoted ?? singleQuoted ?? fallback).trim()}\n\n`, (_, doubleQuoted: string | undefined, singleQuoted: string | undefined) =>
`\n${heading} ${(doubleQuoted ?? singleQuoted ?? fallback).trim()}\n\n`,
); );
const withFallback = withTitles.replace(new RegExp(`<${tag}[^>]*>`, "gi"), `\n${heading} ${fallback}\n\n`); const withFallback = withTitles.replace(new RegExp(`<${tag}[^>]*>`, "gi"), `\n${heading} ${fallback}\n\n`);
return withFallback.replace(new RegExp(`</${tag}>`, "gi"), "\n"); return withFallback.replace(new RegExp(`</${tag}>`, "gi"), "\n");
} }
function formatCards(input) { function formatCards(input: string): string {
return input.replace(/<Card([^>]*)>([\s\S]*?)<\/Card>/gi, (_, attrs, content) => { return input.replace(/<Card([^>]*)>([\s\S]*?)<\/Card>/gi, (_, attrs: string, content: string) => {
const title = getAttributeValue(attrs, "title") ?? "Resource"; const title = getAttributeValue(attrs, "title") ?? "Resource";
const href = getAttributeValue(attrs, "href"); const href = getAttributeValue(attrs, "href");
const summary = collapseWhitespace(stripHtml(content)); const summary = collapseWhitespace(stripHtml(content));
@ -317,17 +356,17 @@ function formatCards(input) {
}); });
} }
function applyCallouts(input, tag) { function applyCallouts(input: string, tag: string): string {
const regex = new RegExp(`<${tag}[^>]*>([\s\S]*?)</${tag}>`, "gi"); const regex = new RegExp(`<${tag}[^>]*>([\s\S]*?)</${tag}>`, "gi");
return input.replace(regex, (_, content) => { return input.replace(regex, (_, content: string) => {
const label = tag.toUpperCase(); const label = tag.toUpperCase();
const text = collapseWhitespace(stripHtml(content)); const text = collapseWhitespace(stripHtml(content));
return `\n> **${label}:** ${text}\n\n`; return `\n> **${label}:** ${text}\n\n`;
}); });
} }
function replaceImages(input) { function replaceImages(input: string): string {
return input.replace(/<img\s+([^>]+?)\s*\/?>(?:\s*<\/img>)?/gi, (_, attrs) => { return input.replace(/<img\s+([^>]+?)\s*\/?>(?:\s*<\/img>)?/gi, (_, attrs: string) => {
const src = getAttributeValue(attrs, "src") ?? ""; const src = getAttributeValue(attrs, "src") ?? "";
const alt = getAttributeValue(attrs, "alt") ?? ""; const alt = getAttributeValue(attrs, "alt") ?? "";
if (!src) return ""; if (!src) return "";
@ -336,29 +375,29 @@ function replaceImages(input) {
}); });
} }
function getAttributeValue(attrs, name) { function getAttributeValue(attrs: string, name: string): string | undefined {
const regex = new RegExp(`${name}=(?:\"([^\"]+)\"|'([^']+)')`, "i"); const regex = new RegExp(`${name}=(?:\"([^\"]+)\"|'([^']+)')`, "i");
const match = attrs.match(regex); const match = attrs.match(regex);
if (!match) return undefined; if (!match) return undefined;
return (match[1] ?? match[2] ?? "").trim(); return (match[1] ?? match[2] ?? "").trim();
} }
function stripHtml(value) { function stripHtml(value: string): string {
return value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); return value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
} }
function collapseWhitespace(value) { function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim(); return value.replace(/\s+/g, " ").trim();
} }
function stripIndentation(input) { function stripIndentation(input: string): string {
return input return input
.split("\n") .split("\n")
.map((line) => line.replace(/^\t+/, "").replace(/^ {2,}/, "")) .map((line) => line.replace(/^\t+/, "").replace(/^ {2,}/, ""))
.join("\n"); .join("\n");
} }
main().catch((error) => { main().catch((error: unknown) => {
console.error(error); console.error(error);
process.exit(1); process.exit(1);
}); });