nuxt-ai-ready 2.2.1 → 2.3.1

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.
@@ -1,8 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { realpath, readFile } from 'node:fs/promises';
3
- import { resolve, isAbsolute, relative, sep } from 'node:path';
2
+ import { realpath, readFile, readdir } from 'node:fs/promises';
3
+ import { resolve, isAbsolute, join, relative, sep } from 'node:path';
4
4
  import { parseDocument } from 'yaml';
5
- import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.BVWXwmUS.mjs';
5
+ import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.CWJ1a7_y.mjs';
6
6
  import 'node:module';
7
7
  import '@nuxt/kit';
8
8
  import 'defu';
@@ -23,9 +23,28 @@ const urlBase = "https://example.com/.well-known/agent-skills/index.json";
23
23
  function skillRoute(name) {
24
24
  return `/.well-known/agent-skills/${name}/SKILL.md`;
25
25
  }
26
+ const ROOT_SKILL_ALIAS = "/SKILL.md";
27
+ function aliasesOf(skill) {
28
+ return skill.alias === void 0 ? [] : Array.isArray(skill.alias) ? skill.alias : [skill.alias];
29
+ }
26
30
  function skillUrl(name) {
27
31
  return `${name}/SKILL.md`;
28
32
  }
33
+ function validateAlias(alias, index, sitemapMd) {
34
+ if (alias === void 0)
35
+ return [];
36
+ if (Array.isArray(alias))
37
+ return alias.flatMap((entry) => validateAlias(entry, index, sitemapMd));
38
+ if (typeof alias !== "string" || !/^\/(?:[^/?#\s]+\/)*[^/?#\s]+\.md$/.test(alias) || alias.split("/").includes(".."))
39
+ return [{ index, field: "alias", message: 'must be a path-absolute route ending in .md, such as "/SKILL.md"' }];
40
+ if (alias.startsWith("/.well-known/"))
41
+ return [{ index, field: "alias", message: "must not use the /.well-known/ prefix reserved for discovery routes" }];
42
+ if (alias === "/index.md")
43
+ return [{ index, field: "alias", message: 'must not use the module-owned markdown route "/index.md"' }];
44
+ if (sitemapMd && alias === "/sitemap.md")
45
+ return [{ index, field: "alias", message: 'must not use the module-owned markdown route "/sitemap.md"' }];
46
+ return [];
47
+ }
29
48
  function isRecord(value) {
30
49
  return typeof value === "object" && value !== null && !Array.isArray(value);
31
50
  }
@@ -51,7 +70,7 @@ function validateCommonFields(skill, index) {
51
70
  }
52
71
  return issues;
53
72
  }
54
- function validateSkill(skill, index) {
73
+ function validateSkill(skill, index, sitemapMd) {
55
74
  if (!isRecord(skill)) {
56
75
  return [{ index, field: "source", message: "must be a local or external skill entry" }];
57
76
  }
@@ -59,6 +78,7 @@ function validateSkill(skill, index) {
59
78
  if (skill.source === "local") {
60
79
  if (typeof skill.file !== "string" || skill.file.trim().length === 0)
61
80
  issues.push({ index, field: "file", message: "must be a non-empty path relative to the Nuxt root directory" });
81
+ issues.push(...validateAlias(skill.alias, index, sitemapMd));
62
82
  return issues;
63
83
  }
64
84
  if (skill.source === "external") {
@@ -83,33 +103,25 @@ function resolveExternalEntry(skill) {
83
103
  digest: skill.digest
84
104
  };
85
105
  }
86
- function parseLocalSkillMetadata(content, skill, index) {
106
+ function readSkillFrontmatter(content) {
87
107
  const match = content.match(/^\uFEFF?---[\t ]*\r?\n([\s\S]*?)\r?\n---[\t ]*(?:\r?\n|$)/);
88
- if (!match?.[1]) {
89
- return [{
90
- index,
91
- field: "file",
92
- message: "must contain YAML frontmatter with name and description fields"
93
- }];
94
- }
108
+ if (!match?.[1])
109
+ return { _tag: "Err", message: "must contain YAML frontmatter with name and description fields" };
95
110
  const document = parseDocument(match[1], { prettyErrors: false });
96
- if (document.errors.length > 0) {
97
- return [{
98
- index,
99
- field: "file",
100
- message: `contains invalid YAML frontmatter: ${document.errors[0]?.message || "unknown YAML error"}`
101
- }];
102
- }
111
+ if (document.errors.length > 0)
112
+ return { _tag: "Err", message: `contains invalid YAML frontmatter: ${document.errors[0]?.message || "unknown YAML error"}` };
103
113
  const metadata = document.toJS();
104
- if (!isRecord(metadata)) {
105
- return [{
106
- index,
107
- field: "file",
108
- message: "frontmatter must be a YAML mapping with name and description fields"
109
- }];
110
- }
114
+ if (!isRecord(metadata))
115
+ return { _tag: "Err", message: "frontmatter must be a YAML mapping with name and description fields" };
116
+ return { _tag: "Ok", metadata, hasBody: content.slice(match[0].length).trim().length > 0 };
117
+ }
118
+ function parseLocalSkillMetadata(content, skill, index) {
119
+ const frontmatter = readSkillFrontmatter(content);
120
+ if (frontmatter._tag === "Err")
121
+ return [{ index, field: "file", message: frontmatter.message }];
122
+ const { metadata } = frontmatter;
111
123
  const issues = [];
112
- if (!content.slice(match[0].length).trim()) {
124
+ if (!frontmatter.hasBody) {
113
125
  issues.push({
114
126
  index,
115
127
  field: "file",
@@ -174,7 +186,7 @@ async function resolveLocalEntry(skill, index, rootDir) {
174
186
  issues: metadataIssues
175
187
  };
176
188
  }
177
- const route = skillRoute(skill.name);
189
+ const routes = [skillRoute(skill.name), ...aliasesOf(skill)];
178
190
  const digest = `sha256:${createHash("sha256").update(content).digest("hex")}`;
179
191
  return {
180
192
  _tag: "Resolved",
@@ -185,7 +197,7 @@ async function resolveLocalEntry(skill, index, rootDir) {
185
197
  url: skillUrl(skill.name),
186
198
  digest
187
199
  },
188
- route,
200
+ routes,
189
201
  content: text
190
202
  };
191
203
  });
@@ -198,40 +210,57 @@ async function resolveLocalEntry(skill, index, rootDir) {
198
210
  }]
199
211
  }));
200
212
  }
201
- async function resolveAgentSkillsConfig(config, rootDir) {
213
+ async function resolveAgentSkillsConfig(config, rootDir, options = {}) {
202
214
  if (config === false || config === void 0)
203
215
  return { _tag: "Disabled" };
204
- if (!isRecord(config) || !Array.isArray(config.skills)) {
216
+ if (!isRecord(config) || config.skills !== void 0 && !Array.isArray(config.skills)) {
205
217
  return {
206
218
  _tag: "Invalid",
207
- issues: [{ field: "agentSkills", message: "must contain a skills array" }]
219
+ issues: [{ field: "agentSkills", message: "skills must be an array when set" }]
208
220
  };
209
221
  }
210
- const issues = config.skills.flatMap((skill, index) => validateSkill(skill, index));
222
+ const configuredSkills = config.skills ?? [];
223
+ if (configuredSkills.length === 0)
224
+ return { _tag: "Disabled" };
225
+ const sitemapMd = options.sitemapMd !== false;
226
+ const issues = configuredSkills.flatMap((skill, index) => validateSkill(skill, index, sitemapMd));
211
227
  const seenNames = /* @__PURE__ */ new Set();
212
- for (const [index, skill] of config.skills.entries()) {
228
+ const seenAliases = /* @__PURE__ */ new Set();
229
+ for (const [index, skill] of configuredSkills.entries()) {
213
230
  if (!isRecord(skill) || typeof skill.name !== "string" || seenNames.has(skill.name)) {
214
231
  if (isRecord(skill) && typeof skill.name === "string" && seenNames.has(skill.name))
215
232
  issues.push({ index, field: "name", message: `duplicates the skill name "${skill.name}"` });
216
233
  continue;
217
234
  }
218
235
  seenNames.add(skill.name);
236
+ for (const alias of aliasesOf(skill)) {
237
+ if (seenAliases.has(alias))
238
+ issues.push({ index, field: "alias", message: `duplicates the alias "${alias}"` });
239
+ seenAliases.add(alias);
240
+ }
219
241
  }
220
242
  if (issues.length > 0)
221
243
  return { _tag: "Invalid", issues };
222
- const skills = config.skills;
244
+ const skills = configuredSkills;
223
245
  const resolved = await Promise.all(skills.map((skill, index) => skill.source === "local" ? resolveLocalEntry(skill, index, rootDir) : Promise.resolve({ _tag: "Resolved", entry: resolveExternalEntry(skill) })));
224
246
  const fileIssues = resolved.flatMap((result) => result._tag === "Invalid" ? result.issues : []);
225
247
  if (fileIssues.length > 0)
226
248
  return { _tag: "Invalid", issues: fileIssues };
227
249
  const entries = [];
228
250
  const localArtifacts = {};
251
+ const links = [];
229
252
  for (const result of resolved) {
230
253
  if (result._tag !== "Resolved")
231
254
  continue;
232
255
  entries.push(result.entry);
233
- if ("route" in result)
234
- localArtifacts[result.route] = result.content;
256
+ if ("routes" in result) {
257
+ for (const route of result.routes)
258
+ localArtifacts[route] = result.content;
259
+ const [alias] = result.routes.slice(1).sort((a, b) => a.length - b.length);
260
+ links.push({ source: "local", name: result.entry.name, description: result.entry.description, href: alias ?? result.routes[0] });
261
+ } else {
262
+ links.push({ source: "external", name: result.entry.name, description: result.entry.description, href: result.entry.url });
263
+ }
235
264
  }
236
265
  return {
237
266
  _tag: "Enabled",
@@ -239,8 +268,88 @@ async function resolveAgentSkillsConfig(config, rootDir) {
239
268
  $schema: AGENT_SKILLS_SCHEMA,
240
269
  skills: entries
241
270
  },
242
- localArtifacts
271
+ localArtifacts,
272
+ links
273
+ };
274
+ }
275
+ function toPosix(path) {
276
+ return path.split(sep).join("/");
277
+ }
278
+ async function discoverAgentSkills(options) {
279
+ const dir = options.dir.replace(/^\.?\/+/, "").replace(/\/+$/, "");
280
+ const skills = [];
281
+ const issues = [];
282
+ const seen = /* @__PURE__ */ new Set();
283
+ for (const scanDir of options.scanDirs) {
284
+ const base = resolve(scanDir, dir);
285
+ if (!isWithinDirectory(options.rootDir, base) && base !== options.rootDir)
286
+ continue;
287
+ const entries = await readdir(base, { withFileTypes: true }).catch(() => {
288
+ return [];
289
+ });
290
+ for (const entry of entries.filter((entry2) => entry2.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
291
+ if (seen.has(entry.name))
292
+ continue;
293
+ const file = join(base, entry.name, "SKILL.md");
294
+ const content = await readFile(file, "utf8").catch(() => {
295
+ return null;
296
+ });
297
+ if (content === null)
298
+ continue;
299
+ const relativeFile = toPosix(relative(options.rootDir, file));
300
+ const frontmatter = readSkillFrontmatter(content);
301
+ if (frontmatter._tag === "Err") {
302
+ issues.push({ field: "file", message: `${relativeFile} ${frontmatter.message}` });
303
+ continue;
304
+ }
305
+ const { name, description } = frontmatter.metadata;
306
+ if (name !== entry.name) {
307
+ issues.push({ field: "name", message: `${relativeFile} frontmatter name "${String(name)}" must equal its directory name "${entry.name}"` });
308
+ continue;
309
+ }
310
+ if (typeof description !== "string" || description.length === 0) {
311
+ issues.push({ field: "description", message: `${relativeFile} frontmatter must contain a description` });
312
+ continue;
313
+ }
314
+ seen.add(entry.name);
315
+ skills.push({
316
+ source: "local",
317
+ name: entry.name,
318
+ description,
319
+ file: relativeFile,
320
+ alias: `/${dir}/${entry.name}/SKILL.md`
321
+ });
322
+ }
323
+ }
324
+ return { skills, issues };
325
+ }
326
+ function mergeAgentSkills(discovered, configured) {
327
+ const overridden = new Set(configured.map((skill) => skill.name));
328
+ return [...discovered.filter((skill) => !overridden.has(skill.name)), ...configured];
329
+ }
330
+ function applyRootAlias(skills, root) {
331
+ if (root === false)
332
+ return { _tag: "Ok", skills: [...skills] };
333
+ const locals = skills.filter((skill) => skill.source === "local");
334
+ const target = root === void 0 ? locals.length === 1 ? locals[0] : void 0 : locals.find((skill) => skill.name === root);
335
+ if (root !== void 0 && !target)
336
+ return { _tag: "Invalid", issues: [{ field: "root", message: `names no local skill: "${root}"` }] };
337
+ if (!target || aliasesOf(target).includes(ROOT_SKILL_ALIAS))
338
+ return { _tag: "Ok", skills: [...skills] };
339
+ return {
340
+ _tag: "Ok",
341
+ skills: skills.map((skill) => skill === target ? { ...skill, alias: [...aliasesOf(skill), ROOT_SKILL_ALIAS] } : skill)
243
342
  };
244
343
  }
344
+ async function prepareAgentSkills(config, options) {
345
+ if (config.dir !== void 0 && config.dir !== false && typeof config.dir !== "string")
346
+ return { _tag: "Invalid", issues: [{ field: "dir", message: "must be a string or false when set" }] };
347
+ if (config.skills !== void 0 && !Array.isArray(config.skills))
348
+ return { _tag: "Invalid", issues: [{ field: "agentSkills", message: "skills must be an array when set" }] };
349
+ const discovered = config.dir === false ? { skills: [], issues: [] } : await discoverAgentSkills({ rootDir: options.rootDir, scanDirs: options.scanDirs, dir: config.dir ?? "skills" });
350
+ if (discovered.issues.length > 0)
351
+ return { _tag: "Invalid", issues: discovered.issues };
352
+ return { _tag: "Ok", skills: mergeAgentSkills(discovered.skills, config.skills ?? []) };
353
+ }
245
354
 
246
- export { AGENT_SKILLS_SCHEMA, resolveAgentSkillsConfig };
355
+ export { AGENT_SKILLS_SCHEMA, ROOT_SKILL_ALIAS, applyRootAlias, discoverAgentSkills, mergeAgentSkills, prepareAgentSkills, readSkillFrontmatter, resolveAgentSkillsConfig };
@@ -5,7 +5,7 @@ import { colorize } from 'consola/utils';
5
5
  import { resolveLocaleFromRoute } from 'nuxtseo-shared/i18n-runtime';
6
6
  import { collectSitemap } from 'sitemapd/parse';
7
7
  import { withLeadingSlash, joinURL, withBase } from 'ufo';
8
- import { l as logger, s as supportsNativeNodeSqlite, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.BVWXwmUS.mjs';
8
+ import { l as logger, s as supportsNativeNodeSqlite, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.CWJ1a7_y.mjs';
9
9
  import { normalizePagePath, toMarkdownPath } from '../../dist/runtime/markdown-path.js';
10
10
  import { toDeployedRoute, toLogicalRoute } from '../../dist/runtime/route-path.js';
11
11
  import { initSchema, computeContentHash, insertPage, queryAllPages, exportDbDump } from '../../dist/runtime/server/db/shared.js';
package/dist/module.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _nuxt_schema from '@nuxt/schema';
2
- import { LlmsTxtConfig, ContentNegotiationPolicy, ModuleOptions } from '../dist/runtime/types.js';
2
+ import { LlmsTxtConfig, AgentSkillConfig, ContentNegotiationPolicy, ModuleOptions } from '../dist/runtime/types.js';
3
3
  export { AgentSkillConfig, AgentSkillsConfig, AgentSkillsIndex, AgentSkillsIndexEntry, ApiCatalogConfig, ApiCatalogEntry, ApiCatalogLinkTarget, ApiCatalogLinks, ExternalAgentSkillConfig, LocalAgentSkillConfig, McpServerCardConfig, ModuleOptions } from '../dist/runtime/types.js';
4
4
  import { RuntimeI18nConfig } from 'nuxtseo-shared/i18n-runtime';
5
5
  import { ResolvedWebMcpToolsConfig } from '../dist/runtime/site-tool-config.js';
@@ -73,11 +73,21 @@ interface ModuleHooks {
73
73
  sections: LlmsTxtConfig['sections'];
74
74
  notes: string[];
75
75
  }) => void | Promise<void>;
76
+ /**
77
+ * Hook called with every agent skill about to be published: the ones
78
+ * discovered under `agentSkills.dir` merged with `agentSkills.skills`.
79
+ * Mutate the array to add external entries, drop a skill, or change its
80
+ * aliases. The root alias is applied after this hook.
81
+ */
82
+ 'ai-ready:agent-skills': (payload: {
83
+ skills: AgentSkillConfig[];
84
+ }) => void | Promise<void>;
76
85
  }
77
86
  declare module '@nuxt/schema' {
78
87
  interface NuxtHooks {
79
88
  'ai-ready:page:markdown': ModuleHooks['ai-ready:page:markdown'];
80
89
  'ai-ready:llms-txt': ModuleHooks['ai-ready:llms-txt'];
90
+ 'ai-ready:agent-skills': ModuleHooks['ai-ready:agent-skills'];
81
91
  }
82
92
  }
83
93
  interface ModulePublicRuntimeConfig {
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": ">=4.0.0"
5
5
  },
6
6
  "configKey": "aiReady",
7
- "version": "2.2.1",
7
+ "version": "2.3.1",
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 'defu';
7
7
  import 'nuxt-site-config/kit';
8
8
  import 'nuxtseo-shared/kit';
9
9
  import 'pkg-types';
10
- export { m as default } from './shared/nuxt-ai-ready.BVWXwmUS.mjs';
10
+ export { m as default } from './shared/nuxt-ai-ready.CWJ1a7_y.mjs';
11
11
  import '../dist/runtime/markdown-path.js';
12
12
  import '../dist/runtime/server/utils/sitemap-md.js';
13
13
  import '../dist/runtime/server/utils/discovery-response.js';
@@ -9,6 +9,7 @@ import { fetchRawWithEvent } from "../utils/fetch.js";
9
9
  import { buildFrontmatter } from "../utils/frontmatter.js";
10
10
  import { extractKeywords } from "../utils/keywords.js";
11
11
  import { getMarkdownRenderInfo } from "../utils/markdown-request.js";
12
+ import { agentSkillArtifactPaths } from "../utils/negotiation-response.js";
12
13
  import { consumePrerenderedHtml } from "../utils/prerender-html.js";
13
14
  import { isSitemapMdRequest } from "../utils/sitemap-md.js";
14
15
  function extractHeadingsFromMarkdown(markdown) {
@@ -23,7 +24,7 @@ function extractHeadingsFromMarkdown(markdown) {
23
24
  return headings;
24
25
  }
25
26
  export default defineEventHandler(async (event) => {
26
- if (event.path.startsWith("/.well-known/"))
27
+ if (event.path.startsWith("/.well-known/") || agentSkillArtifactPaths().has(event.path))
27
28
  return;
28
29
  if (!import.meta.prerender) {
29
30
  return;
@@ -13,10 +13,15 @@ export interface NegotiationInput {
13
13
  request: MarkdownRequest;
14
14
  routeRule: NegotiationRouteRule;
15
15
  policy: ContentNegotiationPolicy;
16
+ /**
17
+ * Routes that already serve a Markdown file verbatim, such as an agent skill
18
+ * alias. They are answered by their own handler, never rendered.
19
+ */
20
+ artifactPaths?: ReadonlySet<string>;
16
21
  }
17
22
  export type NegotiationDecision = {
18
23
  _tag: 'skip';
19
- reason: 'well-known' | 'internal' | 'not-a-page' | 'deferred';
24
+ reason: 'well-known' | 'internal' | 'artifact' | 'not-a-page' | 'deferred';
20
25
  } | {
21
26
  _tag: 'not-acceptable';
22
27
  } | {
@@ -6,6 +6,8 @@ export function resolveNegotiationDecision(input) {
6
6
  const path = withoutQuery(request.path);
7
7
  if (path.startsWith("/.well-known/"))
8
8
  return { _tag: "skip", reason: "well-known" };
9
+ if (input.artifactPaths?.has(path))
10
+ return { _tag: "skip", reason: "artifact" };
9
11
  if (request.headers[INTERNAL_HEADER])
10
12
  return { _tag: "skip", reason: "internal" };
11
13
  const negotiation = resolveContentNegotiation({ policy: input.policy, routeRule: input.routeRule });
@@ -3,6 +3,8 @@ import type { ModulePublicRuntimeConfig } from '../../../module.js';
3
3
  import type { RuntimeRouteContext } from './i18n.js';
4
4
  import type { NegotiationDecision, NegotiationStage } from './negotiation-decision.js';
5
5
  import { sendRedirect } from '#nuxtseo/h3';
6
+ /** Routes an agent skill answers verbatim; negotiation must not render them. */
7
+ export declare function agentSkillArtifactPaths(): ReadonlySet<string>;
6
8
  export type LinkUrlResolver = (path: string) => string;
7
9
  export interface NegotiationContext {
8
10
  config: ModulePublicRuntimeConfig;
@@ -1,4 +1,5 @@
1
1
  import { createNitroRouteRuleMatcher } from "nuxtseo-shared/server";
2
+ import { localAgentSkillArtifacts } from "#ai-ready-virtual/agent-skills.mjs";
2
3
  import { appendHeader, createError, getRequestURL, getResponseHeader, sendRedirect, setHeader } from "#nuxtseo/h3";
3
4
  import { useRuntimeConfig } from "#nuxtseo/nitro";
4
5
  import { withSiteUrl } from "#site-config/server/composables/utils";
@@ -11,6 +12,11 @@ import { buildLinkHeader } from "./link-header.js";
11
12
  import { toMarkdownRequest } from "./markdown-request.js";
12
13
  import { resolveNegotiationDecision } from "./negotiation-decision.js";
13
14
  const APPLIED_KEY = "nuxt-ai-ready:negotiation-applied";
15
+ let artifactPaths;
16
+ export function agentSkillArtifactPaths() {
17
+ artifactPaths ??= new Set(Object.keys(localAgentSkillArtifacts));
18
+ return artifactPaths;
19
+ }
14
20
  export function setLinkHeader(event, ctx, variant) {
15
21
  setHeader(event, "link", buildLinkHeader(ctx.path, variant, ctx.config, ctx.resolveUrl, ctx.routeContext));
16
22
  }
@@ -74,7 +80,8 @@ export function decideNegotiation(event, stage) {
74
80
  stage,
75
81
  request: toMarkdownRequest(event),
76
82
  routeRule: getRouteRuleMatcher(runtimeConfig)(event.path),
77
- policy: config.contentNegotiation
83
+ policy: config.contentNegotiation,
84
+ artifactPaths: agentSkillArtifactPaths()
78
85
  });
79
86
  }
80
87
  export async function applyNegotiation(event, decision) {
@@ -46,6 +46,13 @@ export interface LocalAgentSkillConfig {
46
46
  description: string;
47
47
  /** SKILL.md path, resolved relative to the Nuxt root directory. */
48
48
  file: string;
49
+ /**
50
+ * Also serve the same bytes at these path-absolute `.md` routes, such as
51
+ * `/SKILL.md`. Content negotiation leaves the routes alone, so the response
52
+ * is the file itself with no generated frontmatter. The discovery index
53
+ * keeps advertising the `.well-known` artifact URL.
54
+ */
55
+ alias?: string | string[];
49
56
  }
50
57
  export interface ExternalAgentSkillConfig {
51
58
  /** Advertise an artifact already hosted at a URL. */
@@ -63,7 +70,29 @@ export interface ExternalAgentSkillConfig {
63
70
  }
64
71
  export type AgentSkillConfig = LocalAgentSkillConfig | ExternalAgentSkillConfig;
65
72
  export interface AgentSkillsConfig {
66
- skills: AgentSkillConfig[];
73
+ /**
74
+ * Explicit entries. Discovered skills are merged in first; an explicit entry
75
+ * with the same name replaces the discovered one.
76
+ */
77
+ skills?: AgentSkillConfig[];
78
+ /**
79
+ * Directory scanned for `<name>/SKILL.md` in the project root and in every
80
+ * layer inside it. Each match is published as a local skill and also served
81
+ * at `/<dir>/<name>/SKILL.md`, mirroring the repository path. `false` turns
82
+ * discovery off.
83
+ * @default 'skills'
84
+ */
85
+ dir?: string | false;
86
+ /**
87
+ * Name of the local skill also served at `/SKILL.md`. Defaults to the only
88
+ * local skill when exactly one exists. `false` publishes no root alias.
89
+ */
90
+ root?: string | false;
91
+ /**
92
+ * Add an "Agent Skills" section to llms.txt listing every published skill.
93
+ * @default true
94
+ */
95
+ llmsTxt?: boolean;
67
96
  }
68
97
  export interface AgentSkillsIndexEntry {
69
98
  name: string;
@@ -231,9 +260,11 @@ export interface ModuleOptions {
231
260
  exposedTo?: string[];
232
261
  };
233
262
  /**
234
- * Publish an Agent Skills Discovery v0.2.0 index and optionally host local
235
- * SKILL.md artifacts under `/.well-known/agent-skills/`.
236
- * @default false
263
+ * Publish an Agent Skills Discovery v0.2.0 index and host local SKILL.md
264
+ * artifacts under `/.well-known/agent-skills/`. Skills in `skills/<name>/SKILL.md`
265
+ * are discovered and published without configuration; nothing is published
266
+ * when none exist. Set `false` to turn the feature off.
267
+ * @default {}
237
268
  */
238
269
  agentSkills?: false | AgentSkillsConfig;
239
270
  /**
@@ -3,7 +3,7 @@ import { readFile, mkdir, writeFile, access } from 'node:fs/promises';
3
3
  import { createRequire } from 'node:module';
4
4
  import { dirname, basename, join } from 'node:path';
5
5
  import { hasNuxtModule, useNuxt, useLogger, addTypeTemplate, addTemplate, defineNuxtModule, createResolver, addServerImports, addServerPlugin, addServerHandler, extendRouteRules, addPlugin, addImports } from '@nuxt/kit';
6
- import defu from 'defu';
6
+ import defu$1, { defu } from 'defu';
7
7
  import { installNuxtSiteConfig, useSiteConfig, withSiteUrl } from 'nuxt-site-config/kit';
8
8
  import { resolveNuxtContentVersion, renderNitroTypeAugmentations, setupNitroRuntimeCompatibility } from 'nuxtseo-shared/kit';
9
9
  import { readPackageJSON, resolvePackageJSON } from 'pkg-types';
@@ -278,6 +278,20 @@ declare module '#ai-ready-virtual/agent-skills.mjs' {
278
278
  const AGENT_SKILLS_SCHEMA = "https://schemas.agentskills.io/discovery/0.2.0/schema.json";
279
279
  const AGENT_SKILLS_INDEX_ROUTE = "/.well-known/agent-skills/index.json";
280
280
  const AGENT_SKILLS_CACHE_CONTROL = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
281
+ function resolveExternalSkillUrl(href, indexUrl) {
282
+ const normalized = href.replaceAll(/[\t\n\r]/g, "");
283
+ if (URL.canParse(normalized))
284
+ return new URL(normalized).href;
285
+ if (normalized.startsWith("//")) {
286
+ const resolved2 = new URL(normalized, "https://nuxt-ai-ready.invalid");
287
+ const auth = resolved2.username || resolved2.password ? `${resolved2.username}${resolved2.password ? `:${resolved2.password}` : ""}@` : "";
288
+ return `//${auth}${resolved2.host}${resolved2.pathname}${resolved2.search}${resolved2.hash}`;
289
+ }
290
+ if (URL.canParse(indexUrl))
291
+ return new URL(normalized, indexUrl).href;
292
+ const resolved = new URL(normalized, new URL(indexUrl, "https://nuxt-ai-ready.invalid"));
293
+ return `${resolved.pathname}${resolved.search}${resolved.hash}`;
294
+ }
281
295
 
282
296
  const AI_CATALOG_PATH = "/.well-known/ai-catalog.json";
283
297
  function resolveAiCatalog(input) {
@@ -864,7 +878,7 @@ function ensureStaticHeader(contents, route, name, value) {
864
878
  return `${contents.slice(0, blockStart)}${prefix} ${name}: ${value}${eol}${contents.slice(blockStart)}`;
865
879
  }
866
880
  const CLOUDFLARE_STATIC_HEADER_RULE_LIMIT = 100;
867
- const RE_EXACT_MARKDOWN_ROUTE = /^\/[^*\s]*\.md[\t ]*\r?$/;
881
+ const RE_EXACT_MARKDOWN_ROUTE$1 = /^\/[^*\s]*\.md[\t ]*\r?$/;
868
882
  function splitHeaderBlocks(contents) {
869
883
  const blocks = [];
870
884
  let current = { route: null, text: "" };
@@ -884,7 +898,7 @@ function enforceStaticHeaderBudget(contents, limit) {
884
898
  const ruleCount = blocks.filter((block) => block.route !== null).length;
885
899
  if (ruleCount <= limit)
886
900
  return { contents, total: ruleCount, dropped: 0 };
887
- const kept = blocks.filter((block) => block.route === null || !RE_EXACT_MARKDOWN_ROUTE.test(block.route));
901
+ const kept = blocks.filter((block) => block.route === null || !RE_EXACT_MARKDOWN_ROUTE$1.test(block.route));
888
902
  return {
889
903
  contents: kept.map((block) => block.text).join(""),
890
904
  total: kept.filter((block) => block.route !== null).length,
@@ -923,9 +937,13 @@ function pageRouteFromMarkdownTwin(fileName) {
923
937
  return "/";
924
938
  return isStaticMarkdownSourceRoute(pageRoute) ? pageRoute : null;
925
939
  }
926
- function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby) {
940
+ function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby, artifactRoutes) {
941
+ const artifacts = new Set(artifactRoutes);
927
942
  const rules = /* @__PURE__ */ new Map();
928
943
  for (const entry of prerenderedRoutes) {
944
+ if (entry.route !== void 0 && artifacts.has(entry.route) || entry.fileName !== void 0 && artifacts.has(entry.fileName)) {
945
+ continue;
946
+ }
929
947
  const pageRoute = pageRouteFromMarkdownTwin(entry.fileName);
930
948
  if (pageRoute === null)
931
949
  continue;
@@ -942,6 +960,34 @@ function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby)
942
960
  }
943
961
  return [...rules.values()];
944
962
  }
963
+ const RE_EXACT_MARKDOWN_ROUTE = /^\/[^*:\s]*\.md$/;
964
+ function isStaticHeaderRouteRule([, rule]) {
965
+ return Boolean(rule && "headers" in rule && rule.headers);
966
+ }
967
+ function planStaticMarkdownHeaderRules(routeRules, rules, limit) {
968
+ if (limit === null)
969
+ return { _tag: "apply", rules };
970
+ const entries = Object.entries(routeRules).filter(isStaticHeaderRouteRule);
971
+ const registeredMarkdown = entries.map(([route]) => route).filter((route) => RE_EXACT_MARKDOWN_ROUTE.test(route));
972
+ const other = entries.length - registeredMarkdown.length;
973
+ const markdown = /* @__PURE__ */ new Set([...registeredMarkdown, ...rules.map((rule) => rule.route)]);
974
+ const total = other + markdown.size;
975
+ if (total <= limit)
976
+ return { _tag: "apply", rules };
977
+ return { _tag: "skip", drop: registeredMarkdown, total, dropped: markdown.size };
978
+ }
979
+ function applyStaticMarkdownHeaderPlan(routeRules, plan) {
980
+ if (plan._tag === "skip") {
981
+ for (const route of plan.drop) {
982
+ const rule = routeRules[route];
983
+ if (rule)
984
+ rule.headers = void 0;
985
+ }
986
+ return;
987
+ }
988
+ for (const { route, headers } of plan.rules)
989
+ routeRules[route] = defu({ headers }, routeRules[route]);
990
+ }
945
991
 
946
992
  const DEFAULT_MAX_OUTPUT_CHARS = 1500;
947
993
  const DEFAULT_LIST_LIMIT = 20;
@@ -1111,11 +1157,35 @@ const module$1 = defineNuxtModule({
1111
1157
  logger.debug("Module is disabled, skipping setup.");
1112
1158
  return;
1113
1159
  }
1114
- const agentSkillsResult = config.agentSkills === false || config.agentSkills === void 0 ? { _tag: "Disabled" } : await import('../chunks/agent-skills.mjs').then(({ resolveAgentSkillsConfig }) => resolveAgentSkillsConfig(config.agentSkills, nuxt.options.rootDir));
1115
- if (agentSkillsResult._tag === "Invalid") {
1116
- const details = agentSkillsResult.issues.map((issue) => `${issue.index === void 0 ? "agentSkills" : `agentSkills.skills[${issue.index}]`}.${issue.field}: ${issue.message}`).join("\n");
1117
- throw new Error(`[nuxt-ai-ready] Invalid Agent Skills configuration:
1160
+ const agentSkillsConfig = config.agentSkills === false ? false : config.agentSkills ?? {};
1161
+ const agentSkillsError = (issues) => {
1162
+ const details = issues.map((issue) => `${issue.index === void 0 ? "agentSkills" : `agentSkills.skills[${issue.index}]`}.${issue.field}: ${issue.message}`).join("\n");
1163
+ return new Error(`[nuxt-ai-ready] Invalid Agent Skills configuration:
1118
1164
  ${details}`);
1165
+ };
1166
+ let agentSkillsResult = { _tag: "Disabled" };
1167
+ if (agentSkillsConfig !== false) {
1168
+ const { applyRootAlias, prepareAgentSkills, resolveAgentSkillsConfig } = await import('../chunks/agent-skills.mjs');
1169
+ const prepared = await prepareAgentSkills(agentSkillsConfig, {
1170
+ rootDir: nuxt.options.rootDir,
1171
+ scanDirs: nuxt.options._layers.map((layer) => layer.config.rootDir ?? layer.cwd)
1172
+ });
1173
+ if (prepared._tag === "Invalid")
1174
+ throw agentSkillsError(prepared.issues);
1175
+ const payload = { skills: prepared.skills };
1176
+ await nuxt.callHook("ai-ready:agent-skills", payload);
1177
+ const rooted = applyRootAlias(payload.skills, agentSkillsConfig.root);
1178
+ if (rooted._tag === "Invalid")
1179
+ throw agentSkillsError(rooted.issues);
1180
+ agentSkillsResult = await resolveAgentSkillsConfig(
1181
+ { ...agentSkillsConfig, skills: rooted.skills },
1182
+ nuxt.options.rootDir,
1183
+ { sitemapMd: config.sitemapMd !== false }
1184
+ );
1185
+ if (agentSkillsResult._tag === "Invalid")
1186
+ throw agentSkillsError(agentSkillsResult.issues);
1187
+ if (agentSkillsResult._tag === "Enabled")
1188
+ logger.debug(`Publishing ${agentSkillsResult.index.skills.length} agent skill(s): ${agentSkillsResult.index.skills.map((skill) => skill.name).join(", ")}`);
1119
1189
  }
1120
1190
  const mcpServerCardResult = parseMcpServerCardConfig(config.mcpServerCard);
1121
1191
  if (mcpServerCardResult._tag === "Invalid")
@@ -1291,6 +1361,18 @@ ${details}`);
1291
1361
  }
1292
1362
  ]
1293
1363
  });
1364
+ if (agentSkillsResult._tag === "Enabled" && agentSkillsConfig !== false && agentSkillsConfig.llmsTxt !== false) {
1365
+ const indexUrl = withSiteUrl(AGENT_SKILLS_INDEX_ROUTE.slice(1), { withBase: true });
1366
+ defaultLlmsTxtSections.push({
1367
+ title: "Agent Skills",
1368
+ description: `Skills an agent can install from this site. The index at ${indexUrl} carries a sha256 digest for each one.`,
1369
+ links: agentSkillsResult.links.map((link) => ({
1370
+ title: link.name,
1371
+ href: link.source === "local" ? withSiteUrl(link.href.slice(1), { withBase: true }) : resolveExternalSkillUrl(link.href, indexUrl),
1372
+ description: link.description
1373
+ }))
1374
+ });
1375
+ }
1294
1376
  const mergedLlmsTxt = config.llmsTxt ? {
1295
1377
  markdownLinks: config.llmsTxt.markdownLinks ?? false,
1296
1378
  sections: [
@@ -1658,7 +1740,7 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1658
1740
  mdreamOptions: config.mdreamOptions || {},
1659
1741
  sitemapMd: config.sitemapMd !== false,
1660
1742
  describedby: config.describedby !== false,
1661
- markdownCacheHeaders: defu(config.markdownCacheHeaders, {
1743
+ markdownCacheHeaders: defu$1(config.markdownCacheHeaders, {
1662
1744
  maxAge: 3600,
1663
1745
  swr: true
1664
1746
  }),
@@ -1814,14 +1896,24 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1814
1896
  });
1815
1897
  }
1816
1898
  nuxt.hooks.hook("nitro:build:before", (nitro) => {
1899
+ const isCloudflarePreset = String(nitro.options.preset || "").startsWith("cloudflare");
1817
1900
  nitro.hooks.hook("prerender:done", () => {
1818
- for (const { route, headers } of prerenderedMarkdownHeaderRules(
1819
- nitro._prerenderedRoutes || [],
1820
- staticBaseURL,
1821
- config.describedby !== false
1822
- )) {
1823
- nitro.options.routeRules[route] = defu({ headers }, nitro.options.routeRules[route]);
1901
+ const plan = planStaticMarkdownHeaderRules(
1902
+ nitro.options.routeRules,
1903
+ prerenderedMarkdownHeaderRules(
1904
+ nitro._prerenderedRoutes || [],
1905
+ staticBaseURL,
1906
+ config.describedby !== false,
1907
+ agentSkillsResult._tag === "Enabled" ? Object.keys(agentSkillsResult.localArtifacts) : []
1908
+ ),
1909
+ isCloudflarePreset ? CLOUDFLARE_STATIC_HEADER_RULE_LIMIT : null
1910
+ );
1911
+ if (plan._tag === "skip") {
1912
+ applyStaticMarkdownHeaderPlan(nitro.options.routeRules, plan);
1913
+ logger.warn(`_headers would hold ${plan.total} rules and Cloudflare allows ${CLOUDFLARE_STATIC_HEADER_RULE_LIMIT}. Skipped the ${plan.dropped} per-page .md rules; the /*.md glob still sets the charset and describedby entries, but static markdown does not send a per-page canonical Link.`);
1914
+ return;
1824
1915
  }
1916
+ applyStaticMarkdownHeaderPlan(nitro.options.routeRules, plan);
1825
1917
  });
1826
1918
  nitro.hooks.hook("compiled", async () => {
1827
1919
  const headersPath = join(nitro.options.output.publicDir, "_headers");
@@ -1843,7 +1935,7 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1843
1935
  staticDescribedbyEntry(nitro.options.baseURL || "/")
1844
1936
  );
1845
1937
  }
1846
- if (String(nitro.options.preset || "").startsWith("cloudflare")) {
1938
+ if (isCloudflarePreset) {
1847
1939
  const budget = enforceStaticHeaderBudget(mergedHeaders, CLOUDFLARE_STATIC_HEADER_RULE_LIMIT);
1848
1940
  if (budget.dropped > 0) {
1849
1941
  mergedHeaders = budget.contents;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ai-ready",
3
3
  "type": "module",
4
- "version": "2.2.1",
4
+ "version": "2.3.1",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",