@thallylabs/migrate 0.2.38 → 0.2.39

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.
@@ -1,6 +1,6 @@
1
1
  // src/url-run.ts
2
2
  import { createHash } from "crypto";
3
- import { load as load3 } from "cheerio";
3
+ import { load as load5 } from "cheerio";
4
4
  import remarkParse3 from "remark-parse";
5
5
  import remarkMdx3 from "remark-mdx";
6
6
  import { unified as unified3 } from "unified";
@@ -531,7 +531,53 @@ function cloneUrlMigrationCheckpoint(checkpoint) {
531
531
  }
532
532
 
533
533
  // src/url-navigation.ts
534
+ import { load as load2 } from "cheerio";
535
+
536
+ // src/url-scope.ts
534
537
  import { load } from "cheerio";
538
+ function inferUrlMigrationScope(capture) {
539
+ const source = capture.finalUrl;
540
+ let end = source.pathname.length;
541
+ while (end > 0 && source.pathname.charCodeAt(end - 1) === 47) end--;
542
+ const parent = end < source.pathname.length ? source.pathname.slice(0, end) || "/" : source.pathname.slice(0, source.pathname.lastIndexOf("/")) || "/";
543
+ if (!/html/i.test(capture.contentType) || Buffer.byteLength(capture.body) > 8e6) return { scopePath: parent, evidence: "source-parent" };
544
+ const $ = load(capture.body);
545
+ const ancestor = (path) => path === "/" || source.pathname === path || source.pathname.startsWith(`${path}/`);
546
+ const observed = (reference) => {
547
+ if (!reference || reference.length > 8192) return void 0;
548
+ try {
549
+ const url = new URL(reference, source);
550
+ if (url.origin !== source.origin || url.username || url.password || url.search || url.hash || /%2f|%5c/i.test(url.pathname)) return void 0;
551
+ return url;
552
+ } catch {
553
+ return void 0;
554
+ }
555
+ };
556
+ const indexes = [];
557
+ $("link[href],a[href]").slice(0, 1e4).each((_, node) => {
558
+ const url = observed($(node).attr("href"));
559
+ if (!url || !/\/(?:llms(?:-full)?\.txt|sitemap(?:[-_][^/]*)?\.xml)$/i.test(url.pathname)) return;
560
+ const path = url.pathname.slice(0, url.pathname.lastIndexOf("/")) || "/";
561
+ if (ancestor(path)) indexes.push(path);
562
+ });
563
+ if (indexes.length) return { scopePath: indexes.sort((a, b) => b.length - a.length)[0], evidence: "discovery-index" };
564
+ const paths = [];
565
+ $('nav a[href],aside a[href],[role="navigation"] a[href]').slice(0, 1e4).each((_, node) => {
566
+ if ($(node).closest("header,footer").length) return;
567
+ const url = observed($(node).attr("href"));
568
+ if (url && !/\.[a-z0-9]+$/i.test(url.pathname)) paths.push(url.pathname);
569
+ });
570
+ if (new Set(paths).size >= 2 && paths.some((path) => path === source.pathname)) {
571
+ const segments = paths.map((path) => path.split("/").filter(Boolean));
572
+ let count = 0;
573
+ while (segments.every((parts) => parts[count] !== void 0 && parts[count] === segments[0][count])) count++;
574
+ const common = "/" + segments[0].slice(0, count).join("/");
575
+ if (ancestor(common)) return { scopePath: common, evidence: "navigation" };
576
+ }
577
+ return { scopePath: parent, evidence: "source-parent" };
578
+ }
579
+
580
+ // src/url-navigation.ts
535
581
  var MAX_CAPTURE_BYTES = 8e6;
536
582
  var MAX_JSON_BYTES = 2e6;
537
583
  var MAX_NODES = 1e4;
@@ -598,8 +644,18 @@ function flightRecords(stream) {
598
644
  }
599
645
  return records;
600
646
  }
