@saykit/config 0.8.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,29 +1,8 @@
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");
3
5
  //#region src/features/messages/escape.ts
4
- /**
5
- * Quoting for text that has to survive a message format unchanged.
6
- *
7
- * This lives beside the converter rather than in a transform because it belongs
8
- * to the format being written, not to the syntax being read. A literal is the
9
- * same literal whether it came from a template literal or from JSX, and the
10
- * moment there is a second target format its reserved characters — and the way
11
- * it spells an escape — are its own.
12
- */
13
- /**
14
- * Escape literal text so ICU MessageFormat 1 reads it as the text it is.
15
- *
16
- * MF1 escapes with an apostrophe: a run wrapped in one is taken literally, and a
17
- * doubled apostrophe is a single apostrophe. The subtlety is that an apostrophe
18
- * only opens a quoted run when a character that needs quoting follows it —
19
- * anything else and the apostrophe is itself literal. So `'#'` is a real escape
20
- * inside a plural and the three characters `'#'` anywhere else, which is why
21
- * `hash` has to be told rather than assumed.
22
- *
23
- * @param text Literal text as the author wrote it
24
- * @param hash Whether `#` stands for a number here, i.e. whether some `plural`
25
- * or `ordinal` encloses this text
26
- */
27
6
  function escapeIcuLiteral(text, hash = false) {
28
7
  return text.replace(/'/gu, "''").replace(hash ? /[{}#]+/gu : /[{}]+/gu, (run) => `'${run}'`);
29
8
  }
@@ -102,40 +81,12 @@ var CompositeMessage = class extends Base {
102
81
  //#endregion
103
82
  //#region src/features/messages/identifier.ts
104
83
  const AUTO_INCREMENT_IDENTIFIER = Symbol("auto-increment");
105
- /**
106
- * An ICU case, either an exact value or a key. ICU reserves its own pattern
107
- * syntax, so a key carries no punctuation and no whitespace.
108
- */
109
84
  const BRANCH_PATTERN = /^(?:=\d+|[^\p{Pattern_Syntax}\p{Pattern_White_Space}]+)$/u;
110
- /**
111
- * The ICU case a branch is written as.
112
- *
113
- * Under `plural` and `ordinal` a number names an exact value, spelled `=0`, and
114
- * so is distinct from the CLDR category that would otherwise match it. `select`
115
- * has no such syntax — its cases are literal string matches, and `=0` there is
116
- * a parse error — so a numeric key stays bare, where it matches both `0` and
117
- * `'0'`.
118
- *
119
- * Digits are read literally rather than coerced, because everything JavaScript
120
- * is willing to call a number is not: `''`, `' '`, and `'+0'` all coerce to
121
- * `=0`, which would pass for a key that selects zero and quietly leave the
122
- * author's own key out of the catalogue. Anything else stays a key, where the
123
- * whitespace or punctuation that made it numeric-looking is caught.
124
- */
125
85
  function getBranchCase(kind, identifier) {
126
86
  const key = String(identifier);
127
87
  if (kind === "select") return key;
128
88
  return /^\d+$/u.test(key) ? `=${+key}` : key;
129
89
  }
130
- /**
131
- * Reject a branch key ICU cannot express, while the key is still attached to a
132
- * file and a line.
133
- *
134
- * A hyphenated string union is ordinary application code and typechecks,
135
- * builds, and extracts to a catalogue entry that looks perfectly normal — the
136
- * only sign of trouble is a parse error at format time, in a message whose
137
- * source is long gone.
138
- */
139
90
  function validateBranchIdentifier(kind, identifier) {
140
91
  if (typeof identifier !== "string") return;
141
92
  const branch = getBranchCase(kind, identifier);
@@ -145,12 +96,6 @@ function validateBranchIdentifier(kind, identifier) {
145
96
  }
146
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)}'`);
147
98
  }
