@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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- const require_storage = require("../storage-DqhzA5H8.cjs");
3
- const require_loader = require("../loader-BdCeoj3-.cjs");
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();
@@ -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-B6mn0s3V.mjs";
3
- import { t as resolveConfig } from "../loader-DwF1lM3f.mjs";
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-DqhzA5H8.cjs");
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-B6mn0s3V.mjs";
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-BdCeoj3-.cjs");
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-DwF1lM3f.mjs";
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 = next();
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
- * Reject an argument style the formatter cannot honour, while the style is
260
- * still attached to a file and a line.
261
- *
262
- * A style is a bare string in the source and a bare string in the catalogue, so
263
- * nothing between here and the runtime has an opinion about it. Left unchecked,
264
- * a typo like `{d, date, meduim}` extracts to a catalogue entry that looks
265
- * perfectly normal and only misformats once it reaches a user.
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 literal `{$price}` at runtime
8
- * rather than an amount. Currency belongs to number skeletons, which the
9
- * formatter does not accept yet either.
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 literal `{$price}` at runtime
8
- * rather than an amount. Currency belongs to number skeletons, which the
9
- * formatter does not accept yet either.
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 = next();
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
- * Reject an argument style the formatter cannot honour, while the style is
259
- * still attached to a file and a line.
260
- *
261
- * A style is a bare string in the source and a bare string in the catalogue, so
262
- * nothing between here and the runtime has an opinion about it. Left unchecked,
263
- * a typo like `{d, date, meduim}` extracts to a catalogue entry that looks
264
- * perfectly normal and only misformats once it reaches a user.
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.7.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",