@conduction/docusaurus-preset 3.3.0 → 3.5.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.
- package/README.md +50 -0
- package/bin/validate-ai-baseline.mjs +177 -0
- package/package.json +5 -1
- package/src/components/DetailHero/DetailHero.jsx +55 -0
- package/src/components/FAQ/FAQ.jsx +56 -3
- package/src/data/apps-registry.js +39 -16
- package/src/index.js +184 -6
- package/src/plugins/ai-crawling.js +112 -0
- package/static/img/og-conduction.png +0 -0
- package/static/robots.txt.template +86 -0
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ A few non-negotiables encoded by the package CSS and worth knowing about:
|
|
|
21
21
|
- **Brand-default navbar** — locale-dropdown + GitHub link. Sites override `items[]` for site-specific navigation.
|
|
22
22
|
- **Brand-default footer** — three-column link grid + Conduction-tells (KvK, BTW, address). Per-property override: pass `footer: { links: [...] }` to swap columns and inherit the brand copyright unchanged. Spread `baseFooterLinks()` to keep one or two brand columns alongside site-specific ones.
|
|
23
23
|
- **Sensible defaults** — `trailingSlash`, `onBrokenLinks: 'warn'`, `respectPrefersColorScheme`, dark-mode brand mapping.
|
|
24
|
+
- **AI-crawler baseline** — Organization + WebSite JSON-LD on every page, `SoftwareApplication` JSON-LD from `<DetailHero>`, `FAQPage` JSON-LD from `<FAQ>`, default `og:image` + Twitter card meta, sitemap options, and a `postBuild` plugin that emits `robots.txt` when the site does not ship its own. See the AI baseline section below for the validator and content requirements.
|
|
24
25
|
|
|
25
26
|
## Usage
|
|
26
27
|
|
|
@@ -163,6 +164,55 @@ import '@conduction/docusaurus-preset/diagrams';
|
|
|
163
164
|
|
|
164
165
|
This is how product sites such as `mydash.conduction.nl/docs/...` adopt the brand without copying CSS or theme code, and stay in sync as the design-system evolves.
|
|
165
166
|
|
|
167
|
+
## AI-crawler baseline
|
|
168
|
+
|
|
169
|
+
Every site that consumes this preset inherits a contract that AI crawlers (GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot, Google AI Overviews) expect. The schemas, meta tags, and `robots.txt` ship automatically; sites only have to opt in to the content that surfaces them.
|
|
170
|
+
|
|
171
|
+
**What the preset ships**
|
|
172
|
+
|
|
173
|
+
| Surface | Source | How a site uses it |
|
|
174
|
+
| --- | --- | --- |
|
|
175
|
+
| Organization + WebSite JSON-LD | `headTags` injected by `createConfig` | Automatic on every page |
|
|
176
|
+
| `og:image`, `twitter:site`, `twitter:card`, `og:type` | `themeConfig.image` + `themeConfig.metadata` defaults | Override per site by passing `themeConfig.image: 'img/og-my-app.png'` |
|
|
177
|
+
| Default `robots.txt` | `conduction-ai-crawling` postBuild plugin | Drop `static/robots.txt` to override |
|
|
178
|
+
| `SoftwareApplication` JSON-LD | `<DetailHero appId="my-app" .../>` | Pages that should advertise the app must render `<DetailHero>` with an `appId` that resolves in `src/data/apps-registry.js`. No DetailHero means no schema. |
|
|
179
|
+
| `FAQPage` JSON-LD | `<FAQ>` with `<FAQItem question=...>` children | Drop a `<FAQ>` block onto a page; the schema is auto-emitted from the children |
|
|
180
|
+
| Sitemap options | Default `sitemap` config on the classic preset | Sites that override `presets` must include their own `sitemap` block |
|
|
181
|
+
|
|
182
|
+
**Validating a site**
|
|
183
|
+
|
|
184
|
+
The preset ships a generic 8-check validator as a `bin`. Wire it into the site's build:
|
|
185
|
+
|
|
186
|
+
```jsonc
|
|
187
|
+
// docs/package.json
|
|
188
|
+
{
|
|
189
|
+
"scripts": {
|
|
190
|
+
"build": "docusaurus build",
|
|
191
|
+
"postbuild": "validate-ai-baseline",
|
|
192
|
+
"validate:ai-baseline": "validate-ai-baseline"
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`npm run build` now exits non-zero if any of these regress: `robots.txt` exists with a Sitemap line and an AI-bot allow line, `sitemap.xml` has at least one URL, the homepage emits Organization + WebSite JSON-LD plus `og:image` / `og:type` / `twitter:site` / `twitter:card`, and the `og:image` URL resolves to a real file. Sites can extend the validator with extra checks (per-app SoftwareApplication, FAQPage on specific pages, etc.) by adding their own `scripts/validate-ai-baseline-site.mjs` and chaining it.
|
|
198
|
+
|
|
199
|
+
**Per-app docs site checklist**
|
|
200
|
+
|
|
201
|
+
For a per-app docs site to satisfy the full schema contract, the landing page must render `<DetailHero appId="my-app" .../>` with an `appId` that exists in `src/data/apps-registry.js`. That single render emits the `SoftwareApplication` JSON-LD with category mapping (Data and Processes -> BusinessApplication, Connectors -> DeveloperApplication, etc.), `operatingSystem: 'Nextcloud'`, and the EUPL-1.2 license URL. Sites that build a custom landing without `<DetailHero>` get only Organization + WebSite, not the per-app schema.
|
|
202
|
+
|
|
203
|
+
**Opting out**
|
|
204
|
+
|
|
205
|
+
```js
|
|
206
|
+
createConfig({
|
|
207
|
+
title: '...',
|
|
208
|
+
url: '...',
|
|
209
|
+
baseUrl: '/',
|
|
210
|
+
aiCrawling: { disable: true }, // skip the whole postBuild plugin
|
|
211
|
+
// or, finer-grained:
|
|
212
|
+
aiCrawling: { disable: { robotsTxt: true } }, // ship our own static/robots.txt
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
166
216
|
## Releasing
|
|
167
217
|
|
|
168
218
|
Releases auto-publish on push to `main`, driven by [semantic-release](https://semantic-release.gitbook.io/) reading [conventional-commit](https://www.conventionalcommits.org/) messages. The [.github/workflows/publish-packages.yml](../.github/workflows/publish-packages.yml) workflow walks every commit since the last `@conduction/docusaurus-preset-v*` tag and decides what to ship:
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/validate-ai-baseline.mjs
|
|
4
|
+
*
|
|
5
|
+
* Generic AI-crawler baseline validator. Runs as a postbuild step on
|
|
6
|
+
* every Conduction Docusaurus site that consumes
|
|
7
|
+
* @conduction/docusaurus-preset >= 3.4.0. Asserts the SSG output
|
|
8
|
+
* carries the contract AI crawlers (GPTBot, ClaudeBot, PerplexityBot,
|
|
9
|
+
* OAI-SearchBot, Claude-SearchBot, Google AI Overviews) expect.
|
|
10
|
+
*
|
|
11
|
+
* Universal checks only - no site-specific routes. Sites that want
|
|
12
|
+
* additional gates (per-app SoftwareApplication, FAQPage on specific
|
|
13
|
+
* pages, etc.) extend this script in place. See conduction-website's
|
|
14
|
+
* version for an example of additional checks.
|
|
15
|
+
*
|
|
16
|
+
* Exit codes:
|
|
17
|
+
* 0 all checks passed
|
|
18
|
+
* 1 one or more checks failed (CI should block)
|
|
19
|
+
* 2 build directory not found (script invoked before build)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {readFileSync, existsSync, statSync} from 'node:fs';
|
|
23
|
+
import {join, resolve} from 'node:path';
|
|
24
|
+
|
|
25
|
+
const buildDir = resolve(process.argv[2] || 'build');
|
|
26
|
+
|
|
27
|
+
if (!existsSync(buildDir)) {
|
|
28
|
+
console.error(`✗ build directory not found: ${buildDir}`);
|
|
29
|
+
console.error(` Run \`npx docusaurus build\` first.`);
|
|
30
|
+
process.exit(2);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const results = [];
|
|
34
|
+
|
|
35
|
+
function check(name, fn) {
|
|
36
|
+
try {
|
|
37
|
+
const r = fn();
|
|
38
|
+
results.push({name, ok: r.ok, msg: r.msg});
|
|
39
|
+
} catch (e) {
|
|
40
|
+
results.push({name, ok: false, msg: `threw: ${e.message}`});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readBuild(p) {
|
|
45
|
+
return readFileSync(join(buildDir, p), 'utf8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* robots.txt - shipped by the preset's ai-crawling plugin (or the
|
|
49
|
+
site's own static/robots.txt). Either way, the file must exist
|
|
50
|
+
and name at least one AI search bot so a `grep` audit can confirm
|
|
51
|
+
the posture at a glance. */
|
|
52
|
+
check('robots.txt exists and is non-empty', () => {
|
|
53
|
+
const path = join(buildDir, 'robots.txt');
|
|
54
|
+
if (!existsSync(path)) return {ok: false, msg: 'missing'};
|
|
55
|
+
const size = statSync(path).size;
|
|
56
|
+
if (size < 50) return {ok: false, msg: `too small (${size} bytes)`};
|
|
57
|
+
return {ok: true, msg: `${size} bytes`};
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
check('robots.txt names at least one AI search bot', () => {
|
|
61
|
+
const body = readBuild('robots.txt');
|
|
62
|
+
const candidates = ['OAI-SearchBot', 'Claude-SearchBot', 'PerplexityBot', 'ChatGPT-User', 'Claude-User'];
|
|
63
|
+
const found = candidates.filter(ua => body.includes(`User-agent: ${ua}`));
|
|
64
|
+
if (found.length === 0) {
|
|
65
|
+
return {ok: false, msg: `none of [${candidates.join(', ')}] referenced`};
|
|
66
|
+
}
|
|
67
|
+
return {ok: true, msg: `${found.length} bot(s): ${found.join(', ')}`};
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
check('robots.txt has a Sitemap line', () => {
|
|
71
|
+
const body = readBuild('robots.txt');
|
|
72
|
+
const matches = body.match(/^Sitemap:\s+https?:\/\//gm) || [];
|
|
73
|
+
if (matches.length === 0) return {ok: false, msg: 'no Sitemap: line'};
|
|
74
|
+
return {ok: true, msg: `${matches.length} sitemap line(s)`};
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/* sitemap.xml - emitted by @docusaurus/plugin-sitemap (loaded via
|
|
78
|
+
the classic preset). Locale-specific sitemaps (e.g. /nl/sitemap.xml)
|
|
79
|
+
are present for i18n builds; we only check the canonical one
|
|
80
|
+
because some sites are single-locale. */
|
|
81
|
+
check('sitemap.xml exists and has at least 1 URL', () => {
|
|
82
|
+
const path = join(buildDir, 'sitemap.xml');
|
|
83
|
+
if (!existsSync(path)) return {ok: false, msg: 'missing'};
|
|
84
|
+
const body = readBuild('sitemap.xml');
|
|
85
|
+
const n = (body.match(/<loc>/g) || []).length;
|
|
86
|
+
if (n < 1) return {ok: false, msg: 'no <loc> entries'};
|
|
87
|
+
return {ok: true, msg: `${n} URLs`};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/* Helper for the JSON-LD checks below. Docusaurus emits ld+json
|
|
91
|
+
tags via two paths with different attribute ordering: top-level
|
|
92
|
+
headTags renders <script type="..."> first, while Helmet (used
|
|
93
|
+
by <Head> from inside React components like <DetailHero>, <FAQ>)
|
|
94
|
+
prefixes data-rh="true". The regex matches either ordering. */
|
|
95
|
+
function extractJsonLdBlocks(html) {
|
|
96
|
+
const out = [];
|
|
97
|
+
const re = /<script\b[^>]*\btype="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g;
|
|
98
|
+
let m;
|
|
99
|
+
while ((m = re.exec(html)) !== null) {
|
|
100
|
+
out.push(m[1]);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
check('homepage emits >= 2 JSON-LD blocks, all valid JSON', () => {
|
|
106
|
+
if (!existsSync(join(buildDir, 'index.html'))) return {ok: false, msg: 'no index.html'};
|
|
107
|
+
const html = readBuild('index.html');
|
|
108
|
+
const blocks = extractJsonLdBlocks(html);
|
|
109
|
+
if (blocks.length < 2) return {ok: false, msg: `only ${blocks.length} block(s)`};
|
|
110
|
+
for (const [i, b] of blocks.entries()) {
|
|
111
|
+
try {JSON.parse(b);} catch (e) {
|
|
112
|
+
return {ok: false, msg: `block ${i} invalid JSON: ${e.message}`};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return {ok: true, msg: `${blocks.length} blocks, all valid`};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
check('homepage JSON-LD includes Organization and WebSite', () => {
|
|
119
|
+
const html = readBuild('index.html');
|
|
120
|
+
const types = extractJsonLdBlocks(html).map(b => {
|
|
121
|
+
try {return JSON.parse(b)['@type'];} catch {return null;}
|
|
122
|
+
});
|
|
123
|
+
const want = ['Organization', 'WebSite'];
|
|
124
|
+
const missing = want.filter(t => !types.includes(t));
|
|
125
|
+
if (missing.length) return {ok: false, msg: `missing @type: ${missing.join(', ')}`};
|
|
126
|
+
return {ok: true, msg: types.filter(Boolean).join(' + ')};
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/* Social-card meta. og:image is the one that breaks LinkedIn /
|
|
130
|
+
Slack / AI previews when it 404s, so we also resolve the URL to
|
|
131
|
+
a local file in the build output. */
|
|
132
|
+
function metaTag(html, key) {
|
|
133
|
+
const re = new RegExp(`<meta[^>]+(?:name|property)="${key}"[^>]+content="([^"]+)"`, 'i');
|
|
134
|
+
const m = html.match(re);
|
|
135
|
+
return m ? m[1] : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
check('homepage has og:image, og:type, twitter:site, twitter:card', () => {
|
|
139
|
+
const html = readBuild('index.html');
|
|
140
|
+
const checks = {
|
|
141
|
+
'og:image': metaTag(html, 'og:image'),
|
|
142
|
+
'og:type': metaTag(html, 'og:type'),
|
|
143
|
+
'twitter:site': metaTag(html, 'twitter:site'),
|
|
144
|
+
'twitter:card': metaTag(html, 'twitter:card'),
|
|
145
|
+
};
|
|
146
|
+
const missing = Object.entries(checks).filter(([, v]) => !v).map(([k]) => k);
|
|
147
|
+
if (missing.length) return {ok: false, msg: `missing: ${missing.join(', ')}`};
|
|
148
|
+
return {ok: true, msg: 'all four present'};
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
check('og:image URL resolves to a file in the build', () => {
|
|
152
|
+
const html = readBuild('index.html');
|
|
153
|
+
const url = metaTag(html, 'og:image');
|
|
154
|
+
if (!url) return {ok: false, msg: 'no og:image meta'};
|
|
155
|
+
const path = url.replace(/^https?:\/\/[^/]+\//, '');
|
|
156
|
+
const local = join(buildDir, path);
|
|
157
|
+
if (!existsSync(local)) return {ok: false, msg: `og:image refers to ${url}, not found at ${local}`};
|
|
158
|
+
const size = statSync(local).size;
|
|
159
|
+
if (size < 1024) return {ok: false, msg: `og:image file suspiciously small (${size} bytes)`};
|
|
160
|
+
return {ok: true, msg: `${path} (${size} bytes)`};
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/* Report */
|
|
164
|
+
let failed = 0;
|
|
165
|
+
for (const {name, ok, msg} of results) {
|
|
166
|
+
const icon = ok ? '✓' : '✗';
|
|
167
|
+
console.log(`${icon} ${name} - ${msg}`);
|
|
168
|
+
if (!ok) failed++;
|
|
169
|
+
}
|
|
170
|
+
console.log('');
|
|
171
|
+
if (failed) {
|
|
172
|
+
console.error(`${failed} of ${results.length} checks failed.`);
|
|
173
|
+
console.error('AI-crawler baseline regressed. Fix the failures above before merging.');
|
|
174
|
+
process.exit(1);
|
|
175
|
+
} else {
|
|
176
|
+
console.log(`All ${results.length} AI-baseline checks passed.`);
|
|
177
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conduction/docusaurus-preset",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"prepack": "node scripts/prepack-bundle-css.js"
|
|
6
6
|
},
|
|
7
|
+
"bin": {
|
|
8
|
+
"validate-ai-baseline": "./bin/validate-ai-baseline.mjs"
|
|
9
|
+
},
|
|
7
10
|
"description": "Conduction brand preset for Docusaurus 3. Tokens, theme, navbar, footer, i18n config for nl/en/de/fr, and the React component library that powers conduction.nl and the Conduction product sites.",
|
|
8
11
|
"main": "src/index.js",
|
|
9
12
|
"exports": {
|
|
@@ -28,6 +31,7 @@
|
|
|
28
31
|
"files": [
|
|
29
32
|
"src/",
|
|
30
33
|
"static/",
|
|
34
|
+
"bin/",
|
|
31
35
|
"README.md",
|
|
32
36
|
"MISSING_COMPONENTS.md"
|
|
33
37
|
],
|
|
@@ -45,11 +45,13 @@
|
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
47
|
import React from 'react';
|
|
48
|
+
import Head from '@docusaurus/Head';
|
|
48
49
|
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
|
49
50
|
import HexBullet from '../primitives/HexBullet';
|
|
50
51
|
import Button from '../primitives/Button';
|
|
51
52
|
import {deriveStability} from '../../theme/brand.jsx';
|
|
52
53
|
import {downloadsForApp, formatDownloads} from '../../data/app-downloads';
|
|
54
|
+
import {APPS_REGISTRY, applicationCategoryFor} from '../../data/apps-registry';
|
|
53
55
|
import styles from './DetailHero.module.css';
|
|
54
56
|
|
|
55
57
|
/**
|
|
@@ -107,8 +109,61 @@ export default function DetailHero({
|
|
|
107
109
|
}
|
|
108
110
|
: undefined);
|
|
109
111
|
|
|
112
|
+
/* SoftwareApplication JSON-LD for AI crawlers. Emitted when appId
|
|
113
|
+
resolves to a known entry in apps-registry (so the schema only
|
|
114
|
+
fires on actual product pages, not partner/solution detail pages
|
|
115
|
+
that reuse this hero). Pulls applicationCategory from the registry
|
|
116
|
+
category, operatingSystem is hard-coded "Nextcloud" because every
|
|
117
|
+
Conduction app is a Nextcloud app. Downloads and version surface
|
|
118
|
+
as ratingCount-shaped signals on schema.org/SoftwareApplication.
|
|
119
|
+
The schema lives on every page that mounts this hero, including
|
|
120
|
+
each product page's /apps/<slug> route on conduction.nl AND each
|
|
121
|
+
per-app docs site's landing where DetailHero is the masthead. */
|
|
122
|
+
const appEntry = appId ? APPS_REGISTRY[appId] : undefined;
|
|
123
|
+
const softwareApplicationJsonLd = appEntry ? (() => {
|
|
124
|
+
const titleText = typeof title === 'string' ? title : appEntry.name;
|
|
125
|
+
const taglineText = typeof tagline === 'string' ? tagline : undefined;
|
|
126
|
+
const schema = {
|
|
127
|
+
'@context': 'https://schema.org',
|
|
128
|
+
'@type': 'SoftwareApplication',
|
|
129
|
+
'@id': `${siteConfig?.url || ''}${appEntry.productHref}#app`,
|
|
130
|
+
name: titleText,
|
|
131
|
+
applicationCategory: applicationCategoryFor(appId),
|
|
132
|
+
operatingSystem: 'Nextcloud',
|
|
133
|
+
url: `${siteConfig?.url || ''}${appEntry.productHref}`,
|
|
134
|
+
sameAs: [appEntry.docsHref].filter(Boolean),
|
|
135
|
+
publisher: {'@id': 'https://www.conduction.nl/#org'},
|
|
136
|
+
offers: {
|
|
137
|
+
'@type': 'Offer',
|
|
138
|
+
price: '0',
|
|
139
|
+
priceCurrency: 'EUR',
|
|
140
|
+
availability: 'https://schema.org/InStock',
|
|
141
|
+
},
|
|
142
|
+
license: 'https://eupl.eu/1.2/en/',
|
|
143
|
+
};
|
|
144
|
+
if (taglineText) schema.description = taglineText;
|
|
145
|
+
if (resolvedVersion) schema.softwareVersion = resolvedVersion.replace(/^v/, '');
|
|
146
|
+
if (dlCount > 0) {
|
|
147
|
+
/* Surface install count as InteractionCounter rather than
|
|
148
|
+
aggregateRating; downloads are not reviews. */
|
|
149
|
+
schema.interactionStatistic = {
|
|
150
|
+
'@type': 'InteractionCounter',
|
|
151
|
+
interactionType: {'@type': 'DownloadAction'},
|
|
152
|
+
userInteractionCount: dlCount,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return schema;
|
|
156
|
+
})() : null;
|
|
157
|
+
|
|
110
158
|
return (
|
|
111
159
|
<section className={[styles.head, hasIllustration && styles.withIllustration, bgClass, className].filter(Boolean).join(' ')}>
|
|
160
|
+
{softwareApplicationJsonLd && (
|
|
161
|
+
<Head>
|
|
162
|
+
<script type="application/ld+json">
|
|
163
|
+
{JSON.stringify(softwareApplicationJsonLd)}
|
|
164
|
+
</script>
|
|
165
|
+
</Head>
|
|
166
|
+
)}
|
|
112
167
|
{crumb && Array.isArray(crumb) && (
|
|
113
168
|
<div className={styles.crumb}>
|
|
114
169
|
{crumb.map((c, i) => {
|
|
@@ -5,19 +5,26 @@
|
|
|
5
5
|
* Native <details>/<summary> for keyboard, screen-reader, and no-JS support.
|
|
6
6
|
* One question expands at a time on click; users can have several open.
|
|
7
7
|
*
|
|
8
|
+
* Emits FAQPage JSON-LD automatically from the FAQItem children, so AI
|
|
9
|
+
* crawlers (Google AI Overviews, Perplexity, ChatGPT) pick up each
|
|
10
|
+
* question + answer as a structured entity. Pass `schema={false}` to
|
|
11
|
+
* suppress the JSON-LD output (useful when the FAQ block isn't the
|
|
12
|
+
* primary content on the page).
|
|
13
|
+
*
|
|
8
14
|
* Usage:
|
|
9
15
|
*
|
|
10
16
|
* <FAQ title="FAQ.">
|
|
11
17
|
* <FAQItem question="If the apps are free, what does support cost?" defaultOpen>
|
|
12
|
-
* Whatever the partner you pick charges. Conduction sets no minimum
|
|
18
|
+
* Whatever the partner you pick charges. Conduction sets no minimum.
|
|
13
19
|
* </FAQItem>
|
|
14
20
|
* <FAQItem question="Why doesn't Conduction sell support directly?">
|
|
15
|
-
* Two reasons. First
|
|
21
|
+
* Two reasons. First, ...
|
|
16
22
|
* </FAQItem>
|
|
17
23
|
* </FAQ>
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
26
|
import React from 'react';
|
|
27
|
+
import Head from '@docusaurus/Head';
|
|
21
28
|
import styles from './FAQ.module.css';
|
|
22
29
|
|
|
23
30
|
export function FAQItem({question, defaultOpen, children, className}) {
|
|
@@ -31,10 +38,56 @@ export function FAQItem({question, defaultOpen, children, className}) {
|
|
|
31
38
|
);
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Walk a React children tree and flatten to plain text. Used to
|
|
43
|
+
* convert the JSX body of each <FAQItem> into the `text` field
|
|
44
|
+
* schema.org/Question expects. Strips elements, keeps strings and
|
|
45
|
+
* numbers, joins siblings with a single space.
|
|
46
|
+
*/
|
|
47
|
+
function flattenToText(node) {
|
|
48
|
+
if (node == null || node === false) return '';
|
|
49
|
+
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
|
50
|
+
if (Array.isArray(node)) return node.map(flattenToText).join(' ');
|
|
51
|
+
if (node.props && node.props.children) return flattenToText(node.props.children);
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function FAQ({title = 'FAQ.', children, className, schema = true}) {
|
|
35
56
|
const composed = [styles.faq, className].filter(Boolean).join(' ');
|
|
57
|
+
|
|
58
|
+
/* Build FAQPage JSON-LD from FAQItem children. Each item turns into
|
|
59
|
+
a Question entity with an Answer body. Items without a `question`
|
|
60
|
+
prop (or with empty body text) are skipped so a partial render
|
|
61
|
+
doesn't ship a malformed schema. */
|
|
62
|
+
const items = schema
|
|
63
|
+
? React.Children.toArray(children)
|
|
64
|
+
.filter(c => c && c.props && c.props.question)
|
|
65
|
+
.map(c => {
|
|
66
|
+
const text = flattenToText(c.props.children).trim().replace(/\s+/g, ' ');
|
|
67
|
+
if (!text) return null;
|
|
68
|
+
return {
|
|
69
|
+
'@type': 'Question',
|
|
70
|
+
name: c.props.question,
|
|
71
|
+
acceptedAnswer: {'@type': 'Answer', text},
|
|
72
|
+
};
|
|
73
|
+
})
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
: [];
|
|
76
|
+
const showSchema = schema && items.length > 0;
|
|
77
|
+
|
|
36
78
|
return (
|
|
37
79
|
<section className={composed}>
|
|
80
|
+
{showSchema && (
|
|
81
|
+
<Head>
|
|
82
|
+
<script type="application/ld+json">
|
|
83
|
+
{JSON.stringify({
|
|
84
|
+
'@context': 'https://schema.org',
|
|
85
|
+
'@type': 'FAQPage',
|
|
86
|
+
mainEntity: items,
|
|
87
|
+
})}
|
|
88
|
+
</script>
|
|
89
|
+
</Head>
|
|
90
|
+
)}
|
|
38
91
|
{title && <h2 className={styles.heading}>{title}</h2>}
|
|
39
92
|
{children}
|
|
40
93
|
</section>
|
|
@@ -27,24 +27,47 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
export const APPS_REGISTRY = {
|
|
30
|
-
opencatalogi: {slug: 'opencatalogi', name: 'OpenCatalogi', productHref: '/apps/opencatalogi', docsHref: 'https://docs.conduction.nl/opencatalogi', academyHref: '/academy?app=opencatalogi'},
|
|
31
|
-
openregister: {slug: 'openregister', name: 'OpenRegister', productHref: '/apps/openregister', docsHref: 'https://docs.conduction.nl/openregister', academyHref: '/academy?app=openregister'},
|
|
32
|
-
openconnector: {slug: 'openconnector', name: 'OpenConnector', productHref: '/apps/openconnector', docsHref: 'https://docs.conduction.nl/openconnector', academyHref: '/academy?app=openconnector'},
|
|
33
|
-
docudesk: {slug: 'docudesk', name: 'DocuDesk', productHref: '/apps/docudesk', docsHref: 'https://docs.conduction.nl/docudesk', academyHref: '/academy?app=docudesk'},
|
|
34
|
-
mydash: {slug: 'mydash', name: 'MyDash', productHref: '/apps/mydash', docsHref: 'https://docs.conduction.nl/mydash', academyHref: '/academy?app=mydash'},
|
|
35
|
-
zaakafhandelapp: {slug: 'zaakafhandelapp', name: 'ZaakAfhandelApp', productHref: '/apps/zaakafhandelapp', docsHref: 'https://docs.conduction.nl/zaakafhandelapp', academyHref: '/academy?app=zaakafhandelapp'},
|
|
36
|
-
pipelinq: {slug: 'pipelinq', name: 'PipelinQ', productHref: '/apps/pipelinq', docsHref: 'https://docs.conduction.nl/pipelinq', academyHref: '/academy?app=pipelinq'},
|
|
37
|
-
procest: {slug: 'procest', name: 'Procest', productHref: '/apps/procest', docsHref: 'https://docs.conduction.nl/procest', academyHref: '/academy?app=procest'},
|
|
38
|
-
decidesk: {slug: 'decidesk', name: 'DeciDesk', productHref: '/apps/decidesk', docsHref: 'https://docs.conduction.nl/decidesk', academyHref: '/academy?app=decidesk'},
|
|
39
|
-
softwarecatalog: {slug: 'softwarecatalog', name: 'SoftwareCatalog', productHref: '/apps/softwarecatalog', docsHref: 'https://docs.conduction.nl/softwarecatalog', academyHref: '/academy?app=softwarecatalog'},
|
|
40
|
-
larpingapp: {slug: 'larpingapp', name: 'LarpingApp', productHref: '/apps/larpingapp', docsHref: 'https://docs.conduction.nl/larpingapp', academyHref: '/academy?app=larpingapp'},
|
|
41
|
-
nldesign: {slug: 'nldesign', name: 'NLDesign', productHref: '/apps/nldesign', docsHref: 'https://docs.conduction.nl/nldesign', academyHref: '/academy?app=nldesign'},
|
|
42
|
-
shillinq: {slug: 'shillinq', name: 'Shillinq', productHref: '/apps/shillinq', docsHref: 'https://docs.conduction.nl/shillinq', academyHref: '/academy?app=shillinq'},
|
|
43
|
-
openbuilt: {slug: 'openbuilt', name: 'OpenBuilt', productHref: '/apps/openbuilt', docsHref: 'https://docs.conduction.nl/openbuilt', academyHref: '/academy?app=openbuilt'},
|
|
44
|
-
doriath: {slug: 'doriath', name: 'Doriath', productHref: '/apps/doriath', docsHref: 'https://docs.conduction.nl/doriath', academyHref: '/academy?app=doriath'},
|
|
45
|
-
'app-versions': {slug: 'app-versions', name: 'App Versions', productHref: '/apps/app-versions', docsHref: 'https://docs.conduction.nl/app-versions', academyHref: '/academy?app=app-versions'},
|
|
30
|
+
opencatalogi: {slug: 'opencatalogi', name: 'OpenCatalogi', category: 'Data', productHref: '/apps/opencatalogi', docsHref: 'https://docs.conduction.nl/opencatalogi', academyHref: '/academy?app=opencatalogi'},
|
|
31
|
+
openregister: {slug: 'openregister', name: 'OpenRegister', category: 'Data', productHref: '/apps/openregister', docsHref: 'https://docs.conduction.nl/openregister', academyHref: '/academy?app=openregister'},
|
|
32
|
+
openconnector: {slug: 'openconnector', name: 'OpenConnector', category: 'Connectors', productHref: '/apps/openconnector', docsHref: 'https://docs.conduction.nl/openconnector', academyHref: '/academy?app=openconnector'},
|
|
33
|
+
docudesk: {slug: 'docudesk', name: 'DocuDesk', category: 'Documents', productHref: '/apps/docudesk', docsHref: 'https://docs.conduction.nl/docudesk', academyHref: '/academy?app=docudesk'},
|
|
34
|
+
mydash: {slug: 'mydash', name: 'MyDash', category: 'Dashboards', productHref: '/apps/mydash', docsHref: 'https://docs.conduction.nl/mydash', academyHref: '/academy?app=mydash'},
|
|
35
|
+
zaakafhandelapp: {slug: 'zaakafhandelapp', name: 'ZaakAfhandelApp', category: 'Processes', productHref: '/apps/zaakafhandelapp', docsHref: 'https://docs.conduction.nl/zaakafhandelapp', academyHref: '/academy?app=zaakafhandelapp'},
|
|
36
|
+
pipelinq: {slug: 'pipelinq', name: 'PipelinQ', category: 'Processes', productHref: '/apps/pipelinq', docsHref: 'https://docs.conduction.nl/pipelinq', academyHref: '/academy?app=pipelinq'},
|
|
37
|
+
procest: {slug: 'procest', name: 'Procest', category: 'Processes', productHref: '/apps/procest', docsHref: 'https://docs.conduction.nl/procest', academyHref: '/academy?app=procest'},
|
|
38
|
+
decidesk: {slug: 'decidesk', name: 'DeciDesk', category: 'Processes', productHref: '/apps/decidesk', docsHref: 'https://docs.conduction.nl/decidesk', academyHref: '/academy?app=decidesk'},
|
|
39
|
+
softwarecatalog: {slug: 'softwarecatalog', name: 'SoftwareCatalog', category: 'Data', productHref: '/apps/softwarecatalog', docsHref: 'https://docs.conduction.nl/softwarecatalog', academyHref: '/academy?app=softwarecatalog'},
|
|
40
|
+
larpingapp: {slug: 'larpingapp', name: 'LarpingApp', category: 'Processes', productHref: '/apps/larpingapp', docsHref: 'https://docs.conduction.nl/larpingapp', academyHref: '/academy?app=larpingapp'},
|
|
41
|
+
nldesign: {slug: 'nldesign', name: 'NLDesign', category: 'Documents', productHref: '/apps/nldesign', docsHref: 'https://docs.conduction.nl/nldesign', academyHref: '/academy?app=nldesign'},
|
|
42
|
+
shillinq: {slug: 'shillinq', name: 'Shillinq', category: 'Processes', productHref: '/apps/shillinq', docsHref: 'https://docs.conduction.nl/shillinq', academyHref: '/academy?app=shillinq'},
|
|
43
|
+
openbuilt: {slug: 'openbuilt', name: 'OpenBuilt', category: 'Processes', productHref: '/apps/openbuilt', docsHref: 'https://docs.conduction.nl/openbuilt', academyHref: '/academy?app=openbuilt'},
|
|
44
|
+
doriath: {slug: 'doriath', name: 'Doriath', category: 'Connectors', productHref: '/apps/doriath', docsHref: 'https://docs.conduction.nl/doriath', academyHref: '/academy?app=doriath'},
|
|
45
|
+
'app-versions': {slug: 'app-versions', name: 'App Versions', category: 'Data', productHref: '/apps/app-versions', docsHref: 'https://docs.conduction.nl/app-versions', academyHref: '/academy?app=app-versions'},
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Map an apps-catalog category to a schema.org applicationCategory.
|
|
50
|
+
* Used by <DetailHero> when emitting SoftwareApplication JSON-LD for
|
|
51
|
+
* AI crawlers. Defaults to BusinessApplication for any unknown
|
|
52
|
+
* category, since every Conduction app fits BusinessApplication in
|
|
53
|
+
* the absence of better signal.
|
|
54
|
+
*/
|
|
55
|
+
export const SCHEMA_APPLICATION_CATEGORY = {
|
|
56
|
+
Data: 'BusinessApplication',
|
|
57
|
+
Processes: 'BusinessApplication',
|
|
58
|
+
Connectors: 'DeveloperApplication',
|
|
59
|
+
Documents: 'BusinessApplication',
|
|
60
|
+
Dashboards: 'BusinessApplication',
|
|
61
|
+
AI: 'BusinessApplication',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Resolve an appId to its schema.org applicationCategory. */
|
|
65
|
+
export function applicationCategoryFor(slug) {
|
|
66
|
+
const entry = APPS_REGISTRY[slug];
|
|
67
|
+
if (!entry) return 'BusinessApplication';
|
|
68
|
+
return SCHEMA_APPLICATION_CATEGORY[entry.category] || 'BusinessApplication';
|
|
69
|
+
}
|
|
70
|
+
|
|
48
71
|
export const APP_SLUGS = Object.keys(APPS_REGISTRY);
|
|
49
72
|
|
|
50
73
|
/** Build a label map keyed by slug, suitable for <ContentTypeFilter labels=…/>. */
|
package/src/index.js
CHANGED
|
@@ -88,6 +88,136 @@ function resolveAppVersion(opts) {
|
|
|
88
88
|
return undefined;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Brand-default Organization JSON-LD. One canonical version of the
|
|
93
|
+
* company's legal-entity facts (address, KvK, BTW, socials), shipped on
|
|
94
|
+
* every Conduction site so AI crawlers (GPTBot, ClaudeBot, Perplexity-
|
|
95
|
+
* Bot, OAI-SearchBot, Google AI Overviews) get the same answer to
|
|
96
|
+
* "who is Conduction" regardless of which subdomain they landed on.
|
|
97
|
+
* Updates here propagate to the fleet on the next preset release.
|
|
98
|
+
*
|
|
99
|
+
* Sites that aren't conduction.nl (per-app docs sites at
|
|
100
|
+
* {slug}.conduction.nl, etc.) still reference the same Organization via
|
|
101
|
+
* @id, so cross-site citations consolidate cleanly.
|
|
102
|
+
*/
|
|
103
|
+
const BRAND_ORGANIZATION_JSONLD = {
|
|
104
|
+
'@context': 'https://schema.org',
|
|
105
|
+
'@type': 'Organization',
|
|
106
|
+
'@id': 'https://www.conduction.nl/#org',
|
|
107
|
+
name: 'Conduction B.V.',
|
|
108
|
+
alternateName: 'Conduction',
|
|
109
|
+
url: 'https://www.conduction.nl/',
|
|
110
|
+
logo: 'https://www.conduction.nl/img/brand/avatar-conduction-gold-on-white.svg',
|
|
111
|
+
foundingDate: '2019',
|
|
112
|
+
description:
|
|
113
|
+
'Dutch open-source software company building EUPL-1.2 apps for the Nextcloud workspace.',
|
|
114
|
+
address: {
|
|
115
|
+
'@type': 'PostalAddress',
|
|
116
|
+
streetAddress: 'Lauriergracht 14h',
|
|
117
|
+
postalCode: '1016 RR',
|
|
118
|
+
addressLocality: 'Amsterdam',
|
|
119
|
+
addressCountry: 'NL',
|
|
120
|
+
},
|
|
121
|
+
email: 'info@conduction.nl',
|
|
122
|
+
telephone: '+31-85-303-6840',
|
|
123
|
+
taxID: 'NL860784241B01',
|
|
124
|
+
vatID: 'NL860784241B01',
|
|
125
|
+
identifier: {
|
|
126
|
+
'@type': 'PropertyValue',
|
|
127
|
+
propertyID: 'KvK',
|
|
128
|
+
value: '76741850',
|
|
129
|
+
},
|
|
130
|
+
sameAs: [
|
|
131
|
+
'https://github.com/ConductionNL',
|
|
132
|
+
'https://www.linkedin.com/company/conduction/',
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build the per-site WebSite JSON-LD that ties the consuming site to
|
|
138
|
+
* the shared Organization. WebSite carries the site title and URL the
|
|
139
|
+
* site was configured with; Organization stays canonical.
|
|
140
|
+
*/
|
|
141
|
+
function buildWebsiteJsonLd(opts) {
|
|
142
|
+
return {
|
|
143
|
+
'@context': 'https://schema.org',
|
|
144
|
+
'@type': 'WebSite',
|
|
145
|
+
'@id': `${opts.url}/#website`,
|
|
146
|
+
url: `${opts.url}/`,
|
|
147
|
+
name: opts.title,
|
|
148
|
+
publisher: {'@id': 'https://www.conduction.nl/#org'},
|
|
149
|
+
inLanguage: (opts.i18n && opts.i18n.locales) || ['nl', 'en', 'de', 'fr'],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Default headTags emitted on every page. Two JSON-LD blocks
|
|
155
|
+
* (Organization + WebSite) consumed by AI crawlers, Google rich
|
|
156
|
+
* results, Bing AI, LinkedIn previews. Static SSG output, so non-JS
|
|
157
|
+
* fetchers (GPTBot, ClaudeBot, PerplexityBot) see them too.
|
|
158
|
+
*
|
|
159
|
+
* Sites extend by passing `opts.headTags = [...]`; the preset merges
|
|
160
|
+
* the site's tags after its own defaults.
|
|
161
|
+
*/
|
|
162
|
+
function buildAiHeadTags(opts) {
|
|
163
|
+
return [
|
|
164
|
+
{
|
|
165
|
+
tagName: 'script',
|
|
166
|
+
attributes: {type: 'application/ld+json'},
|
|
167
|
+
innerHTML: JSON.stringify(BRAND_ORGANIZATION_JSONLD),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
tagName: 'script',
|
|
171
|
+
attributes: {type: 'application/ld+json'},
|
|
172
|
+
innerHTML: JSON.stringify(buildWebsiteJsonLd(opts)),
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Default sitemap plugin options. Each locale outputs its own
|
|
179
|
+
* sitemap.xml. /academy/tags/** is excluded site-wide because tag
|
|
180
|
+
* pages are thin and confuse AI summarisers more than they help SEO.
|
|
181
|
+
* ignorePatterns matches the *route path* after locale prefixing, so
|
|
182
|
+
* we list every locale variant.
|
|
183
|
+
*
|
|
184
|
+
* Sites passing their own classic preset config can override by
|
|
185
|
+
* including a `sitemap` key alongside `docs`/`blog`/`theme`.
|
|
186
|
+
*/
|
|
187
|
+
const DEFAULT_SITEMAP_OPTIONS = {
|
|
188
|
+
changefreq: 'weekly',
|
|
189
|
+
priority: 0.5,
|
|
190
|
+
ignorePatterns: [
|
|
191
|
+
'/academy/tags/**',
|
|
192
|
+
'/nl/academy/tags/**',
|
|
193
|
+
'/en/academy/tags/**',
|
|
194
|
+
'/de/academy/tags/**',
|
|
195
|
+
'/fr/academy/tags/**',
|
|
196
|
+
],
|
|
197
|
+
filename: 'sitemap.xml',
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Default themeConfig.metadata. Twitter + og:type baselines so social
|
|
202
|
+
* cards render correctly. Per-page MDX frontmatter still wins (Helmet
|
|
203
|
+
* de-dupes by meta name/property). Sites override the whole array by
|
|
204
|
+
* passing `themeConfig.metadata = [...]` in opts.
|
|
205
|
+
*/
|
|
206
|
+
const DEFAULT_METADATA = [
|
|
207
|
+
{name: 'twitter:site', content: '@ConductionNL'},
|
|
208
|
+
{name: 'twitter:card', content: 'summary_large_image'},
|
|
209
|
+
{property: 'og:type', content: 'website'},
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Default OG image. 1200x630 cobalt brand card shipped from the preset
|
|
214
|
+
* static/img/. Sites can override by dropping their own
|
|
215
|
+
* static/img/og-conduction.png (staticDirectories precedence puts the
|
|
216
|
+
* site's file last). For per-app product cards, set themeConfig.image
|
|
217
|
+
* explicitly in your site config.
|
|
218
|
+
*/
|
|
219
|
+
const DEFAULT_OG_IMAGE = 'img/og-conduction.png';
|
|
220
|
+
|
|
91
221
|
/**
|
|
92
222
|
* Brand-default i18n block. Nederlands at the URL root, others at /en/, /de/, /fr/.
|
|
93
223
|
*/
|
|
@@ -266,12 +396,19 @@ function createConfig(opts) {
|
|
|
266
396
|
onBrokenLinks: 'warn',
|
|
267
397
|
onBrokenMarkdownLinks: 'warn',
|
|
268
398
|
|
|
269
|
-
/* Two static roots
|
|
270
|
-
|
|
271
|
-
|
|
399
|
+
/* Two static roots. Docusaurus wires staticDirectories through
|
|
400
|
+
copy-webpack-plugin's parallel pattern processing (Promise.all),
|
|
401
|
+
so for file collisions the winner is whichever pattern finishes
|
|
402
|
+
reading first — non-deterministic in practice (preset wins on
|
|
403
|
+
most disks because it ships smaller). Don't rely on this array
|
|
404
|
+
order for overrides; ship a Docusaurus plugin like
|
|
405
|
+
./plugins/ai-crawling.js when you need deterministic precedence.
|
|
406
|
+
1. preset's own ../static (canal-footer, conduction-bg,
|
|
407
|
+
hex-rain, platform-diagram, brand img/favicon, logo, logo-
|
|
408
|
+
dark, nextcloud-logo, default OG card)
|
|
272
409
|
2. site's own static/ (CNAME, site-specific images, overrides)
|
|
273
|
-
|
|
274
|
-
|
|
410
|
+
Files unique to one directory always copy; conflicts are
|
|
411
|
+
essentially undefined behaviour. */
|
|
275
412
|
staticDirectories: opts.staticDirectories || [
|
|
276
413
|
path.resolve(__dirname, '..', 'static'),
|
|
277
414
|
'static',
|
|
@@ -295,6 +432,11 @@ function createConfig(opts) {
|
|
|
295
432
|
theme: {
|
|
296
433
|
customCss,
|
|
297
434
|
},
|
|
435
|
+
/* AI-crawler-friendly defaults for @docusaurus/plugin-sitemap.
|
|
436
|
+
Per-locale sitemap.xml emitted automatically; /academy/tags
|
|
437
|
+
excluded across every locale prefix. Sites passing their own
|
|
438
|
+
presets array must include their own sitemap config too. */
|
|
439
|
+
sitemap: DEFAULT_SITEMAP_OPTIONS,
|
|
298
440
|
},
|
|
299
441
|
],
|
|
300
442
|
],
|
|
@@ -363,17 +505,53 @@ function createConfig(opts) {
|
|
|
363
505
|
isoCertifications: true | false,
|
|
364
506
|
} */
|
|
365
507
|
legalLinks: opts.legalLinks || {},
|
|
508
|
+
/* AI-friendly social-card defaults. `image` ships from the
|
|
509
|
+
preset's static/img/og-conduction.png and gets served at every
|
|
510
|
+
consuming site's /img/og-conduction.png; drop your own
|
|
511
|
+
static/img/og-conduction.png to override per-site. `metadata`
|
|
512
|
+
seeds twitter:site + twitter:card + og:type baselines; per-
|
|
513
|
+
page MDX frontmatter still wins via Helmet de-dupe. */
|
|
514
|
+
image: DEFAULT_OG_IMAGE,
|
|
515
|
+
metadata: DEFAULT_METADATA,
|
|
366
516
|
},
|
|
367
517
|
opts.themeConfig || {}
|
|
368
518
|
),
|
|
369
519
|
|
|
370
|
-
|
|
520
|
+
/* AI-crawler discovery: Organization + WebSite JSON-LD on every
|
|
521
|
+
page, plus any site-specific tags the consumer adds. Sites
|
|
522
|
+
inherit the canonical Conduction Organization automatically;
|
|
523
|
+
per-app SoftwareApplication schemas are emitted by the
|
|
524
|
+
<DetailHero> component on the pages it renders. */
|
|
525
|
+
headTags: [
|
|
526
|
+
...buildAiHeadTags(opts),
|
|
527
|
+
...(opts.headTags || []),
|
|
528
|
+
],
|
|
529
|
+
|
|
530
|
+
/* The AI-crawling plugin emits /robots.txt (and optionally /llms.txt)
|
|
531
|
+
in postBuild, after webpack's copy-plugin has copied static files.
|
|
532
|
+
It no-ops when the file already exists in outDir, so a site's own
|
|
533
|
+
static/robots.txt or static/llms.txt always wins. Sites disable
|
|
534
|
+
per-file or wholesale via opts.aiCrawling.disable. Hand-rolled
|
|
535
|
+
plugins in opts.plugins are appended after this default. */
|
|
536
|
+
plugins: [
|
|
537
|
+
[
|
|
538
|
+
require.resolve('./plugins/ai-crawling.js'),
|
|
539
|
+
opts.aiCrawling || {},
|
|
540
|
+
],
|
|
541
|
+
...(opts.plugins || []),
|
|
542
|
+
],
|
|
371
543
|
};
|
|
372
544
|
}
|
|
373
545
|
|
|
374
546
|
module.exports = {
|
|
375
547
|
createConfig,
|
|
376
548
|
I18N,
|
|
549
|
+
BRAND_ORGANIZATION_JSONLD,
|
|
550
|
+
buildWebsiteJsonLd,
|
|
551
|
+
buildAiHeadTags,
|
|
552
|
+
DEFAULT_SITEMAP_OPTIONS,
|
|
553
|
+
DEFAULT_METADATA,
|
|
554
|
+
DEFAULT_OG_IMAGE,
|
|
377
555
|
baseNavbar,
|
|
378
556
|
baseFooter,
|
|
379
557
|
baseFooterLinks,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @conduction/docusaurus-preset/plugins/ai-crawling
|
|
3
|
+
*
|
|
4
|
+
* Docusaurus plugin that ships the AI-crawler-friendly fleet baseline:
|
|
5
|
+
* - /robots.txt default: open posture (allows search and training
|
|
6
|
+
* bots), generated from the template at static/robots.txt.template
|
|
7
|
+
* - /llms.txt optional: a llmstxt.org-format site index. Sites
|
|
8
|
+
* pass `opts.llmsTxt` to provide their own content; absent that,
|
|
9
|
+
* no llms.txt is emitted (the spec needs hand-curated copy and a
|
|
10
|
+
* generic placeholder would be worse than nothing).
|
|
11
|
+
*
|
|
12
|
+
* Why a postBuild plugin instead of static/ files: Docusaurus wires
|
|
13
|
+
* staticDirectories through copy-webpack-plugin's parallel pattern
|
|
14
|
+
* processing, which makes file-collision resolution non-deterministic
|
|
15
|
+
* (patterns are emitted via Promise.all and the first-emitted asset
|
|
16
|
+
* wins). Shipping defaults via this plugin moves the write to the
|
|
17
|
+
* post-build hook, which runs strictly after all static files have
|
|
18
|
+
* been copied. The plugin no-ops when the target file already exists
|
|
19
|
+
* in the build output, so a site's own static/robots.txt or
|
|
20
|
+
* static/llms.txt always wins.
|
|
21
|
+
*
|
|
22
|
+
* Options:
|
|
23
|
+
* robotsTxt string override the default robots.txt body
|
|
24
|
+
* llmsTxt string llms.txt body to emit (no default)
|
|
25
|
+
* disable boolean | object fine-grained opt-out:
|
|
26
|
+
* disable: true skip the whole plugin
|
|
27
|
+
* disable: {robotsTxt: true} skip just robots.txt
|
|
28
|
+
* disable: {llmsTxt: true} skip just llms.txt
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
|
|
34
|
+
const TEMPLATE_PATH = path.resolve(
|
|
35
|
+
__dirname,
|
|
36
|
+
'..',
|
|
37
|
+
'..',
|
|
38
|
+
'static',
|
|
39
|
+
'robots.txt.template'
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
function readRobotsTemplate() {
|
|
43
|
+
try {
|
|
44
|
+
return fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return 'User-agent: *\nAllow: /\n';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Walk every staticDirectory the site exposes and look for the given
|
|
52
|
+
* filename at the root. Used to decide whether the plugin should emit
|
|
53
|
+
* its default — sites that ship their own static/robots.txt (or
|
|
54
|
+
* static/llms.txt) take precedence over the plugin's fallback.
|
|
55
|
+
*/
|
|
56
|
+
function siteShipsFile(context, filename) {
|
|
57
|
+
const dirs = context.siteConfig?.staticDirectories || ['static'];
|
|
58
|
+
const siteDir = context.siteDir;
|
|
59
|
+
for (const dir of dirs) {
|
|
60
|
+
const resolved = path.isAbsolute(dir) ? dir : path.resolve(siteDir, dir);
|
|
61
|
+
if (fs.existsSync(path.join(resolved, filename))) return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function aiCrawlingPlugin(context, options = {}) {
|
|
67
|
+
const disable = options.disable === true
|
|
68
|
+
? {robotsTxt: true, llmsTxt: true}
|
|
69
|
+
: (options.disable || {});
|
|
70
|
+
|
|
71
|
+
/* Decide once at plugin-load time which files the plugin will emit.
|
|
72
|
+
Checking now (not in postBuild) means we read source staticDir
|
|
73
|
+
contents while disk state is stable, avoiding the postBuild race
|
|
74
|
+
where webpack's CopyPlugin may not have flushed its assets yet. */
|
|
75
|
+
const shouldEmitRobots = !disable.robotsTxt
|
|
76
|
+
&& !siteShipsFile(context, 'robots.txt');
|
|
77
|
+
const shouldEmitLlms = !disable.llmsTxt
|
|
78
|
+
&& options.llmsTxt
|
|
79
|
+
&& !siteShipsFile(context, 'llms.txt');
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
name: 'conduction-ai-crawling',
|
|
83
|
+
|
|
84
|
+
async postBuild({outDir, siteConfig}) {
|
|
85
|
+
const tasks = [];
|
|
86
|
+
|
|
87
|
+
if (shouldEmitRobots) {
|
|
88
|
+
const target = path.join(outDir, 'robots.txt');
|
|
89
|
+
let body = options.robotsTxt || readRobotsTemplate();
|
|
90
|
+
/* If the site config has a url, append a Sitemap line so
|
|
91
|
+
crawlers without locale-suffix discovery still find the
|
|
92
|
+
per-locale sitemaps. Sites supplying their own robotsTxt
|
|
93
|
+
body are trusted to include their own Sitemap lines. */
|
|
94
|
+
if (!options.robotsTxt && siteConfig?.url) {
|
|
95
|
+
const u = siteConfig.url.replace(/\/$/, '');
|
|
96
|
+
body = body.trimEnd() + `\n\nSitemap: ${u}/sitemap.xml\n`;
|
|
97
|
+
}
|
|
98
|
+
tasks.push(fs.promises.writeFile(target, body, 'utf8'));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (shouldEmitLlms) {
|
|
102
|
+
const target = path.join(outDir, 'llms.txt');
|
|
103
|
+
tasks.push(fs.promises.writeFile(target, options.llmsTxt, 'utf8'));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await Promise.all(tasks);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = aiCrawlingPlugin;
|
|
112
|
+
module.exports.readRobotsTemplate = readRobotsTemplate;
|
|
Binary file
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Conduction default robots.txt
|
|
2
|
+
#
|
|
3
|
+
# Generated at build time by the @conduction/docusaurus-preset
|
|
4
|
+
# ai-crawling plugin. Sites that ship their own static/robots.txt
|
|
5
|
+
# get that file instead (the plugin no-ops if robots.txt already
|
|
6
|
+
# exists in the build output).
|
|
7
|
+
#
|
|
8
|
+
# Posture: open. Conduction is an open-source company, our content is
|
|
9
|
+
# meant to be read, quoted, and learned from. We allow both AI-search
|
|
10
|
+
# citation crawlers and AI-training crawlers.
|
|
11
|
+
#
|
|
12
|
+
# Override by dropping a custom static/robots.txt in your site repo.
|
|
13
|
+
# To flip the whole fleet, edit the DEFAULT_ROBOTS_TXT constant in
|
|
14
|
+
# design-system/docusaurus-preset/src/plugins/ai-crawling.js.
|
|
15
|
+
#
|
|
16
|
+
# Bots are listed explicitly even though "User-agent: *" already permits
|
|
17
|
+
# them. The explicit list documents intent and makes a future audit a
|
|
18
|
+
# one-line diff.
|
|
19
|
+
|
|
20
|
+
User-agent: *
|
|
21
|
+
Allow: /
|
|
22
|
+
|
|
23
|
+
# AI search / citation crawlers
|
|
24
|
+
User-agent: OAI-SearchBot
|
|
25
|
+
Allow: /
|
|
26
|
+
|
|
27
|
+
User-agent: ChatGPT-User
|
|
28
|
+
Allow: /
|
|
29
|
+
|
|
30
|
+
User-agent: Claude-SearchBot
|
|
31
|
+
Allow: /
|
|
32
|
+
|
|
33
|
+
User-agent: Claude-User
|
|
34
|
+
Allow: /
|
|
35
|
+
|
|
36
|
+
User-agent: PerplexityBot
|
|
37
|
+
Allow: /
|
|
38
|
+
|
|
39
|
+
User-agent: Perplexity-User
|
|
40
|
+
Allow: /
|
|
41
|
+
|
|
42
|
+
User-agent: DuckAssistBot
|
|
43
|
+
Allow: /
|
|
44
|
+
|
|
45
|
+
User-agent: MistralAI-User
|
|
46
|
+
Allow: /
|
|
47
|
+
|
|
48
|
+
User-agent: Amazonbot
|
|
49
|
+
Allow: /
|
|
50
|
+
|
|
51
|
+
User-agent: YouBot
|
|
52
|
+
Allow: /
|
|
53
|
+
|
|
54
|
+
# AI training crawlers
|
|
55
|
+
User-agent: GPTBot
|
|
56
|
+
Allow: /
|
|
57
|
+
|
|
58
|
+
User-agent: ClaudeBot
|
|
59
|
+
Allow: /
|
|
60
|
+
|
|
61
|
+
User-agent: anthropic-ai
|
|
62
|
+
Allow: /
|
|
63
|
+
|
|
64
|
+
User-agent: Claude-Web
|
|
65
|
+
Allow: /
|
|
66
|
+
|
|
67
|
+
User-agent: CCBot
|
|
68
|
+
Allow: /
|
|
69
|
+
|
|
70
|
+
User-agent: cohere-training-data-crawler
|
|
71
|
+
Allow: /
|
|
72
|
+
|
|
73
|
+
User-agent: cohere-ai
|
|
74
|
+
Allow: /
|
|
75
|
+
|
|
76
|
+
User-agent: Meta-ExternalAgent
|
|
77
|
+
Allow: /
|
|
78
|
+
|
|
79
|
+
User-agent: FacebookBot
|
|
80
|
+
Allow: /
|
|
81
|
+
|
|
82
|
+
User-agent: Applebot-Extended
|
|
83
|
+
Allow: /
|
|
84
|
+
|
|
85
|
+
User-agent: Google-Extended
|
|
86
|
+
Allow: /
|