@saykit/config 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,12 @@ import js from '@saykit/transform-js';
21
21
 
22
22
  export default defineConfig({
23
23
  locales: ['en', 'fr'],
24
+ // Optional per-locale fallback chains, most specific first. The source locale
25
+ // (the first entry in `locales`) is always the final fallback.
26
+ fallbackLocales: {
27
+ 'en-NZ': ['en-GB'],
28
+ 'es-MX': 'es',
29
+ },
24
30
  buckets: [
25
31
  {
26
32
  include: ['src/**/*.ts'],
@@ -37,8 +43,22 @@ export default defineConfig({
37
43
  ```sh
38
44
  saykit extract # extract messages once
39
45
  saykit extract --watch # extract and watch for changes
46
+ saykit clean # reconcile other locales against the source
40
47
  ```
41
48
 
49
+ Extraction only writes the **source** locale (the first entry in `locales`). Locales that don't
50
+ have a file yet are bootstrapped with an empty, header-only catalogue so a TMS can register them;
51
+ existing translation files are left untouched. Translated content is owned by your TMS.
52
+
53
+ At load time (via the unplugin or Babel plugin), each locale module is filled from its fallback
54
+ chain, so an untranslated key resolves to a fallback locale and ultimately the source string — the
55
+ runtime still loads a single locale.
56
+
57
+ `saykit clean` reconciles every non-source locale against the current source catalogue: it adds
58
+ missing keys (untranslated), drops entries that no longer exist in the source, and preserves any
59
+ existing translations. Use it when you want to propagate source changes into locale files yourself
60
+ rather than leaving it to your TMS.
61
+
42
62
  ## Documentation
43
63
 
44
64
  Full configuration guide and CLI reference at [saykit.js.org](https://saykit.js.org).
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  require("../index.cjs");
3
- const require_loader = require("../loader-DnWt_9U2.cjs");
4
- const require_hash = require("../hash-CDfMT76E.cjs");
3
+ const require_storage = require("../storage-3BTY8yws.cjs");
4
+ const require_loader = require("../loader-B25O-vF6.cjs");
5
5
  let _commander_js_extra_typings = require("@commander-js/extra-typings");
6
+ let node_fs_promises = require("node:fs/promises");
6
7
  let node_path = require("node:path");
7
8
  let node_async_hooks = require("node:async_hooks");
8
- let node_fs_promises = require("node:fs/promises");
9
9
  //#region src/features/logger.ts
10
10
  const RESET = "\x1B[0m";
11
11
  const DIM = "\x1B[2m";
@@ -45,6 +45,23 @@ var Logger = class {
45
45
  };
46
46
  new node_async_hooks.AsyncLocalStorage({ defaultValue: new Logger() });
47
47
  //#endregion
48
+ //#region src/commands/clean.ts
49
+ var clean_default = new _commander_js_extra_typings.Command("clean").description("Reconcile non-source locale files against the source locale").option("-v, --verbose", "enable verbose logging", false).option("-q, --quiet", "suppress all logging", false).action(async (options) => {
50
+ const config = require_loader.resolveConfig();
51
+ const logger = new Logger(options);
52
+ logger.header("🧹 Cleaning Locales");
53
+ const [sourceLocale, ...otherLocales] = config.locales;
54
+ for (const bucket of config.buckets) {
55
+ const sourceMessages = await require_storage.readCatalogueMessages(bucket, sourceLocale);
56
+ logger.info(`Reconciling ${otherLocales.length} locale(s) against ${sourceLocale}`);
57
+ for (const locale of otherLocales) {
58
+ logger.step(`Reconciling ${locale}`);
59
+ await require_storage.writeCatalogueMessages(bucket, locale, require_storage.reconcileLocaleMessages(await require_storage.readCatalogueMessages(bucket, locale), sourceMessages));
60
+ }
61
+ }
62
+ logger.success("Locales cleaned");
63
+ });
64
+ //#endregion
48
65
  //#region src/features/catalogue/extractor.ts
49
66
  async function extractMessagesFromFile(path, bucket) {
50
67
  const content = await (0, node_fs_promises.readFile)(path, "utf8").catch(() => "");
@@ -56,74 +73,6 @@ async function extractMessagesFromFile(path, bucket) {
56
73
  }));
57
74
  }
58
75
  //#endregion
59
- //#region src/features/catalogue/merge.ts
60
- function mergeUnique(...items) {
61
- return Array.from(new Set(items.flat()));
62
- }
63
- function getMessageKey(message) {
64
- return message.id ?? require_hash.generateHash(message.message, message.context);
65
- }
66
- function mergeExtractedMessages(messages) {
67
- const mergedMessages = messages.reduce((map, message) => {
68
- const key = getMessageKey(message);
69
- const existing = map.get(key) ?? message;
70
- map.set(key, {
71
- ...existing,
72
- comments: mergeUnique(...existing.comments, ...message.comments),
73
- references: mergeUnique(...existing.references, ...message.references)
74
- });
75
- return map;
76
- }, /* @__PURE__ */ new Map());
77
- return Array.from(mergedMessages.values());
78
- }
79
- function reconcileLocaleMessages(existingMessages, nextMessages) {
80
- const existingMessagesByKey = existingMessages.reduce((map, message) => {
81
- map.set(getMessageKey(message), message);
82
- return map;
83
- }, /* @__PURE__ */ new Map());
84
- const reconciledMessages = nextMessages.reduce((map, message) => {
85
- const key = getMessageKey(message);
86
- const existingMessage = existingMessagesByKey.get(key);
87
- map.set(key, {
88
- message: message.message,
89
- translation: void 0,
90
- ...existingMessage,
91
- id: message.id,
92
- context: message.context,
93
- comments: message.comments,
94
- references: message.references
95
- });
96
- return map;
97
- }, /* @__PURE__ */ new Map());
98
- return Array.from(reconciledMessages.values());
99
- }
100
- //#endregion
101
- //#region src/features/catalogue/path.ts
102
- function expandBucketOutputPath(bucket, locale, extension = bucket.formatter.extension) {
103
- return (0, node_path.resolve)(bucket.output.replaceAll("{locale}", locale).replaceAll("{extension}", extension.slice(1)));
104
- }
105
- //#endregion
106
- //#region src/features/catalogue/storage.ts
107
- const DECLARATION_CONTENT = `
108
- declare const messages: Record<string, string>;
109
- export default messages;
110
- `.trim();
111
- async function readCatalogueMessages(bucket, locale, path = expandBucketOutputPath(bucket, locale)) {
112
- const content = await (0, node_fs_promises.readFile)(path, "utf8").catch(() => "");
113
- if (!content) return [];
114
- return bucket.formatter.parse(content);
115
- }
116
- async function writeCatalogueMessages(bucket, locale, messages, path = expandBucketOutputPath(bucket, locale)) {
117
- const existingContent = await (0, node_fs_promises.readFile)(path, "utf8").catch(() => void 0);
118
- const catalogueContent = bucket.formatter.stringify(messages, {
119
- locale,
120
- existingContent
121
- });
122
- const declarationPath = `${path}.d.ts`;
123
- await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
124
- await Promise.all([(0, node_fs_promises.writeFile)(path, catalogueContent), (0, node_fs_promises.writeFile)(declarationPath, DECLARATION_CONTENT)]);
125
- }
126
- //#endregion
127
76
  //#region src/features/watch.ts
128
77
  /**
129
78
  * Expand a buckets include and exclude patterns into a flat list of file paths.
@@ -183,6 +132,9 @@ var BucketWorker = class {
183
132
  };
184
133
  //#endregion
185
134
  //#region src/features/workers/extract-worker.ts
135
+ function exists(path) {
136
+ return (0, node_fs_promises.access)(path).then(() => true, () => false);
137
+ }
186
138
  var BucketExtractWorker = class extends BucketWorker {
187
139
  #indexedMessagesByPath = /* @__PURE__ */ new Map();
188
140
  get #indexedMessages() {
@@ -212,13 +164,18 @@ var BucketExtractWorker = class extends BucketWorker {
212
164
  this.logger.info(`Total extracted messages: ${this.#indexedMessages.length}`);
213
165
  }
214
166
  async write() {
215
- const mergedMessages = mergeExtractedMessages(this.#indexedMessages);
216
- this.logger.info(`Writing ${mergedMessages.length} messages to locales`);
217
- for (const locale of this.config.locales) {
218
- this.logger.step(`Writing locale file for ${locale} to disk`);
219
- const existingMessages = await readCatalogueMessages(this.bucket, locale);
220
- const nextMessages = locale === this.config.locales[0] ? mergedMessages : reconcileLocaleMessages(existingMessages, mergedMessages);
221
- await writeCatalogueMessages(this.bucket, locale, nextMessages);
167
+ const mergedMessages = require_storage.mergeExtractedMessages(this.#indexedMessages);
168
+ const [sourceLocale, ...otherLocales] = this.config.locales;
169
+ this.logger.info(`Writing ${mergedMessages.length} messages to ${sourceLocale}`);
170
+ this.logger.step(`Writing locale file for ${sourceLocale} to disk`);
171
+ await require_storage.writeCatalogueMessages(this.bucket, sourceLocale, mergedMessages);
172
+ for (const locale of otherLocales) {
173
+ if (await exists(require_storage.expandBucketOutputPath(this.bucket, locale))) {
174
+ this.logger.step(`Skipping existing locale file for ${locale}`);
175
+ continue;
176
+ }
177
+ this.logger.step(`Creating empty locale file for ${locale}`);
178
+ await require_storage.writeCatalogueMessages(this.bucket, locale, []);
222
179
  }
223
180
  this.logger.success(`Extraction complete for bucket: ${this.bucket.include}`);
224
181
  }
@@ -248,5 +205,5 @@ var extract_default = new _commander_js_extra_typings.Command("extract").descrip
248
205
  });
249
206
  //#endregion
250
207
  //#region src/commands/index.ts
251
- _commander_js_extra_typings.program.name("saykit").helpOption("-h, --help", "Display help for command").helpCommand("help [command]", "Display help for command").addCommand(extract_default).parse();
208
+ _commander_js_extra_typings.program.name("saykit").helpOption("-h, --help", "Display help for command").helpCommand("help [command]", "Display help for command").addCommand(extract_default).addCommand(clean_default).parse();
252
209
  //#endregion
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { t as resolveConfig } from "../loader-CGuahnrc.mjs";
3
- import { t as generateHash } from "../hash-Cs0dRdGf.mjs";
2
+ import { a as reconcileLocaleMessages, i as mergeExtractedMessages, n as writeCatalogueMessages, r as expandBucketOutputPath, t as readCatalogueMessages } from "../storage-C9nzxPtZ.mjs";
3
+ import { t as resolveConfig } from "../loader-C2eolE_1.mjs";
4
4
  import { Command, program } from "@commander-js/extra-typings";
5
- import { dirname, join, relative, resolve } from "node:path";
5
+ import { access, glob, readFile, watch } from "node:fs/promises";
6
+ import { join, relative } from "node:path";
6
7
  import { AsyncLocalStorage } from "node:async_hooks";
7
- import { glob, mkdir, readFile, watch, writeFile } from "node:fs/promises";
8
8
  //#region src/features/logger.ts
9
9
  const RESET = "\x1B[0m";
10
10
  const DIM = "\x1B[2m";
@@ -44,6 +44,23 @@ var Logger = class {
44
44
  };
45
45
  new AsyncLocalStorage({ defaultValue: new Logger() });
46
46
  //#endregion
47
+ //#region src/commands/clean.ts
48
+ var clean_default = new Command("clean").description("Reconcile non-source locale files against the source locale").option("-v, --verbose", "enable verbose logging", false).option("-q, --quiet", "suppress all logging", false).action(async (options) => {
49
+ const config = resolveConfig();
50
+ const logger = new Logger(options);
51
+ logger.header("🧹 Cleaning Locales");
52
+ const [sourceLocale, ...otherLocales] = config.locales;
53
+ for (const bucket of config.buckets) {
54
+ const sourceMessages = await readCatalogueMessages(bucket, sourceLocale);
55
+ logger.info(`Reconciling ${otherLocales.length} locale(s) against ${sourceLocale}`);
56
+ for (const locale of otherLocales) {
57
+ logger.step(`Reconciling ${locale}`);
58
+ await writeCatalogueMessages(bucket, locale, reconcileLocaleMessages(await readCatalogueMessages(bucket, locale), sourceMessages));
59
+ }
60
+ }
61
+ logger.success("Locales cleaned");
62
+ });
63
+ //#endregion
47
64
  //#region src/features/catalogue/extractor.ts
48
65
  async function extractMessagesFromFile(path, bucket) {
49
66
  const content = await readFile(path, "utf8").catch(() => "");
@@ -55,74 +72,6 @@ async function extractMessagesFromFile(path, bucket) {
55
72
  }));
56
73
  }
57
74
  //#endregion
58
- //#region src/features/catalogue/merge.ts
59
- function mergeUnique(...items) {
60
- return Array.from(new Set(items.flat()));
61
- }
62
- function getMessageKey(message) {
63
- return message.id ?? generateHash(message.message, message.context);
64
- }
65
- function mergeExtractedMessages(messages) {
66
- const mergedMessages = messages.reduce((map, message) => {
67
- const key = getMessageKey(message);
68
- const existing = map.get(key) ?? message;
69
- map.set(key, {
70
- ...existing,
71
- comments: mergeUnique(...existing.comments, ...message.comments),
72
- references: mergeUnique(...existing.references, ...message.references)
73
- });
74
- return map;
75
- }, /* @__PURE__ */ new Map());
76
- return Array.from(mergedMessages.values());
77
- }
78
- function reconcileLocaleMessages(existingMessages, nextMessages) {
79
- const existingMessagesByKey = existingMessages.reduce((map, message) => {
80
- map.set(getMessageKey(message), message);
81
- return map;
82
- }, /* @__PURE__ */ new Map());
83
- const reconciledMessages = nextMessages.reduce((map, message) => {
84
- const key = getMessageKey(message);
85
- const existingMessage = existingMessagesByKey.get(key);
86
- map.set(key, {
87
- message: message.message,
88
- translation: void 0,
89
- ...existingMessage,
90
- id: message.id,
91
- context: message.context,
92
- comments: message.comments,
93
- references: message.references
94
- });
95
- return map;
96
- }, /* @__PURE__ */ new Map());
97
- return Array.from(reconciledMessages.values());
98
- }
99
- //#endregion
100
- //#region src/features/catalogue/path.ts
101
- function expandBucketOutputPath(bucket, locale, extension = bucket.formatter.extension) {
102
- return resolve(bucket.output.replaceAll("{locale}", locale).replaceAll("{extension}", extension.slice(1)));
103
- }
104
- //#endregion
105
- //#region src/features/catalogue/storage.ts
106
- const DECLARATION_CONTENT = `
107
- declare const messages: Record<string, string>;
108
- export default messages;
109
- `.trim();
110
- async function readCatalogueMessages(bucket, locale, path = expandBucketOutputPath(bucket, locale)) {
111
- const content = await readFile(path, "utf8").catch(() => "");
112
- if (!content) return [];
113
- return bucket.formatter.parse(content);
114
- }
115
- async function writeCatalogueMessages(bucket, locale, messages, path = expandBucketOutputPath(bucket, locale)) {
116
- const existingContent = await readFile(path, "utf8").catch(() => void 0);
117
- const catalogueContent = bucket.formatter.stringify(messages, {
118
- locale,
119
- existingContent
120
- });
121
- const declarationPath = `${path}.d.ts`;
122
- await mkdir(dirname(path), { recursive: true });
123
- await Promise.all([writeFile(path, catalogueContent), writeFile(declarationPath, DECLARATION_CONTENT)]);
124
- }
125
- //#endregion
126
75
  //#region src/features/watch.ts
127
76
  /**
128
77
  * Expand a buckets include and exclude patterns into a flat list of file paths.
@@ -182,6 +131,9 @@ var BucketWorker = class {
182
131
  };
183
132
  //#endregion
184
133
  //#region src/features/workers/extract-worker.ts
134
+ function exists(path) {
135
+ return access(path).then(() => true, () => false);
136
+ }
185
137
  var BucketExtractWorker = class extends BucketWorker {
186
138
  #indexedMessagesByPath = /* @__PURE__ */ new Map();
187
139
  get #indexedMessages() {
@@ -212,12 +164,17 @@ var BucketExtractWorker = class extends BucketWorker {
212
164
  }
213
165
  async write() {
214
166
  const mergedMessages = mergeExtractedMessages(this.#indexedMessages);
215
- this.logger.info(`Writing ${mergedMessages.length} messages to locales`);
216
- for (const locale of this.config.locales) {
217
- this.logger.step(`Writing locale file for ${locale} to disk`);
218
- const existingMessages = await readCatalogueMessages(this.bucket, locale);
219
- const nextMessages = locale === this.config.locales[0] ? mergedMessages : reconcileLocaleMessages(existingMessages, mergedMessages);
220
- await writeCatalogueMessages(this.bucket, locale, nextMessages);
167
+ const [sourceLocale, ...otherLocales] = this.config.locales;
168
+ this.logger.info(`Writing ${mergedMessages.length} messages to ${sourceLocale}`);
169
+ this.logger.step(`Writing locale file for ${sourceLocale} to disk`);
170
+ await writeCatalogueMessages(this.bucket, sourceLocale, mergedMessages);
171
+ for (const locale of otherLocales) {
172
+ if (await exists(expandBucketOutputPath(this.bucket, locale))) {
173
+ this.logger.step(`Skipping existing locale file for ${locale}`);
174
+ continue;
175
+ }
176
+ this.logger.step(`Creating empty locale file for ${locale}`);
177
+ await writeCatalogueMessages(this.bucket, locale, []);
221
178
  }
222
179
  this.logger.success(`Extraction complete for bucket: ${this.bucket.include}`);
223
180
  }
@@ -247,6 +204,6 @@ var extract_default = new Command("extract").description("Extract messages from
247
204
  });
248
205
  //#endregion
249
206
  //#region src/commands/index.ts
250
- program.name("saykit").helpOption("-h, --help", "Display help for command").helpCommand("help [command]", "Display help for command").addCommand(extract_default).parse();
207
+ program.name("saykit").helpOption("-h, --help", "Display help for command").helpCommand("help [command]", "Display help for command").addCommand(extract_default).addCommand(clean_default).parse();
251
208
  //#endregion
252
209
  export {};
@@ -0,0 +1,60 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ require("../../index.cjs");
3
+ const require_hash = require("../../hash-8It7TJpB.cjs");
4
+ const require_storage = require("../../storage-3BTY8yws.cjs");
5
+ let node_path = require("node:path");
6
+ //#region src/features/catalogue/record.ts
7
+ /**
8
+ * Resolve the fallback chain for a locale, most specific first. The source
9
+ * locale (the first configured locale) is always the final fallback, so an
10
+ * untranslated key ultimately resolves to the source string.
11
+ */
12
+ function resolveFallbackChain(config, locale) {
13
+ const source = config.locales[0];
14
+ const configured = config.fallbackLocales?.[locale];
15
+ const fallbacks = configured ? Array.isArray(configured) ? configured : [configured] : [];
16
+ return Array.from(new Set([
17
+ locale,
18
+ ...fallbacks,
19
+ source
20
+ ]));
21
+ }
22
+ /**
23
+ * Resolve the catalogue files that contribute to a locale module, most specific
24
+ * first. When the path does not map to a configured locale it is loaded on its
25
+ * own.
26
+ */
27
+ function resolveCatalogueSources(config, bucket, path) {
28
+ const resolved = (0, node_path.resolve)(path);
29
+ const locale = config.locales.find((l) => require_storage.expandBucketOutputPath(bucket, l) === resolved);
30
+ return {
31
+ locale,
32
+ sources: locale ? resolveFallbackChain(config, locale).map((l) => require_storage.expandBucketOutputPath(bucket, l)) : [resolved]
33
+ };
34
+ }
35
+ /**
36
+ * Assemble a `{ id: string }` record from catalogue file contents. `contents`
37
+ * must be aligned with the `sources` returned by {@link resolveCatalogueSources}
38
+ * (most specific first): more specific locales override their fallbacks, and any
39
+ * key still untranslated falls back to its source message.
40
+ */
41
+ function assembleCatalogueRecord(bucket, contents) {
42
+ const record = {};
43
+ for (const content of [...contents].reverse()) {
44
+ if (!content) continue;
45
+ for (const message of bucket.formatter.parse(content)) {
46
+ const key = message.id || require_hash.generateHash(message.message, message.context);
47
+ record[key] = message.translation || message.message;
48
+ }
49
+ }
50
+ return record;
51
+ }
52
+ //#endregion
53
+ exports.assembleCatalogueRecord = assembleCatalogueRecord;
54
+ exports.expandBucketOutputPath = require_storage.expandBucketOutputPath;
55
+ exports.mergeExtractedMessages = require_storage.mergeExtractedMessages;
56
+ exports.readCatalogueMessages = require_storage.readCatalogueMessages;
57
+ exports.reconcileLocaleMessages = require_storage.reconcileLocaleMessages;
58
+ exports.resolveCatalogueSources = resolveCatalogueSources;
59
+ exports.resolveFallbackChain = resolveFallbackChain;
60
+ exports.writeCatalogueMessages = require_storage.writeCatalogueMessages;
@@ -0,0 +1,59 @@
1
+ import { i as Message, n as Config, t as Bucket } from "../../shapes-Ba3xS10n.cjs";
2
+
3
+ //#region src/features/catalogue/merge.d.ts
4
+ declare function mergeExtractedMessages(messages: Message[]): {
5
+ message: string;
6
+ comments: string[];
7
+ references: string[];
8
+ translation?: string | undefined;
9
+ id?: string | undefined;
10
+ context?: string | undefined;
11
+ }[];
12
+ declare function reconcileLocaleMessages(existingMessages: Message[], nextMessages: Message[]): {
13
+ message: string;
14
+ comments: string[];
15
+ references: string[];
16
+ translation?: string | undefined;
17
+ id?: string | undefined;
18
+ context?: string | undefined;
19
+ }[];
20
+ //#endregion
21
+ //#region src/features/catalogue/path.d.ts
22
+ declare function expandBucketOutputPath(bucket: Bucket, locale: string, extension?: `.${string}`): string;
23
+ //#endregion
24
+ //#region src/features/catalogue/record.d.ts
25
+ /**
26
+ * Resolve the fallback chain for a locale, most specific first. The source
27
+ * locale (the first configured locale) is always the final fallback, so an
28
+ * untranslated key ultimately resolves to the source string.
29
+ */
30
+ declare function resolveFallbackChain(config: Config, locale: string): string[];
31
+ /**
32
+ * Resolve the catalogue files that contribute to a locale module, most specific
33
+ * first. When the path does not map to a configured locale it is loaded on its
34
+ * own.
35
+ */
36
+ declare function resolveCatalogueSources(config: Config, bucket: Bucket, path: string): {
37
+ locale: string | undefined;
38
+ sources: string[];
39
+ };
40
+ /**
41
+ * Assemble a `{ id: string }` record from catalogue file contents. `contents`
42
+ * must be aligned with the `sources` returned by {@link resolveCatalogueSources}
43
+ * (most specific first): more specific locales override their fallbacks, and any
44
+ * key still untranslated falls back to its source message.
45
+ */
46
+ declare function assembleCatalogueRecord(bucket: Bucket, contents: string[]): Record<string, string>;
47
+ //#endregion
48
+ //#region src/features/catalogue/storage.d.ts
49
+ declare function readCatalogueMessages(bucket: Bucket, locale: string, path?: string): Promise<{
50
+ message: string;
51
+ comments: string[];
52
+ references: string[];
53
+ translation?: string | undefined;
54
+ id?: string | undefined;
55
+ context?: string | undefined;
56
+ }[]>;
57
+ declare function writeCatalogueMessages(bucket: Bucket, locale: string, messages: Message[], path?: string): Promise<void>;
58
+ //#endregion
59
+ export { assembleCatalogueRecord, expandBucketOutputPath, mergeExtractedMessages, readCatalogueMessages, reconcileLocaleMessages, resolveCatalogueSources, resolveFallbackChain, writeCatalogueMessages };
@@ -0,0 +1,59 @@
1
+ import { i as Message, n as Config, t as Bucket } from "../../shapes-NPfwlInG.mjs";
2
+
3
+ //#region src/features/catalogue/merge.d.ts
4
+ declare function mergeExtractedMessages(messages: Message[]): {
5
+ message: string;
6
+ comments: string[];
7
+ references: string[];
8
+ translation?: string | undefined;
9
+ id?: string | undefined;
10
+ context?: string | undefined;
11
+ }[];
12
+ declare function reconcileLocaleMessages(existingMessages: Message[], nextMessages: Message[]): {
13
+ message: string;
14
+ comments: string[];
15
+ references: string[];
16
+ translation?: string | undefined;
17
+ id?: string | undefined;
18
+ context?: string | undefined;
19
+ }[];
20
+ //#endregion
21
+ //#region src/features/catalogue/path.d.ts
22
+ declare function expandBucketOutputPath(bucket: Bucket, locale: string, extension?: `.${string}`): string;
23
+ //#endregion
24
+ //#region src/features/catalogue/record.d.ts
25
+ /**
26
+ * Resolve the fallback chain for a locale, most specific first. The source
27
+ * locale (the first configured locale) is always the final fallback, so an
28
+ * untranslated key ultimately resolves to the source string.
29
+ */
30
+ declare function resolveFallbackChain(config: Config, locale: string): string[];
31
+ /**
32
+ * Resolve the catalogue files that contribute to a locale module, most specific
33
+ * first. When the path does not map to a configured locale it is loaded on its
34
+ * own.
35
+ */
36
+ declare function resolveCatalogueSources(config: Config, bucket: Bucket, path: string): {
37
+ locale: string | undefined;
38
+ sources: string[];
39
+ };
40
+ /**
41
+ * Assemble a `{ id: string }` record from catalogue file contents. `contents`
42
+ * must be aligned with the `sources` returned by {@link resolveCatalogueSources}
43
+ * (most specific first): more specific locales override their fallbacks, and any
44
+ * key still untranslated falls back to its source message.
45
+ */
46
+ declare function assembleCatalogueRecord(bucket: Bucket, contents: string[]): Record<string, string>;
47
+ //#endregion
48
+ //#region src/features/catalogue/storage.d.ts
49
+ declare function readCatalogueMessages(bucket: Bucket, locale: string, path?: string): Promise<{
50
+ message: string;
51
+ comments: string[];
52
+ references: string[];
53
+ translation?: string | undefined;
54
+ id?: string | undefined;
55
+ context?: string | undefined;
56
+ }[]>;
57
+ declare function writeCatalogueMessages(bucket: Bucket, locale: string, messages: Message[], path?: string): Promise<void>;
58
+ //#endregion
59
+ export { assembleCatalogueRecord, expandBucketOutputPath, mergeExtractedMessages, readCatalogueMessages, reconcileLocaleMessages, resolveCatalogueSources, resolveFallbackChain, writeCatalogueMessages };
@@ -0,0 +1,51 @@
1
+ import { t as generateHash } from "../../hash-DyC8GzMa.mjs";
2
+ import { a as reconcileLocaleMessages, i as mergeExtractedMessages, n as writeCatalogueMessages, r as expandBucketOutputPath, t as readCatalogueMessages } from "../../storage-C9nzxPtZ.mjs";
3
+ import { resolve } from "node:path";
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
+ function resolveFallbackChain(config, locale) {
11
+ const source = config.locales[0];
12
+ const configured = config.fallbackLocales?.[locale];
13
+ const fallbacks = configured ? Array.isArray(configured) ? configured : [configured] : [];
14
+ return Array.from(new Set([
15
+ locale,
16
+ ...fallbacks,
17
+ source
18
+ ]));
19
+ }
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
+ function resolveCatalogueSources(config, bucket, path) {
26
+ const resolved = resolve(path);
27
+ const locale = config.locales.find((l) => expandBucketOutputPath(bucket, l) === resolved);
28
+ return {
29
+ locale,
30
+ sources: locale ? resolveFallbackChain(config, locale).map((l) => expandBucketOutputPath(bucket, l)) : [resolved]
31
+ };
32
+ }
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
+ function assembleCatalogueRecord(bucket, contents) {
40
+ const record = {};
41
+ for (const content of [...contents].reverse()) {
42
+ if (!content) continue;
43
+ for (const message of bucket.formatter.parse(content)) {
44
+ const key = message.id || generateHash(message.message, message.context);
45
+ record[key] = message.translation || message.message;
46
+ }
47
+ }
48
+ return record;
49
+ }
50
+ //#endregion
51
+ export { assembleCatalogueRecord, expandBucketOutputPath, mergeExtractedMessages, readCatalogueMessages, reconcileLocaleMessages, resolveCatalogueSources, resolveFallbackChain, writeCatalogueMessages };
@@ -1,3 +1,3 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_loader = require("../../loader-DnWt_9U2.cjs");
2
+ const require_loader = require("../../loader-B25O-vF6.cjs");
3
3
  exports.resolveConfig = require_loader.resolveConfig;
@@ -1,4 +1,4 @@
1
- import { n as Config } from "../../shapes-CJyTfZXd.cjs";
1
+ import { n as Config } from "../../shapes-Ba3xS10n.cjs";
2
2
 
3
3
  //#region src/features/loader/resolve.d.ts
4
4
  declare function resolveConfig(name?: string): Config;
@@ -1,4 +1,4 @@
1
- import { n as Config } from "../../shapes-DETrtvZf.mjs";
1
+ import { n as Config } from "../../shapes-NPfwlInG.mjs";
2
2
 
3
3
  //#region src/features/loader/resolve.d.ts
4
4
  declare function resolveConfig(name?: string): Config;
@@ -1,2 +1,2 @@
1
- import { t as resolveConfig } from "../../loader-CGuahnrc.mjs";
1
+ import { t as resolveConfig } from "../../loader-C2eolE_1.mjs";
2
2
  export { resolveConfig };
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_hash = require("../../hash-CDfMT76E.cjs");
2
+ const require_hash = require("../../hash-8It7TJpB.cjs");
3
3
  //#region src/features/messages/identifier.ts
4
4
  const AUTO_INCREMENT_IDENTIFIER = Symbol("auto-increment");
5
5
  function assignSequenceIdentifiers(message, sequence = { current: 0 }) {
@@ -1,4 +1,4 @@
1
- import { t as generateHash } from "../../hash-Cs0dRdGf.mjs";
1
+ import { t as generateHash } from "../../hash-DyC8GzMa.mjs";
2
2
  //#region src/features/messages/identifier.ts
3
3
  const AUTO_INCREMENT_IDENTIFIER = Symbol("auto-increment");
4
4
  function assignSequenceIdentifiers(message, sequence = { current: 0 }) {
package/dist/index.cjs CHANGED
@@ -65,6 +65,13 @@ const Bucket = zod.object({
65
65
  }));
66
66
  const Config = zod.object({
67
67
  locales: zod.tuple([zod.string()], zod.string()),
68
+ /**
69
+ * Per-locale fallback chains, most specific first, e.g.
70
+ * `{ 'en-NZ': ['en-GB'], 'es-MX': 'es' }`. The source locale (the first entry
71
+ * in {@link Config.locales}) is always appended as the final fallback, so an
72
+ * untranslated key ultimately resolves to the source string.
73
+ */
74
+ fallbackLocales: zod.record(zod.string(), zod.string().or(zod.string().array())).optional(),
68
75
  buckets: Bucket.array()
69
76
  });
70
77
  //#endregion
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as Transformer, i as Message, n as Config, r as Formatter, t as Bucket } from "./shapes-CJyTfZXd.cjs";
1
+ import { a as Transformer, i as Message, n as Config, r as Formatter, t as Bucket } from "./shapes-Ba3xS10n.cjs";
2
2
  import { input } from "zod";
3
3
  import * as _$picomatch_lib_picomatch_js0 from "picomatch/lib/picomatch.js";
4
4
 
@@ -33,6 +33,7 @@ declare function defineConfig<C extends input<typeof Config>>(config: C): {
33
33
  };
34
34
  exclude?: string[] | undefined;
35
35
  }[];
36
+ fallbackLocales?: Record<string, string | string[]> | undefined;
36
37
  };
37
38
  //#endregion
38
39
  export { Bucket, Config, Formatter, Message, Transformer, defineConfig };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as Transformer, i as Message, n as Config, r as Formatter, t as Bucket } from "./shapes-DETrtvZf.mjs";
1
+ import { a as Transformer, i as Message, n as Config, r as Formatter, t as Bucket } from "./shapes-NPfwlInG.mjs";
2
2
  import { input } from "zod";
3
3
  import * as _$picomatch_lib_picomatch_js0 from "picomatch/lib/picomatch.js";
4
4
 
@@ -33,6 +33,7 @@ declare function defineConfig<C extends input<typeof Config>>(config: C): {
33
33
  };
34
34
  exclude?: string[] | undefined;
35
35
  }[];
36
+ fallbackLocales?: Record<string, string | string[]> | undefined;
36
37
  };
