diff --git a/README.md b/README.md index f6566be..8a7e8e2 100644 --- a/README.md +++ b/README.md @@ -55,14 +55,13 @@ export default [ - -| **Rule Name** | **Description** | **Recommended** | -| :--------------------------------------------------------------- | :------------------------------- | :-------------: | -| [`no-duplicate-imports`](./docs/rules/no-duplicate-imports.md) | Disallow duplicate @import rules | yes | -| [`no-empty-blocks`](./docs/rules/no-empty-blocks.md) | Disallow empty blocks | yes | -| [`no-invalid-at-rules`](./docs/rules/no-invalid-at-rules.md) | Disallow invalid at-rules | yes | -| [`no-invalid-properties`](./docs/rules/no-invalid-properties.md) | Disallow invalid properties | yes | - +| **Rule Name** | **Description** | **Recommended** | +| :- | :- | :-: | +| [`baseline`](./docs/rules/baseline.md) | Enforce the use of baseline features | yes | +| [`no-duplicate-imports`](./docs/rules/no-duplicate-imports.md) | Disallow duplicate @import rules | yes | +| [`no-empty-blocks`](./docs/rules/no-empty-blocks.md) | Disallow empty blocks | yes | +| [`no-invalid-at-rules`](./docs/rules/no-invalid-at-rules.md) | Disallow invalid at-rules | yes | +| [`no-invalid-properties`](./docs/rules/no-invalid-properties.md) | Disallow invalid properties | yes | **Note:** This plugin does not provide formatting rules. We recommend using a source code formatter such as [Prettier](https://prettier.io) for that purpose. diff --git a/docs/rules/baseline.md b/docs/rules/baseline.md new file mode 100644 index 0000000..42d16d7 --- /dev/null +++ b/docs/rules/baseline.md @@ -0,0 +1,39 @@ +# baseline + +Enforce the use of baseline features + +## Background + +[Baseline](https://web.dev/baseline) is an effort by the [W3C WebDX Community Group](https://github.com/web-platform-dx) to document which features are available in four core browsers: Chrome (desktop and Android), Edge, Firefox (desktop and Android), and Safari (macOS and iOS). This data allows developers to choose the technologies that are best supported for their audience. As part of this effort, Baseline tracks which CSS features are available in which browsers. + +Features are grouped into three levels: + +- **Widely available** features are those supported by all core browsers for at least 30 months. +- **Newly available** features are those supported by all core browsers for less than 30 months. +- **Limited availability** features are those supported by some but not all core browsers. + +Generally speaking, it's preferable to stick to widely available features to ensure the greatest interoperability across browsers. + +## Rule Details + +This rule warns when it finds a CSS property or at-rule that isn't widely available or otherwise isn't enclosed in a `@supports` block. The data is provided via the [web-features](https://npmjs.com/package/web-features) package. + +Here are some examples: + +```css +/* invalid - accent-color is not widely available */ +a { + accent-color: red; +} + +/* valid - @supports indicates you're choosing a limited availability property */ +@supports (accent-color: auto) { + a { + accent-color: red; + } +} +``` + +## When Not to Use It + +If your web application targets just one browser then you can safely disable this rule. diff --git a/package.json b/package.json index a88afb8..bc68f87 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,10 @@ "prettier --write" ], "!(*.js)": "prettier --write --ignore-unknown", - "{src/rules/*.js,tools/update-rules-docs.js}": "npm run build:update-rules-docs" + "{src/rules/*.js,tools/update-rules-docs.js}": [ + "node tools/update-rules-docs.js", + "git add README.md " + ] }, "repository": { "type": "git", @@ -48,9 +51,10 @@ "scripts": { "build:dedupe-types": "node tools/dedupe-types.js dist/cjs/index.cjs dist/esm/index.js", "build:cts": "node -e \"fs.copyFileSync('dist/esm/index.d.ts', 'dist/cjs/index.d.cts')\"", - "build": "rollup -c && npm run build:dedupe-types && tsc -p tsconfig.esm.json && npm run build:cts", + "build": "npm run build:baseline && rollup -c && npm run build:dedupe-types && tsc -p tsconfig.esm.json && npm run build:cts", "build:readme": "node tools/update-readme.js", "build:update-rules-docs": "node tools/update-rules-docs.js", + "build:baseline": "node tools/generate-baseline.js", "test:jsr": "npx jsr@latest publish --dry-run", "pretest": "npm run build", "lint": "eslint", @@ -89,6 +93,7 @@ "rollup": "^4.16.2", "rollup-plugin-copy": "^3.5.0", "typescript": "^5.4.5", + "web-features": "^2.11.0", "yorkie": "^2.0.0" }, "engines": { diff --git a/src/data/README.md b/src/data/README.md new file mode 100644 index 0000000..473120d --- /dev/null +++ b/src/data/README.md @@ -0,0 +1,9 @@ +# Autogenerated Data + +The files in this directory are auto-generated and should not be modified manually. + +To generate baseline data, run: + +```shell +npm run build:baseline +``` diff --git a/src/data/baseline-data.js b/src/data/baseline-data.js new file mode 100644 index 0000000..711fc67 --- /dev/null +++ b/src/data/baseline-data.js @@ -0,0 +1,724 @@ +/** + * @fileoverview CSS features extracted from the web-features package. + * @author tools/generate-baseline.js + * + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY DIRECTLY. + */ + +export const BASELINE_HIGH = 10; +export const BASELINE_LOW = 5; +export const BASELINE_FALSE = 0; + +export const properties = new Map([ + ["align-self", 10], + ["justify-self", 10], + ["place-self", 10], + ["position", 10], + ["accent-color", 0], + ["align-content", 10], + ["alignment-baseline", 0], + ["all", 10], + ["content", 10], + ["align-items", 10], + ["anchor-name", 0], + ["anchor-scope", 0], + ["block-size", 10], + ["bottom", 10], + ["height", 10], + ["inline-size", 10], + ["inset-block-end", 10], + ["inset-block-start", 10], + ["inset-block", 10], + ["inset-inline-end", 10], + ["inset-inline-start", 10], + ["inset-inline", 10], + ["inset", 10], + ["justify-items", 10], + ["left", 10], + ["max-block-size", 10], + ["max-height", 10], + ["max-inline-size", 10], + ["max-width", 10], + ["min-block-size", 10], + ["min-height", 10], + ["min-inline-size", 10], + ["min-width", 10], + ["place-items", 10], + ["position-anchor", 0], + ["position-area", 0], + ["position-try", 0], + ["position-try-fallbacks", 0], + ["position-try-order", 0], + ["position-visibility", 0], + ["right", 10], + ["top", 10], + ["width", 10], + ["animation-composition", 5], + ["animation", 10], + ["animation-delay", 10], + ["animation-direction", 10], + ["animation-duration", 10], + ["animation-fill-mode", 10], + ["animation-iteration-count", 10], + ["animation-name", 10], + ["animation-play-state", 10], + ["animation-timing-function", 10], + ["appearance", 10], + ["aspect-ratio", 10], + ["backdrop-filter", 5], + ["background", 10], + ["background-attachment", 10], + ["background-blend-mode", 10], + ["background-clip", 10], + ["background-color", 10], + ["background-image", 10], + ["background-origin", 10], + ["background-position", 10], + ["background-position-x", 10], + ["background-position-y", 10], + ["background-repeat", 10], + ["background-size", 10], + ["baseline-shift", 0], + ["baseline-source", 0], + ["border-image", 10], + ["border-image-outset", 10], + ["border-image-repeat", 10], + ["border-image-slice", 10], + ["border-image-source", 10], + ["border-image-width", 10], + ["border-bottom-left-radius", 10], + ["border-bottom-right-radius", 10], + ["border-radius", 10], + ["border-top-left-radius", 10], + ["border-top-right-radius", 10], + ["border", 10], + ["border-bottom", 10], + ["border-bottom-color", 10], + ["border-bottom-style", 10], + ["border-bottom-width", 10], + ["border-color", 10], + ["border-left", 10], + ["border-left-color", 10], + ["border-left-style", 10], + ["border-left-width", 10], + ["border-right", 10], + ["border-right-color", 10], + ["border-right-style", 10], + ["border-right-width", 10], + ["border-style", 10], + ["border-top", 10], + ["border-top-color", 10], + ["border-top-style", 10], + ["border-top-width", 10], + ["border-width", 10], + ["box-decoration-break", 0], + ["box-shadow", 10], + ["box-sizing", 10], + ["caret-color", 10], + ["clip-path", 10], + ["color", 10], + ["color-scheme", 10], + ["break-after", 0], + ["break-after.multicol_context", 0], + ["break-before", 0], + ["break-before.multicol_context", 0], + ["break-inside", 0], + ["break-inside.multicol_context", 0], + ["column-fill", 10], + ["column-span", 10], + ["contain", 10], + ["contain-intrinsic-block-size", 5], + ["contain-intrinsic-height", 5], + ["contain-intrinsic-inline-size", 5], + ["contain-intrinsic-size", 5], + ["contain-intrinsic-width", 5], + ["container", 5], + ["container-name", 5], + ["container-type", 5], + ["content-visibility", 5], + ["counter-reset", 10], + ["counter-set", 5], + ["counter-increment", 10], + ["image-rendering", 10], + ["cursor", 0], + ["text-overflow", 10], + ["custom-property", 10], + ["display", 10], + ["display.none", 10], + ["display.contents", 0], + ["display.list-item", 10], + ["dominant-baseline", 10], + ["field-sizing", 0], + ["filter", 10], + ["align-content.flex_context", 10], + ["align-items.flex_context", 10], + ["align-self.flex_context", 10], + ["flex", 10], + ["flex-basis", 10], + ["flex-direction", 10], + ["flex-flow", 10], + ["flex-grow", 10], + ["flex-shrink", 10], + ["flex-wrap", 10], + ["justify-content", 10], + ["justify-content.flex_context", 10], + ["order", 10], + ["place-content", 10], + ["column-gap", 10], + ["gap", 10], + ["row-gap", 10], + ["clear", 10], + ["float", 10], + ["font-family", 10], + ["font-feature-settings", 10], + ["font-kerning", 10], + ["font-language-override", 0], + ["font-optical-sizing", 10], + ["font-palette", 5], + ["font", 10], + ["font-size", 10], + ["font-size-adjust", 5], + ["font-stretch", 10], + ["font-style", 10], + ["font-synthesis", 10], + ["font-synthesis-position", 0], + ["font-synthesis-small-caps", 5], + ["font-synthesis-style", 5], + ["font-synthesis-weight", 5], + ["font-variant", 10], + ["font-variant-alternates", 5], + ["font-variant-caps", 10], + ["font-variant-east-asian", 10], + ["font-variant-emoji", 0], + ["font-variant-ligatures", 10], + ["font-variant-numeric", 10], + ["font-variant-position", 0], + ["font-variation-settings", 10], + ["font-weight", 10], + ["forced-color-adjust", 5], + ["align-items.grid_context", 10], + ["gap.grid_context", 10], + ["grid", 10], + ["grid-area", 10], + ["grid-auto-columns", 10], + ["grid-auto-flow", 10], + ["grid-auto-rows", 10], + ["grid-column", 10], + ["grid-column-end", 10], + ["grid-column-start", 10], + ["grid-row", 10], + ["grid-row-end", 10], + ["grid-row-start", 10], + ["grid-template", 10], + ["grid-template-areas", 10], + ["grid-template-columns", 10], + ["grid-template-rows", 10], + ["hanging-punctuation", 0], + ["hyphenate-character", 5], + ["hyphenate-limit-chars", 0], + ["hyphens", 5], + ["image-orientation", 10], + ["rotate", 5], + ["scale", 5], + ["translate", 5], + ["initial-letter", 0], + ["interpolate-size", 0], + ["isolation", 10], + ["direction", 10], + ["unicode-bidi", 10], + ["letter-spacing", 10], + ["line-break", 10], + ["line-clamp", 0], + ["line-height", 10], + ["list-style", 10], + ["list-style-image", 10], + ["list-style-position", 10], + ["list-style-type", 10], + ["border-block", 10], + ["border-block-color", 10], + ["border-block-end", 10], + ["border-block-end-color", 10], + ["border-block-end-style", 10], + ["border-block-end-width", 10], + ["border-block-start", 10], + ["border-block-start-color", 10], + ["border-block-start-style", 10], + ["border-block-start-width", 10], + ["border-block-style", 10], + ["border-block-width", 10], + ["border-end-end-radius", 10], + ["border-end-start-radius", 10], + ["border-inline", 10], + ["border-inline-color", 10], + ["border-inline-end", 10], + ["border-inline-end-color", 10], + ["border-inline-end-style", 10], + ["border-inline-end-width", 10], + ["border-inline-start", 10], + ["border-inline-start-color", 10], + ["border-inline-start-style", 10], + ["border-inline-start-width", 10], + ["border-inline-style", 10], + ["border-inline-width", 10], + ["border-start-end-radius", 10], + ["border-start-start-radius", 10], + ["margin-block", 10], + ["margin-block-end", 10], + ["margin-block-start", 10], + ["margin-inline", 10], + ["margin-inline-end", 10], + ["margin-inline-start", 10], + ["overflow-block", 10], + ["overflow-inline", 10], + ["padding-block", 10], + ["padding-block-end", 10], + ["padding-block-start", 10], + ["padding-inline", 10], + ["padding-inline-end", 10], + ["padding-inline-start", 10], + ["margin", 10], + ["margin-bottom", 10], + ["margin-left", 10], + ["margin-right", 10], + ["margin-top", 10], + ["margin-trim", 0], + ["mask-border", 0], + ["mask-border-outset", 0], + ["mask-border-repeat", 0], + ["mask-border-slice", 0], + ["mask-border-source", 0], + ["mask-border-width", 0], + ["mask-type", 10], + ["mask", 5], + ["mask-clip", 5], + ["mask-composite", 5], + ["mask-image", 5], + ["mask-mode", 5], + ["mask-origin", 5], + ["mask-position", 5], + ["mask-repeat", 5], + ["mask-size", 5], + ["math-depth", 5], + ["math-shift", 5], + ["math-style", 5], + ["text-transform", 10], + ["mix-blend-mode", 10], + ["offset", 5], + ["offset-anchor", 5], + ["offset-distance", 5], + ["offset-path", 5], + ["offset-position", 5], + ["offset-rotate", 5], + ["column-count", 10], + ["column-gap.multicol_context", 10], + ["column-rule", 10], + ["column-rule-color", 10], + ["column-rule-style", 10], + ["column-rule-width", 10], + ["column-width", 10], + ["columns", 10], + ["object-fit", 10], + ["object-position", 10], + ["object-view-box", 0], + ["opacity", 10], + ["fill-opacity", 10], + ["stroke-opacity", 10], + ["outline", 5], + ["outline-color", 10], + ["outline-offset", 10], + ["outline-style", 10], + ["outline-width", 10], + ["overflow-anchor", 0], + ["overflow-clip-margin", 0], + ["overflow", 5], + ["overflow-x", 5], + ["overflow-y", 5], + ["overflow-wrap", 10], + ["overlay", 0], + ["overscroll-behavior", 5], + ["overscroll-behavior-block", 5], + ["overscroll-behavior-inline", 5], + ["overscroll-behavior-x", 5], + ["overscroll-behavior-y", 5], + ["padding", 10], + ["padding-bottom", 10], + ["padding-left", 10], + ["padding-right", 10], + ["padding-top", 10], + ["break-after.paged_context", 0], + ["break-before.paged_context", 0], + ["break-inside.paged_context", 0], + ["page-break-after", 0], + ["page-break-before", 0], + ["page-break-inside", 0], + ["page", 0], + ["paint-order", 0], + ["pointer-events", 10], + ["print-color-adjust", 0], + ["quotes", 10], + ["reading-flow", 0], + ["resize", 0], + ["line-height-step", 0], + ["ruby-align", 0], + ["ruby-overhang", 0], + ["ruby-position", 0], + ["custom-property.env", 10], + ["scroll-behavior", 10], + ["animation-range", 0], + ["animation-range-end", 0], + ["animation-range-start", 0], + ["animation-timeline", 0], + ["scroll-timeline", 0], + ["scroll-timeline-axis", 0], + ["scroll-timeline-name", 0], + ["timeline-scope", 0], + ["view-timeline", 0], + ["view-timeline-axis", 0], + ["view-timeline-inset", 0], + ["view-timeline-name", 0], + ["scroll-margin", 10], + ["scroll-margin-block", 10], + ["scroll-margin-block-end", 10], + ["scroll-margin-block-start", 10], + ["scroll-margin-bottom", 10], + ["scroll-margin-inline", 10], + ["scroll-margin-inline-end", 10], + ["scroll-margin-inline-start", 10], + ["scroll-margin-left", 10], + ["scroll-margin-right", 10], + ["scroll-margin-top", 10], + ["scroll-padding", 10], + ["scroll-padding-block", 10], + ["scroll-padding-block-end", 10], + ["scroll-padding-block-start", 10], + ["scroll-padding-bottom", 10], + ["scroll-padding-inline", 10], + ["scroll-padding-inline-end", 10], + ["scroll-padding-inline-start", 10], + ["scroll-padding-left", 10], + ["scroll-padding-right", 10], + ["scroll-padding-top", 10], + ["scroll-snap-align", 10], + ["scroll-snap-stop", 10], + ["scroll-snap-type", 10], + ["scrollbar-color", 0], + ["scrollbar-gutter", 0], + ["scrollbar-width", 0], + ["shape-image-threshold", 10], + ["shape-margin", 10], + ["shape-outside", 10], + ["speak", 0], + ["speak-as", 0], + ["clip-rule", 10], + ["color-interpolation", 10], + ["cx", 10], + ["cy", 10], + ["d", 10], + ["fill", 10], + ["fill-rule", 10], + ["marker", 10], + ["marker-end", 10], + ["marker-mid", 10], + ["marker-start", 10], + ["r", 10], + ["rx", 10], + ["ry", 10], + ["shape-rendering", 10], + ["stop-color", 10], + ["stop-opacity", 10], + ["stroke", 10], + ["stroke-color", 10], + ["stroke-dasharray", 10], + ["stroke-dashoffset", 10], + ["stroke-linecap", 10], + ["stroke-linejoin", 10], + ["stroke-miterlimit", 10], + ["stroke-width", 10], + ["text-anchor", 10], + ["text-rendering", 10], + ["transform-origin", 10], + ["vector-effect", 10], + ["word-spacing", 10], + ["x", 10], + ["y", 10], + ["color-interpolation-filters", 10], + ["flood-color", 10], + ["flood-opacity", 10], + ["lighting-color", 10], + ["tab-size", 10], + ["border-collapse", 10], + ["border-spacing", 10], + ["caption-side", 10], + ["empty-cells", 10], + ["table-layout", 10], + ["text-align", 10], + ["text-align-last", 5], + ["text-box", 0], + ["text-box-edge", 0], + ["text-box-trim", 0], + ["text-combine-upright", 10], + ["text-decoration", 10], + ["text-decoration-color", 10], + ["text-decoration-line", 10], + ["text-decoration-skip", 10], + ["text-decoration-skip-ink", 10], + ["text-decoration-style", 10], + ["text-decoration-thickness", 10], + ["text-emphasis", 10], + ["text-emphasis-color", 10], + ["text-emphasis-position", 10], + ["text-emphasis-style", 10], + ["text-indent", 10], + ["text-justify", 0], + ["text-orientation", 10], + ["text-shadow", 10], + ["text-size-adjust", 0], + ["text-spacing-trim", 0], + ["-webkit-text-fill-color", 10], + ["-webkit-text-stroke", 10], + ["-webkit-text-stroke-color", 10], + ["-webkit-text-stroke-width", 10], + ["text-underline-offset", 10], + ["text-underline-position", 10], + ["text-wrap", 5], + ["text-wrap-mode", 5], + ["text-wrap-style", 0], + ["touch-action", 10], + ["transform-box", 5], + ["transform", 10], + ["backface-visibility", 10], + ["perspective", 10], + ["perspective-origin", 10], + ["transform-style", 10], + ["transition-behavior", 5], + ["transition", 10], + ["transition-delay", 10], + ["transition-duration", 10], + ["transition-property", 10], + ["transition-timing-function", 10], + ["user-select", 0], + ["vertical-align", 10], + ["writing-mode", 10], + ["view-transition-class", 0], + ["view-transition-name", 0], + ["visibility", 10], + ["white-space", 10], + ["white-space-collapse", 5], + ["orphans", 0], + ["widows", 0], + ["will-change", 10], + ["word-break", 10], + ["z-index", 10], + ["zoom", 5], +]); +export const atRules = new Map([ + ["position-try", 0], + ["keyframes", 10], + ["import", 10], + ["layer", 10], + ["charset", 10], + ["media", 10], + ["font-face", 10], + ["container", 5], + ["counter-style", 5], + ["view-transition", 0], + ["media.display-mode", 0], + ["font-face.src", 10], + ["font-palette-values", 5], + ["font-feature-values", 5], + ["namespace", 10], + ["page", 0], + ["page.size", 0], + ["media.prefers-color-scheme", 10], + ["property", 5], + ["scope", 0], + ["starting-style", 0], + ["supports", 10], +]); +export const types = new Map([ + ["abs", 0], + ["sign", 0], + ["anchor", 0], + ["anchor-size", 0], + ["time", 10], + ["attr", 10], + ["attr.type-or-unit", 0], + ["blend-mode", 10], + ["line-style", 10], + ["calc", 10], + ["calc-constant", 5], + ["calc-size", 0], + ["length", 10], + ["global_keywords", 10], + ["color", 10], + ["color.color", 5], + ["gradient", 10], + ["gradient.conic-gradient", 10], + ["string", 10], + ["counter", 10], + ["counters", 10], + ["image", 10], + ["easing-function", 10], + ["exp", 5], + ["hypot", 5], + ["log", 5], + ["pow", 5], + ["sqrt", 5], + ["filter-function", 10], + ["url", 10], + ["gradient.linear-gradient", 10], + ["gradient.radial-gradient", 10], + ["gradient.repeating-conic-gradient", 5], + ["gradient.repeating-linear-gradient", 10], + ["gradient.repeating-radial-gradient", 10], + ["flex", 10], + ["color.hsl", 10], + ["color.hwb", 10], + ["color.lab", 5], + ["color.lch", 5], + ["ratio", 10], + ["clamp", 10], + ["max", 10], + ["min", 10], + ["ray", 5], + ["color.named-color", 10], + ["color.oklab", 5], + ["color.oklch", 5], + ["number", 10], + ["overflow", 5], + ["image.paint", 0], + ["basic-shape", 10], + ["basic-shape.path", 0], + ["color.rgb", 10], + ["resolution", 5], + ["color.rgb_hexadecimal_notation", 10], + ["mod", 5], + ["rem", 5], + ["round", 5], + ["easing-function.steps", 10], + ["color.system-color", 10], + ["angle", 10], + ["angle-percentage", 10], + ["position", 10], + ["transform-function", 10], + ["transform-function.perspective", 10], + ["acos", 5], + ["asin", 5], + ["atan", 5], + ["atan2", 5], + ["cos", 5], + ["sin", 5], + ["tan", 5], + ["dimension", 10], + ["length-percentage", 10], + ["percentage", 10], + ["integer", 10], +]); +export const selectors = new Map([ + ["active-view-transition", 0], + ["active-view-transition-type", 0], + ["autofill", 5], + ["defined", 10], + ["backdrop", 10], + ["after", 10], + ["before", 10], + ["attribute", 10], + ["default", 10], + ["details-content", 0], + ["dir", 5], + ["empty", 10], + ["file-selector-button", 10], + ["first-letter", 10], + ["first-line", 10], + ["focus-visible", 10], + ["focus-within", 10], + ["in-range", 10], + ["invalid", 10], + ["optional", 10], + ["out-of-range", 10], + ["required", 10], + ["valid", 10], + ["fullscreen", 0], + ["has", 5], + ["has-slotted", 0], + ["highlight", 0], + ["host", 10], + ["hostfunction", 10], + ["host-context", 0], + ["indeterminate", 10], + ["checked", 10], + ["disabled", 10], + ["enabled", 10], + ["is", 10], + ["lang", 10], + ["any-link", 10], + ["link", 10], + ["visited", 10], + ["marker", 0], + ["buffering", 0], + ["muted", 0], + ["paused", 0], + ["playing", 0], + ["seeking", 0], + ["stalled", 0], + ["volume-locked", 0], + ["modal", 5], + ["namespace", 10], + ["nesting", 5], + ["not", 10], + ["first-child", 10], + ["last-child", 10], + ["nth-child", 10], + ["nth-last-child", 10], + ["only-child", 10], + ["first-of-type", 10], + ["last-of-type", 10], + ["nth-last-of-type", 10], + ["nth-of-type", 10], + ["only-of-type", 10], + ["closed", 0], + ["open", 0], + ["first", 0], + ["left", 0], + ["right", 0], + ["picture-in-picture", 0], + ["placeholder", 10], + ["placeholder-shown", 10], + ["popover-open", 0], + ["read-only", 10], + ["read-write", 10], + ["root", 10], + ["scope", 10], + ["selection", 0], + ["child", 10], + ["class", 10], + ["descendant", 10], + ["id", 10], + ["list", 10], + ["next-sibling", 10], + ["subsequent-sibling", 10], + ["type", 10], + ["universal", 10], + ["part", 10], + ["slotted", 10], + ["grammar-error", 0], + ["spelling-error", 0], + ["state", 5], + ["target", 10], + ["target-text", 0], + ["future", 0], + ["past", 0], + ["active", 10], + ["focus", 10], + ["hover", 10], + ["user-invalid", 5], + ["user-valid", 5], + ["view-transition", 0], + ["view-transition-group", 0], + ["view-transition-image-pair", 0], + ["view-transition-new", 0], + ["view-transition-old", 0], + ["cue", 10], + ["xr-overlay", 0], + ["where", 10], +]); diff --git a/src/index.js b/src/index.js index b601722..3d1685b 100644 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ import noEmptyBlocks from "./rules/no-empty-blocks.js"; import noDuplicateImports from "./rules/no-duplicate-imports.js"; import noInvalidProperties from "./rules/no-invalid-properties.js"; import noInvalidAtRules from "./rules/no-invalid-at-rules.js"; +import baseline from "./rules/baseline.js"; //----------------------------------------------------------------------------- // Plugin @@ -31,6 +32,7 @@ const plugin = { "no-duplicate-imports": noDuplicateImports, "no-invalid-at-rules": noInvalidAtRules, "no-invalid-properties": noInvalidProperties, + baseline, }, configs: { recommended: { @@ -40,6 +42,7 @@ const plugin = { "css/no-duplicate-imports": "error", "css/no-invalid-at-rules": "error", "css/no-invalid-properties": "error", + "css/baseline": "error", }), }, }, diff --git a/src/rules/baseline.js b/src/rules/baseline.js new file mode 100644 index 0000000..e01ea2d --- /dev/null +++ b/src/rules/baseline.js @@ -0,0 +1,144 @@ +/** + * @fileoverview Rule to enforce the use of baseline features. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { + BASELINE_HIGH, + BASELINE_LOW, + properties, + atRules, +} from "../data/baseline-data.js"; + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +export default { + meta: { + type: /** @type {const} */ ("problem"), + + docs: { + description: "Enforce the use of baseline features", + recommended: true, + }, + + schema: [ + { + type: "object", + properties: { + available: { + enum: ["widely", "newly"], + }, + }, + additionalProperties: false, + }, + ], + + defaultOptions: [ + { + available: "widely", + }, + ], + + messages: { + notBaselineProperty: + "Property '{{property}}' is not a {{availability}} available baseline feature.", + notBaselineAtRule: + "At-rule '@{{atRule}}' is not a {{availability}} available baseline feature.", + }, + }, + + create(context) { + const availability = context.options[0].available; + const baselineLevel = + availability === "widely" ? BASELINE_HIGH : BASELINE_LOW; + const atSupportedProperties = new Set(); + + return { + "Atrule[name=supports] SupportsDeclaration > Declaration"(node) { + atSupportedProperties.add(node.property); + }, + + "Rule > Block > Declaration"(node) { + // ignore unknown properties - no-invalid-properties already catches this + if (!properties.has(node.property)) { + return; + } + + // if the property has been tested in a @supports rule, ignore it + if (atSupportedProperties.has(node.property)) { + return; + } + + const ruleLevel = properties.get(node.property); + + if (ruleLevel < baselineLevel) { + context.report({ + loc: { + start: node.loc.start, + end: { + line: node.loc.start.line, + column: + node.loc.start.column + + node.property.length, + }, + }, + messageId: "notBaselineProperty", + data: { + property: node.property, + availability, + }, + }); + } + }, + + "Atrule[name=supports]:exit"(node) { + // remove all properties tested in this @supports rule + node.prelude.children.forEach(condition => { + condition.children.forEach(child => { + if (child.type === "SupportsDeclaration") { + atSupportedProperties.delete( + child.declaration.property, + ); + } + }); + }); + }, + + Atrule(node) { + // ignore unknown at-rules - no-invalid-at-rules already catches this + if (!atRules.has(node.name)) { + return; + } + + const ruleLevel = atRules.get(node.name); + + if (ruleLevel < baselineLevel) { + const loc = node.loc; + + context.report({ + loc: { + start: loc.start, + end: { + line: loc.start.line, + + // add 1 to account for the @ symbol + column: loc.start.column + node.name.length + 1, + }, + }, + messageId: "notBaselineAtRule", + data: { + atRule: node.name, + availability, + }, + }); + } + }, + }; + }, +}; diff --git a/tests/rules/baseline.test.js b/tests/rules/baseline.test.js new file mode 100644 index 0000000..96c83e7 --- /dev/null +++ b/tests/rules/baseline.test.js @@ -0,0 +1,181 @@ +/** + * @fileoverview Tests for baseline rule. + * @author Nicholas C. Zakas + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/baseline.js"; +import css from "../../src/index.js"; +import { RuleTester } from "eslint"; +import dedent from "dedent"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + css, + }, + language: "css/css", +}); + +ruleTester.run("baseline", rule, { + valid: [ + "a { color: red; }", + "a { color: red; background-color: blue; }", + "a { color: red; transition: none; }", + "body { --custom-property: red; }", + "body { padding: 0; }", + "a { color: red; -moz-transition: bar }", + "@font-face { font-weight: 100 400 }", + "@media (min-width: 800px) { a { color: red; } }", + "@supports (accent-color: auto) { a { accent-color: red; } }", + `@supports (accent-color: auto) and (backdrop-filter: auto) { + a { accent-color: red; background-filter: auto } + }`, + `@supports (accent-color: auto) { + @supports (backdrop-filter: auto) { + a { accent-color: red; background-filter: auto } + } + }`, + { + code: `@property --foo { + syntax: "*"; + inherits: false; + }`, + options: [{ available: "newly" }], + }, + { + code: "a { backdrop-filter: auto }", + options: [{ available: "newly" }], + }, + ], + invalid: [ + { + code: "a { accent-color: bar; backdrop-filter: auto }", + errors: [ + { + messageId: "notBaselineProperty", + data: { + property: "accent-color", + availability: "widely", + }, + line: 1, + column: 5, + endLine: 1, + endColumn: 17, + }, + { + messageId: "notBaselineProperty", + data: { + property: "backdrop-filter", + availability: "widely", + }, + line: 1, + column: 24, + endLine: 1, + endColumn: 39, + }, + ], + }, + { + code: "a { accent-color: bar; backdrop-filter: auto }", + options: [{ available: "newly" }], + errors: [ + { + messageId: "notBaselineProperty", + data: { + property: "accent-color", + availability: "newly", + }, + line: 1, + column: 5, + endLine: 1, + endColumn: 17, + }, + ], + }, + { + code: `@property --foo { + syntax: "*"; + inherits: false; + } + @media (min-width: 800px) { + a { color: red; } + }`, + options: [{ available: "widely" }], + errors: [ + { + messageId: "notBaselineAtRule", + data: { + atRule: "property", + availability: "widely", + }, + line: 1, + column: 1, + endLine: 1, + endColumn: 10, + }, + ], + }, + { + code: "@container (min-width: 800px) { a { color: red; } }", + errors: [ + { + messageId: "notBaselineAtRule", + data: { + atRule: "container", + availability: "widely", + }, + line: 1, + column: 1, + endLine: 1, + endColumn: 11, + }, + ], + }, + { + code: "@view-transition { from-view: a; to-view: b; }\n@container (min-width: 800px) { a { color: red; } }", + options: [{ available: "newly" }], + errors: [ + { + messageId: "notBaselineAtRule", + data: { + atRule: "view-transition", + availability: "newly", + }, + line: 1, + column: 1, + endLine: 1, + endColumn: 17, + }, + ], + }, + { + code: dedent`@supports (accent-color: auto) { + @supports (backdrop-filter: auto) { + a { accent-color: red; } + } + + a { backdrop-filter: auto; } + }`, + errors: [ + { + messageId: "notBaselineProperty", + data: { + property: "backdrop-filter", + availability: "widely", + }, + line: 6, + column: 9, + endLine: 6, + endColumn: 24, + }, + ], + }, + ], +}); diff --git a/tools/generate-baseline.js b/tools/generate-baseline.js new file mode 100644 index 0000000..0f27fde --- /dev/null +++ b/tools/generate-baseline.js @@ -0,0 +1,190 @@ +/** + * @fileoverview Extracts CSS features from the web-features package and writes + * them to a file. + * See example output from web-features: https://gist.github.com/nzakas/5bbc9eab6900d1e401208fa7bcf49500 + * @author Nicholas C. Zakas + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import { features } from "web-features"; +import fs from "node:fs"; + +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +const BASELINE_HIGH = 10; +const BASELINE_LOW = 5; +const BASELINE_FALSE = 0; +const baselineIds = new Map([ + ["high", BASELINE_HIGH], + ["low", BASELINE_LOW], + [false, BASELINE_FALSE], +]); + +/** + * Filters out non-CSS features and minimizes the data. + * @param {[string, Object]} entry The entry to filter. + * @returns {boolean} True if the entry is a CSS feature, false otherwise. + */ +function filterCSSFeatures([, value]) { + return value.compat_features?.some(feature => feature.startsWith("css.")); +} + +/** + * Minimizes the data for a CSS feature. + * @param {[string, Object]} entry The entry to minimize. + * @returns {[string, Object]} The minimized entry. + */ +function minimizeData([key, value]) { + return [ + key, + { + baseline: value.status.baseline, + properties: [ + ...new Set( + value.compat_features + .filter(feature => + feature.startsWith("css.properties."), + ) + .map(feature => + feature + .replace("css.properties.", "") + .replace(/\.[\w\d-]+$/u, ""), + ), + ), + ], + atRules: [ + ...new Set( + value.compat_features + .filter(feature => feature.startsWith("css.at-rules.")) + .map(feature => + feature + .replace("css.at-rules.", "") + .replace(/\.[\w\d-]+$/u, ""), + ), + ), + ], + types: [ + ...new Set( + value.compat_features + .filter(feature => feature.startsWith("css.types.")) + .map(feature => + feature + .replace("css.types.", "") + .replace(/\.[\w\d-]+$/u, ""), + ), + ), + ], + selectors: [ + ...new Set( + value.compat_features + .filter(feature => feature.startsWith("css.selectors.")) + .map(feature => + feature + .replace("css.selectors.", "") + .replace(/\.[\w\d-]+$/u, ""), + ), + ), + ], + }, + ]; +} + +/** + * Groups CSS features by baseline. + * @param {Array<[string, Object]>} entries The entries to group. + * @returns {Object} The grouped CSS features. + */ +function groupByBaseline(entries) { + const allFeatures = { + properties: {}, + atRules: {}, + types: {}, + selectors: {}, + }; + + /* + * We end up with duplicates due to the naive way we are calculating + * which values to include. So we need to remove duplicates and update + * each to the highest possible baseline value. + */ + + for (const [, value] of entries) { + value.properties.forEach(property => { + if ( + allFeatures.properties[property] === undefined || + allFeatures.properties[property] < + baselineIds.get(value.baseline) + ) { + allFeatures.properties[property] = baselineIds.get( + value.baseline, + ); + } + }); + + value.atRules.forEach(atRule => { + if ( + allFeatures.atRules[atRule] === undefined || + allFeatures.atRules[atRule] < baselineIds.get(value.baseline) + ) { + allFeatures.atRules[atRule] = baselineIds.get(value.baseline); + } + }); + + value.types.forEach(type => { + if ( + allFeatures.types[type] === undefined || + allFeatures.types[type] < baselineIds.get(value.baseline) + ) { + allFeatures.types[type] = baselineIds.get(value.baseline); + } + }); + + value.selectors.forEach(selector => { + if ( + allFeatures.selectors[selector] === undefined || + allFeatures.selectors[selector] < + baselineIds.get(value.baseline) + ) { + allFeatures.selectors[selector] = baselineIds.get( + value.baseline, + ); + } + }); + } + + return allFeatures; +} + +//------------------------------------------------------------------------------ +// Main +//------------------------------------------------------------------------------ + +const featuresPath = "./src/data/baseline-data.js"; +const cssFeatures = groupByBaseline( + Object.entries(features).filter(filterCSSFeatures).map(minimizeData), +); + +// export each group separately as a Set, such as highProperties, lowProperties, etc. +const code = `/** + * @fileoverview CSS features extracted from the web-features package. + * @author tools/generate-baseline.js + * + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY DIRECTLY. + */ + +export const BASELINE_HIGH = ${BASELINE_HIGH}; +export const BASELINE_LOW = ${BASELINE_LOW}; +export const BASELINE_FALSE = ${BASELINE_FALSE}; + +export const properties = new Map(${JSON.stringify(Object.entries(cssFeatures.properties), null, "\t")}); +export const atRules = new Map(${JSON.stringify(Object.entries(cssFeatures.atRules), null, "\t")}); +export const types = new Map(${JSON.stringify(Object.entries(cssFeatures.types), null, "\t")}); +export const selectors = new Map(${JSON.stringify(Object.entries(cssFeatures.selectors), null, "\t")}); +`; + +fs.writeFileSync(featuresPath, code);