textmachine/frontend/src/cssModules.test.ts

79 lines
3 KiB
TypeScript

import { basename, dirname, join } from 'node:path';
import { describe, expect, it } from 'vitest';
// Vite types a style module as { [key: string]: string }, so a typo in styles.someThing
// passes both the typecheck and the lint, and turns into className="undefined" — quietly and
// without a trace. Caught in live showcase code, so it is checked by a test and not by eye.
const modules = import.meta.glob('./**/*.module.css', {
query: '?raw',
eager: true,
import: 'default',
});
const sources = import.meta.glob('./**/*.{ts,tsx}', {
query: '?raw',
eager: true,
import: 'default',
});
// url(...) and string literals are thrown out before the search for classes: otherwise the file
// extension from url(./x.woff2) arrives into the "declared classes" and covers up a typo (Ф-10).
const classesOf = (css: string) =>
new Set(
css
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/url\([^)]*\)/g, '')
.replace(/"[^"]*"|'[^']*'/g, '')
.match(/\.[a-zA-Z][\w-]*/g)
?.map((name) => name.slice(1)),
);
// Pairs are built FROM THE IMPORTS and not from a match of file names: a component importing a
// neighbouring module silently fell out of the check, and in S2 there were several such pairs at
// once (Ф-10).
const importsOf = (source: string) =>
[...source.matchAll(/import\s+(\w+)\s+from\s+['"]([^'"]+\.module\.css)['"]/g)].map((match) => ({
binding: match[1] ?? '',
specifier: match[2] ?? '',
}));
// Three forms of addressing a module: styles.name · styles['name'] · const { name } = styles.
// Destructuring the previous check did not see at all.
const usedIn = (source: string, binding: string) => {
const direct = [
...source.matchAll(new RegExp(String.raw`\b${binding}(?:\.(\w+)|\[['"]([\w-]+)['"]\])`, 'g')),
].map((match) => match[1] ?? match[2] ?? '');
const destructured = [
...source.matchAll(new RegExp(String.raw`\{([^{}]*)\}\s*=\s*${binding}\b`, 'g')),
]
.flatMap((match) => (match[1] ?? '').split(','))
.map((part) => part.split(':')[0]?.trim() ?? '')
.filter((name) => /^\w+$/.test(name));
return [...direct, ...destructured];
};
const pairs = Object.entries(sources).flatMap(([file, source]) =>
importsOf(source).map(({ binding, specifier }) => ({
file,
module: `./${join(dirname(file), specifier)}`,
binding,
})),
);
describe('CSS Modules', () => {
it.each(pairs.map((pair) => [pair.file, pair.module, pair.binding] as const))(
'%s does not reference a class that does not exist in %s',
(file, module, binding) => {
expect(modules[module], `module ${module} not found`).toBeDefined();
const defined = classesOf(modules[module] as string);
const missing = usedIn(sources[file] as string, binding).filter((name) => !defined.has(name));
expect(missing, `not in ${basename(module)}`).toEqual([]);
},
);
it('every style module is imported by someone', () => {
const imported = new Set(pairs.map((pair) => pair.module));
const orphans = Object.keys(modules).filter((module) => !imported.has(module));
expect(orphans).toEqual([]);
});
});