@menapo/vi18n 0.0.0-stage → 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/LICENSE +49 -0
- package/README.md +110 -2
- package/dist/apply-Bre-lY9J.js +228 -0
- package/dist/captured-Bj8bO6vd.js +1034 -0
- package/dist/check-D34P-S--.js +1 -0
- package/dist/check-DyrAbv73.js +201 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +41 -0
- package/dist/codec-DuvU2VQy.js +207 -0
- package/dist/collect-Cx4_SKOe.js +13 -0
- package/dist/collisions-CfpZU8e4.js +217 -0
- package/dist/compile-B5Mz6WJm.js +245 -0
- package/dist/compile-Cc97JaW2.js +2502 -0
- package/dist/concurrency-C9UUWuDC.js +96 -0
- package/dist/concurrency-DONL7t4V.js +1 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +39 -0
- package/dist/dir-zw6bGMKQ.js +290 -0
- package/dist/duplicates-yuRljQ6h.js +185 -0
- package/dist/engine-B7p40M__.js +531 -0
- package/dist/engine.d.ts +43 -0
- package/dist/engine.js +1241 -0
- package/dist/exclude-Du-Zd_qI.js +26 -0
- package/dist/export-DGsMbcTZ.js +103 -0
- package/dist/extract-Dujlsgnk.js +298 -0
- package/dist/formats-KPIg-08V.js +781 -0
- package/dist/i18next-CrUnKFfh.js +1596 -0
- package/dist/i18next-Rgo-v9l0.js +1 -0
- package/dist/import-Cwdhcywt.js +225 -0
- package/dist/index.d.ts +156 -0
- package/dist/index.js +239 -0
- package/dist/init-BecCTvKi.js +792 -0
- package/dist/library-D5CWvTQs.js +1882 -0
- package/dist/lingui--PcOOYry.js +833 -0
- package/dist/lint.d.ts +105 -0
- package/dist/lint.js +971 -0
- package/dist/load-CQRz4PpM.js +2950 -0
- package/dist/load-gW-oJt1Q.js +747 -0
- package/dist/locales-DX4MOBFE.js +827 -0
- package/dist/lsp-Cq1Nuu-Q.js +31 -0
- package/dist/match-BGr8EXB3.js +89 -0
- package/dist/mcp-Cf0T7qPW.js +28 -0
- package/dist/merge-driver-CpiiSVIc.js +422 -0
- package/dist/messages-Cxa-5tR0.js +166 -0
- package/dist/migrate-CntqvrW4.js +1 -0
- package/dist/migrate-dPgq9uzy.js +1484 -0
- package/dist/openai-CTaMDcl-.js +346 -0
- package/dist/output-DsaHKHEt.js +164 -0
- package/dist/parse.d.ts +62 -0
- package/dist/parse.js +156 -0
- package/dist/pipeline-C7g11THL.js +2031 -0
- package/dist/pipeline-wxS0SApG.js +1 -0
- package/dist/project-Dwx7iPm6.js +381 -0
- package/dist/project-pXipT8iG.js +1046 -0
- package/dist/pseudo-DRv30iN5.js +145 -0
- package/dist/raw-C4Y6vyGY.js +4708 -0
- package/dist/react-intl-CaBVwJ9u.js +519 -0
- package/dist/restore-Btq66Tid.js +175 -0
- package/dist/rewrite-CiTRAICl.js +803 -0
- package/dist/rich-DCUKl5HM.js +97 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/run-DYD_VXKy.js +694 -0
- package/dist/runtime-DTMmliT1.js +64 -0
- package/dist/scan-worker.d.ts +62 -0
- package/dist/scan-worker.js +1 -0
- package/dist/scoped-BMYpSthj.js +44 -0
- package/dist/server-BDkykMjb.js +11823 -0
- package/dist/server-qv3mz70d.js +10485 -0
- package/dist/server.d.ts +169 -0
- package/dist/server.js +1234 -0
- package/dist/sitemap-DV3o8QD7.js +159 -0
- package/dist/sources-D19czDSA.js +2242 -0
- package/dist/status-DZArW5aE.js +149 -0
- package/dist/stdio-CVaJfxA7.js +29 -0
- package/dist/stdio-FPOKQAgu.js +95 -0
- package/dist/sync-CcVzger_.js +658 -0
- package/dist/t-DC-c9zE5.js +454 -0
- package/dist/test.d.ts +116 -0
- package/dist/test.js +319 -0
- package/dist/translate-DZR3MkSA.js +299 -0
- package/dist/ts-BaU5yOf0.js +4863 -0
- package/dist/types-CgUCkFU1.d.ts +707 -0
- package/dist/types-D3QGM8XO.js +146 -0
- package/dist/types-DsdWBG0b.d.ts +558 -0
- package/dist/types-DtvQ7UdD.js +1948 -0
- package/dist/types-wSh9fCBY.js +11 -0
- package/dist/units-CQ5Q99RC.js +150 -0
- package/dist/url-CRfCOhUI.js +172 -0
- package/dist/url-CsBkRaJy.js +225 -0
- package/dist/validate-BxpupdAf.js +2615 -0
- package/dist/validate-D6DJepZv.js +365 -0
- package/dist/version-BlyLGlOa.js +28 -0
- package/dist/vite-scan-B_YW4X_a.js +242 -0
- package/dist/vite.d.ts +34 -0
- package/dist/vite.js +7558 -0
- package/dist/vue-i18n-CEMnpv7k.js +651 -0
- package/dist/which-C6PFI1Dm.js +116 -0
- package/dist/worker-84U6uYB3.js +1656 -0
- package/package.json +97 -3
- package/schemas/catalog.json +57 -0
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
import { D as dependencyRef, E as dependencyOf, O as listDependencyFiles, r as libraryIds } from "./library-D5CWvTQs.js";
|
|
2
|
+
import { n as cacheVersion } from "./version-BlyLGlOa.js";
|
|
3
|
+
import { i as scanSource, n as WORKER_MARKER, r as scanConfig, t as ABORT_CODES } from "./worker-84U6uYB3.js";
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { hash, randomBytes } from "node:crypto";
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
7
|
+
import { mkdir, open, readFile, readdir, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { availableParallelism } from "node:os";
|
|
10
|
+
import { Worker } from "node:worker_threads";
|
|
11
|
+
//#region src/core/config/hash.ts
|
|
12
|
+
/**
|
|
13
|
+
* `configHash` (INTERNALS §2.7.1): the key every cache derived from a config
|
|
14
|
+
* carries (scan cache, transform cache), so any config or version change
|
|
15
|
+
* invalidates them and nothing else does.
|
|
16
|
+
*/
|
|
17
|
+
function configHash(config, version) {
|
|
18
|
+
const { provider } = config.ai;
|
|
19
|
+
const hashed = {
|
|
20
|
+
...config,
|
|
21
|
+
routing: {
|
|
22
|
+
...config.routing,
|
|
23
|
+
routeStrategies: Object.entries(config.routing.routeStrategies)
|
|
24
|
+
},
|
|
25
|
+
ssr: {
|
|
26
|
+
...config.ssr,
|
|
27
|
+
timeZoneFrom: Object.entries(config.ssr.timeZoneFrom)
|
|
28
|
+
},
|
|
29
|
+
ai: typeof provider === "string" ? config.ai : {
|
|
30
|
+
...config.ai,
|
|
31
|
+
provider: {
|
|
32
|
+
name: provider.name,
|
|
33
|
+
model: provider.model
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
...config.extraction.dependencies.length === 0 ? {} : { libraryIds: libraryIdList(config) }
|
|
37
|
+
};
|
|
38
|
+
return hash("sha256", stableJson(hashed) + "\0" + version, "hex");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* JSON with object keys sorted at every depth, so the hash does not depend
|
|
42
|
+
* on the order in which a config was written or resolved. Array order is
|
|
43
|
+
* kept: strategy chains and fallbacks are ordered.
|
|
44
|
+
*/
|
|
45
|
+
function stableJson(value) {
|
|
46
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
let out = "[";
|
|
49
|
+
for (let i = 0; i < value.length; i++) out += (i === 0 ? "" : ",") + stableJson(value[i]);
|
|
50
|
+
return out + "]";
|
|
51
|
+
}
|
|
52
|
+
const record = value;
|
|
53
|
+
let out = "{";
|
|
54
|
+
let first = true;
|
|
55
|
+
for (const key of Object.keys(record).sort()) {
|
|
56
|
+
const item = record[key];
|
|
57
|
+
if (item === void 0 || typeof item === "function" || typeof item === "symbol") continue;
|
|
58
|
+
out += (first ? "" : ",") + JSON.stringify(key) + ":" + stableJson(item);
|
|
59
|
+
first = false;
|
|
60
|
+
}
|
|
61
|
+
return out + "}";
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The opted-in packages' manual ids, or none while one is not installed:
|
|
65
|
+
* hashing never fails, loading the config does (§2.5.7).
|
|
66
|
+
*/
|
|
67
|
+
function libraryIdList(config) {
|
|
68
|
+
try {
|
|
69
|
+
return [...libraryIds(config)];
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/core/extract/aggregate.ts
|
|
76
|
+
/**
|
|
77
|
+
* Groups occurrences by id. Contexts are split into single-line notes
|
|
78
|
+
* (trimmed, empty ones dropped, deduplicated, sorted by code unit); `maxLength`
|
|
79
|
+
* is the smallest given (§6 D15: the strictest limit is the safe one); `refs`
|
|
80
|
+
* and `sources` are sorted and unique; `locations` keep scan order. When
|
|
81
|
+
* occurrences share an id but not a canonical text — a collision callers
|
|
82
|
+
* report through `findCollisions` — the first one wins and the others are
|
|
83
|
+
* left out entirely.
|
|
84
|
+
*/
|
|
85
|
+
function aggregateOccurrences(occurrences) {
|
|
86
|
+
const pending = /* @__PURE__ */ new Map();
|
|
87
|
+
for (const occurrence of occurrences) {
|
|
88
|
+
let entry = pending.get(occurrence.id);
|
|
89
|
+
if (entry === void 0) {
|
|
90
|
+
entry = {
|
|
91
|
+
message: {
|
|
92
|
+
id: occurrence.id,
|
|
93
|
+
manual: occurrence.manual,
|
|
94
|
+
canonical: occurrence.canonical,
|
|
95
|
+
sourceHash: occurrence.sourceHash,
|
|
96
|
+
label: occurrence.label,
|
|
97
|
+
contexts: [],
|
|
98
|
+
refs: [],
|
|
99
|
+
sources: [],
|
|
100
|
+
locations: []
|
|
101
|
+
},
|
|
102
|
+
contexts: /* @__PURE__ */ new Set(),
|
|
103
|
+
refs: /* @__PURE__ */ new Set(),
|
|
104
|
+
sources: /* @__PURE__ */ new Set()
|
|
105
|
+
};
|
|
106
|
+
pending.set(occurrence.id, entry);
|
|
107
|
+
} else if (entry.message.canonical !== occurrence.canonical) continue;
|
|
108
|
+
const { message } = entry;
|
|
109
|
+
if (occurrence.context !== void 0) for (const line of occurrence.context.split("\n")) {
|
|
110
|
+
const note = line.trim();
|
|
111
|
+
if (note !== "") entry.contexts.add(note);
|
|
112
|
+
}
|
|
113
|
+
if (occurrence.maxLength !== void 0 && (message.maxLength === void 0 || occurrence.maxLength < message.maxLength)) message.maxLength = occurrence.maxLength;
|
|
114
|
+
entry.refs.add(occurrence.file);
|
|
115
|
+
entry.sources.add(occurrence.source);
|
|
116
|
+
message.locations.push({
|
|
117
|
+
file: occurrence.file,
|
|
118
|
+
line: occurrence.line,
|
|
119
|
+
column: occurrence.column
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const messages = /* @__PURE__ */ new Map();
|
|
123
|
+
for (const [id, { message, contexts, refs, sources }] of pending) {
|
|
124
|
+
message.contexts = [...contexts].sort();
|
|
125
|
+
message.refs = [...refs].sort();
|
|
126
|
+
message.sources = [...sources].sort();
|
|
127
|
+
messages.set(id, message);
|
|
128
|
+
}
|
|
129
|
+
return messages;
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/core/extract/cache.ts
|
|
133
|
+
/**
|
|
134
|
+
* The CLI scan cache (INTERNALS §2.5.6): per absolute file, what the last
|
|
135
|
+
* scan found, keyed by mtime + size or else content hash, and invalidated by
|
|
136
|
+
* any config change and any change of vi18n's code (`configHash` over
|
|
137
|
+
* `cacheVersion()`, the version and a fingerprint of the code: the version
|
|
138
|
+
* alone stays `0.0.0` in the workspace and in vendored tarballs). It lives
|
|
139
|
+
* in `node_modules/.cache/vi18n/scan-v1.json` under the config root's
|
|
140
|
+
* nearest `node_modules`, so a clean install drops it.
|
|
141
|
+
*/
|
|
142
|
+
const SCAN_CACHE_FILE = "scan-v1.json";
|
|
143
|
+
/** sha256 of the file text, the fallback key when mtime or size moved (checkouts, `touch`). */
|
|
144
|
+
function contentHash(text) {
|
|
145
|
+
return hash("sha256", text, "base64url");
|
|
146
|
+
}
|
|
147
|
+
/** `node_modules/.cache/vi18n/scan-v1.json` under the nearest `node_modules` at or above `root`. */
|
|
148
|
+
function scanCachePath(root) {
|
|
149
|
+
for (let dir = root;; dir = dirname(dir)) {
|
|
150
|
+
const modules = join(dir, "node_modules");
|
|
151
|
+
if (existsSync(modules)) return join(modules, ".cache", "vi18n", SCAN_CACHE_FILE);
|
|
152
|
+
if (dirname(dir) === dir) break;
|
|
153
|
+
}
|
|
154
|
+
return join(root, "node_modules", ".cache", "vi18n", SCAN_CACHE_FILE);
|
|
155
|
+
}
|
|
156
|
+
/** How long a save waits for another writer's lock, and when a lock counts as abandoned. */
|
|
157
|
+
const LOCK_WAIT_MS = 5e3;
|
|
158
|
+
const LOCK_STALE_MS = 3e4;
|
|
159
|
+
/** Saves of one cache file in this process, chained so each re-reads what the last wrote. */
|
|
160
|
+
const saving = /* @__PURE__ */ new Map();
|
|
161
|
+
let temps = 0;
|
|
162
|
+
const pause = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
163
|
+
/**
|
|
164
|
+
* Takes `<cache>.lock` for a read-merge-write against other processes (a
|
|
165
|
+
* workspace's packages scanned in parallel share one cache file). Resolves
|
|
166
|
+
* to the release function, or null when the lock stayed taken: the caller
|
|
167
|
+
* skips the write rather than drop another writer's section.
|
|
168
|
+
*/
|
|
169
|
+
async function lock(path) {
|
|
170
|
+
const file = path + ".lock";
|
|
171
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
172
|
+
for (;;) {
|
|
173
|
+
try {
|
|
174
|
+
await (await open(file, "wx")).close();
|
|
175
|
+
return () => unlink(file).catch(() => {});
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error.code !== "EEXIST") throw error;
|
|
178
|
+
}
|
|
179
|
+
const info = await stat(file).catch(() => null);
|
|
180
|
+
if (info !== null && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
181
|
+
await unlink(file).catch(() => {});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (Date.now() > deadline) return null;
|
|
185
|
+
await pause(10 + Math.random() * 40);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function isEntry(value) {
|
|
189
|
+
if (typeof value !== "object" || value === null) return false;
|
|
190
|
+
const e = value;
|
|
191
|
+
return typeof e.mtimeMs === "number" && typeof e.size === "number" && typeof e.contentHash === "string" && typeof e.configHash === "string" && typeof e.version === "string" && Array.isArray(e.occurrences) && Array.isArray(e.diagnostics);
|
|
192
|
+
}
|
|
193
|
+
async function readCacheFile(path) {
|
|
194
|
+
try {
|
|
195
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
196
|
+
if (parsed.format === 1 && typeof parsed.roots === "object" && parsed.roots !== null) return parsed;
|
|
197
|
+
} catch {}
|
|
198
|
+
return {
|
|
199
|
+
format: 1,
|
|
200
|
+
roots: {}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
var ScanCache = class ScanCache {
|
|
204
|
+
path;
|
|
205
|
+
/** `configHash(config, version)`: entries with another key are misses. */
|
|
206
|
+
key;
|
|
207
|
+
/** `cacheVersion()` unless a caller names another: entries of other code are misses. */
|
|
208
|
+
version;
|
|
209
|
+
#root;
|
|
210
|
+
#entries;
|
|
211
|
+
#dirty = false;
|
|
212
|
+
constructor(path, root, key, version, entries) {
|
|
213
|
+
this.path = path;
|
|
214
|
+
this.#root = root;
|
|
215
|
+
this.key = key;
|
|
216
|
+
this.version = version;
|
|
217
|
+
this.#entries = entries;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Reads the cache for `config` (empty when there is none or it is
|
|
221
|
+
* corrupt). `version` defaults to the running code's `cacheVersion()`.
|
|
222
|
+
*/
|
|
223
|
+
static async open(config, path = scanCachePath(config.root), version = cacheVersion()) {
|
|
224
|
+
const file = await readCacheFile(path);
|
|
225
|
+
const entries = /* @__PURE__ */ new Map();
|
|
226
|
+
const section = file.roots[config.root];
|
|
227
|
+
if (typeof section === "object" && section !== null) {
|
|
228
|
+
for (const [name, entry] of Object.entries(section)) if (isEntry(entry)) entries.set(name, entry);
|
|
229
|
+
}
|
|
230
|
+
return new ScanCache(path, config.root, configHash(config, version), version, entries);
|
|
231
|
+
}
|
|
232
|
+
get size() {
|
|
233
|
+
return this.#entries.size;
|
|
234
|
+
}
|
|
235
|
+
#current(file) {
|
|
236
|
+
const entry = this.#entries.get(file);
|
|
237
|
+
return entry !== void 0 && entry.configHash === this.key && entry.version === this.version ? entry : void 0;
|
|
238
|
+
}
|
|
239
|
+
/** A hit by file metadata alone: no read needed. */
|
|
240
|
+
byStat(file, mtimeMs, size) {
|
|
241
|
+
const entry = this.#current(file);
|
|
242
|
+
return entry !== void 0 && entry.mtimeMs === mtimeMs && entry.size === size ? entry : void 0;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* A hit by content after the metadata moved; the entry is refreshed with
|
|
246
|
+
* the new metadata so the next run hits by stat again.
|
|
247
|
+
*/
|
|
248
|
+
byContent(file, hashOfText, mtimeMs, size) {
|
|
249
|
+
const entry = this.#current(file);
|
|
250
|
+
if (entry === void 0 || entry.contentHash !== hashOfText) return void 0;
|
|
251
|
+
if (entry.mtimeMs !== mtimeMs || entry.size !== size) {
|
|
252
|
+
this.#entries.set(file, {
|
|
253
|
+
...entry,
|
|
254
|
+
mtimeMs,
|
|
255
|
+
size
|
|
256
|
+
});
|
|
257
|
+
this.#dirty = true;
|
|
258
|
+
}
|
|
259
|
+
return entry;
|
|
260
|
+
}
|
|
261
|
+
set(file, entry) {
|
|
262
|
+
this.#entries.set(file, {
|
|
263
|
+
...entry,
|
|
264
|
+
configHash: this.key,
|
|
265
|
+
version: this.version
|
|
266
|
+
});
|
|
267
|
+
this.#dirty = true;
|
|
268
|
+
}
|
|
269
|
+
delete(file) {
|
|
270
|
+
if (this.#entries.delete(file)) this.#dirty = true;
|
|
271
|
+
}
|
|
272
|
+
/** Drops entries of files that are no longer part of the project. */
|
|
273
|
+
retain(files) {
|
|
274
|
+
for (const file of this.#entries.keys()) if (!files.has(file)) {
|
|
275
|
+
this.#entries.delete(file);
|
|
276
|
+
this.#dirty = true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Writes the cache when it changed: under a lock, re-read, replace this
|
|
281
|
+
* root's section, write a temporary file and rename it over the old one,
|
|
282
|
+
* so a concurrent scan of another root keeps its section and readers never
|
|
283
|
+
* see half a file. Returns false when the cache was not written (a
|
|
284
|
+
* read-only checkout, a lock held too long): a cache never fails a scan.
|
|
285
|
+
*/
|
|
286
|
+
save() {
|
|
287
|
+
if (!this.#dirty) return Promise.resolve(true);
|
|
288
|
+
const run = (saving.get(this.path) ?? Promise.resolve()).then(() => this.#write());
|
|
289
|
+
const tail = run.catch(() => {});
|
|
290
|
+
saving.set(this.path, tail);
|
|
291
|
+
tail.then(() => {
|
|
292
|
+
if (saving.get(this.path) === tail) saving.delete(this.path);
|
|
293
|
+
});
|
|
294
|
+
return run;
|
|
295
|
+
}
|
|
296
|
+
async #write() {
|
|
297
|
+
if (!this.#dirty) return true;
|
|
298
|
+
const temp = `${this.path}.${process.pid}.${++temps}.${randomBytes(4).toString("hex")}.tmp`;
|
|
299
|
+
let release = null;
|
|
300
|
+
try {
|
|
301
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
302
|
+
release = await lock(this.path);
|
|
303
|
+
if (release === null) return false;
|
|
304
|
+
const file = await readCacheFile(this.path);
|
|
305
|
+
for (const root of Object.keys(file.roots)) if (!existsSync(root)) delete file.roots[root];
|
|
306
|
+
const section = {};
|
|
307
|
+
for (const [name, entry] of this.#entries) section[name] = entry;
|
|
308
|
+
file.roots[this.#root] = section;
|
|
309
|
+
await writeFile(temp, JSON.stringify(file));
|
|
310
|
+
await rename(temp, this.path);
|
|
311
|
+
this.#dirty = false;
|
|
312
|
+
return true;
|
|
313
|
+
} catch {
|
|
314
|
+
await unlink(temp).catch(() => {});
|
|
315
|
+
return false;
|
|
316
|
+
} finally {
|
|
317
|
+
await release?.();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region src/core/extract/files.ts
|
|
323
|
+
/** Directories read at once while walking: enough to keep the disk busy, few enough for any fd limit. */
|
|
324
|
+
const READDIR_CONCURRENCY = 32;
|
|
325
|
+
/** Posix form of a native path; `\` never occurs in posix names worth keeping. */
|
|
326
|
+
function toPosix(path) {
|
|
327
|
+
return sep === "/" ? path : path.replaceAll("\\", "/");
|
|
328
|
+
}
|
|
329
|
+
function escapeRegExp(ch) {
|
|
330
|
+
return /[\\^$.*+?()[\]{}|]/.test(ch) ? "\\" + ch : ch;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* A gitignore glob (`*` one segment, `**` any depth, `?` one character) as a
|
|
334
|
+
* regular expression body over a posix path.
|
|
335
|
+
*/
|
|
336
|
+
function globBody(pattern) {
|
|
337
|
+
let out = "";
|
|
338
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
339
|
+
const ch = pattern[i];
|
|
340
|
+
if (ch === "*") {
|
|
341
|
+
const atSegmentStart = i === 0 || pattern[i - 1] === "/";
|
|
342
|
+
if (pattern[i + 1] === "*" && atSegmentStart) {
|
|
343
|
+
if (pattern[i + 2] === "/") {
|
|
344
|
+
out += "(?:.*/)?";
|
|
345
|
+
i += 2;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (i + 2 === pattern.length) {
|
|
349
|
+
out += ".*";
|
|
350
|
+
i += 1;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
while (pattern[i + 1] === "*") i++;
|
|
355
|
+
out += "[^/]*";
|
|
356
|
+
} else if (ch === "?") out += "[^/]";
|
|
357
|
+
else out += escapeRegExp(ch);
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
const matchers = /* @__PURE__ */ new WeakMap();
|
|
362
|
+
/**
|
|
363
|
+
* One alternation per kind of path, so deciding a path is one regex test
|
|
364
|
+
* whatever the number of patterns. A pattern also matches everything below
|
|
365
|
+
* what it matches (gitignore excludes a directory's contents with it).
|
|
366
|
+
*/
|
|
367
|
+
function compileExcludes(patterns) {
|
|
368
|
+
const file = [];
|
|
369
|
+
const dir = [];
|
|
370
|
+
for (const raw of patterns) {
|
|
371
|
+
let pattern = raw.trim();
|
|
372
|
+
if (pattern === "" || pattern.startsWith("#")) continue;
|
|
373
|
+
const dirOnly = pattern.endsWith("/");
|
|
374
|
+
if (dirOnly) pattern = pattern.replace(/\/+$/, "");
|
|
375
|
+
if (pattern === "") continue;
|
|
376
|
+
if (!pattern.includes("/")) {
|
|
377
|
+
const segment = globBody(pattern);
|
|
378
|
+
file.push(`(?:^|/)${segment}${dirOnly ? "/" : "(?:/|$)"}`);
|
|
379
|
+
dir.push(`(?:^|/)${segment}(?:/|$)`);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const anchored = globBody(pattern.replace(/^\/+/, ""));
|
|
383
|
+
file.push(`^${anchored}${dirOnly ? "/" : "(?:/|$)"}`);
|
|
384
|
+
const pruned = !dirOnly && anchored.endsWith("/.*") ? anchored.slice(0, -3) : anchored;
|
|
385
|
+
dir.push(`^${pruned}(?:/|$)`);
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
file: file.length === 0 ? null : new RegExp(file.join("|")),
|
|
389
|
+
dir: dir.length === 0 ? null : new RegExp(dir.join("|"))
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function matcherFor(config) {
|
|
393
|
+
const project = config.project;
|
|
394
|
+
const known = matchers.get(project);
|
|
395
|
+
if (known !== void 0 && known.include === project.include && known.exclude === project.exclude && known.extensions === project.extensions) return known;
|
|
396
|
+
const roots = project.include.map((entry) => toPosix(entry).replace(/\/+$/, ""));
|
|
397
|
+
const realRoots = project.include.map((entry, i) => {
|
|
398
|
+
try {
|
|
399
|
+
const real = toPosix(realpathSync(entry));
|
|
400
|
+
return real === roots[i] ? null : real;
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
const matcher = {
|
|
406
|
+
include: project.include,
|
|
407
|
+
exclude: project.exclude,
|
|
408
|
+
extensions: project.extensions,
|
|
409
|
+
roots,
|
|
410
|
+
realRoots,
|
|
411
|
+
links: project.include.map(() => null),
|
|
412
|
+
...compileExcludes(project.exclude)
|
|
413
|
+
};
|
|
414
|
+
matchers.set(project, matcher);
|
|
415
|
+
return matcher;
|
|
416
|
+
}
|
|
417
|
+
/** Declaration files: a `.ts`-family extension, never a call. */
|
|
418
|
+
const DECLARATION = /\.d\.[cm]?ts$/;
|
|
419
|
+
/** Whether a file name carries a scanned extension (config extensions are single, like `.tsx`). */
|
|
420
|
+
function hasExtension(name, extensions) {
|
|
421
|
+
for (const ext of extensions) if (name.endsWith(ext) && name.length > ext.length) return !DECLARATION.test(name);
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
/** Config-relative posix path, `../` allowed. */
|
|
425
|
+
function relativeTo(root, path) {
|
|
426
|
+
const rootPosix = toPosix(root);
|
|
427
|
+
if (path.startsWith(rootPosix) && path[rootPosix.length] === "/") return path.slice(rootPosix.length + 1);
|
|
428
|
+
return toPosix(relative(root, path));
|
|
429
|
+
}
|
|
430
|
+
function isExcluded(m, rel, directory) {
|
|
431
|
+
const re = directory ? m.dir : m.file;
|
|
432
|
+
return re !== null && re.test(rel);
|
|
433
|
+
}
|
|
434
|
+
function under(path, dir) {
|
|
435
|
+
return path === dir || path.startsWith(dir) && path[dir.length] === "/";
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Whether the plugin and the CLI extract `file` (absolute; a `?query` or `\0`
|
|
439
|
+
* prefix is ignored): under an include entry, by its path, by the entry's
|
|
440
|
+
* realpath, or by a realpath the walk reached through a link inside the
|
|
441
|
+
* entry, with a scanned extension and no exclude match. Excludes see the
|
|
442
|
+
* path as the walk reached it, so a realpath module id (what Vite gives the
|
|
443
|
+
* plugin) gets the walk's answer.
|
|
444
|
+
*/
|
|
445
|
+
function isIncluded(file, config) {
|
|
446
|
+
if (dependencyOf(file, config) !== null) return true;
|
|
447
|
+
const m = matcherFor(config);
|
|
448
|
+
let path = file.startsWith("\0") ? file.slice(1) : file;
|
|
449
|
+
const query = path.indexOf("?");
|
|
450
|
+
if (query !== -1) path = path.slice(0, query);
|
|
451
|
+
path = toPosix(path);
|
|
452
|
+
if (!hasExtension(path.slice(path.lastIndexOf("/") + 1), m.extensions)) return false;
|
|
453
|
+
for (let i = 0; i < m.roots.length; i++) {
|
|
454
|
+
const root = m.roots[i];
|
|
455
|
+
const real = m.realRoots[i];
|
|
456
|
+
let logical = null;
|
|
457
|
+
if (under(path, root)) logical = path;
|
|
458
|
+
else if (real != null && under(path, real)) logical = root + path.slice(real.length);
|
|
459
|
+
else logical = throughLink(path, linksOf(m, config, i));
|
|
460
|
+
if (logical !== null && !isExcluded(m, relativeTo(config.root, logical), false)) return true;
|
|
461
|
+
}
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
/** The path the walk reached a realpath by through a link: the file itself if linked, else its directory. */
|
|
465
|
+
function throughLink(path, links) {
|
|
466
|
+
const linked = links.get(path);
|
|
467
|
+
if (linked !== void 0) return linked;
|
|
468
|
+
const slash = path.lastIndexOf("/");
|
|
469
|
+
const dir = links.get(path.slice(0, slash));
|
|
470
|
+
return dir === void 0 ? null : dir + path.slice(slash);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* The links of include entry `i`. A module outside every entry's tree may
|
|
474
|
+
* still be reached through a link, which only a walk can tell; the first
|
|
475
|
+
* such question walks the entry's directories once (the plugin asks only for
|
|
476
|
+
* modules with messages), and `listProjectFiles` refreshes the answer.
|
|
477
|
+
*/
|
|
478
|
+
function linksOf(m, config, i) {
|
|
479
|
+
let links = m.links[i];
|
|
480
|
+
if (links == null) {
|
|
481
|
+
links = /* @__PURE__ */ new Map();
|
|
482
|
+
const entry = config.project.include[i];
|
|
483
|
+
try {
|
|
484
|
+
if (statSync(entry).isDirectory() && enterable(m, config, entry)) {
|
|
485
|
+
const walk = new Walk(m, config, {
|
|
486
|
+
logical: entry,
|
|
487
|
+
real: realpathSync(entry)
|
|
488
|
+
}, null);
|
|
489
|
+
while (walk.level.length > 0) walk.step(walk.level.map(listSync));
|
|
490
|
+
links = walk.links;
|
|
491
|
+
}
|
|
492
|
+
} catch (error) {
|
|
493
|
+
if (!isMissing$1(error)) throw error;
|
|
494
|
+
}
|
|
495
|
+
m.links[i] = links;
|
|
496
|
+
}
|
|
497
|
+
return links;
|
|
498
|
+
}
|
|
499
|
+
function isMissing$1(error) {
|
|
500
|
+
const code = error.code;
|
|
501
|
+
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
|
|
502
|
+
}
|
|
503
|
+
function listed(entry) {
|
|
504
|
+
return {
|
|
505
|
+
name: entry.name,
|
|
506
|
+
isDir: entry.isDirectory(),
|
|
507
|
+
isFile: entry.isFile(),
|
|
508
|
+
real: null
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
async function listAsync(dir) {
|
|
512
|
+
let entries;
|
|
513
|
+
try {
|
|
514
|
+
entries = await readdir(dir.logical, { withFileTypes: true });
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (isMissing$1(error)) return null;
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
const out = [];
|
|
520
|
+
for (const entry of entries) {
|
|
521
|
+
if (!entry.isSymbolicLink()) {
|
|
522
|
+
out.push(listed(entry));
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
const logical = dir.logical + sep + entry.name;
|
|
526
|
+
try {
|
|
527
|
+
const target = await stat(logical);
|
|
528
|
+
out.push({
|
|
529
|
+
name: entry.name,
|
|
530
|
+
isDir: target.isDirectory(),
|
|
531
|
+
isFile: target.isFile(),
|
|
532
|
+
real: await realpath(logical)
|
|
533
|
+
});
|
|
534
|
+
} catch (error) {
|
|
535
|
+
if (!isMissing$1(error)) throw error;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
540
|
+
function listSync(dir) {
|
|
541
|
+
let entries;
|
|
542
|
+
try {
|
|
543
|
+
entries = readdirSync(dir.logical, { withFileTypes: true });
|
|
544
|
+
} catch (error) {
|
|
545
|
+
if (isMissing$1(error)) return null;
|
|
546
|
+
throw error;
|
|
547
|
+
}
|
|
548
|
+
const out = [];
|
|
549
|
+
for (const entry of entries) {
|
|
550
|
+
if (!entry.isSymbolicLink()) {
|
|
551
|
+
out.push(listed(entry));
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
const logical = dir.logical + sep + entry.name;
|
|
555
|
+
try {
|
|
556
|
+
const target = statSync(logical);
|
|
557
|
+
out.push({
|
|
558
|
+
name: entry.name,
|
|
559
|
+
isDir: target.isDirectory(),
|
|
560
|
+
isFile: target.isFile(),
|
|
561
|
+
real: realpathSync(logical)
|
|
562
|
+
});
|
|
563
|
+
} catch (error) {
|
|
564
|
+
if (!isMissing$1(error)) throw error;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
/** Whether a native path is `dir` or below it. */
|
|
570
|
+
function within(path, dir) {
|
|
571
|
+
return path === dir || path.startsWith(dir) && path[dir.length] === sep;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* One include directory, walked breadth-first by `step` (the listing is the
|
|
575
|
+
* caller's, async for the walk and sync for `isIncluded`, so both decide
|
|
576
|
+
* alike). Symlinks are followed by their realpath once, so a link cycle ends
|
|
577
|
+
* and a directory linked twice is read once. A link into the entry's own
|
|
578
|
+
* tree is not followed: its target is walked where it is, under its own
|
|
579
|
+
* path, which is the path a realpath module id names.
|
|
580
|
+
*/
|
|
581
|
+
var Walk = class {
|
|
582
|
+
links = /* @__PURE__ */ new Map();
|
|
583
|
+
/** Whether any file matched (also when an earlier entry already listed it: such an entry is not empty). */
|
|
584
|
+
found = false;
|
|
585
|
+
level;
|
|
586
|
+
#m;
|
|
587
|
+
#config;
|
|
588
|
+
#start;
|
|
589
|
+
#files;
|
|
590
|
+
#visited;
|
|
591
|
+
constructor(m, config, start, files) {
|
|
592
|
+
this.#m = m;
|
|
593
|
+
this.#config = config;
|
|
594
|
+
this.#start = start;
|
|
595
|
+
this.#files = files;
|
|
596
|
+
this.#visited = /* @__PURE__ */ new Set([start.real]);
|
|
597
|
+
this.level = [start];
|
|
598
|
+
}
|
|
599
|
+
/** Applies the listings of the current level (index-aligned with `level`). */
|
|
600
|
+
step(listings) {
|
|
601
|
+
const m = this.#m;
|
|
602
|
+
const candidates = [];
|
|
603
|
+
for (let i = 0; i < this.level.length; i++) {
|
|
604
|
+
const dir = this.level[i];
|
|
605
|
+
for (const entry of listings[i] ?? []) {
|
|
606
|
+
if (entry.real !== null && within(entry.real, this.#start.real)) continue;
|
|
607
|
+
const logical = dir.logical + sep + entry.name;
|
|
608
|
+
const real = entry.real ?? dir.real + sep + entry.name;
|
|
609
|
+
const rel = relativeTo(this.#config.root, toPosix(logical));
|
|
610
|
+
if (entry.isDir) {
|
|
611
|
+
if (!isExcluded(m, rel, true)) candidates.push({
|
|
612
|
+
logical,
|
|
613
|
+
real
|
|
614
|
+
});
|
|
615
|
+
} else if (entry.isFile && hasExtension(entry.name, m.extensions)) {
|
|
616
|
+
if (isExcluded(m, rel, false)) continue;
|
|
617
|
+
this.found = true;
|
|
618
|
+
this.#files?.add(real);
|
|
619
|
+
if (entry.real !== null) this.links.set(toPosix(real), toPosix(logical));
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
candidates.sort((a, b) => a.logical < b.logical ? -1 : a.logical > b.logical ? 1 : 0);
|
|
624
|
+
const next = [];
|
|
625
|
+
for (const dir of candidates) {
|
|
626
|
+
if (this.#visited.has(dir.real)) continue;
|
|
627
|
+
this.#visited.add(dir.real);
|
|
628
|
+
next.push(dir);
|
|
629
|
+
if (!within(dir.real, this.#start.real)) this.links.set(toPosix(dir.real), toPosix(dir.logical));
|
|
630
|
+
}
|
|
631
|
+
this.level = next;
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
async function mapLimit$1(items, limit, run) {
|
|
635
|
+
let next = 0;
|
|
636
|
+
const lanes = [];
|
|
637
|
+
for (let lane = 0; lane < Math.min(limit, items.length); lane++) lanes.push((async () => {
|
|
638
|
+
while (next < items.length) await run(items[next++]);
|
|
639
|
+
})());
|
|
640
|
+
await Promise.all(lanes);
|
|
641
|
+
}
|
|
642
|
+
async function walk(walker) {
|
|
643
|
+
while (walker.level.length > 0) {
|
|
644
|
+
const level = walker.level;
|
|
645
|
+
const listings = Array.from({ length: level.length }, () => null);
|
|
646
|
+
await mapLimit$1(level.map((_, i) => i), READDIR_CONCURRENCY, async (i) => {
|
|
647
|
+
listings[i] = await listAsync(level[i]);
|
|
648
|
+
});
|
|
649
|
+
walker.step(listings);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Whether an include directory is walked at all: its own path must pass the
|
|
654
|
+
* directory excludes too (excluding `legacy` excludes an entry `src/legacy`).
|
|
655
|
+
*/
|
|
656
|
+
function enterable(m, config, entry) {
|
|
657
|
+
const rel = relativeTo(config.root, toPosix(entry));
|
|
658
|
+
return rel === "" || !isExcluded(m, rel, true);
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Lists the project's files by realpath, sorted by code unit so scans are
|
|
662
|
+
* deterministic, and the include entries that matched no file (missing,
|
|
663
|
+
* empty, or wholly excluded), which `vi18n extract` reports as
|
|
664
|
+
* `extract-empty-root`. Overlapping entries list a file once.
|
|
665
|
+
*/
|
|
666
|
+
async function listProjectFiles(config) {
|
|
667
|
+
const m = matcherFor(config);
|
|
668
|
+
const files = /* @__PURE__ */ new Set();
|
|
669
|
+
const emptyRoots = [];
|
|
670
|
+
for (let i = 0; i < config.project.include.length; i++) {
|
|
671
|
+
const entry = config.project.include[i];
|
|
672
|
+
const rel = relativeTo(config.root, toPosix(entry));
|
|
673
|
+
let found = false;
|
|
674
|
+
let links = /* @__PURE__ */ new Map();
|
|
675
|
+
try {
|
|
676
|
+
const info = await stat(entry);
|
|
677
|
+
if (info.isDirectory()) {
|
|
678
|
+
if (enterable(m, config, entry)) {
|
|
679
|
+
const walker = new Walk(m, config, {
|
|
680
|
+
logical: entry,
|
|
681
|
+
real: await realpath(entry)
|
|
682
|
+
}, files);
|
|
683
|
+
await walk(walker);
|
|
684
|
+
found = walker.found;
|
|
685
|
+
links = walker.links;
|
|
686
|
+
}
|
|
687
|
+
} else if (info.isFile()) {
|
|
688
|
+
if (hasExtension(entry.slice(entry.lastIndexOf(sep) + 1), m.extensions) && !isExcluded(m, rel, false)) {
|
|
689
|
+
files.add(await realpath(entry));
|
|
690
|
+
found = true;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (!isMissing$1(error)) throw error;
|
|
695
|
+
}
|
|
696
|
+
m.links[i] = links;
|
|
697
|
+
if (!found) emptyRoots.push(entry);
|
|
698
|
+
}
|
|
699
|
+
for (const file of await listDependencyFiles(config)) files.add(file);
|
|
700
|
+
return {
|
|
701
|
+
files: [...files].sort(),
|
|
702
|
+
emptyRoots
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
//#endregion
|
|
706
|
+
//#region src/core/extract/project.ts
|
|
707
|
+
/**
|
|
708
|
+
* The project scan (INTERNALS §2.5.6): what `vi18n extract` and the plugin's
|
|
709
|
+
* build seed know about every message in the code. Files come from the
|
|
710
|
+
* include walk, hits from the scan cache, and misses are scanned in-process
|
|
711
|
+
* or, past 200 of them, in a `worker_threads` pool. Results are in file
|
|
712
|
+
* order whichever path produced them, so a scan is deterministic.
|
|
713
|
+
*/
|
|
714
|
+
/** Misses above which the pool pays for its start-up (each worker loads the parser and compilers). */
|
|
715
|
+
const POOL_THRESHOLD = 200;
|
|
716
|
+
const POOL_MAX = 8;
|
|
717
|
+
/** Files per worker message: large enough to amortise messaging, small enough to balance load. */
|
|
718
|
+
const BATCH = 16;
|
|
719
|
+
/** Explicit files resolved at once. */
|
|
720
|
+
const RESOLVE_CONCURRENCY = 64;
|
|
721
|
+
/**
|
|
722
|
+
* The worker entry next to this module: the source file in the workspace
|
|
723
|
+
* (Node strips its types), `scan-worker.js` beside the packed CLI chunk.
|
|
724
|
+
*/
|
|
725
|
+
const WORKER_URL = new URL(import.meta.url.endsWith(".ts") ? "./worker.ts" : "./scan-worker.js", import.meta.url);
|
|
726
|
+
function isMissing(error) {
|
|
727
|
+
const code = error.code;
|
|
728
|
+
return code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR";
|
|
729
|
+
}
|
|
730
|
+
async function mapLimit(items, limit, run) {
|
|
731
|
+
let next = 0;
|
|
732
|
+
const lanes = [];
|
|
733
|
+
for (let lane = 0; lane < Math.min(limit, items.length); lane++) lanes.push((async () => {
|
|
734
|
+
while (next < items.length) await run(items[next++]);
|
|
735
|
+
})());
|
|
736
|
+
await Promise.all(lanes);
|
|
737
|
+
}
|
|
738
|
+
/** Maps an absolute file to its `#:` reference: posix, relative to the config root. */
|
|
739
|
+
function refMapper(config) {
|
|
740
|
+
const roots = [toPosix(config.root)];
|
|
741
|
+
let realRoot = null;
|
|
742
|
+
try {
|
|
743
|
+
realRoot = realpathSync(config.root);
|
|
744
|
+
if (toPosix(realRoot) !== roots[0]) roots.unshift(toPosix(realRoot));
|
|
745
|
+
} catch {}
|
|
746
|
+
return (file) => {
|
|
747
|
+
const posix = toPosix(file);
|
|
748
|
+
const dependency = dependencyRef(posix, config);
|
|
749
|
+
if (dependency !== null) return dependency;
|
|
750
|
+
for (const root of roots) if (posix.startsWith(root) && posix[root.length] === "/") return posix.slice(root.length + 1);
|
|
751
|
+
if (realRoot !== null) try {
|
|
752
|
+
return toPosix(relative(realRoot, realpathSync(file)));
|
|
753
|
+
} catch {}
|
|
754
|
+
return toPosix(relative(config.root, file));
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
async function explicitFiles(config, files) {
|
|
758
|
+
const out = /* @__PURE__ */ new Set();
|
|
759
|
+
await mapLimit(files, RESOLVE_CONCURRENCY, async (file) => {
|
|
760
|
+
const logical = resolve(config.root, file);
|
|
761
|
+
let real;
|
|
762
|
+
try {
|
|
763
|
+
real = await realpath(logical);
|
|
764
|
+
} catch (error) {
|
|
765
|
+
if (isMissing(error)) return;
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
if (isIncluded(real, config) || isIncluded(logical, config)) out.add(real);
|
|
769
|
+
});
|
|
770
|
+
return [...out].sort();
|
|
771
|
+
}
|
|
772
|
+
function emptyRootDiagnostic(root, ref) {
|
|
773
|
+
const file = ref(root);
|
|
774
|
+
return {
|
|
775
|
+
code: "extract-empty-root",
|
|
776
|
+
severity: "error",
|
|
777
|
+
message: `The include entry ${file} matches no file; fix project.include or project.exclude in the config`,
|
|
778
|
+
file
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* A missing compiler fails every file of its kind with the same message;
|
|
783
|
+
* the project-level list names it once, with the other files as `related`.
|
|
784
|
+
*/
|
|
785
|
+
function projectDiagnostics(scans, leading) {
|
|
786
|
+
const out = leading;
|
|
787
|
+
const missing = /* @__PURE__ */ new Map();
|
|
788
|
+
for (const scan of scans) for (const diagnostic of scan.diagnostics) {
|
|
789
|
+
if (diagnostic.code !== "extract-missing-compiler") {
|
|
790
|
+
out.push(diagnostic);
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
const first = missing.get(diagnostic.message);
|
|
794
|
+
if (first === void 0) {
|
|
795
|
+
const copy = { ...diagnostic };
|
|
796
|
+
missing.set(diagnostic.message, copy);
|
|
797
|
+
out.push(copy);
|
|
798
|
+
} else (first.related ??= []).push({
|
|
799
|
+
file: scan.refFile,
|
|
800
|
+
message: "also needs it"
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
return out;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* A `worker_threads` pool, started before the files are read so the
|
|
807
|
+
* workers load the parser while the main thread reads. `run` hands out
|
|
808
|
+
* batches as workers finish, so one slow file (a large SFC) does not idle
|
|
809
|
+
* the rest.
|
|
810
|
+
*/
|
|
811
|
+
var ScanPool = class {
|
|
812
|
+
#workers = [];
|
|
813
|
+
/** Set while a run is active: how a dead worker ends it. */
|
|
814
|
+
#fail = null;
|
|
815
|
+
#failed = false;
|
|
816
|
+
constructor(size, config, codec) {
|
|
817
|
+
const init = {
|
|
818
|
+
marker: WORKER_MARKER,
|
|
819
|
+
config: scanConfig(config),
|
|
820
|
+
idLength: codec.idLength
|
|
821
|
+
};
|
|
822
|
+
const onFailure = () => {
|
|
823
|
+
this.#failed = true;
|
|
824
|
+
this.#fail?.();
|
|
825
|
+
};
|
|
826
|
+
for (let i = 0; i < size; i++) {
|
|
827
|
+
const worker = new Worker(WORKER_URL, { workerData: init });
|
|
828
|
+
worker.on("error", onFailure);
|
|
829
|
+
worker.on("exit", (code) => {
|
|
830
|
+
if (code !== 0) onFailure();
|
|
831
|
+
});
|
|
832
|
+
this.#workers.push(worker);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Scans `misses` into `results` by file index. Resolves false when a
|
|
837
|
+
* worker could not start or died: the caller scans what is left
|
|
838
|
+
* in-process. A scan error inside a worker rejects, as it would
|
|
839
|
+
* in-process.
|
|
840
|
+
*/
|
|
841
|
+
run(misses, results) {
|
|
842
|
+
if (this.#failed) return Promise.resolve(false);
|
|
843
|
+
const batches = [];
|
|
844
|
+
for (let i = 0; i < misses.length; i += BATCH) batches.push(misses.slice(i, i + BATCH));
|
|
845
|
+
let next = 0;
|
|
846
|
+
let pending = batches.length;
|
|
847
|
+
return new Promise((resolveRun, rejectRun) => {
|
|
848
|
+
let settled = false;
|
|
849
|
+
const finish = (outcome) => {
|
|
850
|
+
if (settled) return;
|
|
851
|
+
settled = true;
|
|
852
|
+
this.#fail = null;
|
|
853
|
+
for (const worker of this.#workers) worker.removeAllListeners("message");
|
|
854
|
+
if (outcome instanceof Error) rejectRun(outcome);
|
|
855
|
+
else resolveRun(outcome);
|
|
856
|
+
};
|
|
857
|
+
this.#fail = () => finish(false);
|
|
858
|
+
const send = (worker) => {
|
|
859
|
+
if (next >= batches.length) return;
|
|
860
|
+
const batch = next++;
|
|
861
|
+
const tasks = batches[batch].map((miss) => miss.task);
|
|
862
|
+
worker.postMessage({
|
|
863
|
+
batch,
|
|
864
|
+
tasks
|
|
865
|
+
});
|
|
866
|
+
};
|
|
867
|
+
if (pending === 0) finish(true);
|
|
868
|
+
for (const worker of this.#workers) {
|
|
869
|
+
worker.on("message", (response) => {
|
|
870
|
+
if (settled) return;
|
|
871
|
+
if ("error" in response) {
|
|
872
|
+
const error = new Error(response.error.message);
|
|
873
|
+
if (response.error.stack !== void 0) error.stack = response.error.stack;
|
|
874
|
+
finish(error);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const batch = batches[response.batch];
|
|
878
|
+
for (let j = 0; j < batch.length; j++) results[batch[j].index] = response.results[j];
|
|
879
|
+
poolStats.files += batch.length;
|
|
880
|
+
if (--pending === 0) finish(true);
|
|
881
|
+
else send(worker);
|
|
882
|
+
});
|
|
883
|
+
send(worker);
|
|
884
|
+
send(worker);
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
close() {
|
|
889
|
+
for (const worker of this.#workers) worker.terminate();
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
function poolSize(misses, option) {
|
|
893
|
+
if (option !== void 0) return Math.max(0, Math.min(Math.floor(option), misses));
|
|
894
|
+
if (misses <= POOL_THRESHOLD) return 0;
|
|
895
|
+
return Math.max(0, Math.min(POOL_MAX, availableParallelism() - 1));
|
|
896
|
+
}
|
|
897
|
+
let workerEntryExists;
|
|
898
|
+
let warnedMissingEntry = false;
|
|
899
|
+
/** A pool for `misses` files, or null to scan in-process (also when the worker entry was not packed). */
|
|
900
|
+
function startPool(misses, option, config, codec) {
|
|
901
|
+
const size = poolSize(misses, option);
|
|
902
|
+
if (size === 0) return null;
|
|
903
|
+
workerEntryExists ??= existsSync(fileURLToPath(WORKER_URL));
|
|
904
|
+
return workerEntryExists ? new ScanPool(size, config, codec) : null;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* A build that lost the worker entry still scans correctly, only slower:
|
|
908
|
+
* counted once per scan so tests of the packed layout see it, and said once
|
|
909
|
+
* when the caller asked for workers by number and would otherwise never
|
|
910
|
+
* learn why the scan did not speed up.
|
|
911
|
+
*/
|
|
912
|
+
function missingEntryFallback(option) {
|
|
913
|
+
poolStats.fallbacks++;
|
|
914
|
+
if (option !== void 0 && option > 0 && !warnedMissingEntry) {
|
|
915
|
+
warnedMissingEntry = true;
|
|
916
|
+
console.warn(`[vi18n] The scan worker entry ${fileURLToPath(WORKER_URL)} is missing; scanning in-process`);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
function cachedScan(file, refFile, entry) {
|
|
920
|
+
return {
|
|
921
|
+
file,
|
|
922
|
+
refFile,
|
|
923
|
+
occurrences: entry.occurrences,
|
|
924
|
+
diagnostics: entry.diagnostics,
|
|
925
|
+
fromCache: true
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Files scanned by pool workers, and scans that wanted a pool but fell back
|
|
930
|
+
* in-process (a worker died, or the worker entry was not packed), since the
|
|
931
|
+
* process started. Diagnostics for tests and benchmarks; nothing reads
|
|
932
|
+
* them to decide anything.
|
|
933
|
+
*/
|
|
934
|
+
const poolStats = {
|
|
935
|
+
files: 0,
|
|
936
|
+
fallbacks: 0
|
|
937
|
+
};
|
|
938
|
+
/**
|
|
939
|
+
* Scans the project (§2.5.6). `aborted` is true when an include entry
|
|
940
|
+
* matched no file, a file failed to parse or compile, or a compiler is
|
|
941
|
+
* missing; the scan still covers every other file so all problems are
|
|
942
|
+
* reported at once, but `vi18n extract` must not write.
|
|
943
|
+
*/
|
|
944
|
+
async function scanProject(config, codec, options = {}) {
|
|
945
|
+
const ref = refMapper(config);
|
|
946
|
+
const leading = [];
|
|
947
|
+
let files;
|
|
948
|
+
if (options.files === void 0) {
|
|
949
|
+
const listed = await listProjectFiles(config);
|
|
950
|
+
files = listed.files;
|
|
951
|
+
for (const root of listed.emptyRoots) leading.push(emptyRootDiagnostic(root, ref));
|
|
952
|
+
} else files = await explicitFiles(config, options.files);
|
|
953
|
+
const cache = options.cache === true ? await ScanCache.open(config) : null;
|
|
954
|
+
let pool = null;
|
|
955
|
+
if (cache === null || cache.size === 0) pool = startPool(files.length, options.workers, config, codec);
|
|
956
|
+
const scans = Array.from({ length: files.length });
|
|
957
|
+
try {
|
|
958
|
+
const misses = [];
|
|
959
|
+
for (let index = 0; index < files.length; index++) {
|
|
960
|
+
const file = files[index];
|
|
961
|
+
const refFile = ref(file);
|
|
962
|
+
let mtimeMs = 0;
|
|
963
|
+
let size = 0;
|
|
964
|
+
let text;
|
|
965
|
+
try {
|
|
966
|
+
if (cache !== null) {
|
|
967
|
+
const info = statSync(file);
|
|
968
|
+
mtimeMs = info.mtimeMs;
|
|
969
|
+
size = info.size;
|
|
970
|
+
const hit = cache.byStat(file, mtimeMs, size);
|
|
971
|
+
if (hit !== void 0) {
|
|
972
|
+
scans[index] = cachedScan(file, refFile, hit);
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
text = readFileSync(file, "utf8");
|
|
977
|
+
} catch (error) {
|
|
978
|
+
if (isMissing(error)) continue;
|
|
979
|
+
throw error;
|
|
980
|
+
}
|
|
981
|
+
let hashOfText = "";
|
|
982
|
+
if (cache !== null) {
|
|
983
|
+
hashOfText = contentHash(text);
|
|
984
|
+
const hit = cache.byContent(file, hashOfText, mtimeMs, size);
|
|
985
|
+
if (hit !== void 0) {
|
|
986
|
+
scans[index] = cachedScan(file, refFile, hit);
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
misses.push({
|
|
991
|
+
index,
|
|
992
|
+
task: {
|
|
993
|
+
file,
|
|
994
|
+
refFile,
|
|
995
|
+
text
|
|
996
|
+
},
|
|
997
|
+
mtimeMs,
|
|
998
|
+
size,
|
|
999
|
+
contentHash: hashOfText
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
pool ??= startPool(misses.length, options.workers, config, codec);
|
|
1003
|
+
if (pool === null && workerEntryExists === false && poolSize(misses.length, options.workers) > 0) missingEntryFallback(options.workers);
|
|
1004
|
+
const results = [];
|
|
1005
|
+
if (pool !== null && misses.length > 0 && !await pool.run(misses, results)) poolStats.fallbacks++;
|
|
1006
|
+
for (const miss of misses) {
|
|
1007
|
+
const result = results[miss.index] ??= await scanSource(miss.task, config, codec);
|
|
1008
|
+
scans[miss.index] = {
|
|
1009
|
+
file: miss.task.file,
|
|
1010
|
+
refFile: miss.task.refFile,
|
|
1011
|
+
occurrences: result.occurrences,
|
|
1012
|
+
diagnostics: result.diagnostics,
|
|
1013
|
+
fromCache: false
|
|
1014
|
+
};
|
|
1015
|
+
if (cache === null) continue;
|
|
1016
|
+
if (result.aborted) cache.delete(miss.task.file);
|
|
1017
|
+
else cache.set(miss.task.file, {
|
|
1018
|
+
mtimeMs: miss.mtimeMs,
|
|
1019
|
+
size: miss.size,
|
|
1020
|
+
contentHash: miss.contentHash,
|
|
1021
|
+
occurrences: result.occurrences,
|
|
1022
|
+
diagnostics: result.diagnostics
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
} finally {
|
|
1026
|
+
pool?.close();
|
|
1027
|
+
}
|
|
1028
|
+
const fileScans = scans.filter((scan) => scan !== void 0);
|
|
1029
|
+
if (cache !== null) {
|
|
1030
|
+
if (options.files === void 0) cache.retain(new Set(fileScans.map((scan) => scan.file)));
|
|
1031
|
+
await cache.save();
|
|
1032
|
+
}
|
|
1033
|
+
const diagnostics = projectDiagnostics(fileScans, leading);
|
|
1034
|
+
let aborted = false;
|
|
1035
|
+
for (const diagnostic of diagnostics) if (ABORT_CODES.has(diagnostic.code)) aborted = true;
|
|
1036
|
+
return {
|
|
1037
|
+
files: fileScans,
|
|
1038
|
+
messages: aggregateOccurrences((function* () {
|
|
1039
|
+
for (const scan of fileScans) yield* scan.occurrences;
|
|
1040
|
+
})()),
|
|
1041
|
+
diagnostics,
|
|
1042
|
+
aborted
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
//#endregion
|
|
1046
|
+
export { toPosix as a, listProjectFiles as i, scanProject as n, aggregateOccurrences as o, isIncluded as r, configHash as s, refMapper as t };
|