gtx-cli 2.1.9 → 2.1.11

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.11
4
+
5
+ ### Patch Changes
6
+
7
+ - [#610](https://github.com/generaltranslation/gt/pull/610) [`bfb4f53`](https://github.com/generaltranslation/gt/commit/bfb4f53658c785520373af53a1e9fadb6eca2d0b) Thanks [@SamEggert](https://github.com/SamEggert)! - create loadTranslations.js when user specifies local translations in gtx-cli init
8
+
9
+ ## 2.1.10
10
+
11
+ ### Patch Changes
12
+
13
+ - [#600](https://github.com/generaltranslation/gt/pull/600) [`e94aac2`](https://github.com/generaltranslation/gt/commit/e94aac2b2554a279245d090b0872f6f64eb71c62) Thanks [@fernando-aviles](https://github.com/fernando-aviles)! - Added handling of fragment URLs (i.e. href="#my-mdx-id") for correct routing across locales.
14
+
3
15
  ## 2.1.9
4
16
 
5
17
  ### Patch Changes
package/dist/cli/base.js CHANGED
@@ -18,6 +18,7 @@ import { attachTranslateFlags } from './flags.js';
18
18
  import { handleStage } from './commands/stage.js';
19
19
  import { handleDownload, handleTranslate, postProcessTranslations, } from './commands/translate.js';
20
20
  import updateConfig from '../fs/config/updateConfig.js';
21
+ import { createLoadTranslationsFile } from '../fs/createLoadTranslationsFile.js';
21
22
  export class BaseCLI {
22
23
  library;
23
24
  additionalModules;
@@ -241,17 +242,22 @@ See the docs for more information: https://generaltranslation.com/docs/react/tut
241
242
  defaultValue: true,
242
243
  })
243
244
  : false;
244
- if (isUsingGT && !usingCDN) {
245
- logMessage(`Make sure to add a loadTranslations function to your app configuration to correctly use local translations.
246
- See https://generaltranslation.com/en/docs/next/guides/local-tx`);
247
- }
248
245
  // Ask where the translations are stored
249
246
  const translationsDir = isUsingGT && !usingCDN
250
247
  ? await promptText({
251
248
  message: 'What is the path to the directory where you would like to locally store your translations?',
252
- defaultValue: './public/locales',
249
+ defaultValue: './public/_gt',
253
250
  })
254
251
  : null;
252
+ // Determine final translations directory with fallback
253
+ const finalTranslationsDir = translationsDir?.trim() || './public/_gt';
254
+ if (isUsingGT && !usingCDN) {
255
+ // Create loadTranslations.js file for local translations
256
+ await createLoadTranslationsFile(process.cwd(), finalTranslationsDir);
257
+ logMessage(`Created ${chalk.cyan('loadTranslations.js')} file for local translations.
258
+ Make sure to add this function to your app configuration.
259
+ See https://generaltranslation.com/en/docs/next/guides/local-tx`);
260
+ }
255
261
  const message = !isUsingGT
256
262
  ? 'What is the format of your language resource files? Select as many as applicable.\nAdditionally, you can translate any other files you have in your project.'
257
263
  : `${chalk.dim('(Optional)')} Do you have any separate files you would like to translate? For example, extra Markdown files for docs.`;
@@ -278,9 +284,9 @@ See https://generaltranslation.com/en/docs/next/guides/local-tx`);
278
284
  };
279
285
  }
280
286
  // Add GT translations if using GT and storing locally
