diff --git a/.gitignore b/.gitignore index 1efa5e7..0c94aea 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ content/upstream/docs/ content/upstream/docs-subset/ content/upstream/sidebars.ts content/upstream/CHANGELOG.md +content/upstream/prompts.yml prototypes/docusaurus/docs/ .DS_Store diff --git a/package.json b/package.json index cc38167..818f282 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,18 @@ "scripts": { "sync:docs": "node scripts/sync-docs.mjs", "sync:docs:subset": "node scripts/sync-docs.mjs --subset", - "prepare:docs": "node scripts/prepare-docs.mjs --target docusaurus", + "prepare:docs": "node scripts/prepare-docs.mjs --target docusaurus && npm run prepare:prompts", "prepare:docs:subset": "node scripts/prepare-docs.mjs --target docusaurus --subset", + "prepare:prompts": "node scripts/prepare-prompts.mjs", + "prepare:prompts:check": "node scripts/prepare-prompts.mjs --check", "audit:docs": "node scripts/audit-docs.mjs", "prepare": "npm run sync:docs && npm run prepare:docs", "prepare:subset": "npm run sync:docs:subset && npm run prepare:docs:subset", "test:docs-layout": "node --test scripts/docs-layout.test.mjs", + "test:prepare-prompts": "node --test scripts/prepare-prompts.test.mjs", "install:site": "npm --prefix prototypes/docusaurus install", "dev": "npm --prefix prototypes/docusaurus run start", - "build": "npm --prefix prototypes/docusaurus run build", + "build": "npm run prepare:prompts:check && npm --prefix prototypes/docusaurus run build", "build:full": "npm run prepare && npm run build", "cloudflare:deploy": "npm run build:full && npx wrangler pages deploy prototypes/docusaurus/build --project-name ${CLOUDFLARE_PAGES_PROJECT:-reactonrails-com}" } diff --git a/prototypes/docusaurus/src/constants/prompts.ts b/prototypes/docusaurus/src/constants/prompts.ts index 801255d..22b2928 100644 --- a/prototypes/docusaurus/src/constants/prompts.ts +++ b/prototypes/docusaurus/src/constants/prompts.ts @@ -1,139 +1,108 @@ -import {docsRoutes} from './docsRoutes'; +/* + * GENERATED FILE - DO NOT EDIT. + * Source: content/upstream/prompts.yml + * Regenerate with `npm run prepare:prompts`. + */ +export const SITE_URL = "https://reactonrails.com"; -/** Canonical site origin; matches `url` in docusaurus.config.ts. */ -export const SITE_URL = 'https://reactonrails.com'; - -/** Shown once per surface (home Quick Start + /prompts hero). */ -export const agentNote = - "Paste into Cursor, Claude Code, Copilot, or any AI assistant. Each prompt points the agent at the official docs so it doesn't guess."; +export const agentNote = "Paste into Cursor, Claude Code, Copilot, or any AI assistant. Each prompt points the agent at the official docs so it doesn't guess."; export type PromptCategory = - | 'get-started' - | 'server-rendering' - | 'migrate' - | 'features' - | 'production'; + | "get-started" + | "server-rendering" + | "migrate" + | "features" + | "production"; export type Prompt = { - /** Stable slug; used as React key and to select the homepage subset. */ id: string; title: string; - /** Copy-able prompt text. Embeds an absolute docs URL so the agent grounds itself. */ prompt: string; - /** Docs route the "Open guide →" link points to (relative; from docsRoutes). */ href: string; category: PromptCategory; }; -/** Absolute docs URL embedded in prompt text so agents fetch the real guide. */ -function docUrl(route: string): string { - return `${SITE_URL}${route}`; -} - export const prompts: Prompt[] = [ { - id: 'turn-on-rsc', - title: 'Turn on React Server Components', - prompt: `Turn on React Server Components in my React on Rails app (no license required). Follow ${docUrl( - docsRoutes.proReactServerComponents, - )} exactly, including the renderer and packer setup it specifies.`, - href: docsRoutes.proReactServerComponents, - category: 'server-rendering', + "id": "create-app", + "title": "Start a new app", + "prompt": "Set up a new Rails app with React on Rails, using TypeScript and server-side rendering. Follow the official guide at https://reactonrails.com/docs/getting-started/create-react-on-rails-app and use the exact commands and versions it specifies — don't improvise.", + "href": "/docs/getting-started/create-react-on-rails-app", + "category": "get-started" }, { - id: 'create-app', - title: 'Start a new app', - prompt: `Set up a new Rails app with React on Rails, using TypeScript and server-side rendering. Follow the official guide at ${docUrl( - docsRoutes.createApp, - )} and use the exact commands and versions it specifies — don't improvise.`, - href: docsRoutes.createApp, - category: 'get-started', + "id": "install-existing", + "title": "Add to an existing Rails app", + "prompt": "Add React on Rails to my existing Rails app with TypeScript, keeping my current routes and conventions. Follow https://reactonrails.com/docs/getting-started/existing-rails-app and don't change any gem or package versions it doesn't tell you to.", + "href": "/docs/getting-started/existing-rails-app", + "category": "get-started" }, { - id: 'install-existing', - title: 'Add to an existing Rails app', - prompt: `Add React on Rails to my existing Rails app with TypeScript, keeping my current routes and conventions. Follow ${docUrl( - docsRoutes.installExistingApp, - )} and don't change any gem or package versions it doesn't tell you to.`, - href: docsRoutes.installExistingApp, - category: 'get-started', + "id": "turn-on-rsc", + "title": "Turn on React Server Components", + "prompt": "Turn on React Server Components in my React on Rails app (no license required). Follow https://reactonrails.com/docs/pro/react-server-components exactly, including the renderer and packer setup it specifies.", + "href": "/docs/pro/react-server-components", + "category": "server-rendering" }, { - id: 'streaming-ssr', - title: 'Add streaming SSR', - prompt: `Add streaming server-side rendering to my React on Rails app. Follow ${docUrl( - docsRoutes.proStreamingSsr, - )} exactly and don't change versions it doesn't ask you to.`, - href: docsRoutes.proStreamingSsr, - category: 'server-rendering', + "id": "streaming-ssr", + "title": "Add streaming SSR", + "prompt": "Add streaming server-side rendering to my React on Rails app. Follow https://reactonrails.com/docs/pro/streaming-ssr exactly and don't change versions it doesn't ask you to.", + "href": "/docs/pro/streaming-ssr", + "category": "server-rendering" }, { - id: 'async-rendering', - title: 'Use async/Suspense rendering', - prompt: `Set up async (Suspense) rendering for a React on Rails component. Follow ${docUrl( - docsRoutes.proAsyncRendering, - )} exactly.`, - href: docsRoutes.proAsyncRendering, - category: 'server-rendering', + "id": "async-rendering", + "title": "Use async/Suspense rendering", + "prompt": "Set up async (Suspense) rendering for a React on Rails component. Follow https://reactonrails.com/docs/api-reference/ruby-api-pro#async_react_componentcomponent_name-options-- exactly.", + "href": "/docs/api-reference/ruby-api-pro#async_react_componentcomponent_name-options--", + "category": "server-rendering" }, { - id: 'migrate-react-rails', - title: 'Migrate from react-rails', - prompt: `Migrate my app from react-rails to React on Rails, keeping my existing components working. Follow ${docUrl( - docsRoutes.migrateFromReactRails, - )} and don't skip any step it lists.`, - href: docsRoutes.migrateFromReactRails, - category: 'migrate', + "id": "migrate-react-rails", + "title": "Migrate from react-rails", + "prompt": "Migrate my app from react-rails to React on Rails, keeping my existing components working. Follow https://reactonrails.com/docs/migrating/migrating-from-react-rails and don't skip any step it lists.", + "href": "/docs/migrating/migrating-from-react-rails", + "category": "migrate" }, { - id: 'code-splitting', - title: 'Add code splitting', - prompt: `Add code splitting / lazy loading to my React on Rails components. Follow ${docUrl( - docsRoutes.codeSplitting, - )} exactly.`, - href: docsRoutes.codeSplitting, - category: 'features', + "id": "code-splitting", + "title": "Add code splitting", + "prompt": "Add code splitting / lazy loading to my React on Rails components. Follow https://reactonrails.com/docs/building-features/code-splitting exactly.", + "href": "/docs/building-features/code-splitting", + "category": "features" }, { - id: 'oss-vs-pro', - title: 'Evaluate OSS vs Pro', - prompt: `Review my React on Rails setup and tell me whether OSS or Pro fits my workload, citing the tradeoffs. Base your answer on ${docUrl( - docsRoutes.ossVsPro, - )}.`, - href: docsRoutes.ossVsPro, - category: 'production', + "id": "oss-vs-pro", + "title": "Evaluate OSS vs Pro", + "prompt": "Review my React on Rails setup and tell me whether OSS or Pro fits my workload, citing the tradeoffs. Base your answer on https://reactonrails.com/docs/getting-started/oss-vs-pro.", + "href": "/docs/getting-started/oss-vs-pro", + "category": "production" }, { - id: 'node-renderer', - title: 'Set up the Node renderer', - prompt: `Set up the React on Rails Pro Node renderer for server rendering. Follow ${docUrl( - docsRoutes.proNodeRenderer, - )} exactly, including the configuration it specifies.`, - href: docsRoutes.proNodeRenderer, - category: 'production', + "id": "node-renderer", + "title": "Set up the Node renderer", + "prompt": "Set up the React on Rails Pro Node renderer for server rendering. Follow https://reactonrails.com/docs/pro/node-renderer exactly, including the configuration it specifies.", + "href": "/docs/pro/node-renderer", + "category": "production" }, { - id: 'fragment-caching', - title: 'Add fragment caching', - prompt: `Add fragment caching to my server-rendered React on Rails components. Follow ${docUrl( - docsRoutes.proFragmentCaching, - )} exactly.`, - href: docsRoutes.proFragmentCaching, - category: 'production', + "id": "fragment-caching", + "title": "Add fragment caching", + "prompt": "Add fragment caching to my server-rendered React on Rails components. Follow https://reactonrails.com/docs/pro/fragment-caching exactly.", + "href": "/docs/pro/fragment-caching", + "category": "production" }, { - id: 'upgrade-to-pro', - title: 'Get a production license / upgrade to Pro', - prompt: `Walk me through upgrading my React on Rails app to Pro and getting a production license. Follow ${docUrl( - docsRoutes.proUpgrade, - )}.`, - href: docsRoutes.proUpgrade, - category: 'production', - }, + "id": "upgrade-to-pro", + "title": "Get a production license / upgrade to Pro", + "prompt": "Walk me through upgrading my React on Rails app to Pro and getting a production license. Follow https://reactonrails.com/docs/pro/upgrading-to-pro.", + "href": "/docs/pro/upgrading-to-pro", + "category": "production" + } ]; -/** Curated, ordered subset for the home Quick Start: RSC first (marquee). */ -const homePromptIds = ['turn-on-rsc', 'create-app', 'install-existing'] as const; +const homePromptIds = ["turn-on-rsc","create-app","install-existing"] as const; export const homePrompts: Prompt[] = homePromptIds.map((id) => { const found = prompts.find((prompt) => prompt.id === id); @@ -149,19 +118,30 @@ export type PromptGroup = { heading: string; }; -/** Display order of categories on /prompts. */ export const promptGroups: PromptGroup[] = [ - {category: 'get-started', eyebrow: 'Get started', heading: 'Spin up React on Rails.'}, { - category: 'server-rendering', - eyebrow: 'Server rendering', - heading: 'Render on the server: RSC, SSR, streaming.', + "category": "get-started", + "eyebrow": "Get started", + "heading": "Spin up React on Rails." + }, + { + "category": "server-rendering", + "eyebrow": "Server rendering", + "heading": "Render on the server: RSC, SSR, streaming." + }, + { + "category": "migrate", + "eyebrow": "Migrate", + "heading": "Move an existing setup to React on Rails." }, - {category: 'migrate', eyebrow: 'Migrate', heading: 'Move an existing setup to React on Rails.'}, - {category: 'features', eyebrow: 'Build features', heading: 'Add common capabilities.'}, { - category: 'production', - eyebrow: 'Optimize & go to production', - heading: 'Tune performance and ship with Pro.', + "category": "features", + "eyebrow": "Build features", + "heading": "Add common capabilities." }, + { + "category": "production", + "eyebrow": "Optimize & go to production", + "heading": "Tune performance and ship with Pro." + } ]; diff --git a/prototypes/docusaurus/static/llms.txt b/prototypes/docusaurus/static/llms.txt new file mode 100644 index 0000000..390e95a --- /dev/null +++ b/prototypes/docusaurus/static/llms.txt @@ -0,0 +1,104 @@ +# React on Rails AI Prompts + +Source: https://reactonrails.com/prompts.json +Schema version: 1 + +Paste into Cursor, Claude Code, Copilot, or any AI assistant. Each prompt points the agent at the official docs so it doesn't guess. + +## Get started + +### Start a new app + +ID: create-app +Category: get-started +Doc: https://reactonrails.com/docs/getting-started/create-react-on-rails-app + +Set up a new Rails app with React on Rails, using TypeScript and server-side rendering. Follow the official guide at https://reactonrails.com/docs/getting-started/create-react-on-rails-app and use the exact commands and versions it specifies — don't improvise. + +### Add to an existing Rails app + +ID: install-existing +Category: get-started +Doc: https://reactonrails.com/docs/getting-started/existing-rails-app + +Add React on Rails to my existing Rails app with TypeScript, keeping my current routes and conventions. Follow https://reactonrails.com/docs/getting-started/existing-rails-app and don't change any gem or package versions it doesn't tell you to. + +## Server rendering + +### Turn on React Server Components + +ID: turn-on-rsc +Category: server-rendering +Doc: https://reactonrails.com/docs/pro/react-server-components + +Turn on React Server Components in my React on Rails app (no license required). Follow https://reactonrails.com/docs/pro/react-server-components exactly, including the renderer and packer setup it specifies. + +### Add streaming SSR + +ID: streaming-ssr +Category: server-rendering +Doc: https://reactonrails.com/docs/pro/streaming-ssr + +Add streaming server-side rendering to my React on Rails app. Follow https://reactonrails.com/docs/pro/streaming-ssr exactly and don't change versions it doesn't ask you to. + +### Use async/Suspense rendering + +ID: async-rendering +Category: server-rendering +Doc: https://reactonrails.com/docs/api-reference/ruby-api-pro#async_react_componentcomponent_name-options-- + +Set up async (Suspense) rendering for a React on Rails component. Follow https://reactonrails.com/docs/api-reference/ruby-api-pro#async_react_componentcomponent_name-options-- exactly. + +## Migrate + +### Migrate from react-rails + +ID: migrate-react-rails +Category: migrate +Doc: https://reactonrails.com/docs/migrating/migrating-from-react-rails + +Migrate my app from react-rails to React on Rails, keeping my existing components working. Follow https://reactonrails.com/docs/migrating/migrating-from-react-rails and don't skip any step it lists. + +## Build features + +### Add code splitting + +ID: code-splitting +Category: features +Doc: https://reactonrails.com/docs/building-features/code-splitting + +Add code splitting / lazy loading to my React on Rails components. Follow https://reactonrails.com/docs/building-features/code-splitting exactly. + +## Optimize & go to production + +### Evaluate OSS vs Pro + +ID: oss-vs-pro +Category: production +Doc: https://reactonrails.com/docs/getting-started/oss-vs-pro + +Review my React on Rails setup and tell me whether OSS or Pro fits my workload, citing the tradeoffs. Base your answer on https://reactonrails.com/docs/getting-started/oss-vs-pro. + +### Set up the Node renderer + +ID: node-renderer +Category: production +Doc: https://reactonrails.com/docs/pro/node-renderer + +Set up the React on Rails Pro Node renderer for server rendering. Follow https://reactonrails.com/docs/pro/node-renderer exactly, including the configuration it specifies. + +### Add fragment caching + +ID: fragment-caching +Category: production +Doc: https://reactonrails.com/docs/pro/fragment-caching + +Add fragment caching to my server-rendered React on Rails components. Follow https://reactonrails.com/docs/pro/fragment-caching exactly. + +### Get a production license / upgrade to Pro + +ID: upgrade-to-pro +Category: production +Doc: https://reactonrails.com/docs/pro/upgrading-to-pro + +Walk me through upgrading my React on Rails app to Pro and getting a production license. Follow https://reactonrails.com/docs/pro/upgrading-to-pro. diff --git a/prototypes/docusaurus/static/prompts.json b/prototypes/docusaurus/static/prompts.json new file mode 100644 index 0000000..ee3060c --- /dev/null +++ b/prototypes/docusaurus/static/prompts.json @@ -0,0 +1,127 @@ +{ + "schema_version": 1, + "site_url": "https://reactonrails.com", + "agent_note": "Paste into Cursor, Claude Code, Copilot, or any AI assistant. Each prompt points the agent at the official docs so it doesn't guess.", + "categories": [ + { + "id": "get-started", + "eyebrow": "Get started", + "heading": "Spin up React on Rails." + }, + { + "id": "server-rendering", + "eyebrow": "Server rendering", + "heading": "Render on the server: RSC, SSR, streaming." + }, + { + "id": "migrate", + "eyebrow": "Migrate", + "heading": "Move an existing setup to React on Rails." + }, + { + "id": "features", + "eyebrow": "Build features", + "heading": "Add common capabilities." + }, + { + "id": "production", + "eyebrow": "Optimize & go to production", + "heading": "Tune performance and ship with Pro." + } + ], + "home_prompt_ids": [ + "turn-on-rsc", + "create-app", + "install-existing" + ], + "prompts": [ + { + "id": "create-app", + "title": "Start a new app", + "category": "get-started", + "doc_route": "/docs/getting-started/create-react-on-rails-app", + "doc_url": "https://reactonrails.com/docs/getting-started/create-react-on-rails-app", + "prompt": "Set up a new Rails app with React on Rails, using TypeScript and server-side rendering. Follow the official guide at https://reactonrails.com/docs/getting-started/create-react-on-rails-app and use the exact commands and versions it specifies — don't improvise." + }, + { + "id": "install-existing", + "title": "Add to an existing Rails app", + "category": "get-started", + "doc_route": "/docs/getting-started/existing-rails-app", + "doc_url": "https://reactonrails.com/docs/getting-started/existing-rails-app", + "prompt": "Add React on Rails to my existing Rails app with TypeScript, keeping my current routes and conventions. Follow https://reactonrails.com/docs/getting-started/existing-rails-app and don't change any gem or package versions it doesn't tell you to." + }, + { + "id": "turn-on-rsc", + "title": "Turn on React Server Components", + "category": "server-rendering", + "doc_route": "/docs/pro/react-server-components", + "doc_url": "https://reactonrails.com/docs/pro/react-server-components", + "prompt": "Turn on React Server Components in my React on Rails app (no license required). Follow https://reactonrails.com/docs/pro/react-server-components exactly, including the renderer and packer setup it specifies." + }, + { + "id": "streaming-ssr", + "title": "Add streaming SSR", + "category": "server-rendering", + "doc_route": "/docs/pro/streaming-ssr", + "doc_url": "https://reactonrails.com/docs/pro/streaming-ssr", + "prompt": "Add streaming server-side rendering to my React on Rails app. Follow https://reactonrails.com/docs/pro/streaming-ssr exactly and don't change versions it doesn't ask you to." + }, + { + "id": "async-rendering", + "title": "Use async/Suspense rendering", + "category": "server-rendering", + "doc_route": "/docs/api-reference/ruby-api-pro#async_react_componentcomponent_name-options--", + "doc_url": "https://reactonrails.com/docs/api-reference/ruby-api-pro#async_react_componentcomponent_name-options--", + "prompt": "Set up async (Suspense) rendering for a React on Rails component. Follow https://reactonrails.com/docs/api-reference/ruby-api-pro#async_react_componentcomponent_name-options-- exactly." + }, + { + "id": "migrate-react-rails", + "title": "Migrate from react-rails", + "category": "migrate", + "doc_route": "/docs/migrating/migrating-from-react-rails", + "doc_url": "https://reactonrails.com/docs/migrating/migrating-from-react-rails", + "prompt": "Migrate my app from react-rails to React on Rails, keeping my existing components working. Follow https://reactonrails.com/docs/migrating/migrating-from-react-rails and don't skip any step it lists." + }, + { + "id": "code-splitting", + "title": "Add code splitting", + "category": "features", + "doc_route": "/docs/building-features/code-splitting", + "doc_url": "https://reactonrails.com/docs/building-features/code-splitting", + "prompt": "Add code splitting / lazy loading to my React on Rails components. Follow https://reactonrails.com/docs/building-features/code-splitting exactly." + }, + { + "id": "oss-vs-pro", + "title": "Evaluate OSS vs Pro", + "category": "production", + "doc_route": "/docs/getting-started/oss-vs-pro", + "doc_url": "https://reactonrails.com/docs/getting-started/oss-vs-pro", + "prompt": "Review my React on Rails setup and tell me whether OSS or Pro fits my workload, citing the tradeoffs. Base your answer on https://reactonrails.com/docs/getting-started/oss-vs-pro." + }, + { + "id": "node-renderer", + "title": "Set up the Node renderer", + "category": "production", + "doc_route": "/docs/pro/node-renderer", + "doc_url": "https://reactonrails.com/docs/pro/node-renderer", + "prompt": "Set up the React on Rails Pro Node renderer for server rendering. Follow https://reactonrails.com/docs/pro/node-renderer exactly, including the configuration it specifies." + }, + { + "id": "fragment-caching", + "title": "Add fragment caching", + "category": "production", + "doc_route": "/docs/pro/fragment-caching", + "doc_url": "https://reactonrails.com/docs/pro/fragment-caching", + "prompt": "Add fragment caching to my server-rendered React on Rails components. Follow https://reactonrails.com/docs/pro/fragment-caching exactly." + }, + { + "id": "upgrade-to-pro", + "title": "Get a production license / upgrade to Pro", + "category": "production", + "doc_route": "/docs/pro/upgrading-to-pro", + "doc_url": "https://reactonrails.com/docs/pro/upgrading-to-pro", + "prompt": "Walk me through upgrading my React on Rails app to Pro and getting a production license. Follow https://reactonrails.com/docs/pro/upgrading-to-pro." + } + ] +} diff --git a/scripts/prepare-prompts.mjs b/scripts/prepare-prompts.mjs new file mode 100644 index 0000000..489288c --- /dev/null +++ b/scripts/prepare-prompts.mjs @@ -0,0 +1,706 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const workspaceRoot = path.resolve(__dirname, ".."); + +const GENERATED_HEADER = [ + "/*", + " * GENERATED FILE - DO NOT EDIT.", + " * Source: content/upstream/prompts.yml", + " * Regenerate with `npm run prepare:prompts`.", + " */", + "", +].join("\n"); + +function argValue(name) { + const index = process.argv.indexOf(name); + if (index === -1) { + return null; + } + return process.argv[index + 1] ?? null; +} + +async function exists(targetPath) { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + +async function walkFiles(dir, callback, relativePrefix = "") { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const rel = relativePrefix ? path.join(relativePrefix, entry.name) : entry.name; + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walkFiles(abs, callback, rel); + continue; + } + if (entry.isFile()) { + await callback(abs, rel); + } + } +} + +function toPosix(relativePath) { + return relativePath.split(path.sep).join("/"); +} + +function stripInlineComment(value) { + const openingQuote = value[0]; + if (openingQuote === "\"" || openingQuote === "'") { + let escaped = false; + + for (let index = 1; index < value.length; index += 1) { + const char = value[index]; + + if (openingQuote === "\"" && escaped) { + escaped = false; + continue; + } + if (openingQuote === "\"" && char === "\\") { + escaped = true; + continue; + } + if (openingQuote === "'" && char === "'" && value[index + 1] === "'") { + index += 1; + continue; + } + if (char === openingQuote) { + const rest = value.slice(index + 1); + return /^\s+#/.test(rest) ? value.slice(0, index + 1) : value; + } + } + + return value; + } + + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + + if (char === "#" && index > 0 && /\s/.test(value[index - 1])) { + return value.slice(0, index).trimEnd(); + } + } + + return value; +} + +function parseScalar(rawValue, context) { + const value = stripInlineComment(rawValue.trim()); + if (value === "") { + return ""; + } + if (value.startsWith("\"")) { + try { + return JSON.parse(value); + } catch (error) { + throw new Error(`Invalid double-quoted YAML scalar at ${context}: ${error.message}`); + } + } + if (value.startsWith("'")) { + if (!value.endsWith("'")) { + throw new Error(`Invalid single-quoted YAML scalar at ${context}`); + } + return value.slice(1, -1).replaceAll("''", "'"); + } + if (/^\d+$/.test(value)) { + return Number(value); + } + return value; +} + +function parseKeyValue(source, context) { + const match = source.match(/^([A-Za-z0-9_]+):(?:\s*(.*))?$/); + if (!match) { + throw new Error(`Expected key/value at ${context}: ${source}`); + } + return [match[1], parseScalar(match[2] ?? "", context)]; +} + +function foldedBlockValue(lines) { + const paragraphs = []; + let current = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === "") { + if (current.length > 0) { + paragraphs.push(current.join(" ")); + current = []; + } + continue; + } + current.push(trimmed); + } + + if (current.length > 0) { + paragraphs.push(current.join(" ")); + } + + return paragraphs.join("\n"); +} + +function collectIndentedBlock(lines, startIndex) { + const block = []; + let index = startIndex + 1; + + while (index < lines.length) { + const line = lines[index]; + const trimmed = line.trim(); + if (trimmed !== "" && /^\S/.test(line)) { + break; + } + block.push({ line, lineNumber: index + 1 }); + index += 1; + } + + return { block, nextIndex: index }; +} + +function parseScalarSequence(block, sectionName) { + const values = []; + for (const { line, lineNumber } of block) { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) { + continue; + } + const match = line.match(/^\s{2}-\s*(.+)$/); + if (!match) { + throw new Error(`Expected scalar list item in ${sectionName} at line ${lineNumber}`); + } + values.push(parseScalar(match[1], `${sectionName}:${lineNumber}`)); + } + return values; +} + +function parseObjectSequence(block, sectionName) { + const entries = []; + let current = null; + + for (const { line, lineNumber } of block) { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) { + continue; + } + + const itemMatch = line.match(/^\s{2}-\s*(.*)$/); + if (itemMatch) { + if (current) { + entries.push(current); + } + current = {}; + const firstProperty = itemMatch[1].trim(); + if (firstProperty) { + const [key, value] = parseKeyValue(firstProperty, `${sectionName}:${lineNumber}`); + current[key] = value; + } + continue; + } + + const propertyMatch = line.match(/^\s{4}([A-Za-z0-9_]+):(?:\s*(.*))?$/); + if (!propertyMatch || !current) { + throw new Error(`Expected object property in ${sectionName} at line ${lineNumber}`); + } + + current[propertyMatch[1]] = parseScalar( + propertyMatch[2] ?? "", + `${sectionName}:${lineNumber}` + ); + } + + if (current) { + entries.push(current); + } + + return entries; +} + +export function parsePromptsYaml(source) { + const lines = source.replace(/\r\n/g, "\n").split("\n"); + const parsed = {}; + let index = 0; + + while (index < lines.length) { + const line = lines[index]; + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) { + index += 1; + continue; + } + + const rootMatch = line.match(/^([A-Za-z0-9_]+):(?:\s*(.*))?$/); + if (!rootMatch) { + throw new Error(`Unsupported YAML root syntax at line ${index + 1}: ${line}`); + } + + const key = rootMatch[1]; + const rawValue = rootMatch[2] ?? ""; + + if (rawValue === ">-" || rawValue === ">") { + const { block, nextIndex } = collectIndentedBlock(lines, index); + parsed[key] = foldedBlockValue(block.map(({ line: blockLine }) => blockLine)); + index = nextIndex; + continue; + } + + if (rawValue === "") { + const { block, nextIndex } = collectIndentedBlock(lines, index); + if (key === "categories" || key === "prompts") { + parsed[key] = parseObjectSequence(block, key); + } else if (key === "home_prompt_ids") { + parsed[key] = parseScalarSequence(block, key); + } else { + parsed[key] = ""; + } + index = nextIndex; + continue; + } + + parsed[key] = parseScalar(rawValue, `line ${index + 1}`); + index += 1; + } + + return validatePromptCatalog(parsed); +} + +function requireString(value, field) { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`Expected ${field} to be a non-empty string`); + } + return value; +} + +function assertUnique(values, field) { + const seen = new Set(); + const duplicates = new Set(); + for (const value of values) { + if (seen.has(value)) { + duplicates.add(value); + } + seen.add(value); + } + if (duplicates.size > 0) { + throw new Error(`Duplicate ${field}: ${[...duplicates].join(", ")}`); + } +} + +function validatePromptCatalog(parsed) { + if (parsed.schema_version !== 1) { + throw new Error(`Unsupported prompts schema_version: ${parsed.schema_version}`); + } + const siteUrl = requireString(parsed.site_url, "site_url").replace(/\/+$/, ""); + if (!/^https?:\/\//.test(siteUrl)) { + throw new Error("Expected site_url to be an absolute http(s) URL"); + } + + const agentNote = requireString(parsed.agent_note, "agent_note"); + const categories = Array.isArray(parsed.categories) ? parsed.categories : []; + const homePromptIds = Array.isArray(parsed.home_prompt_ids) ? parsed.home_prompt_ids : []; + const prompts = Array.isArray(parsed.prompts) ? parsed.prompts : []; + + if (categories.length === 0) { + throw new Error("Expected at least one prompt category"); + } + if (prompts.length === 0) { + throw new Error("Expected at least one prompt"); + } + + for (const category of categories) { + category.id = requireString(category.id, "categories[].id"); + category.eyebrow = requireString(category.eyebrow, `categories[${category.id}].eyebrow`); + category.heading = requireString(category.heading, `categories[${category.id}].heading`); + } + assertUnique(categories.map((category) => category.id), "category id"); + const categoryIds = new Set(categories.map((category) => category.id)); + + for (const promptId of homePromptIds) { + requireString(promptId, "home_prompt_ids[]"); + } + assertUnique(homePromptIds, "home prompt id"); + + for (const prompt of prompts) { + prompt.id = requireString(prompt.id, "prompts[].id"); + prompt.title = requireString(prompt.title, `prompts[${prompt.id}].title`); + prompt.category = requireString(prompt.category, `prompts[${prompt.id}].category`); + prompt.doc_route = requireString(prompt.doc_route, `prompts[${prompt.id}].doc_route`); + prompt.prompt = requireString(prompt.prompt, `prompts[${prompt.id}].prompt`); + + if (!categoryIds.has(prompt.category)) { + throw new Error(`Prompt ${prompt.id} references unknown category: ${prompt.category}`); + } + if (!prompt.doc_route.startsWith("/docs/")) { + throw new Error(`Prompt ${prompt.id} doc_route must start with /docs/: ${prompt.doc_route}`); + } + if (prompt.doc_route.includes("?")) { + throw new Error(`Prompt ${prompt.id} doc_route must not include a query string`); + } + } + assertUnique(prompts.map((prompt) => prompt.id), "prompt id"); + + const promptIds = new Set(prompts.map((prompt) => prompt.id)); + for (const promptId of homePromptIds) { + if (!promptIds.has(promptId)) { + throw new Error(`home_prompt_ids references unknown prompt id: ${promptId}`); + } + } + + return { + schemaVersion: parsed.schema_version, + siteUrl, + agentNote, + categories, + homePromptIds, + prompts, + }; +} + +function parseFrontmatter(markdown) { + if (!markdown.startsWith("---\n")) { + return {}; + } + + const lines = markdown.split("\n"); + const frontmatter = {}; + for (let index = 1; index < lines.length; index += 1) { + const line = lines[index]; + if (line === "---") { + return frontmatter; + } + const match = line.match(/^([A-Za-z0-9_]+):\s*(.+)$/); + if (match) { + frontmatter[match[1]] = parseScalar(match[2], "frontmatter"); + } + } + + return {}; +} + +function normalizeRoute(route) { + const normalized = route.replace(/\/+$/, ""); + return normalized === "" ? "/" : normalized; +} + +function defaultRouteForDoc(relativePath) { + const withoutExtension = toPosix(relativePath).replace(/\.(md|mdx)$/i, ""); + const withoutIndex = withoutExtension.replace(/(?:^|\/)(?:README|index)$/i, ""); + if (withoutIndex === "") { + return "/docs"; + } + return normalizeRoute(`/docs/${withoutIndex}`); +} + +function routeForDoc(relativePath, markdown) { + const frontmatter = parseFrontmatter(markdown); + if (typeof frontmatter.slug === "string" && frontmatter.slug.trim() !== "") { + const rawSlug = frontmatter.slug.trim(); + if (rawSlug.startsWith("/")) { + return normalizeRoute(`/docs/${rawSlug.replace(/^\/+/, "")}`); + } + + const directory = toPosix(path.dirname(relativePath)); + const directoryPrefix = directory === "." ? "" : `${directory}/`; + return normalizeRoute(`/docs/${directoryPrefix}${rawSlug.replace(/^\.\//, "")}`); + } + return defaultRouteForDoc(relativePath); +} + +export async function collectPreparedDocRoutes(docsRoot) { + if (!(await exists(docsRoot))) { + throw new Error(`Prepared docs not found at ${docsRoot}. Run \`npm run prepare:docs\` first.`); + } + + const routes = new Set(); + await walkFiles(docsRoot, async (absoluteFile, relativeFile) => { + if (!/\.(md|mdx)$/i.test(relativeFile)) { + return; + } + const markdown = await fs.readFile(absoluteFile, "utf8"); + routes.add(routeForDoc(relativeFile, markdown)); + }); + + return routes; +} + +function routeWithoutFragment(docRoute) { + return normalizeRoute(docRoute.split("#")[0]); +} + +export function validatePromptRoutes(catalog, preparedDocRoutes) { + const missingRoutes = []; + for (const prompt of catalog.prompts) { + const route = routeWithoutFragment(prompt.doc_route); + if (!preparedDocRoutes.has(route)) { + missingRoutes.push(`${prompt.id}: ${prompt.doc_route}`); + } + } + + if (missingRoutes.length > 0) { + throw new Error( + [ + "Prompt doc_route validation failed. Missing prepared docs routes:", + ...missingRoutes.map((missingRoute) => `- ${missingRoute}`), + ].join("\n") + ); + } +} + +function promptDocUrl(catalog, prompt) { + return `${catalog.siteUrl}${prompt.doc_route}`; +} + +export function resolvedPrompts(catalog) { + return catalog.prompts.map((prompt) => { + const docUrl = promptDocUrl(catalog, prompt); + return { + id: prompt.id, + title: prompt.title, + category: prompt.category, + doc_route: prompt.doc_route, + doc_url: docUrl, + prompt: prompt.prompt.replaceAll("{{doc_url}}", docUrl), + }; + }); +} + +function tsString(value) { + return JSON.stringify(value); +} + +function tsPromptArray(catalog) { + const prompts = resolvedPrompts(catalog); + return `export const prompts: Prompt[] = ${JSON.stringify( + prompts.map((prompt) => ({ + id: prompt.id, + title: prompt.title, + prompt: prompt.prompt, + href: prompt.doc_route, + category: prompt.category, + })), + null, + 2 + )};`; +} + +function tsCategoryUnion(catalog) { + return catalog.categories.map((category) => ` | ${tsString(category.id)}`).join("\n"); +} + +function tsPromptGroups(catalog) { + return `export const promptGroups: PromptGroup[] = ${JSON.stringify( + catalog.categories.map((category) => ({ + category: category.id, + eyebrow: category.eyebrow, + heading: category.heading, + })), + null, + 2 + )};`; +} + +export function renderPromptsTs(catalog) { + return `${GENERATED_HEADER}export const SITE_URL = ${tsString(catalog.siteUrl)}; + +export const agentNote = ${tsString(catalog.agentNote)}; + +export type PromptCategory = +${tsCategoryUnion(catalog)}; + +export type Prompt = { + id: string; + title: string; + prompt: string; + href: string; + category: PromptCategory; +}; + +${tsPromptArray(catalog)} + +const homePromptIds = ${JSON.stringify(catalog.homePromptIds)} as const; + +export const homePrompts: Prompt[] = homePromptIds.map((id) => { + const found = prompts.find((prompt) => prompt.id === id); + if (!found) { + throw new Error(\`homePromptIds references unknown prompt id: \${id}\`); + } + return found; +}); + +export type PromptGroup = { + category: PromptCategory; + eyebrow: string; + heading: string; +}; + +${tsPromptGroups(catalog)} +`; +} + +export function renderPromptsJson(catalog) { + return `${JSON.stringify( + { + schema_version: catalog.schemaVersion, + site_url: catalog.siteUrl, + agent_note: catalog.agentNote, + categories: catalog.categories, + home_prompt_ids: catalog.homePromptIds, + prompts: resolvedPrompts(catalog), + }, + null, + 2 + )}\n`; +} + +function llmsCategoryTitle(category) { + return category.eyebrow || category.id; +} + +export function renderLlmsTxt(catalog) { + const prompts = resolvedPrompts(catalog); + const promptsByCategory = new Map(); + for (const prompt of prompts) { + const group = promptsByCategory.get(prompt.category) ?? []; + group.push(prompt); + promptsByCategory.set(prompt.category, group); + } + + const lines = [ + "# React on Rails AI Prompts", + "", + `Source: ${catalog.siteUrl}/prompts.json`, + `Schema version: ${catalog.schemaVersion}`, + "", + catalog.agentNote, + "", + ]; + + for (const category of catalog.categories) { + const categoryPrompts = promptsByCategory.get(category.id) ?? []; + if (categoryPrompts.length === 0) { + continue; + } + lines.push(`## ${llmsCategoryTitle(category)}`, ""); + for (const prompt of categoryPrompts) { + lines.push( + `### ${prompt.title}`, + "", + `ID: ${prompt.id}`, + `Category: ${prompt.category}`, + `Doc: ${prompt.doc_url}`, + "", + prompt.prompt, + "" + ); + } + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +export function renderPromptArtifacts(catalog) { + return { + promptsTs: renderPromptsTs(catalog), + promptsJson: renderPromptsJson(catalog), + llmsTxt: renderLlmsTxt(catalog), + }; +} + +async function writeOrCheckFile(targetPath, expected, check) { + if (check) { + const actual = await fs.readFile(targetPath, "utf8").catch(() => null); + return actual === expected; + } + + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, expected, "utf8"); + return true; +} + +export async function writePromptArtifacts(artifacts, targets, { check = false } = {}) { + const results = await Promise.all([ + writeOrCheckFile(targets.promptsTs, artifacts.promptsTs, check), + writeOrCheckFile(targets.promptsJson, artifacts.promptsJson, check), + writeOrCheckFile(targets.llmsTxt, artifacts.llmsTxt, check), + ]); + + if (check && results.some((matched) => !matched)) { + const labels = ["prompts.ts", "prompts.json", "llms.txt"]; + const drifted = results + .map((matched, index) => (matched ? null : `- ${labels[index]}`)) + .filter(Boolean); + throw new Error( + [ + "Generated prompt artifacts are out of date. Run `npm run prepare:prompts`.", + ...drifted, + ].join("\n") + ); + } +} + +export async function preparePrompts({ + sourcePrompts, + docsRoot, + promptsTs, + promptsJson, + llmsTxt, + check = false, +}) { + if (!(await exists(sourcePrompts))) { + throw new Error(`Synced prompts source not found at ${sourcePrompts}. Run \`npm run sync:docs\` first.`); + } + + const source = await fs.readFile(sourcePrompts, "utf8"); + const catalog = parsePromptsYaml(source); + const routes = await collectPreparedDocRoutes(docsRoot); + validatePromptRoutes(catalog, routes); + const artifacts = renderPromptArtifacts(catalog); + await writePromptArtifacts( + artifacts, + { + promptsTs, + promptsJson, + llmsTxt, + }, + { check } + ); + + return { + catalog, + artifacts, + routeCount: routes.size, + }; +} + +async function main() { + const siteRoot = path.join(workspaceRoot, "prototypes", "docusaurus"); + const check = process.argv.includes("--check"); + const result = await preparePrompts({ + sourcePrompts: + argValue("--source") ?? path.join(workspaceRoot, "content", "upstream", "prompts.yml"), + docsRoot: argValue("--docs-root") ?? path.join(siteRoot, "docs"), + promptsTs: + argValue("--prompts-ts") ?? path.join(siteRoot, "src", "constants", "prompts.ts"), + promptsJson: argValue("--prompts-json") ?? path.join(siteRoot, "static", "prompts.json"), + llmsTxt: argValue("--llms-txt") ?? path.join(siteRoot, "static", "llms.txt"), + check, + }); + + const mode = check ? "Validated" : "Generated"; + console.log( + `${mode} prompt artifacts from ${path.relative(workspaceRoot, argValue("--source") ?? path.join(workspaceRoot, "content", "upstream", "prompts.yml"))}` + ); + console.log(`Prompt count: ${result.catalog.prompts.length}`); + console.log(`Prepared doc routes checked: ${result.routeCount}`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/prepare-prompts.test.mjs b/scripts/prepare-prompts.test.mjs new file mode 100644 index 0000000..47731a4 --- /dev/null +++ b/scripts/prepare-prompts.test.mjs @@ -0,0 +1,247 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + collectPreparedDocRoutes, + parsePromptsYaml, + preparePrompts, + renderPromptArtifacts, + validatePromptRoutes, + writePromptArtifacts, +} from "./prepare-prompts.mjs"; + +async function withTempDir(callback) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "prepare-prompts-test-")); + try { + return await callback(tmpDir); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +const samplePromptsYaml = `schema_version: 1 +site_url: https://reactonrails.com +agent_note: >- + Paste this into an AI assistant. It should use the official docs. + +# A root comment after a folded block must not become part of agent_note. +categories: + - id: get-started + eyebrow: Get started + heading: Spin up React on Rails. + - id: pro + eyebrow: Pro + heading: Use React on Rails Pro. + +home_prompt_ids: + - create-app + +prompts: + - id: create-app + title: Start a new app + category: get-started + doc_route: /docs/getting-started/create-react-on-rails-app + prompt: "Follow {{doc_url}} exactly." + - id: async-rendering + title: Use async rendering + category: pro + doc_route: "/docs/api-reference/ruby-api-pro#async_react_component" + prompt: "Use {{doc_url}} and then check {{doc_url}} again." +`; + +async function writePreparedDocs(docsRoot) { + await fs.mkdir(path.join(docsRoot, "getting-started"), { recursive: true }); + await fs.mkdir(path.join(docsRoot, "api-reference"), { recursive: true }); + await fs.mkdir(path.join(docsRoot, "pro"), { recursive: true }); + await fs.writeFile( + path.join(docsRoot, "getting-started", "create-react-on-rails-app.md"), + "# Create React on Rails App\n", + "utf8" + ); + await fs.writeFile( + path.join(docsRoot, "getting-started", "installation-into-an-existing-rails-app.md"), + "---\nslug: existing-rails-app\n---\n\n# Install into an Existing Rails App\n", + "utf8" + ); + await fs.writeFile( + path.join(docsRoot, "api-reference", "ruby-api-pro.md"), + "# Ruby API Pro\n", + "utf8" + ); + await fs.writeFile( + path.join(docsRoot, "pro", "react-on-rails-pro.md"), + "---\nslug: /pro\n---\n\n# React on Rails Pro\n", + "utf8" + ); +} + +test("parsePromptsYaml validates schema and folds block strings", () => { + const catalog = parsePromptsYaml(samplePromptsYaml); + + assert.equal(catalog.schemaVersion, 1); + assert.equal(catalog.siteUrl, "https://reactonrails.com"); + assert.equal( + catalog.agentNote, + "Paste this into an AI assistant. It should use the official docs." + ); + assert.deepEqual( + catalog.categories.map((category) => category.id), + ["get-started", "pro"] + ); + assert.equal(catalog.prompts[1].doc_route, "/docs/api-reference/ruby-api-pro#async_react_component"); +}); + +test("parsePromptsYaml handles comments outside quoted scalars and inside folded blocks", () => { + const catalog = parsePromptsYaml(`schema_version: 1 +site_url: https://reactonrails.com +agent_note: >- + Keep the next marker literal. + # Not a YAML comment inside a block scalar. + +categories: + - id: get-started + eyebrow: "Get # started" # outside comment + heading: 'Spin up React # on Rails.' + +home_prompt_ids: + - create-app + +prompts: + - id: create-app + title: "Start # a new app" # outside comment + category: get-started + doc_route: /docs/getting-started/create-react-on-rails-app + prompt: "Follow {{doc_url}} # exactly." + - id: existing-app + title: Don't skip setup # outside comment + category: get-started + doc_route: /docs/getting-started/create-react-on-rails-app + prompt: Follow {{doc_url}} exactly. # outside comment +`); + + assert.equal( + catalog.agentNote, + "Keep the next marker literal. # Not a YAML comment inside a block scalar." + ); + assert.equal(catalog.categories[0].eyebrow, "Get # started"); + assert.equal(catalog.categories[0].heading, "Spin up React # on Rails."); + assert.equal(catalog.prompts[0].title, "Start # a new app"); + assert.equal(catalog.prompts[0].prompt, "Follow {{doc_url}} # exactly."); + assert.equal(catalog.prompts[1].title, "Don't skip setup"); + assert.equal(catalog.prompts[1].prompt, "Follow {{doc_url}} exactly."); +}); + +test("collectPreparedDocRoutes includes default routes and frontmatter slugs", async () => { + await withTempDir(async (tmpDir) => { + const docsRoot = path.join(tmpDir, "docs"); + await writePreparedDocs(docsRoot); + + const routes = await collectPreparedDocRoutes(docsRoot); + + assert.ok(routes.has("/docs/getting-started/create-react-on-rails-app")); + assert.ok(routes.has("/docs/getting-started/existing-rails-app")); + assert.ok(routes.has("/docs/api-reference/ruby-api-pro")); + assert.ok(routes.has("/docs/pro")); + }); +}); + +test("renderPromptArtifacts expands doc URLs in TypeScript and public artifacts", () => { + const catalog = parsePromptsYaml(samplePromptsYaml); + const artifacts = renderPromptArtifacts(catalog); + + assert.match(artifacts.promptsTs, /GENERATED FILE - DO NOT EDIT/); + assert.match(artifacts.promptsTs, /https:\/\/reactonrails\.com\/docs\/getting-started\/create-react-on-rails-app/); + assert.doesNotMatch(artifacts.promptsTs, /\{\{doc_url\}\}/); + + const promptsJson = JSON.parse(artifacts.promptsJson); + assert.equal( + promptsJson.prompts[1].prompt, + "Use https://reactonrails.com/docs/api-reference/ruby-api-pro#async_react_component and then check https://reactonrails.com/docs/api-reference/ruby-api-pro#async_react_component again." + ); + assert.match(artifacts.llmsTxt, /# React on Rails AI Prompts/); + assert.match(artifacts.llmsTxt, /Doc: https:\/\/reactonrails\.com\/docs\/api-reference\/ruby-api-pro#async_react_component/); +}); + +test("validatePromptRoutes fails on dangling prepared doc routes", () => { + const catalog = parsePromptsYaml(samplePromptsYaml); + const routes = new Set(["/docs/getting-started/create-react-on-rails-app"]); + + assert.throws( + () => validatePromptRoutes(catalog, routes), + /async-rendering: \/docs\/api-reference\/ruby-api-pro#async_react_component/ + ); +}); + +test("preparePrompts writes artifacts and check mode detects drift", async () => { + await withTempDir(async (tmpDir) => { + const docsRoot = path.join(tmpDir, "docs"); + const sourcePrompts = path.join(tmpDir, "content", "upstream", "prompts.yml"); + const promptsTs = path.join(tmpDir, "site", "src", "constants", "prompts.ts"); + const promptsJson = path.join(tmpDir, "site", "static", "prompts.json"); + const llmsTxt = path.join(tmpDir, "site", "static", "llms.txt"); + + await writePreparedDocs(docsRoot); + await fs.mkdir(path.dirname(sourcePrompts), { recursive: true }); + await fs.writeFile(sourcePrompts, samplePromptsYaml, "utf8"); + + const result = await preparePrompts({ + sourcePrompts, + docsRoot, + promptsTs, + promptsJson, + llmsTxt, + }); + + assert.equal(result.catalog.prompts.length, 2); + assert.match(await fs.readFile(promptsTs, "utf8"), /export const homePrompts/); + + await preparePrompts({ + sourcePrompts, + docsRoot, + promptsTs, + promptsJson, + llmsTxt, + check: true, + }); + + await fs.writeFile(llmsTxt, "hand edited\n", "utf8"); + await assert.rejects( + () => + preparePrompts({ + sourcePrompts, + docsRoot, + promptsTs, + promptsJson, + llmsTxt, + check: true, + }), + /Generated prompt artifacts are out of date/ + ); + }); +}); + +test("writePromptArtifacts reports which generated file drifted", async () => { + await withTempDir(async (tmpDir) => { + const targets = { + promptsTs: path.join(tmpDir, "prompts.ts"), + promptsJson: path.join(tmpDir, "prompts.json"), + llmsTxt: path.join(tmpDir, "llms.txt"), + }; + const artifacts = { + promptsTs: "ts\n", + promptsJson: "{}\n", + llmsTxt: "llms\n", + }; + + await writePromptArtifacts(artifacts, targets); + await fs.writeFile(targets.promptsJson, "changed\n", "utf8"); + + await assert.rejects( + () => writePromptArtifacts(artifacts, targets, { check: true }), + /prompts\.json/ + ); + }); +}); diff --git a/scripts/sync-docs.mjs b/scripts/sync-docs.mjs index 2579b17..438d9d7 100644 --- a/scripts/sync-docs.mjs +++ b/scripts/sync-docs.mjs @@ -125,6 +125,18 @@ async function main() { console.warn("Warning: CHANGELOG.md not found in source repo"); } + // Sync canonical prompt definitions from the source repo root. The site + // generates display constants and public agent artifacts from this file. + const sourcePrompts = path.join(sourceRepo, "prompts.yml"); + const promptsTarget = path.join(upstreamRoot, "prompts.yml"); + await fs.rm(promptsTarget, { force: true }); + if (await exists(sourcePrompts)) { + await fs.copyFile(sourcePrompts, promptsTarget); + console.log(`Synced prompts.yml to ${promptsTarget}`); + } else { + throw new Error(`prompts.yml not found in source repo at ${sourcePrompts}`); + } + let subsetStats = null; if (buildSubset) { subsetStats = await writeSubset(fullDocsTarget, subsetDocsTarget, layout);