@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 CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ Plain-markdown companions, one rule for where a companion lives, and the companion advertised from its page. Raised against stackql.io, where the Ask AI prompt on the homepage read `https://stackql.io.md` and every `.md` URL returned raw MDX (imports, MUI components, tab definitions and inline SVG path data before the first sentence), and from an independent agent-readiness audit of the built site (alternate link, `llms-full.txt` reference and size, `Source:` URLs, the `how-tos` alias).
6
+
7
+ ### Changed (breaking)
8
+
9
+ - **`companions.format` now defaults to `'plain'`.** The companion is plain markdown rendered from the MDX source rather than the source itself. The source is parsed with the remark toolchain Docusaurus compiles it with (`remark-mdx`, `remark-gfm`, `remark-directive`, `@slorber/remark-comment`) to locate every MDX construct, and the original text is edited by source position: imports, exports and `{expressions}` are removed; presentational elements (`svg`, `video`, `iframe`, icons) are dropped; `<Tabs>`/`<TabItem>` become a bold label per tab with the tab's content under it (the label is read from the `label` attribute or from the `<Tabs values={[...]}>` expression); `<a>`, `<img>`, `<b>`, `<i>`, `<code>`, `<h1>`-`<h6>`, `<br>`, `<details>`/`<summary>` become their markdown equivalent; a self-closing component that carries a link (`to`/`href` plus `text`/`label`/`title`) becomes a markdown link; every other element is unwrapped and the indentation its author added inside it is removed. HTML comments (including `<!-- truncate -->`) and `{#custom-id}` heading ids are removed; fenced code is never touched. Nothing outside MDX syntax is re-stringified, so `snake_case` stays unescaped and tables, admonitions and code fences are byte for byte the author's. A `# Title` / `> description` block from the page metadata is prepended (the title only when the body does not already open with an H1). A source that fails to parse falls back to the v0.4 regex stripper with a build warning. `format: md` files (front matter or `siteConfig.markdown.format`) are parsed as CommonMark and their raw HTML stripped. Set `companions: { format: 'raw' }` to keep emitting the source.
10
+ - **Default `askAi.promptTemplate` is `'Read {companionUrl} and help me understand it. ...'`.** `{companionUrl}` is the page's `.md` file by the same rule feature 1 writes it. `{pageUrl}` is unchanged (the page, no trailing slash). A custom template still using the v0.4 idiom `{pageUrl}.md` keeps working: that sequence now resolves to `{companionUrl}` instead of being computed from the pathname, which produced `https://site.md` on the homepage and `/foo.md` on sites that build `/foo/index.html`.
11
+ - **The Ask AI button is only rendered on pages that have a companion.** Generated category index pages, React pages and blog list pages have no source markdown and so no `.md` file; the button used to point readers at a 404 there. The route list is set as plugin global data from `allContentLoaded` (Docusaurus 3 merges it with the `contentLoaded` data); if it is absent the button shows everywhere as before. With `companions.enabled: false` the button shows everywhere and the prompt uses the page URL.
12
+ - **`llms.txt` links are absolute** (`https://site/foo.md`), as the llms.txt spec's examples are. The file is read away from the site, where a relative link has no base.
13
+
14
+ ### Added
15
+
16
+ - **`<link rel="alternate" type="text/markdown" href="...">` in every page that has a companion** (`companions.alternateLink`, default `true`), inserted into the built HTML in `postBuild`, with the absolute companion URL from the same rule the file is written by. `companions.linkHeader` (default `false`) also writes the relation as a `Link:` header per route in a Netlify-style `_headers` file, appended to one the site already ships.
17
+ - **`## Optional` section in `llms.txt`** linking `llms-full.txt` (`llmsTxt.linkFullTxt`, default `true`), the llmstxt.org convention for secondary resources.
18
+ - **`llmsTxt.fullTxt` object form** `{ include: ['<plugin>@<id>', ...], maxBytes }` to limit `llms-full.txt` to chosen content instances and/or a byte budget; the file stops before the page that would cross the cap. The boolean form is unchanged (`true` = everything).
19
+ - Plain companions carry a `Source: <page URL>` line under the title and description, and `llms-full.txt` reuses it rather than adding a second one.
20
+ - `companions.format: 'clean'` is accepted as a synonym for `'plain'`.
21
+ - `src/features/aiRoutes.js`: `how-tos` and `how-to` directory names map to the `howTo` payload (only `howto` / `howtos` did; stackql.io uses `how-tos`).
22
+
23
+ ### Fixed
24
+
25
+ - Feature 1, feature 2 and the Ask AI button each computed the companion location on their own; the button's version was wrong for the site root and for `trailingSlash: true`/undefined sites. All three now use `src/companionPath.js`.
26
+ - `llms-full.txt` no longer doubles the page title (the plain companion already opens with it) and now puts a blank line after the `Source:` line, which used to run into the first paragraph.
27
+ - Companions are written with LF line endings regardless of the source file's.
28
+
29
+ ### Added
30
+
31
+ - `src/companionPath.js` (`companionPath`, `companionUrl`, `normalizeRoute`) and `src/askAiPrompt.js` (`fillPrompt`), plain CommonJS shared by the Node side and the theme component.
32
+ - Runtime dependencies on `unified`, `remark-parse`, `remark-mdx`, `remark-gfm`, `remark-directive` and `@slorber/remark-comment` at the versions `@docusaurus/mdx-loader` 3.x already ships, so npm dedupes them.
33
+ - A test suite (`npm test`, `node --test`): the companion path and HTML path rules, the prompt template, MDX fixtures for the converter (tabs, nested containers and dedent, link recovery, inline elements, details/summary, comments and heading ids, string-literal expressions, `md` format, the parse-failure fallback, CRLF sources), `llms.txt` rendering (sections, absolute links, the Optional block, the `fullTxt` forms), the alternate-link insertion (both `trailingSlash` layouts, idempotence, `_headers`), and the `/ai/*` directory classifier.
34
+
35
+ ## 0.4.2
36
+
37
+ ### Fixed
38
+
39
+ - **(regression in 0.4.1)** `@lobehub/icons` pins `react: ^19` and pulls `@lobehub/ui` into the import graph via `IconCombine`, which uses React 19's `use()` hook. On React 18 consumers (such as stackql.io), v0.4.1 builds crashed with `Attempted import error: 'use' is not exported from 'react' (imported as 'use')`. Resolved by dropping `@lobehub/icons` entirely and shipping three hand-rolled inline SVG components instead.
40
+
41
+ ### Removed
42
+
43
+ - **Gemini support.** `gemini.google.com/app?q=...` silently ignores the `q` parameter and opens an empty prompt box, so the "Ask Gemini" entry rendered a button that appeared to do nothing useful. There is no documented Gemini URL prefill API, and Google has not signaled one is coming. Default `askAi.providerOrder` is now `['claude', 'chatgpt', 'perplexity']`. **Breaking** for consumers with an explicit `askAi.providerOrder` containing `'gemini'`: the build will fail at plugin construction with an error that names this changelog. Remove `'gemini'` from your config.
44
+ - `@lobehub/icons` dependency.
45
+
46
+ ### Changed
47
+
48
+ - Provider icons are now hand-rolled inline SVGs co-located with the plugin source (`src/theme/AskAiButton/brand-icons/`). Bundle cost across the three components: ~3KB. Claude and Perplexity paths come from [simple-icons](https://simpleicons.org) (CC0-1.0); the OpenAI mark is sourced from the @lobehub/icons project (MIT). Brand colors are hardcoded on each path so MUI's `ListItemIcon` cannot tint them.
49
+ - Default `askAi.promptTemplate` is now self-contained: `'Read {pageUrl}.md and help me understand it. Summarize the key points, then ask me one clarifying question to dig deeper.'` Previous default required the user to type a question after a trailing colon; the new default returns a useful response on its own. Consumers can still override via `askAi.promptTemplate`.
50
+
3
51
  ## 0.4.1
4
52
 
5
53
  Cosmetic patch. No API, option-shape, behavior, or placement changes.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
+ [![NPM Version](https://img.shields.io/npm/v/%40stackql%2Fdocusaurus-plugin-aeo)](https://www.npmjs.com/package/@stackql/docusaurus-plugin-aeo)
2
+
1
3
  # @stackql/docusaurus-plugin-aeo
2
4
 
3
- AEO (Answer Engine Optimization) helpers for Docusaurus 3.x sites: emit raw `.md` companion files for every page, generate `llms.txt` and `llms-full.txt` at the site root, drop an "Ask AI" dropdown into doc and blog footers, and document a `/ai/*` routing convention for machine-readable companion content. This is a sibling to `@stackql/docusaurus-plugin-structured-data` (which emits JSON-LD); the two plugins compose without overlap.
5
+ AEO (Answer Engine Optimization) helpers for Docusaurus 3.x sites: emit plain-markdown `.md` companions for docs and blog posts, generate `llms.txt` and `llms-full.txt` at the build root, add an "Ask AI" dropdown above doc and blog content, and document a `/ai/*` routing convention for machine-readable companion content. This is a sibling to `@stackql/docusaurus-plugin-structured-data` (which emits JSON-LD); the two plugins compose without overlap.
4
6
 
5
7
  ## Installation
6
8
 
@@ -25,7 +27,7 @@ module.exports = {
25
27
  };
26
28
  ```
27
29
 
28
- All four features are on by default. To configure, pass options:
30
+ Companions, `llms.txt` / `llms-full.txt`, and the Ask AI button are enabled by default. The `/ai/*` docs instance requires site configuration, and its optional validator is off by default. To configure, pass options:
29
31
 
30
32
  ```js
31
33
  module.exports = {
@@ -35,7 +37,7 @@ module.exports = {
35
37
  {
36
38
  companions: { format: 'raw' },
37
39
  llmsTxt: { header: 'StackQL is a SQL interface to cloud and SaaS APIs.' },
38
- askAi: { providerOrder: ['claude', 'perplexity', 'chatgpt', 'gemini'] },
40
+ askAi: { providerOrder: ['claude', 'perplexity', 'chatgpt'] },
39
41
  verbose: true,
40
42
  },
41
43
  ],
@@ -45,12 +47,23 @@ module.exports = {
45
47
 
46
48
  ## Feature 1: `.md` companion files
47
49
 
48
- For every emitted HTML route from the docs and blog plugins, this plugin writes a sibling `.md` file. A page at `/docs/intro` gets a companion at `/docs/intro/index.md` (or `/docs/intro.md` if `siteConfig.trailingSlash` is `false`). The file contains the raw markdown source.
50
+ For each doc or blog post with a readable source file, this plugin writes a sibling `.md` file unless its permalink matches `companions.exclude`. The companion mirrors the HTML layout: a page at `/docs/intro` gets `/docs/intro/index.md` (or `/docs/intro.md` if `siteConfig.trailingSlash` is `false`). A doc or blog post at the site root gets `/index.md`. The same URL rule (`src/companionPath.js`) is used by `llms.txt` and the Ask AI button.
49
51
 
50
52
  Two modes:
51
53
 
52
- - `companions.format: 'raw'` (default): the MDX source is emitted as-is. MDX `<Component />` tags pass through. LLMs handle them fine, and the file is a faithful representation of the page.
53
- - `companions.format: 'plain'`: a best-effort regex pass strips `import` / `export` lines and JSX tags, then prepends a `# Title\n\n> description` block from the page frontmatter. Not a full remark pipeline - use `'raw'` unless an MDX-heavy page is causing concrete problems.
54
+ - `companions.format: 'plain'` (default since 0.5.0; `'clean'` is accepted as a synonym): plain markdown rendered from the MDX source, with a `# Title`, `> description` and `Source: <page URL>` block from the page metadata at the top, so an LLM can cite the page without parsing YAML. The converter uses remark-mdx, GFM, directives and comments to locate MDX constructs, then edits the original text by source position. It does not load the site's custom remark/rehype plugins:
55
+ - `import` / `export` statements and `{expressions}` are removed (a string literal expression keeps its value)
56
+ - presentational elements (`<svg>`, `<video>`, `<iframe>`, icons, ...) are dropped with their subtree
57
+ - `<Tabs>` / `<TabItem>` become a bold label per tab followed by the tab's content; the label comes from the `label` attribute or from the `<Tabs values={[...]}>` expression
58
+ - `<a>`, `<img>`, `<b>`, `<i>`, `<code>`, `<h2>`, `<br>`, `<details>` / `<summary>` become their markdown equivalent
59
+ - a self-closing component that plainly carries a link (`to` or `href` plus `text`, `label` or `title`) becomes a markdown link, so download buttons survive
60
+ - everything else (`<div>`, `<Box>`, `<span>`, your own layout components) is unwrapped: the tags go, the children stay, and indentation the author added inside the container is removed
61
+ - HTML comments (including `<!-- truncate -->`) and `{#custom-id}` heading ids are removed outside ordinary code fences; `mdx-code-block` fences are unwrapped and their contents are processed as markup
62
+
63
+ Markdown is not re-stringified, so `snake_case` is not escaped and Markdown syntax is retained. Output is not byte-for-byte identical: line endings become LF, surplus blank lines and outer whitespace are trimmed, leading/trailing horizontal rules are removed, and content inside unwrapped containers is dedented. An existing opening H1 is retained instead of adding another title. A Markdown/MDX parse or conversion failure uses a regex fallback and logs a warning; front matter parsing errors are not covered by that fallback.
64
+
65
+ Source format follows front matter `format`, then `siteConfig.markdown.format`; `detect` selects by extension (`.md` as Markdown, `.mdx` as MDX). Files resolved as `md` use a CommonMark parser with GFM and directives, with HTML tags stripped or converted.
66
+ - `companions.format: 'raw'`: the MDX source is emitted as-is, front matter included. MDX `<Component />` tags, imports and inline SVG data all pass through.
54
67
 
55
68
  Fetch example:
56
69
 
@@ -58,15 +71,30 @@ Fetch example:
58
71
  curl https://your-site.example/docs/intro/index.md
59
72
  ```
60
73
 
61
- **MIME type note.** Setting `Content-Type: text/markdown` from a static-site plugin is not possible. The file simply gets a `.md` extension and the host's MIME table handles it. Vercel, Netlify, Cloudflare Pages, GitHub Pages, and S3+CloudFront all serve `.md` as `text/markdown` or `text/plain` out of the box. If you serve from Nginx, ensure `text/markdown md;` is in your `mime.types`.
74
+ **MIME type note.** The plugin writes `.md` files; the host determines their `Content-Type`. Check a deployed companion with `curl -I` and configure the host if needed. The alternate link below advertises the companion as `text/markdown` but does not set its HTTP response type.
75
+
76
+ ### Advertising the companion from the page
62
77
 
63
- Non-content routes (custom React pages, redirects, the 404 page, search) are skipped silently. Enable `verbose: true` to log skips.
78
+ Every page that has a companion gets, in its `<head>`:
79
+
80
+ ```html
81
+ <link rel="alternate" type="text/markdown" href="https://your-site.example/docs/intro/index.md">
82
+ ```
83
+
84
+ inserted into the built HTML in `postBuild` (the same pattern `@stackql/docusaurus-plugin-structured-data` uses). An agent that lands on the HTML can find the markdown without guessing the URL, and agent-readiness checkers score it. The href is absolute and follows the same rule as the file on disk. Options:
85
+
86
+ | Option | Default | Description |
87
+ | --- | --- | --- |
88
+ | `companions.alternateLink` | `true` | Insert the `<link rel="alternate" type="text/markdown">` tag into each page that has a companion. |
89
+ | `companions.linkHeader` | `false` | Also write the relation as an HTTP header for Netlify-style hosts: a `_headers` file in the build root with `Link: <url>; rel="alternate"; type="text/markdown"` per route (appended to a `_headers` your `static/` already ships). Requires `companions.alternateLink: true`. |
90
+
91
+ Generated category indexes, custom React pages and blog listing pages do not get companions. `verbose: true` logs unreadable source files and missing HTML during alternate-link insertion.
64
92
 
65
93
  ## Feature 2: `llms.txt` and `llms-full.txt`
66
94
 
67
- After feature 1 emits all companions, the plugin writes two files to the build root:
95
+ With the defaults enabled, the plugin writes two files to the build root after emitting companions. Both use the emitted companion list, filtered by `llmsTxt.include` and `llmsTxt.exclude`. If companions are disabled, `llms.txt` contains only the site title, tagline and optional header; no page entries or `llms-full.txt` are emitted.
68
96
 
69
- - `llms.txt` - sectioned index of every doc/blog page, in the format described at <https://llmstxt.org>:
97
+ - `llms.txt` - sectioned index of included doc/blog companions, in the format described at <https://llmstxt.org>. Links are absolute (`https://site/foo.md`) since 0.5.0, as the spec's examples are; the file is read away from the site, where a relative link has no base:
70
98
 
71
99
  ```text
72
100
  # StackQL
@@ -75,15 +103,21 @@ After feature 1 emits all companions, the plugin writes two files to the build r
75
103
 
76
104
  ## Documentation
77
105
 
78
- - [Getting started](/docs/intro/index.md): Install StackQL and run your first query.
79
- - [Providers](/docs/providers/index.md): Catalog of supported cloud APIs.
106
+ - [Getting started](https://stackql.io/docs/intro/index.md): Install StackQL and run your first query.
107
+ - [Providers](https://stackql.io/docs/providers/index.md): Catalog of supported cloud APIs.
80
108
 
81
109
  ## Blog
82
110
 
83
- - [Querying Snowflake with StackQL](/blog/snowflake/index.md): ...
111
+ - [Querying Snowflake with StackQL](https://stackql.io/blog/snowflake/index.md): ...
112
+
113
+ ## Optional
114
+
115
+ - [Full text of every page](https://stackql.io/llms-full.txt): one file with the content of every page listed above
84
116
  ```
85
117
 
86
- - `llms-full.txt` - every companion concatenated with a `\n---\n\n` separator, each block prefixed with the page's title and URL so an LLM can cite individual sections.
118
+ The `## Optional` section is the llmstxt.org convention for secondary resources a reader can skip; it points at `llms-full.txt` whenever that file is emitted (`llmsTxt.linkFullTxt`, default `true`).
119
+
120
+ - `llms-full.txt` - the included companions concatenated with a `\n---\n\n` separator, each block carrying a `Source: <page URL>` line and the page's title (a plain companion already opens with both; a raw one has them added) so an LLM can cite individual sections. Limit it to chosen content instances or a byte budget with the object form of `llmsTxt.fullTxt` (below).
87
121
 
88
122
  Options:
89
123
 
@@ -92,16 +126,17 @@ Options:
92
126
  | `llmsTxt.enabled` | `true` | Master switch. |
93
127
  | `llmsTxt.exclude` | `["/search", "/404", "/blog/tags/**", "/blog/page/**", "/blog/archive", "/blog/authors/**"]` | Route glob patterns to skip. |
94
128
  | `llmsTxt.include` | `null` | If set, only routes matching at least one pattern are included. Use for opt-in mode. |
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. |
129
+ | `llmsTxt.header` | `null` | String inserted after the site title/tagline and before the section list. Good for a project intro paragraph or links to key external resources. |
96
130
  | `llmsTxt.sections` | `{ docs: 'Documentation', blog: 'Blog', pages: 'Pages' }` | Section title overrides for the default per-type grouping. |
97
131
  | `llmsTxt.instanceSections` | `null` | Per-content-plugin-instance section map. When set, supersedes `sections` (see below). |
98
- | `llmsTxt.fullTxt` | `true` | Emit `llms-full.txt`. |
132
+ | `llmsTxt.fullTxt` | `true` | Emit `llms-full.txt`. `true` is every section, uncapped. The object form `{ include?: string[], maxBytes?: number }` limits it: `include` names the content instances to concatenate (`'<pluginName>@<pluginId>'` keys, e.g. `'docusaurus-plugin-content-docs@ai'`; with per-type grouping the keys are `docs`, `blog`, `pages`), in `llms.txt` section order; `maxBytes` stops appending once the next page would cross the cap, so the file always ends on a whole page. The budget includes section headings and separators. If the first page exceeds it, the file is empty. An omitted or empty `include` selects all sections. |
133
+ | `llmsTxt.linkFullTxt` | `true` | Add the `## Optional` section to `llms.txt` with a link to `llms-full.txt`. |
99
134
 
100
135
  Glob patterns are matched against route permalinks. `**` matches across path segments; `*` matches within a single segment.
101
136
 
102
137
  ### Per-instance sectioning
103
138
 
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.
139
+ 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
140
 
106
141
  Set `llmsTxt.instanceSections` to switch to per-instance grouping. Keys are `"${pluginName}@${pluginId}"`; each value is `{ title, order? }`.
107
142
 
@@ -146,31 +181,35 @@ Resulting `llms.txt`:
146
181
 
147
182
  ## Documentation
148
183
 
149
- - [Getting started](/docs/intro/index.md): Install and run your first query.
150
- - [Providers](/docs/providers/index.md): Catalog of supported cloud APIs.
184
+ - [Getting started](https://your-site.example/docs/intro/index.md): Install and run your first query.
185
+ - [Providers](https://your-site.example/docs/providers/index.md): Catalog of supported cloud APIs.
151
186
 
152
187
  ## AI Reference
153
188
 
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.
189
+ - [What is StackQL](https://your-site.example/ai/faqs/what-is-stackql/index.md): Canonical one-paragraph definition.
190
+ - [Connecting to AWS](https://your-site.example/ai/howto/connect-to-aws/index.md): Step-by-step authentication.
156
191
 
157
192
  ## Blog
158
193
 
159
- - [Querying Snowflake](/blog/snowflake/index.md): ...
194
+ - [Querying Snowflake](https://your-site.example/blog/snowflake/index.md): ...
195
+
196
+ ## Optional
197
+
198
+ - [Full text of every page](https://your-site.example/llms-full.txt): one file with the content of every page listed above
160
199
  ```
161
200
 
162
201
  `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
202
 
164
203
  Behavior:
165
204
 
166
- - Sections appear in ascending `order`. Ties break alphabetically by title. Entries with no `order` go last.
205
+ - Non-empty sections appear in ascending `order`. Ties break alphabetically by title. Sections with no `order` go last. Pages within each mapped section are sorted by permalink.
167
206
  - 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
207
  - `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.
208
+ - When `instanceSections` is `null` (default), grouping is by content type using `llmsTxt.sections` titles.
170
209
 
171
210
  ## Feature 3: "Ask AI" button
172
211
 
173
- An outlined pill button with a caret 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. The button is hidden on viewports under 997px to keep the breadcrumb row uncluttered on mobile.
212
+ An outlined pill button with a caret reading "Ask AI about this page" appears above eligible doc and blog-post content. 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. The button is hidden on viewports under 997px to keep the breadcrumb row uncluttered on mobile.
174
213
 
175
214
  Placement specifics:
176
215
 
@@ -184,34 +223,45 @@ Providers and URL patterns:
184
223
  | Claude | `https://claude.ai/new?q={prompt}` |
185
224
  | ChatGPT | `https://chatgpt.com/?q={prompt}` |
186
225
  | Perplexity | `https://www.perplexity.ai/search?q={prompt}` |
187
- | Gemini | `https://gemini.google.com/app?q={prompt}` |
188
226
 
189
227
  Default prompt:
190
228
 
191
229
  ```text
192
- Read https://your-site.example/path/to/page.md and help me with the following question about it:
230
+ Read https://your-site.example/path/to/page/index.md and help me understand it. Summarize the key points, then ask me one clarifying question to dig deeper.
193
231
  ```
194
232
 
195
- The trailing space is intentional - the cursor lands ready for the user to type.
233
+ The default is self-contained - submitting it as-is yields a useful summary plus a follow-up question. The prompt is prefilled in each provider's input box, so users can still edit or replace it before sending.
234
+
235
+ Template placeholders:
236
+
237
+ | Placeholder | Value |
238
+ | --- | --- |
239
+ | `{companionUrl}` | The page's `.md` companion, by the same rule feature 1 writes it: `https://site/index.md` for the homepage, `https://site/foo.md` or `https://site/foo/index.md` depending on `trailingSlash`. With feature 1 disabled, the page URL. |
240
+ | `{pageUrl}` | The page itself: canonical URL, no trailing slash. |
241
+ | `{pageUrl}.md` | Still honoured for templates written against v0.4, and resolves to `{companionUrl}`. (On its own it produced `https://site.md` for the homepage and the wrong path on sites that build `/foo/index.html`.) |
242
+
243
+ The button only renders on pages that have a companion. Generated category index pages, React pages and blog list pages have no `.md` file, so on those the button is not shown rather than sending the reader to a 404. (The list of routes comes from `allContentLoaded`; with feature 1 disabled, the companion-route filter is disabled and the prompt uses the page URL wherever the button is rendered.)
244
+
245
+ Provider icons are hand-rolled inline SVGs in each provider's brand color (Claude `#D97757`, ChatGPT `#000000`, Perplexity `#21808D`). The Claude and Perplexity SVG paths come from [simple-icons](https://simpleicons.org) (CC0-1.0); the OpenAI mark is sourced from the @lobehub/icons project. Bundle cost is ~3KB across the three components combined.
196
246
 
197
- Provider icons are real brand SVGs in each provider's brand color, sourced from [@lobehub/icons](https://github.com/lobehub/lobe-icons). Claude, Perplexity, and Gemini render via their `.Color` variants directly; OpenAI ships only a monochrome mark, so the ChatGPT row uses that mark tinted with the ChatGPT brand green so it visually pairs with the colored icons.
247
+ > **Gemini was previously supported but was removed in 0.4.2** because `gemini.google.com` silently ignores URL-encoded prompts, leaving users on an empty prompt box. There is no documented Gemini URL prefill API and Google has not indicated one is coming.
198
248
 
199
- **Trademark note.** Brand logos are trademarks of their respective owners; their inclusion via @lobehub/icons does not imply endorsement. Consumers using the Ask AI button in commercial contexts should review each provider's brand-usage policy.
249
+ **Trademark note.** Brand logos are trademarks of their respective owners; their inclusion in this plugin does not imply endorsement. Consumers using the Ask AI button in commercial contexts should review each provider's brand-usage policy.
200
250
 
201
251
  Options:
202
252
 
203
253
  | Option | Default | Description |
204
254
  | --- | --- | --- |
205
255
  | `askAi.enabled` | `true` | When `false`, the theme components are not registered. |
206
- | `askAi.providerOrder` | `['claude', 'chatgpt', 'perplexity', 'gemini']` | Order of items in the dropdown. Drop entries to hide them. |
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. |
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. |
256
+ | `askAi.providerOrder` | `['claude', 'chatgpt', 'perplexity']` | Order of items in the dropdown. Omit providers to hide them; an empty array falls back to the default order. Valid values: `'claude'`, `'chatgpt'`, `'perplexity'`. |
257
+ | `askAi.promptTemplate` | `'Read {companionUrl} and help me understand it. Summarize the key points, then ask me one clarifying question to dig deeper.'` | Prompt sent to each provider. `{companionUrl}` is the page's `.md` companion (the page URL when feature 1 is disabled); `{pageUrl}` is the page's canonical URL (no trailing slash). See the placeholder table above. |
258
+ | `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'` disables all bundled theme components, including the `@theme/AskAiButton` alias. See custom placement below. |
209
259
 
210
260
  The button is built from [MUI](https://mui.com) primitives - outlined `Button` with a `KeyboardArrowDownIcon` caret as the trigger, and a `Menu` of `MenuItem` rows for the providers. Theming reads `--ifm-color-primary` and `--ifm-font-family-base` via MUI's `sx` prop, so dark/light mode work automatically. The MUI `Menu` handles click-outside-to-close and Esc-to-close natively.
211
261
 
212
262
  ### Peer dependencies
213
263
 
214
- When `askAi.enabled` is `true` (the default), the following peer dependencies must be installed by the consumer site:
264
+ The package declares these MUI/Emotion peers as required, in addition to Docusaurus `^3.0.0` and React/React DOM `^18.0.0 || ^19.0.0`:
215
265
 
216
266
  | Package | Range |
217
267
  | --- | --- |
@@ -220,19 +270,21 @@ When `askAi.enabled` is `true` (the default), the following peer dependencies mu
220
270
  | `@emotion/react` | `^11.0.0` |
221
271
  | `@emotion/styled` | `^11.0.0` |
222
272
 
223
- These are declared as peer dependencies (not direct dependencies) so consumers that already use MUI - common in Docusaurus sites - get a single deduped copy at install time. Consumers without MUI will get a clean npm peer-dependency error pointing at exactly what to install.
273
+ These are peer dependencies so the plugin can share the site's MUI installation. npm can install missing peers automatically; other package managers may require you to add them explicitly.
224
274
 
225
- Consumers who disable the Ask AI button (`askAi.enabled: false`) can ignore these peers; the theme components are not registered in that case and MUI is never imported.
275
+ Disabling Ask AI (`askAi.enabled: false`) prevents the plugin from registering its theme components, so they do not import MUI at runtime. It does not change the package's required peer declarations or the package manager's installation requirements.
226
276
 
227
277
  ### Customizing placement
228
278
 
229
- 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:
279
+ `askAi.placement: 'none'` disables the entire theme path, so `@theme/AskAiButton` is unavailable with that setting.
280
+
281
+ To reuse the bundled button in a custom location, keep Ask AI enabled with `placement: 'breadcrumb-row'`. Add site-level theme overrides for `DocBreadcrumbs` and `BlogPostItem/Header/Title` that omit the default button, then import it in your chosen site component:
230
282
 
231
283
  ```jsx
232
284
  import AskAiButton from '@theme/AskAiButton';
233
285
  ```
234
286
 
235
- Wrap an existing theme component (e.g. `@theme/Layout`) the same way Docusaurus documents for any swizzle.
287
+ The button retains its companion-route filter and desktop-only styling in a custom location.
236
288
 
237
289
  ## Feature 4: `/ai/*` routing convention
238
290
 
@@ -240,7 +292,7 @@ This plugin documents but does not auto-configure a second docs instance for mac
240
292
 
241
293
  ```js
242
294
  // docusaurus.config.js
243
- const { sitemapExclude, AI_ROUTE_PATTERNS } = require('@stackql/docusaurus-plugin-aeo/helpers');
295
+ const { sitemapExclude } = require('@stackql/docusaurus-plugin-aeo/helpers');
244
296
 
245
297
  module.exports = {
246
298
  plugins: [
@@ -291,11 +343,11 @@ ai-content/
291
343
  └── stackql-cli.md # frontmatter: softwareApplication: { ... }
292
344
  ```
293
345
 
294
- The companion files emitted by feature 1 will live at `/ai/faqs/<slug>/index.md` (etc.), and they will appear in `llms.txt` and `llms-full.txt` just like any other doc, which is the point.
346
+ With default filters, these docs appear in `llms.txt` and `llms-full.txt`. Their companions follow the same URL rule: `/ai/faqs/<slug>/index.md`, or `/ai/faqs/<slug>.md` when `trailingSlash: false`.
295
347
 
296
348
  ### Optional validation
297
349
 
298
- Set `aiRoutes.validate: true` to fail the build (with warnings, not errors) when files under `ai/faqs/`, `ai/howto/`, or `ai/apps/` are missing the corresponding frontmatter payload (`faq`, `howTo`, `softwareApplication`). Off by default.
350
+ Set `aiRoutes.validate: true` to warn when docs at `/ai/faqs/*`, `/ai/howto/*`, or `/ai/apps/*` lack the corresponding frontmatter payload (`faq`, `howTo`, `softwareApplication`). Validation uses route permalinks, not source directories, and does not fail the build. Set `verbose: true` for per-page details. Off by default.
299
351
 
300
352
  ### Helpers
301
353
 
@@ -303,7 +355,7 @@ Set `aiRoutes.validate: true` to fail the build (with warnings, not errors) when
303
355
  const {
304
356
  AI_ROUTE_PATTERNS, // ['/ai', '/ai/**'] - glob patterns
305
357
  sitemapExclude, // { ignorePatterns: AI_ROUTE_PATTERNS }
306
- isAiRoute, // (permalink) => boolean
358
+ isAiRoute, // true for /ai/*; false for /ai itself
307
359
  buildStructuredDataAiExcludes, // (routePaths[]) => string[]
308
360
  } = require('@stackql/docusaurus-plugin-aeo/helpers');
309
361
  ```
@@ -334,8 +386,10 @@ The `sitemapExclude` helper, by contrast, does work with globs because `@docusau
334
386
  // Feature 1
335
387
  companions: {
336
388
  enabled: true, // emit .md siblings
337
- format: 'raw', // 'raw' | 'plain'
389
+ format: 'plain', // 'plain' ('clean' is a synonym) | 'raw'
338
390
  exclude: [], // route glob patterns to skip
391
+ alternateLink: true, // <link rel="alternate" type="text/markdown"> in each page
392
+ linkHeader: false, // also a Link: header via a Netlify _headers file
339
393
  },
340
394
 
341
395
  // Feature 2
@@ -350,7 +404,7 @@ The `sitemapExclude` helper, by contrast, does work with globs because `@docusau
350
404
  '/blog/authors/**',
351
405
  ],
352
406
  include: null, // null = all not-excluded
353
- header: null, // optional string prepended to llms.txt
407
+ header: null, // optional text after the site title/tagline
354
408
  sections: { // per-type section titles (used when
355
409
  docs: 'Documentation', // instanceSections is null)
356
410
  blog: 'Blog',
@@ -360,14 +414,16 @@ The `sitemapExclude` helper, by contrast, does work with globs because `@docusau
360
414
  // sections when set. Shape:
361
415
  // { '${pluginName}@${pluginId}':
362
416
  // { title: string, order?: number } }
363
- fullTxt: true, // emit llms-full.txt
417
+ fullTxt: true, // emit llms-full.txt: true | false |
418
+ // { include: ['<plugin>@<id>'], maxBytes: 65536 }
419
+ linkFullTxt: true, // "## Optional" section in llms.txt -> llms-full.txt
364
420
  },
365
421
 
366
422
  // Feature 3
367
423
  askAi: {
368
424
  enabled: true,
369
- providerOrder: ['claude', 'chatgpt', 'perplexity', 'gemini'],
370
- promptTemplate: 'Read {pageUrl}.md and help me with the following question about it: ',
425
+ providerOrder: ['claude', 'chatgpt', 'perplexity'],
426
+ promptTemplate: 'Read {companionUrl} and help me understand it. Summarize the key points, then ask me one clarifying question to dig deeper.',
371
427
  placement: 'breadcrumb-row', // 'breadcrumb-row' | 'none'
372
428
  },
373
429
 
@@ -383,12 +439,27 @@ The `sitemapExclude` helper, by contrast, does work with globs because `@docusau
383
439
 
384
440
  Options are validated by a small handwritten validator at plugin construction. Invalid options throw a clear error before the build starts.
385
441
 
442
+ ## Development
443
+
444
+ Use Node.js 24.x to match CI. From the repository root:
445
+
446
+ ```bash
447
+ npm ci
448
+ npm test
449
+ ```
450
+
451
+ `npm test` runs every `test/*.test.js` file with Node's test runner: companion paths, Ask AI prompts, plain Markdown, alternate links/headers, `llms.txt`, and AI route classification.
452
+
453
+ The [Tests workflow](.github/workflows/tests.yml) runs on every pull request targeting `main` and on pushes to `main`, using the committed lockfile and Node 24.x. To make it a merge gate, require the **Tests (Node 24)** status check in the branch protection rule or ruleset for `main`. Until that rule is enabled, a failed check does not block merging.
454
+
455
+ The plain-markdown converter lives in `src/features/plainMarkdown.js` and is exercised by `test/plainMarkdown.test.js` with small MDX fixtures; add a fixture there when a new construct needs handling.
456
+
386
457
  ## Composing with `@stackql/docusaurus-plugin-structured-data`
387
458
 
388
459
  The two plugins are complementary and intended to be used together:
389
460
 
390
461
  - `@stackql/docusaurus-plugin-structured-data` emits JSON-LD into page `<head>`. <https://github.com/stackql/docusaurus-plugin-structured-data>
391
- - `@stackql/docusaurus-plugin-aeo` does everything else AEO-adjacent: raw `.md` companions, `llms.txt`, the Ask AI button, the `/ai/*` convention.
462
+ - `@stackql/docusaurus-plugin-aeo` provides plain-markdown `.md` companions by default (raw mode is optional), `llms.txt` / `llms-full.txt`, the Ask AI button, and the `/ai/*` convention.
392
463
 
393
464
  There is no overlap and no shared state. Add both:
394
465
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackql/docusaurus-plugin-aeo",
3
- "version": "0.4.1",
3
+ "version": "0.5.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": {
@@ -17,7 +17,7 @@
17
17
  "access": "public"
18
18
  },
19
19
  "scripts": {
20
- "test": "echo \"Error: no test specified\" && exit 1"
20
+ "test": "node --test test/*.test.js"
21
21
  },
22
22
  "peerDependencies": {
23
23
  "@docusaurus/core": "^3.0.0",
@@ -43,12 +43,21 @@
43
43
  }
44
44
  },
45
45
  "dependencies": {
46
- "@lobehub/icons": "^5.10.0",
47
- "gray-matter": "^4.0.3"
46
+ "@slorber/remark-comment": "^1.0.0",
47
+ "gray-matter": "npm:@11ty/gray-matter@1.0.0",
48
+ "remark-directive": "^3.0.0",
49
+ "remark-gfm": "^4.0.0",
50
+ "remark-mdx": "^3.0.0",
51
+ "remark-parse": "^11.0.0",
52
+ "unified": "^11.0.3"
48
53
  },
49
54
  "overrides": {
50
55
  "serialize-javascript": "^7.0.5",
51
- "uuid": "^11.1.1"
56
+ "uuid": "^11.1.1",
57
+ "@docusaurus/core": {
58
+ "tinypool": "2.1.2"
59
+ },
60
+ "postcss-selector-parser": "7.1.6"
52
61
  },
53
62
  "repository": {
54
63
  "type": "git",
@@ -68,5 +77,8 @@
68
77
  "bugs": {
69
78
  "url": "https://github.com/stackql-labs/docusaurus-plugin-aeo/issues"
70
79
  },
71
- "homepage": "https://github.com/stackql-labs/docusaurus-plugin-aeo#readme"
80
+ "homepage": "https://github.com/stackql-labs/docusaurus-plugin-aeo#readme",
81
+ "engines": {
82
+ "node": ">=18.0"
83
+ }
72
84
  }
@@ -0,0 +1,21 @@
1
+ // Fills the Ask AI prompt template. Plain CommonJS so the theme component
2
+ // can bundle it and the tests can require it.
3
+ //
4
+ // {companionUrl} the page's .md companion (absolute URL)
5
+ // {pageUrl} the page itself (canonical, no trailing slash)
6
+ //
7
+ // The v0.4 idiom `{pageUrl}.md` is still honoured so a custom template
8
+ // keeps working. It was wrong for the homepage (`https://site.md`) and for
9
+ // sites that build /foo/index.html; it now resolves to the real companion
10
+ // URL.
11
+ function fillPrompt(template, { pageUrl, companionUrl }) {
12
+ return String(template || '')
13
+ .split('{pageUrl}.md')
14
+ .join(companionUrl)
15
+ .split('{companionUrl}')
16
+ .join(companionUrl)
17
+ .split('{pageUrl}')
18
+ .join(pageUrl);
19
+ }
20
+
21
+ module.exports = { fillPrompt };
@@ -0,0 +1,55 @@
1
+ // The one rule for where a page's `.md` companion lives, shared by the
2
+ // emitter (feature 1), llms.txt (feature 2) and the Ask AI button (feature 3)
3
+ // so the three can never disagree.
4
+ //
5
+ // Docusaurus emits either /foo/index.html (trailingSlash true or undefined)
6
+ // or /foo.html (trailingSlash false); the companion mirrors the HTML:
7
+ //
8
+ // route trailingSlash: false trailingSlash: true / undefined
9
+ // / /index.md /index.md
10
+ // /foo /foo.md /foo/index.md
11
+ // /foo/ /foo.md /foo/index.md
12
+ // /docs/intro /docs/intro.md /docs/intro/index.md
13
+ //
14
+ // Plain CommonJS with no dependencies: required by the Node-side features
15
+ // and bundled into the theme component by webpack.
16
+
17
+ function normalizeRoute(routePath) {
18
+ const cleaned = String(routePath || '').replace(/\/+$/, '');
19
+ return cleaned === '' ? '/' : cleaned;
20
+ }
21
+
22
+ // Site-relative companion path for a route, e.g. '/foo.md'. Always starts
23
+ // with '/'.
24
+ function companionPath(routePath, trailingSlash) {
25
+ const route = normalizeRoute(routePath);
26
+ if (route === '/') return '/index.md';
27
+ return trailingSlash === false ? `${route}.md` : `${route}/index.md`;
28
+ }
29
+
30
+ // Absolute companion URL for a route, e.g. 'https://example.com/foo.md'.
31
+ // `siteUrl` is siteConfig.url; the route already carries siteConfig.baseUrl
32
+ // (Docusaurus permalinks and location.pathname both include it).
33
+ function companionUrl(siteUrl, routePath, trailingSlash) {
34
+ return `${String(siteUrl || '').replace(/\/+$/, '')}${companionPath(routePath, trailingSlash)}`;
35
+ }
36
+
37
+ // Site-relative path of the HTML file Docusaurus writes for a route, the
38
+ // same rule as @docusaurus/core's pathnameToFilename once the route has
39
+ // been normalised: '/foo.html' when trailingSlash is false, '/foo/index.html'
40
+ // otherwise, '/index.html' for the root.
41
+ function htmlPath(routePath, trailingSlash) {
42
+ const route = normalizeRoute(routePath);
43
+ if (route === '/') return '/index.html';
44
+ return trailingSlash === false ? `${route}.html` : `${route}/index.html`;
45
+ }
46
+
47
+ // Absolute URL of the page itself: canonical, no trailing slash except on
48
+ // the root.
49
+ function pageUrl(siteUrl, routePath) {
50
+ const base = String(siteUrl || '').replace(/\/+$/, '');
51
+ const route = normalizeRoute(routePath);
52
+ return route === '/' ? `${base}/` : `${base}${route}`;
53
+ }
54
+
55
+ module.exports = { companionPath, companionUrl, htmlPath, pageUrl, normalizeRoute };
@@ -5,6 +5,7 @@
5
5
  // ai/faqs/<slug>.md -> must declare a `faq` frontmatter object
6
6
  // ai/howto/<slug>.md -> must declare a `howTo` frontmatter object
7
7
  // ai/howtos/<slug>.md -> same as above
8
+ // ai/how-tos/<slug>.md -> same as above
8
9
  // ai/apps/<slug>.md -> must declare a `softwareApplication`
9
10
  // frontmatter object
10
11
  //
@@ -16,6 +17,8 @@ const KIND_FOR_DIR = {
16
17
  faq: 'faq',
17
18
  howto: 'howTo',
18
19
  howtos: 'howTo',
20
+ 'how-tos': 'howTo',
21
+ 'how-to': 'howTo',
19
22
  apps: 'softwareApplication',
20
23
  app: 'softwareApplication',
21
24
  applications: 'softwareApplication',