@x18n/node 0.0.3 → 0.0.6

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/dist/index.d.mts CHANGED
@@ -13,6 +13,19 @@ export declare function loadYamlPlugin(file: string): Promise<Plugin | Preset>;
13
13
  /** Resolves a plugin specifier relative to the config directory: a YAML file, a module path, or a package name. */
14
14
  export declare function createPluginLoader(dir: string): (specifier: string, options: JsonObject | undefined) => Promise<Plugin | Preset>;
15
15
  //#endregion
16
+ //#region src/git-history.d.ts
17
+ interface GitHistoryOptions {
18
+ /** Most commits to read, newest first. Default: every commit. */
19
+ readonly maxDepth?: number | undefined;
20
+ /** Only commits after this time: a `git log --since` value such as `1 year ago`, or a Date. */
21
+ readonly since?: string | Date | undefined;
22
+ }
23
+ declare module "@x18n/core" {
24
+ interface HistoryConfigs {
25
+ readonly git?: false | GitHistoryOptions;
26
+ }
27
+ }
28
+ //#endregion
16
29
  //#region src/host.d.ts
17
30
  interface NodeHostOptions {
18
31
  /** Directory of the persistent cache, or false to disable it. Default `node_modules/.cache/x18n` under the root. */
@@ -81,4 +94,4 @@ interface MigrateOptions {
81
94
  /** Converts i18n-ally settings, custom framework and reviews into x18n files. Never deletes i18n-ally files. */
82
95
  export declare function migrateI18nAlly(root: string, options?: MigrateOptions): Promise<MigrationResult>;
83
96
  //#endregion
84
- export type { LoadOptions, LoadedProject, MigrateOptions, MigrationFile, MigrationResult, NodeHost, NodeHostOptions, Workspace };
97
+ export type { GitHistoryOptions, LoadOptions, LoadedProject, MigrateOptions, MigrationFile, MigrationResult, NodeHost, NodeHostOptions, Workspace };
package/dist/index.mjs CHANGED
@@ -12,6 +12,8 @@ import process from "node:process";
12
12
  import { watch } from "chokidar";
13
13
  import ignore from "ignore";
14
14
  import { createBuiltins } from "@x18n/builtins";
15
+ import { Buffer } from "node:buffer";
16
+ import { execFile, spawn } from "node:child_process";
15
17
  import { applyEdits, modify, parse as parse$1 } from "jsonc-parser";
16
18
  //#region src/config.ts
17
19
  const configFileNames = [
@@ -41,17 +43,22 @@ function findConfigUpward(cwd) {
41
43
  dir = parent;
42
44
  }
43
45
  }
44
- const core = fileURLToPath(import.meta.resolve("@x18n/core"));
45
- const presets = fileURLToPath(import.meta.resolve("@x18n/builtins/presets"));
46
- const jiti = createJiti(import.meta.url, {
46
+ function resolveOwn(specifier) {
47
+ try {
48
+ return fileURLToPath(import.meta.resolve(specifier));
49
+ } catch {}
50
+ }
51
+ const core = resolveOwn("@x18n/core");
52
+ const presets = resolveOwn("@x18n/builtins/presets");
53
+ const jiti = createJiti(import.meta.url ?? pathToFileURL(__filename).href, {
47
54
  moduleCache: false,
48
55
  interopDefault: true,
49
- alias: {
56
+ alias: core && presets ? {
50
57
  "@x18n/core": core,
51
58
  "@x18n/cli/presets": presets,
52
59
  "@x18n/cli": core,
53
60
  "@x18n/builtins/presets": presets
54
- }
61
+ } : {}
55
62
  });
56
63
  /** Imports a JS or TS module (config, plugin, executed resource). */
57
64
  async function importModule(file) {
@@ -284,6 +291,354 @@ function createNodeHost(root, options = {}) {
284
291
  };
285
292
  }
286
293
  //#endregion
294
+ //#region src/git-history.ts
295
+ const noBlob = /^0+$/;
296
+ function git(cwd, args) {
297
+ return new Promise((resolve, reject) => {
298
+ execFile("git", [
299
+ "-c",
300
+ "core.quotePath=false",
301
+ ...args
302
+ ], {
303
+ cwd,
304
+ maxBuffer: 1024 ** 3,
305
+ encoding: "utf8"
306
+ }, (error, stdout) => {
307
+ if (error) reject(error);
308
+ else resolve(stdout);
309
+ });
310
+ });
311
+ }
312
+ /** Blob texts by sha, through one `git cat-file --batch`. */
313
+ function readBlobs(cwd, shas) {
314
+ return new Promise((resolve, reject) => {
315
+ const child = spawn("git", ["cat-file", "--batch"], { cwd });
316
+ const chunks = [];
317
+ child.stdout.on("data", (chunk) => chunks.push(chunk));
318
+ child.on("error", reject);
319
+ child.on("close", () => {
320
+ const data = Buffer.concat(chunks);
321
+ const out = /* @__PURE__ */ new Map();
322
+ let position = 0;
323
+ while (position < data.length) {
324
+ const end = data.indexOf(10, position);
325
+ const [sha, type, size] = data.subarray(position, end).toString("utf8").split(" ");
326
+ position = end + 1;
327
+ if (type !== "blob") continue;
328
+ out.set(sha, data.subarray(position, position + Number(size)).toString("utf8"));
329
+ position += Number(size) + 1;
330
+ }
331
+ resolve(out);
332
+ });
333
+ child.stdin.end(shas.length ? `${shas.join("\n")}\n` : "");
334
+ });
335
+ }
336
+ /** The path a file added in `commit` was renamed from, with its blob. */
337
+ async function renamedFrom(top, commit, path) {
338
+ const out = await git(top, [
339
+ "diff-tree",
340
+ "-r",
341
+ "-M",
342
+ "--raw",
343
+ "--no-abbrev",
344
+ "--no-commit-id",
345
+ commit
346
+ ]);
347
+ for (const line of out.split("\n")) {
348
+ const [meta, from, to] = line.split(" ");
349
+ if (to === path && meta?.split(" ")[4]?.startsWith("R")) return {
350
+ path: from,
351
+ blob: meta.split(" ")[2]
352
+ };
353
+ }
354
+ }
355
+ /** Parses `git log --raw --format=%x01%H%x00%ct%x00%an`. */
356
+ function parseLog(out) {
357
+ return out.split("").slice(1).map((chunk) => {
358
+ const [header = "", ...lines] = chunk.split("\n");
359
+ const [sha = "", time, author = ""] = header.split("\0");
360
+ const files = lines.filter((line) => line.startsWith(":")).map((line) => {
361
+ const [meta = "", ...paths] = line.split(" ");
362
+ const [, , before = "", after = "", status = ""] = meta.split(" ");
363
+ return {
364
+ path: paths.at(-1) ?? "",
365
+ status,
366
+ before: noBlob.test(before) ? void 0 : before,
367
+ after: noBlob.test(after) ? void 0 : after
368
+ };
369
+ });
370
+ return {
371
+ sha,
372
+ at: Number(time) * 1e3,
373
+ author,
374
+ files
375
+ };
376
+ });
377
+ }
378
+ async function collectCommits(input) {
379
+ const commits = /* @__PURE__ */ new Map();
380
+ const queue = [{
381
+ from: input.head,
382
+ paths: input.resources
383
+ }];
384
+ for (let item = queue.shift(); item; item = queue.shift()) {
385
+ const out = await git(input.top, [
386
+ "log",
387
+ item.from,
388
+ ...input.base ? [`^${input.base}`] : [],
389
+ "--no-merges",
390
+ "--raw",
391
+ "--no-abbrev",
392
+ "--format=%x01%H%x00%ct%x00%an",
393
+ ...input.window,
394
+ "--",
395
+ ...[...item.paths.keys()].map((path) => `:(literal)${path}`)
396
+ ]);
397
+ for (const entry of parseLog(out)) {
398
+ const commit = commits.get(entry.sha) ?? {
399
+ at: entry.at,
400
+ author: entry.author,
401
+ files: []
402
+ };
403
+ commits.set(entry.sha, commit);
404
+ for (const file of entry.files) {
405
+ const resource = item.paths.get(file.path);
406
+ if (!resource) continue;
407
+ const renamed = file.status === "A" ? await renamedFrom(input.top, entry.sha, file.path) : void 0;
408
+ if (renamed) queue.push({
409
+ from: `${entry.sha}^`,
410
+ paths: /* @__PURE__ */ new Map([[renamed.path, resource]])
411
+ });
412
+ commit.files.push({
413
+ resource,
414
+ before: renamed?.blob ?? file.before,
415
+ after: file.after
416
+ });
417
+ }
418
+ }
419
+ }
420
+ return commits;
421
+ }
422
+ /** Message changes per key and locale: in each commit, the messages of a locale's changed files before and after. */
423
+ async function walk(input) {
424
+ const commits = await collectCommits(input);
425
+ const shas = new Set([...commits.values()].flatMap((commit) => commit.files.flatMap((file) => [file.before, file.after].filter((sha) => sha !== void 0))));
426
+ const blobs = await readBlobs(input.top, [...shas]);
427
+ const parsed = /* @__PURE__ */ new Map();
428
+ const messagesOf = (resource, sha) => {
429
+ if (sha === void 0) return /* @__PURE__ */ new Map();
430
+ const id = `${resource.id}\0${sha}`;
431
+ if (!parsed.has(id)) {
432
+ const text = blobs.get(sha);
433
+ parsed.set(id, text === void 0 ? void 0 : input.ctx.messagesIn(resource, text));
434
+ }
435
+ return parsed.get(id);
436
+ };
437
+ const changes = /* @__PURE__ */ new Map();
438
+ for (const [sha, commit] of commits) {
439
+ const byLocale = Map.groupBy(commit.files, (file) => file.resource.locale);
440
+ for (const [locale, files] of byLocale) {
441
+ const before = /* @__PURE__ */ new Map();
442
+ const after = /* @__PURE__ */ new Map();
443
+ for (const file of files) {
444
+ const old = messagesOf(file.resource, file.before);
445
+ const next = messagesOf(file.resource, file.after);
446
+ if (!old || !next) continue;
447
+ old.forEach((value, key) => before.has(key) || before.set(key, value));
448
+ next.forEach((value, key) => after.has(key) || after.set(key, value));
449
+ }
450
+ for (const [key, value] of after) {
451
+ if (before.has(key) && before.get(key) === value) continue;
452
+ const byKey = changes.get(key) ?? /* @__PURE__ */ new Map();
453
+ changes.set(key, byKey);
454
+ byKey.set(locale, [...byKey.get(locale) ?? [], [
455
+ commit.at,
456
+ sha,
457
+ commit.author,
458
+ value
459
+ ]]);
460
+ }
461
+ }
462
+ }
463
+ for (const byKey of changes.values()) for (const list of byKey.values()) list.sort((a, b) => b[0] - a[0]);
464
+ return changes;
465
+ }
466
+ /** Messages at HEAD per locale, to tell uncommitted edits. */
467
+ async function headValues(ctx, top, resources) {
468
+ const files = (await git(top, [
469
+ "ls-tree",
470
+ "-r",
471
+ "HEAD",
472
+ "--",
473
+ ...resources.keys()
474
+ ])).split("\n").flatMap((line) => {
475
+ const [meta = "", path = ""] = line.split(" ");
476
+ const resource = resources.get(path);
477
+ return resource ? [{
478
+ resource,
479
+ sha: meta.split(" ")[2]
480
+ }] : [];
481
+ });
482
+ const blobs = await readBlobs(top, files.map((file) => file.sha));
483
+ const values = /* @__PURE__ */ new Map();
484
+ for (const { resource, sha } of files) {
485
+ const byKey = values.get(resource.locale) ?? /* @__PURE__ */ new Map();
486
+ values.set(resource.locale, byKey);
487
+ const text = blobs.get(sha);
488
+ if (text !== void 0) ctx.messagesIn(resource, text)?.forEach((value, key) => byKey.has(key) || byKey.set(key, value));
489
+ }
490
+ return values;
491
+ }
492
+ function toHistory(changes, head) {
493
+ const latest = /* @__PURE__ */ new Map();
494
+ for (const [key, byLocale] of changes) {
495
+ const entries = /* @__PURE__ */ new Map();
496
+ latest.set(key, entries);
497
+ for (const [locale, [newest]] of byLocale) if (newest) {
498
+ const value = head.get(locale)?.get(key);
499
+ entries.set(locale, {
500
+ updatedAt: newest[0],
501
+ revision: newest[1],
502
+ author: newest[2],
503
+ value: value === void 0 ? newest[3] : value
504
+ });
505
+ }
506
+ }
507
+ return {
508
+ changes: latest,
509
+ valueAt: (key, locale, at) => changes.get(key)?.get(locale)?.find((change) => change[0] <= at)?.[3]
510
+ };
511
+ }
512
+ function fromWalked(walked) {
513
+ return new Map(walked.changes.map(([key, byLocale]) => [key, new Map(byLocale)]));
514
+ }
515
+ function toWalked(head, changes) {
516
+ return {
517
+ head,
518
+ changes: [...changes].map(([key, byLocale]) => [key, [...byLocale]])
519
+ };
520
+ }
521
+ /** Fresh changes first, so lists stay newest first. */
522
+ function mergeChanges(fresh, older) {
523
+ for (const [key, byLocale] of older) {
524
+ const target = fresh.get(key) ?? /* @__PURE__ */ new Map();
525
+ fresh.set(key, target);
526
+ for (const [locale, list] of byLocale) target.set(locale, [...target.get(locale) ?? [], ...list]);
527
+ }
528
+ return fresh;
529
+ }
530
+ function isNotInstalled(error) {
531
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
532
+ }
533
+ /** Reads when each message last changed from git: parsed messages per commit, so formatting, sorting and moves do not count. */
534
+ function gitHistory() {
535
+ const warned = /* @__PURE__ */ new Set();
536
+ const warnOnce = (ctx, message) => {
537
+ if (!warned.has(message)) {
538
+ warned.add(message);
539
+ ctx.host.logger?.warn(message);
540
+ }
541
+ };
542
+ async function load(ctx) {
543
+ const options = ctx.config.history === false ? false : ctx.config.history.git;
544
+ if (options === false) return void 0;
545
+ const run = (args) => git(ctx.host.root, args);
546
+ let top;
547
+ try {
548
+ top = (await run(["rev-parse", "--show-toplevel"])).trim();
549
+ } catch (error) {
550
+ if (isNotInstalled(error)) warnOnce(ctx, "git is not installed, so stale translations are not reported.");
551
+ return;
552
+ }
553
+ if ((await run(["rev-parse", "--is-shallow-repository"])).trim() === "true") {
554
+ warnOnce(ctx, "The git clone is shallow, so stale translations are not reported. Fetch the full history (`fetch-depth: 0` in GitHub Actions).");
555
+ return;
556
+ }
557
+ const head = (await run([
558
+ "rev-parse",
559
+ "--verify",
560
+ "--quiet",
561
+ "HEAD"
562
+ ]).catch(() => "")).trim();
563
+ const prefix = (await run(["rev-parse", "--show-prefix"])).trim();
564
+ const resources = new Map(ctx.resources.filter((resource) => !resource.block).map((resource) => [`${prefix}${resource.path}`, resource]));
565
+ if (!head || resources.size === 0) return void 0;
566
+ const window = [...options?.maxDepth === void 0 ? [] : [`--max-count=${options.maxDepth}`], ...options?.since === void 0 ? [] : [`--since=${options.since instanceof Date ? options.since.toISOString() : options.since}`]];
567
+ const cache = window.length === 0 ? ctx.host.cache : void 0;
568
+ const signature = [
569
+ 1,
570
+ [...resources].map(([path, r]) => [
571
+ path,
572
+ r.locale,
573
+ r.namespace,
574
+ r.format
575
+ ]).sort(),
576
+ ctx.config.namespace,
577
+ ctx.config.keyStyle
578
+ ];
579
+ const cacheKey = `git-history:${hashText(JSON.stringify(signature))}`;
580
+ const cachedText = await cache?.get(cacheKey);
581
+ const cached = cachedText === void 0 ? void 0 : JSON.parse(cachedText);
582
+ let changes;
583
+ if (cached?.head === head) changes = fromWalked(cached);
584
+ else {
585
+ const base = cached && await run([
586
+ "merge-base",
587
+ "--is-ancestor",
588
+ cached.head,
589
+ head
590
+ ]).then(() => cached, () => void 0);
591
+ const fresh = await walk({
592
+ ctx,
593
+ top,
594
+ resources,
595
+ head,
596
+ base: base?.head,
597
+ window
598
+ });
599
+ changes = base ? mergeChanges(fresh, fromWalked(base)) : fresh;
600
+ await cache?.set(cacheKey, JSON.stringify(toWalked(head, changes)));
601
+ }
602
+ return toHistory(changes, await headValues(ctx, top, resources));
603
+ }
604
+ function watch$1(ctx, onChange) {
605
+ let watcher;
606
+ let timer;
607
+ let disposed = false;
608
+ const run = (args) => git(ctx.host.root, args);
609
+ Promise.all([run(["rev-parse", "--absolute-git-dir"]), run([
610
+ "rev-parse",
611
+ "--path-format=absolute",
612
+ "--git-common-dir"
613
+ ])]).then(([dir, common]) => {
614
+ if (disposed) return;
615
+ watcher = watch([
616
+ join(dir.trim(), "HEAD"),
617
+ join(common.trim(), "refs/heads"),
618
+ join(common.trim(), "packed-refs")
619
+ ], { ignoreInitial: true });
620
+ watcher.on("all", () => {
621
+ clearTimeout(timer);
622
+ timer = setTimeout(onChange, 100);
623
+ });
624
+ }, () => void 0);
625
+ return { dispose() {
626
+ disposed = true;
627
+ clearTimeout(timer);
628
+ watcher?.close();
629
+ } };
630
+ }
631
+ return {
632
+ name: "x18n:git-history",
633
+ runtime: "node",
634
+ histories: [{
635
+ id: "git",
636
+ load,
637
+ watch: watch$1
638
+ }]
639
+ };
640
+ }
641
+ //#endregion
287
642
  //#region src/migrate.ts
288
643
  const frameworkPresets = {
289
644
  "vue": "vue-i18n",
@@ -799,8 +1154,12 @@ async function projectSpecs(rootConfigFile, cwd, upward) {
799
1154
  async function loadWorkspace(options) {
800
1155
  const configFile = options.configFile ? resolve(options.cwd, options.configFile) : options.upward === false ? findConfigFile(resolve(options.cwd)) : findConfigUpward(options.cwd);
801
1156
  const { root, specs } = await projectSpecs(configFile, options.cwd, options.upward !== false);
802
- const builtins = options.builtins ?? createBuiltins();
803
1157
  const gitRoot = findGitRoot(root);
1158
+ const defaults = options.builtins ?? createBuiltins();
1159
+ const builtins = gitRoot ? {
1160
+ ...defaults,
1161
+ plugins: [...defaults.plugins, gitHistory()]
1162
+ } : defaults;
804
1163
  const loaded = [];
805
1164
  for (const spec of specs) {
806
1165
  const name = spec.config.name ?? (spec.root === root ? relative(dirname(root), root) : relative(root, spec.root)).replace(/\\/g, "/");
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@x18n/node",
3
3
  "type": "module",
4
- "version": "0.0.3",
4
+ "version": "0.0.6",
5
5
  "description": "Node.js host for x18n: file system access, config and plugin loading",
6
6
  "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
7
  "license": "MIT",
8
- "homepage": "https://github.com/antfu/x18n#readme",
8
+ "homepage": "https://github.com/x18n-dev/x18n#readme",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/antfu/x18n.git",
11
+ "url": "git+https://github.com/x18n-dev/x18n.git",
12
12
  "directory": "packages/node"
13
13
  },
14
- "bugs": "https://github.com/antfu/x18n/issues",
14
+ "bugs": "https://github.com/x18n-dev/x18n/issues",
15
15
  "sideEffects": false,
16
16
  "exports": {
17
17
  ".": "./dist/index.mjs",
@@ -25,11 +25,11 @@
25
25
  "node": ">=22"
26
26
  },
27
27
  "dependencies": {
28
- "@x18n/builtins": "0.0.3",
29
- "@x18n/core": "0.0.3",
30
- "chokidar": "^5.0.0",
31
- "ignore": "^7.0.12",
32
- "jiti": "^2.6.1",
28
+ "@x18n/builtins": "0.0.6",
29
+ "@x18n/core": "0.0.6",
30
+ "chokidar": "^6.0.0",
31
+ "ignore": "^7.0.13",
32
+ "jiti": "^2.7.0",
33
33
  "jsonc-parser": "^3.3.1",
34
34
  "tinyglobby": "^0.2.17",
35
35
  "yaml": "^2.9.1"