@dnb/eufemia
Version:
DNB Eufemia Design System UI Library
279 lines (238 loc) • 8.99 kB
JavaScript
const stylelint = require('stylelint')
const fs = require('fs')
const path = require('path')
const RULE_NAME = 'eufemia/no-undefined-custom-property'
// Resolve the dnb-eufemia "src" root relative to this rule file
// (src/plugins/stylelint/rules/ -> src).
const SRC_ROOT = path.resolve(__dirname, '../../..')
const messages = stylelint.utils.ruleMessages(RULE_NAME, {
undefined: (prop) =>
`Unexpected reference to undefined custom property "${prop}". ` +
`It is referenced via "var(${prop})" without a fallback but is never ` +
`defined in any SCSS file nor set via JavaScript/TypeScript. A var() ` +
`pointing at an undefined custom property resolves to a ` +
`guaranteed-invalid value, which silently breaks the declaration. ` +
`Define the property, add a fallback ("var(${prop}, <fallback>)"), or ` +
`remove the reference.`,
undefinedWithFallback: (prop) =>
`Unexpected reference to undefined custom property "${prop}". ` +
`It is referenced via "var(${prop}, <fallback>)" but is never defined ` +
`in any SCSS file nor set via JavaScript/TypeScript, so it always ` +
`resolves to the fallback \u2013 a "phantom" reference. Reference the ` +
`real variable or value directly, define the property, or add it to ` +
`"ignoreProperties" if it is an intentional override hook or is set at ` +
`runtime with a dynamically built name.`,
})
const meta = {
url: 'https://github.com/dnbexperience/eufemia',
}
// Matches a custom property *definition*, e.g. "--foo:" but not the "--foo"
// inside "var(--foo)". The leading boundary avoids matching inside words.
const SCSS_DEFINITION = /(?:^|[^\w-])(--[\w-]+)\s*:/g
// Matches any custom-property-looking token. Used to harvest properties that
// are defined dynamically from JavaScript/TypeScript (e.g. element.style
// .setProperty('--foo', …) or inline style strings like "--foo: 1rem").
const ANY_CUSTOM_PROPERTY = /--[\w-]+/g
// Matches a var() reference *without* a fallback, e.g. "var(--foo)".
// References that include a fallback ("var(--foo, 0)") or SCSS interpolation
// ("var(--foo-#{$x})") intentionally do not match and are therefore skipped.
const VAR_WITHOUT_FALLBACK = /var\(\s*(--[\w-]+)\s*\)/g
// Matches a var() reference *with* a fallback and captures the primary
// property, e.g. "var(--foo, red)" or "var(--foo, var(--bar))" -> "--foo".
// Used only when the "reportFallbacks" option is enabled, to catch "phantom"
// references whose primary property is never defined and therefore always
// resolve to the fallback. Interpolated names ("var(--foo-#{$x}, …)") do not
// match and are therefore skipped.
const VAR_WITH_FALLBACK = /var\(\s*(--[\w-]+)\s*,/g
const IGNORED_DIRECTORIES = new Set([
'node_modules',
'__tests__',
'__snapshots__',
'build',
])
// Cache the expensive filesystem scan per source root so it runs only once
// per stylelint process, no matter how many files are linted.
const knownPropertiesCache = new Map()
const walkFiles = (dir, extensions, onFile) => {
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (
IGNORED_DIRECTORIES.has(entry.name) ||
entry.name.startsWith('.')
) {
continue
}
walkFiles(fullPath, extensions, onFile)
} else if (extensions.some((ext) => entry.name.endsWith(ext))) {
onFile(fullPath)
}
}
}
const collectMatches = (content, regex, group, target) => {
let match
while ((match = regex.exec(content)) !== null) {
target.add(group === 0 ? match[0] : match[group])
}
}
// Read and scan files asynchronously, yielding to the event loop between
// files. The scan touches the entire "src" tree (thousands of files), so doing
// it synchronously would block long enough to trip worker heartbeats in some
// test runners; an async pass keeps the process responsive.
const scanFiles = async (files, regex, group, target) => {
for (const file of files) {
let content
try {
content = await fs.promises.readFile(file, 'utf8')
} catch {
continue
}
collectMatches(content, regex, group, target)
}
}
const buildKnownProperties = (srcRoot) => {
if (knownPropertiesCache.has(srcRoot)) {
return knownPropertiesCache.get(srcRoot)
}
const scan = (async () => {
const properties = new Set()
// Definitions declared anywhere in SCSS/CSS (cross-file, including theme
// and shared property files).
const styleFiles = []
walkFiles(srcRoot, ['.scss', '.css'], (file) => styleFiles.push(file))
await scanFiles(styleFiles, SCSS_DEFINITION, 1, properties)
// Properties referenced from JavaScript/TypeScript. These are treated as
// "known" because they may be assigned dynamically at runtime (setProperty,
// inline style strings, etc.), which a static SCSS scan cannot see.
// Properties whose *names* are themselves built dynamically (e.g. via
// template literals like `--${name}--${size}`) cannot be discovered this
// way and should be declared through the "ignoreProperties" option instead.
const scriptFiles = []
walkFiles(
srcRoot,
['.ts', '.tsx', '.js', '.jsx', '.cjs', '.mjs'],
(file) => scriptFiles.push(file)
)
await scanFiles(scriptFiles, ANY_CUSTOM_PROPERTY, 0, properties)
return properties
})()
// Cache the in-flight promise so concurrent lints share a single scan.
knownPropertiesCache.set(srcRoot, scan)
return scan
}
const collectLocalDefinitions = (root) => {
const local = new Set()
root.walkDecls((decl) => {
if (decl.prop && decl.prop.startsWith('--')) {
local.add(decl.prop)
}
})
return local
}
const ruleFunction = (primary, secondaryOptions) => {
return async (root, result) => {
const validOptions = stylelint.utils.validateOptions(
result,
RULE_NAME,
{
actual: primary,
possible: [true, false],
},
{
actual: secondaryOptions,
possible: {
ignoreProperties: [(value) => typeof value === 'string'],
reportFallbacks: [true, false],
},
optional: true,
}
)
if (!validOptions || !primary) {
return
}
// ignoreProperties entries may be exact names ("--foo") or regular
// expressions written in "/pattern/" form (e.g. "/^--breakout--/") to
// ignore whole families of dynamically generated properties.
const ignoreExact = new Set()
const ignorePatterns = []
for (const entry of (secondaryOptions &&
secondaryOptions.ignoreProperties) ||
[]) {
const regexMatch = /^\/(.*)\/([a-z]*)$/.exec(entry)
if (regexMatch) {
try {
ignorePatterns.push(new RegExp(regexMatch[1], regexMatch[2]))
} catch (error) {
result.warn(
`Invalid "ignoreProperties" regex "${entry}" for ${RULE_NAME}: ${error.message}`,
{ stylelintType: 'invalidOption' }
)
}
} else {
ignoreExact.add(entry)
}
}
// When enabled, also flag "phantom" references – var() with a fallback
// whose primary property is never defined, so the fallback always wins.
const reportFallbacks = Boolean(
secondaryOptions && secondaryOptions.reportFallbacks
)
const knownProperties = await buildKnownProperties(SRC_ROOT)
const localDefinitions = collectLocalDefinitions(root)
const isKnown = (property) =>
knownProperties.has(property) ||
localDefinitions.has(property) ||
ignoreExact.has(property) ||
ignorePatterns.some((pattern) => pattern.test(property))
root.walkDecls((decl) => {
if (!decl.value || !decl.value.includes('var(')) {
return
}
let match
while ((match = VAR_WITHOUT_FALLBACK.exec(decl.value)) !== null) {
const property = match[1]
if (isKnown(property)) {
continue
}
stylelint.utils.report({
message: messages.undefined(property),
node: decl,
word: property,
result,
ruleName: RULE_NAME,
})
}
if (!reportFallbacks) {
return
}
let fallbackMatch
while (
(fallbackMatch = VAR_WITH_FALLBACK.exec(decl.value)) !== null
) {
const property = fallbackMatch[1]
if (isKnown(property)) {
continue
}
stylelint.utils.report({
message: messages.undefinedWithFallback(property),
node: decl,
word: property,
result,
ruleName: RULE_NAME,
})
}
})
}
}
ruleFunction.ruleName = RULE_NAME
ruleFunction.messages = messages
ruleFunction.meta = meta
module.exports = stylelint.createPlugin(RULE_NAME, ruleFunction)
module.exports.ruleName = RULE_NAME
module.exports.messages = messages