@gtkx/cli 1.6.0 → 2.0.0-beta.2

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.
Files changed (309) hide show
  1. package/README.md +5 -5
  2. package/dist/builder.d.ts +1 -0
  3. package/dist/builder.d.ts.map +1 -1
  4. package/dist/builder.js +17 -5
  5. package/dist/builder.js.map +1 -1
  6. package/dist/cli.js +2 -0
  7. package/dist/cli.js.map +1 -1
  8. package/dist/codegen/freshness.d.ts.map +1 -1
  9. package/dist/codegen/freshness.js +45 -16
  10. package/dist/codegen/freshness.js.map +1 -1
  11. package/dist/codegen/reference.d.ts.map +1 -1
  12. package/dist/codegen/reference.js +1 -7
  13. package/dist/codegen/reference.js.map +1 -1
  14. package/dist/codegen/report.d.ts.map +1 -1
  15. package/dist/codegen/report.js +5 -16
  16. package/dist/codegen/report.js.map +1 -1
  17. package/dist/codegen/run-codegen.d.ts +7 -7
  18. package/dist/codegen/run-codegen.d.ts.map +1 -1
  19. package/dist/codegen/run-codegen.js +35 -107
  20. package/dist/codegen/run-codegen.js.map +1 -1
  21. package/dist/codegen/store-resolver.d.ts +1 -2
  22. package/dist/codegen/store-resolver.d.ts.map +1 -1
  23. package/dist/codegen/store-resolver.js +10 -19
  24. package/dist/codegen/store-resolver.js.map +1 -1
  25. package/dist/commands/build.d.ts +5 -0
  26. package/dist/commands/build.d.ts.map +1 -1
  27. package/dist/commands/build.js +83 -10
  28. package/dist/commands/build.js.map +1 -1
  29. package/dist/commands/codegen.d.ts +1 -0
  30. package/dist/commands/codegen.d.ts.map +1 -1
  31. package/dist/commands/codegen.js +6 -5
  32. package/dist/commands/codegen.js.map +1 -1
  33. package/dist/commands/deploy.d.ts +1 -0
  34. package/dist/commands/deploy.d.ts.map +1 -1
  35. package/dist/commands/deploy.js +4 -2
  36. package/dist/commands/deploy.js.map +1 -1
  37. package/dist/commands/dev.d.ts +8 -0
  38. package/dist/commands/dev.d.ts.map +1 -1
  39. package/dist/commands/dev.js +19 -1
  40. package/dist/commands/dev.js.map +1 -1
  41. package/dist/commands/docs.d.ts.map +1 -1
  42. package/dist/commands/docs.js +5 -15
  43. package/dist/commands/docs.js.map +1 -1
  44. package/dist/deploy/freedesktop/copyright.d.ts.map +1 -1
  45. package/dist/deploy/freedesktop/copyright.js +2 -13
  46. package/dist/deploy/freedesktop/copyright.js.map +1 -1
  47. package/dist/deploy/freedesktop/metainfo.d.ts.map +1 -1
  48. package/dist/deploy/freedesktop/metainfo.js +41 -2
  49. package/dist/deploy/freedesktop/metainfo.js.map +1 -1
  50. package/dist/deploy/freedesktop/xml.d.ts.map +1 -1
  51. package/dist/deploy/freedesktop/xml.js +21 -25
  52. package/dist/deploy/freedesktop/xml.js.map +1 -1
  53. package/dist/deploy/nfpm/config.d.ts.map +1 -1
  54. package/dist/deploy/nfpm/config.js +5 -2
  55. package/dist/deploy/nfpm/config.js.map +1 -1
  56. package/dist/deploy/nfpm/contents.d.ts.map +1 -1
  57. package/dist/deploy/nfpm/contents.js +8 -0
  58. package/dist/deploy/nfpm/contents.js.map +1 -1
  59. package/dist/deploy/node-runtime/elf.d.ts +2 -1
  60. package/dist/deploy/node-runtime/elf.d.ts.map +1 -1
  61. package/dist/deploy/node-runtime/elf.js +32 -5
  62. package/dist/deploy/node-runtime/elf.js.map +1 -1
  63. package/dist/deploy/node-runtime/index.d.ts +2 -1
  64. package/dist/deploy/node-runtime/index.d.ts.map +1 -1
  65. package/dist/deploy/node-runtime/index.js +73 -4
  66. package/dist/deploy/node-runtime/index.js.map +1 -1
  67. package/dist/deploy/node-runtime/license.d.ts.map +1 -1
  68. package/dist/deploy/node-runtime/license.js +2 -9
  69. package/dist/deploy/node-runtime/license.js.map +1 -1
  70. package/dist/deploy/notices/node-runtime.js +2 -2
  71. package/dist/deploy/notices/node-runtime.js.map +1 -1
  72. package/dist/deploy/notices/packages.d.ts.map +1 -1
  73. package/dist/deploy/notices/packages.js +3 -10
  74. package/dist/deploy/notices/packages.js.map +1 -1
  75. package/dist/deploy/payload/copy-tree.d.ts.map +1 -1
  76. package/dist/deploy/payload/copy-tree.js +3 -3
  77. package/dist/deploy/payload/copy-tree.js.map +1 -1
  78. package/dist/deploy/payload/launcher.d.ts.map +1 -1
  79. package/dist/deploy/payload/launcher.js +14 -1
  80. package/dist/deploy/payload/launcher.js.map +1 -1
  81. package/dist/deploy/payload/stage.d.ts.map +1 -1
  82. package/dist/deploy/payload/stage.js +2 -4
  83. package/dist/deploy/payload/stage.js.map +1 -1
  84. package/dist/deploy/run-deploy.d.ts +1 -0
  85. package/dist/deploy/run-deploy.d.ts.map +1 -1
  86. package/dist/deploy/run-deploy.js +100 -14
  87. package/dist/deploy/run-deploy.js.map +1 -1
  88. package/dist/deploy/settings/build-manifest.d.ts +6 -1
  89. package/dist/deploy/settings/build-manifest.d.ts.map +1 -1
  90. package/dist/deploy/settings/build-manifest.js +17 -17
  91. package/dist/deploy/settings/build-manifest.js.map +1 -1
  92. package/dist/deploy/settings/extra-files.d.ts +1 -1
  93. package/dist/deploy/settings/extra-files.d.ts.map +1 -1
  94. package/dist/deploy/settings/extra-files.js +22 -1
  95. package/dist/deploy/settings/extra-files.js.map +1 -1
  96. package/dist/deploy/settings/index.d.ts +1 -0
  97. package/dist/deploy/settings/index.d.ts.map +1 -1
  98. package/dist/deploy/settings/index.js +2 -1
  99. package/dist/deploy/settings/index.js.map +1 -1
  100. package/dist/deploy/settings/libraries.d.ts.map +1 -1
  101. package/dist/deploy/settings/libraries.js +2 -19
  102. package/dist/deploy/settings/libraries.js.map +1 -1
  103. package/dist/deploy/settings/paths.d.ts +2 -2
  104. package/dist/deploy/settings/paths.d.ts.map +1 -1
  105. package/dist/deploy/settings/paths.js +110 -12
  106. package/dist/deploy/settings/paths.js.map +1 -1
  107. package/dist/deploy/settings/screenshots.d.ts.map +1 -1
  108. package/dist/deploy/settings/screenshots.js +6 -31
  109. package/dist/deploy/settings/screenshots.js.map +1 -1
  110. package/dist/deploy/targets/flatpak-pnpm.js +4 -4
  111. package/dist/deploy/targets/flatpak-pnpm.js.map +1 -1
  112. package/dist/deploy/targets/flatpak-source.d.ts.map +1 -1
  113. package/dist/deploy/targets/flatpak-source.js +15 -6
  114. package/dist/deploy/targets/flatpak-source.js.map +1 -1
  115. package/dist/deploy/targets/flatpak-sources.js +1 -1
  116. package/dist/deploy/targets/flatpak-sources.js.map +1 -1
  117. package/dist/deploy/types.d.ts +1 -0
  118. package/dist/deploy/types.d.ts.map +1 -1
  119. package/dist/deploy/types.js.map +1 -1
  120. package/dist/dev/headless.d.ts +3 -0
  121. package/dist/dev/headless.d.ts.map +1 -0
  122. package/dist/dev/headless.js +35 -0
  123. package/dist/dev/headless.js.map +1 -0
  124. package/dist/dev/runner-deps.js +1 -1
  125. package/dist/dev/runner-deps.js.map +1 -1
  126. package/dist/dev/runner-main.d.ts.map +1 -1
  127. package/dist/dev/runner-main.js +1 -2
  128. package/dist/dev/runner-main.js.map +1 -1
  129. package/dist/dev/schema-dir.d.ts +1 -1
  130. package/dist/dev/schema-dir.d.ts.map +1 -1
  131. package/dist/dev/schema-dir.js +2 -4
  132. package/dist/dev/schema-dir.js.map +1 -1
  133. package/dist/i18n/catalogs.d.ts.map +1 -1
  134. package/dist/i18n/catalogs.js +2 -1
  135. package/dist/i18n/catalogs.js.map +1 -1
  136. package/dist/i18n/metadata-templates.d.ts.map +1 -1
  137. package/dist/i18n/metadata-templates.js +2 -1
  138. package/dist/i18n/metadata-templates.js.map +1 -1
  139. package/dist/i18n/source-messages.d.ts +3 -21
  140. package/dist/i18n/source-messages.d.ts.map +1 -1
  141. package/dist/i18n/source-messages.js +900 -85
  142. package/dist/i18n/source-messages.js.map +1 -1
  143. package/dist/i18n/types.d.ts +6 -7
  144. package/dist/i18n/types.d.ts.map +1 -1
  145. package/dist/i18n/types.js +83 -96
  146. package/dist/i18n/types.js.map +1 -1
  147. package/dist/internal/build-manifest.d.ts +5 -2
  148. package/dist/internal/build-manifest.d.ts.map +1 -1
  149. package/dist/internal/build-manifest.js +15 -2
  150. package/dist/internal/build-manifest.js.map +1 -1
  151. package/dist/internal/build-output.d.ts +8 -0
  152. package/dist/internal/build-output.d.ts.map +1 -0
  153. package/dist/internal/build-output.js +69 -0
  154. package/dist/internal/build-output.js.map +1 -0
  155. package/dist/internal/docs-elements.js +2 -2
  156. package/dist/internal/docs-elements.js.map +1 -1
  157. package/dist/internal/entry-arg.d.ts +4 -1
  158. package/dist/internal/entry-arg.d.ts.map +1 -1
  159. package/dist/internal/entry-arg.js +7 -1
  160. package/dist/internal/entry-arg.js.map +1 -1
  161. package/dist/internal/icon-path.d.ts.map +1 -1
  162. package/dist/internal/icon-path.js +5 -16
  163. package/dist/internal/icon-path.js.map +1 -1
  164. package/dist/internal/output-directory.d.ts +15 -0
  165. package/dist/internal/output-directory.d.ts.map +1 -0
  166. package/dist/internal/output-directory.js +490 -0
  167. package/dist/internal/output-directory.js.map +1 -0
  168. package/dist/internal/prepare-project.d.ts +2 -0
  169. package/dist/internal/prepare-project.d.ts.map +1 -1
  170. package/dist/internal/prepare-project.js +3 -3
  171. package/dist/internal/prepare-project.js.map +1 -1
  172. package/dist/internal/source-imports.d.ts.map +1 -1
  173. package/dist/internal/source-imports.js +10 -41
  174. package/dist/internal/source-imports.js.map +1 -1
  175. package/dist/mcp/client.js +2 -2
  176. package/dist/mcp/client.js.map +1 -1
  177. package/dist/settings/render.d.ts +0 -1
  178. package/dist/settings/render.d.ts.map +1 -1
  179. package/dist/settings/render.js +1 -11
  180. package/dist/settings/render.js.map +1 -1
  181. package/dist/settings/schema.d.ts +2 -2
  182. package/dist/settings/schema.d.ts.map +1 -1
  183. package/dist/settings/schema.js +21 -62
  184. package/dist/settings/schema.js.map +1 -1
  185. package/dist/vite-plugins/asset-imports.d.ts +1 -2
  186. package/dist/vite-plugins/asset-imports.d.ts.map +1 -1
  187. package/dist/vite-plugins/asset-imports.js +19 -32
  188. package/dist/vite-plugins/asset-imports.js.map +1 -1
  189. package/dist/vite-plugins/asset-specifier.d.ts +1 -3
  190. package/dist/vite-plugins/asset-specifier.d.ts.map +1 -1
  191. package/dist/vite-plugins/asset-specifier.js +1 -4
  192. package/dist/vite-plugins/asset-specifier.js.map +1 -1
  193. package/dist/vite-plugins/build-manifest.d.ts +5 -1
  194. package/dist/vite-plugins/build-manifest.d.ts.map +1 -1
  195. package/dist/vite-plugins/build-manifest.js +2 -1
  196. package/dist/vite-plugins/build-manifest.js.map +1 -1
  197. package/dist/vite-plugins/i18n.d.ts.map +1 -1
  198. package/dist/vite-plugins/i18n.js +59 -6
  199. package/dist/vite-plugins/i18n.js.map +1 -1
  200. package/dist/vite-plugins/index.d.ts +8 -1
  201. package/dist/vite-plugins/index.d.ts.map +1 -1
  202. package/dist/vite-plugins/index.js +8 -6
  203. package/dist/vite-plugins/index.js.map +1 -1
  204. package/dist/vite-plugins/resources.d.ts.map +1 -1
  205. package/dist/vite-plugins/resources.js +32 -97
  206. package/dist/vite-plugins/resources.js.map +1 -1
  207. package/dist/vite-plugins/self-contained.d.ts.map +1 -1
  208. package/dist/vite-plugins/self-contained.js +159 -158
  209. package/dist/vite-plugins/self-contained.js.map +1 -1
  210. package/dist/vite-plugins/settings-worker-env.d.ts.map +1 -1
  211. package/dist/vite-plugins/settings-worker-env.js +2 -4
  212. package/dist/vite-plugins/settings-worker-env.js.map +1 -1
  213. package/dist/vite-plugins/settings.d.ts +1 -2
  214. package/dist/vite-plugins/settings.d.ts.map +1 -1
  215. package/dist/vite-plugins/settings.js +8 -14
  216. package/dist/vite-plugins/settings.js.map +1 -1
  217. package/dist/vite-plugins/undeclared-library.d.ts.map +1 -1
  218. package/dist/vite-plugins/undeclared-library.js +10 -13
  219. package/dist/vite-plugins/undeclared-library.js.map +1 -1
  220. package/dist/vite-plugins/worker.d.ts.map +1 -1
  221. package/dist/vite-plugins/worker.js +0 -1
  222. package/dist/vite-plugins/worker.js.map +1 -1
  223. package/package.json +21 -15
  224. package/src/builder.ts +18 -5
  225. package/src/cli.ts +3 -0
  226. package/src/codegen/freshness.ts +66 -17
  227. package/src/codegen/reference.ts +1 -8
  228. package/src/codegen/report.ts +5 -21
  229. package/src/codegen/run-codegen.ts +42 -151
  230. package/src/codegen/store-resolver.ts +10 -26
  231. package/src/commands/build.ts +13 -2
  232. package/src/commands/codegen.ts +6 -5
  233. package/src/commands/deploy.ts +4 -2
  234. package/src/commands/dev.ts +20 -1
  235. package/src/commands/docs.ts +5 -17
  236. package/src/deploy/freedesktop/copyright.ts +2 -14
  237. package/src/deploy/freedesktop/metainfo.ts +73 -21
  238. package/src/deploy/freedesktop/xml.ts +24 -32
  239. package/src/deploy/nfpm/config.ts +5 -2
  240. package/src/deploy/nfpm/contents.ts +8 -0
  241. package/src/deploy/node-runtime/elf.ts +43 -5
  242. package/src/deploy/node-runtime/index.ts +105 -5
  243. package/src/deploy/node-runtime/license.ts +2 -8
  244. package/src/deploy/notices/node-runtime.ts +2 -2
  245. package/src/deploy/notices/packages.ts +3 -11
  246. package/src/deploy/payload/copy-tree.ts +3 -4
  247. package/src/deploy/payload/launcher.ts +20 -1
  248. package/src/deploy/payload/stage.ts +3 -7
  249. package/src/deploy/run-deploy.ts +56 -16
  250. package/src/deploy/settings/build-manifest.ts +30 -17
  251. package/src/deploy/settings/extra-files.ts +26 -2
  252. package/src/deploy/settings/index.ts +3 -1
  253. package/src/deploy/settings/libraries.ts +1 -22
  254. package/src/deploy/settings/paths.ts +68 -13
  255. package/src/deploy/settings/screenshots.ts +8 -46
  256. package/src/deploy/targets/flatpak-pnpm.ts +4 -4
  257. package/src/deploy/targets/flatpak-source.ts +17 -6
  258. package/src/deploy/targets/flatpak-sources.ts +1 -1
  259. package/src/deploy/types.ts +1 -0
  260. package/src/dev/headless.ts +40 -0
  261. package/src/dev/runner-deps.ts +1 -1
  262. package/src/dev/runner-main.ts +1 -2
  263. package/src/dev/schema-dir.ts +2 -4
  264. package/src/i18n/catalogs.ts +2 -1
  265. package/src/i18n/metadata-templates.ts +2 -1
  266. package/src/i18n/source-messages.ts +1442 -262
  267. package/src/i18n/types.ts +97 -131
  268. package/src/internal/build-manifest.ts +25 -1
  269. package/src/internal/build-output.ts +99 -0
  270. package/src/internal/docs-elements.ts +2 -2
  271. package/src/internal/entry-arg.ts +8 -1
  272. package/src/internal/icon-path.ts +6 -19
  273. package/src/internal/output-directory.ts +699 -0
  274. package/src/internal/prepare-project.ts +5 -5
  275. package/src/internal/source-imports.ts +14 -50
  276. package/src/mcp/client.ts +2 -2
  277. package/src/settings/render.ts +1 -13
  278. package/src/settings/schema.ts +19 -81
  279. package/src/vite-plugins/asset-imports.ts +20 -44
  280. package/src/vite-plugins/asset-specifier.ts +0 -5
  281. package/src/vite-plugins/build-manifest.ts +7 -1
  282. package/src/vite-plugins/i18n.ts +87 -5
  283. package/src/vite-plugins/index.ts +16 -11
  284. package/src/vite-plugins/resources.ts +37 -140
  285. package/src/vite-plugins/self-contained.ts +184 -185
  286. package/src/vite-plugins/settings-worker-env.ts +2 -4
  287. package/src/vite-plugins/settings.ts +11 -20
  288. package/src/vite-plugins/undeclared-library.ts +14 -20
  289. package/src/vite-plugins/worker.ts +0 -1
  290. package/dist/i18n/binding-extractor.d.ts +0 -30
  291. package/dist/i18n/binding-extractor.d.ts.map +0 -1
  292. package/dist/i18n/binding-extractor.js +0 -1324
  293. package/dist/i18n/binding-extractor.js.map +0 -1
  294. package/dist/i18n/extractor-plugin.d.ts +0 -8
  295. package/dist/i18n/extractor-plugin.d.ts.map +0 -1
  296. package/dist/i18n/extractor-plugin.js +0 -247
  297. package/dist/i18n/extractor-plugin.js.map +0 -1
  298. package/dist/internal/data-dir.d.ts +0 -4
  299. package/dist/internal/data-dir.d.ts.map +0 -1
  300. package/dist/internal/data-dir.js +0 -52
  301. package/dist/internal/data-dir.js.map +0 -1
  302. package/dist/vite-plugins/animated.d.ts +0 -5
  303. package/dist/vite-plugins/animated.d.ts.map +0 -1
  304. package/dist/vite-plugins/animated.js +0 -325
  305. package/dist/vite-plugins/animated.js.map +0 -1
  306. package/src/i18n/binding-extractor.ts +0 -1969
  307. package/src/i18n/extractor-plugin.ts +0 -413
  308. package/src/internal/data-dir.ts +0 -70
  309. package/src/vite-plugins/animated.ts +0 -486
