@stealthscale/vite-plugin-i18n 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/client.d.ts +59 -0
- package/dist/cyclonedx/bom.json +1 -0
- package/dist/index.d.mts +222 -0
- package/dist/index.mjs +788 -0
- package/package.json +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stealth Scale B.V.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# @stealthscale/vite-plugin-i18n
|
|
2
|
+
|
|
3
|
+
`@stealthscale/vite-plugin-i18n` finds every catalogue an application can reach, types their keys,
|
|
4
|
+
and serves the module it loads them from. A package keeps its words beside its code and ships them.
|
|
5
|
+
The application reaches every catalogue without listing one.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add -D @stealthscale/vite-plugin-i18n
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The package peers on `vite` and `@stealthscale/vite-plugin-base`. Most repositories reach it through
|
|
14
|
+
`@stealthscale/vite-config-i18n` rather than adding it by hand.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { i18n } from "@stealthscale/vite-plugin-i18n";
|
|
18
|
+
|
|
19
|
+
export default defineConfig({ plugins: [i18n()] });
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Catalogue layout
|
|
23
|
+
|
|
24
|
+
A package keeps its catalogues under `locales/<language>/<namespace>.json`, or the same in YAML.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
locales/en/menu.json
|
|
28
|
+
locales/en/menu/sections.json
|
|
29
|
+
locales/nl/menu.json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The namespace is the first path segment under the language. Split one namespace across files by
|
|
33
|
+
putting them in a directory: `menu/sections.json` contributes its keys under `sections`. A namespace
|
|
34
|
+
is always fetched as one module, whatever it is split into.
|
|
35
|
+
|
|
36
|
+
## The search
|
|
37
|
+
|
|
38
|
+
The search starts at the application and follows every package it depends on, through runtime
|
|
39
|
+
dependencies, peers, and its own development dependencies. Only packages under the application's
|
|
40
|
+
scope are followed, because a walk that read every dependency of a rendering engine would open
|
|
41
|
+
hundreds of manifests to find no catalogue in any of them. Pass `scopes` to follow others.
|
|
42
|
+
|
|
43
|
+
Packages are read deepest first and the application last, so where two name the same language and
|
|
44
|
+
namespace the application's words win. A package is resolved the way its import is, so a workspace
|
|
45
|
+
link and an installed copy are found alike.
|
|
46
|
+
|
|
47
|
+
## virtual:i18n
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { catalogues } from "virtual:i18n";
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`catalogues` carries the languages and namespaces found, the fallback language's words inlined so
|
|
54
|
+
the first paint has them, and a loader that fetches any other language's namespace as one module.
|
|
55
|
+
Set `eager` to inline every language and fetch nothing.
|
|
56
|
+
|
|
57
|
+
Add the types with a triple-slash directive from a file the project already compiles.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
/// <reference types="@stealthscale/vite-plugin-i18n/client" />
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Generated types
|
|
64
|
+
|
|
65
|
+
The plugin writes `src/i18n.gen.d.ts`, which adds every namespace to the foundation's `Resources`
|
|
66
|
+
interface with each word as the string literal it is. A key that does not exist and a placeholder
|
|
67
|
+
left out of a call are both errors in the editor. Set `types` to another path, or to `false` to
|
|
68
|
+
write none.
|
|
69
|
+
|
|
70
|
+
## The build gate
|
|
71
|
+
|
|
72
|
+
A build fails on any of three faults:
|
|
73
|
+
|
|
74
|
+
- A key the fallback does not define.
|
|
75
|
+
- A placeholder a translation drops.
|
|
76
|
+
- One owner declaring a key in two files.
|
|
77
|
+
|
|
78
|
+
A dev server reports all three and keeps serving.
|
|
79
|
+
|
|
80
|
+
A plural form is checked against any form of the same key, and may write the count out in words:
|
|
81
|
+
`één pagina` is accepted against `{{count}} page`. Every other placeholder is still required.
|
|
82
|
+
|
|
83
|
+
## Hot updates
|
|
84
|
+
|
|
85
|
+
On a dev server a changed catalogue is sent to the page as an `i18n:catalogue` event carrying the
|
|
86
|
+
pair merged afresh. The foundation replaces the words in place, so the page keeps its state. A
|
|
87
|
+
catalogue appearing or disappearing regenerates the types as well.
|
|
88
|
+
|
|
89
|
+
## Licence
|
|
90
|
+
|
|
91
|
+
MIT. See [LICENSE](LICENSE).
|
package/client.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the catalogue plugin emits, declared so that importing it type-checks.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* An application reaches this with a triple-slash directive naming
|
|
6
|
+
* `@stealthscale/vite-plugin-i18n/client`, from a file it already compiles. The module exists
|
|
7
|
+
* only in a build the plugin takes part in.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
declare module "virtual:i18n" {
|
|
11
|
+
/**
|
|
12
|
+
* Describes what one catalogue holds: keys to words, nested as deep as the author likes.
|
|
13
|
+
*/
|
|
14
|
+
export interface Words {
|
|
15
|
+
readonly [key: string]: string | Words;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Describes the catalogues an application loads its words from.
|
|
20
|
+
*/
|
|
21
|
+
export interface Catalogues {
|
|
22
|
+
/**
|
|
23
|
+
* The words inlined, by language then namespace: the fallback language's, or every language's
|
|
24
|
+
* where the plugin was told to be eager.
|
|
25
|
+
*/
|
|
26
|
+
readonly bundled: Readonly<Record<string, Readonly<Record<string, Words>>>>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The fallback language's words, every namespace merged, inlined so the first paint has them.
|
|
30
|
+
*/
|
|
31
|
+
readonly defaults: Readonly<Record<string, Words>>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The language every key is defined in.
|
|
35
|
+
*/
|
|
36
|
+
readonly fallback: string;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Every language a catalogue was found for.
|
|
40
|
+
*/
|
|
41
|
+
readonly languages: readonly string[];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Loads one language's namespace, every file naming the pair merged, or nothing where none
|
|
45
|
+
* does.
|
|
46
|
+
*/
|
|
47
|
+
readonly load: (language: string, namespace: string) => Promise<undefined | Words>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every namespace found.
|
|
51
|
+
*/
|
|
52
|
+
readonly namespaces: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The catalogues found.
|
|
57
|
+
*/
|
|
58
|
+
export const catalogues: Catalogues;
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"$schema":"http://cyclonedx.org/schema/bom-1.7.schema.json","bomFormat":"CycloneDX","specVersion":"1.7","version":1,"metadata":{"lifecycles":[{"phase":"build"}],"tools":{"components":[{"type":"application","name":"rolldown","version":"1.2.7"},{"type":"application","name":"vite","version":"8.2.2"},{"type":"library","name":"core","group":"@arethetypeswrong","version":"0.18.5","bom-ref":"pkg:npm/%40arethetypeswrong/core@0.18.5","author":"Andrew Branch","description":"The API behind arethetypeswrong.github.io","hashes":[{"alg":"SHA-512","content":"f72b63cc6c318e6f54cfb23d6af7dbb79be542deae93db914444b3263aabce797a58abc8e9dc184e8faf277534d96aeafe64778aa97f3f386f1e129d2480230d"}],"licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40arethetypeswrong/core@0.18.5","externalReferences":[{"url":"https://github.com/arethetypeswrong/arethetypeswrong.github.io.git#packages/core","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=20"}]},{"type":"library","name":"testing-config","group":"@stealthscale","version":"0.3.0","bom-ref":"pkg:npm/%40stealthscale/testing-config@0.3.0","description":"Lists every part of the house contract a config, plugin or library package breaks.","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40stealthscale/testing-config@0.3.0","externalReferences":[{"url":"https://github.com/stealth-scale/config/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs\""},{"url":"git+https://github.com/stealth-scale/config.git#packages/testing-config","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://github.com/stealth-scale/config/tree/main/packages/testing-config#readme","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=26.0.0"}]},{"type":"library","name":"testing","group":"@stealthscale","version":"0.3.0","bom-ref":"pkg:npm/%40stealthscale/testing@0.3.0","description":"Supplies the scratch workspaces, manifests, element measurements and plugin drivers a specification needs.","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40stealthscale/testing@0.3.0","externalReferences":[{"url":"git+https://github.com/stealth-scale/config.git#packages/testing","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=26.0.0"}]},{"type":"library","name":"vite-config-typescript","group":"@stealthscale","version":"0.3.0","bom-ref":"pkg:npm/%40stealthscale/vite-config-typescript@0.3.0","description":"Sets the TypeScript options every stealthscale package compiles with.","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40stealthscale/vite-config-typescript@0.3.0","externalReferences":[{"url":"https://github.com/stealth-scale/config/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs\""},{"url":"git+https://github.com/stealth-scale/config.git#packages/vite-config-typescript","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://github.com/stealth-scale/config/tree/main/packages/vite-config-typescript#readme","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=26.0.0"}]},{"type":"library","name":"vite-config","group":"@stealthscale","version":"0.6.0","bom-ref":"pkg:npm/%40stealthscale/vite-config@0.6.0","description":"Configures a package from five Vite+ tiers and a named layer for every block.","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40stealthscale/vite-config@0.6.0","externalReferences":[{"url":"https://github.com/stealth-scale/config/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs\""},{"url":"git+https://github.com/stealth-scale/config.git#packages/vite-config","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://github.com/stealth-scale/config/tree/main/packages/vite-config#readme","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=26.0.0"}]},{"type":"library","name":"vite-plugin-base","group":"@stealthscale","version":"0.2.0","bom-ref":"pkg:npm/%40stealthscale/vite-plugin-base@0.2.0","description":"Turns a name and one write step into a bundler plugin that reads the module graph.","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40stealthscale/vite-plugin-base@0.2.0","externalReferences":[{"url":"https://github.com/stealth-scale/config/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs\""},{"url":"git+https://github.com/stealth-scale/config.git#packages/vite-plugin-base","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://github.com/stealth-scale/config/tree/main/packages/vite-plugin-base#readme","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=26.0.0"}]},{"type":"library","name":"node","group":"@types","version":"26.5.1","bom-ref":"pkg:npm/%40types/node@26.5.1","description":"TypeScript definitions for node","hashes":[{"alg":"SHA-512","content":"0b3366d857b35b8551fcb8c6eb251d88480b13fac043d4a58f9802bbf0b656b75c5bb2346a1359f0345b1d3ef3399eabdce36077f6ec4087926a80c6addd42ea"}],"licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40types/node@26.5.1","externalReferences":[{"url":"https://github.com/DefinitelyTyped/DefinitelyTyped.git#types/node","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node","type":"website","comment":"as detected from PackageJson property \"homepage\""}]},{"type":"library","name":"coverage-v8","group":"@vitest","version":"4.1.11","bom-ref":"pkg:npm/%40vitest/coverage-v8@4.1.11","author":"Anthony Fu <anthonyfu117@hotmail.com>","description":"V8 coverage provider for Vitest","hashes":[{"alg":"SHA-512","content":"f0c5461059c921c7468dc6df2a67aaf33d296471f426556da15647f50feac14a7ac3216710950132bc3d0c26a3fab6b7bd28469a16af8da94c2213cdc5901473"}],"licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40vitest/coverage-v8@4.1.11","externalReferences":[{"url":"https://github.com/vitest-dev/vitest/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs.url\""},{"url":"git+https://github.com/vitest-dev/vitest.git#packages/coverage-v8","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://vitest.dev/guide/coverage","type":"website","comment":"as detected from PackageJson property \"homepage\""}]},{"type":"library","name":"vite-plus-core","group":"@voidzero-dev","version":"0.3.1","bom-ref":"pkg:npm/%40voidzero-dev/vite-plus-core@0.3.1","author":"VoidZero Inc.","description":"The Unified Toolchain for the Web","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40voidzero-dev/vite-plus-core@0.3.1","externalReferences":[{"url":"https://github.com/voidzero-dev/vite-plus/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs.url\""},{"url":"git+https://github.com/voidzero-dev/vite-plus.git#packages/core","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://viteplus.dev/guide","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":"^20.19.0 || ^22.18.0 || >=24.11.0"}]},{"type":"library","name":"typescript","version":"7.0.2","bom-ref":"pkg:npm/typescript@7.0.2","author":"Microsoft Corp.","description":"TypeScript is a language for application scale JavaScript development","hashes":[{"alg":"SHA-512","content":"f0561abbdea8dcd28e85b8ca8bfa8dbc6fd6e63873c646dd9b9b23f406d9ff94f9b16aa7de12602df1b1dbbb11299593bf2cc23fc74b4414dfe6d05349155434"}],"licenses":[{"license":{"id":"Apache-2.0"}}],"purl":"pkg:npm/typescript@7.0.2","externalReferences":[{"url":"https://github.com/microsoft/TypeScript/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs.url\""},{"url":"https://github.com/microsoft/TypeScript.git","type":"vcs","comment":"as detected from PackageJson property \"repository.url\""},{"url":"https://www.typescriptlang.org/","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=16.20.0"}]},{"type":"library","name":"vitest","version":"4.1.11","bom-ref":"pkg:npm/vitest@4.1.11","author":"Anthony Fu <anthonyfu117@hotmail.com>","description":"Next generation testing framework powered by Vite","hashes":[{"alg":"SHA-512","content":"7e1002acd5d489d21b19206be4595bb8190eed5582d59c8b9740cee025360eb42800fc57f3862cc6cf877864298a2e656565754388016534eee3d985f80e8477"}],"licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/vitest@4.1.11","externalReferences":[{"url":"https://github.com/vitest-dev/vitest/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs.url\""},{"url":"git+https://github.com/vitest-dev/vitest.git#packages/vitest","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://vitest.dev","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":"^20.0.0 || ^22.0.0 || >=24.0.0"}]}]},"component":{"type":"library","name":"vite-plugin-i18n","group":"@stealthscale","version":"0.0.0","bom-ref":"pkg:npm/%40stealthscale/vite-plugin-i18n@0.0.0","description":"Finds every catalogue an application can reach, types their keys, and answers the module it loads them from.","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40stealthscale/vite-plugin-i18n@0.0.0","externalReferences":[{"url":"https://github.com/stealth-scale/config/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs\""},{"url":"git+https://github.com/stealth-scale/config.git#packages/vite-plugin-i18n","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://github.com/stealth-scale/config/tree/main/packages/vite-plugin-i18n#readme","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=26.0.0"}],"evidence":{"licenses":[{"license":{"name":"file: LICENSE","text":{"content":"TUlUIExpY2Vuc2UKCkNvcHlyaWdodCAoYykgMjAyNiBTdGVhbHRoIFNjYWxlIEIuVi4KClBlcm1pc3Npb24gaXMgaGVyZWJ5IGdyYW50ZWQsIGZyZWUgb2YgY2hhcmdlLCB0byBhbnkgcGVyc29uIG9idGFpbmluZyBhIGNvcHkKb2YgdGhpcyBzb2Z0d2FyZSBhbmQgYXNzb2NpYXRlZCBkb2N1bWVudGF0aW9uIGZpbGVzICh0aGUgIlNvZnR3YXJlIiksIHRvIGRlYWwKaW4gdGhlIFNvZnR3YXJlIHdpdGhvdXQgcmVzdHJpY3Rpb24sIGluY2x1ZGluZyB3aXRob3V0IGxpbWl0YXRpb24gdGhlIHJpZ2h0cwp0byB1c2UsIGNvcHksIG1vZGlmeSwgbWVyZ2UsIHB1Ymxpc2gsIGRpc3RyaWJ1dGUsIHN1YmxpY2Vuc2UsIGFuZC9vciBzZWxsCmNvcGllcyBvZiB0aGUgU29mdHdhcmUsIGFuZCB0byBwZXJtaXQgcGVyc29ucyB0byB3aG9tIHRoZSBTb2Z0d2FyZSBpcwpmdXJuaXNoZWQgdG8gZG8gc28sIHN1YmplY3QgdG8gdGhlIGZvbGxvd2luZyBjb25kaXRpb25zOgoKVGhlIGFib3ZlIGNvcHlyaWdodCBub3RpY2UgYW5kIHRoaXMgcGVybWlzc2lvbiBub3RpY2Ugc2hhbGwgYmUgaW5jbHVkZWQgaW4gYWxsCmNvcGllcyBvciBzdWJzdGFudGlhbCBwb3J0aW9ucyBvZiB0aGUgU29mdHdhcmUuCgpUSEUgU09GVFdBUkUgSVMgUFJPVklERUQgIkFTIElTIiwgV0lUSE9VVCBXQVJSQU5UWSBPRiBBTlkgS0lORCwgRVhQUkVTUyBPUgpJTVBMSUVELCBJTkNMVURJTkcgQlVUIE5PVCBMSU1JVEVEIFRPIFRIRSBXQVJSQU5USUVTIE9GIE1FUkNIQU5UQUJJTElUWSwKRklUTkVTUyBGT1IgQSBQQVJUSUNVTEFSIFBVUlBPU0UgQU5EIE5PTklORlJJTkdFTUVOVC4gSU4gTk8gRVZFTlQgU0hBTEwgVEhFCkFVVEhPUlMgT1IgQ09QWVJJR0hUIEhPTERFUlMgQkUgTElBQkxFIEZPUiBBTlkgQ0xBSU0sIERBTUFHRVMgT1IgT1RIRVIKTElBQklMSVRZLCBXSEVUSEVSIElOIEFOIEFDVElPTiBPRiBDT05UUkFDVCwgVE9SVCBPUiBPVEhFUldJU0UsIEFSSVNJTkcgRlJPTSwKT1VUIE9GIE9SIElOIENPTk5FQ1RJT04gV0lUSCBUSEUgU09GVFdBUkUgT1IgVEhFIFVTRSBPUiBPVEhFUiBERUFMSU5HUyBJTiBUSEUKU09GVFdBUkUuCg==","contentType":"text/plain","encoding":"base64"}}}]}},"supplier":{"name":"Stealth Scale B.V.","url":["https://stealthscale.io"]}},"components":[],"dependencies":[{"ref":"pkg:npm/%40stealthscale/vite-plugin-i18n@0.0.0"}]}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { Plugin } from "vite";
|
|
2
|
+
//#region src/find.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Locates every `locales/<language>/<namespace>.json` an application and its dependencies ship.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* A namespace is the first path segment under the language. `menu.json` and
|
|
8
|
+
* `menu/sections/billing.json` both belong to the namespace `menu`, and the second contributes
|
|
9
|
+
* its keys under `sections.billing`. A namespace is fetched as one module whatever it is split
|
|
10
|
+
* into.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* One catalogue file, with the language, namespace and package it belongs to.
|
|
14
|
+
*/
|
|
15
|
+
interface Catalogue {
|
|
16
|
+
/**
|
|
17
|
+
* The absolute path of the file, with forward slashes.
|
|
18
|
+
*/
|
|
19
|
+
readonly file: string;
|
|
20
|
+
/**
|
|
21
|
+
* The BCP 47 tag the directory is named after, such as `en` or `nl-BE`.
|
|
22
|
+
*/
|
|
23
|
+
readonly language: string;
|
|
24
|
+
/**
|
|
25
|
+
* The first path segment under the language, without its extension. `menu.json` and
|
|
26
|
+
* `menu/sections.json` both give `menu`.
|
|
27
|
+
*/
|
|
28
|
+
readonly namespace: string;
|
|
29
|
+
/**
|
|
30
|
+
* True when the application owns the file, false when a dependency ships it.
|
|
31
|
+
*/
|
|
32
|
+
readonly own: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The name of the package the file came from.
|
|
35
|
+
*/
|
|
36
|
+
readonly owner: string;
|
|
37
|
+
/**
|
|
38
|
+
* The path below the namespace, joined with dots. `menu/sections/billing.json` gives
|
|
39
|
+
* `sections.billing`, and a file that is the namespace itself gives an empty string.
|
|
40
|
+
*/
|
|
41
|
+
readonly prefix: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The directory a package keeps its catalogues in.
|
|
45
|
+
*/
|
|
46
|
+
export declare const LOCALES = "locales";
|
|
47
|
+
/**
|
|
48
|
+
* Returns every catalogue an application can reach, its dependencies' first and its own last.
|
|
49
|
+
*
|
|
50
|
+
* @remarks
|
|
51
|
+
* When two packages declare the same language and namespace, the one later in the result wins the
|
|
52
|
+
* merge, which puts the application last. Each package is resolved the way an import of it
|
|
53
|
+
* resolves, so a workspace link and an installed copy are found alike.
|
|
54
|
+
* @param root - The application directory.
|
|
55
|
+
* @param scopes - The scopes to follow. Defaults to the application's own scope.
|
|
56
|
+
* @returns The catalogues in merge order.
|
|
57
|
+
*/
|
|
58
|
+
export declare function found(root: string, scopes?: readonly string[]): readonly Catalogue[];
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/emit.d.ts
|
|
61
|
+
/**
|
|
62
|
+
* The specifier an application imports to reach its catalogues.
|
|
63
|
+
*/
|
|
64
|
+
export declare const ID = "virtual:i18n";
|
|
65
|
+
/**
|
|
66
|
+
* Builds the specifier one language's namespace is fetched under.
|
|
67
|
+
*
|
|
68
|
+
* @remarks
|
|
69
|
+
* Neither a language tag nor a namespace contains a slash, so `pairOfId` splits the result back
|
|
70
|
+
* without ambiguity.
|
|
71
|
+
* @param language - The BCP 47 tag.
|
|
72
|
+
* @param namespace - The namespace name.
|
|
73
|
+
* @returns A specifier such as `virtual:i18n/nl/menu`.
|
|
74
|
+
*/
|
|
75
|
+
export declare function pairId(language: string, namespace: string): string;
|
|
76
|
+
/**
|
|
77
|
+
* One language and one namespace, which together address a single module.
|
|
78
|
+
*/
|
|
79
|
+
type Pair = Pick<Catalogue, "language" | "namespace">;
|
|
80
|
+
/**
|
|
81
|
+
* Splits a pair module's specifier back into its language and namespace.
|
|
82
|
+
*
|
|
83
|
+
* @param id - A specifier, with or without the resolution prefix.
|
|
84
|
+
* @returns The pair, or undefined when the specifier addresses something else.
|
|
85
|
+
*/
|
|
86
|
+
export declare function pairOfId(id: string): Pair | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* The contents of one catalogue: keys mapped to strings, nested to any depth.
|
|
89
|
+
*/
|
|
90
|
+
interface Words {
|
|
91
|
+
readonly [key: string]: string | Words;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Catalogue files indexed by language, then by namespace, each list in merge order.
|
|
95
|
+
*/
|
|
96
|
+
type CatalogueIndex = ReadonlyMap<string, ReadonlyMap<string, readonly Catalogue[]>>;
|
|
97
|
+
/**
|
|
98
|
+
* Indexes catalogues by language and namespace, preserving the order they were found in.
|
|
99
|
+
*
|
|
100
|
+
* @param catalogues - Every catalogue found.
|
|
101
|
+
*/
|
|
102
|
+
export declare function indexed(catalogues: readonly Catalogue[]): CatalogueIndex;
|
|
103
|
+
/**
|
|
104
|
+
* Generates the module an application imports its catalogues from.
|
|
105
|
+
*
|
|
106
|
+
* @remarks
|
|
107
|
+
* The fallback language is inlined so the first paint needs no request. Every other language is
|
|
108
|
+
* reached through the loader. Under `eager` every language is inlined instead and the loader
|
|
109
|
+
* fetches nothing, which costs one larger bundle and saves a request per language.
|
|
110
|
+
* @param index - The indexed catalogues.
|
|
111
|
+
* @param fallback - The language that defines every key.
|
|
112
|
+
* @param eager - Inlines every language when true.
|
|
113
|
+
* @returns The module as JavaScript source.
|
|
114
|
+
*/
|
|
115
|
+
export declare function cataloguesModule(index: CatalogueIndex, fallback: string, eager?: boolean): string;
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/check.d.ts
|
|
118
|
+
/**
|
|
119
|
+
* One fault found in a catalogue.
|
|
120
|
+
*/
|
|
121
|
+
interface Problem {
|
|
122
|
+
/**
|
|
123
|
+
* The absolute path of the file holding the fault.
|
|
124
|
+
*/
|
|
125
|
+
readonly file: string;
|
|
126
|
+
/**
|
|
127
|
+
* The dotted key, or an empty string when the fault is about the whole file.
|
|
128
|
+
*/
|
|
129
|
+
readonly key: string;
|
|
130
|
+
/**
|
|
131
|
+
* The fault, phrased as a clause that follows the key.
|
|
132
|
+
*/
|
|
133
|
+
readonly says: string;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Validates every catalogue: the fallback's files against each other, then each translation against
|
|
137
|
+
* the fallback.
|
|
138
|
+
*
|
|
139
|
+
* @param index - The indexed catalogues.
|
|
140
|
+
* @param fallback - The language that defines every key.
|
|
141
|
+
* @returns Every fault, the fallback's first, then in file order.
|
|
142
|
+
*/
|
|
143
|
+
export declare function problems(index: CatalogueIndex, fallback: string): readonly Problem[];
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/plugin.d.ts
|
|
146
|
+
/**
|
|
147
|
+
* The custom event name a changed catalogue is sent to the page under.
|
|
148
|
+
*
|
|
149
|
+
* @remarks
|
|
150
|
+
* The foundation listens for the same string. Neither package imports the other, so the name is a
|
|
151
|
+
* contract between them rather than a shared constant.
|
|
152
|
+
*/
|
|
153
|
+
export declare const EVENT = "i18n:catalogue";
|
|
154
|
+
/**
|
|
155
|
+
* The payload the change event carries.
|
|
156
|
+
*/
|
|
157
|
+
interface Changed {
|
|
158
|
+
/**
|
|
159
|
+
* The BCP 47 tag of the language that changed.
|
|
160
|
+
*/
|
|
161
|
+
readonly language: string;
|
|
162
|
+
/**
|
|
163
|
+
* The namespace.
|
|
164
|
+
*/
|
|
165
|
+
readonly namespace: string;
|
|
166
|
+
/**
|
|
167
|
+
* Every file of the pair merged, so an override survives a change to the package it overrides.
|
|
168
|
+
*/
|
|
169
|
+
readonly words: Readonly<Record<string, unknown>>;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Configures the catalogues.
|
|
173
|
+
*/
|
|
174
|
+
interface Options {
|
|
175
|
+
/**
|
|
176
|
+
* Inlines every language rather than the fallback alone, so nothing is fetched. Off by default.
|
|
177
|
+
*/
|
|
178
|
+
readonly eager?: boolean | undefined;
|
|
179
|
+
/**
|
|
180
|
+
* The language every key is defined in, which is inlined for the first paint. `en` by default.
|
|
181
|
+
*/
|
|
182
|
+
readonly fallback?: string | undefined;
|
|
183
|
+
/**
|
|
184
|
+
* Decides which namespaces to keep, given each name. Keeps every one by default.
|
|
185
|
+
*/
|
|
186
|
+
readonly namespaces?: ((namespace: string) => boolean) | undefined;
|
|
187
|
+
/**
|
|
188
|
+
* The scopes whose packages are searched. The application's own scope by default.
|
|
189
|
+
*/
|
|
190
|
+
readonly scopes?: readonly string[] | undefined;
|
|
191
|
+
/**
|
|
192
|
+
* Where to write the types, against the project root, or false to write none.
|
|
193
|
+
* `src/i18n.gen.d.ts` by default, which the house treats as generated and excludes from coverage.
|
|
194
|
+
*/
|
|
195
|
+
readonly types?: false | string | undefined;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Builds the plugin that finds the catalogues, types their keys, and serves `virtual:i18n`.
|
|
199
|
+
*
|
|
200
|
+
* @remarks
|
|
201
|
+
* On a dev server a catalogue change is pushed to the page as an event rather than a reload, so
|
|
202
|
+
* the page keeps its state. In a build an invalid catalogue throws instead.
|
|
203
|
+
* @param options - Where to search and what to write. `Options` documents every member.
|
|
204
|
+
* @returns The plugin.
|
|
205
|
+
*/
|
|
206
|
+
export declare function i18n(options?: Options): Plugin;
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/typegen.d.ts
|
|
209
|
+
/**
|
|
210
|
+
* The package whose `Resources` interface the types are added to.
|
|
211
|
+
*/
|
|
212
|
+
export declare const FOUNDATION = "@stealthscale/provider-i18n";
|
|
213
|
+
/**
|
|
214
|
+
* Renders the augmentation that adds every namespace to the foundation's `Resources`.
|
|
215
|
+
*
|
|
216
|
+
* @param index - The indexed catalogues.
|
|
217
|
+
* @param fallback - The language whose strings type the keys.
|
|
218
|
+
* @returns The declaration file as TypeScript source.
|
|
219
|
+
*/
|
|
220
|
+
export declare function declared(index: CatalogueIndex, fallback: string): string;
|
|
221
|
+
//#endregion
|
|
222
|
+
export type { Catalogue, CatalogueIndex, Changed, Options, Pair, Problem, Words };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
import { existsSync, globSync, readFileSync } from "node:fs";
|
|
2
|
+
import { parse } from "yaml";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { normalizePath } from "vite";
|
|
5
|
+
import { manifestAt, packageAt, text, writeIfChanged } from "@stealthscale/vite-plugin-base";
|
|
6
|
+
//#region src/find.ts
|
|
7
|
+
/**
|
|
8
|
+
* Locates every `locales/<language>/<namespace>.json` an application and its dependencies ship.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* A namespace is the first path segment under the language. `menu.json` and
|
|
12
|
+
* `menu/sections/billing.json` both belong to the namespace `menu`, and the second contributes
|
|
13
|
+
* its keys under `sections.billing`. A namespace is fetched as one module whatever it is split
|
|
14
|
+
* into.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The directory a package keeps its catalogues in.
|
|
18
|
+
*/
|
|
19
|
+
const LOCALES = "locales";
|
|
20
|
+
/**
|
|
21
|
+
* Matches the extensions a catalogue may use: `.json`, `.yaml` and `.yml`.
|
|
22
|
+
*/
|
|
23
|
+
const EXTENSION = /\.(?:json|ya?ml)$/u;
|
|
24
|
+
/**
|
|
25
|
+
* Returns the namespace a file belongs to.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* The first path segment, with the extension stripped when the file is the namespace itself.
|
|
29
|
+
* @param relative - The file's path under the language directory.
|
|
30
|
+
*/
|
|
31
|
+
function namespaceOf(relative) {
|
|
32
|
+
const at = relative.indexOf("/");
|
|
33
|
+
return at === -1 ? relative.replace(EXTENSION, "") : relative.slice(0, at);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Returns the prefix a file's keys are nested under.
|
|
37
|
+
*
|
|
38
|
+
* @remarks
|
|
39
|
+
* The path below the namespace with the extension stripped and each separator replaced by a dot.
|
|
40
|
+
* @param relative - The file's path under the language directory.
|
|
41
|
+
* @returns The prefix, or an empty string when the file is the namespace itself.
|
|
42
|
+
*/
|
|
43
|
+
function prefixOf(relative) {
|
|
44
|
+
const at = relative.indexOf("/");
|
|
45
|
+
return at === -1 ? "" : relative.slice(at + 1).replace(EXTENSION, "").replaceAll("/", ".");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Lists every catalogue file under one directory's `locales`, at any depth.
|
|
49
|
+
*
|
|
50
|
+
* @param directory - The package or application directory to search.
|
|
51
|
+
* @param owner - The package name to record on each result.
|
|
52
|
+
* @param own - True when the directory is the application's own.
|
|
53
|
+
* @returns The catalogues sorted by path, so the result is stable between runs.
|
|
54
|
+
*/
|
|
55
|
+
function cataloguesIn(directory, owner, own) {
|
|
56
|
+
const root = join(directory, LOCALES);
|
|
57
|
+
if (!existsSync(root)) return [];
|
|
58
|
+
return globSync("*/**/*.{json,yaml,yml}", { cwd: root }).map((relative) => normalizePath(relative)).toSorted().map((relative) => {
|
|
59
|
+
const at = relative.indexOf("/");
|
|
60
|
+
const rest = relative.slice(at + 1);
|
|
61
|
+
return {
|
|
62
|
+
file: normalizePath(join(root, relative)),
|
|
63
|
+
language: relative.slice(0, at),
|
|
64
|
+
namespace: namespaceOf(rest),
|
|
65
|
+
own,
|
|
66
|
+
owner,
|
|
67
|
+
prefix: prefixOf(rest)
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Returns the scope a package name carries.
|
|
73
|
+
*
|
|
74
|
+
* @param name - The package name, or undefined when the manifest declares none.
|
|
75
|
+
* @returns The scope such as `@stealthscale`, or undefined for an unscoped name.
|
|
76
|
+
*/
|
|
77
|
+
function scopeOf(name) {
|
|
78
|
+
return name?.startsWith("@") === true ? name.slice(0, name.indexOf("/")) : void 0;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Lists the package names the walk follows out of one manifest.
|
|
82
|
+
*
|
|
83
|
+
* @remarks
|
|
84
|
+
* `dependencies` and `peerDependencies` both count, because a component package declares its
|
|
85
|
+
* siblings as peers. The application's `devDependencies` count too, because an example declares
|
|
86
|
+
* the packages it demonstrates there.
|
|
87
|
+
* @param manifest - The parsed package.json.
|
|
88
|
+
* @param own - True for the application's own manifest.
|
|
89
|
+
*/
|
|
90
|
+
function namesIn(manifest, own) {
|
|
91
|
+
return [
|
|
92
|
+
"dependencies",
|
|
93
|
+
"peerDependencies",
|
|
94
|
+
...own ? ["devDependencies"] : []
|
|
95
|
+
].flatMap((field) => {
|
|
96
|
+
const declared = manifest[field];
|
|
97
|
+
return typeof declared === "object" && declared !== null ? Object.keys(declared) : [];
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Visits a directory's dependencies depth first, so a package precedes whatever depends on it.
|
|
102
|
+
*
|
|
103
|
+
* @remarks
|
|
104
|
+
* Only packages under one of the scopes are followed. Following every dependency of a rendering
|
|
105
|
+
* engine would read hundreds of manifests and find no catalogue in any of them.
|
|
106
|
+
* @param directory - The directory to start from.
|
|
107
|
+
* @param walk - The scopes to follow and the directories already visited.
|
|
108
|
+
* @param own - True for the application's own directory.
|
|
109
|
+
* @returns Each package with its manifest, the starting directory last.
|
|
110
|
+
*/
|
|
111
|
+
function walked(directory, walk, own) {
|
|
112
|
+
if (walk.seen.has(directory)) return [];
|
|
113
|
+
walk.seen.add(directory);
|
|
114
|
+
const manifest = manifestAt(directory);
|
|
115
|
+
if (manifest === void 0) return [];
|
|
116
|
+
return [...namesIn(manifest, own).filter((name) => walk.scopes.has(scopeOf(name) ?? "")).map((name) => packageAt(name, directory)).filter((at) => at !== void 0).flatMap((at) => walked(at, walk, false)), {
|
|
117
|
+
at: directory,
|
|
118
|
+
manifest
|
|
119
|
+
}];
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Returns every catalogue an application can reach, its dependencies' first and its own last.
|
|
123
|
+
*
|
|
124
|
+
* @remarks
|
|
125
|
+
* When two packages declare the same language and namespace, the one later in the result wins the
|
|
126
|
+
* merge, which puts the application last. Each package is resolved the way an import of it
|
|
127
|
+
* resolves, so a workspace link and an installed copy are found alike.
|
|
128
|
+
* @param root - The application directory.
|
|
129
|
+
* @param scopes - The scopes to follow. Defaults to the application's own scope.
|
|
130
|
+
* @returns The catalogues in merge order.
|
|
131
|
+
*/
|
|
132
|
+
function found(root, scopes) {
|
|
133
|
+
const own = manifestAt(root);
|
|
134
|
+
const named = own === void 0 ? void 0 : text(own, "name");
|
|
135
|
+
return walked(root, {
|
|
136
|
+
scopes: new Set(scopes ?? [scopeOf(named)].filter((scope) => scope !== void 0)),
|
|
137
|
+
seen: /* @__PURE__ */ new Set()
|
|
138
|
+
}, true).flatMap((one) => cataloguesIn(one.at, text(one.manifest, "name") ?? basename(one.at), one.at === root));
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/emit.ts
|
|
142
|
+
/**
|
|
143
|
+
* Generates the module an application imports its catalogues from, and one module per pair.
|
|
144
|
+
*/
|
|
145
|
+
/**
|
|
146
|
+
* The specifier an application imports to reach its catalogues.
|
|
147
|
+
*/
|
|
148
|
+
const ID = "virtual:i18n";
|
|
149
|
+
/**
|
|
150
|
+
* Builds the specifier one language's namespace is fetched under.
|
|
151
|
+
*
|
|
152
|
+
* @remarks
|
|
153
|
+
* Neither a language tag nor a namespace contains a slash, so `pairOfId` splits the result back
|
|
154
|
+
* without ambiguity.
|
|
155
|
+
* @param language - The BCP 47 tag.
|
|
156
|
+
* @param namespace - The namespace name.
|
|
157
|
+
* @returns A specifier such as `virtual:i18n/nl/menu`.
|
|
158
|
+
*/
|
|
159
|
+
function pairId(language, namespace) {
|
|
160
|
+
return `${ID}/${language}/${namespace}`;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Splits a pair module's specifier back into its language and namespace.
|
|
164
|
+
*
|
|
165
|
+
* @param id - A specifier, with or without the resolution prefix.
|
|
166
|
+
* @returns The pair, or undefined when the specifier addresses something else.
|
|
167
|
+
*/
|
|
168
|
+
function pairOfId(id) {
|
|
169
|
+
const [head, language, namespace, ...rest] = id.replace(/^\0/u, "").split("/");
|
|
170
|
+
return head === "virtual:i18n" && language !== void 0 && namespace !== void 0 && rest.length === 0 ? {
|
|
171
|
+
language,
|
|
172
|
+
namespace
|
|
173
|
+
} : void 0;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Indexes catalogues by language and namespace, preserving the order they were found in.
|
|
177
|
+
*
|
|
178
|
+
* @param catalogues - Every catalogue found.
|
|
179
|
+
*/
|
|
180
|
+
function indexed(catalogues) {
|
|
181
|
+
const byLanguage = /* @__PURE__ */ new Map();
|
|
182
|
+
for (const catalogue of catalogues) {
|
|
183
|
+
const byNamespace = byLanguage.get(catalogue.language) ?? /* @__PURE__ */ new Map();
|
|
184
|
+
const files = byNamespace.get(catalogue.namespace) ?? [];
|
|
185
|
+
files.push(catalogue);
|
|
186
|
+
byNamespace.set(catalogue.namespace, files);
|
|
187
|
+
byLanguage.set(catalogue.language, byNamespace);
|
|
188
|
+
}
|
|
189
|
+
return byLanguage;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Returns one language's catalogues indexed by namespace.
|
|
193
|
+
*
|
|
194
|
+
* @param index - The indexed catalogues.
|
|
195
|
+
* @param language - The BCP 47 tag.
|
|
196
|
+
* @returns The files per namespace, or an empty map when the language has no catalogue.
|
|
197
|
+
*/
|
|
198
|
+
function ofLanguage(index, language) {
|
|
199
|
+
return index.get(language) ?? /* @__PURE__ */ new Map();
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Returns every file that belongs to one pair.
|
|
203
|
+
*
|
|
204
|
+
* @param index - The indexed catalogues.
|
|
205
|
+
* @param language - The BCP 47 tag.
|
|
206
|
+
* @param namespace - The namespace name.
|
|
207
|
+
* @returns The files in merge order, or an empty array when no file belongs to the pair.
|
|
208
|
+
*/
|
|
209
|
+
function filesOf(index, language, namespace) {
|
|
210
|
+
return ofLanguage(index, language).get(namespace) ?? [];
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Returns true when a parsed value is an object whose every leaf is a string.
|
|
214
|
+
*
|
|
215
|
+
* @param value - The parsed file contents.
|
|
216
|
+
*/
|
|
217
|
+
function isWords(value) {
|
|
218
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((leaf) => typeof leaf === "string" || isWords(leaf));
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Parses one catalogue file, as JSON or as YAML according to its extension.
|
|
222
|
+
*
|
|
223
|
+
* @param catalogue - The file to read.
|
|
224
|
+
* @returns Its contents, nested as written.
|
|
225
|
+
* @throws {@link Error} When the file does not parse to an object of strings.
|
|
226
|
+
*/
|
|
227
|
+
function wordsOf(catalogue) {
|
|
228
|
+
const text = readFileSync(catalogue.file, "utf8");
|
|
229
|
+
const parsed = catalogue.file.endsWith(".json") ? JSON.parse(text) : parse(text);
|
|
230
|
+
if (!isWords(parsed)) throw new Error(`${catalogue.file} does not parse to an object of strings.`);
|
|
231
|
+
return parsed;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Parses one catalogue file and nests its contents under the file's prefix.
|
|
235
|
+
*
|
|
236
|
+
* @remarks
|
|
237
|
+
* A `title` key in `menu/sections/billing.json` comes back as `sections.billing.title`.
|
|
238
|
+
* @param catalogue - The file to read.
|
|
239
|
+
*/
|
|
240
|
+
function nested(catalogue) {
|
|
241
|
+
return catalogue.prefix.split(".").filter((segment) => segment !== "").reduceRight((inner, segment) => ({ [segment]: inner }), wordsOf(catalogue));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Merges two sets of contents key by key, so the later one overwrites only the keys it declares.
|
|
245
|
+
*
|
|
246
|
+
* @param under - The contents merged so far.
|
|
247
|
+
* @param over - The contents to apply on top.
|
|
248
|
+
*/
|
|
249
|
+
function merged(under, over) {
|
|
250
|
+
const result = { ...under };
|
|
251
|
+
for (const [key, value] of Object.entries(over)) {
|
|
252
|
+
const existing = result[key];
|
|
253
|
+
result[key] = typeof value === "object" && typeof existing === "object" ? merged(existing, value) : value;
|
|
254
|
+
}
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Parses and merges every file of one pair, each nested under its own prefix.
|
|
259
|
+
*
|
|
260
|
+
* @param files - The files in merge order, the last one winning each key.
|
|
261
|
+
*/
|
|
262
|
+
function mergedWords(files) {
|
|
263
|
+
return files.reduce((words, file) => merged(words, nested(file)), {});
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Generates the module one pair is fetched as, holding every file of the pair merged.
|
|
267
|
+
*
|
|
268
|
+
* @param files - The files in merge order.
|
|
269
|
+
*/
|
|
270
|
+
function pairModule(files) {
|
|
271
|
+
return `export default ${JSON.stringify(mergedWords(files))};\n`;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Generates the loader table: one dynamic import per pair, so a namespace costs one request.
|
|
275
|
+
*
|
|
276
|
+
* @param index - The indexed catalogues.
|
|
277
|
+
* @returns The table as JavaScript source.
|
|
278
|
+
*/
|
|
279
|
+
function loaders(index) {
|
|
280
|
+
return `{\n${[...index.entries()].map(([language, byNamespace]) => {
|
|
281
|
+
const imports = [...byNamespace.keys()].map((namespace) => ` ${JSON.stringify(namespace)}: () => import(${JSON.stringify(pairId(language, namespace))})`);
|
|
282
|
+
return ` ${JSON.stringify(language)}: {\n${imports.join(",\n")},\n }`;
|
|
283
|
+
}).join(",\n")},\n}`;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Merges each namespace of one language, for inlining into the module.
|
|
287
|
+
*
|
|
288
|
+
* @param index - The indexed catalogues.
|
|
289
|
+
* @param language - The BCP 47 tag.
|
|
290
|
+
*/
|
|
291
|
+
function inlined(index, language) {
|
|
292
|
+
return Object.fromEntries([...ofLanguage(index, language).entries()].map(([namespace, files]) => [namespace, mergedWords(files)]));
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Lists every namespace any language declares, sorted.
|
|
296
|
+
*
|
|
297
|
+
* @param index - The indexed catalogues.
|
|
298
|
+
*/
|
|
299
|
+
function namespacesIn(index) {
|
|
300
|
+
const names = /* @__PURE__ */ new Set();
|
|
301
|
+
for (const byNamespace of index.values()) for (const namespace of byNamespace.keys()) names.add(namespace);
|
|
302
|
+
return [...names].toSorted();
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Generates the module an application imports its catalogues from.
|
|
306
|
+
*
|
|
307
|
+
* @remarks
|
|
308
|
+
* The fallback language is inlined so the first paint needs no request. Every other language is
|
|
309
|
+
* reached through the loader. Under `eager` every language is inlined instead and the loader
|
|
310
|
+
* fetches nothing, which costs one larger bundle and saves a request per language.
|
|
311
|
+
* @param index - The indexed catalogues.
|
|
312
|
+
* @param fallback - The language that defines every key.
|
|
313
|
+
* @param eager - Inlines every language when true.
|
|
314
|
+
* @returns The module as JavaScript source.
|
|
315
|
+
*/
|
|
316
|
+
function cataloguesModule(index, fallback, eager = false) {
|
|
317
|
+
const languages = [...index.keys()].toSorted();
|
|
318
|
+
const bundled = Object.fromEntries((eager ? languages : [fallback]).map((language) => [language, inlined(index, language)]));
|
|
319
|
+
return [
|
|
320
|
+
`export const fallback = ${JSON.stringify(fallback)};`,
|
|
321
|
+
`export const languages = ${JSON.stringify(languages)};`,
|
|
322
|
+
`export const namespaces = ${JSON.stringify(namespacesIn(index))};`,
|
|
323
|
+
`export const bundled = ${JSON.stringify(bundled)};`,
|
|
324
|
+
`export const defaults = bundled[fallback] ?? {};`,
|
|
325
|
+
`const loaders = ${eager ? "{}" : loaders(index)};`,
|
|
326
|
+
"",
|
|
327
|
+
"export async function load(language, namespace) {",
|
|
328
|
+
" const loader = loaders[language]?.[namespace];",
|
|
329
|
+
" if (loader === undefined) return undefined;",
|
|
330
|
+
" return (await loader()).default;",
|
|
331
|
+
"}",
|
|
332
|
+
"",
|
|
333
|
+
"export const catalogues = { bundled, defaults, fallback, languages, load, namespaces };",
|
|
334
|
+
""
|
|
335
|
+
].join("\n");
|
|
336
|
+
}
|
|
337
|
+
//#endregion
|
|
338
|
+
//#region src/check.ts
|
|
339
|
+
/**
|
|
340
|
+
* Validates every catalogue against the fallback language.
|
|
341
|
+
*
|
|
342
|
+
* @remarks
|
|
343
|
+
* An undefined key, a dropped placeholder and a key one package declares twice are all invisible
|
|
344
|
+
* to a translator, so the build reports them instead.
|
|
345
|
+
*/
|
|
346
|
+
/**
|
|
347
|
+
* Matches an i18next placeholder, `{{name}}` or `{{name, format}}`.
|
|
348
|
+
*/
|
|
349
|
+
const PLACEHOLDER = /\{\{\s*([^,}\s]+)/gu;
|
|
350
|
+
/**
|
|
351
|
+
* Matches a plural suffix, which is one of the six categories CLDR defines.
|
|
352
|
+
*/
|
|
353
|
+
const PLURAL = /_(?:zero|one|two|few|many|other)$/u;
|
|
354
|
+
/**
|
|
355
|
+
* Flattens nested contents to the dotted keys i18next addresses them by.
|
|
356
|
+
*
|
|
357
|
+
* @param words - The contents, nested as the file stores them.
|
|
358
|
+
* @param prefix - The path down to these contents, empty at the top level.
|
|
359
|
+
*/
|
|
360
|
+
function flattened(words, prefix = "") {
|
|
361
|
+
const flat = /* @__PURE__ */ new Map();
|
|
362
|
+
for (const [key, value] of Object.entries(words)) {
|
|
363
|
+
const at = prefix === "" ? key : `${prefix}.${key}`;
|
|
364
|
+
if (typeof value === "string") flat.set(at, value);
|
|
365
|
+
else for (const [inner, word] of flattened(value, at)) flat.set(inner, word);
|
|
366
|
+
}
|
|
367
|
+
return flat;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Lists the placeholder names one string carries.
|
|
371
|
+
*
|
|
372
|
+
* @param word - One translated string.
|
|
373
|
+
* @returns Each name once, sorted.
|
|
374
|
+
*/
|
|
375
|
+
function placeholdersOf(word) {
|
|
376
|
+
return [...new Set([...word.matchAll(PLACEHOLDER)].flatMap((match) => match.slice(1, 2)))].toSorted();
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Strips a plural or context suffix, so `pages_one` can be checked against any `pages_*`.
|
|
380
|
+
*
|
|
381
|
+
* @param key - A dotted key, with or without a suffix.
|
|
382
|
+
*/
|
|
383
|
+
function stem(key) {
|
|
384
|
+
const at = key.lastIndexOf("_");
|
|
385
|
+
return at === -1 ? key : key.slice(0, at);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Lists the placeholders a translation has to carry.
|
|
389
|
+
*
|
|
390
|
+
* @remarks
|
|
391
|
+
* Every placeholder the fallback carries, except `count` in a plural form. Some languages spell
|
|
392
|
+
* the number out for one of their forms, so `één pagina` is valid against `{{count}} page`.
|
|
393
|
+
* @param key - The translated key.
|
|
394
|
+
* @param against - The fallback string for that key.
|
|
395
|
+
*/
|
|
396
|
+
function wanted(key, against) {
|
|
397
|
+
const names = placeholdersOf(against);
|
|
398
|
+
return PLURAL.test(key) ? names.filter((name) => name !== "count") : names;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Reports a placeholder a restated key drops.
|
|
402
|
+
*
|
|
403
|
+
* @param file - The file restating the key.
|
|
404
|
+
* @param key - The key being restated.
|
|
405
|
+
* @param word - The string this file gives it.
|
|
406
|
+
* @param against - The string the fallback gives it.
|
|
407
|
+
* @returns One problem, or an empty array when every placeholder survives.
|
|
408
|
+
*/
|
|
409
|
+
function placeholdersChecked(file, key, word, against) {
|
|
410
|
+
const carried = placeholdersOf(word);
|
|
411
|
+
const missing = wanted(key, against).filter((name) => !carried.includes(name));
|
|
412
|
+
return missing.length === 0 ? [] : [{
|
|
413
|
+
file: file.file,
|
|
414
|
+
key,
|
|
415
|
+
says: `leaves out the placeholder ${missing.map((name) => `{{${name}}}`).join(", ")}`
|
|
416
|
+
}];
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Validates one translation against the fallback's definition of its namespace.
|
|
420
|
+
*
|
|
421
|
+
* @param file - The translation to check.
|
|
422
|
+
* @param defined - The fallback's contents for that namespace, flattened.
|
|
423
|
+
*/
|
|
424
|
+
function checked(file, defined) {
|
|
425
|
+
const stems = new Set([...defined.keys()].map((key) => stem(key)));
|
|
426
|
+
const found = [];
|
|
427
|
+
for (const [key, word] of flattened(nested(file))) {
|
|
428
|
+
const against = defined.get(key) ?? defined.get(`${stem(key)}_other`);
|
|
429
|
+
if (against === void 0) {
|
|
430
|
+
if (!stems.has(stem(key))) found.push({
|
|
431
|
+
file: file.file,
|
|
432
|
+
key,
|
|
433
|
+
says: "names a key the fallback does not define"
|
|
434
|
+
});
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
found.push(...placeholdersChecked(file, key, word, against));
|
|
438
|
+
}
|
|
439
|
+
return found;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Builds the fallback's definition of one namespace, collecting faults as it merges.
|
|
443
|
+
*
|
|
444
|
+
* @remarks
|
|
445
|
+
* A package defines whatever keys it ships. Another package may override one, and the override is
|
|
446
|
+
* checked for placeholders. The application may override too, but may not add a key to a
|
|
447
|
+
* namespace a package owns, because a key nobody else defines is a typo. One owner declaring a
|
|
448
|
+
* key in two files is a fault wherever it happens, because the merge would silently pick one.
|
|
449
|
+
* @param files - The fallback files of the namespace, in merge order.
|
|
450
|
+
* @param found - The array faults are pushed onto.
|
|
451
|
+
* @returns The namespace's contents, flattened.
|
|
452
|
+
*/
|
|
453
|
+
function definition(files, found) {
|
|
454
|
+
const defined = /* @__PURE__ */ new Map();
|
|
455
|
+
const packaged = files.some((file) => !file.own);
|
|
456
|
+
for (const file of files) for (const [key, word] of flattened(nested(file))) {
|
|
457
|
+
const earlier = defined.get(key);
|
|
458
|
+
if (earlier === void 0) {
|
|
459
|
+
if (file.own && packaged && ![...defined.keys()].some((by) => stem(by) === stem(key))) {
|
|
460
|
+
found.push({
|
|
461
|
+
file: file.file,
|
|
462
|
+
key,
|
|
463
|
+
says: "names a key the fallback does not define"
|
|
464
|
+
});
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
} else if (earlier.file.owner === file.owner) found.push({
|
|
468
|
+
file: file.file,
|
|
469
|
+
key,
|
|
470
|
+
says: `is also declared in ${earlier.file.file}`
|
|
471
|
+
});
|
|
472
|
+
else found.push(...placeholdersChecked(file, key, word, earlier.word));
|
|
473
|
+
defined.set(key, {
|
|
474
|
+
file,
|
|
475
|
+
word
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return new Map([...defined].map(([key, { word }]) => [key, word]));
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Validates every catalogue: the fallback's files against each other, then each translation against
|
|
482
|
+
* the fallback.
|
|
483
|
+
*
|
|
484
|
+
* @param index - The indexed catalogues.
|
|
485
|
+
* @param fallback - The language that defines every key.
|
|
486
|
+
* @returns Every fault, the fallback's first, then in file order.
|
|
487
|
+
*/
|
|
488
|
+
function problems(index, fallback) {
|
|
489
|
+
const defining = index.get(fallback);
|
|
490
|
+
if (defining === void 0) return [];
|
|
491
|
+
const found = [];
|
|
492
|
+
const definitions = new Map([...defining].map(([namespace, files]) => [namespace, definition(files, found)]));
|
|
493
|
+
for (const [language, byNamespace] of index) {
|
|
494
|
+
if (language === fallback) continue;
|
|
495
|
+
for (const [namespace, files] of byNamespace) {
|
|
496
|
+
const words = definitions.get(namespace);
|
|
497
|
+
if (words === void 0) {
|
|
498
|
+
found.push(...files.map((file) => ({
|
|
499
|
+
file: file.file,
|
|
500
|
+
key: "",
|
|
501
|
+
says: `names a namespace the ${fallback} catalogues do not define`
|
|
502
|
+
})));
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
for (const file of files) found.push(...checked(file, words));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return found;
|
|
509
|
+
}
|
|
510
|
+
//#endregion
|
|
511
|
+
//#region src/typegen.ts
|
|
512
|
+
/**
|
|
513
|
+
* Generates the declaration file that types every namespace's keys.
|
|
514
|
+
*
|
|
515
|
+
* @remarks
|
|
516
|
+
* Each string becomes a string literal type, so an unknown key and a dropped placeholder are both
|
|
517
|
+
* editor errors.
|
|
518
|
+
*/
|
|
519
|
+
/**
|
|
520
|
+
* The package whose `Resources` interface the types are added to.
|
|
521
|
+
*/
|
|
522
|
+
const FOUNDATION = "@stealthscale/provider-i18n";
|
|
523
|
+
/**
|
|
524
|
+
* The header the generated file opens with, telling a reader not to edit it.
|
|
525
|
+
*/
|
|
526
|
+
const HEADER = [
|
|
527
|
+
"// Generated by @stealthscale/vite-plugin-i18n from the catalogues found. Do not edit: change",
|
|
528
|
+
"// the words in locales/<language>/<namespace>.json and this file follows.",
|
|
529
|
+
"",
|
|
530
|
+
"export {};",
|
|
531
|
+
""
|
|
532
|
+
].join("\n");
|
|
533
|
+
/**
|
|
534
|
+
* Renders one namespace's contents as an object type, nested as the contents are.
|
|
535
|
+
*
|
|
536
|
+
* @param words - The contents, nested as the catalogue stores them.
|
|
537
|
+
* @param depth - The current nesting level, which sets the indentation.
|
|
538
|
+
* @returns The object type as TypeScript source.
|
|
539
|
+
*/
|
|
540
|
+
function typed(words, depth) {
|
|
541
|
+
const pad = " ".repeat(depth);
|
|
542
|
+
return `{\n${Object.entries(words).toSorted(([one], [other]) => one.localeCompare(other)).map(([key, value]) => `${pad} ${JSON.stringify(key)}: ${typeof value === "string" ? JSON.stringify(value) : typed(value, depth + 1)};`).join("\n")}\n${pad}}`;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Renders the augmentation that adds every namespace to the foundation's `Resources`.
|
|
546
|
+
*
|
|
547
|
+
* @param index - The indexed catalogues.
|
|
548
|
+
* @param fallback - The language whose strings type the keys.
|
|
549
|
+
* @returns The declaration file as TypeScript source.
|
|
550
|
+
*/
|
|
551
|
+
function declared(index, fallback) {
|
|
552
|
+
const members = [...ofLanguage(index, fallback).entries()].toSorted(([one], [other]) => one.localeCompare(other)).map(([namespace, files]) => ` ${JSON.stringify(namespace)}: ${typed(mergedWords(files), 2)};`);
|
|
553
|
+
return `${HEADER}declare module ${JSON.stringify(FOUNDATION)} {\n interface Resources {\n${members.join("\n")}\n }\n}\n`;
|
|
554
|
+
}
|
|
555
|
+
//#endregion
|
|
556
|
+
//#region src/plugin.ts
|
|
557
|
+
/**
|
|
558
|
+
* The plugin itself: it finds the catalogues, serves the module an application loads them from,
|
|
559
|
+
* writes their types, and pushes a changed string to a running page.
|
|
560
|
+
*/
|
|
561
|
+
/**
|
|
562
|
+
* The resolved identifier, whose leading NUL marks the module as this plugin's.
|
|
563
|
+
*/
|
|
564
|
+
const RESOLVED = `\0${ID}`;
|
|
565
|
+
/**
|
|
566
|
+
* The custom event name a changed catalogue is sent to the page under.
|
|
567
|
+
*
|
|
568
|
+
* @remarks
|
|
569
|
+
* The foundation listens for the same string. Neither package imports the other, so the name is a
|
|
570
|
+
* contract between them rather than a shared constant.
|
|
571
|
+
*/
|
|
572
|
+
const EVENT = "i18n:catalogue";
|
|
573
|
+
/**
|
|
574
|
+
* The default language that defines every key.
|
|
575
|
+
*/
|
|
576
|
+
const FALLBACK = "en";
|
|
577
|
+
/**
|
|
578
|
+
* The default path the generated types are written to, against the project root.
|
|
579
|
+
*/
|
|
580
|
+
const TYPES = "src/i18n.gen.d.ts";
|
|
581
|
+
/**
|
|
582
|
+
* Searches for the catalogues again and indexes them, keeping the namespaces the options accept.
|
|
583
|
+
*
|
|
584
|
+
* @param state - The state to write the result into.
|
|
585
|
+
* @param options - Where to search and which namespaces to keep.
|
|
586
|
+
*/
|
|
587
|
+
function refound(state, options) {
|
|
588
|
+
const wanted = options.namespaces ?? (() => true);
|
|
589
|
+
state.catalogues = found(state.resolved.root, options.scopes).filter((one) => wanted(one.namespace));
|
|
590
|
+
state.index = indexed(state.catalogues);
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Writes the generated types, unless the options turn them off.
|
|
594
|
+
*
|
|
595
|
+
* @param state - The catalogues found.
|
|
596
|
+
* @param options - Where to write and which language types the keys.
|
|
597
|
+
* @returns True when the file changed.
|
|
598
|
+
*/
|
|
599
|
+
function retyped(state, options) {
|
|
600
|
+
if (options.types === false) return false;
|
|
601
|
+
return writeIfChanged(resolve(state.resolved.root, options.types ?? TYPES), declared(state.index, options.fallback ?? FALLBACK));
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Formats each fault in the catalogues as one line.
|
|
605
|
+
*
|
|
606
|
+
* @param state - The catalogues found.
|
|
607
|
+
* @param options - Which language defines the keys.
|
|
608
|
+
*/
|
|
609
|
+
function wrong(state, options) {
|
|
610
|
+
return problems(state.index, options.fallback ?? FALLBACK).map(({ file: at, key, says }) => `${at}${key === "" ? "" : `: ${key}`} ${says}`);
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Reads the pair a catalogue file belongs to out of its path.
|
|
614
|
+
*
|
|
615
|
+
* @param path - A normalised path under a `locales` directory.
|
|
616
|
+
* @returns The pair, or undefined when no language directory sits under `locales`.
|
|
617
|
+
*/
|
|
618
|
+
function pairOf(path) {
|
|
619
|
+
const rest = path.slice(path.lastIndexOf(`/${LOCALES}/`) + 7 + 2);
|
|
620
|
+
const at = rest.indexOf("/");
|
|
621
|
+
if (at === -1) return void 0;
|
|
622
|
+
return {
|
|
623
|
+
language: rest.slice(0, at),
|
|
624
|
+
namespace: namespaceOf(rest.slice(at + 1))
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Returns true when a path is a catalogue file.
|
|
629
|
+
*
|
|
630
|
+
* @param path - A normalised path.
|
|
631
|
+
*/
|
|
632
|
+
function catalogued(path) {
|
|
633
|
+
return EXTENSION.test(path) && path.includes(`/locales/`);
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Invalidates a module when the graph holds it, and does nothing when nothing imported it.
|
|
637
|
+
*
|
|
638
|
+
* @param graph - The module graph.
|
|
639
|
+
* @param id - The module's resolved identifier.
|
|
640
|
+
*/
|
|
641
|
+
function stale(graph, id) {
|
|
642
|
+
const node = graph.getModuleById(id);
|
|
643
|
+
if (node !== void 0) graph.invalidateModule?.(node);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Pushes a changed pair to a running page and invalidates the modules holding the old strings.
|
|
647
|
+
*
|
|
648
|
+
* @remarks
|
|
649
|
+
* The catalogues module is invalidated as well, because the fallback language is inlined in it,
|
|
650
|
+
* so a page loaded after the change gets the current strings.
|
|
651
|
+
* @param state - The catalogues found.
|
|
652
|
+
* @param options - Which language defines the keys, for the validation that follows.
|
|
653
|
+
* @param pair - The language and namespace that changed.
|
|
654
|
+
* @param watching - The environment the file changed in.
|
|
655
|
+
*/
|
|
656
|
+
function resent(state, options, pair, watching) {
|
|
657
|
+
stale(watching.environment.moduleGraph, `\0${pairId(pair.language, pair.namespace)}`);
|
|
658
|
+
stale(watching.environment.moduleGraph, RESOLVED);
|
|
659
|
+
watching.environment.hot.send(EVENT, {
|
|
660
|
+
language: pair.language,
|
|
661
|
+
namespace: pair.namespace,
|
|
662
|
+
words: mergedWords(filesOf(state.index, pair.language, pair.namespace))
|
|
663
|
+
});
|
|
664
|
+
for (const line of wrong(state, options)) globalThis.console.warn(line);
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Handles a catalogue file that was added or deleted.
|
|
668
|
+
*
|
|
669
|
+
* @remarks
|
|
670
|
+
* The search and the types run again, because the set of languages may have changed. The module
|
|
671
|
+
* is invalidated rather than sent, because sending it would refresh the component tree when only
|
|
672
|
+
* the strings changed.
|
|
673
|
+
* @param state - The catalogues found.
|
|
674
|
+
* @param options - Where to search.
|
|
675
|
+
* @param path - The file that appeared or disappeared.
|
|
676
|
+
* @param watching - The environment the file changed in.
|
|
677
|
+
*/
|
|
678
|
+
function refollowed(state, options, path, watching) {
|
|
679
|
+
refound(state, options);
|
|
680
|
+
retyped(state, options);
|
|
681
|
+
const pair = pairOf(path);
|
|
682
|
+
if (pair === void 0) stale(watching.environment.moduleGraph, RESOLVED);
|
|
683
|
+
else resent(state, options, pair, watching);
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Builds the plugin that finds the catalogues, types their keys, and serves `virtual:i18n`.
|
|
687
|
+
*
|
|
688
|
+
* @remarks
|
|
689
|
+
* On a dev server a catalogue change is pushed to the page as an event rather than a reload, so
|
|
690
|
+
* the page keeps its state. In a build an invalid catalogue throws instead.
|
|
691
|
+
* @param options - Where to search and what to write. `Options` documents every member.
|
|
692
|
+
* @returns The plugin.
|
|
693
|
+
*/
|
|
694
|
+
function i18n(options = {}) {
|
|
695
|
+
const state = {
|
|
696
|
+
catalogues: [],
|
|
697
|
+
index: /* @__PURE__ */ new Map(),
|
|
698
|
+
resolved: {
|
|
699
|
+
command: "serve",
|
|
700
|
+
root: process.cwd()
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
return {
|
|
704
|
+
/**
|
|
705
|
+
* Throws on an invalid catalogue during a build, and warns during a dev server run.
|
|
706
|
+
*
|
|
707
|
+
* @throws {@link Error} When a catalogue defines an unknown key or drops a placeholder.
|
|
708
|
+
*/
|
|
709
|
+
buildStart() {
|
|
710
|
+
const lines = wrong(state, options);
|
|
711
|
+
if (lines.length === 0) return;
|
|
712
|
+
if (state.resolved.command === "build") throw new Error(lines.join("\n"));
|
|
713
|
+
for (const line of lines) this.warn(line);
|
|
714
|
+
},
|
|
715
|
+
/**
|
|
716
|
+
* Records the root, runs the search, and writes the types.
|
|
717
|
+
*
|
|
718
|
+
* @param config - The resolved configuration.
|
|
719
|
+
*/
|
|
720
|
+
configResolved(config) {
|
|
721
|
+
state.resolved = config;
|
|
722
|
+
refound(state, options);
|
|
723
|
+
retyped(state, options);
|
|
724
|
+
},
|
|
725
|
+
/**
|
|
726
|
+
* Adds every `locales` directory to the watcher, including those outside the project root.
|
|
727
|
+
*
|
|
728
|
+
* @param server - The dev server.
|
|
729
|
+
*/
|
|
730
|
+
configureServer(server) {
|
|
731
|
+
server.watcher.add([...new Set(state.catalogues.map((one) => dirname(dirname(one.file))))]);
|
|
732
|
+
},
|
|
733
|
+
/**
|
|
734
|
+
* Handles a catalogue change on a dev server by pushing the new strings to the page.
|
|
735
|
+
*
|
|
736
|
+
* @param changed - The file, what happened to it, and the modules the change reached.
|
|
737
|
+
* @returns An empty array for a catalogue, or undefined for any other file.
|
|
738
|
+
*/
|
|
739
|
+
hotUpdate(changed) {
|
|
740
|
+
const path = normalizePath(changed.file);
|
|
741
|
+
if (!catalogued(path)) return void 0;
|
|
742
|
+
if (changed.type !== "update") {
|
|
743
|
+
refollowed(state, options, path, this);
|
|
744
|
+
return [];
|
|
745
|
+
}
|
|
746
|
+
const catalogue = state.catalogues.find((one) => one.file === path);
|
|
747
|
+
if (catalogue === void 0) return void 0;
|
|
748
|
+
resent(state, options, catalogue, this);
|
|
749
|
+
return [];
|
|
750
|
+
},
|
|
751
|
+
/**
|
|
752
|
+
* Serves the catalogues module, or the module one pair is fetched as.
|
|
753
|
+
*
|
|
754
|
+
* @param id - The module being loaded.
|
|
755
|
+
* @returns The source, or undefined when the module is not this plugin's.
|
|
756
|
+
*/
|
|
757
|
+
load(id) {
|
|
758
|
+
if (id === RESOLVED) return cataloguesModule(state.index, options.fallback ?? FALLBACK, options.eager ?? false);
|
|
759
|
+
const pair = pairOfId(id);
|
|
760
|
+
return pair === void 0 ? void 0 : pairModule(filesOf(state.index, pair.language, pair.namespace));
|
|
761
|
+
},
|
|
762
|
+
name: "stealth:i18n",
|
|
763
|
+
/**
|
|
764
|
+
* Claims the catalogues specifier and every pair specifier.
|
|
765
|
+
*
|
|
766
|
+
* @param id - The specifier being resolved.
|
|
767
|
+
* @returns The resolved identifier, or undefined for any other import.
|
|
768
|
+
*/
|
|
769
|
+
resolveId(id) {
|
|
770
|
+
return id === "virtual:i18n" || pairOfId(id) !== void 0 ? `\0${id}` : void 0;
|
|
771
|
+
},
|
|
772
|
+
/**
|
|
773
|
+
* Runs the search and the types again during a watching build.
|
|
774
|
+
*
|
|
775
|
+
* @remarks
|
|
776
|
+
* A dev server reports the same change to `hotUpdate`, which pushes the strings to the page,
|
|
777
|
+
* so this hook ignores a change while serving. A watching build has no page and rebuilds.
|
|
778
|
+
* @param id - The file that changed.
|
|
779
|
+
*/
|
|
780
|
+
watchChange(id) {
|
|
781
|
+
if (state.resolved.command !== "build" || !catalogued(normalizePath(id))) return;
|
|
782
|
+
refound(state, options);
|
|
783
|
+
retyped(state, options);
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
//#endregion
|
|
788
|
+
export { EVENT, FOUNDATION, ID, LOCALES, cataloguesModule, declared, found, i18n, indexed, pairId, pairOfId, problems };
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stealthscale/vite-plugin-i18n",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Finds every catalogue an application can reach, types their keys, and answers the module it loads them from.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"i18n",
|
|
7
|
+
"vite-plugin"
|
|
8
|
+
],
|
|
9
|
+
"homepage": "https://github.com/stealth-scale/config/tree/main/packages/vite-plugin-i18n#readme",
|
|
10
|
+
"bugs": "https://github.com/stealth-scale/config/issues",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/stealth-scale/config.git",
|
|
15
|
+
"directory": "packages/vite-plugin-i18n"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"client.d.ts",
|
|
19
|
+
"dist",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"imports": {
|
|
26
|
+
"#*": "./src/*"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./dist/index.mjs",
|
|
30
|
+
"./package.json": "./package.json",
|
|
31
|
+
"./client": "./client.d.ts"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"yaml": "^2.9.1"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@arethetypeswrong/core": "^0.18.5",
|
|
41
|
+
"@stealthscale/testing": "^0.3.0",
|
|
42
|
+
"@stealthscale/testing-config": "^0.3.0",
|
|
43
|
+
"@stealthscale/vite-config": "^0.6.0",
|
|
44
|
+
"@stealthscale/vite-config-typescript": "^0.3.0",
|
|
45
|
+
"@stealthscale/vite-plugin-base": "^0.2.0",
|
|
46
|
+
"@types/node": "^26.5.1",
|
|
47
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
48
|
+
"publint": "^0.3.24",
|
|
49
|
+
"typescript": "^7.0.2",
|
|
50
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.1",
|
|
51
|
+
"vitest": "4.1.11"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@stealthscale/vite-plugin-base": "^0.2.0",
|
|
55
|
+
"vite": "^8.0.0"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=26.0.0"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "vp pack",
|
|
62
|
+
"check": "vp check",
|
|
63
|
+
"test": "vp test"
|
|
64
|
+
}
|
|
65
|
+
}
|