/** * @fileoverview Rule to prevent invalid named grid areas in CSS grid templates. * @author xbinaryx */ //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** * @import { CSSRuleDefinition } from "../types.js" * @typedef {"emptyGridArea" | "unevenGridArea" | "nonRectangularGridArea"} NoInvalidNamedGridAreasMessageIds * @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoInvalidNamedGridAreasMessageIds }>} NoInvalidNamedGridAreasRuleDefinition */ //----------------------------------------------------------------------------- // Helpers //----------------------------------------------------------------------------- /** * Regular expression to match null cell tokens (sequences of one or more dots) */ const nullCellToken = /^\.+$/u; /** * Finds non-rectangular grid areas in a 2D grid * @param {string[][]} grid 2D array representing the grid areas * @returns {Array<{name: string, row: number}>} Array of errors found */ function findNonRectangularAreas(grid) { const errors = []; const reported = new Set(); const names = [...new Set(grid.flat())].filter(name => !nullCellToken.test(name)); for (const name of names) { const indicesByRow = grid.map(row => { const indices = []; let idx = row.indexOf(name); while (idx !== -1) { indices.push(idx); idx = row.indexOf(name, idx + 1); } return indices; }); for (let i = 0; i < indicesByRow.length; i++) { for (let j = i + 1; j < indicesByRow.length; j++) { const row1 = indicesByRow[i]; const row2 = indicesByRow[j]; if (row1.length === 0 || row2.length === 0) { continue; } if (row1.length !== row2.length || !row1.every((val, idx) => val === row2[idx])) { const key = `${name}|${j}`; if (!reported.has(key)) { errors.push({ name, row: j }); reported.add(key); } } } } } return errors; } const validProps = new Set(["grid-template-areas", "grid-template", "grid"]); //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- export default /** @satisfies {NoInvalidNamedGridAreasRuleDefinition} */ ({ meta: { type: "problem", docs: { description: "Disallow invalid named grid areas", recommended: true, url: "https://github.com/eslint/css/blob/main/docs/rules/no-invalid-named-grid-areas.md", }, messages: { emptyGridArea: "Grid area must contain at least one cell token.", unevenGridArea: "Grid area strings must have the same number of cell tokens.", nonRectangularGridArea: "Cell tokens with name '{{name}}' must form a rectangle.", }, }, create(context) { return { Declaration(node) { const propName = node.property.toLowerCase(); if (validProps.has(propName) && node.value.type === "Value" && node.value.children.length > 0) { const stringNodes = node.value.children.filter(child => child.type === "String"); if (stringNodes.length === 0) { return; } const grid = []; const emptyNodes = []; const unevenNodes = []; let firstRowLen = null; for (const stringNode of stringNodes) { const trimmedValue = stringNode.value.trim(); if (trimmedValue === "") { emptyNodes.push(stringNode); continue; } const row = trimmedValue.split(" ").filter(Boolean); grid.push(row); if (firstRowLen === null) { firstRowLen = row.length; } else if (row.length !== firstRowLen) { unevenNodes.push(stringNode); } } if (emptyNodes.length > 0) { emptyNodes.forEach(emptyNode => context.report({ node: emptyNode, messageId: "emptyGridArea", })); return; } if (unevenNodes.length > 0) { unevenNodes.forEach(unevenNode => context.report({ node: unevenNode, messageId: "unevenGridArea", })); return; } const nonRectErrors = findNonRectangularAreas(grid); nonRectErrors.forEach(({ name, row }) => { const stringNode = stringNodes[row]; context.report({ node: stringNode, messageId: "nonRectangularGridArea", data: { name, }, }); }); } }, }; }, });