79 lines
3 KiB
TypeScript
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([]);
|
|
});
|
|
});
|