diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..accffc8
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,4 @@
+# Convert text file line endings to lf
+* text=auto
+
+*.js text eol=lf
diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml
new file mode 100644
index 0000000..9aeb487
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-report.yml
@@ -0,0 +1,81 @@
+name: "\U0001F41E Report a problem"
+description: "Report something that isn't working the way you expected."
+title: "Bug: (fill in)"
+labels:
+ - bug
+ - "repro:needed"
+body:
+ - type: markdown
+ attributes:
+ value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct).
+ - type: textarea
+ attributes:
+ label: Environment
+ description: |
+ Please tell us about how you're running ESLint (Run `npx eslint --env-info`.)
+ value: |
+ ESLint version:
+ @eslint/css version:
+ Node version:
+ npm version:
+ Operating System:
+ validations:
+ required: true
+ - type: dropdown
+ attributes:
+ label: Which language are you using?
+ description: |
+ Just tell us which language mode you're using.
+ options:
+ - stylesheet
+ - rule
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: What did you do?
+ description: |
+ Please include a *minimal* reproduction case.
+ value: |
+
+ Configuration
+
+ ```
+
+ ```
+
+
+ ```js
+
+ ```
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: What did you expect to happen?
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: What actually happened?
+ description: |
+ Please copy-paste the actual ESLint output.
+ validations:
+ required: true
+ - type: input
+ attributes:
+ label: Link to Minimal Reproducible Example
+ description: "Link to a [StackBlitz](https://stackblitz.com), or GitHub repo with a minimal reproduction of the problem. **A minimal reproduction is required** so that others can help debug your issue. If a report is vague (e.g. just a generic error message) and has no reproduction, it may be auto-closed."
+ placeholder: "https://stackblitz.com/abcd1234"
+ validations:
+ required: true
+ - type: checkboxes
+ attributes:
+ label: Participation
+ options:
+ - label: I am willing to submit a pull request for this issue.
+ required: false
+ - type: textarea
+ attributes:
+ label: Additional comments
+ description: Is there anything else that's important for the team to know?
diff --git a/.github/ISSUE_TEMPLATE/change.yml b/.github/ISSUE_TEMPLATE/change.yml
new file mode 100644
index 0000000..5af37c9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/change.yml
@@ -0,0 +1,51 @@
+name: "\U0001F680 Request a change (not rule-related)"
+description: "Request a change that is not a bug fix, rule change, or new rule"
+title: "Change Request: (fill in)"
+labels:
+ - enhancement
+ - core
+body:
+ - type: markdown
+ attributes:
+ value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct).
+ - type: textarea
+ attributes:
+ label: Environment
+ description: |
+ Please tell us about how you're running ESLint (Run `npx eslint --env-info`.)
+ value: |
+ ESLint version:
+ @eslint/css version:
+ Node version:
+ npm version:
+ Operating System:
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: What problem do you want to solve?
+ description: |
+ Please explain your use case in as much detail as possible.
+ placeholder: |
+ The CSS plugin currently...
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: What do you think is the correct solution?
+ description: |
+ Please explain how you'd like to change the CSS plugin to address the problem.
+ placeholder: |
+ I'd like the CSS plugin to...
+ validations:
+ required: true
+ - type: checkboxes
+ attributes:
+ label: Participation
+ options:
+ - label: I am willing to submit a pull request for this change.
+ required: false
+ - type: textarea
+ attributes:
+ label: Additional comments
+ description: Is there anything else that's important for the team to know?
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..341580f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: 🗣 Ask a Question, Discuss
+ url: https://github.com/eslint/css/discussions
+ about: Get help using this plugin
+ - name: Discord Server
+ url: https://eslint.org/chat
+ about: Talk with the team
diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml
new file mode 100644
index 0000000..a8a3bac
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/docs.yml
@@ -0,0 +1,46 @@
+name: "\U0001F4DD Docs"
+description: "Request an improvement to documentation"
+title: "Docs: (fill in)"
+labels:
+ - documentation
+body:
+ - type: markdown
+ attributes:
+ value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct).
+ - type: textarea
+ attributes:
+ label: Docs page(s)
+ description: |
+ What page(s) are you suggesting be changed or created?
+ placeholder: |
+ e.g. https://eslint.org/docs/latest/use/getting-started
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: What documentation issue do you want to solve?
+ description: |
+ Please explain your issue in as much detail as possible.
+ placeholder: |
+ The docs currently...
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: What do you think is the correct solution?
+ description: |
+ Please explain how you'd like to change the docs to address the problem.
+ placeholder: |
+ I'd like the docs to...
+ validations:
+ required: true
+ - type: checkboxes
+ attributes:
+ label: Participation
+ options:
+ - label: I am willing to submit a pull request for this change.
+ required: false
+ - type: textarea
+ attributes:
+ label: Additional comments
+ description: Is there anything else that's important for the team to know?
diff --git a/.github/ISSUE_TEMPLATE/new-rule.yml b/.github/ISSUE_TEMPLATE/new-rule.yml
new file mode 100644
index 0000000..0e38481
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/new-rule.yml
@@ -0,0 +1,41 @@
+name: "\U0001F680 Propose a new rule"
+description: "Propose a new rule to be added to the plugin"
+title: "New Rule: (fill in)"
+labels:
+ - rule
+ - feature
+body:
+ - type: markdown
+ attributes:
+ value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct).
+ - type: input
+ attributes:
+ label: Rule details
+ description: What should the new rule do?
+ validations:
+ required: true
+ - type: dropdown
+ attributes:
+ label: What type of rule is this?
+ options:
+ - Warns about a potential problem
+ - Suggests an alternate way of doing something
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Example code
+ description: Please provide some example code that this rule will warn about. This field will render as CSS.
+ render: css
+ validations:
+ required: true
+ - type: checkboxes
+ attributes:
+ label: Participation
+ options:
+ - label: I am willing to submit a pull request to implement this rule.
+ required: false
+ - type: textarea
+ attributes:
+ label: Additional comments
+ description: Is there anything else that's important for the team to know?
diff --git a/.github/ISSUE_TEMPLATE/rule-change.yml b/.github/ISSUE_TEMPLATE/rule-change.yml
new file mode 100644
index 0000000..e8457fc
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/rule-change.yml
@@ -0,0 +1,61 @@
+name: "\U0001F4DD Request a rule change"
+description: "Request a change to an existing rule"
+title: "Rule Change: (fill in)"
+labels:
+ - enhancement
+ - rule
+body:
+ - type: markdown
+ attributes:
+ value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct).
+ - type: input
+ attributes:
+ label: What rule do you want to change?
+ validations:
+ required: true
+ - type: dropdown
+ attributes:
+ label: What change do you want to make?
+ options:
+ - Generate more warnings
+ - Generate fewer warnings
+ - Implement autofix
+ - Implement suggestions
+ validations:
+ required: true
+ - type: dropdown
+ attributes:
+ label: How do you think the change should be implemented?
+ options:
+ - A new option
+ - A new default behavior
+ - Other
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Example code
+ description: Please provide some example code that this change will affect. This field will render as CSS.
+ render: css
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: What does the rule currently do for this code?
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: What will the rule do after it's changed?
+ validations:
+ required: true
+ - type: checkboxes
+ attributes:
+ label: Participation
+ options:
+ - label: I am willing to submit a pull request to implement this change.
+ required: false
+ - type: textarea
+ attributes:
+ label: Additional comments
+ description: Is there anything else that's important for the team to know?
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..1f44735
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,34 @@
+
+
+#### Prerequisites checklist
+
+- [ ] I have read the [contributing guidelines](https://github.com/eslint/eslint/blob/HEAD/CONTRIBUTING.md).
+
+
+
+
+
+#### What is the purpose of this pull request?
+
+#### What changes did you make? (Give an overview)
+
+#### Related Issues
+
+
+
+#### Is there anything you'd like reviewers to focus on?
+
+
diff --git a/.github/workflows/bun-test.yml b/.github/workflows/bun-test.yml
new file mode 100644
index 0000000..c7c30f8
--- /dev/null
+++ b/.github/workflows/bun-test.yml
@@ -0,0 +1,31 @@
+name: Bun CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ build:
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ matrix:
+ os: [windows-latest, macOS-latest, ubuntu-latest]
+ bun: [latest]
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Use Bun ${{ matrix.bun }} ${{ matrix.os }}
+ uses: oven-sh/setup-bun@v1
+ with:
+ bun-version: ${{ matrix.bun }}
+ - name: bun install, build, and test
+ run: |
+ bun install
+ bun run --bun test
+ env:
+ CI: true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..06a2216
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,61 @@
+name: CI
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+jobs:
+ verify_files:
+ name: Verify Files
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "lts/*"
+ - name: Install dependencies
+ run: npm install
+ - name: Build
+ run: npm run build
+ - name: Lint files
+ run: npm run lint
+ - name: Check Formatting
+ run: npm run fmt:check
+ test:
+ name: Test
+ strategy:
+ matrix:
+ os: [ubuntu-latest]
+ node: [23.x, 22.x, 20.x, 18.x, "18.18.0"]
+ include:
+ - os: windows-latest
+ node: "lts/*"
+ - os: macOS-latest
+ node: "lts/*"
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ - name: Install dependencies
+ run: npm install
+ - name: Run tests
+ run: npm run test
+ jsr_test:
+ name: Verify JSR Publish
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "lts/*"
+ - name: Install Packages
+ run: npm install
+ - name: Run --dry-run
+ run: |
+ npm run build
+ npm run test:jsr
diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
new file mode 100644
index 0000000..a3881a8
--- /dev/null
+++ b/.github/workflows/release-please.yml
@@ -0,0 +1,53 @@
+on:
+ push:
+ branches:
+ - main
+name: release-please
+jobs:
+ release-please:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ id-token: write
+ steps:
+ - uses: googleapis/release-please-action@v4
+ id: release
+ - uses: actions/checkout@v4
+ if: ${{ steps.release.outputs.release_created }}
+ - uses: actions/setup-node@v4
+ with:
+ node-version: lts/*
+ registry-url: https://registry.npmjs.org
+ if: ${{ steps.release.outputs.release_created }}
+
+ - name: Publish to npm
+ run: |
+ npm install
+ npm run build --if-present
+ npm publish --provenance
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ if: ${{ steps.release.outputs.release_created }}
+
+ - name: Publish to JSR
+ run: |
+ npm run build --if-present
+ npx jsr publish
+ if: ${{ steps.release.outputs.release_created }}
+
+ - name: Tweet release announcement
+ run: 'npx @humanwhocodes/tweet "eslint/css v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }} has been released: ${{ steps.release.outputs.html_url }}"'
+ if: ${{ steps.release.outputs.release_created }}
+ env:
+ TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }}
+ TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }}
+ TWITTER_ACCESS_TOKEN_KEY: ${{ secrets.TWITTER_ACCESS_TOKEN_KEY }}
+ TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
+
+ - name: Toot release announcement
+ run: 'npx @humanwhocodes/toot "eslint/css v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }} has been released: ${{ steps.release.outputs.html_url }}"'
+ if: ${{ steps.release.outputs.release_created }}
+ env:
+ MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }}
+ MASTODON_HOST: ${{ secrets.MASTODON_HOST }}
diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml
new file mode 100644
index 0000000..d15b4ee
--- /dev/null
+++ b/.github/workflows/update-readme.yml
@@ -0,0 +1,34 @@
+name: Data Fetch
+
+on:
+ schedule:
+ - cron: "0 8 * * *" # Every day at 1am PDT
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repo
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.WORKFLOW_PUSH_BOT_TOKEN }}
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+
+ - name: Install npm packages
+ run: npm install
+
+ - name: Update README with latest sponsor data
+ run: npm run build:readme
+
+ - name: Setup Git
+ run: |
+ git config user.name "GitHub Actions Bot"
+ git config user.email ""
+
+ - name: Save updated files
+ run: |
+ chmod +x ./tools/commit-readme.sh
+ ./tools/commit-readme.sh
diff --git a/.gitignore b/.gitignore
index c6bba59..fdf8c75 100644
--- a/.gitignore
+++ b/.gitignore
@@ -128,3 +128,10 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
+
+# eslint
+.eslintcache
+
+package-lock.json
+yarn.lock
+bun.lockb
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..c1ca392
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+package-lock = false
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..10e4e6f
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,3 @@
+dist
+CHANGELOG.md
+jsr.json
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
new file mode 100644
index 0000000..e18ee07
--- /dev/null
+++ b/.release-please-manifest.json
@@ -0,0 +1,3 @@
+{
+ ".": "0.0.0"
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e69de29
diff --git a/README.md b/README.md
index aaae737..a69e8cb 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,152 @@
-# css
-CSS language plugin for ESLint
+# ESLint CSS Language Plugin
+
+## Overview
+
+This package contains a plugin that allows you to natively lint CSS files using ESLint.
+
+**Important:** This plugin requires ESLint v9.6.0 or higher and you must be using the [new configuration system](https://eslint.org/docs/latest/use/configure/configuration-files).
+
+## Installation
+
+For Node.js and compatible runtimes:
+
+```shell
+npm install @eslint/css -D
+# or
+yarn add @eslint/css -D
+# or
+pnpm install @eslint/css -D
+# or
+bun install @eslint/css -D
+```
+
+For Deno:
+
+```shell
+deno add @eslint/css
+```
+
+### Configurations
+
+| **Configuration Name** | **Description** |
+| ---------------------- | ------------------------------ |
+| `recommended` | Enables all recommended rules. |
+
+In your `eslint.config.js` file, import `@eslint/css` and include the recommended config:
+
+```js
+// eslint.config.js
+import css from "@eslint/css";
+
+export default [
+ // lint CSS files
+ {
+ files: ["**/*.css"],
+ language: "css/css",
+ ...css.configs.recommended,
+ },
+
+ // your other configs here
+];
+```
+
+### Rules
+
+
+
+
+
+| **Rule Name** | **Description** | **Recommended** |
+| :--------------------------------------------------- | :--------------------- | :-------------: |
+| [`no-empty-blocks`](./docs/rules/no-empty-blocks.md) | Disallow empty blocks. | 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.
+
+In order to individually configure a rule in your `eslint.config.js` file, import `@eslint/css` and configure each rule with a prefix:
+
+```js
+// eslint.config.js
+import css from "@eslint/css";
+
+export default [
+ {
+ files: ["**/*.css"],
+ plugins: {
+ css,
+ },
+ language: "css/css",
+ rules: {
+ "css/no-empty-blocks": "error",
+ },
+ },
+];
+```
+
+You can individually config, disable, and enable rules in CSS using comments, such as:
+
+
+```css
+/* eslint css/no-empty-blocks: error */
+
+/* eslint-disable css/no-empty-blocks -- this one is ok */
+a {
+}
+/* eslint-enable css/no-empty-blocks */
+
+b { /* eslint-disable-line css/no-empty-blocks */
+}
+
+/* eslint-disable-next-line css/no-empty-blocks */
+em {
+}
+```
+
+### Languages
+
+| **Language Name** | **Description** |
+| ----------------- | ---------------------- |
+| `css` | Parse CSS stylesheets. |
+
+In order to individually configure a language in your `eslint.config.js` file, import `@eslint/css` and configure a `language`:
+
+```js
+// eslint.config.js
+import css from "@eslint/css";
+
+export default [
+ {
+ files: ["**/*.css"],
+ plugins: {
+ css,
+ },
+ language: "css/css",
+ rules: {
+ "css/no-empty-blocks": "error",
+ },
+ },
+];
+```
+
+## License
+
+Apache 2.0
+
+
+
+
+## Sponsors
+
+The following companies, organizations, and individuals support ESLint's ongoing maintenance and development. [Become a Sponsor](https://eslint.org/donate)
+to get your logo on our READMEs and [website](https://eslint.org/sponsors).
+
+Platinum Sponsors
+

Gold Sponsors
+
Silver Sponsors
+

Bronze Sponsors
+

+Technology Sponsors
+Technology sponsors allow us to use their products and services for free as part of a contribution to the open source ecosystem and our work.
+

+
diff --git a/docs/rules/no-empty-blocks.md b/docs/rules/no-empty-blocks.md
new file mode 100644
index 0000000..b261c4d
--- /dev/null
+++ b/docs/rules/no-empty-blocks.md
@@ -0,0 +1,55 @@
+# no-empty-blocks
+
+Disallow empty blocks.
+
+## Background
+
+CSS blocks are indicated by opening `{` and closing `}` characters, and can occur in both at-rules and rules. For example:
+
+```css
+@media (print) {
+ a {
+ color: black;
+ }
+}
+```
+
+Sometimes during refactoring, you can end up with empty blocks in your code. This is generally a mistake and should be fixed.
+
+## Rule Details
+
+This rule warns when it finds a block that is empty. For the purposes of this rule, comments do not count as content and this rule warns when the only content inside of a block is a comment.
+
+Examples of incorrect code:
+
+```css
+a {
+}
+
+a {
+}
+
+.class-name {
+ /* a comment */
+}
+
+.class-name {
+ /* a comment */
+}
+
+@media (print) {
+}
+
+@media (print) {
+ /* a comment */
+}
+```
+
+## When Not to Use It
+
+If you aren't concerned with empty blocks, you can safely disable this rule.
+
+## Prior Art
+
+- [empty-rules](https://github.com/CSSLint/csslint/wiki/Disallow-empty-rules)
+- [`block-no-empty`](https://stylelint.io/user-guide/rules/block-no-empty)
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..67ba269
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,71 @@
+/**
+ * @fileoverview ESLint configuration file.
+ * @author Nicholas C. Zakas
+ */
+
+//-----------------------------------------------------------------------------
+// Imports
+//-----------------------------------------------------------------------------
+
+import eslintConfigESLint from "eslint-config-eslint";
+import json from "@eslint/json";
+
+//-----------------------------------------------------------------------------
+// Helpers
+//-----------------------------------------------------------------------------
+
+const eslintPluginJSDoc = eslintConfigESLint.find(
+ config => config.plugins?.jsdoc,
+).plugins.jsdoc;
+
+//-----------------------------------------------------------------------------
+// Configuration
+//-----------------------------------------------------------------------------
+
+export default [
+ {
+ ignores: ["**/tests/fixtures/", "**/dist/"],
+ },
+
+ ...eslintConfigESLint.map(config => ({
+ files: ["**/*.js"],
+ ...config,
+ })),
+ {
+ files: ["**/*.json"],
+ ignores: ["**/package-lock.json"],
+ language: "json/json",
+ ...json.configs.recommended,
+ },
+ {
+ files: ["**/*.js"],
+ rules: {
+ // disable rules we don't want to use from eslint-config-eslint
+ "no-undefined": "off",
+ "class-methods-use-this": "off",
+
+ // TODO: re-enable eslint-plugin-jsdoc rules
+ ...Object.fromEntries(
+ Object.keys(eslintPluginJSDoc.rules).map(name => [
+ `jsdoc/${name}`,
+ "off",
+ ]),
+ ),
+ },
+ },
+ {
+ files: ["**/tests/**"],
+ languageOptions: {
+ globals: {
+ describe: "readonly",
+ xdescribe: "readonly",
+ it: "readonly",
+ xit: "readonly",
+ beforeEach: "readonly",
+ afterEach: "readonly",
+ before: "readonly",
+ after: "readonly",
+ },
+ },
+ },
+];
diff --git a/jsr.json b/jsr.json
new file mode 100644
index 0000000..fe22ef5
--- /dev/null
+++ b/jsr.json
@@ -0,0 +1,14 @@
+{
+ "name": "@eslint/css",
+ "version": "0.0.0",
+ "exports": "./dist/esm/index.js",
+ "publish": {
+ "include": [
+ "dist/esm/index.js",
+ "dist/esm/index.d.ts",
+ "README.md",
+ "jsr.json",
+ "LICENSE"
+ ]
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..99d21cf
--- /dev/null
+++ b/package.json
@@ -0,0 +1,96 @@
+{
+ "name": "@eslint/css",
+ "version": "0.0.0",
+ "description": "CSS linting plugin for ESLint",
+ "author": "Nicholas C. Zakas",
+ "type": "module",
+ "main": "dist/esm/index.js",
+ "types": "dist/esm/index.d.ts",
+ "exports": {
+ "require": {
+ "types": "./dist/cjs/index.d.cts",
+ "default": "./dist/cjs/index.cjs"
+ },
+ "import": {
+ "types": "./dist/esm/index.d.ts",
+ "default": "./dist/esm/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "gitHooks": {
+ "pre-commit": "lint-staged"
+ },
+ "lint-staged": {
+ "*.js": [
+ "eslint --fix",
+ "prettier --write"
+ ],
+ "README.md": [
+ "npm run build:update-rules-docs",
+ "prettier --write"
+ ],
+ "!(*.js)": "prettier --write --ignore-unknown",
+ "{src/rules/*.js,tools/update-rules-docs.js}": "npm run build:update-rules-docs"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/eslint/css.git"
+ },
+ "bugs": {
+ "url": "https://github.com/eslint/css/issues"
+ },
+ "homepage": "https://github.com/eslint/css#readme",
+ "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:readme": "node tools/update-readme.js",
+ "build:update-rules-docs": "node tools/update-rules-docs.js",
+ "test:jsr": "npx jsr@latest publish --dry-run",
+ "pretest": "npm run build",
+ "lint": "eslint",
+ "fmt": "prettier --write .",
+ "fmt:check": "prettier --check .",
+ "test": "mocha tests/**/*.js",
+ "test:coverage": "c8 npm test"
+ },
+ "keywords": [
+ "eslint",
+ "eslint-plugin",
+ "eslintplugin",
+ "css",
+ "linting"
+ ],
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/plugin-kit": "^0.2.0",
+ "css-tree": "^3.0.1"
+ },
+ "devDependencies": {
+ "@eslint/core": "^0.6.0",
+ "@eslint/json": "^0.5.0",
+ "@types/css-tree": "^2.3.8",
+ "@types/eslint": "^8.56.10",
+ "c8": "^9.1.0",
+ "dedent": "^1.5.3",
+ "eslint": "^9.11.1",
+ "eslint-config-eslint": "^11.0.0",
+ "got": "^14.4.2",
+ "lint-staged": "^15.2.7",
+ "mdast-util-from-markdown": "^2.0.2",
+ "mocha": "^10.4.0",
+ "prettier": "^3.3.2",
+ "rollup": "^4.16.2",
+ "rollup-plugin-copy": "^3.5.0",
+ "typescript": "^5.4.5",
+ "yorkie": "^2.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+}
diff --git a/prettier.config.js b/prettier.config.js
new file mode 100644
index 0000000..c334317
--- /dev/null
+++ b/prettier.config.js
@@ -0,0 +1,15 @@
+export default {
+ useTabs: true,
+ tabWidth: 4,
+ arrowParens: "avoid",
+
+ overrides: [
+ {
+ files: ["*.json"],
+ options: {
+ tabWidth: 2,
+ useTabs: false,
+ },
+ },
+ ],
+};
diff --git a/release-please-config.json b/release-please-config.json
new file mode 100644
index 0000000..36c2afd
--- /dev/null
+++ b/release-please-config.json
@@ -0,0 +1,18 @@
+{
+ "bump-minor-pre-major": true,
+ "packages": {
+ ".": {
+ "release-as": "0.1.0",
+ "release-type": "node",
+ "pull-request-title-pattern": "chore: release ${version} 🚀",
+ "extra-files": [
+ {
+ "type": "json",
+ "path": "jsr.json",
+ "jsonpath": "$.version"
+ },
+ "src/index.js"
+ ]
+ }
+ }
+}
diff --git a/rollup.config.js b/rollup.config.js
new file mode 100644
index 0000000..0ec27fb
--- /dev/null
+++ b/rollup.config.js
@@ -0,0 +1,14 @@
+export default {
+ input: "src/index.js",
+ output: [
+ {
+ file: "dist/cjs/index.cjs",
+ format: "cjs",
+ },
+ {
+ file: "dist/esm/index.js",
+ format: "esm",
+ banner: '// @ts-self-types="./index.d.ts"',
+ },
+ ],
+};
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..fcb6986
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,42 @@
+/**
+ * @fileoverview CSS plugin.
+ * @author Nicholas C. Zakas
+ */
+
+//-----------------------------------------------------------------------------
+// Imports
+//-----------------------------------------------------------------------------
+
+import { CSSLanguage } from "./languages/css-language.js";
+import { CSSSourceCode } from "./languages/css-source-code.js";
+import noEmptyBlocks from "./rules/no-empty-blocks.js";
+
+//-----------------------------------------------------------------------------
+// Plugin
+//-----------------------------------------------------------------------------
+
+const plugin = {
+ meta: {
+ name: "@eslint/css",
+ version: "0.0.0", // x-release-please-version
+ },
+ languages: {
+ css: new CSSLanguage(),
+ },
+ rules: {
+ "no-empty-blocks": noEmptyBlocks,
+ },
+ configs: {},
+};
+
+Object.assign(plugin.configs, {
+ recommended: {
+ plugins: { css: plugin },
+ rules: {
+ "css/no-empty-blocks": "error",
+ },
+ },
+});
+
+export default plugin;
+export { CSSLanguage, CSSSourceCode };
diff --git a/src/languages/css-language.js b/src/languages/css-language.js
new file mode 100644
index 0000000..f8165b0
--- /dev/null
+++ b/src/languages/css-language.js
@@ -0,0 +1,149 @@
+/**
+ * @filedescription The CSSLanguage class.
+ * @author Nicholas C. Zakas
+ */
+
+//------------------------------------------------------------------------------
+// Imports
+//------------------------------------------------------------------------------
+
+import { parse, toPlainObject } from "css-tree";
+import { CSSSourceCode } from "./css-source-code.js";
+import { visitorKeys } from "./css-visitor-keys.js";
+
+//-----------------------------------------------------------------------------
+// Types
+//-----------------------------------------------------------------------------
+
+/** @typedef {import("css-tree").CssNode} CssNode */
+/** @typedef {import("css-tree").CssNodePlain} CssNodePlain */
+/** @typedef {import("css-tree").StyleSheet} StyleSheet */
+/** @typedef {import("css-tree").Comment} Comment */
+/** @typedef {import("@eslint/core").Language} Language */
+/** @typedef {import("@eslint/core").OkParseResult & { comments: Comment[] }} OkParseResult */
+/** @typedef {import("@eslint/core").ParseResult} ParseResult */
+/** @typedef {import("@eslint/core").File} File */
+/** @typedef {import("@eslint/core").FileError} FileError */
+
+//-----------------------------------------------------------------------------
+// Exports
+//-----------------------------------------------------------------------------
+
+/**
+ * CSS Language Object
+ * @implements {Language}
+ */
+export class CSSLanguage {
+ /**
+ * The type of file to read.
+ * @type {"text"}
+ */
+ fileType = "text";
+
+ /**
+ * The line number at which the parser starts counting.
+ * @type {0|1}
+ */
+ lineStart = 1;
+
+ /**
+ * The column number at which the parser starts counting.
+ * @type {0|1}
+ */
+ columnStart = 1;
+
+ /**
+ * The name of the key that holds the type of the node.
+ * @type {string}
+ */
+ nodeTypeKey = "type";
+
+ /**
+ * The visitor keys for the CSSTree AST.
+ * @type {Record}
+ */
+ visitorKeys = visitorKeys;
+
+ /**
+ * Validates the language options.
+ * @returns {void}
+ * @throws {Error} When the language options are invalid.
+ */
+ validateLanguageOptions() {
+ // noop
+ }
+
+ /**
+ * Parses the given file into an AST.
+ * @param {File} file The virtual file to parse.
+ * @returns {ParseResult} The result of parsing.
+ */
+ parse(file) {
+ // Note: BOM already removed
+ const text = /** @type {string} */ (file.body);
+
+ /** @type {Comment[]} */
+ const comments = [];
+
+ /** @type {FileError[]} */
+ const errors = [];
+
+ /*
+ * Check for parsing errors first. If there's a parsing error, nothing
+ * else can happen. However, a parsing error does not throw an error
+ * from this method - it's just considered a fatal error message, a
+ * problem that ESLint identified just like any other.
+ */
+ try {
+ const root = toPlainObject(
+ parse(text, {
+ filename: file.path,
+ positions: true,
+ onComment(value, loc) {
+ comments.push({
+ type: "Comment",
+ value,
+ loc,
+ });
+ },
+ onParseError(error) {
+ // @ts-ignore -- types are incorrect
+ errors.push(error);
+ },
+ }),
+ );
+
+ if (errors.length) {
+ return {
+ ok: false,
+ errors,
+ };
+ }
+
+ return {
+ ok: true,
+ ast: root,
+ comments,
+ };
+ } catch (ex) {
+ return {
+ ok: false,
+ errors: [ex],
+ };
+ }
+ }
+
+ /**
+ * Creates a new `CSSSourceCode` object from the given information.
+ * @param {File} file The virtual file to create a `CSSSourceCode` object from.
+ * @param {OkParseResult} parseResult The result returned from `parse()`.
+ * @returns {CSSSourceCode} The new `CSSSourceCode` object.
+ */
+ createSourceCode(file, parseResult) {
+ return new CSSSourceCode({
+ text: /** @type {string} */ (file.body),
+ ast: parseResult.ast,
+ comments: parseResult.comments,
+ });
+ }
+}
diff --git a/src/languages/css-source-code.js b/src/languages/css-source-code.js
new file mode 100644
index 0000000..1bdc299
--- /dev/null
+++ b/src/languages/css-source-code.js
@@ -0,0 +1,309 @@
+/**
+ * @fileoverview The CSSSourceCode class.
+ * @author Nicholas C. Zakas
+ */
+
+//-----------------------------------------------------------------------------
+// Imports
+//-----------------------------------------------------------------------------
+
+import {
+ VisitNodeStep,
+ TextSourceCodeBase,
+ ConfigCommentParser,
+ Directive,
+} from "@eslint/plugin-kit";
+import { visitorKeys } from "./css-visitor-keys.js";
+
+//-----------------------------------------------------------------------------
+// Types
+//-----------------------------------------------------------------------------
+
+/** @typedef {import("css-tree").CssNode} CssNode */
+/** @typedef {import("css-tree").CssNodePlain} CssNodePlain */
+/** @typedef {import("css-tree").BlockPlain} BlockPlain */
+/** @typedef {import("css-tree").Comment} Comment */
+/** @typedef {import("@eslint/core").SourceRange} SourceRange */
+/** @typedef {import("@eslint/core").SourceLocation} SourceLocation */
+/** @typedef {import("@eslint/core").SourceLocationWithOffset} SourceLocationWithOffset */
+/** @typedef {import("@eslint/core").File} File */
+/** @typedef {import("@eslint/core").TraversalStep} TraversalStep */
+/** @typedef {import("@eslint/core").TextSourceCode} TextSourceCode */
+/** @typedef {import("@eslint/core").VisitTraversalStep} VisitTraversalStep */
+/** @typedef {import("@eslint/core").FileProblem} FileProblem */
+/** @typedef {import("@eslint/core").DirectiveType} DirectiveType */
+/** @typedef {import("@eslint/core").RulesConfig} RulesConfig */
+
+//-----------------------------------------------------------------------------
+// Helpers
+//-----------------------------------------------------------------------------
+
+const commentParser = new ConfigCommentParser();
+
+const INLINE_CONFIG =
+ /^\s*(?:eslint(?:-enable|-disable(?:(?:-next)?-line)?)?)(?:\s|$)/u;
+
+/**
+ * A class to represent a step in the traversal process.
+ */
+class CSSTraversalStep extends VisitNodeStep {
+ /**
+ * The target of the step.
+ * @type {CssNode}
+ */
+ target = undefined;
+
+ /**
+ * Creates a new instance.
+ * @param {Object} options The options for the step.
+ * @param {CssNode} options.target The target of the step.
+ * @param {1|2} options.phase The phase of the step.
+ * @param {Array} options.args The arguments of the step.
+ */
+ constructor({ target, phase, args }) {
+ super({ target, phase, args });
+
+ this.target = target;
+ }
+}
+
+//-----------------------------------------------------------------------------
+// Exports
+//-----------------------------------------------------------------------------
+
+/**
+ * CSS Source Code Object
+ */
+export class CSSSourceCode extends TextSourceCodeBase {
+ /**
+ * Cached traversal steps.
+ * @type {Array|undefined}
+ */
+ #steps;
+
+ /**
+ * Cache of parent nodes.
+ * @type {WeakMap}
+ */
+ #parents = new WeakMap();
+
+ /**
+ * Collection of inline configuration comments.
+ * @type {Array}
+ */
+ #inlineConfigComments;
+
+ /**
+ * The AST of the source code.
+ * @type {CssNodePlain}
+ */
+ ast = undefined;
+
+ /**
+ * The comment node in the source code.
+ * @type {Array|undefined}
+ */
+ comments;
+
+ /**
+ * Creates a new instance.
+ * @param {Object} options The options for the instance.
+ * @param {string} options.text The source code text.
+ * @param {CssNodePlain} options.ast The root AST node.
+ * @param {Array} options.comments The comment nodes in the source code.
+ */
+ constructor({ text, ast, comments }) {
+ super({ text, ast });
+ this.ast = ast;
+ this.comments = comments;
+ }
+
+ /**
+ * Returns the range of the given node.
+ * @param {CssNodePlain} node The node to get the range of.
+ * @returns {SourceRange} The range of the node.
+ * @override
+ */
+ getRange(node) {
+ return [node.loc.start.offset, node.loc.end.offset];
+ }
+
+ /**
+ * Returns an array of all inline configuration nodes found in the
+ * source code.
+ * @returns {Array} An array of all inline configuration nodes.
+ */
+ getInlineConfigNodes() {
+ if (!this.#inlineConfigComments) {
+ this.#inlineConfigComments = this.comments.filter(comment =>
+ INLINE_CONFIG.test(comment.value),
+ );
+ }
+
+ return this.#inlineConfigComments;
+ }
+
+ /**
+ * Returns directives that enable or disable rules along with any problems
+ * encountered while parsing the directives.
+ * @returns {{problems:Array,directives:Array}} Information
+ * that ESLint needs to further process the directives.
+ */
+ getDisableDirectives() {
+ const problems = [];
+ const directives = [];
+
+ this.getInlineConfigNodes().forEach(comment => {
+ const { label, value, justification } =
+ commentParser.parseDirective(comment.value);
+
+ // `eslint-disable-line` directives are not allowed to span multiple lines as it would be confusing to which lines they apply
+ if (
+ label === "eslint-disable-line" &&
+ comment.loc.start.line !== comment.loc.end.line
+ ) {
+ const message = `${label} comment should not span multiple lines.`;
+
+ problems.push({
+ ruleId: null,
+ message,
+ loc: comment.loc,
+ });
+ return;
+ }
+
+ switch (label) {
+ case "eslint-disable":
+ case "eslint-enable":
+ case "eslint-disable-next-line":
+ case "eslint-disable-line": {
+ const directiveType = label.slice("eslint-".length);
+
+ directives.push(
+ new Directive({
+ type: /** @type {DirectiveType} */ (directiveType),
+ node: comment,
+ value,
+ justification,
+ }),
+ );
+ }
+
+ // no default
+ }
+ });
+
+ return { problems, directives };
+ }
+
+ /**
+ * Returns inline rule configurations along with any problems
+ * encountered while parsing the configurations.
+ * @returns {{problems:Array,configs:Array<{config:{rules:RulesConfig},loc:SourceLocation}>}} Information
+ * that ESLint needs to further process the rule configurations.
+ */
+ applyInlineConfig() {
+ const problems = [];
+ const configs = [];
+
+ this.getInlineConfigNodes().forEach(comment => {
+ const { label, value } = commentParser.parseDirective(
+ comment.value,
+ );
+
+ if (label === "eslint") {
+ const parseResult = commentParser.parseJSONLikeConfig(value);
+
+ if (parseResult.ok) {
+ configs.push({
+ config: {
+ rules: parseResult.config,
+ },
+ loc: comment.loc,
+ });
+ } else {
+ problems.push({
+ ruleId: null,
+ message:
+ /** @type {{ok: false, error: { message: string }}} */ (
+ parseResult
+ ).error.message,
+ loc: comment.loc,
+ });
+ }
+ }
+ });
+
+ return {
+ configs,
+ problems,
+ };
+ }
+
+ /**
+ * Returns the parent of the given node.
+ * @param {CssNodePlain} node The node to get the parent of.
+ * @returns {CssNodePlain|undefined} The parent of the node.
+ */
+ getParent(node) {
+ return this.#parents.get(node);
+ }
+
+ /**
+ * Traverse the source code and return the steps that were taken.
+ * @returns {Iterable} The steps that were taken while traversing the source code.
+ */
+ traverse() {
+ // Because the AST doesn't mutate, we can cache the steps
+ if (this.#steps) {
+ return this.#steps.values();
+ }
+
+ /** @type {Array} */
+ const steps = (this.#steps = []);
+
+ // Note: We can't use `walk` from `css-tree` because it uses `CssNode` instead of `CssNodePlain`
+
+ const visit = (node, parent) => {
+ // first set the parent
+ this.#parents.set(node, parent);
+
+ // then add the step
+ steps.push(
+ new CSSTraversalStep({
+ target: node,
+ phase: 1,
+ args: [node, parent],
+ }),
+ );
+
+ // then visit the children
+ for (const key of visitorKeys[node.type] || []) {
+ const child = node[key];
+
+ if (child) {
+ if (Array.isArray(child)) {
+ child.forEach(grandchild => {
+ visit(grandchild, node);
+ });
+ } else {
+ visit(child, node);
+ }
+ }
+ }
+
+ // then add the exit step
+ steps.push(
+ new CSSTraversalStep({
+ target: node,
+ phase: 2,
+ args: [node, parent],
+ }),
+ );
+ };
+
+ visit(this.ast);
+
+ return steps;
+ }
+}
diff --git a/src/languages/css-visitor-keys.js b/src/languages/css-visitor-keys.js
new file mode 100644
index 0000000..16998d8
--- /dev/null
+++ b/src/languages/css-visitor-keys.js
@@ -0,0 +1,56 @@
+/**
+ * @fileoverview Visitor keys for the CSS Tree AST.
+ * @author Nicholas C. Zakas
+ */
+
+export const visitorKeys = {
+ AnPlusB: [],
+ Atrule: ["prelude", "block"],
+ AtrulePrelude: ["children"],
+ AttributeSelector: ["name", "value"],
+ Block: ["children"],
+ Brackets: ["children"],
+ CDC: [],
+ CDO: [],
+ ClassSelector: [],
+ Combinator: [],
+ Comment: [],
+ Condition: ["children"],
+ Declaration: ["value"],
+ DeclarationList: ["children"],
+ Dimension: [],
+ Feature: ["value"],
+ FeatureFunction: ["value"],
+ FeatureRange: ["left", "middle", "right"],
+ Function: ["children"],
+ GeneralEnclosed: ["children"],
+ Hash: [],
+ IdSelector: [],
+ Identifier: [],
+ Layer: [],
+ LayerList: ["children"],
+ MediaQuery: ["condition"],
+ MediaQueryList: ["children"],
+ NestingSelector: [],
+ Nth: ["nth", "selector"],
+ Number: [],
+ Operator: [],
+ Parentheses: ["children"],
+ Percentage: [],
+ PseudoClassSelector: ["children"],
+ PseudoElementSelector: ["children"],
+ Ratio: ["left", "right"],
+ Raw: [],
+ Rule: ["prelude", "block"],
+ Scope: ["root", "limit"],
+ Selector: ["children"],
+ SelectorList: ["children"],
+ String: [],
+ StyleSheet: ["children"],
+ SupportsDeclaration: ["declaration"],
+ TypeSelector: [],
+ UnicodeRange: [],
+ Url: [],
+ Value: ["children"],
+ WhiteSpace: [],
+};
diff --git a/src/rules/no-empty-blocks.js b/src/rules/no-empty-blocks.js
new file mode 100644
index 0000000..d4b2775
--- /dev/null
+++ b/src/rules/no-empty-blocks.js
@@ -0,0 +1,32 @@
+/**
+ * @fileoverview Rule to prevent empty blocks in CSS.
+ * @author Nicholas C. Zakas
+ */
+
+export default {
+ meta: {
+ type: "problem",
+
+ docs: {
+ description: "Disallow empty blocks.",
+ recommended: true,
+ },
+
+ messages: {
+ emptyBlock: "Unexpected empty block found.",
+ },
+ },
+
+ create(context) {
+ return {
+ Block(node) {
+ if (node.children.length === 0) {
+ context.report({
+ loc: node.loc,
+ messageId: "emptyBlock",
+ });
+ }
+ },
+ };
+ },
+};
diff --git a/tests/languages/css-language.test.js b/tests/languages/css-language.test.js
new file mode 100644
index 0000000..8c405a0
--- /dev/null
+++ b/tests/languages/css-language.test.js
@@ -0,0 +1,107 @@
+/**
+ * @fileoverview Tests for CSSLanguage
+ * @author Nicholas C. Zakas
+ */
+
+//-----------------------------------------------------------------------------
+// Imports
+//-----------------------------------------------------------------------------
+
+import { CSSLanguage } from "../../src/languages/css-language.js";
+import assert from "node:assert";
+
+//-----------------------------------------------------------------------------
+// Tests
+//-----------------------------------------------------------------------------
+
+describe("CSSLanguage", () => {
+ describe("visitorKeys", () => {
+ it("should have visitorKeys property", () => {
+ const language = new CSSLanguage();
+
+ assert.deepStrictEqual(language.visitorKeys.StyleSheet, [
+ "children",
+ ]);
+ });
+ });
+
+ describe("parse()", () => {
+ it("should parse CSS", () => {
+ const language = new CSSLanguage();
+ const result = language.parse({
+ body: "a {\n\n}",
+ path: "test.css",
+ });
+
+ assert.strictEqual(result.ok, true);
+ assert.strictEqual(result.ast.type, "StyleSheet");
+ assert.strictEqual(result.ast.children[0].type, "Rule");
+ });
+
+ it("should return an error when parsing invalid CSS", () => {
+ const language = new CSSLanguage();
+ const result = language.parse({
+ body: "a { foo; bar: 1! }",
+ path: "test.css",
+ });
+
+ assert.strictEqual(result.ok, false);
+ assert.strictEqual(result.ast, undefined);
+ assert.strictEqual(result.errors.length, 2);
+
+ assert.strictEqual(result.errors[0].message, "Colon is expected");
+ assert.strictEqual(result.errors[0].line, 1);
+ assert.strictEqual(result.errors[0].column, 8);
+
+ assert.strictEqual(
+ result.errors[1].message,
+ "Identifier is expected",
+ );
+ assert.strictEqual(result.errors[1].line, 1);
+ assert.strictEqual(result.errors[1].column, 18);
+ });
+
+ // https://github.com/csstree/csstree/issues/301
+ it.skip("should return an error when EOF is discovered before block close", () => {
+ const language = new CSSLanguage();
+ const result = language.parse({
+ body: "a {",
+ path: "test.css",
+ });
+
+ assert.strictEqual(result.ok, false);
+ assert.strictEqual(result.ast, undefined);
+ assert.strictEqual(result.errors.length, 1);
+ assert.strictEqual(result.errors[0].message, "Colon is expected");
+ });
+ });
+
+ describe("createSourceCode()", () => {
+ it("should create a CSSSourceCode instance", () => {
+ const file = { body: "a {\n\n}", path: "test.css" };
+ const language = new CSSLanguage();
+ const parseResult = language.parse(file);
+ const sourceCode = language.createSourceCode(file, parseResult);
+ assert.strictEqual(sourceCode.constructor.name, "CSSSourceCode");
+
+ assert.strictEqual(sourceCode.ast.type, "StyleSheet");
+ assert.strictEqual(sourceCode.ast.children[0].type, "Rule");
+ assert.strictEqual(sourceCode.text, file.body);
+ assert.strictEqual(sourceCode.comments.length, 0);
+ });
+
+ it("should create a CSSSourceCode instance for CSS code with comments", () => {
+ const language = new CSSLanguage();
+ const file = { body: "a {\n/*test*/\n}", path: "test.css" };
+ const parseResult = language.parse(file);
+ const sourceCode = language.createSourceCode(file, parseResult);
+
+ assert.strictEqual(sourceCode.constructor.name, "CSSSourceCode");
+
+ assert.strictEqual(sourceCode.ast.type, "StyleSheet");
+ assert.strictEqual(sourceCode.ast.children[0].type, "Rule");
+ assert.strictEqual(sourceCode.text, file.body);
+ assert.strictEqual(sourceCode.comments.length, 1);
+ });
+ });
+});
diff --git a/tests/languages/css-source-code.test.js b/tests/languages/css-source-code.test.js
new file mode 100644
index 0000000..4467476
--- /dev/null
+++ b/tests/languages/css-source-code.test.js
@@ -0,0 +1,593 @@
+/**
+ * @fileoverview Tests for CSSSourceCode
+ * @author Nicholas C. Zakas
+ */
+
+//-----------------------------------------------------------------------------
+// Imports
+//-----------------------------------------------------------------------------
+
+import { CSSSourceCode } from "../../src/languages/css-source-code.js";
+import { CSSLanguage } from "../../src/languages/css-language.js";
+import { parse, toPlainObject } from "css-tree";
+import assert from "node:assert";
+import dedent from "dedent";
+
+//-----------------------------------------------------------------------------
+// Tests
+//-----------------------------------------------------------------------------
+
+describe("CSSSourceCode", () => {
+ describe("constructor", () => {
+ it("should create a CSSSourceCode instance", () => {
+ const ast = {
+ type: "StyleSheet",
+ children: [
+ {
+ type: "Rule",
+ prelude: {
+ type: "SelectorList",
+ children: [
+ {
+ type: "Selector",
+ children: [
+ {
+ type: "TypeSelector",
+ name: "a",
+ },
+ ],
+ },
+ ],
+ },
+ block: {
+ type: "Block",
+ children: [],
+ },
+ },
+ ],
+ };
+ const text = "a {}";
+ const comments = [];
+ const sourceCode = new CSSSourceCode({
+ text,
+ ast,
+ comments,
+ });
+
+ assert.strictEqual(sourceCode.constructor.name, "CSSSourceCode");
+ assert.strictEqual(sourceCode.ast, ast);
+ assert.strictEqual(sourceCode.text, text);
+ assert.strictEqual(sourceCode.comments, comments);
+ });
+ });
+
+ describe("getText()", () => {
+ it("should return the text of the source code", () => {
+ const file = { body: "a {}", path: "test.css" };
+ const language = new CSSLanguage();
+ const parseResult = language.parse(file);
+ const sourceCode = new CSSSourceCode({
+ text: file.body,
+ ast: parseResult.ast,
+ });
+
+ assert.strictEqual(sourceCode.getText(), file.body);
+ });
+ });
+
+ describe("getLoc()", () => {
+ it("should return the loc property of a node", () => {
+ const loc = {
+ start: {
+ line: 1,
+ column: 1,
+ offset: 0,
+ },
+ end: {
+ line: 1,
+ column: 2,
+ offset: 1,
+ },
+ };
+ const ast = {
+ type: "StyleSheet",
+ children: [],
+ loc,
+ };
+ const text = "{}";
+ const sourceCode = new CSSSourceCode({
+ text,
+ ast,
+ });
+
+ assert.strictEqual(sourceCode.getLoc(ast), loc);
+ });
+ });
+
+ describe("getRange()", () => {
+ it("should return the range property of a node", () => {
+ const loc = {
+ start: {
+ line: 1,
+ column: 1,
+ offset: 0,
+ },
+ end: {
+ line: 1,
+ column: 2,
+ offset: 1,
+ },
+ };
+ const ast = {
+ type: "StyleSheet",
+ children: [],
+ loc,
+ };
+ const text = "{}";
+ const sourceCode = new CSSSourceCode({
+ text,
+ ast,
+ });
+
+ assert.deepStrictEqual(sourceCode.getRange(ast), [0, 1]);
+ });
+ });
+
+ describe("comments", () => {
+ it("should contain an empty array when parsing CSS without comments", () => {
+ const file = { body: "a {}", path: "test.css" };
+ const language = new CSSLanguage();
+ const parseResult = language.parse(file);
+ const sourceCode = new CSSSourceCode({
+ text: file.body,
+ ast: parseResult.ast,
+ comments: parseResult.comments,
+ });
+
+ assert.deepStrictEqual(sourceCode.comments, []);
+ });
+
+ it("should contain an array of comments when parsing CSS with comments", () => {
+ const file = { body: "a {\n/*test*/\n}", path: "test.css" };
+ const language = new CSSLanguage();
+ const parseResult = language.parse(file);
+ const sourceCode = new CSSSourceCode({
+ text: file.body,
+ ast: parseResult.ast,
+ comments: parseResult.comments,
+ });
+
+ // should contain one comment
+ assert.strictEqual(sourceCode.comments.length, 1);
+
+ const comment = sourceCode.comments[0];
+ assert.strictEqual(comment.type, "Comment");
+ assert.strictEqual(comment.value, "test");
+ assert.deepStrictEqual(comment.loc, {
+ source: "test.css",
+ start: { line: 2, column: 1, offset: 4 },
+ end: { line: 2, column: 9, offset: 12 },
+ });
+ });
+ });
+
+ describe("lines", () => {
+ it("should return an array of lines", () => {
+ const file = { body: "a {\n/*test*/\n}", path: "test.css" };
+ const language = new CSSLanguage();
+ const parseResult = language.parse(file);
+ const sourceCode = new CSSSourceCode({
+ text: file.body,
+ ast: parseResult.ast,
+ });
+
+ assert.deepStrictEqual(sourceCode.lines, ["a {", "/*test*/", "}"]);
+ });
+ });
+
+ describe("getParent()", () => {
+ it("should return the parent node for a given node", () => {
+ const ast = {
+ type: "StyleSheet",
+ children: [
+ {
+ type: "Rule",
+ prelude: {
+ type: "SelectorList",
+ children: [
+ {
+ type: "Selector",
+ children: [
+ {
+ type: "TypeSelector",
+ name: "a",
+ },
+ ],
+ },
+ ],
+ },
+ block: {
+ type: "Block",
+ children: [],
+ },
+ },
+ ],
+ };
+ const text = "a {}";
+ const sourceCode = new CSSSourceCode({
+ text,
+ ast,
+ });
+ const node = ast.children[0];
+
+ // call traverse to initialize the parent map
+ sourceCode.traverse();
+
+ assert.strictEqual(sourceCode.getParent(node).type, ast.type);
+ });
+
+ it("should return the parent node for a deeply nested node", () => {
+ const ast = {
+ type: "StyleSheet",
+ children: [
+ {
+ type: "Rule",
+ prelude: {
+ type: "SelectorList",
+ children: [
+ {
+ type: "Selector",
+ children: [
+ {
+ type: "TypeSelector",
+ name: "a",
+ },
+ ],
+ },
+ ],
+ },
+ block: {
+ type: "Block",
+ children: [],
+ },
+ },
+ ],
+ };
+ const text = '{"foo":{}}';
+ const sourceCode = new CSSSourceCode({
+ text,
+ ast,
+ });
+ const node = ast.children[0].prelude.children[0].children[0];
+
+ // call traverse to initialize the parent map
+ sourceCode.traverse();
+
+ assert.strictEqual(
+ sourceCode.getParent(node),
+ ast.children[0].prelude.children[0],
+ );
+ });
+ });
+
+ describe("getAncestors()", () => {
+ it("should return an array of ancestors for a given node", () => {
+ const ast = {
+ type: "StyleSheet",
+ children: [
+ {
+ type: "Rule",
+ prelude: {
+ type: "SelectorList",
+ children: [
+ {
+ type: "Selector",
+ children: [
+ {
+ type: "TypeSelector",
+ name: "a",
+ },
+ ],
+ },
+ ],
+ },
+ block: {
+ type: "Block",
+ children: [],
+ },
+ },
+ ],
+ };
+ const text = "a {}";
+ const sourceCode = new CSSSourceCode({
+ text,
+ ast,
+ });
+ const node = ast.children[0];
+
+ // call traverse to initialize the parent map
+ sourceCode.traverse();
+
+ assert.deepStrictEqual(sourceCode.getAncestors(node), [ast]);
+ });
+
+ it("should return an array of ancestors for a deeply nested node", () => {
+ const ast = {
+ type: "StyleSheet",
+ children: [
+ {
+ type: "Rule",
+ prelude: {
+ type: "SelectorList",
+ children: [
+ {
+ type: "Selector",
+ children: [
+ {
+ type: "TypeSelector",
+ name: "a",
+ },
+ ],
+ },
+ ],
+ },
+ block: {
+ type: "Block",
+ children: [],
+ },
+ },
+ ],
+ };
+ const text = "a {}";
+ const sourceCode = new CSSSourceCode({
+ text,
+ ast,
+ });
+ const node = ast.children[0].prelude.children[0].children[0];
+
+ // call traverse to initialize the parent map
+ sourceCode.traverse();
+
+ assert.deepStrictEqual(sourceCode.getAncestors(node), [
+ ast,
+ ast.children[0],
+ ast.children[0].prelude,
+ ast.children[0].prelude.children[0],
+ ]);
+ });
+ });
+
+ describe("config comments", () => {
+ const text = dedent`
+
+ /* rule config comments */
+ /* eslint css/no-duplicate-selectors: error */
+ .foo .bar {}
+
+ /* eslint-disable css/no-duplicate-selectors -- ok here */
+ /* eslint-enable */
+
+ /* invalid rule config comments */
+ /* eslint css/no-duplicate-selectors: [error */
+ /*eslint css/no-duplicate-selectors: [1, { allow: ["foo"] ]*/
+
+ /* eslint-disable-next-line css/no-duplicate-selectors */
+
+ /* eslint-disable-line css/no-duplicate-selectors -- ok here */
+
+ /* invalid disable directives */
+ /* eslint-disable-line css/no-duplicate-selectors
+ */
+
+ /* not disable directives */
+ /*eslint-disable-*/
+
+ /* eslint css/no-empty: [1] */
+ `;
+
+ let sourceCode = null;
+
+ beforeEach(() => {
+ const file = { body: text, path: "test.css" };
+ const language = new CSSLanguage();
+ const parseResult = language.parse(file);
+ sourceCode = new CSSSourceCode({
+ text: file.body,
+ ast: parseResult.ast,
+ comments: parseResult.comments,
+ });
+ });
+
+ afterEach(() => {
+ sourceCode = null;
+ });
+
+ describe("getInlineConfigNodes()", () => {
+ it("should return inline config comments", () => {
+ const allComments = sourceCode.comments;
+ const configComments = sourceCode.getInlineConfigNodes();
+
+ const configCommentsIndexes = [1, 2, 3, 5, 6, 7, 8, 10, 13];
+
+ assert.strictEqual(
+ configComments.length,
+ configCommentsIndexes.length,
+ );
+
+ configComments.forEach((configComment, i) => {
+ assert.strictEqual(
+ configComment,
+ allComments[configCommentsIndexes[i]],
+ );
+ });
+ });
+ });
+
+ describe("applyInlineConfig()", () => {
+ it("should return rule configs and problems", () => {
+ const allComments = sourceCode.comments;
+ const { configs, problems } = sourceCode.applyInlineConfig();
+
+ assert.deepStrictEqual(configs, [
+ {
+ config: {
+ rules: {
+ "css/no-duplicate-selectors": "error",
+ },
+ },
+ loc: allComments[1].loc,
+ },
+ {
+ config: {
+ rules: {
+ "css/no-empty": [1],
+ },
+ },
+ loc: allComments[13].loc,
+ },
+ ]);
+
+ assert.strictEqual(problems.length, 2);
+ assert.strictEqual(problems[0].ruleId, null);
+ assert.match(problems[0].message, /Failed to parse/u);
+ assert.strictEqual(problems[0].loc, allComments[5].loc);
+ assert.strictEqual(problems[1].ruleId, null);
+ assert.match(problems[1].message, /Failed to parse/u);
+ assert.strictEqual(problems[1].loc, allComments[6].loc);
+ });
+ });
+
+ describe("getDisableDirectives()", () => {
+ it("should return disable directives and problems", () => {
+ const allComments = sourceCode.comments;
+ const { directives, problems } =
+ sourceCode.getDisableDirectives();
+
+ assert.deepStrictEqual(
+ directives.map(obj => ({ ...obj })),
+ [
+ {
+ type: "disable",
+ value: "css/no-duplicate-selectors",
+ justification: "ok here",
+ node: allComments[2],
+ },
+ {
+ type: "enable",
+ value: "",
+ justification: "",
+ node: allComments[3],
+ },
+ {
+ type: "disable-next-line",
+ value: "css/no-duplicate-selectors",
+ justification: "",
+ node: allComments[7],
+ },
+ {
+ type: "disable-line",
+ value: "css/no-duplicate-selectors",
+ justification: "ok here",
+ node: allComments[8],
+ },
+ ],
+ );
+
+ assert.strictEqual(problems.length, 1);
+ assert.strictEqual(problems[0].ruleId, null);
+ assert.strictEqual(
+ problems[0].message,
+ "eslint-disable-line comment should not span multiple lines.",
+ );
+ assert.strictEqual(problems[0].loc, allComments[10].loc);
+ });
+ });
+ });
+
+ describe("traverse()", () => {
+ const css = dedent`
+
+ body {
+ margin: 0;
+ font-family: Arial, sans-serif;
+ }
+
+ nav a:hover {
+ background-color: #555;
+ padding: 10px 0;
+ }`;
+
+ it("should traverse the AST", () => {
+ const sourceCode = new CSSSourceCode({
+ text: css,
+ ast: toPlainObject(parse(css, { positions: true })),
+ });
+ const steps = sourceCode.traverse();
+ const stepsArray = Array.from(steps).map(step => [
+ step.phase,
+ step.target.type,
+ ]);
+
+ assert.deepStrictEqual(stepsArray, [
+ [1, "StyleSheet"],
+ [1, "Rule"],
+ [1, "SelectorList"],
+ [1, "Selector"],
+ [1, "TypeSelector"],
+ [2, "TypeSelector"],
+ [2, "Selector"],
+ [2, "SelectorList"],
+ [1, "Block"],
+ [1, "Declaration"],
+ [1, "Value"],
+ [1, "Number"],
+ [2, "Number"],
+ [2, "Value"],
+ [2, "Declaration"],
+ [1, "Declaration"],
+ [1, "Value"],
+ [1, "Identifier"],
+ [2, "Identifier"],
+ [1, "Operator"],
+ [2, "Operator"],
+ [1, "Identifier"],
+ [2, "Identifier"],
+ [2, "Value"],
+ [2, "Declaration"],
+ [2, "Block"],
+ [2, "Rule"],
+ [1, "Rule"],
+ [1, "SelectorList"],
+ [1, "Selector"],
+ [1, "TypeSelector"],
+ [2, "TypeSelector"],
+ [1, "Combinator"],
+ [2, "Combinator"],
+ [1, "TypeSelector"],
+ [2, "TypeSelector"],
+ [1, "PseudoClassSelector"],
+ [2, "PseudoClassSelector"],
+ [2, "Selector"],
+ [2, "SelectorList"],
+ [1, "Block"],
+ [1, "Declaration"],
+ [1, "Value"],
+ [1, "Hash"],
+ [2, "Hash"],
+ [2, "Value"],
+ [2, "Declaration"],
+ [1, "Declaration"],
+ [1, "Value"],
+ [1, "Dimension"],
+ [2, "Dimension"],
+ [1, "Number"],
+ [2, "Number"],
+ [2, "Value"],
+ [2, "Declaration"],
+ [2, "Block"],
+ [2, "Rule"],
+ [2, "StyleSheet"],
+ ]);
+ });
+ });
+});
diff --git a/tests/package/exports.js b/tests/package/exports.js
new file mode 100644
index 0000000..a773ae1
--- /dev/null
+++ b/tests/package/exports.js
@@ -0,0 +1,34 @@
+/**
+ * @fileoverview Tests for the package index's exports.
+ * @author Steve Dodier-Lazaro
+ */
+
+//------------------------------------------------------------------------------
+// Imports
+//------------------------------------------------------------------------------
+
+import * as exports from "../../src/index.js";
+import assert from "node:assert";
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+describe("Package exports", () => {
+ it("has the ESLint plugin as a default export", () => {
+ assert.deepStrictEqual(Object.keys(exports.default), [
+ "meta",
+ "languages",
+ "rules",
+ "configs",
+ ]);
+ });
+
+ it("has a CSSLanguage export", () => {
+ assert.ok(exports.CSSLanguage);
+ });
+
+ it("has a CSSSourceCode export", () => {
+ assert.ok(exports.CSSSourceCode);
+ });
+});
diff --git a/tests/plugin/eslint.test.js b/tests/plugin/eslint.test.js
new file mode 100644
index 0000000..8ff494a
--- /dev/null
+++ b/tests/plugin/eslint.test.js
@@ -0,0 +1,146 @@
+/**
+ * @fileoverview Integration tests with ESLint.
+ * @author Milos Djermanovic
+ */
+
+//------------------------------------------------------------------------------
+// Imports
+//------------------------------------------------------------------------------
+
+import css from "../../src/index.js";
+import ESLintAPI from "eslint";
+const { ESLint } = ESLintAPI;
+
+import assert from "node:assert";
+
+//-----------------------------------------------------------------------------
+// Tests
+//-----------------------------------------------------------------------------
+
+describe("Plugin", () => {
+ describe("Configuration Comments", () => {
+ const config = {
+ files: ["*.css"],
+ plugins: {
+ css,
+ },
+ language: "css/css",
+ rules: {
+ "css/no-empty-blocks": "error",
+ },
+ };
+
+ let eslint;
+
+ beforeEach(() => {
+ eslint = new ESLint({
+ overrideConfigFile: true,
+ overrideConfig: config,
+ });
+ });
+
+ it("should report empty block without any configuration comments present", async () => {
+ const code = "a { }";
+ const results = await eslint.lintText(code, {
+ filePath: "test.css",
+ });
+
+ assert.strictEqual(results.length, 1);
+ assert.strictEqual(results[0].messages.length, 1);
+ assert.strictEqual(
+ results[0].messages[0].message,
+ "Unexpected empty block found.",
+ );
+ });
+
+ it("should report empty block when a disable configuration comment is present and followed by an enable configuration comment", async () => {
+ const code =
+ "/* eslint-disable css/no-empty-blocks */\na {}\n/* eslint-enable css/no-empty-blocks */\nb {}";
+ const results = await eslint.lintText(code, {
+ filePath: "test.css",
+ });
+
+ assert.strictEqual(results.length, 1);
+ assert.strictEqual(results[0].messages.length, 1);
+ assert.strictEqual(
+ results[0].messages[0].message,
+ "Unexpected empty block found.",
+ );
+ assert.strictEqual(results[0].messages[0].line, 4);
+ assert.strictEqual(results[0].messages[0].column, 3);
+
+ assert.strictEqual(results[0].suppressedMessages.length, 1);
+ assert.strictEqual(
+ results[0].suppressedMessages[0].message,
+ "Unexpected empty block found.",
+ );
+ assert.strictEqual(results[0].suppressedMessages[0].line, 2);
+ assert.strictEqual(results[0].suppressedMessages[0].column, 3);
+ });
+
+ it("should not report empty block when a disable configuration comment is present", async () => {
+ const code = "/* eslint-disable css/no-empty-blocks */\na {}";
+ const results = await eslint.lintText(code, {
+ filePath: "test.css",
+ });
+
+ assert.strictEqual(results.length, 1);
+ assert.strictEqual(results[0].messages.length, 0);
+ assert.strictEqual(results[0].suppressedMessages.length, 1);
+ assert.strictEqual(
+ results[0].suppressedMessages[0].message,
+ "Unexpected empty block found.",
+ );
+ assert.strictEqual(results[0].suppressedMessages[0].line, 2);
+ assert.strictEqual(results[0].suppressedMessages[0].column, 3);
+ });
+
+ it("should not report empty block when a disable-line configuration comment is present", async () => {
+ const code = "a {} /* eslint-disable-line css/no-empty-blocks */";
+ const results = await eslint.lintText(code, {
+ filePath: "test.css",
+ });
+
+ assert.strictEqual(results.length, 1);
+ assert.strictEqual(results[0].messages.length, 0);
+
+ assert.strictEqual(results[0].suppressedMessages.length, 1);
+ assert.strictEqual(
+ results[0].suppressedMessages[0].message,
+ "Unexpected empty block found.",
+ );
+ assert.strictEqual(results[0].suppressedMessages[0].line, 1);
+ assert.strictEqual(results[0].suppressedMessages[0].column, 3);
+ });
+
+ it("should not report empty block when a disable-next-line configuration comment is present", async () => {
+ const code =
+ "/* eslint-disable-next-line css/no-empty-blocks */\na {}";
+ const results = await eslint.lintText(code, {
+ filePath: "test.css",
+ });
+
+ assert.strictEqual(results.length, 1);
+ assert.strictEqual(results[0].messages.length, 0);
+
+ assert.strictEqual(results[0].suppressedMessages.length, 1);
+ assert.strictEqual(
+ results[0].suppressedMessages[0].message,
+ "Unexpected empty block found.",
+ );
+ assert.strictEqual(results[0].suppressedMessages[0].line, 2);
+ assert.strictEqual(results[0].suppressedMessages[0].column, 3);
+ });
+
+ it("should not report empty block when a configuration comment disables a rule is present", async () => {
+ const code = "/* eslint css/no-empty-blocks: off */\na {}";
+ const results = await eslint.lintText(code, {
+ filePath: "test.css",
+ });
+
+ assert.strictEqual(results.length, 1);
+ assert.strictEqual(results[0].messages.length, 0);
+ assert.strictEqual(results[0].suppressedMessages.length, 0);
+ });
+ });
+});
diff --git a/tests/rules/no-empty-blocks.test.js b/tests/rules/no-empty-blocks.test.js
new file mode 100644
index 0000000..436b8e0
--- /dev/null
+++ b/tests/rules/no-empty-blocks.test.js
@@ -0,0 +1,108 @@
+/**
+ * @fileoverview Tests for no-empty-blocks rule.
+ * @author Nicholas C. Zakas
+ */
+
+//------------------------------------------------------------------------------
+// Imports
+//------------------------------------------------------------------------------
+
+import rule from "../../src/rules/no-empty-blocks.js";
+import css from "../../src/index.js";
+import { RuleTester } from "eslint";
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+ plugins: {
+ css,
+ },
+ language: "css/css",
+});
+
+ruleTester.run("no-empty-blocks", rule, {
+ valid: ["a { color: red; }", "@media print { a { color: red; } }"],
+ invalid: [
+ {
+ code: "a { }",
+ errors: [
+ {
+ messageId: "emptyBlock",
+ line: 1,
+ column: 3,
+ endLine: 1,
+ endColumn: 6,
+ },
+ ],
+ },
+ {
+ code: "a { /* comment */ }",
+ errors: [
+ {
+ messageId: "emptyBlock",
+ line: 1,
+ column: 3,
+ endLine: 1,
+ endColumn: 20,
+ },
+ ],
+ },
+ {
+ code: "a {\n}",
+ errors: [
+ {
+ messageId: "emptyBlock",
+ line: 1,
+ column: 3,
+ endLine: 2,
+ endColumn: 2,
+ },
+ ],
+ },
+ {
+ code: "a { \n }",
+ errors: [
+ {
+ messageId: "emptyBlock",
+ line: 1,
+ column: 3,
+ endLine: 2,
+ endColumn: 3,
+ },
+ ],
+ },
+ {
+ code: "@media print { }",
+ errors: [
+ {
+ messageId: "emptyBlock",
+ line: 1,
+ column: 14,
+ endLine: 1,
+ endColumn: 17,
+ },
+ ],
+ },
+ {
+ code: "a { }\n@media print { \nb { } \n}",
+ errors: [
+ {
+ messageId: "emptyBlock",
+ line: 1,
+ column: 3,
+ endLine: 1,
+ endColumn: 6,
+ },
+ {
+ messageId: "emptyBlock",
+ line: 3,
+ column: 3,
+ endLine: 3,
+ endColumn: 6,
+ },
+ ],
+ },
+ ],
+});
diff --git a/tools/commit-readme.sh b/tools/commit-readme.sh
new file mode 100644
index 0000000..dcbc986
--- /dev/null
+++ b/tools/commit-readme.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+
+#------------------------------------------------------------------------------
+# Commits the data files if any have changed
+#------------------------------------------------------------------------------
+
+if [ -z "$(git status --porcelain)" ]; then
+ echo "Data did not change."
+else
+ echo "Data changed!"
+
+ # commit the result
+ git add README.md
+ git commit -m "docs: Update README sponsors"
+
+ # push back to source control
+ git push origin HEAD
+fi
diff --git a/tools/dedupe-types.js b/tools/dedupe-types.js
new file mode 100644
index 0000000..ce8b16d
--- /dev/null
+++ b/tools/dedupe-types.js
@@ -0,0 +1,43 @@
+/**
+ * @fileoverview Strips typedef aliases from the rolled-up file. This
+ * is necessary because the TypeScript compiler throws an error when
+ * it encounters a duplicate typedef.
+ *
+ * Usage:
+ * node scripts/strip-typedefs.js filename1.js filename2.js ...
+ *
+ * @author Nicholas C. Zakas
+ */
+
+//-----------------------------------------------------------------------------
+// Imports
+//-----------------------------------------------------------------------------
+
+import fs from "node:fs";
+
+//-----------------------------------------------------------------------------
+// Main
+//-----------------------------------------------------------------------------
+
+// read files from the command line
+const files = process.argv.slice(2);
+
+files.forEach(filePath => {
+ const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/gu);
+ const typedefs = new Set();
+
+ const remainingLines = lines.filter(line => {
+ if (!line.startsWith("/** @typedef {import")) {
+ return true;
+ }
+
+ if (typedefs.has(line)) {
+ return false;
+ }
+
+ typedefs.add(line);
+ return true;
+ });
+
+ fs.writeFileSync(filePath, remainingLines.join("\n"), "utf8");
+});
diff --git a/tools/update-readme.js b/tools/update-readme.js
new file mode 100644
index 0000000..0420068
--- /dev/null
+++ b/tools/update-readme.js
@@ -0,0 +1,55 @@
+/**
+ * @fileoverview Script to update the README with sponsors details in all packages.
+ *
+ * node tools/update-readme.js
+ *
+ * @author Milos Djermanovic
+ */
+
+//-----------------------------------------------------------------------------
+// Requirements
+//-----------------------------------------------------------------------------
+
+import { readFileSync, writeFileSync } from "node:fs";
+import got from "got";
+
+//-----------------------------------------------------------------------------
+// Data
+//-----------------------------------------------------------------------------
+
+const SPONSORS_URL =
+ "https://raw.githubusercontent.com/eslint/eslint.org/main/includes/sponsors.md";
+
+const README_FILE_PATH = "./README.md";
+
+//-----------------------------------------------------------------------------
+// Helpers
+//-----------------------------------------------------------------------------
+
+/**
+ * Fetches the latest sponsors from the website.
+ * @returns {Promise}} Prerendered sponsors markdown.
+ */
+async function fetchSponsorsMarkdown() {
+ return got(SPONSORS_URL).text();
+}
+
+//-----------------------------------------------------------------------------
+// Main
+//-----------------------------------------------------------------------------
+
+const allSponsors = await fetchSponsorsMarkdown();
+
+// read readme file
+const readme = readFileSync(README_FILE_PATH, "utf8");
+
+let newReadme = readme.replace(
+ /[\w\W]*?/u,
+ `\n\n${allSponsors}\n`,
+);
+
+// replace multiple consecutive blank lines with just one blank line
+newReadme = newReadme.replace(/(?<=^|\n)\n{2,}/gu, "\n");
+
+// output to the files
+writeFileSync(README_FILE_PATH, newReadme, "utf8");
diff --git a/tools/update-rules-docs.js b/tools/update-rules-docs.js
new file mode 100644
index 0000000..07a3be9
--- /dev/null
+++ b/tools/update-rules-docs.js
@@ -0,0 +1,102 @@
+/**
+ * @fileoverview Updates the rules table in README.md with rule names,
+ * descriptions, and whether the rules are recommended or not.
+ *
+ * Usage:
+ * node tools/update-rules-docs.js
+ *
+ * @author Francesco Trotta
+ */
+
+//-----------------------------------------------------------------------------
+// Imports
+//-----------------------------------------------------------------------------
+
+import { fromMarkdown } from "mdast-util-from-markdown";
+import fs from "node:fs/promises";
+import path from "node:path";
+
+//-----------------------------------------------------------------------------
+// Type Definitions
+//-----------------------------------------------------------------------------
+
+/** @typedef {import("eslint").AST.Range} Range */
+
+//-----------------------------------------------------------------------------
+// Helpers
+//-----------------------------------------------------------------------------
+
+const docsFileURL = new URL("../README.md", import.meta.url);
+const rulesDirURL = new URL("../src/rules/", import.meta.url);
+
+/**
+ * Formats a table row from a rule filename.
+ * @param {string} ruleFilename The filename of the rule module without directory.
+ * @returns {Promise} The formatted markdown text of the table row.
+ */
+async function formatTableRowFromFilename(ruleFilename) {
+ const ruleURL = new URL(ruleFilename, rulesDirURL);
+ const { default: rule } = await import(ruleURL);
+ const ruleName = path.parse(ruleFilename).name;
+ const { description, recommended } = rule.meta.docs;
+ const ruleLink = `[\`${ruleName}\`](./docs/rules/${ruleName}.md)`;
+ const recommendedText = recommended ? "yes" : "no";
+
+ return `| ${ruleLink} | ${description} | ${recommendedText} |`;
+}
+
+/**
+ * Generates the markdown text for the rules table.
+ * @returns {Promise} The formatted markdown text of the rules table.
+ */
+async function createRulesTableText() {
+ const filenames = await fs.readdir(rulesDirURL);
+ const ruleFilenames = filenames.filter(
+ filename => path.extname(filename) === ".js",
+ );
+ const text = [
+ "| **Rule Name** | **Description** | **Recommended** |",
+ "| :- | :- | :-: |",
+ ...(await Promise.all(ruleFilenames.map(formatTableRowFromFilename))),
+ ].join("\n");
+
+ return text;
+}
+
+/**
+ * Returns start and end offset of the rules table as indicated by "Rule Table Start" and
+ * "Rule Table End" HTML comments in the markdown text.
+ * @param {string} text The markdown text.
+ * @returns {Range | null} The offset range of the rules table, or `null`.
+ */
+function getRulesTableRange(text) {
+ const tree = fromMarkdown(text);
+ const htmlNodes = tree.children.filter(({ type }) => type === "html");
+ const startComment = htmlNodes.find(
+ ({ value }) => value === "",
+ );
+ const endComment = htmlNodes.find(
+ ({ value }) => value === "",
+ );
+
+ return startComment && endComment
+ ? [startComment.position.end.offset, endComment.position.start.offset]
+ : null;
+}
+
+//-----------------------------------------------------------------------------
+// Main
+//-----------------------------------------------------------------------------
+
+let docsText = await fs.readFile(docsFileURL, "utf-8");
+const rulesTableRange = getRulesTableRange(docsText);
+
+if (!rulesTableRange) {
+ throw Error("Rule Table Start/End comments not found, unable to update.");
+}
+
+const tableText = await createRulesTableText();
+
+docsText = `${docsText.slice(0, rulesTableRange[0])}\n${tableText}\n${docsText.slice(rulesTableRange[1])}`;
+
+await fs.writeFile(docsFileURL, docsText);
diff --git a/tsconfig.esm.json b/tsconfig.esm.json
new file mode 100644
index 0000000..40ece13
--- /dev/null
+++ b/tsconfig.esm.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "files": ["dist/esm/index.js"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..3fa504c
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "files": ["src/index.js"],
+ "compilerOptions": {
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "allowJs": true,
+ "checkJs": true,
+ "outDir": "dist/esm",
+ "target": "ES2022",
+ "moduleResolution": "NodeNext",
+ "module": "NodeNext"
+ }
+}