diff --git a/packages/rollup-plugin-styles/.eslintignore b/packages/rollup-plugin-styles/.eslintignore new file mode 100644 index 00000000..e3f5dd77 --- /dev/null +++ b/packages/rollup-plugin-styles/.eslintignore @@ -0,0 +1,15 @@ +dist +node_modules +coverage + +__fixtures__ +__docs__ + +vitest.config.ts +.prettierrc.cjs +tsup.config.ts +.secretlintrc.cjs +tsconfig.eslint.json + +README.md + diff --git a/packages/rollup-plugin-styles/.eslintrc.cjs b/packages/rollup-plugin-styles/.eslintrc.cjs new file mode 100644 index 00000000..ce296aeb --- /dev/null +++ b/packages/rollup-plugin-styles/.eslintrc.cjs @@ -0,0 +1,90 @@ +/** @ts-check */ +// eslint-disable-next-line import/no-commonjs,import/no-unused-modules +const { defineConfig } = require("@anolilab/eslint-config/define-config"); +// eslint-disable-next-line import/no-commonjs +const globals = require("@anolilab/eslint-config/globals"); + +/// +/// +/// +/// +/// + +/** @type {import('eslint').Linter.Config} */ +module.exports = defineConfig({ + env: { + // Your environments (which contains several predefined global variables) + // Most environments are loaded automatically if our rules are added + }, + extends: ["@anolilab/eslint-config", "@anolilab/eslint-config/typescript-type-checking"], + globals: { + ...globals.es2021, + // Your global variables (setting to false means it's not allowed to be reassigned) + // myGlobal: false + }, + ignorePatterns: ["!**/*"], + overrides: [ + { + files: ["*.ts", "*.tsx", "*.mts", "*.cts", "*.js", "*.jsx"], + // Set parserOptions.project for the project to allow TypeScript to create the type-checker behind the scenes when we run linting + parserOptions: {}, + rules: {}, + }, + { + files: ["*.ts", "*.tsx", "*.mts", "*.cts"], + // Set parserOptions.project for the project to allow TypeScript to create the type-checker behind the scenes when we run linting + parserOptions: {}, + rules: { + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-return": "off", + "prefer-template": "off", + }, + }, + { + files: ["*.js", "*.jsx"], + rules: {}, + }, + { + files: ["*.mdx"], + rules: { + "jsx-a11y/anchor-has-content": "off", + // @see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/issues/917 + "jsx-a11y/heading-has-content": "off", + }, + }, + { + files: ["src/index.ts"], + rules: { + "import/no-unused-modules": "off", + }, + }, + { + files: ["__docs__/**"], + rules: { + "import/no-unresolved": "off", + "import/no-unused-modules": "off", + "no-console": "off", + "no-undef": "off", + "no-unused-vars": "off", + "unicorn/prefer-top-level-await": "off", + }, + }, + { + files: ["__tests__/**"], + rules: { + "import/no-unused-modules": "off", + }, + }, + ], + parserOptions: { + ecmaVersion: 2021, + project: "./tsconfig.eslint.json", + sourceType: "module", + }, + // Report unused `eslint-disable` comments. + reportUnusedDisableDirectives: true, + root: true, +}); diff --git a/packages/rollup-plugin-styles/.npmignore b/packages/rollup-plugin-styles/.npmignore new file mode 100644 index 00000000..ff4abfc4 --- /dev/null +++ b/packages/rollup-plugin-styles/.npmignore @@ -0,0 +1,9 @@ +package-lock.json + +src +__tests__ +__stories__ +__fixtures__ +.rpt2_cache +fixup.sh +.releaserc.json diff --git a/packages/rollup-plugin-styles/.prettierignore b/packages/rollup-plugin-styles/.prettierignore new file mode 100644 index 00000000..ebf52909 --- /dev/null +++ b/packages/rollup-plugin-styles/.prettierignore @@ -0,0 +1,9 @@ +.gitkeep +.env* +*.ico +*.lock +dist +CHANGELOG.md +coverage +node_modules +.eslintcache diff --git a/packages/rollup-plugin-styles/.prettierrc.cjs b/packages/rollup-plugin-styles/.prettierrc.cjs new file mode 100644 index 00000000..32f893c0 --- /dev/null +++ b/packages/rollup-plugin-styles/.prettierrc.cjs @@ -0,0 +1,5 @@ +const config = require("@anolilab/prettier-config"); + +module.exports = { + ...config, +}; diff --git a/packages/rollup-plugin-styles/.releaserc.json b/packages/rollup-plugin-styles/.releaserc.json new file mode 100644 index 00000000..da2ae934 --- /dev/null +++ b/packages/rollup-plugin-styles/.releaserc.json @@ -0,0 +1,3 @@ +{ + "extends": "@anolilab/semantic-release-preset/pnpm" +} diff --git a/packages/rollup-plugin-styles/.secretlintignore b/packages/rollup-plugin-styles/.secretlintignore new file mode 100644 index 00000000..8d4c427f --- /dev/null +++ b/packages/rollup-plugin-styles/.secretlintignore @@ -0,0 +1,3 @@ +.pnpm-store +packages/**/node_modules +node_modules diff --git a/packages/rollup-plugin-styles/.secretlintrc.cjs b/packages/rollup-plugin-styles/.secretlintrc.cjs new file mode 100644 index 00000000..418ad9ce --- /dev/null +++ b/packages/rollup-plugin-styles/.secretlintrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + rules: [ + { + id: "@secretlint/secretlint-rule-preset-recommend", + }, + ], +}; diff --git a/packages/rollup-plugin-styles/CHANGELOG.md b/packages/rollup-plugin-styles/CHANGELOG.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/rollup-plugin-styles/LICENSE.md b/packages/rollup-plugin-styles/LICENSE.md new file mode 100644 index 00000000..3bc8ceb1 --- /dev/null +++ b/packages/rollup-plugin-styles/LICENSE.md @@ -0,0 +1,161 @@ +MIT License + +Copyright (c) 2024 visulima + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + +# Licenses of bundled dependencies +The published @visulima/rollup-plugin-styles artifact additionally contains code with the following licenses: +MIT + +# Bundled dependencies: +## decode-uri-component +License: MIT +By: Sam Verschueren +Repository: SamVerschueren/decode-uri-component + +> The MIT License (MIT) +> +> Copyright (c) 2017, Sam Verschueren (github.com/SamVerschueren) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------- + +## eventemitter3 +License: MIT +By: Arnout Kazemier +Repository: git://github.com/primus/eventemitter3.git + +> The MIT License (MIT) +> +> Copyright (c) 2014 Arnout Kazemier +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +--------------------------------------- + +## filter-obj +License: MIT +By: Sindre Sorhus +Repository: sindresorhus/filter-obj + +> MIT License +> +> Copyright (c) Sindre Sorhus (https://sindresorhus.com) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------- + +## p-queue +License: MIT +Repository: sindresorhus/p-queue + +> MIT License +> +> Copyright (c) Sindre Sorhus (https://sindresorhus.com) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------- + +## p-timeout +License: MIT +By: Sindre Sorhus +Repository: sindresorhus/p-timeout + +> MIT License +> +> Copyright (c) Sindre Sorhus (https://sindresorhus.com) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------- + +## query-string +License: MIT +By: Sindre Sorhus +Repository: sindresorhus/query-string + +> MIT License +> +> Copyright (c) Sindre Sorhus (https://sindresorhus.com) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------- + +## split-on-first +License: MIT +By: Sindre Sorhus +Repository: sindresorhus/split-on-first + +> MIT License +> +> Copyright (c) Sindre Sorhus (https://sindresorhus.com) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + + + + diff --git a/packages/rollup-plugin-styles/README.md b/packages/rollup-plugin-styles/README.md new file mode 100644 index 00000000..eb7f2753 --- /dev/null +++ b/packages/rollup-plugin-styles/README.md @@ -0,0 +1,80 @@ +
+

visulima rollup-plugin-styles

+

+ Universal Rollup plugin for styles: PostCSS, Sass, Less, Stylus and more +

+
+ +
+ +
+ +[![typescript-image]][typescript-url] [![npm-image]][npm-url] [![license-image]][license-url] + +
+ +--- + +
+

+ + Daniel Bannert's open source work is supported by the community on GitHub Sponsors + +

