diff --git a/README.md b/README.md index 04e3158..2a087a1 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ All major frameworks are supported. color: white; /* 👇 Define component's props directly in your CSS */ - &[data-variant="primary"] { + &[data-variant='primary'] { background: blue; } - &[data-variant="secondary"] { + &[data-variant='secondary'] { background: gray; } } @@ -54,7 +54,7 @@ export const App = () => ( ) ``` -MistCSS can generate ⚛️ __React__, 💚 __Vue__, 🚀 __Astro__ and 🔥 __Hono__ components. You can use 🍃 __Tailwind CSS__ to style them. +MistCSS can generate ⚛️ **React**, 💚 **Vue**, 🚀 **Astro**, 🧠**Svelte** and 🔥 **Hono** components. You can use 🍃 **Tailwind CSS** to style them. ## Documentation @@ -66,6 +66,7 @@ https://typicode.github.io/mistcss - [Remix](https://remix.run/) - [React](https://react.dev/) - [Vue](https://vuejs.org) +- [Svelte](https://svelte.dev/) - [Astro](https://astro.build/) - [Hono](https://hono.dev/) - [Tailwind CSS](https://tailwindcss.com/) diff --git a/docs/src/content/docs/integration/frameworks.mdx b/docs/src/content/docs/integration/frameworks.mdx index a715357..64827c8 100644 --- a/docs/src/content/docs/integration/frameworks.mdx +++ b/docs/src/content/docs/integration/frameworks.mdx @@ -16,6 +16,12 @@ mistcss ./components --target=react mistcss ./components --target=vue ``` +## Svelte + +```sh +mistcss ./components --target=svelte +``` + ## Astro ```sh diff --git a/docs/src/content/docs/intro.mdx b/docs/src/content/docs/intro.mdx index bb714be..e7a6f13 100644 --- a/docs/src/content/docs/intro.mdx +++ b/docs/src/content/docs/intro.mdx @@ -20,8 +20,9 @@ Supports: - [Remix](https://remix.run/) - [React](https://react.dev/) - [Vue](https://vuejs.org) +- [Svelte](https://svelte.dev/) - [Astro](https://astro.build/) - [Hono](https://hono.dev/) - [Tailwind CSS](https://tailwindcss.com/) -__Bonus__: if you need to switch from one framework to another, you won't have to rewrite your components. Simply change MistCSS compilation target. \ No newline at end of file +__Bonus__: if you need to switch from one framework to another, you won't have to rewrite your components. Simply change MistCSS compilation target. diff --git a/src/bin.ts b/src/bin.ts index 32f17f5..93b76c9 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import fsPromises from 'node:fs/promises' import fs from 'node:fs' +import fsPromises from 'node:fs/promises' import path from 'node:path' import { parseArgs } from 'node:util' @@ -8,12 +8,13 @@ import chokidar from 'chokidar' import { globby } from 'globby' import { parse } from './parser.js' -import { render as reactRender } from './renderers/react.js' import { render as astroRender } from './renderers/astro.js' +import { render as reactRender } from './renderers/react.js' +import { render as svelteRender } from './renderers/svelte.js' import { render as vueRender } from './renderers/vue.js' -type Extension = '.tsx' | '.astro' -type Target = 'react' | 'hono' | 'astro' | 'vue'; +type Extension = '.tsx' | '.astro' | '.svelte' +type Target = 'react' | 'hono' | 'astro' | 'vue' | 'svelte' function createFile(mist: string, target: Target, ext: Extension) { try { @@ -34,6 +35,9 @@ function createFile(mist: string, target: Target, ext: Extension) { case 'vue': result = vueRender(name, data[0]) break + case 'svelte': + result = svelteRender(name, data[0]) + break } fs.writeFileSync(mist.replace(/\.css$/, ext), result) } @@ -50,7 +54,7 @@ function createFile(mist: string, target: Target, ext: Extension) { function usage() { console.log(`Usage: mistcss [options] --watch, -w Watch for changes - --target, -t Render target (react, vue, astro, hono) [default: react] + --target, -t Render target (react, vue, astro, hono, svelte) [default: react] `) } @@ -84,8 +88,14 @@ if (!(await fsPromises.stat(dir)).isDirectory()) { process.exit(1) } -const { target } = values; -if (target !== 'react' && target !== 'hono' && target !== 'astro' && target !== 'vue') { +const { target } = values +if ( + target !== 'react' && + target !== 'hono' && + target !== 'astro' && + target !== 'vue' && + target !== 'svelte' +) { console.error('Invalid render option') usage() process.exit(1) @@ -110,6 +120,10 @@ switch (target) { ext = '.tsx' console.log('Rendering Vue components') break + case 'svelte': + ext = '.svelte' + console.log('Rendering Svelte components') + break default: console.error('Invalid target option') usage() diff --git a/src/renderers/__snapshots__/svelte.test.ts.snap b/src/renderers/__snapshots__/svelte.test.ts.snap new file mode 100644 index 0000000..94a356e --- /dev/null +++ b/src/renderers/__snapshots__/svelte.test.ts.snap @@ -0,0 +1,52 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`render > renders Svelte component (full) 1`] = ` +" + +
+" +`; + +exports[`render > renders Svelte component (minimal) 1`] = ` +" + +
+" +`; + +exports[`render > renders Svelte component (void element) 1`] = ` +" + +
+" +`; diff --git a/src/renderers/_common.ts b/src/renderers/_common.ts index 05f2caa..ae93b6f 100644 --- a/src/renderers/_common.ts +++ b/src/renderers/_common.ts @@ -1,5 +1,5 @@ -import { attributeToCamelCase, propertyToCamelCase } from './_case.js' import { Data } from '../parser.js' +import { attributeToCamelCase, propertyToCamelCase } from './_case.js' // https://html.spec.whatwg.org/multipage/syntax.html#void-elements const voidElements = new Set([ @@ -65,9 +65,30 @@ export function renderPropsInterface(data: Data, extendedType: string): string { ].join(' ') } +function renderSvelteStyle(properties: Data['properties']): string { + return Array.from(properties) + .map((property) => `style:${property}={${propertyToCamelCase(property)}}`) + .join(' ') +} + +function renderStyleObject(properties: Data['properties']) { + return [ + 'style={{ ', + Array.from(properties) + .map((property) => `'${property}': ${propertyToCamelCase(property)}`) + .join(', '), + ' }}', + ].join('') +} + // Example: //
{children}
-export function renderTag(data: Data, slotText: string, classText: string): string { +export function renderTag( + data: Data, + slotText: string, + classText: string, + styleFormat: 'object' | 'svelte', +): string { return [ `<${data.tag}`, '{...props}', @@ -86,21 +107,13 @@ export function renderTag(data: Data, slotText: string, classText: string): stri .join(' ') : null, data.properties.size - ? [ - 'style={{ ', - Array.from(data.properties) - .map( - (property) => `'${property}': ${propertyToCamelCase(property)}`, - ) - .join(', '), - ' }}', - ].join('') + ? styleFormat === 'object' + ? renderStyleObject(data.properties) + : renderSvelteStyle(data.properties) : null, `${classText}="${data.className}"`, - hasChildren(data.tag) - ? [`>${slotText}`] - : '/>', + hasChildren(data.tag) ? [`>${slotText}`] : '/>', ] .filter((x) => x !== null) .join(' ') -} \ No newline at end of file +} diff --git a/src/renderers/astro.ts b/src/renderers/astro.ts index d414eb6..eb829a7 100644 --- a/src/renderers/astro.ts +++ b/src/renderers/astro.ts @@ -1,6 +1,6 @@ -import { attributeToCamelCase, propertyToCamelCase } from './_case.js' import { Data } from '../parser.js' -import { renderTag, renderPropsInterface } from './_common.js' +import { attributeToCamelCase, propertyToCamelCase } from './_case.js' +import { renderPropsInterface,renderTag } from './_common.js' function renderProps(data: Data): string { return [ @@ -27,6 +27,6 @@ ${renderPropsInterface(data, `HTMLAttributes<'${data.tag}'>`)} ${renderProps(data)} --- -${renderTag(data, '', 'class')} +${renderTag(data, '', 'class', 'object')} ` } diff --git a/src/renderers/react.ts b/src/renderers/react.ts index 9ea79ea..1acac79 100644 --- a/src/renderers/react.ts +++ b/src/renderers/react.ts @@ -1,9 +1,9 @@ +import { Data } from '../parser.js' import { attributeToCamelCase, pascalCase, propertyToCamelCase, } from './_case.js' -import { Data } from '../parser.js' import { hasChildren, renderPropsInterface, renderTag } from './_common.js' function renderImports(data: Data, isHono: boolean): string { @@ -35,7 +35,7 @@ function renderFunction(data: Data, isClass: boolean): string { * ${data.comment} */ export function ${pascalCase(data.className)}({ ${args} }: ${hasChildren(data.tag) ? `PropsWithChildren` : `Props`}) { - return (${renderTag(data, '{children}', isClass ? 'class' : 'className')}) + return (${renderTag(data, '{children}', isClass ? 'class' : 'className', 'object')}) }` } diff --git a/src/renderers/svelte.test.ts b/src/renderers/svelte.test.ts new file mode 100644 index 0000000..0b42199 --- /dev/null +++ b/src/renderers/svelte.test.ts @@ -0,0 +1,48 @@ +import { expect, it, describe } from 'vitest' + +import { Data } from '../parser.js' +import { render } from './svelte.js' + +describe('render', () => { + it('renders Svelte component (full)', () => { + const data: Data = { + tag: 'div', + className: 'foo', + attributes: { + 'data-attr': new Set(['a', 'b']), + 'data-attr-foo-bar': new Set(['foo-bar']), + }, + booleanAttributes: new Set(['data-is-foo']), + properties: new Set(['--prop-foo', '--prop-bar']), + } + + const result = render('component', data) + expect(result).toMatchSnapshot() + }) + + it('renders Svelte component (minimal)', () => { + const data: Data = { + tag: 'div', + className: 'foo', + attributes: {}, + booleanAttributes: new Set(), + properties: new Set(), + } + + const result = render('component', data) + expect(result).toMatchSnapshot() + }) + + it('renders Svelte component (void element)', () => { + const data: Data = { + tag: 'hr', // hr is a void element and should not have children + className: 'foo', + attributes: {}, + booleanAttributes: new Set(), + properties: new Set(), + } + + const result = render('component', data) + expect(result).toMatchSnapshot() + }) +}) diff --git a/src/renderers/svelte.ts b/src/renderers/svelte.ts new file mode 100644 index 0000000..dd1e734 --- /dev/null +++ b/src/renderers/svelte.ts @@ -0,0 +1,33 @@ +import { Data } from '../parser.js' +import { attributeToCamelCase, propertyToCamelCase } from './_case.js' +import { renderPropsInterface, renderTag } from './_common.js' + +function renderProps(data: Data): string { + return [ + 'const {', + [ + ...Object.keys(data.attributes).map(attributeToCamelCase), + ...Array.from(data.booleanAttributes).map(attributeToCamelCase), + ...Array.from(data.properties).map(propertyToCamelCase), + '...props', + ].join(', '), + '} = $$props', + ].join(' ') +} + +export function render(filename: string, data: Data): string { + return ` + +${renderTag(data, '', 'class', 'svelte')} +` +} diff --git a/src/renderers/vue.ts b/src/renderers/vue.ts index 8193a88..cb7feef 100644 --- a/src/renderers/vue.ts +++ b/src/renderers/vue.ts @@ -1,6 +1,6 @@ -import { attributeToCamelCase, pascalCase, propertyToCamelCase } from './_case.js' import { Data } from '../parser.js' -import { renderTag, renderPropsInterface, hasChildren } from './_common.js' +import { attributeToCamelCase, pascalCase, propertyToCamelCase } from './_case.js' +import { hasChildren,renderPropsInterface, renderTag } from './_common.js' function renderFunction(data: Data): string { const args = [ @@ -14,7 +14,7 @@ function renderFunction(data: Data): string { * ${data.comment} */ export function ${pascalCase(data.className)}({ ${args} }: Props${hasChildren(data.tag) ? ', { slots }: SetupContext' : ''}) { - return (${renderTag(data, '{slots.default?.()}', 'class')}) + return (${renderTag(data, '{slots.default?.()}', 'class', 'object')}) }` }