@stackql/docusaurus-plugin-aeo 0.1.2 → 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/CHANGELOG.md +5 -0
- package/README.md +77 -3
- package/package.json +1 -1
- package/src/features/llmsTxt.js +110 -26
- package/src/index.js +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Added: `llmsTxt.instanceSections` option for per-content-plugin-instance llms.txt section grouping. Use case: consumers with multiple content-docs instances (e.g. human docs + AI reference) can split them into named sections. Map keys are `"${pluginName}@${pluginId}"`; values are `{ title, order? }`. Unmapped instances are collected into a single appended "Other" section. `llms-full.txt` mirrors the same section structure as `llms.txt`.
|
|
6
|
+
- No breaking changes: when `llmsTxt.instanceSections` is unset (the default), grouping is identical to v0.1.x — per content-plugin type, using `llmsTxt.sections` titles.
|
|
7
|
+
|
|
3
8
|
## 0.1.2
|
|
4
9
|
|
|
5
10
|
Fix: cross-plugin loaded content was not captured because contentLoaded does not receive allContent in Docusaurus 3.x. Use allContentLoaded hook. Without this fix, feature 1 (.md companions) emitted zero files and feature 2 (llms.txt / llms-full.txt) was empty.
|
package/README.md
CHANGED
|
@@ -93,11 +93,81 @@ Options:
|
|
|
93
93
|
| `llmsTxt.exclude` | `["/search", "/404", "/blog/tags/**", "/blog/page/**", "/blog/archive", "/blog/authors/**"]` | Route glob patterns to skip. |
|
|
94
94
|
| `llmsTxt.include` | `null` | If set, only routes matching at least one pattern are included. Use for opt-in mode. |
|
|
95
95
|
| `llmsTxt.header` | `null` | String prepended to `llms.txt` above the section list. Good for a project intro paragraph or links to key external resources. |
|
|
96
|
-
| `llmsTxt.sections` | `{ docs: 'Documentation', blog: 'Blog', pages: 'Pages' }` | Section title overrides. |
|
|
96
|
+
| `llmsTxt.sections` | `{ docs: 'Documentation', blog: 'Blog', pages: 'Pages' }` | Section title overrides for the default per-type grouping. |
|
|
97
|
+
| `llmsTxt.instanceSections` | `null` | Per-content-plugin-instance section map. When set, supersedes `sections` (see below). |
|
|
97
98
|
| `llmsTxt.fullTxt` | `true` | Emit `llms-full.txt`. |
|
|
98
99
|
|
|
99
100
|
Glob patterns are matched against route permalinks. `**` matches across path segments; `*` matches within a single segment.
|
|
100
101
|
|
|
102
|
+
### Per-instance sectioning
|
|
103
|
+
|
|
104
|
+
By default, `llms.txt` groups by content-plugin TYPE: every `@docusaurus/plugin-content-docs` instance lands under one `## Documentation` heading, every blog instance under `## Blog`, and so on. This is fine for sites with a single docs instance, but it breaks down for sites that run multiple docs instances with different audiences — for example, one for human-facing docs at `/docs/*` and one for AI-targeted reference content at `/ai/*`. In that case, `llms.txt` and `llms-full.txt` interleave both surfaces under one heading, hiding the corpus shape from the crawlers and agents the file exists to serve.
|
|
105
|
+
|
|
106
|
+
Set `llmsTxt.instanceSections` to switch to per-instance grouping. Keys are `"${pluginName}@${pluginId}"`; each value is `{ title, order? }`.
|
|
107
|
+
|
|
108
|
+
Two content-docs instances + blog in `docusaurus.config.js`:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
module.exports = {
|
|
112
|
+
plugins: [
|
|
113
|
+
'@docusaurus/plugin-content-blog', // id: 'default'
|
|
114
|
+
'@docusaurus/plugin-content-docs', // id: 'default'
|
|
115
|
+
[
|
|
116
|
+
'@docusaurus/plugin-content-docs',
|
|
117
|
+
{
|
|
118
|
+
id: 'ai',
|
|
119
|
+
routeBasePath: '/ai',
|
|
120
|
+
path: 'ai-content',
|
|
121
|
+
sidebarPath: false,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
[
|
|
125
|
+
'@stackql/docusaurus-plugin-aeo',
|
|
126
|
+
{
|
|
127
|
+
llmsTxt: {
|
|
128
|
+
instanceSections: {
|
|
129
|
+
'docusaurus-plugin-content-docs@default': { title: 'Documentation', order: 1 },
|
|
130
|
+
'docusaurus-plugin-content-docs@ai': { title: 'AI Reference', order: 2 },
|
|
131
|
+
'docusaurus-plugin-content-blog@default': { title: 'Blog', order: 3 },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Resulting `llms.txt`:
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
# Your site
|
|
144
|
+
|
|
145
|
+
> Your tagline.
|
|
146
|
+
|
|
147
|
+
## Documentation
|
|
148
|
+
|
|
149
|
+
- [Getting started](/docs/intro/index.md): Install and run your first query.
|
|
150
|
+
- [Providers](/docs/providers/index.md): Catalog of supported cloud APIs.
|
|
151
|
+
|
|
152
|
+
## AI Reference
|
|
153
|
+
|
|
154
|
+
- [What is StackQL](/ai/faqs/what-is-stackql/index.md): Canonical one-paragraph definition.
|
|
155
|
+
- [Connecting to AWS](/ai/howto/connect-to-aws/index.md): Step-by-step authentication.
|
|
156
|
+
|
|
157
|
+
## Blog
|
|
158
|
+
|
|
159
|
+
- [Querying Snowflake](/blog/snowflake/index.md): ...
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`llms-full.txt` mirrors the same section structure: each section's title is emitted as an `## H2` heading above its concatenated companion blocks, in the same order.
|
|
163
|
+
|
|
164
|
+
Behavior:
|
|
165
|
+
|
|
166
|
+
- Sections appear in ascending `order`. Ties break alphabetically by title. Entries with no `order` go last.
|
|
167
|
+
- Instances not present in `instanceSections` are collected into a single appended `## Other` section, sorted alphabetically by page title. When `verbose: true`, each unmapped instance name is logged once so you notice and can map it.
|
|
168
|
+
- `llmsTxt.sections` (the per-type titles) is ignored when `instanceSections` is set. When `verbose: true`, the plugin logs a one-line notice if both are configured.
|
|
169
|
+
- When `instanceSections` is `null` (default), behavior is identical to v0.1.x: per-type grouping using `llmsTxt.sections` titles.
|
|
170
|
+
|
|
101
171
|
## Feature 3: "Ask AI" button
|
|
102
172
|
|
|
103
173
|
A swizzle-friendly dropdown is injected above the existing `DocItem/Footer` and `BlogPostItem/Footer`. Each item opens the corresponding AI surface in a new tab with a prefilled prompt that references the current page's `.md` companion.
|
|
@@ -255,11 +325,15 @@ The `sitemapExclude` helper, by contrast, does work with globs because `@docusau
|
|
|
255
325
|
],
|
|
256
326
|
include: null, // null = all not-excluded
|
|
257
327
|
header: null, // optional string prepended to llms.txt
|
|
258
|
-
sections: {
|
|
259
|
-
docs: 'Documentation',
|
|
328
|
+
sections: { // per-type section titles (used when
|
|
329
|
+
docs: 'Documentation', // instanceSections is null)
|
|
260
330
|
blog: 'Blog',
|
|
261
331
|
pages: 'Pages',
|
|
262
332
|
},
|
|
333
|
+
instanceSections: null, // per-instance section map, supersedes
|
|
334
|
+
// sections when set. Shape:
|
|
335
|
+
// { '${pluginName}@${pluginId}':
|
|
336
|
+
// { title: string, order?: number } }
|
|
263
337
|
fullTxt: true, // emit llms-full.txt
|
|
264
338
|
},
|
|
265
339
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackql/docusaurus-plugin-aeo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "AEO (Answer Engine Optimization) helpers for Docusaurus: .md companion files, llms.txt / llms-full.txt, an Ask AI dropdown, and /ai/* route conventions.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"exports": {
|
package/src/features/llmsTxt.js
CHANGED
|
@@ -52,6 +52,103 @@ function groupKind(kind, pluginId) {
|
|
|
52
52
|
return 'pages';
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function instanceKey(item) {
|
|
56
|
+
return `${item.pluginName}@${item.pluginId}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Returns an ordered array of { key, title, items[] }. The same shape is
|
|
60
|
+
// consumed by both llms.txt and llms-full.txt emitters so their section
|
|
61
|
+
// structure stays in lock-step.
|
|
62
|
+
function buildSections(filtered, options, verbose) {
|
|
63
|
+
if (options.instanceSections) {
|
|
64
|
+
if (verbose && options.sections && Object.keys(options.sections).length > 0) {
|
|
65
|
+
// Both options set is allowed; instanceSections wins. Log so the
|
|
66
|
+
// consumer isn't confused that their `sections` titles aren't
|
|
67
|
+
// appearing.
|
|
68
|
+
console.log(
|
|
69
|
+
'[plugin-aeo] llmsTxt: both `sections` and `instanceSections` are set; `instanceSections` takes precedence',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const mapped = new Map(); // key -> { title, order, items[] }
|
|
74
|
+
const unmapped = []; // items that don't match any configured instance
|
|
75
|
+
const seenUnmappedKeys = new Set();
|
|
76
|
+
|
|
77
|
+
for (const [key, cfg] of Object.entries(options.instanceSections)) {
|
|
78
|
+
mapped.set(key, { title: cfg.title, order: cfg.order, items: [] });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const item of filtered) {
|
|
82
|
+
const key = instanceKey(item);
|
|
83
|
+
if (mapped.has(key)) {
|
|
84
|
+
mapped.get(key).items.push(item);
|
|
85
|
+
} else {
|
|
86
|
+
unmapped.push(item);
|
|
87
|
+
if (verbose && !seenUnmappedKeys.has(key)) {
|
|
88
|
+
seenUnmappedKeys.add(key);
|
|
89
|
+
console.log(
|
|
90
|
+
`[plugin-aeo] llmsTxt: instance "${key}" is not in instanceSections; routing to "Other"`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sections = [];
|
|
97
|
+
const ordered = [...mapped.entries()]
|
|
98
|
+
.map(([key, v]) => ({
|
|
99
|
+
key,
|
|
100
|
+
title: v.title,
|
|
101
|
+
order: typeof v.order === 'number' ? v.order : Number.POSITIVE_INFINITY,
|
|
102
|
+
items: v.items,
|
|
103
|
+
}))
|
|
104
|
+
.sort((a, b) => {
|
|
105
|
+
if (a.order !== b.order) return a.order - b.order;
|
|
106
|
+
return a.title.localeCompare(b.title);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
for (const s of ordered) {
|
|
110
|
+
if (s.items.length === 0) continue;
|
|
111
|
+
s.items.sort((a, b) => a.permalink.localeCompare(b.permalink));
|
|
112
|
+
sections.push({ key: s.key, title: s.title, items: s.items });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (unmapped.length > 0) {
|
|
116
|
+
// Spec: unmapped items go in a single "Other" section, sorted by
|
|
117
|
+
// page title (alphabetical). Stable tiebreak on permalink.
|
|
118
|
+
unmapped.sort((a, b) => {
|
|
119
|
+
const at = a.title || a.permalink;
|
|
120
|
+
const bt = b.title || b.permalink;
|
|
121
|
+
const cmp = at.localeCompare(bt);
|
|
122
|
+
return cmp !== 0 ? cmp : a.permalink.localeCompare(b.permalink);
|
|
123
|
+
});
|
|
124
|
+
sections.push({ key: '__other__', title: 'Other', items: unmapped });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return sections;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Default path - per-type grouping, identical to v0.1.x behavior.
|
|
131
|
+
const groups = { docs: [], blog: [], pages: [] };
|
|
132
|
+
for (const item of filtered) {
|
|
133
|
+
const g = groupKind(item.kind, item.pluginId);
|
|
134
|
+
groups[g].push(item);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const sections = [];
|
|
138
|
+
const order = [
|
|
139
|
+
['docs', options.sections.docs],
|
|
140
|
+
['blog', options.sections.blog],
|
|
141
|
+
['pages', options.sections.pages],
|
|
142
|
+
];
|
|
143
|
+
for (const [key, title] of order) {
|
|
144
|
+
const items = groups[key];
|
|
145
|
+
if (!items || items.length === 0) continue;
|
|
146
|
+
items.sort((a, b) => a.permalink.localeCompare(b.permalink));
|
|
147
|
+
sections.push({ key, title, items });
|
|
148
|
+
}
|
|
149
|
+
return sections;
|
|
150
|
+
}
|
|
151
|
+
|
|
55
152
|
module.exports = async function emitLlmsTxt({
|
|
56
153
|
props,
|
|
57
154
|
options,
|
|
@@ -73,12 +170,7 @@ module.exports = async function emitLlmsTxt({
|
|
|
73
170
|
return true;
|
|
74
171
|
});
|
|
75
172
|
|
|
76
|
-
|
|
77
|
-
const groups = { docs: [], blog: [], pages: [] };
|
|
78
|
-
for (const item of filtered) {
|
|
79
|
-
const g = groupKind(item.kind, item.pluginId);
|
|
80
|
-
groups[g].push(item);
|
|
81
|
-
}
|
|
173
|
+
const sections = buildSections(filtered, options, verbose);
|
|
82
174
|
|
|
83
175
|
// Build llms.txt.
|
|
84
176
|
const lines = [];
|
|
@@ -93,20 +185,10 @@ module.exports = async function emitLlmsTxt({
|
|
|
93
185
|
lines.push('');
|
|
94
186
|
}
|
|
95
187
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
['blog', options.sections.blog],
|
|
99
|
-
['pages', options.sections.pages],
|
|
100
|
-
];
|
|
101
|
-
|
|
102
|
-
for (const [key, title] of sectionOrder) {
|
|
103
|
-
const items = groups[key];
|
|
104
|
-
if (!items || items.length === 0) continue;
|
|
105
|
-
lines.push(`## ${title}`);
|
|
188
|
+
for (const section of sections) {
|
|
189
|
+
lines.push(`## ${section.title}`);
|
|
106
190
|
lines.push('');
|
|
107
|
-
|
|
108
|
-
items.sort((a, b) => a.permalink.localeCompare(b.permalink));
|
|
109
|
-
for (const item of items) {
|
|
191
|
+
for (const item of section.items) {
|
|
110
192
|
const url = companionsEnabled
|
|
111
193
|
? companionUrl(item.permalink, trailingSlash)
|
|
112
194
|
: item.permalink;
|
|
@@ -128,7 +210,8 @@ module.exports = async function emitLlmsTxt({
|
|
|
128
210
|
}
|
|
129
211
|
|
|
130
212
|
// Build llms-full.txt by concatenating the companion files that we just
|
|
131
|
-
// emitted in feature 1. Requires feature 1 to have been enabled.
|
|
213
|
+
// emitted in feature 1. Requires feature 1 to have been enabled. Section
|
|
214
|
+
// headings mirror llms.txt so an LLM can navigate the corpus by H2.
|
|
132
215
|
if (options.fullTxt) {
|
|
133
216
|
if (!companionsEnabled) {
|
|
134
217
|
if (verbose) {
|
|
@@ -139,11 +222,10 @@ module.exports = async function emitLlmsTxt({
|
|
|
139
222
|
return;
|
|
140
223
|
}
|
|
141
224
|
|
|
142
|
-
const
|
|
143
|
-
for (const
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
for (const item of items) {
|
|
225
|
+
const sectionChunks = [];
|
|
226
|
+
for (const section of sections) {
|
|
227
|
+
const blocks = [];
|
|
228
|
+
for (const item of section.items) {
|
|
147
229
|
let body;
|
|
148
230
|
try {
|
|
149
231
|
body = await fs.readFile(item.companionPath, 'utf8');
|
|
@@ -164,10 +246,12 @@ module.exports = async function emitLlmsTxt({
|
|
|
164
246
|
].join('\n');
|
|
165
247
|
blocks.push(`${header}${body.trim()}\n`);
|
|
166
248
|
}
|
|
249
|
+
if (blocks.length === 0) continue;
|
|
250
|
+
sectionChunks.push(`## ${section.title}\n\n${blocks.join('\n---\n\n')}`);
|
|
167
251
|
}
|
|
168
252
|
|
|
169
253
|
const fullPath = path.join(outDir, 'llms-full.txt');
|
|
170
|
-
await fs.writeFile(fullPath,
|
|
254
|
+
await fs.writeFile(fullPath, sectionChunks.join('\n---\n\n'), 'utf8');
|
|
171
255
|
if (verbose) {
|
|
172
256
|
console.log(`[plugin-aeo] llmsTxt: wrote ${fullPath}`);
|
|
173
257
|
}
|
package/src/index.js
CHANGED
|
@@ -41,6 +41,10 @@ function normalizeOptions(raw) {
|
|
|
41
41
|
blog: llmsSections.blog || 'Blog',
|
|
42
42
|
pages: llmsSections.pages || 'Pages',
|
|
43
43
|
},
|
|
44
|
+
// Per-instance section map. Keys are "${pluginName}@${pluginId}".
|
|
45
|
+
// When set, supersedes `sections` and switches feature 2 from
|
|
46
|
+
// per-type grouping to per-instance grouping.
|
|
47
|
+
instanceSections: llmsTxt.instanceSections || null,
|
|
44
48
|
fullTxt: llmsTxt.fullTxt !== false,
|
|
45
49
|
},
|
|
46
50
|
askAi: {
|
|
@@ -77,6 +81,36 @@ function validateOptions(opts) {
|
|
|
77
81
|
if (opts.llmsTxt.header !== null && typeof opts.llmsTxt.header !== 'string') {
|
|
78
82
|
errs.push('llmsTxt.header must be a string or null');
|
|
79
83
|
}
|
|
84
|
+
if (opts.llmsTxt.instanceSections !== null) {
|
|
85
|
+
const is = opts.llmsTxt.instanceSections;
|
|
86
|
+
if (typeof is !== 'object' || Array.isArray(is)) {
|
|
87
|
+
errs.push(
|
|
88
|
+
'llmsTxt.instanceSections must be an object keyed by "${pluginName}@${pluginId}" or undefined',
|
|
89
|
+
);
|
|
90
|
+
} else {
|
|
91
|
+
for (const [key, value] of Object.entries(is)) {
|
|
92
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
93
|
+
errs.push(
|
|
94
|
+
`llmsTxt.instanceSections["${key}"] must be an object with { title, order? }`,
|
|
95
|
+
);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (typeof value.title !== 'string' || value.title.length === 0) {
|
|
99
|
+
errs.push(
|
|
100
|
+
`llmsTxt.instanceSections["${key}"].title must be a non-empty string`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
value.order !== undefined &&
|
|
105
|
+
(typeof value.order !== 'number' || !Number.isFinite(value.order))
|
|
106
|
+
) {
|
|
107
|
+
errs.push(
|
|
108
|
+
`llmsTxt.instanceSections["${key}"].order must be a finite number when set`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
80
114
|
|
|
81
115
|
if (!Array.isArray(opts.askAi.providerOrder)) {
|
|
82
116
|
errs.push('askAi.providerOrder must be an array');
|