diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..d5a67e1 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,18 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + - run: bun run check diff --git a/AGENTS.md b/AGENTS.md index a19e00e..12ebb62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ OSS projects. Each source repository owns its documents under - `projects/projects.json` defines publishable repositories. - `src/schema/document.ts` defines the versioned document profile and the Starlight Content Collection schema. +- `src/config/locales.ts` defines the supported site and source-document locales. - `src/schema/json-schema.ts` owns JSON Schema generation. - `src/extras/skill.ts` owns the generated authoring `SKILL` document. - `docs/adr/` records architectural decisions. @@ -19,7 +20,7 @@ OSS projects. Each source repository owns its documents under Do not hand-edit: -- `src/content/docs/{project}/` +- `src/content/docs/{project}/` and `src/content/docs/{locale}/{project}/` - `public/{project}/assets/` - `public/extras/` - `dist/` diff --git a/README.md b/README.md index cc39214..2916645 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Centralized documentation site for OSS projects published by `hsblabs`. - Astro + Starlight - Cloudflare Workers Static Assets - Bun package manager and script runner for repository tooling -- repository-owned source documents under `docs/www/**/*.md` +- repository-owned English and configured-locale documents under `docs/www/**/*.md` - public local assets restricted to `docs/www/assets/**` - OKF v0.2-derived frontmatter profile - versioned Zod Content Collection schema @@ -23,6 +23,7 @@ The site is built below: https://hsb.horse/hsblabs/oss/ https://hsb.horse/hsblabs/oss/{project}/ https://hsb.horse/hsblabs/oss/{project}/guides/... +https://hsb.horse/hsblabs/oss/ja/{project}/guides/... ``` Machine-readable extras are served at: @@ -48,6 +49,10 @@ some-project/ ├── getting-started.md ├── guides/ │ └── advanced.md + ├── ja/ + │ ├── index.md + │ └── guides/ + │ └── advanced.md └── assets/ └── architecture.svg ``` @@ -56,6 +61,8 @@ Files below `docs/www` must follow these rules: - public pages are Markdown files (`**/*.md`) - public non-Markdown files must be below `docs/www/assets/**` +- English documents live at the root; configured locale directories such as + `docs/www/ja/**` are translated documents with the same relative paths - symlinks are rejected - local non-Markdown references from Markdown must resolve below `docs/www/assets/**` @@ -74,6 +81,9 @@ docs/www/getting-started.md docs/www/reference/api.md → /hsblabs/oss/scrape-kdl/reference/api/ + +docs/www/ja/reference/api.md +→ /hsblabs/oss/ja/scrape-kdl/reference/api/ ``` ## Document schema @@ -237,6 +247,7 @@ docs/www validation │ ▼ src/content/docs/{project} +src/content/docs/{locale}/{project} public/{project}/assets │ ├──────────────► public/extras/document-schema.json diff --git a/astro.config.ts b/astro.config.ts index 2609bdb..ddc437e 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'astro/config'; import starlightLlmsTxt from 'starlight-llms-txt'; import { DOCS_BASE_PATH } from './src/config/site'; +import { DEFAULT_SITE_LOCALE, SITE_LOCALES } from './src/config/locales'; import { loadEnabledProjects } from './src/lib/projects'; const projects = loadEnabledProjects(); @@ -36,17 +37,8 @@ export default defineConfig({ `, }, ], - defaultLocale: 'root', - locales: { - root: { - label: 'English', - lang: 'en', - }, - ja: { - label: '日本語', - lang: 'ja', - }, - }, + defaultLocale: DEFAULT_SITE_LOCALE, + locales: SITE_LOCALES, social: [ { icon: 'github', diff --git a/docs/adr/0003-localized-source-docs.md b/docs/adr/0003-localized-source-docs.md new file mode 100644 index 0000000..0c29efe --- /dev/null +++ b/docs/adr/0003-localized-source-docs.md @@ -0,0 +1,32 @@ +--- +date: 2026-08-15 +status: accepted +--- + +# Ingest localized source documents + +## Context + +OSS repositories need to own translated documentation alongside their English +documents. Starlight determines a page locale from the first path segment in +the content collection, so copying `docs/www/ja/**` to +`src/content/docs/{project}/ja/**` creates a normal project subpath instead of +a Japanese page. + +## Decision + +Use the configured site locales as the source-document locale contract. English +documents remain at `docs/www/**`; a configured translation such as +`docs/www/ja/guide.md` is ingested as +`src/content/docs/ja/{project}/guide.md` and published at +`/hsblabs/oss/ja/{project}/guide/`. + +The English document remains the fallback when a translation is missing. Local +assets remain shared under `docs/www/assets/**`. + +## Consequences + +Adding a locale requires updating the centralized locale configuration before +repositories add its source directory. Existing English routes are unchanged, +and translated pages participate in Starlight's locale navigation and fallback +behavior. diff --git a/fixtures/universal-speedtest-cli/docs/www/fallback.md b/fixtures/universal-speedtest-cli/docs/www/fallback.md new file mode 100644 index 0000000..8b1590b --- /dev/null +++ b/fixtures/universal-speedtest-cli/docs/www/fallback.md @@ -0,0 +1,8 @@ +--- +schema_version: '2026-08-15' +type: Reference +title: English fallback reference +description: English-only fixture page used to verify locale fallback. +--- + +This page has no Japanese translation. diff --git a/fixtures/universal-speedtest-cli/docs/www/guide.md b/fixtures/universal-speedtest-cli/docs/www/guide.md new file mode 100644 index 0000000..21b60a1 --- /dev/null +++ b/fixtures/universal-speedtest-cli/docs/www/guide.md @@ -0,0 +1,8 @@ +--- +schema_version: '2026-08-15' +type: Guide +title: English guide +description: English fixture guide. +--- + +Return to the [English home](./index.md). diff --git a/fixtures/universal-speedtest-cli/docs/www/index.md b/fixtures/universal-speedtest-cli/docs/www/index.md new file mode 100644 index 0000000..601089a --- /dev/null +++ b/fixtures/universal-speedtest-cli/docs/www/index.md @@ -0,0 +1,8 @@ +--- +schema_version: '2026-08-15' +type: Reference +title: English home +description: English fixture home. +--- + +See the [English guide](./guide.md). diff --git a/fixtures/universal-speedtest-cli/docs/www/ja/guide.md b/fixtures/universal-speedtest-cli/docs/www/ja/guide.md new file mode 100644 index 0000000..b77a259 --- /dev/null +++ b/fixtures/universal-speedtest-cli/docs/www/ja/guide.md @@ -0,0 +1,8 @@ +--- +schema_version: '2026-08-15' +type: Guide +title: Japanese source guide +description: Japanese source fixture guide. +--- + +Return to the [Japanese home](./index.md). diff --git a/fixtures/universal-speedtest-cli/docs/www/ja/index.md b/fixtures/universal-speedtest-cli/docs/www/ja/index.md new file mode 100644 index 0000000..7e0e2ce --- /dev/null +++ b/fixtures/universal-speedtest-cli/docs/www/ja/index.md @@ -0,0 +1,8 @@ +--- +schema_version: '2026-08-15' +type: Reference +title: Japanese source home +description: Japanese source fixture home. +--- + +See the [Japanese guide](./guide.md). diff --git a/scripts/prepare-content.ts b/scripts/prepare-content.ts index 6bac563..0fbe128 100644 --- a/scripts/prepare-content.ts +++ b/scripts/prepare-content.ts @@ -2,6 +2,7 @@ import { relative, resolve, sep } from 'node:path'; import { rm } from 'node:fs/promises'; import { normalizeDocumentFrontmatter } from '../src/lib/normalize-document'; +import { SOURCE_LOCALES, splitSourceLocalePath } from '../src/config/locales'; import { loadEnabledProjects } from '../src/lib/projects'; import { documentSourceSchema, @@ -39,6 +40,12 @@ for (const project of loadEnabledProjects()) { } await rm(outputContentRoot, { recursive: true, force: true }); + for (const locale of SOURCE_LOCALES) { + await rm(resolve(contentRoot, locale, project.slug), { + recursive: true, + force: true, + }); + } await rm(outputAssetsRoot, { recursive: true, force: true }); const files = await walkFiles(docsRoot); @@ -129,8 +136,12 @@ for (const project of loadEnabledProjects()) { parsed.body, ); const rewritten = rewriteMarkdownLinks(normalizedSource, project.slug); + const { locale, path } = splitSourceLocalePath(normalizedRelativePath); await writeText( - resolve(outputContentRoot, normalizedRelativePath), + resolve( + locale ? contentRoot : outputContentRoot, + ...(locale ? [locale, project.slug, path] : [path]), + ), rewritten, ); } diff --git a/src/config/locales.ts b/src/config/locales.ts new file mode 100644 index 0000000..124ce3c --- /dev/null +++ b/src/config/locales.ts @@ -0,0 +1,28 @@ +export const SITE_LOCALES = { + root: { + label: 'English', + lang: 'en', + }, + ja: { + label: '日本語', + lang: 'ja', + }, +} as const; + +export const DEFAULT_SITE_LOCALE = 'root'; +export const SOURCE_LOCALES = Object.keys(SITE_LOCALES).filter( + (locale) => locale !== DEFAULT_SITE_LOCALE, +); + +export function splitSourceLocalePath(relativePath: string): { + locale?: string; + path: string; +} { + const [candidate, ...rest] = relativePath.split('/'); + + if (candidate && SOURCE_LOCALES.includes(candidate)) { + return { locale: candidate, path: rest.join('/') }; + } + + return { path: relativePath }; +} diff --git a/src/extras/skill.ts b/src/extras/skill.ts index b235d1d..a69c856 100644 --- a/src/extras/skill.ts +++ b/src/extras/skill.ts @@ -28,12 +28,12 @@ Use this skill when creating or updating public hsblabs OSS documentation. ## Project layout and language -- Treat \`docs/www\` as the complete source root for this repository. Do not add \`docs/www//\` or \`docs/www//\`; topic directories such as \`docs/www/guides/\` are allowed. -- The published site adds the project's namespace. \`docs/www/index.md\` is the project home. +- Treat \`docs/www\` as the complete source root for this repository. Do not add \`docs/www//\` or \`docs/www//\`; topic directories such as \`docs/www/guides/\` and configured locale directories such as \`docs/www/ja/\` are allowed. +- The published site adds the project's namespace. \`docs/www/index.md\` is the English project home; \`docs/www/ja/index.md\` is its Japanese translation. - Link to source documents with relative file paths such as \`./output.md\`. Do not use deployment-specific site-root links or add a site prefix such as \`/hsblabs/oss/\`. - English is the default language and keeps the unprefixed site routes. - Japanese uses the \`/ja/\` locale path; untranslated pages fall back to English. -- Do not create translations or locale directories such as \`docs/www/ja/\` unless explicitly requested; translated-source ingestion is not part of the current repository contract. +- Put translations under the matching configured locale directory, keeping the same relative document path. For example, \`docs/www/ja/guide.md\` is published at \`/ja//guide/\`. ## Page structure for SEO and AIO @@ -46,7 +46,7 @@ Use this skill when creating or updating public hsblabs OSS documentation. ## Adding languages later - Keep the \`root\` locale as English and preserve its existing unprefixed URLs. Japanese is the \`ja\` locale at \`/ja/\`. -- Treat adding a locale as a publishing-protocol change, not an OSS source-repository change. Define a stable locale key, language tag, label, URL prefix, translations, and fallback behavior in the publishing configuration. Do not create \`docs/www//\` in source repositories; locale routing is a website concern until translated-source ingestion is explicitly added. +- Treat adding a locale as a publishing-protocol change. Define its stable locale key, language tag, label, URL prefix, source directory, translations, and fallback behavior in the publishing configuration before adding \`docs/www//\` documents. - Keep source-document links relative and language-neutral. Locale navigation is owned by the published site; do not hard-code a special case for Japanese or another language in every document. - Do not change the English URL contract as part of adding a language unless an explicit migration is approved. diff --git a/tests/extras.test.ts b/tests/extras.test.ts index 4b8de4e..8617026 100644 --- a/tests/extras.test.ts +++ b/tests/extras.test.ts @@ -42,10 +42,15 @@ describe('generated extras', () => { expect(skill).toContain('one primary search or answer intent'); expect(skill).toContain("frontmatter `title` provide the page's only H1"); expect(skill).toContain( - 'publishing-protocol change, not an OSS source-repository change', + 'Treat adding a locale as a publishing-protocol change', ); expect(skill).toContain('publishing configuration'); - expect(skill).toContain('Do not create `docs/www//`'); + expect(skill).toContain( + 'Put translations under the matching configured locale directory', + ); + expect(skill).not.toContain( + 'translated-source ingestion is not part of the current repository contract', + ); expect(skill).toContain('extras/validate-docs.sh'); expect(skill).toContain('/hsblabs/oss/extras/validate-docs.sh'); expect(skill).not.toContain('validate-docs.py'); diff --git a/tests/locale-routing.test.ts b/tests/locale-routing.test.ts new file mode 100644 index 0000000..8cadc5d --- /dev/null +++ b/tests/locale-routing.test.ts @@ -0,0 +1,67 @@ +import { execFile } from 'node:child_process'; +import { cp, readFile, rename, rm } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { pathExists } from '../scripts/lib/fs'; + +const execFileAsync = promisify(execFile); + +describe('source locale directories', () => { + it('maps docs/www/ja to the site locale route', async () => { + const projectSlug = 'universal-speedtest-cli'; + const syncedRepositoryRoot = `.context/repos/${projectSlug}`; + const backupRepositoryRoot = `.context/locale-routing-repository-${process.pid}`; + const generatedContentRoot = `src/content/docs/${projectSlug}`; + const generatedLocalizedContentRoot = `src/content/docs/ja/${projectSlug}`; + const generatedAssetsRoot = `public/${projectSlug}`; + const outputRoot = '.context/locale-routing-dist'; + const hadSyncedRepository = await pathExists(syncedRepositoryRoot); + + try { + if (hadSyncedRepository) { + await rm(backupRepositoryRoot, { recursive: true, force: true }); + await rename(syncedRepositoryRoot, backupRepositoryRoot); + } + await rm(syncedRepositoryRoot, { recursive: true, force: true }); + await cp(`fixtures/${projectSlug}`, syncedRepositoryRoot, { + recursive: true, + }); + await execFileAsync('bun', ['run', 'content:prepare']); + + await execFileAsync('bun', [ + 'x', + 'astro', + 'build', + '--outDir', + outputRoot, + ]); + + const englishPage = `${outputRoot}/${projectSlug}/guide/index.html`; + const localizedPage = `${outputRoot}/ja/${projectSlug}/guide/index.html`; + const localizedFallbackPage = `${outputRoot}/ja/${projectSlug}/fallback/index.html`; + const nestedProjectPage = `${outputRoot}/${projectSlug}/ja/guide/index.html`; + + expect(await pathExists(englishPage)).toBe(true); + expect(await pathExists(localizedPage)).toBe(true); + expect(await pathExists(localizedFallbackPage)).toBe(true); + expect(await pathExists(nestedProjectPage)).toBe(false); + expect(await readFile(englishPage, 'utf8')).toContain('English guide'); + expect(await readFile(localizedPage, 'utf8')).toContain( + 'Japanese source guide', + ); + expect(await readFile(localizedFallbackPage, 'utf8')).toContain( + 'English fallback reference', + ); + } finally { + await rm(syncedRepositoryRoot, { recursive: true, force: true }); + await rm(generatedContentRoot, { recursive: true, force: true }); + await rm(generatedLocalizedContentRoot, { recursive: true, force: true }); + await rm(generatedAssetsRoot, { recursive: true, force: true }); + await rm(outputRoot, { recursive: true, force: true }); + if (hadSyncedRepository) { + await rename(backupRepositoryRoot, syncedRepositoryRoot); + } + } + }, 15_000); +});