create-thally-docs 0.10.31 → 0.10.33
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 +24 -0
- package/dist/{chunk-NIYRBHH7.js → chunk-5TBIIZHQ.js} +1 -1
- package/dist/{chunk-EXH4PYPC.js → chunk-DPYULA35.js} +2 -2
- package/dist/{chunk-IRFT5MUJ.js → chunk-HOQBNMQ4.js} +1 -1
- package/dist/{chunk-VLLFRIPP.js → chunk-I2OURFVJ.js} +47 -7
- package/dist/chunk-V5PERCVX.js +608 -0
- package/dist/index.js +31 -405
- package/dist/migrate/index.d.ts +23 -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-DXD5Q42N.js +0 -113
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
initGit,
|
|
4
|
+
installDeps,
|
|
5
|
+
readDocsJson,
|
|
6
|
+
scaffold,
|
|
7
|
+
writeDocsJson
|
|
8
|
+
} from "./chunk-5TBIIZHQ.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, 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
|
+
const packagePath = join2(projectDir, "package.json");
|
|
454
|
+
try {
|
|
455
|
+
const manifest = existsSync2(packagePath) ? JSON.parse(readFileSync2(packagePath, "utf8")) : void 0;
|
|
456
|
+
if (!manifest?.scripts?.build) {
|
|
457
|
+
result.messages.push("No build script is available; production rendering is not verified.");
|
|
458
|
+
return result;
|
|
459
|
+
}
|
|
460
|
+
const build = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "build"], {
|
|
461
|
+
cwd: projectDir,
|
|
462
|
+
stdio: "inherit",
|
|
463
|
+
timeout: 10 * 60 * 1e3
|
|
464
|
+
});
|
|
465
|
+
result.build = build.status === 0 && !build.error ? "passed" : "failed";
|
|
466
|
+
if (result.build === "failed") result.messages.push("Production build failed. Review the build output before publishing.");
|
|
467
|
+
} catch (error) {
|
|
468
|
+
result.build = "failed";
|
|
469
|
+
result.messages.push(`Production build failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
470
|
+
}
|
|
471
|
+
return result;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// src/migrate/index.ts
|
|
475
|
+
function projectPath(projectDir, candidate) {
|
|
476
|
+
const target = resolve(projectDir, candidate);
|
|
477
|
+
const fromRoot = relative2(resolve(projectDir), target);
|
|
478
|
+
if (isAbsolute(fromRoot) || fromRoot === ".." || fromRoot.startsWith(`..${sep}`)) {
|
|
479
|
+
throw new Error(`Generated migration path escapes the project: ${candidate}`);
|
|
480
|
+
}
|
|
481
|
+
return target;
|
|
482
|
+
}
|
|
483
|
+
function readExistingConfig(projectDir) {
|
|
484
|
+
const configPath = projectPath(projectDir, "docs.json");
|
|
485
|
+
if (!existsSync3(configPath)) return void 0;
|
|
486
|
+
return JSON.parse(readFileSync3(configPath, "utf8"));
|
|
487
|
+
}
|
|
488
|
+
function resetFreshMigrationContent(projectDir) {
|
|
489
|
+
const contentDirectory = projectPath(projectDir, "src/content");
|
|
490
|
+
rmSync(contentDirectory, { recursive: true, force: true });
|
|
491
|
+
mkdirSync(contentDirectory, { recursive: true });
|
|
492
|
+
for (const sampleSpec of ["openapi.yaml", "openapi.json"]) {
|
|
493
|
+
rmSync(projectPath(projectDir, sampleSpec), { force: true });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
async function discoverMigration(options) {
|
|
497
|
+
const url = new URL(options.sourceUrl);
|
|
498
|
+
if (url.hostname.toLowerCase() !== "github.com") {
|
|
499
|
+
console.log(` \u{1F310} Discovering public docs at ${url.origin}${url.pathname}...`);
|
|
500
|
+
return migrateUrl({
|
|
501
|
+
sourceUrl: options.sourceUrl,
|
|
502
|
+
platform: options.platform,
|
|
503
|
+
maxPages: options.maxPages,
|
|
504
|
+
fetcher: options.fetcher
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
const source = parseGitHubRepositoryUrl(options.sourceUrl);
|
|
508
|
+
if (options.branch) source.branch = options.branch;
|
|
509
|
+
const temporaryRoot = mkdtempSync(join3(tmpdir(), "thally-migrate-"));
|
|
510
|
+
const cloneDir = join3(temporaryRoot, "repository");
|
|
511
|
+
console.log(` \u{1F4E6} Cloning ${source.owner}/${source.repo}...`);
|
|
512
|
+
try {
|
|
513
|
+
await cloneGitHubRepository(source, cloneDir);
|
|
514
|
+
return migrateRepository({
|
|
515
|
+
repositoryDir: cloneDir,
|
|
516
|
+
sourceUrl: options.sourceUrl,
|
|
517
|
+
docsDir: options.docsDir ?? (source.docsDir || void 0),
|
|
518
|
+
platform: options.platform
|
|
519
|
+
});
|
|
520
|
+
} finally {
|
|
521
|
+
rmSync(temporaryRoot, { recursive: true, force: true });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
async function migrateDocs(options) {
|
|
525
|
+
const projectDir = resolve(options.projectDir);
|
|
526
|
+
const bundle = await discoverMigration(options);
|
|
527
|
+
if (!options.into) {
|
|
528
|
+
console.log(`
|
|
529
|
+
\u{1F3D7} Scaffolding new project at ${projectDir}...`);
|
|
530
|
+
await scaffold({
|
|
531
|
+
projectDir,
|
|
532
|
+
projectName: options.projectName ?? bundle.site?.name ?? "My Docs",
|
|
533
|
+
description: bundle.site?.description ?? `Documentation migrated from ${new URL(options.sourceUrl).hostname}`,
|
|
534
|
+
brandPreset: "primary",
|
|
535
|
+
// Source provenance is not the destination repository. Migrated pages
|
|
536
|
+
// live at new paths, so source URLs cannot power edit/issue actions.
|
|
537
|
+
repoUrl: "",
|
|
538
|
+
doInstall: false
|
|
539
|
+
});
|
|
540
|
+
resetFreshMigrationContent(projectDir);
|
|
541
|
+
} else if (!existsSync3(projectDir)) {
|
|
542
|
+
throw new Error(`Project directory "${projectDir}" does not exist. Use without --into to scaffold a new one.`);
|
|
543
|
+
}
|
|
544
|
+
if (!options.into) {
|
|
545
|
+
const starterConfig = readExistingConfig(projectDir);
|
|
546
|
+
if (starterConfig?.markdown) bundle.docsConfig.markdown = starterConfig.markdown;
|
|
547
|
+
bundle.docsConfig.i18n ??= { defaultLocale: "en", locales: [{ code: "en", label: "English" }] };
|
|
548
|
+
}
|
|
549
|
+
const rendered = renderMigrationFiles(bundle, {
|
|
550
|
+
existingConfig: options.into ? readExistingConfig(projectDir) : void 0,
|
|
551
|
+
existingComponentRegistry: existsSync3(projectPath(projectDir, "src/mdx/custom-components.tsx")) ? readFileSync3(projectPath(projectDir, "src/mdx/custom-components.tsx"), "utf8") : void 0
|
|
552
|
+
});
|
|
553
|
+
for (const file of rendered) {
|
|
554
|
+
const destination = projectPath(projectDir, file.path);
|
|
555
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
556
|
+
writeFileSync(destination, file.content);
|
|
557
|
+
}
|
|
558
|
+
for (const warning of bundle.warnings) {
|
|
559
|
+
console.warn(` \u26A0 ${warning.message}${warning.source ? ` (${warning.source})` : ""}`);
|
|
560
|
+
}
|
|
561
|
+
console.log(` \u2713 Imported ${bundle.pages.length} pages and ${bundle.assets.length} assets from ${bundle.platform}.`);
|
|
562
|
+
let installationFailed = false;
|
|
563
|
+
if (!options.skipValidation) console.log(" Migration validation runs project code locally, including imported MDX and components.");
|
|
564
|
+
if (!options.into && !options.skipValidation) {
|
|
565
|
+
try {
|
|
566
|
+
installDeps(projectDir);
|
|
567
|
+
} catch {
|
|
568
|
+
installationFailed = true;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
console.log("\n Validating imported documentation...");
|
|
572
|
+
const validation = await validateMigration(projectDir, options.skipValidation, installationFailed);
|
|
573
|
+
const reportPath = projectPath(projectDir, "migration-report.json");
|
|
574
|
+
writeFileSync(reportPath, `${JSON.stringify({
|
|
575
|
+
version: 1,
|
|
576
|
+
sourceUrl: `${new URL(options.sourceUrl).origin}${new URL(options.sourceUrl).pathname}`,
|
|
577
|
+
platform: bundle.platform,
|
|
578
|
+
pages: bundle.pages.length,
|
|
579
|
+
assets: bundle.assets.length,
|
|
580
|
+
components: bundle.componentFiles?.length ?? 0,
|
|
581
|
+
warnings: bundle.warnings,
|
|
582
|
+
validation
|
|
583
|
+
}, null, 2)}
|
|
584
|
+
`);
|
|
585
|
+
for (const message of validation.messages) console.warn(` \u26A0 ${message}`);
|
|
586
|
+
console.log(` Migration report: ${reportPath}`);
|
|
587
|
+
if (validation.content === "passed" && validation.build === "passed") {
|
|
588
|
+
console.log(` \u2713 Content and production build passed.${bundle.warnings.length ? " Review the migration warnings for compatibility limitations." : ""}`);
|
|
589
|
+
} else {
|
|
590
|
+
console.warn(" Import retained, but validation is incomplete. Do not publish without reviewing the report.");
|
|
591
|
+
}
|
|
592
|
+
if (!options.into) initGit(projectDir);
|
|
593
|
+
return {
|
|
594
|
+
pagesWritten: bundle.pages.length,
|
|
595
|
+
assetsWritten: bundle.assets.length,
|
|
596
|
+
projectDir,
|
|
597
|
+
platform: bundle.platform,
|
|
598
|
+
warnings: bundle.warnings,
|
|
599
|
+
validation,
|
|
600
|
+
reportPath
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export {
|
|
605
|
+
parseFrontmatter,
|
|
606
|
+
runCheck,
|
|
607
|
+
migrateDocs
|
|
608
|
+
};
|