@@ -1,5 +1,8 @@
1
- import type { ExtractedKey, I18nextToolkitConfig, Logger } from "i18next-cli";
2
- import { findKeys } from "i18next-cli";
1
+ import type { ExtractedKey, ExtractedKeysMap, Logger, Plugin } from "i18next-cli";
2
+ import type { ESTree } from "vite";
3
+ import { type NodePath, parseSync as parseBabelSync, type Scope, traverse, types } from "@babel/core";
4
+ import { isPathInside, isPathWithin, toPosixPath } from "@gtkx/utils";
5
+ import { runExtractor } from "i18next-cli";
3
6
  import {
4
7
  existsSync,
5
8
  mkdirSync,
@@ -8,32 +11,29 @@ import {
8
11
  rmSync,
9
12
  writeFileSync,
10
13
  } from "node:fs";
11
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
15
+ import { parseSync, Visitor } from "vite";
12
16
  import type { CatalogProject } from "./catalogs.js";
13
17
  import { runCliTool } from "../internal/run-cli-tool.js";
18
+ import { sourceLanguage } from "../internal/source-imports.js";
14
19
  import { replaceCatalogTemplate } from "./catalog-template.js";
15
- import { gtkxExtractorPlugin } from "./extractor-plugin.js";
16
20
  import { metadataTemplateFiles } from "./metadata-templates.js";
21
+ import { clearI18nResources, i18nToolkitConfig } from "./types.js";
17
22
 
18
23
  type SourceLocation = {
19
24
  file: string;
20
25
  line?: number | undefined;
21
26
  column?: number | undefined;
22
27
  };
28
+ type ExtractedLocation = NonNullable<ExtractedKey["locations"]>[number];
23
29
 
24
30
  type SourceMessage = {
25
31
  context: string | null;
26
- kind: "default" | "plural" | "pluralDefaults" | "point";
27
32
  locations: SourceLocation[];
28
- msgid: string;
29
33
  namespace: string;
30
34
  plural: string | null;
31
35
  singular: string;
32
- };
33
-
34
- type SourceCatalog = {
35
- messages: SourceMessage[];
36
- sourceFiles: string[];
36
+ sourceKey: string;
37
37
  };
38
38
 
39
39
  type SyntheticEntry = {
@@ -45,7 +45,6 @@ type SourceExtraction = {
45
45
  messages: SourceMessage[];
46
46
  output: string;
47
47
  project: CatalogProject;
48
- sourceFiles: string[];
49
48
  workDir: string;
50
49
  };
51
50
 
@@ -54,7 +53,6 @@ type CatalogTemplateExtraction = {
54
53
  output?: string | undefined;
55
54
  project: CatalogProject;
56
55
  shouldPreserveMetadataMessages: boolean;
57
- sourceFiles: string[];
58
56
  };
59
57
 
60
58
  type PluralVariant = {
@@ -69,10 +67,50 @@ type PluralGroup = {
69
67
  variants: PluralVariant[];
70
68
  };
71
69
 
70
+ type TranslationReferences = {
71
+ calls: Set<number>;
72
+ elements: Set<number>;
73
+ entryLocations: Map<string, SourcePoint[]>;
74
+ locations: Set<string>;
75
+ masks: SourceMask[];
76
+ };
77
+ type SourceMask = { end: number; start: number };
78
+ type SourcePoint = { column: number; line: number };
79
+ type TranslationCallKind = "alias" | "canonical";
80
+ type ImportedBinding = {
81
+ imported: string;
82
+ local: string;
83
+ source: string;
84
+ style: "default" | "named" | "namespace";
85
+ };
86
+ type TranslationHookBinding = { call: types.CallExpression; isCanonical: boolean };
87
+ type TranslationMemberExpression = types.MemberExpression | types.OptionalMemberExpression;
88
+ type ResolvedBinding = NonNullable<ReturnType<Scope["getBinding"]>>;
89
+
72
90
  const CONTEXT_SEPARATOR = "\u{4}";
73
91
  const DEFAULT_NAMESPACE = "translation";
74
92
  const POTFILES_FILENAME = "POTFILES.in";
75
93
  const SYNTHETIC_FILENAME = "messages.js";
94
+ const UNSUPPORTED_PLURAL_OPTION = /^defaultValue_(few|many|two|zero)$/u;
95
+ const STATIC_SOURCE_OPTION_NAMES: ReadonlySet<string> = new Set([
96
+ "context",
97
+ "defaultValue",
98
+ "defaultValue_one",
99
+ "defaultValue_other",
100
+ "keyPrefix",
101
+ ]);
102
+ const NESTED_CONTEXT = /["']?context["']?\s*:\s*(?:"([^"]*)"|'([^']*)')/u;
103
+ const NESTED_COUNT = /["']?count["']?\s*:/u;
104
+ const NESTED_STRUCTURE_TOKEN = /"[^"]*"|'[^']*'|[()]/gu;
105
+ const TRANSLATION_MODULES = new Set(["@gtkx/i18n", "i18next"]);
106
+ const TRANS_ELEMENTS: ReadonlySet<string> = new Set(["Trans", "TransWithoutContext"]);
107
+ const STATIC_KEY_WRAPPER_TYPES: ReadonlySet<string> = new Set([
108
+ "ParenthesizedExpression",
109
+ "TSAsExpression",
110
+ "TSSatisfiesExpression",
111
+ "TSTypeAssertion",
112
+ "TSNonNullExpression",
113
+ ]);
76
114
 
77
115
  const quietLogger = (): { logger: Logger; reports: string[] } => {
78
116
  const reports: string[] = [];
@@ -94,31 +132,6 @@ const quietLogger = (): { logger: Logger; reports: string[] } => {
94
132
  };
95
133
  };
96
134
 
97
- const extractorConfig = (sourceFiles: string[]): I18nextToolkitConfig => {
98
- const integration = gtkxExtractorPlugin();
99
-
100
- return {
101
- locales: ["en"],
102
- plugins: [integration.plugin],
103
- extract: {
104
- input: sourceFiles,
105
- output: "unused/{{language}}/{{namespace}}.json",
106
- contextSeparator: CONTEXT_SEPARATOR,
107
- defaultNS: DEFAULT_NAMESPACE,
108
- disablePlurals: false,
109
- extractFromComments: false,
110
- functions: [],
111
- generateBasePluralForms: false,
112
- keySeparator: ".",
113
- nsSeparator: false,
114
- pluralSeparator: "_",
115
- transComponents: integration.transComponents,
116
- useTranslationNames: [],
117
- warnOnConflicts: "error",
118
- },
119
- };
120
- };
121
-
122
135
  const splitContext = (key: string): { context: string | null; msgid: string } => {
123
136
  const index = key.lastIndexOf(CONTEXT_SEPARATOR);
124
137
 
@@ -155,17 +168,22 @@ const normalizedLocations = (locations: SourceLocation[]): SourceLocation[] => {
155
168
  };
156
169
 
157
170
  const pointSource = (entry: ExtractedKey, msgid: string): string => {
158
- if (entry.explicitDefault !== true || typeof entry.defaultValue !== "string") {
171
+ if (typeof entry.defaultValue !== "string") {
159
172
  return msgid;
160
173
  }
161
174
 
162
- return entry.defaultValue;
175
+ if (entry.explicitDefault === true) {
176
+ return entry.defaultValue;
177
+ }
178
+
179
+ return entry.defaultValue === msgid || msgid.endsWith(`.${entry.defaultValue}`)
180
+ ? msgid
181
+ : entry.defaultValue;
163
182
  };
164
183
 
165
184
  const pointMessage = (entry: ExtractedKey): SourceMessage => {
166
185
  const { context, msgid } = splitContext(entry.key);
167
186
  validateIdentity(context, msgid);
168
- const isDefaultExplicit = entry.explicitDefault === true;
169
187
  const singular = pointSource(entry, msgid);
170
188
 
171
189
  if (singular.length === 0) {
@@ -174,12 +192,11 @@ const pointMessage = (entry: ExtractedKey): SourceMessage => {
174
192
 
175
193
  return {
176
194
  context,
177
- kind: isDefaultExplicit ? "default" : "point",
178
195
  locations: normalizedLocations(entry.locations ?? []),
179
- msgid,
180
196
  namespace: namespaceFor(entry),
181
197
  plural: null,
182
198
  singular,
199
+ sourceKey: msgid,
183
200
  };
184
201
  };
185
202
 
@@ -242,8 +259,7 @@ const pluralMessage = ({ baseKey, namespace, variants }: PluralGroup): SourceMes
242
259
  const { one, other } = pairedVariants(variants);
243
260
  const { context, msgid } = splitContext(baseKey);
244
261
  validateIdentity(context, msgid);
245
- const hasStableKey = one.defaultValue !== other.defaultValue;
246
- const singular = hasStableKey ? one.defaultValue : msgid;
262
+ const singular = one.defaultValue;
247
263
  const plural = other.defaultValue;
248
264
 
249
265
  if (singular === plural || singular.length === 0 || plural.length === 0) {
@@ -252,12 +268,11 @@ const pluralMessage = ({ baseKey, namespace, variants }: PluralGroup): SourceMes
252
268
 
253
269
  return {
254
270
  context,
255
- kind: hasStableKey ? "pluralDefaults" : "plural",
256
271
  locations: normalizedLocations(variants.flatMap((variant) => variant.locations)),
257
- msgid,
258
272
  namespace,
259
273
  plural,
260
274
  singular,
275
+ sourceKey: msgid,
261
276
  };
262
277
  };
263
278
 
@@ -295,314 +310,1479 @@ const sourceMessages = (entries: ExtractedKey[]): SourceMessage[] => {
295
310
  const compareMessages = (left: SourceMessage, right: SourceMessage): number =>
296
311
  left.namespace.localeCompare(right.namespace) ||
297
312
  (left.context ?? "").localeCompare(right.context ?? "") ||
298
- left.msgid.localeCompare(right.msgid) ||
299
313
  left.singular.localeCompare(right.singular) ||
300
314
  (left.plural ?? "").localeCompare(right.plural ?? "");
301
315
 
316
+ const inferLocations = (message: SourceMessage, sources: Map<string, string>): SourceMessage => {
317
+ if (message.locations.length > 0) {
318
+ return message;
319
+ }
320
+
321
+ const locations: SourceLocation[] = [];
322
+
323
+ for (const [file, source] of sources) {
324
+ const index = source.indexOf(message.sourceKey);
325
+
326
+ if (index !== -1) {
327
+ locations.push({ file, line: source.slice(0, index).split("\n").length });
328
+ }
329
+ }
330
+
331
+ return { ...message, locations };
332
+ };
333
+
302
334
  const normalizedSourceFiles = (root: string, paths: string[]): string[] => {
303
335
  const files = paths
304
336
  .map((path) => resolve(root, path))
305
- .filter((path) => {
306
- const projectPath = relative(root, path);
307
-
308
- return projectPath !== ".." && !projectPath.startsWith(`..${sep}`) && !isAbsolute(projectPath);
309
- });
337
+ .filter((path) => isPathWithin(root, path));
310
338
 
311
339
  return new Set(files).values().toArray().toSorted((left, right) => left.localeCompare(right));
312
340
  };
313
341
 
314
- const findSourceMessages = async (sourceFiles: string[]): Promise<SourceMessage[]> => {
315
- if (sourceFiles.length === 0) {
316
- return [];
342
+ type StaticKeyWrapper = ESTree.ParenthesizedExpression |
343
+ ESTree.TSAsExpression |
344
+ ESTree.TSSatisfiesExpression |
345
+ ESTree.TSTypeAssertion |
346
+ ESTree.TSNonNullExpression;
347
+
348
+ const isStaticKeyWrapper = (key: ESTree.Node): key is StaticKeyWrapper =>
349
+ STATIC_KEY_WRAPPER_TYPES.has(key.type);
350
+
351
+ const unwrapStaticWrapper = (node: ESTree.Node): ESTree.Node =>
352
+ isStaticKeyWrapper(node) ? unwrapStaticWrapper(node.expression) : node;
353
+
354
+ const isStaticTranslationKey = (key: ESTree.Node | null | undefined): boolean => {
355
+ if (key === undefined || key === null) {
356
+ return false;
317
357
  }
318
358
 
319
- const fileErrors: string[] = [];
320
- const { logger, reports } = quietLogger();
321
- const { allKeys } = await findKeys(extractorConfig(sourceFiles), logger, fileErrors);
359
+ if (key.type === "Literal") {
360
+ return typeof key.value === "string";
361
+ }
322
362
 
323
- if (fileErrors.length > 0 || reports.length > 0) {
324
- throw new Error([...fileErrors, ...reports].join("\n"));
363
+ if (key.type === "TemplateLiteral") {
364
+ return key.expressions.length === 0;
325
365
  }
326
366
 
327
- return sourceMessages(allKeys.values().toArray()).toSorted(compareMessages);
367
+ if (key.type === "ParenthesizedExpression") {
368
+ return false;
369
+ }
370
+
371
+ if (isStaticKeyWrapper(key)) {
372
+ return isStaticTranslationKey(key.expression);
373
+ }
374
+
375
+ return false;
328
376
  };
329
377
 
330
- const projectPath = (root: string, path: string): string | null => {
331
- const absolute = isAbsolute(path) ? resolve(path) : resolve(root, path);
332
- const projectRelative = relative(root, absolute);
378
+ const assertStaticTranslationKey = (key: ESTree.Node | null | undefined): void => {
379
+ if (!isStaticTranslationKey(key)) {
380
+ throw new Error("Translation keys must be string literals");
381
+ }
382
+ };
333
383
 
334
- if (
335
- projectRelative === "" ||
336
- projectRelative === ".." ||
337
- projectRelative.startsWith(`..${sep}`) ||
338
- isAbsolute(projectRelative)
339
- ) {
340
- return null;
384
+ const propertyName = (property: ESTree.ObjectProperty): string | null => {
385
+ if (!property.computed && property.key.type === "Identifier") {
386
+ return property.key.name;
341
387
  }
342
388
 
343
- return projectRelative.replaceAll("\\", "/");
389
+ return property.key.type === "Literal" && typeof property.key.value === "string"
390
+ ? property.key.value
391
+ : null;
344
392
  };
345
393
 
346
- const writePotfiles = (project: CatalogProject, sourceFiles: string[]): string => {
347
- const paths = sourceFiles
348
- .map((path) => projectPath(project.root, path))
349
- .filter((path): path is string => path !== null);
394
+ const assertSupportedOptionProperty = (property: ESTree.ObjectPropertyKind): void => {
395
+ if (property.type === "SpreadElement") {
396
+ throw new Error("Translation extraction requires explicit option properties");
397
+ }
350
398
 
351
- const sorted = [...new Set(paths)].toSorted((left, right) => left.localeCompare(right));
352
- const target = resolve(project.poDir, POTFILES_FILENAME);
353
- writeFileSync(target, sorted.length === 0 ? "" : `${sorted.join("\n")}\n`);
399
+ const name = propertyName(property);
354
400
 
355
- return target;
401
+ if (name === null) {
402
+ throw new Error("Translation extraction requires static option property names");
403
+ }
404
+
405
+ if (UNSUPPORTED_PLURAL_OPTION.test(name)) {
406
+ throw new Error("GNU gettext accepts one singular and one plural source string");
407
+ }
408
+
409
+ if (STATIC_SOURCE_OPTION_NAMES.has(name)) {
410
+ assertStaticTranslationKey(property.value);
411
+ }
356
412
  };
357
413
 
358
- const renderCatalogCall = (message: SourceMessage): string => {
359
- const msgid = JSON.stringify(message.singular);
414
+ const assertSupportedOptionObject = (options: ESTree.Node | null | undefined): void => {
415
+ if (options === null || options === undefined) {
416
+ return;
417
+ }
360
418
 
361
- if (message.plural !== null) {
362
- const plural = JSON.stringify(message.plural);
419
+ if (isStaticKeyWrapper(options) && unwrapStaticWrapper(options).type === "ObjectExpression") {
420
+ throw new Error("Translation option objects cannot be wrapped in another expression");
421
+ }
363
422
 
364
- return message.context === null
365
- ? `ngettext(${msgid}, ${plural}, 0);`
366
- : `npgettext(${JSON.stringify(message.context)}, ${msgid}, ${plural}, 0);`;
423
+ if (options.type !== "ObjectExpression") {
424
+ throw new Error("Translation option objects must be inline object literals");
367
425
  }
368
426
 
369
- return message.context === null
370
- ? `gettext(${msgid});`
371
- : `pgettext(${JSON.stringify(message.context)}, ${msgid});`;
427
+ for (const property of options.properties) {
428
+ assertSupportedOptionProperty(property);
429
+ }
372
430
  };
373
431
 
374
- const readSources = (sourceFiles: string[]): Map<string, string> =>
375
- new Map(sourceFiles.map((path) => [path, readFileSync(path, "utf8")]));
432
+ const namedImportedBinding = (path: NodePath, source: string): ImportedBinding | null => {
433
+ if (!path.isImportSpecifier() || path.node.importKind === "type") {
434
+ return null;
435
+ }
376
436
 
377
- const locatedOwners = (
378
- project: CatalogProject,
379
- message: SourceMessage,
380
- ): { path: string; line: number | undefined }[] => {
381
- const owners: { path: string; line: number | undefined }[] = [];
437
+ const imported = path.node.imported;
382
438
 
383
- for (const location of message.locations) {
384
- const path = projectPath(project.root, location.file);
439
+ return {
440
+ imported: types.isIdentifier(imported) ? imported.name : imported.value,
441
+ local: path.node.local.name,
442
+ source,
443
+ style: "named",
444
+ };
445
+ };
385
446
 
386
- if (path !== null) {
387
- owners.push({ path, line: location.line });
388
- }
447
+ const defaultImportedBinding = (path: NodePath, source: string): ImportedBinding | null =>
448
+ path.isImportDefaultSpecifier()
449
+ ? { imported: "default", local: path.node.local.name, source, style: "default" }
450
+ : null;
451
+
452
+ const namespaceImportedBinding = (path: NodePath, source: string): ImportedBinding | null =>
453
+ path.isImportNamespaceSpecifier()
454
+ ? { imported: "*", local: path.node.local.name, source, style: "namespace" }
455
+ : null;
456
+
457
+ const importedBinding = (binding: ResolvedBinding | undefined): ImportedBinding | null => {
458
+ if (binding === undefined) {
459
+ return null;
389
460
  }
390
461
 
391
- const unique = new Map(owners.map((owner) => [`${owner.path}\0${String(owner.line)}`, owner]));
462
+ const path = binding.path;
463
+ const declaration = path.parentPath;
392
464
 
393
- return unique.values().toArray();
465
+ if (!declaration.isImportDeclaration() || declaration.node.importKind === "type") {
466
+ return null;
467
+ }
468
+
469
+ const source = declaration.node.source.value;
470
+
471
+ return namedImportedBinding(path, source) ??
472
+ defaultImportedBinding(path, source) ??
473
+ namespaceImportedBinding(path, source);
394
474
  };
395
475
 
396
- const inferredOwner = (
397
- project: CatalogProject,
398
- message: SourceMessage,
399
- sources: Map<string, string>,
400
- ): { path: string; line: number } | null => {
401
- for (const [path, source] of sources) {
402
- const index = source.indexOf(message.msgid);
476
+ const babelPropertyName = (property: types.ObjectProperty): string | null => {
477
+ const key = property.key;
403
478
 
404
- if (index !== -1) {
405
- return {
406
- path: projectPath(project.root, path) ?? SYNTHETIC_FILENAME,
407
- line: source.slice(0, index).split("\n").length,
408
- };
409
- }
479
+ if (!property.computed && types.isIdentifier(key)) {
480
+ return key.name;
410
481
  }
411
482
 
412
- return null;
483
+ return types.isStringLiteral(key) ? key.value : null;
413
484
  };
414
485
 
415
- const sourceOwners = (
416
- project: CatalogProject,
417
- message: SourceMessage,
418
- sources: Map<string, string>,
419
- ): { path: string; line: number | undefined }[] => {
420
- const located = locatedOwners(project, message);
486
+ const isBoundIdentifier = (node: types.Node | null, name: string): boolean => {
487
+ if (types.isIdentifier(node)) {
488
+ return node.name === name;
489
+ }
421
490
 
422
- if (located.length > 0) {
423
- return located;
491
+ if (types.isAssignmentPattern(node)) {
492
+ return types.isIdentifier(node.left) && node.left.name === name;
424
493
  }
425
494
 
426
- return [inferredOwner(project, message, sources) ?? { path: SYNTHETIC_FILENAME, line: undefined }];
495
+ return false;
427
496
  };
428
497
 
429
- const syntheticEntries = (
430
- project: CatalogProject,
431
- messages: SourceMessage[],
432
- sourceFiles: string[],
433
- ): Map<string, SyntheticEntry[]> => {
434
- const sources = readSources(sourceFiles);
435
- const entries: Map<string, SyntheticEntry[]> = new Map();
498
+ const isHookPatternBinding = (pattern: types.VariableDeclarator["id"], name: string): boolean => {
499
+ if (types.isObjectPattern(pattern)) {
500
+ return pattern.properties.some((property) =>
501
+ types.isObjectProperty(property) &&
502
+ babelPropertyName(property) === "t" &&
503
+ isBoundIdentifier(property.value, name));
504
+ }
436
505
 
437
- for (const message of messages) {
438
- for (const owner of sourceOwners(project, message, sources)) {
439
- const owned = entries.get(owner.path) ?? [];
440
- owned.push({ call: renderCatalogCall(message), line: owner.line });
441
- entries.set(owner.path, owned);
442
- }
506
+ if (types.isArrayPattern(pattern)) {
507
+ return isBoundIdentifier(pattern.elements[0] ?? null, name);
443
508
  }
444
509
 
445
- if (entries.size === 0) {
446
- entries.set(SYNTHETIC_FILENAME, []);
510
+ return false;
511
+ };
512
+
513
+ const importedTranslationHook = (scope: Scope, call: types.CallExpression): ImportedBinding | null => {
514
+ if (!types.isIdentifier(call.callee)) {
515
+ return null;
447
516
  }
448
517
 
449
- return entries;
518
+ const imported = importedBinding(scope.getBinding(call.callee.name));
519
+
520
+ return imported?.imported === "useTranslation" && TRANSLATION_MODULES.has(imported.source)
521
+ ? imported
522
+ : null;
450
523
  };
451
524
 
452
- const compareSyntheticEntries = (left: SyntheticEntry, right: SyntheticEntry): number =>
453
- (left.line ?? Number.MAX_SAFE_INTEGER) - (right.line ?? Number.MAX_SAFE_INTEGER);
525
+ const translationHookBinding = (binding: ResolvedBinding): TranslationHookBinding | null => {
526
+ const path = binding.path;
454
527
 
455
- const lastSyntheticLine = (entries: SyntheticEntry[]): number => {
456
- let line = 0;
528
+ if (!path.isVariableDeclarator()) {
529
+ return null;
530
+ }
457
531
 
458
- for (const entry of entries) {
459
- line = Math.max(line, entry.line ?? 0);
532
+ const pattern = path.node.id;
533
+ const initializer = path.node.init;
534
+
535
+ if (!isHookPatternBinding(pattern, binding.identifier.name) ||
536
+ !types.isCallExpression(initializer)) {
537
+ return null;
460
538
  }
461
539
 
462
- return line;
463
- };
540
+ const imported = importedTranslationHook(path.scope, initializer);
464
541
 
465
- const appendSyntheticEntry = (lines: string[], entry: SyntheticEntry, requestedLine: number): void => {
466
- const index = requestedLine - 1;
467
- const existing = lines[index];
542
+ if (imported === null) {
543
+ return null;
544
+ }
468
545
 
469
- if (existing === undefined || existing.length === 0) {
470
- lines[index] = entry.call;
546
+ return { call: initializer, isCanonical: imported.local === "useTranslation" };
547
+ };
471
548
 
472
- return;
549
+ const importedTranslationFunctionKind = (imported: ImportedBinding | null): TranslationCallKind | null => {
550
+ if (imported?.imported !== "t" || !TRANSLATION_MODULES.has(imported.source)) {
551
+ return null;
473
552
  }
474
553
 
475
- lines[index] = `${existing} ${entry.call}`;
554
+ return imported.local === "t" ? "canonical" : "alias";
476
555
  };
477
556
 
478
- const renderSyntheticSource = (entries: SyntheticEntry[]): string => {
479
- const lines: string[] = [];
480
- const sorted = entries.toSorted(compareSyntheticEntries);
481
- let nextLine = lastSyntheticLine(entries) + 1;
557
+ const hookTranslationFunctionKind = (binding: ResolvedBinding | undefined): TranslationCallKind | null => {
558
+ if (binding === undefined) {
559
+ return null;
560
+ }
482
561
 
483
- for (const entry of sorted) {
484
- const requestedLine = Math.max(1, entry.line ?? nextLine++);
485
- appendSyntheticEntry(lines, entry, requestedLine);
562
+ const hook = translationHookBinding(binding);
563
+
564
+ if (hook === null) {
565
+ return null;
486
566
  }
487
567
 
488
- return `${lines.join("\n")}\n`;
568
+ return binding.identifier.name === "t" && hook.isCanonical ? "canonical" : "alias";
489
569
  };
490
570
 
491
- const writeSyntheticSources = (
492
- workDir: string,
493
- project: CatalogProject,
494
- messages: SourceMessage[],
495
- sourceFiles: string[],
496
- ): string => {
497
- const entries = syntheticEntries(project, messages, sourceFiles);
498
- const paths: string[] = [];
571
+ const directTranslationFunctionKind = (scope: Scope, name: string): TranslationCallKind | null => {
572
+ const binding = scope.getBinding(name);
499
573
 
500
- for (const [path, owned] of entries) {
501
- const target = join(workDir, path);
502
- mkdirSync(dirname(target), { recursive: true });
503
- writeFileSync(target, renderSyntheticSource(owned));
504
- paths.push(path);
505
- }
574
+ return importedTranslationFunctionKind(importedBinding(binding)) ?? hookTranslationFunctionKind(binding);
575
+ };
506
576
 
507
- const potfiles = join(workDir, POTFILES_FILENAME);
508
- writeFileSync(potfiles, `${paths.toSorted((left, right) => left.localeCompare(right)).join("\n")}\n`);
577
+ const babelMemberName = (member: TranslationMemberExpression): string | null => {
578
+ const property = member.property;
509
579
 
510
- return potfiles;
580
+ if (member.computed) {
581
+ return types.isStringLiteral(property) ? property.value : null;
582
+ }
583
+
584
+ return types.isIdentifier(property) ? property.name : null;
511
585
  };
512
586
 
513
- const extractSourceMessages = ({ project, messages, sourceFiles, output, workDir }: SourceExtraction): void => {
514
- const potfilesPath = writeSyntheticSources(workDir, project, messages, sourceFiles);
587
+ const isImportedTranslationObject = (scope: Scope, object: types.Node): boolean => {
588
+ if (!types.isIdentifier(object)) {
589
+ return false;
590
+ }
515
591
 
516
- runCliTool({
517
- tool: "xgettext",
518
- args: [
519
- "--language=JavaScript",
520
- "--from-code=UTF-8",
521
- "--force-po",
522
- "--keyword=gettext:1",
523
- "--keyword=ngettext:1,2",
524
- "--keyword=pgettext:1c,2",
525
- "--keyword=npgettext:1c,2,3",
526
- `--directory=${workDir}`,
527
- `--files-from=${potfilesPath}`,
528
- `--output=${output}`,
529
- ],
530
- target: output,
531
- });
592
+ const imported = importedBinding(scope.getBinding(object.name));
593
+
594
+ return imported !== null &&
595
+ TRANSLATION_MODULES.has(imported.source) &&
596
+ (imported.style === "namespace" || (imported.source === "i18next" && imported.imported === "default"));
532
597
  };
533
598
 
534
- const extractMetadataFragment = (project: CatalogProject, input: string, output: string): void => {
535
- runCliTool({
536
- tool: "msggrep",
537
- args: [
538
- "--force-po",
539
- `--output-file=${output}`,
540
- ...metadataTemplateFiles(project).map((file) => `--location=${file.relativePath}`),
541
- input,
542
- ],
543
- target: input,
544
- });
599
+ const constantVariableDeclarator = (binding: ResolvedBinding): NodePath<types.VariableDeclarator> | null => {
600
+ const path = binding.path;
601
+ const declaration = path.parentPath;
602
+
603
+ if (!path.isVariableDeclarator() ||
604
+ !declaration.isVariableDeclaration() ||
605
+ declaration.node.kind !== "const") {
606
+ return null;
607
+ }
608
+
609
+ return path;
545
610
  };
546
611
 
547
- const joinMetadataFragment = (output: string, fragment: string): void => {
548
- runCliTool({
549
- tool: "xgettext",
550
- args: ["--language=PO", "--join-existing", "--force-po", `--output=${output}`, fragment],
551
- target: output,
552
- });
612
+ const isTranslationHookResultBinding = (binding: ResolvedBinding | undefined): boolean => {
613
+ if (binding === undefined) {
614
+ return false;
615
+ }
616
+
617
+ const path = constantVariableDeclarator(binding);
618
+ const initializer = path?.node.init;
619
+
620
+ return path !== null &&
621
+ types.isIdentifier(path.node.id) &&
622
+ types.isCallExpression(initializer) &&
623
+ importedTranslationHook(path.scope, initializer) !== null;
553
624
  };
554
625
 
555
- const extractCatalogTemplate = ({
556
- project,
557
- messages,
558
- sourceFiles,
559
- shouldPreserveMetadataMessages,
560
- output = resolve(project.poDir, `${project.domain}.pot`),
561
- }: CatalogTemplateExtraction): void => {
562
- const workDir = mkdtempSync(join(project.poDir, ".gtkx-i18n-"));
563
- const source = join(workDir, "source.pot");
564
- const hasPreviousTemplate = existsSync(output);
626
+ const isHookTranslationMember = (scope: Scope, member: TranslationMemberExpression): boolean => {
627
+ if (babelMemberName(member) !== "t") {
628
+ return false;
629
+ }
565
630
 
566
- try {
567
- if (shouldPreserveMetadataMessages && hasPreviousTemplate) {
568
- const fragment = join(workDir, "metadata.pot");
569
- extractMetadataFragment(project, output, fragment);
570
- extractSourceMessages({ project, messages, sourceFiles, output: source, workDir });
571
- joinMetadataFragment(source, fragment);
572
- } else {
573
- extractSourceMessages({ project, messages, sourceFiles, output: source, workDir });
574
- }
631
+ const object = member.object;
575
632
 
576
- replaceCatalogTemplate(source, output);
577
- } finally {
578
- rmSync(workDir, { recursive: true, force: true });
633
+ if (types.isCallExpression(object)) {
634
+ return importedTranslationHook(scope, object) !== null;
579
635
  }
636
+
637
+ return types.isIdentifier(object) && isTranslationHookResultBinding(scope.getBinding(object.name));
580
638
  };
581
639
 
582
- const extractSourceCatalogTo = async (
583
- project: CatalogProject,
584
- paths: string[],
585
- output: string,
586
- ): Promise<SourceCatalog> => {
587
- const sourceFiles = normalizedSourceFiles(project.root, paths);
588
- const messages = await findSourceMessages(sourceFiles);
589
- writePotfiles(project, sourceFiles);
590
- extractCatalogTemplate({ project, messages, sourceFiles, shouldPreserveMetadataMessages: false, output });
640
+ const isTranslationMember = (scope: Scope, member: TranslationMemberExpression): boolean =>
641
+ babelMemberName(member) === "t" &&
642
+ (isImportedTranslationObject(scope, member.object) || isHookTranslationMember(scope, member));
643
+
644
+ const isDirectTranslationFunction = (scope: Scope, node: types.Node): boolean =>
645
+ types.isIdentifier(node) && directTranslationFunctionKind(scope, node.name) !== null;
646
+
647
+ const isTranslationFunctionValue = (scope: Scope, node: types.Node): boolean => {
648
+ if (isDirectTranslationFunction(scope, node)) {
649
+ return true;
650
+ }
591
651
 
592
- return { messages, sourceFiles };
652
+ return (types.isMemberExpression(node) || types.isOptionalMemberExpression(node)) &&
653
+ isTranslationMember(scope, node);
593
654
  };
594
655
 
595
- const extractSourceCatalog = async (
596
- project: CatalogProject,
597
- paths: string[],
598
- shouldPreserveMetadataMessages = true,
599
- ): Promise<SourceCatalog> => {
600
- const sourceFiles = normalizedSourceFiles(project.root, paths);
601
- const messages = await findSourceMessages(sourceFiles);
602
- writePotfiles(project, sourceFiles);
603
- extractCatalogTemplate({ project, messages, sourceFiles, shouldPreserveMetadataMessages });
656
+ const isTranslationBindCall = (scope: Scope, node: types.Node): boolean => {
657
+ if (!types.isCallExpression(node) || !types.isMemberExpression(node.callee)) {
658
+ return false;
659
+ }
604
660
 
605
- return { messages, sourceFiles };
661
+ return babelMemberName(node.callee) === "bind" &&
662
+ isTranslationFunctionValue(scope, node.callee.object);
663
+ };
664
+
665
+ const isTranslationObjectPatternAlias = (
666
+ path: NodePath<types.VariableDeclarator>,
667
+ binding: ResolvedBinding,
668
+ ): boolean => {
669
+ const pattern = path.node.id;
670
+ const initializer = path.node.init;
671
+
672
+ if (!types.isObjectPattern(pattern) ||
673
+ !isHookPatternBinding(pattern, binding.identifier.name) ||
674
+ !types.isIdentifier(initializer)) {
675
+ return false;
676
+ }
677
+
678
+ return isImportedTranslationObject(path.scope, initializer) ||
679
+ isTranslationHookResultBinding(path.scope.getBinding(initializer.name));
680
+ };
681
+
682
+ const isTranslationAliasBinding = (binding: ResolvedBinding | undefined): boolean => {
683
+ if (binding === undefined) {
684
+ return false;
685
+ }
686
+
687
+ const path = constantVariableDeclarator(binding);
688
+
689
+ if (path === null) {
690
+ return false;
691
+ }
692
+
693
+ if (isTranslationObjectPatternAlias(path, binding)) {
694
+ return true;
695
+ }
696
+
697
+ const initializer = path.node.init;
698
+
699
+ return types.isIdentifier(path.node.id) &&
700
+ initializer !== null &&
701
+ initializer !== undefined &&
702
+ (isTranslationFunctionValue(path.scope, initializer) || isTranslationBindCall(path.scope, initializer));
703
+ };
704
+
705
+ const translationFunctionKind = (scope: Scope, name: string): TranslationCallKind | null => {
706
+ const direct = directTranslationFunctionKind(scope, name);
707
+
708
+ if (direct !== null) {
709
+ return direct;
710
+ }
711
+
712
+ return isTranslationAliasBinding(scope.getBinding(name)) ? "alias" : null;
713
+ };
714
+
715
+ const translationElementKind = (path: NodePath, name: string): TranslationCallKind | null => {
716
+ const imported = importedBinding(path.scope.getBinding(name));
717
+
718
+ if (imported?.source !== "@gtkx/i18n" || !TRANS_ELEMENTS.has(imported.imported)) {
719
+ return null;
720
+ }
721
+
722
+ return imported.local === imported.imported ? "canonical" : "alias";
723
+ };
724
+
725
+ const nodeStart = (path: NodePath): number => {
726
+ const start = path.node.start;
727
+
728
+ if (start === null || start === undefined) {
729
+ throw new Error("Translation extraction could not locate a source expression");
730
+ }
731
+
732
+ return start;
733
+ };
734
+
735
+ const registerSourceMask = (node: types.Node, references: TranslationReferences): void => {
736
+ const { start, end } = node;
737
+
738
+ if (start === null || start === undefined || end === null || end === undefined) {
739
+ throw new Error("Translation extraction could not locate a source expression");
740
+ }
741
+
742
+ references.masks.push({ start, end });
743
+ };
744
+
745
+ const sourceLocationKey = (line: number, column: number): string => `${String(line)}:${String(column)}`;
746
+
747
+ const sourcePoint = (node: types.Node): SourcePoint | null => {
748
+ const location = node.loc?.start;
749
+
750
+ return location === undefined ? null : { column: location.column, line: location.line };
751
+ };
752
+
753
+ const registerSourceLocation = (node: types.Node, references: TranslationReferences): SourcePoint | null => {
754
+ const point = sourcePoint(node);
755
+
756
+ if (point !== null) {
757
+ references.locations.add(sourceLocationKey(point.line, point.column));
758
+ }
759
+
760
+ return point;
761
+ };
762
+
763
+ const babelStaticKey = (node: types.Node | null | undefined): string | null => {
764
+ if (node === null || node === undefined) {
765
+ return null;
766
+ }
767
+
768
+ if (types.isStringLiteral(node)) {
769
+ return node.value;
770
+ }
771
+
772
+ if (types.isTemplateLiteral(node) && node.expressions.length === 0) {
773
+ return node.quasis[0]?.value.cooked ?? null;
774
+ }
775
+
776
+ if (types.isParenthesizedExpression(node) ||
777
+ types.isTSAsExpression(node) ||
778
+ types.isTSSatisfiesExpression(node) ||
779
+ types.isTSTypeAssertion(node) ||
780
+ types.isTSNonNullExpression(node)) {
781
+ return babelStaticKey(node.expression);
782
+ }
783
+
784
+ return null;
785
+ };
786
+
787
+ const babelObjectExpression = (node: types.Node | null | undefined): types.ObjectExpression | null => {
788
+ if (node === null || node === undefined) {
789
+ return null;
790
+ }
791
+
792
+ if (types.isObjectExpression(node)) {
793
+ return node;
794
+ }
795
+
796
+ if (types.isParenthesizedExpression(node) ||
797
+ types.isTSAsExpression(node) ||
798
+ types.isTSSatisfiesExpression(node) ||
799
+ types.isTSTypeAssertion(node) ||
800
+ types.isTSNonNullExpression(node)) {
801
+ return babelObjectExpression(node.expression);
802
+ }
803
+
804
+ return null;
805
+ };
806
+
807
+ const babelNamedProperty = (
808
+ object: types.ObjectExpression | null,
809
+ name: string,
810
+ ): types.ObjectProperty | null => {
811
+ if (object === null) {
812
+ return null;
813
+ }
814
+
815
+ for (const property of object.properties) {
816
+ if (types.isObjectProperty(property) && babelPropertyName(property) === name) {
817
+ return property;
818
+ }
819
+ }
820
+
821
+ return null;
822
+ };
823
+
824
+ const babelStringProperty = (object: types.ObjectExpression | null, name: string): string | null => {
825
+ const property = babelNamedProperty(object, name);
826
+
827
+ if (property === null) {
828
+ return null;
829
+ }
830
+
831
+ const value = babelStaticKey(property.value);
832
+
833
+ if (value === null) {
834
+ throw new Error(`Translation ${name} values must be string literals`);
835
+ }
836
+
837
+ return value;
838
+ };
839
+
840
+ const callOptions = (call: types.CallExpression): types.ObjectExpression | null =>
841
+ babelObjectExpression(call.arguments[1]) ?? babelObjectExpression(call.arguments[2]);
842
+
843
+ const hookKeyPrefix = (callee: NodePath<types.Identifier>): string => {
844
+ const binding = callee.scope.getBinding(callee.node.name);
845
+ const hook = binding === undefined ? null : translationHookBinding(binding);
846
+
847
+ return babelStringProperty(
848
+ hook === null ? null : babelObjectExpression(hook.call.arguments[1]),
849
+ "keyPrefix",
850
+ ) ?? "";
851
+ };
852
+
853
+ const prefixedTranslationKey = (prefix: string, key: string): string => {
854
+ if (prefix.length === 0) {
855
+ return key;
856
+ }
857
+
858
+ return prefix.endsWith(".") ? `${prefix}${key}` : `${prefix}.${key}`;
859
+ };
860
+
861
+ const extractedEntryKey = (call: types.CallExpression, keyPrefix: string): string | null => {
862
+ const key = babelStaticKey(call.arguments[0]);
863
+
864
+ if (key === null) {
865
+ return null;
866
+ }
867
+
868
+ const options = callOptions(call);
869
+ const callPrefix = babelStringProperty(options, "keyPrefix");
870
+
871
+ if (callPrefix === "" && keyPrefix.length > 0) {
872
+ throw new Error("An empty call keyPrefix cannot override a hook keyPrefix during extraction");
873
+ }
874
+
875
+ const selectedPrefix = callPrefix ?? keyPrefix;
876
+ const prefixed = prefixedTranslationKey(selectedPrefix, key);
877
+ const context = babelStringProperty(options, "context");
878
+
879
+ return context === null ? prefixed : `${prefixed}${CONTEXT_SEPARATOR}${context}`;
880
+ };
881
+
882
+ const registerEntryLocation = (
883
+ key: string | null,
884
+ point: SourcePoint | null,
885
+ references: TranslationReferences,
886
+ ): void => {
887
+ if (key === null || point === null) {
888
+ return;
889
+ }
890
+
891
+ const locations = references.entryLocations.get(key) ?? [];
892
+ locations.push(point);
893
+ references.entryLocations.set(key, locations);
894
+ };
895
+
896
+ const registerCallReference = (
897
+ callPath: NodePath<types.CallExpression>,
898
+ references: TranslationReferences,
899
+ keyPrefix: string,
900
+ ): void => {
901
+ references.calls.add(nodeStart(callPath));
902
+ const firstArgument = callPath.node.arguments[0];
903
+ const point = registerSourceLocation(firstArgument ?? callPath.node, references);
904
+ registerEntryLocation(extractedEntryKey(callPath.node, keyPrefix), point, references);
905
+ };
906
+
907
+ const parserPlugins = (lang: ReturnType<typeof sourceLanguage>): ("decorators" | "jsx" | "typescript")[] => [
908
+ "decorators",
909
+ ...(lang === "ts" || lang === "tsx" ? ["typescript" as const] : []),
910
+ ...(lang === "jsx" || lang === "tsx" ? ["jsx" as const] : []),
911
+ ];
912
+
913
+ const registerIdentifierTranslationCall = (
914
+ callPath: NodePath<types.CallExpression>,
915
+ callee: NodePath,
916
+ references: TranslationReferences,
917
+ ): void => {
918
+ if (!callee.isIdentifier()) {
919
+ return;
920
+ }
921
+
922
+ const kind = translationFunctionKind(callee.scope, callee.node.name);
923
+
924
+ if (kind === "alias") {
925
+ throw new Error("Translation calls must use the imported name t");
926
+ }
927
+
928
+ if (kind === "canonical") {
929
+ registerCallReference(callPath, references, hookKeyPrefix(callee));
930
+ } else if (callee.node.name === "t") {
931
+ registerSourceMask(callee.node, references);
932
+ }
933
+ };
934
+
935
+ const registerTranslationCall = (
936
+ callPath: NodePath<types.CallExpression>,
937
+ references: TranslationReferences,
938
+ ): void => {
939
+ assertSupportedWrappedCallee(callPath);
940
+ const callee = callPath.get("callee");
941
+
942
+ if (callee.isIdentifier()) {
943
+ registerIdentifierTranslationCall(callPath, callee, references);
944
+
945
+ return;
946
+ }
947
+
948
+ if (callee.isMemberExpression() && isTranslationMember(callee.scope, callee.node)) {
949
+ throw new Error("Translation calls must use the named t export");
950
+ }
951
+ };
952
+
953
+ const unwrapTranslationCallee = (node: types.Node): types.Node => {
954
+ if (types.isParenthesizedExpression(node) ||
955
+ types.isTSAsExpression(node) ||
956
+ types.isTSSatisfiesExpression(node) ||
957
+ types.isTSTypeAssertion(node) ||
958
+ types.isTSNonNullExpression(node)) {
959
+ return unwrapTranslationCallee(node.expression);
960
+ }
961
+
962
+ return node;
963
+ };
964
+
965
+ const isTranslationCallee = (scope: Scope, node: types.Node): boolean => {
966
+ if (types.isIdentifier(node)) {
967
+ return translationFunctionKind(scope, node.name) !== null;
968
+ }
969
+
970
+ return (types.isMemberExpression(node) || types.isOptionalMemberExpression(node)) &&
971
+ isTranslationMember(scope, node);
972
+ };
973
+
974
+ const assertSupportedWrappedCallee = (callPath: NodePath<types.CallExpression>): void => {
975
+ const callee = callPath.node.callee;
976
+ const unwrapped = unwrapTranslationCallee(callee);
977
+
978
+ if (unwrapped !== callee && isTranslationCallee(callPath.scope, unwrapped)) {
979
+ throw new Error("Translation call expressions cannot wrap their callee");
980
+ }
981
+ };
982
+
983
+ const assertSupportedOptionalCall = (callPath: NodePath<types.OptionalCallExpression>): void => {
984
+ const callee = unwrapTranslationCallee(callPath.node.callee);
985
+
986
+ if (isTranslationCallee(callPath.scope, callee)) {
987
+ throw new Error("Translation calls cannot use optional chaining");
988
+ }
989
+ };
990
+
991
+ const assertUnsupportedTranslationTag = (tagPath: NodePath<types.TaggedTemplateExpression>): void => {
992
+ const tag = unwrapTranslationCallee(tagPath.node.tag);
993
+
994
+ if (isTranslationCallee(tagPath.scope, tag)) {
995
+ throw new Error("Translation calls cannot use tagged-template syntax");
996
+ }
997
+ };
998
+
999
+ const registerUnrecognizedElementMasks = (
1000
+ elementPath: NodePath,
1001
+ name: NodePath<types.JSXIdentifier>,
1002
+ references: TranslationReferences,
1003
+ ): void => {
1004
+ registerSourceMask(name.node, references);
1005
+ const parent = elementPath.parentPath.node;
1006
+ const closingName = types.isJSXElement(parent) ? parent.closingElement?.name : null;
1007
+
1008
+ if (types.isJSXIdentifier(closingName)) {
1009
+ registerSourceMask(closingName, references);
1010
+ }
1011
+ };
1012
+
1013
+ const registerTranslationElement = (elementPath: NodePath, references: TranslationReferences): void => {
1014
+ const name = elementPath.get("name");
1015
+
1016
+ if (!name.isJSXIdentifier()) {
1017
+ return;
1018
+ }
1019
+
1020
+ const kind = translationElementKind(name, name.node.name);
1021
+
1022
+ if (kind === "alias") {
1023
+ throw new Error("Translation components must use their imported names");
1024
+ }
1025
+
1026
+ if (kind === "canonical") {
1027
+ references.elements.add(nodeStart(elementPath));
1028
+ registerSourceLocation(elementPath.node, references);
1029
+ } else if (TRANS_ELEMENTS.has(name.node.name)) {
1030
+ registerUnrecognizedElementMasks(elementPath, name, references);
1031
+ }
1032
+ };
1033
+
1034
+ const maskUnrecognizedTranslations = (source: string, references: TranslationReferences): string => {
1035
+ let masked = source;
1036
+ const masks = references.masks.toSorted((left, right) => right.start - left.start);
1037
+
1038
+ for (const { start, end } of masks) {
1039
+ masked = `${masked.slice(0, start)}${"_".repeat(end - start)}${masked.slice(end)}`;
1040
+ }
1041
+
1042
+ return masked;
1043
+ };
1044
+
1045
+ const translationReferences = (
1046
+ path: string,
1047
+ source: string,
1048
+ lang: ReturnType<typeof sourceLanguage>,
1049
+ ): TranslationReferences => {
1050
+ const parsed = parseBabelSync(source, {
1051
+ ast: true,
1052
+ babelrc: false,
1053
+ configFile: false,
1054
+ filename: path,
1055
+ parserOpts: { createParenthesizedExpressions: true, plugins: parserPlugins(lang) },
1056
+ sourceType: "module",
1057
+ });
1058
+
1059
+ if (parsed === null) {
1060
+ throw new Error(`Translation extraction could not parse ${path}`);
1061
+ }
1062
+
1063
+ const references: TranslationReferences = {
1064
+ calls: new Set(),
1065
+ elements: new Set(),
1066
+ entryLocations: new Map(),
1067
+ locations: new Set(),
1068
+ masks: [],
1069
+ };
1070
+
1071
+ traverse(parsed, {
1072
+ CallExpression(callPath) {
1073
+ registerTranslationCall(callPath, references);
1074
+ },
1075
+ JSXOpeningElement(elementPath) {
1076
+ registerTranslationElement(elementPath, references);
1077
+ },
1078
+ OptionalCallExpression(callPath) {
1079
+ assertSupportedOptionalCall(callPath);
1080
+ },
1081
+ TaggedTemplateExpression(tagPath) {
1082
+ assertUnsupportedTranslationTag(tagPath);
1083
+ },
1084
+ });
1085
+
1086
+ return references;
1087
+ };
1088
+
1089
+ const assertSupportedTranslationCall = (node: ESTree.CallExpression, references: TranslationReferences): void => {
1090
+ if (!references.calls.has(node.start)) {
1091
+ return;
1092
+ }
1093
+
1094
+ assertStaticTranslationKey(node.arguments[0]);
1095
+ assertSupportedTranslationOptions(node.arguments[1], node.arguments[2]);
1096
+ };
1097
+
1098
+ const assertSupportedTranslationOptions = (
1099
+ second: ESTree.Node | null | undefined,
1100
+ third: ESTree.Node | null | undefined,
1101
+ ): void => {
1102
+ if (third !== undefined && third !== null) {
1103
+ assertStaticTranslationKey(second);
1104
+ assertSupportedOptionObject(third);
1105
+
1106
+ return;
1107
+ }
1108
+
1109
+ if (second === undefined || second === null || isStaticTranslationKey(second)) {
1110
+ return;
1111
+ }
1112
+
1113
+ assertSupportedOptionObject(second);
1114
+ };
1115
+
1116
+ const isNamedJsxAttribute = (attribute: ESTree.JSXAttribute, name: string): boolean =>
1117
+ attribute.name.type === "JSXIdentifier" && attribute.name.name === name;
1118
+
1119
+ const assertSupportedTransKey = (attribute: ESTree.JSXAttribute): void => {
1120
+ if (!isNamedJsxAttribute(attribute, "i18nKey")) {
1121
+ return;
1122
+ }
1123
+
1124
+ const key = attribute.value?.type === "JSXExpressionContainer"
1125
+ ? attribute.value.expression
1126
+ : attribute.value;
1127
+ assertStaticTranslationKey(key);
1128
+ };
1129
+
1130
+ const assertSupportedTransStringAttribute = (attribute: ESTree.JSXAttribute, name: string): void => {
1131
+ if (!isNamedJsxAttribute(attribute, name)) {
1132
+ return;
1133
+ }
1134
+
1135
+ const value = attribute.value?.type === "JSXExpressionContainer"
1136
+ ? attribute.value.expression
1137
+ : attribute.value;
1138
+ assertStaticTranslationKey(value);
1139
+ };
1140
+
1141
+ const assertSupportedTransOptions = (attribute: ESTree.JSXAttribute): void => {
1142
+ if (isNamedJsxAttribute(attribute, "tOptions") && attribute.value?.type === "JSXExpressionContainer") {
1143
+ assertSupportedOptionObject(attribute.value.expression);
1144
+ }
1145
+ };
1146
+
1147
+ const assertSupportedTransAttribute = (attribute: ESTree.JSXAttributeItem): void => {
1148
+ if (attribute.type === "JSXSpreadAttribute") {
1149
+ throw new Error("Translation extraction requires explicit component properties");
1150
+ }
1151
+
1152
+ assertSupportedTransKey(attribute);
1153
+ assertSupportedTransStringAttribute(attribute, "context");
1154
+ assertSupportedTransStringAttribute(attribute, "defaults");
1155
+ assertSupportedTransOptions(attribute);
1156
+ };
1157
+
1158
+ const assertSupportedTransElement = (node: ESTree.JSXOpeningElement, references: TranslationReferences): void => {
1159
+ if (!references.elements.has(node.start)) {
1160
+ return;
1161
+ }
1162
+
1163
+ for (const attribute of node.attributes) {
1164
+ assertSupportedTransAttribute(attribute);
1165
+ }
1166
+ };
1167
+
1168
+ const assertSupportedTranslationSyntax = (path: string, source: string): TranslationReferences => {
1169
+ const lang = sourceLanguage(path);
1170
+
1171
+ if (lang === undefined) {
1172
+ return { calls: new Set(), elements: new Set(), entryLocations: new Map(), locations: new Set(), masks: [] };
1173
+ }
1174
+
1175
+ const parsed = parseSync(path, source, { lang });
1176
+ const references = translationReferences(path, source, lang);
1177
+
1178
+ new Visitor({
1179
+ CallExpression(node) {
1180
+ assertSupportedTranslationCall(node, references);
1181
+ },
1182
+ JSXOpeningElement(node) {
1183
+ assertSupportedTransElement(node, references);
1184
+ },
1185
+ }).visit(parsed.program);
1186
+
1187
+ return references;
1188
+ };
1189
+
1190
+ const extractedLocationFile = (root: string, file: string): string =>
1191
+ resolve(root, file);
1192
+
1193
+ const isRecognizedLocation = (
1194
+ root: string,
1195
+ references: Map<string, TranslationReferences>,
1196
+ location: SourceLocation,
1197
+ ): boolean => {
1198
+ if (location.line === undefined || location.column === undefined) {
1199
+ return false;
1200
+ }
1201
+
1202
+ const sourceReferences = references.get(extractedLocationFile(root, location.file));
1203
+
1204
+ return sourceReferences?.locations.has(sourceLocationKey(location.line, location.column)) === true;
1205
+ };
1206
+
1207
+ const recognizedKeyLocations = (
1208
+ references: Map<string, TranslationReferences>,
1209
+ extractedKey: string,
1210
+ ): ExtractedLocation[] => {
1211
+ const locations: ExtractedLocation[] = [];
1212
+
1213
+ for (const [file, sourceReferences] of references) {
1214
+ const points = sourceReferences.entryLocations.get(extractedKey) ?? [];
1215
+
1216
+ for (const point of points) {
1217
+ locations.push({ file, column: point.column, line: point.line });
1218
+ }
1219
+ }
1220
+
1221
+ return locations;
1222
+ };
1223
+
1224
+ const recognizedEntry = (
1225
+ root: string,
1226
+ references: Map<string, TranslationReferences>,
1227
+ entry: ExtractedKey,
1228
+ ): ExtractedKey | null => {
1229
+ const extractedLocations = entry.locations ?? [];
1230
+ const locations = extractedLocations.length === 0
1231
+ ? recognizedKeyLocations(references, entry.key)
1232
+ : extractedLocations.filter((location) => isRecognizedLocation(root, references, location));
1233
+
1234
+ return locations.length === 0 ? null : { ...entry, locations };
1235
+ };
1236
+
1237
+ type NestedReferenceParts = { base: string; options: string };
1238
+
1239
+ const quotedNestedReferenceParts = (content: string, quote: string): NestedReferenceParts | null => {
1240
+ const end = content.indexOf(quote, 1);
1241
+
1242
+ return end === -1
1243
+ ? null
1244
+ : { base: content.slice(1, end), options: content.slice(end + 1) };
1245
+ };
1246
+
1247
+ const nestedReferenceParts = (value: string): NestedReferenceParts | null => {
1248
+ const content = value.trim();
1249
+ const quote = content[0];
1250
+
1251
+ if (quote === "\"" || quote === "'") {
1252
+ return quotedNestedReferenceParts(content, quote);
1253
+ }
1254
+
1255
+ const separator = content.indexOf(",");
1256
+ const base = (separator === -1 ? content : content.slice(0, separator)).trim();
1257
+
1258
+ return base.length === 0
1259
+ ? null
1260
+ : { base, options: separator === -1 ? "" : content.slice(separator + 1) };
1261
+ };
1262
+
1263
+ const nestedReferenceContext = (options: string): string | null => {
1264
+ const match = NESTED_CONTEXT.exec(options);
1265
+ const context = match?.[1] ?? match?.[2] ?? null;
1266
+
1267
+ return context === null || context.length === 0 ? null : context;
1268
+ };
1269
+
1270
+ const adjustedParenthesisDepth = (character: string | undefined, depth: number): number => {
1271
+ if (character === "(") {
1272
+ return depth + 1;
1273
+ }
1274
+
1275
+ return character === ")" ? depth - 1 : depth;
1276
+ };
1277
+
1278
+ const nestedReferenceEnd = (text: string, start: number): number => {
1279
+ let depth = 0;
1280
+ const tokens = text.slice(start).matchAll(NESTED_STRUCTURE_TOKEN);
1281
+
1282
+ for (const token of tokens) {
1283
+ const character = token[0];
1284
+
1285
+ if (character === ")" && depth === 0) {
1286
+ return start + token.index;
1287
+ }
1288
+
1289
+ depth = adjustedParenthesisDepth(character, depth);
1290
+ }
1291
+
1292
+ return -1;
1293
+ };
1294
+
1295
+ const nestedReferenceContents = (text: string): string[] => {
1296
+ const contents: string[] = [];
1297
+ let offset = 0;
1298
+
1299
+ while (offset < text.length) {
1300
+ const prefix = text.indexOf("$t(", offset);
1301
+
1302
+ if (prefix === -1) {
1303
+ break;
1304
+ }
1305
+
1306
+ const start = prefix + 3;
1307
+ const end = nestedReferenceEnd(text, start);
1308
+
1309
+ if (end === -1) {
1310
+ break;
1311
+ }
1312
+
1313
+ contents.push(text.slice(start, end));
1314
+ offset = end + 1;
1315
+ }
1316
+
1317
+ return contents;
1318
+ };
1319
+
1320
+ const nestedPluralKeys = (base: string, context: string | null): string[] => {
1321
+ const pluralBase = context === null ? base : `${base}${CONTEXT_SEPARATOR}${context}`;
1322
+
1323
+ return [`${pluralBase}_one`, `${pluralBase}_other`];
1324
+ };
1325
+
1326
+ const nestedReferenceKeys = (content: string): string[] => {
1327
+ const parts = nestedReferenceParts(content);
1328
+
1329
+ if (parts === null) {
1330
+ return [];
1331
+ }
1332
+
1333
+ const context = nestedReferenceContext(parts.options);
1334
+ const contextual = context === null
1335
+ ? []
1336
+ : [`${parts.base}_${context}`, `${parts.base}${CONTEXT_SEPARATOR}${context}`];
1337
+
1338
+ return NESTED_COUNT.test(parts.options)
1339
+ ? [parts.base, ...contextual, ...nestedPluralKeys(parts.base, context)]
1340
+ : [parts.base, ...contextual];
1341
+ };
1342
+
1343
+ const nestedEntryKeys = (text: string): Set<string> => {
1344
+ const keys: Set<string> = new Set();
1345
+
1346
+ for (const content of nestedReferenceContents(text)) {
1347
+ for (const key of nestedReferenceKeys(content)) {
1348
+ keys.add(key);
1349
+ }
1350
+ }
1351
+
1352
+ return keys;
1353
+ };
1354
+
1355
+ const registerNestedKeyLocations = (
1356
+ root: string,
1357
+ references: Map<string, TranslationReferences>,
1358
+ key: string,
1359
+ locations: ExtractedLocation[],
1360
+ ): void => {
1361
+ for (const location of locations) {
1362
+ if (location.line === undefined || location.column === undefined) {
1363
+ continue;
1364
+ }
1365
+
1366
+ const sourceReferences = references.get(resolve(root, location.file));
1367
+
1368
+ if (sourceReferences !== undefined) {
1369
+ registerEntryLocation(key, { column: location.column, line: location.line }, sourceReferences);
1370
+ }
1371
+ }
1372
+ };
1373
+
1374
+ const registerNestedTextLocations = (
1375
+ root: string,
1376
+ references: Map<string, TranslationReferences>,
1377
+ text: string,
1378
+ locations: ExtractedLocation[],
1379
+ ): void => {
1380
+ for (const key of nestedEntryKeys(text)) {
1381
+ registerNestedKeyLocations(root, references, key, locations);
1382
+ }
1383
+ };
1384
+
1385
+ const registerNestedEntryLocations = (
1386
+ root: string,
1387
+ references: Map<string, TranslationReferences>,
1388
+ entry: ExtractedKey,
1389
+ locations: ExtractedLocation[],
1390
+ ): void => {
1391
+ registerNestedTextLocations(root, references, entry.key, locations);
1392
+
1393
+ if (typeof entry.defaultValue === "string") {
1394
+ registerNestedTextLocations(root, references, entry.defaultValue, locations);
1395
+ }
1396
+ };
1397
+
1398
+ const didRegisterNestedLocationPass = (
1399
+ root: string,
1400
+ references: Map<string, TranslationReferences>,
1401
+ entries: ExtractedKeysMap,
1402
+ processed: Set<string>,
1403
+ ): boolean => {
1404
+ let didRegister = false;
1405
+
1406
+ for (const [mapKey, entry] of entries) {
1407
+ if (processed.has(mapKey)) {
1408
+ continue;
1409
+ }
1410
+
1411
+ const recognized = recognizedEntry(root, references, entry);
1412
+
1413
+ if (recognized !== null) {
1414
+ processed.add(mapKey);
1415
+ registerNestedEntryLocations(root, references, entry, recognized.locations ?? []);
1416
+ didRegister = true;
1417
+ }
1418
+ }
1419
+
1420
+ return didRegister;
1421
+ };
1422
+
1423
+ const registerNestedLocations = (
1424
+ root: string,
1425
+ references: Map<string, TranslationReferences>,
1426
+ entries: ExtractedKeysMap,
1427
+ ): void => {
1428
+ const processed: Set<string> = new Set();
1429
+ let hasRegisteredLocations = true;
1430
+
1431
+ while (hasRegisteredLocations) {
1432
+ hasRegisteredLocations = didRegisterNestedLocationPass(root, references, entries, processed);
1433
+ }
1434
+ };
1435
+
1436
+ const retainRecognizedEntries = (
1437
+ root: string,
1438
+ references: Map<string, TranslationReferences>,
1439
+ entries: ExtractedKeysMap,
1440
+ ): void => {
1441
+ for (const [mapKey, entry] of entries) {
1442
+ const recognized = recognizedEntry(root, references, entry);
1443
+
1444
+ if (recognized === null) {
1445
+ entries.delete(mapKey);
1446
+ } else {
1447
+ entries.set(mapKey, recognized);
1448
+ }
1449
+ }
1450
+ };
1451
+
1452
+ const fileSourcePointKey = (file: string, line: number, column: number): string =>
1453
+ `${file}\0${sourceLocationKey(line, column)}`;
1454
+
1455
+ const registerExtractedSourcePoint = (
1456
+ root: string,
1457
+ points: Set<string>,
1458
+ location: ExtractedLocation,
1459
+ ): void => {
1460
+ if (location.line !== undefined && location.column !== undefined) {
1461
+ points.add(fileSourcePointKey(resolve(root, location.file), location.line, location.column));
1462
+ }
1463
+ };
1464
+
1465
+ const extractedSourcePoints = (root: string, entries: ExtractedKeysMap): Set<string> => {
1466
+ const points: Set<string> = new Set();
1467
+
1468
+ for (const entry of entries.values()) {
1469
+ const locations = entry.locations ?? [];
1470
+
1471
+ for (const location of locations) {
1472
+ registerExtractedSourcePoint(root, points, location);
1473
+ }
1474
+ }
1475
+
1476
+ return points;
1477
+ };
1478
+
1479
+ const hasMissingSourcePoint = (
1480
+ file: string,
1481
+ references: TranslationReferences,
1482
+ extracted: Set<string>,
1483
+ ): boolean =>
1484
+ references.locations.values().some((location) => !extracted.has(`${file}\0${location}`));
1485
+
1486
+ const assertAllReferencesExtracted = (
1487
+ root: string,
1488
+ references: Map<string, TranslationReferences>,
1489
+ entries: ExtractedKeysMap,
1490
+ ): void => {
1491
+ const extracted = extractedSourcePoints(root, entries);
1492
+
1493
+ for (const [file, sourceReferences] of references) {
1494
+ if (hasMissingSourcePoint(file, sourceReferences, extracted)) {
1495
+ throw new Error("A translation expression could not be extracted safely");
1496
+ }
1497
+ }
1498
+ };
1499
+
1500
+ const findSourceMessages = async (root: string, sourceFiles: string[]): Promise<SourceMessage[]> => {
1501
+ if (sourceFiles.length === 0) {
1502
+ clearI18nResources(root);
1503
+
1504
+ return [];
1505
+ }
1506
+
1507
+ const sources = new Map(sourceFiles.map((file) => [file, readFileSync(file, "utf8")]));
1508
+ const references: Map<string, TranslationReferences> = new Map();
1509
+
1510
+ for (const [file, source] of sources) {
1511
+ references.set(file, assertSupportedTranslationSyntax(file, source));
1512
+ }
1513
+
1514
+ let extracted: ExtractedKeysMap | undefined;
1515
+ const { logger, reports } = quietLogger();
1516
+
1517
+ const capture: Plugin = {
1518
+ name: "gtkx-gettext",
1519
+ onLoad(code, path) {
1520
+ const sourceReferences = references.get(resolve(root, path));
1521
+
1522
+ return sourceReferences === undefined
1523
+ ? code
1524
+ : maskUnrecognizedTranslations(code, sourceReferences);
1525
+ },
1526
+ onEnd(keys) {
1527
+ registerNestedLocations(root, references, keys);
1528
+ retainRecognizedEntries(root, references, keys);
1529
+ assertAllReferencesExtracted(root, references, keys);
1530
+ extracted = keys;
1531
+ },
1532
+ };
1533
+
1534
+ const result = await runExtractor(i18nToolkitConfig(root, sourceFiles, [capture]), {
1535
+ logger,
1536
+ quiet: true,
1537
+ syncPrimaryWithDefaults: true,
1538
+ trustDerivedDefaults: true,
1539
+ });
1540
+
1541
+ if (extracted === undefined || reports.length > 0 || result.hasErrors) {
1542
+ throw new Error(reports.join("\n") || "i18next extraction failed");
1543
+ }
1544
+
1545
+ return sourceMessages(extracted.values().toArray())
1546
+ .map((message) => inferLocations(message, sources))
1547
+ .toSorted(compareMessages);
1548
+ };
1549
+
1550
+ const projectPath = (root: string, path: string): string | null => {
1551
+ const absolute = isAbsolute(path) ? resolve(path) : resolve(root, path);
1552
+ const projectRelative = relative(root, absolute);
1553
+
1554
+ if (!isPathInside(root, absolute)) {
1555
+ return null;
1556
+ }
1557
+
1558
+ return toPosixPath(projectRelative);
1559
+ };
1560
+
1561
+ const writePotfiles = (project: CatalogProject, sourceFiles: string[]): string => {
1562
+ const paths = sourceFiles
1563
+ .map((path) => projectPath(project.root, path))
1564
+ .filter((path): path is string => path !== null);
1565
+
1566
+ const sorted = [...new Set(paths)].toSorted((left, right) => left.localeCompare(right));
1567
+ const target = resolve(project.poDir, POTFILES_FILENAME);
1568
+ writeFileSync(target, sorted.length === 0 ? "" : `${sorted.join("\n")}\n`);
1569
+
1570
+ return target;
1571
+ };
1572
+
1573
+ const renderCatalogCall = (message: SourceMessage): string => {
1574
+ const msgid = JSON.stringify(message.singular);
1575
+
1576
+ if (message.plural !== null) {
1577
+ const plural = JSON.stringify(message.plural);
1578
+
1579
+ return message.context === null
1580
+ ? `ngettext(${msgid}, ${plural}, 0);`
1581
+ : `npgettext(${JSON.stringify(message.context)}, ${msgid}, ${plural}, 0);`;
1582
+ }
1583
+
1584
+ return message.context === null
1585
+ ? `gettext(${msgid});`
1586
+ : `pgettext(${JSON.stringify(message.context)}, ${msgid});`;
1587
+ };
1588
+
1589
+ const locatedOwners = (
1590
+ project: CatalogProject,
1591
+ message: SourceMessage,
1592
+ ): { path: string; line: number | undefined }[] => {
1593
+ const owners: { path: string; line: number | undefined }[] = [];
1594
+
1595
+ for (const location of message.locations) {
1596
+ const path = projectPath(project.root, location.file);
1597
+
1598
+ if (path !== null) {
1599
+ owners.push({ path, line: location.line });
1600
+ }
1601
+ }
1602
+
1603
+ const unique = new Map(owners.map((owner) => [`${owner.path}\0${String(owner.line)}`, owner]));
1604
+
1605
+ return unique.values().toArray();
1606
+ };
1607
+
1608
+ const sourceOwners = (
1609
+ project: CatalogProject,
1610
+ message: SourceMessage,
1611
+ ): { path: string; line: number | undefined }[] => {
1612
+ const located = locatedOwners(project, message);
1613
+
1614
+ return located.length > 0 ? located : [{ path: SYNTHETIC_FILENAME, line: undefined }];
1615
+ };
1616
+
1617
+ const syntheticEntries = (
1618
+ project: CatalogProject,
1619
+ messages: SourceMessage[],
1620
+ ): Map<string, SyntheticEntry[]> => {
1621
+ const entries: Map<string, SyntheticEntry[]> = new Map();
1622
+
1623
+ for (const message of messages) {
1624
+ for (const owner of sourceOwners(project, message)) {
1625
+ const owned = entries.get(owner.path) ?? [];
1626
+ owned.push({ call: renderCatalogCall(message), line: owner.line });
1627
+ entries.set(owner.path, owned);
1628
+ }
1629
+ }
1630
+
1631
+ if (entries.size === 0) {
1632
+ entries.set(SYNTHETIC_FILENAME, []);
1633
+ }
1634
+
1635
+ return entries;
1636
+ };
1637
+
1638
+ const compareSyntheticEntries = (left: SyntheticEntry, right: SyntheticEntry): number =>
1639
+ (left.line ?? Number.MAX_SAFE_INTEGER) - (right.line ?? Number.MAX_SAFE_INTEGER);
1640
+
1641
+ const lastSyntheticLine = (entries: SyntheticEntry[]): number => {
1642
+ let line = 0;
1643
+
1644
+ for (const entry of entries) {
1645
+ line = Math.max(line, entry.line ?? 0);
1646
+ }
1647
+
1648
+ return line;
1649
+ };
1650
+
1651
+ const appendSyntheticEntry = (lines: string[], entry: SyntheticEntry, requestedLine: number): void => {
1652
+ const index = requestedLine - 1;
1653
+ const existing = lines[index];
1654
+
1655
+ if (existing === undefined || existing.length === 0) {
1656
+ lines[index] = entry.call;
1657
+
1658
+ return;
1659
+ }
1660
+
1661
+ lines[index] = `${existing} ${entry.call}`;
1662
+ };
1663
+
1664
+ const renderSyntheticSource = (entries: SyntheticEntry[]): string => {
1665
+ const lines: string[] = [];
1666
+ const sorted = entries.toSorted(compareSyntheticEntries);
1667
+ let nextLine = lastSyntheticLine(entries) + 1;
1668
+
1669
+ for (const entry of sorted) {
1670
+ const requestedLine = Math.max(1, entry.line ?? nextLine++);
1671
+ appendSyntheticEntry(lines, entry, requestedLine);
1672
+ }
1673
+
1674
+ return `${lines.join("\n")}\n`;
1675
+ };
1676
+
1677
+ const writeSyntheticSources = (
1678
+ workDir: string,
1679
+ project: CatalogProject,
1680
+ messages: SourceMessage[],
1681
+ ): string => {
1682
+ const entries = syntheticEntries(project, messages);
1683
+ const paths: string[] = [];
1684
+
1685
+ for (const [path, owned] of entries) {
1686
+ const target = join(workDir, path);
1687
+ mkdirSync(dirname(target), { recursive: true });
1688
+ writeFileSync(target, renderSyntheticSource(owned));
1689
+ paths.push(path);
1690
+ }
1691
+
1692
+ const potfiles = join(workDir, POTFILES_FILENAME);
1693
+ writeFileSync(potfiles, `${paths.toSorted((left, right) => left.localeCompare(right)).join("\n")}\n`);
1694
+
1695
+ return potfiles;
1696
+ };
1697
+
1698
+ const extractSourceMessages = ({ project, messages, output, workDir }: SourceExtraction): void => {
1699
+ const potfilesPath = writeSyntheticSources(workDir, project, messages);
1700
+
1701
+ runCliTool({
1702
+ tool: "xgettext",
1703
+ args: [
1704
+ "--language=JavaScript",
1705
+ "--from-code=UTF-8",
1706
+ "--force-po",
1707
+ "--keyword=gettext:1",
1708
+ "--keyword=ngettext:1,2",
1709
+ "--keyword=pgettext:1c,2",
1710
+ "--keyword=npgettext:1c,2,3",
1711
+ `--directory=${workDir}`,
1712
+ `--files-from=${potfilesPath}`,
1713
+ `--output=${output}`,
1714
+ ],
1715
+ target: output,
1716
+ });
1717
+ };
1718
+
1719
+ const extractMetadataFragment = (project: CatalogProject, input: string, output: string): void => {
1720
+ runCliTool({
1721
+ tool: "msggrep",
1722
+ args: [
1723
+ "--force-po",
1724
+ `--output-file=${output}`,
1725
+ ...metadataTemplateFiles(project).map((file) => `--location=${file.relativePath}`),
1726
+ input,
1727
+ ],
1728
+ target: input,
1729
+ });
1730
+ };
1731
+
1732
+ const joinMetadataFragment = (output: string, fragment: string): void => {
1733
+ runCliTool({
1734
+ tool: "xgettext",
1735
+ args: ["--language=PO", "--join-existing", "--force-po", `--output=${output}`, fragment],
1736
+ target: output,
1737
+ });
1738
+ };
1739
+
1740
+ const extractCatalogTemplate = ({
1741
+ project,
1742
+ messages,
1743
+ shouldPreserveMetadataMessages,
1744
+ output = resolve(project.poDir, `${project.domain}.pot`),
1745
+ }: CatalogTemplateExtraction): void => {
1746
+ const workDir = mkdtempSync(join(project.poDir, ".gtkx-i18n-"));
1747
+ const source = join(workDir, "source.pot");
1748
+ const hasPreviousTemplate = existsSync(output);
1749
+
1750
+ try {
1751
+ if (shouldPreserveMetadataMessages && hasPreviousTemplate) {
1752
+ const fragment = join(workDir, "metadata.pot");
1753
+ extractMetadataFragment(project, output, fragment);
1754
+ extractSourceMessages({ project, messages, output: source, workDir });
1755
+ joinMetadataFragment(source, fragment);
1756
+ } else {
1757
+ extractSourceMessages({ project, messages, output: source, workDir });
1758
+ }
1759
+
1760
+ replaceCatalogTemplate(source, output);
1761
+ } finally {
1762
+ rmSync(workDir, { recursive: true, force: true });
1763
+ }
1764
+ };
1765
+
1766
+ const extractSourceCatalogTo = async (
1767
+ project: CatalogProject,
1768
+ paths: string[],
1769
+ output: string,
1770
+ ): Promise<void> => {
1771
+ const sourceFiles = normalizedSourceFiles(project.root, paths);
1772
+ const messages = await findSourceMessages(project.root, sourceFiles);
1773
+ writePotfiles(project, sourceFiles);
1774
+ extractCatalogTemplate({ project, messages, shouldPreserveMetadataMessages: false, output });
1775
+ };
1776
+
1777
+ const extractSourceCatalog = async (
1778
+ project: CatalogProject,
1779
+ paths: string[],
1780
+ shouldPreserveMetadataMessages = true,
1781
+ ): Promise<void> => {
1782
+ const sourceFiles = normalizedSourceFiles(project.root, paths);
1783
+ const messages = await findSourceMessages(project.root, sourceFiles);
1784
+ writePotfiles(project, sourceFiles);
1785
+ extractCatalogTemplate({ project, messages, shouldPreserveMetadataMessages });
606
1786
  };
607
1787
 
608
- export { type SourceMessage, extractSourceCatalog, extractSourceCatalogTo };
1788
+ export { extractSourceCatalog, extractSourceCatalogTo };