@stackql/docusaurus-plugin-aeo 0.4.2 → 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 = [
@@ -15,7 +17,7 @@ const DEFAULT_LLMS_EXCLUDE = [
15
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 understand it. Summarize the key points, then ask me one clarifying question to dig deeper.';
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
  }
@@ -160,10 +192,10 @@ module.exports = function pluginAeo(context, rawOptions) {
160
192
  const plugin = {
161
193
  name: '@stackql/docusaurus-plugin-aeo',
162
194
 
163
- // Surface the askAi config to theme components. setGlobalData MUST be
164
- // called from contentLoaded - Docusaurus does not accept it from
165
- // allContentLoaded, and theme components read it via
166
- // 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).
167
199
  async contentLoaded({ actions }) {
168
200
  await actions.setGlobalData({
169
201
  askAi: {
@@ -180,7 +212,7 @@ module.exports = function pluginAeo(context, rawOptions) {
180
212
  // allContentLoaded in Docusaurus 3.x. contentLoaded receives only the
181
213
  // current plugin's own content, so feature 1 needs this hook to see
182
214
  // the docs/blog source files it has to mirror.
183
- async allContentLoaded({ allContent }) {
215
+ async allContentLoaded({ allContent, actions }) {
184
216
  if (allContent) {
185
217
  for (const [pluginName, byId] of Object.entries(allContent)) {
186
218
  if (!byId) continue;
@@ -200,6 +232,18 @@ module.exports = function pluginAeo(context, rawOptions) {
200
232
  verbose: options.verbose,
201
233
  });
202
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
+ }
203
247
  },
204
248
 
205
249
  async postBuild(props) {
@@ -216,6 +260,15 @@ module.exports = function pluginAeo(context, rawOptions) {
216
260
  console.log('[plugin-aeo] companions disabled, skipping feature 1');
217
261
  }
218
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
+
219
272
  if (options.llmsTxt.enabled) {
220
273
  await emitLlmsTxt({
221
274
  props,
@@ -10,6 +10,13 @@ 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',
@@ -24,7 +31,8 @@ const PROVIDER_URLS = {
24
31
  };
25
32
 
26
33
  const DEFAULT_PROMPT_TEMPLATE =
27
- 'Read {pageUrl}.md and help me understand it. Summarize the key points, then ask me one clarifying question to dig deeper.';
34
+ 'Read {companionUrl} and help me understand it. Summarize the key points, then ask me one clarifying question to dig deeper.';
35
+
28
36
 
29
37
  export default function AskAiButton() {
30
38
  const { siteConfig } = useDocusaurusContext();
@@ -37,10 +45,28 @@ export default function AskAiButton() {
37
45
 
38
46
  if (cfg.enabled === false) return null;
39
47
 
40
- const baseUrl = (siteConfig.url || '').replace(/\/$/, '');
41
- 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
+
42
68
  const promptTemplate = cfg.promptTemplate || DEFAULT_PROMPT_TEMPLATE;
43
- const prompt = promptTemplate.replace('{pageUrl}', pageUrl);
69
+ const prompt = fillPrompt(promptTemplate, { pageUrl, companionUrl: mdUrl });
44
70
  const encoded = encodeURIComponent(prompt);
45
71
 
46
72
  const order =