gt 2.9.0 → 2.10.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.
Files changed (29) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli/base.js +1 -4
  3. package/dist/extraction/postProcess.js +14 -0
  4. package/dist/formats/files/aggregateFiles.js +80 -0
  5. package/dist/formats/json/parseJson.d.ts +1 -1
  6. package/dist/formats/json/parseJson.js +6 -4
  7. package/dist/formats/parseKeyedMetadata.d.ts +23 -0
  8. package/dist/formats/parseKeyedMetadata.js +111 -0
  9. package/dist/generated/version.d.ts +1 -1
  10. package/dist/generated/version.js +1 -1
  11. package/dist/react/jsx/utils/extractSourceCode.d.ts +15 -0
  12. package/dist/react/jsx/utils/extractSourceCode.js +39 -0
  13. package/dist/react/jsx/utils/jsxParsing/parseJsx.d.ts +1 -0
  14. package/dist/react/jsx/utils/jsxParsing/parseJsx.js +11 -0
  15. package/dist/react/jsx/utils/parseStringFunction.js +2 -0
  16. package/dist/react/jsx/utils/stringParsing/processTranslationCall/extractStringEntryMetadata.d.ts +12 -1
  17. package/dist/react/jsx/utils/stringParsing/processTranslationCall/extractStringEntryMetadata.js +14 -1
  18. package/dist/react/jsx/utils/stringParsing/processTranslationCall/index.js +3 -0
  19. package/dist/react/jsx/utils/stringParsing/types.d.ts +4 -0
  20. package/dist/react/parse/createInlineUpdates.d.ts +1 -1
  21. package/dist/react/parse/createInlineUpdates.js +3 -1
  22. package/dist/translation/parse.d.ts +1 -1
  23. package/dist/translation/parse.js +2 -2
  24. package/dist/translation/stage.js +1 -1
  25. package/dist/translation/validate.js +2 -2
  26. package/dist/types/index.d.ts +1 -0
  27. package/dist/utils/constants.d.ts +1 -0
  28. package/dist/utils/constants.js +2 -0
  29. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # gtx-cli
2
2
 
3
+ ## 2.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#1104](https://github.com/generaltranslation/gt/pull/1104) [`51430bd`](https://github.com/generaltranslation/gt/commit/51430bd1d85a4937ff3b4dcd0090d79e3b4c1504) Thanks [@fernando-aviles](https://github.com/fernando-aviles)! - Adding metadata support for keyed file types
8
+
9
+ ### Patch Changes
10
+
11
+ - [#1101](https://github.com/generaltranslation/gt/pull/1101) [`437a389`](https://github.com/generaltranslation/gt/commit/437a3898f1daa0a40ac033c2cc1bb94b4a0fd86b) Thanks [@ErnestM1234](https://github.com/ErnestM1234)! - fix: remove tw content json from init
12
+
13
+ - [#1103](https://github.com/generaltranslation/gt/pull/1103) [`7164ceb`](https://github.com/generaltranslation/gt/commit/7164ceb9785863cdf4dc659fe5bd0f87511a5bed) Thanks [@fernando-aviles](https://github.com/fernando-aviles)! - Extract code metadata
14
+
3
15
  ## 2.9.0
4
16
 
5
17
  ### Minor Changes
package/dist/cli/base.js CHANGED
@@ -399,10 +399,7 @@ See https://generaltranslation.com/en/docs/next/guides/local-tx`);
399
399
  { value: 'ts', label: FILE_EXT_TO_EXT_LABEL.ts },
400
400
  { value: 'js', label: FILE_EXT_TO_EXT_LABEL.js },
401
401
  { value: 'yaml', label: FILE_EXT_TO_EXT_LABEL.yaml },
402
- {
403
- value: 'twilioContentJson',
404
- label: FILE_EXT_TO_EXT_LABEL.twilioContentJson,
405
- },
402
+ // TWILIO_CONTENT_JSON not supported in CLI init as its too niche
406
403
  ],
407
404
  required: !isUsingGT,
408
405
  });
@@ -47,6 +47,20 @@ export function dedupeUpdates(updates) {
47
47
  if (existingPaths.length) {
48
48
  existing.metadata.filePaths = existingPaths;
49
49
  }
50
+ // Merge sourceCode entries
51
+ const newSourceCode = update.metadata.sourceCode;
52
+ if (newSourceCode && typeof newSourceCode === 'object') {
53
+ if (!existing.metadata.sourceCode) {
54
+ existing.metadata.sourceCode = {};
55
+ }
56
+ const existingSourceCode = existing.metadata.sourceCode;
57
+ for (const [file, entries] of Object.entries(newSourceCode)) {
58
+ if (!existingSourceCode[file]) {
59
+ existingSourceCode[file] = [];
60
+ }
61
+ existingSourceCode[file].push(...entries);
62
+ }
63
+ }
50
64
  }
