@bdocs/plugin-llms-text 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Boltdocs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,46 @@
1
+ import { BoltdocsPlugin } from "boltdocs";
2
+ import { z } from "zod";
3
+
4
+ //#region src/node/schema.d.ts
5
+ /**
6
+ * Controls which routes are included in the llms.txt.
7
+ */
8
+ declare const LlmsTextPluginOptionsSchema: z.ZodObject<{
9
+ title: z.ZodOptional<z.ZodString>;
10
+ description: z.ZodOptional<z.ZodString>;
11
+ bodyText: z.ZodOptional<z.ZodString>;
12
+ includePaths: z.ZodOptional<z.ZodArray<z.ZodString>>;
13
+ excludePaths: z.ZodOptional<z.ZodArray<z.ZodString>>;
14
+ locales: z.ZodOptional<z.ZodArray<z.ZodString>>;
15
+ sections: z.ZodOptional<z.ZodArray<z.ZodObject<{
16
+ title: z.ZodString;
17
+ pathPrefix: z.ZodString;
18
+ description: z.ZodOptional<z.ZodString>;
19
+ maxLinks: z.ZodOptional<z.ZodNumber>;
20
+ optional: z.ZodDefault<z.ZodBoolean>;
21
+ }, z.core.$strip>>>;
22
+ sortBy: z.ZodDefault<z.ZodEnum<{
23
+ title: "title";
24
+ path: "path";
25
+ sidebarPosition: "sidebarPosition";
26
+ }>>;
27
+ maxLinksPerSection: z.ZodOptional<z.ZodNumber>;
28
+ includeDrafts: z.ZodDefault<z.ZodBoolean>;
29
+ devMode: z.ZodDefault<z.ZodBoolean>;
30
+ addLinkTag: z.ZodDefault<z.ZodBoolean>;
31
+ baseUrl: z.ZodOptional<z.ZodString>;
32
+ }, z.core.$strip>;
33
+ type LlmsTextPluginOptions = z.input<typeof LlmsTextPluginOptionsSchema>;
34
+ //#endregion
35
+ //#region src/node/index.d.ts
36
+ /**
37
+ * @bdocs/plugin-llms-text — Generate an `llms.txt` file at build time.
38
+ *
39
+ * The llms.txt specification (llmstxt.org) provides a standardised
40
+ * plain-text index of documentation pages optimised for Large Language
41
+ * Models and AI agents. The file is emitted into the resolved build output
42
+ * directory and is therefore served at `<siteUrl>/llms.txt`.
43
+ */
44
+ declare function llmsTextPlugin(rawOptions?: LlmsTextPluginOptions): BoltdocsPlugin;
45
+ //#endregion
46
+ export { type LlmsTextPluginOptions, llmsTextPlugin as default };
@@ -0,0 +1,359 @@
1
+ import { createPlugin } from "boltdocs";
2
+ import { z } from "zod";
3
+ import path from "node:path";
4
+ import fs from "node:fs";
5
+ //#region src/node/schema.ts
6
+ /**
7
+ * Configuration for a custom section in the llms.txt file.
8
+ * Each section maps to an H2 heading with a list of curated links.
9
+ */
10
+ const LlmsTextSectionSchema = z.object({
11
+ /** The H2 heading label for this section (e.g. 'Getting Started'). */
12
+ title: z.string().min(1).max(100),
13
+ /** Path prefix filter — only routes starting with this path are included. */
14
+ pathPrefix: z.string().min(1).max(200),
15
+ /** Optional description paragraph placed right after the H2 heading. */
16
+ description: z.string().max(500).optional(),
17
+ /** Maximum links in this section. Default: no limit. */
18
+ maxLinks: z.number().int().positive().max(500).optional(),
19
+ /** Whether this section should appear under the '## Optional' umbrella. */
20
+ optional: z.boolean().default(false)
21
+ });
22
+ /**
23
+ * Controls link sorting within each section.
24
+ */
25
+ const SortBySchema = z.enum([
26
+ "path",
27
+ "title",
28
+ "sidebarPosition"
29
+ ]).default("sidebarPosition");
30
+ /**
31
+ * Controls which routes are included in the llms.txt.
32
+ */
33
+ const LlmsTextPluginOptionsSchema = z.object({
34
+ /**
35
+ * Project title used as the H1 heading.
36
+ * Defaults to the site title from boltdocs config.
37
+ */
38
+ title: z.string().min(1).max(200).optional(),
39
+ /**
40
+ * Project description used as the blockquote summary.
41
+ * Defaults to the site description from boltdocs config.
42
+ */
43
+ description: z.string().min(1).max(1e3).optional(),
44
+ /**
45
+ * Additional markdown body text inserted after the blockquote
46
+ * and before any H2 sections. Use this for LLM-specific instructions,
47
+ * common patterns, or high-level architecture notes.
48
+ */
49
+ bodyText: z.string().max(2e3).optional(),
50
+ /**
51
+ * If set, only routes matching at least one of these path prefixes
52
+ * are included. Example: ['/docs', '/blog']. Default: all routes.
53
+ */
54
+ includePaths: z.array(z.string()).optional(),
55
+ /**
56
+ * Routes matching any of these path prefixes are excluded.
57
+ * Applied AFTER includePaths. Example: ['/docs/api/experimental'].
58
+ */
59
+ excludePaths: z.array(z.string()).optional(),
60
+ /**
61
+ * Restrict links to the selected locale codes. The default locale is
62
+ * matched automatically even when its RouteMeta.locale is undefined.
63
+ * Example: ['en', 'es']. When omitted, all locales are included.
64
+ */
65
+ locales: z.array(z.string().trim().min(1)).optional(),
66
+ /**
67
+ * Custom H2 sections that group links by path prefix.
68
+ * When provided, the default "Documentation" section is replaced.
69
+ * Default: a single "Documentation" section with all routes.
70
+ */
71
+ sections: z.array(LlmsTextSectionSchema).optional(),
72
+ /**
73
+ * How to sort links within each section.
74
+ */
75
+ sortBy: SortBySchema,
76
+ /**
77
+ * Maximum number of links per section. Default: no limit.
78
+ */
79
+ maxLinksPerSection: z.number().int().positive().max(500).optional(),
80
+ /**
81
+ * Whether to include draft routes. Default: false.
82
+ */
83
+ includeDrafts: z.boolean().default(false),
84
+ /**
85
+ * @deprecated The afterBuild hook is always registered now; generation
86
+ * happens on every production build. This option is kept for backwards
87
+ * compatibility and no longer has any effect.
88
+ */
89
+ devMode: z.boolean().default(false),
90
+ /**
91
+ * Whether to inject a `<link rel="llms-txt">` tag into the HTML `<head>`.
92
+ * Default: true.
93
+ */
94
+ addLinkTag: z.boolean().default(true),
95
+ /**
96
+ * URL base for generating absolute links in llms.txt.
97
+ * Falls back to `siteUrl` from the boltdocs config.
98
+ */
99
+ baseUrl: z.string().url().optional()
100
+ });
101
+ //#endregion
102
+ //#region src/node/generator.ts
103
+ /**
104
+ * Sort routes within a section according to the configured sort order.
105
+ */
106
+ function sortRoutes(routes, sortBy) {
107
+ const sorted = [...routes];
108
+ switch (sortBy) {
109
+ case "path":
110
+ sorted.sort((a, b) => a.path.localeCompare(b.path));
111
+ break;
112
+ case "title":
113
+ sorted.sort((a, b) => a.title.localeCompare(b.title));
114
+ break;
115
+ case "sidebarPosition":
116
+ sorted.sort((a, b) => {
117
+ const aPos = a.sidebarPosition ?? 999;
118
+ const bPos = b.sidebarPosition ?? 999;
119
+ if (aPos !== bPos) return aPos - bPos;
120
+ return a.title.localeCompare(b.title);
121
+ });
122
+ break;
123
+ }
124
+ return sorted;
125
+ }
126
+ /**
127
+ * Format a single link line following the llms.txt spec:
128
+ * `- [Page Title](https://site.com/docs/page): Brief description`
129
+ */
130
+ function formatLink(route, siteUrl) {
131
+ const url = `${siteUrl.replace(/\/+$/, "")}${route.path}`;
132
+ const description = route.excerpt ?? route.description ?? "";
133
+ const label = description ? `: ${description.replace(/\n/g, " ").replace(/\s+/g, " ").trim()}` : "";
134
+ return `- [${escapeLinkText(route.title)}](${url})${label}`;
135
+ }
136
+ /**
137
+ * Escape special characters in plain-text Markdown link text.
138
+ * Square brackets would break the `[text]` syntax, and parentheses
139
+ * would break the `(url)` syntax.
140
+ */
141
+ function escapeLinkText(text) {
142
+ return text.replace(/\[/g, "\\[").replace(/\]/g, "\\]").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
143
+ }
144
+ /**
145
+ * Check a single route against include/exclude path filters.
146
+ * Returns `true` when the route passes all filters.
147
+ */
148
+ function routePassesPathFilter(route, config) {
149
+ if (config.locales && config.locales.length > 0) {
150
+ const routeLocale = route.locale ?? config.defaultLocale;
151
+ if (routeLocale && !config.locales.includes(routeLocale)) return false;
152
+ }
153
+ if (config.includePaths && config.includePaths.length > 0) {
154
+ if (!config.includePaths.some((p) => route.path.startsWith(p))) return false;
155
+ }
156
+ if (config.excludePaths && config.excludePaths.length > 0) {
157
+ if (config.excludePaths.some((p) => route.path.startsWith(p))) return false;
158
+ }
159
+ return true;
160
+ }
161
+ /**
162
+ * Group routes into a single section based on path prefix matching.
163
+ */
164
+ function routesForSection(routes, section, config, skipSort = false) {
165
+ const filtered = routes.filter((r) => {
166
+ if (!config.includeDrafts && r.draft) return false;
167
+ if (!routePassesPathFilter(r, config)) return false;
168
+ return r.path.startsWith(section.pathPrefix);
169
+ });
170
+ const result = skipSort ? filtered : sortRoutes(filtered, config.sortBy);
171
+ const maxLinks = section.maxLinks ?? config.maxLinksPerSection;
172
+ if (maxLinks && result.length > maxLinks) return result.slice(0, maxLinks);
173
+ return result;
174
+ }
175
+ /**
176
+ * Generate the full llms.txt Markdown content.
177
+ *
178
+ * Follows the llms.txt specification:
179
+ * 1. H1 - Project title
180
+ * 2. Blockquote - Summary/short description
181
+ * 3. Optional body text
182
+ * 4. H2 sections with curated links
183
+ * 5. Optional sections under `## Optional`
184
+ */
185
+ function generateLlmsText(routes, config) {
186
+ const lines = [];
187
+ lines.push(`# ${config.title}`);
188
+ lines.push("");
189
+ lines.push(`> ${config.description}`);
190
+ lines.push("");
191
+ if (config.bodyText) {
192
+ lines.push(config.bodyText.trim());
193
+ lines.push("");
194
+ }
195
+ const requiredSections = config.sections.filter((s) => !s.optional);
196
+ const optionalSections = config.sections.filter((s) => s.optional);
197
+ for (const section of requiredSections) {
198
+ const sectionRoutes = routesForSection(routes, section, config);
199
+ if (sectionRoutes.length === 0) continue;
200
+ lines.push(`## ${section.title}`);
201
+ if (section.description) {
202
+ lines.push("");
203
+ lines.push(section.description);
204
+ }
205
+ lines.push("");
206
+ for (const route of sectionRoutes) lines.push(formatLink(route, config.siteUrl));
207
+ lines.push("");
208
+ }
209
+ if (optionalSections.length > 0) {
210
+ const allOptionalRoutes = [];
211
+ for (const section of optionalSections) {
212
+ const sectionRoutes = routesForSection(routes, section, config, true);
213
+ allOptionalRoutes.push(...sectionRoutes);
214
+ }
215
+ if (allOptionalRoutes.length > 0) {
216
+ const sorted = sortRoutes(allOptionalRoutes, config.sortBy);
217
+ lines.push("## Optional");
218
+ lines.push("");
219
+ for (const route of sorted) lines.push(formatLink(route, config.siteUrl));
220
+ lines.push("");
221
+ }
222
+ }
223
+ return lines.join("\n").trim() + "\n";
224
+ }
225
+ /**
226
+ * Build the default sections from available routes when the user
227
+ * has not provided custom sections.
228
+ */
229
+ function buildDefaultSections(routes) {
230
+ const sections = [];
231
+ const collectionPrefixes = /* @__PURE__ */ new Set();
232
+ for (const route of routes) {
233
+ if (route.draft) continue;
234
+ const parts = route.path.split("/").filter(Boolean);
235
+ if (parts.length > 1) {
236
+ const prefix = "/" + parts[0] + "/";
237
+ if ([
238
+ "/blog/",
239
+ "/changelog/",
240
+ "/news/",
241
+ "/release-notes/"
242
+ ].some((p) => prefix.startsWith(p))) collectionPrefixes.add(prefix);
243
+ }
244
+ }
245
+ if (routes.filter((r) => {
246
+ if (r.draft) return false;
247
+ if (r.path.split("/").filter(Boolean).length <= 1) return true;
248
+ for (const cp of collectionPrefixes) if (r.path.startsWith(cp)) return false;
249
+ return true;
250
+ }).length > 0) sections.push({
251
+ title: "Documentation",
252
+ pathPrefix: "/",
253
+ description: "Core documentation pages covering installation, usage, API reference, and guides.",
254
+ optional: false
255
+ });
256
+ for (const prefix of collectionPrefixes) {
257
+ const label = prefix.replace(/^\//, "").replace(/\/$/, "").split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
258
+ sections.push({
259
+ title: label,
260
+ pathPrefix: prefix,
261
+ description: `${label} articles and posts.`,
262
+ optional: true
263
+ });
264
+ }
265
+ return sections;
266
+ }
267
+ /**
268
+ * Write the generated llms.txt to disk.
269
+ */
270
+ function writeLlmsText(content, outDir, logger) {
271
+ const outputPath = path.join(outDir, "llms.txt");
272
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
273
+ fs.writeFileSync(outputPath, content, "utf-8");
274
+ logger(`llms.txt generated: llms.txt (${Buffer.byteLength(content, "utf-8")} bytes)`);
275
+ }
276
+ /**
277
+ * Format the site URL — strip trailing slash for consistency.
278
+ */
279
+ function formatSiteUrl(raw) {
280
+ return raw.replace(/\/+$/, "");
281
+ }
282
+ //#endregion
283
+ //#region src/node/index.ts
284
+ /**
285
+ * @bdocs/plugin-llms-text — Generate an `llms.txt` file at build time.
286
+ *
287
+ * The llms.txt specification (llmstxt.org) provides a standardised
288
+ * plain-text index of documentation pages optimised for Large Language
289
+ * Models and AI agents. The file is emitted into the resolved build output
290
+ * directory and is therefore served at `<siteUrl>/llms.txt`.
291
+ */
292
+ function llmsTextPlugin(rawOptions = {}) {
293
+ const options = LlmsTextPluginOptionsSchema.parse(rawOptions);
294
+ function linkTagInjection(ctx, params) {
295
+ if (!options.addLinkTag) return { html: params.html };
296
+ const siteUrl = ctx.config.siteUrl ?? options.baseUrl;
297
+ if (!siteUrl) return { html: params.html };
298
+ const tag = `<link rel="llms-txt" href="${formatSiteUrl(siteUrl)}/llms.txt"/>\n</head>`;
299
+ return { html: params.html.replace("</head>", tag) };
300
+ }
301
+ function resolveConfig(ctx) {
302
+ const siteUrl = options.baseUrl ?? ctx.config.siteUrl;
303
+ if (!siteUrl) {
304
+ ctx.logger.info("[llms-text] Skipping generation: no siteUrl configured. Set siteUrl in boltdocs.config.ts or pass baseUrl to the plugin.");
305
+ return null;
306
+ }
307
+ const themeTitle = ctx.config.theme?.title;
308
+ const title = options.title ?? (typeof themeTitle === "object" && themeTitle !== null ? Object.values(themeTitle)[0] : themeTitle) ?? "Documentation";
309
+ const themeDescription = ctx.config.theme?.description;
310
+ const description = options.description ?? (typeof themeDescription === "object" && themeDescription !== null ? Object.values(themeDescription)[0] : themeDescription) ?? "";
311
+ const sections = options.sections ?? buildDefaultSections(ctx.routes);
312
+ return {
313
+ title,
314
+ description,
315
+ bodyText: options.bodyText,
316
+ siteUrl: formatSiteUrl(siteUrl),
317
+ sections,
318
+ sortBy: options.sortBy,
319
+ maxLinksPerSection: options.maxLinksPerSection,
320
+ includeDrafts: options.includeDrafts,
321
+ includePaths: options.includePaths,
322
+ excludePaths: options.excludePaths,
323
+ locales: options.locales,
324
+ defaultLocale: ctx.config.i18n?.defaultLocale
325
+ };
326
+ }
327
+ let generatedKey = null;
328
+ const generate = (ctx, routes, outputDir) => {
329
+ const resolved = resolveConfig({
330
+ ...ctx,
331
+ routes
332
+ });
333
+ if (!resolved) return;
334
+ const content = generateLlmsText(routes, resolved);
335
+ const normalizedOutputDir = path.resolve(outputDir);
336
+ const key = `${normalizedOutputDir}\0${content}`;
337
+ if (generatedKey === key) return;
338
+ writeLlmsText(content, normalizedOutputDir, ctx.logger.info);
339
+ generatedKey = key;
340
+ };
341
+ return createPlugin({
342
+ name: "boltdocs-plugin-llms-text",
343
+ version: "0.1.0",
344
+ hooks: {
345
+ async "build:generate"(ctx, params) {
346
+ generate(ctx, params.routes, params.outDir);
347
+ },
348
+ async afterBuild(ctx) {
349
+ const outputDir = path.resolve(ctx.rootDir ?? process.cwd(), ctx.outDir);
350
+ generate(ctx, ctx.routes, outputDir);
351
+ },
352
+ transformHtml(ctx, params) {
353
+ return linkTagInjection(ctx, params);
354
+ }
355
+ }
356
+ });
357
+ }
358
+ //#endregion
359
+ export { llmsTextPlugin as default };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@bdocs/plugin-llms-text",
3
+ "version": "0.2.0",
4
+ "description": "llms.txt generator plugin for Boltdocs — provides AI-optimized documentation indexes for LLMs and AI agents",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/node/index.d.mts",
14
+ "import": "./dist/node/index.mjs"
15
+ }
16
+ },
17
+ "author": "Jesus Alcala",
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "dependencies": {
21
+ "zod": "^4.3.6"
22
+ },
23
+ "peerDependencies": {
24
+ "boltdocs": "^3.3.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.0.0",
28
+ "tsdown": "^0.21.7",
29
+ "typescript": "^5.9.3",
30
+ "vitest": "^3.2.4",
31
+ "boltdocs": "^3.3.0",
32
+ "tsdown-config": "1.0.0"
33
+ },
34
+ "scripts": {
35
+ "build": "tsdown --config-loader unrun",
36
+ "dev": "tsdown --watch --config-loader unrun",
37
+ "test": "vitest run"
38
+ }
39
+ }