Skip to content
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

fix: union discriminator #66

Merged
merged 2 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
}
Loading