601
- function mintlifyNavigation(body, base, locale) {
602
- const $ = load(body);
647
+ function mintlifyNavigation(body, base, locale, selectedScope) {
648
+ const $ = load2(body);
649
+ const scope = inferUrlMigrationScope({ body, contentType: "text/html", finalUrl: base });
650
+ const mount = scope.evidence === "source-parent" ? selectedScope ?? "/" : scope.scopePath;
651
+ const configTarget = (value) => {
652
+ const reference = text(value);
653
+ if (!reference) return void 0;
654
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(reference)) return target(reference, base);
655
+ const path = reference.replace(/^\/+/, "");
656
+ const prefix = mount.replace(/^\/|\/$/g, "");
657
+ return target(prefix && path !== prefix && !path.startsWith(`${prefix}/`) ? `${prefix}/${path}` : path, new URL("/", base));
658
+ };
603
659
  let stream = "";
604
660
  const records = [];
605
661
  $("script").each((_index, script) => {
@@ -636,7 +692,7 @@ function mintlifyNavigation(body, base, locale) {
636
692
  const walk = (value, depth = 0, activeLocale) => {
637
693
  if (depth > MAX_DEPTH || ++count > MAX_NODES) throw new Error("Source navigation exceeds its safety bound.");
638
694
  if (typeof value === "string") {
639
- const url2 = target(value, new URL("/", base));
695
+ const url2 = configTarget(value);
640
696
  return url2 ? [{ kind: "page", label: value, url: url2 }] : [];
641
697
  }
642
698
  if (Array.isArray(value)) return value.flatMap((child) => walk(child, depth + 1, activeLocale));
@@ -656,7 +712,7 @@ function mintlifyNavigation(body, base, locale) {
656
712
  return;
657
713
  }
658
714
  if (!object(node)) return;
659
- const href = target(node.href, base);
715
+ const href = configTarget(node.href);
660
716
  if (href) {
661
717
  populated++;
662
718
  const url2 = new URL(href);
@@ -674,10 +730,10 @@ function mintlifyNavigation(body, base, locale) {
674
730
  const selected = matching[0]?.item ?? languages.find((item) => text(item.language)?.toLowerCase() === language?.toLowerCase()) ?? languages.find((item) => text(item.language)?.toLowerCase() === "en") ?? languages[0];
675
731
  return selected ? walk({ ...selected, languages: void 0 }, depth + 1, text(selected.language)) : [];
676
732
  }
677
- const label = text(value.tab) ?? text(value.group) ?? text(value.title) ?? text(value.label);
678
- const kind = text(value.tab) ? "tab" : text(value.group) ? "group" : "page";
733
+ const label = text(value.tab) ?? text(value.dropdown) ?? text(value.group) ?? text(value.sidebarTitle) ?? text(value.title) ?? text(value.label);
734
+ const kind = text(value.tab) || text(value.dropdown) ? "tab" : text(value.group) ? "group" : "page";
679
735
  const children = ["tabs", "groups", "pages", "dropdowns", "anchors", "versions"].flatMap((key) => Array.isArray(value[key]) ? walk(value[key], depth + 1, activeLocale) : []);
680
- const url = target(value.href, base);
736
+ const url = configTarget(value.href);
681
737
  if (!label) return children;
682
738
  if (!url && !children.length) return [];
683
739
  return [{
@@ -685,6 +741,7 @@ function mintlifyNavigation(body, base, locale) {
685
741
  label,
686
742
  ...url ? { url } : {},
687
743
  ...children.length ? { children } : {},
744
+ ...text(value.dropdown) ? { presentation: "dropdown" } : {},
688
745
  ...kind === "tab" && activeLocale ? { locale: activeLocale } : {}
689
746
  }];
690
747
  };
@@ -711,18 +768,19 @@ function resolveNavigation(nodes, base) {
711
768
  ...url ? { url } : {},
712
769
  ...children.length ? { children } : {},
713
770
  ...["tab", "group", "page"].includes(node.kind ?? "") ? { kind: node.kind } : {},
771
+ ...node.presentation === "dropdown" ? { presentation: "dropdown" } : {},
714
772
  ...text(node.locale) ? { locale: text(node.locale) } : {}
715
773
  }];
716
774
  });
717
775
  };
718
776
  return walk(nodes);
719
777
  }
720
- function extractSourceNavigation(capture) {
778
+ function extractSourceNavigation(capture, selectedScope) {
721
779
  if (capture.navigation) return resolveNavigation(capture.navigation, capture.finalUrl);
722
780
  if (!/html/i.test(capture.contentType) || Buffer.byteLength(capture.body) > MAX_CAPTURE_BYTES) return [];
723
- const mintlify = mintlifyNavigation(capture.body, capture.finalUrl, capture.locale);
781
+ const mintlify = mintlifyNavigation(capture.body, capture.finalUrl, capture.locale, selectedScope);
724
782
  if (mintlify.length) return mintlify;
725
- const $ = load(capture.body);
783
+ const $ = load2(capture.body);
726
784
  const candidates = ['.theme-doc-sidebar-menu,[aria-label*="sidebar" i]', "aside", '[role="navigation"],nav'];
727
785
  const root = candidates.map((selector) => $(selector).filter((_index, node) => $(node).find("a[href]").length > 0 && $(node).closest("header").length === 0).first()).find((candidate) => candidate.length);
728
786
  if (!root) return [];
@@ -804,7 +862,7 @@ function migrationImageUrl(value, base) {
804
862
  if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error("Responsive image URL is not a credential-free HTTP(S) URL.");
805
863
  return url.toString();
806
864
  }
807
- function migrationImageCandidates(value, base, resolve3 = true) {
865
+ function migrationImageCandidates(value, base, resolve4 = true) {
808
866
  if (value.length > 64e3) throw new Error("Responsive image candidate list exceeds its safety bound.");
809
867
  const result = [];
810
868
  let index = 0;
@@ -822,7 +880,7 @@ function migrationImageCandidates(value, base, resolve3 = true) {
822
880
  if (descriptor && !/^(?:[1-9]\d*w|(?:\d+(?:\.\d+)?|\.\d+)x)$/.test(descriptor)) throw new Error("Responsive image has an unsupported descriptor.");
823
881
  if (descriptor.endsWith("x") && Number(descriptor.slice(0, -1)) <= 0) throw new Error("Responsive image density must be positive.");
824
882
  const resolved = migrationImageUrl(url, base);
825
- result.push({ url: resolve3 ? resolved : url, descriptor });
883
+ result.push({ url: resolve4 ? resolved : url, descriptor });
826
884
  if (result.length > 128) throw new Error("Responsive image has too many candidates.");
827
885
  if (value[index] === ",") index++;
828
886
  }
@@ -836,8 +894,88 @@ function migrationImageAttribute(value) {
836
894
  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;");
837
895
  }
838
896
 
897
+ // src/url-media.ts
898
+ import { load as load3 } from "cheerio";
899
+ var VIDEO_ATTRIBUTES = /* @__PURE__ */ new Set([
900
+ "src",
901
+ "title",
902
+ "poster",
903
+ "controls",
904
+ "muted",
905
+ "autoplay",
906
+ "loop",
907
+ "playsinline",
908
+ "class",
909
+ "id",
910
+ "width",
911
+ "height",
912
+ "preload",
913
+ "aria-label"
914
+ ]);
915
+ var YOUTUBE_ATTRIBUTES = /* @__PURE__ */ new Set([
916
+ "src",
917
+ "title",
918
+ "width",
919
+ "height",
920
+ "allow",
921
+ "allowfullscreen",
922
+ "referrerpolicy",
923
+ "frameborder",
924
+ "loading",
925
+ "class",
926
+ "id",
927
+ "aria-label"
928
+ ]);
929
+ var YOUTUBE_HOSTS = /* @__PURE__ */ new Set([
930
+ "youtube.com",
931
+ "www.youtube.com",
932
+ "youtube-nocookie.com",
933
+ "www.youtube-nocookie.com"
934
+ ]);
935
+ function projectMigrationMedia(html, base) {
936
+ const $ = load3(html, void 0, false);
937
+ const nodes = $.root().contents().filter((_index, node2) => node2.type !== "text" || Boolean(node2.data.trim()));
938
+ if (nodes.length !== 1) return null;
939
+ const element = nodes.first();
940
+ const node = element[0];
941
+ if (node.type !== "tag" || !["video", "iframe"].includes(node.name)) return null;
942
+ if (element.children().length || element.text().trim()) return null;
943
+ const allowed = node.name === "video" ? VIDEO_ATTRIBUTES : YOUTUBE_ATTRIBUTES;
944
+ if (Object.keys(node.attribs).some((name) => !allowed.has(name))) return null;
945
+ const source = element.attr("src");
946
+ if (!source) return null;
947
+ try {
948
+ let src = migrationImageUrl(source, base);
949
+ let component = "Video";
950
+ const props = [];
951
+ if (node.name === "video") {
952
+ if (!/\.(?:mp4|webm|ogg|mov|m4v)(?:[?#]|$)/i.test(src)) component = "video";
953
+ const isMuted = element.attr("muted") !== void 0;
954
+ const hasAutoplay = element.attr("autoplay") !== void 0;
955
+ if (hasAutoplay && !isMuted) return null;
956
+ props.push("controls={true}");
957
+ for (const [attribute, prop] of [["muted", "muted"], ["loop", "loop"], ["playsinline", "playsInline"], ["autoplay", "autoPlay"]]) {
958
+ if (element.attr(attribute) !== void 0) props.push(`${prop}={true}`);
959
+ }
960
+ const poster = element.attr("poster");
961
+ if (poster) props.push(`poster="${migrationImageAttribute(migrationImageUrl(poster, base))}"`);
962
+ } else {
963
+ const url = new URL(src);
964
+ if (url.protocol !== "https:" || url.port || !YOUTUBE_HOSTS.has(url.hostname)) return null;
965
+ const id = url.pathname.match(/^\/embed\/([A-Za-z0-9_-]{11})$/)?.[1];
966
+ if (!id || url.hash || [...url.searchParams.keys()].some((name) => name !== "si")) return null;
967
+ src = `https://www.youtube-nocookie.com/embed/${id}`;
968
+ }
969
+ const title = element.attr("title") ?? element.attr("aria-label");
970
+ if (title !== void 0) props.push(`title="${migrationImageAttribute(title)}"`);
971
+ return `<${component} src="${migrationImageAttribute(src)}"${props.length ? ` ${props.join(" ")}` : ""} />`;
972
+ } catch {
973
+ return null;
974
+ }
975
+ }
976
+
839
977
  // src/url.ts
840
- import { load as load2 } from "cheerio";
978
+ import { load as load4 } from "cheerio";
841
979
  import remarkMdx2 from "remark-mdx";
842
980
  import remarkParse2 from "remark-parse";
843
981
  import TurndownService from "turndown";
@@ -2470,9 +2608,10 @@ var PORTABLE_MDX_COMPONENTS = /* @__PURE__ */ new Set([
2470
2608
  "Warning",
2471
2609
  "YouTube",
2472
2610
  "picture",
2473
- "source"
2611
+ "source",
2612
+ "video"
2474
2613
  ]);
2475
- var PORTABLE_URL_PROPS = "href|src|img|primaryHref|secondaryHref";
2614
+ var PORTABLE_URL_PROPS = "href|src|poster|img|primaryHref|secondaryHref";
2476
2615
  var PORTABLE_URL_PROP_NAMES = new Set(PORTABLE_URL_PROPS.split("|"));
2477
2616
  var MINTLIFY_ARTICLE_LABELS = [
2478
2617
  { baseUrl: "Select base URL", permalink: "Navigate to header" },
@@ -2612,7 +2751,7 @@ function isSafePortableUrl(value) {
2612
2751
  }
2613
2752
  }
2614
2753
  function decodeHtmlAttribute(value) {
2615
- const document = load2(`<i data-thally-value="${value.replace(/"/g, "&quot;")}"></i>`);
2754
+ const document = load4(`<i data-thally-value="${value.replace(/"/g, "&quot;")}"></i>`);
2616
2755
  return document("i").attr("data-thally-value") ?? "";
2617
2756
  }
2618
2757
  function escapeMarkdownText(value, escapeTableSeparator = false) {
@@ -2698,7 +2837,7 @@ function replacePortableHtmlImages(value) {
2698
2837
  if (start < cursor) continue;
2699
2838
  const end = htmlOpeningTagEnd(value, start + match[0].length);
2700
2839
  if (end === null) continue;
2701
- const fragment = load2(value.slice(start, end));
2840
+ const fragment = load4(value.slice(start, end));
2702
2841
  const image = fragment("img").first();
2703
2842
  const source = image.attr("src");
2704
2843
  const destination = source && isSafePortableUrl(source) ? markdownLinkDestination(source) : null;
@@ -2723,7 +2862,7 @@ function replacePortableHtmlAnchors(value) {
2723
2862
  const closing = closingMatch.exec(value);
2724
2863
  if (!closing) continue;
2725
2864
  const end = closing.index + closing[0].length;
2726
- const fragment = load2(value.slice(start, end));
2865
+ const fragment = load4(value.slice(start, end));
2727
2866
  const anchor = fragment("a").first();
2728
2867
  const href = anchor.attr("href");
2729
2868
  const label = escapeMarkdownText(anchor.text().replace(/\s+/g, " ").trim());
@@ -2758,6 +2897,10 @@ function sanitizeMarkdownUrls(value) {
2758
2897
  hasUnsafeMdxSyntax = true;
2759
2898
  return;
2760
2899
  }
2900
+ if (node.name === "video" && node.children?.length) {
2901
+ hasUnsafeMdxSyntax = true;
2902
+ return;
2903
+ }
2761
2904
  for (const attribute of node.attributes ?? []) {
2762
2905
  if (attribute.type !== "mdxJsxAttribute" || !attribute.name || /^on[A-Z]/.test(attribute.name)) {
2763
2906
  hasUnsafeMdxSyntax = true;
@@ -2794,6 +2937,22 @@ function sanitizeMarkdownUrls(value) {
2794
2937
  return;
2795
2938
  }
2796
2939
  }
2940
+ if (node.name === "video") {
2941
+ const isBoolean = ["controls", "muted", "autoPlay", "loop", "playsInline"].includes(attribute.name);
2942
+ const isText = ["src", "poster", "title"].includes(attribute.name);
2943
+ if (isBoolean && typeof staticValue !== "boolean" || isText && typeof attribute.value !== "string" || !isBoolean && !isText) {
2944
+ hasUnsafeMdxSyntax = true;
2945
+ return;
2946
+ }
2947
+ if (attribute.name === "src" || attribute.name === "poster") {
2948
+ try {
2949
+ migrationImageUrl(decodeHtmlAttribute(attribute.value), new URL("https://migration.invalid"));
2950
+ } catch {
2951
+ hasUnsafeMdxSyntax = true;
2952
+ return;
2953
+ }
2954
+ }
2955
+ }
2797
2956
  }
2798
2957
  }
2799
2958
  for (const child of node.children ?? []) validateMdxSyntax(child);
@@ -2906,7 +3065,7 @@ function sanitizeRemoteMarkdown(body) {
2906
3065
  const urlSafeMarkdown = sanitizeMarkdownUrls(sanitized);
2907
3066
  if (urlSafeMarkdown === null) return null;
2908
3067
  const executable = withoutFencedCode(urlSafeMarkdown).replace(/\{\/\*[\s\S]*?\*\/\}/g, "").replace(/<(?:https?:\/\/|mailto:)[^>]+>/gi, "").replace(/(`+)[\s\S]*?\1/g, "");
2909
- if (/^\s*(?:import|export)\s/m.test(executable)) return null;
3068
+ if (/^[\t ]*(?:import|export)[\t ]/m.test(executable)) return null;
2910
3069
  let hasUnsafeExpression = false;
2911
3070
  const withoutStaticProps = executable.replace(/=\{([^{}\n]*)\}/g, (original, value) => {
2912
3071
  try {
@@ -2964,7 +3123,7 @@ function extractEmbeddedOpenApi(content) {
2964
3123
  const root = embeddedOpenApiParser.parse(content);
2965
3124
  const candidates = (root.children ?? []).flatMap((node2) => {
2966
3125
  if (node2.type !== "code" || !/^(?:yaml|yml|json)$/i.test(node2.lang ?? "")) return [];
2967
- const metadata = node2.meta?.match(/^(?:(\S+\.(?:yaml|yml|json))\s+)?(delete|get|head|options|patch|post|put|trace)\s+(\/\S+)\s*$/i);
3126
+ const metadata = node2.meta?.match(/^(?:((?:\S+\.(?:yaml|yml|json)|[A-Za-z][A-Za-z0-9_-]{0,127}))\s+)?(delete|get|head|options|patch|post|put|trace)\s+(\/\S+)\s*$/i);
2968
3127
  return metadata ? [{ node: node2, method: metadata[2].toUpperCase(), path: metadata[3] }] : [];
2969
3128
  });
2970
3129
  if (candidates.length !== 1) return { content };
@@ -3049,7 +3208,7 @@ function markdownPage(document, id, platform, warn) {
3049
3208
  };
3050
3209
  }
3051
3210
  function migrationHtmlArticle(body) {
3052
- const $ = load2(body);
3211
+ const $ = load4(body);
3053
3212
  const root = ["main article", "article", "main", '[role="main"]', "body"].map((selector) => $(selector).first()).find((candidate) => candidate.length);
3054
3213
  root.find([
3055
3214
  "script",
@@ -3067,7 +3226,10 @@ function migrationHtmlArticle(body) {
3067
3226
  "#chat-assistant-sheet",
3068
3227
  "#chat-assistant-textarea"
3069
3228
  ].join(",")).remove();
3070
- const permalinkSelectors = MINTLIFY_ARTICLE_LABELS.map((labels) => `a[aria-label="${labels.permalink}"]`);
3229
+ root.find('div[data-component-part="code-group-tab-content"]').each((_index, element) => {
3230
+ if ($(element).parents("pre").length === 0 && $(element).find("pre").length === 0) element.name = "pre";
3231
+ });
3232
+ const permalinkSelectors = MINTLIFY_ARTICLE_LABELS.map((labels2) => `a[aria-label="${labels2.permalink}"]`);
3071
3233
  root.find([...permalinkSelectors, "h1 .hash-link", "h2 .hash-link", "h3 .hash-link", "h4 .hash-link", "h5 .hash-link", "h6 .hash-link"].join(",")).each((_index, link) => {
3072
3234
  const parent = $(link).parent();
3073
3235
  $(link).remove();
@@ -3078,7 +3240,7 @@ function migrationHtmlArticle(body) {
3078
3240
  const isChecked = $(element).attr("checked") !== void 0;
3079
3241
  $(element).replaceWith($("<span></span>").attr("data-thally-static-task-checkbox", isChecked ? "checked" : "unchecked").text(isChecked ? "[x] " : "[ ] "));
3080
3242
  });
3081
- root.find(MINTLIFY_ARTICLE_LABELS.map((labels) => `select[aria-label="${labels.baseUrl}"]`).join(",")).each((_index, element) => {
3243
+ root.find(MINTLIFY_ARTICLE_LABELS.map((labels2) => `select[aria-label="${labels2.baseUrl}"]`).join(",")).each((_index, element) => {
3082
3244
  const select = $(element);
3083
3245
  const options = select.children("option").toArray();
3084
3246
  if (!options.length || options.length !== select.children().length) return;
@@ -3117,7 +3279,7 @@ function htmlPage(document, id, options = {}) {
3117
3279
  const title = scopedTitle || $("main h1, article h1, h1").first().text().trim() || $('meta[property="og:title"]').attr("content")?.trim() || $("title").text().split("|")[0].trim();
3118
3280
  const description = $('meta[name="description"]').attr("content")?.trim() || $('meta[property="og:description"]').attr("content")?.trim();
3119
3281
  let documentHeadingAnchor = "";
3120
- const excludedElements = options.omitDocumentHeading ? "script,style,noscript,nav,footer,aside,form,button,svg,iframe,object,embed,link,meta" : "script,style,noscript,nav,header,footer,aside,form,button,svg,iframe,object,embed,link,meta";
3282
+ const excludedElements = options.omitDocumentHeading ? "script,style,noscript,nav,footer,aside,form,button,svg,object,embed,link,meta" : "script,style,noscript,nav,header,footer,aside,form,button,svg,object,embed,link,meta";
3121
3283
  root.find(excludedElements).remove();
3122
3284
  root.find(".theme-doc-version-badge,.theme-doc-breadcrumbs,.theme-doc-toc-mobile,.table-of-contents,.hash-link").remove();
3123
3285
  if (options.omitDocumentHeading) {
@@ -3175,6 +3337,18 @@ function htmlPage(document, id, options = {}) {
3175
3337
  // Emit literal task-list syntax rather than escaping its brackets as copy.
3176
3338
  replacement: (_content, node) => node.getAttribute("data-thally-static-task-checkbox") === "checked" ? "[x] " : "[ ] "
3177
3339
  });
3340
+ turndown.addRule("migration-authored-paragraph", {
3341
+ filter: (node) => node.nodeName === "SPAN" && node.getAttribute("data-as") === "p",
3342
+ // Some publishers render authored paragraphs as spans. Their explicit
3343
+ // semantic hint must retain a block boundary: otherwise following copy
3344
+ // joins a picture's closing tag or an accordion's source anchor and can
3345
+ // change the resulting MDX grammar. Ordinary spans remain inline.
3346
+ replacement: (content) => `
3347
+
3348
+ ${content}
3349
+
3350
+ `
3351
+ });
3178
3352
  const anchor = (element) => {
3179
3353
  const value = element.getAttribute("id");
3180
3354
  return options.preserveAnchors && value ? `<a id="${value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/\{/g, "&#123;")}" />
@@ -3193,6 +3367,18 @@ ${anchor(element)}${heading ? `${"#".repeat(Number(heading[1]))} ${content.trim(
3193
3367
  `;
3194
3368
  }
3195
3369
  });
3370
+ turndown.addRule("migration-static-media", {
3371
+ filter: (node) => node.nodeName === "VIDEO" || node.nodeName === "IFRAME",
3372
+ replacement: (_content, node) => {
3373
+ const element = node;
3374
+ const projected = projectMigrationMedia(element.outerHTML, document.finalUrl);
3375
+ return projected ? `
3376
+
3377
+ ${anchor(element)}${projected}
3378
+
3379
+ ` : "";
3380
+ }
3381
+ });
3196
3382
  if (options.preserveResponsiveImages) {
3197
3383
  const image = (element) => {
3198
3384
  const tag = element.nodeName.toLowerCase();
@@ -3228,7 +3414,7 @@ ${content.trim()}
3228
3414
  const element = node;
3229
3415
  const className = element.className;
3230
3416
  const tag = /admonition-(?:danger)/.test(className) ? "Error" : /admonition-(?:caution|warning)/.test(className) ? "Warning" : /admonition-info/.test(className) ? "Info" : "Note";
3231
- const fragment = load2(element.outerHTML);
3417
+ const fragment = load4(element.outerHTML);
3232
3418
  const clone = fragment(".theme-admonition").first();
3233
3419
  const title2 = clone.find('[class*="admonitionHeading"],.admonition-heading').first();
3234
3420
  const titleText = title2.text().trim();
@@ -3249,15 +3435,15 @@ ${titleText ? `**${escapeMarkdownText(titleText)}**
3249
3435
  filter: (node) => node.nodeName === "DIV" && node.classList.contains("tabs-container"),
3250
3436
  replacement: (_content, node) => {
3251
3437
  const element = node;
3252
- const fragment = load2(element.outerHTML);
3438
+ const fragment = load4(element.outerHTML);
3253
3439
  const container = fragment(".tabs-container").first();
3254
- const labels = container.children("ul.tabs").children("li").toArray().map((item) => fragment(item).text().trim() || "Tab");
3440
+ const labels2 = container.children("ul.tabs").children("li").toArray().map((item) => fragment(item).text().trim() || "Tab");
3255
3441
  const panels = container.find('[role="tabpanel"]').toArray().filter((panel) => fragment(panel).closest(".tabs-container")[0] === container[0]);
3256
3442
  if (panels.length === 0) return _content;
3257
3443
  const tabs = panels.map((panel, index) => {
3258
3444
  const panelId = fragment(panel).attr("id");
3259
3445
  const tab = panelId ? container.find('[role="tab"]').toArray().find((item) => fragment(item).attr("aria-controls") === panelId) : void 0;
3260
- const title2 = migrationImageAttribute((tab ? fragment(tab).text().trim() : labels[index]) || `Tab ${index + 1}`);
3446
+ const title2 = migrationImageAttribute((tab ? fragment(tab).text().trim() : labels2[index]) || `Tab ${index + 1}`);
3261
3447
  const panelAnchor = options.preserveAnchors && panelId ? `<a id="${migrationImageAttribute(panelId)}" />
3262
3448
 
3263
3449
  ` : "";
@@ -3277,7 +3463,7 @@ ${tabs.join("\n\n")}
3277
3463
  turndown.addRule("gfm-table", {
3278
3464
  filter: "table",
3279
3465
  replacement: (_content, node) => {
3280
- const fragment = load2(node.outerHTML);
3466
+ const fragment = load4(node.outerHTML);
3281
3467
  const rows = fragment("tr").toArray().map((row) => fragment(row).children("th,td").toArray().map((cell) => {
3282
3468
  const wasConvertingTableCell = isConvertingTableCell;
3283
3469
  isConvertingTableCell = true;
@@ -3303,7 +3489,7 @@ ${normalized.slice(1).map((row) => `| ${row.join(" | ")} |`).join("\n")}
3303
3489
  filter: (node) => node.nodeName === "PRE",
3304
3490
  replacement: (_content, node) => {
3305
3491
  const element = node;
3306
- const fragment = load2(element.outerHTML);
3492
+ const fragment = load4(element.outerHTML);
3307
3493
  const tokenLines = fragment(".token-line").toArray();
3308
3494
  const code = (tokenLines.length > 0 ? tokenLines.map((line) => fragment(line).text()).join("\n") : fragment("pre").text()).replace(/\n$/, "");
3309
3495
  const language = ["code", "pre"].flatMap((selector) => {
@@ -3343,8 +3529,127 @@ ${fence}
3343
3529
  };
3344
3530
  }
3345
3531
 
3532
+ // src/url-api-schema-controls.ts
3533
+ var MAX_CAPTURE_BYTES2 = 8e6;
3534
+ var MAX_TAB_GROUPS = 256;
3535
+ var MAX_TABS = 1024;
3536
+ function object2(value) {
3537
+ return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
3538
+ }
3539
+ function localReference(document, reference) {
3540
+ if (typeof reference !== "string" || !reference.startsWith("#/")) return void 0;
3541
+ let target2 = document;
3542
+ try {
3543
+ for (const part of reference.slice(2).split("/")) {
3544
+ const key = decodeURIComponent(part).replace(/~1/g, "/").replace(/~0/g, "~");
3545
+ if (!target2 || typeof target2 !== "object" || !Object.hasOwn(target2, key)) return void 0;
3546
+ target2 = target2[key];
3547
+ }
3548
+ } catch {
3549
+ return void 0;
3550
+ }
3551
+ return target2;
3552
+ }
3553
+ function resolve2(document, value) {
3554
+ const seen = /* @__PURE__ */ new Set();
3555
+ for (let depth = 0; depth < 32; depth += 1) {
3556
+ const schema = object2(value);
3557
+ if (!schema || seen.has(schema)) return void 0;
3558
+ seen.add(schema);
3559
+ if (!Object.hasOwn(schema, "$ref")) return schema;
3560
+ value = localReference(document, schema.$ref);
3561
+ }
3562
+ return void 0;
3563
+ }
3564
+ function complete(document) {
3565
+ let visited = 0;
3566
+ const active = /* @__PURE__ */ new Set();
3567
+ const visit = (value, depth) => {
3568
+ if (++visited > 1e5 || depth > 96) return false;
3569
+ if (typeof value === "number" && !Number.isFinite(value)) return false;
3570
+ if (!value || typeof value !== "object") return true;
3571
+ if (active.has(value)) return false;
3572
+ active.add(value);
3573
+ const schema = object2(value);
3574
+ const valid = (!schema || !Object.hasOwn(schema, "$ref") || Boolean(object2(localReference(document, schema.$ref)))) && Object.values(value).every((child) => visit(child, depth + 1));
3575
+ active.delete(value);
3576
+ return valid;
3577
+ };
3578
+ return visit(document, 0);
3579
+ }
3580
+ function labels(document, value) {
3581
+ const schema = resolve2(document, value);
3582
+ if (!Array.isArray(schema?.oneOf) || schema.oneOf.length < 2 || schema.oneOf.length > 64) return void 0;
3583
+ const result = [];
3584
+ for (const [index, branch] of schema.oneOf.entries()) {
3585
+ const target2 = resolve2(document, branch);
3586
+ if (!target2) return void 0;
3587
+ const title = object2(branch)?.title ?? target2.title;
3588
+ result.push(typeof title === "string" && title.trim() ? title.trim().replace(/\s+/g, " ") : `Option ${index + 1}`);
3589
+ }
3590
+ return new Set(result).size === result.length ? result : void 0;
3591
+ }
3592
+ function apiSchemaTabGroups(body, document, operation) {
3593
+ if (Buffer.byteLength(body) > MAX_CAPTURE_BYTES2 || !complete(document)) return [];
3594
+ const path = object2(document.paths)?.[operation.path];
3595
+ const method = operation.method.toLowerCase();
3596
+ if (!/^(?:get|post|put|patch|delete|head|options|trace)$/.test(method)) return [];
3597
+ const selected = object2(object2(path)?.[method]);
3598
+ if (!selected) return [];
3599
+ const { $, root } = migrationHtmlArticle(body);
3600
+ const groups = root.find('[role="tablist"],[data-component-part="tabs-list"]');
3601
+ if (groups.length > MAX_TAB_GROUPS || root.find('[role="tab"]').length > MAX_TABS) return [];
3602
+ const idCounts = /* @__PURE__ */ new Map();
3603
+ root.find("[id]").each((_index, node) => {
3604
+ const id = $(node).attr("id");
3605
+ idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
3606
+ });
3607
+ const proofs = [];
3608
+ groups.each((index, node) => {
3609
+ const group = $(node);
3610
+ const container = group.parent(".tabs.tab-container");
3611
+ const field = group.closest(".object-param-field");
3612
+ const section = field.parent(".api-section");
3613
+ if (!group.is('[role="tablist"][data-component-part="tabs-list"]') || !container.length || !section.length || !section.closest('[id^="api-playground-"][id$="-operation-page"]').length) return;
3614
+ if (field.parents(".object-param-field").length) return;
3615
+ const heading = section.children(".api-section-heading");
3616
+ const title = heading.find(".api-section-heading-title").text().trim().toLowerCase();
3617
+ const media = heading.find("*").toArray().filter((element) => $(element).children().length === 0).map((element) => $(element).text().trim()).filter((text2) => /^[a-z][a-z\d.+-]*\/[a-z\d.+-]+$/i.test(text2));
3618
+ if (heading.length !== 1 || !media || media.length !== 1) return;
3619
+ let schema;
3620
+ let scope;
3621
+ if (title === "body") {
3622
+ const request = resolve2(document, selected.requestBody);
3623
+ schema = object2(object2(request?.content)?.[media[0]])?.schema;
3624
+ scope = `request:${media[0]}`;
3625
+ } else if (title === "response") {
3626
+ const statuses = heading.find('button[aria-haspopup="menu"]').toArray().map((button) => $(button).text().trim()).filter((text2) => /^(?:[1-5]\d{2}|default)$/.test(text2));
3627
+ if (statuses.length !== 1) return;
3628
+ const response = resolve2(document, object2(selected.responses)?.[statuses[0]]);
3629
+ schema = object2(object2(response?.content)?.[media[0]])?.schema;
3630
+ scope = `response:${statuses[0]}:${media[0]}`;
3631
+ } else return;
3632
+ const expected = labels(document, schema);
3633
+ const tabs = group.children('[role="tab"][data-component-part="tab"]');
3634
+ if (!expected || tabs.length !== expected.length || group.children().length !== tabs.length) return;
3635
+ const panels = container.children('[data-component-part="tabs-panels"]').children('[role="tabpanel"][data-component-part="tab-content"]');
3636
+ if (panels.length !== tabs.length) return;
3637
+ const ids = /* @__PURE__ */ new Set();
3638
+ for (const [position, tab] of tabs.toArray().entries()) {
3639
+ const control = $(tab).attr("aria-controls");
3640
+ const panel = panels.eq(position);
3641
+ if ($(tab).text().trim().replace(/\s+/g, " ") !== expected[position] || !control || ids.has(control) || panel.attr("id") !== control || !$(tab).attr("id") || panel.attr("aria-labelledby") !== $(tab).attr("id") || idCounts.get(control) !== 1 || panel.text().trim() || panel.children().length) return;
3642
+ ids.add(control);
3643
+ }
3644
+ proofs.push({ index, scope });
3645
+ });
3646
+ const scopeCounts = /* @__PURE__ */ new Map();
3647
+ for (const proof of proofs) scopeCounts.set(proof.scope, (scopeCounts.get(proof.scope) ?? 0) + 1);
3648
+ return proofs.filter((proof) => scopeCounts.get(proof.scope) === 1).map((proof) => proof.index);
3649
+ }
3650
+
3346
3651
  // src/url-run.ts
3347
- var CONVERTER_VERSION = "url-evidence-v4";
3652
+ var CONVERTER_VERSION = "url-evidence-v5";
3348
3653
  var parser = unified3().use(remarkParse3).use(remarkMdx3);
3349
3654
  var ACCEPT = "text/markdown,text/html,application/xhtml+xml;q=0.9";
3350
3655
  var MAX_RESPONSE_BYTES = 8e6;
@@ -3407,7 +3712,7 @@ function linksOf(document) {
3407
3712
  return [...root.find("a[href]").toArray(), ...$('link[rel="sitemap"][href],link[rel="alternate"][href]').toArray()].map((node) => $(node).attr("href")).filter(Boolean);
3408
3713
  }
3409
3714
  if (/xml/i.test(document.contentType) || /<urlset|<sitemapindex/i.test(document.body)) {
3410
- const $ = load3(document.body, { xmlMode: true });
3715
+ const $ = load5(document.body, { xmlMode: true });
3411
3716
  return $("loc").toArray().map((node) => $(node).text().trim());
3412
3717
  }
3413
3718
  return [
@@ -3450,8 +3755,8 @@ function markdownHeadingAnchors(body) {
3450
3755
  return occurrence ? `${base}-${occurrence}` : base;
3451
3756
  });
3452
3757
  }
3453
- function sourceNavigation(capture) {
3454
- return extractSourceNavigation(capture);
3758
+ function sourceNavigation(capture, selectedScope) {
3759
+ return extractSourceNavigation(capture, selectedScope);
3455
3760
  }
3456
3761
  function idForUrl(url, scope) {
3457
3762
  const relative4 = scope === "/" ? url.pathname.slice(1) : url.pathname.slice(scope.length).replace(/^\//, "");
@@ -3460,7 +3765,7 @@ function idForUrl(url, scope) {
3460
3765
  }
3461
3766
  function articleIdentity(document) {
3462
3767
  if (!/html/i.test(document.contentType)) return document.finalUrl;
3463
- const $ = load3(document.body);
3768
+ const $ = load5(document.body);
3464
3769
  const reference = $('link[rel="canonical"][href]').first().attr("href");
3465
3770
  if (!reference) return document.finalUrl;
3466
3771
  try {
@@ -3516,16 +3821,24 @@ function assetReferences(capture) {
3516
3821
  }
3517
3822
  const { $, root } = migrationHtmlArticle(capture.body);
3518
3823
  return [
3519
- ...root.find("img[src],video[poster],a[download][href]").toArray().map((node) => $(node).attr("src") ?? $(node).attr("poster") ?? $(node).attr("href")),
3824
+ ...root.find("img[src],video[poster],a[download][href]").toArray().map((node) => $(node).is("video") ? $(node).attr("poster") : $(node).is("a") ? $(node).attr("href") : $(node).attr("src")),
3520
3825
  ...root.find("[srcset]").toArray().flatMap((node) => migrationImageCandidates($(node).attr("srcset") ?? "", capture.finalUrl).map((candidate) => candidate.url))
3521
3826
  ].filter(Boolean);
3522
3827
  }
3523
- function unresolvedStaticControls(body, hasApiSchema = false) {
3828
+ function unresolvedStaticControls(body, hasApiSchema = false, coveredSchemaGroups = []) {
3524
3829
  const { $, root } = migrationHtmlArticle(body);
3830
+ root.find('[role="tablist"],[data-component-part="tabs-list"]').each((index, node) => {
3831
+ if (coveredSchemaGroups.includes(index)) $(node).remove();
3832
+ });
3525
3833
  const tabs = root.find('[role="tab"]').toArray();
3526
3834
  for (const tab of tabs) {
3527
3835
  const controls = $(tab).attr("aria-controls");
3528
- const panels = controls ? root.find("[id]").filter((_index, node) => $(node).attr("id") === controls) : root.find('[role="tabpanel"]').eq(tabs.indexOf(tab));
3836
+ const group = $(tab).closest('[role="tablist"]');
3837
+ const container = group.closest(".code-group,.tabs-container,.tabs.tab-container");
3838
+ const peers = group.find('[role="tab"]').toArray().filter((node) => $(node).closest('[role="tablist"]')[0] === group[0]);
3839
+ const localPanels = container.find('[role="tabpanel"]').toArray().filter((node) => $(node).closest(".code-group,.tabs-container,.tabs.tab-container")[0] === container[0]);
3840
+ const owners = container.find('[role="tablist"]').toArray().filter((node) => $(node).closest(".code-group,.tabs-container,.tabs.tab-container")[0] === container[0]);
3841
+ const panels = controls ? root.find("[id]").filter((_index, node) => $(node).attr("id") === controls) : owners.length === 1 && peers.length === localPanels.length ? $(localPanels).eq(peers.indexOf(tab)) : $([]);
3529
3842
  if (panels.length !== 1 || !panels.text().trim() && !panels.find("pre,img,table,svg").length || panels.find('[aria-busy="true"],[role="progressbar"]').length) return true;
3530
3843
  }
3531
3844
  return root.find("details:not([open])").toArray().some((node) => {
@@ -3576,12 +3889,12 @@ function hasCompleteLocalApiSchema(document) {
3576
3889
  if (typeof value === "number" && !Number.isFinite(value)) return false;
3577
3890
  if (!value || typeof value !== "object") return true;
3578
3891
  if (Array.isArray(value)) return value.every((child) => visit(child, depth + 1));
3579
- const object2 = value;
3580
- if (Object.prototype.hasOwnProperty.call(object2, "$ref")) {
3581
- if (typeof object2.$ref !== "string" || !object2.$ref.startsWith("#/")) return false;
3892
+ const object3 = value;
3893
+ if (Object.prototype.hasOwnProperty.call(object3, "$ref")) {
3894
+ if (typeof object3.$ref !== "string" || !object3.$ref.startsWith("#/")) return false;
3582
3895
  let target2 = document;
3583
3896
  try {
3584
- for (const encoded of object2.$ref.slice(2).split("/")) {
3897
+ for (const encoded of object3.$ref.slice(2).split("/")) {
3585
3898
  const key = decodeURIComponent(encoded).replace(/~1/g, "/").replace(/~0/g, "~");
3586
3899
  if (!target2 || typeof target2 !== "object" || !Object.prototype.hasOwnProperty.call(target2, key)) return false;
3587
3900
  target2 = target2[key];
@@ -3590,7 +3903,7 @@ function hasCompleteLocalApiSchema(document) {
3590
3903
  return false;
3591
3904
  }
3592
3905
  }
3593
- return Object.values(object2).every((child) => visit(child, depth + 1));
3906
+ return Object.values(object3).every((child) => visit(child, depth + 1));
3594
3907
  };
3595
3908
  return visit(document);
3596
3909
  }
@@ -3605,6 +3918,25 @@ function resolveMigrationRecord(records, record) {
3605
3918
  }
3606
3919
  function navigationConfig(checkpoint, records) {
3607
3920
  const navigation = checkpoint.navigation;
3921
+ const shortcuts = [];
3922
+ const collectShortcuts = (nodes) => {
3923
+ for (const node of nodes) {
3924
+ if (node.url) {
3925
+ const record = records.get(canonical(node.url, checkpoint.sourceUrl) ?? "");
3926
+ const href = record?.redirectTo ?? node.url;
3927
+ const identity = canonical(href, checkpoint.sourceUrl);
3928
+ const target2 = identity ? new URL(href, checkpoint.sourceUrl).href : null;
3929
+ if (target2 && !inScope(new URL(target2), new URL(checkpoint.sourceUrl), checkpoint.scopePath) && !shortcuts.some((link) => link.label === node.label && link.href === target2)) shortcuts.push({ label: node.label, href: target2 });
3930
+ }
3931
+ collectShortcuts(node.children ?? []);
3932
+ }
3933
+ };
3934
+ collectShortcuts(navigation);
3935
+ const hasDropdown = navigation.some((node) => node.presentation === "dropdown");
3936
+ const externalNavigation = shortcuts.length || hasDropdown ? { navigation: {
3937
+ ...shortcuts.length ? { shortcuts } : {},
3938
+ ...hasDropdown ? { display: "dropdown" } : {}
3939
+ } } : {};
3608
3940
  const projection = localeProjection(checkpoint);
3609
3941
  const project = (nodes) => nodes.flatMap((node) => {
3610
3942
  const record = node.url ? records.get(canonical(node.url, node.url) ?? "") : void 0;
@@ -3625,7 +3957,7 @@ function navigationConfig(checkpoint, records) {
3625
3957
  return pages2.length || tab.url ? [{ tab: tab.label, ...pages2.length ? { pages: pages2 } : {}, ...tab.url ? { href: tab.url } : {} }] : [];
3626
3958
  });
3627
3959
  const primary = localizedTabs(projection.defaultLocale);
3628
- return { tabs: primary.length ? primary : localizedTabs(locales[0]), ...locales.length > 1 ? {
3960
+ return { ...externalNavigation, tabs: primary.length ? primary : localizedTabs(locales[0]), ...locales.length > 1 ? {
3629
3961
  i18n: {
3630
3962
  defaultLocale: projection.defaultLocale,
3631
3963
  locales: locales.map((code) => ({ code, label: code === "en" ? "English" : code })),
@@ -3634,7 +3966,7 @@ function navigationConfig(checkpoint, records) {
3634
3966
  } : {} };
3635
3967
  }
3636
3968
  const pages = project(navigation);
3637
- return pages.length ? { tabs: [{ tab: "Documentation", pages }] } : null;
3969
+ return pages.length || shortcuts.length ? { ...externalNavigation, tabs: [{ tab: "Documentation", pages }] } : null;
3638
3970
  }
3639
3971
  function canonicalLanguage(value) {
3640
3972
  if (!/^[a-z]{2,3}(?:-[a-z0-9]{2,8}){0,4}$/i.test(value)) return void 0;
@@ -3654,7 +3986,7 @@ function observedLocale(document, id, scopePath) {
3654
3986
  }
3655
3987
  const urlLocale = [id.split("/", 1)[0], ...scopePath.split("/"), document.finalUrl.pathname.split("/").filter(Boolean)[0]].find((segment) => segment && LOCALES.has(segment.toLowerCase()));
3656
3988
  if (urlLocale) candidates.push({ source: "URL", value: canonicalLanguage(urlLocale) });
3657
- const declared = /html/i.test(document.contentType) ? load3(document.body)("html").attr("lang")?.trim() : void 0;
3989
+ const declared = /html/i.test(document.contentType) ? load5(document.body)("html").attr("lang")?.trim() : void 0;
3658
3990
  if (declared) {
3659
3991
  const locale = canonicalLanguage(declared);
3660
3992
  if (locale) candidates.push({ source: "html lang", value: locale });
@@ -3731,13 +4063,15 @@ function validateUrlMigration(checkpoint, cancelled = false) {
3731
4063
  const projection = localeProjection(checkpoint);
3732
4064
  const primaryIds = new Set(checkpoint.records.flatMap((record) => record.page && (!projection.canonicalLocale(record.page) || projection.canonicalLocale(record.page) === projection.defaultLocale) ? [projection.navigationId(record.page)] : []));
3733
4065
  const outputIds = /* @__PURE__ */ new Set();
3734
- const resolve3 = (url) => {
4066
+ const resolve4 = (url) => {
3735
4067
  const record = records.get(canonical(url, checkpoint.sourceUrl) ?? "");
3736
4068
  return resolveMigrationRecord(records, record);
3737
4069
  };
3738
4070
  for (const record of checkpoint.records) {
3739
4071
  counts[record.outcome]++;
3740
4072
  issues.push(...record.issues ?? []);
4073
+ warnings.push(...record.warnings ?? []);
4074
+ if (record.outcome === "alias" && !resolveMigrationRecord(records, record)?.page) issues.push({ code: "missing-page", source: record.url, message: "An alias does not resolve to an imported source page." });
3741
4075
  if (record.outcome === "pending" || record.outcome === "failed") issues.push({ code: "missing-page", source: record.url, message: record.reason ?? `Inventory entry is ${record.outcome}.` });
3742
4076
  if (!record.page || !record.evidence) continue;
3743
4077
  const outputId = projection.pageId(record.page);
@@ -3756,8 +4090,9 @@ function validateUrlMigration(checkpoint, cancelled = false) {
3756
4090
  continue;
3757
4091
  }
3758
4092
  if (!inScope(link, source, checkpoint.scopePath)) continue;
4093
+ if (isDiscoveryIndex(link, source)) continue;
3759
4094
  if (/\.(?:png|jpe?g|gif|svg|webp|avif|pdf|zip|mp4|webm|css|js|json|ya?ml)$/i.test(link.pathname)) continue;
3760
- const destination = resolve3(link.toString());
4095
+ const destination = resolve4(link.toString());
3761
4096
  if (!destination || !["imported", "excluded"].includes(destination.outcome)) {
3762
4097
  issues.push({ code: "missing-page", source: record.url, message: `Unresolved internal destination: ${link}` });
3763
4098
  } else if (link.hash && destination.outcome === "imported") {
@@ -3784,7 +4119,9 @@ function validateUrlMigration(checkpoint, cancelled = false) {
3784
4119
  if (node.url) {
3785
4120
  const url = new URL(node.url, source);
3786
4121
  if (inScope(url, source, checkpoint.scopePath)) {
3787
- const page = resolve3(url.toString())?.page;
4122
+ const destination = resolve4(url.toString());
4123
+ if (destination?.outcome === "excluded" && destination.redirectTo) continue;
4124
+ const page = destination?.page;
3788
4125
  if (!page) issues.push({ code: "navigation-missing", source: node.url, message: `Source navigation target ${node.label} was not imported.` });
3789
4126
  else if (locale && locale !== projection.defaultLocale && !outputIds.has(`${locale}/${projection.navigationId(page)}`)) {
3790
4127
  issues.push({ code: "navigation-missing", source: node.url, message: `The ${locale} sidebar target ${node.label} has no authored localized runtime page.` });
@@ -3801,16 +4138,32 @@ function validateUrlMigration(checkpoint, cancelled = false) {
3801
4138
  async function runUrlMigration(options) {
3802
4139
  const source = validateMigrationUrl(options.sourceUrl);
3803
4140
  const sourceUrl = canonical(source.toString(), source.toString());
3804
- const requestedScope = options.scopePath ?? (source.pathname.endsWith("/") ? source.pathname : source.pathname.slice(0, source.pathname.lastIndexOf("/")) || "/");
3805
- let scopeEnd = requestedScope.length;
3806
- while (scopeEnd > 0 && requestedScope.charCodeAt(scopeEnd - 1) === 47) scopeEnd--;
3807
- const scopePath = requestedScope.slice(0, scopeEnd) || "/";
3808
- if (!scopePath.startsWith("/") || scopePath.includes("?") || scopePath.includes("#") || !inScope(source, source, scopePath)) throw new Error("The selected documentation scope must contain the source URL.");
3809
4141
  const maxPages = options.maxPages ?? 1e3;
3810
4142
  if (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > 1e4) throw new Error("maxPages must be between 1 and 10000.");
3811
4143
  const concurrency = Math.max(1, Math.min(32, Math.floor(options.concurrency ?? 5)));
3812
4144
  const batchSize = Math.max(1, Math.min(500, Math.floor(options.batchSize ?? 50)));
3813
4145
  if (!Number.isFinite(concurrency) || !Number.isFinite(batchSize) || options.maxCaptures !== void 0 && (!Number.isInteger(options.maxCaptures) || options.maxCaptures < 1) || options.maxTotalBytes !== void 0 && (!Number.isFinite(options.maxTotalBytes) || options.maxTotalBytes < 1)) throw new Error("Migration batch, concurrency and byte limits must be positive finite numbers.");
4146
+ const fetcher = options.fetcher ?? defaultMigrationFetcher;
4147
+ const acquire = options.captureProvider?.capture.bind(options.captureProvider) ?? (async ({ url }) => fetcher(url, { accept: ACCEPT }));
4148
+ let initial;
4149
+ let inferredScope;
4150
+ if (options.scopePath === void 0 && !options.checkpoint && !options.signal?.aborted) {
4151
+ initial = Promise.resolve().then(() => acquire({ url: source, signal: options.signal }));
4152
+ try {
4153
+ const document = await initial;
4154
+ if (document.finalUrl.origin === source.origin) {
4155
+ const inferred = inferUrlMigrationScope(document).scopePath;
4156
+ if (inScope(source, source, inferred)) inferredScope = inferred;
4157
+ }
4158
+ } catch {
4159
+ }
4160
+ }
4161
+ const capture = (request) => initial && canonical(request.url.href, sourceUrl) === sourceUrl ? initial : acquire(request);
4162
+ const requestedScope = options.scopePath ?? options.checkpoint?.scopePath ?? inferredScope ?? (source.pathname.endsWith("/") ? source.pathname : source.pathname.slice(0, source.pathname.lastIndexOf("/")) || "/");
4163
+ let scopeEnd = requestedScope.length;
4164
+ while (scopeEnd > 0 && requestedScope.charCodeAt(scopeEnd - 1) === 47) scopeEnd--;
4165
+ const scopePath = requestedScope.slice(0, scopeEnd) || "/";
4166
+ if (!scopePath.startsWith("/") || scopePath.includes("?") || scopePath.includes("#") || !inScope(source, source, scopePath)) throw new Error("The selected documentation scope must contain the source URL.");
3814
4167
  const captureVersion = options.captureVersion ?? "fetch-v1";
3815
4168
  if (options.checkpoint && options.checkpoint.records.length > MAX_RECORDS) throw new Error("Checkpoint inventory exceeds its bound.");
3816
4169
  const checkpoint = options.checkpoint ? cloneUrlMigrationCheckpoint(options.checkpoint) : { schemaVersion: 1, converterVersion: CONVERTER_VERSION, sourceUrl, scopePath, captureVersion, records: [], navigation: [] };
@@ -3829,6 +4182,8 @@ async function runUrlMigration(options) {
3829
4182
  delete record.apiSpecification;
3830
4183
  delete record.version;
3831
4184
  delete record.issues;
4185
+ delete record.warnings;
4186
+ delete record.redirectTo;
3832
4187
  delete record.reason;
3833
4188
  delete record.aliasOf;
3834
4189
  }
@@ -3841,6 +4196,7 @@ async function runUrlMigration(options) {
3841
4196
  const enqueue = (value, base, discoveredBy) => {
3842
4197
  const url = canonical(value, base);
3843
4198
  if (!url || !inScope(new URL(url), source, scopePath) && !isDiscoveryIndex(new URL(url), source)) return;
4199
+ const isAttachment = discoveredBy !== "submitted" && /\.(?:json|ya?ml)$/i.test(new URL(url).pathname);
3844
4200
  const existing = records.get(url);
3845
4201
  if (existing) {
3846
4202
  if (!existing.discoveredBy.includes(discoveredBy)) {
@@ -3850,10 +4206,15 @@ async function runUrlMigration(options) {
3850
4206
  return;
3851
4207
  }
3852
4208
  if (records.size >= MAX_RECORDS) throw new Error("Discovered URL inventory exceeded the 100000 URL safety bound.");
3853
- const record = { url, discoveredBy: [discoveredBy], outcome: "pending" };
4209
+ const record = {
4210
+ url,
4211
+ discoveredBy: [discoveredBy],
4212
+ outcome: isAttachment ? "excluded" : "pending",
4213
+ ...isAttachment ? { reason: "Machine-readable attachment, not an authored article." } : {}
4214
+ };
3854
4215
  records.set(url, record);
3855
4216
  checkpoint.records.push(record);
3856
- pending.push(record);
4217
+ if (!isAttachment) pending.push(record);
3857
4218
  changed.push(record);
3858
4219
  };
3859
4220
  enqueue(sourceUrl, sourceUrl, "submitted");
@@ -3886,8 +4247,6 @@ async function runUrlMigration(options) {
3886
4247
  }
3887
4248
  }
3888
4249
  enqueueNavigation(checkpoint.navigation, sourceUrl);
3889
- const fetcher = options.fetcher ?? defaultMigrationFetcher;
3890
- const capture = options.captureProvider?.capture.bind(options.captureProvider) ?? (async ({ url }) => fetcher(url, { accept: ACCEPT }));
3891
4250
  const assetFetcher = options.assetFetcher ?? (!options.fetcher && !options.captureProvider ? defaultAssetFetcher : void 0);
3892
4251
  const usedIds = new Map([...records.values()].filter((record) => record.page).map((record) => [record.page.id, record.url]));
3893
4252
  const finalUrls = new Map([...records.values()].filter((record) => record.evidence && record.page).map((record) => [canonical(record.evidence.finalUrl, sourceUrl), record.url]));
@@ -3895,6 +4254,10 @@ async function runUrlMigration(options) {
3895
4254
  const assetPayloads = new Map(checkpoint.records.flatMap((record) => (record.assets ?? []).map((asset) => [asset.sha256, asset])));
3896
4255
  const assetBytes = /* @__PURE__ */ new Map();
3897
4256
  const retainAsset = (asset) => {
4257
+ if (asset.unavailable) {
4258
+ if ([404, 410].includes(asset.unavailable.status) && asset.content.byteLength === 0) return asset;
4259
+ throw new Error("Invalid source asset availability evidence.");
4260
+ }
3898
4261
  if (!acceptedAsset(asset)) throw new Error("Asset MIME/signature is unsupported or unsafe to serve.");
3899
4262
  const sha256 = hash(asset.content);
3900
4263
  const content = assetBytes.get(sha256) ?? Buffer.from(asset.content);
@@ -3927,6 +4290,11 @@ async function runUrlMigration(options) {
3927
4290
  let markdownRepresentation;
3928
4291
  try {
3929
4292
  delete record.reason;
4293
+ if (/\/llms-full\.txt$/i.test(new URL(record.url).pathname)) {
4294
+ record.outcome = "excluded";
4295
+ record.reason = "Aggregate documentation corpus; individual articles are inventoried separately.";
4296
+ return;
4297
+ }
3930
4298
  delete record.apiSpecification;
3931
4299
  const represented = resolveMigrationRecord(records, record.representationOf ? records.get(record.representationOf) : void 0);
3932
4300
  if (represented?.outcome === "imported") {
@@ -3936,6 +4304,16 @@ async function runUrlMigration(options) {
3936
4304
  }
3937
4305
  const document = await capture({ url: new URL(record.url), signal: options.signal });
3938
4306
  if (options.signal?.aborted) return;
4307
+ if (document.status && document.status >= 300 && document.status < 400 && document.headers?.location) {
4308
+ const destination = canonical(document.headers.location, document.finalUrl.href);
4309
+ if (destination && new URL(destination).origin !== source.origin) {
4310
+ record.outcome = "excluded";
4311
+ record.redirectTo = new URL(document.headers.location, document.finalUrl).href;
4312
+ record.reason = "The source entry is a redirect to an external public resource, retained as a link.";
4313
+ return;
4314
+ }
4315
+ throw new Error("Capture returned an unresolved redirect.");
4316
+ }
3939
4317
  if (Buffer.byteLength(document.body) > MAX_RESPONSE_BYTES) throw new Error("Captured response exceeds the 8 MB safety bound.");
3940
4318
  const identity = articleIdentity(document);
3941
4319
  const final = canonical(identity.toString(), sourceUrl);
@@ -3943,6 +4321,7 @@ async function runUrlMigration(options) {
3943
4321
  if (!inScope(document.finalUrl, source, scopePath) && !(role === "index" && isDiscoveryIndex(document.finalUrl, source))) {
3944
4322
  record.outcome = "excluded";
3945
4323
  record.reason = "Redirected outside the selected origin/path.";
4324
+ if (document.finalUrl.origin === source.origin && (!document.status || document.status >= 200 && document.status < 300)) record.redirectTo = document.finalUrl.href;
3946
4325
  return;
3947
4326
  }
3948
4327
  if (document.status && document.status >= 400) throw new Error(`Source page returned HTTP ${document.status}.`);
@@ -3960,7 +4339,7 @@ async function runUrlMigration(options) {
3960
4339
  return;
3961
4340
  }
3962
4341
  if (/html/i.test(document.contentType)) {
3963
- const $ = load3(document.body);
4342
+ const $ = load5(document.body);
3964
4343
  $('link[rel="alternate"][type="text/markdown"][href]').each((_index, node) => {
3965
4344
  const alternate = canonical($(node).attr("href"), final);
3966
4345
  if (!alternate) return;
@@ -3974,7 +4353,7 @@ async function runUrlMigration(options) {
3974
4353
  changed.push(sibling);
3975
4354
  });
3976
4355
  }
3977
- const observedNavigation = sourceNavigation(document);
4356
+ const observedNavigation = sourceNavigation(document, scopePath);
3978
4357
  if (observedNavigation.length && !(checkpoint.navigation.some((node) => node.kind === "tab") && !observedNavigation.some((node) => node.kind === "tab"))) {
3979
4358
  const merged = mergeSourceNavigation(checkpoint.navigation, observedNavigation);
3980
4359
  if (JSON.stringify(merged) !== JSON.stringify(checkpoint.navigation)) {
@@ -4016,6 +4395,7 @@ async function runUrlMigration(options) {
4016
4395
  record.reason = "Content could not be represented as safe inert MDX.";
4017
4396
  return;
4018
4397
  }
4398
+ let coveredSchemaGroups = [];
4019
4399
  const hasSchemaChoice = /html/i.test(document.contentType) && migrationHtmlArticle(document.body).root.find('select[aria-label="Select schema type"],select[aria-label="\u9009\u62E9\u67B6\u6784\u7C7B\u578B"]').length > 0;
4020
4400
  if (/html/i.test(document.contentType) && markdownRepresentation && (unresolvedStaticControls(document.body) || hasSchemaChoice)) {
4021
4401
  const schemaCapture = await capture({ url: new URL(markdownRepresentation), signal: options.signal });
@@ -4031,6 +4411,7 @@ async function runUrlMigration(options) {
4031
4411
  const path = `openapi/migration-${sha256}.json`;
4032
4412
  record.apiSpecification = { path, sha256, document: fragment.document };
4033
4413
  page.openapi = `${path} ${fragment.method} ${fragment.path}`;
4414
+ coveredSchemaGroups = apiSchemaTabGroups(document.body, fragment.document, fragment);
4034
4415
  }
4035
4416
  }
4036
4417
  const states = document.states ?? [];
@@ -4060,17 +4441,24 @@ ${difference}`;
4060
4441
  }
4061
4442
  record.assets = [];
4062
4443
  record.issues = [];
4444
+ record.warnings = [];
4063
4445
  if (/html/i.test(document.contentType)) {
4064
4446
  const { $, root } = migrationHtmlArticle(document.body);
4065
4447
  root.find("nav,footer,aside,.theme-doc-breadcrumbs,.theme-doc-toc-mobile,.table-of-contents,.hash-link").remove();
4066
4448
  root.find('button svg,a.hash-link svg,[aria-hidden="true"] svg,svg[aria-hidden="true"],svg[data-lucide],svg[data-icon]').remove();
4449
+ root.find("video,iframe").each((_index, node) => {
4450
+ if (projectMigrationMedia($.html(node), document.finalUrl) !== null) {
4451
+ record.warnings.push({ code: "source-asset-warning", source: record.url, message: "Embedded video is preserved at its original public media URL; playback depends on the original host." });
4452
+ $(node).remove();
4453
+ }
4454
+ });
4067
4455
  if (record.apiSpecification) {
4068
4456
  root.find('select[aria-label="Select schema type"],select[aria-label="\u9009\u62E9\u67B6\u6784\u7C7B\u578B"]').remove();
4069
4457
  root.find('.option-dropdown svg[data-component-part="icon-svg"]').remove();
4070
4458
  }
4071
4459
  const unsupported = [...new Set(root.find("iframe,object,embed,canvas,svg,form,input,textarea,select,math,video,audio").toArray().map((node) => node.tagName))];
4072
4460
  if (unsupported.length) record.issues.push({ code: "unsupported-component", source: record.url, message: `Rendered content requires explicit review for: ${unsupported.join(", ")}.` });
4073
- if (!states.length && unresolvedStaticControls(document.body, Boolean(record.apiSpecification))) record.issues.push({ code: "unsupported-component", source: record.url, message: "An interactive alternative has no complete captured panel or disclosure content." });
4461
+ if (!states.length && unresolvedStaticControls(document.body, Boolean(record.apiSpecification), coveredSchemaGroups)) record.issues.push({ code: "unsupported-component", source: record.url, message: "An interactive alternative has no complete captured panel or disclosure content." });
4074
4462
  }
4075
4463
  const suppliedAssets = new Map((document.assets ?? []).map((asset) => [canonical(asset.url, final), asset]));
4076
4464
  const assetUrls = [...new Set([...assetReferences(document), ...states.flatMap((state) => assetReferences(state.response))].map((url) => canonical(url, final)).filter((url) => Boolean(url)))];
@@ -4084,6 +4472,11 @@ ${difference}`;
4084
4472
  const supplied = suppliedAssets.get(url);
4085
4473
  const asset = supplied ? retainAsset(supplied) : assetRequest ? await assetRequest : null;
4086
4474
  if (!asset) throw new Error("Capture host did not supply asset bytes or a binary fetch boundary.");
4475
+ if (asset.unavailable && [404, 410].includes(asset.unavailable.status) && asset.content.byteLength === 0) {
4476
+ record.warnings.push({ code: "asset-failed", source: record.url, message: `The original source asset is unavailable (HTTP ${asset.unavailable.status}); its original URL was retained: ${url}` });
4477
+ page.body = rewriteMarkdownDestinations(page.body, (destination) => canonical(destination, final) === url ? url : destination);
4478
+ continue;
4479
+ }
4087
4480
  const extension = acceptedAsset(asset);
4088
4481
  if (!extension) throw new Error("Asset MIME/signature is unsupported or unsafe to serve.");
4089
4482
  const sha256 = hash(asset.content);
@@ -4125,7 +4518,7 @@ ${difference}`;
4125
4518
  } catch (error) {
4126
4519
  if (options.signal?.aborted) return;
4127
4520
  const wasImported = record.outcome === "imported";
4128
- record.outcome = record.discoveredBy.every((origin) => origin === "discovery-index") ? "excluded" : "failed";
4521
+ record.outcome = isDiscoveryIndex(new URL(record.url), source) || record.discoveredBy.every((origin) => origin === "discovery-index") ? "excluded" : "failed";
4129
4522
  record.reason = error instanceof Error ? error.message : String(error);
4130
4523
  if (record.page) {
4131
4524
  if (wasImported) imported--;
@@ -4150,7 +4543,7 @@ ${difference}`;
4150
4543
  for (const record of records.values()) {
4151
4544
  if (!record.representationOf) continue;
4152
4545
  const primary = resolveMigrationRecord(records, records.get(record.representationOf));
4153
- if (!primary) continue;
4546
+ if (!primary || primary === record) continue;
4154
4547
  const winner = primary.outcome === "imported" ? primary : record.outcome === "imported" && primary.outcome === "failed" ? record : void 0;
4155
4548
  if (!winner) continue;
4156
4549
  const alternate = winner === primary ? record : primary;
@@ -4276,7 +4669,7 @@ function rewriteMarkdownDestinations(body, rewrite) {
4276
4669
  return { start: start + index, end: start + index + node.url.length, replacement };
4277
4670
  }).filter((edit) => edit !== null).sort((a, b) => b.start - a.start);
4278
4671
  for (const node of markdownNodes(body)) for (const attribute of node.attributes ?? []) {
4279
- if (!attribute.name || !["src", "href", "srcSet", "srcset", "img", "primaryHref", "secondaryHref"].includes(attribute.name) || !attribute.position) continue;
4672
+ if (!attribute.name || !["src", "poster", "href", "srcSet", "srcset", "img", "primaryHref", "secondaryHref"].includes(attribute.name) || !attribute.position) continue;
4280
4673
  let value = attribute.value;
4281
4674
  if (typeof value !== "string") {
4282
4675
  try {
@@ -4307,7 +4700,7 @@ function rewriteMarkdownDestinations(body, rewrite) {
4307
4700
  // src/components.ts
4308
4701
  import { createHash as createHash2 } from "crypto";
4309
4702
  import { existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as readFileSync2 } from "fs";
4310
- import { basename, dirname as dirname2, extname as extname3, posix as posix2, relative as relative3, resolve as resolve2 } from "path";
4703
+ import { basename, dirname as dirname2, extname as extname3, posix as posix2, relative as relative3, resolve as resolve3 } from "path";
4311
4704
  import postcss from "postcss";
4312
4705
  import selectorParser from "postcss-selector-parser";
4313
4706
  import remarkMdx4 from "remark-mdx";
@@ -4529,6 +4922,7 @@ function renderMigrationFiles(bundle, options = {}) {
4529
4922
  }
4530
4923
  export {
4531
4924
  classifyMigrationResponse,
4925
+ inferUrlMigrationScope,
4532
4926
  renderMigrationFiles,
4533
4927
  runUrlMigration,
4534
4928
  validateUrlMigration