@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/CHANGELOG.md +55 -0
- package/LICENSE +0 -0
- package/README.md +179 -45
- package/package.json +21 -5
- package/src/askAiPrompt.js +22 -0
- package/src/companionPath.js +89 -0
- package/src/features/aiRoutes.js +15 -6
- package/src/features/alternateLinks.js +133 -0
- package/src/features/companions.js +93 -104
- package/src/features/llmsTxt.js +126 -84
- package/src/features/plainMarkdown.js +623 -0
- package/src/helpers.js +34 -10
- package/src/index.js +70 -10
- package/src/routeGlob.js +60 -0
- package/src/theme/AskAiButton/brand-icons/ChatGpt.jsx +0 -0
- package/src/theme/AskAiButton/brand-icons/Claude.jsx +0 -0
- package/src/theme/AskAiButton/brand-icons/Perplexity.jsx +0 -0
- package/src/theme/AskAiButton/icons.js +0 -0
- package/src/theme/AskAiButton/index.jsx +64 -12
- package/src/theme/AskAiButton/styles.module.css +0 -0
- package/src/theme/BlogPostItem/Header/Title/index.jsx +0 -0
- package/src/theme/BlogPostItem/Header/Title/styles.module.css +0 -0
- package/src/theme/DocBreadcrumbs/index.jsx +0 -0
- package/src/theme/DocBreadcrumbs/styles.module.css +0 -0
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 {
|
|
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 || '
|
|
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
|
-
|
|
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 "
|
|
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
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
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,
|
package/src/routeGlob.js
ADDED
|
@@ -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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 {
|
|
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
|
|
41
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
File without changes
|