From 4fda4a0ece3f69ad4a5678df7659ada49061f1c0 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 3 Jun 2026 07:33:53 +0200 Subject: [PATCH] Serve _data/api JSON in dev server app.js fetches /_data/api/commands.json and patterns.json, but those are build artifacts written into site/public/_data/ by scripts/build.js. The plain `astro dev` server never runs that build, so the homepage 404'd on both in dev. Extract generateApiData into scripts/lib/api-data.js (shared by the build and a new scripts/gen-dev-api.mjs prebuild), and run the prebuild before astro dev so `bun run dev` serves the same payloads as production. site/public/_data/ stays gitignored. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- scripts/build.js | 94 +-------------------------------------- scripts/gen-dev-api.mjs | 25 +++++++++++ scripts/lib/api-data.js | 98 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 93 deletions(-) create mode 100644 scripts/gen-dev-api.mjs create mode 100644 scripts/lib/api-data.js diff --git a/package.json b/package.json index 2effebe0e..19dbbf777 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "build:extension": "node scripts/build-extension.js", "clean": "rm -rf dist build", "rebuild": "bun run clean && bun run build", - "dev": "npx astro dev", + "dev": "bun run scripts/gen-dev-api.mjs && npx astro dev", "preview": "bun run build && npx astro preview", "deploy": "bun run build && wrangler pages deploy build/", "test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js tests/lib/detector-bundle.test.js tests/lib/provider-blocks.test.js tests/lib/transformers/provider-blocks.test.js tests/lib/utils.test.js tests/lib/transformers/factory.test.js tests/lib/transformers/providers.test.js tests/skills-cli.test.js && node --test tests/critique-storage.test.mjs && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/impeccable-paths.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-wrap-buffer-aware.test.mjs && node --test tests/live-insert.test.mjs && node --test tests/live-insert-ui.test.mjs && node --test tests/live-event-validation.test.mjs && node --test tests/live-reference.test.mjs && node --test tests/live-e2e-agent-output.test.mjs && node --test tests/live-e2e-llm-agent.test.mjs && node --test tests/live-e2e-cli-options.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-accept-scrub.test.mjs && node --test tests/live-commit-manual-edits.test.mjs && node --test tests/live-discard-manual-edits.test.mjs && node --test tests/live-manual-edits-buffer.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-poll.test.mjs && node --test tests/live-poll-stream.test.mjs && node --test tests/live-server.test.mjs && node --test tests/live-copy-edit-agent.test.mjs && node --test tests/live-browser-regression.test.mjs && node --test tests/live-session-store.test.mjs && node --test tests/live-browser-session.test.mjs && node --test tests/live-browser-source.test.mjs && node --test tests/live-completion.test.mjs && node --test tests/live-recovery-commands.test.mjs && node --test tests/framework-fixtures.test.mjs", diff --git a/scripts/build.js b/scripts/build.js index e1686b720..abf9e5291 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -19,6 +19,7 @@ import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import { readSourceFiles, readPatterns, stashPerProjectArtifacts, restorePerProjectArtifacts } from './lib/utils.js'; +import { generateApiData } from './lib/api-data.js'; import { createTransformer, PROVIDERS } from './lib/transformers/index.js'; import { createAllZips } from './lib/zip.js'; import { ANTIPATTERNS } from '../cli/engine/registry/antipatterns.mjs'; @@ -459,97 +460,6 @@ These are hidden folders (dotfiles). Press Cmd+Shift+. in Finder to see them. console.log(`✓ Assembled universal directory (${providerConfigs.length} providers)`); } -/** - * Generate static API data for Cloudflare Pages deployment. - * Pre-builds all API responses as JSON files so they can be served - * as static assets via _redirects rewrites (no function invocations needed). - */ -function generateApiData(buildDir, skills, patterns) { - const apiDir = path.join(buildDir, '_data', 'api'); - fs.mkdirSync(apiDir, { recursive: true }); - - // skills.json - const skillsData = skills.map(s => ({ - id: path.basename(path.dirname(s.filePath)), - name: s.name, - description: s.description, - userInvocable: s.userInvocable, - })); - fs.writeFileSync(path.join(apiDir, 'skills.json'), JSON.stringify(skillsData)); - - // commands.json - after v3.0 consolidation, commands are sub-commands of - // /impeccable. Load them from command-metadata.json and include the root - // impeccable skill itself so UI surfaces like the cheatsheet can list them. - // Each entry also picks up a short `tagline` from its editorial file - // (site/content/skills/.md) when one exists. Taglines are used by UI - // surfaces that need a human-friendly one-liner, while `description` stays - // optimized for auto-trigger keyword matching in the AI harness. - const readTagline = (id) => { - const editorialPath = path.join(ROOT_DIR, 'site/content/skills', `${id}.md`); - if (!fs.existsSync(editorialPath)) return null; - const raw = fs.readFileSync(editorialPath, 'utf-8'); - const match = raw.match(/^---\n([\s\S]*?)\n---/); - if (!match) return null; - const taglineMatch = match[1].match(/tagline:\s*"([^"]+)"/); - return taglineMatch ? taglineMatch[1] : null; - }; - - const metadataPath = path.join(ROOT_DIR, 'skill/scripts/command-metadata.json'); - if (!fs.existsSync(metadataPath)) { - throw new Error(`command-metadata.json is missing at ${metadataPath}. This file is required to generate the commands API.`); - } - const impeccable = skills.find(s => s.name === 'impeccable'); - if (!impeccable) { - throw new Error('impeccable skill not found at skill/SKILL.src.md. The build system expects exactly one skill at that path.'); - } - - const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); - const commandsData = [ - { - id: 'impeccable', - name: 'impeccable', - description: impeccable.description, - tagline: readTagline('impeccable'), - userInvocable: true, - }, - ...Object.entries(metadata).map(([id, meta]) => ({ - id, - name: id, - description: meta.description, - tagline: readTagline(id), - userInvocable: true, - })), - ]; - fs.writeFileSync(path.join(apiDir, 'commands.json'), JSON.stringify(commandsData)); - - // patterns.json - fs.writeFileSync(path.join(apiDir, 'patterns.json'), JSON.stringify(patterns)); - - // version.json - a tiny endpoint the installed skill polls on boot - // (skill/scripts/context.mjs) to nudge users toward `npx impeccable skills - // update`. Kept deliberately small so the boot-time check is cheap, unlike - // the full bundle download `skills check` performs. The skills version is - // the canonical one in the Claude plugin manifest. - const pluginManifestPath = path.join(ROOT_DIR, '.claude-plugin/plugin.json'); - const skillsVersion = JSON.parse(fs.readFileSync(pluginManifestPath, 'utf-8')).version; - fs.writeFileSync(path.join(apiDir, 'version.json'), JSON.stringify({ skills: skillsVersion })); - - // command-source/{id}.json (one per skill) - const cmdSourceDir = path.join(apiDir, 'command-source'); - fs.mkdirSync(cmdSourceDir, { recursive: true }); - for (const skill of skills) { - const id = path.basename(path.dirname(skill.filePath)); - const content = fs.readFileSync(skill.filePath, 'utf-8'); - fs.writeFileSync( - path.join(cmdSourceDir, `${id}.json`), - JSON.stringify({ content }) - ); - } - - const skillWord = skillsData.length === 1 ? 'skill' : 'skills'; - console.log(`✓ Generated static API data (${skillsData.length} ${skillWord}, ${commandsData.length} commands)`); -} - /** * Copy dist files to build output for Cloudflare Pages Functions access. * Download functions use env.ASSETS.fetch() to read these files. @@ -703,7 +613,7 @@ async function build() { // Astro wipes build/ before writing, so anything written directly to build/ // during build:skills would be destroyed when build:site runs. const publicDir = path.join(ROOT_DIR, 'site', 'public'); - generateApiData(publicDir, skills, patterns); + generateApiData(publicDir, skills, patterns, ROOT_DIR); generateCFConfig(publicDir); // Copy all provider outputs to project root for local testing. diff --git a/scripts/gen-dev-api.mjs b/scripts/gen-dev-api.mjs new file mode 100644 index 000000000..d59db0c32 --- /dev/null +++ b/scripts/gen-dev-api.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +/** + * Dev-only prebuild: write the `_data/api/*.json` payloads into site/public/ + * so `bun run dev` (astro dev) serves them. In production these are generated + * by scripts/build.js; the plain `astro dev` server never runs that build, so + * without this step app.js 404s on /_data/api/commands.json and patterns.json. + * + * site/public/_data/ is gitignored, so this only ever produces local artifacts. + * Reuses the exact production generator (scripts/lib/api-data.js) so dev output + * matches prod. Runs once at dev startup; editing command-metadata.json or the + * pattern catalog mid-session needs a dev-server restart (same as other + * source edits under skill/ and site/content/). + */ + +import path from 'path'; +import { fileURLToPath } from 'url'; +import { readSourceFiles, readPatterns } from './lib/utils.js'; +import { generateApiData } from './lib/api-data.js'; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const { skills } = readSourceFiles(ROOT_DIR); +const patterns = readPatterns(ROOT_DIR); +generateApiData(path.join(ROOT_DIR, 'site', 'public'), skills, patterns, ROOT_DIR); diff --git a/scripts/lib/api-data.js b/scripts/lib/api-data.js new file mode 100644 index 000000000..e73de0c9e --- /dev/null +++ b/scripts/lib/api-data.js @@ -0,0 +1,98 @@ +import path from 'path'; +import fs from 'fs'; + +/** + * Generate static API data for Cloudflare Pages deployment. + * Pre-builds all API responses as JSON files so they can be served + * as static assets via _redirects rewrites (no function invocations needed). + * + * Shared by the production build (scripts/build.js, writing into site/public/) + * and the dev prebuild (scripts/gen-dev-api.mjs) so `bun run dev` serves the + * same payloads `app.js` fetches in production. `outDir` is the directory that + * gets a `_data/api/` tree; `rootDir` is the repo root. + */ +export function generateApiData(outDir, skills, patterns, rootDir) { + const apiDir = path.join(outDir, '_data', 'api'); + fs.mkdirSync(apiDir, { recursive: true }); + + // skills.json + const skillsData = skills.map(s => ({ + id: path.basename(path.dirname(s.filePath)), + name: s.name, + description: s.description, + userInvocable: s.userInvocable, + })); + fs.writeFileSync(path.join(apiDir, 'skills.json'), JSON.stringify(skillsData)); + + // commands.json - after v3.0 consolidation, commands are sub-commands of + // /impeccable. Load them from command-metadata.json and include the root + // impeccable skill itself so UI surfaces like the cheatsheet can list them. + // Each entry also picks up a short `tagline` from its editorial file + // (site/content/skills/.md) when one exists. Taglines are used by UI + // surfaces that need a human-friendly one-liner, while `description` stays + // optimized for auto-trigger keyword matching in the AI harness. + const readTagline = (id) => { + const editorialPath = path.join(rootDir, 'site/content/skills', `${id}.md`); + if (!fs.existsSync(editorialPath)) return null; + const raw = fs.readFileSync(editorialPath, 'utf-8'); + const match = raw.match(/^---\n([\s\S]*?)\n---/); + if (!match) return null; + const taglineMatch = match[1].match(/tagline:\s*"([^"]+)"/); + return taglineMatch ? taglineMatch[1] : null; + }; + + const metadataPath = path.join(rootDir, 'skill/scripts/command-metadata.json'); + if (!fs.existsSync(metadataPath)) { + throw new Error(`command-metadata.json is missing at ${metadataPath}. This file is required to generate the commands API.`); + } + const impeccable = skills.find(s => s.name === 'impeccable'); + if (!impeccable) { + throw new Error('impeccable skill not found at skill/SKILL.src.md. The build system expects exactly one skill at that path.'); + } + + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + const commandsData = [ + { + id: 'impeccable', + name: 'impeccable', + description: impeccable.description, + tagline: readTagline('impeccable'), + userInvocable: true, + }, + ...Object.entries(metadata).map(([id, meta]) => ({ + id, + name: id, + description: meta.description, + tagline: readTagline(id), + userInvocable: true, + })), + ]; + fs.writeFileSync(path.join(apiDir, 'commands.json'), JSON.stringify(commandsData)); + + // patterns.json + fs.writeFileSync(path.join(apiDir, 'patterns.json'), JSON.stringify(patterns)); + + // version.json - a tiny endpoint the installed skill polls on boot + // (skill/scripts/context.mjs) to nudge users toward `npx impeccable skills + // update`. Kept deliberately small so the boot-time check is cheap, unlike + // the full bundle download `skills check` performs. The skills version is + // the canonical one in the Claude plugin manifest. + const pluginManifestPath = path.join(rootDir, '.claude-plugin/plugin.json'); + const skillsVersion = JSON.parse(fs.readFileSync(pluginManifestPath, 'utf-8')).version; + fs.writeFileSync(path.join(apiDir, 'version.json'), JSON.stringify({ skills: skillsVersion })); + + // command-source/{id}.json (one per skill) + const cmdSourceDir = path.join(apiDir, 'command-source'); + fs.mkdirSync(cmdSourceDir, { recursive: true }); + for (const skill of skills) { + const id = path.basename(path.dirname(skill.filePath)); + const content = fs.readFileSync(skill.filePath, 'utf-8'); + fs.writeFileSync( + path.join(cmdSourceDir, `${id}.json`), + JSON.stringify({ content }) + ); + } + + const skillWord = skillsData.length === 1 ? 'skill' : 'skills'; + console.log(`✓ Generated static API data (${skillsData.length} ${skillWord}, ${commandsData.length} commands)`); +}