From c1f386ef44ac249d03592bad026a3fd8a31ab154 Mon Sep 17 00:00:00 2001 From: Per Magnus Petersen Date: Sat, 14 Feb 2026 11:45:09 +0100 Subject: [PATCH] Initial Kit project with PostgreSQL, HTMX, Hiccup, and Tailwind Co-Authored-By: Claude Opus 4.6 --- .gitignore | 21 + Dockerfile | 15 + Makefile | 14 + README.md | 37 + bb.edn | 27 + build.clj | 41 + deps.edn | 69 ++ docker-compose.yml | 14 + .../clj/pmagnus/elprice/dev_middleware.clj | 5 + env/dev/clj/pmagnus/elprice/env.clj | 14 + env/dev/clj/user.clj | 59 + env/dev/resources/logback.xml | 38 + env/prod/clj/pmagnus/elprice/env.clj | 12 + env/prod/resources/logback.xml | 16 + env/test/resources/logback.xml | 16 + kit.edn | 8 + package-lock.json | 1055 +++++++++++++++++ package.json | 21 + resources/css/input.css | 1 + resources/migrations/placeholder.txt | 1 + resources/queries.sql | 2 + resources/system.edn | 69 ++ src/clj/pmagnus/elprice/config.clj | 9 + src/clj/pmagnus/elprice/core.clj | 43 + .../elprice/web/controllers/health.clj | 13 + src/clj/pmagnus/elprice/web/handler.clj | 45 + src/clj/pmagnus/elprice/web/htmx.clj | 25 + .../pmagnus/elprice/web/middleware/core.clj | 14 + .../elprice/web/middleware/exception.clj | 31 + .../elprice/web/middleware/formats.clj | 14 + src/clj/pmagnus/elprice/web/routes/api.clj | 52 + src/clj/pmagnus/elprice/web/routes/ui.clj | 50 + src/clj/pmagnus/elprice/web/routes/utils.clj | 11 + tailwind.config.js | 5 + test/clj/pmagnus/elprice/core_test.clj | 8 + test/clj/pmagnus/elprice/test_utils.clj | 32 + test/clj/pmagnus/elprice/web/request_test.clj | 13 + 37 files changed, 1920 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 bb.edn create mode 100644 build.clj create mode 100644 deps.edn create mode 100644 docker-compose.yml create mode 100644 env/dev/clj/pmagnus/elprice/dev_middleware.clj create mode 100644 env/dev/clj/pmagnus/elprice/env.clj create mode 100644 env/dev/clj/user.clj create mode 100644 env/dev/resources/logback.xml create mode 100644 env/prod/clj/pmagnus/elprice/env.clj create mode 100644 env/prod/resources/logback.xml create mode 100644 env/test/resources/logback.xml create mode 100644 kit.edn create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 resources/css/input.css create mode 100644 resources/migrations/placeholder.txt create mode 100644 resources/queries.sql create mode 100644 resources/system.edn create mode 100644 src/clj/pmagnus/elprice/config.clj create mode 100644 src/clj/pmagnus/elprice/core.clj create mode 100644 src/clj/pmagnus/elprice/web/controllers/health.clj create mode 100644 src/clj/pmagnus/elprice/web/handler.clj create mode 100644 src/clj/pmagnus/elprice/web/htmx.clj create mode 100644 src/clj/pmagnus/elprice/web/middleware/core.clj create mode 100644 src/clj/pmagnus/elprice/web/middleware/exception.clj create mode 100644 src/clj/pmagnus/elprice/web/middleware/formats.clj create mode 100644 src/clj/pmagnus/elprice/web/routes/api.clj create mode 100644 src/clj/pmagnus/elprice/web/routes/ui.clj create mode 100644 src/clj/pmagnus/elprice/web/routes/utils.clj create mode 100644 tailwind.config.js create mode 100644 test/clj/pmagnus/elprice/core_test.clj create mode 100644 test/clj/pmagnus/elprice/test_utils.clj create mode 100644 test/clj/pmagnus/elprice/web/request_test.clj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b95c66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +kit.git-config.edn +.DS_Store +.nrepl-port +hs_err_pid*.log +.cpcache/ +target +log/ +.shadow-cljs/ +node_modules/ +modules/ +resources/public/css/output.css + +# IntelliJ +*.iml +.idea/ +*.bak + +# clj-kondo +.clj-kondo/.cache/ +.calva/output-window/ +.lsp/.cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5973d48 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# syntax = docker/dockerfile:1.2 +FROM clojure:openjdk-17 AS build + +WORKDIR / +COPY . / + +RUN clj -Sforce -T:build all + +FROM azul/zulu-openjdk-alpine:17 + +COPY --from=build /target/elprice-standalone.jar /elprice/elprice-standalone.jar + +EXPOSE $PORT + +ENTRYPOINT exec java $JAVA_OPTS -jar /elprice/elprice-standalone.jar diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..34e64b5 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +clean: + rm -rf target + +run: + clj -M:dev + +repl: + clj -M:dev:nrepl + +test: + clj -M:test + +uberjar: + clj -T:build all diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2f135f --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# elprice + +Start a [REPL](#repls) in your editor or terminal of choice. + +Start the server with: + +```clojure +(go) +``` + +The default API is available under http://localhost:3000/api + +System configuration is available under `resources/system.edn`. + +To reload changes: + +```clojure +(reset) +``` + +## REPLs + +### Cursive + +Configure a [REPL following the Cursive documentation](https://cursive-ide.com/userguide/repl.html). Using the default "Run with IntelliJ project classpath" option will let you select an alias from the ["Clojure deps" aliases selection](https://cursive-ide.com/userguide/deps.html#refreshing-deps-dependencies). + +### CIDER + +Use the `cider` alias for CIDER nREPL support (run `clj -M:dev:cider`). See the [CIDER docs](https://docs.cider.mx/cider/basics/up_and_running.html) for more help. + +Note that this alias runs nREPL during development. To run nREPL in production (typically when the system starts), use the kit-nrepl library through the +nrepl profile as described in [the documentation](https://kit-clj.github.io/docs/profiles.html#profiles). + +### Command Line + +Run `clj -M:dev:nrepl` or `make repl`. + +Note that, just like with [CIDER](#cider), this alias runs nREPL during development. To run nREPL in production (typically when the system starts), use the kit-nrepl library through the +nrepl profile as described in [the documentation](https://kit-clj.github.io/docs/profiles.html#profiles). diff --git a/bb.edn b/bb.edn new file mode 100644 index 0000000..c336062 --- /dev/null +++ b/bb.edn @@ -0,0 +1,27 @@ +{:min-bb-version "0.8.156" + :deps {failjure/failjure {:mvn/version "2.3.0"}} + :tasks {:requires ([babashka.fs :as fs] + [babashka.tasks :refer [shell]]) + + run {:doc "starts the app" + :task (if (fs/windows?) + (clojure {:dir "."} "-M:dev") + (shell {:dir "."} "clj -M:dev"))} + + nrepl {:doc "starts the nREPL" + :task (clojure {:dir "."} "-M:dev:nrepl")} + + cider {:doc "starts the cider" + :task (clojure {:dir "."} "-M:dev:cider")} + + test {:doc "runs tests" + :task (clojure {:dir "."} "-M:test")} + + uberjar {:doc "builds the uberjar" + :task (clojure {:dir "."} "-T:build all")} + + format {:doc "Formats codebase" + :task (try + (shell {:dir "src"} "cljstyle fix") + (catch Exception _ + (clojure {:dir "src"} "-M:dev -m cljstyle.main fix")))}}} diff --git a/build.clj b/build.clj new file mode 100644 index 0000000..ac8a55d --- /dev/null +++ b/build.clj @@ -0,0 +1,41 @@ +(ns build + (:require [clojure.string :as string] + [clojure.tools.build.api :as b])) + +(def lib 'pmagnus/elprice) +(def main-cls (string/join "." (filter some? [(namespace lib) (name lib) "core"]))) +(def version (format "0.0.1-SNAPSHOT")) +(def target-dir "target") +(def class-dir (str target-dir "/" "classes")) +(def uber-file (format "%s/%s-standalone.jar" target-dir (name lib))) +(def basis (b/create-basis {:project "deps.edn"})) + +(defn clean + "Delete the build target directory" + [_] + (println (str "Cleaning " target-dir)) + (b/delete {:path target-dir})) + +(defn prep [_] + (println "Writing Pom...") + (b/write-pom {:class-dir class-dir + :lib lib + :version version + :basis basis + :src-dirs ["src/clj"]}) + (b/copy-dir {:src-dirs ["src/clj" "resources" "env/prod/resources" "env/prod/clj"] + :target-dir class-dir})) + +(defn uber [_] + (println "Compiling Clojure...") + (b/compile-clj {:basis basis + :src-dirs ["src/clj" "resources" "env/prod/resources" "env/prod/clj"] + :class-dir class-dir}) + (println "Making uberjar...") + (b/uber {:class-dir class-dir + :uber-file uber-file + :main main-cls + :basis basis})) + +(defn all [_] + (do (clean nil) (prep nil) (uber nil))) diff --git a/deps.edn b/deps.edn new file mode 100644 index 0000000..439858f --- /dev/null +++ b/deps.edn @@ -0,0 +1,69 @@ +{:paths ["src/clj" + "resources"] + + :deps {org.clojure/clojure {:mvn/version "1.12.3"} + + ;; Routing + metosin/reitit {:mvn/version "0.9.2"} + + ;; Ring + metosin/ring-http-response {:mvn/version "0.9.5"} + ring/ring-core {:mvn/version "1.15.3"} + ring/ring-defaults {:mvn/version "0.7.0"} + + ;; Logging + ch.qos.logback/logback-classic {:mvn/version "1.5.20"} + + ;; Data coercion + luminus-transit/luminus-transit {:mvn/version "0.1.6" + :exclusions [com.cognitect/transit-clj]} + metosin/muuntaja {:mvn/version "0.6.11"} + + ;; kit Libs + io.github.kit-clj/kit-core {:mvn/version "1.0.9"} + io.github.kit-clj/kit-undertow {:mvn/version "1.0.10"} + io.github.kit-clj/kit-postgres {:mvn/version "1.0.7"} + io.github.kit-clj/kit-sql-conman {:mvn/version "1.10.5"} + io.github.kit-clj/kit-sql-migratus {:mvn/version "1.0.5"} + hiccup/hiccup {:mvn/version "2.0.0"}} + :aliases {:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.11"}} + :ns-default build} + + + :dev {:extra-deps {com.lambdaisland/classpath {:mvn/version "0.6.58"} + criterium/criterium {:mvn/version "0.4.6"} + expound/expound {:mvn/version "0.9.0"} + integrant/repl {:mvn/version "0.5.0"} + mvxcvi/cljstyle {:mvn/version "0.17.642"} + pjstadig/humane-test-output {:mvn/version "0.11.0"} + ring/ring-devel {:mvn/version "1.15.3"} + ring/ring-mock {:mvn/version "0.6.2"} + io.github.kit-clj/kit-generator {:mvn/version "0.2.5"} + org.clojure/tools.namespace {:mvn/version "1.5.0"} + } + :extra-paths ["env/dev/clj" "env/dev/resources" "test/clj"]} + :nrepl {:extra-deps {nrepl/nrepl {:mvn/version "1.5.1"}} + :main-opts ["-m" "nrepl.cmdline" "-i"]} + :cider {:extra-deps {nrepl/nrepl {:mvn/version "1.5.1"} + cider/cider-nrepl {:mvn/version "0.58.0"}} + :main-opts ["-m" "nrepl.cmdline" "--middleware" "[cider.nrepl/cider-middleware]" "-i"]} + + :test {:extra-deps {criterium/criterium {:mvn/version "0.4.6"} + expound/expound {:mvn/version "0.9.0"} + integrant/repl {:mvn/version "0.5.0"} + io.github.cognitect-labs/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git" + :git/tag "v0.5.1" + :git/sha "dfb30dd"} + pjstadig/humane-test-output {:mvn/version "0.11.0"} + ring/ring-devel {:mvn/version "1.15.3"} + ring/ring-mock {:mvn/version "0.6.2"} + io.github.kit-clj/kit-generator {:mvn/version "0.2.5"} + org.clojure/tools.namespace {:mvn/version "1.5.0"} + peridot/peridot {:mvn/version "0.5.4"} + org.clj-commons/byte-streams {:mvn/version "0.3.4"} + com.lambdaisland/classpath {:mvn/version "0.6.58"}} + :exec-fn cognitect.test-runner.api/test + :extra-paths ["env/dev/clj" "env/dev/resources" "env/test/resources" "test/clj"] + :main-opts ["-e" "(require 'pjstadig.humane-test-output) (pjstadig.humane-test-output/activate!)" + "-m" "cognitect.test-runner"]}} + } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..94c9542 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: elprice + POSTGRES_USER: elprice + POSTGRES_PASSWORD: elprice + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/env/dev/clj/pmagnus/elprice/dev_middleware.clj b/env/dev/clj/pmagnus/elprice/dev_middleware.clj new file mode 100644 index 0000000..a63844c --- /dev/null +++ b/env/dev/clj/pmagnus/elprice/dev_middleware.clj @@ -0,0 +1,5 @@ +(ns pmagnus.elprice.dev-middleware) + +(defn wrap-dev [handler _opts] + (-> handler + )) diff --git a/env/dev/clj/pmagnus/elprice/env.clj b/env/dev/clj/pmagnus/elprice/env.clj new file mode 100644 index 0000000..daf5725 --- /dev/null +++ b/env/dev/clj/pmagnus/elprice/env.clj @@ -0,0 +1,14 @@ +(ns pmagnus.elprice.env + (:require + [clojure.tools.logging :as log] + [pmagnus.elprice.dev-middleware :refer [wrap-dev]])) + +(def defaults + {:init (fn [] + (log/info "\n-=[elprice starting using the development or test profile]=-")) + :start (fn [] + (log/info "\n-=[elprice started successfully using the development or test profile]=-")) + :stop (fn [] + (log/info "\n-=[elprice has shut down successfully]=-")) + :middleware wrap-dev + :opts {:profile :dev}}) diff --git a/env/dev/clj/user.clj b/env/dev/clj/user.clj new file mode 100644 index 0000000..25a9817 --- /dev/null +++ b/env/dev/clj/user.clj @@ -0,0 +1,59 @@ +(ns user + "Userspace functions you can run by default in your local REPL." + (:require + [clojure.pprint] + [clojure.spec.alpha :as s] + [clojure.tools.namespace.repl :as repl] + [criterium.core :as c] ;; benchmarking + [expound.alpha :as expound] + [integrant.core :as ig] + [integrant.repl :refer [clear go halt prep init reset reset-all]] + [integrant.repl.state :as state] + [kit.api :as kit] + [lambdaisland.classpath.watch-deps :as watch-deps] ;; hot loading for deps + [pmagnus.elprice.core :refer [start-app]])) + +;; uncomment to enable hot loading for deps +(watch-deps/start! {:aliases [:dev :test]}) + +(alter-var-root #'s/*explain-out* (constantly expound/printer)) + +(add-tap (bound-fn* clojure.pprint/pprint)) + +(defn dev-prep! + [] + (integrant.repl/set-prep! (fn [] + (-> (pmagnus.elprice.config/system-config {:profile :dev}) + (ig/expand))))) + +(defn test-prep! + [] + (integrant.repl/set-prep! (fn [] + (-> (pmagnus.elprice.config/system-config {:profile :test}) + (ig/expand))))) + +;; Can change this to test-prep! if want to run tests as the test profile in your repl +;; You can run tests in the dev profile, too, but there are some differences between +;; the two profiles. +(dev-prep!) + +(repl/set-refresh-dirs "src/clj") + +(def refresh repl/refresh) + + +(defn reset-db [] + (migratus.core/reset (:db.sql/migrations state/system))) + +(defn rollback [] + (migratus.core/rollback (:db.sql/migrations state/system))) + +(defn migrate [] + (migratus.core/migrate (:db.sql/migrations state/system))) + +(def query-fn (:db.sql/query-fn state/system)) + + +(comment + (go) + (reset)) diff --git a/env/dev/resources/logback.xml b/env/dev/resources/logback.xml new file mode 100644 index 0000000..d487bb6 --- /dev/null +++ b/env/dev/resources/logback.xml @@ -0,0 +1,38 @@ + + + + + + + UTF-8 + %date{ISO8601} [%thread] %-5level %logger{36} - %msg %n + + + + log/pmagnus.elprice.log + + log/pmagnus.elprice.%d{yyyy-MM-dd}.%i.log + + 100MB + + + 30 + + + UTF-8 + %date{ISO8601} [%thread] %-5level %logger{36} - %msg %n + + + + + + + + + + + + + + diff --git a/env/prod/clj/pmagnus/elprice/env.clj b/env/prod/clj/pmagnus/elprice/env.clj new file mode 100644 index 0000000..e2a02fd --- /dev/null +++ b/env/prod/clj/pmagnus/elprice/env.clj @@ -0,0 +1,12 @@ +(ns pmagnus.elprice.env + (:require [clojure.tools.logging :as log])) + +(def defaults + {:init (fn [] + (log/info "\n-=[elprice starting]=-")) + :start (fn [] + (log/info "\n-=[elprice started successfully]=-")) + :stop (fn [] + (log/info "\n-=[elprice has shut down successfully]=-")) + :middleware (fn [handler _] handler) + :opts {:profile :prod}}) diff --git a/env/prod/resources/logback.xml b/env/prod/resources/logback.xml new file mode 100644 index 0000000..2fade8d --- /dev/null +++ b/env/prod/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + %-5relative %-5level %logger{35} - %msg%n + + + + + + + + + + diff --git a/env/test/resources/logback.xml b/env/test/resources/logback.xml new file mode 100644 index 0000000..2fade8d --- /dev/null +++ b/env/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + %-5relative %-5level %logger{35} - %msg%n + + + + + + + + + + diff --git a/kit.edn b/kit.edn new file mode 100644 index 0000000..c8c3423 --- /dev/null +++ b/kit.edn @@ -0,0 +1,8 @@ +{:full-name "pmagnus/elprice" + :ns-name "pmagnus.elprice" + :sanitized "pmagnus/elprice" + :name "elprice" + :modules {:root "modules" + :repositories [{:url "https://github.com/kit-clj/modules.git" + :tag "master" + :name "kit-modules"}]}} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..16d7489 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1055 @@ +{ + "name": "elprice", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "elprice", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@tailwindcss/cli": "^4.1.18", + "tailwindcss": "^4.1.18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.18.tgz", + "integrity": "sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "enhanced-resolve": "^5.18.3", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.1.18" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..01bb524 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "elprice", + "version": "1.0.0", + "description": "Start a [REPL](#repls) in your editor or terminal of choice.", + "main": "index.js", + "directories": { + "test": "test" + }, + "scripts": { + "css:build": "npx @tailwindcss/cli -i resources/css/input.css -o resources/public/css/output.css --minify", + "css:watch": "npx @tailwindcss/cli -i resources/css/input.css -o resources/public/css/output.css --watch" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "devDependencies": { + "@tailwindcss/cli": "^4.1.18", + "tailwindcss": "^4.1.18" + } +} diff --git a/resources/css/input.css b/resources/css/input.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/resources/css/input.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/resources/migrations/placeholder.txt b/resources/migrations/placeholder.txt new file mode 100644 index 0000000..0b1cbc7 --- /dev/null +++ b/resources/migrations/placeholder.txt @@ -0,0 +1 @@ +See https://github.com/yogthos/migratus diff --git a/resources/queries.sql b/resources/queries.sql new file mode 100644 index 0000000..8f64c5f --- /dev/null +++ b/resources/queries.sql @@ -0,0 +1,2 @@ +-- place your sql queries here +-- see https://www.hugsql.org/ for documentation diff --git a/resources/system.edn b/resources/system.edn new file mode 100644 index 0000000..47b8ec2 --- /dev/null +++ b/resources/system.edn @@ -0,0 +1,69 @@ +{:system/env + #profile {:dev :dev + :test :test + :prod :prod} + + :server/http + {:port #long #or [#env PORT 3000] + :host #or [#env HTTP_HOST "0.0.0.0"] + :handler #ig/ref :handler/ring} + + :handler/ring + {:router #ig/ref :router/core + :api-path "/api" + :cookie-secret #or [#env COOKIE_SECRET "JJCILTOQLMYRBFRU"] + ;; from ring.middleware.defaults. anti-forgery `false` by default because services may not require it + :site-defaults-config {:params {:urlencoded true + :multipart true + :nested true + :keywordize true} + :cookies true + :session {:flash true + :cookie-name "pmagnus.elprice" + :cookie-attrs {:max-age 86400 + :http-only true + :same-site :strict}} + :security {:anti-forgery false + :xss-protection {:enable? true, + :mode :block} + :frame-options :sameorigin + :content-type-options :nosniff} + :static {:resources "public"} + :responses {:not-modified-responses true + :absolute-redirects true + :content-types true + :default-charset "utf-8"}}} + + :reitit.routes/api + {:base-path "/api" + :env #ig/ref :system/env + :query-fn #ig/ref :db.sql/query-fn} + + :router/routes + {:routes #ig/refset :reitit/routes} + + :router/core + {:routes #ig/ref :router/routes + :env #ig/ref :system/env} + + :db.sql/connection + #profile {:dev {:jdbc-url "jdbc:postgresql://localhost:5432/elprice?user=elprice&password=elprice"} + :test {:jdbc-url "jdbc:postgresql://localhost:5432/elprice?user=elprice&password=elprice"} + :prod {:jdbc-url #env JDBC_URL + :init-size 1 + :min-idle 1 + :max-idle 8 + :max-active 32}} + + :db.sql/query-fn + {:conn #ig/ref :db.sql/connection + :options {} + :filename "queries.sql" + :env #ig/ref :system/env} + + :db.sql/migrations + {:store :database + :db {:datasource #ig/ref :db.sql/connection} + :migrate-on-init? true} + :reitit.routes/ui {:base-path "", + :env #ig/ref :system/env}} diff --git a/src/clj/pmagnus/elprice/config.clj b/src/clj/pmagnus/elprice/config.clj new file mode 100644 index 0000000..778b4ec --- /dev/null +++ b/src/clj/pmagnus/elprice/config.clj @@ -0,0 +1,9 @@ +(ns pmagnus.elprice.config + (:require + [kit.config :as config])) + +(def ^:const system-filename "system.edn") + +(defn system-config + [options] + (config/read-config system-filename options)) diff --git a/src/clj/pmagnus/elprice/core.clj b/src/clj/pmagnus/elprice/core.clj new file mode 100644 index 0000000..607c6bc --- /dev/null +++ b/src/clj/pmagnus/elprice/core.clj @@ -0,0 +1,43 @@ +(ns pmagnus.elprice.core + (:require + [clojure.tools.logging :as log] + [integrant.core :as ig] + [pmagnus.elprice.config :as config] + [pmagnus.elprice.env :refer [defaults]] + + ;; Edges + [kit.edge.db.sql.conman] + [kit.edge.db.sql.migratus] + [kit.edge.db.postgres] + [kit.edge.db.mysql] + [kit.edge.server.undertow] + [pmagnus.elprice.web.handler] + + ;; Routes + [pmagnus.elprice.web.routes.api] + [pmagnus.elprice.web.routes.ui]) + (:gen-class)) + +;; log uncaught exceptions in threads +(Thread/setDefaultUncaughtExceptionHandler + (fn [thread ex] + (log/error {:what :uncaught-exception + :exception ex + :where (str "Uncaught exception on" (.getName thread))}))) + +(defonce system (atom nil)) + +(defn stop-app [] + ((or (:stop defaults) (fn []))) + (some-> (deref system) (ig/halt!))) + +(defn start-app [& [params]] + ((or (:start params) (:start defaults) (fn []))) + (->> (config/system-config (or (:opts params) (:opts defaults) {})) + (ig/expand) + (ig/init) + (reset! system))) + +(defn -main [& _] + (start-app) + (.addShutdownHook (Runtime/getRuntime) (Thread. (fn [] (stop-app) (shutdown-agents))))) diff --git a/src/clj/pmagnus/elprice/web/controllers/health.clj b/src/clj/pmagnus/elprice/web/controllers/health.clj new file mode 100644 index 0000000..0c1eb1b --- /dev/null +++ b/src/clj/pmagnus/elprice/web/controllers/health.clj @@ -0,0 +1,13 @@ +(ns pmagnus.elprice.web.controllers.health + (:require + [ring.util.http-response :as http-response]) + (:import + [java.util Date])) + +(defn healthcheck! + [req] + (http-response/ok + {:time (str (Date. (System/currentTimeMillis))) + :up-since (str (Date. (.getStartTime (java.lang.management.ManagementFactory/getRuntimeMXBean)))) + :app {:status "up" + :message ""}})) diff --git a/src/clj/pmagnus/elprice/web/handler.clj b/src/clj/pmagnus/elprice/web/handler.clj new file mode 100644 index 0000000..294c255 --- /dev/null +++ b/src/clj/pmagnus/elprice/web/handler.clj @@ -0,0 +1,45 @@ +(ns pmagnus.elprice.web.handler + (:require + [pmagnus.elprice.web.middleware.core :as middleware] + [integrant.core :as ig] + [ring.util.http-response :as http-response] + [reitit.ring :as ring] + [reitit.swagger-ui :as swagger-ui])) + +(defmethod ig/init-key :handler/ring + [_ {:keys [router api-path] :as opts}] + (ring/ring-handler + (router) + (ring/routes + ;; Handle trailing slash in routes - add it + redirect to it + ;; https://github.com/metosin/reitit/blob/master/doc/ring/slash_handler.md + (ring/redirect-trailing-slash-handler) + (ring/create-resource-handler {:path "/"}) + (when (some? api-path) + (swagger-ui/create-swagger-ui-handler {:path api-path + :url (str api-path "/swagger.json")})) + (ring/create-default-handler + {:not-found + (constantly (-> {:status 404, :body "Page not found"} + (http-response/content-type "text/plain"))) + :method-not-allowed + (constantly (-> {:status 405, :body "Not allowed"} + (http-response/content-type "text/plain"))) + :not-acceptable + (constantly (-> {:status 406, :body "Not acceptable"} + (http-response/content-type "text/plain")))})) + {:middleware [(middleware/wrap-base opts)]})) + +(defmethod ig/init-key :router/routes + [_ {:keys [routes]}] + (mapv (fn [route] + (if (fn? route) + (route) + route)) + routes)) + +(defmethod ig/init-key :router/core + [_ {:keys [routes env] :as opts}] + (if (= env :dev) + #(ring/router ["" opts routes]) + (constantly (ring/router ["" opts routes])))) diff --git a/src/clj/pmagnus/elprice/web/htmx.clj b/src/clj/pmagnus/elprice/web/htmx.clj new file mode 100644 index 0000000..34ac3ac --- /dev/null +++ b/src/clj/pmagnus/elprice/web/htmx.clj @@ -0,0 +1,25 @@ +(ns pmagnus.elprice.web.htmx + (:require + [ring.util.http-response :as http-response] + [hiccup.core :as h] + [hiccup.page :as p])) + +(defmacro page [opts & content] + `(-> (p/html5 ~opts + [:head + [:meta {:charset "UTF-8"}] + [:meta {:name "viewport" + :content "width=device-width, initial-scale=1, viewport-fit=cover"}] + [:link {:rel "stylesheet" :href "/css/output.css"}] + [:script {:src "https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js" + :defer true}]] + [:body {:class "bg-gray-50 min-h-screen"} + [:div {:class "mx-auto max-w-[390px] px-4 py-6"} + ~@content]]) + http-response/ok + (http-response/content-type "text/html"))) + +(defmacro fragment [opts & content] + `(-> (str (h/html ~opts ~@content)) + http-response/ok + (http-response/content-type "text/html"))) diff --git a/src/clj/pmagnus/elprice/web/middleware/core.clj b/src/clj/pmagnus/elprice/web/middleware/core.clj new file mode 100644 index 0000000..ee994cb --- /dev/null +++ b/src/clj/pmagnus/elprice/web/middleware/core.clj @@ -0,0 +1,14 @@ +(ns pmagnus.elprice.web.middleware.core + (:require + [pmagnus.elprice.env :as env] + [ring.middleware.defaults :as defaults] + [ring.middleware.session.cookie :as cookie])) + +(defn wrap-base + [{:keys [metrics site-defaults-config cookie-secret] :as opts}] + (let [cookie-store (cookie/cookie-store {:key (.getBytes ^String cookie-secret)})] + (fn [handler] + (cond-> ((:middleware env/defaults) handler opts) + true (defaults/wrap-defaults + (assoc-in site-defaults-config [:session :store] cookie-store)) + )))) diff --git a/src/clj/pmagnus/elprice/web/middleware/exception.clj b/src/clj/pmagnus/elprice/web/middleware/exception.clj new file mode 100644 index 0000000..4839105 --- /dev/null +++ b/src/clj/pmagnus/elprice/web/middleware/exception.clj @@ -0,0 +1,31 @@ +(ns pmagnus.elprice.web.middleware.exception + (:require + [clojure.tools.logging :as log] + [reitit.ring.middleware.exception :as exception])) + +(defn handler [message status exception request] + (when (>= status 500) + ;; You can optionally use this to report error to an external service + (log/error exception)) + {:status status + :body {:message message + :exception (.getClass exception) + :data (ex-data exception) + :uri (:uri request)}}) + +(def wrap-exception + (exception/create-exception-middleware + (merge + exception/default-handlers + {:system.exception/internal (partial handler "internal exception" 500) + :system.exception/business (partial handler "bad request" 400) + :system.exception/not-found (partial handler "not found" 404) + :system.exception/unauthorized (partial handler "unauthorized" 401) + :system.exception/forbidden (partial handler "forbidden" 403) + + ;; override the default handler + ::exception/default (partial handler "default" 500) + + ;; print stack-traces for all exceptions + ::exception/wrap (fn [handler e request] + (handler e request))}))) diff --git a/src/clj/pmagnus/elprice/web/middleware/formats.clj b/src/clj/pmagnus/elprice/web/middleware/formats.clj new file mode 100644 index 0000000..5d3644d --- /dev/null +++ b/src/clj/pmagnus/elprice/web/middleware/formats.clj @@ -0,0 +1,14 @@ +(ns pmagnus.elprice.web.middleware.formats + (:require + [luminus-transit.time :as time] + [muuntaja.core :as m])) + +(def instance + (m/create + (-> m/default-options + (update-in + [:formats "application/transit+json" :decoder-opts] + (partial merge time/time-deserialization-handlers)) + (update-in + [:formats "application/transit+json" :encoder-opts] + (partial merge time/time-serialization-handlers))))) diff --git a/src/clj/pmagnus/elprice/web/routes/api.clj b/src/clj/pmagnus/elprice/web/routes/api.clj new file mode 100644 index 0000000..cccfb24 --- /dev/null +++ b/src/clj/pmagnus/elprice/web/routes/api.clj @@ -0,0 +1,52 @@ +(ns pmagnus.elprice.web.routes.api + (:require + [pmagnus.elprice.web.controllers.health :as health] + [pmagnus.elprice.web.middleware.exception :as exception] + [pmagnus.elprice.web.middleware.formats :as formats] + [integrant.core :as ig] + [reitit.coercion.malli :as malli] + [reitit.ring.coercion :as coercion] + [reitit.ring.middleware.muuntaja :as muuntaja] + [reitit.ring.middleware.parameters :as parameters] + [reitit.swagger :as swagger])) + +(def route-data + {:coercion malli/coercion + :muuntaja formats/instance + :swagger {:id ::api} + :middleware [;; query-params & form-params + parameters/parameters-middleware + ;; content-negotiation + muuntaja/format-negotiate-middleware + ;; encoding response body + muuntaja/format-response-middleware + ;; exception handling + coercion/coerce-exceptions-middleware + ;; decoding request body + muuntaja/format-request-middleware + ;; coercing response bodys + coercion/coerce-response-middleware + ;; coercing request parameters + coercion/coerce-request-middleware + ;; exception handling + exception/wrap-exception]}) + +;; Routes +(defn api-routes [_opts] + [["/swagger.json" + {:get {:no-doc true + :swagger {:info {:title "pmagnus.elprice API"}} + :handler (swagger/create-swagger-handler)}}] + ["/health" + ;; note that use of the var is necessary + ;; for reitit to reload routes without + ;; restarting the system + {:get #'health/healthcheck!}]]) + +(derive :reitit.routes/api :reitit/routes) + +(defmethod ig/init-key :reitit.routes/api + [_ {:keys [base-path] + :or {base-path ""} + :as opts}] + (fn [] [base-path route-data (api-routes opts)])) diff --git a/src/clj/pmagnus/elprice/web/routes/ui.clj b/src/clj/pmagnus/elprice/web/routes/ui.clj new file mode 100644 index 0000000..7eaa3d1 --- /dev/null +++ b/src/clj/pmagnus/elprice/web/routes/ui.clj @@ -0,0 +1,50 @@ +(ns pmagnus.elprice.web.routes.ui + (:require + [pmagnus.elprice.web.middleware.exception :as exception] + [pmagnus.elprice.web.middleware.formats :as formats] + [pmagnus.elprice.web.routes.utils :as utils] + [pmagnus.elprice.web.htmx :refer [page fragment] :as htmx] + [integrant.core :as ig] + [reitit.ring.middleware.muuntaja :as muuntaja] + [reitit.ring.middleware.parameters :as parameters])) + +(defn home [request] + (page {:lang "en"} + [:h1 {:class "text-2xl font-bold text-gray-900"} + "Welcome to elprice"] + [:p {:class "mt-2 text-gray-600"} + "Built with Kit, HTMX, Hiccup & Tailwind"] + [:button {:class "mt-4 rounded-lg bg-blue-600 px-4 py-2 text-white font-medium + active:bg-blue-700 transition-colors" + :hx-post "/clicked" + :hx-swap "outerHTML"} + "Click me!"])) + +(defn clicked [request] + (fragment + [:div {:class "mt-4 rounded-lg bg-green-100 p-4 text-green-800"} + "Congratulations! You just clicked the button!"])) + +;; Routes +(defn ui-routes [_opts] + [["/" {:get home}] + ["/clicked" {:post clicked}]]) + +(def route-data + {:muuntaja formats/instance + :middleware + [;; Default middleware for ui + ;; query-params & form-params + parameters/parameters-middleware + ;; encoding response body + muuntaja/format-response-middleware + ;; exception handling + exception/wrap-exception]}) + +(derive :reitit.routes/ui :reitit/routes) + +(defmethod ig/init-key :reitit.routes/ui + [_ {:keys [base-path] + :or {base-path ""} + :as opts}] + (fn [] [base-path route-data (ui-routes opts)])) diff --git a/src/clj/pmagnus/elprice/web/routes/utils.clj b/src/clj/pmagnus/elprice/web/routes/utils.clj new file mode 100644 index 0000000..9869eab --- /dev/null +++ b/src/clj/pmagnus/elprice/web/routes/utils.clj @@ -0,0 +1,11 @@ +(ns pmagnus.elprice.web.routes.utils) + +(def route-data-path [:reitit.core/match :data]) + +(defn route-data + [req] + (get-in req route-data-path)) + +(defn route-data-key + [req k] + (get-in req (conj route-data-path k))) diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..ceef7fe --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,5 @@ +module.exports = { + content: ["./src/**/*.clj", "./resources/**/*.html"], + theme: { extend: {} }, + plugins: [] +} diff --git a/test/clj/pmagnus/elprice/core_test.clj b/test/clj/pmagnus/elprice/core_test.clj new file mode 100644 index 0000000..3bb8c4d --- /dev/null +++ b/test/clj/pmagnus/elprice/core_test.clj @@ -0,0 +1,8 @@ +(ns pmagnus.elprice.core-test + (:require + [pmagnus.elprice.test-utils :as utils] + [clojure.test :refer :all])) + +(deftest example-test + (is (= 1 2))) + diff --git a/test/clj/pmagnus/elprice/test_utils.clj b/test/clj/pmagnus/elprice/test_utils.clj new file mode 100644 index 0000000..ac96fbe --- /dev/null +++ b/test/clj/pmagnus/elprice/test_utils.clj @@ -0,0 +1,32 @@ +(ns pmagnus.elprice.test-utils + (:require + [pmagnus.elprice.core :as core] + [peridot.core :as p] + [byte-streams :as bs] + [integrant.repl.state :as state])) + +(defn system-state + [] + (or @core/system state/system)) + +(defn system-fixture + [] + (fn [f] + (when (nil? (system-state)) + (core/start-app {:opts {:profile :test}})) + (f) + (core/stop-app))) + +(defn get-response [ctx] + (-> ctx + :response + (update :body (fnil bs/to-string "")))) + +(defn GET [app path params headers] + (-> (p/session app) + (p/request path + :request-method :get + :content-type "application/edn" + :headers headers + :params params) + (get-response))) diff --git a/test/clj/pmagnus/elprice/web/request_test.clj b/test/clj/pmagnus/elprice/web/request_test.clj new file mode 100644 index 0000000..1b5d7ea --- /dev/null +++ b/test/clj/pmagnus/elprice/web/request_test.clj @@ -0,0 +1,13 @@ +(ns pmagnus.elprice.web.request-test + (:require [clojure.test :refer [deftest testing is use-fixtures]] + [pmagnus.elprice.test-utils :refer [system-state system-fixture GET]])) + +(use-fixtures :once (system-fixture)) + +(deftest health-request-test [] + (testing "happy path" + (let [handler (:handler/ring (system-state)) + params {} + headers {} + response (GET handler "/api/health" params headers)] + (is (= 200 (:status response))))))