51
65
  const mergedUpdates = [...mergedByHash.values(), ...noHashUpdates];
52
66
  updates.splice(0, updates.length, ...mergedUpdates);
@@ -4,10 +4,26 @@ import { getRelative, readFile } from '../../fs/findFilepath.js';
4
4
  import { SUPPORTED_FILE_EXTENSIONS } from './supportedFiles.js';
5
5
  import { parseJson } from '../json/parseJson.js';
6
6
  import parseYaml from '../yaml/parseYaml.js';
7
+ import { validateYamlSchema } from '../yaml/utils.js';
8
+ import { flattenJson } from '../json/flattenJson.js';
7
9
  import YAML from 'yaml';
8
10
  import { determineLibrary } from '../../fs/determineFramework/index.js';
9
11
  import { hashStringSync } from '../../utils/hash.js';
10
12
  import { preprocessContent } from './preprocessContent.js';
13
+ import { parseKeyedMetadata, } from '../parseKeyedMetadata.js';
14
+ /**
15
+ * Checks if a file path is a metadata companion file (e.g. foo.metadata.json)
16
+ * AND its corresponding source file (e.g. foo.json) exists in the file list.
17
+ * If both conditions are true, the metadata file should be skipped as a translation source.
18
+ */
19
+ function isCompanionMetadataFile(filePath, allFilePaths) {
20
+ const metadataPattern = /\.metadata\.(json|yaml|yml)$/;
21
+ if (!metadataPattern.test(filePath))
22
+ return false;
23
+ // Derive the source file path: foo.metadata.json -> foo.json
24
+ const sourceFilePath = filePath.replace(/\.metadata\.(json|yaml|yml)$/, '.$1');
25
+ return allFilePaths.includes(sourceFilePath);
26
+ }
11
27
  export const SUPPORTED_DATA_FORMATS = ['JSX', 'ICU', 'I18NEXT'];
