gtx-cli 2.5.15 → 2.5.17

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.5.17
4
+
5
+ ### Patch Changes
6
+
7
+ - [#848](https://github.com/generaltranslation/gt/pull/848) [`db4ab5c`](https://github.com/generaltranslation/gt/commit/db4ab5cad2726d78dc7c4e4dd7f3a83adaa1fcfb) Thanks [@fernando-aviles](https://github.com/fernando-aviles)! - Adding OpenAPI handling via `gt.config.json`
8
+
9
+ ## 2.5.16
10
+
11
+ ### Patch Changes
12
+
13
+ - [#843](https://github.com/generaltranslation/gt/pull/843) [`b135cbe`](https://github.com/generaltranslation/gt/commit/b135cbed44b259619697d9a429ba61c434bed7b5) Thanks [@fernando-aviles](https://github.com/fernando-aviles)! - Job polling correctly resolves locale aliases
14
+
3
15
  ## 2.5.15
4
16
 
5
17
  ### Patch Changes
@@ -5,6 +5,7 @@ import copyFile from '../../fs/copyFile.js';
5
5
  import flattenJsonFiles from '../../utils/flattenJsonFiles.js';
6
6
  import localizeStaticUrls from '../../utils/localizeStaticUrls.js';
7
7
  import processAnchorIds from '../../utils/processAnchorIds.js';
8
+ import processOpenApi from '../../utils/processOpenApi.js';
8
9
  import { noFilesError, noVersionIdError } from '../../console/index.js';
9
10
  import localizeStaticImports from '../../utils/localizeStaticImports.js';
10
11
  import { logErrorAndExit } from '../../console/logging.js';
@@ -35,6 +36,8 @@ export async function handleDownload(options, settings) {
35
36
  options.force || options.forceDownload);
36
37
  }
37
38
  export async function postProcessTranslations(settings, includeFiles) {
39
+ // Mintlify OpenAPI localization (spec routing + validation)
40
+ await processOpenApi(settings, includeFiles);
38
41
  // Localize static urls (/docs -> /[locale]/docs) and preserve anchor IDs for non-default locales
39
42
  // Default locale is processed earlier in the flow in base.ts
40
43
  if (settings.options?.experimentalLocalizeStaticUrls) {
@@ -132,6 +132,23 @@ export async function generateSettings(flags, cwd = process.cwd()) {
132
132
  };
133
133
  // Add additional options if provided
134
134
  if (mergedOptions.options) {
135
+ // Auto-add jsonSchema include paths for configured OpenAPI specs (Mintlify)
136
+ if (mergedOptions.openapi?.framework === 'mintlify' &&
137
+ Array.isArray(mergedOptions.openapi.files) &&
138
+ mergedOptions.openapi.files.length) {
139
+ const translateFields = mergedOptions.openapi.translateFields?.length &&
140
+ mergedOptions.openapi.translateFields.length > 0
141
+ ? mergedOptions.openapi.translateFields
142
+ : ['$..summary', '$..description'];
143
+ mergedOptions.options.jsonSchema = mergedOptions.options.jsonSchema || {};
144
+ for (const specFile of mergedOptions.openapi.files) {
145
+ if (!mergedOptions.options.jsonSchema[specFile]) {
146
+ mergedOptions.options.jsonSchema[specFile] = {
147
+ include: translateFields,
148
+ };
149
+ }
150
+ }
151
+ }
135
152
  if (mergedOptions.options.jsonSchema) {
136
153
  for (const fileGlob of Object.keys(mergedOptions.options.jsonSchema)) {
137
154
  const jsonSchema = mergedOptions.options.jsonSchema[fileGlob];
@@ -30,6 +30,11 @@ export type Options = {
30
30
  replace: string;
31
31
  }>;
32
32
  };
33
+ export type OpenApiConfig = {
34
+ framework: 'mintlify';
35
+ files: string[];
36
+ translateFields?: string[];
37
+ };
33
38
  export type TranslateFlags = {
34
39
  config?: string;
35
40
  apiKey?: string;
@@ -144,6 +149,7 @@ export type Settings = {
144
149
  parsingOptions: ParsingConfigOptions;
145
150
  branchOptions: BranchOptions;
146
151
  sharedStaticAssets?: SharedStaticAssetsConfig;
152
+ openapi?: OpenApiConfig;
147
153
  };
148
154
  export type BranchOptions = {
149
155
  currentBranch?: string;
@@ -0,0 +1,8 @@
1
+ import { Settings } from '../types/index.js';
2
+ /**
3
+ * Postprocess Mintlify OpenAPI references to point to locale-specific spec files.
4
+ * - Uses openapi.files (ordered) to resolve ambiguities (first match wins).
5
+ * - Relies on the user's json transform rules for locale paths.
6
+ * - Warns on missing/ambiguous references but keeps behavior deterministic.
7
+ */
8
+ export default function processOpenApi(settings: Settings, includeFiles?: Set<string>): Promise<void>;
@@ -0,0 +1,387 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { unified } from 'unified';
4
+ import remarkParse from 'remark-parse';
5
+ import remarkFrontmatter from 'remark-frontmatter';
6
+ import YAML from 'yaml';
7
+ import { logger } from '../console/logger.js';
8
+ import { createFileMapping } from '../formats/files/fileMapping.js';
9
+ const HTTP_METHODS = new Set([
10
+ 'GET',
11
+ 'POST',
12
+ 'PUT',
13
+ 'PATCH',
14
+ 'DELETE',
15
+ 'OPTIONS',
16
+ 'HEAD',
17
+ 'TRACE',
18
+ ]);
19
+ /**
20
+ * Postprocess Mintlify OpenAPI references to point to locale-specific spec files.
21
+ * - Uses openapi.files (ordered) to resolve ambiguities (first match wins).
22
+ * - Relies on the user's json transform rules for locale paths.
23
+ * - Warns on missing/ambiguous references but keeps behavior deterministic.
24
+ */
25
+ export default async function processOpenApi(settings, includeFiles) {
26
+ if (settings.openapi?.framework !== 'mintlify')
27
+ return;
28
+ if (!settings.openapi.files?.length)
29
+ return;
30
+ if (!settings.files)
31
+ return;
32
+ const configDir = path.dirname(settings.config);
33
+ const specAnalyses = buildSpecAnalyses(settings.openapi.files, configDir);
34
+ if (!specAnalyses.length)
35
+ return;
36
+ const warnings = new Set();
37
+ const { resolvedPaths, placeholderPaths, transformPaths } = settings.files;
38
+ const fileMapping = createFileMapping(resolvedPaths, placeholderPaths, transformPaths, settings.locales, settings.defaultLocale);
39
+ const fileMappingAbs = {};
40
+ for (const [locale, mapping] of Object.entries(fileMapping)) {
41
+ fileMappingAbs[locale] = {};
42
+ for (const [src, dest] of Object.entries(mapping)) {
43
+ const absSrc = path.resolve(configDir, src);
44
+ const absDest = path.resolve(configDir, dest);
45
+ fileMappingAbs[locale][absSrc] = absDest;
46
+ }
47
+ }
48
+ // Also rewrite default-locale source files so they use the deterministic spec selection
49
+ const defaultFiles = [
50
+ ...(resolvedPaths.mdx || []),
51
+ ...(resolvedPaths.md || []),
52
+ ];
53
+ for (const filePath of defaultFiles) {
54
+ if (!fs.existsSync(filePath))
55
+ continue;
56
+ const content = fs.readFileSync(filePath, 'utf8');
57
+ const updated = rewriteFrontmatter(content, filePath, settings.defaultLocale, specAnalyses, fileMappingAbs, warnings, configDir);
58
+ if (updated?.changed) {
59
+ await fs.promises.writeFile(filePath, updated.content, 'utf8');
60
+ }
61
+ }
62
+ for (const [locale, filesMap] of Object.entries(fileMapping)) {
63
+ const targetFiles = Object.values(filesMap).filter((p) => (p.endsWith('.md') || p.endsWith('.mdx')) &&
64
+ (!includeFiles || includeFiles.has(p)));
65
+ for (const filePath of targetFiles) {
66
+ if (!fs.existsSync(filePath))
67
+ continue;
68
+ const content = fs.readFileSync(filePath, 'utf8');
69
+ const updated = rewriteFrontmatter(content, filePath, locale, specAnalyses, fileMappingAbs, warnings, configDir);
70
+ if (updated?.changed) {
71
+ await fs.promises.writeFile(filePath, updated.content, 'utf8');
72
+ }
73
+ }
74
+ }
75
+ for (const message of warnings) {
76
+ logger.warn(message);
77
+ }
78
+ }
79
+ /**
80
+ * Resolve configured OpenAPI files to absolute paths and collect the operations,
81
+ * schemas, and webhooks they expose. Warns and skips when files are missing,
82
+ * unsupported (non-JSON), or fail to parse so later steps can continue gracefully.
83
+ */
84
+ function buildSpecAnalyses(openapiFiles, configDir) {
85
+ const analyses = [];
86
+ for (const configEntry of openapiFiles) {
87
+ const absPath = path.resolve(configDir, configEntry);
88
+ if (!fs.existsSync(absPath)) {
89
+ logger.warn(`OpenAPI file not found: ${configEntry}`);
90
+ continue;
91
+ }
92
+ if (path.extname(absPath).toLowerCase() !== '.json') {
93
+ logger.warn(`Skipping OpenAPI file (only .json supported): ${configEntry}`);
94
+ continue;
95
+ }
96
+ let spec;
97
+ try {
98
+ const raw = fs.readFileSync(absPath, 'utf8');
99
+ spec = JSON.parse(raw);
100
+ }
101
+ catch {
102
+ logger.warn(`Failed to parse OpenAPI JSON: ${configEntry}`);
103
+ continue;
104
+ }
105
+ analyses.push({
106
+ absPath,
107
+ configPath: configEntry,
108
+ operations: extractOperations(spec),
109
+ schemas: extractSchemas(spec),
110
+ webhooks: extractWebhooks(spec),
111
+ });
112
+ }
113
+ return analyses;
114
+ }
115
+ function isRecord(val) {
116
+ return typeof val === 'object' && val !== null;
117
+ }
118
+ /**
119
+ * Collect path+method identifiers (e.g., "POST /foo") from an OpenAPI spec.
120
+ * Safely no-ops when paths is missing or malformed.
121
+ */
122
+ function extractOperations(spec) {
123
+ const ops = new Set();
124
+ if (!isRecord(spec) || !isRecord(spec.paths))
125
+ return ops;
126
+ const paths = spec.paths;
127
+ for (const [route, methods] of Object.entries(paths)) {
128
+ if (!isRecord(methods))
129
+ continue;
130
+ for (const [method, operation] of Object.entries(methods)) {
131
+ if (!isRecord(operation))
132
+ continue;
133
+ const upper = method.toUpperCase();
134
+ if (!HTTP_METHODS.has(upper))
135
+ continue;
136
+ ops.add(`${upper} ${route}`);
137
+ }
138
+ }
139
+ return ops;
140
+ }
141
+ /**
142
+ * Collect schema names from components.schemas.
143
+ * Returns empty set if components/schemas are missing or malformed.
144
+ */
145
+ function extractSchemas(spec) {
146
+ if (!isRecord(spec) || !isRecord(spec.components))
147
+ return new Set();
148
+ const components = spec.components;
149
+ if (!isRecord(components.schemas))
150
+ return new Set();
151
+ return new Set(Object.keys(components.schemas));
152
+ }
153
+ /**
154
+ * Collect webhook names from webhooks (OpenAPI 3.1+).
155
+ * Returns empty set if webhooks is missing or malformed.
156
+ */
157
+ function extractWebhooks(spec) {
158
+ if (!isRecord(spec) || !isRecord(spec.webhooks))
159
+ return new Set();
160
+ return new Set(Object.keys(spec.webhooks));
161
+ }
162
+ /**
163
+ * Parse MDX/MD frontmatter, rewrite openapi/openapi-schema entries to the
164
+ * resolved (possibly localized) spec path, and return updated content.
165
+ * Uses remark to find the YAML node so the rest of the document remains
166
+ * untouched. When parsing fails or no relevant keys exist, it returns null.
167
+ */
168
+ function rewriteFrontmatter(content, filePath, locale, specs, fileMapping, warnings, configDir) {
169
+ let tree;
170
+ try {
171
+ tree = unified()
172
+ .use(remarkParse)
173
+ .use(remarkFrontmatter, ['yaml', 'toml'])
174
+ .parse(content);
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ const yamlNode = tree.children.find((node) => node.type === 'yaml');
180
+ if (!yamlNode ||
181
+ !yamlNode.position ||
182
+ yamlNode.position.start?.offset === undefined) {
183
+ return null;
184
+ }
185
+ const start = yamlNode.position.start.offset;
186
+ const end = yamlNode.position.end.offset;
187
+ const frontmatterRaw = yamlNode.value || '';
188
+ let parsed;
189
+ try {
190
+ parsed = YAML.parse(frontmatterRaw, { prettyErrors: false }) || {};
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ if (!parsed || typeof parsed !== 'object')
196
+ return null;
197
+ let changed = false;
198
+ if (typeof parsed.openapi === 'string') {
199
+ const parsedValue = parseOpenApiValue(parsed.openapi);
200
+ if (parsedValue) {
201
+ const matchKey = parsedValue.kind === 'operation'
202
+ ? {
203
+ type: 'operation',
204
+ key: `${parsedValue.method.toUpperCase()} ${parsedValue.operationPath}`,
205
+ }
206
+ : { type: 'webhook', key: parsedValue.name };
207
+ const spec = resolveSpec(parsedValue.specPath, specs, filePath, configDir, warnings, describeOpenApiRef(parsedValue), matchKey);
208
+ if (spec) {
209
+ const descriptor = formatOpenApiDescriptor(parsedValue);
210
+ const localizedSpecPath = resolveLocalizedSpecPath(spec, locale, fileMapping, configDir, parsedValue.specPath || spec.configPath);
211
+ const newValue = `${localizedSpecPath} ${descriptor}`.trim();
212
+ if (newValue !== parsed.openapi) {
213
+ parsed.openapi = newValue;
214
+ changed = true;
215
+ }
216
+ }
217
+ }
218
+ }
219
+ if (typeof parsed['openapi-schema'] === 'string') {
220
+ const parsedValue = parseSchemaValue(parsed['openapi-schema']);
221
+ if (parsedValue) {
222
+ const spec = resolveSpec(parsedValue.specPath, specs, filePath, configDir, warnings, `schema "${parsedValue.schemaName}"`, { type: 'schema', key: parsedValue.schemaName });
223
+ if (spec) {
224
+ const localizedSpecPath = resolveLocalizedSpecPath(spec, locale, fileMapping, configDir, parsedValue.specPath || spec.configPath);
225
+ const newValue = `${localizedSpecPath} ${parsedValue.schemaName}`.trim();
226
+ if (newValue !== parsed['openapi-schema']) {
227
+ parsed['openapi-schema'] = newValue;
228
+ changed = true;
229
+ }
230
+ }
231
+ }
232
+ }
233
+ if (!changed)
234
+ return null;
235
+ const fmString = YAML.stringify(parsed).trimEnd();
236
+ const rebuilt = `${content.slice(0, start)}---\n${fmString}\n---${content.slice(end)}`;
237
+ return { changed, content: rebuilt };
238
+ }
239
+ function stripWrappingQuotes(value) {
240
+ return value.trim().replace(/^['"]|['"]$/g, '');
241
+ }
242
+ /**
243
+ * Parse frontmatter openapi string into spec/method/path or webhook.
244
+ * Supports optional leading spec file, the webhook keyword, quoted values,
245
+ * and forgiving whitespace. Returns null when the structure is unrecognized.
246
+ */
247
+ function parseOpenApiValue(value) {
248
+ const stripped = stripWrappingQuotes(value);
249
+ const tokens = stripped.split(/\s+/).filter(Boolean);
250
+ if (!tokens.length)
251
+ return null;
252
+ let cursor = 0;
253
+ let specPath;
254
+ if (tokens[0].toLowerCase().endsWith('.json')) {
255
+ specPath = tokens[0];
256
+ cursor = 1;
257
+ }
258
+ if (cursor >= tokens.length)
259
+ return null;
260
+ const keyword = tokens[cursor];
261
+ if (keyword.toLowerCase() === 'webhook') {
262
+ const name = tokens.slice(cursor + 1).join(' ');
263
+ return { kind: 'webhook', specPath, name };
264
+ }
265
+ const method = keyword.toUpperCase();
266
+ const operationPath = tokens.slice(cursor + 1).join(' ');
267
+ if (!operationPath)
268
+ return null;
269
+ return { kind: 'operation', specPath, method, operationPath };
270
+ }
271
+ /**
272
+ * Parse frontmatter openapi-schema string into spec/schemaName.
273
+ * Accepts optional leading spec file and quoted values; returns null on invalid
274
+ * shapes so callers can skip rewrites gracefully.
275
+ */
276
+ function parseSchemaValue(value) {
277
+ const stripped = stripWrappingQuotes(value);
278
+ const tokens = stripped.split(/\s+/).filter(Boolean);
279
+ if (!tokens.length)
280
+ return null;
281
+ let cursor = 0;
282
+ let specPath;
283
+ if (tokens[0].toLowerCase().endsWith('.json')) {
284
+ specPath = tokens[0];
285
+ cursor = 1;
286
+ }
287
+ const schemaName = tokens.slice(cursor).join(' ');
288
+ if (!schemaName)
289
+ return null;
290
+ return { specPath, schemaName };
291
+ }
292
+ /**
293
+ * Choose which configured spec a reference should use.
294
+ * - If an explicit spec path is provided, resolve it relative to the config
295
+ * and the referencing file, warn when unknown, and bail.
296
+ * - Otherwise, try to match by operation/webhook/schema name; resolve
297
+ * ambiguity using config order and warn when ambiguous or missing.
298
+ */
299
+ function resolveSpec(explicitPath, specs, filePath, configDir, warnings, refDescription, match) {
300
+ if (!specs.length)
301
+ return null;
302
+ if (explicitPath) {
303
+ const normalizedExplicit = explicitPath.replace(/^\.?\/+/, '');
304
+ const candidates = [
305
+ path.resolve(configDir, normalizedExplicit),
306
+ path.resolve(path.dirname(filePath), normalizedExplicit),
307
+ ];
308
+ const foundSpec = specs.find((spec) => candidates.some((candidate) => samePath(candidate, spec.absPath)));
309
+ if (foundSpec)
310
+ return foundSpec;
311
+ warnings.add(`OpenAPI reference ${refDescription} in ${filePath} points to an unconfigured spec (${explicitPath}). Skipping localization for this reference.`);
312
+ return null;
313
+ }
314
+ // No explicit spec: try to find by contents
315
+ const matches = specs.filter((spec) => {
316
+ if (match.type === 'schema') {
317
+ return spec.schemas.has(match.key);
318
+ }
319
+ if (match.type === 'webhook') {
320
+ return spec.webhooks.has(match.key);
321
+ }
322
+ // operation
323
+ return spec.operations.has(match.key);
324
+ });
325
+ if (matches.length === 1)
326
+ return matches[0];
327
+ if (matches.length > 1) {
328
+ warnings.add(`OpenAPI reference ${refDescription} in ${filePath} is available in multiple specs. Using the first configured file (${matches[0].configPath}).`);
329
+ return matches[0];
330
+ }
331
+ // Not found anywhere, fall back to first configured spec
332
+ warnings.add(`OpenAPI reference ${refDescription} in ${filePath} was not found in any configured spec. Using ${specs[0].configPath}.`);
333
+ return specs[0];
334
+ }
335
+ /**
336
+ * Map a spec to the locale-specific file path when available and normalize it
337
+ * for frontmatter. Falls back to the source spec when the locale copy does
338
+ * not exist to preserve deterministic behavior.
339
+ */
340
+ function resolveLocalizedSpecPath(spec, locale, fileMapping, configDir, originalPathText) {
341
+ const mapping = fileMapping[locale]?.[spec.absPath];
342
+ const chosenAbs = mapping || spec.absPath;
343
+ const rel = normalizeSlashes(path.relative(configDir, chosenAbs));
344
+ const rooted = `/${rel.replace(/^\/+/, '')}`;
345
+ return formatSpecPathForFrontmatter(rooted, originalPathText || spec.configPath);
346
+ }
347
+ /**
348
+ * Format the path that will be written back to frontmatter:
349
+ * - Preserve the user's absolute style when they used a leading slash.
350
+ * - Preserve upward relative references (../) exactly.
351
+ * - Otherwise return a repo-root-relative path with a leading slash so Mintlify
352
+ * resolves consistently regardless of the MDX file location.
353
+ */
354
+ function formatSpecPathForFrontmatter(relativePath, originalPathText) {
355
+ const normalized = normalizeSlashes(relativePath);
356
+ const base = normalized.replace(/^\.\//, '').replace(/\/+/g, '/');
357
+ if (originalPathText.startsWith('/')) {
358
+ // Force repo-root absolute style
359
+ return `/${base.replace(/^\/+/, '')}`;
360
+ }
361
+ if (originalPathText.startsWith('../')) {
362
+ // Preserve explicit relative upward references
363
+ return normalized;
364
+ }
365
+ // Default to repo-root relative with leading slash to avoid resolving relative to the MDX directory
366
+ return `/${base.replace(/^\/+/, '')}`;
367
+ }
368
+ /** Normalize the descriptive portion after the spec path for frontmatter. */
369
+ function formatOpenApiDescriptor(value) {
370
+ if (value.kind === 'webhook')
371
+ return `webhook ${value.name}`;
372
+ return `${value.method.toUpperCase()} ${value.operationPath}`;
373
+ }
374
+ /** Human-readable description a specific OpenAPI reference. */
375
+ function describeOpenApiRef(value) {
376
+ if (value.kind === 'webhook')
377
+ return `webhook ${value.name}`;
378
+ return `${value.method.toUpperCase()} ${value.operationPath}`;
379
+ }
380
+ /** Normalize separators for stable comparisons and output. */
381
+ function normalizeSlashes(p) {
382
+ return p.replace(/\\/g, '/');
383
+ }
384
+ /** Compare paths after resolution to avoid casing/separator mismatches. */
385
+ function samePath(a, b) {
386
+ return path.resolve(a) === path.resolve(b);
387
+ }
@@ -47,18 +47,20 @@ export class PollTranslationJobsStep extends WorkflowStep {
47
47
  // branchId:fileId:versionId:locale -> job
48
48
  const jobMap = new Map();
49
49
  Object.entries(jobData.jobData).forEach(([jobId, job]) => {
50
- const key = `${job.branchId}:${job.fileId}:${job.versionId}:${job.targetLocale}`;
51
- jobMap.set(key, { ...job, jobId });
50
+ const jobLocale = this.gt.resolveAliasLocale(job.targetLocale);
51
+ const key = `${job.branchId}:${job.fileId}:${job.versionId}:${jobLocale}`;
52
+ jobMap.set(key, { ...job, jobId, targetLocale: jobLocale });
52
53
  });
53
54
  // Build a map of jobs for quick lookup:
54
55
  // jobId -> file data for the job
55
56
  const jobFileMap = new Map();
56
57
  Object.entries(jobData.jobData).forEach(([jobId, job]) => {
58
+ const jobLocale = this.gt.resolveAliasLocale(job.targetLocale);
57
59
  jobFileMap.set(jobId, {
58
60
  branchId: job.branchId,
59
61
  fileId: job.fileId,
60
62
  versionId: job.versionId,
61
- locale: job.targetLocale,
63
+ locale: jobLocale,
62
64
  });
63
65
  });
64
66
  // Categorize each file query item
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtx-cli",
3
- "version": "2.5.15",
3
+ "version": "2.5.17",
4
4
  "main": "dist/index.js",
5
5
  "bin": "dist/main.js",
6
6
  "files": [