Skip to content

Commit fa1b465

Browse files
committed
feat: support multiple components in one mistcss file
1 parent 173e593 commit fa1b465

8 files changed

Lines changed: 177 additions & 97 deletions

File tree

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ hero:
2020
features:
2121
- icon: 🌐
2222
title: Universal
23-
details: 'Supports Next.js, TailwindCSS, Remix, ... (more to come).'
23+
details: Supports Next.js, TailwindCSS, Remix, ... (more to come).
2424
- icon: 🌸
2525
title: Focus on the Style
2626
details: No more context switching with JS/TS code. Use all modern CSS features directly.

fixtures/Button.mist.css

Lines changed: 0 additions & 17 deletions
This file was deleted.

fixtures/Button.mist.tsx

Lines changed: 0 additions & 16 deletions
This file was deleted.

fixtures/Foo.mist.css

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
@scope (.foo) {
2+
div:scope {
3+
font-size: 1rem;
4+
5+
&[data-foo-size='lg'] {
6+
font-size: 1.5rem;
7+
}
8+
9+
&[data-foo-size='sm'] {
10+
font-size: 0.75rem;
11+
}
12+
13+
&[data-x] {
14+
color: red;
15+
}
16+
}
17+
}
18+
19+
@scope (.bar) {
20+
span:scope {
21+
&[data-bar-size='lg'] {
22+
font-size: 1.5rem;
23+
}
24+
25+
&[data-x] {
26+
border-color: red;
27+
}
28+
}
29+
}
30+
31+
@scope (.baz) {
32+
p:scope {
33+
font-size: 1rem;
34+
}
35+
}

fixtures/Foo.mist.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Generated by MistCSS, do not modify
2+
import './Foo.mist.css'
3+
4+
type FooProps = {
5+
children?: React.ReactNode
6+
fooSize?: 'lg' | 'sm'
7+
x?: boolean
8+
} & JSX.IntrinsicElements['div']
9+
10+
export function Foo({ children, fooSize, x, ...props }: FooProps) {
11+
return (
12+
<div {...props} className="Foo" data-fooSize={fooSize} data-x={x}>
13+
{children}
14+
</div>
15+
)
16+
}
17+
18+
type BarProps = {
19+
children?: React.ReactNode
20+
barSize?: 'lg'
21+
x?: boolean
22+
} & JSX.IntrinsicElements['span']
23+
24+
export function Bar({ children, barSize, x, ...props }: BarProps) {
25+
return (
26+
<span {...props} className="Bar" data-barSize={barSize} data-x={x}>
27+
{children}
28+
</span>
29+
)
30+
}
31+
32+
type BazProps = {
33+
children?: React.ReactNode
34+
35+
} & JSX.IntrinsicElements['p']
36+
37+
export function Baz({ children, ...props }: BazProps) {
38+
return (
39+
<p {...props} className="Baz" >
40+
{children}
41+
</p>
42+
)
43+
}

fixtures/tsconfig.json

Lines changed: 0 additions & 12 deletions
This file was deleted.

src/index.test.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,29 @@ import assert from 'node:assert'
22
import fs from 'node:fs'
33
import test from 'node:test'
44

5-
import { type ParsedInput, parseInput, render } from './index.js'
5+
import { type Components, parseInput, render } from './index.js'
66

77
// Fixtures
8-
const mistCss: string = fs.readFileSync('fixtures/Button.mist.css', 'utf-8')
9-
const tsx: string = fs.readFileSync('fixtures/Button.mist.tsx', 'utf-8')
8+
const mistCss: string = fs.readFileSync('fixtures/Foo.mist.css', 'utf-8')
9+
const tsx: string = fs.readFileSync('fixtures/Foo.mist.tsx', 'utf-8')
1010

