Files
pireon-ui/tools/stylelint-plugin-pi-theme/no-raw-color-token.mjs
2026-08-06 20:51:46 +02:00

99 lines
3.2 KiB
JavaScript

import stylelint from 'stylelint';
const { createPlugin, utils } = stylelint;
const { report, ruleMessages, validateOptions } = utils;
const ruleName = 'pi/no-raw-color-token';
const messages = ruleMessages(ruleName, {
rejected: (token) =>
`"${token}" se usa directamente. Los componentes de la librería deben ` +
`consumir un token --pi-theme-* con este color como fallback ` +
`(var(--pi-theme-<algo>, ${token})) para que un tema o el modo oscuro ` +
`puedan redefinir la superficie sin tocar el componente. Ver TOKENS.md.`,
});
const meta = {
url: 'https://github.com/pi/ui/tools/stylelint-plugin-pi-theme',
};
/**
* Contrato de esta librería (ver TOKENS.md): un componente NO consume
* `--pi-color-*` en crudo, lo hace a través de un `--pi-theme-*` con el color
* como fallback: `var(--pi-theme-x, var(--pi-color-y))`. Esta regla detecta
* el caso contrario — `--pi-color-*` referenciado fuera de cualquier
* fallback de `--pi-theme-*` — que es exactamente la clase de bug que dejó
* colores de marca fijos filtrando en modo oscuro (accordion/tab/select en
* esta misma librería, antes de tokenizarlos).
*
* Deliberadamente NO seguimos con `postcss-value-parser`: los valores aquí
* son casi siempre `var(...)` con como mucho una función envolvente
* (`linear-gradient(...)`, `rgba(...)`), así que un escaneo con paréntesis
* balanceados es suficiente y evita añadir una dependencia más solo para
* esta regla.
*/
function findMatchingParenEnd(value, openParenIndex) {
let depth = 1;
for (let i = openParenIndex + 1; i < value.length; i++) {
if (value[i] === '(') depth++;
else if (value[i] === ')') {
depth--;
if (depth === 0) return i;
}
}
return value.length - 1;
}
/** Rangos `[start, end)` de cada fallback de un `var(--pi-theme-*, ...)` — lo que haya dentro está exento. */
function findProtectedRanges(value) {
const ranges = [];
const pattern = /var\(\s*--pi-theme-[\w-]+/g;
let match;
while ((match = pattern.exec(value))) {
const openParenIndex = match.index + match[0].indexOf('(');
const end = findMatchingParenEnd(value, openParenIndex);
ranges.push([match.index, end]);
}
return ranges;
}
function isProtected(index, ranges) {
return ranges.some(([start, end]) => index > start && index < end);
}
const rule = (primaryOption) => {
return (root, result) => {
const validOptions = validateOptions(result, ruleName, {
actual: primaryOption,
possible: [true, false],
});
if (!validOptions || !primaryOption) return;
root.walkDecls((decl) => {
const value = decl.value;
if (!value || !value.includes('--pi-color-')) return;
const protectedRanges = findProtectedRanges(value);
const colorPattern = /--pi-color-[\w-]+/g;
let match;
while ((match = colorPattern.exec(value))) {
if (isProtected(match.index, protectedRanges)) continue;
report({
message: messages.rejected(match[0]),
node: decl,
word: match[0],
result,
ruleName,
});
}
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;
export default createPlugin(ruleName, rule);