78 lines
3.5 KiB
TypeScript
78 lines
3.5 KiB
TypeScript
import { basename, dirname, join } from 'node:path';
|
||
|
||
import { describe, expect, it } from 'vitest';
|
||
|
||
// Vite типизирует модуль стилей как { [key: string]: string }, поэтому опечатка в styles.чтоТо
|
||
// проходит и тайпчек, и линт, и превращается в className="undefined" — тихо и без следа.
|
||
// Поймано на живом коде витрины, поэтому проверяется тестом, а не глазами.
|
||
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(...) и строковые литералы выкидываются до поиска классов: иначе расширение файла из
|
||
// url(./x.woff2) приезжает в «объявленные классы» и прикрывает собой опечатку (Ф-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)),
|
||
);
|
||
|
||
// Пары строятся ПО ИМПОРТАМ, а не по совпадению имён файлов: компонент, импортирующий соседний
|
||
// модуль, выпадал из проверки молча, и таких пар в S2 сразу несколько (Ф-10).
|
||
const importsOf = (source: string) =>
|
||
[...source.matchAll(/import\s+(\w+)\s+from\s+['"]([^'"]+\.module\.css)['"]/g)].map((match) => ({
|
||
binding: match[1] ?? '',
|
||
specifier: match[2] ?? '',
|
||
}));
|
||
|
||
// Три формы обращения к модулю: styles.name · styles['name'] · const { name } = styles.
|
||
// Деструктуризацию прежняя проверка не видела вовсе.
|
||
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 не ссылается на несуществующий класс из %s',
|
||
(file, module, binding) => {
|
||
expect(modules[module], `модуль ${module} не найден`).toBeDefined();
|
||
const defined = classesOf(modules[module] as string);
|
||
const missing = usedIn(sources[file] as string, binding).filter((name) => !defined.has(name));
|
||
expect(missing, `нет в ${basename(module)}`).toEqual([]);
|
||
},
|
||
);
|
||
|
||
it('каждый модуль стилей кем-то импортирован', () => {
|
||
const imported = new Set(pairs.map((pair) => pair.module));
|
||
const orphans = Object.keys(modules).filter((module) => !imported.has(module));
|
||
expect(orphans).toEqual([]);
|
||
});
|
||
});
|