Skip to content

Commit

Permalink
fix(jsx): fix handling of SVG elements in JSX. (#3204)
Browse files Browse the repository at this point in the history
* fix(jsx): preserve "title" element position in SVG

* fix(jsx): convert SVG attribute name from camelCase to kebab-case
  • Loading branch information
usualoma authored Jul 29, 2024
1 parent cdd48f4 commit 3a56ec5
Show file tree
Hide file tree
Showing 3 changed files with 214 additions and 3 deletions.
32 changes: 30 additions & 2 deletions src/jsx/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { raw } from '../helper/html'
import { escapeToBuffer, resolveCallbackSync, stringBufferToString } from '../utils/html'
import type { HtmlEscaped, HtmlEscapedString, StringBufferWithCallbacks } from '../utils/html'
import type { Context } from './context'
import { globalContexts } from './context'
import { createContext, globalContexts, useContext } from './context'
import { DOM_RENDERER } from './constants'
import type {
JSX as HonoJSX,
Expand Down Expand Up @@ -32,6 +32,19 @@ export namespace JSX {
}
}

let nameSpaceContext: Context<string> | undefined = undefined
export const getNameSpaceContext = () => nameSpaceContext

const toSVGAttributeName = (key: string): string =>
/[A-Z]/.test(key) &&
// Presentation attributes are findable in style object. "clip-path", "font-size", "stroke-width", etc.
// Or other un-deprecated kebab-case attributes. "overline-position", "paint-order", "strikethrough-position", etc.
key.match(
/^(?:al|basel|clip(?:Path|Rule)$|co|do|fill|fl|fo|gl|let|lig|i|marker[EMS]|o|pai|pointe|sh|st[or]|text[^L]|tr|u|ve|w)/
)
? key.replace(/([A-Z])/g, '-$1').toLowerCase()
: key

const emptyTags = [
'area',
'base',
Expand Down Expand Up @@ -160,8 +173,12 @@ export class JSXNode implements HtmlEscaped {

buffer[0] += `<${tag}`

const normalizeKey: (key: string) => string =
nameSpaceContext && useContext(nameSpaceContext) === 'svg'
? (key) => toSVGAttributeName(normalizeIntrinsicElementKey(key))
: (key) => normalizeIntrinsicElementKey(key)
for (let [key, v] of Object.entries(props)) {
key = normalizeIntrinsicElementKey(key)
key = normalizeKey(key)
if (key === 'children') {
// skip children
} else if (key === 'style' && typeof v === 'object') {
Expand Down Expand Up @@ -307,6 +324,17 @@ export const jsxFn = (
props,
children
)
} else if (tag === 'svg') {
nameSpaceContext ||= createContext('')
return new JSXNode(tag, props, [
new JSXFunctionNode(
nameSpaceContext,
{
value: tag,
},
children
),
])
} else {
return new JSXNode(tag, props, children)
}
Expand Down
177 changes: 177 additions & 0 deletions src/jsx/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,183 @@ describe('StrictMode', () => {
})
})

describe('SVG', () => {
it('simple', () => {
const template = (
<svg>
<circle cx='50' cy='50' r='40' stroke='black' stroke-width='3' fill='red' />
</svg>
)
expect(template.toString()).toBe(
'<svg><circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"></circle></svg>'
)
})

it('title element', () => {
const template = (
<>
<head>
<title>Document Title</title>
</head>
<svg>
<title>SVG Title</title>
</svg>
</>
)
expect(template.toString()).toBe(
'<head><title>Document Title</title></head><svg><title>SVG Title</title></svg>'
)
})

describe('attribute', () => {
describe('camelCase', () => {
test.each`
key
${'attributeName'}
${'baseFrequency'}
${'calcMode'}
${'clipPathUnits'}
${'diffuseConstant'}
${'edgeMode'}
${'filterUnits'}
${'gradientTransform'}
${'gradientUnits'}
${'kernelMatrix'}
${'kernelUnitLength'}
${'keyPoints'}
${'keySplines'}
${'keyTimes'}
${'lengthAdjust'}
${'limitingConeAngle'}
${'markerHeight'}
${'markerUnits'}
${'markerWidth'}
${'maskContentUnits'}
${'maskUnits'}
${'numOctaves'}
${'pathLength'}
${'patternContentUnits'}
${'patternTransform'}
${'patternUnits'}
${'pointsAtX'}
${'pointsAtY'}
${'pointsAtZ'}
${'preserveAlpha'}
${'preserveAspectRatio'}
${'primitiveUnits'}
${'refX'}
${'refY'}
${'repeatCount'}
${'repeatDur'}
${'specularConstant'}
${'specularExponent'}
${'spreadMethod'}
${'startOffset'}
${'stdDeviation'}
${'stitchTiles'}
${'surfaceScale'}
${'crossorigin'}
${'systemLanguage'}
${'tableValues'}
${'targetX'}
${'targetY'}
${'textLength'}
${'viewBox'}
${'xChannelSelector'}
${'yChannelSelector'}
`('$key', ({ key }) => {
const template = (
<svg>
<g {...{ [key]: 'test' }} />
</svg>
)
expect(template.toString()).toBe(`<svg><g ${key}="test"></g></svg>`)
})
})

describe('kebab-case', () => {
test.each`
key
${'alignmentBaseline'}
${'baselineShift'}
${'clipPath'}
${'clipRule'}
${'colorInterpolation'}
${'colorInterpolationFilters'}
${'dominantBaseline'}
${'fillOpacity'}
${'fillRule'}
${'floodColor'}
${'floodOpacity'}
${'fontFamily'}
${'fontSize'}
${'fontSizeAdjust'}
${'fontStretch'}
${'fontStyle'}
${'fontVariant'}
${'fontWeight'}
${'imageRendering'}
${'letterSpacing'}
${'lightingColor'}
${'markerEnd'}
${'markerMid'}
${'markerStart'}
${'overlinePosition'}
${'overlineThickness'}
${'paintOrder'}
${'pointerEvents'}
${'shapeRendering'}
${'stopColor'}
${'stopOpacity'}
${'strikethroughPosition'}
${'strikethroughThickness'}
${'strokeDasharray'}
${'strokeDashoffset'}
${'strokeLinecap'}
${'strokeLinejoin'}
${'strokeMiterlimit'}
${'strokeOpacity'}
${'strokeWidth'}
${'textAnchor'}
${'textDecoration'}
${'textRendering'}
${'transformOrigin'}
${'underlinePosition'}
${'underlineThickness'}
${'unicodeBidi'}
${'vectorEffect'}
${'wordSpacing'}
${'writingMode'}
`('$key', ({ key }) => {
const template = (
<svg>
<g {...{ [key]: 'test' }} />
</svg>
)
expect(template.toString()).toBe(
`<svg><g ${key.replace(/([A-Z])/g, '-$1').toLowerCase()}="test"></g></svg>`
)
})
})

describe('data-*', () => {
test.each`
key
${'data-foo'}
${'data-foo-bar'}
${'data-fooBar'}
`('$key', ({ key }) => {
const template = (
<svg>
<g {...{ [key]: 'test' }} />
</svg>
)
expect(template.toString()).toBe(`<svg><g ${key}="test"></g></svg>`)
})
})
})
})

describe('Context', () => {
let ThemeContext: Context<string>
let Consumer: FC
Expand Down
8 changes: 7 additions & 1 deletion src/jsx/intrinsic-element/components.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { HtmlEscapedCallback, HtmlEscapedString } from '../../utils/html'
import { JSXNode } from '../base'
import { JSXNode, getNameSpaceContext } from '../base'
import { useContext } from '../context'
import type { Child, Props } from '../base'
import type { FC, PropsWithChildren } from '../types'
import { raw } from '../../helper/html'
Expand Down Expand Up @@ -104,6 +105,11 @@ const documentMetadataTag = (tag: string, children: Child, props: Props, sort: b
}

export const title: FC<PropsWithChildren> = ({ children, ...props }) => {
const nameSpaceContext = getNameSpaceContext()
if (nameSpaceContext && useContext(nameSpaceContext) === 'svg') {
new JSXNode('title', props, toArray(children ?? []) as Child[])
}

return documentMetadataTag('title', children, props, false)
}
export const script: FC<PropsWithChildren<IntrinsicElements['script']>> = ({
Expand Down

0 comments on commit 3a56ec5

Please sign in to comment.