diff --git a/.changeset/common-glasses-bow.md b/.changeset/common-glasses-bow.md new file mode 100644 index 0000000..b04713a --- /dev/null +++ b/.changeset/common-glasses-bow.md @@ -0,0 +1,6 @@ +--- +'css-modules-content-mapper': minor +--- + +Support preprocessor adapters (`preprocess`, `validateOptions`, `dispose`), and report invalid +`localsConvention` options in TypeScript’s style. diff --git a/.changeset/loud-emus-appear.md b/.changeset/loud-emus-appear.md new file mode 100644 index 0000000..b787b97 --- /dev/null +++ b/.changeset/loud-emus-appear.md @@ -0,0 +1,5 @@ +--- +'sass-content-mapper': minor +--- + +Add `sass-content-mapper`, a content mapper for Sass CSS modules. diff --git a/LICENSE.md b/LICENSE.md index 19263e1..32f3ecf 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ # MIT License -Copyright © 2026 Remco Haszing +Copyright © 2026 Remco Haszing, Brody McKee 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, diff --git a/README.md b/README.md index 9d995cf..ab0473c 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,12 @@ The monorepo for [TypeScript](https://www.typescriptlang.org) content mappers re ## Packages -| Package | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------- | -| [`css-modules-content-mapper`](packages/css-modules-content-mapper/README.md) | TypeScript content mapper for CSS modules | +| Package | Description | +| ----------------------------------------------------------------------------- | ---------------------------------------------- | +| [`css-modules-content-mapper`](packages/css-modules-content-mapper/README.md) | TypeScript content mapper for CSS modules | +| [`sass-content-mapper`](packages/sass-content-mapper/README.md) | TypeScript content mapper for Sass CSS modules | ## License -[MIT](LICENSE.md) © [Remco Haszing](https://github.com/remcohaszing) +[MIT](LICENSE.md) © [Remco Haszing](https://github.com/remcohaszing), +[Brody McKee](https://github.com/mrmckeb) diff --git a/eslint.config.js b/eslint.config.js index 493bcc9..8899b75 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,10 @@ import { define } from '@remcohaszing/eslint' export default define([ - { ignores: ['packages/css-modules-content-mapper/lib/protocol.ts', 'packages/*/fixtures'] } + { ignores: ['packages/css-modules-content-mapper/lib/protocol.ts', 'packages/*/fixtures'] }, + { + rules: { + 'jsdoc/check-template-names': 'off' + } + } ]) diff --git a/package-lock.json b/package-lock.json index d094c6b..367bfe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2029,6 +2029,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-builder": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", + "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", + "dev": true, + "license": "MIT/X11" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3378,6 +3385,16 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -6214,6 +6231,16 @@ "node": ">= 4" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -6235,6 +6262,87 @@ ], "license": "MIT" }, + "node_modules/sass-content-mapper": { + "resolved": "packages/sass-content-mapper", + "link": true + }, + "node_modules/sass-embedded-android-ia32": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.71.1.tgz", + "integrity": "sha512-tn3WZNdKQtr/DSzl4cQIDZkTO3JuuMxPvM/T+U7gBFyhU62NyF5wvwBnuh+BN3iaMowfkSknzCZCjyJDwnkDjw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "bin": { + "sass": "dart-sass/sass" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-ia32": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.71.1.tgz", + "integrity": "sha512-NvzSljfc/Kw9/0CSn91AsINV2nh8vxhFe2cKexPMwvAqv/etU84dJMfJejxPJ39PmMqT1KvC4G+Qt2+6Mpe7oQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "sass": "dart-sass/sass" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-ia32": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.71.1.tgz", + "integrity": "sha512-Cd5sJkt70bSlYEXUSj9mPMKZLzDL8LGcBKUIfQRrcBKjmzD2Va2eLq4Zati9Xzt54unuDp4bAUUTyvQmjLzFmA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-ia32": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.71.1.tgz", + "integrity": "sha512-ZDhL6hvekeKDkZ1wUj6wN0thrB/7wOO8HaQoagk+pKaHoa0Py7OLR/m9mQM8S13mZpUQduNsznmXV1fOss4GOg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "sass": "dart-sass/sass.bat" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -6333,7 +6441,6 @@ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6668,8 +6775,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -7125,6 +7231,13 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "dev": true, + "license": "MIT" + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -7447,6 +7560,353 @@ "funding": { "url": "https://github.com/sponsors/remcohaszing" } + }, + "packages/sass-content-mapper": { + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "css-modules-content-mapper": "^0.2.0" + }, + "devDependencies": { + "@types/node": "^26.0.0", + "content-mapper-test-utils": "^0.0.1", + "sass": "1.71.1", + "sass-embedded": "1.71.1", + "snapshot-fixtures": "^1.0.0", + "typescript": "^6.0.0", + "vscode-jsonrpc": "^10.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + }, + "peerDependencies": { + "sass": "^1.71.1", + "sass-embedded": "^1.71.1" + }, + "peerDependenciesMeta": { + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + } + } + }, + "packages/sass-content-mapper/node_modules/@bufbuild/protobuf": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", + "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "packages/sass-content-mapper/node_modules/immutable": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", + "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", + "dev": true, + "license": "MIT" + }, + "packages/sass-content-mapper/node_modules/sass": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.71.1.tgz", + "integrity": "sha512-wovtnV2PxzteLlfNzbgm1tFXPLoZILYAMJtvoXXkD7/+1uP41eKkIt1ypWq5/q2uT94qHjXehEYfmjKOvjL9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sass-content-mapper/node_modules/sass-embedded": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.71.1.tgz", + "integrity": "sha512-nOmqErO1zd1wjvTbDscLZZ3fv5JPeQfaKuo0UCjYm7qPbpQcycp0l3nFZHxovjLjCetJ9IrLOADdznFYKV0f1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^1.0.0", + "buffer-builder": "^0.2.0", + "immutable": "^4.0.0", + "rxjs": "^7.4.0", + "supports-color": "^8.1.1", + "varint": "^6.0.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "optionalDependencies": { + "sass-embedded-android-arm": "1.71.1", + "sass-embedded-android-arm64": "1.71.1", + "sass-embedded-android-ia32": "1.71.1", + "sass-embedded-android-x64": "1.71.1", + "sass-embedded-darwin-arm64": "1.71.1", + "sass-embedded-darwin-x64": "1.71.1", + "sass-embedded-linux-arm": "1.71.1", + "sass-embedded-linux-arm64": "1.71.1", + "sass-embedded-linux-ia32": "1.71.1", + "sass-embedded-linux-musl-arm": "1.71.1", + "sass-embedded-linux-musl-arm64": "1.71.1", + "sass-embedded-linux-musl-ia32": "1.71.1", + "sass-embedded-linux-musl-x64": "1.71.1", + "sass-embedded-linux-x64": "1.71.1", + "sass-embedded-win32-ia32": "1.71.1", + "sass-embedded-win32-x64": "1.71.1" + } + }, + "packages/sass-content-mapper/node_modules/sass-embedded-android-arm": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.71.1.tgz", + "integrity": "sha512-Pq6TlRg9lIYsZDo9XNQZnSg6grQKzBG3ssdv0W1SnYS1BzGKwbg8XnlUA/pVxK76BKEm8i+0DA4y8cZ8A3tmpw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "bin": { + "sass": "dart-sass/sass" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sass-content-mapper/node_modules/sass-embedded-android-arm64": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.71.1.tgz", + "integrity": "sha512-a7wJ1MM6sBwcM/8vIvvnwc9spoeNimNeXZpN9baSV4Ylthmr4GkTYYtf96Z/XYLdG5KBgYlxMs5T3OgqafdUMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "bin": { + "sass": "dart-sass/sass" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sass-content-mapper/node_modules/sass-embedded-android-x64": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.71.1.tgz", + "integrity": "sha512-l72Pqxfb/pArpOLyWsuL9s8ODWupRGATWTPwUT/GjVdSQJO/lQL5DopXb55Cwh2T7t2G10e+uXTEMKz0qngoWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "bin": { + "sass": "dart-sass/sass" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sass-content-mapper/node_modules/sass-embedded-darwin-arm64": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.71.1.tgz", + "integrity": "sha512-3eZDAcJBwoG0Kyasa/EbaKt1Jn2y0GHvCd0Oas/VtMsYL+/6abiCO1l8YltdxER4jvuHUKE2Ow7J6T6sC+vVQQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "sass": "dart-sass/sass" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sass-content-mapper/node_modules/sass-embedded-darwin-x64": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.71.1.tgz", + "integrity": "sha512-/9FtMPVdQalhsRCD9opNIlZqJKe7veCjWsdj0J9utbc2bNCTYswXNQtC/jWJTjE9/gQ0+w5zwg9+fQzltdYh1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "sass": "dart-sass/sass" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sass-content-mapper/node_modules/sass-embedded-linux-arm": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.71.1.tgz", + "integrity": "sha512-l7NEn0gji6GTN+p00DP2zZl9SE501Zy5obTA3beiD6+vQy7lCEC6vpNi/ZrlC6eRmgY2OKSBB2lfW7KSej9Hkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "sass": "dart-sass/sass" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sass-content-mapper/node_modules/sass-embedded-linux-arm64": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.71.1.tgz", + "integrity": "sha512-zUSmqeqcgTb3VjZggk9a9xB2ZGaAe/TYAi/vYRPNLY/f7dZSrsa9Ejo+LUm2aNl6V8hFzMz7BpoKsaRQJnb9GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "sass": "dart-sass/sass" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sass-content-mapper/node_modules/sass-embedded-linux-musl-arm": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.71.1.tgz", + "integrity": "sha512-1O37K5EgSeQjCBizIF9xdZJw3mh5XYHOnsB4+65CLZg4ac84ragjFv9d9rYhwGs9QSgg1MoOv7VWnEIxQ8Pp9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sass-content-mapper/node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.71.1.tgz", + "integrity": "sha512-Agtf6BcYQ0mt+jVDcRcN7bDPrMAQOWMeX15NTlQH1rO8voObLo6ThVl2NUkiZyyVmu7a6YOrCxpGBVAK1cLGOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sass-content-mapper/node_modules/sass-embedded-linux-musl-x64": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.71.1.tgz", + "integrity": "sha512-uVfYms/lf4QVSvtQXkSm+Bq3wVsvkRMI30ca82rRwpwebxSaTbUr+uLnskh8QvbyfsbMyrzZQU0SCrO3uCua1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sass-content-mapper/node_modules/sass-embedded-linux-x64": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.71.1.tgz", + "integrity": "sha512-7BXniYic16+MQx0InyH8OXburLPGMRYRWf0l/t/fRkNkUHWFl7NQPAX0yvj73c/PKOdaYEUY6isNB4OGUGtZHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "sass": "dart-sass/sass" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sass-content-mapper/node_modules/sass-embedded-win32-x64": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.71.1.tgz", + "integrity": "sha512-ecWP1TFUA9ujOuOTJfWC1iZsSZOdQy5OxIEHqoERxunyjwzkiTxfN8J7Y4bNQ5uwb4K0brxWyIM8Fq+UgDqcZA==", + "cpu": [ + "arm64", + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "sass": "dart-sass/sass.bat" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sass-content-mapper/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } } } } diff --git a/package.json b/package.json index b793983..63bff09 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test": "node --test" }, "allowScripts": { + "@parcel/watcher": false, "unrs-resolver": false }, "devDependencies": { diff --git a/packages/css-modules-content-mapper/LICENSE.md b/packages/css-modules-content-mapper/LICENSE.md index 19263e1..32f3ecf 100644 --- a/packages/css-modules-content-mapper/LICENSE.md +++ b/packages/css-modules-content-mapper/LICENSE.md @@ -1,6 +1,6 @@ # MIT License -Copyright © 2026 Remco Haszing +Copyright © 2026 Remco Haszing, Brody McKee 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, diff --git a/packages/css-modules-content-mapper/README.md b/packages/css-modules-content-mapper/README.md index 44e00d7..05b2451 100644 --- a/packages/css-modules-content-mapper/README.md +++ b/packages/css-modules-content-mapper/README.md @@ -73,8 +73,10 @@ while you are typing. ## CSS syntax This project does **not** check CSS validity. You can use a linter, such as -[Stylelint](https://stylelint.io), for that. This project also doesn’t support CSS variants such as -[Sass](https://sass-lang.com) or [Less](https://lesscss.org). +[Stylelint](https://stylelint.io), for that. This project also doesn’t support CSS variants. For +[Sass](https://sass-lang.com), use +[`sass-content-mapper`](https://github.com/remcohaszing/css-modules-content-mapper/tree/main/packages/sass-content-mapper). +Support for [Less](https://lesscss.org) is coming soon. ## Compatibility @@ -87,4 +89,5 @@ mappers, you are deliberately breaking this trust boundary. ## License -[MIT](LICENSE.md) © [Remco Haszing](https://github.com/remcohaszing) +[MIT](LICENSE.md) © [Remco Haszing](https://github.com/remcohaszing), +[Brody McKee](https://github.com/mrmckeb) diff --git a/packages/css-modules-content-mapper/fixtures/invalid-locals-convention/input.css b/packages/css-modules-content-mapper/fixtures/invalid-locals-convention/input.css new file mode 100644 index 0000000..989abe3 --- /dev/null +++ b/packages/css-modules-content-mapper/fixtures/invalid-locals-convention/input.css @@ -0,0 +1,3 @@ +.hyphenated-class { + color: #ff0000; +} diff --git a/packages/css-modules-content-mapper/fixtures/invalid-locals-convention/transform.md b/packages/css-modules-content-mapper/fixtures/invalid-locals-convention/transform.md new file mode 100644 index 0000000..84bd415 --- /dev/null +++ b/packages/css-modules-content-mapper/fixtures/invalid-locals-convention/transform.md @@ -0,0 +1,27 @@ +## Text + +````cts +declare const styles: { +/** + * ```css + * .hyphenated-class { + * color: #ff0000; + * } + * ``` + */ +readonly "hyphenated-class": string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| ------------------ | -: | -------------------- | --: | :--------: | +| `hyphenated-class` | 1 | `"hyphenated-class"` | 107 | ✓ | + +## Option diagnostics + +- `localsConvention`: `Argument for 'localsConvention' option must be: 'camelCase', 'camelCaseOnly', 'dashes', 'dashesOnly', 'all', 'none'.` diff --git a/packages/css-modules-content-mapper/fixtures/invalid-locals-convention/tsconfig.json b/packages/css-modules-content-mapper/fixtures/invalid-locals-convention/tsconfig.json new file mode 100644 index 0000000..8c3a191 --- /dev/null +++ b/packages/css-modules-content-mapper/fixtures/invalid-locals-convention/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "css-modules-content-mapper", + "extensions": [".css"], + "options": { + "localsConvention": "kebabCase" + } + } + ] +} diff --git a/packages/css-modules-content-mapper/lib/core.js b/packages/css-modules-content-mapper/lib/core.js index ca08c4e..883089e 100644 --- a/packages/css-modules-content-mapper/lib/core.js +++ b/packages/css-modules-content-mapper/lib/core.js @@ -1,9 +1,13 @@ /** + * @import { Rule } from 'postcss' + * @import { ClassName, Identifier } from 'postcss-selector-parser' * @import { * CloseProjectParams, * InitializeResult, + * MapperDiagnostic, * OpenProjectParams, * OpenProjectResult, + * OptionDiagnostic, * SpanMapFeature, * SpanMapKind, * SpanMapping, @@ -12,6 +16,9 @@ * } from './protocol.js' */ +import assert from 'node:assert/strict' +import { pathToFileURL } from 'node:url' + import { name as isIdentifierName } from 'estree-util-is-identifier-name' import camelCase from 'lodash.camelcase' import safe from 'postcss-safe-parser' @@ -36,10 +43,131 @@ const allowedLocalsConventions = /** @type {const} */ ([ */ /** + * @typedef CssContentMapperOptions + * Shared options for all CSS modules content mappers. + * @property {LocalsConvention} [localsConvention] + * How to expose class names. + */ + +/** + * @template {{}} Options + * Content mapper options after validation. + * @typedef PreprocessParams + * @property {string} configFileName + * The absolute tsconfig path, or an empty string for a project without a config file. + * @property {string} content + * The original content of the file to transform. + * @property {string} fileName + * The absolute path of the file to transform. + * @property {Options} options + * The adapter options returned by `validateOptions`. + */ + +/** + * @typedef Syntax + * The lexical rules of the original language. These are used to find classes and rules in the + * original content, starting from the positions the source map provides. + * @property {(content: string, offset: number) => number} findSelectorEnd + * Find the offset where the selector starting at the given offset ends. + * @property {(content: string, offset: number) => number} findRuleEnd + * Find the offset where the rule starting at the given offset ends. + * @property {(content: string, offset: number) => boolean} isSelector + * Check whether the given offset is part of a selector, as opposed to a comment or a statement. + * @property {(content: string, offset: number) => number} findTokenEnd + * Find the offset where the selector token starting at the given offset ends. This is used for + * tokens that generate a class, such as `&-suffix` or `#{interpolation}`. + */ + +/** + * @typedef CompiledResult + * @property {string} css + * The compiled CSS. + * @property {object} map + * A version 3 source map from the compiled CSS to the original content. + * @property {Syntax} syntax + * The lexical rules of the original content. + */ + +/** + * @typedef FailedResult + * @property {MapperDiagnostic[]} diagnostics + * Diagnostics explaining why the content could not be compiled. + */ + +/** + * @typedef {CompiledResult | FailedResult} PreprocessResult + */ + +/** + * @template {{}} Options + * Content mapper options after validation. + * @typedef ValidateResult + * @property {OptionDiagnostic[]} diagnostics + * Diagnostics for invalid option values. + * @property {Options} options + * The options to use, with invalid values removed. + */ + +/** + * @template {{}} Options + * Content mapper options after validation. * @typedef Adapter * An adapter for a CSS modules preprocessor. * @property {string} diagnosticSource * The source identifier displayed for content mapper produced diagnostics. + * @property {(params: PreprocessParams) => PreprocessResult} [preprocess] + * Compile the original content to CSS. If omitted, the content is treated as CSS. + * @property {(options: Record) => ValidateResult} [validateOptions] + * Validate adapter-specific options from the `contentMappers` entry. TypeScript still opens the + * project if options are invalid, so the returned options are used instead of the original ones. + * @property {() => void} [dispose] + * Release resources that would keep the process alive, such as a compiler process. This is + * called when the connection closes, so the mapper still exits cleanly if TypeScript stops + * without shutting it down, for example if it crashes. + */ + +/** + * @template {{}} Options + * Content mapper options after validation. + * @typedef Project + * @property {string} configFileName + * The absolute tsconfig path, or an empty string for a project without a config file. + * @property {LocalsConvention | undefined} localsConvention + * The locals convention to apply. + * @property {Options} options + * The options from the `contentMappers` entry. + * @property {Map} lastResults + * The last successfully transformed text per file name, used when compilation fails. + */ + +/** + * @typedef Location + * @property {number} offset + * The offset in the original content. + * @property {number} length + * The length of the span in the original content. + */ + +/** + * @typedef Snippet + * @property {number} start + * The start offset of the snippet in the original content. + * @property {number} end + * The end offset of the snippet in the original content. + * @property {string} text + * The snippet text. + */ + +/** + * @typedef Source + * @property {string} content + * The text that the CSS AST is parsed from. + * @property {object} [map] + * A source map from the content to the original content, if the content was compiled. + * @property {(node: Rule, selector: ClassName | Identifier) => Location | undefined} locate + * Find the location of a class or id selector in the original content. + * @property {(node: Rule) => Snippet | undefined} preview + * Get the original source text of a rule. */ /** @@ -59,6 +187,218 @@ function formatPreview(ext, snippet) { .replaceAll('\n', '\n * ')}\n * \`\`\`\n` } +/** + * Create a source for plain CSS, where the AST positions are the original positions. + * + * @param {string} content + * The CSS content. + * @returns {Source} + * The source. + */ +function createCssSource(content) { + return { + content, + locate(node, selector) { + return { + offset: node.rangeBy().start.offset + selector.sourceIndex + 1, + length: selector.value.length + } + }, + preview(node) { + const { end, start } = node.rangeBy() + const snippetStart = start.offset + 1 - start.column + return { + start: snippetStart, + end: end.offset, + text: content.slice(snippetStart, end.offset) + } + } + } +} + +/** + * Check whether a character can be part of a CSS identifier. + * + * @param {string | undefined} char + * The character to check. + * @returns {boolean} + * Whether the character can be part of a CSS identifier. + */ +function isIdentifierChar(char) { + return char != null && (/[\w-]/.test(char) || char.charCodeAt(0) >= 0x80) +} + +/** + * Find a selector token, such as `.foo`, in a range of text. + * + * @param {string} content + * The text to search. + * @param {string} token + * The token to find. + * @param {number} from + * The offset to start searching from. + * @param {number} to + * The offset to stop searching at. + * @returns {number} + * The offset of the token, or -1 if it wasn’t found. + */ +function findToken(content, token, from, to) { + // Search only the range, so a missing token doesn’t scan the rest of the content. + const range = content.slice(from, to) + let index = range.indexOf(token) + while (index !== -1) { + if (!isIdentifierChar(content[from + index + token.length])) { + return from + index + } + index = range.indexOf(token, index + 1) + } + return -1 +} + +/** + * Create a source for compiled CSS, where the AST positions are mapped back to the original + * content using a source map. + * + * Preprocessors such as Sass emit one mapping per compiled rule, pointing at the start of the + * innermost original selector. A class is located by searching for it in that selector first. If + * it isn’t there, it may come from a parent selector, so the whole file is searched. If that fails + * too, the class was generated, for example using `&-suffix` or `#{interpolation}`, and the token + * at the mapped position is used. + * + * @param {string} content + * The original content. + * @param {string} fileName + * The file name of the original content. + * @param {CompiledResult} compiled + * The compiled CSS and how to map it back to the original content. + * @returns {Source} + * The source. + */ +function createMappedSource(content, fileName, compiled) { + const { css, map, syntax } = compiled + const url = pathToFileURL(fileName).href + + /** @type {number[]} */ + const lineStarts = [0] + for (let index = content.indexOf('\n'); index !== -1; index = content.indexOf('\n', index + 1)) { + lineStarts.push(index + 1) + } + + /** + * The original offset of each compiled rule. A rule is looked up once for its preview and once + * for each class in it, so the result is cached. + * + * @type {Map} + */ + const origins = new Map() + + /** + * Get the original offset of a compiled rule. + * + * @param {Rule} node + * The compiled rule. + * @returns {number | undefined} + * The offset in the original content, or undefined if the rule comes from another file. + */ + function getOrigin(node) { + if (origins.has(node)) { + return origins.get(node) + } + + const start = node.source?.start + const position = start && node.source?.input.origin(start.line, start.column) + // PostCSS positions are 1-based. + const origin = + position && position.url === url + ? (lineStarts[position.line - 1] ?? 0) + position.column - 1 + : undefined + origins.set(node, origin) + return origin + } + + /** + * The offsets of every class and ID token in the original content, built on first use. Searching + * the whole content for each class instead is quadratic for large files. + * + * @type {Map | undefined} + */ + let tokenOffsets + + /** + * Find the first occurrence of a class or ID token in the original content that is part of a + * selector. + * + * @param {string} token + * The token to find, such as `.foo`. + * @returns {number} + * The offset of the token, or -1 if it wasn’t found. + */ + function findSelectorToken(token) { + if (!tokenOffsets) { + tokenOffsets = new Map() + for (const match of content.matchAll(/[#.][\w\u0080-￿-]+/g)) { + const offsets = tokenOffsets.get(match[0]) + if (offsets) { + offsets.push(match.index) + } else { + tokenOffsets.set(match[0], [match.index]) + } + } + } + + return tokenOffsets.get(token)?.find((index) => syntax.isSelector(content, index)) ?? -1 + } + + return { + content: css, + map, + locate(node, selector) { + const origin = getOrigin(node) + const token = (selector.type === 'id' ? '#' : '.') + selector.value + + if (origin != null) { + const inSelector = findToken( + content, + token, + origin, + syntax.findSelectorEnd(content, origin) + ) + if (inSelector !== -1) { + return { offset: inSelector + 1, length: selector.value.length } + } + } + + const anywhere = findSelectorToken(token) + if (anywhere !== -1) { + return { offset: anywhere + 1, length: selector.value.length } + } + + if (origin == null) { + return + } + + let start = origin + if ((content[start] === '.' || content[start] === '#') && content[start + 1] !== '{') { + start += 1 + } + const end = syntax.findTokenEnd(content, start) + if (end > start) { + return { offset: start, length: end - start } + } + }, + preview(node) { + const origin = getOrigin(node) + if (origin == null) { + return + } + + const start = content.lastIndexOf('\n', origin - 1) + 1 + const end = syntax.findRuleEnd(content, origin) + return { start, end, text: content.slice(start, end) } + } + } +} + /** * Convert a string from dashes to camel case. * @@ -73,20 +413,65 @@ function dashesCamelCase(string) { } /** - * Start a content mapper for CSS modules. + * Collect local names for a given class name. * - * @param {Adapter} adapter + * @param {LocalsConvention | undefined} localsConvention + * The locals convention to apply. + * @param {string} className + * The CSS class name for which to collect local names. + * @param {Set} localNames + * The local names to add to. + * @see https://github.com/madyankin/postcss-modules/blob/v9.0.1/src/localsConvention.js#L25-L53 + */ +function collectLocalNames(localsConvention, className, localNames) { + switch (localsConvention) { + case 'all': + localNames.add(className) + localNames.add(camelCase(className)) + localNames.add(dashesCamelCase(className)) + break + + case 'camelCase': + localNames.add(className) + localNames.add(camelCase(className)) + break + + case 'camelCaseOnly': + localNames.add(camelCase(className)) + break + + case 'dashes': + localNames.add(className) + localNames.add(dashesCamelCase(className)) + break + + case 'dashesOnly': + localNames.add(dashesCamelCase(className)) + break + + default: + localNames.add(className) + break + } +} + +/** + * Start a content mapper for CSS modules, optionally compiled from another language. + * + * @template {{}} Options + * Content mapper options after validation. + * @param {Adapter} adapter * The adapter that defines how the content is processed. * @returns {undefined} * Nothing. */ export function startMapper(adapter) { /** - * Map a project handle to a locals convention. + * Map a project handle to its settings. * - * @type {Map} + * @type {Map>} */ - const settings = new Map() + const projects = new Map() /** * Close a project @@ -95,7 +480,7 @@ export function startMapper(adapter) { * The params sent by TypeScript. */ function closeProject({ projectHandle }) { - settings.delete(projectHandle) + projects.delete(projectHandle) } /** @@ -111,51 +496,6 @@ export function startMapper(adapter) { } } - /** - * Collect local names for a given class name. - * - * @param {string} projectHandle - * The content mapper project handle for which to use the locals convention. - * @param {string} className - * The CSS class name for which to collect local names. - * @param {Set} localNames - * The local names to add to. - * @see https://github.com/madyankin/postcss-modules/blob/v9.0.1/src/localsConvention.js#L25-L53 - */ - function collectLocalNames(projectHandle, className, localNames) { - const localsConvention = settings.get(projectHandle) - - switch (localsConvention) { - case 'all': - localNames.add(className) - localNames.add(camelCase(className)) - localNames.add(dashesCamelCase(className)) - break - - case 'camelCase': - localNames.add(className) - localNames.add(camelCase(className)) - break - - case 'camelCaseOnly': - localNames.add(camelCase(className)) - break - - case 'dashes': - localNames.add(className) - localNames.add(dashesCamelCase(className)) - break - - case 'dashesOnly': - localNames.add(dashesCamelCase(className)) - break - - default: - localNames.add(className) - break - } - } - /** * Open a project * @@ -164,46 +504,69 @@ export function startMapper(adapter) { * @returns {OpenProjectResult} * The result that TypeScript expects. */ - function openProject({ options, projectHandle }) { - const localsConvention = /** @type {LocalsConvention} */ (options?.localsConvention) - - if (localsConvention == null || allowedLocalsConventions.includes(localsConvention)) { - settings.set(projectHandle, localsConvention) - return {} + function openProject({ configFileName, options = {}, projectHandle }) { + const { localsConvention } = /** @type {CssContentMapperOptions} */ (options) + + /** @type {OptionDiagnostic[]} */ + const optionDiagnostics = [] + + const isValidLocalsConvention = + localsConvention == null || allowedLocalsConventions.includes(localsConvention) + if (!isValidLocalsConvention) { + optionDiagnostics.push({ + path: ['localsConvention'], + messageText: `Argument for 'localsConvention' option must be: ${allowedLocalsConventions.map((value) => `'${value}'`).join(', ')}.`, + code: 1001 + }) } - return { - optionDiagnostics: [ - { - path: ['localsConvention'], - messageText: `Content mapper option 'localsConvention' must be one of ${allowedLocalsConventions.map((value) => `'${value}'`).join(', ')}.`, - code: 1001 - } - ] + const validated = adapter.validateOptions?.(options) + if (validated) { + optionDiagnostics.push(...validated.diagnostics) } + + // TypeScript still opens the project if options are invalid. Storing nothing would discard all + // settings, and storing the original options would pass invalid values to the preprocessor, so + // the project is stored with only the valid values. + projects.set(projectHandle, { + configFileName: configFileName ?? '', + localsConvention: isValidLocalsConvention ? localsConvention : undefined, + // Without `validateOptions`, the adapter takes no options. + options: validated ? validated.options : /** @type {Options} */ ({}), + lastResults: new Map() + }) + + return optionDiagnostics.length ? { optionDiagnostics } : {} } /** - * Transform CSS content into a CSS modules TypeScript export. + * Generate a CSS modules TypeScript export from a CSS source. * - * @param {TransformParams} params - * The transform request parameters as given by TypeScript. - * @returns {TransformResult} - * The transform response expected by TypeScript. + * @param {Source} source + * The source to generate the export from. + * @param {string} ext + * The file extension, used as the language of previews. + * @param {string} fileName + * The file name of the CSS. + * @param {Project} project + * The project the CSS belongs to. + * @returns {{ text: string, mappings: SpanMapping[] }} + * The generated text and its mappings to the original content. */ - function transform({ content, fileName, projectHandle }) { + function generate(source, ext, fileName, project) { let text = 'declare const styles: {' let comment = '' - const ext = fileName.slice(fileName.lastIndexOf('.') + 1) - - /** @type {Map, originalOffset?: number }>} */ + /** @type {Map, original: Location | undefined, snippets: Snippet[] }>} */ const data = new Map() /** @type {SpanMapping[]} */ const mappings = [] - const root = safe(content, { from: fileName }) + const root = safe( + source.content, + source.map ? { from: fileName, map: { prev: source.map } } : { from: fileName } + ) root.walk((node) => { switch (node.type) { @@ -213,11 +576,8 @@ export function startMapper(adapter) { } break case 'rule': { - const { end, start } = node.rangeBy() - const preview = formatPreview( - ext, - content.slice(start.offset + 1 - start.column, end.offset) - ) + const snippet = source.preview(node) + const preview = snippet == null ? '' : formatPreview(ext, snippet.text) const selectorParser = parser((selectors) => { let isGlobal = false @@ -251,15 +611,27 @@ export function startMapper(adapter) { if (!d) { d = { doc: '', - localNames: new Set() + localNames: new Set(), + original: undefined, + snippets: [] } data.set(selector.value, d) } - d.doc += comment - d.doc += preview - d.originalOffset ??= start.offset + selector.sourceIndex + 1 - collectLocalNames(projectHandle, selector.value, d.localNames) + // Compiled nested rules repeat their parent selectors. If this rule is part of a rule + // that already documents this class, its comment and preview would be duplicates. + if ( + !snippet || + !d.snippets.some(({ end, start }) => start <= snippet.start && snippet.end <= end) + ) { + d.doc += comment + d.doc += preview + if (snippet) { + d.snippets.push(snippet) + } + } + d.original ??= source.locate(node, selector) + collectLocalNames(project.localsConvention, selector.value, d.localNames) }) }) @@ -273,18 +645,18 @@ export function startMapper(adapter) { } }) - for (const [className, { doc, localNames, originalOffset }] of data) { + for (const [, { doc, localNames, original }] of data) { for (const localName of localNames) { text += '\n/**\n' text += doc text += ' */\nreadonly ' const quoted = isIdentifierName(localName) ? localName : JSON.stringify(localName) - if (originalOffset != null) { + if (original != null) { mappings.push([ text.length, quoted.length, - originalOffset, - className.length, + original.offset, + original.length, /** @satisfies {SpanMapKind.Alias} */ (2), /** @satisfies {SpanMapFeature.Definition} */ (8) ]) @@ -296,6 +668,75 @@ export function startMapper(adapter) { text += '}\n\nexport = styles\n' + return { text, mappings } + } + + /** + * Transform content into a CSS modules TypeScript export. + * + * @param {TransformParams} params + * The transform request parameters as given by TypeScript. + * @returns {TransformResult} + * The transform response expected by TypeScript. + */ + function transform({ content, fileName, projectHandle }) { + const project = projects.get(projectHandle) + assert.ok(project) + + const ext = fileName.slice(fileName.lastIndexOf('.') + 1) + + if (!adapter.preprocess) { + return { + extension: '.cts', + ...generate(createCssSource(content), ext, fileName, project) + } + } + + /** @type {PreprocessResult} */ + let result + try { + result = adapter.preprocess({ + configFileName: project.configFileName, + content, + fileName, + options: project.options + }) + } catch (error) { + // An adapter should report compile errors as diagnostics, but anything it throws is treated + // the same way, so a bug in a preprocessor doesn’t fail the TypeScript request. + const reason = error instanceof Error ? error.message : String(error) + result = { + diagnostics: [ + { + messageText: `Cannot compile this file: ${reason.replace(/\.$/, '')}.`, + start: 0, + length: 0, + code: 2000 + } + ] + } + } + + if ('diagnostics' in result) { + // Keep the last known exports while the file can’t be compiled, for example while typing. + // The mappings are dropped, because they point at offsets in outdated content. + return { + extension: '.cts', + text: + project.lastResults.get(fileName) ?? + generate(createCssSource(''), ext, fileName, project).text, + mappings: [], + diagnostics: result.diagnostics + } + } + + const { mappings, text } = generate( + createMappedSource(content, fileName, result), + ext, + fileName, + project + ) + project.lastResults.set(fileName, text) return { extension: '.cts', text, mappings } } @@ -309,5 +750,9 @@ export function startMapper(adapter) { connection.onRequest('openProject', openProject) connection.onRequest('transform', transform) + connection.onClose(() => { + adapter.dispose?.() + }) + connection.listen() } diff --git a/packages/css-modules-content-mapper/package.json b/packages/css-modules-content-mapper/package.json index 5c76d61..aa53b30 100644 --- a/packages/css-modules-content-mapper/package.json +++ b/packages/css-modules-content-mapper/package.json @@ -18,6 +18,9 @@ "funding": "https://github.com/sponsors/remcohaszing", "license": "MIT", "author": "Remco Haszing ", + "contributors": [ + "Brody McKee (https://github.com/mrmckeb)" + ], "type": "module", "files": [ "lib", @@ -38,7 +41,8 @@ } }, "scripts": { - "test": "node --test" + "test": "node --test", + "prepack": "tsc --build" }, "dependencies": { "estree-util-is-identifier-name": "^3.0.0", diff --git a/packages/css-modules-content-mapper/test/core.ts b/packages/css-modules-content-mapper/test/core.ts new file mode 100644 index 0000000..8db08d7 --- /dev/null +++ b/packages/css-modules-content-mapper/test/core.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { createContentMapper } from 'content-mapper-test-utils' + +const script = ` +import { startMapper } from './lib/core.js' + +startMapper({ + diagnosticSource: 'Test', + preprocess() { + throw new Error('Preprocessor exploded') + } +}) +` + +test('reports an error thrown by the preprocessor as a diagnostic', async () => { + using contentMapper = await createContentMapper(new URL('../package.json', import.meta.url), { + exec: ['node', '--input-type=module', '--eval', script] + }) + + assert.deepEqual(contentMapper.initializeResult, { + positionEncoding: 'utf-16', + diagnosticSource: 'Test' + }) + + await using project = await contentMapper.openProject( + new URL('../fixtures/class/', import.meta.url) + ) + + const result = await project.transform('/test.css', '.a {}') + assert.deepEqual(result, { + extension: '.cts', + text: 'declare const styles: {}\n\nexport = styles\n', + mappings: [], + diagnostics: [ + { + messageText: 'Cannot compile this file: Preprocessor exploded.', + start: 0, + length: 0, + code: 2000 + } + ] + }) +}) diff --git a/packages/css-modules-content-mapper/test/test.ts b/packages/css-modules-content-mapper/test/test.ts index 491d9ee..4ea50c4 100644 --- a/packages/css-modules-content-mapper/test/test.ts +++ b/packages/css-modules-content-mapper/test/test.ts @@ -1,3 +1,5 @@ +import type { OpenProjectResult } from 'content-mapper-test-utils' + import assert from 'node:assert/strict' import { dirname } from 'node:path' import { after, test } from 'node:test' @@ -5,6 +7,30 @@ import { after, test } from 'node:test' import { createContentMapper, transformResultToMarkdown } from 'content-mapper-test-utils' import { testFixturesDirectory } from 'snapshot-fixtures' +/** + * Render option diagnostics as markdown, so invalid options can be tested with snapshots. + * + * @param openProjectResult + * The result of opening the project. + * @returns + * The markdown, or an empty string if the options are valid. + */ +function optionDiagnosticsToMarkdown(openProjectResult: OpenProjectResult): string { + if (!openProjectResult.optionDiagnostics?.length) { + return '' + } + + const items = openProjectResult.optionDiagnostics.map(({ messageText, path }) => { + const location = path + .map((segment) => (typeof segment === 'number' ? `[${segment}]` : `.${segment}`)) + .join('') + .slice(1) + return `- \`${location || 'options'}\`: \`${messageText}\`` + }) + + return `\n## Option diagnostics\n\n${items.join('\n')}\n` +} + const contentMapper = await createContentMapper(new URL('../package.json', import.meta.url)) after(contentMapper.dispose) @@ -24,11 +50,12 @@ testFixturesDirectory({ const original = String(file) await using project = await contentMapper.openProject(dirname(file.path)) - assert.deepEqual(project.openProjectResult, {}) - const transform = await project.transform(file.path, original) - return transformResultToMarkdown(original, transform) + return ( + transformResultToMarkdown(original, transform) + + optionDiagnosticsToMarkdown(project.openProjectResult) + ) } } }) diff --git a/packages/sass-content-mapper/LICENSE.md b/packages/sass-content-mapper/LICENSE.md new file mode 100644 index 0000000..32f3ecf --- /dev/null +++ b/packages/sass-content-mapper/LICENSE.md @@ -0,0 +1,18 @@ +# MIT License + +Copyright © 2026 Remco Haszing, Brody McKee + +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/sass-content-mapper/README.md b/packages/sass-content-mapper/README.md new file mode 100644 index 0000000..34face5 --- /dev/null +++ b/packages/sass-content-mapper/README.md @@ -0,0 +1,120 @@ +# sass-content-mapper + +[![github actions](https://github.com/remcohaszing/css-modules-content-mapper/actions/workflows/ci.yaml/badge.svg)](https://github.com/remcohaszing/css-modules-content-mapper/actions/workflows/ci.yaml) +[![npm version](https://img.shields.io/npm/v/sass-content-mapper)](https://www.npmjs.com/package/sass-content-mapper) +[![npm downloads](https://img.shields.io/npm/dm/sass-content-mapper)](https://www.npmjs.com/package/sass-content-mapper) + +A [TypeScript](https://www.typescriptlang.org) content mapper for [Sass](https://sass-lang.com) +[CSS modules](https://github.com/css-modules/css-modules). + +## Table of Contents + +- [Installation](#installation) +- [Usage](#usage) +- [Editor support](#editor-support) +- [Options](#options) +- [Sass syntax](#sass-syntax) +- [Compatibility](#compatibility) +- [Security](#security) +- [License](#license) + +## Installation + +```sh +npm install sass-content-mapper sass-embedded +``` + +This content mapper uses the Sass implementation your project already has, so your styles are +compiled with the same version of Sass your build uses, and the types match your build output. It +works with either [`sass-embedded`](https://www.npmjs.com/package/sass-embedded) or +[`sass`](https://www.npmjs.com/package/sass). + +If both are installed, `sass-embedded` is used, because it’s significantly faster. In large projects +this makes a noticeable difference to how quickly types update in your editor. Build tools such as +Vite and sass-loader make the same choice. + +## Usage + +In your `tsconfig.json`, add `sass-content-mapper` to the `contentMappers` array. + +```jsonc +{ + "compilerOptions": { + // … + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".module.scss", ".module.sass"], + "options": { + // Optional options… + } + } + ] +} +``` + +Now run `tsc` with the `--runExternalCode` flag. + +```sh +tsc --runExternalCode +``` + +If your code uses undefined CSS module classes, this will show an error. + +## Editor support + +In addition to type checking, this content mapper also provides useful editor integrations. Each +file is compiled with Sass, and the source map is used to map classes back to your Sass code. + +- _“Go to Definition”_ in a TypeScript file will take you to the matching Sass class or ID. For a + generated class, such as `.block--modifier` from `&--modifier`, it takes you to the selector that + generated it. +- Hovering an imported class in TypeScript will show the Sass definitions. +- Like TypeScript values, Sass classes and IDs can be documented with JSDoc comments (`/** */`). + +Classes from other files, for example from a mixin in a partial, are typed, but _“Go to Definition”_ +doesn’t work for them. + +In your editor, changes to a file you `@use` or `@import`, such as a partial, don’t update the types +of the files that import it straight away. They’re updated when the importing file changes, or when +you restart the TypeScript server. This doesn’t affect `tsc`, which always compiles every file from +scratch. + +If a file can’t be compiled, for example while you are typing, the types from the last successful +compile are kept. + +## Options + +- `localsConvention` (`'camelCase' | 'camelCaseOnly' | 'dashes' | 'dashesOnly' | 'all' | 'none'`): + Style of exported classnames, the keys in your json. For more information, see + [`postcss-modules`](https://github.com/madyankin/postcss-modules#localsconvention). +- `loadPaths` (`string[]`): Additional + [load paths](https://sass-lang.com/documentation/js-api/interfaces/options/#loadPaths), relative + to your `tsconfig.json`. The `node_modules` folder next to your `tsconfig.json` is always a load + path, and [`pkg:` URLs](https://sass-lang.com/documentation/at-rules/use/#pkg-urls) are supported. + +## Sass syntax + +This project uses Sass itself, so it supports everything your version of Sass supports, including +the indented syntax. Like your build, Sass doesn’t output rules without declarations, so their +classes aren’t exported. + +`@import` works, but it’s +[deprecated in Sass](https://sass-lang.com/documentation/breaking-changes/import/). Use `@use` +instead. + +## Compatibility + +This project works with TypeScript 7.1 or greater and `sass-embedded` or `sass` 1.71.1 or greater, +and requires Node.js 22 or greater. + +## Security + +Normally, `tsc` is a self-contained binary that doesn’t execute third party code. By using content +mappers, you are deliberately breaking this trust boundary. + +## License + +[MIT](LICENSE.md) © [Remco Haszing](https://github.com/remcohaszing), +[Brody McKee](https://github.com/mrmckeb) diff --git a/packages/sass-content-mapper/fixtures/empty-rules/input.scss b/packages/sass-content-mapper/fixtures/empty-rules/input.scss new file mode 100644 index 0000000..9f45239 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/empty-rules/input.scss @@ -0,0 +1,9 @@ +// Sass doesn’t output rules without declarations, so `a` and `b` aren’t exported. +.a { +} + +.b { + &--c { + color: #ff0000; + } +} diff --git a/packages/sass-content-mapper/fixtures/empty-rules/transform.md b/packages/sass-content-mapper/fixtures/empty-rules/transform.md new file mode 100644 index 0000000..33debe4 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/empty-rules/transform.md @@ -0,0 +1,23 @@ +## Text + +````cts +declare const styles: { +/** + * ```scss + * &--c { + * color: #ff0000; + * } + * ``` + */ +readonly "b--c": string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| -------- | -: | -------- | --: | :--------: | +| `&--c` | 98 | `"b--c"` | 101 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/empty-rules/tsconfig.json b/packages/sass-content-mapper/fixtures/empty-rules/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/empty-rules/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/global/input.scss b/packages/sass-content-mapper/fixtures/global/input.scss new file mode 100644 index 0000000..7da64a4 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/global/input.scss @@ -0,0 +1,17 @@ +:global(.a) { + .b { + color: #ff0000; + } +} + +:global .c { + .d { + color: #ff0000; + } +} + +:local(.e) { + .f { + color: #ff0000; + } +} diff --git a/packages/sass-content-mapper/fixtures/global/transform.md b/packages/sass-content-mapper/fixtures/global/transform.md new file mode 100644 index 0000000..81f2b9d --- /dev/null +++ b/packages/sass-content-mapper/fixtures/global/transform.md @@ -0,0 +1,43 @@ +## Text + +````cts +declare const styles: { +/** + * ```scss + * .b { + * color: #ff0000; + * } + * ``` + */ +readonly b: string + +/** + * ```scss + * .f { + * color: #ff0000; + * } + * ``` + */ +readonly e: string + +/** + * ```scss + * .f { + * color: #ff0000; + * } + * ``` + */ +readonly f: string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| -------- | --: | ------- | --: | :--------: | +| `b` | 17 | `b` | 99 | ✓ | +| `e` | 103 | `e` | 185 | ✓ | +| `f` | 111 | `f` | 271 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/global/tsconfig.json b/packages/sass-content-mapper/fixtures/global/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/global/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/import/_mixins.scss b/packages/sass-content-mapper/fixtures/import/_mixins.scss new file mode 100644 index 0000000..1383a47 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/import/_mixins.scss @@ -0,0 +1,3 @@ +@mixin red { + color: #ff0000; +} diff --git a/packages/sass-content-mapper/fixtures/import/folder/_index.scss b/packages/sass-content-mapper/fixtures/import/folder/_index.scss new file mode 100644 index 0000000..38bf189 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/import/folder/_index.scss @@ -0,0 +1,3 @@ +.index { + color: green; +} diff --git a/packages/sass-content-mapper/fixtures/import/input.scss b/packages/sass-content-mapper/fixtures/import/input.scss new file mode 100644 index 0000000..32d47e5 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/import/input.scss @@ -0,0 +1,7 @@ +// `@import` is deprecated, but common in projects migrating from typescript-plugin-css-modules. +@import 'mixins'; +@import 'folder'; + +.local { + @include red; +} diff --git a/packages/sass-content-mapper/fixtures/import/transform.md b/packages/sass-content-mapper/fixtures/import/transform.md new file mode 100644 index 0000000..b01e6fd --- /dev/null +++ b/packages/sass-content-mapper/fixtures/import/transform.md @@ -0,0 +1,27 @@ +## Text + +````cts +declare const styles: { +/** + */ +readonly index: string + +/** + * ```scss + * .local { + * @include red; + * } + * ``` + */ +readonly local: string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| -------- | --: | ------- | --: | :--------: | +| `local` | 135 | `local` | 127 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/import/tsconfig.json b/packages/sass-content-mapper/fixtures/import/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/import/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/indented-declaration/input.sass b/packages/sass-content-mapper/fixtures/indented-declaration/input.sass new file mode 100644 index 0000000..1facd47 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/indented-declaration/input.sass @@ -0,0 +1,6 @@ +.icon + background: url(icons.parent) + +.parent + .child + color: red diff --git a/packages/sass-content-mapper/fixtures/indented-declaration/transform.md b/packages/sass-content-mapper/fixtures/indented-declaration/transform.md new file mode 100644 index 0000000..0dda648 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/indented-declaration/transform.md @@ -0,0 +1,40 @@ +## Text + +````cts +declare const styles: { +/** + * ```sass + * .icon + * background: url(icons.parent) + * ``` + */ +readonly icon: string + +/** + * ```sass + * .child + * color: red + * ``` + */ +readonly parent: string + +/** + * ```sass + * .child + * color: red + * ``` + */ +readonly child: string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| -------- | -: | -------- | --: | :--------: | +| `icon` | 1 | `icon` | 103 | ✓ | +| `parent` | 40 | `parent` | 182 | ✓ | +| `child` | 50 | `child` | 263 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/indented-declaration/tsconfig.json b/packages/sass-content-mapper/fixtures/indented-declaration/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/indented-declaration/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/indented/input.sass b/packages/sass-content-mapper/fixtures/indented/input.sass new file mode 100644 index 0000000..da57aff --- /dev/null +++ b/packages/sass-content-mapper/fixtures/indented/input.sass @@ -0,0 +1,8 @@ +.a + color: #ff0000 + + .b + color: green + + &--c + color: blue diff --git a/packages/sass-content-mapper/fixtures/indented/transform.md b/packages/sass-content-mapper/fixtures/indented/transform.md new file mode 100644 index 0000000..b6a5ac7 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/indented/transform.md @@ -0,0 +1,46 @@ +## Text + +````cts +declare const styles: { +/** + * ```sass + * .a + * color: #ff0000 + * + * .b + * color: green + * + * &--c + * color: blue + * ``` + */ +readonly a: string + +/** + * ```sass + * .b + * color: green + * ``` + */ +readonly b: string + +/** + * ```sass + * &--c + * color: blue + * ``` + */ +readonly "a--c": string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| -------- | -: | -------- | --: | :--------: | +| `a` | 1 | `a` | 150 | ✓ | +| `b` | 24 | `b` | 224 | ✓ | +| `&--c` | 46 | `"a--c"` | 299 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/indented/tsconfig.json b/packages/sass-content-mapper/fixtures/indented/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/indented/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/inline-comment/input.scss b/packages/sass-content-mapper/fixtures/inline-comment/input.scss new file mode 100644 index 0000000..ecb438d --- /dev/null +++ b/packages/sass-content-mapper/fixtures/inline-comment/input.scss @@ -0,0 +1,6 @@ +// inline comments are valid Sass +// .commented-out {} + +.red { + color: #ff0000; +} diff --git a/packages/sass-content-mapper/fixtures/inline-comment/transform.md b/packages/sass-content-mapper/fixtures/inline-comment/transform.md new file mode 100644 index 0000000..224e864 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/inline-comment/transform.md @@ -0,0 +1,23 @@ +## Text + +````cts +declare const styles: { +/** + * ```scss + * .red { + * color: #ff0000; + * } + * ``` + */ +readonly red: string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| -------- | -: | ------- | -: | :--------: | +| `red` | 57 | `red` | 95 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/inline-comment/tsconfig.json b/packages/sass-content-mapper/fixtures/inline-comment/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/inline-comment/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/interpolation/input.scss b/packages/sass-content-mapper/fixtures/interpolation/input.scss new file mode 100644 index 0000000..cea5979 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/interpolation/input.scss @@ -0,0 +1,5 @@ +@each $color in red, green { + .text-#{$color} { + color: $color; + } +} diff --git a/packages/sass-content-mapper/fixtures/interpolation/transform.md b/packages/sass-content-mapper/fixtures/interpolation/transform.md new file mode 100644 index 0000000..6de10ad --- /dev/null +++ b/packages/sass-content-mapper/fixtures/interpolation/transform.md @@ -0,0 +1,33 @@ +## Text + +````cts +declare const styles: { +/** + * ```scss + * .text-#{$color} { + * color: $color; + * } + * ``` + */ +readonly "text-red": string + +/** + * ```scss + * .text-#{$color} { + * color: $color; + * } + * ``` + */ +readonly "text-green": string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| ---------------- | -: | -------------- | --: | :--------: | +| `text-#{$color}` | 32 | `"text-red"` | 111 | ✓ | +| `text-#{$color}` | 32 | `"text-green"` | 218 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/interpolation/tsconfig.json b/packages/sass-content-mapper/fixtures/interpolation/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/interpolation/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/invalid-load-paths-entry/input.scss b/packages/sass-content-mapper/fixtures/invalid-load-paths-entry/input.scss new file mode 100644 index 0000000..b9db854 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/invalid-load-paths-entry/input.scss @@ -0,0 +1,6 @@ +// The invalid entry is ignored, but `library` is still resolved from the `vendor` load path. +@use 'library'; + +.local { + color: #ff0000; +} diff --git a/packages/sass-content-mapper/fixtures/invalid-load-paths-entry/transform.md b/packages/sass-content-mapper/fixtures/invalid-load-paths-entry/transform.md new file mode 100644 index 0000000..5f83f82 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/invalid-load-paths-entry/transform.md @@ -0,0 +1,31 @@ +## Text + +````cts +declare const styles: { +/** + */ +readonly library: string + +/** + * ```scss + * .local { + * color: #ff0000; + * } + * ``` + */ +readonly local: string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| -------- | --: | ------- | --: | :--------: | +| `local` | 112 | `local` | 131 | ✓ | + +## Option diagnostics + +- `loadPaths[1]`: `Content mapper option 'loadPaths' requires a value of type string.` diff --git a/packages/sass-content-mapper/fixtures/invalid-load-paths-entry/tsconfig.json b/packages/sass-content-mapper/fixtures/invalid-load-paths-entry/tsconfig.json new file mode 100644 index 0000000..01e36e7 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/invalid-load-paths-entry/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"], + "options": { + "loadPaths": ["vendor", 1] + } + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/invalid-load-paths-entry/vendor/_library.scss b/packages/sass-content-mapper/fixtures/invalid-load-paths-entry/vendor/_library.scss new file mode 100644 index 0000000..b578944 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/invalid-load-paths-entry/vendor/_library.scss @@ -0,0 +1,3 @@ +.library { + color: green; +} diff --git a/packages/sass-content-mapper/fixtures/invalid-load-paths/input.scss b/packages/sass-content-mapper/fixtures/invalid-load-paths/input.scss new file mode 100644 index 0000000..e0b0fa7 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/invalid-load-paths/input.scss @@ -0,0 +1,4 @@ +// `loadPaths` must be an array, so the option is ignored. +.local { + color: #ff0000; +} diff --git a/packages/sass-content-mapper/fixtures/invalid-load-paths/transform.md b/packages/sass-content-mapper/fixtures/invalid-load-paths/transform.md new file mode 100644 index 0000000..caeffe6 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/invalid-load-paths/transform.md @@ -0,0 +1,27 @@ +## Text + +````cts +declare const styles: { +/** + * ```scss + * .local { + * color: #ff0000; + * } + * ``` + */ +readonly local: string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| -------- | -: | ------- | -: | :--------: | +| `local` | 60 | `local` | 97 | ✓ | + +## Option diagnostics + +- `loadPaths`: `Content mapper option 'loadPaths' requires a value of type Array.` diff --git a/packages/sass-content-mapper/fixtures/invalid-load-paths/tsconfig.json b/packages/sass-content-mapper/fixtures/invalid-load-paths/tsconfig.json new file mode 100644 index 0000000..3c49f84 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/invalid-load-paths/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"], + "options": { + "loadPaths": "vendor" + } + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/jsdoc/input.scss b/packages/sass-content-mapper/fixtures/jsdoc/input.scss new file mode 100644 index 0000000..472da37 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/jsdoc/input.scss @@ -0,0 +1,11 @@ +/** + * Comment for `a`. + */ +.a { + color: #ff0000; + + /** `b` comment */ + .b { + color: green; + } +} diff --git a/packages/sass-content-mapper/fixtures/jsdoc/transform.md b/packages/sass-content-mapper/fixtures/jsdoc/transform.md new file mode 100644 index 0000000..b8a6501 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/jsdoc/transform.md @@ -0,0 +1,39 @@ +## Text + +````cts +declare const styles: { +/** + * + * Comment for `a`. + * ```scss + * .a { + * color: #ff0000; + * .b { + * color: green; + * } + * } + * ``` + */ +readonly a: string + +/** + * `b` comment + * ```scss + * .b { + * color: green; + * } + * ``` + */ +readonly b: string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| -------- | -: | ------- | --: | :--------: | +| `a` | 29 | `a` | 154 | ✓ | +| `b` | 76 | `b` | 253 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/jsdoc/tsconfig.json b/packages/sass-content-mapper/fixtures/jsdoc/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/jsdoc/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/load-paths/input.scss b/packages/sass-content-mapper/fixtures/load-paths/input.scss new file mode 100644 index 0000000..96220da --- /dev/null +++ b/packages/sass-content-mapper/fixtures/load-paths/input.scss @@ -0,0 +1,6 @@ +// `library` is resolved from the `vendor` load path. +@use 'library'; + +.local { + color: #ff0000; +} diff --git a/packages/sass-content-mapper/fixtures/load-paths/transform.md b/packages/sass-content-mapper/fixtures/load-paths/transform.md new file mode 100644 index 0000000..838981f --- /dev/null +++ b/packages/sass-content-mapper/fixtures/load-paths/transform.md @@ -0,0 +1,27 @@ +## Text + +````cts +declare const styles: { +/** + */ +readonly library: string + +/** + * ```scss + * .local { + * color: #ff0000; + * } + * ``` + */ +readonly local: string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| -------- | -: | ------- | --: | :--------: | +| `local` | 72 | `local` | 131 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/load-paths/tsconfig.json b/packages/sass-content-mapper/fixtures/load-paths/tsconfig.json new file mode 100644 index 0000000..d375316 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/load-paths/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"], + "options": { + "loadPaths": ["vendor"] + } + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/load-paths/vendor/_library.scss b/packages/sass-content-mapper/fixtures/load-paths/vendor/_library.scss new file mode 100644 index 0000000..b578944 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/load-paths/vendor/_library.scss @@ -0,0 +1,3 @@ +.library { + color: green; +} diff --git a/packages/sass-content-mapper/fixtures/locals-convention/input.scss b/packages/sass-content-mapper/fixtures/locals-convention/input.scss new file mode 100644 index 0000000..e397734 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/locals-convention/input.scss @@ -0,0 +1,5 @@ +.hyphenated-class { + &--modifier { + color: #ff0000; + } +} diff --git a/packages/sass-content-mapper/fixtures/locals-convention/transform.md b/packages/sass-content-mapper/fixtures/locals-convention/transform.md new file mode 100644 index 0000000..c285749 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/locals-convention/transform.md @@ -0,0 +1,33 @@ +## Text + +````cts +declare const styles: { +/** + * ```scss + * &--modifier { + * color: #ff0000; + * } + * ``` + */ +readonly "hyphenated-class--modifier": string + +/** + * ```scss + * &--modifier { + * color: #ff0000; + * } + * ``` + */ +readonly hyphenatedClassModifier: string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| ------------- | -: | ------------------------------ | --: | :--------: | +| `&--modifier` | 22 | `"hyphenated-class--modifier"` | 108 | ✓ | +| `&--modifier` | 22 | `hyphenatedClassModifier` | 230 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/locals-convention/tsconfig.json b/packages/sass-content-mapper/fixtures/locals-convention/tsconfig.json new file mode 100644 index 0000000..a906579 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/locals-convention/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"], + "options": { + "localsConvention": "camelCase" + } + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/mixin/_mixins.scss b/packages/sass-content-mapper/fixtures/mixin/_mixins.scss new file mode 100644 index 0000000..0df8529 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/mixin/_mixins.scss @@ -0,0 +1,9 @@ +@mixin red { + color: #ff0000; +} + +@mixin child { + .child { + color: green; + } +} diff --git a/packages/sass-content-mapper/fixtures/mixin/input.scss b/packages/sass-content-mapper/fixtures/mixin/input.scss new file mode 100644 index 0000000..b5459b7 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/mixin/input.scss @@ -0,0 +1,10 @@ +@use 'mixins'; + +.local { + @include mixins.red; +} + +// `child` is defined in another file, so it has no definition. +.parent { + @include mixins.child; +} diff --git a/packages/sass-content-mapper/fixtures/mixin/transform.md b/packages/sass-content-mapper/fixtures/mixin/transform.md new file mode 100644 index 0000000..1704244 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/mixin/transform.md @@ -0,0 +1,32 @@ +## Text + +````cts +declare const styles: { +/** + * ```scss + * .local { + * @include mixins.red; + * } + * ``` + */ +readonly local: string + +/** + */ +readonly parent: string + +/** + */ +readonly child: string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| -------- | --: | -------- | --: | :--------: | +| `local` | 17 | `local` | 102 | ✓ | +| `parent` | 116 | `parent` | 134 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/mixin/tsconfig.json b/packages/sass-content-mapper/fixtures/mixin/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/mixin/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/nesting/input.scss b/packages/sass-content-mapper/fixtures/nesting/input.scss new file mode 100644 index 0000000..6343059 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/nesting/input.scss @@ -0,0 +1,11 @@ +.a { + color: #ff0000; + + .b { + color: green; + } + + @media print { + color: black; + } +} diff --git a/packages/sass-content-mapper/fixtures/nesting/transform.md b/packages/sass-content-mapper/fixtures/nesting/transform.md new file mode 100644 index 0000000..c270756 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/nesting/transform.md @@ -0,0 +1,41 @@ +## Text + +````cts +declare const styles: { +/** + * ```scss + * .a { + * color: #ff0000; + * + * .b { + * color: green; + * } + * + * @media print { + * color: black; + * } + * } + * ``` + */ +readonly a: string + +/** + * ```scss + * .b { + * color: green; + * } + * ``` + */ +readonly b: string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| -------- | -: | ------- | --: | :--------: | +| `a` | 1 | `a` | 187 | ✓ | +| `b` | 27 | `b` | 271 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/nesting/tsconfig.json b/packages/sass-content-mapper/fixtures/nesting/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/nesting/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/parent-suffix/input.scss b/packages/sass-content-mapper/fixtures/parent-suffix/input.scss new file mode 100644 index 0000000..774b5f7 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/parent-suffix/input.scss @@ -0,0 +1,11 @@ +.block { + color: #ff0000; + + &__element { + color: green; + } + + &--modifier { + color: blue; + } +} diff --git a/packages/sass-content-mapper/fixtures/parent-suffix/transform.md b/packages/sass-content-mapper/fixtures/parent-suffix/transform.md new file mode 100644 index 0000000..c99a66e --- /dev/null +++ b/packages/sass-content-mapper/fixtures/parent-suffix/transform.md @@ -0,0 +1,51 @@ +## Text + +````cts +declare const styles: { +/** + * ```scss + * .block { + * color: #ff0000; + * + * &__element { + * color: green; + * } + * + * &--modifier { + * color: blue; + * } + * } + * ``` + */ +readonly block: string + +/** + * ```scss + * &__element { + * color: green; + * } + * ``` + */ +readonly block__element: string + +/** + * ```scss + * &--modifier { + * color: blue; + * } + * ``` + */ +readonly "block--modifier": string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| ------------- | -: | ------------------- | --: | :--------: | +| `block` | 1 | `block` | 197 | ✓ | +| `&__element` | 30 | `block__element` | 293 | ✓ | +| `&--modifier` | 68 | `"block--modifier"` | 398 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/parent-suffix/tsconfig.json b/packages/sass-content-mapper/fixtures/parent-suffix/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/parent-suffix/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/partial-error/_broken.scss b/packages/sass-content-mapper/fixtures/partial-error/_broken.scss new file mode 100644 index 0000000..1576cf9 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/partial-error/_broken.scss @@ -0,0 +1,3 @@ +.b { + color: $missing; +} diff --git a/packages/sass-content-mapper/fixtures/partial-error/input.scss b/packages/sass-content-mapper/fixtures/partial-error/input.scss new file mode 100644 index 0000000..b9af3c2 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/partial-error/input.scss @@ -0,0 +1,5 @@ +@use 'broken'; + +.a { + color: red; +} diff --git a/packages/sass-content-mapper/fixtures/partial-error/transform.md b/packages/sass-content-mapper/fixtures/partial-error/transform.md new file mode 100644 index 0000000..9d4f019 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/partial-error/transform.md @@ -0,0 +1,12 @@ +## Text + +```cts +declare const styles: {} + +export = styles + +``` + +## Diagnostics + +- `0:0`: `Undefined variable. (_broken.scss:2:10)` diff --git a/packages/sass-content-mapper/fixtures/partial-error/tsconfig.json b/packages/sass-content-mapper/fixtures/partial-error/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/partial-error/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/syntax-error/input.scss b/packages/sass-content-mapper/fixtures/syntax-error/input.scss new file mode 100644 index 0000000..12c1aa4 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/syntax-error/input.scss @@ -0,0 +1,2 @@ +.red { + color: #ff0000; diff --git a/packages/sass-content-mapper/fixtures/syntax-error/transform.md b/packages/sass-content-mapper/fixtures/syntax-error/transform.md new file mode 100644 index 0000000..54fffe3 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/syntax-error/transform.md @@ -0,0 +1,12 @@ +## Text + +```cts +declare const styles: {} + +export = styles + +``` + +## Diagnostics + +- `24:0`: `expected "}".` diff --git a/packages/sass-content-mapper/fixtures/syntax-error/tsconfig.json b/packages/sass-content-mapper/fixtures/syntax-error/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/syntax-error/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/undefined-variable/input.scss b/packages/sass-content-mapper/fixtures/undefined-variable/input.scss new file mode 100644 index 0000000..687866d --- /dev/null +++ b/packages/sass-content-mapper/fixtures/undefined-variable/input.scss @@ -0,0 +1,7 @@ +.🐈 { + color: #ff0000; +} + +.red { + color: $red; +} diff --git a/packages/sass-content-mapper/fixtures/undefined-variable/transform.md b/packages/sass-content-mapper/fixtures/undefined-variable/transform.md new file mode 100644 index 0000000..6d6dde6 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/undefined-variable/transform.md @@ -0,0 +1,12 @@ +## Text + +```cts +declare const styles: {} + +export = styles + +``` + +## Diagnostics + +- `43:4`: `Undefined variable.` diff --git a/packages/sass-content-mapper/fixtures/undefined-variable/tsconfig.json b/packages/sass-content-mapper/fixtures/undefined-variable/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/undefined-variable/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/url-in-selector/input.scss b/packages/sass-content-mapper/fixtures/url-in-selector/input.scss new file mode 100644 index 0000000..6537270 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/url-in-selector/input.scss @@ -0,0 +1,5 @@ +a[href^='https://example.com'] .parent { + .child { + color: red; + } +} diff --git a/packages/sass-content-mapper/fixtures/url-in-selector/transform.md b/packages/sass-content-mapper/fixtures/url-in-selector/transform.md new file mode 100644 index 0000000..d083bab --- /dev/null +++ b/packages/sass-content-mapper/fixtures/url-in-selector/transform.md @@ -0,0 +1,33 @@ +## Text + +````cts +declare const styles: { +/** + * ```scss + * .child { + * color: red; + * } + * ``` + */ +readonly parent: string + +/** + * ```scss + * .child { + * color: red; + * } + * ``` + */ +readonly child: string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| -------- | -: | -------- | --: | :--------: | +| `parent` | 32 | `parent` | 99 | ✓ | +| `child` | 44 | `child` | 190 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/url-in-selector/tsconfig.json b/packages/sass-content-mapper/fixtures/url-in-selector/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/url-in-selector/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/fixtures/use/_partial.scss b/packages/sass-content-mapper/fixtures/use/_partial.scss new file mode 100644 index 0000000..618ef23 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/use/_partial.scss @@ -0,0 +1,5 @@ +$red: #ff0000; + +.partial { + color: $red; +} diff --git a/packages/sass-content-mapper/fixtures/use/input.scss b/packages/sass-content-mapper/fixtures/use/input.scss new file mode 100644 index 0000000..fcd4832 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/use/input.scss @@ -0,0 +1,6 @@ +// `partial` is defined in another file, so it has no definition. +@use 'partial'; + +.local { + color: partial.$red; +} diff --git a/packages/sass-content-mapper/fixtures/use/transform.md b/packages/sass-content-mapper/fixtures/use/transform.md new file mode 100644 index 0000000..907549e --- /dev/null +++ b/packages/sass-content-mapper/fixtures/use/transform.md @@ -0,0 +1,27 @@ +## Text + +````cts +declare const styles: { +/** + */ +readonly partial: string + +/** + * ```scss + * .local { + * color: partial.$red; + * } + * ``` + */ +readonly local: string +} + +export = styles + +```` + +## Alias mappings + +| Original | | Virtual | | Definition | +| -------- | -: | ------- | --: | :--------: | +| `local` | 84 | `local` | 136 | ✓ | diff --git a/packages/sass-content-mapper/fixtures/use/tsconfig.json b/packages/sass-content-mapper/fixtures/use/tsconfig.json new file mode 100644 index 0000000..ce85187 --- /dev/null +++ b/packages/sass-content-mapper/fixtures/use/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "preserve", + "noEmit": true, + "verbatimModuleSyntax": true + }, + "contentMappers": [ + { + "package": "sass-content-mapper", + "extensions": [".scss", ".sass"] + } + ] +} diff --git a/packages/sass-content-mapper/lib/content-mapper.js b/packages/sass-content-mapper/lib/content-mapper.js new file mode 100644 index 0000000..b074c33 --- /dev/null +++ b/packages/sass-content-mapper/lib/content-mapper.js @@ -0,0 +1,164 @@ +/** + * @import { OptionDiagnostic } from 'content-mapper-test-utils' + * @import * as Sass from 'sass' + */ + +import { dirname, relative, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { startMapper } from 'css-modules-content-mapper' + +import { indentedSyntax, scssSyntax } from './syntax.js' + +/** + * @typedef LoadedSass + * @property {typeof Sass} sass + * The Sass API. + * @property {Pick} compiler + * The compiler to use. + * @property {() => void} [dispose] + * Stop the compiler, if it runs in a separate process. + */ + +/** + * Load the installed Sass implementation. Like build tools such as Vite and sass-loader, prefer + * `sass-embedded`, because it’s faster. Both packages implement the same API. + * + * @returns {Promise} + * The Sass API and the compiler to use, or undefined if neither package is installed. + */ +async function loadSass() { + try { + // The types of `sass-embedded` are a copy of those of `sass`, but differ slightly. + const embedded = /** @type {typeof Sass} */ ( + /** @type {unknown} */ (await import('sass-embedded')) + ) + // `sass-embedded` runs Sass in a separate process. Starting one for every compilation is + // slow, so keep a single compiler running. + const embeddedCompiler = embedded.initCompiler() + return { + sass: embedded, + compiler: embeddedCompiler, + dispose: () => embeddedCompiler.dispose() + } + } catch { + // `sass-embedded` isn’t installed, or its compiler isn’t available for this platform. + } + + try { + const js = await import('sass') + return { sass: js, compiler: js } + } catch { + // `sass` isn’t installed either. + } +} + +const { compiler, dispose, sass } = (await loadSass()) ?? {} + +const missingSassMessage = "Either 'sass-embedded' or 'sass' must be installed." + +startMapper({ + diagnosticSource: 'Sass', + + dispose() { + dispose?.() + }, + + validateOptions(options) { + const { loadPaths } = options + + /** @type {OptionDiagnostic[]} */ + const diagnostics = [] + + /** @type {string[]} */ + const validLoadPaths = [] + + if (!sass) { + diagnostics.push({ path: [], messageText: missingSassMessage, code: 1003 }) + } + + if (loadPaths != null && !Array.isArray(loadPaths)) { + diagnostics.push({ + path: ['loadPaths'], + messageText: "Content mapper option 'loadPaths' requires a value of type Array.", + code: 1002 + }) + } else if (loadPaths) { + for (const [index, path] of loadPaths.entries()) { + if (typeof path === 'string') { + validLoadPaths.push(path) + } else { + diagnostics.push({ + path: ['loadPaths', index], + messageText: "Content mapper option 'loadPaths' requires a value of type string.", + code: 1002 + }) + } + } + } + + return { diagnostics, options: { loadPaths: validLoadPaths } } + }, + + preprocess({ configFileName, content, fileName, options }) { + if (!sass || !compiler) { + return { diagnostics: [{ messageText: missingSassMessage, start: 0, length: 0, code: 2002 }] } + } + + const projectDirectory = configFileName ? dirname(configFileName) : dirname(fileName) + const { loadPaths } = options + const url = pathToFileURL(fileName) + const indented = fileName.endsWith('.sass') + + try { + const result = compiler.compileString(content, { + // Available in ESM from Sass 1.71.1. + importers: [new sass.NodePackageImporter(projectDirectory)], + loadPaths: [ + resolve(projectDirectory, 'node_modules'), + ...loadPaths.map((path) => resolve(projectDirectory, path)) + ], + logger: sass.Logger.silent, + sourceMap: true, + syntax: indented ? 'indented' : 'scss', + url + }) + + const { css, sourceMap } = result + if (!sourceMap) { + throw new Error('Sass did not return a source map.') + } + + return { css, map: sourceMap, syntax: indented ? indentedSyntax : scssSyntax } + } catch (error) { + if (!(error instanceof sass.Exception)) { + throw error + } + + const { sassMessage, span } = error + if (span.url?.href === url.href) { + const start = span.start.offset + return { + diagnostics: [ + { messageText: sassMessage, start, length: span.end.offset - start, code: 2001 } + ] + } + } + + // The error is in another file, such as a partial loaded using `@use`. Its span can’t be + // reported in this file, so the location is added to the message instead. + let messageText = sassMessage + if (span.url) { + const path = + span.url.protocol === 'file:' + ? relative(dirname(fileName), fileURLToPath(span.url)) + : span.url.href + messageText += ` (${path}:${span.start.line + 1}:${span.start.column + 1})` + } + + return { + diagnostics: [{ messageText, start: 0, length: 0, code: 2001 }] + } + } + } +}) diff --git a/packages/sass-content-mapper/lib/syntax.js b/packages/sass-content-mapper/lib/syntax.js new file mode 100644 index 0000000..1e73d52 --- /dev/null +++ b/packages/sass-content-mapper/lib/syntax.js @@ -0,0 +1,224 @@ +/** + * @import { Syntax } from 'css-modules-content-mapper' + */ + +// These rules are heuristics. They handle selectors, nesting, interpolation, line comments, and +// statements such as `@include`, which covers how classes are written in practice. They don’t +// tokenize Sass, so a class name inside a block comment or a string followed by `{` can be +// mistaken for a selector. + +/** + * Matches a selector token that may generate a class, such as `&-suffix` or `#{interpolation}`. + */ +const generatedToken = /(?:#{[^}]*}|[^\s()+,.:>[\]{}~])+/y + +/** + * Find the end of a selector token. + * + * @param {string} content + * The Sass source. + * @param {number} offset + * The start offset of the token. + * @returns {number} + * The offset where the token ends, or the start offset if there is no token. + */ +function findTokenEnd(content, offset) { + generatedToken.lastIndex = offset + return generatedToken.test(content) ? generatedToken.lastIndex : offset +} + +/** + * Find the end of a selector. + * + * @param {string} content + * The Sass source. + * @param {number} offset + * The start offset of the selector. + * @param {boolean} indented + * Whether the source uses the indented syntax. + * @returns {number} + * The offset where the selector ends. + */ +function findSelectorEnd(content, offset, indented) { + for (let index = offset; index < content.length; index += 1) { + const char = content[index] + if (char === '#' && content[index + 1] === '{') { + index = content.indexOf('}', index) + if (index === -1) { + break + } + } else if (char === '{' || char === ';' || char === '}' || (indented && char === '\n')) { + return index + } + } + return content.length +} + +/** + * Find the end of a rule. + * + * @param {string} content + * The Sass source. + * @param {number} offset + * The start offset of the rule. + * @param {boolean} indented + * Whether the source uses the indented syntax. + * @returns {number} + * The offset where the rule ends. + */ +function findRuleEnd(content, offset, indented) { + if (indented) { + const lineStart = content.lastIndexOf('\n', offset - 1) + 1 + const indent = offset - lineStart + let end = content.indexOf('\n', offset) + if (end === -1) { + return content.length + } + let next = end + 1 + while (next < content.length) { + let lineEnd = content.indexOf('\n', next) + if (lineEnd === -1) { + lineEnd = content.length + } + const line = content.slice(next, lineEnd) + if (line.trim()) { + if (line.length - line.trimStart().length <= indent) { + break + } + end = lineEnd + } + next = lineEnd + 1 + } + return end + } + + let depth = 0 + for (let index = offset; index < content.length; index += 1) { + const char = content[index] + if (char === '#' && content[index + 1] === '{') { + index = content.indexOf('}', index) + if (index === -1) { + break + } + } else if (char === '{') { + depth += 1 + } else if (char === '}') { + depth -= 1 + if (depth === 0) { + return index + 1 + } + } + } + return content.length +} + +/** + * Check whether an offset is in a line comment. + * + * `//` in a string, such as in `a[href^="https://"]`, doesn’t start a comment. + * + * @param {string} content + * The Sass source. + * @param {number} lineStart + * The start offset of the line. + * @param {number} offset + * The offset to check. + * @returns {boolean} + * Whether the offset is in a line comment. + */ +function isInLineComment(content, lineStart, offset) { + /** @type {string | undefined} */ + let quote + for (let index = lineStart; index < offset; index += 1) { + const char = content[index] + if (quote) { + if (char === '\\') { + index += 1 + } else if (char === quote) { + quote = undefined + } + } else if (char === '"' || char === "'") { + quote = char + } else if (char === '/' && content[index + 1] === '/') { + return true + } + } + return false +} + +/** + * Check whether an offset is part of a selector. + * + * This rules out matches in line comments, in declarations such as `background: url(icons.png)`, + * and in statements such as `@include mixins.name;`. + * + * @param {string} content + * The Sass source. + * @param {number} offset + * The offset to check. + * @param {boolean} indented + * Whether the source uses the indented syntax. + * @returns {boolean} + * Whether the offset is part of a selector. + */ +function isSelector(content, offset, indented) { + const lineStart = content.lastIndexOf('\n', offset - 1) + 1 + if (isInLineComment(content, lineStart, offset)) { + return false + } + + if (indented) { + let lineEnd = content.indexOf('\n', offset) + if (lineEnd === -1) { + lineEnd = content.length + } + const line = content.slice(lineStart, lineEnd) + if (/^\s*[$+=@]/.test(line)) { + return false + } + + // A selector list may continue on the next line. + if (line.trimEnd().endsWith(',')) { + return true + } + + // A selector is followed by a more indented block, whereas a declaration isn’t. + const indent = line.length - line.trimStart().length + let next = lineEnd + 1 + while (next < content.length) { + let nextEnd = content.indexOf('\n', next) + if (nextEnd === -1) { + nextEnd = content.length + } + const nextLine = content.slice(next, nextEnd) + if (nextLine.trim()) { + return nextLine.length - nextLine.trimStart().length > indent + } + next = nextEnd + 1 + } + return false + } + + return content[findSelectorEnd(content, offset, false)] === '{' +} + +/** + * Create the syntax rules for SCSS or the indented syntax. + * + * @param {boolean} indented + * Whether to create rules for the indented syntax. + * @returns {Syntax} + * The syntax rules. + */ +function createSyntax(indented) { + return { + findSelectorEnd: (content, offset) => findSelectorEnd(content, offset, indented), + findRuleEnd: (content, offset) => findRuleEnd(content, offset, indented), + isSelector: (content, offset) => isSelector(content, offset, indented), + findTokenEnd + } +} + +export const scssSyntax = createSyntax(false) + +export const indentedSyntax = createSyntax(true) diff --git a/packages/sass-content-mapper/package.json b/packages/sass-content-mapper/package.json new file mode 100644 index 0000000..ce05591 --- /dev/null +++ b/packages/sass-content-mapper/package.json @@ -0,0 +1,66 @@ +{ + "name": "sass-content-mapper", + "version": "0.0.0", + "description": "TypeScript content mapper for Sass CSS modules", + "keywords": [ + "content-mapper", + "css-modules", + "sass", + "scss", + "typescript" + ], + "homepage": "https://github.com/remcohaszing/css-modules-content-mapper/tree/main/packages/sass-content-mapper#readme", + "bugs": "https://github.com/remcohaszing/css-modules-content-mapper/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/remcohaszing/css-modules-content-mapper.git", + "directory": "packages/sass-content-mapper" + }, + "funding": "https://github.com/sponsors/remcohaszing", + "license": "MIT", + "author": "Remco Haszing ", + "contributors": [ + "Brody McKee (https://github.com/mrmckeb)" + ], + "type": "module", + "files": [ + "lib" + ], + "typescript": { + "contentMapper": { + "exec": [ + "node", + "lib/content-mapper.js" + ], + "compilerOptions": [], + "dynamicConfig": false + } + }, + "scripts": { + "test": "node --test" + }, + "dependencies": { + "css-modules-content-mapper": "^0.2.0" + }, + "devDependencies": { + "@types/node": "^26.0.0", + "content-mapper-test-utils": "^0.0.1", + "sass": "1.71.1", + "sass-embedded": "1.71.1", + "snapshot-fixtures": "^1.0.0", + "typescript": "^6.0.0", + "vscode-jsonrpc": "^10.0.0" + }, + "peerDependencies": { + "sass": "^1.71.1", + "sass-embedded": "^1.71.1" + }, + "peerDependenciesMeta": { + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + } + } +} diff --git a/packages/sass-content-mapper/test/exit.ts b/packages/sass-content-mapper/test/exit.ts new file mode 100644 index 0000000..dbef1d4 --- /dev/null +++ b/packages/sass-content-mapper/test/exit.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { dirname } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter +} from 'vscode-jsonrpc/node' + +import pkg from '../package.json' with { type: 'json' } + +const fileName = fileURLToPath(new URL('../fixtures/exit.scss', import.meta.url)) +const projectHandle = `${pkg.name}@${pkg.version}:0` + +// If TypeScript stops without shutting the mapper down, for example because it crashed, the mapper +// only sees its stdin close. The `sass-embedded` compiler runs in a separate process, which keeps +// the mapper alive unless it’s disposed. +test('exits when stdin is closed', async () => { + const [command, ...args] = pkg.typescript.contentMapper.exec + assert.ok(command) + using proc = spawn(command, args, { + stdio: ['pipe', 'pipe', 'inherit'], + cwd: dirname(import.meta.dirname) + }) + const connection = createMessageConnection( + new StreamMessageReader(proc.stdout), + new StreamMessageWriter(proc.stdin) + ) + + // Reject pending requests if the mapper exits, instead of waiting forever. + proc.once('exit', () => connection.dispose()) + + connection.listen() + + await connection.sendRequest('initialize') + await connection.sendRequest('openProject', { configFileName: '', projectHandle }) + await connection.sendRequest('transform', { + content: '.a {\n color: red;\n}\n', + fileName, + projectHandle + }) + + const exited = once(proc, 'exit') + proc.stdin.end() + + const [code] = await Promise.race([ + exited, + new Promise((resolve, reject) => { + setTimeout( + () => reject(new Error('The mapper didn’t exit after stdin was closed')), + 5000 + ).unref() + }) + ]) + assert.equal(code, 0) +}) diff --git a/packages/sass-content-mapper/test/fallback.ts b/packages/sass-content-mapper/test/fallback.ts new file mode 100644 index 0000000..ed4affa --- /dev/null +++ b/packages/sass-content-mapper/test/fallback.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +import { createContentMapper } from 'content-mapper-test-utils' + +const fixture = new URL('../fixtures/nesting/', import.meta.url) +const fileName = fileURLToPath(new URL('fallback.scss', fixture)) + +const valid = '.valid {\n color: red;\n}\n' +const broken = '.valid {\n color: red;\n}\n\n.broken {\n color: $undefined-variable;\n}\n' + +test('keeps the last successful result when compilation fails', async () => { + using contentMapper = await createContentMapper(new URL('../package.json', import.meta.url)) + + { + await using first = await contentMapper.openProject(fixture) + + const good = await first.transform(fileName, valid) + assert.match(good.text, /readonly valid: string/) + assert.ok(good.mappings?.length) + assert.equal(good.diagnostics, undefined) + + const bad = await first.transform(fileName, broken) + assert.equal(bad.text, good.text) + assert.deepEqual(bad.mappings, []) + assert.equal(bad.diagnostics?.length, 1) + const [diagnostic] = bad.diagnostics + assert.equal(diagnostic?.messageText, 'Undefined variable.') + assert.equal( + broken.slice(diagnostic.start, diagnostic.start + diagnostic.length), + '$undefined-variable' + ) + + const recovered = await first.transform(fileName, valid) + assert.deepEqual(recovered, good) + } + + // A new project doesn’t share the results of a previous one. + await using second = await contentMapper.openProject(fixture) + const afterReopen = await second.transform(fileName, broken) + assert.equal(afterReopen.text, 'declare const styles: {}\n\nexport = styles\n') +}) diff --git a/packages/sass-content-mapper/test/test.ts b/packages/sass-content-mapper/test/test.ts new file mode 100644 index 0000000..f844bba --- /dev/null +++ b/packages/sass-content-mapper/test/test.ts @@ -0,0 +1,158 @@ +import type { OpenProjectResult } from 'content-mapper-test-utils' + +import assert from 'node:assert/strict' +import { dirname } from 'node:path' +import { after, test } from 'node:test' +import { fileURLToPath } from 'node:url' + +import { createContentMapper, transformResultToMarkdown } from 'content-mapper-test-utils' +import { testFixturesDirectory } from 'snapshot-fixtures' + +import pkg from '../package.json' with { type: 'json' } + +type ContentMapper = Awaited> + +/** + * Get Node.js arguments that make packages fail to resolve, as if they weren’t installed. + * + * @param packages + * The package names to hide. + * @returns + * The Node.js arguments. + */ +function hidePackages(packages: string[]): string[] { + if (!packages.length) { + return [] + } + + const hook = ` + import { registerHooks } from 'node:module' + + const hidden = new Set(${JSON.stringify(packages)}) + + registerHooks({ + resolve(specifier, context, nextResolve) { + if (hidden.has(specifier)) { + throw Object.assign(new Error(\`Cannot find package '\${specifier}'\`), { + code: 'ERR_MODULE_NOT_FOUND' + }) + } + return nextResolve(specifier, context) + } + }) + ` + + return ['--import', `data:text/javascript,${encodeURIComponent(hook)}`] +} + +/** + * Start the Sass content mapper. + * + * @param hidden + * Packages to hide from the mapper, to test which Sass implementation it uses. + * @returns + * The content mapper. + */ +function createSassContentMapper(hidden: string[]): Promise { + const [command, ...args] = pkg.typescript.contentMapper.exec + assert.ok(command) + return createContentMapper(new URL('../package.json', import.meta.url), { + exec: [command, ...hidePackages(hidden), ...args] + }) +} + +/** + * Render option diagnostics as markdown, so invalid options can be tested with snapshots. + * + * @param openProjectResult + * The result of opening the project. + * @returns + * The markdown, or an empty string if the options are valid. + */ +function optionDiagnosticsToMarkdown(openProjectResult: OpenProjectResult): string { + if (!openProjectResult.optionDiagnostics?.length) { + return '' + } + + const items = openProjectResult.optionDiagnostics.map(({ messageText, path }) => { + const location = path + .map((segment) => (typeof segment === 'number' ? `[${segment}]` : `.${segment}`)) + .join('') + .slice(1) + return `- \`${location || 'options'}\`: \`${messageText}\`` + }) + + return `\n## Option diagnostics\n\n${items.join('\n')}\n` +} + +// Both implementations must produce the same output. +const sassEmbedded = await createSassContentMapper([]) +const sass = await createSassContentMapper(['sass-embedded']) + +after(() => { + sassEmbedded.dispose() + sass.dispose() +}) + +test('initialize', () => { + for (const contentMapper of [sassEmbedded, sass]) { + assert.deepEqual(contentMapper.initializeResult, { + positionEncoding: 'utf-16', + diagnosticSource: 'Sass' + }) + } +}) + +/** + * Create a fixture test that transforms the input with a content mapper. + * + * @param contentMapper + * The content mapper to use. + * @returns + * The fixture test. + */ +function createTransformTest(contentMapper: ContentMapper) { + return async (file: { path: string }): Promise => { + const original = String(file) + await using project = await contentMapper.openProject(dirname(file.path)) + + const transform = await project.transform(file.path, original) + + return ( + transformResultToMarkdown(original, transform) + + optionDiagnosticsToMarkdown(project.openProjectResult) + ) + } +} + +testFixturesDirectory({ + directory: new URL('../fixtures', import.meta.url), + write: false, + tests: { + 'sass-embedded': { expected: 'transform.md', generate: createTransformTest(sassEmbedded) }, + sass: { expected: 'transform.md', generate: createTransformTest(sass) } + } +}) + +test('reports a diagnostic if neither sass-embedded nor sass is installed', async () => { + const messageText = "Either 'sass-embedded' or 'sass' must be installed." + const fixture = new URL('../fixtures/nesting/', import.meta.url) + + using contentMapper = await createSassContentMapper(['sass-embedded', 'sass']) + await using project = await contentMapper.openProject(fixture) + + assert.deepEqual(project.openProjectResult, { + optionDiagnostics: [{ path: [], messageText, code: 1003 }] + }) + + const transform = await project.transform( + fileURLToPath(new URL('input.scss', fixture)), + '.a {\n color: red;\n}\n' + ) + assert.deepEqual(transform, { + extension: '.cts', + text: 'declare const styles: {}\n\nexport = styles\n', + mappings: [], + diagnostics: [{ messageText, start: 0, length: 0, code: 2002 }] + }) +}) diff --git a/packages/sass-content-mapper/tsconfig.json b/packages/sass-content-mapper/tsconfig.json new file mode 100644 index 0000000..c04e666 --- /dev/null +++ b/packages/sass-content-mapper/tsconfig.json @@ -0,0 +1,13 @@ +{ + "exclude": ["fixtures"], + "references": [{ "path": "../css-modules-content-mapper/tsconfig.build.json" }], + "compilerOptions": { + "checkJs": true, + "module": "nodenext", + "noEmit": true, + "target": "esnext", + "lib": ["esnext"], + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/tsconfig.json b/tsconfig.json index abb7163..7cd1da3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,7 @@ { "files": [], - "references": [{ "path": "./packages/css-modules-content-mapper/tsconfig.json" }] + "references": [ + { "path": "./packages/css-modules-content-mapper/tsconfig.json" }, + { "path": "./packages/sass-content-mapper/tsconfig.json" } + ] }