{m.welcome_greeting({ name })}
+``` + +- Import the `m` namespace; call messages as functions. +- Pass interpolation values as a named object: `m.welcome_greeting({ name })`. +- Override the locale per call when needed: `m.common_cancel({}, { locale: "de" })`. + +Locale detection is configured per app in each `vite.config.ts`. `web-local` +uses `["preferredLanguage", "baseLocale"]` (the browser's preferred language, +falling back to English); `web-admin` adds a `localStorage` step in front so a +user's explicit choice persists. + +## Conventions + +### Key naming + +Use `feature_component_purpose`, lower snake_case, grouped by prefix in +`en.json`: + +```jsonc +{ + "common_cancel": "Cancel", + "common_save": "Save", + "welcome_greeting": "Welcome, {name}", + "dashboards_filters_clear_all": "Clear all filters", +} +``` + +- `common_` — copy reused across features. Reuse an existing `common_` key + rather than duplicating identical copy. +- Otherwise prefix with the feature directory name. + +### Interpolation + +Use named placeholders, never string concatenation: + +```jsonc +{ "exports_row_count": "Exporting {count} rows" } +``` + +### Pluralization and variants + +Use Paraglide [variants](https://inlang.com/m/gerre34r/library-inlang-paraglideJs/variants) +rather than hand-rolled `count === 1 ? ... : ...` logic. + +## Adding or migrating a string + +1. Add the key to `messages/en.json` following the naming convention. +2. Replace the literal in code with `m.key()` (or `m.key({ var })`). +3. `npm run build:i18n` (or rely on the Vite plugin in dev). +4. Run `npm run test -w web-common` and `npm run quality`. + +## Guard against new hardcoded strings + +`scripts/i18n-guard.js` scans already-migrated areas for hardcoded +user-facing strings and runs in `npm run quality`. It is a heuristic and +currently **warning-only**; the final migration chunk flips it to `--strict` +(fatal). Each migration chunk appends its directories to `MIGRATED_GLOBS` in +that script. Suppress an intentional literal with an `i18n-ignore` comment on +the line or the line above it. diff --git a/web-common/src/lib/i18n/messages/en.json b/web-common/src/lib/i18n/messages/en.json new file mode 100644 index 000000000000..08f23297e2cd --- /dev/null +++ b/web-common/src/lib/i18n/messages/en.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "organizations_overview_page_title": "{title} overview - Rill" +} diff --git a/web-common/src/lib/i18n/project.inlang/.meta.json b/web-common/src/lib/i18n/project.inlang/.meta.json new file mode 100644 index 000000000000..d39728f7d73e --- /dev/null +++ b/web-common/src/lib/i18n/project.inlang/.meta.json @@ -0,0 +1,3 @@ +{ + "highestSdkVersion": "2.6.2" +} diff --git a/web-common/src/lib/i18n/project.inlang/README.md b/web-common/src/lib/i18n/project.inlang/README.md new file mode 100644 index 000000000000..33a7fcbf7016 --- /dev/null +++ b/web-common/src/lib/i18n/project.inlang/README.md @@ -0,0 +1,106 @@ +## What is this folder? + +This is an [unpacked (git-friendly)](https://inlang.com/docs/unpacked-project) inlang project. + +## At a glance + +Purpose: + +- This folder stores inlang project configuration and plugin cache data. +- Translation files live outside this folder and are referenced from `settings.json`. + +Safe to edit: + +- `settings.json` + +Do not edit: + +- `cache/` +- `.gitignore` + +Key files: + +- `settings.json` — locales, plugins, file patterns (source of truth) +- `cache/` — plugin caches (safe to delete) +- `.gitignore` — generated + +``` +*.inlang/ +├── settings.json # Locales, plugins, and file patterns (source of truth) +├── cache/ # Plugin caches (gitignored) +└── .gitignore # Ignores everything except settings.json +``` + +Translation files (like `messages/en.json`) live **outside** this folder and are referenced via plugins in `settings.json`. + +## What is inlang? + +[Inlang](https://inlang.com) is an open file format for building custom localization (i18n) tooling. It provides: + +- **CRUD API** — Read and write translations programmatically via SQL +- **Plugin system** — Import/export any format (JSON, XLIFF, etc.) +- **Version control** — Built-in version control via [lix](https://lix.dev) + +``` +┌──────────┐ ┌───────────┐ ┌────────────┐ +│ i18n lib │ │Translation│ │ CI/CD │ +│ │ │ Tool │ │ Automation │ +└────┬─────┘ └─────┬─────┘ └─────┬──────┘ + │ │ │ + └─────────┐ │ ┌──────────┘ + ▼ ▼ ▼ + ┌──────────────────────────────────┐ + │ *.inlang file │ + └──────────────────────────────────┘ +``` + +## Quick start + +```bash +npm install @inlang/sdk +``` + +```ts +import { loadProjectFromDirectory, saveProjectToDirectory } from "@inlang/sdk"; + +const project = await loadProjectFromDirectory({ path: "./project.inlang" }); +// Query messages with SQLite + [Kysely](https://kysely.dev/) under the hood. +const messages = await project.db.selectFrom("message").selectAll().execute(); + +// Use project.db to update messages. +await saveProjectToDirectory({ path: "./project.inlang", project }); +``` + +## Ideas for custom tooling + +- Translation health dashboard (missing/empty/stale messages) +- Locale coverage report in CI +- Auto-PR for new keys with placeholders +- Migration tool between file formats via plugins +- Glossary/term consistency checker + +## Data model ([docs](https://inlang.com/docs/data-model)) + +``` +bundle (a concept, e.g., "welcome_header") + └── message (per locale, e.g., "en", "de") + └── variant (plural forms, gender, etc.) +``` + +- **bundle**: Groups messages by ID (e.g., `welcome_header`) +- **message**: A translation for a specific locale +- **variant**: Handles pluralization/selectors (most messages have one variant) + +## Common tasks + +- List bundles: `project.db.selectFrom("bundle").selectAll().execute()` +- List messages for locale: `project.db.selectFrom("message").where("locale", "=", "en").selectAll().execute()` +- Find missing translations: compare message counts across locales +- Update a message: `project.db.updateTable("message").set({ ... }).where("id", "=", "...").execute()` + +## Links + +- [SDK documentation](https://inlang.com/docs) +- [inlang.com](https://inlang.com) +- [List of plugins](https://inlang.com/c/plugins) +- [List of tools](https://inlang.com/c/tools) diff --git a/web-common/src/lib/i18n/project.inlang/settings.json b/web-common/src/lib/i18n/project.inlang/settings.json new file mode 100644 index 000000000000..20ec58d92061 --- /dev/null +++ b/web-common/src/lib/i18n/project.inlang/settings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "baseLocale": "en", + "locales": ["en"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + } +} diff --git a/web-local/src/app.html b/web-local/src/app.html index 3274bf3fb9c9..40918cd5e081 100644 --- a/web-local/src/app.html +++ b/web-local/src/app.html @@ -1,5 +1,5 @@ - + diff --git a/web-local/src/hooks.server.ts b/web-local/src/hooks.server.ts new file mode 100644 index 000000000000..05424484474c --- /dev/null +++ b/web-local/src/hooks.server.ts @@ -0,0 +1,18 @@ +import type { Handle } from "@sveltejs/kit"; +import { paraglideMiddleware } from "@rilldata/web-common/lib/i18n/gen/server"; + +// creating a handle to use the paraglide middleware +const paraglideHandle: Handle = ({ event, resolve }) => + paraglideMiddleware( + event.request, + ({ request: localizedRequest, locale }) => { + event.request = localizedRequest; + return resolve(event, { + transformPageChunk: ({ html }) => { + return html.replace("%lang%", locale); + }, + }); + }, + ); + +export const handle: Handle = paraglideHandle; diff --git a/web-local/src/hooks.ts b/web-local/src/hooks.ts new file mode 100644 index 000000000000..7bbe52dc202f --- /dev/null +++ b/web-local/src/hooks.ts @@ -0,0 +1,6 @@ +import type { Reroute } from "@sveltejs/kit"; +import { deLocalizeUrl } from "@rilldata/web-common/lib/i18n/gen/runtime"; + +export const reroute: Reroute = ({ url }) => { + return deLocalizeUrl(url).pathname; +}; diff --git a/web-local/vite.config.ts b/web-local/vite.config.ts index 1adbbe781555..0e3ae84fe33a 100644 --- a/web-local/vite.config.ts +++ b/web-local/vite.config.ts @@ -1,6 +1,7 @@ import { sveltekit } from "@sveltejs/kit/vite"; import dns from "dns"; import { defineConfig } from "vitest/config"; +import { paraglideVitePlugin } from "@inlang/paraglide-js"; // print dev server as `localhost` not `127.0.0.1` dns.setDefaultResultOrder("verbatim"); @@ -46,7 +47,14 @@ const config = defineConfig({ "memoize-weak", ], }, - plugins: [sveltekit()], + plugins: [ + sveltekit(), + paraglideVitePlugin({ + project: "../web-common/src/lib/i18n/project.inlang", + outdir: "../web-common/src/lib/i18n/gen", + strategy: ["preferredLanguage", "baseLocale"], + }), + ], envDir: "../", envPrefix: "RILL_UI_PUBLIC_", });