@saykit/config 0.0.0 → 0.2.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 +44 -0
- package/dist/commands/index.cjs +252 -0
- package/dist/commands/index.d.cts +1 -0
- package/dist/commands/index.d.mts +1 -0
- package/dist/commands/index.mjs +252 -0
- package/dist/features/loader/index.cjs +3 -0
- package/dist/features/loader/index.d.cts +6 -0
- package/dist/features/loader/index.d.mts +6 -0
- package/dist/features/loader/index.mjs +2 -0
- package/dist/features/messages/index.cjs +101 -0
- package/dist/features/messages/index.d.cts +63 -0
- package/dist/features/messages/index.d.mts +63 -0
- package/dist/features/messages/index.mjs +92 -0
- package/dist/hash-CDfMT76E.cjs +17 -0
- package/dist/hash-Cs0dRdGf.mjs +11 -0
- package/dist/index.cjs +77 -0
- package/dist/index.d.cts +38 -0
- package/dist/index.d.mts +38 -0
- package/dist/index.mjs +51 -0
- package/dist/loader-CGuahnrc.mjs +115 -0
- package/dist/loader-DnWt_9U2.cjs +121 -0
- package/dist/shapes-CJyTfZXd.d.cts +236 -0
- package/dist/shapes-DETrtvZf.d.mts +236 -0
- package/package.json +86 -3
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @saykit/config
|
|
2
|
+
|
|
3
|
+
> Configuration schema and CLI for [SayKit](https://saykit.js.org).
|
|
4
|
+
|
|
5
|
+
[](https://codecov.io/gh/k0d13/saykit?flags%5B0%5D=config)
|
|
6
|
+
|
|
7
|
+
Provides the `defineConfig` helper, the Zod-validated schema for `saykit.config.ts`, and the `saykit` CLI used to extract messages from your source.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm add -D @saykit/config
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Configuration
|
|
16
|
+
|
|
17
|
+
```ts title="saykit.config.ts"
|
|
18
|
+
import { defineConfig } from '@saykit/config';
|
|
19
|
+
import po from '@saykit/format-po';
|
|
20
|
+
import js from '@saykit/transform-js';
|
|
21
|
+
|
|
22
|
+
export default defineConfig({
|
|
23
|
+
locales: ['en', 'fr'],
|
|
24
|
+
buckets: [
|
|
25
|
+
{
|
|
26
|
+
include: ['src/**/*.ts'],
|
|
27
|
+
output: 'src/locales/{locale}.{extension}',
|
|
28
|
+
formatter: po(),
|
|
29
|
+
transformer: js(),
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## CLI
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
saykit extract # extract messages once
|
|
39
|
+
saykit extract --watch # extract and watch for changes
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Documentation
|
|
43
|
+
|
|
44
|
+
Full configuration guide and CLI reference at [saykit.js.org](https://saykit.js.org).
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
require("../index.cjs");
|
|
3
|
+
const require_loader = require("../loader-DnWt_9U2.cjs");
|
|
4
|
+
const require_hash = require("../hash-CDfMT76E.cjs");
|
|
5
|
+
let _commander_js_extra_typings = require("@commander-js/extra-typings");
|
|
6
|
+
let node_path = require("node:path");
|
|
7
|
+
let node_async_hooks = require("node:async_hooks");
|
|
8
|
+
let node_fs_promises = require("node:fs/promises");
|
|
9
|
+
//#region src/features/logger.ts
|
|
10
|
+
const RESET = "\x1B[0m";
|
|
11
|
+
const DIM = "\x1B[2m";
|
|
12
|
+
const BRIGHT = "\x1B[1m";
|
|
13
|
+
const RED = "\x1B[31m";
|
|
14
|
+
const GREEN = "\x1B[32m";
|
|
15
|
+
const YELLOW = "\x1B[33m";
|
|
16
|
+
const BLUE = "\x1B[34m";
|
|
17
|
+
var Logger = class {
|
|
18
|
+
#quiet;
|
|
19
|
+
#verbose;
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.#quiet = options.quiet ?? false;
|
|
22
|
+
this.#verbose = options.verbose ?? false;
|
|
23
|
+
}
|
|
24
|
+
log(...args) {
|
|
25
|
+
if (!this.#quiet) console.log(...args);
|
|
26
|
+
}
|
|
27
|
+
info(...args) {
|
|
28
|
+
this.log(`${BLUE}🛈${RESET}`, ...args);
|
|
29
|
+
}
|
|
30
|
+
warn(...args) {
|
|
31
|
+
this.log(`${YELLOW}⚠${RESET}`, ...args);
|
|
32
|
+
}
|
|
33
|
+
error(...args) {
|
|
34
|
+
this.log(`${RED}✖${RESET}`, ...args);
|
|
35
|
+
}
|
|
36
|
+
success(...args) {
|
|
37
|
+
this.log(`${GREEN}✔${RESET}`, ...args);
|
|
38
|
+
}
|
|
39
|
+
header(message) {
|
|
40
|
+
this.log(`${BRIGHT}${message}${RESET}`);
|
|
41
|
+
}
|
|
42
|
+
step(message) {
|
|
43
|
+
if (this.#verbose) this.log(` ${DIM}→ ${message}${RESET}`);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
new node_async_hooks.AsyncLocalStorage({ defaultValue: new Logger() });
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/features/catalogue/extractor.ts
|
|
49
|
+
async function extractMessagesFromFile(path, bucket) {
|
|
50
|
+
const content = await (0, node_fs_promises.readFile)(path, "utf8").catch(() => "");
|
|
51
|
+
if (!content) return [];
|
|
52
|
+
return bucket.transformer.extract(content, path).map((message) => ({
|
|
53
|
+
...message,
|
|
54
|
+
translation: message.translation ?? message.message,
|
|
55
|
+
references: message.references.map((reference) => (0, node_path.relative)(process.cwd(), reference).replaceAll("\\", "/"))
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
//#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
|
+
//#region src/features/watch.ts
|
|
128
|
+
/**
|
|
129
|
+
* Expand a buckets include and exclude patterns into a flat list of file paths.
|
|
130
|
+
*/
|
|
131
|
+
async function globBucket(bucket) {
|
|
132
|
+
const paths = [];
|
|
133
|
+
for await (const file of (0, node_fs_promises.glob)(bucket.include, {
|
|
134
|
+
exclude: bucket.exclude,
|
|
135
|
+
withFileTypes: true
|
|
136
|
+
})) if (file.isFile()) paths.push((0, node_path.join)(file.parentPath, file.name));
|
|
137
|
+
return paths;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Watches a path for changes, emitting a debounced event every set delay.
|
|
141
|
+
*
|
|
142
|
+
* Unlike Node's native `fs.watch` method, this:
|
|
143
|
+
* - coalesces rapid consecutive events per file
|
|
144
|
+
* - emits only the final event after `delay` ms of inactivity
|
|
145
|
+
* - deduplicates events by filename
|
|
146
|
+
*/
|
|
147
|
+
async function* watchDebounced(path, options, delay = 300) {
|
|
148
|
+
const timers = /* @__PURE__ */ new Map();
|
|
149
|
+
const queue = /* @__PURE__ */ new Map();
|
|
150
|
+
const resolvers = /* @__PURE__ */ new Map();
|
|
151
|
+
(async () => {
|
|
152
|
+
for await (const event of (0, node_fs_promises.watch)(path, options)) {
|
|
153
|
+
const key = event.filename ?? "__unknown__";
|
|
154
|
+
if (timers.has(key)) clearTimeout(timers.get(key));
|
|
155
|
+
if (!queue.has(key)) queue.set(key, new Promise((r) => resolvers.set(key, r)));
|
|
156
|
+
timers.set(key, setTimeout(() => {
|
|
157
|
+
resolvers.get(key)?.(event);
|
|
158
|
+
timers.delete(key);
|
|
159
|
+
resolvers.delete(key);
|
|
160
|
+
}, delay));
|
|
161
|
+
}
|
|
162
|
+
})();
|
|
163
|
+
while (true) if (queue.size) {
|
|
164
|
+
const next = await Promise.race(queue.values());
|
|
165
|
+
queue.delete(next.filename ?? "__unknown__");
|
|
166
|
+
yield next;
|
|
167
|
+
} else await new Promise((r) => setTimeout(r, 10));
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/features/workers/shared.ts
|
|
171
|
+
function normalisePathForLogs(path) {
|
|
172
|
+
return (0, node_path.relative)(process.cwd(), path).replaceAll("\\", "/");
|
|
173
|
+
}
|
|
174
|
+
var BucketWorker = class {
|
|
175
|
+
config;
|
|
176
|
+
bucket;
|
|
177
|
+
logger;
|
|
178
|
+
constructor(config, bucket, logger) {
|
|
179
|
+
this.config = config;
|
|
180
|
+
this.bucket = bucket;
|
|
181
|
+
this.logger = logger;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/features/workers/extract-worker.ts
|
|
186
|
+
var BucketExtractWorker = class extends BucketWorker {
|
|
187
|
+
#indexedMessagesByPath = /* @__PURE__ */ new Map();
|
|
188
|
+
get #indexedMessages() {
|
|
189
|
+
return Array.from(this.#indexedMessagesByPath.values()).flat();
|
|
190
|
+
}
|
|
191
|
+
async #indexPath(path) {
|
|
192
|
+
const relativePath = normalisePathForLogs(path);
|
|
193
|
+
this.logger.step(`Processing ${relativePath}`);
|
|
194
|
+
const messages = await extractMessagesFromFile(path, this.bucket);
|
|
195
|
+
if (!messages.length) {
|
|
196
|
+
if (this.#indexedMessagesByPath.has(path)) {
|
|
197
|
+
this.#indexedMessagesByPath.delete(path);
|
|
198
|
+
this.logger.step(`Removed stale entries for ${relativePath}`);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
this.#indexedMessagesByPath.set(path, messages);
|
|
204
|
+
this.logger.step(`Found ${messages.length} message(s) in ${relativePath}`);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
async scan() {
|
|
208
|
+
this.logger.info(`Scanning bucket: ${this.bucket.include}`);
|
|
209
|
+
const paths = await globBucket(this.bucket);
|
|
210
|
+
this.logger.step(`Found ${paths.length} file(s)`);
|
|
211
|
+
await Promise.all(paths.map((p) => this.#indexPath(p)));
|
|
212
|
+
this.logger.info(`Total extracted messages: ${this.#indexedMessages.length}`);
|
|
213
|
+
}
|
|
214
|
+
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);
|
|
222
|
+
}
|
|
223
|
+
this.logger.success(`Extraction complete for bucket: ${this.bucket.include}`);
|
|
224
|
+
}
|
|
225
|
+
async update(path) {
|
|
226
|
+
const changed = await this.#indexPath(path);
|
|
227
|
+
if (changed) await this.write();
|
|
228
|
+
return changed;
|
|
229
|
+
}
|
|
230
|
+
async watch() {
|
|
231
|
+
this.logger.header(`👀 Watching bucket for changes: ${this.bucket.include}`);
|
|
232
|
+
for await (const event of watchDebounced(".", { recursive: true })) {
|
|
233
|
+
if (!event.filename || !this.bucket.match(event.filename)) continue;
|
|
234
|
+
const path = (0, node_path.join)(process.cwd(), event.filename);
|
|
235
|
+
await this.update(path);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/commands/extract.ts
|
|
241
|
+
var extract_default = new _commander_js_extra_typings.Command("extract").description("Extract messages from source files").option("-v, --verbose", "enable verbose logging", false).option("-q, --quiet", "suppress all logging", false).option("-w, --watch", "watch source files for changes", false).action(async (options) => {
|
|
242
|
+
const config = require_loader.resolveConfig();
|
|
243
|
+
const logger = new Logger(options);
|
|
244
|
+
logger.header("🛠 Extracting Messages");
|
|
245
|
+
const workers = config.buckets.map((b) => new BucketExtractWorker(config, b, logger));
|
|
246
|
+
await Promise.all(workers.map((w) => w.scan().then(() => w.write())));
|
|
247
|
+
if (options.watch) await Promise.all(workers.map((w) => w.watch()));
|
|
248
|
+
});
|
|
249
|
+
//#endregion
|
|
250
|
+
//#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();
|
|
252
|
+
//#endregion
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { t as resolveConfig } from "../loader-CGuahnrc.mjs";
|
|
3
|
+
import { t as generateHash } from "../hash-Cs0dRdGf.mjs";
|
|
4
|
+
import { Command, program } from "@commander-js/extra-typings";
|
|
5
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
6
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
|
+
import { glob, mkdir, readFile, watch, writeFile } from "node:fs/promises";
|
|
8
|
+
//#region src/features/logger.ts
|
|
9
|
+
const RESET = "\x1B[0m";
|
|
10
|
+
const DIM = "\x1B[2m";
|
|
11
|
+
const BRIGHT = "\x1B[1m";
|
|
12
|
+
const RED = "\x1B[31m";
|
|
13
|
+
const GREEN = "\x1B[32m";
|
|
14
|
+
const YELLOW = "\x1B[33m";
|
|
15
|
+
const BLUE = "\x1B[34m";
|
|
16
|
+
var Logger = class {
|
|
17
|
+
#quiet;
|
|
18
|
+
#verbose;
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.#quiet = options.quiet ?? false;
|
|
21
|
+
this.#verbose = options.verbose ?? false;
|
|
22
|
+
}
|
|
23
|
+
log(...args) {
|
|
24
|
+
if (!this.#quiet) console.log(...args);
|
|
25
|
+
}
|
|
26
|
+
info(...args) {
|
|
27
|
+
this.log(`${BLUE}🛈${RESET}`, ...args);
|
|
28
|
+
}
|
|
29
|
+
warn(...args) {
|
|
30
|
+
this.log(`${YELLOW}⚠${RESET}`, ...args);
|
|
31
|
+
}
|
|
32
|
+
error(...args) {
|
|
33
|
+
this.log(`${RED}✖${RESET}`, ...args);
|
|
34
|
+
}
|
|
35
|
+
success(...args) {
|
|
36
|
+
this.log(`${GREEN}✔${RESET}`, ...args);
|
|
37
|
+
}
|
|
38
|
+
header(message) {
|
|
39
|
+
this.log(`${BRIGHT}${message}${RESET}`);
|
|
40
|
+
}
|
|
41
|
+
step(message) {
|
|
42
|
+
if (this.#verbose) this.log(` ${DIM}→ ${message}${RESET}`);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
new AsyncLocalStorage({ defaultValue: new Logger() });
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/features/catalogue/extractor.ts
|
|
48
|
+
async function extractMessagesFromFile(path, bucket) {
|
|
49
|
+
const content = await readFile(path, "utf8").catch(() => "");
|
|
50
|
+
if (!content) return [];
|
|
51
|
+
return bucket.transformer.extract(content, path).map((message) => ({
|
|
52
|
+
...message,
|
|
53
|
+
translation: message.translation ?? message.message,
|
|
54
|
+
references: message.references.map((reference) => relative(process.cwd(), reference).replaceAll("\\", "/"))
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
//#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
|
+
//#region src/features/watch.ts
|
|
127
|
+
/**
|
|
128
|
+
* Expand a buckets include and exclude patterns into a flat list of file paths.
|
|
129
|
+
*/
|
|
130
|
+
async function globBucket(bucket) {
|
|
131
|
+
const paths = [];
|
|
132
|
+
for await (const file of glob(bucket.include, {
|
|
133
|
+
exclude: bucket.exclude,
|
|
134
|
+
withFileTypes: true
|
|
135
|
+
})) if (file.isFile()) paths.push(join(file.parentPath, file.name));
|
|
136
|
+
return paths;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Watches a path for changes, emitting a debounced event every set delay.
|
|
140
|
+
*
|
|
141
|
+
* Unlike Node's native `fs.watch` method, this:
|
|
142
|
+
* - coalesces rapid consecutive events per file
|
|
143
|
+
* - emits only the final event after `delay` ms of inactivity
|
|
144
|
+
* - deduplicates events by filename
|
|
145
|
+
*/
|
|
146
|
+
async function* watchDebounced(path, options, delay = 300) {
|
|
147
|
+
const timers = /* @__PURE__ */ new Map();
|
|
148
|
+
const queue = /* @__PURE__ */ new Map();
|
|
149
|
+
const resolvers = /* @__PURE__ */ new Map();
|
|
150
|
+
(async () => {
|
|
151
|
+
for await (const event of watch(path, options)) {
|
|
152
|
+
const key = event.filename ?? "__unknown__";
|
|
153
|
+
if (timers.has(key)) clearTimeout(timers.get(key));
|
|
154
|
+
if (!queue.has(key)) queue.set(key, new Promise((r) => resolvers.set(key, r)));
|
|
155
|
+
timers.set(key, setTimeout(() => {
|
|
156
|
+
resolvers.get(key)?.(event);
|
|
157
|
+
timers.delete(key);
|
|
158
|
+
resolvers.delete(key);
|
|
159
|
+
}, delay));
|
|
160
|
+
}
|
|
161
|
+
})();
|
|
162
|
+
while (true) if (queue.size) {
|
|
163
|
+
const next = await Promise.race(queue.values());
|
|
164
|
+
queue.delete(next.filename ?? "__unknown__");
|
|
165
|
+
yield next;
|
|
166
|
+
} else await new Promise((r) => setTimeout(r, 10));
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/features/workers/shared.ts
|
|
170
|
+
function normalisePathForLogs(path) {
|
|
171
|
+
return relative(process.cwd(), path).replaceAll("\\", "/");
|
|
172
|
+
}
|
|
173
|
+
var BucketWorker = class {
|
|
174
|
+
config;
|
|
175
|
+
bucket;
|
|
176
|
+
logger;
|
|
177
|
+
constructor(config, bucket, logger) {
|
|
178
|
+
this.config = config;
|
|
179
|
+
this.bucket = bucket;
|
|
180
|
+
this.logger = logger;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/features/workers/extract-worker.ts
|
|
185
|
+
var BucketExtractWorker = class extends BucketWorker {
|
|
186
|
+
#indexedMessagesByPath = /* @__PURE__ */ new Map();
|
|
187
|
+
get #indexedMessages() {
|
|
188
|
+
return Array.from(this.#indexedMessagesByPath.values()).flat();
|
|
189
|
+
}
|
|
190
|
+
async #indexPath(path) {
|
|
191
|
+
const relativePath = normalisePathForLogs(path);
|
|
192
|
+
this.logger.step(`Processing ${relativePath}`);
|
|
193
|
+
const messages = await extractMessagesFromFile(path, this.bucket);
|
|
194
|
+
if (!messages.length) {
|
|
195
|
+
if (this.#indexedMessagesByPath.has(path)) {
|
|
196
|
+
this.#indexedMessagesByPath.delete(path);
|
|
197
|
+
this.logger.step(`Removed stale entries for ${relativePath}`);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
this.#indexedMessagesByPath.set(path, messages);
|
|
203
|
+
this.logger.step(`Found ${messages.length} message(s) in ${relativePath}`);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
async scan() {
|
|
207
|
+
this.logger.info(`Scanning bucket: ${this.bucket.include}`);
|
|
208
|
+
const paths = await globBucket(this.bucket);
|
|
209
|
+
this.logger.step(`Found ${paths.length} file(s)`);
|
|
210
|
+
await Promise.all(paths.map((p) => this.#indexPath(p)));
|
|
211
|
+
this.logger.info(`Total extracted messages: ${this.#indexedMessages.length}`);
|
|
212
|
+
}
|
|
213
|
+
async write() {
|
|
214
|
+
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);
|
|
221
|
+
}
|
|
222
|
+
this.logger.success(`Extraction complete for bucket: ${this.bucket.include}`);
|
|
223
|
+
}
|
|
224
|
+
async update(path) {
|
|
225
|
+
const changed = await this.#indexPath(path);
|
|
226
|
+
if (changed) await this.write();
|
|
227
|
+
return changed;
|
|
228
|
+
}
|
|
229
|
+
async watch() {
|
|
230
|
+
this.logger.header(`👀 Watching bucket for changes: ${this.bucket.include}`);
|
|
231
|
+
for await (const event of watchDebounced(".", { recursive: true })) {
|
|
232
|
+
if (!event.filename || !this.bucket.match(event.filename)) continue;
|
|
233
|
+
const path = join(process.cwd(), event.filename);
|
|
234
|
+
await this.update(path);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
//#endregion
|
|
239
|
+
//#region src/commands/extract.ts
|
|
240
|
+
var extract_default = new Command("extract").description("Extract messages from source files").option("-v, --verbose", "enable verbose logging", false).option("-q, --quiet", "suppress all logging", false).option("-w, --watch", "watch source files for changes", false).action(async (options) => {
|
|
241
|
+
const config = resolveConfig();
|
|
242
|
+
const logger = new Logger(options);
|
|
243
|
+
logger.header("🛠 Extracting Messages");
|
|
244
|
+
const workers = config.buckets.map((b) => new BucketExtractWorker(config, b, logger));
|
|
245
|
+
await Promise.all(workers.map((w) => w.scan().then(() => w.write())));
|
|
246
|
+
if (options.watch) await Promise.all(workers.map((w) => w.watch()));
|
|
247
|
+
});
|
|
248
|
+
//#endregion
|
|
249
|
+
//#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();
|
|
251
|
+
//#endregion
|
|
252
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_hash = require("../../hash-CDfMT76E.cjs");
|
|
3
|
+
//#region src/features/messages/identifier.ts
|
|
4
|
+
const AUTO_INCREMENT_IDENTIFIER = Symbol("auto-increment");
|
|
5
|
+
function assignSequenceIdentifiers(message, sequence = { current: 0 }) {
|
|
6
|
+
if (message instanceof ArgumentMessage || message instanceof ElementMessage || message instanceof ChoiceMessage) {
|
|
7
|
+
if (message.identifier === AUTO_INCREMENT_IDENTIFIER) message.identifier = `${sequence.current++}`;
|
|
8
|
+
}
|
|
9
|
+
if (message instanceof CompositeMessage || message instanceof ElementMessage) for (const child of message.children) assignSequenceIdentifiers(child, sequence);
|
|
10
|
+
if (message instanceof ChoiceMessage) for (const branch of message.branches) {
|
|
11
|
+
if (branch.identifier === AUTO_INCREMENT_IDENTIFIER) branch.identifier = `${sequence.current++}`;
|
|
12
|
+
assignSequenceIdentifiers(branch.value, sequence);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/features/messages/types.ts
|
|
17
|
+
var Base = class {
|
|
18
|
+
toICUString() {
|
|
19
|
+
return convertMessageToIcu(this);
|
|
20
|
+
}
|
|
21
|
+
toHashString() {
|
|
22
|
+
const context = this instanceof CompositeMessage ? this.descriptor.context : void 0;
|
|
23
|
+
return require_hash.generateHash(this.toICUString(), context);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
var LiteralMessage = class extends Base {
|
|
27
|
+
constructor(text) {
|
|
28
|
+
super();
|
|
29
|
+
this.text = text;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
var ArgumentMessage = class extends Base {
|
|
33
|
+
constructor(identifier, expression) {
|
|
34
|
+
super();
|
|
35
|
+
this.identifier = identifier;
|
|
36
|
+
this.expression = expression;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
var ElementMessage = class extends Base {
|
|
40
|
+
constructor(identifier, children, expression) {
|
|
41
|
+
super();
|
|
42
|
+
this.identifier = identifier;
|
|
43
|
+
this.children = children;
|
|
44
|
+
this.expression = expression;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var ChoiceMessage = class extends Base {
|
|
48
|
+
constructor(kind, identifier, branches, expression) {
|
|
49
|
+
super();
|
|
50
|
+
this.kind = kind;
|
|
51
|
+
this.identifier = identifier;
|
|
52
|
+
this.branches = branches;
|
|
53
|
+
this.expression = expression;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var CompositeMessage = class extends Base {
|
|
57
|
+
constructor(descriptor, comments, references, children, accessor, whitespace) {
|
|
58
|
+
super();
|
|
59
|
+
this.descriptor = descriptor;
|
|
60
|
+
this.comments = comments;
|
|
61
|
+
this.references = references;
|
|
62
|
+
this.children = children;
|
|
63
|
+
this.accessor = accessor;
|
|
64
|
+
this.whitespace = whitespace;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/features/messages/convert.ts
|
|
69
|
+
function convertMessageToIcu(message) {
|
|
70
|
+
function internalConvertMessageToIcu(message) {
|
|
71
|
+
switch (true) {
|
|
72
|
+
case message instanceof LiteralMessage: return String(message.text);
|
|
73
|
+
case message instanceof ArgumentMessage: return `{${String(message.identifier)}}`;
|
|
74
|
+
case message instanceof ElementMessage: {
|
|
75
|
+
const children = message.children.map((m) => internalConvertMessageToIcu(m)).join("");
|
|
76
|
+
return `<${String(message.identifier)}>${children}</${String(message.identifier)}>`;
|
|
77
|
+
}
|
|
78
|
+
case message instanceof ChoiceMessage: {
|
|
79
|
+
const branches = message.branches.map(({ identifier, value }) => ({
|
|
80
|
+
identifier: Number.isNaN(+String(identifier)) ? String(identifier) : `=${+String(identifier)}`,
|
|
81
|
+
value: internalConvertMessageToIcu(value)
|
|
82
|
+
})).map(({ identifier, value }) => ` ${identifier} {${value}}\n`).join("");
|
|
83
|
+
const format = message.kind === "ordinal" ? "selectordinal" : message.kind;
|
|
84
|
+
return `{${String(message.identifier)}, ${format},\n${branches}}`;
|
|
85
|
+
}
|
|
86
|
+
case message instanceof CompositeMessage: return Object.entries(message.children).map(([, m]) => internalConvertMessageToIcu(m)).join("");
|
|
87
|
+
default: throw new Error("Unknown message type", { cause: message });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return internalConvertMessageToIcu(message).trim();
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
exports.AUTO_INCREMENT_IDENTIFIER = AUTO_INCREMENT_IDENTIFIER;
|
|
94
|
+
exports.ArgumentMessage = ArgumentMessage;
|
|
95
|
+
exports.ChoiceMessage = ChoiceMessage;
|
|
96
|
+
exports.CompositeMessage = CompositeMessage;
|
|
97
|
+
exports.ElementMessage = ElementMessage;
|
|
98
|
+
exports.LiteralMessage = LiteralMessage;
|
|
99
|
+
exports.assignSequenceIdentifiers = assignSequenceIdentifiers;
|
|
100
|
+
exports.convertMessageToIcu = convertMessageToIcu;
|
|
101
|
+
exports.generateHash = require_hash.generateHash;
|