Skip to content
Draft
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
10 changes: 9 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
node-version: [18]
# 18 is the documented minimum; 24 is the current LTS and the only leg
# where `require()` of an ESM dependency works, so it is what actually
# exercises the published CJS entry.
node-version: [18, 24]
os: [ubuntu-latest, windows-latest, macOS-latest]

steps:
Expand All @@ -33,3 +36,8 @@ jobs:
run: npm run test
env:
tests: true

- name: Test published bundle
run: npm run test:dist
env:
tests: true
29 changes: 25 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,17 @@ translation state, loading, caching, route matching, and preprocessing — but
| Tests | Jest + `ts-jest` ESM preset, `testEnvironment: node` |
| Lint | ESLint `airbnb-typescript/base` |
| Runtime peer | `svelte >=3.49.0` (uses `svelte/store` only) |
| CI | `.github/workflows/tests.yml` — Node 18, ubuntu/macOS/windows |
| CI | `.github/workflows/tests.yml` — Node 18 + 24, ubuntu/macOS/windows |

## Commands

| Command | Purpose |
|---------|---------|
| `npm install` / `npm ci` | install (respects `package-lock.json`) |
| `npm run build` | tsup build to `dist/` |
| `npm test` | jest suite |
| `npm test` | jest suite (runs `typecheck` first) |
| `npm run test:dist` | bundle-level suite (builds first) — not part of `npm test` |
| `npm run typecheck` | `tsc --noEmit` over `src` **and** the repo's `*.js` configs |
| `npm run lint` | eslint `--fix` over `.ts`/`.js` (also a pre-commit hook) |

## Repository map
Expand All @@ -54,9 +56,11 @@ translation state, loading, caching, route matching, and preprocessing — but
| `src/logger.ts` | `loggerFactory` + module-level `logger` singleton + `setLogger` |
| `src/types.ts` | all public/internal types |
| `tests/specs/index.spec.ts` | the suite (one `describe`) |
| `tests/specs/dist.spec.ts` | bundle-level checks — runs only via `npm run test:dist` |
| `tests/data/` | `CONFIG` + JSON fixtures + `getTranslations()` |
| `docs/README.md` | public API reference — keep in sync with code |
| `dist/` | generated build output — never hand-edit |
| `tsconfig.tools.json` | type-checks the repo's own `*.js` config files |

## Architecture you must respect

Expand Down Expand Up @@ -228,6 +232,14 @@ not RCE/XSS.
user-controlled key must use an own-property check (`Object.prototype.
hasOwnProperty.call`, or the `hasOwn` helper) so `toString`/`__proto__`/
`constructor` are treated as missing, not inherited members.
- **Never bracket-assign a user-supplied locale or key onto a plain object.**
`table[locale] = value` routes `'__proto__'` through the prototype setter, so
no own key is recorded and the object's prototype is replaced. Write with a
computed-key spread (`{ ...table, [locale]: value }`, which is
`DefineProperty`) or target an `Object.create(null)` object — that is why
`loadedKeys` has a null prototype. The locale-indexed accumulators in
`serialize` and `getTranslationProps` are plain objects and stay correct only
while they follow this rule.
- **Fail soft at the edges.** A single throwing loader must not wipe a whole
batch; a missing config/parser must not throw on `t.get()`/`l.get()`.
- **`route` reaches `RegExp.test()` and is visitor-controlled** (`url.pathname`)
Expand All @@ -246,7 +258,11 @@ not RCE/XSS.

## 13. Tests

- Tests live in `tests/specs/index.spec.ts`; fixtures in `tests/data/`.
- Tests live in `tests/specs/index.spec.ts`; fixtures in `tests/data/`. The one
exception is `tests/specs/dist.spec.ts`, which asserts on the built bundle
(toolchain-level guarantees the sources cannot prove) and therefore runs
separately via `npm run test:dist`, which builds first. Everything else
belongs in `index.spec.ts`.
- Drive behavior through the **public API** (`new i18n(CONFIG)`, stores,
methods). Pure helpers may be imported directly from `src/` when that yields a
more deterministic test (e.g. unit-testing `loggerFactory`).
Expand All @@ -256,7 +272,12 @@ not RCE/XSS.
**alongside** the fix — never a fix without it. Escape hatch (SSR hydration,
infra, purely visual): say so explicitly in the PR with manual repro steps.
- Don't assert on the shared module-level `logger` singleton — it leaks across
async tests; construct a logger/instance locally instead.
async tests; construct a logger/instance locally instead. When the output
under test only reaches that singleton, install it through the spec's
`captureLogs` helper (which restores the previous logger, not a fresh one)
and assert on messages matched by content. Counting is fine once filtered to
the message under test; never assert on a raw call count, which any
still-running load from an earlier test can change.
- `t`/`l` resolve via a no-op test parser (`parse: (...) => key`) — design
assertions accordingly.

Expand Down
55 changes: 48 additions & 7 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,19 @@ This will match:
- `/shop`
- `/shop/cart`

