@worthy-ventures/metaglotta-runtime 1.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.
Files changed (57) hide show
  1. package/README.md +44 -0
  2. package/dist/backend.d.ts +19 -0
  3. package/dist/backend.js +77 -0
  4. package/dist/backend.js.map +1 -0
  5. package/dist/cache.d.ts +36 -0
  6. package/dist/cache.js +77 -0
  7. package/dist/cache.js.map +1 -0
  8. package/dist/events.d.ts +6 -0
  9. package/dist/events.js +74 -0
  10. package/dist/events.js.map +1 -0
  11. package/dist/format.d.ts +9 -0
  12. package/dist/format.js +60 -0
  13. package/dist/format.js.map +1 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.js +19 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/instance.d.ts +38 -0
  18. package/dist/instance.js +223 -0
  19. package/dist/instance.js.map +1 -0
  20. package/dist/props.d.ts +5 -0
  21. package/dist/props.js +60 -0
  22. package/dist/props.js.map +1 -0
  23. package/dist/types.d.ts +168 -0
  24. package/dist/types.js +9 -0
  25. package/dist/types.js.map +1 -0
  26. package/dist-esm/backend.js +72 -0
  27. package/dist-esm/backend.js.map +1 -0
  28. package/dist-esm/cache.js +72 -0
  29. package/dist-esm/cache.js.map +1 -0
  30. package/dist-esm/events.js +71 -0
  31. package/dist-esm/events.js.map +1 -0
  32. package/dist-esm/format.js +55 -0
  33. package/dist-esm/format.js.map +1 -0
  34. package/dist-esm/index.js +6 -0
  35. package/dist-esm/index.js.map +1 -0
  36. package/dist-esm/instance.js +219 -0
  37. package/dist-esm/instance.js.map +1 -0
  38. package/dist-esm/package.json +1 -0
  39. package/dist-esm/props.js +55 -0
  40. package/dist-esm/props.js.map +1 -0
  41. package/dist-esm/types.js +8 -0
  42. package/dist-esm/types.js.map +1 -0
  43. package/package.json +37 -0
  44. package/src/backend.test.ts +117 -0
  45. package/src/backend.ts +81 -0
  46. package/src/cache.test.ts +99 -0
  47. package/src/cache.ts +80 -0
  48. package/src/events.test.ts +118 -0
  49. package/src/events.ts +77 -0
  50. package/src/format.test.ts +92 -0
  51. package/src/format.ts +59 -0
  52. package/src/index.ts +25 -0
  53. package/src/instance.test.ts +645 -0
  54. package/src/instance.ts +257 -0
  55. package/src/props.test.ts +79 -0
  56. package/src/props.ts +62 -0
  57. package/src/types.ts +165 -0
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @worthy-ventures/metaglotta-runtime
2
+
3
+ The Metaglotta translation runtime. No framework, no DOM, no plugins.
4
+
5
+ ```ts
6
+ import { Metaglotta } from '@worthy-ventures/metaglotta-runtime';
7
+
8
+ const i18n = Metaglotta({
9
+ language: 'el',
10
+ fallbackLanguage: 'en',
11
+ defaultNs: 'common',
12
+ ns: ['common', 'login'],
13
+ fallbackNs: ['common'],
14
+ backend: { prefix: 'https://cdn.example/langs/lis' },
15
+ });
16
+
17
+ await i18n.run();
18
+ i18n.t('greeting', { name: 'Ada' });
19
+ ```
20
+
21
+ ## What it does
22
+
23
+ Holds translations per language and namespace, fetches the ones it needs as
24
+ `{prefix}/{namespace}/{language}.json`, falls back through namespaces and then languages when
25
+ a key is missing, formats ICU, and emits an event when any of that changes.
26
+
27
+ ## What it does not do
28
+
29
+ In-context editing. That needs every rendered string traced back to its key, which means
30
+ wrapping each one and watching the DOM - a different job, wanted only while authoring. The
31
+ authoring build runs on `@worthy-ventures/metaglotta-ngx/authoring` instead.
32
+
33
+ ## Behaviours worth knowing
34
+
35
+ - **A miss echoes the key.** `t('nope')` returns `'nope'`, so a missing string is visible on
36
+ the page. Pass `orEmpty: true` for `''` instead - which is what the Angular pipe does.
37
+ - **Namespace before language.** The lookup exhausts each namespace across every fallback
38
+ language before trying the next namespace. A key present in both `login/en` and `common/el`
39
+ resolves to `login/en` when you asked for `login`.
40
+ - **A missing ICU parameter renders `'invalid'`.** Not the raw string, and not an empty one.
41
+ - **A file that fails to load costs its namespace, not the load.** The record is remembered as
42
+ empty so it is not re-requested by every component on the page; the failure is reported on
43
+ the `error` event and to the console. Pass `backend.fallbackOnFail: false` to fail the whole
44
+ load instead.
@@ -0,0 +1,19 @@
1
+ import type { BackendOptions, RecordDescriptor, TranslationsInput } from './types.js';
2
+ /**
3
+ * Carries which record failed, because the url alone does not say it.
4
+ *
5
+ * The caller decides whether one unreadable file costs a namespace or the whole load, and it
6
+ * cannot decide that without knowing which namespace it was.
7
+ */
8
+ export declare class RecordLoadError extends Error {
9
+ readonly descriptor: RecordDescriptor;
10
+ readonly url: string;
11
+ readonly cause: unknown;
12
+ constructor(descriptor: RecordDescriptor, url: string, cause: unknown);
13
+ }
14
+ export type Backend = {
15
+ load: (descriptor: RecordDescriptor) => Promise<TranslationsInput | undefined>;
16
+ /** Whether a failed record is survivable. Read by the caller, not here. */
17
+ fallbackOnFail: boolean;
18
+ };
19
+ export declare function createBackend(options: BackendOptions): Backend;
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RecordLoadError = void 0;
4
+ exports.createBackend = createBackend;
5
+ /**
6
+ * Loading one `{namespace}/{language}.json` from wherever the translations are published.
7
+ *
8
+ * Deliberately not a plugin. There is exactly one backend in these applications - a
9
+ * CloudFront prefix - and a plugin system for a single implementation is machinery with
10
+ * nothing to choose between.
11
+ */
12
+ const defaultGetPath = ({ namespace, language, prefix }) => {
13
+ const base = prefix.replace(/\/+$/, '');
14
+ return namespace ? `${base}/${namespace}/${language}.json` : `${base}/${language}.json`;
15
+ };
16
+ /**
17
+ * Carries which record failed, because the url alone does not say it.
18
+ *
19
+ * The caller decides whether one unreadable file costs a namespace or the whole load, and it
20
+ * cannot decide that without knowing which namespace it was.
21
+ */
22
+ class RecordLoadError extends Error {
23
+ constructor(descriptor, url, cause) {
24
+ super(`Metaglotta: could not load ${descriptor.namespace || '(default)'}/${descriptor.language} from ${url}`);
25
+ this.descriptor = descriptor;
26
+ this.url = url;
27
+ this.cause = cause;
28
+ this.name = 'RecordLoadError';
29
+ }
30
+ }
31
+ exports.RecordLoadError = RecordLoadError;
32
+ function createBackend(options) {
33
+ const prefix = options.prefix ?? '/i18n';
34
+ const getPath = options.getPath ?? defaultGetPath;
35
+ const getData = options.getData ?? ((response) => response.json());
36
+ const headers = { Accept: 'application/json', ...options.headers };
37
+ const fetchFn = options.fetch ?? ((input, init) => fetch(input, init));
38
+ return {
39
+ fallbackOnFail: options.fallbackOnFail !== false,
40
+ async load(descriptor) {
41
+ const url = getPath({ ...descriptor, prefix });
42
+ try {
43
+ const response = await withTimeout(() => fetchFn(url, { headers }), options.timeout, url);
44
+ if (!response.ok)
45
+ throw new Error(`responded ${response.status}`);
46
+ return (await getData(response, descriptor));
47
+ }
48
+ catch (error) {
49
+ throw new RecordLoadError(descriptor, url, error);
50
+ }
51
+ },
52
+ };
53
+ }
54
+ /**
55
+ * A fetch that gives up.
56
+ *
57
+ * Without this a hung connection holds the initial load open for as long as the browser's own
58
+ * timeout, which is minutes - and an application that awaits run() waits with it.
59
+ */
60
+ async function withTimeout(run, ms, url) {
61
+ if (ms === undefined)
62
+ return run();
63
+ let timer;
64
+ try {
65
+ return await Promise.race([
66
+ run(),
67
+ new Promise((_, reject) => {
68
+ timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms: ${url}`)), ms);
69
+ }),
70
+ ]);
71
+ }
72
+ finally {
73
+ if (timer)
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+ //# sourceMappingURL=backend.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backend.js","sourceRoot":"","sources":["../src/backend.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ import type { RecordDescriptor, TranslationsInput, TranslationsRecord } from './types.js';
2
+ /**
3
+ * Translations, keyed by language and namespace.
4
+ *
5
+ * Flat on the way in. A file may nest - `{ "menu": { "save": "Save" } }` - and callers ask
6
+ * for `menu.save`, so the nesting is resolved once at load rather than walked on every
7
+ * lookup. Empty values are dropped rather than stored: a null in the file means the string
8
+ * has no translation, and storing it would stop the language fallback from ever running.
9
+ */
10
+ export declare function flatten(data: TranslationsInput, prefix?: string): TranslationsRecord;
11
+ /** `en` for the default namespace, `en:login` otherwise - the shape staticData is keyed by. */
12
+ export declare function recordKey({ language, namespace }: RecordDescriptor): string;
13
+ export declare function createCache(): {
14
+ has(descriptor: RecordDescriptor): boolean;
15
+ set(descriptor: RecordDescriptor, data: TranslationsInput): void;
16
+ /**
17
+ * The first stored value, trying every namespace against every language.
18
+ *
19
+ * Namespace is the OUTER loop, and that is not arbitrary: a key present in both the
20
+ * asked-for namespace's fallback language and a fallback namespace's primary
21
+ * language resolves to the former. Reversing the loops changes which translation the
22
+ * page shows, silently, and only for keys that exist in two namespaces.
23
+ *
24
+ * WHERE it was found comes back with it. A template that names no namespace still
25
+ * resolves to a real one, and anything that wants to edit the string afterwards needs
26
+ * that name rather than the nothing the caller asked with.
27
+ */
28
+ find(namespaces: string[], languages: string[], key: string): {
29
+ value: string;
30
+ namespace: string;
31
+ } | undefined;
32
+ /** In-memory only, for a page whose text was just edited elsewhere. */
33
+ write(descriptor: RecordDescriptor, key: string, value: string): boolean;
34
+ clear(): void;
35
+ };
36
+ export type Cache = ReturnType<typeof createCache>;
package/dist/cache.js ADDED
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.flatten = flatten;
4
+ exports.recordKey = recordKey;
5
+ exports.createCache = createCache;
6
+ /**
7
+ * Translations, keyed by language and namespace.
8
+ *
9
+ * Flat on the way in. A file may nest - `{ "menu": { "save": "Save" } }` - and callers ask
10
+ * for `menu.save`, so the nesting is resolved once at load rather than walked on every
11
+ * lookup. Empty values are dropped rather than stored: a null in the file means the string
12
+ * has no translation, and storing it would stop the language fallback from ever running.
13
+ */
14
+ function flatten(data, prefix = '') {
15
+ const flat = {};
16
+ for (const [key, value] of Object.entries(data)) {
17
+ if (value === undefined || value === null)
18
+ continue;
19
+ const path = prefix ? `${prefix}.${key}` : key;
20
+ if (typeof value === 'object') {
21
+ Object.assign(flat, flatten(value, path));
22
+ }
23
+ else {
24
+ flat[path] = String(value);
25
+ }
26
+ }
27
+ return flat;
28
+ }
29
+ /** `en` for the default namespace, `en:login` otherwise - the shape staticData is keyed by. */
30
+ function recordKey({ language, namespace }) {
31
+ return namespace ? `${language}:${namespace}` : language;
32
+ }
33
+ function createCache() {
34
+ const records = new Map();
35
+ return {
36
+ has(descriptor) {
37
+ return records.has(recordKey(descriptor));
38
+ },
39
+ set(descriptor, data) {
40
+ records.set(recordKey(descriptor), flatten(data));
41
+ },
42
+ /**
43
+ * The first stored value, trying every namespace against every language.
44
+ *
45
+ * Namespace is the OUTER loop, and that is not arbitrary: a key present in both the
46
+ * asked-for namespace's fallback language and a fallback namespace's primary
47
+ * language resolves to the former. Reversing the loops changes which translation the
48
+ * page shows, silently, and only for keys that exist in two namespaces.
49
+ *
50
+ * WHERE it was found comes back with it. A template that names no namespace still
51
+ * resolves to a real one, and anything that wants to edit the string afterwards needs
52
+ * that name rather than the nothing the caller asked with.
53
+ */
54
+ find(namespaces, languages, key) {
55
+ for (const namespace of namespaces) {
56
+ for (const language of languages) {
57
+ const value = records.get(recordKey({ language, namespace }))?.[key];
58
+ if (value !== undefined)
59
+ return { value, namespace };
60
+ }
61
+ }
62
+ return undefined;
63
+ },
64
+ /** In-memory only, for a page whose text was just edited elsewhere. */
65
+ write(descriptor, key, value) {
66
+ const record = records.get(recordKey(descriptor));
67
+ if (!record)
68
+ return false;
69
+ record[key] = value;
70
+ return true;
71
+ },
72
+ clear() {
73
+ records.clear();
74
+ },
75
+ };
76
+ }
77
+ //# sourceMappingURL=cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.js","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":";;AAUA,0BAcC;AAGD,8BAEC;AAED,kCA8CC;AA3ED;;;;;;;GAOG;AACH,SAAgB,OAAO,CAAC,IAAuB,EAAE,MAAM,GAAG,EAAE;IACxD,MAAM,IAAI,GAAuB,EAAE,CAAC;IAEpC,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAC9C,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;YAAE,SAAS;QACpD,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI,GAAG,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC;QAC/C,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YAC5B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC;QAC9C,CAAC;aAAM,CAAC;YACJ,IAAI,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;QAC/B,CAAC;IACL,CAAC;IAED,OAAO,IAAI,CAAC;AAChB,CAAC;AAED,+FAA+F;AAC/F,SAAgB,SAAS,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAoB;IAC/D,OAAO,SAAS,CAAC,CAAC,CAAC,GAAG,QAAQ,IAAI,SAAS,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC;AAC7D,CAAC;AAED,SAAgB,WAAW;IACvB,MAAM,OAAO,GAAG,IAAI,GAAG,EAA8B,CAAC;IAEtD,OAAO;QACH,GAAG,CAAC,UAA4B;YAC5B,OAAO,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC,CAAC;QAC9C,CAAC;QAED,GAAG,CAAC,UAA4B,EAAE,IAAuB;YACrD,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;QACtD,CAAC;QAED;;;;;;;;;;;WAWG;QACH,IAAI,CAAC,UAAoB,EAAE,SAAmB,EAAE,GAAW;YACvD,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;gBACjC,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;oBAC/B,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;oBACrE,IAAI,KAAK,KAAK,SAAS;wBAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;gBACzD,CAAC;YACL,CAAC;YACD,OAAO,SAAS,CAAC;QACrB,CAAC;QAED,uEAAuE;QACvE,KAAK,CAAC,UAA4B,EAAE,GAAW,EAAE,KAAa;YAC1D,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC,CAAC;YAClD,IAAI,CAAC,MAAM;gBAAE,OAAO,KAAK,CAAC;YAC1B,MAAM,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;YACpB,OAAO,IAAI,CAAC;QAChB,CAAC;QAED,KAAK;YACD,OAAO,CAAC,KAAK,EAAE,CAAC;QACpB,CAAC;KACJ,CAAC;AACN,CAAC"}
@@ -0,0 +1,6 @@
1
+ import type { EventName, Subscription } from './types.js';
2
+ export declare function createEvents(): {
3
+ on(name: EventName | "update", handler: (event: never) => void): Subscription;
4
+ emit(name: EventName, value: unknown): void;
5
+ };
6
+ export type Events = ReturnType<typeof createEvents>;
package/dist/events.js ADDED
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createEvents = createEvents;
4
+ /**
5
+ * Which events mean "what is on screen may now be wrong".
6
+ *
7
+ * Cache writes are batched. A load writes one record per (language, namespace), and emitting
8
+ * an update for each would re-translate the page six times for one load, so they are queued
9
+ * and flushed on the next tick. A language change is not batched: it is a single event and
10
+ * the page should not wait a tick to reflect it. An error is not an update at all - nothing
11
+ * on screen changed because a file failed to load.
12
+ */
13
+ const FEEDS_UPDATE = {
14
+ language: 'now',
15
+ initialLoad: 'now',
16
+ cache: 'batched',
17
+ };
18
+ function createEvents() {
19
+ const handlers = new Map();
20
+ let queued = [];
21
+ let flushScheduled = false;
22
+ function listeners(name) {
23
+ const existing = handlers.get(name);
24
+ if (existing)
25
+ return existing;
26
+ const created = [];
27
+ handlers.set(name, created);
28
+ return created;
29
+ }
30
+ function deliver(name, payload) {
31
+ // Copied before iterating: a handler that unsubscribes itself would otherwise make
32
+ // the loop skip the handler after it.
33
+ for (const handler of [...listeners(name)])
34
+ handler(payload);
35
+ }
36
+ function flush() {
37
+ flushScheduled = false;
38
+ if (!queued.length)
39
+ return;
40
+ const batch = queued;
41
+ queued = [];
42
+ deliver('update', batch);
43
+ }
44
+ return {
45
+ on(name, handler) {
46
+ const list = listeners(name);
47
+ list.push(handler);
48
+ return {
49
+ unsubscribe() {
50
+ const at = list.indexOf(handler);
51
+ if (at >= 0)
52
+ list.splice(at, 1);
53
+ },
54
+ };
55
+ },
56
+ emit(name, value) {
57
+ const event = { type: name, value };
58
+ deliver(name, event);
59
+ const update = FEEDS_UPDATE[name];
60
+ if (!update)
61
+ return;
62
+ queued.push(event);
63
+ if (update === 'now') {
64
+ flush();
65
+ return;
66
+ }
67
+ if (!flushScheduled) {
68
+ flushScheduled = true;
69
+ setTimeout(flush, 0);
70
+ }
71
+ },
72
+ };
73
+ }
74
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.js","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":";;AAiBA,oCAyDC;AAxED;;;;;;;;GAQG;AACH,MAAM,YAAY,GAAkD;IAChE,QAAQ,EAAE,KAAK;IACf,WAAW,EAAE,KAAK;IAClB,KAAK,EAAE,SAAS;CACnB,CAAC;AAEF,SAAgB,YAAY;IACxB,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAsC,CAAC;IAC/D,IAAI,MAAM,GAAgB,EAAE,CAAC;IAC7B,IAAI,cAAc,GAAG,KAAK,CAAC;IAE3B,SAAS,SAAS,CAAC,IAAY;QAC3B,MAAM,QAAQ,GAAG,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACpC,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAC;QAC9B,MAAM,OAAO,GAA+B,EAAE,CAAC;QAC/C,QAAQ,CAAC,GAAG,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAC5B,OAAO,OAAO,CAAC;IACnB,CAAC;IAED,SAAS,OAAO,CAAC,IAAY,EAAE,OAAgB;QAC3C,mFAAmF;QACnF,sCAAsC;QACtC,KAAK,MAAM,OAAO,IAAI,CAAC,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC;YAAG,OAAoC,CAAC,OAAO,CAAC,CAAC;IAC/F,CAAC;IAED,SAAS,KAAK;QACV,cAAc,GAAG,KAAK,CAAC;QACvB,IAAI,CAAC,MAAM,CAAC,MAAM;YAAE,OAAO;QAC3B,MAAM,KAAK,GAAG,MAAM,CAAC;QACrB,MAAM,GAAG,EAAE,CAAC;QACZ,OAAO,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IAC7B,CAAC;IAED,OAAO;QACH,EAAE,CAAC,IAA0B,EAAE,OAA+B;YAC1D,MAAM,IAAI,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC;YAC7B,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YACnB,OAAO;gBACH,WAAW;oBACP,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;oBACjC,IAAI,EAAE,IAAI,CAAC;wBAAE,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;gBACpC,CAAC;aACJ,CAAC;QACN,CAAC;QAED,IAAI,CAAC,IAAe,EAAE,KAAc;YAChC,MAAM,KAAK,GAAc,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;YAC/C,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;YAErB,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;YAClC,IAAI,CAAC,MAAM;gBAAE,OAAO;YAEpB,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACnB,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;gBACnB,KAAK,EAAE,CAAC;gBACR,OAAO;YACX,CAAC;YACD,IAAI,CAAC,cAAc,EAAE,CAAC;gBAClB,cAAc,GAAG,IAAI,CAAC;gBACtB,UAAU,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;YACzB,CAAC;QACL,CAAC;KACJ,CAAC;AACN,CAAC"}
@@ -0,0 +1,9 @@
1
+ import type { FormatFn, TranslateParams } from './types.js';
2
+ export declare function formatIcu({ translation, language, params }: {
3
+ translation: string;
4
+ language: string;
5
+ params?: TranslateParams;
6
+ }): string;
7
+ /** Test seam: the cache is a module global by design, and lives as long as the page. */
8
+ export declare function forgetCompiled(): void;
9
+ export declare function resolveFormat(format: 'icu' | 'none' | FormatFn | undefined): FormatFn;
package/dist/format.js ADDED
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatIcu = formatIcu;
4
+ exports.forgetCompiled = forgetCompiled;
5
+ exports.resolveFormat = resolveFormat;
6
+ /**
7
+ * The NAMED import, not the default one.
8
+ *
9
+ * intl-messageformat ships CommonJS with an ESM wrapper, and under Node's ESM interop the
10
+ * default export resolves to the whole module namespace rather than to the class - so
11
+ * `new IntlMessageFormat(...)` throws "is not a constructor", and every string with a
12
+ * placeholder in it renders as the format-error text. Bundlers and jest both take the
13
+ * CommonJS path, where the default DOES work, so nothing catches this until it is running
14
+ * somewhere real. The named export is the class in both.
15
+ */
16
+ const intl_messageformat_1 = require("intl-messageformat");
17
+ /**
18
+ * ICU MessageFormat, over FormatJS directly rather than through a wrapper.
19
+ *
20
+ * Compiled formatters are cached by (language, message). Parsing an ICU message is the
21
+ * expensive half, and the message text is what varies least: the same string is formatted
22
+ * once per render of every component that shows it, with different parameters each time.
23
+ */
24
+ const compiled = new Map();
25
+ /**
26
+ * Strings that cannot mean anything but themselves.
27
+ *
28
+ * Most translations are plain text, and running the parser over them is pure cost. Only two
29
+ * characters can make a string non-literal: `{` opens a placeholder, and `'` is ICU's escape
30
+ * character - `'{'` renders a literal brace and `''` renders one apostrophe, so `It''s` would
31
+ * render wrongly if this shortcut took it. `#` is special only inside a plural, which needs a
32
+ * `{` to open.
33
+ */
34
+ const LITERAL = /^[^{']*$/;
35
+ function formatIcu({ translation, language, params }) {
36
+ if (LITERAL.test(translation))
37
+ return translation;
38
+ const cacheKey = `${language} ${translation}`;
39
+ let formatter = compiled.get(cacheKey);
40
+ if (!formatter) {
41
+ formatter = new intl_messageformat_1.IntlMessageFormat(translation, language);
42
+ compiled.set(cacheKey, formatter);
43
+ }
44
+ const formatted = formatter.format(params);
45
+ // format() returns an array for messages with rich-text tags. Nothing here uses those,
46
+ // but joining beats rendering "[object Object]" if one ever appears.
47
+ return Array.isArray(formatted) ? formatted.join('') : String(formatted);
48
+ }
49
+ /** Test seam: the cache is a module global by design, and lives as long as the page. */
50
+ function forgetCompiled() {
51
+ compiled.clear();
52
+ }
53
+ function resolveFormat(format) {
54
+ if (typeof format === 'function')
55
+ return format;
56
+ if (format === 'none')
57
+ return ({ translation }) => translation;
58
+ return formatIcu;
59
+ }
60
+ //# sourceMappingURL=format.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.js","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":";;AAiCA,8BAcC;AAGD,wCAEC;AAED,sCAIC;AA1DD;;;;;;;;;GASG;AACH,2DAAuD;AAGvD;;;;;;GAMG;AACH,MAAM,QAAQ,GAAG,IAAI,GAAG,EAA6B,CAAC;AAEtD;;;;;;;;GAQG;AACH,MAAM,OAAO,GAAG,UAAU,CAAC;AAE3B,SAAgB,SAAS,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,EAAuE;IAC5H,IAAI,OAAO,CAAC,IAAI,CAAC,WAAW,CAAC;QAAE,OAAO,WAAW,CAAC;IAElD,MAAM,QAAQ,GAAG,GAAG,QAAQ,IAAI,WAAW,EAAE,CAAC;IAC9C,IAAI,SAAS,GAAG,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IACvC,IAAI,CAAC,SAAS,EAAE,CAAC;QACb,SAAS,GAAG,IAAI,sCAAiB,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC;QACzD,QAAQ,CAAC,GAAG,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC;IACtC,CAAC;IAED,MAAM,SAAS,GAAG,SAAS,CAAC,MAAM,CAAC,MAAqD,CAAC,CAAC;IAC1F,uFAAuF;IACvF,qEAAqE;IACrE,OAAO,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;AAC7E,CAAC;AAED,wFAAwF;AACxF,SAAgB,cAAc;IAC1B,QAAQ,CAAC,KAAK,EAAE,CAAC;AACrB,CAAC;AAED,SAAgB,aAAa,CAAC,MAA6C;IACvE,IAAI,OAAO,MAAM,KAAK,UAAU;QAAE,OAAO,MAAM,CAAC;IAChD,IAAI,MAAM,KAAK,MAAM;QAAE,OAAO,CAAC,EAAE,WAAW,EAAE,EAAE,EAAE,CAAC,WAAW,CAAC;IAC/D,OAAO,SAAS,CAAC;AACrB,CAAC"}
@@ -0,0 +1,7 @@
1
+ export { Metaglotta } from './instance.js';
2
+ export type { I18nInstance } from './instance.js';
3
+ export { RecordLoadError } from './backend.js';
4
+ export { getTranslateProps, asList, unique } from './props.js';
5
+ export { flatten, recordKey } from './cache.js';
6
+ export { formatIcu, forgetCompiled, resolveFormat } from './format.js';
7
+ export type { BackendOptions, EventName, FormatFn, GetPath, I18nEvent, LanguageFallback, MetaglottaOptions, NamespaceFallback, RecordDescriptor, Subscription, TranslateParams, TranslateProps, DecorateFn, TranslateOptions, TranslateFn, TranslationsInput, TranslationsRecord, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveFormat = exports.forgetCompiled = exports.formatIcu = exports.recordKey = exports.flatten = exports.unique = exports.asList = exports.getTranslateProps = exports.RecordLoadError = exports.Metaglotta = void 0;
4
+ var instance_js_1 = require("./instance.js");
5
+ Object.defineProperty(exports, "Metaglotta", { enumerable: true, get: function () { return instance_js_1.Metaglotta; } });
6
+ var backend_js_1 = require("./backend.js");
7
+ Object.defineProperty(exports, "RecordLoadError", { enumerable: true, get: function () { return backend_js_1.RecordLoadError; } });
8
+ var props_js_1 = require("./props.js");
9
+ Object.defineProperty(exports, "getTranslateProps", { enumerable: true, get: function () { return props_js_1.getTranslateProps; } });
10
+ Object.defineProperty(exports, "asList", { enumerable: true, get: function () { return props_js_1.asList; } });
11
+ Object.defineProperty(exports, "unique", { enumerable: true, get: function () { return props_js_1.unique; } });
12
+ var cache_js_1 = require("./cache.js");
13
+ Object.defineProperty(exports, "flatten", { enumerable: true, get: function () { return cache_js_1.flatten; } });
14
+ Object.defineProperty(exports, "recordKey", { enumerable: true, get: function () { return cache_js_1.recordKey; } });
15
+ var format_js_1 = require("./format.js");
16
+ Object.defineProperty(exports, "formatIcu", { enumerable: true, get: function () { return format_js_1.formatIcu; } });
17
+ Object.defineProperty(exports, "forgetCompiled", { enumerable: true, get: function () { return format_js_1.forgetCompiled; } });
18
+ Object.defineProperty(exports, "resolveFormat", { enumerable: true, get: function () { return format_js_1.resolveFormat; } });
19
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,6CAA2C;AAAlC,yGAAA,UAAU,OAAA;AAEnB,2CAA+C;AAAtC,6GAAA,eAAe,OAAA;AACxB,uCAA+D;AAAtD,6GAAA,iBAAiB,OAAA;AAAE,kGAAA,MAAM,OAAA;AAAE,kGAAA,MAAM,OAAA;AAC1C,uCAAgD;AAAvC,mGAAA,OAAO,OAAA;AAAE,qGAAA,SAAS,OAAA;AAC3B,yCAAuE;AAA9D,sGAAA,SAAS,OAAA;AAAE,2GAAA,cAAc,OAAA;AAAE,0GAAA,aAAa,OAAA"}
@@ -0,0 +1,38 @@
1
+ import { RecordLoadError } from './backend.js';
2
+ import type { EventName, MetaglottaOptions, NamespaceFallback, Subscription, TranslateProps } from './types.js';
3
+ /**
4
+ * What both runtimes provide, and all the Angular layer asks for.
5
+ *
6
+ * Eight methods. That number is the whole reason this package could be written at all: the
7
+ * dependency being replaced is nineteen thousand lines, but the part anything here actually
8
+ * calls is this - measured by counting call sites, not by reading a feature list.
9
+ */
10
+ export type I18nInstance = {
11
+ t: (keyOrProps: string | TranslateProps, ...rest: unknown[]) => string;
12
+ on: (event: EventName | 'update', handler: (event: never) => void) => Subscription;
13
+ run: () => Promise<void>;
14
+ stop: () => void;
15
+ getLanguage: () => string | undefined;
16
+ changeLanguage: (language: string) => Promise<void>;
17
+ addActiveNs: (ns?: NamespaceFallback, forget?: boolean) => Promise<void>;
18
+ removeActiveNs: (ns?: NamespaceFallback) => void;
19
+ /** In-memory only, and only where the record is loaded. Returns whether it took. */
20
+ changeTranslation: (descriptor: {
21
+ language?: string;
22
+ namespace?: string;
23
+ }, key: string, value: string) => boolean;
24
+ };
25
+ /**
26
+ * The Metaglotta translation runtime.
27
+ *
28
+ * Everything the eleven applications do in production: hold translations per language and
29
+ * namespace, fetch the ones they need from the CDN, fall back through namespaces and
30
+ * languages when a key is missing, format ICU, and say when any of that changed.
31
+ *
32
+ * What it deliberately does not do is in-context editing. That needs the page's rendered text
33
+ * traced back to its key, which means wrapping every string and watching the DOM - a
34
+ * different job, only wanted while authoring, and it stays where it is until it is rewritten
35
+ * too. Both runtimes satisfy I18nInstance, so a bootstrap picks one per environment.
36
+ */
37
+ export declare function Metaglotta(options: MetaglottaOptions): I18nInstance;
38
+ export { RecordLoadError };