Skip to content

Commit

Permalink
feat: support multiple components in one mistcss file
Browse files Browse the repository at this point in the history
  • Loading branch information
typicode committed Mar 21, 2024
1 parent 173e593 commit fa1b465
Show file tree
Hide file tree
Showing 8 changed files with 177 additions and 97 deletions.
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ hero:
features:
- icon: 🌐
title: Universal
details: 'Supports Next.js, TailwindCSS, Remix, ... (more to come).'
details: Supports Next.js, TailwindCSS, Remix, ... (more to come).
- icon: 🌸
title: Focus on the Style
details: No more context switching with JS/TS code. Use all modern CSS features directly.
Expand Down
17 changes: 0 additions & 17 deletions fixtures/Button.mist.css

This file was deleted.

16 changes: 0 additions & 16 deletions fixtures/Button.mist.tsx

This file was deleted.

35 changes: 35 additions & 0 deletions fixtures/Foo.mist.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@scope (.foo) {
div:scope {
font-size: 1rem;

&[data-foo-size='lg'] {
font-size: 1.5rem;
}

&[data-foo-size='sm'] {
font-size: 0.75rem;
}

&[data-x] {
color: red;
}
}
}

@scope (.bar) {
span:scope {
&[data-bar-size='lg'] {
font-size: 1.5rem;
}

&[data-x] {
border-color: red;
}
}
}

@scope (.baz) {
p:scope {
font-size: 1rem;
}
}
43 changes: 43 additions & 0 deletions fixtures/Foo.mist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Generated by MistCSS, do not modify
import './Foo.mist.css'

type FooProps = {
children?: React.ReactNode
fooSize?: 'lg' | 'sm'
x?: boolean
} & JSX.IntrinsicElements['div']

export function Foo({ children, fooSize, x, ...props }: FooProps) {
return (
<div {...props} className="Foo" data-fooSize={fooSize} data-x={x}>
{children}
</div>
)
}

type BarProps = {
children?: React.ReactNode
barSize?: 'lg'
x?: boolean
} & JSX.IntrinsicElements['span']

export function Bar({ children, barSize, x, ...props }: BarProps) {
return (
<span {...props} className="Bar" data-barSize={barSize} data-x={x}>
{children}
</span>
)
}

type BazProps = {
children?: React.ReactNode

} & JSX.IntrinsicElements['p']

export function Baz({ children, ...props }: BazProps) {
return (
<p {...props} className="Baz" >
{children}
</p>
)
}
12 changes: 0 additions & 12 deletions fixtures/tsconfig.json

This file was deleted.

32 changes: 20 additions & 12 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,29 @@ import assert from 'node:assert'
import fs from 'node:fs'
import test from 'node:test'

import { type ParsedInput, parseInput, render } from './index.js'
import { type Components, parseInput, render } from './index.js'

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

