Skip to content

Commit

Permalink
fix: union discriminator (#66)
Browse files Browse the repository at this point in the history
  • Loading branch information
typicode authored Nov 18, 2024
1 parent 713be2b commit 5a35d76
Show file tree
Hide file tree
Showing 5 changed files with 164 additions and 51 deletions.
40 changes: 26 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,16 @@ button {
```jsx
<>
<button data-variant="primary">Save</button>
<button data-variant="tertiary">Save</button> {/* TS error, tertiary isn't valid */}

{/* TS error, tertiary isn't valid */}
<button data-variant="tertiary">Save</button>
</>
```

Output

```jsx
<button data-variant="primary">Save</button> {/* Same as in Page.tsx */}
<button data-variant="primary">Save</button> {/* Same as in Page.tsx */}
```

_This example demonstrates enums, but MistCSS also supports boolean and string props. For more details, see the FAQ._
Expand All @@ -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:

<!-- prettier-ignore-start -->
```typescript
interface Mist_button extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> {
'data-variant'?: 'primary' | 'secondary'
Expand All @@ -78,6 +81,7 @@ declare namespace JSX {
}
}
```
<!-- prettier-ignore-stop -->

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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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] { ... }
Expand All @@ -216,39 +219,48 @@ 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'] { /* ... */ }
}
```

<!-- prettier-ignore-start -->
```jsx
<>
<a href="/home">Home</a>
<a href="/home" data-component="button">Home</a>
<a href="/home" data-component="button" data-variant="primary">Home</a>
<a href="/home" data-variant="primary">Home</a> {/* TS error, `data-variant` is only valid with `data-component="button"` */}

{/* TS error, `data-variant` is only valid with `data-component="button"` */}
<a href="/home" data-variant="primary">Home</a>
</>
```
<!-- prettier-ignore-stop -->

> [!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?

`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`
Expand Down Expand Up @@ -294,7 +306,7 @@ import 'my-ui/mist.css'

`app/mist.d.ts`

```
```typescript
import 'my-ui/mist.d.ts
```

Expand Down
148 changes: 111 additions & 37 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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')

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

Object.entries(parsed).forEach(
([key, { tag, rootAttribute, attributes, booleanAttributes, properties }]) => {
const interfaceName = `Mist_${key}`
// Normalize
type Component = {
rootAttribute: string
discriminatorAttributes: Set<string>
attributes: Record<string, Set<string>>
booleanAttributes: Set<string>
properties: Set<string>
}

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<string>(),
attributes: {},
booleanAttributes: new Set<string>(),
properties: new Set<string>(),
},
}

let interfaceDefinition = `interface ${interfaceName} extends React.DetailedHTMLProps<React.HTMLAttributes<${htmlElement}>, ${htmlElement}> {\n`
if (rootAttribute !== '') {
normalized[tag][key] ??= {
rootAttribute,
discriminatorAttributes: new Set<string>(),
attributes,
booleanAttributes,
properties,
}
normalized[tag]['_base']['discriminatorAttributes'] ??= new Set()
normalized[tag]['_base']['discriminatorAttributes'].add(rootAttribute)
} else {
normalized[tag]['_base'] = {
rootAttribute,
discriminatorAttributes: new Set<string>(),
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<React.HTMLAttributes<${htmlElement}>, ${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 =
Expand Down Expand Up @@ -151,7 +225,7 @@ _mistcss.postcss = true
const mistcss: PluginCreator<{}> = (_opts = {}) => {
return {
postcssPlugin: 'mistcss',
plugins: [atImport(), _mistcss()]
plugins: [atImport(), _mistcss()],
}
}

Expand Down
12 changes: 12 additions & 0 deletions test/card.mist.css
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions test/mist.css
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
@import './button.mist.css';
@import './card.mist.css';
14 changes: 14 additions & 0 deletions test/mist.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,22 @@ interface Mist_button extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLB
'data-variant'?: 'primary' | 'secondary'
}

interface Mist_div extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
'data-component'?: never
}

interface Mist_div_data_component_card extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
'data-component': 'card'
'data-size'?: 'sm' | 'xl'
}

interface Mist_div_data_component_card_title extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, 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
}
}

0 comments on commit 5a35d76

Please sign in to comment.