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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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;");
799
+ }
800
+ function escapeHtml$6(value) {
801
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
648
802
  }
649
- function renderTweetText(data) {
650
- const [start, end] = data.display_text_range ?? [0, data.text.length];
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
@@ -4130,6 +4723,22 @@ function stripLocalePrefix(sitePath, locales) {
4130
4723
  return normalized;
4131
4724
  }
4132
4725
  //#endregion
4726
+ //#region src/page-head.ts
4727
+ /** Resolve descriptors to escaped `<head>` markup. Build-time only. */
4728
+ function renderHead(input) {
4729
+ return importNapiModuleSync().renderHead(JSON.stringify(input));
4730
+ }
4731
+ function resolveHeadValidation(value) {
4732
+ if (value === "warn" || value === "strict") return value;
4733
+ return false;
4734
+ }
4735
+ function reportHeadDiagnostics(diagnostics, validation) {
4736
+ if (!validation || diagnostics.length === 0) return;
4737
+ const fatal = diagnostics.filter((item) => item.strict);
4738
+ if (validation === "strict" && fatal.length > 0) throw new Error(`[ox-content] ${fatal[0].message}`);
4739
+ if (validation === "warn") for (const item of diagnostics) console.warn(`[ox-content] ${item.message}`);
4740
+ }
4741
+ //#endregion
4133
4742
  //#region src/page-context.ts
4134
4743
  var page_context_exports = /* @__PURE__ */ __exportAll({
4135
4744
  clearRenderContext: () => clearRenderContext,
@@ -4347,6 +4956,7 @@ function renderPage(page, options) {
4347
4956
  contributors: page.contributors,
4348
4957
  path: page.path,
4349
4958
  url: page.url,
4959
+ markdownSource: page.markdownSource,
4350
4960
  frontmatter: page.frontmatter,
4351
4961
  layout: page.layout
4352
4962
  },
@@ -4363,6 +4973,7 @@ function renderPage(page, options) {
4363
4973
  contributors: p.contributors,
4364
4974
  path: p.path,
4365
4975
  url: p.url,
4976
+ markdownSource: p.markdownSource,
4366
4977
  frontmatter: p.frontmatter,
4367
4978
  layout: p.layout
4368
4979
  }))
@@ -4421,8 +5032,8 @@ function DefaultTheme({ children }) {
4421
5032
  <head>
4422
5033
  <meta charset="UTF-8">
4423
5034
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
4424
- <title>${escapeHtml$4(page.title)} - ${escapeHtml$4(site.name)}</title>
4425
- ${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)}">` : ""}
4426
5037
  <style>
4427
5038
  :root {
4428
5039
  --octc-color-primary: #4f6fae;
@@ -4446,7 +5057,7 @@ function DefaultTheme({ children }) {
4446
5057
  </head>
4447
5058
  <body>
4448
5059
  <header>
4449
- <h1>${escapeHtml$4(site.name)}</h1>
5060
+ <h1>${escapeHtml$3(site.name)}</h1>
4450
5061
  </header>
4451
5062
  <main>
4452
5063
  ${children.__html}
@@ -4454,7 +5065,7 @@ function DefaultTheme({ children }) {
4454
5065
  </body>
4455
5066
  </html>` };
4456
5067
  }
4457
- function escapeHtml$4(str) {
5068
+ function escapeHtml$3(str) {
4458
5069
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4459
5070
  }
4460
5071
  /**
@@ -4554,6 +5165,13 @@ async function writeSiteMapFiles(input) {
4554
5165
  }
4555
5166
  return { files };
4556
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
+ }
4557
5175
  function hasSiteUrl$2(siteUrl) {
4558
5176
  return Boolean(siteUrl && siteUrl.trim());
4559
5177
  }
@@ -4566,7 +5184,14 @@ function generateSitemapXml(pages) {
4566
5184
  for (const page of pages) {
4567
5185
  xml += " <url>\n <loc>";
4568
5186
  xml += escapeXml$1(page.loc);
4569
- 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";
4570
5195
  }
4571
5196
  xml += "</urlset>\n";
4572
5197
  return xml;
@@ -4627,6 +5252,168 @@ function escapeLlmsUrl(value) {
4627
5252
  return escaped;
4628
5253
  }
4629
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
4630
5417
  //#region src/publish-state.ts
4631
5418
  /**
4632
5419
  * Opt-in draft / unlisted / scheduled page classification.
@@ -4717,160 +5504,202 @@ function hiddenNavKeys(pages, listed) {
4717
5504
  function toNapiPublishState(options) {
4718
5505
  if (!options) return;
4719
5506
  return {
4720
- enabled: options.enabled,
4721
- now: options.now,
4722
- 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
4723
5537
  };
4724
5538
  }
4725
- //#endregion
4726
- //#region src/permalinks.ts
4727
- const RESERVED_CASCADE_KEYS = /* @__PURE__ */ new Set(["permalink", "slug"]);
4728
- /** Resolves `permalinks`. `false` / omitted stays off. `true` / `{}` enables. */
4729
- function resolvePermalinksOptions(value) {
4730
- 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);
4731
5551
  }
4732
- /** Resolves `cascade`. `false` / omitted stays off. `true` / `{}` enables. */
4733
- function resolveCascadeOptions(value) {
4734
- 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("/"));
4735
5563
  }
4736
5564
  /**
4737
- * Applies cascade (when on) then permalink / slug rewriting (when on).
5565
+ * Whether this page may publish a companion.
4738
5566
  *
4739
- * Collisions skip the later page and keep the first. Rejected permalinks stay
4740
- * on the file-tree URL. Hostile non-string values are ignored.
4741
- */
4742
- function resolvePageRoutes(input) {
4743
- const cascaded = applyCascade(input.pages, input.cascade);
4744
- if (!input.permalinks?.enabled) return {
4745
- pages: cascaded.map((page) => ({
4746
- source: page.source,
4747
- urlPath: normalizeUrlPath$1(page.fileUrl),
4748
- frontmatter: page.frontmatter
4749
- })),
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: [],
4750
5590
  errors: []
4751
5591
  };
4752
- const pages = [];
5592
+ const files = [];
4753
5593
  const errors = [];
4754
- const claimed = /* @__PURE__ */ new Map();
4755
- for (const page of cascaded) {
4756
- const { urlPath, error } = resolveOne(page);
4757
- if (error) errors.push(error);
4758
- const owner = claimed.get(urlPath);
4759
- if (owner) {
4760
- 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}`);
4761
5600
  continue;
4762
5601
  }
4763
- claimed.set(urlPath, page.source);
4764
- pages.push({
4765
- source: page.source,
4766
- urlPath,
4767
- frontmatter: page.frontmatter
4768
- });
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);
4769
5611
  }
4770
5612
  return {
4771
- pages,
5613
+ files,
4772
5614
  errors
4773
5615
  };
4774
5616
  }
4775
- function normalizeUrlPath$1(value) {
4776
- const segments = pathSegments(value);
4777
- return segments.length === 0 ? "/" : segments.join("/");
4778
- }
4779
- function resolveFlag(value) {
4780
- if (!value) return { enabled: false };
4781
- if (value === true) return { enabled: true };
4782
- 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("\\");
4783
5621
  }
