-
-
Notifications
You must be signed in to change notification settings - Fork 49
Export CSS parser function, add CLI with --stats flag, and add stats utility #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3321b1f
fc53e2f
9a02c01
dbd6060
3c6da86
44be182
3ce456e
b4481b0
bf2f871
a44df83
6e1cec8
3f40f7b
173ccac
6e926a6
f3aaa48
9bcb29a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| #!/usr/bin/env node | ||
| import util = require('node:util') | ||
| import chalk = require('chalk') | ||
| type Parsed = import('./index').Parsed | ||
| import index = require('./index') | ||
| import statsModule = require('./stats') | ||
| const tableLib = require('table') as typeof import('table') | ||
|
|
||
| const { parseArgs } = util | ||
| const { parseFile } = index | ||
| const { stats } = statsModule | ||
|
|
||
| type StatsResult = ReturnType<typeof stats> | ||
|
|
||
| function formatCount(count: number): string { | ||
| if (count === 0) return chalk.red(String(count)) | ||
| return String(count) | ||
| } | ||
|
|
||
| function formatSelector(selector: string): string { | ||
| if (!selector || selector.startsWith(' ')) return selector | ||
| return chalk.bold(selector) | ||
| } | ||
|
|
||
| const { values, positionals } = parseArgs({ | ||
| args: process.argv.slice(2), | ||
| options: { | ||
| stats: { | ||
| type: 'boolean', | ||
| default: false, | ||
| }, | ||
| tsconfig: { | ||
| type: 'string', | ||
| }, | ||
| pretty: { | ||
| type: 'boolean', | ||
| default: false, | ||
| }, | ||
| }, | ||
| strict: true, | ||
| allowPositionals: true, | ||
| }) | ||
|
|
||
| function formatPrettyStats(parsed: Parsed, result: StatsResult): string { | ||
| const rows: string[][] = [[chalk.cyan('Selector'), chalk.cyan('Count')]] | ||
|
|
||
| const sortedEntries = Object.entries(parsed).sort( | ||
| ([a], [b]) => result[b].count - result[a].count, | ||
| ) | ||
|
|
||
| for (const [key, entry] of sortedEntries) { | ||
| const stat = result[key] | ||
| if (!stat) continue | ||
|
|
||
| let title = entry.tag | ||
| if (entry.rootAttribute) { | ||
| const rootValues = Object.keys(stat.attributes[entry.rootAttribute] ?? {}) | ||
| const rootValue = rootValues[0] | ||
| title = rootValue | ||
| ? `${entry.tag}[${entry.rootAttribute}="${rootValue}"]` | ||
| : `${entry.tag}[${entry.rootAttribute}]` | ||
| } | ||
| rows.push([formatSelector(title), formatCount(stat.count)]) | ||
|
|
||
| for (const [attribute, values] of Object.entries(stat.attributes)) { | ||
| if (attribute === entry.rootAttribute) continue | ||
| for (const [value, count] of Object.entries(values)) { | ||
| rows.push([ | ||
| formatSelector(` [${attribute}="${value}"]`), | ||
| formatCount(count), | ||
| ]) | ||
| } | ||
| } | ||
|
|
||
| for (const [attribute, count] of Object.entries(stat.booleanAttributes)) { | ||
| rows.push([formatSelector(` [${attribute}]`), formatCount(count)]) | ||
| } | ||
|
|
||
| for (const [property, count] of Object.entries(stat.properties)) { | ||
| rows.push([formatSelector(` style["${property}"]`), formatCount(count)]) | ||
| } | ||
|
|
||
| rows.push(['', '']) | ||
| } | ||
|
|
||
| if (rows.length > 1) { | ||
| const last = rows[rows.length - 1] | ||
| if (last[0] === '' && last[1] === '') rows.pop() | ||
| } | ||
|
|
||
| return tableLib.table(rows, { | ||
| columns: { | ||
| 1: { alignment: 'right' }, | ||
| }, | ||
| drawHorizontalLine: (lineIndex, rowCount) => | ||
| lineIndex === 0 || lineIndex === 1 || lineIndex === rowCount, | ||
| }) | ||
| } | ||
|
|
||
| async function main(): Promise<void> { | ||
| if (positionals.length === 0) { | ||
| console.error('Error: Please provide a CSS file path') | ||
| console.error( | ||
| 'Usage: mistcss <path-to-css-file> [--stats] [--tsconfig <path>] [--pretty]', | ||
| ) | ||
| process.exit(1) | ||
| } | ||
|
|
||
| const cssPath = positionals[0] | ||
| const parsed = await parseFile(cssPath) | ||
|
|
||
| if (values.stats) { | ||
| const statsResult = stats({ parsed, tsConfigFilePath: values.tsconfig }) | ||
| if (values.pretty) { | ||
| console.log(formatPrettyStats(parsed, statsResult)) | ||
| } else { | ||
| console.log(JSON.stringify(statsResult, null, 2)) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| // Convert Sets to Arrays for JSON serialization | ||
| const serializable = Object.fromEntries( | ||
| (Object.entries(parsed) as [string, Parsed[string]][]).map( | ||
| ([key, value]) => [ | ||
| key, | ||
| { | ||
| ...value, | ||
| attributes: Object.fromEntries( | ||
| Object.entries(value.attributes).map(([k, v]) => [ | ||
| k, | ||
| Array.from(v), | ||
| ]), | ||
| ), | ||
| booleanAttributes: Array.from(value.booleanAttributes), | ||
| properties: Array.from(value.properties), | ||
| }, | ||
| ], | ||
| ), | ||
| ) | ||
|
|
||
| console.log(JSON.stringify(serializable, null, 2)) | ||
| } | ||
|
|
||
| main().catch((error) => { | ||
| const message = error instanceof Error ? error.message : String(error) | ||
| console.error(message) | ||
| process.exit(1) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,14 @@ | ||
| import fs = require('node:fs') | ||
| import { type PluginCreator } from 'postcss' | ||
| import postcss = require('postcss') | ||
| import selectorParser = require('postcss-selector-parser') | ||
| import atImport = require('postcss-import') | ||
| import path = require('node:path') | ||
| const key = require('./key') | ||
| import keyModule = require('./key') | ||
|
|
||
| type PluginCreator<PluginOptions> = | ||
| import('postcss').PluginCreator<PluginOptions> | ||
| type Root = import('postcss').Root | ||
| const key = keyModule as unknown as (selector: selectorParser.Node) => string | ||
|
|
||
| declare module 'postcss-selector-parser' { | ||
| // For some reasons these aren't avaiblable in this module types | ||
|
|
@@ -14,7 +19,7 @@ declare module 'postcss-selector-parser' { | |
| } | ||
| } | ||
|
|
||
| type Parsed = Record< | ||
| export type Parsed = Record< | ||
| string, | ||
| { | ||
| tag: string | ||
|
|
@@ -164,6 +169,71 @@ function initialParsedValue(): Parsed[keyof Parsed] { | |
| } | ||
| } | ||
|
|
||
| function parseRoot(root: Root): Parsed { | ||
| const parsed: Parsed = {} | ||
| let current: Parsed[keyof Parsed] = initialParsedValue() | ||
|
|
||
| root.walkRules((rule) => { | ||
| const entriesForRule: Array<Parsed[keyof Parsed]> = [] | ||
|
|
||
| selectorParser((selectors) => { | ||
| selectors.walk((selector) => { | ||
| if (selector.type === 'tag') { | ||
| current = parsed[key(selector)] = initialParsedValue() | ||
| current.tag = selector.toString().toLowerCase() | ||
| const next = selector.next() | ||
| if (next?.type === 'attribute') { | ||
| const { attribute, value } = next as selectorParser.Attribute | ||
| if (value) current.rootAttribute = attribute | ||
| } | ||
| entriesForRule.push(current) | ||
| } | ||
|
|
||
| if (selector.type === 'attribute') { | ||
| const { attribute, value } = selector as selectorParser.Attribute | ||
| if (value) { | ||
| const values = (current.attributes[attribute] ??= new Set<string>()) | ||
| values.add(value) | ||
| } else { | ||
| current.booleanAttributes.add(attribute) | ||
| } | ||
| } | ||
| }) | ||
| }).processSync(rule.selector, { | ||
| lossless: false, | ||
| }) | ||
|
|
||
| // Apply declarations to all entries collected for this rule | ||
| // If we collected entries (multi-selector), apply to all | ||
| // Otherwise apply to current (nested selector case) | ||
| rule.walkDecls(({ prop }) => { | ||
| if (prop.startsWith('--') && prop !== '--apply') { | ||
| if (entriesForRule.length > 0) { | ||
| for (const entry of entriesForRule) { | ||
| entry.properties.add(prop) | ||
| } | ||
| } else { | ||
| current.properties.add(prop) | ||
| } | ||
| } | ||
| }) | ||
|
Comment on lines
209
to
219
|
||
| }) | ||
|
|
||
| return parsed | ||
| } | ||
|
|
||
| export function parse(css: string): Parsed { | ||
| return parseRoot(postcss.parse(css)) | ||
| } | ||
|
|
||
| export async function parseFile(cssFilePath: string): Promise<Parsed> { | ||
| const css = fs.readFileSync(cssFilePath, 'utf-8') | ||
| const result = await postcss([atImport()]).process(css, { | ||
| from: cssFilePath, | ||
| }) | ||
| return parseRoot(postcss.parse(result.css)) | ||
| } | ||
|
|
||
| const _mistcss: PluginCreator<{}> = (_opts = {}) => { | ||
| return { | ||
| postcssPlugin: '_mistcss', | ||
|
|
@@ -172,41 +242,8 @@ const _mistcss: PluginCreator<{}> = (_opts = {}) => { | |
| const from = helper.result.opts.from | ||
| if (from === undefined || path.basename(from) !== 'mist.css') return | ||
|
|
||
| const parsed: Parsed = {} | ||
| let current: Parsed[keyof Parsed] = initialParsedValue() | ||
| root.walkRules((rule) => { | ||
| selectorParser((selectors) => { | ||
| selectors.walk((selector) => { | ||
| if (selector.type === 'tag') { | ||
| current = parsed[key(selector)] = initialParsedValue() | ||
| current.tag = selector.toString().toLowerCase() | ||
| const next = selector.next() | ||
| if (next?.type === 'attribute') { | ||
| const { attribute, value } = next as selectorParser.Attribute | ||
| if (value) current.rootAttribute = attribute | ||
| } | ||
| } | ||
|
|
||
| if (selector.type === 'attribute') { | ||
| const { attribute, value } = selector as selectorParser.Attribute | ||
| if (value) { | ||
| const values = (current.attributes[attribute] ??= | ||
| new Set<string>()) | ||
| values.add(value) | ||
| } else { | ||
| current.booleanAttributes.add(attribute) | ||
| } | ||
| } | ||
| }) | ||
| }).processSync(rule.selector, { | ||
| lossless: false, | ||
| }) | ||
|
|
||
| rule.walkDecls(({ prop }) => { | ||
| if (prop.startsWith('--') && prop !== '--apply') | ||
| current.properties.add(prop) | ||
| }) | ||
| }) | ||
| const css = root.toString() | ||
| const parsed = parse(css) | ||
| const rendered = render(parsed) | ||
| const to = path.resolve(from, '../mist.d.ts') | ||
| fs.writeFileSync(to, rendered, 'utf-8') | ||
|
|
@@ -225,4 +262,8 @@ const mistcss: PluginCreator<{}> = (_opts = {}) => { | |
|
|
||
| mistcss.postcss = true | ||
|
|
||
| export { mistcss as default } | ||
| module.exports = mistcss | ||
| module.exports.parse = parse | ||
| module.exports.parseFile = parseFile | ||
| module.exports.default = mistcss | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
exportsfield is a string, which restricts consumers to importing only the package root. That makes the documentedmistcss/lib/stats(and any other subpath likelib/stats) unavailable in Node whenexportsis present. Ifstatsis intended to be a public utility, switchexportsto an object and explicitly export./stats(and any other public subpaths), while keeping.pointing at./lib/index.js.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot don't export stats