@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.
@@ -0,0 +1,89 @@
1
+ // The one rule for where a page's `.md` companion lives, shared by the
2
+ // emitter (feature 1), llms.txt (feature 2) and the Ask AI button (feature 3)
3
+ // so the three can never disagree.
4
+ //
5
+ // Docusaurus emits either /foo/index.html (trailingSlash true or undefined)
6
+ // or /foo.html (trailingSlash false); the companion mirrors the HTML:
7
+ //
8
+ // route trailingSlash: false trailingSlash: true / undefined
9
+ // / /index.md /index.md
10
+ // /foo /foo.md /foo/index.md
11
+ // /foo/ /foo.md /foo/index.md
12
+ // /docs/intro /docs/intro.md /docs/intro/index.md
13
+ //
14
+ // Plain CommonJS with no dependencies: required by the Node-side features
15
+ // and bundled into the theme component by webpack.
16
+
17
+ function normalizeRoute(routePath) {
18
+ const cleaned = String(routePath || '').replace(/\/+$/, '');
19
+ return cleaned === '' ? '/' : cleaned;
20
+ }
21
+
22
+ // Every function below takes an optional `baseUrl` (siteConfig.baseUrl).
23
+ // Docusaurus permalinks and location.pathname carry it, and the part of
24
+ // the route that is the base is a root, not a page: on a site built at
25
+ // '/docs/' or in a localized build (baseUrl '/de/') the route '/de/' is the
26
+ // home page, whose companion is '/de/index.md', not '/de.md'. With the
27
+ // default '/' nothing changes.
28
+ function withBase(baseUrl, sitePath) {
29
+ return `${String(baseUrl || '/').replace(/\/+$/, '')}${sitePath}`;
30
+ }
31
+
32
+ // Site-relative companion path for a route, e.g. '/foo.md'. Always starts
33
+ // with '/'.
34
+ function companionPath(routePath, trailingSlash, baseUrl) {
35
+ const route = outputRoute(routePath, baseUrl);
36
+ const rel = route === '/'
37
+ ? '/index.md'
38
+ : trailingSlash === false ? `${route}.md` : `${route}/index.md`;
39
+ return withBase(baseUrl, rel);
40
+ }
41
+
42
+ // Absolute companion URL for a route, e.g. 'https://example.com/foo.md'.
43
+ // `siteUrl` is siteConfig.url.
44
+ function companionUrl(siteUrl, routePath, trailingSlash, baseUrl) {
45
+ return `${String(siteUrl || '').replace(/\/+$/, '')}${companionPath(routePath, trailingSlash, baseUrl)}`;
46
+ }
47
+
48
+ // Site-relative path of the HTML file Docusaurus writes for a route, the
49
+ // same rule as @docusaurus/core's pathnameToFilename once the route has
50
+ // been normalised: '/foo.html' when trailingSlash is false, '/foo/index.html'
51
+ // otherwise, '/index.html' for the root.
52
+ function htmlPath(routePath, trailingSlash, baseUrl) {
53
+ const route = outputRoute(routePath, baseUrl);
54
+ const rel = route === '/'
55
+ ? '/index.html'
56
+ : trailingSlash === false ? `${route}.html` : `${route}/index.html`;
57
+ return withBase(baseUrl, rel);
58
+ }
59
+
60
+ // Absolute URL of the page itself: canonical, no trailing slash except on
61
+ // the root (the site root, or the localized root '/de/').
62
+ function pageUrl(siteUrl, routePath, baseUrl) {
63
+ const base = String(siteUrl || '').replace(/\/+$/, '');
64
+ const route = outputRoute(routePath, baseUrl);
65
+ return `${base}${withBase(baseUrl, route)}`;
66
+ }
67
+
68
+ // The route relative to the build output tree. Docusaurus permalinks carry
69
+ // siteConfig.baseUrl, but the output tree is rooted at it: a site with
70
+ // baseUrl '/docs/' writes /docs/intro to build/intro.html, and a localized
71
+ // build (baseUrl '/de/', outDir build/de) writes /de/intro to
72
+ // build/de/intro.html. URLs keep the full route (pass baseUrl to the
73
+ // functions above); file paths under outDir use this route with no base.
74
+ function outputRoute(routePath, baseUrl) {
75
+ return stripBase(normalizeRoute(routePath), baseUrl);
76
+ }
77
+
78
+ // A route with siteConfig.baseUrl removed and nothing else changed (a
79
+ // trailing slash stays): '/de/blog/post/' under '/de/' is '/blog/post/', and
80
+ // the base itself is '/'. A route outside the base is returned as is.
81
+ function stripBase(routePath, baseUrl) {
82
+ const route = String(routePath || '');
83
+ const base = String(baseUrl || '/').replace(/\/+$/, '');
84
+ if (base === '') return route;
85
+ if (route === base) return '/';
86
+ return route.startsWith(`${base}/`) ? route.slice(base.length) : route;
87
+ }
88
+
89
+ module.exports = { companionPath, companionUrl, htmlPath, pageUrl, outputRoute, stripBase, normalizeRoute };
@@ -5,26 +5,35 @@
5
5
  // ai/faqs/<slug>.md -> must declare a `faq` frontmatter object
