@conduction/docusaurus-preset 3.25.0 → 3.26.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.25.0",
3
+ "version": "3.26.0",
4
4
  "scripts": {
5
5
  "prepack": "node scripts/prepack-bundle-css.js"
6
6
  },
@@ -70,8 +70,11 @@
70
70
  background: var(--c-mint-500);
71
71
  flex-shrink: 0;
72
72
  }
73
- .beta { background: var(--c-cobalt-500); }
74
- .soon { background: var(--c-orange-knvb); }
73
+ /* Compound selectors (.h.beta / .h.soon) so the status colour always wins
74
+ over the default .h mint, regardless of how the CSS minifier reorders or
75
+ merges the single-class rules in the bundle. */
76
+ .h.beta { background: var(--c-cobalt-500); }
77
+ .h.soon { background: var(--c-orange-knvb); }
75
78
 
76
79
  .label {
77
80
  flex: 1;
@@ -1,47 +1,70 @@
1
1
  /**
2
2
  * <FeaturesPage />
3
3
  *
4
- * Route component used by the `conduction-features-page` plugin. Renders
5
- * the list of implemented/reviewed capabilities extracted from
6
- * `openspec/specs/` into `docs/features.json` (regenerated by the
7
- * org-wide Features Extract workflow stage).
4
+ * Route component used by the `conduction-features-page` plugin. Renders the
5
+ * app's commercial capability list, sourced (in priority order) from a curated
6
+ * `openspec/features.overlay.json` or, as a fallback, hardened spec derivation
7
+ * from `openspec/specs/` (see plugins/extractFeatures.js).
8
8
  *
9
- * Reuses the brand `<FeatureGrid>` for the actual list every entry maps
10
- * to a single `<FeatureItem>` with status `stable` (the extractor only
11
- * emits specs whose frontmatter declares `implemented` or `reviewed`,
12
- * both of which are stable by definition).
9
+ * Each entry maps to a single `<FeatureItem>` inside `<FeatureGrid>`, coloured
10
+ * by its maturity: stable (mint), beta (blue), soon (orange).
11
+ *
12
+ * Locale-aware: on the Dutch site each entry shows `title_nl` / `summary_nl`
13
+ * when present, falling back to the English `title` / `summary`. A
14
+ * `providedBy` capability (e.g. surfaced from OpenRegister) is credited inline
15
+ * so cross-app functionality reads as real and attributed.
13
16
  *
14
17
  * Receives one prop module from the plugin via `addRoute({modules})`:
15
18
  *
16
19
  * data: {
17
- * features: Array<{slug, title, summary, docsUrl}>,
20
+ * features: Array<{slug,title,summary,status,docsUrl,providedBy?,title_nl?,summary_nl?}>,
18
21
  * title: string,
19
22
  * intro: string | null,
20
23
  * }
21
- *
22
- * Sites that want a different page chrome can swizzle the plugin route
23
- * to register their own component; this default is intentionally minimal
24
- * (one heading, one optional intro paragraph, the grid).
25
24
  */
26
25
 
27
26
  import React from 'react';
28
27
  import Layout from '@theme/Layout';
28
+ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
29
29
  import FeatureGrid from '../FeatureGrid/FeatureGrid.jsx';
30
30
 
31
+ // Display name + localized "powered by" lead-in for a providedBy app id.
32
+ const PROVIDER_NAMES = {
33
+ openregister: 'OpenRegister',
34
+ opencatalogi: 'OpenCatalogi',
35
+ openconnector: 'OpenConnector',
36
+ docudesk: 'DocuDesk',
37
+ nldesign: 'NLDesign',
38
+ };
39
+
40
+ function providerCredit(providedBy, locale) {
41
+ if (!providedBy) return '';
42
+ const name = PROVIDER_NAMES[String(providedBy).toLowerCase()] || providedBy;
43
+ return locale === 'nl' ? `Draait op ${name}.` : `Powered by ${name}.`;
44
+ }
45
+
31
46
  export default function FeaturesPage({data}) {
32
47
  const {features = [], title = 'Features', intro = null} = data || {};
48
+ const {i18n} = useDocusaurusContext();
49
+ const locale = (i18n && i18n.currentLocale) || 'en';
33
50
 
34
- const items = features.map((f) => ({
35
- label: f.title || f.slug,
36
- tip: f.summary || '',
37
- status: f.status || 'stable',
38
- href: f.docsUrl || undefined,
39
- }));
51
+ const items = features.map((f) => {
52
+ const label = (locale === 'nl' && f.title_nl) || f.title || f.slug;
53
+ const summary = (locale === 'nl' && f.summary_nl) || f.summary || '';
54
+ const credit = providerCredit(f.providedBy, locale);
55
+ const tip = [summary, credit].filter(Boolean).join(' ');
56
+ return {
57
+ label,
58
+ tip,
59
+ status: f.status || 'stable',
60
+ href: f.docsUrl || undefined,
61
+ };
62
+ });
40
63
 
41
64
  return (
42
65
  <Layout
43
66
  title={title}
44
- description={intro || `Capabilities shipped in this app, sourced from openspec/specs/.`}
67
+ description={intro || `Capabilities shipped in this app.`}
45
68
  >
46
69
  <main className="container margin-vert--lg">
47
70
  <h1>{title}</h1>
@@ -3,19 +3,26 @@
3
3
  *
4
4
  * Build-time port of the org-wide `scripts/extract-features.py`
5
5
  * (https://codeberg.org/Conduction/.github). Derives the published feature
6
- * list directly from an app's `openspec/specs/<slug>/spec.md` files so the
7
- * docs `/features` page is ALWAYS generated from the current specs — the
8
- * committed `docs/features.json` is no longer a hand-maintained source of
9
- * truth that can drift.
6
+ * list for the docs `/features` page from two sources, in priority order:
10
7
  *
11
- * Only specs whose YAML frontmatter declares `status: done` are emitted
12
- * (the canonical "shipped" status). Each entry: { slug, title, summary,
13
- * docsUrl } identical shape and ordering (sorted by slug) to the Python
14
- * extractor, so docs output matches CI output byte-for-byte.
8
+ * 1. Commercial overlay `openspec/features.overlay.json` (optional). When
9
+ * present it is the AUTHORITATIVE curated list: honest maturity,
10
+ * brand-voiced NL/EN benefit copy, roadmap rows, and capabilities
11
+ * provided by another app (e.g. OpenRegister's AI assistants and content
12
+ * leaves surfaced through a consuming app, via `providedBy`).
13
+ * 2. Spec derivation (fallback) — `openspec/specs/<slug>/spec.md`. Hardened:
14
+ * internal/process specs are filtered out, engineering artifacts
15
+ * (`@e2e`, `#<pr>`, `Spec:`, em-dashes, `TBD`) are stripped, and optional
16
+ * frontmatter fields (`commercial`, `maturity`, `title`, `summary`,
17
+ * `provided-by`, `*_nl`) let an author curate without an overlay.
15
18
  *
16
- * Returns null when the directory is absent or holds no done specs, so the
17
- * caller can fall back to a committed file on deploys that ship without
18
- * `openspec/`.
19
+ * Maturity is one of stable (mint) / beta (blue) / soon (orange). Each entry:
20
+ * { slug, title, summary, status, docsUrl } plus optional { providedBy,
21
+ * title_nl, summary_nl }. Overlay order is preserved; spec-derived entries are
22
+ * sorted by slug. Mirrors the Python extractor so docs output matches CI.
23
+ *
24
+ * Returns null when nothing is available, so the caller can fall back to a
25
+ * committed `docs/features.json` on deploys shipped without `openspec/`.
19
26
  */
20
27
 
21
28
  const fs = require('fs');
@@ -24,14 +31,16 @@ const path = require('path');
24
31
  const FRONTMATTER_RE = /^---\s*\n([\s\S]*?\n)---\s*\n([\s\S]*)$/;
25
32
  const SLUGGY_RE = /^[a-z0-9]+(?:-[a-z0-9]+)+$/;
26
33
 
27
- // Map a spec's frontmatter status to a roadmap kind: stable (mint),
28
- // beta (cobalt blue), soon (orange). Unmapped statuses are skipped.
34
+ // Fallback map: spec lifecycle status -> buyer-facing maturity. Used only when
35
+ // a spec has no explicit `maturity:` and the app has no overlay.
29
36
  const STATUS_KIND = {
30
37
  done: 'stable', implemented: 'stable', reviewed: 'stable', active: 'stable', stable: 'stable',
31
38
  'in-progress': 'beta', implementing: 'beta', partial: 'beta', beta: 'beta',
32
39
  draft: 'soon', specified: 'soon', proposed: 'soon', planned: 'soon', 'coming-soon': 'soon', soon: 'soon',
33
40
  };
34
41
 
42
+ const VALID_MATURITY = new Set(['stable', 'beta', 'soon']);
43
+
35
44
  const ACRONYMS = new Set([
36
45
  'ai', 'api', 'ui', 'ux', 'or', 'bi', 'mcp', 'tmlo', 'mdto', 'dcat', 'woo',
37
46
  'vth', 'kcc', 'crm', 'pdf', 'csv', 'sepa', 'zgw', 'ztc', 'dso', 'rbac',
@@ -40,6 +49,31 @@ const ACRONYMS = new Set([
40
49
  'pwa', 'sip', 'eml', 'sla', 'llm', 'rag', 'e2e', 'qr', 'vng',
41
50
  ]);
42
51
 
52
+ // Slug segments that mark an internal/process spec (filtered unless the spec
53
+ // sets `commercial: true` or an overlay re-includes it).
54
+ const INTERNAL_SEGMENTS = new Set([
55
+ 'refactor', 'refactoring', 'migrate', 'migration', 'migrations', 'migrated',
56
+ 'test', 'tests', 'testing', 'e2e', 'ci', 'cd', 'docs', 'documentation',
57
+ 'manifest', 'plumbing', 'scaffold', 'scaffolding', 'bootstrap', 'chore',
58
+ 'cleanup', 'rename', 'renaming', 'bump', 'deps', 'dependency',
59
+ 'dependencies', 'lint', 'linting', 'coverage', 'gate', 'gates', 'seed',
60
+ 'seeding', 'fixture', 'fixtures', 'spike', 'poc', 'tooling', 'devx',
61
+ 'internal',
62
+ ]);
63
+
64
+ function field(front, key) {
65
+ const re = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:\\s*(.+?)\\s*$`, 'm');
66
+ const m = front.match(re);
67
+ if (!m) return null;
68
+ return m[1].trim().replace(/^["']|["']$/g, '');
69
+ }
70
+
71
+ function boolField(front, key) {
72
+ const raw = field(front, key);
73
+ if (raw === null) return null;
74
+ return ['true', 'yes', '1'].includes(raw.trim().toLowerCase());
75
+ }
76
+
43
77
  function titlecaseSlug(slug) {
44
78
  return slug
45
79
  .split('-')
@@ -47,12 +81,53 @@ function titlecaseSlug(slug) {
47
81
  .join(' ');
48
82
  }
49
83
 
84
+ function stripArtifacts(text) {
85
+ if (!text) return '';
86
+ let t = text;
87
+ t = t.replace(/@e2e\b[^\n.]*/gi, '');
88
+ t = t.replace(/\(?#\d+\)?/g, '');
89
+ t = t.replace(/\b(?:delta\s+)?spec:\s*/gi, '');
90
+ t = t.replace(/`/g, '');
91
+ t = t.replace(/\s*--\s*/g, ', ');
92
+ t = t.replace(/\s*[—]\s*/g, '. ');
93
+ t = t.replace(/\b(?:TODO|TBD)\b/gi, '');
94
+ t = t.replace(/\s+/g, ' ');
95
+ t = t.replace(/\s+([.,;:])/g, '$1');
96
+ t = t.replace(/\.\s*\./g, '.');
97
+ return t.replace(/^[\s.,;:-]+|[\s.,;:-]+$/g, '');
98
+ }
99
+
50
100
  function cleanTitle(rawTitle, slug) {
51
- let title = rawTitle.replace(/^\s*spec:\s*/i, '').replace(/\s+specification\s*$/i, '').trim();
101
+ let title = rawTitle.replace(/^\s*(?:delta\s+)?spec:\s*/i, '').replace(/\s+specification\s*$/i, '').trim();
102
+ title = stripArtifacts(title);
103
+ title = title.split('. ')[0].trim().replace(/\.$/, '');
52
104
  if (!title || title === slug || SLUGGY_RE.test(title)) title = titlecaseSlug(slug);
53
105
  return title;
54
106
  }
55
107
 
108
+ function firstSentence(summary) {
109
+ let s = stripArtifacts(summary);
110
+ if (!s) return '';
111
+ const m = s.match(/^as an? .*? so that (.+)$/i);
112
+ if (m) s = m[1].trim();
113
+ s = s.split('. ')[0].trim();
114
+ if (!s || ['tbd', 'todo', 'purpose'].includes(s.toLowerCase())) return '';
115
+ s = s.charAt(0).toUpperCase() + s.slice(1);
116
+ if (!s.endsWith('.')) s += '.';
117
+ return s;
118
+ }
119
+
120
+ function isInternalSlug(slug) {
121
+ return slug.split('-').some((seg) => INTERNAL_SEGMENTS.has(seg));
122
+ }
123
+
124
+ function attachOptional(entry, providedBy, titleNl, summaryNl) {
125
+ if (providedBy) entry.providedBy = providedBy;
126
+ if (titleNl) entry.title_nl = titleNl;
127
+ if (summaryNl) entry.summary_nl = summaryNl;
128
+ return entry;
129
+ }
130
+
56
131
  function parseSpec(specPath, slug) {
57
132
  let text;
58
133
  try {
@@ -60,45 +135,108 @@ function parseSpec(specPath, slug) {
60
135
  } catch (e) {
61
136
  return null;
62
137
  }
63
-
64
138
  const fm = FRONTMATTER_RE.exec(text);
65
139
  if (fm === null) return null;
66
140
  const front = fm[1];
67
141
  const body = fm[2];
68
142
 
69
- const statusMatch = front.match(/^status:\s*(.+?)\s*$/m);
70
- const status = statusMatch ? statusMatch[1].trim().replace(/^["']|["']$/g, '').toLowerCase() : '';
71
- const kind = STATUS_KIND[status];
143
+ const commercial = boolField(front, 'commercial');
144
+ if (commercial === false) return null;
145
+
146
+ const status = (field(front, 'status') || '').toLowerCase();
147
+ const maturity = (field(front, 'maturity') || '').toLowerCase();
148
+ let kind = VALID_MATURITY.has(maturity) ? maturity : STATUS_KIND[status];
149
+ if (!kind) kind = commercial === true ? 'beta' : null;
72
150
  if (!kind) return null;
73
151
 
74
- const titleMatch = body.match(/^#\s+(.+?)\s*$/m);
75
- const rawTitle = titleMatch ? titleMatch[1].trim() : slug;
76
- const title = cleanTitle(rawTitle, slug);
152
+ if (isInternalSlug(slug) && commercial !== true) return null;
153
+
154
+ const titleOverride = field(front, 'title');
155
+ let title;
156
+ if (titleOverride) {
157
+ title = stripArtifacts(titleOverride) || titlecaseSlug(slug);
158
+ } else {
159
+ const titleMatch = body.match(/^#\s+(.+?)\s*$/m);
160
+ const rawTitle = titleMatch ? titleMatch[1].trim() : slug;
161
+ title = cleanTitle(rawTitle, slug);
162
+ }
77
163
 
164
+ const summaryOverride = field(front, 'summary') || field(front, 'tagline');
78
165
  let summary = '';
79
- const purposeHeading = body.match(/^##\s+Purpose\s*$/m);
80
- if (purposeHeading) {
81
- const rest = body.slice(purposeHeading.index + purposeHeading[0].length);
82
- const nextHeadingIdx = rest.search(/\n##\s/);
83
- const section = (nextHeadingIdx === -1 ? rest : rest.slice(0, nextHeadingIdx)).trim();
84
- const firstPara = section.split(/\n\s*\n/)[0] || '';
85
- summary = firstPara.trim().split(/\s+/).join(' ');
166
+ if (summaryOverride) {
167
+ summary = firstSentence(summaryOverride);
168
+ } else {
169
+ const purposeHeading = body.match(/^##\s+Purpose\s*$/m);
170
+ if (purposeHeading) {
171
+ const rest = body.slice(purposeHeading.index + purposeHeading[0].length);
172
+ const nextHeadingIdx = rest.search(/\n##\s/);
173
+ const section = (nextHeadingIdx === -1 ? rest : rest.slice(0, nextHeadingIdx)).trim();
174
+ const firstPara = section.split(/\n\s*\n/)[0] || '';
175
+ summary = firstSentence(firstPara);
176
+ }
86
177
  }
87
178
 
88
- return {
179
+ const entry = {
89
180
  slug,
90
181
  title,
91
182
  summary,
92
183
  status: kind,
93
184
  docsUrl: `openspec/specs/${slug}/spec.md`,
94
185
  };
186
+ return attachOptional(
187
+ entry,
188
+ field(front, 'provided-by') || field(front, 'providedBy'),
189
+ field(front, 'title_nl'),
190
+ firstSentence(field(front, 'summary_nl') || field(front, 'tagline_nl') || ''),
191
+ );
192
+ }
193
+
194
+ function normalizeOverlayEntry(raw) {
195
+ if (!raw || typeof raw !== 'object') return null;
196
+ let slug = String(raw.slug || '').trim();
197
+ let status = String(raw.status || '').trim().toLowerCase();
198
+ if (!VALID_MATURITY.has(status)) status = STATUS_KIND[status] || 'stable';
199
+ const title = String(raw.title || '').trim() || (slug ? titlecaseSlug(slug) : '');
200
+ if (!title) return null;
201
+ if (!slug) slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
202
+ const entry = {
203
+ slug,
204
+ title,
205
+ summary: String(raw.summary || '').trim(),
206
+ status,
207
+ docsUrl: String(raw.docsUrl || `openspec/specs/${slug}/spec.md`),
208
+ };
209
+ return attachOptional(
210
+ entry,
211
+ raw.providedBy,
212
+ raw.title_nl ? String(raw.title_nl).trim() : null,
213
+ raw.summary_nl ? String(raw.summary_nl).trim() : null,
214
+ );
215
+ }
216
+
217
+ function loadOverlay(specsDir) {
218
+ const overlayPath = path.resolve(specsDir, '..', 'features.overlay.json');
219
+ let data;
220
+ try {
221
+ if (!fs.statSync(overlayPath).isFile()) return null;
222
+ data = JSON.parse(fs.readFileSync(overlayPath, 'utf8'));
223
+ } catch (e) {
224
+ return null;
225
+ }
226
+ const rawList = Array.isArray(data) ? data : (data && data.features);
227
+ if (!Array.isArray(rawList)) return null;
228
+ const entries = rawList.map(normalizeOverlayEntry).filter((e) => e !== null);
229
+ return entries.length > 0 ? entries : null;
95
230
  }
96
231
 
97
232
  /**
98
233
  * @param {string} specsDir Absolute path to `<app>/openspec/specs`.
99
- * @returns {Array<{slug,title,summary,docsUrl}>|null}
234
+ * @returns {Array<object>|null}
100
235
  */
101
236
  function extractFeatures(specsDir) {
237
+ const overlay = loadOverlay(specsDir);
238
+ if (overlay !== null) return overlay;
239
+
102
240
  let slugs;
103
241
  try {
104
242
  if (!fs.statSync(specsDir).isDirectory()) return null;
@@ -124,4 +262,4 @@ function extractFeatures(specsDir) {
124
262
  return entries.length > 0 ? entries : null;
125
263
  }
126
264
 
127
- module.exports = { extractFeatures };
265
+ module.exports = {extractFeatures};