@_linked/translation 0.6.0 → 0.8.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/README.md +18 -17
- package/lib/esm/archive.d.ts +2 -2
- package/lib/esm/archive.js +2 -2
- package/lib/esm/archive.js.map +1 -1
- package/lib/esm/authorization.d.ts +1 -1
- package/lib/esm/authorization.js +1 -1
- package/lib/esm/authorization.js.map +1 -1
- package/lib/esm/cdn-loader.d.ts +1 -1
- package/lib/esm/cdn-loader.js.map +1 -1
- package/lib/esm/core/messages.d.ts +14 -1
- package/lib/esm/core/messages.js +33 -2
- package/lib/esm/core/messages.js.map +1 -1
- package/lib/esm/exchange.d.ts +1 -1
- package/lib/esm/exchange.js +1 -1
- package/lib/esm/exchange.js.map +1 -1
- package/lib/esm/export.js +4 -1
- package/lib/esm/export.js.map +1 -1
- package/lib/esm/formats/xliff.js +1 -1
- package/lib/esm/formats/xliff.js.map +1 -1
- package/lib/esm/key-sync.d.ts +12 -0
- package/lib/esm/key-sync.js +32 -7
- package/lib/esm/key-sync.js.map +1 -1
- package/lib/esm/mt.js +1 -1
- package/lib/esm/mt.js.map +1 -1
- package/lib/esm/ontologies/translation.d.ts +3 -1
- package/lib/esm/ontologies/translation.js +4 -2
- package/lib/esm/ontologies/translation.js.map +1 -1
- package/lib/esm/package.js +4 -9
- package/lib/esm/package.js.map +1 -1
- package/lib/esm/react.d.ts +38 -2
- package/lib/esm/react.js +98 -15
- package/lib/esm/react.js.map +1 -1
- package/lib/esm/records.d.ts +2 -1
- package/lib/esm/records.js.map +1 -1
- package/lib/esm/release.d.ts +3 -1
- package/lib/esm/release.js +21 -4
- package/lib/esm/release.js.map +1 -1
- package/lib/esm/shape-catalog.d.ts +1 -1
- package/lib/esm/shape-catalog.js.map +1 -1
- package/lib/esm/shapes/TranslationKey.d.ts +10 -5
- package/lib/esm/shapes/TranslationKey.js +16 -5
- package/lib/esm/shapes/TranslationKey.js.map +1 -1
- package/lib/esm/shapes/TranslationProvider.d.ts +25 -8
- package/lib/esm/shapes/TranslationProvider.js +50 -19
- package/lib/esm/shapes/TranslationProvider.js.map +1 -1
- package/package.json +3 -3
- package/src/archive.ts +2 -2
- package/src/authorization.ts +1 -1
- package/src/cdn-loader.ts +1 -1
- package/src/core/messages.ts +35 -2
- package/src/exchange.ts +1 -1
- package/src/export.ts +4 -1
- package/src/formats/xliff.ts +1 -1
- package/src/key-sync.ts +46 -6
- package/src/mt.ts +1 -1
- package/src/ontologies/translation.ts +4 -2
- package/src/package.ts +4 -9
- package/src/react.tsx +158 -35
- package/src/records.ts +2 -1
- package/src/release.ts +31 -3
- package/src/shape-catalog.ts +1 -1
- package/src/shapes/TranslationKey.ts +14 -5
- package/src/shapes/TranslationProvider.ts +73 -13
package/src/archive.ts
CHANGED
|
@@ -26,8 +26,8 @@ export const TRANSLATION_ARCHIVE_MANIFEST_PATH =
|
|
|
26
26
|
'linked.translation-archive.json';
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Archives written before 0.3.0
|
|
30
|
-
*
|
|
29
|
+
* Archives written before 0.3.0 used the pre-0.3.0 spelling for the manifest
|
|
30
|
+
* and the archive format. Both spellings are read; only the current one is
|
|
31
31
|
* written. The format id is inside `archiveContentHash`'s input, so a legacy
|
|
32
32
|
* manifest keeps the value it was hashed with.
|
|
33
33
|
*/
|
package/src/authorization.ts
CHANGED
|
@@ -20,7 +20,7 @@ let resolver: TranslationAuthorizationResolver | undefined;
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Host hook: the host decides who may read and author an app's translations
|
|
23
|
-
* (
|
|
23
|
+
* (for example by evaluating its own translator assignments and project roles).
|
|
24
24
|
*
|
|
25
25
|
* The resolver is the only source of a "yes". A host that does not configure
|
|
26
26
|
* one gets no authoring or read access over the translation provider; only the
|
package/src/cdn-loader.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
* Any source that yields a non-empty map wins; all-empty ⇒ `{}` and the client
|
|
14
14
|
* keeps its inline English defaults. Never throws.
|
|
15
15
|
*
|
|
16
|
-
* Portable (no
|
|
16
|
+
* Portable (no host-application import): the caller wires `base`/`devApi` for its environment.
|
|
17
17
|
*/
|
|
18
18
|
import type { TranslationMessages } from './core/messages.js';
|
|
19
19
|
import { sha256Hex } from './key-version.js';
|
package/src/core/messages.ts
CHANGED
|
@@ -26,6 +26,36 @@ export type TranslationMessage = string | FormattedTranslationMessage;
|
|
|
26
26
|
/** key → message template, for a single language. */
|
|
27
27
|
export type TranslationMessages = Record<string, TranslationMessage>;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Development pseudo-locale used by chrome coverage checks. Keeping this in
|
|
31
|
+
* the framework-light core means React and non-React clients exercise the
|
|
32
|
+
* exact same fallback path. The wrapper makes missed layout allowance visible;
|
|
33
|
+
* the accented glyphs make an untranslated English fallback unmistakable.
|
|
34
|
+
*/
|
|
35
|
+
export function pseudoLocalize(message: string): string {
|
|
36
|
+
const accents: Record<string, string> = {
|
|
37
|
+
A: 'Å', B: 'Ɓ', C: 'Ç', D: 'Ð', E: 'Ë', F: 'Ƒ', G: 'Ĝ', H: 'Ĥ', I: 'Ï',
|
|
38
|
+
J: 'Ĵ', K: 'Ķ', L: 'Ļ', M: 'Ṁ', N: 'Ñ', O: 'Ö', P: 'Ƥ', Q: 'Ǫ', R: 'Ŕ',
|
|
39
|
+
S: 'Š', T: 'Ŧ', U: 'Ü', V: 'Ṽ', W: 'Ŵ', X: 'Ẍ', Y: 'Ŷ', Z: 'Ž',
|
|
40
|
+
a: 'å', b: 'ƀ', c: 'ç', d: 'đ', e: 'ë', f: 'ƒ', g: 'ĝ', h: 'ĥ', i: 'ï',
|
|
41
|
+
j: 'ĵ', k: 'ķ', l: 'ļ', m: 'ṁ', n: 'ñ', o: 'ö', p: 'ƥ', q: 'ǫ', r: 'ŕ',
|
|
42
|
+
s: 'š', t: 'ŧ', u: 'ü', v: 'ṽ', w: 'ŵ', x: 'ẍ', y: 'ŷ', z: 'ž',
|
|
43
|
+
};
|
|
44
|
+
const accented = message.replace(/[A-Za-z]/g, (character) => accents[character]);
|
|
45
|
+
return `[${accented} ···]`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Layout-stress pseudo mode. Unlike en-XA's accented fallback, this deliberately
|
|
50
|
+
* expands the resolved message—including a real translated catalog value—so a
|
|
51
|
+
* preview can expose controls that only fit their English copy.
|
|
52
|
+
*/
|
|
53
|
+
export function pseudoExpand(message: string): string {
|
|
54
|
+
const visibleLength = [...message].length;
|
|
55
|
+
const padding = '·'.repeat(Math.max(3, Math.ceil(visibleLength * 0.35)));
|
|
56
|
+
return `[${message} ${padding}]`;
|
|
57
|
+
}
|
|
58
|
+
|
|
29
59
|
/** FormatSimple: replace `{name}` placeholders with `params.name`. Unknown → left as-is. */
|
|
30
60
|
export function interpolate(
|
|
31
61
|
template: string,
|
|
@@ -59,7 +89,7 @@ function compiledIcu(template: string, locale: string): IntlMessageFormat {
|
|
|
59
89
|
/**
|
|
60
90
|
* Full ICU MessageFormat using the active locale's CLDR plural rules. Invalid
|
|
61
91
|
* authoring data never crashes the host app: the unformatted template remains
|
|
62
|
-
* visible and
|
|
92
|
+
* visible and authoring tools can report the syntax error for repair.
|
|
63
93
|
*/
|
|
64
94
|
export function formatIcu(
|
|
65
95
|
template: string,
|
|
@@ -93,9 +123,12 @@ export function translate(
|
|
|
93
123
|
: stored?.message ?? defaultValue ?? key;
|
|
94
124
|
const resolvedFormat =
|
|
95
125
|
typeof stored === 'object' ? stored.format : format;
|
|
96
|
-
|
|
126
|
+
const formatted = resolvedFormat === 'icu'
|
|
97
127
|
? formatIcu(template, params, locale)
|
|
98
128
|
: interpolate(template, params);
|
|
129
|
+
return stored === undefined && locale.toLowerCase() === 'en-xa'
|
|
130
|
+
? pseudoLocalize(formatted)
|
|
131
|
+
: formatted;
|
|
99
132
|
}
|
|
100
133
|
|
|
101
134
|
/** @deprecated Use directionFor; this historical set is not a support boundary. */
|
package/src/exchange.ts
CHANGED
|
@@ -363,7 +363,7 @@ export function normalizeParsedTranslationExchangeDocument(
|
|
|
363
363
|
};
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
/** Validate the stronger, version-evidenced document
|
|
366
|
+
/** Validate the stronger, version-evidenced document this package's own exports carry. */
|
|
367
367
|
export function validateTranslationExchangeDocument(
|
|
368
368
|
value: unknown,
|
|
369
369
|
): TranslationExchangeDocument {
|
package/src/export.ts
CHANGED
|
@@ -95,7 +95,10 @@ export async function createTranslationExchangeDocument(
|
|
|
95
95
|
key: entry.key,
|
|
96
96
|
namespace: entry.namespace,
|
|
97
97
|
kind:
|
|
98
|
-
entry.ofShape ||
|
|
98
|
+
entry.ofShape ||
|
|
99
|
+
entry.ofProperty ||
|
|
100
|
+
entry.ofResource ||
|
|
101
|
+
entry.fromPackage
|
|
99
102
|
? 'semantic'
|
|
100
103
|
: 'ui',
|
|
101
104
|
sourceText: version.sourceText,
|
package/src/formats/xliff.ts
CHANGED
|
@@ -14,7 +14,7 @@ const UTF8 = new TextEncoder();
|
|
|
14
14
|
* Our own XML metadata namespace for exchange files. It is a file-format
|
|
15
15
|
* identifier written into exported documents, not the translation ontology
|
|
16
16
|
* (which lives at `https://linked.cm/ont/translation/`). Files exported before 0.3.0
|
|
17
|
-
* used
|
|
17
|
+
* used the pre-0.3.0 namespace URI and prefix; the reader below still accepts
|
|
18
18
|
* both, so those files keep importing.
|
|
19
19
|
*/
|
|
20
20
|
const EXCHANGE_NAMESPACE = 'https://id.linked.cm/translation/exchange/1';
|
package/src/key-sync.ts
CHANGED
|
@@ -12,7 +12,18 @@ export interface ExtractedTranslationKey {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface TranslationKeySyncTarget {
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Existing keys. `overridden: true` marks a declared key the app has reworded
|
|
17
|
+
* locally; sync reports it in `preservedOverrides` and never writes it.
|
|
18
|
+
*/
|
|
19
|
+
list(data: { appId: string }): Promise<
|
|
20
|
+
Array<{ key: string; sourceText?: string; overridden?: boolean }>
|
|
21
|
+
>;
|
|
22
|
+
/**
|
|
23
|
+
* Write one key. A writer that keeps an app override instead of applying the
|
|
24
|
+
* declared source answers `{ preservedOverride: true }` (see
|
|
25
|
+
* `upsertTranslationKey` with `source: 'declaration-sync'`).
|
|
26
|
+
*/
|
|
16
27
|
upsert(data: {
|
|
17
28
|
appId: string;
|
|
18
29
|
key: string;
|
|
@@ -25,6 +36,8 @@ export interface TranslationKeySyncReport {
|
|
|
25
36
|
created: string[];
|
|
26
37
|
updated: string[];
|
|
27
38
|
unchanged: string[];
|
|
39
|
+
/** Keys whose app override was kept over the declared source; also in `unchanged`. */
|
|
40
|
+
preservedOverrides: string[];
|
|
28
41
|
orphaned: string[];
|
|
29
42
|
conflicts: Array<{ key: string; defaults: string[] }>;
|
|
30
43
|
}
|
|
@@ -141,7 +154,12 @@ export async function syncTranslationKeys(
|
|
|
141
154
|
defaultsByKey.set(item.key, defaults);
|
|
142
155
|
}
|
|
143
156
|
const report: TranslationKeySyncReport = {
|
|
144
|
-
created: [],
|
|
157
|
+
created: [],
|
|
158
|
+
updated: [],
|
|
159
|
+
unchanged: [],
|
|
160
|
+
preservedOverrides: [],
|
|
161
|
+
orphaned: [],
|
|
162
|
+
conflicts: [],
|
|
145
163
|
};
|
|
146
164
|
for (const [key, defaults] of defaultsByKey) {
|
|
147
165
|
if (defaults.size > 1) {
|
|
@@ -150,15 +168,37 @@ export async function syncTranslationKeys(
|
|
|
150
168
|
}
|
|
151
169
|
const sourceText = [...defaults][0];
|
|
152
170
|
const current = existingByKey.get(key);
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
171
|
+
if (current?.overridden === true) {
|
|
172
|
+
report.preservedOverrides.push(key);
|
|
173
|
+
report.unchanged.push(key);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (current?.sourceText === sourceText) {
|
|
156
177
|
report.unchanged.push(key);
|
|
157
178
|
continue;
|
|
158
179
|
}
|
|
159
180
|
if (!options.dryRun) {
|
|
160
|
-
await target.upsert({
|
|
181
|
+
const result = await target.upsert({
|
|
182
|
+
appId,
|
|
183
|
+
key,
|
|
184
|
+
sourceText,
|
|
185
|
+
kind: 'ui',
|
|
186
|
+
});
|
|
187
|
+
// The writer is authoritative: an override may have appeared since list,
|
|
188
|
+
// or the portable target may not project ownership in its list response.
|
|
189
|
+
if (
|
|
190
|
+
result &&
|
|
191
|
+
typeof result === 'object' &&
|
|
192
|
+
'preservedOverride' in result &&
|
|
193
|
+
result.preservedOverride === true
|
|
194
|
+
) {
|
|
195
|
+
report.preservedOverrides.push(key);
|
|
196
|
+
report.unchanged.push(key);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
161
199
|
}
|
|
200
|
+
if (current) report.updated.push(key);
|
|
201
|
+
else report.created.push(key);
|
|
162
202
|
}
|
|
163
203
|
report.orphaned = existing
|
|
164
204
|
.map((item) => item.key)
|
package/src/mt.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* with a fallback chain: not every provider covers every language (DeepL has
|
|
6
6
|
* no Swahili or Urdu), so the preferred provider is used where it supports
|
|
7
7
|
* the target and the chain falls through otherwise. Pure module: no I/O, no
|
|
8
|
-
* SDK imports — concrete providers live with the host
|
|
8
|
+
* SDK imports — concrete providers live with the host application and are
|
|
9
9
|
* registered at boot.
|
|
10
10
|
*/
|
|
11
11
|
import {
|
|
@@ -12,7 +12,7 @@ export var loadData = () => {
|
|
|
12
12
|
* Public ontology namespace for the translation package. First-party Linked
|
|
13
13
|
* ontologies live at `https://linked.cm/ont/{ontologySlug}/{localName}`, where
|
|
14
14
|
* the ontology slug — and the prefix label — is the package's public slug
|
|
15
|
-
* (
|
|
15
|
+
* (the LINKED first-party ontology namespace rule). So terms are
|
|
16
16
|
* `https://linked.cm/ont/translation/TranslationKey` and the prefix is
|
|
17
17
|
* `translation:`. Before 0.5.0 this was `https://id.linked.cm/translation/vocab#`.
|
|
18
18
|
*/
|
|
@@ -51,6 +51,7 @@ export var ofNode = ns('ofNode'); // content-translation subject (kind:'content'
|
|
|
51
51
|
export var ofField = ns('ofField');
|
|
52
52
|
export var ofShape = ns('ofShape'); // semantic SHACL shape context for package keys
|
|
53
53
|
export var ofProperty = ns('ofProperty'); // semantic predicate context for package keys
|
|
54
|
+
export var ofResource = ns('ofResource'); // arbitrary semantic resource (action, group, condition, port)
|
|
54
55
|
export var fromPackage = ns('fromPackage'); // package that supplied a seed catalog
|
|
55
56
|
export var overridden = ns('overridden'); // app diverged this shape key from canonical → excluded from shape propagation (AD-N)
|
|
56
57
|
export var shapeSource = ns('shapeSource'); // canonical source snapshot at override time → the value "clear override" reverts to (AD-N)
|
|
@@ -78,7 +79,7 @@ export var publishedAt = ns('publishedAt');
|
|
|
78
79
|
export var supportedUntil = ns('supportedUntil');
|
|
79
80
|
export var qualitySummary = ns('qualitySummary');
|
|
80
81
|
|
|
81
|
-
// ── CapabilityAppConfig properties (stored in
|
|
82
|
+
// ── CapabilityAppConfig properties (stored in the host's app configuration) ────
|
|
82
83
|
export var languages = ns('languages'); // active BCP-47 tags, including source
|
|
83
84
|
export var defaultLanguage = ns('defaultLanguage'); // authored/source language
|
|
84
85
|
export var cdnTarget = ns('cdnTarget'); // delivery base URL / bucket prefix — the "repoint" knob (AD-K, P2)
|
|
@@ -167,6 +168,7 @@ export const tr = {
|
|
|
167
168
|
ofField,
|
|
168
169
|
ofShape,
|
|
169
170
|
ofProperty,
|
|
171
|
+
ofResource,
|
|
170
172
|
fromPackage,
|
|
171
173
|
overridden,
|
|
172
174
|
shapeSource,
|
package/src/package.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { linkedPackage } from '@_linked/core/utils/Package';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// (`@_linked/translation` / `@semantu/translation`) — a move, not a rewrite
|
|
7
|
-
// (Plan 014 AD-G). R14: confirm ownership of `id.linked.cm` before seeding real
|
|
8
|
-
// data under these IRIs; changing the baseUri afterwards means re-materializing.
|
|
3
|
+
// First-party LINKED package: no `baseUri`, so core's default root applies and
|
|
4
|
+
// IRIs follow the public rule — shapes `https://linked.cm/shape/translation/{ShapeName}`,
|
|
5
|
+
// package `https://linked.cm/pkg/translation` (arch-02 "Public community").
|
|
9
6
|
export const {
|
|
10
7
|
linkedShape,
|
|
11
8
|
linkedUtil,
|
|
@@ -14,6 +11,4 @@ export const {
|
|
|
14
11
|
packageExports,
|
|
15
12
|
packageName,
|
|
16
13
|
getPackageShape,
|
|
17
|
-
} = linkedPackage('@_linked/translation'
|
|
18
|
-
baseUri: 'https://id.linked.cm/translation/',
|
|
19
|
-
});
|
|
14
|
+
} = linkedPackage('@_linked/translation');
|
package/src/react.tsx
CHANGED
|
@@ -4,24 +4,67 @@ import {
|
|
|
4
4
|
} from './languages.js';
|
|
5
5
|
import React, {
|
|
6
6
|
createContext,
|
|
7
|
+
startTransition,
|
|
7
8
|
useCallback,
|
|
8
9
|
useContext,
|
|
9
10
|
useEffect,
|
|
10
11
|
useMemo,
|
|
11
12
|
useState,
|
|
13
|
+
useSyncExternalStore,
|
|
12
14
|
} from 'react';
|
|
13
15
|
import {
|
|
14
16
|
directionFor,
|
|
15
17
|
type MessageFormat,
|
|
18
|
+
pseudoExpand,
|
|
19
|
+
pseudoLocalize,
|
|
16
20
|
translate,
|
|
17
21
|
type TranslationMessages,
|
|
18
22
|
} from './core/messages.js';
|
|
19
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Window event that previews a language, text direction or pseudo-locale in
|
|
26
|
+
* every mounted `TranslationProvider`, without changing the user's language.
|
|
27
|
+
*
|
|
28
|
+
* Dispatch it with a {@link TranslationPreviewDetail}:
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* window.dispatchEvent(
|
|
32
|
+
* new CustomEvent(TRANSLATION_PREVIEW_EVENT, {
|
|
33
|
+
* detail: { language: 'ar', direction: 'ltr', pseudoLocale: 'expanded' },
|
|
34
|
+
* })
|
|
35
|
+
* );
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* Each event replaces the whole preview: a field that is missing or invalid
|
|
39
|
+
* falls back to its default (no preview language, `'auto'`, `'off'`), so an
|
|
40
|
+
* empty detail ends the preview. The preview language is never written to
|
|
41
|
+
* storage, and a preview direction wins over the language's own direction.
|
|
42
|
+
*/
|
|
43
|
+
export const TRANSLATION_PREVIEW_EVENT = 'linked:translation-preview';
|
|
44
|
+
|
|
45
|
+
export type TranslationPreviewDirection = 'auto' | 'ltr' | 'rtl';
|
|
46
|
+
export type TranslationPreviewPseudoLocale = 'off' | 'accented' | 'expanded';
|
|
47
|
+
|
|
48
|
+
/** The `detail` of a {@link TRANSLATION_PREVIEW_EVENT}. */
|
|
49
|
+
export interface TranslationPreviewDetail {
|
|
50
|
+
/** BCP-47 tag to render in instead of the selected language. */
|
|
51
|
+
language?: string;
|
|
52
|
+
/** Force a text direction; `'auto'` uses the language's own. */
|
|
53
|
+
direction?: TranslationPreviewDirection;
|
|
54
|
+
/**
|
|
55
|
+
* `'accented'` accents every message; `'expanded'` pads every message by
|
|
56
|
+
* about a third to show controls that only fit their English copy.
|
|
57
|
+
*/
|
|
58
|
+
pseudoLocale?: TranslationPreviewPseudoLocale;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const EMPTY_MESSAGES: Record<string, TranslationMessages> = {};
|
|
62
|
+
|
|
20
63
|
/**
|
|
21
64
|
* React binding for the translation core (Plan 014 P1.5). Kept in a `/react`
|
|
22
65
|
* subpath so non-React consumers use `./core/messages` without pulling React.
|
|
23
66
|
* Message LOADING is caller-injected (`loadMessages`) so this stays transport-
|
|
24
|
-
* agnostic: dev wires it to
|
|
67
|
+
* agnostic: dev wires it to the host's API, prod to the bundled `{lang}.json` with a
|
|
25
68
|
* CDN overlay (AD-J/AD-K) — the provider doesn't care which.
|
|
26
69
|
*
|
|
27
70
|
* The hook shape mirrors Tolgee (`useTranslate().t(key, default, params)`,
|
|
@@ -40,13 +83,17 @@ export interface TranslationCallOptions {
|
|
|
40
83
|
format?: MessageFormat;
|
|
41
84
|
}
|
|
42
85
|
|
|
86
|
+
type TranslateFn = (
|
|
87
|
+
key: string,
|
|
88
|
+
defaultValue?: string,
|
|
89
|
+
params?: Record<string, unknown>,
|
|
90
|
+
options?: TranslationCallOptions
|
|
91
|
+
) => string;
|
|
92
|
+
|
|
43
93
|
interface TranslationContextValue {
|
|
44
|
-
t:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
params?: Record<string, unknown>,
|
|
48
|
-
options?: TranslationCallOptions
|
|
49
|
-
) => string;
|
|
94
|
+
t: TranslateFn;
|
|
95
|
+
/** `t` over no catalog: exactly what the server rendered. */
|
|
96
|
+
tInline: TranslateFn;
|
|
50
97
|
language: string;
|
|
51
98
|
setLanguage: (tag: string) => void;
|
|
52
99
|
languages: TranslationLanguage[];
|
|
@@ -97,9 +144,19 @@ export function TranslationProvider({
|
|
|
97
144
|
}
|
|
98
145
|
return defaultLanguage;
|
|
99
146
|
});
|
|
147
|
+
const [previewLanguage, setPreviewLanguage] = useState<string | null>(null);
|
|
148
|
+
const [previewPseudoLocale, setPreviewPseudoLocale] =
|
|
149
|
+
useState<TranslationPreviewPseudoLocale>('off');
|
|
150
|
+
const [previewDirection, setPreviewDirection] =
|
|
151
|
+
useState<TranslationPreviewDirection>('auto');
|
|
152
|
+
const effectiveLanguage = previewLanguage ?? language;
|
|
153
|
+
// A preview direction is an editor coordinate and wins; otherwise the
|
|
154
|
+
// configured language's own direction, then the tag's script default.
|
|
100
155
|
const dir =
|
|
101
|
-
|
|
102
|
-
|
|
156
|
+
previewDirection === 'ltr' || previewDirection === 'rtl'
|
|
157
|
+
? previewDirection
|
|
158
|
+
: (languages.find((item) => item.tag === effectiveLanguage)?.direction ??
|
|
159
|
+
directionFor(effectiveLanguage));
|
|
103
160
|
useEffect(() => {
|
|
104
161
|
if (!loadLanguages) return;
|
|
105
162
|
let cancelled = false;
|
|
@@ -133,10 +190,10 @@ export function TranslationProvider({
|
|
|
133
190
|
// Load the active language's messages once. English renders from inline
|
|
134
191
|
// defaults until (and if) a payload arrives, so the app is never blank.
|
|
135
192
|
useEffect(() => {
|
|
136
|
-
if (messagesByLang[
|
|
193
|
+
if (messagesByLang[effectiveLanguage]) return;
|
|
137
194
|
let cancelled = false;
|
|
138
195
|
const chain = languageFallbacks(
|
|
139
|
-
|
|
196
|
+
effectiveLanguage,
|
|
140
197
|
defaultLanguage,
|
|
141
198
|
remoteLanguages
|
|
142
199
|
).reverse();
|
|
@@ -144,7 +201,15 @@ export function TranslationProvider({
|
|
|
144
201
|
.then((maps) => Object.assign({}, ...maps) as TranslationMessages)
|
|
145
202
|
.then((msgs) => {
|
|
146
203
|
if (!cancelled) {
|
|
147
|
-
|
|
204
|
+
// A descendant lazy route may still be hydrating when the catalog
|
|
205
|
+
// arrives. Overlay messages without replacing its server-rendered
|
|
206
|
+
// DOM; inline defaults remain usable until the transition commits.
|
|
207
|
+
startTransition(() => {
|
|
208
|
+
setMessagesByLang((prev) => ({
|
|
209
|
+
...prev,
|
|
210
|
+
[effectiveLanguage]: msgs,
|
|
211
|
+
}));
|
|
212
|
+
});
|
|
148
213
|
}
|
|
149
214
|
})
|
|
150
215
|
.catch(() => {
|
|
@@ -154,20 +219,50 @@ export function TranslationProvider({
|
|
|
154
219
|
cancelled = true;
|
|
155
220
|
};
|
|
156
221
|
}, [
|
|
157
|
-
|
|
222
|
+
effectiveLanguage,
|
|
158
223
|
loadMessages,
|
|
159
224
|
messagesByLang,
|
|
160
225
|
defaultLanguage,
|
|
161
226
|
remoteLanguages,
|
|
162
227
|
]);
|
|
163
228
|
|
|
164
|
-
//
|
|
229
|
+
// Preview locale is an ephemeral editor coordinate. It intentionally does
|
|
230
|
+
// not call setLanguage(), because doing so would persist a test condition as
|
|
231
|
+
// the app user's real language preference.
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (typeof window === 'undefined') return;
|
|
234
|
+
const onPreview = (event: Event) => {
|
|
235
|
+
const detail = (event as CustomEvent<TranslationPreviewDetail | null>)
|
|
236
|
+
.detail;
|
|
237
|
+
const nextLanguage: unknown = detail?.language;
|
|
238
|
+
const nextPseudoLocale: unknown = detail?.pseudoLocale;
|
|
239
|
+
const nextDirection: unknown = detail?.direction;
|
|
240
|
+
setPreviewLanguage(
|
|
241
|
+
typeof nextLanguage === 'string' && nextLanguage ? nextLanguage : null
|
|
242
|
+
);
|
|
243
|
+
setPreviewPseudoLocale(
|
|
244
|
+
nextPseudoLocale === 'accented' || nextPseudoLocale === 'expanded'
|
|
245
|
+
? nextPseudoLocale
|
|
246
|
+
: 'off'
|
|
247
|
+
);
|
|
248
|
+
setPreviewDirection(
|
|
249
|
+
nextDirection === 'ltr' || nextDirection === 'rtl'
|
|
250
|
+
? nextDirection
|
|
251
|
+
: 'auto'
|
|
252
|
+
);
|
|
253
|
+
};
|
|
254
|
+
window.addEventListener(TRANSLATION_PREVIEW_EVENT, onPreview);
|
|
255
|
+
return () =>
|
|
256
|
+
window.removeEventListener(TRANSLATION_PREVIEW_EVENT, onPreview);
|
|
257
|
+
}, []);
|
|
258
|
+
|
|
259
|
+
// Reflect the effective language and text direction on <html>.
|
|
165
260
|
useEffect(() => {
|
|
166
261
|
if (typeof document !== 'undefined') {
|
|
167
262
|
document.documentElement.dir = dir;
|
|
168
|
-
document.documentElement.lang =
|
|
263
|
+
document.documentElement.lang = effectiveLanguage;
|
|
169
264
|
}
|
|
170
|
-
}, [
|
|
265
|
+
}, [effectiveLanguage, dir]);
|
|
171
266
|
|
|
172
267
|
const setLanguage = useCallback(
|
|
173
268
|
(tag: string) => {
|
|
@@ -179,27 +274,44 @@ export function TranslationProvider({
|
|
|
179
274
|
[storageKey]
|
|
180
275
|
);
|
|
181
276
|
|
|
182
|
-
const
|
|
183
|
-
(
|
|
184
|
-
key
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
277
|
+
const translatorFor = useCallback(
|
|
278
|
+
(catalogs: Record<string, TranslationMessages>): TranslateFn =>
|
|
279
|
+
(key, defaultValue, params, options) => {
|
|
280
|
+
const translated = translate(
|
|
281
|
+
catalogs[effectiveLanguage] ?? {},
|
|
282
|
+
key,
|
|
283
|
+
defaultValue,
|
|
284
|
+
params,
|
|
285
|
+
options?.format ?? format,
|
|
286
|
+
effectiveLanguage
|
|
287
|
+
);
|
|
288
|
+
if (previewPseudoLocale === 'accented') {
|
|
289
|
+
return effectiveLanguage.toLowerCase() === 'en-xa'
|
|
290
|
+
? translated
|
|
291
|
+
: pseudoLocalize(translated);
|
|
292
|
+
}
|
|
293
|
+
return previewPseudoLocale === 'expanded'
|
|
294
|
+
? pseudoExpand(translated)
|
|
295
|
+
: translated;
|
|
296
|
+
},
|
|
297
|
+
[effectiveLanguage, format, previewPseudoLocale]
|
|
298
|
+
);
|
|
299
|
+
const t = useMemo(
|
|
300
|
+
() => translatorFor(messagesByLang),
|
|
301
|
+
[translatorFor, messagesByLang]
|
|
198
302
|
);
|
|
303
|
+
const tInline = useMemo(() => translatorFor(EMPTY_MESSAGES), [translatorFor]);
|
|
199
304
|
|
|
200
305
|
const value = useMemo<TranslationContextValue>(
|
|
201
|
-
() => ({
|
|
202
|
-
|
|
306
|
+
() => ({
|
|
307
|
+
t,
|
|
308
|
+
tInline,
|
|
309
|
+
language: effectiveLanguage,
|
|
310
|
+
setLanguage,
|
|
311
|
+
languages,
|
|
312
|
+
dir,
|
|
313
|
+
}),
|
|
314
|
+
[t, tInline, effectiveLanguage, setLanguage, languages, dir]
|
|
203
315
|
);
|
|
204
316
|
|
|
205
317
|
return (
|
|
@@ -219,9 +331,20 @@ function useTranslationContext(hook: string): TranslationContextValue {
|
|
|
219
331
|
/** Tolgee-compatible: `const { t } = useTranslate()`. */
|
|
220
332
|
export function useTranslate() {
|
|
221
333
|
const ctx = useTranslationContext('useTranslate');
|
|
222
|
-
|
|
334
|
+
// The server never has a catalog, and a lazy descendant can hydrate after
|
|
335
|
+
// the catalog has already reached context. Gating per consumer means every
|
|
336
|
+
// hydrating render replays the inline defaults, then re-renders with the
|
|
337
|
+
// catalog once that consumer commits.
|
|
338
|
+
const hydrated = useSyncExternalStore(
|
|
339
|
+
subscribeNever,
|
|
340
|
+
() => true,
|
|
341
|
+
() => false
|
|
342
|
+
);
|
|
343
|
+
return { t: hydrated ? ctx.t : ctx.tInline };
|
|
223
344
|
}
|
|
224
345
|
|
|
346
|
+
const subscribeNever = () => () => {};
|
|
347
|
+
|
|
225
348
|
/** Active language + switcher + direction + the available languages. */
|
|
226
349
|
export function useLanguage() {
|
|
227
350
|
const ctx = useTranslationContext('useLanguage');
|
package/src/records.ts
CHANGED
|
@@ -171,8 +171,9 @@ export interface TranslationEntryRecord {
|
|
|
171
171
|
/** Optional semantic organization for translations supplied with LINKED shapes. */
|
|
172
172
|
ofShape?: string;
|
|
173
173
|
ofProperty?: string;
|
|
174
|
+
ofResource?: string;
|
|
174
175
|
fromPackage?: string;
|
|
175
|
-
/**
|
|
176
|
+
/** Explicit app wording for a shape/property/resource/package declaration (AD-N). */
|
|
176
177
|
overridden?: boolean;
|
|
177
178
|
currentVersion?: TranslationKeyVersionRecord;
|
|
178
179
|
versions?: TranslationKeyVersionRecord[];
|
package/src/release.ts
CHANGED
|
@@ -2,7 +2,11 @@ import type { TranslationLanguageDefinition } from './languages.js';
|
|
|
2
2
|
import { runChecks, type CheckFinding } from './checks.js';
|
|
3
3
|
import { compileLanguage } from './compile.js';
|
|
4
4
|
import type { TranslationMessages } from './core/messages.js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
classifyKeyVersion,
|
|
7
|
+
createArgumentSignature,
|
|
8
|
+
sha256Hex,
|
|
9
|
+
} from './key-version.js';
|
|
6
10
|
import {
|
|
7
11
|
isConfirmedBuildDeclaration,
|
|
8
12
|
type TranslationBuildDeclaration,
|
|
@@ -183,7 +187,9 @@ export async function translationContractSetHash(
|
|
|
183
187
|
|
|
184
188
|
/**
|
|
185
189
|
* Resolve keys extracted from one immutable build workspace to graph versions.
|
|
186
|
-
*
|
|
190
|
+
* Missing/orphaned/conflicting/default-drifted keys fail before publication.
|
|
191
|
+
* Explicit app-owned declaration overrides may change wording, never the
|
|
192
|
+
* exact workspace's declared format or runtime-argument contract.
|
|
187
193
|
*/
|
|
188
194
|
export async function buildSnapshotContractSetHash(
|
|
189
195
|
entries: TranslationEntryRecord[],
|
|
@@ -245,7 +251,29 @@ export async function buildSnapshotContractSetHash(
|
|
|
245
251
|
}
|
|
246
252
|
const extractedContract = [...distinct.values()][0];
|
|
247
253
|
const decision = classifyKeyVersion(current, extractedContract);
|
|
248
|
-
|
|
254
|
+
const explicitDeclarationOverride =
|
|
255
|
+
entry.overridden === true &&
|
|
256
|
+
!!(
|
|
257
|
+
entry.ofShape ||
|
|
258
|
+
entry.ofProperty ||
|
|
259
|
+
entry.ofResource ||
|
|
260
|
+
entry.fromPackage
|
|
261
|
+
);
|
|
262
|
+
// source-changed is reached only AFTER the existing classifier has checked
|
|
263
|
+
// identical format and argument signature. Do not infer the missing format
|
|
264
|
+
// of a legacy extraction from the very app override being verified.
|
|
265
|
+
const compatibleAppWording =
|
|
266
|
+
explicitDeclarationOverride &&
|
|
267
|
+
decision.reason === 'source-changed' &&
|
|
268
|
+
(declarationsByKey.get(entry.key) ?? []).every(
|
|
269
|
+
(item) =>
|
|
270
|
+
item.format !== undefined && item.namespace === entry.namespace
|
|
271
|
+
) &&
|
|
272
|
+
entry.sourceText === current.sourceText &&
|
|
273
|
+
entry.format === current.format &&
|
|
274
|
+
createArgumentSignature(current.sourceText, current.format) ===
|
|
275
|
+
current.argumentSignature;
|
|
276
|
+
if (decision.action !== 'unchanged' && !compatibleAppWording) {
|
|
249
277
|
throw new Error(
|
|
250
278
|
`Build translation source for "${entry.key}" does not match its current key version (${decision.reason}).`
|
|
251
279
|
);
|
package/src/shape-catalog.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { TranslationDiscoverySource } from './discovery.js';
|
|
|
7
7
|
/**
|
|
8
8
|
* Code-canonical translations shipped beside one reusable LINKED shape.
|
|
9
9
|
* Packages may expose this object from code or serialize the same contract as
|
|
10
|
-
* JSON for
|
|
10
|
+
* JSON for a host's discovery tooling without executing package code.
|
|
11
11
|
*/
|
|
12
12
|
export interface ShapeTranslationCatalog {
|
|
13
13
|
schemaVersion: 1;
|