@stackql/docusaurus-plugin-aeo 0.1.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.
@@ -0,0 +1,234 @@
1
+ const path = require('path');
2
+ const fs = require('fs').promises;
3
+ const matter = require('gray-matter');
4
+
5
+ // Minimal glob matcher for route paths. Patterns like "/blog/tags/**",
6
+ // "/search". Avoids pulling micromatch directly.
7
+ function matchAny(value, patterns) {
8
+ if (!patterns || patterns.length === 0) return false;
9
+ for (const p of patterns) {
10
+ if (toRegExp(p).test(value)) return true;
11
+ }
12
+ return false;
13
+ }
14
+
15
+ function toRegExp(glob) {
16
+ // Minimal globstar -> regex translation. Supports: **, *, ?, character
17
+ // classes left untouched. Good enough for route paths like
18
+ // "/blog/tags/**" and "/search".
19
+ let re = '';
20
+ for (let i = 0; i < glob.length; i++) {
21
+ const c = glob[i];
22
+ if (c === '*') {
23
+ if (glob[i + 1] === '*') {
24
+ re += '.*';
25
+ i++;
26
+ if (glob[i + 1] === '/') i++;
27
+ } else {
28
+ re += '[^/]*';
29
+ }
30
+ } else if (c === '?') {
31
+ re += '[^/]';
32
+ } else if ('.+^$()|{}[]\\'.includes(c)) {
33
+ re += '\\' + c;
34
+ } else {
35
+ re += c;
36
+ }
37
+ }
38
+ return new RegExp('^' + re + '$');
39
+ }
40
+
41
+ function stripMdx(source) {
42
+ let out = source;
43
+ // Strip import / export lines (top-of-file MDX).
44
+ out = out.replace(/^\s*import\s+[^\n]+\n/gm, '');
45
+ out = out.replace(/^\s*export\s+[^\n]+\n/gm, '');
46
+ // Strip self-closing JSX tags like <Component prop="x" />.
47
+ out = out.replace(/<([A-Z][A-Za-z0-9]*)\b[^>]*\/>\s*/g, '');
48
+ // Strip paired JSX blocks like <Foo>...</Foo> (non-greedy, single component).
49
+ out = out.replace(
50
+ /<([A-Z][A-Za-z0-9]*)\b[^>]*>[\s\S]*?<\/\1>\s*/g,
51
+ '',
52
+ );
53
+ // Strip stray opening or closing component tags left behind.
54
+ out = out.replace(/<\/?[A-Z][A-Za-z0-9]*\b[^>]*>/g, '');
55
+ // Collapse 3+ blank lines.
56
+ out = out.replace(/\n{3,}/g, '\n\n');
57
+ return out;
58
+ }
59
+
60
+ function routeToCompanionPath(routePath, trailingSlash, outDir) {
61
+ // Docusaurus emits either /foo/index.html (trailingSlash: true / undefined)
62
+ // or /foo.html (trailingSlash: false). The companion mirrors the HTML.
63
+ let rel;
64
+ if (routePath === '/' || routePath === '') {
65
+ rel = 'index.md';
66
+ } else {
67
+ const cleaned = routePath.replace(/^\/+|\/+$/g, '');
68
+ if (trailingSlash === false) {
69
+ rel = `${cleaned}.md`;
70
+ } else {
71
+ // true or undefined (Docusaurus default behavior: index.html in folder)
72
+ rel = `${cleaned}/index.md`;
73
+ }
74
+ }
75
+ return path.join(outDir, rel);
76
+ }
77
+
78
+ function collectFromDocs(content, pluginName, pluginId) {
79
+ // Docs plugin loaded content shape:
80
+ // { loadedVersions: [{ docs: [{ id, permalink, source, title, description, frontMatter }] }] }
81
+ const results = [];
82
+ if (!content || !Array.isArray(content.loadedVersions)) return results;
83
+ for (const version of content.loadedVersions) {
84
+ if (!Array.isArray(version.docs)) continue;
85
+ for (const doc of version.docs) {
86
+ if (!doc.permalink || !doc.source) continue;
87
+ results.push({
88
+ permalink: doc.permalink,
89
+ // doc.source is prefixed with "@site/" - resolve later against siteDir.
90
+ sourceRef: doc.source,
91
+ title: doc.title,
92
+ description: doc.description,
93
+ frontMatter: doc.frontMatter || {},
94
+ kind: 'docs',
95
+ pluginName,
96
+ pluginId,
97
+ });
98
+ }
99
+ }
100
+ return results;
101
+ }
102
+
103
+ function collectFromBlog(content, pluginName, pluginId) {
104
+ // Blog plugin loaded content shape:
105
+ // { blogPosts: [{ id, metadata: { permalink, source, title, description, frontMatter } }] }
106
+ const results = [];
107
+ if (!content || !Array.isArray(content.blogPosts)) return results;
108
+ for (const post of content.blogPosts) {
109
+ const m = post.metadata || {};
110
+ if (!m.permalink || !m.source) continue;
111
+ results.push({
112
+ permalink: m.permalink,
113
+ sourceRef: m.source,
114
+ title: m.title,
115
+ description: m.description,
116
+ frontMatter: m.frontMatter || {},
117
+ kind: 'blog',
118
+ pluginName,
119
+ pluginId,
120
+ });
121
+ }
122
+ return results;
123
+ }
124
+
125
+ function resolveSourcePath(sourceRef, siteDir) {
126
+ if (!sourceRef) return null;
127
+ // Docusaurus content references look like "@site/docs/intro.md".
128
+ if (sourceRef.startsWith('@site/')) {
129
+ return path.join(siteDir, sourceRef.slice('@site/'.length));
130
+ }
131
+ if (path.isAbsolute(sourceRef)) return sourceRef;
132
+ return path.join(siteDir, sourceRef);
133
+ }
134
+
135
+ async function ensureDir(p) {
136
+ await fs.mkdir(path.dirname(p), { recursive: true });
137
+ }
138
+
139
+ module.exports = async function emitCompanions({
140
+ props,
141
+ options,
142
+ loadedContentByPlugin,
143
+ verbose,
144
+ }) {
145
+ const { outDir, siteConfig, siteDir } = props;
146
+ const trailingSlash = siteConfig.trailingSlash;
147
+ const excludePatterns = options.exclude || [];
148
+ const format = options.format;
149
+
150
+ // Build the work list from captured loadedContent.
151
+ const items = [];
152
+ for (const [, entry] of loadedContentByPlugin) {
153
+ const { pluginName, pluginId, content } = entry;
154
+ if (pluginName === 'docusaurus-plugin-content-docs') {
155
+ items.push(...collectFromDocs(content, pluginName, pluginId));
156
+ } else if (pluginName === 'docusaurus-plugin-content-blog') {
157
+ items.push(...collectFromBlog(content, pluginName, pluginId));
158
+ }
159
+ // Custom pages plugin emits React components, not markdown. Skip silently.
160
+ }
161
+
162
+ const emitted = [];
163
+ for (const item of items) {
164
+ if (matchAny(item.permalink, excludePatterns)) {
165
+ if (verbose) {
166
+ console.log(`[plugin-aeo] companions: excluded ${item.permalink}`);
167
+ }
168
+ continue;
169
+ }
170
+
171
+ const sourcePath = resolveSourcePath(item.sourceRef, siteDir);
172
+ if (!sourcePath) {
173
+ if (verbose) {
174
+ console.log(
175
+ `[plugin-aeo] companions: skipping ${item.permalink} (no source)`,
176
+ );
177
+ }
178
+ continue;
179
+ }
180
+
181
+ let raw;
182
+ try {
183
+ raw = await fs.readFile(sourcePath, 'utf8');
184
+ } catch (e) {
185
+ if (verbose) {
186
+ console.log(
187
+ `[plugin-aeo] companions: failed to read source for ${item.permalink}: ${e.message}`,
188
+ );
189
+ }
190
+ continue;
191
+ }
192
+
193
+ let body = raw;
194
+ if (format === 'plain') {
195
+ // Strip frontmatter, then strip MDX-specific bits.
196
+ const parsed = matter(raw);
197
+ body = stripMdx(parsed.content);
198
+ // Re-prepend a minimal "title + description" block so an LLM can see them
199
+ // without parsing YAML.
200
+ const prefixParts = [];
201
+ if (item.title) prefixParts.push(`# ${item.title}`);
202
+ if (item.description) prefixParts.push(`> ${item.description}`);
203
+ if (prefixParts.length > 0) {
204
+ body = `${prefixParts.join('\n\n')}\n\n${body.trim()}\n`;
205
+ }
206
+ }
207
+
208
+ const target = routeToCompanionPath(item.permalink, trailingSlash, outDir);
209
+ await ensureDir(target);
210
+ await fs.writeFile(target, body, 'utf8');
211
+
212
+ emitted.push({
213
+ permalink: item.permalink,
214
+ companionPath: target,
215
+ title: item.title,
216
+ description: item.description,
217
+ kind: item.kind,
218
+ pluginName: item.pluginName,
219
+ pluginId: item.pluginId,
220
+ frontMatter: item.frontMatter,
221
+ });
222
+
223
+ if (verbose) {
224
+ console.log(
225
+ `[plugin-aeo] companions: emitted ${path.relative(outDir, target)}`,
226
+ );
227
+ }
228
+ }
229
+
230
+ if (verbose) {
231
+ console.log(`[plugin-aeo] companions: emitted ${emitted.length} file(s)`);
232
+ }
233
+ return emitted;
234
+ };
@@ -0,0 +1,175 @@
1
+ const path = require('path');
2
+ const fs = require('fs').promises;
3
+
4
+ function matchAny(value, patterns) {
5
+ if (!patterns || patterns.length === 0) return false;
6
+ for (const p of patterns) {
7
+ if (toRegExp(p).test(value)) return true;
8
+ }
9
+ return false;
10
+ }
11
+
12
+ function toRegExp(glob) {
13
+ let re = '';
14
+ for (let i = 0; i < glob.length; i++) {
15
+ const c = glob[i];
16
+ if (c === '*') {
17
+ if (glob[i + 1] === '*') {
18
+ re += '.*';
19
+ i++;
20
+ if (glob[i + 1] === '/') i++;
21
+ } else {
22
+ re += '[^/]*';
23
+ }
24
+ } else if (c === '?') {
25
+ re += '[^/]';
26
+ } else if ('.+^$()|{}[]\\'.includes(c)) {
27
+ re += '\\' + c;
28
+ } else {
29
+ re += c;
30
+ }
31
+ }
32
+ return new RegExp('^' + re + '$');
33
+ }
34
+
35
+ function companionUrl(permalink, trailingSlash) {
36
+ if (permalink === '/' || permalink === '') {
37
+ return '/index.md';
38
+ }
39
+ const cleaned = permalink.replace(/\/+$/, '');
40
+ if (trailingSlash === false) {
41
+ return `${cleaned}.md`;
42
+ }
43
+ return `${cleaned}/index.md`;
44
+ }
45
+
46
+ function groupKind(kind, pluginId) {
47
+ if (kind === 'docs') return 'docs';
48
+ if (kind === 'blog') return 'blog';
49
+ if (kind === 'pages') return 'pages';
50
+ // Treat additional content-docs instances (id !== 'default') as docs too.
51
+ if (pluginId && pluginId !== 'default') return 'docs';
52
+ return 'pages';
53
+ }
54
+
55
+ module.exports = async function emitLlmsTxt({
56
+ props,
57
+ options,
58
+ emittedCompanions,
59
+ companionsEnabled,
60
+ verbose,
61
+ }) {
62
+ const { outDir, siteConfig } = props;
63
+ const trailingSlash = siteConfig.trailingSlash;
64
+
65
+ // Filter the companions through include/exclude rules.
66
+ const filtered = emittedCompanions.filter((item) => {
67
+ if (options.include && !options.include.some((p) => toRegExp(p).test(item.permalink))) {
68
+ return false;
69
+ }
70
+ if (matchAny(item.permalink, options.exclude)) {
71
+ return false;
72
+ }
73
+ return true;
74
+ });
75
+
76
+ // Group by kind.
77
+ const groups = { docs: [], blog: [], pages: [] };
78
+ for (const item of filtered) {
79
+ const g = groupKind(item.kind, item.pluginId);
80
+ groups[g].push(item);
81
+ }
82
+
83
+ // Build llms.txt.
84
+ const lines = [];
85
+ lines.push(`# ${siteConfig.title}`);
86
+ lines.push('');
87
+ if (siteConfig.tagline) {
88
+ lines.push(`> ${siteConfig.tagline}`);
89
+ lines.push('');
90
+ }
91
+ if (options.header) {
92
+ lines.push(options.header.trim());
93
+ lines.push('');
94
+ }
95
+
96
+ const sectionOrder = [
97
+ ['docs', options.sections.docs],
98
+ ['blog', options.sections.blog],
99
+ ['pages', options.sections.pages],
100
+ ];
101
+
102
+ for (const [key, title] of sectionOrder) {
103
+ const items = groups[key];
104
+ if (!items || items.length === 0) continue;
105
+ lines.push(`## ${title}`);
106
+ lines.push('');
107
+ // Stable order: by permalink.
108
+ items.sort((a, b) => a.permalink.localeCompare(b.permalink));
109
+ for (const item of items) {
110
+ const url = companionsEnabled
111
+ ? companionUrl(item.permalink, trailingSlash)
112
+ : item.permalink;
113
+ const desc = item.description || siteConfig.tagline || '';
114
+ const titleText = item.title || item.permalink;
115
+ if (desc) {
116
+ lines.push(`- [${titleText}](${url}): ${desc}`);
117
+ } else {
118
+ lines.push(`- [${titleText}](${url})`);
119
+ }
120
+ }
121
+ lines.push('');
122
+ }
123
+
124
+ const llmsTxtPath = path.join(outDir, 'llms.txt');
125
+ await fs.writeFile(llmsTxtPath, lines.join('\n'), 'utf8');
126
+ if (verbose) {
127
+ console.log(`[plugin-aeo] llmsTxt: wrote ${llmsTxtPath}`);
128
+ }
129
+
130
+ // Build llms-full.txt by concatenating the companion files that we just
131
+ // emitted in feature 1. Requires feature 1 to have been enabled.
132
+ if (options.fullTxt) {
133
+ if (!companionsEnabled) {
134
+ if (verbose) {
135
+ console.log(
136
+ '[plugin-aeo] llmsTxt: companions disabled, skipping llms-full.txt',
137
+ );
138
+ }
139
+ return;
140
+ }
141
+
142
+ const blocks = [];
143
+ for (const [key] of sectionOrder) {
144
+ const items = groups[key];
145
+ if (!items) continue;
146
+ for (const item of items) {
147
+ let body;
148
+ try {
149
+ body = await fs.readFile(item.companionPath, 'utf8');
150
+ } catch (e) {
151
+ if (verbose) {
152
+ console.log(
153
+ `[plugin-aeo] llmsTxt: cannot read ${item.companionPath}: ${e.message}`,
154
+ );
155
+ }
156
+ continue;
157
+ }
158
+ const url = `${(siteConfig.url || '').replace(/\/$/, '')}${item.permalink}`;
159
+ const header = [
160
+ `# ${item.title || item.permalink}`,
161
+ '',
162
+ `Source: ${url}`,
163
+ '',
164
+ ].join('\n');
165
+ blocks.push(`${header}${body.trim()}\n`);
166
+ }
167
+ }
168
+
169
+ const fullPath = path.join(outDir, 'llms-full.txt');
170
+ await fs.writeFile(fullPath, blocks.join('\n---\n\n'), 'utf8');
171
+ if (verbose) {
172
+ console.log(`[plugin-aeo] llmsTxt: wrote ${fullPath}`);
173
+ }
174
+ }
175
+ };
package/src/helpers.js ADDED
@@ -0,0 +1,61 @@
1
+ // Helpers exported as "@stackql/docusaurus-plugin-aeo/helpers".
2
+ //
3
+ // These are pure data helpers - consumers spread them into other plugin
4
+ // configs so that /ai/* routes don't pollute artifacts aimed at humans
5
+ // (sitemaps, breadcrumb graphs, etc.).
6
+
7
+ // Glob patterns matching the /ai/* convention. Suitable for plugins whose
8
+ // exclude lists accept globs (notably @docusaurus/plugin-sitemap, which
9
+ // uses `ignorePatterns`).
10
+ const AI_ROUTE_PATTERNS = ['/ai', '/ai/**'];
11
+
12
+ // Sitemap slice for @docusaurus/plugin-sitemap (standalone or via preset).
13
+ // Usage:
14
+ // [
15
+ // '@docusaurus/plugin-sitemap',
16
+ // { ...sitemapExclude, /* your other sitemap options */ },
17
+ // ]
18
+ const sitemapExclude = {
19
+ ignorePatterns: [...AI_ROUTE_PATTERNS],
20
+ };
21
+
22
+ // Predicate consumers can use in custom code or when building an exact
23
+ // excludedRoutes list for plugins that don't accept globs (e.g.
24
+ // @stackql/docusaurus-plugin-structured-data, whose `excludedRoutes`
25
+ // is an exact-match array).
26
+ function isAiRoute(permalink) {
27
+ return typeof permalink === 'string' && permalink.startsWith('/ai/');
28
+ }
29
+
30
+ // Build an exact-match exclude list for @stackql/docusaurus-plugin-
31
+ // structured-data. That plugin compares `themeConfig.structuredData
32
+ // .excludedRoutes` with strict equality, so globs don't work there.
33
+ //
34
+ // Pass an array of route permalinks (typically the docs/blog plugin
35
+ // outputs, or your own enumerated list); the function returns the subset
36
+ // that matches /ai/*. Spread the result into your structuredData config:
37
+ //
38
+ // themeConfig: {
39
+ // structuredData: {
40
+ // excludedRoutes: [
41
+ // ...buildStructuredDataAiExcludes(allRoutePaths),
42
+ // // ...other exact-match routes you want to skip
43
+ // ],
44
+ // // ...rest of structuredData config
45
+ // },
46
+ // }
47
+ //
48
+ // If you don't have the route list at config time, list the known
49
+ // /ai/* parents by hand (e.g. ['/ai', '/ai/faqs/install', ...]); the
50
+ // glob-based AI_ROUTE_PATTERNS will NOT work for that plugin.
51
+ function buildStructuredDataAiExcludes(allRoutePaths) {
52
+ if (!Array.isArray(allRoutePaths)) return [];
53
+ return allRoutePaths.filter(isAiRoute).concat(['/ai']);
54
+ }
55
+
56
+ module.exports = {
57
+ AI_ROUTE_PATTERNS,
58
+ sitemapExclude,
59
+ isAiRoute,
60
+ buildStructuredDataAiExcludes,
61
+ };
package/src/index.js ADDED
@@ -0,0 +1,200 @@
1
+ const path = require('path');
2
+ const emitCompanions = require('./features/companions');
3
+ const emitLlmsTxt = require('./features/llmsTxt');
4
+ const { validateAiRoutes } = require('./features/aiRoutes');
5
+
6
+ const DEFAULT_LLMS_EXCLUDE = [
7
+ '/search',
8
+ '/404',
9
+ '/blog/tags/**',
10
+ '/blog/page/**',
11
+ '/blog/archive',
12
+ '/blog/authors/**',
13
+ ];
14
+
15
+ const DEFAULT_PROVIDER_ORDER = ['claude', 'chatgpt', 'perplexity', 'gemini'];
16
+ const VALID_PROVIDERS = new Set(DEFAULT_PROVIDER_ORDER);
17
+ const DEFAULT_PROMPT_TEMPLATE =
18
+ 'Read {pageUrl}.md and help me with the following question about it: ';
19
+
20
+ function normalizeOptions(raw) {
21
+ const opts = raw || {};
22
+ const companions = opts.companions || {};
23
+ const llmsTxt = opts.llmsTxt || {};
24
+ const askAi = opts.askAi || {};
25
+ const aiRoutes = opts.aiRoutes || {};
26
+ const llmsSections = llmsTxt.sections || {};
27
+
28
+ return {
29
+ companions: {
30
+ enabled: companions.enabled !== false,
31
+ format: companions.format || 'raw',
32
+ exclude: companions.exclude || [],
33
+ },
34
+ llmsTxt: {
35
+ enabled: llmsTxt.enabled !== false,
36
+ exclude: llmsTxt.exclude || DEFAULT_LLMS_EXCLUDE,
37
+ include: llmsTxt.include || null,
38
+ header: llmsTxt.header || null,
39
+ sections: {
40
+ docs: llmsSections.docs || 'Documentation',
41
+ blog: llmsSections.blog || 'Blog',
42
+ pages: llmsSections.pages || 'Pages',
43
+ },
44
+ fullTxt: llmsTxt.fullTxt !== false,
45
+ },
46
+ askAi: {
47
+ enabled: askAi.enabled !== false,
48
+ providerOrder: askAi.providerOrder || DEFAULT_PROVIDER_ORDER,
49
+ promptTemplate: askAi.promptTemplate || DEFAULT_PROMPT_TEMPLATE,
50
+ placement: askAi.placement || 'doc-footer',
51
+ },
52
+ aiRoutes: {
53
+ validate: aiRoutes.validate === true,
54
+ },
55
+ verbose: opts.verbose === true,
56
+ };
57
+ }
58
+
59
+ function validateOptions(opts) {
60
+ const errs = [];
61
+
62
+ if (!['raw', 'plain'].includes(opts.companions.format)) {
63
+ errs.push(
64
+ `companions.format must be "raw" or "plain", got "${opts.companions.format}"`,
65
+ );
66
+ }
67
+ if (!Array.isArray(opts.companions.exclude)) {
68
+ errs.push('companions.exclude must be an array of glob patterns');
69
+ }
70
+
71
+ if (!Array.isArray(opts.llmsTxt.exclude)) {
72
+ errs.push('llmsTxt.exclude must be an array of glob patterns');
73
+ }
74
+ if (opts.llmsTxt.include !== null && !Array.isArray(opts.llmsTxt.include)) {
75
+ errs.push('llmsTxt.include must be an array of glob patterns or null');
76
+ }
77
+ if (opts.llmsTxt.header !== null && typeof opts.llmsTxt.header !== 'string') {
78
+ errs.push('llmsTxt.header must be a string or null');
79
+ }
80
+
81
+ if (!Array.isArray(opts.askAi.providerOrder)) {
82
+ errs.push('askAi.providerOrder must be an array');
83
+ } else {
84
+ for (const p of opts.askAi.providerOrder) {
85
+ if (!VALID_PROVIDERS.has(p)) {
86
+ errs.push(
87
+ `askAi.providerOrder contains unknown provider "${p}". Valid: ${[...VALID_PROVIDERS].join(', ')}`,
88
+ );
89
+ }
90
+ }
91
+ }
92
+ if (typeof opts.askAi.promptTemplate !== 'string') {
93
+ errs.push('askAi.promptTemplate must be a string');
94
+ }
95
+ if (!['doc-footer', 'none'].includes(opts.askAi.placement)) {
96
+ errs.push(
97
+ `askAi.placement must be "doc-footer" or "none", got "${opts.askAi.placement}"`,
98
+ );
99
+ }
100
+
101
+ if (errs.length > 0) {
102
+ throw new Error(
103
+ `@stackql/docusaurus-plugin-aeo: invalid options:\n - ${errs.join('\n - ')}`,
104
+ );
105
+ }
106
+ }
107
+
108
+ module.exports = function pluginAeo(context, rawOptions) {
109
+ const options = normalizeOptions(rawOptions);
110
+ validateOptions(options);
111
+
112
+ // Captured from contentLoaded for downstream postBuild use.
113
+ // Map<pluginName, { plugin: { name, id }, content: any }>
114
+ const loadedContentByPlugin = new Map();
115
+
116
+ return {
117
+ name: '@stackql/docusaurus-plugin-aeo',
118
+
119
+ getThemePath() {
120
+ if (!options.askAi.enabled || options.askAi.placement === 'none') {
121
+ return undefined;
122
+ }
123
+ return path.resolve(__dirname, './theme');
124
+ },
125
+
126
+ getClientModules() {
127
+ return [];
128
+ },
129
+
130
+ // Surface the askAi config to theme components via a global data
131
+ // injection. Docusaurus client code can read this through useDocusaurusContext().
132
+ async contentLoaded({ actions, allContent }) {
133
+ if (allContent) {
134
+ for (const [pluginName, byId] of Object.entries(allContent)) {
135
+ if (!byId) continue;
136
+ for (const [pluginId, content] of Object.entries(byId)) {
137
+ loadedContentByPlugin.set(`${pluginName}@${pluginId}`, {
138
+ pluginName,
139
+ pluginId,
140
+ content,
141
+ });
142
+ }
143
+ }
144
+ }
145
+
146
+ await actions.setGlobalData({
147
+ askAi: {
148
+ enabled: options.askAi.enabled,
149
+ providerOrder: options.askAi.providerOrder,
150
+ promptTemplate: options.askAi.promptTemplate,
151
+ placement: options.askAi.placement,
152
+ companionsEnabled: options.companions.enabled,
153
+ },
154
+ });
155
+
156
+ if (options.aiRoutes.validate) {
157
+ validateAiRoutes({
158
+ loadedContentByPlugin,
159
+ verbose: options.verbose,
160
+ });
161
+ }
162
+ },
163
+
164
+ async postBuild(props) {
165
+ let emittedCompanions = [];
166
+
167
+ if (options.companions.enabled) {
168
+ emittedCompanions = await emitCompanions({
169
+ props,
170
+ options: options.companions,
171
+ loadedContentByPlugin,
172
+ verbose: options.verbose,
173
+ });
174
+ } else if (options.verbose) {
175
+ console.log('[plugin-aeo] companions disabled, skipping feature 1');
176
+ }
177
+
178
+ if (options.llmsTxt.enabled) {
179
+ await emitLlmsTxt({
180
+ props,
181
+ options: options.llmsTxt,
182
+ emittedCompanions,
183
+ companionsEnabled: options.companions.enabled,
184
+ verbose: options.verbose,
185
+ });
186
+ } else if (options.verbose) {
187
+ console.log('[plugin-aeo] llmsTxt disabled, skipping feature 2');
188
+ }
189
+ },
190
+ };
191
+ };
192
+
193
+ module.exports.validateOptions = function validateDocusaurusOptions({
194
+ options,
195
+ validate: _validate,
196
+ }) {
197
+ // Docusaurus passes a Joi validator we deliberately don't use - the plugin
198
+ // entry runs its own handwritten validator at construction time.
199
+ return options || {};
200
+ };