diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 1c36e38..4e6f963 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -6,12 +6,14 @@ on: paths: - 'docs/**' - 'schema/**' + - 'package.json' - '.github/workflows/website.yml' pull_request: branches: [main] paths: - 'docs/**' - 'schema/**' + - 'package.json' - '.github/workflows/website.yml' workflow_dispatch: @@ -51,7 +53,7 @@ jobs: run: pnpm build - name: Verify static website run: | - for file in index.html zh/index.html en/index.html zh/guide/getting-started/index.html pagefind/pagefind.js pagefind/pagefind-entry.json schema/preset.schema.json CNAME; do + for file in index.html zh/index.html en/index.html zh/guide/getting-started/index.html pagefind/pagefind.js pagefind/pagefind-entry.json schema/preset.schema.json llms.txt llms-full.txt agent/guide/getting-started.md agent/reference/preset-configs.md CNAME; do test -s ".next-static/$file" done test -d .next-static/_next/static diff --git a/docs/.gitignore b/docs/.gitignore index 71b0d3e..bb26d94 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -6,3 +6,6 @@ node_modules/ out/ tsconfig.tsbuildinfo /public/schema/preset.schema.json +/public/agent/ +/public/llms.txt +/public/llms-full.txt diff --git a/docs/README.md b/docs/README.md index d8d3965..d9f79ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,10 +26,56 @@ pnpm build pnpm start ``` +Keep tests in a nearby `__tests__/` directory, such as +`src/locales/__tests__/locales.test.ts` or `scripts/__tests__/agent-docs.test.mjs`, +rather than beside production files. `pnpm test` runs all source tests with Vitest +and script tests with Node.js. + `pnpm build` writes the static site and Pagefind search index to `.next-static`. Development and production builds copy `../schema/preset.schema.json` into the public site, so the CLI and website share one schema source. +## UI translations + +Shared interface messages live in `src/locales/en.json` and `zh.json`, grouped +by module (`home`, `examples`, `search`, `agent`, etc.). Components and helpers +read them through `getMessages(locale)` from `@/locales`; this works in both +Server and Client Components without a provider: + +```ts +const { agent } = getMessages(locale) +``` + +Add new interface copy to the same module in both JSON files. English defines +the TypeScript message shape; type checks and locale tests catch missing fields. +`src/locales/config.ts` owns the supported language definitions and default +language used by the docs framework. `resolveLocale` accepts region tags such +as `zh-CN` and falls back to English for unsupported languages. + +Markdown/MDX articles remain in `src/contents/`. The docs framework's +navigation/theme configuration keeps its native localized-label format. + +## Agent documentation + +`pnpm dev` and `pnpm build` generate one English `/llms.txt` index, +`/llms-full.txt` bundle, and Markdown pages under `/agent/`. The guides and +examples come from `src/contents/en`; full references come from the two reference +documents in this directory. Command tabs and interactive examples expand from +the same command data and fixtures used by the website. Generated files are not +committed. After editing content during development, run `pnpm build:agent-docs` +to refresh them; production builds always regenerate them. + +The homepage's secondary links and guide/example titles offer a localized **Copy for AI** +button. Prompts follow the website language but always link to the same English +index and corresponding English Markdown page. All prompts use +the public production domain so they can be shared outside the current browser. +The task placeholder is intended to be filled in before sending to an agent. + +When adding interactive MDX to guides or examples, add a plain Markdown renderer +in `scripts/build-agent-docs.mjs`. Unsupported blocks fail generation rather than +silently dropping useful content. Keep prose links outside code blocks: exported +links resolve to the published Markdown pages, while sample code is preserved. + ## Social previews Every canonical page gets Open Graph and X Card metadata from its existing @@ -52,7 +98,7 @@ The workflow uses the repository's `ACCESS_TOKEN` secret to push the generated files and trigger the Pages deployment. The token needs write access to this repository. Pull requests only build and verify the website. -Each build checks the homepages, guide, search index, schema and `CNAME`, and +Each build checks the homepages, guide, search index, schema, agent docs and `CNAME`, and keeps a `website-static` download artifact for seven days. After publication, inspect `gh-pages` for `index.html`, `zh/`, `_next/`, `pagefind/`, `schema/`, `CNAME` and `.nojekyll`. The deployment commit includes the source commit SHA. diff --git a/docs/content.config.ts b/docs/content.config.ts index 21c0654..cd89440 100644 --- a/docs/content.config.ts +++ b/docs/content.config.ts @@ -1,21 +1,11 @@ import { type DocsContentConfig } from '@blackwork/docs' +import { defaultLocale, localeDefinitions } from './src/locales/config' export const docsContentConfig = { root: 'src/contents', - defaultLocale: 'en', + defaultLocale, enableDefaultLocaleRedirect: true, - locales: { - en: { - code: 'en', - lang: 'en-US', - label: 'English', - }, - zh: { - code: 'zh', - lang: 'zh-CN', - label: '简体中文', - }, - }, + locales: localeDefinitions, sections: { showcase: { layout: 'content' }, examples: { diff --git a/docs/package.json b/docs/package.json index 6a1ee5e..3e71a6d 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,14 +4,15 @@ "type": "module", "packageManager": "pnpm@12.4.2", "scripts": { - "dev": "node scripts/sync-schema.mjs && next dev --port 3300", - "build": "node scripts/sync-schema.mjs && NEXT_OUTPUT=export NEXT_DIST_DIR=.next-static next build && pnpm run build:search", + "dev": "node scripts/sync-schema.mjs && pnpm run build:agent-docs && next dev --port 3300", + "build": "node scripts/sync-schema.mjs && pnpm run build:agent-docs && NEXT_OUTPUT=export NEXT_DIST_DIR=.next-static next build && pnpm run build:search", + "build:agent-docs": "node scripts/build-agent-docs.mjs", "build:search": "node ./scripts/build-search-index.mjs", "build:static": "pnpm run build", "start": "pnpm run start:static", "start:static": "pnpm dlx serve .next-static --listen 4300", "lint": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run && node --test scripts/__tests__/*.test.mjs" }, "dependencies": { "@bassist/utils": "^0.17.0", diff --git a/docs/scripts/__tests__/agent-docs.test.mjs b/docs/scripts/__tests__/agent-docs.test.mjs new file mode 100644 index 0000000..c4202fd --- /dev/null +++ b/docs/scripts/__tests__/agent-docs.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, writeFile, access } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { buildAgentDocs, toMarkdown } from '../build-agent-docs.mjs' + +const options = { commands: {}, fixtures: {}, links: new Map([['/guide/example', '/agent/guide/example.md']]) } + +test('rewrites documentation links without changing fenced example content', () => { + const example = '```md\n[Sample](/guide/example)\n\n```' + const input = `[Guide](/guide/example#heading)\n\n${example}\n\n[Schema](/schema/preset.schema.json)` + const output = toMarkdown(input, options) + assert.ok(output.includes('[Guide](https://preset.js.org/agent/guide/example.md#heading)')) + assert.ok(output.includes(example)) + assert.ok(output.includes('[Schema](https://preset.js.org/schema/preset.schema.json)')) +}) + +test('fails on unsupported MDX instead of silently omitting information', () => { + for (const input of ['', '', '', '']) { + assert.throws(() => toMarkdown(input, options)) + } +}) + +test('publishes one English documentation set with resolving links and current example data', async (t) => { + const output = await mkdtemp(join(tmpdir(), 'preset-agent-docs-')) + t.after(() => rm(output, { recursive: true, force: true })) + const pages = await buildAgentDocs(output) + const fixtures = JSON.parse(await readFile(new URL('../../src/examples/fixtures.json', import.meta.url), 'utf8')) + const commands = JSON.parse(await readFile(new URL('../../src/mdx/package-manager-commands.json', import.meta.url), 'utf8')) + const outputs = new Set(pages.map((page) => page.path)) + outputs.add('/llms.txt') + outputs.add('/llms-full.txt') + const quickstart = await readFile(join(output, 'agent/guide/getting-started.md'), 'utf8') + for (const group of Object.values(commands)) { + for (const { command } of group) assert.ok(quickstart.includes(command)) + } + const index = await readFile(join(output, 'llms.txt'), 'utf8') + assert.ok(index.length < 6000, 'index stays concise') + const bundle = await readFile(join(output, 'llms-full.txt'), 'utf8') + for (const page of pages) { + assert.ok(index.includes(`https://preset.js.org${page.path}`)) + assert.ok(bundle.includes(page.markdown)) + } + for (const [name, fixture] of Object.entries(fixtures)) { + const markdown = await readFile(join(output, `agent/examples/${name}.md`), 'utf8') + for (const preset of fixture.presets) { + assert.ok(markdown.includes(JSON.stringify(preset.config, null, 2))) + for (const content of Object.values(preset.outputFiles)) assert.ok(markdown.includes(content.trimEnd())) + } + for (const content of Object.values(fixture.sourceFiles)) assert.ok(markdown.includes(content.trimEnd())) + } + for (const path of outputs) { + const content = await readFile(join(output, path), 'utf8') + assert.doesNotMatch(content, /<(PackageManagerCommands|ExampleExplorer)\b/) + for (const match of content.matchAll(/\]\(https:\/\/preset\.js\.org([^\s)#]+)(?:#[^\s)]*)?\)/g)) { + const target = match[1] + if (target === '/schema/preset.schema.json') continue + assert.ok(outputs.has(target), `unresolved generated link in ${path}: ${target}`) + } + } + // Content removal must not leave obsolete published pages behind. + const stale = join(output, 'agent/removed.md') + await writeFile(stale, 'obsolete') + await mkdir(join(output, 'zh'), { recursive: true }) + await writeFile(join(output, 'zh/llms.txt'), 'obsolete') + await writeFile(join(output, 'zh/llms-full.txt'), 'obsolete') + await buildAgentDocs(output) + for (const path of ['agent/removed.md', 'zh/llms.txt', 'zh/llms-full.txt', 'agent/en', 'agent/zh']) { + await assert.rejects(access(join(output, path))) + } +}) diff --git a/docs/scripts/build-agent-docs.mjs b/docs/scripts/build-agent-docs.mjs new file mode 100644 index 0000000..56b6224 --- /dev/null +++ b/docs/scripts/build-agent-docs.mjs @@ -0,0 +1,136 @@ +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { parseFrontmatter } from '@blackwork/machine' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const site = 'https://preset.js.org' +const readJson = async (path) => JSON.parse(await readFile(resolve(root, path), 'utf8')) + +const codeBlock = (content, language = '') => { + const longest = Math.max(2, ...(content.match(/`+/g) ?? []).map((run) => run.length)) + const fence = '`'.repeat(longest + 1) + return `${fence}${language}\n${content.trimEnd()}\n${fence}` +} + +const renderExample = (fixture) => { + const files = (entries) => Object.entries(entries).map(([path, content]) => + `#### ${path}\n\n${codeBlock(content, path.endsWith('.json') ? 'json' : '')}`, + ).join('\n\n') + return [ + '> Fictional repository and commands. Replace them with your actual repository and config paths.', + '### Source files', + files(fixture.sourceFiles), + ...fixture.presets.flatMap((preset) => [ + `### ${preset.label.en}`, + codeBlock(`pnpm create preset init ${preset.target} --from ./presets/${preset.id}.json`, 'bash'), + `#### presets/${preset.id}.json`, + codeBlock(JSON.stringify(preset.config, null, 2), 'json'), + `#### Output directory: ${preset.target}`, + files(preset.outputFiles), + ]), + ].join('\n\n') +} + +// These are the only interactive blocks in the exported guides/examples. +// Reject new MDX constructs instead of silently publishing incomplete docs. +export const toMarkdown = (body, { commands, fixtures, links }) => { + let fence + return body.split('\n').map((line) => { + const marker = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/) + if (fence) { + if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length && !marker[2].trim()) fence = undefined + return line + } + if (marker) { + fence = marker[1] + return line + } + const component = line.match(/^\s*<(PackageManagerCommands|ExampleExplorer)\b([^<>]*?)\s*\/>\s*$/) + if (component) { + const attrs = Object.fromEntries([...component[2].matchAll(/(\w+)="([^"]*)"/g)].map((match) => [match[1], match[2]])) + const allowed = component[1] === 'PackageManagerCommands' ? ['mode', 'locale'] : ['example', 'locale'] + const remaining = component[2].replace(/\w+="[^"]*"/g, '').trim() + if (remaining || Object.keys(attrs).some((key) => !allowed.includes(key))) throw new Error(`Unsupported MDX attributes: ${line}`) + if (component[1] === 'PackageManagerCommands') { + const group = commands[attrs.mode ?? 'create'] + if (!group) throw new Error(`Unknown command mode: ${attrs.mode}`) + return group.map(({ label, command }) => `${label}:\n\n${codeBlock(command, 'bash')}`).join('\n\n') + } + const fixture = fixtures[attrs.example] + if (!fixture) throw new Error(`Unknown example: ${attrs.example}`) + return renderExample(fixture) + } + if (/^\s*(?:<\/?[A-Za-z]|import\s|export\s|\{)/.test(line)) throw new Error(`Unsupported MDX in agent docs: ${line}`) + // Rewrite prose links only: example code must remain byte-for-byte intact. + return line.replace(/\]\(([^\s)]+)\)/g, (match, href) => { + const [path, hash] = href.split('#') + const target = links.get(path.replace(/\/$/, '')) + if (target) return `](${site}${target}${hash ? `#${hash}` : ''})` + if (href.startsWith('/') && !href.startsWith('//')) return `](${site}${href})` + return match + }) + }).join('\n').trim() + '\n' +} + +export const buildAgentDocs = async (output = resolve(root, 'public')) => { + const [commands, fixtures, pkg] = await Promise.all([ + readJson('src/mdx/package-manager-commands.json'), + readJson('src/examples/fixtures.json'), + readJson('../package.json'), + ]) + const pages = [] + for (const section of ['guide', 'examples']) { + const directory = resolve(root, 'src/contents/en', section) + for (const filename of (await readdir(directory)).filter((name) => /\.mdx?$/.test(name)).sort()) { + const { frontmatter, body } = parseFrontmatter(await readFile(resolve(directory, filename), 'utf8')) + const slug = `${section}/${filename.replace(/\.mdx?$/, '')}` + pages.push({ section, title: frontmatter.title, description: frontmatter.description, order: frontmatter.order ?? 0, body, + href: `/${slug}`, path: `/agent/${slug}.md` }) + } + } + for (const name of ['preset-configs', 'private-presets']) { + const body = await readFile(resolve(root, `${name}.md`), 'utf8') + pages.push({ section: 'reference', title: name, body, href: `https://github.com/preset-cli/create-preset/blob/main/docs/${name}.md`, path: `/agent/reference/${name}.md` }) + } + const links = new Map(pages.map((page) => [page.href, page.path])) + for (const page of pages.filter((page) => page.section === 'reference')) links.set(`${page.title}.md`, page.path) + const rendered = pages.map((page) => ({ ...page, markdown: toMarkdown(page.body, { commands, fixtures, links }) })) + + // This directory is generated exclusively by this script; remove stale pages. + await rm(resolve(output, 'agent'), { recursive: true, force: true }) + // Remove files generated by the previous bilingual implementation. + for (const path of ['zh/llms.txt', 'zh/llms-full.txt']) { + await rm(resolve(output, path), { force: true }) + } + const write = async (path, content) => { + const target = resolve(output, `.${path}`) + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, content) + } + for (const page of rendered) await write(page.path, page.markdown) + const guides = rendered.filter((page) => page.section !== 'reference').sort((a, b) => a.order - b.order) + const list = (section) => guides.filter((page) => page.section === section).map((page) => `- [${page.title}](${site}${page.path}): ${page.description}`).join('\n') + const index = [ + '# Create Preset', + '> Create projects with official generators and declarative JSON presets, or apply selected files to existing projects.', + `CLI version: ${pkg.version}. Start with Getting Started, then read only the references or examples needed for the task. Links below provide plain text without website navigation.`, + '## Guides', list('guide'), + '## Examples', list('examples'), + '## Reference', + `- [Preset config reference](${site}/agent/reference/preset-configs.md): Complete field and execution rules.`, + `- [Private preset manifest](${site}/agent/reference/private-presets.md): Full manifest format.`, + `- [JSON Schema](${site}/schema/preset.schema.json): Preset validation schema.`, + '## Optional', + `- [Full documentation](${site}/llms-full.txt): Combined guides, examples and references. Prefer individual pages for smaller context.`, + ].join('\n\n') + '\n' + await write('/llms.txt', index) + const bundle = [...guides, ...rendered.filter((page) => page.section === 'reference')] + await write('/llms-full.txt', `# Create Preset ${pkg.version}\n\n` + bundle.map((page) => `Source: ${site}${page.path}\n\n${page.markdown}`).join('\n---\n\n')) + return rendered +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const pages = await buildAgentDocs() + console.log(`[preset-agent-docs] Generated ${pages.length} Markdown pages and an English index/bundle`) +} diff --git a/docs/src/agent/__tests__/prompt.test.ts b/docs/src/agent/__tests__/prompt.test.ts new file mode 100644 index 0000000..ce8a2c4 --- /dev/null +++ b/docs/src/agent/__tests__/prompt.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from 'vitest' +import { createAgentPrompt, getAgentPagePath } from '../prompt' + +test('localized prompts share the same English documentation URLs', () => { + const pagePath = getAgentPagePath({ slugSegments: ['guide', 'preset-configs'] }) + const english = createAgentPrompt('en', pagePath) + const chinese = createAgentPrompt('zh', pagePath) + + expect(english).toContain('My task:') + expect(chinese).toContain('我的需求:') + for (const prompt of [english, chinese]) { + expect(prompt.match(/https:\/\/\S+/g)).toEqual([ + 'https://preset.js.org/llms.txt', + 'https://preset.js.org/agent/guide/preset-configs.md', + ]) + } +}) diff --git a/docs/src/agent/copy-agent-prompt.tsx b/docs/src/agent/copy-agent-prompt.tsx new file mode 100644 index 0000000..3f657e6 --- /dev/null +++ b/docs/src/agent/copy-agent-prompt.tsx @@ -0,0 +1,64 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Textarea } from 'blackwork' +import { Check, Copy } from 'lucide-react' +import { createAgentPrompt } from './prompt' +import { getMessages, type Locale } from '@/locales' + +export const CopyAgentPrompt = ({ locale, pagePath }: { locale: Locale; pagePath?: string }) => { + const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle') + const prompt = createAgentPrompt(locale, pagePath) + const { label, description, copied, failed, promptLabel } = getMessages(locale).agent + + useEffect(() => { + if (status !== 'copied') return + const timeout = setTimeout(() => setStatus('idle'), 2500) + return () => clearTimeout(timeout) + }, [status]) + + const copy = async () => { + try { + await navigator.clipboard.writeText(prompt) + setStatus('copied') + } catch { + setStatus('failed') + } + } + + return ( + + + + {status === 'copied' ? copied : status === 'failed' ? failed : ''} + + { if (!open) setStatus('idle') }}> + + + {label} + {failed} + +