create-thally-docs 0.10.30 → 0.10.32
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/README.md +27 -0
- package/dist/{chunk-4RPHM4NA.js → chunk-74SGZLEY.js} +1 -1
- package/dist/chunk-E5URHAYU.js +611 -0
- package/dist/{chunk-XAUCGJ6B.js → chunk-FLSOVTVO.js} +2 -2
- package/dist/{chunk-PMAJ4BVM.js → chunk-HYGEXPZG.js} +1 -1
- package/dist/{chunk-SSKXNB6D.js → chunk-ZFCZE7M7.js} +47 -7
- package/dist/index.js +44 -407
- package/dist/migrate/index.d.ts +25 -0
- package/dist/migrate/index.js +5 -5
- package/dist/release.js +1 -1
- package/dist/scaffold.js +4 -4
- package/dist/starter-sync.js +2 -2
- package/dist/starter-update.js +3 -3
- package/package.json +2 -2
- package/dist/chunk-UYGDCVX3.js +0 -113
package/README.md
CHANGED
|
@@ -63,6 +63,33 @@ projected into Thally. A live Mintlify URL uses the structured configuration
|
|
|
63
63
|
embedded by Mintlify when it is available, with bounded same-site crawling as a
|
|
64
64
|
fallback.
|
|
65
65
|
|
|
66
|
+
Repository-local JSX/TSX components and supported static dependencies are copied
|
|
67
|
+
into the customer-owned MDX registry. Simple interactive HTML blocks are moved
|
|
68
|
+
into client components, and locale/root routing is normalized. Unsupported
|
|
69
|
+
customizations remain visible as warnings rather than being silently dropped.
|
|
70
|
+
|
|
71
|
+
After importing, the CLI runs static content checks and writes
|
|
72
|
+
`migration-report.json`. A failed check or build returns a nonzero exit status;
|
|
73
|
+
the imported files remain available for inspection. Warnings may include
|
|
74
|
+
pre-existing source problems and compatibility limitations even when a build
|
|
75
|
+
passes. Review them before publishing. Use `--skip-validation` for an explicit
|
|
76
|
+
import-only run; its report is marked unverified. An existing `--into` project
|
|
77
|
+
without a build script is also reported as unverified.
|
|
78
|
+
|
|
79
|
+
Dependency installation and production builds require explicit authorization:
|
|
80
|
+
answer the interactive trust prompt or pass `--trust-source` for a source you
|
|
81
|
+
have reviewed. `--yes` does not grant that authorization. Without it, files are
|
|
82
|
+
imported and statically checked, but the build is skipped and the report stays
|
|
83
|
+
unverified. MCP callers use `trustSource: true` only after the user authorizes
|
|
84
|
+
execution. `--skip-validation` also skips installation, even with trust granted.
|
|
85
|
+
Imported MDX and JSX/TSX are executable code; static migration analysis is not a
|
|
86
|
+
sandbox. Review the imported files before installing or building them manually.
|
|
87
|
+
|
|
88
|
+
A fresh migration leaves the destination repository unset. After creating the
|
|
89
|
+
new repository, set `site.repoUrl` in `src/data/site.ts` to its root GitHub URL
|
|
90
|
+
to enable edit and issue links. The source location remains in the migration
|
|
91
|
+
report; it is not used as the destination for reader feedback.
|
|
92
|
+
|
|
66
93
|
Prefer a single binary? Install [`@thallylabs/cli`](https://www.npmjs.com/package/@thallylabs/cli)
|
|
67
94
|
and use `thally init`, which delegates here.
|
|
68
95
|
|
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
initGit,
|
|
4
|
+
installDeps,
|
|
5
|
+
readDocsJson,
|
|
6
|
+
scaffold,
|
|
7
|
+
writeDocsJson
|
|
8
|
+
} from "./chunk-74SGZLEY.js";
|
|
9
|
+
|
|
10
|
+
// src/migrate/index.ts
|
|
11
|
+
import { existsSync as existsSync3, mkdirSync, mkdtempSync, readFileSync as readFileSync3, rmSync, writeFileSync } from "fs";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import { dirname, isAbsolute, join as join3, relative as relative2, resolve, sep } from "path";
|
|
14
|
+
import {
|
|
15
|
+
cloneGitHubRepository,
|
|
16
|
+
migrateRepository,
|
|
17
|
+
migrateUrl,
|
|
18
|
+
parseGitHubRepositoryUrl,
|
|
19
|
+
renderMigrationFiles
|
|
20
|
+
} from "@thallylabs/migrate";
|
|
21
|
+
|
|
22
|
+
// src/migrate/validate.ts
|
|
23
|
+
import { spawnSync } from "child_process";
|
|
24
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
25
|
+
import { join as join2 } from "path";
|
|
26
|
+
|
|
27
|
+
// src/check.ts
|
|
28
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
29
|
+
import { join, extname, relative } from "path";
|
|
30
|
+
import { execFileSync } from "child_process";
|
|
31
|
+
|
|
32
|
+
// src/frontmatter.ts
|
|
33
|
+
import { parse as parseYaml } from "yaml";
|
|
34
|
+
function parseFrontmatter(raw) {
|
|
35
|
+
const source = raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw;
|
|
36
|
+
const opening = /^---([^\r\n]*)\r?\n/.exec(source);
|
|
37
|
+
if (!opening || opening[1].startsWith("-")) return { content: source, data: {} };
|
|
38
|
+
const language = opening[1].trim().toLowerCase();
|
|
39
|
+
const remainder = source.slice(opening[0].length);
|
|
40
|
+
const closing = /^---[ \t]*\r?$/m.exec(remainder);
|
|
41
|
+
const matter = closing ? remainder.slice(0, closing.index) : remainder;
|
|
42
|
+
let content = closing ? remainder.slice(closing.index + closing[0].length) : "";
|
|
43
|
+
if (content.startsWith("\r\n")) content = content.slice(2);
|
|
44
|
+
else if (content.startsWith("\n")) content = content.slice(1);
|
|
45
|
+
if (matter.trim() === "" || !["", "yaml", "yml"].includes(language)) {
|
|
46
|
+
return { content, data: {} };
|
|
47
|
+
}
|
|
48
|
+
const parsed = parseYaml(matter);
|
|
49
|
+
return {
|
|
50
|
+
content,
|
|
51
|
+
data: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/check.ts
|
|
56
|
+
import { parse as parseYaml2 } from "yaml";
|
|
57
|
+
function gitLocal(projectDir, args) {
|
|
58
|
+
try {
|
|
59
|
+
const out = execFileSync("git", args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
60
|
+
return { ok: true, out: out.trim() };
|
|
61
|
+
} catch {
|
|
62
|
+
return { ok: false, out: "" };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function checkDrift(projectDir, file, data, issues) {
|
|
66
|
+
const sources = data.sources;
|
|
67
|
+
const verifiedCommit = data.verifiedCommit;
|
|
68
|
+
if (!Array.isArray(sources) || sources.length === 0 || typeof verifiedCommit !== "string" || !verifiedCommit.trim()) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const commit = verifiedCommit.trim();
|
|
72
|
+
if (!gitLocal(projectDir, ["cat-file", "-e", `${commit}^{commit}`]).ok) {
|
|
73
|
+
issues.push({
|
|
74
|
+
severity: "warning",
|
|
75
|
+
message: `Cannot verify freshness: verifiedCommit "${commit.slice(0, 8)}" is not in git history \u2014 run with a full clone (fetch-depth: 0).`,
|
|
76
|
+
file
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
for (const src of sources) {
|
|
81
|
+
if (typeof src !== "string" || !src.trim()) continue;
|
|
82
|
+
const colon = src.indexOf(":");
|
|
83
|
+
let filePath = src;
|
|
84
|
+
if (colon > 0) {
|
|
85
|
+
const alias = src.slice(0, colon);
|
|
86
|
+
if (alias !== "." && alias !== "self") {
|
|
87
|
+
issues.push({
|
|
88
|
+
severity: "warning",
|
|
89
|
+
message: `Cross-repo source "${src}" \u2014 drift check skipped (needs the referenced repo; see multi-repo setup).`,
|
|
90
|
+
file
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
filePath = src.slice(colon + 1);
|
|
95
|
+
}
|
|
96
|
+
filePath = filePath.replace(/^\.\//, "").replace(/#.*$/, "");
|
|
97
|
+
const changed = gitLocal(projectDir, ["log", "--format=%H", `${commit}..HEAD`, "--", filePath]).out;
|
|
98
|
+
if (changed) {
|
|
99
|
+
const n = changed.split("\n").filter(Boolean).length;
|
|
100
|
+
issues.push({
|
|
101
|
+
severity: "warning",
|
|
102
|
+
message: `Drift: source "${src}" changed in ${n} commit(s) since it was verified \u2014 this page may be stale.`,
|
|
103
|
+
file
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function collectNavPageIds(groups, seen, duplicates) {
|
|
109
|
+
for (const page of groups) {
|
|
110
|
+
if (typeof page === "string") {
|
|
111
|
+
if (seen.has(page)) duplicates.add(page);
|
|
112
|
+
else seen.add(page);
|
|
113
|
+
} else if (page.pages) {
|
|
114
|
+
collectNavPageIds(page.pages, seen, duplicates);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function scanMdx(dir, results) {
|
|
119
|
+
let entries;
|
|
120
|
+
try {
|
|
121
|
+
entries = readdirSync(dir);
|
|
122
|
+
} catch {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
const fullPath = join(dir, entry);
|
|
127
|
+
try {
|
|
128
|
+
const stat = statSync(fullPath);
|
|
129
|
+
if (stat.isDirectory()) scanMdx(fullPath, results);
|
|
130
|
+
else if (extname(entry).toLowerCase() === ".mdx") results.push(fullPath);
|
|
131
|
+
} catch {
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function addOrphanToNav(projectDir, pageId) {
|
|
136
|
+
const config = readDocsJson(projectDir);
|
|
137
|
+
const tab = config.tabs.find((candidate) => !candidate.href && !candidate.api && (candidate.pages?.length || candidate.groups?.length));
|
|
138
|
+
if (!tab) return;
|
|
139
|
+
if (tab.pages) {
|
|
140
|
+
if (!tab.pages.includes(pageId)) {
|
|
141
|
+
tab.pages.push(pageId);
|
|
142
|
+
writeDocsJson(projectDir, config);
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (!tab.groups) return;
|
|
147
|
+
const lastGroup = tab.groups[tab.groups.length - 1];
|
|
148
|
+
const existing = lastGroup.pages.filter((p) => typeof p === "string");
|
|
149
|
+
if (!existing.includes(pageId)) {
|
|
150
|
+
lastGroup.pages.push(pageId);
|
|
151
|
+
writeDocsJson(projectDir, config);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function slugify(text) {
|
|
155
|
+
return text.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-");
|
|
156
|
+
}
|
|
157
|
+
function extractHeadingAnchors(content) {
|
|
158
|
+
const anchors = /* @__PURE__ */ new Set();
|
|
159
|
+
for (const line of content.split("\n")) {
|
|
160
|
+
const m = /^#{1,6}\s+(.+?)\s*#*\s*$/.exec(line);
|
|
161
|
+
if (m) anchors.add(slugify(m[1]));
|
|
162
|
+
}
|
|
163
|
+
return anchors;
|
|
164
|
+
}
|
|
165
|
+
function extractLinks(content) {
|
|
166
|
+
const links = [];
|
|
167
|
+
const lines = content.split("\n");
|
|
168
|
+
let inFence = false;
|
|
169
|
+
for (let i = 0; i < lines.length; i++) {
|
|
170
|
+
if (/^\s*(```|~~~)/.test(lines[i])) {
|
|
171
|
+
inFence = !inFence;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (inFence) continue;
|
|
175
|
+
const line = lines[i].replace(/`[^`]*`/g, "");
|
|
176
|
+
for (const m of line.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
|
|
177
|
+
links.push({ target: m[1], line: i + 1 });
|
|
178
|
+
}
|
|
179
|
+
for (const m of line.matchAll(/href=["']([^"']+)["']/g)) {
|
|
180
|
+
links.push({ target: m[1], line: i + 1 });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return links;
|
|
184
|
+
}
|
|
185
|
+
function localizedPage(pageId, secondaryLocales) {
|
|
186
|
+
const [first, ...rest] = pageId.split("/");
|
|
187
|
+
if (secondaryLocales.has(first) && rest.length > 0) {
|
|
188
|
+
return { navPageId: rest.join("/"), locale: first };
|
|
189
|
+
}
|
|
190
|
+
return { navPageId: pageId };
|
|
191
|
+
}
|
|
192
|
+
function pageIdToPath(pageId, secondaryLocales) {
|
|
193
|
+
const { navPageId, locale } = localizedPage(pageId, secondaryLocales);
|
|
194
|
+
const basePath = navPageId === "introduction" ? "/" : `/${navPageId}`;
|
|
195
|
+
return locale ? `/${locale}${basePath === "/" ? "" : basePath}` : basePath;
|
|
196
|
+
}
|
|
197
|
+
function resolveLink(target, redirects) {
|
|
198
|
+
let current = target;
|
|
199
|
+
let anchor;
|
|
200
|
+
const seen = /* @__PURE__ */ new Set();
|
|
201
|
+
while (true) {
|
|
202
|
+
if (/^(?:https?:)?\/\//i.test(current)) return { path: current, external: true };
|
|
203
|
+
const hash = current.indexOf("#");
|
|
204
|
+
if (hash >= 0) anchor = current.slice(hash + 1);
|
|
205
|
+
const beforeHash = hash >= 0 ? current.slice(0, hash) : current;
|
|
206
|
+
const path = beforeHash.split("?", 1)[0].replace(/\/$/, "") || "/";
|
|
207
|
+
if (seen.has(path)) return { path, anchor, cycle: true };
|
|
208
|
+
seen.add(path);
|
|
209
|
+
const redirected = redirects.get(path);
|
|
210
|
+
if (!redirected) return { path, anchor };
|
|
211
|
+
current = redirected;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function validateOpenApi(projectDir, source, issues) {
|
|
215
|
+
const specPath = source.startsWith("/") ? join(projectDir, "public", source.slice(1)) : join(projectDir, source);
|
|
216
|
+
if (!existsSync(specPath)) {
|
|
217
|
+
issues.push({ severity: "error", message: `API reference points at "${source}" but the file does not exist`, file: source });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
let spec;
|
|
221
|
+
try {
|
|
222
|
+
const raw = readFileSync(specPath, "utf8");
|
|
223
|
+
spec = source.endsWith(".json") ? JSON.parse(raw) : parseYaml2(raw);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
issues.push({ severity: "error", message: `OpenAPI spec is not valid ${source.endsWith(".json") ? "JSON" : "YAML"}: ${err.message}`, file: source });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const s = spec;
|
|
229
|
+
if (typeof s?.openapi !== "string" && typeof s?.swagger !== "string") {
|
|
230
|
+
issues.push({ severity: "error", message: 'OpenAPI spec is missing the "openapi" (or "swagger") version field', file: source });
|
|
231
|
+
}
|
|
232
|
+
if (typeof s?.info !== "object" || s.info === null) {
|
|
233
|
+
issues.push({ severity: "error", message: 'OpenAPI spec is missing the "info" object', file: source });
|
|
234
|
+
}
|
|
235
|
+
const paths = s?.paths;
|
|
236
|
+
if (typeof paths !== "object" || paths === null) {
|
|
237
|
+
issues.push({ severity: "error", message: 'OpenAPI spec is missing the "paths" object', file: source });
|
|
238
|
+
} else {
|
|
239
|
+
const methods = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "options", "head", "trace"]);
|
|
240
|
+
for (const [p, ops] of Object.entries(paths)) {
|
|
241
|
+
if (typeof ops !== "object" || ops === null) {
|
|
242
|
+
issues.push({ severity: "error", message: `OpenAPI path "${p}" is not an object`, file: source });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const hasOp = Object.keys(ops).some((k) => methods.has(k.toLowerCase()));
|
|
246
|
+
if (!hasOp) {
|
|
247
|
+
issues.push({ severity: "warning", message: `OpenAPI path "${p}" has no operations`, file: source });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function runCheck(projectDir, options) {
|
|
253
|
+
const { fix, ci } = options;
|
|
254
|
+
if (!existsSync(join(projectDir, "docs.json"))) {
|
|
255
|
+
console.error(`
|
|
256
|
+
\u274C Not a Thally project: docs.json not found in ${projectDir}
|
|
257
|
+
`);
|
|
258
|
+
return 1;
|
|
259
|
+
}
|
|
260
|
+
const contentDir = join(projectDir, "src", "content");
|
|
261
|
+
const issues = [];
|
|
262
|
+
const config = readDocsJson(projectDir);
|
|
263
|
+
const secondaryLocales = new Set(
|
|
264
|
+
(config.i18n?.locales ?? []).map((locale) => locale.code).filter((code) => code !== config.i18n?.defaultLocale)
|
|
265
|
+
);
|
|
266
|
+
const generatedApiPaths = /* @__PURE__ */ new Set([
|
|
267
|
+
"/api",
|
|
268
|
+
...Array.from(secondaryLocales, (locale) => `/${locale}/api`)
|
|
269
|
+
]);
|
|
270
|
+
const redirectDestinations = new Map(
|
|
271
|
+
(config.redirects ?? []).map((redirect) => [
|
|
272
|
+
redirect.source.replace(/\/$/, "") || "/",
|
|
273
|
+
redirect.destination
|
|
274
|
+
])
|
|
275
|
+
);
|
|
276
|
+
const navPageIds = /* @__PURE__ */ new Set();
|
|
277
|
+
const duplicates = /* @__PURE__ */ new Set();
|
|
278
|
+
for (const tab of config.tabs) {
|
|
279
|
+
const hasNavigationNodes = Boolean(tab.pages?.length || tab.groups?.length);
|
|
280
|
+
if (tab.href && !hasNavigationNodes) {
|
|
281
|
+
if (tab.href.startsWith("/")) navPageIds.add(tab.href.slice(1) || "introduction");
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (tab.api && tab.api.navigation !== false && !hasNavigationNodes) continue;
|
|
285
|
+
if (!hasNavigationNodes) {
|
|
286
|
+
issues.push({ severity: "error", message: `Tab "${tab.tab}" has no groups and no href \u2014 it will render empty` });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
collectNavPageIds(tab.pages ?? [], navPageIds, duplicates);
|
|
290
|
+
collectNavPageIds(tab.groups ?? [], navPageIds, duplicates);
|
|
291
|
+
}
|
|
292
|
+
for (const dup of duplicates) {
|
|
293
|
+
issues.push({ severity: "warning", message: `[duplicate] "${dup}" appears more than once in docs.json` });
|
|
294
|
+
}
|
|
295
|
+
for (const pageId of navPageIds) {
|
|
296
|
+
const candidates = [join(contentDir, `${pageId}.mdx`), join(contentDir, `${pageId}/index.mdx`)];
|
|
297
|
+
if (!candidates.some((c) => existsSync(c))) {
|
|
298
|
+
issues.push({ severity: "error", message: `"${pageId}" is in docs.json but has no MDX file`, file: `src/content/${pageId}.mdx` });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const allFiles = [];
|
|
302
|
+
if (existsSync(contentDir)) scanMdx(contentDir, allFiles);
|
|
303
|
+
const fixedOrphans = [];
|
|
304
|
+
const validPaths = /* @__PURE__ */ new Set(["/"]);
|
|
305
|
+
const anchorsByPath = /* @__PURE__ */ new Map();
|
|
306
|
+
const linksByFile = [];
|
|
307
|
+
for (const filePath of allFiles) {
|
|
308
|
+
const rel = filePath.slice(contentDir.length + 1).replace(/\.mdx$/, "").replace(/\\/g, "/");
|
|
309
|
+
const pageId = rel.endsWith("/index") ? rel.slice(0, -6) : rel;
|
|
310
|
+
const { navPageId } = localizedPage(pageId, secondaryLocales);
|
|
311
|
+
if (!navPageIds.has(navPageId)) {
|
|
312
|
+
if (fix) {
|
|
313
|
+
addOrphanToNav(projectDir, pageId);
|
|
314
|
+
fixedOrphans.push(pageId);
|
|
315
|
+
} else {
|
|
316
|
+
issues.push({ severity: "warning", message: `"${pageId}" is not in docs.json nav (orphan)`, file: relative(projectDir, filePath) });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
let data = {};
|
|
320
|
+
let content = "";
|
|
321
|
+
let lineOffset = 0;
|
|
322
|
+
try {
|
|
323
|
+
const raw = readFileSync(filePath, "utf8");
|
|
324
|
+
const parsed = parseFrontmatter(raw);
|
|
325
|
+
data = parsed.data;
|
|
326
|
+
content = parsed.content;
|
|
327
|
+
lineOffset = raw.slice(0, raw.indexOf(content)).split("\n").length - 1;
|
|
328
|
+
} catch {
|
|
329
|
+
issues.push({ severity: "error", message: `Could not parse frontmatter`, file: relative(projectDir, filePath) });
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const rel2 = relative(projectDir, filePath);
|
|
333
|
+
if (!data.title) issues.push({ severity: "warning", message: `Missing "title" in frontmatter`, file: rel2 });
|
|
334
|
+
if (!data.description) issues.push({ severity: "warning", message: `Missing "description" in frontmatter`, file: rel2 });
|
|
335
|
+
if (typeof data.openapi !== "string" && content.trim().length < 50) {
|
|
336
|
+
issues.push({ severity: "warning", message: `Very short body (${content.trim().length} chars) \u2014 page may be empty`, file: rel2 });
|
|
337
|
+
}
|
|
338
|
+
if (options.drift) checkDrift(projectDir, rel2, data, issues);
|
|
339
|
+
const path = pageIdToPath(pageId, secondaryLocales);
|
|
340
|
+
const anchors = extractHeadingAnchors(content);
|
|
341
|
+
validPaths.add(path);
|
|
342
|
+
validPaths.add(`/${pageId}`);
|
|
343
|
+
anchorsByPath.set(path, anchors);
|
|
344
|
+
anchorsByPath.set(`/${pageId}`, anchors);
|
|
345
|
+
linksByFile.push({ file: rel2, path, anchors, links: extractLinks(content), offset: lineOffset });
|
|
346
|
+
}
|
|
347
|
+
for (const [path, anchors] of [...anchorsByPath]) {
|
|
348
|
+
if (secondaryLocales.has(path.split("/")[1])) continue;
|
|
349
|
+
for (const locale of secondaryLocales) {
|
|
350
|
+
const localized = `/${locale}${path === "/" ? "" : path}`;
|
|
351
|
+
if (validPaths.has(localized)) continue;
|
|
352
|
+
validPaths.add(localized);
|
|
353
|
+
anchorsByPath.set(localized, anchors);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
for (const { file, anchors, links, offset } of linksByFile) {
|
|
357
|
+
for (const { target, line: contentLine } of links) {
|
|
358
|
+
const line = contentLine + offset;
|
|
359
|
+
if (/^(https?:|mailto:|tel:)/i.test(target)) continue;
|
|
360
|
+
if (target.startsWith("#")) {
|
|
361
|
+
const anchor2 = target.slice(1);
|
|
362
|
+
if (anchor2 && !anchors.has(anchor2)) {
|
|
363
|
+
issues.push({ severity: "warning", message: `Broken anchor: "${target}" not found on this page`, file, line });
|
|
364
|
+
}
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (!target.startsWith("/")) continue;
|
|
368
|
+
const { path, anchor, cycle, external } = resolveLink(target, redirectDestinations);
|
|
369
|
+
if (external) continue;
|
|
370
|
+
const isGeneratedApiPath = Array.from(generatedApiPaths).some(
|
|
371
|
+
(prefix) => path === prefix || path.startsWith(`${prefix}/`)
|
|
372
|
+
);
|
|
373
|
+
if (!cycle && (isGeneratedApiPath || path.startsWith("/_next") || /\.[a-z0-9]+$/i.test(path))) continue;
|
|
374
|
+
if (cycle || !validPaths.has(path)) {
|
|
375
|
+
issues.push({ severity: "error", message: `Broken link: "${target}" \u2014 no page at "${path}"`, file, line });
|
|
376
|
+
} else if (anchor && !anchorsByPath.get(path)?.has(anchor)) {
|
|
377
|
+
issues.push({ severity: "warning", message: `Broken anchor: "${target}" \u2014 no heading "#${anchor}" on that page`, file, line });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
for (const tab of config.tabs) {
|
|
382
|
+
if (tab.api?.source) validateOpenApi(projectDir, tab.api.source, issues);
|
|
383
|
+
}
|
|
384
|
+
const errors = issues.filter((i) => i.severity === "error");
|
|
385
|
+
options.onIssues?.(issues);
|
|
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/migrate/validate.ts
|
|
433
|
+
async function validateMigration(projectDir, skip = false, trustSource = false, installationFailed = false) {
|
|
434
|
+
const result = { content: "skipped", build: "skipped", messages: [], diagnostics: [] };
|
|
435
|
+
if (skip) {
|
|
436
|
+
result.messages.push("Validation was explicitly skipped; this import is not verified.");
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
result.content = await runCheck(projectDir, { fix: false, ci: true, onIssues: (issues) => {
|
|
441
|
+
result.diagnostics = issues;
|
|
442
|
+
} }) === 0 ? "passed" : "failed";
|
|
443
|
+
if (result.content === "failed") result.messages.push("Content validation failed. Review the check output; source links are not silently rewritten.");
|
|
444
|
+
} catch (error) {
|
|
445
|
+
result.content = "failed";
|
|
446
|
+
result.messages.push(`Content validation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
447
|
+
}
|
|
448
|
+
if (installationFailed) {
|
|
449
|
+
result.build = "failed";
|
|
450
|
+
result.messages.push("Dependency installation failed; production build was not attempted. Review the installation output before retrying.");
|
|
451
|
+
return result;
|
|
452
|
+
}
|
|
453
|
+
if (trustSource !== true) {
|
|
454
|
+
result.messages.push("Production build skipped: source execution was not authorized. Review the imported code before running npm install and npm run build, or explicitly authorize a trusted migration with --trust-source.");
|
|
455
|
+
return result;
|
|
456
|
+
}
|
|
457
|
+
const packagePath = join2(projectDir, "package.json");
|
|
458
|
+
try {
|
|
459
|
+
const manifest = existsSync2(packagePath) ? JSON.parse(readFileSync2(packagePath, "utf8")) : void 0;
|
|
460
|
+
if (!manifest?.scripts?.build) {
|
|
461
|
+
result.messages.push("No build script is available; production rendering is not verified.");
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
const build = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "build"], {
|
|
465
|
+
cwd: projectDir,
|
|
466
|
+
stdio: "inherit",
|
|
467
|
+
timeout: 10 * 60 * 1e3
|
|
468
|
+
});
|
|
469
|
+
result.build = build.status === 0 && !build.error ? "passed" : "failed";
|
|
470
|
+
if (result.build === "failed") result.messages.push("Production build failed. Review the build output before publishing.");
|
|
471
|
+
} catch (error) {
|
|
472
|
+
result.build = "failed";
|
|
473
|
+
result.messages.push(`Production build failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
474
|
+
}
|
|
475
|
+
return result;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// src/migrate/index.ts
|
|
479
|
+
function projectPath(projectDir, candidate) {
|
|
480
|
+
const target = resolve(projectDir, candidate);
|
|
481
|
+
const fromRoot = relative2(resolve(projectDir), target);
|
|
482
|
+
if (isAbsolute(fromRoot) || fromRoot === ".." || fromRoot.startsWith(`..${sep}`)) {
|
|
483
|
+
throw new Error(`Generated migration path escapes the project: ${candidate}`);
|
|
484
|
+
}
|
|
485
|
+
return target;
|
|
486
|
+
}
|
|
487
|
+
function readExistingConfig(projectDir) {
|
|
488
|
+
const configPath = projectPath(projectDir, "docs.json");
|
|
489
|
+
if (!existsSync3(configPath)) return void 0;
|
|
490
|
+
return JSON.parse(readFileSync3(configPath, "utf8"));
|
|
491
|
+
}
|
|
492
|
+
function resetFreshMigrationContent(projectDir) {
|
|
493
|
+
const contentDirectory = projectPath(projectDir, "src/content");
|
|
494
|
+
rmSync(contentDirectory, { recursive: true, force: true });
|
|
495
|
+
mkdirSync(contentDirectory, { recursive: true });
|
|
496
|
+
for (const sampleSpec of ["openapi.yaml", "openapi.json"]) {
|
|
497
|
+
rmSync(projectPath(projectDir, sampleSpec), { force: true });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async function discoverMigration(options) {
|
|
501
|
+
const url = new URL(options.sourceUrl);
|
|
502
|
+
if (url.hostname.toLowerCase() !== "github.com") {
|
|
503
|
+
console.log(` \u{1F310} Discovering public docs at ${url.origin}${url.pathname}...`);
|
|
504
|
+
return migrateUrl({
|
|
505
|
+
sourceUrl: options.sourceUrl,
|
|
506
|
+
platform: options.platform,
|
|
507
|
+
maxPages: options.maxPages,
|
|
508
|
+
fetcher: options.fetcher
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
const source = parseGitHubRepositoryUrl(options.sourceUrl);
|
|
512
|
+
if (options.branch) source.branch = options.branch;
|
|
513
|
+
const temporaryRoot = mkdtempSync(join3(tmpdir(), "thally-migrate-"));
|
|
514
|
+
const cloneDir = join3(temporaryRoot, "repository");
|
|
515
|
+
console.log(` \u{1F4E6} Cloning ${source.owner}/${source.repo}...`);
|
|
516
|
+
try {
|
|
517
|
+
await cloneGitHubRepository(source, cloneDir);
|
|
518
|
+
return migrateRepository({
|
|
519
|
+
repositoryDir: cloneDir,
|
|
520
|
+
sourceUrl: options.sourceUrl,
|
|
521
|
+
docsDir: options.docsDir ?? (source.docsDir || void 0),
|
|
522
|
+
platform: options.platform
|
|
523
|
+
});
|
|
524
|
+
} finally {
|
|
525
|
+
rmSync(temporaryRoot, { recursive: true, force: true });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
async function migrateDocs(options) {
|
|
529
|
+
const projectDir = resolve(options.projectDir);
|
|
530
|
+
const bundle = await discoverMigration(options);
|
|
531
|
+
if (!options.into) {
|
|
532
|
+
console.log(`
|
|
533
|
+
\u{1F3D7} Scaffolding new project at ${projectDir}...`);
|
|
534
|
+
await scaffold({
|
|
535
|
+
projectDir,
|
|
536
|
+
projectName: options.projectName ?? bundle.site?.name ?? "My Docs",
|
|
537
|
+
description: bundle.site?.description ?? `Documentation migrated from ${new URL(options.sourceUrl).hostname}`,
|
|
538
|
+
brandPreset: "primary",
|
|
539
|
+
// Source provenance is not the destination repository. Migrated pages
|
|
540
|
+
// live at new paths, so source URLs cannot power edit/issue actions.
|
|
541
|
+
repoUrl: "",
|
|
542
|
+
doInstall: false
|
|
543
|
+
});
|
|
544
|
+
resetFreshMigrationContent(projectDir);
|
|
545
|
+
} else if (!existsSync3(projectDir)) {
|
|
546
|
+
throw new Error(`Project directory "${projectDir}" does not exist. Use without --into to scaffold a new one.`);
|
|
547
|
+
}
|
|
548
|
+
if (!options.into) {
|
|
549
|
+
const starterConfig = readExistingConfig(projectDir);
|
|
550
|
+
if (starterConfig?.markdown) bundle.docsConfig.markdown = starterConfig.markdown;
|
|
551
|
+
bundle.docsConfig.i18n ??= { defaultLocale: "en", locales: [{ code: "en", label: "English" }] };
|
|
552
|
+
}
|
|
553
|
+
const rendered = renderMigrationFiles(bundle, {
|
|
554
|
+
existingConfig: options.into ? readExistingConfig(projectDir) : void 0,
|
|
555
|
+
existingComponentRegistry: existsSync3(projectPath(projectDir, "src/mdx/custom-components.tsx")) ? readFileSync3(projectPath(projectDir, "src/mdx/custom-components.tsx"), "utf8") : void 0
|
|
556
|
+
});
|
|
557
|
+
for (const file of rendered) {
|
|
558
|
+
const destination = projectPath(projectDir, file.path);
|
|
559
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
560
|
+
writeFileSync(destination, file.content);
|
|
561
|
+
}
|
|
562
|
+
for (const warning of bundle.warnings) {
|
|
563
|
+
console.warn(` \u26A0 ${warning.message}${warning.source ? ` (${warning.source})` : ""}`);
|
|
564
|
+
}
|
|
565
|
+
console.log(` \u2713 Imported ${bundle.pages.length} pages and ${bundle.assets.length} assets from ${bundle.platform}.`);
|
|
566
|
+
let installationFailed = false;
|
|
567
|
+
if (!options.into && options.trustSource === true && !options.skipValidation) {
|
|
568
|
+
try {
|
|
569
|
+
installDeps(projectDir);
|
|
570
|
+
} catch {
|
|
571
|
+
installationFailed = true;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
console.log("\n Validating imported documentation...");
|
|
575
|
+
const validation = await validateMigration(projectDir, options.skipValidation, options.trustSource === true, installationFailed);
|
|
576
|
+
const reportPath = projectPath(projectDir, "migration-report.json");
|
|
577
|
+
writeFileSync(reportPath, `${JSON.stringify({
|
|
578
|
+
version: 1,
|
|
579
|
+
sourceUrl: `${new URL(options.sourceUrl).origin}${new URL(options.sourceUrl).pathname}`,
|
|
580
|
+
platform: bundle.platform,
|
|
581
|
+
pages: bundle.pages.length,
|
|
582
|
+
assets: bundle.assets.length,
|
|
583
|
+
components: bundle.componentFiles?.length ?? 0,
|
|
584
|
+
warnings: bundle.warnings,
|
|
585
|
+
validation
|
|
586
|
+
}, null, 2)}
|
|
587
|
+
`);
|
|
588
|
+
for (const message of validation.messages) console.warn(` \u26A0 ${message}`);
|
|
589
|
+
console.log(` Migration report: ${reportPath}`);
|
|
590
|
+
if (validation.content === "passed" && validation.build === "passed") {
|
|
591
|
+
console.log(` \u2713 Content and production build passed.${bundle.warnings.length ? " Review the migration warnings for compatibility limitations." : ""}`);
|
|
592
|
+
} else {
|
|
593
|
+
console.warn(" Import retained, but validation is incomplete. Do not publish without reviewing the report.");
|
|
594
|
+
}
|
|
595
|
+
if (!options.into) initGit(projectDir);
|
|
596
|
+
return {
|
|
597
|
+
pagesWritten: bundle.pages.length,
|
|
598
|
+
assetsWritten: bundle.assets.length,
|
|
599
|
+
projectDir,
|
|
600
|
+
platform: bundle.platform,
|
|
601
|
+
warnings: bundle.warnings,
|
|
602
|
+
validation,
|
|
603
|
+
reportPath
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export {
|
|
608
|
+
parseFrontmatter,
|
|
609
|
+
runCheck,
|
|
610
|
+
migrateDocs
|
|
611
|
+
};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
readStarterReleaseManifest
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-HYGEXPZG.js";
|
|
5
5
|
import {
|
|
6
6
|
STABLE_SCAFFOLD_RELEASE
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-ZFCZE7M7.js";
|
|
8
8
|
|
|
9
9
|
// src/download.ts
|
|
10
10
|
import { Readable, Transform, pipeline } from "stream";
|