@stackql/docusaurus-plugin-aeo 0.4.1 → 0.5.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/CHANGELOG.md +48 -0
- package/README.md +119 -48
- package/package.json +18 -6
- package/src/askAiPrompt.js +21 -0
- package/src/companionPath.js +55 -0
- package/src/features/aiRoutes.js +3 -0
- package/src/features/alternateLinks.js +100 -0
- package/src/features/companions.js +84 -68
- package/src/features/llmsTxt.js +121 -53
- package/src/features/plainMarkdown.js +623 -0
- package/src/index.js +69 -12
- package/src/theme/AskAiButton/brand-icons/ChatGpt.jsx +26 -0
- package/src/theme/AskAiButton/brand-icons/Claude.jsx +25 -0
- package/src/theme/AskAiButton/brand-icons/Perplexity.jsx +23 -0
- package/src/theme/AskAiButton/icons.js +19 -38
- package/src/theme/AskAiButton/index.jsx +31 -7
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Feature 1b: advertise each page's .md companion from the page itself.
|
|
2
|
+
//
|
|
3
|
+
// After the companions are written, every route that got one has
|
|
4
|
+
//
|
|
5
|
+
// <link rel="alternate" type="text/markdown" href="https://site/foo.md">
|
|
6
|
+
//
|
|
7
|
+
// inserted into its built HTML before </head>, so an agent that lands on
|
|
8
|
+
// the page can find the markdown without guessing the URL. Optionally the
|
|
9
|
+
// same relation is written as an HTTP header for Netlify-style hosts via a
|
|
10
|
+
// `_headers` file (`Link: <url>; rel="alternate"; type="text/markdown"`),
|
|
11
|
+
// for agents that read headers before bodies.
|
|
12
|
+
//
|
|
13
|
+
// Same approach as @stackql/docusaurus-plugin-structured-data, which also
|
|
14
|
+
// post-processes the built HTML in postBuild: the file is a string, the
|
|
15
|
+
// insertion point is the first </head>, no DOM library.
|
|
16
|
+
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const fs = require('fs').promises;
|
|
19
|
+
const { htmlPath, companionUrl } = require('../companionPath');
|
|
20
|
+
|
|
21
|
+
const MARK = 'rel="alternate" type="text/markdown"';
|
|
22
|
+
|
|
23
|
+
function linkTag(href) {
|
|
24
|
+
return `<link ${MARK} href="${href}">`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function headerBlock(route, href) {
|
|
28
|
+
return `${route}\n Link: <${href}>; rel="alternate"; type="text/markdown"\n`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readIfExists(file) {
|
|
32
|
+
try {
|
|
33
|
+
return await fs.readFile(file, 'utf8');
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if (e.code === 'ENOENT') return null;
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = async function injectAlternateLinks({
|
|
41
|
+
props,
|
|
42
|
+
emittedCompanions,
|
|
43
|
+
linkHeader,
|
|
44
|
+
verbose,
|
|
45
|
+
}) {
|
|
46
|
+
const { outDir, siteConfig } = props;
|
|
47
|
+
const trailingSlash = siteConfig.trailingSlash;
|
|
48
|
+
const siteUrl = siteConfig.url;
|
|
49
|
+
|
|
50
|
+
let injected = 0;
|
|
51
|
+
let skipped = 0;
|
|
52
|
+
const headerBlocks = [];
|
|
53
|
+
|
|
54
|
+
for (const item of emittedCompanions) {
|
|
55
|
+
const href = companionUrl(siteUrl, item.permalink, trailingSlash);
|
|
56
|
+
const file = path.join(outDir, htmlPath(item.permalink, trailingSlash));
|
|
57
|
+
const html = await readIfExists(file);
|
|
58
|
+
if (html === null) {
|
|
59
|
+
skipped++;
|
|
60
|
+
if (verbose) {
|
|
61
|
+
console.log(`[plugin-aeo] alternate link: no HTML at ${path.relative(outDir, file)} for ${item.permalink}`);
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!html.includes(MARK)) {
|
|
66
|
+
const at = html.indexOf('</head>');
|
|
67
|
+
if (at === -1) {
|
|
68
|
+
skipped++;
|
|
69
|
+
if (verbose) {
|
|
70
|
+
console.log(`[plugin-aeo] alternate link: no </head> in ${path.relative(outDir, file)}`);
|
|
71
|
+
}
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
await fs.writeFile(file, `${html.slice(0, at)}${linkTag(href)}${html.slice(at)}`, 'utf8');
|
|
75
|
+
injected++;
|
|
76
|
+
}
|
|
77
|
+
if (linkHeader) {
|
|
78
|
+
// Netlify matches the route as requested; with trailingSlash false
|
|
79
|
+
// that is the bare route, otherwise the route with its slash.
|
|
80
|
+
const route = htmlPath(item.permalink, trailingSlash).replace(/index\.html$/, '').replace(/\.html$/, '');
|
|
81
|
+
headerBlocks.push(headerBlock(route === '' ? '/' : route, href));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (linkHeader && headerBlocks.length > 0) {
|
|
86
|
+
// Append to a `_headers` the site may already ship from static/.
|
|
87
|
+
const file = path.join(outDir, '_headers');
|
|
88
|
+
const existing = (await readIfExists(file)) || '';
|
|
89
|
+
const banner = '# @stackql/docusaurus-plugin-aeo: .md companions as Link headers\n';
|
|
90
|
+
const sep = existing && !existing.endsWith('\n') ? '\n\n' : existing ? '\n' : '';
|
|
91
|
+
await fs.writeFile(file, `${existing}${sep}${banner}${headerBlocks.join('')}`, 'utf8');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (verbose) {
|
|
95
|
+
console.log(
|
|
96
|
+
`[plugin-aeo] alternate link: injected into ${injected} page(s)${skipped ? `, skipped ${skipped}` : ''}${linkHeader ? `, ${headerBlocks.length} _headers rule(s)` : ''}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return { injected, skipped, headerRules: headerBlocks.length };
|
|
100
|
+
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const fs = require('fs').promises;
|
|
3
3
|
const matter = require('gray-matter');
|
|
4
|
+
const { companionPath, pageUrl } = require('../companionPath');
|
|
5
|
+
const { toPlainMarkdown, resolveFormat } = require('./plainMarkdown');
|
|
4
6
|
|
|
5
7
|
// Minimal glob matcher for route paths. Patterns like "/blog/tags/**",
|
|
6
8
|
// "/search". Avoids pulling micromatch directly.
|
|
@@ -38,43 +40,6 @@ function toRegExp(glob) {
|
|
|
38
40
|
return new RegExp('^' + re + '$');
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
function stripMdx(source) {
|
|
42
|
-
let out = source;
|
|
43
|
-
// Strip import / export lines (top-of-file MDX).
|
|
44
|
-
out = out.replace(/^\s*import\s+[^\n]+\n/gm, '');
|
|
45
|
-
out = out.replace(/^\s*export\s+[^\n]+\n/gm, '');
|
|
46
|
-
// Strip self-closing JSX tags like <Component prop="x" />.
|
|
47
|
-
out = out.replace(/<([A-Z][A-Za-z0-9]*)\b[^>]*\/>\s*/g, '');
|
|
48
|
-
// Strip paired JSX blocks like <Foo>...</Foo> (non-greedy, single component).
|
|
49
|
-
out = out.replace(
|
|
50
|
-
/<([A-Z][A-Za-z0-9]*)\b[^>]*>[\s\S]*?<\/\1>\s*/g,
|
|
51
|
-
'',
|
|
52
|
-
);
|
|
53
|
-
// Strip stray opening or closing component tags left behind.
|
|
54
|
-
out = out.replace(/<\/?[A-Z][A-Za-z0-9]*\b[^>]*>/g, '');
|
|
55
|
-
// Collapse 3+ blank lines.
|
|
56
|
-
out = out.replace(/\n{3,}/g, '\n\n');
|
|
57
|
-
return out;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function routeToCompanionPath(routePath, trailingSlash, outDir) {
|
|
61
|
-
// Docusaurus emits either /foo/index.html (trailingSlash: true / undefined)
|
|
62
|
-
// or /foo.html (trailingSlash: false). The companion mirrors the HTML.
|
|
63
|
-
let rel;
|
|
64
|
-
if (routePath === '/' || routePath === '') {
|
|
65
|
-
rel = 'index.md';
|
|
66
|
-
} else {
|
|
67
|
-
const cleaned = routePath.replace(/^\/+|\/+$/g, '');
|
|
68
|
-
if (trailingSlash === false) {
|
|
69
|
-
rel = `${cleaned}.md`;
|
|
70
|
-
} else {
|
|
71
|
-
// true or undefined (Docusaurus default behavior: index.html in folder)
|
|
72
|
-
rel = `${cleaned}/index.md`;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return path.join(outDir, rel);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
43
|
function collectFromDocs(content, pluginName, pluginId) {
|
|
79
44
|
// Docs plugin loaded content shape:
|
|
80
45
|
// { loadedVersions: [{ docs: [{ id, permalink, source, title, description, frontMatter }] }] }
|
|
@@ -122,6 +87,26 @@ function collectFromBlog(content, pluginName, pluginId) {
|
|
|
122
87
|
return results;
|
|
123
88
|
}
|
|
124
89
|
|
|
90
|
+
// Every page that gets a companion: docs and blog pages with a source file,
|
|
91
|
+
// minus `companions.exclude`. Generated category indexes, React pages and
|
|
92
|
+
// blog list/tag/author pages have no source and are not in this list. Used
|
|
93
|
+
// by postBuild to emit the files and by allContentLoaded to tell the Ask AI
|
|
94
|
+
// button which routes have one.
|
|
95
|
+
function collectCompanionItems(loadedContentByPlugin, options) {
|
|
96
|
+
const items = [];
|
|
97
|
+
for (const [, entry] of loadedContentByPlugin) {
|
|
98
|
+
const { pluginName, pluginId, content } = entry;
|
|
99
|
+
if (pluginName === 'docusaurus-plugin-content-docs') {
|
|
100
|
+
items.push(...collectFromDocs(content, pluginName, pluginId));
|
|
101
|
+
} else if (pluginName === 'docusaurus-plugin-content-blog') {
|
|
102
|
+
items.push(...collectFromBlog(content, pluginName, pluginId));
|
|
103
|
+
}
|
|
104
|
+
// Custom pages plugin emits React components, not markdown. Skip silently.
|
|
105
|
+
}
|
|
106
|
+
const exclude = (options && options.exclude) || [];
|
|
107
|
+
return items.filter((item) => !matchAny(item.permalink, exclude));
|
|
108
|
+
}
|
|
109
|
+
|
|
125
110
|
function resolveSourcePath(sourceRef, siteDir) {
|
|
126
111
|
if (!sourceRef) return null;
|
|
127
112
|
// Docusaurus content references look like "@site/docs/intro.md".
|
|
@@ -136,6 +121,34 @@ async function ensureDir(p) {
|
|
|
136
121
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
137
122
|
}
|
|
138
123
|
|
|
124
|
+
// Put the page's title, description and canonical URL at the top of a
|
|
125
|
+
// plain companion so an LLM sees them without parsing YAML, and can cite
|
|
126
|
+
// the page:
|
|
127
|
+
//
|
|
128
|
+
// # Title
|
|
129
|
+
//
|
|
130
|
+
// > description
|
|
131
|
+
//
|
|
132
|
+
// Source: https://site/route
|
|
133
|
+
//
|
|
134
|
+
// A body that already opens with an H1 keeps it; the rest goes under it.
|
|
135
|
+
function withTitleBlock(markdown, title, description, sourceUrl) {
|
|
136
|
+
const lines = markdown.split('\n');
|
|
137
|
+
const first = lines.findIndex((l) => l.trim() !== '');
|
|
138
|
+
const hasH1 = first >= 0 && /^#\s+\S/.test(lines[first]);
|
|
139
|
+
const extras = [];
|
|
140
|
+
if (description) extras.push(`> ${String(description).replace(/\s+/g, ' ').trim()}`);
|
|
141
|
+
if (sourceUrl) extras.push(`Source: ${sourceUrl}`);
|
|
142
|
+
if (hasH1) {
|
|
143
|
+
if (extras.length === 0) return markdown;
|
|
144
|
+
return [...lines.slice(0, first + 1), '', extras.join('\n\n'), ...lines.slice(first + 1)].join('\n');
|
|
145
|
+
}
|
|
146
|
+
const head = [];
|
|
147
|
+
if (title) head.push(`# ${title}`);
|
|
148
|
+
head.push(...extras);
|
|
149
|
+
return head.length ? `${head.join('\n\n')}\n\n${markdown}` : markdown;
|
|
150
|
+
}
|
|
151
|
+
|
|
139
152
|
module.exports = async function emitCompanions({
|
|
140
153
|
props,
|
|
141
154
|
options,
|
|
@@ -144,30 +157,16 @@ module.exports = async function emitCompanions({
|
|
|
144
157
|
}) {
|
|
145
158
|
const { outDir, siteConfig, siteDir } = props;
|
|
146
159
|
const trailingSlash = siteConfig.trailingSlash;
|
|
147
|
-
|
|
148
|
-
|
|
160
|
+
// 'clean' is accepted as a synonym for 'plain' (the name the external
|
|
161
|
+
// audit of 0.4.2 used for this behaviour).
|
|
162
|
+
const format = options.format === 'clean' ? 'plain' : options.format;
|
|
163
|
+
const markdownConfigFormat = siteConfig.markdown && siteConfig.markdown.format;
|
|
149
164
|
|
|
150
|
-
|
|
151
|
-
const items = [];
|
|
152
|
-
for (const [, entry] of loadedContentByPlugin) {
|
|
153
|
-
const { pluginName, pluginId, content } = entry;
|
|
154
|
-
if (pluginName === 'docusaurus-plugin-content-docs') {
|
|
155
|
-
items.push(...collectFromDocs(content, pluginName, pluginId));
|
|
156
|
-
} else if (pluginName === 'docusaurus-plugin-content-blog') {
|
|
157
|
-
items.push(...collectFromBlog(content, pluginName, pluginId));
|
|
158
|
-
}
|
|
159
|
-
// Custom pages plugin emits React components, not markdown. Skip silently.
|
|
160
|
-
}
|
|
165
|
+
const items = collectCompanionItems(loadedContentByPlugin, options);
|
|
161
166
|
|
|
162
167
|
const emitted = [];
|
|
168
|
+
let fallbacks = 0;
|
|
163
169
|
for (const item of items) {
|
|
164
|
-
if (matchAny(item.permalink, excludePatterns)) {
|
|
165
|
-
if (verbose) {
|
|
166
|
-
console.log(`[plugin-aeo] companions: excluded ${item.permalink}`);
|
|
167
|
-
}
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
170
|
const sourcePath = resolveSourcePath(item.sourceRef, siteDir);
|
|
172
171
|
if (!sourcePath) {
|
|
173
172
|
if (verbose) {
|
|
@@ -192,20 +191,31 @@ module.exports = async function emitCompanions({
|
|
|
192
191
|
|
|
193
192
|
let body = raw;
|
|
194
193
|
if (format === 'plain') {
|
|
195
|
-
// Strip frontmatter, then strip MDX-specific bits.
|
|
196
194
|
const parsed = matter(raw);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
195
|
+
const sourceFormat = resolveFormat({
|
|
196
|
+
filePath: sourcePath,
|
|
197
|
+
frontMatterFormat: parsed.data && parsed.data.format,
|
|
198
|
+
markdownConfigFormat,
|
|
199
|
+
});
|
|
200
|
+
const result = await toPlainMarkdown(parsed.content, {
|
|
201
|
+
format: sourceFormat,
|
|
202
|
+
filePath: sourcePath,
|
|
203
|
+
});
|
|
204
|
+
if (result.fallback) {
|
|
205
|
+
fallbacks++;
|
|
206
|
+
console.warn(
|
|
207
|
+
`[plugin-aeo] companions: ${path.relative(siteDir, sourcePath)} did not parse as ${sourceFormat} (${result.error && result.error.message}); used the regex stripper for its companion`,
|
|
208
|
+
);
|
|
205
209
|
}
|
|
210
|
+
body = withTitleBlock(
|
|
211
|
+
result.markdown,
|
|
212
|
+
item.title,
|
|
213
|
+
item.description,
|
|
214
|
+
pageUrl(siteConfig.url, item.permalink),
|
|
215
|
+
);
|
|
206
216
|
}
|
|
207
217
|
|
|
208
|
-
const target =
|
|
218
|
+
const target = path.join(outDir, companionPath(item.permalink, trailingSlash));
|
|
209
219
|
await ensureDir(target);
|
|
210
220
|
await fs.writeFile(target, body, 'utf8');
|
|
211
221
|
|
|
@@ -228,7 +238,13 @@ module.exports = async function emitCompanions({
|
|
|
228
238
|
}
|
|
229
239
|
|
|
230
240
|
if (verbose) {
|
|
231
|
-
console.log(
|
|
241
|
+
console.log(
|
|
242
|
+
`[plugin-aeo] companions: emitted ${emitted.length} file(s)${fallbacks ? `, ${fallbacks} via the regex fallback` : ''}`,
|
|
243
|
+
);
|
|
232
244
|
}
|
|
233
245
|
return emitted;
|
|
234
246
|
};
|
|
247
|
+
|
|
248
|
+
module.exports.collectCompanionItems = collectCompanionItems;
|
|
249
|
+
module.exports.withTitleBlock = withTitleBlock;
|
|
250
|
+
module.exports.matchAny = matchAny;
|
package/src/features/llmsTxt.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const fs = require('fs').promises;
|
|
3
|
+
const { companionUrl, pageUrl } = require('../companionPath');
|
|
3
4
|
|
|
4
5
|
function matchAny(value, patterns) {
|
|
5
6
|
if (!patterns || patterns.length === 0) return false;
|
|
@@ -32,17 +33,6 @@ function toRegExp(glob) {
|
|
|
32
33
|
return new RegExp('^' + re + '$');
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
function companionUrl(permalink, trailingSlash) {
|
|
36
|
-
if (permalink === '/' || permalink === '') {
|
|
37
|
-
return '/index.md';
|
|
38
|
-
}
|
|
39
|
-
const cleaned = permalink.replace(/\/+$/, '');
|
|
40
|
-
if (trailingSlash === false) {
|
|
41
|
-
return `${cleaned}.md`;
|
|
42
|
-
}
|
|
43
|
-
return `${cleaned}/index.md`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
36
|
function groupKind(kind, pluginId) {
|
|
47
37
|
if (kind === 'docs') return 'docs';
|
|
48
38
|
if (kind === 'blog') return 'blog';
|
|
@@ -149,30 +139,20 @@ function buildSections(filtered, options, verbose) {
|
|
|
149
139
|
return sections;
|
|
150
140
|
}
|
|
151
141
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
// Filter the companions through include/exclude rules.
|
|
163
|
-
const filtered = emittedCompanions.filter((item) => {
|
|
164
|
-
if (options.include && !options.include.some((p) => toRegExp(p).test(item.permalink))) {
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
|
-
if (matchAny(item.permalink, options.exclude)) {
|
|
168
|
-
return false;
|
|
169
|
-
}
|
|
170
|
-
return true;
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
const sections = buildSections(filtered, options, verbose);
|
|
142
|
+
// Normalised view of `llmsTxt.fullTxt`: the boolean form (`true` = every
|
|
143
|
+
// section, no cap) or the object form `{ include, maxBytes }`.
|
|
144
|
+
function fullTxtOptions(fullTxt) {
|
|
145
|
+
if (!fullTxt) return null;
|
|
146
|
+
if (fullTxt === true) return { include: null, maxBytes: null };
|
|
147
|
+
return {
|
|
148
|
+
include: Array.isArray(fullTxt.include) && fullTxt.include.length > 0 ? fullTxt.include : null,
|
|
149
|
+
maxBytes: Number.isFinite(fullTxt.maxBytes) && fullTxt.maxBytes > 0 ? fullTxt.maxBytes : null,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
174
152
|
|
|
175
|
-
|
|
153
|
+
// Render llms.txt from the sections. Pure, so it can be tested without a
|
|
154
|
+
// build: returns the file text.
|
|
155
|
+
function renderLlmsTxt({ siteConfig, siteUrl, trailingSlash, sections, options, companionsEnabled }) {
|
|
176
156
|
const lines = [];
|
|
177
157
|
lines.push(`# ${siteConfig.title}`);
|
|
178
158
|
lines.push('');
|
|
@@ -189,9 +169,11 @@ module.exports = async function emitLlmsTxt({
|
|
|
189
169
|
lines.push(`## ${section.title}`);
|
|
190
170
|
lines.push('');
|
|
191
171
|
for (const item of section.items) {
|
|
172
|
+
// Absolute, as the llms.txt spec's examples are: the file is read
|
|
173
|
+
// away from the site, where a relative link has no base.
|
|
192
174
|
const url = companionsEnabled
|
|
193
|
-
? companionUrl(item.permalink, trailingSlash)
|
|
194
|
-
: item.permalink;
|
|
175
|
+
? companionUrl(siteUrl, item.permalink, trailingSlash)
|
|
176
|
+
: pageUrl(siteUrl, item.permalink);
|
|
195
177
|
const desc = item.description || siteConfig.tagline || '';
|
|
196
178
|
const titleText = item.title || item.permalink;
|
|
197
179
|
if (desc) {
|
|
@@ -203,8 +185,68 @@ module.exports = async function emitLlmsTxt({
|
|
|
203
185
|
lines.push('');
|
|
204
186
|
}
|
|
205
187
|
|
|
188
|
+
// The llmstxt.org convention for secondary resources: an "Optional"
|
|
189
|
+
// section a reader can skip. llms-full.txt is the whole corpus in one
|
|
190
|
+
// file, so it belongs here rather than inline with the page list.
|
|
191
|
+
if (options.fullTxt && options.linkFullTxt !== false && companionsEnabled) {
|
|
192
|
+
lines.push('## Optional');
|
|
193
|
+
lines.push('');
|
|
194
|
+
lines.push(
|
|
195
|
+
`- [Full text of every page](${siteUrl}/llms-full.txt): one file with the content of every page listed above`,
|
|
196
|
+
);
|
|
197
|
+
lines.push('');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return lines.join('\n');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// One block of llms-full.txt: the companion body with a `Source:` line (and
|
|
204
|
+
// the title, when the body does not already open with it). A plain
|
|
205
|
+
// companion already carries both, so the header is skipped and the body
|
|
206
|
+
// is emitted as is.
|
|
207
|
+
function fullTxtBlock({ body, item, siteUrl }) {
|
|
208
|
+
const url = pageUrl(siteUrl, item.permalink);
|
|
209
|
+
const head = body.trimStart().split('\n', 8);
|
|
210
|
+
const hasH1 = /^#\s+\S/.test(head[0] || '');
|
|
211
|
+
const hasSource = head.some((l) => /^Source: \S/.test(l));
|
|
212
|
+
const header = [
|
|
213
|
+
...(hasH1 ? [] : [`# ${item.title || item.permalink}`, '']),
|
|
214
|
+
...(hasSource ? [] : [`Source: ${url}`, '']),
|
|
215
|
+
];
|
|
216
|
+
const prefix = header.length ? `${header.join('\n')}\n` : '';
|
|
217
|
+
return `${prefix}${body.trim()}\n`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = async function emitLlmsTxt({
|
|
221
|
+
props,
|
|
222
|
+
options,
|
|
223
|
+
emittedCompanions,
|
|
224
|
+
companionsEnabled,
|
|
225
|
+
verbose,
|
|
226
|
+
}) {
|
|
227
|
+
const { outDir, siteConfig } = props;
|
|
228
|
+
const trailingSlash = siteConfig.trailingSlash;
|
|
229
|
+
const siteUrl = (siteConfig.url || '').replace(/\/+$/, '');
|
|
230
|
+
|
|
231
|
+
// Filter the companions through include/exclude rules.
|
|
232
|
+
const filtered = emittedCompanions.filter((item) => {
|
|
233
|
+
if (options.include && !options.include.some((p) => toRegExp(p).test(item.permalink))) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
if (matchAny(item.permalink, options.exclude)) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const sections = buildSections(filtered, options, verbose);
|
|
243
|
+
|
|
206
244
|
const llmsTxtPath = path.join(outDir, 'llms.txt');
|
|
207
|
-
await fs.writeFile(
|
|
245
|
+
await fs.writeFile(
|
|
246
|
+
llmsTxtPath,
|
|
247
|
+
renderLlmsTxt({ siteConfig, siteUrl, trailingSlash, sections, options, companionsEnabled }),
|
|
248
|
+
'utf8',
|
|
249
|
+
);
|
|
208
250
|
if (verbose) {
|
|
209
251
|
console.log(`[plugin-aeo] llmsTxt: wrote ${llmsTxtPath}`);
|
|
210
252
|
}
|
|
@@ -212,7 +254,8 @@ module.exports = async function emitLlmsTxt({
|
|
|
212
254
|
// Build llms-full.txt by concatenating the companion files that we just
|
|
213
255
|
// emitted in feature 1. Requires feature 1 to have been enabled. Section
|
|
214
256
|
// headings mirror llms.txt so an LLM can navigate the corpus by H2.
|
|
215
|
-
|
|
257
|
+
const full = fullTxtOptions(options.fullTxt);
|
|
258
|
+
if (full) {
|
|
216
259
|
if (!companionsEnabled) {
|
|
217
260
|
if (verbose) {
|
|
218
261
|
console.log(
|
|
@@ -222,9 +265,26 @@ module.exports = async function emitLlmsTxt({
|
|
|
222
265
|
return;
|
|
223
266
|
}
|
|
224
267
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
268
|
+
// `include` names content instances ("<plugin>@<id>") or, with per-type
|
|
269
|
+
// grouping, the group keys (docs, blog, pages). Sections keep the order
|
|
270
|
+
// of llms.txt; `maxBytes` stops appending once the next block would
|
|
271
|
+
// cross the cap, so the file always ends on a whole page.
|
|
272
|
+
const wanted = full.include
|
|
273
|
+
? sections.filter((s) => full.include.includes(s.key))
|
|
274
|
+
: sections;
|
|
275
|
+
if (full.include && wanted.length === 0 && verbose) {
|
|
276
|
+
console.log(
|
|
277
|
+
`[plugin-aeo] llmsTxt: fullTxt.include matched no section (have: ${sections.map((s) => s.key).join(', ')})`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const SEP = '\n---\n\n';
|
|
282
|
+
let out = '';
|
|
283
|
+
let size = 0;
|
|
284
|
+
let truncated = false;
|
|
285
|
+
let pages = 0;
|
|
286
|
+
outer: for (const section of wanted) {
|
|
287
|
+
let sectionOpen = false;
|
|
228
288
|
for (const item of section.items) {
|
|
229
289
|
let body;
|
|
230
290
|
try {
|
|
@@ -237,23 +297,31 @@ module.exports = async function emitLlmsTxt({
|
|
|
237
297
|
}
|
|
238
298
|
continue;
|
|
239
299
|
}
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
300
|
+
const block = fullTxtBlock({ body, item, siteUrl });
|
|
301
|
+
const chunk = `${sectionOpen ? SEP : `${out ? SEP : ''}## ${section.title}\n\n`}${block}`;
|
|
302
|
+
const bytes = Buffer.byteLength(chunk, 'utf8');
|
|
303
|
+
if (full.maxBytes && size + bytes > full.maxBytes) {
|
|
304
|
+
truncated = true;
|
|
305
|
+
break outer;
|
|
306
|
+
}
|
|
307
|
+
out += chunk;
|
|
308
|
+
size += bytes;
|
|
309
|
+
sectionOpen = true;
|
|
310
|
+
pages++;
|
|
248
311
|
}
|
|
249
|
-
if (blocks.length === 0) continue;
|
|
250
|
-
sectionChunks.push(`## ${section.title}\n\n${blocks.join('\n---\n\n')}`);
|
|
251
312
|
}
|
|
252
313
|
|
|
253
314
|
const fullPath = path.join(outDir, 'llms-full.txt');
|
|
254
|
-
await fs.writeFile(fullPath,
|
|
315
|
+
await fs.writeFile(fullPath, out, 'utf8');
|
|
255
316
|
if (verbose) {
|
|
256
|
-
console.log(
|
|
317
|
+
console.log(
|
|
318
|
+
`[plugin-aeo] llmsTxt: wrote ${fullPath} (${pages} page(s), ${size} bytes${truncated ? `, stopped at the ${full.maxBytes}-byte cap` : ''})`,
|
|
319
|
+
);
|
|
257
320
|
}
|
|
258
321
|
}
|
|
259
322
|
};
|
|
323
|
+
|
|
324
|
+
module.exports.renderLlmsTxt = renderLlmsTxt;
|
|
325
|
+
module.exports.fullTxtBlock = fullTxtBlock;
|
|
326
|
+
module.exports.fullTxtOptions = fullTxtOptions;
|
|
327
|
+
module.exports.buildSections = buildSections;
|