diff --git a/cms/systems/bread/alpha/cms/config/bread.cljc b/cms/systems/bread/alpha/cms/config/bread.cljc index 5b36118f6..2f2b54f56 100644 --- a/cms/systems/bread/alpha/cms/config/bread.cljc +++ b/cms/systems/bread/alpha/cms/config/bread.cljc @@ -1,7 +1,8 @@ (ns systems.bread.alpha.cms.config.bread (:require [aero.core :as aero] - [integrant.core :as ig])) + [integrant.core :as ig] + [systems.bread.alpha.internal.time :as t])) (defmethod aero/reader 'ig/ref [_ _ value] (ig/ref value)) @@ -11,3 +12,6 @@ (when-not (var? var*) (throw (ex-info (str sym " does not resolve to a var") {:symbol sym}))) var*)) + +(defmethod aero/reader 'seconds-ago [_ _ n] + (t/seconds-ago n)) diff --git a/cms/systems/bread/alpha/cms/data.cljc b/cms/systems/bread/alpha/cms/data.cljc index a79a64870..73895d87b 100644 --- a/cms/systems/bread/alpha/cms/data.cljc +++ b/cms/systems/bread/alpha/cms/data.cljc @@ -16,16 +16,42 @@ :field/content "Le Titre"} {:field/key :content :field/lang :en - :field/format :edn - :field/content (pr-str [{:a "some content" :b "more content"}])} + :field/format :html + :field/content + "
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam vel egestas sapien, vel gravida lacus. Morbi placerat lorem justo, luctus pretium sem mattis ac. Maecenas convallis lorem a enim iaculis luctus. Pellentesque malesuada elit et sodales efficitur. Integer quis blandit felis. Aenean fringilla magna quis lacinia egestas. Praesent neque mauris, aliquam quis elementum quis, sagittis sit amet diam. Nulla eu scelerisque tortor, nec condimentum erat.
+Cras tellus purus, ultricies nec augue ut, lacinia dignissim leo. Quisque eros erat, semper ut ornare et, mattis at ipsum. Pellentesque odio dui, hendrerit sollicitudin ultricies fringilla, feugiat at turpis. Curabitur quam felis, dapibus mollis tellus a, accumsan euismod mauris. Suspendisse dictum commodo arcu, eget pharetra nibh aliquet ut. Duis accumsan nec leo a dignissim. Nam nunc massa, vulputate non neque vel, convallis congue nisi. Maecenas ut cursus orci.
+Duis ipsum nunc, gravida ut est sit amet, facilisis luctus enim. Maecenas eget sapien aliquam odio luctus eleifend sed sit amet metus. Nulla malesuada efficitur odio. Aenean ligula ipsum, faucibus maximus consectetur eget, condimentum placerat nibh. Nunc laoreet luctus velit vel dignissim. Pellentesque hendrerit purus et leo tincidunt, egestas finibus turpis tempor. Curabitur ut ligula sit amet justo vulputate condimentum. Phasellus aliquet magna et est convallis blandit. Nullam eros magna, ornare at tempus et, pellentesque non justo.
+Integer faucibus mauris quis lobortis consequat. Sed ac congue arcu. Nunc convallis, massa non imperdiet consequat, mi nibh iaculis elit, mollis dignissim ipsum quam vitae felis. Aliquam laoreet tellus odio, nec molestie erat pretium sed. Quisque lacus tortor, hendrerit ut nulla at, rhoncus malesuada velit. Nunc rhoncus interdum mi, nec dictum nisi ullamcorper vel. Fusce sed porta felis. Suspendisse sagittis ornare nulla. Suspendisse potenti. Sed sed ligula malesuada, pulvinar mauris sed, vehicula sapien. Vestibulum nec ipsum quis orci condimentum volutpat. Quisque sollicitudin mi in enim mollis, ac pretium enim interdum.
+Aenean nunc nulla, finibus sed tortor eget, lobortis viverra tortor. In in orci maximus, mollis nulla finibus, blandit nunc. Nulla facilisi. Morbi quis nisi a massa consequat porta nec ut turpis. Proin varius porttitor euismod. Vivamus elit felis, suscipit vel ullamcorper luctus, eleifend vel lacus. Vivamus vestibulum elit quis volutpat commodo. Curabitur dapibus porttitor ultrices. Etiam facilisis lorem vitae diam pulvinar, at ullamcorper augue rutrum.
+Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, eu vulputate magna eros eu erat. Aliquam erat volutpat. Nam dui mi, tincidunt quis, accumsan porttitor, facilisis luctus, metus
+Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.
+Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.
" + ,} {:field/key :content :field/lang :fr - :field/format :edn - :field/content (pr-str [{:a "le content" :b "et plus"}])}}} + :field/format :html + :field/content + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam vel egestas sapien, vel gravida lacus. Morbi placerat lorem justo, luctus pretium sem mattis ac. Maecenas convallis lorem a enim iaculis luctus. Pellentesque malesuada elit et sodales efficitur. Integer quis blandit felis. Aenean fringilla magna quis lacinia egestas. Praesent neque mauris, aliquam quis elementum quis, sagittis sit amet diam. Nulla eu scelerisque tortor, nec condimentum erat.
+Cras tellus purus, ultricies nec augue ut, lacinia dignissim leo. Quisque eros erat, semper ut ornare et, mattis at ipsum. Pellentesque odio dui, hendrerit sollicitudin ultricies fringilla, feugiat at turpis. Curabitur quam felis, dapibus mollis tellus a, accumsan euismod mauris. Suspendisse dictum commodo arcu, eget pharetra nibh aliquet ut. Duis accumsan nec leo a dignissim. Nam nunc massa, vulputate non neque vel, convallis congue nisi. Maecenas ut cursus orci.
+Duis ipsum nunc, gravida ut est sit amet, facilisis luctus enim. Maecenas eget sapien aliquam odio luctus eleifend sed sit amet metus. Nulla malesuada efficitur odio. Aenean ligula ipsum, faucibus maximus consectetur eget, condimentum placerat nibh. Nunc laoreet luctus velit vel dignissim. Pellentesque hendrerit purus et leo tincidunt, egestas finibus turpis tempor. Curabitur ut ligula sit amet justo vulputate condimentum. Phasellus aliquet magna et est convallis blandit. Nullam eros magna, ornare at tempus et, pellentesque non justo.
+Integer faucibus mauris quis lobortis consequat. Sed ac congue arcu. Nunc convallis, massa non imperdiet consequat, mi nibh iaculis elit, mollis dignissim ipsum quam vitae felis. Aliquam laoreet tellus odio, nec molestie erat pretium sed. Quisque lacus tortor, hendrerit ut nulla at, rhoncus malesuada velit. Nunc rhoncus interdum mi, nec dictum nisi ullamcorper vel. Fusce sed porta felis. Suspendisse sagittis ornare nulla. Suspendisse potenti. Sed sed ligula malesuada, pulvinar mauris sed, vehicula sapien. Vestibulum nec ipsum quis orci condimentum volutpat. Quisque sollicitudin mi in enim mollis, ac pretium enim interdum.
+Aenean nunc nulla, finibus sed tortor eget, lobortis viverra tortor. In in orci maximus, mollis nulla finibus, blandit nunc. Nulla facilisi. Morbi quis nisi a massa consequat porta nec ut turpis. Proin varius porttitor euismod. Vivamus elit felis, suscipit vel ullamcorper luctus, eleifend vel lacus. Vivamus vestibulum elit quis volutpat commodo. Curabitur dapibus porttitor ultrices. Etiam facilisis lorem vitae diam pulvinar, at ullamcorper augue rutrum.
+Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, eu vulputate magna eros eu erat. Aliquam erat volutpat. Nam dui mi, tincidunt quis, accumsan porttitor, facilisis luctus, metus
+Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.
+Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.
" + ,}}} {:db/id "page.child" :post/type :page :thing/slug "child-page" + :thing/created-at #inst "2026-01-01T04:20:00-08:00" :post/status :post.status/published + :post/taxons ["tag.two"] :thing/children ["page.grandchild"] :thing/fields #{{:field/key :title @@ -45,7 +71,9 @@ {:db/id "page.daughter" :post/type :page :thing/slug "daughter-page" + :thing/created-at #inst "2025-10-10T05:53:00-08:00" :post/status :post.status/draft + :post/taxons ["tag.one"] :thing/fields #{{:field/key :title :field/lang :en @@ -56,6 +84,7 @@ {:db/id "page.parent" :post/type :page :thing/slug "hello" + :thing/created-at #inst "2025-03-16T14:21:22-08:00" :thing/children ["page.child" "page.daughter"] :post/taxons ["tag.one" "tag.two"] :post/status :post.status/published @@ -98,7 +127,9 @@ {:db/id "page.grandchild" :post/type :page :thing/slug "grandchild-page" + :thing/created-at #inst "2024-01-15T13:12:42-08:00" :post/status :post.status/published + :post/taxons ["tag.one" "tag.two"] :thing/fields #{{:field/key :title :field/lang :en @@ -108,7 +139,7 @@ :field/content "Petit Enfant Page"}}} {:db/id "tag.one" :thing/slug "one" - :taxon/taxonomy :taxon.taxonomy/tag + :taxon/taxonomy :tag :thing/fields [{:field/key :name :field/content "One" @@ -118,7 +149,7 @@ :field/lang :fr}]} {:db/id "tag.two" :thing/slug "two" - :taxon/taxonomy :taxon.taxonomy/tag + :taxon/taxonomy :tag :thing/fields [{:field/key :name :field/content "Two" @@ -198,7 +229,7 @@ :field/content "Doggo"} {:field/key :alt-text :field/lang :fr - :field/content "un chat"}}} + :field/content "un chien"}}} ;; Site-wide translations {:field/lang :en diff --git a/cms/systems/bread/alpha/cms/main.cljc b/cms/systems/bread/alpha/cms/main.cljc index d8aab0318..8add46337 100644 --- a/cms/systems/bread/alpha/cms/main.cljc +++ b/cms/systems/bread/alpha/cms/main.cljc @@ -16,7 +16,10 @@ [taoensso.timbre :as log] [systems.bread.alpha.core :as bread] + [systems.bread.alpha.component :as component] [systems.bread.alpha.cms.theme :as theme] + [systems.bread.alpha.cms.theme.crust :as crust] + [systems.bread.alpha.cms.theme.rise :as rise] [systems.bread.alpha.cms.data :as data] [systems.bread.alpha.i18n :as i18n] [systems.bread.alpha.post :as post] @@ -25,6 +28,7 @@ [systems.bread.alpha.defaults :as defaults] [systems.bread.alpha.ring :as bread.ring] [systems.bread.alpha.schema :as schema] + [systems.bread.alpha.taxon :as taxon] [systems.bread.alpha.user :as user] [systems.bread.alpha.util.logging :refer [log-redactor]] [systems.bread.alpha.cms.config.bread] @@ -33,6 +37,9 @@ [systems.bread.alpha.plugin.datahike] [systems.bread.alpha.plugin.email :as email] [systems.bread.alpha.plugin.marx :as marx] + [systems.bread.alpha.navigation :as navigation] + #_ ;; TODO + [systems.bread.alpha.plugin.navigation :as navigation] [systems.bread.alpha.plugin.reitit] [systems.bread.alpha.plugin.rum :as rum] [systems.bread.alpha.plugin.signup :as signup] @@ -45,6 +52,8 @@ [org.sqlite JDBC]) (:gen-class)) +(set! *print-namespace-maps* false) + (defn not-found [req] {:body "not found" :status 404}) @@ -57,6 +66,16 @@ {:root "marx" :path "/marx"})) +(def crust-handler + (reitit.ring/create-resource-handler + {:root "crust" + :path "/crust"})) + +(def rise-handler + (reitit.ring/create-resource-handler + {:root "rise" + :path "/rise"})) + (def router (reitit/router ["/" @@ -65,19 +84,15 @@ ["/login" {:name :login :dispatcher/type ::auth/login=> - :dispatcher/component #'auth/LoginPage}] - ["/signup" - {:name :signup - :dispatcher/type ::signup/signup=> - :dispatcher/component #'signup/SignupPage}] + :dispatcher/component #'rise/LoginPage}] ["/account" {:name :account :dispatcher/type ::account/account=> - :dispatcher/component #'account/AccountPage}] + :dispatcher/component #'rise/AccountPage}] ["/email" {:name :email :dispatcher/type ::email/settings=> - :dispatcher/component #'email/EmailPage}] + :dispatcher/component #'rise/EmailPage}] ["/edit" {:name :edit :dispatcher/type ::marx/edit=>}] @@ -89,34 +104,42 @@ ["_" ["/confirm-email" {:name :confirm-email - :dispatcher/type ::email/confirm=> - :dispatcher/component #'email/ConfirmPage}]] + :dispatcher/type ::email/confirm=> + :dispatcher/component #'rise/ConfirmPage}] + ["/patterns" + ["/rise" + {:name :patterns.rise + :dispatcher/type ::component/standalone=> + :dispatcher/component #'rise/PatternLibrary}]] + ["/signup" + {:name :signup + :dispatcher/type ::signup/signup=> + :dispatcher/component #'rise/SignupPage}]] ["assets/*" (reitit.ring/create-resource-handler {})] ;; TODO publish to assets? ["marx/*" marx-handler] + ["crust/*" crust-handler] + ["rise/*" rise-handler] ["{field/lang}" ["" {:name :home :dispatcher/type ::post/page=> - :dispatcher/component #'theme/HomePage}] + :dispatcher/component #'crust/HomePage}] ["/i/{db/id}" {:name :id :dispatcher/type ::thing/by-id=> - :dispatcher/component #'theme/InteriorPage}] + :dispatcher/component #'crust/InteriorPage}] ["/tag/{thing/slug}" {:name :tag - :dispatcher/type ::post/tag ;; TODO - :dispatcher/component #'theme/Tag}] - ["/{thing/slug*}" + :dispatcher/type ::taxon/tag=> + :dispatcher/component #'crust/Tag + :post/type :page}] + ["/*slugs" {:name :page :dispatcher/type ::post/page=> - :dispatcher/component #'theme/InteriorPage}] - ["/page/{thing/slug*}" ;; TODO - {:name :page! - :dispatcher/type ::post/page=> - :dispatcher/component #'theme/InteriorPage}]]] + :dispatcher/component #'crust/InteriorPage}]]] {:conflicts nil})) (def cli-options @@ -348,8 +371,10 @@ (signup/plugin (:signup app-config)) (account/plugin (:account app-config)) (marx/plugin (:marx app-config)) + (navigation/plugin (:navigation app-config)) (rum/plugin (:renderer app-config)) - (email/plugin (:email app-config))])] + (email/plugin (:email app-config)) + (theme/plugin (:theme app-config))])] (bread/load-app (bread/app {:plugins plugins})))) (defmethod ig/halt-key! :bread/app [_ app] @@ -364,8 +389,6 @@ true) (comment - (set! *print-namespace-maps* false) - (require '[flow-storm.api :as flow]) (flow/local-connect) @@ -613,24 +636,27 @@ :thing/_children [{:thing/slug "b" :thing/_children [{:thing/slug "a"}]}]}) - (def $router (route/router (->app $req))) - + (reitit/match-by-path router "/en/a") + (reitit/match-by-path router "/en/a/b/c") + (-> router + (reitit/match-by-path "/en/tag/two") + :data :name) (reitit/match->path - (reitit/match-by-path $router "/en/a/b/c") - {:field/lang :en :thing/slug* "a/b/c"}) + (reitit/match-by-path router "/en/a/b/c") + {:field/lang :en :slugs "a/b/c"}) (reitit/match->path - (reitit/match-by-name $router :page {:field/lang :en :thing/slug* "x"})) + (reitit/match-by-name router :page {:field/lang :en :slugs "x"})) - (bread/routes $router) - (bread/route-params $router $req) + (bread/routes router) + (bread/route-params router $req) ;; route/uri infers params and then just calls bread/path under the hood... - (bread/path $router :page {:field/lang :en :thing/slug* "a/b/c"}) + (bread/path router :page {:field/lang :en :slugs "a/b/c"}) (route/uri (->app $req) :page (merge {:field/lang :en} grandchild)) (route/uri (->app $req) :page! (merge {:field/lang :en} grandchild)) (route/ancestry grandchild) - (bread/infer-param :thing/slug* grandchild) + (bread/infer-param :slugs grandchild) (bread/routes (route/router (->app $req))) (route/uri (->app $req) :page (merge {:field/lang :en} grandchild)) @@ -693,6 +719,7 @@ (find-path adjacents seen :thing/slug)) (def full-path (vec (concat [:field/lang] path))) + ;; -> [:field/lang :thing/fields :thing/slug] ;; We've now found the path between :field/lang and :thing/slug, the only two ;; attrs in our route definition. So, we can stop looking in this case. But, @@ -700,6 +727,35 @@ ;; simply add the adjacent attrs we just found to seen, and explore each of ;; those (via references) recursively... + [:field/lang :thing/fields :thing/slug] + + (defn- ->sym [k] (symbol (str "?" (name k)))) + (map vector (butlast full-path) (rest full-path)) + + ;; Now that we have the path, we can construct a query from it: + ;; TODO + (def query + '{:find [(pull ?e [{:thing/fields [:field/lang]} + :thing/slug + {:thing/_children ...}])] + :in [$] + :where [[?field :field/lang ?lang] + [?e :thing/fields ?field] + [?e :thing/slug ?slug]]}) + + ;; TODO how do we go from :slugs => :thing/slug + {:thing/children ...} ? + + ;; Now we infer values from route-spec, and we have out list of uris... + ;; TODO how do we get to lang? + (bread/infer-param :field/lang [{:thing/slug "two", + :thing/fields + [{:field/lang :en} + {:field/lang :fr}]}]) + (map (fn [[thing]] + (map #(bread/infer-param % thing) route-spec) + ) + (q query)) + ;; /experiment (require '[kaocha.repl :as k]) diff --git a/cms/systems/bread/alpha/cms/theme.cljc b/cms/systems/bread/alpha/cms/theme.cljc index c861fa369..e93d17857 100644 --- a/cms/systems/bread/alpha/cms/theme.cljc +++ b/cms/systems/bread/alpha/cms/theme.cljc @@ -1,88 +1,155 @@ ;; TODO figure out how themes work :P (ns systems.bread.alpha.cms.theme (:require + [clojure.walk :as walk] + [markdown-to-hiccup.core :as md2h] + [rum.core :as rum] + [systems.bread.alpha.user :as user] [systems.bread.alpha.core :as bread] - [systems.bread.alpha.component :refer [defc]] + [systems.bread.alpha.component :refer [defc] :as component] [systems.bread.alpha.plugin.marx :as marx])) -(defn- NavItem [{:keys [children uri] - {:keys [title] :as fields} :thing/fields - :as item}] - [:li - [:div - [:a {:href uri} title]] - (map NavItem children)]) +(defn title [& strs] + (clojure.string/join " | " (filter seq strs))) + +(defn- pp [x] + (with-out-str (clojure.pprint/pprint x))) + +(defn pattern-type [pattern] + (or (:type pattern) + (:type (meta pattern)))) + +(defmulti ContentsItem pattern-type) +(defmulti Pattern pattern-type) + +(defmethod ContentsItem :default [pattern] pattern) -(defn- Nav [{items :menu/items}] +(defmethod ContentsItem ::component/component [component] + (let [component-meta (meta component)] + {:id (:name component-meta) + :title (:name component-meta) + :children (map (fn [example] + (assoc example :type :example)) + (:examples component-meta))})) + +(defn- ->id [s] + (apply str (map (fn [c] (if (Character/isWhitespace c) \_ c)) s))) + +(comment + (->id "How to do stuff")) + +(defc TableOfContents [{:as data :keys [patterns]}] [:nav + [:h1#contents "Table of contents"] [:ul - (map NavItem items)]]) - -(defc MainLayout [{:keys [lang content user] - {:keys [main-nav]} :menus - :as data}] - {} - [:html {:lang lang} - [:head - [:meta {:content-type "utf-8"}] - [:title "hey"] - [:link {:rel :stylesheet :href "/assets/site.css"}]] - [:body - (Nav main-nav) - content - (marx/Embed data)]]) - -(defc NotFoundPage - [{:keys [lang]}] - {:extends MainLayout} - [:main - "404"]) - -(defc HomePage - [{:keys [lang user post]}] - {:extends MainLayout - :key :post - :query '[:thing/children - :thing/slug - :post/authors - {:thing/fields [*]}]} - {:content - [:main {:role :main} - [:h1 (:title (:thing/fields post))] - [:pre (pr-str post)] - [:pre (pr-str (user/can? user :edit-posts))]]}) - -(defc Tag - [{{fields :thing/fields :as tag} :tag}] - {:extends MainLayout - :key :tag - :query '[:thing/slug - {:thing/fields [*]} - {:post/_taxons - [{:post/authors [*]} - {:thing/fields [*]}]}]} - [:main - [:h1 (:name fields)] - [:h2 [:code (:thing/slug tag)]]]) - -(defc InteriorPage - [{{{:as fields field-defs :bread/fields} :thing/fields tags :post/taxons :as post} :post - {:keys [main-nav]} :menus - {:keys [user]} :session - :keys [hook]}] - {:extends MainLayout - :key :post - :query '[{:thing/fields [*]} - {:post/taxons [{:thing/fields [*]}]}]} - (let [Field (partial marx/Field post)] - [:<> - [:main - (Field :text :title :tag :h1) - [:h2 (:db/id post)] - (Field :rich-text :rte) - [:div.tags-list - [:p "TAGS"] - (map (fn [{tag :thing/fields}] - [:span.tag (:name tag)]) - tags)]]])) + [:<> (doall (map (fn [pattern] + (let [{:keys [id title children]} (ContentsItem pattern)] + [:li + [:a {:href (str "#" (name id))} title] + [:ul + (map (fn [{:as child :keys [doc]}] + (when doc + [:li [:a {:href (str "#" (->id doc))} doc]])) + children)]])) + patterns))]]]) + +(defn- remove-noop-elements [html] + (walk/postwalk (fn [x] + (if (and (vector? x) (not (map-entry? x))) + (filterv (complement (partial contains? #{nil [:<>]})) x) + x)) html)) + +(defn- md->hiccup [s] + (when s (-> s md2h/md->hiccup md2h/component))) + +(defn- html-comment [s] + (str "\n")) + +(defn- render-html [content] + (if (map? content) + (mapcat (juxt (comp html-comment name key) + (comp #(str % "\n") + rum/render-static-markup + remove-noop-elements + val)) + content) + (rum/render-static-markup content))) + +(defmethod Pattern :default DocSection [{:keys [content id title]}] + [:section.pattern {:id id} + [:h1 title] + [:a.section-link {:href (str "#" (name id)) :title title} "#"] + (if (string? content) + (md->hiccup content) + content) + [:a {:href "#contents"} "Back to top"]]) + +(defmethod Pattern ::component/component ComponentSection [component] + (let [{component-name :name + :keys [doc doc/show-html? doc/default-data expr examples doc/post-render] + :or {show-html? true + post-render identity}} + (meta component)] + (let [component-name (name component-name)] + [:article.pattern {:id component-name :data-component component-name} + [:h1 component-name] + [:a.section-link {:href (str "#" component-name) + :title (str "Link to " component-name)} + "#"] + (md->hiccup doc) + (map (fn [{:as example :keys [doc description args]}] + (let [post-render (or (:doc/post-render example) post-render) + args' (cons (merge-with merge default-data (first args)) (rest args)) + id (->id doc) + content (apply component args') + formatted-content (-> content remove-noop-elements post-render pp) + formatted-html (-> content post-render render-html)] + [:section.example {:id id} + (when doc + [:<> + [:h2 doc] + [:a.section-link {:href (str "#" id) :title (str "Link to " doc)} "#"]]) + (md->hiccup description) + [:pre [:code.clj (pp (apply list (symbol component-name) args))]] + [:pre [:code.clj formatted-content]] + [:pre [:code.xml formatted-html]]])) + examples) + [:details + [:summary "Show source"] + [:pre [:code.clj (pp (apply list 'defc (symbol component-name) expr))]]] + [:a {:href "#contents"} "Back to top"]]))) + +(defn pattern->section [pattern] + (if (= ::component/component (:type (meta pattern))) + {:component pattern} + pattern)) + +(comment + (macroexpand + '(defc P [{:keys [text] :or {text "Default text."}}] + {:doc "Example description" + :examples + '[{:doc "With text" + :args ({:text "Sample text."})} + {:doc "With default text" + :args ({})}]} + [:p text])) + (ComponentSection {:component P})) + +(defmethod bread/action ::html.head.pattern-library [req _ [head]] + (let [head (or head [:<>])] + (conj head + [:link {:rel :stylesheet :href "/assets/highlight/styles/atom-one-dark.min.css"}] + [:script {:src "/assets/highlight/highlight.min.js"}] + [:script "hljs.highlightAll()"]))) + +(defn plugin + ([] (plugin {})) + ([_] + {:hooks + {::html.head.pattern-library + [{:action/name ::html.head.pattern-library + :action/description + "Call this hook inside the of your theme's PatternLibrary component + to automatically include standard assets, e.g. for syntax highlighting."}]}})) diff --git a/cms/systems/bread/alpha/cms/theme/crust.cljc b/cms/systems/bread/alpha/cms/theme/crust.cljc new file mode 100644 index 000000000..e935b2b94 --- /dev/null +++ b/cms/systems/bread/alpha/cms/theme/crust.cljc @@ -0,0 +1,116 @@ +(ns systems.bread.alpha.cms.theme.crust + (:require + [systems.bread.alpha.cms.theme :as theme] + [systems.bread.alpha.component :refer [defc Section]] + [systems.bread.alpha.i18n :as i18n] + [systems.bread.alpha.user :as user] + [systems.bread.alpha.plugin.account :as account] + [systems.bread.alpha.plugin.auth :as auth] + [systems.bread.alpha.plugin.marx :as marx]) + (:import + [java.text SimpleDateFormat])) + +(def date-fmt-published-at (SimpleDateFormat. "d LLL YYY")) + +(defc NavItem [{:keys [children uri] + {:keys [title] :as fields} :thing/fields + :as item}] + [:li + [:div + [:a {:href uri} title]] + (map NavItem children)]) + +(defc MainNav [{items :menu/items}] + [:nav.main-nav + [:ul + (map NavItem items)]]) + +(defc MainLayout [{:keys [lang config content user] + {:keys [main-nav]} :menus + :as data}] + (let [{:keys [content head title]} + (if (map? content) content {:content content})] + [:html {:lang lang} + [:head + [:meta {:content-type "utf-8"}] + [:title (theme/title title (:site/name config))] + [:link {:rel :stylesheet :href "/crust/css/base.css"}] + head] + [:body + [:.container + (MainNav main-nav) + [:main {:role :main} + content + (marx/Embed data)]] + [:footer.main-footer + [:.footer-content (:site/name config)]]]])) + +(defc NotFoundPage + [{:keys [lang]}] + {:extends MainLayout} + [:article + ;; TODO i18n + [:h1 "404"] + [:p "The page you are looking for was not found."]]) + +(defc HomePage + [{:keys [lang user] + {{:as fields} :thing/fields + :as post} :post}] + {:extends MainLayout + :key :post + :query '[:thing/children + :thing/slug + :thing/authors + {:thing/fields [*]}]} + {:content + [:article + [:h1 (:title fields)] + (:content fields)]}) + +(defc Tag + [{{fields :thing/fields :as tag pages :post/_taxons} :tag + :keys [route/uri]}] + {:extends MainLayout + :key :tag + :query '[:thing/slug + {:thing/fields [*]} + {:post/_taxons + [:thing/slug + :thing/created-at + {:thing/authors [*]} + {:thing/fields [*]} + {:thing/_children + [:thing/slug + {:thing/_children ...}]} + {:thing/children ...}]}]} + [:article + [:h1 (:name fields)] + [:.posts-list {:role :list} + (map (fn [{:as page + :keys [thing/created-at] + {:keys [title]} :thing/fields}] + [:div + [:h2 + [:a {:href (uri :page page)} title]] + [:h3 (.format date-fmt-published-at created-at)]]) + pages)]]) + +(defc InteriorPage + [{{{:as fields field-defs :bread/fields} :thing/fields tags :post/taxons :as post} :post + {:keys [main-nav]} :menus + {:keys [user]} :session + :keys [hook route/uri]}] + {:extends MainLayout + :key :post + :query '[{:thing/fields [*]} + {:post/taxons [:thing/slug {:thing/fields [*]}]}]} + (let [Field (partial marx/Field post)] + [:article + (Field :text :title :tag :h1) + [:.post-content (Field :rich-text :rte)] + [:footer + [:.tags-list {:role :list} + (map (fn [{:as tag {tag-name :name} :thing/fields}] + [:a.tag-link {:href (uri :tag tag)} (str "#" tag-name)]) + tags)]]])) diff --git a/cms/systems/bread/alpha/cms/theme/rise.cljc b/cms/systems/bread/alpha/cms/theme/rise.cljc new file mode 100644 index 000000000..f098d6c4e --- /dev/null +++ b/cms/systems/bread/alpha/cms/theme/rise.cljc @@ -0,0 +1,555 @@ +(ns systems.bread.alpha.cms.theme.rise + (:require + [systems.bread.alpha.cms.theme :as theme] + [systems.bread.alpha.component :refer [defc Section]] + [systems.bread.alpha.i18n :as i18n] + [systems.bread.alpha.plugin.account :as account] + [systems.bread.alpha.plugin.email :as email] + [systems.bread.alpha.plugin.auth :as auth])) + +(defn- IntroSection [_] + {:id :intro + :title "Introduction" + :content + [:<> + [:p + "This is the pattern library for the RISE Bread theme." + " This document serves two purposes:"] + [:ol + [:li "to illustrate the look and feel of the RISE theme"] + [:li "to illustrate usage of the RISE components"]] + ,]}) + +(defn- HowToSection [_] + {:id :how-to + :title "How to use this document" + :content + [:<> + [:p "Don't."]]}) + +(defn- TypographySection [_] + {:id :typography + :title "Typography" + :content + [:<> + [:p "RISE is designed to be used for functional UIs in web apps, as opposed + to long-form or image-heavy content. The font-family is + therefore a uniform sans-serif across the board, to maximize scannability. + RISE uses a system font cascade:"] + [:pre + "--font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, Cantarell, Ubuntu, roboto, noto, helvetica, arial, sans-serif; + "] + [:p "This ensures that fonts load instantly, and helps keep things simple."] + [:h1 "Heading one"] + [:h2 "Heading two"] + [:h3 "Heading three"] + [:h4 "Heading four"] + [:h5 "Heading five"] + [:h6 "Heading six"] + [:p "Paragraph text with a " + [:a {:href "#"} "link"] + ". Here's what a " + [:a {:href "#" :data-visited true} "visited link"] + " will look like and here's what a " + [:a {:href "#" :data-hover true} "hovered link"] + " will look like. Here is some " + [:strong "bold text"] " and some " [:i "italicized text."]] + ,]}) + +(defc Page [{:keys [dir config content hook field/lang]}] + {:doc "`Page` is the foundation of the RISE theme. This is the component + you should use to serve most user-facing web pages in your application. + " + :doc/default-data + {:content [:div "Page content"] + :config {:site/name "Site name"} + :hook (fn hook [_ x & _] x)} + :examples + '[{:doc "Language and text direction" + ;; TODO support markdown in docs + :description + "Specify document language and text direction with `:field/lang` and `:dir`, + resp. Typically the `i18n` core plugin takes care of this for you, including + detecting text direction based on language." + :args ({:dir :rtl + :field/lang :ar + :content [:p "محتوى الصفحة"]})} + {:doc "Document title" + :description + "By default, `(:site/name config)` is used for the document title. This + is set up automatically if you pass `{:site {:name your-site-name}}` to + the `defaults` plugin. + " + :args ({:config {:site/name "Title in config"}})} + {:doc "Overriding document title" + :description + "Set `(:title content)` to have it prepended to the globally configured + site name, separated with `\" | \"`. If you need further customization, + use the `::theme/html.title` hook. + " + :args ({:config {:site/name "Title in config"} + :content {:title "Title override!" :content [:div "Page content"]}})} + ,]} + (let [{:keys [content head title]} + (if (vector? content) {:content content} content)] + [:html {:lang lang :dir dir} + [:head + [:meta {:content-type :utf-8}] + (hook ::theme/html.title + [:title (theme/title title (:site/name config))] + title) + [:link {:rel :stylesheet :href "/rise/css/base.css"}] + head + ;; Support arbitrary markup in + (->> [:<>] (hook ::theme/html.head))] + [:body + content]])) + +(defc SuccessMessage [message] + {:doc "A success message" + :description + "Used to indicate success of some action, typically a side-effect, such + as an account update." + :examples + '[{:args ({:message "Update successful!"})}]} + [:.success [:p message]]) + +(defc ErrorMessage [message] + {:doc "An error message" + :description + "Used to indicate an error completing some action, typically a side-effect, such + as an account update." + :examples + '[{:args ({:message "Something bad happened!"})}]} + [:.error [:p message]]) + +(defc Field [field-name & {field-type :type + :keys [id label value input-attrs label-attrs]}] + (let [id (or id field-name)] + [:.field + [:label (merge label-attrs {:for id}) label] + [:input (merge input-attrs {:name field-name + :id id + :type (or field-type :text) + :value value})]])) + +(defc Submit [label & {field-name :name :keys [value]}] + [:.field + [:span.spacer] + [:button {:type :submit :name field-name :value value} label]]) + +(defc LoginPage + [{:as data + :keys [config hook i18n session dir totp ring/anti-forgery-token-field] + :auth/keys [result]}] + {:extends Page + :doc + "The standard Bread login page, designed to work with the `::auth/login=>` + dispatcher. You typically won't need to call this component from your code, + except to reference it from your route if implementing custom routing."} + (let [{:keys [totp-key issuer]} totp + user (or (:user session) (:auth/user session)) + step (:auth/step session) + error? (false? (:valid result))] + {:title "Login" + :head [:<> [:style + " + .totp-key { + font-family: monospace; + letter-spacing: 5; + } + "]] + :content + (cond + (:user/locked-at user) + [:main + [:form.flex.col + (anti-forgery-token-field) + (hook ::html.locked-heading [:h2 (:auth/account-locked i18n)]) + (hook ::html.locked-explanation [:p (:auth/too-many-attempts i18n)])]] + + (= :setup-two-factor step) + (let [data-uri (auth/qr-datauri {:label issuer + :user (:user/username user) + :secret totp-key + :image-type :PNG})] + [:main + [:form.flex.col {:name :setup-mfa :method :post} + (anti-forgery-token-field) + (hook ::html.login-heading [:h1 (:auth/login-to-bread i18n)]) + (hook ::html.scan-qr-instructions + [:p.instruct (:auth/please-scan-qr-code i18n)]) + [:div.center [:img {:src data-uri :width 125 :alt (:auth/qr-code i18n)}]] + [:p.instruct (:auth/or-enter-key-manually i18n)] + [:div.center [:h2.totp-key totp-key]] + [:input {:type :hidden :name :totp-key :value totp-key}] + [:hr] + [:p.instruct (:auth/enter-totp-next i18n)] + [:.field + [:input {:id :two-factor-code :type :number :name :two-factor-code}] + [:button {:type :submit :name :submit :value "verify"} + (:auth/verify i18n)]] + (when error? + (hook ::html.invalid-code (ErrorMessage (:auth/invalid-totp i18n))))]]) + + (= :two-factor step) + [:main + [:form.flex.col {:name :bread-login :method :post} + (anti-forgery-token-field) + (hook ::html.login-heading [:h1 (:auth/login-to-bread i18n)]) + (hook ::html.enter-2fa-code + [:p.instruct (:auth/enter-totp i18n)]) + [:.field.two-factor + [:input {:id :two-factor-code :type :number :name :two-factor-code}] + [:button {:type :submit :name :submit :value "verify"} + (:auth/verify i18n)]] + (when error? + (hook ::html.invalid-code (ErrorMessage (:auth/invalid-totp i18n))))]] + + :default + [:main + [:form.flex.col {:name :bread-login :method :post} + (anti-forgery-token-field) + (hook ::html.login-heading [:h1 (:auth/login-to-bread i18n)]) + (hook ::html.enter-username + [:p.instruct (:auth/enter-username-password i18n)]) + (Field :username :label (:auth/username i18n)) + (Field :password :type :password :label (:auth/password i18n)) + (when error? + (hook ::html.invalid-login + (ErrorMessage (:auth/invalid-username-password i18n)))) + (Submit (:auth/login i18n))]])})) + +(defc AccountNav [{:as data :keys [config]}] + {:doc + "Top-level navigation for all user settings pages. Calls `component/Section` + on each member of `(:account/html.account.header config)`. See also: + `SettingsPage`. + using the `account` plugin and extend the `component/Section` method + to customize." + :examples + '[{:doc "Customizing the account nav" + :description + "You typically won't need to to call this component directly from your theme + code. To customize the account nav, configure the `:html-account-header` + option to the `account` plugin and implement the `component/Section` method + for each custom value. See also: + [Adding a custom user settings page](#Adding_a_custom_user_settings_page)." + :args [{:config {:account/html.account.header [[:span "First section"] + [:span "Second section"] + "..."]}}]}]} + (apply conj [:nav.row] + (map (partial Section data) (:account/html.account.header config)))) + +(defc SettingsPage [{:as data :keys [content]}] + {:extends Page + :doc + "Reusable account settings page that includes `AccountNav` automatically. + To add custom user settings pages, extend this component. For adding links + to any custom pages within the account nav itself, see + [Customizing the account nav](#Customizing_the_account_nav)." + :examples + '[{:doc "Adding a custom user settings page" + :description + "`SettingsPage` extends `Page`, so all the same options for `content`, + `title`, etc. apply." + :args ({:content [:div "My custom settings page content"] + :config {:account/html.account.header + [[:a {:href "/my-custom-route"}]]}})} + ,]} + (let [{:as content settings-content :content} + (if (vector? content) {:content content} content)] + (assoc content :content + [:<> + (AccountNav data) + settings-content]))) + +(defc AccountPage + [{:as data :keys [config user]}] + {:extends SettingsPage + :doc + "The main account settings page, the default redirect target after logging in. + Contains settings for name, pronouns, timezone, etc. You typically won't have + to call this component except to reference it from your route if implementing + custom routing." + :query '[:db/id + :thing/created-at + :user/username + :user/name + :user/lang + :user/preferences + {:user/roles [:role/key {:role/abilities [:ability/key]}]} + {:invitation/_redeemer [{:invitation/invited-by [:db/id :user/username]}]} + {:user/sessions [:db/id :session/data :thing/created-at :thing/updated-at]}] + :doc/default-data {:user {:user/username "username"} + :config {:account/html.account.sections + [::account/account-form + [:section "Login sessions section..."]]}} + :examples + '[{:doc "Customizing account page sections" + :description + "`AccountPage` renders each item in `(:account/html.account.sections config)` + as a section on this page. To customize this, pass `:html-account-section` + to the `account` plugin and implement the `component/Section` method for + each custom value." + :args ({:config {:account/html.account.sections + [[:section "First section"] + [:section "Second section"]]}})} + {:doc "Customizing the account settings form" + :description + "To customize the fields that appear in the main settings form, override + the `:html-account-form` option to the `account` plugin and implement the + `component/Section` method for each custom value. In this example, we + include only the default `::account/name` and `::account/pronouns` options + and a custom option called `:my-custom-field`. NOTE: The `::account/account=>` + dispatcher treats any keys that are not part of Bread's default user + schema as user preferences, automatically handling serialization and + deserialization." + :args ({:config {:account/html.account.form + [::account/name + ::account/pronouns + [:field + [:label "My custom field"] + [:input {:name :my-custom-field + :value "value in user preferences"}]]]}})}] + :doc/post-render (fn [content] + (assoc content :head [:style "...page-specific styles..."]))} + {:title (:user/username user) + :head [:<> [:style + " + .user-session { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-items: start; + + padding: 1em; + border: 2px dashed var(--color-stroke-tertiary); + } + "]] + :content + (apply conj [:main] + (map (partial Section data) (:account/html.account.sections config)))}) + +(defmethod Section ::email/settings-link + [{:keys [i18n] {:email/keys [settings-uri]} :config} _] + [:a {:href settings-uri :title (:email/email-settings i18n)} + (:email/email i18n)]) + +(defmethod Section ::email/heading [{:keys [i18n]} _] + [:h3 (:email/email-heading i18n)]) + +(defn- compare-emails [a b] + (cond + ;; Always list primary first... + (:email/primary? a) -1 + (:email/primary? b) 1 + ;; ...then confirmed... + (and (:email/confirmed-at a) (:email/confirmed-at b)) + (compare (:email/confirmed-at a) (:email/confirmed-at b)) + ;; ...and finally unconfirmed. + :else (compare (:email/created-at a) (:email/created-at b)))) + +(defmethod Section ::email/emails + [{:keys [config i18n user ring/anti-forgery-token-field]} _] + (let [{:email/keys [allow-delete-primary?]} config + emails (sort compare-emails (:user/emails user))] + [:<> + (if (seq emails) + [:.flex.col {:role :list} + (map (fn [{:keys [email/address + email/confirmed-at + email/primary? + thing/created-at + db/id]}] + [:form.flex.row {:method :post :role :listitem} + (anti-forgery-token-field) + [:input {:type :hidden :name :email :value address}] + [:input {:type :hidden :name :id :value id}] + (cond + primary? + [:<> + [:.flex.col.tight + [:label address] + [:small (:email/primary i18n)] + [:small + (:email/confirmed i18n) + ;; TODO date locale/formatting + " " confirmed-at]] + [:span.spacer] + (when allow-delete-primary? + [:button {:type :submit :name :action :value :delete} + (:email/delete i18n)])] + + confirmed-at + [:<> + [:.flex.col.tight + [:label address] + [:small (:email/confirmed i18n) + ;; TODO date locale/formatting + " " confirmed-at]] + [:span.spacer] + [:button {:type :submit :name :action :value :make-primary} + (:email/make-primary i18n)] + [:button {:type :submit :name :action :value :delete} + (:email/delete i18n)]] + + :pending + [:<> + [:.flex.col.tight + [:label address] + [:small (:email/confirmation-pending i18n)]] + [:span.spacer] + [:button {:type :submit :name :action :value :resend-confirmation} + (:email/resend-confirmation i18n)] + [:button {:type :submit :name :action :value :delete} + (:email/delete i18n)]])]) + emails)] + [:p.instruct (:email/no-emails i18n)])])) + +(defmethod Section ::email/add-email + [{:keys [config i18n user ring/anti-forgery-token-field]} _] + (let [emails (:user/emails user) + any-pending? (seq (filter (complement :email/confirmed-at) emails)) + allow-multiple-pending? (:email/allow-multiple-pending? config)] + (if (or (not any-pending?) allow-multiple-pending?) + [:<> + [:h3 {:for :add-email} + (:email/add-email i18n)] + [:form.flex.row {:method :post} + (anti-forgery-token-field) + [:input {:id :add-email :type :email :name :email :placeholder "me@example.email"}] + [:button {:type :submit :name :action :value :add} + (:email/add i18n)]]] + [:p.instruct (:email/to-add-email-confirm-pending i18n)]))) + +(defc EmailPage + [{:as data :keys [config i18n]}] + {:extends SettingsPage + :query '[:db/id :user/username {:user/emails [* :thing/created-at]}]} + {:title (:email/email i18n) + :content + [:main + (map (partial Section data) (:email/html.email.sections config))]}) + +(defc LogoutForm [{:keys [config i18n ring/anti-forgery-token-field]}] + {:doc "Standard logout form for the account page."} + [:form.logout-form {:method :post :action (:auth/login-uri config)} + (anti-forgery-token-field) + [:button {:type :submit :name :submit :value "logout"} + (:auth/logout i18n)]]) + +(defmethod Section ::account/logout-form [data _] + (LogoutForm data)) + +(defc ConfirmPage + [{:keys [pending-email i18n ring/uri]}] + {:extends Page + :query '[:db/id :email/address] + :key :pending-email} + (let [{:email/keys [address code]} pending-email] + {:title (:email/confirm-email i18n) + :content + [:main.gap-large + [:h2 (:email/please-confirm i18n)] + [:p address] + [:form {:method :post :action uri} + [:input {:type :hidden :name :email :value address}] + [:input {:type :hidden :name :code :value code}] + [:button {:type :submit} + (:email/confirm-email i18n)]]]})) + +(defc SignupPage + [{:as data + :keys [config error hook i18n invitation rtl? dir ring/params ring/anti-forgery-token-field] + [valid? error-key] :validation}] + [:html {:lang (:field/lang data) :dir dir} + [:head + [:meta {:content-type "utf-8"}] + (hook ::html.title [:title (str (:signup/signup i18n) " | Bread")]) + (->> (auth/LoginStyle data) (hook ::auth/html.stylesheet) (hook ::html.signup.stylesheet)) + (->> [:<>] (hook ::auth/html.head) (hook ::html.signup.head))] + [:body + (cond + (and (:signup/invite-only? config) (not (:code params))) + [:main + [:form.flex.col + (anti-forgery-token-field) + (hook ::html.signup-heading [:h1 (:signup/signup i18n)]) + [:p (:signup/site-invite-only i18n)]]] + + (and (:signup/invite-only? config) (not invitation)) + [:main + [:form.flex.col + (anti-forgery-token-field) + (hook ::html.signup-heading [:h1 (:signup/signup i18n)]) + [:p (:signup/invitation-invalid i18n)]]] + + :default + [:main + [:form.flex.col {:name :bread-signup :method :post} + (anti-forgery-token-field) + (hook ::html.signup-heading [:h1 (:signup/signup i18n)]) + (hook ::html.enter-username + [:p.instruct (:signup/please-choose-username-password i18n)]) + (Field :username :label (:auth/username i18n) :value (:username params)) + (Field :password + :type :password + :label (:auth/password i18n) + :input-attrs {:maxlength (:auth/max-password-length config)}) + (Field :password-confirmation + :type :password + :label (:auth/password-confirmation i18n) + :input-attrs {:maxlength (:auth/max-password-length config)}) + (when error-key + (hook ::html.invalid-signup (ErrorMessage (i18n/t i18n error-key)))) + (Submit (:signup/create-account i18n))]])]]) + +(defmethod Section :flash [{:keys [session ring/flash i18n]} _] + [:<> + (when-let [success-key (:success-key flash)] + (SuccessMessage (i18n/t i18n success-key))) + (when-let [error-key (:error-key flash)] + (ErrorMessage (i18n/t i18n error-key)))]) + +(defmethod Section :save [{:keys [i18n]} _] + (Submit (:account/save i18n) :name :action :value "update")) + +(defn- CustomizingSection [_] + {:id :customizing + :title "Customizing RISE" + :content + [:<> + [:p + "RISE is designed to be extensible via CSS variables, AKA custom properties. + By overriding these, you can get a lot of variation from the core theme. + Of course, you can extend it further by serving your own custom CSS."] + [:p + "This technique is powerful, but if your needs are more complex, look into + creating your own custom theme with its own pattern library."]]}) + +(defc PatternLibrary [{:as data :keys [hook]}] + {:extends Page} + (let [patterns [(IntroSection data) + (HowToSection data) + (TypographySection data) + SuccessMessage + ErrorMessage + Page + LoginPage + AccountNav + SettingsPage + AccountPage + (CustomizingSection data)]] + {:title "RISE" + :head (hook ::theme/html.head.pattern-library + [:<> + [:script {:src "/rise/js/patterns.js"}] + [:link {:rel :stylesheet :href "/rise/css/patterns.css"}]]) + :content + [:<> + [:div#theme-toggle-container + [:button#toggle-theme {:style {:position :relative}} "light/dark"]] + (theme/TableOfContents {:patterns patterns}) + [:main.gap-spacious + (map theme/Pattern patterns)]]})) diff --git a/deps.edn b/deps.edn index 2365499b5..b45754e73 100644 --- a/deps.edn +++ b/deps.edn @@ -44,25 +44,26 @@ "plugins/marx" "plugins/reitit" "plugins/rum"] - :extra-deps {aero/aero {:mvn/version "1.1.6"} - buddy/buddy-hashers {:mvn/version "2.0.167"} - metosin/reitit {:mvn/version "0.9.1"} - http-kit/http-kit {:mvn/version "2.6.0"} - integrant/integrant {:mvn/version "0.8.0"} - io.replikativ/datahike {:mvn/version "0.6.1612"} - io.replikativ/datahike-jdbc {:mvn/version "0.3.50"} - juji/editscript {:mvn/version "0.6.3"} - markdown-clj/markdown-clj {:mvn/version "1.10.5"} - one-time/one-time {:mvn/version "0.8.0"} + :extra-deps {aero/aero {:mvn/version "1.1.6"} + buddy/buddy-hashers {:mvn/version "2.0.167"} + metosin/reitit {:mvn/version "0.9.1"} + http-kit/http-kit {:mvn/version "2.6.0"} + integrant/integrant {:mvn/version "0.8.0"} + io.replikativ/datahike {:mvn/version "0.6.1612"} + io.replikativ/datahike-jdbc {:mvn/version "0.3.50"} + juji/editscript {:mvn/version "0.6.3"} + markdown-clj/markdown-clj {:mvn/version "1.10.5"} + markdown-to-hiccup/markdown-to-hiccup {:mvn/version "0.6.2"} + one-time/one-time {:mvn/version "0.8.0"} ;; TODO ? - org.clj-commons/hickory {:mvn/version "0.7.4"} - org.xerial/sqlite-jdbc {:mvn/version "3.41.0.0"} - ring/ring {:mvn/version "1.9.5"} - ring/ring-defaults {:mvn/version "0.4.0"} - rum/rum {:mvn/version "0.12.10"} - com.taoensso/timbre {:mvn/version "6.7.1"} + org.clj-commons/hickory {:mvn/version "0.7.4"} + org.xerial/sqlite-jdbc {:mvn/version "3.41.0.0"} + ring/ring {:mvn/version "1.9.5"} + ring/ring-defaults {:mvn/version "0.4.0"} + rum/rum {:mvn/version "0.12.10"} + com.taoensso/timbre {:mvn/version "6.7.1"} + com.draines/postal {:mvn/version "2.0.5"} com.googlecode.owasp-java-html-sanitizer/owasp-java-html-sanitizer {:mvn/version "20240325.1"} - com.draines/postal {:mvn/version "2.0.5"} ,}} :marx diff --git a/dev/main.edn b/dev/main.edn index e7333b1be..0bf889193 100644 --- a/dev/main.edn +++ b/dev/main.edn @@ -3,7 +3,7 @@ :wrap-defaults #ig/ref :ring/wrap-defaults} :ring/wrap-defaults {:kind :site-defaults :session-store #ig/ref :ring/session-store - :overrides {[:security :anti-forgery] false + :overrides {[:security :anti-forgery] true [:session :cookie-name] "bread-session" ;; https://github.com/ring-clojure/ring/blob/fd08dd8d905bc8062866cfec938c8cbf65afc7b0/ring-core/src/ring/middleware/cookies.clj#L101 [:session :cookie-attrs] {:http-only true @@ -25,15 +25,14 @@ :min-password-length 4 :store-session-ip? true :store-session-user-agent? true} - :signup {:signup-uri "/~/signup" - :invite-only? true} + :signup {:invite-only? true} :account {:account-uri "/~/account"} :i18n {:home-route :home :query-global-strings? false :supported-langs #{:en :fr :es :ar} :fallback-lang :ar} :routes {:router #ig/ref :bread/router} - :components {:not-found #var systems.bread.alpha.cms.theme/NotFoundPage} + :components {:not-found #var systems.bread.alpha.cms.theme.crust/NotFoundPage} :navigation {:menus {:main-nav {:menu/type :systems.bread.alpha.navigation/location :menu/location :primary @@ -57,7 +56,7 @@ ;; STORE_BACKEND=mem :store #include #join ["db-store." #or [#env STORE_BACKEND "jdbc"] ".edn"]} :db/initial-txns - [{:invitation/code #uuid "a7d190e5-d7f4-4b92-a751-3c36add92610" + [{:invitation/code "a7d190e5-d7f4-4b92-a751-3c36add92610" :invitation/invited-by "user.admin"} {:db/id "user.admin" :user/username "bread" @@ -69,7 +68,8 @@ {:email/address "admin2@bread.systems" :email/confirmed-at #inst "2025-12-30T12:00:00-08:00" :email/code "qwerty"} - {:email/address "admin3@bread.systems" + {:thing/created-at #seconds-ago 0 + :email/address "admin3@bread.systems" :email/code "asdf"}] :user/password #buddy/derive ["hello"] #_#_ ;; Uncomment to enable MFA diff --git a/plugins/auth/systems/bread/alpha/plugin/account.cljc b/plugins/auth/systems/bread/alpha/plugin/account.cljc index 950f18b13..0d1f09a99 100644 --- a/plugins/auth/systems/bread/alpha/plugin/account.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/account.cljc @@ -42,12 +42,6 @@ (re-find #"windows" normalized) "Windows" :default "Unknown OS")))) -(defn- i18n-format [i18n k] - (if (sequential? k) ;; TODO tongue - (let [[k & args] k] - (apply format (get i18n k) args)) - (get i18n k))) - (defmethod Section ::username [{:keys [user]} _] [:span.username (:user/username user)]) @@ -59,14 +53,6 @@ (defmethod Section ::heading [{:keys [i18n]} _] [:h3 (:account/account i18n)]) -;; TODO move to generic UI ns... -(defmethod Section :flash [{:keys [session ring/flash i18n]} _] - [:<> - (when-let [success-key (:success-key flash)] - [:.emphasis [:p (i18n-format i18n success-key)]]) - (when-let [error-key (:error-key flash)] - [:.error [:p (i18n-format i18n error-key)]])]) - (defmethod Section ::name [{:keys [user i18n]} _] [:.field [:label {:for :name} (:account/name i18n)] @@ -116,26 +102,24 @@ :name :password-confirmation :maxlength (:auth/max-password-length config)}]]]) -(defmethod Section :save [{:keys [i18n]} _] - [:.field - [:span.spacer] - [:button {:type :submit :name :action :value "update"} - (:account/save i18n)]]) - -(defmethod Section ::account-form [{:as data :keys [config]} _] - [:form.flex.col {:method :post} - (map (partial Section data) (:account/html.account.form config))]) +(defmethod Section ::account-form + [{:as data :keys [config ring/anti-forgery-token-field]} _] + (apply conj [:form.flex.col {:method :post}] + (when anti-forgery-token-field + (anti-forgery-token-field)) + (map (partial Section data) (:account/html.account.form config)))) (defmethod Section ::sessions [{:keys [i18n session user]} _] (let [date-fmt (SimpleDateFormat. (:account/date-format-default i18n "d LLL"))] - [:section.flex.col + [:section [:h3 (:account/your-sessions i18n)] [:.flex.col (map (fn [{:as user-session {:keys [user-agent remote-addr]} :session/data :thing/keys [created-at updated-at]}] (if (= (:db/id session) (:db/id user-session)) - [:div.user-session.current + ;; Current session. + [:div.user-session [:div (when user-agent [:div (ua->browser user-agent) " | " (ua->os user-agent)]) @@ -146,6 +130,7 @@ ;; TODO i18n [:div "Last active at " (.format date-fmt updated-at)])] [:div [:span.instruct "This session"]]] + ;; Sessions on other devices. [:form.user-session {:method :post} [:input {:type :hidden :name :dbid :value (:db/id user-session)}] [:div @@ -161,30 +146,6 @@ (:auth/logout i18n)]]])) (:user/sessions user))]])) -(defc AccountPage - [{:as data :keys [config hook dir user]}] - {:query '[:db/id - :thing/created-at - :user/username - :user/name - :user/lang - :user/preferences - {:user/roles [:role/key {:role/abilities [:ability/key]}]} - {:invitation/_redeemer [{:invitation/invited-by [:db/id :user/username]}]} - {:user/sessions [:db/id :session/data :thing/created-at :thing/updated-at]}]} - [:html {:lang (:field/lang data) :dir dir} - [:head - [:meta {:content-type :utf-8}] - (hook ::html.account.title [:title (:user/username user) " | " (:site/name config)]) - ;; TODO theme/Style - (->> (auth/LoginStyle data) (hook ::html.stylesheet) (hook ::html.account.stylesheet)) - (->> [:<>] (hook ::html.head) (hook ::html.account.head))] - [:body - [:nav.flex.row - (map (partial Section data) (:account/html.account.header config))] - [:main.flex.col - (map (partial Section data) (:account/html.account.sections config))]]]) - (defmethod bread/expand ::user [_ {:keys [user]}] ;; TODO infer from query/schema... (when user @@ -297,7 +258,7 @@ html-account-header [::account-link ::email/settings-link :spacer - auth/LogoutForm] + ::logout-form] html-account-form [::heading :flash ::name diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index 1da80f730..188c9850d 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -252,93 +252,6 @@ (.encodeToString (Base64/getEncoder)) (str "data:image/png;base64,")))) -(defc LoginPage - [{:as data - :keys [config hook i18n session dir totp] - :auth/keys [result]}] - {} - (let [{:keys [totp-key issuer]} totp - user (or (:user session) (:auth/user session)) - step (:auth/step session) - error? (false? (:valid result))] - [:html {:lang (:field/lang data) :dir dir} - [:head - [:meta {:content-type :utf-8}] - (hook ::html.title [:title (str (:auth/login i18n) " | " (:site/name config))]) - (->> (LoginStyle data) (hook ::html.stylesheet) (hook ::html.login.stylesheet)) - (->> [:<>] (hook ::html.head) (hook ::html.login.head))] - [:body - (cond - (:user/locked-at user) - [:main.flex.col - [:form.flex.col - (hook ::html.locked-heading [:h2 (:auth/account-locked i18n)]) - (hook ::html.locked-explanation [:p (:auth/too-many-attempts i18n)])]] - - (= :setup-two-factor step) - (let [data-uri (qr-datauri {:label issuer - :user (:user/username user) - :secret totp-key - :image-type :PNG})] - [:main.flex.col - [:form.flex.col {:name :setup-mfa :method :post} - (hook ::html.login-heading [:h1 (:auth/login-to-bread i18n)]) - (hook ::html.scan-qr-instructions - [:p.instruct (:auth/please-scan-qr-code i18n)]) - [:div.center [:img {:src data-uri :width 125 :alt (:auth/qr-code i18n)}]] - [:p.instruct (:auth/or-enter-key-manually i18n)] - [:div.center [:h2.totp-key totp-key]] - [:input {:type :hidden :name :totp-key :value totp-key}] - [:hr] - [:p.instruct (:auth/enter-totp-next i18n)] - [:div.field.two-factor - [:input {:id :two-factor-code :type :number :name :two-factor-code}] - [:button {:type :submit :name :submit :value "verify"} - (:auth/verify i18n)]] - (when error? - (hook ::html.invalid-code - [:div.error - [:p (:auth/invalid-totp i18n)]]))]]) - - (= :two-factor step) - [:main.flex.col - [:form.flex.col {:name :bread-login :method :post} - (hook ::html.login-heading [:h1 (:auth/login-to-bread i18n)]) - (hook ::html.enter-2fa-code - [:p.instruct (:auth/enter-totp i18n)]) - [:div.field.two-factor - [:input {:id :two-factor-code :type :number :name :two-factor-code}] - [:button {:type :submit :name :submit :value "verify"} - (:auth/verify i18n)]] - (when error? - (hook ::html.invalid-code - [:div.error - [:p (:auth/invalid-totp i18n)]]))]] - - :default - [:main.flex.col - [:form.flex.col {:name :bread-login :method :post} - (hook ::html.login-heading [:h1 (:auth/login-to-bread i18n)]) - (hook ::html.enter-username - [:p.instruct (:auth/enter-username-password i18n)]) - [:div.field - [:label {:for :user} (:auth/username i18n)] - [:input {:id :user :type :text :name :username}]] - [:div.field - [:label {:for :password} (:auth/password i18n)] - [:input {:id :password :type :password :name :password}]] - (when error? - (hook ::html.invalid-login - [:div.error [:p (:auth/invalid-username-password i18n)]])) - [:div.field - [:span.spacer] - [:button {:type :submit} (:auth/login i18n)]]]])]])) - -(defn LogoutForm [{:keys [config i18n]}] - [:form.logout-form {:method :post :action (:auth/login-uri config)} - [:button {:type :submit :name :submit :value "logout"} - (:auth/logout i18n)]]) - (defmethod bread/action ::require-auth [{:keys [headers session query-string uri] :as req} _ _] (let [login-uri (bread/config req :auth/login-uri) diff --git a/plugins/auth/systems/bread/alpha/plugin/signup.cljc b/plugins/auth/systems/bread/alpha/plugin/signup.cljc index 1af259612..27b2077de 100644 --- a/plugins/auth/systems/bread/alpha/plugin/signup.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/signup.cljc @@ -3,6 +3,7 @@ [buddy.hashers :as hashers] [clojure.edn :as edn] [clojure.java.io :as io] + [crypto.random :as random] [one-time.core :as ot] [systems.bread.alpha.core :as bread] @@ -10,70 +11,7 @@ [systems.bread.alpha.database :as db] [systems.bread.alpha.i18n :as i18n] [systems.bread.alpha.internal.time :as t] - [systems.bread.alpha.plugin.auth :as auth]) - (:import - [java.util UUID])) - -(defn- ->uuid [x] - (if (string? x) - (try (UUID/fromString x) (catch IllegalArgumentException _ nil)) - x)) - -(defc SignupPage - [{:as data - :keys [config error hook i18n invitation rtl? dir ring/params] - [valid? error-key] :validation}] - {} - [:html {:lang (:field/lang data) :dir dir} - [:head - [:meta {:content-type "utf-8"}] - (hook ::html.title [:title (str (:signup/signup i18n) " | Bread")]) - (->> (auth/LoginStyle data) (hook ::auth/html.stylesheet) (hook ::html.signup.stylesheet)) - (->> [:<>] (hook ::auth/html.head) (hook ::html.signup.head))] - [:body - (cond - (and (:signup/invite-only? config) (not (:code params))) - [:main - [:form.flex.col - (hook ::html.signup-heading [:h1 (:signup/signup i18n)]) - [:p (:signup/site-invite-only i18n)]]] - - (and (:signup/invite-only? config) (not invitation)) - [:main - [:form.flex.col - (hook ::html.signup-heading [:h1 (:signup/signup i18n)]) - [:p (:signup/invitation-invalid i18n)]]] - - :default - [:main - [:form.flex.col {:name :bread-signup :method :post} - (hook ::html.signup-heading [:h1 (:signup/signup i18n)]) - (hook ::html.enter-username - [:p.instruct (:signup/please-choose-username-password i18n)]) - [:div.field - [:label {:for :user} (:auth/username i18n)] - [:input {:id :user :type :text :name :username :value (:username params)}]] - [:div.field - [:label {:for :password} (:auth/password i18n)] - [:input {:id :password - :type :password - :name :password - :maxlength (:auth/max-password-length config)}]] - [:div.field - [:label {:for :password-confirmation} (:auth/password-confirmation i18n)] - [:input {:id :password-confirmation - :type :password - :name :password-confirmation - :maxlength (:auth/max-password-length config)}]] - (when error-key - (hook ::html.invalid-signup - [:div.error [:p (if (sequential? error-key) ;; TODO tongue? - (let [[k & args] error-key] - (apply format (get i18n k) args)) - (get i18n error-key))]])) - [:div.field - [:span.spacer] - [:button {:type :submit} (:signup/create-account i18n)]]]])]]) + [systems.bread.alpha.plugin.auth :as auth])) (defmethod bread/expand ::validate [{{:auth/keys [min-password-length max-password-length] @@ -137,7 +75,7 @@ :in [$ ?code] :where [[?e :invitation/code ?code] (not [?e :invitation/redeemer])]} - (->uuid (:code params))]}) + (:code params)]}) expansions [{:expansion/key :config :expansion/name ::bread/value :expansion/description "Signup config" @@ -202,10 +140,10 @@ :attr/migration "migration.invitation"} {:db/ident :invitation/code :attr/label "Invitation code" - :db/doc "Secure UUID for this invitation" + :db/doc "Secure ID for this invitation" :attr/sensitive? true :db/unique :db.unique/identity - :db/valueType :db.type/uuid + :db/valueType :db.type/string :db/cardinality :db.cardinality/one :attr/migration "migration.invitation"} {:db/ident :invitation/invited-by @@ -232,7 +170,7 @@ invite-only? invitation-expiration-seconds signup-uri] :or {invite-only? false invitation-expiration-seconds (* 72 60 60) - signup-uri "/signup"}}] + signup-uri "/_/signup"}}] {:hooks {::db/migrations [{:action/name ::db/add-schema-migration diff --git a/plugins/email/email.i18n.edn b/plugins/email/email.i18n.edn index 882d84e55..f250d62d4 100644 --- a/plugins/email/email.i18n.edn +++ b/plugins/email/email.i18n.edn @@ -16,11 +16,13 @@ :email-added-please-confirm "Email added. We sent a confirmation link to your new email. Please follow the link to confirm." :email-confirmed "Email confirmed." :email-deleted "Email deleted." + :email-heading "Emails" :email-in-use "That email is already in use." :email-settings "Email settings" :invalid-email "Invalid email" :no-emails "No emails yet." :make-primary "Make primary" + :please-confirm "Please confirm your email:" :primary "Primary email" :resend-confirmation "Resend confirmation" :to-add-email-confirm-pending "To add another email, first confirm any pending email addresses." diff --git a/plugins/email/systems/bread/alpha/plugin/email.cljc b/plugins/email/systems/bread/alpha/plugin/email.cljc index c8c9b8e3b..81dd9146f 100644 --- a/plugins/email/systems/bread/alpha/plugin/email.cljc +++ b/plugins/email/systems/bread/alpha/plugin/email.cljc @@ -7,7 +7,6 @@ [postal.core :as postal] [taoensso.timbre :as log] - [systems.bread.alpha.component :refer [defc Section]] [systems.bread.alpha.core :as bread] [systems.bread.alpha.database :as db] [systems.bread.alpha.i18n :as i18n] @@ -54,122 +53,6 @@ (log/error (ex-info "Error sending email" {:mailer mailer :message message} e)))) (log/info "simulating email" (summarize message))))) -(defmethod Section ::settings-link - [{:keys [i18n] {:email/keys [settings-uri]} :config} _] - [:a {:href settings-uri :title (:email/email-settings i18n)} - ;; TODO i18n - (:email i18n "Email")]) - -(defmethod Section ::heading [{:keys [i18n]} _] - [:h3 (:email/email i18n "Email")]) - -(defn- compare-emails [a b] - (cond - ;; Always list primary first... - (:email/primary? a) -1 - (:email/primary? b) 1 - ;; ...then confirmed... - (and (:email/confirmed-at a) (:email/confirmed-at b)) - (compare (:email/confirmed-at a) (:email/confirmed-at b)) - ;; ...and finally unconfirmed. - :else (compare (:email/created-at a) (:email/created-at b)))) - -(defmethod Section ::emails [{:keys [config i18n user]} _] - (let [{:email/keys [allow-delete-primary?]} config - emails (sort compare-emails (:user/emails user))] - [:<> - (if (seq emails) - [:.flex.col {:role :list} - (map (fn [{:keys [email/address - email/confirmed-at - email/primary? - thing/created-at - db/id]}] - [:form.flex.row {:method :post :role :listitem} - [:input {:type :hidden :name :email :value address}] - [:input {:type :hidden :name :id :value id}] - (cond - primary? - [:<> - [:.flex.col.tight - [:label address] - [:small (:email/primary i18n)] - [:small - (:email/confirmed i18n) - ;; TODO date locale/formatting - " " confirmed-at]] - [:span.spacer] - (when allow-delete-primary? - [:button {:type :submit :name :action :value :delete} - (:email/delete i18n)])] - - confirmed-at - [:<> - [:.flex.col.tight - [:label address] - [:small (:email/confirmed i18n) - ;; TODO date locale/formatting - " " confirmed-at]] - [:span.spacer] - [:button {:type :submit :name :action :value :make-primary} - (:email/make-primary i18n)] - [:button {:type :submit :name :action :value :delete} - (:email/delete i18n)]] - - :pending - [:<> - [:.flex.col.tight - [:label address] - [:small (:email/confirmation-pending i18n)]] - [:span.spacer] - [:button {:type :submit :name :action :value :resend-confirmation} - (:email/resend-confirmation i18n)] - [:button {:type :submit :name :action :value :delete} - (:email/delete i18n)]])]) - emails)] - [:p.instruct (:email/no-emails i18n)])])) - -(defmethod Section ::add-email [{:keys [config i18n user]} _] - (let [emails (:user/emails user) - any-pending? (seq (filter (complement :email/confirmed-at) emails)) - allow-multiple-pending? (:email/allow-multiple-pending? config)] - (if (or (not any-pending?) allow-multiple-pending?) - [:<> - [:h3 {:for :add-email} - (:email/add-email i18n)] - [:form.flex.row {:method :post} - [:input {:id :add-email :type :email :name :email :placeholder "me@example.email"}] - [:button {:type :submit :name :action :value :add} - (:email/add i18n)]]] - [:p.instruct (:email/to-add-email-confirm-pending i18n)]))) - -(defc EmailPage - [{:as data :keys [config dir hook i18n user]}] - {:query '[:db/id :user/username {:user/emails [* :thing/created-at]}]} - ;; TODO UI lib - [:html {:lang {:field/lang data} :dir dir} - [:head - [:meta {:content-type :utf-8}] - (->> (auth/LoginStyle data) (hook ::html.stylesheet) (hook ::html.email.stylesheet)) - (hook ::html.email.title [:title (:email/email i18n "Email")])] - [:body - [:nav.flex.row - (map (partial Section data) (:account/html.account.header config))] - [:main.flex.col - (map (partial Section data) (:email/html.email.sections config))]]]) - -(defc ConfirmPage - [{:keys [pending-email i18n ring/params ring/uri]}] - {:query '[:db/id :email/address] - :key :pending-email} - (let [{:email/keys [address code]} pending-email] - ;; TODO styles - [:form {:method :post :action uri} - [:input {:type :hidden :name :email :value address}] - [:input {:type :hidden :name :code :value code}] - [:button {:type :submit} - (:email/confirm-email i18n)]])) - (defn- ensure-own-email-id [user id] (let [own-id? (contains? (set (map :db/id (:user/emails user))) id)] (when-not own-id? @@ -346,8 +229,10 @@ (defmethod bread/expand ::validate-recency [{:keys [max-pending-minutes]} {:keys [pending-email]}] (let [min-updated (t/minutes-ago (t/now) max-pending-minutes) - valid? (when pending-email - (.after (:thing/updated-at pending-email) min-updated))] + updated-at (or (:thing/updated-at pending-email) + (:thing/created-at pending-email)) + valid? (when updated-at + (.after updated-at min-updated))] (when valid? pending-email))) (defmethod bread/dispatch ::confirm=> @@ -361,6 +246,7 @@ :expansion/args ['{:find [(pull ?e [:db/id :email/code :email/address + :thing/created-at :thing/updated-at]) .] :in [$ ?code ?email] :where [[?e :email/code ?code] diff --git a/plugins/reitit/systems/bread/alpha/plugin/reitit.cljc b/plugins/reitit/systems/bread/alpha/plugin/reitit.cljc index fec69c31a..5d933dc8f 100644 --- a/plugins/reitit/systems/bread/alpha/plugin/reitit.cljc +++ b/plugins/reitit/systems/bread/alpha/plugin/reitit.cljc @@ -33,9 +33,12 @@ params [] ctx {:keyword? false}] (case c - nil params + nil (if (:keyword? ctx) + (conj params (keyword param)) + params) ;; TODO support keyword-style :route/:params syntax \{ (recur cs "" params {:keyword? true}) + \* (recur cs "" params {:keyword? true}) \} (recur cs "" (conj params (keyword param)) {:keyword? false}) \/ (let [param? (seq param) parsing-keyword? (:keyword? ctx)] @@ -45,6 +48,10 @@ :else (recur cs param params ctx))) (recur cs (str param c) params ctx)))) +(comment + (template->spec "/{field/lang}/*slugs") + ) + (defn- route-name [compiled-route] (or (:name compiled-route) (keyword (first compiled-route)))) diff --git a/plugins/rum/systems/bread/alpha/plugin/rum.cljc b/plugins/rum/systems/bread/alpha/plugin/rum.cljc index 4728d8b6c..f25011828 100644 --- a/plugins/rum/systems/bread/alpha/plugin/rum.cljc +++ b/plugins/rum/systems/bread/alpha/plugin/rum.cljc @@ -1,5 +1,6 @@ (ns systems.bread.alpha.plugin.rum (:require + [clojure.edn :as edn] [clojure.walk :as walk] [rum.core :as rum :exclude [cljsjs/react cljsjs/react-dom]] @@ -19,18 +20,26 @@ (defmethod i18n/deserialize :html [field] (HtmlString. (:field/content field))) +(defmethod i18n/deserialize :edn+html [{:field/keys [content]}] + (let [content (edn/read-string content)] + (walk/postwalk #(if (string? %) (HtmlString. %) %) content))) + (defn html-string? [x] (instance? HtmlString x)) +(defn- ->html [& xs] + (let [xs (map (fn [x] (if (vector? x) (rum/render-static-markup x) x)) xs)] + (apply str xs))) + (defn unescape [html] (walk/postwalk (fn [x] (if (and (vector? x) (seq (filter html-string? x))) (let [[tag attrs & content] x attrs (if (map? attrs) (merge attrs {:dangerouslySetInnerHTML - {:__html (apply str content)}}) + {:__html (apply ->html content)}}) {:dangerouslySetInnerHTML - {:__html (apply str attrs content)}})] + {:__html (apply ->html attrs content)}})] [tag attrs]) x)) html)) diff --git a/resources/.gitignore b/resources/.gitignore index aa11e8cb5..b102a2ec7 100644 --- a/resources/.gitignore +++ b/resources/.gitignore @@ -1,4 +1,3 @@ -public/* debugger/js debug/js marx/js diff --git a/resources/crust/css/base.css b/resources/crust/css/base.css new file mode 100644 index 000000000..4d334e45d --- /dev/null +++ b/resources/crust/css/base.css @@ -0,0 +1,155 @@ +:root { + --color-offwhite: #fbfaf5; +} + +*, *::before, *::after { + box-sizing: border-box; +} + +/* Prevent font size inflation */ +html { + -moz-text-size-adjust: none; + -webkit-text-size-adjust: none; + text-size-adjust: none; +} + +/* Remove default margin in favour of better control in authored CSS */ +body, h1, h2, h3, h4, p, +figure, blockquote, dl, dd, ol, ul { + margin: 0; +} + +/* SIDEBAR LAYOUT */ +body { + width: 100%; + + background: #f0ead6; +} +.container { + display: flex; + flex-wrap: wrap; + width: 95ch; + max-width: 100%; + margin: auto; + gap: 1rem; +} +.main-nav { + flex-basis: 20ch; + flex-grow: 1; + min-inline-size: 30%; + + background: lightgrey; + background: #cec9b9; + padding-inline: 1em; +} +main { + flex-basis: 30ch; + flex-grow: 999; + min-height: 80vh; +} +.main-footer { + display: flex; + width: 100%; + min-height: 20vh; + + background: #393636; + color: white; +} +.footer-content { + max-width: 100%; + margin: 2em auto; + flex-basis: 79ch; + + color: #f0ead6; +} + +nav ul { + display: flex; + flex-flow: column nowrap; + gap: 0.5em; + padding: 0; + + list-style: none; +} + +nav, article { + padding: 3em 0; +} +h1 { + margin-block-end: 2em; +} +h2 { + margin-block-start: 1.5em; + margin-block-end: 1.2em; +} +h3 { + margin-block-start: 1em; + margin-block-end: 1em; +} +p { + margin: 1em auto; +} +mark { + background-color: #fbe10d; +} + +.post-content::after { + display: block; + content: " "; + width: 0.5em; + height: 0.5em; + background: #aea895; +} +article footer { + margin-block-start: 3em; +} + +h1, h2, h3, h4, h5, h6, nav, footer { + font-family: ui-monospace, + Menlo, + Monaco, + "Cascadia Mono", + "Segoe UI Mono", + "Roboto Mono", + "Oxygen Mono", + "Ubuntu Mono", + "Source Code Pro", + "Fira Mono", + "Droid Sans Mono", + "Consolas", + monospace; +} +body, main, article, section { + font-family: "Apple Garamond", + "Times New Roman", + "Droid Serif", + "Times", + "Source Serif Pro", + serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol"; +} + +a { + color: #621c1c; +} +a:visited { + color: #550e47; +} + +.posts-list { + display: flex; + flex-flow: column nowrap; + gap: 1em; + + h2, h3, h4 { + margin-block: 0.5em; + } +} + +.tags-list { + display: flex; + flex-flow: row wrap; + gap: 1ch; +} diff --git a/resources/public/assets/cat.jpeg b/resources/public/assets/cat.jpeg new file mode 100755 index 000000000..74733887f Binary files /dev/null and b/resources/public/assets/cat.jpeg differ diff --git a/resources/public/assets/dog.png b/resources/public/assets/dog.png new file mode 100644 index 000000000..42cb65e28 Binary files /dev/null and b/resources/public/assets/dog.png differ diff --git a/resources/public/assets/hi.txt b/resources/public/assets/hi.txt new file mode 100755 index 000000000..ce0136250 --- /dev/null +++ b/resources/public/assets/hi.txt @@ -0,0 +1 @@ +hello diff --git a/resources/public/assets/highlight/LICENSE b/resources/public/assets/highlight/LICENSE new file mode 100644 index 000000000..2250cc7ec --- /dev/null +++ b/resources/public/assets/highlight/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2006, Ivan Sagalaev. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/resources/public/assets/highlight/README.md b/resources/public/assets/highlight/README.md new file mode 100644 index 000000000..30d84b95f --- /dev/null +++ b/resources/public/assets/highlight/README.md @@ -0,0 +1,45 @@ +# Highlight.js CDN Assets + +[](https://packagephobia.now.sh/result?p=highlight.js) + +**This package contains only the CDN build assets of highlight.js.** + +This may be what you want if you'd like to install the pre-built distributable highlight.js client-side assets via NPM. If you're wanting to use highlight.js mainly on the server-side you likely want the [highlight.js][1] package instead. + +To access these files via CDN: