From 3321b1f937bcd5ad38c22abafaca6785a7cb8c5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:06:59 +0000 Subject: [PATCH 01/16] Initial plan From fc53e2f163786d3b2609c0af6bacf1c6dc1568c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:08:30 +0000 Subject: [PATCH 02/16] Initial plan for CLI and parser function implementation Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> --- package-lock.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/package-lock.json b/package-lock.json index a9457a9..7083340 100644 --- a/package-lock.json +++ b/package-lock.json @@ -854,6 +854,7 @@ "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -984,6 +985,7 @@ "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1448,6 +1450,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", @@ -2654,6 +2657,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.0", @@ -3286,6 +3290,7 @@ "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.23.0", "get-tsconfig": "^4.7.5" From 9a02c0146a059dba1da4ce9ee74f845612e59489 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:11:29 +0000 Subject: [PATCH 03/16] Add CSS parser function and CLI implementation Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> --- package.json | 1 + src/cli.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 src/cli.ts diff --git a/package.json b/package.json index ff1032b..775aa26 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.0.6", "description": "", "exports": "./lib/index.js", + "bin": "./lib/cli.js", "scripts": { "build": "rm -rf lib && tsc", "format": "prettier --write .", diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..86b0c9c --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import { parseArgs } from 'node:util' +import fs = require('node:fs') +import { parse } from './index' + +async function main() { + // Parse command line arguments (no args expected for now) + parseArgs({ + args: process.argv.slice(2), + options: {}, + strict: true, + }) + + // Read CSS from stdin + let css = '' + + if (process.stdin.isTTY) { + console.error('Error: Please provide CSS via stdin') + console.error('Usage: mistcss < input.css') + process.exit(1) + } + + for await (const chunk of process.stdin) { + css += chunk + } + + // Parse the CSS + const parsed = await parse(css) + + // Convert Sets to Arrays for JSON serialization + const serializable = Object.fromEntries( + Object.entries(parsed).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), + }, + ]) + ) + + // Output the parsed result as JSON + console.log(JSON.stringify(serializable, null, 2)) +} + +main().catch((err) => { + console.error('Error:', err.message) + process.exit(1) +}) diff --git a/src/index.ts b/src/index.ts index d6ab7f1..7c19d8b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ 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') @@ -14,7 +15,7 @@ declare module 'postcss-selector-parser' { } } -type Parsed = Record< +export type Parsed = Record< string, { tag: string @@ -164,6 +165,50 @@ function initialParsedValue(): Parsed[keyof Parsed] { } } +export async function parse(css: string): Promise { + const parsed: Parsed = {} + let current: Parsed[keyof Parsed] = initialParsedValue() + + // Parse the CSS using postcss + const root = postcss.parse(css) + + 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) + }) + }) + + return parsed +} + const _mistcss: PluginCreator<{}> = (_opts = {}) => { return { postcssPlugin: '_mistcss', @@ -225,4 +270,7 @@ const mistcss: PluginCreator<{}> = (_opts = {}) => { mistcss.postcss = true +export { mistcss as default } module.exports = mistcss +module.exports.parse = parse +module.exports.default = mistcss From dbd6060957ab5489eef98745ed74b560b8cf1053 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:13:07 +0000 Subject: [PATCH 04/16] Address code review feedback: remove unused import and make parse function synchronous Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> --- src/cli.ts | 3 +-- src/index.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 86b0c9c..d3e9fdb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,5 @@ #!/usr/bin/env node import { parseArgs } from 'node:util' -import fs = require('node:fs') import { parse } from './index' async function main() { @@ -25,7 +24,7 @@ async function main() { } // Parse the CSS - const parsed = await parse(css) + const parsed = parse(css) // Convert Sets to Arrays for JSON serialization const serializable = Object.fromEntries( diff --git a/src/index.ts b/src/index.ts index 7c19d8b..fbe81dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -165,7 +165,7 @@ function initialParsedValue(): Parsed[keyof Parsed] { } } -export async function parse(css: string): Promise { +export function parse(css: string): Parsed { const parsed: Parsed = {} let current: Parsed[keyof Parsed] = initialParsedValue() From 3c6da865a46c4826f8b720a4f19e6939aedbc42d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:24:59 +0000 Subject: [PATCH 05/16] Rename cli.ts to bin.ts and use consistent import style Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> --- package-lock.json | 3 +++ package.json | 2 +- src/{cli.ts => bin.ts} | 5 +++-- 3 files changed, 7 insertions(+), 3 deletions(-) rename src/{cli.ts => bin.ts} (87%) diff --git a/package-lock.json b/package-lock.json index 7083340..7c5184e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "postcss-import": "^16.1.0", "postcss-selector-parser": "^6.1.2" }, + "bin": { + "mistcss": "lib/bin.js" + }, "devDependencies": { "@tsconfig/node18": "^18.2.4", "@types/node": "^20.11.19", diff --git a/package.json b/package.json index 775aa26..7c90ca2 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.6", "description": "", "exports": "./lib/index.js", - "bin": "./lib/cli.js", + "bin": "./lib/bin.js", "scripts": { "build": "rm -rf lib && tsc", "format": "prettier --write .", diff --git a/src/cli.ts b/src/bin.ts similarity index 87% rename from src/cli.ts rename to src/bin.ts index d3e9fdb..030b9cf 100644 --- a/src/cli.ts +++ b/src/bin.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { parseArgs } from 'node:util' -import { parse } from './index' +import type { Parsed } from './index' +const { parse } = require('./index') async function main() { // Parse command line arguments (no args expected for now) @@ -28,7 +29,7 @@ async function main() { // Convert Sets to Arrays for JSON serialization const serializable = Object.fromEntries( - Object.entries(parsed).map(([key, value]) => [ + (Object.entries(parsed) as [string, Parsed[string]][]).map(([key, value]) => [ key, { ...value, From 44be1823666c29bbb8e5f988107428fec05de093 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:46:06 +0000 Subject: [PATCH 06/16] Update CLI to accept file path argument instead of stdin Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> --- src/bin.ts | 72 +++++++++++++++++++++++------------------------------- 1 file changed, 30 insertions(+), 42 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 030b9cf..374ea1b 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,52 +1,40 @@ #!/usr/bin/env node +import fs = require('node:fs') import { parseArgs } from 'node:util' import type { Parsed } from './index' const { parse } = require('./index') -async function main() { - // Parse command line arguments (no args expected for now) - parseArgs({ - args: process.argv.slice(2), - options: {}, - strict: true, - }) +const { positionals } = parseArgs({ + args: process.argv.slice(2), + options: {}, + strict: true, + allowPositionals: true, +}) - // Read CSS from stdin - let css = '' - - if (process.stdin.isTTY) { - console.error('Error: Please provide CSS via stdin') - console.error('Usage: mistcss < input.css') - process.exit(1) - } +if (positionals.length === 0) { + console.error('Error: Please provide a CSS file path') + console.error('Usage: mistcss ') + process.exit(1) +} - for await (const chunk of process.stdin) { - css += chunk - } +const cssPath = positionals[0] +const css = fs.readFileSync(cssPath, 'utf-8') - // Parse the CSS - const parsed = parse(css) +const parsed = parse(css) - // 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), - }, - ]) - ) +// 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), + }, + ]) +) - // Output the parsed result as JSON - console.log(JSON.stringify(serializable, null, 2)) -} - -main().catch((err) => { - console.error('Error:', err.message) - process.exit(1) -}) +console.log(JSON.stringify(serializable, null, 2)) From 3ce456ea7b14defc33930eb177cb25ae73c30363 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:52:46 +0000 Subject: [PATCH 07/16] Refactor to use parse function in plugin and add comprehensive tests Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> --- src/index.ts | 37 +----------- src/parse.test.ts | 142 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 35 deletions(-) create mode 100644 src/parse.test.ts diff --git a/src/index.ts b/src/index.ts index fbe81dc..58cfcac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -217,41 +217,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') diff --git a/src/parse.test.ts b/src/parse.test.ts new file mode 100644 index 0000000..1bd44e1 --- /dev/null +++ b/src/parse.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { parse } from './index' + +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) + }) +}) From b4481b0c70600bab226878b3d344b8f3c810c541 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:09:44 +0000 Subject: [PATCH 08/16] Add stats.ts with ts-morph to count tag usage in codebase Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> --- package-lock.json | 118 +++++++++++++++++++++++++++- package.json | 3 +- src/stats.test.ts | 196 ++++++++++++++++++++++++++++++++++++++++++++++ src/stats.ts | 51 ++++++++++++ 4 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 src/stats.test.ts create mode 100644 src/stats.ts diff --git a/package-lock.json b/package-lock.json index 7c5184e..5bee251 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "dependencies": { "postcss-import": "^16.1.0", - "postcss-selector-parser": "^6.1.2" + "postcss-selector-parser": "^6.1.2", + "ts-morph": "^27.0.2" }, "bin": { "mistcss": "lib/bin.js" @@ -535,6 +536,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", @@ -837,6 +859,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", @@ -1246,6 +1294,12 @@ "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", @@ -2535,6 +2589,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", @@ -3254,6 +3314,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", @@ -3287,6 +3393,16 @@ "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", diff --git a/package.json b/package.json index 7c90ca2..f5885c5 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "postcss-import": "^16.1.0", - "postcss-selector-parser": "^6.1.2" + "postcss-selector-parser": "^6.1.2", + "ts-morph": "^27.0.2" } } diff --git a/src/stats.test.ts b/src/stats.test.ts new file mode 100644 index 0000000..4359fee --- /dev/null +++ b/src/stats.test.ts @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { stats } from './stats' +import type { Parsed } from './index' +import fs = require('node:fs') +import path = require('node:path') + +test('stats', async (t) => { + await t.test('counts tag usage in a simple TSX file', () => { + // Create a temporary test project + const tempDir = fs.mkdtempSync('/tmp/mistcss-test-') + + try { + // Create a tsconfig.json + const tsconfigPath = path.join(tempDir, 'tsconfig.json') + fs.writeFileSync( + tsconfigPath, + JSON.stringify({ + compilerOptions: { + jsx: 'react', + target: 'ES2015', + }, + }) + ) + + // Create a test TSX file + const testFile = path.join(tempDir, 'test.tsx') + fs.writeFileSync( + testFile, + ` + export function App() { + return ( +
+ + + Text +
+ ) + } + ` + ) + + // Create a parsed object + const parsed: Parsed = { + button: { + tag: 'button', + rootAttribute: '', + attributes: {}, + booleanAttributes: new Set(), + properties: new Set(), + }, + div: { + tag: 'div', + rootAttribute: '', + attributes: {}, + booleanAttributes: new Set(), + properties: new Set(), + }, + span: { + tag: 'span', + rootAttribute: '', + attributes: {}, + booleanAttributes: new Set(), + properties: new Set(), + }, + } + + // Run stats + const result = stats(parsed, tsconfigPath) + + // Verify counts + assert.equal(result.button, 2, 'Should count 2 button elements') + assert.equal(result.div, 1, 'Should count 1 div element') + assert.equal(result.span, 1, 'Should count 1 span element') + } finally { + // Cleanup + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) + + await t.test('returns zero counts for unused tags', () => { + const tempDir = fs.mkdtempSync('/tmp/mistcss-test-') + + try { + const tsconfigPath = path.join(tempDir, 'tsconfig.json') + fs.writeFileSync( + tsconfigPath, + JSON.stringify({ + compilerOptions: { + jsx: 'react', + target: 'ES2015', + }, + }) + ) + + const testFile = path.join(tempDir, 'test.tsx') + fs.writeFileSync( + testFile, + ` + export function App() { + return
Hello
+ } + ` + ) + + const parsed: Parsed = { + button: { + tag: 'button', + rootAttribute: '', + attributes: {}, + booleanAttributes: new Set(), + properties: new Set(), + }, + div: { + tag: 'div', + rootAttribute: '', + attributes: {}, + booleanAttributes: new Set(), + properties: new Set(), + }, + } + + const result = stats(parsed, tsconfigPath) + + assert.equal(result.button, 0, 'Should count 0 button elements') + assert.equal(result.div, 1, 'Should count 1 div element') + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) + + await t.test('handles self-closing JSX elements', () => { + const tempDir = fs.mkdtempSync('/tmp/mistcss-test-') + + try { + const tsconfigPath = path.join(tempDir, 'tsconfig.json') + fs.writeFileSync( + tsconfigPath, + JSON.stringify({ + compilerOptions: { + jsx: 'react', + target: 'ES2015', + }, + }) + ) + + const testFile = path.join(tempDir, 'test.tsx') + fs.writeFileSync( + testFile, + ` + export function App() { + return ( +
+ + +
+
+ ) + } + ` + ) + + const parsed: Parsed = { + input: { + tag: 'input', + rootAttribute: '', + attributes: {}, + booleanAttributes: new Set(), + properties: new Set(), + }, + br: { + tag: 'br', + rootAttribute: '', + attributes: {}, + booleanAttributes: new Set(), + properties: new Set(), + }, + div: { + tag: 'div', + rootAttribute: '', + attributes: {}, + booleanAttributes: new Set(), + properties: new Set(), + }, + } + + const result = stats(parsed, tsconfigPath) + + assert.equal(result.input, 2, 'Should count 2 input elements') + assert.equal(result.br, 1, 'Should count 1 br element') + assert.equal(result.div, 1, 'Should count 1 div element') + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/stats.ts b/src/stats.ts new file mode 100644 index 0000000..6e2bc37 --- /dev/null +++ b/src/stats.ts @@ -0,0 +1,51 @@ +import { Project, SyntaxKind } from 'ts-morph' +import type { Parsed } from './index' + +export type Stats = Record + +export function stats(parsed: Parsed, projectPath: string): Stats { + const project = new Project({ + tsConfigFilePath: projectPath, + }) + + const counts: Stats = {} + + // Initialize counts for all tags from parsed + for (const key in parsed) { + const tag = parsed[key].tag + if (tag && !counts[tag]) { + counts[tag] = 0 + } + } + + // 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() + const tagName = openingElement.getTagNameNode().getText().toLowerCase() + if (counts.hasOwnProperty(tagName)) { + counts[tagName]++ + } + } + + // Count self-closing JSX elements + for (const element of jsxSelfClosingElements) { + const tagName = element.getTagNameNode().getText().toLowerCase() + if (counts.hasOwnProperty(tagName)) { + counts[tagName]++ + } + } + } + + return counts +} From bf2f87109cb855436aae01f3f930fd496d261360 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:43:08 +0000 Subject: [PATCH 09/16] Update stats to count by rootAttributes from Parsed Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> --- src/stats.test.ts | 130 ++++++++++++++++++++++++++++++++++++++++++++++ src/stats.ts | 88 ++++++++++++++++++++++++++----- 2 files changed, 205 insertions(+), 13 deletions(-) diff --git a/src/stats.test.ts b/src/stats.test.ts index 4359fee..66585c1 100644 --- a/src/stats.test.ts +++ b/src/stats.test.ts @@ -193,4 +193,134 @@ test('stats', async (t) => { fs.rmSync(tempDir, { recursive: true, force: true }) } }) + + await t.test('counts by rootAttribute values', () => { + const tempDir = fs.mkdtempSync('/tmp/mistcss-test-') + + try { + const tsconfigPath = path.join(tempDir, 'tsconfig.json') + fs.writeFileSync( + tsconfigPath, + JSON.stringify({ + compilerOptions: { + jsx: 'react', + target: 'ES2015', + }, + }) + ) + + const testFile = path.join(tempDir, 'test.tsx') + fs.writeFileSync( + testFile, + ` + export function App() { + return ( +
+ + + + +
+ ) + } + ` + ) + + const parsed: Parsed = { + button: { + tag: 'button', + rootAttribute: '', + attributes: {}, + booleanAttributes: new Set(), + properties: new Set(), + }, + button_data_variant_primary: { + tag: 'button', + rootAttribute: 'data-variant', + attributes: { + 'data-variant': new Set(['primary']), + }, + booleanAttributes: new Set(), + properties: new Set(), + }, + button_data_variant_secondary: { + tag: 'button', + rootAttribute: 'data-variant', + attributes: { + 'data-variant': new Set(['secondary']), + }, + booleanAttributes: new Set(), + properties: new Set(), + }, + } + + const result = stats(parsed, tsconfigPath) + + assert.equal(result.button, 1, 'Should count 1 regular button without data-variant') + assert.equal(result.button_data_variant_primary, 2, 'Should count 2 primary variant buttons') + assert.equal(result.button_data_variant_secondary, 1, 'Should count 1 secondary variant button') + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) + + await t.test('distinguishes between elements with and without rootAttribute', () => { + const tempDir = fs.mkdtempSync('/tmp/mistcss-test-') + + try { + const tsconfigPath = path.join(tempDir, 'tsconfig.json') + fs.writeFileSync( + tsconfigPath, + JSON.stringify({ + compilerOptions: { + jsx: 'react', + target: 'ES2015', + }, + }) + ) + + const testFile = path.join(tempDir, 'test.tsx') + fs.writeFileSync( + testFile, + ` + export function App() { + return ( +
+
Regular div
+
Card component
+
Another card
+
+ ) + } + ` + ) + + const parsed: Parsed = { + div: { + tag: 'div', + rootAttribute: '', + attributes: {}, + booleanAttributes: new Set(), + properties: new Set(), + }, + div_data_component_card: { + tag: 'div', + rootAttribute: 'data-component', + attributes: { + 'data-component': new Set(['card']), + }, + booleanAttributes: new Set(), + properties: new Set(), + }, + } + + const result = stats(parsed, tsconfigPath) + + assert.equal(result.div, 2, 'Should count 2 regular divs (parent + one child without data-component)') + assert.equal(result.div_data_component_card, 2, 'Should count 2 div elements with data-component="card"') + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) }) + diff --git a/src/stats.ts b/src/stats.ts index 6e2bc37..b79f929 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -10,12 +10,9 @@ export function stats(parsed: Parsed, projectPath: string): Stats { const counts: Stats = {} - // Initialize counts for all tags from parsed + // Initialize counts for all parsed entries (including those with rootAttributes) for (const key in parsed) { - const tag = parsed[key].tag - if (tag && !counts[tag]) { - counts[tag] = 0 - } + counts[key] = 0 } // Get all source files in the project @@ -32,20 +29,85 @@ export function stats(parsed: Parsed, projectPath: string): Stats { // Count JSX elements for (const element of jsxElements) { const openingElement = element.getOpeningElement() - const tagName = openingElement.getTagNameNode().getText().toLowerCase() - if (counts.hasOwnProperty(tagName)) { - counts[tagName]++ - } + countElement(openingElement, parsed, counts) } // Count self-closing JSX elements for (const element of jsxSelfClosingElements) { - const tagName = element.getTagNameNode().getText().toLowerCase() - if (counts.hasOwnProperty(tagName)) { - counts[tagName]++ - } + countElement(element, parsed, counts) } } return counts } + +function countElement( + element: any, + parsed: Parsed, + counts: Stats +): void { + const tagName = element.getTagNameNode().getText().toLowerCase() + + // Get attributes from the JSX element + const attributes = element.getAttributes() + + // Try to match with parsed entries + for (const key in parsed) { + const entry = parsed[key] + + // Check if tag matches + if (entry.tag !== tagName) { + continue + } + + // If no rootAttribute, match any element with this tag that doesn't have the rootAttribute + if (!entry.rootAttribute) { + // Check if this element has any of the rootAttributes from other entries + let hasOtherRootAttribute = false + for (const otherKey in parsed) { + const otherEntry = parsed[otherKey] + if (otherEntry.tag === tagName && otherEntry.rootAttribute) { + // Check if current element has this rootAttribute + const attr = attributes.find((a: any) => { + if (a.getKind() === SyntaxKind.JsxAttribute) { + const attrName = a.getNameNode().getText() + return attrName === otherEntry.rootAttribute + } + return false + }) + if (attr) { + hasOtherRootAttribute = true + break + } + } + } + + if (!hasOtherRootAttribute) { + counts[key]++ + break + } + } else { + // Has rootAttribute - check if element has this attribute with matching value + const attr = attributes.find((a: any) => { + if (a.getKind() === SyntaxKind.JsxAttribute) { + const attrName = a.getNameNode().getText() + return attrName === entry.rootAttribute + } + return false + }) + + if (attr) { + // Check if the attribute value matches any of the expected values + const initializer = attr.getInitializer() + if (initializer) { + const value = initializer.getText().replace(/^["']|["']$/g, '') + const expectedValues = entry.attributes[entry.rootAttribute] + if (expectedValues && expectedValues.has(value)) { + counts[key]++ + break + } + } + } + } + } +} From a44df833f1bace253917a5d2e98782ec1e521c14 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:36:58 +0000 Subject: [PATCH 10/16] Add --stats flag to CLI to output tag usage statistics Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> --- src/bin.ts | 66 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 374ea1b..d5fef35 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,19 +1,26 @@ #!/usr/bin/env node import fs = require('node:fs') +import path = require('node:path') import { parseArgs } from 'node:util' import type { Parsed } from './index' const { parse } = require('./index') +const { stats } = require('./stats') -const { positionals } = parseArgs({ +const { values, positionals } = parseArgs({ args: process.argv.slice(2), - options: {}, + options: { + stats: { + type: 'boolean', + default: false, + }, + }, strict: true, allowPositionals: true, }) if (positionals.length === 0) { console.error('Error: Please provide a CSS file path') - console.error('Usage: mistcss ') + console.error('Usage: mistcss [--stats]') process.exit(1) } @@ -22,19 +29,42 @@ const css = fs.readFileSync(cssPath, 'utf-8') const parsed = parse(css) -// 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), - }, - ]) -) +if (values.stats) { + // Find tsconfig.json in current directory or parent directories + let currentDir = process.cwd() + let tsconfigPath = null + + while (currentDir !== path.dirname(currentDir)) { + const candidate = path.join(currentDir, 'tsconfig.json') + if (fs.existsSync(candidate)) { + tsconfigPath = candidate + break + } + currentDir = path.dirname(currentDir) + } + + if (!tsconfigPath) { + console.error('Error: tsconfig.json not found in current directory or parent directories') + process.exit(1) + } + + const statsResult = stats(parsed, tsconfigPath) + console.log(JSON.stringify(statsResult, null, 2)) +} else { + // 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)) + console.log(JSON.stringify(serializable, null, 2)) +} From 6e1cec815284c0d0c2fd19080afd08e560e76e4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:44:10 +0000 Subject: [PATCH 11/16] Fix type safety in stats.ts and use os.tmpdir() in tests Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> --- src/stats.test.ts | 11 ++++++----- src/stats.ts | 17 ++++++++++------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/stats.test.ts b/src/stats.test.ts index 66585c1..5420ef7 100644 --- a/src/stats.test.ts +++ b/src/stats.test.ts @@ -4,11 +4,12 @@ import { stats } from './stats' import type { Parsed } from './index' import fs = require('node:fs') import path = require('node:path') +import os = require('node:os') test('stats', async (t) => { await t.test('counts tag usage in a simple TSX file', () => { // Create a temporary test project - const tempDir = fs.mkdtempSync('/tmp/mistcss-test-') + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mistcss-test-')) try { // Create a tsconfig.json @@ -79,7 +80,7 @@ test('stats', async (t) => { }) await t.test('returns zero counts for unused tags', () => { - const tempDir = fs.mkdtempSync('/tmp/mistcss-test-') + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mistcss-test-')) try { const tsconfigPath = path.join(tempDir, 'tsconfig.json') @@ -130,7 +131,7 @@ test('stats', async (t) => { }) await t.test('handles self-closing JSX elements', () => { - const tempDir = fs.mkdtempSync('/tmp/mistcss-test-') + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mistcss-test-')) try { const tsconfigPath = path.join(tempDir, 'tsconfig.json') @@ -195,7 +196,7 @@ test('stats', async (t) => { }) await t.test('counts by rootAttribute values', () => { - const tempDir = fs.mkdtempSync('/tmp/mistcss-test-') + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mistcss-test-')) try { const tsconfigPath = path.join(tempDir, 'tsconfig.json') @@ -265,7 +266,7 @@ test('stats', async (t) => { }) await t.test('distinguishes between elements with and without rootAttribute', () => { - const tempDir = fs.mkdtempSync('/tmp/mistcss-test-') + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mistcss-test-')) try { const tsconfigPath = path.join(tempDir, 'tsconfig.json') diff --git a/src/stats.ts b/src/stats.ts index b79f929..92e8712 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -1,4 +1,4 @@ -import { Project, SyntaxKind } from 'ts-morph' +import { Project, SyntaxKind, JsxOpeningElement, JsxSelfClosingElement, JsxAttribute } from 'ts-morph' import type { Parsed } from './index' export type Stats = Record @@ -42,7 +42,7 @@ export function stats(parsed: Parsed, projectPath: string): Stats { } function countElement( - element: any, + element: JsxOpeningElement | JsxSelfClosingElement, parsed: Parsed, counts: Stats ): void { @@ -68,9 +68,10 @@ function countElement( const otherEntry = parsed[otherKey] if (otherEntry.tag === tagName && otherEntry.rootAttribute) { // Check if current element has this rootAttribute - const attr = attributes.find((a: any) => { + const attr = attributes.find((a) => { if (a.getKind() === SyntaxKind.JsxAttribute) { - const attrName = a.getNameNode().getText() + const jsxAttr = a as JsxAttribute + const attrName = jsxAttr.getNameNode().getText() return attrName === otherEntry.rootAttribute } return false @@ -88,9 +89,10 @@ function countElement( } } else { // Has rootAttribute - check if element has this attribute with matching value - const attr = attributes.find((a: any) => { + const attr = attributes.find((a) => { if (a.getKind() === SyntaxKind.JsxAttribute) { - const attrName = a.getNameNode().getText() + const jsxAttr = a as JsxAttribute + const attrName = jsxAttr.getNameNode().getText() return attrName === entry.rootAttribute } return false @@ -98,7 +100,8 @@ function countElement( if (attr) { // Check if the attribute value matches any of the expected values - const initializer = attr.getInitializer() + const jsxAttr = attr as JsxAttribute + const initializer = jsxAttr.getInitializer() if (initializer) { const value = initializer.getText().replace(/^["']|["']$/g, '') const expectedValues = entry.attributes[entry.rootAttribute] From 3f40f7b548fa377e391176134d2154e3b8ac331c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:48:25 +0000 Subject: [PATCH 12/16] Fix parse() to handle multi-selector rules correctly Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> --- src/index.ts | 17 +++++++++++++++-- src/parse.test.ts | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 58cfcac..c27ab9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -173,6 +173,8 @@ export function parse(css: string): Parsed { const root = postcss.parse(css) root.walkRules((rule) => { + const entriesForRule: Array = [] + selectorParser((selectors) => { selectors.walk((selector) => { if (selector.type === 'tag') { @@ -183,6 +185,7 @@ export function parse(css: string): Parsed { const { attribute, value } = next as selectorParser.Attribute if (value) current.rootAttribute = attribute } + entriesForRule.push(current) } if (selector.type === 'attribute') { @@ -200,9 +203,19 @@ export function parse(css: string): Parsed { 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') - current.properties.add(prop) + if (prop.startsWith('--') && prop !== '--apply') { + if (entriesForRule.length > 0) { + for (const entry of entriesForRule) { + entry.properties.add(prop) + } + } else { + current.properties.add(prop) + } + } }) }) diff --git a/src/parse.test.ts b/src/parse.test.ts index 1bd44e1..55f60c4 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -139,4 +139,18 @@ test('parse', async (t) => { 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') + }) }) From 173ccacac2b00b70c7f82f750cbbf31cb8c4088e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:00:52 +0000 Subject: [PATCH 13/16] Move postcss from devDependencies to dependencies Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f5885c5..de3d888 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,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", @@ -36,6 +35,7 @@ "vitest": "^1.5.0" }, "dependencies": { + "postcss": "^8.4.47", "postcss-import": "^16.1.0", "postcss-selector-parser": "^6.1.2", "ts-morph": "^27.0.2" From 6e926a6ceddee78899d039f6d0533c7fce59f928 Mon Sep 17 00:00:00 2001 From: typicode Date: Fri, 13 Feb 2026 01:47:32 +0100 Subject: [PATCH 14/16] update --- package-lock.json | 117 ++++++++-- package.json | 3 + src/bin.ts | 165 +++++++++---- src/index.ts | 35 ++- src/key.test.ts | 12 +- src/parse.test.ts | 92 ++++++-- src/stats.test.ts | 488 +++++++++++++++++++-------------------- src/stats.ts | 384 ++++++++++++++++++++++++------ test/index.tsx | 18 ++ test/tsconfig.stats.json | 7 + tsconfig.json | 3 +- 11 files changed, 893 insertions(+), 431 deletions(-) create mode 100644 test/index.tsx create mode 100644 test/tsconfig.stats.json diff --git a/package-lock.json b/package-lock.json index 5bee251..732996f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,11 @@ "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", + "table": "^6.9.0", "ts-morph": "^27.0.2" }, "bin": { @@ -22,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", @@ -905,7 +907,6 @@ "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -1036,7 +1037,6 @@ "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1088,7 +1088,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" @@ -1098,7 +1097,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" @@ -1141,6 +1139,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", @@ -1228,7 +1235,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", @@ -1304,7 +1310,6 @@ "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" @@ -1317,7 +1322,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": { @@ -1436,7 +1440,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": { @@ -1507,7 +1510,6 @@ "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", @@ -1703,7 +1705,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": { @@ -1737,6 +1738,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", @@ -2007,7 +2024,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" @@ -2149,7 +2165,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" @@ -2339,6 +2354,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", @@ -2720,7 +2741,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.0", @@ -2997,6 +3017,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", @@ -3178,6 +3207,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", @@ -3205,7 +3251,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", @@ -3220,7 +3265,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" @@ -3272,7 +3316,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" @@ -3293,6 +3336,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", @@ -3352,7 +3433,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3409,7 +3489,6 @@ "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 de3d888..e6692de 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "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", @@ -35,9 +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", + "table": "^6.9.0", "ts-morph": "^27.0.2" } } diff --git a/src/bin.ts b/src/bin.ts index d5fef35..586096b 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,10 +1,26 @@ #!/usr/bin/env node -import fs = require('node:fs') -import path = require('node:path') -import { parseArgs } from 'node:util' -import type { Parsed } from './index' -const { parse } = require('./index') -const { stats } = require('./stats') +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), @@ -13,58 +29,117 @@ const { values, positionals } = parseArgs({ type: 'boolean', default: false, }, + tsconfig: { + type: 'string', + }, + pretty: { + type: 'boolean', + default: false, + }, }, strict: true, allowPositionals: true, }) -if (positionals.length === 0) { - console.error('Error: Please provide a CSS file path') - console.error('Usage: mistcss [--stats]') - process.exit(1) -} +function formatPrettyStats(parsed: Parsed, result: StatsResult): string { + const rows: string[][] = [[chalk.cyan('Selector'), chalk.cyan('Count')]] + + for (const [key, entry] of Object.entries(parsed)) { + 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), + ]) + } + } -const cssPath = positionals[0] -const css = fs.readFileSync(cssPath, 'utf-8') - -const parsed = parse(css) - -if (values.stats) { - // Find tsconfig.json in current directory or parent directories - let currentDir = process.cwd() - let tsconfigPath = null - - while (currentDir !== path.dirname(currentDir)) { - const candidate = path.join(currentDir, 'tsconfig.json') - if (fs.existsSync(candidate)) { - tsconfigPath = candidate - break + for (const [attribute, count] of Object.entries(stat.booleanAttributes)) { + rows.push([formatSelector(` [${attribute}]`), formatCount(count)]) } - currentDir = path.dirname(currentDir) + + 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() } - - if (!tsconfigPath) { - console.error('Error: tsconfig.json not found in current directory or parent directories') + + 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 statsResult = stats(parsed, tsconfigPath) - console.log(JSON.stringify(statsResult, null, 2)) -} else { + + 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), - }, - ]) + (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 c27ab9c..5ea19f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +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 @@ -165,16 +169,13 @@ function initialParsedValue(): Parsed[keyof Parsed] { } } -export function parse(css: string): Parsed { +function parseRoot(root: Root): Parsed { const parsed: Parsed = {} let current: Parsed[keyof Parsed] = initialParsedValue() - - // Parse the CSS using postcss - const root = postcss.parse(css) - + root.walkRules((rule) => { const entriesForRule: Array = [] - + selectorParser((selectors) => { selectors.walk((selector) => { if (selector.type === 'tag') { @@ -191,8 +192,7 @@ export function parse(css: string): Parsed { if (selector.type === 'attribute') { const { attribute, value } = selector as selectorParser.Attribute if (value) { - const values = (current.attributes[attribute] ??= - new Set()) + const values = (current.attributes[attribute] ??= new Set()) values.add(value) } else { current.booleanAttributes.add(attribute) @@ -218,10 +218,22 @@ export function parse(css: string): Parsed { } }) }) - + 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', @@ -253,4 +265,5 @@ 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 index 55f60c4..de7ebc1 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -1,12 +1,17 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import { parse } from './index' +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') @@ -23,12 +28,19 @@ test('parse', async (t) => { } ` 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.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')) + assert.ok( + parsed.button_data_variant_primary.attributes['data-variant'].has( + 'primary', + ), + ) }) await t.test('parses multiple variants', () => { @@ -46,7 +58,7 @@ test('parse', async (t) => { } ` const parsed = parse(css) - + assert.ok(parsed.button) assert.equal(parsed.button.tag, 'button') assert.ok(parsed.button.attributes['data-variant']) @@ -62,10 +74,12 @@ test('parse', async (t) => { } ` 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')) + assert.ok( + parsed.button_data_disabled.booleanAttributes.has('data-disabled'), + ) }) await t.test('parses CSS custom properties', () => { @@ -77,7 +91,7 @@ test('parse', async (t) => { } ` const parsed = parse(css) - + assert.ok(parsed.button) assert.ok(parsed.button.properties.has('--button-color')) assert.ok(parsed.button.properties.has('--button-size')) @@ -99,12 +113,14 @@ test('parse', async (t) => { } ` 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-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')) @@ -113,7 +129,7 @@ test('parse', async (t) => { 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') }) @@ -121,7 +137,7 @@ test('parse', async (t) => { await t.test('handles empty CSS', () => { const css = '' const parsed = parse(css) - + assert.equal(Object.keys(parsed).length, 0) }) @@ -133,7 +149,7 @@ test('parse', async (t) => { } ` const parsed = parse(css) - + assert.ok(parsed.button) assert.ok(parsed.button.properties.has('--color')) assert.ok(!parsed.button.properties.has('--apply')) @@ -147,10 +163,48 @@ test('parse', async (t) => { } ` 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') + 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 index 5420ef7..8bbccc1 100644 --- a/src/stats.test.ts +++ b/src/stats.test.ts @@ -1,34 +1,54 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import { stats } from './stats' -import type { Parsed } from './index' +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) => { - await t.test('counts tag usage in a simple TSX file', () => { - // Create a temporary test project - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mistcss-test-')) - - try { - // Create a tsconfig.json - const tsconfigPath = path.join(tempDir, 'tsconfig.json') - fs.writeFileSync( - tsconfigPath, - JSON.stringify({ - compilerOptions: { - jsx: 'react', - target: 'ES2015', - }, - }) - ) + 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 }) + }) - // Create a test TSX file - const testFile = path.join(tempDir, 'test.tsx') - fs.writeFileSync( - testFile, - ` + await t.test('counts tag usage in a simple TSX file', () => { + writeTestFile(` export function App() { return (
@@ -38,117 +58,38 @@ test('stats', async (t) => {
) } - ` - ) + `) - // Create a parsed object - const parsed: Parsed = { - button: { - tag: 'button', - rootAttribute: '', - attributes: {}, - booleanAttributes: new Set(), - properties: new Set(), - }, - div: { - tag: 'div', - rootAttribute: '', - attributes: {}, - booleanAttributes: new Set(), - properties: new Set(), - }, - span: { - tag: 'span', - rootAttribute: '', - attributes: {}, - booleanAttributes: new Set(), - properties: new Set(), - }, - } - - // Run stats - const result = stats(parsed, tsconfigPath) - - // Verify counts - assert.equal(result.button, 2, 'Should count 2 button elements') - assert.equal(result.div, 1, 'Should count 1 div element') - assert.equal(result.span, 1, 'Should count 1 span element') - } finally { - // Cleanup - fs.rmSync(tempDir, { recursive: true, force: true }) - } + 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', () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mistcss-test-')) - - try { - const tsconfigPath = path.join(tempDir, 'tsconfig.json') - fs.writeFileSync( - tsconfigPath, - JSON.stringify({ - compilerOptions: { - jsx: 'react', - target: 'ES2015', - }, - }) - ) - - const testFile = path.join(tempDir, 'test.tsx') - fs.writeFileSync( - testFile, - ` + writeTestFile(` export function App() { return
Hello
} - ` - ) + `) - const parsed: Parsed = { - button: { - tag: 'button', - rootAttribute: '', - attributes: {}, - booleanAttributes: new Set(), - properties: new Set(), - }, - div: { - tag: 'div', - rootAttribute: '', - attributes: {}, - booleanAttributes: new Set(), - properties: new Set(), - }, - } + const parsed = parseSelectors(['button', 'div']) - const result = stats(parsed, tsconfigPath) + const result = stats({ parsed, tsConfigFilePath: tsconfigPath }) - assert.equal(result.button, 0, 'Should count 0 button elements') - assert.equal(result.div, 1, 'Should count 1 div element') - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }) - } + assert.deepEqual(getCounts(result), { + button: 0, + div: 1, + }) }) await t.test('handles self-closing JSX elements', () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mistcss-test-')) - - try { - const tsconfigPath = path.join(tempDir, 'tsconfig.json') - fs.writeFileSync( - tsconfigPath, - JSON.stringify({ - compilerOptions: { - jsx: 'react', - target: 'ES2015', - }, - }) - ) - - const testFile = path.join(tempDir, 'test.tsx') - fs.writeFileSync( - testFile, - ` + writeTestFile(` export function App() { return (
@@ -158,62 +99,21 @@ test('stats', async (t) => {
) } - ` - ) + `) - const parsed: Parsed = { - input: { - tag: 'input', - rootAttribute: '', - attributes: {}, - booleanAttributes: new Set(), - properties: new Set(), - }, - br: { - tag: 'br', - rootAttribute: '', - attributes: {}, - booleanAttributes: new Set(), - properties: new Set(), - }, - div: { - tag: 'div', - rootAttribute: '', - attributes: {}, - booleanAttributes: new Set(), - properties: new Set(), - }, - } + const parsed = parseSelectors(['input', 'br', 'div']) - const result = stats(parsed, tsconfigPath) + const result = stats({ parsed, tsConfigFilePath: tsconfigPath }) - assert.equal(result.input, 2, 'Should count 2 input elements') - assert.equal(result.br, 1, 'Should count 1 br element') - assert.equal(result.div, 1, 'Should count 1 div element') - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }) - } + assert.deepEqual(getCounts(result), { + input: 2, + br: 1, + div: 1, + }) }) await t.test('counts by rootAttribute values', () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mistcss-test-')) - - try { - const tsconfigPath = path.join(tempDir, 'tsconfig.json') - fs.writeFileSync( - tsconfigPath, - JSON.stringify({ - compilerOptions: { - jsx: 'react', - target: 'ES2015', - }, - }) - ) - - const testFile = path.join(tempDir, 'test.tsx') - fs.writeFileSync( - testFile, - ` + writeTestFile(` export function App() { return (
@@ -224,66 +124,38 @@ test('stats', async (t) => {
) } - ` - ) + `) - const parsed: Parsed = { - button: { - tag: 'button', - rootAttribute: '', - attributes: {}, - booleanAttributes: new Set(), - properties: new Set(), - }, - button_data_variant_primary: { - tag: 'button', - rootAttribute: 'data-variant', - attributes: { - 'data-variant': new Set(['primary']), - }, - booleanAttributes: new Set(), - properties: new Set(), - }, - button_data_variant_secondary: { - tag: 'button', - rootAttribute: 'data-variant', - attributes: { - 'data-variant': new Set(['secondary']), - }, - booleanAttributes: new Set(), - properties: new Set(), - }, - } + const parsed = parseSelectors([ + 'button', + "button[data-variant='primary']", + "button[data-variant='secondary']", + ]) - const result = stats(parsed, tsconfigPath) + const result = stats({ parsed, tsConfigFilePath: tsconfigPath }) - assert.equal(result.button, 1, 'Should count 1 regular button without data-variant') - assert.equal(result.button_data_variant_primary, 2, 'Should count 2 primary variant buttons') - assert.equal(result.button_data_variant_secondary, 1, 'Should count 1 secondary variant button') - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }) - } - }) + assert.deepEqual(getCounts(result), { + button: 1, + button_data_variant_primary: 2, + button_data_variant_secondary: 1, + }) - await t.test('distinguishes between elements with and without rootAttribute', () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mistcss-test-')) - - try { - const tsconfigPath = path.join(tempDir, 'tsconfig.json') - fs.writeFileSync( - tsconfigPath, - JSON.stringify({ - compilerOptions: { - jsx: 'react', - target: 'ES2015', - }, - }) - ) + assert.deepEqual(result.button_data_variant_primary.attributes, { + 'data-variant': { + primary: 2, + }, + }) + assert.deepEqual(result.button_data_variant_secondary.attributes, { + 'data-variant': { + secondary: 1, + }, + }) + }) - const testFile = path.join(tempDir, 'test.tsx') - fs.writeFileSync( - testFile, - ` + await t.test( + 'distinguishes between elements with and without rootAttribute', + () => { + writeTestFile(` export function App() { return (
@@ -293,35 +165,143 @@ test('stats', async (t) => {
) } - ` - ) + `) - const parsed: Parsed = { - div: { - tag: 'div', - rootAttribute: '', - attributes: {}, - booleanAttributes: new Set(), - properties: new Set(), - }, - div_data_component_card: { - tag: 'div', - rootAttribute: 'data-component', - attributes: { - 'data-component': new Set(['card']), - }, - booleanAttributes: new Set(), - properties: new Set(), + 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, }, - } + }) + }, + ) - const result = stats(parsed, tsconfigPath) + await t.test('does not count PascalCase JSX components as HTML tags', () => { + writeTestFile(` + function Button() { + return + } - assert.equal(result.div, 2, 'Should count 2 regular divs (parent + one child without data-component)') - assert.equal(result.div_data_component_card, 2, 'Should count 2 div elements with data-component="card"') - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }) - } + 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 index 92e8712..8433aeb 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -1,19 +1,59 @@ -import { Project, SyntaxKind, JsxOpeningElement, JsxSelfClosingElement, JsxAttribute } from 'ts-morph' -import type { Parsed } from './index' +const tsMorph = require('ts-morph') as typeof import('ts-morph') -export type Stats = Record +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 function stats(parsed: Parsed, projectPath: string): Stats { - const project = new Project({ - tsConfigFilePath: projectPath, - }) +export type Stats = Record< + string, + { + count: number + attributes: Record> + booleanAttributes: Record + properties: Record + } +> - const counts: Stats = {} +type StatsOptions = { + parsed: Parsed + tsConfigFilePath?: string +} - // Initialize counts for all parsed entries (including those with rootAttributes) - for (const key in parsed) { - counts[key] = 0 - } +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() @@ -23,18 +63,18 @@ export function stats(parsed: Parsed, projectPath: string): Stats { // Find all JSX elements const jsxElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement) const jsxSelfClosingElements = sourceFile.getDescendantsOfKind( - SyntaxKind.JsxSelfClosingElement + SyntaxKind.JsxSelfClosingElement, ) // Count JSX elements for (const element of jsxElements) { const openingElement = element.getOpeningElement() - countElement(openingElement, parsed, counts) + countElement(openingElement, matchersByTag, parsed, counts) } // Count self-closing JSX elements for (const element of jsxSelfClosingElements) { - countElement(element, parsed, counts) + countElement(element, matchersByTag, parsed, counts) } } @@ -43,74 +83,266 @@ export function stats(parsed: Parsed, projectPath: string): Stats { function countElement( element: JsxOpeningElement | JsxSelfClosingElement, + matchersByTag: Record, parsed: Parsed, - counts: Stats + counts: Stats, ): void { - const tagName = element.getTagNameNode().getText().toLowerCase() - + 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() - - // Try to match with parsed entries - for (const key in parsed) { - const entry = parsed[key] - - // Check if tag matches - if (entry.tag !== tagName) { + 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 no rootAttribute, match any element with this tag that doesn't have the rootAttribute - if (!entry.rootAttribute) { - // Check if this element has any of the rootAttributes from other entries - let hasOtherRootAttribute = false - for (const otherKey in parsed) { - const otherEntry = parsed[otherKey] - if (otherEntry.tag === tagName && otherEntry.rootAttribute) { - // Check if current element has this rootAttribute - const attr = attributes.find((a) => { - if (a.getKind() === SyntaxKind.JsxAttribute) { - const jsxAttr = a as JsxAttribute - const attrName = jsxAttr.getNameNode().getText() - return attrName === otherEntry.rootAttribute - } - return false - }) - if (attr) { - hasOtherRootAttribute = true - break - } - } - } - - if (!hasOtherRootAttribute) { - counts[key]++ - break - } - } else { - // Has rootAttribute - check if element has this attribute with matching value - const attr = attributes.find((a) => { - if (a.getKind() === SyntaxKind.JsxAttribute) { - const jsxAttr = a as JsxAttribute - const attrName = jsxAttr.getNameNode().getText() - return attrName === entry.rootAttribute - } - return false + + if (entry.booleanAttributes.size > 0) { + const requiredAttributes = new Set(entry.booleanAttributes) + requiredAttributes.forEach((attribute) => { + tagMatchers.booleanDiscriminatorAttributes.add(attribute) + }) + tagMatchers.booleanVariants.push({ + key, + requiredAttributes, }) - - if (attr) { - // Check if the attribute value matches any of the expected values - const jsxAttr = attr as JsxAttribute - const initializer = jsxAttr.getInitializer() - if (initializer) { - const value = initializer.getText().replace(/^["']|["']$/g, '') - const expectedValues = entry.attributes[entry.rootAttribute] - if (expectedValues && expectedValues.has(value)) { - counts[key]++ - break - } - } - } + 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"] } From f3aaa48faca2f29b6803f677f8a902a8ee45f3cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:45:07 +0000 Subject: [PATCH 15/16] Sort stats results by count in descending order Co-authored-by: typicode <5502029+typicode@users.noreply.github.com> --- package-lock.json | 6 ++++++ src/bin.ts | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 732996f..c7d9558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -907,6 +907,7 @@ "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -1037,6 +1038,7 @@ "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1510,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", @@ -2741,6 +2744,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.0", @@ -3433,6 +3437,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3489,6 +3494,7 @@ "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/src/bin.ts b/src/bin.ts index 586096b..1e1b90a 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -44,7 +44,17 @@ const { values, positionals } = parseArgs({ function formatPrettyStats(parsed: Parsed, result: StatsResult): string { const rows: string[][] = [[chalk.cyan('Selector'), chalk.cyan('Count')]] - for (const [key, entry] of Object.entries(parsed)) { + // Sort entries by count in descending order + const sortedEntries = Object.entries(parsed).sort((a, b) => { + const statA = result[a[0]] + const statB = result[b[0]] + if (!statA && !statB) return 0 + if (!statA) return 1 + if (!statB) return -1 + return statB.count - statA.count + }) + + for (const [key, entry] of sortedEntries) { const stat = result[key] if (!stat) continue @@ -110,7 +120,11 @@ async function main(): Promise { if (values.pretty) { console.log(formatPrettyStats(parsed, statsResult)) } else { - console.log(JSON.stringify(statsResult, null, 2)) + // Sort by count in descending order for JSON output + const sortedStats = Object.fromEntries( + Object.entries(statsResult).sort((a, b) => b[1].count - a[1].count), + ) + console.log(JSON.stringify(sortedStats, null, 2)) } return } From 9bcb29a76cad44c2d4f7e6e1efeddf896bc2493a Mon Sep 17 00:00:00 2001 From: typicode Date: Fri, 13 Feb 2026 12:39:31 +0100 Subject: [PATCH 16/16] update --- src/bin.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 1e1b90a..e64cdc5 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -44,15 +44,9 @@ const { values, positionals } = parseArgs({ function formatPrettyStats(parsed: Parsed, result: StatsResult): string { const rows: string[][] = [[chalk.cyan('Selector'), chalk.cyan('Count')]] - // Sort entries by count in descending order - const sortedEntries = Object.entries(parsed).sort((a, b) => { - const statA = result[a[0]] - const statB = result[b[0]] - if (!statA && !statB) return 0 - if (!statA) return 1 - if (!statB) return -1 - return statB.count - statA.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] @@ -120,11 +114,7 @@ async function main(): Promise { if (values.pretty) { console.log(formatPrettyStats(parsed, statsResult)) } else { - // Sort by count in descending order for JSON output - const sortedStats = Object.fromEntries( - Object.entries(statsResult).sort((a, b) => b[1].count - a[1].count), - ) - console.log(JSON.stringify(sortedStats, null, 2)) + console.log(JSON.stringify(statsResult, null, 2)) } return }