1111
void test('parseInput', () => {
1212
const input: string = mistCss
13-
const actual: ParsedInput = parseInput(input)
14-
const expected: ParsedInput = {
15-
className: 'button',
16-
tag: 'button',
17-
data: {
18-
size: ['lg', 'sm'],
19-
danger: true,
13+
const actual: Components = parseInput(input)
14+
const expected: Components = {
15+
Foo: {
16+
tag: 'div',
17+
data: {
18+
fooSize: ['lg', 'sm'],
19+
x: true,
20+
},
21+
},
22+
Bar: {
23+
tag: 'span',
24+
data: {
25+
barSize: ['lg'],
26+
x: true,
27+
},
2028
},
2129
}
2230
assert.deepStrictEqual(actual, expected)
@@ -25,8 +33,8 @@ void test('parseInput', () => {
2533
void test.todo('parseInput with empty input', () => {})
2634

2735
void test('render', () => {
28-
const name = 'Button'
29-
const parsedInput: ParsedInput = parseInput(mistCss)
36+
const name = 'Foo'
37+
const parsedInput: Components = parseInput(mistCss)
3038
const actual = render(name, parsedInput)
3139
const expected: string = tsx
3240
if (process.env['UPDATE']) {

src/index.ts

Lines changed: 78 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import fs from 'node:fs'
22
import path from 'node:path'
33
import { compile, Element } from 'stylis'
44

5-
export interface ParsedInput {
6-
className: string
5+
// Components in a MistCSS file
6+
export type Components = Record<string, Component>
7+
8+
type Component = {
79
tag: string
810
data: Record<string, string[] | boolean>
911
}
@@ -12,81 +14,106 @@ const enumDataAttributeRegex =
1214
/\[data-(?<attribute>[a-z-]+)='(?<value>[^']*)'\]/g
1315
const booleanDataAttributeRegex = /\[data-(?<attribute>[a-z-]+)(?=\])/g
1416

15-
function visit(nodes: Element[], arr: { type: string; props: string[] }[]) {
17+
// Visit all nodes in the AST and return @scope and rule nodes
18+
function visit(nodes: Element[]): { type: string; props: string[] }[] {
19+
let result: { type: string; props: string[] }[] = []
20+
1621
for (const node of nodes) {
1722
if (['@scope', 'rule'].includes(node.type) && Array.isArray(node.props)) {
18-
arr.push({ type: node.type, props: node.props })
23+
result.push({ type: node.type, props: node.props })
1924
}
2025

2126
if (Array.isArray(node.children)) {
22-
visit(node.children, arr)
27+
result = result.concat(visit(node.children))
2328
}
2429
}
25-
}
2630

27-
export function parseInput(input: string): ParsedInput {
28-
const result: ParsedInput = { className: '', tag: '', data: {} }
31+
return result
32+
}
2933

30-
const arr: { type: string; props: string[] }[] = []
31-
visit(compile(input), arr)
34+
export function parseInput(input: string): Components {
35+
const components: Components = {}
3236

33-
arr.forEach((node) => {
37+
let name
38+
const nodes = visit(compile(input))
39+
console.log(nodes)
40+
for (const node of nodes) {
41+
// Parse name
3442
if (node.type === '@scope') {
3543
const prop = node.props[0]
3644
if (prop === undefined) {
37-
return
45+
throw new Error('Invalid MistCSS file, no class found in @scope')
3846
}
39-
result.className = prop.replace('(.', '').replace(')', '')
40-
return
47+
name = prop.replace('(.', '').replace(')', '')
48+
// Convert to PascalCase
49+
name = name.replace(/(?:^|-)([a-z])/g, (_, g) => g.toUpperCase())
50+
components[name] = { tag: '', data: {} }
51+
continue
4152
}
4253

54+
// Parse tag and data attributes
4355
if (node.type === 'rule') {
4456
const prop = node.props[0]
45-
if (prop === undefined) {
46-
return
57+
if (prop === undefined || name === undefined) {
58+
continue
59+
}
60+
const component = components[name]
61+
if (component === undefined) {
62+
continue
4763
}
4864

4965
// Parse tag
5066
if (prop.endsWith(':scope')) {
51-
result.tag = prop.replace(':scope', '')
67+
component.tag = prop.replace(':scope', '')
68+
continue
5269
}
5370

5471
// Parse enum data attributes
55-
for (const match of prop.matchAll(enumDataAttributeRegex)) {
72+
const enumMatches = prop.matchAll(enumDataAttributeRegex)
73+
for (const match of enumMatches) {
5674
const attribute = match.groups?.['attribute']
5775
const value = match.groups?.['value'] ?? ''
5876

5977
if (attribute === undefined) {
6078
continue
6179
}
6280

63-
result.data[attribute] ||= []
81+
// Convert to camelCase
82+
const camelCasedAttribute = attribute.replace(
83+
/-([a-z])/g,
84+
(g) => g[1]?.toUpperCase() ?? '',
85+
)
6486

65-
const attr = result.data[attribute]
87+
// Initialize data if it doesn't exist
88+
component.data[camelCasedAttribute] ||= []
89+
const attr = component.data[camelCasedAttribute]
6690
if (Array.isArray(attr) && !attr.includes(value)) {
6791
attr.push(value)
6892
}
93+
continue
6994
}
7095

7196
// Parse boolean data attributes
72-
for (const match of prop.matchAll(booleanDataAttributeRegex)) {
97+
const booleanMatches = prop.matchAll(booleanDataAttributeRegex)
98+
for (const match of booleanMatches) {
7399
const attribute = match.groups?.['attribute']
74100
if (attribute === undefined) {
75101
continue
76102
}
77103

78-
result.data[attribute] ||= true
104+
component.data[attribute] ||= true
105+
continue
79106
}
80107
}
81-
})
108+
}
82109

83-
return result
110+
return components
84111
}
85112

86-
function renderProps(parsedInput: ParsedInput): string {
87-
return Object.keys(parsedInput.data)
113+
function renderProps(component: Component): string {
114+
return Object.keys(component.data)
88115
.map((attribute) => {
89-
const values = parsedInput.data[attribute]
116+
const values = component.data[attribute]
90117
if (Array.isArray(values)) {
91118
return `${attribute}?: ${values
92119
.map((value) => `'${value}'`)
@@ -98,33 +125,45 @@ function renderProps(parsedInput: ParsedInput): string {
98125
.join('\n')
99126
}
100127

101-
export function render(name: string, parsedInput: ParsedInput): string {
102-
return `// Generated by MistCSS, do not modify
103-
import './${name}.mist.css'
104-
105-
type Props = {
128+
function renderComponent(components: Components, name: string): string {
129+
const component = components[name]
130+
if (component === undefined) {
131+
return ''
132+
}
133+
return `type ${name}Props = {
106134
children?: React.ReactNode
107-
${renderProps(parsedInput)}
108-
} & JSX.IntrinsicElements['${parsedInput.tag}']
135+
${renderProps(component)}
136+
} & JSX.IntrinsicElements['${component.tag}']
109137
110138
export function ${name}({ ${[
111139
'children',
112-
...Object.keys(parsedInput.data),
140+
...Object.keys(component.data),
113141
'...props',
114-
].join(', ')} }: Props) {
142+
].join(', ')} }: ${name}Props) {
115143
return (
116-
<${parsedInput.tag} {...props} className="${parsedInput.className}" ${Object.keys(
117-
parsedInput.data,
144+
<${component.tag} {...props} className="${name}" ${Object.keys(
145+
component.data,
118146
)
119147
.map((key) => `data-${key}={${key}}`)
120148
.join(' ')}>
121149
{children}
122-
</${parsedInput.tag}>
150+
</${component.tag}>
123151
)
124152
}
125153
`
126154
}
127155

156+
export function render(name: string, components: Components): string {
157+
return `// Generated by MistCSS, do not modify
158+
import './${name}.mist.css'
159+
160+
${Object.keys(components)
161+
.map((key) => renderComponent(components, key))
162+
.join('\n')
163+
.trim()}
164+
`
165+
}
166+
128167
export function createFile(filename: string) {
129168
let data = fs.readFileSync(filename, 'utf8')
130169
const parsedInput = parseInput(data)

0 commit comments

Comments
 (0)