Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import stylelint from "stylelint";
import valueParser from "postcss-value-parser";
import {
backgroundToText,
customPropertiesRead,
findColorDeclarations,
isCustomPropertyDefinition,
isDesignToken,
isFunction,
namespace,
parseColorTokenName,
textToBackground,
} from "../helpers.mjs";
const {
utils: { report, ruleMessages, validateOptions },
} = stylelint;
let ruleName = namespace("use-paired-color-tokens");
let messages = ruleMessages(ruleName, {
noTextColor: (background, text) =>
`"${background}" should be used with a text color; add "color: var(${text})", or disable the rule with a comment saying where the text color comes from.`,
notPaired: (background, text, expected) =>
`"${background}" and "${text}" are not a semantic pair; use "${expected}" for the text color, or a background color that pairs with "${text}".`,
noPairedToken: (background, text, expected) =>
`"${background}" and "${text}" are different variants of the same tokens and there is no "${expected}"; file a bug for the missing token.`,
noPairedTokenUseBase: (background, text, expected, base) =>
`"${background}" and "${text}" are different variants of the same tokens and there is no "${expected}"; use "${base}" instead, or file a bug for the missing token.`,
});
let meta = {
fixable: true,
};
// A state variant usually restyles an element its base rule has already given a
// text color, so state selectors are exempt. That base rule is generally a flat
// sibling (`.foo:hover {}` beside `.foo {}`) rather than a nesting parent, which
// is why this keys on the selector rather than walking up.
const STATE_PSEUDO_CLASS =
/:(?:active|checked|current|default|disabled|enabled|focus|focus-visible|focus-within|hover|in-range|indeterminate|invalid|open|out-of-range|past|paused|placeholder-shown|playing|popover-open|read-only|read-write|target|user-invalid|user-valid|valid|visited|-moz-broken|-moz-drag-over|-moz-focusring|-moz-window-inactive)\b/;
// Chrome markup carries the states a pseudo-class cannot express as attributes
// instead, so `menulist[disabled]` is a state variant of `menulist`.
const STATE_ATTRIBUTE =
/\[\s*(?:active|aria-checked|aria-current|aria-disabled|aria-expanded|aria-pressed|aria-selected|busy|checked|disabled|focused|open|pressed|selected)\s*[\]~^|$*=]/;
/**
* Drops the argument of every `:not()`, innermost first. A negated state names
* the base state rather than a variant of it, so `&:not(:hover)` is the rule
* that owes the surface a text color, not one that inherits it from elsewhere.
*
* @param {string} selector
* @returns {string}
*/
let stripNegations = selector => {
for (let previous; selector != previous; ) {
previous = selector;
selector = selector.replace(/:not\([^()]*\)/g, "");
}
return selector;
};
/**
* Whether a selector says the text color is not this block's to declare.
*
* @param {string} selector
* @returns {boolean}
*/
let isExemptSelector = selector => {
let states = stripNegations(selector);
return STATE_PSEUDO_CLASS.test(states) || STATE_ATTRIBUTE.test(states);
};
/**
* Whether the selector of a block, or of a block it is nested in, exempts it.
*
* @param {object} block - A PostCSS Rule or AtRule.
* @returns {boolean}
*/
let isExempt = block => {
for (let node = block; node; node = node.parent) {
if (
node.type == "rule" &&
isExemptSelector(node.raws.selector?.raw ?? node.selector)
) {
return true;
}
}
return false;
};
/**
* Whether the element a block styles is given a text color. An at-rule nested
* in a rule styles that rule's element, so a `color` the rule sets applies to
* the at-rule's surface too; a nested rule matches a different element.
*
* @param {object} block - A PostCSS Rule or AtRule.
* @returns {boolean}
*/
let hasTextColor = block => {
for (let node = block; node; node = node.parent) {
if (findColorDeclarations(node).text) {
return true;
}
if (node.type != "atrule") {
return false;
}
}
return false;
};
/**
* Whether a block gives the surface a text color through a custom property,
* which is how a component rendering the text in its own shadow tree takes
* one.
*
* @param {object} block - A PostCSS Rule or AtRule.
* @returns {boolean}
*/
let definesTextColor = block =>
block.nodes.some(
node =>
node.type == "decl" &&
isCustomPropertyDefinition(node) &&
(textToBackground.has(node.prop) ||
customPropertiesRead(node.value).some(token =>
textToBackground.has(token)
))
);
/**
* Whether a comment sits at the end of the background declaration's line.
* PostCSS models it as the declaration's next sibling, so a declaration
* inserted between the two takes the comment onto its own line.
*
* @param {object} background - A PostCSS Declaration.
* @returns {boolean}
*/
let hasTrailingComment = background => {
let next = background.next();
return next?.type == "comment" && !next.raws.before?.includes("\n");
};
/**
* Declares the counterpart text color beside the background that paints the
* surface. Cloning carries the background declaration's own raws over, so the
* new declaration takes its indentation and spacing.
*
* @param {object} background - The PostCSS Declaration painting the background.
* @param {string} text - The name of the paired text color token.
*/
let insertTextColor = (background, text) =>
background.cloneAfter({ prop: "color", value: `var(${text})` });
/**
* Rewrites every read of one custom property in a declaration value.
*
* @param {object} declaration - A PostCSS Declaration.
* @param {string} from - The custom property name to replace.
* @param {string} to - The custom property name to read instead.
*/
let replaceCustomProperty = (declaration, from, to) => {
let parsed = valueParser(declaration.value);
parsed.walk(node => {
if (isFunction(node) && node.value === "var") {
let [first] = node.nodes;
if (first?.value === from) {
first.value = to;
}
}
});
declaration.value = parsed.toString();
};
/**
* Reports a block that paints a paired background token and sets no text
* color, leaving the surface to inherit one no theme guarantees the contrast
* of.
*
* @param {object} block - A PostCSS Rule or AtRule.
* @param {object} background - The Declaration painting the background.
* @param {object} result - The PostCSS result to report to.
*/
let checkTextColorPresent = (block, background, result) => {
if (hasTextColor(block) || definesTextColor(block) || isExempt(block)) {
return;
}
let [paired] = customPropertiesRead(background.value).filter(token =>
backgroundToText.has(token)
);
if (!paired) {
return;
}
let text = backgroundToText.get(paired);
report({
message: messages.noTextColor(paired, text),
node: background,
result,
ruleName,
fix: hasTrailingComment(background)
? undefined
: () => insertTextColor(background, text),
});
};
/**
* Reports the background and text color tokens of one block when they are not
* the semantic pair they were designed as.
*
* @param {object} backgroundDeclaration - The Declaration painting the background.
* @param {object} textDeclaration - The Declaration setting the text color.
* @param {object} result - The PostCSS result to report to.
*/
let checkPair = (backgroundDeclaration, textDeclaration, result) => {
let backgroundTokens = customPropertiesRead(backgroundDeclaration.value);
let textTokens = customPropertiesRead(textDeclaration.value);
let paired = backgroundTokens.filter(token => backgroundToText.has(token));
if (paired.some(token => textTokens.includes(backgroundToText.get(token)))) {
return;
}
let pairedText = textTokens.filter(token => textToBackground.has(token));
if (paired.length && pairedText.length) {
let expected = backgroundToText.get(paired[0]);
report({
message: messages.notPaired(paired[0], pairedText[0], expected),
node: textDeclaration,
result,
ruleName,
fix: () =>
replaceCustomProperty(textDeclaration, pairedText[0], expected),
});
return;
}
// The pair the author reached for may not exist as a token, but mixing two
// variants of the same component's tokens is a mistake either way. The global
// background-color/text-color tokens have no component prefix and are meant
// to combine freely, so they are not variants of each other, and a component
// whose variant has no text color of its own is meant to fall back to the
// family's base one.
for (let background of backgroundTokens.filter(isDesignToken)) {
let backgroundName = parseColorTokenName(background);
if (!backgroundName?.family) {
continue;
}
for (let text of textTokens.filter(isDesignToken)) {
let textName = parseColorTokenName(text);
if (
textName?.family != backgroundName.family ||
!textName.variant ||
textName.variant == backgroundName.variant
) {
continue;
}
let expected = `--${backgroundName.family}text-color${backgroundName.variant}`;
let base = `--${backgroundName.family}text-color`;
let message;
let fix;
if (isDesignToken(expected)) {
message = messages.notPaired(background, text, expected);
fix = () => replaceCustomProperty(textDeclaration, text, expected);
} else if (isDesignToken(base)) {
message = messages.noPairedTokenUseBase(
background,
text,
expected,
base
);
} else {
message = messages.noPairedToken(background, text, expected);
}
report({ message, node: textDeclaration, result, ruleName, fix });
return;
}
}
};
/**
* Checks the color tokens of one declaration block: a background token needs a
* text color, and a text color declared beside it needs to be the background's
* counterpart. Only the declarations that win the cascade within the block are
* considered.
*
* @param {object} block - A PostCSS Rule or AtRule.
* @param {object} result - The PostCSS result to report to.
*/
let checkBlock = (block, result) => {
let { background, text } = findColorDeclarations(block);
// A block that sets no background claims no surface, and a text color it
// sets pairs with a background from an ancestor, a sibling rule, or another
// pseudo-element.
if (!background) {
return;
}
if (text) {
checkPair(background, text, result);
} else {
checkTextColorPresent(block, background, result);
}
};
let ruleFunction = primaryOption => {
return (root, result) => {
let validOptions = validateOptions(result, ruleName, {
actual: primaryOption,
possible: [true],
});
if (!validOptions) {
return;
}
// Declarations nest directly inside an at-rule as well as inside a rule,
// which is the shape of every generated token sheet. A statement at-rule
// such as @namespace has no block and so no nodes at all.
root.walk(node => {
if ((node.type == "rule" || node.type == "atrule") && node.nodes) {
checkBlock(node, result);
}
});
};
};
ruleFunction.ruleName = ruleName;
ruleFunction.messages = messages;
ruleFunction.meta = meta;
export default ruleFunction;