core/stylelint/custom-property-order.stylelint.rule.ts

core/stylelint/custom-property-order.stylelint.rule.ts is a file in GovLab Quality. 105 lines of code and 20 definitions.

import type { ChildNode, Rule as CssRule, Root } from "postcss";
import { asLayerOptions, layerOf } from "#core/resolvers/layer.resolver";
import { isAsciiDigit, isDot } from "#core/predicates/code-point.predicate";
import stylelint, { type PostcssResult, type Rule } from "stylelint";
import type { GovlabStylelintMeta } from "#types/stylelint.types";
import { withRuleId } from "#core/formatters/stylelint.formatter";

const { createPlugin, utils } = stylelint;
const ruleName = "govlab/custom-property-order";
const ruleId = "custom_property_order";
const CUSTOM_PROPERTY_PREFIX = "--";

export const RULE_META: GovlabStylelintMeta = {
    meta: {
        canonical: ["css-architecture"],
        description:
            "Require same-prefix custom properties in the tokens layer to be declared in ascending numeric order",
        fixable: false,
    },
    ruleId,
    ruleName,
};

const TOKENS_LAYER = "tokens";
const SEMANTIC_SUFFIXES = [
    "xxs",
    "xs",
    "sm",
    "base",
    "md",
    "regular",
    "lg",
    "xl",
    "xxl",
    "xxxl",
    "hero",
    "light",
    "normal",
    "medium",
    "semibold",
    "bold",
    "fast",
    "slow",
    "slower",
];

const messages = utils.ruleMessages(ruleName, {
    outOfOrder: (name: string): string =>
        withRuleId(
            `Custom property '${name}' breaks ascending numeric order within its prefix family. Order same-prefix tokens smallest→largest.`,
            ruleId,
        ),
});

const isTokensFile = function isTokensFile(from: string | undefined, secondaryOptions: unknown): boolean {
    return layerOf(from, asLayerOptions(secondaryOptions)) === TOKENS_LAYER;
};

const prefixOf = function prefixOf(name: string): string {
    if (!name.startsWith(CUSTOM_PROPERTY_PREFIX)) {
        return name;
    }
    const body = name.slice(CUSTOM_PROPERTY_PREFIX.length);
    for (const suffix of SEMANTIC_SUFFIXES) {
        if (body.endsWith(`-${suffix}`)) {
            return body.slice(0, body.length - suffix.length - 1);
        }
    }
    return body;
};

const numericValue = function numericValue(value: string): number {
    let digits = "";
    for (let i = 0; i < value.length; i += 1) {
        const code = value.codePointAt(i);
        if (isAsciiDigit(code) || isDot(code)) {
            digits += value[i];
        } else {
            break;
        }
    }
    return digits.length > 0 ? Number(digits) : 0;
};

const checkRoot = function checkRoot(cssRule: CssRule, result: PostcssResult): void {
    let prev: { prefix: string; value: number } | null = null;
    cssRule.each((node: ChildNode) => {
        if (node.type !== "decl" || !node.prop.startsWith("--")) {
            return;
        }
        const decl = node;
        const current = { prefix: prefixOf(decl.prop), value: numericValue(decl.value) };
        if (prev !== null && prev.prefix === current.prefix && prev.value > current.value) {
            utils.report({ message: messages.outOfOrder(decl.prop), node: decl, result, ruleName, word: decl.prop });
        }
        prev = current;
    });
};

const rule: Rule = Object.assign(
    (primary: unknown, secondaryOptions: unknown) =>
        (root: Root, result: PostcssResult): void => {
            if (primary !== true || !isTokensFile(root.source?.input.from, secondaryOptions)) {
                return;
            }
            root.walkRules((cssRule: CssRule) => {
                if (cssRule.selector === ":root") {
                    checkRoot(cssRule, result);
                }
            });
        },
    { messages, ruleName },
);

export default createPlugin(ruleName, rule);