Skip to content

Convert attributes to camelCase, with some exceptions #5

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@

## Changed

- Convert multi-word attributes to match Reagent + React's behaviour.

data-, aria-, and hx- attributes remain kebab-case.
html and svg attributes that are kebab-case in those specs, are converted to kebab-case

BREAKING in some cases:

Previously:
:tab-index -> "tab-index"
"fontStyle" -> "fontStyle"
:fontStyle -> "fontStyle"

Now:
:tab-index -> "tabIndex"
"fontStyle" -> "font-style"
:fontStyle -> "font-style"

# 0.0.15 (2023-03-20 / c0a2d53)

## Added
Expand All @@ -23,4 +40,4 @@
- Initial implementation
- fragment support
- component support (fn? in first vector position)
- unsafe-html support
- unsafe-html support
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# hiccup

<!-- badges -->
[![CircleCI](https://circleci.com/gh/lambdaisland/hiccup.svg?style=svg)](https://circleci.com/gh/lambdaisland/hiccup) [![cljdoc badge](https://cljdoc.org/badge/com.lambdaisland/hiccup)](https://cljdoc.org/d/com.lambdaisland/hiccup) [![Clojars Project](https://img.shields.io/clojars/v/com.lambdaisland/hiccup.svg)](https://clojars.org/com.lambdaisland/hiccup)
[![CircleCI](https://circleci.com/gh/lambdaisland/hiccup.svg?style=svg)](https://circleci.com/gh/lambdaisland/hiccup) [![cljdoc badge](https://cljdoc.org/badge/com.lambdaisland/hiccup)](https://cljdoc.org/d/com.lambdaisland/hiccup) [![Clojars Project](https://img.shields.io/clojars/v/com.lambdaisland/hiccup.svg)](https://clojars.org/com.lambdaisland/hiccup)
<!-- /badges -->

Enlive-backed Hiccup implementation (clj-only)
Expand All @@ -13,9 +13,7 @@ Enlive-backed Hiccup implementation (clj-only)
- Components (`[my-fn ...]`)
- Style maps (`[:div {:style {:color "blue"}}]`)
- Insert pre-rendered HTML with `[::hiccup/unsafe-html "your html"]`

This makes it behave closer to how Hiccup works in Reagent, reducing cognitive
overhead when doing cross-platform development.
- Convert multi-word attributes to the appropriate case, matching Reagent + React's behaviour (`:tab-index -> "tabIndex"`; `"fontStyle" -> "font-style"`); you should be able to use `:kebab-case` keywords and get what you expect

<!-- installation -->
## Installation
Expand Down
78 changes: 71 additions & 7 deletions src/lambdaisland/hiccup.clj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@
[garden.compiler :as gc]
[clojure.string :as str]))

(def kebab-case-tags
;; from https://github.com/preactjs/preact-compat/issues/222
#{;; html
"accept-charset" "http-equiv"
;; svg
"accent-height" "alignment-baseline" "arabic-form" "baseline-shift" "cap-height"
"clip-path" "clip-rule" "color-interpolation" "color-interpolation-filters"
"color-profile" "color-rendering" "fill-opacity" "fill-rule" "flood-color"
"flood-opacity" "font-family" "font-size" "font-size-adjust" "font-stretch"
"font-style" "font-variant" "font-weight" "glyph-name"
"glyph-orientation-horizontal" "glyph-orientation-vertical" "horiz-adv-x"
"horiz-origin-x" "marker-end" "marker-mid" "marker-start" "overline-position"
"overline-thickness" "panose-1" "paint-order" "stop-color" "stop-opacity"
"strikethrough-position" "strikethrough-thickness" "stroke-dasharray"
"stroke-dashoffset" "stroke-linecap" "stroke-linejoin" "stroke-miterlimit"
"stroke-opacity" "stroke-width" "text-anchor" "text-decoration" "text-rendering"
"underline-position" "underline-thickness" "unicode-bidi" "unicode-range"
"units-per-em" "v-alphabetic" "v-hanging" "v-ideographic" "v-mathematical"
"vert-adv-y" "vert-origin-x" "vert-origin-y" "word-spacing" "writing-mode"
"x-height"})

(def block-level-tag?
#{:head :body :meta :title :script :svg :iframe :style
:link :address :article :aside :blockquote :details
Expand All @@ -17,6 +38,43 @@
(defn- attr-map? [node-spec]
(and (map? node-spec) (not (keyword? (:tag node-spec)))))

(defn- kebab-in-html? [attr-str]
(or (contains? kebab-case-tags attr-str)
(str/starts-with? attr-str "data-")
(str/starts-with? attr-str "aria-")
(str/starts-with? attr-str "hx-")))

(defn- kebab->camel [s]
(str/replace s #"-(\w)" (fn [[_ match]] (str/capitalize match))))

(defn- camel->kebab [s]
(str/replace s #"([A-Z])" (fn [[_ match]] (str "-" (str/lower-case match)))))

(defn convert-attribute-reagent-logic
[attr]
(cond
(string? attr)
attr
(keyword? attr)
(if (kebab-in-html? (name attr))
(name attr)
(kebab->camel (name attr)))))

(defn convert-attribute-react-logic
[attr-str]
(let [kebab-str (camel->kebab attr-str)]
;; not using kebab-in-html? here because
;; React does not convert dataFoo to data-foo
;; but does convert fontStretch to font-stretch
(if (kebab-case-tags kebab-str)
kebab-str
attr-str)))

(defn convert-attribute [attr]
(->> attr
convert-attribute-reagent-logic
convert-attribute-react-logic))

(defn- nodify [node-spec {:keys [newlines?] :as opts}]
(cond
(string? node-spec) node-spec
Expand All @@ -43,6 +101,12 @@
(into {} (filter val m))
{})
:content (enlive/flatmap #(nodify % opts) (if (attr-map? m) ms more))}
node (update node :attrs
(fn [attrs]
(->> attrs
(map (fn [[k v]]
[(convert-attribute k) v]))
(into {}))))
node (if id (assoc-in node [:attrs :id] id) node)
node (if (seq classes)
(update-in node
Expand All @@ -51,13 +115,13 @@
(concat classes (if (string? kls) [kls] kls))))
node)]
(cond-> node
(map? (get-in node [:attrs :style]))
(update-in [:attrs :style] (fn [style]
(-> (gc/compile-css [:& style])
(str/replace #"^\s*\{|\}\s*$" "")
str/trim)))
(sequential? (get-in node [:attrs :class]))
(update-in [:attrs :class] #(str/join " " %))
(map? (get-in node [:attrs "style"]))
(update-in [:attrs "style"] (fn [style]
(-> (gc/compile-css [:& style])
(str/replace #"^\s*\{|\}\s*$" "")
str/trim)))
(sequential? (get-in node [:attrs "class"]))
(update-in [:attrs "class"] #(str/join " " %))
(and newlines? (block-level-tag? tag))
(->> (list "\n"))))

Expand Down
42 changes: 36 additions & 6 deletions test/lambdaisland/hiccup_test.clj
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@

(ns lambdaisland.hiccup-test
(ns lambdaisland.hiccup-test
(:require [clojure.test :refer [deftest testing is]]
[lambdaisland.hiccup :as hiccup]))

(defn my-test-component [contents]
[:p contents])

(defn test-fragment-component [contents]
[:<>
[:<>
[:p contents]
[:p contents]])

(deftest render-test
(deftest render-test
(testing "simple tag"
(is (= (hiccup/render [:p] {:doctype? false})
"<p></p>")))
Expand All @@ -22,14 +22,44 @@
(is (= (hiccup/render [:div {:style {:color "blue"}} [:p]] {:doctype? false})
"<div style=\"color: blue;\"><p></p></div>")))
(testing "simple component"
(is (= (hiccup/render [my-test-component "hello"] {:doctype? false})
(is (= (hiccup/render [my-test-component "hello"] {:doctype? false})
"<p>hello</p>")))
(testing "simple component with fragment"
(is (= (hiccup/render [:div [test-fragment-component "hello"]] {:doctype? false})
(is (= (hiccup/render [:div [test-fragment-component "hello"]] {:doctype? false})
"<div><p>hello</p><p>hello</p></div>")))
(testing "pre-rendered HTML"
(is (= (hiccup/render [::hiccup/unsafe-html "<body><main><article><p></p></article></main></body>"] {:doctype? false})
"<body><main><article><p></p></article></main></body>")))
(testing "autoescaping"
(is (= (hiccup/render [:div "<p></p>"] {:doctype? false})
"<div>&lt;p&gt;&lt;/p&gt;</div>"))))
"<div>&lt;p&gt;&lt;/p&gt;</div>")))

(testing "attribute conversion"
;; convert kebab-case and camelCase attributes
;; based on behaviour of using Reagent + React
;; except, don't force lowercase (the * below)
(doseq [[input expected]
{"tabIndex" "tabIndex" ;; *
"dataA" "dataA"
"fontStyle" "font-style"

"tab-index" "tab-index"
"data-b" "data-b"
"font-variant" "font-variant"

:tabIndex "tabIndex" ;; *
:dataD "dataD"
:fontStretch "font-stretch"

:tab-index "tabIndex" ;; *
:data-c "data-c"
:font-weight "font-weight"

:hx-foo "hx-foo"
"hx-foo" "hx-foo"}]
(testing (str (pr-str input) "->" (pr-str expected))
(is (= expected
(hiccup/convert-attribute input)))
(is (= (str "<div " expected "=\"baz\"></div>")
(hiccup/render [:div {input "baz"}]
{:doctype? false})))))))