281
- if (isUsingGT && !usingCDN && translationsDir) {
287
+ if (isUsingGT && !usingCDN) {
282
288
  files.gt = {
283
- output: path.join(translationsDir, `[locale].json`),
289
+ output: path.join(finalTranslationsDir, `[locale].json`),
284
290
  };
285
291
  }
286
292
  let configFilepath = 'gt.config.json';
@@ -5,6 +5,7 @@ import { getStagedVersions } from '../../fs/config/updateVersions.js';
5
5
  import copyFile from '../../fs/copyFile.js';
6
6
  import flattenJsonFiles from '../../utils/flattenJsonFiles.js';
7
7
  import localizeStaticUrls from '../../utils/localizeStaticUrls.js';
8
+ import processAnchorIds from '../../utils/processAnchorIds.js';
8
9
  import { noFilesError, noVersionIdError } from '../../console/index.js';
9
10
  // Downloads translations that were completed
10
11
  export async function handleTranslate(options, settings, filesTranslationResponse) {
@@ -34,13 +35,16 @@ export async function handleDownload(options, settings) {
34
35
  await checkFileTranslations(stagedVersionData, settings.locales, options.timeout, (sourcePath, locale) => fileMapping[locale][sourcePath] ?? null, settings);
35
36
  }
36
37
  export async function postProcessTranslations(settings) {
37
- // Localize static urls (/docs -> /[locale]/docs) for non-default locales only
38
+ // Localize static urls (/docs -> /[locale]/docs) and preserve anchor IDs for non-default locales
38
39
  // Default locale is processed earlier in the flow in base.ts
39
40
  if (settings.options?.experimentalLocalizeStaticUrls) {
40
41
  const nonDefaultLocales = settings.locales.filter((locale) => locale !== settings.defaultLocale);
41
42
  if (nonDefaultLocales.length > 0) {
42
43
  await localizeStaticUrls(settings, nonDefaultLocales);
43
44
  }
45
+ // Add explicit anchor IDs to translated MDX/MD files to preserve navigation
46
+ // Uses inline {#id} format by default, or div wrapping if experimentalAddHeaderAnchorIds is 'mintlify'
47
+ await processAnchorIds(settings);
44
48
  }
45
49
  // Flatten json files into a single file
46
50
  if (settings.options?.experimentalFlattenJsonFiles) {
@@ -0,0 +1 @@
1
+ export declare function createLoadTranslationsFile(appDirectory: string, translationsDir?: string): Promise<void>;
@@ -0,0 +1,36 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { logInfo } from '../console/logging.js';
4
+ import chalk from 'chalk';
5
+ export async function createLoadTranslationsFile(appDirectory, translationsDir = './public/_gt') {
6
+ const usingSrcDirectory = fs.existsSync(path.join(appDirectory, 'src'));
7
+ // Calculate the relative path from the loadTranslations.js location to the translations directory
8
+ const loadTranslationsDir = usingSrcDirectory
9
+ ? path.join(appDirectory, 'src')
10
+ : appDirectory;
11
+ const relativePath = path.relative(loadTranslationsDir, path.resolve(appDirectory, translationsDir));
12
+ const publicPath = relativePath ? `${relativePath}/` : './';
13
+ const filePath = usingSrcDirectory
14
+ ? path.join(appDirectory, 'src', 'loadTranslations.js')
15
+ : path.join(appDirectory, 'loadTranslations.js');
16
+ if (!fs.existsSync(filePath)) {
17
+ const loadTranslationsContent = `
18
+ export default async function loadTranslations(locale) {
19
+ try {
20
+ // Load translations from ${translationsDir} directory
21
+ // This matches the GT config files.gt.output path
22
+ const t = await import(\`${publicPath}\${locale}.json\`);
23
+ return t.default;
24
+ } catch (error) {
25
+ console.warn(\`Failed to load translations for locale \${locale}:\`, error);
26
+ return {};
27
+ }
28
+ }
29
+ `;
30
+ await fs.promises.writeFile(filePath, loadTranslationsContent);
31
+ logInfo(`Created ${chalk.cyan('loadTranslations.js')} file at ${chalk.cyan(filePath)}.`);
32
+ }
33
+ else {
34
+ logInfo(`Found ${chalk.cyan('loadTranslations.js')} file at ${chalk.cyan(filePath)}. Skipping creation...`);
35
+ }
36
+ }
@@ -21,6 +21,7 @@ export type Options = {
21
21
  experimentalHideDefaultLocale?: boolean;
22
22
  experimentalFlattenJsonFiles?: boolean;
23
23
  experimentalLocalizeStaticImports?: boolean;
24
+ experimentalAddHeaderAnchorIds?: 'mintlify';
24
25
  };
25
26
  export type TranslateFlags = {
26
27
  config?: string;
@@ -41,6 +42,7 @@ export type TranslateFlags = {
41
42
  experimentalHideDefaultLocale?: boolean;
42
43
  experimentalFlattenJsonFiles?: boolean;
43
44
  experimentalLocalizeStaticImports?: boolean;
45
+ experimentalAddHeaderAnchorIds?: 'mintlify';
44
46
  excludeStaticUrls?: string[];
45
47
  excludeStaticImports?: string[];
46
48
  };
@@ -139,6 +141,7 @@ export type AdditionalOptions = {
139
141
  copyFiles?: string[];
140
142
  experimentalLocalizeStaticImports?: boolean;
141
143
  experimentalLocalizeStaticUrls?: boolean;
144
+ experimentalAddHeaderAnchorIds?: 'mintlify';
142
145
  experimentalHideDefaultLocale?: boolean;
143
146
  experimentalFlattenJsonFiles?: boolean;
144
147
  baseDomain?: string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Represents a heading with its position and metadata
3
+ */
4
+ export interface HeadingInfo {
5
+ text: string;
6
+ level: number;
7
+ slug: string;
8
+ position: number;
9
+ }
10
+ /**
11
+ * Extracts heading information from content (read-only, no modifications)
12
+ */
13
+ export declare function extractHeadingInfo(mdxContent: string): HeadingInfo[];
14
+ /**
15
+ * Applies anchor IDs to translated content based on source heading mapping
16
+ */
17
+ export declare function addExplicitAnchorIds(translatedContent: string, sourceHeadingMap: HeadingInfo[], settings?: any, sourcePath?: string, translatedPath?: string): {
18
+ content: string;
19
+ hasChanges: boolean;
20
+ addedIds: Array<{
21
+ heading: string;
22
+ id: string;
23
+ }>;
24
+ };
@@ -0,0 +1,263 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkMdx from 'remark-mdx';
4
+ import remarkFrontmatter from 'remark-frontmatter';
5
+ import remarkStringify from 'remark-stringify';
6
+ import { visit } from 'unist-util-visit';
7
+ import { logErrorAndExit } from '../console/logging.js';
8
+ /**
9
+ * Generates a slug from heading text
10
+ */
11
+ function generateSlug(text) {
12
+ return text
13
+ .toLowerCase()
14
+ .replace(/[^\w\s-]/g, '') // Remove special chars except spaces and hyphens
15
+ .trim()
16
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
17
+ .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
18
+ .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
19
+ }
20
+ /**
21
+ * Extracts text content from heading nodes
22
+ */
23
+ function extractHeadingText(heading) {
24
+ let text = '';
25
+ visit(heading, ['text', 'inlineCode'], (node) => {
26
+ if ('value' in node && typeof node.value === 'string') {
27
+ text += node.value;
28
+ }
29
+ });
30
+ return text;
31
+ }
32
+ /**
33
+ * Checks if a heading is already wrapped in a div with id
34
+ */
35
+ function hasExplicitId(heading, ast) {
36
+ const lastChild = heading.children[heading.children.length - 1];
37
+ if (lastChild?.type === 'text') {
38
+ return /(\{#[^}]+\}|\[[^\]]+\])$/.test(lastChild.value);
39
+ }
40
+ return false;
41
+ }
42
+ /**
43
+ * Extracts heading information from content (read-only, no modifications)
44
+ */
45
+ export function extractHeadingInfo(mdxContent) {
46
+ const headings = [];
47
+ // Parse the MDX content into an AST
48
+ let processedAst;
49
+ try {
50
+ const parseProcessor = unified()
51
+ .use(remarkParse)
52
+ .use(remarkFrontmatter, ['yaml', 'toml'])
53
+ .use(remarkMdx);
54
+ const ast = parseProcessor.parse(mdxContent);
55
+ processedAst = parseProcessor.runSync(ast);
56
+ }
57
+ catch (error) {
58
+ console.warn(`Failed to parse MDX content: ${error instanceof Error ? error.message : String(error)}`);
59
+ return [];
60
+ }
61
+ let position = 0;
62
+ visit(processedAst, 'heading', (heading) => {
63
+ const headingText = extractHeadingText(heading);
64
+ if (headingText) {
65
+ const slug = generateSlug(headingText);
66
+ headings.push({
67
+ text: headingText,
68
+ level: heading.depth,
69
+ slug,
70
+ position: position++,
71
+ });
72
+ }
73
+ });
74
+ return headings;
75
+ }
76
+ /**
77
+ * Applies anchor IDs to translated content based on source heading mapping
78
+ */
79
+ export function addExplicitAnchorIds(translatedContent, sourceHeadingMap, settings, sourcePath, translatedPath) {
80
+ const addedIds = [];
81
+ const useDivWrapping = settings?.options?.experimentalAddHeaderAnchorIds === 'mintlify';
82
+ // Extract headings from translated content
83
+ const translatedHeadings = extractHeadingInfo(translatedContent);
84
+ // Pre-processing validation: check if header counts match
85
+ if (sourceHeadingMap.length !== translatedHeadings.length) {
86
+ const sourceFile = sourcePath
87
+ ? `Source file: ${sourcePath}`
88
+ : 'Source file';
89
+ const translatedFile = translatedPath
90
+ ? `translated file: ${translatedPath}`
91
+ : 'translated file';
92
+ logErrorAndExit(`Header count mismatch detected! ${sourceFile} has ${sourceHeadingMap.length} headers but ${translatedFile} has ${translatedHeadings.length} headers. ` +
93
+ `This likely means your source file was edited after translation was requested, causing a mismatch between ` +
94
+ `the number of headers in your source file vs the translated file. Please re-translate this file to resolve the issue.`);
95
+ }
96
+ // Create ID mapping based on positional matching
97
+ const idMappings = new Map();
98
+ sourceHeadingMap.forEach((sourceHeading, index) => {
99
+ const translatedHeading = translatedHeadings[index];
100
+ // Match by position and level for safety
101
+ if (translatedHeading && translatedHeading.level === sourceHeading.level) {
102
+ idMappings.set(index, sourceHeading.slug);
103
+ addedIds.push({
104
+ heading: translatedHeading.text,
105
+ id: sourceHeading.slug,
106
+ });
107
+ }
108
+ });
109
+ if (idMappings.size === 0) {
110
+ return {
111
+ content: translatedContent,
112
+ hasChanges: false,
113
+ addedIds: [],
114
+ };
115
+ }
116
+ // Apply IDs to translated content
117
+ let content;
118
+ if (useDivWrapping) {
119
+ content = applyDivWrappedIds(translatedContent, translatedHeadings, idMappings);
120
+ }
121
+ else {
122
+ content = applyInlineIds(translatedContent, idMappings);
123
+ }
124
+ return {
125
+ content,
126
+ hasChanges: addedIds.length > 0,
127
+ addedIds,
128
+ };
129
+ }
130
+ /**
131
+ * Adds inline {#id} syntax to headings (standard markdown approach)
132
+ */
133
+ function applyInlineIds(translatedContent, idMappings) {
134
+ // Parse the translated content
135
+ let processedAst;
136
+ try {
137
+ const parseProcessor = unified()
138
+ .use(remarkParse)
139
+ .use(remarkFrontmatter, ['yaml', 'toml'])
140
+ .use(remarkMdx);
141
+ const ast = parseProcessor.parse(translatedContent);
142
+ processedAst = parseProcessor.runSync(ast);
143
+ }
144
+ catch (error) {
145
+ console.warn(`Failed to parse translated MDX content: ${error instanceof Error ? error.message : String(error)}`);
146
+ return translatedContent;
147
+ }
148
+ // Apply IDs to headings based on position
149
+ let headingIndex = 0;
150
+ visit(processedAst, 'heading', (heading) => {
151
+ const id = idMappings.get(headingIndex);
152
+ if (id) {
153
+ // Skip if heading already has explicit ID
154
+ if (hasExplicitId(heading, processedAst)) {
155
+ headingIndex++;
156
+ return;
157
+ }
158
+ // Add the ID to the heading
159
+ const lastChild = heading.children[heading.children.length - 1];
160
+ if (lastChild?.type === 'text') {
161
+ lastChild.value += ` {#${id}}`;
162
+ }
163
+ else {
164
+ // If last child is not text, add a new text node
165
+ heading.children.push({
166
+ type: 'text',
167
+ value: ` {#${id}}`,
168
+ });
169
+ }
170
+ }
171
+ headingIndex++;
172
+ });
173
+ // Convert the modified AST back to MDX string
174
+ try {
175
+ const stringifyProcessor = unified()
176
+ .use(remarkStringify, {
177
+ bullet: '-',
178
+ emphasis: '_',
179
+ strong: '*',
180
+ rule: '-',
181
+ ruleRepetition: 3,
182
+ ruleSpaces: false,
183
+ handlers: {
184
+ // Custom handler to prevent escaping of {#id} syntax
185
+ text(node) {
186
+ return node.value;
187
+ },
188
+ },
189
+ })
190
+ .use(remarkFrontmatter, ['yaml', 'toml'])
191
+ .use(remarkMdx);
192
+ let content = stringifyProcessor.stringify(processedAst);
193
+ // Handle newline formatting to match original input
194
+ if (content.endsWith('\n') && !translatedContent.endsWith('\n')) {
195
+ content = content.slice(0, -1);
196
+ }
197
+ // Preserve leading newlines from original content
198
+ if (translatedContent.startsWith('\n') && !content.startsWith('\n')) {
199
+ content = '\n' + content;
200
+ }
201
+ return content;
202
+ }
203
+ catch (error) {
204
+ console.warn(`Failed to stringify translated MDX content: ${error instanceof Error ? error.message : String(error)}`);
205
+ return translatedContent;
206
+ }
207
+ }
208
+ /**
209
+ * Wraps headings in divs with IDs (Mintlify approach)
210
+ */
211
+ function applyDivWrappedIds(translatedContent, translatedHeadings, idMappings) {
212
+ // Extract all heading lines from the translated markdown
213
+ const lines = translatedContent.split('\n');
214
+ const headingLines = [];
215
+ lines.forEach((line, index) => {
216
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
217
+ if (headingMatch) {
218
+ const level = headingMatch[1].length;
219
+ headingLines.push({ line, level, index });
220
+ }
221
+ });
222
+ // Use string-based approach to wrap headings in divs
223
+ let content = translatedContent;
224
+ const headingsToWrap = [];
225
+ // Match translated headings with their corresponding lines by position and level
226
+ translatedHeadings.forEach((heading, position) => {
227
+ const id = idMappings.get(position);
228
+ if (id) {
229
+ // Find the corresponding original line for this heading
230
+ const matchingLine = headingLines.find((hl) => {
231
+ // Extract clean text from the original line for comparison
232
+ const lineCleanText = hl.line.replace(/^#{1,6}\s+/, '').trim();
233
+ // Create a version without markdown formatting for comparison
234
+ const cleanLineText = lineCleanText
235
+ .replace(/\*\*(.*?)\*\*/g, '$1') // Remove bold
236
+ .replace(/\*(.*?)\*/g, '$1') // Remove italic
237
+ .replace(/`(.*?)`/g, '$1') // Remove inline code
238
+ .replace(/\[(.*?)\]\(.*?\)/g, '$1') // Remove links, keep text
239
+ .trim();
240
+ return cleanLineText === heading.text && hl.level === heading.level;
241
+ });
242
+ if (matchingLine) {
243
+ headingsToWrap.push({
244
+ originalLine: matchingLine.line,
245
+ id,
246
+ });
247
+ }
248
+ }
249
+ });
250
+ if (headingsToWrap.length > 0) {
251
+ // Process headings from longest to shortest original line to avoid partial matches
252
+ const sortedHeadings = headingsToWrap.sort((a, b) => b.originalLine.length - a.originalLine.length);
253
+ for (const heading of sortedHeadings) {
254
+ // Escape the original line for use in regex
255
+ const escapedLine = heading.originalLine.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
256
+ const headingPattern = new RegExp(`^${escapedLine}\\s*$`, 'gm');
257
+ content = content.replace(headingPattern, (match) => {
258
+ return `<div id="${heading.id}">\n ${match.trim()}\n</div>\n`;
259
+ });
260
+ }
261
+ }
262
+ return content;
263
+ }
@@ -87,6 +87,10 @@ export default async function localizeStaticUrls(settings, targetLocales) {
87
87
  * Determines if a URL should be processed based on pattern matching
88
88
  */
89
89
  function shouldProcessUrl(originalUrl, patternHead, targetLocale, defaultLocale, baseDomain) {
90
+ // Check fragment-only URLs like "#id-name"
91
+ if (/^\s*#/.test(originalUrl)) {
92
+ return false;
93
+ }
90
94
  const patternWithoutSlash = patternHead.replace(/\/$/, '');
91
95
  // Handle absolute URLs with baseDomain
92
96
  let urlToCheck = originalUrl;
@@ -0,0 +1,6 @@
1
+ import { Settings } from '../types/index.js';
2
+ /**
3
+ * Processes all translated MD/MDX files to add explicit anchor IDs
4
+ * This preserves navigation links when headings are translated
5
+ */
6
+ export default function processAnchorIds(settings: Settings): Promise<void>;
@@ -0,0 +1,43 @@
1
+ import { addExplicitAnchorIds, extractHeadingInfo, } from './addExplicitAnchorIds.js';
2
+ import { readFile } from '../fs/findFilepath.js';
3
+ import { createFileMapping } from '../formats/files/fileMapping.js';
4
+ import * as fs from 'fs';
5
+ /**
6
+ * Processes all translated MD/MDX files to add explicit anchor IDs
7
+ * This preserves navigation links when headings are translated
8
+ */
9
+ export default async function processAnchorIds(settings) {
10
+ if (!settings.files)
11
+ return;
12
+ const { resolvedPaths, placeholderPaths, transformPaths } = settings.files;
13
+ const fileMapping = createFileMapping(resolvedPaths, placeholderPaths, transformPaths, settings.locales, settings.defaultLocale);
14
+ // Process each locale's translated files
15
+ const processPromises = Object.entries(fileMapping)
16
+ .filter(([locale, filesMap]) => locale !== settings.defaultLocale) // Skip default locale
17
+ .map(async ([locale, filesMap]) => {
18
+ // Get all translated files that are md or mdx
19
+ const translatedFiles = Object.values(filesMap).filter((path) => path && (path.endsWith('.md') || path.endsWith('.mdx')));
20
+ for (const translatedPath of translatedFiles) {
21
+ try {
22
+ // Find the corresponding source file
23
+ const sourcePath = Object.keys(filesMap).find((key) => filesMap[key] === translatedPath);
24
+ if (!sourcePath) {
25
+ continue;
26
+ }
27
+ // Extract heading info from source file
28
+ const sourceContent = readFile(sourcePath);
29
+ const sourceHeadingMap = extractHeadingInfo(sourceContent);
30
+ // Read translated file and apply anchor IDs
31
+ const translatedContent = readFile(translatedPath);
32
+ const result = addExplicitAnchorIds(translatedContent, sourceHeadingMap, settings, sourcePath, translatedPath);
33
+ if (result.hasChanges) {
34
+ fs.writeFileSync(translatedPath, result.content, 'utf8');
35
+ }
36
+ }
37
+ catch (error) {
38
+ console.warn(`Failed to process IDs for ${translatedPath}: ${error instanceof Error ? error.message : String(error)}`);
39
+ }
40
+ }
41
+ });
42
+ await Promise.all(processPromises);
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtx-cli",
3
- "version": "2.1.9",
3
+ "version": "2.1.11",
4
4
  "main": "dist/index.js",
5
5
  "bin": "dist/main.js",
6
6
  "files": [