@cliff-format/i18n 0.0.0-stage → 0.2.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 CHANGED
@@ -1,3 +1,228 @@
1
- # Temporary Holding Version
1
+ # @cliff-format/i18n
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ CLIFF-native runtime localization for the browser. It reads and writes CLIFF documents directly:
4
+ there is no JSON resource layer, and a type/emotion/context field is part of the message rather
5
+ than an external comment.
6
+
7
+ ## Install
8
+
9
+ ~~~bash
10
+ pnpm add @cliff-format/core @cliff-format/i18n
11
+ ~~~
12
+
13
+ The main entry bundles a full ICU runtime. Use `@cliff-format/i18n/lite` when you need only
14
+ simple placeholders, or `@cliff-format/i18n/react` for React 18+.
15
+
16
+ ## Quick start
17
+
18
+ ~~~ts
19
+ import { createI18n, TYPE, EMOTION } from "@cliff-format/i18n";
20
+
21
+ const i18n = createI18n({
22
+ locale: "zh-CN",
23
+ fallbackLocale: "en-US",
24
+ resources: [cliffText],
25
+ });
26
+
27
+ const { t } = i18n.scope({ namespace: "demo", clan: "settings", group: "video" });
28
+
29
+ const label = t("resolution").render();
30
+ const authored = t("contrast")
31
+ .source("Contrast")
32
+ .type(TYPE.noun)
33
+ .emotion(EMOTION.objective)
34
+ .render();
35
+ ~~~
36
+
37
+ The scope is mandatory. CLIFF requires a namespace and a clan in every document, so the runtime
38
+ does not hide defaults:
39
+
40
+ ~~~ts
41
+ // compile error: clan is required
42
+ i18n.scope({ namespace: "demo" });
43
+ ~~~
44
+
45
+ ## Entry IDs
46
+
47
+ An entry starts with `<id>` and contains fields such as `source`, `target`, `type` and
48
+ `status`. Its canonical ID is `namespace.clan.group-path.entry-id`.
49
+
50
+ Pass a canonical ID, a scoped short form, or an entry ID object to `t()` or `i18n.resolve()`.
51
+
52
+ ## Entry ID syntax
53
+
54
+ The resolver accepts these forms:
55
+
56
+ | Form | Example |
57
+ | --- | --- |
58
+ | Colon namespace | `demo:settings.video.resolution` |
59
+ | CLIFF canonical | `demo.settings.video.resolution` |
60
+ | Friendly path | `demo/settings/video/resolution` |
61
+ | Scope clan | `settings.video.resolution` |
62
+ | Scope group | `video.resolution` |
63
+ | Unique entry | `resolution` |
64
+
65
+ If an entry ID is ambiguous, the default `strictEntryIds: true` throws `CliffAmbiguousEntryError`. Set it
66
+ to `false` only when a deterministic diagnostic-assisted choice is acceptable.
67
+
68
+ Define aliases explicitly:
69
+
70
+ ~~~ts
71
+ createI18n({
72
+ locale: "zh-CN",
73
+ aliases: { "home-title": "demo:marketing.home.hero-title" },
74
+ resources: [cliffText],
75
+ });
76
+ ~~~
77
+
78
+ ## Authoring states
79
+
80
+ `t("home.title")` returns a builder. Calling `.source()` enters authoring mode; `render()` and
81
+ `commit()` do not exist until a type is supplied.
82
+
83
+ ~~~ts
84
+ // A: explicit type, the default
85
+ t("contrast").source("Contrast").type(TYPE.noun).render();
86
+
87
+ // B: explicit group inheritance, only when the group already has a type
88
+ t("contrast").source("Contrast").typeFromGroup().render();
89
+
90
+ // @ts-expect-error render is unavailable before type()
91
+ t("contrast").source("Contrast").render();
92
+ ~~~
93
+
94
+ A draft is created on `render()` or `commit()`, not when the builder is created. It is a CLIFF
95
+ entry with `status: initial`; `i18n.toCliff()` exports it. Lookup needs neither `source()` nor
96
+ `type()`:
97
+
98
+ ~~~ts
99
+ t("resolution").render();
100
+ ~~~
101
+
102
+ ## CLIFF import and export
103
+
104
+ ~~~ts
105
+ i18n.add("CLIFF 1.1\n...", { sourcePath: "zh-CN/settings.cliff" });
106
+ i18n.add(await file.text(), { tolerant: true });
107
+
108
+ i18n.files(); // [{ filePath, namespace, clan, locale, text, document }]
109
+ i18n.toCliff(); // one serialized CLIFF document
110
+ ~~~
111
+
112
+ `filePath` and `sourcePath` are runtime/build metadata; the serializer does not write them.
113
+
114
+ Entries whose target is missing or `status: initial` fall back to the source text. A fallback
115
+ locale is used before the source-language record when it has a target.
116
+
117
+ ## Status and queries
118
+
119
+ The builder reconciles status on writes without promoting through review:
120
+
121
+ - adding a target to an initial entry → `translated`;
122
+ - reworking the target of a `reviewed`/`final` entry → `translated`;
123
+ - changing the source through `commit({ overwrite: true })` → `translated`;
124
+ - an explicit `.status(...)` always wins;
125
+ - a missing target means `initial`.
126
+
127
+ Review and final are deliberate states: the library never promotes to `reviewed` or `final` by
128
+ itself. The core validator still enforces that `translated`, `reviewed` and `final` require a
129
+ target.
130
+
131
+ ~~~ts
132
+ i18n.untranslated({ namespace: "demo", clan: "settings" });
133
+ i18n.byStatus("reviewed", { namespace: "demo", clan: "settings" });
134
+ i18n.query({ status: ["initial", "translated"], target: "present" });
135
+ i18n.statusCounts({ namespace: "demo", clan: "settings" });
136
+ ~~~
137
+
138
+ `locale` defaults to `"all"`. Pass a BCP 47 tag to restrict a query to one target language.
139
+
140
+ Fixed vocabularies are exported as enum-like const objects and guards:
141
+
142
+ ~~~ts
143
+ TYPE.label; // "label"
144
+ EMOTION.objective; // "objective"
145
+ STATUS.final; // "final"
146
+ VARIANT.glossary; // "glossary"
147
+ isStatusTag("final"); // true
148
+ isVariantTag("glossary"); // true
149
+ canTransitionStatus("reviewed", "final"); // true
150
+ ~~~
151
+
152
+ ## ICU
153
+
154
+ The full entry point uses:
155
+
156
+ - [messageformat](https://messageformat.github.io/) for MessageFormat 2 (LDML 48);
157
+ - [@messageformat/icu-messageformat-1](https://github.com/messageformat/messageformat/blob/main/mf2/icu-messageformat-1/README.md)
158
+ to compile MF1 into the same formatter.
159
+
160
+ Dialect detection follows CLIFF section 14, with an MF1 precedence check for plural/select
161
+ messages whose closing braces look like MF2 markers.
162
+
163
+ ~~~ts
164
+ i18n.format("{count, plural, one {# message} other {# messages}}", { count: 2 });
165
+ i18n.formatToParts("{#b}Hello{/b} {$name}", { name: "Alice" });
166
+ ~~~
167
+
168
+ `format()` returns text without invisible bidi-isolation characters. `formatToParts()` returns
169
+ `literal`, `expression` and `markup` parts for safe framework rendering. The lite entry reports
170
+ `icu-unsupported` instead of pretending to format a complex message.
171
+
172
+ ## React
173
+
174
+ ~~~tsx
175
+ import { I18nProvider, useTranslation } from "@cliff-format/i18n/react";
176
+
177
+ createRoot(root).render(
178
+ <I18nProvider i18n={i18n}>
179
+ <App />
180
+ </I18nProvider>,
181
+ );
182
+
183
+ function Home() {
184
+ const { t, i18n } = useTranslation({
185
+ namespace: "demo",
186
+ clan: "marketing",
187
+ group: "home",
188
+ });
189
+ return <h1>{t("hero-title").render()}</h1>;
190
+ }
191
+ ~~~
192
+
193
+ `useTranslation` returns a scope-bound `t` and the controller. It re-renders on locale changes via
194
+ `useSyncExternalStore`. The core package does not depend on React.
195
+
196
+ Use **one hook per clan** for a page that spans several groups; put the group path in the key:
197
+
198
+ ~~~tsx
199
+ const { t } = useTranslation({ namespace: "demo", clan: "marketing" });
200
+ t("home.title").render();
201
+ t("features.context-title").render();
202
+ t("format.line-1").render();
203
+ ~~~
204
+
205
+ The `group` option is a convenience prefix for a component whose entry IDs all belong to one group. Do
206
+ not call `useTranslation` once per section in the same component: each call creates another scoped
207
+ translator and another locale subscription.
208
+
209
+ ## Diagnostics
210
+
211
+ `onDiagnostic` and `i18n.diagnostics()` report `missing-key`, `ambiguous-key`,
212
+ `type-mismatch`, `emotion-mismatch`, `source-mismatch`, `width-exceeded`, `icu-error`,
213
+ `draft-created` and related codes. `missing` accepts `"key"`, `"empty"`, `"throw"` or
214
+ `"source"`.
215
+
216
+ ## API
217
+
218
+ ```ts
219
+ createI18n(options): I18n
220
+ i18n.scope({ namespace, clan, group? }): { t, scope }
221
+ i18n.add(resource, options?): AddResult
222
+ i18n.files(options?): CliffFile[]
223
+ i18n.toCliff(options?): string
224
+ i18n.setLocale(locale) / changeLanguage(locale)
225
+ i18n.subscribe(listener): () => void
226
+ i18n.format(message, params?, options?) / formatToParts(...)
227
+ i18n.validate() / diagnostics()
228
+ ```