Skip to content

Commit 98117a3

Browse files
committed
Add support for passing styles as CSS fields
Previously, all style props were passed using DOM (`backgroundColor`) casing. This does not work in some any other JSX runtimes. Instead, some JSX runtimes want `background-color` instead. This adds an option for that. This commit also fixes support for CSS custom properties (`--fg: red`), which always have to include those initial dashes, even when setting to the DOM.
1 parent 56d8336 commit 98117a3

File tree

4 files changed

+183
-19
lines changed

4 files changed

+183
-19
lines changed

Diff for: index.js

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* @typedef {import('./lib/index.js').Props} Props
99
* @typedef {import('./lib/index.js').Source} Source
1010
* @typedef {import('./lib/index.js').Space} Space
11+
* @typedef {import('./lib/index.js').StylePropertyNameCase} StylePropertyNameCase
1112
*/
1213

1314
export {toJsxRuntime} from './lib/index.js'

Diff for: lib/index.js

+73-10
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,17 @@
4646
* @typedef {'html' | 'svg'} Space
4747
* Namespace.
4848
*
49-
* @typedef {'react' | 'html'} ElementAttributeNameCase
50-
* Specify casing to use for attribute names.
49+
* @typedef {'html' | 'react'} ElementAttributeNameCase
50+
* Casing to use for attribute names.
5151
*
52-
* React casing is for example `className`, `strokeLinecap`, `xmlLang`.
5352
* HTML casing is for example `class`, `stroke-linecap`, `xml:lang`.
53+
* React casing is for example `className`, `strokeLinecap`, `xmlLang`.
54+
*
55+
* @typedef {'css' | 'dom'} StylePropertyNameCase
56+
* Casing to use for property names in `style` objects.
57+
*
58+
* CSS casing is for example `background-color` and `-webkit-line-clamp`.
59+
* DOM casing is for example `backgroundColor` and `WebkitLineClamp`.
5460
*
5561
* @typedef Source
5662
* Info about source.
@@ -99,6 +105,8 @@
99105
* Pass `node` to components.
100106
* @property {ElementAttributeNameCase} elementAttributeNameCase
101107
* Casing to use for attribute names.
108+
* @property {StylePropertyNameCase} stylePropertyNameCase
109+
* Casing to use for property names in `style` objects.
102110
* @property {Schema} schema
103111
* Current schema.
104112
* @property {unknown} Fragment
@@ -122,6 +130,8 @@
122130
* Pass the hast element node to components.
123131
* @property {ElementAttributeNameCase | null | undefined} [elementAttributeNameCase='react']
124132
* Specify casing to use for attribute names.
133+
* @property {StylePropertyNameCase | null | undefined} [stylePropertyNameCase='dom']
134+
* Specify casing to use for property names in `style` objects.
125135
* @property {Space | null | undefined} [space='html']
126136
* Whether `tree` is in the `'html'` or `'svg'` space.
127137
*
@@ -195,6 +205,9 @@ import {whitespace} from 'hast-util-whitespace'
195205

196206
const own = {}.hasOwnProperty
197207

