@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/docusaurus-preset",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "scripts": {
5
5
  "prepack": "node scripts/prepack-bundle-css.js"
6
6
  },
@@ -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
- export default function FAQ({title = 'FAQ.', children, className}) {
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, in increasing-precedence order:
270
- 1. preset's own ../static (lib/canal-footer, conduction-bg, hex-rain,
271
- platform-diagram + brand img/favicon, logo, logo-dark, nextcloud-logo)
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
- Last wins per-file, so a site can drop its own /img/logo.svg into
274
- static/img/logo.svg to override the brand default. */
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
- plugins: opts.plugins || [],
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: /