diff --git a/.husky/pre-push b/.husky/pre-push index b5abef7..476f785 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,6 +1,7 @@ #!/usr/bin/env sh set -euo pipefail +npm run check:cycles npm run lint npm run prettier:check npm run check-types diff --git a/README.md b/README.md index dac52f0..bc998ff 100644 --- a/README.md +++ b/README.md @@ -249,13 +249,7 @@ No vendor copies are necessary—the declarations live inside `@knighted/css`, y #### Combined module + CSS import -If you prefer a single import that returns both your module exports and the compiled stylesheet, append `&combined` to the query: - -```ts -import { Button, knightedCss } from './button.tsx?knighted-css&combined' -``` - -This keeps your bundler’s other CSS loaders intact while guaranteeing that `knightedCss` is only computed once. The trade-off is that TypeScript can’t infer the original module shape automatically. To restore those typings, use the helper type exported by the loader: +If you prefer a single import that returns both your module exports and the compiled stylesheet, append `&combined` to the query. Then narrow the import once so TypeScript understands the shape: ```ts import type { KnightedCssCombinedModule } from '@knighted/css/loader' @@ -266,6 +260,14 @@ const { Button, knightedCss } = combined as KnightedCssCombinedModule< > ``` +Combined imports mirror the source module’s default export strategy. Need to guarantee “named exports only” regardless of what the source module does? Append `&named-only` (alias: `&no-default`) to the query—the typing stays the same: + +```ts +import combined from './button.tsx?knighted-css&combined&named-only' +``` + +The `named-only` flag suppresses the synthetic default entirely, which is handy for codebases that consistently destructure combined modules or rely on namespace imports for type narrowing. + You can mix and match: regular `?knighted-css` imports keep strong module typings and just add the CSS string, while `?knighted-css&combined` dedupes your CSS loader pipeline when you need everything at once. #### vanilla-extract loader guidance diff --git a/package-lock.json b/package-lock.json index 064938f..d851ca7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "husky": "^9.1.7", "less": "^4.2.0", "lint-staged": "^16.2.7", + "madge": "^8.0.0", "oxlint": "^0.4.1", "prettier": "^3.7.4", "sass": "^1.80.7", @@ -2879,6 +2880,96 @@ "dev": true, "license": "MIT" }, + "node_modules/@ts-graphviz/adapter": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ts-graphviz/adapter/-/adapter-2.0.6.tgz", + "integrity": "sha512-kJ10lIMSWMJkLkkCG5gt927SnGZcBuG0s0HHswGzcHTgvtUe7yk5/3zTEr0bafzsodsOq5Gi6FhQeV775nC35Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "license": "MIT", + "dependencies": { + "@ts-graphviz/common": "^2.1.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ts-graphviz/ast": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@ts-graphviz/ast/-/ast-2.0.7.tgz", + "integrity": "sha512-e6+2qtNV99UT6DJSoLbHfkzfyqY84aIuoV8Xlb9+hZAjgpum8iVHprGeAMQ4rF6sKUAxrmY8rfF/vgAwoPc3gw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "license": "MIT", + "dependencies": { + "@ts-graphviz/common": "^2.1.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ts-graphviz/common": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@ts-graphviz/common/-/common-2.1.5.tgz", + "integrity": "sha512-S6/9+T6x8j6cr/gNhp+U2olwo1n0jKj/682QVqsh7yXWV6ednHYqxFw0ZsY3LyzT0N8jaZ6jQY9YD99le3cmvg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@ts-graphviz/core": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@ts-graphviz/core/-/core-2.0.7.tgz", + "integrity": "sha512-w071DSzP94YfN6XiWhOxnLpYT3uqtxJBDYdh6Jdjzt+Ce6DNspJsPQgpC7rbts/B8tEkq0LHoYuIF/O5Jh5rPg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "license": "MIT", + "dependencies": { + "@ts-graphviz/ast": "^2.0.7", + "@ts-graphviz/common": "^2.1.5" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -3936,6 +4027,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -3985,6 +4083,27 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.6", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.6.tgz", @@ -4038,6 +4157,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -4161,6 +4292,31 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4426,6 +4582,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-truncate": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", @@ -4524,6 +4693,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -4575,6 +4754,13 @@ "node": ">=20" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -4847,6 +5033,16 @@ } } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-object-diff": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.9.tgz", @@ -4894,6 +5090,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -6370,6 +6579,27 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/image-size": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", @@ -6431,6 +6661,13 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/interpret": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", @@ -6553,6 +6790,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-network-error": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", @@ -6620,6 +6867,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-url": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", @@ -7283,6 +7543,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -7309,6 +7586,55 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/madge": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/madge/-/madge-8.0.0.tgz", + "integrity": "sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "commander": "^7.2.0", + "commondir": "^1.0.1", + "debug": "^4.3.4", + "dependency-tree": "^11.0.0", + "ora": "^5.4.1", + "pluralize": "^8.0.0", + "pretty-ms": "^7.0.1", + "rc": "^1.2.8", + "stream-to-array": "^2.3.0", + "ts-graphviz": "^2.1.2", + "walkdir": "^0.4.1" + }, + "bin": { + "madge": "bin/cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://www.paypal.me/pahen" + }, + "peerDependencies": { + "typescript": "^5.4.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/madge/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7473,6 +7799,16 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -7932,6 +8268,103 @@ "opener": "bin/opener-bin.js" } }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/oxc-parser": { "version": "0.78.0", "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.78.0.tgz", @@ -8090,6 +8523,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", + "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parse-node-version": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", @@ -8348,6 +8791,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -8462,6 +8915,22 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-ms": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", + "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -8572,6 +9041,22 @@ "node": ">=0.10.0" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -9577,6 +10062,16 @@ "node": ">= 0.8" } }, + "node_modules/stream-to-array": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz", + "integrity": "sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -9747,6 +10242,16 @@ "node": ">=4" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stylus-lookup": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/stylus-lookup/-/stylus-lookup-6.1.0.tgz", @@ -10084,6 +10589,32 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-graphviz": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/ts-graphviz/-/ts-graphviz-2.1.6.tgz", + "integrity": "sha512-XyLVuhBVvdJTJr2FJJV2L1pc4MwSjMhcunRVgDE9k4wbb2ee7ORYnPewxMWUav12vxyfUM686MSGsqnVRIInuw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "license": "MIT", + "dependencies": { + "@ts-graphviz/adapter": "^2.0.6", + "@ts-graphviz/ast": "^2.0.7", + "@ts-graphviz/common": "^2.1.5", + "@ts-graphviz/core": "^2.0.7" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-loader": { "version": "9.5.4", "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", @@ -10364,6 +10895,16 @@ "node": ">= 0.8" } }, + "node_modules/walkdir": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/walkdir/-/walkdir-0.4.1.tgz", + "integrity": "sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", @@ -10388,6 +10929,16 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/webpack": { "version": "5.103.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", @@ -11041,10 +11592,11 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.0.0-rc.5", + "version": "1.0.0-rc.6", "license": "MIT", "dependencies": { "dependency-tree": "^11.2.0", + "es-module-lexer": "^2.0.0", "lightningcss": "^1.30.2" }, "peerDependencies": { @@ -11064,11 +11616,17 @@ } } }, + "packages/css/node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "license": "MIT" + }, "packages/playwright": { "name": "@knighted/css-playwright-fixture", "version": "0.0.0", "dependencies": { - "@knighted/css": "1.0.0-rc.5", + "@knighted/css": "1.0.0-rc.6", "@knighted/jsx": "^1.2.1", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/package.json b/package.json index 9bc316c..624555b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "lint": "oxlint packages", "prettier": "prettier --write .", "prettier:check": "prettier --check .", + "check:cycles": "madge packages/css/src --ts-config packages/css/tsconfig.json --extensions ts,tsx --circular", "preview:e2e": "npm run preview -w @knighted/css-playwright-fixture", "check-types": "npm run check-types -w @knighted/css && npm run check-types -w @knighted/css-playwright-fixture", "clean:deps": "find . -name node_modules -type d -prune -exec rm -rf {} +", @@ -46,6 +47,7 @@ "husky": "^9.1.7", "less": "^4.2.0", "lint-staged": "^16.2.7", + "madge": "^8.0.0", "oxlint": "^0.4.1", "prettier": "^3.7.4", "sass": "^1.80.7", @@ -64,6 +66,7 @@ "singleQuote": true }, "lint-staged": { + "*": "npm run check:cycles", "*.{js,jsx,ts,tsx,mjs,cjs,cts,mts}": "oxlint", "*.{js,jsx,ts,tsx,mjs,cjs,cts,mts,json,md,css,scss,html}": "prettier --check" } diff --git a/packages/css/loader-queries.d.ts b/packages/css/loader-queries.d.ts index 4d641c7..b964a79 100644 --- a/packages/css/loader-queries.d.ts +++ b/packages/css/loader-queries.d.ts @@ -19,3 +19,11 @@ declare module '*?knighted-css&combined' { export default combined export const knightedCss: string } + +declare module '*?knighted-css&combined&named-only*' { + export const knightedCss: string +} + +declare module '*?knighted-css&combined&no-default*' { + export const knightedCss: string +} diff --git a/packages/css/package.json b/packages/css/package.json index 8c3bc9f..6ad47f7 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.0.0-rc.5", + "version": "1.0.0-rc.6", "description": "A build-time utility that traverses JavaScript/TypeScript module dependency graphs to extract, compile, and optimize all imported CSS into a single, in-memory string.", "type": "module", "main": "./dist/css.js", @@ -66,6 +66,7 @@ }, "dependencies": { "dependency-tree": "^11.2.0", + "es-module-lexer": "^2.0.0", "lightningcss": "^1.30.2" }, "overrides": { diff --git a/packages/css/src/css.ts b/packages/css/src/css.ts index 766f8e9..09d1b1b 100644 --- a/packages/css/src/css.ts +++ b/packages/css/src/css.ts @@ -1,6 +1,5 @@ import path from 'node:path' import { existsSync, promises as fs } from 'node:fs' -import { fileURLToPath, pathToFileURL } from 'node:url' import dependencyTree from 'dependency-tree' import type { Options as DependencyTreeOpts } from 'dependency-tree' @@ -15,6 +14,10 @@ import { type SpecificitySelector, type SpecificityStrategy, } from './helpers.js' +import { createSassImporter } from './sassInternals.js' +import type { CssResolver } from './sassInternals.js' + +export type { CssResolver } from './sassInternals.js' export const DEFAULT_EXTENSIONS = ['.css', '.scss', '.sass', '.less', '.css.ts'] @@ -22,11 +25,6 @@ type LightningCssConfig = | boolean | Partial, 'code'>> -export type CssResolver = ( - specifier: string, - ctx: { cwd: string }, -) => string | Promise - type PeerLoader = (name: string) => Promise export interface CssOptions { @@ -269,132 +267,6 @@ function buildSassLoadPaths(filePath: string): string[] { return Array.from(loadPaths).filter(dir => dir && existsSync(dir)) } -function createSassImporter({ cwd, resolver }: { cwd: string; resolver?: CssResolver }) { - if (!resolver) return undefined - const debug = process.env.KNIGHTED_CSS_DEBUG_SASS === '1' - - return { - async canonicalize(url: string, context?: { containingUrl?: URL | null }) { - if (debug) { - console.error('[knighted-css:sass] canonicalize request:', url) - if (context?.containingUrl) { - console.error('[knighted-css:sass] containing url:', context.containingUrl.href) - } - } - if (shouldNormalizeSpecifier(url)) { - const resolvedPath = await resolveAliasSpecifier(url, resolver, cwd) - if (!resolvedPath) { - if (debug) { - console.error('[knighted-css:sass] resolver returned no result for', url) - } - return null - } - const fileUrl = pathToFileURL(resolvedPath) - if (debug) { - console.error('[knighted-css:sass] canonical url:', fileUrl.href) - } - return fileUrl - } - const relativePath = resolveRelativeSpecifier(url, context?.containingUrl) - if (relativePath) { - const fileUrl = pathToFileURL(relativePath) - if (debug) { - console.error('[knighted-css:sass] canonical url:', fileUrl.href) - } - return fileUrl - } - return null - }, - async load(canonicalUrl: URL) { - if (debug) { - console.error('[knighted-css:sass] load request:', canonicalUrl.href) - } - const filePath = fileURLToPath(canonicalUrl) - const contents = await fs.readFile(filePath, 'utf8') - return { - contents, - syntax: inferSassSyntax(filePath), - } - }, - } -} - -async function resolveAliasSpecifier( - specifier: string, - resolver: CssResolver, - cwd: string, -): Promise { - const resolved = await resolver(specifier, { cwd }) - if (!resolved) { - return undefined - } - if (resolved.startsWith('file://')) { - return ensureSassPath(fileURLToPath(new URL(resolved))) - } - const normalized = path.isAbsolute(resolved) ? resolved : path.resolve(cwd, resolved) - return ensureSassPath(normalized) -} - -function shouldNormalizeSpecifier(specifier: string): boolean { - const schemeMatch = specifier.match(/^([a-z][\w+.-]*):/i) - if (!schemeMatch) { - return false - } - const scheme = schemeMatch[1].toLowerCase() - if ( - scheme === 'file' || - scheme === 'http' || - scheme === 'https' || - scheme === 'data' || - scheme === 'sass' - ) { - return false - } - return true -} - -function inferSassSyntax(filePath: string): 'scss' | 'indented' { - return filePath.endsWith('.sass') ? 'indented' : 'scss' -} - -function ensureSassPath(filePath: string): string | undefined { - if (existsSync(filePath)) { - return filePath - } - const ext = path.extname(filePath) - const dir = path.dirname(filePath) - const base = path.basename(filePath, ext) - const partialCandidate = path.join(dir, `_${base}${ext}`) - if (ext && existsSync(partialCandidate)) { - return partialCandidate - } - const indexCandidate = path.join(dir, base, `index${ext}`) - if (ext && existsSync(indexCandidate)) { - return indexCandidate - } - const partialIndexCandidate = path.join(dir, base, `_index${ext}`) - if (ext && existsSync(partialIndexCandidate)) { - return partialIndexCandidate - } - return undefined -} - -function resolveRelativeSpecifier( - specifier: string, - containingUrl?: URL | null, -): string | undefined { - if (!containingUrl || containingUrl.protocol !== 'file:') { - return undefined - } - if (/^[a-z][\w+.-]*:/i.test(specifier)) { - return undefined - } - const containingPath = fileURLToPath(containingUrl) - const baseDir = path.dirname(containingPath) - const candidate = path.resolve(baseDir, specifier) - return ensureSassPath(candidate) -} - async function compileLess(filePath: string, peerResolver?: PeerLoader): Promise { const mod = await optionalPeer('less', 'Less', peerResolver) const less = unwrapModuleNamespace(mod) diff --git a/packages/css/src/loader.ts b/packages/css/src/loader.ts index 39fc0b4..125c900 100644 --- a/packages/css/src/loader.ts +++ b/packages/css/src/loader.ts @@ -5,6 +5,16 @@ import type { } from 'webpack' import { cssWithMeta, compileVanillaModule, type CssOptions } from './css.js' +import { detectModuleDefaultExport, type ModuleDefaultSignal } from './moduleInfo.js' +import { + buildSanitizedQuery, + COMBINED_QUERY_FLAG, + isQueryFlag, + NAMED_ONLY_QUERY_FLAGS, + shouldEmitCombinedDefault, + shouldForwardDefaultExport, + splitQuery, +} from './loaderInternals.js' export type KnightedCssCombinedModule = TModule & { knightedCss: string @@ -19,7 +29,6 @@ export interface KnightedCssLoaderOptions extends CssOptions { } const DEFAULT_EXPORT_NAME = 'knightedCss' -const COMBINED_QUERY_FLAG = 'combined' const loader: LoaderDefinitionFunction = async function loader( source: string | Buffer, @@ -83,8 +92,21 @@ export const pitch: PitchLoaderDefinitionFunction = const request = buildProxyRequest(this) const { cssOptions } = resolveLoaderOptions(this) - - return extractCss(this, cssOptions).then(css => createCombinedModule(request, css)) + const skipSyntheticDefault = hasNamedOnlyQueryFlag(this.resourceQuery) + const defaultSignalPromise = skipSyntheticDefault + ? Promise.resolve('unknown') + : detectModuleDefaultExport(this.resourcePath) + + return Promise.all([extractCss(this, cssOptions), defaultSignalPromise]).then( + ([css, defaultSignal]) => { + const emitDefault = shouldEmitCombinedDefault({ + request, + skipSyntheticDefault, + detection: defaultSignal, + }) + return createCombinedModule(request, css, { emitDefault }) + }, + ) } ;(loader as LoaderDefinitionFunction & { pitch?: typeof pitch }).pitch = pitch @@ -138,6 +160,14 @@ function hasCombinedQuery(query?: string | null): boolean { .some(part => isQueryFlag(part, COMBINED_QUERY_FLAG)) } +function hasNamedOnlyQueryFlag(query?: string | null): boolean { + if (!query) return false + const entries = splitQuery(query) + return entries.some(part => + NAMED_ONLY_QUERY_FLAGS.some(flag => isQueryFlag(part, flag)), + ) +} + function buildProxyRequest(ctx: LoaderContext): string { const sanitizedQuery = buildSanitizedQuery(ctx.resourceQuery) const rawRequest = getRawRequest(ctx) @@ -171,37 +201,23 @@ function stripResourceQuery(request: string): string { return idx >= 0 ? request.slice(0, idx) : request } -function buildSanitizedQuery(query?: string | null): string { - if (!query) return '' - const entries = splitQuery(query).filter(part => { - return !isQueryFlag(part, COMBINED_QUERY_FLAG) && !isQueryFlag(part, 'knighted-css') - }) - return entries.length > 0 ? `?${entries.join('&')}` : '' -} - -function splitQuery(query: string): string[] { - const trimmed = query.startsWith('?') ? query.slice(1) : query - if (!trimmed) return [] - return trimmed.split('&').filter(Boolean) +interface CombinedModuleOptions { + emitDefault?: boolean } -function isQueryFlag(entry: string, flag: string): boolean { - const [rawKey] = entry.split('=') - try { - return decodeURIComponent(rawKey) === flag - } catch { - return rawKey === flag - } -} - -function createCombinedModule(request: string, css: string): string { +function createCombinedModule( + request: string, + css: string, + options?: CombinedModuleOptions, +): string { + const shouldEmitDefault = options?.emitDefault ?? shouldForwardDefaultExport(request) const requestLiteral = JSON.stringify(request) const lines = [ `import * as __knightedModule from ${requestLiteral};`, `export * from ${requestLiteral};`, ] - if (shouldForwardDefaultExport(request)) { + if (shouldEmitDefault) { lines.push( `const __knightedDefault = typeof __knightedModule.default !== 'undefined' @@ -214,13 +230,3 @@ typeof __knightedModule.default !== 'undefined' lines.push(buildInjection(css)) return lines.join('\n') } - -function shouldForwardDefaultExport(request: string): boolean { - const [pathPart] = request.split('?') - if (!pathPart) return true - const lower = pathPart.toLowerCase() - if (lower.endsWith('.css.ts') || lower.endsWith('.css.js')) { - return false - } - return true -} diff --git a/packages/css/src/loaderInternals.ts b/packages/css/src/loaderInternals.ts new file mode 100644 index 0000000..e57c7eb --- /dev/null +++ b/packages/css/src/loaderInternals.ts @@ -0,0 +1,71 @@ +import type { ModuleDefaultSignal } from './moduleInfo.js' + +export const COMBINED_QUERY_FLAG = 'combined' +export const NAMED_ONLY_QUERY_FLAGS = ['named-only', 'no-default'] as const + +export function splitQuery(query: string): string[] { + const trimmed = query.startsWith('?') ? query.slice(1) : query + if (!trimmed) return [] + return trimmed.split('&').filter(Boolean) +} + +export function isQueryFlag(entry: string, flag: string): boolean { + const [rawKey] = entry.split('=') + try { + return decodeURIComponent(rawKey) === flag + } catch { + return rawKey === flag + } +} + +export function buildSanitizedQuery(query?: string | null): string { + if (!query) return '' + const entries = splitQuery(query).filter(part => { + if (isQueryFlag(part, COMBINED_QUERY_FLAG)) { + return false + } + if (isQueryFlag(part, 'knighted-css')) { + return false + } + if (NAMED_ONLY_QUERY_FLAGS.some(flag => isQueryFlag(part, flag))) { + return false + } + return true + }) + return entries.length > 0 ? `?${entries.join('&')}` : '' +} + +export function shouldForwardDefaultExport(request: string): boolean { + const [pathPart] = request.split('?') + if (!pathPart) return true + const lower = pathPart.toLowerCase() + if (lower.endsWith('.css.ts') || lower.endsWith('.css.js')) { + return false + } + return true +} + +export function shouldEmitCombinedDefault(options: { + detection: ModuleDefaultSignal + request: string + skipSyntheticDefault: boolean +}): boolean { + if (options.skipSyntheticDefault) { + return false + } + if (!shouldForwardDefaultExport(options.request)) { + return false + } + if (options.detection === 'has-default') { + return true + } + if (options.detection === 'no-default') { + return false + } + return true +} + +export const __loaderInternals = { + buildSanitizedQuery, + shouldEmitCombinedDefault, +} diff --git a/packages/css/src/moduleInfo.ts b/packages/css/src/moduleInfo.ts new file mode 100644 index 0000000..3368474 --- /dev/null +++ b/packages/css/src/moduleInfo.ts @@ -0,0 +1,69 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +import { init, parse } from 'es-module-lexer' + +export type ModuleDefaultSignal = 'has-default' | 'no-default' | 'unknown' + +type LexerOverrides = { + parse?: typeof parse +} + +const DETECTABLE_EXTENSIONS = new Set([ + '.js', + '.jsx', + '.ts', + '.tsx', + '.mjs', + '.mts', + '.cjs', + '.cts', +]) + +let lexerInit: Promise | undefined +let lexerOverrides: LexerOverrides | undefined + +function ensureLexerInitialized(): Promise { + if (!lexerInit) { + lexerInit = init + } + return lexerInit +} + +export async function detectModuleDefaultExport( + filePath: string, +): Promise { + if (!DETECTABLE_EXTENSIONS.has(path.extname(filePath))) { + return 'unknown' + } + + let source: string + try { + source = await readFile(filePath, 'utf8') + } catch { + return 'unknown' + } + + try { + await ensureLexerInitialized() + const [, exports] = (lexerOverrides?.parse ?? parse)(source, filePath) + if (exports.some(entry => entry.n === 'default')) { + return 'has-default' + } + if (exports.length === 0) { + return 'unknown' + } + return 'no-default' + } catch { + return 'unknown' + } +} + +export const __moduleInfoInternals = { + setLexerOverrides(overrides?: LexerOverrides) { + lexerOverrides = overrides + if (!overrides) { + lexerInit = undefined + } + }, +} diff --git a/packages/css/src/sassInternals.ts b/packages/css/src/sassInternals.ts new file mode 100644 index 0000000..41ddafb --- /dev/null +++ b/packages/css/src/sassInternals.ts @@ -0,0 +1,148 @@ +import path from 'node:path' +import { existsSync, promises as fs } from 'node:fs' +import { fileURLToPath, pathToFileURL } from 'node:url' + +export type CssResolver = ( + specifier: string, + ctx: { cwd: string }, +) => string | Promise + +export function createSassImporter({ + cwd, + resolver, +}: { + cwd: string + resolver?: CssResolver +}) { + if (!resolver) return undefined + const debug = process.env.KNIGHTED_CSS_DEBUG_SASS === '1' + + return { + async canonicalize(url: string, context?: { containingUrl?: URL | null }) { + if (debug) { + console.error('[knighted-css:sass] canonicalize request:', url) + if (context?.containingUrl) { + console.error('[knighted-css:sass] containing url:', context.containingUrl.href) + } + } + if (shouldNormalizeSpecifier(url)) { + const resolvedPath = await resolveAliasSpecifier(url, resolver, cwd) + if (!resolvedPath) { + if (debug) { + console.error('[knighted-css:sass] resolver returned no result for', url) + } + return null + } + const fileUrl = pathToFileURL(resolvedPath) + if (debug) { + console.error('[knighted-css:sass] canonical url:', fileUrl.href) + } + return fileUrl + } + const relativePath = resolveRelativeSpecifier(url, context?.containingUrl) + if (relativePath) { + const fileUrl = pathToFileURL(relativePath) + if (debug) { + console.error('[knighted-css:sass] canonical url:', fileUrl.href) + } + return fileUrl + } + return null + }, + async load(canonicalUrl: URL) { + if (debug) { + console.error('[knighted-css:sass] load request:', canonicalUrl.href) + } + const filePath = fileURLToPath(canonicalUrl) + const contents = await fs.readFile(filePath, 'utf8') + return { + contents, + syntax: inferSassSyntax(filePath), + } + }, + } +} + +export async function resolveAliasSpecifier( + specifier: string, + resolver: CssResolver, + cwd: string, +): Promise { + const resolved = await resolver(specifier, { cwd }) + if (!resolved) { + return undefined + } + if (resolved.startsWith('file://')) { + return ensureSassPath(fileURLToPath(new URL(resolved))) + } + const normalized = path.isAbsolute(resolved) ? resolved : path.resolve(cwd, resolved) + return ensureSassPath(normalized) +} + +export function shouldNormalizeSpecifier(specifier: string): boolean { + const schemeMatch = specifier.match(/^([a-z][\w+.-]*):/i) + if (!schemeMatch) { + return false + } + const scheme = schemeMatch[1].toLowerCase() + if ( + scheme === 'file' || + scheme === 'http' || + scheme === 'https' || + scheme === 'data' || + scheme === 'sass' + ) { + return false + } + return true +} + +function inferSassSyntax(filePath: string): 'scss' | 'indented' { + return filePath.endsWith('.sass') ? 'indented' : 'scss' +} + +export function ensureSassPath(filePath: string): string | undefined { + if (existsSync(filePath)) { + return filePath + } + const ext = path.extname(filePath) + const dir = path.dirname(filePath) + const base = path.basename(filePath, ext) + const partialCandidate = path.join(dir, `_${base}${ext}`) + if (ext && existsSync(partialCandidate)) { + return partialCandidate + } + const indexCandidate = path.join(dir, base, `index${ext}`) + if (ext && existsSync(indexCandidate)) { + return indexCandidate + } + const partialIndexCandidate = path.join(dir, base, `_index${ext}`) + if (ext && existsSync(partialIndexCandidate)) { + return partialIndexCandidate + } + return undefined +} + +export function resolveRelativeSpecifier( + specifier: string, + containingUrl?: URL | null, +): string | undefined { + if (!containingUrl || containingUrl.protocol !== 'file:') { + return undefined + } + if (/^[a-z][\w+.-]*:/i.test(specifier)) { + return undefined + } + const containingPath = fileURLToPath(containingUrl) + const baseDir = path.dirname(containingPath) + const candidate = path.resolve(baseDir, specifier) + return ensureSassPath(candidate) +} + +export const __sassInternals = { + createSassImporter, + resolveAliasSpecifier, + shouldNormalizeSpecifier, + ensureSassPath, + resolveRelativeSpecifier, +} diff --git a/packages/css/test/fixtures/combined/commonjs.js b/packages/css/test/fixtures/combined/commonjs.js new file mode 100644 index 0000000..39b00a8 --- /dev/null +++ b/packages/css/test/fixtures/combined/commonjs.js @@ -0,0 +1,3 @@ +module.exports = { + labelled: 'cjs', +} diff --git a/packages/css/test/fixtures/combined/default-export.ts b/packages/css/test/fixtures/combined/default-export.ts new file mode 100644 index 0000000..1935edd --- /dev/null +++ b/packages/css/test/fixtures/combined/default-export.ts @@ -0,0 +1,5 @@ +export default function DefaultComponent() { + return 'default' +} + +export const namedValue = 'named' diff --git a/packages/css/test/fixtures/combined/named-only.ts b/packages/css/test/fixtures/combined/named-only.ts new file mode 100644 index 0000000..fdb633d --- /dev/null +++ b/packages/css/test/fixtures/combined/named-only.ts @@ -0,0 +1,2 @@ +export const first = 'one' +export const second = () => 'two' diff --git a/packages/css/test/fixtures/sass-paths/_partial.scss b/packages/css/test/fixtures/sass-paths/_partial.scss new file mode 100644 index 0000000..2e29728 --- /dev/null +++ b/packages/css/test/fixtures/sass-paths/_partial.scss @@ -0,0 +1,3 @@ +.partial { + color: hotpink; +} diff --git a/packages/css/test/fixtures/sass-paths/block/_index.scss b/packages/css/test/fixtures/sass-paths/block/_index.scss new file mode 100644 index 0000000..d358317 --- /dev/null +++ b/packages/css/test/fixtures/sass-paths/block/_index.scss @@ -0,0 +1,3 @@ +.block { + margin: 0; +} diff --git a/packages/css/test/fixtures/sass-paths/component/index.scss b/packages/css/test/fixtures/sass-paths/component/index.scss new file mode 100644 index 0000000..bd71206 --- /dev/null +++ b/packages/css/test/fixtures/sass-paths/component/index.scss @@ -0,0 +1,3 @@ +.component { + padding: 4px; +} diff --git a/packages/css/test/fixtures/sass-paths/entry.scss b/packages/css/test/fixtures/sass-paths/entry.scss new file mode 100644 index 0000000..fe37bce --- /dev/null +++ b/packages/css/test/fixtures/sass-paths/entry.scss @@ -0,0 +1 @@ +@use './partial.scss'; diff --git a/packages/css/test/loaderInternals.test.ts b/packages/css/test/loaderInternals.test.ts new file mode 100644 index 0000000..b2c0017 --- /dev/null +++ b/packages/css/test/loaderInternals.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { __loaderInternals } from '../src/loaderInternals.ts' + +test('buildSanitizedQuery strips loader-specific flags', () => { + const { buildSanitizedQuery } = __loaderInternals + const query = '?knighted-css&combined&named-only&foo=bar&no-default&baz=qux' + assert.equal(buildSanitizedQuery(query), '?foo=bar&baz=qux') +}) + +test('shouldEmitCombinedDefault honors skip flag, detection signals, and css modules', () => { + const { shouldEmitCombinedDefault } = __loaderInternals + + assert.equal( + shouldEmitCombinedDefault({ + detection: 'has-default', + request: 'button.tsx', + skipSyntheticDefault: true, + }), + false, + ) + + assert.equal( + shouldEmitCombinedDefault({ + detection: 'has-default', + request: 'button.tsx', + skipSyntheticDefault: false, + }), + true, + ) + + assert.equal( + shouldEmitCombinedDefault({ + detection: 'no-default', + request: 'button.tsx', + skipSyntheticDefault: false, + }), + false, + ) + + assert.equal( + shouldEmitCombinedDefault({ + detection: 'unknown', + request: 'button.tsx', + skipSyntheticDefault: false, + }), + true, + ) + + assert.equal( + shouldEmitCombinedDefault({ + detection: 'unknown', + request: 'styles.css.ts', + skipSyntheticDefault: false, + }), + false, + ) +}) diff --git a/packages/css/test/moduleInfo.test.ts b/packages/css/test/moduleInfo.test.ts new file mode 100644 index 0000000..68476e9 --- /dev/null +++ b/packages/css/test/moduleInfo.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +import { __moduleInfoInternals, detectModuleDefaultExport } from '../src/moduleInfo.ts' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const fixturesDir = path.resolve(__dirname, './fixtures/combined') + +test('detects default exports in source modules', async () => { + const target = path.join(fixturesDir, 'default-export.ts') + const signal = await detectModuleDefaultExport(target) + assert.equal(signal, 'has-default') +}) + +test('detects absence of default exports when named bindings exist', async () => { + const target = path.join(fixturesDir, 'named-only.ts') + const signal = await detectModuleDefaultExport(target) + assert.equal(signal, 'no-default') +}) + +test('returns unknown when module format is not detectable', async () => { + const target = path.join(fixturesDir, 'commonjs.js') + const signal = await detectModuleDefaultExport(target) + assert.equal(signal, 'unknown') +}) + +test('returns unknown for unsupported extensions', async () => { + const target = path.join(__dirname, './fixtures/sass-paths/entry.scss') + const signal = await detectModuleDefaultExport(target) + assert.equal(signal, 'unknown') +}) + +test('returns unknown when source file cannot be read', async () => { + const missing = path.join(fixturesDir, 'does-not-exist.ts') + const signal = await detectModuleDefaultExport(missing) + assert.equal(signal, 'unknown') +}) + +test('falls back to unknown when lexer parse throws', async () => { + __moduleInfoInternals.setLexerOverrides({ + parse() { + throw new Error('boom') + }, + }) + try { + const target = path.join(fixturesDir, 'named-only.ts') + const signal = await detectModuleDefaultExport(target) + assert.equal(signal, 'unknown') + } finally { + __moduleInfoInternals.setLexerOverrides() + } +}) diff --git a/packages/css/test/sassImporter.test.ts b/packages/css/test/sassImporter.test.ts new file mode 100644 index 0000000..508063b --- /dev/null +++ b/packages/css/test/sassImporter.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import test from 'node:test' + +import { __sassInternals, type CssResolver } from '../src/sassInternals.ts' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const sassFixturesDir = path.resolve(__dirname, './fixtures/sass-paths') + +function resetEnv(key: string, value?: string) { + if (typeof value === 'undefined') { + delete process.env[key] + } else { + process.env[key] = value + } +} + +test('sass importer reports debug logs and resolves alias plus relative specifiers', async () => { + const previous = process.env.KNIGHTED_CSS_DEBUG_SASS + process.env.KNIGHTED_CSS_DEBUG_SASS = '1' + const originalError = console.error + const captured: string[] = [] + ;(console as Console).error = (...args: unknown[]) => { + captured.push(args.map(arg => String(arg)).join(' ')) + } + + const resolver: CssResolver = async specifier => { + if (specifier === 'alias:entry') { + return path.join(sassFixturesDir, 'entry.scss') + } + return undefined + } + + try { + const importer = __sassInternals.createSassImporter({ + cwd: sassFixturesDir, + resolver, + }) + assert.ok(importer, 'expected importer to be created') + + const missing = await importer.canonicalize('alias:missing', { + containingUrl: pathToFileURL(path.join(sassFixturesDir, 'entry.scss')), + }) + assert.equal(missing, null) + + const aliasUrl = await importer.canonicalize('alias:entry') + assert.ok(aliasUrl, 'resolved alias should return a file url') + + const relativeUrl = await importer.canonicalize('./partial.scss', { + containingUrl: pathToFileURL(path.join(sassFixturesDir, 'entry.scss')), + }) + assert.ok(relativeUrl, 'relative specifier should resolve via ensureSassPath') + assert.match(relativeUrl!.href, /_partial\.scss$/) + + const ignored = await importer.canonicalize('http://example.com/reset.css', { + containingUrl: pathToFileURL(path.join(sassFixturesDir, 'entry.scss')), + }) + assert.equal(ignored, null) + + const loaded = await importer.load(aliasUrl!) + assert.ok(loaded.contents.includes('@use'), 'load() should read file contents') + assert.equal(loaded.syntax, 'scss') + } finally { + ;(console as Console).error = originalError + resetEnv('KNIGHTED_CSS_DEBUG_SASS', previous) + } + + assert.ok( + captured.some(line => line.includes('canonicalize request')), + 'expected debug log for canonicalize request', + ) + assert.ok( + captured.some(line => line.includes('resolver returned no result')), + 'expected debug log when resolver fails', + ) + assert.ok( + captured.some(line => line.includes('canonical url')), + 'expected debug log when canonical url resolved', + ) +}) + +test('ensureSassPath and relative resolver fallbacks', () => { + const { ensureSassPath, resolveRelativeSpecifier } = __sassInternals + const entryUrl = pathToFileURL(path.join(sassFixturesDir, 'entry.scss')) + + const direct = ensureSassPath(path.join(sassFixturesDir, 'entry.scss')) + assert.equal(direct, path.join(sassFixturesDir, 'entry.scss')) + + const partial = ensureSassPath(path.join(sassFixturesDir, 'partial.scss')) + assert.equal(partial, path.join(sassFixturesDir, '_partial.scss')) + + const index = ensureSassPath(path.join(sassFixturesDir, 'component.scss')) + assert.equal(index, path.join(sassFixturesDir, 'component', 'index.scss')) + + const partialIndex = ensureSassPath(path.join(sassFixturesDir, 'block.scss')) + assert.equal(partialIndex, path.join(sassFixturesDir, 'block', '_index.scss')) + + const relative = resolveRelativeSpecifier('./partial.scss', entryUrl) + assert.ok(relative?.endsWith('_partial.scss')) + + const schemeIgnored = resolveRelativeSpecifier('http://example.com/foo.scss', entryUrl) + assert.equal(schemeIgnored, undefined) +}) + +test('resolveAliasSpecifier normalizes returned file urls', async () => { + const target = path.join(sassFixturesDir, 'entry.scss') + const asFileUrl = pathToFileURL(target).href + const result = await __sassInternals.resolveAliasSpecifier( + 'alias:file-url', + async () => asFileUrl, + sassFixturesDir, + ) + assert.equal(result, target) +}) diff --git a/packages/playwright/package.json b/packages/playwright/package.json index 5392d88..836aede 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -14,7 +14,7 @@ "pretest": "npm run build" }, "dependencies": { - "@knighted/css": "1.0.0-rc.5", + "@knighted/css": "1.0.0-rc.6", "@knighted/jsx": "^1.2.1", "lit": "^3.2.1", "react": "^19.0.0",