create-thally-docs 0.7.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/dist/index.js ADDED
@@ -0,0 +1,804 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ migrateDocs,
4
+ parseGitHubUrl
5
+ } from "./chunk-BVI7FKFR.js";
6
+ import {
7
+ logo,
8
+ readDocsJson,
9
+ scaffold,
10
+ slugify,
11
+ success,
12
+ writeDocsJson
13
+ } from "./chunk-EPL2DCB2.js";
14
+
15
+ // src/index.ts
16
+ import { existsSync as existsSync3, readdirSync as readdirSync2 } from "fs";
17
+ import { resolve as resolve2 } from "path";
18
+
19
+ // src/prompts.ts
20
+ import { input, select } from "@inquirer/prompts";
21
+ import { basename } from "path";
22
+ import { resolve } from "path";
23
+ async function gatherAnswers(dirArg, useDefaults) {
24
+ let projectDir;
25
+ if (dirArg) {
26
+ projectDir = resolve(dirArg);
27
+ } else if (useDefaults) {
28
+ projectDir = resolve("my-docs");
29
+ } else {
30
+ const dirName = await input({
31
+ message: " Project directory:",
32
+ default: "my-docs"
33
+ });
34
+ projectDir = resolve(dirName);
35
+ }
36
+ const defaultName = basename(projectDir).replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
37
+ const projectName = useDefaults ? defaultName : await input({
38
+ message: " Project name:",
39
+ default: defaultName
40
+ });
41
+ const defaultDesc = `Documentation for ${projectName}.`;
42
+ const description = useDefaults ? defaultDesc : await input({
43
+ message: " Description:",
44
+ default: defaultDesc
45
+ });
46
+ const brandPreset = useDefaults ? "primary" : await select({
47
+ message: " Brand preset:",
48
+ choices: [
49
+ { name: "primary", value: "primary" },
50
+ { name: "secondary", value: "secondary" }
51
+ ],
52
+ default: "primary"
53
+ });
54
+ const repoUrl = useDefaults ? "" : await input({
55
+ message: " GitHub repo URL (optional):",
56
+ default: ""
57
+ });
58
+ let trackRepos;
59
+ if (!useDefaults) {
60
+ const enableTrack = await input({
61
+ message: " Keep your docs in sync automatically with Thally Track? When a PR merges in a repo you list,\n the docs agent drafts the doc updates as a PR for you to review. (y/N):",
62
+ default: "N"
63
+ });
64
+ if (enableTrack.trim().toLowerCase() === "y") {
65
+ const reposInput = await input({
66
+ message: " Which repo(s) should Thally watch? (comma-separated owner/repo, e.g. acme/api,acme/web):",
67
+ default: ""
68
+ });
69
+ const parsed = reposInput.split(",").map((spec) => spec.trim().match(/^([A-Za-z0-9-_.]+)\/([A-Za-z0-9-_.]+)$/)).filter((m) => m !== null).map((m) => ({ owner: m[1], repo: m[2] }));
70
+ if (parsed.length > 0) trackRepos = parsed;
71
+ else console.log(" \u26A0 No valid owner/repo entries \u2014 skipping Track (add it later with `thally track add`).");
72
+ }
73
+ }
74
+ let doInstall = true;
75
+ if (!useDefaults) {
76
+ const shouldInstall = await input({
77
+ message: " Install dependencies? (Y/n):",
78
+ default: "Y"
79
+ });
80
+ doInstall = shouldInstall.toLowerCase() !== "n";
81
+ }
82
+ let i18nLocales;
83
+ if (!useDefaults) {
84
+ const enableI18n = await input({
85
+ message: " Enable multi-language support? (y/N):",
86
+ default: "N"
87
+ });
88
+ if (enableI18n.toLowerCase() === "y") {
89
+ const localesInput = await input({
90
+ message: " Which locales? (comma-separated codes, e.g. es,fr,de):",
91
+ default: "es"
92
+ });
93
+ const LOCALE_LABELS = {
94
+ en: "English",
95
+ es: "Espa\xF1ol",
96
+ fr: "Fran\xE7ais",
97
+ de: "Deutsch",
98
+ it: "Italiano",
99
+ pt: "Portugu\xEAs",
100
+ ja: "\u65E5\u672C\u8A9E",
101
+ ko: "\uD55C\uAD6D\uC5B4",
102
+ zh: "\u4E2D\u6587",
103
+ ru: "\u0420\u0443\u0441\u0441\u043A\u0438\u0439",
104
+ ar: "\u0627\u0644\u0639\u0631\u0628\u064A\u0629",
105
+ nl: "Nederlands"
106
+ };
107
+ const codes = localesInput.split(",").map((c) => c.trim()).filter(Boolean);
108
+ i18nLocales = codes.map((code) => ({
109
+ code,
110
+ label: LOCALE_LABELS[code] ?? code.toUpperCase()
111
+ }));
112
+ }
113
+ }
114
+ return { projectDir, projectName, description, brandPreset, repoUrl, doInstall, i18nLocales, trackRepos };
115
+ }
116
+
117
+ // src/check.ts
118
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
119
+ import { join, extname, relative } from "path";
120
+ import { execFileSync } from "child_process";
121
+ import matter from "gray-matter";
122
+ import { parse as parseYaml } from "yaml";
123
+ function gitLocal(projectDir, args2) {
124
+ try {
125
+ const out = execFileSync("git", args2, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
126
+ return { ok: true, out: out.trim() };
127
+ } catch {
128
+ return { ok: false, out: "" };
129
+ }
130
+ }
131
+ function checkDrift(projectDir, file, data, issues) {
132
+ const sources = data.sources;
133
+ const verifiedCommit = data.verifiedCommit;
134
+ if (!Array.isArray(sources) || sources.length === 0 || typeof verifiedCommit !== "string" || !verifiedCommit.trim()) {
135
+ return;
136
+ }
137
+ const commit = verifiedCommit.trim();
138
+ if (!gitLocal(projectDir, ["cat-file", "-e", `${commit}^{commit}`]).ok) {
139
+ issues.push({
140
+ severity: "warning",
141
+ message: `Cannot verify freshness: verifiedCommit "${commit.slice(0, 8)}" is not in git history \u2014 run with a full clone (fetch-depth: 0).`,
142
+ file
143
+ });
144
+ return;
145
+ }
146
+ for (const src of sources) {
147
+ if (typeof src !== "string" || !src.trim()) continue;
148
+ const colon = src.indexOf(":");
149
+ let filePath = src;
150
+ if (colon > 0) {
151
+ const alias = src.slice(0, colon);
152
+ if (alias !== "." && alias !== "self") {
153
+ issues.push({
154
+ severity: "warning",
155
+ message: `Cross-repo source "${src}" \u2014 drift check skipped (needs the referenced repo; see multi-repo setup).`,
156
+ file
157
+ });
158
+ continue;
159
+ }
160
+ filePath = src.slice(colon + 1);
161
+ }
162
+ filePath = filePath.replace(/^\.\//, "").replace(/#.*$/, "");
163
+ const changed = gitLocal(projectDir, ["log", "--format=%H", `${commit}..HEAD`, "--", filePath]).out;
164
+ if (changed) {
165
+ const n = changed.split("\n").filter(Boolean).length;
166
+ issues.push({
167
+ severity: "warning",
168
+ message: `Drift: source "${src}" changed in ${n} commit(s) since it was verified \u2014 this page may be stale.`,
169
+ file
170
+ });
171
+ }
172
+ }
173
+ }
174
+ function collectNavPageIds(groups, seen, duplicates) {
175
+ for (const page of groups) {
176
+ if (typeof page === "string") {
177
+ if (seen.has(page)) duplicates.add(page);
178
+ else seen.add(page);
179
+ } else if (page.pages) {
180
+ collectNavPageIds(page.pages, seen, duplicates);
181
+ }
182
+ }
183
+ }
184
+ function scanMdx(dir, results) {
185
+ let entries;
186
+ try {
187
+ entries = readdirSync(dir);
188
+ } catch {
189
+ return;
190
+ }
191
+ for (const entry of entries) {
192
+ const fullPath = join(dir, entry);
193
+ try {
194
+ const stat = statSync(fullPath);
195
+ if (stat.isDirectory()) scanMdx(fullPath, results);
196
+ else if (extname(entry).toLowerCase() === ".mdx") results.push(fullPath);
197
+ } catch {
198
+ }
199
+ }
200
+ }
201
+ function addOrphanToNav(projectDir, pageId) {
202
+ const config = readDocsJson(projectDir);
203
+ const tab = config.tabs.find((t) => !t.href && !t.api && t.groups && t.groups.length > 0);
204
+ if (!tab?.groups) return;
205
+ const lastGroup = tab.groups[tab.groups.length - 1];
206
+ const existing = lastGroup.pages.filter((p) => typeof p === "string");
207
+ if (!existing.includes(pageId)) {
208
+ lastGroup.pages.push(pageId);
209
+ writeDocsJson(projectDir, config);
210
+ }
211
+ }
212
+ function slugify2(text) {
213
+ return text.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-");
214
+ }
215
+ function extractHeadingAnchors(content) {
216
+ const anchors = /* @__PURE__ */ new Set();
217
+ for (const line of content.split("\n")) {
218
+ const m = /^#{1,6}\s+(.+?)\s*#*\s*$/.exec(line);
219
+ if (m) anchors.add(slugify2(m[1]));
220
+ }
221
+ return anchors;
222
+ }
223
+ function extractLinks(content) {
224
+ const links = [];
225
+ const lines = content.split("\n");
226
+ let inFence = false;
227
+ for (let i = 0; i < lines.length; i++) {
228
+ if (/^\s*(```|~~~)/.test(lines[i])) {
229
+ inFence = !inFence;
230
+ continue;
231
+ }
232
+ if (inFence) continue;
233
+ const line = lines[i].replace(/`[^`]*`/g, "");
234
+ for (const m of line.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
235
+ links.push({ target: m[1], line: i + 1 });
236
+ }
237
+ for (const m of line.matchAll(/href=["']([^"']+)["']/g)) {
238
+ links.push({ target: m[1], line: i + 1 });
239
+ }
240
+ }
241
+ return links;
242
+ }
243
+ function pageIdToPath(pageId) {
244
+ return pageId === "introduction" ? "/" : `/${pageId}`;
245
+ }
246
+ function validateOpenApi(projectDir, source, issues) {
247
+ const specPath = join(projectDir, source);
248
+ if (!existsSync(specPath)) {
249
+ issues.push({ severity: "error", message: `API reference points at "${source}" but the file does not exist`, file: source });
250
+ return;
251
+ }
252
+ let spec;
253
+ try {
254
+ const raw = readFileSync(specPath, "utf8");
255
+ spec = source.endsWith(".json") ? JSON.parse(raw) : parseYaml(raw);
256
+ } catch (err) {
257
+ issues.push({ severity: "error", message: `OpenAPI spec is not valid ${source.endsWith(".json") ? "JSON" : "YAML"}: ${err.message}`, file: source });
258
+ return;
259
+ }
260
+ const s = spec;
261
+ if (typeof s?.openapi !== "string" && typeof s?.swagger !== "string") {
262
+ issues.push({ severity: "error", message: 'OpenAPI spec is missing the "openapi" (or "swagger") version field', file: source });
263
+ }
264
+ if (typeof s?.info !== "object" || s.info === null) {
265
+ issues.push({ severity: "error", message: 'OpenAPI spec is missing the "info" object', file: source });
266
+ }
267
+ const paths = s?.paths;
268
+ if (typeof paths !== "object" || paths === null) {
269
+ issues.push({ severity: "error", message: 'OpenAPI spec is missing the "paths" object', file: source });
270
+ } else {
271
+ const methods = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "options", "head", "trace"]);
272
+ for (const [p, ops] of Object.entries(paths)) {
273
+ if (typeof ops !== "object" || ops === null) {
274
+ issues.push({ severity: "error", message: `OpenAPI path "${p}" is not an object`, file: source });
275
+ continue;
276
+ }
277
+ const hasOp = Object.keys(ops).some((k) => methods.has(k.toLowerCase()));
278
+ if (!hasOp) {
279
+ issues.push({ severity: "warning", message: `OpenAPI path "${p}" has no operations`, file: source });
280
+ }
281
+ }
282
+ }
283
+ }
284
+ async function runCheck(projectDir, options) {
285
+ const { fix, ci } = options;
286
+ if (!existsSync(join(projectDir, "docs.json"))) {
287
+ console.error(`
288
+ \u274C Not a Thally project: docs.json not found in ${projectDir}
289
+ `);
290
+ return 1;
291
+ }
292
+ const contentDir = join(projectDir, "src", "content");
293
+ const issues = [];
294
+ const config = readDocsJson(projectDir);
295
+ const navPageIds = /* @__PURE__ */ new Set();
296
+ const duplicates = /* @__PURE__ */ new Set();
297
+ for (const tab of config.tabs) {
298
+ if (tab.href) {
299
+ if (tab.href.startsWith("/")) navPageIds.add(tab.href.slice(1) || "introduction");
300
+ continue;
301
+ }
302
+ if (tab.api) continue;
303
+ if (!tab.groups || tab.groups.length === 0) {
304
+ issues.push({ severity: "error", message: `Tab "${tab.tab}" has no groups and no href \u2014 it will render empty` });
305
+ continue;
306
+ }
307
+ collectNavPageIds(tab.groups.map((g) => g), navPageIds, duplicates);
308
+ }
309
+ for (const dup of duplicates) {
310
+ issues.push({ severity: "error", message: `[duplicate] "${dup}" appears more than once in docs.json` });
311
+ }
312
+ for (const pageId of navPageIds) {
313
+ const candidates = [join(contentDir, `${pageId}.mdx`), join(contentDir, `${pageId}/index.mdx`)];
314
+ if (!candidates.some((c) => existsSync(c))) {
315
+ issues.push({ severity: "error", message: `"${pageId}" is in docs.json but has no MDX file`, file: `src/content/${pageId}.mdx` });
316
+ }
317
+ }
318
+ const allFiles = [];
319
+ if (existsSync(contentDir)) scanMdx(contentDir, allFiles);
320
+ const fixedOrphans = [];
321
+ const validPaths = /* @__PURE__ */ new Set(["/"]);
322
+ const anchorsByPath = /* @__PURE__ */ new Map();
323
+ const linksByFile = [];
324
+ for (const filePath of allFiles) {
325
+ const rel = filePath.slice(contentDir.length + 1).replace(/\.mdx$/, "").replace(/\\/g, "/");
326
+ const pageId = rel.endsWith("/index") ? rel.slice(0, -6) : rel;
327
+ if (!navPageIds.has(pageId)) {
328
+ if (fix) {
329
+ addOrphanToNav(projectDir, pageId);
330
+ fixedOrphans.push(pageId);
331
+ } else {
332
+ issues.push({ severity: "warning", message: `"${pageId}" is not in docs.json nav (orphan)`, file: relative(projectDir, filePath) });
333
+ }
334
+ }
335
+ let data = {};
336
+ let content = "";
337
+ let lineOffset = 0;
338
+ try {
339
+ const raw = readFileSync(filePath, "utf8");
340
+ const parsed = matter(raw);
341
+ data = parsed.data;
342
+ content = parsed.content;
343
+ lineOffset = raw.slice(0, raw.indexOf(content)).split("\n").length - 1;
344
+ } catch {
345
+ issues.push({ severity: "error", message: `Could not parse frontmatter`, file: relative(projectDir, filePath) });
346
+ continue;
347
+ }
348
+ const rel2 = relative(projectDir, filePath);
349
+ if (!data.title) issues.push({ severity: "warning", message: `Missing "title" in frontmatter`, file: rel2 });
350
+ if (!data.description) issues.push({ severity: "warning", message: `Missing "description" in frontmatter`, file: rel2 });
351
+ if (content.trim().length < 50) issues.push({ severity: "warning", message: `Very short body (${content.trim().length} chars) \u2014 page may be empty`, file: rel2 });
352
+ if (options.drift) checkDrift(projectDir, rel2, data, issues);
353
+ const path = pageIdToPath(pageId);
354
+ const anchors = extractHeadingAnchors(content);
355
+ validPaths.add(path);
356
+ anchorsByPath.set(path, anchors);
357
+ linksByFile.push({ file: rel2, path, anchors, links: extractLinks(content), offset: lineOffset });
358
+ }
359
+ for (const { file, anchors, links, offset } of linksByFile) {
360
+ for (const { target, line: contentLine } of links) {
361
+ const line = contentLine + offset;
362
+ if (/^(https?:|mailto:|tel:)/i.test(target)) continue;
363
+ if (target.startsWith("#")) {
364
+ const anchor2 = target.slice(1);
365
+ if (anchor2 && !anchors.has(anchor2)) {
366
+ issues.push({ severity: "warning", message: `Broken anchor: "${target}" not found on this page`, file, line });
367
+ }
368
+ continue;
369
+ }
370
+ if (!target.startsWith("/")) continue;
371
+ const [beforeHash, anchor] = target.split("#");
372
+ let path = beforeHash.split("?")[0];
373
+ if (path.length > 1) path = path.replace(/\/$/, "");
374
+ if (path.startsWith("/api") || path.startsWith("/_next") || /\.[a-z0-9]+$/i.test(path)) continue;
375
+ if (!validPaths.has(path)) {
376
+ issues.push({ severity: "error", message: `Broken link: "${target}" \u2014 no page at "${path}"`, file, line });
377
+ } else if (anchor && !anchorsByPath.get(path)?.has(anchor)) {
378
+ issues.push({ severity: "warning", message: `Broken anchor: "${target}" \u2014 no heading "#${anchor}" on that page`, file, line });
379
+ }
380
+ }
381
+ }
382
+ for (const tab of config.tabs) {
383
+ if (tab.api?.source) validateOpenApi(projectDir, tab.api.source, issues);
384
+ }
385
+ const errors = issues.filter((i) => i.severity === "error");
386
+ const warnings = issues.filter((i) => i.severity === "warning");
387
+ if (ci) {
388
+ for (const issue of issues) {
389
+ const loc = issue.file ? `file=${issue.file}${issue.line ? `,line=${issue.line}` : ""}` : "";
390
+ console.log(`::${issue.severity} ${loc}::${issue.message}`);
391
+ }
392
+ console.log(`
393
+ thally check: ${errors.length} error(s), ${warnings.length} warning(s)`);
394
+ return errors.length > 0 ? 1 : 0;
395
+ }
396
+ console.log(`
397
+ Linting ${projectDir}...
398
+ `);
399
+ if (errors.length === 0 && warnings.length === 0 && fixedOrphans.length === 0) {
400
+ console.log(" \u2705 No issues found.\n");
401
+ return 0;
402
+ }
403
+ console.log(` \u274C ${errors.length} error${errors.length !== 1 ? "s" : ""}, \u26A0\uFE0F ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}
404
+ `);
405
+ if (errors.length > 0) {
406
+ console.log(" ERRORS:");
407
+ for (const issue of errors) {
408
+ console.log(` ${issue.message}`);
409
+ if (issue.file) console.log(` \u2192 ${issue.file}${issue.line ? `:${issue.line}` : ""}`);
410
+ }
411
+ console.log("");
412
+ }
413
+ if (warnings.length > 0) {
414
+ console.log(" WARNINGS:");
415
+ for (const issue of warnings) {
416
+ console.log(` ${issue.message}`);
417
+ if (issue.file) console.log(` \u2192 ${issue.file}${issue.line ? `:${issue.line}` : ""}`);
418
+ }
419
+ console.log("");
420
+ }
421
+ if (fixedOrphans.length > 0) {
422
+ console.log(` \u2705 Auto-fixed ${fixedOrphans.length} orphan page${fixedOrphans.length > 1 ? "s" : ""} (added to nav):`);
423
+ for (const p of fixedOrphans) console.log(` + ${p}`);
424
+ console.log("");
425
+ }
426
+ if (!fix && warnings.some((w) => w.message.includes("orphan"))) {
427
+ console.log(" Tip: run with --fix to auto-add orphan pages to navigation.\n");
428
+ }
429
+ return errors.length > 0 ? 1 : 0;
430
+ }
431
+
432
+ // src/translate.ts
433
+ import { readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2, mkdirSync } from "fs";
434
+ import { join as join2, dirname } from "path";
435
+ import { input as input2 } from "@inquirer/prompts";
436
+ import matter2 from "gray-matter";
437
+ import Anthropic from "@anthropic-ai/sdk";
438
+ import pLimit from "p-limit";
439
+ function readDocsJson2(projectDir) {
440
+ const docsPath = join2(projectDir, "docs.json");
441
+ const raw = readFileSync2(docsPath, "utf8");
442
+ return JSON.parse(raw);
443
+ }
444
+ function collectPageIds(pages) {
445
+ const ids = [];
446
+ for (const page of pages) {
447
+ if (typeof page === "string") {
448
+ ids.push(page);
449
+ } else if (page && typeof page === "object" && "pages" in page) {
450
+ ids.push(...collectPageIds(page.pages));
451
+ }
452
+ }
453
+ return ids;
454
+ }
455
+ function getAllPageIds(config) {
456
+ const ids = [];
457
+ const seen = /* @__PURE__ */ new Set();
458
+ const skippedApiTabs = [];
459
+ const hrefOnlyPages = [];
460
+ for (const tab of config.tabs) {
461
+ if (tab.api) {
462
+ skippedApiTabs.push(tab.tab);
463
+ continue;
464
+ }
465
+ if (!tab.groups && tab.href) {
466
+ const pageId = tab.href.replace(/^\//, "");
467
+ if (pageId && !seen.has(pageId)) {
468
+ seen.add(pageId);
469
+ ids.push(pageId);
470
+ hrefOnlyPages.push({ tab: tab.tab, pageId });
471
+ }
472
+ continue;
473
+ }
474
+ if (!tab.groups) continue;
475
+ for (const group of tab.groups) {
476
+ for (const id of collectPageIds(group.pages)) {
477
+ if (!seen.has(id)) {
478
+ seen.add(id);
479
+ ids.push(id);
480
+ }
481
+ }
482
+ }
483
+ }
484
+ return { ids, skippedApiTabs, hrefOnlyPages };
485
+ }
486
+ function findSourceFile(projectDir, pageId) {
487
+ const contentRoot = join2(projectDir, "src", "content");
488
+ const candidates = [
489
+ join2(contentRoot, `${pageId}.mdx`),
490
+ join2(contentRoot, `${pageId}/index.mdx`)
491
+ ];
492
+ return candidates.find((p) => existsSync2(p)) ?? null;
493
+ }
494
+ var TRANSLATION_SYSTEM_PROMPT = `You are a professional documentation translator. You will receive an MDX documentation file and translate it into the target language.
495
+
496
+ CRITICAL RULES \u2014 follow exactly:
497
+ 1. Translate ALL prose text, headings, and paragraphs.
498
+ 2. Translate frontmatter fields: title, description, and keywords values.
499
+ 3. DO NOT translate or modify MDX component names (e.g. <Note>, <Warning>, <Steps>, <Step>, <CodeGroup>, <Tabs>, <Tab>, <Card>, <Accordion>, <Columns>).
500
+ 4. DO NOT translate component prop names or prop values that are identifiers.
501
+ 5. DO NOT translate content inside code blocks (\`\`\` ... \`\`\`).
502
+ 6. DO NOT translate inline code spans (\`...\`).
503
+ 7. DO NOT translate URLs, file paths, or import statements.
504
+ 8. Preserve ALL whitespace, blank lines, and indentation exactly as in the original.
505
+ 9. Preserve ALL frontmatter YAML structure exactly \u2014 only translate the string values.
506
+ 10. Output ONLY the translated MDX file content \u2014 no preamble, no explanation, no markdown fences.
507
+
508
+ Example (translating to Spanish):
509
+ Input frontmatter:
510
+ title: Getting Started
511
+ description: Learn how to use the SDK.
512
+ Output frontmatter:
513
+ title: Comenzando
514
+ description: Aprende a usar el SDK.
515
+
516
+ Input MDX body:
517
+ ## Installation
518
+ Run the following command:
519
+ \`\`\`bash
520
+ npm install my-sdk
521
+ \`\`\`
522
+ <Note>This is important.</Note>
523
+ Output MDX body:
524
+ ## Instalaci\xF3n
525
+ Ejecuta el siguiente comando:
526
+ \`\`\`bash
527
+ npm install my-sdk
528
+ \`\`\`
529
+ <Note>Esto es importante.</Note>`;
530
+ async function translatePage(sourceContent, targetLocaleLabel, targetLocaleCode, model, client) {
531
+ const message = await client.messages.create({
532
+ model,
533
+ max_tokens: 8192,
534
+ system: TRANSLATION_SYSTEM_PROMPT,
535
+ messages: [
536
+ {
537
+ role: "user",
538
+ content: `Translate the following MDX documentation file to ${targetLocaleLabel} (locale code: ${targetLocaleCode}). Output ONLY the translated MDX content.
539
+
540
+ ${sourceContent}`
541
+ }
542
+ ]
543
+ });
544
+ const text = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
545
+ return text.trim();
546
+ }
547
+ async function runTranslateCommand(locale, pages, force, apiKey, model, yes, projectDir) {
548
+ const config = readDocsJson2(projectDir);
549
+ if (!config.i18n) {
550
+ console.error("\n \u274C No i18n config found in docs.json.");
551
+ console.error(' Add an "i18n" block to docs.json first:');
552
+ console.error(" {");
553
+ console.error(' "i18n": {');
554
+ console.error(' "defaultLocale": "en",');
555
+ console.error(' "locales": [{"code":"en","label":"English"},{"code":"es","label":"Espa\xF1ol"}]');
556
+ console.error(" }");
557
+ console.error(" }");
558
+ process.exit(1);
559
+ }
560
+ const targetLocale = config.i18n.locales.find((l) => l.code === locale);
561
+ if (!targetLocale) {
562
+ const available = config.i18n.locales.map((l) => l.code).join(", ");
563
+ console.error(`
564
+ \u274C Locale "${locale}" not found in docs.json i18n config.`);
565
+ console.error(` Available locales: ${available}`);
566
+ process.exit(1);
567
+ }
568
+ if (locale === config.i18n.defaultLocale) {
569
+ console.error(`
570
+ \u274C Cannot translate to the default locale "${locale}".`);
571
+ process.exit(1);
572
+ }
573
+ if (!apiKey) {
574
+ console.error("\n \u274C Anthropic API key required. Set ANTHROPIC_API_KEY or pass --api-key.");
575
+ process.exit(1);
576
+ }
577
+ const { ids: allPageIds, skippedApiTabs, hrefOnlyPages } = getAllPageIds(config);
578
+ if (skippedApiTabs.length > 0) {
579
+ console.log(` \u2139 Skipping API reference tab(s): ${skippedApiTabs.join(", ")}`);
580
+ console.log(" API reference pages are auto-generated from your OpenAPI spec and cannot be translated as MDX files.");
581
+ console.log("");
582
+ }
583
+ if (hrefOnlyPages.length > 0) {
584
+ const labels = hrefOnlyPages.map(({ tab, pageId }) => `${tab} (${pageId}.mdx)`).join(", ");
585
+ console.log(` \u2139 Including standalone tab page(s): ${labels}`);
586
+ console.log("");
587
+ }
588
+ const targetPageIds = pages ?? allPageIds;
589
+ const contentRoot = join2(projectDir, "src", "content");
590
+ const toTranslate = [];
591
+ for (const pageId of targetPageIds) {
592
+ const sourceFile = findSourceFile(projectDir, pageId);
593
+ if (!sourceFile) {
594
+ console.warn(` \u26A0 Page "${pageId}" not found in src/content \u2014 skipping.`);
595
+ continue;
596
+ }
597
+ const relativeFromContent = sourceFile.slice(contentRoot.length + 1);
598
+ const targetFile = join2(contentRoot, locale, relativeFromContent);
599
+ if (existsSync2(targetFile) && !force) {
600
+ console.log(` \u23ED ${pageId} (already translated, use --force to overwrite)`);
601
+ continue;
602
+ }
603
+ toTranslate.push({ pageId, sourceFile, targetFile });
604
+ }
605
+ if (toTranslate.length === 0) {
606
+ console.log("\n \u2705 Nothing to translate.");
607
+ return;
608
+ }
609
+ console.log(`
610
+ \u{1F4CB} ${toTranslate.length} page(s) to translate to ${targetLocale.label} (${locale}):`);
611
+ for (const { pageId } of toTranslate) {
612
+ console.log(` \u2022 ${pageId}`);
613
+ }
614
+ console.log("");
615
+ if (!yes) {
616
+ const confirm = await input2({
617
+ message: " Proceed? (Y/n):",
618
+ default: "Y"
619
+ });
620
+ if (confirm.toLowerCase() === "n") {
621
+ console.log("\n Aborted.");
622
+ return;
623
+ }
624
+ }
625
+ const client = new Anthropic({ apiKey });
626
+ const limit = pLimit(3);
627
+ let doneCount = 0;
628
+ const total = toTranslate.length;
629
+ await Promise.all(
630
+ toTranslate.map(
631
+ ({ pageId, sourceFile, targetFile }) => limit(async () => {
632
+ try {
633
+ const sourceContent = readFileSync2(sourceFile, "utf8");
634
+ const parsed = matter2(sourceContent);
635
+ if (!parsed.data.title) {
636
+ console.warn(` \u26A0 ${pageId}: missing title in frontmatter \u2014 translating anyway`);
637
+ }
638
+ const translated = await translatePage(
639
+ sourceContent,
640
+ targetLocale.label,
641
+ locale,
642
+ model,
643
+ client
644
+ );
645
+ mkdirSync(dirname(targetFile), { recursive: true });
646
+ writeFileSync(targetFile, translated + "\n", "utf8");
647
+ doneCount++;
648
+ console.log(` \u2713 [${doneCount}/${total}] ${pageId}`);
649
+ } catch (err) {
650
+ doneCount++;
651
+ const msg = err instanceof Error ? err.message : String(err);
652
+ console.error(` \u2717 [${doneCount}/${total}] ${pageId}: ${msg}`);
653
+ }
654
+ })
655
+ )
656
+ );
657
+ console.log("");
658
+ console.log(` \u2705 Translation complete! ${doneCount}/${total} pages translated.`);
659
+ console.log(` Files written to: src/content/${locale}/`);
660
+ }
661
+
662
+ // src/index.ts
663
+ var args = process.argv.slice(2);
664
+ var flags = args.filter((a) => a.startsWith("-"));
665
+ var positional = [];
666
+ for (let i = 0; i < args.length; i++) {
667
+ if (args[i].startsWith("-")) {
668
+ if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
669
+ i++;
670
+ }
671
+ } else {
672
+ positional.push(args[i]);
673
+ }
674
+ }
675
+ function getFlagValue(flag) {
676
+ const idx = args.indexOf(flag);
677
+ if (idx !== -1 && idx + 1 < args.length && !args[idx + 1].startsWith("-")) {
678
+ return args[idx + 1];
679
+ }
680
+ return void 0;
681
+ }
682
+ async function runMigrateCommand() {
683
+ const sourceUrl = positional[1];
684
+ if (!sourceUrl) {
685
+ console.error("\n \u274C Source URL is required.");
686
+ console.error(" Usage: create-thally-docs migrate <github-url> [output-dir] [options]");
687
+ console.error(" Example: create-thally-docs migrate https://github.com/mintlify/docs my-docs");
688
+ process.exit(1);
689
+ }
690
+ let parsedSource;
691
+ try {
692
+ parsedSource = parseGitHubUrl(sourceUrl);
693
+ } catch (err) {
694
+ console.error(`
695
+ \u274C ${err instanceof Error ? err.message : err}`);
696
+ process.exit(1);
697
+ }
698
+ const apiKey = getFlagValue("--api-key") ?? process.env.ANTHROPIC_API_KEY;
699
+ const intoDir = getFlagValue("--into");
700
+ const isInto = Boolean(intoDir);
701
+ let projectDir;
702
+ if (intoDir) {
703
+ projectDir = resolve2(intoDir);
704
+ } else if (positional[2]) {
705
+ projectDir = resolve2(positional[2]);
706
+ } else {
707
+ projectDir = resolve2(`${slugify(parsedSource.repo)}-docs`);
708
+ }
709
+ const branch = getFlagValue("--branch");
710
+ const docsDir = getFlagValue("--docs-dir");
711
+ const yes = flags.includes("--yes") || flags.includes("-y");
712
+ logo();
713
+ console.log(" \u{1F680} Thally Migrate");
714
+ console.log("");
715
+ console.log(` Source: ${sourceUrl}`);
716
+ console.log(` Target: ${projectDir}`);
717
+ if (branch) console.log(` Branch: ${branch}`);
718
+ if (docsDir) console.log(` Docs dir: ${docsDir}`);
719
+ console.log("");
720
+ if (!apiKey) {
721
+ console.warn(" \u26A0 No API key provided. Non-Markdown files will be skipped.");
722
+ console.warn(" Set ANTHROPIC_API_KEY=... or pass --api-key <key> to convert them.");
723
+ console.warn("");
724
+ }
725
+ await migrateDocs({
726
+ sourceUrl,
727
+ projectDir,
728
+ into: isInto,
729
+ apiKey,
730
+ branch,
731
+ docsDir,
732
+ yes
733
+ });
734
+ }
735
+ async function runScaffoldCommand() {
736
+ const useDefaults = flags.includes("--yes") || flags.includes("-y");
737
+ const dirArg = positional[0];
738
+ if (dirArg) {
739
+ const resolved = resolve2(dirArg);
740
+ if (existsSync3(resolved) && readdirSync2(resolved).length > 0) {
741
+ console.error(`
742
+ \u274C Directory "${resolved}" already exists and is not empty.`);
743
+ process.exit(1);
744
+ }
745
+ }
746
+ const answers = await gatherAnswers(dirArg, useDefaults);
747
+ const result = await scaffold({
748
+ projectDir: answers.projectDir,
749
+ projectName: answers.projectName,
750
+ description: answers.description,
751
+ brandPreset: answers.brandPreset,
752
+ repoUrl: answers.repoUrl,
753
+ doInstall: answers.doInstall,
754
+ i18nLocales: answers.i18nLocales,
755
+ trackRepos: answers.trackRepos
756
+ });
757
+ success(result.projectDir, answers.projectName);
758
+ }
759
+ async function runCheckCommand() {
760
+ const projectDir = resolve2(positional[1] ?? ".");
761
+ const exitCode = await runCheck(projectDir, {
762
+ fix: flags.includes("--fix"),
763
+ ci: flags.includes("--ci"),
764
+ external: flags.includes("--external"),
765
+ drift: flags.includes("--drift")
766
+ });
767
+ process.exit(exitCode);
768
+ }
769
+ async function runTranslateSubcommand() {
770
+ const locale = getFlagValue("--locale");
771
+ if (!locale) {
772
+ console.error("\n \u274C --locale is required.");
773
+ console.error(" Usage: create-thally-docs translate --locale es [--pages page1,page2] [--force] [--api-key key]");
774
+ process.exit(1);
775
+ }
776
+ const pagesArg = getFlagValue("--pages");
777
+ const pages = pagesArg ? pagesArg.split(",").map((p) => p.trim()).filter(Boolean) : void 0;
778
+ const force = flags.includes("--force");
779
+ const apiKey = getFlagValue("--api-key") ?? process.env.ANTHROPIC_API_KEY;
780
+ const model = getFlagValue("--model") ?? "claude-sonnet-4-6";
781
+ const yes = flags.includes("--yes") || flags.includes("-y");
782
+ const projectDir = resolve2(positional[1] ?? ".");
783
+ logo();
784
+ console.log(" \u{1F310} Thally Translate");
785
+ console.log("");
786
+ await runTranslateCommand(locale, pages, force, apiKey, model, yes, projectDir);
787
+ }
788
+ async function main() {
789
+ const subcommand = positional[0];
790
+ if (subcommand === "migrate") {
791
+ await runMigrateCommand();
792
+ } else if (subcommand === "check") {
793
+ await runCheckCommand();
794
+ } else if (subcommand === "translate") {
795
+ await runTranslateSubcommand();
796
+ } else {
797
+ logo();
798
+ await runScaffoldCommand();
799
+ }
800
+ }
801
+ main().catch((err) => {
802
+ console.error("\n \u274C Error:", err.message);
803
+ process.exit(1);
804
+ });