diff --git a/.changeset/funny-seahorses-refuse.md b/.changeset/funny-seahorses-refuse.md new file mode 100644 index 00000000..4a7b5b38 --- /dev/null +++ b/.changeset/funny-seahorses-refuse.md @@ -0,0 +1,5 @@ +--- +"preact-render-to-string": patch +--- + +Transform attribute names to proper casing diff --git a/package-lock.json b/package-lock.json index fe9beeb4..e3964f99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "preact-render-to-string", - "version": "6.0.0", + "version": "6.0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "preact-render-to-string", - "version": "6.0.0", + "version": "6.0.1", "license": "MIT", "dependencies": { "pretty-format": "^3.8.0" diff --git a/src/index.js b/src/index.js index d610ca9c..6450ec52 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,9 @@ -import { encodeEntities, styleObjToCss, UNSAFE_NAME, XLINK } from './util'; +import { + encodeEntities, + styleObjToCss, + transformAttributeName, + UNSAFE_NAME +} from './util'; import { options, h, Fragment } from 'preact'; import { CHILDREN, @@ -307,15 +312,15 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { break; default: { - if (isSvgMode && XLINK.test(name)) { - name = name.toLowerCase().replace(XLINK_REPLACE_REGEX, 'xlink:'); - } else if (UNSAFE_NAME.test(name)) { + if (UNSAFE_NAME.test(name)) { continue; } else if ((name[4] === '-' || name === 'draggable') && v != null) { // serialize boolean aria-xyz or draggable attribute values as strings // `draggable` is an enumerated attribute and not Boolean. A value of `true` or `false` is mandatory // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/draggable v += ''; + } else { + name = transformAttributeName(name); } } } @@ -360,7 +365,6 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { return s + '>' + html + '</' + type + '>'; } -const XLINK_REPLACE_REGEX = /^xlink:?/; const SELF_CLOSING = new Set([ 'area', 'base', diff --git a/src/pretty.js b/src/pretty.js index fd73431d..3a74df6e 100644 --- a/src/pretty.js +++ b/src/pretty.js @@ -5,6 +5,7 @@ import { styleObjToCss, getChildren, createComponent, + transformAttributeName, UNSAFE_NAME, XLINK, VOID_ELEMENTS @@ -133,7 +134,6 @@ function _renderToStringPretty( !nodeName.prototype || typeof nodeName.prototype.render !== 'function' ) { - // If a hook invokes setState() to invalidate the component during rendering, // re-render it up to 25 times to allow "settling" of memoized states. // Note: @@ -149,7 +149,6 @@ function _renderToStringPretty( rendered = nodeName.call(vnode.__c, props, cctx); } } else { - // c = new nodeName(props, context); c = vnode.__c = new nodeName(props, cctx); c.__v = vnode; @@ -278,6 +277,8 @@ function _renderToStringPretty( // <textarea value="a&b"> --> <textarea>a&b</textarea> propChildren = v; } else if ((v || v === 0 || v === '') && typeof v !== 'function') { + name = transformAttributeName(name); + if (v === true || v === '') { v = name; // in non-xml mode, allow boolean attributes diff --git a/src/util.js b/src/util.js index c3a598e1..955e2df5 100644 --- a/src/util.js +++ b/src/util.js @@ -148,3 +148,23 @@ export function createComponent(vnode, context) { __h: [] }; } + +const DASHED_ATTRS = /^(acceptC|httpE|(clip|color|fill|font|glyph|marker|stop|stroke|text|vert)[A-Z])/; +const CAMEL_ATTRS = /^(isP|viewB)/; +const COLON_ATTRS = /^(xlink|xml|xmlns)([A-Z])/; + +const CAPITAL_REGEXP = /([A-Z])/g; + +export function transformAttributeName(name) { + if (CAMEL_ATTRS.test(name)) return name; + + if (DASHED_ATTRS.test(name)) { + return name.replace(CAPITAL_REGEXP, '-$1').toLowerCase(); + } + + if (COLON_ATTRS.test(name)) { + return name.replace(CAPITAL_REGEXP, ':$1').toLowerCase(); + } + + return name.toLowerCase(); +} diff --git a/test/render.test.js b/test/render.test.js index 17213b5b..4a93e95e 100644 --- a/test/render.test.js +++ b/test/render.test.js @@ -128,6 +128,38 @@ describe('render', () => { expect(rendered).to.equal(expected); }); + it('should decamelize attributes', () => { + let rendered = render(<img srcSet="foo.png, foo2.png 2x" />), + expected = `<img srcset="foo.png, foo2.png 2x"/>`; + + expect(rendered).to.equal(expected); + }); + + it('should decamelize bool attributes', () => { + let rendered = render( + <link rel="preconnect" href="https://foo.com" crossOrigin /> + ), + expected = `<link rel="preconnect" href="https://foo.com" crossorigin/>`; + + expect(rendered).to.equal(expected); + }); + + it('should dasherize certain attributes', () => { + let rendered = render(<meta httpEquiv="refresh" />), + expected = `<meta http-equiv="refresh"/>`; + + expect(rendered).to.equal(expected); + }); + + it('should colonize/dasherize certain attributes & leave certain attributes camelized', () => { + let rendered = render( + <svg xmlSpace="preserve" viewBox="0 0 10 10" fillRule="nonzero" /> + ), + expected = `<svg xml:space="preserve" viewBox="0 0 10 10" fill-rule="nonzero"></svg>`; + + expect(rendered).to.equal(expected); + }); + it('should include boolean aria-* attributes', () => { let rendered = render(<div aria-hidden aria-whatever={false} />), expected = `<div aria-hidden="true" aria-whatever="false"></div>`; @@ -321,7 +353,7 @@ describe('render', () => { it('should render SVG elements', () => { let rendered = render( - <svg> + <svg viewBox="0 0 100 100"> <image xlinkHref="#" /> <foreignObject> <div xlinkHref="#" /> @@ -333,7 +365,7 @@ describe('render', () => { ); expect(rendered).to.equal( - `<svg><image xlink:href="#"></image><foreignObject><div xlinkHref="#"></div></foreignObject><g><image xlink:href="#"></image></g></svg>` + `<svg viewBox="0 0 100 100"><image xlink:href="#"></image><foreignObject><div xlink:href="#"></div></foreignObject><g><image xlink:href="#"></image></g></svg>` ); }); });