**⚠️ Security note:** Route patterns are tested against the current route — typically the visitor-controlled `url.pathname`. A regular expression with catastrophic backtracking (e.g. nested quantifiers like `/^\/(a+)+$/` — a run of `a`s followed by a non-matching character stalls it for seconds) lets a crafted URL stall your server (ReDoS). Keep route regexes simple and anchored, and avoid nested quantifiers.

Route patterns are matched statelessly and are never written to: a pattern
carrying the `g` or `y` flag matches the same way on every navigation instead of
alternating, its `lastIndex` is left untouched for your own use, and a frozen
pattern works like any other. Flag semantics are preserved — `y` still anchors
the match at the start of the route.

An object that is not a regular expression is asked for a `test(route)` method
like any other, so a custom matcher works; one that cannot answer is reported as
an invalid route config. Entries that are neither strings nor objects simply
never match.

**No routes (global):**

```javascript
Expand Down Expand Up @@ -852,7 +865,11 @@ Array of all available locales (from loaders).

### `loading`

**Type:** `Readable<boolean> & { toPromise: () => Promise<void[]>, get: () => boolean }`
**Type:** `Readable<boolean> & { toPromise: (locale?: string, route?: string) => Promise<void[] | void>, get: () => boolean }`

With overlapping loads the flag belongs to whichever load last touched it, so
prefer awaiting the promise returned by `loadTranslations()` for the load you
actually care about.

Loading state indicator.

Expand Down Expand Up @@ -964,9 +981,11 @@ All loaded translations before preprocessing.

### `loadTranslations`

**Type:** `(locale: string, route?: string) => Promise<void>`
**Type:** `(locale: string, route?: string) => Promise<void[] | void> | undefined`

Load translations for a specific locale and route.
Load translations for a specific locale and route. Returns `undefined` when the
locale cannot be resolved (unknown and no matching `fallbackLocale`), so use
`await` rather than chaining onto the result directly.

**Usage in layouts:**

Expand All @@ -991,13 +1010,33 @@ export const load = async ({ url }) => {
4. Preprocesses translations
5. Updates stores

**Errors:** a loader that throws is caught and logged individually, so one
broken loader does not fail the batch. Anything that throws afterwards — a
custom `preprocess`, a malformed payload — **rejects the returned promise**, so
`await loadTranslations(...)` surfaces it (in SvelteKit, straight to the error
boundary). A result you discard is safe — the failure is logged through the
configured logger and never surfaces as an unhandled rejection — but it is then
only visible in the log.

A subscriber of your own that throws is the exception, and where the error
surfaces depends on which store it subscribes to. `loading` is written while the
load is still starting up, inside the store write your call triggered, so a
throwing `loading` subscriber propagates **synchronously** out of
`loadTranslations()` / `setLocale()` / `setRoute()` — there is no promise to
reject yet. `translations` and `locale` are written after the fetch resolves, so
a throwing subscriber there **rejects the returned promise** like any other
late failure. Either way the throw leaves svelte's subscriber queue mid-flush,
which stalls later store updates, so keep subscribers total.

---

### `setLocale`

**Type:** `(locale: string) => Promise<void>`
**Type:** `(locale?: string) => Promise<void[] | void> | undefined`

Set locale and load its translations.
Set locale and load its translations. Returns `undefined` when no locale is
given or the locale is already active; errors behave as in
[`loadTranslations`](#loadtranslations).

**Usage:**

Expand All @@ -1017,9 +1056,11 @@ await setLocale('cs');

### `setRoute`

**Type:** `(route: string) => Promise<void>`
**Type:** `(route: string) => Promise<void[] | void> | undefined`

Update current route and load route-specific translations.
Update current route and load route-specific translations. Returns `undefined`
when the route is already active; errors behave as in
[`loadTranslations`](#loadtranslations).

**Usage:**

Expand Down
13 changes: 11 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { createDefaultEsmPreset } from 'ts-jest';

/** @type {import('ts-jest').JestConfigWithTsJest} */
export default ({
preset: 'ts-jest/presets/default-esm',
// Compiled with `tests/tsconfig.json` (see the comment there). The preset
// helper owns the transform pattern, so an upgrade cannot leave a second,
// stale entry behind that the `tsconfig` override would not reach.
...createDefaultEsmPreset({ tsconfig: '<rootDir>/tests/tsconfig.json' }),
testEnvironment: 'node',
});
// `dist.spec.ts` asserts on a built bundle and only runs through
// `npm run test:dist`, which builds it first; a bare `jest` would otherwise
// resolve nothing on a fresh clone or assert against a stale artifact.
testPathIgnorePatterns: ['/node_modules/', '<rootDir>/tests/specs/dist\\.spec\\.ts$'],
});
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
},
"scripts": {
"dev": "tsup --watch",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.tools.json",
"pretest": "npm run typecheck",
"test": "npx cross-env NODE_OPTIONS=--experimental-vm-modules jest",
"pretest:dist": "npm run build",
"test:dist": "npx cross-env NODE_OPTIONS=--experimental-vm-modules jest tests/specs/dist.spec.ts --testPathIgnorePatterns=/node_modules/",
"build": "tsup",
"prepublishOnly": "npm run build",
"lint": "eslint --fix --ext .ts,.js --ignore-path .gitignore ."
Expand Down
Loading
Loading