From 3545139cd32f05f692b34212e1f5c44a7d93be03 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Thu, 12 Feb 2026 15:29:14 -0800 Subject: [PATCH] chore: migrate skill generator to TypeScript --- .github/workflows/skill-generator.yml | 2 +- .../{generate.js => generate.ts} | 127 ++++++++++++------ 2 files changed, 84 insertions(+), 45 deletions(-) rename scripts/skill-generator/{generate.js => generate.ts} (74%) diff --git a/.github/workflows/skill-generator.yml b/.github/workflows/skill-generator.yml index f9a81ec5..7127ccf3 100644 --- a/.github/workflows/skill-generator.yml +++ b/.github/workflows/skill-generator.yml @@ -16,7 +16,7 @@ jobs: node-version: 20 - 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 env: diff --git a/scripts/skill-generator/generate.js b/scripts/skill-generator/generate.ts similarity index 74% rename from scripts/skill-generator/generate.js rename to scripts/skill-generator/generate.ts index 81556eba..d9ffded7 100644 --- a/scripts/skill-generator/generate.js +++ b/scripts/skill-generator/generate.ts @@ -1,24 +1,51 @@ #!/usr/bin/env node -const fs = require("node:fs"); -const fsp = require("node:fs/promises"); -const path = require("node:path"); +import fs from "node:fs"; +import fsp from "node:fs/promises"; +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 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 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 { if (!fs.existsSync(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 }); const docFiles = await listDocFiles(DOCS_ROOT); - const references = []; + const references: Reference[] = []; for (const filePath of docFiles) { const relPath = normalizePath(path.relative(DOCS_ROOT, filePath)); @@ -78,9 +105,9 @@ async function main() { console.log(`Generated skill files in ${OUTPUT_ROOT}`); } -async function listDocFiles(dir) { +async function listDocFiles(dir: string): Promise { const entries = await fsp.readdir(dir, { withFileTypes: true }); - const files = []; + const files: string[] = []; for (const entry of entries) { const fullPath = path.join(dir, entry.name); @@ -96,19 +123,19 @@ async function listDocFiles(dir) { return files; } -function parseFrontmatter(content) { +function parseFrontmatter(content: string): { data: Record; body: string } { if (!content.startsWith("---")) { - return { data: {}, body: content.trim() }; + return { data: {} as Record, body: content.trim() }; } const match = content.match(/^---\n([\s\S]*?)\n---\n?/); if (!match) { - return { data: {}, body: content.trim() }; + return { data: {} as Record, body: content.trim() }; } const frontmatter = match[1]; const body = content.slice(match[0].length); - const data = {}; + const data: Record = {}; for (const line of frontmatter.split("\n")) { const trimmed = line.trim(); @@ -124,7 +151,7 @@ function parseFrontmatter(content) { return { data, body: body.trim() }; } -function toSlug(relPath) { +function toSlug(relPath: string): string { const withoutExt = stripExtension(relPath); const normalized = withoutExt.replace(/\\/g, "/"); if (normalized.endsWith("/index")) { @@ -133,18 +160,25 @@ function toSlug(relPath) { return normalized; } -function stripExtension(value) { +function stripExtension(value: string): string { return value.replace(/\.mdx?$/i, ""); } -function titleFromSlug(value) { +function titleFromSlug(value: string): string { 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 }) { +function buildReferenceFile(args: { + title: string; + description: string; + canonicalUrl: string; + sourcePath: string; + body: string; +}): string { + const { title, description, canonicalUrl, sourcePath, body } = args; const lines = [ `# ${title}`, "", @@ -159,9 +193,9 @@ function buildReferenceFile({ title, description, canonicalUrl, sourcePath, body return `${lines.join("\n").trim()}\n`; } -function buildReferenceMap(references) { - const grouped = new Map(); - const groupRoots = new Set(); +function buildReferenceMap(references: Reference[]): string { + const grouped = new Map(); + const groupRoots = new Set(); for (const ref of references) { const segments = (ref.slug || "").split("/").filter(Boolean); @@ -179,11 +213,15 @@ function buildReferenceMap(references) { group = segments[0]; } - if (!grouped.has(group)) grouped.set(group, []); - grouped.get(group).push(ref); + const bucket = grouped.get(group); + 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)); for (const group of sortedGroups) { @@ -198,9 +236,9 @@ function buildReferenceMap(references) { return lines.join("\n").trim(); } -function formatSegment(value) { +function formatSegment(value: string): string { if (!value) return "General"; - const special = { + const special: Record = { ai: "AI", sdks: "SDKs", }; @@ -212,11 +250,11 @@ function formatSegment(value) { .join(" "); } -function normalizePath(value) { +function normalizePath(value: string): string { return value.replace(/\\/g, "/"); } -function convertDocToMarkdown(body) { +function convertDocToMarkdown(body: string): string { const { replaced, restore } = extractCodeBlocks(body ?? ""); let text = replaced; @@ -260,8 +298,8 @@ function convertDocToMarkdown(body) { return restore(text).trim(); } -function extractCodeBlocks(input) { - const blocks = []; +function extractCodeBlocks(input: string): { replaced: string; restore: (value: string) => string } { + const blocks: string[] = []; const replaced = input.replace(/```[\s\S]*?```/g, (match) => { const token = `@@CODE_BLOCK_${blocks.length}@@`; blocks.push(normalizeCodeBlock(match)); @@ -274,7 +312,7 @@ function extractCodeBlocks(input) { }; } -function normalizeCodeBlock(block) { +function normalizeCodeBlock(block: string): string { const lines = block.split("\n"); if (lines.length < 2) return block.trim(); @@ -290,24 +328,25 @@ function normalizeCodeBlock(block) { return [opening, ...normalizedContent, closing].join("\n"); } -function stripWrapperTags(input, tag) { +function stripWrapperTags(input: string, tag: string): string { 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) { +function formatHeadingBlocks(input: string, tag: string, fallback: string, level: number): string { const heading = "#".repeat(level); const withTitles = input.replace( 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`); return withFallback.replace(new RegExp(``, "gi"), "\n"); } -function formatCards(input) { - return input.replace(/]*)>([\s\S]*?)<\/Card>/gi, (_, attrs, content) => { +function formatCards(input: string): string { + return input.replace(/]*)>([\s\S]*?)<\/Card>/gi, (_, attrs: string, content: string) => { const title = getAttributeValue(attrs, "title") ?? "Resource"; const href = getAttributeValue(attrs, "href"); 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]*?)`, "gi"); - return input.replace(regex, (_, content) => { + return input.replace(regex, (_, content: string) => { 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) => { +function replaceImages(input: string): string { + return input.replace(/]+?)\s*\/?>(?:\s*<\/img>)?/gi, (_, attrs: string) => { const src = getAttributeValue(attrs, "src") ?? ""; const alt = getAttributeValue(attrs, "alt") ?? ""; 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 match = attrs.match(regex); if (!match) return undefined; return (match[1] ?? match[2] ?? "").trim(); } -function stripHtml(value) { +function stripHtml(value: string): string { return value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); } -function collapseWhitespace(value) { +function collapseWhitespace(value: string): string { return value.replace(/\s+/g, " ").trim(); } -function stripIndentation(input) { +function stripIndentation(input: string): string { return input .split("\n") .map((line) => line.replace(/^\t+/, "").replace(/^ {2,}/, "")) .join("\n"); } -main().catch((error) => { +main().catch((error: unknown) => { console.error(error); process.exit(1); });