nuxt-ai-ready 3.3.1 → 3.4.0

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.
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { realpath, readFile, readdir } from 'node:fs/promises';
3
3
  import { join, resolve, isAbsolute, relative, sep } from 'node:path';
4
4
  import { parseDocument } from 'yaml';
5
- import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.CKXM1jnE.mjs';
5
+ import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.O6_9Egm8.mjs';
6
6
  import 'node:module';
7
7
  import 'node:url';
8
8
  import '@nuxt/kit';
@@ -6,7 +6,7 @@ import { resolveLocaleFromRoute } from 'nuxtseo-shared/i18n-runtime';
6
6
  import { createPrerenderFetch } from 'nuxtseo-shared/prerender';
7
7
  import { collectSitemap } from 'sitemapd/parse';
8
8
  import { parseURL, withLeadingSlash, joinURL, withBase } from 'ufo';
9
- import { l as logger, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.CKXM1jnE.mjs';
9
+ import { l as logger, m as matchingStaticContentSignals, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.O6_9Egm8.mjs';
10
10
  import { toLogicalRoute, toDeployedRoute } from '../../dist/runtime/route-path.js';
11
11
  import { insertLlmsTxtDirective } from '../../dist/runtime/llms-txt-directive.js';
12
12
  import { absoluteSiteUrl } from '../../dist/runtime/markdown-links.js';
@@ -497,6 +497,17 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
497
497
  initPromise = initCrawler(state);
498
498
  await initPromise;
499
499
  const parsed = JSON.parse(route.contents || "{}");
500
+ if (parsed.contentSignal !== void 0) {
501
+ route.contentSignal = parsed.contentSignal;
502
+ for (const sourceRoute of pageRoute === "/" ? ["/"] : [pageRoute, `${pageRoute}/`]) {
503
+ if (matchingStaticContentSignals(nitro.options.routeRules, sourceRoute).includes(parsed.contentSignal))
504
+ continue;
505
+ const source = nitro.options.routeRules[sourceRoute] ||= {};
506
+ source.headers ||= {};
507
+ if (!Object.keys(source.headers).some((name) => name.toLowerCase() === "content-signal"))
508
+ source.headers["Content-Signal"] = parsed.contentSignal;
509
+ }
510
+ }
500
511
  if (!state.prerenderedRoutes.has(pageRoute))
501
512
  await processMarkdownRoute(state, nuxt, pageRoute, parsed);
502
513
  const withSitemap = options.sitemapMd === false ? parsed.markdown : appendSitemapSection(parsed.markdown, absoluteSiteUrl(toDeployedRoute(SITEMAP_MD_ROUTE, nitro.options.baseURL), siteInfo?.url));
package/dist/module.d.mts CHANGED
@@ -29,6 +29,7 @@ interface ParsedMarkdownResult {
29
29
  headings: Array<Record<string, string>>;
30
30
  keywords?: string[];
31
31
  updatedAt?: string;
32
+ contentSignal?: string;
32
33
  }
33
34
 
34
35
  interface ResolvedApiCatalogLinkTarget {
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": "^4.6.0 || ^5.0.0-0"
5
5
  },
6
6
  "configKey": "aiReady",
7
- "version": "3.3.1",
7
+ "version": "3.4.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -7,7 +7,7 @@ import '@nuxt/kit';
7
7
  import 'defu';
8
8
  import 'nuxt-site-config/kit';
9
9
  import 'nuxtseo-shared/kit';
10
- export { m as default } from './shared/nuxt-ai-ready.CKXM1jnE.mjs';
10
+ export { a as default } from './shared/nuxt-ai-ready.O6_9Egm8.mjs';
11
11
  import './shared/nuxt-ai-ready.B3hAuOLQ.mjs';
12
12
  import '../dist/runtime/llms-txt-files.js';
13
13
  import '../dist/runtime/route-path.js';
@@ -11,7 +11,7 @@ import { buildFrontmatter } from "../utils/frontmatter.js";
11
11
  import { extractKeywords } from "../utils/keywords.js";
12
12
  import { getMarkdownRenderInfo } from "../utils/markdown-request.js";
13
13
  import { agentSkillArtifactPaths } from "../utils/negotiation-response.js";
14
- import { consumePrerenderedHtml } from "../utils/prerender-html.js";
14
+ import { consumePrerenderedContentSignal, consumePrerenderedHtml } from "../utils/prerender-html.js";
15
15
  import { isSitemapMdRequest } from "../utils/sitemap-md.js";
16
16
  function extractHeadingsFromMarkdown(markdown) {
17
17
  const headings = [];
@@ -37,6 +37,7 @@ export default defineEventHandler(async (event) => {
37
37
  if (!renderInfo || "notAcceptable" in renderInfo)
38
38
  return;
39
39
  const { path } = renderInfo;
40
+ let contentSignal = consumePrerenderedContentSignal(path);
40
41
  const runtimeConfig = fullRuntimeConfig["nuxt-ai-ready"];
41
42
  const deployedPath = toDeployedRoute(path, fullRuntimeConfig.app.baseURL);
42
43
  const canonicalUrl = withSiteUrl(event, deployedPath, { withBase: true });
@@ -62,6 +63,7 @@ ${absoluteMarkdownLinks(contentPage.markdown, canonicalUrl)}`;
62
63
  description: contentPage.description || "",
63
64
  headings,
64
65
  keywords: keywords2,
66
+ ...contentSignal !== void 0 && { contentSignal },
65
67
  // Only a real content date feeds the page index and sitemap lastmod.
66
68
  ...contentPage.updatedAt && { updatedAt: contentPage.updatedAt }
67
69
  });
@@ -95,8 +97,13 @@ ${absoluteMarkdownLinks(contentPage.markdown, canonicalUrl)}`;
95
97
  });
96
98
  }
97
99
  html = await response.text();
100
+ const fetchedPolicy = response.headers.get("content-signal");
101
+ const sharedPolicy = fullRuntimeConfig["nuxt-robots"]?.contentSignalHeader;
102
+ if (fetchedPolicy !== null && fetchedPolicy !== sharedPolicy)
103
+ contentSignal = fetchedPolicy;
98
104
  logger.debug(`[markdown.prerender] Fetched HTML for ${path} (${html.length} bytes)`);
99
105
  consumePrerenderedHtml(path);
106
+ consumePrerenderedContentSignal(path);
100
107
  }
101
108
  if (html.includes("__NUXT_ERROR__") || html.includes("nuxt-error-page")) {
102
109
  return createError({
@@ -125,6 +132,7 @@ ${absoluteMarkdownLinks(contentPage.markdown, canonicalUrl)}`;
125
132
  description: result.description,
126
133
  headings: result.headings,
127
134
  keywords,
135
+ ...contentSignal !== void 0 && { contentSignal },
128
136
  ...result.updatedAt && { updatedAt: result.updatedAt }
129
137
  });
130
138
  });
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,13 @@
1
+ import { defineNitroPlugin, useRuntimeConfig } from "#nuxtseo/nitro";
2
+ import { toLogicalRoute } from "../../route-path.js";
3
+ import { storePrerenderedContentSignal } from "../utils/prerender-html.js";
4
+ export default defineNitroPlugin((nitro) => {
5
+ const hooks = nitro.hooks;
6
+ hooks.hook("response", (response, event) => {
7
+ const policy = response.headers.get("content-signal");
8
+ const config = useRuntimeConfig();
9
+ const sharedPolicy = config["nuxt-robots"]?.contentSignalHeader;
10
+ if (policy !== null && policy !== sharedPolicy)
11
+ storePrerenderedContentSignal(toLogicalRoute(event.url.pathname, config.app.baseURL), policy);
12
+ });
13
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,13 @@
1
+ import { getRequestURL, getResponseHeader } from "#nuxtseo/h3";
2
+ import { defineNitroPlugin, useRuntimeConfig } from "#nuxtseo/nitro";
3
+ import { toLogicalRoute } from "../../route-path.js";
4
+ import { storePrerenderedContentSignal } from "../utils/prerender-html.js";
5
+ export default defineNitroPlugin((nitro) => {
6
+ nitro.hooks.hook("beforeResponse", (event) => {
7
+ const policy = getResponseHeader(event, "content-signal");
8
+ const config = useRuntimeConfig(event);
9
+ const sharedPolicy = config["nuxt-robots"]?.contentSignalHeader;
10
+ if (typeof policy === "string" && policy !== sharedPolicy)
11
+ storePrerenderedContentSignal(toLogicalRoute(getRequestURL(event).pathname, config.app.baseURL), policy);
12
+ });
13
+ });
@@ -1,3 +1,6 @@
1
1
  import type { NuxtRenderHTMLContext } from 'nuxt/app';
2
2
  export declare function storePrerenderedHtml(path: string, html: string | NuxtRenderHTMLContext): void;
3
+ /** Only rendered pages can hand a policy to a Markdown twin. */
4
+ export declare function storePrerenderedContentSignal(path: string, policy: string): void;
5
+ export declare function consumePrerenderedContentSignal(path: string): string | undefined;
3
6
  export declare function consumePrerenderedHtml(path: string): string | undefined;
@@ -1,10 +1,22 @@
1
1
  const htmlByPath = /* @__PURE__ */ new Map();
2
+ const contentSignalByPath = /* @__PURE__ */ new Map();
2
3
  function normalizeHtmlCachePath(path) {
3
4
  return path.replace(/\/+$/, "") || "/";
4
5
  }
5
6
  export function storePrerenderedHtml(path, html) {
6
7
  htmlByPath.set(normalizeHtmlCachePath(path), html);
7
8
  }
9
+ export function storePrerenderedContentSignal(path, policy) {
10
+ const key = normalizeHtmlCachePath(path);
11
+ if (htmlByPath.has(key))
12
+ contentSignalByPath.set(key, policy);
13
+ }
14
+ export function consumePrerenderedContentSignal(path) {
15
+ const key = normalizeHtmlCachePath(path);
16
+ const policy = contentSignalByPath.get(key);
17
+ contentSignalByPath.delete(key);
18
+ return policy;
19
+ }
8
20
  export function consumePrerenderedHtml(path) {
9
21
  const key = normalizeHtmlCachePath(path);
10
22
  const html = htmlByPath.get(key);
@@ -563,6 +563,334 @@ async function readCloudflareAssetRouting(rootDir) {
563
563
  }
564
564
  }
565
565
 
566
+ function escapeRegExp(value) {
567
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
568
+ }
569
+ function ensureStaticHeader(contents, route, name, value, mode = "default") {
570
+ const eol = contents.includes("\r\n") ? "\r\n" : "\n";
571
+ const routePattern = new RegExp(`^${escapeRegExp(route)}[\\t ]*\\r?$`, "gm");
572
+ const routeMatches = [...contents.matchAll(routePattern)];
573
+ const routeMatch = routeMatches.at(-1);
574
+ if (!routeMatch || routeMatch.index === void 0) {
575
+ const separator = contents.length === 0 ? "" : contents.endsWith(`${eol}${eol}`) ? "" : contents.endsWith(eol) ? eol : `${eol}${eol}`;
576
+ return `${contents}${separator}${route}${eol} ${name}: ${value}${eol}`;
577
+ }
578
+ const routeLineEnd = routeMatch.index + routeMatch[0].length;
579
+ const blockStart = contents[routeLineEnd] === "\n" ? routeLineEnd + 1 : routeLineEnd;
580
+ const nextBlockPattern = /^(?![\t ]|\r?$).+/gm;
581
+ nextBlockPattern.lastIndex = blockStart;
582
+ const nextBlock = nextBlockPattern.exec(contents);
583
+ const blockEnd = nextBlock?.index ?? contents.length;
584
+ const block = contents.slice(blockStart, blockEnd);
585
+ const escapedName = escapeRegExp(name);
586
+ const existingHeaderPattern = new RegExp(
587
+ `^[\\t ]+(?:${escapedName}[\\t ]*:|![\\t ]*${escapedName}[\\t ]*\\r?$)`,
588
+ "im"
589
+ );
590
+ if (existingHeaderPattern.test(block)) {
591
+ if (mode === "append") {
592
+ const removalPattern = new RegExp(`^[\\t ]+![\\t ]*${escapedName}[\\t ]*\\r?$`, "im");
593
+ if (removalPattern.test(block))
594
+ return contents;
595
+ const valuePattern = new RegExp(`^([\\t ]+${escapedName}[\\t ]*:[\\t ]*)([^\\r\\n]*)(\\r?$)`, "im");
596
+ const match = block.match(valuePattern);
597
+ if (match) {
598
+ const merged = block.replace(valuePattern, (_line, prefix2, current, suffix) => {
599
+ const appended = name.toLowerCase() === "link" ? current.includes(value) ? current : [current, value].filter(Boolean).join(", ") : mergeVaryHeader(current, value);
600
+ return `${prefix2}${appended}${suffix}`;
601
+ });
602
+ return `${contents.slice(0, blockStart)}${merged}${contents.slice(blockEnd)}`;
603
+ }
604
+ }
605
+ return contents;
606
+ }
607
+ const prefix = blockStart === routeLineEnd ? eol : "";
608
+ return `${contents.slice(0, blockStart)}${prefix} ${name}: ${value}${eol}${contents.slice(blockStart)}`;
609
+ }
610
+ function mergeStaticDiscoveryHeaders(contents, baseURL, entries) {
611
+ if (entries.length === 0)
612
+ return contents;
613
+ const route = toDeployedRoute("/*", baseURL);
614
+ const blocks = splitHeaderBlocks(contents);
615
+ const matching = blocks.filter((block) => block.route === route);
616
+ let merged = matching[0]?.text || "";
617
+ for (const block of matching.slice(1)) {
618
+ for (const line of block.text.split(/(?<=\n)/).slice(1)) {
619
+ const header = line.match(/^[\t ]+([^:\s]+):[\t ]*([^\r\n]*)/);
620
+ if (header?.[1]?.toLowerCase() === "link") {
621
+ merged = ensureStaticHeader(merged, route, header[1], header[2], "append");
622
+ } else if (header) {
623
+ const existing = new RegExp(`^[\\t ]+${escapeRegExp(header[1])}[\\t ]*:[^\\r\\n]*(?:\\r?\\n|$)`, "gim");
624
+ merged = ensureStaticHeader(merged.replace(existing, ""), route, header[1], header[2]);
625
+ } else {
626
+ merged += line;
627
+ }
628
+ }
629
+ }
630
+ if (matching.length > 1) {
631
+ let inserted = false;
632
+ contents = blocks.map((block) => {
633
+ if (block.route !== route)
634
+ return block.text;
635
+ if (inserted)
636
+ return "";
637
+ inserted = true;
638
+ return merged;
639
+ }).join("");
640
+ }
641
+ return entries.reduce((headers, entry) => ensureStaticHeader(headers, route, "Link", entry, "append"), contents);
642
+ }
643
+ function parseHeaderLine(line) {
644
+ const colon = line.indexOf(":");
645
+ if (!/^[\t ]/.test(line) || colon < 0)
646
+ return void 0;
647
+ const name = line.slice(0, colon).trim();
648
+ if (!name || name.startsWith("!") || /\s/.test(name))
649
+ return void 0;
650
+ const eol = line.endsWith("\r\n") ? "\r\n" : line.endsWith("\n") ? "\n" : "";
651
+ return {
652
+ name,
653
+ value: line.slice(colon + 1, line.length - eol.length).trim(),
654
+ withValue: (value) => `${line.slice(0, colon + 1)} ${value}${eol}`
655
+ };
656
+ }
657
+ const RE_DETACH_LINE = /^[\t ]+![\t ]*([^\s:]+)/;
658
+ function patternToRegExp(pattern) {
659
+ const source = pattern.split(/(\*|:\w+)/).map((part) => part === "*" ? ".*" : part.startsWith(":") ? "[^/]+" : escapeRegExp(part)).join("");
660
+ return new RegExp(`^${source}$`);
661
+ }
662
+ function matchingStaticContentSignals(routeRules, route) {
663
+ return Object.entries(routeRules).flatMap(([pattern, rule]) => {
664
+ if (!patternToRegExp(pattern).test(route))
665
+ return [];
666
+ const headers = rule?.headers;
667
+ return Object.entries(headers || {}).filter(([name]) => name.toLowerCase() === "content-signal").map(([, value]) => value);
668
+ });
669
+ }
670
+ function normalizeStaticContentSignalHeaders(contents) {
671
+ const parsed = splitHeaderBlocks(contents);
672
+ if (!parsed.some((block) => /^\s+content-signal\s*:/im.test(block.text)))
673
+ return contents;
674
+ const policyLine = /^[\t ]+(?:content-signal\s*:.*|!\s*content-signal\s*)$/i;
675
+ const hasPolicy = (block) => block.text.split(/\r?\n/).slice(1).some((line) => policyLine.test(line));
676
+ const repeated = /* @__PURE__ */ new Map();
677
+ for (const block of parsed) {
678
+ if (block.route !== null)
679
+ repeated.set(block.route, [...repeated.get(block.route) || [], block]);
680
+ }
681
+ const combined = /* @__PURE__ */ new Set();
682
+ const blocks = parsed.flatMap((block) => {
683
+ const group = block.route === null ? [] : repeated.get(block.route);
684
+ if (group.length < 2 || !group.some(hasPolicy))
685
+ return [block];
686
+ if (combined.has(block.route))
687
+ return [];
688
+ combined.add(block.route);
689
+ const lines = group.at(-1).text.split(/\r?\n/).slice(1).filter((line) => !policyLine.test(line));
690
+ const policy = group.filter(hasPolicy).at(-1).text.split(/\r?\n/).slice(1).filter((line) => policyLine.test(line));
691
+ return [{ route: block.route, text: `${block.route}
692
+ ${[...lines, ...policy].join("\n")}
693
+ ` }];
694
+ });
695
+ const policyOnly = (block) => hasPolicy(block) && block.text.split(/\r?\n/).slice(1).every((line) => !line.trim() || line.trimStart().startsWith("#") || policyLine.test(line));
696
+ const policyValue = (block) => block.text.match(/^[\t ]+content-signal[\t ]*:(.*)$/im)?.[1]?.trim() ?? null;
697
+ const edges = blocks.map(() => /* @__PURE__ */ new Set());
698
+ const incoming = blocks.map(() => 0);
699
+ const connect = (before, after) => {
700
+ if (!edges[before].has(after)) {
701
+ edges[before].add(after);
702
+ incoming[after]++;
703
+ }
704
+ };
705
+ let previousUnrelated;
706
+ for (let index = 0; index < blocks.length; index++) {
707
+ if (!policyOnly(blocks[index])) {
708
+ if (previousUnrelated !== void 0)
709
+ connect(previousUnrelated, index);
710
+ previousUnrelated = index;
711
+ }
712
+ }
713
+ for (let broad = 0; broad < blocks.length; broad++) {
714
+ const source = blocks[broad];
715
+ if (!hasPolicy(source) || source.route === null)
716
+ continue;
717
+ for (let narrow = 0; narrow < blocks.length; narrow++) {
718
+ const target = blocks[narrow];
719
+ if (broad !== narrow && hasPolicy(target) && target.route !== null && policyValue(source) !== policyValue(target) && coversContentSignalPattern(source.route, target.route)) {
720
+ connect(broad, narrow);
721
+ }
722
+ }
723
+ }
724
+ const ordered = [];
725
+ const emitted = /* @__PURE__ */ new Set();
726
+ while (ordered.length < blocks.length) {
727
+ const next = incoming.findIndex((count, index) => count === 0 && !emitted.has(index));
728
+ if (next === -1)
729
+ throw new Error("Content-Signal rules conflict with other header ordering. Place broader mixed policy rules before narrower rules.");
730
+ emitted.add(next);
731
+ ordered.push(blocks[next]);
732
+ for (const target of edges[next])
733
+ incoming[target]--;
734
+ }
735
+ return ordered.map((block) => {
736
+ const text = block.text.trimEnd();
737
+ if (!/^\s+content-signal\s*:/im.test(text) || /^\s+!\s*content-signal\s*$/im.test(text))
738
+ return `${text}
739
+ `;
740
+ return `${text.replace(/^(\s+)(content-signal\s*:)/im, "$1! Content-Signal\n$1$2")}
741
+ `;
742
+ }).join("\n");
743
+ }
744
+ function coversPattern(broad, narrow) {
745
+ if (broad === narrow || !broad.startsWith("/") || !narrow.startsWith("/"))
746
+ return false;
747
+ if (!/[*:]/.test(narrow))
748
+ return patternToRegExp(broad).test(narrow);
749
+ return broad.indexOf("*") === broad.length - 1 && !broad.includes(":") && narrow.startsWith(broad.slice(0, -1));
750
+ }
751
+ function coversContentSignalPattern(broad, narrow) {
752
+ if (coversPattern(broad, narrow))
753
+ return true;
754
+ if (broad === narrow || !broad.startsWith("/") || !narrow.startsWith("/") || broad.indexOf("*") !== broad.length - 1 || !broad.includes(":")) {
755
+ return false;
756
+ }
757
+ const prefix = narrow.split("*")[0];
758
+ return !prefix.includes(":") && patternToRegExp(broad).test(prefix);
759
+ }
760
+ function splitLinkEntries(value) {
761
+ const entries = [];
762
+ let current = "";
763
+ let inUrl = false;
764
+ let inQuote = false;
765
+ for (const char of value) {
766
+ if (char === "<" && !inQuote)
767
+ inUrl = true;
768
+ else if (char === ">" && !inQuote)
769
+ inUrl = false;
770
+ else if (char === '"' && !inUrl)
771
+ inQuote = !inQuote;
772
+ if (char === "," && !inUrl && !inQuote) {
773
+ entries.push(current.trim());
774
+ current = "";
775
+ continue;
776
+ }
777
+ current += char;
778
+ }
779
+ entries.push(current.trim());
780
+ return entries.filter(Boolean);
781
+ }
782
+ function headerTokens(name, value) {
783
+ if (name === "link")
784
+ return splitLinkEntries(value);
785
+ if (name === "vary")
786
+ return value.split(",").map((token) => token.trim()).filter(Boolean);
787
+ return [value.trim()];
788
+ }
789
+ function tokenKey(name, token) {
790
+ return name === "vary" ? token.toLowerCase() : token;
791
+ }
792
+ function dedupeStaticHeaders(contents, names) {
793
+ const owned = new Set(names.map((name) => name.toLowerCase()));
794
+ const blocks = splitHeaderBlocks(contents);
795
+ const detached = /* @__PURE__ */ new Set();
796
+ const parsed = blocks.map((block) => {
797
+ const headers = [];
798
+ for (const line of block.text.split(/(?<=\n)/).slice(block.route === null ? 0 : 1)) {
799
+ const detach = line.match(RE_DETACH_LINE);
800
+ if (detach) {
801
+ detached.add(detach[1].toLowerCase());
802
+ continue;
803
+ }
804
+ const header = parseHeaderLine(line);
805
+ if (!header)
806
+ continue;
807
+ const name = header.name.toLowerCase();
808
+ if (owned.has(name))
809
+ headers.push({ name, tokens: headerTokens(name, header.value) });
810
+ }
811
+ return headers;
812
+ });
813
+ const lastRuleIndex = /* @__PURE__ */ new Map();
814
+ blocks.forEach((block, index) => {
815
+ if (block.route !== null)
816
+ lastRuleIndex.set(block.route, index);
817
+ });
818
+ let changed = false;
819
+ const output = blocks.map((block) => {
820
+ const route = block.route;
821
+ if (route === null)
822
+ return block.text;
823
+ const provided = /* @__PURE__ */ new Map();
824
+ lastRuleIndex.forEach((index, pattern) => {
825
+ if (!coversPattern(pattern, route))
826
+ return;
827
+ for (const { name, tokens } of parsed[index]) {
828
+ if (detached.has(name))
829
+ continue;
830
+ const values = provided.get(name) ?? /* @__PURE__ */ new Set();
831
+ tokens.forEach((token) => values.add(tokenKey(name, token)));
832
+ provided.set(name, values);
833
+ }
834
+ });
835
+ if (provided.size === 0)
836
+ return block.text;
837
+ const [routeLine, ...lines] = block.text.split(/(?<=\n)/);
838
+ let headerLines = 0;
839
+ const kept = lines.flatMap((line) => {
840
+ const header = parseHeaderLine(line);
841
+ if (!header) {
842
+ if (RE_DETACH_LINE.test(line))
843
+ headerLines++;
844
+ return [line];
845
+ }
846
+ headerLines++;
847
+ const name = header.name.toLowerCase();
848
+ const covered = provided.get(name);
849
+ if (!covered)
850
+ return [line];
851
+ const tokens = headerTokens(name, header.value);
852
+ const remaining = tokens.filter((token) => !covered.has(tokenKey(name, token)));
853
+ if (remaining.length === tokens.length)
854
+ return [line];
855
+ changed = true;
856
+ if (remaining.length > 0)
857
+ return [header.withValue(remaining.join(", "))];
858
+ headerLines--;
859
+ return [];
860
+ });
861
+ return headerLines === 0 ? kept.join("") : `${routeLine}${kept.join("")}`;
862
+ });
863
+ return changed ? output.join("") : contents;
864
+ }
865
+ const CLOUDFLARE_STATIC_HEADER_RULE_LIMIT = 100;
866
+ const RE_EXACT_MARKDOWN_ROUTE$1 = /^\/[^*\s]*\.md[\t ]*\r?$/;
867
+ function splitHeaderBlocks(contents) {
868
+ const blocks = [];
869
+ let current = { route: null, text: "" };
870
+ for (const line of contents.split(/(?<=\n)/)) {
871
+ const isRoute = /^[^\s#]/.test(line);
872
+ if (isRoute) {
873
+ blocks.push(current);
874
+ current = { route: line.trimEnd(), text: "" };
875
+ }
876
+ current.text += line;
877
+ }
878
+ blocks.push(current);
879
+ return blocks;
880
+ }
881
+ function enforceStaticHeaderBudget(contents, limit) {
882
+ const blocks = splitHeaderBlocks(contents);
883
+ const ruleCount = blocks.filter((block) => block.route !== null).length;
884
+ if (ruleCount <= limit)
885
+ return { contents, total: ruleCount, dropped: 0 };
886
+ const kept = blocks.filter((block) => block.route === null || !RE_EXACT_MARKDOWN_ROUTE$1.test(block.route) || /^\s+(?:content-signal\s*:|!\s*content-signal\s*$)/im.test(block.text));
887
+ return {
888
+ contents: kept.map((block) => block.text).join(""),
889
+ total: kept.filter((block) => block.route !== null).length,
890
+ dropped: ruleCount - kept.filter((block) => block.route !== null).length
891
+ };
892
+ }
893
+
566
894
  const RE_MD_EXT = /\.md$/;
567
895
  function isStaticMarkdownSourceRoute(route) {
568
896
  if (route.includes("*") || route.includes(":"))
@@ -607,41 +935,68 @@ function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby,
607
935
  headers: {
608
936
  "Content-Type": "text/markdown; charset=utf-8",
609
937
  "Vary": "Accept",
610
- "Link": buildStaticMarkdownLinkHeader(pageRoute, baseURL, describedby)
938
+ "Link": buildStaticMarkdownLinkHeader(pageRoute, baseURL, describedby),
939
+ ...entry.contentSignal !== void 0 && { "Content-Signal": entry.contentSignal }
611
940
  }
612
941
  });
613
942
  }
614
943
  }
615
944
  return [...rules.values()];
616
945
  }
617
- const RE_EXACT_MARKDOWN_ROUTE$1 = /^\/[^*:\s]*\.md$/;
946
+ const RE_EXACT_MARKDOWN_ROUTE = /^\/[^*:\s]*\.md$/;
618
947
  function isStaticHeaderRouteRule([, rule]) {
619
948
  return Boolean(rule && "headers" in rule && rule.headers);
620
949
  }
950
+ function hasContentSignal$1(headers) {
951
+ return Object.keys(headers || {}).some((name) => name.toLowerCase() === "content-signal");
952
+ }
621
953
  function planStaticMarkdownHeaderRules(routeRules, rules, limit) {
954
+ rules = rules.map((rule) => {
955
+ if (!hasContentSignal$1(rule.headers) || matchingStaticContentSignals(routeRules, rule.route).length === 0)
956
+ return rule;
957
+ return { ...rule, headers: Object.fromEntries(Object.entries(rule.headers).filter(([name]) => name.toLowerCase() !== "content-signal")) };
958
+ });
622
959
  if (limit === null)
623
960
  return { _tag: "apply", rules };
624
961
  const entries = Object.entries(routeRules).filter(isStaticHeaderRouteRule);
625
- const registeredMarkdown = entries.map(([route]) => route).filter((route) => RE_EXACT_MARKDOWN_ROUTE$1.test(route));
962
+ const registeredMarkdown = entries.map(([route]) => route).filter((route) => RE_EXACT_MARKDOWN_ROUTE.test(route));
626
963
  const other = entries.length - registeredMarkdown.length;
627
964
  const markdown = /* @__PURE__ */ new Set([...registeredMarkdown, ...rules.map((rule) => rule.route)]);
628
965
  const total = other + markdown.size;
629
966
  if (total <= limit)
630
967
  return { _tag: "apply", rules };
631
- return { _tag: "skip", drop: registeredMarkdown, total, dropped: markdown.size };
968
+ const policyRules = rules.filter((rule) => hasContentSignal$1(rule.headers));
969
+ const policyRoutes = /* @__PURE__ */ new Set([
970
+ ...policyRules.map((rule) => rule.route),
971
+ ...registeredMarkdown.filter((route) => hasContentSignal$1(routeRules[route].headers))
972
+ ]);
973
+ if (other + policyRoutes.size > limit)
974
+ return { _tag: "blocked", total: other + policyRoutes.size, limit };
975
+ return { _tag: "skip", drop: registeredMarkdown.filter((route) => !policyRoutes.has(route)), total, dropped: markdown.size - policyRoutes.size, rules: policyRules };
632
976
  }
633
977
  function applyStaticMarkdownHeaderPlan(routeRules, plan) {
978
+ if (plan._tag === "blocked")
979
+ throw new Error(`Content-Signal policy rules exceed the static header limit (${plan.total}/${plan.limit}). Reduce per-page policies or use a shared policy.`);
634
980
  if (plan._tag === "skip") {
635
981
  for (const route of plan.drop) {
636
982
  const rule = routeRules[route];
637
983
  if (rule)
638
984
  rule.headers = void 0;
639
985
  }
640
- return;
641
986
  }
642
987
  for (const { route, headers } of plan.rules) {
643
988
  const existing = routeRules[route];
644
989
  const merged = defu({ headers: { ...headers } }, existing);
990
+ if (hasContentSignal$1(existing?.headers)) {
991
+ for (const name of Object.keys(merged.headers)) {
992
+ if (name.toLowerCase() === "content-signal")
993
+ delete merged.headers[name];
994
+ }
995
+ for (const [name, value] of Object.entries(existing.headers)) {
996
+ if (name.toLowerCase() === "content-signal")
997
+ merged.headers[name] = value;
998
+ }
999
+ }
645
1000
  const vary = Object.entries(existing?.headers || {}).filter(([name]) => name.toLowerCase() === "vary").map(([, value]) => value).join(", ");
646
1001
  if (headers.Vary) {
647
1002
  for (const name of Object.keys(merged.headers)) {
@@ -1256,243 +1611,6 @@ function opaqueJsonModule(exports) {
1256
1611
  ].join("\n");
1257
1612
  }
1258
1613
 
1259
- function escapeRegExp(value) {
1260
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1261
- }
1262
- function ensureStaticHeader(contents, route, name, value, mode = "default") {
1263
- const eol = contents.includes("\r\n") ? "\r\n" : "\n";
1264
- const routePattern = new RegExp(`^${escapeRegExp(route)}[\\t ]*\\r?$`, "gm");
1265
- const routeMatches = [...contents.matchAll(routePattern)];
1266
- const routeMatch = routeMatches.at(-1);
1267
- if (!routeMatch || routeMatch.index === void 0) {
1268
- const separator = contents.length === 0 ? "" : contents.endsWith(`${eol}${eol}`) ? "" : contents.endsWith(eol) ? eol : `${eol}${eol}`;
1269
- return `${contents}${separator}${route}${eol} ${name}: ${value}${eol}`;
1270
- }
1271
- const routeLineEnd = routeMatch.index + routeMatch[0].length;
1272
- const blockStart = contents[routeLineEnd] === "\n" ? routeLineEnd + 1 : routeLineEnd;
1273
- const nextBlockPattern = /^(?![\t ]|\r?$).+/gm;
1274
- nextBlockPattern.lastIndex = blockStart;
1275
- const nextBlock = nextBlockPattern.exec(contents);
1276
- const blockEnd = nextBlock?.index ?? contents.length;
1277
- const block = contents.slice(blockStart, blockEnd);
1278
- const escapedName = escapeRegExp(name);
1279
- const existingHeaderPattern = new RegExp(
1280
- `^[\\t ]+(?:${escapedName}[\\t ]*:|![\\t ]*${escapedName}[\\t ]*\\r?$)`,
1281
- "im"
1282
- );
1283
- if (existingHeaderPattern.test(block)) {
1284
- if (mode === "append") {
1285
- const removalPattern = new RegExp(`^[\\t ]+![\\t ]*${escapedName}[\\t ]*\\r?$`, "im");
1286
- if (removalPattern.test(block))
1287
- return contents;
1288
- const valuePattern = new RegExp(`^([\\t ]+${escapedName}[\\t ]*:[\\t ]*)([^\\r\\n]*)(\\r?$)`, "im");
1289
- const match = block.match(valuePattern);
1290
- if (match) {
1291
- const merged = block.replace(valuePattern, (_line, prefix2, current, suffix) => {
1292
- const appended = name.toLowerCase() === "link" ? current.includes(value) ? current : [current, value].filter(Boolean).join(", ") : mergeVaryHeader(current, value);
1293
- return `${prefix2}${appended}${suffix}`;
1294
- });
1295
- return `${contents.slice(0, blockStart)}${merged}${contents.slice(blockEnd)}`;
1296
- }
1297
- }
1298
- return contents;
1299
- }
1300
- const prefix = blockStart === routeLineEnd ? eol : "";
1301
- return `${contents.slice(0, blockStart)}${prefix} ${name}: ${value}${eol}${contents.slice(blockStart)}`;
1302
- }
1303
- function mergeStaticDiscoveryHeaders(contents, baseURL, entries) {
1304
- if (entries.length === 0)
1305
- return contents;
1306
- const route = toDeployedRoute("/*", baseURL);
1307
- const blocks = splitHeaderBlocks(contents);
1308
- const matching = blocks.filter((block) => block.route === route);
1309
- let merged = matching[0]?.text || "";
1310
- for (const block of matching.slice(1)) {
1311
- for (const line of block.text.split(/(?<=\n)/).slice(1)) {
1312
- const header = line.match(/^[\t ]+([^:\s]+):[\t ]*([^\r\n]*)/);
1313
- if (header?.[1]?.toLowerCase() === "link") {
1314
- merged = ensureStaticHeader(merged, route, header[1], header[2], "append");
1315
- } else if (header) {
1316
- const existing = new RegExp(`^[\\t ]+${escapeRegExp(header[1])}[\\t ]*:[^\\r\\n]*(?:\\r?\\n|$)`, "gim");
1317
- merged = ensureStaticHeader(merged.replace(existing, ""), route, header[1], header[2]);
1318
- } else {
1319
- merged += line;
1320
- }
1321
- }
1322
- }
1323
- if (matching.length > 1) {
1324
- let inserted = false;
1325
- contents = blocks.map((block) => {
1326
- if (block.route !== route)
1327
- return block.text;
1328
- if (inserted)
1329
- return "";
1330
- inserted = true;
1331
- return merged;
1332
- }).join("");
1333
- }
1334
- return entries.reduce((headers, entry) => ensureStaticHeader(headers, route, "Link", entry, "append"), contents);
1335
- }
1336
- function parseHeaderLine(line) {
1337
- const colon = line.indexOf(":");
1338
- if (!/^[\t ]/.test(line) || colon < 0)
1339
- return void 0;
1340
- const name = line.slice(0, colon).trim();
1341
- if (!name || name.startsWith("!") || /\s/.test(name))
1342
- return void 0;
1343
- const eol = line.endsWith("\r\n") ? "\r\n" : line.endsWith("\n") ? "\n" : "";
1344
- return {
1345
- name,
1346
- value: line.slice(colon + 1, line.length - eol.length).trim(),
1347
- withValue: (value) => `${line.slice(0, colon + 1)} ${value}${eol}`
1348
- };
1349
- }
1350
- const RE_DETACH_LINE = /^[\t ]+![\t ]*([^\s:]+)/;
1351
- function patternToRegExp(pattern) {
1352
- const source = pattern.split(/(\*|:\w+)/).map((part) => part === "*" ? ".*" : part.startsWith(":") ? "[^/]+" : escapeRegExp(part)).join("");
1353
- return new RegExp(`^${source}$`);
1354
- }
1355
- function coversPattern(broad, narrow) {
1356
- if (broad === narrow || !broad.startsWith("/") || !narrow.startsWith("/"))
1357
- return false;
1358
- if (!/[*:]/.test(narrow))
1359
- return patternToRegExp(broad).test(narrow);
1360
- return broad.indexOf("*") === broad.length - 1 && !broad.includes(":") && narrow.startsWith(broad.slice(0, -1));
1361
- }
1362
- function splitLinkEntries(value) {
1363
- const entries = [];
1364
- let current = "";
1365
- let inUrl = false;
1366
- let inQuote = false;
1367
- for (const char of value) {
1368
- if (char === "<" && !inQuote)
1369
- inUrl = true;
1370
- else if (char === ">" && !inQuote)
1371
- inUrl = false;
1372
- else if (char === '"' && !inUrl)
1373
- inQuote = !inQuote;
1374
- if (char === "," && !inUrl && !inQuote) {
1375
- entries.push(current.trim());
1376
- current = "";
1377
- continue;
1378
- }
1379
- current += char;
1380
- }
1381
- entries.push(current.trim());
1382
- return entries.filter(Boolean);
1383
- }
1384
- function headerTokens(name, value) {
1385
- if (name === "link")
1386
- return splitLinkEntries(value);
1387
- if (name === "vary")
1388
- return value.split(",").map((token) => token.trim()).filter(Boolean);
1389
- return [value.trim()];
1390
- }
1391
- function tokenKey(name, token) {
1392
- return name === "vary" ? token.toLowerCase() : token;
1393
- }
1394
- function dedupeStaticHeaders(contents, names) {
1395
- const owned = new Set(names.map((name) => name.toLowerCase()));
1396
- const blocks = splitHeaderBlocks(contents);
1397
- const detached = /* @__PURE__ */ new Set();
1398
- const parsed = blocks.map((block) => {
1399
- const headers = [];
1400
- for (const line of block.text.split(/(?<=\n)/).slice(block.route === null ? 0 : 1)) {
1401
- const detach = line.match(RE_DETACH_LINE);
1402
- if (detach) {
1403
- detached.add(detach[1].toLowerCase());
1404
- continue;
1405
- }
1406
- const header = parseHeaderLine(line);
1407
- if (!header)
1408
- continue;
1409
- const name = header.name.toLowerCase();
1410
- if (owned.has(name))
1411
- headers.push({ name, tokens: headerTokens(name, header.value) });
1412
- }
1413
- return headers;
1414
- });
1415
- const lastRuleIndex = /* @__PURE__ */ new Map();
1416
- blocks.forEach((block, index) => {
1417
- if (block.route !== null)
1418
- lastRuleIndex.set(block.route, index);
1419
- });
1420
- let changed = false;
1421
- const output = blocks.map((block) => {
1422
- const route = block.route;
1423
- if (route === null)
1424
- return block.text;
1425
- const provided = /* @__PURE__ */ new Map();
1426
- lastRuleIndex.forEach((index, pattern) => {
1427
- if (!coversPattern(pattern, route))
1428
- return;
1429
- for (const { name, tokens } of parsed[index]) {
1430
- if (detached.has(name))
1431
- continue;
1432
- const values = provided.get(name) ?? /* @__PURE__ */ new Set();
1433
- tokens.forEach((token) => values.add(tokenKey(name, token)));
1434
- provided.set(name, values);
1435
- }
1436
- });
1437
- if (provided.size === 0)
1438
- return block.text;
1439
- const [routeLine, ...lines] = block.text.split(/(?<=\n)/);
1440
- let headerLines = 0;
1441
- const kept = lines.flatMap((line) => {
1442
- const header = parseHeaderLine(line);
1443
- if (!header) {
1444
- if (RE_DETACH_LINE.test(line))
1445
- headerLines++;
1446
- return [line];
1447
- }
1448
- headerLines++;
1449
- const name = header.name.toLowerCase();
1450
- const covered = provided.get(name);
1451
- if (!covered)
1452
- return [line];
1453
- const tokens = headerTokens(name, header.value);
1454
- const remaining = tokens.filter((token) => !covered.has(tokenKey(name, token)));
1455
- if (remaining.length === tokens.length)
1456
- return [line];
1457
- changed = true;
1458
- if (remaining.length > 0)
1459
- return [header.withValue(remaining.join(", "))];
1460
- headerLines--;
1461
- return [];
1462
- });
1463
- return headerLines === 0 ? kept.join("") : `${routeLine}${kept.join("")}`;
1464
- });
1465
- return changed ? output.join("") : contents;
1466
- }
1467
- const CLOUDFLARE_STATIC_HEADER_RULE_LIMIT = 100;
1468
- const RE_EXACT_MARKDOWN_ROUTE = /^\/[^*\s]*\.md[\t ]*\r?$/;
1469
- function splitHeaderBlocks(contents) {
1470
- const blocks = [];
1471
- let current = { route: null, text: "" };
1472
- for (const line of contents.split(/(?<=\n)/)) {
1473
- const isRoute = /^[^\s#]/.test(line);
1474
- if (isRoute) {
1475
- blocks.push(current);
1476
- current = { route: line.trimEnd(), text: "" };
1477
- }
1478
- current.text += line;
1479
- }
1480
- blocks.push(current);
1481
- return blocks;
1482
- }
1483
- function enforceStaticHeaderBudget(contents, limit) {
1484
- const blocks = splitHeaderBlocks(contents);
1485
- const ruleCount = blocks.filter((block) => block.route !== null).length;
1486
- if (ruleCount <= limit)
1487
- return { contents, total: ruleCount, dropped: 0 };
1488
- const kept = blocks.filter((block) => block.route === null || !RE_EXACT_MARKDOWN_ROUTE.test(block.route));
1489
- return {
1490
- contents: kept.map((block) => block.text).join(""),
1491
- total: kept.filter((block) => block.route !== null).length,
1492
- dropped: ruleCount - kept.filter((block) => block.route !== null).length
1493
- };
1494
- }
1495
-
1496
1614
  const DEFAULT_MAX_OUTPUT_CHARS = 1500;
1497
1615
  const DEFAULT_LIST_LIMIT = 20;
1498
1616
  const DEFAULT_SEARCH_LIMIT = 10;
@@ -2335,9 +2453,10 @@ export function trackDrizzleWork(event, work) { return work }
2335
2453
  handler: resolve$1("./runtime/server/middleware/markdown.prerender")
2336
2454
  });
2337
2455
  const htmlCapturePlugin = resolve$1("./runtime/server/plugins/html-capture.prerender");
2456
+ const policyCapturePlugin = resolve$1(getNitroVersion(nuxt) === 3 ? "./runtime/server/plugins/content-signal-capture-nitro3.prerender" : "./runtime/server/plugins/content-signal-capture.prerender");
2338
2457
  nuxt.hook("nitro:init", (nitro) => {
2339
2458
  nitro.hooks.hook("prerender:config", (prerenderConfig) => {
2340
- prerenderConfig.plugins = [...prerenderConfig.plugins || [], htmlCapturePlugin];
2459
+ prerenderConfig.plugins = [...prerenderConfig.plugins || [], htmlCapturePlugin, policyCapturePlugin];
2341
2460
  });
2342
2461
  });
2343
2462
  addPlugin({
@@ -2598,11 +2717,14 @@ export function trackDrizzleWork(event, work) { return work }
2598
2717
  discovery.push(`<${encodeURI(apiCatalogConfig.href)}>; rel="api-catalog"`);
2599
2718
  mergedHeaders = mergeStaticDiscoveryHeaders(mergedHeaders, staticBaseURL, discovery);
2600
2719
  mergedHeaders = dedupeStaticHeaders(mergedHeaders, ["Content-Type", "Link", "Vary"]);
2720
+ mergedHeaders = normalizeStaticContentSignalHeaders(mergedHeaders);
2601
2721
  const budget = enforceStaticHeaderBudget(mergedHeaders, CLOUDFLARE_STATIC_HEADER_RULE_LIMIT);
2602
2722
  if (budget.dropped > 0) {
2603
2723
  mergedHeaders = budget.contents;
2604
2724
  noteSharedMarkdownRule(budget.total + budget.dropped, budget.dropped);
2605
2725
  }
2726
+ if (budget.total > CLOUDFLARE_STATIC_HEADER_RULE_LIMIT && /^\s+(?:content-signal\s*:|!\s*content-signal\s*$)/im.test(budget.contents))
2727
+ throw new Error(`Content-Signal policy rules exceed Cloudflare's ${CLOUDFLARE_STATIC_HEADER_RULE_LIMIT}-rule limit. Reduce per-page policies or use a shared policy.`);
2606
2728
  if (budget.total > CLOUDFLARE_STATIC_HEADER_RULE_LIMIT)
2607
2729
  logger.warn(`_headers still has ${budget.total} rules after dropping the .md rules. Cloudflare will reject the deploy until the other rules fit under ${CLOUDFLARE_STATIC_HEADER_RULE_LIMIT}.`);
2608
2730
  }
@@ -2616,4 +2738,4 @@ export function trackDrizzleWork(event, work) { return work }
2616
2738
  }
2617
2739
  });
2618
2740
 
2619
- export { AGENT_SKILLS_SCHEMA as A, MARKDOWN_LINK_AVAILABILITY_FILE as M, logger as l, module$1 as m };
2741
+ export { AGENT_SKILLS_SCHEMA as A, MARKDOWN_LINK_AVAILABILITY_FILE as M, module$1 as a, logger as l, matchingStaticContentSignals as m };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ai-ready",
3
3
  "type": "module",
4
- "version": "3.3.1",
4
+ "version": "3.4.0",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",