@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
|
@@ -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 };
|
package/src/features/aiRoutes.js
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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(
|
|
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;
|