#!/usr/bin/env bb
(ns html2hiccup
(:require [babashka.cli :as cli]
[camel-snake-kebab.core :as csk]
[clojure.java.io :as io]
[clojure.string :as str]
[zprint.core :as zprint])
(:import [org.jsoup Jsoup]
[org.jsoup.nodes
Attribute
Comment
DataNode
Element
Node
TextNode]))
(def default-opts
{:add-classes-to-tag-keyword true
:kebab-attrs false
:mapify-style false})
(def special-attr-cases
{"baseprofile" "baseProfile"
"viewbox" "viewBox"})
(defn normalize-css-value
[v]
(cond (re-matches #"^-?\d+(\.\d+)?$" v) (Double/parseDouble v)
(re-find #"^[a-zA-Z]+$" v) (keyword v)
:else (str v)))
(defn mapifier
[style-str]
(try (into {}
(for [[_ k v]
(re-seq #"(\S+?)\s*:\s*([^;]+);?" style-str)]
[(-> k
str/lower-case
keyword)
(normalize-css-value v)]))
(catch Exception _ style-str)))
(defn normalize-attr-key
[k kebab-attrs]
(let [s (name k)]
(keyword (or (special-attr-cases (str/lower-case s))
(if kebab-attrs (csk/->kebab-case s) (str/lower-case s))))))
(defn normalize-attrs
[attrs {:keys [mapify-style kebab-attrs]}]
(let [m (into {}
(map (fn [[k v]] [(normalize-attr-key k kebab-attrs) v]))
attrs)]
(if (and mapify-style (:style m)) (update m :style mapifier) m)))
(defn valid-as-hiccup-kw?
[s]
(and s (not (re-matches #"^\d.*|.*[./:#@~`\[\]\(\){}].*" s))))
(defn tag+id [tag id] (str tag (when (valid-as-hiccup-kw? id) (str "#" id))))
(defn bisect-all-by
[pred coll]
(reduce (fn [[yes no] x] (if (pred x) [(conj yes x) no] [yes (conj no x)]))
[[] []]
coll))
(defn build-tag-with-classes
[tag-w-id kw-classes]
(str tag-w-id (when (seq kw-classes) (str "." (str/join "." kw-classes)))))
(declare node->hiccup)
(defn text-node->hiccup
[^TextNode node]
(let [s (some-> node
.text
str/trim)]
(when-not (str/blank? s) s)))
(defn comment-node->hiccup [^Comment node] (list 'comment (.getData node)))
(defn attrs->map
[^Element el]
(into {}
(map (fn [^Attribute a] [(.getKey a) (.getValue a)]))
(.attributes el)))
(defn element->hiccup
[^Element el options]
(let [normalized-attrs (normalize-attrs (attrs->map el) options)
{:keys [id class]} normalized-attrs
lower-tag (.tagName el)
tag-id (tag+id lower-tag id)
classes (when class (str/split class #"\s+"))
[kw-classes remaining-classes]
(if-not (:add-classes-to-tag-keyword options)
[[] classes]
(bisect-all-by valid-as-hiccup-kw? classes))
tag-name (build-tag-with-classes tag-id kw-classes)
remaining-attrs
(cond-> normalized-attrs
true (dissoc :class)
(seq remaining-classes) (assoc :class (vec remaining-classes))
(valid-as-hiccup-kw? id) (dissoc :id))
children
(->> (.childNodes el)
(map #(node->hiccup % options))
(remove nil?))]
(into (cond-> [(keyword tag-name)]
(seq remaining-attrs) (conj remaining-attrs))
children)))
(defn node->hiccup
[^Node node options]
(cond (instance? Element node) (element->hiccup node options)
(instance? TextNode node) (text-node->hiccup node)
(instance? Comment node) (comment-node->hiccup node)
(instance? DataNode node) (some-> node
.getWholeData
str/trim
not-empty)
:else nil))
(defn html->hiccup
([html] (html->hiccup html default-opts))
([html options]
(let [doc (Jsoup/parseBodyFragment html)
body (.body doc)]
(->> (.childNodes body)
(map #(node->hiccup % (merge default-opts options)))
(remove nil?)
vec))))
(defn pretty-print
[form]
(zprint/zprint-str form
{:map {:comma? false}
:style :hiccup}))
(defn convert-html
[html options]
(->> (html->hiccup html options)
(map pretty-print)
(str/join "\n")))
(defn output-path
[input-file]
(let [f (io/file input-file)
parent (.getParent f)
name (.getName f)
stem (str/replace name #"\.[^.]+$" "")
out (str stem ".edn")]
(if parent (str (io/file parent out)) out)))
(def cli-spec
{:add-classes-to-tag-keyword {:coerce :boolean}
:kebab-attrs {:coerce :boolean}
:mapify-style {:coerce :boolean}})
(defn usage
[]
(println
(str/join "\n"
["html2hiccup"
""
"Usage:"
" html2hiccup [options]"
" html2hiccup [options] file1.html file2.htm ..."
""
"No files:"
" Reads HTML from stdin"
" Writes hiccup to stdout"
""
"Files:"
" Writes sibling .edn files"
""
"Options:"
" --mapify-style true|false"
" --kebab-attrs true|false"
" --add-classes-to-tag-keyword true|false"])))
(defn process-file
[path options]
(let [html (slurp path)
out-path (output-path path)
result (convert-html html options)]
(spit out-path result)
(println "Wrote" out-path)))
(defn -main
[& argv]
(let [{:keys [opts args]}
(cli/parse-args argv {:spec cli-spec})]
(cond (or (:h opts) (:help opts)) (usage)
(seq args) (doseq [path args]
(process-file path opts))
:else (-> (slurp *in*)
(convert-html opts)
(println)))))
(apply -main *command-line-args*)