@thallylabs/migrate 0.1.0 → 0.1.2

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 CHANGED
@@ -29,7 +29,6 @@ function normalizeMdx(body) {
29
29
  function parseMarkdownPage(input) {
30
30
  const parsed = matter(input.raw);
31
31
  const body = normalizeMdx(parsed.content).trim();
32
- if (parsed.data.openapi && !body) return null;
33
32
  const keywords = Array.isArray(parsed.data.keywords) ? parsed.data.keywords.filter((value) => typeof value === "string") : [];
34
33
  const title = typeof parsed.data.title === "string" && parsed.data.title.trim() ? parsed.data.title.trim() : titleFromId(input.navigationId ?? input.id);
35
34
  const description = typeof parsed.data.description === "string" && parsed.data.description.trim() ? parsed.data.description.trim() : firstParagraph(body);
@@ -40,6 +39,7 @@ function parseMarkdownPage(input) {
40
39
  title,
41
40
  description,
42
41
  keywords,
42
+ openapi: typeof parsed.data.openapi === "string" ? parsed.data.openapi.trim() : void 0,
43
43
  body,
44
44
  source: input.source
45
45
  };
@@ -308,9 +308,91 @@ function projectMintlifyNavigation(config) {
308
308
  function titleCase(value) {
309
309
  return value.split(/[-_]/).map((word) => ["api", "cli", "sdk", "ui"].includes(word.toLowerCase()) ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
310
310
  }
311
- function buildNavigationFromPages(pages) {
311
+ var TOP_LEVEL_LABELS = {
312
+ "api-reference": "API Reference",
313
+ cli: "CLI",
314
+ faqs: "FAQs",
315
+ introduction: "Overview",
316
+ "mcp-server": "MCP Server",
317
+ sdk: "SDK"
318
+ };
319
+ function topLevelLabel(segment) {
320
+ return TOP_LEVEL_LABELS[segment] ?? titleCase(segment);
321
+ }
322
+ function groupsWithinSection(section, pageIds, sectionLabel = topLevelLabel(section)) {
323
+ const groups = /* @__PURE__ */ new Map();
324
+ for (const id of pageIds) {
325
+ const relative4 = id === "introduction" ? "" : id.startsWith(`${section}/`) ? id.slice(section.length + 1) : id;
326
+ const nestedSegment = relative4.includes("/") ? relative4.split("/", 1)[0] : "overview";
327
+ const group = groups.get(nestedSegment) ?? [];
328
+ group.push(id);
329
+ groups.set(nestedSegment, group);
330
+ }
331
+ return [...groups].map(([segment, pages]) => ({
332
+ group: segment === "overview" ? sectionLabel : titleCase(segment),
333
+ pages
334
+ }));
335
+ }
336
+ function preferredLandingPage(section, pageIds) {
337
+ const candidates = [
338
+ section === "introduction" ? "introduction" : void 0,
339
+ `${section}/overview`,
340
+ `${section}/introduction`,
341
+ section
342
+ ];
343
+ return candidates.find((candidate) => Boolean(candidate && pageIds.includes(candidate)));
344
+ }
345
+ function buildNavigationFromPages(pages, options = {}) {
312
346
  const ids = pages.filter((page) => !page.locale || page.locale === "en").map((page) => page.navigationId);
313
347
  const ordered = [...new Set(ids)];
348
+ if (options.topLevelTabs) {
349
+ const sourceNavigation = (options.topLevelNavigation ?? []).flatMap((entry) => {
350
+ const sectionPages = ordered.filter((id) => id === entry.section || id.startsWith(`${entry.section}/`));
351
+ const pageId = ordered.includes(entry.pageId) ? entry.pageId : preferredLandingPage(entry.section, sectionPages) ?? sectionPages[0];
352
+ return pageId ? [{ ...entry, pageId }] : [];
353
+ });
354
+ if (sourceNavigation.length > 1) {
355
+ const claimedIds = /* @__PURE__ */ new Set();
356
+ const tabs = sourceNavigation.map((entry) => {
357
+ const pageIds = ordered.filter((id) => {
358
+ const matches = id === entry.pageId || id === entry.section || id.startsWith(`${entry.section}/`);
359
+ if (matches) claimedIds.add(id);
360
+ return matches;
361
+ });
362
+ return { entry, pageIds };
363
+ });
364
+ tabs[0].pageIds.push(...ordered.filter((id) => !claimedIds.has(id)));
365
+ return {
366
+ tabs: tabs.map(({ entry, pageIds }) => ({
367
+ tab: entry.label,
368
+ href: entry.pageId === "introduction" ? "/" : `/${entry.pageId}`,
369
+ groups: groupsWithinSection(entry.section, [...new Set(pageIds)], entry.label)
370
+ }))
371
+ };
372
+ }
373
+ const sectionNames = [...new Set(ordered.filter((id) => id.includes("/")).map((id) => id.split("/", 1)[0]))];
374
+ if (sectionNames.length > 1) {
375
+ const defaultSection = sectionNames.includes("introduction") ? "introduction" : sectionNames[0];
376
+ const sections = /* @__PURE__ */ new Map();
377
+ for (const id of ordered) {
378
+ const section = id.includes("/") ? id.split("/", 1)[0] : defaultSection;
379
+ const bucket = sections.get(section) ?? [];
380
+ bucket.push(id);
381
+ sections.set(section, bucket);
382
+ }
383
+ return {
384
+ tabs: [...sections].map(([section, pageIds]) => {
385
+ const label = topLevelLabel(section);
386
+ const landingPage = preferredLandingPage(section, pageIds);
387
+ return {
388
+ tab: label,
389
+ ...landingPage ? { href: landingPage === "introduction" ? "/" : `/${landingPage}` } : {},
390
+ groups: groupsWithinSection(section, pageIds)
391
+ };
392
+ })
393
+ };
394
+ }
395
+ }
314
396
  const buckets = /* @__PURE__ */ new Map();
315
397
  for (const id of ordered) {
316
398
  const segment = id.includes("/") ? id.split("/", 1)[0] : "overview";
@@ -700,6 +782,7 @@ function renderPage(bundle, page) {
700
782
  `title: ${yamlString(page.title)}`,
701
783
  `description: ${yamlString(page.description)}`,
702
784
  page.keywords.length > 0 ? `keywords: [${page.keywords.map(yamlString).join(", ")}]` : null,
785
+ page.openapi ? `openapi: ${yamlString(page.openapi)}` : null,
703
786
  bundle.sourceKind === "url" ? `source: ${yamlString(page.source)}` : null,
704
787
  "---",
705
788
  "",
@@ -778,6 +861,7 @@ function renderMigrationFiles(bundle, options = {}) {
778
861
  // src/url.ts
779
862
  import { load } from "cheerio";
780
863
  import TurndownService from "turndown";
864
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
781
865
  var DEFAULT_MAX_PAGES = 1e3;
782
866
  var DEFAULT_MAX_TOTAL_BYTES = 1e8;
783
867
  var MAX_DISCOVERED_URLS = 5e3;
@@ -857,8 +941,12 @@ var LOCALE_CODES = /* @__PURE__ */ new Set([
857
941
  "tr",
858
942
  "uk",
859
943
  "vi",
860
- "zh"
944
+ "zh",
945
+ "pt-br",
946
+ "zh-hans",
947
+ "zh-hant"
861
948
  ]);
949
+ var PORTABLE_URL_PROPS = "href|src|img|primaryHref|secondaryHref";
862
950
  function validateMigrationUrl(value) {
863
951
  let url;
864
952
  try {
@@ -921,6 +1009,27 @@ function docsScopePath(source) {
921
1009
  const segments = path.split("/").filter(Boolean);
922
1010
  return `/${segments.length > 1 ? segments[0] : segments.join("/")}`;
923
1011
  }
1012
+ function machineIndexScopePath(source, documents) {
1013
+ const candidates = /* @__PURE__ */ new Set();
1014
+ for (const document of documents) {
1015
+ if (!document) continue;
1016
+ const references = [...headerLinks(document)];
1017
+ if (/html/i.test(document.contentType)) {
1018
+ const $ = load(document.body);
1019
+ references.push(...$("link[href], a[href]").map((_index, element) => $(element).attr("href") ?? "").get().filter((href) => /\/(?:llms(?:-full)?\.txt|sitemap\.xml)(?:[?#]|$)/i.test(href)));
1020
+ }
1021
+ for (const reference of references) {
1022
+ try {
1023
+ const indexUrl = new URL(reference, document.finalUrl);
1024
+ if (indexUrl.origin !== source.origin) continue;
1025
+ const scope = indexUrl.pathname.replace(/\/(?:llms(?:-full)?\.txt|sitemap\.xml)$/i, "") || "/";
1026
+ if (isInScope(source, source, scope)) candidates.add(scope);
1027
+ } catch {
1028
+ }
1029
+ }
1030
+ }
1031
+ return [...candidates].sort((left, right) => right.length - left.length)[0] ?? null;
1032
+ }
924
1033
  function isInScope(url, source, scopePath) {
925
1034
  if (url.origin !== source.origin || !["http:", "https:"].includes(url.protocol)) return false;
926
1035
  const path = url.pathname.replace(/\/+$/, "") || "/";
@@ -932,7 +1041,9 @@ function normalizeCandidate(value, base, source, scopePath) {
932
1041
  const url = new URL(value, base);
933
1042
  url.hash = "";
934
1043
  if (!isInScope(url, source, scopePath)) return null;
935
- if (/\.(?:avif|bmp|gif|ico|jpe?g|mp[34]|pdf|png|svg|webm|webp|zip)$/i.test(url.pathname)) return null;
1044
+ if (url.pathname.startsWith("/cdn-cgi/")) return null;
1045
+ if (/\/(?:llms(?:-full)?|robots)\.txt$/i.test(url.pathname) || /\/sitemap\.xml$/i.test(url.pathname)) return null;
1046
+ if (/\.(?:avif|bmp|gif|ico|jpe?g|mp[34]|pdf|png|svg|ya?ml|webm|webp|zip)$/i.test(url.pathname) || /\/(?:openapi|swagger|asyncapi)[^/]*\.json$/i.test(url.pathname)) return null;
936
1047
  return url;
937
1048
  } catch {
938
1049
  return null;
@@ -951,9 +1062,19 @@ function pageIdForUrl(url, scopePath) {
951
1062
  }
952
1063
  return pageIdFromReference(path.replace(/^\/+/, "") || "introduction");
953
1064
  }
1065
+ function canonicalLocalizedPageId(id) {
1066
+ const [segment, ...rest] = id.split("/");
1067
+ if (LOCALE_CODES.has(segment)) return rest.length > 0 ? id : `${segment}/introduction`;
1068
+ for (const locale of LOCALE_CODES) {
1069
+ if (segment !== `${locale}-api-reference`) continue;
1070
+ return [locale, "api-reference", ...rest].join("/");
1071
+ }
1072
+ return id;
1073
+ }
954
1074
  function detectUrlPlatform(document) {
955
- const value = `${document.body.slice(0, 2e5)} ${document.headers?.["x-powered-by"] ?? ""}`.toLowerCase();
956
- if (value.includes("mintlify") || value.includes("__mintlify")) return "mintlify";
1075
+ const headers = Object.entries(document.headers ?? {}).map(([key, value2]) => `${key}:${value2 ?? ""}`).join(" ");
1076
+ const value = `${document.body.slice(0, 2e5)} ${headers}`.toLowerCase();
1077
+ if (value.includes("__mintlify") || value.includes("/mintlify-assets/") || value.includes("x-mintlify-") || value.includes("/_mintlify/")) return "mintlify";
957
1078
  if (value.includes("docusaurus") || value.includes("__docusaurus")) return "docusaurus";
958
1079
  if (value.includes("gitbook") || value.includes("gitbook.io")) return "gitbook";
959
1080
  if (value.includes("nextra")) return "nextra";
@@ -982,10 +1103,122 @@ function withoutFencedCode(body) {
982
1103
  return fenceCharacter ? "" : line;
983
1104
  }).join("\n");
984
1105
  }
1106
+ function eventHandlerPropEnd(value, start) {
1107
+ const opening = value[start];
1108
+ if (opening === '"' || opening === "'") {
1109
+ for (let index = start + 1; index < value.length; index += 1) {
1110
+ if (value[index] === opening && value[index - 1] !== "\\") return index + 1;
1111
+ }
1112
+ return null;
1113
+ }
1114
+ if (opening !== "{") {
1115
+ const match = value.slice(start).match(/^[^\s>]+/);
1116
+ return match ? start + match[0].length : null;
1117
+ }
1118
+ let depth = 0;
1119
+ let quote = "";
1120
+ for (let index = start; index < value.length; index += 1) {
1121
+ const character = value[index];
1122
+ if (quote) {
1123
+ if (character === quote && value[index - 1] !== "\\") quote = "";
1124
+ continue;
1125
+ }
1126
+ if (character === '"' || character === "'" || character === "`") {
1127
+ quote = character;
1128
+ continue;
1129
+ }
1130
+ if (character === "{") depth += 1;
1131
+ if (character === "}") {
1132
+ depth -= 1;
1133
+ if (depth === 0) return index + 1;
1134
+ }
1135
+ }
1136
+ return null;
1137
+ }
1138
+ function stripEventHandlerProps(line) {
1139
+ const matcher = /\s+on[A-Z][A-Za-z0-9]*\s*=\s*/g;
1140
+ let cursor = 0;
1141
+ let result = "";
1142
+ for (const match of line.matchAll(matcher)) {
1143
+ const start = match.index;
1144
+ const valueStart = start + match[0].length;
1145
+ const end = eventHandlerPropEnd(line, valueStart);
1146
+ if (end === null) continue;
1147
+ result += line.slice(cursor, start);
1148
+ cursor = end;
1149
+ }
1150
+ return cursor === 0 ? line : `${result}${line.slice(cursor)}`;
1151
+ }
1152
+ function hasUnsafeUrlScheme(value) {
1153
+ const normalized = value.trim().replace(/[\u0000-\u0020]/g, "").replace(/&(?:#x0*3a|#0*58|colon);/gi, ":");
1154
+ return /^(?:javascript|data|vbscript):/i.test(normalized);
1155
+ }
1156
+ function hasUnsafePortableUrlProp(value) {
1157
+ const quotedProps = new RegExp(`\\b(?:${PORTABLE_URL_PROPS})\\s*=\\s*(['"])([^'"\\r\\n]*)\\1`, "gi");
1158
+ for (const match of value.matchAll(quotedProps)) {
1159
+ if (hasUnsafeUrlScheme(match[2])) return true;
1160
+ }
1161
+ const expressionProps = new RegExp(`\\b(?:${PORTABLE_URL_PROPS})\\s*=\\s*\\{\\s*("(?:\\\\.|[^"\\\\])*")\\s*\\}`, "gi");
1162
+ for (const match of value.matchAll(expressionProps)) {
1163
+ try {
1164
+ if (hasUnsafeUrlScheme(JSON.parse(match[1]))) return true;
1165
+ } catch {
1166
+ return true;
1167
+ }
1168
+ }
1169
+ return false;
1170
+ }
1171
+ function portableHtmlToMarkdown(value) {
1172
+ return value.replace(/<span\b[^>]*>|<\/span>/gi, "").replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (_original, attributes, label) => {
1173
+ const href = attributes.match(/\bhref\s*=\s*(['"])(.*?)\1/i)?.[2];
1174
+ const plainLabel = label.replace(/<[^>]+>/g, "").trim();
1175
+ if (!href || hasUnsafeUrlScheme(href)) return plainLabel;
1176
+ return `[${plainLabel.replace(/]/g, "\\]")}](${href})`;
1177
+ }).replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_original, level, content) => {
1178
+ return `
1179
+ ${"#".repeat(Number(level))} ${content.trim()}
1180
+ `;
1181
+ }).replace(/<p\b[^>]*>([\s\S]*?)<\/p>/gi, (_original, content) => `
1182
+ ${content.trim()}
1183
+ `).replace(/<br\s*\/?\s*>/gi, "\n").replace(/<\/?(?:article|div|main|section)\b[^>]*>/gi, "");
1184
+ }
1185
+ function transformMarkdownProse(body, transform) {
1186
+ const output = [];
1187
+ let prose = [];
1188
+ let fenceCharacter = "";
1189
+ let fenceLength = 0;
1190
+ const flushProse = () => {
1191
+ if (prose.length === 0) return;
1192
+ output.push(transform(prose.join("\n")));
1193
+ prose = [];
1194
+ };
1195
+ for (const line of body.split("\n")) {
1196
+ const fence = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
1197
+ if (fence && !fenceCharacter) {
1198
+ flushProse();
1199
+ fenceCharacter = fence[1][0];
1200
+ fenceLength = fence[1].length;
1201
+ output.push(line);
1202
+ continue;
1203
+ }
1204
+ if (fenceCharacter) {
1205
+ output.push(line);
1206
+ if (fence && fence[1][0] === fenceCharacter && fence[1].length >= fenceLength && fence[2].trim() === "") {
1207
+ fenceCharacter = "";
1208
+ fenceLength = 0;
1209
+ }
1210
+ continue;
1211
+ }
1212
+ prose.push(line);
1213
+ }
1214
+ flushProse();
1215
+ return output.join("\n");
1216
+ }
985
1217
  function sanitizeRemoteMarkdown(body) {
986
1218
  let fenceCharacter = "";
987
1219
  let fenceLength = 0;
988
- const sanitized = body.split("\n").map((line) => {
1220
+ const portableBody = transformMarkdownProse(body, portableHtmlToMarkdown);
1221
+ const sanitized = portableBody.split("\n").map((line) => {
989
1222
  const fence = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
990
1223
  if (fence && !fenceCharacter) {
991
1224
  fenceCharacter = fence[1][0];
@@ -998,12 +1231,14 @@ function sanitizeRemoteMarkdown(body) {
998
1231
  return line;
999
1232
  }
1000
1233
  if (fenceCharacter) return line;
1001
- return line.replace(/<\/?div\b[^>]*>/gi, "").replace(/<img\s+([^>]*?)\/?\s*>/gi, (original, attributes) => {
1234
+ return portableHtmlToMarkdown(stripEventHandlerProps(line.replace(/!\[([^\]]*)\]\(\s*([^)]+)\)/g, (original, alternative, destination) => {
1235
+ return hasUnsafeUrlScheme(destination.replace(/^<|>$/g, "")) ? alternative : original;
1236
+ }).replace(/<img\s+([^>]*?)\/?\s*>/gi, (original, attributes) => {
1002
1237
  const source = attributes.match(/\bsrc\s*=\s*(['"])(.*?)\1/i)?.[2];
1003
1238
  const alternative = attributes.match(/\balt\s*=\s*(['"])(.*?)\1/i)?.[2] ?? "";
1004
- if (!source || /^(?:javascript|data):/i.test(source)) return "";
1239
+ if (!source || hasUnsafeUrlScheme(source)) return "";
1005
1240
  return `![${alternative.replace(/]/g, "\\]")}](${source})`;
1006
- });
1241
+ })));
1007
1242
  }).join("\n");
1008
1243
  const executable = withoutFencedCode(sanitized).replace(/\{\/\*[\s\S]*?\*\/\}/g, "").replace(/<(?:https?:\/\/|mailto:)[^>]+>/gi, "").replace(/(`+)[\s\S]*?\1/g, "");
1009
1244
  if (/^\s*(?:import|export)\s/m.test(executable)) return null;
@@ -1017,27 +1252,35 @@ function sanitizeRemoteMarkdown(body) {
1017
1252
  return original;
1018
1253
  }
1019
1254
  }).replace(/\\[{}]/g, "");
1020
- if (hasUnsafeExpression || /[{}]/.test(withoutStaticProps) || /\bon[A-Z][A-Za-z]*\s*=/g.test(withoutStaticProps) || /\b(?:href|src)\s*=\s*(['"])\s*(?:javascript|data):/gi.test(withoutStaticProps)) return null;
1255
+ if (hasUnsafeExpression || /[{}]/.test(withoutStaticProps) || /\bon[A-Z][A-Za-z]*\s*=/g.test(withoutStaticProps) || hasUnsafePortableUrlProp(executable)) return null;
1021
1256
  for (const match of withoutStaticProps.matchAll(/<\/?([A-Za-z][A-Za-z0-9.]*)(?:\s|>|\/)/g)) {
1022
1257
  const component = match[1].split(".", 1)[0];
1023
1258
  if (!PORTABLE_MDX_COMPONENTS.has(component)) return null;
1024
1259
  }
1025
1260
  return sanitized;
1026
1261
  }
1027
- function migratedHref(href, currentUrl, source, scopePath, importedIds) {
1262
+ function migratedHref(href, currentUrl, source, scopePath, importedIds, sourceHomePageId) {
1028
1263
  if (/^(?:javascript|data|vbscript):/i.test(href)) return "#";
1029
1264
  if (href.startsWith("#") || /^(?:mailto|tel):/i.test(href)) return href;
1030
1265
  try {
1031
1266
  const target = new URL(href, currentUrl);
1032
1267
  if (target.origin !== source.origin) return href;
1268
+ if (scopePath === "/" && candidateIdentity(target) === candidateIdentity(source)) {
1269
+ return `/${target.search}${target.hash}`;
1270
+ }
1033
1271
  let path = target.pathname;
1034
1272
  if (scopePath !== "/" && (path === scopePath || path.startsWith(`${scopePath}/`))) {
1035
1273
  path = path.slice(scopePath.length);
1036
1274
  }
1037
1275
  const id = pageIdFromReference(path.replace(/^\/+/, "") || "introduction");
1038
- const localizedId = id && LOCALE_CODES.has(id) ? `${id}/introduction` : id;
1039
- if (localizedId && importedIds.has(localizedId)) {
1040
- const destination = localizedId === "introduction" ? "/" : `/${localizedId.replace(/\/introduction$/, "")}`;
1276
+ const localizedId = id ? canonicalLocalizedPageId(id) : id;
1277
+ const destinationId = localizedId === sourceHomePageId ? "introduction" : localizedId;
1278
+ let importedDestinationId = destinationId && importedIds.has(destinationId) ? destinationId : void 0;
1279
+ if (destinationId && !importedDestinationId) {
1280
+ importedDestinationId = [`${destinationId}/overview`, `${destinationId}/introduction`].find((candidate) => importedIds.has(candidate)) ?? [...importedIds].find((candidate) => candidate.startsWith(`${destinationId}/`));
1281
+ }
1282
+ if (importedDestinationId) {
1283
+ const destination = importedDestinationId === "introduction" ? "/" : `/${importedDestinationId}`;
1041
1284
  return `${destination}${target.search}${target.hash}`;
1042
1285
  }
1043
1286
  return target.toString();
@@ -1045,13 +1288,13 @@ function migratedHref(href, currentUrl, source, scopePath, importedIds) {
1045
1288
  return href;
1046
1289
  }
1047
1290
  }
1048
- function rewriteInternalLinks(body, currentUrl, source, scopePath, importedIds) {
1291
+ function rewriteInternalLinks(body, currentUrl, source, scopePath, importedIds, sourceHomePageId) {
1049
1292
  const markdown = body.replace(/(?<!!)\[([^\]]+)\]\(([^)\s]+)(\s+['"][^'"]*['"])?\)/g, (original, label, href, title = "") => {
1050
- const destination = migratedHref(href, currentUrl, source, scopePath, importedIds);
1293
+ const destination = migratedHref(href, currentUrl, source, scopePath, importedIds, sourceHomePageId);
1051
1294
  return destination === href ? original : `[${label}](${destination}${title})`;
1052
1295
  });
1053
1296
  return markdown.replace(/\bhref=(['"])([^'"]+)\1/g, (_original, quote, href) => {
1054
- const destination = migratedHref(href, currentUrl, source, scopePath, importedIds);
1297
+ const destination = migratedHref(href, currentUrl, source, scopePath, importedIds, sourceHomePageId);
1055
1298
  return `href=${quote}${destination}${quote}`;
1056
1299
  });
1057
1300
  }
@@ -1080,19 +1323,126 @@ function remoteMarkdownMetadata(body) {
1080
1323
  description: scalar("description")
1081
1324
  };
1082
1325
  }
1326
+ function stripMintlifyDocumentationIndex(body) {
1327
+ const lines = body.split(/\r?\n/);
1328
+ if (!/^>\s*##\s+Documentation Index\s*$/i.test(lines[0] ?? "") || !/^>.*\/llms(?:-full)?\.txt/i.test(lines[1] ?? "") || !/^>.*discover all available pages/i.test(lines[2] ?? "")) return body;
1329
+ let index = 3;
1330
+ while (index < lines.length && /^>/.test(lines[index])) index += 1;
1331
+ while (index < lines.length && lines[index].trim() === "") index += 1;
1332
+ return lines.slice(index).join("\n");
1333
+ }
1334
+ function extractEmbeddedOpenApi(content) {
1335
+ const fencePattern = /^\s{0,3}(`{3,}|~{3,})(?:yaml|yml|json)\s+(\S+\.(?:yaml|yml|json))\s+(delete|get|head|options|patch|post|put|trace)\s+(\/\S+)\s*\r?\n([\s\S]*?)^\s{0,3}\1\s*$/im;
1336
+ const match = fencePattern.exec(content);
1337
+ if (!match) return { content };
1338
+ try {
1339
+ const parsed = parseYaml(match[5], { maxAliasCount: 50 });
1340
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { content };
1341
+ const document = parsed;
1342
+ if (typeof document.openapi !== "string" && typeof document.swagger !== "string") return { content };
1343
+ const method = match[3].toUpperCase();
1344
+ const path = match[4];
1345
+ const paths = document.paths;
1346
+ if (!paths || typeof paths !== "object" || Array.isArray(paths)) return { content };
1347
+ const pathItem = paths[path];
1348
+ if (!pathItem || typeof pathItem !== "object" || Array.isArray(pathItem) || !(match[3].toLowerCase() in pathItem)) return { content };
1349
+ const before = content.slice(0, match.index).replace(/(?:^|\n)##\s+OpenAPI\s*\n\s*$/i, "\n");
1350
+ const after = content.slice(match.index + match[0].length);
1351
+ return {
1352
+ content: `${before}${after}`.trim(),
1353
+ openapi: `${method} ${path}`,
1354
+ fragment: {
1355
+ method,
1356
+ path,
1357
+ document
1358
+ }
1359
+ };
1360
+ } catch {
1361
+ return { content };
1362
+ }
1363
+ }
1083
1364
  function markdownPage(document, id) {
1084
1365
  const parsed = remoteMarkdownMetadata(document.body);
1085
- const titleMatch = parsed.content.match(/^#\s+(.+)$/m);
1366
+ let content = stripMintlifyDocumentationIndex(parsed.content).trimStart();
1367
+ const titleMatch = content.match(/^#\s+(.+)\r?\n/);
1086
1368
  const title = parsed.title ?? titleMatch?.[1] ?? id.split("/").at(-1) ?? "Introduction";
1369
+ if (titleMatch) content = content.slice(titleMatch[0].length).trimStart();
1370
+ let description = parsed.description;
1371
+ if (!description) {
1372
+ const quoteMatch = content.match(/^(>[^\r\n]*(?:\r?\n>[^\r\n]*)*)\r?\n/);
1373
+ if (quoteMatch) {
1374
+ description = plainDescription(quoteMatch[1].replace(/^>\s?/gm, ""));
1375
+ content = content.slice(quoteMatch[0].length).trimStart();
1376
+ }
1377
+ }
1378
+ const embeddedOpenApi = extractEmbeddedOpenApi(content);
1087
1379
  const raw = [
1088
1380
  "---",
1089
1381
  `title: ${JSON.stringify(title)}`,
1090
- ...parsed.description ? [`description: ${JSON.stringify(parsed.description)}`] : [],
1382
+ ...description ? [`description: ${JSON.stringify(description)}`] : [],
1383
+ ...embeddedOpenApi.openapi ? [`openapi: ${JSON.stringify(embeddedOpenApi.openapi)}`] : [],
1091
1384
  "---",
1092
1385
  "",
1093
- parsed.content
1386
+ embeddedOpenApi.content
1094
1387
  ].join("\n");
1095
- return parseMarkdownPage({ id, raw, source: document.finalUrl.toString() });
1388
+ return {
1389
+ page: parseMarkdownPage({ id, raw, source: document.finalUrl.toString() }),
1390
+ openApiFragment: embeddedOpenApi.fragment
1391
+ };
1392
+ }
1393
+ function mergeRecord(target, source) {
1394
+ if (!source || typeof source !== "object" || Array.isArray(source)) return;
1395
+ for (const [key, value] of Object.entries(source)) {
1396
+ if (["__proto__", "constructor", "prototype"].includes(key)) continue;
1397
+ if (!Object.prototype.hasOwnProperty.call(target, key)) {
1398
+ target[key] = value;
1399
+ continue;
1400
+ }
1401
+ const current = target[key];
1402
+ if (current && value && typeof current === "object" && typeof value === "object" && !Array.isArray(current) && !Array.isArray(value)) {
1403
+ mergeRecord(current, value);
1404
+ }
1405
+ }
1406
+ }
1407
+ function mergeEmbeddedOpenApi(fragments) {
1408
+ const first = fragments[0];
1409
+ if (!first) return null;
1410
+ const combined = {
1411
+ ...first.document,
1412
+ paths: {},
1413
+ components: {}
1414
+ };
1415
+ const combinedPaths = combined.paths;
1416
+ const combinedComponents = combined.components;
1417
+ const tags = /* @__PURE__ */ new Map();
1418
+ for (const fragment of fragments) {
1419
+ const sourcePaths = fragment.document.paths;
1420
+ const sourcePathItem = sourcePaths?.[fragment.path];
1421
+ if (!sourcePathItem || typeof sourcePathItem !== "object" || Array.isArray(sourcePathItem)) continue;
1422
+ const sourceOperation = sourcePathItem[fragment.method.toLowerCase()];
1423
+ if (!sourceOperation || typeof sourceOperation !== "object" || Array.isArray(sourceOperation)) continue;
1424
+ const operation = { ...sourceOperation };
1425
+ if (!operation.servers && Array.isArray(fragment.document.servers)) operation.servers = fragment.document.servers;
1426
+ if (!operation.security && Array.isArray(fragment.document.security)) operation.security = fragment.document.security;
1427
+ const existingPath = combinedPaths[fragment.path];
1428
+ const pathItem = existingPath && typeof existingPath === "object" && !Array.isArray(existingPath) ? existingPath : {};
1429
+ for (const sharedKey of ["parameters", "servers", "summary", "description"]) {
1430
+ const sharedValue = sourcePathItem[sharedKey];
1431
+ if (sharedValue !== void 0 && pathItem[sharedKey] === void 0) pathItem[sharedKey] = sharedValue;
1432
+ }
1433
+ pathItem[fragment.method.toLowerCase()] = operation;
1434
+ combinedPaths[fragment.path] = pathItem;
1435
+ mergeRecord(combinedComponents, fragment.document.components);
1436
+ for (const tag of Array.isArray(fragment.document.tags) ? fragment.document.tags : []) {
1437
+ if (!tag || typeof tag !== "object" || Array.isArray(tag)) continue;
1438
+ const name = tag.name;
1439
+ if (typeof name === "string" && !tags.has(name)) tags.set(name, tag);
1440
+ }
1441
+ }
1442
+ if (Object.keys(combinedPaths).length === 0) return null;
1443
+ if (Object.keys(combinedComponents).length === 0) delete combined.components;
1444
+ if (tags.size > 0) combined.tags = [...tags.values()];
1445
+ return new TextEncoder().encode(stringifyYaml(combined));
1096
1446
  }
1097
1447
  function htmlPage(document, id) {
1098
1448
  const $ = load(document.body);
@@ -1166,6 +1516,48 @@ ${fence}
1166
1516
  links
1167
1517
  };
1168
1518
  }
1519
+ function htmlNavigationLinks(document) {
1520
+ const $ = load(document.body);
1521
+ return $('.nav-tabs a[href], nav a[href], aside a[href], [role="navigation"] a[href]').map((_index, element) => $(element).attr("href") ?? "").get().filter(Boolean);
1522
+ }
1523
+ function htmlTopLevelNavigation(document, source, scopePath) {
1524
+ const $ = load(document.body);
1525
+ const collectCandidates = (selector) => {
1526
+ const candidates2 = [];
1527
+ $(selector).each((_containerIndex, container) => {
1528
+ const entries = [];
1529
+ const seenSections = /* @__PURE__ */ new Set();
1530
+ let anchorCount = 0;
1531
+ $(container).find("a[href]").each((_index, element) => {
1532
+ const label = $(element).text().replace(/\s+/g, " ").trim();
1533
+ const href = $(element).attr("href");
1534
+ if (!label || !href) return;
1535
+ try {
1536
+ const target = new URL(href, document.finalUrl);
1537
+ if (!isInScope(target, source, scopePath)) return;
1538
+ anchorCount += 1;
1539
+ const targetId = canonicalLocalizedPageId(pageIdForUrl(target, scopePath) ?? "");
1540
+ const pageId = targetId;
1541
+ const section = targetId.split("/", 1)[0] || "introduction";
1542
+ if (!pageId || seenSections.has(section)) return;
1543
+ seenSections.add(section);
1544
+ entries.push({ section, label, pageId });
1545
+ } catch {
1546
+ }
1547
+ });
1548
+ if (entries.length > 1) {
1549
+ candidates2.push({ entries, anchorCount });
1550
+ }
1551
+ });
1552
+ return candidates2;
1553
+ };
1554
+ const tabCandidates = collectCandidates(".nav-tabs");
1555
+ const candidates = tabCandidates.length > 0 ? tabCandidates : collectCandidates('nav, [role="navigation"]');
1556
+ return candidates.sort((left, right) => {
1557
+ const densityDifference = right.entries.length / right.anchorCount - left.entries.length / left.anchorCount;
1558
+ return densityDifference || right.entries.length - left.entries.length;
1559
+ })[0]?.entries ?? [];
1560
+ }
1169
1561
  function markdownLinks(body) {
1170
1562
  return [...body.matchAll(/\[[^\]]+\]\(([^)\s]+)(?:\s+['"][^'"]*['"])?\)/g)].map((match) => match[1]);
1171
1563
  }
@@ -1199,12 +1591,17 @@ async function migrateUrl(options) {
1199
1591
  const maxPages = Math.max(1, Math.min(options.maxPages ?? DEFAULT_MAX_PAGES, 1e3));
1200
1592
  const concurrency = Math.max(1, Math.min(options.concurrency ?? 5, 10));
1201
1593
  const maxTotalBytes = Math.max(1e6, Math.min(options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES, 5e8));
1202
- const scopePath = docsScopePath(source);
1594
+ const submittedScopePath = docsScopePath(source);
1203
1595
  const initial = await fetcher(source, { accept: "text/markdown,text/html,application/xhtml+xml;q=0.9" });
1204
- if (!isInScope(initial.finalUrl, source, scopePath)) {
1596
+ if (!isInScope(initial.finalUrl, source, submittedScopePath)) {
1205
1597
  throw new Error("The documentation URL redirected outside the submitted docs origin or path.");
1206
1598
  }
1207
- const platform = detectUrlPlatform(initial);
1599
+ const htmlProbe = /html/i.test(initial.contentType) ? initial : await safeFetch(fetcher, source, "text/html,application/xhtml+xml");
1600
+ const scopedHtmlProbe = htmlProbe && isInScope(htmlProbe.finalUrl, source, submittedScopePath) ? htmlProbe : null;
1601
+ const platform = detectUrlPlatform(scopedHtmlProbe ?? initial);
1602
+ const scopePath = platform === "mintlify" ? machineIndexScopePath(source, [initial, scopedHtmlProbe]) ?? submittedScopePath : submittedScopePath;
1603
+ const sourceTopLevelNavigation = platform === "mintlify" && scopedHtmlProbe && /html/i.test(scopedHtmlProbe.contentType) ? htmlTopLevelNavigation(scopedHtmlProbe, source, scopePath) : void 0;
1604
+ const sourceHomePageId = sourceTopLevelNavigation?.[0]?.pageId ?? (platform === "mintlify" && scopePath === "/" ? canonicalLocalizedPageId(pageIdForUrl(source, scopePath) ?? "") || void 0 : void 0);
1208
1605
  const cache = /* @__PURE__ */ new Map([[source.toString(), initial]]);
1209
1606
  const queue = [source];
1210
1607
  const queued = /* @__PURE__ */ new Set([candidateIdentity(source)]);
@@ -1217,6 +1614,9 @@ async function migrateUrl(options) {
1217
1614
  queued.add(candidateIdentity(url));
1218
1615
  queue.push(url);
1219
1616
  }
1617
+ if (scopedHtmlProbe && /html/i.test(scopedHtmlProbe.contentType)) {
1618
+ for (const link of htmlNavigationLinks(scopedHtmlProbe)) enqueue(link, scopedHtmlProbe.finalUrl);
1619
+ }
1220
1620
  async function discoverDocumentLinks(document, depth = 0) {
1221
1621
  const links = sitemapLinks(document.body);
1222
1622
  for (const link of links) {
@@ -1282,7 +1682,9 @@ async function migrateUrl(options) {
1282
1682
  }
1283
1683
  }
1284
1684
  const pages = [];
1685
+ const openApiFragments = [];
1285
1686
  const seenIds = /* @__PURE__ */ new Set();
1687
+ const seenSources = /* @__PURE__ */ new Set();
1286
1688
  const visited = /* @__PURE__ */ new Set();
1287
1689
  let importedBytes = 0;
1288
1690
  let isByteBudgetExhausted = false;
@@ -1320,6 +1722,8 @@ async function migrateUrl(options) {
1320
1722
  const htmlDocument = await safeFetch(fetcher, htmlUrl, "text/html,application/xhtml+xml");
1321
1723
  if (htmlDocument && isInScope(htmlDocument.finalUrl, source, scopePath) && /html/i.test(htmlDocument.contentType)) {
1322
1724
  document = htmlDocument;
1725
+ } else {
1726
+ return { candidate, page: null, links: [], failure: false };
1323
1727
  }
1324
1728
  } else if (sanitizedMarkdown !== null) {
1325
1729
  document = { ...document, body: sanitizedMarkdown };
@@ -1330,12 +1734,15 @@ async function migrateUrl(options) {
1330
1734
  return { candidate, page: null, links: [], failure: false, budgetExceeded: true };
1331
1735
  }
1332
1736
  importedBytes += responseBytes;
1333
- const id = pageIdForUrl(document.finalUrl, scopePath) ?? pageIdForUrl(candidate, scopePath);
1737
+ const discoveredId = pageIdForUrl(document.finalUrl, scopePath) ?? pageIdForUrl(candidate, scopePath);
1738
+ const id = discoveredId && canonicalLocalizedPageId(discoveredId) === sourceHomePageId ? "introduction" : discoveredId;
1334
1739
  if (!id) return { candidate, page: null, links: [], failure: false };
1335
1740
  if (/(?:markdown|text\/plain)/i.test(document.contentType) || /\.mdx?$/i.test(document.finalUrl.pathname)) {
1741
+ const markdown = markdownPage(document, id);
1336
1742
  return {
1337
1743
  candidate,
1338
- page: markdownPage(document, id),
1744
+ page: markdown.page,
1745
+ openApiFragment: markdown.openApiFragment,
1339
1746
  links: markdownLinks(document.body),
1340
1747
  base: document.finalUrl,
1341
1748
  failure: false
@@ -1350,9 +1757,15 @@ async function migrateUrl(options) {
1350
1757
  continue;
1351
1758
  }
1352
1759
  if (result.budgetExceeded) continue;
1353
- if (result.page && !seenIds.has(result.page.id) && pages.length < maxPages) {
1760
+ if (result.page) result.page.id = canonicalLocalizedPageId(result.page.id);
1761
+ const sourceIdentity = result.page ? candidateIdentity(new URL(result.page.source)) : null;
1762
+ if (result.page && sourceIdentity && !seenSources.has(sourceIdentity) && !seenIds.has(result.page.id) && pages.length < maxPages) {
1354
1763
  pages.push(result.page);
1764
+ if ("openApiFragment" in result && result.openApiFragment) {
1765
+ openApiFragments.push(result.openApiFragment);
1766
+ }
1355
1767
  seenIds.add(result.page.id);
1768
+ seenSources.add(sourceIdentity);
1356
1769
  }
1357
1770
  for (const link of result.links) enqueue(link, result.base ?? result.candidate);
1358
1771
  }
@@ -1368,14 +1781,41 @@ async function migrateUrl(options) {
1368
1781
  }
1369
1782
  const importedIds = new Set(pages.map((page) => page.id));
1370
1783
  for (const page of pages) {
1371
- page.body = rewriteInternalLinks(page.body, new URL(page.source), source, scopePath, importedIds);
1784
+ page.body = rewriteInternalLinks(
1785
+ page.body,
1786
+ new URL(page.source),
1787
+ source,
1788
+ scopePath,
1789
+ importedIds,
1790
+ sourceHomePageId
1791
+ );
1372
1792
  }
1373
1793
  if (isByteBudgetExhausted) {
1374
1794
  warnings.push({ code: "limit-reached", message: `Import stopped after reaching the ${Math.round(maxTotalBytes / 1e6)} MB content budget.` });
1375
1795
  } else if (queue.length > 0 || queued.size >= MAX_DISCOVERED_URLS) {
1376
1796
  warnings.push({ code: "limit-reached", message: `Import stopped after ${pages.length} pages; narrow the URL or raise the caller limit to import more.` });
1377
1797
  }
1378
- const docsConfig = buildNavigationFromPages(pages);
1798
+ const topLevelNavigation = sourceTopLevelNavigation?.map((entry) => ({
1799
+ ...entry,
1800
+ pageId: entry.pageId === sourceHomePageId ? "introduction" : entry.pageId
1801
+ }));
1802
+ const docsConfig = buildNavigationFromPages(pages, {
1803
+ topLevelTabs: platform === "mintlify",
1804
+ topLevelNavigation
1805
+ });
1806
+ const openApiAsset = mergeEmbeddedOpenApi(openApiFragments);
1807
+ if (openApiAsset) {
1808
+ const operationPageIds = new Set(pages.filter((page) => page.openapi).map((page) => page.navigationId));
1809
+ const apiTab = docsConfig.tabs.find((tab) => tab.groups?.some((group) => {
1810
+ const containsOperation = (pages2) => pages2.some((page) => {
1811
+ return typeof page === "string" ? operationPageIds.has(page) : containsOperation(page.pages);
1812
+ });
1813
+ return containsOperation(group.pages);
1814
+ })) ?? docsConfig.tabs[0];
1815
+ if (apiTab) {
1816
+ apiTab.api = { source: "/openapi.yaml", navigation: false };
1817
+ }
1818
+ }
1379
1819
  const locales = [...new Set(pages.map((page) => page.locale).filter((value) => Boolean(value)))];
1380
1820
  if (locales.length > 0) {
1381
1821
  docsConfig.i18n = {
@@ -1391,7 +1831,7 @@ async function migrateUrl(options) {
1391
1831
  sourceKind: "url",
1392
1832
  platform,
1393
1833
  pages,
1394
- assets: [],
1834
+ assets: openApiAsset ? [{ path: "openapi.yaml", content: openApiAsset }] : [],
1395
1835
  docsConfig,
1396
1836
  warnings,
1397
1837
  stats: {