|
3 | 3 | [clojure.java.io :as io] |
4 | 4 | [clojure.java.process :as process] |
5 | 5 | [clojure.data.json :as json] |
6 | | - [eden.site-generator :as sg] |
7 | | - [eden.image-processor :as img]) |
| 6 | + [eden.image-processor :as img] |
| 7 | + [eden.esbuild :as esbuild]) |
8 | 8 | (:import [java.lang ProcessBuilder ProcessBuilder$Redirect] |
9 | 9 | [java.io File])) |
10 | 10 |
|
| 11 | +(defn- extract-image-urls |
| 12 | + "Extract image URLs with query parameters from HTML or CSS." |
| 13 | + [content] |
| 14 | + (let [url-pattern #"(?:src=[\"']?|url\([\"']?)(/assets/images/[^\"')\s]+\?[^\"')\s]+)" |
| 15 | + parse-params (fn [query-string] |
| 16 | + (let [params (java.net.URLDecoder/decode ^String query-string "UTF-8") |
| 17 | + pairs (str/split params #"&")] |
| 18 | + (reduce (fn [m pair] |
| 19 | + (let [[k v] (str/split pair #"=" 2)] |
| 20 | + (case k |
| 21 | + "size" (if-let [[_ w h] (re-matches #"^(\d+)x(.*)$" v)] |
| 22 | + (cond |
| 23 | + (not (str/blank? h)) |
| 24 | + (if (re-matches #"\d+" h) |
| 25 | + (assoc m :width (Long/parseLong w) |
| 26 | + :height (Long/parseLong h)) |
| 27 | + (assoc m :error (str "Invalid height: " h))) |
| 28 | + :else |
| 29 | + (assoc m :width (Long/parseLong w))) |
| 30 | + (assoc m :error (str "Invalid size format: " v))) |
| 31 | + m))) |
| 32 | + {} |
| 33 | + pairs))) |
| 34 | + |
| 35 | + generate-replace-url (fn [path params] |
| 36 | + (let [[base-path ext] (let [last-dot (.lastIndexOf ^String path ".")] |
| 37 | + [(subs path 0 last-dot) |
| 38 | + (subs path (inc last-dot))]) |
| 39 | + {:keys [width height]} params |
| 40 | + size-suffix (cond |
| 41 | + (and width height) (str "-" width "x" height) |
| 42 | + width (str "-" width "x") |
| 43 | + :else "")] |
| 44 | + (str base-path size-suffix "." ext)))] |
| 45 | + |
| 46 | + (into [] |
| 47 | + (map (fn [[_ url]] |
| 48 | + (let [[path query-string] (str/split url #"\?" 2) |
| 49 | + params (parse-params query-string)] |
| 50 | + (cond-> {:url url |
| 51 | + :source-path path} |
| 52 | + (not (:error params)) (merge (select-keys params [:width :height]) |
| 53 | + {:replace-url (generate-replace-url path params)}) |
| 54 | + (:error params) (assoc :error (:error params)))))) |
| 55 | + (re-seq url-pattern content)))) |
| 56 | + |
| 57 | + |
11 | 58 | (defn process-images |
12 | 59 | "Process images in HTML and CSS files based on query parameters." |
13 | | - [html-files css-files root-path] |
14 | | - (let [;; Extract image URLs from all HTML and CSS |
15 | | - html-image-urls (mapcat #(sg/extract-image-urls (:html %)) html-files) |
16 | | - css-image-urls (mapcat #(sg/extract-image-urls (slurp %)) css-files) |
17 | | - all-image-urls (concat html-image-urls css-image-urls) |
18 | | - |
19 | | - ;; Ensure .temp/images directory exists |
20 | | - temp-dir (io/file ".temp/images") |
21 | | - _ (io/make-parents (io/file temp-dir "dummy.txt")) |
22 | | - |
23 | | - ;; Process each unique image |
24 | | - _ (doall |
25 | | - (for [img-data all-image-urls] |
26 | | - (when-not (:error img-data) |
27 | | - (let [;; Convert web path to file system path |
28 | | - source-path (str root-path (:source-path img-data)) |
29 | | - output-dir ".temp/images"] |
30 | | - ;; Call image processor with consistent keys |
31 | | - (img/process-image (merge {:source-path source-path |
32 | | - :output-dir output-dir} |
33 | | - (select-keys img-data [:width :height]))))))) |
| 60 | + [{:keys [site-config rendered css] :as _ctx}] |
| 61 | + (let [root-path (:root-path site-config) |
| 62 | + output-path (:output-path site-config) |
| 63 | + |
| 64 | + ;; Extract image URLs from all HTML and CSS |
| 65 | + html-image-urls (into #{} (mapcat #(extract-image-urls (:html/output %)) rendered)) |
| 66 | + css-image-urls (into #{} (mapcat #(extract-image-urls (:content %)) css)) |
| 67 | + all-image-urls (concat css-image-urls html-image-urls) |
| 68 | + |
| 69 | + image-results (mapv |
| 70 | + (fn [img-data] |
| 71 | + (if (:error img-data) |
| 72 | + img-data |
| 73 | + (let [opts (-> (select-keys img-data [:height :width]) |
| 74 | + (assoc :source-path (str root-path (:source-path img-data)) |
| 75 | + :output-path (str output-path (:source-path img-data))))] |
| 76 | + (merge opts (img/process-image opts))))) |
| 77 | + all-image-urls) |
| 78 | + |
34 | 79 |
|
35 | 80 | ;; Build URL replacement map |
36 | 81 | url-replacements (reduce (fn [m img-data] |
|
40 | 85 | {} |
41 | 86 | all-image-urls)] |
42 | 87 |
|
43 | | - ;; Replace URLs in HTML files |
44 | | - (map (fn [html-file] |
45 | | - (let [updated-html (reduce (fn [html [old-url new-url]] |
46 | | - (str/replace html old-url new-url)) |
47 | | - (:html html-file) |
48 | | - url-replacements)] |
49 | | - (assoc html-file :html updated-html))) |
50 | | - html-files))) |
51 | | - |
52 | | -(defn- ensure-npm-setup |
53 | | - "Ensure npm is set up in the site directory with esbuild" |
54 | | - [site-dir] |
55 | | - (let [package-json (io/file site-dir "package.json") |
56 | | - node-modules (io/file site-dir "node_modules")] |
57 | | - ;; Only install dependencies if package.json exists |
58 | | - (when (and (.exists package-json) |
59 | | - (not (.exists node-modules))) |
60 | | - (println " Installing npm dependencies...") |
61 | | - (process/exec {:dir site-dir} "npm" "install") |
62 | | - (println " npm dependencies installed")))) |
63 | | - |
64 | | -(defn- bundle-css |
65 | | - "Bundle CSS with esbuild, or copy files if esbuild not available" |
66 | | - [site-root output-dir mode] |
67 | | - (let [css-dir (io/file site-root "assets" "css") |
68 | | - css-files (when (.exists css-dir) |
69 | | - (seq (.listFiles css-dir (reify java.io.FilenameFilter |
70 | | - (accept [_ _dir name] |
71 | | - (.endsWith name ".css"))))))] |
72 | | - (when css-files |
73 | | - (let [css-start (System/currentTimeMillis) |
74 | | - out-dir (io/file output-dir "assets" "css") |
75 | | - _ (io/make-parents (io/file out-dir "dummy")) |
76 | | - ;; esbuild path is relative to site-root |
77 | | - esbuild-path (io/file site-root "node_modules" ".bin" "esbuild") |
78 | | - bundled-files (if (.exists esbuild-path) |
79 | | - ;; Use esbuild if available |
80 | | - (into [] |
81 | | - (mapcat (fn [css-file] |
82 | | - (let [css-path (File/.getPath css-file) |
83 | | - out-file (io/file out-dir (File/.getName css-file)) |
84 | | - args (into-array String |
85 | | - (cond-> [(File/.getPath esbuild-path) css-path "--bundle" |
86 | | - (str "--outfile=" (File/.getPath out-file)) |
87 | | - "--metafile=/dev/stdout" |
88 | | - "--external:/assets/*" |
89 | | - "--loader:.css=css" |
90 | | - "--log-level=error"] |
91 | | - (= mode :prod) (conj "--minify")))] |
92 | | - (try |
93 | | - ;; Use ProcessBuilder to capture stdout separately |
94 | | - (let [pb (new ProcessBuilder ^"[Ljava.lang.String;" args) |
95 | | - _ (.redirectError pb ProcessBuilder$Redirect/DISCARD) |
96 | | - p (.start pb) |
97 | | - output (slurp (.getInputStream p)) |
98 | | - exit-code (.waitFor p)] |
99 | | - (if (zero? exit-code) |
100 | | - ;; Parse JSON from stdout |
101 | | - (if (and output (not (str/blank? output))) |
102 | | - (let [meta-data (json/read-str output :key-fn keyword) |
103 | | - outputs (:outputs meta-data)] |
104 | | - (map (fn [[out-path out-info]] |
105 | | - {:file (File/.getName (io/file (str out-path))) |
106 | | - :size (:bytes out-info) |
107 | | - :type :css}) |
108 | | - outputs)) |
109 | | - ;; Fallback if no metadata |
110 | | - [{:file (File/.getName css-file) |
111 | | - :size (.length out-file) |
112 | | - :type :css}]) |
113 | | - (do |
114 | | - (println (format " CSS %s failed with exit code %d" |
115 | | - (File/.getName css-file) exit-code)) |
116 | | - []))) |
117 | | - (catch Exception e |
118 | | - (println (format " CSS %s failed: %s" (File/.getName css-file) (.getMessage e))) |
119 | | - [])))) |
120 | | - css-files)) |
121 | | - ;; Otherwise just copy the files |
122 | | - (mapv (fn [css-file] |
123 | | - (let [out-file (io/file out-dir (File/.getName css-file))] |
124 | | - (io/copy css-file out-file) |
125 | | - {:file (File/.getName css-file) |
126 | | - :size (.length out-file) |
127 | | - :type :css})) |
128 | | - css-files))] |
129 | | - {:elapsed (- (System/currentTimeMillis) css-start) |
130 | | - :files bundled-files})))) |
| 88 | + {:image-results image-results |
| 89 | + |
| 90 | + ;; Replace urls in HTML |
| 91 | + :rendered |
| 92 | + (mapv (fn [html-file] |
| 93 | + (let [updated-html (reduce (fn [html [old-url new-url]] |
| 94 | + (str/replace html old-url new-url)) |
| 95 | + (:html/output html-file) |
| 96 | + url-replacements)] |
| 97 | + (assoc html-file :html/replaced updated-html))) |
| 98 | + rendered) |
| 99 | + |
| 100 | + ;; Replace urls in css |
| 101 | + :css |
| 102 | + (mapv (fn [css-file] |
| 103 | + (let [updated-css (reduce (fn [css [old-url new-url]] |
| 104 | + (str/replace css old-url new-url)) |
| 105 | + (:content css-file) |
| 106 | + url-replacements)] |
| 107 | + (assoc css-file :replaced updated-css))) |
| 108 | + css)})) |
| 109 | + |
| 110 | +(defn- copy-css |
| 111 | + "For now just copy css files over" |
| 112 | + [ctx] |
| 113 | + (when-let [css (seq (:css ctx))] |
| 114 | + (let [copy-start (System/currentTimeMillis) |
| 115 | + output-dir (io/file (:output-path (:site-config ctx))) |
| 116 | + files (mapv (fn [{:keys [content relative-path]}] |
| 117 | + (let [output-file (io/file output-dir relative-path)] |
| 118 | + (io/make-parents output-file) |
| 119 | + (io/copy content output-file) |
| 120 | + {:file (File/.getName output-file) |
| 121 | + :path (str output-file) |
| 122 | + :size (File/.length output-file) |
| 123 | + :type :css})) |
| 124 | + css)] |
| 125 | + {:elapsed (- (System/currentTimeMillis) copy-start) |
| 126 | + :files files}))) |
131 | 127 |
|
132 | 128 | (defn- bundle-js |
133 | 129 | "Bundle JavaScript with esbuild, or copy files if esbuild not available" |
134 | | - [site-root output-dir mode] |
135 | | - (let [js-dir (io/file site-root "assets" "js") |
136 | | - js-files (when (.exists js-dir) |
137 | | - (seq (.listFiles js-dir (reify java.io.FilenameFilter |
138 | | - (accept [_ _dir name] |
139 | | - (.endsWith name ".js"))))))] |
| 130 | + [ctx] |
| 131 | + (let [site-root (-> ctx :site-config :root-path) |
| 132 | + assets-path (or (-> ctx :site-config :assets) "assets") |
| 133 | + js-dir (io/file site-root assets-path "js") |
| 134 | + js-files (when (and (File/.exists js-dir) |
| 135 | + (File/.isDirectory js-dir)) |
| 136 | + (filter #(str/ends-with? % ".js") (file-seq js-dir)))] |
140 | 137 | (when js-files |
141 | 138 | (let [js-start (System/currentTimeMillis) |
142 | | - out-dir (io/file output-dir "assets" "js") |
143 | | - _ (io/make-parents (io/file out-dir "dummy")) |
| 139 | + output-path (-> ctx :site-config :output-path) |
| 140 | + mode (:mode ctx) |
| 141 | + out-dir (io/file output-path assets-path "js") |
| 142 | + _ (io/make-parents (io/file out-dir ".")) |
144 | 143 | ;; esbuild path is relative to site-root |
145 | 144 | esbuild-path (io/file site-root "node_modules" ".bin" "esbuild") |
146 | 145 | bundled-files (if (.exists esbuild-path) |
|
149 | 148 | (mapcat (fn [js-file] |
150 | 149 | (let [js-path (File/.getPath js-file) |
151 | 150 | out-file (io/file out-dir (File/.getName js-file)) |
152 | | - args (into-array String |
153 | | - (cond-> [(File/.getPath esbuild-path) js-path "--bundle" |
154 | | - (str "--outfile=" (File/.getPath out-file)) |
155 | | - "--metafile=/dev/stdout" |
156 | | - "--format=iife" |
157 | | - "--log-level=error"] |
158 | | - (= mode :dev) (conj "--sourcemap") |
159 | | - (= mode :prod) (conj "--minify")))] |
| 151 | + args (into |
| 152 | + [(File/.getPath esbuild-path) js-path] |
| 153 | + (esbuild/args (cond-> {:bundle true |
| 154 | + :outfile (File/.getPath out-file) |
| 155 | + :metafile "/dev/stdout" |
| 156 | + :format "iife" |
| 157 | + :log-level "error"} |
| 158 | + (= mode :dev) (assoc :sourcemap true) |
| 159 | + (= mode :prod) (assoc :minify true))))] |
| 160 | + |
160 | 161 | (try |
161 | 162 | ;; Use ProcessBuilder to capture stdout separately |
162 | | - (let [pb (new ProcessBuilder ^"[Ljava.lang.String;" args) |
| 163 | + ;; TODO: clojure.java.process |
| 164 | + (let [pb (new ProcessBuilder ^"[Ljava.lang.String;" (into-array String args)) |
163 | 165 | _ (.redirectError pb ProcessBuilder$Redirect/DISCARD) |
164 | 166 | p (.start pb) |
165 | 167 | output (slurp (.getInputStream p)) |
|
170 | 172 | (let [meta-data (json/read-str output :key-fn keyword) |
171 | 173 | outputs (:outputs meta-data)] |
172 | 174 | (map (fn [[out-path out-info]] |
173 | | - {:file (File/.getName (io/file (str out-path))) |
174 | | - :size (:bytes out-info) |
175 | | - :type :js}) |
| 175 | + (let [output-file (io/file (name out-path))] |
| 176 | + {:file (File/.getName output-file) |
| 177 | + :size (:bytes out-info) |
| 178 | + :path (File/.getAbsolutePath output-file) |
| 179 | + :type :js})) |
176 | 180 | outputs)) |
177 | 181 | ;; Fallback if no metadata |
178 | 182 | [{:file (File/.getName js-file) |
|
199 | 203 |
|
200 | 204 | (defn bundle-assets |
201 | 205 | "Bundle all CSS and JS assets" |
202 | | - [site-root output-dir mode] |
203 | | - ;; Only ensure npm setup if we're in a traditional Eden project structure |
204 | | - ;; (with site/ subdirectory), not in a generated site |
205 | | - (let [site-subdir (io/file site-root "site")] |
206 | | - (when (.exists site-subdir) |
207 | | - (ensure-npm-setup site-root))) |
208 | | - (println " Bundling assets:") |
209 | | - (let [css-result (bundle-css site-root output-dir mode) |
210 | | - js-result (bundle-js site-root output-dir mode)] |
211 | | - ;; Print timing info |
212 | | - (when css-result |
213 | | - (println (format " CSS: %dms" (:elapsed css-result)))) |
214 | | - (when js-result |
215 | | - (println (format " JS: %dms" (:elapsed js-result)))) |
216 | | - ;; Return bundle info for reporting |
217 | | - {:css css-result |
218 | | - :js js-result})) |
| 206 | + [ctx] |
| 207 | + {:assets-output {:css (copy-css ctx) |
| 208 | + :js (bundle-js ctx)}}) |
| 209 | + |
| 210 | + |
0 commit comments