Skip to content
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
2 changes: 1 addition & 1 deletion deps.edn
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
cheshire/cheshire {:mvn/version "6.1.0"}

;; Client-side component compilation (CLJS-dialect -> JS)
io.github.squint-cljs/squint {:mvn/version "0.14.210"}
io.github.squint-cljs/squint {:mvn/version "0.15.212"}

;; Logging
com.taoensso/telemere {:mvn/version "1.2.1"}}
Expand Down
2 changes: 1 addition & 1 deletion example/example/app.clj
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@
;; ---------------------------------------------------------------------------

(def app
(h/start! (h/create-handler #'routes :head [styles]) {:port 4000}))
(h/start! (h/create-handler #'routes :head [styles] :source-map? true) {:port 4000}))

(comment
(h/stop! app))
71 changes: 35 additions & 36 deletions src/hyper/component.clj
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,14 @@
:context :statement})

(defn- compile-squint*
[source]
(try
(squint/compile* source squint-opts)
(catch Exception e
(throw (ex-info (str "Squint compilation failed: " (ex-message e))
{:source source}
e)))))
([source] (compile-squint* source nil))
([source opts]
(try
(squint/compile* source (merge squint-opts opts))
(catch Exception e
(throw (ex-info (str "Squint compilation failed: " (ex-message e))
{:source (if (string? source) source (str/join "\n" (map pr-str source)))}
e))))))

(defn compile-squint
"Compile a string of Squint (CLJS-dialect) source to JavaScript.
Expand All @@ -74,29 +75,23 @@
[source]
(:body (compile-squint* source)))

(defn- ns-form-source
(defn- ns-form
"Build a Squint ns form declaring ES module requires, so alias-qualified
symbols (e.g. d3/line) resolve during compilation. The import statements
themselves are elided from the compiled output (the bundle prelude emits
them once, deduplicated across components)."
[tag-name requires]
(when (seq requires)
(str "(ns hyper.component."
(str/replace tag-name "-" "_")
" (:require "
(str/join " " (map (fn [{:keys [url alias]}]
(str "[\"" url "\" :as " alias "]"))
requires))
"))\n")))
(list 'ns (symbol (str "hyper.component." (str/replace tag-name "-" "_")))
(list* :require (map (fn [{:keys [url alias]}] [url :as alias]) requires)))))

(defn- define-call-source
"Assemble the full Squint source for a component definition: the optional
ns/require form followed by a call to the runtime's `$define` (which the
bundle prelude provides) with the given spec-entry strings as the spec
map body. Shared by registration-source (raw Squint strings) and
squint-source (defc)."
map body. Used by registration-source (raw Squint strings)."
[tag-name requires spec-entries]
(str (ns-form-source tag-name requires)
(str (some-> (ns-form tag-name requires) pr-str (str "\n"))
"($define " (pr-str tag-name) " "
"{" (str/join " " spec-entries) "})"))

Expand Down Expand Up @@ -315,7 +310,8 @@
form))

