Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/skill-generator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
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));
Expand Down Expand Up @@ -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<string[]> {
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);
Expand All @@ -96,19 +123,19 @@ async function listDocFiles(dir) {
return files;
}

function parseFrontmatter(content) {
function parseFrontmatter(content: string): { data: Record<string, string>; body: string } {
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?/);
if (!match) {
return { data: {}, body: content.trim() };
return { data: {} as Record<string, string>, body: content.trim() };
}

const frontmatter = match[1];
const body = content.slice(match[0].length);
const data = {};
const data: Record<string, string> = {};

for (const line of frontmatter.split("\n")) {
const trimmed = line.trim();
Expand All @@ -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")) {
Expand All @@ -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}`,
"",
Expand All @@ -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<string, Reference[]>();
const groupRoots = new Set<string>();

for (const ref of references) {
const segments = (ref.slug || "").split("/").filter(Boolean);
Expand All @@ -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) {
Expand All @@ -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<string, string> = {
ai: "AI",
sdks: "SDKs",
};
Expand All @@ -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;

Expand Down Expand Up @@ -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));
Expand All @@ -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();

Expand All @@ -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(`</${tag}>`, "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(`</${tag}>`, "gi"), "\n");
}

function formatCards(input) {
return input.replace(/<Card([^>]*)>([\s\S]*?)<\/Card>/gi, (_, attrs, content) => {
function formatCards(input: string): string {
return input.replace(/<Card([^>]*)>([\s\S]*?)<\/Card>/gi, (_, attrs: string, content: string) => {
const title = getAttributeValue(attrs, "title") ?? "Resource";
const href = getAttributeValue(attrs, "href");
const summary = collapseWhitespace(stripHtml(content));
Expand All @@ -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");
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(/<img\s+([^>]+?)\s*\/?>(?:\s*<\/img>)?/gi, (_, attrs) => {
function replaceImages(input: string): string {
return input.replace(/<img\s+([^>]+?)\s*\/?>(?:\s*<\/img>)?/gi, (_, attrs: string) => {
const src = getAttributeValue(attrs, "src") ?? "";
const alt = getAttributeValue(attrs, "alt") ?? "";
if (!src) return "";
Expand All @@ -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);
});
Loading