208+
const cap = /[A-Z]/g
209+
const dashSomething = /-([a-z])/g
210+
198211
// `react-dom` triggers a warning for *any* white space in tables.
199212
// To follow GFM, `mdast-util-to-hast` injects line endings between elements.
200213
// Other tools might do so too, but they don’t do here, so we remove all of
@@ -255,6 +268,7 @@ export function toJsxRuntime(tree, options) {
255268
schema: options.space === 'svg' ? svg : html,
256269
passNode: options.passNode || false,
257270
elementAttributeNameCase: options.elementAttributeNameCase || 'react',
271+
stylePropertyNameCase: options.stylePropertyNameCase || 'dom',
258272
components: options.components || {},
259273
filePath,
260274
create
@@ -485,8 +499,15 @@ function createProperty(state, node, prop, value) {
485499
}
486500

487501
// React only accepts `style` as object.
488-
if (info.property === 'style' && typeof value === 'string') {
489-
return ['style', parseStyle(state, node, value)]
502+
if (info.property === 'style') {
503+
let styleObject =
504+
typeof value === 'object' ? value : parseStyle(state, node, String(value))
505+
506+
if (state.stylePropertyNameCase === 'css') {
507+
styleObject = transformStyleToCssCasing(styleObject)
508+
}
509+
510+
return ['style', styleObject]
490511
}
491512

492513
return [
@@ -534,8 +555,8 @@ function parseStyle(state, node, value) {
534555
return result
535556

536557
/**
537-
* Add a CSS property (normal, so with dashes) to `result` as a camelcased
538-
* CSS property.
558+
* Add a CSS property (normal, so with dashes) to `result` as a DOM CSS
559+
* property.
539560
*
540561
* @param {string} name
541562
* Key.
@@ -545,9 +566,39 @@ function parseStyle(state, node, value) {
545566
* Nothing.
546567
*/
547568
function replacer(name, value) {
548-
if (name.slice(0, 4) === '-ms-') name = 'ms-' + name.slice(4)
549-
result[name.replace(/-([a-z])/g, replace)] = value
569+
let key = name
570+
571+
if (key.slice(0, 2) !== '--') {
572+
if (key.slice(0, 4) === '-ms-') key = 'ms-' + key.slice(4)
573+
key = key.replace(dashSomething, toCamel)
574+
}
575+
576+
result[key] = value
577+
}
578+
}
579+
580+
/**
581+
* Transform a DOM casing style object to a CSS casing style object.
582+
*
583+
* @param {Style} domCasing
584+
* @returns {Style}
585+
*/
586+
function transformStyleToCssCasing(domCasing) {
587+
/** @type {Style} */
588+
const cssCasing = {}
589+
/** @type {string} */
590+
let from
591+
592+
for (from in domCasing) {
593+
if (own.call(domCasing, from)) {
594+
let to = from.replace(cap, toDash)
595+
// Handle `ms-xxx` -> `-ms-xxx`.
596+
if (to.slice(0, 3) === 'ms-') to = '-' + to
597+
cssCasing[to] = domCasing[from]
598+
}
550599
}
600+
601+
return cssCasing
551602
}
552603

553604
/**
@@ -560,6 +611,18 @@ function parseStyle(state, node, value) {
560611
* @returns {string}
561612
* Capitalized `$1`.
562613
*/
563-
function replace(_, $1) {
614+
function toCamel(_, $1) {
564615
return $1.toUpperCase()
565616
}
617+
618+
/**
619+
* Make `$0` dash cased.
620+
*
621+
* @param {string} $0
622+
* Capitalized ASCII leter.
623+
* @returns {string}
624+
* Dash and lower letter.
625+
*/
626+
function toDash($0) {
627+
return '-' + $0.toLowerCase()
628+
}

Diff for: readme.md

+32-9
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ with an automatic JSX runtime.
2828
* [`Props`](#props)
2929
* [`Source`](#source)
3030
* [`Space`](#space-1)
31+
* [`StylePropertyNameCase`](#stylepropertynamecase-1)
3132
* [Examples](#examples)
3233
* [Example: Preact](#example-preact)
3334
* [Example: Vue](#example-vue)
@@ -157,19 +158,19 @@ Components to use ([`Partial<Components>`][api-components], optional).
157158
Each key is the name of an HTML (or SVG) element to override.
158159
The value is the component to render instead.
159160

161+
###### `elementAttributeNameCase`
162+
163+
Specify casing to use for attribute names
164+
([`ElementAttributeNameCase`][api-element-attribute-name-case], default:
165+
`'react'`).
166+
160167
###### `filePath`
161168

162169
File path to the original source file (`string`, optional).
163170

164171
Passed in source info to `jsxDEV` when using the automatic runtime with
165172
`development: true`.
166173

167-
###### `elementAttributeNameCase`
168-
169-
Specify casing to use for attribute names
170-
([`ElementAttributeNameCase`][api-element-attribute-name-case], default:
171-
`'react'`).
172-
173174
###### `passNode`
174175

175176
Pass the hast element node to components (`boolean`, default: `false`).
@@ -189,6 +190,12 @@ it.
189190
> Passing SVG might break but fragments of modern SVG should be fine.
190191
> Use `xast` if you need to support SVG as XML.
191192
193+
###### `stylePropertyNameCase`
194+
195+
Specify casing to use for property names in `style` objects
196+
([`StylePropertyNameCase`][api-style-property-name-case], default:
197+
`'dom'`).
198+
192199
### `Components`
193200

194201
Possible components to use (TypeScript type).
@@ -222,10 +229,10 @@ type Component<ComponentProps> =
222229
223230
### `ElementAttributeNameCase`
224231
225-
Specify casing to use for attribute names (TypeScript type).
232+
Casing to use for attribute names (TypeScript type).
226233
227-
React casing is for example `className`, `strokeLinecap`, `xmlLang`.
228234
HTML casing is for example `class`, `stroke-linecap`, `xml:lang`.
235+
React casing is for example `className`, `strokeLinecap`, `xmlLang`.
229236
230237
###### Type
231238
@@ -329,6 +336,19 @@ Namespace (TypeScript type).
329336
type Space = 'html' | 'svg'
330337
```
331338
339+
### `StylePropertyNameCase`
340+
341+
Casing to use for property names in `style` objects (TypeScript type).
342+
343+
CSS casing is for example `background-color` and `-webkit-line-clamp`.
344+
DOM casing is for example `backgroundColor` and `WebkitLineClamp`.
345+
346+
###### Type
347+
348+
```ts
349+
type StylePropertyNameCase = 'dom' | 'css'
350+
```
351+
332352
## Examples
333353
334354
### Example: Preact
@@ -420,7 +440,8 @@ It exports the additional types [`Components`][api-components],
420440
[`ElementAttributeNameCase`][api-element-attribute-name-case],
421441
[`Fragment`][api-fragment], [`Jsx`][api-jsx], [`JsxDev`][api-jsx-dev],
422442
[`Options`][api-options], [`Props`][api-props], [`Source`][api-source],
423-
and [`Space`][api-Space].
443+
[`Space`][api-Space],
444+
and [`StylePropertyNameCase`][api-style-property-name-case].
424445

425446
The function `toJsxRuntime` returns a `JSX.Element`, which means that the JSX
426447
namespace has to by typed.
@@ -537,3 +558,5 @@ abide by its terms.
537558
[api-source]: #source
538559

539560
[api-space]: #space-1
561+
562+
[api-style-property-name-case]: #stylepropertynamecase-1

Diff for: test/index.js

+77
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,83 @@ test('properties', () => {
182182
'should support `style`'
183183
)
184184

185+
assert.equal(
186+
renderToStaticMarkup(
187+
toJsxRuntime(
188+
{
189+
type: 'element',
190+
tagName: 'div',
191+
// @ts-expect-error: style as object, normally not supported, but passed through here.
192+
properties: {style: {color: 'red'}},
193+
children: []
194+
},
195+
production
196+
)
197+
),
198+
'<div style="color:red"></div>',
199+
'should support `style` as an object'
200+
)
201+
202+
assert.equal(
203+
renderToStaticMarkup(
204+
toJsxRuntime(
205+
h('div', {style: '-webkit-transform: rotate(0.01turn)'}),
206+
production
207+
)
208+
),
209+
'<div style="-webkit-transform:rotate(0.01turn)"></div>',
210+
'should support vendor prefixes'
211+
)
212+
213+
assert.equal(
214+
renderToStaticMarkup(
215+
toJsxRuntime(
216+
h('div', {style: '--fg: #0366d6; color: var(--fg)'}),
217+
production
218+
)
219+
),
220+
'<div style="--fg:#0366d6;color:var(--fg)"></div>',
221+
'should support CSS variables'
222+
)
223+
224+
/** @type {unknown} */
225+
let foundProps
226+
227+
assert.equal(
228+
renderToStaticMarkup(
229+
toJsxRuntime(
230+
h('div', {
231+
style:
232+
'-webkit-transform:rotate(0.01turn); --fg: #0366d6; color: var(--fg); -ms-transition: unset'
233+
}),
234+
{
235+
...production,
236+
jsx(type, props) {
237+
foundProps = props
238+
return production.jsx('div', {children: []}, undefined)
239+
},
240+
stylePropertyNameCase: 'css'
241+
}
242+
)
243+
),
244+
'<div></div>',
245+
'should support CSS cased style objects (1)'
246+
)
247+
248+
assert.deepEqual(
249+
foundProps,
250+
{
251+
children: undefined,
252+
style: {
253+
'-webkit-transform': 'rotate(0.01turn)',
254+
'--fg': '#0366d6',
255+
color: 'var(--fg)',
256+
'-ms-transition': 'unset'
257+
}
258+
},
259+
'should support CSS cased style objects (2)'
260+
)
261+
185262
assert.throws(
186263
() => {
187264
toJsxRuntime(h('div', {style: 'color:red; /*'}), production)

0 commit comments

Comments
 (0)