@the-i18n-kit/cli 4.10.0 → 5.0.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 +29 -296
- package/dist/{_shared-Dn15c_g9.js → _shared-DFyy3sh8.js} +24 -12
- package/dist/_shared-DFyy3sh8.js.map +1 -0
- package/dist/{add-Dj6hsdl9.js → add-C-qs8iBa.js} +5 -5
- package/dist/{add-Dj6hsdl9.js.map → add-C-qs8iBa.js.map} +1 -1
- package/dist/bin.js +1 -1
- package/dist/{check-BI-CGxpY.js → check-BI1orjXb.js} +11 -19
- package/dist/check-BI1orjXb.js.map +1 -0
- package/dist/cli-DsFuVJNP.js +74 -0
- package/dist/cli-DsFuVJNP.js.map +1 -0
- package/dist/config/framework/stubs/{next-intl-routing-Se7Y8cCb.d.ts → next-intl-routing-Cwaa3f4R.d.ts} +1 -1
- package/dist/config/framework/stubs/{next-intl-routing-Se7Y8cCb.d.ts.map → next-intl-routing-Cwaa3f4R.d.ts.map} +1 -1
- package/dist/config/framework/stubs/{unplugin-vue-i18n-DrR5pbEW.d.ts → unplugin-vue-i18n-1ud7-Ly8.d.ts} +1 -1
- package/dist/config/framework/stubs/{unplugin-vue-i18n-DrR5pbEW.d.ts.map → unplugin-vue-i18n-1ud7-Ly8.d.ts.map} +1 -1
- package/dist/define-config-4ubtfvUy.js.map +1 -1
- package/dist/{define-config-DpbZ4BZ6.d.ts → define-config-BaZ1dZph.d.ts} +11 -4
- package/dist/define-config-BaZ1dZph.d.ts.map +1 -0
- package/dist/{define-config-BSsvRnWR.d.ts → define-config-CLgKN-7N.d.ts} +1 -1
- package/dist/define-config.d.ts +1 -1
- package/dist/{detect-BmvyRD1a.js → detect-7cVfsNJP.js} +5 -5
- package/dist/{detect-BmvyRD1a.js.map → detect-7cVfsNJP.js.map} +1 -1
- package/dist/{empty-Ww6FnmgY.js → empty-DB8Hl_8u.js} +5 -5
- package/dist/{empty-Ww6FnmgY.js.map → empty-DB8Hl_8u.js.map} +1 -1
- package/dist/find-duplicates-CmEqBfby.js +42 -0
- package/dist/find-duplicates-CmEqBfby.js.map +1 -0
- package/dist/{get-oF_xIbVR.js → get-BE21Iy6N.js} +5 -5
- package/dist/{get-oF_xIbVR.js.map → get-BE21Iy6N.js.map} +1 -1
- package/dist/{index-JeU02vfH.d.ts → index-DCG3dOdC.d.ts} +332 -3
- package/dist/index-DCG3dOdC.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -4
- package/dist/{init-D4B1_DXb.js → init-BtzmhsOM.js} +5 -5
- package/dist/{init-D4B1_DXb.js.map → init-BtzmhsOM.js.map} +1 -1
- package/dist/{list-dirs-CgXh-Lg1.js → list-dirs-DUQbwvlE.js} +5 -5
- package/dist/{list-dirs-CgXh-Lg1.js.map → list-dirs-DUQbwvlE.js.map} +1 -1
- package/dist/{missing-BZT6PgW5.js → missing-CcYrgd54.js} +5 -5
- package/dist/{missing-BZT6PgW5.js.map → missing-CcYrgd54.js.map} +1 -1
- package/dist/move-B2px6Ck-.js +50 -0
- package/dist/move-B2px6Ck-.js.map +1 -0
- package/dist/{operations-BCJd7in3.js → operations-CTo44gPu.js} +1332 -241
- package/dist/operations-CTo44gPu.js.map +1 -0
- package/dist/php-reader-3Fgw80zK.js +64 -0
- package/dist/php-reader-3Fgw80zK.js.map +1 -0
- package/dist/php-reader-CpnaPSpZ.js +2 -0
- package/dist/{providers-BAwPjeCy.js → providers-CHE2ffi6.js} +53 -27
- package/dist/providers-CHE2ffi6.js.map +1 -0
- package/dist/{remove-B7m-hi2O.js → remove-DTbqM4bR.js} +5 -5
- package/dist/{remove-B7m-hi2O.js.map → remove-DTbqM4bR.js.map} +1 -1
- package/dist/{remove-orphans-DOTWE7Ji.js → remove-orphans-Dfzu7hRz.js} +5 -5
- package/dist/{remove-orphans-DOTWE7Ji.js.map → remove-orphans-Dfzu7hRz.js.map} +1 -1
- package/dist/{rename-DEJWj5_P.js → rename-C_rFKkU4.js} +5 -5
- package/dist/{rename-DEJWj5_P.js.map → rename-C_rFKkU4.js.map} +1 -1
- package/dist/{scaffold-BH_OX-5z.js → scaffold-CUimlNqR.js} +5 -5
- package/dist/{scaffold-BH_OX-5z.js.map → scaffold-CUimlNqR.js.map} +1 -1
- package/dist/{scan-LeU4xG0G.js → scan-BkOIYhjZ.js} +5 -5
- package/dist/{scan-LeU4xG0G.js.map → scan-BkOIYhjZ.js.map} +1 -1
- package/dist/{search-CHdDywqc.js → search-0io1BcsY.js} +5 -5
- package/dist/{search-CHdDywqc.js.map → search-0io1BcsY.js.map} +1 -1
- package/dist/{status-CwI94d_R.js → status-Bp-SzjTN.js} +5 -5
- package/dist/{status-CwI94d_R.js.map → status-Bp-SzjTN.js.map} +1 -1
- package/dist/{translate-Cf3npNpr.js → translate-cCb2Z57w.js} +6 -6
- package/dist/{translate-Cf3npNpr.js.map → translate-cCb2Z57w.js.map} +1 -1
- package/dist/{translate-key-Bg_8B6c_.js → translate-key-DHLWEmrA.js} +5 -5
- package/dist/{translate-key-Bg_8B6c_.js.map → translate-key-DHLWEmrA.js.map} +1 -1
- package/dist/{update-DNv-dU1U.js → update-DV4ijRVB.js} +5 -5
- package/dist/{update-DNv-dU1U.js.map → update-DV4ijRVB.js.map} +1 -1
- package/dist/{write-DUwMSc75.js → write-Czzm_Nb0.js} +5 -5
- package/dist/{write-DUwMSc75.js.map → write-Czzm_Nb0.js.map} +1 -1
- package/package.json +15 -5
- package/dist/_shared-Dn15c_g9.js.map +0 -1
- package/dist/check-BI-CGxpY.js.map +0 -1
- package/dist/cli-CDhNbaUM.js +0 -82
- package/dist/cli-CDhNbaUM.js.map +0 -1
- package/dist/define-config-DpbZ4BZ6.d.ts.map +0 -1
- package/dist/find-duplicates-BfBBqP7I.js +0 -31
- package/dist/find-duplicates-BfBBqP7I.js.map +0 -1
- package/dist/index-JeU02vfH.d.ts.map +0 -1
- package/dist/operations-BCJd7in3.js.map +0 -1
- package/dist/php-reader-DcgOAhTw.js +0 -2
- package/dist/php-reader-DuZ0Hyl_.js +0 -32
- package/dist/php-reader-DuZ0Hyl_.js.map +0 -1
- package/dist/providers-BAwPjeCy.js.map +0 -1
- /package/dist/{bin-CF8fSc-R.d.ts → bin-DrKRgnr9.d.ts} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { i as toErrorMessage, n as FileIOError, r as ToolError, t as ConfigError } from "./errors-coI1dhw1.js";
|
|
2
|
-
import { c as validateProjectConfig, l as log, n as TranslateProviderError, o as CONFIG_FILENAME, s as loadProjectConfig } from "./providers-
|
|
3
|
-
import { n as readPhpLocaleFile, t as clearPhpFileCacheEntry } from "./php-reader-
|
|
2
|
+
import { c as validateProjectConfig, l as log, n as TranslateProviderError, o as CONFIG_FILENAME, s as loadProjectConfig } from "./providers-CHE2ffi6.js";
|
|
3
|
+
import { n as readPhpLocaleFile, t as clearPhpFileCacheEntry } from "./php-reader-3Fgw80zK.js";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
@@ -539,13 +539,6 @@ const layerSchema = z.object({
|
|
|
539
539
|
rootDir: z.string().min(1),
|
|
540
540
|
localeDir: z.string().min(1).optional()
|
|
541
541
|
});
|
|
542
|
-
/**
|
|
543
|
-
* Authoring policy declared in nuxt.config.ts. Deliberately permissive: the
|
|
544
|
-
* shape is owned by ProjectConfig's own schema, which validates it once the two
|
|
545
|
-
* sources are merged. Re-stating it here would be a second definition to keep
|
|
546
|
-
* in step.
|
|
547
|
-
*/
|
|
548
|
-
const policySchema = z.record(z.string(), z.unknown());
|
|
549
542
|
const artifactSchema = z.object({
|
|
550
543
|
version: z.literal(1),
|
|
551
544
|
generator: z.string(),
|
|
@@ -559,8 +552,7 @@ const artifactSchema = z.object({
|
|
|
559
552
|
]),
|
|
560
553
|
localeFileFormat: z.literal("json"),
|
|
561
554
|
locales: z.array(localeSchema).min(1),
|
|
562
|
-
layers: z.array(layerSchema).min(1)
|
|
563
|
-
policy: policySchema.optional()
|
|
555
|
+
layers: z.array(layerSchema).min(1)
|
|
564
556
|
});
|
|
565
557
|
/**
|
|
566
558
|
* Read an app's artifact, or null when there isn't a usable one.
|
|
@@ -633,7 +625,6 @@ function staleAgainst(artifact, artifactPath) {
|
|
|
633
625
|
* fallback path producing the same names for the same project.
|
|
634
626
|
*/
|
|
635
627
|
async function artifactToConfig(artifact, appDir, discoveryRoot, projectConfig) {
|
|
636
|
-
const merged = mergePolicy(artifact.policy, projectConfig);
|
|
637
628
|
const localeDirs = [];
|
|
638
629
|
const claimed = /* @__PURE__ */ new Map();
|
|
639
630
|
const usedLayerNames = /* @__PURE__ */ new Set();
|
|
@@ -666,31 +657,13 @@ async function artifactToConfig(artifact, appDir, discoveryRoot, projectConfig)
|
|
|
666
657
|
rootDir: appDir,
|
|
667
658
|
defaultLocale: artifact.defaultLocale,
|
|
668
659
|
fallbackLocale: normalizeFallbackLocale(artifact.fallbackLocale, artifact.defaultLocale),
|
|
669
|
-
locales: applyLocaleOverride(locales,
|
|
660
|
+
locales: applyLocaleOverride(locales, projectConfig?.locales),
|
|
670
661
|
localeDirs,
|
|
671
662
|
layerRootDirs,
|
|
672
|
-
projectConfig:
|
|
663
|
+
projectConfig: projectConfig ?? void 0,
|
|
673
664
|
apps: [app]
|
|
674
665
|
};
|
|
675
666
|
}
|
|
676
|
-
/**
|
|
677
|
-
* Fold the policy declared in nuxt.config.ts into the policy read from
|
|
678
|
-
* .i18n-mcp.json.
|
|
679
|
-
*
|
|
680
|
-
* The file wins on a collision, which should never happen: the module fails the
|
|
681
|
-
* build when a key is declared in both. This is the belt to that braces — an
|
|
682
|
-
* artifact generated before the check existed, or by a newer module, cannot
|
|
683
|
-
* quietly override a value someone can see in their repository.
|
|
684
|
-
*/
|
|
685
|
-
function mergePolicy(policy, projectConfig) {
|
|
686
|
-
if (!policy || Object.keys(policy).length === 0) return projectConfig ?? void 0;
|
|
687
|
-
const declared = Object.keys(policy).filter((key) => projectConfig?.[key] !== void 0);
|
|
688
|
-
if (declared.length > 0) log.warn(`Ignoring ${declared.join(", ")} from the Nuxt artifact: also declared in .i18n-mcp.json, which wins.`);
|
|
689
|
-
return {
|
|
690
|
-
...policy,
|
|
691
|
-
...projectConfig
|
|
692
|
-
};
|
|
693
|
-
}
|
|
694
667
|
//#endregion
|
|
695
668
|
//#region src/adapters/nuxt/merge-apps.ts
|
|
696
669
|
/**
|
|
@@ -711,13 +684,6 @@ var AppMerger = class {
|
|
|
711
684
|
apps = [];
|
|
712
685
|
defaultLocale = "en";
|
|
713
686
|
fallbackLocale = { default: ["en"] };
|
|
714
|
-
/**
|
|
715
|
-
* Authoring policy contributed by the apps themselves — each app's artifact
|
|
716
|
-
* may carry what its nuxt.config declared. Accumulated first-wins, then
|
|
717
|
-
* overlaid by .i18n-mcp.json, which is the source a reader can see without
|
|
718
|
-
* building.
|
|
719
|
-
*/
|
|
720
|
-
policy = {};
|
|
721
687
|
claimedPaths = /* @__PURE__ */ new Map();
|
|
722
688
|
seenLocaleCodes = /* @__PURE__ */ new Set();
|
|
723
689
|
usedLayerNames = /* @__PURE__ */ new Set();
|
|
@@ -726,17 +692,12 @@ var AppMerger = class {
|
|
|
726
692
|
this.defaultLocale = appConfig.defaultLocale;
|
|
727
693
|
this.fallbackLocale = appConfig.fallbackLocale;
|
|
728
694
|
}
|
|
729
|
-
this.collectPolicy(appConfig);
|
|
730
695
|
const renamedLayers = await this.claimDirs(appConfig, discoveryRoot);
|
|
731
696
|
this.collectLocales(appConfig);
|
|
732
697
|
this.collectLayerRoots(appConfig);
|
|
733
698
|
this.collectApps(appConfig, renamedLayers);
|
|
734
699
|
}
|
|
735
700
|
toConfig(discoveryRoot, projectConfig, locales) {
|
|
736
|
-
const merged = Object.keys(this.policy).length > 0 ? {
|
|
737
|
-
...this.policy,
|
|
738
|
-
...projectConfig
|
|
739
|
-
} : projectConfig;
|
|
740
701
|
return {
|
|
741
702
|
rootDir: discoveryRoot,
|
|
742
703
|
defaultLocale: this.defaultLocale,
|
|
@@ -744,7 +705,7 @@ var AppMerger = class {
|
|
|
744
705
|
locales,
|
|
745
706
|
localeDirs: this.localeDirs,
|
|
746
707
|
layerRootDirs: this.layerRootDirs,
|
|
747
|
-
projectConfig
|
|
708
|
+
projectConfig,
|
|
748
709
|
apps: this.apps
|
|
749
710
|
};
|
|
750
711
|
}
|
|
@@ -775,9 +736,6 @@ var AppMerger = class {
|
|
|
775
736
|
if (!this.usedLayerNames.has(dir.layer)) return dir.layer;
|
|
776
737
|
return deriveLayerName(dir.layerRootDir, discoveryRoot, this.usedLayerNames);
|
|
777
738
|
}
|
|
778
|
-
collectPolicy(appConfig) {
|
|
779
|
-
for (const [key, value] of Object.entries(appConfig.projectConfig ?? {})) if (value !== void 0 && this.policy[key] === void 0) this.policy[key] = value;
|
|
780
|
-
}
|
|
781
739
|
collectLocales(appConfig) {
|
|
782
740
|
for (const locale of appConfig.locales) {
|
|
783
741
|
if (this.seenLocaleCodes.has(locale.code)) continue;
|
|
@@ -2103,6 +2061,17 @@ function buildLayerGraph(config) {
|
|
|
2103
2061
|
layersOfApp
|
|
2104
2062
|
};
|
|
2105
2063
|
}
|
|
2064
|
+
/** Flatten {@link buildLayerGraph}'s view of `config` into plain JSON. */
|
|
2065
|
+
function serializeLayerGraph(config) {
|
|
2066
|
+
const graph = buildLayerGraph(config);
|
|
2067
|
+
const canonical = graph.canonicalLayers.map((dir) => dir.layer);
|
|
2068
|
+
return {
|
|
2069
|
+
canonical,
|
|
2070
|
+
shared: graph.sharedLayers.map((dir) => dir.layer),
|
|
2071
|
+
aliases: Object.fromEntries((config.localeDirs ?? []).filter((dir) => dir.aliasOf).map((dir) => [dir.layer, graph.ownerOf(dir.layer)])),
|
|
2072
|
+
consumers: Object.fromEntries(canonical.map((layer) => [layer, graph.appsUsingLayer(layer)]))
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2106
2075
|
//#endregion
|
|
2107
2076
|
//#region src/io/read-cache.ts
|
|
2108
2077
|
/**
|
|
@@ -2521,7 +2490,7 @@ function detectPhpStyle(content) {
|
|
|
2521
2490
|
* are inserted in sorted position among their siblings.
|
|
2522
2491
|
*/
|
|
2523
2492
|
async function mutatePhpLocaleFile(filePath, mutate) {
|
|
2524
|
-
const { readPhpLocaleFile } = await import("./php-reader-
|
|
2493
|
+
const { readPhpLocaleFile } = await import("./php-reader-CpnaPSpZ.js");
|
|
2525
2494
|
const data = await readPhpLocaleFile(filePath);
|
|
2526
2495
|
const { quoteStyle, indent } = detectPhpStyle(await readFile(filePath, "utf-8"));
|
|
2527
2496
|
const reference = structuredClone(data);
|
|
@@ -4016,34 +3985,565 @@ function propagateCounts(node) {
|
|
|
4016
3985
|
node.keyCount = total;
|
|
4017
3986
|
return total;
|
|
4018
3987
|
}
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
3988
|
+
//#endregion
|
|
3989
|
+
//#region src/scanner/frontends/oxc.ts
|
|
3990
|
+
/**
|
|
3991
|
+
* JavaScript, TypeScript and Vue SFCs, read as syntax rather than matched as
|
|
3992
|
+
* text (#332).
|
|
3993
|
+
*
|
|
3994
|
+
* What this buys is one thing: it can follow `t` back to where it came from.
|
|
3995
|
+
* A regex sees the name and has to guess from the argument's shape whether the
|
|
3996
|
+
* call is a translation — the guess that offered a live key for deletion in
|
|
3997
|
+
* #298. Here, a `t` destructured from `useI18n()` is known to be i18n, and a
|
|
3998
|
+
* `t` that is anything else is known not to be.
|
|
3999
|
+
*/
|
|
4000
|
+
const JS_EXTENSIONS = new Set([
|
|
4001
|
+
".js",
|
|
4002
|
+
".jsx",
|
|
4003
|
+
".mjs",
|
|
4004
|
+
".cjs",
|
|
4005
|
+
".mts",
|
|
4006
|
+
".cts",
|
|
4007
|
+
".ts",
|
|
4008
|
+
".tsx"
|
|
4009
|
+
]);
|
|
4010
|
+
/** Packages whose exports are translation functions. */
|
|
4011
|
+
const I18N_MODULES = new Set([
|
|
4012
|
+
"vue-i18n",
|
|
4013
|
+
"@nuxtjs/i18n",
|
|
4014
|
+
"next-intl",
|
|
4015
|
+
"react-i18next",
|
|
4016
|
+
"i18next",
|
|
4017
|
+
"petite-vue-i18n"
|
|
4018
|
+
]);
|
|
4019
|
+
/** Composables whose destructured `t` is a translation function. */
|
|
4020
|
+
const I18N_FACTORIES = new Set([
|
|
4021
|
+
"useI18n",
|
|
4022
|
+
"useTranslation",
|
|
4023
|
+
"useTranslations",
|
|
4024
|
+
"getTranslations"
|
|
4025
|
+
]);
|
|
4026
|
+
/**
|
|
4027
|
+
* Callees that are unambiguous wherever they appear: a Vue template's `$t`, or
|
|
4028
|
+
* `this.$t` in an options-API component. Nothing else is named that.
|
|
4029
|
+
*/
|
|
4030
|
+
const ALWAYS_I18N = new Set([
|
|
4031
|
+
"$t",
|
|
4032
|
+
"$te",
|
|
4033
|
+
"$tc"
|
|
4034
|
+
]);
|
|
4035
|
+
/**
|
|
4036
|
+
* Names that might be a translation function without proving it — the same set
|
|
4037
|
+
* the patterns match. Without this the frontend reports every call in the file,
|
|
4038
|
+
* and `axios.get('/api/v1')` or `require('node:fs')` become translation keys
|
|
4039
|
+
* because their argument happens to contain a dot.
|
|
4040
|
+
*
|
|
4041
|
+
* A call whose callee resolves to an i18n import is reported whatever it is
|
|
4042
|
+
* named, which is the point: the list bounds guesswork, not knowledge.
|
|
4043
|
+
*/
|
|
4044
|
+
const MAYBE_I18N = new Set([
|
|
4045
|
+
"t",
|
|
4046
|
+
"te",
|
|
4047
|
+
"tc",
|
|
4048
|
+
"$t",
|
|
4049
|
+
"$te",
|
|
4050
|
+
"$tc"
|
|
4051
|
+
]);
|
|
4052
|
+
function createOxcFrontend() {
|
|
4053
|
+
return {
|
|
4054
|
+
name: "oxc",
|
|
4055
|
+
handles(filePath) {
|
|
4056
|
+
return JS_EXTENSIONS.has(extname(filePath)) || filePath.endsWith(".vue");
|
|
4057
|
+
},
|
|
4058
|
+
async read(content, filePath) {
|
|
4059
|
+
const parse = await loadParser();
|
|
4060
|
+
if (!parse) return null;
|
|
4061
|
+
const blocks = readableBlocks(content, filePath);
|
|
4062
|
+
if (!blocks) return null;
|
|
4063
|
+
const parsed = [];
|
|
4064
|
+
for (const block of blocks) {
|
|
4065
|
+
const ast = parseBlock(parse, block.source, filePath);
|
|
4066
|
+
if (!ast) return null;
|
|
4067
|
+
parsed.push({
|
|
4068
|
+
block,
|
|
4069
|
+
ast
|
|
4070
|
+
});
|
|
4071
|
+
}
|
|
4072
|
+
return collectAcrossBlocks(parsed);
|
|
4073
|
+
}
|
|
4074
|
+
};
|
|
4075
|
+
}
|
|
4076
|
+
/**
|
|
4077
|
+
* The parseable blocks of a file, or null to decline it. A .vue file with
|
|
4078
|
+
* neither a template nor a script tag is not an SFC: the block splitter would
|
|
4079
|
+
* read only the fragments it recognises and silently drop everything between
|
|
4080
|
+
* them — declining hands the whole file to the fallback instead. The same
|
|
4081
|
+
* goes for an SFC yielding no blocks at all.
|
|
4082
|
+
*/
|
|
4083
|
+
function readableBlocks(content, filePath) {
|
|
4084
|
+
if (!filePath.endsWith(".vue")) return [{
|
|
4085
|
+
source: content,
|
|
4086
|
+
lineOffset: 0
|
|
4087
|
+
}];
|
|
4088
|
+
if (!/<template[\s>]|<script[\s>]/.test(content)) return null;
|
|
4089
|
+
const blocks = vueBlocks(content);
|
|
4090
|
+
return blocks.length === 0 ? null : blocks;
|
|
4091
|
+
}
|
|
4092
|
+
/**
|
|
4093
|
+
* An SFC is one scope split across blocks: a template uses what the script
|
|
4094
|
+
* declared. Collecting per block would leave `t(`${base}.title`)` in the
|
|
4095
|
+
* template unresolvable, and it is the same file.
|
|
4096
|
+
*/
|
|
4097
|
+
function collectAcrossBlocks(parsed) {
|
|
4098
|
+
const i18nNames = new Set(parsed.flatMap((p) => [...p.ast.i18nNames]));
|
|
4099
|
+
const constants = /* @__PURE__ */ new Map();
|
|
4100
|
+
for (const { ast } of parsed) for (const [name, value] of ast.constants) addConstant(constants, name, value);
|
|
4101
|
+
const sites = [];
|
|
4102
|
+
for (const { block, ast } of parsed) collect({
|
|
4103
|
+
...ast,
|
|
4104
|
+
i18nNames,
|
|
4105
|
+
constants
|
|
4106
|
+
}, sites, lineResolver(block.source, block.lineOffset));
|
|
4107
|
+
return sites;
|
|
4108
|
+
}
|
|
4109
|
+
/**
|
|
4110
|
+
* Loaded on first use rather than at import, so a command that never scans does
|
|
4111
|
+
* not pay for a native binary it will not touch. Cached including the failure,
|
|
4112
|
+
* so a broken install is reported once and then falls back quietly.
|
|
4113
|
+
*/
|
|
4114
|
+
let parserPromise;
|
|
4115
|
+
function loadParser() {
|
|
4116
|
+
parserPromise ??= import("oxc-parser").then((m) => m.parseSync).catch((error) => {
|
|
4117
|
+
log.warn(`oxc-parser could not be loaded (${error instanceof Error ? error.message : String(error)}) — falling back to pattern matching for JavaScript and TypeScript.`);
|
|
4118
|
+
return null;
|
|
4119
|
+
});
|
|
4120
|
+
return parserPromise;
|
|
4121
|
+
}
|
|
4122
|
+
function parseBlock(parseSync, source, filePath) {
|
|
4123
|
+
const result = parseSync(filePath.endsWith(".vue") ? "block.ts" : filePath, source);
|
|
4124
|
+
if (result.errors.length > 0) {
|
|
4125
|
+
log.debug(`oxc declined ${filePath}: ${result.errors[0]?.message ?? "parse error"}`);
|
|
4126
|
+
return null;
|
|
4127
|
+
}
|
|
4128
|
+
return {
|
|
4129
|
+
program: result.program,
|
|
4130
|
+
source,
|
|
4131
|
+
i18nNames: collectI18nNames(result.program),
|
|
4132
|
+
constants: collectStringConstants(result.program)
|
|
4133
|
+
};
|
|
4134
|
+
}
|
|
4135
|
+
/**
|
|
4136
|
+
* The names bound to a translation function in this file — imports from an
|
|
4137
|
+
* i18n package, and destructures of the composables those packages expose.
|
|
4138
|
+
*/
|
|
4139
|
+
function collectI18nNames(program) {
|
|
4140
|
+
const names = /* @__PURE__ */ new Set();
|
|
4141
|
+
const factories = /* @__PURE__ */ new Set();
|
|
4142
|
+
walk$1(program, (node) => {
|
|
4143
|
+
if (node.type !== "ImportDeclaration") return;
|
|
4144
|
+
if (typeof node.source?.value !== "string" || !I18N_MODULES.has(node.source.value)) return;
|
|
4145
|
+
readImportSpecifiers(node, names, factories);
|
|
4146
|
+
});
|
|
4147
|
+
walk$1(program, (node) => {
|
|
4148
|
+
if (node.type !== "VariableDeclarator") return;
|
|
4149
|
+
if (!isI18nFactoryCall(node.init, factories)) return;
|
|
4150
|
+
readBoundNames(node.id, names);
|
|
4151
|
+
});
|
|
4152
|
+
return names;
|
|
4153
|
+
}
|
|
4154
|
+
function isI18nFactoryCall(init, factories) {
|
|
4155
|
+
if (init?.type !== "CallExpression") return false;
|
|
4156
|
+
const callee = calleeName$1(init.callee);
|
|
4157
|
+
return callee !== void 0 && (factories.has(callee) || I18N_FACTORIES.has(callee));
|
|
4158
|
+
}
|
|
4159
|
+
/** The names a destructure or a plain assignment binds. */
|
|
4160
|
+
function readBoundNames(id, names) {
|
|
4161
|
+
if (!id) return;
|
|
4162
|
+
if (id.type === "Identifier") {
|
|
4163
|
+
names.add(id.name);
|
|
4164
|
+
return;
|
|
4165
|
+
}
|
|
4166
|
+
if (id.type !== "ObjectPattern") return;
|
|
4167
|
+
for (const prop of id.properties ?? []) {
|
|
4168
|
+
const local = propertyName(prop);
|
|
4169
|
+
if (local) names.add(local);
|
|
4170
|
+
}
|
|
4171
|
+
}
|
|
4172
|
+
/** `{ t }` binds `t`; `{ t: translate }` binds `translate`. */
|
|
4173
|
+
function propertyName(prop) {
|
|
4174
|
+
return prop.value?.name ?? prop.key?.name;
|
|
4175
|
+
}
|
|
4176
|
+
/**
|
|
4177
|
+
* String constants, so a key assembled from one resolves to the key it names.
|
|
4178
|
+
*
|
|
4179
|
+
* const base = 'pages.settings'
|
|
4180
|
+
* t(`${base}.title`) → pages.settings.title
|
|
4181
|
+
*
|
|
4182
|
+
* The regex path approximates this with a table of textual substitutions. Here
|
|
4183
|
+
* it is the binding itself, which is the difference between resolving a name
|
|
4184
|
+
* and hoping no other name looks like it.
|
|
4185
|
+
*/
|
|
4186
|
+
function collectStringConstants(program) {
|
|
4187
|
+
const constants = /* @__PURE__ */ new Map();
|
|
4188
|
+
walk$1(program, (node) => {
|
|
4189
|
+
if (node.type !== "VariableDeclarator") return;
|
|
4190
|
+
if (node.id?.type !== "Identifier") return;
|
|
4191
|
+
if (node.init?.type !== "Literal" || typeof node.init.value !== "string") return;
|
|
4192
|
+
addConstant(constants, node.id.name, node.init.value);
|
|
4193
|
+
});
|
|
4194
|
+
return constants;
|
|
4195
|
+
}
|
|
4196
|
+
function addConstant(table, name, value) {
|
|
4197
|
+
const existing = table.get(name);
|
|
4198
|
+
if (existing === void 0) table.set(name, value);
|
|
4199
|
+
else if (existing !== value) table.set(name, null);
|
|
4200
|
+
}
|
|
4201
|
+
function readImportSpecifiers(node, names, factories) {
|
|
4202
|
+
for (const spec of node.specifiers ?? []) {
|
|
4203
|
+
const local = spec.local?.name;
|
|
4204
|
+
if (!local) continue;
|
|
4205
|
+
const imported = spec.imported?.name ?? local;
|
|
4206
|
+
if (I18N_FACTORIES.has(imported)) factories.add(local);
|
|
4207
|
+
else names.add(local);
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4210
|
+
/**
|
|
4211
|
+
* Offsets to line numbers. The parser reports positions as byte offsets, so
|
|
4212
|
+
* the line index is built once per block rather than counted per call site.
|
|
4213
|
+
*/
|
|
4214
|
+
function lineResolver(source, lineOffset) {
|
|
4215
|
+
const starts = [0];
|
|
4216
|
+
for (let i = 0; i < source.length; i++) if (source[i] === "\n") starts.push(i + 1);
|
|
4217
|
+
return (offset) => {
|
|
4218
|
+
let low = 0;
|
|
4219
|
+
let high = starts.length - 1;
|
|
4220
|
+
while (low < high) {
|
|
4221
|
+
const mid = Math.ceil((low + high) / 2);
|
|
4222
|
+
if ((starts[mid] ?? 0) <= offset) low = mid;
|
|
4223
|
+
else high = mid - 1;
|
|
4224
|
+
}
|
|
4225
|
+
return low + 1 + lineOffset;
|
|
4226
|
+
};
|
|
4227
|
+
}
|
|
4228
|
+
function collect(parsed, sites, lineAt) {
|
|
4229
|
+
walk$1(parsed.program, (node) => {
|
|
4230
|
+
if (node.type !== "CallExpression") return;
|
|
4231
|
+
const site = toCallSite(node, parsed, lineAt);
|
|
4232
|
+
if (site) sites.push(site);
|
|
4233
|
+
});
|
|
4234
|
+
}
|
|
4235
|
+
function toCallSite(node, parsed, lineAt) {
|
|
4236
|
+
const callee = resolveCallee(node.callee, parsed.i18nNames);
|
|
4237
|
+
if (!callee) return void 0;
|
|
4238
|
+
if (!callee.resolved && !MAYBE_I18N.has(callee.name)) return void 0;
|
|
4239
|
+
const [first] = node.arguments ?? [];
|
|
4240
|
+
if (!first) return void 0;
|
|
4241
|
+
return {
|
|
4242
|
+
callee: callee.name,
|
|
4243
|
+
binding: callee.resolved ? "resolved" : "ambiguous",
|
|
4244
|
+
argument: readArgument$1(first, parsed),
|
|
4245
|
+
line: lineAt(node.start ?? 0)
|
|
4246
|
+
};
|
|
4247
|
+
}
|
|
4248
|
+
/**
|
|
4249
|
+
* Name a callee and decide whether its binding proves it is i18n.
|
|
4250
|
+
*
|
|
4251
|
+
* An identifier resolves through what this file bound — an i18n import or a
|
|
4252
|
+
* destructure of `useI18n()`. A member call resolves only through its own
|
|
4253
|
+
* shape: `$t` is unambiguous on any receiver, and `t` proves i18n only when
|
|
4254
|
+
* the receiver itself is an i18n binding (`const i18n = useI18n(); i18n.t(…)`).
|
|
4255
|
+
* A local `t` from `useI18n()` says nothing about `client.t(…)` — matching a
|
|
4256
|
+
* member by its property name against local bindings would resolve exactly the
|
|
4257
|
+
* calls this frontend exists to tell apart.
|
|
4258
|
+
*/
|
|
4259
|
+
function resolveCallee(node, i18nNames) {
|
|
4260
|
+
if (!node) return void 0;
|
|
4261
|
+
if (node.type === "Identifier") return {
|
|
4262
|
+
name: node.name,
|
|
4263
|
+
resolved: i18nNames.has(node.name) || ALWAYS_I18N.has(node.name)
|
|
4264
|
+
};
|
|
4265
|
+
if (node.type === "MemberExpression") return resolveMemberCallee(node, i18nNames);
|
|
4266
|
+
}
|
|
4267
|
+
function resolveMemberCallee(node, i18nNames) {
|
|
4268
|
+
if (node.property?.type !== "Identifier") return void 0;
|
|
4269
|
+
const name = node.property.name;
|
|
4270
|
+
const receiverIsI18n = node.object?.type === "Identifier" && i18nNames.has(node.object.name);
|
|
4271
|
+
return {
|
|
4272
|
+
name,
|
|
4273
|
+
resolved: ALWAYS_I18N.has(name) || receiverIsI18n && MAYBE_I18N.has(name)
|
|
4274
|
+
};
|
|
4275
|
+
}
|
|
4276
|
+
function readArgument$1(node, parsed) {
|
|
4277
|
+
if (node.type === "Literal" && typeof node.value === "string") return {
|
|
4278
|
+
kind: "static",
|
|
4279
|
+
value: node.value
|
|
4280
|
+
};
|
|
4281
|
+
if (node.type === "TemplateLiteral") return readTemplateArgument(node, parsed);
|
|
4282
|
+
if (node.type === "BinaryExpression" && node.operator === "+" && typeof node.left?.value === "string") return {
|
|
4283
|
+
kind: "concat",
|
|
4284
|
+
prefix: node.left.value
|
|
4285
|
+
};
|
|
4286
|
+
return { kind: "unknown" };
|
|
4287
|
+
}
|
|
4288
|
+
function readTemplateArgument(node, parsed) {
|
|
4289
|
+
if ((node.expressions ?? []).length === 0) {
|
|
4290
|
+
const only = node.quasis?.[0]?.value?.cooked;
|
|
4291
|
+
return typeof only === "string" ? {
|
|
4292
|
+
kind: "static",
|
|
4293
|
+
value: only
|
|
4294
|
+
} : { kind: "unknown" };
|
|
4295
|
+
}
|
|
4296
|
+
const resolved = resolveTemplate(node, parsed.constants);
|
|
4297
|
+
if (resolved !== void 0) return {
|
|
4298
|
+
kind: "static",
|
|
4299
|
+
value: resolved
|
|
4300
|
+
};
|
|
4301
|
+
return {
|
|
4302
|
+
kind: "template",
|
|
4303
|
+
expression: typeof node.start === "number" && typeof node.end === "number" ? parsed.source.slice(node.start + 1, node.end - 1) : (node.quasis ?? []).map((q) => q.value?.cooked ?? "").join("${_}")
|
|
4304
|
+
};
|
|
4305
|
+
}
|
|
4306
|
+
/**
|
|
4307
|
+
* The literal a template resolves to, or undefined when any slot is something
|
|
4308
|
+
* other than a constant this file declares.
|
|
4309
|
+
*/
|
|
4310
|
+
function resolveTemplate(node, constants) {
|
|
4311
|
+
const parts = [];
|
|
4312
|
+
const quasis = node.quasis ?? [];
|
|
4313
|
+
const expressions = node.expressions ?? [];
|
|
4314
|
+
for (const [i, quasi] of quasis.entries()) {
|
|
4315
|
+
parts.push(quasi.value?.cooked ?? "");
|
|
4316
|
+
const expression = expressions[i];
|
|
4317
|
+
if (!expression) continue;
|
|
4318
|
+
if (expression.type !== "Identifier") return void 0;
|
|
4319
|
+
const value = constants.get(expression.name);
|
|
4320
|
+
if (typeof value !== "string") return void 0;
|
|
4321
|
+
parts.push(value);
|
|
4322
|
+
}
|
|
4323
|
+
return parts.join("");
|
|
4324
|
+
}
|
|
4325
|
+
function calleeName$1(node) {
|
|
4326
|
+
if (!node) return void 0;
|
|
4327
|
+
if (node.type === "Identifier") return node.name;
|
|
4328
|
+
if (node.type === "MemberExpression" && node.property?.type === "Identifier") return node.property.name;
|
|
4329
|
+
}
|
|
4330
|
+
function walk$1(node, visit) {
|
|
4331
|
+
if (!node || typeof node !== "object") return;
|
|
4332
|
+
if (typeof node.type === "string") visit(node);
|
|
4333
|
+
for (const [key, child] of Object.entries(node)) {
|
|
4334
|
+
if (SKIP_KEYS.has(key)) continue;
|
|
4335
|
+
walkChild$1(child, visit);
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
4338
|
+
/** Positional metadata, not syntax — walking it wastes time and finds nothing. */
|
|
4339
|
+
const SKIP_KEYS = new Set([
|
|
4340
|
+
"loc",
|
|
4341
|
+
"range",
|
|
4342
|
+
"parent"
|
|
4343
|
+
]);
|
|
4344
|
+
function walkChild$1(child, visit) {
|
|
4345
|
+
if (Array.isArray(child)) {
|
|
4346
|
+
for (const item of child) walkChild$1(item, visit);
|
|
4347
|
+
return;
|
|
4348
|
+
}
|
|
4349
|
+
if (child && typeof child === "object") walk$1(child, visit);
|
|
4350
|
+
}
|
|
4351
|
+
/**
|
|
4352
|
+
* Split an SFC into parseable blocks. A template block's expressions are not
|
|
4353
|
+
* JavaScript, so its interpolations are lifted out and parsed as expressions —
|
|
4354
|
+
* the same trick the PHP frontend uses for Blade directives.
|
|
4355
|
+
*/
|
|
4356
|
+
function vueBlocks(content) {
|
|
4357
|
+
return [...scriptBlocks(content), ...templateExpressionBlocks(content)];
|
|
4358
|
+
}
|
|
4359
|
+
const lineOffsetAt = (content, offset) => content.slice(0, offset).split("\n").length - 1;
|
|
4360
|
+
function scriptBlocks(content) {
|
|
4361
|
+
const blocks = [];
|
|
4362
|
+
for (const match of content.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)) {
|
|
4363
|
+
const openTagLength = match[0].length - (match[1]?.length ?? 0) - 9;
|
|
4364
|
+
blocks.push({
|
|
4365
|
+
source: match[1] ?? "",
|
|
4366
|
+
lineOffset: lineOffsetAt(content, (match.index ?? 0) + openTagLength)
|
|
4367
|
+
});
|
|
4368
|
+
}
|
|
4369
|
+
return blocks;
|
|
4370
|
+
}
|
|
4371
|
+
function templateExpressionBlocks(content) {
|
|
4372
|
+
const blocks = [];
|
|
4373
|
+
for (const match of content.matchAll(/\{\{([\s\S]*?)\}\}|(?:v-[a-z-]+|:[\w-]+|@[\w-]+)=(?:"([^"]*)"|'([^']*)')/g)) {
|
|
4374
|
+
const expression = match[1] ?? match[2] ?? match[3];
|
|
4375
|
+
if (!expression?.trim()) continue;
|
|
4376
|
+
blocks.push({
|
|
4377
|
+
source: `(${expression})`,
|
|
4378
|
+
lineOffset: lineOffsetAt(content, match.index ?? 0)
|
|
4379
|
+
});
|
|
4380
|
+
}
|
|
4381
|
+
return blocks;
|
|
4382
|
+
}
|
|
4383
|
+
//#endregion
|
|
4384
|
+
//#region src/scanner/frontends/php/index.ts
|
|
4385
|
+
/**
|
|
4386
|
+
* Laravel PHP, read as syntax (#403, #332).
|
|
4387
|
+
*
|
|
4388
|
+
* The helpers are global functions, so recognition is by name rather than by
|
|
4389
|
+
* following an import — but the *arguments* are read from a real parse:
|
|
4390
|
+
* `"statuses.{$status}.label"` is an interpolated string with known parts, not
|
|
4391
|
+
* a regex's guess about where the quotes end. Heredocs, escapes and nested
|
|
4392
|
+
* quotes come for free.
|
|
4393
|
+
*
|
|
4394
|
+
* Blade is not read here: templates are lifted to PHP expressions by their own
|
|
4395
|
+
* frontend. This one declines `.blade.php`.
|
|
4396
|
+
*/
|
|
4397
|
+
/** The Laravel translation helpers. Global names; nothing else is called that. */
|
|
4398
|
+
const PHP_I18N_CALLEES = new Set([
|
|
4399
|
+
"__",
|
|
4400
|
+
"trans",
|
|
4401
|
+
"trans_choice"
|
|
4402
|
+
]);
|
|
4403
|
+
function createPhpFrontend() {
|
|
4404
|
+
return {
|
|
4405
|
+
name: "php",
|
|
4406
|
+
handles(filePath) {
|
|
4407
|
+
return filePath.endsWith(".php") && !filePath.endsWith(".blade.php");
|
|
4408
|
+
},
|
|
4409
|
+
async read(content, filePath) {
|
|
4410
|
+
const parser = await loadPhpParser(filePath);
|
|
4411
|
+
if (!parser) return null;
|
|
4412
|
+
let program;
|
|
4413
|
+
try {
|
|
4414
|
+
program = parser.parseCode(content, filePath);
|
|
4415
|
+
} catch (error) {
|
|
4416
|
+
log.debug(`php frontend declined ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4417
|
+
return null;
|
|
4418
|
+
}
|
|
4419
|
+
return collectPhpSites(program);
|
|
4420
|
+
}
|
|
4421
|
+
};
|
|
4422
|
+
}
|
|
4423
|
+
/**
|
|
4424
|
+
* Call sites in a parsed program. Shared with the Blade frontend, which parses
|
|
4425
|
+
* lifted expressions through the same engine and interprets them identically —
|
|
4426
|
+
* a key's fate must not depend on which file type referenced it (#332).
|
|
4427
|
+
*/
|
|
4428
|
+
function collectPhpSites(program, lineOffset = 0, calleeOverride) {
|
|
4429
|
+
const sites = [];
|
|
4430
|
+
walk(program, (node) => {
|
|
4431
|
+
if (node.kind !== "call") return;
|
|
4432
|
+
const callee = calleeName(node.what);
|
|
4433
|
+
if (!callee) return;
|
|
4434
|
+
const [first] = node.arguments ?? [];
|
|
4435
|
+
if (!first) return;
|
|
4436
|
+
sites.push({
|
|
4437
|
+
callee: calleeOverride ?? callee,
|
|
4438
|
+
binding: "resolved",
|
|
4439
|
+
argument: readArgument(first),
|
|
4440
|
+
line: (node.loc?.start?.line ?? 1) + lineOffset
|
|
4441
|
+
});
|
|
4442
|
+
});
|
|
4443
|
+
return sites;
|
|
4444
|
+
}
|
|
4445
|
+
function calleeName(what) {
|
|
4446
|
+
if (!what) return void 0;
|
|
4447
|
+
if (what.kind === "name" && typeof what.name === "string" && PHP_I18N_CALLEES.has(what.name)) return what.name;
|
|
4448
|
+
return isLangGet(what) ? "Lang::get" : void 0;
|
|
4449
|
+
}
|
|
4450
|
+
function isLangGet(what) {
|
|
4451
|
+
if (what.kind !== "staticlookup") return false;
|
|
4452
|
+
return what.what?.name === "Lang" && what.offset?.name === "get";
|
|
4453
|
+
}
|
|
4454
|
+
function readArgument(node) {
|
|
4455
|
+
if ((node.kind === "string" || node.kind === "nowdoc") && typeof node.value === "string") return {
|
|
4456
|
+
kind: "static",
|
|
4457
|
+
value: node.value
|
|
4458
|
+
};
|
|
4459
|
+
if (node.kind === "encapsed" && Array.isArray(node.value)) return readEncapsed(node.value);
|
|
4460
|
+
if (node.kind === "bin" && node.type === ".") return readConcat(node);
|
|
4461
|
+
return { kind: "unknown" };
|
|
4462
|
+
}
|
|
4463
|
+
/** `'orders.status.' . $status` — the literal side bounds what the call can produce. */
|
|
4464
|
+
function readConcat(node) {
|
|
4465
|
+
const left = node.left;
|
|
4466
|
+
if (left?.kind === "string" && typeof left.value === "string") return {
|
|
4467
|
+
kind: "concat",
|
|
4468
|
+
prefix: left.value
|
|
4469
|
+
};
|
|
4470
|
+
return { kind: "unknown" };
|
|
4471
|
+
}
|
|
4472
|
+
/**
|
|
4473
|
+
* Double-quoted or heredoc string with interpolation: known literal parts
|
|
4474
|
+
* around `${_}` slots — or a plain string after all, when nothing
|
|
4475
|
+
* interpolates.
|
|
4476
|
+
*/
|
|
4477
|
+
function readEncapsed(parts) {
|
|
4478
|
+
const rendered = parts.map((part) => {
|
|
4479
|
+
const expression = part.expression;
|
|
4480
|
+
return expression?.kind === "string" && typeof expression.value === "string" ? expression.value : "${_}";
|
|
4481
|
+
}).join("");
|
|
4482
|
+
return rendered.includes("${_}") ? {
|
|
4483
|
+
kind: "template",
|
|
4484
|
+
expression: rendered
|
|
4485
|
+
} : {
|
|
4486
|
+
kind: "static",
|
|
4487
|
+
value: rendered
|
|
4488
|
+
};
|
|
4489
|
+
}
|
|
4490
|
+
const parserPromises = /* @__PURE__ */ new Map();
|
|
4491
|
+
function loadPhpParser(fromFile) {
|
|
4492
|
+
const key = dirname(fromFile);
|
|
4493
|
+
let promise = parserPromises.get(key);
|
|
4494
|
+
if (!promise) {
|
|
4495
|
+
promise = resolveParser(fromFile);
|
|
4496
|
+
parserPromises.set(key, promise);
|
|
4497
|
+
}
|
|
4498
|
+
return promise;
|
|
4499
|
+
}
|
|
4500
|
+
let warnedMissingParser = false;
|
|
4501
|
+
async function resolveParser(fromFile) {
|
|
4502
|
+
const Engine = requireFromProject(fromFile) ?? await importFromOwnTree();
|
|
4503
|
+
if (!Engine) {
|
|
4504
|
+
if (warnedMissingParser) return null;
|
|
4505
|
+
warnedMissingParser = true;
|
|
4506
|
+
log.warn("PHP files found, but php-parser is not installed — falling back to pattern matching. Laravel projects need the PHP packages installed: npm i -D php-parser php-array-reader");
|
|
4507
|
+
return null;
|
|
4508
|
+
}
|
|
4509
|
+
return new Engine({
|
|
4510
|
+
parser: {
|
|
4511
|
+
php7: true,
|
|
4512
|
+
suppressErrors: false
|
|
4513
|
+
},
|
|
4514
|
+
ast: { withPositions: true }
|
|
4515
|
+
});
|
|
4516
|
+
}
|
|
4517
|
+
function requireFromProject(fromFile) {
|
|
4518
|
+
try {
|
|
4519
|
+
return createRequire(isAbsolute(fromFile) ? fromFile : join(process.cwd(), fromFile))("php-parser");
|
|
4520
|
+
} catch {
|
|
4521
|
+
return;
|
|
4522
|
+
}
|
|
4523
|
+
}
|
|
4524
|
+
async function importFromOwnTree() {
|
|
4525
|
+
try {
|
|
4526
|
+
const mod = await import("php-parser");
|
|
4527
|
+
return mod.default ?? mod;
|
|
4528
|
+
} catch {
|
|
4529
|
+
return;
|
|
4530
|
+
}
|
|
4531
|
+
}
|
|
4532
|
+
function walk(node, visit) {
|
|
4533
|
+
if (!node || typeof node !== "object") return;
|
|
4534
|
+
if (typeof node.kind === "string") visit(node);
|
|
4535
|
+
for (const [key, child] of Object.entries(node)) {
|
|
4536
|
+
if (key === "loc" || key === "parent") continue;
|
|
4537
|
+
walkChild(child, visit);
|
|
4538
|
+
}
|
|
4539
|
+
}
|
|
4540
|
+
function walkChild(child, visit) {
|
|
4541
|
+
if (Array.isArray(child)) {
|
|
4542
|
+
for (const item of child) walkChild(item, visit);
|
|
4543
|
+
return;
|
|
4544
|
+
}
|
|
4545
|
+
if (child && typeof child === "object") walk(child, visit);
|
|
4546
|
+
}
|
|
4047
4547
|
const LARAVEL_PATTERNS = {
|
|
4048
4548
|
label: "Laravel",
|
|
4049
4549
|
filePatterns: ["**/*.blade.php", "**/*.php"],
|
|
@@ -4062,163 +4562,438 @@ const LARAVEL_PATTERNS = {
|
|
|
4062
4562
|
bareShapes: "php"
|
|
4063
4563
|
};
|
|
4064
4564
|
/**
|
|
4065
|
-
*
|
|
4066
|
-
*
|
|
4067
|
-
*
|
|
4565
|
+
* Matches PHP double-quoted interpolated strings with i18n-key shape,
|
|
4566
|
+
* regardless of call context — `$transKey = "api.x.{$key}"` assigned first
|
|
4567
|
+
* and passed to Lang::get() later must still suppress api.x.* orphans.
|
|
4568
|
+
* Content is restricted to key-like chars plus {$expr} / $var->prop
|
|
4569
|
+
* interpolations: a permissive "any double-quoted string containing $"
|
|
4570
|
+
* match swallows the code BETWEEN quoted strings (PHP code is full of $),
|
|
4571
|
+
* shifting quote parity past the real candidates.
|
|
4068
4572
|
*/
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4573
|
+
const BARE_PHP_DYNAMIC = /"((?:[\w.-]|\{\$[^}]+\}|\$[a-zA-Z_][a-zA-Z0-9_]*(?:->[a-zA-Z_][a-zA-Z0-9_]*)*)+)"/g;
|
|
4574
|
+
function collectBarePhpCandidates(content, bareDynamics) {
|
|
4575
|
+
BARE_PHP_DYNAMIC.lastIndex = 0;
|
|
4576
|
+
for (const match of content.matchAll(BARE_PHP_DYNAMIC)) {
|
|
4577
|
+
const expr = match[1];
|
|
4578
|
+
if (!expr?.includes("$")) continue;
|
|
4579
|
+
const normalized = expr.replace(/\{\$[^}]+\}/g, "${_}").replace(/\$[a-zA-Z_][a-zA-Z0-9_]*(?:->[a-zA-Z_][a-zA-Z0-9_]*)*/g, "${_}");
|
|
4580
|
+
if (!normalized.replace(/\$\{_\}/g, "").includes(".")) continue;
|
|
4581
|
+
bareDynamics.add(`\`${normalized}\``);
|
|
4073
4582
|
}
|
|
4074
4583
|
}
|
|
4075
4584
|
//#endregion
|
|
4076
|
-
//#region src/scanner/
|
|
4585
|
+
//#region src/scanner/frontends/php/blade.ts
|
|
4077
4586
|
/**
|
|
4078
|
-
*
|
|
4079
|
-
*
|
|
4080
|
-
*
|
|
4081
|
-
*
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
*
|
|
4086
|
-
*
|
|
4087
|
-
*
|
|
4088
|
-
*
|
|
4089
|
-
* A name bound to different values (shadowing across scopes) is ambiguous
|
|
4090
|
-
* and dropped: substituting one of several possible values could narrow a
|
|
4091
|
-
* pattern past a live key.
|
|
4587
|
+
* Blade, by lifting (#404, #332).
|
|
4588
|
+
*
|
|
4589
|
+
* No maintained Blade AST parser exists, and none is needed: every construct
|
|
4590
|
+
* that can carry a translation key wraps a PHP expression. The lexical pass
|
|
4591
|
+
* here finds those wrappers — echoes, `@lang`/`@choice`, `@php` blocks, raw
|
|
4592
|
+
* PHP tags — and hands the expression inside to the same parser and the same
|
|
4593
|
+
* site collection plain PHP uses. The regex frames text; it never decides
|
|
4594
|
+
* what a key is.
|
|
4595
|
+
*
|
|
4596
|
+
* A lifted chunk the parser cannot read declines the whole file to the
|
|
4597
|
+
* pattern fallback: partially-read templates would silently drop keys.
|
|
4092
4598
|
*/
|
|
4093
|
-
function
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4599
|
+
function createBladeFrontend() {
|
|
4600
|
+
return {
|
|
4601
|
+
name: "blade",
|
|
4602
|
+
handles(filePath) {
|
|
4603
|
+
return filePath.endsWith(".blade.php");
|
|
4604
|
+
},
|
|
4605
|
+
async read(content, filePath) {
|
|
4606
|
+
const parser = await loadPhpParser(filePath);
|
|
4607
|
+
if (!parser) return null;
|
|
4608
|
+
const sites = [];
|
|
4609
|
+
for (const chunk of liftChunks(content)) {
|
|
4610
|
+
const parsed = parseChunk(parser, chunk, filePath);
|
|
4611
|
+
if (!parsed) {
|
|
4612
|
+
if (chunk.optional) continue;
|
|
4613
|
+
return null;
|
|
4614
|
+
}
|
|
4615
|
+
sites.push(...collectPhpSites(parsed, chunk.lineOffset, chunk.callee));
|
|
4616
|
+
}
|
|
4617
|
+
sites.sort((a, b) => a.line - b.line);
|
|
4618
|
+
return sites;
|
|
4106
4619
|
}
|
|
4107
|
-
|
|
4620
|
+
};
|
|
4621
|
+
}
|
|
4622
|
+
function parseChunk(parser, chunk, filePath) {
|
|
4623
|
+
try {
|
|
4624
|
+
return parser.parseCode(`<?php ${chunk.source}`, filePath);
|
|
4625
|
+
} catch (error) {
|
|
4626
|
+
log.debug(`blade frontend declined ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4627
|
+
return null;
|
|
4108
4628
|
}
|
|
4109
|
-
return table;
|
|
4110
4629
|
}
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4630
|
+
const BLADE_COMMENT = /\{\{--[\s\S]*?--\}\}/g;
|
|
4631
|
+
function liftChunks(content) {
|
|
4632
|
+
const source = content.replace(BLADE_COMMENT, (m) => m.replace(/[^\n]/g, " "));
|
|
4633
|
+
const lineAt = (offset) => source.slice(0, offset).split("\n").length - 1;
|
|
4634
|
+
return [
|
|
4635
|
+
...echoChunks(source, lineAt),
|
|
4636
|
+
...phpBlockChunks(source, lineAt),
|
|
4637
|
+
...boundAttributeChunks(source, lineAt),
|
|
4638
|
+
...directiveChunks(source, lineAt)
|
|
4639
|
+
];
|
|
4120
4640
|
}
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4641
|
+
/** {{ expr }} and {!! expr !!} — echoes of a PHP expression. */
|
|
4642
|
+
function echoChunks(source, lineAt) {
|
|
4643
|
+
return expressionChunks(source, /\{\{([\s\S]*?)\}\}|\{!!([\s\S]*?)!!\}/g, lineAt, (expression) => ({ source: `${expression};` }));
|
|
4644
|
+
}
|
|
4645
|
+
function expressionChunks(source, pattern, lineAt, shape) {
|
|
4646
|
+
const chunks = [];
|
|
4647
|
+
for (const match of source.matchAll(pattern)) {
|
|
4648
|
+
const expression = match[1] ?? match[2];
|
|
4649
|
+
if (!expression?.trim()) continue;
|
|
4650
|
+
chunks.push({
|
|
4651
|
+
...shape(expression),
|
|
4652
|
+
lineOffset: lineAt(match.index ?? 0)
|
|
4653
|
+
});
|
|
4654
|
+
}
|
|
4655
|
+
return chunks;
|
|
4656
|
+
}
|
|
4657
|
+
/** @php ... @endphp and raw <?php ... ?> — statements as written. */
|
|
4658
|
+
function phpBlockChunks(source, lineAt) {
|
|
4659
|
+
return [...bodyChunks(source, /@php\b(?!\s*\()([\s\S]*?)@endphp/g, lineAt), ...bodyChunks(source, /<\?php\b([\s\S]*?)(?:\?>|$)/g, lineAt)];
|
|
4660
|
+
}
|
|
4661
|
+
function bodyChunks(source, pattern, lineAt) {
|
|
4662
|
+
const chunks = [];
|
|
4663
|
+
for (const match of source.matchAll(pattern)) {
|
|
4664
|
+
const body = match[1];
|
|
4665
|
+
if (body?.trim()) chunks.push({
|
|
4666
|
+
source: body,
|
|
4667
|
+
lineOffset: lineAt(match.index ?? 0)
|
|
4668
|
+
});
|
|
4140
4669
|
}
|
|
4670
|
+
return chunks;
|
|
4141
4671
|
}
|
|
4142
4672
|
/**
|
|
4143
|
-
*
|
|
4144
|
-
*
|
|
4145
|
-
* contains no interpolation at all.
|
|
4673
|
+
* Bound component attributes — :message="__('alerts.saved')" compiles to a
|
|
4674
|
+
* PHP expression. `::` escapes to a literal colon and carries none.
|
|
4146
4675
|
*/
|
|
4147
|
-
function
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4676
|
+
function boundAttributeChunks(source, lineAt) {
|
|
4677
|
+
return expressionChunks(source, /(?<![:\w]):[\w-]+=(?:"([^"]*)"|'([^']*)')/g, lineAt, (expression) => ({
|
|
4678
|
+
source: `__args__(${expression});`,
|
|
4679
|
+
optional: true
|
|
4680
|
+
}));
|
|
4681
|
+
}
|
|
4682
|
+
/**
|
|
4683
|
+
* Directive arguments. @lang and @choice are thin wrappers over __ and
|
|
4684
|
+
* trans_choice — the argument list is the translation call, reported under
|
|
4685
|
+
* the directive's own name. Every other directive gets its arguments read
|
|
4686
|
+
* as an expression list (`@section('title', __('Forbidden'))` carries a
|
|
4687
|
+
* real call), best-effort: what is not an expression is Blade grammar.
|
|
4688
|
+
* The inline @php($x = ...) form is an expression list like any other.
|
|
4689
|
+
*/
|
|
4690
|
+
function directiveChunks(source, lineAt) {
|
|
4691
|
+
const chunks = [];
|
|
4692
|
+
for (const match of source.matchAll(/@(\w+)\s*\(/g)) {
|
|
4693
|
+
const args = balancedParens(source, (match.index ?? 0) + match[0].length - 1);
|
|
4694
|
+
if (args === void 0) continue;
|
|
4695
|
+
const chunk = directiveChunk(match[1] ?? "", args, lineAt(match.index ?? 0));
|
|
4696
|
+
if (chunk) chunks.push(chunk);
|
|
4697
|
+
}
|
|
4698
|
+
return chunks;
|
|
4699
|
+
}
|
|
4700
|
+
function directiveChunk(directive, args, lineOffset) {
|
|
4701
|
+
if (directive === "lang" || directive === "choice") return {
|
|
4702
|
+
source: `${directive === "lang" ? "__" : "trans_choice"}(${args});`,
|
|
4703
|
+
lineOffset,
|
|
4704
|
+
callee: `@${directive}`
|
|
4705
|
+
};
|
|
4706
|
+
return args.trim() ? {
|
|
4707
|
+
source: `__args__(${args});`,
|
|
4708
|
+
lineOffset,
|
|
4709
|
+
optional: true
|
|
4710
|
+
} : void 0;
|
|
4711
|
+
}
|
|
4712
|
+
/**
|
|
4713
|
+
* The argument text between a directive's parentheses, quote-aware — a `)`
|
|
4714
|
+
* inside a string does not close the call. Returns undefined when the call
|
|
4715
|
+
* never closes, which declines the construct rather than guessing.
|
|
4716
|
+
*/
|
|
4717
|
+
function balancedParens(source, openIndex) {
|
|
4718
|
+
let depth = 0;
|
|
4719
|
+
for (let i = openIndex; i < source.length; i++) {
|
|
4720
|
+
const ch = source[i];
|
|
4721
|
+
if (ch === "'" || ch === "\"") i = skipString(source, i);
|
|
4722
|
+
else if (ch === "(") depth++;
|
|
4723
|
+
else if (ch === ")" && --depth === 0) return source.slice(openIndex + 1, i);
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4726
|
+
/** The index of a string literal's closing quote, escapes respected. */
|
|
4727
|
+
function skipString(source, start) {
|
|
4728
|
+
const quote = source[start];
|
|
4729
|
+
for (let i = start + 1; i < source.length; i++) if (source[i] === "\\") i++;
|
|
4730
|
+
else if (source[i] === quote) return i;
|
|
4731
|
+
return source.length;
|
|
4732
|
+
}
|
|
4733
|
+
//#endregion
|
|
4734
|
+
//#region src/scanner/rules.ts
|
|
4735
|
+
/**
|
|
4736
|
+
* The one callee whose dotless arguments are not evidence: a bare `t` is what
|
|
4737
|
+
* `emit`, test helpers and local functions are also called. Every other name
|
|
4738
|
+
* the pattern sets match (`$t`, `__`, `trans`, ...) is distinctive enough that
|
|
4739
|
+
* its argument counts, dot or no dot. Formerly per-pattern-set configuration;
|
|
4740
|
+
* it is a rule about meaning, so it lives with the rules.
|
|
4741
|
+
*/
|
|
4742
|
+
const ambiguousCalleeNeedsDot = (callee) => callee === "t";
|
|
4743
|
+
function interpret(sites, ctx) {
|
|
4744
|
+
const usages = [];
|
|
4745
|
+
const dynamicKeys = [];
|
|
4746
|
+
const bareStringCandidates = /* @__PURE__ */ new Set();
|
|
4747
|
+
for (const site of sites) {
|
|
4748
|
+
const { callee, line, argument } = site;
|
|
4749
|
+
const guarded = site.binding === "ambiguous" && ctx.ambiguousCalleeNeedsDot(callee);
|
|
4750
|
+
switch (argument.kind) {
|
|
4751
|
+
case "static":
|
|
4752
|
+
if (guarded && !argument.value.includes(".")) {
|
|
4753
|
+
bareStringCandidates.add(argument.value);
|
|
4754
|
+
break;
|
|
4755
|
+
}
|
|
4756
|
+
usages.push({
|
|
4757
|
+
key: argument.value,
|
|
4758
|
+
file: ctx.filePath,
|
|
4759
|
+
line,
|
|
4760
|
+
callee
|
|
4761
|
+
});
|
|
4762
|
+
break;
|
|
4763
|
+
case "template":
|
|
4764
|
+
dynamicKeys.push({
|
|
4765
|
+
expression: `\`${argument.expression}\``,
|
|
4766
|
+
file: ctx.filePath,
|
|
4767
|
+
line,
|
|
4768
|
+
callee
|
|
4769
|
+
});
|
|
4770
|
+
break;
|
|
4771
|
+
case "concat":
|
|
4772
|
+
if (guarded && !argument.prefix.includes(".")) break;
|
|
4773
|
+
dynamicKeys.push({
|
|
4774
|
+
expression: `\`${argument.prefix}\${_}\``,
|
|
4775
|
+
file: ctx.filePath,
|
|
4776
|
+
line,
|
|
4777
|
+
callee
|
|
4778
|
+
});
|
|
4779
|
+
break;
|
|
4780
|
+
case "unknown": break;
|
|
4781
|
+
}
|
|
4782
|
+
}
|
|
4783
|
+
return {
|
|
4784
|
+
usages,
|
|
4785
|
+
dynamicKeys,
|
|
4786
|
+
bareStringCandidates
|
|
4787
|
+
};
|
|
4788
|
+
}
|
|
4789
|
+
//#endregion
|
|
4790
|
+
//#region src/scanner/frontends/patterns.ts
|
|
4791
|
+
/**
|
|
4792
|
+
* The regex path as a language frontend (#332).
|
|
4793
|
+
*
|
|
4794
|
+
* Regexes frame text and report call sites; what a site means is decided once,
|
|
4795
|
+
* in the rules, the same as for every other frontend. Binding is always
|
|
4796
|
+
* `ambiguous`, because a regex can never prove what a name is bound to — which
|
|
4797
|
+
* is the entire reason the syntax frontends exist.
|
|
4798
|
+
*
|
|
4799
|
+
* This frontend never declines: it is the floor every scan can fall back to.
|
|
4800
|
+
*/
|
|
4801
|
+
function createPatternsFrontend(pat) {
|
|
4802
|
+
return {
|
|
4803
|
+
name: "patterns",
|
|
4804
|
+
handles: () => true,
|
|
4805
|
+
read: (content, filePath) => Promise.resolve(readPatternSites(content, filePath, pat))
|
|
4806
|
+
};
|
|
4807
|
+
}
|
|
4808
|
+
/**
|
|
4809
|
+
* Synchronous core, so the sync `extractKeys` contract the scanner suites are
|
|
4810
|
+
* written against keeps working unchanged.
|
|
4811
|
+
*/
|
|
4812
|
+
function readPatternSites(content, _filePath, pat) {
|
|
4813
|
+
const sites = [];
|
|
4814
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4815
|
+
const lines = content.split("\n");
|
|
4816
|
+
for (const [i, line] of lines.entries()) {
|
|
4817
|
+
const lineNumber = i + 1;
|
|
4818
|
+
staticSites(line, lineNumber, pat, sites, seen);
|
|
4819
|
+
dynamicSites(line, lineNumber, pat, sites, seen);
|
|
4820
|
+
concatSites(line, lineNumber, pat, sites);
|
|
4821
|
+
}
|
|
4822
|
+
return sites;
|
|
4823
|
+
}
|
|
4824
|
+
function pushStatic(sites, seen, callee, value, line) {
|
|
4825
|
+
const id = `${line}:${callee}:${value}`;
|
|
4826
|
+
if (seen.has(id)) return;
|
|
4827
|
+
seen.add(id);
|
|
4828
|
+
sites.push({
|
|
4829
|
+
callee,
|
|
4830
|
+
binding: "ambiguous",
|
|
4831
|
+
argument: {
|
|
4832
|
+
kind: "static",
|
|
4833
|
+
value
|
|
4834
|
+
},
|
|
4835
|
+
line
|
|
4836
|
+
});
|
|
4153
4837
|
}
|
|
4154
|
-
function
|
|
4155
|
-
for (const regex of
|
|
4838
|
+
function staticSites(line, lineNumber, pat, sites, seen) {
|
|
4839
|
+
for (const regex of pat.staticKeyPatterns) {
|
|
4840
|
+
regex.lastIndex = 0;
|
|
4841
|
+
for (const match of line.matchAll(regex)) {
|
|
4842
|
+
const callee = match[1] ?? "";
|
|
4843
|
+
const key = match[3];
|
|
4844
|
+
if (!key) continue;
|
|
4845
|
+
if (key.includes("{$")) continue;
|
|
4846
|
+
pushStatic(sites, seen, callee, key, lineNumber);
|
|
4847
|
+
}
|
|
4848
|
+
}
|
|
4849
|
+
}
|
|
4850
|
+
function dynamicSites(line, lineNumber, pat, sites, seen) {
|
|
4851
|
+
for (const regex of pat.dynamicKeyPatterns) {
|
|
4156
4852
|
regex.lastIndex = 0;
|
|
4157
4853
|
for (const match of line.matchAll(regex)) {
|
|
4158
4854
|
const callee = match[1] ?? "";
|
|
4159
4855
|
const raw = match[2];
|
|
4160
4856
|
if (!raw) continue;
|
|
4161
|
-
const
|
|
4162
|
-
const normalized = normalizeDynamicExpression(expression);
|
|
4857
|
+
const normalized = normalizeDynamicExpression(raw);
|
|
4163
4858
|
if (normalized === void 0) {
|
|
4164
|
-
|
|
4165
|
-
if (!expression) continue;
|
|
4166
|
-
if (ctx.pat.requiresDotForCallee?.(callee) && !expression.includes(".")) continue;
|
|
4167
|
-
ctx.usages.push({
|
|
4168
|
-
key: expression,
|
|
4169
|
-
file: ctx.filePath,
|
|
4170
|
-
line: lineNumber,
|
|
4171
|
-
callee
|
|
4172
|
-
});
|
|
4859
|
+
pushStatic(sites, seen, callee, raw, lineNumber);
|
|
4173
4860
|
continue;
|
|
4174
4861
|
}
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4862
|
+
sites.push({
|
|
4863
|
+
callee,
|
|
4864
|
+
binding: "ambiguous",
|
|
4865
|
+
argument: {
|
|
4866
|
+
kind: "template",
|
|
4867
|
+
expression: normalized
|
|
4868
|
+
},
|
|
4869
|
+
line: lineNumber
|
|
4180
4870
|
});
|
|
4181
4871
|
}
|
|
4182
4872
|
}
|
|
4183
4873
|
}
|
|
4184
|
-
function
|
|
4185
|
-
for (const regex of
|
|
4874
|
+
function concatSites(line, lineNumber, pat, sites) {
|
|
4875
|
+
for (const regex of pat.concatKeyPatterns) {
|
|
4186
4876
|
regex.lastIndex = 0;
|
|
4187
4877
|
for (const match of line.matchAll(regex)) {
|
|
4188
4878
|
const callee = match[1] ?? "";
|
|
4189
4879
|
const prefix = match[3];
|
|
4190
4880
|
if (!prefix) continue;
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4881
|
+
sites.push({
|
|
4882
|
+
callee,
|
|
4883
|
+
binding: "ambiguous",
|
|
4884
|
+
argument: {
|
|
4885
|
+
kind: "concat",
|
|
4886
|
+
prefix
|
|
4887
|
+
},
|
|
4888
|
+
line: lineNumber
|
|
4197
4889
|
});
|
|
4198
4890
|
}
|
|
4199
4891
|
}
|
|
4200
4892
|
}
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4893
|
+
/**
|
|
4894
|
+
* Matches `const` declarations initialized to a key-shaped string literal
|
|
4895
|
+
* (>=1 dot). Scope is deliberately tight: identifier = literal only — no
|
|
4896
|
+
* object properties, no expressions, and no `let` (a reassigned binding
|
|
4897
|
+
* would substitute a stale literal and bypass the conservative widening).
|
|
4898
|
+
*/
|
|
4899
|
+
const CONST_KEY_DECL = /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(['"])((?:[\w-]+\.)+[\w-]+)\2/g;
|
|
4900
|
+
/**
|
|
4901
|
+
* Collects same-file `const NAME = 'dotted.path'` declarations for the
|
|
4902
|
+
* bare-candidate net, which stays deliberately non-syntactic (#332): a
|
|
4903
|
+
* substituted candidate is an exact protector where an unsubstituted one
|
|
4904
|
+
* would be a wildcard. Usage extraction no longer consults this — the syntax
|
|
4905
|
+
* frontends resolve real bindings instead.
|
|
4906
|
+
*/
|
|
4907
|
+
function collectConstKeyTable(content) {
|
|
4908
|
+
const table = /* @__PURE__ */ new Map();
|
|
4909
|
+
const ambiguous = /* @__PURE__ */ new Set();
|
|
4910
|
+
CONST_KEY_DECL.lastIndex = 0;
|
|
4911
|
+
for (const match of content.matchAll(CONST_KEY_DECL)) {
|
|
4912
|
+
const name = match[1];
|
|
4913
|
+
const value = match[3];
|
|
4914
|
+
if (!name || !value || ambiguous.has(name)) continue;
|
|
4915
|
+
const existing = table.get(name);
|
|
4916
|
+
if (existing !== void 0 && existing !== value) {
|
|
4917
|
+
table.delete(name);
|
|
4918
|
+
ambiguous.add(name);
|
|
4919
|
+
continue;
|
|
4920
|
+
}
|
|
4921
|
+
table.set(name, value);
|
|
4922
|
+
}
|
|
4923
|
+
return table;
|
|
4924
|
+
}
|
|
4925
|
+
/**
|
|
4926
|
+
* Substitutes `${NAME}` interpolations with the const table's literal value,
|
|
4927
|
+
* producing an exact or narrower pattern: `${i18nBase}.title` +
|
|
4928
|
+
* `const i18nBase = 'a.b.c'` -> `a.b.c.title`. Only plain-identifier
|
|
4929
|
+
* interpolations qualify; member expressions and anything else stay dynamic.
|
|
4930
|
+
*/
|
|
4931
|
+
function substituteConstIdentifiers(expr, table) {
|
|
4932
|
+
if (table.size === 0 || !expr.includes("${")) return expr;
|
|
4933
|
+
return expr.replace(/\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g, (whole, name) => table.get(name) ?? whole);
|
|
4934
|
+
}
|
|
4935
|
+
/**
|
|
4936
|
+
* Normalizes every interpolation syntax (JS `${expr}`, PHP `{$expr}` and
|
|
4937
|
+
* bare `$var->prop`) to `${_}` slots. Returns undefined when the expression
|
|
4938
|
+
* contains no interpolation at all.
|
|
4939
|
+
*/
|
|
4940
|
+
function normalizeDynamicExpression(expression) {
|
|
4941
|
+
const hasDollarBrace = expression.includes("${");
|
|
4942
|
+
const hasBraceDollar = expression.includes("{$");
|
|
4943
|
+
const hasBarePHP = !hasDollarBrace && !hasBraceDollar && /\$[a-zA-Z_]/.test(expression);
|
|
4944
|
+
if (!hasDollarBrace && !hasBraceDollar && !hasBarePHP) return void 0;
|
|
4945
|
+
return hasBraceDollar ? expression.replace(/\{\$[^}]+\}/g, "${_}") : hasBarePHP ? expression.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*(?:->[a-zA-Z_][a-zA-Z0-9_]*)*/g, "${_}") : expression;
|
|
4946
|
+
}
|
|
4947
|
+
const VUE_NUXT_PATTERNS = {
|
|
4948
|
+
label: "Vue / Nuxt",
|
|
4949
|
+
filePatterns: [
|
|
4950
|
+
"**/*.vue",
|
|
4951
|
+
"**/*.ts",
|
|
4952
|
+
"**/*.tsx",
|
|
4953
|
+
"**/*.js",
|
|
4954
|
+
"**/*.jsx",
|
|
4955
|
+
"**/*.mjs",
|
|
4956
|
+
"**/*.mts"
|
|
4957
|
+
],
|
|
4958
|
+
ignoreDirs: [
|
|
4959
|
+
"node_modules",
|
|
4960
|
+
".nuxt",
|
|
4961
|
+
".output",
|
|
4962
|
+
"dist",
|
|
4963
|
+
".git",
|
|
4964
|
+
"coverage",
|
|
4965
|
+
".tmp"
|
|
4966
|
+
],
|
|
4967
|
+
staticKeyPatterns: [/(?<!\w)(this\.\$te?|\$te?|\bt)\s*\(\s*(['"])((?:(?!\2).)*)\2/g],
|
|
4968
|
+
dynamicKeyPatterns: [/(?<!\w)(this\.\$te?|\$te?|\bt)\s*\(\s*`((?:[^`]|\\.)*)`/g],
|
|
4969
|
+
concatKeyPatterns: [/(?<!\w)(this\.\$te?|\$te?|\bt)\s*\(\s*(['"])((?:(?!\2).)*)\2\s*\+/g],
|
|
4970
|
+
bareShapes: "js"
|
|
4971
|
+
};
|
|
4972
|
+
/**
|
|
4973
|
+
* Maps locale file format to the appropriate scan pattern set.
|
|
4974
|
+
* 'php-array' → Laravel (PHP translation helpers in Blade/PHP files).
|
|
4975
|
+
* 'json' / undefined → Vue/Nuxt ($t / t calls in Vue/TS/JS files).
|
|
4976
|
+
*/
|
|
4977
|
+
function getPatternSet(format) {
|
|
4978
|
+
switch (format) {
|
|
4979
|
+
case "php-array": return LARAVEL_PATTERNS;
|
|
4980
|
+
default: return VUE_NUXT_PATTERNS;
|
|
4217
4981
|
}
|
|
4982
|
+
}
|
|
4983
|
+
//#endregion
|
|
4984
|
+
//#region src/scanner/code-scanner.ts
|
|
4985
|
+
/**
|
|
4986
|
+
* One file's evidence through the pattern frontend — the sync contract the
|
|
4987
|
+
* scanner suites are written against. Same pipeline as every scan: the
|
|
4988
|
+
* frontend reports call sites, the rules decide what they mean.
|
|
4989
|
+
*/
|
|
4990
|
+
function extractKeys(content, filePath, patterns) {
|
|
4991
|
+
return interpret(readPatternSites(content, filePath, patterns ?? VUE_NUXT_PATTERNS), ruleContext(filePath));
|
|
4992
|
+
}
|
|
4993
|
+
function ruleContext(filePath) {
|
|
4218
4994
|
return {
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
bareStringCandidates: ctx.bareCandidates
|
|
4995
|
+
filePath,
|
|
4996
|
+
ambiguousCalleeNeedsDot
|
|
4222
4997
|
};
|
|
4223
4998
|
}
|
|
4224
4999
|
/**
|
|
@@ -4315,16 +5090,6 @@ const BARE_DYNAMIC_TEMPLATE = /`([\w.-]*(?:\$\{(?:[^`{}\n]|\{[^`{}\n]*\})*\}[\w.
|
|
|
4315
5090
|
/** Longer candidates cannot plausibly be i18n keys — drop, don't truncate. */
|
|
4316
5091
|
const MAX_BARE_TEMPLATE_LENGTH = 120;
|
|
4317
5092
|
/**
|
|
4318
|
-
* Matches PHP double-quoted interpolated strings with i18n-key shape,
|
|
4319
|
-
* regardless of call context — `$transKey = "api.x.{$key}"` assigned first
|
|
4320
|
-
* and passed to Lang::get() later must still suppress api.x.* orphans.
|
|
4321
|
-
* Content is restricted to key-like chars plus {$expr} / $var->prop
|
|
4322
|
-
* interpolations: a permissive "any double-quoted string containing $"
|
|
4323
|
-
* match swallows the code BETWEEN quoted strings (PHP code is full of $),
|
|
4324
|
-
* shifting quote parity past the real candidates.
|
|
4325
|
-
*/
|
|
4326
|
-
const BARE_PHP_DYNAMIC = /"((?:[\w.-]|\{\$[^}]+\}|\$[a-zA-Z_][a-zA-Z0-9_]*(?:->[a-zA-Z_][a-zA-Z0-9_]*)*)+)"/g;
|
|
4327
|
-
/**
|
|
4328
5093
|
* Matches prefix-shaped string literals (≥1 key-like segment, trailing dot,
|
|
4329
5094
|
* closing quote right after the dot) regardless of call context: concat
|
|
4330
5095
|
* prefixes ('menu.' + var, __('a.b.' . $x) — incl. multiline t() calls where
|
|
@@ -4362,16 +5127,6 @@ function collectBareTemplateCandidates(content, constTable, bareStrings, bareDyn
|
|
|
4362
5127
|
bareDynamics.add(`\`${normalized}\``);
|
|
4363
5128
|
}
|
|
4364
5129
|
}
|
|
4365
|
-
function collectBarePhpCandidates(content, bareDynamics) {
|
|
4366
|
-
BARE_PHP_DYNAMIC.lastIndex = 0;
|
|
4367
|
-
for (const match of content.matchAll(BARE_PHP_DYNAMIC)) {
|
|
4368
|
-
const expr = match[1];
|
|
4369
|
-
if (!expr?.includes("$")) continue;
|
|
4370
|
-
const normalized = expr.replace(/\{\$[^}]+\}/g, "${_}").replace(/\$[a-zA-Z_][a-zA-Z0-9_]*(?:->[a-zA-Z_][a-zA-Z0-9_]*)*/g, "${_}");
|
|
4371
|
-
if (!normalized.replace(/\$\{_\}/g, "").includes(".")) continue;
|
|
4372
|
-
bareDynamics.add(`\`${normalized}\``);
|
|
4373
|
-
}
|
|
4374
|
-
}
|
|
4375
5130
|
function collectBarePrefixCandidates(content, bareDynamics) {
|
|
4376
5131
|
BARE_PREFIX_LITERAL.lastIndex = 0;
|
|
4377
5132
|
for (const match of content.matchAll(BARE_PREFIX_LITERAL)) bareDynamics.add(`\`${match[2]}\${_}\``);
|
|
@@ -4417,8 +5172,69 @@ function collectBareCandidates(content, constTable, bareStrings, bareDynamics, b
|
|
|
4417
5172
|
*
|
|
4418
5173
|
* When `patterns` is omitted, defaults to Vue/Nuxt patterns.
|
|
4419
5174
|
*/
|
|
4420
|
-
|
|
5175
|
+
/**
|
|
5176
|
+
* One file's evidence, preferring a frontend that reads the language as syntax
|
|
5177
|
+
* over one that matches it as text (#332).
|
|
5178
|
+
*
|
|
5179
|
+
* A frontend that declines — an unparseable file, a parser that would not load
|
|
5180
|
+
* — falls through to pattern matching rather than failing. Better evidence when
|
|
5181
|
+
* it is available, never a scan that stops because it was not.
|
|
5182
|
+
*/
|
|
5183
|
+
async function extractFileEvidence(content, filePath, frontends, patterns) {
|
|
5184
|
+
const pat = patterns ?? VUE_NUXT_PATTERNS;
|
|
5185
|
+
let declined = false;
|
|
5186
|
+
for (const frontend of frontends) {
|
|
5187
|
+
if (!frontend.handles(filePath)) continue;
|
|
5188
|
+
const sites = await frontend.read(content, filePath);
|
|
5189
|
+
if (!sites) {
|
|
5190
|
+
declined = true;
|
|
5191
|
+
continue;
|
|
5192
|
+
}
|
|
5193
|
+
return {
|
|
5194
|
+
...interpret(sites, ruleContext(filePath)),
|
|
5195
|
+
declined
|
|
5196
|
+
};
|
|
5197
|
+
}
|
|
5198
|
+
return {
|
|
5199
|
+
...extractKeys(content, filePath, pat),
|
|
5200
|
+
declined
|
|
5201
|
+
};
|
|
5202
|
+
}
|
|
5203
|
+
/**
|
|
5204
|
+
* Opt-in, via `I18N_SCANNER=ast`.
|
|
5205
|
+
*
|
|
5206
|
+
* The architecture is settled; the migration is not. #332 gates each frontend
|
|
5207
|
+
* on a differential run showing it is at least as conservative as what it
|
|
5208
|
+
* replaces, and on anny-ui the AST frontend still misses 13 keys the patterns
|
|
5209
|
+
* find — most of them regex artifacts, a handful genuine. A key the outgoing
|
|
5210
|
+
* frontend saw and the incoming one does not becomes an orphan, and orphans
|
|
5211
|
+
* get deleted, so the default stays where the evidence is.
|
|
5212
|
+
*
|
|
5213
|
+
* Flip it once `scripts/scanner-diff.mjs` reports nothing in that direction.
|
|
5214
|
+
*/
|
|
5215
|
+
let warnedRegexHatch = false;
|
|
5216
|
+
/**
|
|
5217
|
+
* The syntax frontends are the default (#402 for JS/TS/Vue, #405 for
|
|
5218
|
+
* PHP/Blade); patterns read only what they decline. `I18N_SCANNER=regex`
|
|
5219
|
+
* restores the old scanner for exactly one release — an escape hatch for
|
|
5220
|
+
* reporting a regression, not a mode.
|
|
5221
|
+
*/
|
|
5222
|
+
function defaultFrontends(pat) {
|
|
5223
|
+
if (process.env.I18N_SCANNER === "regex") {
|
|
5224
|
+
if (!warnedRegexHatch) {
|
|
5225
|
+
warnedRegexHatch = true;
|
|
5226
|
+
log.warn("I18N_SCANNER=regex is deprecated and will be removed in the next major. If the default scanner misses something the regex found, please file it: https://github.com/fabkho/the-i18n-kit/issues");
|
|
5227
|
+
}
|
|
5228
|
+
return [createPatternsFrontend(pat)];
|
|
5229
|
+
}
|
|
5230
|
+
return [...pat.bareShapes === "php" ? [phpFrontend, bladeFrontend] : [oxcFrontend], createPatternsFrontend(pat)];
|
|
5231
|
+
}
|
|
5232
|
+
const oxcFrontend = createOxcFrontend();
|
|
5233
|
+
const phpFrontend = createPhpFrontend();
|
|
5234
|
+
const bladeFrontend = createBladeFrontend();
|
|
5235
|
+
async function scanSourceFiles(rootDir, excludeDirs, patterns, frontends) {
|
|
4421
5236
|
const pat = patterns ?? VUE_NUXT_PATTERNS;
|
|
5237
|
+
const active = frontends ?? defaultFrontends(pat);
|
|
4422
5238
|
const ignore = [...pat.ignoreDirs, ...excludeDirs ?? []];
|
|
4423
5239
|
let relativePaths;
|
|
4424
5240
|
try {
|
|
@@ -4433,6 +5249,7 @@ async function scanSourceFiles(rootDir, excludeDirs, patterns) {
|
|
|
4433
5249
|
usages: [],
|
|
4434
5250
|
dynamicKeys: [],
|
|
4435
5251
|
filesScanned: 0,
|
|
5252
|
+
declinedFiles: [],
|
|
4436
5253
|
uniqueKeys: /* @__PURE__ */ new Set(),
|
|
4437
5254
|
bareStringCandidates: /* @__PURE__ */ new Set(),
|
|
4438
5255
|
bareDynamicCandidates: /* @__PURE__ */ new Set()
|
|
@@ -4442,6 +5259,7 @@ async function scanSourceFiles(rootDir, excludeDirs, patterns) {
|
|
|
4442
5259
|
const allDynamicKeys = [];
|
|
4443
5260
|
const bareStringCandidates = /* @__PURE__ */ new Set();
|
|
4444
5261
|
const bareDynamicCandidates = /* @__PURE__ */ new Set();
|
|
5262
|
+
const declinedFiles = [];
|
|
4445
5263
|
let filesScanned = 0;
|
|
4446
5264
|
for (const relPath of relativePaths) {
|
|
4447
5265
|
const filePath = join(rootDir, relPath);
|
|
@@ -4452,8 +5270,9 @@ async function scanSourceFiles(rootDir, excludeDirs, patterns) {
|
|
|
4452
5270
|
log.warn(`Failed to read file: ${filePath}`);
|
|
4453
5271
|
continue;
|
|
4454
5272
|
}
|
|
4455
|
-
const constTable = pat.
|
|
4456
|
-
const { usages, dynamicKeys, bareStringCandidates: bareFromCalls } =
|
|
5273
|
+
const constTable = (pat.bareShapes ?? "js") === "js" ? collectConstKeyTable(content) : /* @__PURE__ */ new Map();
|
|
5274
|
+
const { usages, dynamicKeys, bareStringCandidates: bareFromCalls, declined } = await extractFileEvidence(content, filePath, active, pat);
|
|
5275
|
+
if (declined) declinedFiles.push(relPath);
|
|
4457
5276
|
allUsages.push(...usages);
|
|
4458
5277
|
allDynamicKeys.push(...dynamicKeys);
|
|
4459
5278
|
for (const candidate of bareFromCalls) bareStringCandidates.add(candidate);
|
|
@@ -4466,6 +5285,7 @@ async function scanSourceFiles(rootDir, excludeDirs, patterns) {
|
|
|
4466
5285
|
usages: allUsages,
|
|
4467
5286
|
dynamicKeys: allDynamicKeys,
|
|
4468
5287
|
filesScanned,
|
|
5288
|
+
declinedFiles,
|
|
4469
5289
|
uniqueKeys,
|
|
4470
5290
|
bareStringCandidates,
|
|
4471
5291
|
bareDynamicCandidates
|
|
@@ -4530,10 +5350,12 @@ async function findOrphanKeysForConfig(options) {
|
|
|
4530
5350
|
const evidences = [];
|
|
4531
5351
|
const allDynamicKeysRaw = [];
|
|
4532
5352
|
let totalFilesScanned = 0;
|
|
5353
|
+
let totalFilesDeclined = 0;
|
|
4533
5354
|
for (const unit of units) {
|
|
4534
5355
|
const ignores = globalScope ? [] : nestedUnitIgnores(unit, units);
|
|
4535
5356
|
const result = await scanSourceFiles(unit.dir, [...excludeDirs ?? [], ...ignores], patterns);
|
|
4536
5357
|
totalFilesScanned += result.filesScanned;
|
|
5358
|
+
totalFilesDeclined += result.declinedFiles.length;
|
|
4537
5359
|
const dynamicRaw = [...result.dynamicKeys, ...[...result.bareDynamicCandidates].map((bd) => ({
|
|
4538
5360
|
expression: bd,
|
|
4539
5361
|
file: "",
|
|
@@ -4578,6 +5400,8 @@ async function findOrphanKeysForConfig(options) {
|
|
|
4578
5400
|
const unitDynRegexes = (evidence) => evidence.dynRegexes ??= buildDynamicKeyRegexes(evidence.dynamicRaw);
|
|
4579
5401
|
const orphansByLayer = {};
|
|
4580
5402
|
let orphanCount = 0;
|
|
5403
|
+
const candidateOnlyByLayer = {};
|
|
5404
|
+
let candidateOnlyCount = 0;
|
|
4581
5405
|
const uncertainByLayer = {};
|
|
4582
5406
|
let uncertainCount = 0;
|
|
4583
5407
|
let dynamicMatchedCount = 0;
|
|
@@ -4592,9 +5416,13 @@ async function findOrphanKeysForConfig(options) {
|
|
|
4592
5416
|
const outOfScope = globalScope ? [] : evidences.filter((e) => !scopeNameSet.has(e.unit.name));
|
|
4593
5417
|
const ignorePatterns = resolveIgnorePatterns(layerName);
|
|
4594
5418
|
const ignoreRegexes = ignorePatterns ? buildIgnorePatternRegexes(ignorePatterns) : [];
|
|
5419
|
+
const candidateOnly = [];
|
|
4595
5420
|
const orphans = keys.filter((k) => {
|
|
4596
5421
|
if (scope.unique.has(k)) return false;
|
|
4597
|
-
if (scope.bare.has(k))
|
|
5422
|
+
if (scope.bare.has(k)) {
|
|
5423
|
+
if (!scope.dynRegexes.some((re) => re.test(k))) candidateOnly.push(k);
|
|
5424
|
+
return false;
|
|
5425
|
+
}
|
|
4598
5426
|
if (scope.dynRegexes.some((re) => re.test(k))) {
|
|
4599
5427
|
dynamicMatchedCount++;
|
|
4600
5428
|
return false;
|
|
@@ -4628,6 +5456,10 @@ async function findOrphanKeysForConfig(options) {
|
|
|
4628
5456
|
uncertainByLayer[layerName] = uncertain;
|
|
4629
5457
|
uncertainCount += uncertain.length;
|
|
4630
5458
|
}
|
|
5459
|
+
if (candidateOnly.length > 0) {
|
|
5460
|
+
candidateOnlyByLayer[layerName] = candidateOnly.sort();
|
|
5461
|
+
candidateOnlyCount += candidateOnly.length;
|
|
5462
|
+
}
|
|
4631
5463
|
}
|
|
4632
5464
|
misplacedUsages.sort((a, b) => a.layer.localeCompare(b.layer) || a.key.localeCompare(b.key));
|
|
4633
5465
|
const byLocation = (a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.expression.localeCompare(b.expression);
|
|
@@ -4638,7 +5470,10 @@ async function findOrphanKeysForConfig(options) {
|
|
|
4638
5470
|
orphanCount,
|
|
4639
5471
|
uncertainByLayer,
|
|
4640
5472
|
uncertainCount,
|
|
5473
|
+
candidateOnlyByLayer,
|
|
5474
|
+
candidateOnlyCount,
|
|
4641
5475
|
totalFilesScanned,
|
|
5476
|
+
totalFilesDeclined,
|
|
4642
5477
|
dynamicMatchedCount,
|
|
4643
5478
|
ignoredCount,
|
|
4644
5479
|
allDynamicKeys: allDynamicKeysRaw,
|
|
@@ -5099,6 +5934,147 @@ async function scaffoldLocaleFiles(opts) {
|
|
|
5099
5934
|
dryRun: opts.dryRun ?? false
|
|
5100
5935
|
};
|
|
5101
5936
|
}
|
|
5937
|
+
/**
|
|
5938
|
+
* Move a key from one layer to another, carrying every locale that defines it.
|
|
5939
|
+
*
|
|
5940
|
+
* Promoting an app-layer key to the shared layer once a second app needs it is
|
|
5941
|
+
* a first-class operation in a layered monorepo, and composing it out of
|
|
5942
|
+
* get/write/remove is three calls across up to thirty locales with no way to
|
|
5943
|
+
* fail cleanly: a truncation between the write and the remove leaves the key in
|
|
5944
|
+
* both layers, which is the state `find_duplicate_keys` exists to flag (#341).
|
|
5945
|
+
*
|
|
5946
|
+
* So the whole move is planned before anything is written. A target that
|
|
5947
|
+
* already holds a *different* value is a conflict, and one conflict in one
|
|
5948
|
+
* locale writes nothing at all — a half-moved key across thirty files is worse
|
|
5949
|
+
* than a refusal. A target already holding the *same* value is not a conflict
|
|
5950
|
+
* but a duplicate the move resolves: the source copy is dropped and the locale
|
|
5951
|
+
* is reported as deduplicated.
|
|
5952
|
+
*
|
|
5953
|
+
* Locales come from the resolved config rather than from caller-supplied refs,
|
|
5954
|
+
* so there is no ref to leave unresolved (#301) — a locale the source layer
|
|
5955
|
+
* does not define is reported in `notFoundInLocales` rather than skipped
|
|
5956
|
+
* silently.
|
|
5957
|
+
*/
|
|
5958
|
+
async function moveTranslationKey(opts) {
|
|
5959
|
+
const { fromLayer, toLayer, key } = opts;
|
|
5960
|
+
const targetKey = opts.newKey ?? key;
|
|
5961
|
+
const config = await detectI18nConfig(opts.projectDir ?? process.cwd());
|
|
5962
|
+
if (fromLayer === toLayer) throw new ToolError(`fromLayer and toLayer are both "${fromLayer}". To rename a key within one layer, use rename_translation_key.`, "SAME_LAYER");
|
|
5963
|
+
findWritableLayerOrThrow(config, fromLayer);
|
|
5964
|
+
findWritableLayerOrThrow(config, toLayer);
|
|
5965
|
+
const { plan, notFound, conflicts } = await planMove(config, {
|
|
5966
|
+
fromLayer,
|
|
5967
|
+
toLayer,
|
|
5968
|
+
key,
|
|
5969
|
+
targetKey
|
|
5970
|
+
});
|
|
5971
|
+
const identity = {
|
|
5972
|
+
fromLayer,
|
|
5973
|
+
toLayer,
|
|
5974
|
+
key,
|
|
5975
|
+
...opts.newKey ? { newKey: opts.newKey } : {},
|
|
5976
|
+
...notFound.length > 0 ? { notFoundInLocales: notFound } : {}
|
|
5977
|
+
};
|
|
5978
|
+
if (conflicts.length > 0) return {
|
|
5979
|
+
...identity,
|
|
5980
|
+
conflictsInLocales: conflicts,
|
|
5981
|
+
summary: {
|
|
5982
|
+
localesAffected: 0,
|
|
5983
|
+
message: "Nothing was written.",
|
|
5984
|
+
warning: `"${targetKey}" already exists in "${toLayer}" with a different value in ${conflicts.length} locale(s). Resolve those locales first — reconcile the values, or move to a key that does not collide.`
|
|
5985
|
+
}
|
|
5986
|
+
};
|
|
5987
|
+
if (opts.dryRun ?? false) return {
|
|
5988
|
+
dryRun: true,
|
|
5989
|
+
wouldMove: plan,
|
|
5990
|
+
...identity,
|
|
5991
|
+
summary: {
|
|
5992
|
+
localesAffected: plan.length,
|
|
5993
|
+
message: "Call again with dryRun: false to apply these changes."
|
|
5994
|
+
}
|
|
5995
|
+
};
|
|
5996
|
+
const applied = await applyMove(config, {
|
|
5997
|
+
fromLayer,
|
|
5998
|
+
toLayer,
|
|
5999
|
+
key,
|
|
6000
|
+
targetKey
|
|
6001
|
+
}, plan);
|
|
6002
|
+
return {
|
|
6003
|
+
movedLocales: applied.moved,
|
|
6004
|
+
...applied.deduplicated.length > 0 ? { deduplicatedLocales: applied.deduplicated } : {},
|
|
6005
|
+
filesWritten: applied.filesWritten,
|
|
6006
|
+
...identity
|
|
6007
|
+
};
|
|
6008
|
+
}
|
|
6009
|
+
/**
|
|
6010
|
+
* Decide every locale's outcome before any of them is written, so that one
|
|
6011
|
+
* conflicting locale can stop the whole move rather than half of it.
|
|
6012
|
+
*/
|
|
6013
|
+
async function planMove(config, { fromLayer, toLayer, key, targetKey }) {
|
|
6014
|
+
const plan = [];
|
|
6015
|
+
const notFound = [];
|
|
6016
|
+
const conflicts = [];
|
|
6017
|
+
for (const locale of config.locales) {
|
|
6018
|
+
const source = await readLocaleDataIfPresent(config, fromLayer, locale);
|
|
6019
|
+
const value = source ? getNestedValue(source, key) : void 0;
|
|
6020
|
+
if (value === void 0) {
|
|
6021
|
+
notFound.push(locale.code);
|
|
6022
|
+
continue;
|
|
6023
|
+
}
|
|
6024
|
+
const target = await readLocaleDataIfPresent(config, toLayer, locale);
|
|
6025
|
+
const existing = target ? getNestedValue(target, targetKey) : void 0;
|
|
6026
|
+
if (existing === void 0) plan.push({
|
|
6027
|
+
locale: locale.code,
|
|
6028
|
+
value,
|
|
6029
|
+
action: "move"
|
|
6030
|
+
});
|
|
6031
|
+
else if (sameTranslation(existing, value)) plan.push({
|
|
6032
|
+
locale: locale.code,
|
|
6033
|
+
value,
|
|
6034
|
+
action: "deduplicate"
|
|
6035
|
+
});
|
|
6036
|
+
else conflicts.push(locale.code);
|
|
6037
|
+
}
|
|
6038
|
+
return {
|
|
6039
|
+
plan,
|
|
6040
|
+
notFound,
|
|
6041
|
+
conflicts
|
|
6042
|
+
};
|
|
6043
|
+
}
|
|
6044
|
+
/** Execute an already-validated plan. */
|
|
6045
|
+
async function applyMove(config, { fromLayer, toLayer, key, targetKey }, plan) {
|
|
6046
|
+
const moved = [];
|
|
6047
|
+
const deduplicated = [];
|
|
6048
|
+
const filesWritten = /* @__PURE__ */ new Set();
|
|
6049
|
+
for (const entry of plan) {
|
|
6050
|
+
const locale = findLocaleImpl(config, entry.locale);
|
|
6051
|
+
if (!locale) continue;
|
|
6052
|
+
if (entry.action === "move") {
|
|
6053
|
+
for (const file of await mutateLocaleData(config, toLayer, locale, (data) => {
|
|
6054
|
+
setNestedValue(data, targetKey, entry.value);
|
|
6055
|
+
})) filesWritten.add(file);
|
|
6056
|
+
moved.push(entry.locale);
|
|
6057
|
+
} else deduplicated.push(entry.locale);
|
|
6058
|
+
for (const file of await mutateLocaleData(config, fromLayer, locale, (data) => {
|
|
6059
|
+
removeNestedValue(data, key);
|
|
6060
|
+
})) filesWritten.add(file);
|
|
6061
|
+
}
|
|
6062
|
+
return {
|
|
6063
|
+
moved,
|
|
6064
|
+
deduplicated,
|
|
6065
|
+
filesWritten: filesWritten.size
|
|
6066
|
+
};
|
|
6067
|
+
}
|
|
6068
|
+
/**
|
|
6069
|
+
* Whether the target already holds what the move would write. Values are
|
|
6070
|
+
* usually strings, but a key can name a whole namespace object, so this
|
|
6071
|
+
* compares structurally rather than by identity.
|
|
6072
|
+
*/
|
|
6073
|
+
function sameTranslation(a, b) {
|
|
6074
|
+
if (a === b) return true;
|
|
6075
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
6076
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
6077
|
+
}
|
|
5102
6078
|
//#endregion
|
|
5103
6079
|
//#region src/core/ops-init.ts
|
|
5104
6080
|
/**
|
|
@@ -5545,6 +6521,7 @@ function referenceLocaleAnchorPaths(config, layers, locale, projectDir) {
|
|
|
5545
6521
|
* referenced in source code, plus code-usage scanning.
|
|
5546
6522
|
*/
|
|
5547
6523
|
const MISPLACED_USAGE_NOTE = "Keys referenced only from apps that do not consume their layer. Either the key belongs in a broader (shared) layer, or the usage is a bug. These keys are not counted as orphans and are never removed.";
|
|
6524
|
+
const CANDIDATE_ONLY_NOTE = "These keys are protected only by the bare-candidate net: either a dotted string somewhere merely shares their name (often a comment or a data structure), or a call too ambiguous to commit to references them (a bare t(...) that could be anything). They are not offered for removal, but dead references hide here - verify before pruning.";
|
|
5548
6525
|
/** True when `child` equals `parent` or lies inside it. */
|
|
5549
6526
|
function isWithin(child, parent) {
|
|
5550
6527
|
const rel = relative(parent, child);
|
|
@@ -5763,17 +6740,21 @@ async function findOrphanKeys(opts) {
|
|
|
5763
6740
|
const output = {
|
|
5764
6741
|
orphanKeys: sortedByLayer,
|
|
5765
6742
|
uncertainKeys: orphanResult.uncertainCount > 0 ? orphanResult.uncertainByLayer : void 0,
|
|
6743
|
+
candidateOnlyKeys: orphanResult.candidateOnlyCount > 0 ? orphanResult.candidateOnlyByLayer : void 0,
|
|
6744
|
+
candidateOnlyNote: orphanResult.candidateOnlyCount > 0 ? CANDIDATE_ONLY_NOTE : void 0,
|
|
5766
6745
|
misplacedUsages: misplacedCount > 0 ? orphanResult.misplacedUsages : void 0,
|
|
5767
6746
|
misplacedUsageNote: misplacedCount > 0 ? MISPLACED_USAGE_NOTE : void 0,
|
|
5768
6747
|
summary: {
|
|
5769
6748
|
totalKeys,
|
|
5770
6749
|
orphanCount: orphanResult.orphanCount,
|
|
5771
6750
|
uncertainCount: orphanResult.uncertainCount,
|
|
6751
|
+
candidateOnlyCount: orphanResult.candidateOnlyCount,
|
|
5772
6752
|
misplacedCount,
|
|
5773
6753
|
dynamicMatchedCount: orphanResult.dynamicMatchedCount,
|
|
5774
6754
|
ignoredCount: orphanResult.ignoredCount,
|
|
5775
6755
|
usedCount: totalKeys - orphanResult.orphanCount - orphanResult.uncertainCount - misplacedCount,
|
|
5776
6756
|
filesScanned: orphanResult.totalFilesScanned,
|
|
6757
|
+
filesDeclined: orphanResult.totalFilesDeclined,
|
|
5777
6758
|
layersChecked: layersToCheck.map((d) => d.layer),
|
|
5778
6759
|
dirsScanned: orphanResult.dirsScanned,
|
|
5779
6760
|
scanScope: relativeScanScope(orphanResult, dir),
|
|
@@ -5818,9 +6799,11 @@ async function scanCodeUsage(opts) {
|
|
|
5818
6799
|
const allUsages = [];
|
|
5819
6800
|
const allDynamicKeys = [];
|
|
5820
6801
|
let totalFilesScanned = 0;
|
|
6802
|
+
let totalFilesDeclined = 0;
|
|
5821
6803
|
for (const scanDir of dirsToScan) {
|
|
5822
6804
|
const result = await scanSourceFiles(scanDir, excludeDirs, getPatternSet(config.localeFileFormat));
|
|
5823
6805
|
totalFilesScanned += result.filesScanned;
|
|
6806
|
+
totalFilesDeclined += result.declinedFiles.length;
|
|
5824
6807
|
allUsages.push(...result.usages);
|
|
5825
6808
|
allDynamicKeys.push(...result.dynamicKeys);
|
|
5826
6809
|
}
|
|
@@ -5840,6 +6823,7 @@ async function scanCodeUsage(opts) {
|
|
|
5840
6823
|
uniqueKeysFound: Object.keys(sortedByKey).length,
|
|
5841
6824
|
totalReferences: filteredUsages.length,
|
|
5842
6825
|
filesScanned: totalFilesScanned,
|
|
6826
|
+
filesDeclined: totalFilesDeclined,
|
|
5843
6827
|
dirsScanned: dirsToScan
|
|
5844
6828
|
}
|
|
5845
6829
|
};
|
|
@@ -6084,6 +7068,26 @@ async function removeOrphanKeys(opts) {
|
|
|
6084
7068
|
* Cross-layer duplicate-key detection: keys defined in both a shared layer
|
|
6085
7069
|
* and a consuming child layer, compared in one reference locale.
|
|
6086
7070
|
*/
|
|
7071
|
+
/**
|
|
7072
|
+
* Values shorter than this are excluded from value grouping. "Ja", "OK" and
|
|
7073
|
+
* "Nein" repeat across unrelated namespaces legitimately, and reporting them
|
|
7074
|
+
* buries the findings worth acting on. A length floor is a blunt rule, which
|
|
7075
|
+
* is the point — anything cleverer would be guessing at intent.
|
|
7076
|
+
*/
|
|
7077
|
+
const DEFAULT_MIN_VALUE_LENGTH = 4;
|
|
7078
|
+
/**
|
|
7079
|
+
* A floor arrives as a CLI string or an MCP number, so it can be NaN or
|
|
7080
|
+
* negative by the time it lands here. Comparing a length against NaN is always
|
|
7081
|
+
* false, which silently removes the floor and buries the report in "OK" — the
|
|
7082
|
+
* opposite of what asking for a floor means. Saying so beats defaulting: the
|
|
7083
|
+
* caller asked for a specific threshold and would not learn it was ignored.
|
|
7084
|
+
*/
|
|
7085
|
+
function resolveMinValueLength(requested) {
|
|
7086
|
+
if (requested === void 0) return DEFAULT_MIN_VALUE_LENGTH;
|
|
7087
|
+
if (!Number.isFinite(requested) || requested < 0) throw new ToolError(`minValueLength must be a non-negative number, got ${String(requested)}.`, "INVALID_MIN_VALUE_LENGTH");
|
|
7088
|
+
return Math.floor(requested);
|
|
7089
|
+
}
|
|
7090
|
+
const VALUE_DUPLICATE_GUIDANCE = "Different keys carrying the same value. \"reuse\" means a shared layer already defines this value: delete the app-layer keys and repoint their call sites at the shared key. \"promote\" means two or more app layers define it and no shared layer does: move one to a shared layer (move_translation_key) and repoint the rest. \"consolidate\" is duplication inside one layer. Each duplicate is translated into every locale separately, so removing one saves provider spend on every future translate run, not just tidiness. Short generic labels (\"Name\", \"Status\") dominate the list by group size and are the least worth acting on individually; raise minValueLength to see the longer copy, where duplication is rarely deliberate.";
|
|
6087
7091
|
const DUPLICATE_GUIDANCE = "At runtime the child layer's value shadows the shared layer's value for the same key. Fix each collision by deleting one side — usually the shared copy when the child value is authoritative, or the child copy to fall through to the shared value. Never move the key: both layers already define it.";
|
|
6088
7092
|
/** Leaf values may be arrays (getLeafKeys treats them as leaves) — compare
|
|
6089
7093
|
* those structurally; identity covers primitives. */
|
|
@@ -6137,25 +7141,75 @@ function emptyPairsMessage(config) {
|
|
|
6137
7141
|
return "No (shared layer, child layer) pairs to check — no app consumes more than one locale-backed layer.";
|
|
6138
7142
|
}
|
|
6139
7143
|
/**
|
|
6140
|
-
*
|
|
6141
|
-
*
|
|
6142
|
-
*
|
|
7144
|
+
* Group keys by the value they carry, so that two keys spelling the same
|
|
7145
|
+
* string differently are visible as the duplication they are.
|
|
7146
|
+
*
|
|
7147
|
+
* Key-path collision detection cannot see this: `common.actions.save` and
|
|
7148
|
+
* `calendar.views.save` collide on nothing while both holding "Speichern"
|
|
7149
|
+
* (#343). Each duplicate is then translated into every locale independently,
|
|
7150
|
+
* so the duplication costs provider spend on every run, not just tidiness.
|
|
6143
7151
|
*/
|
|
6144
|
-
|
|
6145
|
-
const
|
|
6146
|
-
const
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
6154
|
-
|
|
6155
|
-
|
|
6156
|
-
|
|
6157
|
-
|
|
7152
|
+
function groupByValue(entries, sharedLayers, minValueLength) {
|
|
7153
|
+
const byNormalized = /* @__PURE__ */ new Map();
|
|
7154
|
+
for (const entry of entries) {
|
|
7155
|
+
if (typeof entry.value !== "string") continue;
|
|
7156
|
+
const normalized = normalizeValue(entry.value);
|
|
7157
|
+
if (normalized.length < minValueLength) continue;
|
|
7158
|
+
const group = byNormalized.get(normalized) ?? {
|
|
7159
|
+
value: entry.value,
|
|
7160
|
+
normalized,
|
|
7161
|
+
action: "consolidate",
|
|
7162
|
+
members: []
|
|
7163
|
+
};
|
|
7164
|
+
group.members.push({
|
|
7165
|
+
key: entry.key,
|
|
7166
|
+
layer: entry.layer,
|
|
7167
|
+
shared: sharedLayers.has(entry.layer)
|
|
7168
|
+
});
|
|
7169
|
+
byNormalized.set(normalized, group);
|
|
7170
|
+
}
|
|
7171
|
+
const groups = [...byNormalized.values()].filter((group) => group.members.length > 1 && new Set(group.members.map((m) => m.key)).size > 1);
|
|
7172
|
+
for (const group of groups) group.action = classifyGroup(group.members);
|
|
7173
|
+
const rank = {
|
|
7174
|
+
reuse: 0,
|
|
7175
|
+
promote: 1,
|
|
7176
|
+
consolidate: 2
|
|
6158
7177
|
};
|
|
7178
|
+
return groups.sort((a, b) => rank[a.action] - rank[b.action] || b.members.length - a.members.length || a.normalized.localeCompare(b.normalized));
|
|
7179
|
+
}
|
|
7180
|
+
function classifyGroup(members) {
|
|
7181
|
+
const inShared = members.filter((m) => m.shared);
|
|
7182
|
+
if (inShared.length > 0 && inShared.length < members.length) return "reuse";
|
|
7183
|
+
if (new Set(members.map((m) => m.layer)).size > 1) return "promote";
|
|
7184
|
+
return "consolidate";
|
|
7185
|
+
}
|
|
7186
|
+
/**
|
|
7187
|
+
* Fold away the differences that do not change what a translator would write:
|
|
7188
|
+
* surrounding space, capitalisation, internal whitespace runs and trailing
|
|
7189
|
+
* punctuation. "Speichern", "speichern " and "Speichern." group together.
|
|
7190
|
+
*/
|
|
7191
|
+
function normalizeValue(value) {
|
|
7192
|
+
return value.trim().replace(/\s+/g, " ").replace(/[.!?:;,\u2026]+$/u, "").toLowerCase();
|
|
7193
|
+
}
|
|
7194
|
+
/** Every leaf key of every canonical layer, minus the ones config says to ignore. */
|
|
7195
|
+
async function collectLayerEntries(config, layers, dataFor) {
|
|
7196
|
+
const entries = [];
|
|
7197
|
+
for (const layer of layers) {
|
|
7198
|
+
const data = await dataFor(layer.layer);
|
|
7199
|
+
const ignore = buildIgnorePatternRegexes(resolveOrphanIgnorePatterns(config, layer.layer) ?? []);
|
|
7200
|
+
for (const key of getLeafKeys(data)) {
|
|
7201
|
+
if (ignore.some((re) => re.test(key))) continue;
|
|
7202
|
+
entries.push({
|
|
7203
|
+
key,
|
|
7204
|
+
layer: layer.layer,
|
|
7205
|
+
value: getNestedValue(data, key)
|
|
7206
|
+
});
|
|
7207
|
+
}
|
|
7208
|
+
}
|
|
7209
|
+
return entries;
|
|
7210
|
+
}
|
|
7211
|
+
/** Keys defined on both sides of each (shared, child) pair, in one locale. */
|
|
7212
|
+
async function findCollisions(pairs, dataFor) {
|
|
6159
7213
|
const collisions = [];
|
|
6160
7214
|
for (const { shared, child } of pairs) {
|
|
6161
7215
|
const sharedData = await dataFor(shared.layer);
|
|
@@ -6175,23 +7229,57 @@ async function findDuplicateKeys(opts = {}) {
|
|
|
6175
7229
|
});
|
|
6176
7230
|
}
|
|
6177
7231
|
}
|
|
7232
|
+
return collisions;
|
|
7233
|
+
}
|
|
7234
|
+
/**
|
|
7235
|
+
* Find keys defined in both a shared layer and a consuming child layer,
|
|
7236
|
+
* comparing values in a single reference locale (default: the project
|
|
7237
|
+
* default locale). Pure locale-file I/O — no source scanning.
|
|
7238
|
+
*/
|
|
7239
|
+
async function findDuplicateKeys(opts = {}) {
|
|
7240
|
+
const dir = opts.projectDir ?? process.cwd();
|
|
7241
|
+
const config = await detectI18nConfig(dir);
|
|
7242
|
+
const locale = opts.locale ? findLocaleOrThrow(config, opts.locale) : findLocaleImpl(config, config.defaultLocale) ?? config.locales[0];
|
|
7243
|
+
if (!locale) throw new ToolError("No locales found in configuration.", "LOCALE_NOT_FOUND");
|
|
7244
|
+
const graph = buildLayerGraph(config);
|
|
7245
|
+
const pairs = deriveLayerPairs(config, graph);
|
|
7246
|
+
const layerDataCache = /* @__PURE__ */ new Map();
|
|
7247
|
+
const dataFor = (layer) => {
|
|
7248
|
+
let cached = layerDataCache.get(layer);
|
|
7249
|
+
if (!cached) {
|
|
7250
|
+
cached = readLocaleData(config, layer, locale);
|
|
7251
|
+
layerDataCache.set(layer, cached);
|
|
7252
|
+
}
|
|
7253
|
+
return cached;
|
|
7254
|
+
};
|
|
7255
|
+
const collisions = await findCollisions(pairs, dataFor);
|
|
7256
|
+
const valueDuplicates = opts.byValue ? groupByValue(await collectLayerEntries(config, graph.canonicalLayers, dataFor), new Set(pairs.map((pair) => pair.shared.layer)), resolveMinValueLength(opts.minValueLength)) : void 0;
|
|
6178
7257
|
const summary = {
|
|
6179
7258
|
totalCollisions: collisions.length,
|
|
6180
7259
|
divergentCount: collisions.filter((c) => c.divergent).length,
|
|
6181
7260
|
pairsChecked: pairs.length,
|
|
6182
7261
|
locale: locale.code,
|
|
7262
|
+
...valueDuplicates ? {
|
|
7263
|
+
valueGroups: valueDuplicates.length,
|
|
7264
|
+
reusableGroups: valueDuplicates.filter((g) => g.action === "reuse").length
|
|
7265
|
+
} : {},
|
|
6183
7266
|
...pairs.length === 0 ? { message: emptyPairsMessage(config) } : {}
|
|
6184
7267
|
};
|
|
6185
7268
|
const output = {
|
|
6186
7269
|
collisions,
|
|
6187
|
-
|
|
7270
|
+
...valueDuplicates ? { valueDuplicates } : {},
|
|
7271
|
+
guidance: valueDuplicates ? `${DUPLICATE_GUIDANCE}\n\n${VALUE_DUPLICATE_GUIDANCE}` : DUPLICATE_GUIDANCE,
|
|
6188
7272
|
summary
|
|
6189
7273
|
};
|
|
6190
7274
|
const reportPath = resolveOutputFile(dir, opts.outputFile) ?? resolveReportFilePath(config, dir, "find_duplicate_keys");
|
|
6191
7275
|
if (reportPath) {
|
|
6192
7276
|
await writeReportFile(reportPath, output, {
|
|
6193
7277
|
tool: "find_duplicate_keys",
|
|
6194
|
-
args: {
|
|
7278
|
+
args: {
|
|
7279
|
+
locale: opts.locale,
|
|
7280
|
+
byValue: opts.byValue,
|
|
7281
|
+
minValueLength: opts.minValueLength
|
|
7282
|
+
}
|
|
6195
7283
|
});
|
|
6196
7284
|
return {
|
|
6197
7285
|
reportFile: reportPath,
|
|
@@ -6400,11 +7488,13 @@ async function checkUndefinedKeys(opts = {}) {
|
|
|
6400
7488
|
const searchedLayersByApp = {};
|
|
6401
7489
|
const checkedKeys = /* @__PURE__ */ new Set();
|
|
6402
7490
|
let filesScanned = 0;
|
|
7491
|
+
let filesDeclined = 0;
|
|
6403
7492
|
let ignoredCount = 0;
|
|
6404
7493
|
for (const unit of units) {
|
|
6405
7494
|
const ignores = globalScope ? [] : nestedUnitIgnores(unit, units);
|
|
6406
7495
|
const scan = await scanSourceFiles(unit.dir, [...opts.excludeDirs ?? [], ...ignores], patterns);
|
|
6407
7496
|
filesScanned += scan.filesScanned;
|
|
7497
|
+
filesDeclined += scan.declinedFiles.length;
|
|
6408
7498
|
const searchedLayers = layersForUnit(unit.name);
|
|
6409
7499
|
searchedLayersByApp[unit.name] = searchedLayers;
|
|
6410
7500
|
const outcome = classifyUnitUsages(scan, {
|
|
@@ -6427,6 +7517,7 @@ async function checkUndefinedKeys(opts = {}) {
|
|
|
6427
7517
|
uncertainCount: uncertainKeys.length,
|
|
6428
7518
|
ignoredCount,
|
|
6429
7519
|
filesScanned,
|
|
7520
|
+
filesDeclined,
|
|
6430
7521
|
locale: localeCode,
|
|
6431
7522
|
searchedLayersByApp,
|
|
6432
7523
|
message: buildCheckMessage(undefinedKeys.length, uncertainKeys.length)
|
|
@@ -6459,6 +7550,6 @@ async function checkUndefinedKeys(opts = {}) {
|
|
|
6459
7550
|
return output;
|
|
6460
7551
|
}
|
|
6461
7552
|
//#endregion
|
|
6462
|
-
export {
|
|
7553
|
+
export { resolveProtectedLocales as A, detectConfig as C, listLocaleDirs as D, getTranslations as E, serializeLayerGraph as F, clearConfigCache as I, detectI18nConfig as L, translateMissing as M, readLocaleData as N, listNamespaces as O, buildLayerGraph as P, getCachedConfig as R, createOxcFrontend as S, getMissingTranslations as T, getPatternSet as _, scanCodeUsage as a, LARAVEL_PATTERNS as b, addTranslations as c, renameTranslationKey as d, scaffoldLocaleFiles as f, VUE_NUXT_PATTERNS as g, scanSourceFiles as h, removeOrphanKeys as i, translateKey as j, searchTranslations as k, moveTranslationKey as l, writeTranslations as m, findDuplicateKeys as n, getTranslationStatus as o, updateTranslations as p, findOrphanKeys as r, initProjectConfig as s, checkUndefinedKeys as t, removeTranslations as u, createPatternsFrontend as v, findEmptyTranslations as w, createPhpFrontend as x, createBladeFrontend as y, findLocaleImpl as z };
|
|
6463
7554
|
|
|
6464
|
-
//# sourceMappingURL=operations-
|
|
7555
|
+
//# sourceMappingURL=operations-CTo44gPu.js.map
|