4784
- function applyCascade(pages, options) {
4785
- if (!options?.enabled) return pages.map((page) => ({
4786
- ...page,
4787
- frontmatter: { ...page.frontmatter }
4788
- }));
4789
- const indexes = /* @__PURE__ */ new Map();
4790
- for (const page of pages) {
4791
- const source = normalizeSeparators(page.source);
4792
- if (isIndexFile(source)) indexes.set(directoryOf(source), { ...page.frontmatter });
4793
- }
4794
- return pages.map((page) => {
4795
- const source = normalizeSeparators(page.source);
4796
- const frontmatter = { ...page.frontmatter };
4797
- for (const dir of ancestorDirs(source)) {
4798
- const defaults = indexes.get(dir);
4799
- if (!defaults || isIndexFile(source) && directoryOf(source) === dir) continue;
4800
- for (const [key, value] of Object.entries(defaults)) if (!RESERVED_CASCADE_KEYS.has(key) && !(key in frontmatter)) frontmatter[key] = value;
4801
- }
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");
4802
5626
  return {
4803
- ...page,
4804
- frontmatter
5627
+ source: file,
5628
+ fileUrl: importNapiModuleSync().getSsgUrlPath(file, input.srcDir),
5629
+ frontmatter: parseSourceFrontmatter(source),
5630
+ body: source
4805
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
4806
5641
  });
4807
- }
4808
- function resolveOne(page) {
4809
- const fileUrl = normalizeUrlPath$1(page.fileUrl);
4810
- const permalink = readString(page.frontmatter.permalink);
4811
- if (permalink !== void 0) {
4812
- const url = isSafePermalink(permalink) ? normalizeUrlPath$1(permalink) : void 0;
4813
- return url ? { urlPath: url } : {
4814
- urlPath: fileUrl,
4815
- error: `[ox-content] rejected permalink ${JSON.stringify(permalink)} on ${page.source} (path escape); using the file-tree URL`
4816
- };
4817
- }
4818
- const slug = readString(page.frontmatter.slug);
4819
- if (slug !== void 0) {
4820
- const url = rewriteSlug(fileUrl, slug);
4821
- return url ? { urlPath: url } : {
4822
- urlPath: fileUrl,
4823
- error: `[ox-content] rejected slug ${JSON.stringify(slug)} on ${page.source} (path escape); using the file-tree URL`
4824
- };
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
+ });
4825
5652
  }
4826
- return { urlPath: fileUrl };
5653
+ return index;
4827
5654
  }
4828
- function rewriteSlug(fileUrl, slug) {
4829
- const trimmed = slug.trim();
4830
- if (trimmed.includes("/") || !isSafePermalink(trimmed)) return;
4831
- const normalized = normalizeUrlPath$1(trimmed);
4832
- if (normalized === "/") return;
4833
- if (fileUrl === "/") return normalized;
4834
- const segments = fileUrl.split("/").filter(Boolean);
4835
- segments.pop();
4836
- segments.push(normalized);
4837
- 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)));
4838
5659
  }
4839
- function isSafePermalink(value) {
4840
- const trimmed = value.trim();
4841
- if (!trimmed || /[\n\r\0]/u.test(trimmed) || trimmed.includes("\\") || trimmed.startsWith("//")) return false;
4842
- if (/^[A-Za-z]:/u.test(trimmed)) return false;
4843
- const lower = trimmed.toLowerCase();
4844
- if (lower.includes("javascript:") || lower.includes("data:") || lower.includes("vbscript:") || lower.includes("file:") || lower.includes("://")) return false;
4845
- 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;
4846
5671
  }
4847
- function pathSegments(value) {
4848
- 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;
4849
5677
  }
4850
- function readString(value) {
4851
- 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`;
4852
5683
  }
4853
- function normalizeSeparators(value) {
4854
- 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;
4855
5690
  }
4856
- function isIndexFile(source) {
4857
- const name = source.split("/").pop() ?? source;
4858
- 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}/`;
4859
5694
  }
4860
- function directoryOf(source) {
4861
- const index = source.lastIndexOf("/");
4862
- return index === -1 ? "" : source.slice(0, index);
5695
+ function stripSearch(pathname) {
5696
+ return pathname.split("?")[0]?.split("#")[0] ?? pathname;
4863
5697
  }
4864
- function ancestorDirs(source) {
4865
- const dir = directoryOf(source);
4866
- const dirs = [""];
4867
- if (!dir) return dirs;
4868
- let acc = "";
4869
- for (const segment of dir.split("/")) {
4870
- acc = acc ? `${acc}/${segment}` : segment;
4871
- dirs.push(acc);
4872
- }
4873
- 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;
4874
5703
  }
4875
5704
  //#endregion
4876
5705
  //#region src/apply-permalinks.ts
@@ -5102,7 +5931,7 @@ async function writeRedirectFiles(input) {
5102
5931
  }
5103
5932
  /** Static HTML redirect body. `dest` is escaped. */
5104
5933
  function generateRedirectHtml(dest) {
5105
- const escaped = escapeHtml$3(dest);
5934
+ const escaped = escapeHtml$2(dest);
5106
5935
  return `\
5107
5936
  <!DOCTYPE html>
5108
5937
  <html lang="en">
@@ -5187,7 +6016,7 @@ function upsert(files, index, occupied, from, to, base) {
5187
6016
  html
5188
6017
  });
5189
6018
  }
5190
- function escapeHtml$3(value) {
6019
+ function escapeHtml$2(value) {
5191
6020
  return value.replace(/[&<>"']/g, (ch) => {
5192
6021
  switch (ch) {
5193
6022
  case "&": return "&amp;";
@@ -5545,6 +6374,7 @@ function createNativeTransformOptions(options) {
5545
6374
  return {
5546
6375
  gfm: options.gfm,
5547
6376
  footnotes: options.footnotes,
6377
+ semanticFootnotes: options.semanticFootnotes ?? false,
5548
6378
  taskLists: options.taskLists,
5549
6379
  tables: options.tables,
5550
6380
  strikethrough: options.strikethrough,
@@ -5552,6 +6382,7 @@ function createNativeTransformOptions(options) {
5552
6382
  autolinkUrls: options.autolinks,
5553
6383
  frontmatter: options.frontmatter,
5554
6384
  tocMaxDepth: options.tocMaxDepth,
6385
+ headingPermalinks: options.headingPermalinks?.enabled ?? false,
5555
6386
  codeAnnotations: options.codeAnnotations?.enabled ?? false,
5556
6387
  codeAnnotationMetaKey: options.codeAnnotations?.metaKey ?? "annotate",
5557
6388
  codeAnnotationSyntax: options.codeAnnotations?.notation ?? "attribute",
@@ -5566,6 +6397,13 @@ function createNativeTransformOptions(options) {
5566
6397
  } : void 0,
5567
6398
  attributes: options.attrs?.enabled ? { enabled: true } : void 0,
5568
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,
5569
6407
  containers: options.containers?.enabled ? {
5570
6408
  enabled: true,
5571
6409
  types: options.containers.types
@@ -5990,6 +6828,7 @@ function isExcludedFromFeed(item, publishState) {
5990
6828
  const frontmatter = item.frontmatter ?? {};
5991
6829
  if (item.draft === true || frontmatter.draft === true) return true;
5992
6830
  if (item.unlisted === true || frontmatter.unlisted === true) return true;
6831
+ if (frontmatter.external === true) return true;
5993
6832
  if (!publishState?.enabled) return false;
5994
6833
  return !classifyPublishState({
5995
6834
  ...frontmatter,
@@ -6230,14 +7069,14 @@ function relatedMarkup(pages) {
6230
7069
  }
6231
7070
  function listPageContent(terms, base, urlName) {
6232
7071
  const items = terms.map((term) => listItem$1(siteHref$3(base, urlName, term.slug), term.label)).join("");
6233
- 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>`;
6234
7073
  }
6235
7074
  function termPageContent(term) {
6236
7075
  const items = [...term.pages].sort((left, right) => {
6237
7076
  const titleCmp = left.title.localeCompare(right.title);
6238
7077
  return titleCmp !== 0 ? titleCmp : left.routePaths.href.localeCompare(right.routePaths.href);
6239
7078
  }).map((page) => listItem$1(page.routePaths.href, page.title)).join("");
6240
- 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>`;
6241
7080
  }
6242
7081
  function displayTaxonomyName(name) {
6243
7082
  return name.charAt(0).toUpperCase() + name.slice(1);
@@ -6255,9 +7094,9 @@ function containedPath$2(outDir, ...segments) {
6255
7094
  return resolved;
6256
7095
  }
6257
7096
  function listItem$1(href, label) {
6258
- 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>`;
6259
7098
  }
6260
- function escapeHtml$2(value) {
7099
+ function escapeHtml$1(value) {
6261
7100
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
6262
7101
  }
6263
7102
  //#endregion
@@ -6531,96 +7370,441 @@ function resolveBlogOptions(value) {
6531
7370
  if (!value) return {
6532
7371
  enabled: false,
6533
7372
  authors: {},
6534
- pageSize: DEFAULT_PAGE_SIZE
7373
+ pageSize: DEFAULT_PAGE_SIZE,
7374
+ feeds: []
6535
7375
  };
6536
7376
  if (value === true) return {
6537
7377
  enabled: true,
6538
7378
  authors: {},
6539
- 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)
6540
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;
6541
7688
  return {
6542
- enabled: true,
6543
- collection: value.collection,
6544
- authors: normalizeAuthors(value.authors),
6545
- pageSize: normalizePageSize(value.pageSize)
6546
- };
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
+ }
6547
7740
  }
6548
- /**
6549
- * Picks a collection named `blog`, else the only configured collection.
6550
- *
6551
- * An explicit name always wins. Several collections and no `blog` name
6552
- * require `blog.collection`.
6553
- */
6554
- function resolveBlogCollectionName(requested, collectionNames) {
6555
- if (requested) return requested;
6556
- if (collectionNames.includes("blog")) return "blog";
6557
- 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
+ };
6558
7751
  }
