@_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.
Files changed (63) hide show
  1. package/README.md +18 -17
  2. package/lib/esm/archive.d.ts +2 -2
  3. package/lib/esm/archive.js +2 -2
  4. package/lib/esm/archive.js.map +1 -1
  5. package/lib/esm/authorization.d.ts +1 -1
  6. package/lib/esm/authorization.js +1 -1
  7. package/lib/esm/authorization.js.map +1 -1
  8. package/lib/esm/cdn-loader.d.ts +1 -1
  9. package/lib/esm/cdn-loader.js.map +1 -1
  10. package/lib/esm/core/messages.d.ts +14 -1
  11. package/lib/esm/core/messages.js +33 -2
  12. package/lib/esm/core/messages.js.map +1 -1
  13. package/lib/esm/exchange.d.ts +1 -1
  14. package/lib/esm/exchange.js +1 -1
  15. package/lib/esm/exchange.js.map +1 -1
  16. package/lib/esm/export.js +4 -1
  17. package/lib/esm/export.js.map +1 -1
  18. package/lib/esm/formats/xliff.js +1 -1
  19. package/lib/esm/formats/xliff.js.map +1 -1
  20. package/lib/esm/key-sync.d.ts +12 -0
  21. package/lib/esm/key-sync.js +32 -7
  22. package/lib/esm/key-sync.js.map +1 -1
  23. package/lib/esm/mt.js +1 -1
  24. package/lib/esm/mt.js.map +1 -1
  25. package/lib/esm/ontologies/translation.d.ts +3 -1
  26. package/lib/esm/ontologies/translation.js +4 -2
  27. package/lib/esm/ontologies/translation.js.map +1 -1
  28. package/lib/esm/package.js +4 -9
  29. package/lib/esm/package.js.map +1 -1
  30. package/lib/esm/react.d.ts +38 -2
  31. package/lib/esm/react.js +98 -15
  32. package/lib/esm/react.js.map +1 -1
  33. package/lib/esm/records.d.ts +2 -1
  34. package/lib/esm/records.js.map +1 -1
  35. package/lib/esm/release.d.ts +3 -1
  36. package/lib/esm/release.js +21 -4
  37. package/lib/esm/release.js.map +1 -1
  38. package/lib/esm/shape-catalog.d.ts +1 -1
  39. package/lib/esm/shape-catalog.js.map +1 -1
  40. package/lib/esm/shapes/TranslationKey.d.ts +10 -5
  41. package/lib/esm/shapes/TranslationKey.js +16 -5
  42. package/lib/esm/shapes/TranslationKey.js.map +1 -1
  43. package/lib/esm/shapes/TranslationProvider.d.ts +25 -8
  44. package/lib/esm/shapes/TranslationProvider.js +50 -19
  45. package/lib/esm/shapes/TranslationProvider.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/archive.ts +2 -2
  48. package/src/authorization.ts +1 -1
  49. package/src/cdn-loader.ts +1 -1
  50. package/src/core/messages.ts +35 -2
  51. package/src/exchange.ts +1 -1
  52. package/src/export.ts +4 -1
  53. package/src/formats/xliff.ts +1 -1
  54. package/src/key-sync.ts +46 -6
  55. package/src/mt.ts +1 -1
  56. package/src/ontologies/translation.ts +4 -2
  57. package/src/package.ts +4 -9
  58. package/src/react.tsx +158 -35
  59. package/src/records.ts +2 -1
  60. package/src/release.ts +31 -3
  61. package/src/shape-catalog.ts +1 -1
  62. package/src/shapes/TranslationKey.ts +14 -5
  63. 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 named the manifest — and the archive format
30
- * itself — after Create Now. Both spellings are read; only the current one is
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
  */
@@ -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
- * (Create Now evaluates its translator assignments and project roles here).
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 CN import): the caller wires `base`/`devApi` for its environment.
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';
@@ -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 Translation Studio reports the syntax error for repair.
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
- return resolvedFormat === 'icu'
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 required for CN exports. */
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 || entry.ofProperty || entry.fromPackage
98
+ entry.ofShape ||
99
+ entry.ofProperty ||
100
+ entry.ofResource ||
101
+ entry.fromPackage
99
102
  ? 'semantic'
100
103
  : 'ui',
101
104
  sourceText: version.sourceText,
@@ -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 a Create Now URI and a `cn:` prefix; the reader below still accepts
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
- list(data: { appId: string }): Promise<Array<{ key: string; sourceText?: string }>>;
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: [], updated: [], unchanged: [], orphaned: [], conflicts: [],
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 (!current) report.created.push(key);
154
- else if (current.sourceText !== sourceText) report.updated.push(key);
155
- else {
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({ appId, key, sourceText, kind: 'ui' });
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 (Create Now) and are
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
- * (Create Now arch-02, "Public ontology namespace"). So terms are
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 Create Now's cn-main) ────
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
- // Reusable LINKED translation package. Uses a HOST-NEUTRAL identifier root
4
- // (NOT CN's id.create.now), so the shape/ontology IRIs baked into every app's
5
- // dataset stay stable when this package is later extracted to its own repo
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 CN's API, prod to the bundled `{lang}.json` with a
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
- key: string,
46
- defaultValue?: string,
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
- languages.find((item) => item.tag === language)?.direction ??
102
- directionFor(language);
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[language]) return;
193
+ if (messagesByLang[effectiveLanguage]) return;
137
194
  let cancelled = false;
138
195
  const chain = languageFallbacks(
139
- language,
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
- setMessagesByLang((prev) => ({ ...prev, [language]: msgs }));
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
- language,
222
+ effectiveLanguage,
158
223
  loadMessages,
159
224
  messagesByLang,
160
225
  defaultLanguage,
161
226
  remoteLanguages,
162
227
  ]);
163
228
 
164
- // Reflect the configured language and text direction on <html>.
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 = language;
263
+ document.documentElement.lang = effectiveLanguage;
169
264
  }
170
- }, [language, dir]);
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 t = useCallback(
183
- (
184
- key: string,
185
- defaultValue?: string,
186
- params?: Record<string, unknown>,
187
- options?: TranslationCallOptions
188
- ) =>
189
- translate(
190
- messagesByLang[language] ?? {},
191
- key,
192
- defaultValue,
193
- params,
194
- options?.format ?? format,
195
- language
196
- ),
197
- [messagesByLang, language, format]
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
- () => ({ t, language, setLanguage, languages, dir }),
202
- [t, language, setLanguage, languages, dir]
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
- return { t: ctx.t };
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
- /** This app diverged a shape-owned key locally; shape propagation skips it (AD-N). */
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 { classifyKeyVersion, sha256Hex } from './key-version.js';
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
- * Any missing/orphaned/conflicting/default-drifted key fails before publication.
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
- if (decision.action !== 'unchanged') {
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
  );
@@ -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 Host Agent discovery without executing package code.
10
+ * JSON for a host's discovery tooling without executing package code.
11
11
  */
12
12
  export interface ShapeTranslationCatalog {
13
13
  schemaVersion: 1;