core/stylelint/no-held-entrance-fill.stylelint.rule.ts

core/stylelint/no-held-entrance-fill.stylelint.rule.ts is a file in GovLab Quality. 157 lines of code and 43 definitions.

import { dirname, join, sep } from "node:path";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import postcss, { type AtRule, type Rule as CssRule, type Declaration, type Root } from "postcss";
import stylelint, { type PostcssResult, type Rule } from "stylelint";
import type { GovlabStylelintMeta } from "#types/stylelint.types";
import valueParser, { type Node } from "postcss-value-parser";
import { withRuleId } from "#core/formatters/stylelint.formatter";

const { createPlugin, utils } = stylelint;
const ruleName = "govlab/no-held-entrance-fill";
const ruleId = "no_held_entrance_fill";

export const RULE_META: GovlabStylelintMeta = {
    meta: {
        canonical: ["css-architecture"],
        description: "Forbid an animation that keeps holding a fully opaque final keyframe after it ends",
        fixable: false,
    },
    ruleId,
    ruleName,
};

const KEYFRAMES = "keyframes";
const FINAL_FRAMES = new Set(["to", "100%"]);
const OPACITY = "opacity";
const OPAQUE = new Set(["1", "100%"]);
const HOLDING_FILLS = new Set(["forwards", "both"]);
const SHORTHAND = "animation";
const NAME_LONGHAND = "animation-name";
const FILL_LONGHAND = "animation-fill-mode";
const LAYER_SEPARATOR = ",";
const PACKAGE_MANIFEST = "package.json";
const STYLESHEET_EXTENSION = ".css";
const DEPENDENCY_FOLDER = "node_modules";

const messages = utils.ruleMessages(ruleName, {
    heldFill: (name: string): string =>
        withRuleId(
            `The animation '${name}' ends fully opaque and its fill keeps holding that end state after it finishes, so the element stays a composited layer and becomes the backdrop root of every child. A child's backdrop-filter then blurs nothing. Put the end state in the element's own rule and fill the animation backwards.`,
            ruleId,
        ),
});

const isFinalFrame = function isFinalFrame(frame: CssRule): boolean {
    return frame.selector.split(LAYER_SEPARATOR).some((part) => FINAL_FRAMES.has(part.trim().toLowerCase()));
};

const endsOpaque = function endsOpaque(keyframes: AtRule): boolean {
    let opaque = false;
    keyframes.walkRules((frame) => {
        if (!isFinalFrame(frame)) {
            return;
        }
        frame.walkDecls(OPACITY, (decl) => {
            opaque = OPAQUE.has(decl.value.trim());
        });
    });
    return opaque;
};

const opaqueEndingsIn = function opaqueEndingsIn(root: Root): readonly string[] {
    const names: string[] = [];
    root.walkAtRules((atRule) => {
        if (atRule.name.toLowerCase().endsWith(KEYFRAMES) && endsOpaque(atRule)) {
            names.push(atRule.params.trim());
        }
    });
    return names;
};

const memberRootOf = function memberRootOf(file: string): string | null {
    for (let folder = dirname(file); ; folder = dirname(folder)) {
        if (existsSync(join(folder, PACKAGE_MANIFEST))) {
            return folder;
        }
        if (dirname(folder) === folder) {
            return null;
        }
    }
};

const stylesheetsUnder = function stylesheetsUnder(folder: string): readonly string[] {
    return readdirSync(folder, { recursive: true, withFileTypes: true })
        .filter((entry) => entry.isFile() && entry.name.endsWith(STYLESHEET_EXTENSION))
        .map((entry) => join(entry.parentPath, entry.name))
        .filter((path) => !path.split(sep).includes(DEPENDENCY_FOLDER));
};

const memberEndings = new Map<string, ReadonlySet<string>>();

const opaqueEndingsOf = function opaqueEndingsOf(root: Root): ReadonlySet<string> {
    const file = root.source?.input.file;
    const member = file === undefined ? null : memberRootOf(file);
    if (member === null) {
        return new Set(opaqueEndingsIn(root));
    }
    const cached = memberEndings.get(member);
    if (cached !== undefined) {
        return new Set([...cached, ...opaqueEndingsIn(root)]);
    }
    const names = new Set(
        stylesheetsUnder(member).flatMap((path) => opaqueEndingsIn(postcss.parse(readFileSync(path, "utf8")))),
    );
    memberEndings.set(member, names);
    return new Set([...names, ...opaqueEndingsIn(root)]);
};

const layersOf = function layersOf(value: string): readonly (readonly string[])[] {
    const layers: string[][] = [[]];
    for (const node of valueParser(value).nodes) {
        layers.push(...layerBreak(node));
        const current = layers.at(-1);
        if (node.type === "word" && current !== undefined) {
            current.push(node.value);
        }
    }
    return layers;
};

const layerBreak = function layerBreak(node: Node): string[][] {
    return node.type === "div" && node.value === LAYER_SEPARATOR ? [[]] : [];
};

const heldIn = function heldIn(words: readonly string[], opaque: ReadonlySet<string>): string | null {
    const holds = words.some((word) => HOLDING_FILLS.has(word.toLowerCase()));
    return holds ? (words.find((word) => opaque.has(word)) ?? null) : null;
};

const siblingValue = function siblingValue(decl: Declaration, prop: string): string | null {
    let found: string | null = null;
    decl.parent?.each((sibling) => {
        if (sibling.type === "decl" && sibling.prop.toLowerCase() === prop) {
            found = sibling.value;
        }
    });
    return found;
};

const heldNames = function heldNames(decl: Declaration, opaque: ReadonlySet<string>): readonly string[] {
    const prop = decl.prop.toLowerCase();
    if (prop === SHORTHAND) {
        return layersOf(decl.value).flatMap((words) => {
            const name = heldIn(words, opaque);
            return name === null ? [] : [name];
        });
    }
    if (prop !== FILL_LONGHAND) {
        return [];
    }
    const names = siblingValue(decl, NAME_LONGHAND);
    const words = [...layersOf(decl.value).flat(), ...(names === null ? [] : layersOf(names).flat())];
    const name = heldIn(words, opaque);
    return name === null ? [] : [name];
};

const rule: Rule = Object.assign(
    (primary: unknown) =>
        (root: Root, result: PostcssResult): void => {
            if (primary !== true) {
                return;
            }
            const opaque = opaqueEndingsOf(root);
            if (opaque.size === 0) {
                return;
            }
            root.walkDecls((decl: Declaration) => {
                for (const name of heldNames(decl, opaque)) {
                    utils.report({ message: messages.heldFill(name), node: decl, result, ruleName, word: name });
                }
            });
        },
    { messages, ruleName },
);

export default createPlugin(ruleName, rule);