diff --git a/README.md b/README.md index 048fddc..751fdb9 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,16 @@ button { ```jsx <> - {/* TS error, tertiary isn't valid */} + + {/* TS error, tertiary isn't valid */} + ``` Output ```jsx - {/* Same as in Page.tsx */} + {/* Same as in Page.tsx */} ``` _This example demonstrates enums, but MistCSS also supports boolean and string props. For more details, see the FAQ._ @@ -67,6 +69,7 @@ MistCSS parses your `mist.css` file and generates `mist.d.ts` for type safety. For instance, here’s the generated `mist.d.ts` for our button component: + ```typescript interface Mist_button extends React.DetailedHTMLProps, HTMLButtonElement> { 'data-variant'?: 'primary' | 'secondary' @@ -78,6 +81,7 @@ declare namespace JSX { } } ``` + That’s it! Simple yet powerful, built entirely on browser standards and TypeScript/JSX. @@ -132,8 +136,7 @@ button { #### Tailwind v4 -Tailwind v4 will support CSS variables natively (see [blog post](https://tailwindcss.com/blog/tailwindcss-v4-alpha -)). +Tailwind v4 will support CSS variables natively (see [blog post](https://tailwindcss.com/blog/tailwindcss-v4-alpha)). #### Tailwind (inline style) @@ -190,7 +193,7 @@ div[data-component='section'] &[data-size="lg"] { ... } /* Boolean props */ - &[data-is-active] { ... } + &[data-is-active] { ... } /* Condition: size="lg" && is-active */ &[data-size="lg"]&[data-is-active] { ... } @@ -216,29 +219,32 @@ If you want both basic links and button-styled links, here’s how you can do: ```css a:not([data-component]) { /* ... */ } -a[data-component='button'] { /* ... */ } +a[data-component='button'] { &[data-variant='primary'] { /* ... */ } } ``` + ```jsx <> Home Home Home - Home {/* TS error, `data-variant` is only valid with `data-component="button"` */} + + {/* TS error, `data-variant` is only valid with `data-component="button"` */} + Home ``` + -> [!NOTE] -> `data-component` is just a naming convention. Feel free to use any attribute, like `data-kind='button'` or just `data-c`. It’s simply a way to differentiate between components using the same tag. +> [!NOTE] > `data-component` is just a naming convention. Feel free to use any attribute, like `data-kind='button'` or just `data-c`. It’s simply a way to differentiate between components using the same tag. ### How to split my code? You can use CSS [@import](https://developer.mozilla.org/en-US/docs/Web/CSS/@import). For example, in your `mist.css` file: ```css -@import './button.css' +@import './button.css'; ``` ### How to build complex components? @@ -246,9 +252,15 @@ You can use CSS [@import](https://developer.mozilla.org/en-US/docs/Web/CSS/@impo `mist.css` ```css -article[data-component='card'] { /* ... */ } -div[data-component='card-title'] { /* ... */ } -div[data-component='card-content'] { /* ... */ } +article[data-component='card'] { + /* ... */ +} +div[data-component='card-title'] { + /* ... */ +} +div[data-component='card-content'] { + /* ... */ +} ``` `Card.jsx` @@ -294,7 +306,7 @@ import 'my-ui/mist.css' `app/mist.d.ts` -``` +```typescript import 'my-ui/mist.d.ts ``` diff --git a/src/index.ts b/src/index.ts index 06b572b..01e4062 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ import fs = require('node:fs') import { type PluginCreator } from 'postcss' -import selectorParser = require('postcss-selector-parser'); -import atImport = require("postcss-import") -import path = require('node:path'); +import selectorParser = require('postcss-selector-parser') +import atImport = require('postcss-import') +import path = require('node:path') const html = require('./html') const key = require('./key') @@ -30,49 +30,123 @@ function render(parsed: Parsed): string { let interfaceDefinitions = '' const jsxElements: Record = {} - Object.entries(parsed).forEach( - ([key, { tag, rootAttribute, attributes, booleanAttributes, properties }]) => { - const interfaceName = `Mist_${key}` + // Normalize + type Component = { + rootAttribute: string + discriminatorAttributes: Set + attributes: Record> + booleanAttributes: Set + properties: Set + } - const attributeEntries = Object.entries(attributes) + const normalized: Record< + string, + { + _base: Component + [other: string]: Component + } + > = {} - let htmlElement = 'HTMLElement' - if (tag in html) { - htmlElement = html[tag as keyof typeof html] + console.log(parsed) + + Object.entries(parsed).forEach( + ([ + key, + { tag, rootAttribute, attributes, booleanAttributes, properties }, + ]) => { + // Default base tag, always there + normalized[tag] ??= { + _base: { + rootAttribute: '', + discriminatorAttributes: new Set(), + attributes: {}, + booleanAttributes: new Set(), + properties: new Set(), + }, } - let interfaceDefinition = `interface ${interfaceName} extends React.DetailedHTMLProps, ${htmlElement}> {\n` + if (rootAttribute !== '') { + normalized[tag][key] ??= { + rootAttribute, + discriminatorAttributes: new Set(), + attributes, + booleanAttributes, + properties, + } + normalized[tag]['_base']['discriminatorAttributes'] ??= new Set() + normalized[tag]['_base']['discriminatorAttributes'].add(rootAttribute) + } else { + normalized[tag]['_base'] = { + rootAttribute, + discriminatorAttributes: new Set(), + attributes, + booleanAttributes, + properties, + } + } + }, + ) - attributeEntries.forEach(([attr, values]) => { - const valueType = Array.from(values) - .map((v) => `'${v}'`) - .join(' | ') - // Root attribute is used to narrow type and therefore is the only attribute - // that shouldn't be optional (i.e. attr: ... and not attr?: ...) - interfaceDefinition += ` '${attr}'${rootAttribute === attr ? '' : '?'}: ${valueType}\n` - }) + console.dir(normalized, { depth: null }) + + Object.entries(normalized).forEach(([tag, components]) => { + Object.entries(components).forEach( + ([ + key, + { + rootAttribute, + discriminatorAttributes, + attributes, + booleanAttributes, + properties, + }, + ]) => { + const interfaceName = `Mist_${key === '_base' ? tag : key}` + + const attributeEntries = Object.entries(attributes) + + let htmlElement = 'HTMLElement' + if (tag in html) { + htmlElement = html[tag as keyof typeof html] + } + + let interfaceDefinition = `interface ${interfaceName} extends React.DetailedHTMLProps, ${htmlElement}> {\n` + + discriminatorAttributes.forEach((attr) => { + interfaceDefinition += ` '${attr}'?: never\n` + }) - booleanAttributes.forEach((attr) => { - interfaceDefinition += ` '${attr}'?: boolean\n` - }) + attributeEntries.forEach(([attr, values]) => { + const valueType = Array.from(values) + .map((v) => `'${v}'`) + .join(' | ') + // Root attribute is used to narrow type and therefore is the only attribute + // that shouldn't be optional (i.e. attr: ... and not attr?: ...) + interfaceDefinition += ` '${attr}'${rootAttribute === attr ? '' : '?'}: ${valueType}\n` + }) - if (Array.from(properties).length > 0) { - const propertyEntries = Array.from(properties) - .map((prop) => `'${prop}': string`) - .join(', ') - interfaceDefinition += ` style?: { ${propertyEntries} } & React.CSSProperties\n` - } + booleanAttributes.forEach((attr) => { + interfaceDefinition += ` '${attr}'?: boolean\n` + }) - interfaceDefinition += '}\n\n' + if (Array.from(properties).length > 0) { + const propertyEntries = Array.from(properties) + .map((prop) => `'${prop}': string`) + .join(', ') + interfaceDefinition += ` style?: { ${propertyEntries} } & React.CSSProperties\n` + } - interfaceDefinitions += interfaceDefinition + interfaceDefinition += '}\n\n' - if (!jsxElements[tag]) { - jsxElements[tag] = [] - } - jsxElements[tag].push(interfaceName) - }, - ) + interfaceDefinitions += interfaceDefinition + + if (!jsxElements[tag]) { + jsxElements[tag] = [] + } + jsxElements[tag].push(interfaceName) + }, + ) + }) // Generate the JSX namespace declaration dynamically let jsxDeclaration = @@ -151,7 +225,7 @@ _mistcss.postcss = true const mistcss: PluginCreator<{}> = (_opts = {}) => { return { postcssPlugin: 'mistcss', - plugins: [atImport(), _mistcss()] + plugins: [atImport(), _mistcss()], } } diff --git a/test/card.mist.css b/test/card.mist.css new file mode 100644 index 0000000..2c419d3 --- /dev/null +++ b/test/card.mist.css @@ -0,0 +1,12 @@ +/* Testing union discriminator */ +div[data-component='card'] { + background: gray; + &[data-size='sm'] { + } + &[data-size='xl'] { + } +} + +div[data-component='card-title'] { + background: gray; +} diff --git a/test/mist.css b/test/mist.css index 0d0d6ba..34f0466 100644 --- a/test/mist.css +++ b/test/mist.css @@ -1 +1,2 @@ @import './button.mist.css'; +@import './card.mist.css'; diff --git a/test/mist.d.ts b/test/mist.d.ts index 699d8e6..b64cbfd 100644 --- a/test/mist.d.ts +++ b/test/mist.d.ts @@ -2,8 +2,22 @@ interface Mist_button extends React.DetailedHTMLProps, HTMLDivElement> { + 'data-component'?: never +} + +interface Mist_div_data_component_card extends React.DetailedHTMLProps, HTMLDivElement> { + 'data-component': 'card' + 'data-size'?: 'sm' | 'xl' +} + +interface Mist_div_data_component_card_title extends React.DetailedHTMLProps, HTMLDivElement> { + 'data-component': 'card-title' +} + declare namespace JSX { interface IntrinsicElements { button: Mist_button + div: Mist_div | Mist_div_data_component_card | Mist_div_data_component_card_title } }