@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 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.1.2",
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": {
@@ -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
- // Group by kind.
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 sectionOrder = [
97
- ['docs', options.sections.docs],
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
- // Stable order: by permalink.
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 blocks = [];
143
- for (const [key] of sectionOrder) {
144
- const items = groups[key];
145
- if (!items) continue;
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, blocks.join('\n---\n\n'), 'utf8');
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');