+
+ +--- + +## Install + +```sh +npm install @visulima/rollup-plugin-styles +``` + +```sh +yarn add @visulima/rollup-plugin-styles +``` + +```sh +pnpm add @visulima/rollup-plugin-styles +``` + +## Usage + +### PostCSS + +Install all the necessary dependencies: +```sh +npm install --save-dev postcss postcss-load-config postcss-modules postcss-modules-extract-imports postcss-modules-local-by-default postcss-modules-scope postcss-modules-values postcss-value-parser icss-utils +``` + +```js + +## Related + +## Supported Node.js Versions + +Libraries in this ecosystem make the best effort to track [Node.js’ release schedule](https://github.com/nodejs/release#release-schedule). +Here’s [a post on why we think this is important](https://medium.com/the-node-js-collection/maintainers-should-consider-following-node-js-release-schedule-ab08ed4de71a). + +## Contributing + +If you would like to help take a look at the [list of issues](https://github.com/visulima/packem/issues) and check our [Contributing](.github/CONTRIBUTING.md) guidelines. + +> **Note:** please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms. + +## Credits + +- [Daniel Bannert](https://github.com/prisis) +- [All Contributors](https://github.com/visulima/packem/graphs/contributors) + +## License + +The visulima rollup-plugin-styles is open-sourced software licensed under the [MIT][license-url] + +[typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript +[typescript-url]: "typescript" +[license-image]: https://img.shields.io/npm/l/@visulima/rollup-plugin-styles?color=blueviolet&style=for-the-badge +[license-url]: LICENSE.md "license" +[npm-image]: https://img.shields.io/npm/v/@visulima/rollup-plugin-styles/latest.svg?style=for-the-badge&logo=npm +[npm-url]: https://www.npmjs.com/package/@visulima/rollup-plugin-styles/v/latest "npm" diff --git a/packages/rollup-plugin-styles/package.json b/packages/rollup-plugin-styles/package.json new file mode 100644 index 00000000..7fa97af4 --- /dev/null +++ b/packages/rollup-plugin-styles/package.json @@ -0,0 +1,261 @@ +{ + "name": "@visulima/rollup-plugin-styles", + "version": "0.0.0", + "description": "Universal Rollup plugin for styles: PostCSS, Sass, Less, Stylus and more", + "keywords": [ + "visulima", + "rollup-plugin-styles", + "rollup", + "rollup-plugin", + "css", + "css-modules", + "postcss", + "sass", + "scss", + "less", + "stylus", + "packem" + ], + "homepage": "https://github.com/visulima/packem/tree/main/packages/rollup-plugin-styles", + "bugs": { + "url": "https://github.com/visulima/packem/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/visulima/packem.git", + "directory": "packages/rollup-plugin-styles" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/prisis" + }, + { + "type": "consulting", + "url": "https://anolilab.com/support" + } + ], + "license": "MIT", + "author": { + "name": "Daniel Bannert", + "email": "d.bannert@anolilab.de" + }, + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + }, + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + }, + "./loader/less": { + "require": { + "types": "./dist/loaders/less/index.d.cts", + "default": "./dist/loaders/less/index.cjs" + }, + "import": { + "types": "./dist/loaders/less/index.d.mts", + "default": "./dist/loaders/less/index.mjs" + } + }, + "./loader/postcss": { + "require": { + "types": "./dist/loaders/postcss/index.d.cts", + "default": "./dist/loaders/postcss/index.cjs" + }, + "import": { + "types": "./dist/loaders/postcss/index.d.mts", + "default": "./dist/loaders/postcss/index.mjs" + } + }, + "./loader/sass": { + "require": { + "types": "./dist/loaders/sass/index.d.cts", + "default": "./dist/loaders/sass/index.cjs" + }, + "import": { + "types": "./dist/loaders/sass/index.d.mts", + "default": "./dist/loaders/sass/index.mjs" + } + }, + "./loader/stylus": { + "require": { + "types": "./dist/loaders/stylus.d.cts", + "default": "./dist/loaders/stylus.cjs" + }, + "import": { + "types": "./dist/loaders/stylus.d.mts", + "default": "./dist/loaders/stylus.mjs" + } + }, + "./package.json": "./package.json" + }, + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "build": "cross-env NODE_ENV=development packem build", + "build:prod": "cross-env NODE_ENV=production packem build", + "clean": "rimraf node_modules dist .eslintcache", + "dev": "pnpm run build --watch", + "lint:eslint": "eslint . --ext js,cjs,mjs,jsx,ts,tsx,json,yaml,yml,md,mdx --max-warnings=0 --config .eslintrc.cjs", + "lint:eslint:fix": "eslint . --ext js,cjs,mjs,jsx,ts,tsx,json,yaml,yml,md,mdx --max-warnings=0 --config .eslintrc.cjs --fix", + "lint:package-json": "publint --strict", + "lint:prettier": "prettier --config=.prettierrc.cjs --check .", + "lint:prettier:fix": "prettier --config=.prettierrc.cjs --write .", + "lint:types": "tsc --noEmit", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:ui": "vitest --ui --coverage.enabled=true", + "test:watch": "vitest" + }, + "dependencies": { + "@rollup/pluginutils": "5.1.2", + "@visulima/fs": "2.2.0", + "@visulima/path": "1.0.9", + "cssnano": "7.0.6", + "resolve": "2.0.0-next.5", + "resolve.exports": "2.0.2", + "mime-types": "2.1.35", + "source-map-js": "1.2.1" + }, + "devDependencies": { + "@types/resolve": "^1.20.6", + "@anolilab/eslint-config": "^15.0.3", + "@anolilab/prettier-config": "^5.0.14", + "@anolilab/semantic-release-pnpm": "1.1.3", + "@anolilab/semantic-release-preset": "^9.0.0", + "@babel/core": "^7.25.2", + "@rushstack/eslint-plugin-security": "^0.8.3", + "@secretlint/secretlint-rule-preset-recommend": "^8.2.4", + "@types/less": "3.0.6", + "@types/mime-types": "^2.1.4", + "@types/node": "18.19.54", + "@types/node-sass": "^4.11.7", + "@types/stylus": "^0.48.43", + "@types/uglifycss": "^0.0.11", + "@visulima/packem": "^1.0.5", + "@vitest/coverage-v8": "^2.1.1", + "@vitest/ui": "^2.1.1", + "cross-env": "^7.0.3", + "esbuild": "^0.24.0", + "eslint": "8.57.1", + "eslint-plugin-deprecation": "^3.0.0", + "eslint-plugin-etc": "^2.0.3", + "eslint-plugin-import": "npm:eslint-plugin-i@^2.29.1", + "eslint-plugin-mdx": "^3.1.5", + "eslint-plugin-vitest": "0.4.1", + "eslint-plugin-vitest-globals": "^1.5.0", + "icss-utils": "^5.1.0", + "less": "^4.2.0", + "node-sass": "^9.0.0", + "p-queue": "^8.0.1", + "postcss": "^8.4.47", + "postcss-load-config": "^6.0.1", + "postcss-modules": "6.0.0", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "prettier": "^3.3.3", + "query-string": "^9.1.0", + "rimraf": "^6.0.1", + "rollup": "^4.22.5", + "sass": "^1.79.4", + "secretlint": "8.2.4", + "semantic-release": "^24.1.2", + "stylus": "^0.63.0", + "typescript": "^5.6.2", + "vitest": "^2.1.1" + }, + "peerDependencies": { + "icss-utils": "^5.1.0", + "less": "^4.2.0", + "node-sass": "^9.0.0", + "postcss": "^8.4.38", + "postcss-load-config": "^6.0.1", + "postcss-modules": "6.0.0", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "rollup": "^4.0.0", + "sass": "^1.43.4" + }, + "peerDependenciesMeta": { + "icss-utils": { + "optional": true + }, + "less": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "postcss": { + "optional": true + }, + "postcss-load-config": { + "optional": true + }, + "postcss-modules": { + "optional": true + }, + "postcss-modules-extract-imports": { + "optional": true + }, + "postcss-modules-local-by-default": { + "optional": true + }, + "postcss-modules-scope": { + "optional": true + }, + "postcss-modules-values": { + "optional": true + }, + "postcss-value-parser": { + "optional": true + }, + "rollup": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + } + }, + "engines": { + "node": ">=18.* <=22.*" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "anolilab": { + "eslint-config": { + "plugin": { + "tsdoc": false, + "etc": false + }, + "warn_on_unsupported_typescript_version": false, + "info_on_disabling_jsx_react_rule": false, + "info_on_disabling_prettier_conflict_rule": false, + "info_on_disabling_jsonc_sort_keys_rule": false, + "info_on_disabling_etc_no_deprecated": false + } + } +} diff --git a/packages/rollup-plugin-styles/packem.config.ts b/packages/rollup-plugin-styles/packem.config.ts new file mode 100644 index 00000000..33101cd1 --- /dev/null +++ b/packages/rollup-plugin-styles/packem.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "@visulima/packem/config"; +import transformer from "@visulima/packem/transformer/esbuild"; + +// eslint-disable-next-line import/no-unused-modules +export default defineConfig({ + cjsInterop: true, + declaration: false, + externals: [ + "stylus", + "less", + "sass", + "node-sass", + "postcss", + "rollup", + ], + fileCache: false, + rollup: { + license: { + path: "./LICENSE.md", + }, + node10Compatibility: { + typeScriptVersion: ">=5.0", + writeToPackageJson: true, + }, + }, + transformer, +}); diff --git a/packages/rollup-plugin-styles/project.json b/packages/rollup-plugin-styles/project.json new file mode 100644 index 00000000..35449a14 --- /dev/null +++ b/packages/rollup-plugin-styles/project.json @@ -0,0 +1,8 @@ +{ + "name": "rollup-plugin-styles", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/rollup-plugin-styles/src", + "projectType": "library", + "tags": ["rollup-plugin-styles", "styles", "type:package"], + "implicitDependencies": ["packem"] +} diff --git a/packages/rollup-plugin-styles/src/index.ts b/packages/rollup-plugin-styles/src/index.ts new file mode 100644 index 00000000..21ea81a5 --- /dev/null +++ b/packages/rollup-plugin-styles/src/index.ts @@ -0,0 +1,402 @@ +import path from "node:path"; + +import { createFilter } from "@rollup/pluginutils"; +import cssnano from "cssnano"; +import type { OutputAsset, OutputChunk, Plugin } from "rollup"; + +import Loaders from "./loaders"; +import type { Extracted, LoaderContext } from "./loaders/types"; +import type { ExtractedData, Options, PostCSSLoaderOptions } from "./types"; +import concat from "./utils/concat"; +import { ensurePCSSOption, ensurePCSSPlugins, ensureUseOption, inferHandlerOption, inferModeOption, inferOption, inferSourceMapOption } from "./utils/options"; +import { humanlizePath, isAbsolutePath, isRelativePath, normalizePath } from "./utils/path"; +import { mm } from "./utils/sourcemap"; + +// eslint-disable-next-line sonarjs/cognitive-complexity +export default (options: Options = {}): Plugin => { + const isIncluded = createFilter(options.include, options.exclude); + + const sourceMap = inferSourceMapOption(options.sourceMap); + const loaderOptions: PostCSSLoaderOptions = { + ...inferModeOption(options.mode), + + autoModules: options.autoModules ?? false, + config: inferOption(options.config, {}), + dts: options.dts ?? false, + extensions: options.extensions ?? [".css", ".pcss", ".postcss", ".sss"], + import: inferHandlerOption(options.import, options.alias), + minimize: inferOption(options.minimize, false), + modules: inferOption(options.modules, false), + namedExports: options.namedExports ?? false, + postcss: {}, + to: options.to, + url: inferHandlerOption(options.url, options.alias), + }; + + if (typeof loaderOptions.inject === "object" && loaderOptions.inject.treeshakeable && loaderOptions.namedExports) { + throw new Error("`inject.treeshakeable` option is incompatible with `namedExports` option"); + } + + if (options.parser) { + loaderOptions.postcss.parser = ensurePCSSOption(options.parser, "parser"); + } + + if (options.syntax) { + loaderOptions.postcss.syntax = ensurePCSSOption(options.syntax, "syntax"); + } + + if (options.stringifier) { + loaderOptions.postcss.stringifier = ensurePCSSOption(options.stringifier, "stringifier"); + } + + if (options.plugins) { + loaderOptions.postcss.plugins = ensurePCSSPlugins(options.plugins); + } + + const loaders = new Loaders({ + extensions: loaderOptions.extensions, + loaders: options.loaders, + use: [["postcss", loaderOptions], ...ensureUseOption(options), ["sourcemap", {}]], + }); + + let extracted: Extracted[] = []; + + return { + augmentChunkHash(chunk) { + if (extracted.length === 0) { + return; + } + + const ids: string[] = []; + + for (const module of Object.keys(chunk.modules)) { + const traversed = new Set(); + + let current = [module]; + + do { + const imports: string[] = []; + for (const id of current) { + if (traversed.has(id)) { + // eslint-disable-next-line no-continue + continue; + } + + if (loaders.isSupported(id)) { + if (isIncluded(id)) { + imports.push(id); + } + + // eslint-disable-next-line no-continue + continue; + } + + traversed.add(id); + + const index = this.getModuleInfo(id); + + index && imports.push(...index.importedIds); + } + + current = imports; + } while (current.some((id) => !loaders.isSupported(id))); + ids.push(...current); + } + + const hashable = extracted + .filter((e) => ids.includes(e.id)) + .sort((a, b) => ids.lastIndexOf(a.id) - ids.lastIndexOf(b.id)) + .map((e) => `${path.basename(e.id)}:${e.css}`); + + if (hashable.length === 0) { + return; + } + + return hashable.join(":"); + }, + + async generateBundle(options_, bundle) { + if (extracted.length === 0 || !(options_.dir || options_.file)) { + return; + } + + const dir = options_.dir ?? path.dirname(options_.file!); + const chunks = Object.values(bundle).filter((c): c is OutputChunk => c.type === "chunk"); + const manual = chunks.filter((c) => !c.facadeModuleId); + const emitted = options_.preserveModules ? chunks : chunks.filter((c) => c.isEntry || c.isDynamicEntry); + + const emittedList: [string, string[]][] = []; + + const getExtractedData = async (name: string, ids: string[]): Promise => { + const fileName = + typeof loaderOptions.extract === "string" ? normalizePath(loaderOptions.extract).replace(/^\.[/\\]/, "") : normalizePath(`${name}.css`); + + if (isAbsolutePath(fileName)) { + this.error(["Extraction path must be relative to the output directory,", `which is ${humanlizePath(dir)}`].join("\n")); + } + + if (isRelativePath(fileName)) { + this.error(["Extraction path must be nested inside output directory,", `which is ${humanlizePath(dir)}`].join("\n")); + } + + const entries = extracted.filter((e) => ids.includes(e.id)).sort((a, b) => ids.lastIndexOf(a.id) - ids.lastIndexOf(b.id)); + + const result = await concat(entries); + + return { + css: result.css, + map: mm(result.map.toString()) + .relative(path.dirname(path.resolve(dir, fileName))) + .toString(), + name: fileName, + }; + }; + + const getName = (chunk: OutputChunk): string => { + if (options_.file) { + return path.parse(options_.file).name; + } + + if (options_.preserveModules) { + const { dir, name } = path.parse(chunk.fileName); + return dir ? `${dir}/${name}` : name; + } + + return chunk.name; + }; + + const getImports = (chunk: OutputChunk): string[] => { + const ids: string[] = []; + + for (const module of Object.keys(chunk.modules)) { + const traversed = new Set(); + + let current = [module]; + + do { + const imports: string[] = []; + for (const id of current) { + if (traversed.has(id)) { + continue; + } + + if (loaders.isSupported(id)) { + if (isIncluded(id)) { + imports.push(id); + } + + continue; + } + + traversed.add(id); + + const index = this.getModuleInfo(id); + + index && imports.push(...index.importedIds); + } + + current = imports; + } while (current.some((id) => !loaders.isSupported(id))); + + ids.push(...current); + } + + return ids; + }; + + const moved: string[] = []; + if (typeof loaderOptions.extract === "string") { + const ids: string[] = []; + + for (const chunk of manual) { + const chunkIds = getImports(chunk); + + moved.push(...chunkIds); + ids.push(...chunkIds); + } + + for (const chunk of emitted) { + ids.push(...getImports(chunk).filter((id) => !moved.includes(id))); + } + + const name = getName(chunks[0] as OutputChunk); + + emittedList.push([name, ids]); + } else { + for (const chunk of manual) { + const ids = getImports(chunk); + + if (ids.length === 0) { + // eslint-disable-next-line no-continue + continue; + } + + moved.push(...ids); + + const name = getName(chunk); + + emittedList.push([name, ids]); + } + + for (const chunk of emitted) { + const ids = getImports(chunk).filter((id) => !moved.includes(id)); + + if (ids.length === 0) { + // eslint-disable-next-line no-continue + continue; + } + + const name = getName(chunk); + + emittedList.push([name, ids]); + } + } + + for await (const [name, ids] of emittedList) { + const res = await getExtractedData(name, ids); + + if (typeof options.onExtract === "function") { + const shouldExtract = options.onExtract(res); + + if (!shouldExtract) { + continue; + } + } + + // Perform minimization on the extracted file + if (loaderOptions.minimize) { + const cssnanoOptions = typeof loaderOptions.minimize === "object" ? loaderOptions.minimize : {}; + const minifier = cssnano(cssnanoOptions); + + const resMin = await minifier.process(res.css, { + from: res.name, + map: sourceMap && { + annotation: false, + inline: false, + prev: res.map, + sourcesContent: sourceMap.content, + }, + to: res.name, + }); + + res.css = resMin.css; + res.map = resMin.map?.toString(); + } + + const cssFile = { fileName: res.name, name: res.name, source: res.css, type: "asset" as const }; + const cssFileId = this.emitFile(cssFile); + + if (res.map && sourceMap) { + const fileName = this.getFileName(cssFileId); + + const assetDir = + typeof options_.assetFileNames === "string" + ? normalizePath(path.dirname(options_.assetFileNames)) + : typeof options_.assetFileNames === "function" + ? normalizePath(path.dirname(options_.assetFileNames(cssFile))) + : "assets"; // Default for Rollup v2 + + const map = mm(res.map) + .modify((m) => (m.file = path.basename(fileName))) + .modifySources((s) => { + // Compensate for possible nesting depending on `assetFileNames` value + if (s === "") { + return s; + } + + if (assetDir.length <= 1) { + return s; + } + + s = `../${s}`; // ...then there's definitely at least 1 level offset + + for (const c of assetDir) { + if (c === "/") { + s = `../${s}`; + } + } + + return s; + }); + + if (sourceMap.inline) { + map.modify((m) => sourceMap.transform?.(m, normalizePath(dir, fileName))); + + (bundle[fileName] as OutputAsset).source += map.toCommentData(); + } else { + const mapFileName = `${fileName}.map`; + + map.modify((m) => sourceMap.transform?.(m, normalizePath(dir, mapFileName))); + + this.emitFile({ fileName: mapFileName, source: map.toString(), type: "asset" }); + + const { base } = path.parse(mapFileName); + + (bundle[fileName] as OutputAsset).source += map.toCommentFile(base); + } + } + } + }, + + name: "styles", + + async transform(code, id) { + if (!isIncluded(id) || !loaders.isSupported(id)) { + return null; + } + + // Skip empty files + if (code.replaceAll(/\s/g, "") === "") { + return null; + } + + // Check if file was already processed into JS + // by other instance(s) of this or other plugin(s) + try { + this.parse(code, {}); // If it doesn't throw... + this.warn(`Skipping processed file ${humanlizePath(id)}`); + + return null; + } catch { + // Was not already processed, continuing + } + + if (typeof options.onImport === "function") { + options.onImport(code, id); + } + + const context: LoaderContext = { + assets: new Map(), + deps: new Set(), + id, + options: {}, + plugin: this, + sourceMap, + warn: this.warn.bind(this), + }; + + const res = await loaders.process({ code }, context); + + for (const dep of context.deps) { + this.addWatchFile(dep); + } + + for (const [fileName, source] of context.assets) { + this.emitFile({ fileName, source, type: "asset" }); + } + + if (res.extracted) { + const { id } = res.extracted; + + extracted = extracted.filter((e) => e.id !== id); + extracted.push(res.extracted); + } + + return { + code: res.code, + map: sourceMap && res.map ? res.map : { mappings: "" as const }, + moduleSideEffects: res.extracted ? true : null, + }; + }, + }; +}; diff --git a/packages/rollup-plugin-styles/src/loaders/index.ts b/packages/rollup-plugin-styles/src/loaders/index.ts new file mode 100644 index 00000000..a18853f9 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/index.ts @@ -0,0 +1,105 @@ +import type PQueue from "p-queue"; + +import lessLoader from "./less"; +import postcssLoader from "./postcss"; +import sassLoader from "./sass"; +import sourcemapLoader from "./sourcemap"; +import stylusLoader from "./stylus"; +import type { Loader, LoaderContext, Payload } from "./types"; + +function matchFile(file: string, condition: Loader["test"]): boolean { + if (!condition) { + return false; + } + + if (typeof condition === "function") { + return condition(file); + } + + return condition.test(file); +} + +// This queue makes sure one thread is always available, +// which is necessary for some cases +// ex.: https://github.com/sass/node-sass/issues/857 +const threadPoolSize = process.env.UV_THREADPOOL_SIZE ? Number.parseInt(process.env.UV_THREADPOOL_SIZE) : 4; // default `libuv` threadpool size + +/** Options for {@link Loaders} class */ +interface LoadersOptions { + /** @see {@link Options.extensions} */ + extensions: string[]; + /** @see {@link Options.loaders} */ + loaders?: Loader[]; + /** @see {@link Options.use} */ + use: [string, Record][]; +} + +export default class Loaders { + private readonly use: Map>; + + private readonly test: (file: string) => boolean; + + private readonly loaders = new Map(); + + private workQueue?: PQueue; + + constructor(options: LoadersOptions) { + this.use = new Map(options.use.reverse()); + this.test = (file): boolean => options.extensions.some((extension) => file.toLowerCase().endsWith(extension)); + this.add(postcssLoader, sourcemapLoader, sassLoader, lessLoader, stylusLoader); + + if (options.loaders) { + this.add(...options.loaders); + } + } + + add>(...loaders: Loader[]): void { + for (const loader of loaders) { + if (!this.use.has(loader.name)) { + continue; + } + + this.loaders.set(loader.name, loader as Loader); + } + } + + isSupported(file: string): boolean { + if (this.test(file)) { + return true; + } + + for (const [, loader] of this.loaders) { + if (matchFile(file, loader.test)) { + return true; + } + } + + return false; + } + + async process(payload: Payload, context: LoaderContext): Promise { + if (!this.workQueue) { + const { default: pQueue } = await import("p-queue"); + + this.workQueue = new pQueue({ concurrency: threadPoolSize - 1 }); + } + + const { workQueue } = this; + + for await (const [name, options] of this.use) { + const loader = this.loaders.get(name); + + if (!loader) { + continue; + } + + const context_: LoaderContext = { ...context, options }; + + if (loader.alwaysProcess || matchFile(context_.id, loader.test)) { + payload = (await workQueue.add(loader.process.bind(context_, payload)))!; + } + } + + return payload; + } +} diff --git a/packages/rollup-plugin-styles/src/loaders/less/importer.ts b/packages/rollup-plugin-styles/src/loaders/less/importer.ts new file mode 100644 index 00000000..feb424f6 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/less/importer.ts @@ -0,0 +1,39 @@ +import { readFileSync } from "node:fs"; + +import { resolveAsync } from "../../utils/resolve"; +import { getUrlOfPartial, normalizeUrl } from "../../utils/url"; + +const extensions = [".less", ".css"]; + +const getStylesFileManager = (less: LessStatic): Less.FileManager => + new (class extends less.FileManager implements Less.FileManager { + // eslint-disable-next-line class-methods-use-this + public override supports(): boolean { + return true; + } + + // eslint-disable-next-line class-methods-use-this + public override async loadFile(filename: string, filedir: string, options_: Less.Options): Promise { + const url = normalizeUrl(filename); + const partialUrl = getUrlOfPartial(url); + const options = { basedirs: [filedir], caller: "Less importer", extensions }; + + if (options_.paths) { + options.basedirs.push(...options_.paths); + } + + // Give precedence to importing a partial + const id = await resolveAsync([partialUrl, url], options); + + // eslint-disable-next-line security/detect-non-literal-fs-filename + return { contents: readFileSync(id, "utf8"), filename: id }; + } + })(); + +const importer: Less.Plugin = { + install(less, pluginManager) { + pluginManager.addFileManager(getStylesFileManager(less)); + }, +}; + +export default importer; diff --git a/packages/rollup-plugin-styles/src/loaders/less/index.ts b/packages/rollup-plugin-styles/src/loaders/less/index.ts new file mode 100644 index 00000000..026b7330 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/less/index.ts @@ -0,0 +1,47 @@ +import path from "node:path"; + +import { normalizePath } from "../../utils/path"; +import type { Loader } from "../types"; +import importer from "./importer"; + +const loader: Loader = { + name: "less", + async process({ code, map }) { + const options = { ...this.options }; + const less = await import("less").then((m) => m.default); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!less) { + throw new Error("You need to install `less` package in order to process Less files"); + } + + const plugins = [importer]; + + if (options.plugins) { + plugins.push(...options.plugins); + } + + const result: Less.RenderOutput = await less.render(code, { + ...options, + filename: this.id, + plugins, + sourceMap: { outputSourceFiles: true, sourceMapBasepath: path.dirname(this.id) }, + }) as Less.RenderOutput; + + const deps = result.imports; + + // eslint-disable-next-line no-loops/no-loops,no-restricted-syntax + for (const dep of deps) { + this.deps.add(normalizePath(dep)); + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return { code: result.css, map: result.map ?? map }; + }, + test: /\.less$/i, +}; + +// eslint-disable-next-line import/no-unused-modules +export interface LESSLoaderOptions extends Record, Less.Options {} + +export default loader; diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/common.ts b/packages/rollup-plugin-styles/src/loaders/postcss/common.ts new file mode 100644 index 00000000..1dda8e9e --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/common.ts @@ -0,0 +1,5 @@ +// eslint-disable-next-line security/detect-unsafe-regex +export const hashRe = /\[hash(?::(\d+))?\]/; +export const firstExtRe = /(? => { + if (!config) { + return { file: "", options: {}, plugins: [] }; + } + + const { dir } = parse(id); + + const searchPath = config.path ? resolve(config.path) : dir; + + try { + let postcssConfig: Result; + + if (configCache) { + postcssConfig = configCache; + } else { + postcssConfig = await postcssrc( + { + cwd: process.cwd(), + env: process.env.NODE_ENV ?? "development", + ...config.ctx, + }, + searchPath, + ); + + configCache = postcssConfig; + } + + const result: Result = { file: postcssConfig.file, options: postcssConfig.options, plugins: ensurePCSSPlugins(postcssConfig.plugins) }; + + if (result.options.parser) { + result.options.parser = ensurePCSSOption(result.options.parser, "parser"); + } + + if (result.options.syntax) { + result.options.syntax = ensurePCSSOption(result.options.syntax, "syntax"); + } + + if (result.options.stringifier) { + result.options.stringifier = ensurePCSSOption(result.options.stringifier, "stringifier"); + } + + return result; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if (error.message.includes("No PostCSS Config found in")) { + return { file: "", options: {}, plugins: [] }; + } + + throw error; + } +}; diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/icss/index.ts b/packages/rollup-plugin-styles/src/loaders/postcss/icss/index.ts new file mode 100644 index 00000000..b064f584 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/icss/index.ts @@ -0,0 +1,50 @@ +import { extractICSS, replaceSymbols, replaceValueSymbols } from "icss-utils"; +import type { PluginCreator, Result } from "postcss"; + +import type { Load } from "./load"; +import loadDefault from "./load"; +import resolve from "./resolve"; + +const name = "styles-icss"; +const extensionsDefault = [".css", ".pcss", ".postcss", ".sss"]; + +export interface InteroperableCSSOptions { + extensions?: string[]; + load?: Load; +} + +const plugin: PluginCreator = (options = {}) => { + const load = options.load ?? loadDefault; + const extensions = options.extensions ?? extensionsDefault; + + return { + async OnceExit(css, { result }) { + if (!css.source?.input.file) { + return; + } + + const resultOptions: Result["opts"] = { ...result.opts }; + delete resultOptions.map; + + const { icssExports, icssImports } = extractICSS(css); + + const imports = await resolve(icssImports, load, css.source.input.file, extensions, result.processor, resultOptions); + + replaceSymbols(css, imports); + + // eslint-disable-next-line no-loops/no-loops,no-restricted-syntax + for (const [k, v] of Object.entries(icssExports)) { + result.messages.push({ + export: { [k]: replaceValueSymbols(v, imports) }, + plugin: name, + type: "icss", + }); + } + }, + postcssPlugin: name, + }; +}; + +plugin.postcss = true; + +export default plugin; diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/icss/load.ts b/packages/rollup-plugin-styles/src/loaders/postcss/icss/load.ts new file mode 100644 index 00000000..7aed1577 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/icss/load.ts @@ -0,0 +1,32 @@ + +import { readFile } from "@visulima/fs"; +import { dirname } from "@visulima/path"; +import type { ProcessOptions } from "postcss"; +import type Processor from "postcss/lib/processor"; + +import { resolveAsync } from "../../../utils/resolve"; + +export type Load = (url: string, file: string, extensions: string[], processor: Processor, options?: ProcessOptions) => Promise>; + +const load: Load = async (url, file, extensions, processor, options_) => { + const options = { basedirs: [dirname(file)], caller: "ICSS loader", extensions }; + const from = await resolveAsync([url, `./${url}`], options); + const source = await readFile(from); + const { messages } = await processor.process(source, { ...options_, from }); + + const exports: Record = {}; + + // eslint-disable-next-line no-loops/no-loops,no-restricted-syntax + for (const message of messages) { + if (message.type !== "icss") { + // eslint-disable-next-line no-continue + continue; + } + + Object.assign(exports, message.export as Record); + } + + return exports; +}; + +export default load; diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/icss/resolve.ts b/packages/rollup-plugin-styles/src/loaders/postcss/icss/resolve.ts new file mode 100644 index 00000000..364dfc9e --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/icss/resolve.ts @@ -0,0 +1,28 @@ +import type { CSSImports } from "icss-utils"; +import type { ProcessOptions } from "postcss"; +import type Processor from "postcss/lib/processor"; + +import type { Load } from "./load"; + +export default async function ( + icssImports: CSSImports, + load: Load, + file: string, + extensions: string[], + processor: Processor, + options?: ProcessOptions, +): Promise> { + const imports: Record = {}; + + // eslint-disable-next-line no-loops/no-loops,no-restricted-syntax + for await (const [url, values] of Object.entries(icssImports)) { + const exports = await load(url, file, extensions, processor, options); + + // eslint-disable-next-line no-loops/no-loops,no-restricted-syntax + for (const [k, v] of Object.entries(values)) { + imports[k] = exports[v]; + } + } + + return imports; +} diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/import/index.ts b/packages/rollup-plugin-styles/src/loaders/postcss/import/index.ts new file mode 100644 index 00000000..3e188a2c --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/import/index.ts @@ -0,0 +1,153 @@ +import type { AtRule, PluginCreator, Result } from "postcss"; +import postcss from "postcss"; +import valueParser from "postcss-value-parser"; + +import { isAbsolutePath, normalizePath } from "../../../utils/path"; +import type { ImportResolve } from "./resolve"; +import resolveDefault from "./resolve"; +import { dirname } from "@visulima/path"; + +const name = "styles-import"; +const extensionsDefault = [".css", ".pcss", ".postcss", ".sss"]; + +/** `@import` handler options */ +export interface ImportOptions { + /** + * Aliases for import paths. + * Overrides the global `alias` option. + * - ex.: `{"foo":"bar"}` + */ + alias?: Record; + /** + * Import files ending with these extensions. + * Overrides the global `extensions` option. + * @default [".css", ".pcss", ".postcss", ".sss"] + */ + extensions?: string[]; + /** + * Provide custom resolver for imports + * in place of the default one + */ + resolve?: ImportResolve; +} + +const plugin: PluginCreator = (options = {}) => { + const resolve = options.resolve ?? resolveDefault; + const alias = options.alias ?? {}; + const extensions = options.extensions ?? extensionsDefault; + + return { + async Once(css, { result: res }) { + if (!css.source?.input.file) { + return; + } + + const options_: Result["opts"] = { ...res.opts }; + delete options_.map; + + const { file } = css.source.input; + const importList: { rule: AtRule; url: string }[] = []; + const basedir = dirname(file); + + css.walkAtRules(/^import$/i, (rule) => { + // Top level only + if (rule.parent && rule.parent.type !== "root") { + rule.warn(res, "`@import` should be top level"); + return; + } + + // Child nodes should not exist + if (rule.nodes) { + rule.warn(res, "`@import` was not terminated correctly"); + return; + } + + const [urlNode] = valueParser(rule.params).nodes; + + // No URL detected + if (!urlNode || (urlNode.type !== "string" && urlNode.type !== "function")) { + rule.warn(res, `No URL in \`${rule.toString()}\``); + return; + } + + let url = ""; + + if (urlNode.type === "string") { + url = urlNode.value; + } else if (urlNode.type === "function") { + // Invalid function + if (!/^url$/i.test(urlNode.value)) { + rule.warn(res, `Invalid \`url\` function in \`${rule.toString()}\``); + return; + } + + const isString = urlNode.nodes[0]?.type === "string"; + + url = isString ? (urlNode.nodes[0] as valueParser.Node).value : valueParser.stringify(urlNode.nodes); + } + + url = url.replaceAll(/^\s+|\s+$/g, ""); + + // Resolve aliases + for (const [from, to] of Object.entries(alias)) { + if (url !== from && !url.startsWith(`${from}/`)) { + continue; + } + + url = normalizePath(to) + url.slice(from.length); + } + + // Empty URL + if (url.length === 0) { + rule.warn(res, `Empty URL in \`${rule.toString()}\``); + return; + } + + // Skip Web URLs + if (!isAbsolutePath(url)) { + try { + new URL(url); + return; + } catch { + // Is not a Web URL, continuing + } + } + + importList.push({ rule, url }); + }); + + for await (const { rule, url } of importList) { + try { + const { from, source } = await resolve(url, basedir, extensions); + + if (!(source instanceof Uint8Array) || typeof from !== "string") { + rule.warn(res, `Incorrectly resolved \`@import\` in \`${rule.toString()}\``); + continue; + } + + if (normalizePath(from) === normalizePath(file)) { + rule.warn(res, `\`@import\` loop in \`${rule.toString()}\``); + continue; + } + + const imported = await postcss(plugin(options)).process(source, { ...options_, from }); + + res.messages.push(...imported.messages, { file: from, plugin: name, type: "dependency" }); + + if (imported.root) { + rule.replaceWith(imported.root); + } else { + rule.remove(); + } + } catch { + rule.warn(res, `Unresolved \`@import\` in \`${rule.toString()}\``); + } + } + }, + postcssPlugin: name, + }; +}; + +plugin.postcss = true; + +export default plugin; diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/import/resolve.ts b/packages/rollup-plugin-styles/src/loaders/postcss/import/resolve.ts new file mode 100644 index 00000000..2b4b3b48 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/import/resolve.ts @@ -0,0 +1,26 @@ +import { readFile } from "@visulima/fs"; +import qs from "query-string"; + +import { resolveAsync } from "../../../utils/resolve"; + +/** File resolved by `@import` resolver */ +export interface ImportFile { + /** Absolute path to file */ + from: string; + /** File source */ + source: Uint8Array; +} + +/** `@import` resolver */ +export type ImportResolve = (url: string, basedir: string, extensions: string[]) => Promise; + +const resolve: ImportResolve = async (inputUrl, basedir, extensions) => { + const options = { basedirs: [basedir], caller: "@import resolver", extensions }; + const parseOptions = { decode: false, parseFragmentIdentifier: true, sort: false as const }; + const { url } = qs.parseUrl(inputUrl, parseOptions); + const from = await resolveAsync([url, `./${url}`], options); + + return { from, source: await readFile(from) }; +}; + +export default resolve; diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/index.ts b/packages/rollup-plugin-styles/src/loaders/postcss/index.ts new file mode 100644 index 00000000..2bc44229 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/index.ts @@ -0,0 +1,267 @@ +import { fileURLToPath } from "node:url"; + +import { makeLegalIdentifier } from "@rollup/pluginutils"; +import { isAccessibleSync, writeFileSync } from "@visulima/fs"; +import { dirname, join } from "@visulima/path"; +import cssnano from "cssnano"; +import type { AcceptedPlugin, ProcessOptions } from "postcss"; +import postcss from "postcss"; +import type { RawSourceMap } from "source-map-js"; + +import type { InjectOptions, PostCSSLoaderOptions } from "../../types"; +import { humanlizePath, normalizePath } from "../../utils/path"; +import { resolveAsync } from "../../utils/resolve"; +import safeId from "../../utils/safe-id"; +import { mm } from "../../utils/sourcemap"; +import type { Loader } from "../types"; +import loadConfig from "./config"; +import postcssICSS from "./icss"; +import postcssImport from "./import"; +import postcssModules from "./modules"; +import postcssNoop from "./noop"; +import postcssUrl from "./url"; + +const baseDirectory = dirname(fileURLToPath(import.meta.url)); + +let injectorId: string; +const testing = process.env.NODE_ENV === "test"; + +const cssVariableName = "css"; +const reservedWords = new Set([cssVariableName]); + +function getClassNameDefault(name: string): string { + const id = makeLegalIdentifier(name); + + if (reservedWords.has(id)) { + return `_${id}`; + } + + return id; +} + +function ensureAutoModules(am: PostCSSLoaderOptions["autoModules"], id: string): boolean { + if (typeof am === "function") { + return am(id); + } + + if (am instanceof RegExp) { + return am.test(id); + } + + return am && /\.module\.[A-Za-z]+$/.test(id); +} + +type PostCSSOptions = Pick, "from" | "map" | "to"> & PostCSSLoaderOptions["postcss"]; + +const loader: Loader = { + alwaysProcess: true, + name: "postcss", + async process({ code, extracted, map }) { + const options = { ...this.options }; + const config = await loadConfig(this.id, options.config); + const plugins: AcceptedPlugin[] = []; + const autoModules = ensureAutoModules(options.autoModules, this.id); + const supportModules = Boolean(options.modules || autoModules); + const modulesExports: Record = {}; + + const postcssOptions: PostCSSOptions = { + ...config.options, + ...options.postcss, + from: this.id, + map: { + annotation: false, + inline: false, + prev: mm(map).relative(dirname(this.id)).toObject(), + sourcesContent: this.sourceMap ? this.sourceMap.content : true, + }, + to: options.to ?? this.id, + }; + + delete postcssOptions.plugins; + + if (options.import) { + plugins.push(postcssImport({ extensions: options.extensions, ...options.import })); + } + + if (options.url) { + plugins.push(postcssUrl({ inline: Boolean(options.inject), ...options.url })); + } + + if (options.postcss.plugins) { + plugins.push(...options.postcss.plugins); + } + + if (config.plugins) { + plugins.push(...config.plugins); + } + + if (supportModules) { + const modulesOptions = typeof options.modules === "object" ? options.modules : {}; + + plugins.push( + ...postcssModules({ + failOnWrongOrder: true, + generateScopedName: testing ? "[name]_[local]" : undefined, + ...modulesOptions, + }), + postcssICSS({ extensions: options.extensions }), + ); + } + + if (options.minimize) { + const cssnanoOptions = typeof options.minimize === "object" ? options.minimize : {}; + + plugins.push(cssnano(cssnanoOptions)); + } + + // Avoid PostCSS warning + if (plugins.length === 0) { + plugins.push(postcssNoop); + } + + const res = await postcss(plugins).process(code, postcssOptions); + + for (const message of res.messages) + switch (message.type) { + case "warning": { + this.warn({ message: message.text as string, plugin: message.plugin }); + break; + } + + case "icss": { + Object.assign(modulesExports, message.export as Record); + break; + } + + case "dependency": { + this.deps.add(normalizePath(message.file as string)); + break; + } + + case "asset": { + this.assets.set(message.to as string, message.source as Uint8Array); + break; + } + } + + map = mm(res.map.toJSON()).resolve(dirname(postcssOptions.to)).toString(); + + if (!options.extract && this.sourceMap) { + const m = mm(map) + .modify((map) => void delete (map as Partial).file) + .relative(); + + if (this.sourceMap.transform) { + m.modify(this.sourceMap.transform); + } + + map = m.toString(); + res.css += m.toCommentData(); + } + + if (options.emit) { + return { code: res.css, map }; + } + + const saferId = (id: string): string => safeId(id, basename(this.id)); + const modulesVariableName = saferId("modules"); + + const output = [`export var ${cssVariableName} = ${JSON.stringify(res.css)};`]; + const dts = [`export var ${cssVariableName}: string;`]; + + if (options.namedExports) { + const getClassName = typeof options.namedExports === "function" ? options.namedExports : getClassNameDefault; + + for (const name in modulesExports) { + const newName = getClassName(name); + + if (name !== newName) { + this.warn(`Exported \`${name}\` as \`${newName}\` in ${humanlizePath(this.id)}`); + } + + const fmt = JSON.stringify(modulesExports[name]); + + output.push(`export var ${newName} = ${fmt};`); + + if (options.dts) { + dts.push(`export var ${newName}: ${fmt};`); + } + } + } + + if (options.extract) { + extracted = { css: res.css, id: this.id, map }; + } + + if (options.inject) { + if (typeof options.inject === "function") { + output.push(options.inject(cssVariableName, this.id), `var ${modulesVariableName} = ${JSON.stringify(modulesExports)};`); + } else { + const { treeshakeable, ...injectorOptions } = typeof options.inject === "object" ? options.inject : ({} as InjectOptions); + + const injectorName = saferId("injector"); + const injectorCall = `${injectorName}(${cssVariableName},${JSON.stringify(injectorOptions)});`; + + if (!injectorId) { + injectorId = await resolveAsync(["./inject-css"], { basedirs: [join(testing ? process.cwd() : baseDirectory, "runtime")] }); + injectorId = `"${normalizePath(injectorId)}"`; + } + + output.unshift(`import ${injectorName} from ${injectorId};`); + + if (!treeshakeable) { + output.push(`var ${modulesVariableName} = ${JSON.stringify(modulesExports)};`, injectorCall); + } + + if (treeshakeable) { + output.push("var injected = false;"); + + const injectorCallOnce = `if (!injected) { injected = true; ${injectorCall} }`; + + if (modulesExports.inject) { + throw new Error("`inject` keyword is reserved when using `inject.treeshakeable` option"); + } + + let getters = ""; + + for (const [k, v] of Object.entries(modulesExports)) { + const name = JSON.stringify(k); + const value = JSON.stringify(v); + getters += `get ${name}() { ${injectorCallOnce} return ${value}; },\n`; + } + + getters += `inject: function inject() { ${injectorCallOnce} },`; + + output.push(`var ${modulesVariableName} = {${getters}};`); + } + } + } + + if (!options.inject) { + output.push(`var ${modulesVariableName} = ${JSON.stringify(modulesExports)};`); + } + + const defaultExport = `export default ${supportModules ? modulesVariableName : cssVariableName};`; + + output.push(defaultExport); + + if (options.dts && (isAccessibleSync(this.id))) { + if (supportModules) + dts.push( + `interface ModulesExports ${JSON.stringify(modulesExports)}`, + + typeof options.inject === "object" && options.inject.treeshakeable ? `interface ModulesExports {inject:()=>void}` : "", + + `declare const ${modulesVariableName}: ModulesExports;`, + ); + + dts.push(defaultExport); + + writeFileSync(`${this.id}.d.ts`, dts.filter(Boolean).join("\n")); + } + + return { code: output.filter(Boolean).join("\n"), extracted, map }; + }, +}; + +export default loader; diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/modules/generate.ts b/packages/rollup-plugin-styles/src/loaders/postcss/modules/generate.ts new file mode 100644 index 00000000..73f893a6 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/modules/generate.ts @@ -0,0 +1,22 @@ +import path from "node:path"; + +import { makeLegalIdentifier } from "@rollup/pluginutils"; + +import hasher from "../../../utils/hasher"; +import { hashRe } from "../common"; + +export default (placeholder = "[name]_[local]__[hash:8]") => + (local: string, file: string, css: string): string => { + const { base, dir, name } = path.parse(file); + const hash = hasher(`${base}:${css}`); + const match = hashRe.exec(placeholder); + const hashLength = match && Number.parseInt(match[1]); + + return makeLegalIdentifier( + placeholder + .replace("[dir]", path.basename(dir)) + .replace("[name]", name) + .replace("[local]", local) + .replace(hashRe, hashLength ? hash.slice(0, hashLength) : hash), + ); + }; diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/modules/index.ts b/packages/rollup-plugin-styles/src/loaders/postcss/modules/index.ts new file mode 100644 index 00000000..66658f3a --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/modules/index.ts @@ -0,0 +1,50 @@ +import type { Plugin } from "postcss"; +import type Processor from "postcss/lib/processor"; +import extractImports from "postcss-modules-extract-imports"; +import localByDefault from "postcss-modules-local-by-default"; +import modulesScope from "postcss-modules-scope"; +import modulesValues from "postcss-modules-values"; + +import generateScopedNameDefault from "./generate"; + +/** Options for [CSS Modules](https://github.com/css-modules/css-modules) */ +export interface ModulesOptions { + /** Export global classes */ + exportGlobals?: boolean; + /** Fail on wrong order of composition */ + failOnWrongOrder?: boolean; + /** + * Placeholder or function for scoped name generation. + * Allowed blocks for placeholder: + * - `[dir]`: The directory name of the asset. + * - `[name]`: The file name of the asset excluding any extension. + * - `[local]`: The original value of the selector. + * - `[hash(:)]`: A hash based on the name and content of the asset (with optional length). + * @default "[name]_[local]__[hash:8]" + */ + generateScopedName?: string | ((name: string, file: string, css: string) => string); + /** + * Default mode for classes + * @default "local" + */ + mode?: "global" | "local" | "pure"; +} + +export default (options: ModulesOptions): (Plugin | Processor)[] => { + const config = { + mode: "local" as const, + ...options, + generateScopedName: + typeof options.generateScopedName === "function" ? options.generateScopedName : generateScopedNameDefault(options.generateScopedName), + }; + + return [ + modulesValues(), + localByDefault({ mode: config.mode }), + extractImports({ failOnWrongOrder: config.failOnWrongOrder }), + modulesScope({ + exportGlobals: config.exportGlobals, + generateScopedName: config.generateScopedName, + }), + ]; +}; diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/noop.ts b/packages/rollup-plugin-styles/src/loaders/postcss/noop.ts new file mode 100644 index 00000000..1194a563 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/noop.ts @@ -0,0 +1,11 @@ +import type { PluginCreator } from "postcss"; + +const name = "styles-noop"; + +const plugin: PluginCreator = () => { + return { postcssPlugin: name }; +}; + +plugin.postcss = true; + +export default plugin; diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/url/generate.ts b/packages/rollup-plugin-styles/src/loaders/postcss/url/generate.ts new file mode 100644 index 00000000..97100913 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/url/generate.ts @@ -0,0 +1,19 @@ +import { basename, parse } from "@visulima/path"; + +import hasher from "../../../utils/hasher"; +import { hashRe } from "../common"; + +export default (placeholder: string, file: string, source: Uint8Array): string => { + const { base, dir, ext, name } = parse(file); + const hash = hasher(`${base}:${Buffer.from(source).toString()}`); + const match = hashRe.exec(placeholder); + const hashLength = match && Number.parseInt(match[1]); + + return placeholder + .replace("[dir]", basename(dir)) + .replace("[name]", name) + .replace("[extname]", ext) + .replace(".[ext]", ext) + .replace("[ext]", ext.slice(1)) + .replace(hashRe, hashLength ? hash.slice(0, hashLength) : hash.slice(0, 8)); +}; diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/url/index.ts b/packages/rollup-plugin-styles/src/loaders/postcss/url/index.ts new file mode 100644 index 00000000..4f5f4c41 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/url/index.ts @@ -0,0 +1,225 @@ +import { basename, dirname, normalize } from "@visulima/path"; +import type { Declaration, PluginCreator } from "postcss"; +import type { Node, ParsedValue } from "postcss-value-parser"; +import valueParser from "postcss-value-parser"; + +import { isAbsolutePath, normalizePath } from "../../../utils/path"; +import { mm } from "../../../utils/sourcemap"; +import { dataURIRe, firstExtRe as firstExtensionRe } from "../common"; +import generateName from "./generate"; +import inlineFile from "./inline"; +import type { UrlFile, UrlResolve } from "./resolve"; +import resolveDefault from "./resolve"; +import { isDeclWithUrl, walkUrls } from "./utils"; + +const name = "styles-url"; +const placeholderHashDefault = "assets/[name]-[hash][extname]"; +const placeholderNoHashDefault = "assets/[name][extname]"; + +/** URL handler options */ +export interface UrlOptions { + /** + * Aliases for URL paths. + * Overrides the global `alias` option. + * - ex.: `{"foo":"bar"}` + */ + alias?: Record; + /** + * Directory path for outputted CSS assets, + * which is not included into resulting URL + * @default "." + */ + assetDir?: string; + /** + * Enable/disable name generation with hash for outputted CSS assets + * or provide your own placeholder with the following blocks: + * - `[extname]`: The file extension of the asset including a leading dot, e.g. `.png`. + * - `[ext]`: The file extension without a leading dot, e.g. `png`. + * - `[hash(:)]`: A hash based on the name and content of the asset (with optional length). + * - `[name]`: The file name of the asset excluding any extension. + * + * Forward slashes / can be used to place files in sub-directories. + * @default "assets/[name]-[hash][extname]" ("assets/[name][extname]" if false) + */ + hash?: boolean | string; + /** + * Inline files instead of copying + * @default true for `inject` mode, otherwise false + */ + inline?: boolean; + /** + * Public Path for URLs in CSS files + * @default "./" + */ + publicPath?: string; + /** + * Provide custom resolver for URLs + * in place of the default one + */ + resolve?: UrlResolve; +} + +// eslint-disable-next-line sonarjs/cognitive-complexity +const plugin: PluginCreator = (options = {}) => { + const inline = options.inline ?? false; + const publicPath = options.publicPath ?? "./"; + const assetDirectory = options.assetDir ?? "."; + const resolve = options.resolve ?? resolveDefault; + const alias = options.alias ?? {}; + const placeholder = (options.hash ?? true) ? (typeof options.hash === "string" ? options.hash : placeholderHashDefault) : placeholderNoHashDefault; + + return { + async Once(css, { result }) { + if (!css.source?.input.file) { + return; + } + + const { file } = css.source.input; + const map = mm(css.source.input.map.text).resolve(dirname(file)).toConsumer(); + + const urlList: { + basedirs: Set; + decl: Declaration; + node: Node; + parsed: ParsedValue; + url: string; + }[] = []; + + const imported = new Set(result.messages.filter((message) => message.type === "dependency").map((message) => message.file as string)); + + css.walkDecls((decl) => { + if (!isDeclWithUrl(decl)) { + return; + } + + const parsed = valueParser(decl.value); + walkUrls(parsed, (url, node) => { + // Resolve aliases + // eslint-disable-next-line no-loops/no-loops,no-restricted-syntax + for (const [from, to] of Object.entries(alias)) { + if (url !== from && !url.startsWith(`${from}/`)) { + // eslint-disable-next-line no-continue + continue; + } + + // eslint-disable-next-line no-param-reassign + url = normalizePath(to) + url.slice(from.length); + } + + // Empty URL + if (!node || url.length === 0) { + decl.warn(result, `Empty URL in \`${decl.toString()}\``); + return; + } + + // Skip Data URI + if (dataURIRe.test(url)) { + return; + } + + // Skip Web URLs + if (!isAbsolutePath(url)) { + try { + new URL(url); + return; + } catch { + // Is not a Web URL, continuing + } + } + + const basedirs = new Set(); + + // Use PostCSS imports + if (decl.source?.input.file && imported.has(decl.source.input.file)) { + basedirs.add(dirname(decl.source.input.file)); + } + + // Use SourceMap + if (decl.source?.start) { + const pos = decl.source.start; + const realPos = map?.originalPositionFor(pos); + const basedir = realPos?.source && dirname(realPos.source); + + if (basedir) { + basedirs.add(normalize(basedir)); + } + } + + // Use current file + basedirs.add(dirname(file)); + + urlList.push({ basedirs, decl, node, parsed, url }); + }); + }); + + const usedNames = new Map(); + + // eslint-disable-next-line no-loops/no-loops,no-restricted-syntax + for await (const { basedirs, decl, node, parsed, url } of urlList) { + let resolved: UrlFile | undefined; + // eslint-disable-next-line no-loops/no-loops,no-restricted-syntax + for await (const basedir of basedirs) { + try { + if (!resolved) { + resolved = await resolve(url, basedir); + } + } catch { + /* noop */ + } + } + + if (!resolved) { + decl.warn(result, `Unresolved URL \`${url}\` in \`${decl.toString()}\``); + // eslint-disable-next-line no-continue + continue; + } + + const { from, source, urlQuery } = resolved; + + if (!(source instanceof Uint8Array) || typeof from !== "string") { + decl.warn(result, `Incorrectly resolved URL \`${url}\` in \`${decl.toString()}\``); + // eslint-disable-next-line no-continue + continue; + } + + result.messages.push({ file: from, plugin: name, type: "dependency" }); + + if (inline) { + node.type = "string"; + node.value = inlineFile(from, source); + } else { + const unsafeTo = normalizePath(generateName(placeholder, from, source)); + let to = unsafeTo; + + // Avoid file overrides + const hasExtension = firstExtensionRe.test(unsafeTo); + + // eslint-disable-next-line no-loops/no-loops + for (let index = 1; usedNames.has(to) && usedNames.get(to) !== from; index++) { + to = hasExtension ? unsafeTo.replace(firstExtensionRe, `${index}$1`) : `${unsafeTo}${index}`; + } + + usedNames.set(to, from); + + node.type = "string"; + node.value = publicPath + (/[/\\]$/.test(publicPath) ? "" : "/") + basename(to); + + if (urlQuery) { + node.value += urlQuery; + } + + to = normalizePath(assetDirectory, to); + + result.messages.push({ plugin: name, source, to, type: "asset" }); + } + + decl.value = parsed.toString(); + } + }, + postcssPlugin: name, + }; +}; + +plugin.postcss = true; + +export default plugin; diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/url/inline.ts b/packages/rollup-plugin-styles/src/loaders/postcss/url/inline.ts new file mode 100644 index 00000000..27897bb3 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/url/inline.ts @@ -0,0 +1,8 @@ +import { lookup } from "mime-types"; + +export default (file: string, source: Uint8Array): string => { + const mime = lookup(file) || "application/octet-stream"; + const data = Buffer.from(source).toString("base64"); + + return `data:${mime};base64,${data}`; +}; diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/url/resolve.ts b/packages/rollup-plugin-styles/src/loaders/postcss/url/resolve.ts new file mode 100644 index 00000000..211594b3 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/url/resolve.ts @@ -0,0 +1,29 @@ +import { readFile } from "@visulima/fs"; +import qs from "query-string"; + +import { resolveAsync } from "../../../utils/resolve"; + +/** File resolved by URL resolver */ +export interface UrlFile { + /** Absolute path to file */ + from: string; + /** File source */ + source: Uint8Array; + /** Original query extracted from the input path */ + urlQuery?: string; +} + +/** URL resolver */ +export type UrlResolve = (inputUrl: string, basedir: string) => Promise; + +const resolve: UrlResolve = async (inputUrl, basedir) => { + const options = { basedirs: [basedir], caller: "URL resolver" }; + const parseOptions = { decode: false, parseFragmentIdentifier: true, sort: false as const }; + const { fragmentIdentifier, query, url } = qs.parseUrl(inputUrl, parseOptions); + const from = await resolveAsync([url, `./${url}`], options); + const urlQuery = qs.stringifyUrl({ fragmentIdentifier, query, url: "" }, parseOptions); + + return { from, source: await readFile(from), urlQuery }; +}; + +export default resolve; diff --git a/packages/rollup-plugin-styles/src/loaders/postcss/url/utils.ts b/packages/rollup-plugin-styles/src/loaders/postcss/url/utils.ts new file mode 100644 index 00000000..0971eb1c --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/postcss/url/utils.ts @@ -0,0 +1,46 @@ +import type { Declaration } from "postcss"; +import type { Node, ParsedValue } from "postcss-value-parser"; +import valueParser from "postcss-value-parser"; + +const urlFunctionRe = /^url$/i; +const imageSetFunctionRe = /^(?:-webkit-)?image-set$/i; + +export const isDeclWithUrl = (decl: Declaration): boolean => /(?:url|(?:-webkit-)?image-set)\(/i.test(decl.value); + +export const walkUrls = (parsed: ParsedValue, callback: (url: string, node?: Node) => void): void => { + // eslint-disable-next-line sonarjs/cognitive-complexity + parsed.walk((node) => { + if (node.type !== "function") { + return; + } + + if (urlFunctionRe.test(node.value)) { + const { nodes } = node; + const [urlNode] = nodes; + const url = urlNode?.type === "string" ? urlNode.value : valueParser.stringify(nodes); + + callback(url.replaceAll(/^\s+|\s+$/g, ""), urlNode); + + return; + } + + if (imageSetFunctionRe.test(node.value)) { + // eslint-disable-next-line no-loops/no-loops,no-restricted-syntax + for (const nNode of node.nodes) { + if (nNode.type === "string") { + callback(nNode.value.replaceAll(/^\s+|\s+$/g, ""), nNode); + // eslint-disable-next-line no-continue + continue; + } + + if (nNode.type === "function" && urlFunctionRe.test(nNode.value)) { + const { nodes } = nNode; + const [urlNode] = nodes; + const url = urlNode?.type === "string" ? urlNode.value : valueParser.stringify(nodes); + + callback(url.replaceAll(/^\s+|\s+$/g, ""), urlNode); + } + } + } + }); +}; diff --git a/packages/rollup-plugin-styles/src/loaders/sass/importer.ts b/packages/rollup-plugin-styles/src/loaders/sass/importer.ts new file mode 100644 index 00000000..f1c6f4ec --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/sass/importer.ts @@ -0,0 +1,56 @@ +import { dirname } from "@visulima/path"; + +import { packageFilterBuilder, resolveAsync, resolveSync } from "../../utils/resolve"; +import { getUrlOfPartial, isModule, normalizeUrl } from "../../utils/url"; + +const extensions = [".scss", ".sass", ".css"]; +const conditions = ["sass", "style"]; + +// eslint-disable-next-line @typescript-eslint/no-shadow +export const importer: sass.Importer = (url, importer, done): void => { + const next = (): void => done(null); + + if (!isModule(url)) { + next(); + return; + } + + const moduleUrl = normalizeUrl(url); + const partialUrl = getUrlOfPartial(moduleUrl); + const options = { + basedirs: [dirname(importer)], + caller: "Sass importer", + extensions, + packageFilter: packageFilterBuilder({ conditions }), + }; + + // Give precedence to importing a partial + resolveAsync([partialUrl, moduleUrl], options) + .then((id: string): void => done({ file: id.replace(/\.css$/i, "") })) + .catch(next); +}; + +// eslint-disable-next-line @typescript-eslint/no-shadow +export const importerSync: sass.Importer = (url, importer): sass.Data => { + if (!isModule(url)) { + return null; + } + + const moduleUrl = normalizeUrl(url); + const partialUrl = getUrlOfPartial(moduleUrl); + const options = { + basedirs: [dirname(importer)], + caller: "Sass importer", + extensions, + packageFilter: packageFilterBuilder({ conditions }), + }; + + // Give precedence to importing a partial + try { + const id = resolveSync([partialUrl, moduleUrl], options); + + return { file: id.replace(/\.css$/i, "") }; + } catch { + return null; + } +}; diff --git a/packages/rollup-plugin-styles/src/loaders/sass/index.ts b/packages/rollup-plugin-styles/src/loaders/sass/index.ts new file mode 100644 index 00000000..6c73fda0 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/sass/index.ts @@ -0,0 +1,82 @@ +import { normalizePath } from "../../utils/path"; +import type { Loader } from "../types"; +import { importer, importerSync } from "./importer"; +import loadSass from "./load"; + +/** Options for Sass loader */ +export interface SASSLoaderOptions extends Record, sass.PublicOptions { + /** Force Sass implementation */ + impl?: string; + /** Forcefully enable/disable sync mode */ + sync?: boolean; +} + +const loader: Loader = { + name: "sass", + // eslint-disable-next-line sonarjs/cognitive-complexity + async process({ code, map }) { + const options = { ...this.options }; + const [sass, type] = await loadSass(options.impl); + const sync = options.sync ?? type !== "node-sass"; + const importers = [sync ? importerSync : importer]; + + if (options.data) { + // eslint-disable-next-line no-param-reassign + code = options.data + code; + } + + if (options.importer) { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + Array.isArray(options.importer) ? importers.push(...options.importer) : importers.push(options.importer); + } + + const render = async (options: sass.Options): Promise => + // eslint-disable-next-line compat/compat + await new Promise((resolve, reject) => { + if (sync) { + resolve(sass.renderSync(options)); + } else { + sass.render(options, (error, css) => (error ? reject(error) : resolve(css))); + } + }); + + // Remove non-Sass options + delete options.impl; + delete options.sync; + + // node-sass won't produce sourcemaps if the `data` + // option is used and `sourceMap` option is not a string. + // + // In case it is a string, `sourceMap` option + // should be a path where the sourcemap is written. + // + // But since we're using the `data` option, + // the sourcemap will not actually be written, but + // all paths in sourcemap's sources will be relative to that path. + const result = await render({ + ...options, + data: code, + file: this.id, + importer: importers, + indentedSyntax: /\.sass$/i.test(this.id), + omitSourceMapUrl: true, + sourceMap: this.id, + sourceMapContents: true, + }); + + const deps = result.stats.includedFiles; + + // eslint-disable-next-line no-loops/no-loops,no-restricted-syntax + for (const dep of deps) { + this.deps.add(normalizePath(dep)); + } + + return { + code: Buffer.from(result.css).toString(), + map: result.map ? Buffer.from(result.map).toString() : map, + }; + }, + test: /\.(sass|scss)$/i, +}; + +export default loader; diff --git a/packages/rollup-plugin-styles/src/loaders/sass/load.ts b/packages/rollup-plugin-styles/src/loaders/sass/load.ts new file mode 100644 index 00000000..cc23d935 --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/sass/load.ts @@ -0,0 +1,33 @@ +import arrayFmt from "../../utils/array-fmt"; + +const ids = ["sass", "node-sass"]; +const idsFmt = arrayFmt(ids); + +export default async function (impl?: string): Promise<[sass.Sass, string]> { + // Loading provided implementation + if (impl) { + return await import(impl) + .then(({ default: provided }: { default?: sass.Sass } = {}) => { + if (provided) { + return [provided, impl] as [sass.Sass, string]; + } + + throw undefined; + }) + .catch(() => { + throw new Error(`Could not load \`${impl}\` Sass implementation`); + }); + } + + // Loading one of the supported modules + for (const id of ids) { + // eslint-disable-next-line no-await-in-loop + const sass = await import(id).then((m: { default?: sass.Sass }) => m.default); + + if (sass) { + return [sass, id]; + } + } + + throw new Error(`You need to install ${idsFmt} package in order to process Sass files`); +} diff --git a/packages/rollup-plugin-styles/src/loaders/sourcemap.ts b/packages/rollup-plugin-styles/src/loaders/sourcemap.ts new file mode 100644 index 00000000..ddd0877d --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/sourcemap.ts @@ -0,0 +1,15 @@ +import { getMap, stripMap } from "../utils/sourcemap"; +import type { Loader } from "./types"; + +const loader: Loader = { + alwaysProcess: true, + name: "sourcemap", + async process({ code, map }) { + // eslint-disable-next-line no-param-reassign + map = (await getMap(code, this.id)) ?? map; + + return { code: stripMap(code), map }; + }, +}; + +export default loader; diff --git a/packages/rollup-plugin-styles/src/loaders/stylus.ts b/packages/rollup-plugin-styles/src/loaders/stylus.ts new file mode 100644 index 00000000..dc3b5dfb --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/stylus.ts @@ -0,0 +1,68 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; + +import { readFileSync } from "@visulima/fs"; +import type { RawSourceMap } from "source-map-js"; + +import { normalizePath } from "../utils/path"; +import { mm } from "../utils/sourcemap"; +import type { Loader } from "./types"; + +/** Options for Stylus loader */ +export interface StylusLoaderOptions extends Record, stylus.PublicOptions {} + +const loader: Loader = { + name: "stylus", + async process({ code, map }) { + const options = { ...this.options }; + const stylus = (await import("stylus").then((m) => m.default)) as stylus.Stylus; + + if (!stylus) { + throw new Error("You need to install `stylus` package in order to process Stylus files"); + } + + const basePath = normalizePath(path.dirname(this.id)); + + const paths = [`${basePath}/node_modules`, basePath]; + + if (options.paths) { + paths.push(...options.paths); + } + + const style = stylus(code, options).set("filename", this.id).set("paths", paths).set("sourcemap", { basePath, comment: false }); + + const render = async (): Promise => + await new Promise((resolve, reject) => { + style.render((error, css) => (error ? reject(error) : resolve(css))); + }); + + code = await render(); + + const deps = style.deps(); + for (const dep of deps) this.deps.add(normalizePath(dep)); + + // We have to manually modify the `sourcesContent` field + // since stylus compiler doesn't support it yet + if (style.sourcemap?.sources && !style.sourcemap.sourcesContent) { + style.sourcemap.sourcesContent = await Promise.all( + style.sourcemap.sources.map(async (source) => { + const file = normalizePath(basePath, source); + const exists = existsSync(file); + + if (!exists) { + return null as unknown as string; + } + + return readFileSync(file); + }), + ); + } + + map = mm(style.sourcemap as unknown as RawSourceMap).toString() ?? map; + + return { code, map }; + }, + test: /\.(styl|stylus)$/i, +}; + +export default loader; diff --git a/packages/rollup-plugin-styles/src/loaders/types.ts b/packages/rollup-plugin-styles/src/loaders/types.ts new file mode 100644 index 00000000..72a76e5e --- /dev/null +++ b/packages/rollup-plugin-styles/src/loaders/types.ts @@ -0,0 +1,75 @@ +import type { PluginContext } from "rollup"; +import type { RawSourceMap } from "source-map-js"; + +/** + * Loader + * @param T type of loader's options + */ +export interface Loader> { + /** Skip testing, always process the file */ + alwaysProcess?: boolean; + /** Name */ + name: string; + /** Function for processing */ + process: (this: LoaderContext, payload: Payload) => Payload | Promise; + /** + * Test to control if file should be processed. + * Also used for plugin's supported files test. + */ + test?: RegExp | ((file: string) => boolean); +} + +/** + * Loader's context + * @param T type of loader's options + */ +export interface LoaderContext> { + /** Assets to emit */ + readonly assets: Map; + /** Files to watch */ + readonly deps: Set; + /** Resource path */ + readonly id: string; + /** + * Loader's options + * @default {} + */ + readonly options: T; + /** [Plugin's context](https://rollupjs.org/guide/en#plugin-context) */ + readonly plugin: PluginContext; + /** @see {@link Options.sourceMap} */ + readonly sourceMap: false | (SourceMapOptions & { inline: boolean }); + /** [Function for emitting a warning](https://rollupjs.org/guide/en/#thiswarnwarning-string--rollupwarning-position-number---column-number-line-number---void) */ + readonly warn: PluginContext["warn"]; +} + +/** Extracted data */ +export interface Extracted { + /** CSS */ + css: string; + /** Source file path */ + id: string; + /** Sourcemap */ + map?: string; +} + +/** Loader's payload */ +export interface Payload { + /** File content */ + code: string; + /** Extracted data */ + extracted?: Extracted; + /** Sourcemap */ + map?: string; +} + +/** Options for sourcemaps */ +export interface SourceMapOptions { + /** + * Include sources content + * @default true + */ + content?: boolean; + /** Function for transforming resulting sourcemap */ + transform?: (map: RawSourceMap, name?: string) => void; +} diff --git a/packages/rollup-plugin-styles/src/runtime/inject-css.js b/packages/rollup-plugin-styles/src/runtime/inject-css.js new file mode 100644 index 00000000..62e15ad8 --- /dev/null +++ b/packages/rollup-plugin-styles/src/runtime/inject-css.js @@ -0,0 +1,74 @@ +/** @type {HTMLElement[]} */ +const containers = []; +/** @type {{prepend:HTMLStyleElement,append:HTMLStyleElement}[]} */ +const styleTags = []; + +/** + * @param {string} css + * @param {object} options + * @param {boolean} [options.prepend] + * @param {boolean} [options.singleTag] + * @param {string} [options.container] + * @param {Record} [options.attributes] + * @returns {void} + */ +export default function (css, options) { + if (!css || typeof document === "undefined") { + return; + } + + const position = options.prepend === true ? "prepend" : "append"; + const singleTag = options.singleTag === true; + + const container = typeof options.container === "string" ? document.querySelector(options.container) : document.querySelectorAll("head")[0]; + + function createStyleTag() { + const styleTag = document.createElement("style"); + + styleTag.setAttribute("type", "text/css"); + + if (options.attributes) { + const k = Object.keys(options.attributes); + for (const element of k) { + styleTag.setAttribute(element, options.attributes[element]); + } + } + + if (typeof __webpack_nonce__ !== "undefined") { + styleTag.setAttribute("nonce", __webpack_nonce__); + } + + const pos = position === "prepend" ? "afterbegin" : "beforeend"; + + container.insertAdjacentElement(pos, styleTag); + + return styleTag; + } + + /** @type {HTMLStyleElement} */ + let styleTag; + + if (singleTag) { + let id = containers.indexOf(container); + + if (id === -1) { + id = containers.push(container) - 1; + styleTags[id] = {}; + } + + styleTag = styleTags[id] && styleTags[id][position] ? styleTags[id][position] : (styleTags[id][position] = createStyleTag()); + } else { + styleTag = createStyleTag(); + } + + // strip potential UTF-8 BOM if css was read from a file + if (css.charCodeAt(0) === 0xfe_ff) { + css = css.slice(1); + } + + if (styleTag.styleSheet) { + styleTag.styleSheet.cssText += css; + } else { + styleTag.append(document.createTextNode(css)); + } +} diff --git a/packages/rollup-plugin-styles/src/types.ts b/packages/rollup-plugin-styles/src/types.ts new file mode 100644 index 00000000..e40a9c9e --- /dev/null +++ b/packages/rollup-plugin-styles/src/types.ts @@ -0,0 +1,257 @@ +import type { Options as CssNanoOptions } from "cssnano"; +import type * as postcss from "postcss"; + +import type { LESSLoaderOptions } from "./loaders/less"; +import type { ImportOptions } from "./loaders/postcss/import"; +import type { ModulesOptions } from "./loaders/postcss/modules"; +import type { UrlOptions } from "./loaders/postcss/url"; +import type { SASSLoaderOptions } from "./loaders/sass"; +import type { StylusLoaderOptions } from "./loaders/stylus"; +import type { Loader, SourceMapOptions } from "./loaders/types"; + +/** Options for PostCSS config loader */ +export interface PostCSSConfigLoaderOptions { + /** + * Context object passed to PostCSS config file + * @default {} + */ + ctx?: Record; + /** Path to PostCSS config file directory */ + path?: string; +} + +/** Options for PostCSS loader */ +export interface PostCSSLoaderOptions extends Record { + /** @see {@link Options.autoModules} */ + autoModules: NonNullable; + /** @see {@link Options.config} */ + config: Exclude; + /** @see {@link Options.dts} */ + dts: NonNullable; + /** @see {@link Options.mode} */ + emit: boolean; + /** @see {@link Options.extensions} */ + extensions: NonNullable; + + /** @see {@link Options.mode} */ + extract: boolean | string; + /** @see {@link Options.import} */ + import: Exclude; + /** @see {@link Options.mode} */ + inject: InjectOptions | boolean | ((varname: string, id: string) => string); + + /** @see {@link Options.minimize} */ + minimize: Exclude; + /** @see {@link Options.modules} */ + modules: Exclude; + /** @see {@link Options.namedExports} */ + namedExports: NonNullable; + /** Options for PostCSS processor */ + postcss: { + /** @see {@link Options.parser} */ + parser?: postcss.Parser; + /** @see {@link Options.plugins} */ + plugins?: postcss.AcceptedPlugin[]; + /** @see {@link Options.stringifier} */ + stringifier?: postcss.Stringifier; + /** @see {@link Options.syntax} */ + syntax?: postcss.Syntax; + }; + /** @see {@link Options.to} */ + to: Options["to"]; + + /** @see {@link Options.url} */ + url: Exclude; +} + +/** CSS data for extraction */ +export interface ExtractedData { + /** CSS */ + css: string; + /** Sourcemap */ + map?: string; + /** Output name for CSS */ + name: string; +} + +/** Options for CSS injection */ +export interface InjectOptions { + /** + * Set attributes of injected `