gtx-cli 2.2.0-alpha.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # gtx-cli
2
2
 
3
+ ## 2.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#651](https://github.com/generaltranslation/gt/pull/651) [`3e5705b`](https://github.com/generaltranslation/gt/commit/3e5705bc96005441798619fec97574fa15a5a2bd) Thanks [@fernando-aviles](https://github.com/fernando-aviles)! - Split up file upload into source/translation specific uploads; added project setup visibility
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [[`3e5705b`](https://github.com/generaltranslation/gt/commit/3e5705bc96005441798619fec97574fa15a5a2bd)]:
12
+ - generaltranslation@7.6.0
13
+
14
+ ## 2.2.0
15
+
16
+ ### Minor Changes
17
+
18
+ - [#638](https://github.com/generaltranslation/gt/pull/638) [`16bf30d`](https://github.com/generaltranslation/gt/commit/16bf30d70a0599ec863305f4f7a5a0852dd07e5d) Thanks [@ErnestM1234](https://github.com/ErnestM1234)! - feat: add locale aliasing
19
+
20
+ ### Patch Changes
21
+
22
+ - Updated dependencies [[`16bf30d`](https://github.com/generaltranslation/gt/commit/16bf30d70a0599ec863305f4f7a5a0852dd07e5d)]:
23
+ - generaltranslation@7.5.0
24
+
25
+ ## 2.1.21
26
+
27
+ ### Patch Changes
28
+
29
+ - [#645](https://github.com/generaltranslation/gt/pull/645) [`58cfaee`](https://github.com/generaltranslation/gt/commit/58cfaee5cc1dcd187f0b72b2761f96c19b4f313e) Thanks [@fernando-aviles](https://github.com/fernando-aviles)! - Escaping HTML to avoid parsing issues from MDX consumers
30
+
31
+ ## 2.1.20
32
+
33
+ ### Patch Changes
34
+
35
+ - [#643](https://github.com/generaltranslation/gt/pull/643) [`4f553c0`](https://github.com/generaltranslation/gt/commit/4f553c00c119f272edc5ccb3616f2d0effec8586) Thanks [@fernando-aviles](https://github.com/fernando-aviles)! - Removing custom configuration on remarkStringify
36
+
37
+ ## 2.1.19
38
+
39
+ ### Patch Changes
40
+
41
+ - [#641](https://github.com/generaltranslation/gt/pull/641) [`4c67f77`](https://github.com/generaltranslation/gt/commit/4c67f775ee892b47eebcc3178c00ad6547a84d84) Thanks [@fernando-aviles](https://github.com/fernando-aviles)! - Encoding placeholders that break MDX parse
42
+
43
+ ## 2.1.18
44
+
45
+ ### Patch Changes
46
+
47
+ - [#637](https://github.com/generaltranslation/gt/pull/637) [`9c40a3c`](https://github.com/generaltranslation/gt/commit/9c40a3c729bf690381959679078c11c9c29bcdf2) Thanks [@fernando-aviles](https://github.com/fernando-aviles)! - Skipping over empty files when sending for translation
48
+
49
+ ## 2.1.17
50
+
51
+ ### Patch Changes
52
+
53
+ - [#635](https://github.com/generaltranslation/gt/pull/635) [`10aa051`](https://github.com/generaltranslation/gt/commit/10aa051592cea43f772615da200c8615d4dd1a78) Thanks [@brian-lou](https://github.com/brian-lou)! - Create dictionary with uuid to reduce flakiness
54
+
55
+ ## 2.1.16
56
+
57
+ ### Patch Changes
58
+
59
+ - [#630](https://github.com/generaltranslation/gt/pull/630) [`1f0dc1b`](https://github.com/generaltranslation/gt/commit/1f0dc1b17f22737263938998f5c516e0aa136b1a) Thanks [@fernando-aviles](https://github.com/fernando-aviles)! - Adding localization of import paths to MDX files
60
+
3
61
  ## 2.1.15
4
62
 
5
63
  ### Patch Changes
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { createSpinner, logMessage, logSuccess } from '../console/logging.js';
2
+ import { createSpinner, logError, logMessage, logSuccess, } from '../console/logging.js';
3
3
  import { gt } from '../utils/gt.js';
4
4
  import { TEMPLATE_FILE_NAME } from '../cli/commands/stage.js';
5
5
  /**
@@ -9,6 +9,8 @@ import { TEMPLATE_FILE_NAME } from '../cli/commands/stage.js';
9
9
  * @returns The translated content or version ID
10
10
  */
11
11
  export async function sendFiles(files, options, settings) {
12
+ // Keep track of the most recent spinner so we can stop it on error
13
+ let currentSpinner = null;
12
14
  logMessage(chalk.cyan('Files to translate:') +
13
15
  '\n' +
14
16
  files
@@ -19,26 +21,95 @@ export async function sendFiles(files, options, settings) {
19
21
  return `- ${file.fileName}`;
20
22
  })
21
23
  .join('\n'));
22
- const spinner = createSpinner('dots');
23
- spinner.start(`Sending ${files.length} file${files.length !== 1 ? 's' : ''} to General Translation API...`);
24
24
  try {
25
- // Send the files to the API
26
- const responseData = await gt.enqueueFiles(files, {
27
- publish: settings.publish,
25
+ // Step 1: Upload files (get references)
26
+ const uploadSpinner = createSpinner('dots');
27
+ currentSpinner = uploadSpinner;
28
+ uploadSpinner.start(`Uploading ${files.length} file${files.length !== 1 ? 's' : ''} to General Translation API...`);
29
+ const sourceLocale = settings.defaultLocale;
30
+ if (!sourceLocale) {
31
+ uploadSpinner.stop(chalk.red('Missing default source locale'));
32
+ throw new Error('sendFiles: settings.defaultLocale is required to upload source files');
33
+ }
34
+ // Convert FileToTranslate[] -> { source: FileUpload }[]
35
+ const uploads = files.map(({ content, fileName, fileFormat, dataFormat }) => ({
36
+ source: {
37
+ content,
38
+ fileName,
39
+ fileFormat,
40
+ dataFormat,
41
+ locale: sourceLocale,
42
+ },
43
+ }));
44
+ const upload = await gt.uploadSourceFiles(uploads, {
45
+ sourceLocale,
46
+ modelProvider: settings.modelProvider,
47
+ });
48
+ uploadSpinner.stop(chalk.green('Files uploaded successfully'));
49
+ // Check if setup is needed
50
+ const setupDecision = await Promise.resolve(gt.shouldSetupProject?.())
51
+ .then((v) => v)
52
+ .catch(() => ({ shouldSetupProject: false }));
53
+ const shouldSetupProject = Boolean(setupDecision?.shouldSetupProject);
54
+ // Step 2: Setup if needed and poll until complete
55
+ if (shouldSetupProject) {
56
+ // Calculate timeout once for setup fetching
57
+ // Accept number or numeric string, default to 600s
58
+ const timeoutVal = options?.timeout !== undefined ? Number(options.timeout) : 600;
59
+ const setupTimeoutMs = (Number.isFinite(timeoutVal) ? timeoutVal : 600) * 1000;
60
+ const { setupJobId } = await gt.setupProject(upload.uploadedFiles);
61
+ const setupSpinner = createSpinner('dots');
62
+ currentSpinner = setupSpinner;
63
+ setupSpinner.start('Setting up project...');
64
+ const start = Date.now();
65
+ const pollInterval = 2000;
66
+ let setupCompleted = false;
67
+ let setupFailedMessage = null;
68
+ while (true) {
69
+ const status = await gt.checkSetupStatus(setupJobId);
70
+ if (status.status === 'completed') {
71
+ setupCompleted = true;
72
+ break;
73
+ }
74
+ if (status.status === 'failed') {
75
+ setupFailedMessage = status.error?.message || 'Unknown error';
76
+ break;
77
+ }
78
+ if (Date.now() - start > setupTimeoutMs) {
79
+ setupFailedMessage = 'Timed out while waiting for setup generation';
80
+ break;
81
+ }
82
+ await new Promise((r) => setTimeout(r, pollInterval));
83
+ }
84
+ if (setupCompleted) {
85
+ setupSpinner.stop(chalk.green('Setup successfully completed'));
86
+ }
87
+ else {
88
+ setupSpinner.stop(chalk.yellow(`Setup ${setupFailedMessage ? 'failed' : 'timed out'} — proceeding without setup${setupFailedMessage ? ` (${setupFailedMessage})` : ''}`));
89
+ }
90
+ }
91
+ // Step 3: Enqueue translations by reference
92
+ const enqueueSpinner = createSpinner('dots');
93
+ currentSpinner = enqueueSpinner;
94
+ enqueueSpinner.start('Enqueuing translations...');
95
+ const enqueueResult = await gt.enqueueFiles(upload.uploadedFiles, {
28
96
  sourceLocale: settings.defaultLocale,
29
97
  targetLocales: settings.locales,
30
- version: settings.version, // not set ATM
98
+ publish: settings.publish,
99
+ requireApproval: settings.stageTranslations,
31
100
  modelProvider: settings.modelProvider,
32
101
  force: options?.force,
33
102
  });
34
- // Handle version ID response (for async processing)
35
- const { data, message, locales, translations } = responseData;
36
- spinner.stop(chalk.green('Files for translation uploaded successfully'));
103
+ const { data, message, locales, translations } = enqueueResult;
104
+ enqueueSpinner.stop(chalk.green('Files for translation uploaded successfully'));
37
105
  logSuccess(message);
38
106
  return { data, locales, translations };
39
107
  }
40
108
  catch (error) {
41
- spinner.stop(chalk.red('Failed to send files for translation'));
109
+ if (currentSpinner) {
110
+ currentSpinner.stop(chalk.red('Failed to send files for translation'));
111
+ }
112
+ logError('Failed to send files for translation');
42
113
  throw error;
43
114
  }
44
115
  }
@@ -24,4 +24,4 @@ export type UploadData = {
24
24
  export declare function uploadFiles(files: {
25
25
  source: FileUpload;
26
26
  translations: FileUpload[];
27
- }[], options: Settings): Promise<any>;
27
+ }[], options: Settings): Promise<void>;
@@ -18,12 +18,20 @@ export async function uploadFiles(files, options) {
18
18
  const spinner = createSpinner('dots');
19
19
  spinner.start(`Uploading ${files.length} file${files.length !== 1 ? 's' : ''} to General Translation...`);
20
20
  try {
21
- const result = gt.uploadFiles(files, {
21
+ // Upload sources
22
+ await gt.uploadSourceFiles(files, {
22
23
  ...options,
23
24
  sourceLocale: options.defaultLocale,
24
25
  });
26
+ // Upload translations (if any exist)
27
+ const withTranslations = files.filter((f) => f.translations.length > 0);
28
+ if (withTranslations.length > 0) {
29
+ await gt.uploadTranslations(withTranslations, {
30
+ ...options,
31
+ sourceLocale: options.defaultLocale, // optional, safe to include
32
+ });
33
+ }
25
34
  spinner.stop(chalk.green('Files uploaded successfully'));
26
- return result;
27
35
  }
28
36
  catch {
29
37
  spinner.stop(chalk.red('An unexpected error occurred while uploading files'));
@@ -7,6 +7,7 @@ import flattenJsonFiles from '../../utils/flattenJsonFiles.js';
7
7
  import localizeStaticUrls from '../../utils/localizeStaticUrls.js';
8
8
  import processAnchorIds from '../../utils/processAnchorIds.js';
9
9
  import { noFilesError, noVersionIdError } from '../../console/index.js';
10
+ import localizeStaticImports from '../../utils/localizeStaticImports.js';
10
11
  // Downloads translations that were completed
11
12
  export async function handleTranslate(options, settings, filesTranslationResponse) {
12
13
  if (filesTranslationResponse && settings.files) {
@@ -46,6 +47,10 @@ export async function postProcessTranslations(settings) {
46
47
  // Uses inline {#id} format by default, or div wrapping if experimentalAddHeaderAnchorIds is 'mintlify'
47
48
  await processAnchorIds(settings);
48
49
  }
50
+ // Localize static imports (import Snippet from /snippets/file.mdx -> import Snippet from /snippets/[locale]/file.mdx)
51
+ if (settings.options?.experimentalLocalizeStaticImports) {
52
+ await localizeStaticImports(settings);
53
+ }
49
54
  // Flatten json files into a single file
50
55
  if (settings.options?.experimentalFlattenJsonFiles) {
51
56
  await flattenJsonFiles(settings);
@@ -35,30 +35,46 @@ export async function aggregateFiles(settings) {
35
35
  else {
36
36
  dataFormat = 'JSX';
37
37
  }
38
- const jsonFiles = filePaths.json.map((filePath) => {
38
+ const jsonFiles = filePaths.json
39
+ .map((filePath) => {
39
40
  const content = readFile(filePath);
40
- const parsedJson = parseJson(content, filePath, settings.options || {}, settings.defaultLocale);
41
41
  const relativePath = getRelative(filePath);
42
+ const parsedJson = parseJson(content, filePath, settings.options || {}, settings.defaultLocale);
42
43
  return {
43
44
  content: parsedJson,
44
45
  fileName: relativePath,
45
46
  fileFormat: 'JSON',
46
47
  dataFormat,
47
48
  };
49
+ })
50
+ .filter((file) => {
51
+ if (!file || typeof file.content !== 'string' || !file.content.trim()) {
52
+ logWarning(`Skipping ${file?.fileName ?? 'unknown'}: JSON file is empty`);
53
+ return false;
54
+ }
55
+ return true;
48
56
  });
49
57
  allFiles.push(...jsonFiles);
50
58
  }
51
59
  // Process YAML files
52
60
  if (filePaths.yaml) {
53
- const yamlFiles = filePaths.yaml.map((filePath) => {
61
+ const yamlFiles = filePaths.yaml
62
+ .map((filePath) => {
54
63
  const content = readFile(filePath);
55
- const { content: parsedYaml, fileFormat } = parseYaml(content, filePath, settings.options || {});
56
64
  const relativePath = getRelative(filePath);
65
+ const { content: parsedYaml, fileFormat } = parseYaml(content, filePath, settings.options || {});
57
66
  return {
58
67
  content: parsedYaml,
59
68
  fileName: relativePath,
60
69
  fileFormat,
61
70
  };
71
+ })
72
+ .filter((file) => {
73
+ if (!file || typeof file.content !== 'string' || !file.content.trim()) {
74
+ logWarning(`Skipping ${file?.fileName ?? 'unknown'}: YAML file is empty`);
75
+ return false;
76
+ }
77
+ return true;
62
78
  });
63
79
  allFiles.push(...yamlFiles);
64
80
  }
@@ -73,8 +89,7 @@ export async function aggregateFiles(settings) {
73
89
  if (fileType === 'mdx') {
74
90
  const validation = isValidMdx(content, filePath);
75
91
  if (!validation.isValid) {
76
- const errorMsg = validation.error ? `: ${validation.error}` : '';
77
- logWarning(`Skipping ${relativePath}: MDX file is not AST parsable${errorMsg}`);
92
+ logWarning(`Skipping ${relativePath}: MDX file is not AST parsable${validation.error ? `: ${validation.error}` : ''}`);
78
93
  return null;
79
94
  }
80
95
  }
@@ -85,7 +100,15 @@ export async function aggregateFiles(settings) {
85
100
  fileFormat: fileType.toUpperCase(),
86
101
  };
87
102
  })
88
- .filter((file) => file !== null);
103
+ .filter((file) => {
104
+ if (!file ||
105
+ typeof file.content !== 'string' ||
106
+ !file.content.trim()) {
107
+ logWarning(`Skipping ${file?.fileName ?? 'unknown'}: File is empty after sanitization`);
108
+ return false;
109
+ }
110
+ return true;
111
+ });
89
112
  allFiles.push(...files);
90
113
  }
91
114
  }
@@ -100,9 +100,8 @@ export async function upload(filePaths, placeholderPaths, transformPaths, dataFo
100
100
  const fileMapping = createFileMapping(filePaths, placeholderPaths, transformPaths, locales, options.defaultLocale);
101
101
  // construct object
102
102
  const uploadData = allFiles.map((file) => {
103
- const encodedContent = Buffer.from(file.content).toString('base64');
104
103
  const sourceFile = {
105
- content: encodedContent,
104
+ content: file.content,
106
105
  fileName: file.fileName,
107
106
  fileFormat: file.fileFormat,
108
107
  dataFormat: file.dataFormat,
@@ -113,9 +112,8 @@ export async function upload(filePaths, placeholderPaths, transformPaths, dataFo
113
112
  const translatedFileName = fileMapping[locale][file.fileName];
114
113
  if (translatedFileName && existsSync(translatedFileName)) {
115
114
  const translatedContent = readFileSync(translatedFileName, 'utf8');
116
- const encodedTranslatedContent = Buffer.from(translatedContent).toString('base64');
117
115
  translations.push({
118
- content: encodedTranslatedContent,
116
+ content: translatedContent,
119
117
  fileName: translatedFileName,
120
118
  fileFormat: file.fileFormat,
121
119
  dataFormat: file.dataFormat,
@@ -7,6 +7,7 @@ import loadJSON from '../../fs/loadJSON.js';
7
7
  import { hashSource } from 'generaltranslation/id';
8
8
  import getEntryAndMetadata from '../utils/getEntryAndMetadata.js';
9
9
  import { logError } from '../../console/logging.js';
10
+ import { randomUUID } from 'node:crypto';
10
11
  export async function createDictionaryUpdates(dictionaryPath, esbuildConfig) {
11
12
  let dictionary;
12
13
  // ---- HANDLE JSON STRING DICTIONARY ----- //
@@ -21,7 +22,7 @@ export async function createDictionaryUpdates(dictionaryPath, esbuildConfig) {
21
22
  write: false,
22
23
  });
23
24
  const bundledCode = result.outputFiles[0].text;
24
- const tempFilePath = path.join(os.tmpdir(), 'bundled-dictionary.js');
25
+ const tempFilePath = path.join(os.tmpdir(), `bundled-dictionary-${randomUUID()}.js`);
25
26
  await fs.promises.writeFile(tempFilePath, bundledCode);
26
27
  // Load the module using dynamic import
27
28
  let dictionaryModule;
@@ -5,6 +5,7 @@ import remarkFrontmatter from 'remark-frontmatter';
5
5
  import remarkStringify from 'remark-stringify';
6
6
  import { visit } from 'unist-util-visit';
7
7
  import { logWarning } from '../console/logging.js';
8
+ import { escapeHtmlInTextNodes } from './escapeHtml.js';
8
9
  /**
9
10
  * Generates a slug from heading text
10
11
  */
@@ -158,13 +159,13 @@ function applyInlineIds(translatedContent, idMappings) {
158
159
  // Add the ID to the heading
159
160
  const lastChild = heading.children[heading.children.length - 1];
160
161
  if (lastChild?.type === 'text') {
161
- lastChild.value += ` {#${id}}`;
162
+ lastChild.value += ` \\{#${id}\\}`;
162
163
  }
163
164
  else {
164
165
  // If last child is not text, add a new text node
165
166
  heading.children.push({
166
167
  type: 'text',
167
- value: ` {#${id}}`,
168
+ value: ` \\{#${id}\\}`,
168
169
  });
169
170
  }
170
171
  }
@@ -173,23 +174,19 @@ function applyInlineIds(translatedContent, idMappings) {
173
174
  // Convert the modified AST back to MDX string
174
175
  try {
175
176
  const stringifyProcessor = unified()
177
+ .use(remarkFrontmatter, ['yaml', 'toml'])
178
+ .use(remarkMdx)
179
+ .use(escapeHtmlInTextNodes)
176
180
  .use(remarkStringify, {
177
- bullet: '-',
178
- emphasis: '_',
179
- strong: '*',
180
- rule: '-',
181
- ruleRepetition: 3,
182
- ruleSpaces: false,
183
181
  handlers: {
184
182
  // Custom handler to prevent escaping of {#id} syntax
185
183
  text(node) {
186
184
  return node.value;
187
185
  },
188
186
  },
189
- })
190
- .use(remarkFrontmatter, ['yaml', 'toml'])
191
- .use(remarkMdx);
192
- let content = stringifyProcessor.stringify(processedAst);
187
+ });
188
+ const outTree = stringifyProcessor.runSync(processedAst);
189
+ let content = stringifyProcessor.stringify(outTree);
193
190
  // Handle newline formatting to match original input
194
191
  if (content.endsWith('\n') && !translatedContent.endsWith('\n')) {
195
192
  content = content.slice(0, -1);
@@ -0,0 +1,8 @@
1
+ import type { Plugin } from 'unified';
2
+ import type { Root } from 'mdast';
3
+ /**
4
+ * Escape HTML-sensitive characters (`&`, `<`, `>`, `"`, `'`) in text nodes,
5
+ * leaving code, math, MDX expressions, and front-matter untouched.
6
+ * Ensures literals render safely without altering already-escaped entities.
7
+ */
8
+ export declare const escapeHtmlInTextNodes: Plugin<[], Root>;
@@ -0,0 +1,32 @@
1
+ import { findAndReplace } from 'mdast-util-find-and-replace';
2
+ const IGNORE_PARENTS = [
3
+ 'code',
4
+ 'inlineCode',
5
+ 'mdxFlowExpression',
6
+ 'mdxTextExpression',
7
+ 'mdxjsEsm',
8
+ 'heading',
9
+ 'yaml',
10
+ 'toml',
11
+ 'math',
12
+ 'inlineMath',
13
+ ];
14
+ // & that is NOT already an entity: &word; &#123; &#x1A2B;
15
+ const AMP_NOT_ENTITY = /&(?![a-zA-Z][a-zA-Z0-9]*;|#\d+;|#x[0-9A-Fa-f]+;)/g;
16
+ /**
17
+ * Escape HTML-sensitive characters (`&`, `<`, `>`, `"`, `'`) in text nodes,
18
+ * leaving code, math, MDX expressions, and front-matter untouched.
19
+ * Ensures literals render safely without altering already-escaped entities.
20
+ */
21
+ export const escapeHtmlInTextNodes = function () {
22
+ return (tree) => {
23
+ findAndReplace(tree, [
24
+ // Order matters: & first (idempotency), then the rest
25
+ [AMP_NOT_ENTITY, '&amp;'],
26
+ [/</g, '&lt;'],
27
+ [/>/g, '&gt;'],
28
+ [/"/g, '&quot;'],
29
+ [/'/g, '&#39;'],
30
+ ], { ignore: IGNORE_PARENTS });
31
+ };
32
+ };
@@ -1,11 +1,11 @@
1
1
  import * as fs from 'fs';
2
+ import * as path from 'path';
2
3
  import { createFileMapping } from '../formats/files/fileMapping.js';
3
4
  import micromatch from 'micromatch';
4
5
  import { unified } from 'unified';
5
6
  import remarkParse from 'remark-parse';
6
7
  import remarkMdx from 'remark-mdx';
7
8
  import remarkFrontmatter from 'remark-frontmatter';
8
- import remarkStringify from 'remark-stringify';
9
9
  import { visit } from 'unist-util-visit';
10
10
  const { isMatch } = micromatch;
11
11
  /**
@@ -44,11 +44,15 @@ export default async function localizeStaticImports(settings) {
44
44
  }
45
45
  if (defaultLocaleFiles.length > 0) {
46
46
  const defaultPromise = Promise.all(defaultLocaleFiles.map(async (filePath) => {
47
+ // Check if file exists before processing
48
+ if (!fs.existsSync(filePath)) {
49
+ return;
50
+ }
47
51
  // Get file content
48
52
  const fileContent = await fs.promises.readFile(filePath, 'utf8');
49
53
  // Localize the file using default locale
50
54
  const localizedFile = localizeStaticImportsForFile(fileContent, settings.defaultLocale, settings.defaultLocale, // Process as default locale
51
- settings.options?.docsHideDefaultLocaleImport || false, settings.options?.docsImportPattern, settings.options?.excludeStaticImports, filePath.endsWith('.md'));
55
+ settings.options?.docsHideDefaultLocaleImport || false, settings.options?.docsImportPattern, settings.options?.excludeStaticImports, filePath);
52
56
  // Write the localized file back to the same path
53
57
  await fs.promises.writeFile(filePath, localizedFile);
54
58
  }));
@@ -61,10 +65,14 @@ export default async function localizeStaticImports(settings) {
61
65
  const targetFiles = Object.values(filesMap).filter((path) => path.endsWith('.md') || path.endsWith('.mdx'));
62
66
  // Replace the placeholder path with the target path
63
67
  await Promise.all(targetFiles.map(async (filePath) => {
68
+ // Check if file exists before processing
69
+ if (!fs.existsSync(filePath)) {
70
+ return;
71
+ }
64
72
  // Get file content
65
73
  const fileContent = await fs.promises.readFile(filePath, 'utf8');
66
74
  // Localize the file
67
- const localizedFile = localizeStaticImportsForFile(fileContent, settings.defaultLocale, locale, settings.options?.docsHideDefaultLocaleImport || false, settings.options?.docsImportPattern, settings.options?.excludeStaticImports, filePath.endsWith('.md'));
75
+ const localizedFile = localizeStaticImportsForFile(fileContent, settings.defaultLocale, locale, settings.options?.docsHideDefaultLocaleImport || false, settings.options?.docsImportPattern, settings.options?.excludeStaticImports, filePath);
68
76
  // Write the localized file to the target path
69
77
  await fs.promises.writeFile(filePath, localizedFile);
70
78
  }));
@@ -175,23 +183,44 @@ function transformNonDefaultLocaleImportPath(fullPath, patternHead, targetLocale
175
183
  /**
176
184
  * Main import path transformation function that delegates to specific scenarios
177
185
  */
178
- function transformImportPath(fullPath, patternHead, targetLocale, defaultLocale, hideDefaultLocale) {
186
+ function transformImportPath(fullPath, patternHead, targetLocale, defaultLocale, hideDefaultLocale, currentFilePath, projectRoot = process.cwd() // fallback if not provided
187
+ ) {
188
+ let newPath;
179
189
  if (targetLocale === defaultLocale) {
180
- return transformDefaultLocaleImportPath(fullPath, patternHead, defaultLocale, hideDefaultLocale);
190
+ newPath = transformDefaultLocaleImportPath(fullPath, patternHead, defaultLocale, hideDefaultLocale);
181
191
  }
182
192
  else if (hideDefaultLocale) {
183
- return transformNonDefaultLocaleImportPathWithHidden(fullPath, patternHead, targetLocale, defaultLocale);
193
+ newPath = transformNonDefaultLocaleImportPathWithHidden(fullPath, patternHead, targetLocale, defaultLocale);
184
194
  }
185
195
  else {
186
- return transformNonDefaultLocaleImportPath(fullPath, patternHead, targetLocale, defaultLocale);
196
+ newPath = transformNonDefaultLocaleImportPath(fullPath, patternHead, targetLocale, defaultLocale);
187
197
  }
198
+ if (!newPath)
199
+ return null;
200
+ if (currentFilePath) {
201
+ let resolvedPath;
202
+ if (newPath.startsWith('/')) {
203
+ // Interpret as project-root relative
204
+ resolvedPath = path.join(projectRoot, newPath.replace(/^\//, ''));
205
+ }
206
+ else {
207
+ // Relative to current file
208
+ const currentDir = path.dirname(currentFilePath);
209
+ resolvedPath = path.resolve(currentDir, newPath);
210
+ }
211
+ if (!fs.existsSync(resolvedPath)) {
212
+ return null;
213
+ }
214
+ }
215
+ return newPath;
188
216
  }
189
217
  /**
190
218
  * AST-based transformation for MDX files using remark-mdx
191
219
  */
192
- function transformMdxImports(mdxContent, defaultLocale, targetLocale, hideDefaultLocale, pattern = '/[locale]', exclude = []) {
220
+ function transformMdxImports(mdxContent, defaultLocale, targetLocale, hideDefaultLocale, pattern = '/[locale]', exclude = [], currentFilePath) {
193
221
  const transformedImports = [];
194
- if (!pattern.startsWith('/')) {
222
+ // Don't auto-prefix relative patterns that start with . or ..
223
+ if (!pattern.startsWith('/') && !pattern.startsWith('.')) {
195
224
  pattern = '/' + pattern;
196
225
  }
197
226
  const patternHead = pattern.split('[locale]')[0];
@@ -239,23 +268,23 @@ function transformMdxImports(mdxContent, defaultLocale, targetLocale, hideDefaul
239
268
  transformedImports: [],
240
269
  };
241
270
  }
242
- // Visit only mdxjsEsm nodes (import/export statements)
271
+ let content = mdxContent;
272
+ // Visit only mdxjsEsm nodes (import/export statements) to collect replacements
243
273
  visit(processedAst, 'mdxjsEsm', (node) => {
244
274
  if (node.value && node.value.includes(patternHead.replace(/\/$/, ''))) {
245
- // Find and transform import paths in the node value
275
+ // Find import lines that need transformation
246
276
  const lines = node.value.split('\n');
247
- const transformedLines = lines.map((line) => {
277
+ lines.forEach((line) => {
248
278
  // Only process import lines that match our pattern
249
279
  if (!line.trim().startsWith('import ')) {
250
- return line;
280
+ return;
251
281
  }
252
282
  // Check if this line should be processed
253
283
  if (!shouldProcessImportPath(line, patternHead, targetLocale, defaultLocale)) {
254
- return line;
284
+ return;
255
285
  }
256
286
  // Extract the path from the import statement
257
287
  const quotes = ['"', "'", '`'];
258
- let transformedLine = line;
259
288
  for (const quote of quotes) {
260
289
  // Try both with and without trailing slash
261
290
  let startPattern = `${quote}${patternHead}`;
@@ -274,7 +303,7 @@ function transformMdxImports(mdxContent, defaultLocale, targetLocale, hideDefaul
274
303
  continue;
275
304
  const fullPath = line.slice(pathStart, pathEnd);
276
305
  // Transform the import path
277
- const newPath = transformImportPath(fullPath, patternHead, targetLocale, defaultLocale, hideDefaultLocale);
306
+ const newPath = transformImportPath(fullPath, patternHead, targetLocale, defaultLocale, hideDefaultLocale, currentFilePath);
278
307
  if (!newPath) {
279
308
  continue; // No transformation needed
280
309
  }
@@ -282,43 +311,15 @@ function transformMdxImports(mdxContent, defaultLocale, targetLocale, hideDefaul
282
311
  if (isImportPathExcluded(fullPath, exclude, defaultLocale)) {
283
312
  continue;
284
313
  }
285
- // Apply the transformation
314
+ // Apply the transformation to the original content
315
+ // Simply replace the import path with the new path
316
+ content = content.replace(`${quote}${fullPath}${quote}`, `${quote}${newPath}${quote}`);
286
317
  transformedImports.push({ originalPath: fullPath, newPath });
287
- transformedLine =
288
- line.slice(0, pathStart) + newPath + line.slice(pathEnd);
289
318
  break;
290
319
  }
291
- return transformedLine;
292
320
  });
293
- node.value = transformedLines.join('\n');
294
321
  }
295
322
  });
296
- // Convert the modified AST back to MDX string
297
- let content;
298
- try {
299
- const stringifyProcessor = unified()
300
- .use(remarkStringify)
301
- .use(remarkFrontmatter, ['yaml', 'toml'])
302
- .use(remarkMdx);
303
- content = stringifyProcessor.stringify(processedAst);
304
- }
305
- catch (error) {
306
- console.warn(`Failed to stringify MDX content: ${error instanceof Error ? error.message : String(error)}`);
307
- console.warn('Returning original content unchanged due to stringify error.');
308
- return {
309
- content: mdxContent,
310
- hasChanges: false,
311
- transformedImports: [],
312
- };
313
- }
314
- // Handle newline formatting to match original input
315
- if (content.endsWith('\n') && !mdxContent.endsWith('\n')) {
316
- content = content.slice(0, -1);
317
- }
318
- // Preserve leading newlines from original content
319
- if (mdxContent.startsWith('\n') && !content.startsWith('\n')) {
320
- content = '\n' + content;
321
- }
322
323
  return {
323
324
  content,
324
325
  hasChanges: transformedImports.length > 0,
@@ -329,12 +330,12 @@ function transformMdxImports(mdxContent, defaultLocale, targetLocale, hideDefaul
329
330
  * AST-based transformation for MDX files only
330
331
  */
331
332
  function localizeStaticImportsForFile(file, defaultLocale, targetLocale, hideDefaultLocale, pattern = '/[locale]', // eg /docs/[locale] or /[locale]
332
- exclude = [], isMarkdown = false) {
333
+ exclude = [], currentFilePath) {
333
334
  // Skip .md files entirely - they cannot have imports
334
- if (isMarkdown) {
335
+ if (currentFilePath && currentFilePath.endsWith('.md')) {
335
336
  return file;
336
337
  }
337
338
  // For MDX files, use AST-based transformation
338
- const result = transformMdxImports(file, defaultLocale, targetLocale, hideDefaultLocale, pattern, exclude);
339
+ const result = transformMdxImports(file, defaultLocale, targetLocale, hideDefaultLocale, pattern, exclude, currentFilePath);
339
340
  return result.content;
340
341
  }
@@ -7,6 +7,7 @@ import remarkMdx from 'remark-mdx';
7
7
  import remarkFrontmatter from 'remark-frontmatter';
8
8
  import remarkStringify from 'remark-stringify';
9
9
  import { visit } from 'unist-util-visit';
10
+ import { escapeHtmlInTextNodes } from './escapeHtml.js';
10
11
  const { isMatch } = micromatch;
11
12
  /**
12
13
  * Localizes static urls in content files.
@@ -49,6 +50,10 @@ export default async function localizeStaticUrls(settings, targetLocales) {
49
50
  }
50
51
  if (defaultLocaleFiles.length > 0) {
51
52
  const defaultPromise = Promise.all(defaultLocaleFiles.map(async (filePath) => {
53
+ // Check if file exists before processing
54
+ if (!fs.existsSync(filePath)) {
55
+ return;
56
+ }
52
57
  // Get file content
53
58
  const fileContent = await fs.promises.readFile(filePath, 'utf8');
54
59
  // Localize the file using default locale
@@ -366,17 +371,19 @@ function transformMdxUrls(mdxContent, defaultLocale, targetLocale, hideDefaultLo
366
371
  let content;
367
372
  try {
368
373
  const stringifyProcessor = unified()
369
- .use(remarkStringify, {
370
- bullet: '-',
371
- emphasis: '_',
372
- strong: '*',
373
- rule: '-',
374
- ruleRepetition: 3,
375
- ruleSpaces: false,
376
- })
377
374
  .use(remarkFrontmatter, ['yaml', 'toml'])
378
- .use(remarkMdx);
379
- content = stringifyProcessor.stringify(processedAst);
375
+ .use(remarkMdx)
376
+ .use(escapeHtmlInTextNodes)
377
+ .use(remarkStringify, {
378
+ handlers: {
379
+ // Handler to prevent escaping (avoids '&lt;' -> '\&lt;')
380
+ text(node) {
381
+ return node.value;
382
+ },
383
+ },
384
+ });
385
+ const outTree = stringifyProcessor.runSync(processedAst);
386
+ content = stringifyProcessor.stringify(outTree);
380
387
  }
381
388
  catch (error) {
382
389
  console.warn(`Failed to stringify MDX content: ${error instanceof Error ? error.message : String(error)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtx-cli",
3
- "version": "2.2.0-alpha.0",
3
+ "version": "2.3.0",
4
4
  "main": "dist/index.js",
5
5
  "bin": "dist/main.js",
6
6
  "files": [
@@ -87,10 +87,11 @@
87
87
  "esbuild": "^0.25.4",
88
88
  "fast-glob": "^3.3.3",
89
89
  "form-data": "^4.0.4",
90
- "generaltranslation": "^7.5.0-alpha.0",
90
+ "generaltranslation": "^7.6.0",
91
91
  "json-pointer": "^0.6.2",
92
92
  "jsonpath-plus": "^10.3.0",
93
93
  "jsonpointer": "^5.0.1",
94
+ "mdast-util-find-and-replace": "^3.0.2",
94
95
  "micromatch": "^4.0.8",
95
96
  "open": "^10.1.1",
96
97
  "ora": "^8.2.0",