nuxt-ai-ready 2.2.1 → 2.3.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.
@@ -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.B8hPhZL9.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({ name: result.entry.name, description: result.entry.description, href: alias ?? result.routes[0] });
261
+ } else {
262
+ links.push({ 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.B8hPhZL9.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.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 '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.B8hPhZL9.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';
@@ -864,7 +864,7 @@ function ensureStaticHeader(contents, route, name, value) {
864
864
  return `${contents.slice(0, blockStart)}${prefix} ${name}: ${value}${eol}${contents.slice(blockStart)}`;
865
865
  }
866
866
  const CLOUDFLARE_STATIC_HEADER_RULE_LIMIT = 100;
867
- const RE_EXACT_MARKDOWN_ROUTE = /^\/[^*\s]*\.md[\t ]*\r?$/;
867
+ const RE_EXACT_MARKDOWN_ROUTE$1 = /^\/[^*\s]*\.md[\t ]*\r?$/;
868
868
  function splitHeaderBlocks(contents) {
869
869
  const blocks = [];
870
870
  let current = { route: null, text: "" };
@@ -884,7 +884,7 @@ function enforceStaticHeaderBudget(contents, limit) {
884
884
  const ruleCount = blocks.filter((block) => block.route !== null).length;
885
885
  if (ruleCount <= limit)
886
886
  return { contents, total: ruleCount, dropped: 0 };
887
- const kept = blocks.filter((block) => block.route === null || !RE_EXACT_MARKDOWN_ROUTE.test(block.route));
887
+ const kept = blocks.filter((block) => block.route === null || !RE_EXACT_MARKDOWN_ROUTE$1.test(block.route));
888
888
  return {
889
889
  contents: kept.map((block) => block.text).join(""),
890
890
  total: kept.filter((block) => block.route !== null).length,
@@ -923,9 +923,13 @@ function pageRouteFromMarkdownTwin(fileName) {
923
923
  return "/";
924
924
  return isStaticMarkdownSourceRoute(pageRoute) ? pageRoute : null;
925
925
  }
926
- function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby) {
926
+ function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby, artifactRoutes) {
927
+ const artifacts = new Set(artifactRoutes);
927
928
  const rules = /* @__PURE__ */ new Map();
928
929
  for (const entry of prerenderedRoutes) {
930
+ if (entry.route !== void 0 && artifacts.has(entry.route) || entry.fileName !== void 0 && artifacts.has(entry.fileName)) {
931
+ continue;
932
+ }
929
933
  const pageRoute = pageRouteFromMarkdownTwin(entry.fileName);
930
934
  if (pageRoute === null)
931
935
  continue;
@@ -942,6 +946,34 @@ function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby)
942
946
  }
943
947
  return [...rules.values()];
944
948
  }
949
+ const RE_EXACT_MARKDOWN_ROUTE = /^\/[^*:\s]*\.md$/;
950
+ function isStaticHeaderRouteRule([, rule]) {
951
+ return Boolean(rule && "headers" in rule && rule.headers);
952
+ }
953
+ function planStaticMarkdownHeaderRules(routeRules, rules, limit) {
954
+ if (limit === null)
955
+ return { _tag: "apply", rules };
956
+ const entries = Object.entries(routeRules).filter(isStaticHeaderRouteRule);
957
+ const registeredMarkdown = entries.map(([route]) => route).filter((route) => RE_EXACT_MARKDOWN_ROUTE.test(route));
958
+ const other = entries.length - registeredMarkdown.length;
959
+ const markdown = /* @__PURE__ */ new Set([...registeredMarkdown, ...rules.map((rule) => rule.route)]);
960
+ const total = other + markdown.size;
961
+ if (total <= limit)
962
+ return { _tag: "apply", rules };
963
+ return { _tag: "skip", drop: registeredMarkdown, total, dropped: markdown.size };
964
+ }
965
+ function applyStaticMarkdownHeaderPlan(routeRules, plan) {
966
+ if (plan._tag === "skip") {
967
+ for (const route of plan.drop) {
968
+ const rule = routeRules[route];
969
+ if (rule)
970
+ rule.headers = void 0;
971
+ }
972
+ return;
973
+ }
974
+ for (const { route, headers } of plan.rules)
975
+ routeRules[route] = defu({ headers }, routeRules[route]);
976
+ }
945
977
 
946
978
  const DEFAULT_MAX_OUTPUT_CHARS = 1500;
947
979
  const DEFAULT_LIST_LIMIT = 20;
@@ -1111,11 +1143,35 @@ const module$1 = defineNuxtModule({
1111
1143
  logger.debug("Module is disabled, skipping setup.");
1112
1144
  return;
1113
1145
  }
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:
1146
+ const agentSkillsConfig = config.agentSkills === false ? false : config.agentSkills ?? {};
1147
+ const agentSkillsError = (issues) => {
1148
+ const details = issues.map((issue) => `${issue.index === void 0 ? "agentSkills" : `agentSkills.skills[${issue.index}]`}.${issue.field}: ${issue.message}`).join("\n");
1149
+ return new Error(`[nuxt-ai-ready] Invalid Agent Skills configuration:
1118
1150
  ${details}`);
1151
+ };
1152
+ let agentSkillsResult = { _tag: "Disabled" };
1153
+ if (agentSkillsConfig !== false) {
1154
+ const { applyRootAlias, prepareAgentSkills, resolveAgentSkillsConfig } = await import('../chunks/agent-skills.mjs');
1155
+ const prepared = await prepareAgentSkills(agentSkillsConfig, {
1156
+ rootDir: nuxt.options.rootDir,
1157
+ scanDirs: nuxt.options._layers.map((layer) => layer.config.rootDir ?? layer.cwd)
1158
+ });
1159
+ if (prepared._tag === "Invalid")
1160
+ throw agentSkillsError(prepared.issues);
1161
+ const payload = { skills: prepared.skills };
1162
+ await nuxt.callHook("ai-ready:agent-skills", payload);
1163
+ const rooted = applyRootAlias(payload.skills, agentSkillsConfig.root);
1164
+ if (rooted._tag === "Invalid")
1165
+ throw agentSkillsError(rooted.issues);
1166
+ agentSkillsResult = await resolveAgentSkillsConfig(
1167
+ { ...agentSkillsConfig, skills: rooted.skills },
1168
+ nuxt.options.rootDir,
1169
+ { sitemapMd: config.sitemapMd !== false }
1170
+ );
1171
+ if (agentSkillsResult._tag === "Invalid")
1172
+ throw agentSkillsError(agentSkillsResult.issues);
1173
+ if (agentSkillsResult._tag === "Enabled")
1174
+ logger.debug(`Publishing ${agentSkillsResult.index.skills.length} agent skill(s): ${agentSkillsResult.index.skills.map((skill) => skill.name).join(", ")}`);
1119
1175
  }
1120
1176
  const mcpServerCardResult = parseMcpServerCardConfig(config.mcpServerCard);
1121
1177
  if (mcpServerCardResult._tag === "Invalid")
@@ -1291,6 +1347,18 @@ ${details}`);
1291
1347
  }
1292
1348
  ]
1293
1349
  });
1350
+ if (agentSkillsResult._tag === "Enabled" && agentSkillsConfig !== false && agentSkillsConfig.llmsTxt !== false) {
1351
+ const indexUrl = withSiteUrl(AGENT_SKILLS_INDEX_ROUTE.slice(1), { withBase: true });
1352
+ defaultLlmsTxtSections.push({
1353
+ title: "Agent Skills",
1354
+ description: `Skills an agent can install from this site. The index at ${indexUrl} carries a sha256 digest for each one.`,
1355
+ links: agentSkillsResult.links.map((link) => ({
1356
+ title: link.name,
1357
+ href: link.href.startsWith("/") ? withSiteUrl(link.href.slice(1), { withBase: true }) : new URL(link.href, indexUrl).href,
1358
+ description: link.description
1359
+ }))
1360
+ });
1361
+ }
1294
1362
  const mergedLlmsTxt = config.llmsTxt ? {
1295
1363
  markdownLinks: config.llmsTxt.markdownLinks ?? false,
1296
1364
  sections: [
@@ -1658,7 +1726,7 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1658
1726
  mdreamOptions: config.mdreamOptions || {},
1659
1727
  sitemapMd: config.sitemapMd !== false,
1660
1728
  describedby: config.describedby !== false,
1661
- markdownCacheHeaders: defu(config.markdownCacheHeaders, {
1729
+ markdownCacheHeaders: defu$1(config.markdownCacheHeaders, {
1662
1730
  maxAge: 3600,
1663
1731
  swr: true
1664
1732
  }),
@@ -1814,14 +1882,24 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1814
1882
  });
1815
1883
  }
1816
1884
  nuxt.hooks.hook("nitro:build:before", (nitro) => {
1885
+ const isCloudflarePreset = String(nitro.options.preset || "").startsWith("cloudflare");
1817
1886
  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]);
1887
+ const plan = planStaticMarkdownHeaderRules(
1888
+ nitro.options.routeRules,
1889
+ prerenderedMarkdownHeaderRules(
1890
+ nitro._prerenderedRoutes || [],
1891
+ staticBaseURL,
1892
+ config.describedby !== false,
1893
+ agentSkillsResult._tag === "Enabled" ? Object.keys(agentSkillsResult.localArtifacts) : []
1894
+ ),
1895
+ isCloudflarePreset ? CLOUDFLARE_STATIC_HEADER_RULE_LIMIT : null
1896
+ );
1897
+ if (plan._tag === "skip") {
1898
+ applyStaticMarkdownHeaderPlan(nitro.options.routeRules, plan);
1899
+ 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.`);
1900
+ return;
1824
1901
  }
1902
+ applyStaticMarkdownHeaderPlan(nitro.options.routeRules, plan);
1825
1903
  });
1826
1904
  nitro.hooks.hook("compiled", async () => {
1827
1905
  const headersPath = join(nitro.options.output.publicDir, "_headers");
@@ -1843,7 +1921,7 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1843
1921
  staticDescribedbyEntry(nitro.options.baseURL || "/")
1844
1922
  );
1845
1923
  }
1846
- if (String(nitro.options.preset || "").startsWith("cloudflare")) {
1924
+ if (isCloudflarePreset) {
1847
1925
  const budget = enforceStaticHeaderBudget(mergedHeaders, CLOUDFLARE_STATIC_HEADER_RULE_LIMIT);
1848
1926
  if (budget.dropped > 0) {
1849
1927
  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.0",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",