6
6
  // ai/howto/<slug>.md -> must declare a `howTo` frontmatter object
7
7
  // ai/howtos/<slug>.md -> same as above
8
+ // ai/how-tos/<slug>.md -> same as above
8
9
  // ai/apps/<slug>.md -> must declare a `softwareApplication`
9
10
  // frontmatter object
10
11
  //
11
12
  // Anything else under /ai/* is permitted without a payload (consumer can
12
13
  // extend this with additional checks if needed).
13
14
 
15
+ const { outputRoute } = require('../companionPath');
16
+
14
17
  const KIND_FOR_DIR = {
15
18
  faqs: 'faq',
16
19
  faq: 'faq',
17
20
  howto: 'howTo',
18
21
  howtos: 'howTo',
22
+ 'how-tos': 'howTo',
23
+ 'how-to': 'howTo',
19
24
  apps: 'softwareApplication',
20
25
  app: 'softwareApplication',
21
26
  applications: 'softwareApplication',
22
27
  };
23
28
 
24
- function classify(permalink) {
25
- // permalink like "/ai/faqs/foo"
26
- if (!permalink.startsWith('/ai/')) return null;
27
- const segments = permalink.split('/').filter(Boolean); // ["ai", "faqs", "foo"]
29
+ // `baseUrl` is siteConfig.baseUrl. The convention is relative to it: on a
30
+ // site built at '/docs/' the routes are '/docs/ai/...', and in a localized
31
+ // build '/de/ai/...'.
32
+ function classify(permalink, baseUrl) {
33
+ // route like "/ai/faqs/foo"
34
+ const route = outputRoute(permalink, baseUrl);
35
+ if (!route.startsWith('/ai/')) return null;
36
+ const segments = route.split('/').filter(Boolean); // ["ai", "faqs", "foo"]
28
37
  if (segments.length < 2) return null;
29
38
  const dir = segments[1].toLowerCase();
30
39
  return KIND_FOR_DIR[dir] || null;
@@ -42,7 +51,7 @@ function collectDocs(content) {
42
51
  return out;
43
52
  }
44
53
 
45
- function validateAiRoutes({ loadedContentByPlugin, verbose }) {
54
+ function validateAiRoutes({ loadedContentByPlugin, baseUrl, verbose }) {
46
55
  const problems = [];
47
56
  for (const [, entry] of loadedContentByPlugin) {
48
57
  if (entry.pluginName !== 'docusaurus-plugin-content-docs') continue;
@@ -50,7 +59,7 @@ function validateAiRoutes({ loadedContentByPlugin, verbose }) {
50
59
  for (const doc of docs) {
51
60
  const permalink = doc.permalink;
52
61
  if (!permalink) continue;
53
- const expectedKey = classify(permalink);
62
+ const expectedKey = classify(permalink, baseUrl);
54
63
  if (!expectedKey) continue;
55
64
  const fm = doc.frontMatter || {};
56
65
  const value = fm[expectedKey];
@@ -0,0 +1,133 @@
1
+ // Feature 1b: advertise each page's .md companion from the page itself.
2
+ //
3
+ // After the companions are written, every route that got one has
4
+ //
5
+ // <link rel="alternate" type="text/markdown" href="https://site/foo.md">
6
+ //
7
+ // inserted into its built HTML before </head>, so an agent that lands on
8
+ // the page can find the markdown without guessing the URL. Optionally the
9
+ // same relation is written as an HTTP header via a `_headers` file
10
+ // (`Link: <url>; rel="alternate"; type="text/markdown"`), for agents that
11
+ // read headers before bodies. Only hosts that read `_headers` use it
12
+ // (Netlify, Cloudflare Pages); elsewhere the file is inert and the <link>
13
+ // tag is what advertises the companion. The file sits at the root of the
14
+ // build directory, shared by every locale built into it.
15
+ //
16
+ // Same approach as @stackql/docusaurus-plugin-structured-data, which also
17
+ // post-processes the built HTML in postBuild: the file is a string, the
18
+ // insertion point is the first </head>, no DOM library.
19
+
20
+ const path = require('path');
21
+ const fs = require('fs').promises;
22
+ const { htmlPath, companionUrl, outputRoute } = require('../companionPath');
23
+
24
+ const MARK = 'rel="alternate" type="text/markdown"';
25
+
26
+ function linkTag(href) {
27
+ return `<link ${MARK} href="${href}">`;
28
+ }
29
+
30
+ function headerBlock(route, href) {
31
+ return `${route}\n Link: <${href}>; rel="alternate"; type="text/markdown"\n`;
32
+ }
33
+
34
+ // Where `_headers` goes. The hosts that read the file (Netlify, Cloudflare
35
+ // Pages) document it at the root of the published directory, so that is
36
+ // the only place it can be relied on. A build of several locales puts
37
+ // every locale but the default in a sub-directory of the build directory
38
+ // named after the locale (baseUrl '/de/' or '/docs/de/' -> build/de), so
39
+ // that locale's rules belong one level up, in the file the default
40
+ // locale's build wrote (Docusaurus builds the default locale first). This
41
+ // assumes the build directory is deployed as one site. Every other build
42
+ // is its own root: one locale, a locale built on its own (`--locale de`,
43
+ // baseUrl '/'), a locale given its own baseUrl, or a core that passes no
44
+ // i18n.
45
+ function headersDir({ outDir, siteConfig, i18n }) {
46
+ const locale = i18n && i18n.currentLocale;
47
+ if (!locale || locale === i18n.defaultLocale) return outDir;
48
+ const base = String(siteConfig.baseUrl || '/').replace(/\/+$/, '');
49
+ const nested = base.endsWith(`/${locale}`) && path.basename(outDir) === locale;
50
+ return nested ? path.dirname(outDir) : outDir;
51
+ }
52
+
53
+ async function readIfExists(file) {
54
+ try {
55
+ return await fs.readFile(file, 'utf8');
56
+ } catch (e) {
57
+ if (e.code === 'ENOENT') return null;
58
+ throw e;
59
+ }
60
+ }
61
+
62
+ module.exports = async function injectAlternateLinks({
63
+ props,
64
+ emittedCompanions,
65
+ linkHeader,
66
+ verbose,
67
+ }) {
68
+ const { outDir, siteConfig } = props;
69
+ const trailingSlash = siteConfig.trailingSlash;
70
+ const siteUrl = siteConfig.url;
71
+
72
+ let injected = 0;
73
+ let skipped = 0;
74
+ const headerBlocks = [];
75
+
76
+ for (const item of emittedCompanions) {
77
+ const href = companionUrl(siteUrl, item.permalink, trailingSlash, siteConfig.baseUrl);
78
+ // The HTML lives under outDir at the route without siteConfig.baseUrl
79
+ // (see outputRoute); the URL and the _headers route keep the full one.
80
+ const file = path.join(
81
+ outDir,
82
+ htmlPath(outputRoute(item.permalink, siteConfig.baseUrl), trailingSlash),
83
+ );
84
+ const html = await readIfExists(file);
85
+ if (html === null) {
86
+ skipped++;
87
+ if (verbose) {
88
+ console.log(`[plugin-aeo] alternate link: no HTML at ${path.relative(outDir, file)} for ${item.permalink}`);
89
+ }
90
+ continue;
91
+ }
92
+ if (!html.includes(MARK)) {
93
+ const at = html.indexOf('</head>');
94
+ if (at === -1) {
95
+ skipped++;
96
+ if (verbose) {
97
+ console.log(`[plugin-aeo] alternate link: no </head> in ${path.relative(outDir, file)}`);
98
+ }
99
+ continue;
100
+ }
101
+ await fs.writeFile(file, `${html.slice(0, at)}${linkTag(href)}${html.slice(at)}`, 'utf8');
102
+ injected++;
103
+ }
104
+ if (linkHeader) {
105
+ // A `_headers` rule matches the path as requested; with
106
+ // trailingSlash false that is the bare route, otherwise the route
107
+ // with its slash.
108
+ const route = htmlPath(item.permalink, trailingSlash, siteConfig.baseUrl).replace(/index\.html$/, '').replace(/\.html$/, '');
109
+ headerBlocks.push(headerBlock(route === '' ? '/' : route, href));
110
+ }
111
+ }
112
+
113
+ if (linkHeader && headerBlocks.length > 0) {
114
+ // Append to a `_headers` the site may already ship from static/, or
115
+ // that another locale's build has already added its rules to. A rule
116
+ // already in the file is not written again.
117
+ const file = path.join(headersDir(props), '_headers');
118
+ const existing = (await readIfExists(file)) || '';
119
+ const fresh = headerBlocks.filter((block) => !existing.includes(block));
120
+ if (fresh.length > 0) {
121
+ const banner = '# @stackql/docusaurus-plugin-aeo: .md companions as Link headers\n';
122
+ const sep = existing && !existing.endsWith('\n') ? '\n\n' : existing ? '\n' : '';
123
+ await fs.writeFile(file, `${existing}${sep}${banner}${fresh.join('')}`, 'utf8');
124
+ }
125
+ }
126
+
127
+ if (verbose) {
128
+ console.log(
129
+ `[plugin-aeo] alternate link: injected into ${injected} page(s)${skipped ? `, skipped ${skipped}` : ''}${linkHeader ? `, ${headerBlocks.length} _headers rule(s)` : ''}`,
130
+ );
131
+ }
132
+ return { injected, skipped, headerRules: headerBlocks.length };
133
+ };
@@ -1,79 +1,9 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs').promises;
3
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
- }
4
+ const { companionPath, pageUrl, outputRoute } = require('../companionPath');
5
+ const { matchAny, matchesRoute } = require('../routeGlob');
6
+ const { toPlainMarkdown, resolveFormat } = require('./plainMarkdown');
77
7
 
78
8
  function collectFromDocs(content, pluginName, pluginId) {
79
9
  // Docs plugin loaded content shape:
@@ -122,6 +52,28 @@ function collectFromBlog(content, pluginName, pluginId) {
122
52
  return results;
123
53
  }
124
54
 
55
+ // Every page that gets a companion: docs and blog pages with a source file,
56
+ // minus `companions.exclude`. Generated category indexes, React pages and
57
+ // blog list/tag/author pages have no source and are not in this list. Used
58
+ // by postBuild to emit the files and by allContentLoaded to tell the Ask AI
59
+ // button which routes have one. `baseUrl` is siteConfig.baseUrl: an exclude
60
+ // pattern matches a route with or without it (see src/routeGlob.js), so the
61
+ // same patterns apply in every locale's build.
62
+ function collectCompanionItems(loadedContentByPlugin, options, baseUrl) {
63
+ const items = [];
64
+ for (const [, entry] of loadedContentByPlugin) {
65
+ const { pluginName, pluginId, content } = entry;
66
+ if (pluginName === 'docusaurus-plugin-content-docs') {
67
+ items.push(...collectFromDocs(content, pluginName, pluginId));
68
+ } else if (pluginName === 'docusaurus-plugin-content-blog') {
69
+ items.push(...collectFromBlog(content, pluginName, pluginId));
70
+ }
71
+ // Custom pages plugin emits React components, not markdown. Skip silently.
72
+ }
73
+ const exclude = (options && options.exclude) || [];
74
+ return items.filter((item) => !matchesRoute(item.permalink, exclude, baseUrl));
75
+ }
76
+
125
77
  function resolveSourcePath(sourceRef, siteDir) {
126
78
  if (!sourceRef) return null;
127
79
  // Docusaurus content references look like "@site/docs/intro.md".
@@ -136,6 +88,34 @@ async function ensureDir(p) {
136
88
  await fs.mkdir(path.dirname(p), { recursive: true });
137
89
  }
138
90
 
91
+ // Put the page's title, description and canonical URL at the top of a
92
+ // plain companion so an LLM sees them without parsing YAML, and can cite
93
+ // the page:
94
+ //
95
+ // # Title
96
+ //
97
+ // > description
98
+ //
99
+ // Source: https://site/route
100
+ //
101
+ // A body that already opens with an H1 keeps it; the rest goes under it.
102
+ function withTitleBlock(markdown, title, description, sourceUrl) {
103
+ const lines = markdown.split('\n');
104
+ const first = lines.findIndex((l) => l.trim() !== '');
105
+ const hasH1 = first >= 0 && /^#\s+\S/.test(lines[first]);
106
+ const extras = [];
107
+ if (description) extras.push(`> ${String(description).replace(/\s+/g, ' ').trim()}`);
108
+ if (sourceUrl) extras.push(`Source: ${sourceUrl}`);
109
+ if (hasH1) {
110
+ if (extras.length === 0) return markdown;
111
+ return [...lines.slice(0, first + 1), '', extras.join('\n\n'), ...lines.slice(first + 1)].join('\n');
112
+ }
113
+ const head = [];
114
+ if (title) head.push(`# ${title}`);
115
+ head.push(...extras);
116
+ return head.length ? `${head.join('\n\n')}\n\n${markdown}` : markdown;
117
+ }
118
+
139
119
  module.exports = async function emitCompanions({
140
120
  props,
141
121
  options,
@@ -144,30 +124,16 @@ module.exports = async function emitCompanions({
144
124
  }) {
145
125
  const { outDir, siteConfig, siteDir } = props;
146
126
  const trailingSlash = siteConfig.trailingSlash;
147
- const excludePatterns = options.exclude || [];
148
- const format = options.format;
127
+ // 'clean' is accepted as a synonym for 'plain' (the name the external
128
+ // audit of 0.4.2 used for this behaviour).
129
+ const format = options.format === 'clean' ? 'plain' : options.format;
130
+ const markdownConfigFormat = siteConfig.markdown && siteConfig.markdown.format;
149
131
 
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
- }
132
+ const items = collectCompanionItems(loadedContentByPlugin, options, siteConfig.baseUrl);
161
133
 
162
134
  const emitted = [];
135
+ let fallbacks = 0;
163
136
  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
137
  const sourcePath = resolveSourcePath(item.sourceRef, siteDir);
172
138
  if (!sourcePath) {
173
139
  if (verbose) {
@@ -192,20 +158,37 @@ module.exports = async function emitCompanions({
192
158
 
193
159
  let body = raw;
194
160
  if (format === 'plain') {
195
- // Strip frontmatter, then strip MDX-specific bits.
196
161
  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`;
162
+ const sourceFormat = resolveFormat({
163
+ filePath: sourcePath,
164
+ frontMatterFormat: parsed.data && parsed.data.format,
165
+ markdownConfigFormat,
166
+ });
167
+ const result = await toPlainMarkdown(parsed.content, {
168
+ format: sourceFormat,
169
+ filePath: sourcePath,
170
+ });
171
+ if (result.fallback) {
172
+ fallbacks++;
173
+ console.warn(
174
+ `[plugin-aeo] companions: ${path.relative(siteDir, sourcePath)} did not parse as ${sourceFormat} (${result.error && result.error.message}); used the regex stripper for its companion`,
175
+ );
205
176
  }
177
+ body = withTitleBlock(
178
+ result.markdown,
179
+ item.title,
180
+ item.description,
181
+ pageUrl(siteConfig.url, item.permalink, siteConfig.baseUrl),
182
+ );
206
183
  }
207
184
 
208
- const target = routeToCompanionPath(item.permalink, trailingSlash, outDir);
185
+ // The permalink carries siteConfig.baseUrl (localized to /<locale>/ in a
186
+ // localized build, whose outDir is build/<locale>); the file goes under
187
+ // outDir at the route without it.
188
+ const target = path.join(
189
+ outDir,
190
+ companionPath(outputRoute(item.permalink, siteConfig.baseUrl), trailingSlash),
191
+ );
209
192
  await ensureDir(target);
210
193
  await fs.writeFile(target, body, 'utf8');
211
194
 
@@ -228,7 +211,13 @@ module.exports = async function emitCompanions({
228
211
  }
229
212
 
230
213
  if (verbose) {
231
- console.log(`[plugin-aeo] companions: emitted ${emitted.length} file(s)`);
214
+ console.log(
215
+ `[plugin-aeo] companions: emitted ${emitted.length} file(s)${fallbacks ? `, ${fallbacks} via the regex fallback` : ''}`,
216
+ );
232
217
  }
233
218
  return emitted;
234
219
  };
220
+
221
+ module.exports.collectCompanionItems = collectCompanionItems;
222
+ module.exports.withTitleBlock = withTitleBlock;
223
+ module.exports.matchAny = matchAny;