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

+

Automattic Airbnb

Gold Sponsors

+

trunk.io

Silver Sponsors

+

JetBrains Liftoff American Express Workleap

Bronze Sponsors

+

WordHint Anagram Solver Icons8 Discord GitBook Nx HeroCoders Nextbase Starter Kit

+

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. +

Netlify Algolia 1Password

+ 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" + } +}