@stackql/docusaurus-plugin-aeo 0.1.2 → 0.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.
- package/CHANGELOG.md +29 -0
- package/README.md +96 -11
- package/package.json +3 -2
- package/src/features/llmsTxt.js +110 -26
- package/src/index.js +40 -3
- package/src/theme/AskAiButton/icons.js +50 -33
- package/src/theme/AskAiButton/index.jsx +15 -1
- package/src/theme/AskAiButton/styles.module.css +35 -22
- package/src/theme/BlogPostItem/Header/Title/index.jsx +19 -0
- package/src/theme/BlogPostItem/Header/Title/styles.module.css +8 -0
- package/src/theme/DocBreadcrumbs/index.jsx +17 -0
- package/src/theme/DocBreadcrumbs/styles.module.css +8 -0
- package/src/theme/BlogPostItem/Footer/index.jsx +0 -15
- package/src/theme/DocItem/Footer/index.jsx +0 -17
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
Focused UX upgrade to the Ask AI button (feature 3). No changes to features 1, 2, or 4.
|
|
6
|
+
|
|
7
|
+
### Changed (breaking)
|
|
8
|
+
|
|
9
|
+
- Default Ask AI button placement moved from doc / blog footer to the breadcrumb row at the top of each page. On doc pages the button sits right-aligned in the breadcrumb row; on blog posts (which have no breadcrumbs) it sits right-aligned above the post title.
|
|
10
|
+
- `askAi.placement` no longer accepts `'doc-footer'`. Valid values are `'breadcrumb-row'` (the new default) and `'none'`. The v0.1.x-v0.2.x `DocItem/Footer` and `BlogPostItem/Footer` swizzles have been deleted; there is no config flag that restores them. Consumers who want footer placement (or any other custom location) should set `askAi.placement: 'none'` and swizzle `@theme/AskAiButton` manually.
|
|
11
|
+
- Ask AI button restyled as a solid pill dropdown (themed background, rounded-full corners, animated caret). The trigger reads "Ask AI about this page". The menu is a right-aligned card with rounded corners and a subtle shadow.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Provider icons replaced with real brand SVGs sourced from [simple-icons](https://simpleicons.org) where available - Claude, Perplexity, Gemini. OpenAI / ChatGPT was removed from simple-icons in 2024 following a takedown; the ChatGPT menu row renders a neutral chat-bubble glyph instead. No build failure; the missing icon is logged as a warning when verbose.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `simple-icons` (`^16.22.0`, CC0-1.0) as a runtime dependency. Bundle-size impact is bounded by webpack tree-shaking: the icons module uses named ESM imports so only the three referenced icons end up in the production bundle.
|
|
20
|
+
|
|
21
|
+
Even though placement changes are breaking, this is 0.3.0 (not 1.0.0) - the project is still pre-1.0 and the README never promised a stable placement option set.
|
|
22
|
+
|
|
23
|
+
### Migration
|
|
24
|
+
|
|
25
|
+
Bump the plugin version. If `askAi.placement: 'doc-footer'` is set in plugin options, remove it (or change it to `'breadcrumb-row'` for explicitness). No other config changes needed. After rebuild, the button appears at the top of each doc and blog page.
|
|
26
|
+
|
|
27
|
+
## 0.2.0
|
|
28
|
+
|
|
29
|
+
- 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`.
|
|
30
|
+
- 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.
|
|
31
|
+
|
|
3
32
|
## 0.1.2
|
|
4
33
|
|
|
5
34
|
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,14 +93,89 @@ 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
|
-
A
|
|
173
|
+
A solid pill dropdown reading "Ask AI about this page" is injected at the top of every doc and blog-post content area, right-aligned in the breadcrumb row. Each item in the dropdown opens the corresponding AI surface in a new tab with a prefilled prompt that references the current page's `.md` companion.
|
|
174
|
+
|
|
175
|
+
Placement specifics:
|
|
176
|
+
|
|
177
|
+
- **Doc pages** - the button sits on the same row as the breadcrumb trail, flex-aligned to the right edge of the content area.
|
|
178
|
+
- **Blog posts** - blog posts have no breadcrumbs, so the button sits at the very top of the post, right-aligned above the post title (the closest visual equivalent to the docs breadcrumb-row position).
|
|
104
179
|
|
|
105
180
|
Providers and URL patterns:
|
|
106
181
|
|
|
@@ -119,6 +194,10 @@ Read https://your-site.example/path/to/page.md and help me with the following qu
|
|
|
119
194
|
|
|
120
195
|
The trailing space is intentional - the cursor lands ready for the user to type.
|
|
121
196
|
|
|
197
|
+
Provider icons are real brand SVGs sourced from [simple-icons](https://simpleicons.org). Three of four (Claude, Perplexity, Gemini) come from the upstream catalog directly. OpenAI / ChatGPT was removed from simple-icons in 2024 following a takedown, so the ChatGPT row shows a neutral chat-bubble glyph as a placeholder.
|
|
198
|
+
|
|
199
|
+
**Trademark note.** Brand logos are trademarks of their respective owners; their inclusion via simple-icons does not imply endorsement. Consumers using the Ask AI button in commercial contexts should review each provider's brand-usage policy.
|
|
200
|
+
|
|
122
201
|
Options:
|
|
123
202
|
|
|
124
203
|
| Option | Default | Description |
|
|
@@ -126,17 +205,19 @@ Options:
|
|
|
126
205
|
| `askAi.enabled` | `true` | When `false`, the theme components are not registered. |
|
|
127
206
|
| `askAi.providerOrder` | `['claude', 'chatgpt', 'perplexity', 'gemini']` | Order of items in the dropdown. Drop entries to hide them. |
|
|
128
207
|
| `askAi.promptTemplate` | `'Read {pageUrl}.md and help me with the following question about it: '` | Prompt sent to each provider. `{pageUrl}` is the page's canonical URL (no trailing slash). If feature 1 is disabled, the consumer should remove the `.md` from the template. |
|
|
129
|
-
| `askAi.placement` | `'
|
|
208
|
+
| `askAi.placement` | `'breadcrumb-row'` | `'breadcrumb-row'` puts the button at the top of every doc/blog page (docs breadcrumb row, or above the blog title). `'none'` does not register any theme components - swizzle the button into your preferred location manually. |
|
|
130
209
|
|
|
131
|
-
Styling uses CSS modules and reads from Docusaurus's theme tokens (`--ifm-color-primary`, etc.) so dark/light mode work automatically.
|
|
210
|
+
Styling uses CSS modules and reads from Docusaurus's theme tokens (`--ifm-color-primary`, `--ifm-color-primary-darker`, `--ifm-color-emphasis-300`, etc.) so dark/light mode work automatically without extra rules.
|
|
132
211
|
|
|
133
|
-
|
|
212
|
+
### Customizing placement
|
|
134
213
|
|
|
135
|
-
|
|
136
|
-
|
|
214
|
+
To put the button somewhere other than the breadcrumb row - sidebar, header, a specific page region - set `askAi.placement: 'none'` to disable the bundled swizzles, then import and place the component manually wherever you want:
|
|
215
|
+
|
|
216
|
+
```jsx
|
|
217
|
+
import AskAiButton from '@theme/AskAiButton';
|
|
137
218
|
```
|
|
138
219
|
|
|
139
|
-
|
|
220
|
+
Wrap an existing theme component (e.g. `@theme/Layout`) the same way Docusaurus documents for any swizzle.
|
|
140
221
|
|
|
141
222
|
## Feature 4: `/ai/*` routing convention
|
|
142
223
|
|
|
@@ -255,11 +336,15 @@ The `sitemapExclude` helper, by contrast, does work with globs because `@docusau
|
|
|
255
336
|
],
|
|
256
337
|
include: null, // null = all not-excluded
|
|
257
338
|
header: null, // optional string prepended to llms.txt
|
|
258
|
-
sections: {
|
|
259
|
-
docs: 'Documentation',
|
|
339
|
+
sections: { // per-type section titles (used when
|
|
340
|
+
docs: 'Documentation', // instanceSections is null)
|
|
260
341
|
blog: 'Blog',
|
|
261
342
|
pages: 'Pages',
|
|
262
343
|
},
|
|
344
|
+
instanceSections: null, // per-instance section map, supersedes
|
|
345
|
+
// sections when set. Shape:
|
|
346
|
+
// { '${pluginName}@${pluginId}':
|
|
347
|
+
// { title: string, order?: number } }
|
|
263
348
|
fullTxt: true, // emit llms-full.txt
|
|
264
349
|
},
|
|
265
350
|
|
|
@@ -268,7 +353,7 @@ The `sitemapExclude` helper, by contrast, does work with globs because `@docusau
|
|
|
268
353
|
enabled: true,
|
|
269
354
|
providerOrder: ['claude', 'chatgpt', 'perplexity', 'gemini'],
|
|
270
355
|
promptTemplate: 'Read {pageUrl}.md and help me with the following question about it: ',
|
|
271
|
-
placement: '
|
|
356
|
+
placement: 'breadcrumb-row', // 'breadcrumb-row' | 'none'
|
|
272
357
|
},
|
|
273
358
|
|
|
274
359
|
// Feature 4
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackql/docusaurus-plugin-aeo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": {
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"gray-matter": "^4.0.3"
|
|
28
|
+
"gray-matter": "^4.0.3",
|
|
29
|
+
"simple-icons": "^16.22.0"
|
|
29
30
|
},
|
|
30
31
|
"overrides": {
|
|
31
32
|
"serialize-javascript": "^7.0.5",
|
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,13 +41,17 @@ 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: {
|
|
47
51
|
enabled: askAi.enabled !== false,
|
|
48
52
|
providerOrder: askAi.providerOrder || DEFAULT_PROVIDER_ORDER,
|
|
49
53
|
promptTemplate: askAi.promptTemplate || DEFAULT_PROMPT_TEMPLATE,
|
|
50
|
-
placement: askAi.placement || '
|
|
54
|
+
placement: askAi.placement || 'breadcrumb-row',
|
|
51
55
|
},
|
|
52
56
|
aiRoutes: {
|
|
53
57
|
validate: aiRoutes.validate === true,
|
|
@@ -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');
|
|
@@ -92,9 +126,12 @@ function validateOptions(opts) {
|
|
|
92
126
|
if (typeof opts.askAi.promptTemplate !== 'string') {
|
|
93
127
|
errs.push('askAi.promptTemplate must be a string');
|
|
94
128
|
}
|
|
95
|
-
if (!['
|
|
129
|
+
if (!['breadcrumb-row', 'none'].includes(opts.askAi.placement)) {
|
|
130
|
+
// 'doc-footer' was the v0.1.x-v0.2.x default and is no longer
|
|
131
|
+
// accepted in v0.3.0. The wrappers that implemented it have been
|
|
132
|
+
// removed. See CHANGELOG for migration notes.
|
|
96
133
|
errs.push(
|
|
97
|
-
`askAi.placement must be "
|
|
134
|
+
`askAi.placement must be "breadcrumb-row" or "none", got "${opts.askAi.placement}"`,
|
|
98
135
|
);
|
|
99
136
|
}
|
|
100
137
|
|
|
@@ -1,55 +1,72 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Brand icons for the four supported AI providers.
|
|
2
|
+
//
|
|
3
|
+
// Three of four come from simple-icons (siClaude, siPerplexity,
|
|
4
|
+
// siGooglegemini). OpenAI / ChatGPT was removed from simple-icons in
|
|
5
|
+
// 2024 following a takedown, so for that one we ship a neutral inline
|
|
6
|
+
// chat-bubble glyph. The same fallback path is reused for any future
|
|
7
|
+
// icon that disappears from upstream.
|
|
8
|
+
//
|
|
9
|
+
// All icons render as a 24x24 viewBox SVG with `fill="currentColor"`,
|
|
10
|
+
// so the menu CSS can size them via font-size or width/height and tint
|
|
11
|
+
// them via the surrounding text color. ESM `import` is used so webpack
|
|
12
|
+
// can tree-shake unused simple-icons exports out of the bundle.
|
|
4
13
|
|
|
5
|
-
|
|
14
|
+
import React from 'react';
|
|
15
|
+
import { siClaude, siPerplexity, siGooglegemini } from 'simple-icons';
|
|
6
16
|
|
|
7
|
-
function svg(children
|
|
17
|
+
function svg(children) {
|
|
8
18
|
return React.createElement(
|
|
9
19
|
'svg',
|
|
10
20
|
{
|
|
11
21
|
xmlns: 'http://www.w3.org/2000/svg',
|
|
12
22
|
width: '1em',
|
|
13
23
|
height: '1em',
|
|
14
|
-
viewBox,
|
|
24
|
+
viewBox: '0 0 24 24',
|
|
15
25
|
fill: 'currentColor',
|
|
26
|
+
role: 'img',
|
|
16
27
|
'aria-hidden': 'true',
|
|
17
28
|
},
|
|
18
29
|
children,
|
|
19
30
|
);
|
|
20
31
|
}
|
|
21
32
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
function fromSimpleIcons(si) {
|
|
34
|
+
if (!si || typeof si.path !== 'string') {
|
|
35
|
+
return GenericAiIcon;
|
|
36
|
+
}
|
|
37
|
+
return function BrandIcon() {
|
|
38
|
+
return svg(React.createElement('path', { d: si.path }));
|
|
39
|
+
};
|
|
40
|
+
}
|
|
28
41
|
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
// Neutral chat-bubble glyph - used when a brand icon isn't available
|
|
43
|
+
// upstream. Not a brand mark, no trademark concerns.
|
|
44
|
+
function GenericAiIcon() {
|
|
45
|
+
return svg(
|
|
31
46
|
React.createElement('path', {
|
|
32
|
-
d: '
|
|
47
|
+
d: 'M4 4h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-8l-5 4v-4H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm3 6h2v2H7v-2Zm4 0h2v2h-2v-2Zm4 0h2v2h-2v-2Z',
|
|
33
48
|
}),
|
|
34
49
|
);
|
|
50
|
+
}
|
|
35
51
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
const icons = {
|
|
53
|
+
claude: fromSimpleIcons(siClaude),
|
|
54
|
+
// simple-icons dropped OpenAI/ChatGPT in 2024; fall back to a neutral
|
|
55
|
+
// chat-bubble glyph rather than failing the build or shipping a guess
|
|
56
|
+
// at the wordmark.
|
|
57
|
+
chatgpt: GenericAiIcon,
|
|
58
|
+
perplexity: fromSimpleIcons(siPerplexity),
|
|
59
|
+
gemini: fromSimpleIcons(siGooglegemini),
|
|
60
|
+
};
|
|
42
61
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
if (typeof console !== 'undefined') {
|
|
63
|
+
for (const [name, icon] of Object.entries(icons)) {
|
|
64
|
+
if (icon === GenericAiIcon && name !== 'chatgpt') {
|
|
65
|
+
console.warn(
|
|
66
|
+
`[plugin-aeo] AskAiButton: brand icon for "${name}" missing from simple-icons; using generic AI glyph`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
49
71
|
|
|
50
|
-
|
|
51
|
-
claude: ClaudeIcon,
|
|
52
|
-
chatgpt: ChatGptIcon,
|
|
53
|
-
perplexity: PerplexityIcon,
|
|
54
|
-
gemini: GeminiIcon,
|
|
55
|
-
};
|
|
72
|
+
export default icons;
|
|
@@ -81,7 +81,21 @@ export default function AskAiButton(props) {
|
|
|
81
81
|
onClick={toggle}
|
|
82
82
|
>
|
|
83
83
|
<span>Ask AI about this page</span>
|
|
84
|
-
<span
|
|
84
|
+
<span
|
|
85
|
+
className={`${styles.caret}${open ? ` ${styles.caretOpen}` : ''}`}
|
|
86
|
+
aria-hidden="true"
|
|
87
|
+
>
|
|
88
|
+
{/* Inline chevron-down so we don't depend on a font glyph. */}
|
|
89
|
+
<svg
|
|
90
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
91
|
+
width="0.75em"
|
|
92
|
+
height="0.75em"
|
|
93
|
+
viewBox="0 0 24 24"
|
|
94
|
+
fill="currentColor"
|
|
95
|
+
>
|
|
96
|
+
<path d="M6 9l6 6 6-6H6z" />
|
|
97
|
+
</svg>
|
|
98
|
+
</span>
|
|
85
99
|
</button>
|
|
86
100
|
{open && (
|
|
87
101
|
<ul className={styles.menu} role="menu">
|
|
@@ -1,50 +1,58 @@
|
|
|
1
1
|
.wrapper {
|
|
2
2
|
position: relative;
|
|
3
3
|
display: inline-block;
|
|
4
|
-
margin: 1.5rem 0;
|
|
5
4
|
}
|
|
6
5
|
|
|
7
6
|
.trigger {
|
|
8
7
|
display: inline-flex;
|
|
9
8
|
align-items: center;
|
|
10
9
|
gap: 0.5rem;
|
|
11
|
-
padding: 0.
|
|
10
|
+
padding: 0.4rem 0.9rem;
|
|
12
11
|
background: var(--ifm-color-primary);
|
|
13
12
|
color: var(--ifm-color-primary-contrast-foreground, #fff);
|
|
14
|
-
border:
|
|
15
|
-
border-radius:
|
|
16
|
-
font:
|
|
17
|
-
font-weight:
|
|
13
|
+
border: none;
|
|
14
|
+
border-radius: 999px;
|
|
15
|
+
font-size: 0.875rem;
|
|
16
|
+
font-weight: 500;
|
|
17
|
+
line-height: 1.2;
|
|
18
18
|
cursor: pointer;
|
|
19
|
-
|
|
19
|
+
white-space: nowrap;
|
|
20
|
+
transition: background-color 0.15s ease;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
.trigger:hover {
|
|
23
|
-
background: var(--ifm-color-primary-dark);
|
|
24
|
+
background: var(--ifm-color-primary-darker, var(--ifm-color-primary-dark));
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
.trigger:
|
|
27
|
-
|
|
27
|
+
.trigger:focus-visible {
|
|
28
|
+
outline: 2px solid var(--ifm-color-primary-light, currentColor);
|
|
29
|
+
outline-offset: 2px;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
.caret {
|
|
33
|
+
display: inline-block;
|
|
31
34
|
font-size: 0.7em;
|
|
32
|
-
opacity: 0.
|
|
35
|
+
opacity: 0.9;
|
|
36
|
+
transition: transform 0.15s ease;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.caretOpen {
|
|
40
|
+
transform: rotate(180deg);
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
.menu {
|
|
36
44
|
position: absolute;
|
|
37
|
-
top: calc(100% + 0.
|
|
38
|
-
|
|
39
|
-
z-index:
|
|
40
|
-
min-width:
|
|
45
|
+
top: calc(100% + 0.4rem);
|
|
46
|
+
right: 0;
|
|
47
|
+
z-index: 100;
|
|
48
|
+
min-width: 200px;
|
|
41
49
|
margin: 0;
|
|
42
50
|
padding: 0.25rem 0;
|
|
43
51
|
list-style: none;
|
|
44
52
|
background: var(--ifm-background-surface-color, var(--ifm-background-color));
|
|
45
53
|
border: 1px solid var(--ifm-color-emphasis-300);
|
|
46
|
-
border-radius:
|
|
47
|
-
box-shadow: 0
|
|
54
|
+
border-radius: 0.5rem;
|
|
55
|
+
box-shadow: 0 6px 24px -8px rgba(0, 0, 0, 0.18);
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
.item {
|
|
@@ -54,21 +62,26 @@
|
|
|
54
62
|
.itemLink {
|
|
55
63
|
display: flex;
|
|
56
64
|
align-items: center;
|
|
57
|
-
gap: 0.
|
|
65
|
+
gap: 0.65rem;
|
|
58
66
|
padding: 0.5rem 0.85rem;
|
|
59
|
-
color: var(--ifm-font-color-base);
|
|
67
|
+
color: var(--ifm-color-content, var(--ifm-font-color-base));
|
|
60
68
|
text-decoration: none;
|
|
61
|
-
font-size: 0.
|
|
69
|
+
font-size: 0.875rem;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
.itemLink:hover {
|
|
65
73
|
background: var(--ifm-color-emphasis-100);
|
|
74
|
+
color: var(--ifm-color-content, var(--ifm-font-color-base));
|
|
66
75
|
text-decoration: none;
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
.icon {
|
|
70
79
|
display: inline-flex;
|
|
71
80
|
align-items: center;
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
justify-content: center;
|
|
82
|
+
width: 18px;
|
|
83
|
+
height: 18px;
|
|
84
|
+
font-size: 18px;
|
|
85
|
+
color: var(--ifm-color-content, var(--ifm-font-color-base));
|
|
86
|
+
flex: 0 0 auto;
|
|
74
87
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import Title from '@theme-init/BlogPostItem/Header/Title';
|
|
3
|
+
import AskAiButton from '@theme/AskAiButton';
|
|
4
|
+
import styles from './styles.module.css';
|
|
5
|
+
|
|
6
|
+
// Mirror the docs breadcrumb-row placement: render a flex row above the
|
|
7
|
+
// post title with the Ask AI button right-aligned. Blog posts have no
|
|
8
|
+
// breadcrumbs, so the title block is the closest visual analog to the
|
|
9
|
+
// docs "above the H1, top of content" position.
|
|
10
|
+
export default function TitleWrapper(props) {
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<div className={styles.row}>
|
|
14
|
+
<AskAiButton />
|
|
15
|
+
</div>
|
|
16
|
+
<Title {...props} />
|
|
17
|
+
</>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
// See note in the AskAiButton swizzle: @theme-init resolves to the
|
|
3
|
+
// initial component from the theme chain (theme-classic), avoiding the
|
|
4
|
+
// recursion that @theme-original produces when a plugin (not a theme)
|
|
5
|
+
// is the only contributor in the wrapper layer.
|
|
6
|
+
import DocBreadcrumbs from '@theme-init/DocBreadcrumbs';
|
|
7
|
+
import AskAiButton from '@theme/AskAiButton';
|
|
8
|
+
import styles from './styles.module.css';
|
|
9
|
+
|
|
10
|
+
export default function DocBreadcrumbsWrapper(props) {
|
|
11
|
+
return (
|
|
12
|
+
<div className={styles.row}>
|
|
13
|
+
<DocBreadcrumbs {...props} />
|
|
14
|
+
<AskAiButton />
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
// See note in src/theme/DocItem/Footer/index.jsx - @theme-init avoids the
|
|
3
|
-
// SSR recursion that @theme-original triggers when a plugin (not a theme)
|
|
4
|
-
// contributes the wrapper.
|
|
5
|
-
import Footer from '@theme-init/BlogPostItem/Footer';
|
|
6
|
-
import AskAiButton from '@theme/AskAiButton';
|
|
7
|
-
|
|
8
|
-
export default function FooterWrapper(props) {
|
|
9
|
-
return (
|
|
10
|
-
<>
|
|
11
|
-
<AskAiButton />
|
|
12
|
-
<Footer {...props} />
|
|
13
|
-
</>
|
|
14
|
-
);
|
|
15
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
// Use @theme-init (the initial component from the theme chain, before any
|
|
3
|
-
// wrappers) instead of @theme-original. When a plugin contributes both the
|
|
4
|
-
// wrapper and is the only contributor in the wrapper layer,
|
|
5
|
-
// @theme-original/X resolves back to this wrapper file and renders infinitely
|
|
6
|
-
// on every SSR pass. @theme-init/X always resolves to the un-wrapped origin.
|
|
7
|
-
import Footer from '@theme-init/DocItem/Footer';
|
|
8
|
-
import AskAiButton from '@theme/AskAiButton';
|
|
9
|
-
|
|
10
|
-
export default function FooterWrapper(props) {
|
|
11
|
-
return (
|
|
12
|
-
<>
|
|
13
|
-
<AskAiButton />
|
|
14
|
-
<Footer {...props} />
|
|
15
|
-
</>
|
|
16
|
-
);
|
|
17
|
-
}
|