(defn- squint-source
"Build the complete Squint source for a `defc` component.
"Build the Squint forms for a `defc` component, keeping the source
positions of the user's forms.

The generated source is a call to `$define` with:
- :attrs — vector of attribute name strings
Expand All @@ -331,10 +327,7 @@
and `emit`/`ctx` in scope.

When `requires` is non-empty, an ns form with the module requires is
prepended so alias-qualified symbols resolve during compilation.

All forms are emitted as Clojure data (readable by Squint) so no
string-quoting escaping is needed — `pr-str` handles it."
prepended so alias-qualified symbols resolve during compilation."
[tag-name attr-keys {:keys [events render mount update unmount]} ns-str requires]
(let [attr-strs (mapv clojure.core/name attr-keys)
attr-destructure {:keys (mapv symbol (map clojure.core/name attr-keys))}
Expand Down Expand Up @@ -362,12 +355,14 @@
~@handler-bindings
~@(when (seq events) ['$handlers handlers-map])]
~render-rewritten))))
spec-entries (cond-> [(str ":attrs " (pr-str attr-strs))]
render-fn (conj (str ":render " (pr-str render-fn)))
mount (conj (str ":mount " (pr-str (lifecycle-fn mount))))
update (conj (str ":update " (pr-str (lifecycle-fn update))))
unmount (conj (str ":unmount " (pr-str (lifecycle-fn unmount)))))]
(define-call-source tag-name requires spec-entries)))
spec (cond-> (array-map :attrs attr-strs)
render-fn (assoc :render render-fn)
mount (assoc :mount (lifecycle-fn mount))
update (assoc :update (lifecycle-fn update))
unmount (assoc :unmount (lifecycle-fn unmount)))]
(cond-> []
(seq requires) (conj (ns-form tag-name requires))
true (conj (list '$define tag-name spec)))))

(defmacro defc
"Define a client-side web component.
Expand Down Expand Up @@ -477,21 +472,25 @@
_ (assert (or (:render parsed) (:mount parsed))
(str "defc " cname ": a (render ...) or (mount ...) segment is required"))
ns-str (str *ns*)
src (squint-source tag-name attr-keys parsed ns-str requires)
forms (squint-source tag-name attr-keys parsed ns-str requires)
tag-kw (keyword tag-name)
;; Compile at macro-expansion time so Squint errors surface at compile
;; time (with the source attached), but emit the registration into the
;; expansion so it executes at runtime. This keeps defc AOT-safe
;; (expansion-time side effects don't survive AOT compilation) and
;; testable (with-redefs of registry* works).
{compiled-js :body
core-vars :used-core-vars} (compile-squint* src)]
{:as compiled
compiled-js :body
core-vars :used-core-vars} (compile-squint* forms {:source-map true})]
`(do
(check-alias-conflicts! ~tag-name '~requires)
(swap! registry* assoc ~tag-name {:attrs ~attr-keys
:requires '~requires
:js ~compiled-js
:core-vars ~core-vars})
(swap! registry* assoc ~tag-name {:attrs ~attr-keys
:requires '~requires
:js ~compiled-js
:core-vars ~core-vars
:source-map ~(when (not= "NO_SOURCE_PATH" *file*)
{:segments (:source-map-segments compiled)
:source *file*})})
(defn ~cname
~@(when docstring [docstring])
[attr-map# & children#]
Expand Down
82 changes: 55 additions & 27 deletions src/hyper/component/bundle.clj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
REPL redefinition hot-swap the bundle over SSE."
(:require [clojure.java.io :as io]
[clojure.string :as str]
[hyper.component :as component]))
[hyper.component :as component]
[squint.compiler.source-map :as sm]))

(defn- runtime-js []
(slurp (io/resource "hyper/component-runtime.js")))
Expand Down Expand Up @@ -46,35 +47,61 @@
(str "import * as " (munge (name alias)) " from '" url "';\n"))
pairs))))

(defn- source-text [path]
(when path
(or (some-> (io/resource path) slurp)
(let [f (io/file path)] (when (.exists f) (slurp f))))))

(defn- bundle-source-map
"Returns the source map JSON for components placed after prefix, joined with
newlines, or nil if no component carries segments."
[prefix components]
(let [sources (vec (distinct (keep (comp :source :source-map) components)))
index (zipmap sources (range))
segments (loop [[c & more] components, line (sm/count-newlines prefix), acc []]
(if-not c
acc
(let [{:keys [segments source]} (:source-map c)
acc (into acc (map (fn [[gl gc sl sc]] [(+ gl line) gc sl sc (index source)]))
(when source segments))]
(recur more (+ line 1 (sm/count-newlines (:js c))) acc))))]
(when (seq segments)
(sm/encode segments {:file "components.js"
:sources sources
:sources-content (mapv source-text sources)}))))

(defn- assemble-bundle
[registry]
(let [js (str "const $sc = window.hyper_sc;\n"
(import-lines registry)
(runtime-js)
"\n"
(->> (sort-by key registry)
(map (comp :js val))
(str/join "\n")))]
{:js js :hash (sha256-hex js)}))
[registry source-map?]
(let [prefix (str "const $sc = window.hyper_sc;\n"
(import-lines registry)
(runtime-js)
"\n")
components (map val (sort-by key registry))
source-map (when source-map? (bundle-source-map prefix components))
js (cond-> (str prefix (str/join "\n" (map :js components)))
source-map (str "\n//# sourceMappingURL=components.js.map\n"))]
{:js js :hash (sha256-hex js) :source-map source-map}))

(defonce ^:private bundle-cache* (atom nil))
(defonce ^:private bundle-cache* (atom {}))

(defn bundle
"Assemble (or return cached) the components JS bundle.

Returns {:js \"...\" :hash \"...\"} or nil when no components are
registered. The bundle is a single ES module: a binding to the page's
Returns {:js \"...\" :hash \"...\" :source-map \"...\"} or nil when no
components are registered. The bundle is a single ES module: a binding to the page's
squint core (window.hyper_sc), the hyper component runtime, then each
registered component sorted by name. Cached against the registry snapshot."
[]
(let [registry @component/registry*]
(when (seq registry)
(let [cached @bundle-cache*]
(if (= registry (:key cached))
(:bundle cached)
(let [b (assemble-bundle registry)]
(reset! bundle-cache* {:key registry :bundle b})
b))))))
registered component sorted by name. Cached against the registry snapshot.
:source-map is nil if source-map? is false."
([] (bundle false))
([source-map?]
(let [registry @component/registry*]
(when (seq registry)
(let [cached (get @bundle-cache* source-map?)]
(if (= registry (:key cached))
(:bundle cached)
(let [b (assemble-bundle registry source-map?)]
(swap! bundle-cache* assoc source-map? {:key registry :bundle b})
b)))))))

(defn core-vars
"Returns the munged squint core names that the registered components use."
Expand All @@ -87,7 +114,8 @@
endpoint can serve immutable cache headers and a registry change rotates
the URL (which, combined with head-element fingerprint diffing, makes
REPL redefinition hot-swap the bundle over SSE)."
[base-path]
(when-let [{:keys [hash]} (bundle)]
[:script {:type "module"
:src (str base-path "/hyper/components.js?v=" hash)}]))
([base-path] (head-script-tag base-path false))
([base-path source-map?]
(when-let [{:keys [hash]} (bundle source-map?)]
[:script {:type "module"
:src (str base-path "/hyper/components.js?v=" hash)}])))
8 changes: 6 additions & 2 deletions src/hyper/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,9 @@
components (default: /hyper/squint-core.js, served by hyper).
- :tree-shake? - Serve only the squint core functions that h/expr output
and defc components use. Needs babashka.esbuild on the classpath.
- :source-map? - Serve a source map for defc components. The map contains
the full source and path of each file that defines one.
Use in development only. Default false.
- :head — Hiccup nodes appended to the HTML <head>, or (fn [req] ...) -> hiccup
- :webkit-sse-shim? — Inject a small client shim (into <head>, only for WebKit/Safari
user agents) that routes the GET render stream through a native
Expand Down Expand Up @@ -1019,7 +1022,7 @@
(stop! app)"
[routes & {:keys [app-state head static-resources static-dir watches
datastar-script base-path middleware render-middleware
render-error squint-core-url tree-shake? render-guard
render-error squint-core-url tree-shake? source-map? render-guard
max-file-size max-file-count upload-expires-in param-transformer]
:or {app-state (atom (state/init-state))
datastar-script server/default-datastar-script}
Expand All @@ -1034,7 +1037,8 @@
:middleware middleware
:render-middleware render-middleware
:squint-core-url squint-core-url
:tree-shake? tree-shake?}
:tree-shake? tree-shake?
:source-map? source-map?}
;; Only forward when supplied so server defaults apply.
render-error (assoc :render-error render-error)
render-guard (assoc :render-guard render-guard)
Expand Down
3 changes: 2 additions & 1 deletion src/hyper/render.clj
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@
hiccup or nil."
[app-state* req]
(let [user-head (routes/resolve-head (get @app-state* :head) req)
comp-tag (component.bundle/head-script-tag (get @app-state* :base-path ""))
comp-tag (component.bundle/head-script-tag (get @app-state* :base-path "")
(:source-map? @app-state*))
els (cond-> (head-elements user-head)
comp-tag (conj comp-tag))]
(when (seq els)
Expand Down
22 changes: 19 additions & 3 deletions src/hyper/server.clj
Original file line number Diff line number Diff line change
Expand Up @@ -1016,9 +1016,9 @@
URL forever, so a later URL reuse (e.g. reverting an edit) would serve
that stale entry until a hard refresh. A mismatched (or missing) `?v=`
is therefore served `no-cache` so the browser always revalidates."
[]
[app-state*]
(fn [req]
(if-let [{:keys [js hash]} (component.bundle/bundle)]
(if-let [{:keys [js hash]} (component.bundle/bundle (:source-map? @app-state*))]
(let [requested (get-in req [:query-params "v"])
matched? (= requested hash)]
{:status 200
Expand All @@ -1032,6 +1032,20 @@
:headers {"Content-Type" "text/plain"}
:body "No components registered"})))

(defn- components-js-map-handler
"Serve the source map of the client-components bundle if :source-map? is true."
[app-state*]
(fn [_req]
(if-let [source-map (when (:source-map? @app-state*)
(:source-map (component.bundle/bundle true)))]
{:status 200
:headers {"Content-Type" "application/json; charset=utf-8"
"Cache-Control" "no-cache"}
:body source-map}
{:status 404
:headers {"Content-Type" "text/plain"}
:body "No source map"})))

(defn- squint-core-js-handler
"Returns a handler that serves squint's core.js from the classpath.
Serves only the functions h/expr output uses if tree-shake? is true, which needs babashka.esbuild.
Expand Down Expand Up @@ -1328,7 +1342,8 @@
[(str base-path "/hyper/actions") {:post (action-handler app-state*)}]
[(str base-path "/hyper/upload") {:post upload-route}]
[(str base-path "/hyper/navigate") {:post (navigate-handler app-state*)}]
[(str base-path "/hyper/components.js") {:get (components-js-handler)}]
[(str base-path "/hyper/components.js") {:get (components-js-handler app-state*)}]
[(str base-path "/hyper/components.js.map") {:get (components-js-map-handler app-state*)}]
[(str base-path "/hyper/squint-core.js") {:get (squint-core-js-handler app-state* (:tree-shake? opts))}]]
;; Store the routes source (Var or value) so title resolution can
;; always read the latest route metadata, even between router rebuilds.
Expand All @@ -1350,6 +1365,7 @@
:heartbeat-ms heartbeat-ms
:open-when-hidden? (get opts :open-when-hidden? true)
:tree-shake? (:tree-shake? opts)
:source-map? (:source-map? opts)
:param-transformer (or (:param-transformer opts)
routes/default-malli-transformer))
initial-routes (if (var? routes) @routes routes)
Expand Down
Loading
Loading