@dnb/eufemia
Version:
DNB Eufemia Design System UI Library
599 lines (531 loc) • 18.9 kB
JavaScript
const stylelint = require('stylelint')
const fs = require('fs')
const path = require('path')
const DEFAULT_POLICY = {
// Validates the 1st-segment prefix in declarations and references.
tokenPrefix: '--token-',
// Validates the 2nd-segment in --token-<segment>-... to avoid naming drift.
allowedTokenCategories: ['color', 'radius'],
// Validates the 3rd segment in --token-color-<segment>-...
allowedTokenColorCategories: [
'background',
'text',
'icon',
'stroke',
'decorative',
'component',
],
// Enables 4th-segment checks for --token-color-<category>-<semantic>-...
categoriesWithSemanticValidation: [
'background',
'text',
'icon',
'stroke',
],
// Validates 4th-segment values for categories using semantic validation.
allowedTokenColorSemantics: [
'action',
'neutral',
'destructive',
'marketing',
'selected',
'info',
'positive',
'warning',
'error',
'page',
],
// Map theme folder names to their required foundation variable prefix.
themePrefixes: {
ui: 'dnb',
sbanken: 'sbanken',
carnegie: 'carnegie',
},
// Prevent temporary in-progress tokens from being committed as stable API.
disallowedSuffixes: ['-wip'],
// Token filename used when discovering brands dynamically.
tokenFileName: 'tokens.scss',
// Root directory that contains one subfolder per brand theme.
themesDirRelativePath: 'src/style/themes',
// Allowed foundation-like prefixes that are intentionally used outside tokens.scss.
allowedFoundationReferencePrefixes: [
'--dnb-payment-',
'--dnb-forms-',
'--dnb-sidebar-',
],
// Prevent Sass-style rgba(#hex, alpha) and require CSS channel-based rgba/rgb syntax.
disallowSassHexRgba: true,
}
const RULE_NAME = 'eufemia/token-name-policy'
const PROJECT_ROOT = path.resolve(__dirname, '../../../..')
const THEME_FROM_PATH_REGEX = /\/style\/themes\/([^/]+)\//
const TOKEN_DECLARATION_PATTERN = /(^|\s)(--token-[a-z0-9-]+)\s*:/gim
const FOUNDATION_REFERENCE_REGEX =
/var\(\s*(--(?:dnb|sbanken|carnegie)-[a-z0-9-]+)\s*\)/gi
const TOKEN_REFERENCE_REGEX = /var\(\s*(--token-[a-z0-9-]+)\s*\)/gi
const GENERIC_VAR_REFERENCE_REGEX = /var\(\s*(--[a-z0-9-]+)\s*\)/gi
const SINGLE_VAR_REFERENCE_REGEX = /var\(\s*(--[a-z0-9-]+)\s*\)/i
const SINGLE_TOKEN_REFERENCE_REGEX = /var\(\s*(--token-[a-z0-9-]+)\s*\)/i
const SASS_HEX_RGBA_REGEX = /rgba\(\s*#/i
const escapeRegExp = (value) => {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
const createTokenDeclarationPattern = (tokenPrefix) => {
return new RegExp(
`(^|\\s)(${escapeRegExp(tokenPrefix)}[a-z0-9-]+)\\s*:`,
'gim'
)
}
const createTokenReferenceRegex = (tokenPrefix, flags = 'gi') => {
return new RegExp(
`var\\(\\s*(${escapeRegExp(tokenPrefix)}[a-z0-9-]+)\\s*\\)`,
flags
)
}
const messages = stylelint.utils.ruleMessages(RULE_NAME, {
disallowedSuffix: (prop, suffix) =>
`Unexpected token variable "${prop}". Suffix "${suffix}" is not allowed.`,
wrongPrefix: (prop, expectedPrefix, theme) =>
`Unexpected token variable "${prop}" in theme "${theme}". Expected prefix "--${expectedPrefix}-".`,
wrongReferencePrefix: (ref, expectedPrefix, theme) =>
`Unexpected token reference "${ref}" in theme "${theme}". Expected prefix "--${expectedPrefix}-".`,
wrongTokenDeclarationPrefix: (prop, tokenPrefix) =>
`Unexpected token variable "${prop}" in tokens file. Expected prefix "${tokenPrefix}".`,
wrongTokenCategory: (prop, categories) =>
`Unexpected token variable "${prop}" in tokens file. Expected "--token-" followed by one of: ${categories.join(
', '
)}.`,
wrongTokenColorCategory: (prop, categories) =>
`Unexpected token variable "${prop}" in tokens file. Expected "--token-color-" followed by one of: ${categories.join(
', '
)}.`,
wrongTokenColorSemantic: (prop, semantics) =>
`Unexpected token variable "${prop}" in tokens file. Expected the 4th segment to be one of: ${semantics.join(
', '
)}.`,
forbiddenFoundationReferenceOutsideTokens: (ref) =>
`Unexpected foundation token reference "${ref}" outside tokens.scss. Foundation variables may only be used in tokens.scss.`,
unknownTokenReferenceOutsideTokens: (ref) =>
`Unexpected token reference "${ref}" outside tokens.scss. The token was not found in theme token files.`,
missingTokenAcrossBrands: (prop, theme) =>
`Missing token "${prop}" in "${theme}" tokens.scss. All brand tokens.scss files must contain the same tokens.`,
sassHexRgba: (value) =>
`Unexpected Sass rgba hex usage "${value}". Use CSS channel notation, e.g. rgba(0 0 0 / 40%).`,
tokenInGlobalScope: (ref, selector) =>
`Unexpected token reference "${ref}" inside "${selector}". Design tokens must not be used in :root, html, or body selectors, in order to support scoped dark mode.`,
})
const normalizePath = (filePath) => {
return (filePath || '').replace(/\\/g, '/')
}
const getThemeFromPath = (filePath) => {
const normalizedPath = normalizePath(filePath)
const match = normalizedPath.match(THEME_FROM_PATH_REGEX)
return match?.[1] || null
}
const isFoundationFile = (filePath) => {
const normalizedPath = normalizePath(filePath)
return normalizedPath.endsWith('/foundation.scss')
}
const isTokensFile = (filePath) => {
const normalizedPath = normalizePath(filePath)
return (
normalizedPath.endsWith('/tokens.scss') ||
normalizedPath.endsWith('/tokens-dark.scss')
)
}
const resolveTokenFilePaths = ({
projectRoot = PROJECT_ROOT,
tokenFiles,
} = {}) => {
if (tokenFiles?.length) {
return tokenFiles
.map((filePath) =>
path.isAbsolute(filePath)
? filePath
: path.resolve(projectRoot, filePath)
)
.filter((filePath) => fs.existsSync(filePath))
}
const themesDir = path.resolve(
projectRoot,
DEFAULT_POLICY.themesDirRelativePath
)
if (!fs.existsSync(themesDir)) {
return []
}
return fs
.readdirSync(themesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) =>
path.join(themesDir, entry.name, DEFAULT_POLICY.tokenFileName)
)
.filter((filePath) => fs.existsSync(filePath))
}
const extractTokenDeclarations = (
content,
tokenPrefix = DEFAULT_POLICY.tokenPrefix
) => {
const declarations = new Set()
const declarationRegex =
tokenPrefix === DEFAULT_POLICY.tokenPrefix
? new RegExp(TOKEN_DECLARATION_PATTERN)
: createTokenDeclarationPattern(tokenPrefix)
let match
while ((match = declarationRegex.exec(content)) !== null) {
const variable = match[2]
if (variable) {
declarations.add(variable)
}
}
return declarations
}
const loadKnownTokenVariables = ({
projectRoot = PROJECT_ROOT,
tokenFiles,
tokenPrefix = DEFAULT_POLICY.tokenPrefix,
} = {}) => {
const knownVariables = new Set()
const resolvedTokenFiles = resolveTokenFilePaths({
projectRoot,
tokenFiles,
})
for (const absolutePath of resolvedTokenFiles) {
const fileContent = fs.readFileSync(absolutePath, 'utf-8')
for (const variable of extractTokenDeclarations(
fileContent,
tokenPrefix
)) {
knownVariables.add(variable)
}
}
return {
knownVariables,
hasTokenFiles: resolvedTokenFiles.length > 0,
}
}
const loadTokenVariablesByFile = ({
projectRoot = PROJECT_ROOT,
tokenFiles,
tokenPrefix = DEFAULT_POLICY.tokenPrefix,
} = {}) => {
const variablesByFile = new Map()
const resolvedTokenFiles = resolveTokenFilePaths({
projectRoot,
tokenFiles,
})
for (const absolutePath of resolvedTokenFiles) {
const fileContent = fs.readFileSync(absolutePath, 'utf-8')
variablesByFile.set(
absolutePath,
extractTokenDeclarations(fileContent, tokenPrefix)
)
}
return variablesByFile
}
const ruleFunction = (primary, secondaryOptions = {}) => {
return (root, result) => {
const validOptions = stylelint.utils.validateOptions(
result,
RULE_NAME,
{
actual: primary,
possible: [true],
}
)
if (!validOptions) {
return
}
const disallowedSuffixes =
secondaryOptions.disallowedSuffixes ||
DEFAULT_POLICY.disallowedSuffixes
const tokenPrefix =
secondaryOptions.tokenPrefix || DEFAULT_POLICY.tokenPrefix
const allowedTokenCategories =
secondaryOptions.allowedTokenCategories ||
DEFAULT_POLICY.allowedTokenCategories
const allowedTokenColorCategories =
secondaryOptions.allowedTokenColorCategories ||
DEFAULT_POLICY.allowedTokenColorCategories
const categoriesWithSemanticValidation =
secondaryOptions.categoriesWithSemanticValidation ||
DEFAULT_POLICY.categoriesWithSemanticValidation
const allowedTokenColorSemantics =
secondaryOptions.allowedTokenColorSemantics ||
DEFAULT_POLICY.allowedTokenColorSemantics
const allowedFoundationReferencePrefixes =
secondaryOptions.allowedFoundationReferencePrefixes ||
DEFAULT_POLICY.allowedFoundationReferencePrefixes
const disallowSassHexRgba =
secondaryOptions.disallowSassHexRgba ??
DEFAULT_POLICY.disallowSassHexRgba
const themePrefixes = {
...DEFAULT_POLICY.themePrefixes,
...(secondaryOptions.themePrefixes || {}),
}
const { knownVariables, hasTokenFiles } = loadKnownTokenVariables({
projectRoot: secondaryOptions.projectRoot,
tokenFiles: secondaryOptions.tokenFiles,
tokenPrefix,
})
const tokenVariablesByFile = loadTokenVariablesByFile({
projectRoot: secondaryOptions.projectRoot,
tokenFiles: secondaryOptions.tokenFiles,
tokenPrefix,
})
const filePath = root.source?.input?.file || ''
const theme = getThemeFromPath(filePath)
const expectedPrefix = theme ? themePrefixes[theme] : null
const shouldValidateThemePrefix =
Boolean(expectedPrefix) && isFoundationFile(filePath)
const shouldValidateTokenReferences =
Boolean(expectedPrefix) && isTokensFile(filePath)
const shouldValidateTokenDeclarationPrefix = isTokensFile(filePath)
const shouldValidateHasAllTokensAsOtherBrands =
Boolean(expectedPrefix) && isTokensFile(filePath)
if (shouldValidateHasAllTokensAsOtherBrands) {
const normalizedFilePath = path.resolve(filePath)
const hasCurrentTokenFile =
tokenVariablesByFile.has(normalizedFilePath)
if (hasCurrentTokenFile) {
const currentFileVariables =
tokenVariablesByFile.get(normalizedFilePath) || new Set()
const allVariables = new Set()
for (const variables of tokenVariablesByFile.values()) {
for (const variable of variables) {
allVariables.add(variable)
}
}
for (const variable of allVariables) {
if (!currentFileVariables.has(variable)) {
stylelint.utils.report({
result,
ruleName: RULE_NAME,
node: root,
message: messages.missingTokenAcrossBrands(variable, theme),
})
}
}
}
}
const GLOBAL_SCOPE_SELECTOR_REGEX = /^(:root|html|body)$/i
root.walkDecls((decl) => {
if (!shouldValidateTokenDeclarationPrefix) {
const ruleNode = decl.parent
if (
ruleNode?.type === 'rule' &&
GLOBAL_SCOPE_SELECTOR_REGEX.test(ruleNode.selector?.trim())
) {
const tokenReferenceRegex =
tokenPrefix === DEFAULT_POLICY.tokenPrefix
? TOKEN_REFERENCE_REGEX
: createTokenReferenceRegex(tokenPrefix)
const singleTokenReferenceRegex =
tokenPrefix === DEFAULT_POLICY.tokenPrefix
? SINGLE_TOKEN_REFERENCE_REGEX
: createTokenReferenceRegex(tokenPrefix, 'i')
const tokenReferences =
decl.value?.match(tokenReferenceRegex) || []
for (const match of tokenReferences) {
const referenceMatch = match.match(singleTokenReferenceRegex)
const variableReference = referenceMatch?.[1]
if (variableReference) {
stylelint.utils.report({
result,
ruleName: RULE_NAME,
node: decl,
message: messages.tokenInGlobalScope(
variableReference,
ruleNode.selector.trim()
),
})
}
}
}
}
if (disallowSassHexRgba && SASS_HEX_RGBA_REGEX.test(decl.value)) {
stylelint.utils.report({
result,
ruleName: RULE_NAME,
node: decl,
message: messages.sassHexRgba(decl.value),
})
}
if (decl.prop && decl.prop.startsWith('--')) {
for (const suffix of disallowedSuffixes) {
if (decl.prop.endsWith(suffix)) {
stylelint.utils.report({
result,
ruleName: RULE_NAME,
node: decl,
message: messages.disallowedSuffix(decl.prop, suffix),
})
return
}
}
if (
shouldValidateTokenDeclarationPrefix &&
!decl.prop.startsWith(tokenPrefix)
) {
stylelint.utils.report({
result,
ruleName: RULE_NAME,
node: decl,
message: messages.wrongTokenDeclarationPrefix(
decl.prop,
tokenPrefix
),
})
}
if (shouldValidateTokenDeclarationPrefix) {
const tokenNameParts = decl.prop.split('-').filter(Boolean)
const tokenCategory = tokenNameParts[1]
if (!allowedTokenCategories.includes(tokenCategory)) {
stylelint.utils.report({
result,
ruleName: RULE_NAME,
node: decl,
message: messages.wrongTokenCategory(
decl.prop,
allowedTokenCategories
),
})
} else if (tokenCategory === 'color') {
const tokenColorCategory = tokenNameParts[2]
if (
tokenColorCategory &&
!allowedTokenColorCategories.includes(tokenColorCategory)
) {
stylelint.utils.report({
result,
ruleName: RULE_NAME,
node: decl,
message: messages.wrongTokenColorCategory(
decl.prop,
allowedTokenColorCategories
),
})
} else if (
tokenColorCategory &&
categoriesWithSemanticValidation.includes(tokenColorCategory)
) {
const tokenColorSemantic = tokenNameParts[3]
if (
tokenColorSemantic &&
!allowedTokenColorSemantics.includes(tokenColorSemantic)
) {
stylelint.utils.report({
result,
ruleName: RULE_NAME,
node: decl,
message: messages.wrongTokenColorSemantic(
decl.prop,
allowedTokenColorSemantics
),
})
}
}
}
}
}
const hasThemeReferenceValidation =
shouldValidateThemePrefix || shouldValidateTokenReferences
const expectedVariablePrefix = hasThemeReferenceValidation
? `--${expectedPrefix}-`
: null
if (
shouldValidateThemePrefix &&
expectedVariablePrefix &&
!decl.prop.startsWith(expectedVariablePrefix)
) {
stylelint.utils.report({
result,
ruleName: RULE_NAME,
node: decl,
message: messages.wrongPrefix(decl.prop, expectedPrefix, theme),
})
}
if (shouldValidateTokenReferences && expectedVariablePrefix) {
const variableReferenceMatches =
decl.value?.match(GENERIC_VAR_REFERENCE_REGEX) || []
for (const match of variableReferenceMatches) {
const referenceMatch = match.match(SINGLE_VAR_REFERENCE_REGEX)
const variableReference = referenceMatch?.[1]
if (
variableReference &&
!variableReference.startsWith(expectedVariablePrefix)
) {
stylelint.utils.report({
result,
ruleName: RULE_NAME,
node: decl,
message: messages.wrongReferencePrefix(
variableReference,
expectedPrefix,
theme
),
})
}
}
}
if (!shouldValidateTokenDeclarationPrefix) {
const forbiddenReferences =
decl.value?.match(FOUNDATION_REFERENCE_REGEX) || []
for (const match of forbiddenReferences) {
const referenceMatch = match.match(SINGLE_VAR_REFERENCE_REGEX)
const variableReference = referenceMatch?.[1]
if (
variableReference &&
!allowedFoundationReferencePrefixes.some((prefix) =>
variableReference.startsWith(prefix)
)
) {
stylelint.utils.report({
result,
ruleName: RULE_NAME,
node: decl,
message:
messages.forbiddenFoundationReferenceOutsideTokens(
variableReference
),
})
}
}
if (hasTokenFiles) {
const tokenReferenceRegex =
tokenPrefix === DEFAULT_POLICY.tokenPrefix
? TOKEN_REFERENCE_REGEX
: createTokenReferenceRegex(tokenPrefix)
const singleTokenReferenceRegex =
tokenPrefix === DEFAULT_POLICY.tokenPrefix
? SINGLE_TOKEN_REFERENCE_REGEX
: createTokenReferenceRegex(tokenPrefix, 'i')
const tokenReferences =
decl.value?.match(tokenReferenceRegex) || []
for (const match of tokenReferences) {
const referenceMatch = match.match(singleTokenReferenceRegex)
const variableReference = referenceMatch?.[1]
if (
variableReference &&
!knownVariables.has(variableReference)
) {
stylelint.utils.report({
result,
ruleName: RULE_NAME,
node: decl,
message:
messages.unknownTokenReferenceOutsideTokens(
variableReference
),
})
}
}
}
}
})
}
}
ruleFunction.ruleName = RULE_NAME
ruleFunction.messages = messages
module.exports = stylelint.createPlugin(RULE_NAME, ruleFunction)
module.exports.ruleName = RULE_NAME
module.exports.messages = messages