@conduction/docusaurus-preset 3.37.0 → 3.39.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/MISSING_COMPONENTS.md +1 -0
- package/package.json +1 -1
- package/src/__tests__/no-icu-messages.test.js +57 -0
- package/src/components/AiDisclosure/AiDisclosure.jsx +45 -21
- package/src/components/AiDisclosure/AiDisclosure.module.css +38 -3
- package/src/components/AiDisclosure/__tests__/AiDisclosure.render.test.js +25 -13
- package/src/components/AiDisclosure/__tests__/disclosure.test.js +60 -0
- package/src/components/AiDisclosure/disclosure.js +60 -4
- package/src/components/BlueprintRush/BlueprintRush.jsx +238 -0
- package/src/components/BlueprintRush/BlueprintRush.module.css +186 -0
- package/src/components/BlueprintRush/__tests__/engine.test.js +154 -0
- package/src/components/BlueprintRush/engine.js +172 -0
- package/src/components/DeadlineDefender/DeadlineDefender.jsx +237 -0
- package/src/components/DeadlineDefender/DeadlineDefender.module.css +193 -0
- package/src/components/DeadlineDefender/__tests__/engine.test.js +163 -0
- package/src/components/DeadlineDefender/engine.js +188 -0
- package/src/components/DetailHero/DetailHero.jsx +11 -4
- package/src/components/DetailHero/__tests__/DetailHero.downloads.test.js +160 -0
- package/src/components/FeaturedCard/FeaturedCard.jsx +14 -1
- package/src/components/FeaturedCard/FeaturedCard.module.css +10 -0
- package/src/components/FeaturedCard/__tests__/FeaturedCard.visual.test.js +110 -0
- package/src/components/GameModal/GameModal.jsx +255 -50
- package/src/components/GameModal/GameModal.module.css +103 -0
- package/src/components/GameModal/__tests__/scores.test.js +123 -0
- package/src/components/GameModal/__tests__/share.test.js +83 -0
- package/src/components/GameModal/scores.js +149 -0
- package/src/components/GameModal/share.js +97 -0
- package/src/components/RecordRun/RecordRun.jsx +245 -0
- package/src/components/RecordRun/RecordRun.module.css +208 -0
- package/src/components/RecordRun/__tests__/engine.test.js +191 -0
- package/src/components/RecordRun/engine.js +180 -0
- package/src/components/StampRush/StampRush.jsx +232 -0
- package/src/components/StampRush/StampRush.module.css +188 -0
- package/src/components/StampRush/__tests__/engine.test.js +182 -0
- package/src/components/StampRush/engine.js +185 -0
- package/src/components/ThemeSeamMock/ThemeSeamMock.jsx +79 -0
- package/src/components/ThemeSeamMock/ThemeSeamMock.module.css +178 -0
- package/src/components/ThemeSeamMock/__tests__/ThemeSeamMock.render.test.js +122 -0
- package/src/components/index.js +5 -0
- package/src/data/app-downloads.js +21 -0
- package/src/index.js +10 -0
- package/src/theme/Footer/index.jsx +10 -1
package/MISSING_COMPONENTS.md
CHANGED
|
@@ -65,6 +65,7 @@ Most consume one or more primitives.
|
|
|
65
65
|
- **BuildMock** — `BuildMock/BuildMock.jsx`. Looping "make it yours" animation: the orange Buildiq edit FAB is pressed, a component palette opens, and a widget is dragged into the page layout where it snaps in (~8s loop). `prefers-reduced-motion` / `running={false}` renders the static end state (component placed).
|
|
66
66
|
- **KanbanMock** — `KanbanMock/KanbanMock.jsx`. Looping kanban drag for the pipelinq hero: a cursor lifts the top lead card, drags it to the next column, drops it flush into the column's card slot, and at the drop the column counts nudge (3→2, 2→3) and the per-lane € KPI tiles above the board shift the moved deal's value (~7s loop, clean reset). `prefers-reduced-motion` / `running={false}` renders the static board with the initial counts and totals.
|
|
67
67
|
- **MockScene** — `MockScene/MockScene.jsx`. Free-form positioning stage for overlap shots of WidgetMocks + SidebarMocks (caller supplies canvas size and per-item x/y/z). The scene assembles on a loop: items land on a 220ms stagger, transform-only so the inline left/top positioning is never fought; `running={false}` / `prefers-reduced-motion` show the assembled scene.
|
|
68
|
+
- **ThemeSeamMock** — `ThemeSeamMock/ThemeSeamMock.jsx`. One AppMock frame rendered twice, the second inside a wrapper that redefines the palette and radius tokens. A stitched seam travels across the frame and the finish changes behind it (`--ts-dur`, 9s), then the loop hands the frame back. The themed layer is nothing but tokens, which is the claim Thematiq makes about a token set; the inner mocks run `running={false}` so their own loops do not compete with the seam. Themes: `lasuite`. `running={false}` / `prefers-reduced-motion` render the themed end state with the seam at rest.
|
|
68
69
|
- **ExternalAppShelf** — `ExternalAppShelf/ExternalAppShelf.jsx`. Third-party-app shelf (Outlook-style, Mattermost, Keycloak, etc.) used on `/connext`. Specimen: `preview/components/external-app-shelf.html`.
|
|
69
70
|
- **WidgetShelf** — `WidgetShelf/WidgetShelf.jsx`. Widget showcase with eyebrow + title + lede. Default is a slow single-row carousel (seamless duplicated track, pause on hover/focus, static wrapping grid under `prefers-reduced-motion`); loop duration derives from the widget count (6s per card, min 30s; `speed` overrides), with pause/play + prev/next controls (cobalt ghost, hidden under reduced motion). Widgets without an explicit `panel` get a deterministic auto-generated mini panel (KPI tile / bar chart / list rows / donut, hashed from the title). `carousel={false}` renders the N-column grid. Specimen: `preview/components/widget-shelf.html`.
|
|
70
71
|
- **Showcase** — `Showcase/Showcase.jsx`. Multi-item collapsible showcase with screenshot + body. Used on `/commonground` for the "5-lagen model". Specimen: `preview/components/showcase.html`.
|
package/package.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* no-icu-messages.test.js — no translatable string may use ICU plural
|
|
3
|
+
* or select syntax.
|
|
4
|
+
*
|
|
5
|
+
* Docusaurus's `translate()` and `<Translate>` substitute
|
|
6
|
+
* `{placeholder}` and nothing else. An ICU message therefore does not
|
|
7
|
+
* fail, does not warn, and does not fall back: it renders to the
|
|
8
|
+
* reader verbatim, braces and keywords and all, in every locale. It
|
|
9
|
+
* shipped that way in the game-over modal and was reported from the
|
|
10
|
+
* live site.
|
|
11
|
+
*
|
|
12
|
+
* The fix is two messages chosen in JS. This test is the reason nobody
|
|
13
|
+
* has to remember that.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const test = require('node:test');
|
|
19
|
+
const assert = require('node:assert/strict');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
|
|
23
|
+
const SRC = path.resolve(__dirname, '..');
|
|
24
|
+
/* `{name, plural, ...}` / `{count, select, ...}`, the two ICU forms
|
|
25
|
+
that render raw. Deliberately loose: any argument name, any spacing. */
|
|
26
|
+
const ICU = /\{\s*\w+\s*,\s*(plural|select|selectordinal)\s*,/;
|
|
27
|
+
|
|
28
|
+
function sourceFiles(dir) {
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
31
|
+
const full = path.join(dir, entry.name);
|
|
32
|
+
if (entry.isDirectory()) {
|
|
33
|
+
if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
|
|
34
|
+
out.push(...sourceFiles(full));
|
|
35
|
+
} else if (/\.(jsx?|tsx?)$/.test(entry.name)) {
|
|
36
|
+
out.push(full);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('no component ships an ICU plural or select message', () => {
|
|
43
|
+
const offenders = [];
|
|
44
|
+
for (const file of sourceFiles(SRC)) {
|
|
45
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
46
|
+
lines.forEach((line, i) => {
|
|
47
|
+
/* Skip the comment that explains the rule. */
|
|
48
|
+
if (/^\s*(\*|\/\/|\/\*)/.test(line)) return;
|
|
49
|
+
if (ICU.test(line)) offenders.push(`${path.relative(SRC, file)}:${i + 1}: ${line.trim()}`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
assert.deepEqual(
|
|
53
|
+
offenders,
|
|
54
|
+
[],
|
|
55
|
+
'ICU syntax renders verbatim to the reader; use two messages picked in JS:\n' + offenders.join('\n'),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
@@ -32,28 +32,39 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import React from 'react';
|
|
35
|
-
import {useColorMode} from '@docusaurus/theme-common';
|
|
36
35
|
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
|
37
36
|
import useBaseUrl from '@docusaurus/useBaseUrl';
|
|
38
37
|
import styles from './AiDisclosure.module.css';
|
|
39
|
-
import {isValidKind, getCopy,
|
|
38
|
+
import {isValidKind, getCopy, getLinkText, resolveLinkHref, LINK_HREF, AI_KINDS} from './disclosure';
|
|
39
|
+
|
|
40
|
+
// The Commission's own AI letterforms, lifted verbatim from the
|
|
41
|
+
// vendored Basic mark (static/img/ai-disclosure/ai-black.svg, paths 2
|
|
42
|
+
// and 3) so the glyph a reader recognises is unchanged. What is
|
|
43
|
+
// replaced is only the container: the Commission's black disc becomes
|
|
44
|
+
// Conduction's hexagon in neutral grey, matching the hex used for app
|
|
45
|
+
// glyphs across the design system.
|
|
46
|
+
//
|
|
47
|
+
// Both live in the mark's original 566.93 viewBox, so the letterforms
|
|
48
|
+
// keep their exact position and proportion inside the new container.
|
|
49
|
+
// The hexagon is regular, centred on the disc's own centre
|
|
50
|
+
// (272.03, 283.46) with a circumradius of 200 - larger than the disc's
|
|
51
|
+
// 182.74 because a hexagon's inradius is only 0.866 of its
|
|
52
|
+
// circumradius, and the letters need the vertical room.
|
|
53
|
+
const HEX_PATH =
|
|
54
|
+
'M472.03,283.46 L372.03,456.67 L172.03,456.67 L72.03,283.46 L172.03,110.25 L372.03,110.25 Z';
|
|
55
|
+
const AI_LETTER_A =
|
|
56
|
+
'M170.79,353.74c-1.08,0-2.05-.43-2.92-1.31-.88-.87-1.31-1.84-1.31-2.92,0-.67.07-1.27.2-1.81l47.34-129.32c.4-1.48,1.24-2.79,2.52-3.93,1.27-1.14,3.05-1.71,5.34-1.71h29.81c2.28,0,4.06.57,5.34,1.71,1.27,1.14,2.11,2.45,2.52,3.93l47.14,129.32c.27.54.4,1.14.4,1.81,0,1.08-.44,2.05-1.31,2.92s-1.91,1.31-3.12,1.31h-24.78c-2.01,0-3.52-.5-4.53-1.51-1.01-1.01-1.65-1.91-1.91-2.72l-7.86-20.55h-53.78l-7.65,20.55c-.27.81-.88,1.71-1.81,2.72-.94,1.01-2.55,1.51-4.83,1.51h-24.78ZM218.13,299.96h37.47l-18.93-53.18-18.53,53.18Z';
|
|
57
|
+
const AI_LETTER_I =
|
|
58
|
+
'M328.11,353.74c-1.48,0-2.69-.47-3.63-1.41-.94-.94-1.41-2.15-1.41-3.63v-130.93c0-1.48.47-2.68,1.41-3.63s2.15-1.41,3.63-1.41h26.99c1.48,0,2.68.47,3.63,1.41.94.94,1.41,2.15,1.41,3.63v130.93c0,1.48-.47,2.69-1.41,3.63-.94.94-2.15,1.41-3.63,1.41h-26.99Z';
|
|
40
59
|
|
|
41
60
|
export default function AiDisclosure({kind, className}) {
|
|
42
|
-
const {
|
|
43
|
-
const {i18n} = useDocusaurusContext();
|
|
61
|
+
const {i18n, siteConfig} = useDocusaurusContext();
|
|
44
62
|
const locale = (i18n && i18n.currentLocale) || 'en';
|
|
45
63
|
|
|
46
64
|
const isKindValid = isValidKind(kind);
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
const treatment = colorMode === 'dark' ? 'white' : 'black';
|
|
51
|
-
// Served as a plain static file (static/img/ai-disclosure/), not a
|
|
52
|
-
// webpack `.svg` import - see disclosure.js's ICONS_BASE_PATH
|
|
53
|
-
// comment for why. useBaseUrl() must run unconditionally (React
|
|
54
|
-
// hooks rule), so it's called even when kind is invalid; the result
|
|
55
|
-
// is simply unused in that branch.
|
|
56
|
-
const iconPath = useBaseUrl(getIconPath(isKindValid ? kind : 'assisted', treatment));
|
|
65
|
+
// useBaseUrl() must run unconditionally (React hooks rule), so it is
|
|
66
|
+
// called even when kind is invalid; the result is unused in that branch.
|
|
67
|
+
const href = resolveLinkHref(siteConfig && siteConfig.url, useBaseUrl(LINK_HREF));
|
|
57
68
|
|
|
58
69
|
if (!isKindValid) {
|
|
59
70
|
if (typeof console !== 'undefined') {
|
|
@@ -65,18 +76,31 @@ export default function AiDisclosure({kind, className}) {
|
|
|
65
76
|
return null;
|
|
66
77
|
}
|
|
67
78
|
|
|
68
|
-
const {width, height} = getViewBox(kind);
|
|
69
79
|
const copy = getCopy(kind, locale);
|
|
80
|
+
const linkText = getLinkText(locale);
|
|
70
81
|
|
|
71
82
|
return (
|
|
72
83
|
<div className={[styles.disclosure, className].filter(Boolean).join(' ')} role="note">
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
{/* One mark for all three kinds and both colour modes: the kind is
|
|
85
|
+
stated in the copy, and grey-on-white reads the same as
|
|
86
|
+
grey-on-dark, so there is no treatment to switch. */}
|
|
87
|
+
<svg
|
|
76
88
|
className={styles.mark}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
89
|
+
viewBox="0 0 566.93 566.93"
|
|
90
|
+
role="img"
|
|
91
|
+
aria-label="AI"
|
|
92
|
+
focusable="false"
|
|
93
|
+
xmlns="http://www.w3.org/2000/svg">
|
|
94
|
+
<path className={styles.markHex} d={HEX_PATH} />
|
|
95
|
+
<path className={styles.markGlyph} fillRule="evenodd" d={AI_LETTER_A} />
|
|
96
|
+
<path className={styles.markGlyph} d={AI_LETTER_I} />
|
|
97
|
+
</svg>
|
|
98
|
+
<p className={styles.copy}>
|
|
99
|
+
{copy}{' '}
|
|
100
|
+
<a className={styles.link} href={href}>
|
|
101
|
+
{linkText}
|
|
102
|
+
</a>
|
|
103
|
+
</p>
|
|
80
104
|
</div>
|
|
81
105
|
);
|
|
82
106
|
}
|
|
@@ -19,17 +19,52 @@
|
|
|
19
19
|
border-radius: var(--radius-md);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The mark is a single square hexagon glyph now, not the three
|
|
24
|
+
* differently-proportioned Commission word-marks, so it gets a fixed
|
|
25
|
+
* square box rather than a per-kind aspect-ratio.
|
|
26
|
+
*/
|
|
22
27
|
.mark {
|
|
23
28
|
display: block;
|
|
24
|
-
width:
|
|
25
|
-
|
|
26
|
-
height: auto;
|
|
29
|
+
width: 40px;
|
|
30
|
+
height: 40px;
|
|
27
31
|
flex-shrink: 0;
|
|
28
32
|
}
|
|
29
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Neutral grey, and deliberately not brand orange: this is a factual
|
|
36
|
+
* disclosure, not a call to action. Grey reads correctly on both the
|
|
37
|
+
* light and the dark banner surface, which is why the mark needs no
|
|
38
|
+
* light/dark treatment switch at all.
|
|
39
|
+
*/
|
|
40
|
+
.markHex {
|
|
41
|
+
fill: var(--ai-mark-hex, #6b7280);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.markGlyph {
|
|
45
|
+
fill: var(--ai-mark-glyph, #ffffff);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
[data-theme='dark'] .markHex {
|
|
49
|
+
fill: var(--ai-mark-hex-dark, #9ca3af);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
[data-theme='dark'] .markGlyph {
|
|
53
|
+
fill: var(--ai-mark-glyph-dark, #1f2937);
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
.copy {
|
|
31
57
|
margin: 0;
|
|
32
58
|
font-size: 14px;
|
|
33
59
|
line-height: 1.5;
|
|
34
60
|
color: var(--c-cobalt-700);
|
|
35
61
|
}
|
|
62
|
+
|
|
63
|
+
.link {
|
|
64
|
+
color: inherit;
|
|
65
|
+
text-decoration: underline;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.link:hover {
|
|
69
|
+
text-decoration: none;
|
|
70
|
+
}
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
* `@docusaurus/useBaseUrl`) are stubbed via esbuild plugins since this
|
|
12
12
|
* test runs outside an actual Docusaurus build; the useBaseUrl stub
|
|
13
13
|
* prefixes a fake `/base/` the same way the real hook prefixes a
|
|
14
|
-
* site's configured baseUrl, so the assertion is "the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* site's configured baseUrl, so the assertion is "the policy link was
|
|
15
|
+
* passed through base-url resolution", not a snapshot of a real
|
|
16
|
+
* deployed URL.
|
|
17
17
|
*
|
|
18
18
|
* @spec openspec/changes/ai-content-disclosure/tasks.md#task-5.2
|
|
19
19
|
*/
|
|
@@ -42,7 +42,7 @@ const cssModuleStub = {
|
|
|
42
42
|
},
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
-
function docusaurusStub({colorMode, locale}) {
|
|
45
|
+
function docusaurusStub({colorMode, locale, siteUrl}) {
|
|
46
46
|
return {
|
|
47
47
|
name: 'docusaurus-stub',
|
|
48
48
|
setup(b) {
|
|
@@ -54,7 +54,7 @@ function docusaurusStub({colorMode, locale}) {
|
|
|
54
54
|
loader: 'js',
|
|
55
55
|
}));
|
|
56
56
|
b.onLoad({filter: /^docusaurus-context$/, namespace: 'docusaurus-stub'}, () => ({
|
|
57
|
-
contents: `export default function useDocusaurusContext() { return {i18n: {currentLocale: ${JSON.stringify(locale)}}}; }`,
|
|
57
|
+
contents: `export default function useDocusaurusContext() { return {i18n: {currentLocale: ${JSON.stringify(locale)}}, siteConfig: {url: ${JSON.stringify(siteUrl)}}}; }`,
|
|
58
58
|
loader: 'js',
|
|
59
59
|
}));
|
|
60
60
|
// Mirrors the real hook's job: prefix the site's baseUrl onto a
|
|
@@ -73,7 +73,7 @@ function docusaurusStub({colorMode, locale}) {
|
|
|
73
73
|
// inside the package instead, mirroring build-kit.mjs's `.tmp-build-kit`.
|
|
74
74
|
const PRESET_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
|
|
75
75
|
|
|
76
|
-
async function renderAiDisclosure(props, {colorMode = 'light', locale = 'en'} = {}) {
|
|
76
|
+
async function renderAiDisclosure(props, {colorMode = 'light', locale = 'en', siteUrl = 'https://conduction.nl'} = {}) {
|
|
77
77
|
const scratchRoot = path.join(PRESET_ROOT, '.tmp-ai-disclosure-test');
|
|
78
78
|
await fs.mkdir(scratchRoot, {recursive: true});
|
|
79
79
|
const tmpDir = await fs.mkdtemp(path.join(scratchRoot, 'run-'));
|
|
@@ -89,7 +89,7 @@ async function renderAiDisclosure(props, {colorMode = 'light', locale = 'en'} =
|
|
|
89
89
|
tsconfigRaw: {compilerOptions: {jsx: 'react-jsx', jsxImportSource: 'react'}},
|
|
90
90
|
platform: 'node',
|
|
91
91
|
external: ['react'],
|
|
92
|
-
plugins: [cssModuleStub, docusaurusStub({colorMode, locale})],
|
|
92
|
+
plugins: [cssModuleStub, docusaurusStub({colorMode, locale, siteUrl})],
|
|
93
93
|
logLevel: 'warning',
|
|
94
94
|
});
|
|
95
95
|
delete require.cache[require.resolve(outFile)];
|
|
@@ -101,20 +101,32 @@ async function renderAiDisclosure(props, {colorMode = 'light', locale = 'en'} =
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
test('renders the
|
|
104
|
+
test('renders the hexagon mark, English copy and the base-url policy link', async () => {
|
|
105
105
|
const html = await renderAiDisclosure({kind: 'generated'}, {colorMode: 'light', locale: 'en'});
|
|
106
|
-
assert.match(html,
|
|
106
|
+
assert.match(html, /<svg[^>]*aria-label="AI"/);
|
|
107
107
|
assert.match(html, /This page was generated with AI\./);
|
|
108
|
+
assert.match(html, /href="\/base\/\/ai"/);
|
|
109
|
+
assert.match(html, /how Conduction uses AI\./);
|
|
108
110
|
});
|
|
109
111
|
|
|
110
|
-
test('renders the
|
|
111
|
-
const
|
|
112
|
-
|
|
112
|
+
test('renders the same mark in dark mode, with no treatment switch', async () => {
|
|
113
|
+
const light = await renderAiDisclosure({kind: 'modified'}, {colorMode: 'light', locale: 'en'});
|
|
114
|
+
const dark = await renderAiDisclosure({kind: 'modified'}, {colorMode: 'dark', locale: 'en'});
|
|
115
|
+
assert.equal(dark, light);
|
|
116
|
+
assert.doesNotMatch(dark, /<img/);
|
|
113
117
|
});
|
|
114
118
|
|
|
115
119
|
test('renders Dutch copy when the active locale is nl', async () => {
|
|
116
120
|
const html = await renderAiDisclosure({kind: 'assisted'}, {colorMode: 'light', locale: 'nl'});
|
|
117
|
-
assert.match(html, /Deze pagina is geschreven met hulp van AI\./);
|
|
121
|
+
assert.match(html, /Deze pagina is geschreven met hulp van AI, bijvoorbeeld voor spelling en onderzoek\./);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('points at the policy page on conduction.nl from a product docs site', async () => {
|
|
125
|
+
const html = await renderAiDisclosure(
|
|
126
|
+
{kind: 'assisted'},
|
|
127
|
+
{colorMode: 'light', locale: 'en', siteUrl: 'https://dossiq.conduction.nl'},
|
|
128
|
+
);
|
|
129
|
+
assert.match(html, /href="https:\/\/conduction\.nl\/ai"/);
|
|
118
130
|
});
|
|
119
131
|
|
|
120
132
|
test('renders nothing for an unrecognised kind', async () => {
|
|
@@ -32,6 +32,11 @@ const {
|
|
|
32
32
|
getIconPath,
|
|
33
33
|
getViewBox,
|
|
34
34
|
getCopy,
|
|
35
|
+
getLinkText,
|
|
36
|
+
LINK_TEXT,
|
|
37
|
+
LINK_HREF,
|
|
38
|
+
POLICY_URL,
|
|
39
|
+
resolveLinkHref,
|
|
35
40
|
} = require('../disclosure');
|
|
36
41
|
|
|
37
42
|
// Icons are vendored as plain static files (not a webpack `.svg`
|
|
@@ -216,3 +221,58 @@ test('isValidKind rejects anything outside the three permitted values', () => {
|
|
|
216
221
|
assert.equal(isValidKind(undefined), false);
|
|
217
222
|
assert.equal(isValidKind(null), false);
|
|
218
223
|
});
|
|
224
|
+
|
|
225
|
+
// -- "read more" pointer to the AI policy page --------------------------------
|
|
226
|
+
//
|
|
227
|
+
// The link is navigation, not a statement about the page, which is why
|
|
228
|
+
// it lives outside COPY and is asserted separately here.
|
|
229
|
+
|
|
230
|
+
test('link text covers every locale COPY covers', () => {
|
|
231
|
+
for (const locale of LOCALES) {
|
|
232
|
+
assert.equal(
|
|
233
|
+
typeof LINK_TEXT[locale],
|
|
234
|
+
'string',
|
|
235
|
+
`LINK_TEXT is missing locale "${locale}" that COPY defines`,
|
|
236
|
+
);
|
|
237
|
+
assert.ok(LINK_TEXT[locale].length > 0, `LINK_TEXT["${locale}"] is empty`);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('getLinkText falls back to English for an unknown locale', () => {
|
|
242
|
+
assert.equal(getLinkText('en'), LINK_TEXT.en);
|
|
243
|
+
assert.equal(getLinkText('nl'), LINK_TEXT.nl);
|
|
244
|
+
assert.equal(getLinkText('xx'), LINK_TEXT.en);
|
|
245
|
+
assert.equal(getLinkText(undefined), LINK_TEXT.en);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('the link points at the AI policy page, site-relative', () => {
|
|
249
|
+
// Site-relative so useBaseUrl() can prefix a deployment baseUrl.
|
|
250
|
+
assert.equal(LINK_HREF, '/ai');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('link text makes no compliance claim either', () => {
|
|
254
|
+
// Same denylist discipline as COPY: the pointer must not smuggle in a
|
|
255
|
+
// claim the copy is forbidden from making.
|
|
256
|
+
const denied = [/compliant/i, /compliance/i, /code of practice/i, /conform/i, /certified/i];
|
|
257
|
+
for (const locale of LOCALES) {
|
|
258
|
+
for (const pattern of denied) {
|
|
259
|
+
assert.ok(
|
|
260
|
+
!pattern.test(LINK_TEXT[locale]),
|
|
261
|
+
`LINK_TEXT["${locale}"] matches forbidden pattern ${pattern}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('the link stays site-relative on conduction.nl itself', () => {
|
|
268
|
+
assert.equal(resolveLinkHref('https://conduction.nl', '/en/ai'), '/en/ai');
|
|
269
|
+
assert.equal(resolveLinkHref('https://www.conduction.nl/', '/ai'), '/ai');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('off conduction.nl the link goes to the policy page there, not a local 404', () => {
|
|
273
|
+
assert.equal(POLICY_URL, 'https://conduction.nl/ai');
|
|
274
|
+
assert.equal(resolveLinkHref('https://dossiq.conduction.nl', '/ai'), POLICY_URL);
|
|
275
|
+
assert.equal(resolveLinkHref('https://docs.example.org', '/docs/ai'), POLICY_URL);
|
|
276
|
+
assert.equal(resolveLinkHref(undefined, '/ai'), POLICY_URL);
|
|
277
|
+
assert.equal(resolveLinkHref('not a url', '/ai'), POLICY_URL);
|
|
278
|
+
});
|
|
@@ -49,27 +49,78 @@ const COPY = {
|
|
|
49
49
|
nl: {
|
|
50
50
|
generated: 'Deze pagina is gegenereerd met AI.',
|
|
51
51
|
modified: 'Deze pagina is gedeeltelijk aangepast met AI.',
|
|
52
|
-
assisted: 'Deze pagina is geschreven met hulp van AI.',
|
|
52
|
+
assisted: 'Deze pagina is geschreven met hulp van AI, bijvoorbeeld voor spelling en onderzoek.',
|
|
53
53
|
},
|
|
54
54
|
en: {
|
|
55
55
|
generated: 'This page was generated with AI.',
|
|
56
56
|
modified: 'This page was partially modified with AI.',
|
|
57
|
-
assisted: 'This page was written
|
|
57
|
+
assisted: 'This page was written using AI assistance, for example for spelling and research.',
|
|
58
58
|
},
|
|
59
59
|
de: {
|
|
60
60
|
generated: 'Diese Seite wurde mit KI erstellt.',
|
|
61
61
|
modified: 'Diese Seite wurde teilweise mit KI bearbeitet.',
|
|
62
|
-
assisted:
|
|
62
|
+
assisted:
|
|
63
|
+
'Diese Seite wurde mit KI-Unterstützung geschrieben, zum Beispiel für Rechtschreibung und Recherche.',
|
|
63
64
|
},
|
|
64
65
|
fr: {
|
|
65
66
|
generated: "Cette page a été générée avec l'IA.",
|
|
66
67
|
modified: "Cette page a été partiellement modifiée avec l'IA.",
|
|
67
|
-
assisted:
|
|
68
|
+
assisted:
|
|
69
|
+
"Cette page a été rédigée avec l'aide de l'IA, par exemple pour l'orthographe et la recherche.",
|
|
68
70
|
},
|
|
69
71
|
};
|
|
70
72
|
|
|
71
73
|
const LOCALES = Object.keys(COPY);
|
|
72
74
|
|
|
75
|
+
// The "what does that mean" pointer. Kept OUT of the COPY table on
|
|
76
|
+
// purpose: COPY holds only statements of fact about the page (the
|
|
77
|
+
// denylist test asserts every value makes no compliance claim), while
|
|
78
|
+
// this is navigation. One link text per locale, identical across all
|
|
79
|
+
// three kinds - what the reader wants to know ("how does Conduction
|
|
80
|
+
// use AI?") does not change with the kind.
|
|
81
|
+
const LINK_TEXT = {
|
|
82
|
+
nl: 'Lees wat dat betekent en hoe Conduction AI gebruikt.',
|
|
83
|
+
en: 'Read what that means and how Conduction uses AI.',
|
|
84
|
+
de: 'Lesen Sie, was das bedeutet und wie Conduction KI einsetzt.',
|
|
85
|
+
fr: "Découvrez ce que cela signifie et comment Conduction utilise l'IA.",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Site-relative. The page exists at src/pages/ai.mdx ("How we use AI")
|
|
89
|
+
// on conduction.nl only. Every product docs site uses this preset too,
|
|
90
|
+
// and none of them carries an /ai page, so off conduction.nl the link
|
|
91
|
+
// goes to the policy page on conduction.nl instead of a 404.
|
|
92
|
+
const LINK_HREF = '/ai';
|
|
93
|
+
const POLICY_SITE_HOSTS = ['conduction.nl', 'www.conduction.nl'];
|
|
94
|
+
const POLICY_URL = 'https://conduction.nl/ai';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Where the "read more" link points on a given site.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} siteUrl the site's configured `url` (siteConfig.url)
|
|
100
|
+
* @param {string} localHref LINK_HREF already passed through useBaseUrl()
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
function resolveLinkHref(siteUrl, localHref) {
|
|
104
|
+
let host = '';
|
|
105
|
+
try {
|
|
106
|
+
host = new URL(siteUrl).hostname;
|
|
107
|
+
} catch (e) {
|
|
108
|
+
host = '';
|
|
109
|
+
}
|
|
110
|
+
return POLICY_SITE_HOSTS.indexOf(host) !== -1 ? localHref : POLICY_URL;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Localised "read more" text for the disclosure banner.
|
|
115
|
+
* Falls back to English for an unknown locale, same as getCopy().
|
|
116
|
+
*
|
|
117
|
+
* @param {string} locale
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
function getLinkText(locale) {
|
|
121
|
+
return LINK_TEXT[locale] || LINK_TEXT.en;
|
|
122
|
+
}
|
|
123
|
+
|
|
73
124
|
function isValidKind(value) {
|
|
74
125
|
return AI_KINDS.indexOf(value) !== -1;
|
|
75
126
|
}
|
|
@@ -159,6 +210,9 @@ module.exports = {
|
|
|
159
210
|
KIND_TO_FILE_PREFIX,
|
|
160
211
|
VIEWBOX,
|
|
161
212
|
COPY,
|
|
213
|
+
LINK_TEXT,
|
|
214
|
+
LINK_HREF,
|
|
215
|
+
POLICY_URL,
|
|
162
216
|
ICONS_BASE_PATH,
|
|
163
217
|
isValidKind,
|
|
164
218
|
resolveAiFrontmatter,
|
|
@@ -166,4 +220,6 @@ module.exports = {
|
|
|
166
220
|
getIconPath,
|
|
167
221
|
getViewBox,
|
|
168
222
|
getCopy,
|
|
223
|
+
getLinkText,
|
|
224
|
+
resolveLinkHref,
|
|
169
225
|
};
|