diff --git a/docs/rules/use-baseline.md b/docs/rules/use-baseline.md index 98ed850f..85d62efd 100644 --- a/docs/rules/use-baseline.md +++ b/docs/rules/use-baseline.md @@ -29,6 +29,7 @@ This rule warns when it finds any of the following: - A media condition inside `@media` that isn't widely available. - A CSS property value that isn't widely available or otherwise isn't enclosed in a `@supports` block (currently limited to identifiers only). - A CSS property function that isn't widely available. +- A CSS unit that isn't widely available or otherwise isn't enclosed in a `@supports` block. - A CSS pseudo-element or pseudo-class selector that isn't widely available. The data is provided via the [web-features](https://npmjs.com/package/web-features) package. @@ -48,6 +49,12 @@ a { width: abs(20% - 100px); } +/* invalid - svh is not available before 2022 */ +/* eslint css/use-baseline: ["error", { available: 2021 }] */ +a { + height: 100svh; +} + /* invalid - :has() is not widely available */ h1:has(+ h2) { margin: 0 0 0.25rem 0; @@ -110,6 +117,7 @@ This rule accepts an options object with the following properties: - `allowProperties` (default: `[]`) - Specify an array of properties that are allowed to be used. - `allowPropertyValues` (default: `{}`) - Specify an object mapping properties to an array of allowed identifier values for that property. - `allowSelectors` (default: `[]`) - Specify an array of selectors that are allowed to be used. +- `allowUnits` (default: `[]`) - Specify an array of CSS units that are allowed to be used. #### `allowAtRules` @@ -201,6 +209,18 @@ h1:has(+ h2) { } ``` +#### `allowUnits` + +Examples of **correct** code with `{ allowUnits: ["svh"] }`: + +```css +/* eslint css/use-baseline: ["error", { available: 2021, allowUnits: ["svh"] }] */ + +a { + height: 100svh; +} +``` + ## When Not to Use It If your web application doesn't target all Baseline browsers then you can safely disable this rule. diff --git a/src/data/baseline-data.js b/src/data/baseline-data.js index 5e7319a6..77a46bbd 100644 --- a/src/data/baseline-data.js +++ b/src/data/baseline-data.js @@ -463,7 +463,6 @@ export const properties = new Map([ ["text-decoration", "10:2015"], ["text-decoration-color", "10:2020"], ["text-decoration-line", "10:2020"], - ["text-decoration-skip", "0:"], ["text-decoration-style", "10:2020"], ["text-decoration-thickness", "10:2021"], ["text-decoration-skip-ink", "10:2022"], @@ -639,9 +638,9 @@ export const functions = new Map([ ["paint", "0:"], ["path", "10:2020"], ["xywh", "5:2024"], - ["rem", "5:2024"], ["rgb", "10:2015"], ["mod", "5:2024"], + ["rem", "5:2024"], ["env", "10:2020"], ["circle", "10:2020"], ["ellipse", "10:2020"], @@ -677,6 +676,51 @@ export const functions = new Map([ ["sin", "10:2023"], ["tan", "10:2023"], ]); +export const units = new Map([ + ["cap", "5:2023"], + ["ch", "10:2015"], + ["cqw", "10:2023"], + ["cqh", "10:2023"], + ["cqi", "10:2023"], + ["cqb", "10:2023"], + ["cqmin", "10:2023"], + ["cqmax", "10:2023"], + ["em", "10:2015"], + ["ex", "10:2015"], + ["ic", "10:2022"], + ["lh", "5:2023"], + ["Q", "10:2020"], + ["rcap", "5:2026"], + ["rch", "5:2026"], + ["rem", "10:2015"], + ["rex", "5:2026"], + ["ric", "5:2026"], + ["rlh", "5:2023"], + ["vb", "10:2022"], + ["vi", "10:2022"], + ["dvb", "10:2022"], + ["dvh", "10:2022"], + ["dvi", "10:2022"], + ["dvmax", "10:2022"], + ["dvmin", "10:2022"], + ["dvw", "10:2022"], + ["lvb", "10:2022"], + ["lvh", "10:2022"], + ["lvi", "10:2022"], + ["lvmax", "10:2022"], + ["lvmin", "10:2022"], + ["lvw", "10:2022"], + ["svb", "10:2022"], + ["svh", "10:2022"], + ["svi", "10:2022"], + ["svmax", "10:2022"], + ["svmin", "10:2022"], + ["svw", "10:2022"], + ["vh", "10:2015"], + ["vmax", "10:2017"], + ["vmin", "10:2015"], + ["vw", "10:2015"], +]); export const selectors = new Map([ ["active-view-transition", "5:2025"], ["active-view-transition-type", "5:2026"], @@ -1990,7 +2034,7 @@ export const propertyValues = new Map([ ["nastaliq", "10:2015"], ["sans-serif", "10:2015"], ["serif", "10:2015"], - ["math", "5:2025"], + ["math", "0:"], ["system-ui", "10:2021"], ["ui-monospace", "0:"], ["ui-rounded", "0:"], @@ -3132,13 +3176,6 @@ export const propertyValues = new Map([ ["spelling-error", "5:2025"], ]), ], - [ - "text-decoration-skip", - new Map([ - ["auto", "0:"], - ["none", "0:"], - ]), - ], ["text-decoration-style", new Map([["wavy", "10:2020"]])], [ "text-decoration-thickness", diff --git a/src/rules/use-baseline.js b/src/rules/use-baseline.js index 55d87de7..10caf837 100644 --- a/src/rules/use-baseline.js +++ b/src/rules/use-baseline.js @@ -15,6 +15,7 @@ import { atRules, mediaConditions, functions, + units, selectors, } from "../data/baseline-data.js"; import { namedColors } from "../data/colors.js"; @@ -25,8 +26,8 @@ import { namedColors } from "../data/colors.js"; /** * @import { CSSRuleDefinition } from "../types.js" - * @import { Identifier, FunctionNodePlain } from "@eslint/css-tree" - * @typedef {"notBaselineProperty" | "notBaselinePropertyValue" | "notBaselineAtRule" | "notBaselineFunction" | "notBaselineMediaCondition" | "notBaselineSelector"} UseBaselineMessageIds + * @import { Identifier, FunctionNodePlain, Dimension } from "@eslint/css-tree" + * @typedef {"notBaselineProperty" | "notBaselinePropertyValue" | "notBaselineAtRule" | "notBaselineFunction" | "notBaselineMediaCondition" | "notBaselineSelector" | "notBaselineUnit"} UseBaselineMessageIds * @typedef {[{ * available?: "widely" | "newly" | number, * allowAtRules?: string[], @@ -34,7 +35,8 @@ import { namedColors } from "../data/colors.js"; * allowMediaConditions?: string[], * allowProperties?: string[], * allowPropertyValues?: { [property: string]: string[] }, - * allowSelectors?: string[] + * allowSelectors?: string[], + * allowUnits?: string[] * }]} UseBaselineOptions * @typedef {CSSRuleDefinition<{ RuleOptions: UseBaselineOptions, MessageIds: UseBaselineMessageIds }>} UseBaselineRuleDefinition */ @@ -59,6 +61,12 @@ class SupportedProperty { */ #identifiers = new Set(); + /** + * Supported units. + * @type {Set} + */ + #units = new Set(); + /** * Supported function types. * @type {Set} @@ -99,6 +107,32 @@ class SupportedProperty { return this.#identifiers.size > 0; } + /** + * Adds a unit to the list of supported units. + * @param {string} unit The unit to add. + * @returns {void} + */ + addUnit(unit) { + this.#units.add(unit); + } + + /** + * Determines if a unit is supported. + * @param {string} unit The unit to check. + * @returns {boolean} `true` if the unit is supported, `false` if not. + */ + hasUnit(unit) { + return this.#units.has(unit); + } + + /** + * Determines if any units are supported. + * @returns {boolean} `true` if any units are supported, `false` if not. + */ + hasUnits() { + return this.#units.size > 0; + } + /** * Adds a function to the list of supported functions. * @param {string} func The function to add. @@ -238,6 +272,37 @@ class SupportsRule { return supportedProperty.hasFunctions(); } + /** + * Determines if the rule supports a unit. + * @param {string} property The name of the property. + * @param {string} unit The unit to check. + * @returns {boolean} `true` if the unit is supported, `false` if not. + */ + hasPropertyUnit(property, unit) { + const supportedProperty = this.#properties.get(property); + + if (!supportedProperty) { + return false; + } + + return supportedProperty.hasUnit(unit); + } + + /** + * Determines if the rule supports any units. + * @param {string} property The name of the property. + * @returns {boolean} `true` if any units are supported, `false` if not. + */ + hasPropertyUnits(property) { + const supportedProperty = this.#properties.get(property); + + if (!supportedProperty) { + return false; + } + + return supportedProperty.hasUnits(); + } + /** * Adds a selector to the rule. * @param {string} selector The name of the selector. @@ -341,6 +406,16 @@ class SupportsRules { return this.#rules.some(rule => rule.hasFunctions(property)); } + /** + * Determines if any rule supports a unit. + * @param {string} property The name of the property. + * @param {string} unit The unit to check. + * @returns {boolean} `true` if any rule supports the unit, `false` if not. + */ + hasPropertyUnit(property, unit) { + return this.#rules.some(rule => rule.hasPropertyUnit(property, unit)); + } + /** * Determines if any rule supports a selector. * @param {string} selector The name of the selector. @@ -489,6 +564,13 @@ export default { }, uniqueItems: true, }, + allowUnits: { + type: "array", + items: { + enum: Array.from(units.keys()), + }, + uniqueItems: true, + }, }, additionalProperties: false, }, @@ -503,6 +585,7 @@ export default { allowProperties: [], allowPropertyValues: {}, allowSelectors: [], + allowUnits: [], }, ], @@ -519,6 +602,8 @@ export default { "Media condition '{{condition}}' is not a {{availability}} available baseline feature.", notBaselineSelector: "Selector '{{selector}}' is not a {{availability}} available baseline feature.", + notBaselineUnit: + "Unit '{{unit}}' is not a {{availability}} available baseline feature.", }, }, @@ -534,6 +619,7 @@ export default { const allowMediaConditions = new Set( context.options[0].allowMediaConditions, ); + const allowUnits = new Set(context.options[0].allowUnits); const allowPropertyValuesMap = new Map(); for (const [prop, values] of Object.entries( context.options[0].allowPropertyValues, @@ -614,6 +700,36 @@ export default { } } + /** + * Checks a property value unit to see if it's a baseline feature. + * @param {string} property The name of the property. + * @param {Dimension} child The node to check. + * @returns {void} + */ + function checkPropertyValueUnit(property, child) { + if (allowUnits.has(child.unit)) { + return; + } + + const featureStatus = units.get(child.unit); + + // if we don't know of this unit, just skip it + if (featureStatus === undefined) { + return; + } + + if (!baselineAvailability.isSupported(featureStatus)) { + context.report({ + loc: child.loc, + messageId: "notBaselineUnit", + data: { + unit: child.unit, + availability: baselineAvailability.availability, + }, + }); + } + } + return { "Atrule[name=/^supports$/i]"() { supportsRules.push(new SupportsRule()); @@ -647,6 +763,11 @@ export default { return; } + if (child.type === "Dimension") { + supportedProperty.addUnit(child.unit); + return; + } + if (child.type === "Function") { supportedProperty.addFunction(child.name); } @@ -744,6 +865,16 @@ export default { continue; } + if (child.type === "Dimension") { + if ( + !supportsRules.hasPropertyUnit(property, child.unit) + ) { + checkPropertyValueUnit(property, child); + } + + continue; + } + if (child.type === "Function") { if ( !supportsRules.hasPropertyFunction( diff --git a/tests/rules/use-baseline.test.js b/tests/rules/use-baseline.test.js index 82734d18..847fdea5 100644 --- a/tests/rules/use-baseline.test.js +++ b/tests/rules/use-baseline.test.js @@ -150,6 +150,19 @@ ruleTester.run("use-baseline", rule, { code: "@supports (clip-path: fill-box) { .a { clip-path: fill-box; }\n.b { clip-path: stroke-box; } }", options: [{ allowPropertyValues: { "clip-path": ["stroke-box"] } }], }, + "a { height: 100vh; }", + "a { width: 50vw; }", + "a { height: 100svh; }", + "a { height: 100px; }", + "@supports (height: 100svh) { a { height: 100svh; } }", + { + code: "a { height: 100svh; }", + options: [{ allowUnits: ["svh"] }], + }, + { + code: "a { height: 100svh; }", + options: [{ available: 2022 }], + }, ], invalid: [ { @@ -713,5 +726,56 @@ ruleTester.run("use-baseline", rule, { }, ], }, + { + code: "a { height: 100svh; }", + options: [{ available: 2021 }], + errors: [ + { + messageId: "notBaselineUnit", + data: { + unit: "svh", + availability: 2021, + }, + line: 1, + column: 13, + endLine: 1, + endColumn: 19, + }, + ], + }, + { + code: "a { height: 100dvh; }", + options: [{ available: 2021 }], + errors: [ + { + messageId: "notBaselineUnit", + data: { + unit: "dvh", + availability: 2021, + }, + line: 1, + column: 13, + endLine: 1, + endColumn: 19, + }, + ], + }, + { + code: "@supports (height: 100svh) { a { height: 100dvh; } }", + options: [{ available: 2021 }], + errors: [ + { + messageId: "notBaselineUnit", + data: { + unit: "dvh", + availability: 2021, + }, + line: 1, + column: 42, + endLine: 1, + endColumn: 48, + }, + ], + }, ], }); diff --git a/tools/generate-baseline.js b/tools/generate-baseline.js index 22c9edd1..06c99981 100644 --- a/tools/generate-baseline.js +++ b/tools/generate-baseline.js @@ -25,6 +25,39 @@ import fs from "node:fs"; const WIDE_SUPPORT_PROPERTIES = new Set(["cursor"]); +/** + * Mapping from grouped BCD keys to individual CSS unit names. + * These keys use underscores to describe a group of related units. + * https://github.com/mdn/browser-compat-data/blob/main/css/types/length.json + */ +const groupedUnitMappings = { + viewportPercentageUnitsSmall: [ + "svb", + "svh", + "svi", + "svmax", + "svmin", + "svw", + ], + viewportPercentageUnitsLarge: [ + "lvb", + "lvh", + "lvi", + "lvmax", + "lvmin", + "lvw", + ], + viewportPercentageUnitsDynamic: [ + "dvb", + "dvh", + "dvi", + "dvmax", + "dvmin", + "dvw", + ], + containerQueryLengthUnits: ["cqw", "cqh", "cqi", "cqb", "cqmin", "cqmax"], +}; + const BASELINE_HIGH = 10; const BASELINE_LOW = 5; const BASELINE_FALSE = 0; @@ -102,6 +135,7 @@ function extractCSSFeatures(features) { const cssMediaConditionPattern = /^css\.at-rules\.media\.(?[a-zA-Z$\d-]+)$/u; const cssTypePattern = /^css\.types\.(?:.*?\.)?(?[a-zA-Z\d-]+)$/u; + const cssUnitPattern = /^css\.types\.length\.(?[\w-]+)$/u; const cssSelectorPattern = /^css\.selectors\.(?[a-zA-Z$\d-]+)$/u; const properties = {}; @@ -109,13 +143,48 @@ function extractCSSFeatures(features) { const atRules = {}; const mediaConditions = {}; const functions = {}; + const units = {}; const selectors = {}; for (const [key, featureId] of Object.entries(features)) { const feature = webFeatures[featureId]; - const status = feature.status.by_compat_key[key]; let match; + // Handle CSS units before the by_compat_key check, since some unit + // features (e.g., viewport-unit-variants) don't have by_compat_key. + if ((match = cssUnitPattern.exec(key)) !== null) { + const unitStatus = + feature.status.by_compat_key?.[key] ?? feature.status; + + if (unitStatus.baseline === undefined) { + continue; + } + + const unitKey = match.groups.unit; + const encoded = mapFeatureStatus(unitStatus); + + // Grouped keys (with underscores) map to multiple unit names + // Convert BCD underscore keys to camelCase for lookup + const camelKey = unitKey.replace(/_([a-z])/gu, (_, c) => + c.toUpperCase(), + ); + if (groupedUnitMappings[camelKey]) { + for (const unit of groupedUnitMappings[camelKey]) { + units[unit] = encoded; + } + } else { + // Simple unit names like "vb", "vi" + units[unitKey] = encoded; + } + + continue; + } + + const status = feature.status.by_compat_key?.[key]; + if (!status) { + continue; + } + // property names if ( (match = cssPropertyPattern.exec(key)) !== null && @@ -175,6 +244,7 @@ function extractCSSFeatures(features) { atRules, mediaConditions, functions, + units, selectors, }; } @@ -210,6 +280,7 @@ export const properties = new Map(${JSON.stringify(Object.entries(cssFeatures.pr export const atRules = new Map(${JSON.stringify(Object.entries(cssFeatures.atRules), null, "\t")}); export const mediaConditions = new Map(${JSON.stringify(Object.entries(cssFeatures.mediaConditions), null, "\t")}); export const functions = new Map(${JSON.stringify(Object.entries(cssFeatures.functions), null, "\t")}); +export const units = new Map(${JSON.stringify(Object.entries(cssFeatures.units), null, "\t")}); export const selectors = new Map(${JSON.stringify(Object.entries(cssFeatures.selectors), null, "\t")}); export const propertyValues = new Map([${Object.entries( cssFeatures.propertyValues,