Files
reader/web/test/css-classes.test.ts
T
hectorqinandClaude Opus 5.5 20a85c33fe fix(web): restore the ⋯ menu row styles removed as dead CSS
a894bdd deleted .action-menu-item/-label/-icon/-check/-description and the
.sheet-list-icon/-check/-description rules as unused, but ActionMenu builds
those names as `${base}-item` (base 'action-menu' or 'sheet-list'), which the
dead-class scan did not recognise. Every ⋯ dropdown and its bottom-sheet form
lost their row layout, hover, danger colour and check mark. Both files are
restored to their state before that commit.

The class-usage test now covers every stylesheet (not only media) and
understands built names: `${expr}-suffix` with a literal prefix, and
`prefix-${expr}`. Library markup (Notyf, DPlayer) is exempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:47:19 +00:00

41 lines
2.3 KiB
TypeScript

import { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { expect, it } from 'vitest';
/**
* Every class a stylesheet names must be one the source can render.
*
* The media stylesheets grew by layering and replaced screens left their rules
* behind (~300 lines of them), and a rule for a class nothing uses is where the next
* change gets made, to no effect. The check has to know the two ways a class name is
* *built* rather than written, because getting that wrong deletes live styles — which
* is exactly what happened to the ⋯ menu rows (`${base}-item`, base 'action-menu' or
* 'sheet-list'):
*
* - `${expr}-suffix`: any class ending in `-suffix` whose prefix the source spells
* as a string literal;
* - `prefix-${expr}`: any class starting with `prefix-`.
*
* Library markup (Notyf, DPlayer) is theirs, not ours. A class inside :not() is matched
* by its absence, so it need not exist.
*/
const src = join(import.meta.dirname, '..', 'src');
const files = (dir: string): string[] => readdirSync(dir, { withFileTypes: true }).flatMap(entry =>
entry.isDirectory() ? files(join(dir, entry.name)) : [join(dir, entry.name)]);
it('styles only classes that the source renders', () => {
const code = files(src).filter(file => /\.tsx?$/.test(file)).map(file => readFileSync(file, 'utf8')).join('\n');
const builtSuffixes = new Set([...code.matchAll(/\$\{[\w.]+\}-([a-z][\w-]*)/g)].map(match => match[1]!));
const builtPrefixes = new Set([...code.matchAll(/[`'" ]([a-z][\w-]*-)\$\{/g)].map(match => match[1]!));
const literal = (text: string) => new RegExp(`['"\`]${text}['"\`]`).test(code);
const used = (cls: string) => code.includes(cls)
|| [...builtPrefixes].some(prefix => cls.startsWith(prefix))
|| [...builtSuffixes].some(suffix => cls.endsWith('-' + suffix) && literal(cls.slice(0, -suffix.length - 1)));
const unused = files(src).filter(file => file.endsWith('.css')).flatMap(file => {
const css = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/:not\([^)]*\)/g, '');
const names = new Set([...css.matchAll(/\.(-?[a-zA-Z_][\w-]*)/g)].map(match => match[1]!));
return [...names].filter(cls => !/^(dplayer|notyf)/.test(cls) && !used(cls)).map(cls => `${relative(src, file)}: .${cls}`);
});
expect(unused).toEqual([]);
});