@ox-content/vite-plugin 3.0.0-alpha.4 → 3.0.0-alpha.6

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.cjs CHANGED
@@ -18,6 +18,7 @@ node_path = require_vitepress.__toESM(node_path, 1);
18
18
  let node_fs_promises = require("node:fs/promises");
19
19
  node_fs_promises = require_vitepress.__toESM(node_fs_promises, 1);
20
20
  let node_buffer = require("node:buffer");
21
+ let node_crypto = require("node:crypto");
21
22
  let node_os = require("node:os");
22
23
  let node_util = require("node:util");
23
24
  let node_child_process = require("node:child_process");
@@ -25,7 +26,7 @@ let fs_promises = require("fs/promises");
25
26
  fs_promises = require_vitepress.__toESM(fs_promises, 1);
26
27
  let crypto = require("crypto");
27
28
  crypto = require_vitepress.__toESM(crypto, 1);
28
- let node_crypto = require("node:crypto");
29
+ let node_dns_promises = require("node:dns/promises");
29
30
  let node_zlib = require("node:zlib");
30
31
  let fs = require("fs");
31
32
  fs = require_vitepress.__toESM(fs, 1);
@@ -457,7 +458,8 @@ async function transformPm(html, options) {
457
458
  * YouTube Plugin - Privacy-enhanced iframe embedding
458
459
  *
459
460
  * Transforms <YouTube> components into responsive iframe embeds using
460
- * youtube-nocookie.com for enhanced privacy.
461
+ * youtube-nocookie.com for enhanced privacy. A digits-only `start` attribute
462
+ * becomes `?start=` on the iframe URL.
461
463
  *
462
464
  * The HTML rewrite is performed in Rust (`transformYoutubeEmbeds` in
463
465
  * @ox-content/napi), replacing the previous rehype parse/stringify
@@ -488,7 +490,7 @@ async function transformYouTube(html, options) {
488
490
  }
489
491
  //#endregion
490
492
  //#region src/plugins/twitter/url.ts
491
- const STATUS_PATH = /^\/(?:[^/]+|i\/web)\/status\/(\d+)(?:\/.*)?$/;
493
+ const STATUS_PATH$1 = /^\/(?:[^/]+|i\/web)\/status\/(\d+)(?:\/.*)?$/;
492
494
  function createSyndicationToken(id) {
493
495
  return (Number(id) / 0x38d7ea4c68000 * Math.PI).toString(36).replaceAll(/(0+|\.)/g, "");
494
496
  }
@@ -502,7 +504,7 @@ function parseTweetReference(value) {
502
504
  const url = new URL(trimmed);
503
505
  const hostname = url.hostname.toLowerCase().replace(/^(?:www\.|mobile\.)/, "");
504
506
  if (url.protocol !== "https:" || hostname !== "x.com" && hostname !== "twitter.com") return null;
505
- const match = url.pathname.match(STATUS_PATH);
507
+ const match = url.pathname.match(STATUS_PATH$1);
506
508
  if (!match) return null;
507
509
  const screenName = url.pathname.startsWith("/i/web/status/") ? "i/web" : url.pathname.split("/")[1];
508
510
  return {
@@ -513,10 +515,164 @@ function parseTweetReference(value) {
513
515
  return null;
514
516
  }
515
517
  }
516
- function referenceFromAttributes(attributes) {
518
+ function tweetElementAttributes(attributes) {
517
519
  const values = /* @__PURE__ */ new Map();
518
- for (const match of attributes.matchAll(/\b(url|href|id)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi)) values.set(match[1].toLowerCase(), match[2] ?? match[3] ?? match[4] ?? "");
519
- return parseTweetReference(values.get("url") ?? values.get("href") ?? values.get("id") ?? "");
520
+ for (const match of attributes.matchAll(/\b(url|href|id|appearance)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi)) values.set(match[1].toLowerCase(), match[2] ?? match[3] ?? match[4] ?? "");
521
+ const appearance = values.get("appearance");
522
+ return {
523
+ reference: parseTweetReference(values.get("url") ?? values.get("href") ?? values.get("id") ?? ""),
524
+ appearance: appearance === "full" || appearance === "compact" ? appearance : void 0
525
+ };
526
+ }
527
+ //#endregion
528
+ //#region src/plugins/twitter/validate.ts
529
+ const SCREEN_NAME = /^[A-Za-z0-9_]{1,15}$/;
530
+ const STATUS_ID = /^\d+$/;
531
+ const STATUS_PATH = /(?:x\.com|twitter\.com)\/(?:[^/]+|i\/web)\/status\/(\d+)/i;
532
+ const TCO = /^https?:\/\/t\.co\/[A-Za-z0-9]+$/i;
533
+ function isTweetData(data) {
534
+ return isTweetBodyData(data);
535
+ }
536
+ function parseTweetData(data) {
537
+ return isTweetData(data) ? normalizeTweetData(data) : null;
538
+ }
539
+ function isTweetBodyData(data) {
540
+ if (!data || typeof data !== "object") return false;
541
+ const value = data;
542
+ return typeof value.text === "string" && isTweetUser(value.user);
543
+ }
544
+ function isTweetUser(value) {
545
+ if (!value || typeof value !== "object") return false;
546
+ const user = value;
547
+ return typeof user.name === "string" && typeof user.screen_name === "string";
548
+ }
549
+ function normalizeTweetData(data) {
550
+ const handle = sanitizeScreenName(data.in_reply_to_screen_name);
551
+ return {
552
+ ...data,
553
+ quoted_tweet: isTweetBodyData(data.quoted_tweet) ? stripNestedQuote(data.quoted_tweet) : void 0,
554
+ in_reply_to_screen_name: handle,
555
+ in_reply_to_status_id_str: handle ? sanitizeStatusId(data.in_reply_to_status_id_str) : void 0
556
+ };
557
+ }
558
+ function stripNestedQuote(data) {
559
+ const { quoted_tweet: _nested, ...quoted } = data;
560
+ return quoted;
561
+ }
562
+ function sanitizeScreenName(value) {
563
+ return value && SCREEN_NAME.test(value) ? value : void 0;
564
+ }
565
+ function sanitizeStatusId(value) {
566
+ return value && STATUS_ID.test(value) ? value : void 0;
567
+ }
568
+ function quotedPermalink(quoted) {
569
+ const id = sanitizeStatusId(quoted.id_str);
570
+ if (!id) return void 0;
571
+ return `https://x.com/${sanitizeScreenName(quoted.user.screen_name) ?? "i/web"}/status/${id}`;
572
+ }
573
+ function replyPermalink(data) {
574
+ const handle = sanitizeScreenName(data.in_reply_to_screen_name);
575
+ if (!handle) return void 0;
576
+ const id = sanitizeStatusId(data.in_reply_to_status_id_str);
577
+ return id ? `https://x.com/${handle}/status/${id}` : `https://x.com/${handle}`;
578
+ }
579
+ function visibleTextRange(data, omitTrailingQuoteUrl = false) {
580
+ const start = Math.max(0, data.display_text_range?.[0] ?? 0);
581
+ let end = Math.min(data.text.length, data.display_text_range?.[1] ?? data.text.length);
582
+ if (!omitTrailingQuoteUrl || start >= end) return [start, end];
583
+ const quoted = "quoted_tweet" in data ? data.quoted_tweet : void 0;
584
+ for (const entity of data.entities?.urls ?? []) {
585
+ const indices = entity.indices;
586
+ if (!indices || !isQuoteUrlEntity(entity, quoted)) continue;
587
+ const [entityStart, entityEnd] = indices;
588
+ if (entityStart >= start && isTrailingEntity(entityEnd, end, data.text)) end = Math.min(end, entityStart);
589
+ }
590
+ while (end > start && isUtf16Space(data.text, end - 1)) end -= 1;
591
+ return [start, end];
592
+ }
593
+ function isQuoteUrlEntity(entity, quoted) {
594
+ for (const href of [
595
+ entity.expanded_url,
596
+ entity.url,
597
+ entity.display_url
598
+ ]) {
599
+ if (!href) continue;
600
+ const match = href.match(STATUS_PATH);
601
+ if (match) return !quoted?.id_str || match[1] === quoted.id_str;
602
+ if (TCO.test(href)) return true;
603
+ }
604
+ return false;
605
+ }
606
+ function isTrailingEntity(entityEnd, rangeEnd, text) {
607
+ if (entityEnd >= rangeEnd || entityEnd === text.length) return true;
608
+ return entityEnd > 0 && /^[\t\n\r ]*$/.test(text.slice(entityEnd, rangeEnd));
609
+ }
610
+ function isUtf16Space(text, index) {
611
+ const char = text[index];
612
+ return char === " " || char === "\n" || char === " " || char === "\r";
613
+ }
614
+ //#endregion
615
+ //#region src/plugins/twitter/video.ts
616
+ const VIDEO_HOSTS = /* @__PURE__ */ new Set(["pbs.twimg.com", "video.twimg.com"]);
617
+ function selectBestMp4Url(variants) {
618
+ const candidates = (variants ?? []).filter((variant) => isVideoMp4Type(variant.content_type) && isAllowedVideoUrl(variant.url));
619
+ if (candidates.length === 0) return void 0;
620
+ return candidates.reduce((best, variant) => {
621
+ const bestBitrate = best.bitrate ?? Number.NEGATIVE_INFINITY;
622
+ const nextBitrate = variant.bitrate ?? Number.NEGATIVE_INFINITY;
623
+ if (nextBitrate > bestBitrate) return variant;
624
+ if (nextBitrate === bestBitrate && variant.url < best.url) return variant;
625
+ return best;
626
+ }).url;
627
+ }
628
+ function isVideoMp4Type(value) {
629
+ return (value ?? "").split(";", 1)[0].trim().toLowerCase() === "video/mp4";
630
+ }
631
+ function isAllowedVideoUrl(value) {
632
+ if (!value) return false;
633
+ try {
634
+ const url = new URL(value);
635
+ return url.protocol === "https:" && VIDEO_HOSTS.has(url.hostname.toLowerCase());
636
+ } catch {
637
+ return false;
638
+ }
639
+ }
640
+ async function downloadVideoAsset(source, basename, options) {
641
+ if (!isAllowedVideoUrl(source)) return void 0;
642
+ const filename = `${sanitizeFilename(basename)}.mp4`;
643
+ const output = node_path.default.join(options.mediaOutputDir, filename);
644
+ const publicPath = joinPublicPath$1(options.mediaPublicPath, filename);
645
+ try {
646
+ await (0, node_fs_promises.access)(output);
647
+ return publicPath;
648
+ } catch {}
649
+ const controller = new AbortController();
650
+ const timeout = setTimeout(() => controller.abort(), options.timeout);
651
+ try {
652
+ const response = await fetch(source, {
653
+ headers: { Accept: "video/mp4" },
654
+ signal: controller.signal
655
+ });
656
+ if (!response.ok) return void 0;
657
+ if (!isVideoMp4Type(response.headers?.get("content-type"))) return void 0;
658
+ const declared = Number(response.headers?.get("content-length"));
659
+ if (Number.isFinite(declared) && declared > options.maxVideoBytes) return void 0;
660
+ const bytes = new Uint8Array(await response.arrayBuffer());
661
+ if (bytes.byteLength > options.maxVideoBytes) return void 0;
662
+ await (0, node_fs_promises.mkdir)(options.mediaOutputDir, { recursive: true });
663
+ await (0, node_fs_promises.writeFile)(output, bytes);
664
+ return publicPath;
665
+ } catch {
666
+ return;
667
+ } finally {
668
+ clearTimeout(timeout);
669
+ }
670
+ }
671
+ function sanitizeFilename(value) {
672
+ return value.replaceAll(/[^a-zA-Z0-9_-]/g, "-") || "video";
673
+ }
674
+ function joinPublicPath$1(prefix, filename) {
675
+ return `${prefix.replace(/\/$/, "")}/${filename}`;
520
676
  }
521
677
  //#endregion
522
678
  //#region src/plugins/twitter/fetch.ts
@@ -544,8 +700,8 @@ async function fetchTweetData(id, options) {
544
700
  signal: controller.signal
545
701
  });
546
702
  if (!response.ok) return null;
547
- const data = await response.json();
548
- if (!isTweetData(data)) return null;
703
+ const data = parseTweetData(await response.json());
704
+ if (!data) return null;
549
705
  if (options.cache) {
550
706
  tweetCache.set(key, data);
551
707
  await writeCachedTweet(key, data, options.cacheDir);
@@ -558,15 +714,29 @@ async function fetchTweetData(id, options) {
558
714
  }
559
715
  }
560
716
  async function materializeTweetAssets(id, data, options) {
717
+ const assets = await materializeBodyAssets(id, data, options);
718
+ if (data.quoted_tweet) assets.quoted = await materializeBodyAssets(`${id}-quoted`, data.quoted_tweet, options);
719
+ return assets;
720
+ }
721
+ async function materializeBodyAssets(id, data, options) {
561
722
  const assets = { media: [] };
562
723
  const avatarUrl = data.user.profile_image_url_https?.replace(/_normal(?=\.[^.]+$)/, "_bigger");
563
724
  if (avatarUrl) assets.avatar = await downloadAsset(avatarUrl, `${id}-avatar`, options);
564
725
  const media = data.mediaDetails ?? data.entities?.media ?? [];
565
726
  for (const [index, item] of media.entries()) {
566
- if (item.type && item.type !== "photo") continue;
567
- if (!item.media_url_https) continue;
568
- const src = await downloadAsset(item.media_url_https, `${id}-media-${index + 1}`, options);
569
- if (src) assets.media.push(assetRecord(src, item));
727
+ const kind = item.type === "video" || item.type === "animated_gif" ? item.type : "photo";
728
+ const basename = `${id}-media-${index + 1}`;
729
+ if (kind === "photo") {
730
+ if (item.type && item.type !== "photo") continue;
731
+ if (!item.media_url_https) continue;
732
+ const src = await downloadAsset(item.media_url_https, basename, options);
733
+ if (src) assets.media.push(assetRecord("photo", src, item));
734
+ continue;
735
+ }
736
+ const poster = item.media_url_https ? await downloadAsset(item.media_url_https, `${basename}-poster`, options) : void 0;
737
+ const videoUrl = options.downloadVideo ? selectBestMp4Url(item.video_info?.variants) : void 0;
738
+ const src = videoUrl ? await downloadVideoAsset(videoUrl, basename, options) : void 0;
739
+ assets.media.push(assetRecord(kind, src, item, poster));
570
740
  }
571
741
  return assets;
572
742
  }
@@ -596,8 +766,7 @@ async function downloadAsset(source, basename, options) {
596
766
  }
597
767
  async function readCachedTweet(key, directory) {
598
768
  try {
599
- const data = JSON.parse(await (0, node_fs_promises.readFile)(node_path.default.join(directory, `${key}.json`), "utf8"));
600
- return isTweetData(data) ? data : null;
769
+ return parseTweetData(JSON.parse(await (0, node_fs_promises.readFile)(node_path.default.join(directory, `${key}.json`), "utf8")));
601
770
  } catch {
602
771
  return null;
603
772
  }
@@ -608,11 +777,6 @@ async function writeCachedTweet(key, data, directory) {
608
777
  await (0, node_fs_promises.writeFile)(node_path.default.join(directory, `${key}.json`), `${JSON.stringify(data)}\n`);
609
778
  } catch {}
610
779
  }
611
- function isTweetData(data) {
612
- if (!data || typeof data !== "object") return false;
613
- const value = data;
614
- return typeof value.text === "string" && Boolean(value.user) && typeof value.user?.name === "string" && typeof value.user.screen_name === "string";
615
- }
616
780
  function extensionFromUrl(url) {
617
781
  const match = url.pathname.match(/\.(jpe?g|png|webp|gif)$/i);
618
782
  return match ? `.${match[1].toLowerCase().replace("jpeg", "jpg")}` : ".jpg";
@@ -623,39 +787,64 @@ function joinPublicPath(prefix, filename) {
623
787
  function sanitizeSegment(value) {
624
788
  return value.replaceAll(/[^a-zA-Z0-9_-]/g, "-");
625
789
  }
626
- function assetRecord(src, media) {
790
+ function assetRecord(kind, src, media, poster) {
627
791
  return {
792
+ kind,
628
793
  src,
794
+ poster,
629
795
  alt: media.ext_alt_text,
630
796
  width: media.original_info?.width,
631
797
  height: media.original_info?.height
632
798
  };
633
799
  }
634
800
  //#endregion
635
- //#region src/plugins/twitter/render.ts
636
- function renderFetchedTweet(permalink, data, assets, options) {
637
- const profile = `https://x.com/${encodeURIComponent(data.user.screen_name)}`;
638
- const author = escapeHtml$6(data.user.name);
639
- const handle = escapeHtml$6(data.user.screen_name);
640
- const avatar = assets.avatar ? `<img class="ox-tweet__avatar" src="${escapeAttribute$2(assets.avatar)}" alt="" width="48" height="48" loading="lazy" decoding="async">` : "";
641
- const media = renderMedia(assets);
642
- const footer = renderFooter(permalink, data.created_at, options.lang);
643
- return [
644
- "<figure class=\"ox-tweet ox-tweet--fetched\">",
645
- "<header class=\"ox-tweet__header\">",
646
- `<a class="ox-tweet__profile" href="${escapeAttribute$2(profile)}" target="_blank" rel="noopener noreferrer">`,
647
- avatar,
648
- `<span class="ox-tweet__author-name">${author}</span>`,
649
- `<span class="ox-tweet__author-handle">@${handle}</span>`,
650
- "</a></header>",
651
- `<div class="ox-tweet__body">${renderTweetText(data)}</div>`,
652
- media,
653
- footer,
654
- "</figure>"
655
- ].join("");
801
+ //#region src/plugins/twitter/html.ts
802
+ function escapeText(value) {
803
+ return escapeHtml$6(value).replaceAll("\n", "<br>");
804
+ }
805
+ function escapeAttribute$3(value) {
806
+ return escapeHtml$6(value).replaceAll("`", "&#96;");
807
+ }
808
+ function escapeHtml$6(value) {
809
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
810
+ }
811
+ //#endregion
812
+ //#region src/plugins/twitter/markup.ts
813
+ function renderMedia(assets, permalink) {
814
+ if (assets.media.length === 0) return "";
815
+ const items = assets.media.map((item) => renderMediaItem(item, permalink)).join("");
816
+ return `<div class="ox-tweet__media" data-count="${assets.media.length}">${items}</div>`;
817
+ }
818
+ function renderMediaItem(item, permalink) {
819
+ if (item.kind === "video" || item.kind === "animated_gif") return renderVideoItem(item, permalink);
820
+ const size = sizeAttributes(item);
821
+ return `<img class="ox-tweet__media-item" src="${escapeAttribute$3(item.src ?? "")}" alt="${escapeAttribute$3(item.alt ?? "")}"${size} loading="lazy" decoding="async">`;
656
822
  }
657
- function renderTweetText(data) {
658
- const [start, end] = data.display_text_range ?? [0, data.text.length];
823
+ function renderVideoItem(item, permalink) {
824
+ const watch = watchOnX(permalink);
825
+ const size = sizeAttributes(item);
826
+ const src = selfHostedMediaSrc(item.src);
827
+ if (src) {
828
+ const poster = item.poster ? ` poster="${escapeAttribute$3(item.poster)}"` : "";
829
+ const gif = item.kind === "animated_gif" ? " muted loop" : "";
830
+ return `<video class="ox-tweet__media-item" src="${escapeAttribute$3(src)}"${poster}${size} controls playsinline preload="none"${gif}>${watch}</video>`;
831
+ }
832
+ return `<div class="ox-tweet__media-item ox-tweet__media-fallback">${item.poster ? `<img src="${escapeAttribute$3(item.poster)}" alt="${escapeAttribute$3(item.alt ?? "")}"${size} loading="lazy" decoding="async">` : ""}${watch}</div>`;
833
+ }
834
+ function selfHostedMediaSrc(src) {
835
+ return src && !src.includes("video.twimg.com") ? src : void 0;
836
+ }
837
+ function sizeAttributes(item) {
838
+ return [item.width ? ` width="${item.width}"` : "", item.height ? ` height="${item.height}"` : ""].join("");
839
+ }
840
+ function watchOnX(permalink) {
841
+ if (!permalink) return "";
842
+ return `<a class="ox-tweet__watch" href="${escapeAttribute$3(permalink)}" target="_blank" rel="noopener noreferrer">Watch on X</a>`;
843
+ }
844
+ //#endregion
845
+ //#region src/plugins/twitter/text.ts
846
+ function renderTweetText(data, options) {
847
+ const [start, end] = visibleTextRange(data, options?.omitTrailingQuoteUrl === true);
659
848
  const entities = collectEntities(data).filter((entity) => validRange(entity.indices, start, end)).sort((left, right) => left.indices[0] - right.indices[0]);
660
849
  let cursor = start;
661
850
  let output = "";
@@ -663,10 +852,9 @@ function renderTweetText(data) {
663
852
  const [entityStart, entityEnd] = entity.indices;
664
853
  if (entityStart < cursor) continue;
665
854
  output += escapeText(data.text.slice(cursor, entityStart));
666
- if (entity.kind === "url") {
667
- const href = entity.expanded_url ?? entity.url;
668
- const label = entity.display_url ?? href;
669
- output += `<a href="${escapeAttribute$2(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml$6(label)}</a>`;
855
+ if (entity.href) {
856
+ const label = entity.label ?? data.text.slice(entityStart, entityEnd);
857
+ output += `<a href="${escapeAttribute$3(entity.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml$6(label)}</a>`;
670
858
  }
671
859
  cursor = entityEnd;
672
860
  }
@@ -674,27 +862,228 @@ function renderTweetText(data) {
674
862
  return output.trim();
675
863
  }
676
864
  function collectEntities(data) {
677
- return [...(data.entities?.urls ?? []).map((entity) => ({
678
- ...entity,
679
- kind: "url"
680
- })), ...(data.entities?.media ?? []).map((entity) => ({
681
- ...entity,
682
- kind: "media"
683
- }))];
865
+ const collected = [];
866
+ for (const entity of data.entities?.urls ?? []) collected.push({
867
+ kind: "url",
868
+ indices: entity.indices,
869
+ href: entity.expanded_url ?? entity.url,
870
+ label: entity.display_url ?? entity.expanded_url ?? entity.url
871
+ });
872
+ for (const entity of data.entities?.media ?? []) collected.push({
873
+ kind: "media",
874
+ indices: entity.indices
875
+ });
876
+ for (const entity of data.entities?.hashtags ?? []) {
877
+ if (!entity.text) continue;
878
+ collected.push({
879
+ kind: "hashtag",
880
+ indices: entity.indices,
881
+ href: `https://x.com/hashtag/${encodeURIComponent(entity.text)}`
882
+ });
883
+ }
884
+ for (const entity of data.entities?.user_mentions ?? []) {
885
+ const screen = sanitizeScreenName(entity.screen_name);
886
+ if (!screen) continue;
887
+ collected.push({
888
+ kind: "mention",
889
+ indices: entity.indices,
890
+ href: `https://x.com/${encodeURIComponent(screen)}`
891
+ });
892
+ }
893
+ for (const entity of data.entities?.symbols ?? []) {
894
+ if (!entity.text) continue;
895
+ collected.push({
896
+ kind: "symbol",
897
+ indices: entity.indices,
898
+ href: `https://x.com/search?q=%24${encodeURIComponent(entity.text)}`
899
+ });
900
+ }
901
+ return collected;
684
902
  }
685
903
  function validRange(indices, start, end) {
686
904
  return Boolean(indices && indices[0] >= start && indices[1] <= end && indices[0] < indices[1]);
687
905
  }
688
- function renderMedia(assets) {
689
- if (assets.media.length === 0) return "";
690
- const images = assets.media.map((item) => {
691
- const size = [item.width ? ` width="${item.width}"` : "", item.height ? ` height="${item.height}"` : ""].join("");
692
- return `<img class="ox-tweet__media-item" src="${escapeAttribute$2(item.src)}" alt="${escapeAttribute$2(item.alt ?? "")}"${size} loading="lazy" decoding="async">`;
693
- }).join("");
694
- return `<div class="ox-tweet__media" data-count="${assets.media.length}">${images}</div>`;
906
+ //#endregion
907
+ //#region src/plugins/twitter/full.ts
908
+ const HELP_HREF = "https://help.x.com/en/x-for-websites-ads-info-and-privacy";
909
+ function renderFullTweet(permalink, data, assets) {
910
+ const quote = data.quoted_tweet ? renderFullQuote(data.quoted_tweet, assets.quoted) : "";
911
+ return [
912
+ "<figure class=\"ox-tweet ox-tweet--fetched ox-tweet--full\">",
913
+ renderFullHeader(data.user, assets.avatar, permalink),
914
+ renderReply$1(data),
915
+ `<div class="ox-tweet__body">${renderTweetText(data, { omitTrailingQuoteUrl: Boolean(quote) })}</div>`,
916
+ renderMedia(assets, permalink),
917
+ quote,
918
+ renderInfo(permalink, data.created_at),
919
+ renderActions(permalink, data),
920
+ renderReplies(permalink, data.conversation_count),
921
+ "</figure>"
922
+ ].join("");
923
+ }
924
+ function renderFullQuote(data, assets) {
925
+ const permalink = quotedPermalink(data) ?? "";
926
+ return [
927
+ "<blockquote class=\"ox-tweet__quote\">",
928
+ renderQuoteHeader(data.user, assets?.avatar, permalink),
929
+ `<div class="ox-tweet__quote-body">${renderTweetText(data)}</div>`,
930
+ renderMedia(assets ?? { media: [] }, permalink),
931
+ "</blockquote>"
932
+ ].join("");
933
+ }
934
+ function renderFullHeader(user, avatarSrc, permalink) {
935
+ const profile = profileHref(user);
936
+ const follow = followHref(user);
937
+ return [
938
+ "<header class=\"ox-tweet__header\">",
939
+ `<a class="ox-tweet__avatar-link" href="${escapeAttribute$3(profile)}" target="_blank" rel="noopener noreferrer">`,
940
+ avatar(avatarSrc, 48),
941
+ "</a>",
942
+ "<div class=\"ox-tweet__author\">",
943
+ `<a class="ox-tweet__author-name" href="${escapeAttribute$3(profile)}" target="_blank" rel="noopener noreferrer">${escapeHtml$6(user.name)}${verifiedBadge(user)}</a>`,
944
+ "<div class=\"ox-tweet__author-meta\">",
945
+ `<a class="ox-tweet__author-handle" href="${escapeAttribute$3(profile)}" target="_blank" rel="noopener noreferrer">@${escapeHtml$6(user.screen_name)}</a>`,
946
+ follow ? `<span class="ox-tweet__sep" aria-hidden="true">·</span><a class="ox-tweet__follow" href="${escapeAttribute$3(follow)}" target="_blank" rel="noopener noreferrer">Follow</a>` : "",
947
+ "</div></div>",
948
+ `<a class="ox-tweet__brand" href="${escapeAttribute$3(permalink)}" target="_blank" rel="noopener noreferrer" aria-label="View on X"><span class="ox-tweet__icon ox-tweet__icon--x"></span></a>`,
949
+ "</header>"
950
+ ].join("");
951
+ }
952
+ function renderQuoteHeader(user, avatarSrc, permalink) {
953
+ return [
954
+ "<header class=\"ox-tweet__quote-header\">",
955
+ `<a class="ox-tweet__profile" href="${escapeAttribute$3(permalink || profileHref(user))}" target="_blank" rel="noopener noreferrer">`,
956
+ avatar(avatarSrc, 20),
957
+ `<span class="ox-tweet__author-name">${escapeHtml$6(user.name)}${verifiedBadge(user)}</span>`,
958
+ `<span class="ox-tweet__author-handle">@${escapeHtml$6(user.screen_name)}</span>`,
959
+ "</a></header>"
960
+ ].join("");
961
+ }
962
+ function renderReply$1(data) {
963
+ const href = replyPermalink(data);
964
+ const handle = data.in_reply_to_screen_name;
965
+ if (!href || !handle) return "";
966
+ return `<p class="ox-tweet__reply"><a class="ox-tweet__reply-link" href="${escapeAttribute$3(href)}" target="_blank" rel="noopener noreferrer">Replying to @${escapeHtml$6(handle)}</a></p>`;
967
+ }
968
+ function renderInfo(permalink, createdAt) {
969
+ const formatted = formatFullDate(createdAt);
970
+ return `<div class="ox-tweet__info">${formatted ? `<a class="ox-tweet__permalink" href="${escapeAttribute$3(permalink)}" target="_blank" rel="noopener noreferrer"><time datetime="${formatted.iso}">${escapeHtml$6(formatted.label)}</time></a>` : `<a class="ox-tweet__permalink" href="${escapeAttribute$3(permalink)}" target="_blank" rel="noopener noreferrer">View on X</a>`}<a class="ox-tweet__info-help" href="${HELP_HREF}" target="_blank" rel="noopener noreferrer" aria-label="X for Websites, Ads Information and Privacy"><span class="ox-tweet__icon ox-tweet__icon--info"></span></a></div>`;
971
+ }
972
+ function renderActions(permalink, data) {
973
+ const id = statusId(data, permalink);
974
+ if (!id) return "";
975
+ return [
976
+ "<div class=\"ox-tweet__actions\">",
977
+ `<a class="ox-tweet__action ox-tweet__action--like" href="https://x.com/intent/like?tweet_id=${id}" target="_blank" rel="noopener noreferrer"><span class="ox-tweet__icon ox-tweet__icon--like"></span><span>${formatCount(data.favorite_count)}</span></a>`,
978
+ `<a class="ox-tweet__action ox-tweet__action--reply" href="https://x.com/intent/tweet?in_reply_to=${id}" target="_blank" rel="noopener noreferrer"><span class="ox-tweet__icon ox-tweet__icon--reply"></span>Reply</a>`,
979
+ "</div>"
980
+ ].join("");
981
+ }
982
+ function renderReplies(permalink, conversationCount) {
983
+ return `<p class="ox-tweet__replies"><a class="ox-tweet__replies-link" href="${escapeAttribute$3(permalink)}" target="_blank" rel="noopener noreferrer">${escapeHtml$6(repliesLabel(conversationCount))}</a></p>`;
984
+ }
985
+ function avatar(src, size) {
986
+ return src ? `<img class="ox-tweet__avatar" src="${escapeAttribute$3(src)}" alt="" width="${size}" height="${size}" loading="lazy" decoding="async">` : "";
987
+ }
988
+ function verifiedBadge(user) {
989
+ const kind = verifiedKind(user);
990
+ return kind ? `<span class="ox-tweet__badge ox-tweet__badge--${kind}" title="Verified"></span>` : "";
991
+ }
992
+ function verifiedKind(user) {
993
+ if (user.verified_type === "Government") return "gray";
994
+ if (user.verified_type === "Business") return "gold";
995
+ if (user.is_blue_verified) return "blue";
996
+ if (user.verified) return "gray";
997
+ }
998
+ function profileHref(user) {
999
+ const screen = sanitizeScreenName(user.screen_name) ?? user.screen_name;
1000
+ return `https://x.com/${encodeURIComponent(screen)}`;
1001
+ }
1002
+ function followHref(user) {
1003
+ const screen = sanitizeScreenName(user.screen_name);
1004
+ return screen ? `https://x.com/intent/follow?screen_name=${encodeURIComponent(screen)}` : void 0;
1005
+ }
1006
+ function statusId(data, permalink) {
1007
+ return sanitizeStatusId(data.id_str) ?? permalink.match(/\/status\/(\d+)/)?.[1];
1008
+ }
1009
+ function formatCount(value) {
1010
+ const n = typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
1011
+ if (n > 999999) return `${(n / 1e6).toFixed(1)}M`;
1012
+ if (n > 999) return `${(n / 1e3).toFixed(1)}K`;
1013
+ return String(n);
1014
+ }
1015
+ function repliesLabel(value) {
1016
+ const n = typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
1017
+ if (n === 0) return "Read more on X";
1018
+ if (n === 1) return "Read 1 reply";
1019
+ return `Read ${formatCount(n)} replies`;
1020
+ }
1021
+ function formatFullDate(createdAt) {
1022
+ if (!createdAt) return void 0;
1023
+ const date = new Date(createdAt);
1024
+ if (Number.isNaN(date.valueOf())) return void 0;
1025
+ const parts = new Intl.DateTimeFormat("en-US", {
1026
+ hour: "numeric",
1027
+ minute: "2-digit",
1028
+ hour12: true,
1029
+ month: "short",
1030
+ day: "numeric",
1031
+ year: "numeric",
1032
+ timeZone: "UTC"
1033
+ }).formatToParts(date);
1034
+ const get = (type) => parts.find((part) => part.type === type)?.value ?? "";
1035
+ return {
1036
+ iso: date.toISOString(),
1037
+ label: `${get("hour")}:${get("minute")} ${get("dayPeriod")} · ${get("month")} ${get("day")}, ${get("year")}`
1038
+ };
1039
+ }
1040
+ //#endregion
1041
+ //#region src/plugins/twitter/render.ts
1042
+ function renderFetchedTweet(permalink, data, assets, options) {
1043
+ if (options.appearance === "full") return renderFullTweet(permalink, data, assets);
1044
+ const quote = data.quoted_tweet ? renderQuotedTweet(data.quoted_tweet, assets.quoted) : "";
1045
+ return [
1046
+ "<figure class=\"ox-tweet ox-tweet--fetched\">",
1047
+ renderHeader(data.user, assets.avatar),
1048
+ renderReply(data),
1049
+ `<div class="ox-tweet__body">${renderTweetText(data, { omitTrailingQuoteUrl: Boolean(quote) })}</div>`,
1050
+ renderMedia(assets, permalink),
1051
+ quote,
1052
+ renderFooter(permalink, data.created_at, options.lang),
1053
+ "</figure>"
1054
+ ].join("");
1055
+ }
1056
+ function renderQuotedTweet(data, assets) {
1057
+ const permalink = quotedPermalink(data) ?? "";
1058
+ return [
1059
+ "<blockquote class=\"ox-tweet__quote\">",
1060
+ renderHeader(data.user, assets?.avatar, permalink || void 0, "ox-tweet__quote-header"),
1061
+ `<div class="ox-tweet__quote-body">${renderTweetText(data)}</div>`,
1062
+ renderMedia(assets ?? { media: [] }, permalink),
1063
+ "</blockquote>"
1064
+ ].join("");
1065
+ }
1066
+ function renderHeader(user, avatarSrc, href, headerClass = "ox-tweet__header") {
1067
+ const screen = sanitizeScreenName(user.screen_name) ?? user.screen_name;
1068
+ const profile = href ?? `https://x.com/${encodeURIComponent(screen)}`;
1069
+ const avatar = avatarSrc ? `<img class="ox-tweet__avatar" src="${escapeAttribute$3(avatarSrc)}" alt="" width="48" height="48" loading="lazy" decoding="async">` : "";
1070
+ return [
1071
+ `<header class="${headerClass}">`,
1072
+ `<a class="ox-tweet__profile" href="${escapeAttribute$3(profile)}" target="_blank" rel="noopener noreferrer">`,
1073
+ avatar,
1074
+ `<span class="ox-tweet__author-name">${escapeHtml$6(user.name)}</span>`,
1075
+ `<span class="ox-tweet__author-handle">@${escapeHtml$6(user.screen_name)}</span>`,
1076
+ "</a></header>"
1077
+ ].join("");
1078
+ }
1079
+ function renderReply(data) {
1080
+ const href = replyPermalink(data);
1081
+ const handle = data.in_reply_to_screen_name;
1082
+ if (!href || !handle) return "";
1083
+ return `<p class="ox-tweet__reply"><a class="ox-tweet__reply-link" href="${escapeAttribute$3(href)}" target="_blank" rel="noopener noreferrer">Replying to @${escapeHtml$6(handle)}</a></p>`;
695
1084
  }
696
1085
  function renderFooter(permalink, createdAt, lang) {
697
- if (!createdAt) return `<footer class="ox-tweet__footer"><a class="ox-tweet__permalink" href="${escapeAttribute$2(permalink)}" target="_blank" rel="noopener noreferrer">View on X</a></footer>`;
1086
+ if (!createdAt) return `<footer class="ox-tweet__footer"><a class="ox-tweet__permalink" href="${escapeAttribute$3(permalink)}" target="_blank" rel="noopener noreferrer">View on X</a></footer>`;
698
1087
  const date = new Date(createdAt);
699
1088
  if (Number.isNaN(date.valueOf())) return renderFooter(permalink, void 0, lang);
700
1089
  const iso = date.toISOString();
@@ -710,16 +1099,7 @@ function renderFooter(permalink, createdAt, lang) {
710
1099
  timeZone: "UTC"
711
1100
  }).format(date);
712
1101
  }
713
- return `<footer class="ox-tweet__footer"><a class="ox-tweet__permalink" href="${escapeAttribute$2(permalink)}" target="_blank" rel="noopener noreferrer"><time datetime="${iso}">${escapeHtml$6(label)}</time></a></footer>`;
714
- }
715
- function escapeText(value) {
716
- return escapeHtml$6(value).replaceAll("\n", "<br>");
717
- }
718
- function escapeAttribute$2(value) {
719
- return escapeHtml$6(value).replaceAll("`", "&#96;");
720
- }
721
- function escapeHtml$6(value) {
722
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
1102
+ return `<footer class="ox-tweet__footer"><a class="ox-tweet__permalink" href="${escapeAttribute$3(permalink)}" target="_blank" rel="noopener noreferrer"><time datetime="${iso}">${escapeHtml$6(label)}</time></a></footer>`;
723
1103
  }
724
1104
  //#endregion
725
1105
  //#region src/plugins/twitter/transform.ts
@@ -732,7 +1112,10 @@ function resolveTwitterEmbedOptions$1(options) {
732
1112
  cache: options.cache ?? true,
733
1113
  cacheDir: node_path.default.resolve(options.cacheDir ?? ".cache/ox-content/twitter"),
734
1114
  mediaOutputDir: node_path.default.resolve(options.mediaOutputDir ?? "public/ox-content/twitter"),
735
- mediaPublicPath: options.mediaPublicPath ?? "/ox-content/twitter"
1115
+ mediaPublicPath: options.mediaPublicPath ?? "/ox-content/twitter",
1116
+ downloadVideo: options.downloadVideo ?? false,
1117
+ maxVideoBytes: options.maxVideoBytes ?? 8388608,
1118
+ appearance: options.appearance === "full" ? "full" : "compact"
736
1119
  };
737
1120
  }
738
1121
  async function transformFetchedTweets(html, options) {
@@ -743,20 +1126,23 @@ async function transformFetchedTweets(html, options) {
743
1126
  for (const match of html.matchAll(TWEET_ELEMENT)) {
744
1127
  const index = match.index ?? 0;
745
1128
  output += html.slice(cursor, index);
746
- const reference = referenceFromAttributes(match[2]);
747
- if (!reference) {
1129
+ const attrs = tweetElementAttributes(match[2]);
1130
+ if (!attrs.reference) {
748
1131
  output += match[0];
749
1132
  cursor = index + match[0].length;
750
1133
  continue;
751
1134
  }
752
- const data = await fetchTweetData(reference.id, resolved);
1135
+ const data = await fetchTweetData(attrs.reference.id, resolved);
753
1136
  if (!data) {
754
1137
  output += match[0];
755
1138
  cursor = index + match[0].length;
756
1139
  continue;
757
1140
  }
758
- const assets = await materializeTweetAssets(reference.id, data, resolved);
759
- output += renderFetchedTweet(reference.url, data, assets, resolved);
1141
+ const assets = await materializeTweetAssets(attrs.reference.id, data, resolved);
1142
+ output += renderFetchedTweet(attrs.reference.url, data, assets, {
1143
+ ...resolved,
1144
+ appearance: attrs.appearance ?? resolved.appearance
1145
+ });
760
1146
  cursor = index + match[0].length;
761
1147
  }
762
1148
  return output + html.slice(cursor);
@@ -899,7 +1285,7 @@ function inferLanguage(path) {
899
1285
  }
900
1286
  //#endregion
901
1287
  //#region src/plugins/github/types.ts
902
- const defaultOptions$1 = {
1288
+ const defaultOptions = {
903
1289
  token: "",
904
1290
  cache: true,
905
1291
  cacheTTL: 36e5,
@@ -1009,7 +1395,7 @@ async function fetchGitHubSource(source, options) {
1009
1395
  */
1010
1396
  async function prefetchGitHubRepos(repos, options) {
1011
1397
  const mergedOptions = {
1012
- ...defaultOptions$1,
1398
+ ...defaultOptions,
1013
1399
  ...options
1014
1400
  };
1015
1401
  const results = /* @__PURE__ */ new Map();
@@ -1024,7 +1410,7 @@ async function prefetchGitHubRepos(repos, options) {
1024
1410
  */
1025
1411
  async function prefetchGitHubSources(sources, options) {
1026
1412
  const mergedOptions = {
1027
- ...defaultOptions$1,
1413
+ ...defaultOptions,
1028
1414
  ...options
1029
1415
  };
1030
1416
  const results = /* @__PURE__ */ new Map();
@@ -1441,7 +1827,7 @@ function rehypeGitHub(repoDataMap, sourceDataMap, options) {
1441
1827
  */
1442
1828
  async function transformGitHub(html, repoDataMap, options) {
1443
1829
  const mergedOptions = {
1444
- ...defaultOptions$1,
1830
+ ...defaultOptions,
1445
1831
  ...options
1446
1832
  };
1447
1833
  let dataMap = repoDataMap;
@@ -1454,29 +1840,111 @@ async function transformGitHub(html, repoDataMap, options) {
1454
1840
  //#region src/plugins/github.ts
1455
1841
  var github_exports = /* @__PURE__ */ require_vitepress.__exportAll({ transformGitHub: () => transformGitHub });
1456
1842
  //#endregion
1457
- //#region src/plugins/ogp.ts
1458
- /**
1459
- * OGP Card Plugin - Link card embedding
1460
- *
1461
- * Transforms <OgCard> components into static link preview cards
1462
- * by fetching OGP metadata at build time.
1463
- */
1464
- var ogp_exports = /* @__PURE__ */ require_vitepress.__exportAll({
1465
- collectOgpUrls: () => collectOgpUrls,
1466
- fetchOgpData: () => fetchOgpData,
1467
- isSafeOgpUrl: () => isSafeOgpUrl,
1468
- prefetchOgpData: () => prefetchOgpData,
1469
- transformOgp: () => transformOgp
1470
- });
1471
- const rehypeParse$1 = require_interop.interopDefault(rehype_parse.default);
1472
- const rehypeStringify$1 = require_interop.interopDefault(rehype_stringify.default);
1473
- const defaultOptions = {
1474
- timeout: 1e4,
1475
- cache: true,
1476
- cacheTTL: 36e5,
1477
- userAgent: "ox-content-ogp-bot/1.0 (compatible; +https://github.com/ubugeeei-prod/ox-content)"
1478
- };
1479
- const ogpCache = /* @__PURE__ */ new Map();
1843
+ //#region src/plugins/ogp/cache.ts
1844
+ const memoryCache = /* @__PURE__ */ new Map();
1845
+ function ogpCacheFilePath(directory, key) {
1846
+ return node_path.default.join(directory, `${key}.json`);
1847
+ }
1848
+ function isFreshOgpEntry(cachedAt, ttl, now) {
1849
+ return now - cachedAt < ttl;
1850
+ }
1851
+ function parseOgpCacheEntry(value) {
1852
+ if (!value || typeof value !== "object") return null;
1853
+ const entry = value;
1854
+ if (entry.v !== 1) return null;
1855
+ if (typeof entry.url !== "string" || entry.url.length === 0) return null;
1856
+ if (typeof entry.cachedAt !== "number" || !Number.isFinite(entry.cachedAt)) return null;
1857
+ if (entry.data !== null && !isOgpData(entry.data)) return null;
1858
+ return {
1859
+ v: 1,
1860
+ url: entry.url,
1861
+ cachedAt: entry.cachedAt,
1862
+ data: entry.data
1863
+ };
1864
+ }
1865
+ function readMemoryOgp(key, options, now) {
1866
+ const cached = memoryCache.get(key);
1867
+ if (!cached || !isFreshOgpEntry(cached.timestamp, options.cacheTTL, now)) return;
1868
+ return cached.data;
1869
+ }
1870
+ function writeMemoryOgp(key, data, timestamp, options) {
1871
+ if (data === null && !options.persistCache) return;
1872
+ memoryCache.set(key, {
1873
+ data,
1874
+ timestamp
1875
+ });
1876
+ }
1877
+ async function readDiskOgp(key, options, now) {
1878
+ const file = ogpCacheFilePath(options.cacheDir, key);
1879
+ try {
1880
+ const entry = parseOgpCacheEntry(JSON.parse(await (0, node_fs_promises.readFile)(file, "utf8")));
1881
+ if (!entry) {
1882
+ await discardCorruptEntry(file);
1883
+ return;
1884
+ }
1885
+ if (!isFreshOgpEntry(entry.cachedAt, options.cacheTTL, now)) return void 0;
1886
+ return entry.data;
1887
+ } catch (error) {
1888
+ if (isEnoent(error)) return void 0;
1889
+ await discardCorruptEntry(file);
1890
+ return;
1891
+ }
1892
+ }
1893
+ async function writeDiskOgp(key, url, data, options, cachedAt) {
1894
+ const directory = options.cacheDir;
1895
+ const target = ogpCacheFilePath(directory, key);
1896
+ const temp = node_path.default.join(directory, `.${key}.${process.pid}.${(0, node_crypto.randomBytes)(8).toString("hex")}.tmp`);
1897
+ const entry = {
1898
+ v: 1,
1899
+ url,
1900
+ cachedAt,
1901
+ data
1902
+ };
1903
+ try {
1904
+ await (0, node_fs_promises.mkdir)(directory, { recursive: true });
1905
+ await (0, node_fs_promises.writeFile)(temp, `${JSON.stringify(entry)}\n`);
1906
+ try {
1907
+ await (0, node_fs_promises.rename)(temp, target);
1908
+ } catch {
1909
+ await (0, node_fs_promises.writeFile)(target, `${JSON.stringify(entry)}\n`);
1910
+ await (0, node_fs_promises.rm)(temp, { force: true });
1911
+ }
1912
+ } catch {
1913
+ await (0, node_fs_promises.rm)(temp, { force: true }).catch(() => void 0);
1914
+ }
1915
+ }
1916
+ function isOgpData(value) {
1917
+ if (!value || typeof value !== "object") return false;
1918
+ const data = value;
1919
+ if (typeof data.url !== "string" || typeof data.title !== "string") return false;
1920
+ for (const field of [
1921
+ "description",
1922
+ "image",
1923
+ "siteName",
1924
+ "favicon"
1925
+ ]) if (data[field] !== void 0 && typeof data[field] !== "string") return false;
1926
+ return true;
1927
+ }
1928
+ async function discardCorruptEntry(file) {
1929
+ console.warn(`Ignoring corrupt Open Graph cache entry ${file}`);
1930
+ await (0, node_fs_promises.rm)(file, { force: true }).catch(() => void 0);
1931
+ }
1932
+ function isEnoent(error) {
1933
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
1934
+ }
1935
+ function resolveOgpOptions(options = {}) {
1936
+ return {
1937
+ timeout: options.timeout ?? 1e4,
1938
+ cache: options.cache ?? true,
1939
+ cacheTTL: options.cacheTTL ?? 36e5,
1940
+ persistCache: options.persistCache ?? false,
1941
+ cacheDir: node_path.default.resolve(options.cacheDir ?? ".cache/ox-content/ogp"),
1942
+ refresh: options.refresh ?? false,
1943
+ userAgent: options.userAgent ?? "ox-content-ogp-bot/1.0 (compatible; +https://github.com/ubugeeei-prod/ox-content)"
1944
+ };
1945
+ }
1946
+ //#endregion
1947
+ //#region src/plugins/ogp/url.ts
1480
1948
  function isPrivateIPv4(hostname) {
1481
1949
  const parts = hostname.split(".").map(Number);
1482
1950
  if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
@@ -1496,17 +1964,6 @@ function isSafeOgpUrl(value) {
1496
1964
  return false;
1497
1965
  }
1498
1966
  }
1499
- /**
1500
- * Get element attribute value.
1501
- */
1502
- function getAttribute$1(el, name) {
1503
- const value = el.properties?.[name];
1504
- if (typeof value === "string") return value;
1505
- if (Array.isArray(value)) return value.join(" ");
1506
- }
1507
- /**
1508
- * Extract domain from URL.
1509
- */
1510
1967
  function extractDomain(url) {
1511
1968
  try {
1512
1969
  return new URL(url).hostname;
@@ -1514,9 +1971,6 @@ function extractDomain(url) {
1514
1971
  return url;
1515
1972
  }
1516
1973
  }
1517
- /**
1518
- * Get favicon URL for a domain.
1519
- */
1520
1974
  function getFaviconUrl(url) {
1521
1975
  try {
1522
1976
  return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=32`;
@@ -1525,40 +1979,60 @@ function getFaviconUrl(url) {
1525
1979
  }
1526
1980
  }
1527
1981
  /**
1528
- * Parse OGP metadata from HTML.
1982
+ * Normalize a URL for cache keys: lowercase host, drop default ports and
1983
+ * fragments, and strip a trailing slash that is not the root path.
1529
1984
  */
1530
- function parseOgpFromHtml(html, url) {
1531
- const result = {
1532
- url,
1533
- title: ""
1534
- };
1535
- const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
1536
- result.title = (html.match(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:title["']/i))?.[1] || titleMatch?.[1] || extractDomain(url);
1537
- const descMatch = html.match(/<meta[^>]*property=["']og:description["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:description["']/i) || html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
1538
- if (descMatch) result.description = descMatch[1];
1539
- const imageMatch = html.match(/<meta[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:image["']/i);
1540
- if (imageMatch) {
1541
- let imageUrl = imageMatch[1];
1542
- if (imageUrl.startsWith("/")) try {
1543
- const urlObj = new URL(url);
1544
- imageUrl = `${urlObj.protocol}//${urlObj.host}${imageUrl}`;
1545
- } catch {}
1546
- result.image = imageUrl;
1985
+ function normalizeOgpUrl(url) {
1986
+ try {
1987
+ const parsed = new URL(url);
1988
+ parsed.hash = "";
1989
+ parsed.hostname = parsed.hostname.toLowerCase();
1990
+ if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") parsed.port = "";
1991
+ if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) parsed.pathname = parsed.pathname.slice(0, -1);
1992
+ return parsed.href;
1993
+ } catch {
1994
+ return url;
1547
1995
  }
1548
- const siteNameMatch = html.match(/<meta[^>]*property=["']og:site_name["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:site_name["']/i);
1549
- if (siteNameMatch) result.siteName = siteNameMatch[1];
1550
- result.favicon = getFaviconUrl(url);
1551
- return result;
1552
1996
  }
1553
- /**
1554
- * Fetch OGP data for a URL.
1555
- */
1556
- async function fetchOgpData(url, options) {
1997
+ function ogpCacheKey(url) {
1998
+ return (0, node_crypto.createHash)("sha256").update(normalizeOgpUrl(url)).digest("hex");
1999
+ }
2000
+ //#endregion
2001
+ //#region src/plugins/ogp/fetch.ts
2002
+ const inflight = /* @__PURE__ */ new Map();
2003
+ async function fetchOgpData(url, options = {}) {
1557
2004
  if (!isSafeOgpUrl(url)) return null;
2005
+ const resolved = resolveOgpOptions(options);
2006
+ const key = ogpCacheKey(url);
2007
+ const pending = inflight.get(key);
2008
+ if (pending) return pending;
2009
+ const request = loadOgpData(url, key, resolved).finally(() => {
2010
+ if (inflight.get(key) === request) inflight.delete(key);
2011
+ });
2012
+ inflight.set(key, request);
2013
+ return request;
2014
+ }
2015
+ async function loadOgpData(url, key, options) {
2016
+ const now = Date.now();
2017
+ if (options.cache && !options.refresh) {
2018
+ const memory = readMemoryOgp(key, options, now);
2019
+ if (memory !== void 0) return memory;
2020
+ if (options.persistCache) {
2021
+ const disk = await readDiskOgp(key, options, now);
2022
+ if (disk !== void 0) {
2023
+ writeMemoryOgp(key, disk, now, options);
2024
+ return disk;
2025
+ }
2026
+ }
2027
+ }
2028
+ const data = await requestOgpData(url, options);
1558
2029
  if (options.cache) {
1559
- const cached = ogpCache.get(url);
1560
- if (cached && Date.now() - cached.timestamp < options.cacheTTL) return cached.data;
2030
+ writeMemoryOgp(key, data, now, options);
2031
+ if (options.persistCache) await writeDiskOgp(key, normalizeOgpUrl(url), data, options, now);
1561
2032
  }
2033
+ return data;
2034
+ }
2035
+ async function requestOgpData(url, options) {
1562
2036
  try {
1563
2037
  const controller = new AbortController();
1564
2038
  const timeoutId = setTimeout(() => controller.abort(), options.timeout);
@@ -1574,37 +2048,54 @@ async function fetchOgpData(url, options) {
1574
2048
  console.warn(`Failed to fetch OGP for ${url}: ${response.status}`);
1575
2049
  return null;
1576
2050
  }
1577
- const data = parseOgpFromHtml(await response.text(), url);
1578
- if (options.cache) ogpCache.set(url, {
1579
- data,
1580
- timestamp: Date.now()
1581
- });
1582
- return data;
2051
+ return parseOgpFromHtml(await response.text(), url);
1583
2052
  } catch (error) {
1584
2053
  if (error instanceof Error && error.name === "AbortError") console.warn(`Timeout fetching OGP for ${url}`);
1585
2054
  else console.warn(`Error fetching OGP for ${url}:`, error);
1586
2055
  return null;
1587
2056
  }
1588
2057
  }
1589
- /**
1590
- * Create OGP card element.
1591
- */
1592
- function createOgpCard(data) {
1593
- const children = [];
1594
- const contentChildren = [];
1595
- contentChildren.push({
1596
- type: "element",
1597
- tagName: "div",
1598
- properties: { className: ["ox-ogp-title"] },
1599
- children: [{
1600
- type: "text",
1601
- value: data.title
1602
- }]
1603
- });
1604
- if (data.description) contentChildren.push({
1605
- type: "element",
1606
- tagName: "div",
1607
- properties: { className: ["ox-ogp-description"] },
2058
+ function parseOgpFromHtml(html, url) {
2059
+ const result = {
2060
+ url,
2061
+ title: ""
2062
+ };
2063
+ const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
2064
+ result.title = (html.match(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:title["']/i))?.[1] || titleMatch?.[1] || extractDomain(url);
2065
+ const descMatch = html.match(/<meta[^>]*property=["']og:description["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:description["']/i) || html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
2066
+ if (descMatch) result.description = descMatch[1];
2067
+ const imageMatch = html.match(/<meta[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:image["']/i);
2068
+ if (imageMatch) {
2069
+ let imageUrl = imageMatch[1];
2070
+ if (imageUrl.startsWith("/")) try {
2071
+ const urlObj = new URL(url);
2072
+ imageUrl = `${urlObj.protocol}//${urlObj.host}${imageUrl}`;
2073
+ } catch {}
2074
+ result.image = imageUrl;
2075
+ }
2076
+ const siteNameMatch = html.match(/<meta[^>]*property=["']og:site_name["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:site_name["']/i);
2077
+ if (siteNameMatch) result.siteName = siteNameMatch[1];
2078
+ result.favicon = getFaviconUrl(url);
2079
+ return result;
2080
+ }
2081
+ //#endregion
2082
+ //#region src/plugins/ogp/render.ts
2083
+ function createOgpCard(data) {
2084
+ const children = [];
2085
+ const contentChildren = [];
2086
+ contentChildren.push({
2087
+ type: "element",
2088
+ tagName: "div",
2089
+ properties: { className: ["ox-ogp-title"] },
2090
+ children: [{
2091
+ type: "text",
2092
+ value: data.title
2093
+ }]
2094
+ });
2095
+ if (data.description) contentChildren.push({
2096
+ type: "element",
2097
+ tagName: "div",
2098
+ properties: { className: ["ox-ogp-description"] },
1608
2099
  children: [{
1609
2100
  type: "text",
1610
2101
  value: data.description
@@ -1666,9 +2157,6 @@ function createOgpCard(data) {
1666
2157
  children
1667
2158
  };
1668
2159
  }
1669
- /**
1670
- * Create fallback element when OGP data is unavailable.
1671
- */
1672
2160
  function createFallbackCard(url) {
1673
2161
  return {
1674
2162
  type: "element",
@@ -1700,9 +2188,15 @@ function createFallbackCard(url) {
1700
2188
  }]
1701
2189
  };
1702
2190
  }
1703
- /**
1704
- * Collect all OGP URLs from HTML for pre-fetching.
1705
- */
2191
+ //#endregion
2192
+ //#region src/plugins/ogp/transform.ts
2193
+ const rehypeParse$1 = require_interop.interopDefault(rehype_parse.default);
2194
+ const rehypeStringify$1 = require_interop.interopDefault(rehype_stringify.default);
2195
+ function getAttribute$1(el, name) {
2196
+ const value = el.properties?.[name];
2197
+ if (typeof value === "string") return value;
2198
+ if (Array.isArray(value)) return value.join(" ");
2199
+ }
1706
2200
  async function collectOgpUrls(html) {
1707
2201
  const urls = [];
1708
2202
  const urlPattern = /<ogcard[^>]*\s+url=["']([^"']+)["']/gi;
@@ -1710,24 +2204,13 @@ async function collectOgpUrls(html) {
1710
2204
  while ((match = urlPattern.exec(html)) !== null) if (isSafeOgpUrl(match[1])) urls.push(match[1]);
1711
2205
  return urls;
1712
2206
  }
1713
- /**
1714
- * Pre-fetch all OGP data.
1715
- */
1716
2207
  async function prefetchOgpData(urls, options) {
1717
- const mergedOptions = {
1718
- ...defaultOptions,
1719
- ...options
1720
- };
1721
2208
  const results = /* @__PURE__ */ new Map();
1722
2209
  await Promise.all(urls.map(async (url) => {
1723
- const data = await fetchOgpData(url, mergedOptions);
1724
- results.set(url, data);
2210
+ results.set(url, await fetchOgpData(url, options));
1725
2211
  }));
1726
2212
  return results;
1727
2213
  }
1728
- /**
1729
- * Rehype plugin to transform OgCard components.
1730
- */
1731
2214
  function rehypeOgp(ogpDataMap) {
1732
2215
  return (tree) => {
1733
2216
  const visit = (node) => {
@@ -1738,8 +2221,7 @@ function rehypeOgp(ogpDataMap) {
1738
2221
  const url = getAttribute$1(child, "url");
1739
2222
  if (url) {
1740
2223
  const ogpData = ogpDataMap.get(url);
1741
- const cardElement = ogpData ? createOgpCard(ogpData) : createFallbackCard(url);
1742
- node.children[i] = cardElement;
2224
+ node.children[i] = ogpData ? createOgpCard(ogpData) : createFallbackCard(url);
1743
2225
  }
1744
2226
  } else visit(child);
1745
2227
  }
@@ -1748,9 +2230,6 @@ function rehypeOgp(ogpDataMap) {
1748
2230
  visit(tree);
1749
2231
  };
1750
2232
  }
1751
- /**
1752
- * Transform OgCard components in HTML.
1753
- */
1754
2233
  async function transformOgp(html, ogpDataMap, options) {
1755
2234
  let dataMap = ogpDataMap;
1756
2235
  if (!dataMap) dataMap = await prefetchOgpData(await collectOgpUrls(html), options);
@@ -1758,6 +2237,9 @@ async function transformOgp(html, ogpDataMap, options) {
1758
2237
  return String(result);
1759
2238
  }
1760
2239
  //#endregion
2240
+ //#region src/plugins/ogp.ts
2241
+ var ogp_exports = /* @__PURE__ */ require_vitepress.__exportAll({ transformOgp: () => transformOgp });
2242
+ //#endregion
1761
2243
  //#region src/plugins/index.ts
1762
2244
  const SELF_CLOSING_EMBED_TAG = /<(GitHub|OgCard|Tweet|XPost|Bluesky|Spotify|StackBlitz|WebContainer|YouTube)((?:[^>"']|"[^"]*"|'[^']*')*?)\s*\/>/gi;
1763
2245
  /**
@@ -2432,6 +2914,7 @@ async function transformMarkdown(source, filePath, options, ssgOptions) {
2432
2914
  gfm: options.gfm,
2433
2915
  mdx: resolveMdxForFilePath(filePath, options.mdx),
2434
2916
  footnotes: options.footnotes,
2917
+ semanticFootnotes: options.semanticFootnotes ?? false,
2435
2918
  taskLists: options.taskLists,
2436
2919
  tables: options.tables,
2437
2920
  strikethrough: options.strikethrough,
@@ -2439,6 +2922,7 @@ async function transformMarkdown(source, filePath, options, ssgOptions) {
2439
2922
  autolinkUrls: options.autolinks,
2440
2923
  frontmatter: options.frontmatter,
2441
2924
  tocMaxDepth: options.tocMaxDepth,
2925
+ headingPermalinks: options.headingPermalinks?.enabled ?? false,
2442
2926
  convertMdLinks: ssgOptions?.convertMdLinks,
2443
2927
  baseUrl: ssgOptions?.baseUrl,
2444
2928
  sourcePath: ssgOptions?.sourcePath ?? filePath,
@@ -2456,6 +2940,13 @@ async function transformMarkdown(source, filePath, options, ssgOptions) {
2456
2940
  } : void 0,
2457
2941
  attributes: options.attrs?.enabled ? { enabled: true } : void 0,
2458
2942
  badges: options.badges?.enabled ? { enabled: true } : void 0,
2943
+ magicLinks: options.magicLinks?.enabled ? {
2944
+ enabled: true,
2945
+ aliases: options.magicLinks.aliases,
2946
+ favicon: options.magicLinks.favicon,
2947
+ faviconTemplate: options.magicLinks.faviconTemplate,
2948
+ imageOverrides: options.magicLinks.imageOverrides
2949
+ } : void 0,
2459
2950
  containers: options.containers?.enabled ? {
2460
2951
  enabled: true,
2461
2952
  types: options.containers.types
@@ -3939,6 +4430,108 @@ initIslands((el, props) => {
3939
4430
  `;
3940
4431
  }
3941
4432
  //#endregion
4433
+ //#region src/versions-html.ts
4434
+ function versionSwitcherMarkup(links, badge) {
4435
+ if (links.length === 0) return "";
4436
+ const current = links.find((link) => link.current) ?? links[0];
4437
+ const items = links.map((link) => {
4438
+ const label = `${escapeHtml$4(link.label)}${badgeMarkup(link, badge)}`;
4439
+ if (link.current || !isSafeHref(link.href)) return `<li><span aria-current="page">${label}</span></li>`;
4440
+ return `<li><a href="${escapeHtml$4(link.href)}">${label}</a></li>`;
4441
+ }).join("");
4442
+ return `<nav class="ox-header-select ox-version-switcher" aria-label="Version"><button type="button" aria-expanded="false" aria-haspopup="true">${escapeHtml$4(current.label)}${badgeMarkup(current, badge)}</button><ul class="ox-header-select-menu">${items}</ul></nav><script>(function(){var n=document.currentScript&&document.currentScript.previousElementSibling;if(!n||!n.classList.contains("ox-version-switcher"))return;var b=n.querySelector("button");if(!b)return;function closeOthers(){document.querySelectorAll(".header-nav-dropdown > button[aria-expanded='true'], .ox-locale-switcher > button[aria-expanded='true']").forEach(function(btn){btn.setAttribute("aria-expanded","false");});}b.addEventListener("click",function(e){e.stopPropagation();var o=b.getAttribute("aria-expanded")==="true";closeOthers();b.setAttribute("aria-expanded",o?"false":"true");});document.addEventListener("click",function(e){if(!n.contains(e.target))b.setAttribute("aria-expanded","false");});document.addEventListener("keydown",function(e){if(e.key==="Escape"){b.setAttribute("aria-expanded","false");b.focus();}});})()<\/script>`;
4443
+ }
4444
+ function versionBannerMarkup(kind) {
4445
+ if (kind === "unreleased") return `<aside class="ox-version-banner ox-version-banner--unreleased" role="status">This documentation describes an unreleased version.</aside>`;
4446
+ if (kind === "unmaintained") return `<aside class="ox-version-banner ox-version-banner--unmaintained" role="status">This documentation is unmaintained.</aside>`;
4447
+ return "";
4448
+ }
4449
+ function injectVersionChrome(html, switcher, banner, searchFrom, searchTo) {
4450
+ let next = html;
4451
+ if (banner) next = next.replace(/<body([^>]*)>/, `<body$1>${banner}`);
4452
+ if (switcher) {
4453
+ if (next.includes("<div class=\"header-actions\">")) next = next.replace("<div class=\"header-actions\">", `<div class="header-actions">${switcher}`);
4454
+ else if (next.includes("</header>")) next = next.replace("</header>", `${switcher}</header>`);
4455
+ }
4456
+ if (searchTo && isSafeHref(searchTo)) next = next.replace(/<html([^>]*)>/i, (match, attrs) => {
4457
+ if (/\sdata-ox-search-index=/.test(attrs)) return match;
4458
+ return `<html${attrs} data-ox-search-index="${escapeHtml$4(searchTo)}">`;
4459
+ });
4460
+ if (searchFrom && searchTo && searchFrom !== searchTo && isSafeHref(searchTo)) {
4461
+ next = next.split(searchFrom).join(searchTo);
4462
+ const script = `<script>(function(){var f=${JSON.stringify(searchFrom)},t=${JSON.stringify(searchTo)};var o=window.fetch;window.fetch=function(i,n){if(typeof i==="string"&&i.indexOf(f)!==-1)i=i.split(f).join(t);return o.call(this,i,n);};})()<\/script>`;
4463
+ next = next.includes("</body>") ? next.replace("</body>", `${script}</body>`) : `${next}${script}`;
4464
+ }
4465
+ return next;
4466
+ }
4467
+ function searchIndexUrl(base, prefix) {
4468
+ const root = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
4469
+ return prefix ? `${root}${prefix}/search-index.json` : `${root}search-index.json`;
4470
+ }
4471
+ function isSafeHref(href) {
4472
+ const trimmed = href.trim();
4473
+ if (!trimmed || trimmed.startsWith("//")) return false;
4474
+ const lower = trimmed.replace(/\s+/g, "").toLowerCase();
4475
+ if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:")) return false;
4476
+ return trimmed.startsWith("/") || trimmed.startsWith("./") || !trimmed.includes(":");
4477
+ }
4478
+ function escapeHtml$4(value) {
4479
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
4480
+ }
4481
+ function badgeMarkup(link, badge) {
4482
+ if (!badge || !link.banner) return "";
4483
+ return `<span class="ox-version-badge">${link.banner === "unreleased" ? "unreleased" : "unmaintained"}</span>`;
4484
+ }
4485
+ //#endregion
4486
+ //#region src/search-filters.ts
4487
+ /**
4488
+ * Language and version filters for the default search dialog.
4489
+ */
4490
+ const RESULTS_MARKUP = "<div class=\"search-results\"></div>";
4491
+ function injectSearchLocaleFilters(html, input) {
4492
+ const locales = input.locales.filter((locale) => locale.code.trim() && locale.name.trim());
4493
+ if (locales.length < 2) return html;
4494
+ const next = ensureSearchFilters(html);
4495
+ const select = selectMarkup(next, "locale");
4496
+ if (!select) return next;
4497
+ const defaultLocale = input.defaultLocale.trim() || locales[0].code;
4498
+ const selected = locales.some((locale) => locale.code === input.current) ? input.current : defaultLocale;
4499
+ const options = [`<option value="">All languages</option>`, ...locales.map((locale) => {
4500
+ return `<option value="${escapeHtml$4(locale.code)}"${locale.code === selected ? " selected" : ""}>${escapeHtml$4(locale.name)}</option>`;
4501
+ })].join("");
4502
+ return revealFilter(next.replace(select.markup, `<select class="search-filter-select" data-search-filter="locale" data-default-locale="${escapeHtml$4(defaultLocale)}" aria-label="Language">${options}</select>`), "locale");
4503
+ }
4504
+ function injectSearchVersionFilters(html, versions) {
4505
+ const safe = versions.filter((version) => version.id.trim() && version.label.trim() && isSafeHref(version.indexUrl));
4506
+ if (safe.length < 2) return html;
4507
+ const next = ensureSearchFilters(html);
4508
+ const select = selectMarkup(next, "version");
4509
+ if (!select) return next;
4510
+ const current = safe.find((version) => version.current) ?? safe[0];
4511
+ const options = safe.map((version) => {
4512
+ const selected = version.id === current.id ? " selected" : "";
4513
+ return `<option value="${escapeHtml$4(version.id)}" data-prefix="${escapeHtml$4(version.prefix)}" data-index="${escapeHtml$4(version.indexUrl)}"${selected}>${escapeHtml$4(version.label)}</option>`;
4514
+ }).join("");
4515
+ return revealFilter(next.replace(select.markup, `<select class="search-filter-select" data-search-filter="version" aria-label="Version">${options}</select>`), "version");
4516
+ }
4517
+ function ensureSearchFilters(html) {
4518
+ if (html.includes("class=\"search-filters\"") || !html.includes(RESULTS_MARKUP)) return html;
4519
+ return html.replace(RESULTS_MARKUP, `${searchFiltersMarkup()}${RESULTS_MARKUP}`);
4520
+ }
4521
+ function searchFiltersMarkup() {
4522
+ return `${searchFiltersStyle()}<div class="search-filters"><label class="search-filter" data-search-filter-label="locale" hidden><span class="search-filter-label">Language</span><select class="search-filter-select" data-search-filter="locale" data-default-locale="" aria-label="Language"></select></label><label class="search-filter" data-search-filter-label="version" hidden><span class="search-filter-label">Version</span><select class="search-filter-select" data-search-filter="version" aria-label="Version"></select></label></div>`;
4523
+ }
4524
+ function searchFiltersStyle() {
4525
+ return `<style class="ox-search-filters-style">.search-filters{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;padding:.65rem 1rem;border-bottom:1px solid var(--octc-color-border);background:var(--octc-color-bg-alt)}.search-filter{display:flex;align-items:center;gap:.4rem;min-width:0}.search-filter[hidden]{display:none}.search-filter-label{font-size:.75rem;color:var(--octc-color-text-muted);white-space:nowrap}.search-filter-select{min-width:8rem;max-width:12rem;padding:.25rem .4rem;border:1px solid var(--octc-color-border);border-radius:4px;background:var(--octc-color-bg);color:var(--octc-color-text);font:inherit;font-size:.8125rem}.search-filter-select:focus{outline:2px solid var(--octc-color-primary);outline-offset:1px}</style>`;
4526
+ }
4527
+ function selectMarkup(html, kind) {
4528
+ const match = html.match(new RegExp(`<select class="search-filter-select" data-search-filter="${kind}"[^>]*>[\\s\\S]*?<\\/select>`));
4529
+ return match?.[0] ? { markup: match[0] } : void 0;
4530
+ }
4531
+ function revealFilter(html, kind) {
4532
+ return html.replace(`data-search-filter-label="${kind}" hidden`, `data-search-filter-label="${kind}"`);
4533
+ }
4534
+ //#endregion
3942
4535
  //#region src/locale-switcher.ts
3943
4536
  /**
3944
4537
  * Resolves `ssg.localeSwitcher`. Omitted / `false` stay off. `true` or an
@@ -4138,6 +4731,22 @@ function stripLocalePrefix(sitePath, locales) {
4138
4731
  return normalized;
4139
4732
  }
4140
4733
  //#endregion
4734
+ //#region src/page-head.ts
4735
+ /** Resolve descriptors to escaped `<head>` markup. Build-time only. */
4736
+ function renderHead(input) {
4737
+ return require_vitepress.importNapiModuleSync().renderHead(JSON.stringify(input));
4738
+ }
4739
+ function resolveHeadValidation(value) {
4740
+ if (value === "warn" || value === "strict") return value;
4741
+ return false;
4742
+ }
4743
+ function reportHeadDiagnostics(diagnostics, validation) {
4744
+ if (!validation || diagnostics.length === 0) return;
4745
+ const fatal = diagnostics.filter((item) => item.strict);
4746
+ if (validation === "strict" && fatal.length > 0) throw new Error(`[ox-content] ${fatal[0].message}`);
4747
+ if (validation === "warn") for (const item of diagnostics) console.warn(`[ox-content] ${item.message}`);
4748
+ }
4749
+ //#endregion
4141
4750
  //#region src/page-context.ts
4142
4751
  var page_context_exports = /* @__PURE__ */ require_vitepress.__exportAll({
4143
4752
  clearRenderContext: () => clearRenderContext,
@@ -4355,6 +4964,7 @@ function renderPage(page, options) {
4355
4964
  contributors: page.contributors,
4356
4965
  path: page.path,
4357
4966
  url: page.url,
4967
+ markdownSource: page.markdownSource,
4358
4968
  frontmatter: page.frontmatter,
4359
4969
  layout: page.layout
4360
4970
  },
@@ -4371,6 +4981,7 @@ function renderPage(page, options) {
4371
4981
  contributors: p.contributors,
4372
4982
  path: p.path,
4373
4983
  url: p.url,
4984
+ markdownSource: p.markdownSource,
4374
4985
  frontmatter: p.frontmatter,
4375
4986
  layout: p.layout
4376
4987
  }))
@@ -4429,8 +5040,8 @@ function DefaultTheme({ children }) {
4429
5040
  <head>
4430
5041
  <meta charset="UTF-8">
4431
5042
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
4432
- <title>${escapeHtml$4(page.title)} - ${escapeHtml$4(site.name)}</title>
4433
- ${page.description ? `<meta name="description" content="${escapeHtml$4(page.description)}">` : ""}
5043
+ <title>${escapeHtml$3(page.title)} - ${escapeHtml$3(site.name)}</title>
5044
+ ${page.description ? `<meta name="description" content="${escapeHtml$3(page.description)}">` : ""}
4434
5045
  <style>
4435
5046
  :root {
4436
5047
  --octc-color-primary: #4f6fae;
@@ -4454,7 +5065,7 @@ function DefaultTheme({ children }) {
4454
5065
  </head>
4455
5066
  <body>
4456
5067
  <header>
4457
- <h1>${escapeHtml$4(site.name)}</h1>
5068
+ <h1>${escapeHtml$3(site.name)}</h1>
4458
5069
  </header>
4459
5070
  <main>
4460
5071
  ${children.__html}
@@ -4462,7 +5073,7 @@ function DefaultTheme({ children }) {
4462
5073
  </body>
4463
5074
  </html>` };
4464
5075
  }
4465
- function escapeHtml$4(str) {
5076
+ function escapeHtml$3(str) {
4466
5077
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4467
5078
  }
4468
5079
  /**
@@ -4562,6 +5173,13 @@ async function writeSiteMapFiles(input) {
4562
5173
  }
4563
5174
  return { files };
4564
5175
  }
5176
+ /** UTC `YYYY-MM-DD` for W3C lastmod. Invalid or negative timestamps are dropped. */
5177
+ function formatLastmod(timestampMs) {
5178
+ if (timestampMs == null || !Number.isFinite(timestampMs) || timestampMs < 0) return;
5179
+ const date = new Date(timestampMs);
5180
+ if (Number.isNaN(date.getTime())) return;
5181
+ return date.toISOString().slice(0, 10);
5182
+ }
4565
5183
  function hasSiteUrl$2(siteUrl) {
4566
5184
  return Boolean(siteUrl && siteUrl.trim());
4567
5185
  }
@@ -4574,7 +5192,14 @@ function generateSitemapXml(pages) {
4574
5192
  for (const page of pages) {
4575
5193
  xml += " <url>\n <loc>";
4576
5194
  xml += escapeXml$1(page.loc);
4577
- xml += "</loc>\n </url>\n";
5195
+ xml += "</loc>\n";
5196
+ const lastmod = formatLastmod(page.lastUpdated);
5197
+ if (lastmod) {
5198
+ xml += " <lastmod>";
5199
+ xml += lastmod;
5200
+ xml += "</lastmod>\n";
5201
+ }
5202
+ xml += " </url>\n";
4578
5203
  }
4579
5204
  xml += "</urlset>\n";
4580
5205
  return xml;
@@ -4635,102 +5260,6 @@ function escapeLlmsUrl(value) {
4635
5260
  return escaped;
4636
5261
  }
4637
5262
  //#endregion
4638
- //#region src/publish-state.ts
4639
- /**
4640
- * Opt-in draft / unlisted / scheduled page classification.
4641
- */
4642
- /**
4643
- * Resolves `publishState` with defaults.
4644
- *
4645
- * `false` / omitted stays off. `true` enables production filtering. An object
4646
- * enables the feature and overrides only the fields the site set.
4647
- */
4648
- function resolvePublishStateOptions(value) {
4649
- if (!value) return {
4650
- enabled: false,
4651
- includeDrafts: false
4652
- };
4653
- if (value === true) return {
4654
- enabled: true,
4655
- includeDrafts: false
4656
- };
4657
- return {
4658
- enabled: value.enabled ?? true,
4659
- now: value.now,
4660
- includeDrafts: value.includeDrafts ?? false
4661
- };
4662
- }
4663
- /** Classifies one frontmatter object. Never throws. */
4664
- function classifyPublishState(frontmatter, options) {
4665
- try {
4666
- return require_vitepress.importNapiModuleSync().classifyPublishState(JSON.stringify(frontmatter ?? {}), toNapiPublishState(options));
4667
- } catch {
4668
- return {
4669
- output: true,
4670
- listed: true
4671
- };
4672
- }
4673
- }
4674
- /** Splits pages into those that write HTML and those that appear in listings. */
4675
- function partitionPublishedPages(pages, options) {
4676
- if (!options?.enabled) return {
4677
- output: [...pages],
4678
- listed: [...pages]
4679
- };
4680
- const output = [];
4681
- const listed = [];
4682
- for (const page of pages) {
4683
- const decision = classifyPublishState(page.frontmatter, options);
4684
- if (decision.output) output.push(page);
4685
- if (decision.listed) listed.push(page);
4686
- }
4687
- return {
4688
- output,
4689
- listed
4690
- };
4691
- }
4692
- /** Drops nav items that resolve to hidden (unpublished or unlisted) pages. */
4693
- function filterNavGroups(groups, hidden) {
4694
- return groups.map((group) => ({
4695
- ...group,
4696
- items: filterNavItems(group.items, hidden)
4697
- })).filter((group) => group.items.length > 0);
4698
- }
4699
- function filterNavItems(items, hidden) {
4700
- const kept = [];
4701
- for (const item of items) {
4702
- if (isHiddenNavTarget(item, hidden)) continue;
4703
- const children = item.children?.length ? filterNavItems(item.children, hidden) : item.children;
4704
- kept.push(children === item.children ? item : {
4705
- ...item,
4706
- children
4707
- });
4708
- }
4709
- return kept;
4710
- }
4711
- function isHiddenNavTarget(item, hidden) {
4712
- return hidden.has(item.path) || hidden.has(item.href);
4713
- }
4714
- /** Keys used to match a page against generated nav items. */
4715
- function hiddenNavKeys(pages, listed) {
4716
- const listedPaths = new Set(listed.map((page) => page.inputPath));
4717
- const hidden = /* @__PURE__ */ new Set();
4718
- for (const page of pages) {
4719
- if (listedPaths.has(page.inputPath)) continue;
4720
- hidden.add(page.routePaths.urlPath);
4721
- hidden.add(page.routePaths.href);
4722
- }
4723
- return hidden;
4724
- }
4725
- function toNapiPublishState(options) {
4726
- if (!options) return;
4727
- return {
4728
- enabled: options.enabled,
4729
- now: options.now,
4730
- includeDrafts: options.includeDrafts
4731
- };
4732
- }
4733
- //#endregion
4734
5263
  //#region src/permalinks.ts
4735
5264
  const RESERVED_CASCADE_KEYS = /* @__PURE__ */ new Set(["permalink", "slug"]);
4736
5265
  /** Resolves `permalinks`. `false` / omitted stays off. `true` / `{}` enables. */
@@ -4780,6 +5309,18 @@ function resolvePageRoutes(input) {
4780
5309
  errors
4781
5310
  };
4782
5311
  }
5312
+ /** Escapes a value for use in an HTML attribute. */
5313
+ function escapeAttribute$2(value) {
5314
+ return value.replace(/[&<>"']/gu, (ch) => {
5315
+ switch (ch) {
5316
+ case "&": return "&amp;";
5317
+ case "<": return "&lt;";
5318
+ case ">": return "&gt;";
5319
+ case "\"": return "&quot;";
5320
+ default: return "&#39;";
5321
+ }
5322
+ });
5323
+ }
4783
5324
  function normalizeUrlPath$1(value) {
4784
5325
  const segments = pathSegments(value);
4785
5326
  return segments.length === 0 ? "/" : segments.join("/");
@@ -4881,27 +5422,315 @@ function ancestorDirs(source) {
4881
5422
  return dirs;
4882
5423
  }
4883
5424
  //#endregion
4884
- //#region src/apply-permalinks.ts
5425
+ //#region src/publish-state.ts
4885
5426
  /**
4886
- * Applies resolved permalinks / cascade to SSG pages and collection entries.
5427
+ * Opt-in draft / unlisted / scheduled page classification.
4887
5428
  */
4888
- /** Rewrites SSG `routePaths` from resolved permalinks / slugs. */
4889
- function applySsgPageRoutes(input) {
4890
- const resolved = resolvePageRoutes({
4891
- pages: input.pages.map((page) => ({
4892
- source: page.inputPath,
4893
- fileUrl: page.routePaths.urlPath,
4894
- frontmatter: page.frontmatter
4895
- })),
4896
- permalinks: input.permalinks,
4897
- cascade: input.cascade
4898
- });
4899
- const bySource = new Map(resolved.pages.map((page) => [page.source, page]));
4900
- const pages = [];
4901
- for (const page of input.pages) {
4902
- const hit = bySource.get(page.inputPath);
4903
- if (!hit) continue;
4904
- pages.push({
5429
+ /**
5430
+ * Resolves `publishState` with defaults.
5431
+ *
5432
+ * `false` / omitted stays off. `true` enables production filtering. An object
5433
+ * enables the feature and overrides only the fields the site set.
5434
+ */
5435
+ function resolvePublishStateOptions(value) {
5436
+ if (!value) return {
5437
+ enabled: false,
5438
+ includeDrafts: false
5439
+ };
5440
+ if (value === true) return {
5441
+ enabled: true,
5442
+ includeDrafts: false
5443
+ };
5444
+ return {
5445
+ enabled: value.enabled ?? true,
5446
+ now: value.now,
5447
+ includeDrafts: value.includeDrafts ?? false
5448
+ };
5449
+ }
5450
+ /** Classifies one frontmatter object. Never throws. */
5451
+ function classifyPublishState(frontmatter, options) {
5452
+ try {
5453
+ return require_vitepress.importNapiModuleSync().classifyPublishState(JSON.stringify(frontmatter ?? {}), toNapiPublishState(options));
5454
+ } catch {
5455
+ return {
5456
+ output: true,
5457
+ listed: true
5458
+ };
5459
+ }
5460
+ }
5461
+ /** Splits pages into those that write HTML and those that appear in listings. */
5462
+ function partitionPublishedPages(pages, options) {
5463
+ if (!options?.enabled) return {
5464
+ output: [...pages],
5465
+ listed: [...pages]
5466
+ };
5467
+ const output = [];
5468
+ const listed = [];
5469
+ for (const page of pages) {
5470
+ const decision = classifyPublishState(page.frontmatter, options);
5471
+ if (decision.output) output.push(page);
5472
+ if (decision.listed) listed.push(page);
5473
+ }
5474
+ return {
5475
+ output,
5476
+ listed
5477
+ };
5478
+ }
5479
+ /** Drops nav items that resolve to hidden (unpublished or unlisted) pages. */
5480
+ function filterNavGroups(groups, hidden) {
5481
+ return groups.map((group) => ({
5482
+ ...group,
5483
+ items: filterNavItems(group.items, hidden)
5484
+ })).filter((group) => group.items.length > 0);
5485
+ }
5486
+ function filterNavItems(items, hidden) {
5487
+ const kept = [];
5488
+ for (const item of items) {
5489
+ if (isHiddenNavTarget(item, hidden)) continue;
5490
+ const children = item.children?.length ? filterNavItems(item.children, hidden) : item.children;
5491
+ kept.push(children === item.children ? item : {
5492
+ ...item,
5493
+ children
5494
+ });
5495
+ }
5496
+ return kept;
5497
+ }
5498
+ function isHiddenNavTarget(item, hidden) {
5499
+ return hidden.has(item.path) || hidden.has(item.href);
5500
+ }
5501
+ /** Keys used to match a page against generated nav items. */
5502
+ function hiddenNavKeys(pages, listed) {
5503
+ const listedPaths = new Set(listed.map((page) => page.inputPath));
5504
+ const hidden = /* @__PURE__ */ new Set();
5505
+ for (const page of pages) {
5506
+ if (listedPaths.has(page.inputPath)) continue;
5507
+ hidden.add(page.routePaths.urlPath);
5508
+ hidden.add(page.routePaths.href);
5509
+ }
5510
+ return hidden;
5511
+ }
5512
+ function toNapiPublishState(options) {
5513
+ if (!options) return;
5514
+ return {
5515
+ enabled: options.enabled,
5516
+ now: options.now,
5517
+ includeDrafts: options.includeDrafts
5518
+ };
5519
+ }
5520
+ //#endregion
5521
+ //#region src/markdown-source.ts
5522
+ /**
5523
+ * Opt-in Markdown source companions written beside generated HTML.
5524
+ *
5525
+ * Copies already-read source bytes. Does not re-parse Markdown to emit them.
5526
+ */
5527
+ /**
5528
+ * Resolves `ssg.markdownSource` with defaults.
5529
+ *
5530
+ * `false` / omitted stays off. `true` enables companions and the alternate
5531
+ * link. An object enables the feature and overrides only the fields set.
5532
+ */
5533
+ function resolveMarkdownSourceOptions(value) {
5534
+ if (!value) return {
5535
+ enabled: false,
5536
+ alternate: true
5537
+ };
5538
+ if (value === true) return {
5539
+ enabled: true,
5540
+ alternate: true
5541
+ };
5542
+ return {
5543
+ enabled: true,
5544
+ alternate: value.alternate !== false
5545
+ };
5546
+ }
5547
+ /** Companion href for one page after permalink / publish-state checks. */
5548
+ function markdownSourceHrefForPage(input) {
5549
+ if (!shouldPublishMarkdownSource(input.frontmatter, input.publishState)) return;
5550
+ return markdownSourceHref(resolvePageRoutes({
5551
+ pages: [{
5552
+ source: input.source,
5553
+ fileUrl: input.fileUrl,
5554
+ frontmatter: input.frontmatter
5555
+ }],
5556
+ permalinks: input.permalinks,
5557
+ cascade: input.cascade
5558
+ }).pages[0]?.urlPath ?? input.fileUrl, input.base);
5559
+ }
5560
+ /** Public companion href, including `base`. Always ends in `.md`. */
5561
+ function markdownSourceHref(urlPath, base) {
5562
+ const relative = companionRelativePath(urlPath);
5563
+ if (!relative) return;
5564
+ return `${normalizeBase$1(base)}${relative}`;
5565
+ }
5566
+ /** Filesystem path for a companion, or `undefined` when it would escape `outDir`. */
5567
+ function markdownSourceOutputPath(outDir, urlPath) {
5568
+ const relative = companionRelativePath(urlPath);
5569
+ if (!relative) return;
5570
+ return containedPath$3(outDir, ...relative.split("/"));
5571
+ }
5572
+ /**
5573
+ * Whether this page may publish a companion.
5574
+ *
5575
+ * Draft and unlisted source is never emitted. When `publishState` is on,
5576
+ * scheduled / expired pages follow that filter and `includeDrafts` is ignored
5577
+ * so preview HTML cannot leak source.
5578
+ */
5579
+ function shouldPublishMarkdownSource(frontmatter, publishState) {
5580
+ if (frontmatter.draft === true || frontmatter.unlisted === true) return false;
5581
+ if (!publishState?.enabled) return true;
5582
+ return classifyPublishState(frontmatter, {
5583
+ ...publishState,
5584
+ includeDrafts: false
5585
+ }).output;
5586
+ }
5587
+ /** Inserts `<link rel="alternate" type="text/markdown">` before `</head>`. */
5588
+ function injectMarkdownSourceAlternate(html, href) {
5589
+ if (!href || !/<\/head>/i.test(html)) return html;
5590
+ const tag = `<link rel="alternate" type="text/markdown" href="${escapeAttribute$2(href)}">`;
5591
+ const index = html.toLowerCase().lastIndexOf("</head>");
5592
+ return `${html.slice(0, index)} ${tag}\n${html.slice(index)}`;
5593
+ }
5594
+ /** Writes enabled companions from already-read source bytes. */
5595
+ async function writeMarkdownSourceFiles(input) {
5596
+ if (!input.options?.enabled) return {
5597
+ files: [],
5598
+ errors: []
5599
+ };
5600
+ const files = [];
5601
+ const errors = [];
5602
+ const seen = /* @__PURE__ */ new Map();
5603
+ for (const page of input.pages) {
5604
+ if (page.source == null || !shouldPublishMarkdownSource(page.frontmatter, input.publishState)) continue;
5605
+ const outputPath = markdownSourceOutputPath(input.outDir, page.urlPath);
5606
+ if (!outputPath) {
5607
+ errors.push(`[ox-content] markdownSource skipped path-escape for ${page.inputPath}`);
5608
+ continue;
5609
+ }
5610
+ const previous = seen.get(outputPath);
5611
+ if (previous) {
5612
+ errors.push(`[ox-content] markdownSource collision: ${page.inputPath} and ${previous} both map to ${outputPath}`);
5613
+ continue;
5614
+ }
5615
+ seen.set(outputPath, page.inputPath);
5616
+ await node_fs_promises.mkdir(node_path.dirname(outputPath), { recursive: true });
5617
+ await node_fs_promises.writeFile(outputPath, page.source);
5618
+ files.push(outputPath);
5619
+ }
5620
+ return {
5621
+ files,
5622
+ errors
5623
+ };
5624
+ }
5625
+ /** True when the request pathname is a `.md` companion URL. */
5626
+ function isMarkdownSourceRequest(pathname) {
5627
+ const clean = stripSearch(pathname);
5628
+ return clean.toLowerCase().endsWith(".md") && !clean.includes("\\");
5629
+ }
5630
+ /** Builds a companion index from source files without transforming Markdown. */
5631
+ async function buildMarkdownSourceIndex(input) {
5632
+ const loaded = await Promise.all(input.files.map(async (file) => {
5633
+ const source = await node_fs_promises.readFile(file, "utf8");
5634
+ return {
5635
+ source: file,
5636
+ fileUrl: require_vitepress.importNapiModuleSync().getSsgUrlPath(file, input.srcDir),
5637
+ frontmatter: parseSourceFrontmatter(source),
5638
+ body: source
5639
+ };
5640
+ }));
5641
+ const routed = resolvePageRoutes({
5642
+ pages: loaded.map(({ source, fileUrl, frontmatter }) => ({
5643
+ source,
5644
+ fileUrl,
5645
+ frontmatter
5646
+ })),
5647
+ permalinks: input.permalinks,
5648
+ cascade: input.cascade
5649
+ });
5650
+ const bodies = new Map(loaded.map((page) => [page.source, page.body]));
5651
+ const index = /* @__PURE__ */ new Map();
5652
+ for (const page of routed.pages) {
5653
+ const href = markdownSourceHref(page.urlPath, "/");
5654
+ const body = bodies.get(page.source);
5655
+ if (!href || body == null) continue;
5656
+ index.set(normalizePathname(href), {
5657
+ source: body,
5658
+ allowed: shouldPublishMarkdownSource(page.frontmatter, input.publishState)
5659
+ });
5660
+ }
5661
+ return index;
5662
+ }
5663
+ /** Looks up a companion after the site `base` has been stripped. */
5664
+ function resolveMarkdownSourceRequest(pathname, index) {
5665
+ if (!isMarkdownSourceRequest(pathname)) return;
5666
+ return index.get(normalizePathname(stripSearch(pathname)));
5667
+ }
5668
+ /** Frontmatter keys only — not a Markdown parse. */
5669
+ function parseSourceFrontmatter(source) {
5670
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
5671
+ if (!match?.[1]) return {};
5672
+ const result = {};
5673
+ for (const line of match[1].split("\n")) {
5674
+ const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
5675
+ if (!kv) continue;
5676
+ result[kv[1]] = parseFrontmatterScalar(kv[2].trim());
5677
+ }
5678
+ return result;
5679
+ }
5680
+ function parseFrontmatterScalar(value) {
5681
+ if (value === "true") return true;
5682
+ if (value === "false") return false;
5683
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
5684
+ return value;
5685
+ }
5686
+ function companionRelativePath(urlPath) {
5687
+ const trimmed = urlPath === "/" || !urlPath ? "index" : urlPath.replace(/^\/+|\/+$/gu, "");
5688
+ if (!trimmed) return;
5689
+ if (trimmed.split("/").some((segment) => !segment || segment === "." || segment === "..")) return;
5690
+ return `${trimmed}.md`;
5691
+ }
5692
+ function containedPath$3(outDir, ...segments) {
5693
+ const root = node_path.resolve(outDir);
5694
+ const resolved = node_path.resolve(root, ...segments);
5695
+ const prefix = root.endsWith(node_path.sep) ? root : `${root}${node_path.sep}`;
5696
+ if (resolved === root || !resolved.startsWith(prefix)) return;
5697
+ return resolved;
5698
+ }
5699
+ function normalizeBase$1(base) {
5700
+ if (!base || base === "/") return "/";
5701
+ return base.endsWith("/") ? base : `${base}/`;
5702
+ }
5703
+ function stripSearch(pathname) {
5704
+ return pathname.split("?")[0]?.split("#")[0] ?? pathname;
5705
+ }
5706
+ function normalizePathname(pathname) {
5707
+ const clean = stripSearch(pathname);
5708
+ if (!clean || clean === "/") return "/";
5709
+ const withSlash = clean.startsWith("/") ? clean : `/${clean}`;
5710
+ return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
5711
+ }
5712
+ //#endregion
5713
+ //#region src/apply-permalinks.ts
5714
+ /**
5715
+ * Applies resolved permalinks / cascade to SSG pages and collection entries.
5716
+ */
5717
+ /** Rewrites SSG `routePaths` from resolved permalinks / slugs. */
5718
+ function applySsgPageRoutes(input) {
5719
+ const resolved = resolvePageRoutes({
5720
+ pages: input.pages.map((page) => ({
5721
+ source: page.inputPath,
5722
+ fileUrl: page.routePaths.urlPath,
5723
+ frontmatter: page.frontmatter
5724
+ })),
5725
+ permalinks: input.permalinks,
5726
+ cascade: input.cascade
5727
+ });
5728
+ const bySource = new Map(resolved.pages.map((page) => [page.source, page]));
5729
+ const pages = [];
5730
+ for (const page of input.pages) {
5731
+ const hit = bySource.get(page.inputPath);
5732
+ if (!hit) continue;
5733
+ pages.push({
4905
5734
  ...page,
4906
5735
  frontmatter: hit.frontmatter,
4907
5736
  routePaths: routePathsFromUrl(hit.urlPath, input.srcDir, input.outDir, input.base, input.extension, input.siteUrl)
@@ -5110,7 +5939,7 @@ async function writeRedirectFiles(input) {
5110
5939
  }
5111
5940
  /** Static HTML redirect body. `dest` is escaped. */
5112
5941
  function generateRedirectHtml(dest) {
5113
- const escaped = escapeHtml$3(dest);
5942
+ const escaped = escapeHtml$2(dest);
5114
5943
  return `\
5115
5944
  <!DOCTYPE html>
5116
5945
  <html lang="en">
@@ -5195,7 +6024,7 @@ function upsert(files, index, occupied, from, to, base) {
5195
6024
  html
5196
6025
  });
5197
6026
  }
5198
- function escapeHtml$3(value) {
6027
+ function escapeHtml$2(value) {
5199
6028
  return value.replace(/[&<>"']/g, (ch) => {
5200
6029
  switch (ch) {
5201
6030
  case "&": return "&amp;";
@@ -5553,6 +6382,7 @@ function createNativeTransformOptions(options) {
5553
6382
  return {
5554
6383
  gfm: options.gfm,
5555
6384
  footnotes: options.footnotes,
6385
+ semanticFootnotes: options.semanticFootnotes ?? false,
5556
6386
  taskLists: options.taskLists,
5557
6387
  tables: options.tables,
5558
6388
  strikethrough: options.strikethrough,
@@ -5560,6 +6390,7 @@ function createNativeTransformOptions(options) {
5560
6390
  autolinkUrls: options.autolinks,
5561
6391
  frontmatter: options.frontmatter,
5562
6392
  tocMaxDepth: options.tocMaxDepth,
6393
+ headingPermalinks: options.headingPermalinks?.enabled ?? false,
5563
6394
  codeAnnotations: options.codeAnnotations?.enabled ?? false,
5564
6395
  codeAnnotationMetaKey: options.codeAnnotations?.metaKey ?? "annotate",
5565
6396
  codeAnnotationSyntax: options.codeAnnotations?.notation ?? "attribute",
@@ -5574,6 +6405,13 @@ function createNativeTransformOptions(options) {
5574
6405
  } : void 0,
5575
6406
  attributes: options.attrs?.enabled ? { enabled: true } : void 0,
5576
6407
  badges: options.badges?.enabled ? { enabled: true } : void 0,
6408
+ magicLinks: options.magicLinks?.enabled ? {
6409
+ enabled: true,
6410
+ aliases: options.magicLinks.aliases,
6411
+ favicon: options.magicLinks.favicon,
6412
+ faviconTemplate: options.magicLinks.faviconTemplate,
6413
+ imageOverrides: options.magicLinks.imageOverrides
6414
+ } : void 0,
5577
6415
  containers: options.containers?.enabled ? {
5578
6416
  enabled: true,
5579
6417
  types: options.containers.types
@@ -5998,6 +6836,7 @@ function isExcludedFromFeed(item, publishState) {
5998
6836
  const frontmatter = item.frontmatter ?? {};
5999
6837
  if (item.draft === true || frontmatter.draft === true) return true;
6000
6838
  if (item.unlisted === true || frontmatter.unlisted === true) return true;
6839
+ if (frontmatter.external === true) return true;
6001
6840
  if (!publishState?.enabled) return false;
6002
6841
  return !classifyPublishState({
6003
6842
  ...frontmatter,
@@ -6238,14 +7077,14 @@ function relatedMarkup(pages) {
6238
7077
  }
6239
7078
  function listPageContent(terms, base, urlName) {
6240
7079
  const items = terms.map((term) => listItem$1(siteHref$3(base, urlName, term.slug), term.label)).join("");
6241
- return `<h1>${escapeHtml$2(displayTaxonomyName(urlName))}</h1><ul class="ox-taxonomy">${items}</ul>`;
7080
+ return `<h1>${escapeHtml$1(displayTaxonomyName(urlName))}</h1><ul class="ox-taxonomy">${items}</ul>`;
6242
7081
  }
6243
7082
  function termPageContent(term) {
6244
7083
  const items = [...term.pages].sort((left, right) => {
6245
7084
  const titleCmp = left.title.localeCompare(right.title);
6246
7085
  return titleCmp !== 0 ? titleCmp : left.routePaths.href.localeCompare(right.routePaths.href);
6247
7086
  }).map((page) => listItem$1(page.routePaths.href, page.title)).join("");
6248
- return `<h1>${escapeHtml$2(term.label)}</h1><ul class="ox-taxonomy-term">${items}</ul>`;
7087
+ return `<h1>${escapeHtml$1(term.label)}</h1><ul class="ox-taxonomy-term">${items}</ul>`;
6249
7088
  }
6250
7089
  function displayTaxonomyName(name) {
6251
7090
  return name.charAt(0).toUpperCase() + name.slice(1);
@@ -6263,9 +7102,9 @@ function containedPath$2(outDir, ...segments) {
6263
7102
  return resolved;
6264
7103
  }
6265
7104
  function listItem$1(href, label) {
6266
- return `<li><a href="${escapeHtml$2(href)}">${escapeHtml$2(label)}</a></li>`;
7105
+ return `<li><a href="${escapeHtml$1(href)}">${escapeHtml$1(label)}</a></li>`;
6267
7106
  }
6268
- function escapeHtml$2(value) {
7107
+ function escapeHtml$1(value) {
6269
7108
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
6270
7109
  }
6271
7110
  //#endregion
@@ -6539,18 +7378,21 @@ function resolveBlogOptions(value) {
6539
7378
  if (!value) return {
6540
7379
  enabled: false,
6541
7380
  authors: {},
6542
- pageSize: DEFAULT_PAGE_SIZE
7381
+ pageSize: DEFAULT_PAGE_SIZE,
7382
+ feeds: []
6543
7383
  };
6544
7384
  if (value === true) return {
6545
7385
  enabled: true,
6546
7386
  authors: {},
6547
- pageSize: DEFAULT_PAGE_SIZE
7387
+ pageSize: DEFAULT_PAGE_SIZE,
7388
+ feeds: []
6548
7389
  };
6549
7390
  return {
6550
7391
  enabled: true,
6551
7392
  collection: value.collection,
6552
7393
  authors: normalizeAuthors(value.authors),
6553
- pageSize: normalizePageSize(value.pageSize)
7394
+ pageSize: normalizePageSize(value.pageSize),
7395
+ feeds: normalizeFeeds(value.feeds)
6554
7396
  };
6555
7397
  }
6556
7398
  /**
@@ -6581,54 +7423,396 @@ function normalizePageSize(value) {
6581
7423
  if (typeof value === "number" && Number.isFinite(value) && value >= 1) return Math.floor(value);
6582
7424
  return DEFAULT_PAGE_SIZE;
6583
7425
  }
7426
+ function normalizeFeeds(feeds) {
7427
+ if (!Array.isArray(feeds)) return [];
7428
+ const resolved = [];
7429
+ for (const entry of feeds) {
7430
+ if (typeof entry === "string") {
7431
+ const url = entry.trim();
7432
+ if (url) resolved.push({
7433
+ url,
7434
+ onError: "warn"
7435
+ });
7436
+ continue;
7437
+ }
7438
+ if (!entry || typeof entry !== "object" || typeof entry.url !== "string") continue;
7439
+ const url = entry.url.trim();
7440
+ if (!url) continue;
7441
+ const language = trimOptional(entry.language);
7442
+ const author = trimOptional(entry.author);
7443
+ resolved.push({
7444
+ url,
7445
+ ...language ? { language } : {},
7446
+ ...author ? { author } : {},
7447
+ onError: entry.onError === "error" ? "error" : "warn"
7448
+ });
7449
+ }
7450
+ return resolved;
7451
+ }
7452
+ function trimOptional(value) {
7453
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
7454
+ }
6584
7455
  //#endregion
6585
- //#region src/blog-reading.ts
7456
+ //#region src/blog-feed-date.ts
7457
+ /**
7458
+ * Publication dates from RSS / Atom items.
7459
+ */
7460
+ const MONTHS = {
7461
+ jan: 1,
7462
+ feb: 2,
7463
+ mar: 3,
7464
+ apr: 4,
7465
+ may: 5,
7466
+ jun: 6,
7467
+ jul: 7,
7468
+ aug: 8,
7469
+ sep: 9,
7470
+ oct: 10,
7471
+ nov: 11,
7472
+ dec: 12
7473
+ };
7474
+ const RFC822 = /^(?:[A-Za-z]{3},\s+)?(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s+(?:GMT|UTC|UT|([+-]\d{4}))$/;
7475
+ function parseFeedDate(value) {
7476
+ const trimmed = value?.trim();
7477
+ if (!trimmed) return;
7478
+ return parseDate(trimmed) ?? parseRfc822(trimmed);
7479
+ }
7480
+ function feedDateLabel(date) {
7481
+ return `${String(date.year).padStart(4, "0")}-${String(date.month).padStart(2, "0")}-${String(date.day).padStart(2, "0")}`;
7482
+ }
7483
+ function feedDateIso(date) {
7484
+ return `${feedDateLabel(date)}T${String(date.hour).padStart(2, "0")}:${String(date.minute).padStart(2, "0")}:${String(date.second).padStart(2, "0")}Z`;
7485
+ }
7486
+ function parseRfc822(value) {
7487
+ const match = value.match(RFC822);
7488
+ if (!match) return;
7489
+ const month = MONTHS[match[2]?.toLowerCase() ?? ""];
7490
+ if (!month) return;
7491
+ const day = match[1]?.padStart(2, "0");
7492
+ const year = match[3];
7493
+ const hour = match[4];
7494
+ const minute = match[5];
7495
+ const second = (match[6] ?? "00").padStart(2, "0");
7496
+ const zone = match[7];
7497
+ const tz = zone ? `${zone.slice(0, 3)}:${zone.slice(3)}` : "Z";
7498
+ return parseDate(`${year}-${String(month).padStart(2, "0")}-${day}T${hour}:${minute}:${second}${tz}`);
7499
+ }
7500
+ //#endregion
7501
+ //#region src/blog-feed-url.ts
6586
7502
  /**
6587
- * Deterministic blog reading-time estimates.
7503
+ * Safe-URL checks for configured external blog feeds.
6588
7504
  */
6589
- const LATIN_WORDS_PER_MINUTE = 200;
6590
- const CJK_CHARS_PER_MINUTE = 500;
6591
- function readingTimeMinutes(markdown) {
6592
- const body = stripInlineCode(stripFences(stripFrontmatter(markdown)));
6593
- let latin = 0;
6594
- let cjk = 0;
6595
- let latinRun = false;
6596
- for (const char of body) {
6597
- const code = char.codePointAt(0) ?? 0;
6598
- if (isCjkCodePoint(code)) {
6599
- cjk += 1;
6600
- latinRun = false;
6601
- continue;
6602
- }
6603
- if (isLatinWordChar(code)) {
6604
- if (!latinRun) {
6605
- latin += 1;
6606
- latinRun = true;
7505
+ const CONTROL_CHARS = /[\n\r\t\0]/;
7506
+ function isSafeFeedUrl(value) {
7507
+ const trimmed = value.trim();
7508
+ if (!trimmed || CONTROL_CHARS.test(trimmed)) return false;
7509
+ try {
7510
+ const url = new URL(trimmed);
7511
+ if (url.protocol !== "https:") return false;
7512
+ if (url.username || url.password) return false;
7513
+ return !isBlockedFeedHost(url.hostname);
7514
+ } catch {
7515
+ return false;
7516
+ }
7517
+ }
7518
+ function canonicalizeFeedItemUrl(value) {
7519
+ if (!isSafeFeedUrl(value)) return;
7520
+ const url = new URL(value.trim());
7521
+ url.hash = "";
7522
+ url.username = "";
7523
+ url.password = "";
7524
+ if (url.port === "443") url.port = "";
7525
+ url.hostname = url.hostname.toLowerCase();
7526
+ let href = url.href;
7527
+ if (url.pathname !== "/" && href.endsWith("/")) href = href.slice(0, -1);
7528
+ return href;
7529
+ }
7530
+ function isBlockedFeedHost(hostname) {
7531
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
7532
+ if (!host || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
7533
+ if (host.includes(":")) return isBlockedIPv6(host);
7534
+ return isIPv4(host) ? isBlockedIPv4(host) : false;
7535
+ }
7536
+ function isBlockedFeedAddress(address) {
7537
+ const value = address.toLowerCase().replace(/^\[|\]$/g, "");
7538
+ if (value.includes(":")) return isBlockedIPv6(value);
7539
+ return isIPv4(value) ? isBlockedIPv4(value) : true;
7540
+ }
7541
+ function isIPv4(value) {
7542
+ const parts = value.split(".");
7543
+ if (parts.length !== 4) return false;
7544
+ return parts.every((part) => {
7545
+ const n = Number(part);
7546
+ return Number.isInteger(n) && n >= 0 && n <= 255 && String(n) === part;
7547
+ });
7548
+ }
7549
+ function isBlockedIPv4(ip) {
7550
+ const [a, b] = ip.split(".").map(Number);
7551
+ return a === 0 || a === 10 || a === 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
7552
+ }
7553
+ function isBlockedIPv6(ip) {
7554
+ if (ip === "::" || ip === "::1") return true;
7555
+ if (ip.startsWith("::ffff:")) {
7556
+ const mapped = ip.slice(7);
7557
+ return isIPv4(mapped) ? isBlockedIPv4(mapped) : true;
7558
+ }
7559
+ const first = Number.parseInt(ip.split(":")[0] ?? "", 16);
7560
+ if (!Number.isFinite(first)) return true;
7561
+ if (first >= 65152 && first <= 65215) return true;
7562
+ return (first & 65024) === 64512;
7563
+ }
7564
+ let installedNetwork = {};
7565
+ async function fetchBlogFeedBody(url, network = {}) {
7566
+ const timeoutMs = network.limits?.timeoutMs ?? installedNetwork.limits?.timeoutMs ?? 1e4;
7567
+ const maxBytes = network.limits?.maxBytes ?? installedNetwork.limits?.maxBytes ?? 1048576;
7568
+ const maxRedirects = network.limits?.maxRedirects ?? installedNetwork.limits?.maxRedirects ?? 5;
7569
+ const fetchFn = network.fetch ?? installedNetwork.fetch ?? defaultFetch;
7570
+ const lookup = network.lookup ?? installedNetwork.lookup ?? defaultLookup;
7571
+ const controller = new AbortController();
7572
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
7573
+ try {
7574
+ return await followFeed(url, fetchFn, lookup, controller.signal, maxBytes, maxRedirects);
7575
+ } catch (error) {
7576
+ if (isAbortError(error)) throw new Error("timeout");
7577
+ throw error instanceof Error ? error : new Error(String(error));
7578
+ } finally {
7579
+ clearTimeout(timer);
7580
+ }
7581
+ }
7582
+ async function followFeed(startUrl, fetchFn, lookup, signal, maxBytes, maxRedirects) {
7583
+ const seen = /* @__PURE__ */ new Set();
7584
+ let current = startUrl;
7585
+ for (let hops = 0; hops <= maxRedirects; hops += 1) {
7586
+ await assertSafeFeedTarget(current, lookup);
7587
+ if (seen.has(current)) throw new Error("too many redirects");
7588
+ seen.add(current);
7589
+ const response = await fetchFn(current, {
7590
+ method: "GET",
7591
+ redirect: "manual",
7592
+ signal,
7593
+ headers: {
7594
+ Accept: "application/rss+xml, application/atom+xml, application/xml, text/xml;q=0.9",
7595
+ "User-Agent": "ox-content-blog-feeds/1.0"
6607
7596
  }
7597
+ });
7598
+ if (isRedirect(response.status)) {
7599
+ current = resolveRedirect(current, response.headers.get("location"));
6608
7600
  continue;
6609
7601
  }
6610
- if (char === "'" || char === "’") continue;
6611
- latinRun = false;
7602
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
7603
+ assertFeedContentType(response.headers.get("content-type"));
7604
+ return readBoundedBody(response, maxBytes);
6612
7605
  }
6613
- if (latin === 0 && cjk === 0) return 0;
6614
- return Math.max(1, Math.ceil(latin / LATIN_WORDS_PER_MINUTE + cjk / CJK_CHARS_PER_MINUTE));
7606
+ throw new Error("too many redirects");
6615
7607
  }
6616
- function stripFrontmatter(markdown) {
6617
- if (!markdown.startsWith("---")) return markdown;
6618
- const match = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
6619
- return match ? markdown.slice(match[0].length) : markdown;
7608
+ async function assertSafeFeedTarget(url, lookup) {
7609
+ if (!isSafeFeedUrl(url)) throw new Error("unsafe URL");
7610
+ const hostname = new URL(url).hostname;
7611
+ const addresses = await lookup(hostname);
7612
+ if (addresses.length === 0 || addresses.some((address) => isBlockedFeedAddress(address))) throw new Error("private network");
6620
7613
  }
6621
- function stripFences(text) {
6622
- return text.replace(/```[\s\S]*?(?:```|$)/g, " ");
7614
+ async function defaultLookup(hostname) {
7615
+ return (await (0, node_dns_promises.lookup)(hostname, {
7616
+ all: true,
7617
+ verbatim: true
7618
+ })).map((record) => record.address);
6623
7619
  }
6624
- function stripInlineCode(text) {
6625
- return text.replace(/`[^`\n]*`/g, " ");
7620
+ function defaultFetch(input, init) {
7621
+ return fetch(input, init);
6626
7622
  }
6627
- function isCjkCodePoint(code) {
6628
- return code >= 12352 && code <= 12543 || code >= 12784 && code <= 12799 || code >= 13312 && code <= 19903 || code >= 19968 && code <= 40959 || code >= 63744 && code <= 64255 || code >= 44032 && code <= 55215 || code >= 4352 && code <= 4607;
7623
+ function isRedirect(status) {
7624
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
6629
7625
  }
6630
- function isLatinWordChar(code) {
6631
- return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122;
7626
+ function resolveRedirect(current, location) {
7627
+ if (!location?.trim()) throw new Error("too many redirects");
7628
+ try {
7629
+ return new URL(location, current).href;
7630
+ } catch {
7631
+ throw new Error("unsafe URL");
7632
+ }
7633
+ }
7634
+ function assertFeedContentType(value) {
7635
+ if (!value) return;
7636
+ const type = value.split(";")[0]?.trim().toLowerCase() ?? "";
7637
+ if (type === "text/html" || type === "application/xhtml+xml" || type === "application/json") throw new Error("not a feed");
7638
+ }
7639
+ async function readBoundedBody(response, maxBytes) {
7640
+ const length = Number(response.headers.get("content-length"));
7641
+ if (Number.isFinite(length) && length > maxBytes) throw new Error("oversized");
7642
+ const reader = response.body?.getReader();
7643
+ if (!reader) {
7644
+ const text = await response.text();
7645
+ if (new TextEncoder().encode(text).byteLength > maxBytes) throw new Error("oversized");
7646
+ return text;
7647
+ }
7648
+ const chunks = [];
7649
+ let total = 0;
7650
+ while (true) {
7651
+ const { done, value } = await reader.read();
7652
+ if (done) break;
7653
+ if (!value) continue;
7654
+ total += value.byteLength;
7655
+ if (total > maxBytes) {
7656
+ await reader.cancel();
7657
+ throw new Error("oversized");
7658
+ }
7659
+ chunks.push(value);
7660
+ }
7661
+ return new TextDecoder("utf-8").decode(concatBytes(chunks, total));
7662
+ }
7663
+ function concatBytes(chunks, total) {
7664
+ const out = new Uint8Array(total);
7665
+ let offset = 0;
7666
+ for (const chunk of chunks) {
7667
+ out.set(chunk, offset);
7668
+ offset += chunk.byteLength;
7669
+ }
7670
+ return out;
7671
+ }
7672
+ function isAbortError(error) {
7673
+ return error instanceof Error && (error.name === "AbortError" || error.message === "timeout");
7674
+ }
7675
+ //#endregion
7676
+ //#region src/blog-feed-parse.ts
7677
+ /**
7678
+ * RSS 2.0 / Atom 1.0 item extraction. HTML documents are rejected.
7679
+ */
7680
+ const ITEM_BLOCK = /<(?:[\w.-]+:)?item\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?item>/gi;
7681
+ const ENTRY_BLOCK = /<(?:[\w.-]+:)?entry\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?entry>/gi;
7682
+ function parseBlogFeed(body, feedLanguage) {
7683
+ const xml = stripBom(body);
7684
+ if (looksLikeHtml(xml)) throw new Error("not a feed");
7685
+ if (!looksLikeXmlFeed(xml)) throw new Error("malformed XML");
7686
+ const channelLanguage = textChild(xml, ["language", "dc:language"]) ?? xmlLang(xml) ?? feedLanguage;
7687
+ const items = collectBlocks(xml, ITEM_BLOCK).map((block) => normalizeRssItem(block, channelLanguage));
7688
+ if (items.length > 0 || /<(?:[\w.-]+:)?rss\b/i.test(xml)) return items.filter((item) => item != null);
7689
+ return collectBlocks(xml, ENTRY_BLOCK).map((block) => normalizeAtomEntry(block, channelLanguage)).filter((item) => item != null);
7690
+ }
7691
+ function normalizeRssItem(block, fallbackLanguage) {
7692
+ const title = textChild(block, ["title"]);
7693
+ const guid = guidChild(block);
7694
+ const link = canonicalizeFeedItemUrl(textChild(block, ["link"]) ?? "") ?? (guid?.permalink ? canonicalizeFeedItemUrl(guid.value) : void 0);
7695
+ if (!title || !link) return;
7696
+ return {
7697
+ title,
7698
+ link,
7699
+ id: guid?.value || link,
7700
+ date: parseFeedDate(textChild(block, [
7701
+ "pubDate",
7702
+ "dc:date",
7703
+ "published",
7704
+ "updated"
7705
+ ])),
7706
+ language: textChild(block, ["language", "dc:language"]) ?? xmlLang(block) ?? fallbackLanguage,
7707
+ summary: textChild(block, [
7708
+ "description",
7709
+ "summary",
7710
+ "content:encoded",
7711
+ "content"
7712
+ ])
7713
+ };
7714
+ }
7715
+ function normalizeAtomEntry(block, fallbackLanguage) {
7716
+ const title = textChild(block, ["title"]);
7717
+ const link = canonicalizeFeedItemUrl(atomLink(block) ?? "");
7718
+ if (!title || !link) return;
7719
+ return {
7720
+ title,
7721
+ link,
7722
+ id: textChild(block, ["id"]) || link,
7723
+ date: parseFeedDate(textChild(block, [
7724
+ "published",
7725
+ "updated",
7726
+ "dc:date"
7727
+ ])),
7728
+ language: xmlLang(block) ?? textChild(block, ["language", "dc:language"]) ?? fallbackLanguage,
7729
+ summary: textChild(block, [
7730
+ "summary",
7731
+ "content",
7732
+ "description"
7733
+ ])
7734
+ };
7735
+ }
7736
+ function collectBlocks(xml, pattern) {
7737
+ return [...xml.matchAll(pattern)].flatMap((match) => match[1] ? [match[1]] : []);
7738
+ }
7739
+ function textChild(block, names) {
7740
+ for (const name of names) {
7741
+ const pattern = new RegExp(`<(?:[\\w.-]+:)?${escapeRegExp$1(localName(name))}(?:\\s[^>]*)?>([\\s\\S]*?)</(?:[\\w.-]+:)?${escapeRegExp$1(localName(name))}>`, "i");
7742
+ const match = block.match(pattern);
7743
+ if (match?.[1] != null) {
7744
+ const text = decodeXmlText(match[1]);
7745
+ if (text) return text;
7746
+ }
7747
+ }
7748
+ }
7749
+ function guidChild(block) {
7750
+ const match = block.match(/<(?:[\w.-]+:)?guid\b([^>]*)>([\s\S]*?)<\/(?:[\w.-]+:)?guid>/i);
7751
+ if (!match?.[2]) return;
7752
+ const value = decodeXmlText(match[2]);
7753
+ if (!value) return;
7754
+ const attrs = match[1] ?? "";
7755
+ return {
7756
+ value,
7757
+ permalink: !/isPermaLink\s*=\s*(['"]?)false\1/i.test(attrs)
7758
+ };
7759
+ }
7760
+ function atomLink(block) {
7761
+ const links = [];
7762
+ for (const match of block.matchAll(/<(?:[\w.-]+:)?link\b([^>]*)\/?>/gi)) {
7763
+ const href = attrValue(match[1] ?? "", "href");
7764
+ if (!href) continue;
7765
+ links.push({
7766
+ href,
7767
+ rel: (attrValue(match[1] ?? "", "rel") ?? "alternate").toLowerCase()
7768
+ });
7769
+ }
7770
+ return links.find((link) => link.rel === "alternate")?.href ?? links[0]?.href;
7771
+ }
7772
+ function xmlLang(block) {
7773
+ return block.match(/\bxml:lang\s*=\s*(['"])([^'"]+)\1/i)?.[2]?.trim() || void 0;
7774
+ }
7775
+ function attrValue(attrs, name) {
7776
+ return attrs.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, "i"))?.[2]?.trim() || void 0;
7777
+ }
7778
+ function decodeXmlText(value) {
7779
+ return decodeEntities(value.replace(/<!\[CDATA\[([\s\S]*?)]]>/g, "$1").replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
7780
+ }
7781
+ function decodeEntities(value) {
7782
+ return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (entity, name) => {
7783
+ const lower = name.toLowerCase();
7784
+ if (lower === "amp") return "&";
7785
+ if (lower === "lt") return "<";
7786
+ if (lower === "gt") return ">";
7787
+ if (lower === "quot") return "\"";
7788
+ if (lower === "apos") return "'";
7789
+ if (lower.startsWith("#x")) {
7790
+ const code = Number.parseInt(lower.slice(2), 16);
7791
+ return Number.isFinite(code) ? String.fromCodePoint(code) : entity;
7792
+ }
7793
+ if (lower.startsWith("#")) {
7794
+ const code = Number.parseInt(lower.slice(1), 10);
7795
+ return Number.isFinite(code) ? String.fromCodePoint(code) : entity;
7796
+ }
7797
+ return entity;
7798
+ });
7799
+ }
7800
+ function looksLikeHtml(body) {
7801
+ const start = body.trim().slice(0, 256).toLowerCase();
7802
+ return start.startsWith("<!doctype html") || start.startsWith("<html");
7803
+ }
7804
+ function looksLikeXmlFeed(body) {
7805
+ return /<(?:[\w.-]+:)?(?:rss|feed|rdf:RDF|item|entry)\b/i.test(body);
7806
+ }
7807
+ function stripBom(value) {
7808
+ return value.charCodeAt(0) === 65279 ? value.slice(1) : value;
7809
+ }
7810
+ function localName(name) {
7811
+ const index = name.indexOf(":");
7812
+ return index === -1 ? name : name.slice(index + 1);
7813
+ }
7814
+ function escapeRegExp$1(value) {
7815
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6632
7816
  }
6633
7817
  //#endregion
6634
7818
  //#region src/blog-html.ts
@@ -6644,13 +7828,13 @@ function isSafeBlogUrl(value) {
6644
7828
  return trimmed.toLowerCase().startsWith("https:");
6645
7829
  }
6646
7830
  function postMetaMarkup(meta) {
6647
- const parts = [`<p class="ox-blog-meta__reading-time">${escapeHtml$1(String(meta.minutes))} min read</p>`];
7831
+ const parts = [`<p class="ox-blog-meta__reading-time">${escapeHtml(String(meta.minutes))} min read</p>`];
6648
7832
  if (meta.authors.length > 0) {
6649
7833
  const items = meta.authors.map((author) => authorMarkup(author)).join("");
6650
7834
  parts.push(`<ul class="ox-blog-meta__authors">${items}</ul>`);
6651
7835
  }
6652
7836
  if (meta.tags.length > 0) {
6653
- const items = meta.tags.map((tag) => `<li><a href="${escapeHtml$1(tag.href)}">${escapeHtml$1(tag.label)}</a></li>`).join("");
7837
+ const items = meta.tags.map((tag) => `<li><a href="${escapeHtml(tag.href)}">${escapeHtml(tag.label)}</a></li>`).join("");
6654
7838
  parts.push(`<ul class="ox-blog-meta__tags">${items}</ul>`);
6655
7839
  }
6656
7840
  return `<aside class="ox-blog-meta">${parts.join("")}</aside>\n`;
@@ -6658,25 +7842,25 @@ function postMetaMarkup(meta) {
6658
7842
  function indexPageContent(items, pager) {
6659
7843
  const list = items.map((item) => listItem(item)).join("");
6660
7844
  const links = [];
6661
- if (pager.newerHref) links.push(`<a href="${escapeHtml$1(pager.newerHref)}" rel="prev">Newer</a>`);
6662
- if (pager.olderHref) links.push(`<a href="${escapeHtml$1(pager.olderHref)}" rel="next">Older</a>`);
7845
+ if (pager.newerHref) links.push(`<a href="${escapeHtml(pager.newerHref)}" rel="prev">Newer</a>`);
7846
+ if (pager.olderHref) links.push(`<a href="${escapeHtml(pager.olderHref)}" rel="next">Older</a>`);
6663
7847
  return `<h1>Blog</h1><ul class="ox-blog">${list}</ul>${links.length > 0 ? `<nav class="ox-blog-pager">${links.join("")}</nav>` : ""}`;
6664
7848
  }
6665
7849
  function tagPageContent(label, items) {
6666
7850
  const list = items.map((item) => listItem(item)).join("");
6667
- return `<h1>${escapeHtml$1(label)}</h1><ul class="ox-blog-tag">${list}</ul>`;
7851
+ return `<h1>${escapeHtml(label)}</h1><ul class="ox-blog-tag">${list}</ul>`;
6668
7852
  }
6669
7853
  function archiveIndexContent(years) {
6670
- return `<h1>Archive</h1><ul class="ox-blog-archive">${years.map((entry) => `<li><a href="${escapeHtml$1(entry.href)}">${escapeHtml$1(entry.year)}</a></li>`).join("")}</ul>`;
7854
+ return `<h1>Archive</h1><ul class="ox-blog-archive">${years.map((entry) => `<li><a href="${escapeHtml(entry.href)}">${escapeHtml(entry.year)}</a></li>`).join("")}</ul>`;
6671
7855
  }
6672
7856
  function archiveYearContent(year, months, items) {
6673
- const monthList = months.map((entry) => `<li><a href="${escapeHtml$1(entry.href)}">${escapeHtml$1(entry.month)}</a></li>`).join("");
7857
+ const monthList = months.map((entry) => `<li><a href="${escapeHtml(entry.href)}">${escapeHtml(entry.month)}</a></li>`).join("");
6674
7858
  const posts = items.map((item) => listItem(item)).join("");
6675
- return `<h1>${escapeHtml$1(year)}</h1><ul class="ox-blog-archive-months">${monthList}</ul><ul class="ox-blog">${posts}</ul>`;
7859
+ return `<h1>${escapeHtml(year)}</h1><ul class="ox-blog-archive-months">${monthList}</ul><ul class="ox-blog">${posts}</ul>`;
6676
7860
  }
6677
7861
  function archiveMonthContent(label, items) {
6678
7862
  const list = items.map((item) => listItem(item)).join("");
6679
- return `<h1>${escapeHtml$1(label)}</h1><ul class="ox-blog">${list}</ul>`;
7863
+ return `<h1>${escapeHtml(label)}</h1><ul class="ox-blog">${list}</ul>`;
6680
7864
  }
6681
7865
  function siteHref$2(base, ...segments) {
6682
7866
  const prefix = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
@@ -6690,17 +7874,18 @@ function containedPath$1(outDir, ...segments) {
6690
7874
  if (resolved === root || !resolved.startsWith(prefix)) return;
6691
7875
  return resolved;
6692
7876
  }
6693
- function escapeHtml$1(value) {
7877
+ function escapeHtml(value) {
6694
7878
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
6695
7879
  }
6696
7880
  function authorMarkup(author) {
6697
- const name = escapeHtml$1(author.name);
7881
+ const name = escapeHtml(author.name);
6698
7882
  const url = author.url?.trim();
6699
- return `<li>${url && isSafeBlogUrl(url) ? `<a class="ox-blog-meta__name" href="${escapeHtml$1(url)}">${name}</a>` : `<span class="ox-blog-meta__name">${name}</span>`}${author.bio && author.bio.length > 0 ? `<p class="ox-blog-meta__bio">${escapeHtml$1(author.bio)}</p>` : ""}</li>`;
7883
+ return `<li>${url && isSafeBlogUrl(url) ? `<a class="ox-blog-meta__name" href="${escapeHtml(url)}">${name}</a>` : `<span class="ox-blog-meta__name">${name}</span>`}${author.bio && author.bio.length > 0 ? `<p class="ox-blog-meta__bio">${escapeHtml(author.bio)}</p>` : ""}</li>`;
6700
7884
  }
6701
7885
  function listItem(item) {
6702
- const time = item.dateLabel ? ` <time datetime="${escapeHtml$1(item.dateLabel)}">${escapeHtml$1(item.dateLabel)}</time>` : "";
6703
- return `<li><a href="${escapeHtml$1(item.href)}">${escapeHtml$1(item.title)}</a>${time}</li>`;
7886
+ const time = item.dateLabel ? ` <time datetime="${escapeHtml(item.dateLabel)}">${escapeHtml(item.dateLabel)}</time>` : "";
7887
+ if (item.external) return `<li class="ox-blog-external" data-ox-blog-external="true"><a href="${escapeHtml(item.href)}" rel="external noopener noreferrer">${escapeHtml(item.title)}</a>${time}</li>`;
7888
+ return `<li><a href="${escapeHtml(item.href)}">${escapeHtml(item.title)}</a>${time}</li>`;
6704
7889
  }
6705
7890
  //#endregion
6706
7891
  //#region src/blog-posts.ts
@@ -6806,7 +7991,8 @@ function toListItem(page) {
6806
7991
  return {
6807
7992
  title: page.title,
6808
7993
  href: page.routePaths.href,
6809
- dateLabel: parsed ? `${String(parsed.year).padStart(4, "0")}-${String(parsed.month).padStart(2, "0")}-${String(parsed.day).padStart(2, "0")}` : void 0
7994
+ dateLabel: parsed ? `${String(parsed.year).padStart(4, "0")}-${String(parsed.month).padStart(2, "0")}-${String(parsed.day).padStart(2, "0")}` : void 0,
7995
+ ...page.external || page.frontmatter.external === true ? { external: true } : {}
6810
7996
  };
6811
7997
  }
6812
7998
  function resolvePostAuthors(frontmatter, map) {
@@ -6866,6 +8052,158 @@ function dateField(value) {
6866
8052
  if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
6867
8053
  }
6868
8054
  //#endregion
8055
+ //#region src/blog-feeds.ts
8056
+ var BlogFeedError = class extends Error {
8057
+ issues;
8058
+ constructor(issues) {
8059
+ super(issues.join("\n"));
8060
+ this.name = "BlogFeedError";
8061
+ this.issues = issues;
8062
+ }
8063
+ };
8064
+ async function loadExternalBlogPosts(sources, network = {}) {
8065
+ const pages = [];
8066
+ const warnings = [];
8067
+ const fatals = [];
8068
+ if (sources.length === 0) return {
8069
+ pages,
8070
+ warnings,
8071
+ fatals
8072
+ };
8073
+ const bodies = /* @__PURE__ */ new Map();
8074
+ const results = await Promise.all(sources.map(async (source) => {
8075
+ try {
8076
+ return {
8077
+ source,
8078
+ pages: parseBlogFeed(await cachedBody(source.url, bodies, network), source.language).map((item) => toExternalPage(item, source)).filter((page) => page != null)
8079
+ };
8080
+ } catch (error) {
8081
+ const detail = error instanceof Error ? error.message : String(error);
8082
+ return {
8083
+ source,
8084
+ message: `[ox-content] blog feed ${source.url}: ${detail}`
8085
+ };
8086
+ }
8087
+ }));
8088
+ for (const result of results) {
8089
+ if ("pages" in result) {
8090
+ pages.push(...result.pages);
8091
+ continue;
8092
+ }
8093
+ if (result.source.onError === "error") fatals.push(result.message);
8094
+ else warnings.push(result.message);
8095
+ }
8096
+ return {
8097
+ pages,
8098
+ warnings,
8099
+ fatals
8100
+ };
8101
+ }
8102
+ function mergeBlogPosts(local, external) {
8103
+ const seenUrls = /* @__PURE__ */ new Set();
8104
+ const seenIds = /* @__PURE__ */ new Set();
8105
+ const merged = [];
8106
+ for (const page of [...local, ...external]) {
8107
+ const keys = identityKeys(page);
8108
+ if (seenUrls.has(keys.url) || seenIds.has(keys.id)) continue;
8109
+ seenUrls.add(keys.url);
8110
+ seenIds.add(keys.id);
8111
+ merged.push(page);
8112
+ }
8113
+ return sortPosts(merged);
8114
+ }
8115
+ function cachedBody(url, cache, network) {
8116
+ const existing = cache.get(url);
8117
+ if (existing) return existing;
8118
+ const pending = fetchBlogFeedBody(url, network);
8119
+ cache.set(url, pending);
8120
+ return pending;
8121
+ }
8122
+ function toExternalPage(item, source) {
8123
+ const link = canonicalizeFeedItemUrl(item.link);
8124
+ if (!link) return;
8125
+ const id = item.id.trim() || link;
8126
+ const language = item.language ?? source.language;
8127
+ const author = source.author;
8128
+ return {
8129
+ title: item.title,
8130
+ inputPath: `external:${id}`,
8131
+ transformedHtml: "",
8132
+ external: true,
8133
+ routePaths: { href: link },
8134
+ frontmatter: {
8135
+ external: true,
8136
+ id,
8137
+ date: item.date ? feedDateIso(item.date) : void 0,
8138
+ language,
8139
+ author,
8140
+ summary: item.summary
8141
+ }
8142
+ };
8143
+ }
8144
+ function identityKeys(page) {
8145
+ const explicitId = stringField(page.frontmatter.id);
8146
+ const canonical = stringField(page.frontmatter.canonical);
8147
+ const href = page.routePaths.href;
8148
+ const url = canonicalizeFeedItemUrl(canonical ?? "") ?? canonicalizeFeedItemUrl(href) ?? href;
8149
+ return {
8150
+ url,
8151
+ id: explicitId || url
8152
+ };
8153
+ }
8154
+ function stringField(value) {
8155
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
8156
+ }
8157
+ //#endregion
8158
+ //#region src/blog-reading.ts
8159
+ /**
8160
+ * Deterministic blog reading-time estimates.
8161
+ */
8162
+ const LATIN_WORDS_PER_MINUTE = 200;
8163
+ const CJK_CHARS_PER_MINUTE = 500;
8164
+ function readingTimeMinutes(markdown) {
8165
+ const body = stripInlineCode(stripFences(stripFrontmatter(markdown)));
8166
+ let latin = 0;
8167
+ let cjk = 0;
8168
+ let latinRun = false;
8169
+ for (const char of body) {
8170
+ const code = char.codePointAt(0) ?? 0;
8171
+ if (isCjkCodePoint(code)) {
8172
+ cjk += 1;
8173
+ latinRun = false;
8174
+ continue;
8175
+ }
8176
+ if (isLatinWordChar(code)) {
8177
+ if (!latinRun) {
8178
+ latin += 1;
8179
+ latinRun = true;
8180
+ }
8181
+ continue;
8182
+ }
8183
+ if (char === "'" || char === "’") continue;
8184
+ latinRun = false;
8185
+ }
8186
+ if (latin === 0 && cjk === 0) return 0;
8187
+ return Math.max(1, Math.ceil(latin / LATIN_WORDS_PER_MINUTE + cjk / CJK_CHARS_PER_MINUTE));
8188
+ }
8189
+ function stripFrontmatter(markdown) {
8190
+ if (!markdown.startsWith("---")) return markdown;
8191
+ const match = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
8192
+ return match ? markdown.slice(match[0].length) : markdown;
8193
+ }
8194
+ function stripFences(text) {
8195
+ return text.replace(/```[\s\S]*?(?:```|$)/g, " ");
8196
+ }
8197
+ function stripInlineCode(text) {
8198
+ return text.replace(/`[^`\n]*`/g, " ");
8199
+ }
8200
+ function isCjkCodePoint(code) {
8201
+ return code >= 12352 && code <= 12543 || code >= 12784 && code <= 12799 || code >= 13312 && code <= 19903 || code >= 19968 && code <= 40959 || code >= 63744 && code <= 64255 || code >= 44032 && code <= 55215 || code >= 4352 && code <= 4607;
8202
+ }
8203
+ function isLatinWordChar(code) {
8204
+ return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122;
8205
+ }
8206
+ //#endregion
6869
8207
  //#region src/blog-pages.ts
6870
8208
  /**
6871
8209
  * Generated blog index, tag, and archive pages.
@@ -6877,7 +8215,7 @@ async function injectBlogPostMeta(input) {
6877
8215
  if (posts === void 0) return;
6878
8216
  const listedPaths = new Set(posts.map((page) => page.inputPath));
6879
8217
  for (const page of input.pages) {
6880
- if (!listedPaths.has(page.inputPath)) continue;
8218
+ if (!listedPaths.has(page.inputPath) || page.external === true) continue;
6881
8219
  const markdown = await readMarkdown(page.inputPath);
6882
8220
  page.transformedHtml = postMetaMarkup({
6883
8221
  authors: resolvePostAuthors(page.frontmatter, input.options.authors),
@@ -6910,8 +8248,9 @@ async function appendBlogPages(input) {
6910
8248
  input.errors.push(AMBIGUOUS_COLLECTION);
6911
8249
  return;
6912
8250
  }
6913
- const posts = selectBlogPosts(input.listedPages, input.options, input.srcDir, input.collections);
6914
- if (posts === void 0) return;
8251
+ const local = selectBlogPosts(input.listedPages, input.options, input.srcDir, input.collections);
8252
+ if (local === void 0) return;
8253
+ const posts = await collectIndexPosts(local, input.options, input.errors, input.feedNetwork);
6915
8254
  for (const spec of blogPageSpecs(posts, input.options, input.outDir, input.base)) try {
6916
8255
  input.generatedPages.push({
6917
8256
  inputPath: spec.outputPath,
@@ -7006,6 +8345,14 @@ function blogPageSpecs(posts, options, outDir, base) {
7006
8345
  }
7007
8346
  return pages;
7008
8347
  }
8348
+ async function collectIndexPosts(local, options, errors, network) {
8349
+ if (options.feeds.length === 0) return local;
8350
+ const loaded = await loadExternalBlogPosts(options.feeds, network);
8351
+ errors.push(...loaded.warnings);
8352
+ for (const warning of loaded.warnings) console.warn(warning);
8353
+ if (loaded.fatals.length > 0) throw new BlogFeedError(loaded.fatals);
8354
+ return mergeBlogPosts(local, loaded.pages);
8355
+ }
7009
8356
  async function readMarkdown(inputPath) {
7010
8357
  try {
7011
8358
  return await node_fs_promises.readFile(inputPath, "utf8");
@@ -7518,59 +8865,6 @@ function generateSearchModule(options, indexPath) {
7518
8865
  return require_vitepress.importNapiModuleSync().generateSearchModuleFromOptions(toLocalSearchRuntimeOptions(options), indexPath);
7519
8866
  }
7520
8867
  //#endregion
7521
- //#region src/versions-html.ts
7522
- function versionSwitcherMarkup(links, badge) {
7523
- if (links.length === 0) return "";
7524
- const current = links.find((link) => link.current) ?? links[0];
7525
- const items = links.map((link) => {
7526
- const label = `${escapeHtml(link.label)}${badgeMarkup(link, badge)}`;
7527
- if (link.current || !isSafeHref(link.href)) return `<li><span aria-current="page">${label}</span></li>`;
7528
- return `<li><a href="${escapeHtml(link.href)}">${label}</a></li>`;
7529
- }).join("");
7530
- return `<nav class="ox-header-select ox-version-switcher" aria-label="Version"><button type="button" aria-expanded="false" aria-haspopup="true">${escapeHtml(current.label)}${badgeMarkup(current, badge)}</button><ul class="ox-header-select-menu">${items}</ul></nav><script>(function(){var n=document.currentScript&&document.currentScript.previousElementSibling;if(!n||!n.classList.contains("ox-version-switcher"))return;var b=n.querySelector("button");if(!b)return;function closeOthers(){document.querySelectorAll(".header-nav-dropdown > button[aria-expanded='true'], .ox-locale-switcher > button[aria-expanded='true']").forEach(function(btn){btn.setAttribute("aria-expanded","false");});}b.addEventListener("click",function(e){e.stopPropagation();var o=b.getAttribute("aria-expanded")==="true";closeOthers();b.setAttribute("aria-expanded",o?"false":"true");});document.addEventListener("click",function(e){if(!n.contains(e.target))b.setAttribute("aria-expanded","false");});document.addEventListener("keydown",function(e){if(e.key==="Escape"){b.setAttribute("aria-expanded","false");b.focus();}});})()<\/script>`;
7531
- }
7532
- function versionBannerMarkup(kind) {
7533
- if (kind === "unreleased") return `<aside class="ox-version-banner ox-version-banner--unreleased" role="status">This documentation describes an unreleased version.</aside>`;
7534
- if (kind === "unmaintained") return `<aside class="ox-version-banner ox-version-banner--unmaintained" role="status">This documentation is unmaintained.</aside>`;
7535
- return "";
7536
- }
7537
- function injectVersionChrome(html, switcher, banner, searchFrom, searchTo) {
7538
- let next = html;
7539
- if (banner) next = next.replace(/<body([^>]*)>/, `<body$1>${banner}`);
7540
- if (switcher) {
7541
- if (next.includes("<div class=\"header-actions\">")) next = next.replace("<div class=\"header-actions\">", `<div class="header-actions">${switcher}`);
7542
- else if (next.includes("</header>")) next = next.replace("</header>", `${switcher}</header>`);
7543
- }
7544
- if (searchTo && isSafeHref(searchTo)) next = next.replace(/<html([^>]*)>/i, (match, attrs) => {
7545
- if (/\sdata-ox-search-index=/.test(attrs)) return match;
7546
- return `<html${attrs} data-ox-search-index="${escapeHtml(searchTo)}">`;
7547
- });
7548
- if (searchFrom && searchTo && searchFrom !== searchTo && isSafeHref(searchTo)) {
7549
- next = next.split(searchFrom).join(searchTo);
7550
- const script = `<script>(function(){var f=${JSON.stringify(searchFrom)},t=${JSON.stringify(searchTo)};var o=window.fetch;window.fetch=function(i,n){if(typeof i==="string"&&i.indexOf(f)!==-1)i=i.split(f).join(t);return o.call(this,i,n);};})()<\/script>`;
7551
- next = next.includes("</body>") ? next.replace("</body>", `${script}</body>`) : `${next}${script}`;
7552
- }
7553
- return next;
7554
- }
7555
- function searchIndexUrl(base, prefix) {
7556
- const root = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
7557
- return prefix ? `${root}${prefix}/search-index.json` : `${root}search-index.json`;
7558
- }
7559
- function isSafeHref(href) {
7560
- const trimmed = href.trim();
7561
- if (!trimmed || trimmed.startsWith("//")) return false;
7562
- const lower = trimmed.replace(/\s+/g, "").toLowerCase();
7563
- if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:")) return false;
7564
- return trimmed.startsWith("/") || trimmed.startsWith("./") || !trimmed.includes(":");
7565
- }
7566
- function escapeHtml(value) {
7567
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
7568
- }
7569
- function badgeMarkup(link, badge) {
7570
- if (!badge || !link.banner) return "";
7571
- return `<span class="ox-version-badge">${link.banner === "unreleased" ? "unreleased" : "unmaintained"}</span>`;
7572
- }
7573
- //#endregion
7574
8868
  //#region src/versions.ts
7575
8869
  /**
7576
8870
  * Opt-in documentation versioning: prefixes, snapshots, and header chrome.
@@ -7701,7 +8995,13 @@ async function writeSnapshotSearchIndex(input) {
7701
8995
  function applyVersionChrome(html, options, activeId, siblingPath, base, existingHrefs) {
7702
8996
  if (!options.enabled) return html;
7703
8997
  const active = options.entries.find((entry) => entry.id === activeId);
7704
- return injectVersionChrome(html, options.switcher ? versionSwitcherMarkup(versionLinks(options, activeId, siblingPath, base, existingHrefs), options.badge) : "", versionBannerMarkup(active?.banner), searchIndexUrl(base, currentVersionPrefix(options)), searchIndexUrl(base, active?.prefix ?? ""));
8998
+ return injectSearchVersionFilters(injectVersionChrome(html, options.switcher ? versionSwitcherMarkup(versionLinks(options, activeId, siblingPath, base, existingHrefs), options.badge) : "", versionBannerMarkup(active?.banner), searchIndexUrl(base, currentVersionPrefix(options)), searchIndexUrl(base, active?.prefix ?? "")), options.entries.map((entry) => ({
8999
+ id: entry.id,
9000
+ label: entry.label,
9001
+ prefix: entry.prefix,
9002
+ indexUrl: searchIndexUrl(base, entry.prefix),
9003
+ current: entry.id === activeId
9004
+ })));
7705
9005
  }
7706
9006
  function sanitizePrefix(prefix) {
7707
9007
  const trimmed = prefix.trim().replace(/^\/+|\/+$/g, "");
@@ -7738,21 +9038,182 @@ function normalizeEntries(entries) {
7738
9038
  banner: normalizeBanner(entry.banner)
7739
9039
  });
7740
9040
  }
7741
- return resolved;
9041
+ return resolved;
9042
+ }
9043
+ function normalizeBanner(value) {
9044
+ return value === "unreleased" || value === "unmaintained" ? value : false;
9045
+ }
9046
+ function siteHref$1(base, prefix, rest) {
9047
+ const root = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
9048
+ const parts = [prefix, rest].filter((part) => part && part !== "/");
9049
+ return parts.length === 0 ? root : `${root}${parts.join("/")}/`;
9050
+ }
9051
+ function relativeUrl(outputPath, outDir) {
9052
+ const rel = node_path.posix.normalize(node_path.relative(node_path.resolve(outDir), node_path.resolve(outputPath)).replaceAll(node_path.sep, "/"));
9053
+ if (rel.startsWith("..")) return "";
9054
+ const dir = rel.endsWith("/index.html") ? rel.slice(0, -11) : rel.replace(/\.html$/, "");
9055
+ return dir === "." ? "" : dir;
9056
+ }
9057
+ //#endregion
9058
+ //#region src/resources-dedupe.ts
9059
+ /**
9060
+ * Site-wide content-addressed emit for identical page-resource bytes.
9061
+ *
9062
+ * Hashing streams the file. The first digest+extension pair writes once;
9063
+ * later pages reuse that path. Image decode is never used here.
9064
+ */
9065
+ const DEDUPE_ASSET_DIR = node_path.join("assets", "content");
9066
+ const TRANSFORM_QUERY_KEYS = /* @__PURE__ */ new Set([
9067
+ "width",
9068
+ "w",
9069
+ "height",
9070
+ "h",
9071
+ "crop",
9072
+ "format"
9073
+ ]);
9074
+ function createResourceDedupeStore() {
9075
+ return {
9076
+ canonical: /* @__PURE__ */ new Map(),
9077
+ hashes: /* @__PURE__ */ new Map()
9078
+ };
9079
+ }
9080
+ function normalizeDedupeExt(ext) {
9081
+ const value = ext.replace(/^\./, "").trim().toLowerCase();
9082
+ if (value === "jpeg") return "jpg";
9083
+ return value || "bin";
9084
+ }
9085
+ function canonicalPublicPath(base, digest, ext) {
9086
+ return `${!base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`}${DEDUPE_ASSET_DIR.split(node_path.sep).join("/")}/${digest}.${ext}`;
9087
+ }
9088
+ function canonicalAbsolutePath(outDir, digest, ext) {
9089
+ return node_path.join(outDir, DEDUPE_ASSET_DIR, `${digest}.${ext}`);
9090
+ }
9091
+ /**
9092
+ * SHA-256 of emitted bytes plus a NUL and the serving extension so the
9093
+ * same payload cannot be served under an incompatible media type.
9094
+ */
9095
+ async function hashResourceFile(filePath, ext, store, reuseKey) {
9096
+ const cached = store.hashes.get(reuseKey);
9097
+ if (cached) return cached;
9098
+ const hash = (0, node_crypto.createHash)("sha256");
9099
+ for await (const chunk of (0, node_fs.createReadStream)(filePath)) hash.update(chunk);
9100
+ hash.update("\0");
9101
+ hash.update(ext);
9102
+ const digest = hash.digest("hex");
9103
+ store.hashes.set(reuseKey, digest);
9104
+ return digest;
9105
+ }
9106
+ async function emitCanonicalResource(store, input) {
9107
+ const key = `${input.digest}\0${input.ext}`;
9108
+ const existing = store.canonical.get(key);
9109
+ const publicPath = canonicalPublicPath(input.base, input.digest, input.ext);
9110
+ if (existing) return {
9111
+ asset: {
9112
+ digest: input.digest,
9113
+ ext: input.ext,
9114
+ absolutePath: existing,
9115
+ publicPath
9116
+ },
9117
+ wrote: false
9118
+ };
9119
+ const absolutePath = canonicalAbsolutePath(input.outDir, input.digest, input.ext);
9120
+ await node_fs_promises.mkdir(node_path.dirname(absolutePath), { recursive: true });
9121
+ await node_fs_promises.copyFile(input.sourcePath, absolutePath);
9122
+ store.canonical.set(key, absolutePath);
9123
+ return {
9124
+ asset: {
9125
+ digest: input.digest,
9126
+ ext: input.ext,
9127
+ absolutePath,
9128
+ publicPath
9129
+ },
9130
+ wrote: true
9131
+ };
9132
+ }
9133
+ /**
9134
+ * Prefer a hard link at the original output path. `link` failure removes
9135
+ * any stale alias and copies so a shared inode is never overwritten.
9136
+ */
9137
+ async function linkOrCopyAlias(canonical, alias, linker = node_fs_promises.link) {
9138
+ await node_fs_promises.mkdir(node_path.dirname(alias), { recursive: true });
9139
+ try {
9140
+ await linker(canonical, alias);
9141
+ return "link";
9142
+ } catch {
9143
+ await node_fs_promises.rm(alias, { force: true });
9144
+ }
9145
+ try {
9146
+ await linker(canonical, alias);
9147
+ return "link";
9148
+ } catch {
9149
+ await node_fs_promises.copyFile(canonical, alias);
9150
+ return "copy";
9151
+ }
7742
9152
  }
7743
- function normalizeBanner(value) {
7744
- return value === "unreleased" || value === "unmaintained" ? value : false;
9153
+ /** Keep leftover search/hash; drop consumed transform params. */
9154
+ function rewriteToCanonicalUrl(originalSrc, canonicalPath) {
9155
+ const hashIndex = originalSrc.indexOf("#");
9156
+ const hash = hashIndex === -1 ? "" : originalSrc.slice(hashIndex);
9157
+ const withoutHash = hashIndex === -1 ? originalSrc : originalSrc.slice(0, hashIndex);
9158
+ const queryIndex = withoutHash.indexOf("?");
9159
+ return `${canonicalPath}${leftoverQuery(queryIndex === -1 ? "" : withoutHash.slice(queryIndex + 1))}${hash}`;
7745
9160
  }
7746
- function siteHref$1(base, prefix, rest) {
7747
- const root = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
7748
- const parts = [prefix, rest].filter((part) => part && part !== "/");
7749
- return parts.length === 0 ? root : `${root}${parts.join("/")}/`;
9161
+ function leftoverQuery(query) {
9162
+ if (!query) return "";
9163
+ const params = new URLSearchParams(query);
9164
+ for (const key of TRANSFORM_QUERY_KEYS) params.delete(key);
9165
+ const next = params.toString();
9166
+ return next ? `?${next}` : "";
7750
9167
  }
7751
- function relativeUrl(outputPath, outDir) {
7752
- const rel = node_path.posix.normalize(node_path.relative(node_path.resolve(outDir), node_path.resolve(outputPath)).replaceAll(node_path.sep, "/"));
7753
- if (rel.startsWith("..")) return "";
7754
- const dir = rel.endsWith("/index.html") ? rel.slice(0, -11) : rel.replace(/\.html$/, "");
7755
- return dir === "." ? "" : dir;
9168
+ //#endregion
9169
+ //#region src/resources-html.ts
9170
+ /**
9171
+ * Collect local `src`, `poster`, and relevant `href` values from HTML tags.
9172
+ */
9173
+ const RESOURCE_TAG = /<(?:img|video|audio|source|track|a)\b[^>]*>/gi;
9174
+ const RESOURCE_ATTR = /\b(src|poster|href)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
9175
+ function collectResourceTags(html) {
9176
+ return (html.match(RESOURCE_TAG) ?? []).map((tag) => ({
9177
+ tag,
9178
+ refs: collectResourceRefs(tag)
9179
+ })).filter((entry) => entry.refs.length > 0);
9180
+ }
9181
+ function collectResourceRefs(tag) {
9182
+ const name = /^<([a-z]+)/i.exec(tag)?.[1]?.toLowerCase();
9183
+ if (!name) return [];
9184
+ const refs = [];
9185
+ RESOURCE_ATTR.lastIndex = 0;
9186
+ let match = RESOURCE_ATTR.exec(tag);
9187
+ while (match) {
9188
+ const attr = match[1].toLowerCase();
9189
+ if (isRelevantAttr(name, attr)) {
9190
+ const raw = match[2] ?? match[3] ?? "";
9191
+ refs.push({
9192
+ attr,
9193
+ raw,
9194
+ value: unescapeHtml(raw)
9195
+ });
9196
+ }
9197
+ match = RESOURCE_ATTR.exec(tag);
9198
+ }
9199
+ return refs;
9200
+ }
9201
+ function isRelevantAttr(tagName, attr) {
9202
+ if (tagName === "a") return attr === "href";
9203
+ if (attr === "href") return false;
9204
+ if (attr === "poster") return tagName === "video";
9205
+ return attr === "src";
9206
+ }
9207
+ function unescapeHtml(value) {
9208
+ return value.replaceAll("&amp;", "&").replaceAll("&quot;", "\"").replaceAll("&#39;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">");
9209
+ }
9210
+ function escapeAttribute(value) {
9211
+ return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
9212
+ }
9213
+ function replaceAttributeRaw(tag, raw, nextRaw) {
9214
+ const index = tag.indexOf(raw);
9215
+ if (index === -1) return tag;
9216
+ return tag.slice(0, index) + nextRaw + tag.slice(index + raw.length);
7756
9217
  }
7757
9218
  //#endregion
7758
9219
  //#region src/resources-jpeg.ts
@@ -8795,129 +10256,23 @@ function coverCrop(image, width, height) {
8795
10256
  return cropImage(scaled, Math.max(0, Math.floor((scaled.width - width) / 2)), Math.max(0, Math.floor((scaled.height - height) / 2)), width, height);
8796
10257
  }
8797
10258
  //#endregion
8798
- //#region src/resources-process.ts
10259
+ //#region src/resources-write.ts
8799
10260
  /**
8800
- * Page-resource HTML rewriting and transform writes.
10261
+ * Transform cache writes for page resources.
8801
10262
  */
8802
- const IMG_TAG = /<img\b[^>]*>/gi;
8803
- const SRC_ATTR = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
8804
- async function processPageResources(input) {
8805
- if (!input.options.enabled) return {
8806
- html: input.html,
8807
- files: [],
8808
- errors: [],
8809
- fatal: []
8810
- };
8811
- const bundleRoot = node_path.dirname(input.inputPath);
8812
- const outputDir = node_path.dirname(input.outputPath);
8813
- const files = [];
8814
- const errors = [];
8815
- const fatal = [];
8816
- let html = input.html;
8817
- const tags = input.html.match(IMG_TAG) ?? [];
8818
- for (const tag of tags) {
8819
- const srcMatch = tag.match(SRC_ATTR);
8820
- const rawSrc = srcMatch?.[1] ?? srcMatch?.[2];
8821
- if (!rawSrc) continue;
8822
- const src = unescapeHtml(rawSrc);
8823
- const parsed = parseResourceSrc(src);
8824
- if (!parsed) continue;
8825
- const resolved = resolveBundlePath(parsed.pathname, bundleRoot, input.srcDir);
8826
- if (!resolved.ok) {
8827
- const message = `[ox-content] page resource ${JSON.stringify(src)} on ${input.inputPath} is outside the page bundle`;
8828
- errors.push(message);
8829
- fatal.push(message);
8830
- continue;
8831
- }
8832
- let stat;
8833
- try {
8834
- stat = await node_fs_promises.stat(resolved.absolute);
8835
- } catch {
8836
- const message = `[ox-content] missing page resource ${JSON.stringify(parsed.pathname)} on ${input.inputPath}`;
8837
- errors.push(message);
8838
- if (input.options.missing === "error") fatal.push(message);
8839
- continue;
8840
- }
8841
- const transformError = validateTransform(parsed.transform, input.options);
8842
- if (transformError) {
8843
- const message = `[ox-content] ${transformError} for ${JSON.stringify(src)} on ${input.inputPath}`;
8844
- errors.push(message);
8845
- fatal.push(message);
8846
- continue;
8847
- }
8848
- const hasTransform = hasPixelOrFormatTransform(parsed.transform);
8849
- const outputName = hasTransform ? transformedFileName(parsed.pathname, parsed.transform, resourceCacheKey(resolved.absolute, stat.mtimeMs, parsed.transform)) : node_path.basename(resolved.absolute);
8850
- const outputFile = node_path.join(outputDir, outputName);
8851
- try {
8852
- if (hasTransform) await writeTransformedResource({
8853
- sourcePath: resolved.absolute,
8854
- outputFile,
8855
- cacheDir: input.cacheDir,
8856
- mtimeMs: stat.mtimeMs,
8857
- transform: parsed.transform
8858
- });
8859
- else {
8860
- await node_fs_promises.mkdir(outputDir, { recursive: true });
8861
- await node_fs_promises.copyFile(resolved.absolute, outputFile);
8862
- }
8863
- files.push(outputFile);
8864
- const rewritten = tag.replace(rawSrc, escapeAttribute(outputName));
8865
- html = html.replace(tag, rewritten);
8866
- } catch (error) {
8867
- const detail = error instanceof Error ? error.message : String(error);
8868
- const message = `[ox-content] failed to process page resource ${JSON.stringify(src)} on ${input.inputPath}: ${detail}`;
8869
- errors.push(message);
8870
- fatal.push(message);
8871
- }
8872
- }
8873
- return {
8874
- html,
8875
- files,
8876
- errors,
8877
- fatal
8878
- };
8879
- }
8880
- function resolveBundlePath(pathname, bundleRoot, contentRoot) {
8881
- if (node_path.isAbsolute(pathname) || pathname.includes("\0")) return { ok: false };
8882
- const absolute = node_path.resolve(bundleRoot, pathname);
8883
- if (!isInsideRoot$1(bundleRoot, absolute) || !isInsideRoot$1(contentRoot, absolute)) return { ok: false };
8884
- return {
8885
- ok: true,
8886
- absolute
8887
- };
8888
- }
8889
- function validateTransform(transform, options) {
8890
- if (transform.width && options.widths.length > 0 && !options.widths.includes(transform.width)) return `width ${transform.width} is not in resources.widths`;
8891
- if (transform.format && !options.formats.includes(transform.format)) return `format ${transform.format} is not in resources.formats`;
8892
- }
8893
- function hasPixelOrFormatTransform(transform) {
8894
- return Boolean(transform.width || transform.height || transform.crop || transform.format);
8895
- }
8896
- function transformedFileName(pathname, transform, cacheKey) {
8897
- const stem = node_path.basename(pathname).replace(/\.[^.]+$/, "") || "resource";
8898
- const ext = outputExtension(pathname, transform.format);
8899
- return `${stem}.${cacheKey.slice(0, 12)}.${ext}`;
8900
- }
8901
- function outputExtension(pathname, format) {
8902
- if (format === "jpeg") return "jpg";
8903
- if (format) return format;
8904
- const ext = node_path.extname(pathname).slice(1).toLowerCase();
8905
- return ext === "jpeg" ? "jpg" : ext || "png";
8906
- }
8907
- async function writeTransformedResource(input) {
10263
+ async function ensureTransformedCache(input) {
8908
10264
  const key = resourceCacheKey(input.sourcePath, input.mtimeMs, input.transform);
8909
10265
  const ext = node_path.extname(input.outputFile);
8910
10266
  const cacheFile = node_path.join(input.cacheDir, `${key}${ext}`);
8911
10267
  try {
8912
- await node_fs_promises.copyFile(cacheFile, input.outputFile);
8913
- return;
10268
+ await node_fs_promises.access(cacheFile);
10269
+ return cacheFile;
8914
10270
  } catch {}
8915
10271
  const output = transformResourceBuffer(await node_fs_promises.readFile(input.sourcePath), input.sourcePath, input.transform);
8916
10272
  if (output.length > 8388608) throw new Error("transform produced an oversized file");
8917
- await node_fs_promises.mkdir(node_path.dirname(input.outputFile), { recursive: true });
8918
10273
  await node_fs_promises.mkdir(input.cacheDir, { recursive: true });
8919
10274
  await node_fs_promises.writeFile(cacheFile, output);
8920
- await node_fs_promises.writeFile(input.outputFile, output);
10275
+ return cacheFile;
8921
10276
  }
8922
10277
  function transformResourceBuffer(source, sourcePath, transform) {
8923
10278
  const needsPixels = Boolean(transform.width || transform.height || transform.crop);
@@ -8960,11 +10315,221 @@ function formatFromPath(filePath) {
8960
10315
  const ext = node_path.extname(filePath).slice(1).toLowerCase();
8961
10316
  return ext === "jpg" ? "jpeg" : ext;
8962
10317
  }
8963
- function unescapeHtml(value) {
8964
- return value.replaceAll("&amp;", "&").replaceAll("&quot;", "\"").replaceAll("&#39;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">");
10318
+ //#endregion
10319
+ //#region src/resources-process.ts
10320
+ /**
10321
+ * Page-resource HTML rewriting and transform writes.
10322
+ */
10323
+ const PAGE_EXTS = /* @__PURE__ */ new Set([
10324
+ ".md",
10325
+ ".markdown",
10326
+ ".mdx",
10327
+ ".html",
10328
+ ".htm"
10329
+ ]);
10330
+ async function processPageResources(input) {
10331
+ if (!input.options.enabled) return {
10332
+ html: input.html,
10333
+ files: [],
10334
+ errors: [],
10335
+ fatal: []
10336
+ };
10337
+ if (input.options.dedupe && !input.outDir) {
10338
+ const message = "[ox-content] resources.dedupe requires outDir";
10339
+ return {
10340
+ html: input.html,
10341
+ files: [],
10342
+ errors: [message],
10343
+ fatal: [message]
10344
+ };
10345
+ }
10346
+ const bundleRoot = node_path.dirname(input.inputPath);
10347
+ const outputDir = node_path.dirname(input.outputPath);
10348
+ const files = [];
10349
+ const errors = [];
10350
+ const fatal = [];
10351
+ let html = input.html;
10352
+ const store = input.options.dedupe ? input.dedupeStore ?? createResourceDedupeStore() : void 0;
10353
+ for (const { tag, refs } of collectResourceTags(input.html)) {
10354
+ let nextTag = tag;
10355
+ for (const ref of refs) {
10356
+ const result = await processResourceRef(input, {
10357
+ bundleRoot,
10358
+ outputDir,
10359
+ ref: ref.attr,
10360
+ src: ref.value,
10361
+ store
10362
+ });
10363
+ errors.push(...result.errors);
10364
+ fatal.push(...result.fatal);
10365
+ files.push(...result.files);
10366
+ if (result.rewrite) nextTag = replaceAttributeRaw(nextTag, ref.raw, escapeAttribute(result.rewrite));
10367
+ }
10368
+ if (nextTag !== tag) html = html.replace(tag, nextTag);
10369
+ }
10370
+ return {
10371
+ html,
10372
+ files,
10373
+ errors,
10374
+ fatal
10375
+ };
8965
10376
  }
8966
- function escapeAttribute(value) {
8967
- return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
10377
+ async function processResourceRef(input, ctx) {
10378
+ const parsed = parseResourceSrc(ctx.src);
10379
+ if (!parsed) return {
10380
+ files: [],
10381
+ errors: [],
10382
+ fatal: []
10383
+ };
10384
+ const resolved = resolveBundlePath(parsed.pathname, ctx.bundleRoot, input.srcDir);
10385
+ if (!resolved.ok) {
10386
+ if (ctx.ref === "href") return {
10387
+ files: [],
10388
+ errors: [],
10389
+ fatal: []
10390
+ };
10391
+ const message = `[ox-content] page resource ${JSON.stringify(ctx.src)} on ${input.inputPath} is outside the page bundle`;
10392
+ return {
10393
+ files: [],
10394
+ errors: [message],
10395
+ fatal: [message]
10396
+ };
10397
+ }
10398
+ let stat;
10399
+ try {
10400
+ stat = await node_fs_promises.stat(resolved.absolute);
10401
+ } catch {
10402
+ if (ctx.ref === "href") return {
10403
+ files: [],
10404
+ errors: [],
10405
+ fatal: []
10406
+ };
10407
+ const message = `[ox-content] missing page resource ${JSON.stringify(parsed.pathname)} on ${input.inputPath}`;
10408
+ return {
10409
+ files: [],
10410
+ errors: [message],
10411
+ fatal: input.options.missing === "error" ? [message] : []
10412
+ };
10413
+ }
10414
+ const hrefToPage = ctx.ref === "href" && PAGE_EXTS.has(node_path.extname(resolved.absolute).toLowerCase());
10415
+ if (!stat.isFile() || hrefToPage) return {
10416
+ files: [],
10417
+ errors: [],
10418
+ fatal: []
10419
+ };
10420
+ const transformError = validateTransform(parsed.transform, input.options);
10421
+ if (transformError) {
10422
+ const message = `[ox-content] ${transformError} for ${JSON.stringify(ctx.src)} on ${input.inputPath}`;
10423
+ return {
10424
+ files: [],
10425
+ errors: [message],
10426
+ fatal: [message]
10427
+ };
10428
+ }
10429
+ const hasTransform = hasPixelOrFormatTransform(parsed.transform);
10430
+ const outputName = hasTransform ? transformedFileName(parsed.pathname, parsed.transform, resourceCacheKey(resolved.absolute, stat.mtimeMs, parsed.transform)) : decodedBasename(parsed.pathname) || node_path.basename(resolved.absolute);
10431
+ const outputFile = node_path.join(ctx.outputDir, outputName);
10432
+ try {
10433
+ const materialized = hasTransform ? await ensureTransformedCache({
10434
+ sourcePath: resolved.absolute,
10435
+ outputFile,
10436
+ cacheDir: input.cacheDir,
10437
+ mtimeMs: stat.mtimeMs,
10438
+ transform: parsed.transform
10439
+ }) : resolved.absolute;
10440
+ if (ctx.store && input.outDir) return await emitDedupedResource({
10441
+ store: ctx.store,
10442
+ materialized,
10443
+ outputFile,
10444
+ src: ctx.src,
10445
+ sourcePath: resolved.absolute,
10446
+ mtimeMs: stat.mtimeMs,
10447
+ transform: parsed.transform,
10448
+ hasTransform,
10449
+ outDir: input.outDir,
10450
+ base: input.base ?? "/"
10451
+ });
10452
+ if (hasTransform) {
10453
+ await node_fs_promises.mkdir(ctx.outputDir, { recursive: true });
10454
+ await node_fs_promises.copyFile(materialized, outputFile);
10455
+ } else {
10456
+ await node_fs_promises.mkdir(ctx.outputDir, { recursive: true });
10457
+ await node_fs_promises.copyFile(resolved.absolute, outputFile);
10458
+ }
10459
+ return {
10460
+ files: [outputFile],
10461
+ errors: [],
10462
+ fatal: [],
10463
+ rewrite: outputName
10464
+ };
10465
+ } catch (error) {
10466
+ const detail = error instanceof Error ? error.message : String(error);
10467
+ const message = `[ox-content] failed to process page resource ${JSON.stringify(ctx.src)} on ${input.inputPath}: ${detail}`;
10468
+ return {
10469
+ files: [],
10470
+ errors: [message],
10471
+ fatal: [message]
10472
+ };
10473
+ }
10474
+ }
10475
+ async function emitDedupedResource(input) {
10476
+ const ext = normalizeDedupeExt(node_path.extname(input.outputFile));
10477
+ const reuseKey = input.hasTransform ? resourceCacheKey(input.sourcePath, input.mtimeMs, input.transform) : `${input.sourcePath}\0${input.mtimeMs}\0copy`;
10478
+ const digest = await hashResourceFile(input.materialized, ext, input.store, reuseKey);
10479
+ const { asset, wrote } = await emitCanonicalResource(input.store, {
10480
+ digest,
10481
+ ext,
10482
+ sourcePath: input.materialized,
10483
+ outDir: input.outDir,
10484
+ base: input.base
10485
+ });
10486
+ await linkOrCopyAlias(asset.absolutePath, input.outputFile);
10487
+ return {
10488
+ files: wrote ? [asset.absolutePath, input.outputFile] : [input.outputFile],
10489
+ errors: [],
10490
+ fatal: [],
10491
+ rewrite: rewriteToCanonicalUrl(input.src, asset.publicPath)
10492
+ };
10493
+ }
10494
+ function resolveBundlePath(pathname, bundleRoot, contentRoot) {
10495
+ let decoded;
10496
+ try {
10497
+ decoded = decodeURIComponent(pathname);
10498
+ } catch {
10499
+ decoded = pathname;
10500
+ }
10501
+ if (node_path.isAbsolute(decoded) || decoded.includes("\0")) return { ok: false };
10502
+ const absolute = node_path.resolve(bundleRoot, decoded);
10503
+ if (!isInsideRoot$1(bundleRoot, absolute) || !isInsideRoot$1(contentRoot, absolute)) return { ok: false };
10504
+ return {
10505
+ ok: true,
10506
+ absolute
10507
+ };
10508
+ }
10509
+ function validateTransform(transform, options) {
10510
+ if (transform.width && options.widths.length > 0 && !options.widths.includes(transform.width)) return `width ${transform.width} is not in resources.widths`;
10511
+ if (transform.format && !options.formats.includes(transform.format)) return `format ${transform.format} is not in resources.formats`;
10512
+ }
10513
+ function hasPixelOrFormatTransform(transform) {
10514
+ return Boolean(transform.width || transform.height || transform.crop || transform.format);
10515
+ }
10516
+ function transformedFileName(pathname, transform, cacheKey) {
10517
+ const stem = node_path.basename(pathname).replace(/\.[^.]+$/, "") || "resource";
10518
+ const ext = outputExtension(pathname, transform.format);
10519
+ return `${stem}.${cacheKey.slice(0, 12)}.${ext}`;
10520
+ }
10521
+ function outputExtension(pathname, format) {
10522
+ if (format === "jpeg") return "jpg";
10523
+ if (format) return format;
10524
+ const ext = node_path.extname(pathname).slice(1).toLowerCase();
10525
+ return ext === "jpeg" ? "jpg" : ext || "png";
10526
+ }
10527
+ function decodedBasename(pathname) {
10528
+ try {
10529
+ return node_path.basename(decodeURIComponent(pathname));
10530
+ } catch {
10531
+ return node_path.basename(pathname);
10532
+ }
8968
10533
  }
8969
10534
  //#endregion
8970
10535
  //#region src/resources.ts
@@ -8999,19 +10564,22 @@ function resolveResourcesOptions(value) {
8999
10564
  enabled: false,
9000
10565
  formats: [...DEFAULT_FORMATS],
9001
10566
  widths: [],
9002
- missing: "error"
10567
+ missing: "error",
10568
+ dedupe: false
9003
10569
  };
9004
10570
  if (value === true) return {
9005
10571
  enabled: true,
9006
10572
  formats: [...DEFAULT_FORMATS],
9007
10573
  widths: [],
9008
- missing: "error"
10574
+ missing: "error",
10575
+ dedupe: false
9009
10576
  };
9010
10577
  return {
9011
10578
  enabled: true,
9012
10579
  formats: normalizeFormats(value.formats),
9013
10580
  widths: normalizeWidths(value.widths),
9014
- missing: value.missing === "warn" ? "warn" : "error"
10581
+ missing: value.missing === "warn" ? "warn" : "error",
10582
+ dedupe: value.dedupe === true
9015
10583
  };
9016
10584
  }
9017
10585
  /**
@@ -9257,10 +10825,12 @@ function resolveSsgOptions(ssg) {
9257
10825
  pagination: false,
9258
10826
  breadcrumbs: false,
9259
10827
  jsonLd: false,
10828
+ headValidation: false,
9260
10829
  readerChrome: false,
9261
10830
  localeSwitcher: false,
9262
10831
  a11y: false,
9263
10832
  pageChrome: false,
10833
+ markdownSource: resolveMarkdownSourceOptions(void 0),
9264
10834
  notFound: resolveNotFoundOptions(void 0),
9265
10835
  team: resolveTeamOptions(void 0),
9266
10836
  blog: resolveBlogOptions(void 0),
@@ -9277,10 +10847,12 @@ function resolveSsgOptions(ssg) {
9277
10847
  pagination: false,
9278
10848
  breadcrumbs: false,
9279
10849
  jsonLd: false,
10850
+ headValidation: false,
9280
10851
  readerChrome: false,
9281
10852
  localeSwitcher: false,
9282
10853
  a11y: false,
9283
10854
  pageChrome: false,
10855
+ markdownSource: resolveMarkdownSourceOptions(void 0),
9284
10856
  notFound: resolveNotFoundOptions(void 0),
9285
10857
  team: resolveTeamOptions(void 0),
9286
10858
  blog: resolveBlogOptions(void 0),
@@ -9305,10 +10877,12 @@ function resolveSsgOptions(ssg) {
9305
10877
  pagination: resolvePaginationOption(ssg.pagination),
9306
10878
  breadcrumbs: resolvePaginationOption(ssg.breadcrumbs),
9307
10879
  jsonLd: resolveJsonLdOption(ssg.jsonLd),
10880
+ headValidation: resolveHeadValidation(ssg.headValidation),
9308
10881
  readerChrome: resolveReaderChromeOption(ssg.readerChrome),
9309
10882
  localeSwitcher: resolveLocaleSwitcherOption(ssg.localeSwitcher),
9310
10883
  a11y: resolveA11yOption(ssg.a11y),
9311
10884
  pageChrome: require_vitepress.resolvePageChromeOption(ssg.pageChrome),
10885
+ markdownSource: resolveMarkdownSourceOptions(ssg.markdownSource),
9312
10886
  notFound: resolveNotFoundOptions(ssg.notFound),
9313
10887
  team: resolveTeamOptions(ssg.team),
9314
10888
  blog: resolveBlogOptions(ssg.blog),
@@ -9336,7 +10910,9 @@ function resolveJsonLdOption(value) {
9336
10910
  const publisher = resolveJsonLdPublisher(value.publisher);
9337
10911
  return {
9338
10912
  breadcrumbs: value.breadcrumbs !== false,
9339
- ...publisher ? { publisher } : {}
10913
+ ...publisher ? { publisher } : {},
10914
+ ...value.type ? { type: value.type } : {},
10915
+ ...value.graph ? { graph: value.graph } : {}
9340
10916
  };
9341
10917
  }
9342
10918
  return false;
@@ -9479,7 +11055,7 @@ function localeCodesFor(locales) {
9479
11055
  async function generateHtmlPage(pageData, navGroups, siteName, base, ogImage, theme, locale, availableLocales, pagination = false, readerChrome = false, breadcrumbs = false, localeSwitcher = false, localePaths, a11y = false, team = {
9480
11056
  enabled: false,
9481
11057
  members: []
9482
- }, pageChrome = false, breadcrumbRootHref, jsonLd = false, siteUrl) {
11058
+ }, pageChrome = false, breadcrumbRootHref, jsonLd = false, siteUrl, headValidation = false, defaultLocale) {
9483
11059
  const mod = await require_vitepress.importNapiModule();
9484
11060
  const tocForRust = pageData.toc.map(toRustTocEntry);
9485
11061
  const navGroupsForRust = convertNavGroupsForRust(navGroups);
@@ -9515,7 +11091,7 @@ async function generateHtmlPage(pageData, navGroups, siteName, base, ogImage, th
9515
11091
  linkText: f.linkText
9516
11092
  }))
9517
11093
  } : void 0;
9518
- return mod.generateSsgHtml({
11094
+ const result = mod.generateSsgHtml({
9519
11095
  title: pageData.title,
9520
11096
  description: pageData.description,
9521
11097
  content: pageData.content,
@@ -9528,12 +11104,16 @@ async function generateHtmlPage(pageData, navGroups, siteName, base, ogImage, th
9528
11104
  next: pageData.next,
9529
11105
  breadcrumbs: pageData.breadcrumbs,
9530
11106
  layout: typeof pageData.frontmatter.layout === "string" ? pageData.frontmatter.layout : void 0,
9531
- chrome: pageData.chrome
11107
+ chrome: pageData.chrome,
11108
+ robots: typeof pageData.frontmatter.robots === "string" ? pageData.frontmatter.robots : void 0,
11109
+ canonical: typeof pageData.frontmatter.canonical === "string" ? pageData.frontmatter.canonical : void 0
9532
11110
  }, navGroupsForRust, {
9533
11111
  siteName,
9534
11112
  base,
9535
11113
  breadcrumbRootHref,
9536
11114
  ogImage,
11115
+ siteUrl,
11116
+ headValidation: headValidation || void 0,
9537
11117
  theme: themeForRust,
9538
11118
  locale,
9539
11119
  availableLocales: availableLocales ? toRustLocales(availableLocales) : void 0,
@@ -9552,9 +11132,18 @@ async function generateHtmlPage(pageData, navGroups, siteName, base, ogImage, th
9552
11132
  jsonLd: jsonLd ? {
9553
11133
  breadcrumbs: jsonLd.breadcrumbs,
9554
11134
  publisher: jsonLd.publisher,
9555
- siteUrl
11135
+ siteUrl,
11136
+ pageType: jsonLd.type,
11137
+ graph: jsonLd.graph?.map((node) => JSON.stringify(node))
9556
11138
  } : void 0
9557
11139
  });
11140
+ const html = typeof result === "string" ? result : result.html;
11141
+ reportHeadDiagnostics(typeof result === "string" ? [] : result.diagnostics ?? [], headValidation);
11142
+ return injectSearchLocaleFilters(html, {
11143
+ locales: availableLocales ?? [],
11144
+ current: locale,
11145
+ defaultLocale: defaultLocale ?? availableLocales?.[0]?.code ?? "en"
11146
+ });
9558
11147
  }
9559
11148
  async function externalizeSharedPageAssets(pages, outDir, base) {
9560
11149
  const optimized = (await require_vitepress.importNapiModule()).externalizeSsgAssets(pages, outDir, base);
@@ -9639,6 +11228,7 @@ async function buildSsg(options, root) {
9639
11228
  applyPermalinkRoutes(context, collected);
9640
11229
  errors.push(...collected.errors);
9641
11230
  const { outputPages, listedPages } = applyPublishState(context, collected);
11231
+ context.markdownSourcePages.push(...outputPages);
9642
11232
  remapPermalinkNav(context, listedPages);
9643
11233
  await applyPageResources(context, outputPages, generatedFiles, errors);
9644
11234
  await generateOgImageAssets(context, collected, generatedFiles, errors);
@@ -9716,7 +11306,8 @@ async function createBuildSsgContext(options, root, srcDir, outDir, markdownFile
9716
11306
  navItems: resolveNavigationGroups(ssgOptions.navigation, base, ssgOptions.extension) ?? (ssgOptions.theme?.sidebar.length ? buildThemeNavItems(ssgOptions.theme.sidebar, base, ssgOptions.extension) : buildNavItems(markdownFiles, srcDir, base, ssgOptions.extension)),
9717
11307
  siteName: await resolveSiteName$1(root, ssgOptions),
9718
11308
  shouldGenerateOgImages: shouldGenerateOgImages(options),
9719
- napi: ssgOptions.lastUpdated || ssgOptions.contributors ? await require_vitepress.importNapiModule() : void 0
11309
+ markdownSourcePages: [],
11310
+ napi: ssgOptions.lastUpdated || ssgOptions.contributors || options.siteMaps?.enabled ? await require_vitepress.importNapiModule() : void 0
9720
11311
  };
9721
11312
  }
9722
11313
  /**
@@ -9746,6 +11337,7 @@ async function applyPageResources(context, pages, generatedFiles, errors) {
9746
11337
  if (!options?.enabled) return;
9747
11338
  const cacheDir = path.join(context.root, ".cache", "ox-content-resources");
9748
11339
  const fatal = [];
11340
+ const dedupeStore = options.dedupe ? createResourceDedupeStore() : void 0;
9749
11341
  for (const page of pages) {
9750
11342
  const processed = await processPageResources({
9751
11343
  html: page.transformedHtml,
@@ -9753,7 +11345,10 @@ async function applyPageResources(context, pages, generatedFiles, errors) {
9753
11345
  outputPath: page.routePaths.outputPath,
9754
11346
  srcDir: context.srcDir,
9755
11347
  options,
9756
- cacheDir
11348
+ cacheDir,
11349
+ outDir: context.outDir,
11350
+ base: context.base,
11351
+ dedupeStore
9757
11352
  });
9758
11353
  page.transformedHtml = processed.html;
9759
11354
  generatedFiles.push(...processed.files);
@@ -9827,7 +11422,8 @@ function applyPublishState(context, collected) {
9827
11422
  };
9828
11423
  }
9829
11424
  async function transformSsgPage(context, inputPath) {
9830
- const result = await transformMarkdown(await fs_promises.readFile(inputPath, "utf-8"), inputPath, context.options, {
11425
+ const content = await fs_promises.readFile(inputPath, "utf-8");
11426
+ const result = await transformMarkdown(content, inputPath, context.options, {
9831
11427
  convertMdLinks: true,
9832
11428
  baseUrl: context.base,
9833
11429
  sourcePath: inputPath
@@ -9837,11 +11433,12 @@ async function transformSsgPage(context, inputPath) {
9837
11433
  const title = extractTitle$1(transformedHtml, frontmatter);
9838
11434
  return {
9839
11435
  inputPath,
11436
+ source: content,
9840
11437
  routePaths: getRoutePaths(inputPath, context.srcDir, context.outDir, context.base, context.ssgOptions.extension, context.ssgOptions.siteUrl),
9841
11438
  transformedHtml,
9842
11439
  title,
9843
11440
  description: frontmatter.description,
9844
- lastUpdated: context.ssgOptions.lastUpdated ? context.napi?.getGitLastUpdated(inputPath, context.root) ?? void 0 : void 0,
11441
+ lastUpdated: context.ssgOptions.lastUpdated || context.options.siteMaps?.enabled ? context.napi?.getGitLastUpdated(inputPath, context.root) ?? void 0 : void 0,
9845
11442
  contributors: contributorsForPage(context, inputPath),
9846
11443
  frontmatter,
9847
11444
  toc: result.toc
@@ -9932,17 +11529,18 @@ async function generateHtmlPages(context, pageResults, collected, errors) {
9932
11529
  async function renderSsgPage(context, pageResult, collected, allPageResults) {
9933
11530
  const { ogImageUrlMap } = collected;
9934
11531
  const pageOgImage = context.shouldGenerateOgImages && ogImageUrlMap.has(pageResult.inputPath) ? ogImageUrlMap.get(pageResult.inputPath) : context.ssgOptions.ogImage;
11532
+ const markdownSource = pageMarkdownSourceHref(context, pageResult);
9935
11533
  if (context.ssgOptions.render) {
9936
11534
  const nav = context.versionNavigation ? rewriteVersionedNavGroups(context.navItems, context.versionNavigation) : context.navItems;
9937
- return renderPage(toThemePageData(pageResult), {
11535
+ return applyMarkdownSourceAlternate(context, renderPage(toThemePageData(pageResult, markdownSource), {
9938
11536
  theme: context.ssgOptions.render,
9939
11537
  siteName: context.siteName,
9940
11538
  base: context.base,
9941
11539
  nav,
9942
- pages: allPageResults.map(toThemePageData)
9943
- });
11540
+ pages: allPageResults.map((page) => toThemePageData(page, pageMarkdownSourceHref(context, page)))
11541
+ }), markdownSource);
9944
11542
  }
9945
- if (context.ssgOptions.bare) return generateBarePage({
11543
+ if (context.ssgOptions.bare) return applyMarkdownSourceAlternate(context, generateBarePage({
9946
11544
  title: pageResult.title,
9947
11545
  content: pageResult.transformedHtml,
9948
11546
  lang: context.ssgOptions.lang ?? getPageLocale(pageResult.routePaths.urlPath, context.options.i18n),
@@ -9953,7 +11551,7 @@ async function renderSsgPage(context, pageResult, collected, allPageResults) {
9953
11551
  head: context.ssgOptions.head,
9954
11552
  bodyStart: context.ssgOptions.bodyStart,
9955
11553
  bodyEnd: context.ssgOptions.bodyEnd
9956
- });
11554
+ }), markdownSource);
9957
11555
  const pageData = createSsgPageData(pageResult);
9958
11556
  const versionNavigation = context.versionNavigation;
9959
11557
  if (versionNavigation) {
@@ -9994,10 +11592,18 @@ async function renderSsgPage(context, pageResult, collected, allPageResults) {
9994
11592
  base: context.base,
9995
11593
  roots: versionNavigation ? versionedLocaleRoots(versionNavigation, i18n.locales, i18n.defaultLocale, i18n.hideDefaultLocale) : void 0
9996
11594
  }) : void 0;
9997
- return generateHtmlPage(pageData, navItems, context.siteName, context.base, pageOgImage, theme, locale, i18n ? i18n.locales : void 0, context.ssgOptions.pagination, context.ssgOptions.readerChrome, context.ssgOptions.breadcrumbs, context.ssgOptions.localeSwitcher, localePaths, context.ssgOptions.a11y, context.ssgOptions.team ?? {
11595
+ return applyMarkdownSourceAlternate(context, await generateHtmlPage(pageData, navItems, context.siteName, context.base, pageOgImage, theme, locale, i18n ? i18n.locales : void 0, context.ssgOptions.pagination, context.ssgOptions.readerChrome, context.ssgOptions.breadcrumbs, context.ssgOptions.localeSwitcher, localePaths, context.ssgOptions.a11y, context.ssgOptions.team ?? {
9998
11596
  enabled: false,
9999
11597
  members: []
10000
- }, context.ssgOptions.pageChrome, versionNavigation?.root.href, context.ssgOptions.jsonLd, context.ssgOptions.siteUrl);
11598
+ }, context.ssgOptions.pageChrome, versionNavigation?.root.href, context.ssgOptions.jsonLd, context.ssgOptions.siteUrl, context.ssgOptions.headValidation, i18n?.defaultLocale), markdownSource);
11599
+ }
11600
+ function pageMarkdownSourceHref(context, page) {
11601
+ if (!context.ssgOptions.markdownSource?.enabled || !shouldPublishMarkdownSource(page.frontmatter, context.options.publishState)) return;
11602
+ return markdownSourceHref(page.routePaths.urlPath, context.base);
11603
+ }
11604
+ function applyMarkdownSourceAlternate(context, html, href) {
11605
+ if (!href || !context.ssgOptions.markdownSource?.alternate) return html;
11606
+ return injectMarkdownSourceAlternate(html, href);
10001
11607
  }
10002
11608
  function rewritePagerOverride(pager, context) {
10003
11609
  return pager?.href ? {
@@ -10006,7 +11612,7 @@ function rewritePagerOverride(pager, context) {
10006
11612
  } : pager;
10007
11613
  }
10008
11614
  /** Maps an internal page result onto the theme renderer's page shape. */
10009
- function toThemePageData(pageResult) {
11615
+ function toThemePageData(pageResult, markdownSource) {
10010
11616
  return {
10011
11617
  title: pageResult.title,
10012
11618
  description: pageResult.description,
@@ -10016,6 +11622,7 @@ function toThemePageData(pageResult) {
10016
11622
  contributors: pageResult.contributors,
10017
11623
  path: pageResult.inputPath,
10018
11624
  url: pageResult.routePaths.href,
11625
+ markdownSource,
10019
11626
  frontmatter: pageResult.frontmatter,
10020
11627
  layout: typeof pageResult.frontmatter.layout === "string" ? pageResult.frontmatter.layout : void 0
10021
11628
  };
@@ -10128,6 +11735,7 @@ async function applyDocumentationVersions(generatedPages, context, errors) {
10128
11735
  ...page.routePaths,
10129
11736
  ...prefixRoutePaths(page.routePaths, entry.prefix, context.outDir, context.base)
10130
11737
  };
11738
+ context.markdownSourcePages.push(...outputPages);
10131
11739
  snapContext.versionNavigation = createVersionNavigationContext({
10132
11740
  prefix: entry.prefix,
10133
11741
  base: context.base,
@@ -10240,6 +11848,20 @@ async function writeGeneratedPages(generatedPages, context, generatedFiles, list
10240
11848
  errors.push(feeds.warning);
10241
11849
  console.warn(feeds.warning);
10242
11850
  }
11851
+ const markdownSource = await writeMarkdownSourceFiles({
11852
+ outDir: context.outDir,
11853
+ base: context.base,
11854
+ options: context.ssgOptions.markdownSource,
11855
+ publishState: context.options.publishState,
11856
+ pages: context.markdownSourcePages.map((page) => ({
11857
+ inputPath: page.inputPath,
11858
+ source: page.source,
11859
+ urlPath: page.routePaths.urlPath,
11860
+ frontmatter: page.frontmatter
11861
+ }))
11862
+ });
11863
+ generatedFiles.push(...markdownSource.files);
11864
+ errors.push(...markdownSource.errors);
10243
11865
  }
10244
11866
  /** Turns an SSG `urlPath` (`guide` or `/`) into a same-origin dest (`/guide`). */
10245
11867
  function sitePathFromUrlPath(urlPath) {
@@ -10253,6 +11875,7 @@ function sitemapPages(context, listedPages, outputPages) {
10253
11875
  loc: canonicalPageUrl(context, page.routePaths.urlPath) ?? "",
10254
11876
  title: page.title,
10255
11877
  description: page.description,
11878
+ lastUpdated: page.lastUpdated,
10256
11879
  draft: page.frontmatter.draft === true,
10257
11880
  unlisted: Boolean(context.options.publishState?.enabled) && !listedPaths.has(page.inputPath)
10258
11881
  }));
@@ -10350,7 +11973,8 @@ function createDevServerCache() {
10350
11973
  navGroups: null,
10351
11974
  localePages: null,
10352
11975
  pages: /* @__PURE__ */ new Map(),
10353
- siteName: null
11976
+ siteName: null,
11977
+ markdownSourceIndex: null
10354
11978
  };
10355
11979
  }
10356
11980
  /**
@@ -10359,6 +11983,7 @@ function createDevServerCache() {
10359
11983
  function invalidateNavCache(cache) {
10360
11984
  cache.navGroups = null;
10361
11985
  cache.localePages = null;
11986
+ cache.markdownSourceIndex = null;
10362
11987
  cache.pages.clear();
10363
11988
  }
10364
11989
  /**
@@ -10366,6 +11991,7 @@ function invalidateNavCache(cache) {
10366
11991
  */
10367
11992
  function invalidatePageCache(cache, filePath) {
10368
11993
  cache.pages.delete(filePath);
11994
+ cache.markdownSourceIndex = null;
10369
11995
  }
10370
11996
  /**
10371
11997
  * Resolve site name from options or package.json.
@@ -10455,13 +12081,35 @@ async function renderPage$1(filePath, options, navGroups, siteName, base, root,
10455
12081
  pages: localePages,
10456
12082
  base
10457
12083
  }) : void 0;
12084
+ const markdownSource = options.ssg.markdownSource?.enabled ? markdownSourceHrefForPage({
12085
+ source: filePath,
12086
+ fileUrl: pageData.path,
12087
+ frontmatter,
12088
+ base,
12089
+ permalinks: options.permalinks,
12090
+ cascade: options.cascade,
12091
+ publishState: options.publishState
12092
+ }) : void 0;
10458
12093
  let html = await generateHtmlPage(pageData, localizedNav, siteName, base, options.ssg.ogImage, theme, locale, i18n ? i18n.locales : void 0, options.ssg.pagination, options.ssg.readerChrome, options.ssg.breadcrumbs, options.ssg.localeSwitcher, localePaths, options.ssg.a11y, options.ssg.team ?? {
10459
12094
  enabled: false,
10460
12095
  members: []
10461
- }, options.ssg.pageChrome, void 0, options.ssg.jsonLd, options.ssg.siteUrl);
12096
+ }, options.ssg.pageChrome, void 0, options.ssg.jsonLd, options.ssg.siteUrl, options.ssg.headValidation, i18n?.defaultLocale);
12097
+ if (markdownSource && options.ssg.markdownSource?.alternate) html = injectMarkdownSourceAlternate(html, markdownSource);
10462
12098
  html = injectViteHmrClient(html);
10463
12099
  return html;
10464
12100
  }
12101
+ async function serveMarkdownSource(routeUrl, options, srcDir, cache) {
12102
+ if (!cache.markdownSourceIndex) cache.markdownSourceIndex = await buildMarkdownSourceIndex({
12103
+ files: await collectMarkdownFiles(srcDir, options.extensions),
12104
+ srcDir,
12105
+ permalinks: options.permalinks,
12106
+ cascade: options.cascade,
12107
+ publishState: options.publishState
12108
+ });
12109
+ const entry = resolveMarkdownSourceRequest(routeUrl, cache.markdownSourceIndex);
12110
+ if (!entry) return "missing";
12111
+ return entry.allowed ? entry.source : "hidden";
12112
+ }
10465
12113
  /**
10466
12114
  * Create the dev server middleware for SSG page serving.
10467
12115
  */
@@ -10474,6 +12122,19 @@ function createDevServerMiddleware(options, root, cache) {
10474
12122
  let routeUrl = url;
10475
12123
  if (base !== "/" && routeUrl.startsWith(base)) routeUrl = "/" + routeUrl.slice(base.length);
10476
12124
  if (shouldSkip(routeUrl)) return next();
12125
+ if (options.ssg.markdownSource?.enabled && isMarkdownSourceRequest(routeUrl)) {
12126
+ const served = await serveMarkdownSource(routeUrl, options, srcDir, cache);
12127
+ if (served === "missing") return next();
12128
+ if (served === "hidden") {
12129
+ res.statusCode = 404;
12130
+ res.end();
12131
+ return;
12132
+ }
12133
+ res.setHeader("Content-Type", "text/markdown; charset=utf-8");
12134
+ res.setHeader("Cache-Control", "no-cache");
12135
+ res.end(served);
12136
+ return;
12137
+ }
10477
12138
  const filePath = await resolveMarkdownFile(routeUrl, srcDir, options.extensions);
10478
12139
  if (!filePath) return next();
10479
12140
  try {
@@ -10990,6 +12651,43 @@ function resolveCardOptions(options) {
10990
12651
  return { enabled: options.enabled ?? true };
10991
12652
  }
10992
12653
  //#endregion
12654
+ //#region src/heading-permalinks-options.ts
12655
+ function resolveHeadingPermalinksOptions(options) {
12656
+ if (!options) return { enabled: false };
12657
+ if (options === true) return { enabled: true };
12658
+ return { enabled: options.enabled ?? true };
12659
+ }
12660
+ //#endregion
12661
+ //#region src/magic-link-options.ts
12662
+ function resolveMagicLinkOptions(options) {
12663
+ if (!options) return {
12664
+ enabled: false,
12665
+ aliases: {},
12666
+ favicon: false,
12667
+ imageOverrides: []
12668
+ };
12669
+ if (options === true) return {
12670
+ enabled: true,
12671
+ aliases: {},
12672
+ favicon: false,
12673
+ imageOverrides: []
12674
+ };
12675
+ const favicon = options.favicon === true || typeof options.favicon === "object" && options.favicon != null;
12676
+ const faviconTemplate = typeof options.favicon === "object" ? options.favicon.template : void 0;
12677
+ return {
12678
+ enabled: options.enabled ?? true,
12679
+ aliases: normalizeAliases(options.aliases),
12680
+ favicon,
12681
+ faviconTemplate,
12682
+ imageOverrides: options.imageOverrides ?? []
12683
+ };
12684
+ }
12685
+ function normalizeAliases(aliases) {
12686
+ const normalized = {};
12687
+ for (const [key, value] of Object.entries(aliases ?? {})) normalized[key] = typeof value === "string" ? { href: value } : value;
12688
+ return normalized;
12689
+ }
12690
+ //#endregion
10993
12691
  //#region src/include-options.ts
10994
12692
  function resolveIncludeOptions(options) {
10995
12693
  if (!options) return { enabled: false };
@@ -11564,6 +13262,12 @@ function createFrameworkMarkdownOptions(options) {
11564
13262
  },
11565
13263
  attrs: { enabled: false },
11566
13264
  badges: { enabled: false },
13265
+ magicLinks: {
13266
+ enabled: false,
13267
+ aliases: {},
13268
+ favicon: false,
13269
+ imageOverrides: []
13270
+ },
11567
13271
  containers: {
11568
13272
  enabled: false,
11569
13273
  types: {}
@@ -12563,7 +14267,7 @@ function createSsgPlugin(resolvedOptions, getRoot, ssgDevCache) {
12563
14267
  for (const error of result.errors) console.warn(`[ox-content] ${error}`);
12564
14268
  } catch (err) {
12565
14269
  console.error("[ox-content] SSG build failed:", err);
12566
- if (err instanceof PageResourceError) throw err;
14270
+ if (err instanceof PageResourceError || err instanceof BlogFeedError) throw err;
12567
14271
  }
12568
14272
  }
12569
14273
  };
@@ -12678,6 +14382,7 @@ function resolveOptions(options) {
12678
14382
  gfm: options.gfm ?? true,
12679
14383
  mdx: options.mdx,
12680
14384
  footnotes: options.footnotes ?? true,
14385
+ semanticFootnotes: options.semanticFootnotes ?? false,
12681
14386
  tables: options.tables ?? true,
12682
14387
  taskLists: options.taskLists ?? true,
12683
14388
  strikethrough: options.strikethrough ?? true,
@@ -12688,6 +14393,7 @@ function resolveOptions(options) {
12688
14393
  emojiShortcodes: resolveEmojiShortcodeOptions(options.emojiShortcodes),
12689
14394
  attrs: resolveAttrsOptions(options.attrs),
12690
14395
  badges: resolveBadgeOptions(options.badges),
14396
+ magicLinks: resolveMagicLinkOptions(options.magicLinks),
12691
14397
  containers: resolveContainerOptions(options.containers),
12692
14398
  images: resolveImageOptions(options.images),
12693
14399
  codeImports: resolveCodeImportOptions(options.codeImports),
@@ -12707,6 +14413,7 @@ function resolveOptions(options) {
12707
14413
  frontmatter: options.frontmatter ?? true,
12708
14414
  toc: options.toc ?? true,
12709
14415
  tocMaxDepth: options.tocMaxDepth ?? 3,
14416
+ headingPermalinks: resolveHeadingPermalinksOptions(options.headingPermalinks),
12710
14417
  ogImage: options.ogImage ?? false,
12711
14418
  ogImageOptions: resolveOgImageOptions(options.ogImageOptions),
12712
14419
  transformers: options.transformers ?? [],
@@ -12948,9 +14655,9 @@ function resolveCodeAnnotationsOptions(options) {
12948
14655
  /**
12949
14656
  * Generates virtual module content.
12950
14657
  */
12951
- function generateVirtualModule(path$5, options) {
12952
- if (path$5 === "config") return `export default ${JSON.stringify(options)};`;
12953
- if (path$5 === "runtime") {
14658
+ function generateVirtualModule(path$8, options) {
14659
+ if (path$8 === "config") return `export default ${JSON.stringify(options)};`;
14660
+ if (path$8 === "runtime") {
12954
14661
  const base = normalizeRuntimeBase(options.base);
12955
14662
  return `
12956
14663
  export const base = ${JSON.stringify(base)};
@@ -12997,6 +14704,7 @@ function normalizeRuntimeBase(base) {
12997
14704
  return withLeading.endsWith("/") ? withLeading : `${withLeading}/`;
12998
14705
  }
12999
14706
  //#endregion
14707
+ exports.BlogFeedError = BlogFeedError;
13000
14708
  exports.DEFAULT_HTML_TEMPLATE = DEFAULT_HTML_TEMPLATE;
13001
14709
  exports.DEFAULT_MARKDOWN_EXTENSIONS = DEFAULT_MARKDOWN_EXTENSIONS;
13002
14710
  exports.DefaultTheme = DefaultTheme;
@@ -13080,6 +14788,7 @@ exports.prefetchOgpData = prefetchOgpData;
13080
14788
  exports.raw = require_jsx_html.raw;
13081
14789
  exports.readingTimeMinutes = readingTimeMinutes;
13082
14790
  exports.renderAllPages = renderAllPages;
14791
+ exports.renderHead = renderHead;
13083
14792
  exports.renderHtmlToFrameworkCode = renderHtmlToFrameworkCode;
13084
14793
  exports.renderHtmlToReactComponent = renderHtmlToReactComponent;
13085
14794
  exports.renderHtmlToReactCreateElement = renderHtmlToReactCreateElement;
@@ -13102,11 +14811,14 @@ exports.resolveDocsOptions = resolveDocsOptions;
13102
14811
  exports.resolveDocumentComponentImports = resolveDocumentComponentImports;
13103
14812
  exports.resolveFeedsOptions = resolveFeedsOptions;
13104
14813
  exports.resolveFileTreeOptions = resolveFileTreeOptions;
14814
+ exports.resolveHeadValidation = resolveHeadValidation;
13105
14815
  exports.resolveHeaderNavItems = require_vitepress.resolveHeaderNavItems;
14816
+ exports.resolveHeadingPermalinksOptions = resolveHeadingPermalinksOptions;
13106
14817
  exports.resolveI18nOptions = resolveI18nOptions;
13107
14818
  exports.resolveImageOptions = resolveImageOptions;
13108
14819
  exports.resolveIncludeOptions = resolveIncludeOptions;
13109
14820
  exports.resolveLocaleLabel = require_vitepress.resolveLocaleLabel;
14821
+ exports.resolveMarkdownSourceOptions = resolveMarkdownSourceOptions;
13110
14822
  exports.resolveMathOptions = resolveMathOptions;
13111
14823
  exports.resolveMdxForFilePath = resolveMdxForFilePath;
13112
14824
  exports.resolveNotFoundOptions = resolveNotFoundOptions;