12
28
  export async function aggregateFiles(settings) {
13
29
  // Aggregate all files to translate
@@ -39,6 +55,7 @@ export async function aggregateFiles(settings) {
39
55
  dataFormat = 'STRING';
40
56
  }
41
57
  const jsonFiles = filePaths.json
58
+ .filter((filePath) => !isCompanionMetadataFile(filePath, filePaths.json))
42
59
  .map((filePath) => {
43
60
  const content = readFile(filePath);
44
61
  const relativePath = getRelative(filePath);
@@ -54,6 +71,34 @@ export async function aggregateFiles(settings) {
54
71
  }
55
72
  }
56
73
  const parsedJson = parseJson(content, filePath, settings.options || {}, settings.defaultLocale);
74
+ // Detect companion metadata file
75
+ let keyedMetadata;
76
+ let parsedContent;
77
+ try {
78
+ parsedContent = JSON.parse(content);
79
+ }
80
+ catch {
81
+ // Content not parsable — skip metadata detection
82
+ }
83
+ if (parsedContent) {
84
+ const rawMetadata = parseKeyedMetadata(filePath, parsedContent);
85
+ if (rawMetadata) {
86
+ // Run metadata through the same include/composite schema as the source
87
+ // so key paths align at translation time
88
+ const transformed = parseJson(JSON.stringify(rawMetadata), filePath, settings.options || {}, settings.defaultLocale, false);
89
+ const transformedMetadata = JSON.parse(transformed);
90
+ // Filter metadata to only keep keys that exist in the transformed source
91
+ // This prevents misaligned entries from wide JSONPath patterns
92
+ const sourceKeys = new Set(Object.keys(JSON.parse(parsedJson)));
93
+ const filtered = Object.fromEntries(Object.entries(transformedMetadata).filter(([k]) => sourceKeys.has(k)));
94
+ if (Object.keys(filtered).length > 0) {
95
+ keyedMetadata = filtered;
96
+ }
97
+ else {
98
+ logger.warn(`Companion metadata found for ${relativePath} but no keys aligned with the JSON schema — metadata was not attached`);
99
+ }
100
+ }
101
+ }
57
102
  return {
58
103
  fileId: hashStringSync(relativePath),
59
104
  versionId: hashStringSync(parsedJson),
@@ -62,6 +107,9 @@ export async function aggregateFiles(settings) {
62
107
  fileFormat: 'JSON',
63
108
  dataFormat,
64
109
  locale: settings.defaultLocale,
110
+ ...(keyedMetadata && {
111
+ formatMetadata: { keyedMetadata },
112
+ }),
65
113
  };
66
114
  })
67
115
  .filter((file) => {
@@ -79,6 +127,7 @@ export async function aggregateFiles(settings) {
79
127
  // Process YAML files
80
128
  if (filePaths.yaml) {
81
129
  const yamlFiles = filePaths.yaml
130
+ .filter((filePath) => !isCompanionMetadataFile(filePath, filePaths.yaml))
82
131
  .map((filePath) => {
83
132
  const content = readFile(filePath);
84
133
  const relativePath = getRelative(filePath);
@@ -94,6 +143,34 @@ export async function aggregateFiles(settings) {
94
143
  }
95
144
  }
96
145
  const { content: parsedYaml, fileFormat } = parseYaml(content, filePath, settings.options || {});
146
+ // Detect companion metadata file
147
+ let keyedMetadata;
148
+ try {
149
+ const parsedYamlContent = YAML.parse(content);
150
+ const rawMetadata = parseKeyedMetadata(filePath, parsedYamlContent);
151
+ if (rawMetadata) {
152
+ const yamlSchema = validateYamlSchema(settings.options || {}, filePath);
153
+ if (yamlSchema?.include) {
154
+ // Flatten metadata through the same include schema as the source
155
+ const flattened = flattenJson(rawMetadata, yamlSchema.include);
156
+ // Filter to only keep keys that exist in the transformed source
157
+ const sourceKeys = new Set(Object.keys(JSON.parse(parsedYaml)));
158
+ const filtered = Object.fromEntries(Object.entries(flattened).filter(([k]) => sourceKeys.has(k)));
159
+ if (Object.keys(filtered).length > 0) {
160
+ keyedMetadata = filtered;
161
+ }
162
+ else {
163
+ logger.warn(`Companion metadata found for ${relativePath} but no keys aligned with the YAML schema — metadata was not attached`);
164
+ }
165
+ }
166
+ else {
167
+ keyedMetadata = rawMetadata;
168
+ }
169
+ }
170
+ }
171
+ catch {
172
+ // Content not parsable as YAML — skip metadata detection
173
+ }
97
174
  return {
98
175
  content: parsedYaml,
99
176
  fileName: relativePath,
@@ -101,6 +178,9 @@ export async function aggregateFiles(settings) {
101
178
  fileId: hashStringSync(relativePath),
102
179
  versionId: hashStringSync(parsedYaml),
103
180
  locale: settings.defaultLocale,
181
+ ...(keyedMetadata && {
182
+ formatMetadata: { keyedMetadata },
183
+ }),
104
184
  };
105
185
  })
106
186
  .filter((file) => {
@@ -1,2 +1,2 @@
1
1
  import { AdditionalOptions } from '../../types/index.js';
2
- export declare function parseJson(content: string, filePath: string, options: AdditionalOptions, defaultLocale: string): string;
2
+ export declare function parseJson(content: string, filePath: string, options: AdditionalOptions, defaultLocale: string, filterStrings?: boolean): string;
@@ -1,11 +1,11 @@
1
- import { flattenJsonWithStringFilter } from './flattenJson.js';
1
+ import { flattenJson, flattenJsonWithStringFilter } from './flattenJson.js';
2
2
  import { JSONPath } from 'jsonpath-plus';
3
3
  import { exitSync } from '../../console/logging.js';
4
4
  import { logger } from '../../console/logger.js';
5
5
  import { findMatchingItemArray, findMatchingItemObject, generateSourceObjectPointers, validateJsonSchema, } from './utils.js';
6
6
  import { applyStructuralTransforms } from './transformJson.js';
7
7
  // Parse a JSON file according to a JSON schema
8
- export function parseJson(content, filePath, options, defaultLocale) {
8
+ export function parseJson(content, filePath, options, defaultLocale, filterStrings = true) {
9
9
  const jsonSchema = validateJsonSchema(options, filePath);
10
10
  if (!jsonSchema) {
11
11
  return content;
@@ -23,7 +23,8 @@ export function parseJson(content, filePath, options, defaultLocale) {
23
23
  }
24
24
  // Handle include
25
25
  if (jsonSchema.include) {
26
- const flattenedJson = flattenJsonWithStringFilter(json, jsonSchema.include);
26
+ const flatten = filterStrings ? flattenJsonWithStringFilter : flattenJson;
27
+ const flattenedJson = flatten(json, jsonSchema.include);
27
28
  return JSON.stringify(flattenedJson);
28
29
  }
29
30
  if (!jsonSchema.composite) {
@@ -104,7 +105,8 @@ export function parseJson(content, filePath, options, defaultLocale) {
104
105
  }
105
106
  const { sourceItem } = matchingItem;
106
107
  // Get the fields to translate from the includes
107
- const itemsToTranslate = flattenJsonWithStringFilter(sourceItem, sourceObjectOptions.include);
108
+ const flatten = filterStrings ? flattenJsonWithStringFilter : flattenJson;
109
+ const itemsToTranslate = flatten(sourceItem, sourceObjectOptions.include);
108
110
  // Add the items to translate to the result
109
111
  sourceObjectsToTranslate[sourceObjectPointer] = itemsToTranslate;
110
112
  }
@@ -0,0 +1,23 @@
1
+ import type { SourceCode } from '../react/jsx/utils/extractSourceCode.js';
2
+ import type { JSONObject } from '../types/data/json.js';
3
+ export type MetadataLeaf = {
4
+ context?: string;
5
+ maxChars?: number;
6
+ sourceCode?: Record<string, SourceCode[]>;
7
+ };
8
+ export type MetadataObject = {
9
+ [key: string]: MetadataLeaf | MetadataObject;
10
+ };
11
+ export type MetadataArray = (MetadataLeaf | MetadataObject)[];
12
+ export type KeyedMetadata = MetadataObject | MetadataArray;
13
+ /**
14
+ * Detects and parses a companion metadata file for a given source file.
15
+ *
16
+ * For `translations.json`, looks for `translations.metadata.json`.
17
+ * For `translations.yaml` or `translations.yml`, looks for `translations.metadata.yaml` or `.yml`.
18
+ *
19
+ * @param sourceFilePath - Absolute path to the source file
20
+ * @param sourceContent - Parsed source content (object) for structure validation
21
+ * @returns Parsed metadata object, or undefined if no companion file exists
22
+ */
23
+ export declare function parseKeyedMetadata(sourceFilePath: string, sourceContent: JSONObject | JSONObject[]): KeyedMetadata | undefined;
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import YAML from 'yaml';
4
+ import { logger } from '../console/logger.js';
5
+ import { exitSync } from '../console/logging.js';
6
+ /**
7
+ * Validates that the metadata key structure is a subset of the source key structure.
8
+ * Uses the source to determine whether a metadata value is a leaf (source value is a string)
9
+ * or a nested object (source value is an object).
10
+ */
11
+ function validateMetadataStructure(source, metadata, currentPath = []) {
12
+ const errors = [];
13
+ for (const key of Object.keys(metadata)) {
14
+ const sourceValue = source[key];
15
+ const keyPath = [...currentPath, key];
16
+ if (sourceValue === undefined) {
17
+ errors.push(`Key "${keyPath.join('.')}" does not exist in source`);
18
+ continue;
19
+ }
20
+ // If the source value is a string, this is a translatable leaf — metadata should be a MetadataLeaf
21
+ // If the source value is a nested object, recurse
22
+ if (typeof sourceValue === 'object' &&
23
+ sourceValue !== null &&
24
+ !Array.isArray(sourceValue)) {
25
+ const metaValue = metadata[key];
26
+ if (Array.isArray(metaValue)) {
27
+ errors.push(`Key "${keyPath.join('.')}" is an array but source is an object`);
28
+ }
29
+ else if (typeof metaValue === 'object' && metaValue !== null) {
30
+ errors.push(...validateMetadataStructure(sourceValue, metaValue, keyPath));
31
+ }
32
+ else {
33
+ errors.push(`Key "${keyPath.join('.')}" is a primitive but source is an object`);
34
+ }
35
+ }
36
+ }
37
+ return errors;
38
+ }
39
+ /**
40
+ * Detects and parses a companion metadata file for a given source file.
41
+ *
42
+ * For `translations.json`, looks for `translations.metadata.json`.
43
+ * For `translations.yaml` or `translations.yml`, looks for `translations.metadata.yaml` or `.yml`.
44
+ *
45
+ * @param sourceFilePath - Absolute path to the source file
46
+ * @param sourceContent - Parsed source content (object) for structure validation
47
+ * @returns Parsed metadata object, or undefined if no companion file exists
48
+ */
49
+ export function parseKeyedMetadata(sourceFilePath, sourceContent) {
50
+ const ext = path.extname(sourceFilePath);
51
+ const baseName = sourceFilePath.slice(0, -ext.length);
52
+ // Determine companion file path and parser
53
+ let metadataFilePath;
54
+ let parse;
55
+ if (ext === '.json') {
56
+ metadataFilePath = `${baseName}.metadata.json`;
57
+ parse = JSON.parse;
58
+ }
59
+ else if (ext === '.yaml' || ext === '.yml') {
60
+ const yamlPath = `${baseName}.metadata.yaml`;
61
+ const ymlPath = `${baseName}.metadata.yml`;
62
+ if (fs.existsSync(yamlPath)) {
63
+ metadataFilePath = yamlPath;
64
+ }
65
+ else if (fs.existsSync(ymlPath)) {
66
+ metadataFilePath = ymlPath;
67
+ }
68
+ parse = YAML.parse;
69
+ }
70
+ if (!metadataFilePath || !parse) {
71
+ return undefined;
72
+ }
73
+ if (!fs.existsSync(metadataFilePath)) {
74
+ return undefined;
75
+ }
76
+ // Read and parse
77
+ let metadataContent;
78
+ try {
79
+ const raw = fs.readFileSync(metadataFilePath, 'utf8');
80
+ const parsed = parse(raw);
81
+ if (typeof parsed !== 'object' || parsed === null) {
82
+ const relativePath = path.relative(process.cwd(), metadataFilePath);
83
+ logger.error(`Metadata file ${relativePath}: Expected an object or array`);
84
+ return exitSync(1);
85
+ }
86
+ metadataContent = parsed;
87
+ }
88
+ catch {
89
+ const relativePath = path.relative(process.cwd(), metadataFilePath);
90
+ logger.error(`Metadata file ${relativePath}: File is not parsable`);
91
+ return exitSync(1);
92
+ }
93
+ // Reject if root types don't match (array vs object)
94
+ if (Array.isArray(metadataContent) !== Array.isArray(sourceContent)) {
95
+ const relativePath = path.relative(process.cwd(), metadataFilePath);
96
+ logger.error(`Metadata file ${relativePath}: Root type (array vs object) does not match source`);
97
+ return exitSync(1);
98
+ }
99
+ // Validate structure against source (only for object-rooted files)
100
+ if (!Array.isArray(metadataContent) && !Array.isArray(sourceContent)) {
101
+ const errors = validateMetadataStructure(sourceContent, metadataContent);
102
+ if (errors.length > 0) {
103
+ const relativePath = path.relative(process.cwd(), metadataFilePath);
104
+ for (const error of errors) {
105
+ logger.error(`Metadata file ${relativePath}: ${error}`);
106
+ }
107
+ return exitSync(1);
108
+ }
109
+ }
110
+ return metadataContent;
111
+ }
@@ -1 +1 @@
1
- export declare const PACKAGE_VERSION = "2.9.0";
1
+ export declare const PACKAGE_VERSION = "2.10.0";
@@ -1,2 +1,2 @@
1
1
  // This file is auto-generated. Do not edit manually.
2
- export const PACKAGE_VERSION = '2.9.0';
2
+ export const PACKAGE_VERSION = '2.10.0';
@@ -0,0 +1,15 @@
1
+ export type SourceCode = {
2
+ before: string;
3
+ target: string;
4
+ after: string;
5
+ };
6
+ /**
7
+ * Extracts the surrounding lines of source code around a target node.
8
+ *
9
+ * @param filePath - Absolute path to the source file
10
+ * @param startLine - 1-based start line of the target node
11
+ * @param endLine - 1-based end line of the target node
12
+ * @param n - Number of surrounding lines before and after to capture
13
+ * @returns The surrounding lines, or undefined if the file can't be read
14
+ */
15
+ export declare function extractSourceCode(filePath: string, startLine: number, endLine: number, n: number): SourceCode | undefined;
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs';
2
+ // Cache file contents to avoid re-reading the same file for multiple translation sites
3
+ const fileContentCache = new Map();
4
+ /**
5
+ * Extracts the surrounding lines of source code around a target node.
6
+ *
7
+ * @param filePath - Absolute path to the source file
8
+ * @param startLine - 1-based start line of the target node
9
+ * @param endLine - 1-based end line of the target node
10
+ * @param n - Number of surrounding lines before and after to capture
11
+ * @returns The surrounding lines, or undefined if the file can't be read
12
+ */
13
+ export function extractSourceCode(filePath, startLine, endLine, n) {
14
+ let fileContent = fileContentCache.get(filePath);
15
+ if (fileContent === undefined) {
16
+ try {
17
+ const result = fs.readFileSync(filePath, 'utf8');
18
+ if (typeof result !== 'string') {
19
+ return undefined;
20
+ }
21
+ fileContent = result;
22
+ fileContentCache.set(filePath, fileContent);
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ const lines = fileContent.split('\n');
29
+ const totalLines = lines.length;
30
+ // Clamp to valid line ranges (convert to 0-based)
31
+ const targetStart = Math.max(0, startLine - 1);
32
+ const targetEnd = Math.min(totalLines - 1, endLine - 1);
33
+ const beforeStart = Math.max(0, targetStart - n);
34
+ const afterEnd = Math.min(totalLines - 1, targetEnd + n);
35
+ const before = lines.slice(beforeStart, targetStart).join('\n');
36
+ const target = lines.slice(targetStart, targetEnd + 1).join('\n');
37
+ const after = lines.slice(targetEnd + 1, afterEnd + 1).join('\n');
38
+ return { before, target, after };
39
+ }
@@ -10,6 +10,7 @@ type ConfigOptions = {
10
10
  importAliases: Record<string, string>;
11
11
  pkgs: GTLibrary[];
12
12
  file: string;
13
+ includeSourceCodeContext?: boolean;
13
14
  };
14
15
  /**
15
16
  * Collectors for errors, warnings, and unwrapped expressions.
@@ -21,6 +21,8 @@ import { isElementNode } from './types.js';
21
21
  import { multiplyJsxTree } from './multiplication/multiplyJsxTree.js';
22
22
  import { removeNullChildrenFields } from './removeNullChildrenFields.js';
23
23
  import path from 'node:path';
24
+ import { extractSourceCode } from '../extractSourceCode.js';
25
+ import { SURROUNDING_LINE_COUNT } from '../../../../utils/constants.js';
24
26
  // Handle CommonJS/ESM interop
25
27
  const traverse = traverseModule.default || traverseModule;
26
28
  // TODO: currently we cover VariableDeclaration and FunctionDeclaration nodes, but are there others we should cover as well?
@@ -410,6 +412,15 @@ function parseJSXElement({ node, originalName, scopeNode, updates, config, state
410
412
  const metadata = {};
411
413
  const relativeFilepath = path.relative(process.cwd(), config.file);
412
414
  metadata.filePaths = [relativeFilepath];
415
+ // Extract surrounding lines from source file
416
+ const startLine = node.loc?.start?.line;
417
+ const endLine = node.loc?.end?.line;
418
+ if (config.includeSourceCodeContext && startLine && endLine) {
419
+ const entry = extractSourceCode(config.file, startLine, endLine, SURROUNDING_LINE_COUNT);
420
+ if (entry && relativeFilepath) {
421
+ metadata.sourceCode = { [relativeFilepath]: [entry] };
422
+ }
423
+ }
413
424
  // We'll track this flag to know if any unwrapped {variable} is found in children
414
425
  const unwrappedExpressions = [];
415
426
  // Gather <T>'s props
@@ -283,6 +283,7 @@ export function parseStrings(importName, originalName, path, config, output) {
283
283
  ignoreDynamicContent: false,
284
284
  ignoreInvalidIcu: false,
285
285
  ignoreInlineListContent: false,
286
+ includeSourceCodeContext: config.includeSourceCodeContext,
286
287
  };
287
288
  // Check if this is a direct call to msg('string')
288
289
  if (refPath.parent.type === 'CallExpression' &&
@@ -322,6 +323,7 @@ export function parseStrings(importName, originalName, path, config, output) {
322
323
  ignoreInvalidIcu: isMessageHook,
323
324
  // TODO: when we add support for array content in gt function, this should just always be false
324
325
  ignoreInlineListContent: isInlineGT,
326
+ includeSourceCodeContext: config.includeSourceCodeContext,
325
327
  };
326
328
  const effectiveParent = parentPath?.node.type === 'AwaitExpression'
327
329
  ? parentPath.parentPath
@@ -1,6 +1,7 @@
1
1
  import * as t from '@babel/types';
2
2
  import { ParsingConfig } from '../types.js';
3
3
  import { ParsingOutput } from '../types.js';
4
+ import type { SourceCode } from '../../extractSourceCode.js';
4
5
  /**
5
6
  * Metadata record type
6
7
  */
@@ -10,6 +11,7 @@ export type InlineMetadata = {
10
11
  id?: string;
11
12
  hash?: string;
12
13
  filePaths?: string[];
14
+ sourceCode?: Record<string, SourceCode[]>;
13
15
  };
14
16
  /**
15
17
  * Extracts inline metadata from a string entry
@@ -22,8 +24,17 @@ export type InlineMetadata = {
22
24
  * @note - this function does not automatically append the index to the id, this must be done manually in the caller.
23
25
  *
24
26
  */
25
- export declare function extractStringEntryMetadata({ options, output, config, }: {
27
+ export declare function extractStringEntryMetadata({ options, output, config, nodeLoc, surroundingLineCount, }: {
26
28
  options?: t.CallExpression['arguments'][number];
27
29
  output: ParsingOutput;
28
30
  config: ParsingConfig;
31
+ nodeLoc?: {
32
+ start?: {
33
+ line: number;
34
+ } | null;
35
+ end?: {
36
+ line: number;
37
+ } | null;
38
+ } | null;
39
+ surroundingLineCount?: number;
29
40
  }): InlineMetadata;
@@ -7,6 +7,8 @@ import generateModule from '@babel/generator';
7
7
  import { mapAttributeName } from '../../mapAttributeName.js';
8
8
  import pathModule from 'node:path';
9
9
  import { isNumberLiteral } from '../../isNumberLiteral.js';
10
+ import { extractSourceCode } from '../../extractSourceCode.js';
11
+ import { SURROUNDING_LINE_COUNT } from '../../../../../utils/constants.js';
10
12
  // Handle CommonJS/ESM interop
11
13
  const generate = generateModule.default || generateModule;
12
14
  /**
@@ -20,7 +22,7 @@ const generate = generateModule.default || generateModule;
20
22
  * @note - this function does not automatically append the index to the id, this must be done manually in the caller.
21
23
  *
22
24
  */
23
- export function extractStringEntryMetadata({ options, output, config, }) {
25
+ export function extractStringEntryMetadata({ options, output, config, nodeLoc, surroundingLineCount = SURROUNDING_LINE_COUNT, }) {
24
26
  // extract filepath for entry
25
27
  const relativeFilepath = pathModule.relative(process.cwd(), config.file);
26
28
  // extract inline metadata
@@ -29,9 +31,20 @@ export function extractStringEntryMetadata({ options, output, config, }) {
29
31
  output,
30
32
  config,
31
33
  });
34
+ // extract surrounding lines from source file
35
+ let sourceCode;
36
+ if (config.includeSourceCodeContext &&
37
+ nodeLoc?.start?.line &&
38
+ nodeLoc?.end?.line) {
39
+ const entry = extractSourceCode(config.file, nodeLoc.start.line, nodeLoc.end.line, surroundingLineCount);
40
+ if (entry && relativeFilepath) {
41
+ sourceCode = { [relativeFilepath]: [entry] };
42
+ }
43
+ }
32
44
  return {
33
45
  ...inlineMetadata,
34
46
  filePaths: relativeFilepath ? [relativeFilepath] : undefined,
47
+ ...(sourceCode && { sourceCode }),
35
48
  };
36
49
  }
37
50
  // ----- Helper Functions ----- //
@@ -1,5 +1,6 @@
1
1
  import { routeTranslationCall } from './routeTranslationCall.js';
2
2
  import { extractStringEntryMetadata } from './extractStringEntryMetadata.js';
3
+ import { SURROUNDING_LINE_COUNT } from '../../../../../utils/constants.js';
3
4
  /**
4
5
  * Processes a single translation function call (e.g., t('hello world', { id: 'greeting' })).
5
6
  * Extracts the translatable string content and metadata, then adds it to the updates array.
@@ -27,6 +28,8 @@ export function processTranslationCall(tPath, config, output) {
27
28
  options,
28
29
  output,
29
30
  config,
31
+ nodeLoc: tPath.parent.loc,
32
+ surroundingLineCount: SURROUNDING_LINE_COUNT,
30
33
  });
31
34
  // Route tx call to appropriate handler
32
35
  routeTranslationCall({
@@ -23,6 +23,10 @@ export type ParsingConfig = {
23
23
  * eg msg(['hello', 'world', 'foo', 'bar']) will not be registered
24
24
  */
25
25
  ignoreInlineListContent: boolean;
26
+ /**
27
+ * If true, include surrounding source code lines as context for translations
28
+ */
29
+ includeSourceCodeContext?: boolean;
26
30
  };
27
31
  /**
28
32
  * Mutable state for tracking parsing progress.
@@ -2,7 +2,7 @@ import { Updates } from '../../types/index.js';
2
2
  import type { ParsingConfigOptions } from '../../types/parsing.js';
3
3
  import { GTLibrary } from '../../types/libraries.js';
4
4
  import { dedupeUpdates } from '../../extraction/postProcess.js';
5
- export declare function createInlineUpdates(pkg: GTLibrary, validate: boolean, filePatterns: string[] | undefined, parsingOptions: ParsingConfigOptions): Promise<{
5
+ export declare function createInlineUpdates(pkg: GTLibrary, validate: boolean, filePatterns: string[] | undefined, parsingOptions: ParsingConfigOptions, includeSourceCodeContext?: boolean): Promise<{
6
6
  updates: Updates;
7
7
  errors: string[];
8
8
  warnings: string[];
@@ -8,7 +8,7 @@ import { DEFAULT_SRC_PATTERNS } from '../../config/generateSettings.js';
8
8
  import { getPathsAndAliases } from '../jsx/utils/getPathsAndAliases.js';
9
9
  import { GT_LIBRARIES_UPSTREAM, REACT_LIBRARIES, } from '../../types/libraries.js';
10
10
  import { calculateHashes, dedupeUpdates, linkStaticUpdates, } from '../../extraction/postProcess.js';
11
- export async function createInlineUpdates(pkg, validate, filePatterns, parsingOptions) {
11
+ export async function createInlineUpdates(pkg, validate, filePatterns, parsingOptions, includeSourceCodeContext = false) {
12
12
  const updates = [];
13
13
  const errors = [];
14
14
  const warnings = new Set();
@@ -39,6 +39,7 @@ export async function createInlineUpdates(pkg, validate, filePatterns, parsingOp
39
39
  ignoreDynamicContent: false,
40
40
  ignoreInvalidIcu: false,
41
41
  ignoreInlineListContent: false,
42
+ includeSourceCodeContext,
42
43
  }, { updates, errors, warnings });
43
44
  }
44
45
  // Parse <T> components
@@ -54,6 +55,7 @@ export async function createInlineUpdates(pkg, validate, filePatterns, parsingOp
54
55
  parsingOptions,
55
56
  pkgs,
56
57
  file,
58
+ includeSourceCodeContext,
57
59
  },
58
60
  output: {
59
61
  errors,
@@ -10,7 +10,7 @@ import { InlineLibrary } from '../types/libraries.js';
10
10
  * @param pkg - The package name
11
11
  * @returns An object containing the updates and errors
12
12
  */
13
- export declare function createUpdates(options: TranslateFlags, src: string[] | undefined, sourceDictionary: string | undefined, pkg: InlineLibrary, validate: boolean, parsingOptions: ParsingConfigOptions): Promise<{
13
+ export declare function createUpdates(options: TranslateFlags, src: string[] | undefined, sourceDictionary: string | undefined, pkg: InlineLibrary, validate: boolean, parsingOptions: ParsingConfigOptions, includeSourceCodeContext?: boolean): Promise<{
14
14
  updates: Updates;
15
15
  errors: string[];
16
16
  warnings: string[];
@@ -17,7 +17,7 @@ import { isPythonLibrary } from '../types/libraries.js';
17
17
  * @param pkg - The package name
18
18
  * @returns An object containing the updates and errors
19
19
  */
20
- export async function createUpdates(options, src, sourceDictionary, pkg, validate, parsingOptions) {
20
+ export async function createUpdates(options, src, sourceDictionary, pkg, validate, parsingOptions, includeSourceCodeContext = false) {
21
21
  let updates = [];
22
22
  let errors = [];
23
23
  let warnings = [];
@@ -53,7 +53,7 @@ export async function createUpdates(options, src, sourceDictionary, pkg, validat
53
53
  // Scan through project for translatable content
54
54
  const { updates: newUpdates, errors: newErrors, warnings: newWarnings, } = isPythonLibrary(pkg)
55
55
  ? await createPythonInlineUpdates(src)
56
- : await createInlineUpdates(pkg, validate, src, parsingOptions);
56
+ : await createInlineUpdates(pkg, validate, src, parsingOptions, includeSourceCodeContext);
57
57
  errors = [...errors, ...newErrors];
58
58
  warnings = [...warnings, ...newWarnings];
59
59
  updates = [...updates, ...newUpdates];
@@ -15,7 +15,7 @@ export async function aggregateInlineTranslations(options, settings, library) {
15
15
  ]);
16
16
  }
17
17
  // ---- CREATING UPDATES ---- //
18
- const { updates, errors, warnings } = await createUpdates(options, settings.src, options.dictionary, library, false, settings.parsingOptions);
18
+ const { updates, errors, warnings } = await createUpdates(options, settings.src, options.dictionary, library, false, settings.parsingOptions, settings.options?.includeSourceCodeContext ?? false);
19
19
  if (warnings.length > 0) {
20
20
  logger.warn(chalk.yellow(`CLI tool encountered ${warnings.length} warnings while scanning for translatable content.\n` +
21
21
  warnings
@@ -10,7 +10,7 @@ import { Libraries } from '../types/libraries.js';
10
10
  */
11
11
  async function runValidation(settings, pkg, files) {
12
12
  if (files && files.length > 0) {
13
- return createInlineUpdates(pkg, true, files, settings.parsingOptions);
13
+ return createInlineUpdates(pkg, true, files, settings.parsingOptions, settings.options?.includeSourceCodeContext ?? false);
14
14
  }
15
15
  // Full project validation
16
16
  // Use local variable to avoid mutating caller's settings object
@@ -23,7 +23,7 @@ async function runValidation(settings, pkg, files) {
23
23
  './dictionary.ts',
24
24
  './src/dictionary.ts',
25
25
  ]);
26
- return createUpdates(settings, settings.src, dictionary, pkg, true, settings.parsingOptions);
26
+ return createUpdates(settings, settings.src, dictionary, pkg, true, settings.parsingOptions, settings.options?.includeSourceCodeContext ?? false);
27
27
  }
28
28
  /**
29
29
  * Parse file path from error/warning string in withLocation format: "filepath (line:col): message"
@@ -211,6 +211,7 @@ export type AdditionalOptions = {
211
211
  replace: string;
212
212
  }>;
213
213
  experimentalCanonicalLocaleKeys?: boolean;
214
+ includeSourceCodeContext?: boolean;
214
215
  };
215
216
  export type SharedStaticAssetsConfig = {
216
217
  include: string | string[];
@@ -4,3 +4,4 @@ export declare const TEMPLATE_FILE_NAME = "__INTERNAL_GT_TEMPLATE_NAME__";
4
4
  export declare const TEMPLATE_FILE_ID: string;
5
5
  export declare const DEFAULT_GIT_REMOTE_NAME = "origin";
6
6
  export declare const DEFAULT_TIMEOUT_SECONDS = 900;
7
+ export declare const SURROUNDING_LINE_COUNT = 5;
@@ -5,3 +5,5 @@ export const TEMPLATE_FILE_NAME = '__INTERNAL_GT_TEMPLATE_NAME__';
5
5
  export const TEMPLATE_FILE_ID = hashStringSync(TEMPLATE_FILE_NAME);
6
6
  export const DEFAULT_GIT_REMOTE_NAME = 'origin';
7
7
  export const DEFAULT_TIMEOUT_SECONDS = 900;
8
+ // Number of source code lines to capture above and below a translation site
9
+ export const SURROUNDING_LINE_COUNT = 5;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gt",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "main": "dist/index.js",
5
5
  "bin": "dist/main.js",
6
6
  "files": [