Fail the lint on css that reads an undefined custom property (#2926)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-01 23:31:51 +02:00
1 parent e1ecd39a3c
commit 2662dcd0ee
3 files changed
+158 -1

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
// Fails when the css references a custom property that nothing defines.
//
// `color: var(--tblr-nav-link-active-color)` where that name is never declared
// is invalid at computed-value time: the browser drops the whole declaration and
// the element falls back to the inherited or initial value. Nothing reports it —
// not sass, not stylelint, not the browser console — so it renders wrong and
// quietly. Three shipped bugs came from exactly this: the button focus ring
// (#2662), the active nav link (#2287) and the disabled button border.
//
// A `var()` with a fallback cannot break, so those references are not checked.
//
// The scan compiles the same scss entry points the build does, so it needs no
// dist/ and sees what actually ships. Names that stay unprefixed belong to
// third-party stylesheets that declare them themselves (see cssVarIgnore), so
// only `--tblr-` names are checked.
//
// Known offenders are listed in css-vars-baseline.txt. Anything not on that list
// fails the check; so does a name on the list that no longer dangles, which
// keeps the file from going stale — delete the line when you fix one.
//
// Usage: tsx .build/check-css-vars.ts
/// <reference path="./modules.d.ts" />
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { compile as compileSass } from 'sass'
import postcss from 'postcss'
import prefixCustomProperties from 'postcss-prefix-custom-properties'
import { cssVarIgnore, cssVarPrefix, inlineValueComments } from './css-var-prefix'
const scssDir = 'core/scss'
const baselineFile = '.build/css-vars-baseline.txt'
const definition = /(--[\w-]+)\s*:/g
const reference = /var\(\s*(--[\w-]+)\s*([,)])/g
const registration = /@property\s+(--[\w-]+)/g
const defined = new Set<string>()
// name → stylesheets referencing it without a fallback
const referenced = new Map<string, Set<string>>()
async function scan(entry: string) {
const compiled = compileSass(join(scssDir, entry), { loadPaths: ['node_modules'], style: 'expanded' })
const { css } = await postcss([inlineValueComments, prefixCustomProperties({ prefix: cssVarPrefix, ignore: cssVarIgnore })]).process(compiled.css, { from: undefined, map: false })
const file = entry.replace('.scss', '.css')
for (const [, name] of css.matchAll(definition)) defined.add(name)
for (const [, name] of css.matchAll(registration)) defined.add(name)
for (const [, name, next] of css.matchAll(reference)) {
if (next !== ')') continue
if (!name.startsWith(`--${cssVarPrefix}`)) continue
const files = referenced.get(name) ?? new Set<string>()
files.add(file)
referenced.set(name, files)
}
}
async function main() {
const entries = readdirSync(scssDir).filter((file) => file.endsWith('.scss') && !file.startsWith('_'))
for (const entry of entries) await scan(entry)
const dangling = new Map([...referenced].filter(([name]) => !defined.has(name)).sort())
const baseline = readFileSync(baselineFile, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('--'))
const added = [...dangling.keys()].filter((name) => !baseline.includes(name))
const fixed = baseline.filter((name) => !dangling.has(name))
for (const name of added) {
console.error(`✗ ${name} — referenced by ${[...dangling.get(name)!].join(', ')}, defined nowhere`)
}
if (added.length > 0) {
console.error(`\n${added.length} new dangling custom ${added.length === 1 ? 'property' : 'properties'}. Define the property, give the var() a fallback, or drop the declaration.`)
}
if (fixed.length > 0) {
console.error(`\n${fixed.join(', ')} no longer dangling — delete ${fixed.length === 1 ? 'that line' : 'those lines'} from ${baselineFile}.`)
}
if (added.length > 0 || fixed.length > 0) process.exit(1)
console.log(`OK — ${referenced.size} custom properties referenced without a fallback, all defined (${baseline.length} known exceptions in ${baselineFile}).`)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
+69
View File
@@ -0,0 +1,69 @@
# Custom properties the css references but never defines. Each one makes the
# declaration that reads it invalid, so the browser drops it - see
# check-css-vars.ts. The check fails on any name that is not listed here, and
# also on a listed name that no longer dangles, so fixing one means deleting
# its line.
#
# Everything below is a real bug waiting to be fixed, not an accepted exception.
# `.list-group-item-{color}` reads subtle/emphasis tokens that bootstrap only
# builds for $theme-colors, so these variants render with no background and no
# text color at all.
--tblr-azure-bg-subtle
--tblr-azure-border-subtle
--tblr-azure-text-emphasis
--tblr-blue-bg-subtle
--tblr-blue-border-subtle
--tblr-blue-text-emphasis
--tblr-cyan-bg-subtle
--tblr-cyan-border-subtle
--tblr-cyan-text-emphasis
--tblr-green-bg-subtle
--tblr-green-border-subtle
--tblr-green-text-emphasis
--tblr-indigo-bg-subtle
--tblr-indigo-border-subtle
--tblr-indigo-text-emphasis
--tblr-lime-bg-subtle
--tblr-lime-border-subtle
--tblr-lime-text-emphasis
--tblr-muted-bg-subtle
--tblr-muted-border-subtle
--tblr-muted-text-emphasis
--tblr-orange-bg-subtle
--tblr-orange-border-subtle
--tblr-orange-text-emphasis
--tblr-pink-bg-subtle
--tblr-pink-border-subtle
--tblr-pink-text-emphasis
--tblr-purple-bg-subtle
--tblr-purple-border-subtle
--tblr-purple-text-emphasis
--tblr-red-bg-subtle
--tblr-red-border-subtle
--tblr-red-text-emphasis
--tblr-teal-bg-subtle
--tblr-teal-border-subtle
--tblr-teal-text-emphasis
--tblr-yellow-bg-subtle
--tblr-yellow-border-subtle
--tblr-yellow-text-emphasis
# tabler-marketing.css: `.shape-*` backgrounds, borders and headings reach for
# tokens that only ever existed in the marketing prototype.
--tblr-black-lt
--tblr-border-color-dark
--tblr-border-color-light
--tblr-border-radius-circle
--tblr-font-weight-black
--tblr-gray-dark-lt
--tblr-gray-lt
# `body { text-align }` - bootstrap leaves the token unset by default, so the
# declaration never applies.
--tblr-body-text-align
# `.nav-link` font size and the active nav link color (#2287): both drop, so a
# nav link takes the body font size and keeps the inherited color when active.
--tblr-nav-link-active-color
--tblr-nav-link-font-size
+2 -1
View File
@@ -20,7 +20,8 @@
"import-payments": "tsx .build/import-payments.ts",
"generate-tokens:check": "tsx .build/generate-tokens.ts check",
"check-docs-links": "tsx .build/check-docs-links.ts",
"lint": "pnpm run lint-md && pnpm run check-docs-links && pnpm run lint-prettier && pnpm run lint-scss-vars && pnpm run generate-tokens:check",
"check-css-vars": "tsx .build/check-css-vars.ts",
"lint": "pnpm run lint-md && pnpm run check-docs-links && pnpm run lint-prettier && pnpm run lint-scss-vars && pnpm run check-css-vars && pnpm run generate-tokens:check",
"lint:fix": "(pnpm run lint-md-fix || true) && (pnpm run lint:scss:fix || true) && (pnpm run format-prettier || true)",
"lint:scss": "pnpm --filter @tabler/core lint:scss",
"lint:scss:fix": "pnpm --filter @tabler/core lint:scss:fix",