@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/src/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  const path = require('path');
2
2
  const emitCompanions = require('./features/companions');
3
+ const { collectCompanionItems } = require('./features/companions');
3
4
  const emitLlmsTxt = require('./features/llmsTxt');
5
+ const injectAlternateLinks = require('./features/alternateLinks');
4
6
  const { validateAiRoutes } = require('./features/aiRoutes');
5
7
 
6
8
  const DEFAULT_LLMS_EXCLUDE = [
@@ -12,10 +14,10 @@ const DEFAULT_LLMS_EXCLUDE = [
12
14
  '/blog/authors/**',
13
15
  ];
14
16
 
15
- const DEFAULT_PROVIDER_ORDER = ['claude', 'chatgpt', 'perplexity', 'gemini'];
17
+ const DEFAULT_PROVIDER_ORDER = ['claude', 'chatgpt', 'perplexity'];
16
18
  const VALID_PROVIDERS = new Set(DEFAULT_PROVIDER_ORDER);
17
19
  const DEFAULT_PROMPT_TEMPLATE =
18
- 'Read {pageUrl}.md and help me with the following question about it: ';
20
+ 'Read {companionUrl} and help me understand it. Summarize the key points, then ask me one clarifying question to dig deeper.';
19
21
 
20
22
  function normalizeOptions(raw) {
21
23
  const opts = raw || {};
@@ -28,8 +30,13 @@ function normalizeOptions(raw) {
28
30
  return {
29
31
  companions: {
30
32
  enabled: companions.enabled !== false,
31
- format: companions.format || 'raw',
33
+ format: companions.format || 'plain',
32
34
  exclude: companions.exclude || [],
35
+ // <link rel="alternate" type="text/markdown"> in every page that has
36
+ // a companion, and optionally the same as a Link header via a
37
+ // Netlify-style _headers file.
38
+ alternateLink: companions.alternateLink !== false,
39
+ linkHeader: companions.linkHeader === true,
33
40
  },
34
41
  llmsTxt: {
35
42
  enabled: llmsTxt.enabled !== false,
@@ -45,7 +52,10 @@ function normalizeOptions(raw) {
45
52
  // When set, supersedes `sections` and switches feature 2 from
46
53
  // per-type grouping to per-instance grouping.
47
54
  instanceSections: llmsTxt.instanceSections || null,
48
- fullTxt: llmsTxt.fullTxt !== false,
55
+ // true (everything), false, or { include: ['<plugin>@<id>'], maxBytes }
56
+ fullTxt: llmsTxt.fullTxt === undefined ? true : llmsTxt.fullTxt,
57
+ // "## Optional" section in llms.txt pointing at llms-full.txt
58
+ linkFullTxt: llmsTxt.linkFullTxt !== false,
49
59
  },
50
60
  askAi: {
51
61
  enabled: askAi.enabled !== false,
@@ -63,11 +73,33 @@ function normalizeOptions(raw) {
63
73
  function validateOptions(opts) {
64
74
  const errs = [];
65
75
 
66
- if (!['raw', 'plain'].includes(opts.companions.format)) {
76
+ if (!['raw', 'plain', 'clean'].includes(opts.companions.format)) {
67
77
  errs.push(
68
- `companions.format must be "raw" or "plain", got "${opts.companions.format}"`,
78
+ `companions.format must be "plain" (or its synonym "clean") or "raw", got "${opts.companions.format}"`,
69
79
  );
70
80
  }
81
+ if (typeof opts.companions.alternateLink !== 'boolean') {
82
+ errs.push('companions.alternateLink must be a boolean');
83
+ }
84
+ if (typeof opts.companions.linkHeader !== 'boolean') {
85
+ errs.push('companions.linkHeader must be a boolean');
86
+ }
87
+ const ft = opts.llmsTxt.fullTxt;
88
+ if (typeof ft !== 'boolean') {
89
+ if (!ft || typeof ft !== 'object' || Array.isArray(ft)) {
90
+ errs.push('llmsTxt.fullTxt must be a boolean or an object { include?: string[], maxBytes?: number }');
91
+ } else {
92
+ if (ft.include !== undefined && (!Array.isArray(ft.include) || ft.include.some((k) => typeof k !== 'string'))) {
93
+ errs.push('llmsTxt.fullTxt.include must be an array of "<plugin>@<id>" strings');
94
+ }
95
+ if (ft.maxBytes !== undefined && (!Number.isFinite(ft.maxBytes) || ft.maxBytes <= 0)) {
96
+ errs.push('llmsTxt.fullTxt.maxBytes must be a positive number');
97
+ }
98
+ }
99
+ }
100
+ if (typeof opts.llmsTxt.linkFullTxt !== 'boolean') {
101
+ errs.push('llmsTxt.linkFullTxt must be a boolean');
102
+ }
71
103
  if (!Array.isArray(opts.companions.exclude)) {
72
104
  errs.push('companions.exclude must be an array of glob patterns');
73
105
  }
@@ -117,8 +149,12 @@ function validateOptions(opts) {
117
149
  } else {
118
150
  for (const p of opts.askAi.providerOrder) {
119
151
  if (!VALID_PROVIDERS.has(p)) {
152
+ const hint =
153
+ p === 'gemini'
154
+ ? '. Gemini was removed in 0.4.2 because gemini.google.com does not accept URL-encoded prompts'
155
+ : '';
120
156
  errs.push(
121
- `askAi.providerOrder contains unknown provider "${p}". Valid: ${[...VALID_PROVIDERS].join(', ')}`,
157
+ `askAi.providerOrder contains unknown provider "${p}"${hint}. Valid providers: ${[...VALID_PROVIDERS].join(', ')}`,
122
158
  );
123
159
  }
124
160
  }
@@ -156,10 +192,10 @@ module.exports = function pluginAeo(context, rawOptions) {
156
192
  const plugin = {
157
193
  name: '@stackql/docusaurus-plugin-aeo',
158
194
 
159
- // Surface the askAi config to theme components. setGlobalData MUST be
160
- // called from contentLoaded - Docusaurus does not accept it from
161
- // allContentLoaded, and theme components read it via
162
- // usePluginData('@stackql/docusaurus-plugin-aeo') at render time.
195
+ // Surface the askAi config to theme components, which read it via
196
+ // usePluginData('@stackql/docusaurus-plugin-aeo') at render time. The
197
+ // companion route list is added from allContentLoaded below; Docusaurus
198
+ // merges global data set from the two hooks (shallow, per plugin).
163
199
  async contentLoaded({ actions }) {
164
200
  await actions.setGlobalData({
165
201
  askAi: {
@@ -176,7 +212,7 @@ module.exports = function pluginAeo(context, rawOptions) {
176
212
  // allContentLoaded in Docusaurus 3.x. contentLoaded receives only the
177
213
  // current plugin's own content, so feature 1 needs this hook to see
178
214
  // the docs/blog source files it has to mirror.
179
- async allContentLoaded({ allContent }) {
215
+ async allContentLoaded({ allContent, actions }) {
180
216
  if (allContent) {
181
217
  for (const [pluginName, byId] of Object.entries(allContent)) {
182
218
  if (!byId) continue;
@@ -196,6 +232,18 @@ module.exports = function pluginAeo(context, rawOptions) {
196
232
  verbose: options.verbose,
197
233
  });
198
234
  }
235
+
236
+ // Which routes will have a companion, so the Ask AI button can point
237
+ // at the file and stay hidden on pages without one (generated
238
+ // category indexes, React pages). Same list postBuild emits from.
239
+ if (actions && typeof actions.setGlobalData === 'function') {
240
+ const companionRoutes = options.companions.enabled
241
+ ? collectCompanionItems(loadedContentByPlugin, options.companions).map(
242
+ (item) => item.permalink,
243
+ )
244
+ : [];
245
+ await actions.setGlobalData({ companionRoutes });
246
+ }
199
247
  },
200
248
 
201
249
  async postBuild(props) {
@@ -212,6 +260,15 @@ module.exports = function pluginAeo(context, rawOptions) {
212
260
  console.log('[plugin-aeo] companions disabled, skipping feature 1');
213
261
  }
214
262
 
263
+ if (options.companions.enabled && options.companions.alternateLink) {
264
+ await injectAlternateLinks({
265
+ props,
266
+ emittedCompanions,
267
+ linkHeader: options.companions.linkHeader,
268
+ verbose: options.verbose,
269
+ });
270
+ }
271
+
215
272
  if (options.llmsTxt.enabled) {
216
273
  await emitLlmsTxt({
217
274
  props,
@@ -0,0 +1,26 @@
1
+ // OpenAI / ChatGPT petaled mark. SVG path lifted verbatim from
2
+ // @lobehub/icons (MIT) before the React 19 hard-pin made the
3
+ // runtime install impossible on React 18 hosts. Brand color #000000
4
+ // is hardcoded to match OpenAI's brand guidelines (the black
5
+ // monochrome mark is the canonical one).
6
+
7
+ import React from 'react';
8
+
9
+ const PATH =
10
+ 'M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z';
11
+
12
+ export default function ChatGptIcon({ size = 18 }) {
13
+ return (
14
+ <svg
15
+ xmlns="http://www.w3.org/2000/svg"
16
+ width={size}
17
+ height={size}
18
+ viewBox="0 0 24 24"
19
+ role="img"
20
+ aria-hidden="true"
21
+ style={{ flex: 'none', lineHeight: 1 }}
22
+ >
23
+ <path d={PATH} fill="#000000" />
24
+ </svg>
25
+ );
26
+ }
@@ -0,0 +1,25 @@
1
+ // Claude wordmark / petal. SVG path lifted verbatim from simple-icons
2
+ // (CC0-1.0) so the mark is faithful to the brand kit. Brand color
3
+ // #D97757 is hardcoded; ListItemIcon's `color: 'inherit'` keeps MUI
4
+ // from tinting it.
5
+
6
+ import React from 'react';
7
+
8
+ const PATH =
9
+ 'm4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z';
10
+
11
+ export default function ClaudeIcon({ size = 18 }) {
12
+ return (
13
+ <svg
14
+ xmlns="http://www.w3.org/2000/svg"
15
+ width={size}
16
+ height={size}
17
+ viewBox="0 0 24 24"
18
+ role="img"
19
+ aria-hidden="true"
20
+ style={{ flex: 'none', lineHeight: 1 }}
21
+ >
22
+ <path d={PATH} fill="#D97757" />
23
+ </svg>
24
+ );
25
+ }
@@ -0,0 +1,23 @@
1
+ // Perplexity wordmark. SVG path lifted verbatim from simple-icons
2
+ // (CC0-1.0). Brand color #21808D is hardcoded.
3
+
4
+ import React from 'react';
5
+
6
+ const PATH =
7
+ 'M22.3977 7.0896h-2.3106V.0676l-7.5094 6.3542V.1577h-1.1554v6.1966L4.4904 0v7.0896H1.6023v10.3976h2.8882V24l6.932-6.3591v6.2005h1.1554v-6.0469l6.9318 6.1807v-6.4879h2.8882V7.0896zm-3.4657-4.531v4.531h-5.355l5.355-4.531zm-13.2862.0676 4.8691 4.4634H5.6458V2.6262zM2.7576 16.332V8.245h7.8476l-6.1149 6.1147v1.9723H2.7576zm2.8882 5.0404v-3.8852h.0001v-2.6488l5.7763-5.7764v7.0111l-5.7764 5.2993zm12.7086.0248-5.7766-5.1509V9.0618l5.7766 5.7766v6.5588zm2.8882-5.0652h-1.733v-1.9723L13.3948 8.245h7.8478v8.087z';
8
+
9
+ export default function PerplexityIcon({ size = 18 }) {
10
+ return (
11
+ <svg
12
+ xmlns="http://www.w3.org/2000/svg"
13
+ width={size}
14
+ height={size}
15
+ viewBox="0 0 24 24"
16
+ role="img"
17
+ aria-hidden="true"
18
+ style={{ flex: 'none', lineHeight: 1 }}
19
+ >
20
+ <path d={PATH} fill="#21808D" />
21
+ </svg>
22
+ );
23
+ }
@@ -1,45 +1,26 @@
1
- // Brand icons for the four supported AI providers, sourced from
2
- // @lobehub/icons. Each provider's package exports a default Mono
3
- // component with .Color, .Text, .Combine, .Avatar attached as
4
- // properties. We prefer .Color (multi-color brand variant) when the
5
- // provider ships one; OpenAI does not, so we fall back to Mono and
6
- // tint it with the ChatGPT brand green via inline style.
1
+ // Provider brand icons. Hand-rolled inline SVGs lifted from
2
+ // publicly-available marks (simple-icons paths under CC0-1.0 for
3
+ // Claude and Perplexity; the @lobehub/icons OpenAI Mono path for
4
+ // ChatGPT). Brand color is hardcoded on each path so MUI's
5
+ // ListItemIcon color override doesn't tint them.
7
6
  //
8
- // Deep import paths (@lobehub/icons/es/<Provider>) are used because
9
- // the package does not declare a subpath exports map; the deep path
10
- // is stable across the 5.x line and tree-shakes cleanly because each
11
- // per-provider module's dependency footprint is just React.
12
-
13
- import React from 'react';
14
- import ClaudeIcons from '@lobehub/icons/es/Claude';
15
- import OpenAIIcons from '@lobehub/icons/es/OpenAI';
16
- import PerplexityIcons from '@lobehub/icons/es/Perplexity';
17
- import GeminiIcons from '@lobehub/icons/es/Gemini';
18
-
19
- const ICON_SIZE = 18;
20
-
21
- // OpenAI ships only the monochrome mark (Color variant absent from
22
- // @lobehub/icons as of 5.10.0). Use the known ChatGPT brand green so
23
- // the row visually matches the other three colored marks.
24
- const CHATGPT_GREEN = '#10A37F';
7
+ // We hand-roll rather than depend on @lobehub/icons because lobehub
8
+ // pinned react@^19 from 5.x onward, breaking React 18 consumers.
9
+ // simple-icons works on any Node + React but no longer ships an
10
+ // OpenAI mark following a 2024 trademark request, so a unified
11
+ // "single library" path is no longer available - hand-rolled is the
12
+ // only zero-conflict option.
13
+ //
14
+ // Bundle cost: ~3KB across the three SVG components combined.
25
15
 
26
- function makeColorIcon(IconNamespace, fallbackColor) {
27
- const Component = IconNamespace.Color || IconNamespace;
28
- const usingFallback = !IconNamespace.Color;
29
- return function Icon() {
30
- const props = { size: ICON_SIZE };
31
- if (usingFallback && fallbackColor) {
32
- props.style = { color: fallbackColor };
33
- }
34
- return React.createElement(Component, props);
35
- };
36
- }
16
+ import Claude from './brand-icons/Claude.jsx';
17
+ import ChatGpt from './brand-icons/ChatGpt.jsx';
18
+ import Perplexity from './brand-icons/Perplexity.jsx';
37
19
 
38
20
  const icons = {
39
- claude: makeColorIcon(ClaudeIcons),
40
- chatgpt: makeColorIcon(OpenAIIcons, CHATGPT_GREEN),
41
- perplexity: makeColorIcon(PerplexityIcons),
42
- gemini: makeColorIcon(GeminiIcons),
21
+ claude: Claude,
22
+ chatgpt: ChatGpt,
23
+ perplexity: Perplexity,
43
24
  };
44
25
 
45
26
  export default icons;
@@ -10,23 +10,29 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
10
10
  import { usePluginData } from '@docusaurus/useGlobalData';
11
11
  import icons from './icons.js';
12
12
  import styles from './styles.module.css';
13
+ // CommonJS, shared with the Node-side features so the button and the
14
+ // emitted file can never disagree about where the companion lives.
15
+ import companionPathModule from '../../companionPath.js';
16
+ import askAiPromptModule from '../../askAiPrompt.js';
17
+
18
+ const { companionUrl, normalizeRoute } = companionPathModule;
19
+ const { fillPrompt } = askAiPromptModule;
13
20
 
14
21
  const PROVIDER_LABELS = {
15
22
  claude: 'Ask Claude',
16
23
  chatgpt: 'Ask ChatGPT',
17
24
  perplexity: 'Ask Perplexity',
18
- gemini: 'Ask Gemini',
19
25
  };
20
26
 
21
27
  const PROVIDER_URLS = {
22
28
  claude: 'https://claude.ai/new?q=',
23
29
  chatgpt: 'https://chatgpt.com/?q=',
24
30
  perplexity: 'https://www.perplexity.ai/search?q=',
25
- gemini: 'https://gemini.google.com/app?q=',
26
31
  };
27
32
 
28
33
  const DEFAULT_PROMPT_TEMPLATE =
29
- 'Read {pageUrl}.md and help me with the following question about it: ';
34
+ 'Read {companionUrl} and help me understand it. Summarize the key points, then ask me one clarifying question to dig deeper.';
35
+
30
36
 
31
37
  export default function AskAiButton() {
32
38
  const { siteConfig } = useDocusaurusContext();
@@ -39,16 +45,34 @@ export default function AskAiButton() {
39
45
 
40
46
  if (cfg.enabled === false) return null;
41
47
 
42
- const baseUrl = (siteConfig.url || '').replace(/\/$/, '');
43
- const pageUrl = `${baseUrl}${location.pathname.replace(/\/$/, '') || ''}`;
48
+ const siteUrl = (siteConfig.url || '').replace(/\/+$/, '');
49
+ const route = normalizeRoute(location.pathname);
50
+ const pageUrl = route === '/' ? siteUrl : `${siteUrl}${route}`;
51
+
52
+ // Only pages with a companion get the button: a generated category index
53
+ // or a React page has no .md file, so a prompt pointing at one would
54
+ // send the reader to a 404. `companionRoutes` arrives from
55
+ // allContentLoaded; if an older core did not merge it, fall back to
56
+ // showing the button everywhere as before. With companions disabled the
57
+ // prompt uses the page itself.
58
+ const companionsEnabled = cfg.companionsEnabled !== false;
59
+ const routes = data.companionRoutes;
60
+ if (companionsEnabled && Array.isArray(routes)) {
61
+ const known = routes.some((r) => normalizeRoute(r) === route);
62
+ if (!known) return null;
63
+ }
64
+ const mdUrl = companionsEnabled
65
+ ? companionUrl(siteUrl, route, siteConfig.trailingSlash)
66
+ : pageUrl;
67
+
44
68
  const promptTemplate = cfg.promptTemplate || DEFAULT_PROMPT_TEMPLATE;
45
- const prompt = promptTemplate.replace('{pageUrl}', pageUrl);
69
+ const prompt = fillPrompt(promptTemplate, { pageUrl, companionUrl: mdUrl });
46
70
  const encoded = encodeURIComponent(prompt);
47
71
 
48
72
  const order =
49
73
  Array.isArray(cfg.providerOrder) && cfg.providerOrder.length > 0
50
74
  ? cfg.providerOrder
51
- : ['claude', 'chatgpt', 'perplexity', 'gemini'];
75
+ : ['claude', 'chatgpt', 'perplexity'];
52
76
 
53
77
  const handleOpen = (e) => setAnchorEl(e.currentTarget);
54
78
  const handleClose = () => setAnchorEl(null);