From 3f2309bdf25a3d15119e0cc63cbd3290f945e48b Mon Sep 17 00:00:00 2001 From: timhilhorst Date: Tue, 1 Sep 2026 15:14:03 +0200 Subject: [PATCH 1/7] chore: setup of clippy-a11y-validator --- packages/clippy-a11y-validator/package.json | 43 +++++++++ .../scripts/validate-html.ts | 88 +++++++++++++++++++ .../clippy-a11y-validator/src/constants.ts | 5 ++ packages/clippy-a11y-validator/src/helpers.ts | 9 ++ .../nlds-components/paragraph/constants.ts | 4 + .../src/nlds-components/paragraph/index.ts | 5 ++ .../src/nlds-components/paragraph/rules.ts | 14 +++ .../should-not-be-entirely-bold/corrector.ts | 9 ++ .../should-not-be-entirely-bold/index.ts | 16 ++++ .../should-not-be-entirely-bold/rules.ts | 7 ++ packages/clippy-a11y-validator/src/types.ts | 13 +++ .../clippy-a11y-validator/src/validate.ts | 16 ++++ packages/clippy-a11y-validator/tsconfig.json | 19 ++++ packages/clippy-a11y-validator/vite.config.ts | 14 +++ pnpm-lock.yaml | 21 +++++ 15 files changed, 283 insertions(+) create mode 100644 packages/clippy-a11y-validator/package.json create mode 100755 packages/clippy-a11y-validator/scripts/validate-html.ts create mode 100644 packages/clippy-a11y-validator/src/constants.ts create mode 100644 packages/clippy-a11y-validator/src/helpers.ts create mode 100644 packages/clippy-a11y-validator/src/nlds-components/paragraph/constants.ts create mode 100644 packages/clippy-a11y-validator/src/nlds-components/paragraph/index.ts create mode 100644 packages/clippy-a11y-validator/src/nlds-components/paragraph/rules.ts create mode 100644 packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/corrector.ts create mode 100644 packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.ts create mode 100644 packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/rules.ts create mode 100644 packages/clippy-a11y-validator/src/types.ts create mode 100644 packages/clippy-a11y-validator/src/validate.ts create mode 100644 packages/clippy-a11y-validator/tsconfig.json create mode 100644 packages/clippy-a11y-validator/vite.config.ts diff --git a/packages/clippy-a11y-validator/package.json b/packages/clippy-a11y-validator/package.json new file mode 100644 index 00000000..e57fc4bc --- /dev/null +++ b/packages/clippy-a11y-validator/package.json @@ -0,0 +1,43 @@ +{ + "name": "@nl-design-system-community/clippy-a11y-validator", + "repository": { + "type": "git", + "url": "https://github.com/nl-design-system/editor.git", + "directory": "packages/clippy-a11y-validator" + }, + "keywords": [ + "expertteam-digitale-toegankelijkheid", + "nl-design-system", + "wcag", + "validator" + ], + "publishConfig": { + "access": "public", + "provenance": true + }, + "version": "0.1.0", + "description": "Accessibility validator provided by NL Design System", + "type": "module", + "exports": { + ".": { + "types": "./dist/validate.d.ts", + "default": "./dist/validate.js" + } + }, + "files": [ + "dist" + ], + "types": "dist/validate.d.ts", + "scripts": { + "build": "vite build", + "validate": "node scripts/validate-html.ts" + }, + "devDependencies": { + "@nl-design-system/tsconfig": "1.0.5", + "@types/node": "22.20.1", + "playwright": "1.62.1", + "typescript": "6.0.3", + "vite": "8.2.1", + "vite-plugin-dts": "5.0.3" + } +} diff --git a/packages/clippy-a11y-validator/scripts/validate-html.ts b/packages/clippy-a11y-validator/scripts/validate-html.ts new file mode 100755 index 00000000..e9c129b0 --- /dev/null +++ b/packages/clippy-a11y-validator/scripts/validate-html.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { chromium } from 'playwright'; + +/** Matches `server.port` in the editor-website's astro.config.mjs. */ +const ORIGIN = 'http://localhost:5174'; + +/** The ES module built by `pnpm build`. */ +const BUNDLE = fileURLToPath(new URL('../dist/validate.js', import.meta.url)); + +function help(): string { + return ` +Usage: validate-html [path] [options] + +Validates a page of the running editor-website. Start it first with +\`pnpm dev\` in packages/editor-website. + +Arguments: + path Path to validate, e.g. /preview (default) or /en/guidelines + +Options: + --fix Apply the available corrections and show the result + --help, -h Show this help + `.trim(); +} + +const { positionals, values } = parseArgs({ + allowPositionals: true, + options: { + fix: { default: false, type: 'boolean' }, + help: { default: false, short: 'h', type: 'boolean' }, + }, +}); + +if (values['help']) { + process.stdout.write(help() + '\n'); + process.exit(0); +} + +const url = new URL(positionals[0] ?? '/preview', ORIGIN).href; + +if (!existsSync(BUNDLE)) throw new Error(`${BUNDLE} is missing — run \`pnpm build\` first.`); + +const browser = await chromium.launch(); + +try { + const page = await browser.newPage(); + + try { + await page.goto(url, { waitUntil: 'networkidle' }); + } catch (error) { + throw new Error(`Could not load ${url} — start the site with \`pnpm dev\` in packages/editor-website.`, { + cause: error, + }); + } + + // The validator only speaks DOM, so it runs in the page rather than in Node. + const findings = await page.evaluate( + async ({ fix, source }) => { + // Import the bundle as a module, so it needs no global to hand its exports back. + const moduleUrl = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); + const { validate } = (await import(moduleUrl)) as typeof import('../src/validate.ts'); + URL.revokeObjectURL(moduleUrl); + + return validate(document.body).map(({ correct, element, severity, validatorKey }) => { + const before = element.outerHTML; + if (fix) correct?.(); + + return { after: fix ? element.outerHTML : undefined, before, severity, validatorKey }; + }); + }, + { fix: values['fix'], source: readFileSync(BUNDLE, 'utf8') }, + ); + + console.log(`${url}\n`); + + for (const { after, before, severity, validatorKey } of findings) { + console.log(`${severity}: ${validatorKey}\n ${before}`); + if (after !== undefined) console.log(` → ${after}`); + } + + console.log(`\n${findings.length} issue(s) found.`); + process.exitCode = findings.length > 0 && !values['fix'] ? 1 : 0; +} finally { + await browser.close(); +} diff --git a/packages/clippy-a11y-validator/src/constants.ts b/packages/clippy-a11y-validator/src/constants.ts new file mode 100644 index 00000000..d7b0bb7c --- /dev/null +++ b/packages/clippy-a11y-validator/src/constants.ts @@ -0,0 +1,5 @@ +export const validationSeverity = { + ERROR: 'error', + INFO: 'info', + WARNING: 'warning', +} as const; diff --git a/packages/clippy-a11y-validator/src/helpers.ts b/packages/clippy-a11y-validator/src/helpers.ts new file mode 100644 index 00000000..8546e19a --- /dev/null +++ b/packages/clippy-a11y-validator/src/helpers.ts @@ -0,0 +1,9 @@ +const EMPTY_STR_REGEX = /^\s*$/; +export const isEmptyOrWhitespace = (text: string): boolean => EMPTY_STR_REGEX.test(text); + +export const unwrapElement = (element: Element): void => { + const parent = element.parentNode; + if (!parent) return; + while (element.firstChild) parent.insertBefore(element.firstChild, element); + parent.removeChild(element); +}; diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/constants.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/constants.ts new file mode 100644 index 00000000..15201d70 --- /dev/null +++ b/packages/clippy-a11y-validator/src/nlds-components/paragraph/constants.ts @@ -0,0 +1,4 @@ +// https://nldesignsystem.nl/paragraph +export const paragraphValidations = { + PARAGRAPH_SHOULD_NOT_BE_ENTIRELY_BOLD: 'PARAGRAPH_SHOULD_NOT_BE_ENTIRELY_BOLD', +} as const; diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/index.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/index.ts new file mode 100644 index 00000000..64e3f49e --- /dev/null +++ b/packages/clippy-a11y-validator/src/nlds-components/paragraph/index.ts @@ -0,0 +1,5 @@ +import { paragraphShouldNotBeEntirelyBold } from './should-not-be-entirely-bold'; + +export { paragraphShouldNotBeEntirelyBold }; + +export const paragraphValidators = [paragraphShouldNotBeEntirelyBold]; diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/rules.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/rules.ts new file mode 100644 index 00000000..eb19c7c0 --- /dev/null +++ b/packages/clippy-a11y-validator/src/nlds-components/paragraph/rules.ts @@ -0,0 +1,14 @@ +const BOLD_TAGS = new Set(['B', 'STRONG']); + +export const BOLD_SELECTOR = 'strong, b'; + +const visibleChildren = (element: Element): ChildNode[] => + Array.from(element.childNodes).filter( + (node) => node.nodeType !== Node.TEXT_NODE || (node.textContent?.trim().length ?? 0) > 0, + ); + +export const isEntirelyBold = (element: Element): boolean => { + const children = visibleChildren(element); + if (children.length === 0) return false; + return children.every((node) => node instanceof Element && BOLD_TAGS.has(node.tagName)); +}; diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/corrector.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/corrector.ts new file mode 100644 index 00000000..6cf8b5c7 --- /dev/null +++ b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/corrector.ts @@ -0,0 +1,9 @@ +import type { CorrectValidationFunction } from '../../../types.ts'; +import { unwrapElement } from '../../../helpers.ts'; +import { BOLD_SELECTOR } from '../rules.ts'; + +export const correctEntirelyBoldParagraph = + (paragraph: HTMLParagraphElement): CorrectValidationFunction => + () => { + paragraph.querySelectorAll(BOLD_SELECTOR).forEach(unwrapElement); + }; diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.ts new file mode 100644 index 00000000..77365067 --- /dev/null +++ b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.ts @@ -0,0 +1,16 @@ +import type { ValidationResult } from '../../../types.ts'; +import { validationSeverity } from '../../../constants.ts'; +import { paragraphValidations } from '../constants.ts'; +import { correctEntirelyBoldParagraph } from './corrector.ts'; +import { isEntirelyBoldParagraph } from './rules.ts'; + +export const paragraphShouldNotBeEntirelyBold = (element: HTMLParagraphElement): ValidationResult | null => { + if (!isEntirelyBoldParagraph(element)) return null; + return { + correct: correctEntirelyBoldParagraph(element), + element, + scope: 'block', + severity: validationSeverity.WARNING, + validatorKey: paragraphValidations.PARAGRAPH_SHOULD_NOT_BE_ENTIRELY_BOLD, + }; +}; diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/rules.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/rules.ts new file mode 100644 index 00000000..b3fe3744 --- /dev/null +++ b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/rules.ts @@ -0,0 +1,7 @@ +import { isEmptyOrWhitespace } from '../../../helpers.ts'; +import { isEntirelyBold } from '../rules.ts'; + +export const isEntirelyBoldParagraph = (paragraph: HTMLParagraphElement): boolean => { + if (isEmptyOrWhitespace(paragraph.textContent ?? '')) return false; + return isEntirelyBold(paragraph); +}; diff --git a/packages/clippy-a11y-validator/src/types.ts b/packages/clippy-a11y-validator/src/types.ts new file mode 100644 index 00000000..4bf34b83 --- /dev/null +++ b/packages/clippy-a11y-validator/src/types.ts @@ -0,0 +1,13 @@ +export type ValidationSeverity = 'info' | 'warning' | 'error'; + +export type ValidationScope = 'block' | 'inline'; + +export type CorrectValidationFunction = () => void; + +export type ValidationResult = { + validatorKey: string; + element: HTMLElement; + scope: ValidationScope; + severity: ValidationSeverity; + correct?: CorrectValidationFunction; +}; diff --git a/packages/clippy-a11y-validator/src/validate.ts b/packages/clippy-a11y-validator/src/validate.ts new file mode 100644 index 00000000..ae102e35 --- /dev/null +++ b/packages/clippy-a11y-validator/src/validate.ts @@ -0,0 +1,16 @@ +import type { ValidationResult } from './types.ts'; +import { paragraphValidators } from './nlds-components/paragraph'; + +const bindValidatorsToTag = + ( + tag: TTag, + validators: ((element: HTMLElementTagNameMap[TTag]) => ValidationResult | null)[], + ) => + (root: ParentNode): ValidationResult[] => + [...root.querySelectorAll(tag)].flatMap((element) => + validators.map((validate) => validate(element)).filter((result) => result !== null), + ); + +const validators = [bindValidatorsToTag('p', paragraphValidators)]; + +export const validate = (root: ParentNode): ValidationResult[] => validators.flatMap((validate) => validate(root)); diff --git a/packages/clippy-a11y-validator/tsconfig.json b/packages/clippy-a11y-validator/tsconfig.json new file mode 100644 index 00000000..7a4c6fdd --- /dev/null +++ b/packages/clippy-a11y-validator/tsconfig.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "ignoreDeprecations": "6.0", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "preserve", + "moduleDetection": "force", + "moduleResolution": "bundler", + "noEmit": true, + "noUncheckedIndexedAccess": true, + "strict": true, + "target": "ES2023", + "verbatimModuleSyntax": true + }, + "extends": "@nl-design-system/tsconfig", + "include": ["src/**/*", "scripts/**/*", "*.ts"] +} diff --git a/packages/clippy-a11y-validator/vite.config.ts b/packages/clippy-a11y-validator/vite.config.ts new file mode 100644 index 00000000..0730c34c --- /dev/null +++ b/packages/clippy-a11y-validator/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +export default defineConfig({ + build: { + lib: { + entry: 'src/validate.ts', + fileName: 'validate', + formats: ['es'], + }, + minify: false, + }, + plugins: [dts({ include: ['src'] })], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 763a36c3..3b0ba556 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,27 @@ importers: specifier: 4.1.11 version: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + packages/clippy-a11y-validator: + devDependencies: + '@nl-design-system/tsconfig': + specifier: 1.0.5 + version: 1.0.5(typescript@6.0.3) + '@types/node': + specifier: 22.20.1 + version: 22.20.1 + playwright: + specifier: 1.62.1 + version: 1.62.1 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.2.1 + version: 8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0) + vite-plugin-dts: + specifier: 5.0.3 + version: 5.0.3(esbuild@0.28.2)(rolldown@1.2.3)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + packages/clippy-alt-text-wizard: dependencies: '@lit/localize': From d95a0a1b36d1816570939e38a61c0be3dc7d2a72 Mon Sep 17 00:00:00 2001 From: timhilhorst Date: Tue, 1 Sep 2026 15:30:24 +0200 Subject: [PATCH 2/7] chore: loop through dom nodes instead of validators --- .../clippy-a11y-validator/src/validate.ts | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/clippy-a11y-validator/src/validate.ts b/packages/clippy-a11y-validator/src/validate.ts index ae102e35..4ddc95c3 100644 --- a/packages/clippy-a11y-validator/src/validate.ts +++ b/packages/clippy-a11y-validator/src/validate.ts @@ -1,16 +1,25 @@ import type { ValidationResult } from './types.ts'; import { paragraphValidators } from './nlds-components/paragraph'; -const bindValidatorsToTag = - ( - tag: TTag, - validators: ((element: HTMLElementTagNameMap[TTag]) => ValidationResult | null)[], - ) => - (root: ParentNode): ValidationResult[] => - [...root.querySelectorAll(tag)].flatMap((element) => - validators.map((validate) => validate(element)).filter((result) => result !== null), - ); +type HTMLElementValidators = (element: Element) => ValidationResult[]; -const validators = [bindValidatorsToTag('p', paragraphValidators)]; +const bindValidatorsToTag = ( + tag: TTag, + validators: ((element: HTMLElementTagNameMap[TTag]) => ValidationResult | null)[], +): [string, HTMLElementValidators] => [ + tag.toUpperCase(), + (element) => + validators.map((validate) => validate(element as HTMLElementTagNameMap[TTag])).filter((result) => result !== null), +]; -export const validate = (root: ParentNode): ValidationResult[] => validators.flatMap((validate) => validate(root)); +const validatorRegistry = new Map([bindValidatorsToTag('p', paragraphValidators)]); + +export const validate = (root: ParentNode): ValidationResult[] => { + return [...root.querySelectorAll('*')].flatMap((element) => { + const validator = validatorRegistry.get(element.tagName); + if (validator) { + return validator(element); + } + return []; + }); +}; From 768973bd7693a4b06c6e8141c79668e30c1baa0c Mon Sep 17 00:00:00 2001 From: timhilhorst Date: Thu, 3 Sep 2026 15:39:30 +0200 Subject: [PATCH 3/7] chore: broken lockfile --- pnpm-lock.yaml | 134 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b0ba556..ae24b228 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,7 +141,7 @@ importers: version: 8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0) vite-plugin-dts: specifier: 5.0.3 - version: 5.0.3(esbuild@0.28.2)(rolldown@1.2.3)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + version: 5.0.3(esbuild@0.28.2)(rolldown@1.2.6)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) packages/clippy-alt-text-wizard: dependencies: @@ -6796,6 +6796,36 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unplugin-dts@1.0.3: + resolution: {integrity: sha512-/GR887wfG4r1cWyt1UZsLRuMIjsmEbGkS9yJrz+0dsToHAYUD5CTyP3JMGVLv25j9K0mJcwAVvZno/aTuSUvNg==} + peerDependencies: + '@microsoft/api-extractor': '>=7' + '@rspack/core': ^1 + '@vue/language-core': ^3.1.5 + esbuild: '*' + rolldown: '*' + rollup: '>=3' + typescript: '>=4' + vite: '>=3' + webpack: ^4 || ^5 + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@rspack/core': + optional: true + '@vue/language-core': + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + unplugin-dts@1.1.0: resolution: {integrity: sha512-KZJ+qk+lmd9dJY/PJvDRFL9BUtVubKDiX7Ai/X6mlkCArcQNzwmupKjVqHbRA42uqO5ZfFRFc+o8/OCbQ1GonQ==} peerDependencies: @@ -6943,6 +6973,20 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-plugin-dts@5.0.3: + resolution: {integrity: sha512-gIth6NdCEHWPiiRMCK3N6C8WjvdsrtEQrmsiG8h6Ov+lFP+b07Y+wcs9H0H7n146l0PDTYK4cQN1vgeG1pMdRQ==} + peerDependencies: + '@microsoft/api-extractor': '>=7' + rollup: '>=3' + vite: '>=3' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + rollup: + optional: true + vite: + optional: true + vite-plugin-dts@5.1.0: resolution: {integrity: sha512-MfLc2G+mXPUDGGxpHQeYbdunavFP9UpT9yxiAsTgUXRxfEd3HqysSAM4E72jhz5eIz3OOexCHkqiVlQq8ok9/A==} peerDependencies: @@ -6957,6 +7001,49 @@ packages: vite: optional: true + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vite@8.2.2: resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -14579,6 +14666,24 @@ snapshots: universalify@2.0.1: {} + unplugin-dts@1.0.3(esbuild@0.28.2)(rolldown@1.2.6)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)): + dependencies: + '@rollup/pluginutils': 5.4.0 + '@volar/typescript': 2.4.28 + compare-versions: 6.1.1 + debug: 4.4.3(supports-color@10.2.2) + kolorist: 1.8.0 + local-pkg: 1.2.1 + magic-string: 0.30.21 + typescript: 6.0.3 + unplugin: 2.3.11 + optionalDependencies: + esbuild: 0.28.2 + rolldown: 1.2.6 + vite: 8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + unplugin-dts@1.1.0(esbuild@0.28.2)(rolldown@1.2.6)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)): dependencies: '@rollup/pluginutils': 5.4.0 @@ -14674,6 +14779,20 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-plugin-dts@5.0.3(esbuild@0.28.2)(rolldown@1.2.6)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)): + dependencies: + unplugin-dts: 1.0.3(esbuild@0.28.2)(rolldown@1.2.6)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + optionalDependencies: + vite: 8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0) + transitivePeerDependencies: + - '@rspack/core' + - '@vue/language-core' + - esbuild + - rolldown + - supports-color + - typescript + - webpack + vite-plugin-dts@5.1.0(esbuild@0.28.2)(rolldown@1.2.6)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)): dependencies: unplugin-dts: 1.1.0(esbuild@0.28.2)(rolldown@1.2.6)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) @@ -14688,6 +14807,19 @@ snapshots: - typescript - webpack + vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.26 + rolldown: 1.2.6 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + esbuild: 0.28.2 + fsevents: 2.3.3 + yaml: 2.9.0 + vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 From bc5df3e5aa56c27fd850e162765e329e4f581f24 Mon Sep 17 00:00:00 2001 From: Tim Hilhorst Date: Thu, 3 Sep 2026 16:08:20 +0200 Subject: [PATCH 4/7] feat/clippy-a11y-validator-component-selector (#618) Co-authored-by: Wouter Lenting --- packages/clippy-a11y-validator/package.json | 13 +- .../scripts/validate-html.ts | 14 ++- .../src/conditions/content.ts | 4 + .../src/conditions/formatting.test.ts | 31 +++++ .../src/conditions/formatting.ts | 16 +++ .../src/conditions/index.ts | 2 + .../clippy-a11y-validator/src/consts/index.ts | 2 + .../src/consts/selectors.ts | 4 + .../src/{constants.ts => consts/severity.ts} | 0 .../src/define-validation.ts | 5 + packages/clippy-a11y-validator/src/helpers.ts | 9 -- packages/clippy-a11y-validator/src/index.ts | 19 +++ .../src/messages.test.ts | 39 ++++++ .../clippy-a11y-validator/src/messages.ts | 26 ++++ .../src/nlds-components/index.ts | 13 ++ .../nlds-components/paragraph/constants.ts | 3 +- .../src/nlds-components/paragraph/index.ts | 12 +- .../src/nlds-components/paragraph/rules.ts | 14 --- .../should-not-be-empty/index.test.ts | 52 ++++++++ .../paragraph/should-not-be-empty/index.ts | 14 +++ .../paragraph/should-not-be-empty/messages.ts | 8 ++ .../should-not-be-entirely-bold/corrector.ts | 9 -- .../should-not-be-entirely-bold/index.test.ts | 52 ++++++++ .../should-not-be-entirely-bold/index.ts | 30 ++--- .../should-not-be-entirely-bold/messages.ts | 13 ++ .../should-not-be-entirely-bold/rules.ts | 7 -- .../src/test-helpers/render.ts | 5 + packages/clippy-a11y-validator/src/types.ts | 13 -- .../src/types/messages.ts | 16 +++ .../src/types/selector.ts | 10 ++ .../src/types/validation.ts | 35 ++++++ .../src/utils/combinators.test.ts | 36 ++++++ .../src/utils/combinators.ts | 14 +++ .../src/utils/dom.test.ts | 26 ++++ .../clippy-a11y-validator/src/utils/dom.ts | 20 +++ .../src/utils/text.test.ts | 13 ++ .../clippy-a11y-validator/src/utils/text.ts | 3 + .../clippy-a11y-validator/src/validate.ts | 25 ---- .../src/validator.test.ts | 75 +++++++++++ .../clippy-a11y-validator/src/validator.ts | 43 +++++++ packages/clippy-a11y-validator/src/walk.ts | 41 ++++++ packages/clippy-a11y-validator/vite.config.ts | 6 +- .../clippy-a11y-validator/vitest.config.ts | 8 ++ pnpm-lock.yaml | 118 +++++++++++++++++- 44 files changed, 802 insertions(+), 116 deletions(-) create mode 100644 packages/clippy-a11y-validator/src/conditions/content.ts create mode 100644 packages/clippy-a11y-validator/src/conditions/formatting.test.ts create mode 100644 packages/clippy-a11y-validator/src/conditions/formatting.ts create mode 100644 packages/clippy-a11y-validator/src/conditions/index.ts create mode 100644 packages/clippy-a11y-validator/src/consts/index.ts create mode 100644 packages/clippy-a11y-validator/src/consts/selectors.ts rename packages/clippy-a11y-validator/src/{constants.ts => consts/severity.ts} (100%) create mode 100644 packages/clippy-a11y-validator/src/define-validation.ts delete mode 100644 packages/clippy-a11y-validator/src/helpers.ts create mode 100644 packages/clippy-a11y-validator/src/index.ts create mode 100644 packages/clippy-a11y-validator/src/messages.test.ts create mode 100644 packages/clippy-a11y-validator/src/messages.ts create mode 100644 packages/clippy-a11y-validator/src/nlds-components/index.ts delete mode 100644 packages/clippy-a11y-validator/src/nlds-components/paragraph/rules.ts create mode 100644 packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/index.test.ts create mode 100644 packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/index.ts create mode 100644 packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/messages.ts delete mode 100644 packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/corrector.ts create mode 100644 packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.test.ts create mode 100644 packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/messages.ts delete mode 100644 packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/rules.ts create mode 100644 packages/clippy-a11y-validator/src/test-helpers/render.ts delete mode 100644 packages/clippy-a11y-validator/src/types.ts create mode 100644 packages/clippy-a11y-validator/src/types/messages.ts create mode 100644 packages/clippy-a11y-validator/src/types/selector.ts create mode 100644 packages/clippy-a11y-validator/src/types/validation.ts create mode 100644 packages/clippy-a11y-validator/src/utils/combinators.test.ts create mode 100644 packages/clippy-a11y-validator/src/utils/combinators.ts create mode 100644 packages/clippy-a11y-validator/src/utils/dom.test.ts create mode 100644 packages/clippy-a11y-validator/src/utils/dom.ts create mode 100644 packages/clippy-a11y-validator/src/utils/text.test.ts create mode 100644 packages/clippy-a11y-validator/src/utils/text.ts delete mode 100644 packages/clippy-a11y-validator/src/validate.ts create mode 100644 packages/clippy-a11y-validator/src/validator.test.ts create mode 100644 packages/clippy-a11y-validator/src/validator.ts create mode 100644 packages/clippy-a11y-validator/src/walk.ts create mode 100644 packages/clippy-a11y-validator/vitest.config.ts diff --git a/packages/clippy-a11y-validator/package.json b/packages/clippy-a11y-validator/package.json index e57fc4bc..12ed42c2 100644 --- a/packages/clippy-a11y-validator/package.json +++ b/packages/clippy-a11y-validator/package.json @@ -20,24 +20,27 @@ "type": "module", "exports": { ".": { - "types": "./dist/validate.d.ts", - "default": "./dist/validate.js" + "types": "./dist/index.d.ts", + "default": "./dist/index.js" } }, "files": [ "dist" ], - "types": "dist/validate.d.ts", + "types": "dist/index.d.ts", "scripts": { "build": "vite build", - "validate": "node scripts/validate-html.ts" + "validate": "node scripts/validate-html.ts", + "test": "vitest --run" }, "devDependencies": { "@nl-design-system/tsconfig": "1.0.5", "@types/node": "22.20.1", + "happy-dom": "20.0.11", "playwright": "1.62.1", "typescript": "6.0.3", "vite": "8.2.1", - "vite-plugin-dts": "5.0.3" + "vite-plugin-dts": "5.0.3", + "vitest": "4.1.11" } } diff --git a/packages/clippy-a11y-validator/scripts/validate-html.ts b/packages/clippy-a11y-validator/scripts/validate-html.ts index e9c129b0..484f787a 100755 --- a/packages/clippy-a11y-validator/scripts/validate-html.ts +++ b/packages/clippy-a11y-validator/scripts/validate-html.ts @@ -8,7 +8,7 @@ import { chromium } from 'playwright'; const ORIGIN = 'http://localhost:5174'; /** The ES module built by `pnpm build`. */ -const BUNDLE = fileURLToPath(new URL('../dist/validate.js', import.meta.url)); +const BUNDLE = fileURLToPath(new URL('../dist/index.js', import.meta.url)); function help(): string { return ` @@ -61,14 +61,16 @@ try { async ({ fix, source }) => { // Import the bundle as a module, so it needs no global to hand its exports back. const moduleUrl = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); - const { validate } = (await import(moduleUrl)) as typeof import('../src/validate.ts'); + const { coreValidations, Validator } = (await import(moduleUrl)) as typeof import('../src/index.ts'); URL.revokeObjectURL(moduleUrl); - return validate(document.body).map(({ correct, element, severity, validatorKey }) => { + const validator = new Validator({ validations: Object.values(coreValidations) }); + + return validator.validate(document.body).map(({ correct, element, messages, rule, severity }) => { const before = element.outerHTML; if (fix) correct?.(); - return { after: fix ? element.outerHTML : undefined, before, severity, validatorKey }; + return { after: fix ? element.outerHTML : undefined, before, message: messages.error, rule, severity }; }); }, { fix: values['fix'], source: readFileSync(BUNDLE, 'utf8') }, @@ -76,8 +78,8 @@ try { console.log(`${url}\n`); - for (const { after, before, severity, validatorKey } of findings) { - console.log(`${severity}: ${validatorKey}\n ${before}`); + for (const { after, before, message, rule, severity } of findings) { + console.log(`${severity}: ${rule} — ${message}\n ${before}`); if (after !== undefined) console.log(` → ${after}`); } diff --git a/packages/clippy-a11y-validator/src/conditions/content.ts b/packages/clippy-a11y-validator/src/conditions/content.ts new file mode 100644 index 00000000..824da82d --- /dev/null +++ b/packages/clippy-a11y-validator/src/conditions/content.ts @@ -0,0 +1,4 @@ +import type { ValidationCondition } from '../types/validation.ts'; +import { isEmptyOrWhitespace } from '../utils/text.ts'; + +export const hasTextContent: ValidationCondition = (element) => !isEmptyOrWhitespace(element.textContent ?? ''); diff --git a/packages/clippy-a11y-validator/src/conditions/formatting.test.ts b/packages/clippy-a11y-validator/src/conditions/formatting.test.ts new file mode 100644 index 00000000..fa7d79d6 --- /dev/null +++ b/packages/clippy-a11y-validator/src/conditions/formatting.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { render } from '../test-helpers/render.ts'; +import { isNotEntirelyBold } from './formatting.ts'; + +describe('isNotEntirelyBold', () => { + it('is false when all text sits inside bold elements', () => { + expect(isNotEntirelyBold(render('

a

'))).toBe(false); + expect(isNotEntirelyBold(render('

a

'))).toBe(false); + expect(isNotEntirelyBold(render('

a b

'))).toBe(false); + }); + + it('is false when the bold element sits inside another inline wrapper', () => { + expect(isNotEntirelyBold(render('

a

'))).toBe(false); + expect(isNotEntirelyBold(render('

a

'))).toBe(false); + }); + + it('is true when text sits outside the bold elements', () => { + expect(isNotEntirelyBold(render('

a and more

'))).toBe(true); + expect(isNotEntirelyBold(render('

ab

'))).toBe(true); + }); + + it('is true without visible text', () => { + expect(isNotEntirelyBold(render('

'))).toBe(true); + expect(isNotEntirelyBold(render('

'))).toBe(true); + expect(isNotEntirelyBold(render('

'))).toBe(true); + }); + + it('is true for plain text', () => { + expect(isNotEntirelyBold(render('

plain

'))).toBe(true); + }); +}); diff --git a/packages/clippy-a11y-validator/src/conditions/formatting.ts b/packages/clippy-a11y-validator/src/conditions/formatting.ts new file mode 100644 index 00000000..009237b2 --- /dev/null +++ b/packages/clippy-a11y-validator/src/conditions/formatting.ts @@ -0,0 +1,16 @@ +import type { ValidationCondition } from '../types/validation.ts'; +import { selectors } from '../consts/selectors.ts'; +import { not } from '../utils/combinators.ts'; +import { visibleTextNodes } from '../utils/dom.ts'; + +const isEntirelyBold: ValidationCondition = (element) => { + const nodes = visibleTextNodes(element); + if (nodes.length === 0) return false; + + return nodes.every((node) => { + const bold = node.parentElement?.closest(selectors.BOLD); + return bold !== null && bold !== undefined && element.contains(bold); + }); +}; + +export const isNotEntirelyBold: ValidationCondition = not(isEntirelyBold); diff --git a/packages/clippy-a11y-validator/src/conditions/index.ts b/packages/clippy-a11y-validator/src/conditions/index.ts new file mode 100644 index 00000000..53341060 --- /dev/null +++ b/packages/clippy-a11y-validator/src/conditions/index.ts @@ -0,0 +1,2 @@ +export { hasTextContent } from './content.ts'; +export { isNotEntirelyBold } from './formatting.ts'; diff --git a/packages/clippy-a11y-validator/src/consts/index.ts b/packages/clippy-a11y-validator/src/consts/index.ts new file mode 100644 index 00000000..b98db24e --- /dev/null +++ b/packages/clippy-a11y-validator/src/consts/index.ts @@ -0,0 +1,2 @@ +export { selectors } from './selectors.ts'; +export { validationSeverity } from './severity.ts'; diff --git a/packages/clippy-a11y-validator/src/consts/selectors.ts b/packages/clippy-a11y-validator/src/consts/selectors.ts new file mode 100644 index 00000000..c8d5be75 --- /dev/null +++ b/packages/clippy-a11y-validator/src/consts/selectors.ts @@ -0,0 +1,4 @@ +export const selectors = { + BOLD: 'b, strong', + PARAGRAPH: 'p', +} as const; diff --git a/packages/clippy-a11y-validator/src/constants.ts b/packages/clippy-a11y-validator/src/consts/severity.ts similarity index 100% rename from packages/clippy-a11y-validator/src/constants.ts rename to packages/clippy-a11y-validator/src/consts/severity.ts diff --git a/packages/clippy-a11y-validator/src/define-validation.ts b/packages/clippy-a11y-validator/src/define-validation.ts new file mode 100644 index 00000000..2fd78182 --- /dev/null +++ b/packages/clippy-a11y-validator/src/define-validation.ts @@ -0,0 +1,5 @@ +import type { ElementFor, Selector } from './types/selector.ts'; +import type { Validation, ValidationDefinition } from './types/validation.ts'; + +export const defineValidation = (definition: ValidationDefinition>): Validation => + definition as unknown as Validation; diff --git a/packages/clippy-a11y-validator/src/helpers.ts b/packages/clippy-a11y-validator/src/helpers.ts deleted file mode 100644 index 8546e19a..00000000 --- a/packages/clippy-a11y-validator/src/helpers.ts +++ /dev/null @@ -1,9 +0,0 @@ -const EMPTY_STR_REGEX = /^\s*$/; -export const isEmptyOrWhitespace = (text: string): boolean => EMPTY_STR_REGEX.test(text); - -export const unwrapElement = (element: Element): void => { - const parent = element.parentNode; - if (!parent) return; - while (element.firstChild) parent.insertBefore(element.firstChild, element); - parent.removeChild(element); -}; diff --git a/packages/clippy-a11y-validator/src/index.ts b/packages/clippy-a11y-validator/src/index.ts new file mode 100644 index 00000000..8e23de9b --- /dev/null +++ b/packages/clippy-a11y-validator/src/index.ts @@ -0,0 +1,19 @@ +export { selectors, validationSeverity } from './consts/index.ts'; +export { defineValidation } from './define-validation.ts'; +export { coreValidationRules, coreValidations } from './nlds-components/index.ts'; +export type { CoreValidationRule } from './nlds-components/index.ts'; +export { hasTextContent, isNotEntirelyBold } from './conditions/index.ts'; +export { Validator } from './validator.ts'; +export type { ValidateOptions, ValidatorOptions } from './validator.ts'; +export type { Locale, ResolvedMessages, ValidationMessages, ValidationMessagesByLocale } from './types/messages.ts'; +export type { CoreSelector, ElementFor, Selector } from './types/selector.ts'; +export type { + CorrectValidationFunction, + ValidationCondition, + Validation, + ValidationDefinition, + ValidationPayload, + ValidationScope, + ValidationSeverity, + Violation, +} from './types/validation.ts'; diff --git a/packages/clippy-a11y-validator/src/messages.test.ts b/packages/clippy-a11y-validator/src/messages.test.ts new file mode 100644 index 00000000..982d7df5 --- /dev/null +++ b/packages/clippy-a11y-validator/src/messages.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import type { ValidationMessagesByLocale } from './types/messages.ts'; +import { resolveMessages } from './messages.ts'; + +const messages: ValidationMessagesByLocale = { + en: { error: 'The {nodeType} is wrong.', solution: 'Fix it.', solutions: { heading: 'Use a heading.' } }, + nl: { error: 'De {nodeType} is fout.', solution: 'Herstel het.' }, +}; + +describe('resolveMessages', () => { + it('returns the requested locale', () => { + expect(resolveMessages(messages, 'en', 'nl').error).toBe('The {nodeType} is wrong.'); + }); + + it('falls back when the locale is missing', () => { + expect(resolveMessages({ nl: messages.nl }, 'en', 'nl').error).toBe('De {nodeType} is fout.'); + }); + + it('interpolates payload values into both messages', () => { + const resolved = resolveMessages(messages, 'nl', 'nl', { nodeType: 'alinea' }); + expect(resolved.error).toBe('De alinea is fout.'); + }); + + it('leaves unknown placeholders untouched', () => { + expect(resolveMessages(messages, 'nl', 'nl', { other: 1 }).error).toBe('De {nodeType} is fout.'); + }); + + it('prefers the solution variant named by the payload', () => { + expect(resolveMessages(messages, 'en', 'nl', { variant: 'heading' }).solution).toBe('Use a heading.'); + }); + + it('falls back to the default solution for an unknown variant', () => { + expect(resolveMessages(messages, 'en', 'nl', { variant: 'lead' }).solution).toBe('Fix it.'); + }); + + it('omits the solution when there is none', () => { + expect(resolveMessages({ nl: { error: 'Fout.' } }, 'nl', 'nl').solution).toBeUndefined(); + }); +}); diff --git a/packages/clippy-a11y-validator/src/messages.ts b/packages/clippy-a11y-validator/src/messages.ts new file mode 100644 index 00000000..5f63c30d --- /dev/null +++ b/packages/clippy-a11y-validator/src/messages.ts @@ -0,0 +1,26 @@ +import type { Locale, ResolvedMessages, ValidationMessagesByLocale } from './types/messages.ts'; +import type { ValidationPayload } from './types/validation.ts'; + +const PLACEHOLDER_REGEX = /\{(\w+)\}/g; + +const interpolate = (text: string, payload: ValidationPayload | undefined): string => + text.replace(PLACEHOLDER_REGEX, (placeholder, key: string) => { + const value = payload?.[key]; + return value === undefined ? placeholder : String(value); + }); + +export const resolveMessages = ( + messages: ValidationMessagesByLocale, + locale: Locale, + fallbackLocale: Locale, + payload?: ValidationPayload, +): ResolvedMessages => { + const localised = messages[locale] ?? messages[fallbackLocale] ?? messages.nl; + const variant = payload?.['variant']; + const solution = (typeof variant === 'string' ? localised.solutions?.[variant] : undefined) ?? localised.solution; + + return { + error: interpolate(localised.error, payload), + ...(solution === undefined ? {} : { solution: interpolate(solution, payload) }), + }; +}; diff --git a/packages/clippy-a11y-validator/src/nlds-components/index.ts b/packages/clippy-a11y-validator/src/nlds-components/index.ts new file mode 100644 index 00000000..14245624 --- /dev/null +++ b/packages/clippy-a11y-validator/src/nlds-components/index.ts @@ -0,0 +1,13 @@ +import type { Validation } from '../types/validation.ts'; +import { paragraphValidationRules } from './paragraph/constants.ts'; +import { paragraphValidations } from './paragraph/index.ts'; + +export const coreValidationRules = { + ...paragraphValidationRules, +} as const; + +export type CoreValidationRule = keyof typeof coreValidationRules; + +export const coreValidations = { + ...paragraphValidations, +} satisfies Record; diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/constants.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/constants.ts index 15201d70..ef6ad03d 100644 --- a/packages/clippy-a11y-validator/src/nlds-components/paragraph/constants.ts +++ b/packages/clippy-a11y-validator/src/nlds-components/paragraph/constants.ts @@ -1,4 +1,5 @@ // https://nldesignsystem.nl/paragraph -export const paragraphValidations = { +export const paragraphValidationRules = { + PARAGRAPH_SHOULD_NOT_BE_EMPTY: 'PARAGRAPH_SHOULD_NOT_BE_EMPTY', PARAGRAPH_SHOULD_NOT_BE_ENTIRELY_BOLD: 'PARAGRAPH_SHOULD_NOT_BE_ENTIRELY_BOLD', } as const; diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/index.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/index.ts index 64e3f49e..668dac26 100644 --- a/packages/clippy-a11y-validator/src/nlds-components/paragraph/index.ts +++ b/packages/clippy-a11y-validator/src/nlds-components/paragraph/index.ts @@ -1,5 +1,11 @@ -import { paragraphShouldNotBeEntirelyBold } from './should-not-be-entirely-bold'; +import type { Validation } from '../../types/validation.ts'; +import { paragraphValidationRules } from './constants.ts'; +import { paragraphShouldNotBeEmpty } from './should-not-be-empty/index.ts'; +import { paragraphShouldNotBeEntirelyBold } from './should-not-be-entirely-bold/index.ts'; -export { paragraphShouldNotBeEntirelyBold }; +export type ParagraphValidationRule = keyof typeof paragraphValidationRules; -export const paragraphValidators = [paragraphShouldNotBeEntirelyBold]; +export const paragraphValidations = { + [paragraphValidationRules.PARAGRAPH_SHOULD_NOT_BE_EMPTY]: paragraphShouldNotBeEmpty, + [paragraphValidationRules.PARAGRAPH_SHOULD_NOT_BE_ENTIRELY_BOLD]: paragraphShouldNotBeEntirelyBold, +} satisfies Record; diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/rules.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/rules.ts deleted file mode 100644 index eb19c7c0..00000000 --- a/packages/clippy-a11y-validator/src/nlds-components/paragraph/rules.ts +++ /dev/null @@ -1,14 +0,0 @@ -const BOLD_TAGS = new Set(['B', 'STRONG']); - -export const BOLD_SELECTOR = 'strong, b'; - -const visibleChildren = (element: Element): ChildNode[] => - Array.from(element.childNodes).filter( - (node) => node.nodeType !== Node.TEXT_NODE || (node.textContent?.trim().length ?? 0) > 0, - ); - -export const isEntirelyBold = (element: Element): boolean => { - const children = visibleChildren(element); - if (children.length === 0) return false; - return children.every((node) => node instanceof Element && BOLD_TAGS.has(node.tagName)); -}; diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/index.test.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/index.test.ts new file mode 100644 index 00000000..cb1c2e33 --- /dev/null +++ b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/index.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { Validator } from '../../../validator.ts'; +import { paragraphShouldNotBeEmpty } from './index.ts'; + +let root: HTMLElement; +const validator = new Validator({ validations: [paragraphShouldNotBeEmpty] }); + +const validate = (html: string) => { + root.innerHTML = html; + return validator.validate(root); +}; + +beforeEach(() => { + root = document.createElement('div'); + document.body.replaceChildren(root); +}); + +describe('paragraphShouldNotBeEmpty', () => { + it('flags an empty paragraph', () => { + const [violation] = validate('

'); + + expect(violation?.rule).toBe('PARAGRAPH_SHOULD_NOT_BE_EMPTY'); + expect(violation?.severity).toBe('info'); + expect(violation?.scope).toBe('block'); + expect(violation?.messages.error).toBe('Deze alinea is leeg.'); + expect(violation?.messages.solution).toBe('Verwijder de lege alinea of voeg tekst toe.'); + }); + + it('flags a paragraph that contains only whitespace', () => { + expect(validate('

')).toHaveLength(1); + }); + + it('flags a paragraph that contains only a line break', () => { + expect(validate('


')).toHaveLength(1); + }); + + it('accepts a paragraph with text', () => { + expect(validate('

tekst

')).toHaveLength(0); + }); + + it('accepts a paragraph whose text is nested in an inline element', () => { + expect(validate('

tekst

')).toHaveLength(0); + }); + + it('ignores elements that are not paragraphs', () => { + expect(validate('
')).toHaveLength(0); + }); + + it('offers no correction', () => { + expect(validate('

')[0]?.correct).toBeUndefined(); + }); +}); diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/index.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/index.ts new file mode 100644 index 00000000..34b40c83 --- /dev/null +++ b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/index.ts @@ -0,0 +1,14 @@ +import { hasTextContent } from '../../../conditions/index.ts'; +import { selectors, validationSeverity } from '../../../consts/index.ts'; +import { defineValidation } from '../../../define-validation.ts'; +import { paragraphValidationRules } from '../constants.ts'; +import { messages } from './messages.ts'; + +export const paragraphShouldNotBeEmpty = defineValidation({ + condition: hasTextContent, + messages, + rule: paragraphValidationRules.PARAGRAPH_SHOULD_NOT_BE_EMPTY, + scope: 'block', + selector: selectors.PARAGRAPH, + severity: validationSeverity.INFO, +}); diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/messages.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/messages.ts new file mode 100644 index 00000000..ade87ebd --- /dev/null +++ b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/messages.ts @@ -0,0 +1,8 @@ +import type { ValidationMessagesByLocale } from '../../../types/messages.ts'; + +export const messages: ValidationMessagesByLocale = { + nl: { + error: 'Deze alinea is leeg.', + solution: 'Verwijder de lege alinea of voeg tekst toe.', + }, +}; diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/corrector.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/corrector.ts deleted file mode 100644 index 6cf8b5c7..00000000 --- a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/corrector.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { CorrectValidationFunction } from '../../../types.ts'; -import { unwrapElement } from '../../../helpers.ts'; -import { BOLD_SELECTOR } from '../rules.ts'; - -export const correctEntirelyBoldParagraph = - (paragraph: HTMLParagraphElement): CorrectValidationFunction => - () => { - paragraph.querySelectorAll(BOLD_SELECTOR).forEach(unwrapElement); - }; diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.test.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.test.ts new file mode 100644 index 00000000..22b0395c --- /dev/null +++ b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { Validator } from '../../../validator.ts'; +import { paragraphShouldNotBeEntirelyBold } from './index.ts'; + +let root: HTMLElement; +const validator = new Validator({ validations: [paragraphShouldNotBeEntirelyBold] }); + +const validate = (html: string) => { + root.innerHTML = html; + return validator.validate(root); +}; + +beforeEach(() => { + root = document.createElement('div'); + document.body.replaceChildren(root); +}); + +describe('paragraphShouldNotBeEntirelyBold', () => { + it('flags a paragraph that is entirely bold', () => { + const [violation] = validate('

Alles dik

'); + + expect(violation?.rule).toBe('PARAGRAPH_SHOULD_NOT_BE_ENTIRELY_BOLD'); + expect(violation?.severity).toBe('warning'); + expect(violation?.scope).toBe('block'); + expect(violation?.messages.error).toBe('De hele alinea is dikgedrukt.'); + expect(violation?.messages.solution).toContain('alleen voor de woorden'); + }); + + it('accepts a paragraph with bold and plain text', () => { + expect(validate('

Dik en gewoon

')).toHaveLength(0); + }); + + it('accepts an empty paragraph', () => { + expect(validate('

')).toHaveLength(0); + }); + + it('flags a paragraph whose bold text is wrapped in another inline element', () => { + expect(validate('

Alles dik

')).toHaveLength(1); + }); + + it('ignores elements that are not paragraphs', () => { + expect(validate('
Alles dik
')).toHaveLength(0); + }); + + it('unwraps the bold children when corrected', () => { + const [violation] = validate('

Alles dik

'); + violation?.correct?.(); + + expect(root.querySelector('p')?.innerHTML).toBe('Alles dik'); + expect(validator.validate(root)).toHaveLength(0); + }); +}); diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.ts index 77365067..1409fe49 100644 --- a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.ts +++ b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.ts @@ -1,16 +1,16 @@ -import type { ValidationResult } from '../../../types.ts'; -import { validationSeverity } from '../../../constants.ts'; -import { paragraphValidations } from '../constants.ts'; -import { correctEntirelyBoldParagraph } from './corrector.ts'; -import { isEntirelyBoldParagraph } from './rules.ts'; +import { isNotEntirelyBold } from '../../../conditions/index.ts'; +import { selectors, validationSeverity } from '../../../consts/index.ts'; +import { defineValidation } from '../../../define-validation.ts'; +import { unwrapElement } from '../../../utils/dom.ts'; +import { paragraphValidationRules } from '../constants.ts'; +import { messages } from './messages.ts'; -export const paragraphShouldNotBeEntirelyBold = (element: HTMLParagraphElement): ValidationResult | null => { - if (!isEntirelyBoldParagraph(element)) return null; - return { - correct: correctEntirelyBoldParagraph(element), - element, - scope: 'block', - severity: validationSeverity.WARNING, - validatorKey: paragraphValidations.PARAGRAPH_SHOULD_NOT_BE_ENTIRELY_BOLD, - }; -}; +export const paragraphShouldNotBeEntirelyBold = defineValidation({ + condition: isNotEntirelyBold, + correct: (paragraph) => () => paragraph.querySelectorAll(selectors.BOLD).forEach(unwrapElement), + messages, + rule: paragraphValidationRules.PARAGRAPH_SHOULD_NOT_BE_ENTIRELY_BOLD, + scope: 'block', + selector: selectors.PARAGRAPH, + severity: validationSeverity.WARNING, +}); diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/messages.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/messages.ts new file mode 100644 index 00000000..f62f49f0 --- /dev/null +++ b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/messages.ts @@ -0,0 +1,13 @@ +import type { ValidationMessagesByLocale } from '../../../types/messages.ts'; + +// Copied from @nl-design-system-unstable/documentation componenten/paragraph/_issues/strong. +export const messages: ValidationMessagesByLocale = { + nl: { + error: 'De hele alinea is dikgedrukt.', + solution: + 'Gebruik de optie om tekst dikgedrukt te maken alleen voor de woorden of zinnen die extra aandacht nodig hebben.', + solutions: { + heading: 'Gebruik een kop in plaats van een dikgedrukte alinea.', + }, + }, +}; diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/rules.ts b/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/rules.ts deleted file mode 100644 index b3fe3744..00000000 --- a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/rules.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { isEmptyOrWhitespace } from '../../../helpers.ts'; -import { isEntirelyBold } from '../rules.ts'; - -export const isEntirelyBoldParagraph = (paragraph: HTMLParagraphElement): boolean => { - if (isEmptyOrWhitespace(paragraph.textContent ?? '')) return false; - return isEntirelyBold(paragraph); -}; diff --git a/packages/clippy-a11y-validator/src/test-helpers/render.ts b/packages/clippy-a11y-validator/src/test-helpers/render.ts new file mode 100644 index 00000000..129b3480 --- /dev/null +++ b/packages/clippy-a11y-validator/src/test-helpers/render.ts @@ -0,0 +1,5 @@ +export const render = (html: string): HTMLElement => { + const container = document.createElement('div'); + container.innerHTML = html; + return container.firstElementChild as HTMLElement; +}; diff --git a/packages/clippy-a11y-validator/src/types.ts b/packages/clippy-a11y-validator/src/types.ts deleted file mode 100644 index 4bf34b83..00000000 --- a/packages/clippy-a11y-validator/src/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type ValidationSeverity = 'info' | 'warning' | 'error'; - -export type ValidationScope = 'block' | 'inline'; - -export type CorrectValidationFunction = () => void; - -export type ValidationResult = { - validatorKey: string; - element: HTMLElement; - scope: ValidationScope; - severity: ValidationSeverity; - correct?: CorrectValidationFunction; -}; diff --git a/packages/clippy-a11y-validator/src/types/messages.ts b/packages/clippy-a11y-validator/src/types/messages.ts new file mode 100644 index 00000000..4f3c968b --- /dev/null +++ b/packages/clippy-a11y-validator/src/types/messages.ts @@ -0,0 +1,16 @@ +export type Locale = 'en' | 'nl'; + +export type ValidationMessages = { + error: string; + solution?: string; + solutions?: Readonly>; +}; + +export type ValidationMessagesByLocale = Partial> & { + nl: ValidationMessages; +}; + +export type ResolvedMessages = { + error: string; + solution?: string; +}; diff --git a/packages/clippy-a11y-validator/src/types/selector.ts b/packages/clippy-a11y-validator/src/types/selector.ts new file mode 100644 index 00000000..1ca8f589 --- /dev/null +++ b/packages/clippy-a11y-validator/src/types/selector.ts @@ -0,0 +1,10 @@ +import type { selectors } from '../consts/selectors.ts'; + +export type CoreSelector = (typeof selectors)[keyof typeof selectors]; + +export type Selector = CoreSelector | (string & Record); + +export type ElementFor = (S extends keyof HTMLElementTagNameMap + ? HTMLElementTagNameMap[S] + : HTMLElement) & + HTMLElement; diff --git a/packages/clippy-a11y-validator/src/types/validation.ts b/packages/clippy-a11y-validator/src/types/validation.ts new file mode 100644 index 00000000..c612ed8b --- /dev/null +++ b/packages/clippy-a11y-validator/src/types/validation.ts @@ -0,0 +1,35 @@ +import type { ValidationMessagesByLocale, ResolvedMessages } from './messages.ts'; +import type { ElementFor, Selector } from './selector.ts'; + +export type ValidationSeverity = 'error' | 'info' | 'warning'; + +export type ValidationScope = 'block' | 'inline'; + +export type ValidationPayload = Readonly>; + +export type CorrectValidationFunction = () => void; + +export type ValidationCondition = (element: E) => boolean; + +export type ValidationDefinition> = { + condition: ValidationCondition; + correct?: (element: E) => CorrectValidationFunction; + messages: ValidationMessagesByLocale; + payload?: (element: E) => ValidationPayload; + rule: string; + scope: ValidationScope; + selector: S; + severity: ValidationSeverity; +}; + +export type Validation = ValidationDefinition; + +export type Violation = { + correct?: CorrectValidationFunction; + element: HTMLElement; + messages: ResolvedMessages; + payload?: ValidationPayload; + rule: string; + scope: ValidationScope; + severity: ValidationSeverity; +}; diff --git a/packages/clippy-a11y-validator/src/utils/combinators.test.ts b/packages/clippy-a11y-validator/src/utils/combinators.test.ts new file mode 100644 index 00000000..9a9ee3cc --- /dev/null +++ b/packages/clippy-a11y-validator/src/utils/combinators.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { and, not, or } from './combinators.ts'; + +const isEven = (value: number): boolean => value % 2 === 0; +const isPositive = (value: number): boolean => value > 0; + +describe('not', () => { + it('inverts the result of a predicate', () => { + expect(not(isEven)(2)).toBe(false); + expect(not(isEven)(3)).toBe(true); + }); +}); + +describe('and', () => { + it('is true only when every predicate holds', () => { + expect(and(isEven, isPositive)(2)).toBe(true); + expect(and(isEven, isPositive)(-2)).toBe(false); + expect(and(isEven, isPositive)(3)).toBe(false); + }); + + it('is true without predicates', () => { + expect(and<[number]>()(1)).toBe(true); + }); +}); + +describe('or', () => { + it('is true when any predicate holds', () => { + expect(or(isEven, isPositive)(-2)).toBe(true); + expect(or(isEven, isPositive)(3)).toBe(true); + expect(or(isEven, isPositive)(-3)).toBe(false); + }); + + it('is false without predicates', () => { + expect(or<[number]>()(1)).toBe(false); + }); +}); diff --git a/packages/clippy-a11y-validator/src/utils/combinators.ts b/packages/clippy-a11y-validator/src/utils/combinators.ts new file mode 100644 index 00000000..afd5afbb --- /dev/null +++ b/packages/clippy-a11y-validator/src/utils/combinators.ts @@ -0,0 +1,14 @@ +export const not = + (predicate: (...args: A) => boolean) => + (...args: A): boolean => + !predicate(...args); + +export const and = + (...predicates: readonly ((...args: A) => boolean)[]) => + (...args: A): boolean => + predicates.every((predicate) => predicate(...args)); + +export const or = + (...predicates: readonly ((...args: A) => boolean)[]) => + (...args: A): boolean => + predicates.some((predicate) => predicate(...args)); diff --git a/packages/clippy-a11y-validator/src/utils/dom.test.ts b/packages/clippy-a11y-validator/src/utils/dom.test.ts new file mode 100644 index 00000000..4388715d --- /dev/null +++ b/packages/clippy-a11y-validator/src/utils/dom.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { render } from '../test-helpers/render.ts'; +import { unwrapElement, visibleTextNodes } from './dom.ts'; + +describe('visibleTextNodes', () => { + it('collects text nodes that hold content', () => { + expect(visibleTextNodes(render('

a b

')).map((node) => node.data)).toEqual(['a', ' b']); + }); + + it('skips whitespace-only text nodes', () => { + expect(visibleTextNodes(render('

'))).toEqual([]); + }); +}); + +describe('unwrapElement', () => { + it('replaces the element with its children', () => { + const paragraph = render('

bold tail

'); + unwrapElement(paragraph.querySelector('strong')!); + expect(paragraph.innerHTML).toBe('bold tail'); + }); + + it('does nothing for a detached element', () => { + const detached = document.createElement('strong'); + expect(() => unwrapElement(detached)).not.toThrow(); + }); +}); diff --git a/packages/clippy-a11y-validator/src/utils/dom.ts b/packages/clippy-a11y-validator/src/utils/dom.ts new file mode 100644 index 00000000..17a2d7dd --- /dev/null +++ b/packages/clippy-a11y-validator/src/utils/dom.ts @@ -0,0 +1,20 @@ +import { isEmptyOrWhitespace } from './text.ts'; + +export const visibleTextNodes = (element: Element): Text[] => { + const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const nodes: Text[] = []; + + while (walker.nextNode()) { + const node = walker.currentNode as Text; + if (!isEmptyOrWhitespace(node.data)) nodes.push(node); + } + + return nodes; +}; + +export const unwrapElement = (element: Element): void => { + const parent = element.parentNode; + if (!parent) return; + while (element.firstChild) parent.insertBefore(element.firstChild, element); + parent.removeChild(element); +}; diff --git a/packages/clippy-a11y-validator/src/utils/text.test.ts b/packages/clippy-a11y-validator/src/utils/text.test.ts new file mode 100644 index 00000000..2db5182e --- /dev/null +++ b/packages/clippy-a11y-validator/src/utils/text.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { isEmptyOrWhitespace } from './text.ts'; + +describe('isEmptyOrWhitespace', () => { + it('is true for empty and whitespace-only strings', () => { + expect(isEmptyOrWhitespace('')).toBe(true); + expect(isEmptyOrWhitespace(' \n\t ')).toBe(true); + }); + + it('is false once there is content', () => { + expect(isEmptyOrWhitespace(' a ')).toBe(false); + }); +}); diff --git a/packages/clippy-a11y-validator/src/utils/text.ts b/packages/clippy-a11y-validator/src/utils/text.ts new file mode 100644 index 00000000..b6b38832 --- /dev/null +++ b/packages/clippy-a11y-validator/src/utils/text.ts @@ -0,0 +1,3 @@ +const EMPTY_STR_REGEX = /^\s*$/; + +export const isEmptyOrWhitespace = (text: string): boolean => EMPTY_STR_REGEX.test(text); diff --git a/packages/clippy-a11y-validator/src/validate.ts b/packages/clippy-a11y-validator/src/validate.ts deleted file mode 100644 index 4ddc95c3..00000000 --- a/packages/clippy-a11y-validator/src/validate.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ValidationResult } from './types.ts'; -import { paragraphValidators } from './nlds-components/paragraph'; - -type HTMLElementValidators = (element: Element) => ValidationResult[]; - -const bindValidatorsToTag = ( - tag: TTag, - validators: ((element: HTMLElementTagNameMap[TTag]) => ValidationResult | null)[], -): [string, HTMLElementValidators] => [ - tag.toUpperCase(), - (element) => - validators.map((validate) => validate(element as HTMLElementTagNameMap[TTag])).filter((result) => result !== null), -]; - -const validatorRegistry = new Map([bindValidatorsToTag('p', paragraphValidators)]); - -export const validate = (root: ParentNode): ValidationResult[] => { - return [...root.querySelectorAll('*')].flatMap((element) => { - const validator = validatorRegistry.get(element.tagName); - if (validator) { - return validator(element); - } - return []; - }); -}; diff --git a/packages/clippy-a11y-validator/src/validator.test.ts b/packages/clippy-a11y-validator/src/validator.test.ts new file mode 100644 index 00000000..21cbeac6 --- /dev/null +++ b/packages/clippy-a11y-validator/src/validator.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { coreValidationRules, coreValidations } from './nlds-components/index.ts'; +import { Validator } from './validator.ts'; + +const { PARAGRAPH_SHOULD_NOT_BE_EMPTY, PARAGRAPH_SHOULD_NOT_BE_ENTIRELY_BOLD } = coreValidationRules; + +let root: HTMLElement; + +beforeEach(() => { + root = document.createElement('div'); + root.innerHTML = '

Vetgedrukt

'; + document.body.replaceChildren(root); +}); + +describe('Validator', () => { + it('only reports the cherry-picked validation', () => { + const validator = new Validator({ validations: [coreValidations[PARAGRAPH_SHOULD_NOT_BE_EMPTY]] }); + + expect(validator.validate(root).map(({ rule }) => rule)).toEqual([PARAGRAPH_SHOULD_NOT_BE_EMPTY]); + }); + + it('reports every core validation when all of them are registered', () => { + const validator = new Validator({ validations: Object.values(coreValidations) }); + + expect(validator.validate(root).map(({ rule }) => rule)).toEqual([ + PARAGRAPH_SHOULD_NOT_BE_ENTIRELY_BOLD, + PARAGRAPH_SHOULD_NOT_BE_EMPTY, + ]); + }); + + it('reports violations in document order', () => { + const validator = new Validator({ validations: Object.values(coreValidations) }); + + expect(validator.validate(root).map(({ element }) => element.textContent)).toEqual(['Vetgedrukt', ' ']); + }); + + it('replaces a validation registered under an existing rule', () => { + const validator = new Validator({ validations: [coreValidations[PARAGRAPH_SHOULD_NOT_BE_EMPTY]] }); + validator.register({ ...coreValidations[PARAGRAPH_SHOULD_NOT_BE_EMPTY], severity: 'error' }); + + expect(validator.validate(root).map(({ severity }) => severity)).toEqual(['error']); + }); + + it('stops reporting a validation after it is unregistered', () => { + const validator = new Validator(); + const unregister = validator.register(coreValidations[PARAGRAPH_SHOULD_NOT_BE_EMPTY]); + + expect(validator.validate(root)).toHaveLength(1); + unregister(); + expect(validator.validate(root)).toHaveLength(0); + }); + + it('leaves a replacement in place when the replaced validation is unregistered', () => { + const validator = new Validator(); + const unregister = validator.register(coreValidations[PARAGRAPH_SHOULD_NOT_BE_EMPTY]); + validator.register({ ...coreValidations[PARAGRAPH_SHOULD_NOT_BE_EMPTY], severity: 'error' }); + unregister(); + + expect(validator.validate(root).map(({ severity }) => severity)).toEqual(['error']); + }); + + it('skips validations outside the requested severities', () => { + const validator = new Validator({ validations: Object.values(coreValidations) }); + + expect(validator.validate(root, { severities: ['info'] }).map(({ rule }) => rule)).toEqual([ + PARAGRAPH_SHOULD_NOT_BE_EMPTY, + ]); + }); + + it('falls back to the default locale when the requested locale has no messages', () => { + const validator = new Validator({ locale: 'en', validations: [coreValidations[PARAGRAPH_SHOULD_NOT_BE_EMPTY]] }); + + expect(validator.validate(root)[0]?.messages.error).toBe('Deze alinea is leeg.'); + }); +}); diff --git a/packages/clippy-a11y-validator/src/validator.ts b/packages/clippy-a11y-validator/src/validator.ts new file mode 100644 index 00000000..e6209cc1 --- /dev/null +++ b/packages/clippy-a11y-validator/src/validator.ts @@ -0,0 +1,43 @@ +import type { Locale } from './types/messages.ts'; +import type { Validation, ValidationSeverity, Violation } from './types/validation.ts'; +import { walk } from './walk.ts'; + +export type ValidatorOptions = { + fallbackLocale?: Locale; + locale?: Locale; + validations?: readonly Validation[]; +}; + +export type ValidateOptions = { + severities?: readonly ValidationSeverity[]; +}; + +export class Validator { + readonly #validations = new Map(); + readonly #locale: Locale; + readonly #fallbackLocale: Locale; + + constructor({ fallbackLocale = 'nl', locale = 'nl', validations = [] }: ValidatorOptions = {}) { + this.#locale = locale; + this.#fallbackLocale = fallbackLocale; + validations.forEach((validation) => this.register(validation)); + } + + register(validation: Validation): () => void { + this.#validations.set(validation.rule, validation); + + return () => { + if (this.#validations.get(validation.rule) === validation) { + this.#validations.delete(validation.rule); + } + }; + } + + validate(root: ParentNode, { severities }: ValidateOptions = {}): Violation[] { + return walk(root, [...this.#validations.values()], { + fallbackLocale: this.#fallbackLocale, + locale: this.#locale, + ...(severities === undefined ? {} : { severities }), + }); + } +} diff --git a/packages/clippy-a11y-validator/src/walk.ts b/packages/clippy-a11y-validator/src/walk.ts new file mode 100644 index 00000000..8932d364 --- /dev/null +++ b/packages/clippy-a11y-validator/src/walk.ts @@ -0,0 +1,41 @@ +import type { Locale } from './types/messages.ts'; +import type { Validation, ValidationSeverity, Violation } from './types/validation.ts'; +import { resolveMessages } from './messages.ts'; + +export type WalkOptions = { + fallbackLocale: Locale; + locale: Locale; + severities?: readonly ValidationSeverity[]; +}; + +const violate = (validation: Validation, element: HTMLElement, options: WalkOptions): Violation | null => { + const { condition, correct, messages, payload, rule, scope, selector, severity } = validation; + + if (!element.matches(selector)) return null; + if (condition(element)) return null; + + const violationPayload = payload?.(element); + + return { + correct: correct?.(element), + element, + messages: resolveMessages(messages, options.locale, options.fallbackLocale, violationPayload), + rule, + scope, + severity, + ...(violationPayload === undefined ? {} : { payload: violationPayload }), + }; +}; + +export const walk = (root: ParentNode, validations: readonly Validation[], options: WalkOptions): Violation[] => { + const { severities } = options; + const applicable = severities ? validations.filter(({ severity }) => severities.includes(severity)) : validations; + + return [...root.querySelectorAll('*')].flatMap((element) => { + if (!(element instanceof HTMLElement)) return []; + + return applicable + .map((validation) => violate(validation, element, options)) + .filter((violation) => violation !== null); + }); +}; diff --git a/packages/clippy-a11y-validator/vite.config.ts b/packages/clippy-a11y-validator/vite.config.ts index 0730c34c..606b59ec 100644 --- a/packages/clippy-a11y-validator/vite.config.ts +++ b/packages/clippy-a11y-validator/vite.config.ts @@ -4,11 +4,11 @@ import dts from 'vite-plugin-dts'; export default defineConfig({ build: { lib: { - entry: 'src/validate.ts', - fileName: 'validate', + entry: 'src/index.ts', + fileName: 'index', formats: ['es'], }, minify: false, }, - plugins: [dts({ include: ['src'] })], + plugins: [dts({ exclude: ['src/**/*.test.ts', 'src/test-helpers/**'], include: ['src'] })], }); diff --git a/packages/clippy-a11y-validator/vitest.config.ts b/packages/clippy-a11y-validator/vitest.config.ts new file mode 100644 index 00000000..0ae70540 --- /dev/null +++ b/packages/clippy-a11y-validator/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae24b228..5376297b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,7 +120,7 @@ importers: version: 5.1.0(esbuild@0.28.2)(rolldown@1.2.6)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + version: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.0.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) packages/clippy-a11y-validator: devDependencies: @@ -130,6 +130,9 @@ importers: '@types/node': specifier: 22.20.1 version: 22.20.1 + happy-dom: + specifier: 20.0.11 + version: 20.0.11 playwright: specifier: 1.62.1 version: 1.62.1 @@ -142,6 +145,9 @@ importers: vite-plugin-dts: specifier: 5.0.3 version: 5.0.3(esbuild@0.28.2)(rolldown@1.2.6)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + vitest: + specifier: 4.1.11 + version: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.0.11)(jsdom@30.0.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) packages/clippy-alt-text-wizard: dependencies: @@ -498,7 +504,7 @@ importers: version: 5.1.0(esbuild@0.28.2)(rolldown@1.2.6)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + version: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.0.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) packages/editor-website: dependencies: @@ -2715,6 +2721,9 @@ packages: '@types/nlcst@2.0.3': resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} @@ -2747,6 +2756,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@typescript-eslint/eslint-plugin@8.54.0': resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4593,6 +4605,10 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + happy-dom@20.0.11: + resolution: {integrity: sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==} + engines: {node: '>=20.0.0'} + hard-rejection@2.1.0: resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} engines: {node: '>=6'} @@ -7167,6 +7183,10 @@ packages: engines: {node: '>=12'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@5.0.0: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} @@ -9689,6 +9709,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 @@ -9719,6 +9743,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@10.9.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -10256,19 +10282,51 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/browser-playwright@4.1.11(playwright@1.62.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))(vitest@4.1.11)': + dependencies: + '@vitest/browser': 4.1.11(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + playwright: 1.62.1 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.0.11)(jsdom@30.0.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser-playwright@4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))(vitest@4.1.11)': dependencies: '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))(vitest@4.1.11) '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) playwright: 1.62.1 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.0.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite + '@vitest/browser@4.1.11(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))(vitest@4.1.11)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.0.11)(jsdom@30.0.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))(vitest@4.1.11)': dependencies: '@blazediff/core': 1.9.1 @@ -10278,7 +10336,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.0.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -10298,7 +10356,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.0.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) optionalDependencies: '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))(vitest@4.1.11) @@ -10311,6 +10369,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 + '@vitest/mocker@4.1.11(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0) + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 @@ -11952,6 +12018,12 @@ snapshots: hachure-fill@0.5.2: {} + happy-dom@20.0.11: + dependencies: + '@types/node': 20.19.43 + '@types/whatwg-mimetype': 3.0.2 + whatwg-mimetype: 3.0.0 + hard-rejection@2.1.0: {} has-bigints@1.1.0: {} @@ -14837,7 +14909,38 @@ snapshots: optionalDependencies: vite: 8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0) - vitest@4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)): + vitest@4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.0.11)(jsdom@30.0.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + '@vitest/browser-playwright': 4.1.11(playwright@1.62.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/coverage-v8': 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) + happy-dom: 20.0.11 + jsdom: 30.0.1 + transitivePeerDependencies: + - msw + + vitest@4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.0.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)) @@ -14863,6 +14966,7 @@ snapshots: '@types/node': 22.20.1 '@vitest/browser-playwright': 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))(vitest@4.1.11) '@vitest/coverage-v8': 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) + happy-dom: 20.0.11 jsdom: 30.0.1 transitivePeerDependencies: - msw @@ -14889,6 +14993,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@5.0.0: {} whatwg-url@16.0.1: From dd7fc4eab6c41d26211f367b67435d453155838e Mon Sep 17 00:00:00 2001 From: timhilhorst Date: Thu, 3 Sep 2026 16:28:28 +0200 Subject: [PATCH 5/7] chore: rename nlds-components to components --- .../src/{nlds-components => components}/index.ts | 0 .../{nlds-components => components}/paragraph/constants.ts | 0 .../src/{nlds-components => components}/paragraph/index.ts | 0 .../paragraph/should-not-be-empty/index.test.ts | 0 .../paragraph/should-not-be-empty/index.ts | 0 .../paragraph/should-not-be-empty/messages.ts | 0 .../paragraph/should-not-be-entirely-bold/index.test.ts | 0 .../paragraph/should-not-be-entirely-bold/index.ts | 0 .../paragraph/should-not-be-entirely-bold/messages.ts | 0 packages/clippy-a11y-validator/src/index.ts | 4 ++-- packages/clippy-a11y-validator/src/validator.test.ts | 2 +- 11 files changed, 3 insertions(+), 3 deletions(-) rename packages/clippy-a11y-validator/src/{nlds-components => components}/index.ts (100%) rename packages/clippy-a11y-validator/src/{nlds-components => components}/paragraph/constants.ts (100%) rename packages/clippy-a11y-validator/src/{nlds-components => components}/paragraph/index.ts (100%) rename packages/clippy-a11y-validator/src/{nlds-components => components}/paragraph/should-not-be-empty/index.test.ts (100%) rename packages/clippy-a11y-validator/src/{nlds-components => components}/paragraph/should-not-be-empty/index.ts (100%) rename packages/clippy-a11y-validator/src/{nlds-components => components}/paragraph/should-not-be-empty/messages.ts (100%) rename packages/clippy-a11y-validator/src/{nlds-components => components}/paragraph/should-not-be-entirely-bold/index.test.ts (100%) rename packages/clippy-a11y-validator/src/{nlds-components => components}/paragraph/should-not-be-entirely-bold/index.ts (100%) rename packages/clippy-a11y-validator/src/{nlds-components => components}/paragraph/should-not-be-entirely-bold/messages.ts (100%) diff --git a/packages/clippy-a11y-validator/src/nlds-components/index.ts b/packages/clippy-a11y-validator/src/components/index.ts similarity index 100% rename from packages/clippy-a11y-validator/src/nlds-components/index.ts rename to packages/clippy-a11y-validator/src/components/index.ts diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/constants.ts b/packages/clippy-a11y-validator/src/components/paragraph/constants.ts similarity index 100% rename from packages/clippy-a11y-validator/src/nlds-components/paragraph/constants.ts rename to packages/clippy-a11y-validator/src/components/paragraph/constants.ts diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/index.ts b/packages/clippy-a11y-validator/src/components/paragraph/index.ts similarity index 100% rename from packages/clippy-a11y-validator/src/nlds-components/paragraph/index.ts rename to packages/clippy-a11y-validator/src/components/paragraph/index.ts diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/index.test.ts b/packages/clippy-a11y-validator/src/components/paragraph/should-not-be-empty/index.test.ts similarity index 100% rename from packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/index.test.ts rename to packages/clippy-a11y-validator/src/components/paragraph/should-not-be-empty/index.test.ts diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/index.ts b/packages/clippy-a11y-validator/src/components/paragraph/should-not-be-empty/index.ts similarity index 100% rename from packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/index.ts rename to packages/clippy-a11y-validator/src/components/paragraph/should-not-be-empty/index.ts diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/messages.ts b/packages/clippy-a11y-validator/src/components/paragraph/should-not-be-empty/messages.ts similarity index 100% rename from packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-empty/messages.ts rename to packages/clippy-a11y-validator/src/components/paragraph/should-not-be-empty/messages.ts diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.test.ts b/packages/clippy-a11y-validator/src/components/paragraph/should-not-be-entirely-bold/index.test.ts similarity index 100% rename from packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.test.ts rename to packages/clippy-a11y-validator/src/components/paragraph/should-not-be-entirely-bold/index.test.ts diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.ts b/packages/clippy-a11y-validator/src/components/paragraph/should-not-be-entirely-bold/index.ts similarity index 100% rename from packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/index.ts rename to packages/clippy-a11y-validator/src/components/paragraph/should-not-be-entirely-bold/index.ts diff --git a/packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/messages.ts b/packages/clippy-a11y-validator/src/components/paragraph/should-not-be-entirely-bold/messages.ts similarity index 100% rename from packages/clippy-a11y-validator/src/nlds-components/paragraph/should-not-be-entirely-bold/messages.ts rename to packages/clippy-a11y-validator/src/components/paragraph/should-not-be-entirely-bold/messages.ts diff --git a/packages/clippy-a11y-validator/src/index.ts b/packages/clippy-a11y-validator/src/index.ts index 8e23de9b..47d0155b 100644 --- a/packages/clippy-a11y-validator/src/index.ts +++ b/packages/clippy-a11y-validator/src/index.ts @@ -1,7 +1,7 @@ export { selectors, validationSeverity } from './consts/index.ts'; export { defineValidation } from './define-validation.ts'; -export { coreValidationRules, coreValidations } from './nlds-components/index.ts'; -export type { CoreValidationRule } from './nlds-components/index.ts'; +export { coreValidationRules, coreValidations } from './components/index.ts'; +export type { CoreValidationRule } from './components/index.ts'; export { hasTextContent, isNotEntirelyBold } from './conditions/index.ts'; export { Validator } from './validator.ts'; export type { ValidateOptions, ValidatorOptions } from './validator.ts'; diff --git a/packages/clippy-a11y-validator/src/validator.test.ts b/packages/clippy-a11y-validator/src/validator.test.ts index 21cbeac6..572f5d33 100644 --- a/packages/clippy-a11y-validator/src/validator.test.ts +++ b/packages/clippy-a11y-validator/src/validator.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { coreValidationRules, coreValidations } from './nlds-components/index.ts'; +import { coreValidationRules, coreValidations } from './components/index.ts'; import { Validator } from './validator.ts'; const { PARAGRAPH_SHOULD_NOT_BE_EMPTY, PARAGRAPH_SHOULD_NOT_BE_ENTIRELY_BOLD } = coreValidationRules; From 7e2c9f5341cd57fcb921a7c3cabe818fabc8fafb Mon Sep 17 00:00:00 2001 From: timhilhorst Date: Thu, 3 Sep 2026 16:33:33 +0200 Subject: [PATCH 6/7] chore: sonar --- packages/clippy-a11y-validator/src/utils/dom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clippy-a11y-validator/src/utils/dom.ts b/packages/clippy-a11y-validator/src/utils/dom.ts index 17a2d7dd..882b3ac3 100644 --- a/packages/clippy-a11y-validator/src/utils/dom.ts +++ b/packages/clippy-a11y-validator/src/utils/dom.ts @@ -16,5 +16,5 @@ export const unwrapElement = (element: Element): void => { const parent = element.parentNode; if (!parent) return; while (element.firstChild) parent.insertBefore(element.firstChild, element); - parent.removeChild(element); + element.remove(); }; From 5706afb11ace4058dba87df1029b2ede1e183585 Mon Sep 17 00:00:00 2001 From: timhilhorst Date: Thu, 3 Sep 2026 16:40:47 +0200 Subject: [PATCH 7/7] chore: flip test --- .../should-not-be-entirely-bold/index.ts | 5 +-- .../src/conditions/formatting.test.ts | 36 +++++++++---------- .../src/conditions/formatting.ts | 5 +-- .../src/conditions/index.ts | 2 +- packages/clippy-a11y-validator/src/index.ts | 2 +- 5 files changed, 24 insertions(+), 26 deletions(-) diff --git a/packages/clippy-a11y-validator/src/components/paragraph/should-not-be-entirely-bold/index.ts b/packages/clippy-a11y-validator/src/components/paragraph/should-not-be-entirely-bold/index.ts index 1409fe49..b6b5e3da 100644 --- a/packages/clippy-a11y-validator/src/components/paragraph/should-not-be-entirely-bold/index.ts +++ b/packages/clippy-a11y-validator/src/components/paragraph/should-not-be-entirely-bold/index.ts @@ -1,12 +1,13 @@ -import { isNotEntirelyBold } from '../../../conditions/index.ts'; +import { isEntirelyBold } from '../../../conditions/index.ts'; import { selectors, validationSeverity } from '../../../consts/index.ts'; import { defineValidation } from '../../../define-validation.ts'; +import { not } from '../../../utils/combinators.ts'; import { unwrapElement } from '../../../utils/dom.ts'; import { paragraphValidationRules } from '../constants.ts'; import { messages } from './messages.ts'; export const paragraphShouldNotBeEntirelyBold = defineValidation({ - condition: isNotEntirelyBold, + condition: not(isEntirelyBold), correct: (paragraph) => () => paragraph.querySelectorAll(selectors.BOLD).forEach(unwrapElement), messages, rule: paragraphValidationRules.PARAGRAPH_SHOULD_NOT_BE_ENTIRELY_BOLD, diff --git a/packages/clippy-a11y-validator/src/conditions/formatting.test.ts b/packages/clippy-a11y-validator/src/conditions/formatting.test.ts index fa7d79d6..0fad7a79 100644 --- a/packages/clippy-a11y-validator/src/conditions/formatting.test.ts +++ b/packages/clippy-a11y-validator/src/conditions/formatting.test.ts @@ -1,31 +1,31 @@ import { describe, expect, it } from 'vitest'; import { render } from '../test-helpers/render.ts'; -import { isNotEntirelyBold } from './formatting.ts'; +import { isEntirelyBold } from './formatting.ts'; -describe('isNotEntirelyBold', () => { - it('is false when all text sits inside bold elements', () => { - expect(isNotEntirelyBold(render('

a

'))).toBe(false); - expect(isNotEntirelyBold(render('

a

'))).toBe(false); - expect(isNotEntirelyBold(render('

a b

'))).toBe(false); +describe('isEntirelyBold', () => { + it('is true when all text sits inside bold elements', () => { + expect(isEntirelyBold(render('

a

'))).toBe(true); + expect(isEntirelyBold(render('

a

'))).toBe(true); + expect(isEntirelyBold(render('

a b

'))).toBe(true); }); - it('is false when the bold element sits inside another inline wrapper', () => { - expect(isNotEntirelyBold(render('

a

'))).toBe(false); - expect(isNotEntirelyBold(render('

a

'))).toBe(false); + it('is true when the bold element sits inside another inline wrapper', () => { + expect(isEntirelyBold(render('

a

'))).toBe(true); + expect(isEntirelyBold(render('

a

'))).toBe(true); }); - it('is true when text sits outside the bold elements', () => { - expect(isNotEntirelyBold(render('

a and more

'))).toBe(true); - expect(isNotEntirelyBold(render('

ab

'))).toBe(true); + it('is false when text sits outside the bold elements', () => { + expect(isEntirelyBold(render('

a and more

'))).toBe(false); + expect(isEntirelyBold(render('

ab

'))).toBe(false); }); - it('is true without visible text', () => { - expect(isNotEntirelyBold(render('

'))).toBe(true); - expect(isNotEntirelyBold(render('

'))).toBe(true); - expect(isNotEntirelyBold(render('

'))).toBe(true); + it('is false without visible text', () => { + expect(isEntirelyBold(render('

'))).toBe(false); + expect(isEntirelyBold(render('

'))).toBe(false); + expect(isEntirelyBold(render('

'))).toBe(false); }); - it('is true for plain text', () => { - expect(isNotEntirelyBold(render('

plain

'))).toBe(true); + it('is false for plain text', () => { + expect(isEntirelyBold(render('

plain

'))).toBe(false); }); }); diff --git a/packages/clippy-a11y-validator/src/conditions/formatting.ts b/packages/clippy-a11y-validator/src/conditions/formatting.ts index 009237b2..b8b1cc78 100644 --- a/packages/clippy-a11y-validator/src/conditions/formatting.ts +++ b/packages/clippy-a11y-validator/src/conditions/formatting.ts @@ -1,9 +1,8 @@ import type { ValidationCondition } from '../types/validation.ts'; import { selectors } from '../consts/selectors.ts'; -import { not } from '../utils/combinators.ts'; import { visibleTextNodes } from '../utils/dom.ts'; -const isEntirelyBold: ValidationCondition = (element) => { +export const isEntirelyBold: ValidationCondition = (element) => { const nodes = visibleTextNodes(element); if (nodes.length === 0) return false; @@ -12,5 +11,3 @@ const isEntirelyBold: ValidationCondition = (element) => { return bold !== null && bold !== undefined && element.contains(bold); }); }; - -export const isNotEntirelyBold: ValidationCondition = not(isEntirelyBold); diff --git a/packages/clippy-a11y-validator/src/conditions/index.ts b/packages/clippy-a11y-validator/src/conditions/index.ts index 53341060..0fdb9063 100644 --- a/packages/clippy-a11y-validator/src/conditions/index.ts +++ b/packages/clippy-a11y-validator/src/conditions/index.ts @@ -1,2 +1,2 @@ export { hasTextContent } from './content.ts'; -export { isNotEntirelyBold } from './formatting.ts'; +export { isEntirelyBold } from './formatting.ts'; diff --git a/packages/clippy-a11y-validator/src/index.ts b/packages/clippy-a11y-validator/src/index.ts index 47d0155b..183fbe62 100644 --- a/packages/clippy-a11y-validator/src/index.ts +++ b/packages/clippy-a11y-validator/src/index.ts @@ -2,7 +2,7 @@ export { selectors, validationSeverity } from './consts/index.ts'; export { defineValidation } from './define-validation.ts'; export { coreValidationRules, coreValidations } from './components/index.ts'; export type { CoreValidationRule } from './components/index.ts'; -export { hasTextContent, isNotEntirelyBold } from './conditions/index.ts'; +export { hasTextContent, isEntirelyBold } from './conditions/index.ts'; export { Validator } from './validator.ts'; export type { ValidateOptions, ValidatorOptions } from './validator.ts'; export type { Locale, ResolvedMessages, ValidationMessages, ValidationMessagesByLocale } from './types/messages.ts';