@thallylabs/migrate 0.2.32 → 0.2.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/dist/index.js CHANGED
@@ -2393,9 +2393,9 @@ function escapeOrphanCapitalizedTags(body) {
2393
2393
  continue;
2394
2394
  }
2395
2395
  const start = match.index;
2396
- const list = opens.get(name) ?? [];
2397
- list.push({ start, end: start + full.length });
2398
- opens.set(name, list);
2396
+ const list2 = opens.get(name) ?? [];
2397
+ list2.push({ start, end: start + full.length });
2398
+ opens.set(name, list2);
2399
2399
  }
2400
2400
  const edits = [];
2401
2401
  for (const [name, ranges] of opens) {
@@ -4238,24 +4238,25 @@ import { existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as rea
4238
4238
  import { dirname as dirname2, extname as extname3, relative as relative3 } from "path";
4239
4239
  var MAX_MINTLIFY_CONFIG_BYTES = 2e7;
4240
4240
  function addMintlifyDirectoryRedirects(config, pages) {
4241
- const pageIds = new Set(pages.map((page) => page.id));
4241
+ const pageIds = new Set(pages.map((page) => page.id.toLowerCase()));
4242
4242
  const redirects = [...config.redirects ?? []];
4243
- const redirectSources = new Set(redirects.map((redirect) => redirect.source.replace(/\/$/, "") || "/"));
4243
+ const redirectSources = new Set(redirects.map((redirect) => (redirect.source.replace(/\/$/, "") || "/").toLowerCase()));
4244
4244
  function hasAuthoredRedirect(source) {
4245
- if (redirectSources.has(source)) return true;
4245
+ const route = source.toLowerCase();
4246
+ if (redirectSources.has(route)) return true;
4246
4247
  return (config.redirects ?? []).some((redirect) => {
4247
- const match = redirect.source.match(/^(.*)\/:[A-Za-z_][A-Za-z0-9_]*\*\/?$/);
4248
- return match ? source === match[1] || source.startsWith(`${match[1]}/`) : false;
4248
+ const match = redirect.source.toLowerCase().match(/^(.*)\/:[a-z_][a-z0-9_]*\*\/?$/);
4249
+ return match ? route === match[1] || route.startsWith(`${match[1]}/`) : false;
4249
4250
  });
4250
4251
  }
4251
4252
  for (const page of pages) {
4252
4253
  if (!/\/(?:overview|introduction)$/.test(page.navigationId)) continue;
4253
4254
  const parent = page.id.replace(/\/(?:overview|introduction)$/, "");
4254
- if (!parent || pageIds.has(parent)) continue;
4255
+ if (!parent || pageIds.has(parent.toLowerCase())) continue;
4255
4256
  const source = `/${parent}`;
4256
4257
  if (hasAuthoredRedirect(source)) continue;
4257
4258
  redirects.push({ source, destination: `/${page.id}`, permanent: false });
4258
- redirectSources.add(source);
4259
+ redirectSources.add(source.toLowerCase());
4259
4260
  }
4260
4261
  const navigationOrder = [];
4261
4262
  function collectPages(nodes) {
@@ -4285,11 +4286,11 @@ function addMintlifyDirectoryRedirects(config, pages) {
4285
4286
  }
4286
4287
  }
4287
4288
  for (const [directory, landing] of firstDescendantByDirectory) {
4288
- if (pageIds.has(directory)) continue;
4289
+ if (pageIds.has(directory.toLowerCase())) continue;
4289
4290
  const source = `/${directory}`;
4290
4291
  if (hasAuthoredRedirect(source)) continue;
4291
4292
  redirects.push({ source, destination: `/${landing.id}`, permanent: false });
4292
- redirectSources.add(source);
4293
+ redirectSources.add(source.toLowerCase());
4293
4294
  }
4294
4295
  return redirects.length > 0 ? { ...config, redirects } : config;
4295
4296
  }
@@ -4308,11 +4309,11 @@ function addMintlifyHomepageRedirects(config, pages) {
4308
4309
  const homepage = config.tabs.filter((tab) => !tab.hidden).map((tab) => firstPage([...tab.pages ?? [], ...tab.groups ?? []])).find(Boolean);
4309
4310
  if (!homepage) return config;
4310
4311
  const redirects = [...config.redirects ?? []];
4311
- const sources = new Set(redirects.map((redirect) => redirect.source.replace(/\/$/, "") || "/"));
4312
+ const sources = new Set(redirects.map((redirect) => (redirect.source.replace(/\/$/, "") || "/").toLowerCase()));
4312
4313
  const roots = ["", ...(config.i18n?.locales ?? []).filter((locale) => locale.code !== config.i18n?.defaultLocale).filter((locale) => homepage !== "introduction" || pages.some((page) => page.locale === locale.code && page.navigationId === homepage)).map((locale) => locale.code)];
4313
4314
  for (const locale of roots) {
4314
4315
  const source = locale ? `/${locale}` : "/";
4315
- if (sources.has(source)) continue;
4316
+ if (sources.has(source.toLowerCase())) continue;
4316
4317
  redirects.push({ source, destination: `${locale ? `/${locale}` : ""}/${homepage}`, permanent: true });
4317
4318
  }
4318
4319
  return { ...config, redirects };
@@ -4439,6 +4440,22 @@ function specSource(value) {
4439
4440
  const source = typeof value === "string" ? value : objectValue2(value)?.source;
4440
4441
  return typeof source === "string" && source.trim() ? source.trim() : void 0;
4441
4442
  }
4443
+ function listedOperations(container) {
4444
+ const operations = [];
4445
+ const visit = (entry, root) => {
4446
+ if (typeof entry === "string") {
4447
+ if (OPENAPI_OPERATION_REF.test(entry)) operations.push(entry.trim());
4448
+ return;
4449
+ }
4450
+ const object2 = objectValue2(entry);
4451
+ if (!object2 || !root && (specSource(object2.openapi) || specSource(object2.asyncapi))) return;
4452
+ for (const key of ["groups", "pages"]) {
4453
+ if (Array.isArray(object2[key])) object2[key].forEach((child) => visit(child, false));
4454
+ }
4455
+ };
4456
+ visit(container, true);
4457
+ return operations;
4458
+ }
4442
4459
  var hasNavigationChildren = (node) => ["groups", "pages"].some((key) => Array.isArray(node[key]) && node[key].length > 0);
4443
4460
  function mintlifyNavigationApiReferences(config) {
4444
4461
  const navigation = objectValue2(config.navigation) ?? config;
@@ -4450,12 +4467,14 @@ function mintlifyNavigationApiReferences(config) {
4450
4467
  const value = specSource(node[kind]);
4451
4468
  const directory = objectValue2(node[kind])?.directory;
4452
4469
  if (value) {
4470
+ const operations = typeof directory === "string" && directory.trim() ? listedOperations(node) : [];
4453
4471
  references.push({
4454
4472
  value,
4455
4473
  kind,
4456
4474
  tabLabel,
4457
4475
  ...sibling,
4458
- ...typeof directory === "string" && directory.trim() ? { directory: directory.trim() } : {}
4476
+ ...typeof directory === "string" && directory.trim() ? { directory: directory.trim() } : {},
4477
+ ...operations.length > 0 ? { operations } : {}
4459
4478
  });
4460
4479
  }
4461
4480
  }
@@ -7206,16 +7225,32 @@ function withheldOperationKeys(spec, withheldRefs, keptRefs) {
7206
7225
  const kept = keysFor(spec, keptRefs);
7207
7226
  return { withheld: keysFor(spec, withheldRefs).filter((key) => !kept.includes(key)), kept };
7208
7227
  }
7228
+ function withoutByteOrderMark(content) {
7229
+ return content.length >= 3 && content[0] === 239 && content[1] === 187 && content[2] === 191 ? content.subarray(3) : content;
7230
+ }
7231
+ function parseSpecDocument(text3) {
7232
+ const source = text3.replace(/^\uFEFF/, "");
7233
+ try {
7234
+ return { format: "json", document: JSON.parse(source) };
7235
+ } catch (jsonError) {
7236
+ try {
7237
+ return { format: "yaml", document: parseYaml4(source) };
7238
+ } catch (yamlError) {
7239
+ throw source.trimStart().startsWith("{") ? jsonError : yamlError;
7240
+ }
7241
+ }
7242
+ }
7209
7243
  function markExcluded(content, operationKeys, keptKeys = []) {
7210
7244
  const untouched = { content, excluded: [] };
7211
7245
  if (operationKeys.length === 0) return untouched;
7212
7246
  const text3 = content.toString("utf8");
7213
- let doc;
7247
+ let parsed;
7214
7248
  try {
7215
- doc = parseYaml4(text3);
7249
+ parsed = parseSpecDocument(text3);
7216
7250
  } catch {
7217
7251
  return untouched;
7218
7252
  }
7253
+ const doc = parsed.document;
7219
7254
  if (!doc || typeof doc !== "object") return untouched;
7220
7255
  const excluded = [];
7221
7256
  for (const key of operationKeys) {
@@ -7238,7 +7273,7 @@ function markExcluded(content, operationKeys, keptKeys = []) {
7238
7273
  }
7239
7274
  }
7240
7275
  if (excluded.length === 0) return untouched;
7241
- const out = text3.trimStart().startsWith("{") ? `${JSON.stringify(doc, null, 2)}
7276
+ const out = parsed.format === "json" ? `${JSON.stringify(doc, null, 2)}
7242
7277
  ` : stringifyYaml(doc);
7243
7278
  return { content: Buffer.from(out), excluded };
7244
7279
  }
@@ -7469,6 +7504,150 @@ function migrationImageAttribute(value) {
7469
7504
  return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\{/g, "&#123;").replace(/\}/g, "&#125;").replace(/\r/g, "&#13;").replace(/\n/g, "&#10;");
7470
7505
  }
7471
7506
 
7507
+ // src/redirects.ts
7508
+ var LISTED = 10;
7509
+ function exactRoute(route) {
7510
+ return route.startsWith("/") && !/[?:*#()[\]{}]/.test(route) ? route.replace(/\/+$/, "") || "/" : null;
7511
+ }
7512
+ function literalRoute(route) {
7513
+ return exactRoute(route)?.toLowerCase() ?? null;
7514
+ }
7515
+ function resolveDotSegments(route) {
7516
+ const segments = [];
7517
+ for (const segment of route.split("/").slice(1)) {
7518
+ const dots = segment.replace(/%2e/gi, ".");
7519
+ if (dots === "..") segments.pop();
7520
+ else if (dots !== ".") segments.push(segment);
7521
+ }
7522
+ return `/${segments.join("/")}`;
7523
+ }
7524
+ function destinationRoute(destination) {
7525
+ const route = destination.startsWith("//") ? null : exactRoute(destination.split(/[?#]/, 1)[0]);
7526
+ return route === null ? null : resolveDotSegments(route);
7527
+ }
7528
+ function pageRoutes(pages) {
7529
+ return pages.flatMap((page) => [`/${page.id}`, `/${page.id.replace(/\/index$/, "")}`]);
7530
+ }
7531
+ function servedRoutes(pages) {
7532
+ return new Set(pageRoutes(pages).map((route) => route.toLowerCase()));
7533
+ }
7534
+ function list(sources) {
7535
+ return `${sources.slice(0, LISTED).join(", ")}${sources.length > LISTED ? ", ..." : ""}`;
7536
+ }
7537
+ var ROUTE_PATTERN_OPERATORS = /[+?()[\]{}]/;
7538
+ function dropUnroutableRedirects(config, warnings) {
7539
+ if (!config.redirects?.length) return config;
7540
+ const unmatchable = [];
7541
+ const redirects = config.redirects.filter((redirect) => {
7542
+ const source = exactRoute(redirect.source);
7543
+ if (source && source === exactRoute(redirect.destination)) {
7544
+ warnings.push({ code: "unsupported-config", message: `Self-redirect from ${redirect.source} to ${redirect.destination} was omitted.` });
7545
+ return false;
7546
+ }
7547
+ if (!ROUTE_PATTERN_OPERATORS.test(redirect.source)) return true;
7548
+ unmatchable.push(redirect.source);
7549
+ return false;
7550
+ });
7551
+ if (unmatchable.length > 0) {
7552
+ warnings.push({
7553
+ code: "unsupported-config",
7554
+ message: `Skipped ${unmatchable.length} redirect${unmatchable.length === 1 ? "" : "s"} from ${list(unmatchable)}: ${unmatchable.length === 1 ? "that address has" : "those addresses have"} a character (+ ? ( ) [ ] { }) that Next.js reads as route syntax and cannot match literally, so visits to ${unmatchable.length === 1 ? "it" : "them"} will not redirect.`
7555
+ });
7556
+ }
7557
+ if (redirects.length === config.redirects.length) return config;
7558
+ const result = { ...config, redirects };
7559
+ if (redirects.length === 0) delete result.redirects;
7560
+ return result;
7561
+ }
7562
+ function redirectChains() {
7563
+ const kept = /* @__PURE__ */ new Map();
7564
+ const terminal = /* @__PURE__ */ new Set();
7565
+ const has = (source) => kept.has(source) || terminal.has(source);
7566
+ const add = (source, destination) => {
7567
+ if (destination === null) terminal.add(source);
7568
+ else kept.set(source, destination);
7569
+ };
7570
+ const closesLoop = (source, destination) => {
7571
+ let end = destination;
7572
+ for (let steps = 0; kept.has(end); steps++) {
7573
+ if (steps >= kept.size) return true;
7574
+ end = kept.get(end);
7575
+ }
7576
+ for (let step = destination; step !== end; ) {
7577
+ const next = kept.get(step);
7578
+ kept.set(step, end);
7579
+ step = next;
7580
+ }
7581
+ return end === source;
7582
+ };
7583
+ return { has, add, closesLoop };
7584
+ }
7585
+ function dropConflictingRedirects(config, pages, warnings) {
7586
+ if (!config.redirects?.length) return config;
7587
+ const served = servedRoutes(pages);
7588
+ const servedAsWritten = new Set(pageRoutes(pages));
7589
+ const hidden = [];
7590
+ const hiddenByCase = [];
7591
+ const looping = [];
7592
+ const loopingByCase = [];
7593
+ const folded = redirectChains();
7594
+ const asWritten = redirectChains();
7595
+ const redirects = config.redirects.filter((redirect) => {
7596
+ const source = literalRoute(redirect.source);
7597
+ if (!source) return true;
7598
+ const writtenSource = exactRoute(redirect.source);
7599
+ if (served.has(source)) {
7600
+ const hides = servedAsWritten.has(writtenSource) ? hidden : hiddenByCase;
7601
+ hides.push(redirect.source);
7602
+ return false;
7603
+ }
7604
+ const writtenDestination = destinationRoute(redirect.destination);
7605
+ const destination = writtenDestination?.toLowerCase() ?? null;
7606
+ const firesFolded = !folded.has(source);
7607
+ const firesAsWritten = !asWritten.has(writtenSource);
7608
+ const loopsFolded = firesFolded && destination !== null && folded.closesLoop(source, destination);
7609
+ const loopsAsWritten = firesAsWritten && writtenDestination !== null && asWritten.closesLoop(writtenSource, writtenDestination);
7610
+ if (loopsFolded || loopsAsWritten) {
7611
+ const loops = loopsAsWritten ? looping : loopingByCase;
7612
+ loops.push(`${redirect.source} -> ${redirect.destination}`);
7613
+ return false;
7614
+ }
7615
+ if (firesFolded) folded.add(source, destination);
7616
+ if (firesAsWritten) asWritten.add(writtenSource, writtenDestination);
7617
+ return true;
7618
+ });
7619
+ if (hidden.length > 0) {
7620
+ warnings.push({
7621
+ code: "unsupported-config",
7622
+ message: `Skipped ${hidden.length} redirect${hidden.length === 1 ? "" : "s"} from ${list(hidden)}: ${hidden.length === 1 ? "that URL is" : "those URLs are"} also ${hidden.length === 1 ? "a page" : "pages"} in this site, and a redirect would hide ${hidden.length === 1 ? "it" : "them"}. To redirect instead, delete the page and add the redirect to docs.json.`
7623
+ });
7624
+ }
7625
+ if (hiddenByCase.length > 0) {
7626
+ const one = hiddenByCase.length === 1;
7627
+ warnings.push({
7628
+ code: "unsupported-config",
7629
+ message: `Skipped ${hiddenByCase.length} redirect${one ? "" : "s"} from ${list(hiddenByCase)}: ${one ? "that URL differs" : "those URLs differ"} only in letter case from ${one ? "a page" : "pages"} in this site, so on a server that ignores letter case ${one ? "it" : "they"} would hide ${one ? "that page" : "those pages"}. If your host matches URLs case-sensitively, add ${one ? "it" : "them"} back to docs.json.`
7630
+ });
7631
+ }
7632
+ if (looping.length > 0) {
7633
+ warnings.push({
7634
+ code: "unsupported-config",
7635
+ message: `Skipped ${looping.length} redirect${looping.length === 1 ? "" : "s"} that would send visitors in a circle: ${list(looping)}. Check these redirects in the source docs.`
7636
+ });
7637
+ }
7638
+ if (loopingByCase.length > 0) {
7639
+ const one = loopingByCase.length === 1;
7640
+ warnings.push({
7641
+ code: "unsupported-config",
7642
+ message: `Skipped ${loopingByCase.length} redirect${one ? "" : "s"} that would send visitors in a circle on a server that ignores letter case, since the addresses involved differ only in letter case: ${list(loopingByCase)}. If your host matches URLs case-sensitively, add ${one ? "it" : "them"} back to docs.json.`
7643
+ });
7644
+ }
7645
+ if (redirects.length === config.redirects.length) return config;
7646
+ const result = { ...config, redirects };
7647
+ if (redirects.length === 0) delete result.redirects;
7648
+ return result;
7649
+ }
7650
+
7472
7651
  // src/url.ts
7473
7652
  var DEFAULT_MAX_PAGES = 1e3;
7474
7653
  var DEFAULT_MAX_TOTAL_BYTES = 1e8;
@@ -8443,13 +8622,13 @@ function migrationHtmlArticle(body) {
8443
8622
  const sourceId = select.attr("id");
8444
8623
  if (sourceId) projection.attr("id", sourceId);
8445
8624
  projection.append($("<p></p>").text(select.attr("aria-label")));
8446
- const list = $("<ul></ul>");
8625
+ const list2 = $("<ul></ul>");
8447
8626
  for (const url of urls) {
8448
8627
  const item = $("<li></li>").append($("<a></a>").attr("href", url.href).text(url.label));
8449
8628
  if (url.isSelected) item.append(" (selected)");
8450
- list.append(item);
8629
+ list2.append(item);
8451
8630
  }
8452
- projection.append(list);
8631
+ projection.append(list2);
8453
8632
  select.replaceWith(projection);
8454
8633
  });
8455
8634
  return { $, root };
@@ -8691,8 +8870,8 @@ function readDocusaurusSidebar(document) {
8691
8870
  const $ = load2(document.body);
8692
8871
  const sidebar = $("ul.theme-doc-sidebar-menu").first();
8693
8872
  if (sidebar.length === 0) return null;
8694
- function readList(list) {
8695
- return $(list).children("li").toArray().flatMap((item) => {
8873
+ function readList(list2) {
8874
+ return $(list2).children("li").toArray().flatMap((item) => {
8696
8875
  const directLink = $(item).children("a.menu__link").first();
8697
8876
  const collapsibleLink = $(item).children(".menu__list-item-collapsible").children("a.menu__link").first();
8698
8877
  const link = directLink.length > 0 ? directLink : collapsibleLink;
@@ -9255,6 +9434,7 @@ async function migrateUrl(options) {
9255
9434
  };
9256
9435
  }
9257
9436
  if (platform === "mintlify") docsConfig = addMintlifyDirectoryRedirects(docsConfig, pages);
9437
+ docsConfig = dropConflictingRedirects(dropUnroutableRedirects(docsConfig, warnings), pages, warnings);
9258
9438
  return {
9259
9439
  sourceUrl: source.toString(),
9260
9440
  sourceKind: "url",
@@ -10402,7 +10582,7 @@ function readSpecFile(absolutePath, relativePath, warnings) {
10402
10582
  });
10403
10583
  return void 0;
10404
10584
  }
10405
- return readFileSync6(absolutePath);
10585
+ return withoutByteOrderMark(readFileSync6(absolutePath));
10406
10586
  }
10407
10587
  function specAssetPath(filename) {
10408
10588
  return `${SPEC_DIRECTORY}/${filename}`;
@@ -10438,10 +10618,26 @@ function resolveMintlifyApiSpecs(mintlifyConfig, files, warnings, remoteSpecs, p
10438
10618
  references.push({ value: key, kind: "openapi", tabLabel: `OpenAPI: ${key}`, hidden: true, pageOnly: true });
10439
10619
  }
10440
10620
  const seen = /* @__PURE__ */ new Set();
10621
+ const remoteByKey = /* @__PURE__ */ new Map();
10622
+ const mountDirectory = (remote, reference) => {
10623
+ const directory = reference.directory?.replace(/^\/+|\/+$/g, "");
10624
+ if (!remote || !directory) return;
10625
+ const mount = remote.directories?.find((existing) => existing.directory === directory);
10626
+ if (!mount) {
10627
+ remote.directories = [...remote.directories ?? [], { directory, ...reference.operations ? { operations: reference.operations } : {} }];
10628
+ } else if (mount.operations && reference.operations) {
10629
+ mount.operations = [.../* @__PURE__ */ new Set([...mount.operations, ...reference.operations])];
10630
+ } else {
10631
+ delete mount.operations;
10632
+ }
10633
+ };
10441
10634
  const specs = [];
10442
10635
  for (const reference of references) {
10443
10636
  const dedupeKey = `${reference.kind}:${reference.value}:${reference.tabLabel ?? ""}`;
10444
- if (seen.has(dedupeKey)) continue;
10637
+ if (seen.has(dedupeKey)) {
10638
+ mountDirectory(remoteByKey.get(dedupeKey), reference);
10639
+ continue;
10640
+ }
10445
10641
  seen.add(dedupeKey);
10446
10642
  const tabSuffix = reference.tabLabel ? ` (tab "${reference.tabLabel}")` : "";
10447
10643
  if (reference.kind === "asyncapi") {
@@ -10452,7 +10648,10 @@ function resolveMintlifyApiSpecs(mintlifyConfig, files, warnings, remoteSpecs, p
10452
10648
  continue;
10453
10649
  }
10454
10650
  if (/^https:\/\//i.test(reference.value)) {
10455
- remoteSpecs.push({ url: reference.value, ...reference.tabLabel ? { tabLabel: reference.tabLabel } : {}, ...reference.parentTab ? { parentTab: reference.parentTab } : {}, ...reference.icon ? { icon: reference.icon } : {}, ...reference.hidden ? { hidden: true } : {} });
10651
+ const remote = { url: reference.value, ...reference.tabLabel ? { tabLabel: reference.tabLabel } : {}, ...reference.parentTab ? { parentTab: reference.parentTab } : {}, ...reference.icon ? { icon: reference.icon } : {}, ...reference.hidden ? { hidden: true } : {} };
10652
+ mountDirectory(remote, reference);
10653
+ remoteSpecs.push(remote);
10654
+ remoteByKey.set(dedupeKey, remote);
10456
10655
  warnings.push({
10457
10656
  code: "unsupported-config",
10458
10657
  message: `The remote OpenAPI spec "${reference.value}"${tabSuffix} requires a network download before this import is complete.`,
@@ -10552,10 +10751,15 @@ function thallyOperationSlugSegments(path, method) {
10552
10751
  function slugifyApiSpecId(value) {
10553
10752
  return value.toLowerCase().replace(/[^a-z0-9/]+/g, "-").replace(/(^-|-$)+/g, "").replace(/\//g, "-") || value.toLowerCase();
10554
10753
  }
10754
+ function apiTabSpecIds(tabs) {
10755
+ return tabs.filter((tab) => !tab.hidden && tab.api).map((tab, index) => ({ tab, specId: index === 0 ? "default" : slugifyApiSpecId(tab.tab) }));
10756
+ }
10757
+ var flagged = (value) => value === true || value === "true";
10758
+ var isWithheld = (node) => flagged(node["x-hidden"]) || flagged(node["x-excluded"]);
10555
10759
  function parseOpenApiOperationsForLinking(content) {
10556
10760
  let document;
10557
10761
  try {
10558
- document = parseYaml6(content.toString("utf8"));
10762
+ document = parseSpecDocument(content.toString("utf8")).document;
10559
10763
  } catch {
10560
10764
  return [];
10561
10765
  }
@@ -10564,10 +10768,13 @@ function parseOpenApiOperationsForLinking(content) {
10564
10768
  const operations = [];
10565
10769
  for (const [path, pathItem] of Object.entries(paths)) {
10566
10770
  if (!pathItem || typeof pathItem !== "object") continue;
10771
+ const item = pathItem;
10772
+ if (isWithheld(item)) continue;
10567
10773
  for (const method of OPENAPI_HTTP_METHODS) {
10568
- const operation = pathItem[method];
10774
+ const operation = item[method];
10569
10775
  if (!operation || typeof operation !== "object") continue;
10570
10776
  const op = operation;
10777
+ if (isWithheld(op)) continue;
10571
10778
  const tags = Array.isArray(op.tags) ? op.tags.filter((tag) => typeof tag === "string") : [];
10572
10779
  operations.push({
10573
10780
  method,
@@ -10580,19 +10787,39 @@ function parseOpenApiOperationsForLinking(content) {
10580
10787
  }
10581
10788
  return operations;
10582
10789
  }
10583
- function apiOperationLinkMap(sources, docsConfig) {
10790
+ function servedOperations(source, parsed) {
10791
+ let operations = parsed.get(source.filename);
10792
+ if (!operations) {
10793
+ operations = parseOpenApiOperationsForLinking(source.content);
10794
+ parsed.set(source.filename, operations);
10795
+ }
10796
+ if (!source.operations) return operations;
10797
+ const byKey = new Map(operations.map((operation) => [`${operation.method} ${operation.path}`, operation]));
10798
+ return source.operations.flatMap((reference) => {
10799
+ const [method, ...path] = reference.trim().split(/\s+/);
10800
+ const operation = byKey.get(`${method.toLowerCase()} ${path.join(" ")}`);
10801
+ return operation ? [operation] : [];
10802
+ });
10803
+ }
10804
+ var mountPrefix = (directory) => directory.replace(/^\/+|\/+$/g, "").toLowerCase();
10805
+ function apiOperationLinkMap(sources, docsConfig, otherMounts = []) {
10584
10806
  const map = /* @__PURE__ */ new Map();
10585
10807
  const prefixLandings = /* @__PURE__ */ new Map();
10586
10808
  const mintlifyPages = /* @__PURE__ */ new Map();
10587
10809
  const duplicatePages = [];
10588
- const apiTabs = docsConfig.tabs.filter((tab) => !tab.hidden && tab.api);
10589
- apiTabs.forEach((tab, index) => {
10590
- const source = sources.find((entry) => tab.api?.source === specAssetPath(entry.filename));
10591
- if (!source || !source.prefix) return;
10592
- const specId = index === 0 ? "default" : slugifyApiSpecId(tab.tab);
10593
- const prefix = source.prefix.replace(/^\/+|\/+$/g, "").toLowerCase();
10810
+ const parsedByFile = /* @__PURE__ */ new Map();
10811
+ const bound = apiTabSpecIds(docsConfig.tabs).flatMap(({ tab, specId }) => sources.filter((entry) => tab.api?.source === specAssetPath(entry.filename) && (!entry.tab || entry.tab === tab.tab)).map((source) => ({ source, specId })));
10812
+ const mounts = new Set([...bound.map(({ source }) => source.prefix ? mountPrefix(source.prefix) : ""), ...otherMounts.map(mountPrefix)].filter(Boolean));
10813
+ const underDeeperMount = (route, own2) => {
10814
+ const lower = route.toLowerCase();
10815
+ return [...mounts].some((mount) => mount.length > own2.length && (lower === mount || lower.startsWith(`${mount}/`)));
10816
+ };
10817
+ bound.forEach(({ source, specId }) => {
10818
+ if (!source.prefix) return;
10819
+ const base = source.prefix.replace(/^\/+|\/+$/g, "");
10820
+ const prefix = mountPrefix(source.prefix);
10594
10821
  if (prefix) prefixLandings.set(prefix, specId === "default" ? "/api" : `/api/${specId}`);
10595
- for (const operation of parseOpenApiOperationsForLinking(source.content)) {
10822
+ for (const operation of servedOperations(source, parsedByFile)) {
10596
10823
  const thallyHref = `/api/${specId}/${thallyOperationSlugSegments(operation.path, operation.method).join("/")}`;
10597
10824
  const leafCandidates = /* @__PURE__ */ new Set();
10598
10825
  if (operation.operationId) {
@@ -10608,31 +10835,66 @@ function apiOperationLinkMap(sources, docsConfig) {
10608
10835
  const tagSegment = mintlifyOperationSlugSegment(operation.tag ?? "default");
10609
10836
  const mintlifyTag = mintlifyUrlSlug(operation.tag ?? "default");
10610
10837
  const mintlifyLeaf = mintlifyUrlSlug(operation.summary?.trim() || operation.operationId?.trim() || `${operation.method} ${operation.path}`);
10611
- const canonical2 = `${source.prefix}/${mintlifyTag}/${mintlifyLeaf}`.replace(/^\/+|\/+$/g, "");
10612
- if (mintlifyTag && mintlifyLeaf) {
10838
+ const canonical2 = `${base}/${mintlifyTag}/${mintlifyLeaf}`.replace(/^\/+|\/+$/g, "");
10839
+ if (mintlifyTag && mintlifyLeaf && !underDeeperMount(canonical2, prefix)) {
10613
10840
  if (!mintlifyPages.has(canonical2)) mintlifyPages.set(canonical2, thallyHref);
10614
10841
  else if (mintlifyPages.get(canonical2) !== thallyHref && !duplicatePages.includes(canonical2)) duplicatePages.push(canonical2);
10842
+ if (!map.has(canonical2.toLowerCase())) map.set(canonical2.toLowerCase(), thallyHref);
10843
+ }
10844
+ const tagRoot = `${base}/${mintlifyTag}`.replace(/^\/+|\/+$/g, "");
10845
+ if (mintlifyTag && !underDeeperMount(tagRoot, prefix)) {
10846
+ if (!mintlifyPages.has(tagRoot)) mintlifyPages.set(tagRoot, thallyHref);
10847
+ if (!map.has(tagRoot.toLowerCase())) map.set(tagRoot.toLowerCase(), thallyHref);
10615
10848
  }
10616
- if (mintlifyTag && mintlifyLeaf && !map.has(canonical2.toLowerCase())) map.set(canonical2.toLowerCase(), thallyHref);
10617
- const tagRoot = `${source.prefix}/${mintlifyTag}`.replace(/^\/+|\/+$/g, "");
10618
- if (mintlifyTag && !mintlifyPages.has(tagRoot)) mintlifyPages.set(tagRoot, thallyHref);
10619
- if (mintlifyTag && !map.has(tagRoot.toLowerCase())) map.set(tagRoot.toLowerCase(), thallyHref);
10620
10849
  for (const leaf of leafCandidates) {
10621
10850
  if (!leaf) continue;
10622
- const tagged = `${source.prefix}/${tagSegment}/${leaf}`.replace(/^\/+|\/+$/g, "").toLowerCase();
10623
- const flat = `${source.prefix}/${leaf}`.replace(/^\/+|\/+$/g, "").toLowerCase();
10624
- if (!map.has(tagged)) map.set(tagged, thallyHref);
10625
- if (!map.has(flat)) map.set(flat, thallyHref);
10851
+ const tagged = `${base}/${tagSegment}/${leaf}`.replace(/^\/+|\/+$/g, "").toLowerCase();
10852
+ const flat = `${base}/${leaf}`.replace(/^\/+|\/+$/g, "").toLowerCase();
10853
+ if (!map.has(tagged) && !underDeeperMount(tagged, prefix)) map.set(tagged, thallyHref);
10854
+ if (!map.has(flat) && !underDeeperMount(flat, prefix)) map.set(flat, thallyHref);
10626
10855
  }
10627
10856
  }
10628
10857
  });
10629
10858
  return { operationLinks: map, prefixLandings, mintlifyPages, duplicatePages };
10630
10859
  }
10631
- function rewriteApiLinksInPages(pages, operationLinks, prefixLandings, warnings) {
10860
+ function mintlifyEndpointRedirects(maps, pages, existing, warnings) {
10861
+ const { mintlifyPages, duplicatePages } = maps;
10862
+ if (duplicatePages.length > 0) {
10863
+ const listed = duplicatePages.slice(0, 10).map((url) => `/${url}`).join(", ");
10864
+ warnings.push({
10865
+ code: "unsupported-config",
10866
+ message: `${duplicatePages.length} Mintlify endpoint URL${duplicatePages.length === 1 ? " is" : "s are"} shared by more than one operation (same tag and summary), so only the first operation is redirected from each: ${listed}${duplicatePages.length > 10 ? ", ..." : ""}.`
10867
+ });
10868
+ }
10869
+ const taken = servedRoutes(pages);
10870
+ for (const redirect of existing) taken.add(literalRoute(redirect.source) ?? redirect.source);
10871
+ const candidates = [...mintlifyPages].filter(([source]) => !taken.has(literalRoute(`/${source}`) ?? `/${source}`));
10872
+ const redirects = candidates.slice(0, MAX_ENDPOINT_REDIRECTS).map(([source, destination]) => ({ source: `/${source}`, destination }));
10873
+ const capped = candidates.length - redirects.length;
10874
+ if (capped > 0) {
10875
+ warnings.push({
10876
+ code: "unsupported-config",
10877
+ message: `Created ${redirects.length} of ${candidates.length} Mintlify endpoint redirects; skipped ${capped} because a site is limited to ${MAX_ENDPOINT_REDIRECTS} generated endpoint redirects. Old links to skipped endpoint URLs will not redirect.`
10878
+ });
10879
+ }
10880
+ return redirects;
10881
+ }
10882
+ function linkApiMounts(sources, docsConfig, pages, warnings, reservedMounts = []) {
10883
+ const maps = apiOperationLinkMap(sources, docsConfig, reservedMounts);
10884
+ rewriteApiLinksInPages(pages, maps.operationLinks, maps.prefixLandings, warnings, { mounts: reservedMounts, redirects: docsConfig.redirects });
10885
+ return mintlifyEndpointRedirects(maps, pages, docsConfig.redirects ?? [], warnings);
10886
+ }
10887
+ function rewriteApiLinksInPages(pages, operationLinks, prefixLandings, warnings, answered = {}) {
10632
10888
  if (operationLinks.size === 0 && prefixLandings.size === 0) return;
10633
10889
  const unmatched = /* @__PURE__ */ new Set();
10890
+ const served = servedRoutes(pages);
10891
+ for (const { source } of answered.redirects ?? []) {
10892
+ const route = literalRoute(source);
10893
+ if (route) served.add(route);
10894
+ }
10895
+ const others = (answered.mounts ?? []).map(mountPrefix).filter(Boolean);
10634
10896
  for (const page of pages) {
10635
- page.body = rewriteApiOperationLinks(page.body, operationLinks, prefixLandings, unmatched);
10897
+ page.body = rewriteApiOperationLinks(page.body, operationLinks, prefixLandings, served, unmatched, others);
10636
10898
  }
10637
10899
  if (unmatched.size === 0) return;
10638
10900
  const sorted = [...unmatched].sort();
@@ -10641,7 +10903,7 @@ function rewriteApiLinksInPages(pages, operationLinks, prefixLandings, warnings)
10641
10903
  message: `${sorted.length} link(s) to API endpoints matched no endpoint in the OpenAPI spec, so they now point to the API section's overview page instead: ${sorted.slice(0, 10).join(", ")}${sorted.length > 10 ? `, and ${sorted.length - 10} more` : ""}. Update those links if you want them to reach a specific endpoint.`
10642
10904
  });
10643
10905
  }
10644
- function rewriteApiOperationLinks(body, linkMap, prefixLandings, unmatched) {
10906
+ function rewriteApiOperationLinks(body, linkMap, prefixLandings, served, unmatched, otherMounts) {
10645
10907
  if (linkMap.size === 0 && prefixLandings.size === 0) return body;
10646
10908
  const orderedPrefixes = [...prefixLandings.entries()].sort((a, b) => b[0].length - a[0].length);
10647
10909
  let codeFence = null;
@@ -10655,8 +10917,12 @@ function rewriteApiOperationLinks(body, linkMap, prefixLandings, unmatched) {
10655
10917
  if (codeFence) return line;
10656
10918
  const rewriteTarget = (target2) => {
10657
10919
  const suffixIndex = target2.search(/[?#]/);
10658
- const path = (suffixIndex >= 0 ? target2.slice(0, suffixIndex) : target2).replace(/^\/+/, "").toLowerCase();
10920
+ const literal = suffixIndex >= 0 ? target2.slice(0, suffixIndex) : target2;
10921
+ if (served.has(literalRoute(literal) ?? literal)) return target2;
10922
+ const path = literal.replace(/^\/+|\/+$/g, "").toLowerCase();
10659
10923
  const suffix = suffixIndex >= 0 ? target2.slice(suffixIndex) : "";
10924
+ const owner = [...prefixLandings.keys(), ...otherMounts].filter((mount) => path === mount || path.startsWith(`${mount}/`)).reduce((longest, mount) => longest === null || mount.length > longest.length ? mount : longest, null);
10925
+ if (owner !== null && !prefixLandings.has(owner)) return target2;
10660
10926
  const mapped = linkMap.get(path);
10661
10927
  if (mapped) return `${mapped}${suffix}`;
10662
10928
  for (const [prefix, landing] of orderedPrefixes) {
@@ -12028,6 +12294,7 @@ ${warnings.map((warning) => `- ${warning.message}`).join("\n")}`);
12028
12294
  const sourceRefPages = [];
12029
12295
  const sourceRefAssets = [];
12030
12296
  const remoteApiSpecs = [];
12297
+ const localApiMounts = [];
12031
12298
  const referencedAssetPaths = /* @__PURE__ */ new Map();
12032
12299
  const addAssetReference = (assetPath, referencingPage) => {
12033
12300
  const referrers = referencedAssetPaths.get(assetPath);
@@ -12994,7 +13261,7 @@ ${warnings.map((warning) => `- ${warning.message}`).join("\n")}`);
12994
13261
  resolvedSpecs.map((spec) => ({ filename: spec.filename, content: spec.content, prefix: spec.routeSegments.join("/") })),
12995
13262
  docsConfig
12996
13263
  );
12997
- rewriteApiLinksInPages(pages, operationLinks, prefixLandings, warnings);
13264
+ rewriteApiLinksInPages(pages, operationLinks, prefixLandings, warnings, { redirects: docsConfig.redirects });
12998
13265
  }
12999
13266
  } else if (platform === "mintlify") {
13000
13267
  const warnSharedSpec = (path, excluded = []) => {
@@ -13029,34 +13296,17 @@ ${warnings.map((warning) => `- ${warning.message}`).join("\n")}`);
13029
13296
  spec.generatedNavigation = Boolean(spec.directory) && !publishedSpecRefs.some((ref) => specRefMatches(ref, spec.sourcePath, spec.filename, true));
13030
13297
  }
13031
13298
  docsConfig = injectOpenApiSpecs(docsConfig, resolvedSpecs, warnings);
13032
- const { operationLinks, prefixLandings, mintlifyPages, duplicatePages } = apiOperationLinkMap(
13033
- resolvedSpecs.map((spec) => ({ filename: spec.filename, content: spec.content, prefix: spec.directory })),
13034
- docsConfig
13035
- );
13036
- rewriteApiLinksInPages(pages, operationLinks, prefixLandings, warnings);
13037
- if (duplicatePages.length > 0) {
13038
- const listed = duplicatePages.slice(0, 10).map((url) => `/${url}`).join(", ");
13039
- warnings.push({
13040
- code: "unsupported-config",
13041
- message: `${duplicatePages.length} Mintlify endpoint URL${duplicatePages.length === 1 ? " is" : "s are"} shared by more than one operation (same tag and summary), so only the first operation is redirected from each: ${listed}${duplicatePages.length > 10 ? ", ..." : ""}.`
13042
- });
13043
- }
13044
- const taken = /* @__PURE__ */ new Set([...pages.map((page) => page.id), ...(docsConfig.redirects ?? []).map((redirect) => redirect.source.replace(/^\//, ""))]);
13045
- const candidates = [...mintlifyPages].filter(([source]) => !taken.has(source));
13046
- const endpointRedirects = candidates.slice(0, MAX_ENDPOINT_REDIRECTS).map(([source, destination]) => ({ source: `/${source}`, destination }));
13047
- const alreadyTaken = mintlifyPages.size - candidates.length;
13048
- const capped = candidates.length - endpointRedirects.length;
13049
- if (alreadyTaken + capped > 0) {
13050
- const reasons = [
13051
- alreadyTaken > 0 ? `${alreadyTaken} because the URL already has a page or redirect` : "",
13052
- capped > 0 ? `${capped} because a site is limited to ${MAX_ENDPOINT_REDIRECTS} generated endpoint redirects` : ""
13053
- ].filter(Boolean).join(" and ");
13054
- warnings.push({
13055
- code: "unsupported-config",
13056
- message: `Created ${endpointRedirects.length} of ${mintlifyPages.size} Mintlify endpoint redirects; skipped ${alreadyTaken + capped}: ${reasons}. Old links to skipped endpoint URLs will not redirect.`
13057
- });
13299
+ if (remoteApiSpecs.length > 0) {
13300
+ localApiMounts.push(...resolvedSpecs.flatMap((spec) => spec.directory ? [{ filename: spec.filename, directory: spec.directory, ...spec.tabLabel ? { tab: spec.tabLabel } : {} }] : []));
13301
+ } else {
13302
+ const endpointRedirects = linkApiMounts(
13303
+ resolvedSpecs.map((spec) => ({ filename: spec.filename, content: spec.content, prefix: spec.directory, ...spec.tabLabel ? { tab: spec.tabLabel } : {} })),
13304
+ docsConfig,
13305
+ pages,
13306
+ warnings
13307
+ );
13308
+ if (endpointRedirects.length > 0) docsConfig = { ...docsConfig, redirects: [...docsConfig.redirects ?? [], ...endpointRedirects] };
13058
13309
  }
13059
- if (endpointRedirects.length > 0) docsConfig = { ...docsConfig, redirects: [...docsConfig.redirects ?? [], ...endpointRedirects] };
13060
13310
  } else {
13061
13311
  const fallback = findOpenApi(files);
13062
13312
  const fallbackContent = fallback ? readSpecFile(fallback.absolutePath, fallback.relativePath, warnings) : void 0;
@@ -13099,8 +13349,9 @@ ${warnings.map((warning) => `- ${warning.message}`).join("\n")}`);
13099
13349
  if (docsConfig.tabs.some((tab) => tab.api)) docsConfig = { ...docsConfig, api: { ...docsConfig.api, specLink: false } };
13100
13350
  }
13101
13351
  if (platform === "mintlify") {
13102
- const sources = new Set((docsConfig.redirects ?? []).map((redirect) => redirect.source));
13103
- const aliases = routeAliases.filter((redirect) => !sources.has(redirect.source));
13352
+ const sources = new Set((docsConfig.redirects ?? []).map((redirect) => redirect.source.toLowerCase()));
13353
+ const served = servedRoutes(pages);
13354
+ const aliases = routeAliases.filter((redirect) => !sources.has(redirect.source.toLowerCase()) && !served.has(literalRoute(redirect.source) ?? redirect.source));
13104
13355
  if (aliases.length > 0) docsConfig = { ...docsConfig, redirects: [...docsConfig.redirects ?? [], ...aliases] };
13105
13356
  docsConfig = addMintlifyHomepageRedirects(addMintlifyDirectoryRedirects(docsConfig, pages), pages);
13106
13357
  const reachablePaths = /* @__PURE__ */ new Set([
@@ -13314,26 +13565,7 @@ ${body}`;
13314
13565
  }
13315
13566
  }
13316
13567
  if (docsConfig.redirects) {
13317
- docsConfig = {
13318
- ...docsConfig,
13319
- redirects: docsConfig.redirects.filter((redirect) => {
13320
- const literalRoute = (route) => route.startsWith("/") && !/[?:*#()[\]{}]/.test(route) ? route.replace(/\/+$/, "") || "/" : null;
13321
- const sourceRoute = literalRoute(redirect.source);
13322
- if (sourceRoute && sourceRoute === literalRoute(redirect.destination)) {
13323
- warnings.push({
13324
- code: "unsupported-config",
13325
- message: `Self-redirect from ${redirect.source} to ${redirect.destination} was omitted.`
13326
- });
13327
- return false;
13328
- }
13329
- if (!/[+?()[\]{}]/.test(redirect.source)) return true;
13330
- warnings.push({
13331
- code: "unsupported-config",
13332
- message: `Redirect from ${redirect.source} contains route-pattern syntax Next.js cannot express literally and was omitted.`
13333
- });
13334
- return false;
13335
- })
13336
- };
13568
+ docsConfig = dropConflictingRedirects(dropUnroutableRedirects(docsConfig, warnings), [...pages, ...sourceRefPages], warnings);
13337
13569
  }
13338
13570
  if (pages.length === 0) {
13339
13571
  const reasons = warnings.map((warning) => warning.message);
@@ -13546,6 +13778,7 @@ ${reasons.map((reason) => `- ${reason}`).join("\n")}` : "No importable Markdown
13546
13778
  assets,
13547
13779
  ...importedSourceRefs.length > 0 ? { sourceRefs: importedSourceRefs } : {},
13548
13780
  ...remoteApiSpecs.length > 0 ? { remoteApiSpecs } : {},
13781
+ ...remoteApiSpecs.length > 0 && localApiMounts.length > 0 ? { localApiMounts } : {},
13549
13782
  ...componentMigrator ? { componentFiles: componentMigrator.files() } : {},
13550
13783
  ...quarantinedFiles.length > 0 ? { quarantinedFiles } : {},
13551
13784
  ...droppedGatedFiles.length > 0 ? { droppedGatedPages: droppedGatedFiles.length } : {},
@@ -15314,7 +15547,6 @@ import { createHash as createHash4 } from "crypto";
15314
15547
  import { lookup } from "dns/promises";
15315
15548
  import { request } from "https";
15316
15549
  import { isIP } from "net";
15317
- import { parse as parseYaml7 } from "yaml";
15318
15550
  var MAX_SPEC_BYTES2 = 25e6;
15319
15551
  var MAX_REDIRECTS = 3;
15320
15552
  function isPublicAddress(address, family) {
@@ -15408,9 +15640,7 @@ async function downloadSpec(value, fetcher) {
15408
15640
  }
15409
15641
  throw new Error("Remote OpenAPI spec redirected too many times.");
15410
15642
  }
15411
- function parseOpenApi(content, extension) {
15412
- const text3 = content.toString("utf8");
15413
- const parsed = extension === "yaml" || !text3.trimStart().startsWith("{") ? parseYaml7(text3) : JSON.parse(text3);
15643
+ function parseOpenApi(parsed) {
15414
15644
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("OpenAPI spec is not an object.");
15415
15645
  const document = parsed;
15416
15646
  if (typeof document.openapi !== "string" || !/^3\./.test(document.openapi)) {
@@ -15423,10 +15653,11 @@ function parseOpenApi(content, extension) {
15423
15653
  if (paths.length > 5e3) throw new Error("Remote OpenAPI spec has too many paths to import.");
15424
15654
  const operations = [];
15425
15655
  for (const [path, value] of paths) {
15426
- if (!value || typeof value !== "object" || Array.isArray(value)) continue;
15656
+ if (!value || typeof value !== "object" || Array.isArray(value) || isWithheld(value)) continue;
15427
15657
  for (const [method, raw] of Object.entries(value)) {
15428
15658
  if (!/^(get|post|put|patch|delete|head|options|trace)$/i.test(method) || !raw || typeof raw !== "object" || Array.isArray(raw)) continue;
15429
15659
  const operation = raw;
15660
+ if (isWithheld(operation)) continue;
15430
15661
  const tags = Array.isArray(operation.tags) ? operation.tags : [];
15431
15662
  operations.push({
15432
15663
  method: method.toLowerCase(),
@@ -15441,9 +15672,8 @@ function parseOpenApi(content, extension) {
15441
15672
  function slug(value) {
15442
15673
  return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
15443
15674
  }
15444
- function operationTarget(operation) {
15445
- const path = operation.path.split("/").filter(Boolean).map((part) => part.replace(/[{}]/g, "").replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").toLowerCase());
15446
- return `/api/default/${[...path, operation.method].join("/")}`;
15675
+ function operationTarget(specId, operation) {
15676
+ return `/api/${specId}/${thallyOperationSlugSegments(operation.path, operation.method).join("/")}`;
15447
15677
  }
15448
15678
  async function hydrateRemoteApiSpecs(bundle, fetcher) {
15449
15679
  if (!bundle.remoteApiSpecs?.length) return bundle;
@@ -15453,28 +15683,32 @@ async function hydrateRemoteApiSpecs(bundle, fetcher) {
15453
15683
  const warnings = [...bundle.warnings];
15454
15684
  const pages = bundle.pages.map((page) => ({ ...page }));
15455
15685
  const authoredLinks = /* @__PURE__ */ new Set();
15686
+ const served = servedRoutes(pages);
15687
+ const endpointUrls = /* @__PURE__ */ new Map();
15456
15688
  for (const page of bundle.pages) {
15457
15689
  for (const match of page.body.matchAll(/\/[a-z0-9_/-]+/gi)) {
15458
15690
  const segments = match[0].split("/").filter(Boolean);
15459
15691
  if (segments.length < 3) continue;
15460
15692
  const section = segments[0].toLowerCase();
15461
- if (section.includes("api") || section.includes("reference")) authoredLinks.add(match[0]);
15693
+ if (section.includes("api") || section.includes("reference")) authoredLinks.add(match[0].replace(/\/+$/, ""));
15462
15694
  }
15463
15695
  }
15696
+ const bound = [];
15464
15697
  for (const reference of bundle.remoteApiSpecs) {
15465
15698
  for (let index = warnings.length - 1; index >= 0; index--) {
15466
15699
  if (warnings[index].source === reference.url && warnings[index].message.includes("requires a network download")) warnings.splice(index, 1);
15467
15700
  }
15468
15701
  try {
15469
- const url = validatedSpecUrl(reference.url);
15470
- const extension = /\.ya?ml$/i.test(url.pathname) ? "yaml" : "json";
15471
- const downloaded = await downloadSpec(reference.url, fetcher);
15702
+ const downloaded = withoutByteOrderMark(await downloadSpec(reference.url, fetcher));
15472
15703
  const pruned = markExcluded(downloaded, reference.withheldOperations ?? [], reference.keptOperations);
15473
15704
  const body = pruned.content;
15474
- if (reference.withheldOperations?.length) warnings.push({ code: "gated-page", message: sharedSpecMessage(reference.url, pruned.excluded), source: reference.url });
15475
- const operations = parseOpenApi(body, extension);
15705
+ if (reference.withheldOperations?.length && !warnings.some((warning) => warning.code === "gated-page" && warning.source === reference.url)) {
15706
+ warnings.push({ code: "gated-page", message: sharedSpecMessage(reference.url, pruned.excluded), source: reference.url });
15707
+ }
15708
+ const { format: extension, document } = parseSpecDocument(body.toString("utf8"));
15709
+ const operations = parseOpenApi(document);
15476
15710
  const filename = `openapi-${createHash4("sha256").update(reference.url).digest("hex").slice(0, 12)}.${extension}`;
15477
- assets.push({ path: `openapi/${filename}`, content: body, projectRelative: true });
15711
+ if (!assets.some((asset) => asset.path === `openapi/${filename}`)) assets.push({ path: `openapi/${filename}`, content: body, projectRelative: true });
15478
15712
  for (const page of pages) {
15479
15713
  const ref = page.openapi ? splitOpenApiRef(page.openapi) : null;
15480
15714
  if (ref?.specRef === reference.url) page.openapi = withSpecRef(ref, `openapi/${filename}`);
@@ -15482,21 +15716,7 @@ async function hydrateRemoteApiSpecs(bundle, fetcher) {
15482
15716
  const tab = reference.tabLabel ? tabs.find((candidate) => candidate.tab === reference.tabLabel) : tabs.find((candidate) => candidate.tab.toLowerCase().includes("api"));
15483
15717
  if (tab) tab.api = { source: `openapi/${filename}` };
15484
15718
  else insertApiTab(tabs, { tab: reference.tabLabel ?? "API Reference", ...reference.icon ? { icon: reference.icon } : {}, ...reference.hidden ? { hidden: true } : {}, api: { source: `openapi/${filename}` } }, reference.parentTab);
15485
- const routes = /* @__PURE__ */ new Map();
15486
- for (const operation of operations) {
15487
- const key = `${slug(operation.tag)}/${slug(operation.summary)}`;
15488
- if (routes.has(key)) routes.set(key, null);
15489
- else routes.set(key, operationTarget(operation));
15490
- }
15491
- const existingSources = new Set(redirects.map((redirect) => redirect.source));
15492
- for (const source of authoredLinks) {
15493
- const parts = source.split("/").filter(Boolean);
15494
- const key = parts.slice(-2).join("/");
15495
- const destination = routes.get(key);
15496
- if (!destination || existingSources.has(source)) continue;
15497
- redirects.push({ source, destination, permanent: false });
15498
- existingSources.add(source);
15499
- }
15719
+ bound.push({ reference, filename, content: body, operations, tabName: tab?.tab ?? reference.tabLabel ?? "API Reference" });
15500
15720
  } catch (error) {
15501
15721
  warnings.push({
15502
15722
  code: "fetch-failed",
@@ -15505,11 +15725,59 @@ async function hydrateRemoteApiSpecs(bundle, fetcher) {
15505
15725
  });
15506
15726
  }
15507
15727
  }
15728
+ const directories = [
15729
+ ...(bundle.localApiMounts ?? []).map(({ directory }) => directory),
15730
+ ...bundle.remoteApiSpecs.flatMap((reference) => (reference.directories ?? []).map(({ directory }) => directory))
15731
+ ];
15732
+ const endpointRedirects = linkApiMounts(
15733
+ [
15734
+ ...(bundle.localApiMounts ?? []).map(({ filename, directory, tab }) => ({
15735
+ filename,
15736
+ content: Buffer.from(assets.find((asset) => asset.path === `openapi/${filename}`)?.content ?? []),
15737
+ prefix: directory,
15738
+ ...tab ? { tab } : {}
15739
+ })),
15740
+ ...bound.flatMap(({ reference, filename, content, tabName }) => (reference.directories ?? []).map(({ directory, operations: listed }) => ({ filename, content, prefix: directory, tab: tabName, ...listed ? { operations: listed } : {} })))
15741
+ ],
15742
+ { ...bundle.docsConfig, tabs, redirects },
15743
+ pages,
15744
+ warnings,
15745
+ directories
15746
+ );
15747
+ for (const redirect of endpointRedirects) {
15748
+ redirects.push(redirect);
15749
+ endpointUrls.set(literalRoute(redirect.source) ?? redirect.source, redirect.destination);
15750
+ }
15751
+ const existingSources = new Set(redirects.map((redirect) => literalRoute(redirect.source) ?? redirect.source.toLowerCase()));
15752
+ const mounts = directories.map(mountPrefix).filter(Boolean);
15753
+ const underMount = (source) => mounts.some((prefix) => source.toLowerCase() === `/${prefix}` || source.toLowerCase().startsWith(`/${prefix}/`));
15754
+ const guesses = /* @__PURE__ */ new Map();
15755
+ const specIds = apiTabSpecIds(tabs);
15756
+ for (const { reference, filename, operations, tabName } of bound) {
15757
+ const specId = specIds.find(({ tab }) => tab.tab === tabName && tab.api?.source === `openapi/${filename}`)?.specId;
15758
+ const listed = reference.directories?.length && reference.directories.every((directory) => directory.operations) ? new Set(reference.directories.flatMap((directory) => directory.operations ?? []).map((name) => name.trim().split(/\s+/)).map(([method, ...path]) => `${method.toLowerCase()} ${path.join(" ")}`)) : null;
15759
+ for (const operation of operations) {
15760
+ if (listed && !listed.has(`${operation.method} ${operation.path}`)) continue;
15761
+ const key = `${slug(operation.tag)}/${slug(operation.summary)}`;
15762
+ guesses.set(key, guesses.has(key) || !specId ? null : operationTarget(specId, operation));
15763
+ }
15764
+ }
15765
+ for (const source of authoredLinks) {
15766
+ const destination = guesses.get(source.split("/").filter(Boolean).slice(-2).join("/"));
15767
+ if (!destination || existingSources.has(literalRoute(source) ?? source.toLowerCase()) || served.has(literalRoute(source) ?? source) || underMount(source)) continue;
15768
+ redirects.push({ source, destination, permanent: false });
15769
+ existingSources.add(literalRoute(source) ?? source.toLowerCase());
15770
+ }
15771
+ const direct = redirects.map((redirect) => {
15772
+ const endpoint = endpointUrls.get(literalRoute(redirect.destination) ?? "");
15773
+ return endpoint ? { ...redirect, destination: endpoint } : redirect;
15774
+ });
15508
15775
  return {
15509
15776
  ...bundle,
15510
15777
  pages,
15511
15778
  assets,
15512
- docsConfig: { ...bundle.docsConfig, tabs, ...redirects.length > 0 ? { redirects } : {} },
15779
+ // Redirects added above meet the same checks as any other: none may fail the build, hide a page that exists, or loop.
15780
+ docsConfig: dropConflictingRedirects(dropUnroutableRedirects({ ...bundle.docsConfig, tabs, ...direct.length > 0 ? { redirects: direct } : {} }, warnings), pages, warnings),
15513
15781
  warnings
15514
15782
  };
15515
15783
  }