From 859dff5ed0bbcfa6c6c5f35159f66e5502d71834 Mon Sep 17 00:00:00 2001
From: gpoitch <garth22@gmail.com>
Date: Fri, 30 Jul 2021 09:32:45 -0400
Subject: [PATCH 1/7] Decamelize html attributes

---
 src/index.js        | 11 +++++++++--
 test/jsx.test.js    | 14 ++++++++++++--
 test/render.test.js |  4 ++--
 3 files changed, 23 insertions(+), 6 deletions(-)

diff --git a/src/index.js b/src/index.js
index e506d054..75b4ef78 100644
--- a/src/index.js
+++ b/src/index.js
@@ -17,6 +17,9 @@ const UNNAMED = [];
 
 const VOID_ELEMENTS = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/;
 
+const DASHED_ATTRS = /^(acceptC|httpE)/;
+const CAMEL_ATTRS = /^(viewB)/;
+
 const UNSAFE_NAME = /[\s\n\\/='"\0<>]/;
 
 const noop = () => {};
@@ -250,9 +253,13 @@ function _renderToString(vnode, context, opts, inner, isSvgMode, selectValue) {
 				name = 'class';
 			} else if (isSvgMode && name.match(/^xlink:?./)) {
 				name = name.toLowerCase().replace(/^xlink:?/, 'xlink:');
+			} else if (DASHED_ATTRS.test(name)) {
+				name = name.replace(/([A-Z])/g, (l) => '-' + l.toLowerCase());
+			} else if (!CAMEL_ATTRS.test(name)) {
+				name = name.toLowerCase();
 			}
 
-			if (name === 'htmlFor') {
+			if (name === 'htmlfor') {
 				if (props.for) continue;
 				name = 'for';
 			}
@@ -275,7 +282,7 @@ function _renderToString(vnode, context, opts, inner, isSvgMode, selectValue) {
 				continue;
 			}
 
-			if (name === 'dangerouslySetInnerHTML') {
+			if (name === 'dangerouslysetinnerhtml') {
 				html = v && v.__html;
 			} else if (nodeName === 'textarea' && name === 'value') {
 				// <textarea value="a&b"> --> <textarea>a&amp;b</textarea>
diff --git a/test/jsx.test.js b/test/jsx.test.js
index 733ebaf4..ec221f8b 100644
--- a/test/jsx.test.js
+++ b/test/jsx.test.js
@@ -74,6 +74,16 @@ describe('jsx', () => {
 		`);
 	});
 
+	it('should decamelize attributes', () => {
+		expect(renderJsx(<img srcSet="foo.png, foo2.png 2x" />)).to.equal(
+			`<img srcset="foo.png, foo2.png 2x" />`
+		);
+	});
+
+	it('should dasherize certain attributes', () => {
+		expect(renderJsx(<meta httpEquiv="" />)).to.equal(`<meta http-equiv="" />`);
+	});
+
 	it('should skip null and undefined attributes', () => {
 		expect(renderJsx(<a b={null}>bar</a>)).to.equal(`<a>bar</a>`);
 
@@ -151,11 +161,11 @@ describe('jsx', () => {
 	it('should skip function names if functionNames=false', () => {
 		expect(
 			renderJsx(<div onClick={() => {}} />, { functionNames: false })
-		).to.equal('<div onClick={Function}></div>');
+		).to.equal('<div onclick={Function}></div>');
 
 		expect(
 			renderJsx(<div onClick={function foo() {}} />, { functionNames: false })
-		).to.equal('<div onClick={Function}></div>');
+		).to.equal('<div onclick={Function}></div>');
 	});
 
 	it('should render self-closing elements', () => {
diff --git a/test/render.test.js b/test/render.test.js
index 9327d496..aeae9f58 100644
--- a/test/render.test.js
+++ b/test/render.test.js
@@ -265,7 +265,7 @@ describe('render', () => {
 
 		it('should render SVG elements', () => {
 			let rendered = render(
-				<svg>
+				<svg viewBox="0 0 100 100">
 					<image xlinkHref="#" />
 					<foreignObject>
 						<div xlinkHref="#" />
@@ -277,7 +277,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 xlinkhref="#"></div></foreignObject><g><image xlink:href="#"></image></g></svg>`
 			);
 		});
 	});

From 14b0ca62460f369799b429b060bd3d870ec1cc7b Mon Sep 17 00:00:00 2001
From: gpoitch <garth22@gmail.com>
Date: Mon, 2 Aug 2021 16:56:15 -0400
Subject: [PATCH 2/7] Apply later so it leaves jsx mode untouched

---
 src/index.js        | 16 ++++++++++------
 test/jsx.test.js    | 14 ++------------
 test/render.test.js | 14 ++++++++++++++
 3 files changed, 26 insertions(+), 18 deletions(-)

diff --git a/src/index.js b/src/index.js
index 75b4ef78..4059359e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -253,13 +253,9 @@ function _renderToString(vnode, context, opts, inner, isSvgMode, selectValue) {
 				name = 'class';
 			} else if (isSvgMode && name.match(/^xlink:?./)) {
 				name = name.toLowerCase().replace(/^xlink:?/, 'xlink:');
-			} else if (DASHED_ATTRS.test(name)) {
-				name = name.replace(/([A-Z])/g, (l) => '-' + l.toLowerCase());
-			} else if (!CAMEL_ATTRS.test(name)) {
-				name = name.toLowerCase();
 			}
 
-			if (name === 'htmlfor') {
+			if (name === 'htmlFor') {
 				if (props.for) continue;
 				name = 'for';
 			}
@@ -282,7 +278,7 @@ function _renderToString(vnode, context, opts, inner, isSvgMode, selectValue) {
 				continue;
 			}
 
-			if (name === 'dangerouslysetinnerhtml') {
+			if (name === 'dangerouslySetInnerHTML') {
 				html = v && v.__html;
 			} else if (nodeName === 'textarea' && name === 'value') {
 				// <textarea value="a&b"> --> <textarea>a&amp;b</textarea>
@@ -305,6 +301,14 @@ function _renderToString(vnode, context, opts, inner, isSvgMode, selectValue) {
 						s += ` selected`;
 					}
 				}
+
+				// Convert attribute names to proper html casing
+				if (DASHED_ATTRS.test(name)) {
+					name = name.replace(/([A-Z])/g, (l) => '-' + l.toLowerCase());
+				} else if (!CAMEL_ATTRS.test(name)) {
+					name = name.toLowerCase();
+				}
+
 				s += ` ${name}="${encodeEntities(v)}"`;
 			}
 		}
diff --git a/test/jsx.test.js b/test/jsx.test.js
index ec221f8b..733ebaf4 100644
--- a/test/jsx.test.js
+++ b/test/jsx.test.js
@@ -74,16 +74,6 @@ describe('jsx', () => {
 		`);
 	});
 
-	it('should decamelize attributes', () => {
-		expect(renderJsx(<img srcSet="foo.png, foo2.png 2x" />)).to.equal(
-			`<img srcset="foo.png, foo2.png 2x" />`
-		);
-	});
-
-	it('should dasherize certain attributes', () => {
-		expect(renderJsx(<meta httpEquiv="" />)).to.equal(`<meta http-equiv="" />`);
-	});
-
 	it('should skip null and undefined attributes', () => {
 		expect(renderJsx(<a b={null}>bar</a>)).to.equal(`<a>bar</a>`);
 
@@ -161,11 +151,11 @@ describe('jsx', () => {
 	it('should skip function names if functionNames=false', () => {
 		expect(
 			renderJsx(<div onClick={() => {}} />, { functionNames: false })
-		).to.equal('<div onclick={Function}></div>');
+		).to.equal('<div onClick={Function}></div>');
 
 		expect(
 			renderJsx(<div onClick={function foo() {}} />, { functionNames: false })
-		).to.equal('<div onclick={Function}></div>');
+		).to.equal('<div onClick={Function}></div>');
 	});
 
 	it('should render self-closing elements', () => {
diff --git a/test/render.test.js b/test/render.test.js
index aeae9f58..f7ae6781 100644
--- a/test/render.test.js
+++ b/test/render.test.js
@@ -89,6 +89,20 @@ 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 dasherize certain attributes', () => {
+			let rendered = render(<meta httpEquiv="refresh" />),
+				expected = `<meta http-equiv="refresh" />`;
+
+			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>`;

From 3455e633cd240c49c81187de1a8d1752c32e00c1 Mon Sep 17 00:00:00 2001
From: gpoitch <garth22@gmail.com>
Date: Wed, 29 Sep 2021 10:19:26 -0400
Subject: [PATCH 3/7] Support colon attrs

---
 src/index.js        | 9 +++++++--
 test/render.test.js | 9 ++++++++-
 2 files changed, 15 insertions(+), 3 deletions(-)

diff --git a/src/index.js b/src/index.js
index 4059359e..4a499061 100644
--- a/src/index.js
+++ b/src/index.js
@@ -18,7 +18,10 @@ const UNNAMED = [];
 const VOID_ELEMENTS = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/;
 
 const DASHED_ATTRS = /^(acceptC|httpE)/;
-const CAMEL_ATTRS = /^(viewB)/;
+const CAMEL_ATTRS = /^(viewB|isP)/;
+const COLON_ATTRS = /^(xmlS|xlinkH)/;
+const transformAttr = (attr, separator) =>
+	attr.replace(/([A-Z])/g, (w) => separator + w.toLowerCase());
 
 const UNSAFE_NAME = /[\s\n\\/='"\0<>]/;
 
@@ -304,7 +307,9 @@ function _renderToString(vnode, context, opts, inner, isSvgMode, selectValue) {
 
 				// Convert attribute names to proper html casing
 				if (DASHED_ATTRS.test(name)) {
-					name = name.replace(/([A-Z])/g, (l) => '-' + l.toLowerCase());
+					name = transformAttr(name, '-');
+				} else if (COLON_ATTRS.test(name)) {
+					name = transformAttr(name, ':');
 				} else if (!CAMEL_ATTRS.test(name)) {
 					name = name.toLowerCase();
 				}
diff --git a/test/render.test.js b/test/render.test.js
index f7ae6781..e2a20b2f 100644
--- a/test/render.test.js
+++ b/test/render.test.js
@@ -103,6 +103,13 @@ describe('render', () => {
 			expect(rendered).to.equal(expected);
 		});
 
+		it('should colonize certain attributes & leave certain attributes camelized', () => {
+			let rendered = render(<svg xmlSpace="preserve" viewBox="0 0 10 10" />),
+				expected = `<svg xml:space="preserve" viewBox="0 0 10 10"></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>`;
@@ -291,7 +298,7 @@ describe('render', () => {
 			);
 
 			expect(rendered).to.equal(
-				`<svg viewBox="0 0 100 100"><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>`
 			);
 		});
 	});

From 2c5657cd14bf4b0639a37f932326c0854b9055a5 Mon Sep 17 00:00:00 2001
From: gpoitch <garth22@gmail.com>
Date: Wed, 29 Sep 2021 11:00:37 -0400
Subject: [PATCH 4/7] Support bool attrs

---
 src/index.js        | 28 +++++++++++++++++-----------
 test/render.test.js |  9 +++++++++
 2 files changed, 26 insertions(+), 11 deletions(-)

diff --git a/src/index.js b/src/index.js
index 4a499061..c2509ff3 100644
--- a/src/index.js
+++ b/src/index.js
@@ -20,8 +20,8 @@ const VOID_ELEMENTS = /^(area|base|br|col|embed|hr|img|input|link|meta|param|sou
 const DASHED_ATTRS = /^(acceptC|httpE)/;
 const CAMEL_ATTRS = /^(viewB|isP)/;
 const COLON_ATTRS = /^(xmlS|xlinkH)/;
-const transformAttr = (attr, separator) =>
-	attr.replace(/([A-Z])/g, (w) => separator + w.toLowerCase());
+
+const CAPITAL_REGEXP = /([A-Z])/g;
 
 const UNSAFE_NAME = /[\s\n\\/='"\0<>]/;
 
@@ -287,6 +287,8 @@ function _renderToString(vnode, context, opts, inner, isSvgMode, selectValue) {
 				// <textarea value="a&b"> --> <textarea>a&amp;b</textarea>
 				propChildren = v;
 			} else if ((v || v === 0 || v === '') && typeof v !== 'function') {
+				name = getAttributeNameInHtmlCase(name);
+
 				if (v === true || v === '') {
 					v = name;
 					// in non-xml mode, allow boolean attributes
@@ -305,15 +307,6 @@ function _renderToString(vnode, context, opts, inner, isSvgMode, selectValue) {
 					}
 				}
 
-				// Convert attribute names to proper html casing
-				if (DASHED_ATTRS.test(name)) {
-					name = transformAttr(name, '-');
-				} else if (COLON_ATTRS.test(name)) {
-					name = transformAttr(name, ':');
-				} else if (!CAMEL_ATTRS.test(name)) {
-					name = name.toLowerCase();
-				}
-
 				s += ` ${name}="${encodeEntities(v)}"`;
 			}
 		}
@@ -442,6 +435,19 @@ function getFallbackComponentName(component) {
 	}
 	return name;
 }
+
+function getAttributeNameInHtmlCase(name) {
+	if (CAMEL_ATTRS.test(name)) return name;
+
+	if (DASHED_ATTRS.test(name))
+		return name.replace(CAPITAL_REGEXP, (w) => '-' + w.toLowerCase());
+
+	if (COLON_ATTRS.test(name))
+		return name.replace(CAPITAL_REGEXP, (w) => ':' + w.toLowerCase());
+
+	return name.toLowerCase();
+}
+
 renderToString.shallowRender = shallowRender;
 
 export default renderToString;
diff --git a/test/render.test.js b/test/render.test.js
index e2a20b2f..3c1c11bd 100644
--- a/test/render.test.js
+++ b/test/render.test.js
@@ -96,6 +96,15 @@ describe('render', () => {
 			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" />`;

From 0ff6093216c2216523908bb4d883bef86c42e424 Mon Sep 17 00:00:00 2001
From: gpoitch <garth22@gmail.com>
Date: Fri, 22 Oct 2021 10:56:18 -0400
Subject: [PATCH 5/7] Add more svg & rss attrs

---
 src/index.js        | 6 +++---
 test/render.test.js | 8 +++++---
 2 files changed, 8 insertions(+), 6 deletions(-)

diff --git a/src/index.js b/src/index.js
index c2509ff3..0bd6a9c5 100644
--- a/src/index.js
+++ b/src/index.js
@@ -17,9 +17,9 @@ const UNNAMED = [];
 
 const VOID_ELEMENTS = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/;
 
-const DASHED_ATTRS = /^(acceptC|httpE)/;
-const CAMEL_ATTRS = /^(viewB|isP)/;
-const COLON_ATTRS = /^(xmlS|xlinkH)/;
+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;
 
diff --git a/test/render.test.js b/test/render.test.js
index 3c1c11bd..d847556d 100644
--- a/test/render.test.js
+++ b/test/render.test.js
@@ -112,9 +112,11 @@ describe('render', () => {
 			expect(rendered).to.equal(expected);
 		});
 
-		it('should colonize certain attributes & leave certain attributes camelized', () => {
-			let rendered = render(<svg xmlSpace="preserve" viewBox="0 0 10 10" />),
-				expected = `<svg xml:space="preserve" viewBox="0 0 10 10"></svg>`;
+		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);
 		});

From 697cb20082769902efcf21ab479dd01fcb77fc8f Mon Sep 17 00:00:00 2001
From: gpoitch <garth22@gmail.com>
Date: Fri, 12 Nov 2021 10:30:21 -0500
Subject: [PATCH 6/7] Review pass 1

---
 src/index.js | 17 +++++++++--------
 1 file changed, 9 insertions(+), 8 deletions(-)

diff --git a/src/index.js b/src/index.js
index 0bd6a9c5..442dff59 100644
--- a/src/index.js
+++ b/src/index.js
@@ -19,7 +19,7 @@ const VOID_ELEMENTS = /^(area|base|br|col|embed|hr|img|input|link|meta|param|sou
 
 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 COLON_ATTRS = /^(xlink|xml|xmlns)([A-Z])/;
 
 const CAPITAL_REGEXP = /([A-Z])/g;
 
@@ -287,7 +287,7 @@ function _renderToString(vnode, context, opts, inner, isSvgMode, selectValue) {
 				// <textarea value="a&b"> --> <textarea>a&amp;b</textarea>
 				propChildren = v;
 			} else if ((v || v === 0 || v === '') && typeof v !== 'function') {
-				name = getAttributeNameInHtmlCase(name);
+				name = transformAttributeName(name);
 
 				if (v === true || v === '') {
 					v = name;
@@ -306,7 +306,6 @@ function _renderToString(vnode, context, opts, inner, isSvgMode, selectValue) {
 						s += ` selected`;
 					}
 				}
-
 				s += ` ${name}="${encodeEntities(v)}"`;
 			}
 		}
@@ -436,14 +435,16 @@ function getFallbackComponentName(component) {
 	return name;
 }
 
-function getAttributeNameInHtmlCase(name) {
+function transformAttributeName(name) {
 	if (CAMEL_ATTRS.test(name)) return name;
 
-	if (DASHED_ATTRS.test(name))
-		return name.replace(CAPITAL_REGEXP, (w) => '-' + w.toLowerCase());
+	if (DASHED_ATTRS.test(name)) {
+		return name.replace(CAPITAL_REGEXP, '-$1').toLowerCase();
+	}
 
-	if (COLON_ATTRS.test(name))
-		return name.replace(CAPITAL_REGEXP, (w) => ':' + w.toLowerCase());
+	if (COLON_ATTRS.test(name)) {
+		return name.replace(CAPITAL_REGEXP, ':$1').toLowerCase();
+	}
 
 	return name.toLowerCase();
 }

From b566b917300b242890f40df33bd58fa33f1c51c2 Mon Sep 17 00:00:00 2001
From: Jovi De Croock <decroockjovi@gmail.com>
Date: Mon, 15 Nov 2021 18:21:03 +0100
Subject: [PATCH 7/7] add changeset

---
 .changeset/funny-seahorses-refuse.md | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 .changeset/funny-seahorses-refuse.md

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