gtx-cli 2.1.0 → 2.1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # gtx-cli
2
2
 
3
+ ## 2.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#562](https://github.com/generaltranslation/gt/pull/562) [`8461c5e`](https://github.com/generaltranslation/gt/commit/8461c5ee2ca25cf50d4e366cb4d1e765107851fd) Thanks [@SamEggert](https://github.com/SamEggert)! - localStaticImports gracefully handles invalid MDX
8
+
9
+ ## 2.1.1
10
+
11
+ ### Patch Changes
12
+
13
+ - [#554](https://github.com/generaltranslation/gt/pull/554) [`77fb048`](https://github.com/generaltranslation/gt/commit/77fb048ab2e4432739df1c4fbabe165712e84fb3) Thanks [@SamEggert](https://github.com/SamEggert)! - use MDX AST for static imports/urls
14
+
3
15
  ## 2.1.0
4
16
 
5
17
  ### Minor Changes
@@ -1,7 +1,12 @@
1
1
  import * as fs from 'fs';
2
2
  import { createFileMapping } from '../formats/files/fileMapping.js';
3
- import { logError } from '../console/logging.js';
4
3
  import micromatch from 'micromatch';
4
+ import { unified } from 'unified';
5
+ import remarkParse from 'remark-parse';
6
+ import remarkMdx from 'remark-mdx';
7
+ import remarkFrontmatter from 'remark-frontmatter';
8
+ import remarkStringify from 'remark-stringify';
9
+ import { visit } from 'unist-util-visit';
5
10
  const { isMatch } = micromatch;
6
11
  /**
7
12
  * Localizes static imports in content files.
@@ -25,7 +30,33 @@ export default async function localizeStaticImports(settings) {
25
30
  const { resolvedPaths: sourceFiles } = settings.files;
26
31
  const fileMapping = createFileMapping(sourceFiles, settings.files.placeholderPaths, settings.files.transformPaths, settings.locales, settings.defaultLocale);
27
32
  // Process all file types at once with a single call
28
- await Promise.all(Object.entries(fileMapping).map(async ([locale, filesMap]) => {
33
+ const processPromises = [];
34
+ // First, process default locale files (from source files)
35
+ // This is needed because they might not be in the fileMapping if they're not being translated
36
+ if (!fileMapping[settings.defaultLocale]) {
37
+ const defaultLocaleFiles = [];
38
+ // Collect all .md and .mdx files from sourceFiles
39
+ if (sourceFiles.md) {
40
+ defaultLocaleFiles.push(...sourceFiles.md);
41
+ }
42
+ if (sourceFiles.mdx) {
43
+ defaultLocaleFiles.push(...sourceFiles.mdx);
44
+ }
45
+ if (defaultLocaleFiles.length > 0) {
46
+ const defaultPromise = Promise.all(defaultLocaleFiles.map(async (filePath) => {
47
+ // Get file content
48
+ const fileContent = await fs.promises.readFile(filePath, 'utf8');
49
+ // Localize the file using default locale
50
+ 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'));
52
+ // Write the localized file back to the same path
53
+ await fs.promises.writeFile(filePath, localizedFile);
54
+ }));
55
+ processPromises.push(defaultPromise);
56
+ }
57
+ }
58
+ // Then process all other locales from fileMapping
59
+ const mappingPromises = Object.entries(fileMapping).map(async ([locale, filesMap]) => {
29
60
  // Get all files that are md or mdx
30
61
  const targetFiles = Object.values(filesMap).filter((path) => path.endsWith('.md') || path.endsWith('.mdx'));
31
62
  // Replace the placeholder path with the target path
@@ -33,83 +64,277 @@ export default async function localizeStaticImports(settings) {
33
64
  // Get file content
34
65
  const fileContent = await fs.promises.readFile(filePath, 'utf8');
35
66
  // Localize the file
36
- const localizedFile = localizeStaticImportsForFile(fileContent, settings.defaultLocale, locale, settings.options?.docsHideDefaultLocaleImport || false, settings.options?.docsImportPattern, settings.options?.excludeStaticImports);
67
+ const localizedFile = localizeStaticImportsForFile(fileContent, settings.defaultLocale, locale, settings.options?.docsHideDefaultLocaleImport || false, settings.options?.docsImportPattern, settings.options?.excludeStaticImports, filePath.endsWith('.md'));
37
68
  // Write the localized file to the target path
38
69
  await fs.promises.writeFile(filePath, localizedFile);
39
70
  }));
40
- }));
71
+ });
72
+ processPromises.push(...mappingPromises);
73
+ await Promise.all(processPromises);
41
74
  }
42
- // Naive find and replace, in the future, construct an AST
43
- function localizeStaticImportsForFile(file, defaultLocale, targetLocale, hideDefaultLocale, pattern = '/[locale]', // eg /docs/[locale] or /[locale]
44
- exclude = []) {
45
- if (!pattern.startsWith('/')) {
46
- pattern = '/' + pattern;
47
- }
48
- // 1. Search for all instances of:
49
- const patternHead = pattern.split('[locale]')[0];
50
- // Escape special regex characters and remove trailing slash if present
51
- const escapedPatternHead = patternHead
52
- .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
53
- .replace(/\/$/, '');
54
- let regex;
55
- if (hideDefaultLocale) {
56
- // Match complete import statements: `import { Foo } from '/docs/foo.md'`
57
- regex = new RegExp(`import\\s+(.*?)\\s+from\\s+(["'])${escapedPatternHead}(?:/([^"']*?))?\\2`, 'g');
75
+ /**
76
+ * Determines if an import path should be processed based on pattern matching
77
+ */
78
+ function shouldProcessImportPath(importPath, patternHead, targetLocale, defaultLocale) {
79
+ const patternWithoutSlash = patternHead.replace(/\/$/, '');
80
+ if (targetLocale === defaultLocale) {
81
+ // For default locale processing, check if path contains the pattern
82
+ return importPath.includes(patternWithoutSlash);
58
83
  }
59
84
  else {
60
- // Match complete import statements with default locale: `import { Foo } from '/docs/${defaultLocale}/foo.md'`
61
- regex = new RegExp(`import\\s+(.*?)\\s+from\\s+(["'])${escapedPatternHead}/${defaultLocale}(?:/([^"']*?))?\\2`, 'g');
85
+ // For non-default locales, check if path starts with pattern
86
+ return importPath.includes(patternWithoutSlash);
62
87
  }
63
- const matches = file.match(regex);
64
- if (!matches) {
65
- return file;
88
+ }
89
+ /**
90
+ * Checks if an import path should be excluded based on exclusion patterns
91
+ */
92
+ function isImportPathExcluded(importPath, exclude, defaultLocale) {
93
+ const excludePatterns = exclude.map((p) => p.replace(/\[locale\]/g, defaultLocale));
94
+ return excludePatterns.some((pattern) => isMatch(importPath, pattern));
95
+ }
96
+ /**
97
+ * Transforms import path for default locale processing
98
+ */
99
+ function transformDefaultLocaleImportPath(fullPath, patternHead, defaultLocale, hideDefaultLocale) {
100
+ if (hideDefaultLocale) {
101
+ // Remove locale from imports that have it: '/snippets/en/file.mdx' -> '/snippets/file.mdx'
102
+ if (fullPath.includes(`/${defaultLocale}/`)) {
103
+ return fullPath.replace(`/${defaultLocale}/`, '/');
104
+ }
105
+ else if (fullPath.endsWith(`/${defaultLocale}`)) {
106
+ return fullPath.replace(`/${defaultLocale}`, '');
107
+ }
108
+ return null; // Path doesn't have default locale
66
109
  }
67
- exclude = exclude.map((pattern) => pattern.replace(/\[locale\]/g, defaultLocale));
68
- // 2. Replace the default locale with the target locale in all matched instances
69
- const localizedFile = file.replace(regex, (match, bindings, quoteType, pathContent) => {
70
- // Check if this path should be excluded from localization
71
- if (exclude.length > 0) {
72
- let matchPath = '';
73
- // let matchPath = patternHead;
74
- if (pathContent) {
75
- matchPath = hideDefaultLocale
76
- ? `${patternHead}${pathContent}`
77
- : `${patternHead}${defaultLocale}/${pathContent}`;
110
+ else {
111
+ // Add locale to imports that don't have it: '/snippets/file.mdx' -> '/snippets/en/file.mdx'
112
+ if (fullPath.includes(`/${defaultLocale}/`) ||
113
+ fullPath.endsWith(`/${defaultLocale}`)) {
114
+ return null; // Already has default locale
115
+ }
116
+ if (fullPath.startsWith(patternHead)) {
117
+ const pathAfterHead = fullPath.slice(patternHead.length);
118
+ if (pathAfterHead) {
119
+ return `${patternHead}${defaultLocale}/${pathAfterHead}`;
78
120
  }
79
121
  else {
80
- matchPath = hideDefaultLocale
81
- ? `${patternHead}`
82
- : `${patternHead}${defaultLocale}`;
122
+ return `${patternHead.replace(/\/$/, '')}/${defaultLocale}`;
83
123
  }
84
- if (exclude.some((pattern) => isMatch(matchPath, pattern))) {
85
- return match; // Don't localize excluded paths
86
- }
87
- }
88
- // get the quote type
89
- quoteType = match.match(/["']/)?.[0] || '"';
90
- if (!quoteType) {
91
- logError(`Failed to localize static imports: Import pattern must include quotes in ${pattern}`);
92
- return match;
93
124
  }
94
- if (hideDefaultLocale) {
95
- // For hideDefaultLocale, check if path already has target locale
96
- if (pathContent) {
97
- if (pathContent.startsWith(`${targetLocale}/`) ||
98
- pathContent === targetLocale) {
99
- return match; // Already localized
100
- }
101
- }
102
- // Add target locale to the path
103
- if (!pathContent || pathContent === '') {
104
- return `import ${bindings} from ${quoteType}${patternHead}${targetLocale}${quoteType}`;
105
- }
106
- return `import ${bindings} from ${quoteType}${patternHead}${targetLocale}/${pathContent}${quoteType}`;
125
+ return null; // Path doesn't match pattern
126
+ }
127
+ }
128
+ /**
129
+ * Transforms import path for non-default locale processing with hideDefaultLocale=true
130
+ */
131
+ function transformNonDefaultLocaleImportPathWithHidden(fullPath, patternHead, targetLocale, defaultLocale) {
132
+ // Check if already localized
133
+ if (fullPath.startsWith(`${patternHead}${targetLocale}/`) ||
134
+ fullPath === `${patternHead}${targetLocale}`) {
135
+ return null;
136
+ }
137
+ // Replace default locale with target locale
138
+ const expectedPathWithDefaultLocale = `${patternHead}${defaultLocale}`;
139
+ if (fullPath.startsWith(`${expectedPathWithDefaultLocale}/`) ||
140
+ fullPath === expectedPathWithDefaultLocale) {
141
+ return fullPath.replace(`${patternHead}${defaultLocale}`, `${patternHead}${targetLocale}`);
142
+ }
143
+ // Handle exact pattern match
144
+ if (fullPath === patternHead.replace(/\/$/, '')) {
145
+ return `${patternHead.replace(/\/$/, '')}/${targetLocale}`;
146
+ }
147
+ // Add target locale to path without any locale
148
+ const pathAfterHead = fullPath.slice(patternHead.length);
149
+ return pathAfterHead
150
+ ? `${patternHead}${targetLocale}/${pathAfterHead}`
151
+ : `${patternHead}${targetLocale}`;
152
+ }
153
+ /**
154
+ * Transforms import path for non-default locale processing with hideDefaultLocale=false
155
+ */
156
+ function transformNonDefaultLocaleImportPath(fullPath, patternHead, targetLocale, defaultLocale) {
157
+ const expectedPathWithLocale = `${patternHead}${defaultLocale}`;
158
+ if (fullPath.startsWith(`${expectedPathWithLocale}/`) ||
159
+ fullPath === expectedPathWithLocale) {
160
+ // Replace existing default locale with target locale
161
+ return fullPath.replace(`${patternHead}${defaultLocale}`, `${patternHead}${targetLocale}`);
162
+ }
163
+ else if (fullPath.startsWith(patternHead)) {
164
+ // Add target locale to path that doesn't have any locale
165
+ const pathAfterHead = fullPath.slice(patternHead.length);
166
+ if (pathAfterHead) {
167
+ return `${patternHead}${targetLocale}/${pathAfterHead}`;
107
168
  }
108
169
  else {
109
- // For non-hideDefaultLocale, replace defaultLocale with targetLocale
110
- // pathContent contains everything after the default locale (no leading slash if present)
111
- return `import ${bindings} from ${quoteType}${patternHead}${targetLocale}${pathContent ? '/' + pathContent : ''}${quoteType}`;
170
+ return `${patternHead.replace(/\/$/, '')}/${targetLocale}`;
171
+ }
172
+ }
173
+ return null; // Path doesn't match pattern
174
+ }
175
+ /**
176
+ * Main import path transformation function that delegates to specific scenarios
177
+ */
178
+ function transformImportPath(fullPath, patternHead, targetLocale, defaultLocale, hideDefaultLocale) {
179
+ if (targetLocale === defaultLocale) {
180
+ return transformDefaultLocaleImportPath(fullPath, patternHead, defaultLocale, hideDefaultLocale);
181
+ }
182
+ else if (hideDefaultLocale) {
183
+ return transformNonDefaultLocaleImportPathWithHidden(fullPath, patternHead, targetLocale, defaultLocale);
184
+ }
185
+ else {
186
+ return transformNonDefaultLocaleImportPath(fullPath, patternHead, targetLocale, defaultLocale);
187
+ }
188
+ }
189
+ /**
190
+ * AST-based transformation for MDX files using remark-mdx
191
+ */
192
+ function transformMdxImports(mdxContent, defaultLocale, targetLocale, hideDefaultLocale, pattern = '/[locale]', exclude = []) {
193
+ const transformedImports = [];
194
+ if (!pattern.startsWith('/')) {
195
+ pattern = '/' + pattern;
196
+ }
197
+ const patternHead = pattern.split('[locale]')[0];
198
+ // Quick check: if the file doesn't contain the pattern, skip expensive AST parsing
199
+ // For default locale processing, we also need to check if content might need adjustment
200
+ if (targetLocale === defaultLocale) {
201
+ // For default locale files, we always need to check as we're looking for either:
202
+ // - paths without locale (when hideDefaultLocale=false)
203
+ // - paths with default locale (when hideDefaultLocale=true)
204
+ const patternWithoutSlash = patternHead.replace(/\/$/, '');
205
+ if (!mdxContent.includes(patternWithoutSlash)) {
206
+ return {
207
+ content: mdxContent,
208
+ hasChanges: false,
209
+ transformedImports: [],
210
+ };
211
+ }
212
+ }
213
+ else {
214
+ // For non-default locales, use the original logic
215
+ if (!mdxContent.includes(patternHead.replace(/\/$/, ''))) {
216
+ return {
217
+ content: mdxContent,
218
+ hasChanges: false,
219
+ transformedImports: [],
220
+ };
221
+ }
222
+ }
223
+ // Parse the MDX content into an AST
224
+ let processedAst;
225
+ try {
226
+ const parseProcessor = unified()
227
+ .use(remarkParse)
228
+ .use(remarkFrontmatter, ['yaml', 'toml'])
229
+ .use(remarkMdx);
230
+ const ast = parseProcessor.parse(mdxContent);
231
+ processedAst = parseProcessor.runSync(ast);
232
+ }
233
+ catch (error) {
234
+ console.warn(`Failed to parse MDX content: ${error instanceof Error ? error.message : String(error)}`);
235
+ console.warn('Returning original content unchanged due to parsing error.');
236
+ return {
237
+ content: mdxContent,
238
+ hasChanges: false,
239
+ transformedImports: [],
240
+ };
241
+ }
242
+ // Visit only mdxjsEsm nodes (import/export statements)
243
+ visit(processedAst, 'mdxjsEsm', (node) => {
244
+ if (node.value && node.value.includes(patternHead.replace(/\/$/, ''))) {
245
+ // Find and transform import paths in the node value
246
+ const lines = node.value.split('\n');
247
+ const transformedLines = lines.map((line) => {
248
+ // Only process import lines that match our pattern
249
+ if (!line.trim().startsWith('import ')) {
250
+ return line;
251
+ }
252
+ // Check if this line should be processed
253
+ if (!shouldProcessImportPath(line, patternHead, targetLocale, defaultLocale)) {
254
+ return line;
255
+ }
256
+ // Extract the path from the import statement
257
+ const quotes = ['"', "'", '`'];
258
+ let transformedLine = line;
259
+ for (const quote of quotes) {
260
+ // Try both with and without trailing slash
261
+ let startPattern = `${quote}${patternHead}`;
262
+ let startIndex = line.indexOf(startPattern);
263
+ // If pattern has trailing slash but path doesn't, try without slash
264
+ if (startIndex === -1 && patternHead.endsWith('/')) {
265
+ const patternWithoutSlash = patternHead.slice(0, -1);
266
+ startPattern = `${quote}${patternWithoutSlash}`;
267
+ startIndex = line.indexOf(startPattern);
268
+ }
269
+ if (startIndex === -1)
270
+ continue;
271
+ const pathStart = startIndex + 1; // After the quote
272
+ const pathEnd = line.indexOf(quote, pathStart);
273
+ if (pathEnd === -1)
274
+ continue;
275
+ const fullPath = line.slice(pathStart, pathEnd);
276
+ // Transform the import path
277
+ const newPath = transformImportPath(fullPath, patternHead, targetLocale, defaultLocale, hideDefaultLocale);
278
+ if (!newPath) {
279
+ continue; // No transformation needed
280
+ }
281
+ // Check exclusions
282
+ if (isImportPathExcluded(fullPath, exclude, defaultLocale)) {
283
+ continue;
284
+ }
285
+ // Apply the transformation
286
+ transformedImports.push({ originalPath: fullPath, newPath });
287
+ transformedLine =
288
+ line.slice(0, pathStart) + newPath + line.slice(pathEnd);
289
+ break;
290
+ }
291
+ return transformedLine;
292
+ });
293
+ node.value = transformedLines.join('\n');
112
294
  }
113
295
  });
114
- return localizedFile;
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
+ return {
323
+ content,
324
+ hasChanges: transformedImports.length > 0,
325
+ transformedImports,
326
+ };
327
+ }
328
+ /**
329
+ * AST-based transformation for MDX files only
330
+ */
331
+ function localizeStaticImportsForFile(file, defaultLocale, targetLocale, hideDefaultLocale, pattern = '/[locale]', // eg /docs/[locale] or /[locale]
332
+ exclude = [], isMarkdown = false) {
333
+ // Skip .md files entirely - they cannot have imports
334
+ if (isMarkdown) {
335
+ return file;
336
+ }
337
+ // For MDX files, use AST-based transformation
338
+ const result = transformMdxImports(file, defaultLocale, targetLocale, hideDefaultLocale, pattern, exclude);
339
+ return result.content;
115
340
  }
@@ -1,6 +1,12 @@
1
1
  import * as fs from 'fs';
2
2
  import { createFileMapping } from '../formats/files/fileMapping.js';
3
3
  import micromatch from 'micromatch';
4
+ import { unified } from 'unified';
5
+ import remarkParse from 'remark-parse';
6
+ import remarkMdx from 'remark-mdx';
7
+ import remarkFrontmatter from 'remark-frontmatter';
8
+ import remarkStringify from 'remark-stringify';
9
+ import { visit } from 'unist-util-visit';
4
10
  const { isMatch } = micromatch;
5
11
  /**
6
12
  * Localizes static urls in content files.
@@ -24,147 +30,355 @@ export default async function localizeStaticUrls(settings) {
24
30
  const { resolvedPaths: sourceFiles } = settings.files;
25
31
  const fileMapping = createFileMapping(sourceFiles, settings.files.placeholderPaths, settings.files.transformPaths, settings.locales, settings.defaultLocale);
26
32
  // Process all file types at once with a single call
27
- await Promise.all(Object.entries(fileMapping).map(async ([locale, filesMap]) => {
33
+ const processPromises = [];
34
+ // First, process default locale files (from source files)
35
+ // This is needed because they might not be in the fileMapping if they're not being translated
36
+ if (!fileMapping[settings.defaultLocale]) {
37
+ const defaultLocaleFiles = [];
38
+ // Collect all .md and .mdx files from sourceFiles
39
+ if (sourceFiles.md) {
40
+ defaultLocaleFiles.push(...sourceFiles.md);
41
+ }
42
+ if (sourceFiles.mdx) {
43
+ defaultLocaleFiles.push(...sourceFiles.mdx);
44
+ }
45
+ if (defaultLocaleFiles.length > 0) {
46
+ const defaultPromise = Promise.all(defaultLocaleFiles.map(async (filePath) => {
47
+ // Get file content
48
+ const fileContent = await fs.promises.readFile(filePath, 'utf8');
49
+ // Localize the file using default locale
50
+ const localizedFile = localizeStaticUrlsForFile(fileContent, settings.defaultLocale, settings.defaultLocale, // Process as default locale
51
+ settings.experimentalHideDefaultLocale || false, settings.options?.docsUrlPattern, settings.options?.excludeStaticUrls);
52
+ // Write the localized file back to the same path
53
+ await fs.promises.writeFile(filePath, localizedFile);
54
+ }));
55
+ processPromises.push(defaultPromise);
56
+ }
57
+ }
58
+ // Then process all other locales from fileMapping
59
+ const mappingPromises = Object.entries(fileMapping).map(async ([locale, filesMap]) => {
28
60
  // Get all files that are md or mdx
29
61
  const targetFiles = Object.values(filesMap).filter((path) => path.endsWith('.md') || path.endsWith('.mdx'));
30
62
  // Replace the placeholder path with the target path
31
63
  await Promise.all(targetFiles.map(async (filePath) => {
32
64
  // Get file content
33
65
  const fileContent = await fs.promises.readFile(filePath, 'utf8');
34
- // Localize the file
66
+ // Localize the file (handles both URLs and hrefs in single AST pass)
35
67
  const localizedFile = localizeStaticUrlsForFile(fileContent, settings.defaultLocale, locale, settings.experimentalHideDefaultLocale || false, settings.options?.docsUrlPattern, settings.options?.excludeStaticUrls);
36
- const localizedFileHrefs = localizeStaticHrefsForFile(localizedFile, settings.defaultLocale, locale, settings.experimentalHideDefaultLocale || false, settings.options?.docsUrlPattern, settings.options?.excludeStaticUrls);
37
68
  // Write the localized file to the target path
38
- await fs.promises.writeFile(filePath, localizedFileHrefs);
69
+ await fs.promises.writeFile(filePath, localizedFile);
39
70
  }));
40
- }));
71
+ });
72
+ processPromises.push(...mappingPromises);
73
+ await Promise.all(processPromises);
41
74
  }
42
- // Naive find and replace, in the future, construct an AST
43
- function localizeStaticUrlsForFile(file, defaultLocale, targetLocale, hideDefaultLocale, pattern = '/[locale]', // eg /docs/[locale] or /[locale]
44
- exclude = []) {
45
- if (!pattern.startsWith('/')) {
46
- pattern = '/' + pattern;
75
+ /**
76
+ * Determines if a URL should be processed based on pattern matching
77
+ */
78
+ function shouldProcessUrl(originalUrl, patternHead, targetLocale, defaultLocale) {
79
+ const patternWithoutSlash = patternHead.replace(/\/$/, '');
80
+ if (targetLocale === defaultLocale) {
81
+ // For default locale processing, check if URL contains the pattern
82
+ return originalUrl.includes(patternWithoutSlash);
47
83
  }
48
- // 1. Search for all instances of:
49
- const patternHead = pattern.split('[locale]')[0];
50
- // Escape special regex characters and remove trailing slash if present
51
- const escapedPatternHead = patternHead
52
- .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
53
- .replace(/\/$/, '');
54
- let regex;
84
+ else {
85
+ // For non-default locales, check if URL starts with pattern
86
+ return originalUrl.startsWith(patternWithoutSlash);
87
+ }
88
+ }
89
+ /**
90
+ * Checks if a URL should be excluded based on exclusion patterns
91
+ */
92
+ function isUrlExcluded(originalUrl, exclude, defaultLocale) {
93
+ const excludePatterns = exclude.map((p) => p.replace(/\[locale\]/g, defaultLocale));
94
+ return excludePatterns.some((pattern) => isMatch(originalUrl, pattern));
95
+ }
96
+ /**
97
+ * Transforms URL for default locale processing
98
+ */
99
+ function transformDefaultLocaleUrl(originalUrl, patternHead, defaultLocale, hideDefaultLocale) {
55
100
  if (hideDefaultLocale) {
56
- // Match complete markdown links: `](/docs/...)` or `](/docs)`
57
- regex = new RegExp(`\\]\\(${escapedPatternHead}(?:/([^)]*))?\\)`, 'g');
101
+ // Remove locale from URLs that have it: '/docs/en/file' -> '/docs/file'
102
+ if (originalUrl.includes(`/${defaultLocale}/`)) {
103
+ return originalUrl.replace(`/${defaultLocale}/`, '/');
104
+ }
105
+ else if (originalUrl.endsWith(`/${defaultLocale}`)) {
106
+ return originalUrl.replace(`/${defaultLocale}`, '');
107
+ }
108
+ return null; // URL doesn't have default locale
58
109
  }
59
110
  else {
60
- // Match complete markdown links with default locale: `](/docs/${defaultLocale}/...)` or `](/docs/${defaultLocale})`
61
- regex = new RegExp(`\\]\\(${escapedPatternHead}/${defaultLocale}(?:/([^)]*))?\\)`, 'g');
62
- }
63
- const matches = file.match(regex);
64
- if (!matches) {
65
- return file;
66
- }
67
- exclude = exclude.map((pattern) => pattern.replace(/\[locale\]/g, defaultLocale));
68
- // 2. Replace the default locale with the target locale in all matched instances
69
- const localizedFile = file.replace(regex, (match, pathContent) => {
70
- if (exclude.length > 0) {
71
- // Check if this path should be excluded from localization
72
- let matchPath = '';
73
- if (pathContent) {
74
- matchPath = hideDefaultLocale
75
- ? `${patternHead}${pathContent}`
76
- : `${patternHead}${defaultLocale}/${pathContent}`;
111
+ // Add locale to URLs that don't have it: '/docs/file' -> '/docs/en/file'
112
+ if (originalUrl.includes(`/${defaultLocale}/`) ||
113
+ originalUrl.endsWith(`/${defaultLocale}`)) {
114
+ return null; // Already has default locale
115
+ }
116
+ if (originalUrl.startsWith(patternHead)) {
117
+ const pathAfterHead = originalUrl.slice(patternHead.length);
118
+ if (pathAfterHead) {
119
+ return `${patternHead}${defaultLocale}/${pathAfterHead}`;
77
120
  }
78
121
  else {
79
- matchPath = hideDefaultLocale
80
- ? `${patternHead}`
81
- : `${patternHead}${defaultLocale}`;
82
- }
83
- if (exclude.some((pattern) => isMatch(matchPath, pattern))) {
84
- return match; // Don't localize excluded paths
122
+ return `${patternHead.replace(/\/$/, '')}/${defaultLocale}`;
85
123
  }
86
124
  }
87
- if (hideDefaultLocale) {
88
- // For hideDefaultLocale, check if path already has target locale
89
- if (pathContent) {
90
- if (pathContent.startsWith(`${targetLocale}/`) ||
91
- pathContent === targetLocale) {
92
- return match; // Already localized
93
- }
94
- }
95
- // Add target locale to the path
96
- if (!pathContent || pathContent === '') {
97
- return `](${patternHead}${targetLocale})`;
98
- }
99
- return `](${patternHead}${targetLocale}/${pathContent})`;
125
+ return null; // URL doesn't match pattern
126
+ }
127
+ }
128
+ /**
129
+ * Transforms URL for non-default locale processing with hideDefaultLocale=true
130
+ */
131
+ function transformNonDefaultLocaleUrlWithHidden(originalUrl, patternHead, targetLocale, defaultLocale) {
132
+ // Check if already localized
133
+ if (originalUrl.startsWith(`${patternHead}${targetLocale}/`) ||
134
+ originalUrl === `${patternHead}${targetLocale}`) {
135
+ return null;
136
+ }
137
+ // Replace default locale with target locale
138
+ const expectedPathWithDefaultLocale = `${patternHead}${defaultLocale}`;
139
+ if (originalUrl.startsWith(`${expectedPathWithDefaultLocale}/`) ||
140
+ originalUrl === expectedPathWithDefaultLocale) {
141
+ return originalUrl.replace(`${patternHead}${defaultLocale}`, `${patternHead}${targetLocale}`);
142
+ }
143
+ // Handle exact pattern match
144
+ if (originalUrl === patternHead.replace(/\/$/, '')) {
145
+ return `${patternHead.replace(/\/$/, '')}/${targetLocale}`;
146
+ }
147
+ // Add target locale to URL without any locale
148
+ const pathAfterHead = originalUrl.slice(patternHead.length);
149
+ return pathAfterHead
150
+ ? `${patternHead}${targetLocale}/${pathAfterHead}`
151
+ : `${patternHead}${targetLocale}`;
152
+ }
153
+ /**
154
+ * Transforms URL for non-default locale processing with hideDefaultLocale=false
155
+ */
156
+ function transformNonDefaultLocaleUrl(originalUrl, patternHead, targetLocale, defaultLocale) {
157
+ const expectedPathWithLocale = `${patternHead}${defaultLocale}`;
158
+ if (originalUrl.startsWith(`${expectedPathWithLocale}/`) ||
159
+ originalUrl === expectedPathWithLocale) {
160
+ // Replace existing default locale with target locale
161
+ return originalUrl.replace(`${patternHead}${defaultLocale}`, `${patternHead}${targetLocale}`);
162
+ }
163
+ else if (originalUrl.startsWith(patternHead)) {
164
+ // Add target locale to URL that doesn't have any locale
165
+ const pathAfterHead = originalUrl.slice(patternHead.length);
166
+ if (pathAfterHead) {
167
+ return `${patternHead}${targetLocale}/${pathAfterHead}`;
100
168
  }
101
169
  else {
102
- // For non-hideDefaultLocale, replace defaultLocale with targetLocale
103
- // pathContent contains everything after the default locale (no leading slash if present)
104
- const result = `](${patternHead}${targetLocale}${pathContent ? '/' + pathContent : ''})`;
105
- return result;
170
+ return `${patternHead.replace(/\/$/, '')}/${targetLocale}`;
106
171
  }
107
- });
108
- return localizedFile;
172
+ }
173
+ return null; // URL doesn't match pattern
109
174
  }
110
- function localizeStaticHrefsForFile(file, defaultLocale, targetLocale, hideDefaultLocale, pattern = '/[locale]', // eg /docs/[locale] or /[locale]
111
- exclude = []) {
175
+ /**
176
+ * Main URL transformation function that delegates to specific scenarios
177
+ */
178
+ function transformUrlPath(originalUrl, patternHead, targetLocale, defaultLocale, hideDefaultLocale) {
179
+ if (targetLocale === defaultLocale) {
180
+ return transformDefaultLocaleUrl(originalUrl, patternHead, defaultLocale, hideDefaultLocale);
181
+ }
182
+ else if (hideDefaultLocale) {
183
+ return transformNonDefaultLocaleUrlWithHidden(originalUrl, patternHead, targetLocale, defaultLocale);
184
+ }
185
+ else {
186
+ return transformNonDefaultLocaleUrl(originalUrl, patternHead, targetLocale, defaultLocale);
187
+ }
188
+ }
189
+ /**
190
+ * AST-based transformation for MDX files using remark-mdx
191
+ */
192
+ function transformMdxUrls(mdxContent, defaultLocale, targetLocale, hideDefaultLocale, pattern = '/[locale]', exclude = []) {
193
+ const transformedUrls = [];
112
194
  if (!pattern.startsWith('/')) {
113
195
  pattern = '/' + pattern;
114
196
  }
115
- // 1. Search for all instances of:
116
197
  const patternHead = pattern.split('[locale]')[0];
117
- // Escape special regex characters and remove trailing slash if present
118
- const escapedPatternHead = patternHead
119
- .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
120
- .replace(/\/$/, '');
121
- let regex;
122
- if (hideDefaultLocale) {
123
- // Match complete href attributes: `href="/docs/..."` or `href="/docs"`
124
- regex = new RegExp(`href="${escapedPatternHead}(?:/([^"]*))?"`, 'g');
198
+ // Quick check: if the file doesn't contain the pattern, skip expensive AST parsing
199
+ // For default locale processing, we also need to check if content might need adjustment
200
+ if (targetLocale === defaultLocale) {
201
+ // For default locale files, we always need to check as we're looking for either:
202
+ // - paths without locale (when hideDefaultLocale=false)
203
+ // - paths with default locale (when hideDefaultLocale=true)
204
+ const patternWithoutSlash = patternHead.replace(/\/$/, '');
205
+ if (!mdxContent.includes(patternWithoutSlash)) {
206
+ return {
207
+ content: mdxContent,
208
+ hasChanges: false,
209
+ transformedUrls: [],
210
+ };
211
+ }
125
212
  }
126
213
  else {
127
- // Match complete href attributes with default locale: `href="/docs/${defaultLocale}/..."` or `href="/docs/${defaultLocale}"`
128
- regex = new RegExp(`href="${escapedPatternHead}/${defaultLocale}(?:/([^"]*))?"`, 'g');
129
- }
130
- const matches = file.match(regex);
131
- if (!matches) {
132
- return file;
133
- }
134
- exclude = exclude.map((pattern) => pattern.replace(/\[locale\]/g, defaultLocale));
135
- // 2. Replace the default locale with the target locale in all matched instances
136
- const localizedFile = file.replace(regex, (match, pathContent) => {
137
- // Check if this path should be excluded from localization
138
- if (exclude.length > 0) {
139
- let matchPath = patternHead;
140
- if (pathContent) {
141
- matchPath = hideDefaultLocale
142
- ? `${patternHead}${pathContent}`
143
- : `${patternHead}${defaultLocale}/${pathContent}`;
214
+ // For non-default locales, use the original logic
215
+ if (!mdxContent.includes(patternHead.replace(/\/$/, ''))) {
216
+ return {
217
+ content: mdxContent,
218
+ hasChanges: false,
219
+ transformedUrls: [],
220
+ };
221
+ }
222
+ }
223
+ // Parse the MDX content into an AST
224
+ let processedAst;
225
+ try {
226
+ const parseProcessor = unified()
227
+ .use(remarkParse)
228
+ .use(remarkFrontmatter, ['yaml', 'toml'])
229
+ .use(remarkMdx);
230
+ const ast = parseProcessor.parse(mdxContent);
231
+ processedAst = parseProcessor.runSync(ast);
232
+ }
233
+ catch (error) {
234
+ console.warn(`Failed to parse MDX content: ${error instanceof Error ? error.message : String(error)}`);
235
+ console.warn('Returning original content unchanged due to parsing error.');
236
+ return {
237
+ content: mdxContent,
238
+ hasChanges: false,
239
+ transformedUrls: [],
240
+ };
241
+ }
242
+ // Helper function to transform URL based on pattern
243
+ const transformUrl = (originalUrl, linkType) => {
244
+ // Check if URL should be processed
245
+ if (!shouldProcessUrl(originalUrl, patternHead, targetLocale, defaultLocale)) {
246
+ return null;
247
+ }
248
+ // Transform the URL based on locale and configuration
249
+ const newUrl = transformUrlPath(originalUrl, patternHead, targetLocale, defaultLocale, hideDefaultLocale);
250
+ if (!newUrl) {
251
+ return null;
252
+ }
253
+ // Check exclusions
254
+ if (isUrlExcluded(originalUrl, exclude, defaultLocale)) {
255
+ return null;
256
+ }
257
+ transformedUrls.push({
258
+ originalPath: originalUrl,
259
+ newPath: newUrl,
260
+ type: linkType,
261
+ });
262
+ return newUrl;
263
+ };
264
+ // Visit markdown link nodes: [text](url)
265
+ visit(processedAst, 'link', (node) => {
266
+ if (node.url) {
267
+ const newUrl = transformUrl(node.url, 'markdown');
268
+ if (newUrl) {
269
+ node.url = newUrl;
144
270
  }
145
- if (exclude.some((pattern) => isMatch(matchPath, pattern))) {
146
- return match; // Don't localize excluded paths
271
+ }
272
+ });
273
+ // Visit JSX/HTML elements for href attributes: <a href="url"> or <Card href="url">
274
+ visit(processedAst, ['mdxJsxFlowElement', 'mdxJsxTextElement'], (node) => {
275
+ const jsxNode = node;
276
+ if (jsxNode.attributes) {
277
+ for (const attr of jsxNode.attributes) {
278
+ if (attr.type === 'mdxJsxAttribute' &&
279
+ attr.name === 'href' &&
280
+ attr.value) {
281
+ // Handle MdxJsxAttribute with string or MdxJsxAttributeValueExpression
282
+ const hrefValue = typeof attr.value === 'string' ? attr.value : attr.value.value;
283
+ if (typeof hrefValue === 'string') {
284
+ const newUrl = transformUrl(hrefValue, 'href');
285
+ if (newUrl) {
286
+ if (typeof attr.value === 'string') {
287
+ attr.value = newUrl;
288
+ }
289
+ else {
290
+ attr.value.value = newUrl;
291
+ }
292
+ }
293
+ }
294
+ }
147
295
  }
148
296
  }
149
- if (hideDefaultLocale) {
150
- // For hideDefaultLocale, check if path already has target locale
151
- if (pathContent) {
152
- if (pathContent.startsWith(`${targetLocale}/`) ||
153
- pathContent === targetLocale) {
154
- return match; // Already localized
297
+ });
298
+ // Visit raw JSX nodes for href attributes in JSX strings
299
+ visit(processedAst, 'jsx', (node) => {
300
+ if (node.value && typeof node.value === 'string') {
301
+ const jsxContent = node.value;
302
+ // Use regex to find href attributes in the JSX string
303
+ const hrefRegex = /href\s*=\s*["']([^"']+)["']/g;
304
+ let match;
305
+ const replacements = [];
306
+ // Reset regex lastIndex to avoid issues with global flag
307
+ hrefRegex.lastIndex = 0;
308
+ while ((match = hrefRegex.exec(jsxContent)) !== null) {
309
+ const originalHref = match[1];
310
+ const newUrl = transformUrl(originalHref, 'href');
311
+ if (newUrl) {
312
+ // Store replacement info
313
+ const oldHrefAttr = match[0]; // The full match like 'href="/quickstart"'
314
+ const quote = oldHrefAttr.includes('"') ? '"' : "'";
315
+ const newHrefAttr = `href=${quote}${newUrl}${quote}`;
316
+ replacements.push({
317
+ start: match.index,
318
+ end: match.index + oldHrefAttr.length,
319
+ oldHrefAttr,
320
+ newHrefAttr,
321
+ });
155
322
  }
156
323
  }
157
- // Add target locale to the path
158
- if (!pathContent || pathContent === '') {
159
- return `href="${patternHead}${targetLocale}"`;
324
+ // Apply replacements in reverse order (from end to start) to avoid position shifts
325
+ if (replacements.length > 0) {
326
+ let newJsxContent = jsxContent;
327
+ replacements
328
+ .sort((a, b) => b.start - a.start)
329
+ .forEach(({ start, end, newHrefAttr }) => {
330
+ newJsxContent =
331
+ newJsxContent.slice(0, start) +
332
+ newHrefAttr +
333
+ newJsxContent.slice(end);
334
+ });
335
+ node.value = newJsxContent;
160
336
  }
161
- return `href="${patternHead}${targetLocale}/${pathContent}"`;
162
- }
163
- else {
164
- // For non-hideDefaultLocale, replace defaultLocale with targetLocale
165
- // pathContent contains everything after the default locale (no leading slash if present)
166
- return `href="${patternHead}${targetLocale}${pathContent ? '/' + pathContent : ''}"`;
167
337
  }
168
338
  });
169
- return localizedFile;
339
+ // Convert the modified AST back to MDX string
340
+ let content;
341
+ try {
342
+ const stringifyProcessor = unified()
343
+ .use(remarkStringify, {
344
+ bullet: '-',
345
+ emphasis: '_',
346
+ strong: '*',
347
+ rule: '-',
348
+ ruleRepetition: 3,
349
+ ruleSpaces: false,
350
+ })
351
+ .use(remarkFrontmatter, ['yaml', 'toml'])
352
+ .use(remarkMdx);
353
+ content = stringifyProcessor.stringify(processedAst);
354
+ }
355
+ catch (error) {
356
+ console.warn(`Failed to stringify MDX content: ${error instanceof Error ? error.message : String(error)}`);
357
+ console.warn('Returning original content unchanged due to stringify error.');
358
+ return {
359
+ content: mdxContent,
360
+ hasChanges: false,
361
+ transformedUrls: [],
362
+ };
363
+ }
364
+ // Handle newline formatting to match original input
365
+ if (content.endsWith('\n') && !mdxContent.endsWith('\n')) {
366
+ content = content.slice(0, -1);
367
+ }
368
+ // Preserve leading newlines from original content
369
+ if (mdxContent.startsWith('\n') && !content.startsWith('\n')) {
370
+ content = '\n' + content;
371
+ }
372
+ return {
373
+ content,
374
+ hasChanges: transformedUrls.length > 0,
375
+ transformedUrls,
376
+ };
377
+ }
378
+ // AST-based transformation for MDX files using remark
379
+ function localizeStaticUrlsForFile(file, defaultLocale, targetLocale, hideDefaultLocale, pattern = '/[locale]', // eg /docs/[locale] or /[locale]
380
+ exclude = []) {
381
+ // Use AST-based transformation for MDX files
382
+ const result = transformMdxUrls(file, defaultLocale, targetLocale, hideDefaultLocale, pattern, exclude);
383
+ return result.content;
170
384
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtx-cli",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "main": "dist/index.js",
5
5
  "bin": "dist/main.js",
6
6
  "files": [
@@ -94,8 +94,14 @@
94
94
  "micromatch": "^4.0.8",
95
95
  "open": "^10.1.1",
96
96
  "ora": "^8.2.0",
97
+ "remark-frontmatter": "^5.0.0",
98
+ "remark-mdx": "^3.1.0",
99
+ "remark-parse": "^11.0.0",
100
+ "remark-stringify": "^11.0.0",
97
101
  "resolve": "^1.22.10",
98
102
  "tsconfig-paths": "^4.2.0",
103
+ "unified": "^11.0.5",
104
+ "unist-util-visit": "^5.0.0",
99
105
  "yaml": "^2.8.0"
100
106
  },
101
107
  "devDependencies": {
@@ -109,6 +115,7 @@
109
115
  "@types/resolve": "^1.20.2",
110
116
  "eslint": "^9.20.0",
111
117
  "esm": "^3.2.25",
118
+ "mdast": "^3.0.0",
112
119
  "prettier": "^3.4.2",
113
120
  "ts-node": "^10.9.2",
114
121
  "typescript": "^5.5.4"