void test('parseInput', () => {
const input: string = mistCss
const actual: ParsedInput = parseInput(input)
const expected: ParsedInput = {
className: 'button',
tag: 'button',
data: {
size: ['lg', 'sm'],
danger: true,
const actual: Components = parseInput(input)
const expected: Components = {
Foo: {
tag: 'div',
data: {
fooSize: ['lg', 'sm'],
x: true,
},
},
Bar: {
tag: 'span',
data: {
barSize: ['lg'],
x: true,
},
},
}
assert.deepStrictEqual(actual, expected)
Expand All @@ -25,8 +33,8 @@ void test('parseInput', () => {
void test.todo('parseInput with empty input', () => {})

void test('render', () => {
const name = 'Button'
const parsedInput: ParsedInput = parseInput(mistCss)
const name = 'Foo'
const parsedInput: Components = parseInput(mistCss)
const actual = render(name, parsedInput)
const expected: string = tsx
if (process.env['UPDATE']) {
Expand Down
117 changes: 78 additions & 39 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import fs from 'node:fs'
import path from 'node:path'
import { compile, Element } from 'stylis'

export interface ParsedInput {
className: string
// Components in a MistCSS file
export type Components = Record<string, Component>

type Component = {
tag: string
data: Record<string, string[] | boolean>
}
Expand All @@ -12,81 +14,106 @@ const enumDataAttributeRegex =
/\[data-(?<attribute>[a-z-]+)='(?<value>[^']*)'\]/g
const booleanDataAttributeRegex = /\[data-(?<attribute>[a-z-]+)(?=\])/g

function visit(nodes: Element[], arr: { type: string; props: string[] }[]) {
// Visit all nodes in the AST and return @scope and rule nodes
function visit(nodes: Element[]): { type: string; props: string[] }[] {
let result: { type: string; props: string[] }[] = []

for (const node of nodes) {
if (['@scope', 'rule'].includes(node.type) && Array.isArray(node.props)) {
arr.push({ type: node.type, props: node.props })
result.push({ type: node.type, props: node.props })
}

if (Array.isArray(node.children)) {
visit(node.children, arr)
result = result.concat(visit(node.children))
}
}
}

export function parseInput(input: string): ParsedInput {
const result: ParsedInput = { className: '', tag: '', data: {} }
return result
}

const arr: { type: string; props: string[] }[] = []
visit(compile(input), arr)
export function parseInput(input: string): Components {
const components: Components = {}

arr.forEach((node) => {
let name
const nodes = visit(compile(input))
console.log(nodes)
for (const node of nodes) {
// Parse name
if (node.type === '@scope') {
const prop = node.props[0]
if (prop === undefined) {
return
throw new Error('Invalid MistCSS file, no class found in @scope')
}
result.className = prop.replace('(.', '').replace(')', '')
return
name = prop.replace('(.', '').replace(')', '')
// Convert to PascalCase
name = name.replace(/(?:^|-)([a-z])/g, (_, g) => g.toUpperCase())
components[name] = { tag: '', data: {} }
continue
}

// Parse tag and data attributes
if (node.type === 'rule') {
const prop = node.props[0]
if (prop === undefined) {
return
if (prop === undefined || name === undefined) {
continue
}
const component = components[name]
if (component === undefined) {
continue
}

// Parse tag
if (prop.endsWith(':scope')) {
result.tag = prop.replace(':scope', '')
component.tag = prop.replace(':scope', '')
continue
}

// Parse enum data attributes
for (const match of prop.matchAll(enumDataAttributeRegex)) {
const enumMatches = prop.matchAll(enumDataAttributeRegex)
for (const match of enumMatches) {
const attribute = match.groups?.['attribute']
const value = match.groups?.['value'] ?? ''

if (attribute === undefined) {
continue
}

result.data[attribute] ||= []
// Convert to camelCase
const camelCasedAttribute = attribute.replace(
/-([a-z])/g,
(g) => g[1]?.toUpperCase() ?? '',
)

const attr = result.data[attribute]
// Initialize data if it doesn't exist
component.data[camelCasedAttribute] ||= []
const attr = component.data[camelCasedAttribute]
if (Array.isArray(attr) && !attr.includes(value)) {
attr.push(value)
}
continue
}

// Parse boolean data attributes
for (const match of prop.matchAll(booleanDataAttributeRegex)) {
const booleanMatches = prop.matchAll(booleanDataAttributeRegex)
for (const match of booleanMatches) {
const attribute = match.groups?.['attribute']
if (attribute === undefined) {
continue
}

result.data[attribute] ||= true
component.data[attribute] ||= true
continue
}
}
})
}

return result
return components
}

function renderProps(parsedInput: ParsedInput): string {
return Object.keys(parsedInput.data)
function renderProps(component: Component): string {
return Object.keys(component.data)
.map((attribute) => {
const values = parsedInput.data[attribute]
const values = component.data[attribute]
if (Array.isArray(values)) {
return `${attribute}?: ${values
.map((value) => `'${value}'`)
Expand All @@ -98,33 +125,45 @@ function renderProps(parsedInput: ParsedInput): string {
.join('\n')
}

export function render(name: string, parsedInput: ParsedInput): string {
return `// Generated by MistCSS, do not modify
import './${name}.mist.css'
type Props = {
function renderComponent(components: Components, name: string): string {
const component = components[name]
if (component === undefined) {
return ''
}
return `type ${name}Props = {
children?: React.ReactNode
${renderProps(parsedInput)}
} & JSX.IntrinsicElements['${parsedInput.tag}']
${renderProps(component)}
} & JSX.IntrinsicElements['${component.tag}']
export function ${name}({ ${[
'children',
...Object.keys(parsedInput.data),
...Object.keys(component.data),
'...props',
].join(', ')} }: Props) {
].join(', ')} }: ${name}Props) {
return (
<${parsedInput.tag} {...props} className="${parsedInput.className}" ${Object.keys(
parsedInput.data,
<${component.tag} {...props} className="${name}" ${Object.keys(
component.data,
)
.map((key) => `data-${key}={${key}}`)
.join(' ')}>
{children}
</${parsedInput.tag}>
</${component.tag}>
)
}
`
}

export function render(name: string, components: Components): string {
return `// Generated by MistCSS, do not modify
import './${name}.mist.css'
${Object.keys(components)
.map((key) => renderComponent(components, key))
.join('\n')
.trim()}
`
}

export function createFile(filename: string) {
let data = fs.readFileSync(filename, 'utf8')
const parsedInput = parseInput(data)
Expand Down

0 comments on commit fa1b465

Please sign in to comment.