diff --git a/package-lock.json b/package-lock.json index a9457a9..c7d9558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,15 @@ "version": "1.0.6", "license": "MIT", "dependencies": { + "chalk": "^4.1.2", + "postcss": "^8.4.47", "postcss-import": "^16.1.0", - "postcss-selector-parser": "^6.1.2" + "postcss-selector-parser": "^6.1.2", + "table": "^6.9.0", + "ts-morph": "^27.0.2" + }, + "bin": { + "mistcss": "lib/bin.js" }, "devDependencies": { "@tsconfig/node18": "^18.2.4", @@ -18,7 +25,6 @@ "@types/postcss-import": "^14.0.3", "eslint": "^8.56.0", "husky": "^9.0.11", - "postcss": "^8.4.47", "postcss-cli": "^11.0.0", "prettier": "^3.2.5", "tsx": "^4.19.2", @@ -532,6 +538,27 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -834,6 +861,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ts-morph/common": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.28.1.tgz", + "integrity": "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==", + "license": "MIT", + "dependencies": { + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", + "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tsconfig/node18": { "version": "18.2.4", "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.4.tgz", @@ -854,6 +907,7 @@ "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -984,6 +1038,7 @@ "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1035,7 +1090,6 @@ "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" @@ -1045,7 +1099,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1088,6 +1141,15 @@ "node": "*" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1175,7 +1237,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -1241,11 +1302,16 @@ "node": ">=12" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1258,7 +1324,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -1377,7 +1442,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/esbuild": { @@ -1448,6 +1512,7 @@ "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -1643,7 +1708,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -1677,6 +1741,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -1947,7 +2027,6 @@ "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" @@ -2089,7 +2168,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2279,6 +2357,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "license": "MIT" + }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -2529,6 +2613,12 @@ "node": ">=6" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2654,6 +2744,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.0", @@ -2930,6 +3021,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -3111,6 +3211,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3138,7 +3255,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -3153,7 +3269,6 @@ "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" @@ -3205,7 +3320,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -3226,6 +3340,44 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -3247,6 +3399,52 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinypool": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", @@ -3280,12 +3478,23 @@ "node": ">=8.0" } }, + "node_modules/ts-morph": { + "version": "27.0.2", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-27.0.2.tgz", + "integrity": "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.28.1", + "code-block-writer": "^13.0.3" + } + }, "node_modules/tsx": { "version": "4.19.2", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.23.0", "get-tsconfig": "^4.7.5" diff --git a/package.json b/package.json index ff1032b..e6692de 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,10 @@ "version": "1.0.6", "description": "", "exports": "./lib/index.js", + "bin": "./lib/bin.js", "scripts": { "build": "rm -rf lib && tsc", + "cli:test": "node --import tsx src/bin.ts test/mist.css --stats --tsconfig test/tsconfig.stats.json --pretty", "format": "prettier --write .", "lint": "eslint", "test": "node --import tsx --test src/*.test.ts && npm run build && postcss test/mist.css", @@ -27,7 +29,6 @@ "@types/postcss-import": "^14.0.3", "eslint": "^8.56.0", "husky": "^9.0.11", - "postcss": "^8.4.47", "postcss-cli": "^11.0.0", "prettier": "^3.2.5", "tsx": "^4.19.2", @@ -35,7 +36,11 @@ "vitest": "^1.5.0" }, "dependencies": { + "chalk": "^4.1.2", + "postcss": "^8.4.47", "postcss-import": "^16.1.0", - "postcss-selector-parser": "^6.1.2" + "postcss-selector-parser": "^6.1.2", + "table": "^6.9.0", + "ts-morph": "^27.0.2" } } diff --git a/src/bin.ts b/src/bin.ts new file mode 100644 index 0000000..e64cdc5 --- /dev/null +++ b/src/bin.ts @@ -0,0 +1,149 @@ +#!/usr/bin/env node +import util = require('node:util') +import chalk = require('chalk') +type Parsed = import('./index').Parsed +import index = require('./index') +import statsModule = require('./stats') +const tableLib = require('table') as typeof import('table') + +const { parseArgs } = util +const { parseFile } = index +const { stats } = statsModule + +type StatsResult = ReturnType + +function formatCount(count: number): string { + if (count === 0) return chalk.red(String(count)) + return String(count) +} + +function formatSelector(selector: string): string { + if (!selector || selector.startsWith(' ')) return selector + return chalk.bold(selector) +} + +const { values, positionals } = parseArgs({ + args: process.argv.slice(2), + options: { + stats: { + type: 'boolean', + default: false, + }, + tsconfig: { + type: 'string', + }, + pretty: { + type: 'boolean', + default: false, + }, + }, + strict: true, + allowPositionals: true, +}) + +function formatPrettyStats(parsed: Parsed, result: StatsResult): string { + const rows: string[][] = [[chalk.cyan('Selector'), chalk.cyan('Count')]] + + const sortedEntries = Object.entries(parsed).sort( + ([a], [b]) => result[b].count - result[a].count, + ) + + for (const [key, entry] of sortedEntries) { + const stat = result[key] + if (!stat) continue + + let title = entry.tag + if (entry.rootAttribute) { + const rootValues = Object.keys(stat.attributes[entry.rootAttribute] ?? {}) + const rootValue = rootValues[0] + title = rootValue + ? `${entry.tag}[${entry.rootAttribute}="${rootValue}"]` + : `${entry.tag}[${entry.rootAttribute}]` + } + rows.push([formatSelector(title), formatCount(stat.count)]) + + for (const [attribute, values] of Object.entries(stat.attributes)) { + if (attribute === entry.rootAttribute) continue + for (const [value, count] of Object.entries(values)) { + rows.push([ + formatSelector(` [${attribute}="${value}"]`), + formatCount(count), + ]) + } + } + + for (const [attribute, count] of Object.entries(stat.booleanAttributes)) { + rows.push([formatSelector(` [${attribute}]`), formatCount(count)]) + } + + for (const [property, count] of Object.entries(stat.properties)) { + rows.push([formatSelector(` style["${property}"]`), formatCount(count)]) + } + + rows.push(['', '']) + } + + if (rows.length > 1) { + const last = rows[rows.length - 1] + if (last[0] === '' && last[1] === '') rows.pop() + } + + return tableLib.table(rows, { + columns: { + 1: { alignment: 'right' }, + }, + drawHorizontalLine: (lineIndex, rowCount) => + lineIndex === 0 || lineIndex === 1 || lineIndex === rowCount, + }) +} + +async function main(): Promise { + if (positionals.length === 0) { + console.error('Error: Please provide a CSS file path') + console.error( + 'Usage: mistcss [--stats] [--tsconfig ] [--pretty]', + ) + process.exit(1) + } + + const cssPath = positionals[0] + const parsed = await parseFile(cssPath) + + if (values.stats) { + const statsResult = stats({ parsed, tsConfigFilePath: values.tsconfig }) + if (values.pretty) { + console.log(formatPrettyStats(parsed, statsResult)) + } else { + console.log(JSON.stringify(statsResult, null, 2)) + } + return + } + + // Convert Sets to Arrays for JSON serialization + const serializable = Object.fromEntries( + (Object.entries(parsed) as [string, Parsed[string]][]).map( + ([key, value]) => [ + key, + { + ...value, + attributes: Object.fromEntries( + Object.entries(value.attributes).map(([k, v]) => [ + k, + Array.from(v), + ]), + ), + booleanAttributes: Array.from(value.booleanAttributes), + properties: Array.from(value.properties), + }, + ], + ), + ) + + console.log(JSON.stringify(serializable, null, 2)) +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + process.exit(1) +}) diff --git a/src/index.ts b/src/index.ts index d6ab7f1..5ea19f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,14 @@ import fs = require('node:fs') -import { type PluginCreator } from 'postcss' +import postcss = require('postcss') import selectorParser = require('postcss-selector-parser') import atImport = require('postcss-import') import path = require('node:path') -const key = require('./key') +import keyModule = require('./key') + +type PluginCreator = + import('postcss').PluginCreator +type Root = import('postcss').Root +const key = keyModule as unknown as (selector: selectorParser.Node) => string declare module 'postcss-selector-parser' { // For some reasons these aren't avaiblable in this module types @@ -14,7 +19,7 @@ declare module 'postcss-selector-parser' { } } -type Parsed = Record< +export type Parsed = Record< string, { tag: string @@ -164,6 +169,71 @@ function initialParsedValue(): Parsed[keyof Parsed] { } } +function parseRoot(root: Root): Parsed { + const parsed: Parsed = {} + let current: Parsed[keyof Parsed] = initialParsedValue() + + root.walkRules((rule) => { + const entriesForRule: Array = [] + + selectorParser((selectors) => { + selectors.walk((selector) => { + if (selector.type === 'tag') { + current = parsed[key(selector)] = initialParsedValue() + current.tag = selector.toString().toLowerCase() + const next = selector.next() + if (next?.type === 'attribute') { + const { attribute, value } = next as selectorParser.Attribute + if (value) current.rootAttribute = attribute + } + entriesForRule.push(current) + } + + if (selector.type === 'attribute') { + const { attribute, value } = selector as selectorParser.Attribute + if (value) { + const values = (current.attributes[attribute] ??= new Set()) + values.add(value) + } else { + current.booleanAttributes.add(attribute) + } + } + }) + }).processSync(rule.selector, { + lossless: false, + }) + + // Apply declarations to all entries collected for this rule + // If we collected entries (multi-selector), apply to all + // Otherwise apply to current (nested selector case) + rule.walkDecls(({ prop }) => { + if (prop.startsWith('--') && prop !== '--apply') { + if (entriesForRule.length > 0) { + for (const entry of entriesForRule) { + entry.properties.add(prop) + } + } else { + current.properties.add(prop) + } + } + }) + }) + + return parsed +} + +export function parse(css: string): Parsed { + return parseRoot(postcss.parse(css)) +} + +export async function parseFile(cssFilePath: string): Promise { + const css = fs.readFileSync(cssFilePath, 'utf-8') + const result = await postcss([atImport()]).process(css, { + from: cssFilePath, + }) + return parseRoot(postcss.parse(result.css)) +} + const _mistcss: PluginCreator<{}> = (_opts = {}) => { return { postcssPlugin: '_mistcss', @@ -172,41 +242,8 @@ const _mistcss: PluginCreator<{}> = (_opts = {}) => { const from = helper.result.opts.from if (from === undefined || path.basename(from) !== 'mist.css') return - const parsed: Parsed = {} - let current: Parsed[keyof Parsed] = initialParsedValue() - root.walkRules((rule) => { - selectorParser((selectors) => { - selectors.walk((selector) => { - if (selector.type === 'tag') { - current = parsed[key(selector)] = initialParsedValue() - current.tag = selector.toString().toLowerCase() - const next = selector.next() - if (next?.type === 'attribute') { - const { attribute, value } = next as selectorParser.Attribute - if (value) current.rootAttribute = attribute - } - } - - if (selector.type === 'attribute') { - const { attribute, value } = selector as selectorParser.Attribute - if (value) { - const values = (current.attributes[attribute] ??= - new Set()) - values.add(value) - } else { - current.booleanAttributes.add(attribute) - } - } - }) - }).processSync(rule.selector, { - lossless: false, - }) - - rule.walkDecls(({ prop }) => { - if (prop.startsWith('--') && prop !== '--apply') - current.properties.add(prop) - }) - }) + const css = root.toString() + const parsed = parse(css) const rendered = render(parsed) const to = path.resolve(from, '../mist.d.ts') fs.writeFileSync(to, rendered, 'utf-8') @@ -225,4 +262,8 @@ const mistcss: PluginCreator<{}> = (_opts = {}) => { mistcss.postcss = true +export { mistcss as default } module.exports = mistcss +module.exports.parse = parse +module.exports.parseFile = parseFile +module.exports.default = mistcss diff --git a/src/key.test.ts b/src/key.test.ts index 19860cc..b26c7fb 100644 --- a/src/key.test.ts +++ b/src/key.test.ts @@ -1,11 +1,11 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import selectorParser = require('postcss-selector-parser'); -import key = require('./key'); +import assert = require('node:assert/strict') +import selectorParser = require('postcss-selector-parser') +import key = require('./key') const parser = selectorParser() +const test: typeof import('node:test').test = require('node:test') -test("key", async (t) => { +test('key', async (t) => { const arr: [string, string | ErrorConstructor][] = [ ['div', 'div'], ['div[data-foo="bar"]', 'div_data_foo_bar'], @@ -16,7 +16,7 @@ test("key", async (t) => { ['div[data-1]', 'div_data_1'], [' div[ data-foo ] ', 'div_data_foo'], ['div:not([data-component])', 'div'], - ['div[data-foo=" bar"]', 'div_data_foo__bar'] + ['div[data-foo=" bar"]', 'div_data_foo__bar'], ] for (const [input, expected] of arr) { await t.test(`${input} → ${expected}`, () => { diff --git a/src/parse.test.ts b/src/parse.test.ts new file mode 100644 index 0000000..de7ebc1 --- /dev/null +++ b/src/parse.test.ts @@ -0,0 +1,210 @@ +import assert = require('node:assert/strict') +import index = require('./index') +import fs = require('node:fs') +import os = require('node:os') +import path = require('node:path') + +const { parse, parseFile } = index +const test: typeof import('node:test').test = require('node:test') + +test('parse', async (t) => { + await t.test('parses basic button selector', () => { + const css = 'button { color: red; }' + const parsed = parse(css) + + assert.equal(Object.keys(parsed).length, 1) + assert.ok(parsed.button) + assert.equal(parsed.button.tag, 'button') + assert.equal(parsed.button.rootAttribute, '') + assert.equal(Object.keys(parsed.button.attributes).length, 0) + assert.equal(parsed.button.booleanAttributes.size, 0) + assert.equal(parsed.button.properties.size, 0) + }) + + await t.test('parses button with data-variant attribute', () => { + const css = ` + button[data-variant='primary'] { + background: blue; + } + ` + const parsed = parse(css) + + assert.ok(parsed.button_data_variant_primary) + assert.equal(parsed.button_data_variant_primary.tag, 'button') + assert.equal( + parsed.button_data_variant_primary.rootAttribute, + 'data-variant', + ) + assert.ok(parsed.button_data_variant_primary.attributes['data-variant']) + assert.ok( + parsed.button_data_variant_primary.attributes['data-variant'].has( + 'primary', + ), + ) + }) + + await t.test('parses multiple variants', () => { + const css = ` + button { + padding: 1rem; + + &[data-variant='primary'] { + background: blue; + } + + &[data-variant='secondary'] { + background: gray; + } + } + ` + const parsed = parse(css) + + assert.ok(parsed.button) + assert.equal(parsed.button.tag, 'button') + assert.ok(parsed.button.attributes['data-variant']) + assert.ok(parsed.button.attributes['data-variant'].has('primary')) + assert.ok(parsed.button.attributes['data-variant'].has('secondary')) + assert.equal(parsed.button.attributes['data-variant'].size, 2) + }) + + await t.test('parses boolean attributes', () => { + const css = ` + button[data-disabled] { + opacity: 0.5; + } + ` + const parsed = parse(css) + + assert.ok(parsed.button_data_disabled) + assert.equal(parsed.button_data_disabled.tag, 'button') + assert.ok( + parsed.button_data_disabled.booleanAttributes.has('data-disabled'), + ) + }) + + await t.test('parses CSS custom properties', () => { + const css = ` + button { + --button-color: red; + --button-size: large; + color: var(--button-color); + } + ` + const parsed = parse(css) + + assert.ok(parsed.button) + assert.ok(parsed.button.properties.has('--button-color')) + assert.ok(parsed.button.properties.has('--button-size')) + assert.equal(parsed.button.properties.size, 2) + }) + + await t.test('parses complex component with data-component attribute', () => { + const css = ` + div[data-component='card'] { + background: gray; + + &[data-size='sm'] { + padding: 0.5rem; + } + + &[data-size='xl'] { + padding: 2rem; + } + } + ` + const parsed = parse(css) + + assert.ok(parsed.div_data_component_card) + assert.equal(parsed.div_data_component_card.tag, 'div') + assert.equal(parsed.div_data_component_card.rootAttribute, 'data-component') + assert.ok(parsed.div_data_component_card.attributes['data-component']) + assert.ok( + parsed.div_data_component_card.attributes['data-component'].has('card'), + ) + assert.ok(parsed.div_data_component_card.attributes['data-size']) + assert.ok(parsed.div_data_component_card.attributes['data-size'].has('sm')) + assert.ok(parsed.div_data_component_card.attributes['data-size'].has('xl')) + }) + + await t.test('parses uppercase tag names as lowercase', () => { + const css = 'DIV { color: red; }' + const parsed = parse(css) + + assert.ok(parsed.div) + assert.equal(parsed.div.tag, 'div') + }) + + await t.test('handles empty CSS', () => { + const css = '' + const parsed = parse(css) + + assert.equal(Object.keys(parsed).length, 0) + }) + + await t.test('ignores --apply property', () => { + const css = ` + button { + --apply: flex; + --color: red; + } + ` + const parsed = parse(css) + + assert.ok(parsed.button) + assert.ok(parsed.button.properties.has('--color')) + assert.ok(!parsed.button.properties.has('--apply')) + assert.equal(parsed.button.properties.size, 1) + }) + + await t.test('handles multi-selector rules', () => { + const css = ` + button, a { + --shared-color: blue; + } + ` + const parsed = parse(css) + + assert.ok(parsed.button, 'Should have button entry') + assert.ok(parsed.a, 'Should have a entry') + assert.ok( + parsed.button.properties.has('--shared-color'), + 'button should have --shared-color', + ) + assert.ok( + parsed.a.properties.has('--shared-color'), + 'a should have --shared-color', + ) + }) + + await t.test('parseFile resolves @import rules', async () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'mistcss-parse-file-'), + ) + + try { + const importedCssPath = path.join(tempDir, 'button.mist.css') + const cssPath = path.join(tempDir, 'mist.css') + + fs.writeFileSync( + importedCssPath, + ` + button[data-variant='primary'] { + color: red; + } + `, + ) + fs.writeFileSync(cssPath, `@import './button.mist.css';`) + + const parsed = await parseFile(cssPath) + + assert.ok(parsed.button_data_variant_primary) + assert.equal(parsed.button_data_variant_primary.tag, 'button') + assert.equal( + parsed.button_data_variant_primary.rootAttribute, + 'data-variant', + ) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/stats.test.ts b/src/stats.test.ts new file mode 100644 index 0000000..8bbccc1 --- /dev/null +++ b/src/stats.test.ts @@ -0,0 +1,307 @@ +import assert = require('node:assert/strict') +import statsModule = require('./stats') +import indexModule = require('./index') +import fs = require('node:fs') +import path = require('node:path') +import os = require('node:os') + +const { stats } = statsModule +const { parse } = indexModule +const test: typeof import('node:test').test = require('node:test') + +function getCounts( + result: Record, +): Record { + return Object.fromEntries( + Object.entries(result).map(([key, value]) => [key, value.count]), + ) +} + +test('stats', async (t) => { + let tempDir = '' + let tsconfigPath = '' + + const writeTestFile = (contents: string) => { + const testFile = path.join(tempDir, 'test.tsx') + fs.writeFileSync(testFile, contents) + } + + const parseSelectors = (selectors: string[]) => + parse(selectors.map((selector) => `${selector} {}`).join('\n')) + + t.beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mistcss-test-')) + tsconfigPath = path.join(tempDir, 'tsconfig.json') + fs.writeFileSync( + tsconfigPath, + JSON.stringify({ + compilerOptions: { + jsx: 'react', + target: 'ES2015', + }, + }), + ) + }) + + t.afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + await t.test('counts tag usage in a simple TSX file', () => { + writeTestFile(` + export function App() { + return ( +
+ + + Text +
+ ) + } + `) + + const parsed = parseSelectors(['button', 'div', 'span']) + + const result = stats({ parsed, tsConfigFilePath: tsconfigPath }) + + assert.deepEqual(getCounts(result), { + button: 2, + div: 1, + span: 1, + }) + }) + + await t.test('returns zero counts for unused tags', () => { + writeTestFile(` + export function App() { + return
Hello
+ } + `) + + const parsed = parseSelectors(['button', 'div']) + + const result = stats({ parsed, tsConfigFilePath: tsconfigPath }) + + assert.deepEqual(getCounts(result), { + button: 0, + div: 1, + }) + }) + + await t.test('handles self-closing JSX elements', () => { + writeTestFile(` + export function App() { + return ( +
+ + +
+
+ ) + } + `) + + const parsed = parseSelectors(['input', 'br', 'div']) + + const result = stats({ parsed, tsConfigFilePath: tsconfigPath }) + + assert.deepEqual(getCounts(result), { + input: 2, + br: 1, + div: 1, + }) + }) + + await t.test('counts by rootAttribute values', () => { + writeTestFile(` + export function App() { + return ( +
+ + + + +
+ ) + } + `) + + const parsed = parseSelectors([ + 'button', + "button[data-variant='primary']", + "button[data-variant='secondary']", + ]) + + const result = stats({ parsed, tsConfigFilePath: tsconfigPath }) + + assert.deepEqual(getCounts(result), { + button: 1, + button_data_variant_primary: 2, + button_data_variant_secondary: 1, + }) + + assert.deepEqual(result.button_data_variant_primary.attributes, { + 'data-variant': { + primary: 2, + }, + }) + assert.deepEqual(result.button_data_variant_secondary.attributes, { + 'data-variant': { + secondary: 1, + }, + }) + }) + + await t.test( + 'distinguishes between elements with and without rootAttribute', + () => { + writeTestFile(` + export function App() { + return ( +
+
Regular div
+
Card component
+
Another card
+
+ ) + } + `) + + const parsed = parseSelectors(['div', "div[data-component='card']"]) + + const result = stats({ parsed, tsConfigFilePath: tsconfigPath }) + + assert.deepEqual(getCounts(result), { + div: 2, + div_data_component_card: 2, + }) + }, + ) + + await t.test('counts boolean-attribute variants from parsed values', () => { + writeTestFile(` + export function App() { + return ( +
+ + + +
+ ) + } + `) + + const parsed = parseSelectors(['button', 'button[data-disabled]']) + + const result = stats({ parsed, tsConfigFilePath: tsconfigPath }) + + assert.deepEqual(getCounts(result), { + button: 1, + button_data_disabled: 2, + }) + assert.deepEqual(result.button_data_disabled.booleanAttributes, { + 'data-disabled': 2, + }) + }) + + await t.test( + 'does not fallback to base for unknown rootAttribute values', + () => { + writeTestFile(` + export function App() { + return ( +
+ + + +
+ ) + } + `) + + const parsed = parseSelectors([ + 'button', + "button[data-variant='primary']", + ]) + + const result = stats({ parsed, tsConfigFilePath: tsconfigPath }) + + assert.deepEqual(getCounts(result), { + button: 1, + button_data_variant_primary: 1, + }) + assert.deepEqual(result.button_data_variant_primary.attributes, { + 'data-variant': { + primary: 1, + }, + }) + }, + ) + + await t.test('does not count PascalCase JSX components as HTML tags', () => { + writeTestFile(` + function Button() { + return + } + + export function App() { + return ( +
+ +
+ ) + } + `) + + const parsed = parseSelectors(['button']) + + const result = stats({ parsed, tsConfigFilePath: tsconfigPath }) + + assert.deepEqual(getCounts(result), { + button: 2, + }) + }) + + await t.test( + 'keeps zero counts for never used attributes and properties', + () => { + writeTestFile(` + export function App() { + return ( + + ) + } + `) + + const parsed = parseSelectors(["button[data-variant='primary']"]) + parsed.button_data_variant_primary.attributes['data-variant'].add( + 'secondary', + ) + parsed.button_data_variant_primary.booleanAttributes.add('data-disabled') + parsed.button_data_variant_primary.properties.add('--highlightColor') + parsed.button_data_variant_primary.properties.add('--unusedProp') + + const result = stats({ parsed, tsConfigFilePath: tsconfigPath }) + + assert.deepEqual(result.button_data_variant_primary, { + count: 1, + attributes: { + 'data-variant': { + primary: 1, + secondary: 0, + }, + }, + booleanAttributes: { + 'data-disabled': 0, + }, + properties: { + '--highlightColor': 1, + '--unusedProp': 0, + }, + }) + }, + ) +}) diff --git a/src/stats.ts b/src/stats.ts new file mode 100644 index 0000000..8433aeb --- /dev/null +++ b/src/stats.ts @@ -0,0 +1,348 @@ +const tsMorph = require('ts-morph') as typeof import('ts-morph') + +type Parsed = import('./index').Parsed +type ParsedEntry = Parsed[string] +type JsxAttribute = import('ts-morph').JsxAttribute +type JsxAttributeLike = import('ts-morph').JsxAttributeLike +type JsxOpeningElement = import('ts-morph').JsxOpeningElement +type JsxSelfClosingElement = import('ts-morph').JsxSelfClosingElement +type Expression = import('ts-morph').Expression +type JsxExpression = import('ts-morph').JsxExpression +type ObjectLiteralExpression = import('ts-morph').ObjectLiteralExpression +type PropertyAssignment = import('ts-morph').PropertyAssignment +type ShorthandPropertyAssignment = + import('ts-morph').ShorthandPropertyAssignment + +export type Stats = Record< + string, + { + count: number + attributes: Record> + booleanAttributes: Record + properties: Record + } +> + +type StatsOptions = { + parsed: Parsed + tsConfigFilePath?: string +} + +type VariantMatcher = { + key: string + rootAttribute: string + expectedValues: Set +} + +type TagMatchers = { + baseKey?: string + unconstrainedFallbackKey?: string + discriminatorRootAttributes: Set + booleanDiscriminatorAttributes: Set + variants: VariantMatcher[] + booleanVariants: Array<{ + key: string + requiredAttributes: Set + }> +} + +const { Project, SyntaxKind } = tsMorph + +export function stats({ parsed, tsConfigFilePath }: StatsOptions): Stats { + const project = tsConfigFilePath + ? new Project({ tsConfigFilePath }) + : new Project() + const matchersByTag = buildMatchersByTag(parsed) + const counts = initializeStats(parsed) + + // Get all source files in the project + const sourceFiles = project.getSourceFiles() + + // Iterate through all source files + for (const sourceFile of sourceFiles) { + // Find all JSX elements + const jsxElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement) + const jsxSelfClosingElements = sourceFile.getDescendantsOfKind( + SyntaxKind.JsxSelfClosingElement, + ) + + // Count JSX elements + for (const element of jsxElements) { + const openingElement = element.getOpeningElement() + countElement(openingElement, matchersByTag, parsed, counts) + } + + // Count self-closing JSX elements + for (const element of jsxSelfClosingElements) { + countElement(element, matchersByTag, parsed, counts) + } + } + + return counts +} + +function countElement( + element: JsxOpeningElement | JsxSelfClosingElement, + matchersByTag: Record, + parsed: Parsed, + counts: Stats, +): void { + const rawTagName = element.getTagNameNode().getText() + if (!isIntrinsicTagName(rawTagName)) return + const tagName = rawTagName.toLowerCase() + + const matchers = matchersByTag[tagName] + if (!matchers) return + + // Get attributes from the JSX element + const attributes = element.getAttributes() + const attributesByName = getAttributesByName(attributes) + + const matchedKey = classifyElement(matchers, attributesByName) + if (!matchedKey) return + + const entry = parsed[matchedKey] + if (!entry) return + + counts[matchedKey].count++ + incrementEntryStats(entry, counts[matchedKey], attributesByName) +} + +function initializeStats(parsed: Parsed): Stats { + const counts: Stats = {} + + for (const [key, entry] of Object.entries(parsed)) { + const attributes: Record> = {} + for (const [attribute, values] of Object.entries(entry.attributes)) { + attributes[attribute] = Object.fromEntries( + Array.from(values).map((value) => [value, 0]), + ) + } + + const booleanAttributes: Record = Object.fromEntries( + Array.from(entry.booleanAttributes).map((attribute) => [attribute, 0]), + ) + + const properties: Record = Object.fromEntries( + Array.from(entry.properties).map((property) => [property, 0]), + ) + + counts[key] = { + count: 0, + attributes, + booleanAttributes, + properties, + } + } + + return counts +} + +function incrementEntryStats( + entry: ParsedEntry, + entryStats: Stats[string], + attributesByName: Map, +): void { + for (const [attribute, values] of Object.entries(entry.attributes)) { + const jsxAttribute = attributesByName.get(attribute) + if (!jsxAttribute) continue + + const value = getAttributeStringValue(jsxAttribute) + if (!value) continue + + if (values.has(value)) { + entryStats.attributes[attribute][value]++ + } + } + + for (const attribute of entry.booleanAttributes) { + if (attributesByName.has(attribute)) { + entryStats.booleanAttributes[attribute]++ + } + } + + const styleAttribute = attributesByName.get('style') + if (!styleAttribute) return + + for (const property of getStylePropertyNames(styleAttribute)) { + if (property in entryStats.properties) { + entryStats.properties[property]++ + } + } +} + +function classifyElement( + matchers: TagMatchers, + attributesByName: Map, +): string | undefined { + for (const variant of matchers.variants) { + const attr = attributesByName.get(variant.rootAttribute) + if (!attr) continue + const initializer = attr.getInitializer() + if (!initializer) continue + const value = initializer.getText().replace(/^["']|["']$/g, '') + if (variant.expectedValues.has(value)) { + return variant.key + } + } + + for (const booleanVariant of matchers.booleanVariants) { + const isMatch = Array.from(booleanVariant.requiredAttributes).every( + (attributeName) => attributesByName.has(attributeName), + ) + if (isMatch) { + return booleanVariant.key + } + } + + const hasDiscriminatorAttribute = Array.from( + matchers.discriminatorRootAttributes, + ).some((attributeName) => attributesByName.has(attributeName)) + if (hasDiscriminatorAttribute) return undefined + + const hasBooleanDiscriminatorAttribute = Array.from( + matchers.booleanDiscriminatorAttributes, + ).some((attributeName) => attributesByName.has(attributeName)) + if (hasBooleanDiscriminatorAttribute) return undefined + + if (matchers.baseKey) { + return matchers.baseKey + } + + if (matchers.unconstrainedFallbackKey) { + return matchers.unconstrainedFallbackKey + } + + return undefined +} + +function buildMatchersByTag(parsed: Parsed): Record { + const matchersByTag: Record = {} + + for (const [key, entry] of Object.entries(parsed)) { + const tagMatchers = (matchersByTag[entry.tag] ??= { + baseKey: undefined, + unconstrainedFallbackKey: undefined, + discriminatorRootAttributes: new Set(), + booleanDiscriminatorAttributes: new Set(), + variants: [], + booleanVariants: [], + }) + + if (entry.rootAttribute) { + const expectedValues = + entry.attributes[entry.rootAttribute] ?? new Set() + tagMatchers.discriminatorRootAttributes.add(entry.rootAttribute) + tagMatchers.variants.push({ + key, + rootAttribute: entry.rootAttribute, + expectedValues, + }) + continue + } + + if (entry.booleanAttributes.size > 0) { + const requiredAttributes = new Set(entry.booleanAttributes) + requiredAttributes.forEach((attribute) => { + tagMatchers.booleanDiscriminatorAttributes.add(attribute) + }) + tagMatchers.booleanVariants.push({ + key, + requiredAttributes, + }) + continue + } + + if (key === entry.tag) { + tagMatchers.baseKey = key + } else if (!tagMatchers.unconstrainedFallbackKey) { + tagMatchers.unconstrainedFallbackKey = key + } + } + + return matchersByTag +} + +function getAttributeStringValue(attribute: JsxAttribute): string | undefined { + const initializer = attribute.getInitializer() + if (!initializer) return undefined + + if (initializer.getKind() === SyntaxKind.StringLiteral) { + return (initializer as any).getLiteralText() + } + + if (initializer.getKind() === SyntaxKind.JsxExpression) { + const expression = (initializer as JsxExpression).getExpression() + if (!expression) return undefined + return getStringFromExpression(expression) + } + + return undefined +} + +function getStringFromExpression(expression: Expression): string | undefined { + if (expression.getKind() === SyntaxKind.StringLiteral) { + return (expression as any).getLiteralText() + } + + if (expression.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral) { + return (expression as any).getLiteralText() + } + + return undefined +} + +function getStylePropertyNames(styleAttribute: JsxAttribute): Set { + const propertyNames = new Set() + const initializer = styleAttribute.getInitializer() + if (!initializer || initializer.getKind() !== SyntaxKind.JsxExpression) { + return propertyNames + } + + const expression = (initializer as JsxExpression).getExpression() + if ( + !expression || + expression.getKind() !== SyntaxKind.ObjectLiteralExpression + ) { + return propertyNames + } + + for (const property of ( + expression as ObjectLiteralExpression + ).getProperties()) { + if (property.getKind() === SyntaxKind.PropertyAssignment) { + const name = (property as PropertyAssignment).getNameNode().getText() + propertyNames.add(stripQuotes(name)) + } + + if (property.getKind() === SyntaxKind.ShorthandPropertyAssignment) { + propertyNames.add((property as ShorthandPropertyAssignment).getName()) + } + } + + return propertyNames +} + +function stripQuotes(value: string): string { + return value.replace(/^["']|["']$/g, '') +} + +function getAttributesByName( + attributes: JsxAttributeLike[], +): Map { + const attributesByName = new Map() + + for (const attribute of attributes) { + if (attribute.getKind() !== SyntaxKind.JsxAttribute) continue + const jsxAttribute = attribute as JsxAttribute + attributesByName.set(jsxAttribute.getNameNode().getText(), jsxAttribute) + } + + return attributesByName +} + +function isIntrinsicTagName(tagName: string): boolean { + const firstChar = tagName[0] + if (!firstChar) return false + return firstChar === firstChar.toLowerCase() +} diff --git a/test/index.tsx b/test/index.tsx new file mode 100644 index 0000000..2e9d9b0 --- /dev/null +++ b/test/index.tsx @@ -0,0 +1,18 @@ +// @ts-nocheck +export function App() { + return ( +
+ + + + +
+ Small card +
+
+ XL card +
+
Card title
+
+ ) +} diff --git a/test/tsconfig.stats.json b/test/tsconfig.stats.json new file mode 100644 index 0000000..ee98c0e --- /dev/null +++ b/test/tsconfig.stats.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "jsx": "react", + "target": "ES2015" + }, + "include": ["./index.tsx"] +} diff --git a/tsconfig.json b/tsconfig.json index bcdba3a..037c124 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@tsconfig/node18/tsconfig.json", "compilerOptions": { - "outDir": "./lib", + "outDir": "./lib" }, + "exclude": ["test/index.tsx"] }