6559
- function normalizeAuthors(authors) {
6560
- if (!authors || typeof authors !== "object") return {};
6561
- const resolved = {};
6562
- for (const [key, value] of Object.entries(authors)) {
6563
- if (!value || typeof value.name !== "string") continue;
6564
- resolved[key] = {
6565
- name: value.name,
6566
- bio: typeof value.bio === "string" ? value.bio : void 0,
6567
- url: typeof value.url === "string" ? value.url : void 0
6568
- };
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
+ });
6569
7761
  }
6570
- return resolved;
7762
+ return links.find((link) => link.rel === "alternate")?.href ?? links[0]?.href;
6571
7763
  }
6572
- function normalizePageSize(value) {
6573
- if (typeof value === "number" && Number.isFinite(value) && value >= 1) return Math.floor(value);
6574
- return DEFAULT_PAGE_SIZE;
7764
+ function xmlLang(block) {
7765
+ return block.match(/\bxml:lang\s*=\s*(['"])([^'"]+)\1/i)?.[2]?.trim() || void 0;
6575
7766
  }
6576
- //#endregion
6577
- //#region src/blog-reading.ts
6578
- /**
6579
- * Deterministic blog reading-time estimates.
6580
- */
6581
- const LATIN_WORDS_PER_MINUTE = 200;
6582
- const CJK_CHARS_PER_MINUTE = 500;
6583
- function readingTimeMinutes(markdown) {
6584
- const body = stripInlineCode(stripFences(stripFrontmatter(markdown)));
6585
- let latin = 0;
6586
- let cjk = 0;
6587
- let latinRun = false;
6588
- for (const char of body) {
6589
- const code = char.codePointAt(0) ?? 0;
6590
- if (isCjkCodePoint(code)) {
6591
- cjk += 1;
6592
- latinRun = false;
6593
- 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;
6594
7784
  }
6595
- if (isLatinWordChar(code)) {
6596
- if (!latinRun) {
6597
- latin += 1;
6598
- latinRun = true;
6599
- }
6600
- continue;
7785
+ if (lower.startsWith("#")) {
7786
+ const code = Number.parseInt(lower.slice(1), 10);
7787
+ return Number.isFinite(code) ? String.fromCodePoint(code) : entity;
6601
7788
  }
6602
- if (char === "'" || char === "’") continue;
6603
- latinRun = false;
6604
- }
6605
- if (latin === 0 && cjk === 0) return 0;
6606
- return Math.max(1, Math.ceil(latin / LATIN_WORDS_PER_MINUTE + cjk / CJK_CHARS_PER_MINUTE));
7789
+ return entity;
7790
+ });
6607
7791
  }
6608
- function stripFrontmatter(markdown) {
6609
- if (!markdown.startsWith("---")) return markdown;
6610
- const match = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
6611
- 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");
6612
7795
  }
6613
- function stripFences(text) {
6614
- return text.replace(/```[\s\S]*?(?:```|$)/g, " ");
7796
+ function looksLikeXmlFeed(body) {
7797
+ return /<(?:[\w.-]+:)?(?:rss|feed|rdf:RDF|item|entry)\b/i.test(body);
6615
7798
  }
6616
- function stripInlineCode(text) {
6617
- return text.replace(/`[^`\n]*`/g, " ");
7799
+ function stripBom(value) {
7800
+ return value.charCodeAt(0) === 65279 ? value.slice(1) : value;
6618
7801
  }
6619
- function isCjkCodePoint(code) {
6620
- 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);
6621
7805
  }
6622
- function isLatinWordChar(code) {
6623
- return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122;
7806
+ function escapeRegExp$1(value) {
7807
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6624
7808
  }
6625
7809
  //#endregion
6626
7810
  //#region src/blog-html.ts
@@ -6636,13 +7820,13 @@ function isSafeBlogUrl(value) {
6636
7820
  return trimmed.toLowerCase().startsWith("https:");
6637
7821
  }
6638
7822
  function postMetaMarkup(meta) {
6639
- 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>`];
6640
7824
  if (meta.authors.length > 0) {
6641
7825
  const items = meta.authors.map((author) => authorMarkup(author)).join("");
6642
7826
  parts.push(`<ul class="ox-blog-meta__authors">${items}</ul>`);
6643
7827
  }
6644
7828
  if (meta.tags.length > 0) {
6645
- 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("");
6646
7830
  parts.push(`<ul class="ox-blog-meta__tags">${items}</ul>`);
6647
7831
  }
6648
7832
  return `<aside class="ox-blog-meta">${parts.join("")}</aside>\n`;
@@ -6650,25 +7834,25 @@ function postMetaMarkup(meta) {
6650
7834
  function indexPageContent(items, pager) {
6651
7835
  const list = items.map((item) => listItem(item)).join("");
6652
7836
  const links = [];
6653
- if (pager.newerHref) links.push(`<a href="${escapeHtml$1(pager.newerHref)}" rel="prev">Newer</a>`);
6654
- 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>`);
6655
7839
  return `<h1>Blog</h1><ul class="ox-blog">${list}</ul>${links.length > 0 ? `<nav class="ox-blog-pager">${links.join("")}</nav>` : ""}`;
6656
7840
  }
6657
7841
  function tagPageContent(label, items) {
6658
7842
  const list = items.map((item) => listItem(item)).join("");
6659
- 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>`;
6660
7844
  }
6661
7845
  function archiveIndexContent(years) {
6662
- 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>`;
6663
7847
  }
6664
7848
  function archiveYearContent(year, months, items) {
6665
- 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("");
6666
7850
  const posts = items.map((item) => listItem(item)).join("");
6667
- 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>`;
6668
7852
  }
6669
7853
  function archiveMonthContent(label, items) {
6670
7854
  const list = items.map((item) => listItem(item)).join("");
6671
- return `<h1>${escapeHtml$1(label)}</h1><ul class="ox-blog">${list}</ul>`;
7855
+ return `<h1>${escapeHtml(label)}</h1><ul class="ox-blog">${list}</ul>`;
6672
7856
  }
6673
7857
  function siteHref$2(base, ...segments) {
6674
7858
  const prefix = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
@@ -6682,17 +7866,18 @@ function containedPath$1(outDir, ...segments) {
6682
7866
  if (resolved === root || !resolved.startsWith(prefix)) return;
6683
7867
  return resolved;
6684
7868
  }
6685
- function escapeHtml$1(value) {
7869
+ function escapeHtml(value) {
6686
7870
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
6687
7871
  }
6688
7872
  function authorMarkup(author) {
6689
- const name = escapeHtml$1(author.name);
7873
+ const name = escapeHtml(author.name);
6690
7874
  const url = author.url?.trim();
6691
- 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>`;
6692
7876
  }
6693
7877
  function listItem(item) {
6694
- const time = item.dateLabel ? ` <time datetime="${escapeHtml$1(item.dateLabel)}">${escapeHtml$1(item.dateLabel)}</time>` : "";
6695
- 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>`;
6696
7881
  }
6697
7882
  //#endregion
6698
7883
  //#region src/blog-posts.ts
@@ -6798,7 +7983,8 @@ function toListItem(page) {
6798
7983
  return {
6799
7984
  title: page.title,
6800
7985
  href: page.routePaths.href,
6801
- 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 } : {}
6802
7988
  };
6803
7989
  }
6804
7990
  function resolvePostAuthors(frontmatter, map) {
@@ -6858,6 +8044,158 @@ function dateField(value) {
6858
8044
  if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
6859
8045
  }
6860
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
6861
8199
  //#region src/blog-pages.ts
6862
8200
  /**
6863
8201
  * Generated blog index, tag, and archive pages.
@@ -6869,7 +8207,7 @@ async function injectBlogPostMeta(input) {
6869
8207
  if (posts === void 0) return;
6870
8208
  const listedPaths = new Set(posts.map((page) => page.inputPath));
6871
8209
  for (const page of input.pages) {
6872
- if (!listedPaths.has(page.inputPath)) continue;
8210
+ if (!listedPaths.has(page.inputPath) || page.external === true) continue;
6873
8211
  const markdown = await readMarkdown(page.inputPath);
6874
8212
  page.transformedHtml = postMetaMarkup({
6875
8213
  authors: resolvePostAuthors(page.frontmatter, input.options.authors),
@@ -6902,8 +8240,9 @@ async function appendBlogPages(input) {
6902
8240
  input.errors.push(AMBIGUOUS_COLLECTION);
6903
8241
  return;
6904
8242
  }
6905
- const posts = selectBlogPosts(input.listedPages, input.options, input.srcDir, input.collections);
6906
- 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);
6907
8246
  for (const spec of blogPageSpecs(posts, input.options, input.outDir, input.base)) try {
6908
8247
  input.generatedPages.push({
6909
8248
  inputPath: spec.outputPath,
@@ -6998,6 +8337,14 @@ function blogPageSpecs(posts, options, outDir, base) {
6998
8337
  }
6999
8338
  return pages;
7000
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
+ }
7001
8348
  async function readMarkdown(inputPath) {
7002
8349
  try {
7003
8350
  return await fs$2.readFile(inputPath, "utf8");
@@ -7510,59 +8857,6 @@ function generateSearchModule(options, indexPath) {
7510
8857
  return importNapiModuleSync().generateSearchModuleFromOptions(toLocalSearchRuntimeOptions(options), indexPath);
7511
8858
  }
7512
8859
  //#endregion
7513
- //#region src/versions-html.ts
7514
- function versionSwitcherMarkup(links, badge) {
7515
- if (links.length === 0) return "";
7516
- const current = links.find((link) => link.current) ?? links[0];
7517
- const items = links.map((link) => {
7518
- const label = `${escapeHtml(link.label)}${badgeMarkup(link, badge)}`;
7519
- if (link.current || !isSafeHref(link.href)) return `<li><span aria-current="page">${label}</span></li>`;
7520
- return `<li><a href="${escapeHtml(link.href)}">${label}</a></li>`;
7521
- }).join("");
7522
- 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>`;
7523
- }
7524
- function versionBannerMarkup(kind) {
7525
- if (kind === "unreleased") return `<aside class="ox-version-banner ox-version-banner--unreleased" role="status">This documentation describes an unreleased version.</aside>`;
7526
- if (kind === "unmaintained") return `<aside class="ox-version-banner ox-version-banner--unmaintained" role="status">This documentation is unmaintained.</aside>`;
7527
- return "";
7528
- }
7529
- function injectVersionChrome(html, switcher, banner, searchFrom, searchTo) {
7530
- let next = html;
7531
- if (banner) next = next.replace(/<body([^>]*)>/, `<body$1>${banner}`);
7532
- if (switcher) {
7533
- if (next.includes("<div class=\"header-actions\">")) next = next.replace("<div class=\"header-actions\">", `<div class="header-actions">${switcher}`);
7534
- else if (next.includes("</header>")) next = next.replace("</header>", `${switcher}</header>`);
7535
- }
7536
- if (searchTo && isSafeHref(searchTo)) next = next.replace(/<html([^>]*)>/i, (match, attrs) => {
7537
- if (/\sdata-ox-search-index=/.test(attrs)) return match;
7538
- return `<html${attrs} data-ox-search-index="${escapeHtml(searchTo)}">`;
7539
- });
7540
- if (searchFrom && searchTo && searchFrom !== searchTo && isSafeHref(searchTo)) {
7541
- next = next.split(searchFrom).join(searchTo);
7542
- 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>`;
7543
- next = next.includes("</body>") ? next.replace("</body>", `${script}</body>`) : `${next}${script}`;
7544
- }
7545
- return next;
7546
- }
7547
- function searchIndexUrl(base, prefix) {
7548
- const root = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
7549
- return prefix ? `${root}${prefix}/search-index.json` : `${root}search-index.json`;
7550
- }
7551
- function isSafeHref(href) {
7552
- const trimmed = href.trim();
7553
- if (!trimmed || trimmed.startsWith("//")) return false;
7554
- const lower = trimmed.replace(/\s+/g, "").toLowerCase();
7555
- if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:")) return false;
7556
- return trimmed.startsWith("/") || trimmed.startsWith("./") || !trimmed.includes(":");
7557
- }
7558
- function escapeHtml(value) {
7559
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
7560
- }
7561
- function badgeMarkup(link, badge) {
7562
- if (!badge || !link.banner) return "";
7563
- return `<span class="ox-version-badge">${link.banner === "unreleased" ? "unreleased" : "unmaintained"}</span>`;
7564
- }
7565
- //#endregion
7566
8860
  //#region src/versions.ts
7567
8861
  /**
7568
8862
  * Opt-in documentation versioning: prefixes, snapshots, and header chrome.
@@ -7693,7 +8987,13 @@ async function writeSnapshotSearchIndex(input) {
7693
8987
  function applyVersionChrome(html, options, activeId, siblingPath, base, existingHrefs) {
7694
8988
  if (!options.enabled) return html;
7695
8989
  const active = options.entries.find((entry) => entry.id === activeId);
7696
- 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
+ })));
7697
8997
  }
7698
8998
  function sanitizePrefix(prefix) {
7699
8999
  const trimmed = prefix.trim().replace(/^\/+|\/+$/g, "");
@@ -7730,21 +9030,182 @@ function normalizeEntries(entries) {
7730
9030
  banner: normalizeBanner(entry.banner)
7731
9031
  });
7732
9032
  }
7733
- 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
+ }
7734
9144
  }
7735
- function normalizeBanner(value) {
7736
- 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}`;
7737
9152
  }
7738
- function siteHref$1(base, prefix, rest) {
7739
- const root = !base || base === "/" ? "/" : base.endsWith("/") ? base : `${base}/`;
7740
- const parts = [prefix, rest].filter((part) => part && part !== "/");
7741
- 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}` : "";
7742
9159
  }
7743
- function relativeUrl(outputPath, outDir) {
7744
- const rel = path$1.posix.normalize(path$1.relative(path$1.resolve(outDir), path$1.resolve(outputPath)).replaceAll(path$1.sep, "/"));
7745
- if (rel.startsWith("..")) return "";
7746
- const dir = rel.endsWith("/index.html") ? rel.slice(0, -11) : rel.replace(/\.html$/, "");
7747
- 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);
7748
9209
  }
7749
9210
  //#endregion
7750
9211
  //#region src/resources-jpeg.ts
@@ -8787,129 +10248,23 @@ function coverCrop(image, width, height) {
8787
10248
  return cropImage(scaled, Math.max(0, Math.floor((scaled.width - width) / 2)), Math.max(0, Math.floor((scaled.height - height) / 2)), width, height);
8788
10249
  }
8789
10250
  //#endregion
8790
- //#region src/resources-process.ts
10251
+ //#region src/resources-write.ts
8791
10252
  /**
8792
- * Page-resource HTML rewriting and transform writes.
10253
+ * Transform cache writes for page resources.
8793
10254
  */
8794
- const IMG_TAG = /<img\b[^>]*>/gi;
8795
- const SRC_ATTR = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
8796
- async function processPageResources(input) {
8797
- if (!input.options.enabled) return {
8798
- html: input.html,
8799
- files: [],
8800
- errors: [],
8801
- fatal: []
8802
- };
8803
- const bundleRoot = path$1.dirname(input.inputPath);
8804
- const outputDir = path$1.dirname(input.outputPath);
8805
- const files = [];
8806
- const errors = [];
8807
- const fatal = [];
8808
- let html = input.html;
8809
- const tags = input.html.match(IMG_TAG) ?? [];
8810
- for (const tag of tags) {
8811
- const srcMatch = tag.match(SRC_ATTR);
8812
- const rawSrc = srcMatch?.[1] ?? srcMatch?.[2];
8813
- if (!rawSrc) continue;
8814
- const src = unescapeHtml(rawSrc);
8815
- const parsed = parseResourceSrc(src);
8816
- if (!parsed) continue;
8817
- const resolved = resolveBundlePath(parsed.pathname, bundleRoot, input.srcDir);
8818
- if (!resolved.ok) {
8819
- const message = `[ox-content] page resource ${JSON.stringify(src)} on ${input.inputPath} is outside the page bundle`;
8820
- errors.push(message);
8821
- fatal.push(message);
8822
- continue;
8823
- }
8824
- let stat;
8825
- try {
8826
- stat = await fs$2.stat(resolved.absolute);
8827
- } catch {
8828
- const message = `[ox-content] missing page resource ${JSON.stringify(parsed.pathname)} on ${input.inputPath}`;
8829
- errors.push(message);
8830
- if (input.options.missing === "error") fatal.push(message);
8831
- continue;
8832
- }
8833
- const transformError = validateTransform(parsed.transform, input.options);
8834
- if (transformError) {
8835
- const message = `[ox-content] ${transformError} for ${JSON.stringify(src)} on ${input.inputPath}`;
8836
- errors.push(message);
8837
- fatal.push(message);
8838
- continue;
8839
- }
8840
- const hasTransform = hasPixelOrFormatTransform(parsed.transform);
8841
- const outputName = hasTransform ? transformedFileName(parsed.pathname, parsed.transform, resourceCacheKey(resolved.absolute, stat.mtimeMs, parsed.transform)) : path$1.basename(resolved.absolute);
8842
- const outputFile = path$1.join(outputDir, outputName);
8843
- try {
8844
- if (hasTransform) await writeTransformedResource({
8845
- sourcePath: resolved.absolute,
8846
- outputFile,
8847
- cacheDir: input.cacheDir,
8848
- mtimeMs: stat.mtimeMs,
8849
- transform: parsed.transform
8850
- });
8851
- else {
8852
- await fs$2.mkdir(outputDir, { recursive: true });
8853
- await fs$2.copyFile(resolved.absolute, outputFile);
8854
- }
8855
- files.push(outputFile);
8856
- const rewritten = tag.replace(rawSrc, escapeAttribute(outputName));
8857
- html = html.replace(tag, rewritten);
8858
- } catch (error) {
8859
- const detail = error instanceof Error ? error.message : String(error);
8860
- const message = `[ox-content] failed to process page resource ${JSON.stringify(src)} on ${input.inputPath}: ${detail}`;
8861
- errors.push(message);
8862
- fatal.push(message);
8863
- }
8864
- }
8865
- return {
8866
- html,
8867
- files,
8868
- errors,
8869
- fatal
8870
- };
8871
- }
8872
- function resolveBundlePath(pathname, bundleRoot, contentRoot) {
8873
- if (path$1.isAbsolute(pathname) || pathname.includes("\0")) return { ok: false };
8874
- const absolute = path$1.resolve(bundleRoot, pathname);
8875
- if (!isInsideRoot$1(bundleRoot, absolute) || !isInsideRoot$1(contentRoot, absolute)) return { ok: false };
8876
- return {
8877
- ok: true,
8878
- absolute
8879
- };
8880
- }
8881
- function validateTransform(transform, options) {
8882
- if (transform.width && options.widths.length > 0 && !options.widths.includes(transform.width)) return `width ${transform.width} is not in resources.widths`;
8883
- if (transform.format && !options.formats.includes(transform.format)) return `format ${transform.format} is not in resources.formats`;
8884
- }
8885
- function hasPixelOrFormatTransform(transform) {
8886
- return Boolean(transform.width || transform.height || transform.crop || transform.format);
8887
- }
8888
- function transformedFileName(pathname, transform, cacheKey) {
8889
- const stem = path$1.basename(pathname).replace(/\.[^.]+$/, "") || "resource";
8890
- const ext = outputExtension(pathname, transform.format);
8891
- return `${stem}.${cacheKey.slice(0, 12)}.${ext}`;
8892
- }
8893
- function outputExtension(pathname, format) {
8894
- if (format === "jpeg") return "jpg";
8895
- if (format) return format;
8896
- const ext = path$1.extname(pathname).slice(1).toLowerCase();
8897
- return ext === "jpeg" ? "jpg" : ext || "png";
8898
- }
8899
- async function writeTransformedResource(input) {
10255
+ async function ensureTransformedCache(input) {
8900
10256
  const key = resourceCacheKey(input.sourcePath, input.mtimeMs, input.transform);
8901
10257
  const ext = path$1.extname(input.outputFile);
8902
10258
  const cacheFile = path$1.join(input.cacheDir, `${key}${ext}`);
8903
10259
  try {
8904
- await fs$2.copyFile(cacheFile, input.outputFile);
8905
- return;
10260
+ await fs$2.access(cacheFile);
10261
+ return cacheFile;
8906
10262
  } catch {}
8907
10263
  const output = transformResourceBuffer(await fs$2.readFile(input.sourcePath), input.sourcePath, input.transform);
8908
10264
  if (output.length > 8388608) throw new Error("transform produced an oversized file");
8909
- await fs$2.mkdir(path$1.dirname(input.outputFile), { recursive: true });
8910
10265
  await fs$2.mkdir(input.cacheDir, { recursive: true });
8911
10266
  await fs$2.writeFile(cacheFile, output);
8912
- await fs$2.writeFile(input.outputFile, output);
10267
+ return cacheFile;
8913
10268
  }
8914
10269
  function transformResourceBuffer(source, sourcePath, transform) {
8915
10270
  const needsPixels = Boolean(transform.width || transform.height || transform.crop);
@@ -8952,11 +10307,221 @@ function formatFromPath(filePath) {
8952
10307
  const ext = path$1.extname(filePath).slice(1).toLowerCase();
8953
10308
  return ext === "jpg" ? "jpeg" : ext;
8954
10309
  }
8955
- function unescapeHtml(value) {
8956
- 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
+ };
8957
10368
  }
8958
- function escapeAttribute(value) {
8959
- 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
+ }
8960
10525
  }
8961
10526
  //#endregion
8962
10527
  //#region src/resources.ts
@@ -8991,19 +10556,22 @@ function resolveResourcesOptions(value) {
8991
10556
  enabled: false,
8992
10557
  formats: [...DEFAULT_FORMATS],
8993
10558
  widths: [],
8994
- missing: "error"
10559
+ missing: "error",
10560
+ dedupe: false
8995
10561
  };
8996
10562
  if (value === true) return {
8997
10563
  enabled: true,
8998
10564
  formats: [...DEFAULT_FORMATS],
8999
10565
  widths: [],
9000
- missing: "error"
10566
+ missing: "error",
10567
+ dedupe: false
9001
10568
  };
9002
10569
  return {
9003
10570
  enabled: true,
9004
10571
  formats: normalizeFormats(value.formats),
9005
10572
  widths: normalizeWidths(value.widths),
9006
- missing: value.missing === "warn" ? "warn" : "error"
10573
+ missing: value.missing === "warn" ? "warn" : "error",
10574
+ dedupe: value.dedupe === true
9007
10575
  };
9008
10576
  }
9009
10577
  /**
@@ -9249,10 +10817,12 @@ function resolveSsgOptions(ssg) {
9249
10817
  pagination: false,
9250
10818
  breadcrumbs: false,
9251
10819
  jsonLd: false,
10820
+ headValidation: false,
9252
10821
  readerChrome: false,
9253
10822
  localeSwitcher: false,
9254
10823
  a11y: false,
9255
10824
  pageChrome: false,
10825
+ markdownSource: resolveMarkdownSourceOptions(void 0),
9256
10826
  notFound: resolveNotFoundOptions(void 0),
9257
10827
  team: resolveTeamOptions(void 0),
9258
10828
  blog: resolveBlogOptions(void 0),
@@ -9269,10 +10839,12 @@ function resolveSsgOptions(ssg) {
9269
10839
  pagination: false,
9270
10840
  breadcrumbs: false,
9271
10841
  jsonLd: false,
10842
+ headValidation: false,
9272
10843
  readerChrome: false,
9273
10844
  localeSwitcher: false,
9274
10845
  a11y: false,
9275
10846
  pageChrome: false,
10847
+ markdownSource: resolveMarkdownSourceOptions(void 0),
9276
10848
  notFound: resolveNotFoundOptions(void 0),
9277
10849
  team: resolveTeamOptions(void 0),
9278
10850
  blog: resolveBlogOptions(void 0),
@@ -9297,10 +10869,12 @@ function resolveSsgOptions(ssg) {
9297
10869
  pagination: resolvePaginationOption(ssg.pagination),
9298
10870
  breadcrumbs: resolvePaginationOption(ssg.breadcrumbs),
9299
10871
  jsonLd: resolveJsonLdOption(ssg.jsonLd),
10872
+ headValidation: resolveHeadValidation(ssg.headValidation),
9300
10873
  readerChrome: resolveReaderChromeOption(ssg.readerChrome),
9301
10874
  localeSwitcher: resolveLocaleSwitcherOption(ssg.localeSwitcher),
9302
10875
  a11y: resolveA11yOption(ssg.a11y),
9303
10876
  pageChrome: resolvePageChromeOption(ssg.pageChrome),
10877
+ markdownSource: resolveMarkdownSourceOptions(ssg.markdownSource),
9304
10878
  notFound: resolveNotFoundOptions(ssg.notFound),
9305
10879
  team: resolveTeamOptions(ssg.team),
9306
10880
  blog: resolveBlogOptions(ssg.blog),
@@ -9328,7 +10902,9 @@ function resolveJsonLdOption(value) {
9328
10902
  const publisher = resolveJsonLdPublisher(value.publisher);
9329
10903
  return {
9330
10904
  breadcrumbs: value.breadcrumbs !== false,
9331
- ...publisher ? { publisher } : {}
10905
+ ...publisher ? { publisher } : {},
10906
+ ...value.type ? { type: value.type } : {},
10907
+ ...value.graph ? { graph: value.graph } : {}
9332
10908
  };
9333
10909
  }
9334
10910
  return false;
@@ -9471,7 +11047,7 @@ function localeCodesFor(locales) {
9471
11047
  async function generateHtmlPage(pageData, navGroups, siteName, base, ogImage, theme, locale, availableLocales, pagination = false, readerChrome = false, breadcrumbs = false, localeSwitcher = false, localePaths, a11y = false, team = {
9472
11048
  enabled: false,
9473
11049
  members: []
9474
- }, pageChrome = false, breadcrumbRootHref, jsonLd = false, siteUrl) {
11050
+ }, pageChrome = false, breadcrumbRootHref, jsonLd = false, siteUrl, headValidation = false, defaultLocale) {
9475
11051
  const mod = await importNapiModule();
9476
11052
  const tocForRust = pageData.toc.map(toRustTocEntry);
9477
11053
  const navGroupsForRust = convertNavGroupsForRust(navGroups);
@@ -9507,7 +11083,7 @@ async function generateHtmlPage(pageData, navGroups, siteName, base, ogImage, th
9507
11083
  linkText: f.linkText
9508
11084
  }))
9509
11085
  } : void 0;
9510
- return mod.generateSsgHtml({
11086
+ const result = mod.generateSsgHtml({
9511
11087
  title: pageData.title,
9512
11088
  description: pageData.description,
9513
11089
  content: pageData.content,
@@ -9520,12 +11096,16 @@ async function generateHtmlPage(pageData, navGroups, siteName, base, ogImage, th
9520
11096
  next: pageData.next,
9521
11097
  breadcrumbs: pageData.breadcrumbs,
9522
11098
  layout: typeof pageData.frontmatter.layout === "string" ? pageData.frontmatter.layout : void 0,
9523
- chrome: pageData.chrome
11099
+ chrome: pageData.chrome,
11100
+ robots: typeof pageData.frontmatter.robots === "string" ? pageData.frontmatter.robots : void 0,
11101
+ canonical: typeof pageData.frontmatter.canonical === "string" ? pageData.frontmatter.canonical : void 0
9524
11102
  }, navGroupsForRust, {
9525
11103
  siteName,
9526
11104
  base,
9527
11105
  breadcrumbRootHref,
9528
11106
  ogImage,
11107
+ siteUrl,
11108
+ headValidation: headValidation || void 0,
9529
11109
  theme: themeForRust,
9530
11110
  locale,
9531
11111
  availableLocales: availableLocales ? toRustLocales(availableLocales) : void 0,
@@ -9544,9 +11124,18 @@ async function generateHtmlPage(pageData, navGroups, siteName, base, ogImage, th
9544
11124
  jsonLd: jsonLd ? {
9545
11125
  breadcrumbs: jsonLd.breadcrumbs,
9546
11126
  publisher: jsonLd.publisher,
9547
- siteUrl
11127
+ siteUrl,
11128
+ pageType: jsonLd.type,
11129
+ graph: jsonLd.graph?.map((node) => JSON.stringify(node))
9548
11130
  } : void 0
9549
11131
  });
11132
+ const html = typeof result === "string" ? result : result.html;
11133
+ reportHeadDiagnostics(typeof result === "string" ? [] : result.diagnostics ?? [], headValidation);
11134
+ return injectSearchLocaleFilters(html, {
11135
+ locales: availableLocales ?? [],
11136
+ current: locale,
11137
+ defaultLocale: defaultLocale ?? availableLocales?.[0]?.code ?? "en"
11138
+ });
9550
11139
  }
9551
11140
  async function externalizeSharedPageAssets(pages, outDir, base) {
9552
11141
  const optimized = (await importNapiModule()).externalizeSsgAssets(pages, outDir, base);
@@ -9631,6 +11220,7 @@ async function buildSsg(options, root) {
9631
11220
  applyPermalinkRoutes(context, collected);
9632
11221
  errors.push(...collected.errors);
9633
11222
  const { outputPages, listedPages } = applyPublishState(context, collected);
11223
+ context.markdownSourcePages.push(...outputPages);
9634
11224
  remapPermalinkNav(context, listedPages);
9635
11225
  await applyPageResources(context, outputPages, generatedFiles, errors);
9636
11226
  await generateOgImageAssets(context, collected, generatedFiles, errors);
@@ -9708,7 +11298,8 @@ async function createBuildSsgContext(options, root, srcDir, outDir, markdownFile
9708
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)),
9709
11299
  siteName: await resolveSiteName$1(root, ssgOptions),
9710
11300
  shouldGenerateOgImages: shouldGenerateOgImages(options),
9711
- napi: ssgOptions.lastUpdated || ssgOptions.contributors ? await importNapiModule() : void 0
11301
+ markdownSourcePages: [],
11302
+ napi: ssgOptions.lastUpdated || ssgOptions.contributors || options.siteMaps?.enabled ? await importNapiModule() : void 0
9712
11303
  };
9713
11304
  }
9714
11305
  /**
@@ -9738,6 +11329,7 @@ async function applyPageResources(context, pages, generatedFiles, errors) {
9738
11329
  if (!options?.enabled) return;
9739
11330
  const cacheDir = path$2.join(context.root, ".cache", "ox-content-resources");
9740
11331
  const fatal = [];
11332
+ const dedupeStore = options.dedupe ? createResourceDedupeStore() : void 0;
9741
11333
  for (const page of pages) {
9742
11334
  const processed = await processPageResources({
9743
11335
  html: page.transformedHtml,
@@ -9745,7 +11337,10 @@ async function applyPageResources(context, pages, generatedFiles, errors) {
9745
11337
  outputPath: page.routePaths.outputPath,
9746
11338
  srcDir: context.srcDir,
9747
11339
  options,
9748
- cacheDir
11340
+ cacheDir,
11341
+ outDir: context.outDir,
11342
+ base: context.base,
11343
+ dedupeStore
9749
11344
  });
9750
11345
  page.transformedHtml = processed.html;
9751
11346
  generatedFiles.push(...processed.files);
@@ -9819,7 +11414,8 @@ function applyPublishState(context, collected) {
9819
11414
  };
9820
11415
  }
9821
11416
  async function transformSsgPage(context, inputPath) {
9822
- 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, {
9823
11419
  convertMdLinks: true,
9824
11420
  baseUrl: context.base,
9825
11421
  sourcePath: inputPath
@@ -9829,11 +11425,12 @@ async function transformSsgPage(context, inputPath) {
9829
11425
  const title = extractTitle$1(transformedHtml, frontmatter);
9830
11426
  return {
9831
11427
  inputPath,
11428
+ source: content,
9832
11429
  routePaths: getRoutePaths(inputPath, context.srcDir, context.outDir, context.base, context.ssgOptions.extension, context.ssgOptions.siteUrl),
9833
11430
  transformedHtml,
9834
11431
  title,
9835
11432
  description: frontmatter.description,
9836
- 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,
9837
11434
  contributors: contributorsForPage(context, inputPath),
9838
11435
  frontmatter,
9839
11436
  toc: result.toc
@@ -9924,17 +11521,18 @@ async function generateHtmlPages(context, pageResults, collected, errors) {
9924
11521
  async function renderSsgPage(context, pageResult, collected, allPageResults) {
9925
11522
  const { ogImageUrlMap } = collected;
9926
11523
  const pageOgImage = context.shouldGenerateOgImages && ogImageUrlMap.has(pageResult.inputPath) ? ogImageUrlMap.get(pageResult.inputPath) : context.ssgOptions.ogImage;
11524
+ const markdownSource = pageMarkdownSourceHref(context, pageResult);
9927
11525
  if (context.ssgOptions.render) {
9928
11526
  const nav = context.versionNavigation ? rewriteVersionedNavGroups(context.navItems, context.versionNavigation) : context.navItems;
9929
- return renderPage(toThemePageData(pageResult), {
11527
+ return applyMarkdownSourceAlternate(context, renderPage(toThemePageData(pageResult, markdownSource), {
9930
11528
  theme: context.ssgOptions.render,
9931
11529
  siteName: context.siteName,
9932
11530
  base: context.base,
9933
11531
  nav,
9934
- pages: allPageResults.map(toThemePageData)
9935
- });
11532
+ pages: allPageResults.map((page) => toThemePageData(page, pageMarkdownSourceHref(context, page)))
11533
+ }), markdownSource);
9936
11534
  }
9937
- if (context.ssgOptions.bare) return generateBarePage({
11535
+ if (context.ssgOptions.bare) return applyMarkdownSourceAlternate(context, generateBarePage({
9938
11536
  title: pageResult.title,
9939
11537
  content: pageResult.transformedHtml,
9940
11538
  lang: context.ssgOptions.lang ?? getPageLocale(pageResult.routePaths.urlPath, context.options.i18n),
@@ -9945,7 +11543,7 @@ async function renderSsgPage(context, pageResult, collected, allPageResults) {
9945
11543
  head: context.ssgOptions.head,
9946
11544
  bodyStart: context.ssgOptions.bodyStart,
9947
11545
  bodyEnd: context.ssgOptions.bodyEnd
9948
- });
11546
+ }), markdownSource);
9949
11547
  const pageData = createSsgPageData(pageResult);
9950
11548
  const versionNavigation = context.versionNavigation;
9951
11549
  if (versionNavigation) {
@@ -9986,10 +11584,18 @@ async function renderSsgPage(context, pageResult, collected, allPageResults) {
9986
11584
  base: context.base,
9987
11585
  roots: versionNavigation ? versionedLocaleRoots(versionNavigation, i18n.locales, i18n.defaultLocale, i18n.hideDefaultLocale) : void 0
9988
11586
  }) : void 0;
9989
- 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 ?? {
9990
11588
  enabled: false,
9991
11589
  members: []
9992
- }, context.ssgOptions.pageChrome, versionNavigation?.root.href, context.ssgOptions.jsonLd, context.ssgOptions.siteUrl);
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);
9993
11599
  }
9994
11600
  function rewritePagerOverride(pager, context) {
9995
11601
  return pager?.href ? {
@@ -9998,7 +11604,7 @@ function rewritePagerOverride(pager, context) {
9998
11604
  } : pager;
9999
11605
  }
10000
11606
  /** Maps an internal page result onto the theme renderer's page shape. */
10001
- function toThemePageData(pageResult) {
11607
+ function toThemePageData(pageResult, markdownSource) {
10002
11608
  return {
10003
11609
  title: pageResult.title,
10004
11610
  description: pageResult.description,
@@ -10008,6 +11614,7 @@ function toThemePageData(pageResult) {
10008
11614
  contributors: pageResult.contributors,
10009
11615
  path: pageResult.inputPath,
10010
11616
  url: pageResult.routePaths.href,
11617
+ markdownSource,
10011
11618
  frontmatter: pageResult.frontmatter,
10012
11619
  layout: typeof pageResult.frontmatter.layout === "string" ? pageResult.frontmatter.layout : void 0
10013
11620
  };
@@ -10120,6 +11727,7 @@ async function applyDocumentationVersions(generatedPages, context, errors) {
10120
11727
  ...page.routePaths,
10121
11728
  ...prefixRoutePaths(page.routePaths, entry.prefix, context.outDir, context.base)
10122
11729
  };
11730
+ context.markdownSourcePages.push(...outputPages);
10123
11731
  snapContext.versionNavigation = createVersionNavigationContext({
10124
11732
  prefix: entry.prefix,
10125
11733
  base: context.base,
@@ -10232,6 +11840,20 @@ async function writeGeneratedPages(generatedPages, context, generatedFiles, list
10232
11840
  errors.push(feeds.warning);
10233
11841
  console.warn(feeds.warning);
10234
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);
10235
11857
  }
10236
11858
  /** Turns an SSG `urlPath` (`guide` or `/`) into a same-origin dest (`/guide`). */
10237
11859
  function sitePathFromUrlPath(urlPath) {
@@ -10245,6 +11867,7 @@ function sitemapPages(context, listedPages, outputPages) {
10245
11867
  loc: canonicalPageUrl(context, page.routePaths.urlPath) ?? "",
10246
11868
  title: page.title,
10247
11869
  description: page.description,
11870
+ lastUpdated: page.lastUpdated,
10248
11871
  draft: page.frontmatter.draft === true,
10249
11872
  unlisted: Boolean(context.options.publishState?.enabled) && !listedPaths.has(page.inputPath)
10250
11873
  }));
@@ -10342,7 +11965,8 @@ function createDevServerCache() {
10342
11965
  navGroups: null,
10343
11966
  localePages: null,
10344
11967
  pages: /* @__PURE__ */ new Map(),
10345
- siteName: null
11968
+ siteName: null,
11969
+ markdownSourceIndex: null
10346
11970
  };
10347
11971
  }
10348
11972
  /**
@@ -10351,6 +11975,7 @@ function createDevServerCache() {
10351
11975
  function invalidateNavCache(cache) {
10352
11976
  cache.navGroups = null;
10353
11977
  cache.localePages = null;
11978
+ cache.markdownSourceIndex = null;
10354
11979
  cache.pages.clear();
10355
11980
  }
10356
11981
  /**
@@ -10358,6 +11983,7 @@ function invalidateNavCache(cache) {
10358
11983
  */
10359
11984
  function invalidatePageCache(cache, filePath) {
10360
11985
  cache.pages.delete(filePath);
11986
+ cache.markdownSourceIndex = null;
10361
11987
  }
10362
11988
  /**
10363
11989
  * Resolve site name from options or package.json.
@@ -10447,13 +12073,35 @@ async function renderPage$1(filePath, options, navGroups, siteName, base, root,
10447
12073
  pages: localePages,
10448
12074
  base
10449
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;
10450
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 ?? {
10451
12086
  enabled: false,
10452
12087
  members: []
10453
- }, options.ssg.pageChrome, void 0, options.ssg.jsonLd, options.ssg.siteUrl);
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);
10454
12090
  html = injectViteHmrClient(html);
10455
12091
  return html;
10456
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
+ }
10457
12105
  /**
10458
12106
  * Create the dev server middleware for SSG page serving.
10459
12107
  */
@@ -10466,6 +12114,19 @@ function createDevServerMiddleware(options, root, cache) {
10466
12114
  let routeUrl = url;
10467
12115
  if (base !== "/" && routeUrl.startsWith(base)) routeUrl = "/" + routeUrl.slice(base.length);
10468
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
+ }
10469
12130
  const filePath = await resolveMarkdownFile(routeUrl, srcDir, options.extensions);
10470
12131
  if (!filePath) return next();
10471
12132
  try {
@@ -10982,6 +12643,43 @@ function resolveCardOptions(options) {
10982
12643
  return { enabled: options.enabled ?? true };
10983
12644
  }
10984
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
10985
12683
  //#region src/include-options.ts
10986
12684
  function resolveIncludeOptions(options) {
10987
12685
  if (!options) return { enabled: false };
@@ -11556,6 +13254,12 @@ function createFrameworkMarkdownOptions(options) {
11556
13254
  },
11557
13255
  attrs: { enabled: false },
11558
13256
  badges: { enabled: false },
13257
+ magicLinks: {
13258
+ enabled: false,
13259
+ aliases: {},
13260
+ favicon: false,
13261
+ imageOverrides: []
13262
+ },
11559
13263
  containers: {
11560
13264
  enabled: false,
11561
13265
  types: {}
@@ -12555,7 +14259,7 @@ function createSsgPlugin(resolvedOptions, getRoot, ssgDevCache) {
12555
14259
  for (const error of result.errors) console.warn(`[ox-content] ${error}`);
12556
14260
  } catch (err) {
12557
14261
  console.error("[ox-content] SSG build failed:", err);
12558
- if (err instanceof PageResourceError) throw err;
14262
+ if (err instanceof PageResourceError || err instanceof BlogFeedError) throw err;
12559
14263
  }
12560
14264
  }
12561
14265
  };
@@ -12670,6 +14374,7 @@ function resolveOptions(options) {
12670
14374
  gfm: options.gfm ?? true,
12671
14375
  mdx: options.mdx,
12672
14376
  footnotes: options.footnotes ?? true,
14377
+ semanticFootnotes: options.semanticFootnotes ?? false,
12673
14378
  tables: options.tables ?? true,
12674
14379
  taskLists: options.taskLists ?? true,
12675
14380
  strikethrough: options.strikethrough ?? true,
@@ -12680,6 +14385,7 @@ function resolveOptions(options) {
12680
14385
  emojiShortcodes: resolveEmojiShortcodeOptions(options.emojiShortcodes),
12681
14386
  attrs: resolveAttrsOptions(options.attrs),
12682
14387
  badges: resolveBadgeOptions(options.badges),
14388
+ magicLinks: resolveMagicLinkOptions(options.magicLinks),
12683
14389
  containers: resolveContainerOptions(options.containers),
12684
14390
  images: resolveImageOptions(options.images),
12685
14391
  codeImports: resolveCodeImportOptions(options.codeImports),
@@ -12699,6 +14405,7 @@ function resolveOptions(options) {
12699
14405
  frontmatter: options.frontmatter ?? true,
12700
14406
  toc: options.toc ?? true,
12701
14407
  tocMaxDepth: options.tocMaxDepth ?? 3,
14408
+ headingPermalinks: resolveHeadingPermalinksOptions(options.headingPermalinks),
12702
14409
  ogImage: options.ogImage ?? false,
12703
14410
  ogImageOptions: resolveOgImageOptions(options.ogImageOptions),
12704
14411
  transformers: options.transformers ?? [],
@@ -12989,6 +14696,6 @@ function normalizeRuntimeBase(base) {
12989
14696
  return withLeading.endsWith("/") ? withLeading : `${withLeading}/`;
12990
14697
  }
12991
14698
  //#endregion
12992
- 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, renderHtmlToFrameworkCode, renderHtmlToReactComponent, renderHtmlToReactCreateElement, renderHtmlToSvelteComponent, renderHtmlToVueComponent, renderHtmlToVueH, renderIslandComponentImports, renderMarkdownStream, renderPage, renderToString, resolveBadgeOptions, resolveBlogCollectionName, resolveBlogOptions, resolveBuiltinEmbedOptions, resolveCardOptions, resolveCascadeOptions, resolveCollectionsOptions, resolveContentRootPath, resolveDocsOptions, resolveDocumentComponentImports, resolveFeedsOptions, resolveFileTreeOptions, 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 };
12993
14700
 
12994
14701
  //# sourceMappingURL=index.mjs.map