@saykit/config 0.0.0 → 0.1.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 +42 -0
- package/dist/commands/index.cjs +264 -0
- package/dist/commands/index.d.cts +1 -0
- package/dist/commands/index.d.mts +1 -0
- package/dist/commands/index.mjs +264 -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 +100 -0
- package/dist/features/messages/index.d.cts +62 -0
- package/dist/features/messages/index.d.mts +62 -0
- package/dist/features/messages/index.mjs +91 -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 +88 -3
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# @saykit/config
|
|
2
|
+
|
|
3
|
+
> Configuration schema and CLI for [SayKit](https://saykit.js.org).
|
|
4
|
+
|
|
5
|
+
Provides the `defineConfig` helper, the Zod-validated schema for `saykit.config.ts`, and the `saykit` CLI used to extract messages from your source.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add -D @saykit/config
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
```ts title="saykit.config.ts"
|
|
16
|
+
import { defineConfig } from '@saykit/config';
|
|
17
|
+
import po from '@saykit/format-po';
|
|
18
|
+
import js from '@saykit/transform-js';
|
|
19
|
+
|
|
20
|
+
export default defineConfig({
|
|
21
|
+
locales: ['en', 'fr'],
|
|
22
|
+
buckets: [
|
|
23
|
+
{
|
|
24
|
+
include: ['src/**/*.ts'],
|
|
25
|
+
output: 'src/locales/{locale}.{extension}',
|
|
26
|
+
formatter: po(),
|
|
27
|
+
transformer: js(),
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## CLI
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
saykit extract # extract messages once
|
|
37
|
+
saykit extract --watch # extract and watch for changes
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Documentation
|
|
41
|
+
|
|
42
|
+
Full configuration guide and CLI reference at [saykit.js.org](https://saykit.js.org).
|
|
@@ -0,0 +1,264 @@
|
|
|
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
|
+
function expandBucketOutputIgnoreDirectory(bucket) {
|
|
106
|
+
const [prefix] = bucket.output.split("{locale}");
|
|
107
|
+
return (0, node_path.resolve)(prefix || ".");
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/features/catalogue/storage.ts
|
|
111
|
+
const DECLARATION_CONTENT = `
|
|
112
|
+
declare const translations: Record<string, string>;
|
|
113
|
+
export default translations;
|
|
114
|
+
`.trim();
|
|
115
|
+
async function readCatalogueMessages(bucket, locale, path = expandBucketOutputPath(bucket, locale)) {
|
|
116
|
+
const content = await (0, node_fs_promises.readFile)(path, "utf8").catch(() => "");
|
|
117
|
+
if (!content) return [];
|
|
118
|
+
return bucket.formatter.parse(content);
|
|
119
|
+
}
|
|
120
|
+
async function writeCatalogueMessages(bucket, locale, messages, path = expandBucketOutputPath(bucket, locale)) {
|
|
121
|
+
const existingContent = await (0, node_fs_promises.readFile)(path, "utf8").catch(() => void 0);
|
|
122
|
+
const catalogueContent = bucket.formatter.stringify(messages, {
|
|
123
|
+
locale,
|
|
124
|
+
existingContent
|
|
125
|
+
});
|
|
126
|
+
const declarationPath = `${path}.d.ts`;
|
|
127
|
+
const ignoreDirectory = expandBucketOutputIgnoreDirectory(bucket);
|
|
128
|
+
const ignorePath = (0, node_path.join)(ignoreDirectory, ".gitignore");
|
|
129
|
+
const ignoreContent = `.gitignore\n*.${bucket.formatter.extension.slice(1)}.d.ts`;
|
|
130
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
|
|
131
|
+
await (0, node_fs_promises.mkdir)(ignoreDirectory, { recursive: true });
|
|
132
|
+
await Promise.all([
|
|
133
|
+
(0, node_fs_promises.writeFile)(path, catalogueContent),
|
|
134
|
+
(0, node_fs_promises.writeFile)(declarationPath, DECLARATION_CONTENT),
|
|
135
|
+
(0, node_fs_promises.writeFile)(ignorePath, ignoreContent)
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/features/watch.ts
|
|
140
|
+
/**
|
|
141
|
+
* Expand a buckets include and exclude patterns into a flat list of file paths.
|
|
142
|
+
*/
|
|
143
|
+
async function globBucket(bucket) {
|
|
144
|
+
const paths = [];
|
|
145
|
+
for await (const file of (0, node_fs_promises.glob)(bucket.include, {
|
|
146
|
+
exclude: bucket.exclude,
|
|
147
|
+
withFileTypes: true
|
|
148
|
+
})) if (file.isFile()) paths.push((0, node_path.join)(file.parentPath, file.name));
|
|
149
|
+
return paths;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Watches a path for changes, emitting a debounced event every set delay.
|
|
153
|
+
*
|
|
154
|
+
* Unlike Node's native `fs.watch` method, this:
|
|
155
|
+
* - coalesces rapid consecutive events per file
|
|
156
|
+
* - emits only the final event after `delay` ms of inactivity
|
|
157
|
+
* - deduplicates events by filename
|
|
158
|
+
*/
|
|
159
|
+
async function* watchDebounced(path, options, delay = 300) {
|
|
160
|
+
const timers = /* @__PURE__ */ new Map();
|
|
161
|
+
const queue = /* @__PURE__ */ new Map();
|
|
162
|
+
const resolvers = /* @__PURE__ */ new Map();
|
|
163
|
+
(async () => {
|
|
164
|
+
for await (const event of (0, node_fs_promises.watch)(path, options)) {
|
|
165
|
+
const key = event.filename ?? "__unknown__";
|
|
166
|
+
if (timers.has(key)) clearTimeout(timers.get(key));
|
|
167
|
+
if (!queue.has(key)) queue.set(key, new Promise((r) => resolvers.set(key, r)));
|
|
168
|
+
timers.set(key, setTimeout(() => {
|
|
169
|
+
resolvers.get(key)?.(event);
|
|
170
|
+
timers.delete(key);
|
|
171
|
+
resolvers.delete(key);
|
|
172
|
+
}, delay));
|
|
173
|
+
}
|
|
174
|
+
})();
|
|
175
|
+
while (true) if (queue.size) {
|
|
176
|
+
const next = await Promise.race(queue.values());
|
|
177
|
+
queue.delete(next.filename ?? "__unknown__");
|
|
178
|
+
yield next;
|
|
179
|
+
} else await new Promise((r) => setTimeout(r, 10));
|
|
180
|
+
}
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/features/workers/shared.ts
|
|
183
|
+
function normalisePathForLogs(path) {
|
|
184
|
+
return (0, node_path.relative)(process.cwd(), path).replaceAll("\\", "/");
|
|
185
|
+
}
|
|
186
|
+
var BucketWorker = class {
|
|
187
|
+
config;
|
|
188
|
+
bucket;
|
|
189
|
+
logger;
|
|
190
|
+
constructor(config, bucket, logger) {
|
|
191
|
+
this.config = config;
|
|
192
|
+
this.bucket = bucket;
|
|
193
|
+
this.logger = logger;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
//#endregion
|
|
197
|
+
//#region src/features/workers/extract-worker.ts
|
|
198
|
+
var BucketExtractWorker = class extends BucketWorker {
|
|
199
|
+
#indexedMessagesByPath = /* @__PURE__ */ new Map();
|
|
200
|
+
get #indexedMessages() {
|
|
201
|
+
return Array.from(this.#indexedMessagesByPath.values()).flat();
|
|
202
|
+
}
|
|
203
|
+
async #indexPath(path) {
|
|
204
|
+
const relativePath = normalisePathForLogs(path);
|
|
205
|
+
this.logger.step(`Processing ${relativePath}`);
|
|
206
|
+
const messages = await extractMessagesFromFile(path, this.bucket);
|
|
207
|
+
if (!messages.length) {
|
|
208
|
+
if (this.#indexedMessagesByPath.has(path)) {
|
|
209
|
+
this.#indexedMessagesByPath.delete(path);
|
|
210
|
+
this.logger.step(`Removed stale entries for ${relativePath}`);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
this.#indexedMessagesByPath.set(path, messages);
|
|
216
|
+
this.logger.step(`Found ${messages.length} message(s) in ${relativePath}`);
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
async scan() {
|
|
220
|
+
this.logger.info(`Scanning bucket: ${this.bucket.include}`);
|
|
221
|
+
const paths = await globBucket(this.bucket);
|
|
222
|
+
this.logger.step(`Found ${paths.length} file(s)`);
|
|
223
|
+
await Promise.all(paths.map((p) => this.#indexPath(p)));
|
|
224
|
+
this.logger.info(`Total extracted messages: ${this.#indexedMessages.length}`);
|
|
225
|
+
}
|
|
226
|
+
async write() {
|
|
227
|
+
const mergedMessages = mergeExtractedMessages(this.#indexedMessages);
|
|
228
|
+
this.logger.info(`Writing ${mergedMessages.length} messages to locales`);
|
|
229
|
+
for (const locale of this.config.locales) {
|
|
230
|
+
this.logger.step(`Writing locale file for ${locale} to disk`);
|
|
231
|
+
const existingMessages = await readCatalogueMessages(this.bucket, locale);
|
|
232
|
+
const nextMessages = locale === this.config.locales[0] ? mergedMessages : reconcileLocaleMessages(existingMessages, mergedMessages);
|
|
233
|
+
await writeCatalogueMessages(this.bucket, locale, nextMessages);
|
|
234
|
+
}
|
|
235
|
+
this.logger.success(`Extraction complete for bucket: ${this.bucket.include}`);
|
|
236
|
+
}
|
|
237
|
+
async update(path) {
|
|
238
|
+
const changed = await this.#indexPath(path);
|
|
239
|
+
if (changed) await this.write();
|
|
240
|
+
return changed;
|
|
241
|
+
}
|
|
242
|
+
async watch() {
|
|
243
|
+
this.logger.header(`👀 Watching bucket for changes: ${this.bucket.include}`);
|
|
244
|
+
for await (const event of watchDebounced(".", { recursive: true })) {
|
|
245
|
+
if (!event.filename || !this.bucket.match(event.filename)) continue;
|
|
246
|
+
const path = (0, node_path.join)(process.cwd(), event.filename);
|
|
247
|
+
await this.update(path);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/commands/extract.ts
|
|
253
|
+
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) => {
|
|
254
|
+
const config = require_loader.resolveConfig();
|
|
255
|
+
const logger = new Logger(options);
|
|
256
|
+
logger.header("🛠 Extracting Messages");
|
|
257
|
+
const workers = config.buckets.map((b) => new BucketExtractWorker(config, b, logger));
|
|
258
|
+
await Promise.all(workers.map((w) => w.scan().then(() => w.write())));
|
|
259
|
+
if (options.watch) await Promise.all(workers.map((w) => w.watch()));
|
|
260
|
+
});
|
|
261
|
+
//#endregion
|
|
262
|
+
//#region src/commands/index.ts
|
|
263
|
+
_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();
|
|
264
|
+
//#endregion
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,264 @@
|
|
|
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
|
+
function expandBucketOutputIgnoreDirectory(bucket) {
|
|
105
|
+
const [prefix] = bucket.output.split("{locale}");
|
|
106
|
+
return resolve(prefix || ".");
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/features/catalogue/storage.ts
|
|
110
|
+
const DECLARATION_CONTENT = `
|
|
111
|
+
declare const translations: Record<string, string>;
|
|
112
|
+
export default translations;
|
|
113
|
+
`.trim();
|
|
114
|
+
async function readCatalogueMessages(bucket, locale, path = expandBucketOutputPath(bucket, locale)) {
|
|
115
|
+
const content = await readFile(path, "utf8").catch(() => "");
|
|
116
|
+
if (!content) return [];
|
|
117
|
+
return bucket.formatter.parse(content);
|
|
118
|
+
}
|
|
119
|
+
async function writeCatalogueMessages(bucket, locale, messages, path = expandBucketOutputPath(bucket, locale)) {
|
|
120
|
+
const existingContent = await readFile(path, "utf8").catch(() => void 0);
|
|
121
|
+
const catalogueContent = bucket.formatter.stringify(messages, {
|
|
122
|
+
locale,
|
|
123
|
+
existingContent
|
|
124
|
+
});
|
|
125
|
+
const declarationPath = `${path}.d.ts`;
|
|
126
|
+
const ignoreDirectory = expandBucketOutputIgnoreDirectory(bucket);
|
|
127
|
+
const ignorePath = join(ignoreDirectory, ".gitignore");
|
|
128
|
+
const ignoreContent = `.gitignore\n*.${bucket.formatter.extension.slice(1)}.d.ts`;
|
|
129
|
+
await mkdir(dirname(path), { recursive: true });
|
|
130
|
+
await mkdir(ignoreDirectory, { recursive: true });
|
|
131
|
+
await Promise.all([
|
|
132
|
+
writeFile(path, catalogueContent),
|
|
133
|
+
writeFile(declarationPath, DECLARATION_CONTENT),
|
|
134
|
+
writeFile(ignorePath, ignoreContent)
|
|
135
|
+
]);
|
|
136
|
+
}
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/features/watch.ts
|
|
139
|
+
/**
|
|
140
|
+
* Expand a buckets include and exclude patterns into a flat list of file paths.
|
|
141
|
+
*/
|
|
142
|
+
async function globBucket(bucket) {
|
|
143
|
+
const paths = [];
|
|
144
|
+
for await (const file of glob(bucket.include, {
|
|
145
|
+
exclude: bucket.exclude,
|
|
146
|
+
withFileTypes: true
|
|
147
|
+
})) if (file.isFile()) paths.push(join(file.parentPath, file.name));
|
|
148
|
+
return paths;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Watches a path for changes, emitting a debounced event every set delay.
|
|
152
|
+
*
|
|
153
|
+
* Unlike Node's native `fs.watch` method, this:
|
|
154
|
+
* - coalesces rapid consecutive events per file
|
|
155
|
+
* - emits only the final event after `delay` ms of inactivity
|
|
156
|
+
* - deduplicates events by filename
|
|
157
|
+
*/
|
|
158
|
+
async function* watchDebounced(path, options, delay = 300) {
|
|
159
|
+
const timers = /* @__PURE__ */ new Map();
|
|
160
|
+
const queue = /* @__PURE__ */ new Map();
|
|
161
|
+
const resolvers = /* @__PURE__ */ new Map();
|
|
162
|
+
(async () => {
|
|
163
|
+
for await (const event of watch(path, options)) {
|
|
164
|
+
const key = event.filename ?? "__unknown__";
|
|
165
|
+
if (timers.has(key)) clearTimeout(timers.get(key));
|
|
166
|
+
if (!queue.has(key)) queue.set(key, new Promise((r) => resolvers.set(key, r)));
|
|
167
|
+
timers.set(key, setTimeout(() => {
|
|
168
|
+
resolvers.get(key)?.(event);
|
|
169
|
+
timers.delete(key);
|
|
170
|
+
resolvers.delete(key);
|
|
171
|
+
}, delay));
|
|
172
|
+
}
|
|
173
|
+
})();
|
|
174
|
+
while (true) if (queue.size) {
|
|
175
|
+
const next = await Promise.race(queue.values());
|
|
176
|
+
queue.delete(next.filename ?? "__unknown__");
|
|
177
|
+
yield next;
|
|
178
|
+
} else await new Promise((r) => setTimeout(r, 10));
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region src/features/workers/shared.ts
|
|
182
|
+
function normalisePathForLogs(path) {
|
|
183
|
+
return relative(process.cwd(), path).replaceAll("\\", "/");
|
|
184
|
+
}
|
|
185
|
+
var BucketWorker = class {
|
|
186
|
+
config;
|
|
187
|
+
bucket;
|
|
188
|
+
logger;
|
|
189
|
+
constructor(config, bucket, logger) {
|
|
190
|
+
this.config = config;
|
|
191
|
+
this.bucket = bucket;
|
|
192
|
+
this.logger = logger;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region src/features/workers/extract-worker.ts
|
|
197
|
+
var BucketExtractWorker = class extends BucketWorker {
|
|
198
|
+
#indexedMessagesByPath = /* @__PURE__ */ new Map();
|
|
199
|
+
get #indexedMessages() {
|
|
200
|
+
return Array.from(this.#indexedMessagesByPath.values()).flat();
|
|
201
|
+
}
|
|
202
|
+
async #indexPath(path) {
|
|
203
|
+
const relativePath = normalisePathForLogs(path);
|
|
204
|
+
this.logger.step(`Processing ${relativePath}`);
|
|
205
|
+
const messages = await extractMessagesFromFile(path, this.bucket);
|
|
206
|
+
if (!messages.length) {
|
|
207
|
+
if (this.#indexedMessagesByPath.has(path)) {
|
|
208
|
+
this.#indexedMessagesByPath.delete(path);
|
|
209
|
+
this.logger.step(`Removed stale entries for ${relativePath}`);
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
this.#indexedMessagesByPath.set(path, messages);
|
|
215
|
+
this.logger.step(`Found ${messages.length} message(s) in ${relativePath}`);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
async scan() {
|
|
219
|
+
this.logger.info(`Scanning bucket: ${this.bucket.include}`);
|
|
220
|
+
const paths = await globBucket(this.bucket);
|
|
221
|
+
this.logger.step(`Found ${paths.length} file(s)`);
|
|
222
|
+
await Promise.all(paths.map((p) => this.#indexPath(p)));
|
|
223
|
+
this.logger.info(`Total extracted messages: ${this.#indexedMessages.length}`);
|
|
224
|
+
}
|
|
225
|
+
async write() {
|
|
226
|
+
const mergedMessages = mergeExtractedMessages(this.#indexedMessages);
|
|
227
|
+
this.logger.info(`Writing ${mergedMessages.length} messages to locales`);
|
|
228
|
+
for (const locale of this.config.locales) {
|
|
229
|
+
this.logger.step(`Writing locale file for ${locale} to disk`);
|
|
230
|
+
const existingMessages = await readCatalogueMessages(this.bucket, locale);
|
|
231
|
+
const nextMessages = locale === this.config.locales[0] ? mergedMessages : reconcileLocaleMessages(existingMessages, mergedMessages);
|
|
232
|
+
await writeCatalogueMessages(this.bucket, locale, nextMessages);
|
|
233
|
+
}
|
|
234
|
+
this.logger.success(`Extraction complete for bucket: ${this.bucket.include}`);
|
|
235
|
+
}
|
|
236
|
+
async update(path) {
|
|
237
|
+
const changed = await this.#indexPath(path);
|
|
238
|
+
if (changed) await this.write();
|
|
239
|
+
return changed;
|
|
240
|
+
}
|
|
241
|
+
async watch() {
|
|
242
|
+
this.logger.header(`👀 Watching bucket for changes: ${this.bucket.include}`);
|
|
243
|
+
for await (const event of watchDebounced(".", { recursive: true })) {
|
|
244
|
+
if (!event.filename || !this.bucket.match(event.filename)) continue;
|
|
245
|
+
const path = join(process.cwd(), event.filename);
|
|
246
|
+
await this.update(path);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/commands/extract.ts
|
|
252
|
+
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) => {
|
|
253
|
+
const config = resolveConfig();
|
|
254
|
+
const logger = new Logger(options);
|
|
255
|
+
logger.header("🛠 Extracting Messages");
|
|
256
|
+
const workers = config.buckets.map((b) => new BucketExtractWorker(config, b, logger));
|
|
257
|
+
await Promise.all(workers.map((w) => w.scan().then(() => w.write())));
|
|
258
|
+
if (options.watch) await Promise.all(workers.map((w) => w.watch()));
|
|
259
|
+
});
|
|
260
|
+
//#endregion
|
|
261
|
+
//#region src/commands/index.ts
|
|
262
|
+
program.name("saykit").helpOption("-h, --help", "Display help for command").helpCommand("help [command]", "Display help for command").addCommand(extract_default).parse();
|
|
263
|
+
//#endregion
|
|
264
|
+
export {};
|