@saykit/config 0.7.0 → 0.9.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/dist/commands/index.cjs +2 -16
- package/dist/commands/index.mjs +2 -16
- package/dist/features/catalogue/index.cjs +1 -17
- package/dist/features/catalogue/index.mjs +1 -17
- package/dist/features/loader/index.cjs +1 -1
- package/dist/features/loader/index.mjs +1 -1
- package/dist/features/messages/index.cjs +48 -88
- package/dist/features/messages/index.d.cts +44 -4
- package/dist/features/messages/index.d.mts +44 -4
- package/dist/features/messages/index.mjs +48 -89
- package/dist/index.cjs +0 -18
- package/dist/index.mjs +0 -18
- package/dist/{loader-DwF1lM3f.mjs → loader-C6XxYhMl.mjs} +0 -17
- package/dist/{loader-BdCeoj3-.cjs → loader-XE7SprlF.cjs} +0 -17
- package/dist/{storage-B6mn0s3V.mjs → storage-8485YdBh.mjs} +0 -8
- package/dist/{storage-DqhzA5H8.cjs → storage-DSdC-TYq.cjs} +0 -8
- package/package.json +3 -1
package/dist/commands/index.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const require_storage = require("../storage-
|
|
3
|
-
const require_loader = require("../loader-
|
|
2
|
+
const require_storage = require("../storage-DSdC-TYq.cjs");
|
|
3
|
+
const require_loader = require("../loader-XE7SprlF.cjs");
|
|
4
4
|
let _commander_js_extra_typings = require("@commander-js/extra-typings");
|
|
5
5
|
let node_fs_promises = require("node:fs/promises");
|
|
6
6
|
let node_path = require("node:path");
|
|
@@ -76,12 +76,6 @@ async function extractMessagesFromFile(path, bucket) {
|
|
|
76
76
|
}
|
|
77
77
|
//#endregion
|
|
78
78
|
//#region src/features/watch.ts
|
|
79
|
-
/**
|
|
80
|
-
* Expand a buckets include and exclude patterns into a flat list of file paths.
|
|
81
|
-
*
|
|
82
|
-
* We stat each match instead of using `glob`'s `withFileTypes` option, which
|
|
83
|
-
* Bun's `node:fs/promises` compatibility layer does not yet support.
|
|
84
|
-
*/
|
|
85
79
|
async function globBucket(bucket) {
|
|
86
80
|
const paths = [];
|
|
87
81
|
for await (const path of (0, node_fs_promises.glob)(bucket.include, { exclude: bucket.exclude })) try {
|
|
@@ -91,14 +85,6 @@ async function globBucket(bucket) {
|
|
|
91
85
|
}
|
|
92
86
|
return paths;
|
|
93
87
|
}
|
|
94
|
-
/**
|
|
95
|
-
* Watches a path for changes, emitting a debounced event every set delay.
|
|
96
|
-
*
|
|
97
|
-
* Unlike Node's native `fs.watch` method, this:
|
|
98
|
-
* - coalesces rapid consecutive events per file
|
|
99
|
-
* - emits only the final event after `delay` ms of inactivity
|
|
100
|
-
* - deduplicates events by filename
|
|
101
|
-
*/
|
|
102
88
|
async function* watchDebounced(path, options, delay = 300) {
|
|
103
89
|
const timers = /* @__PURE__ */ new Map();
|
|
104
90
|
const queue = /* @__PURE__ */ new Map();
|
package/dist/commands/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as mergeExtractedMessages, i as expandBucketOutputPath, n as writeCatalogueMessages, o as pruneLocaleMessages, t as readCatalogueMessages } from "../storage-
|
|
3
|
-
import { t as resolveConfig } from "../loader-
|
|
2
|
+
import { a as mergeExtractedMessages, i as expandBucketOutputPath, n as writeCatalogueMessages, o as pruneLocaleMessages, t as readCatalogueMessages } from "../storage-8485YdBh.mjs";
|
|
3
|
+
import { t as resolveConfig } from "../loader-C6XxYhMl.mjs";
|
|
4
4
|
import { Command, program } from "@commander-js/extra-typings";
|
|
5
5
|
import { access, glob, readFile, stat, watch } from "node:fs/promises";
|
|
6
6
|
import { join, relative } from "node:path";
|
|
@@ -76,12 +76,6 @@ async function extractMessagesFromFile(path, bucket) {
|
|
|
76
76
|
}
|
|
77
77
|
//#endregion
|
|
78
78
|
//#region src/features/watch.ts
|
|
79
|
-
/**
|
|
80
|
-
* Expand a buckets include and exclude patterns into a flat list of file paths.
|
|
81
|
-
*
|
|
82
|
-
* We stat each match instead of using `glob`'s `withFileTypes` option, which
|
|
83
|
-
* Bun's `node:fs/promises` compatibility layer does not yet support.
|
|
84
|
-
*/
|
|
85
79
|
async function globBucket(bucket) {
|
|
86
80
|
const paths = [];
|
|
87
81
|
for await (const path of glob(bucket.include, { exclude: bucket.exclude })) try {
|
|
@@ -91,14 +85,6 @@ async function globBucket(bucket) {
|
|
|
91
85
|
}
|
|
92
86
|
return paths;
|
|
93
87
|
}
|
|
94
|
-
/**
|
|
95
|
-
* Watches a path for changes, emitting a debounced event every set delay.
|
|
96
|
-
*
|
|
97
|
-
* Unlike Node's native `fs.watch` method, this:
|
|
98
|
-
* - coalesces rapid consecutive events per file
|
|
99
|
-
* - emits only the final event after `delay` ms of inactivity
|
|
100
|
-
* - deduplicates events by filename
|
|
101
|
-
*/
|
|
102
88
|
async function* watchDebounced(path, options, delay = 300) {
|
|
103
89
|
const timers = /* @__PURE__ */ new Map();
|
|
104
90
|
const queue = /* @__PURE__ */ new Map();
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_hash = require("../../hash-51ce8YiG.cjs");
|
|
3
|
-
const require_storage = require("../../storage-
|
|
3
|
+
const require_storage = require("../../storage-DSdC-TYq.cjs");
|
|
4
4
|
let node_path = require("node:path");
|
|
5
5
|
//#region src/features/catalogue/record.ts
|
|
6
|
-
/**
|
|
7
|
-
* Resolve the fallback chain for a locale, most specific first. The source
|
|
8
|
-
* locale (the first configured locale) is always the final fallback, so an
|
|
9
|
-
* untranslated key ultimately resolves to the source string.
|
|
10
|
-
*/
|
|
11
6
|
function resolveFallbackChain(config, locale) {
|
|
12
7
|
const source = config.locales[0];
|
|
13
8
|
const configured = config.fallbackLocales?.[locale];
|
|
@@ -18,11 +13,6 @@ function resolveFallbackChain(config, locale) {
|
|
|
18
13
|
source
|
|
19
14
|
]));
|
|
20
15
|
}
|
|
21
|
-
/**
|
|
22
|
-
* Resolve the catalogue files that contribute to a locale module, most specific
|
|
23
|
-
* first. When the path does not map to a configured locale it is loaded on its
|
|
24
|
-
* own.
|
|
25
|
-
*/
|
|
26
16
|
function resolveCatalogueSources(config, bucket, path) {
|
|
27
17
|
const resolved = (0, node_path.resolve)(path);
|
|
28
18
|
const locale = config.locales.find((l) => require_storage.expandBucketOutputPath(bucket, l) === resolved);
|
|
@@ -31,12 +21,6 @@ function resolveCatalogueSources(config, bucket, path) {
|
|
|
31
21
|
sources: locale ? resolveFallbackChain(config, locale).map((l) => require_storage.expandBucketOutputPath(bucket, l)) : [resolved]
|
|
32
22
|
};
|
|
33
23
|
}
|
|
34
|
-
/**
|
|
35
|
-
* Assemble a `{ id: string }` record from catalogue file contents. `contents`
|
|
36
|
-
* must be aligned with the `sources` returned by {@link resolveCatalogueSources}
|
|
37
|
-
* (most specific first): more specific locales override their fallbacks, and any
|
|
38
|
-
* key still untranslated falls back to its source message.
|
|
39
|
-
*/
|
|
40
24
|
function assembleCatalogueRecord(bucket, contents) {
|
|
41
25
|
const record = {};
|
|
42
26
|
for (const content of [...contents].reverse()) {
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { t as generateHash } from "../../hash-DvzpieJD.mjs";
|
|
2
|
-
import { a as mergeExtractedMessages, i as expandBucketOutputPath, n as writeCatalogueMessages, o as pruneLocaleMessages, r as declarationPathFor, t as readCatalogueMessages } from "../../storage-
|
|
2
|
+
import { a as mergeExtractedMessages, i as expandBucketOutputPath, n as writeCatalogueMessages, o as pruneLocaleMessages, r as declarationPathFor, t as readCatalogueMessages } from "../../storage-8485YdBh.mjs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
//#region src/features/catalogue/record.ts
|
|
5
|
-
/**
|
|
6
|
-
* Resolve the fallback chain for a locale, most specific first. The source
|
|
7
|
-
* locale (the first configured locale) is always the final fallback, so an
|
|
8
|
-
* untranslated key ultimately resolves to the source string.
|
|
9
|
-
*/
|
|
10
5
|
function resolveFallbackChain(config, locale) {
|
|
11
6
|
const source = config.locales[0];
|
|
12
7
|
const configured = config.fallbackLocales?.[locale];
|
|
@@ -17,11 +12,6 @@ function resolveFallbackChain(config, locale) {
|
|
|
17
12
|
source
|
|
18
13
|
]));
|
|
19
14
|
}
|
|
20
|
-
/**
|
|
21
|
-
* Resolve the catalogue files that contribute to a locale module, most specific
|
|
22
|
-
* first. When the path does not map to a configured locale it is loaded on its
|
|
23
|
-
* own.
|
|
24
|
-
*/
|
|
25
15
|
function resolveCatalogueSources(config, bucket, path) {
|
|
26
16
|
const resolved = resolve(path);
|
|
27
17
|
const locale = config.locales.find((l) => expandBucketOutputPath(bucket, l) === resolved);
|
|
@@ -30,12 +20,6 @@ function resolveCatalogueSources(config, bucket, path) {
|
|
|
30
20
|
sources: locale ? resolveFallbackChain(config, locale).map((l) => expandBucketOutputPath(bucket, l)) : [resolved]
|
|
31
21
|
};
|
|
32
22
|
}
|
|
33
|
-
/**
|
|
34
|
-
* Assemble a `{ id: string }` record from catalogue file contents. `contents`
|
|
35
|
-
* must be aligned with the `sources` returned by {@link resolveCatalogueSources}
|
|
36
|
-
* (most specific first): more specific locales override their fallbacks, and any
|
|
37
|
-
* key still untranslated falls back to its source message.
|
|
38
|
-
*/
|
|
39
23
|
function assembleCatalogueRecord(bucket, contents) {
|
|
40
24
|
const record = {};
|
|
41
25
|
for (const content of [...contents].reverse()) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_loader = require("../../loader-
|
|
2
|
+
const require_loader = require("../../loader-XE7SprlF.cjs");
|
|
3
3
|
exports.resolveConfig = require_loader.resolveConfig;
|
|
4
4
|
exports.resolveConfigFile = require_loader.resolveConfigFile;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as resolveConfigFile, t as resolveConfig } from "../../loader-
|
|
1
|
+
import { n as resolveConfigFile, t as resolveConfig } from "../../loader-C6XxYhMl.mjs";
|
|
2
2
|
export { resolveConfig, resolveConfigFile };
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_hash = require("../../hash-51ce8YiG.cjs");
|
|
3
|
+
let _messageformat_date_skeleton = require("@messageformat/date-skeleton");
|
|
4
|
+
let _messageformat_number_skeleton = require("@messageformat/number-skeleton");
|
|
5
|
+
//#region src/features/messages/escape.ts
|
|
6
|
+
function escapeIcuLiteral(text, hash = false) {
|
|
7
|
+
return text.replace(/'/gu, "''").replace(hash ? /[{}#]+/gu : /[{}]+/gu, (run) => `'${run}'`);
|
|
8
|
+
}
|
|
9
|
+
//#endregion
|
|
3
10
|
//#region src/features/messages/types.ts
|
|
4
11
|
var Base = class {
|
|
5
12
|
toICUString() {
|
|
@@ -74,40 +81,12 @@ var CompositeMessage = class extends Base {
|
|
|
74
81
|
//#endregion
|
|
75
82
|
//#region src/features/messages/identifier.ts
|
|
76
83
|
const AUTO_INCREMENT_IDENTIFIER = Symbol("auto-increment");
|
|
77
|
-
/**
|
|
78
|
-
* An ICU case, either an exact value or a key. ICU reserves its own pattern
|
|
79
|
-
* syntax, so a key carries no punctuation and no whitespace.
|
|
80
|
-
*/
|
|
81
84
|
const BRANCH_PATTERN = /^(?:=\d+|[^\p{Pattern_Syntax}\p{Pattern_White_Space}]+)$/u;
|
|
82
|
-
/**
|
|
83
|
-
* The ICU case a branch is written as.
|
|
84
|
-
*
|
|
85
|
-
* Under `plural` and `ordinal` a number names an exact value, spelled `=0`, and
|
|
86
|
-
* so is distinct from the CLDR category that would otherwise match it. `select`
|
|
87
|
-
* has no such syntax — its cases are literal string matches, and `=0` there is
|
|
88
|
-
* a parse error — so a numeric key stays bare, where it matches both `0` and
|
|
89
|
-
* `'0'`.
|
|
90
|
-
*
|
|
91
|
-
* Digits are read literally rather than coerced, because everything JavaScript
|
|
92
|
-
* is willing to call a number is not: `''`, `' '`, and `'+0'` all coerce to
|
|
93
|
-
* `=0`, which would pass for a key that selects zero and quietly leave the
|
|
94
|
-
* author's own key out of the catalogue. Anything else stays a key, where the
|
|
95
|
-
* whitespace or punctuation that made it numeric-looking is caught.
|
|
96
|
-
*/
|
|
97
85
|
function getBranchCase(kind, identifier) {
|
|
98
86
|
const key = String(identifier);
|
|
99
87
|
if (kind === "select") return key;
|
|
100
88
|
return /^\d+$/u.test(key) ? `=${+key}` : key;
|
|
101
89
|
}
|
|
102
|
-
/**
|
|
103
|
-
* Reject a branch key ICU cannot express, while the key is still attached to a
|
|
104
|
-
* file and a line.
|
|
105
|
-
*
|
|
106
|
-
* A hyphenated string union is ordinary application code and typechecks,
|
|
107
|
-
* builds, and extracts to a catalogue entry that looks perfectly normal — the
|
|
108
|
-
* only sign of trouble is a parse error at format time, in a message whose
|
|
109
|
-
* source is long gone.
|
|
110
|
-
*/
|
|
111
90
|
function validateBranchIdentifier(kind, identifier) {
|
|
112
91
|
if (typeof identifier !== "string") return;
|
|
113
92
|
const branch = getBranchCase(kind, identifier);
|
|
@@ -117,12 +96,6 @@ function validateBranchIdentifier(kind, identifier) {
|
|
|
117
96
|
}
|
|
118
97
|
if (kind === "select" && /^=\d+$/u.test(branch)) throw new Error(`Invalid select branch key '${identifier}', an exact value is only meaningful to 'plural' and 'ordinal', write it as '${branch.slice(1)}'`);
|
|
119
98
|
}
|
|
120
|
-
/**
|
|
121
|
-
* The nearest identifier-safe form of a key, so the error names the fix as well
|
|
122
|
-
* as the problem. The constraint comes from ICU rather than from anything the
|
|
123
|
-
* author wrote, and camel case is how the rest of the codebase already spells a
|
|
124
|
-
* name of more than one word.
|
|
125
|
-
*/
|
|
126
99
|
function suggestBranchIdentifier(identifier) {
|
|
127
100
|
const suggestion = identifier.split(/[^\p{L}\p{N}]+/u).filter(Boolean).map((word, index) => index === 0 ? word : word[0].toUpperCase() + word.slice(1)).join("");
|
|
128
101
|
if (!BRANCH_PATTERN.test(suggestion) || !Number.isNaN(+suggestion)) return void 0;
|
|
@@ -135,9 +108,20 @@ function assignSequenceIdentifiers(message, sequence = { current: 0 }, equivalen
|
|
|
135
108
|
while (reserved.has(identifier)) identifier = `${sequence.current++}`;
|
|
136
109
|
return identifier;
|
|
137
110
|
}
|
|
111
|
+
const numbered = {
|
|
112
|
+
tags: [],
|
|
113
|
+
values: []
|
|
114
|
+
};
|
|
115
|
+
function number(assigned, expression) {
|
|
116
|
+
const seen = assigned.find(([e]) => equivalent(e, expression));
|
|
117
|
+
if (seen) return seen[1];
|
|
118
|
+
const identifier = next();
|
|
119
|
+
assigned.push([expression, identifier]);
|
|
120
|
+
return identifier;
|
|
121
|
+
}
|
|
138
122
|
function walk(message) {
|
|
139
123
|
if (message instanceof ArgumentMessage || message instanceof ElementMessage || message instanceof ChoiceMessage) {
|
|
140
|
-
if (message.identifier === AUTO_INCREMENT_IDENTIFIER) message.identifier =
|
|
124
|
+
if (message.identifier === AUTO_INCREMENT_IDENTIFIER) message.identifier = number(message instanceof ElementMessage ? numbered.tags : numbered.values, message.expression);
|
|
141
125
|
}
|
|
142
126
|
if (message instanceof CompositeMessage || message instanceof ElementMessage) for (const child of message.children) walk(child);
|
|
143
127
|
if (message instanceof ChoiceMessage) for (const branch of message.branches) {
|
|
@@ -147,16 +131,6 @@ function assignSequenceIdentifiers(message, sequence = { current: 0 }, equivalen
|
|
|
147
131
|
}
|
|
148
132
|
walk(message);
|
|
149
133
|
}
|
|
150
|
-
/**
|
|
151
|
-
* Collect the identifiers already assigned before this pass runs, so generated
|
|
152
|
-
* sequence numbers never shadow an explicit one (e.g. an element tagged `0`).
|
|
153
|
-
*
|
|
154
|
-
* A name is claimed by what produced it, not by the name alone. Two that differ
|
|
155
|
-
* each compile to their own prop, and a translator moving them around a sentence
|
|
156
|
-
* has to be able to tell them apart, so they are a build error. Repeats are fine
|
|
157
|
-
* when nothing distinguishes them: the same variable interpolated twice is one
|
|
158
|
-
* value, and two identical elements are one tag.
|
|
159
|
-
*/
|
|
160
134
|
function collectAssignedIdentifiers(message, equivalent) {
|
|
161
135
|
const tags = /* @__PURE__ */ new Map();
|
|
162
136
|
const values = /* @__PURE__ */ new Map();
|
|
@@ -177,10 +151,11 @@ function collectAssignedIdentifiers(message, equivalent) {
|
|
|
177
151
|
//#endregion
|
|
178
152
|
//#region src/features/messages/convert.ts
|
|
179
153
|
function convertMessageToIcu(message) {
|
|
180
|
-
function internalConvertMessageToIcu(message) {
|
|
154
|
+
function internalConvertMessageToIcu(message, hash) {
|
|
181
155
|
switch (true) {
|
|
182
|
-
case message instanceof LiteralMessage: return String(message.text);
|
|
156
|
+
case message instanceof LiteralMessage: return escapeIcuLiteral(String(message.text), hash !== void 0);
|
|
183
157
|
case message instanceof ArgumentMessage: {
|
|
158
|
+
if (!message.format && hash !== void 0 && String(message.identifier) === hash) return "#";
|
|
184
159
|
const parts = [String(message.identifier)];
|
|
185
160
|
if (message.format) parts.push(message.format.type);
|
|
186
161
|
if (message.format?.style) parts.push(message.format.style);
|
|
@@ -188,39 +163,27 @@ function convertMessageToIcu(message) {
|
|
|
188
163
|
}
|
|
189
164
|
case message instanceof ElementMessage: {
|
|
190
165
|
if (message.children.length === 0) return `<${String(message.identifier)}/>`;
|
|
191
|
-
const children = message.children.map((m) => internalConvertMessageToIcu(m)).join("");
|
|
166
|
+
const children = message.children.map((m) => internalConvertMessageToIcu(m, hash)).join("");
|
|
192
167
|
return `<${String(message.identifier)}>${children}</${String(message.identifier)}>`;
|
|
193
168
|
}
|
|
194
169
|
case message instanceof ChoiceMessage: {
|
|
170
|
+
const scope = message.kind === "select" ? hash : String(message.identifier);
|
|
195
171
|
const branches = message.branches.map(({ identifier, value }) => ({
|
|
196
172
|
identifier: getBranchCase(message.kind, identifier),
|
|
197
|
-
value: internalConvertMessageToIcu(value)
|
|
173
|
+
value: internalConvertMessageToIcu(value, scope)
|
|
198
174
|
})).map(({ identifier, value }) => ` ${identifier} {${value}}\n`).join("");
|
|
199
175
|
const format = message.kind === "ordinal" ? "selectordinal" : message.kind;
|
|
200
176
|
const offset = message.offset === void 0 || message.kind === "select" ? "" : ` offset:${message.offset}`;
|
|
201
177
|
return `{${String(message.identifier)}, ${format},${offset}\n${branches}}`;
|
|
202
178
|
}
|
|
203
|
-
case message instanceof CompositeMessage: return Object.entries(message.children).map(([, m]) => internalConvertMessageToIcu(m)).join("");
|
|
179
|
+
case message instanceof CompositeMessage: return Object.entries(message.children).map(([, m]) => internalConvertMessageToIcu(m, hash)).join("");
|
|
204
180
|
default: throw new Error("Unknown message type", { cause: message });
|
|
205
181
|
}
|
|
206
182
|
}
|
|
207
|
-
return internalConvertMessageToIcu(message).trim();
|
|
183
|
+
return internalConvertMessageToIcu(message, void 0).trim();
|
|
208
184
|
}
|
|
209
185
|
//#endregion
|
|
210
186
|
//#region src/features/messages/format.ts
|
|
211
|
-
/**
|
|
212
|
-
* The ICU argument types a macro can author, and the named styles each accepts.
|
|
213
|
-
*
|
|
214
|
-
* `currency` is deliberately absent from `number`. MF1 has nowhere to write the
|
|
215
|
-
* currency code — it comes from the formatter's configuration, not the message
|
|
216
|
-
* — so `{price, number, currency}` formats as a literal `{$price}` at runtime
|
|
217
|
-
* rather than an amount. Currency belongs to number skeletons, which the
|
|
218
|
-
* formatter does not accept yet either.
|
|
219
|
-
*
|
|
220
|
-
* `spellout`, RBNF `ordinal`, and `choice` are absent by decision rather than
|
|
221
|
-
* oversight: the first two are ICU4J/ICU4C rule-based formats with no `Intl`
|
|
222
|
-
* equivalent, and the third is deprecated in ICU itself in favour of `plural`.
|
|
223
|
-
*/
|
|
224
187
|
const ARGUMENT_STYLES = {
|
|
225
188
|
number: ["integer", "percent"],
|
|
226
189
|
date: [
|
|
@@ -240,36 +203,32 @@ const ARGUMENT_TYPES = Object.keys(ARGUMENT_STYLES);
|
|
|
240
203
|
function isArgumentType(kind) {
|
|
241
204
|
return Object.hasOwn(ARGUMENT_STYLES, kind);
|
|
242
205
|
}
|
|
243
|
-
/**
|
|
244
|
-
* A literal `NumberFormat` pattern, e.g. `#,##0.00`.
|
|
245
|
-
*
|
|
246
|
-
* A pattern has to carry a digit placeholder — `#` or `0` — because that is
|
|
247
|
-
* what makes it a pattern rather than a word. Without that requirement any
|
|
248
|
-
* brace-free string qualifies, which quietly readmits the named styles this
|
|
249
|
-
* module exists to reject: `currency` would sail through as a "pattern" and
|
|
250
|
-
* extract to the `{price, number, currency}` the formatter cannot honour, and
|
|
251
|
-
* so would a plain typo.
|
|
252
|
-
*
|
|
253
|
-
* Braces are excluded separately: ICU reserves them for its own pattern syntax,
|
|
254
|
-
* so a style carrying one would close the argument early and take the rest of
|
|
255
|
-
* the message with it.
|
|
256
|
-
*/
|
|
257
206
|
const LITERAL_STYLE_PATTERN = /^[^{}\r\n]*[#0][^{}\r\n]*$/;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
207
|
+
const SKELETON = "::";
|
|
208
|
+
function invalidSkeleton(type, skeleton) {
|
|
209
|
+
if (skeleton === "") return "it names no fields";
|
|
210
|
+
const errors = [];
|
|
211
|
+
if (type === "number") {
|
|
212
|
+
(0, _messageformat_number_skeleton.getNumberFormatOptions)((0, _messageformat_number_skeleton.parseNumberSkeleton)(skeleton, (error) => errors.push(String(error))), (stem) => {
|
|
213
|
+
if (stem !== "scale") errors.push(`unsupported stem '${stem}'`);
|
|
214
|
+
});
|
|
215
|
+
return errors[0];
|
|
216
|
+
}
|
|
217
|
+
const options = (0, _messageformat_date_skeleton.getDateTimeFormatOptions)((0, _messageformat_date_skeleton.parseDateTokens)(skeleton), (_type, message) => errors.push(message));
|
|
218
|
+
if (errors.length > 0) return errors[0];
|
|
219
|
+
if (Object.keys(options).length === 0) return "it names no fields";
|
|
220
|
+
}
|
|
267
221
|
function validateArgumentStyle(type, style) {
|
|
268
222
|
const named = ARGUMENT_STYLES[type];
|
|
269
223
|
if (named.includes(style)) return;
|
|
224
|
+
if (style.startsWith(SKELETON)) {
|
|
225
|
+
const reason = invalidSkeleton(type, style.slice(2));
|
|
226
|
+
if (!reason) return;
|
|
227
|
+
throw new Error(`Invalid ${type} skeleton '${style}': ${reason}`);
|
|
228
|
+
}
|
|
270
229
|
if (type === "number" && LITERAL_STYLE_PATTERN.test(style)) return;
|
|
271
230
|
const expected = named.map((s) => `'${s}'`).join(", ");
|
|
272
|
-
throw new Error(`Invalid ${type} style '${style}', expected ${expected}` + (type === "number" ? ", or a literal number pattern such as #,##0.00" : ""));
|
|
231
|
+
throw new Error(`Invalid ${type} style '${style}', expected ${expected}, or a skeleton such as ` + (type === "number" ? "::currency/EUR" : "::yyyyMMdd") + (type === "number" ? ", or a literal number pattern such as #,##0.00" : ""));
|
|
273
232
|
}
|
|
274
233
|
//#endregion
|
|
275
234
|
exports.ARGUMENT_STYLES = ARGUMENT_STYLES;
|
|
@@ -282,6 +241,7 @@ exports.ElementMessage = ElementMessage;
|
|
|
282
241
|
exports.LiteralMessage = LiteralMessage;
|
|
283
242
|
exports.assignSequenceIdentifiers = assignSequenceIdentifiers;
|
|
284
243
|
exports.convertMessageToIcu = convertMessageToIcu;
|
|
244
|
+
exports.escapeIcuLiteral = escapeIcuLiteral;
|
|
285
245
|
exports.generateHash = require_hash.generateHash;
|
|
286
246
|
exports.getBranchCase = getBranchCase;
|
|
287
247
|
exports.isArgumentType = isArgumentType;
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* The ICU argument types a macro can author, and the named styles each accepts.
|
|
4
4
|
*
|
|
5
|
+
* A style may also be a skeleton, which is where the formats these names have
|
|
6
|
+
* no word for live — see {@link validateArgumentStyle}.
|
|
7
|
+
*
|
|
5
8
|
* `currency` is deliberately absent from `number`. MF1 has nowhere to write the
|
|
6
9
|
* currency code — it comes from the formatter's configuration, not the message
|
|
7
|
-
* — so `{price, number, currency}` formats as a
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
+
* — so `{price, number, currency}` formats as a bare number at runtime rather
|
|
11
|
+
* than an amount. Currency belongs to a skeleton, `::currency/EUR`, which names
|
|
12
|
+
* the code and which the formatter does honour.
|
|
10
13
|
*
|
|
11
14
|
* `spellout`, RBNF `ordinal`, and `choice` are absent by decision rather than
|
|
12
15
|
* oversight: the first two are ICU4J/ICU4C rule-based formats with no `Intl`
|
|
@@ -28,6 +31,9 @@ declare function isArgumentType(kind: string): kind is ArgumentType;
|
|
|
28
31
|
* nothing between here and the runtime has an opinion about it. Left unchecked,
|
|
29
32
|
* a typo like `{d, date, meduim}` extracts to a catalogue entry that looks
|
|
30
33
|
* perfectly normal and only misformats once it reaches a user.
|
|
34
|
+
*
|
|
35
|
+
* Three forms are accepted: a named style, a `::`-prefixed skeleton, and — for
|
|
36
|
+
* a number — a literal pattern such as `#,##0.00`.
|
|
31
37
|
*/
|
|
32
38
|
declare function validateArgumentStyle(type: ArgumentType, style: string): void;
|
|
33
39
|
//#endregion
|
|
@@ -147,7 +153,41 @@ type Message = LiteralMessage | ArgumentMessage | ElementMessage | ChoiceMessage
|
|
|
147
153
|
//#region src/features/messages/convert.d.ts
|
|
148
154
|
declare function convertMessageToIcu(message: Message): string;
|
|
149
155
|
//#endregion
|
|
156
|
+
//#region src/features/messages/escape.d.ts
|
|
157
|
+
/**
|
|
158
|
+
* Quoting for text that has to survive a message format unchanged.
|
|
159
|
+
*
|
|
160
|
+
* This lives beside the converter rather than in a transform because it belongs
|
|
161
|
+
* to the format being written, not to the syntax being read. A literal is the
|
|
162
|
+
* same literal whether it came from a template literal or from JSX, and the
|
|
163
|
+
* moment there is a second target format its reserved characters — and the way
|
|
164
|
+
* it spells an escape — are its own.
|
|
165
|
+
*/
|
|
166
|
+
/**
|
|
167
|
+
* Escape literal text so ICU MessageFormat 1 reads it as the text it is.
|
|
168
|
+
*
|
|
169
|
+
* MF1 escapes with an apostrophe: a run wrapped in one is taken literally, and a
|
|
170
|
+
* doubled apostrophe is a single apostrophe. The subtlety is that an apostrophe
|
|
171
|
+
* only opens a quoted run when a character that needs quoting follows it —
|
|
172
|
+
* anything else and the apostrophe is itself literal. So `'#'` is a real escape
|
|
173
|
+
* inside a plural and the three characters `'#'` anywhere else, which is why
|
|
174
|
+
* `hash` has to be told rather than assumed.
|
|
175
|
+
*
|
|
176
|
+
* @param text Literal text as the author wrote it
|
|
177
|
+
* @param hash Whether `#` stands for a number here, i.e. whether some `plural`
|
|
178
|
+
* or `ordinal` encloses this text
|
|
179
|
+
*/
|
|
180
|
+
declare function escapeIcuLiteral(text: string, hash?: boolean): string;
|
|
181
|
+
//#endregion
|
|
150
182
|
//#region src/features/messages/hash.d.ts
|
|
183
|
+
/**
|
|
184
|
+
* Hashed in userland rather than with `node:crypto`, because this runs wherever
|
|
185
|
+
* a bundler runs it. `createHash` is one of the entry points `unenv` leaves
|
|
186
|
+
* unimplemented — "[unenv] crypto.createHash is not implemented yet!" — so a
|
|
187
|
+
* plugin reaching for it throws in a Nitro or workerd build. The Web Crypto
|
|
188
|
+
* equivalent, `subtle.digest`, is async, and an id is resolved from a
|
|
189
|
+
* synchronous extraction pass.
|
|
190
|
+
*/
|
|
151
191
|
declare function generateHash(input: string, context?: string): string;
|
|
152
192
|
//#endregion
|
|
153
|
-
export { ARGUMENT_STYLES, ARGUMENT_TYPES, AUTO_INCREMENT_IDENTIFIER, ArgumentFormat, ArgumentMessage, ArgumentType, ChoiceMessage, CompositeMessage, ElementMessage, LiteralMessage, Message, PlaceholderEquivalence, assignSequenceIdentifiers, convertMessageToIcu, generateHash, getBranchCase, isArgumentType, validateArgumentStyle, validateBranchIdentifier };
|
|
193
|
+
export { ARGUMENT_STYLES, ARGUMENT_TYPES, AUTO_INCREMENT_IDENTIFIER, ArgumentFormat, ArgumentMessage, ArgumentType, ChoiceMessage, CompositeMessage, ElementMessage, LiteralMessage, Message, PlaceholderEquivalence, assignSequenceIdentifiers, convertMessageToIcu, escapeIcuLiteral, generateHash, getBranchCase, isArgumentType, validateArgumentStyle, validateBranchIdentifier };
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* The ICU argument types a macro can author, and the named styles each accepts.
|
|
4
4
|
*
|
|
5
|
+
* A style may also be a skeleton, which is where the formats these names have
|
|
6
|
+
* no word for live — see {@link validateArgumentStyle}.
|
|
7
|
+
*
|
|
5
8
|
* `currency` is deliberately absent from `number`. MF1 has nowhere to write the
|
|
6
9
|
* currency code — it comes from the formatter's configuration, not the message
|
|
7
|
-
* — so `{price, number, currency}` formats as a
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
+
* — so `{price, number, currency}` formats as a bare number at runtime rather
|
|
11
|
+
* than an amount. Currency belongs to a skeleton, `::currency/EUR`, which names
|
|
12
|
+
* the code and which the formatter does honour.
|
|
10
13
|
*
|
|
11
14
|
* `spellout`, RBNF `ordinal`, and `choice` are absent by decision rather than
|
|
12
15
|
* oversight: the first two are ICU4J/ICU4C rule-based formats with no `Intl`
|
|
@@ -28,6 +31,9 @@ declare function isArgumentType(kind: string): kind is ArgumentType;
|
|
|
28
31
|
* nothing between here and the runtime has an opinion about it. Left unchecked,
|
|
29
32
|
* a typo like `{d, date, meduim}` extracts to a catalogue entry that looks
|
|
30
33
|
* perfectly normal and only misformats once it reaches a user.
|
|
34
|
+
*
|
|
35
|
+
* Three forms are accepted: a named style, a `::`-prefixed skeleton, and — for
|
|
36
|
+
* a number — a literal pattern such as `#,##0.00`.
|
|
31
37
|
*/
|
|
32
38
|
declare function validateArgumentStyle(type: ArgumentType, style: string): void;
|
|
33
39
|
//#endregion
|
|
@@ -147,7 +153,41 @@ type Message = LiteralMessage | ArgumentMessage | ElementMessage | ChoiceMessage
|
|
|
147
153
|
//#region src/features/messages/convert.d.ts
|
|
148
154
|
declare function convertMessageToIcu(message: Message): string;
|
|
149
155
|
//#endregion
|
|
156
|
+
//#region src/features/messages/escape.d.ts
|
|
157
|
+
/**
|
|
158
|
+
* Quoting for text that has to survive a message format unchanged.
|
|
159
|
+
*
|
|
160
|
+
* This lives beside the converter rather than in a transform because it belongs
|
|
161
|
+
* to the format being written, not to the syntax being read. A literal is the
|
|
162
|
+
* same literal whether it came from a template literal or from JSX, and the
|
|
163
|
+
* moment there is a second target format its reserved characters — and the way
|
|
164
|
+
* it spells an escape — are its own.
|
|
165
|
+
*/
|
|
166
|
+
/**
|
|
167
|
+
* Escape literal text so ICU MessageFormat 1 reads it as the text it is.
|
|
168
|
+
*
|
|
169
|
+
* MF1 escapes with an apostrophe: a run wrapped in one is taken literally, and a
|
|
170
|
+
* doubled apostrophe is a single apostrophe. The subtlety is that an apostrophe
|
|
171
|
+
* only opens a quoted run when a character that needs quoting follows it —
|
|
172
|
+
* anything else and the apostrophe is itself literal. So `'#'` is a real escape
|
|
173
|
+
* inside a plural and the three characters `'#'` anywhere else, which is why
|
|
174
|
+
* `hash` has to be told rather than assumed.
|
|
175
|
+
*
|
|
176
|
+
* @param text Literal text as the author wrote it
|
|
177
|
+
* @param hash Whether `#` stands for a number here, i.e. whether some `plural`
|
|
178
|
+
* or `ordinal` encloses this text
|
|
179
|
+
*/
|
|
180
|
+
declare function escapeIcuLiteral(text: string, hash?: boolean): string;
|
|
181
|
+
//#endregion
|
|
150
182
|
//#region src/features/messages/hash.d.ts
|
|
183
|
+
/**
|
|
184
|
+
* Hashed in userland rather than with `node:crypto`, because this runs wherever
|
|
185
|
+
* a bundler runs it. `createHash` is one of the entry points `unenv` leaves
|
|
186
|
+
* unimplemented — "[unenv] crypto.createHash is not implemented yet!" — so a
|
|
187
|
+
* plugin reaching for it throws in a Nitro or workerd build. The Web Crypto
|
|
188
|
+
* equivalent, `subtle.digest`, is async, and an id is resolved from a
|
|
189
|
+
* synchronous extraction pass.
|
|
190
|
+
*/
|
|
151
191
|
declare function generateHash(input: string, context?: string): string;
|
|
152
192
|
//#endregion
|
|
153
|
-
export { ARGUMENT_STYLES, ARGUMENT_TYPES, AUTO_INCREMENT_IDENTIFIER, ArgumentFormat, ArgumentMessage, ArgumentType, ChoiceMessage, CompositeMessage, ElementMessage, LiteralMessage, Message, PlaceholderEquivalence, assignSequenceIdentifiers, convertMessageToIcu, generateHash, getBranchCase, isArgumentType, validateArgumentStyle, validateBranchIdentifier };
|
|
193
|
+
export { ARGUMENT_STYLES, ARGUMENT_TYPES, AUTO_INCREMENT_IDENTIFIER, ArgumentFormat, ArgumentMessage, ArgumentType, ChoiceMessage, CompositeMessage, ElementMessage, LiteralMessage, Message, PlaceholderEquivalence, assignSequenceIdentifiers, convertMessageToIcu, escapeIcuLiteral, generateHash, getBranchCase, isArgumentType, validateArgumentStyle, validateBranchIdentifier };
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { t as generateHash } from "../../hash-DvzpieJD.mjs";
|
|
2
|
+
import { getDateTimeFormatOptions, parseDateTokens } from "@messageformat/date-skeleton";
|
|
3
|
+
import { getNumberFormatOptions, parseNumberSkeleton } from "@messageformat/number-skeleton";
|
|
4
|
+
//#region src/features/messages/escape.ts
|
|
5
|
+
function escapeIcuLiteral(text, hash = false) {
|
|
6
|
+
return text.replace(/'/gu, "''").replace(hash ? /[{}#]+/gu : /[{}]+/gu, (run) => `'${run}'`);
|
|
7
|
+
}
|
|
8
|
+
//#endregion
|
|
2
9
|
//#region src/features/messages/types.ts
|
|
3
10
|
var Base = class {
|
|
4
11
|
toICUString() {
|
|
@@ -73,40 +80,12 @@ var CompositeMessage = class extends Base {
|
|
|
73
80
|
//#endregion
|
|
74
81
|
//#region src/features/messages/identifier.ts
|
|
75
82
|
const AUTO_INCREMENT_IDENTIFIER = Symbol("auto-increment");
|
|
76
|
-
/**
|
|
77
|
-
* An ICU case, either an exact value or a key. ICU reserves its own pattern
|
|
78
|
-
* syntax, so a key carries no punctuation and no whitespace.
|
|
79
|
-
*/
|
|
80
83
|
const BRANCH_PATTERN = /^(?:=\d+|[^\p{Pattern_Syntax}\p{Pattern_White_Space}]+)$/u;
|
|
81
|
-
/**
|
|
82
|
-
* The ICU case a branch is written as.
|
|
83
|
-
*
|
|
84
|
-
* Under `plural` and `ordinal` a number names an exact value, spelled `=0`, and
|
|
85
|
-
* so is distinct from the CLDR category that would otherwise match it. `select`
|
|
86
|
-
* has no such syntax — its cases are literal string matches, and `=0` there is
|
|
87
|
-
* a parse error — so a numeric key stays bare, where it matches both `0` and
|
|
88
|
-
* `'0'`.
|
|
89
|
-
*
|
|
90
|
-
* Digits are read literally rather than coerced, because everything JavaScript
|
|
91
|
-
* is willing to call a number is not: `''`, `' '`, and `'+0'` all coerce to
|
|
92
|
-
* `=0`, which would pass for a key that selects zero and quietly leave the
|
|
93
|
-
* author's own key out of the catalogue. Anything else stays a key, where the
|
|
94
|
-
* whitespace or punctuation that made it numeric-looking is caught.
|
|
95
|
-
*/
|
|
96
84
|
function getBranchCase(kind, identifier) {
|
|
97
85
|
const key = String(identifier);
|
|
98
86
|
if (kind === "select") return key;
|
|
99
87
|
return /^\d+$/u.test(key) ? `=${+key}` : key;
|
|
100
88
|
}
|
|
101
|
-
/**
|
|
102
|
-
* Reject a branch key ICU cannot express, while the key is still attached to a
|
|
103
|
-
* file and a line.
|
|
104
|
-
*
|
|
105
|
-
* A hyphenated string union is ordinary application code and typechecks,
|
|
106
|
-
* builds, and extracts to a catalogue entry that looks perfectly normal — the
|
|
107
|
-
* only sign of trouble is a parse error at format time, in a message whose
|
|
108
|
-
* source is long gone.
|
|
109
|
-
*/
|
|
110
89
|
function validateBranchIdentifier(kind, identifier) {
|
|
111
90
|
if (typeof identifier !== "string") return;
|
|
112
91
|
const branch = getBranchCase(kind, identifier);
|
|
@@ -116,12 +95,6 @@ function validateBranchIdentifier(kind, identifier) {
|
|
|
116
95
|
}
|
|
117
96
|
if (kind === "select" && /^=\d+$/u.test(branch)) throw new Error(`Invalid select branch key '${identifier}', an exact value is only meaningful to 'plural' and 'ordinal', write it as '${branch.slice(1)}'`);
|
|
118
97
|
}
|
|
119
|
-
/**
|
|
120
|
-
* The nearest identifier-safe form of a key, so the error names the fix as well
|
|
121
|
-
* as the problem. The constraint comes from ICU rather than from anything the
|
|
122
|
-
* author wrote, and camel case is how the rest of the codebase already spells a
|
|
123
|
-
* name of more than one word.
|
|
124
|
-
*/
|
|
125
98
|
function suggestBranchIdentifier(identifier) {
|
|
126
99
|
const suggestion = identifier.split(/[^\p{L}\p{N}]+/u).filter(Boolean).map((word, index) => index === 0 ? word : word[0].toUpperCase() + word.slice(1)).join("");
|
|
127
100
|
if (!BRANCH_PATTERN.test(suggestion) || !Number.isNaN(+suggestion)) return void 0;
|
|
@@ -134,9 +107,20 @@ function assignSequenceIdentifiers(message, sequence = { current: 0 }, equivalen
|
|
|
134
107
|
while (reserved.has(identifier)) identifier = `${sequence.current++}`;
|
|
135
108
|
return identifier;
|
|
136
109
|
}
|
|
110
|
+
const numbered = {
|
|
111
|
+
tags: [],
|
|
112
|
+
values: []
|
|
113
|
+
};
|
|
114
|
+
function number(assigned, expression) {
|
|
115
|
+
const seen = assigned.find(([e]) => equivalent(e, expression));
|
|
116
|
+
if (seen) return seen[1];
|
|
117
|
+
const identifier = next();
|
|
118
|
+
assigned.push([expression, identifier]);
|
|
119
|
+
return identifier;
|
|
120
|
+
}
|
|
137
121
|
function walk(message) {
|
|
138
122
|
if (message instanceof ArgumentMessage || message instanceof ElementMessage || message instanceof ChoiceMessage) {
|
|
139
|
-
if (message.identifier === AUTO_INCREMENT_IDENTIFIER) message.identifier =
|
|
123
|
+
if (message.identifier === AUTO_INCREMENT_IDENTIFIER) message.identifier = number(message instanceof ElementMessage ? numbered.tags : numbered.values, message.expression);
|
|
140
124
|
}
|
|
141
125
|
if (message instanceof CompositeMessage || message instanceof ElementMessage) for (const child of message.children) walk(child);
|
|
142
126
|
if (message instanceof ChoiceMessage) for (const branch of message.branches) {
|
|
@@ -146,16 +130,6 @@ function assignSequenceIdentifiers(message, sequence = { current: 0 }, equivalen
|
|
|
146
130
|
}
|
|
147
131
|
walk(message);
|
|
148
132
|
}
|
|
149
|
-
/**
|
|
150
|
-
* Collect the identifiers already assigned before this pass runs, so generated
|
|
151
|
-
* sequence numbers never shadow an explicit one (e.g. an element tagged `0`).
|
|
152
|
-
*
|
|
153
|
-
* A name is claimed by what produced it, not by the name alone. Two that differ
|
|
154
|
-
* each compile to their own prop, and a translator moving them around a sentence
|
|
155
|
-
* has to be able to tell them apart, so they are a build error. Repeats are fine
|
|
156
|
-
* when nothing distinguishes them: the same variable interpolated twice is one
|
|
157
|
-
* value, and two identical elements are one tag.
|
|
158
|
-
*/
|
|
159
133
|
function collectAssignedIdentifiers(message, equivalent) {
|
|
160
134
|
const tags = /* @__PURE__ */ new Map();
|
|
161
135
|
const values = /* @__PURE__ */ new Map();
|
|
@@ -176,10 +150,11 @@ function collectAssignedIdentifiers(message, equivalent) {
|
|
|
176
150
|
//#endregion
|
|
177
151
|
//#region src/features/messages/convert.ts
|
|
178
152
|
function convertMessageToIcu(message) {
|
|
179
|
-
function internalConvertMessageToIcu(message) {
|
|
153
|
+
function internalConvertMessageToIcu(message, hash) {
|
|
180
154
|
switch (true) {
|
|
181
|
-
case message instanceof LiteralMessage: return String(message.text);
|
|
155
|
+
case message instanceof LiteralMessage: return escapeIcuLiteral(String(message.text), hash !== void 0);
|
|
182
156
|
case message instanceof ArgumentMessage: {
|
|
157
|
+
if (!message.format && hash !== void 0 && String(message.identifier) === hash) return "#";
|
|
183
158
|
const parts = [String(message.identifier)];
|
|
184
159
|
if (message.format) parts.push(message.format.type);
|
|
185
160
|
if (message.format?.style) parts.push(message.format.style);
|
|
@@ -187,39 +162,27 @@ function convertMessageToIcu(message) {
|
|
|
187
162
|
}
|
|
188
163
|
case message instanceof ElementMessage: {
|
|
189
164
|
if (message.children.length === 0) return `<${String(message.identifier)}/>`;
|
|
190
|
-
const children = message.children.map((m) => internalConvertMessageToIcu(m)).join("");
|
|
165
|
+
const children = message.children.map((m) => internalConvertMessageToIcu(m, hash)).join("");
|
|
191
166
|
return `<${String(message.identifier)}>${children}</${String(message.identifier)}>`;
|
|
192
167
|
}
|
|
193
168
|
case message instanceof ChoiceMessage: {
|
|
169
|
+
const scope = message.kind === "select" ? hash : String(message.identifier);
|
|
194
170
|
const branches = message.branches.map(({ identifier, value }) => ({
|
|
195
171
|
identifier: getBranchCase(message.kind, identifier),
|
|
196
|
-
value: internalConvertMessageToIcu(value)
|
|
172
|
+
value: internalConvertMessageToIcu(value, scope)
|
|
197
173
|
})).map(({ identifier, value }) => ` ${identifier} {${value}}\n`).join("");
|
|
198
174
|
const format = message.kind === "ordinal" ? "selectordinal" : message.kind;
|
|
199
175
|
const offset = message.offset === void 0 || message.kind === "select" ? "" : ` offset:${message.offset}`;
|
|
200
176
|
return `{${String(message.identifier)}, ${format},${offset}\n${branches}}`;
|
|
201
177
|
}
|
|
202
|
-
case message instanceof CompositeMessage: return Object.entries(message.children).map(([, m]) => internalConvertMessageToIcu(m)).join("");
|
|
178
|
+
case message instanceof CompositeMessage: return Object.entries(message.children).map(([, m]) => internalConvertMessageToIcu(m, hash)).join("");
|
|
203
179
|
default: throw new Error("Unknown message type", { cause: message });
|
|
204
180
|
}
|
|
205
181
|
}
|
|
206
|
-
return internalConvertMessageToIcu(message).trim();
|
|
182
|
+
return internalConvertMessageToIcu(message, void 0).trim();
|
|
207
183
|
}
|
|
208
184
|
//#endregion
|
|
209
185
|
//#region src/features/messages/format.ts
|
|
210
|
-
/**
|
|
211
|
-
* The ICU argument types a macro can author, and the named styles each accepts.
|
|
212
|
-
*
|
|
213
|
-
* `currency` is deliberately absent from `number`. MF1 has nowhere to write the
|
|
214
|
-
* currency code — it comes from the formatter's configuration, not the message
|
|
215
|
-
* — so `{price, number, currency}` formats as a literal `{$price}` at runtime
|
|
216
|
-
* rather than an amount. Currency belongs to number skeletons, which the
|
|
217
|
-
* formatter does not accept yet either.
|
|
218
|
-
*
|
|
219
|
-
* `spellout`, RBNF `ordinal`, and `choice` are absent by decision rather than
|
|
220
|
-
* oversight: the first two are ICU4J/ICU4C rule-based formats with no `Intl`
|
|
221
|
-
* equivalent, and the third is deprecated in ICU itself in favour of `plural`.
|
|
222
|
-
*/
|
|
223
186
|
const ARGUMENT_STYLES = {
|
|
224
187
|
number: ["integer", "percent"],
|
|
225
188
|
date: [
|
|
@@ -239,36 +202,32 @@ const ARGUMENT_TYPES = Object.keys(ARGUMENT_STYLES);
|
|
|
239
202
|
function isArgumentType(kind) {
|
|
240
203
|
return Object.hasOwn(ARGUMENT_STYLES, kind);
|
|
241
204
|
}
|
|
242
|
-
/**
|
|
243
|
-
* A literal `NumberFormat` pattern, e.g. `#,##0.00`.
|
|
244
|
-
*
|
|
245
|
-
* A pattern has to carry a digit placeholder — `#` or `0` — because that is
|
|
246
|
-
* what makes it a pattern rather than a word. Without that requirement any
|
|
247
|
-
* brace-free string qualifies, which quietly readmits the named styles this
|
|
248
|
-
* module exists to reject: `currency` would sail through as a "pattern" and
|
|
249
|
-
* extract to the `{price, number, currency}` the formatter cannot honour, and
|
|
250
|
-
* so would a plain typo.
|
|
251
|
-
*
|
|
252
|
-
* Braces are excluded separately: ICU reserves them for its own pattern syntax,
|
|
253
|
-
* so a style carrying one would close the argument early and take the rest of
|
|
254
|
-
* the message with it.
|
|
255
|
-
*/
|
|
256
205
|
const LITERAL_STYLE_PATTERN = /^[^{}\r\n]*[#0][^{}\r\n]*$/;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
206
|
+
const SKELETON = "::";
|
|
207
|
+
function invalidSkeleton(type, skeleton) {
|
|
208
|
+
if (skeleton === "") return "it names no fields";
|
|
209
|
+
const errors = [];
|
|
210
|
+
if (type === "number") {
|
|
211
|
+
getNumberFormatOptions(parseNumberSkeleton(skeleton, (error) => errors.push(String(error))), (stem) => {
|
|
212
|
+
if (stem !== "scale") errors.push(`unsupported stem '${stem}'`);
|
|
213
|
+
});
|
|
214
|
+
return errors[0];
|
|
215
|
+
}
|
|
216
|
+
const options = getDateTimeFormatOptions(parseDateTokens(skeleton), (_type, message) => errors.push(message));
|
|
217
|
+
if (errors.length > 0) return errors[0];
|
|
218
|
+
if (Object.keys(options).length === 0) return "it names no fields";
|
|
219
|
+
}
|
|
266
220
|
function validateArgumentStyle(type, style) {
|
|
267
221
|
const named = ARGUMENT_STYLES[type];
|
|
268
222
|
if (named.includes(style)) return;
|
|
223
|
+
if (style.startsWith(SKELETON)) {
|
|
224
|
+
const reason = invalidSkeleton(type, style.slice(2));
|
|
225
|
+
if (!reason) return;
|
|
226
|
+
throw new Error(`Invalid ${type} skeleton '${style}': ${reason}`);
|
|
227
|
+
}
|
|
269
228
|
if (type === "number" && LITERAL_STYLE_PATTERN.test(style)) return;
|
|
270
229
|
const expected = named.map((s) => `'${s}'`).join(", ");
|
|
271
|
-
throw new Error(`Invalid ${type} style '${style}', expected ${expected}` + (type === "number" ? ", or a literal number pattern such as #,##0.00" : ""));
|
|
230
|
+
throw new Error(`Invalid ${type} style '${style}', expected ${expected}, or a skeleton such as ` + (type === "number" ? "::currency/EUR" : "::yyyyMMdd") + (type === "number" ? ", or a literal number pattern such as #,##0.00" : ""));
|
|
272
231
|
}
|
|
273
232
|
//#endregion
|
|
274
|
-
export { ARGUMENT_STYLES, ARGUMENT_TYPES, AUTO_INCREMENT_IDENTIFIER, ArgumentMessage, ChoiceMessage, CompositeMessage, ElementMessage, LiteralMessage, assignSequenceIdentifiers, convertMessageToIcu, generateHash, getBranchCase, isArgumentType, validateArgumentStyle, validateBranchIdentifier };
|
|
233
|
+
export { ARGUMENT_STYLES, ARGUMENT_TYPES, AUTO_INCREMENT_IDENTIFIER, ArgumentMessage, ChoiceMessage, CompositeMessage, ElementMessage, LiteralMessage, assignSequenceIdentifiers, convertMessageToIcu, escapeIcuLiteral, generateHash, getBranchCase, isArgumentType, validateArgumentStyle, validateBranchIdentifier };
|
package/dist/index.cjs
CHANGED
|
@@ -43,11 +43,6 @@ const Transformer = zod.object({
|
|
|
43
43
|
extract: zod.custom((v) => typeof v === "function"),
|
|
44
44
|
transform: zod.custom((v) => typeof v === "function")
|
|
45
45
|
});
|
|
46
|
-
/**
|
|
47
|
-
* A message declared in the config rather than found in source. The shorthand
|
|
48
|
-
* is the source string; the object form adds the metadata a descriptor would
|
|
49
|
-
* otherwise carry.
|
|
50
|
-
*/
|
|
51
46
|
const DeclaredMessage = zod.string().or(zod.object({
|
|
52
47
|
message: zod.string(),
|
|
53
48
|
context: zod.string().optional(),
|
|
@@ -62,13 +57,6 @@ const Bucket = zod.object({
|
|
|
62
57
|
zod.string(),
|
|
63
58
|
".{extension}"
|
|
64
59
|
]),
|
|
65
|
-
/**
|
|
66
|
-
* Messages that belong in the catalogue but have no call site to extract
|
|
67
|
-
* them from — strings owned by a non-JavaScript artefact such as a manifest,
|
|
68
|
-
* a store listing, or an email subject. Keyed by id, so the id is stable and
|
|
69
|
-
* hand-written by definition, and merged into every extraction alongside
|
|
70
|
-
* whatever the transformers find.
|
|
71
|
-
*/
|
|
72
60
|
messages: zod.record(zod.string(), DeclaredMessage).optional(),
|
|
73
61
|
formatter: Formatter,
|
|
74
62
|
transformer: Transformer.transform((t) => [t]).or(Transformer.array()).transform((t) => ({
|
|
@@ -94,12 +82,6 @@ const Bucket = zod.object({
|
|
|
94
82
|
}));
|
|
95
83
|
const Config = zod.object({
|
|
96
84
|
locales: zod.tuple([zod.string()], zod.string()),
|
|
97
|
-
/**
|
|
98
|
-
* Per-locale fallback chains, most specific first, e.g.
|
|
99
|
-
* `{ 'en-NZ': ['en-GB'], 'es-MX': 'es' }`. The source locale (the first entry
|
|
100
|
-
* in {@link Config.locales}) is always appended as the final fallback, so an
|
|
101
|
-
* untranslated key ultimately resolves to the source string.
|
|
102
|
-
*/
|
|
103
85
|
fallbackLocales: zod.record(zod.string(), zod.string().or(zod.string().array())).optional(),
|
|
104
86
|
buckets: Bucket.array()
|
|
105
87
|
});
|
package/dist/index.mjs
CHANGED
|
@@ -18,11 +18,6 @@ const Transformer = z.object({
|
|
|
18
18
|
extract: z.custom((v) => typeof v === "function"),
|
|
19
19
|
transform: z.custom((v) => typeof v === "function")
|
|
20
20
|
});
|
|
21
|
-
/**
|
|
22
|
-
* A message declared in the config rather than found in source. The shorthand
|
|
23
|
-
* is the source string; the object form adds the metadata a descriptor would
|
|
24
|
-
* otherwise carry.
|
|
25
|
-
*/
|
|
26
21
|
const DeclaredMessage = z.string().or(z.object({
|
|
27
22
|
message: z.string(),
|
|
28
23
|
context: z.string().optional(),
|
|
@@ -37,13 +32,6 @@ const Bucket = z.object({
|
|
|
37
32
|
z.string(),
|
|
38
33
|
".{extension}"
|
|
39
34
|
]),
|
|
40
|
-
/**
|
|
41
|
-
* Messages that belong in the catalogue but have no call site to extract
|
|
42
|
-
* them from — strings owned by a non-JavaScript artefact such as a manifest,
|
|
43
|
-
* a store listing, or an email subject. Keyed by id, so the id is stable and
|
|
44
|
-
* hand-written by definition, and merged into every extraction alongside
|
|
45
|
-
* whatever the transformers find.
|
|
46
|
-
*/
|
|
47
35
|
messages: z.record(z.string(), DeclaredMessage).optional(),
|
|
48
36
|
formatter: Formatter,
|
|
49
37
|
transformer: Transformer.transform((t) => [t]).or(Transformer.array()).transform((t) => ({
|
|
@@ -69,12 +57,6 @@ const Bucket = z.object({
|
|
|
69
57
|
}));
|
|
70
58
|
const Config = z.object({
|
|
71
59
|
locales: z.tuple([z.string()], z.string()),
|
|
72
|
-
/**
|
|
73
|
-
* Per-locale fallback chains, most specific first, e.g.
|
|
74
|
-
* `{ 'en-NZ': ['en-GB'], 'es-MX': 'es' }`. The source locale (the first entry
|
|
75
|
-
* in {@link Config.locales}) is always appended as the final fallback, so an
|
|
76
|
-
* untranslated key ultimately resolves to the source string.
|
|
77
|
-
*/
|
|
78
60
|
fallbackLocales: z.record(z.string(), z.string().or(z.string().array())).optional(),
|
|
79
61
|
buckets: Bucket.array()
|
|
80
62
|
});
|
|
@@ -22,7 +22,6 @@ function findConfigFile(moduleName, projectDir) {
|
|
|
22
22
|
}
|
|
23
23
|
//#endregion
|
|
24
24
|
//#region src/features/loader/module.ts
|
|
25
|
-
/** Requires `path` without leaving it in (or reading it from) the require cache. */
|
|
26
25
|
function requireFresh(require, path) {
|
|
27
26
|
const resolved = require.resolve(path);
|
|
28
27
|
delete require.cache[resolved];
|
|
@@ -35,11 +34,6 @@ const TYPESCRIPT = /* @__PURE__ */ new Set([
|
|
|
35
34
|
".mts",
|
|
36
35
|
".cts"
|
|
37
36
|
]);
|
|
38
|
-
/**
|
|
39
|
-
* Adds what the runtime cannot: why a config it could not read is one it will
|
|
40
|
-
* never read. Everything else is the config's own problem, and its error
|
|
41
|
-
* already describes that better than we could.
|
|
42
|
-
*/
|
|
43
37
|
function diagnose(error, path) {
|
|
44
38
|
const code = error?.code;
|
|
45
39
|
if (code === "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX") return "Enums, namespaces and parameter properties are not erasable, so no runtime will read them.";
|
|
@@ -47,12 +41,6 @@ function diagnose(error, path) {
|
|
|
47
41
|
if ((code === "ERR_UNKNOWN_FILE_EXTENSION" || !code && error instanceof SyntaxError) && TYPESCRIPT.has(extname(path).toLowerCase())) return "Reading this config needs Node 22.18+, or a runtime that loads TypeScript itself (Bun, Deno, tsx).";
|
|
48
42
|
return null;
|
|
49
43
|
}
|
|
50
|
-
/**
|
|
51
|
-
* Loads a config file as it sits on disk, leaving the runtime to deal with the
|
|
52
|
-
* extension. Nothing is copied or rewritten, so `__dirname`,
|
|
53
|
-
* `import.meta.dirname`, relative specifiers and `require.resolve` all resolve
|
|
54
|
-
* against the config's own directory.
|
|
55
|
-
*/
|
|
56
44
|
function loadModule(path) {
|
|
57
45
|
try {
|
|
58
46
|
return requireFresh(createRequire(path), path);
|
|
@@ -73,11 +61,6 @@ const configLoaders = Object.freeze({
|
|
|
73
61
|
});
|
|
74
62
|
//#endregion
|
|
75
63
|
//#region src/features/loader/resolve.ts
|
|
76
|
-
/**
|
|
77
|
-
* The config file {@link resolveConfig} would load, for callers that need the
|
|
78
|
-
* path itself rather than its contents — salting a bundler's cache key with it,
|
|
79
|
-
* for one, since what a catalogue assembles into depends on the config.
|
|
80
|
-
*/
|
|
81
64
|
function resolveConfigFile(name = "saykit") {
|
|
82
65
|
const file = findConfigFile(name, process.cwd());
|
|
83
66
|
if (!file) throw new Error(`Could not find config file for "${name}"`);
|
|
@@ -22,7 +22,6 @@ function findConfigFile(moduleName, projectDir) {
|
|
|
22
22
|
}
|
|
23
23
|
//#endregion
|
|
24
24
|
//#region src/features/loader/module.ts
|
|
25
|
-
/** Requires `path` without leaving it in (or reading it from) the require cache. */
|
|
26
25
|
function requireFresh(require, path) {
|
|
27
26
|
const resolved = require.resolve(path);
|
|
28
27
|
delete require.cache[resolved];
|
|
@@ -35,11 +34,6 @@ const TYPESCRIPT = /* @__PURE__ */ new Set([
|
|
|
35
34
|
".mts",
|
|
36
35
|
".cts"
|
|
37
36
|
]);
|
|
38
|
-
/**
|
|
39
|
-
* Adds what the runtime cannot: why a config it could not read is one it will
|
|
40
|
-
* never read. Everything else is the config's own problem, and its error
|
|
41
|
-
* already describes that better than we could.
|
|
42
|
-
*/
|
|
43
37
|
function diagnose(error, path) {
|
|
44
38
|
const code = error?.code;
|
|
45
39
|
if (code === "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX") return "Enums, namespaces and parameter properties are not erasable, so no runtime will read them.";
|
|
@@ -47,12 +41,6 @@ function diagnose(error, path) {
|
|
|
47
41
|
if ((code === "ERR_UNKNOWN_FILE_EXTENSION" || !code && error instanceof SyntaxError) && TYPESCRIPT.has((0, node_path.extname)(path).toLowerCase())) return "Reading this config needs Node 22.18+, or a runtime that loads TypeScript itself (Bun, Deno, tsx).";
|
|
48
42
|
return null;
|
|
49
43
|
}
|
|
50
|
-
/**
|
|
51
|
-
* Loads a config file as it sits on disk, leaving the runtime to deal with the
|
|
52
|
-
* extension. Nothing is copied or rewritten, so `__dirname`,
|
|
53
|
-
* `import.meta.dirname`, relative specifiers and `require.resolve` all resolve
|
|
54
|
-
* against the config's own directory.
|
|
55
|
-
*/
|
|
56
44
|
function loadModule(path) {
|
|
57
45
|
try {
|
|
58
46
|
return requireFresh((0, node_module.createRequire)(path), path);
|
|
@@ -73,11 +61,6 @@ const configLoaders = Object.freeze({
|
|
|
73
61
|
});
|
|
74
62
|
//#endregion
|
|
75
63
|
//#region src/features/loader/resolve.ts
|
|
76
|
-
/**
|
|
77
|
-
* The config file {@link resolveConfig} would load, for callers that need the
|
|
78
|
-
* path itself rather than its contents — salting a bundler's cache key with it,
|
|
79
|
-
* for one, since what a catalogue assembles into depends on the config.
|
|
80
|
-
*/
|
|
81
64
|
function resolveConfigFile(name = "saykit") {
|
|
82
65
|
const file = findConfigFile(name, process.cwd());
|
|
83
66
|
if (!file) throw new Error(`Could not find config file for "${name}"`);
|
|
@@ -30,14 +30,6 @@ function pruneLocaleMessages(existingMessages, sourceMessages) {
|
|
|
30
30
|
function expandBucketOutputPath(bucket, locale, extension = bucket.formatter.extension) {
|
|
31
31
|
return resolve(bucket.output.replaceAll("{locale}", locale).replaceAll("{extension}", extension.slice(1)));
|
|
32
32
|
}
|
|
33
|
-
/**
|
|
34
|
-
* The declaration file that types a catalogue, e.g. `en.json` -> `en.d.json.ts`.
|
|
35
|
-
*
|
|
36
|
-
* TypeScript resolves `./en.json` by stripping the extension and looking for
|
|
37
|
-
* `en.d.json.ts`; the `en.json.d.ts` form is only consulted for extensions the
|
|
38
|
-
* resolver does not recognise. Non-JS extensions additionally require
|
|
39
|
-
* `allowArbitraryExtensions` in the consumer's tsconfig.
|
|
40
|
-
*/
|
|
41
33
|
function declarationPathFor(cataloguePath) {
|
|
42
34
|
const { dir, name, ext } = parse(cataloguePath);
|
|
43
35
|
return resolve(dir, `${name}.d${ext}.ts`);
|
|
@@ -30,14 +30,6 @@ function pruneLocaleMessages(existingMessages, sourceMessages) {
|
|
|
30
30
|
function expandBucketOutputPath(bucket, locale, extension = bucket.formatter.extension) {
|
|
31
31
|
return (0, node_path.resolve)(bucket.output.replaceAll("{locale}", locale).replaceAll("{extension}", extension.slice(1)));
|
|
32
32
|
}
|
|
33
|
-
/**
|
|
34
|
-
* The declaration file that types a catalogue, e.g. `en.json` -> `en.d.json.ts`.
|
|
35
|
-
*
|
|
36
|
-
* TypeScript resolves `./en.json` by stripping the extension and looking for
|
|
37
|
-
* `en.d.json.ts`; the `en.json.d.ts` form is only consulted for extensions the
|
|
38
|
-
* resolver does not recognise. Non-JS extensions additionally require
|
|
39
|
-
* `allowArbitraryExtensions` in the consumer's tsconfig.
|
|
40
|
-
*/
|
|
41
33
|
function declarationPathFor(cataloguePath) {
|
|
42
34
|
const { dir, name, ext } = (0, node_path.parse)(cataloguePath);
|
|
43
35
|
return (0, node_path.resolve)(dir, `${name}.d${ext}.ts`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saykit/config",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "CLI and configuration tooling for saykit",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -74,6 +74,8 @@
|
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
76
|
"@commander-js/extra-typings": "^15.0.0",
|
|
77
|
+
"@messageformat/date-skeleton": "2.0.0-0",
|
|
78
|
+
"@messageformat/number-skeleton": "2.0.0-0",
|
|
77
79
|
"commander": "^15.0.0",
|
|
78
80
|
"js-sha256": "^0.12.0",
|
|
79
81
|
"picomatch": "^4.0.5",
|