Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions packages/clippy-a11y-validator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"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/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"types": "dist/index.d.ts",
"scripts": {
"build": "vite build",
"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",
"vitest": "4.1.11"
}
}
90 changes: 90 additions & 0 deletions packages/clippy-a11y-validator/scripts/validate-html.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

qua architectuur zou ik de CLI tool en de module die playwright dingen doet in losse TS files zetten. Dan is je Playwright dinges ook los testbaar en je CLI ook.

Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/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/index.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,
Comment thread
hilhorstt marked this conversation as resolved.
});
}

// 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 { coreValidations, Validator } = (await import(moduleUrl)) as typeof import('../src/index.ts');
URL.revokeObjectURL(moduleUrl);

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, message: messages.error, rule, severity };
});
},
{ fix: values['fix'], source: readFileSync(BUNDLE, 'utf8') },
);

console.log(`${url}\n`);

for (const { after, before, message, rule, severity } of findings) {
console.log(`${severity}: ${rule} — ${message}\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();
}
13 changes: 13 additions & 0 deletions packages/clippy-a11y-validator/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -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<CoreValidationRule, Validation>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// https://nldesignsystem.nl/paragraph
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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
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 type ParagraphValidationRule = keyof typeof paragraphValidationRules;

export const paragraphValidations = {
[paragraphValidationRules.PARAGRAPH_SHOULD_NOT_BE_EMPTY]: paragraphShouldNotBeEmpty,
[paragraphValidationRules.PARAGRAPH_SHOULD_NOT_BE_ENTIRELY_BOLD]: paragraphShouldNotBeEntirelyBold,
} satisfies Record<ParagraphValidationRule, Validation>;
Original file line number Diff line number Diff line change
@@ -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('<p></p>');

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('<p> </p>')).toHaveLength(1);
});

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

it('accepts a paragraph with text', () => {
expect(validate('<p>tekst</p>')).toHaveLength(0);
});

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

it('ignores elements that are not paragraphs', () => {
expect(validate('<div></div>')).toHaveLength(0);
});

it('offers no correction', () => {
expect(validate('<p></p>')[0]?.correct).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -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,
});
Original file line number Diff line number Diff line change
@@ -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.',
},
};
Original file line number Diff line number Diff line change
@@ -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('<p><strong>Alles dik</strong></p>');

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('<p><strong>Dik</strong> en gewoon</p>')).toHaveLength(0);
});

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

it('flags a paragraph whose bold text is wrapped in another inline element', () => {
expect(validate('<p><em><strong>Alles dik</strong></em></p>')).toHaveLength(1);
});

it('ignores elements that are not paragraphs', () => {
expect(validate('<div><strong>Alles dik</strong></div>')).toHaveLength(0);
});

it('unwraps the bold children when corrected', () => {
const [violation] = validate('<p><strong>Alles</strong> <b>dik</b></p>');
violation?.correct?.();

expect(root.querySelector('p')?.innerHTML).toBe('Alles dik');
expect(validator.validate(root)).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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: not(isEntirelyBold),
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,
});
Original file line number Diff line number Diff line change
@@ -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.',
},
},
};
4 changes: 4 additions & 0 deletions packages/clippy-a11y-validator/src/conditions/content.ts
Original file line number Diff line number Diff line change
@@ -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 ?? '');
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { render } from '../test-helpers/render.ts';
import { isEntirelyBold } from './formatting.ts';

describe('isEntirelyBold', () => {
it('is true when all text sits inside bold elements', () => {
expect(isEntirelyBold(render('<p><strong>a</strong></p>'))).toBe(true);
expect(isEntirelyBold(render('<p><b>a</b></p>'))).toBe(true);
expect(isEntirelyBold(render('<p><strong>a</strong> <b>b</b></p>'))).toBe(true);
});

it('is true when the bold element sits inside another inline wrapper', () => {
expect(isEntirelyBold(render('<p><em><strong>a</strong></em></p>'))).toBe(true);
expect(isEntirelyBold(render('<p><span class="x"><strong>a</strong></span></p>'))).toBe(true);
});

it('is false when text sits outside the bold elements', () => {
expect(isEntirelyBold(render('<p><strong>a</strong> and more</p>'))).toBe(false);
expect(isEntirelyBold(render('<p><strong>a</strong><em>b</em></p>'))).toBe(false);
});

it('is false without visible text', () => {
expect(isEntirelyBold(render('<p></p>'))).toBe(false);
expect(isEntirelyBold(render('<p> </p>'))).toBe(false);
expect(isEntirelyBold(render('<p><strong> </strong></p>'))).toBe(false);
});

it('is false for plain text', () => {
expect(isEntirelyBold(render('<p>plain</p>'))).toBe(false);
});
});
13 changes: 13 additions & 0 deletions packages/clippy-a11y-validator/src/conditions/formatting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ValidationCondition } from '../types/validation.ts';
import { selectors } from '../consts/selectors.ts';
import { visibleTextNodes } from '../utils/dom.ts';

export 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);
});
};
2 changes: 2 additions & 0 deletions packages/clippy-a11y-validator/src/conditions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { hasTextContent } from './content.ts';
export { isEntirelyBold } from './formatting.ts';
2 changes: 2 additions & 0 deletions packages/clippy-a11y-validator/src/consts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { selectors } from './selectors.ts';
export { validationSeverity } from './severity.ts';
4 changes: 4 additions & 0 deletions packages/clippy-a11y-validator/src/consts/selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const selectors = {
BOLD: 'b, strong',
PARAGRAPH: 'p',
} as const;
5 changes: 5 additions & 0 deletions packages/clippy-a11y-validator/src/consts/severity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const validationSeverity = {
ERROR: 'error',
INFO: 'info',
WARNING: 'warning',
} as const;
Loading