Skip to content

Commit 5a35d76

Browse files
authored
fix: union discriminator (#66)
1 parent 713be2b commit 5a35d76

File tree

5 files changed

+164
-51
lines changed

5 files changed

+164
-51
lines changed

README.md

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,16 @@ button {
4949
```jsx
5050
<>
5151
<button data-variant="primary">Save</button>
52-
<button data-variant="tertiary">Save</button> {/* TS error, tertiary isn't valid */}
52+
53+
{/* TS error, tertiary isn't valid */}
54+
<button data-variant="tertiary">Save</button>
5355
</>
5456
```
5557

5658
Output
5759

5860
```jsx
59-
<button data-variant="primary">Save</button> {/* Same as in Page.tsx */}
61+
<button data-variant="primary">Save</button> {/* Same as in Page.tsx */}
6062
```
6163

6264
_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.
6769

6870
For instance, here’s the generated `mist.d.ts` for our button component:
6971

72+
<!-- prettier-ignore-start -->
7073
```typescript
7174
interface Mist_button extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> {
7275
'data-variant'?: 'primary' | 'secondary'
@@ -78,6 +81,7 @@ declare namespace JSX {
7881
}
7982
}
8083
```
84+
<!-- prettier-ignore-stop -->
8185

8286
That’s it! Simple yet powerful, built entirely on browser standards and TypeScript/JSX.
8387

@@ -132,8 +136,7 @@ button {
132136

133137
#### Tailwind v4
134138

135-
Tailwind v4 will support CSS variables natively (see [blog post](https://tailwindcss.com/blog/tailwindcss-v4-alpha
136-
)).
139+
Tailwind v4 will support CSS variables natively (see [blog post](https://tailwindcss.com/blog/tailwindcss-v4-alpha)).
137140

138141
#### Tailwind (inline style)
139142

@@ -190,7 +193,7 @@ div[data-component='section']
190193
&[data-size="lg"] { ... }
191194

192195
/* Boolean props */
193-
&[data-is-active] { ... }
196+
&[data-is-active] { ... }
194197

195198
/* Condition: size="lg" && is-active */
196199
&[data-size="lg"]&[data-is-active] { ... }
@@ -216,39 +219,48 @@ If you want both basic links and button-styled links, here’s how you can do:
216219
```css
217220
a:not([data-component]) { /* ... */ }
218221

219-
a[data-component='button'] { /* ... */ }
222+
a[data-component='button'] {
220223
&[data-variant='primary'] { /* ... */ }
221224
}
222225
```
223226

227+
<!-- prettier-ignore-start -->
224228
```jsx
225229
<>
226230
<a href="/home">Home</a>
227231
<a href="/home" data-component="button">Home</a>
228232
<a href="/home" data-component="button" data-variant="primary">Home</a>
229-
<a href="/home" data-variant="primary">Home</a> {/* TS error, `data-variant` is only valid with `data-component="button"` */}
233+
234+
{/* TS error, `data-variant` is only valid with `data-component="button"` */}
235+
<a href="/home" data-variant="primary">Home</a>
230236
</>
231237
```
238+
<!-- prettier-ignore-stop -->
232239

233-
> [!NOTE]
234-
> `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.
240+
> [!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.
235241
236242
### How to split my code?
237243

238244
You can use CSS [@import](https://developer.mozilla.org/en-US/docs/Web/CSS/@import). For example, in your `mist.css` file:
239245

240246
```css
241-
@import './button.css'
247+
@import './button.css';
242248
```
243249

244250
### How to build complex components?
245251

246252
`mist.css`
247253

248254
```css
249-
article[data-component='card'] { /* ... */ }
250-
div[data-component='card-title'] { /* ... */ }
251-
div[data-component='card-content'] { /* ... */ }
255+
article[data-component='card'] {
256+
/* ... */
257+
}
258+
div[data-component='card-title'] {
259+
/* ... */
260+
}
261+
div[data-component='card-content'] {
262+
/* ... */
263+
}
252264
```
253265

254266
`Card.jsx`
@@ -294,7 +306,7 @@ import 'my-ui/mist.css'
294306

295307
`app/mist.d.ts`
296308

297-
```
309+
```typescript
298310
import 'my-ui/mist.d.ts
299311
```
300312

src/index.ts

Lines changed: 111 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import fs = require('node:fs')
22
import { type PluginCreator } from 'postcss'
3-
import selectorParser = require('postcss-selector-parser');
4-
import atImport = require("postcss-import")
5-
import path = require('node:path');
3+
import selectorParser = require('postcss-selector-parser')
4+
import atImport = require('postcss-import')
5+
import path = require('node:path')
66
const html = require('./html')
77
const key = require('./key')
88

@@ -30,49 +30,123 @@ function render(parsed: Parsed): string {
3030
let interfaceDefinitions = ''
3131
const jsxElements: Record<string, string[]> = {}
3232

33-
Object.entries(parsed).forEach(
34-
([key, { tag, rootAttribute, attributes, booleanAttributes, properties }]) => {
35-
const interfaceName = `Mist_${key}`
33+
// Normalize
34+
type Component = {
35+
rootAttribute: string
36+
discriminatorAttributes: Set<string>
37+
attributes: Record<string, Set<string>>
38+
booleanAttributes: Set<string>
39+
properties: Set<string>
40+
}
3641

37-
const attributeEntries = Object.entries(attributes)
42+
const normalized: Record<
43+
string,
44+
{
45+
_base: Component
46+
[other: string]: Component
47+
}
48+
> = {}
3849

39-
let htmlElement = 'HTMLElement'
40-
if (tag in html) {
41-
htmlElement = html[tag as keyof typeof html]
50+
console.log(parsed)
51+
52+
Object.entries(parsed).forEach(
53+
([
54+
key,
55+
{ tag, rootAttribute, attributes, booleanAttributes, properties },
56+
]) => {
57+
// Default base tag, always there
58+
normalized[tag] ??= {
59+
_base: {
60+
rootAttribute: '',
61+
discriminatorAttributes: new Set<string>(),
62+
attributes: {},
63+
booleanAttributes: new Set<string>(),
64+
properties: new Set<string>(),
65+
},
4266
}
4367

44-
let interfaceDefinition = `interface ${interfaceName} extends React.DetailedHTMLProps<React.HTMLAttributes<${htmlElement}>, ${htmlElement}> {\n`
68+
if (rootAttribute !== '') {
69+
normalized[tag][key] ??= {
70+
rootAttribute,
71+
discriminatorAttributes: new Set<string>(),
72+
attributes,
73+
booleanAttributes,
74+
properties,
75+
}
76+
normalized[tag]['_base']['discriminatorAttributes'] ??= new Set()
77+
normalized[tag]['_base']['discriminatorAttributes'].add(rootAttribute)
78+
} else {
79+
normalized[tag]['_base'] = {
80+
rootAttribute,
81+
discriminatorAttributes: new Set<string>(),
82+
attributes,
83+
booleanAttributes,
84+
properties,
85+
}
86+
}
87+
},
88+
)
4589

46-
attributeEntries.forEach(([attr, values]) => {
47-
const valueType = Array.from(values)
48-
.map((v) => `'${v}'`)
49-
.join(' | ')
50-
// Root attribute is used to narrow type and therefore is the only attribute
51-
// that shouldn't be optional (i.e. attr: ... and not attr?: ...)
52-
interfaceDefinition += ` '${attr}'${rootAttribute === attr ? '' : '?'}: ${valueType}\n`
53-
})
90+
console.dir(normalized, { depth: null })
91+
92+
Object.entries(normalized).forEach(([tag, components]) => {
93+
Object.entries(components).forEach(
94+
([
95+
key,
96+
{
97+
rootAttribute,
98+
discriminatorAttributes,
99+
attributes,
100+
booleanAttributes,
101+
properties,
102+
},
103+
]) => {
104+
const interfaceName = `Mist_${key === '_base' ? tag : key}`
105+
106+
const attributeEntries = Object.entries(attributes)
107+
108+
let htmlElement = 'HTMLElement'
109+
if (tag in html) {
110+
htmlElement = html[tag as keyof typeof html]
111+
}
112+
113+
let interfaceDefinition = `interface ${interfaceName} extends React.DetailedHTMLProps<React.HTMLAttributes<${htmlElement}>, ${htmlElement}> {\n`
114+
115+
discriminatorAttributes.forEach((attr) => {
116+
interfaceDefinition += ` '${attr}'?: never\n`
117+
})
54118

55-
booleanAttributes.forEach((attr) => {
56-
interfaceDefinition += ` '${attr}'?: boolean\n`
57-
})
119+
attributeEntries.forEach(([attr, values]) => {
120+
const valueType = Array.from(values)
121+
.map((v) => `'${v}'`)
122+
.join(' | ')
123+
// Root attribute is used to narrow type and therefore is the only attribute
124+
// that shouldn't be optional (i.e. attr: ... and not attr?: ...)
125+
interfaceDefinition += ` '${attr}'${rootAttribute === attr ? '' : '?'}: ${valueType}\n`
126+
})
58127

59-
if (Array.from(properties).length > 0) {
60-
const propertyEntries = Array.from(properties)
61-
.map((prop) => `'${prop}': string`)
62-
.join(', ')
63-
interfaceDefinition += ` style?: { ${propertyEntries} } & React.CSSProperties\n`
64-
}
128+
booleanAttributes.forEach((attr) => {
129+
interfaceDefinition += ` '${attr}'?: boolean\n`
130+
})
65131

66-
interfaceDefinition += '}\n\n'
132+
if (Array.from(properties).length > 0) {
133+
const propertyEntries = Array.from(properties)
134+
.map((prop) => `'${prop}': string`)
135+
.join(', ')
136+
interfaceDefinition += ` style?: { ${propertyEntries} } & React.CSSProperties\n`
137+
}
67138

68-
interfaceDefinitions += interfaceDefinition
139+
interfaceDefinition += '}\n\n'
69140

70-
if (!jsxElements[tag]) {
71-
jsxElements[tag] = []
72-
}
73-
jsxElements[tag].push(interfaceName)
74-
},
75-
)
141+
interfaceDefinitions += interfaceDefinition
142+
143+
if (!jsxElements[tag]) {
144+
jsxElements[tag] = []
145+
}
146+
jsxElements[tag].push(interfaceName)
147+
},
148+
)
149+
})
76150

77151
// Generate the JSX namespace declaration dynamically
78152
let jsxDeclaration =
@@ -151,7 +225,7 @@ _mistcss.postcss = true
151225
const mistcss: PluginCreator<{}> = (_opts = {}) => {
152226
return {
153227
postcssPlugin: 'mistcss',
154-
plugins: [atImport(), _mistcss()]
228+
plugins: [atImport(), _mistcss()],
155229
}
156230
}
157231

test/card.mist.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/* Testing union discriminator */
2+
div[data-component='card'] {
3+
background: gray;
4+
&[data-size='sm'] {
5+
}
6+
&[data-size='xl'] {
7+
}
8+
}
9+
10+
div[data-component='card-title'] {
11+
background: gray;
12+
}

test/mist.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
@import './button.mist.css';
2+
@import './card.mist.css';

test/mist.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,22 @@ interface Mist_button extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLB
22
'data-variant'?: 'primary' | 'secondary'
33
}
44

5+
interface Mist_div extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
6+
'data-component'?: never
7+
}
8+
9+
interface Mist_div_data_component_card extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
10+
'data-component': 'card'
11+
'data-size'?: 'sm' | 'xl'
12+
}
13+
14+
interface Mist_div_data_component_card_title extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
15+
'data-component': 'card-title'
16+
}
17+
518
declare namespace JSX {
619
interface IntrinsicElements {
720
button: Mist_button
21+
div: Mist_div | Mist_div_data_component_card | Mist_div_data_component_card_title
822
}
923
}

0 commit comments

Comments
 (0)