Skip to content

Index

FieldValue
TypeSkill Resource
Source~/.copilot/skills/design/references/assets/stylelint/plugins/index.mjs
DescriptionNot specified

Source Content

/* ===================================================================
* plugins/index.mjs — the CSS skill's custom Stylelint rules
*
* Two rules that no off-the-shelf plugin covers, both in the
* "css-skill" namespace:
*
* css-skill/symmetric-padding left==right and top==bottom unless an
* adjacent comment says "asymmetric".
* css-skill/no-component-margins margins (except 0 / auto) belong at
* the layout layer, not on components.
*
* WHY a custom plugin, not config: both rules need to compare values
* across a shorthand or look at neighbouring comments — that is logic,
* not a static disallowed-list. Stylelint v16 expects ESM plugins that
* export the result of stylelint.createPlugin(...).
*
* The default export is the ARRAY of both plugins; Stylelint accepts a
* plugin module that exports an array, so one file registers both.
* =================================================================== */
import stylelint from "stylelint";
const {
createPlugin,
utils: { report, ruleMessages, validateOptions },
} = stylelint;
/* -------------------------------------------------------------------
* Rule 1: css-skill/symmetric-padding
* ----------------------------------------------------------------- */
const symmetricRuleName = "css-skill/symmetric-padding";
const symmetricMessages = ruleMessages(symmetricRuleName, {
// Reported when opposing axes differ with no "asymmetric" comment.
rejected: (prop, value) =>
`Expected symmetric padding for "${prop}: ${value}" (left==right, top==bottom). Add an adjacent comment with the word "asymmetric" if intentional.`,
});
// Split a shorthand value into space-separated tokens, but keep
// functions like var(--x, 1px) intact (commas inside parens are not
// token separators). Good enough for padding shorthands.
function splitTopLevel(value) {
const parts = [];
let depth = 0;
let current = "";
for (const char of value) {
if (char === "(") depth += 1;
if (char === ")") depth -= 1;
if (/\s/.test(char) && depth === 0) {
if (current) parts.push(current);
current = "";
} else {
current += char;
}
}
if (current) parts.push(current);
return parts;
}
// True when a shorthand's opposing sides are NOT symmetric.
// CSS padding shorthand sides: [top right bottom left].
// 2 values -> always symmetric (block, inline).
// 3 values -> top / inline / bottom; asymmetric iff top != bottom.
// 4 values -> asymmetric iff top != bottom OR right != left.
function shorthandIsAsymmetric(parts) {
if (parts.length === 3) return parts[0] !== parts[2];
if (parts.length === 4) return parts[0] !== parts[2] || parts[1] !== parts[3];
return false; // 1 or 2 values are symmetric by definition
}
// Look at the comment immediately before a declaration (handles a
// same-rule comment node and a trailing comment in the decl's raws).
function hasAsymmetricComment(decl) {
const prev = decl.prev();
if (prev && prev.type === "comment" && /asymmetric/i.test(prev.text)) {
return true;
}
// A trailing comment on the same line lands in raws.value.raw.
const raw = decl.raws && decl.raws.value && decl.raws.value.raw;
if (raw && /asymmetric/i.test(raw)) return true;
// A comment after the declaration on its own (between decl and next).
const next = decl.next();
if (next && next.type === "comment" && /asymmetric/i.test(next.text)) {
return true;
}
return false;
}
const symmetricPlugin = createPlugin(symmetricRuleName, (primary) => {
return (root, result) => {
const valid = validateOptions(result, symmetricRuleName, {
actual: primary,
possible: [true],
});
if (!valid) return;
// Track per-rule longhand padding sides so padding-top vs
// padding-bottom (and -left vs -right) can be compared.
root.walkRules((rule) => {
const longhand = {};
rule.walkDecls(/^padding/, (decl) => {
const prop = decl.prop.toLowerCase();
// Shorthand: compare opposing sides directly.
if (prop === "padding") {
if (hasAsymmetricComment(decl)) return;
const parts = splitTopLevel(decl.value.trim());
if (shorthandIsAsymmetric(parts)) {
report({
message: symmetricMessages.rejected(decl.prop, decl.value),
node: decl,
result,
ruleName: symmetricRuleName,
});
}
return;
}
// Longhands: stash for an after-the-walk comparison so we have
// both sides of an axis before deciding.
if (/^padding-(top|right|bottom|left)$/.test(prop)) {
longhand[prop] = decl;
}
});
// Compare opposing longhand axes within the same rule.
const axes = [
["padding-top", "padding-bottom"],
["padding-left", "padding-right"],
];
for (const [a, b] of axes) {
const da = longhand[a];
const db = longhand[b];
if (da && db && da.value.trim() !== db.value.trim()) {
// One justifying comment on either side clears the pair.
if (hasAsymmetricComment(da) || hasAsymmetricComment(db)) continue;
report({
message: symmetricMessages.rejected(
`${a}/${b}`,
`${da.value} / ${db.value}`
),
node: db,
result,
ruleName: symmetricRuleName,
});
}
}
});
};
});
symmetricPlugin.ruleName = symmetricRuleName;
symmetricPlugin.messages = symmetricMessages;
/* -------------------------------------------------------------------
* Rule 2: css-skill/no-component-margins
* ----------------------------------------------------------------- */
const marginRuleName = "css-skill/no-component-margins";
const marginMessages = ruleMessages(marginRuleName, {
rejected: (prop, value) =>
`Unexpected margin "${prop}: ${value}" — margins belong at the layout layer, not on components. Use gap or a layout primitive (0 and auto are allowed).`,
});
// 0 in any unit, and the keyword auto, are the only allowed margins.
// A shorthand is allowed only if EVERY part is 0-ish or auto.
function isAllowedMarginValue(value) {
const parts = value.trim().split(/\s+/);
return parts.every((part) => part === "auto" || /^0[a-z%]*$/i.test(part));
}
const marginPlugin = createPlugin(marginRuleName, (primary) => {
return (root, result) => {
const valid = validateOptions(result, marginRuleName, {
actual: primary,
possible: [true],
});
if (!valid) return;
// Match margin and every margin-* longhand/logical variant.
// The config overrides decide WHERE this rule runs — here we just
// flag every disallowed margin we see.
root.walkDecls(/^margin($|-)/, (decl) => {
if (isAllowedMarginValue(decl.value)) return;
report({
message: marginMessages.rejected(decl.prop, decl.value),
node: decl,
result,
ruleName: marginRuleName,
});
});
};
});
marginPlugin.ruleName = marginRuleName;
marginPlugin.messages = marginMessages;
/* -------------------------------------------------------------------
* Register both plugins.
* ----------------------------------------------------------------- */
export default [symmetricPlugin, marginPlugin];