37
38
  //#endregion
38
39
  export { Bucket, Config, Formatter, Message, Transformer, defineConfig };
package/dist/index.mjs CHANGED
@@ -40,6 +40,13 @@ const Bucket = z.object({
40
40
  }));
41
41
  const Config = z.object({
42
42
  locales: z.tuple([z.string()], z.string()),
43
+ /**
44
+ * Per-locale fallback chains, most specific first, e.g.
45
+ * `{ 'en-NZ': ['en-GB'], 'es-MX': 'es' }`. The source locale (the first entry
46
+ * in {@link Config.locales}) is always appended as the final fallback, so an
47
+ * untranslated key ultimately resolves to the source string.
48
+ */
49
+ fallbackLocales: z.record(z.string(), z.string().or(z.string().array())).optional(),
43
50
  buckets: Bucket.array()
44
51
  });
45
52
  //#endregion
@@ -131,6 +131,7 @@ declare const Bucket: z.ZodPipe<z.ZodObject<{
131
131
  type Bucket = z.infer<typeof Bucket>;
132
132
  declare const Config: z.ZodObject<{
133
133
  locales: z.ZodTuple<[z.ZodString], z.ZodString>;
134
+ fallbackLocales: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodString, z.ZodArray<z.ZodString>]>>>;
134
135
  buckets: z.ZodArray<z.ZodPipe<z.ZodObject<{
135
136
  include: z.ZodArray<z.ZodString>;
136
137
  exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -131,6 +131,7 @@ declare const Bucket: z.ZodPipe<z.ZodObject<{
131
131
  type Bucket = z.infer<typeof Bucket>;
132
132
  declare const Config: z.ZodObject<{
133
133
  locales: z.ZodTuple<[z.ZodString], z.ZodString>;
134
+ fallbackLocales: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodString, z.ZodArray<z.ZodString>]>>>;
134
135
  buckets: z.ZodArray<z.ZodPipe<z.ZodObject<{
135
136
  include: z.ZodArray<z.ZodString>;
136
137
  exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -0,0 +1,97 @@
1
+ require("./index.cjs");
2
+ const require_hash = require("./hash-8It7TJpB.cjs");
3
+ let node_fs_promises = require("node:fs/promises");
4
+ let node_path = require("node:path");
5
+ //#region src/features/catalogue/merge.ts
6
+ function mergeUnique(...items) {
7
+ return Array.from(new Set(items.flat()));
8
+ }
9
+ function getMessageKey(message) {
10
+ return message.id ?? require_hash.generateHash(message.message, message.context);
11
+ }
12
+ function mergeExtractedMessages(messages) {
13
+ const mergedMessages = messages.reduce((map, message) => {
14
+ const key = getMessageKey(message);
15
+ const existing = map.get(key) ?? message;
16
+ map.set(key, {
17
+ ...existing,
18
+ comments: mergeUnique(...existing.comments, ...message.comments),
19
+ references: mergeUnique(...existing.references, ...message.references)
20
+ });
21
+ return map;
22
+ }, /* @__PURE__ */ new Map());
23
+ return Array.from(mergedMessages.values());
24
+ }
25
+ function reconcileLocaleMessages(existingMessages, nextMessages) {
26
+ const existingMessagesByKey = existingMessages.reduce((map, message) => {
27
+ map.set(getMessageKey(message), message);
28
+ return map;
29
+ }, /* @__PURE__ */ new Map());
30
+ const reconciledMessages = nextMessages.reduce((map, message) => {
31
+ const key = getMessageKey(message);
32
+ const existingMessage = existingMessagesByKey.get(key);
33
+ map.set(key, {
34
+ ...message,
35
+ translation: existingMessage?.translation
36
+ });
37
+ return map;
38
+ }, /* @__PURE__ */ new Map());
39
+ return Array.from(reconciledMessages.values());
40
+ }
41
+ //#endregion
42
+ //#region src/features/catalogue/path.ts
43
+ function expandBucketOutputPath(bucket, locale, extension = bucket.formatter.extension) {
44
+ return (0, node_path.resolve)(bucket.output.replaceAll("{locale}", locale).replaceAll("{extension}", extension.slice(1)));
45
+ }
46
+ //#endregion
47
+ //#region src/features/catalogue/storage.ts
48
+ const DECLARATION_CONTENT = `
49
+ declare const messages: Record<string, string>;
50
+ export default messages;
51
+ `.trim();
52
+ async function readCatalogueMessages(bucket, locale, path = expandBucketOutputPath(bucket, locale)) {
53
+ const content = await (0, node_fs_promises.readFile)(path, "utf8").catch(() => "");
54
+ if (!content) return [];
55
+ return bucket.formatter.parse(content);
56
+ }
57
+ async function writeCatalogueMessages(bucket, locale, messages, path = expandBucketOutputPath(bucket, locale)) {
58
+ const existingContent = await (0, node_fs_promises.readFile)(path, "utf8").catch(() => void 0);
59
+ const catalogueContent = bucket.formatter.stringify(messages, {
60
+ locale,
61
+ existingContent
62
+ });
63
+ const declarationPath = `${path}.d.ts`;
64
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
65
+ await Promise.all([(0, node_fs_promises.writeFile)(path, catalogueContent), (0, node_fs_promises.writeFile)(declarationPath, DECLARATION_CONTENT)]);
66
+ }
67
+ //#endregion
68
+ Object.defineProperty(exports, "expandBucketOutputPath", {
69
+ enumerable: true,
70
+ get: function() {
71
+ return expandBucketOutputPath;
72
+ }
73
+ });
74
+ Object.defineProperty(exports, "mergeExtractedMessages", {
75
+ enumerable: true,
76
+ get: function() {
77
+ return mergeExtractedMessages;
78
+ }
79
+ });
80
+ Object.defineProperty(exports, "readCatalogueMessages", {
81
+ enumerable: true,
82
+ get: function() {
83
+ return readCatalogueMessages;
84
+ }
85
+ });
86
+ Object.defineProperty(exports, "reconcileLocaleMessages", {
87
+ enumerable: true,
88
+ get: function() {
89
+ return reconcileLocaleMessages;
90
+ }
91
+ });
92
+ Object.defineProperty(exports, "writeCatalogueMessages", {
93
+ enumerable: true,
94
+ get: function() {
95
+ return writeCatalogueMessages;
96
+ }
97
+ });
@@ -0,0 +1,67 @@
1
+ import { t as generateHash } from "./hash-DyC8GzMa.mjs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ //#region src/features/catalogue/merge.ts
5
+ function mergeUnique(...items) {
6
+ return Array.from(new Set(items.flat()));
7
+ }
8
+ function getMessageKey(message) {
9
+ return message.id ?? generateHash(message.message, message.context);
10
+ }
11
+ function mergeExtractedMessages(messages) {
12
+ const mergedMessages = messages.reduce((map, message) => {
13
+ const key = getMessageKey(message);
14
+ const existing = map.get(key) ?? message;
15
+ map.set(key, {
16
+ ...existing,
17
+ comments: mergeUnique(...existing.comments, ...message.comments),
18
+ references: mergeUnique(...existing.references, ...message.references)
19
+ });
20
+ return map;
21
+ }, /* @__PURE__ */ new Map());
22
+ return Array.from(mergedMessages.values());
23
+ }
24
+ function reconcileLocaleMessages(existingMessages, nextMessages) {
25
+ const existingMessagesByKey = existingMessages.reduce((map, message) => {
26
+ map.set(getMessageKey(message), message);
27
+ return map;
28
+ }, /* @__PURE__ */ new Map());
29
+ const reconciledMessages = nextMessages.reduce((map, message) => {
30
+ const key = getMessageKey(message);
31
+ const existingMessage = existingMessagesByKey.get(key);
32
+ map.set(key, {
33
+ ...message,
34
+ translation: existingMessage?.translation
35
+ });
36
+ return map;
37
+ }, /* @__PURE__ */ new Map());
38
+ return Array.from(reconciledMessages.values());
39
+ }
40
+ //#endregion
41
+ //#region src/features/catalogue/path.ts
42
+ function expandBucketOutputPath(bucket, locale, extension = bucket.formatter.extension) {
43
+ return resolve(bucket.output.replaceAll("{locale}", locale).replaceAll("{extension}", extension.slice(1)));
44
+ }
45
+ //#endregion
46
+ //#region src/features/catalogue/storage.ts
47
+ const DECLARATION_CONTENT = `
48
+ declare const messages: Record<string, string>;
49
+ export default messages;
50
+ `.trim();
51
+ async function readCatalogueMessages(bucket, locale, path = expandBucketOutputPath(bucket, locale)) {
52
+ const content = await readFile(path, "utf8").catch(() => "");
53
+ if (!content) return [];
54
+ return bucket.formatter.parse(content);
55
+ }
56
+ async function writeCatalogueMessages(bucket, locale, messages, path = expandBucketOutputPath(bucket, locale)) {
57
+ const existingContent = await readFile(path, "utf8").catch(() => void 0);
58
+ const catalogueContent = bucket.formatter.stringify(messages, {
59
+ locale,
60
+ existingContent
61
+ });
62
+ const declarationPath = `${path}.d.ts`;
63
+ await mkdir(dirname(path), { recursive: true });
64
+ await Promise.all([writeFile(path, catalogueContent), writeFile(declarationPath, DECLARATION_CONTENT)]);
65
+ }
66
+ //#endregion
67
+ export { reconcileLocaleMessages as a, mergeExtractedMessages as i, writeCatalogueMessages as n, expandBucketOutputPath as r, readCatalogueMessages as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saykit/config",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "CLI and configuration tooling for saykit",
5
5
  "keywords": [
6
6
  "cli",
@@ -37,6 +37,16 @@
37
37
  "default": "./dist/index.mjs"
38
38
  }
39
39
  },
40
+ "./features/catalogue": {
41
+ "require": {
42
+ "types": "./dist/features/catalogue/index.d.cts",
43
+ "default": "./dist/features/catalogue/index.cjs"
44
+ },
45
+ "import": {
46
+ "types": "./dist/features/catalogue/index.d.mts",
47
+ "default": "./dist/features/catalogue/index.mjs"
48
+ }
49
+ },
40
50
  "./features/loader": {
41
51
  "require": {
42
52
  "types": "./dist/features/loader/index.d.cts",
File without changes
File without changes