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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ jobs:
- name: Audit frozen dependencies
run: bun audit
- run: bun scripts/check-docs.ts
- run: bun test scripts/ci ./bench ./apps/shell/scripts
- run: bun test scripts/ci scripts/migrate ./bench ./apps/shell/scripts
# Missing sentences are warnings here and in the nightly; release.yml refuses them.
- run: bun scripts/ci/translations.ts
- run: bun run check:architecture
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ prerequisites, adjacent runtime files and shell E2E coverage.
webview of that window.
- UI colors, radii and durations come from `app.css`, strings from
`lib/strings.ts`, which serves them in the language the app speaks. Write
English in `lib/strings.en.ts` and mirror it in every translation
([docs/language.md](docs/language.md)). No native `<select>`,
English in `lib/strings/en/<part>.ts` and mirror it in the same part of every translation
([docs/language.md](docs/language.md)); a branch still editing the single
`strings.en.ts` runs `bun scripts/migrate/split-catalogues.ts` after merging
main. No native `<select>`,
`window.confirm` or hard-coded hex.
- Each connected machine owns its client and Store. Route actions through the
owning Store; project and thread IDs can collide between machines.
Expand Down
26 changes: 19 additions & 7 deletions docs/language.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ provider's own name.
| File | What it holds |
|---|---|
| `packages/ui/src/lib/strings.ts` | What every component imports: `strings` in the language of the moment, and `fill`. |
| `packages/ui/src/lib/strings.en.ts` | English. Every user-facing sentence of the UI, and the shape every translation mirrors. |
| `packages/ui/src/lib/strings.fr.ts` | French, typed `Translation`: the English catalogue's shape, literals widened, every sentence optional. |
| `packages/ui/src/lib/strings.en.ts` | English. Spreads one file per top-level block of `strings/en/` (`chat.ts`, `settings.ts`): every user-facing sentence of the UI, and the shape every translation mirrors. |
| `packages/ui/src/lib/strings.fr.ts` | French, typed `Translation`: spreads `strings/fr/`, one file per English part with the same name, literals widened, every sentence optional. |
| `packages/ui/src/lib/i18n.svelte.ts` | The choice, the detection, `fill`, and the proxy `strings.ts` re-exports. |
| `packages/ui/catalogue-plugin.ts` | The build step that writes each translation over English, so its chunk is whole. |

Expand Down Expand Up @@ -84,8 +84,19 @@ imports `lib/i18n.svelte` for `setLocaleSetting` and the list of locales.

## Adding a sentence

Put it in `strings.en.ts`, under the block its screen belongs to, then in
`strings.fr.ts` at the same path.
Put it in `strings/en/<part>.ts`, the file of the block its screen belongs to,
then in `strings/fr/<part>.ts` at the same path. Two changes to different
parts never edit the same file. A new block is a new part: a file in
`strings/en/` and in every translation's folder, spread by each index;
`scripts/ci/translations.ts` fails when one of them is missing.

A branch cut before the split still edits the single `strings.en.ts` and
`strings.fr.ts`, so merging main conflicts on both. Once `git merge origin/main`
stops, run `bun scripts/migrate/split-catalogues.ts`: it splits the merge base
and the branch versions with the codemod that made the split, replays each
part's change onto main's part with `git merge-file`, adds a part the branch
created to both indexes and stages the result. A part both sides changed on the
same lines keeps conflict markers and is listed; resolve it like any conflict.

English alone is enough to merge and to ship a nightly. A sentence French has
not got shows in English, key by key, never per screen, and a dotted path never
Expand All @@ -107,9 +118,10 @@ switch. In a component, read `strings.x.y` in the markup or in a

## Adding a language

1. Copy `strings.fr.ts` to `strings.<code>.ts`, translate it, keep the type,
and name its export after the code: `scripts/ci/translations.ts` finds a
catalogue by that file name and that export.
1. Copy `strings.fr.ts` to `strings.<code>.ts` and `strings/fr/` to
`strings/<code>/`, translate them, keep the type, and name each export after
the code: `scripts/ci/translations.ts` finds a catalogue by those file names
and that export.
2. Add the code to `Locale` and `LOCALES` in `i18n.svelte.ts`, and a dynamic
import of the catalogue to `LOADERS`. The build finds the chunk by its file
name and preloads it on a device that speaks the language.
Expand Down
4 changes: 2 additions & 2 deletions docs/onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@ same host settings and shell command as Settings.
## Adding a screen

1. A key in `ORDER` and in `OnboardingStep`, in `lib/onboarding.ts`.
2. A block under `onboarding` in `lib/strings.en.ts`, with a `title` and at
most one sentence, then the same block in `lib/strings.fr.ts`.
2. A block under `onboarding` in `lib/strings/en/onboarding.ts`, with a `title` and at
most one sentence, then the same block in `lib/strings/fr/onboarding.ts`.
3. A branch in `Onboarding.svelte`, between the two it sits between.
4. `ONBOARDING_VERSION` up, since the tour changed shape.

