You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/recipe-css-contract.test.ts

141 lines
4.4 KiB

import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { THEME_BASE_RECIPE_TOKENS } from './lib/recipes/base'
const COMPONENTS_DIR = 'src/uix/eidos/components'
const CSS_CUSTOM_PROPERTY = /--([a-z][a-z0-9]*-[a-z0-9-]+)/g
const COMPONENT_SOURCE_FILE = /\.(css|svelte|ts)$/
const RAW_COLOR_LITERAL = /#[0-9a-f]{3,8}\b|\b(?:rgb|rgba|hsl|hsla)\(/i
const RAW_FONT_SIZE_LITERAL = /font-size\s*:\s*[0-9.]+(?:px|rem)\b/i
const RECIPE_TOKENS = THEME_BASE_RECIPE_TOKENS as Readonly<
Record<string, Readonly<Record<string, string>>>
>
function stripCssComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, '')
}
function recipeTokenName(component: string, key: string): string {
return `--${component}-${key}`
}
function readComponentCssFiles(): readonly { component: string; css: string }[] {
return readdirSync(COMPONENTS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => {
const file = join(COMPONENTS_DIR, entry.name, `${entry.name}.css`)
return { component: entry.name, file }
})
.filter(({ file }) => existsSync(file))
.map(({ component, file }) => ({
component,
css: stripCssComments(readFileSync(file, 'utf8'))
}))
}
function readTextFilesRecursive(dir: string): readonly string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name)
if (entry.isDirectory()) return readTextFilesRecursive(path)
if (!COMPONENT_SOURCE_FILE.test(entry.name)) return []
return [readFileSync(path, 'utf8')]
})
}
function readComponentSource(component: string): string {
const dir = join(COMPONENTS_DIR, component)
return existsSync(dir) ? readTextFilesRecursive(dir).join('\n') : ''
}
function readRecipeValueReferences(): string {
return Object.values(RECIPE_TOKENS)
.flatMap((tokens) => Object.values(tokens))
.join('\n')
}
function collectOwnPublicVariables(component: string, css: string): readonly string[] {
const ownPrefix = `--${component}-`
return [
...new Set(
[...css.matchAll(CSS_CUSTOM_PROPERTY)]
.map((match) => `--${match[1]}`)
.filter((name) => name.startsWith(ownPrefix))
)
].sort()
}
describe('Eidos recipe CSS contract', () => {
it('declares every public component variable consumed by component CSS', () => {
const missing: string[] = []
for (const { component, css } of readComponentCssFiles()) {
const declared = new Set(
Object.keys(RECIPE_TOKENS[component] ?? {}).map((key) =>
recipeTokenName(component, key)
)
)
for (const name of collectOwnPublicVariables(component, css)) {
if (!declared.has(name)) missing.push(`${component}: ${name}`)
}
}
expect(missing).toEqual([])
})
it('does not declare recipes for missing component CSS files', () => {
const cssComponents = new Set(
readComponentCssFiles().map(({ component }) => component)
)
const extra = Object.keys(THEME_BASE_RECIPE_TOKENS)
.filter((component) => !cssComponents.has(component))
expect(extra).toEqual([])
})
it('imports every component CSS recipe from the Eidos entrypoint', () => {
const indexCss = readFileSync('src/uix/eidos/index.css', 'utf8')
const missingImports = readComponentCssFiles()
.map(({ component }) => `./components/${component}/${component}.css`)
.filter((path) => !indexCss.includes(path))
expect(missingImports).toEqual([])
})
it('keeps raw color values out of component CSS', () => {
const violations = readComponentCssFiles()
.filter(({ css }) => RAW_COLOR_LITERAL.test(css))
.map(({ component }) => component)
expect(violations).toEqual([])
})
it('keeps raw font-size literals out of component CSS', () => {
const violations = readComponentCssFiles()
.filter(({ css }) => RAW_FONT_SIZE_LITERAL.test(css))
.map(({ component }) => component)
expect(violations).toEqual([])
})
it('does not leave declared public recipe variables orphaned', () => {
const recipeValueReferences = readRecipeValueReferences()
const orphaned: string[] = []
for (const [component, tokens] of Object.entries(RECIPE_TOKENS)) {
const source = `${readComponentSource(component)}\n${recipeValueReferences}`
for (const key of Object.keys(tokens)) {
const name = recipeTokenName(component, key)
if (!source.includes(name)) orphaned.push(`${component}: ${name}`)
}
}
expect(orphaned).toEqual([])
})
})

Powered by TurnKey Linux.