From e61e71c4ada12c50e7d0f7427569f26aa8914a16 Mon Sep 17 00:00:00 2001 From: chengpeiquan Date: Sat, 24 Oct 2026 15:54:24 +0800 Subject: [PATCH 1/3] test(website): organize tests in dedicated directories --- docs/README.md | 3 +++ docs/src/examples/{ => __tests__}/changes.test.ts | 2 +- docs/src/mdx/components/{ => __tests__}/callout.test.ts | 2 +- .../src/mdx/components/{ => __tests__}/fade-preview.test.ts | 2 +- .../components/{ => __tests__}/rail-scroll-state.test.ts | 2 +- docs/src/site/{ => __tests__}/docs-link.test.ts | 4 ++-- docs/src/site/{ => __tests__}/social-metadata.test.ts | 6 +++--- 7 files changed, 12 insertions(+), 9 deletions(-) rename docs/src/examples/{ => __tests__}/changes.test.ts (94%) rename docs/src/mdx/components/{ => __tests__}/callout.test.ts (93%) rename docs/src/mdx/components/{ => __tests__}/fade-preview.test.ts (94%) rename docs/src/mdx/components/{ => __tests__}/rail-scroll-state.test.ts (95%) rename docs/src/site/{ => __tests__}/docs-link.test.ts (97%) rename docs/src/site/{ => __tests__}/social-metadata.test.ts (93%) diff --git a/docs/README.md b/docs/README.md index d8d3965..c2b73a1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,9 @@ pnpm build pnpm start ``` +Keep tests in a nearby `__tests__/` directory, such as +`src/site/__tests__/social-metadata.test.ts`, rather than beside production files. + `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. diff --git a/docs/src/examples/changes.test.ts b/docs/src/examples/__tests__/changes.test.ts similarity index 94% rename from docs/src/examples/changes.test.ts rename to docs/src/examples/__tests__/changes.test.ts index aa79290..c3f939b 100644 --- a/docs/src/examples/changes.test.ts +++ b/docs/src/examples/__tests__/changes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest' -import { changedLines, sourceChange } from './changes' +import { changedLines, sourceChange } from '../changes' describe('example change annotations', () => { test('does not mark retained lines after a removed import', () => { diff --git a/docs/src/mdx/components/callout.test.ts b/docs/src/mdx/components/__tests__/callout.test.ts similarity index 93% rename from docs/src/mdx/components/callout.test.ts rename to docs/src/mdx/components/__tests__/callout.test.ts index 1b60790..2c5f1a3 100644 --- a/docs/src/mdx/components/callout.test.ts +++ b/docs/src/mdx/components/__tests__/callout.test.ts @@ -1,7 +1,7 @@ import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, test } from 'vitest' -import { Callout } from './callout' +import { Callout } from '../callout' describe('Callout', () => { test('renders through the shared alert component', () => { diff --git a/docs/src/mdx/components/fade-preview.test.ts b/docs/src/mdx/components/__tests__/fade-preview.test.ts similarity index 94% rename from docs/src/mdx/components/fade-preview.test.ts rename to docs/src/mdx/components/__tests__/fade-preview.test.ts index d27d92c..687b23d 100644 --- a/docs/src/mdx/components/fade-preview.test.ts +++ b/docs/src/mdx/components/__tests__/fade-preview.test.ts @@ -1,7 +1,7 @@ import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, test } from 'vitest' -import { DocsFadePreview } from './fade-preview' +import { DocsFadePreview } from '../fade-preview' describe('DocsFadePreview', () => { test('renders a mock long-sidebar preview with fade rails', () => { diff --git a/docs/src/mdx/components/rail-scroll-state.test.ts b/docs/src/mdx/components/__tests__/rail-scroll-state.test.ts similarity index 95% rename from docs/src/mdx/components/rail-scroll-state.test.ts rename to docs/src/mdx/components/__tests__/rail-scroll-state.test.ts index 2e3fdee..296dd9c 100644 --- a/docs/src/mdx/components/rail-scroll-state.test.ts +++ b/docs/src/mdx/components/__tests__/rail-scroll-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest' -import { getDocsRailFadeState } from './rail-scroll-state' +import { getDocsRailFadeState } from '../rail-scroll-state' describe('getDocsRailFadeState', () => { test('hides both fades when the rail does not overflow', () => { diff --git a/docs/src/site/docs-link.test.ts b/docs/src/site/__tests__/docs-link.test.ts similarity index 97% rename from docs/src/site/docs-link.test.ts rename to docs/src/site/__tests__/docs-link.test.ts index 745012f..f515a8c 100644 --- a/docs/src/site/docs-link.test.ts +++ b/docs/src/site/__tests__/docs-link.test.ts @@ -1,8 +1,8 @@ import * as React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, test, vi } from 'vitest' -import { DocsSiteLink } from './docs-link' -import { getCurrentHeaderHref } from './navigation' +import { DocsSiteLink } from '../docs-link' +import { getCurrentHeaderHref } from '../navigation' const { usePathname } = vi.hoisted(() => ({ usePathname: vi.fn() })) diff --git a/docs/src/site/social-metadata.test.ts b/docs/src/site/__tests__/social-metadata.test.ts similarity index 93% rename from docs/src/site/social-metadata.test.ts rename to docs/src/site/__tests__/social-metadata.test.ts index 4dafc34..4b4d571 100644 --- a/docs/src/site/social-metadata.test.ts +++ b/docs/src/site/__tests__/social-metadata.test.ts @@ -1,11 +1,11 @@ import { createDocsSource, type DocEntry } from '@blackwork/docs' import { describe, expect, test } from 'vitest' -import { docsConfig } from '../../docs.config' -import { createSocialMetadata, getSocialImagePath } from './social-metadata' +import { docsConfig } from '../../../docs.config' +import { createSocialMetadata, getSocialImagePath } from '../social-metadata' const source = createDocsSource({ config: docsConfig, rootDir: '.' }) -function getEntry(locale: string, slugSegments: string[] = []) { +const getEntry = (locale: string, slugSegments: string[] = []) => { const entry = source.getEntry(locale, slugSegments) expect(entry).not.toBeNull() return entry as DocEntry From 45bb593a59c7e8bb545af9244dee1120d3e1a1ea Mon Sep 17 00:00:00 2001 From: chengpeiquan Date: Sat, 24 Oct 2026 15:54:44 +0800 Subject: [PATCH 2/3] refactor(website): centralize shared locale messages --- docs/README.md | 20 ++ docs/content.config.ts | 16 +- docs/src/examples/code-view.tsx | 14 +- docs/src/examples/copy.ts | 70 ------- docs/src/examples/example-explorer-client.tsx | 31 +-- docs/src/home/copy.ts | 115 ----------- docs/src/home/preset-home.tsx | 25 ++- docs/src/home/scenario-demo.tsx | 35 +--- docs/src/home/source-diagram.tsx | 17 +- docs/src/locales/__tests__/locales.test.ts | 33 ++++ docs/src/locales/config.ts | 8 + docs/src/locales/en.json | 184 ++++++++++++++++++ docs/src/locales/index.ts | 15 ++ docs/src/locales/zh.json | 184 ++++++++++++++++++ docs/src/mdx/components/command-block.tsx | 15 +- .../components/package-manager-commands.tsx | 25 +-- docs/src/mdx/package-manager-commands.json | 14 ++ docs/src/search/docs-search.tsx | 34 +--- docs/src/search/use-docs-search.ts | 9 +- docs/src/showcase/showcase-project.tsx | 34 +--- 20 files changed, 529 insertions(+), 369 deletions(-) delete mode 100644 docs/src/examples/copy.ts delete mode 100644 docs/src/home/copy.ts create mode 100644 docs/src/locales/__tests__/locales.test.ts create mode 100644 docs/src/locales/config.ts create mode 100644 docs/src/locales/en.json create mode 100644 docs/src/locales/index.ts create mode 100644 docs/src/locales/zh.json create mode 100644 docs/src/mdx/package-manager-commands.json diff --git a/docs/README.md b/docs/README.md index c2b73a1..738b1dd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,26 @@ Keep tests in a nearby `__tests__/` directory, such as 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. + ## Social previews Every canonical page gets Open Graph and X Card metadata from its existing 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/src/examples/code-view.tsx b/docs/src/examples/code-view.tsx index e225ef7..6b9df07 100644 --- a/docs/src/examples/code-view.tsx +++ b/docs/src/examples/code-view.tsx @@ -1,21 +1,17 @@ +import { getMessages, type Locale } from '@/locales' import type { CSSProperties } from 'react' import type { ExampleFile, FileChange } from './code-types' import styles from './code-view.module.css' -export const changeLabels: Record<'zh' | 'en', Record> = { - zh: { unchanged: '保留', modified: '修改', added: '新增', removed: '排除', outside: '未选取' }, - en: { unchanged: 'Kept', modified: 'Edited', added: 'Added', removed: 'Excluded', outside: 'Not selected' }, +export const ChangeMark = ({ change, locale }: { change: FileChange; locale: Locale }) => { + return {getMessages(locale).changes[change]} } -export function ChangeMark({ change, locale }: { change: FileChange; locale: 'zh' | 'en' }) { - return {changeLabels[locale][change]} -} - -export function CodeView({ file, side = 'output', compact = false }: { +export const CodeView = ({ file, side = 'output', compact = false }: { file: ExampleFile side?: 'source' | 'config' | 'output' compact?: boolean -}) { +}) => { return (
       
diff --git a/docs/src/examples/copy.ts b/docs/src/examples/copy.ts
deleted file mode 100644
index 5825743..0000000
--- a/docs/src/examples/copy.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-export const exampleCopy = {
-  zh: {
-    title: '工作场景不再繁琐',
-    description: '从一个常见问题开始,看看源文件、Preset 配置和生成结果。',
-    before: '手动维护',
-    after: '使用 Create Preset',
-    view: '查看示例',
-    all: '使用示例',
-    scenarios: [
-      {
-        id: 'variants',
-        title: '模板差别不大,却要维护好几份?',
-        before: '基础版、团队版各复制一份源码。修一个问题,每份模板都要检查和修改。',
-        after: '共用一份源码,把文件和配置差异写进 Preset,创建时生成需要的版本。',
-      },
-      {
-        id: 'skills',
-        title: '每个项目都要复制同一批 Skills?',
-        before: '找到常用 Skills,逐个复制到项目目录,再删掉这次用不上的。',
-        after: '常用 Skills 集中放在一个仓库,按配置选取,一次引入到项目的 Skills 目录。',
-      },
-      {
-        id: 'monorepo',
-        title: '只想用 monorepo 里的一个子项目?',
-        before: '克隆整个仓库,取出子目录,删除无关文件,再处理 workspace: 依赖。',
-        after: '指定子目录、排除文件和依赖处理规则,直接生成独立项目。',
-      },
-      {
-        id: 'file-changes',
-        title: '模板拉下来,每次都要改同样的配置?',
-        before: '删除示例文件、修改 README、替换配置值,每次创建项目都再做一遍。',
-        after: '将固定步骤写进 Preset,创建项目时一起完成。',
-      },
-    ],
-  },
-  en: {
-    title: 'Less repetition in your workflow',
-    description: 'Start with a familiar problem. Compare the source files, preset config, and generated output.',
-    before: 'Manual upkeep',
-    after: 'With Create Preset',
-    view: 'View example',
-    all: 'Examples',
-    scenarios: [
-      {
-        id: 'variants',
-        title: 'Almost identical templates, maintained separately?',
-        before: 'Keep separate copies for basic and team editions. Check and fix the same code in every copy.',
-        after: 'Keep one source. Describe the file and config differences in presets and generate the edition needed.',
-      },
-      {
-        id: 'skills',
-        title: 'Copying the same skills into every project?',
-        before: 'Find the usual skills, copy them one by one, then remove the ones this project does not need.',
-        after: 'Keep common skills in one repository. Select them with a preset and bring them into a project together.',
-      },
-      {
-        id: 'monorepo',
-        title: 'Only need one project from a monorepo?',
-        before: 'Clone the repository, extract a directory, remove unrelated files, then fix workspace: dependencies.',
-        after: 'Choose the directory, exclusions, and dependency rules to generate a standalone project.',
-      },
-      {
-        id: 'file-changes',
-        title: 'Making the same edits after every clone?',
-        before: 'Remove sample files, rewrite the README, and change config values every time a project is created.',
-        after: 'Describe those steps in a preset and apply them during project creation.',
-      },
-    ],
-  },
-} as const
diff --git a/docs/src/examples/example-explorer-client.tsx b/docs/src/examples/example-explorer-client.tsx
index 7cbb92d..f80f50b 100644
--- a/docs/src/examples/example-explorer-client.tsx
+++ b/docs/src/examples/example-explorer-client.tsx
@@ -1,5 +1,7 @@
 'use client'
 
+import { getMessages, type Locale } from '@/locales'
+
 import { useId, useState } from 'react'
 import {
   Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
@@ -10,36 +12,19 @@ import { Callout } from '@/mdx/components/callout'
 import { CommandBlock } from '@/mdx/components/command-block'
 import { ChangeMark, CodeView } from './code-view'
 
-type Locale = 'en' | 'zh'
-
-const labels = {
-  zh: {
-    source: '源文件', config: 'Preset 配置', output: '生成结果',
-    files: '文件列表', edition: '生成版本', command: '创建命令',
-    noteTitle: '示例说明',
-    note: '下方只演示文件变化,不会运行命令。试用前,先替换示例仓库地址,再把 JSON 保存到命令指定的路径。',
-  },
-  en: {
-    source: 'Source', config: 'Preset config', output: 'Output',
-    files: 'Files', edition: 'Edition', command: 'Create command',
-    noteTitle: 'Example note',
-    note: 'The files below show the source, config, and expected output; no commands run here. To try it locally, replace the example repository URL and save the JSON at the path in the command.',
-  },
-}
-
-function FilePanel({ files, locale, root, side }: {
+const FilePanel = ({ files, locale, root, side }: {
   files: ExampleFile[]
   locale: Locale
   root: string
   side: 'source' | 'config' | 'output'
-}) {
+}) => {
   const [selected, setSelected] = useState(files[0].path)
   const file = files.find((item) => item.path === selected) ?? files[0]
   const panelId = useId()
 
   return (
     
-

- {locale === 'zh' ? <>模板只差几处,
也要维护多份? 不需要! : <>Similar templates.
Separate copies? No!} + {copy.hero.title}
{copy.hero.question} {copy.hero.answer}

- {locale === 'zh' - ? '公共代码维护一份,文件差异交给 Preset。创建项目时,选取目录、调整配置,生成需要的版本。' - : 'Maintain shared code once. Describe the differences in a preset, then select files and apply changes when creating each project.'} + {copy.hero.description}

{badge && {badge.alt}} - {exampleCopy[locale].all} + {examples.all} GitHub
@@ -171,7 +168,7 @@ export function PresetHome({

{copy.usageDescription}

- +
diff --git a/docs/src/home/scenario-demo.tsx b/docs/src/home/scenario-demo.tsx index 7a2f56e..8c33036 100644 --- a/docs/src/home/scenario-demo.tsx +++ b/docs/src/home/scenario-demo.tsx @@ -4,32 +4,13 @@ import { useState } from 'react' import Link from 'next/link' import { Tabs, TabsList, TabsTrigger, TabsContent, Button } from 'blackwork' import { ArrowRight, FileCode2, FolderOpen, SlidersHorizontal } from 'lucide-react' -import { exampleCopy } from '@/examples/copy' +import { getMessages, type Locale } from '@/locales' import type { ExampleFile, ExampleView, PresetView } from '@/examples/code-types' import { CodeView, ChangeMark } from '@/examples/code-view' import { CommandBlock } from '@/mdx/components/command-block' import styles from './scenario-demo.module.css' -const ui = { - zh: { - tabs: ['模板重复维护', '复用 Skills', '提取子项目', '调整文件'], - source: '源文件', config: '配置差异', output: '生成结果', - excerpt: '关键配置节选', edition: '切换生成版本', - sourceHint: '琥珀色标出需要处理的内容', outputHint: '绿色标出生成时改写的行', - footnote: '虚构仓库演示 · 不会在浏览器中执行命令', - view: '查看完整示例', browse: '选择一个场景,看看文件如何变化。', - }, - en: { - tabs: ['Template variants', 'Shared skills', 'Monorepo projects', 'File changes'], - source: 'Source files', config: 'Preset rules', output: 'Generated files', - excerpt: 'Config excerpt', edition: 'Choose an edition', - sourceHint: 'Amber marks content to change', outputHint: 'Green marks rewritten lines', - footnote: 'Fictional repository · Commands are not run in the browser', - view: 'Explore the example', browse: 'Choose a scenario to see what happens to the files.', - }, -} - -function FileRows({ files, locale, directory = '' }: { files: ExampleFile[]; locale: 'en' | 'zh'; directory?: string }) { +const FileRows = ({ files, locale, directory = '' }: { files: ExampleFile[]; locale: Locale; directory?: string }) => { return (
    {files.map((file) => ( @@ -43,10 +24,10 @@ function FileRows({ files, locale, directory = '' }: { files: ExampleFile[]; loc ) } -function Demonstration({ data, locale }: { data: ExampleView; locale: 'en' | 'zh' }) { +const Demonstration = ({ data, locale }: { data: ExampleView; locale: Locale }) => { const [selected, setSelected] = useState(0) const preset: PresetView = data.presets[selected] - const copy = ui[locale] + const copy = getMessages(locale).scenario const focusPath = data.id === 'monorepo' ? 'package.json' : data.id === 'file-changes' ? '.env.example' : data.id === 'skills' ? 'code-review/SKILL.md' : 'src/main.ts' @@ -82,7 +63,7 @@ function Demonstration({ data, locale }: { data: ExampleView; locale: 'en' | 'zh
-
03

{copy.output}

{preset.output.length} {locale === 'zh' ? '个文件' : 'files'}
+
03

{copy.output}

{preset.output.length} {copy.files}
{preset.target}/
{focusPath}
@@ -100,9 +81,9 @@ function Demonstration({ data, locale }: { data: ExampleView; locale: 'en' | 'zh ) } -export function ScenarioDemo({ locale, examples }: { locale: 'en' | 'zh'; examples: ExampleView[] }) { - const copy = exampleCopy[locale] - const text = ui[locale] +export const ScenarioDemo = ({ locale, examples }: { locale: Locale; examples: ExampleView[] }) => { + const copy = getMessages(locale).examples + const text = getMessages(locale).scenario const prefix = locale === 'zh' ? '/zh' : '' return ( diff --git a/docs/src/home/source-diagram.tsx b/docs/src/home/source-diagram.tsx index 8a722c8..e4677d7 100644 --- a/docs/src/home/source-diagram.tsx +++ b/docs/src/home/source-diagram.tsx @@ -1,12 +1,13 @@ +import { getMessages, type Locale } from '@/locales' import { FileCode2, Folder, GitBranch } from 'lucide-react' import styles from './preset-home.module.css' -export function SourceDiagram({ locale }: { locale: 'en' | 'zh' }) { - const zh = locale === 'zh' +export const SourceDiagram = ({ locale }: { locale: Locale }) => { + const copy = getMessages(locale).home.diagram return ( -
+
-
+

@@ -18,21 +19,21 @@ export function SourceDiagram({ locale }: { locale: 'en' | 'zh' }) {

basic.json

-

{zh ? '基础版' : 'Basic edition'}

+

{copy.basic}

src/main.ts

-

src/analytics.ts −

+

src/analytics.ts −

team.json

-

{zh ? '团队版' : 'Team edition'}

+

{copy.team}

src/main.ts

src/analytics.ts

-
{zh ? '公共代码改一处,版本差异留在配置里。' : 'Edit shared code once. Keep edition differences in config.'}
+
{copy.caption}
) } diff --git a/docs/src/locales/__tests__/locales.test.ts b/docs/src/locales/__tests__/locales.test.ts new file mode 100644 index 0000000..17c2d37 --- /dev/null +++ b/docs/src/locales/__tests__/locales.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vitest' +import en from '../en.json' +import zh from '../zh.json' +import { getMessages, resolveLocale } from '../index' +import { defaultLocale, localeDefinitions } from '../config' + +const getShape = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(getShape) + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, getShape(item)])) + } + expect(typeof value).toBe('string') + expect((value as string).trim().length).toBeGreaterThan(0) + return 'string' +} + +describe('shared website locales', () => { + test('keeps both dictionaries complete, including nested messages and lists', () => { + expect(getShape(zh)).toEqual(getShape(en)) + expect(zh.examples.scenarios.map(({ id }) => id)).toEqual(en.examples.scenarios.map(({ id }) => id)) + for (const locale of Object.keys(localeDefinitions)) { + expect(resolveLocale(locale)).toBe(locale) + } + }) + + test.each([ + ['en-US', 'en'], ['zh-CN', 'zh'], ['ZH_cn', 'zh'], + [undefined, defaultLocale], ['fr', defaultLocale], ['__proto__', defaultLocale], + ])('resolves %s with the common fallback', (input, expected) => { + expect(resolveLocale(input)).toBe(expected) + expect(getMessages(input)).toBe(expected === 'zh' ? zh : en) + }) +}) diff --git a/docs/src/locales/config.ts b/docs/src/locales/config.ts new file mode 100644 index 0000000..8742bbe --- /dev/null +++ b/docs/src/locales/config.ts @@ -0,0 +1,8 @@ +export const defaultLocale = 'en' + +export const localeDefinitions = { + en: { code: 'en', lang: 'en-US', label: 'English' }, + zh: { code: 'zh', lang: 'zh-CN', label: '简体中文' }, +} as const + +export type Locale = keyof typeof localeDefinitions diff --git a/docs/src/locales/en.json b/docs/src/locales/en.json new file mode 100644 index 0000000..b1420ff --- /dev/null +++ b/docs/src/locales/en.json @@ -0,0 +1,184 @@ +{ + "home": { + "sourceCode": "Source code on GitHub", + "logo": "Create Preset toolbox logo", + "stars": "GitHub stars", + "featuresLabel": "Features", + "features": [ + { + "icon": "🧰", + "title": "Practicality", + "description": "Supports everything from starter templates to complex projects." + }, + { + "icon": "⚡️", + "title": "Efficient", + "description": "Reduces repetitive configuration processes every time a new project is created." + }, + { + "icon": "🤹", + "title": "Interactive", + "description": "Simple command-line interactive operation." + }, + { + "icon": "🛠", + "title": "Multi-Tech Stacks", + "description": "Provide commonly used multiple technology stack project support." + }, + { + "icon": "🎛️", + "title": "Project customization", + "description": "Use the same template to create projects with different configurations." + }, + { + "icon": "🔑", + "title": "Private Configuration", + "description": "Manage private project sources in a local config." + } + ], + "usageTitle": "Try it out", + "usageDescription": "Run one of the following commands and follow the prompts.", + "previewTitle": "A look at the CLI", + "previewDescription": "Vue offers the official Vue starter and Nuxt.", + "previewCaption": "Vue options in Create Preset 1.0.", + "terminalDescription": "Create projects with official generators and declarative presets.", + "projectName": "Project name", + "selectStack": "Select a tech stack", + "selectPreset": "Select a preset", + "vueStarter": "Official Vue Starter", + "labels": { + "changeLanguage": "Change language", + "openSiteNavigation": "Open site navigation", + "primaryNavigation": "Primary navigation", + "toggleTheme": "Toggle theme" + }, + "hero": { + "brandNote": "One source. Your presets.", + "title": "Similar templates.", + "question": "Separate copies?", + "answer": "No!", + "description": "Maintain shared code once. Describe the differences in a preset, then select files and apply changes when creating each project.", + "bestPractices": "Best practices" + }, + "diagram": { + "label": "One starter generates two editions through basic.json and team.json", + "maintain": "Maintain once", + "basic": "Basic edition", + "team": "Team edition", + "excluded": "Excluded", + "caption": "Edit shared code once. Keep edition differences in config." + } + }, + "examples": { + "title": "Less repetition in your workflow", + "description": "Start with a familiar problem. Compare the source files, preset config, and generated output.", + "before": "Manual upkeep", + "after": "With Create Preset", + "view": "View example", + "all": "Examples", + "scenarios": [ + { + "id": "variants", + "title": "Almost identical templates, maintained separately?", + "before": "Keep separate copies for basic and team editions. Check and fix the same code in every copy.", + "after": "Keep one source. Describe the file and config differences in presets and generate the edition needed." + }, + { + "id": "skills", + "title": "Copying the same skills into every project?", + "before": "Find the usual skills, copy them one by one, then remove the ones this project does not need.", + "after": "Keep common skills in one repository. Select them with a preset and bring them into a project together." + }, + { + "id": "monorepo", + "title": "Only need one project from a monorepo?", + "before": "Clone the repository, extract a directory, remove unrelated files, then fix workspace: dependencies.", + "after": "Choose the directory, exclusions, and dependency rules to generate a standalone project." + }, + { + "id": "file-changes", + "title": "Making the same edits after every clone?", + "before": "Remove sample files, rewrite the README, and change config values every time a project is created.", + "after": "Describe those steps in a preset and apply them during project creation." + } + ] + }, + "agent": { + "label": "Copy for AI", + "description": "Copy a prompt to paste into your AI assistant", + "copied": "Copied", + "failed": "Clipboard unavailable. Select and copy the prompt below.", + "promptLabel": "AI prompt", + "prompt": { + "index": "Read the Create Preset agent documentation index first: ", + "page": "Markdown for the current page: ", + "instructions": "Follow only the relevant guides, references and examples, then suggest commands or a preset config for my project. Distinguish creating a new project from adding files to an existing one, and explain what the commands do. Ask me for any missing essential information.", + "task": "My task: [describe the goal, source repository and target directory]" + } + }, + "search": { + "ariaLabel": "Search docs", + "closeLabel": "Close search", + "emptyNoMatch": "No matching pages found in this locale.", + "inputPlaceholder": "Search docs...", + "loading": "Searching docs...", + "minQuery": "Type at least 2 characters to search the docs.", + "shortLabel": "Search docs", + "triggerLabel": "Search docs...", + "indexUnavailable": "Search index unavailable. Run the static build to generate Pagefind assets." + }, + "scenario": { + "tabs": [ + "Template variants", + "Shared skills", + "Monorepo projects", + "File changes" + ], + "source": "Source files", + "config": "Preset rules", + "output": "Generated files", + "excerpt": "Config excerpt", + "edition": "Choose an edition", + "sourceHint": "Amber marks content to change", + "outputHint": "Green marks rewritten lines", + "footnote": "Fictional repository · Commands are not run in the browser", + "view": "Explore the example", + "browse": "Choose a scenario to see what happens to the files.", + "files": "files" + }, + "explorer": { + "source": "Source", + "config": "Preset config", + "output": "Output", + "files": "Files", + "edition": "Edition", + "command": "Create command", + "noteTitle": "Example note", + "note": "The files below show the source, config, and expected output; no commands run here. To try it locally, replace the example repository URL and save the JSON at the path in the command.", + "steps": "Example steps" + }, + "changes": { + "unchanged": "Kept", + "modified": "Edited", + "added": "Added", + "removed": "Excluded", + "outside": "Not selected" + }, + "showcase": { + "description": "A documentation starter with Markdown and MDX, bilingual content, theme controls, and static search. Maintained by Blackwork and created through a public preset.", + "screenshot": "The MDX Playground in a project generated from the Blackwork Docs Starter preset.", + "caption": "Actual generated starter · MDX Playground", + "source": "View preset", + "website": "Docs Starter guide", + "websiteHref": "https://ui.chengpeiquan.com/docs-starter/", + "category": "Documentation", + "create": "Create this project", + "createHref": "#create-this-project" + }, + "common": { + "copyCommand": "Copy command", + "copied": "Copied", + "command": "Command", + "packageManager": "Package manager" + } +} diff --git a/docs/src/locales/index.ts b/docs/src/locales/index.ts new file mode 100644 index 0000000..997fe47 --- /dev/null +++ b/docs/src/locales/index.ts @@ -0,0 +1,15 @@ +import en from './en.json' +import zh from './zh.json' +import { defaultLocale, type Locale } from './config' + +export type { Locale } from './config' +export type Messages = typeof en + +const messages = { en, zh } satisfies Record + +export const resolveLocale = (locale?: string): Locale => { + const language = locale?.toLowerCase().split(/[-_]/)[0] ?? defaultLocale + return Object.hasOwn(messages, language) ? language as Locale : defaultLocale +} + +export const getMessages = (locale?: string): Messages => messages[resolveLocale(locale)] diff --git a/docs/src/locales/zh.json b/docs/src/locales/zh.json new file mode 100644 index 0000000..119c25a --- /dev/null +++ b/docs/src/locales/zh.json @@ -0,0 +1,184 @@ +{ + "home": { + "sourceCode": "在 GitHub 查看源码", + "logo": "Create Preset 工具箱 Logo", + "stars": "GitHub 星标数", + "featuresLabel": "功能特点", + "features": [ + { + "icon": "🧰", + "title": "实用性", + "description": "从入门模板到复杂项目均可支持。" + }, + { + "icon": "⚡️", + "title": "高效的", + "description": "减少每次创建新项目时的重复配置过程。" + }, + { + "icon": "🤹", + "title": "交互式", + "description": "简单的命令行交互式操作。" + }, + { + "icon": "🛠", + "title": "多技术栈", + "description": "提供常用的多种技术栈项目支持。" + }, + { + "icon": "🎛️", + "title": "项目定制", + "description": "同一模板可用于创建不同配置的项目。" + }, + { + "icon": "🔑", + "title": "私有配置", + "description": "通过本地配置管理私有项目源。" + } + ], + "usageTitle": "简单体验", + "usageDescription": "运行以下命令,并按照提示进行操作。", + "previewTitle": "功能预览", + "previewDescription": "选择 Vue 后可使用 Vue 官方 Starter 或 Nuxt。", + "previewCaption": "Create Preset 1.0 的 Vue 选项。", + "terminalDescription": "使用官方脚手架和声明式 Preset 创建项目。", + "projectName": "项目名称", + "selectStack": "选择技术栈", + "selectPreset": "选择 Preset", + "vueStarter": "Vue 官方 Starter", + "labels": { + "changeLanguage": "切换语言", + "openSiteNavigation": "打开网站导航", + "primaryNavigation": "主导航", + "toggleTheme": "切换主题" + }, + "hero": { + "brandNote": "一份源码,按需生成", + "title": "模板只差几处,", + "question": "也要维护多份?", + "answer": "不需要!", + "description": "公共代码维护一份,文件差异交给 Preset。创建项目时,选取目录、调整配置,生成需要的版本。", + "bestPractices": "最佳实践" + }, + "diagram": { + "label": "同一份 starter,通过 basic.json 和 team.json 生成两个版本", + "maintain": "维护这一份", + "basic": "基础版", + "team": "团队版", + "excluded": "排除", + "caption": "公共代码改一处,版本差异留在配置里。" + } + }, + "examples": { + "title": "工作场景不再繁琐", + "description": "从一个常见问题开始,看看源文件、Preset 配置和生成结果。", + "before": "手动维护", + "after": "使用 Create Preset", + "view": "查看示例", + "all": "使用示例", + "scenarios": [ + { + "id": "variants", + "title": "模板差别不大,却要维护好几份?", + "before": "基础版、团队版各复制一份源码。修一个问题,每份模板都要检查和修改。", + "after": "共用一份源码,把文件和配置差异写进 Preset,创建时生成需要的版本。" + }, + { + "id": "skills", + "title": "每个项目都要复制同一批 Skills?", + "before": "找到常用 Skills,逐个复制到项目目录,再删掉这次用不上的。", + "after": "常用 Skills 集中放在一个仓库,按配置选取,一次引入到项目的 Skills 目录。" + }, + { + "id": "monorepo", + "title": "只想用 monorepo 里的一个子项目?", + "before": "克隆整个仓库,取出子目录,删除无关文件,再处理 workspace: 依赖。", + "after": "指定子目录、排除文件和依赖处理规则,直接生成独立项目。" + }, + { + "id": "file-changes", + "title": "模板拉下来,每次都要改同样的配置?", + "before": "删除示例文件、修改 README、替换配置值,每次创建项目都再做一遍。", + "after": "将固定步骤写进 Preset,创建项目时一起完成。" + } + ] + }, + "agent": { + "label": "复制给 AI", + "description": "复制提示词,粘贴给你的 AI 助手", + "copied": "已复制", + "failed": "无法访问剪贴板,请选中下方提示词并手动复制。", + "promptLabel": "AI 提示词", + "prompt": { + "index": "请先阅读 Create Preset 的 Agent 文档索引:", + "page": "当前页面的 Markdown:", + "instructions": "按需阅读相关指南、配置参考和示例,结合我的项目给出适用的命令或 Preset 配置。请区分创建新项目与向已有项目添加文件,并说明命令的实际效果;缺少关键信息时先向我确认。", + "task": "我的需求:[在这里描述目标、源仓库和目标目录]" + } + }, + "search": { + "ariaLabel": "搜索文档", + "closeLabel": "关闭搜索", + "emptyNoMatch": "当前语言下没有匹配结果。", + "inputPlaceholder": "搜索文档...", + "loading": "正在搜索文档...", + "minQuery": "至少输入 2 个字符后开始搜索。", + "shortLabel": "搜索文档", + "triggerLabel": "搜索文档...", + "indexUnavailable": "搜索索引不可用,请运行静态构建以生成 Pagefind 资源。" + }, + "scenario": { + "tabs": [ + "模板重复维护", + "复用 Skills", + "提取子项目", + "调整文件" + ], + "source": "源文件", + "config": "配置差异", + "output": "生成结果", + "excerpt": "关键配置节选", + "edition": "切换生成版本", + "sourceHint": "琥珀色标出需要处理的内容", + "outputHint": "绿色标出生成时改写的行", + "footnote": "虚构仓库演示 · 不会在浏览器中执行命令", + "view": "查看完整示例", + "browse": "选择一个场景,看看文件如何变化。", + "files": "个文件" + }, + "explorer": { + "source": "源文件", + "config": "Preset 配置", + "output": "生成结果", + "files": "文件列表", + "edition": "生成版本", + "command": "创建命令", + "noteTitle": "示例说明", + "note": "下方只演示文件变化,不会运行命令。试用前,先替换示例仓库地址,再把 JSON 保存到命令指定的路径。", + "steps": "示例步骤" + }, + "changes": { + "unchanged": "保留", + "modified": "修改", + "added": "新增", + "removed": "排除", + "outside": "未选取" + }, + "showcase": { + "description": "支持 Markdown 和 MDX、中英文内容、主题切换与静态搜索的文档站模板。由 Blackwork 维护,通过公开 Preset 创建。", + "screenshot": "通过 Blackwork Docs Starter Preset 实际生成的项目中的 MDX 示例页面。", + "caption": "实际生成的模板 · MDX 示例页面", + "source": "查看 Preset", + "website": "文档站指南", + "websiteHref": "https://ui.chengpeiquan.com/zh/docs-starter/", + "category": "文档站", + "create": "创建这个项目", + "createHref": "#创建这个项目" + }, + "common": { + "copyCommand": "复制命令", + "copied": "已复制", + "command": "命令", + "packageManager": "包管理器" + } +} diff --git a/docs/src/mdx/components/command-block.tsx b/docs/src/mdx/components/command-block.tsx index 9fc0245..87e2bf0 100644 --- a/docs/src/mdx/components/command-block.tsx +++ b/docs/src/mdx/components/command-block.tsx @@ -1,5 +1,7 @@ 'use client' +import { getMessages, type Locale } from '@/locales' + import type { CSSProperties } from 'react' import { CodeBlock } from '@blackwork/machine/runtime' @@ -8,17 +10,18 @@ export interface HighlightedCommand { lines: { text: string; light?: string; dark?: string }[][] } -export function CommandBlock({ command, locale = 'en' }: { +export const CommandBlock = ({ command, locale = 'en' }: { command: HighlightedCommand - locale?: 'en' | 'zh' -}) { + locale?: Locale +}) => { + const copy = getMessages(locale).common return ( diff --git a/docs/src/mdx/components/package-manager-commands.tsx b/docs/src/mdx/components/package-manager-commands.tsx index 631847f..af14dd8 100644 --- a/docs/src/mdx/components/package-manager-commands.tsx +++ b/docs/src/mdx/components/package-manager-commands.tsx @@ -1,29 +1,16 @@ +import { getMessages, type Locale } from '@/locales' import { Tabs, TabsContent, TabsList, TabsTrigger } from 'blackwork' import { highlightCommand } from '../command-highlight.server' import { CommandBlock } from './command-block' +import commands from '../package-manager-commands.json' -const commands = { - create: [ - { id: 'pnpm', label: 'pnpm', command: 'pnpm create preset' }, - { id: 'npm', label: 'npm', command: 'npm create preset' }, - { id: 'yarn', label: 'Yarn', command: 'yarn create preset' }, - { id: 'bun', label: 'Bun', command: 'bun create preset' }, - ], - install: [ - { id: 'pnpm', label: 'pnpm', command: 'pnpm add -g create-preset' }, - { id: 'npm', label: 'npm', command: 'npm install -g create-preset' }, - { id: 'yarn', label: 'Yarn Classic', command: 'yarn global add create-preset' }, - { id: 'bun', label: 'Bun', command: 'bun add -g create-preset' }, - ], -} as const - -export async function PackageManagerCommands({ +export const PackageManagerCommands = async ({ mode = 'create', locale = 'en', }: { mode?: keyof typeof commands - locale?: 'en' | 'zh' -}) { + locale?: Locale +}) => { const highlightedCommands = await Promise.all(commands[mode].map(async (item) => ({ ...item, highlighted: await highlightCommand(item.command), @@ -31,7 +18,7 @@ export async function PackageManagerCommands({ return ( - + {commands[mode].map(({ id, label }) => ( {label} diff --git a/docs/src/mdx/package-manager-commands.json b/docs/src/mdx/package-manager-commands.json new file mode 100644 index 0000000..cc6b2e2 --- /dev/null +++ b/docs/src/mdx/package-manager-commands.json @@ -0,0 +1,14 @@ +{ + "create": [ + { "id": "pnpm", "label": "pnpm", "command": "pnpm create preset" }, + { "id": "npm", "label": "npm", "command": "npm create preset" }, + { "id": "yarn", "label": "Yarn", "command": "yarn create preset" }, + { "id": "bun", "label": "Bun", "command": "bun create preset" } + ], + "install": [ + { "id": "pnpm", "label": "pnpm", "command": "pnpm add -g create-preset" }, + { "id": "npm", "label": "npm", "command": "npm install -g create-preset" }, + { "id": "yarn", "label": "Yarn Classic", "command": "yarn global add create-preset" }, + { "id": "bun", "label": "Bun", "command": "bun add -g create-preset" } + ] +} diff --git a/docs/src/search/docs-search.tsx b/docs/src/search/docs-search.tsx index 2eca065..bc3d123 100644 --- a/docs/src/search/docs-search.tsx +++ b/docs/src/search/docs-search.tsx @@ -1,5 +1,7 @@ 'use client' +import { getMessages, type Messages } from '@/locales' + import { Button, QuickSearchDialog, @@ -14,33 +16,7 @@ import { cn } from '@/utils/class-name' import { useDocsSearch } from './use-docs-search' import type { DocsThemeHeaderActionsProps } from '@blackwork/docs/theme' -const SEARCH_COPY = { - en: { - ariaLabel: 'Search docs', - closeLabel: 'Close search', - emptyNoMatch: 'No matching pages found in this locale.', - inputPlaceholder: 'Search docs...', - loading: 'Searching docs...', - minQuery: 'Type at least 2 characters to search the docs.', - shortLabel: 'Search docs', - triggerLabel: 'Search docs...', - }, - zh: { - ariaLabel: '搜索文档', - closeLabel: '关闭搜索', - emptyNoMatch: '当前语言下没有匹配结果。', - inputPlaceholder: '搜索文档...', - loading: '正在搜索文档...', - minQuery: '至少输入 2 个字符后开始搜索。', - shortLabel: '搜索文档', - triggerLabel: '搜索文档...', - }, -} as const - -type SearchLocaleCode = keyof typeof SEARCH_COPY - -const resolveCopy = (locale: string | undefined) => - SEARCH_COPY[(locale ?? 'en').startsWith('zh') ? 'zh' : 'en'] + const resolveSearchLocale = ( localeLinks: DocsThemeHeaderActionsProps['localeLinks'], @@ -52,7 +28,7 @@ const getEmptyStateMessage = ({ hasQuery, loading, }: Pick, 'error' | 'hasQuery' | 'loading'> & { - copy: (typeof SEARCH_COPY)[SearchLocaleCode] + copy: Messages['search'] }) => { if (error) { return error @@ -87,7 +63,7 @@ export const DocsHeaderSearchAction: React.FC = ({ }) => { const { open, setOpen } = useQuickSearchState() const locale = resolveSearchLocale(localeLinks) - const copy = resolveCopy(locale) + const copy = getMessages(locale).search const { error, hasQuery, items, loading, query, setQuery } = useDocsSearch({ locale, open, diff --git a/docs/src/search/use-docs-search.ts b/docs/src/search/use-docs-search.ts index 1dc8fec..94e58dd 100644 --- a/docs/src/search/use-docs-search.ts +++ b/docs/src/search/use-docs-search.ts @@ -1,5 +1,7 @@ 'use client' +import { getMessages } from '@/locales' + import { createSearchClient, type SearchClient, @@ -9,8 +11,6 @@ import { import * as React from 'react' const MIN_QUERY_LENGTH = 2 -const INDEX_UNAVAILABLE_MESSAGE = - 'Search index unavailable. Run the static build to generate Pagefind assets.' const getSearchOptions = ( locale: string | undefined, @@ -23,7 +23,6 @@ const getSearchOptions = ( } : undefined -const toErrorMessage = (_error: unknown) => INDEX_UNAVAILABLE_MESSAGE export interface UseDocsSearchOptions { locale?: string @@ -113,7 +112,7 @@ export const useDocsSearch = ({ setItems(result.items) setLoading(false) }) - } catch (error) { + } catch { if (cancelled) { return } @@ -121,7 +120,7 @@ export const useDocsSearch = ({ React.startTransition(() => { setItems([]) setLoading(false) - setError(toErrorMessage(error)) + setError(getMessages(locale).search.indexUnavailable) }) } } diff --git a/docs/src/showcase/showcase-project.tsx b/docs/src/showcase/showcase-project.tsx index 80127b6..fd3c50e 100644 --- a/docs/src/showcase/showcase-project.tsx +++ b/docs/src/showcase/showcase-project.tsx @@ -1,36 +1,8 @@ +import { getMessages, type Locale } from '@/locales' import { Badge, Button, Card, CardContent, CardHeader } from 'blackwork/rsc' -const copy = { - en: { - description: - 'A documentation starter with Markdown and MDX, bilingual content, theme controls, and static search. Maintained by Blackwork and created through a public preset.', - screenshot: - 'The MDX Playground in a project generated from the Blackwork Docs Starter preset.', - caption: 'Actual generated starter · MDX Playground', - source: 'View preset', - website: 'Docs Starter guide', - websiteHref: 'https://ui.chengpeiquan.com/docs-starter/', - category: 'Documentation', - create: 'Create this project', - createHref: '#create-this-project', - }, - zh: { - description: - '支持 Markdown 和 MDX、中英文内容、主题切换与静态搜索的文档站模板。由 Blackwork 维护,通过公开 Preset 创建。', - screenshot: - '通过 Blackwork Docs Starter Preset 实际生成的项目中的 MDX 示例页面。', - caption: '实际生成的模板 · MDX 示例页面', - source: '查看 Preset', - website: '文档站指南', - websiteHref: 'https://ui.chengpeiquan.com/zh/docs-starter/', - category: '文档站', - create: '创建这个项目', - createHref: '#创建这个项目', - }, -} as const - -export function ShowcaseProject({ locale = 'en' }: { locale?: 'en' | 'zh' }) { - const text = copy[locale] +export const ShowcaseProject = ({ locale = 'en' }: { locale?: Locale }) => { + const text = getMessages(locale).showcase return (
Date: Sat, 24 Oct 2026 15:54:59 +0800 Subject: [PATCH 3/3] feat(website): add agent documentation and prompt copying --- .github/workflows/website.yml | 4 +- docs/.gitignore | 3 + docs/README.md | 27 +++- docs/package.json | 7 +- docs/scripts/__tests__/agent-docs.test.mjs | 72 ++++++++++ docs/scripts/build-agent-docs.mjs | 136 ++++++++++++++++++ docs/src/agent/__tests__/prompt.test.ts | 17 +++ docs/src/agent/copy-agent-prompt.tsx | 64 +++++++++ docs/src/agent/docs-agent-page.tsx | 27 ++++ docs/src/agent/prompt.ts | 20 +++ docs/src/app/[[...slug]]/page.tsx | 6 + docs/src/app/globals.css | 18 ++- docs/src/home/preset-home.tsx | 2 + .../site/__tests__/social-metadata.test.ts | 3 + docs/src/site/social-metadata.ts | 6 + 15 files changed, 405 insertions(+), 7 deletions(-) create mode 100644 docs/scripts/__tests__/agent-docs.test.mjs create mode 100644 docs/scripts/build-agent-docs.mjs create mode 100644 docs/src/agent/__tests__/prompt.test.ts create mode 100644 docs/src/agent/copy-agent-prompt.tsx create mode 100644 docs/src/agent/docs-agent-page.tsx create mode 100644 docs/src/agent/prompt.ts 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 738b1dd..d9f79ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,7 +27,9 @@ pnpm start ``` Keep tests in a nearby `__tests__/` directory, such as -`src/site/__tests__/social-metadata.test.ts`, rather than beside production files. +`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 @@ -53,6 +55,27 @@ 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 @@ -75,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/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} + +