Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/`
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -48,6 +49,10 @@ some-project/
├── getting-started.md
├── guides/
│ └── advanced.md
├── ja/
│ ├── index.md
│ └── guides/
│ └── advanced.md
└── assets/
└── architecture.svg
```
Expand All @@ -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/**`
Expand All @@ -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
Expand Down Expand Up @@ -237,6 +247,7 @@ docs/www validation
│
▼
src/content/docs/{project}
src/content/docs/{locale}/{project}
public/{project}/assets
│
├──────────────► public/extras/document-schema.json
Expand Down
14 changes: 3 additions & 11 deletions astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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',
Expand Down
32 changes: 32 additions & 0 deletions docs/adr/0003-localized-source-docs.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions fixtures/universal-speedtest-cli/docs/www/fallback.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions fixtures/universal-speedtest-cli/docs/www/guide.md
Original file line number Diff line number Diff line change
@@ -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).
8 changes: 8 additions & 0 deletions fixtures/universal-speedtest-cli/docs/www/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
schema_version: '2026-08-15'
type: Reference
title: English home
description: English fixture home.
---

See the [English guide](./guide.md).
8 changes: 8 additions & 0 deletions fixtures/universal-speedtest-cli/docs/www/ja/guide.md
Original file line number Diff line number Diff line change
@@ -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).
8 changes: 8 additions & 0 deletions fixtures/universal-speedtest-cli/docs/www/ja/index.md
Original file line number Diff line number Diff line change
@@ -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).
13 changes: 12 additions & 1 deletion scripts/prepare-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
);
}
Expand Down
28 changes: 28 additions & 0 deletions src/config/locales.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
8 changes: 4 additions & 4 deletions src/extras/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repository-name>/\` or \`docs/www/<project-slug>/\`; 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/<repository-name>/\` or \`docs/www/<project-slug>/\`; 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/<project>\`.
- 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/<project>/guide/\`.

## Page structure for SEO and AIO

Expand All @@ -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/<locale>/\` 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/<locale>/\` 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.

Expand Down
9 changes: 7 additions & 2 deletions tests/extras.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<locale>/`');
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');
Expand Down
67 changes: 67 additions & 0 deletions tests/locale-routing.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading