@ox-content/vite-plugin 3.0.0-alpha.5 → 3.0.0-alpha.7

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.mjs CHANGED
@@ -11,14 +11,15 @@ import fs, { createReadStream, existsSync, readFileSync } from "node:fs";
11
11
  import * as path$1 from "node:path";
12
12
  import path, { dirname, extname, join, relative, resolve, sep } from "node:path";
13
13
  import * as fs$2 from "node:fs/promises";
14
- import { access, copyFile, cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
14
+ import { access, copyFile, cp, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
15
15
  import { Buffer as Buffer$1 } from "node:buffer";
16
+ import { createHash, randomBytes } from "node:crypto";
16
17
  import { tmpdir } from "node:os";
17
18
  import { promisify } from "node:util";
18
19
  import { execFile, spawn } from "node:child_process";
19
20
  import * as fs$3 from "fs/promises";
20
21
  import * as crypto from "crypto";
21
- import { createHash } from "node:crypto";
22
+ import { lookup } from "node:dns/promises";
22
23
  import { deflateSync, inflateSync } from "node:zlib";
23
24
  import * as fs$1 from "fs";
24
25
  import { glob } from "glob";
@@ -449,7 +450,8 @@ async function transformPm(html, options) {
449
450
  * YouTube Plugin - Privacy-enhanced iframe embedding
450
451
  *
451
452
  * Transforms <YouTube> components into responsive iframe embeds using
452
- * youtube-nocookie.com for enhanced privacy.
453
+ * youtube-nocookie.com for enhanced privacy. A digits-only `start` attribute
454
+ * becomes `?start=` on the iframe URL.
453
455
  *
454
456
  * The HTML rewrite is performed in Rust (`transformYoutubeEmbeds` in
455
457
  * @ox-content/napi), replacing the previous rehype parse/stringify
@@ -480,7 +482,7 @@ async function transformYouTube(html, options) {
480
482
  }
481
483
  //#endregion
482
484
  //#region src/plugins/twitter/url.ts
483
- const STATUS_PATH = /^\/(?:[^/]+|i\/web)\/status\/(\d+)(?:\/.*)?$/;
485
+ const STATUS_PATH$1 = /^\/(?:[^/]+|i\/web)\/status\/(\d+)(?:\/.*)?$/;
484
486
  function createSyndicationToken(id) {
485
487
  return (Number(id) / 0x38d7ea4c68000 * Math.PI).toString(36).replaceAll(/(0+|\.)/g, "");
486
488
  }
@@ -494,7 +496,7 @@ function parseTweetReference(value) {
494
496
  const url = new URL(trimmed);
495
497
  const hostname = url.hostname.toLowerCase().replace(/^(?:www\.|mobile\.)/, "");
496
498
  if (url.protocol !== "https:" || hostname !== "x.com" && hostname !== "twitter.com") return null;
497
- const match = url.pathname.match(STATUS_PATH);
499
+ const match = url.pathname.match(STATUS_PATH$1);
498
500
  if (!match) return null;
499
501
  const screenName = url.pathname.startsWith("/i/web/status/") ? "i/web" : url.pathname.split("/")[1];
500
502
  return {
@@ -505,10 +507,164 @@ function parseTweetReference(value) {
505
507
  return null;
506
508
  }
507
509
  }
508
- function referenceFromAttributes(attributes) {
510
+ function tweetElementAttributes(attributes) {
509
511
  const values = /* @__PURE__ */ new Map();
510
- 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] ?? "");
511
- return parseTweetReference(values.get("url") ?? values.get("href") ?? values.get("id") ?? "");
512
+ 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] ?? "");
513
+ const appearance = values.get("appearance");
514
+ return {
515
+ reference: parseTweetReference(values.get("url") ?? values.get("href") ?? values.get("id") ?? ""),
516
+ appearance: appearance === "full" || appearance === "compact" ? appearance : void 0
517
+ };
518
+ }
519
+ //#endregion
520
+ //#region src/plugins/twitter/validate.ts
521
+ const SCREEN_NAME = /^[A-Za-z0-9_]{1,15}$/;
522
+ const STATUS_ID = /^\d+$/;
523
+ const STATUS_PATH = /(?:x\.com|twitter\.com)\/(?:[^/]+|i\/web)\/status\/(\d+)/i;
524
+ const TCO = /^https?:\/\/t\.co\/[A-Za-z0-9]+$/i;
525
+ function isTweetData(data) {
526
+ return isTweetBodyData(data);
527
+ }
528
+ function parseTweetData(data) {
529
+ return isTweetData(data) ? normalizeTweetData(data) : null;
530
+ }
531
+ function isTweetBodyData(data) {
532
+ if (!data || typeof data !== "object") return false;
533
+ const value = data;
534
+ return typeof value.text === "string" && isTweetUser(value.user);
535
+ }
536
+ function isTweetUser(value) {
537
+ if (!value || typeof value !== "object") return false;
538
+ const user = value;
539
+ return typeof user.name === "string" && typeof user.screen_name === "string";
540
+ }
541
+ function normalizeTweetData(data) {
542
+ const handle = sanitizeScreenName(data.in_reply_to_screen_name);
543
+ return {
544
+ ...data,
545
+ quoted_tweet: isTweetBodyData(data.quoted_tweet) ? stripNestedQuote(data.quoted_tweet) : void 0,
546
+ in_reply_to_screen_name: handle,
547
+ in_reply_to_status_id_str: handle ? sanitizeStatusId(data.in_reply_to_status_id_str) : void 0
548
+ };
549
+ }
550
+ function stripNestedQuote(data) {
551
+ const { quoted_tweet: _nested, ...quoted } = data;
552
+ return quoted;
553
+ }
554
+ function sanitizeScreenName(value) {
555
+ return value && SCREEN_NAME.test(value) ? value : void 0;
556
+ }
557
+ function sanitizeStatusId(value) {
558
+ return value && STATUS_ID.test(value) ? value : void 0;
559
+ }
560
+ function quotedPermalink(quoted) {
561
+ const id = sanitizeStatusId(quoted.id_str);
562
+ if (!id) return void 0;
563
+ return `https://x.com/${sanitizeScreenName(quoted.user.screen_name) ?? "i/web"}/status/${id}`;
564
+ }
565
+ function replyPermalink(data) {
566
+ const handle = sanitizeScreenName(data.in_reply_to_screen_name);
567
+ if (!handle) return void 0;
568
+ const id = sanitizeStatusId(data.in_reply_to_status_id_str);
569
+ return id ? `https://x.com/${handle}/status/${id}` : `https://x.com/${handle}`;
570
+ }
571
+ function visibleTextRange(data, omitTrailingQuoteUrl = false) {
572
+ const start = Math.max(0, data.display_text_range?.[0] ?? 0);
573
+ let end = Math.min(data.text.length, data.display_text_range?.[1] ?? data.text.length);
574
+ if (!omitTrailingQuoteUrl || start >= end) return [start, end];
575
+ const quoted = "quoted_tweet" in data ? data.quoted_tweet : void 0;
576
+ for (const entity of data.entities?.urls ?? []) {
577
+ const indices = entity.indices;
578
+ if (!indices || !isQuoteUrlEntity(entity, quoted)) continue;
579
+ const [entityStart, entityEnd] = indices;
580
+ if (entityStart >= start && isTrailingEntity(entityEnd, end, data.text)) end = Math.min(end, entityStart);
581
+ }
582
+ while (end > start && isUtf16Space(data.text, end - 1)) end -= 1;
583
+ return [start, end];
584
+ }
585
+ function isQuoteUrlEntity(entity, quoted) {
586
+ for (const href of [
587
+ entity.expanded_url,
588
+ entity.url,
589
+ entity.display_url
590
+ ]) {
591
+ if (!href) continue;
592
+ const match = href.match(STATUS_PATH);
593
+ if (match) return !quoted?.id_str || match[1] === quoted.id_str;
594
+ if (TCO.test(href)) return true;
595
+ }
596
+ return false;
597
+ }
598
+ function isTrailingEntity(entityEnd, rangeEnd, text) {
599
+ if (entityEnd >= rangeEnd || entityEnd === text.length) return true;
600
+ return entityEnd > 0 && /^[\t\n\r ]*$/.test(text.slice(entityEnd, rangeEnd));
601
+ }
602
+ function isUtf16Space(text, index) {
603
+ const char = text[index];
604
+ return char === " " || char === "\n" || char === " " || char === "\r";
605
+ }
606
+ //#endregion
607
+ //#region src/plugins/twitter/video.ts
608
+ const VIDEO_HOSTS = /* @__PURE__ */ new Set(["pbs.twimg.com", "video.twimg.com"]);
609
+ function selectBestMp4Url(variants) {
610
+ const candidates = (variants ?? []).filter((variant) => isVideoMp4Type(variant.content_type) && isAllowedVideoUrl(variant.url));
611
+ if (candidates.length === 0) return void 0;
612
+ return candidates.reduce((best, variant) => {
613
+ const bestBitrate = best.bitrate ?? Number.NEGATIVE_INFINITY;
614
+ const nextBitrate = variant.bitrate ?? Number.NEGATIVE_INFINITY;
615
+ if (nextBitrate > bestBitrate) return variant;
616
+ if (nextBitrate === bestBitrate && variant.url < best.url) return variant;
617
+ return best;
618
+ }).url;
619
+ }
620
+ function isVideoMp4Type(value) {
621
+ return (value ?? "").split(";", 1)[0].trim().toLowerCase() === "video/mp4";
622
+ }
623
+ function isAllowedVideoUrl(value) {
624
+ if (!value) return false;
625
+ try {
626
+ const url = new URL(value);
627
+ return url.protocol === "https:" && VIDEO_HOSTS.has(url.hostname.toLowerCase());
628
+ } catch {
629
+ return false;
630
+ }
631
+ }
632
+ async function downloadVideoAsset(source, basename, options) {
633
+ if (!isAllowedVideoUrl(source)) return void 0;
634
+ const filename = `${sanitizeFilename(basename)}.mp4`;
635
+ const output = path.join(options.mediaOutputDir, filename);
636
+ const publicPath = joinPublicPath$1(options.mediaPublicPath, filename);
637
+ try {
638
+ await access(output);
639
+ return publicPath;
640
+ } catch {}
641
+ const controller = new AbortController();
642
+ const timeout = setTimeout(() => controller.abort(), options.timeout);
643
+ try {
644
+ const response = await fetch(source, {
645
+ headers: { Accept: "video/mp4" },
646
+ signal: controller.signal
647
+ });
648
+ if (!response.ok) return void 0;
649
+ if (!isVideoMp4Type(response.headers?.get("content-type"))) return void 0;
650
+ const declared = Number(response.headers?.get("content-length"));
651
+ if (Number.isFinite(declared) && declared > options.maxVideoBytes) return void 0;
652
+ const bytes = new Uint8Array(await response.arrayBuffer());
653
+ if (bytes.byteLength > options.maxVideoBytes) return void 0;
654
+ await mkdir(options.mediaOutputDir, { recursive: true });
655
+ await writeFile(output, bytes);
656
+ return publicPath;
657
+ } catch {
658
+ return;
659
+ } finally {
660
+ clearTimeout(timeout);
661
+ }
662
+ }
663
+ function sanitizeFilename(value) {
664
+ return value.replaceAll(/[^a-zA-Z0-9_-]/g, "-") || "video";
665
+ }
666
+ function joinPublicPath$1(prefix, filename) {
667
+ return `${prefix.replace(/\/$/, "")}/${filename}`;
512
668
  }
513
669
  //#endregion
514
670
  //#region src/plugins/twitter/fetch.ts
@@ -536,8 +692,8 @@ async function fetchTweetData(id, options) {
536
692
  signal: controller.signal
537
693
  });
538
694
  if (!response.ok) return null;
539
- const data = await response.json();
540
- if (!isTweetData(data)) return null;
695
+ const data = parseTweetData(await response.json());
696
+ if (!data) return null;
541
697
  if (options.cache) {
542
698
  tweetCache.set(key, data);
543
699
  await writeCachedTweet(key, data, options.cacheDir);
@@ -550,15 +706,29 @@ async function fetchTweetData(id, options) {
550
706
  }
551
707
  }
552
708
  async function materializeTweetAssets(id, data, options) {
709
+ const assets = await materializeBodyAssets(id, data, options);
710
+ if (data.quoted_tweet) assets.quoted = await materializeBodyAssets(`${id}-quoted`, data.quoted_tweet, options);
711
+ return assets;
712
+ }
713
+ async function materializeBodyAssets(id, data, options) {
553
714
  const assets = { media: [] };
554
715
  const avatarUrl = data.user.profile_image_url_https?.replace(/_normal(?=\.[^.]+$)/, "_bigger");
555
716
  if (avatarUrl) assets.avatar = await downloadAsset(avatarUrl, `${id}-avatar`, options);
556
717
  const media = data.mediaDetails ?? data.entities?.media ?? [];
557
718
  for (const [index, item] of media.entries()) {
558
- if (item.type && item.type !== "photo") continue;
559
- if (!item.media_url_https) continue;
560
- const src = await downloadAsset(item.media_url_https, `${id}-media-${index + 1}`, options);
561
- if (src) assets.media.push(assetRecord(src, item));
719
+ const kind = item.type === "video" || item.type === "animated_gif" ? item.type : "photo";
720
+ const basename = `${id}-media-${index + 1}`;
721
+ if (kind === "photo") {
722
+ if (item.type && item.type !== "photo") continue;
723
+ if (!item.media_url_https) continue;
724
+ const src = await downloadAsset(item.media_url_https, basename, options);
725
+ if (src) assets.media.push(assetRecord("photo", src, item));
726
+ continue;
727
+ }
728
+ const poster = item.media_url_https ? await downloadAsset(item.media_url_https, `${basename}-poster`, options) : void 0;
729
+ const videoUrl = options.downloadVideo ? selectBestMp4Url(item.video_info?.variants) : void 0;
730
+ const src = videoUrl ? await downloadVideoAsset(videoUrl, basename, options) : void 0;
731
+ assets.media.push(assetRecord(kind, src, item, poster));
562
732
  }
563
733
  return assets;
564
734
  }
@@ -588,8 +758,7 @@ async function downloadAsset(source, basename, options) {
588
758
  }
589
759
  async function readCachedTweet(key, directory) {
590
760
  try {
591
- const data = JSON.parse(await readFile(path.join(directory, `${key}.json`), "utf8"));
592
- return isTweetData(data) ? data : null;
761
+ return parseTweetData(JSON.parse(await readFile(path.join(directory, `${key}.json`), "utf8")));
593
762
  } catch {
594
763
  return null;
595
764
  }
@@ -600,11 +769,6 @@ async function writeCachedTweet(key, data, directory) {
600
769
  await writeFile(path.join(directory, `${key}.json`), `${JSON.stringify(data)}\n`);
601
770
  } catch {}
602
771
  }
603
- function isTweetData(data) {
604
- if (!data || typeof data !== "object") return false;
605
- const value = data;
606
- return typeof value.text === "string" && Boolean(value.user) && typeof value.user?.name === "string" && typeof value.user.screen_name === "string";
607
- }
608
772
  function extensionFromUrl(url) {
609
773
  const match = url.pathname.match(/\.(jpe?g|png|webp|gif)$/i);
610
774
  return match ? `.${match[1].toLowerCase().replace("jpeg", "jpg")}` : ".jpg";
@@ -615,39 +779,64 @@ function joinPublicPath(prefix, filename) {
615
779
  function sanitizeSegment(value) {
616
780
  return value.replaceAll(/[^a-zA-Z0-9_-]/g, "-");
617
781
  }
618
- function assetRecord(src, media) {
782
+ function assetRecord(kind, src, media, poster) {
619
783
  return {
784
+ kind,
620
785
  src,
786
+ poster,
621
787
  alt: media.ext_alt_text,
622
788
  width: media.original_info?.width,
623
789
  height: media.original_info?.height
624
790
  };
625
791
  }
626
792
  //#endregion
627
- //#region src/plugins/twitter/render.ts
628
- function renderFetchedTweet(permalink, data, assets, options) {
629
- const profile = `https://x.com/${encodeURIComponent(data.user.screen_name)}`;
630
- const author = escapeHtml$6(data.user.name);
631
- const handle = escapeHtml$6(data.user.screen_name);
632
- const avatar = assets.avatar ? `<img class="ox-tweet__avatar" src="${escapeAttribute$2(assets.avatar)}" alt="" width="48" height="48" loading="lazy" decoding="async">` : "";
633
- const media = renderMedia(assets);
634
- const footer = renderFooter(permalink, data.created_at, options.lang);
635
- return [
636
- "<figure class=\"ox-tweet ox-tweet--fetched\">",
637
- "<header class=\"ox-tweet__header\">",
638
- `<a class="ox-tweet__profile" href="${escapeAttribute$2(profile)}" target="_blank" rel="noopener noreferrer">`,
639
- avatar,
640
- `<span class="ox-tweet__author-name">${author}</span>`,
641
- `<span class="ox-tweet__author-handle">@${handle}</span>`,
642
- "</a></header>",
643
- `<div class="ox-tweet__body">${renderTweetText(data)}</div>`,
644
- media,
645
- footer,
646
- "</figure>"
647
- ].join("");
793
+ //#region src/plugins/twitter/html.ts
794
+ function escapeText(value) {
795
+ return escapeHtml$6(value).replaceAll("\n", "<br>");
796
+ }
797
+ function escapeAttribute$3(value) {
798
+ return escapeHtml$6(value).replaceAll("`", "&#96;");
648
799
  }
649
- function renderTweetText(data) {
650
- const [start, end] = data.display_text_range ?? [0, data.text.length];
800
+ function escapeHtml$6(value) {
801
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
802
+ }
803
+ //#endregion
804
+ //#region src/plugins/twitter/markup.ts
805
+ function renderMedia(assets, permalink) {
806
+ if (assets.media.length === 0) return "";
807
+ const items = assets.media.map((item) => renderMediaItem(item, permalink)).join("");
808
+ return `<div class="ox-tweet__media" data-count="${assets.media.length}">${items}</div>`;
809
+ }
810
+ function renderMediaItem(item, permalink) {
811
+ if (item.kind === "video" || item.kind === "animated_gif") return renderVideoItem(item, permalink);
812
+ const size = sizeAttributes(item);
813
+ return `<img class="ox-tweet__media-item" src="${escapeAttribute$3(item.src ?? "")}" alt="${escapeAttribute$3(item.alt ?? "")}"${size} loading="lazy" decoding="async">`;
814
+ }
815
+ function renderVideoItem(item, permalink) {
816
+ const watch = watchOnX(permalink);
817
+ const size = sizeAttributes(item);
818
+ const src = selfHostedMediaSrc(item.src);
819
+ if (src) {
820
+ const poster = item.poster ? ` poster="${escapeAttribute$3(item.poster)}"` : "";
821
+ const gif = item.kind === "animated_gif" ? " muted loop" : "";
822
+ return `<video class="ox-tweet__media-item" src="${escapeAttribute$3(src)}"${poster}${size} controls playsinline preload="none"${gif}>${watch}</video>`;
823
+ }
824
+ 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>`;
825
+ }
826
+ function selfHostedMediaSrc(src) {
827
+ return src && !src.includes("video.twimg.com") ? src : void 0;
828
+ }
829
+ function sizeAttributes(item) {
830
+ return [item.width ? ` width="${item.width}"` : "", item.height ? ` height="${item.height}"` : ""].join("");
831
+ }
832
+ function watchOnX(permalink) {
833
+ if (!permalink) return "";
834
+ return `<a class="ox-tweet__watch" href="${escapeAttribute$3(permalink)}" target="_blank" rel="noopener noreferrer">Watch on X</a>`;
835
+ }
836
+ //#endregion
837
+ //#region src/plugins/twitter/text.ts
838
+ function renderTweetText(data, options) {
839
+ const [start, end] = visibleTextRange(data, options?.omitTrailingQuoteUrl === true);
651
840
  const entities = collectEntities(data).filter((entity) => validRange(entity.indices, start, end)).sort((left, right) => left.indices[0] - right.indices[0]);
652
841
  let cursor = start;
653
842
  let output = "";
@@ -655,10 +844,9 @@ function renderTweetText(data) {
655
844
  const [entityStart, entityEnd] = entity.indices;
656
845
  if (entityStart < cursor) continue;
657
846
  output += escapeText(data.text.slice(cursor, entityStart));
658
- if (entity.kind === "url") {
659
- const href = entity.expanded_url ?? entity.url;
660
- const label = entity.display_url ?? href;
661
- output += `<a href="${escapeAttribute$2(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml$6(label)}</a>`;
847
+ if (entity.href) {
848
+ const label = entity.label ?? data.text.slice(entityStart, entityEnd);
849
+ output += `<a href="${escapeAttribute$3(entity.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml$6(label)}</a>`;
662
850
  }
663
851
  cursor = entityEnd;
664
852
  }
@@ -666,27 +854,228 @@ function renderTweetText(data) {
666
854
  return output.trim();
667
855
  }
668
856
  function collectEntities(data) {
669
- return [...(data.entities?.urls ?? []).map((entity) => ({
670
- ...entity,
671
- kind: "url"
672
- })), ...(data.entities?.media ?? []).map((entity) => ({
673
- ...entity,
674
- kind: "media"
675
- }))];
857
+ const collected = [];
858
+ for (const entity of data.entities?.urls ?? []) collected.push({
859
+ kind: "url",
860
+ indices: entity.indices,
861
+ href: entity.expanded_url ?? entity.url,
862
+ label: entity.display_url ?? entity.expanded_url ?? entity.url
863
+ });
864
+ for (const entity of data.entities?.media ?? []) collected.push({
865
+ kind: "media",
866
+ indices: entity.indices
867
+ });
868
+ for (const entity of data.entities?.hashtags ?? []) {
869
+ if (!entity.text) continue;
870
+ collected.push({
871
+ kind: "hashtag",
872
+ indices: entity.indices,
873
+ href: `https://x.com/hashtag/${encodeURIComponent(entity.text)}`
874
+ });
875
+ }
876
+ for (const entity of data.entities?.user_mentions ?? []) {
877
+ const screen = sanitizeScreenName(entity.screen_name);
878
+ if (!screen) continue;
879
+ collected.push({
880
+ kind: "mention",
881
+ indices: entity.indices,
882
+ href: `https://x.com/${encodeURIComponent(screen)}`
883
+ });
884
+ }
885
+ for (const entity of data.entities?.symbols ?? []) {
886
+ if (!entity.text) continue;
887
+ collected.push({
888
+ kind: "symbol",
889
+ indices: entity.indices,
890
+ href: `https://x.com/search?q=%24${encodeURIComponent(entity.text)}`
891
+ });
892
+ }
893
+ return collected;
676
894
  }
677
895
  function validRange(indices, start, end) {
678
896
  return Boolean(indices && indices[0] >= start && indices[1] <= end && indices[0] < indices[1]);
679
897
  }
680
- function renderMedia(assets) {
681
- if (assets.media.length === 0) return "";
682
- const images = assets.media.map((item) => {
683
- const size = [item.width ? ` width="${item.width}"` : "", item.height ? ` height="${item.height}"` : ""].join("");
684
- return `<img class="ox-tweet__media-item" src="${escapeAttribute$2(item.src)}" alt="${escapeAttribute$2(item.alt ?? "")}"${size} loading="lazy" decoding="async">`;
685
- }).join("");
686
- return `<div class="ox-tweet__media" data-count="${assets.media.length}">${images}</div>`;
898
+ //#endregion
899
+ //#region src/plugins/twitter/full.ts
900
+ const HELP_HREF = "https://help.x.com/en/x-for-websites-ads-info-and-privacy";
901
+ function renderFullTweet(permalink, data, assets) {
902
+ const quote = data.quoted_tweet ? renderFullQuote(data.quoted_tweet, assets.quoted) : "";
903
+ return [
904
+ "<figure class=\"ox-tweet ox-tweet--fetched ox-tweet--full\">",
905
+ renderFullHeader(data.user, assets.avatar, permalink),
906
+ renderReply$1(data),
907
+ `<div class="ox-tweet__body">${renderTweetText(data, { omitTrailingQuoteUrl: Boolean(quote) })}</div>`,
908
+ renderMedia(assets, permalink),
909
+ quote,
910
+ renderInfo(permalink, data.created_at),
911
+ renderActions(permalink, data),
912
+ renderReplies(permalink, data.conversation_count),
913
+ "</figure>"
914
+ ].join("");
915
+ }
916
+ function renderFullQuote(data, assets) {
917
+ const permalink = quotedPermalink(data) ?? "";
918
+ return [
919
+ "<blockquote class=\"ox-tweet__quote\">",
920
+ renderQuoteHeader(data.user, assets?.avatar, permalink),
921
+ `<div class="ox-tweet__quote-body">${renderTweetText(data)}</div>`,
922
+ renderMedia(assets ?? { media: [] }, permalink),
923
+ "</blockquote>"
924
+ ].join("");
925
+ }
926
+ function renderFullHeader(user, avatarSrc, permalink) {
927
+ const profile = profileHref(user);
928
+ const follow = followHref(user);
929
+ return [
930
+ "<header class=\"ox-tweet__header\">",
931
+ `<a class="ox-tweet__avatar-link" href="${escapeAttribute$3(profile)}" target="_blank" rel="noopener noreferrer">`,
932
+ avatar(avatarSrc, 48),
933
+ "</a>",
934
+ "<div class=\"ox-tweet__author\">",
935
+ `<a class="ox-tweet__author-name" href="${escapeAttribute$3(profile)}" target="_blank" rel="noopener noreferrer">${escapeHtml$6(user.name)}${verifiedBadge(user)}</a>`,
936
+ "<div class=\"ox-tweet__author-meta\">",
937
+ `<a class="ox-tweet__author-handle" href="${escapeAttribute$3(profile)}" target="_blank" rel="noopener noreferrer">@${escapeHtml$6(user.screen_name)}</a>`,
938
+ 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>` : "",
939
+ "</div></div>",
940
+ `<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>`,
941
+ "</header>"
942
+ ].join("");
943
+ }
944
+ function renderQuoteHeader(user, avatarSrc, permalink) {
945
+ return [
946
+ "<header class=\"ox-tweet__quote-header\">",
947
+ `<a class="ox-tweet__profile" href="${escapeAttribute$3(permalink || profileHref(user))}" target="_blank" rel="noopener noreferrer">`,
948
+ avatar(avatarSrc, 20),
949
+ `<span class="ox-tweet__author-name">${escapeHtml$6(user.name)}${verifiedBadge(user)}</span>`,
950
+ `<span class="ox-tweet__author-handle">@${escapeHtml$6(user.screen_name)}</span>`,
951
+ "</a></header>"
952
+ ].join("");
953
+ }
954
+ function renderReply$1(data) {
955
+ const href = replyPermalink(data);
956
+ const handle = data.in_reply_to_screen_name;
957
+ if (!href || !handle) return "";
958
+ 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>`;
959
+ }
960
+ function renderInfo(permalink, createdAt) {
961
+ const formatted = formatFullDate(createdAt);
962
+ 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>`;
963
+ }
964
+ function renderActions(permalink, data) {
965
+ const id = statusId(data, permalink);
966
+ if (!id) return "";
967
+ return [
968
+ "<div class=\"ox-tweet__actions\">",
969
+ `<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>`,
970
+ `<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>`,
971
+ "</div>"
972
+ ].join("");
973
+ }
974
+ function renderReplies(permalink, conversationCount) {
975
+ 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>`;
976
+ }
977
+ function avatar(src, size) {
978
+ return src ? `<img class="ox-tweet__avatar" src="${escapeAttribute$3(src)}" alt="" width="${size}" height="${size}" loading="lazy" decoding="async">` : "";
979
+ }
980
+ function verifiedBadge(user) {
981
+ const kind = verifiedKind(user);
982
+ return kind ? `<span class="ox-tweet__badge ox-tweet__badge--${kind}" title="Verified"></span>` : "";
983
+ }
984
+ function verifiedKind(user) {
985
+ if (user.verified_type === "Government") return "gray";
986
+ if (user.verified_type === "Business") return "gold";
987
+ if (user.is_blue_verified) return "blue";
988
+ if (user.verified) return "gray";
989
+ }
990
+ function profileHref(user) {
991
+ const screen = sanitizeScreenName(user.screen_name) ?? user.screen_name;
992
+ return `https://x.com/${encodeURIComponent(screen)}`;
993
+ }
994
+ function followHref(user) {
995
+ const screen = sanitizeScreenName(user.screen_name);
996
+ return screen ? `https://x.com/intent/follow?screen_name=${encodeURIComponent(screen)}` : void 0;
997
+ }
998
+ function statusId(data, permalink) {
999
+ return sanitizeStatusId(data.id_str) ?? permalink.match(/\/status\/(\d+)/)?.[1];
1000
+ }
1001
+ function formatCount(value) {
1002
+ const n = typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
1003
+ if (n > 999999) return `${(n / 1e6).toFixed(1)}M`;
1004
+ if (n > 999) return `${(n / 1e3).toFixed(1)}K`;
1005
+ return String(n);
1006
+ }
1007
+ function repliesLabel(value) {
1008
+ const n = typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
1009
+ if (n === 0) return "Read more on X";
1010
+ if (n === 1) return "Read 1 reply";
1011
+ return `Read ${formatCount(n)} replies`;
1012
+ }
1013
+ function formatFullDate(createdAt) {
1014
+ if (!createdAt) return void 0;
1015
+ const date = new Date(createdAt);
1016
+ if (Number.isNaN(date.valueOf())) return void 0;
1017
+ const parts = new Intl.DateTimeFormat("en-US", {
1018
+ hour: "numeric",
1019
+ minute: "2-digit",
1020
+ hour12: true,
1021
+ month: "short",
1022
+ day: "numeric",
1023
+ year: "numeric",
1024
+ timeZone: "UTC"
1025
+ }).formatToParts(date);
1026
+ const get = (type) => parts.find((part) => part.type === type)?.value ?? "";
1027
+ return {
1028
+ iso: date.toISOString(),
1029
+ label: `${get("hour")}:${get("minute")} ${get("dayPeriod")} · ${get("month")} ${get("day")}, ${get("year")}`
1030
+ };
1031
+ }
1032
+ //#endregion
1033
+ //#region src/plugins/twitter/render.ts
1034
+ function renderFetchedTweet(permalink, data, assets, options) {
1035
+ if (options.appearance === "full") return renderFullTweet(permalink, data, assets);
1036
+ const quote = data.quoted_tweet ? renderQuotedTweet(data.quoted_tweet, assets.quoted) : "";
1037
+ return [
1038
+ "<figure class=\"ox-tweet ox-tweet--fetched\">",
1039
+ renderHeader(data.user, assets.avatar),
1040
+ renderReply(data),
1041
+ `<div class="ox-tweet__body">${renderTweetText(data, { omitTrailingQuoteUrl: Boolean(quote) })}</div>`,
1042
+ renderMedia(assets, permalink),
1043
+ quote,
1044
+ renderFooter(permalink, data.created_at, options.lang),
1045
+ "</figure>"
1046
+ ].join("");
1047
+ }
1048
+ function renderQuotedTweet(data, assets) {
1049
+ const permalink = quotedPermalink(data) ?? "";
1050
+ return [
1051
+ "<blockquote class=\"ox-tweet__quote\">",
1052
+ renderHeader(data.user, assets?.avatar, permalink || void 0, "ox-tweet__quote-header"),
1053
+ `<div class="ox-tweet__quote-body">${renderTweetText(data)}</div>`,
1054
+ renderMedia(assets ?? { media: [] }, permalink),
1055
+ "</blockquote>"
1056
+ ].join("");
1057
+ }
1058
+ function renderHeader(user, avatarSrc, href, headerClass = "ox-tweet__header") {
1059
+ const screen = sanitizeScreenName(user.screen_name) ?? user.screen_name;
1060
+ const profile = href ?? `https://x.com/${encodeURIComponent(screen)}`;
1061
+ const avatar = avatarSrc ? `<img class="ox-tweet__avatar" src="${escapeAttribute$3(avatarSrc)}" alt="" width="48" height="48" loading="lazy" decoding="async">` : "";
1062
+ return [
1063
+ `<header class="${headerClass}">`,
1064
+ `<a class="ox-tweet__profile" href="${escapeAttribute$3(profile)}" target="_blank" rel="noopener noreferrer">`,
1065
+ avatar,
1066
+ `<span class="ox-tweet__author-name">${escapeHtml$6(user.name)}</span>`,
1067
+ `<span class="ox-tweet__author-handle">@${escapeHtml$6(user.screen_name)}</span>`,
1068
+ "</a></header>"
1069
+ ].join("");
1070
+ }
1071
+ function renderReply(data) {
1072
+ const href = replyPermalink(data);
1073
+ const handle = data.in_reply_to_screen_name;
1074
+ if (!href || !handle) return "";
1075
+ 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>`;
687
1076
  }
688
1077
  function renderFooter(permalink, createdAt, lang) {
689
- 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>`;
1078
+ 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>`;
690
1079
  const date = new Date(createdAt);
691
1080
  if (Number.isNaN(date.valueOf())) return renderFooter(permalink, void 0, lang);
692
1081
  const iso = date.toISOString();
@@ -702,16 +1091,7 @@ function renderFooter(permalink, createdAt, lang) {
702
1091
  timeZone: "UTC"
703
1092
  }).format(date);
704
1093
  }
705
- 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>`;
706
- }
707
- function escapeText(value) {
708
- return escapeHtml$6(value).replaceAll("\n", "<br>");
709
- }
710
- function escapeAttribute$2(value) {
711
- return escapeHtml$6(value).replaceAll("`", "&#96;");
712
- }
713
- function escapeHtml$6(value) {
714
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
1094
+ 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>`;
715
1095
  }
716
1096
  //#endregion
717
1097
  //#region src/plugins/twitter/transform.ts
@@ -724,7 +1104,10 @@ function resolveTwitterEmbedOptions$1(options) {
724
1104
  cache: options.cache ?? true,
725
1105
  cacheDir: path.resolve(options.cacheDir ?? ".cache/ox-content/twitter"),
726
1106
  mediaOutputDir: path.resolve(options.mediaOutputDir ?? "public/ox-content/twitter"),
727
- mediaPublicPath: options.mediaPublicPath ?? "/ox-content/twitter"
1107
+ mediaPublicPath: options.mediaPublicPath ?? "/ox-content/twitter",
1108
+ downloadVideo: options.downloadVideo ?? false,
1109
+ maxVideoBytes: options.maxVideoBytes ?? 8388608,
1110
+ appearance: options.appearance === "full" ? "full" : "compact"
728
1111
  };
729
1112
  }
730
1113
  async function transformFetchedTweets(html, options) {
@@ -735,20 +1118,23 @@ async function transformFetchedTweets(html, options) {
735
1118
  for (const match of html.matchAll(TWEET_ELEMENT)) {
736
1119
  const index = match.index ?? 0;
737
1120
  output += html.slice(cursor, index);
738
- const reference = referenceFromAttributes(match[2]);
739
- if (!reference) {
1121
+ const attrs = tweetElementAttributes(match[2]);
1122
+ if (!attrs.reference) {
740
1123
  output += match[0];
741
1124
  cursor = index + match[0].length;
742
1125
  continue;
743
1126
  }
744
- const data = await fetchTweetData(reference.id, resolved);
1127
+ const data = await fetchTweetData(attrs.reference.id, resolved);
745
1128
  if (!data) {
746
1129
  output += match[0];
747
1130
  cursor = index + match[0].length;
748
1131
  continue;
749
1132
  }
750
- const assets = await materializeTweetAssets(reference.id, data, resolved);
751
- output += renderFetchedTweet(reference.url, data, assets, resolved);
1133
+ const assets = await materializeTweetAssets(attrs.reference.id, data, resolved);
1134
+ output += renderFetchedTweet(attrs.reference.url, data, assets, {
1135
+ ...resolved,
1136
+ appearance: attrs.appearance ?? resolved.appearance
1137
+ });
752
1138
  cursor = index + match[0].length;
753
1139
  }
754
1140
  return output + html.slice(cursor);
@@ -891,7 +1277,7 @@ function inferLanguage(path) {
891
1277
  }
892
1278
  //#endregion
893
1279
  //#region src/plugins/github/types.ts
894
- const defaultOptions$1 = {
1280
+ const defaultOptions = {
895
1281
  token: "",
896
1282
  cache: true,
897
1283
  cacheTTL: 36e5,
@@ -1001,7 +1387,7 @@ async function fetchGitHubSource(source, options) {
1001
1387
  */
1002
1388
  async function prefetchGitHubRepos(repos, options) {
1003
1389
  const mergedOptions = {
1004
- ...defaultOptions$1,
1390
+ ...defaultOptions,
1005
1391
  ...options
1006
1392
  };
1007
1393
  const results = /* @__PURE__ */ new Map();
@@ -1016,7 +1402,7 @@ async function prefetchGitHubRepos(repos, options) {
1016
1402
  */
1017
1403
  async function prefetchGitHubSources(sources, options) {
1018
1404
  const mergedOptions = {
1019
- ...defaultOptions$1,
1405
+ ...defaultOptions,
1020
1406
  ...options
1021
1407
  };
1022
1408
  const results = /* @__PURE__ */ new Map();
@@ -1433,7 +1819,7 @@ function rehypeGitHub(repoDataMap, sourceDataMap, options) {
1433
1819
  */
1434
1820
  async function transformGitHub(html, repoDataMap, options) {
1435
1821
  const mergedOptions = {
1436
- ...defaultOptions$1,
1822
+ ...defaultOptions,
1437
1823
  ...options
1438
1824
  };
1439
1825
  let dataMap = repoDataMap;
@@ -1446,29 +1832,111 @@ async function transformGitHub(html, repoDataMap, options) {
1446
1832
  //#region src/plugins/github.ts
1447
1833
  var github_exports = /* @__PURE__ */ __exportAll({ transformGitHub: () => transformGitHub });
1448
1834
  //#endregion
1449
- //#region src/plugins/ogp.ts
1450
- /**
1451
- * OGP Card Plugin - Link card embedding
1452
- *
1453
- * Transforms <OgCard> components into static link preview cards
1454
- * by fetching OGP metadata at build time.
1455
- */
1456
- var ogp_exports = /* @__PURE__ */ __exportAll({
1457
- collectOgpUrls: () => collectOgpUrls,
1458
- fetchOgpData: () => fetchOgpData,
1459
- isSafeOgpUrl: () => isSafeOgpUrl,
1460
- prefetchOgpData: () => prefetchOgpData,
1461
- transformOgp: () => transformOgp
1462
- });
1463
- const rehypeParse$1 = interopDefault(rehypeParsePlugin);
1464
- const rehypeStringify$1 = interopDefault(rehypeStringifyPlugin);
1465
- const defaultOptions = {
1466
- timeout: 1e4,
1467
- cache: true,
1468
- cacheTTL: 36e5,
1469
- userAgent: "ox-content-ogp-bot/1.0 (compatible; +https://github.com/ubugeeei-prod/ox-content)"
1470
- };
1471
- const ogpCache = /* @__PURE__ */ new Map();
1835
+ //#region src/plugins/ogp/cache.ts
1836
+ const memoryCache = /* @__PURE__ */ new Map();
1837
+ function ogpCacheFilePath(directory, key) {
1838
+ return path.join(directory, `${key}.json`);
1839
+ }
1840
+ function isFreshOgpEntry(cachedAt, ttl, now) {
1841
+ return now - cachedAt < ttl;
1842
+ }
1843
+ function parseOgpCacheEntry(value) {
1844
+ if (!value || typeof value !== "object") return null;
1845
+ const entry = value;
1846
+ if (entry.v !== 1) return null;
1847
+ if (typeof entry.url !== "string" || entry.url.length === 0) return null;
1848
+ if (typeof entry.cachedAt !== "number" || !Number.isFinite(entry.cachedAt)) return null;
1849
+ if (entry.data !== null && !isOgpData(entry.data)) return null;
1850
+ return {
1851
+ v: 1,
1852
+ url: entry.url,
1853
+ cachedAt: entry.cachedAt,
1854
+ data: entry.data
1855
+ };
1856
+ }
1857
+ function readMemoryOgp(key, options, now) {
1858
+ const cached = memoryCache.get(key);
1859
+ if (!cached || !isFreshOgpEntry(cached.timestamp, options.cacheTTL, now)) return;
1860
+ return cached.data;
1861
+ }
1862
+ function writeMemoryOgp(key, data, timestamp, options) {
1863
+ if (data === null && !options.persistCache) return;
1864
+ memoryCache.set(key, {
1865
+ data,
1866
+ timestamp
1867
+ });
1868
+ }
1869
+ async function readDiskOgp(key, options, now) {
1870
+ const file = ogpCacheFilePath(options.cacheDir, key);
1871
+ try {
1872
+ const entry = parseOgpCacheEntry(JSON.parse(await readFile(file, "utf8")));
1873
+ if (!entry) {
1874
+ await discardCorruptEntry(file);
1875
+ return;
1876
+ }
1877
+ if (!isFreshOgpEntry(entry.cachedAt, options.cacheTTL, now)) return void 0;
1878
+ return entry.data;
1879
+ } catch (error) {
1880
+ if (isEnoent(error)) return void 0;
1881
+ await discardCorruptEntry(file);
1882
+ return;
1883
+ }
1884
+ }
1885
+ async function writeDiskOgp(key, url, data, options, cachedAt) {
1886
+ const directory = options.cacheDir;
1887
+ const target = ogpCacheFilePath(directory, key);
1888
+ const temp = path.join(directory, `.${key}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
1889
+ const entry = {
1890
+ v: 1,
1891
+ url,
1892
+ cachedAt,
1893
+ data
1894
+ };
1895
+ try {
1896
+ await mkdir(directory, { recursive: true });
1897
+ await writeFile(temp, `${JSON.stringify(entry)}\n`);
1898
+ try {
1899
+ await rename(temp, target);
1900
+ } catch {
1901
+ await writeFile(target, `${JSON.stringify(entry)}\n`);
1902
+ await rm(temp, { force: true });
1903
+ }
1904
+ } catch {
1905
+ await rm(temp, { force: true }).catch(() => void 0);
1906
+ }
1907
+ }
1908
+ function isOgpData(value) {
1909
+ if (!value || typeof value !== "object") return false;
1910
+ const data = value;
1911
+ if (typeof data.url !== "string" || typeof data.title !== "string") return false;
1912
+ for (const field of [
1913
+ "description",
1914
+ "image",
1915
+ "siteName",
1916
+ "favicon"
1917
+ ]) if (data[field] !== void 0 && typeof data[field] !== "string") return false;
1918
+ return true;
1919
+ }
1920
+ async function discardCorruptEntry(file) {
1921
+ console.warn(`Ignoring corrupt Open Graph cache entry ${file}`);
1922
+ await rm(file, { force: true }).catch(() => void 0);
1923
+ }
1924
+ function isEnoent(error) {
1925
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
1926
+ }
1927
+ function resolveOgpOptions(options = {}) {
1928
+ return {
1929
+ timeout: options.timeout ?? 1e4,
1930
+ cache: options.cache ?? true,
1931
+ cacheTTL: options.cacheTTL ?? 36e5,
1932
+ persistCache: options.persistCache ?? false,
1933
+ cacheDir: path.resolve(options.cacheDir ?? ".cache/ox-content/ogp"),
1934
+ refresh: options.refresh ?? false,
1935
+ userAgent: options.userAgent ?? "ox-content-ogp-bot/1.0 (compatible; +https://github.com/ubugeeei-prod/ox-content)"
1936
+ };
1937
+ }
1938
+ //#endregion
1939
+ //#region src/plugins/ogp/url.ts
1472
1940
  function isPrivateIPv4(hostname) {
1473
1941
  const parts = hostname.split(".").map(Number);
1474
1942
  if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
@@ -1488,17 +1956,6 @@ function isSafeOgpUrl(value) {
1488
1956
  return false;
1489
1957
  }
1490
1958
  }
1491
- /**
1492
- * Get element attribute value.
1493
- */
1494
- function getAttribute$1(el, name) {
1495
- const value = el.properties?.[name];
1496
- if (typeof value === "string") return value;
1497
- if (Array.isArray(value)) return value.join(" ");
1498
- }
1499
- /**
1500
- * Extract domain from URL.
1501
- */
1502
1959
  function extractDomain(url) {
1503
1960
  try {
1504
1961
  return new URL(url).hostname;
@@ -1506,9 +1963,6 @@ function extractDomain(url) {
1506
1963
  return url;
1507
1964
  }
1508
1965
  }
1509
- /**
1510
- * Get favicon URL for a domain.
1511
- */
1512
1966
  function getFaviconUrl(url) {
1513
1967
  try {
1514
1968
  return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=32`;
@@ -1517,40 +1971,60 @@ function getFaviconUrl(url) {
1517
1971
  }
1518
1972
  }
1519
1973
  /**
1520
- * Parse OGP metadata from HTML.
1974
+ * Normalize a URL for cache keys: lowercase host, drop default ports and
1975
+ * fragments, and strip a trailing slash that is not the root path.
1521
1976
  */
1522
- function parseOgpFromHtml(html, url) {
1523
- const result = {
1524
- url,
1525
- title: ""
1526
- };
1527
- const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
1528
- result.title = (html.match(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:title["']/i))?.[1] || titleMatch?.[1] || extractDomain(url);
1529
- 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);
1530
- if (descMatch) result.description = descMatch[1];
1531
- const imageMatch = html.match(/<meta[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:image["']/i);
1532
- if (imageMatch) {
1533
- let imageUrl = imageMatch[1];
1534
- if (imageUrl.startsWith("/")) try {
1535
- const urlObj = new URL(url);
1536
- imageUrl = `${urlObj.protocol}//${urlObj.host}${imageUrl}`;
1537
- } catch {}
1538
- result.image = imageUrl;
1977
+ function normalizeOgpUrl(url) {
1978
+ try {
1979
+ const parsed = new URL(url);
1980
+ parsed.hash = "";
1981
+ parsed.hostname = parsed.hostname.toLowerCase();
1982
+ if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") parsed.port = "";
1983
+ if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) parsed.pathname = parsed.pathname.slice(0, -1);
1984
+ return parsed.href;
1985
+ } catch {
1986
+ return url;
1539
1987
  }
1540
- const siteNameMatch = html.match(/<meta[^>]*property=["']og:site_name["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:site_name["']/i);
1541
- if (siteNameMatch) result.siteName = siteNameMatch[1];
1542
- result.favicon = getFaviconUrl(url);
1543
- return result;
1544
1988
  }
1545
- /**
1546
- * Fetch OGP data for a URL.
1547
- */
1548
- async function fetchOgpData(url, options) {
1989
+ function ogpCacheKey(url) {
1990
+ return createHash("sha256").update(normalizeOgpUrl(url)).digest("hex");
1991
+ }
1992
+ //#endregion
1993
+ //#region src/plugins/ogp/fetch.ts
1994
+ const inflight = /* @__PURE__ */ new Map();
1995
+ async function fetchOgpData(url, options = {}) {
1549
1996
  if (!isSafeOgpUrl(url)) return null;
1997
+ const resolved = resolveOgpOptions(options);
1998
+ const key = ogpCacheKey(url);
1999
+ const pending = inflight.get(key);
2000
+ if (pending) return pending;
2001
+ const request = loadOgpData(url, key, resolved).finally(() => {
2002
+ if (inflight.get(key) === request) inflight.delete(key);
2003
+ });
2004
+ inflight.set(key, request);
2005
+ return request;
2006
+ }
2007
+ async function loadOgpData(url, key, options) {
2008
+ const now = Date.now();
2009
+ if (options.cache && !options.refresh) {
2010
+ const memory = readMemoryOgp(key, options, now);
2011
+ if (memory !== void 0) return memory;
2012
+ if (options.persistCache) {
2013
+ const disk = await readDiskOgp(key, options, now);
2014
+ if (disk !== void 0) {
2015
+ writeMemoryOgp(key, disk, now, options);
2016
+ return disk;
2017
+ }
2018
+ }
2019
+ }
2020
+ const data = await requestOgpData(url, options);
1550
2021
  if (options.cache) {
1551
- const cached = ogpCache.get(url);
1552
- if (cached && Date.now() - cached.timestamp < options.cacheTTL) return cached.data;
2022
+ writeMemoryOgp(key, data, now, options);
2023
+ if (options.persistCache) await writeDiskOgp(key, normalizeOgpUrl(url), data, options, now);
1553
2024
  }
2025
+ return data;
2026
+ }
2027
+ async function requestOgpData(url, options) {
1554
2028
  try {
1555
2029
  const controller = new AbortController();
1556
2030
  const timeoutId = setTimeout(() => controller.abort(), options.timeout);
@@ -1566,21 +2040,38 @@ async function fetchOgpData(url, options) {
1566
2040
  console.warn(`Failed to fetch OGP for ${url}: ${response.status}`);
1567
2041
  return null;
1568
2042
  }
1569
- const data = parseOgpFromHtml(await response.text(), url);
1570
- if (options.cache) ogpCache.set(url, {
1571
- data,
1572
- timestamp: Date.now()
1573
- });
1574
- return data;
2043
+ return parseOgpFromHtml(await response.text(), url);
1575
2044
  } catch (error) {
1576
2045
  if (error instanceof Error && error.name === "AbortError") console.warn(`Timeout fetching OGP for ${url}`);
1577
2046
  else console.warn(`Error fetching OGP for ${url}:`, error);
1578
2047
  return null;
1579
2048
  }
1580
2049
  }
1581
- /**
1582
- * Create OGP card element.
1583
- */
2050
+ function parseOgpFromHtml(html, url) {
2051
+ const result = {
2052
+ url,
2053
+ title: ""
2054
+ };
2055
+ const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
2056
+ result.title = (html.match(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:title["']/i))?.[1] || titleMatch?.[1] || extractDomain(url);
2057
+ 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);
2058
+ if (descMatch) result.description = descMatch[1];
2059
+ const imageMatch = html.match(/<meta[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:image["']/i);
2060
+ if (imageMatch) {
2061
+ let imageUrl = imageMatch[1];
2062
+ if (imageUrl.startsWith("/")) try {
2063
+ const urlObj = new URL(url);
2064
+ imageUrl = `${urlObj.protocol}//${urlObj.host}${imageUrl}`;
2065
+ } catch {}
2066
+ result.image = imageUrl;
2067
+ }
2068
+ const siteNameMatch = html.match(/<meta[^>]*property=["']og:site_name["'][^>]*content=["']([^"']+)["']/i) || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:site_name["']/i);
2069
+ if (siteNameMatch) result.siteName = siteNameMatch[1];
2070
+ result.favicon = getFaviconUrl(url);
2071
+ return result;
2072
+ }
2073
+ //#endregion
2074
+ //#region src/plugins/ogp/render.ts
1584
2075
  function createOgpCard(data) {
1585
2076
  const children = [];
1586
2077
  const contentChildren = [];
@@ -1658,9 +2149,6 @@ function createOgpCard(data) {
1658
2149
  children
1659
2150
  };
1660
2151
  }
1661
- /**
1662
- * Create fallback element when OGP data is unavailable.
1663
- */
1664
2152
  function createFallbackCard(url) {
1665
2153
  return {
1666
2154
  type: "element",
@@ -1692,9 +2180,15 @@ function createFallbackCard(url) {
1692
2180
  }]
1693
2181
  };
1694
2182
  }
1695
- /**
1696
- * Collect all OGP URLs from HTML for pre-fetching.
1697
- */
2183
+ //#endregion
2184
+ //#region src/plugins/ogp/transform.ts
2185
+ const rehypeParse$1 = interopDefault(rehypeParsePlugin);
2186
+ const rehypeStringify$1 = interopDefault(rehypeStringifyPlugin);
2187
+ function getAttribute$1(el, name) {
2188
+ const value = el.properties?.[name];
2189
+ if (typeof value === "string") return value;
2190
+ if (Array.isArray(value)) return value.join(" ");
2191
+ }
1698
2192
  async function collectOgpUrls(html) {
1699
2193
  const urls = [];
1700
2194
  const urlPattern = /<ogcard[^>]*\s+url=["']([^"']+)["']/gi;
@@ -1702,24 +2196,13 @@ async function collectOgpUrls(html) {
1702
2196
  while ((match = urlPattern.exec(html)) !== null) if (isSafeOgpUrl(match[1])) urls.push(match[1]);
1703
2197
  return urls;
1704
2198
  }
1705
- /**
1706
- * Pre-fetch all OGP data.
1707
- */
1708
2199
  async function prefetchOgpData(urls, options) {
1709
- const mergedOptions = {
1710
- ...defaultOptions,
1711
- ...options
1712
- };
1713
2200
  const results = /* @__PURE__ */ new Map();
1714
2201
  await Promise.all(urls.map(async (url) => {
1715
- const data = await fetchOgpData(url, mergedOptions);
1716
- results.set(url, data);
2202
+ results.set(url, await fetchOgpData(url, options));
1717
2203
  }));
1718
2204
  return results;
1719
2205
  }
1720
- /**
1721
- * Rehype plugin to transform OgCard components.
1722
- */
1723
2206
  function rehypeOgp(ogpDataMap) {
1724
2207
  return (tree) => {
1725
2208
  const visit = (node) => {
@@ -1730,8 +2213,7 @@ function rehypeOgp(ogpDataMap) {
1730
2213
  const url = getAttribute$1(child, "url");
1731
2214
  if (url) {
1732
2215
  const ogpData = ogpDataMap.get(url);
1733
- const cardElement = ogpData ? createOgpCard(ogpData) : createFallbackCard(url);
1734
- node.children[i] = cardElement;
2216
+ node.children[i] = ogpData ? createOgpCard(ogpData) : createFallbackCard(url);
1735
2217
  }
1736
2218
  } else visit(child);
1737
2219
  }
@@ -1740,9 +2222,6 @@ function rehypeOgp(ogpDataMap) {
1740
2222
  visit(tree);
1741
2223
  };
1742
2224
  }
1743
- /**
1744
- * Transform OgCard components in HTML.
1745
- */
1746
2225
  async function transformOgp(html, ogpDataMap, options) {
1747
2226
  let dataMap = ogpDataMap;
1748
2227
  if (!dataMap) dataMap = await prefetchOgpData(await collectOgpUrls(html), options);
@@ -1750,6 +2229,9 @@ async function transformOgp(html, ogpDataMap, options) {
1750
2229
  return String(result);
1751
2230
  }
1752
2231
  //#endregion
2232
+ //#region src/plugins/ogp.ts
2233
+ var ogp_exports = /* @__PURE__ */ __exportAll({ transformOgp: () => transformOgp });
2234
+ //#endregion
1753
2235
  //#region src/plugins/index.ts
1754
2236
  const SELF_CLOSING_EMBED_TAG = /<(GitHub|OgCard|Tweet|XPost|Bluesky|Spotify|StackBlitz|WebContainer|YouTube)((?:[^>"']|"[^"]*"|'[^']*')*?)\s*\/>/gi;
1755
2237
  /**
@@ -2424,6 +2906,7 @@ async function transformMarkdown(source, filePath, options, ssgOptions) {
2424
2906
  gfm: options.gfm,
2425
2907
  mdx: resolveMdxForFilePath(filePath, options.mdx),
2426
2908
  footnotes: options.footnotes,
2909
+ semanticFootnotes: options.semanticFootnotes ?? false,
2427
2910
  taskLists: options.taskLists,
2428
2911
  tables: options.tables,
2429
2912
  strikethrough: options.strikethrough,
@@ -2431,6 +2914,7 @@ async function transformMarkdown(source, filePath, options, ssgOptions) {
2431
2914
  autolinkUrls: options.autolinks,
2432
2915
  frontmatter: options.frontmatter,
2433
2916
  tocMaxDepth: options.tocMaxDepth,
2917
+ headingPermalinks: options.headingPermalinks?.enabled ?? false,
2434
2918
  convertMdLinks: ssgOptions?.convertMdLinks,
2435
2919
  baseUrl: ssgOptions?.baseUrl,
2436
2920
  sourcePath: ssgOptions?.sourcePath ?? filePath,
@@ -2448,6 +2932,13 @@ async function transformMarkdown(source, filePath, options, ssgOptions) {
2448
2932
  } : void 0,
2449
2933
  attributes: options.attrs?.enabled ? { enabled: true } : void 0,
2450
2934
  badges: options.badges?.enabled ? { enabled: true } : void 0,
2935
+ magicLinks: options.magicLinks?.enabled ? {
2936
+ enabled: true,
2937
+ aliases: options.magicLinks.aliases,
2938
+ favicon: options.magicLinks.favicon,
2939
+ faviconTemplate: options.magicLinks.faviconTemplate,
2940
+ imageOverrides: options.magicLinks.imageOverrides
2941
+ } : void 0,
2451
2942
  containers: options.containers?.enabled ? {
2452
2943
  enabled: true,
2453
2944
  types: options.containers.types
@@ -3931,6 +4422,108 @@ initIslands((el, props) => {
3931
4422
  `;
3932
4423
  }
3933
4424
  //#endregion
4425
+ //#region src/versions-html.ts
4426
+ function versionSwitcherMarkup(links, badge) {
4427
+ if (links.length === 0) return "";
4428
+ const current = links.find((link) => link.current) ?? links[0];
4429
+ const items = links.map((link) => {
4430
+ const label = `${escapeHtml$4(link.label)}${badgeMarkup(link, badge)}`;
4431
+ if (link.current || !isSafeHref(link.href)) return `<li><span aria-current="page">${label}</span></li>`;
4432
+ return `<li><a href="${escapeHtml$4(link.href)}">${label}</a></li>`;
4433
+ }).join("");
4434
+ 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>`;
4435
+ }
4436
+ function versionBannerMarkup(kind) {
4437
+ if (kind === "unreleased") return `<aside class="ox-version-banner ox-version-banner--unreleased" role="status">This documentation describes an unreleased version.</aside>`;
4438
+ if (kind === "unmaintained") return `<aside class="ox-version-banner ox-version-banner--unmaintained" role="status">This documentation is unmaintained.</aside>`;
4439
+ return "";
4440
+ }
4441
+ function injectVersionChrome(html, switcher, banner, searchFrom, searchTo) {
4442
+ let next = html;
4443
+ if (banner) next = next.replace(/<body([^>]*)>/, `<body$1>${banner}`);
4444
+ if (switcher) {
4445
+ if (next.includes("<div class=\"header-actions\">")) next = next.replace("<div class=\"header-actions\">", `<div class="header-actions">${switcher}`);
4446
+ else if (next.includes("</header>")) next = next.replace("</header>", `${switcher}</header>`);
4447
+ }
4448
+ if (searchTo && isSafeHref(searchTo)) next = next.replace(/<html([^>]*)>/i, (match, attrs) => {
4449
+ if (/\sdata-ox-search-index=/.test(attrs)) return match;
4450
+ return `<html${attrs} data-ox-search-index="${escapeHtml$4(searchTo)}">`;
4451
+ });
4452
+ if (searchFrom && searchTo && searchFrom !== searchTo && isSafeHref(searchTo)) {
4453
+ next = next.split(searchFrom).join(searchTo);
4454
+ 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>`;
4455
+ next = next.includes("</body>") ? next.replace("</body>", `${script}</body>`) : `${next}${script}`;
4456
+ }
4457
+ return next;
4458
+ }
4459
+ function searchIndexUrl(base, prefix) {
4460
+ const root = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
4461
+ return prefix ? `${root}${prefix}/search-index.json` : `${root}search-index.json`;
4462
+ }
4463
+ function isSafeHref(href) {
4464
+ const trimmed = href.trim();
4465
+ if (!trimmed || trimmed.startsWith("//")) return false;
4466
+ const lower = trimmed.replace(/\s+/g, "").toLowerCase();
4467
+ if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:")) return false;
4468
+ return trimmed.startsWith("/") || trimmed.startsWith("./") || !trimmed.includes(":");
4469
+ }
4470
+ function escapeHtml$4(value) {
4471
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
4472
+ }
4473
+ function badgeMarkup(link, badge) {
4474
+ if (!badge || !link.banner) return "";
4475
+ return `<span class="ox-version-badge">${link.banner === "unreleased" ? "unreleased" : "unmaintained"}</span>`;
4476
+ }
4477
+ //#endregion
4478
+ //#region src/search-filters.ts
4479
+ /**
4480
+ * Language and version filters for the default search dialog.
4481
+ */
4482
+ const RESULTS_MARKUP = "<div class=\"search-results\"></div>";
4483
+ function injectSearchLocaleFilters(html, input) {
4484
+ const locales = input.locales.filter((locale) => locale.code.trim() && locale.name.trim());
4485
+ if (locales.length < 2) return html;
4486
+ const next = ensureSearchFilters(html);
4487
+ const select = selectMarkup(next, "locale");
4488
+ if (!select) return next;
4489
+ const defaultLocale = input.defaultLocale.trim() || locales[0].code;
4490
+ const selected = locales.some((locale) => locale.code === input.current) ? input.current : defaultLocale;
4491
+ const options = [`<option value="">All languages</option>`, ...locales.map((locale) => {
4492
+ return `<option value="${escapeHtml$4(locale.code)}"${locale.code === selected ? " selected" : ""}>${escapeHtml$4(locale.name)}</option>`;
4493
+ })].join("");
4494
+ 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");
4495
+ }
4496
+ function injectSearchVersionFilters(html, versions) {
4497
+ const safe = versions.filter((version) => version.id.trim() && version.label.trim() && isSafeHref(version.indexUrl));
4498
+ if (safe.length < 2) return html;
4499
+ const next = ensureSearchFilters(html);
4500
+ const select = selectMarkup(next, "version");
4501
+ if (!select) return next;
4502
+ const current = safe.find((version) => version.current) ?? safe[0];
4503
+ const options = safe.map((version) => {
4504
+ const selected = version.id === current.id ? " selected" : "";
4505
+ 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>`;
4506
+ }).join("");
4507
+ return revealFilter(next.replace(select.markup, `<select class="search-filter-select" data-search-filter="version" aria-label="Version">${options}</select>`), "version");
4508
+ }
4509
+ function ensureSearchFilters(html) {
4510
+ if (html.includes("class=\"search-filters\"") || !html.includes(RESULTS_MARKUP)) return html;
4511
+ return html.replace(RESULTS_MARKUP, `${searchFiltersMarkup()}${RESULTS_MARKUP}`);
4512
+ }
4513
+ function searchFiltersMarkup() {
4514
+ 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>`;
4515
+ }
4516
+ function searchFiltersStyle() {
4517
+ 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>`;
4518
+ }
4519
+ function selectMarkup(html, kind) {
4520
+ const match = html.match(new RegExp(`<select class="search-filter-select" data-search-filter="${kind}"[^>]*>[\\s\\S]*?<\\/select>`));
4521
+ return match?.[0] ? { markup: match[0] } : void 0;
4522
+ }
4523
+ function revealFilter(html, kind) {
4524
+ return html.replace(`data-search-filter-label="${kind}" hidden`, `data-search-filter-label="${kind}"`);
4525
+ }
4526
+ //#endregion
3934
4527
  //#region src/locale-switcher.ts
3935
4528
  /**
3936
4529
  * Resolves `ssg.localeSwitcher`. Omitted / `false` stay off. `true` or an
@@ -4363,6 +4956,7 @@ function renderPage(page, options) {
4363
4956
  contributors: page.contributors,
4364
4957
  path: page.path,
4365
4958
  url: page.url,
4959
+ markdownSource: page.markdownSource,
4366
4960
  frontmatter: page.frontmatter,
4367
4961
  layout: page.layout
4368
4962
  },
@@ -4379,6 +4973,7 @@ function renderPage(page, options) {
4379
4973
  contributors: p.contributors,
4380
4974
  path: p.path,
4381
4975
  url: p.url,
4976
+ markdownSource: p.markdownSource,
4382
4977
  frontmatter: p.frontmatter,
4383
4978
  layout: p.layout
4384
4979
  }))
@@ -4437,8 +5032,8 @@ function DefaultTheme({ children }) {
4437
5032
  <head>
4438
5033
  <meta charset="UTF-8">
4439
5034
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
4440
- <title>${escapeHtml$4(page.title)} - ${escapeHtml$4(site.name)}</title>
4441
- ${page.description ? `<meta name="description" content="${escapeHtml$4(page.description)}">` : ""}
5035
+ <title>${escapeHtml$3(page.title)} - ${escapeHtml$3(site.name)}</title>
5036
+ ${page.description ? `<meta name="description" content="${escapeHtml$3(page.description)}">` : ""}
4442
5037
  <style>
4443
5038
  :root {
4444
5039
  --octc-color-primary: #4f6fae;
@@ -4462,7 +5057,7 @@ function DefaultTheme({ children }) {
4462
5057
  </head>
4463
5058
  <body>
4464
5059
  <header>
4465
- <h1>${escapeHtml$4(site.name)}</h1>
5060
+ <h1>${escapeHtml$3(site.name)}</h1>
4466
5061
  </header>
4467
5062
  <main>
4468
5063
  ${children.__html}
@@ -4470,7 +5065,7 @@ function DefaultTheme({ children }) {
4470
5065
  </body>
4471
5066
  </html>` };
4472
5067
  }
4473
- function escapeHtml$4(str) {
5068
+ function escapeHtml$3(str) {
4474
5069
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4475
5070
  }
4476
5071
  /**
@@ -4570,6 +5165,13 @@ async function writeSiteMapFiles(input) {
4570
5165
  }
4571
5166
  return { files };
4572
5167
  }
5168
+ /** UTC `YYYY-MM-DD` for W3C lastmod. Invalid or negative timestamps are dropped. */
5169
+ function formatLastmod(timestampMs) {
5170
+ if (timestampMs == null || !Number.isFinite(timestampMs) || timestampMs < 0) return;
5171
+ const date = new Date(timestampMs);
5172
+ if (Number.isNaN(date.getTime())) return;
5173
+ return date.toISOString().slice(0, 10);
5174
+ }
4573
5175
  function hasSiteUrl$2(siteUrl) {
4574
5176
  return Boolean(siteUrl && siteUrl.trim());
4575
5177
  }
@@ -4582,7 +5184,14 @@ function generateSitemapXml(pages) {
4582
5184
  for (const page of pages) {
4583
5185
  xml += " <url>\n <loc>";
4584
5186
  xml += escapeXml$1(page.loc);
4585
- xml += "</loc>\n </url>\n";
5187
+ xml += "</loc>\n";
5188
+ const lastmod = formatLastmod(page.lastUpdated);
5189
+ if (lastmod) {
5190
+ xml += " <lastmod>";
5191
+ xml += lastmod;
5192
+ xml += "</lastmod>\n";
5193
+ }
5194
+ xml += " </url>\n";
4586
5195
  }
4587
5196
  xml += "</urlset>\n";
4588
5197
  return xml;
@@ -4643,6 +5252,168 @@ function escapeLlmsUrl(value) {
4643
5252
  return escaped;
4644
5253
  }
4645
5254
  //#endregion
5255
+ //#region src/permalinks.ts
5256
+ const RESERVED_CASCADE_KEYS = /* @__PURE__ */ new Set(["permalink", "slug"]);
5257
+ /** Resolves `permalinks`. `false` / omitted stays off. `true` / `{}` enables. */
5258
+ function resolvePermalinksOptions(value) {
5259
+ return resolveFlag(value);
5260
+ }
5261
+ /** Resolves `cascade`. `false` / omitted stays off. `true` / `{}` enables. */
5262
+ function resolveCascadeOptions(value) {
5263
+ return resolveFlag(value);
5264
+ }
5265
+ /**
5266
+ * Applies cascade (when on) then permalink / slug rewriting (when on).
5267
+ *
5268
+ * Collisions skip the later page and keep the first. Rejected permalinks stay
5269
+ * on the file-tree URL. Hostile non-string values are ignored.
5270
+ */
5271
+ function resolvePageRoutes(input) {
5272
+ const cascaded = applyCascade(input.pages, input.cascade);
5273
+ if (!input.permalinks?.enabled) return {
5274
+ pages: cascaded.map((page) => ({
5275
+ source: page.source,
5276
+ urlPath: normalizeUrlPath$1(page.fileUrl),
5277
+ frontmatter: page.frontmatter
5278
+ })),
5279
+ errors: []
5280
+ };
5281
+ const pages = [];
5282
+ const errors = [];
5283
+ const claimed = /* @__PURE__ */ new Map();
5284
+ for (const page of cascaded) {
5285
+ const { urlPath, error } = resolveOne(page);
5286
+ if (error) errors.push(error);
5287
+ const owner = claimed.get(urlPath);
5288
+ if (owner) {
5289
+ errors.push(`[ox-content] URL collision at "${urlPath}": ${owner} kept, ${page.source} skipped`);
5290
+ continue;
5291
+ }
5292
+ claimed.set(urlPath, page.source);
5293
+ pages.push({
5294
+ source: page.source,
5295
+ urlPath,
5296
+ frontmatter: page.frontmatter
5297
+ });
5298
+ }
5299
+ return {
5300
+ pages,
5301
+ errors
5302
+ };
5303
+ }
5304
+ /** Escapes a value for use in an HTML attribute. */
5305
+ function escapeAttribute$2(value) {
5306
+ return value.replace(/[&<>"']/gu, (ch) => {
5307
+ switch (ch) {
5308
+ case "&": return "&amp;";
5309
+ case "<": return "&lt;";
5310
+ case ">": return "&gt;";
5311
+ case "\"": return "&quot;";
5312
+ default: return "&#39;";
5313
+ }
5314
+ });
5315
+ }
5316
+ function normalizeUrlPath$1(value) {
5317
+ const segments = pathSegments(value);
5318
+ return segments.length === 0 ? "/" : segments.join("/");
5319
+ }
5320
+ function resolveFlag(value) {
5321
+ if (!value) return { enabled: false };
5322
+ if (value === true) return { enabled: true };
5323
+ return { enabled: value.enabled !== false };
5324
+ }
5325
+ function applyCascade(pages, options) {
5326
+ if (!options?.enabled) return pages.map((page) => ({
5327
+ ...page,
5328
+ frontmatter: { ...page.frontmatter }
5329
+ }));
5330
+ const indexes = /* @__PURE__ */ new Map();
5331
+ for (const page of pages) {
5332
+ const source = normalizeSeparators(page.source);
5333
+ if (isIndexFile(source)) indexes.set(directoryOf(source), { ...page.frontmatter });
5334
+ }
5335
+ return pages.map((page) => {
5336
+ const source = normalizeSeparators(page.source);
5337
+ const frontmatter = { ...page.frontmatter };
5338
+ for (const dir of ancestorDirs(source)) {
5339
+ const defaults = indexes.get(dir);
5340
+ if (!defaults || isIndexFile(source) && directoryOf(source) === dir) continue;
5341
+ for (const [key, value] of Object.entries(defaults)) if (!RESERVED_CASCADE_KEYS.has(key) && !(key in frontmatter)) frontmatter[key] = value;
5342
+ }
5343
+ return {
5344
+ ...page,
5345
+ frontmatter
5346
+ };
5347
+ });
5348
+ }
5349
+ function resolveOne(page) {
5350
+ const fileUrl = normalizeUrlPath$1(page.fileUrl);
5351
+ const permalink = readString(page.frontmatter.permalink);
5352
+ if (permalink !== void 0) {
5353
+ const url = isSafePermalink(permalink) ? normalizeUrlPath$1(permalink) : void 0;
5354
+ return url ? { urlPath: url } : {
5355
+ urlPath: fileUrl,
5356
+ error: `[ox-content] rejected permalink ${JSON.stringify(permalink)} on ${page.source} (path escape); using the file-tree URL`
5357
+ };
5358
+ }
5359
+ const slug = readString(page.frontmatter.slug);
5360
+ if (slug !== void 0) {
5361
+ const url = rewriteSlug(fileUrl, slug);
5362
+ return url ? { urlPath: url } : {
5363
+ urlPath: fileUrl,
5364
+ error: `[ox-content] rejected slug ${JSON.stringify(slug)} on ${page.source} (path escape); using the file-tree URL`
5365
+ };
5366
+ }
5367
+ return { urlPath: fileUrl };
5368
+ }
5369
+ function rewriteSlug(fileUrl, slug) {
5370
+ const trimmed = slug.trim();
5371
+ if (trimmed.includes("/") || !isSafePermalink(trimmed)) return;
5372
+ const normalized = normalizeUrlPath$1(trimmed);
5373
+ if (normalized === "/") return;
5374
+ if (fileUrl === "/") return normalized;
5375
+ const segments = fileUrl.split("/").filter(Boolean);
5376
+ segments.pop();
5377
+ segments.push(normalized);
5378
+ return segments.join("/");
5379
+ }
5380
+ function isSafePermalink(value) {
5381
+ const trimmed = value.trim();
5382
+ if (!trimmed || /[\n\r\0]/u.test(trimmed) || trimmed.includes("\\") || trimmed.startsWith("//")) return false;
5383
+ if (/^[A-Za-z]:/u.test(trimmed)) return false;
5384
+ const lower = trimmed.toLowerCase();
5385
+ if (lower.includes("javascript:") || lower.includes("data:") || lower.includes("vbscript:") || lower.includes("file:") || lower.includes("://")) return false;
5386
+ return pathSegments(trimmed).every((segment) => segment !== ".." && segment !== ".");
5387
+ }
5388
+ function pathSegments(value) {
5389
+ return value.trim().replace(/^\/+|\/+$/gu, "").split("/").filter(Boolean);
5390
+ }
5391
+ function readString(value) {
5392
+ return typeof value === "string" ? value : void 0;
5393
+ }
5394
+ function normalizeSeparators(value) {
5395
+ return value.replaceAll("\\", "/");
5396
+ }
5397
+ function isIndexFile(source) {
5398
+ const name = source.split("/").pop() ?? source;
5399
+ return (name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : name).toLowerCase() === "_index";
5400
+ }
5401
+ function directoryOf(source) {
5402
+ const index = source.lastIndexOf("/");
5403
+ return index === -1 ? "" : source.slice(0, index);
5404
+ }
5405
+ function ancestorDirs(source) {
5406
+ const dir = directoryOf(source);
5407
+ const dirs = [""];
5408
+ if (!dir) return dirs;
5409
+ let acc = "";
5410
+ for (const segment of dir.split("/")) {
5411
+ acc = acc ? `${acc}/${segment}` : segment;
5412
+ dirs.push(acc);
5413
+ }
5414
+ return dirs;
5415
+ }
5416
+ //#endregion
4646
5417
  //#region src/publish-state.ts
4647
5418
  /**
4648
5419
  * Opt-in draft / unlisted / scheduled page classification.
@@ -4733,160 +5504,202 @@ function hiddenNavKeys(pages, listed) {
4733
5504
  function toNapiPublishState(options) {
4734
5505
  if (!options) return;
4735
5506
  return {
4736
- enabled: options.enabled,
4737
- now: options.now,
4738
- includeDrafts: options.includeDrafts
5507
+ enabled: options.enabled,
5508
+ now: options.now,
5509
+ includeDrafts: options.includeDrafts
5510
+ };
5511
+ }
5512
+ //#endregion
5513
+ //#region src/markdown-source.ts
5514
+ /**
5515
+ * Opt-in Markdown source companions written beside generated HTML.
5516
+ *
5517
+ * Copies already-read source bytes. Does not re-parse Markdown to emit them.
5518
+ */
5519
+ /**
5520
+ * Resolves `ssg.markdownSource` with defaults.
5521
+ *
5522
+ * `false` / omitted stays off. `true` enables companions and the alternate
5523
+ * link. An object enables the feature and overrides only the fields set.
5524
+ */
5525
+ function resolveMarkdownSourceOptions(value) {
5526
+ if (!value) return {
5527
+ enabled: false,
5528
+ alternate: true
5529
+ };
5530
+ if (value === true) return {
5531
+ enabled: true,
5532
+ alternate: true
5533
+ };
5534
+ return {
5535
+ enabled: true,
5536
+ alternate: value.alternate !== false
4739
5537
  };
4740
5538
  }
4741
- //#endregion
4742
- //#region src/permalinks.ts
4743
- const RESERVED_CASCADE_KEYS = /* @__PURE__ */ new Set(["permalink", "slug"]);
4744
- /** Resolves `permalinks`. `false` / omitted stays off. `true` / `{}` enables. */
4745
- function resolvePermalinksOptions(value) {
4746
- return resolveFlag(value);
5539
+ /** Companion href for one page after permalink / publish-state checks. */
5540
+ function markdownSourceHrefForPage(input) {
5541
+ if (!shouldPublishMarkdownSource(input.frontmatter, input.publishState)) return;
5542
+ return markdownSourceHref(resolvePageRoutes({
5543
+ pages: [{
5544
+ source: input.source,
5545
+ fileUrl: input.fileUrl,
5546
+ frontmatter: input.frontmatter
5547
+ }],
5548
+ permalinks: input.permalinks,
5549
+ cascade: input.cascade
5550
+ }).pages[0]?.urlPath ?? input.fileUrl, input.base);
4747
5551
  }
4748
- /** Resolves `cascade`. `false` / omitted stays off. `true` / `{}` enables. */
4749
- function resolveCascadeOptions(value) {
4750
- return resolveFlag(value);
5552
+ /** Public companion href, including `base`. Always ends in `.md`. */
5553
+ function markdownSourceHref(urlPath, base) {
5554
+ const relative = companionRelativePath(urlPath);
5555
+ if (!relative) return;
5556
+ return `${normalizeBase$1(base)}${relative}`;
5557
+ }
5558
+ /** Filesystem path for a companion, or `undefined` when it would escape `outDir`. */
5559
+ function markdownSourceOutputPath(outDir, urlPath) {
5560
+ const relative = companionRelativePath(urlPath);
5561
+ if (!relative) return;
5562
+ return containedPath$3(outDir, ...relative.split("/"));
4751
5563
  }
4752
5564
  /**
4753
- * Applies cascade (when on) then permalink / slug rewriting (when on).
5565
+ * Whether this page may publish a companion.
4754
5566
  *
4755
- * Collisions skip the later page and keep the first. Rejected permalinks stay
4756
- * on the file-tree URL. Hostile non-string values are ignored.
4757
- */
4758
- function resolvePageRoutes(input) {
4759
- const cascaded = applyCascade(input.pages, input.cascade);
4760
- if (!input.permalinks?.enabled) return {
4761
- pages: cascaded.map((page) => ({
4762
- source: page.source,
4763
- urlPath: normalizeUrlPath$1(page.fileUrl),
4764
- frontmatter: page.frontmatter
4765
- })),
5567
+ * Draft and unlisted source is never emitted. When `publishState` is on,
5568
+ * scheduled / expired pages follow that filter and `includeDrafts` is ignored
5569
+ * so preview HTML cannot leak source.
5570
+ */
5571
+ function shouldPublishMarkdownSource(frontmatter, publishState) {
5572
+ if (frontmatter.draft === true || frontmatter.unlisted === true) return false;
5573
+ if (!publishState?.enabled) return true;
5574
+ return classifyPublishState(frontmatter, {
5575
+ ...publishState,
5576
+ includeDrafts: false
5577
+ }).output;
5578
+ }
5579
+ /** Inserts `<link rel="alternate" type="text/markdown">` before `</head>`. */
5580
+ function injectMarkdownSourceAlternate(html, href) {
5581
+ if (!href || !/<\/head>/i.test(html)) return html;
5582
+ const tag = `<link rel="alternate" type="text/markdown" href="${escapeAttribute$2(href)}">`;
5583
+ const index = html.toLowerCase().lastIndexOf("</head>");
5584
+ return `${html.slice(0, index)} ${tag}\n${html.slice(index)}`;
5585
+ }
5586
+ /** Writes enabled companions from already-read source bytes. */
5587
+ async function writeMarkdownSourceFiles(input) {
5588
+ if (!input.options?.enabled) return {
5589
+ files: [],
4766
5590
  errors: []
4767
5591
  };
4768
- const pages = [];
5592
+ const files = [];
4769
5593
  const errors = [];
4770
- const claimed = /* @__PURE__ */ new Map();
4771
- for (const page of cascaded) {
4772
- const { urlPath, error } = resolveOne(page);
4773
- if (error) errors.push(error);
4774
- const owner = claimed.get(urlPath);
4775
- if (owner) {
4776
- errors.push(`[ox-content] URL collision at "${urlPath}": ${owner} kept, ${page.source} skipped`);
5594
+ const seen = /* @__PURE__ */ new Map();
5595
+ for (const page of input.pages) {
5596
+ if (page.source == null || !shouldPublishMarkdownSource(page.frontmatter, input.publishState)) continue;
5597
+ const outputPath = markdownSourceOutputPath(input.outDir, page.urlPath);
5598
+ if (!outputPath) {
5599
+ errors.push(`[ox-content] markdownSource skipped path-escape for ${page.inputPath}`);
4777
5600
  continue;
4778
5601
  }
4779
- claimed.set(urlPath, page.source);
4780
- pages.push({
4781
- source: page.source,
4782
- urlPath,
4783
- frontmatter: page.frontmatter
4784
- });
5602
+ const previous = seen.get(outputPath);
5603
+ if (previous) {
5604
+ errors.push(`[ox-content] markdownSource collision: ${page.inputPath} and ${previous} both map to ${outputPath}`);
5605
+ continue;
5606
+ }
5607
+ seen.set(outputPath, page.inputPath);
5608
+ await fs$2.mkdir(path$1.dirname(outputPath), { recursive: true });
5609
+ await fs$2.writeFile(outputPath, page.source);
5610
+ files.push(outputPath);
4785
5611
  }
4786
5612
  return {
4787
- pages,
5613
+ files,
4788
5614
  errors
4789
5615
  };
4790
5616
  }
4791
- function normalizeUrlPath$1(value) {
4792
- const segments = pathSegments(value);
4793
- return segments.length === 0 ? "/" : segments.join("/");
4794
- }
4795
- function resolveFlag(value) {
4796
- if (!value) return { enabled: false };
4797
- if (value === true) return { enabled: true };
4798
- return { enabled: value.enabled !== false };
5617
+ /** True when the request pathname is a `.md` companion URL. */
5618
+ function isMarkdownSourceRequest(pathname) {
5619
+ const clean = stripSearch(pathname);
5620
+ return clean.toLowerCase().endsWith(".md") && !clean.includes("\\");
4799
5621
  }
4800
- function applyCascade(pages, options) {
4801
- if (!options?.enabled) return pages.map((page) => ({
4802
- ...page,
4803
- frontmatter: { ...page.frontmatter }
4804
- }));
4805
- const indexes = /* @__PURE__ */ new Map();
4806
- for (const page of pages) {
4807
- const source = normalizeSeparators(page.source);
4808
- if (isIndexFile(source)) indexes.set(directoryOf(source), { ...page.frontmatter });
4809
- }
4810
- return pages.map((page) => {
4811
- const source = normalizeSeparators(page.source);
4812
- const frontmatter = { ...page.frontmatter };
4813
- for (const dir of ancestorDirs(source)) {
4814
- const defaults = indexes.get(dir);
4815
- if (!defaults || isIndexFile(source) && directoryOf(source) === dir) continue;
4816
- for (const [key, value] of Object.entries(defaults)) if (!RESERVED_CASCADE_KEYS.has(key) && !(key in frontmatter)) frontmatter[key] = value;
4817
- }
5622
+ /** Builds a companion index from source files without transforming Markdown. */
5623
+ async function buildMarkdownSourceIndex(input) {
5624
+ const loaded = await Promise.all(input.files.map(async (file) => {
5625
+ const source = await fs$2.readFile(file, "utf8");
4818
5626
  return {
4819
- ...page,
4820
- frontmatter
5627
+ source: file,
5628
+ fileUrl: importNapiModuleSync().getSsgUrlPath(file, input.srcDir),
5629
+ frontmatter: parseSourceFrontmatter(source),
5630
+ body: source
4821
5631
  };
5632
+ }));
5633
+ const routed = resolvePageRoutes({
5634
+ pages: loaded.map(({ source, fileUrl, frontmatter }) => ({
5635
+ source,
5636
+ fileUrl,
5637
+ frontmatter
5638
+ })),
5639
+ permalinks: input.permalinks,
5640
+ cascade: input.cascade
4822
5641
  });
4823
- }
4824
- function resolveOne(page) {
4825
- const fileUrl = normalizeUrlPath$1(page.fileUrl);
4826
- const permalink = readString(page.frontmatter.permalink);
4827
- if (permalink !== void 0) {
4828
- const url = isSafePermalink(permalink) ? normalizeUrlPath$1(permalink) : void 0;
4829
- return url ? { urlPath: url } : {
4830
- urlPath: fileUrl,
4831
- error: `[ox-content] rejected permalink ${JSON.stringify(permalink)} on ${page.source} (path escape); using the file-tree URL`
4832
- };
4833
- }
4834
- const slug = readString(page.frontmatter.slug);
4835
- if (slug !== void 0) {
4836
- const url = rewriteSlug(fileUrl, slug);
4837
- return url ? { urlPath: url } : {
4838
- urlPath: fileUrl,
4839
- error: `[ox-content] rejected slug ${JSON.stringify(slug)} on ${page.source} (path escape); using the file-tree URL`
4840
- };
5642
+ const bodies = new Map(loaded.map((page) => [page.source, page.body]));
5643
+ const index = /* @__PURE__ */ new Map();
5644
+ for (const page of routed.pages) {
5645
+ const href = markdownSourceHref(page.urlPath, "/");
5646
+ const body = bodies.get(page.source);
5647
+ if (!href || body == null) continue;
5648
+ index.set(normalizePathname(href), {
5649
+ source: body,
5650
+ allowed: shouldPublishMarkdownSource(page.frontmatter, input.publishState)
5651
+ });
4841
5652
  }
4842
- return { urlPath: fileUrl };
5653
+ return index;
4843
5654
  }
4844
- function rewriteSlug(fileUrl, slug) {
4845
- const trimmed = slug.trim();
4846
- if (trimmed.includes("/") || !isSafePermalink(trimmed)) return;
4847
- const normalized = normalizeUrlPath$1(trimmed);
4848
- if (normalized === "/") return;
4849
- if (fileUrl === "/") return normalized;
4850
- const segments = fileUrl.split("/").filter(Boolean);
4851
- segments.pop();
4852
- segments.push(normalized);
4853
- return segments.join("/");
5655
+ /** Looks up a companion after the site `base` has been stripped. */
5656
+ function resolveMarkdownSourceRequest(pathname, index) {
5657
+ if (!isMarkdownSourceRequest(pathname)) return;
5658
+ return index.get(normalizePathname(stripSearch(pathname)));
4854
5659
  }
4855
- function isSafePermalink(value) {
4856
- const trimmed = value.trim();
4857
- if (!trimmed || /[\n\r\0]/u.test(trimmed) || trimmed.includes("\\") || trimmed.startsWith("//")) return false;
4858
- if (/^[A-Za-z]:/u.test(trimmed)) return false;
4859
- const lower = trimmed.toLowerCase();
4860
- if (lower.includes("javascript:") || lower.includes("data:") || lower.includes("vbscript:") || lower.includes("file:") || lower.includes("://")) return false;
4861
- return pathSegments(trimmed).every((segment) => segment !== ".." && segment !== ".");
5660
+ /** Frontmatter keys only — not a Markdown parse. */
5661
+ function parseSourceFrontmatter(source) {
5662
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
5663
+ if (!match?.[1]) return {};
5664
+ const result = {};
5665
+ for (const line of match[1].split("\n")) {
5666
+ const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
5667
+ if (!kv) continue;
5668
+ result[kv[1]] = parseFrontmatterScalar(kv[2].trim());
5669
+ }
5670
+ return result;
4862
5671
  }
4863
- function pathSegments(value) {
4864
- return value.trim().replace(/^\/+|\/+$/gu, "").split("/").filter(Boolean);
5672
+ function parseFrontmatterScalar(value) {
5673
+ if (value === "true") return true;
5674
+ if (value === "false") return false;
5675
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
5676
+ return value;
4865
5677
  }
4866
- function readString(value) {
4867
- return typeof value === "string" ? value : void 0;
5678
+ function companionRelativePath(urlPath) {
5679
+ const trimmed = urlPath === "/" || !urlPath ? "index" : urlPath.replace(/^\/+|\/+$/gu, "");
5680
+ if (!trimmed) return;
5681
+ if (trimmed.split("/").some((segment) => !segment || segment === "." || segment === "..")) return;
5682
+ return `${trimmed}.md`;
4868
5683
  }
4869
- function normalizeSeparators(value) {
4870
- return value.replaceAll("\\", "/");
5684
+ function containedPath$3(outDir, ...segments) {
5685
+ const root = path$1.resolve(outDir);
5686
+ const resolved = path$1.resolve(root, ...segments);
5687
+ const prefix = root.endsWith(path$1.sep) ? root : `${root}${path$1.sep}`;
5688
+ if (resolved === root || !resolved.startsWith(prefix)) return;
5689
+ return resolved;
4871
5690
  }
4872
- function isIndexFile(source) {
4873
- const name = source.split("/").pop() ?? source;
4874
- return (name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : name).toLowerCase() === "_index";
5691
+ function normalizeBase$1(base) {
5692
+ if (!base || base === "/") return "/";
5693
+ return base.endsWith("/") ? base : `${base}/`;
4875
5694
  }
4876
- function directoryOf(source) {
4877
- const index = source.lastIndexOf("/");
4878
- return index === -1 ? "" : source.slice(0, index);
5695
+ function stripSearch(pathname) {
5696
+ return pathname.split("?")[0]?.split("#")[0] ?? pathname;
4879
5697
  }
4880
- function ancestorDirs(source) {
4881
- const dir = directoryOf(source);
4882
- const dirs = [""];
4883
- if (!dir) return dirs;
4884
- let acc = "";
4885
- for (const segment of dir.split("/")) {
4886
- acc = acc ? `${acc}/${segment}` : segment;
4887
- dirs.push(acc);
4888
- }
4889
- return dirs;
5698
+ function normalizePathname(pathname) {
5699
+ const clean = stripSearch(pathname);
5700
+ if (!clean || clean === "/") return "/";
5701
+ const withSlash = clean.startsWith("/") ? clean : `/${clean}`;
5702
+ return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
4890
5703
  }
4891
5704
  //#endregion
4892
5705
  //#region src/apply-permalinks.ts
@@ -5118,7 +5931,7 @@ async function writeRedirectFiles(input) {
5118
5931
  }
5119
5932
  /** Static HTML redirect body. `dest` is escaped. */
5120
5933
  function generateRedirectHtml(dest) {
5121
- const escaped = escapeHtml$3(dest);
5934
+ const escaped = escapeHtml$2(dest);
5122
5935
  return `\
5123
5936
  <!DOCTYPE html>
5124
5937
  <html lang="en">
@@ -5203,7 +6016,7 @@ function upsert(files, index, occupied, from, to, base) {
5203
6016
  html
5204
6017
  });
5205
6018
  }
5206
- function escapeHtml$3(value) {
6019
+ function escapeHtml$2(value) {
5207
6020
  return value.replace(/[&<>"']/g, (ch) => {
5208
6021
  switch (ch) {
5209
6022
  case "&": return "&amp;";
@@ -5561,6 +6374,7 @@ function createNativeTransformOptions(options) {
5561
6374
  return {
5562
6375
  gfm: options.gfm,
5563
6376
  footnotes: options.footnotes,
6377
+ semanticFootnotes: options.semanticFootnotes ?? false,
5564
6378
  taskLists: options.taskLists,
5565
6379
  tables: options.tables,
5566
6380
  strikethrough: options.strikethrough,
@@ -5568,6 +6382,7 @@ function createNativeTransformOptions(options) {
5568
6382
  autolinkUrls: options.autolinks,
5569
6383
  frontmatter: options.frontmatter,
5570
6384
  tocMaxDepth: options.tocMaxDepth,
6385
+ headingPermalinks: options.headingPermalinks?.enabled ?? false,
5571
6386
  codeAnnotations: options.codeAnnotations?.enabled ?? false,
5572
6387
  codeAnnotationMetaKey: options.codeAnnotations?.metaKey ?? "annotate",
5573
6388
  codeAnnotationSyntax: options.codeAnnotations?.notation ?? "attribute",
@@ -5582,6 +6397,13 @@ function createNativeTransformOptions(options) {
5582
6397
  } : void 0,
5583
6398
  attributes: options.attrs?.enabled ? { enabled: true } : void 0,
5584
6399
  badges: options.badges?.enabled ? { enabled: true } : void 0,
6400
+ magicLinks: options.magicLinks?.enabled ? {
6401
+ enabled: true,
6402
+ aliases: options.magicLinks.aliases,
6403
+ favicon: options.magicLinks.favicon,
6404
+ faviconTemplate: options.magicLinks.faviconTemplate,
6405
+ imageOverrides: options.magicLinks.imageOverrides
6406
+ } : void 0,
5585
6407
  containers: options.containers?.enabled ? {
5586
6408
  enabled: true,
5587
6409
  types: options.containers.types
@@ -6006,6 +6828,7 @@ function isExcludedFromFeed(item, publishState) {
6006
6828
  const frontmatter = item.frontmatter ?? {};
6007
6829
  if (item.draft === true || frontmatter.draft === true) return true;
6008
6830
  if (item.unlisted === true || frontmatter.unlisted === true) return true;
6831
+ if (frontmatter.external === true) return true;
6009
6832
  if (!publishState?.enabled) return false;
6010
6833
  return !classifyPublishState({
6011
6834
  ...frontmatter,
@@ -6246,14 +7069,14 @@ function relatedMarkup(pages) {
6246
7069
  }
6247
7070
  function listPageContent(terms, base, urlName) {
6248
7071
  const items = terms.map((term) => listItem$1(siteHref$3(base, urlName, term.slug), term.label)).join("");
6249
- return `<h1>${escapeHtml$2(displayTaxonomyName(urlName))}</h1><ul class="ox-taxonomy">${items}</ul>`;
7072
+ return `<h1>${escapeHtml$1(displayTaxonomyName(urlName))}</h1><ul class="ox-taxonomy">${items}</ul>`;
6250
7073
  }
6251
7074
  function termPageContent(term) {
6252
7075
  const items = [...term.pages].sort((left, right) => {
6253
7076
  const titleCmp = left.title.localeCompare(right.title);
6254
7077
  return titleCmp !== 0 ? titleCmp : left.routePaths.href.localeCompare(right.routePaths.href);
6255
7078
  }).map((page) => listItem$1(page.routePaths.href, page.title)).join("");
6256
- return `<h1>${escapeHtml$2(term.label)}</h1><ul class="ox-taxonomy-term">${items}</ul>`;
7079
+ return `<h1>${escapeHtml$1(term.label)}</h1><ul class="ox-taxonomy-term">${items}</ul>`;
6257
7080
  }
6258
7081
  function displayTaxonomyName(name) {
6259
7082
  return name.charAt(0).toUpperCase() + name.slice(1);
@@ -6271,9 +7094,9 @@ function containedPath$2(outDir, ...segments) {
6271
7094
  return resolved;
6272
7095
  }
6273
7096
  function listItem$1(href, label) {
6274
- return `<li><a href="${escapeHtml$2(href)}">${escapeHtml$2(label)}</a></li>`;
7097
+ return `<li><a href="${escapeHtml$1(href)}">${escapeHtml$1(label)}</a></li>`;
6275
7098
  }
6276
- function escapeHtml$2(value) {
7099
+ function escapeHtml$1(value) {
6277
7100
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
6278
7101
  }
6279
7102
  //#endregion
@@ -6547,96 +7370,441 @@ function resolveBlogOptions(value) {
6547
7370
  if (!value) return {
6548
7371
  enabled: false,
6549
7372
  authors: {},
6550
- pageSize: DEFAULT_PAGE_SIZE
7373
+ pageSize: DEFAULT_PAGE_SIZE,
7374
+ feeds: []
6551
7375
  };
6552
7376
  if (value === true) return {
6553
7377
  enabled: true,
6554
7378
  authors: {},
6555
- pageSize: DEFAULT_PAGE_SIZE
7379
+ pageSize: DEFAULT_PAGE_SIZE,
7380
+ feeds: []
7381
+ };
7382
+ return {
7383
+ enabled: true,
7384
+ collection: value.collection,
7385
+ authors: normalizeAuthors(value.authors),
7386
+ pageSize: normalizePageSize(value.pageSize),
7387
+ feeds: normalizeFeeds(value.feeds)
6556
7388
  };
7389
+ }
7390
+ /**
7391
+ * Picks a collection named `blog`, else the only configured collection.
7392
+ *
7393
+ * An explicit name always wins. Several collections and no `blog` name
7394
+ * require `blog.collection`.
7395
+ */
7396
+ function resolveBlogCollectionName(requested, collectionNames) {
7397
+ if (requested) return requested;
7398
+ if (collectionNames.includes("blog")) return "blog";
7399
+ if (collectionNames.length === 1) return collectionNames[0];
7400
+ }
7401
+ function normalizeAuthors(authors) {
7402
+ if (!authors || typeof authors !== "object") return {};
7403
+ const resolved = {};
7404
+ for (const [key, value] of Object.entries(authors)) {
7405
+ if (!value || typeof value.name !== "string") continue;
7406
+ resolved[key] = {
7407
+ name: value.name,
7408
+ bio: typeof value.bio === "string" ? value.bio : void 0,
7409
+ url: typeof value.url === "string" ? value.url : void 0
7410
+ };
7411
+ }
7412
+ return resolved;
7413
+ }
7414
+ function normalizePageSize(value) {
7415
+ if (typeof value === "number" && Number.isFinite(value) && value >= 1) return Math.floor(value);
7416
+ return DEFAULT_PAGE_SIZE;
7417
+ }
7418
+ function normalizeFeeds(feeds) {
7419
+ if (!Array.isArray(feeds)) return [];
7420
+ const resolved = [];
7421
+ for (const entry of feeds) {
7422
+ if (typeof entry === "string") {
7423
+ const url = entry.trim();
7424
+ if (url) resolved.push({
7425
+ url,
7426
+ onError: "warn"
7427
+ });
7428
+ continue;
7429
+ }
7430
+ if (!entry || typeof entry !== "object" || typeof entry.url !== "string") continue;
7431
+ const url = entry.url.trim();
7432
+ if (!url) continue;
7433
+ const language = trimOptional(entry.language);
7434
+ const author = trimOptional(entry.author);
7435
+ resolved.push({
7436
+ url,
7437
+ ...language ? { language } : {},
7438
+ ...author ? { author } : {},
7439
+ onError: entry.onError === "error" ? "error" : "warn"
7440
+ });
7441
+ }
7442
+ return resolved;
7443
+ }
7444
+ function trimOptional(value) {
7445
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
7446
+ }
7447
+ //#endregion
7448
+ //#region src/blog-feed-date.ts
7449
+ /**
7450
+ * Publication dates from RSS / Atom items.
7451
+ */
7452
+ const MONTHS = {
7453
+ jan: 1,
7454
+ feb: 2,
7455
+ mar: 3,
7456
+ apr: 4,
7457
+ may: 5,
7458
+ jun: 6,
7459
+ jul: 7,
7460
+ aug: 8,
7461
+ sep: 9,
7462
+ oct: 10,
7463
+ nov: 11,
7464
+ dec: 12
7465
+ };
7466
+ 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}))$/;
7467
+ function parseFeedDate(value) {
7468
+ const trimmed = value?.trim();
7469
+ if (!trimmed) return;
7470
+ return parseDate(trimmed) ?? parseRfc822(trimmed);
7471
+ }
7472
+ function feedDateLabel(date) {
7473
+ return `${String(date.year).padStart(4, "0")}-${String(date.month).padStart(2, "0")}-${String(date.day).padStart(2, "0")}`;
7474
+ }
7475
+ function feedDateIso(date) {
7476
+ return `${feedDateLabel(date)}T${String(date.hour).padStart(2, "0")}:${String(date.minute).padStart(2, "0")}:${String(date.second).padStart(2, "0")}Z`;
7477
+ }
7478
+ function parseRfc822(value) {
7479
+ const match = value.match(RFC822);
7480
+ if (!match) return;
7481
+ const month = MONTHS[match[2]?.toLowerCase() ?? ""];
7482
+ if (!month) return;
7483
+ const day = match[1]?.padStart(2, "0");
7484
+ const year = match[3];
7485
+ const hour = match[4];
7486
+ const minute = match[5];
7487
+ const second = (match[6] ?? "00").padStart(2, "0");
7488
+ const zone = match[7];
7489
+ const tz = zone ? `${zone.slice(0, 3)}:${zone.slice(3)}` : "Z";
7490
+ return parseDate(`${year}-${String(month).padStart(2, "0")}-${day}T${hour}:${minute}:${second}${tz}`);
7491
+ }
7492
+ //#endregion
7493
+ //#region src/blog-feed-url.ts
7494
+ /**
7495
+ * Safe-URL checks for configured external blog feeds.
7496
+ */
7497
+ const CONTROL_CHARS = /[\n\r\t\0]/;
7498
+ function isSafeFeedUrl(value) {
7499
+ const trimmed = value.trim();
7500
+ if (!trimmed || CONTROL_CHARS.test(trimmed)) return false;
7501
+ try {
7502
+ const url = new URL(trimmed);
7503
+ if (url.protocol !== "https:") return false;
7504
+ if (url.username || url.password) return false;
7505
+ return !isBlockedFeedHost(url.hostname);
7506
+ } catch {
7507
+ return false;
7508
+ }
7509
+ }
7510
+ function canonicalizeFeedItemUrl(value) {
7511
+ if (!isSafeFeedUrl(value)) return;
7512
+ const url = new URL(value.trim());
7513
+ url.hash = "";
7514
+ url.username = "";
7515
+ url.password = "";
7516
+ if (url.port === "443") url.port = "";
7517
+ url.hostname = url.hostname.toLowerCase();
7518
+ let href = url.href;
7519
+ if (url.pathname !== "/" && href.endsWith("/")) href = href.slice(0, -1);
7520
+ return href;
7521
+ }
7522
+ function isBlockedFeedHost(hostname) {
7523
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
7524
+ if (!host || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
7525
+ if (host.includes(":")) return isBlockedIPv6(host);
7526
+ return isIPv4(host) ? isBlockedIPv4(host) : false;
7527
+ }
7528
+ function isBlockedFeedAddress(address) {
7529
+ const value = address.toLowerCase().replace(/^\[|\]$/g, "");
7530
+ if (value.includes(":")) return isBlockedIPv6(value);
7531
+ return isIPv4(value) ? isBlockedIPv4(value) : true;
7532
+ }
7533
+ function isIPv4(value) {
7534
+ const parts = value.split(".");
7535
+ if (parts.length !== 4) return false;
7536
+ return parts.every((part) => {
7537
+ const n = Number(part);
7538
+ return Number.isInteger(n) && n >= 0 && n <= 255 && String(n) === part;
7539
+ });
7540
+ }
7541
+ function isBlockedIPv4(ip) {
7542
+ const [a, b] = ip.split(".").map(Number);
7543
+ return a === 0 || a === 10 || a === 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
7544
+ }
7545
+ function isBlockedIPv6(ip) {
7546
+ if (ip === "::" || ip === "::1") return true;
7547
+ if (ip.startsWith("::ffff:")) {
7548
+ const mapped = ip.slice(7);
7549
+ return isIPv4(mapped) ? isBlockedIPv4(mapped) : true;
7550
+ }
7551
+ const first = Number.parseInt(ip.split(":")[0] ?? "", 16);
7552
+ if (!Number.isFinite(first)) return true;
7553
+ if (first >= 65152 && first <= 65215) return true;
7554
+ return (first & 65024) === 64512;
7555
+ }
7556
+ let installedNetwork = {};
7557
+ async function fetchBlogFeedBody(url, network = {}) {
7558
+ const timeoutMs = network.limits?.timeoutMs ?? installedNetwork.limits?.timeoutMs ?? 1e4;
7559
+ const maxBytes = network.limits?.maxBytes ?? installedNetwork.limits?.maxBytes ?? 1048576;
7560
+ const maxRedirects = network.limits?.maxRedirects ?? installedNetwork.limits?.maxRedirects ?? 5;
7561
+ const fetchFn = network.fetch ?? installedNetwork.fetch ?? defaultFetch;
7562
+ const lookup = network.lookup ?? installedNetwork.lookup ?? defaultLookup;
7563
+ const controller = new AbortController();
7564
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
7565
+ try {
7566
+ return await followFeed(url, fetchFn, lookup, controller.signal, maxBytes, maxRedirects);
7567
+ } catch (error) {
7568
+ if (isAbortError(error)) throw new Error("timeout");
7569
+ throw error instanceof Error ? error : new Error(String(error));
7570
+ } finally {
7571
+ clearTimeout(timer);
7572
+ }
7573
+ }
7574
+ async function followFeed(startUrl, fetchFn, lookup, signal, maxBytes, maxRedirects) {
7575
+ const seen = /* @__PURE__ */ new Set();
7576
+ let current = startUrl;
7577
+ for (let hops = 0; hops <= maxRedirects; hops += 1) {
7578
+ await assertSafeFeedTarget(current, lookup);
7579
+ if (seen.has(current)) throw new Error("too many redirects");
7580
+ seen.add(current);
7581
+ const response = await fetchFn(current, {
7582
+ method: "GET",
7583
+ redirect: "manual",
7584
+ signal,
7585
+ headers: {
7586
+ Accept: "application/rss+xml, application/atom+xml, application/xml, text/xml;q=0.9",
7587
+ "User-Agent": "ox-content-blog-feeds/1.0"
7588
+ }
7589
+ });
7590
+ if (isRedirect(response.status)) {
7591
+ current = resolveRedirect(current, response.headers.get("location"));
7592
+ continue;
7593
+ }
7594
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
7595
+ assertFeedContentType(response.headers.get("content-type"));
7596
+ return readBoundedBody(response, maxBytes);
7597
+ }
7598
+ throw new Error("too many redirects");
7599
+ }
7600
+ async function assertSafeFeedTarget(url, lookup) {
7601
+ if (!isSafeFeedUrl(url)) throw new Error("unsafe URL");
7602
+ const hostname = new URL(url).hostname;
7603
+ const addresses = await lookup(hostname);
7604
+ if (addresses.length === 0 || addresses.some((address) => isBlockedFeedAddress(address))) throw new Error("private network");
7605
+ }
7606
+ async function defaultLookup(hostname) {
7607
+ return (await lookup(hostname, {
7608
+ all: true,
7609
+ verbatim: true
7610
+ })).map((record) => record.address);
7611
+ }
7612
+ function defaultFetch(input, init) {
7613
+ return fetch(input, init);
7614
+ }
7615
+ function isRedirect(status) {
7616
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
7617
+ }
7618
+ function resolveRedirect(current, location) {
7619
+ if (!location?.trim()) throw new Error("too many redirects");
7620
+ try {
7621
+ return new URL(location, current).href;
7622
+ } catch {
7623
+ throw new Error("unsafe URL");
7624
+ }
7625
+ }
7626
+ function assertFeedContentType(value) {
7627
+ if (!value) return;
7628
+ const type = value.split(";")[0]?.trim().toLowerCase() ?? "";
7629
+ if (type === "text/html" || type === "application/xhtml+xml" || type === "application/json") throw new Error("not a feed");
7630
+ }
7631
+ async function readBoundedBody(response, maxBytes) {
7632
+ const length = Number(response.headers.get("content-length"));
7633
+ if (Number.isFinite(length) && length > maxBytes) throw new Error("oversized");
7634
+ const reader = response.body?.getReader();
7635
+ if (!reader) {
7636
+ const text = await response.text();
7637
+ if (new TextEncoder().encode(text).byteLength > maxBytes) throw new Error("oversized");
7638
+ return text;
7639
+ }
7640
+ const chunks = [];
7641
+ let total = 0;
7642
+ while (true) {
7643
+ const { done, value } = await reader.read();
7644
+ if (done) break;
7645
+ if (!value) continue;
7646
+ total += value.byteLength;
7647
+ if (total > maxBytes) {
7648
+ await reader.cancel();
7649
+ throw new Error("oversized");
7650
+ }
7651
+ chunks.push(value);
7652
+ }
7653
+ return new TextDecoder("utf-8").decode(concatBytes(chunks, total));
7654
+ }
7655
+ function concatBytes(chunks, total) {
7656
+ const out = new Uint8Array(total);
7657
+ let offset = 0;
7658
+ for (const chunk of chunks) {
7659
+ out.set(chunk, offset);
7660
+ offset += chunk.byteLength;
7661
+ }
7662
+ return out;
7663
+ }
7664
+ function isAbortError(error) {
7665
+ return error instanceof Error && (error.name === "AbortError" || error.message === "timeout");
7666
+ }
7667
+ //#endregion
7668
+ //#region src/blog-feed-parse.ts
7669
+ /**
7670
+ * RSS 2.0 / Atom 1.0 item extraction. HTML documents are rejected.
7671
+ */
7672
+ const ITEM_BLOCK = /<(?:[\w.-]+:)?item\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?item>/gi;
7673
+ const ENTRY_BLOCK = /<(?:[\w.-]+:)?entry\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?entry>/gi;
7674
+ function parseBlogFeed(body, feedLanguage) {
7675
+ const xml = stripBom(body);
7676
+ if (looksLikeHtml(xml)) throw new Error("not a feed");
7677
+ if (!looksLikeXmlFeed(xml)) throw new Error("malformed XML");
7678
+ const channelLanguage = textChild(xml, ["language", "dc:language"]) ?? xmlLang(xml) ?? feedLanguage;
7679
+ const items = collectBlocks(xml, ITEM_BLOCK).map((block) => normalizeRssItem(block, channelLanguage));
7680
+ if (items.length > 0 || /<(?:[\w.-]+:)?rss\b/i.test(xml)) return items.filter((item) => item != null);
7681
+ return collectBlocks(xml, ENTRY_BLOCK).map((block) => normalizeAtomEntry(block, channelLanguage)).filter((item) => item != null);
7682
+ }
7683
+ function normalizeRssItem(block, fallbackLanguage) {
7684
+ const title = textChild(block, ["title"]);
7685
+ const guid = guidChild(block);
7686
+ const link = canonicalizeFeedItemUrl(textChild(block, ["link"]) ?? "") ?? (guid?.permalink ? canonicalizeFeedItemUrl(guid.value) : void 0);
7687
+ if (!title || !link) return;
6557
7688
  return {
6558
- enabled: true,
6559
- collection: value.collection,
6560
- authors: normalizeAuthors(value.authors),
6561
- pageSize: normalizePageSize(value.pageSize)
6562
- };
7689
+ title,
7690
+ link,
7691
+ id: guid?.value || link,
7692
+ date: parseFeedDate(textChild(block, [
7693
+ "pubDate",
7694
+ "dc:date",
7695
+ "published",
7696
+ "updated"
7697
+ ])),
7698
+ language: textChild(block, ["language", "dc:language"]) ?? xmlLang(block) ?? fallbackLanguage,
7699
+ summary: textChild(block, [
7700
+ "description",
7701
+ "summary",
7702
+ "content:encoded",
7703
+ "content"
7704
+ ])
7705
+ };
7706
+ }
7707
+ function normalizeAtomEntry(block, fallbackLanguage) {
7708
+ const title = textChild(block, ["title"]);
7709
+ const link = canonicalizeFeedItemUrl(atomLink(block) ?? "");
7710
+ if (!title || !link) return;
7711
+ return {
7712
+ title,
7713
+ link,
7714
+ id: textChild(block, ["id"]) || link,
7715
+ date: parseFeedDate(textChild(block, [
7716
+ "published",
7717
+ "updated",
7718
+ "dc:date"
7719
+ ])),
7720
+ language: xmlLang(block) ?? textChild(block, ["language", "dc:language"]) ?? fallbackLanguage,
7721
+ summary: textChild(block, [
7722
+ "summary",
7723
+ "content",
7724
+ "description"
7725
+ ])
7726
+ };
7727
+ }
7728
+ function collectBlocks(xml, pattern) {
7729
+ return [...xml.matchAll(pattern)].flatMap((match) => match[1] ? [match[1]] : []);
7730
+ }
7731
+ function textChild(block, names) {
7732
+ for (const name of names) {
7733
+ const pattern = new RegExp(`<(?:[\\w.-]+:)?${escapeRegExp$1(localName(name))}(?:\\s[^>]*)?>([\\s\\S]*?)</(?:[\\w.-]+:)?${escapeRegExp$1(localName(name))}>`, "i");
7734
+ const match = block.match(pattern);
7735
+ if (match?.[1] != null) {
7736
+ const text = decodeXmlText(match[1]);
7737
+ if (text) return text;
7738
+ }
7739
+ }
6563
7740
  }
6564
- /**
6565
- * Picks a collection named `blog`, else the only configured collection.
6566
- *
6567
- * An explicit name always wins. Several collections and no `blog` name
6568
- * require `blog.collection`.
6569
- */
6570
- function resolveBlogCollectionName(requested, collectionNames) {
6571
- if (requested) return requested;
6572
- if (collectionNames.includes("blog")) return "blog";
6573
- if (collectionNames.length === 1) return collectionNames[0];
7741
+ function guidChild(block) {
7742
+ const match = block.match(/<(?:[\w.-]+:)?guid\b([^>]*)>([\s\S]*?)<\/(?:[\w.-]+:)?guid>/i);
7743
+ if (!match?.[2]) return;
7744
+ const value = decodeXmlText(match[2]);
7745
+ if (!value) return;
7746
+ const attrs = match[1] ?? "";
7747
+ return {
7748
+ value,
7749
+ permalink: !/isPermaLink\s*=\s*(['"]?)false\1/i.test(attrs)
7750
+ };
6574
7751
  }
6575
- function normalizeAuthors(authors) {
6576
- if (!authors || typeof authors !== "object") return {};
6577
- const resolved = {};
6578
- for (const [key, value] of Object.entries(authors)) {
6579
- if (!value || typeof value.name !== "string") continue;
6580
- resolved[key] = {
6581
- name: value.name,
6582
- bio: typeof value.bio === "string" ? value.bio : void 0,
6583
- url: typeof value.url === "string" ? value.url : void 0
6584
- };
7752
+ function atomLink(block) {
7753
+ const links = [];
7754
+ for (const match of block.matchAll(/<(?:[\w.-]+:)?link\b([^>]*)\/?>/gi)) {
7755
+ const href = attrValue(match[1] ?? "", "href");
7756
+ if (!href) continue;
7757
+ links.push({
7758
+ href,
7759
+ rel: (attrValue(match[1] ?? "", "rel") ?? "alternate").toLowerCase()
7760
+ });
6585
7761
  }
6586
- return resolved;
7762
+ return links.find((link) => link.rel === "alternate")?.href ?? links[0]?.href;
6587
7763
  }
6588
- function normalizePageSize(value) {
6589
- if (typeof value === "number" && Number.isFinite(value) && value >= 1) return Math.floor(value);
6590
- return DEFAULT_PAGE_SIZE;
7764
+ function xmlLang(block) {
7765
+ return block.match(/\bxml:lang\s*=\s*(['"])([^'"]+)\1/i)?.[2]?.trim() || void 0;
6591
7766
  }
6592
- //#endregion
6593
- //#region src/blog-reading.ts
6594
- /**
6595
- * Deterministic blog reading-time estimates.
6596
- */
6597
- const LATIN_WORDS_PER_MINUTE = 200;
6598
- const CJK_CHARS_PER_MINUTE = 500;
6599
- function readingTimeMinutes(markdown) {
6600
- const body = stripInlineCode(stripFences(stripFrontmatter(markdown)));
6601
- let latin = 0;
6602
- let cjk = 0;
6603
- let latinRun = false;
6604
- for (const char of body) {
6605
- const code = char.codePointAt(0) ?? 0;
6606
- if (isCjkCodePoint(code)) {
6607
- cjk += 1;
6608
- latinRun = false;
6609
- continue;
7767
+ function attrValue(attrs, name) {
7768
+ return attrs.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, "i"))?.[2]?.trim() || void 0;
7769
+ }
7770
+ function decodeXmlText(value) {
7771
+ return decodeEntities(value.replace(/<!\[CDATA\[([\s\S]*?)]]>/g, "$1").replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
7772
+ }
7773
+ function decodeEntities(value) {
7774
+ return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (entity, name) => {
7775
+ const lower = name.toLowerCase();
7776
+ if (lower === "amp") return "&";
7777
+ if (lower === "lt") return "<";
7778
+ if (lower === "gt") return ">";
7779
+ if (lower === "quot") return "\"";
7780
+ if (lower === "apos") return "'";
7781
+ if (lower.startsWith("#x")) {
7782
+ const code = Number.parseInt(lower.slice(2), 16);
7783
+ return Number.isFinite(code) ? String.fromCodePoint(code) : entity;
6610
7784
  }
6611
- if (isLatinWordChar(code)) {
6612
- if (!latinRun) {
6613
- latin += 1;
6614
- latinRun = true;
6615
- }
6616
- continue;
7785
+ if (lower.startsWith("#")) {
7786
+ const code = Number.parseInt(lower.slice(1), 10);
7787
+ return Number.isFinite(code) ? String.fromCodePoint(code) : entity;
6617
7788
  }
6618
- if (char === "'" || char === "’") continue;
6619
- latinRun = false;
6620
- }
6621
- if (latin === 0 && cjk === 0) return 0;
6622
- return Math.max(1, Math.ceil(latin / LATIN_WORDS_PER_MINUTE + cjk / CJK_CHARS_PER_MINUTE));
7789
+ return entity;
7790
+ });
6623
7791
  }
6624
- function stripFrontmatter(markdown) {
6625
- if (!markdown.startsWith("---")) return markdown;
6626
- const match = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
6627
- return match ? markdown.slice(match[0].length) : markdown;
7792
+ function looksLikeHtml(body) {
7793
+ const start = body.trim().slice(0, 256).toLowerCase();
7794
+ return start.startsWith("<!doctype html") || start.startsWith("<html");
6628
7795
  }
6629
- function stripFences(text) {
6630
- return text.replace(/```[\s\S]*?(?:```|$)/g, " ");
7796
+ function looksLikeXmlFeed(body) {
7797
+ return /<(?:[\w.-]+:)?(?:rss|feed|rdf:RDF|item|entry)\b/i.test(body);
6631
7798
  }
6632
- function stripInlineCode(text) {
6633
- return text.replace(/`[^`\n]*`/g, " ");
7799
+ function stripBom(value) {
7800
+ return value.charCodeAt(0) === 65279 ? value.slice(1) : value;
6634
7801
  }
6635
- function isCjkCodePoint(code) {
6636
- 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;
7802
+ function localName(name) {
7803
+ const index = name.indexOf(":");
7804
+ return index === -1 ? name : name.slice(index + 1);
6637
7805
  }
6638
- function isLatinWordChar(code) {
6639
- return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122;
7806
+ function escapeRegExp$1(value) {
7807
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6640
7808
  }
6641
7809
  //#endregion
6642
7810
  //#region src/blog-html.ts
@@ -6652,13 +7820,13 @@ function isSafeBlogUrl(value) {
6652
7820
  return trimmed.toLowerCase().startsWith("https:");
6653
7821
  }
6654
7822
  function postMetaMarkup(meta) {
6655
- const parts = [`<p class="ox-blog-meta__reading-time">${escapeHtml$1(String(meta.minutes))} min read</p>`];
7823
+ const parts = [`<p class="ox-blog-meta__reading-time">${escapeHtml(String(meta.minutes))} min read</p>`];
6656
7824
  if (meta.authors.length > 0) {
6657
7825
  const items = meta.authors.map((author) => authorMarkup(author)).join("");
6658
7826
  parts.push(`<ul class="ox-blog-meta__authors">${items}</ul>`);
6659
7827
  }
6660
7828
  if (meta.tags.length > 0) {
6661
- const items = meta.tags.map((tag) => `<li><a href="${escapeHtml$1(tag.href)}">${escapeHtml$1(tag.label)}</a></li>`).join("");
7829
+ const items = meta.tags.map((tag) => `<li><a href="${escapeHtml(tag.href)}">${escapeHtml(tag.label)}</a></li>`).join("");
6662
7830
  parts.push(`<ul class="ox-blog-meta__tags">${items}</ul>`);
6663
7831
  }
6664
7832
  return `<aside class="ox-blog-meta">${parts.join("")}</aside>\n`;
@@ -6666,25 +7834,25 @@ function postMetaMarkup(meta) {
6666
7834
  function indexPageContent(items, pager) {
6667
7835
  const list = items.map((item) => listItem(item)).join("");
6668
7836
  const links = [];
6669
- if (pager.newerHref) links.push(`<a href="${escapeHtml$1(pager.newerHref)}" rel="prev">Newer</a>`);
6670
- if (pager.olderHref) links.push(`<a href="${escapeHtml$1(pager.olderHref)}" rel="next">Older</a>`);
7837
+ if (pager.newerHref) links.push(`<a href="${escapeHtml(pager.newerHref)}" rel="prev">Newer</a>`);
7838
+ if (pager.olderHref) links.push(`<a href="${escapeHtml(pager.olderHref)}" rel="next">Older</a>`);
6671
7839
  return `<h1>Blog</h1><ul class="ox-blog">${list}</ul>${links.length > 0 ? `<nav class="ox-blog-pager">${links.join("")}</nav>` : ""}`;
6672
7840
  }
6673
7841
  function tagPageContent(label, items) {
6674
7842
  const list = items.map((item) => listItem(item)).join("");
6675
- return `<h1>${escapeHtml$1(label)}</h1><ul class="ox-blog-tag">${list}</ul>`;
7843
+ return `<h1>${escapeHtml(label)}</h1><ul class="ox-blog-tag">${list}</ul>`;
6676
7844
  }
6677
7845
  function archiveIndexContent(years) {
6678
- 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>`;
7846
+ 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>`;
6679
7847
  }
6680
7848
  function archiveYearContent(year, months, items) {
6681
- const monthList = months.map((entry) => `<li><a href="${escapeHtml$1(entry.href)}">${escapeHtml$1(entry.month)}</a></li>`).join("");
7849
+ const monthList = months.map((entry) => `<li><a href="${escapeHtml(entry.href)}">${escapeHtml(entry.month)}</a></li>`).join("");
6682
7850
  const posts = items.map((item) => listItem(item)).join("");
6683
- return `<h1>${escapeHtml$1(year)}</h1><ul class="ox-blog-archive-months">${monthList}</ul><ul class="ox-blog">${posts}</ul>`;
7851
+ return `<h1>${escapeHtml(year)}</h1><ul class="ox-blog-archive-months">${monthList}</ul><ul class="ox-blog">${posts}</ul>`;
6684
7852
  }
6685
7853
  function archiveMonthContent(label, items) {
6686
7854
  const list = items.map((item) => listItem(item)).join("");
6687
- return `<h1>${escapeHtml$1(label)}</h1><ul class="ox-blog">${list}</ul>`;
7855
+ return `<h1>${escapeHtml(label)}</h1><ul class="ox-blog">${list}</ul>`;
6688
7856
  }
6689
7857
  function siteHref$2(base, ...segments) {
6690
7858
  const prefix = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
@@ -6698,17 +7866,18 @@ function containedPath$1(outDir, ...segments) {
6698
7866
  if (resolved === root || !resolved.startsWith(prefix)) return;
6699
7867
  return resolved;
6700
7868
  }
6701
- function escapeHtml$1(value) {
7869
+ function escapeHtml(value) {
6702
7870
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
6703
7871
  }
6704
7872
  function authorMarkup(author) {
6705
- const name = escapeHtml$1(author.name);
7873
+ const name = escapeHtml(author.name);
6706
7874
  const url = author.url?.trim();
6707
- 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>`;
7875
+ 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>`;
6708
7876
  }
6709
7877
  function listItem(item) {
6710
- const time = item.dateLabel ? ` <time datetime="${escapeHtml$1(item.dateLabel)}">${escapeHtml$1(item.dateLabel)}</time>` : "";
6711
- return `<li><a href="${escapeHtml$1(item.href)}">${escapeHtml$1(item.title)}</a>${time}</li>`;
7878
+ const time = item.dateLabel ? ` <time datetime="${escapeHtml(item.dateLabel)}">${escapeHtml(item.dateLabel)}</time>` : "";
7879
+ 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>`;
7880
+ return `<li><a href="${escapeHtml(item.href)}">${escapeHtml(item.title)}</a>${time}</li>`;
6712
7881
  }
6713
7882
  //#endregion
6714
7883
  //#region src/blog-posts.ts
@@ -6814,7 +7983,8 @@ function toListItem(page) {
6814
7983
  return {
6815
7984
  title: page.title,
6816
7985
  href: page.routePaths.href,
6817
- dateLabel: parsed ? `${String(parsed.year).padStart(4, "0")}-${String(parsed.month).padStart(2, "0")}-${String(parsed.day).padStart(2, "0")}` : void 0
7986
+ dateLabel: parsed ? `${String(parsed.year).padStart(4, "0")}-${String(parsed.month).padStart(2, "0")}-${String(parsed.day).padStart(2, "0")}` : void 0,
7987
+ ...page.external || page.frontmatter.external === true ? { external: true } : {}
6818
7988
  };
6819
7989
  }
6820
7990
  function resolvePostAuthors(frontmatter, map) {
@@ -6874,6 +8044,158 @@ function dateField(value) {
6874
8044
  if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
6875
8045
  }
6876
8046
  //#endregion
8047
+ //#region src/blog-feeds.ts
8048
+ var BlogFeedError = class extends Error {
8049
+ issues;
8050
+ constructor(issues) {
8051
+ super(issues.join("\n"));
8052
+ this.name = "BlogFeedError";
8053
+ this.issues = issues;
8054
+ }
8055
+ };
8056
+ async function loadExternalBlogPosts(sources, network = {}) {
8057
+ const pages = [];
8058
+ const warnings = [];
8059
+ const fatals = [];
8060
+ if (sources.length === 0) return {
8061
+ pages,
8062
+ warnings,
8063
+ fatals
8064
+ };
8065
+ const bodies = /* @__PURE__ */ new Map();
8066
+ const results = await Promise.all(sources.map(async (source) => {
8067
+ try {
8068
+ return {
8069
+ source,
8070
+ pages: parseBlogFeed(await cachedBody(source.url, bodies, network), source.language).map((item) => toExternalPage(item, source)).filter((page) => page != null)
8071
+ };
8072
+ } catch (error) {
8073
+ const detail = error instanceof Error ? error.message : String(error);
8074
+ return {
8075
+ source,
8076
+ message: `[ox-content] blog feed ${source.url}: ${detail}`
8077
+ };
8078
+ }
8079
+ }));
8080
+ for (const result of results) {
8081
+ if ("pages" in result) {
8082
+ pages.push(...result.pages);
8083
+ continue;
8084
+ }
8085
+ if (result.source.onError === "error") fatals.push(result.message);
8086
+ else warnings.push(result.message);
8087
+ }
8088
+ return {
8089
+ pages,
8090
+ warnings,
8091
+ fatals
8092
+ };
8093
+ }
8094
+ function mergeBlogPosts(local, external) {
8095
+ const seenUrls = /* @__PURE__ */ new Set();
8096
+ const seenIds = /* @__PURE__ */ new Set();
8097
+ const merged = [];
8098
+ for (const page of [...local, ...external]) {
8099
+ const keys = identityKeys(page);
8100
+ if (seenUrls.has(keys.url) || seenIds.has(keys.id)) continue;
8101
+ seenUrls.add(keys.url);
8102
+ seenIds.add(keys.id);
8103
+ merged.push(page);
8104
+ }
8105
+ return sortPosts(merged);
8106
+ }
8107
+ function cachedBody(url, cache, network) {
8108
+ const existing = cache.get(url);
8109
+ if (existing) return existing;
8110
+ const pending = fetchBlogFeedBody(url, network);
8111
+ cache.set(url, pending);
8112
+ return pending;
8113
+ }
8114
+ function toExternalPage(item, source) {
8115
+ const link = canonicalizeFeedItemUrl(item.link);
8116
+ if (!link) return;
8117
+ const id = item.id.trim() || link;
8118
+ const language = item.language ?? source.language;
8119
+ const author = source.author;
8120
+ return {
8121
+ title: item.title,
8122
+ inputPath: `external:${id}`,
8123
+ transformedHtml: "",
8124
+ external: true,
8125
+ routePaths: { href: link },
8126
+ frontmatter: {
8127
+ external: true,
8128
+ id,
8129
+ date: item.date ? feedDateIso(item.date) : void 0,
8130
+ language,
8131
+ author,
8132
+ summary: item.summary
8133
+ }
8134
+ };
8135
+ }
8136
+ function identityKeys(page) {
8137
+ const explicitId = stringField(page.frontmatter.id);
8138
+ const canonical = stringField(page.frontmatter.canonical);
8139
+ const href = page.routePaths.href;
8140
+ const url = canonicalizeFeedItemUrl(canonical ?? "") ?? canonicalizeFeedItemUrl(href) ?? href;
8141
+ return {
8142
+ url,
8143
+ id: explicitId || url
8144
+ };
8145
+ }
8146
+ function stringField(value) {
8147
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
8148
+ }
8149
+ //#endregion
8150
+ //#region src/blog-reading.ts
8151
+ /**
8152
+ * Deterministic blog reading-time estimates.
8153
+ */
8154
+ const LATIN_WORDS_PER_MINUTE = 200;
8155
+ const CJK_CHARS_PER_MINUTE = 500;
8156
+ function readingTimeMinutes(markdown) {
8157
+ const body = stripInlineCode(stripFences(stripFrontmatter(markdown)));
8158
+ let latin = 0;
8159
+ let cjk = 0;
8160
+ let latinRun = false;
8161
+ for (const char of body) {
8162
+ const code = char.codePointAt(0) ?? 0;
8163
+ if (isCjkCodePoint(code)) {
8164
+ cjk += 1;
8165
+ latinRun = false;
8166
+ continue;
8167
+ }
8168
+ if (isLatinWordChar(code)) {
8169
+ if (!latinRun) {
8170
+ latin += 1;
8171
+ latinRun = true;
8172
+ }
8173
+ continue;
8174
+ }
8175
+ if (char === "'" || char === "’") continue;
8176
+ latinRun = false;
8177
+ }
8178
+ if (latin === 0 && cjk === 0) return 0;
8179
+ return Math.max(1, Math.ceil(latin / LATIN_WORDS_PER_MINUTE + cjk / CJK_CHARS_PER_MINUTE));
8180
+ }
8181
+ function stripFrontmatter(markdown) {
8182
+ if (!markdown.startsWith("---")) return markdown;
8183
+ const match = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
8184
+ return match ? markdown.slice(match[0].length) : markdown;
8185
+ }
8186
+ function stripFences(text) {
8187
+ return text.replace(/```[\s\S]*?(?:```|$)/g, " ");
8188
+ }
8189
+ function stripInlineCode(text) {
8190
+ return text.replace(/`[^`\n]*`/g, " ");
8191
+ }
8192
+ function isCjkCodePoint(code) {
8193
+ 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;
8194
+ }
8195
+ function isLatinWordChar(code) {
8196
+ return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122;
8197
+ }
8198
+ //#endregion
6877
8199
  //#region src/blog-pages.ts
6878
8200
  /**
6879
8201
  * Generated blog index, tag, and archive pages.
@@ -6885,7 +8207,7 @@ async function injectBlogPostMeta(input) {
6885
8207
  if (posts === void 0) return;
6886
8208
  const listedPaths = new Set(posts.map((page) => page.inputPath));
6887
8209
  for (const page of input.pages) {
6888
- if (!listedPaths.has(page.inputPath)) continue;
8210
+ if (!listedPaths.has(page.inputPath) || page.external === true) continue;
6889
8211
  const markdown = await readMarkdown(page.inputPath);
6890
8212
  page.transformedHtml = postMetaMarkup({
6891
8213
  authors: resolvePostAuthors(page.frontmatter, input.options.authors),
@@ -6918,8 +8240,9 @@ async function appendBlogPages(input) {
6918
8240
  input.errors.push(AMBIGUOUS_COLLECTION);
6919
8241
  return;
6920
8242
  }
6921
- const posts = selectBlogPosts(input.listedPages, input.options, input.srcDir, input.collections);
6922
- if (posts === void 0) return;
8243
+ const local = selectBlogPosts(input.listedPages, input.options, input.srcDir, input.collections);
8244
+ if (local === void 0) return;
8245
+ const posts = await collectIndexPosts(local, input.options, input.errors, input.feedNetwork);
6923
8246
  for (const spec of blogPageSpecs(posts, input.options, input.outDir, input.base)) try {
6924
8247
  input.generatedPages.push({
6925
8248
  inputPath: spec.outputPath,
@@ -7014,6 +8337,14 @@ function blogPageSpecs(posts, options, outDir, base) {
7014
8337
  }
7015
8338
  return pages;
7016
8339
  }
8340
+ async function collectIndexPosts(local, options, errors, network) {
8341
+ if (options.feeds.length === 0) return local;
8342
+ const loaded = await loadExternalBlogPosts(options.feeds, network);
8343
+ errors.push(...loaded.warnings);
8344
+ for (const warning of loaded.warnings) console.warn(warning);
8345
+ if (loaded.fatals.length > 0) throw new BlogFeedError(loaded.fatals);
8346
+ return mergeBlogPosts(local, loaded.pages);
8347
+ }
7017
8348
  async function readMarkdown(inputPath) {
7018
8349
  try {
7019
8350
  return await fs$2.readFile(inputPath, "utf8");
@@ -7526,59 +8857,6 @@ function generateSearchModule(options, indexPath) {
7526
8857
  return importNapiModuleSync().generateSearchModuleFromOptions(toLocalSearchRuntimeOptions(options), indexPath);
7527
8858
  }
7528
8859
  //#endregion
7529
- //#region src/versions-html.ts
7530
- function versionSwitcherMarkup(links, badge) {
7531
- if (links.length === 0) return "";
7532
- const current = links.find((link) => link.current) ?? links[0];
7533
- const items = links.map((link) => {
7534
- const label = `${escapeHtml(link.label)}${badgeMarkup(link, badge)}`;
7535
- if (link.current || !isSafeHref(link.href)) return `<li><span aria-current="page">${label}</span></li>`;
7536
- return `<li><a href="${escapeHtml(link.href)}">${label}</a></li>`;
7537
- }).join("");
7538
- 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>`;
7539
- }
7540
- function versionBannerMarkup(kind) {
7541
- if (kind === "unreleased") return `<aside class="ox-version-banner ox-version-banner--unreleased" role="status">This documentation describes an unreleased version.</aside>`;
7542
- if (kind === "unmaintained") return `<aside class="ox-version-banner ox-version-banner--unmaintained" role="status">This documentation is unmaintained.</aside>`;
7543
- return "";
7544
- }
7545
- function injectVersionChrome(html, switcher, banner, searchFrom, searchTo) {
7546
- let next = html;
7547
- if (banner) next = next.replace(/<body([^>]*)>/, `<body$1>${banner}`);
7548
- if (switcher) {
7549
- if (next.includes("<div class=\"header-actions\">")) next = next.replace("<div class=\"header-actions\">", `<div class="header-actions">${switcher}`);
7550
- else if (next.includes("</header>")) next = next.replace("</header>", `${switcher}</header>`);
7551
- }
7552
- if (searchTo && isSafeHref(searchTo)) next = next.replace(/<html([^>]*)>/i, (match, attrs) => {
7553
- if (/\sdata-ox-search-index=/.test(attrs)) return match;
7554
- return `<html${attrs} data-ox-search-index="${escapeHtml(searchTo)}">`;
7555
- });
7556
- if (searchFrom && searchTo && searchFrom !== searchTo && isSafeHref(searchTo)) {
7557
- next = next.split(searchFrom).join(searchTo);
7558
- 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>`;
7559
- next = next.includes("</body>") ? next.replace("</body>", `${script}</body>`) : `${next}${script}`;
7560
- }
7561
- return next;
7562
- }
7563
- function searchIndexUrl(base, prefix) {
7564
- const root = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
7565
- return prefix ? `${root}${prefix}/search-index.json` : `${root}search-index.json`;
7566
- }
7567
- function isSafeHref(href) {
7568
- const trimmed = href.trim();
7569
- if (!trimmed || trimmed.startsWith("//")) return false;
7570
- const lower = trimmed.replace(/\s+/g, "").toLowerCase();
7571
- if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:")) return false;
7572
- return trimmed.startsWith("/") || trimmed.startsWith("./") || !trimmed.includes(":");
7573
- }
7574
- function escapeHtml(value) {
7575
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
7576
- }
7577
- function badgeMarkup(link, badge) {
7578
- if (!badge || !link.banner) return "";
7579
- return `<span class="ox-version-badge">${link.banner === "unreleased" ? "unreleased" : "unmaintained"}</span>`;
7580
- }
7581
- //#endregion
7582
8860
  //#region src/versions.ts
7583
8861
  /**
7584
8862
  * Opt-in documentation versioning: prefixes, snapshots, and header chrome.
@@ -7709,7 +8987,13 @@ async function writeSnapshotSearchIndex(input) {
7709
8987
  function applyVersionChrome(html, options, activeId, siblingPath, base, existingHrefs) {
7710
8988
  if (!options.enabled) return html;
7711
8989
  const active = options.entries.find((entry) => entry.id === activeId);
7712
- 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 ?? ""));
8990
+ 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) => ({
8991
+ id: entry.id,
8992
+ label: entry.label,
8993
+ prefix: entry.prefix,
8994
+ indexUrl: searchIndexUrl(base, entry.prefix),
8995
+ current: entry.id === activeId
8996
+ })));
7713
8997
  }
7714
8998
  function sanitizePrefix(prefix) {
7715
8999
  const trimmed = prefix.trim().replace(/^\/+|\/+$/g, "");
@@ -7746,21 +9030,182 @@ function normalizeEntries(entries) {
7746
9030
  banner: normalizeBanner(entry.banner)
7747
9031
  });
7748
9032
  }
7749
- return resolved;
9033
+ return resolved;
9034
+ }
9035
+ function normalizeBanner(value) {
9036
+ return value === "unreleased" || value === "unmaintained" ? value : false;
9037
+ }
9038
+ function siteHref$1(base, prefix, rest) {
9039
+ const root = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
9040
+ const parts = [prefix, rest].filter((part) => part && part !== "/");
9041
+ return parts.length === 0 ? root : `${root}${parts.join("/")}/`;
9042
+ }
9043
+ function relativeUrl(outputPath, outDir) {
9044
+ const rel = path$1.posix.normalize(path$1.relative(path$1.resolve(outDir), path$1.resolve(outputPath)).replaceAll(path$1.sep, "/"));
9045
+ if (rel.startsWith("..")) return "";
9046
+ const dir = rel.endsWith("/index.html") ? rel.slice(0, -11) : rel.replace(/\.html$/, "");
9047
+ return dir === "." ? "" : dir;
9048
+ }
9049
+ //#endregion
9050
+ //#region src/resources-dedupe.ts
9051
+ /**
9052
+ * Site-wide content-addressed emit for identical page-resource bytes.
9053
+ *
9054
+ * Hashing streams the file. The first digest+extension pair writes once;
9055
+ * later pages reuse that path. Image decode is never used here.
9056
+ */
9057
+ const DEDUPE_ASSET_DIR = path$1.join("assets", "content");
9058
+ const TRANSFORM_QUERY_KEYS = /* @__PURE__ */ new Set([
9059
+ "width",
9060
+ "w",
9061
+ "height",
9062
+ "h",
9063
+ "crop",
9064
+ "format"
9065
+ ]);
9066
+ function createResourceDedupeStore() {
9067
+ return {
9068
+ canonical: /* @__PURE__ */ new Map(),
9069
+ hashes: /* @__PURE__ */ new Map()
9070
+ };
9071
+ }
9072
+ function normalizeDedupeExt(ext) {
9073
+ const value = ext.replace(/^\./, "").trim().toLowerCase();
9074
+ if (value === "jpeg") return "jpg";
9075
+ return value || "bin";
9076
+ }
9077
+ function canonicalPublicPath(base, digest, ext) {
9078
+ return `${!base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`}${DEDUPE_ASSET_DIR.split(path$1.sep).join("/")}/${digest}.${ext}`;
9079
+ }
9080
+ function canonicalAbsolutePath(outDir, digest, ext) {
9081
+ return path$1.join(outDir, DEDUPE_ASSET_DIR, `${digest}.${ext}`);
9082
+ }
9083
+ /**
9084
+ * SHA-256 of emitted bytes plus a NUL and the serving extension so the
9085
+ * same payload cannot be served under an incompatible media type.
9086
+ */
9087
+ async function hashResourceFile(filePath, ext, store, reuseKey) {
9088
+ const cached = store.hashes.get(reuseKey);
9089
+ if (cached) return cached;
9090
+ const hash = createHash("sha256");
9091
+ for await (const chunk of createReadStream(filePath)) hash.update(chunk);
9092
+ hash.update("\0");
9093
+ hash.update(ext);
9094
+ const digest = hash.digest("hex");
9095
+ store.hashes.set(reuseKey, digest);
9096
+ return digest;
9097
+ }
9098
+ async function emitCanonicalResource(store, input) {
9099
+ const key = `${input.digest}\0${input.ext}`;
9100
+ const existing = store.canonical.get(key);
9101
+ const publicPath = canonicalPublicPath(input.base, input.digest, input.ext);
9102
+ if (existing) return {
9103
+ asset: {
9104
+ digest: input.digest,
9105
+ ext: input.ext,
9106
+ absolutePath: existing,
9107
+ publicPath
9108
+ },
9109
+ wrote: false
9110
+ };
9111
+ const absolutePath = canonicalAbsolutePath(input.outDir, input.digest, input.ext);
9112
+ await fs$2.mkdir(path$1.dirname(absolutePath), { recursive: true });
9113
+ await fs$2.copyFile(input.sourcePath, absolutePath);
9114
+ store.canonical.set(key, absolutePath);
9115
+ return {
9116
+ asset: {
9117
+ digest: input.digest,
9118
+ ext: input.ext,
9119
+ absolutePath,
9120
+ publicPath
9121
+ },
9122
+ wrote: true
9123
+ };
9124
+ }
9125
+ /**
9126
+ * Prefer a hard link at the original output path. `link` failure removes
9127
+ * any stale alias and copies so a shared inode is never overwritten.
9128
+ */
9129
+ async function linkOrCopyAlias(canonical, alias, linker = fs$2.link) {
9130
+ await fs$2.mkdir(path$1.dirname(alias), { recursive: true });
9131
+ try {
9132
+ await linker(canonical, alias);
9133
+ return "link";
9134
+ } catch {
9135
+ await fs$2.rm(alias, { force: true });
9136
+ }
9137
+ try {
9138
+ await linker(canonical, alias);
9139
+ return "link";
9140
+ } catch {
9141
+ await fs$2.copyFile(canonical, alias);
9142
+ return "copy";
9143
+ }
7750
9144
  }
7751
- function normalizeBanner(value) {
7752
- return value === "unreleased" || value === "unmaintained" ? value : false;
9145
+ /** Keep leftover search/hash; drop consumed transform params. */
9146
+ function rewriteToCanonicalUrl(originalSrc, canonicalPath) {
9147
+ const hashIndex = originalSrc.indexOf("#");
9148
+ const hash = hashIndex === -1 ? "" : originalSrc.slice(hashIndex);
9149
+ const withoutHash = hashIndex === -1 ? originalSrc : originalSrc.slice(0, hashIndex);
9150
+ const queryIndex = withoutHash.indexOf("?");
9151
+ return `${canonicalPath}${leftoverQuery(queryIndex === -1 ? "" : withoutHash.slice(queryIndex + 1))}${hash}`;
7753
9152
  }
7754
- function siteHref$1(base, prefix, rest) {
7755
- const root = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
7756
- const parts = [prefix, rest].filter((part) => part && part !== "/");
7757
- return parts.length === 0 ? root : `${root}${parts.join("/")}/`;
9153
+ function leftoverQuery(query) {
9154
+ if (!query) return "";
9155
+ const params = new URLSearchParams(query);
9156
+ for (const key of TRANSFORM_QUERY_KEYS) params.delete(key);
9157
+ const next = params.toString();
9158
+ return next ? `?${next}` : "";
7758
9159
  }
7759
- function relativeUrl(outputPath, outDir) {
7760
- const rel = path$1.posix.normalize(path$1.relative(path$1.resolve(outDir), path$1.resolve(outputPath)).replaceAll(path$1.sep, "/"));
7761
- if (rel.startsWith("..")) return "";
7762
- const dir = rel.endsWith("/index.html") ? rel.slice(0, -11) : rel.replace(/\.html$/, "");
7763
- return dir === "." ? "" : dir;
9160
+ //#endregion
9161
+ //#region src/resources-html.ts
9162
+ /**
9163
+ * Collect local `src`, `poster`, and relevant `href` values from HTML tags.
9164
+ */
9165
+ const RESOURCE_TAG = /<(?:img|video|audio|source|track|a)\b[^>]*>/gi;
9166
+ const RESOURCE_ATTR = /\b(src|poster|href)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
9167
+ function collectResourceTags(html) {
9168
+ return (html.match(RESOURCE_TAG) ?? []).map((tag) => ({
9169
+ tag,
9170
+ refs: collectResourceRefs(tag)
9171
+ })).filter((entry) => entry.refs.length > 0);
9172
+ }
9173
+ function collectResourceRefs(tag) {
9174
+ const name = /^<([a-z]+)/i.exec(tag)?.[1]?.toLowerCase();
9175
+ if (!name) return [];
9176
+ const refs = [];
9177
+ RESOURCE_ATTR.lastIndex = 0;
9178
+ let match = RESOURCE_ATTR.exec(tag);
9179
+ while (match) {
9180
+ const attr = match[1].toLowerCase();
9181
+ if (isRelevantAttr(name, attr)) {
9182
+ const raw = match[2] ?? match[3] ?? "";
9183
+ refs.push({
9184
+ attr,
9185
+ raw,
9186
+ value: unescapeHtml(raw)
9187
+ });
9188
+ }
9189
+ match = RESOURCE_ATTR.exec(tag);
9190
+ }
9191
+ return refs;
9192
+ }
9193
+ function isRelevantAttr(tagName, attr) {
9194
+ if (tagName === "a") return attr === "href";
9195
+ if (attr === "href") return false;
9196
+ if (attr === "poster") return tagName === "video";
9197
+ return attr === "src";
9198
+ }
9199
+ function unescapeHtml(value) {
9200
+ return value.replaceAll("&amp;", "&").replaceAll("&quot;", "\"").replaceAll("&#39;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">");
9201
+ }
9202
+ function escapeAttribute(value) {
9203
+ return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
9204
+ }
9205
+ function replaceAttributeRaw(tag, raw, nextRaw) {
9206
+ const index = tag.indexOf(raw);
9207
+ if (index === -1) return tag;
9208
+ return tag.slice(0, index) + nextRaw + tag.slice(index + raw.length);
7764
9209
  }
7765
9210
  //#endregion
7766
9211
  //#region src/resources-jpeg.ts
@@ -8803,129 +10248,23 @@ function coverCrop(image, width, height) {
8803
10248
  return cropImage(scaled, Math.max(0, Math.floor((scaled.width - width) / 2)), Math.max(0, Math.floor((scaled.height - height) / 2)), width, height);
8804
10249
  }
8805
10250
  //#endregion
8806
- //#region src/resources-process.ts
10251
+ //#region src/resources-write.ts
8807
10252
  /**
8808
- * Page-resource HTML rewriting and transform writes.
10253
+ * Transform cache writes for page resources.
8809
10254
  */
8810
- const IMG_TAG = /<img\b[^>]*>/gi;
8811
- const SRC_ATTR = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
8812
- async function processPageResources(input) {
8813
- if (!input.options.enabled) return {
8814
- html: input.html,
8815
- files: [],
8816
- errors: [],
8817
- fatal: []
8818
- };
8819
- const bundleRoot = path$1.dirname(input.inputPath);
8820
- const outputDir = path$1.dirname(input.outputPath);
8821
- const files = [];
8822
- const errors = [];
8823
- const fatal = [];
8824
- let html = input.html;
8825
- const tags = input.html.match(IMG_TAG) ?? [];
8826
- for (const tag of tags) {
8827
- const srcMatch = tag.match(SRC_ATTR);
8828
- const rawSrc = srcMatch?.[1] ?? srcMatch?.[2];
8829
- if (!rawSrc) continue;
8830
- const src = unescapeHtml(rawSrc);
8831
- const parsed = parseResourceSrc(src);
8832
- if (!parsed) continue;
8833
- const resolved = resolveBundlePath(parsed.pathname, bundleRoot, input.srcDir);
8834
- if (!resolved.ok) {
8835
- const message = `[ox-content] page resource ${JSON.stringify(src)} on ${input.inputPath} is outside the page bundle`;
8836
- errors.push(message);
8837
- fatal.push(message);
8838
- continue;
8839
- }
8840
- let stat;
8841
- try {
8842
- stat = await fs$2.stat(resolved.absolute);
8843
- } catch {
8844
- const message = `[ox-content] missing page resource ${JSON.stringify(parsed.pathname)} on ${input.inputPath}`;
8845
- errors.push(message);
8846
- if (input.options.missing === "error") fatal.push(message);
8847
- continue;
8848
- }
8849
- const transformError = validateTransform(parsed.transform, input.options);
8850
- if (transformError) {
8851
- const message = `[ox-content] ${transformError} for ${JSON.stringify(src)} on ${input.inputPath}`;
8852
- errors.push(message);
8853
- fatal.push(message);
8854
- continue;
8855
- }
8856
- const hasTransform = hasPixelOrFormatTransform(parsed.transform);
8857
- const outputName = hasTransform ? transformedFileName(parsed.pathname, parsed.transform, resourceCacheKey(resolved.absolute, stat.mtimeMs, parsed.transform)) : path$1.basename(resolved.absolute);
8858
- const outputFile = path$1.join(outputDir, outputName);
8859
- try {
8860
- if (hasTransform) await writeTransformedResource({
8861
- sourcePath: resolved.absolute,
8862
- outputFile,
8863
- cacheDir: input.cacheDir,
8864
- mtimeMs: stat.mtimeMs,
8865
- transform: parsed.transform
8866
- });
8867
- else {
8868
- await fs$2.mkdir(outputDir, { recursive: true });
8869
- await fs$2.copyFile(resolved.absolute, outputFile);
8870
- }
8871
- files.push(outputFile);
8872
- const rewritten = tag.replace(rawSrc, escapeAttribute(outputName));
8873
- html = html.replace(tag, rewritten);
8874
- } catch (error) {
8875
- const detail = error instanceof Error ? error.message : String(error);
8876
- const message = `[ox-content] failed to process page resource ${JSON.stringify(src)} on ${input.inputPath}: ${detail}`;
8877
- errors.push(message);
8878
- fatal.push(message);
8879
- }
8880
- }
8881
- return {
8882
- html,
8883
- files,
8884
- errors,
8885
- fatal
8886
- };
8887
- }
8888
- function resolveBundlePath(pathname, bundleRoot, contentRoot) {
8889
- if (path$1.isAbsolute(pathname) || pathname.includes("\0")) return { ok: false };
8890
- const absolute = path$1.resolve(bundleRoot, pathname);
8891
- if (!isInsideRoot$1(bundleRoot, absolute) || !isInsideRoot$1(contentRoot, absolute)) return { ok: false };
8892
- return {
8893
- ok: true,
8894
- absolute
8895
- };
8896
- }
8897
- function validateTransform(transform, options) {
8898
- if (transform.width && options.widths.length > 0 && !options.widths.includes(transform.width)) return `width ${transform.width} is not in resources.widths`;
8899
- if (transform.format && !options.formats.includes(transform.format)) return `format ${transform.format} is not in resources.formats`;
8900
- }
8901
- function hasPixelOrFormatTransform(transform) {
8902
- return Boolean(transform.width || transform.height || transform.crop || transform.format);
8903
- }
8904
- function transformedFileName(pathname, transform, cacheKey) {
8905
- const stem = path$1.basename(pathname).replace(/\.[^.]+$/, "") || "resource";
8906
- const ext = outputExtension(pathname, transform.format);
8907
- return `${stem}.${cacheKey.slice(0, 12)}.${ext}`;
8908
- }
8909
- function outputExtension(pathname, format) {
8910
- if (format === "jpeg") return "jpg";
8911
- if (format) return format;
8912
- const ext = path$1.extname(pathname).slice(1).toLowerCase();
8913
- return ext === "jpeg" ? "jpg" : ext || "png";
8914
- }
8915
- async function writeTransformedResource(input) {
10255
+ async function ensureTransformedCache(input) {
8916
10256
  const key = resourceCacheKey(input.sourcePath, input.mtimeMs, input.transform);
8917
10257
  const ext = path$1.extname(input.outputFile);
8918
10258
  const cacheFile = path$1.join(input.cacheDir, `${key}${ext}`);
8919
10259
  try {
8920
- await fs$2.copyFile(cacheFile, input.outputFile);
8921
- return;
10260
+ await fs$2.access(cacheFile);
10261
+ return cacheFile;
8922
10262
  } catch {}
8923
10263
  const output = transformResourceBuffer(await fs$2.readFile(input.sourcePath), input.sourcePath, input.transform);
8924
10264
  if (output.length > 8388608) throw new Error("transform produced an oversized file");
8925
- await fs$2.mkdir(path$1.dirname(input.outputFile), { recursive: true });
8926
10265
  await fs$2.mkdir(input.cacheDir, { recursive: true });
8927
10266
  await fs$2.writeFile(cacheFile, output);
8928
- await fs$2.writeFile(input.outputFile, output);
10267
+ return cacheFile;
8929
10268
  }
8930
10269
  function transformResourceBuffer(source, sourcePath, transform) {
8931
10270
  const needsPixels = Boolean(transform.width || transform.height || transform.crop);
@@ -8968,11 +10307,221 @@ function formatFromPath(filePath) {
8968
10307
  const ext = path$1.extname(filePath).slice(1).toLowerCase();
8969
10308
  return ext === "jpg" ? "jpeg" : ext;
8970
10309
  }
8971
- function unescapeHtml(value) {
8972
- return value.replaceAll("&amp;", "&").replaceAll("&quot;", "\"").replaceAll("&#39;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">");
10310
+ //#endregion
10311
+ //#region src/resources-process.ts
10312
+ /**
10313
+ * Page-resource HTML rewriting and transform writes.
10314
+ */
10315
+ const PAGE_EXTS = /* @__PURE__ */ new Set([
10316
+ ".md",
10317
+ ".markdown",
10318
+ ".mdx",
10319
+ ".html",
10320
+ ".htm"
10321
+ ]);
10322
+ async function processPageResources(input) {
10323
+ if (!input.options.enabled) return {
10324
+ html: input.html,
10325
+ files: [],
10326
+ errors: [],
10327
+ fatal: []
10328
+ };
10329
+ if (input.options.dedupe && !input.outDir) {
10330
+ const message = "[ox-content] resources.dedupe requires outDir";
10331
+ return {
10332
+ html: input.html,
10333
+ files: [],
10334
+ errors: [message],
10335
+ fatal: [message]
10336
+ };
10337
+ }
10338
+ const bundleRoot = path$1.dirname(input.inputPath);
10339
+ const outputDir = path$1.dirname(input.outputPath);
10340
+ const files = [];
10341
+ const errors = [];
10342
+ const fatal = [];
10343
+ let html = input.html;
10344
+ const store = input.options.dedupe ? input.dedupeStore ?? createResourceDedupeStore() : void 0;
10345
+ for (const { tag, refs } of collectResourceTags(input.html)) {
10346
+ let nextTag = tag;
10347
+ for (const ref of refs) {
10348
+ const result = await processResourceRef(input, {
10349
+ bundleRoot,
10350
+ outputDir,
10351
+ ref: ref.attr,
10352
+ src: ref.value,
10353
+ store
10354
+ });
10355
+ errors.push(...result.errors);
10356
+ fatal.push(...result.fatal);
10357
+ files.push(...result.files);
10358
+ if (result.rewrite) nextTag = replaceAttributeRaw(nextTag, ref.raw, escapeAttribute(result.rewrite));
10359
+ }
10360
+ if (nextTag !== tag) html = html.replace(tag, nextTag);
10361
+ }
10362
+ return {
10363
+ html,
10364
+ files,
10365
+ errors,
10366
+ fatal
10367
+ };
8973
10368
  }
8974
- function escapeAttribute(value) {
8975
- return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
10369
+ async function processResourceRef(input, ctx) {
10370
+ const parsed = parseResourceSrc(ctx.src);
10371
+ if (!parsed) return {
10372
+ files: [],
10373
+ errors: [],
10374
+ fatal: []
10375
+ };
10376
+ const resolved = resolveBundlePath(parsed.pathname, ctx.bundleRoot, input.srcDir);
10377
+ if (!resolved.ok) {
10378
+ if (ctx.ref === "href") return {
10379
+ files: [],
10380
+ errors: [],
10381
+ fatal: []
10382
+ };
10383
+ const message = `[ox-content] page resource ${JSON.stringify(ctx.src)} on ${input.inputPath} is outside the page bundle`;
10384
+ return {
10385
+ files: [],
10386
+ errors: [message],
10387
+ fatal: [message]
10388
+ };
10389
+ }
10390
+ let stat;
10391
+ try {
10392
+ stat = await fs$2.stat(resolved.absolute);
10393
+ } catch {
10394
+ if (ctx.ref === "href") return {
10395
+ files: [],
10396
+ errors: [],
10397
+ fatal: []
10398
+ };
10399
+ const message = `[ox-content] missing page resource ${JSON.stringify(parsed.pathname)} on ${input.inputPath}`;
10400
+ return {
10401
+ files: [],
10402
+ errors: [message],
10403
+ fatal: input.options.missing === "error" ? [message] : []
10404
+ };
10405
+ }
10406
+ const hrefToPage = ctx.ref === "href" && PAGE_EXTS.has(path$1.extname(resolved.absolute).toLowerCase());
10407
+ if (!stat.isFile() || hrefToPage) return {
10408
+ files: [],
10409
+ errors: [],
10410
+ fatal: []
10411
+ };
10412
+ const transformError = validateTransform(parsed.transform, input.options);
10413
+ if (transformError) {
10414
+ const message = `[ox-content] ${transformError} for ${JSON.stringify(ctx.src)} on ${input.inputPath}`;
10415
+ return {
10416
+ files: [],
10417
+ errors: [message],
10418
+ fatal: [message]
10419
+ };
10420
+ }
10421
+ const hasTransform = hasPixelOrFormatTransform(parsed.transform);
10422
+ const outputName = hasTransform ? transformedFileName(parsed.pathname, parsed.transform, resourceCacheKey(resolved.absolute, stat.mtimeMs, parsed.transform)) : decodedBasename(parsed.pathname) || path$1.basename(resolved.absolute);
10423
+ const outputFile = path$1.join(ctx.outputDir, outputName);
10424
+ try {
10425
+ const materialized = hasTransform ? await ensureTransformedCache({
10426
+ sourcePath: resolved.absolute,
10427
+ outputFile,
10428
+ cacheDir: input.cacheDir,
10429
+ mtimeMs: stat.mtimeMs,
10430
+ transform: parsed.transform
10431
+ }) : resolved.absolute;
10432
+ if (ctx.store && input.outDir) return await emitDedupedResource({
10433
+ store: ctx.store,
10434
+ materialized,
10435
+ outputFile,
10436
+ src: ctx.src,
10437
+ sourcePath: resolved.absolute,
10438
+ mtimeMs: stat.mtimeMs,
10439
+ transform: parsed.transform,
10440
+ hasTransform,
10441
+ outDir: input.outDir,
10442
+ base: input.base ?? "/"
10443
+ });
10444
+ if (hasTransform) {
10445
+ await fs$2.mkdir(ctx.outputDir, { recursive: true });
10446
+ await fs$2.copyFile(materialized, outputFile);
10447
+ } else {
10448
+ await fs$2.mkdir(ctx.outputDir, { recursive: true });
10449
+ await fs$2.copyFile(resolved.absolute, outputFile);
10450
+ }
10451
+ return {
10452
+ files: [outputFile],
10453
+ errors: [],
10454
+ fatal: [],
10455
+ rewrite: outputName
10456
+ };
10457
+ } catch (error) {
10458
+ const detail = error instanceof Error ? error.message : String(error);
10459
+ const message = `[ox-content] failed to process page resource ${JSON.stringify(ctx.src)} on ${input.inputPath}: ${detail}`;
10460
+ return {
10461
+ files: [],
10462
+ errors: [message],
10463
+ fatal: [message]
10464
+ };
10465
+ }
10466
+ }
10467
+ async function emitDedupedResource(input) {
10468
+ const ext = normalizeDedupeExt(path$1.extname(input.outputFile));
10469
+ const reuseKey = input.hasTransform ? resourceCacheKey(input.sourcePath, input.mtimeMs, input.transform) : `${input.sourcePath}\0${input.mtimeMs}\0copy`;
10470
+ const digest = await hashResourceFile(input.materialized, ext, input.store, reuseKey);
10471
+ const { asset, wrote } = await emitCanonicalResource(input.store, {
10472
+ digest,
10473
+ ext,
10474
+ sourcePath: input.materialized,
10475
+ outDir: input.outDir,
10476
+ base: input.base
10477
+ });
10478
+ await linkOrCopyAlias(asset.absolutePath, input.outputFile);
10479
+ return {
10480
+ files: wrote ? [asset.absolutePath, input.outputFile] : [input.outputFile],
10481
+ errors: [],
10482
+ fatal: [],
10483
+ rewrite: rewriteToCanonicalUrl(input.src, asset.publicPath)
10484
+ };
10485
+ }
10486
+ function resolveBundlePath(pathname, bundleRoot, contentRoot) {
10487
+ let decoded;
10488
+ try {
10489
+ decoded = decodeURIComponent(pathname);
10490
+ } catch {
10491
+ decoded = pathname;
10492
+ }
10493
+ if (path$1.isAbsolute(decoded) || decoded.includes("\0")) return { ok: false };
10494
+ const absolute = path$1.resolve(bundleRoot, decoded);
10495
+ if (!isInsideRoot$1(bundleRoot, absolute) || !isInsideRoot$1(contentRoot, absolute)) return { ok: false };
10496
+ return {
10497
+ ok: true,
10498
+ absolute
10499
+ };
10500
+ }
10501
+ function validateTransform(transform, options) {
10502
+ if (transform.width && options.widths.length > 0 && !options.widths.includes(transform.width)) return `width ${transform.width} is not in resources.widths`;
10503
+ if (transform.format && !options.formats.includes(transform.format)) return `format ${transform.format} is not in resources.formats`;
10504
+ }
10505
+ function hasPixelOrFormatTransform(transform) {
10506
+ return Boolean(transform.width || transform.height || transform.crop || transform.format);
10507
+ }
10508
+ function transformedFileName(pathname, transform, cacheKey) {
10509
+ const stem = path$1.basename(pathname).replace(/\.[^.]+$/, "") || "resource";
10510
+ const ext = outputExtension(pathname, transform.format);
10511
+ return `${stem}.${cacheKey.slice(0, 12)}.${ext}`;
10512
+ }
10513
+ function outputExtension(pathname, format) {
10514
+ if (format === "jpeg") return "jpg";
10515
+ if (format) return format;
10516
+ const ext = path$1.extname(pathname).slice(1).toLowerCase();
10517
+ return ext === "jpeg" ? "jpg" : ext || "png";
10518
+ }
10519
+ function decodedBasename(pathname) {
10520
+ try {
10521
+ return path$1.basename(decodeURIComponent(pathname));
10522
+ } catch {
10523
+ return path$1.basename(pathname);
10524
+ }
8976
10525
  }
8977
10526
  //#endregion
8978
10527
  //#region src/resources.ts
@@ -9007,19 +10556,22 @@ function resolveResourcesOptions(value) {
9007
10556
  enabled: false,
9008
10557
  formats: [...DEFAULT_FORMATS],
9009
10558
  widths: [],
9010
- missing: "error"
10559
+ missing: "error",
10560
+ dedupe: false
9011
10561
  };
9012
10562
  if (value === true) return {
9013
10563
  enabled: true,
9014
10564
  formats: [...DEFAULT_FORMATS],
9015
10565
  widths: [],
9016
- missing: "error"
10566
+ missing: "error",
10567
+ dedupe: false
9017
10568
  };
9018
10569
  return {
9019
10570
  enabled: true,
9020
10571
  formats: normalizeFormats(value.formats),
9021
10572
  widths: normalizeWidths(value.widths),
9022
- missing: value.missing === "warn" ? "warn" : "error"
10573
+ missing: value.missing === "warn" ? "warn" : "error",
10574
+ dedupe: value.dedupe === true
9023
10575
  };
9024
10576
  }
9025
10577
  /**
@@ -9270,6 +10822,7 @@ function resolveSsgOptions(ssg) {
9270
10822
  localeSwitcher: false,
9271
10823
  a11y: false,
9272
10824
  pageChrome: false,
10825
+ markdownSource: resolveMarkdownSourceOptions(void 0),
9273
10826
  notFound: resolveNotFoundOptions(void 0),
9274
10827
  team: resolveTeamOptions(void 0),
9275
10828
  blog: resolveBlogOptions(void 0),
@@ -9291,6 +10844,7 @@ function resolveSsgOptions(ssg) {
9291
10844
  localeSwitcher: false,
9292
10845
  a11y: false,
9293
10846
  pageChrome: false,
10847
+ markdownSource: resolveMarkdownSourceOptions(void 0),
9294
10848
  notFound: resolveNotFoundOptions(void 0),
9295
10849
  team: resolveTeamOptions(void 0),
9296
10850
  blog: resolveBlogOptions(void 0),
@@ -9320,6 +10874,7 @@ function resolveSsgOptions(ssg) {
9320
10874
  localeSwitcher: resolveLocaleSwitcherOption(ssg.localeSwitcher),
9321
10875
  a11y: resolveA11yOption(ssg.a11y),
9322
10876
  pageChrome: resolvePageChromeOption(ssg.pageChrome),
10877
+ markdownSource: resolveMarkdownSourceOptions(ssg.markdownSource),
9323
10878
  notFound: resolveNotFoundOptions(ssg.notFound),
9324
10879
  team: resolveTeamOptions(ssg.team),
9325
10880
  blog: resolveBlogOptions(ssg.blog),
@@ -9492,7 +11047,7 @@ function localeCodesFor(locales) {
9492
11047
  async function generateHtmlPage(pageData, navGroups, siteName, base, ogImage, theme, locale, availableLocales, pagination = false, readerChrome = false, breadcrumbs = false, localeSwitcher = false, localePaths, a11y = false, team = {
9493
11048
  enabled: false,
9494
11049
  members: []
9495
- }, pageChrome = false, breadcrumbRootHref, jsonLd = false, siteUrl, headValidation = false) {
11050
+ }, pageChrome = false, breadcrumbRootHref, jsonLd = false, siteUrl, headValidation = false, defaultLocale) {
9496
11051
  const mod = await importNapiModule();
9497
11052
  const tocForRust = pageData.toc.map(toRustTocEntry);
9498
11053
  const navGroupsForRust = convertNavGroupsForRust(navGroups);
@@ -9576,7 +11131,11 @@ async function generateHtmlPage(pageData, navGroups, siteName, base, ogImage, th
9576
11131
  });
9577
11132
  const html = typeof result === "string" ? result : result.html;
9578
11133
  reportHeadDiagnostics(typeof result === "string" ? [] : result.diagnostics ?? [], headValidation);
9579
- return html;
11134
+ return injectSearchLocaleFilters(html, {
11135
+ locales: availableLocales ?? [],
11136
+ current: locale,
11137
+ defaultLocale: defaultLocale ?? availableLocales?.[0]?.code ?? "en"
11138
+ });
9580
11139
  }
9581
11140
  async function externalizeSharedPageAssets(pages, outDir, base) {
9582
11141
  const optimized = (await importNapiModule()).externalizeSsgAssets(pages, outDir, base);
@@ -9661,6 +11220,7 @@ async function buildSsg(options, root) {
9661
11220
  applyPermalinkRoutes(context, collected);
9662
11221
  errors.push(...collected.errors);
9663
11222
  const { outputPages, listedPages } = applyPublishState(context, collected);
11223
+ context.markdownSourcePages.push(...outputPages);
9664
11224
  remapPermalinkNav(context, listedPages);
9665
11225
  await applyPageResources(context, outputPages, generatedFiles, errors);
9666
11226
  await generateOgImageAssets(context, collected, generatedFiles, errors);
@@ -9738,7 +11298,8 @@ async function createBuildSsgContext(options, root, srcDir, outDir, markdownFile
9738
11298
  navItems: resolveNavigationGroups(ssgOptions.navigation, base, ssgOptions.extension) ?? (ssgOptions.theme?.sidebar.length ? buildThemeNavItems(ssgOptions.theme.sidebar, base, ssgOptions.extension) : buildNavItems(markdownFiles, srcDir, base, ssgOptions.extension)),
9739
11299
  siteName: await resolveSiteName$1(root, ssgOptions),
9740
11300
  shouldGenerateOgImages: shouldGenerateOgImages(options),
9741
- napi: ssgOptions.lastUpdated || ssgOptions.contributors ? await importNapiModule() : void 0
11301
+ markdownSourcePages: [],
11302
+ napi: ssgOptions.lastUpdated || ssgOptions.contributors || options.siteMaps?.enabled ? await importNapiModule() : void 0
9742
11303
  };
9743
11304
  }
9744
11305
  /**
@@ -9768,6 +11329,7 @@ async function applyPageResources(context, pages, generatedFiles, errors) {
9768
11329
  if (!options?.enabled) return;
9769
11330
  const cacheDir = path$2.join(context.root, ".cache", "ox-content-resources");
9770
11331
  const fatal = [];
11332
+ const dedupeStore = options.dedupe ? createResourceDedupeStore() : void 0;
9771
11333
  for (const page of pages) {
9772
11334
  const processed = await processPageResources({
9773
11335
  html: page.transformedHtml,
@@ -9775,7 +11337,10 @@ async function applyPageResources(context, pages, generatedFiles, errors) {
9775
11337
  outputPath: page.routePaths.outputPath,
9776
11338
  srcDir: context.srcDir,
9777
11339
  options,
9778
- cacheDir
11340
+ cacheDir,
11341
+ outDir: context.outDir,
11342
+ base: context.base,
11343
+ dedupeStore
9779
11344
  });
9780
11345
  page.transformedHtml = processed.html;
9781
11346
  generatedFiles.push(...processed.files);
@@ -9849,7 +11414,8 @@ function applyPublishState(context, collected) {
9849
11414
  };
9850
11415
  }
9851
11416
  async function transformSsgPage(context, inputPath) {
9852
- const result = await transformMarkdown(await fs$3.readFile(inputPath, "utf-8"), inputPath, context.options, {
11417
+ const content = await fs$3.readFile(inputPath, "utf-8");
11418
+ const result = await transformMarkdown(content, inputPath, context.options, {
9853
11419
  convertMdLinks: true,
9854
11420
  baseUrl: context.base,
9855
11421
  sourcePath: inputPath
@@ -9859,11 +11425,12 @@ async function transformSsgPage(context, inputPath) {
9859
11425
  const title = extractTitle$1(transformedHtml, frontmatter);
9860
11426
  return {
9861
11427
  inputPath,
11428
+ source: content,
9862
11429
  routePaths: getRoutePaths(inputPath, context.srcDir, context.outDir, context.base, context.ssgOptions.extension, context.ssgOptions.siteUrl),
9863
11430
  transformedHtml,
9864
11431
  title,
9865
11432
  description: frontmatter.description,
9866
- lastUpdated: context.ssgOptions.lastUpdated ? context.napi?.getGitLastUpdated(inputPath, context.root) ?? void 0 : void 0,
11433
+ lastUpdated: context.ssgOptions.lastUpdated || context.options.siteMaps?.enabled ? context.napi?.getGitLastUpdated(inputPath, context.root) ?? void 0 : void 0,
9867
11434
  contributors: contributorsForPage(context, inputPath),
9868
11435
  frontmatter,
9869
11436
  toc: result.toc
@@ -9954,17 +11521,18 @@ async function generateHtmlPages(context, pageResults, collected, errors) {
9954
11521
  async function renderSsgPage(context, pageResult, collected, allPageResults) {
9955
11522
  const { ogImageUrlMap } = collected;
9956
11523
  const pageOgImage = context.shouldGenerateOgImages && ogImageUrlMap.has(pageResult.inputPath) ? ogImageUrlMap.get(pageResult.inputPath) : context.ssgOptions.ogImage;
11524
+ const markdownSource = pageMarkdownSourceHref(context, pageResult);
9957
11525
  if (context.ssgOptions.render) {
9958
11526
  const nav = context.versionNavigation ? rewriteVersionedNavGroups(context.navItems, context.versionNavigation) : context.navItems;
9959
- return renderPage(toThemePageData(pageResult), {
11527
+ return applyMarkdownSourceAlternate(context, renderPage(toThemePageData(pageResult, markdownSource), {
9960
11528
  theme: context.ssgOptions.render,
9961
11529
  siteName: context.siteName,
9962
11530
  base: context.base,
9963
11531
  nav,
9964
- pages: allPageResults.map(toThemePageData)
9965
- });
11532
+ pages: allPageResults.map((page) => toThemePageData(page, pageMarkdownSourceHref(context, page)))
11533
+ }), markdownSource);
9966
11534
  }
9967
- if (context.ssgOptions.bare) return generateBarePage({
11535
+ if (context.ssgOptions.bare) return applyMarkdownSourceAlternate(context, generateBarePage({
9968
11536
  title: pageResult.title,
9969
11537
  content: pageResult.transformedHtml,
9970
11538
  lang: context.ssgOptions.lang ?? getPageLocale(pageResult.routePaths.urlPath, context.options.i18n),
@@ -9975,7 +11543,7 @@ async function renderSsgPage(context, pageResult, collected, allPageResults) {
9975
11543
  head: context.ssgOptions.head,
9976
11544
  bodyStart: context.ssgOptions.bodyStart,
9977
11545
  bodyEnd: context.ssgOptions.bodyEnd
9978
- });
11546
+ }), markdownSource);
9979
11547
  const pageData = createSsgPageData(pageResult);
9980
11548
  const versionNavigation = context.versionNavigation;
9981
11549
  if (versionNavigation) {
@@ -10016,10 +11584,18 @@ async function renderSsgPage(context, pageResult, collected, allPageResults) {
10016
11584
  base: context.base,
10017
11585
  roots: versionNavigation ? versionedLocaleRoots(versionNavigation, i18n.locales, i18n.defaultLocale, i18n.hideDefaultLocale) : void 0
10018
11586
  }) : void 0;
10019
- 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 ?? {
11587
+ 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 ?? {
10020
11588
  enabled: false,
10021
11589
  members: []
10022
- }, context.ssgOptions.pageChrome, versionNavigation?.root.href, context.ssgOptions.jsonLd, context.ssgOptions.siteUrl, context.ssgOptions.headValidation);
11590
+ }, context.ssgOptions.pageChrome, versionNavigation?.root.href, context.ssgOptions.jsonLd, context.ssgOptions.siteUrl, context.ssgOptions.headValidation, i18n?.defaultLocale), markdownSource);
11591
+ }
11592
+ function pageMarkdownSourceHref(context, page) {
11593
+ if (!context.ssgOptions.markdownSource?.enabled || !shouldPublishMarkdownSource(page.frontmatter, context.options.publishState)) return;
11594
+ return markdownSourceHref(page.routePaths.urlPath, context.base);
11595
+ }
11596
+ function applyMarkdownSourceAlternate(context, html, href) {
11597
+ if (!href || !context.ssgOptions.markdownSource?.alternate) return html;
11598
+ return injectMarkdownSourceAlternate(html, href);
10023
11599
  }
10024
11600
  function rewritePagerOverride(pager, context) {
10025
11601
  return pager?.href ? {
@@ -10028,7 +11604,7 @@ function rewritePagerOverride(pager, context) {
10028
11604
  } : pager;
10029
11605
  }
10030
11606
  /** Maps an internal page result onto the theme renderer's page shape. */
10031
- function toThemePageData(pageResult) {
11607
+ function toThemePageData(pageResult, markdownSource) {
10032
11608
  return {
10033
11609
  title: pageResult.title,
10034
11610
  description: pageResult.description,
@@ -10038,6 +11614,7 @@ function toThemePageData(pageResult) {
10038
11614
  contributors: pageResult.contributors,
10039
11615
  path: pageResult.inputPath,
10040
11616
  url: pageResult.routePaths.href,
11617
+ markdownSource,
10041
11618
  frontmatter: pageResult.frontmatter,
10042
11619
  layout: typeof pageResult.frontmatter.layout === "string" ? pageResult.frontmatter.layout : void 0
10043
11620
  };
@@ -10150,6 +11727,7 @@ async function applyDocumentationVersions(generatedPages, context, errors) {
10150
11727
  ...page.routePaths,
10151
11728
  ...prefixRoutePaths(page.routePaths, entry.prefix, context.outDir, context.base)
10152
11729
  };
11730
+ context.markdownSourcePages.push(...outputPages);
10153
11731
  snapContext.versionNavigation = createVersionNavigationContext({
10154
11732
  prefix: entry.prefix,
10155
11733
  base: context.base,
@@ -10262,6 +11840,20 @@ async function writeGeneratedPages(generatedPages, context, generatedFiles, list
10262
11840
  errors.push(feeds.warning);
10263
11841
  console.warn(feeds.warning);
10264
11842
  }
11843
+ const markdownSource = await writeMarkdownSourceFiles({
11844
+ outDir: context.outDir,
11845
+ base: context.base,
11846
+ options: context.ssgOptions.markdownSource,
11847
+ publishState: context.options.publishState,
11848
+ pages: context.markdownSourcePages.map((page) => ({
11849
+ inputPath: page.inputPath,
11850
+ source: page.source,
11851
+ urlPath: page.routePaths.urlPath,
11852
+ frontmatter: page.frontmatter
11853
+ }))
11854
+ });
11855
+ generatedFiles.push(...markdownSource.files);
11856
+ errors.push(...markdownSource.errors);
10265
11857
  }
10266
11858
  /** Turns an SSG `urlPath` (`guide` or `/`) into a same-origin dest (`/guide`). */
10267
11859
  function sitePathFromUrlPath(urlPath) {
@@ -10275,6 +11867,7 @@ function sitemapPages(context, listedPages, outputPages) {
10275
11867
  loc: canonicalPageUrl(context, page.routePaths.urlPath) ?? "",
10276
11868
  title: page.title,
10277
11869
  description: page.description,
11870
+ lastUpdated: page.lastUpdated,
10278
11871
  draft: page.frontmatter.draft === true,
10279
11872
  unlisted: Boolean(context.options.publishState?.enabled) && !listedPaths.has(page.inputPath)
10280
11873
  }));
@@ -10372,7 +11965,8 @@ function createDevServerCache() {
10372
11965
  navGroups: null,
10373
11966
  localePages: null,
10374
11967
  pages: /* @__PURE__ */ new Map(),
10375
- siteName: null
11968
+ siteName: null,
11969
+ markdownSourceIndex: null
10376
11970
  };
10377
11971
  }
10378
11972
  /**
@@ -10381,6 +11975,7 @@ function createDevServerCache() {
10381
11975
  function invalidateNavCache(cache) {
10382
11976
  cache.navGroups = null;
10383
11977
  cache.localePages = null;
11978
+ cache.markdownSourceIndex = null;
10384
11979
  cache.pages.clear();
10385
11980
  }
10386
11981
  /**
@@ -10388,6 +11983,7 @@ function invalidateNavCache(cache) {
10388
11983
  */
10389
11984
  function invalidatePageCache(cache, filePath) {
10390
11985
  cache.pages.delete(filePath);
11986
+ cache.markdownSourceIndex = null;
10391
11987
  }
10392
11988
  /**
10393
11989
  * Resolve site name from options or package.json.
@@ -10477,13 +12073,35 @@ async function renderPage$1(filePath, options, navGroups, siteName, base, root,
10477
12073
  pages: localePages,
10478
12074
  base
10479
12075
  }) : void 0;
12076
+ const markdownSource = options.ssg.markdownSource?.enabled ? markdownSourceHrefForPage({
12077
+ source: filePath,
12078
+ fileUrl: pageData.path,
12079
+ frontmatter,
12080
+ base,
12081
+ permalinks: options.permalinks,
12082
+ cascade: options.cascade,
12083
+ publishState: options.publishState
12084
+ }) : void 0;
10480
12085
  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 ?? {
10481
12086
  enabled: false,
10482
12087
  members: []
10483
- }, options.ssg.pageChrome, void 0, options.ssg.jsonLd, options.ssg.siteUrl, options.ssg.headValidation);
12088
+ }, options.ssg.pageChrome, void 0, options.ssg.jsonLd, options.ssg.siteUrl, options.ssg.headValidation, i18n?.defaultLocale);
12089
+ if (markdownSource && options.ssg.markdownSource?.alternate) html = injectMarkdownSourceAlternate(html, markdownSource);
10484
12090
  html = injectViteHmrClient(html);
10485
12091
  return html;
10486
12092
  }
12093
+ async function serveMarkdownSource(routeUrl, options, srcDir, cache) {
12094
+ if (!cache.markdownSourceIndex) cache.markdownSourceIndex = await buildMarkdownSourceIndex({
12095
+ files: await collectMarkdownFiles(srcDir, options.extensions),
12096
+ srcDir,
12097
+ permalinks: options.permalinks,
12098
+ cascade: options.cascade,
12099
+ publishState: options.publishState
12100
+ });
12101
+ const entry = resolveMarkdownSourceRequest(routeUrl, cache.markdownSourceIndex);
12102
+ if (!entry) return "missing";
12103
+ return entry.allowed ? entry.source : "hidden";
12104
+ }
10487
12105
  /**
10488
12106
  * Create the dev server middleware for SSG page serving.
10489
12107
  */
@@ -10496,6 +12114,19 @@ function createDevServerMiddleware(options, root, cache) {
10496
12114
  let routeUrl = url;
10497
12115
  if (base !== "/" && routeUrl.startsWith(base)) routeUrl = "/" + routeUrl.slice(base.length);
10498
12116
  if (shouldSkip(routeUrl)) return next();
12117
+ if (options.ssg.markdownSource?.enabled && isMarkdownSourceRequest(routeUrl)) {
12118
+ const served = await serveMarkdownSource(routeUrl, options, srcDir, cache);
12119
+ if (served === "missing") return next();
12120
+ if (served === "hidden") {
12121
+ res.statusCode = 404;
12122
+ res.end();
12123
+ return;
12124
+ }
12125
+ res.setHeader("Content-Type", "text/markdown; charset=utf-8");
12126
+ res.setHeader("Cache-Control", "no-cache");
12127
+ res.end(served);
12128
+ return;
12129
+ }
10499
12130
  const filePath = await resolveMarkdownFile(routeUrl, srcDir, options.extensions);
10500
12131
  if (!filePath) return next();
10501
12132
  try {
@@ -11012,6 +12643,43 @@ function resolveCardOptions(options) {
11012
12643
  return { enabled: options.enabled ?? true };
11013
12644
  }
11014
12645
  //#endregion
12646
+ //#region src/heading-permalinks-options.ts
12647
+ function resolveHeadingPermalinksOptions(options) {
12648
+ if (!options) return { enabled: false };
12649
+ if (options === true) return { enabled: true };
12650
+ return { enabled: options.enabled ?? true };
12651
+ }
12652
+ //#endregion
12653
+ //#region src/magic-link-options.ts
12654
+ function resolveMagicLinkOptions(options) {
12655
+ if (!options) return {
12656
+ enabled: false,
12657
+ aliases: {},
12658
+ favicon: false,
12659
+ imageOverrides: []
12660
+ };
12661
+ if (options === true) return {
12662
+ enabled: true,
12663
+ aliases: {},
12664
+ favicon: false,
12665
+ imageOverrides: []
12666
+ };
12667
+ const favicon = options.favicon === true || typeof options.favicon === "object" && options.favicon != null;
12668
+ const faviconTemplate = typeof options.favicon === "object" ? options.favicon.template : void 0;
12669
+ return {
12670
+ enabled: options.enabled ?? true,
12671
+ aliases: normalizeAliases(options.aliases),
12672
+ favicon,
12673
+ faviconTemplate,
12674
+ imageOverrides: options.imageOverrides ?? []
12675
+ };
12676
+ }
12677
+ function normalizeAliases(aliases) {
12678
+ const normalized = {};
12679
+ for (const [key, value] of Object.entries(aliases ?? {})) normalized[key] = typeof value === "string" ? { href: value } : value;
12680
+ return normalized;
12681
+ }
12682
+ //#endregion
11015
12683
  //#region src/include-options.ts
11016
12684
  function resolveIncludeOptions(options) {
11017
12685
  if (!options) return { enabled: false };
@@ -11586,6 +13254,12 @@ function createFrameworkMarkdownOptions(options) {
11586
13254
  },
11587
13255
  attrs: { enabled: false },
11588
13256
  badges: { enabled: false },
13257
+ magicLinks: {
13258
+ enabled: false,
13259
+ aliases: {},
13260
+ favicon: false,
13261
+ imageOverrides: []
13262
+ },
11589
13263
  containers: {
11590
13264
  enabled: false,
11591
13265
  types: {}
@@ -12585,7 +14259,7 @@ function createSsgPlugin(resolvedOptions, getRoot, ssgDevCache) {
12585
14259
  for (const error of result.errors) console.warn(`[ox-content] ${error}`);
12586
14260
  } catch (err) {
12587
14261
  console.error("[ox-content] SSG build failed:", err);
12588
- if (err instanceof PageResourceError) throw err;
14262
+ if (err instanceof PageResourceError || err instanceof BlogFeedError) throw err;
12589
14263
  }
12590
14264
  }
12591
14265
  };
@@ -12700,6 +14374,7 @@ function resolveOptions(options) {
12700
14374
  gfm: options.gfm ?? true,
12701
14375
  mdx: options.mdx,
12702
14376
  footnotes: options.footnotes ?? true,
14377
+ semanticFootnotes: options.semanticFootnotes ?? false,
12703
14378
  tables: options.tables ?? true,
12704
14379
  taskLists: options.taskLists ?? true,
12705
14380
  strikethrough: options.strikethrough ?? true,
@@ -12710,6 +14385,7 @@ function resolveOptions(options) {
12710
14385
  emojiShortcodes: resolveEmojiShortcodeOptions(options.emojiShortcodes),
12711
14386
  attrs: resolveAttrsOptions(options.attrs),
12712
14387
  badges: resolveBadgeOptions(options.badges),
14388
+ magicLinks: resolveMagicLinkOptions(options.magicLinks),
12713
14389
  containers: resolveContainerOptions(options.containers),
12714
14390
  images: resolveImageOptions(options.images),
12715
14391
  codeImports: resolveCodeImportOptions(options.codeImports),
@@ -12729,6 +14405,7 @@ function resolveOptions(options) {
12729
14405
  frontmatter: options.frontmatter ?? true,
12730
14406
  toc: options.toc ?? true,
12731
14407
  tocMaxDepth: options.tocMaxDepth ?? 3,
14408
+ headingPermalinks: resolveHeadingPermalinksOptions(options.headingPermalinks),
12732
14409
  ogImage: options.ogImage ?? false,
12733
14410
  ogImageOptions: resolveOgImageOptions(options.ogImageOptions),
12734
14411
  transformers: options.transformers ?? [],
@@ -13019,6 +14696,6 @@ function normalizeRuntimeBase(base) {
13019
14696
  return withLeading.endsWith("/") ? withLeading : `${withLeading}/`;
13020
14697
  }
13021
14698
  //#endregion
13022
- export { DEFAULT_HTML_TEMPLATE, DEFAULT_MARKDOWN_EXTENSIONS, DefaultTheme, DocsTestRunError, Fragment, IncrementalMarkdownParser, IncrementalMarkdownRenderer, PageResourceError, applyIslandSsrHtml, buildCollectionManifest, buildSearchIndex, buildSsg, classifyPublishState, clearRenderContext, collectDocsTests, collectGitHubRepos, collectGitHubSources, collectMdxIslandNamesFromHtml, collectMdxJsxNamesFromAst, collectOgpUrls, convertVitePressNav, convertVitePressSidebar, createFrameworkMarkdownOptions, createI18nPlugin, createIncrementalMarkdownParser, createIncrementalMarkdownRenderer, createMarkdownEnvironment, createTheme, defaultTheme, defineCollection, defineCollections, defineTheme, discoverDocumentMdxIslands, discoverRegisteredMdxComponents, each, escapeSvelteMarkup, extractCodeBlocks, extractDocs, extractDocsTests, extractIslandInfo, extractVideoId, fetchGitHubSource, fetchOgpData, fetchRepoData, fromVitePressConfig, generateCollectionsVirtualModule, generateFrontmatterTypes, generateHydrationScript, generateMarkdown, generateOgImages, generateTabsCSS, generateTypes, generateVirtualModule, generateVitePressMigrationConfig, hasIslands, inferType, intersectHydratableComponentNames, intersectRegisteredComponentNames, isMarkdownFilePath, isMdxFilePath, isRegisteredComponent, jsx, jsxs, lintCodeBlocks, lintMarkdown, lintMarkdownAsync, lintMarkdownFile, lintMarkdownFiles, mergeThemes, mermaidClientScript, normalizeMarkdownExtensions, normalizeVitePressFrontmatter, oxContent, parseGitHubLineRange, parseGitHubPermalink, parsePageChromeFlags, partitionPublishedPages, prefetchGitHubRepos, prefetchGitHubSources, prefetchOgpData, raw, readingTimeMinutes, renderAllPages, renderHead, renderHtmlToFrameworkCode, renderHtmlToReactComponent, renderHtmlToReactCreateElement, renderHtmlToSvelteComponent, renderHtmlToVueComponent, renderHtmlToVueH, renderIslandComponentImports, renderMarkdownStream, renderPage, renderToString, resolveBadgeOptions, resolveBlogCollectionName, resolveBlogOptions, resolveBuiltinEmbedOptions, resolveCardOptions, resolveCascadeOptions, resolveCollectionsOptions, resolveContentRootPath, resolveDocsOptions, resolveDocumentComponentImports, resolveFeedsOptions, resolveFileTreeOptions, resolveHeadValidation, resolveHeaderNavItems, resolveI18nOptions, resolveImageOptions, resolveIncludeOptions, resolveLocaleLabel, resolveMathOptions, resolveMdxForFilePath, resolveNotFoundOptions, resolveOgImageOptions, resolvePageChromeOption, resolvePermalinksOptions, resolvePublishStateOptions, resolvePwaOptions, resolveRedirectsOptions, resolveResourcesOptions, resolveSearchOptions, resolveSectionIndexOptions, resolveSiteMapsOptions, resolveSsgOptions, resolveStepsOptions, resolveTaxonomiesOptions, resolveTeamOptions, resolveTheme, resolveTypedHoverOptions, resolveVersionsOptions, runDocsTests, setRenderContext, shouldLintMarkdownFile, stripMarkdownExtension, stripViteQuery, transformAllPlugins, transformGitHub, transformIslands, transformMarkdown, transformMermaidStatic, transformOgp, transformTabs, transformYouTube, typecheckCodeBlocks, useIsActive, useNav, usePageProps, useRenderContext, useSiteConfig, when, writeDocs, writeDocsTestFiles, writeSearchIndex };
14699
+ export { BlogFeedError, DEFAULT_HTML_TEMPLATE, DEFAULT_MARKDOWN_EXTENSIONS, DefaultTheme, DocsTestRunError, Fragment, IncrementalMarkdownParser, IncrementalMarkdownRenderer, PageResourceError, applyIslandSsrHtml, buildCollectionManifest, buildSearchIndex, buildSsg, classifyPublishState, clearRenderContext, collectDocsTests, collectGitHubRepos, collectGitHubSources, collectMdxIslandNamesFromHtml, collectMdxJsxNamesFromAst, collectOgpUrls, convertVitePressNav, convertVitePressSidebar, createFrameworkMarkdownOptions, createI18nPlugin, createIncrementalMarkdownParser, createIncrementalMarkdownRenderer, createMarkdownEnvironment, createTheme, defaultTheme, defineCollection, defineCollections, defineTheme, discoverDocumentMdxIslands, discoverRegisteredMdxComponents, each, escapeSvelteMarkup, extractCodeBlocks, extractDocs, extractDocsTests, extractIslandInfo, extractVideoId, fetchGitHubSource, fetchOgpData, fetchRepoData, fromVitePressConfig, generateCollectionsVirtualModule, generateFrontmatterTypes, generateHydrationScript, generateMarkdown, generateOgImages, generateTabsCSS, generateTypes, generateVirtualModule, generateVitePressMigrationConfig, hasIslands, inferType, intersectHydratableComponentNames, intersectRegisteredComponentNames, isMarkdownFilePath, isMdxFilePath, isRegisteredComponent, jsx, jsxs, lintCodeBlocks, lintMarkdown, lintMarkdownAsync, lintMarkdownFile, lintMarkdownFiles, mergeThemes, mermaidClientScript, normalizeMarkdownExtensions, normalizeVitePressFrontmatter, oxContent, parseGitHubLineRange, parseGitHubPermalink, parsePageChromeFlags, partitionPublishedPages, prefetchGitHubRepos, prefetchGitHubSources, prefetchOgpData, raw, readingTimeMinutes, renderAllPages, renderHead, renderHtmlToFrameworkCode, renderHtmlToReactComponent, renderHtmlToReactCreateElement, renderHtmlToSvelteComponent, renderHtmlToVueComponent, renderHtmlToVueH, renderIslandComponentImports, renderMarkdownStream, renderPage, renderToString, resolveBadgeOptions, resolveBlogCollectionName, resolveBlogOptions, resolveBuiltinEmbedOptions, resolveCardOptions, resolveCascadeOptions, resolveCollectionsOptions, resolveContentRootPath, resolveDocsOptions, resolveDocumentComponentImports, resolveFeedsOptions, resolveFileTreeOptions, resolveHeadValidation, resolveHeaderNavItems, resolveHeadingPermalinksOptions, resolveI18nOptions, resolveImageOptions, resolveIncludeOptions, resolveLocaleLabel, resolveMarkdownSourceOptions, resolveMathOptions, resolveMdxForFilePath, resolveNotFoundOptions, resolveOgImageOptions, resolvePageChromeOption, resolvePermalinksOptions, resolvePublishStateOptions, resolvePwaOptions, resolveRedirectsOptions, resolveResourcesOptions, resolveSearchOptions, resolveSectionIndexOptions, resolveSiteMapsOptions, resolveSsgOptions, resolveStepsOptions, resolveTaxonomiesOptions, resolveTeamOptions, resolveTheme, resolveTypedHoverOptions, resolveVersionsOptions, runDocsTests, setRenderContext, shouldLintMarkdownFile, stripMarkdownExtension, stripViteQuery, transformAllPlugins, transformGitHub, transformIslands, transformMarkdown, transformMermaidStatic, transformOgp, transformTabs, transformYouTube, typecheckCodeBlocks, useIsActive, useNav, usePageProps, useRenderContext, useSiteConfig, when, writeDocs, writeDocsTestFiles, writeSearchIndex };
13023
14700
 
13024
14701
  //# sourceMappingURL=index.mjs.map