@aethervtt/shard-text 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/build.d.ts +43 -0
- package/dist/build.js +106 -0
- package/dist/charset.d.ts +8 -0
- package/dist/charset.js +43 -0
- package/dist/components.d.ts +54 -0
- package/dist/components.js +118 -0
- package/dist/font.d.ts +193 -0
- package/dist/font.js +377 -0
- package/dist/importer.d.ts +40 -0
- package/dist/importer.js +193 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +140 -0
- package/dist/kerning.d.ts +8 -0
- package/dist/kerning.js +168 -0
- package/dist/layout.d.ts +84 -0
- package/dist/layout.js +297 -0
- package/dist/locale.d.ts +133 -0
- package/dist/locale.js +574 -0
- package/dist/msdf.d.ts +52 -0
- package/dist/msdf.js +1147 -0
- package/dist/opentype.d.ts +36 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +43 -0
- package/dist/preview.d.ts +2 -0
- package/dist/preview.js +76 -0
- package/dist/render.d.ts +114 -0
- package/dist/render.js +544 -0
- package/dist/shaders.d.ts +2 -0
- package/dist/shaders.js +158 -0
- package/dist/shape.d.ts +77 -0
- package/dist/shape.js +404 -0
- package/dist/source.d.ts +44 -0
- package/dist/source.js +108 -0
- package/package.json +40 -0
package/dist/locale.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AssetStore,
|
|
3
|
+
assetServer,
|
|
4
|
+
defineAssetSchema,
|
|
5
|
+
defineAssetType,
|
|
6
|
+
defineImporter
|
|
7
|
+
} from "@aethervtt/shard-assets";
|
|
8
|
+
import {
|
|
9
|
+
defineResource,
|
|
10
|
+
defineSchema,
|
|
11
|
+
defineSystem,
|
|
12
|
+
isPlainObject,
|
|
13
|
+
PostUpdate,
|
|
14
|
+
pointer,
|
|
15
|
+
ShardError,
|
|
16
|
+
t
|
|
17
|
+
} from "@aethervtt/shard-core";
|
|
18
|
+
import { LogResource } from "@aethervtt/shard-runtime";
|
|
19
|
+
import { Localized, ScreenText, Text } from "./components.js";
|
|
20
|
+
const PLURAL_CATEGORIES = ["zero", "one", "two", "few", "many", "other"];
|
|
21
|
+
class StringTable {
|
|
22
|
+
locale;
|
|
23
|
+
strings;
|
|
24
|
+
version = 0;
|
|
25
|
+
constructor(locale, strings) {
|
|
26
|
+
this.locale = locale;
|
|
27
|
+
this.strings = strings;
|
|
28
|
+
}
|
|
29
|
+
copyFrom(other) {
|
|
30
|
+
this.locale = other.locale;
|
|
31
|
+
this.strings = other.strings;
|
|
32
|
+
this.version++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const StringTables = defineResource(
|
|
36
|
+
"text/StringTables",
|
|
37
|
+
{
|
|
38
|
+
description: "Loaded string tables (locales/*.strings.json) by guid.",
|
|
39
|
+
init: () => new AssetStore("StringTable")
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
const StringTableAssetType = defineAssetType("StringTable", {
|
|
43
|
+
store: StringTables,
|
|
44
|
+
load: (artifact) => {
|
|
45
|
+
const json = artifact.json;
|
|
46
|
+
return new StringTable(json.locale, new Map(Object.entries(json.strings)));
|
|
47
|
+
},
|
|
48
|
+
update: (existing, next) => existing.copyFrom(next)
|
|
49
|
+
});
|
|
50
|
+
function localeOfPath(path) {
|
|
51
|
+
const name = path.slice(path.lastIndexOf("/") + 1).replace(/\.strings\.json$/i, "");
|
|
52
|
+
const tag = name.slice(name.lastIndexOf(".") + 1);
|
|
53
|
+
try {
|
|
54
|
+
return Intl.getCanonicalLocales(tag)[0];
|
|
55
|
+
} catch {
|
|
56
|
+
return void 0;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const PLACEHOLDER = /\{([A-Za-z_][\w-]*)\}/g;
|
|
60
|
+
function placeholdersOf(entry) {
|
|
61
|
+
const names = /* @__PURE__ */ new Set();
|
|
62
|
+
const texts = typeof entry === "string" ? [entry] : Object.values(entry);
|
|
63
|
+
for (const text of texts) {
|
|
64
|
+
if (typeof text !== "string") continue;
|
|
65
|
+
for (const m of text.matchAll(PLACEHOLDER)) names.add(m[1]);
|
|
66
|
+
}
|
|
67
|
+
return [...names].sort();
|
|
68
|
+
}
|
|
69
|
+
function validateStringTable(json) {
|
|
70
|
+
const errors = [];
|
|
71
|
+
if (!isPlainObject(json)) {
|
|
72
|
+
return [
|
|
73
|
+
new ShardError("locale/invalid-table", "A string table must be a JSON object", {
|
|
74
|
+
path: "",
|
|
75
|
+
hint: 'Map keys to strings: { "hud.fuel": "Fuel: {amount}%" }.'
|
|
76
|
+
})
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
for (const [key, value] of Object.entries(json)) {
|
|
80
|
+
if (key === "$schema") continue;
|
|
81
|
+
const at = pointer("", key);
|
|
82
|
+
if (typeof value === "string") continue;
|
|
83
|
+
if (!isPlainObject(value)) {
|
|
84
|
+
errors.push(
|
|
85
|
+
new ShardError("schema/type-mismatch", `"${key}" must be a string or plural forms`, {
|
|
86
|
+
path: at,
|
|
87
|
+
hint: 'Use "text", or { "one": "{n} item", "other": "{n} items" } for plurals.'
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
for (const [category, text] of Object.entries(value)) {
|
|
93
|
+
if (!PLURAL_CATEGORIES.includes(category)) {
|
|
94
|
+
errors.push(
|
|
95
|
+
new ShardError("locale/bad-plural", `"${category}" isn't a plural category in "${key}"`, {
|
|
96
|
+
path: pointer(at, category),
|
|
97
|
+
hint: `Plural forms are ${PLURAL_CATEGORIES.join(", ")} (Intl.PluralRules categories).`
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
} else if (typeof text !== "string") {
|
|
101
|
+
errors.push(
|
|
102
|
+
new ShardError("schema/type-mismatch", `"${key}".${category} must be a string`, {
|
|
103
|
+
path: pointer(at, category)
|
|
104
|
+
})
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (typeof value.other !== "string") {
|
|
109
|
+
errors.push(
|
|
110
|
+
new ShardError("locale/bad-plural", `Plural forms of "${key}" need "other"`, {
|
|
111
|
+
path: at,
|
|
112
|
+
hint: '"other" is the form every locale falls back to.'
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return errors;
|
|
118
|
+
}
|
|
119
|
+
const NoSettings = defineSchema("text/StringTableSettings", {}, { description: "No settings." });
|
|
120
|
+
const StringTableImporter = defineImporter({
|
|
121
|
+
name: "strings",
|
|
122
|
+
version: 1,
|
|
123
|
+
extensions: [".strings.json"],
|
|
124
|
+
settings: NoSettings,
|
|
125
|
+
async import(source) {
|
|
126
|
+
const locale = localeOfPath(source.path);
|
|
127
|
+
if (!locale) {
|
|
128
|
+
throw new ShardError("locale/bad-locale", `${source.path} doesn't name a locale`, {
|
|
129
|
+
path: "",
|
|
130
|
+
hint: "Name tables by BCP 47 tag: locales/en.strings.json, locales/pt-BR.strings.json (or hud.pt-BR.strings.json)."
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
let json;
|
|
134
|
+
try {
|
|
135
|
+
json = JSON.parse(source.text());
|
|
136
|
+
} catch (cause) {
|
|
137
|
+
throw new ShardError("assets/import-failed", `${source.path} isn't valid JSON`, {
|
|
138
|
+
path: "",
|
|
139
|
+
cause
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const errors = validateStringTable(json);
|
|
143
|
+
if (errors.length > 0) {
|
|
144
|
+
throw new ShardError(errors[0].code, `${source.path}: ${errors[0].message}`, {
|
|
145
|
+
path: errors[0].path,
|
|
146
|
+
hint: errors[0].hint,
|
|
147
|
+
details: errors
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const { $schema: _, ...strings } = json;
|
|
151
|
+
return {
|
|
152
|
+
assets: [
|
|
153
|
+
{
|
|
154
|
+
label: "",
|
|
155
|
+
type: "StringTable",
|
|
156
|
+
json: { locale, strings },
|
|
157
|
+
info: { locale, keys: Object.keys(strings).length }
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
const stringTableSchema = defineAssetSchema("strings.schema.json", () => ({
|
|
164
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
165
|
+
title: "Shard string table",
|
|
166
|
+
description: "locales/<locale>.strings.json: key \u2192 string. {name} is a placeholder filled from params; an object picks a plural form by n (or count).",
|
|
167
|
+
type: "object",
|
|
168
|
+
properties: { $schema: { type: "string" } },
|
|
169
|
+
additionalProperties: {
|
|
170
|
+
oneOf: [
|
|
171
|
+
{ type: "string" },
|
|
172
|
+
{
|
|
173
|
+
type: "object",
|
|
174
|
+
properties: Object.fromEntries(PLURAL_CATEGORIES.map((c) => [c, { type: "string" }])),
|
|
175
|
+
required: ["other"],
|
|
176
|
+
additionalProperties: false
|
|
177
|
+
}
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
}));
|
|
181
|
+
const Locale = defineResource("text/Locale", {
|
|
182
|
+
description: 'The current locale (current, e.g. "pt-BR") and the last one tried (fallback, "en"). Set current to switch language: keyed text updates on the next frame.',
|
|
183
|
+
init: () => ({ current: "en", fallback: "en" })
|
|
184
|
+
});
|
|
185
|
+
function localeChain(current, fallback) {
|
|
186
|
+
const out = [];
|
|
187
|
+
const add = (tag) => {
|
|
188
|
+
if (tag !== "" && !out.includes(tag)) out.push(tag);
|
|
189
|
+
};
|
|
190
|
+
add(current);
|
|
191
|
+
const dash = current.indexOf("-");
|
|
192
|
+
if (dash > 0) add(current.slice(0, dash));
|
|
193
|
+
add(fallback);
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
class LocaleStore {
|
|
197
|
+
/** Bumped when a string table loads, changes, or goes away. */
|
|
198
|
+
revision = 0;
|
|
199
|
+
/** The chain tried for the current locale. */
|
|
200
|
+
chain = [];
|
|
201
|
+
/** Bumped when the chain changes (the locale was switched). */
|
|
202
|
+
chainRevision = 0;
|
|
203
|
+
chainFor = "\0";
|
|
204
|
+
merged;
|
|
205
|
+
plurals = /* @__PURE__ */ new Map();
|
|
206
|
+
numbers = /* @__PURE__ */ new Map();
|
|
207
|
+
/** Keys requested at runtime that no locale in the chain had, by locale. */
|
|
208
|
+
missing = /* @__PURE__ */ new Map();
|
|
209
|
+
/** Components whose `key` and `params` fields localize (UiText, Text, ScreenText). */
|
|
210
|
+
targets = [];
|
|
211
|
+
/** The resolved string's locale after `resolve` ('' if the key was missing). */
|
|
212
|
+
resolvedLocale = "";
|
|
213
|
+
/** Recomputes the chain if the locale changed. True if it did. */
|
|
214
|
+
sync(locale) {
|
|
215
|
+
const id = `${locale.current}|${locale.fallback}`;
|
|
216
|
+
if (id === this.chainFor) return false;
|
|
217
|
+
this.chainFor = id;
|
|
218
|
+
this.chain = localeChain(locale.current, locale.fallback);
|
|
219
|
+
this.chainRevision++;
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
/** Every loaded table's strings, merged per locale (rebuilt after a table changes). */
|
|
223
|
+
tables(world) {
|
|
224
|
+
if (this.merged) return this.merged;
|
|
225
|
+
const out = /* @__PURE__ */ new Map();
|
|
226
|
+
const server = assetServer(world);
|
|
227
|
+
const store = world.tryResource(StringTables);
|
|
228
|
+
for (const entry of server.all("StringTable")) {
|
|
229
|
+
const table = store?.byGuid(entry.guid);
|
|
230
|
+
if (!table) {
|
|
231
|
+
if (entry.state === "unloaded") {
|
|
232
|
+
server.pin(entry.guid, PIN);
|
|
233
|
+
void server.request(entry.guid);
|
|
234
|
+
}
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
let m = out.get(table.locale);
|
|
238
|
+
if (!m) {
|
|
239
|
+
m = { strings: /* @__PURE__ */ new Map(), source: /* @__PURE__ */ new Map() };
|
|
240
|
+
out.set(table.locale, m);
|
|
241
|
+
}
|
|
242
|
+
for (const [key, value] of table.strings) {
|
|
243
|
+
m.strings.set(key, value);
|
|
244
|
+
m.source.set(key, entry.path);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
this.merged = out;
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
invalidate() {
|
|
251
|
+
this.merged = void 0;
|
|
252
|
+
this.revision++;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The string for `key` in the first locale of the chain that has it, formatted with `params`.
|
|
256
|
+
* Undefined if none has it (the miss is recorded and logged once).
|
|
257
|
+
*/
|
|
258
|
+
resolve(world, key, params) {
|
|
259
|
+
const tables = this.tables(world);
|
|
260
|
+
for (let i = 0; i < this.chain.length; i++) {
|
|
261
|
+
const locale = this.chain[i];
|
|
262
|
+
const entry = tables.get(locale)?.strings.get(key);
|
|
263
|
+
if (entry === void 0) continue;
|
|
264
|
+
this.resolvedLocale = locale;
|
|
265
|
+
return this.format(entry, this.chain[0], params);
|
|
266
|
+
}
|
|
267
|
+
this.resolvedLocale = "";
|
|
268
|
+
const current = this.chain[0] ?? "";
|
|
269
|
+
let keys = this.missing.get(current);
|
|
270
|
+
if (!keys) {
|
|
271
|
+
keys = /* @__PURE__ */ new Set();
|
|
272
|
+
this.missing.set(current, keys);
|
|
273
|
+
}
|
|
274
|
+
if (!keys.has(key)) {
|
|
275
|
+
keys.add(key);
|
|
276
|
+
world.tryResource(LogResource)?.log("warn", `No string for "${key}" in ${this.chain.join(" \u2192 ")}`, {
|
|
277
|
+
code: "locale/missing-key",
|
|
278
|
+
hint: `Add "${key}" to locales/${current}.strings.json (or the fallback locale's table).`
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return void 0;
|
|
282
|
+
}
|
|
283
|
+
/** Formats one entry: picks the plural form, then fills `{name}` placeholders. */
|
|
284
|
+
format(entry, locale, params) {
|
|
285
|
+
const values = isPlainObject(params) ? params : void 0;
|
|
286
|
+
let text;
|
|
287
|
+
if (typeof entry === "string") text = entry;
|
|
288
|
+
else {
|
|
289
|
+
const n = pluralCount(values);
|
|
290
|
+
const category = n === 0 && entry.zero !== void 0 ? "zero" : this.pluralRules(locale).select(n);
|
|
291
|
+
text = entry[category] ?? entry.other ?? "";
|
|
292
|
+
}
|
|
293
|
+
if (!values || text.indexOf("{") === -1) return text;
|
|
294
|
+
return text.replace(PLACEHOLDER, (whole, name) => {
|
|
295
|
+
const v = values[name];
|
|
296
|
+
if (typeof v === "number") return this.numberFormat(locale).format(v);
|
|
297
|
+
if (typeof v === "string" || typeof v === "boolean") return String(v);
|
|
298
|
+
return whole;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
pluralRules(locale) {
|
|
302
|
+
let rules = this.plurals.get(locale);
|
|
303
|
+
if (!rules) {
|
|
304
|
+
rules = new Intl.PluralRules(locale);
|
|
305
|
+
this.plurals.set(locale, rules);
|
|
306
|
+
}
|
|
307
|
+
return rules;
|
|
308
|
+
}
|
|
309
|
+
numberFormat(locale) {
|
|
310
|
+
let format = this.numbers.get(locale);
|
|
311
|
+
if (!format) {
|
|
312
|
+
format = new Intl.NumberFormat(locale);
|
|
313
|
+
this.numbers.set(locale, format);
|
|
314
|
+
}
|
|
315
|
+
return format;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function pluralCount(params) {
|
|
319
|
+
if (!params) return 0;
|
|
320
|
+
if (typeof params.n === "number") return params.n;
|
|
321
|
+
if (typeof params.count === "number") return params.count;
|
|
322
|
+
for (const k in params) if (typeof params[k] === "number") return params[k];
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
const LocaleState = defineResource("text/LocaleState", {
|
|
326
|
+
description: "Merged string tables, plural rules, and missing keys (localization internals)."
|
|
327
|
+
});
|
|
328
|
+
function tr(world, key, params) {
|
|
329
|
+
const store = world.tryResource(LocaleState);
|
|
330
|
+
if (!store) return key;
|
|
331
|
+
store.sync(world.resource(Locale));
|
|
332
|
+
return store.resolve(world, key, params) ?? key;
|
|
333
|
+
}
|
|
334
|
+
function setLocale(world, locale) {
|
|
335
|
+
let tag;
|
|
336
|
+
try {
|
|
337
|
+
tag = Intl.getCanonicalLocales(locale)[0];
|
|
338
|
+
} catch {
|
|
339
|
+
tag = void 0;
|
|
340
|
+
}
|
|
341
|
+
if (!tag) {
|
|
342
|
+
throw new ShardError("locale/bad-locale", `"${locale}" isn't a locale`, {
|
|
343
|
+
hint: 'Use a BCP 47 tag like "en", "pt-BR", or "ja".'
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
world.resource(Locale).current = tag;
|
|
347
|
+
}
|
|
348
|
+
function localizeComponent(world, def) {
|
|
349
|
+
const store = world.resource(LocaleState);
|
|
350
|
+
if (!store.targets.some((x) => x.def === def)) store.targets.push({ def, query: void 0 });
|
|
351
|
+
}
|
|
352
|
+
const localizeText = defineSystem({
|
|
353
|
+
name: "text/localize",
|
|
354
|
+
description: "Resolves UiText, Text, and ScreenText keys in the current locale.",
|
|
355
|
+
setup: () => ({ revision: -1, chain: -1 }),
|
|
356
|
+
run: (s, world, ctx) => {
|
|
357
|
+
const store = world.resource(LocaleState);
|
|
358
|
+
store.sync(world.resource(Locale));
|
|
359
|
+
const all = store.chainRevision !== s.chain || store.revision !== s.revision;
|
|
360
|
+
s.revision = store.revision;
|
|
361
|
+
s.chain = store.chainRevision;
|
|
362
|
+
const since = ctx.lastRunTick;
|
|
363
|
+
for (let t2 = 0; t2 < store.targets.length; t2++) {
|
|
364
|
+
const target = store.targets[t2];
|
|
365
|
+
target.query ??= world.query({ with: [target.def] });
|
|
366
|
+
const def = target.def;
|
|
367
|
+
const tables = target.query.tables;
|
|
368
|
+
for (let k = 0; k < tables.length; k++) {
|
|
369
|
+
const table = tables[k];
|
|
370
|
+
if (table.count === 0 || !all && table.lastChanged(def) <= since) continue;
|
|
371
|
+
const keys = table.column(def, "key");
|
|
372
|
+
const params = table.column(def, "params");
|
|
373
|
+
const ticks = table.changedTicks(def);
|
|
374
|
+
const has = table.has(Localized);
|
|
375
|
+
const values = has ? table.column(Localized, "value") : void 0;
|
|
376
|
+
const locales = has ? table.column(Localized, "locale") : void 0;
|
|
377
|
+
let wrote = false;
|
|
378
|
+
for (let i = 0; i < table.count; i++) {
|
|
379
|
+
if (!all && ticks[i] <= since) continue;
|
|
380
|
+
const entity = table.entities[i];
|
|
381
|
+
const key = keys[i];
|
|
382
|
+
if (key === "") {
|
|
383
|
+
if (has) ctx.commands.remove(entity, Localized);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const value = store.resolve(world, key, params[i]);
|
|
387
|
+
const locale = store.resolvedLocale;
|
|
388
|
+
if (value === void 0) {
|
|
389
|
+
if (has) ctx.commands.remove(entity, Localized);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (!has) ctx.commands.add(entity, Localized, { value, locale });
|
|
393
|
+
else if (values[i] !== value || locales[i] !== locale) {
|
|
394
|
+
values[i] = value;
|
|
395
|
+
locales[i] = locale;
|
|
396
|
+
wrote = true;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (wrote) table.markChanged(Localized);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
const KEYED_COMPONENTS = ["ui/UiText", "text/Text", "text/ScreenText"];
|
|
405
|
+
function localizationKeysIn(json) {
|
|
406
|
+
const out = [];
|
|
407
|
+
const walk = (value, at) => {
|
|
408
|
+
if (Array.isArray(value)) {
|
|
409
|
+
for (let i = 0; i < value.length; i++) walk(value[i], pointer(at, i));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (!isPlainObject(value)) return;
|
|
413
|
+
for (const [k, v] of Object.entries(value)) {
|
|
414
|
+
const here = pointer(at, k);
|
|
415
|
+
if (KEYED_COMPONENTS.includes(k) && isPlainObject(v)) {
|
|
416
|
+
if (typeof v.key === "string" && v.key !== "")
|
|
417
|
+
out.push({ key: v.key, path: pointer(here, "key") });
|
|
418
|
+
}
|
|
419
|
+
walk(v, here);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
walk(json, "");
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
async function validateLocalization(world) {
|
|
426
|
+
const server = assetServer(world);
|
|
427
|
+
const entries = server.all("StringTable");
|
|
428
|
+
await Promise.all(entries.map((e) => server.load(e.guid).catch(() => void 0)));
|
|
429
|
+
const store = world.initResource(StringTables);
|
|
430
|
+
const byLocale = /* @__PURE__ */ new Map();
|
|
431
|
+
for (const entry of entries) {
|
|
432
|
+
const table = store.byGuid(entry.guid);
|
|
433
|
+
if (!table) continue;
|
|
434
|
+
const list = byLocale.get(table.locale) ?? [];
|
|
435
|
+
list.push({ path: entry.path, table });
|
|
436
|
+
byLocale.set(table.locale, list);
|
|
437
|
+
}
|
|
438
|
+
const keys = /* @__PURE__ */ new Set();
|
|
439
|
+
for (const list of byLocale.values())
|
|
440
|
+
for (const { table } of list) for (const key of table.strings.keys()) keys.add(key);
|
|
441
|
+
const locales = [...byLocale.keys()].sort();
|
|
442
|
+
const errors = [];
|
|
443
|
+
const firstDefinition = /* @__PURE__ */ new Map();
|
|
444
|
+
for (const locale of locales) {
|
|
445
|
+
const list = byLocale.get(locale);
|
|
446
|
+
const own = /* @__PURE__ */ new Map();
|
|
447
|
+
for (const { table } of list) for (const [k, v] of table.strings) own.set(k, v);
|
|
448
|
+
const source = list[0].path;
|
|
449
|
+
for (const key of [...keys].sort()) {
|
|
450
|
+
const entry = own.get(key);
|
|
451
|
+
if (entry === void 0) {
|
|
452
|
+
const others = locales.filter(
|
|
453
|
+
(l) => l !== locale && byLocale.get(l).some((x) => x.table.strings.has(key))
|
|
454
|
+
);
|
|
455
|
+
errors.push({
|
|
456
|
+
source,
|
|
457
|
+
error: new ShardError("locale/missing-key", `"${key}" is missing in ${locale}`, {
|
|
458
|
+
path: pointer("", key),
|
|
459
|
+
hint: `Defined in ${others.join(", ")}. Add it to ${source}.`
|
|
460
|
+
})
|
|
461
|
+
});
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const params = placeholdersOf(entry).join(", ");
|
|
465
|
+
const first = firstDefinition.get(key);
|
|
466
|
+
if (!first) firstDefinition.set(key, { locale, params });
|
|
467
|
+
else if (first.params !== params) {
|
|
468
|
+
errors.push({
|
|
469
|
+
source,
|
|
470
|
+
error: new ShardError(
|
|
471
|
+
"locale/param-mismatch",
|
|
472
|
+
`"${key}" uses {${params}} in ${locale} but {${first.params}} in ${first.locale}`,
|
|
473
|
+
{
|
|
474
|
+
path: pointer("", key),
|
|
475
|
+
hint: "Every locale gets the same params: use the same {placeholders} in each."
|
|
476
|
+
}
|
|
477
|
+
)
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return { locales, keys, errors };
|
|
483
|
+
}
|
|
484
|
+
const localeMethods = [
|
|
485
|
+
{
|
|
486
|
+
name: "locale.set",
|
|
487
|
+
description: 'Switches the locale (a BCP 47 tag like "pt-BR"): keyed UiText, Text, and ScreenText update on the next frame. Returns the fallback chain.',
|
|
488
|
+
params: defineSchema("protocol/LocaleSetParams", {
|
|
489
|
+
locale: t.string({ required: true, description: 'e.g. "en", "pt-BR".' })
|
|
490
|
+
}),
|
|
491
|
+
handler: ({ world }, params) => {
|
|
492
|
+
setLocale(world, params.locale);
|
|
493
|
+
const store = world.resource(LocaleState);
|
|
494
|
+
store.sync(world.resource(Locale));
|
|
495
|
+
return { current: world.resource(Locale).current, chain: store.chain };
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
name: "locale.missing",
|
|
500
|
+
description: "What doesn't translate: per locale, keys another locale's table defines but its own lacks, and keys requested at runtime that no locale in the chain had. Also the current locale, chain, and loaded locales.",
|
|
501
|
+
params: defineSchema("protocol/LocaleMissingParams", {}),
|
|
502
|
+
handler: ({ world }) => {
|
|
503
|
+
const store = world.resource(LocaleState);
|
|
504
|
+
store.sync(world.resource(Locale));
|
|
505
|
+
const tables = store.tables(world);
|
|
506
|
+
const all = /* @__PURE__ */ new Set();
|
|
507
|
+
for (const m of tables.values()) for (const k of m.strings.keys()) all.add(k);
|
|
508
|
+
const missing = {};
|
|
509
|
+
for (const [locale, m] of tables) {
|
|
510
|
+
const lacks = [...all].filter((k) => !m.strings.has(k)).sort();
|
|
511
|
+
if (lacks.length > 0) missing[locale] = lacks;
|
|
512
|
+
}
|
|
513
|
+
const requested = {};
|
|
514
|
+
for (const [locale, keys] of store.missing) requested[locale] = [...keys].sort();
|
|
515
|
+
return {
|
|
516
|
+
current: world.resource(Locale).current,
|
|
517
|
+
chain: store.chain,
|
|
518
|
+
locales: [...tables.keys()].sort(),
|
|
519
|
+
missing,
|
|
520
|
+
requested
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
];
|
|
525
|
+
const installed = /* @__PURE__ */ new WeakSet();
|
|
526
|
+
function installLocalization(app) {
|
|
527
|
+
if (installed.has(app)) return;
|
|
528
|
+
installed.add(app);
|
|
529
|
+
const world = app.world;
|
|
530
|
+
world.initResource(Locale);
|
|
531
|
+
world.initResource(StringTables);
|
|
532
|
+
const store = new LocaleStore();
|
|
533
|
+
world.insertResource(LocaleState, store);
|
|
534
|
+
localizeComponent(world, Text);
|
|
535
|
+
localizeComponent(world, ScreenText);
|
|
536
|
+
assetServer(world).onEvent((e) => {
|
|
537
|
+
if (assetServer(world).entry(e.guid)?.type !== "StringTable" && e.kind !== "removed") return;
|
|
538
|
+
store.invalidate();
|
|
539
|
+
});
|
|
540
|
+
app.addSystems(PostUpdate, localizeText);
|
|
541
|
+
app.addMethod(...localeMethods);
|
|
542
|
+
}
|
|
543
|
+
async function loadStringTables(world) {
|
|
544
|
+
const server = assetServer(world);
|
|
545
|
+
const entries = server.all("StringTable");
|
|
546
|
+
for (const e of entries) server.pin(e.guid, PIN);
|
|
547
|
+
await Promise.all(entries.map((e) => server.load(e.guid)));
|
|
548
|
+
}
|
|
549
|
+
const PIN = "text/strings";
|
|
550
|
+
export {
|
|
551
|
+
KEYED_COMPONENTS,
|
|
552
|
+
Locale,
|
|
553
|
+
LocaleState,
|
|
554
|
+
LocaleStore,
|
|
555
|
+
PLURAL_CATEGORIES,
|
|
556
|
+
StringTable,
|
|
557
|
+
StringTableAssetType,
|
|
558
|
+
StringTableImporter,
|
|
559
|
+
StringTables,
|
|
560
|
+
installLocalization,
|
|
561
|
+
loadStringTables,
|
|
562
|
+
localeChain,
|
|
563
|
+
localeMethods,
|
|
564
|
+
localeOfPath,
|
|
565
|
+
localizationKeysIn,
|
|
566
|
+
localizeComponent,
|
|
567
|
+
localizeText,
|
|
568
|
+
placeholdersOf,
|
|
569
|
+
setLocale,
|
|
570
|
+
stringTableSchema,
|
|
571
|
+
tr,
|
|
572
|
+
validateLocalization,
|
|
573
|
+
validateStringTable
|
|
574
|
+
};
|
package/dist/msdf.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-channel signed distance fields: a TypeScript port of msdfgen's core.
|
|
3
|
+
*
|
|
4
|
+
* - Per-channel perpendicular ("pseudo") distance with msdfgen's `MultiDistanceSelector`, per-edge
|
|
5
|
+
* distance caches, and the `OverlappingContourCombiner` (correct fields for overlapping contours).
|
|
6
|
+
* - A scanline pass that fixes the sign of any texel whose median disagrees with the nonzero fill.
|
|
7
|
+
* - msdfgen's edge-priority error correction: protect corners and edges, find texels whose linear
|
|
8
|
+
* or bilinear interpolation makes an artifact, and flatten those to their median.
|
|
9
|
+
*
|
|
10
|
+
* Everything per pixel runs on typed arrays that grow once and are reused across glyphs.
|
|
11
|
+
* Coordinates follow msdfgen: shape units, y up; bitmap row 0 is the bottom row.
|
|
12
|
+
*/
|
|
13
|
+
import { type Shape } from './shape.js';
|
|
14
|
+
export interface MsdfOptions {
|
|
15
|
+
/** Pixels per shape unit. */
|
|
16
|
+
scale: number;
|
|
17
|
+
/** Distance range in pixels: the field spans ±range/2 around the outline. */
|
|
18
|
+
range: number;
|
|
19
|
+
/** Corner angle threshold for edge coloring, radians. Default 3. */
|
|
20
|
+
angleThreshold?: number;
|
|
21
|
+
/** Run the scanline sign pass (default true). */
|
|
22
|
+
scanline?: boolean;
|
|
23
|
+
/** Run edge-priority error correction (default true). */
|
|
24
|
+
errorCorrection?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** Where a glyph's bitmap sits relative to the shape (msdf-atlas-gen's box wrapping). */
|
|
27
|
+
export interface MsdfBox {
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
/** Shape-space translation: pixel x = (shapeX + tx) * scale. */
|
|
31
|
+
tx: number;
|
|
32
|
+
ty: number;
|
|
33
|
+
/** Quad bounds in shape units (pixel center to pixel center): left, bottom, right, top. */
|
|
34
|
+
plane: [number, number, number, number];
|
|
35
|
+
}
|
|
36
|
+
/** The bitmap box for bounds: padded by half the range, centered, one spare pixel. */
|
|
37
|
+
export declare function msdfBox(bounds: readonly [number, number, number, number], scale: number, range: number): MsdfBox;
|
|
38
|
+
/**
|
|
39
|
+
* Prepares a shape for MSDF: splits single-edge contours, fixes the orientation so the outside is
|
|
40
|
+
* negative, and colors the edges. Mutates the shape.
|
|
41
|
+
*/
|
|
42
|
+
export declare function prepareShape(shape: Shape, angleThreshold?: number): void;
|
|
43
|
+
/**
|
|
44
|
+
* Generates the MSDF of a prepared shape into a reused float buffer (3 channels, normalized so 0.5
|
|
45
|
+
* is the outline, row 0 at the bottom). Returns the buffer; valid until the next call.
|
|
46
|
+
*/
|
|
47
|
+
export declare function generateMsdf(shape: Shape, box: MsdfBox, options: MsdfOptions): Float32Array;
|
|
48
|
+
/**
|
|
49
|
+
* Writes a generated field into an RGBA8 image with row 0 at the top (flipping msdfgen's rows),
|
|
50
|
+
* at (x, y). Alpha is 255.
|
|
51
|
+
*/
|
|
52
|
+
export declare function blitMsdf(sdf: Float32Array, w: number, h: number, out: Uint8Array, outWidth: number, x: number, y: number): void;
|