@conduction/docusaurus-preset 3.27.2 → 3.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/package.json +3 -2
- package/src/components/AiDisclosure/AiDisclosure.jsx +82 -0
- package/src/components/AiDisclosure/AiDisclosure.module.css +35 -0
- package/src/components/AiDisclosure/__tests__/AiDisclosure.render.test.js +127 -0
- package/src/components/AiDisclosure/__tests__/disclosure.test.js +218 -0
- package/src/components/AiDisclosure/disclosure.js +169 -0
- package/src/components/index.js +7 -0
- package/src/theme/BlogPostItem/Content/index.jsx +43 -0
- package/src/theme/DocItem/Content/index.jsx +15 -0
- package/static/img/ai-disclosure/PROVENANCE.md +87 -0
- package/static/img/ai-disclosure/ai-black-transparent.svg +20 -0
- package/static/img/ai-disclosure/ai-black.svg +19 -0
- package/static/img/ai-disclosure/ai-generated-black-transparent.svg +31 -0
- package/static/img/ai-disclosure/ai-generated-black.svg +30 -0
- package/static/img/ai-disclosure/ai-generated-white-transparent.svg +32 -0
- package/static/img/ai-disclosure/ai-generated-white.svg +31 -0
- package/static/img/ai-disclosure/ai-modified-black-transparent.svg +30 -0
- package/static/img/ai-disclosure/ai-modified-black.svg +29 -0
- package/static/img/ai-disclosure/ai-modified-white-transparent.svg +31 -0
- package/static/img/ai-disclosure/ai-modified-white.svg +30 -0
- package/static/img/ai-disclosure/ai-white-transparent.svg +21 -0
- package/static/img/ai-disclosure/ai-white.svg +20 -0
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ A few non-negotiables encoded by the package CSS and worth knowing about:
|
|
|
22
22
|
- **Brand-default footer** — three-column link grid + Conduction-tells (KvK, BTW, address). Per-property override: pass `footer: { links: [...] }` to swap columns and inherit the brand copyright unchanged. Spread `baseFooterLinks()` to keep one or two brand columns alongside site-specific ones.
|
|
23
23
|
- **Sensible defaults** — `trailingSlash`, `onBrokenLinks: 'warn'`, `respectPrefersColorScheme`, dark-mode brand mapping.
|
|
24
24
|
- **AI-crawler baseline** — Organization + WebSite JSON-LD on every page, `SoftwareApplication` JSON-LD from `<DetailHero>`, `FAQPage` JSON-LD from `<FAQ>`, default `og:image` + Twitter card meta, sitemap options, and a `postBuild` plugin that emits `robots.txt` when the site does not ship its own. See the AI baseline section below for the validator and content requirements.
|
|
25
|
+
- **AI content disclosure** — opt-in `ai:` frontmatter key (`generated` / `modified` / `assisted`) renders the official EU Article-50 mark + a factual, no-compliance-claim line at the top of a doc or blog page. See the AI content disclosure section below.
|
|
25
26
|
|
|
26
27
|
## Usage
|
|
27
28
|
|
|
@@ -274,6 +275,39 @@ Failure-tolerant: timeouts or 5xx responses log a warning and let the deploy con
|
|
|
274
275
|
|
|
275
276
|
Docusaurus defaults to `{Page} | {Site}`, which produces `OpenRegister | OpenRegister` on per-app homepages. Override per page via frontmatter `title:` for now; a `titleFormat` option may land in a future release.
|
|
276
277
|
|
|
278
|
+
## AI content disclosure (EU AI Act Article 50)
|
|
279
|
+
|
|
280
|
+
The EU AI Act's transparency tier (Article 50) obliges the discloser of AI-generated or AI-modified content to say so. On 2026-06-10 the European Commission published a set of icons for exactly this purpose, free to use without attribution. This preset vendors those icons and wires a strictly opt-in way to show them on a page.
|
|
281
|
+
|
|
282
|
+
**Important:** using these icons does **not** by itself establish legal compliance with Article 50, and the Commission asks that non-signatories not use them in a way that implies adherence to the Code of Practice on Transparency of AI-Generated Content. The copy this preset ships states a fact about the page ("This page was generated with AI.") and nothing more — it never asserts compliance or Code-of-Practice adherence. See `static/img/ai-disclosure/PROVENANCE.md` for the icon source, licence, and per-mark meaning.
|
|
283
|
+
|
|
284
|
+
**Frontmatter usage** — add an `ai:` key to a doc or blog post's frontmatter:
|
|
285
|
+
|
|
286
|
+
```md
|
|
287
|
+
---
|
|
288
|
+
title: How we generate release notes
|
|
289
|
+
ai: generated
|
|
290
|
+
---
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
| Value | EU mark | Meaning |
|
|
294
|
+
| --- | --- | --- |
|
|
295
|
+
| `generated` | Fully AI-Generated | Entirely AI-produced, no human-authored elements or editorial control (prompting excluded) |
|
|
296
|
+
| `modified` | Partially AI-Modified | Pre-existing human content partially altered with AI |
|
|
297
|
+
| `assisted` | Basic | AI assisted the work, or a custom/interactive label is used |
|
|
298
|
+
|
|
299
|
+
The banner renders at the top of the page, above the title. **Omitting `ai:` renders nothing** — there is no site-wide default, no directory inheritance, and no content inference; labelling a human-written page as AI-generated would publish a false authorship claim, which is the exact harm the disclosure regime exists to prevent. A misspelled or empty value (e.g. `ai: genrated`) emits a build-time warning naming the file, the bad value, and the three permitted values, and still renders no banner — it never falls back to a mark.
|
|
300
|
+
|
|
301
|
+
**Inline MDX usage** — the same component the frontmatter key triggers is also exported directly, for a mark that belongs somewhere other than the page top (for example, beside a single AI-generated figure within an otherwise human-written page):
|
|
302
|
+
|
|
303
|
+
```mdx
|
|
304
|
+
import { AiDisclosure } from '@conduction/docusaurus-preset/components';
|
|
305
|
+
|
|
306
|
+
<AiDisclosure kind="modified" />
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
**Theme-aware** — the mark automatically switches between the Commission's black and white treatments to match the reader's active colour mode, live, with no page reload. **Localised** — copy is available in all four of the preset's locales (nl default, en, de, fr) and renders in the page's active locale.
|
|
310
|
+
|
|
277
311
|
## Releasing
|
|
278
312
|
|
|
279
313
|
Releases auto-publish on push to `main`, driven by [semantic-release](https://semantic-release.gitbook.io/) reading [conventional-commit](https://www.conventionalcommits.org/) messages. The [.github/workflows/publish-packages.yml](../.github/workflows/publish-packages.yml) workflow walks every commit since the last `@conduction/docusaurus-preset-v*` tag and decides what to ship:
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conduction/docusaurus-preset",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.28.0",
|
|
4
4
|
"scripts": {
|
|
5
|
-
"prepack": "node scripts/prepack-bundle-css.js"
|
|
5
|
+
"prepack": "node scripts/prepack-bundle-css.js",
|
|
6
|
+
"test": "node --test"
|
|
6
7
|
},
|
|
7
8
|
"bin": {
|
|
8
9
|
"validate-ai-baseline": "./bin/validate-ai-baseline.mjs"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <AiDisclosure />
|
|
3
|
+
*
|
|
4
|
+
* Renders one of the three official EU Article-50 marks (Basic,
|
|
5
|
+
* Partially AI-Modified, Fully AI-Generated) plus a short factual
|
|
6
|
+
* line of copy. This is the single rendering path for the feature:
|
|
7
|
+
* the `ai` frontmatter key (see the DocItem/Content and
|
|
8
|
+
* BlogPostItem/Content theme swizzles) resolves to the same component
|
|
9
|
+
* an author can drop inline in MDX, so the page-top banner and an
|
|
10
|
+
* inline mark can never drift apart (design.md D1).
|
|
11
|
+
*
|
|
12
|
+
* The copy states what happened to the page and stops there - it
|
|
13
|
+
* does not, and must not, claim EU AI Act compliance or Code-of-
|
|
14
|
+
* Practice adherence (see disclosure.js COPY and
|
|
15
|
+
* static/img/ai-disclosure/PROVENANCE.md). Do not hand-edit strings
|
|
16
|
+
* here; edit the COPY table in ./disclosure.js so the denylist test
|
|
17
|
+
* covers every value.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
*
|
|
21
|
+
* import { AiDisclosure } from '@conduction/docusaurus-preset/components';
|
|
22
|
+
*
|
|
23
|
+
* <AiDisclosure kind="modified" />
|
|
24
|
+
*
|
|
25
|
+
* Props:
|
|
26
|
+
* - kind: 'generated' | 'modified' | 'assisted' (required)
|
|
27
|
+
* - className: string
|
|
28
|
+
*
|
|
29
|
+
* An unrecognised `kind` warns via console.warn and renders nothing -
|
|
30
|
+
* it never falls back to a mark (spec: "An unrecognised value fails
|
|
31
|
+
* loudly and renders nothing").
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import React from 'react';
|
|
35
|
+
import {useColorMode} from '@docusaurus/theme-common';
|
|
36
|
+
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
|
37
|
+
import useBaseUrl from '@docusaurus/useBaseUrl';
|
|
38
|
+
import styles from './AiDisclosure.module.css';
|
|
39
|
+
import {isValidKind, getCopy, getViewBox, getIconPath, AI_KINDS} from './disclosure';
|
|
40
|
+
|
|
41
|
+
export default function AiDisclosure({kind, className}) {
|
|
42
|
+
const {colorMode} = useColorMode();
|
|
43
|
+
const {i18n} = useDocusaurusContext();
|
|
44
|
+
const locale = (i18n && i18n.currentLocale) || 'en';
|
|
45
|
+
|
|
46
|
+
const isKindValid = isValidKind(kind);
|
|
47
|
+
// Dark-ink mark on light surfaces, light-ink mark on dark surfaces -
|
|
48
|
+
// the opaque treatments (not the transparent ones) so the mark
|
|
49
|
+
// reads correctly regardless of what sits behind the banner panel.
|
|
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));
|
|
57
|
+
|
|
58
|
+
if (!isKindValid) {
|
|
59
|
+
if (typeof console !== 'undefined') {
|
|
60
|
+
console.warn(
|
|
61
|
+
`[ai-content-disclosure] <AiDisclosure> received an unknown kind ${JSON.stringify(kind)}; ` +
|
|
62
|
+
`expected one of ${AI_KINDS.join(', ')}. Rendering nothing.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const {width, height} = getViewBox(kind);
|
|
69
|
+
const copy = getCopy(kind, locale);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className={[styles.disclosure, className].filter(Boolean).join(' ')} role="note">
|
|
73
|
+
<img
|
|
74
|
+
src={iconPath}
|
|
75
|
+
alt=""
|
|
76
|
+
className={styles.mark}
|
|
77
|
+
style={{aspectRatio: `${width} / ${height}`}}
|
|
78
|
+
/>
|
|
79
|
+
<p className={styles.copy}>{copy}</p>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <AiDisclosure /> styles. A neutral, low-emphasis notice - this is a
|
|
3
|
+
* factual disclosure, not a call to action, so it carries no KNVB
|
|
4
|
+
* orange (brand rule: orange is reserved for highlights/CTAs).
|
|
5
|
+
*
|
|
6
|
+
* Mark width is capped and height is left to `auto` so the inline
|
|
7
|
+
* `aspect-ratio` set per-kind in AiDisclosure.jsx (Basic 1:1, the two
|
|
8
|
+
* word-marks ~3:1) is what actually governs the rendered proportions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
.disclosure {
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
gap: var(--space-4);
|
|
15
|
+
padding: var(--space-3) var(--space-5);
|
|
16
|
+
margin: 0 0 var(--space-6);
|
|
17
|
+
background: var(--c-cobalt-50);
|
|
18
|
+
border: 1px solid var(--c-cobalt-100);
|
|
19
|
+
border-radius: var(--radius-md);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.mark {
|
|
23
|
+
display: block;
|
|
24
|
+
width: 96px;
|
|
25
|
+
max-width: 30%;
|
|
26
|
+
height: auto;
|
|
27
|
+
flex-shrink: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.copy {
|
|
31
|
+
margin: 0;
|
|
32
|
+
font-size: 14px;
|
|
33
|
+
line-height: 1.5;
|
|
34
|
+
color: var(--c-cobalt-700);
|
|
35
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AiDisclosure.render.test.js — renders the real <AiDisclosure> JSX
|
|
3
|
+
* to static markup and asserts on the output, so a broken import path
|
|
4
|
+
* or a JSX mistake in AiDisclosure.jsx fails a test instead of only
|
|
5
|
+
* surfacing the first time a consuming site runs `docusaurus build`.
|
|
6
|
+
*
|
|
7
|
+
* Uses the same esbuild-bundle-then-renderToStaticMarkup technique as
|
|
8
|
+
* ../../../../scripts/build-kit.mjs (Rule 1: copy an existing pattern
|
|
9
|
+
* rather than inventing new test tooling). Docusaurus-only modules
|
|
10
|
+
* (`@docusaurus/theme-common`, `@docusaurus/useDocusaurusContext`,
|
|
11
|
+
* `@docusaurus/useBaseUrl`) are stubbed via esbuild plugins since this
|
|
12
|
+
* test runs outside an actual Docusaurus build; the useBaseUrl stub
|
|
13
|
+
* prefixes a fake `/base/` the same way the real hook prefixes a
|
|
14
|
+
* site's configured baseUrl, so the assertion is "the resolved icon
|
|
15
|
+
* path for this kind/treatment was passed through base-url
|
|
16
|
+
* resolution", not a snapshot of a real deployed URL.
|
|
17
|
+
*
|
|
18
|
+
* @spec openspec/changes/ai-content-disclosure/tasks.md#task-5.2
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const test = require('node:test');
|
|
24
|
+
const {after} = test;
|
|
25
|
+
const assert = require('node:assert/strict');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const fs = require('node:fs/promises');
|
|
28
|
+
const {build} = require('esbuild');
|
|
29
|
+
const React = require('react');
|
|
30
|
+
const {renderToStaticMarkup} = require('react-dom/server');
|
|
31
|
+
|
|
32
|
+
const COMPONENT = path.resolve(__dirname, '..', 'AiDisclosure.jsx');
|
|
33
|
+
|
|
34
|
+
const cssModuleStub = {
|
|
35
|
+
name: 'css-module-stub',
|
|
36
|
+
setup(b) {
|
|
37
|
+
b.onResolve({filter: /\.module\.css$/}, (args) => ({path: args.path, namespace: 'css-stub'}));
|
|
38
|
+
b.onLoad({filter: /.*/, namespace: 'css-stub'}, () => ({
|
|
39
|
+
contents: 'export default new Proxy({}, {get: (_, p) => p});',
|
|
40
|
+
loader: 'js',
|
|
41
|
+
}));
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function docusaurusStub({colorMode, locale}) {
|
|
46
|
+
return {
|
|
47
|
+
name: 'docusaurus-stub',
|
|
48
|
+
setup(b) {
|
|
49
|
+
b.onResolve({filter: /^@docusaurus\/theme-common$/}, () => ({path: 'docusaurus-theme-common', namespace: 'docusaurus-stub'}));
|
|
50
|
+
b.onResolve({filter: /^@docusaurus\/useDocusaurusContext$/}, () => ({path: 'docusaurus-context', namespace: 'docusaurus-stub'}));
|
|
51
|
+
b.onResolve({filter: /^@docusaurus\/useBaseUrl$/}, () => ({path: 'docusaurus-base-url', namespace: 'docusaurus-stub'}));
|
|
52
|
+
b.onLoad({filter: /^docusaurus-theme-common$/, namespace: 'docusaurus-stub'}, () => ({
|
|
53
|
+
contents: `export function useColorMode() { return {colorMode: ${JSON.stringify(colorMode)}}; }`,
|
|
54
|
+
loader: 'js',
|
|
55
|
+
}));
|
|
56
|
+
b.onLoad({filter: /^docusaurus-context$/, namespace: 'docusaurus-stub'}, () => ({
|
|
57
|
+
contents: `export default function useDocusaurusContext() { return {i18n: {currentLocale: ${JSON.stringify(locale)}}}; }`,
|
|
58
|
+
loader: 'js',
|
|
59
|
+
}));
|
|
60
|
+
// Mirrors the real hook's job: prefix the site's baseUrl onto a
|
|
61
|
+
// base-relative static path.
|
|
62
|
+
b.onLoad({filter: /^docusaurus-base-url$/, namespace: 'docusaurus-stub'}, () => ({
|
|
63
|
+
contents: `export default function useBaseUrl(path) { return "/base/" + path; }`,
|
|
64
|
+
loader: 'js',
|
|
65
|
+
}));
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Bundle output must live under a directory that can `require('react')`
|
|
71
|
+
// via normal node_modules walk-up (react is `external`, not bundled) -
|
|
72
|
+
// os.tmpdir() has no node_modules ancestor, so we use a scratch dir
|
|
73
|
+
// inside the package instead, mirroring build-kit.mjs's `.tmp-build-kit`.
|
|
74
|
+
const PRESET_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
|
|
75
|
+
|
|
76
|
+
async function renderAiDisclosure(props, {colorMode = 'light', locale = 'en'} = {}) {
|
|
77
|
+
const scratchRoot = path.join(PRESET_ROOT, '.tmp-ai-disclosure-test');
|
|
78
|
+
await fs.mkdir(scratchRoot, {recursive: true});
|
|
79
|
+
const tmpDir = await fs.mkdtemp(path.join(scratchRoot, 'run-'));
|
|
80
|
+
const outFile = path.join(tmpDir, 'bundle.cjs');
|
|
81
|
+
try {
|
|
82
|
+
await build({
|
|
83
|
+
entryPoints: [COMPONENT],
|
|
84
|
+
outfile: outFile,
|
|
85
|
+
bundle: true,
|
|
86
|
+
format: 'cjs',
|
|
87
|
+
jsx: 'automatic',
|
|
88
|
+
jsxImportSource: 'react',
|
|
89
|
+
tsconfigRaw: {compilerOptions: {jsx: 'react-jsx', jsxImportSource: 'react'}},
|
|
90
|
+
platform: 'node',
|
|
91
|
+
external: ['react'],
|
|
92
|
+
plugins: [cssModuleStub, docusaurusStub({colorMode, locale})],
|
|
93
|
+
logLevel: 'warning',
|
|
94
|
+
});
|
|
95
|
+
delete require.cache[require.resolve(outFile)];
|
|
96
|
+
const mod = require(outFile);
|
|
97
|
+
const Component = mod.default;
|
|
98
|
+
return renderToStaticMarkup(React.createElement(Component, props));
|
|
99
|
+
} finally {
|
|
100
|
+
await fs.rm(tmpDir, {recursive: true, force: true});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
test('renders the generated mark + English copy in light mode', async () => {
|
|
105
|
+
const html = await renderAiDisclosure({kind: 'generated'}, {colorMode: 'light', locale: 'en'});
|
|
106
|
+
assert.match(html, /\/base\/img\/ai-disclosure\/ai-generated-black\.svg/);
|
|
107
|
+
assert.match(html, /This page was generated with AI\./);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('renders the white (light-ink-on-dark) treatment in dark mode', async () => {
|
|
111
|
+
const html = await renderAiDisclosure({kind: 'modified'}, {colorMode: 'dark', locale: 'en'});
|
|
112
|
+
assert.match(html, /\/base\/img\/ai-disclosure\/ai-modified-white\.svg/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('renders Dutch copy when the active locale is nl', async () => {
|
|
116
|
+
const html = await renderAiDisclosure({kind: 'assisted'}, {colorMode: 'light', locale: 'nl'});
|
|
117
|
+
assert.match(html, /Deze pagina is geschreven met hulp van AI\./);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('renders nothing for an unrecognised kind', async () => {
|
|
121
|
+
const html = await renderAiDisclosure({kind: 'basic'}, {colorMode: 'light', locale: 'en'});
|
|
122
|
+
assert.equal(html, '');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
after(async () => {
|
|
126
|
+
await fs.rm(path.join(PRESET_ROOT, '.tmp-ai-disclosure-test'), {recursive: true, force: true});
|
|
127
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* disclosure.test.js — unit tests for the ai-content-disclosure
|
|
3
|
+
* resolver, asset mapping, locale coverage, and no-compliance-claim
|
|
4
|
+
* copy denylist.
|
|
5
|
+
*
|
|
6
|
+
* Runs on Node's built-in test runner (no bundler, no React needed -
|
|
7
|
+
* see disclosure.js's header for why the pure logic lives apart from
|
|
8
|
+
* the JSX component). Run with `npm test` from docusaurus-preset/, or
|
|
9
|
+
* `node --test src` from the same directory.
|
|
10
|
+
*
|
|
11
|
+
* @spec openspec/changes/ai-content-disclosure/tasks.md#task-5.1
|
|
12
|
+
* @spec openspec/changes/ai-content-disclosure/tasks.md#task-5.2
|
|
13
|
+
* @spec openspec/changes/ai-content-disclosure/tasks.md#task-5.3
|
|
14
|
+
* @spec openspec/changes/ai-content-disclosure/tasks.md#task-5.4
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const test = require('node:test');
|
|
20
|
+
const assert = require('node:assert/strict');
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
AI_KINDS,
|
|
26
|
+
LOCALES,
|
|
27
|
+
VIEWBOX,
|
|
28
|
+
ICONS_BASE_PATH,
|
|
29
|
+
isValidKind,
|
|
30
|
+
resolveAiFrontmatter,
|
|
31
|
+
getIconFilename,
|
|
32
|
+
getIconPath,
|
|
33
|
+
getViewBox,
|
|
34
|
+
getCopy,
|
|
35
|
+
} = require('../disclosure');
|
|
36
|
+
|
|
37
|
+
// Icons are vendored as plain static files (not a webpack `.svg`
|
|
38
|
+
// import - see disclosure.js's ICONS_BASE_PATH comment) so the tests
|
|
39
|
+
// resolve them against static/img/ai-disclosure/ directly.
|
|
40
|
+
const ICONS_DIR = path.resolve(__dirname, '..', '..', '..', '..', 'static', 'img', 'ai-disclosure');
|
|
41
|
+
|
|
42
|
+
// -- 5.1: frontmatter resolver -- present / absent / misspelled / empty --
|
|
43
|
+
|
|
44
|
+
test('resolver: present and valid resolves to the kind, no warning', () => {
|
|
45
|
+
for (const kind of AI_KINDS) {
|
|
46
|
+
const {kind: resolved, warning} = resolveAiFrontmatter(kind, 'docs/foo.md');
|
|
47
|
+
assert.equal(resolved, kind);
|
|
48
|
+
assert.equal(warning, null);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('resolver: absent key resolves to no kind and no warning', () => {
|
|
53
|
+
const {kind, warning} = resolveAiFrontmatter(undefined, 'docs/foo.md');
|
|
54
|
+
assert.equal(kind, null);
|
|
55
|
+
assert.equal(warning, null);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('resolver: misspelled value resolves to no kind and warns, naming file/value/permitted', () => {
|
|
59
|
+
const {kind, warning} = resolveAiFrontmatter('genrated', 'docs/foo.md');
|
|
60
|
+
assert.equal(kind, null);
|
|
61
|
+
assert.match(warning, /docs\/foo\.md/);
|
|
62
|
+
assert.match(warning, /genrated/);
|
|
63
|
+
for (const permitted of AI_KINDS) {
|
|
64
|
+
assert.match(warning, new RegExp(permitted));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('resolver: empty value resolves to no kind and warns', () => {
|
|
69
|
+
const {kind, warning} = resolveAiFrontmatter('', 'docs/foo.md');
|
|
70
|
+
assert.equal(kind, null);
|
|
71
|
+
assert.ok(warning);
|
|
72
|
+
assert.match(warning, /docs\/foo\.md/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('resolver: never falls back to a mark for an unrecognised value', () => {
|
|
76
|
+
const {kind} = resolveAiFrontmatter('fully-generated', 'docs/foo.md');
|
|
77
|
+
assert.notEqual(kind, 'generated');
|
|
78
|
+
assert.equal(kind, null);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// -- 5.2: kind -> vendored-asset mapping + preserved aspect ratio --
|
|
82
|
+
|
|
83
|
+
test('mapping: generated maps to an ai-generated-* asset', () => {
|
|
84
|
+
assert.equal(getIconFilename('generated', 'black'), 'ai-generated-black.svg');
|
|
85
|
+
assert.equal(getIconFilename('generated', 'white'), 'ai-generated-white.svg');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('mapping: modified maps to an ai-modified-* asset', () => {
|
|
89
|
+
assert.equal(getIconFilename('modified', 'black'), 'ai-modified-black.svg');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('mapping: assisted maps to the basic mark (neither ai-generated-* nor ai-modified-*)', () => {
|
|
93
|
+
const filename = getIconFilename('assisted', 'black');
|
|
94
|
+
assert.equal(filename, 'ai-black.svg');
|
|
95
|
+
assert.doesNotMatch(filename, /^ai-generated-/);
|
|
96
|
+
assert.doesNotMatch(filename, /^ai-modified-/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('mapping: every kind x treatment resolves to a file that actually exists on disk', () => {
|
|
100
|
+
const treatments = ['black', 'white', 'black-transparent', 'white-transparent'];
|
|
101
|
+
for (const kind of AI_KINDS) {
|
|
102
|
+
for (const treatment of treatments) {
|
|
103
|
+
const filename = getIconFilename(kind, treatment);
|
|
104
|
+
const filePath = path.join(ICONS_DIR, filename);
|
|
105
|
+
assert.ok(fs.existsSync(filePath), `expected vendored asset at ${filePath}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('mapping: getIconPath is base-relative (no leading slash) so useBaseUrl can prefix it', () => {
|
|
111
|
+
const p = getIconPath('generated', 'black');
|
|
112
|
+
assert.equal(p, `${ICONS_BASE_PATH}/ai-generated-black.svg`);
|
|
113
|
+
assert.ok(!p.startsWith('/'));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('aspect ratio: getViewBox matches the vendored SVG viewBox within 1% tolerance', () => {
|
|
117
|
+
for (const kind of AI_KINDS) {
|
|
118
|
+
const filename = getIconFilename(kind, 'black');
|
|
119
|
+
const svg = fs.readFileSync(path.join(ICONS_DIR, filename), 'utf8');
|
|
120
|
+
const match = svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
|
|
121
|
+
assert.ok(match, `${filename} has no parseable viewBox`);
|
|
122
|
+
const sourceRatio = Number(match[1]) / Number(match[2]);
|
|
123
|
+
const {width, height} = getViewBox(kind);
|
|
124
|
+
const declaredRatio = width / height;
|
|
125
|
+
const tolerance = sourceRatio * 0.01;
|
|
126
|
+
assert.ok(
|
|
127
|
+
Math.abs(sourceRatio - declaredRatio) <= tolerance,
|
|
128
|
+
`${kind}: source ratio ${sourceRatio} vs declared ${declaredRatio} (VIEWBOX=${JSON.stringify(VIEWBOX[kind])})`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('aspect ratio: the three marks are not forced into a uniform box', () => {
|
|
134
|
+
const ratios = AI_KINDS.map((kind) => {
|
|
135
|
+
const {width, height} = getViewBox(kind);
|
|
136
|
+
return width / height;
|
|
137
|
+
});
|
|
138
|
+
assert.ok(new Set(ratios.map((r) => r.toFixed(3))).size > 1, 'expected differing ratios across marks');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// -- 5.3: locale coverage --
|
|
142
|
+
|
|
143
|
+
test('locale coverage: nl, en, de, fr all present, nl is the default', () => {
|
|
144
|
+
assert.deepEqual(new Set(LOCALES), new Set(['nl', 'en', 'de', 'fr']));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('locale coverage: every locale has distinct, non-empty copy for all three kinds', () => {
|
|
148
|
+
for (const locale of LOCALES) {
|
|
149
|
+
const strings = AI_KINDS.map((kind) => getCopy(kind, locale));
|
|
150
|
+
for (const [i, s] of strings.entries()) {
|
|
151
|
+
assert.ok(typeof s === 'string' && s.trim().length > 0, `${locale}/${AI_KINDS[i]} missing or empty`);
|
|
152
|
+
}
|
|
153
|
+
assert.equal(new Set(strings).size, strings.length, `${locale}: two kinds share one string`);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('locale coverage: no locale silently falls back to another locale\'s string', () => {
|
|
158
|
+
const seenAcrossLocales = new Map();
|
|
159
|
+
for (const locale of LOCALES) {
|
|
160
|
+
for (const kind of AI_KINDS) {
|
|
161
|
+
const s = getCopy(kind, locale);
|
|
162
|
+
const key = `${kind}:${s}`;
|
|
163
|
+
if (locale !== 'nl') {
|
|
164
|
+
// Same (kind, string) pair appearing under two different
|
|
165
|
+
// locales would mean one locale leaked another's copy.
|
|
166
|
+
assert.ok(!seenAcrossLocales.has(key) || seenAcrossLocales.get(key) === locale, `duplicate string across locales for ${key}`);
|
|
167
|
+
}
|
|
168
|
+
seenAcrossLocales.set(key, locale);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// -- 5.4: no compliance / Code-of-Practice claim, in any locale --
|
|
174
|
+
|
|
175
|
+
const COMPLIANCE_DENYLIST = [
|
|
176
|
+
/compliant/i,
|
|
177
|
+
/compliance/i,
|
|
178
|
+
/conform/i,
|
|
179
|
+
/voldoet aan/i,
|
|
180
|
+
/naleving/i,
|
|
181
|
+
/in accordance with the ai act/i,
|
|
182
|
+
/in Übereinstimmung mit/i,
|
|
183
|
+
/conforme (à|au|aux)/i,
|
|
184
|
+
/code of practice/i,
|
|
185
|
+
/gedragscode/i,
|
|
186
|
+
/code de bonne pratique/i,
|
|
187
|
+
/verhaltenskodex/i,
|
|
188
|
+
/signatory/i,
|
|
189
|
+
/signataire/i,
|
|
190
|
+
/unterzeichner/i,
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
test('copy: no locale/kind combination contains a compliance or Code-of-Practice claim', () => {
|
|
194
|
+
const offenders = [];
|
|
195
|
+
for (const locale of LOCALES) {
|
|
196
|
+
for (const kind of AI_KINDS) {
|
|
197
|
+
const text = getCopy(kind, locale);
|
|
198
|
+
for (const pattern of COMPLIANCE_DENYLIST) {
|
|
199
|
+
if (pattern.test(text)) {
|
|
200
|
+
offenders.push(`${locale}/${kind}: "${text}" matched ${pattern}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
assert.deepEqual(offenders, []);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// -- isValidKind guard used by the component for both frontmatter and inline props --
|
|
209
|
+
|
|
210
|
+
test('isValidKind rejects anything outside the three permitted values', () => {
|
|
211
|
+
assert.equal(isValidKind('generated'), true);
|
|
212
|
+
assert.equal(isValidKind('modified'), true);
|
|
213
|
+
assert.equal(isValidKind('assisted'), true);
|
|
214
|
+
assert.equal(isValidKind('basic'), false);
|
|
215
|
+
assert.equal(isValidKind(''), false);
|
|
216
|
+
assert.equal(isValidKind(undefined), false);
|
|
217
|
+
assert.equal(isValidKind(null), false);
|
|
218
|
+
});
|