@ox-content/vite-plugin 3.0.0-alpha.5 → 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
@@ -4371,6 +4964,7 @@ function renderPage(page, options) {
4371
4964
  contributors: page.contributors,
4372
4965
  path: page.path,
4373
4966
  url: page.url,
4967
+ markdownSource: page.markdownSource,
4374
4968
  frontmatter: page.frontmatter,
4375
4969
  layout: page.layout
4376
4970
  },
@@ -4387,6 +4981,7 @@ function renderPage(page, options) {
4387
4981
  contributors: p.contributors,
4388
4982
  path: p.path,
4389
4983
  url: p.url,
4984
+ markdownSource: p.markdownSource,
4390
4985
  frontmatter: p.frontmatter,
4391
4986
  layout: p.layout
4392
4987
  }))
@@ -4445,8 +5040,8 @@ function DefaultTheme({ children }) {
4445
5040
  <head>
4446
5041
  <meta charset="UTF-8">
4447
5042
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
4448
- <title>${escapeHtml$4(page.title)} - ${escapeHtml$4(site.name)}</title>
4449
- ${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)}">` : ""}
4450
5045
  <style>
4451
5046
  :root {
4452
5047
  --octc-color-primary: #4f6fae;
@@ -4470,7 +5065,7 @@ function DefaultTheme({ children }) {
4470
5065
  </head>
4471
5066
  <body>
4472
5067
  <header>
4473
- <h1>${escapeHtml$4(site.name)}</h1>
5068
+ <h1>${escapeHtml$3(site.name)}</h1>
4474
5069
  </header>
4475
5070
  <main>
4476
5071
  ${children.__html}
@@ -4478,7 +5073,7 @@ function DefaultTheme({ children }) {
4478
5073
  </body>
4479
5074
  </html>` };
4480
5075
  }
4481
- function escapeHtml$4(str) {
5076
+ function escapeHtml$3(str) {
4482
5077
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4483
5078
  }
4484
5079
  /**
@@ -4578,6 +5173,13 @@ async function writeSiteMapFiles(input) {
4578
5173
  }
4579
5174
  return { files };
4580
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
+ }
4581
5183
  function hasSiteUrl$2(siteUrl) {
4582
5184
  return Boolean(siteUrl && siteUrl.trim());
4583
5185
  }
@@ -4590,7 +5192,14 @@ function generateSitemapXml(pages) {
4590
5192
  for (const page of pages) {
4591
5193
  xml += " <url>\n <loc>";
4592
5194
  xml += escapeXml$1(page.loc);
4593
- 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";
4594
5203
  }
4595
5204
  xml += "</urlset>\n";
4596
5205
  return xml;
@@ -4651,102 +5260,6 @@ function escapeLlmsUrl(value) {
4651
5260
  return escaped;
4652
5261
  }
4653
5262
  //#endregion
4654
- //#region src/publish-state.ts
4655
- /**
4656
- * Opt-in draft / unlisted / scheduled page classification.
4657
- */
4658
- /**
4659
- * Resolves `publishState` with defaults.
4660
- *
4661
- * `false` / omitted stays off. `true` enables production filtering. An object
4662
- * enables the feature and overrides only the fields the site set.
4663
- */
4664
- function resolvePublishStateOptions(value) {
4665
- if (!value) return {
4666
- enabled: false,
4667
- includeDrafts: false
4668
- };
4669
- if (value === true) return {
4670
- enabled: true,
4671
- includeDrafts: false
4672
- };
4673
- return {
4674
- enabled: value.enabled ?? true,
4675
- now: value.now,
4676
- includeDrafts: value.includeDrafts ?? false
4677
- };
4678
- }
4679
- /** Classifies one frontmatter object. Never throws. */
4680
- function classifyPublishState(frontmatter, options) {
4681
- try {
4682
- return require_vitepress.importNapiModuleSync().classifyPublishState(JSON.stringify(frontmatter ?? {}), toNapiPublishState(options));
4683
- } catch {
4684
- return {
4685
- output: true,
4686
- listed: true
4687
- };
4688
- }
4689
- }
4690
- /** Splits pages into those that write HTML and those that appear in listings. */
4691
- function partitionPublishedPages(pages, options) {
4692
- if (!options?.enabled) return {
4693
- output: [...pages],
4694
- listed: [...pages]
4695
- };
4696
- const output = [];
4697
- const listed = [];
4698
- for (const page of pages) {
4699
- const decision = classifyPublishState(page.frontmatter, options);
4700
- if (decision.output) output.push(page);
4701
- if (decision.listed) listed.push(page);
4702
- }
4703
- return {
4704
- output,
4705
- listed
4706
- };
4707
- }
4708
- /** Drops nav items that resolve to hidden (unpublished or unlisted) pages. */
4709
- function filterNavGroups(groups, hidden) {
4710
- return groups.map((group) => ({
4711
- ...group,
4712
- items: filterNavItems(group.items, hidden)
4713
- })).filter((group) => group.items.length > 0);
4714
- }
4715
- function filterNavItems(items, hidden) {
4716
- const kept = [];
4717
- for (const item of items) {
4718
- if (isHiddenNavTarget(item, hidden)) continue;
4719
- const children = item.children?.length ? filterNavItems(item.children, hidden) : item.children;
4720
- kept.push(children === item.children ? item : {
4721
- ...item,
4722
- children
4723
- });
4724
- }
4725
- return kept;
4726
- }
4727
- function isHiddenNavTarget(item, hidden) {
4728
- return hidden.has(item.path) || hidden.has(item.href);
4729
- }
4730
- /** Keys used to match a page against generated nav items. */
4731
- function hiddenNavKeys(pages, listed) {
4732
- const listedPaths = new Set(listed.map((page) => page.inputPath));
4733
- const hidden = /* @__PURE__ */ new Set();
4734
- for (const page of pages) {
4735
- if (listedPaths.has(page.inputPath)) continue;
4736
- hidden.add(page.routePaths.urlPath);
4737
- hidden.add(page.routePaths.href);
4738
- }
4739
- return hidden;
4740
- }
4741
- function toNapiPublishState(options) {
4742
- if (!options) return;
4743
- return {
4744
- enabled: options.enabled,
4745
- now: options.now,
4746
- includeDrafts: options.includeDrafts
4747
- };
4748
- }
4749
- //#endregion
4750
5263
  //#region src/permalinks.ts
4751
5264
  const RESERVED_CASCADE_KEYS = /* @__PURE__ */ new Set(["permalink", "slug"]);
4752
5265
  /** Resolves `permalinks`. `false` / omitted stays off. `true` / `{}` enables. */
@@ -4796,6 +5309,18 @@ function resolvePageRoutes(input) {
4796
5309
  errors
4797
5310
  };
4798
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
+ }
4799
5324
  function normalizeUrlPath$1(value) {
4800
5325
  const segments = pathSegments(value);
4801
5326
  return segments.length === 0 ? "/" : segments.join("/");
@@ -4897,32 +5422,320 @@ function ancestorDirs(source) {
4897
5422
  return dirs;
4898
5423
  }
4899
5424
  //#endregion
4900
- //#region src/apply-permalinks.ts
5425
+ //#region src/publish-state.ts
4901
5426
  /**
4902
- * Applies resolved permalinks / cascade to SSG pages and collection entries.
5427
+ * Opt-in draft / unlisted / scheduled page classification.
4903
5428
  */
4904
- /** Rewrites SSG `routePaths` from resolved permalinks / slugs. */
4905
- function applySsgPageRoutes(input) {
4906
- const resolved = resolvePageRoutes({
4907
- pages: input.pages.map((page) => ({
4908
- source: page.inputPath,
4909
- fileUrl: page.routePaths.urlPath,
4910
- frontmatter: page.frontmatter
4911
- })),
4912
- permalinks: input.permalinks,
4913
- cascade: input.cascade
4914
- });
4915
- const bySource = new Map(resolved.pages.map((page) => [page.source, page]));
4916
- const pages = [];
4917
- for (const page of input.pages) {
4918
- const hit = bySource.get(page.inputPath);
4919
- if (!hit) continue;
4920
- pages.push({
4921
- ...page,
4922
- frontmatter: hit.frontmatter,
4923
- routePaths: routePathsFromUrl(hit.urlPath, input.srcDir, input.outDir, input.base, input.extension, input.siteUrl)
4924
- });
4925
- }
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({
5734
+ ...page,
5735
+ frontmatter: hit.frontmatter,
5736
+ routePaths: routePathsFromUrl(hit.urlPath, input.srcDir, input.outDir, input.base, input.extension, input.siteUrl)
5737
+ });
5738
+ }
4926
5739
  return {
4927
5740
  pages,
4928
5741
  errors: resolved.errors
@@ -5126,7 +5939,7 @@ async function writeRedirectFiles(input) {
5126
5939
  }
5127
5940
  /** Static HTML redirect body. `dest` is escaped. */
5128
5941
  function generateRedirectHtml(dest) {
5129
- const escaped = escapeHtml$3(dest);
5942
+ const escaped = escapeHtml$2(dest);
5130
5943
  return `\
5131
5944
  <!DOCTYPE html>
5132
5945
  <html lang="en">
@@ -5211,7 +6024,7 @@ function upsert(files, index, occupied, from, to, base) {
5211
6024
  html
5212
6025
  });
5213
6026
  }
5214
- function escapeHtml$3(value) {
6027
+ function escapeHtml$2(value) {
5215
6028
  return value.replace(/[&<>"']/g, (ch) => {
5216
6029
  switch (ch) {
5217
6030
  case "&": return "&amp;";
@@ -5569,6 +6382,7 @@ function createNativeTransformOptions(options) {
5569
6382
  return {
5570
6383
  gfm: options.gfm,
5571
6384
  footnotes: options.footnotes,
6385
+ semanticFootnotes: options.semanticFootnotes ?? false,
5572
6386
  taskLists: options.taskLists,
5573
6387
  tables: options.tables,
5574
6388
  strikethrough: options.strikethrough,
@@ -5576,6 +6390,7 @@ function createNativeTransformOptions(options) {
5576
6390
  autolinkUrls: options.autolinks,
5577
6391
  frontmatter: options.frontmatter,
5578
6392
  tocMaxDepth: options.tocMaxDepth,
6393
+ headingPermalinks: options.headingPermalinks?.enabled ?? false,
5579
6394
  codeAnnotations: options.codeAnnotations?.enabled ?? false,
5580
6395
  codeAnnotationMetaKey: options.codeAnnotations?.metaKey ?? "annotate",
5581
6396
  codeAnnotationSyntax: options.codeAnnotations?.notation ?? "attribute",
@@ -5590,6 +6405,13 @@ function createNativeTransformOptions(options) {
5590
6405
  } : void 0,
5591
6406
  attributes: options.attrs?.enabled ? { enabled: true } : void 0,
5592
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,
5593
6415
  containers: options.containers?.enabled ? {
5594
6416
  enabled: true,
5595
6417
  types: options.containers.types
@@ -6014,6 +6836,7 @@ function isExcludedFromFeed(item, publishState) {
6014
6836
  const frontmatter = item.frontmatter ?? {};
6015
6837
  if (item.draft === true || frontmatter.draft === true) return true;
6016
6838
  if (item.unlisted === true || frontmatter.unlisted === true) return true;
6839
+ if (frontmatter.external === true) return true;
6017
6840
  if (!publishState?.enabled) return false;
6018
6841
  return !classifyPublishState({
6019
6842
  ...frontmatter,
@@ -6254,14 +7077,14 @@ function relatedMarkup(pages) {
6254
7077
  }
6255
7078
  function listPageContent(terms, base, urlName) {
6256
7079
  const items = terms.map((term) => listItem$1(siteHref$3(base, urlName, term.slug), term.label)).join("");
6257
- 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>`;
6258
7081
  }
6259
7082
  function termPageContent(term) {
6260
7083
  const items = [...term.pages].sort((left, right) => {
6261
7084
  const titleCmp = left.title.localeCompare(right.title);
6262
7085
  return titleCmp !== 0 ? titleCmp : left.routePaths.href.localeCompare(right.routePaths.href);
6263
7086
  }).map((page) => listItem$1(page.routePaths.href, page.title)).join("");
6264
- 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>`;
6265
7088
  }
6266
7089
  function displayTaxonomyName(name) {
6267
7090
  return name.charAt(0).toUpperCase() + name.slice(1);
@@ -6279,9 +7102,9 @@ function containedPath$2(outDir, ...segments) {
6279
7102
  return resolved;
6280
7103
  }
6281
7104
  function listItem$1(href, label) {
6282
- 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>`;
6283
7106
  }
6284
- function escapeHtml$2(value) {
7107
+ function escapeHtml$1(value) {
6285
7108
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
6286
7109
  }
6287
7110
  //#endregion
@@ -6555,18 +7378,21 @@ function resolveBlogOptions(value) {
6555
7378
  if (!value) return {
6556
7379
  enabled: false,
6557
7380
  authors: {},
6558
- pageSize: DEFAULT_PAGE_SIZE
7381
+ pageSize: DEFAULT_PAGE_SIZE,
7382
+ feeds: []
6559
7383
  };
6560
7384
  if (value === true) return {
6561
7385
  enabled: true,
6562
7386
  authors: {},
6563
- pageSize: DEFAULT_PAGE_SIZE
7387
+ pageSize: DEFAULT_PAGE_SIZE,
7388
+ feeds: []
6564
7389
  };
6565
7390
  return {
6566
7391
  enabled: true,
6567
7392
  collection: value.collection,
6568
7393
  authors: normalizeAuthors(value.authors),
6569
- pageSize: normalizePageSize(value.pageSize)
7394
+ pageSize: normalizePageSize(value.pageSize),
7395
+ feeds: normalizeFeeds(value.feeds)
6570
7396
  };
6571
7397
  }
6572
7398
  /**
@@ -6597,54 +7423,396 @@ function normalizePageSize(value) {
6597
7423
  if (typeof value === "number" && Number.isFinite(value) && value >= 1) return Math.floor(value);
6598
7424
  return DEFAULT_PAGE_SIZE;
6599
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
+ }
6600
7455
  //#endregion
6601
- //#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
6602
7502
  /**
6603
- * Deterministic blog reading-time estimates.
7503
+ * Safe-URL checks for configured external blog feeds.
6604
7504
  */
6605
- const LATIN_WORDS_PER_MINUTE = 200;
6606
- const CJK_CHARS_PER_MINUTE = 500;
6607
- function readingTimeMinutes(markdown) {
6608
- const body = stripInlineCode(stripFences(stripFrontmatter(markdown)));
6609
- let latin = 0;
6610
- let cjk = 0;
6611
- let latinRun = false;
6612
- for (const char of body) {
6613
- const code = char.codePointAt(0) ?? 0;
6614
- if (isCjkCodePoint(code)) {
6615
- cjk += 1;
6616
- latinRun = false;
6617
- continue;
6618
- }
6619
- if (isLatinWordChar(code)) {
6620
- if (!latinRun) {
6621
- latin += 1;
6622
- 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"
6623
7596
  }
7597
+ });
7598
+ if (isRedirect(response.status)) {
7599
+ current = resolveRedirect(current, response.headers.get("location"));
6624
7600
  continue;
6625
7601
  }
6626
- if (char === "'" || char === "’") continue;
6627
- latinRun = false;
7602
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
7603
+ assertFeedContentType(response.headers.get("content-type"));
7604
+ return readBoundedBody(response, maxBytes);
6628
7605
  }
6629
- if (latin === 0 && cjk === 0) return 0;
6630
- return Math.max(1, Math.ceil(latin / LATIN_WORDS_PER_MINUTE + cjk / CJK_CHARS_PER_MINUTE));
7606
+ throw new Error("too many redirects");
6631
7607
  }
6632
- function stripFrontmatter(markdown) {
6633
- if (!markdown.startsWith("---")) return markdown;
6634
- const match = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
6635
- 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");
6636
7613
  }
6637
- function stripFences(text) {
6638
- 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);
6639
7619
  }
6640
- function stripInlineCode(text) {
6641
- return text.replace(/`[^`\n]*`/g, " ");
7620
+ function defaultFetch(input, init) {
7621
+ return fetch(input, init);
6642
7622
  }
6643
- function isCjkCodePoint(code) {
6644
- 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;
6645
7625
  }
6646
- function isLatinWordChar(code) {
6647
- 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, "\\$&");
6648
7816
  }
6649
7817
  //#endregion
6650
7818
  //#region src/blog-html.ts
@@ -6660,13 +7828,13 @@ function isSafeBlogUrl(value) {
6660
7828
  return trimmed.toLowerCase().startsWith("https:");
6661
7829
  }
6662
7830
  function postMetaMarkup(meta) {
6663
- 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>`];
6664
7832
  if (meta.authors.length > 0) {
6665
7833
  const items = meta.authors.map((author) => authorMarkup(author)).join("");
6666
7834
  parts.push(`<ul class="ox-blog-meta__authors">${items}</ul>`);
6667
7835
  }
6668
7836
  if (meta.tags.length > 0) {
6669
- 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("");
6670
7838
  parts.push(`<ul class="ox-blog-meta__tags">${items}</ul>`);
6671
7839
  }
6672
7840
  return `<aside class="ox-blog-meta">${parts.join("")}</aside>\n`;
@@ -6674,25 +7842,25 @@ function postMetaMarkup(meta) {
6674
7842
  function indexPageContent(items, pager) {
6675
7843
  const list = items.map((item) => listItem(item)).join("");
6676
7844
  const links = [];
6677
- if (pager.newerHref) links.push(`<a href="${escapeHtml$1(pager.newerHref)}" rel="prev">Newer</a>`);
6678
- 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>`);
6679
7847
  return `<h1>Blog</h1><ul class="ox-blog">${list}</ul>${links.length > 0 ? `<nav class="ox-blog-pager">${links.join("")}</nav>` : ""}`;
6680
7848
  }
6681
7849
  function tagPageContent(label, items) {
6682
7850
  const list = items.map((item) => listItem(item)).join("");
6683
- 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>`;
6684
7852
  }
6685
7853
  function archiveIndexContent(years) {
6686
- 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>`;
6687
7855
  }
6688
7856
  function archiveYearContent(year, months, items) {
6689
- 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("");
6690
7858
  const posts = items.map((item) => listItem(item)).join("");
6691
- 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>`;
6692
7860
  }
6693
7861
  function archiveMonthContent(label, items) {
6694
7862
  const list = items.map((item) => listItem(item)).join("");
6695
- return `<h1>${escapeHtml$1(label)}</h1><ul class="ox-blog">${list}</ul>`;
7863
+ return `<h1>${escapeHtml(label)}</h1><ul class="ox-blog">${list}</ul>`;
6696
7864
  }
6697
7865
  function siteHref$2(base, ...segments) {
6698
7866
  const prefix = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
@@ -6706,17 +7874,18 @@ function containedPath$1(outDir, ...segments) {
6706
7874
  if (resolved === root || !resolved.startsWith(prefix)) return;
6707
7875
  return resolved;
6708
7876
  }
6709
- function escapeHtml$1(value) {
7877
+ function escapeHtml(value) {
6710
7878
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
6711
7879
  }
6712
7880
  function authorMarkup(author) {
6713
- const name = escapeHtml$1(author.name);
7881
+ const name = escapeHtml(author.name);
6714
7882
  const url = author.url?.trim();
6715
- 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>`;
6716
7884
  }
6717
7885
  function listItem(item) {
6718
- const time = item.dateLabel ? ` <time datetime="${escapeHtml$1(item.dateLabel)}">${escapeHtml$1(item.dateLabel)}</time>` : "";
6719
- 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>`;
6720
7889
  }
6721
7890
  //#endregion
6722
7891
  //#region src/blog-posts.ts
@@ -6822,7 +7991,8 @@ function toListItem(page) {
6822
7991
  return {
6823
7992
  title: page.title,
6824
7993
  href: page.routePaths.href,
6825
- 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 } : {}
6826
7996
  };
6827
7997
  }
6828
7998
  function resolvePostAuthors(frontmatter, map) {
@@ -6882,6 +8052,158 @@ function dateField(value) {
6882
8052
  if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
6883
8053
  }
6884
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
6885
8207
  //#region src/blog-pages.ts
6886
8208
  /**
6887
8209
  * Generated blog index, tag, and archive pages.
@@ -6893,7 +8215,7 @@ async function injectBlogPostMeta(input) {
6893
8215
  if (posts === void 0) return;
6894
8216
  const listedPaths = new Set(posts.map((page) => page.inputPath));
6895
8217
  for (const page of input.pages) {
6896
- if (!listedPaths.has(page.inputPath)) continue;
8218
+ if (!listedPaths.has(page.inputPath) || page.external === true) continue;
6897
8219
  const markdown = await readMarkdown(page.inputPath);
6898
8220
  page.transformedHtml = postMetaMarkup({
6899
8221
  authors: resolvePostAuthors(page.frontmatter, input.options.authors),
@@ -6926,8 +8248,9 @@ async function appendBlogPages(input) {
6926
8248
  input.errors.push(AMBIGUOUS_COLLECTION);
6927
8249
  return;
6928
8250
  }
6929
- const posts = selectBlogPosts(input.listedPages, input.options, input.srcDir, input.collections);
6930
- 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);
6931
8254
  for (const spec of blogPageSpecs(posts, input.options, input.outDir, input.base)) try {
6932
8255
  input.generatedPages.push({
6933
8256
  inputPath: spec.outputPath,
@@ -7022,6 +8345,14 @@ function blogPageSpecs(posts, options, outDir, base) {
7022
8345
  }
7023
8346
  return pages;
7024
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
+ }
7025
8356
  async function readMarkdown(inputPath) {
7026
8357
  try {
7027
8358
  return await node_fs_promises.readFile(inputPath, "utf8");
@@ -7534,59 +8865,6 @@ function generateSearchModule(options, indexPath) {
7534
8865
  return require_vitepress.importNapiModuleSync().generateSearchModuleFromOptions(toLocalSearchRuntimeOptions(options), indexPath);
7535
8866
  }
7536
8867
  //#endregion
7537
- //#region src/versions-html.ts
7538
- function versionSwitcherMarkup(links, badge) {
7539
- if (links.length === 0) return "";
7540
- const current = links.find((link) => link.current) ?? links[0];
7541
- const items = links.map((link) => {
7542
- const label = `${escapeHtml(link.label)}${badgeMarkup(link, badge)}`;
7543
- if (link.current || !isSafeHref(link.href)) return `<li><span aria-current="page">${label}</span></li>`;
7544
- return `<li><a href="${escapeHtml(link.href)}">${label}</a></li>`;
7545
- }).join("");
7546
- 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>`;
7547
- }
7548
- function versionBannerMarkup(kind) {
7549
- if (kind === "unreleased") return `<aside class="ox-version-banner ox-version-banner--unreleased" role="status">This documentation describes an unreleased version.</aside>`;
7550
- if (kind === "unmaintained") return `<aside class="ox-version-banner ox-version-banner--unmaintained" role="status">This documentation is unmaintained.</aside>`;
7551
- return "";
7552
- }
7553
- function injectVersionChrome(html, switcher, banner, searchFrom, searchTo) {
7554
- let next = html;
7555
- if (banner) next = next.replace(/<body([^>]*)>/, `<body$1>${banner}`);
7556
- if (switcher) {
7557
- if (next.includes("<div class=\"header-actions\">")) next = next.replace("<div class=\"header-actions\">", `<div class="header-actions">${switcher}`);
7558
- else if (next.includes("</header>")) next = next.replace("</header>", `${switcher}</header>`);
7559
- }
7560
- if (searchTo && isSafeHref(searchTo)) next = next.replace(/<html([^>]*)>/i, (match, attrs) => {
7561
- if (/\sdata-ox-search-index=/.test(attrs)) return match;
7562
- return `<html${attrs} data-ox-search-index="${escapeHtml(searchTo)}">`;
7563
- });
7564
- if (searchFrom && searchTo && searchFrom !== searchTo && isSafeHref(searchTo)) {
7565
- next = next.split(searchFrom).join(searchTo);
7566
- 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>`;
7567
- next = next.includes("</body>") ? next.replace("</body>", `${script}</body>`) : `${next}${script}`;
7568
- }
7569
- return next;
7570
- }
7571
- function searchIndexUrl(base, prefix) {
7572
- const root = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
7573
- return prefix ? `${root}${prefix}/search-index.json` : `${root}search-index.json`;
7574
- }
7575
- function isSafeHref(href) {
7576
- const trimmed = href.trim();
7577
- if (!trimmed || trimmed.startsWith("//")) return false;
7578
- const lower = trimmed.replace(/\s+/g, "").toLowerCase();
7579
- if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:")) return false;
7580
- return trimmed.startsWith("/") || trimmed.startsWith("./") || !trimmed.includes(":");
7581
- }
7582
- function escapeHtml(value) {
7583
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
7584
- }
7585
- function badgeMarkup(link, badge) {
7586
- if (!badge || !link.banner) return "";
7587
- return `<span class="ox-version-badge">${link.banner === "unreleased" ? "unreleased" : "unmaintained"}</span>`;
7588
- }
7589
- //#endregion
7590
8868
  //#region src/versions.ts
7591
8869
  /**
7592
8870
  * Opt-in documentation versioning: prefixes, snapshots, and header chrome.
@@ -7717,7 +8995,13 @@ async function writeSnapshotSearchIndex(input) {
7717
8995
  function applyVersionChrome(html, options, activeId, siblingPath, base, existingHrefs) {
7718
8996
  if (!options.enabled) return html;
7719
8997
  const active = options.entries.find((entry) => entry.id === activeId);
7720
- 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
+ })));
7721
9005
  }
7722
9006
  function sanitizePrefix(prefix) {
7723
9007
  const trimmed = prefix.trim().replace(/^\/+|\/+$/g, "");
@@ -7754,21 +9038,182 @@ function normalizeEntries(entries) {
7754
9038
  banner: normalizeBanner(entry.banner)
7755
9039
  });
7756
9040
  }
7757
- 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
+ }
7758
9152
  }
7759
- function normalizeBanner(value) {
7760
- 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}`;
7761
9160
  }
7762
- function siteHref$1(base, prefix, rest) {
7763
- const root = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
7764
- const parts = [prefix, rest].filter((part) => part && part !== "/");
7765
- 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}` : "";
7766
9167
  }
7767
- function relativeUrl(outputPath, outDir) {
7768
- const rel = node_path.posix.normalize(node_path.relative(node_path.resolve(outDir), node_path.resolve(outputPath)).replaceAll(node_path.sep, "/"));
7769
- if (rel.startsWith("..")) return "";
7770
- const dir = rel.endsWith("/index.html") ? rel.slice(0, -11) : rel.replace(/\.html$/, "");
7771
- 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);
7772
9217
  }
7773
9218
  //#endregion
7774
9219
  //#region src/resources-jpeg.ts
@@ -8811,129 +10256,23 @@ function coverCrop(image, width, height) {
8811
10256
  return cropImage(scaled, Math.max(0, Math.floor((scaled.width - width) / 2)), Math.max(0, Math.floor((scaled.height - height) / 2)), width, height);
8812
10257
  }
8813
10258
  //#endregion
8814
- //#region src/resources-process.ts
10259
+ //#region src/resources-write.ts
8815
10260
  /**
8816
- * Page-resource HTML rewriting and transform writes.
10261
+ * Transform cache writes for page resources.
8817
10262
  */
8818
- const IMG_TAG = /<img\b[^>]*>/gi;
8819
- const SRC_ATTR = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
8820
- async function processPageResources(input) {
8821
- if (!input.options.enabled) return {
8822
- html: input.html,
8823
- files: [],
8824
- errors: [],
8825
- fatal: []
8826
- };
8827
- const bundleRoot = node_path.dirname(input.inputPath);
8828
- const outputDir = node_path.dirname(input.outputPath);
8829
- const files = [];
8830
- const errors = [];
8831
- const fatal = [];
8832
- let html = input.html;
8833
- const tags = input.html.match(IMG_TAG) ?? [];
8834
- for (const tag of tags) {
8835
- const srcMatch = tag.match(SRC_ATTR);
8836
- const rawSrc = srcMatch?.[1] ?? srcMatch?.[2];
8837
- if (!rawSrc) continue;
8838
- const src = unescapeHtml(rawSrc);
8839
- const parsed = parseResourceSrc(src);
8840
- if (!parsed) continue;
8841
- const resolved = resolveBundlePath(parsed.pathname, bundleRoot, input.srcDir);
8842
- if (!resolved.ok) {
8843
- const message = `[ox-content] page resource ${JSON.stringify(src)} on ${input.inputPath} is outside the page bundle`;
8844
- errors.push(message);
8845
- fatal.push(message);
8846
- continue;
8847
- }
8848
- let stat;
8849
- try {
8850
- stat = await node_fs_promises.stat(resolved.absolute);
8851
- } catch {
8852
- const message = `[ox-content] missing page resource ${JSON.stringify(parsed.pathname)} on ${input.inputPath}`;
8853
- errors.push(message);
8854
- if (input.options.missing === "error") fatal.push(message);
8855
- continue;
8856
- }
8857
- const transformError = validateTransform(parsed.transform, input.options);
8858
- if (transformError) {
8859
- const message = `[ox-content] ${transformError} for ${JSON.stringify(src)} on ${input.inputPath}`;
8860
- errors.push(message);
8861
- fatal.push(message);
8862
- continue;
8863
- }
8864
- const hasTransform = hasPixelOrFormatTransform(parsed.transform);
8865
- const outputName = hasTransform ? transformedFileName(parsed.pathname, parsed.transform, resourceCacheKey(resolved.absolute, stat.mtimeMs, parsed.transform)) : node_path.basename(resolved.absolute);
8866
- const outputFile = node_path.join(outputDir, outputName);
8867
- try {
8868
- if (hasTransform) await writeTransformedResource({
8869
- sourcePath: resolved.absolute,
8870
- outputFile,
8871
- cacheDir: input.cacheDir,
8872
- mtimeMs: stat.mtimeMs,
8873
- transform: parsed.transform
8874
- });
8875
- else {
8876
- await node_fs_promises.mkdir(outputDir, { recursive: true });
8877
- await node_fs_promises.copyFile(resolved.absolute, outputFile);
8878
- }
8879
- files.push(outputFile);
8880
- const rewritten = tag.replace(rawSrc, escapeAttribute(outputName));
8881
- html = html.replace(tag, rewritten);
8882
- } catch (error) {
8883
- const detail = error instanceof Error ? error.message : String(error);
8884
- const message = `[ox-content] failed to process page resource ${JSON.stringify(src)} on ${input.inputPath}: ${detail}`;
8885
- errors.push(message);
8886
- fatal.push(message);
8887
- }
8888
- }
8889
- return {
8890
- html,
8891
- files,
8892
- errors,
8893
- fatal
8894
- };
8895
- }
8896
- function resolveBundlePath(pathname, bundleRoot, contentRoot) {
8897
- if (node_path.isAbsolute(pathname) || pathname.includes("\0")) return { ok: false };
8898
- const absolute = node_path.resolve(bundleRoot, pathname);
8899
- if (!isInsideRoot$1(bundleRoot, absolute) || !isInsideRoot$1(contentRoot, absolute)) return { ok: false };
8900
- return {
8901
- ok: true,
8902
- absolute
8903
- };
8904
- }
8905
- function validateTransform(transform, options) {
8906
- if (transform.width && options.widths.length > 0 && !options.widths.includes(transform.width)) return `width ${transform.width} is not in resources.widths`;
8907
- if (transform.format && !options.formats.includes(transform.format)) return `format ${transform.format} is not in resources.formats`;
8908
- }
8909
- function hasPixelOrFormatTransform(transform) {
8910
- return Boolean(transform.width || transform.height || transform.crop || transform.format);
8911
- }
8912
- function transformedFileName(pathname, transform, cacheKey) {
8913
- const stem = node_path.basename(pathname).replace(/\.[^.]+$/, "") || "resource";
8914
- const ext = outputExtension(pathname, transform.format);
8915
- return `${stem}.${cacheKey.slice(0, 12)}.${ext}`;
8916
- }
8917
- function outputExtension(pathname, format) {
8918
- if (format === "jpeg") return "jpg";
8919
- if (format) return format;
8920
- const ext = node_path.extname(pathname).slice(1).toLowerCase();
8921
- return ext === "jpeg" ? "jpg" : ext || "png";
8922
- }
8923
- async function writeTransformedResource(input) {
10263
+ async function ensureTransformedCache(input) {
8924
10264
  const key = resourceCacheKey(input.sourcePath, input.mtimeMs, input.transform);
8925
10265
  const ext = node_path.extname(input.outputFile);
8926
10266
  const cacheFile = node_path.join(input.cacheDir, `${key}${ext}`);
8927
10267
  try {
8928
- await node_fs_promises.copyFile(cacheFile, input.outputFile);
8929
- return;
10268
+ await node_fs_promises.access(cacheFile);
10269
+ return cacheFile;
8930
10270
  } catch {}
8931
10271
  const output = transformResourceBuffer(await node_fs_promises.readFile(input.sourcePath), input.sourcePath, input.transform);
8932
10272
  if (output.length > 8388608) throw new Error("transform produced an oversized file");
8933
- await node_fs_promises.mkdir(node_path.dirname(input.outputFile), { recursive: true });
8934
10273
  await node_fs_promises.mkdir(input.cacheDir, { recursive: true });
8935
10274
  await node_fs_promises.writeFile(cacheFile, output);
8936
- await node_fs_promises.writeFile(input.outputFile, output);
10275
+ return cacheFile;
8937
10276
  }
8938
10277
  function transformResourceBuffer(source, sourcePath, transform) {
8939
10278
  const needsPixels = Boolean(transform.width || transform.height || transform.crop);
@@ -8976,11 +10315,221 @@ function formatFromPath(filePath) {
8976
10315
  const ext = node_path.extname(filePath).slice(1).toLowerCase();
8977
10316
  return ext === "jpg" ? "jpeg" : ext;
8978
10317
  }
8979
- function unescapeHtml(value) {
8980
- 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
+ };
8981
10376
  }
8982
- function escapeAttribute(value) {
8983
- 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
+ }
8984
10533
  }
8985
10534
  //#endregion
8986
10535
  //#region src/resources.ts
@@ -9015,19 +10564,22 @@ function resolveResourcesOptions(value) {
9015
10564
  enabled: false,
9016
10565
  formats: [...DEFAULT_FORMATS],
9017
10566
  widths: [],
9018
- missing: "error"
10567
+ missing: "error",
10568
+ dedupe: false
9019
10569
  };
9020
10570
  if (value === true) return {
9021
10571
  enabled: true,
9022
10572
  formats: [...DEFAULT_FORMATS],
9023
10573
  widths: [],
9024
- missing: "error"
10574
+ missing: "error",
10575
+ dedupe: false
9025
10576
  };
9026
10577
  return {
9027
10578
  enabled: true,
9028
10579
  formats: normalizeFormats(value.formats),
9029
10580
  widths: normalizeWidths(value.widths),
9030
- missing: value.missing === "warn" ? "warn" : "error"
10581
+ missing: value.missing === "warn" ? "warn" : "error",
10582
+ dedupe: value.dedupe === true
9031
10583
  };
9032
10584
  }
9033
10585
  /**
@@ -9278,6 +10830,7 @@ function resolveSsgOptions(ssg) {
9278
10830
  localeSwitcher: false,
9279
10831
  a11y: false,
9280
10832
  pageChrome: false,
10833
+ markdownSource: resolveMarkdownSourceOptions(void 0),
9281
10834
  notFound: resolveNotFoundOptions(void 0),
9282
10835
  team: resolveTeamOptions(void 0),
9283
10836
  blog: resolveBlogOptions(void 0),
@@ -9299,6 +10852,7 @@ function resolveSsgOptions(ssg) {
9299
10852
  localeSwitcher: false,
9300
10853
  a11y: false,
9301
10854
  pageChrome: false,
10855
+ markdownSource: resolveMarkdownSourceOptions(void 0),
9302
10856
  notFound: resolveNotFoundOptions(void 0),
9303
10857
  team: resolveTeamOptions(void 0),
9304
10858
  blog: resolveBlogOptions(void 0),
@@ -9328,6 +10882,7 @@ function resolveSsgOptions(ssg) {
9328
10882
  localeSwitcher: resolveLocaleSwitcherOption(ssg.localeSwitcher),
9329
10883
  a11y: resolveA11yOption(ssg.a11y),
9330
10884
  pageChrome: require_vitepress.resolvePageChromeOption(ssg.pageChrome),
10885
+ markdownSource: resolveMarkdownSourceOptions(ssg.markdownSource),
9331
10886
  notFound: resolveNotFoundOptions(ssg.notFound),
9332
10887
  team: resolveTeamOptions(ssg.team),
9333
10888
  blog: resolveBlogOptions(ssg.blog),
@@ -9500,7 +11055,7 @@ function localeCodesFor(locales) {
9500
11055
  async function generateHtmlPage(pageData, navGroups, siteName, base, ogImage, theme, locale, availableLocales, pagination = false, readerChrome = false, breadcrumbs = false, localeSwitcher = false, localePaths, a11y = false, team = {
9501
11056
  enabled: false,
9502
11057
  members: []
9503
- }, pageChrome = false, breadcrumbRootHref, jsonLd = false, siteUrl, headValidation = false) {
11058
+ }, pageChrome = false, breadcrumbRootHref, jsonLd = false, siteUrl, headValidation = false, defaultLocale) {
9504
11059
  const mod = await require_vitepress.importNapiModule();
9505
11060
  const tocForRust = pageData.toc.map(toRustTocEntry);
9506
11061
  const navGroupsForRust = convertNavGroupsForRust(navGroups);
@@ -9584,7 +11139,11 @@ async function generateHtmlPage(pageData, navGroups, siteName, base, ogImage, th
9584
11139
  });
9585
11140
  const html = typeof result === "string" ? result : result.html;
9586
11141
  reportHeadDiagnostics(typeof result === "string" ? [] : result.diagnostics ?? [], headValidation);
9587
- return html;
11142
+ return injectSearchLocaleFilters(html, {
11143
+ locales: availableLocales ?? [],
11144
+ current: locale,
11145
+ defaultLocale: defaultLocale ?? availableLocales?.[0]?.code ?? "en"
11146
+ });
9588
11147
  }
9589
11148
  async function externalizeSharedPageAssets(pages, outDir, base) {
9590
11149
  const optimized = (await require_vitepress.importNapiModule()).externalizeSsgAssets(pages, outDir, base);
@@ -9669,6 +11228,7 @@ async function buildSsg(options, root) {
9669
11228
  applyPermalinkRoutes(context, collected);
9670
11229
  errors.push(...collected.errors);
9671
11230
  const { outputPages, listedPages } = applyPublishState(context, collected);
11231
+ context.markdownSourcePages.push(...outputPages);
9672
11232
  remapPermalinkNav(context, listedPages);
9673
11233
  await applyPageResources(context, outputPages, generatedFiles, errors);
9674
11234
  await generateOgImageAssets(context, collected, generatedFiles, errors);
@@ -9746,7 +11306,8 @@ async function createBuildSsgContext(options, root, srcDir, outDir, markdownFile
9746
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)),
9747
11307
  siteName: await resolveSiteName$1(root, ssgOptions),
9748
11308
  shouldGenerateOgImages: shouldGenerateOgImages(options),
9749
- 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
9750
11311
  };
9751
11312
  }
9752
11313
  /**
@@ -9776,6 +11337,7 @@ async function applyPageResources(context, pages, generatedFiles, errors) {
9776
11337
  if (!options?.enabled) return;
9777
11338
  const cacheDir = path.join(context.root, ".cache", "ox-content-resources");
9778
11339
  const fatal = [];
11340
+ const dedupeStore = options.dedupe ? createResourceDedupeStore() : void 0;
9779
11341
  for (const page of pages) {
9780
11342
  const processed = await processPageResources({
9781
11343
  html: page.transformedHtml,
@@ -9783,7 +11345,10 @@ async function applyPageResources(context, pages, generatedFiles, errors) {
9783
11345
  outputPath: page.routePaths.outputPath,
9784
11346
  srcDir: context.srcDir,
9785
11347
  options,
9786
- cacheDir
11348
+ cacheDir,
11349
+ outDir: context.outDir,
11350
+ base: context.base,
11351
+ dedupeStore
9787
11352
  });
9788
11353
  page.transformedHtml = processed.html;
9789
11354
  generatedFiles.push(...processed.files);
@@ -9857,7 +11422,8 @@ function applyPublishState(context, collected) {
9857
11422
  };
9858
11423
  }
9859
11424
  async function transformSsgPage(context, inputPath) {
9860
- 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, {
9861
11427
  convertMdLinks: true,
9862
11428
  baseUrl: context.base,
9863
11429
  sourcePath: inputPath
@@ -9867,11 +11433,12 @@ async function transformSsgPage(context, inputPath) {
9867
11433
  const title = extractTitle$1(transformedHtml, frontmatter);
9868
11434
  return {
9869
11435
  inputPath,
11436
+ source: content,
9870
11437
  routePaths: getRoutePaths(inputPath, context.srcDir, context.outDir, context.base, context.ssgOptions.extension, context.ssgOptions.siteUrl),
9871
11438
  transformedHtml,
9872
11439
  title,
9873
11440
  description: frontmatter.description,
9874
- 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,
9875
11442
  contributors: contributorsForPage(context, inputPath),
9876
11443
  frontmatter,
9877
11444
  toc: result.toc
@@ -9962,17 +11529,18 @@ async function generateHtmlPages(context, pageResults, collected, errors) {
9962
11529
  async function renderSsgPage(context, pageResult, collected, allPageResults) {
9963
11530
  const { ogImageUrlMap } = collected;
9964
11531
  const pageOgImage = context.shouldGenerateOgImages && ogImageUrlMap.has(pageResult.inputPath) ? ogImageUrlMap.get(pageResult.inputPath) : context.ssgOptions.ogImage;
11532
+ const markdownSource = pageMarkdownSourceHref(context, pageResult);
9965
11533
  if (context.ssgOptions.render) {
9966
11534
  const nav = context.versionNavigation ? rewriteVersionedNavGroups(context.navItems, context.versionNavigation) : context.navItems;
9967
- return renderPage(toThemePageData(pageResult), {
11535
+ return applyMarkdownSourceAlternate(context, renderPage(toThemePageData(pageResult, markdownSource), {
9968
11536
  theme: context.ssgOptions.render,
9969
11537
  siteName: context.siteName,
9970
11538
  base: context.base,
9971
11539
  nav,
9972
- pages: allPageResults.map(toThemePageData)
9973
- });
11540
+ pages: allPageResults.map((page) => toThemePageData(page, pageMarkdownSourceHref(context, page)))
11541
+ }), markdownSource);
9974
11542
  }
9975
- if (context.ssgOptions.bare) return generateBarePage({
11543
+ if (context.ssgOptions.bare) return applyMarkdownSourceAlternate(context, generateBarePage({
9976
11544
  title: pageResult.title,
9977
11545
  content: pageResult.transformedHtml,
9978
11546
  lang: context.ssgOptions.lang ?? getPageLocale(pageResult.routePaths.urlPath, context.options.i18n),
@@ -9983,7 +11551,7 @@ async function renderSsgPage(context, pageResult, collected, allPageResults) {
9983
11551
  head: context.ssgOptions.head,
9984
11552
  bodyStart: context.ssgOptions.bodyStart,
9985
11553
  bodyEnd: context.ssgOptions.bodyEnd
9986
- });
11554
+ }), markdownSource);
9987
11555
  const pageData = createSsgPageData(pageResult);
9988
11556
  const versionNavigation = context.versionNavigation;
9989
11557
  if (versionNavigation) {
@@ -10024,10 +11592,18 @@ async function renderSsgPage(context, pageResult, collected, allPageResults) {
10024
11592
  base: context.base,
10025
11593
  roots: versionNavigation ? versionedLocaleRoots(versionNavigation, i18n.locales, i18n.defaultLocale, i18n.hideDefaultLocale) : void 0
10026
11594
  }) : void 0;
10027
- 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 ?? {
10028
11596
  enabled: false,
10029
11597
  members: []
10030
- }, context.ssgOptions.pageChrome, versionNavigation?.root.href, context.ssgOptions.jsonLd, context.ssgOptions.siteUrl, context.ssgOptions.headValidation);
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);
10031
11607
  }
10032
11608
  function rewritePagerOverride(pager, context) {
10033
11609
  return pager?.href ? {
@@ -10036,7 +11612,7 @@ function rewritePagerOverride(pager, context) {
10036
11612
  } : pager;
10037
11613
  }
10038
11614
  /** Maps an internal page result onto the theme renderer's page shape. */
10039
- function toThemePageData(pageResult) {
11615
+ function toThemePageData(pageResult, markdownSource) {
10040
11616
  return {
10041
11617
  title: pageResult.title,
10042
11618
  description: pageResult.description,
@@ -10046,6 +11622,7 @@ function toThemePageData(pageResult) {
10046
11622
  contributors: pageResult.contributors,
10047
11623
  path: pageResult.inputPath,
10048
11624
  url: pageResult.routePaths.href,
11625
+ markdownSource,
10049
11626
  frontmatter: pageResult.frontmatter,
10050
11627
  layout: typeof pageResult.frontmatter.layout === "string" ? pageResult.frontmatter.layout : void 0
10051
11628
  };
@@ -10158,6 +11735,7 @@ async function applyDocumentationVersions(generatedPages, context, errors) {
10158
11735
  ...page.routePaths,
10159
11736
  ...prefixRoutePaths(page.routePaths, entry.prefix, context.outDir, context.base)
10160
11737
  };
11738
+ context.markdownSourcePages.push(...outputPages);
10161
11739
  snapContext.versionNavigation = createVersionNavigationContext({
10162
11740
  prefix: entry.prefix,
10163
11741
  base: context.base,
@@ -10270,6 +11848,20 @@ async function writeGeneratedPages(generatedPages, context, generatedFiles, list
10270
11848
  errors.push(feeds.warning);
10271
11849
  console.warn(feeds.warning);
10272
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);
10273
11865
  }
10274
11866
  /** Turns an SSG `urlPath` (`guide` or `/`) into a same-origin dest (`/guide`). */
10275
11867
  function sitePathFromUrlPath(urlPath) {
@@ -10283,6 +11875,7 @@ function sitemapPages(context, listedPages, outputPages) {
10283
11875
  loc: canonicalPageUrl(context, page.routePaths.urlPath) ?? "",
10284
11876
  title: page.title,
10285
11877
  description: page.description,
11878
+ lastUpdated: page.lastUpdated,
10286
11879
  draft: page.frontmatter.draft === true,
10287
11880
  unlisted: Boolean(context.options.publishState?.enabled) && !listedPaths.has(page.inputPath)
10288
11881
  }));
@@ -10380,7 +11973,8 @@ function createDevServerCache() {
10380
11973
  navGroups: null,
10381
11974
  localePages: null,
10382
11975
  pages: /* @__PURE__ */ new Map(),
10383
- siteName: null
11976
+ siteName: null,
11977
+ markdownSourceIndex: null
10384
11978
  };
10385
11979
  }
10386
11980
  /**
@@ -10389,6 +11983,7 @@ function createDevServerCache() {
10389
11983
  function invalidateNavCache(cache) {
10390
11984
  cache.navGroups = null;
10391
11985
  cache.localePages = null;
11986
+ cache.markdownSourceIndex = null;
10392
11987
  cache.pages.clear();
10393
11988
  }
10394
11989
  /**
@@ -10396,6 +11991,7 @@ function invalidateNavCache(cache) {
10396
11991
  */
10397
11992
  function invalidatePageCache(cache, filePath) {
10398
11993
  cache.pages.delete(filePath);
11994
+ cache.markdownSourceIndex = null;
10399
11995
  }
10400
11996
  /**
10401
11997
  * Resolve site name from options or package.json.
@@ -10485,13 +12081,35 @@ async function renderPage$1(filePath, options, navGroups, siteName, base, root,
10485
12081
  pages: localePages,
10486
12082
  base
10487
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;
10488
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 ?? {
10489
12094
  enabled: false,
10490
12095
  members: []
10491
- }, options.ssg.pageChrome, void 0, options.ssg.jsonLd, options.ssg.siteUrl, options.ssg.headValidation);
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);
10492
12098
  html = injectViteHmrClient(html);
10493
12099
  return html;
10494
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
+ }
10495
12113
  /**
10496
12114
  * Create the dev server middleware for SSG page serving.
10497
12115
  */
@@ -10504,6 +12122,19 @@ function createDevServerMiddleware(options, root, cache) {
10504
12122
  let routeUrl = url;
10505
12123
  if (base !== "/" && routeUrl.startsWith(base)) routeUrl = "/" + routeUrl.slice(base.length);
10506
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
+ }
10507
12138
  const filePath = await resolveMarkdownFile(routeUrl, srcDir, options.extensions);
10508
12139
  if (!filePath) return next();
10509
12140
  try {
@@ -11020,6 +12651,43 @@ function resolveCardOptions(options) {
11020
12651
  return { enabled: options.enabled ?? true };
11021
12652
  }
11022
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
11023
12691
  //#region src/include-options.ts
11024
12692
  function resolveIncludeOptions(options) {
11025
12693
  if (!options) return { enabled: false };
@@ -11594,6 +13262,12 @@ function createFrameworkMarkdownOptions(options) {
11594
13262
  },
11595
13263
  attrs: { enabled: false },
11596
13264
  badges: { enabled: false },
13265
+ magicLinks: {
13266
+ enabled: false,
13267
+ aliases: {},
13268
+ favicon: false,
13269
+ imageOverrides: []
13270
+ },
11597
13271
  containers: {
11598
13272
  enabled: false,
11599
13273
  types: {}
@@ -12593,7 +14267,7 @@ function createSsgPlugin(resolvedOptions, getRoot, ssgDevCache) {
12593
14267
  for (const error of result.errors) console.warn(`[ox-content] ${error}`);
12594
14268
  } catch (err) {
12595
14269
  console.error("[ox-content] SSG build failed:", err);
12596
- if (err instanceof PageResourceError) throw err;
14270
+ if (err instanceof PageResourceError || err instanceof BlogFeedError) throw err;
12597
14271
  }
12598
14272
  }
12599
14273
  };
@@ -12708,6 +14382,7 @@ function resolveOptions(options) {
12708
14382
  gfm: options.gfm ?? true,
12709
14383
  mdx: options.mdx,
12710
14384
  footnotes: options.footnotes ?? true,
14385
+ semanticFootnotes: options.semanticFootnotes ?? false,
12711
14386
  tables: options.tables ?? true,
12712
14387
  taskLists: options.taskLists ?? true,
12713
14388
  strikethrough: options.strikethrough ?? true,
@@ -12718,6 +14393,7 @@ function resolveOptions(options) {
12718
14393
  emojiShortcodes: resolveEmojiShortcodeOptions(options.emojiShortcodes),
12719
14394
  attrs: resolveAttrsOptions(options.attrs),
12720
14395
  badges: resolveBadgeOptions(options.badges),
14396
+ magicLinks: resolveMagicLinkOptions(options.magicLinks),
12721
14397
  containers: resolveContainerOptions(options.containers),
12722
14398
  images: resolveImageOptions(options.images),
12723
14399
  codeImports: resolveCodeImportOptions(options.codeImports),
@@ -12737,6 +14413,7 @@ function resolveOptions(options) {
12737
14413
  frontmatter: options.frontmatter ?? true,
12738
14414
  toc: options.toc ?? true,
12739
14415
  tocMaxDepth: options.tocMaxDepth ?? 3,
14416
+ headingPermalinks: resolveHeadingPermalinksOptions(options.headingPermalinks),
12740
14417
  ogImage: options.ogImage ?? false,
12741
14418
  ogImageOptions: resolveOgImageOptions(options.ogImageOptions),
12742
14419
  transformers: options.transformers ?? [],
@@ -12978,9 +14655,9 @@ function resolveCodeAnnotationsOptions(options) {
12978
14655
  /**
12979
14656
  * Generates virtual module content.
12980
14657
  */
12981
- function generateVirtualModule(path$5, options) {
12982
- if (path$5 === "config") return `export default ${JSON.stringify(options)};`;
12983
- 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") {
12984
14661
  const base = normalizeRuntimeBase(options.base);
12985
14662
  return `
12986
14663
  export const base = ${JSON.stringify(base)};
@@ -13027,6 +14704,7 @@ function normalizeRuntimeBase(base) {
13027
14704
  return withLeading.endsWith("/") ? withLeading : `${withLeading}/`;
13028
14705
  }
13029
14706
  //#endregion
14707
+ exports.BlogFeedError = BlogFeedError;
13030
14708
  exports.DEFAULT_HTML_TEMPLATE = DEFAULT_HTML_TEMPLATE;
13031
14709
  exports.DEFAULT_MARKDOWN_EXTENSIONS = DEFAULT_MARKDOWN_EXTENSIONS;
13032
14710
  exports.DefaultTheme = DefaultTheme;
@@ -13135,10 +14813,12 @@ exports.resolveFeedsOptions = resolveFeedsOptions;
13135
14813
  exports.resolveFileTreeOptions = resolveFileTreeOptions;
13136
14814
  exports.resolveHeadValidation = resolveHeadValidation;
13137
14815
  exports.resolveHeaderNavItems = require_vitepress.resolveHeaderNavItems;
14816
+ exports.resolveHeadingPermalinksOptions = resolveHeadingPermalinksOptions;
13138
14817
  exports.resolveI18nOptions = resolveI18nOptions;
13139
14818
  exports.resolveImageOptions = resolveImageOptions;
13140
14819
  exports.resolveIncludeOptions = resolveIncludeOptions;
13141
14820
  exports.resolveLocaleLabel = require_vitepress.resolveLocaleLabel;
14821
+ exports.resolveMarkdownSourceOptions = resolveMarkdownSourceOptions;
13142
14822
  exports.resolveMathOptions = resolveMathOptions;
13143
14823
  exports.resolveMdxForFilePath = resolveMdxForFilePath;
13144
14824
  exports.resolveNotFoundOptions = resolveNotFoundOptions;