diff --git a/src/jsx/base.ts b/src/jsx/base.ts index 36f0cc0c9..0f4b7e861 100644 --- a/src/jsx/base.ts +++ b/src/jsx/base.ts @@ -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, @@ -32,6 +32,19 @@ export namespace JSX { } } +let nameSpaceContext: Context | 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', @@ -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') { @@ -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) } diff --git a/src/jsx/index.test.tsx b/src/jsx/index.test.tsx index 87543243a..bc1c09119 100644 --- a/src/jsx/index.test.tsx +++ b/src/jsx/index.test.tsx @@ -566,6 +566,183 @@ describe('StrictMode', () => { }) }) +describe('SVG', () => { + it('simple', () => { + const template = ( + + + + ) + expect(template.toString()).toBe( + '' + ) + }) + + it('title element', () => { + const template = ( + <> + + Document Title + + + SVG Title + + + ) + expect(template.toString()).toBe( + 'Document TitleSVG Title' + ) + }) + + 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 = ( + + + + ) + expect(template.toString()).toBe(``) + }) + }) + + 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 = ( + + + + ) + expect(template.toString()).toBe( + `` + ) + }) + }) + + describe('data-*', () => { + test.each` + key + ${'data-foo'} + ${'data-foo-bar'} + ${'data-fooBar'} + `('$key', ({ key }) => { + const template = ( + + + + ) + expect(template.toString()).toBe(``) + }) + }) + }) +}) + describe('Context', () => { let ThemeContext: Context let Consumer: FC diff --git a/src/jsx/intrinsic-element/components.ts b/src/jsx/intrinsic-element/components.ts index f6a4541eb..d9342bee4 100644 --- a/src/jsx/intrinsic-element/components.ts +++ b/src/jsx/intrinsic-element/components.ts @@ -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' @@ -104,6 +105,11 @@ const documentMetadataTag = (tag: string, children: Child, props: Props, sort: b } export const title: FC = ({ 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> = ({