Expand Down
22 changes: 13 additions & 9 deletions packages/ui/catalogue-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { parseSync, type Plugin } from 'vite';

/**
* A translation as its module ships it: English with every sentence the
* translation has written over it (`src/lib/i18n.svelte.ts`). A device loads
* translation has written over it (`src/lib/i18n.svelte.ts`), one part at a
* time: `strings/fr/chat.ts` over `strings/en/chat.ts`. A device loads
* the one catalogue it speaks, and a sentence the translation lacks still
* answers in English without the English chunk. The build and the tests both
* run it, so what a test reads is what ships. `scripts/ci/translations.ts`
Expand All @@ -15,11 +16,12 @@ export function completeCatalogues(): Plugin {
name: 'boite-complete-catalogues',
enforce: 'pre',
transform(code, id) {
const locale = /[\\/]src[\\/]lib[\\/]strings\.([a-z]{2})\.ts$/.exec(id)?.[1];
if (!locale || locale === 'en') return null;
const english = join(dirname(id), 'strings.en.ts');
// One file per part of the catalogue: `strings/fr/chat.ts` over `strings/en/chat.ts`.
const [, locale, part] = /[\\/]src[\\/]lib[\\/]strings[\\/]([a-z]{2})[\\/]([\w-]+)\.ts$/.exec(id) ?? [];
if (!locale || !part || locale === 'en') return null;
const english = join(dirname(id), '..', 'en', `${part}.ts`);
this.addWatchFile(english);
return { code: overEnglish(readFileSync(english, 'utf8'), code, locale), map: null };
return { code: overEnglish(readFileSync(english, 'utf8'), code, locale, part), map: null };
},
};
}
Expand Down Expand Up @@ -62,12 +64,14 @@ function keyOf(property: Property, file: string): string {
* order, each sentence the translation's when it has one. Values are copied as
* written, so a sentence that is a function (a count, a plural) stays one.
*/
export function overEnglish(english: string, translation: string, locale: string): string {
export function overEnglish(english: string, translation: string, locale: string, part: string): string {
const englishFile = `strings/en/${part}.ts`;
const translationFile = `strings/${locale}/${part}.ts`;
const merge = (reference: Node, translated: Node | undefined): string => {
const own = new Map<string, Node>();
for (const property of (translated?.properties ?? []) as Property[]) own.set(keyOf(property, `strings.${locale}.ts`), unwrap(property.value));
for (const property of (translated?.properties ?? []) as Property[]) own.set(keyOf(property, translationFile), unwrap(property.value));
const entries = (reference.properties as Property[]).map((property) => {
const key = keyOf(property, 'strings.en.ts');
const key = keyOf(property, englishFile);
const value = unwrap(property.value);
const theirs = own.get(key);
const name = english.slice(property.key.start, property.key.end);
Expand All @@ -76,6 +80,6 @@ export function overEnglish(english: string, translation: string, locale: string
});
return `{\n${entries.join(',\n')}\n}`;
};
const merged = merge(catalogueObject(english, 'strings.en.ts', 'strings'), catalogueObject(translation, `strings.${locale}.ts`, locale));
const merged = merge(catalogueObject(english, englishFile, 'strings'), catalogueObject(translation, translationFile, locale));
return `export const ${locale} = ${merged};\n`;
}
6 changes: 3 additions & 3 deletions packages/ui/src/lib/catalogue-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const fr: Translation = {
`;

test('a translation ships whole: its sentences over English, English for the rest, in English order', () => {
const source = overEnglish(english, french, 'fr');
const source = overEnglish(english, french, 'fr', 'common');
// The sample's one type annotation stripped, the module run as a function body.
const module = { fr: new Function(source.replace('export const fr = ', 'return ').replace(/\(n: number\)/g, '(n)'))() as Record<string, unknown> };
expect(module.fr).toEqual({
Expand All @@ -35,6 +35,6 @@ test('a translation ships whole: its sentences over English, English for the res
});

test('a catalogue entry that is not a plain key and value is refused with its file', () => {
expect(() => overEnglish(english, `export const fr = { ...other };`, 'fr')).toThrow(/strings\.fr\.ts: a catalogue holds plain/);
expect(() => overEnglish(english, `export const de = {};`, 'fr')).toThrow(/strings\.fr\.ts: expected export const fr/);
expect(() => overEnglish(english, `export const fr = { ...other };`, 'fr', 'common')).toThrow(/strings\/fr\/common\.ts: a catalogue holds plain/);
expect(() => overEnglish(english, `export const de = {};`, 'fr', 'common')).toThrow(/strings\/fr\/common\.ts: expected export const fr/);
});
Loading
Loading