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) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-06-03 07:33:53 +02:00
co-authored by Claude Opus 4.8
parent be83085dbd
commit 4fda4a0ece
4 changed files with 126 additions and 93 deletions
+1 -1
View File
@@ -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",
+2 -92
View File
@@ -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/<id>.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.
+25
View File
@@ -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);
+98
View File
@@ -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/<id>.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)`);
}