148
- /**
149
- * The nearest identifier-safe form of a key, so the error names the fix as well
150
- * as the problem. The constraint comes from ICU rather than from anything the
151
- * author wrote, and camel case is how the rest of the codebase already spells a
152
- * name of more than one word.
153
- */
154
99
  function suggestBranchIdentifier(identifier) {
155
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("");
156
101
  if (!BRANCH_PATTERN.test(suggestion) || !Number.isNaN(+suggestion)) return void 0;
@@ -167,14 +112,6 @@ function assignSequenceIdentifiers(message, sequence = { current: 0 }, equivalen
167
112
  tags: [],
168
113
  values: []
169
114
  };
170
- /**
171
- * The number this expression already has, or a fresh one. An anonymous
172
- * placeholder is named by its position, so the same value written twice would
173
- * otherwise arrive as two names — `{0} x {1}` for one length, which asks a
174
- * translator to keep two holes in step and the caller to supply one value
175
- * under two props. This is the rule explicit names already follow, where a
176
- * repeat is allowed precisely when nothing distinguishes it.
177
- */
178
115
  function number(assigned, expression) {
179
116
  const seen = assigned.find(([e]) => equivalent(e, expression));
180
117
  if (seen) return seen[1];
@@ -194,16 +131,6 @@ function assignSequenceIdentifiers(message, sequence = { current: 0 }, equivalen
194
131
  }
195
132
  walk(message);
196
133
  }
197
- /**
198
- * Collect the identifiers already assigned before this pass runs, so generated
199
- * sequence numbers never shadow an explicit one (e.g. an element tagged `0`).
200
- *
201
- * A name is claimed by what produced it, not by the name alone. Two that differ
202
- * each compile to their own prop, and a translator moving them around a sentence
203
- * has to be able to tell them apart, so they are a build error. Repeats are fine
204
- * when nothing distinguishes them: the same variable interpolated twice is one
205
- * value, and two identical elements are one tag.
206
- */
207
134
  function collectAssignedIdentifiers(message, equivalent) {
208
135
  const tags = /* @__PURE__ */ new Map();
209
136
  const values = /* @__PURE__ */ new Map();
@@ -257,19 +184,6 @@ function convertMessageToIcu(message) {
257
184
  }
258
185
  //#endregion
259
186
  //#region src/features/messages/format.ts
260
- /**
261
- * The ICU argument types a macro can author, and the named styles each accepts.
262
- *
263
- * `currency` is deliberately absent from `number`. MF1 has nowhere to write the
264
- * currency code — it comes from the formatter's configuration, not the message
265
- * — so `{price, number, currency}` formats as a literal `{$price}` at runtime
266
- * rather than an amount. Currency belongs to number skeletons, which the
267
- * formatter does not accept yet either.
268
- *
269
- * `spellout`, RBNF `ordinal`, and `choice` are absent by decision rather than
270
- * oversight: the first two are ICU4J/ICU4C rule-based formats with no `Intl`
271
- * equivalent, and the third is deprecated in ICU itself in favour of `plural`.
272
- */
273
187
  const ARGUMENT_STYLES = {
274
188
  number: ["integer", "percent"],
275
189
  date: [
@@ -289,36 +203,32 @@ const ARGUMENT_TYPES = Object.keys(ARGUMENT_STYLES);
289
203
  function isArgumentType(kind) {
290
204
  return Object.hasOwn(ARGUMENT_STYLES, kind);
291
205
  }
292
- /**
293
- * A literal `NumberFormat` pattern, e.g. `#,##0.00`.
294
- *
295
- * A pattern has to carry a digit placeholder — `#` or `0` — because that is
296
- * what makes it a pattern rather than a word. Without that requirement any
297
- * brace-free string qualifies, which quietly readmits the named styles this
298
- * module exists to reject: `currency` would sail through as a "pattern" and
299
- * extract to the `{price, number, currency}` the formatter cannot honour, and
300
- * so would a plain typo.
301
- *
302
- * Braces are excluded separately: ICU reserves them for its own pattern syntax,
303
- * so a style carrying one would close the argument early and take the rest of
304
- * the message with it.
305
- */
306
206
  const LITERAL_STYLE_PATTERN = /^[^{}\r\n]*[#0][^{}\r\n]*$/;
307
- /**
308
- * Reject an argument style the formatter cannot honour, while the style is
309
- * still attached to a file and a line.
310
- *
311
- * A style is a bare string in the source and a bare string in the catalogue, so
312
- * nothing between here and the runtime has an opinion about it. Left unchecked,
313
- * a typo like `{d, date, meduim}` extracts to a catalogue entry that looks
314
- * perfectly normal and only misformats once it reaches a user.
315
- */
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
+ }
316
221
  function validateArgumentStyle(type, style) {
317
222
  const named = ARGUMENT_STYLES[type];
318
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
+ }
319
229
  if (type === "number" && LITERAL_STYLE_PATTERN.test(style)) return;
320
230
  const expected = named.map((s) => `'${s}'`).join(", ");
321
- 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" : ""));
322
232
  }
323
233
  //#endregion
324
234
  exports.ARGUMENT_STYLES = ARGUMENT_STYLES;
@@ -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
@@ -174,6 +180,14 @@ declare function convertMessageToIcu(message: Message): string;
174
180
  declare function escapeIcuLiteral(text: string, hash?: boolean): string;
175
181
  //#endregion
176
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
+ */
177
191
  declare function generateHash(input: string, context?: string): string;
178
192
  //#endregion
179
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
@@ -174,6 +180,14 @@ declare function convertMessageToIcu(message: Message): string;
174
180
  declare function escapeIcuLiteral(text: string, hash?: boolean): string;
175
181
  //#endregion
176
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
+ */
177
191
  declare function generateHash(input: string, context?: string): string;
178
192
  //#endregion
179
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,28 +1,7 @@
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";
2
4
  //#region src/features/messages/escape.ts
3
- /**
4
- * Quoting for text that has to survive a message format unchanged.
5
- *
6
- * This lives beside the converter rather than in a transform because it belongs
7
- * to the format being written, not to the syntax being read. A literal is the
8
- * same literal whether it came from a template literal or from JSX, and the
9
- * moment there is a second target format its reserved characters — and the way
10
- * it spells an escape — are its own.
11
- */
12
- /**
13
- * Escape literal text so ICU MessageFormat 1 reads it as the text it is.
14
- *
15
- * MF1 escapes with an apostrophe: a run wrapped in one is taken literally, and a
16
- * doubled apostrophe is a single apostrophe. The subtlety is that an apostrophe
17
- * only opens a quoted run when a character that needs quoting follows it —
18
- * anything else and the apostrophe is itself literal. So `'#'` is a real escape
19
- * inside a plural and the three characters `'#'` anywhere else, which is why
20
- * `hash` has to be told rather than assumed.
21
- *
22
- * @param text Literal text as the author wrote it
23
- * @param hash Whether `#` stands for a number here, i.e. whether some `plural`
24
- * or `ordinal` encloses this text
25
- */
26
5
  function escapeIcuLiteral(text, hash = false) {
27
6
  return text.replace(/'/gu, "''").replace(hash ? /[{}#]+/gu : /[{}]+/gu, (run) => `'${run}'`);
28
7
  }
@@ -101,40 +80,12 @@ var CompositeMessage = class extends Base {
101
80
  //#endregion
102
81
  //#region src/features/messages/identifier.ts
103
82
  const AUTO_INCREMENT_IDENTIFIER = Symbol("auto-increment");
104
- /**
105
- * An ICU case, either an exact value or a key. ICU reserves its own pattern
106
- * syntax, so a key carries no punctuation and no whitespace.
107
- */
108
83
  const BRANCH_PATTERN = /^(?:=\d+|[^\p{Pattern_Syntax}\p{Pattern_White_Space}]+)$/u;
109
- /**
110
- * The ICU case a branch is written as.
111
- *
112
- * Under `plural` and `ordinal` a number names an exact value, spelled `=0`, and
113
- * so is distinct from the CLDR category that would otherwise match it. `select`
114
- * has no such syntax — its cases are literal string matches, and `=0` there is
115
- * a parse error — so a numeric key stays bare, where it matches both `0` and
116
- * `'0'`.
117
- *
118
- * Digits are read literally rather than coerced, because everything JavaScript
119
- * is willing to call a number is not: `''`, `' '`, and `'+0'` all coerce to
120
- * `=0`, which would pass for a key that selects zero and quietly leave the
121
- * author's own key out of the catalogue. Anything else stays a key, where the
122
- * whitespace or punctuation that made it numeric-looking is caught.
123
- */
124
84
  function getBranchCase(kind, identifier) {
125
85
  const key = String(identifier);
126
86
  if (kind === "select") return key;
127
87
  return /^\d+$/u.test(key) ? `=${+key}` : key;
128
88
  }
129
- /**
130
- * Reject a branch key ICU cannot express, while the key is still attached to a
131
- * file and a line.
132
- *
133
- * A hyphenated string union is ordinary application code and typechecks,
134
- * builds, and extracts to a catalogue entry that looks perfectly normal — the
135
- * only sign of trouble is a parse error at format time, in a message whose
136
- * source is long gone.
137
- */
138
89
  function validateBranchIdentifier(kind, identifier) {
139
90
  if (typeof identifier !== "string") return;
140
91
  const branch = getBranchCase(kind, identifier);
@@ -144,12 +95,6 @@ function validateBranchIdentifier(kind, identifier) {
144
95
  }
145
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)}'`);
146
97
  }
147
- /**
148
- * The nearest identifier-safe form of a key, so the error names the fix as well
149
- * as the problem. The constraint comes from ICU rather than from anything the
150
- * author wrote, and camel case is how the rest of the codebase already spells a
151
- * name of more than one word.
152
- */
153
98
  function suggestBranchIdentifier(identifier) {
154
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("");
155
100
  if (!BRANCH_PATTERN.test(suggestion) || !Number.isNaN(+suggestion)) return void 0;
@@ -166,14 +111,6 @@ function assignSequenceIdentifiers(message, sequence = { current: 0 }, equivalen
166
111
  tags: [],
167
112
  values: []
168
113
  };
169
- /**
170
- * The number this expression already has, or a fresh one. An anonymous
171
- * placeholder is named by its position, so the same value written twice would
172
- * otherwise arrive as two names — `{0} x {1}` for one length, which asks a
173
- * translator to keep two holes in step and the caller to supply one value
174
- * under two props. This is the rule explicit names already follow, where a
175
- * repeat is allowed precisely when nothing distinguishes it.
176
- */
177
114
  function number(assigned, expression) {
178
115
  const seen = assigned.find(([e]) => equivalent(e, expression));
179
116
  if (seen) return seen[1];
@@ -193,16 +130,6 @@ function assignSequenceIdentifiers(message, sequence = { current: 0 }, equivalen
193
130
  }
194
131
  walk(message);
195
132
  }
196
- /**
197
- * Collect the identifiers already assigned before this pass runs, so generated
198
- * sequence numbers never shadow an explicit one (e.g. an element tagged `0`).
199
- *
200
- * A name is claimed by what produced it, not by the name alone. Two that differ
201
- * each compile to their own prop, and a translator moving them around a sentence
202
- * has to be able to tell them apart, so they are a build error. Repeats are fine
203
- * when nothing distinguishes them: the same variable interpolated twice is one
204
- * value, and two identical elements are one tag.
205
- */
206
133
  function collectAssignedIdentifiers(message, equivalent) {
207
134
  const tags = /* @__PURE__ */ new Map();
208
135
  const values = /* @__PURE__ */ new Map();
@@ -256,19 +183,6 @@ function convertMessageToIcu(message) {
256
183
  }
257
184
  //#endregion
258
185
  //#region src/features/messages/format.ts
259
- /**
260
- * The ICU argument types a macro can author, and the named styles each accepts.
261
- *
262
- * `currency` is deliberately absent from `number`. MF1 has nowhere to write the
263
- * currency code — it comes from the formatter's configuration, not the message
264
- * — so `{price, number, currency}` formats as a literal `{$price}` at runtime
265
- * rather than an amount. Currency belongs to number skeletons, which the
266
- * formatter does not accept yet either.
267
- *
268
- * `spellout`, RBNF `ordinal`, and `choice` are absent by decision rather than
269
- * oversight: the first two are ICU4J/ICU4C rule-based formats with no `Intl`
270
- * equivalent, and the third is deprecated in ICU itself in favour of `plural`.
271
- */
272
186
  const ARGUMENT_STYLES = {
273
187
  number: ["integer", "percent"],
274
188
  date: [
@@ -288,36 +202,32 @@ const ARGUMENT_TYPES = Object.keys(ARGUMENT_STYLES);
288
202
  function isArgumentType(kind) {
289
203
  return Object.hasOwn(ARGUMENT_STYLES, kind);
290
204
  }
291
- /**
292
- * A literal `NumberFormat` pattern, e.g. `#,##0.00`.
293
- *
294
- * A pattern has to carry a digit placeholder — `#` or `0` — because that is
295
- * what makes it a pattern rather than a word. Without that requirement any
296
- * brace-free string qualifies, which quietly readmits the named styles this
297
- * module exists to reject: `currency` would sail through as a "pattern" and
298
- * extract to the `{price, number, currency}` the formatter cannot honour, and
299
- * so would a plain typo.
300
- *
301
- * Braces are excluded separately: ICU reserves them for its own pattern syntax,
302
- * so a style carrying one would close the argument early and take the rest of
303
- * the message with it.
304
- */
305
205
  const LITERAL_STYLE_PATTERN = /^[^{}\r\n]*[#0][^{}\r\n]*$/;
306
- /**
307
- * Reject an argument style the formatter cannot honour, while the style is
308
- * still attached to a file and a line.
309
- *
310
- * A style is a bare string in the source and a bare string in the catalogue, so
311
- * nothing between here and the runtime has an opinion about it. Left unchecked,
312
- * a typo like `{d, date, meduim}` extracts to a catalogue entry that looks
313
- * perfectly normal and only misformats once it reaches a user.
314
- */
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
+ }
315
220
  function validateArgumentStyle(type, style) {
316
221
  const named = ARGUMENT_STYLES[type];
317
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
+ }
318
228
  if (type === "number" && LITERAL_STYLE_PATTERN.test(style)) return;
319
229
  const expected = named.map((s) => `'${s}'`).join(", ");
320
- 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" : ""));
321
231
  }
322
232
  //#endregion
323
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.8.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",