@stackql/docusaurus-plugin-aeo 0.4.2 → 0.5.1

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
  }
@@ -150,6 +182,12 @@ module.exports = function pluginAeo(context, rawOptions) {
150
182
  const options = normalizeOptions(rawOptions);
151
183
  validateOptions(options);
152
184
 
185
+ // siteConfig.baseUrl for this build. Docusaurus constructs the plugin
186
+ // once per locale, and a localized build has its own base URL ('/de/');
187
+ // routes carry it, and the route conventions below are relative to it.
188
+ const baseUrl =
189
+ (context && ((context.siteConfig && context.siteConfig.baseUrl) || context.baseUrl)) || '/';
190
+
153
191
  // Captured from contentLoaded for downstream postBuild use.
154
192
  // Map<pluginName, { plugin: { name, id }, content: any }>
155
193
  const loadedContentByPlugin = new Map();
@@ -160,10 +198,10 @@ module.exports = function pluginAeo(context, rawOptions) {
160
198
  const plugin = {
161
199
  name: '@stackql/docusaurus-plugin-aeo',
162
200
 
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.
201
+ // Surface the askAi config to theme components, which read it via
202
+ // usePluginData('@stackql/docusaurus-plugin-aeo') at render time. The
203
+ // companion route list is added from allContentLoaded below; Docusaurus
204
+ // merges global data set from the two hooks (shallow, per plugin).
167
205
  async contentLoaded({ actions }) {
168
206
  await actions.setGlobalData({
169
207
  askAi: {
@@ -180,7 +218,7 @@ module.exports = function pluginAeo(context, rawOptions) {
180
218
  // allContentLoaded in Docusaurus 3.x. contentLoaded receives only the
181
219
  // current plugin's own content, so feature 1 needs this hook to see
182
220
  // the docs/blog source files it has to mirror.
183
- async allContentLoaded({ allContent }) {
221
+ async allContentLoaded({ allContent, actions }) {
184
222
  if (allContent) {
185
223
  for (const [pluginName, byId] of Object.entries(allContent)) {
186
224
  if (!byId) continue;
@@ -197,9 +235,22 @@ module.exports = function pluginAeo(context, rawOptions) {
197
235
  if (options.aiRoutes.validate) {
198
236
  validateAiRoutes({
199
237
  loadedContentByPlugin,
238
+ baseUrl,
200
239
  verbose: options.verbose,
201
240
  });
202
241
  }
242
+
243
+ // Which routes will have a companion, so the Ask AI button can point
244
+ // at the file and stay hidden on pages without one (generated
245
+ // category indexes, React pages). Same list postBuild emits from.
246
+ if (actions && typeof actions.setGlobalData === 'function') {
247
+ const companionRoutes = options.companions.enabled
248
+ ? collectCompanionItems(loadedContentByPlugin, options.companions, baseUrl).map(
249
+ (item) => item.permalink,
250
+ )
251
+ : [];
252
+ await actions.setGlobalData({ companionRoutes });
253
+ }
203
254
  },
204
255
 
205
256
  async postBuild(props) {
@@ -216,6 +267,15 @@ module.exports = function pluginAeo(context, rawOptions) {
216
267
  console.log('[plugin-aeo] companions disabled, skipping feature 1');
217
268
  }
218
269
 
270
+ if (options.companions.enabled && options.companions.alternateLink) {
271
+ await injectAlternateLinks({
272
+ props,
273
+ emittedCompanions,
274
+ linkHeader: options.companions.linkHeader,
275
+ verbose: options.verbose,
276
+ });
277
+ }
278
+
219
279
  if (options.llmsTxt.enabled) {
220
280
  await emitLlmsTxt({
221
281
  props,
@@ -0,0 +1,60 @@
1
+ // Route glob matching for the `exclude` / `include` options.
2
+ //
3
+ // A pattern describes a route of the site: '/blog/tags/**', '/search'.
4
+ // Docusaurus permalinks carry siteConfig.baseUrl, and that differs between
5
+ // builds of the same site: a localized build runs with baseUrl '/de/', so
6
+ // the page a pattern names is '/de/blog/tags/x' there. A pattern is
7
+ // therefore tried against the route with the base URL removed as well as
8
+ // the route as Docusaurus reports it:
9
+ //
10
+ // - one configuration covers every locale and any baseUrl, and
11
+ // - a pattern that spells the base out keeps working ('/docs/guide/**' on
12
+ // a site built at '/docs/') and can single out one locale
13
+ // ('/de/blog/**').
14
+ //
15
+ // Minimal matcher, to avoid pulling micromatch in directly: `**` matches
16
+ // across path segments, `*` and `?` within one.
17
+
18
+ const { stripBase } = require('./companionPath');
19
+
20
+ function toRegExp(glob) {
21
+ let re = '';
22
+ for (let i = 0; i < glob.length; i++) {
23
+ const c = glob[i];
24
+ if (c === '*') {
25
+ if (glob[i + 1] === '*') {
26
+ re += '.*';
27
+ i++;
28
+ if (glob[i + 1] === '/') i++;
29
+ } else {
30
+ re += '[^/]*';
31
+ }
32
+ } else if (c === '?') {
33
+ re += '[^/]';
34
+ } else if ('.+^$()|{}[]\\'.includes(c)) {
35
+ re += '\\' + c;
36
+ } else {
37
+ re += c;
38
+ }
39
+ }
40
+ return new RegExp('^' + re + '$');
41
+ }
42
+
43
+ // True when `value` matches at least one glob. No patterns, no match.
44
+ function matchAny(value, patterns) {
45
+ if (!patterns || patterns.length === 0) return false;
46
+ for (const p of patterns) {
47
+ if (toRegExp(p).test(value)) return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ // True when a permalink matches at least one glob, with or without
53
+ // `baseUrl` (siteConfig.baseUrl) in front of it.
54
+ function matchesRoute(permalink, patterns, baseUrl) {
55
+ if (matchAny(permalink, patterns)) return true;
56
+ const relative = stripBase(permalink, baseUrl);
57
+ return relative !== permalink && matchAny(relative, patterns);
58
+ }
59
+
60
+ module.exports = { toRegExp, matchAny, matchesRoute };
File without changes
File without changes
File without changes
File without changes
@@ -8,14 +8,39 @@ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
8
8
  import { useLocation } from '@docusaurus/router';
9
9
  import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
10
10
  import { usePluginData } from '@docusaurus/useGlobalData';
11
+ import { translate } from '@docusaurus/Translate';
11
12
  import icons from './icons.js';
12
13
  import styles from './styles.module.css';
14
+ // CommonJS, shared with the Node-side features so the button and the
15
+ // emitted file can never disagree about where the companion lives.
16
+ import companionPathModule from '../../companionPath.js';
17
+ import askAiPromptModule from '../../askAiPrompt.js';
13
18
 
14
- const PROVIDER_LABELS = {
15
- claude: 'Ask Claude',
16
- chatgpt: 'Ask ChatGPT',
17
- perplexity: 'Ask Perplexity',
18
- };
19
+ const { companionUrl, pageUrl: pageUrlFor, normalizeRoute } = companionPathModule;
20
+ const { fillPrompt } = askAiPromptModule;
21
+
22
+ // Visible strings go through translate() so a localized site can override
23
+ // them in i18n/<locale>/code.json (`docusaurus write-translations` extracts
24
+ // them from this theme component).
25
+ function providerLabels() {
26
+ return {
27
+ claude: translate({
28
+ id: 'aeo.askAi.provider.claude',
29
+ message: 'Ask Claude',
30
+ description: 'Ask AI menu entry',
31
+ }),
32
+ chatgpt: translate({
33
+ id: 'aeo.askAi.provider.chatgpt',
34
+ message: 'Ask ChatGPT',
35
+ description: 'Ask AI menu entry',
36
+ }),
37
+ perplexity: translate({
38
+ id: 'aeo.askAi.provider.perplexity',
39
+ message: 'Ask Perplexity',
40
+ description: 'Ask AI menu entry',
41
+ }),
42
+ };
43
+ }
19
44
 
20
45
  const PROVIDER_URLS = {
21
46
  claude: 'https://claude.ai/new?q=',
@@ -24,7 +49,8 @@ const PROVIDER_URLS = {
24
49
  };
25
50
 
26
51
  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.';
52
+ 'Read {companionUrl} and help me understand it. Summarize the key points, then ask me one clarifying question to dig deeper.';
53
+
28
54
 
29
55
  export default function AskAiButton() {
30
56
  const { siteConfig } = useDocusaurusContext();
@@ -37,10 +63,30 @@ export default function AskAiButton() {
37
63
 
38
64
  if (cfg.enabled === false) return null;
39
65
 
40
- const baseUrl = (siteConfig.url || '').replace(/\/$/, '');
41
- const pageUrl = `${baseUrl}${location.pathname.replace(/\/$/, '') || ''}`;
66
+ const siteUrl = (siteConfig.url || '').replace(/\/+$/, '');
67
+ const route = normalizeRoute(location.pathname);
68
+ // siteConfig.baseUrl is the locale's base (/de/) in a localized build, so
69
+ // the localized home page resolves like the site root.
70
+ const pageUrl = pageUrlFor(siteUrl, route, siteConfig.baseUrl);
71
+
72
+ // Only pages with a companion get the button: a generated category index
73
+ // or a React page has no .md file, so a prompt pointing at one would
74
+ // send the reader to a 404. `companionRoutes` arrives from
75
+ // allContentLoaded; if an older core did not merge it, fall back to
76
+ // showing the button everywhere as before. With companions disabled the
77
+ // prompt uses the page itself.
78
+ const companionsEnabled = cfg.companionsEnabled !== false;
79
+ const routes = data.companionRoutes;
80
+ if (companionsEnabled && Array.isArray(routes)) {
81
+ const known = routes.some((r) => normalizeRoute(r) === route);
82
+ if (!known) return null;
83
+ }
84
+ const mdUrl = companionsEnabled
85
+ ? companionUrl(siteUrl, route, siteConfig.trailingSlash, siteConfig.baseUrl)
86
+ : pageUrl;
87
+
42
88
  const promptTemplate = cfg.promptTemplate || DEFAULT_PROMPT_TEMPLATE;
43
- const prompt = promptTemplate.replace('{pageUrl}', pageUrl);
89
+ const prompt = fillPrompt(promptTemplate, { pageUrl, companionUrl: mdUrl });
44
90
  const encoded = encodeURIComponent(prompt);
45
91
 
46
92
  const order =
@@ -50,6 +96,7 @@ export default function AskAiButton() {
50
96
 
51
97
  const handleOpen = (e) => setAnchorEl(e.currentTarget);
52
98
  const handleClose = () => setAnchorEl(null);
99
+ const labels = providerLabels();
53
100
 
54
101
  return (
55
102
  <div className={styles.dropdownWrapper}>
@@ -69,11 +116,16 @@ export default function AskAiButton() {
69
116
  color: 'var(--ifm-color-primary)',
70
117
  '&:hover': {
71
118
  borderColor: 'var(--ifm-color-primary)',
72
- backgroundColor: 'rgba(0, 65, 101, 0.04)',
119
+ // a 4% tint of the site's own primary colour
120
+ backgroundColor: 'color-mix(in srgb, var(--ifm-color-primary) 4%, transparent)',
73
121
  },
74
122
  }}
75
123
  >
76
- Ask AI about this page
124
+ {translate({
125
+ id: 'aeo.askAi.button',
126
+ message: 'Ask AI about this page',
127
+ description: 'Label of the Ask AI dropdown button',
128
+ })}
77
129
  </Button>
78
130
  <Menu
79
131
  anchorEl={anchorEl}
@@ -90,7 +142,7 @@ export default function AskAiButton() {
90
142
  >
91
143
  {order.map((key) => {
92
144
  const Icon = icons[key];
93
- const label = PROVIDER_LABELS[key];
145
+ const label = labels[key];
94
146
  const base = PROVIDER_URLS[key];
95
147
  if (!base || !Icon) return null;
96
148
  const href = `${base}${encoded}`;
File without changes
File without changes
File without changes
File without changes