@conduction/docusaurus-preset 3.3.0 → 3.4.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/package.json +1 -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/package.json
CHANGED
|
@@ -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: /
|