Просмотр исходного кода

Merge pull request #12279 from logseq/feat/page-publish

feat: page publish
Tienson Qin 2 недель назад
Родитель
Сommit
e5964202d8
46 измененных файлов с 6277 добавлено и 41 удалено
  1. 4 0
      .gitignore
  2. 2 3
      deps.edn
  3. 10 14
      deps/db/src/logseq/db/common/entity_plus.cljc
  4. 2 1
      deps/db/src/logseq/db/frontend/malli_schema.cljs
  5. 7 1
      deps/db/src/logseq/db/frontend/property.cljs
  6. 1 1
      deps/db/src/logseq/db/frontend/schema.cljs
  7. 13 1
      deps/db/src/logseq/db/sqlite/create_graph.cljs
  8. 4 2
      deps/db/test/logseq/db/sqlite/create_graph_test.cljs
  9. 5 1
      deps/db/test/logseq/db/sqlite/export_test.cljs
  10. 22 0
      deps/publish/README.md
  11. 27 0
      deps/publish/deps.edn
  12. 16 0
      deps/publish/package.json
  13. 17 0
      deps/publish/scripts/bump-publish-version.js
  14. 12 0
      deps/publish/shadow-cljs.edn
  15. 66 0
      deps/publish/src/logseq/publish/assets.cljs
  16. 11 0
      deps/publish/src/logseq/publish/async.clj
  17. 332 0
      deps/publish/src/logseq/publish/common.cljs
  18. 94 0
      deps/publish/src/logseq/publish/index.cljs
  19. 457 0
      deps/publish/src/logseq/publish/meta_store.cljs
  20. 41 0
      deps/publish/src/logseq/publish/model.cljs
  21. 945 0
      deps/publish/src/logseq/publish/publish.css
  22. 712 0
      deps/publish/src/logseq/publish/publish.js
  23. 1597 0
      deps/publish/src/logseq/publish/render.cljs
  24. 773 0
      deps/publish/src/logseq/publish/routes.cljs
  25. 22 0
      deps/publish/src/logseq/publish/worker.cljs
  26. 46 0
      deps/publish/worker/README.md
  27. 12 0
      deps/publish/worker/scripts/clear_dev_state.sh
  28. 36 0
      deps/publish/worker/scripts/dev_test.sh
  29. 71 0
      deps/publish/worker/wrangler.toml
  30. 84 0
      deps/publish/yarn.lock
  31. 2 0
      scripts/nbb.edn
  32. 1 1
      scripts/src/logseq/tasks/dev/lint.clj
  33. 1 0
      shadow-cljs.edn
  34. 3 0
      src/main/frontend/common/missionary.cljs
  35. 4 1
      src/main/frontend/components/header.cljs
  36. 41 1
      src/main/frontend/components/page_menu.cljs
  37. 22 2
      src/main/frontend/components/property/value.cljs
  38. 6 2
      src/main/frontend/config.cljs
  39. 8 9
      src/main/frontend/extensions/fsrs.cljs
  40. 431 0
      src/main/frontend/handler/publish.cljs
  41. 10 1
      src/main/frontend/worker/db/migrate.cljs
  42. 1 0
      src/main/frontend/worker/db_worker.cljs
  43. 231 0
      src/main/frontend/worker/publish.cljs
  44. 1 0
      src/main/frontend/worker/rtc/full_upload_download_graph.cljs
  45. 4 0
      src/resources/dicts/en.edn
  46. 70 0
      src/test/frontend/worker/publish_test.cljs

+ 4 - 0
.gitignore

@@ -67,6 +67,10 @@ packages/ui/.storybook/cljs
 deps/shui/.lsp
 deps/shui/.lsp-cache
 deps/shui/.clj-kondo
+deps/publish/worker/.wrangler
+deps/publish/worker/dist
+deps/publish/worker/.env*
+
 tx-log*
 clj-e2e/.wally
 clj-e2e/resources

+ 2 - 3
deps.edn

@@ -30,7 +30,6 @@
   expound/expound                       {:mvn/version "0.8.6"}
   com.lambdaisland/glogi                {:git/url "https://github.com/lambdaisland/glogi"
                                          :git/sha "30328a045141717aadbbb693465aed55f0904976"}
-  binaryage/devtools                    {:mvn/version "1.0.5"}
   camel-snake-kebab/camel-snake-kebab   {:mvn/version "0.4.3"}
   instaparse/instaparse                 {:mvn/version "1.4.10"}
   org.clojars.mmb90/cljs-cache          {:mvn/version "0.1.4"}
@@ -56,9 +55,9 @@
                   :extra-deps  {org.clojure/tools.namespace      {:mvn/version "0.2.11"}
                                 cider/cider-nrepl                {:mvn/version "0.55.1"}
                                 org.clojars.knubie/cljs-run-test {:mvn/version "1.0.1"}
-                                tortue/spy                       {:mvn/version "2.14.0"}}
+                                tortue/spy                       {:mvn/version "2.14.0"}
+                                binaryage/devtools               {:mvn/version "1.0.5"}}
                   :main-opts   ["-m" "shadow.cljs.devtools.cli"]}
-
            :test {:extra-paths ["src/test/"]
                   :extra-deps  {org.clojure/test.check           {:mvn/version "1.1.1"}
                                 pjstadig/humane-test-output      {:mvn/version "0.11.0"}

+ 10 - 14
deps/db/src/logseq/db/common/entity_plus.cljc

@@ -53,25 +53,21 @@
   []
   (vreset! *seen-immutable-entities {}))
 
-(def ^:private *reset-cache-background-task-running?
-  ;; missionary is not compatible with nbb, so entity-memoized is disabled in nbb
-  (delay
-    ;; FIXME: Correct dependency ordering instead of resolve workaround
-    #?(:org.babashka/nbb false
-       :cljs (when-let [f (resolve 'frontend.common.missionary/background-task-running?)]
-               (f :logseq.db.common.entity-plus/reset-immutable-entities-cache!)))))
+(defonce *reset-cache-background-task-running-f (atom nil))
 
 (defn entity-memoized
   [db eid]
   (if (and (qualified-keyword? eid) (not (exists? js/process))) ; don't memoize on node
     (when-not (contains? nil-db-ident-entities eid) ;fast return nil
-      (if (and @*reset-cache-background-task-running?
-               (contains? immutable-db-ident-entities eid)) ;return cache entity if possible which isn't nil
-        (or (get @*seen-immutable-entities eid)
-            (let [r (d/entity db eid)]
-              (when r (vswap! *seen-immutable-entities assoc eid r))
-              r))
-        (d/entity db eid)))
+      (let [f @*reset-cache-background-task-running-f]
+        (if (and (fn? f)
+                 (f :logseq.db.common.entity-plus/reset-immutable-entities-cache!)
+                 (contains? immutable-db-ident-entities eid)) ;return cache entity if possible which isn't nil
+          (or (get @*seen-immutable-entities eid)
+              (let [r (d/entity db eid)]
+                (when r (vswap! *seen-immutable-entities assoc eid r))
+                r))
+          (d/entity db eid))))
     (d/entity db eid)))
 
 (defn unsafe->Entity

+ 2 - 1
deps/db/src/logseq/db/frontend/malli_schema.cljs

@@ -285,7 +285,8 @@
    [:block/tags {:optional true} block-tags]
    [:block/refs {:optional true} [:set :int]]
    [:block/tx-id {:optional true} :int]
-   [:block/collapsed? {:optional true} :boolean]])
+   [:block/collapsed? {:optional true} :boolean]
+   [:block/warning {:optional true} [:string]]])
 
 (def page-attrs
   "Common attributes for pages"

+ 7 - 1
deps/db/src/logseq/db/frontend/property.cljs

@@ -389,6 +389,11 @@
                                            :hide? true
                                            :view-context :page
                                            :public? true}}
+     :logseq.property.publish/published-url {:title "Published URL"
+                                             :schema
+                                             {:type :url
+                                              :view-context :page
+                                              :public? true}}
      :logseq.property/exclude-from-graph-view {:title "Excluded from Graph view?"
                                                :schema
                                                {:type :checkbox
@@ -651,7 +656,8 @@
     "logseq.property.linked-references" "logseq.property.asset" "logseq.property.table" "logseq.property.node"
     "logseq.property.code" "logseq.property.repeat"
     "logseq.property.journal" "logseq.property.class" "logseq.property.view"
-    "logseq.property.user" "logseq.property.history" "logseq.property.embedding"})
+    "logseq.property.user" "logseq.property.history" "logseq.property.embedding"
+    "logseq.property.publish"})
 
 (defn logseq-property?
   "Determines if keyword is a logseq property"

+ 1 - 1
deps/db/src/logseq/db/frontend/schema.cljs

@@ -37,7 +37,7 @@
          (map (juxt :major :minor)
               [(parse-schema-version x) (parse-schema-version y)])))
 
-(def version (parse-schema-version "65.16"))
+(def version (parse-schema-version "65.18"))
 
 (defn major-version
   "Return a number.

+ 13 - 1
deps/db/src/logseq/db/sqlite/create_graph.cljs

@@ -207,6 +207,16 @@
     :file/path (str "logseq/" "custom.js")
     :file/content ""
     :file/created-at (js/Date.)
+    :file/last-modified-at (js/Date.)}
+   {:block/uuid (common-uuid/gen-uuid :builtin-block-uuid "logseq/publish.css")
+    :file/path (str "logseq/" "publish.css")
+    :file/content ""
+    :file/created-at (js/Date.)
+    :file/last-modified-at (js/Date.)}
+   {:block/uuid (common-uuid/gen-uuid :builtin-block-uuid "logseq/publish.js")
+    :file/path (str "logseq/" "publish.js")
+    :file/content ""
+    :file/created-at (js/Date.)
     :file/last-modified-at (js/Date.)}])
 
 (defn build-db-initial-data
@@ -225,7 +235,9 @@
                        import-type
                        (into (sqlite-util/import-tx import-type))
                        graph-git-sha
-                       (conj (sqlite-util/kv :logseq.kv/graph-git-sha graph-git-sha)))
+                       (conj (sqlite-util/kv :logseq.kv/graph-git-sha graph-git-sha))
+                       true
+                       (conj (sqlite-util/kv :logseq.kv/graph-uuid (common-uuid/gen-uuid))))
         initial-files (build-initial-files config-content)
         {properties-tx :tx :keys [properties]} (build-initial-properties)
         db-ident->properties (zipmap (map :db/ident properties) properties)

+ 4 - 2
deps/db/test/logseq/db/sqlite/create_graph_test.cljs

@@ -155,8 +155,10 @@
 (deftest build-db-initial-data-test
   (testing "idempotent initial-data"
     (letfn [(remove-ignored-attrs&entities [init-data]
-              (let [[before after] (split-with #(not= :logseq.kv/graph-created-at (:db/ident %)) init-data)
-                    init-data* (concat before (rest after))]
+              (let [ignored-idents #{:logseq.kv/graph-created-at :logseq.kv/graph-uuid}
+                    init-data* (remove (fn [ent]
+                                         (contains? ignored-idents (:db/ident ent)))
+                                       init-data)]
                 (map (fn [ent] (dissoc ent
                                        :block/created-at :block/updated-at
                                        :file/last-modified-at :file/created-at

+ 5 - 1
deps/db/test/logseq/db/sqlite/export_test.cljs

@@ -749,7 +749,11 @@
           {:file/path "logseq/custom.css"
            :file/content ".foo {background-color: blue}"}
           {:file/path "logseq/custom.js"
-           :file/content "// comment"}]
+           :file/content "// comment"}
+          {:file/path "logseq/publish.css"
+           :file/content ""}
+          {:file/path "logseq/publish.js"
+           :file/content ""}]
          :build-existing-tx? true}]
     original-data))
 

+ 22 - 0
deps/publish/README.md

@@ -0,0 +1,22 @@
+## Description
+
+Shared library for page publishing (snapshot payloads, SSR helpers, shared schemas, and storage contracts).
+
+The Cloudflare Durable Object implementation is expected to use SQLite with the
+Logseq datascript fork layered on top. Page publish payloads are expected to
+send datoms (transit) so the DO can reconstruct/query datascript state.
+
+See `deps/publish/worker` for a Cloudflare Worker skeleton that stores transit
+blobs in R2 and metadata in a SQLite-backed Durable Object.
+
+## API
+
+Namespaces live under `logseq.publish`.
+
+## Usage
+
+This module is intended to be consumed by the Logseq app and the publishing worker.
+
+## Dev
+
+Keep this module aligned with the main repo's linting and testing conventions.

+ 27 - 0
deps/publish/deps.edn

@@ -0,0 +1,27 @@
+{:paths ["src" "../../resources"]
+ :deps
+ {org.clojure/clojure                   {:mvn/version "1.11.1"}
+  rum/rum                               {:git/url "https://github.com/logseq/rum" ;; fork
+                                         :sha     "5d672bf84ed944414b9f61eeb83808ead7be9127"}
+
+  datascript/datascript                 {:git/url "https://github.com/logseq/datascript" ;; fork
+                                         :sha     "ff5a7d5326e2546f40146e4a489343f557519bc3"}
+  datascript-transit/datascript-transit {:mvn/version "0.3.0"
+                                         :exclusions [datascript/datascript]}
+  funcool/promesa                       {:mvn/version "11.0.678"}
+  thheller/shadow-cljs                  {:mvn/version "3.3.4"}
+  logseq/common                         {:local/root "../common"}
+  logseq/graph-parser                   {:local/root "../graph-parser"}
+  logseq/db                             {:local/root "../db"}
+  missionary/missionary                 {:mvn/version "b.46"}
+  com.cognitect/transit-cljs            {:mvn/version "0.8.280"}
+  hiccups/hiccups                       {:mvn/version "0.3.0"}}
+ :aliases
+ {:cljs {:extra-deps  {org.clojure/tools.namespace      {:mvn/version "0.2.11"}
+                       cider/cider-nrepl                {:mvn/version "0.55.1"}
+                       org.clojars.knubie/cljs-run-test {:mvn/version "1.0.1"}
+                       tortue/spy                       {:mvn/version "2.14.0"}}
+         :main-opts   ["-m" "shadow.cljs.devtools.cli"]}
+  :clj-kondo
+  {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2024.09.27"}}
+   :main-opts  ["-m" "clj-kondo.main"]}}}

+ 16 - 0
deps/publish/package.json

@@ -0,0 +1,16 @@
+{
+  "name": "@logseq/publish",
+  "version": "1.0.0",
+  "private": true,
+  "scripts": {
+    "dev": "cd ./worker && npx wrangler dev",
+    "watch": "clojure -M:cljs watch publish-worker",
+    "release": "clojure -M:cljs release publish-worker",
+    "clean": "rm -rf ./worker/dist/",
+    "bump-publish-version": "node ./scripts/bump-publish-version.js",
+    "deploy": "yarn bump-publish-version && yarn clean && yarn release && cd ./worker && wrangler deploy --env prod"
+  },
+  "dependencies": {
+    "shadow-cljs": "^3.3.4"
+  }
+}

+ 17 - 0
deps/publish/scripts/bump-publish-version.js

@@ -0,0 +1,17 @@
+const fs = require("fs");
+const path = require("path");
+
+const renderPath = path.join(__dirname, "..", "src", "logseq", "publish", "render.cljs");
+const source = fs.readFileSync(renderPath, "utf8");
+const timestamp = Date.now();
+
+const next = source.replace(
+  /\(defonce version [^)]+\)/,
+  `(defonce version ${timestamp})`
+);
+
+if (next === source) {
+  throw new Error("Failed to update logseq.publish.render/version.");
+}
+
+fs.writeFileSync(renderPath, next);

+ 12 - 0
deps/publish/shadow-cljs.edn

@@ -0,0 +1,12 @@
+;; shadow-cljs configuration
+{:deps true
+ :http {:port 9631}
+ :nrepl {:port 8702}
+ :builds
+ {:publish-worker {:target :esm
+                   :output-dir "worker/dist/worker"
+                   :modules {:main {:exports {default logseq.publish.worker/worker
+                                              PublishMetaDO logseq.publish.worker/PublishMetaDO}}}
+                   :js-options {:js-provider :import}
+                   :closure-defines {shadow.cljs.devtools.client.env/enabled false}
+                   :devtools {:enabled false}}}}

+ 66 - 0
deps/publish/src/logseq/publish/assets.cljs

@@ -0,0 +1,66 @@
+(ns logseq.publish.assets
+  (:require [clojure.string :as string]
+            [logseq.publish.common :as publish-common])
+  (:require-macros [logseq.publish.async :refer [js-await]]))
+
+(defn asset-content-type [ext]
+  (case (string/lower-case (or ext ""))
+    ("png") "image/png"
+    ("jpg" "jpeg") "image/jpeg"
+    ("gif") "image/gif"
+    ("webp") "image/webp"
+    ("svg") "image/svg+xml"
+    ("bmp") "image/bmp"
+    ("avif") "image/avif"
+    ("mp4") "video/mp4"
+    ("webm") "video/webm"
+    ("mov") "video/quicktime"
+    ("mp3") "audio/mpeg"
+    ("wav") "audio/wav"
+    ("ogg") "audio/ogg"
+    ("pdf") "application/pdf"
+    "application/octet-stream"))
+
+(defn parse-asset-meta-header [request]
+  (let [meta-header (.get (.-headers request) "x-asset-meta")]
+    (when meta-header
+      (try
+        (publish-common/normalize-meta (js/JSON.parse meta-header))
+        (catch :default _
+          nil)))))
+
+(defn handle-post-asset [request env]
+  (js-await [auth-header (.get (.-headers request) "authorization")
+             token (when (and auth-header (string/starts-with? auth-header "Bearer "))
+                     (subs auth-header 7))
+             claims (cond
+                      (nil? token) nil
+                      :else (publish-common/verify-jwt token env))]
+            (if (nil? claims)
+              (publish-common/unauthorized)
+              (let [meta (parse-asset-meta-header request)
+                    graph-uuid (get meta :graph)
+                    asset-uuid (get meta :asset_uuid)
+                    asset-type (get meta :asset_type)
+                    checksum (get meta :checksum)]
+                (if (or (nil? meta) (string/blank? graph-uuid) (string/blank? asset-uuid) (string/blank? asset-type))
+                  (publish-common/bad-request "missing asset metadata")
+                  (js-await [body (.arrayBuffer request)
+                             r2 (aget env "PUBLISH_R2")
+                             r2-key (str "publish/assets/" graph-uuid "/" asset-uuid "." asset-type)
+                             ^js existing (.head r2 r2-key)
+                             existing-checksum (when existing
+                                                 (when-let [meta (.-customMetadata existing)]
+                                                   (aget meta "checksum")))
+                             content-type (or (get meta :content_type)
+                                              (asset-content-type asset-type))
+                             put? (not (and existing-checksum checksum (= existing-checksum checksum)))
+                             _ (when put?
+                                 (.put r2 r2-key body
+                                       #js {:httpMetadata #js {:contentType content-type}
+                                            :customMetadata #js {:checksum (or checksum "")
+                                                                 :owner_sub (aget claims "sub")}}))]
+                            (publish-common/json-response {:asset_uuid asset-uuid
+                                                           :graph_uuid graph-uuid
+                                                           :asset_type asset-type
+                                                           :asset_url (str "/asset/" graph-uuid "/" asset-uuid "." asset-type)})))))))

+ 11 - 0
deps/publish/src/logseq/publish/async.clj

@@ -0,0 +1,11 @@
+(ns logseq.publish.async
+  (:require [shadow.cljs.modern]))
+
+(defmacro js-await
+  "Like `let` but for async values, executed sequentially.
+  Non-async values are wrapped in `js/Promise.resolve`."
+  [[a b & bindings] & body]
+  (let [b `(~'js/Promise.resolve ~b)]
+    (if (seq bindings)
+      `(shadow.cljs.modern/js-await ~[a b] (js-await ~bindings ~@body))
+      `(shadow.cljs.modern/js-await ~[a b] ~@body))))

+ 332 - 0
deps/publish/src/logseq/publish/common.cljs

@@ -0,0 +1,332 @@
+(ns logseq.publish.common
+  (:require [clojure.string :as string]
+            [cognitect.transit :as transit]
+            [datascript.transit :as dt]
+            [logseq.db :as ldb])
+  (:require-macros [logseq.publish.async :refer [js-await]]))
+
+(def text-decoder (js/TextDecoder.))
+(def text-encoder (js/TextEncoder.))
+
+(def ^:private fallback-transit-reader
+  (let [handlers (assoc dt/read-handlers
+                        "datascript/Entity" identity
+                        "error" (fn [m] (ex-info (:message m) (:data m)))
+                        "js/Error" (fn [m] (js/Error. (:message m))))
+        reader (transit/reader :json {:handlers handlers})]
+    (fn [s]
+      (transit/read reader s))))
+
+(defn read-transit-safe [s]
+  (try
+    (ldb/read-transit-str s)
+    (catch :default _
+      (fallback-transit-reader s))))
+
+(defn cors-headers
+  []
+  #js {"access-control-allow-origin" "*"
+       "access-control-allow-methods" "GET,POST,DELETE,OPTIONS"
+       "access-control-allow-headers" "content-type,authorization,x-publish-meta,x-asset-meta,if-none-match"
+       "access-control-expose-headers" "etag"})
+
+(defn merge-headers [base extra]
+  (let [headers (js/Headers. base)]
+    (doseq [[k v] (js/Object.entries extra)]
+      (.set headers k v))
+    headers))
+
+(defn json-response
+  ([data] (json-response data 200))
+  ([data status]
+   (js/Response.
+    (js/JSON.stringify (clj->js data))
+    #js {:status status
+         :headers (merge-headers
+                   #js {"content-type" "application/json"}
+                   (cors-headers))})))
+
+(defn unauthorized []
+  (json-response {:error "unauthorized"} 401))
+
+(defn forbidden []
+  (json-response {:error "forbidden"} 403))
+
+(defn bad-request [message]
+  (json-response {:error message} 400))
+
+(defn not-found []
+  (json-response {:error "not found"} 404))
+
+(defn normalize-meta [meta]
+  (when meta
+    (if (map? meta)
+      meta
+      (js->clj meta :keywordize-keys true))))
+
+(defn parse-meta-header [request]
+  (let [meta-header (.get (.-headers request) "x-publish-meta")]
+    (when meta-header
+      (try
+        (normalize-meta (js/JSON.parse meta-header))
+        (catch :default _
+          nil)))))
+
+(defn get-publish-meta [payload]
+  (when payload
+    (:meta payload)))
+
+(defn meta-from-body [buffer]
+  (try
+    (let [payload (read-transit-safe (.decode text-decoder buffer))
+          meta (get-publish-meta payload)]
+      (normalize-meta meta))
+    (catch :default e
+      (js/console.warn "publish: failed to parse meta from body" e)
+      nil)))
+
+(defn valid-meta? [{:keys [content_hash graph page_uuid]}]
+  (and content_hash graph page_uuid))
+
+(defn get-sql-rows [^js result]
+  (let [iter-fn (when result (aget result js/Symbol.iterator))]
+    (cond
+      (nil? result) []
+      (fn? (.-toArray result)) (.toArray result)
+      (fn? iter-fn) (vec (js/Array.from result))
+      (array? (.-results result)) (.-results result)
+      (array? (.-rows result)) (.-rows result)
+      (array? result) (if (empty? result)
+                        []
+                        (let [first-row (first result)]
+                          (cond
+                            (array? (.-results first-row)) (.-results first-row)
+                            (array? (.-rows first-row)) (.-rows first-row)
+                            :else result)))
+      :else [])))
+
+(defn sql-exec
+  [sql sql-str & args]
+  (.apply (.-exec sql) sql (to-array (cons sql-str args))))
+
+(defn to-hex [buffer]
+  (->> (js/Uint8Array. buffer)
+       (array-seq)
+       (map (fn [b] (.padStart (.toString b 16) 2 "0")))
+       (apply str)))
+
+(defn sha256-hex [message]
+  (js-await [data (.encode text-encoder message)
+             digest (.digest js/crypto.subtle "SHA-256" data)]
+            (to-hex digest)))
+
+(def password-kdf-iterations 210000)
+
+(defn bytes->base64url [bytes]
+  (let [binary (apply str (map #(js/String.fromCharCode %) (array-seq bytes)))
+        b64 (js/btoa binary)]
+    (-> b64
+        (string/replace #"\+" "-")
+        (string/replace #"/" "_")
+        (string/replace #"=+$" ""))))
+
+(defn hash-password [password]
+  (js-await [salt (doto (js/Uint8Array. 16)
+                    (js/crypto.getRandomValues))
+             crypto-key (.importKey js/crypto.subtle
+                                    "raw"
+                                    (.encode text-encoder password)
+                                    #js {:name "PBKDF2"}
+                                    false
+                                    #js ["deriveBits"])
+             derived (.deriveBits js/crypto.subtle
+                                  #js {:name "PBKDF2"
+                                       :hash "SHA-256"
+                                       :salt salt
+                                       :iterations password-kdf-iterations}
+                                  crypto-key
+                                  256)
+             derived-bytes (js/Uint8Array. derived)
+             salt-encoded (bytes->base64url salt)
+             hash-encoded (bytes->base64url derived-bytes)]
+            (str "pbkdf2$sha256$"
+                 password-kdf-iterations
+                 "$"
+                 salt-encoded
+                 "$"
+                 hash-encoded)))
+
+(defn base64url->uint8array [input]
+  (let [pad (if (pos? (mod (count input) 4))
+              (apply str (repeat (- 4 (mod (count input) 4)) "="))
+              "")
+        base64 (-> (str input pad)
+                   (string/replace "-" "+")
+                   (string/replace "_" "/"))
+        raw (js/atob base64)
+        data (js/Uint8Array. (.-length raw))]
+    (dotimes [i (.-length raw)]
+      (aset data i (.charCodeAt raw i)))
+    data))
+
+(defn verify-password [password stored-hash]
+  (let [parts (when (string? stored-hash)
+                (string/split stored-hash #"\$"))]
+    (if-not (and (= 5 (count parts))
+                 (= "pbkdf2" (nth parts 0))
+                 (= "sha256" (nth parts 1)))
+      false
+      (js-await [iterations (js/parseInt (nth parts 2))
+                 salt (base64url->uint8array (nth parts 3))
+                 expected (base64url->uint8array (nth parts 4))
+                 crypto-key (.importKey js/crypto.subtle
+                                        "raw"
+                                        (.encode text-encoder password)
+                                        #js {:name "PBKDF2"}
+                                        false
+                                        #js ["deriveBits"])
+                 derived (.deriveBits js/crypto.subtle
+                                      #js {:name "PBKDF2"
+                                           :hash "SHA-256"
+                                           :salt salt
+                                           :iterations iterations}
+                                      crypto-key
+                                      (* 8 (.-length expected)))
+                 derived-bytes (js/Uint8Array. derived)]
+                (if (not= (.-length derived-bytes) (.-length expected))
+                  false
+                  (let [mismatch (reduce (fn [acc idx]
+                                           (bit-or acc
+                                                   (bit-xor (aget derived-bytes idx)
+                                                            (aget expected idx))))
+                                         0
+                                         (range (.-length expected)))]
+                    (zero? mismatch)))))))
+
+(defn hmac-sha256 [key message]
+  (js-await [crypto-key (.importKey js/crypto.subtle
+                                    "raw"
+                                    key
+                                    #js {:name "HMAC" :hash "SHA-256"}
+                                    false
+                                    #js ["sign"])]
+            (.sign js/crypto.subtle "HMAC" crypto-key message)))
+
+(defn encode-rfc3986 [value]
+  (-> (js/encodeURIComponent value)
+      (.replace #"[!'()*]" (fn [c]
+                             (str "%"
+                                  (.toUpperCase (.toString (.charCodeAt c 0) 16)))))))
+
+(defn encode-path [path]
+  (->> (string/split path #"/")
+       (map encode-rfc3986)
+       (string/join "/")))
+
+(defn get-signature-key [secret date-stamp region service]
+  (js-await [k-date (hmac-sha256
+                     (.encode text-encoder (str "AWS4" secret))
+                     (.encode text-encoder date-stamp))
+             k-region (hmac-sha256 k-date (.encode text-encoder region))
+             k-service (hmac-sha256 k-region (.encode text-encoder service))]
+            (hmac-sha256 k-service (.encode text-encoder "aws4_request"))))
+
+(defn presign-r2-url [r2-key env]
+  (js-await [region "auto"
+             service "s3"
+             host (str (aget env "R2_ACCOUNT_ID") ".r2.cloudflarestorage.com")
+             bucket (aget env "R2_BUCKET")
+             method "GET"
+             now (js/Date.)
+             amz-date (.replace (.toISOString now) #"[ :-]|\.\d{3}" "")
+             date-stamp (.slice amz-date 0 8)
+             credential-scope (str date-stamp "/" region "/" service "/aws4_request")
+             params (->> [["X-Amz-Algorithm" "AWS4-HMAC-SHA256"]
+                          ["X-Amz-Credential" (str (aget env "R2_ACCESS_KEY_ID") "/" credential-scope)]
+                          ["X-Amz-Date" amz-date]
+                          ["X-Amz-Expires" "300"]
+                          ["X-Amz-SignedHeaders" "host"]]
+                         (sort-by first))
+             canonical-query (->> params
+                                  (map (fn [[k v]]
+                                         (str (encode-rfc3986 k) "=" (encode-rfc3986 v))))
+                                  (string/join "&"))
+             canonical-uri (str "/" bucket "/" (encode-path r2-key))
+             canonical-headers (str "host:" host "\n")
+             signed-headers "host"
+             payload-hash "UNSIGNED-PAYLOAD"
+             canonical-request (string/join "\n"
+                                            [method
+                                             canonical-uri
+                                             canonical-query
+                                             canonical-headers
+                                             signed-headers
+                                             payload-hash])
+             canonical-hash (sha256-hex canonical-request)
+             string-to-sign (string/join "\n"
+                                         ["AWS4-HMAC-SHA256"
+                                          amz-date
+                                          credential-scope
+                                          canonical-hash])
+             signing-key (get-signature-key (aget env "R2_SECRET_ACCESS_KEY")
+                                            date-stamp
+                                            region
+                                            service)
+             raw-signature (hmac-sha256 signing-key (.encode text-encoder string-to-sign))
+             signature (to-hex raw-signature)
+             signed-query (str canonical-query "&X-Amz-Signature=" signature)]
+            (str "https://" host canonical-uri "?" signed-query)))
+
+(defn decode-jwt-part [part]
+  (let [data (base64url->uint8array part)]
+    (js/JSON.parse (.decode text-decoder data))))
+
+(defn import-rsa-key [jwk]
+  (.importKey js/crypto.subtle
+              "jwk"
+              jwk
+              #js {:name "RSASSA-PKCS1-v1_5" :hash "SHA-256"}
+              false
+              #js ["verify"]))
+
+(defn verify-jwt [token env]
+  (js-await [parts (string/split token #"\.")
+             _ (when (not= 3 (count parts)) (throw (ex-info "invalid" {})))
+             header-part (nth parts 0)
+             payload-part (nth parts 1)
+             signature-part (nth parts 2)
+             header (decode-jwt-part header-part)
+             payload (decode-jwt-part payload-part)
+             issuer (aget env "COGNITO_ISSUER")
+             client-id (aget env "COGNITO_CLIENT_ID")
+             _ (when (not= (aget payload "iss") issuer) (throw (ex-info "iss not found" {})))
+             _ (when (not= (aget payload "aud") client-id) (throw (ex-info "aud not found" {})))
+             now (js/Math.floor (/ (.now js/Date) 1000))
+             _ (when (and (aget payload "exp") (< (aget payload "exp") now))
+                 (throw (ex-info "exp" {})))
+             jwks-resp (js/fetch (aget env "COGNITO_JWKS_URL"))
+             _ (when-not (.-ok jwks-resp) (throw (ex-info "jwks" {})))
+             jwks (.json jwks-resp)
+             keys (or (aget jwks "keys") #js [])
+             key (.find keys (fn [k] (= (aget k "kid") (aget header "kid"))))
+             _ (when-not key (throw (ex-info "kid" {})))
+             crypto-key (import-rsa-key key)
+             data (.encode text-encoder (str header-part "." payload-part))
+             signature (base64url->uint8array signature-part)
+             ok (.verify js/crypto.subtle
+                         "RSASSA-PKCS1-v1_5"
+                         crypto-key
+                         signature
+                         data)]
+            (when ok payload)))
+
+(defn normalize-etag [etag]
+  (when etag
+    (string/replace etag #"\"" "")))
+
+(defn short-id-for-page [graph-uuid page-uuid]
+  (js-await [payload (.encode text-encoder (str graph-uuid ":" page-uuid))
+             digest (.digest js/crypto.subtle "SHA-256" payload)]
+            (let [data (js/Uint8Array. digest)
+                  encoded (bytes->base64url data)]
+              (subs encoded 0 10))))

+ 94 - 0
deps/publish/src/logseq/publish/index.cljs

@@ -0,0 +1,94 @@
+(ns logseq.publish.index
+  (:require [clojure.string :as string]
+            [logseq.publish.model :as publish-model]))
+
+(defn page-refs-from-payload [payload page-eid page-uuid page-title graph-uuid]
+  (let [entities (publish-model/datoms->entities (:datoms payload))
+        refs (->> entities
+                  (mapcat (fn [[_e entity]]
+                            (when (and (= (:block/page entity) page-eid)
+                                       (not= (:block/uuid entity) page-uuid))
+                              (let [block-uuid (some-> (:block/uuid entity) str)
+                                    block-content (or (:block/content entity)
+                                                      (:block/title entity)
+                                                      (:block/name entity)
+                                                      "")
+                                    block-format (name (or (:block/format entity) :markdown))
+                                    refs (:block/refs entity)
+                                    refs (if (sequential? refs) refs (when refs [refs]))
+                                    targets (->> refs
+                                                 (map publish-model/ref-eid)
+                                                 (keep #(get entities %))
+                                                 distinct)]
+                                (when (seq targets)
+                                  (map (fn [target-entity]
+                                         (let [target-uuid (some-> (:block/uuid target-entity) str)
+                                               target-title (publish-model/entity->title target-entity)
+                                               target-name (or (:block/name target-entity)
+                                                               target-title)
+                                               target-name (when target-name
+                                                             (string/lower-case (str target-name)))]
+                                           {:graph_uuid graph-uuid
+                                            :target_page_uuid target-uuid
+                                            :target_page_title target-title
+                                            :target_page_name target-name
+                                            :source_page_uuid (str page-uuid)
+                                            :source_page_title page-title
+                                            :source_block_uuid block-uuid
+                                            :source_block_content block-content
+                                            :source_block_format block-format
+                                            :updated_at (.now js/Date)}))
+                                       targets)))))))]
+    (vec refs)))
+
+(defn page-tagged-nodes-from-payload [payload page-eid page-uuid page-title graph-uuid]
+  (let [entities (publish-model/datoms->entities (:datoms payload))
+        normalize-tags (fn [tags]
+                         (let [tags (if (sequential? tags) tags (when tags [tags]))]
+                           (->> tags
+                                (map publish-model/ref-eid)
+                                (keep #(get entities %))
+                                (keep (fn [entity]
+                                        (when-let [uuid (:block/uuid entity)]
+                                          {:tag_page_uuid (str uuid)
+                                           :tag_title (publish-model/entity->title entity)})))
+                                distinct)))
+        page-entity (get entities page-eid)
+        page-tags (normalize-tags (:block/tags page-entity))
+        page-entries (when (seq page-tags)
+                       (map (fn [tag]
+                              {:graph_uuid graph-uuid
+                               :tag_page_uuid (:tag_page_uuid tag)
+                               :tag_title (:tag_title tag)
+                               :source_page_uuid (str page-uuid)
+                               :source_page_title page-title
+                               :source_block_uuid (str page-uuid)
+                               :source_block_content nil
+                               :source_block_format "page"
+                               :updated_at (.now js/Date)})
+                            page-tags))
+        block-entries (mapcat (fn [[_e entity]]
+                                (when (and (= (:block/page entity) page-eid)
+                                           (not= (:block/uuid entity) page-uuid)
+                                           (not (:logseq.property/created-from-property entity)))
+                                  (let [block-uuid (some-> (:block/uuid entity) str)
+                                        block-content (or (:block/content entity)
+                                                          (:block/title entity)
+                                                          (:block/name entity)
+                                                          "")
+                                        block-format (name (or (:block/format entity) :markdown))
+                                        tags (normalize-tags (:block/tags entity))]
+                                    (when (seq tags)
+                                      (map (fn [tag]
+                                             {:graph_uuid graph-uuid
+                                              :tag_page_uuid (:tag_page_uuid tag)
+                                              :tag_title (:tag_title tag)
+                                              :source_page_uuid (str page-uuid)
+                                              :source_page_title page-title
+                                              :source_block_uuid block-uuid
+                                              :source_block_content block-content
+                                              :source_block_format block-format
+                                              :updated_at (.now js/Date)})
+                                           tags)))))
+                              entities)]
+    (vec (distinct (concat page-entries block-entries)))))

+ 457 - 0
deps/publish/src/logseq/publish/meta_store.cljs

@@ -0,0 +1,457 @@
+(ns logseq.publish.meta-store
+  (:require [clojure.string :as string]
+            [logseq.publish.common :as publish-common])
+  (:require-macros [logseq.publish.async :refer [js-await]]))
+
+(defn init-schema! [sql]
+  (let [cols (publish-common/get-sql-rows (publish-common/sql-exec sql "PRAGMA table_info(pages);"))
+        drop? (some #(contains? #{"page_id" "graph"} (aget % "name")) cols)]
+    (when drop?
+      (publish-common/sql-exec sql "DROP TABLE IF EXISTS pages;"))
+    (publish-common/sql-exec sql
+                             (str "CREATE TABLE IF NOT EXISTS pages ("
+                                  "page_uuid TEXT NOT NULL,"
+                                  "page_title TEXT,"
+                                  "page_tags TEXT,"
+                                  "graph_uuid TEXT NOT NULL,"
+                                  "schema_version TEXT,"
+                                  "block_count INTEGER,"
+                                  "content_hash TEXT NOT NULL,"
+                                  "content_length INTEGER,"
+                                  "r2_key TEXT NOT NULL,"
+                                  "owner_sub TEXT,"
+                                  "owner_username TEXT,"
+                                  "created_at INTEGER,"
+                                  "updated_at INTEGER,"
+                                  "password_hash TEXT,"
+                                  "PRIMARY KEY (graph_uuid, page_uuid)"
+                                  ");"))
+    (let [cols (publish-common/get-sql-rows (publish-common/sql-exec sql "PRAGMA table_info(pages);"))
+          col-names (set (map #(aget % "name") cols))]
+      (when-not (contains? col-names "page_title")
+        (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN page_title TEXT;"))
+      (when-not (contains? col-names "page_tags")
+        (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN page_tags TEXT;"))
+      (when-not (contains? col-names "short_id")
+        (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN short_id TEXT;"))
+      (when-not (contains? col-names "owner_username")
+        (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN owner_username TEXT;"))
+      (when-not (contains? col-names "password_hash")
+        (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN password_hash TEXT;")))
+    (let [cols (publish-common/get-sql-rows (publish-common/sql-exec sql "PRAGMA table_info(page_refs);"))
+          col-names (set (map #(aget % "name") cols))]
+      (when (seq col-names)
+        (when-not (contains? col-names "target_page_title")
+          (publish-common/sql-exec sql "ALTER TABLE page_refs ADD COLUMN target_page_title TEXT;"))
+        (when-not (contains? col-names "target_page_name")
+          (publish-common/sql-exec sql "ALTER TABLE page_refs ADD COLUMN target_page_name TEXT;"))))
+    (publish-common/sql-exec sql
+                             (str "CREATE TABLE IF NOT EXISTS page_refs ("
+                                  "graph_uuid TEXT NOT NULL,"
+                                  "target_page_uuid TEXT NOT NULL,"
+                                  "target_page_title TEXT,"
+                                  "target_page_name TEXT,"
+                                  "source_page_uuid TEXT NOT NULL,"
+                                  "source_page_title TEXT,"
+                                  "source_block_uuid TEXT,"
+                                  "source_block_content TEXT,"
+                                  "source_block_format TEXT,"
+                                  "updated_at INTEGER,"
+                                  "PRIMARY KEY (graph_uuid, target_page_uuid, source_block_uuid)"
+                                  ");"))
+    (publish-common/sql-exec sql
+                             (str "CREATE TABLE IF NOT EXISTS page_tags ("
+                                  "graph_uuid TEXT NOT NULL,"
+                                  "tag_page_uuid TEXT NOT NULL,"
+                                  "tag_title TEXT,"
+                                  "source_page_uuid TEXT NOT NULL,"
+                                  "source_page_title TEXT,"
+                                  "source_block_uuid TEXT NOT NULL,"
+                                  "source_block_content TEXT,"
+                                  "source_block_format TEXT,"
+                                  "updated_at INTEGER,"
+                                  "PRIMARY KEY (graph_uuid, tag_page_uuid, source_block_uuid)"
+                                  ");"))
+    (publish-common/sql-exec sql
+                             (str "CREATE TABLE IF NOT EXISTS page_blocks ("
+                                  "graph_uuid TEXT NOT NULL,"
+                                  "page_uuid TEXT NOT NULL,"
+                                  "block_uuid TEXT NOT NULL,"
+                                  "block_content TEXT,"
+                                  "updated_at INTEGER,"
+                                  "PRIMARY KEY (graph_uuid, block_uuid)"
+                                  ");"))))
+
+(defn parse-page-tags [value]
+  (cond
+    (nil? value) #js []
+    (array? value) value
+    (string? value) (try
+                      (js/JSON.parse value)
+                      (catch :default _
+                        #js []))
+    :else #js []))
+
+(defn row->meta [row]
+  (let [data (js->clj row :keywordize-keys false)
+        page-tags (parse-page-tags (get data "page_tags"))
+        short-id (get data "short_id")]
+    (assoc data
+           "graph" (get data "graph_uuid")
+           "page_tags" page-tags
+           "short_id" short-id
+           "short_url" (when short-id (str "/p/" short-id))
+           "content_hash" (get data "content_hash")
+           "content_length" (get data "content_length"))))
+
+(defn do-fetch [^js self request]
+  (let [sql (.-sql self)]
+    (init-schema! sql)
+    (cond
+      (= "POST" (.-method request))
+      (js-await [body (.json request)]
+                (let [page-uuid (aget body "page_uuid")
+                      graph-uuid (aget body "graph")]
+                  (if (and (string? page-uuid) (string? graph-uuid))
+                    (publish-common/sql-exec sql
+                                             (str "INSERT INTO pages ("
+                                                  "page_uuid,"
+                                                  "page_title,"
+                                                  "page_tags,"
+                                                  "graph_uuid,"
+                                                  "schema_version,"
+                                                  "block_count,"
+                                                  "content_hash,"
+                                                  "content_length,"
+                                                  "r2_key,"
+                                                  "owner_sub,"
+                                                  "owner_username,"
+                                                  "created_at,"
+                                                  "updated_at,"
+                                                  "short_id,"
+                                                  "password_hash"
+                                                  ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
+                                                  " ON CONFLICT(graph_uuid, page_uuid) DO UPDATE SET"
+                                                  " page_uuid=excluded.page_uuid,"
+                                                  " page_title=excluded.page_title,"
+                                                  " page_tags=excluded.page_tags,"
+                                                  " schema_version=excluded.schema_version,"
+                                                  " block_count=excluded.block_count,"
+                                                  " content_hash=excluded.content_hash,"
+                                                  " content_length=excluded.content_length,"
+                                                  " r2_key=excluded.r2_key,"
+                                                  " owner_sub=excluded.owner_sub,"
+                                                  " owner_username=excluded.owner_username,"
+                                                  " updated_at=excluded.updated_at,"
+                                                  " short_id=excluded.short_id,"
+                                                  " password_hash=excluded.password_hash;")
+                                             page-uuid
+                                             (aget body "page_title")
+                                             (aget body "page_tags")
+                                             graph-uuid
+                                             (aget body "schema_version")
+                                             (aget body "block_count")
+                                             (aget body "content_hash")
+                                             (aget body "content_length")
+                                             (aget body "r2_key")
+                                             (aget body "owner_sub")
+                                             (aget body "owner_username")
+                                             (aget body "created_at")
+                                             (aget body "updated_at")
+                                             (aget body "short_id")
+                                             (aget body "password_hash"))
+                    (throw (js/Error. "publish: missing page_uuid or graph")))
+                  (let [refs (aget body "refs")
+                        tagged-nodes (aget body "tagged_nodes")
+                        blocks (aget body "blocks")
+                        graph-uuid (aget body "graph")
+                        page-uuid (aget body "page_uuid")]
+                    (when (and graph-uuid page-uuid)
+                      (publish-common/sql-exec sql
+                                               "DELETE FROM page_refs WHERE graph_uuid = ? AND source_page_uuid = ?;"
+                                               graph-uuid
+                                               page-uuid)
+                      (publish-common/sql-exec sql
+                                               "DELETE FROM page_tags WHERE graph_uuid = ? AND source_page_uuid = ?;"
+                                               graph-uuid
+                                               page-uuid)
+                      (doseq [ref refs]
+                        (publish-common/sql-exec sql
+                                                 (str "INSERT OR REPLACE INTO page_refs ("
+                                                      "graph_uuid, target_page_uuid, target_page_title, target_page_name, source_page_uuid, "
+                                                      "source_page_title, source_block_uuid, source_block_content, "
+                                                      "source_block_format, updated_at"
+                                                      ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);")
+                                                 (aget ref "graph_uuid")
+                                                 (aget ref "target_page_uuid")
+                                                 (aget ref "target_page_title")
+                                                 (aget ref "target_page_name")
+                                                 (aget ref "source_page_uuid")
+                                                 (aget ref "source_page_title")
+                                                 (aget ref "source_block_uuid")
+                                                 (aget ref "source_block_content")
+                                                 (aget ref "source_block_format")
+                                                 (aget ref "updated_at")))
+
+                      (doseq [tag tagged-nodes]
+                        (publish-common/sql-exec sql
+                                                 (str "INSERT OR REPLACE INTO page_tags ("
+                                                      "graph_uuid, tag_page_uuid, tag_title, source_page_uuid, "
+                                                      "source_page_title, source_block_uuid, source_block_content, "
+                                                      "source_block_format, updated_at"
+                                                      ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);")
+                                                 (aget tag "graph_uuid")
+                                                 (aget tag "tag_page_uuid")
+                                                 (aget tag "tag_title")
+                                                 (aget tag "source_page_uuid")
+                                                 (aget tag "source_page_title")
+                                                 (aget tag "source_block_uuid")
+                                                 (aget tag "source_block_content")
+                                                 (aget tag "source_block_format")
+                                                 (aget tag "updated_at"))))
+                    (publish-common/sql-exec sql
+                                             "DELETE FROM page_blocks WHERE graph_uuid = ? AND page_uuid = ?;"
+                                             graph-uuid
+                                             page-uuid)
+                    (doseq [block blocks]
+                      (publish-common/sql-exec sql
+                                               (str "INSERT OR REPLACE INTO page_blocks ("
+                                                    "graph_uuid, page_uuid, block_uuid, block_content, updated_at"
+                                                    ") VALUES (?, ?, ?, ?, ?);")
+                                               (aget body "graph")
+                                               (aget block "page_uuid")
+                                               (aget block "block_uuid")
+                                               (aget block "block_content")
+                                               (aget block "updated_at"))))
+                  (publish-common/json-response {:ok true})))
+
+      (= "GET" (.-method request))
+      (let [url (js/URL. (.-url request))
+            parts (string/split (.-pathname url) #"/")
+            graph-uuid (nth parts 2 nil)
+            page-uuid (nth parts 3 nil)]
+        (cond
+          (= (nth parts 1 nil) "search")
+          (let [graph-uuid (nth parts 2 nil)
+                query (.get (.-searchParams url) "q")
+                query (some-> query string/trim)
+                query (when (and query (not (string/blank? query)))
+                        (string/lower-case query))]
+            (if (or (string/blank? graph-uuid) (string/blank? query))
+              (publish-common/bad-request "missing graph uuid or query")
+              (let [like-query (str "%" query "%")
+                    pages (publish-common/get-sql-rows
+                           (publish-common/sql-exec sql
+                                                    (str "SELECT page_uuid, page_title, short_id "
+                                                         "FROM pages "
+                                                         "WHERE graph_uuid = ? "
+                                                         "AND password_hash IS NULL "
+                                                         "AND page_title IS NOT NULL "
+                                                         "AND lower(page_title) LIKE ? "
+                                                         "ORDER BY updated_at DESC "
+                                                         "LIMIT 20;")
+                                                    graph-uuid
+                                                    like-query))
+                    blocks (publish-common/get-sql-rows
+                            (publish-common/sql-exec sql
+                                                     (str "SELECT page_blocks.page_uuid, page_blocks.block_uuid, "
+                                                          "page_blocks.block_content, pages.page_title, pages.short_id "
+                                                          "FROM page_blocks "
+                                                          "LEFT JOIN pages "
+                                                          "ON pages.graph_uuid = page_blocks.graph_uuid "
+                                                          "AND pages.page_uuid = page_blocks.page_uuid "
+                                                          "WHERE page_blocks.graph_uuid = ? "
+                                                          "AND pages.password_hash IS NULL "
+                                                          "AND page_blocks.block_content IS NOT NULL "
+                                                          "AND lower(page_blocks.block_content) LIKE ? "
+                                                          "ORDER BY page_blocks.updated_at DESC "
+                                                          "LIMIT 50;")
+                                                     graph-uuid
+                                                     like-query))]
+                (publish-common/json-response {:pages pages :blocks blocks}))))
+
+          (= (nth parts 1 nil) "tag")
+          (let [tag-name (when-let [raw (nth parts 2 nil)]
+                           (js/decodeURIComponent raw))
+                tagged-rows (publish-common/get-sql-rows
+                             (publish-common/sql-exec sql
+                                                      (str "SELECT page_tags.graph_uuid, page_tags.tag_page_uuid, page_tags.tag_title, "
+                                                           "page_tags.source_page_uuid, page_tags.source_page_title, page_tags.source_block_uuid, "
+                                                           "page_tags.source_block_content, page_tags.source_block_format, page_tags.updated_at, "
+                                                           "pages.short_id "
+                                                           "FROM page_tags "
+                                                           "LEFT JOIN pages "
+                                                           "ON pages.graph_uuid = page_tags.graph_uuid "
+                                                           "AND pages.page_uuid = page_tags.source_page_uuid "
+                                                           "WHERE page_tags.tag_title = ? "
+                                                           "ORDER BY page_tags.updated_at DESC;")
+                                                      tag-name))
+                page-rows (publish-common/get-sql-rows
+                           (publish-common/sql-exec sql
+                                                    (str "SELECT page_tags.graph_uuid, page_tags.source_page_uuid, page_tags.source_page_title, "
+                                                         "pages.short_id, "
+                                                         "MAX(page_tags.updated_at) AS updated_at "
+                                                         "FROM page_tags "
+                                                         "LEFT JOIN pages "
+                                                         "ON pages.graph_uuid = page_tags.graph_uuid "
+                                                         "AND pages.page_uuid = page_tags.source_page_uuid "
+                                                         "WHERE page_tags.tag_title = ? "
+                                                         "GROUP BY page_tags.graph_uuid, page_tags.source_page_uuid, page_tags.source_page_title, pages.short_id "
+                                                         "ORDER BY updated_at DESC;")
+                                                    tag-name))]
+            (publish-common/json-response {:pages (map (fn [row]
+                                                         (js->clj row :keywordize-keys false))
+                                                       page-rows)
+                                           :tagged_nodes (map (fn [row]
+                                                                (js->clj row :keywordize-keys false))
+                                                              tagged-rows)}))
+
+          (= (nth parts 1 nil) "ref")
+          (let [ref-name (when-let [raw (nth parts 2 nil)]
+                           (js/decodeURIComponent raw))
+                rows (publish-common/get-sql-rows
+                      (publish-common/sql-exec sql
+                                               (str "SELECT page_refs.graph_uuid, page_refs.source_page_uuid, page_refs.source_page_title, "
+                                                    "pages.short_id, "
+                                                    "MAX(page_refs.updated_at) AS updated_at "
+                                                    "FROM page_refs "
+                                                    "LEFT JOIN pages "
+                                                    "ON pages.graph_uuid = page_refs.graph_uuid "
+                                                    "AND pages.page_uuid = page_refs.source_page_uuid "
+                                                    "WHERE (lower(page_refs.target_page_title) = lower(?)) "
+                                                    "OR (page_refs.target_page_name = lower(?)) "
+                                                    "GROUP BY page_refs.graph_uuid, page_refs.source_page_uuid, page_refs.source_page_title, pages.short_id "
+                                                    "ORDER BY updated_at DESC;")
+                                               ref-name
+                                               ref-name))]
+            (publish-common/json-response {:pages (map (fn [row]
+                                                         (js->clj row :keywordize-keys false))
+                                                       rows)}))
+
+          (= (nth parts 1 nil) "short")
+          (let [short-id (nth parts 2 nil)
+                rows (publish-common/get-sql-rows
+                      (publish-common/sql-exec sql
+                                               (str "SELECT page_uuid, graph_uuid, page_title, short_id "
+                                                    "FROM pages WHERE short_id = ? LIMIT 1;")
+                                               short-id))
+                row (first rows)]
+            (publish-common/json-response {:page (when row (js->clj row :keywordize-keys false))}))
+
+          (= (nth parts 1 nil) "user")
+          (let [raw-username (nth parts 2 nil)
+                username (when raw-username (js/decodeURIComponent raw-username))
+                rows (publish-common/get-sql-rows
+                      (publish-common/sql-exec sql
+                                               (str "SELECT page_uuid, page_title, short_id, graph_uuid, updated_at, owner_username "
+                                                    "FROM pages WHERE owner_username = ? ORDER BY updated_at DESC;")
+                                               username))]
+            (publish-common/json-response {:user {:username username}
+                                           :pages (map (fn [row]
+                                                         (js->clj row :keywordize-keys false))
+                                                       rows)}))
+
+          (= (nth parts 4 nil) "password")
+          (let [rows (publish-common/get-sql-rows
+                      (publish-common/sql-exec sql
+                                               (str "SELECT password_hash "
+                                                    "FROM pages WHERE graph_uuid = ? AND page_uuid = ? LIMIT 1;")
+                                               graph-uuid
+                                               page-uuid))
+                row (first rows)]
+            (if-not row
+              (publish-common/not-found)
+              (publish-common/json-response {:password_hash (aget row "password_hash")})))
+
+          (= (nth parts 4 nil) "refs")
+          (let [rows (publish-common/get-sql-rows
+                      (publish-common/sql-exec sql
+                                               (str "SELECT graph_uuid, target_page_uuid, source_page_uuid, "
+                                                    "target_page_title, target_page_name, source_page_title, source_block_uuid, source_block_content, "
+                                                    "source_block_format, updated_at "
+                                                    "FROM page_refs WHERE graph_uuid = ? AND target_page_uuid = ? "
+                                                    "ORDER BY updated_at DESC;")
+                                               graph-uuid
+                                               page-uuid))]
+            (publish-common/json-response {:refs (map (fn [row]
+                                                        (js->clj row :keywordize-keys false))
+                                                      rows)}))
+
+          (= (nth parts 4 nil) "tagged_nodes")
+          (let [rows (publish-common/get-sql-rows
+                      (publish-common/sql-exec sql
+                                               (str "SELECT graph_uuid, tag_page_uuid, tag_title, source_page_uuid, "
+                                                    "source_page_title, source_block_uuid, source_block_content, "
+                                                    "source_block_format, updated_at "
+                                                    "FROM page_tags WHERE graph_uuid = ? AND tag_page_uuid = ? "
+                                                    "ORDER BY updated_at DESC;")
+                                               graph-uuid
+                                               page-uuid))]
+            (publish-common/json-response {:tagged_nodes (map (fn [row]
+                                                                (js->clj row :keywordize-keys false))
+                                                              rows)}))
+
+          (and graph-uuid page-uuid)
+          (let [rows (publish-common/get-sql-rows
+                      (publish-common/sql-exec sql
+                                               (str "SELECT page_uuid, page_title, page_tags, short_id, graph_uuid, schema_version, block_count, "
+                                                    "content_hash, content_length, r2_key, owner_sub, owner_username, created_at, updated_at "
+                                                    "FROM pages WHERE graph_uuid = ? AND page_uuid = ? LIMIT 1;")
+                                               graph-uuid
+                                               page-uuid))
+                row (first rows)]
+            (if-not row
+              (publish-common/not-found)
+              (publish-common/json-response (row->meta row))))
+
+          graph-uuid
+          (let [rows (publish-common/get-sql-rows
+                      (publish-common/sql-exec sql
+                                               (str "SELECT page_uuid, page_title, page_tags, short_id, graph_uuid, schema_version, block_count, "
+                                                    "content_hash, content_length, r2_key, owner_sub, owner_username, created_at, updated_at "
+                                                    "FROM pages WHERE graph_uuid = ? ORDER BY updated_at DESC;")
+                                               graph-uuid))]
+            (publish-common/json-response {:pages (map row->meta rows)}))
+
+          :else
+          (let [rows (publish-common/get-sql-rows
+                      (publish-common/sql-exec sql
+                                               (str "SELECT page_uuid, page_title, page_tags, short_id, graph_uuid, schema_version, block_count, "
+                                                    "content_hash, content_length, r2_key, owner_sub, owner_username, created_at, updated_at "
+                                                    "FROM pages ORDER BY updated_at DESC;")))]
+            (publish-common/json-response {:pages (map row->meta rows)}))))
+
+      (= "DELETE" (.-method request))
+      (let [url (js/URL. (.-url request))
+            parts (string/split (.-pathname url) #"/")
+            graph-uuid (nth parts 2 nil)
+            page-uuid (nth parts 3 nil)]
+        (cond
+          (and graph-uuid page-uuid)
+          (do
+            (publish-common/sql-exec sql
+                                     "DELETE FROM pages WHERE graph_uuid = ? AND page_uuid = ?;"
+                                     graph-uuid
+                                     page-uuid)
+            (publish-common/sql-exec sql
+                                     "DELETE FROM page_refs WHERE graph_uuid = ? AND source_page_uuid = ?;"
+                                     graph-uuid
+                                     page-uuid)
+            (publish-common/sql-exec sql
+                                     "DELETE FROM page_tags WHERE graph_uuid = ? AND source_page_uuid = ?;"
+                                     graph-uuid
+                                     page-uuid)
+            (publish-common/json-response {:ok true}))
+
+          graph-uuid
+          (do
+            (publish-common/sql-exec sql "DELETE FROM pages WHERE graph_uuid = ?;" graph-uuid)
+            (publish-common/sql-exec sql "DELETE FROM page_refs WHERE graph_uuid = ?;" graph-uuid)
+            (publish-common/sql-exec sql "DELETE FROM page_tags WHERE graph_uuid = ?;" graph-uuid)
+            (publish-common/json-response {:ok true}))
+
+          :else
+          (publish-common/bad-request "missing graph uuid or page uuid")))
+
+      :else
+      (publish-common/json-response {:error "method not allowed"} 405))))

+ 41 - 0
deps/publish/src/logseq/publish/model.cljs

@@ -0,0 +1,41 @@
+(ns logseq.publish.model)
+
+(defn merge-attr
+  [entity attr value]
+  (let [existing (get entity attr ::none)]
+    (cond
+      (= existing ::none) (assoc entity attr value)
+      (vector? existing) (assoc entity attr (conj existing value))
+      (set? existing) (assoc entity attr (conj existing value))
+      :else (assoc entity attr [existing value]))))
+
+(defn datoms->entities
+  [datoms]
+  (reduce
+   (fn [acc datom]
+     (let [[e a v _tx added?] datom]
+       (if added?
+         (update acc e (fn [entity]
+                         (merge-attr (or entity {:db/id e}) a v)))
+         acc)))
+   {}
+   datoms))
+
+(defn entity->title
+  [entity]
+  (or (:block/title entity)
+      (:block/name entity)
+      (str (:logseq.property/value entity))
+      "Untitled"))
+
+(defn page-entity?
+  [entity]
+  (and (nil? (:block/page entity))
+       (or (:block/name entity)
+           (:block/title entity))))
+
+(defn ref-eid [value]
+  (cond
+    (number? value) value
+    (map? value) (:db/id value)
+    :else nil))

+ 945 - 0
deps/publish/src/logseq/publish/publish.css

@@ -0,0 +1,945 @@
+@import url("https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css");
+@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap");
+
+:root {
+  color-scheme: light;
+  --bg: #fffcf0;
+  --bg-accent: #f2f0e5;
+  --surface: #fffcf0;
+  --surface-strong: #f2f0e5;
+  --ink: #282726;
+  --muted: #6f6e69;
+  --border: #cecdc3;
+  --link: #282726;
+  --action: #24837B;
+  --shadow: 0 18px 40px rgba(40, 39, 38, 0.1);
+  --bg-gradient-1: rgba(218, 112, 44, 0.12);
+  --bg-gradient-2: rgba(67, 133, 190, 0.1);
+  --button-bg: #f2e9d6;
+  --button-bg-hover: #efe0c2;
+  --code-bg: #1f2933;
+  --code-ink: #f8f4ec;
+  --code-muted: #b59d82;
+  --math-bg: #f6ede2;
+  --quote-border: #282726;
+  --card-bg: #fff7ee;
+  --input-bg: #fffcf0;
+  --image-shadow: 0 12px 24px rgba(40, 39, 38, 0.08);
+  --icon-day: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='currentColor' %3E%3Cpath fill-rule='evenodd' d='M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z' clip-rule='evenodd' /%3E%3C/svg%3E");
+  --icon-night: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24' %3E%3Cpath d='M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z' /%3E%3C/svg%3E");
+}
+
+[data-theme="dark"] {
+  color-scheme: dark;
+  --bg: #100f0f;
+  --bg-accent: #1c1b1a;
+  --surface: #1c1b1a;
+  --surface-strong: #282726;
+  --ink: #e6e4d9;
+  --muted: #b7b5ac;
+  --border: #403e3c;
+  --link: #e6e4d9;
+  --action: #3AA99F;
+  --shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
+  --bg-gradient-1: rgba(218, 112, 44, 0.18);
+  --bg-gradient-2: rgba(67, 133, 190, 0.14);
+  --button-bg: #2f2c2b;
+  --button-bg-hover: #3a3635;
+  --code-bg: #1a1f24;
+  --code-ink: #f8f4ec;
+  --code-muted: #a3a091;
+  --math-bg: #242220;
+  --quote-border: #e6e4d9;
+  --card-bg: #1f1d1c;
+  --input-bg: #1c1b1a;
+  --image-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  background:
+    radial-gradient(1200px 600px at 10% -10%, var(--bg-gradient-1), transparent 60%),
+    radial-gradient(900px 400px at 90% 0%, var(--bg-gradient-2), transparent 60%),
+    linear-gradient(180deg, var(--bg) 0%, var(--bg-accent) 100%);
+  color: var(--ink);
+  font-family: "Inter", "Segoe UI", sans-serif;
+  line-height: 1.65;
+  letter-spacing: 0.01em;
+}
+
+.publish-home {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 32px;
+  overflow: hidden;
+}
+
+.publish-home-bg {
+  position: fixed;
+  inset: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 0;
+  pointer-events: none;
+}
+
+.publish-home-card {
+  position: relative;
+  z-index: 1;
+  max-width: 520px;
+  padding: 32px 28px 28px;
+  border-radius: 20px;
+  background: var(--surface);
+  border: 1px solid var(--border);
+  box-shadow: var(--shadow);
+  text-align: center;
+}
+
+.publish-home-logo {
+  font-size: 12px;
+  letter-spacing: 0.18em;
+  text-transform: uppercase;
+  color: var(--muted);
+  margin-bottom: 18px;
+}
+
+.publish-home-title {
+  margin: 0 0 12px;
+  font-size: clamp(26px, 3.6vw, 36px);
+  font-weight: 600;
+  letter-spacing: -0.02em;
+  color: var(--ink);
+}
+
+.publish-home-subtitle {
+  margin: 0;
+  font-size: 14px;
+  color: var(--muted);
+  line-height: 1.6;
+}
+
+.publish-home-subtitle code {
+  font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+  font-size: 12px;
+  background: var(--bg-accent);
+  padding: 1px 4px;
+  border-radius: 4px;
+}
+
+@media (max-width: 600px) {
+  .publish-home-card {
+    margin: 0 16px;
+    padding: 24px 20px 22px;
+  }
+}
+
+.wrap {
+  max-width: 920px;
+  margin: 32px auto 56px;
+  padding: 32px 28px 40px;
+  overflow-x: hidden;
+}
+
+h1 {
+  font-size: clamp(30px, 3.4vw, 44px);
+  margin-top: 1.5em;
+  margin-bottom: 0.25em;
+  font-weight: 600;
+  letter-spacing: 0.01em;
+}
+
+h2 {
+  font-weight: 500;
+  letter-spacing: 0.01em;
+}
+
+.block-heading {
+  margin: 0;
+  font-weight: 600;
+  line-height: 1.4;
+}
+
+h1.block-heading {
+  font-size: 1.6em;
+}
+
+h2.block-heading {
+  font-size: 1.4em;
+}
+
+h3.block-heading {
+  font-size: 1.25em;
+}
+
+h4.block-heading {
+  font-size: 1.1em;
+}
+
+h5.block-heading,
+h6.block-heading {
+  font-size: 1em;
+}
+
+a {
+  color: var(--link);
+  text-decoration: underline;
+}
+
+a:hover {
+  color: var(--action);
+}
+
+.page-toolbar {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+  justify-content: flex-end;
+  flex-wrap: wrap;
+  padding: 12px 0;
+  margin: -8px 0 24px;
+}
+
+.page-toolbar .toolbar-btn:first-child {
+  margin-right: auto;
+}
+
+.publish-search {
+  position: relative;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 240px;
+  flex-direction: row-reverse;
+  height: 32px;
+}
+
+.publish-search-toggle {
+  border: none;
+  background: var(--button-bg);
+  color: var(--ink);
+  width: 32px;
+  height: 32px;
+  padding: 0;
+  border-radius: 999px;
+  cursor: pointer;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
+}
+
+.publish-search-toggle:hover {
+  background: var(--button-bg-hover);
+  box-shadow: 0 6px 14px rgba(40, 39, 38, 0.12);
+  transform: translateY(-1px);
+}
+
+.publish-search-toggle .ti {
+  font-size: 16px;
+  transition: transform 0.2s ease, opacity 0.2s ease;
+}
+
+.publish-search-input {
+  width: 0;
+  opacity: 0;
+  padding: 0 12px;
+  border: 1px solid var(--border);
+  background: var(--input-bg);
+  color: var(--ink);
+  font-size: 13px;
+  border-radius: 999px;
+  pointer-events: none;
+  transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease;
+}
+
+.publish-search.is-expanded .publish-search-input {
+  width: min(320px, 70vw);
+  padding: 8px 12px;
+  opacity: 1;
+  pointer-events: auto;
+}
+
+.publish-search-input:focus {
+  outline: none;
+}
+
+.publish-search-input:disabled {
+  opacity: 0.6;
+  cursor: not-allowed;
+}
+
+.publish-search-results {
+  opacity: 0;
+  transform: translateY(-6px);
+  position: absolute;
+  right: 0;
+  top: calc(100% + 24px);
+  z-index: 10;
+  width: min(480px, 90vw);
+  max-height: 320px;
+  overflow: auto;
+  border-radius: 16px;
+  background: var(--surface-strong);
+  box-shadow: var(--shadow);
+  transition: opacity 0.2s ease, transform 0.2s ease;
+}
+
+.publish-search.is-expanded .publish-search-results:not([hidden]) {
+  opacity: 1;
+  transform: translateY(0);
+}
+
+.publish-search-hint {
+  position: absolute;
+  right: 40px;
+  top: calc(100% + 6px);
+  font-size: 10px;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--muted);
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity 0.2s ease;
+}
+
+.publish-search.is-expanded .publish-search-hint {
+  opacity: 1;
+}
+.publish-search-list {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.publish-search-section {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.18em;
+  color: var(--muted);
+  margin: 6px 6px 2px;
+}
+
+.publish-search-result {
+  background: transparent;
+  text-align: left;
+  display: grid;
+  padding: 4px 12px;
+  cursor: pointer;
+  color: var(--ink);
+  text-decoration: none;
+}
+
+.publish-search-result:hover {
+  background: var(--surface);
+}
+
+.publish-search-result:first-child {
+  padding-top: 12px;
+}
+
+.publish-search-result:last-child {
+  padding-bottom: 12px;
+}
+
+.publish-search-result.is-active {
+  background: var(--surface);
+}
+
+.publish-search-kind {
+  font-size: 10px;
+  letter-spacing: 0.16em;
+  text-transform: uppercase;
+  color: var(--muted);
+}
+
+.publish-search-title {
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.publish-search-snippet {
+  font-size: 12px;
+  color: var(--muted);
+}
+
+.publish-search-empty {
+  font-size: 12px;
+  color: var(--muted);
+  padding: 8px;
+}
+
+.toolbar-btn {
+  background: transparent;
+  border: none;
+  color: var(--ink);
+  font-size: 13px;
+  font-weight: 500;
+  letter-spacing: 0.08em;
+  cursor: pointer;
+  text-decoration: none;
+  display: inline-flex;
+  align-items: center;
+  transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
+}
+
+.theme-toggle {
+  position: relative;
+  width: 62px;
+  height: 30px;
+  padding: 0 8px;
+  background: var(--button-bg);
+  border: none;
+  border-radius: 999px;
+  color: var(--ink);
+  display: inline-flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  transition: background 0.2s ease, box-shadow 0.2s ease;
+}
+
+.theme-toggle:hover {
+  background: var(--button-bg-hover);
+  box-shadow: 0 6px 16px rgba(40, 39, 38, 0.12);
+}
+
+.theme-toggle__thumb {
+  position: absolute;
+  top: 3px;
+  left: 3px;
+  width: 24px;
+  height: 24px;
+  border-radius: 999px;
+  background: var(--surface);
+  box-shadow: 0 4px 10px rgba(40, 39, 38, 0.12);
+  transition: transform 0.2s ease;
+}
+
+.theme-toggle.is-dark .theme-toggle__thumb {
+  transform: translateX(32px);
+}
+
+.theme-toggle__icon {
+  width: 14px;
+  height: 14px;
+  background-color: currentColor;
+  opacity: 0.6;
+  transition: opacity 0.2s ease;
+  mask-repeat: no-repeat;
+  mask-position: center;
+  mask-size: contain;
+  -webkit-mask-repeat: no-repeat;
+  -webkit-mask-position: center;
+  -webkit-mask-size: contain;
+}
+
+.theme-toggle__icon--day {
+  mask-image: var(--icon-day);
+  -webkit-mask-image: var(--icon-day);
+}
+
+.theme-toggle__icon--night {
+  mask-image: var(--icon-night);
+  -webkit-mask-image: var(--icon-night);
+}
+
+.theme-toggle.is-dark .theme-toggle__icon--night,
+.theme-toggle:not(.is-dark) .theme-toggle__icon--day {
+  opacity: 1;
+}
+
+.blocks {
+  margin: 0;
+  padding-left: 0;
+  list-style: none;
+}
+
+.block-children .blocks {
+  list-style: initial;
+  padding-left: 18px;
+}
+
+.block {
+  margin: 8px 0;
+}
+
+.block-content {
+  white-space: pre-wrap;
+  display: flex;
+  gap: 4px;
+  align-items: flex-start;
+}
+
+.positioned-properties {
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+  flex-wrap: wrap;
+}
+
+.positioned-properties.block-left, .positioned-properties.block-right {
+  display: flex;
+  align-items: center;
+  margin-top: 2px;
+}
+
+.positioned-properties.block-right {
+  margin-left: auto;
+}
+
+.positioned-properties.block-below {
+  margin: 6px 0 0 22px;
+  gap: 8px 12px;
+}
+
+.positioned-property {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.positioned-property .property-name {
+  color: var(--muted);
+  font-weight: 500;
+  font-size: 11px;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+}
+
+.positioned-property .property-value {
+  color: var(--ink);
+}
+
+.property-icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 1rem;
+  height: 1rem;
+  line-height: 1;
+  font-size: 1rem;
+  color: currentColor;
+}
+
+.property-icon svg {
+  width: 1rem;
+  height: 1rem;
+}
+
+.property-value-with-icon {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.block-text {
+  flex: 1;
+  font-size: 16px;
+}
+
+.macro-embed {
+  width: min(100%, 560px);
+  aspect-ratio: 16 / 9;
+  margin: 10px 0;
+  border-radius: 14px;
+  overflow: hidden;
+  background: var(--surface-strong);
+  box-shadow: var(--image-shadow);
+}
+
+.macro-embed iframe {
+  width: 100%;
+  height: 100%;
+  border: 0;
+}
+
+.macro-embed--tweet {
+  aspect-ratio: 4 / 5;
+}
+
+.cloze {
+  padding: 0 6px;
+  border-radius: 6px;
+  background: var(--surface-strong);
+  box-shadow: inset 0 0 0 1px rgba(40, 39, 38, 0.12);
+}
+
+.code-block {
+  flex: 1;
+  position: relative;
+  margin: 1.5em 0;
+  border-radius: 14px;
+  overflow: hidden;
+  /* background: var(--code-bg); */
+}
+
+.code-block[data-lang]:before {
+  content: attr(data-lang);
+  position: absolute;
+  top: 10px;
+  right: 12px;
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.16em;
+  color: var(--code-muted);
+  z-index: 2;
+}
+
+.code-block .cm-editor {
+  height: auto;
+  /* background: var(--code-bg); */
+  /* color: var(--code-ink); */
+  font-size: 13px;
+  line-height: 1.6;
+}
+
+.code-block .cm-scroller {
+  font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
+}
+
+.ͼ2 .cm-gutters.cm-gutters-before {
+  border-right-width: 0;
+}
+
+.math-block {
+  flex: 1;
+  padding: 12px 14px;
+}
+
+.quote-block, blockquote {
+  padding-left: 1.5em;
+  margin: 1.5em 0;
+  border-left: 2px solid var(--quote-border);
+}
+
+.asset-image {
+  max-width: 100%;
+  border-radius: 14px;
+  box-shadow: var(--image-shadow);
+}
+
+.asset-video,
+.asset-audio {
+  width: 100%;
+}
+
+.asset-link {
+  color: var(--ink);
+  font-weight: 500;
+}
+
+.page-properties {
+  margin: 0 0 28px;
+  padding: 16px 18px;
+  border-radius: 16px;
+  background: var(--surface-strong);
+}
+
+.properties {
+  margin: 0;
+  display: grid;
+  grid-template-columns: 160px 1fr;
+  gap: 6px 16px;
+}
+
+.property {
+  display: contents;
+}
+
+.property-name {
+  margin: 0;
+  color: var(--muted);
+  font-weight: 500;
+  font-size: 13px;
+  letter-spacing: 0.08em;
+}
+
+.property-value {
+  margin: 0;
+  color: var(--ink);
+}
+
+.block-properties {
+  margin: 8px 0 0 22px;
+}
+
+.block-properties .properties {
+  grid-template-columns: 120px 1fr;
+  font-size: 13px;
+}
+
+.block-toggle {
+  border: none;
+  background: transparent;
+  cursor: pointer;
+  font-size: 14px;
+  line-height: 1;
+  margin-top: 4px;
+  margin-left: auto;
+  color: var(--muted);
+}
+
+.block.is-collapsed > .block-content > .block-toggle {
+  transform: rotate(-90deg);
+}
+
+.block-children {
+  margin-left: 16px;
+}
+
+.block.is-collapsed > .block-children {
+  display: none;
+}
+
+.linked-refs,
+.tagged-pages {
+  margin-top: 36px;
+}
+
+.linked-refs h2,
+.tagged-pages h2 {
+  font-size: 14px;
+  margin: 64px 0 16px 0;
+  color: var(--muted);
+}
+
+.ref-page {
+  margin: 0 0 16px;
+}
+
+.ref-blocks {
+  margin: 8px 0 0 18px;
+  padding: 0;
+  list-style: disc;
+}
+
+.ref-block {
+  margin: 6px 0;
+}
+
+.tagged-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.tagged-item {
+  padding: 12px 14px;
+  border-radius: 14px;
+  background: var(--surface-strong);
+  display: flex;
+  justify-content: space-between;
+  gap: 16px;
+  align-items: flex-start;
+}
+
+.tagged-main {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  min-width: 0;
+}
+
+.tagged-block {
+  font-size: 13px;
+  color: var(--ink);
+  white-space: pre-wrap;
+}
+
+.tagged-sub,
+.tagged-meta {
+  font-size: 12px;
+  color: var(--muted);
+  white-space: nowrap;
+}
+
+.graph-meta,
+.tag-sub {
+  color: var(--muted);
+  font-size: 13px;
+  margin: 0 0 20px;
+  letter-spacing: 0.12em;
+}
+
+.password-card {
+  margin: 20px auto 0;
+  max-width: 460px;
+  padding: 24px;
+  border-radius: 18px;
+  background: var(--card-bg);
+  box-shadow: 0 12px 24px rgba(40, 39, 38, 0.12);
+  text-align: center;
+}
+
+.password-form {
+  margin-top: 18px;
+  display: grid;
+  gap: 12px;
+}
+
+.password-label {
+  font-size: 12px;
+  text-transform: uppercase;
+  letter-spacing: 0.14em;
+  color: var(--muted);
+}
+
+.password-input {
+  width: 100%;
+  padding: 10px 12px;
+  border-radius: 10px;
+  border: none;
+  background: var(--input-bg);
+  box-shadow: inset 0 0 0 1px rgba(40, 39, 38, 0.1);
+  font-size: 15px;
+  font-family: inherit;
+}
+
+.password-input:focus {
+  outline: 2px solid var(--action);
+}
+
+.password-error {
+  margin: 8px 0 0;
+  color: #b42318;
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.not-found {
+  text-align: center;
+  padding: 32px 16px 8px;
+}
+
+.not-found-eyebrow {
+  font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
+  font-size: 14px;
+  letter-spacing: 0.4em;
+  text-transform: uppercase;
+  color: var(--ink);
+  margin: 0 0 12px;
+}
+
+.page-list {
+  list-style: none;
+  padding: 0;
+  margin: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.page-item {
+  padding: 14px 16px;
+  border-radius: 16px;
+  background: var(--surface-strong);
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  align-items: center;
+  transition: transform 0.15s ease, box-shadow 0.15s ease;
+}
+
+.page-item:hover {
+  transform: translateY(-1px);
+  box-shadow: 0 12px 24px rgba(40, 39, 38, 0.12);
+}
+
+.page-links {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  min-width: 0;
+}
+
+.short-link {
+  color: var(--muted);
+  font-size: 12px;
+  letter-spacing: 0.04em;
+}
+
+.page-link,
+.page-ref {
+  color: var(--link);
+  overflow-wrap: anywhere;
+}
+
+.page-updated-at {
+  color: var(--muted);
+  white-space: nowrap;
+  margin-left: 2px;
+}
+
+.page-authors {
+  color: var(--muted);
+  white-space: nowrap;
+  margin-left: 2px;
+}
+
+.page-meta {
+  display: flex;
+  margin-bottom: 2rem;
+  gap: 8px;
+  font-size: 13px;
+}
+
+@media (max-width: 720px) {
+  .wrap {
+    margin: 16px;
+    padding: 4px;
+  }
+
+  .page-toolbar {
+    gap: 10px;
+    justify-content: flex-end;
+  }
+
+  .publish-search {
+    min-width: 100%;
+  }
+
+  .publish-search-input {
+    width: 0;
+  }
+
+  .publish-search.is-expanded .publish-search-input {
+    width: 100%;
+  }
+
+  .block-children {
+    margin-left: 12px;
+  }
+
+  .block-children .blocks {
+    padding-left: 12px;
+  }
+
+  .properties {
+    grid-template-columns: 1fr;
+  }
+
+  .block-properties .properties {
+    grid-template-columns: 1fr;
+  }
+
+  .page-item,
+  .tagged-item {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+
+  .page-updated-at,
+  .page-authors,
+  .tagged-meta {
+    white-space: normal;
+  }
+}

+ 712 - 0
deps/publish/src/logseq/publish/publish.js

@@ -0,0 +1,712 @@
+import katexPkg from "https://esm.sh/[email protected]?bundle";
+
+// Core CodeMirror pieces
+import { EditorState } from "https://esm.sh/@codemirror/state@6";
+import {
+  EditorView,
+  lineNumbers,
+} from "https://esm.sh/@codemirror/view@6";
+
+// Highlighting
+import {
+  syntaxHighlighting,
+  defaultHighlightStyle,
+} from "https://esm.sh/@codemirror/language@6";
+
+// Languages
+import { javascript } from "https://esm.sh/@codemirror/lang-javascript@6";
+import { python } from "https://esm.sh/@codemirror/lang-python@6";
+import { html } from "https://esm.sh/@codemirror/lang-html@6";
+import { json } from "https://esm.sh/@codemirror/lang-json@6";
+import { markdown } from "https://esm.sh/@codemirror/lang-markdown@6";
+import { sql } from "https://esm.sh/@codemirror/lang-sql@6";
+import { css } from "https://esm.sh/@codemirror/lang-css@6";
+import { clojure } from "https://esm.sh/@nextjournal/lang-clojure";
+import emojiData from "https://esm.sh/@emoji-mart/data@1?bundle";
+
+const katex = katexPkg.default || katexPkg;
+const THEME_KEY = "publish-theme";
+
+document.addEventListener("click", (event) => {
+  const btn = event.target.closest(".block-toggle");
+  if (!btn) return;
+  const li = btn.closest("li.block");
+  if (!li) return;
+  const collapsed = li.classList.toggle("is-collapsed");
+  btn.setAttribute("aria-expanded", String(!collapsed));
+});
+
+const getEmojiNative = (id) => {
+  const emoji = emojiData?.emojis?.[id];
+  if (!emoji) return null;
+  return emoji?.skins?.[0]?.native || null;
+};
+
+const toKebabCase = (value) =>
+  (value || "")
+    .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
+    .replace(/([a-zA-Z])([0-9])/g, "$1-$2")
+    .replace(/([0-9])([a-zA-Z])/g, "$1-$2")
+    .replace(/[_\s]+/g, "-")
+    .replace(/-+/g, "-")
+    .toLowerCase();
+
+const toPascalCase = (value) =>
+  (value || "")
+    .split(/[^a-zA-Z0-9]+/)
+    .filter(Boolean)
+    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+    .join("");
+
+const toTablerIconName = (id) => {
+  if (!id) return null;
+  return id.startsWith("Icon") ? id : `Icon${toPascalCase(id)}`;
+};
+
+const svgNamespace = "http://www.w3.org/2000/svg";
+
+const isReactElement = (node) =>
+  node &&
+  typeof node === "object" &&
+  node.$$typeof &&
+  node.type &&
+  node.props;
+
+const setDomAttribute = (el, key, val, isSvg) => {
+  if (key === "className") {
+    el.setAttribute("class", val);
+    return;
+  }
+  if (key === "style" && val && typeof val === "object") {
+    Object.entries(val).forEach(([styleKey, styleVal]) => {
+      el.style[styleKey] = styleVal;
+    });
+    return;
+  }
+  if (key === "ref" || key === "key" || key === "children") return;
+  if (val === true) {
+    el.setAttribute(key, "");
+    return;
+  }
+  if (val === false || val == null) return;
+
+  let attr = key;
+  if (isSvg) {
+    if (key === "strokeWidth") attr = "stroke-width";
+    else if (key === "strokeLinecap") attr = "stroke-linecap";
+    else if (key === "strokeLinejoin") attr = "stroke-linejoin";
+    else if (key !== "viewBox" && /[A-Z]/.test(key)) {
+      attr = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
+    }
+  }
+  el.setAttribute(attr, val);
+};
+
+const reactNodeToDom = (node, parentIsSvg = false) => {
+  if (node == null || node === false) return null;
+  if (Array.isArray(node)) {
+    const frag = document.createDocumentFragment();
+    node.forEach((child) => {
+      const childNode = reactNodeToDom(child, parentIsSvg);
+      if (childNode) frag.appendChild(childNode);
+    });
+    return frag;
+  }
+  if (typeof node === "string" || typeof node === "number") {
+    return document.createTextNode(String(node));
+  }
+  if (node.nodeType) return node;
+  if (isReactElement(node)) {
+    if (node.type === Symbol.for("react.fragment")) {
+      return reactNodeToDom(node.props?.children, parentIsSvg);
+    }
+    if (typeof node.type === "function") {
+      return reactNodeToDom(node.type(node.props), parentIsSvg);
+    }
+    const tag = node.type;
+    const isSvg = parentIsSvg || tag === "svg";
+    const el = isSvg
+      ? document.createElementNS(svgNamespace, tag)
+      : document.createElement(tag);
+    const props = node.props || {};
+    Object.entries(props).forEach(([key, val]) => {
+      setDomAttribute(el, key, val, isSvg);
+    });
+    const children = props.children;
+    if (children != null) {
+      const childNode = reactNodeToDom(children, isSvg);
+      if (childNode) el.appendChild(childNode);
+    }
+    return el;
+  }
+  return null;
+};
+
+const getTablerExtIcon = (id) => {
+  const name = toTablerIconName(id);
+  if (!name) return null;
+  return window.tablerIcons?.[name] || null;
+};
+
+const renderTablerExtIcon = (el, id) => {
+  const iconFn = getTablerExtIcon(id);
+  if (!iconFn) return false;
+  const node = iconFn({ size: 14, stroke: 2 });
+  if (!node) return false;
+  el.textContent = "";
+  const domNode = reactNodeToDom(node);
+  if (!domNode) return false;
+  if (domNode.nodeType === 11) {
+    el.appendChild(domNode);
+    return true;
+  }
+  if (domNode.nodeType) {
+    if (domNode.tagName === "svg") {
+      domNode.setAttribute("aria-hidden", "true");
+    }
+    el.appendChild(domNode);
+    return true;
+  }
+  return false;
+};
+
+const renderPropertyIcons = () => {
+  const icons = Array.from(
+    document.querySelectorAll(".property-icon[data-icon-type][data-icon-id]")
+  );
+  if (!icons.length) return;
+
+  icons.forEach((el) => {
+    const id = el.dataset.iconId;
+    const type = el.dataset.iconType;
+    if (!id) return;
+
+    if (type === "emoji") {
+      const native = getEmojiNative(id);
+      el.textContent = native || id;
+      return;
+    }
+
+    el.textContent = "";
+    el.setAttribute("aria-hidden", "true");
+
+    if (type === "tabler-ext-icon") {
+      if (renderTablerExtIcon(el, id)) return;
+      const slug = toKebabCase(id);
+      el.classList.add("tie", `tie-${slug}`);
+      return;
+    }
+
+    if (type === "tabler-icon") {
+      if (renderTablerExtIcon(el, id)) return;
+      const slug = toKebabCase(id);
+      el.classList.add("ti", `ti-${slug}`);
+      return;
+    }
+
+    el.textContent = id;
+  });
+};
+
+let sequenceKey = null;
+let sequenceTimer = null;
+const SEQUENCE_TIMEOUT_MS = 900;
+
+const resetSequence = () => {
+  sequenceKey = null;
+  if (sequenceTimer) {
+    clearTimeout(sequenceTimer);
+    sequenceTimer = null;
+  }
+};
+
+const isTypingTarget = (target) => {
+  if (!target) return false;
+  const tag = target.tagName;
+  return (
+    tag === "INPUT" ||
+    tag === "TEXTAREA" ||
+    target.isContentEditable
+  );
+};
+
+document.addEventListener("keydown", (event) => {
+  if (event.metaKey || event.ctrlKey || event.altKey) return;
+  if (isTypingTarget(event.target)) return;
+
+  const key = (event.key || "").toLowerCase();
+  if (!key) return;
+
+  if (sequenceKey === "t" && key === "o") {
+    resetSequence();
+    window.toggleTopBlocks();
+    event.preventDefault();
+    return;
+  }
+
+  if (sequenceKey === "t" && key === "t") {
+    resetSequence();
+    window.toggleTheme();
+    event.preventDefault();
+    return;
+  }
+
+  if (key === "t") {
+    sequenceKey = "t";
+    if (sequenceTimer) clearTimeout(sequenceTimer);
+    sequenceTimer = setTimeout(resetSequence, SEQUENCE_TIMEOUT_MS);
+    return;
+  }
+
+  resetSequence();
+});
+
+document.addEventListener("click", (event) => {
+  const toggle = event.target.closest(".theme-toggle");
+  if (!toggle) return;
+  event.preventDefault();
+  window.toggleTheme();
+});
+
+const searchStateMap = new WeakMap();
+
+const getSearchContainerState = () => {
+  const container =
+    document.querySelector(".publish-search.is-expanded") ||
+    document.querySelector(".publish-search");
+  if (!container) return null;
+  return searchStateMap.get(container) || null;
+};
+
+document.addEventListener("keydown", (event) => {
+  const isMod = event.metaKey || event.ctrlKey;
+  if (!isMod) return;
+
+  const key = (event.key || "").toLowerCase();
+  if (!key) return;
+
+  const typingTarget = isTypingTarget(event.target);
+  if (
+    typingTarget &&
+    !event.target.classList?.contains("publish-search-input")
+  ) {
+    return;
+  }
+
+  const state = getSearchContainerState();
+  if (!state) return;
+
+  if (key === "k") {
+    event.preventDefault();
+    state.setExpanded(true);
+    state.focusInput();
+    return;
+  }
+});
+
+window.toggleTopBlocks = (btn) => {
+  const list = document.querySelector(".blocks");
+  if (!list) return;
+  const collapsed = list.classList.toggle("collapsed-all");
+  list.querySelectorAll(":scope > .block").forEach((el) => {
+    if (collapsed) {
+      el.classList.add("is-collapsed");
+    } else {
+      el.classList.remove("is-collapsed");
+    }
+  });
+  if (btn) {
+    btn.textContent = collapsed ? "Expand all" : "Collapse all";
+  }
+};
+
+const applyTheme = (theme) => {
+  document.documentElement.setAttribute("data-theme", theme);
+  document.querySelectorAll(".theme-toggle").forEach((toggle) => {
+    toggle.classList.toggle("is-dark", theme === "dark");
+    toggle.setAttribute("aria-checked", String(theme === "dark"));
+  });
+};
+
+const preferredTheme = () => {
+  const stored = window.localStorage.getItem(THEME_KEY);
+  if (stored) return stored;
+  return window.matchMedia("(prefers-color-scheme: dark)").matches
+    ? "dark"
+    : "light";
+};
+
+window.toggleTheme = () => {
+  const current = document.documentElement.getAttribute("data-theme") || "light";
+  const next = current === "dark" ? "light" : "dark";
+  applyTheme(next);
+  window.localStorage.setItem(THEME_KEY, next);
+};
+
+const initTwitterEmbeds = () => {
+  const tweetTargets = document.querySelectorAll(".twitter-tweet");
+  if (!tweetTargets.length) return;
+
+  const ensureTwitterScript = () =>
+    new Promise((resolve) => {
+      if (window.twttr?.widgets?.createTweet) {
+        return resolve(window.twttr);
+      }
+
+      let script = document.querySelector("script[data-twitter-widget]");
+      if (!script) {
+        script = document.createElement("script");
+        script.src = "https://platform.twitter.com/widgets.js";
+        script.async = true;
+        script.defer = true;
+        script.setAttribute("data-twitter-widget", "true");
+        document.body.appendChild(script);
+      }
+
+      script.addEventListener("load", () => {
+        resolve(window.twttr);
+      });
+    });
+
+  ensureTwitterScript().then((twttr) => {
+    if (!twttr?.widgets?.createTweet) return;
+
+    tweetTargets.forEach((el) => {
+      const a = el.querySelector("a[href*='/status/']");
+      if (!a) return;
+      const m = a.href.match(/status\/(\d+)/);
+      if (!m) return;
+      const tweetId = m[1];
+
+      // Clear fallback text
+      el.innerHTML = "";
+
+      // Optional: theme based on your current theme
+      const theme =
+        (document.documentElement.getAttribute("data-theme") || "light") ===
+        "dark"
+          ? "dark"
+          : "light";
+
+      twttr.widgets.createTweet(tweetId, el, { theme });
+    });
+  });
+};
+
+const buildSnippet = (text, query) => {
+  const haystack = text.toLowerCase();
+  const needle = query.toLowerCase();
+  const idx = haystack.indexOf(needle);
+  if (idx < 0) return text.slice(0, 160);
+  const start = Math.max(0, idx - 48);
+  const end = Math.min(text.length, idx + needle.length + 48);
+  return text.slice(start, end).replace(/\s+/g, " ").trim();
+};
+
+const initSearch = () => {
+  const containers = Array.from(
+    document.querySelectorAll(".publish-search")
+  );
+  if (!containers.length) return;
+
+  containers.forEach((container) => {
+    const graphUuid = container.dataset.graphUuid;
+    const input = container.querySelector(".publish-search-input");
+    const toggleBtn = container.querySelector(".publish-search-toggle");
+    const toggleIcon = container.querySelector(".publish-search-toggle .ti");
+    const resultsEl = container.querySelector(".publish-search-results");
+    if (!input || !resultsEl || !toggleBtn) return;
+
+    let debounceTimer = null;
+    let activeController = null;
+    let activeIndex = -1;
+    let activeItems = [];
+
+    const hideResults = () => {
+      resultsEl.hidden = true;
+      resultsEl.innerHTML = "";
+      activeIndex = -1;
+      activeItems = [];
+    };
+
+    const renderResults = (query, data) => {
+      const pages = data?.pages || [];
+      const blocks = data?.blocks || [];
+
+      if (!pages.length && !blocks.length) {
+        resultsEl.innerHTML = "";
+        const empty = document.createElement("div");
+        empty.className = "publish-search-empty";
+        empty.textContent = `No results for "${query}".`;
+        resultsEl.appendChild(empty);
+        resultsEl.hidden = false;
+        activeIndex = -1;
+        activeItems = [];
+        return;
+      }
+
+      const list = document.createElement("div");
+      list.className = "publish-search-list";
+
+      if (pages.length) {
+        pages.forEach((page) => {
+          const title = page.page_title || page.page_uuid;
+          const href = `/page/${graphUuid}/${page.page_uuid}`;
+          const item = document.createElement("a");
+          item.className = "publish-search-result";
+          item.href = href;
+
+          const kind = document.createElement("span");
+          kind.className = "publish-search-kind";
+          kind.textContent = "Page";
+
+          const titleEl = document.createElement("span");
+          titleEl.className = "publish-search-title";
+          titleEl.textContent = title;
+
+          item.appendChild(kind);
+          item.appendChild(titleEl);
+          list.appendChild(item);
+        });
+      }
+
+      if (blocks.length) {
+        blocks.forEach((block) => {
+          const title = block.page_title || block.page_uuid;
+          const href = `/page/${graphUuid}/${block.page_uuid}#block-${block.block_uuid}`;
+          const snippet = buildSnippet(block.block_content || "", query);
+          const item = document.createElement("a");
+          item.className = "publish-search-result";
+          item.href = href;
+
+          const titleEl = document.createElement("span");
+          titleEl.className = "publish-search-title";
+          titleEl.textContent = title;
+
+          const snippetEl = document.createElement("span");
+          snippetEl.className = "publish-search-snippet";
+          snippetEl.textContent = snippet;
+
+          item.appendChild(titleEl);
+          item.appendChild(snippetEl);
+          list.appendChild(item);
+        });
+      }
+
+      resultsEl.innerHTML = "";
+      resultsEl.appendChild(list);
+      resultsEl.hidden = false;
+      activeIndex = -1;
+      activeItems = Array.from(
+        resultsEl.querySelectorAll(".publish-search-result")
+      );
+      activeItems.forEach((item, index) => {
+        item.addEventListener("mouseenter", () => {
+          activeIndex = index;
+          updateActive();
+        });
+      });
+    };
+
+    const updateActive = () => {
+      if (!activeItems.length) return;
+      activeItems.forEach((item, index) => {
+        item.classList.toggle("is-active", index === activeIndex);
+      });
+      const activeEl = activeItems[activeIndex];
+      if (activeEl) {
+        activeEl.scrollIntoView({ block: "nearest" });
+      }
+    };
+
+    const moveActive = (direction) => {
+      if (!activeItems.length) {
+        activeItems = Array.from(
+          resultsEl.querySelectorAll(".publish-search-result")
+        );
+      }
+      if (!activeItems.length) return;
+
+      if (activeIndex === -1) {
+        activeIndex = direction > 0 ? 0 : activeItems.length - 1;
+      } else {
+        activeIndex =
+          (activeIndex + direction + activeItems.length) %
+          activeItems.length;
+      }
+      updateActive();
+    };
+
+    const activateSelection = () => {
+      if (!activeItems.length) {
+        activeItems = Array.from(
+          resultsEl.querySelectorAll(".publish-search-result")
+        );
+      }
+      if (!activeItems.length) return;
+      const item =
+        activeIndex >= 0 ? activeItems[activeIndex] : activeItems[0];
+      if (item?.href) {
+        window.location.href = item.href;
+      }
+    };
+
+    const setExpanded = (expanded) => {
+      container.classList.toggle("is-expanded", expanded);
+      toggleBtn.setAttribute("aria-expanded", String(expanded));
+      if (toggleIcon) {
+        toggleIcon.classList.toggle("ti-search", !expanded);
+        toggleIcon.classList.toggle("ti-x", expanded);
+      }
+      if (expanded) {
+        input.focus();
+      } else {
+        input.value = "";
+        hideResults();
+      }
+    };
+
+    const runSearch = async (query) => {
+      if (!query) {
+        hideResults();
+        return;
+      }
+
+      if (activeController) activeController.abort();
+      activeController = new AbortController();
+
+      try {
+        const resp = await fetch(
+          `/search/${encodeURIComponent(graphUuid)}?q=${encodeURIComponent(query)}`,
+          { signal: activeController.signal }
+        );
+        if (!resp.ok) throw new Error("search request failed");
+        const data = await resp.json();
+        renderResults(query, data);
+      } catch (error) {
+        if (error?.name === "AbortError") return;
+        hideResults();
+      }
+    };
+
+    if (graphUuid) {
+      input.addEventListener("input", () => {
+        const query = input.value.trim();
+        if (debounceTimer) clearTimeout(debounceTimer);
+        debounceTimer = setTimeout(() => runSearch(query), 250);
+      });
+    }
+
+    input.addEventListener("keydown", (event) => {
+      if (event.key === "Escape") {
+        setExpanded(false);
+      }
+      if (event.key === "Enter") {
+        if (!resultsEl.hidden && input.value.trim()) {
+          activateSelection();
+          event.preventDefault();
+        }
+      }
+      if (
+        !resultsEl.hidden &&
+        input.value.trim() &&
+        resultsEl.querySelector(".publish-search-result")
+      ) {
+        const key = event.key;
+        if (key === "ArrowDown" || key === "Down") {
+          moveActive(1);
+          event.preventDefault();
+        } else if (key === "ArrowUp" || key === "Up") {
+          moveActive(-1);
+          event.preventDefault();
+        } else if ((event.metaKey || event.ctrlKey) && key === "n") {
+          moveActive(1);
+          event.preventDefault();
+        } else if ((event.metaKey || event.ctrlKey) && key === "p") {
+          moveActive(-1);
+          event.preventDefault();
+        }
+      }
+    });
+
+    document.addEventListener("click", (event) => {
+      if (!container.contains(event.target)) setExpanded(false);
+    });
+
+    toggleBtn.addEventListener("click", () => {
+      const expanded = container.classList.contains("is-expanded");
+      setExpanded(!expanded);
+    });
+
+    searchStateMap.set(container, {
+      setExpanded,
+      focusInput: () => input.focus(),
+      moveActive,
+      activateSelection,
+      hasResults: () => !!resultsEl.querySelector(".publish-search-result"),
+      isExpanded: () => container.classList.contains("is-expanded"),
+    });
+  });
+};
+
+const initPublish = () => {
+  applyTheme(preferredTheme());
+  renderPropertyIcons();
+  if (!window.tablerIcons) {
+    window.addEventListener("load", renderPropertyIcons, { once: true });
+  }
+
+  initTwitterEmbeds();
+  initSearch();
+
+  document.querySelectorAll(".math-block").forEach((el) => {
+    const tex = el.textContent;
+    try {
+      katex.render(tex, el, { displayMode: true, throwOnError: false });
+    } catch (_) {}
+  });
+
+  document.querySelectorAll(".code-block").forEach((block) => {
+    const codeEl = block.querySelector("code");
+    const doc = codeEl ? codeEl.textContent : "";
+    block.textContent = "";
+
+    const lang = (block.dataset.lang || "").toLowerCase();
+    const langExt = (() => {
+      if (!lang) return null;
+      if (["js", "javascript", "ts", "typescript"].includes(lang)) {
+        return javascript({ typescript: lang.startsWith("t") });
+      }
+      if (["py", "python"].includes(lang)) return python();
+      if (["html", "htm"].includes(lang)) return html();
+      if (["json"].includes(lang)) return json();
+      if (["md", "markdown"].includes(lang)) return markdown();
+      if (["sql"].includes(lang)) return sql();
+      if (["css", "scss"].includes(lang)) return css();
+      if (["clj", "cljc", "cljs", "clojure"].includes(lang)) return clojure();
+      return null;
+    })();
+
+    const extensions = [
+      lineNumbers(),
+      syntaxHighlighting(defaultHighlightStyle),
+      EditorView.editable.of(false),
+      EditorView.lineWrapping,
+    ];
+
+    if (langExt) extensions.push(langExt);
+
+    const state = EditorState.create({
+      doc,
+      extensions,
+    });
+
+    new EditorView({ state, parent: block });
+  });
+};
+
+if (document.readyState === "loading") {
+  document.addEventListener("DOMContentLoaded", initPublish);
+} else {
+  initPublish();
+}

+ 1597 - 0
deps/publish/src/logseq/publish/render.cljs

@@ -0,0 +1,1597 @@
+(ns logseq.publish.render
+  (:require-macros [hiccups.core])
+  (:require [clojure.string :as string]
+            [hiccups.runtime]
+            [logseq.common.util :as common-util]
+            [logseq.db.frontend.property :as db-property]
+            [logseq.db.frontend.property.type :as db-property-type]
+            [logseq.graph-parser.mldoc :as gp-mldoc]
+            [logseq.publish.common :as publish-common]
+            [logseq.publish.model :as publish-model]))
+
+;; Timestamp in milliseconds used for cache busting static assets.
+(defonce version 1767100530314)
+
+(def ref-regex
+  (js/RegExp. "\\[\\[([0-9a-fA-F-]{36})\\]\\]|\\(\\(([0-9a-fA-F-]{36})\\)\\)" "g"))
+
+(defonce inline-config
+  (gp-mldoc/default-config :markdown))
+
+(defn- block-ast
+  [text]
+  (when-not (string/blank? text)
+    (->> (gp-mldoc/->edn text inline-config)
+         (map first))))
+
+(defn inline-ast [text]
+  (gp-mldoc/inline->edn text inline-config))
+
+(defn content->nodes [content uuid->title graph-uuid]
+  (let [s (or content "")
+        re ref-regex]
+    (set! (.-lastIndex re) 0)
+    (loop [idx 0 out []]
+      (let [m (.exec re s)]
+        (if (nil? m)
+          (cond-> out
+            (< idx (count s)) (conj (subs s idx)))
+          (let [start (.-index m)
+                end (.-lastIndex re)
+                uuid (or (aget m 1) (aget m 2))
+                title (get uuid->title uuid uuid)
+                href (when graph-uuid
+                       (str "/page/" graph-uuid "/" uuid))
+                node (if href
+                       [:a.page-ref {:href href} title]
+                       title)
+                out (cond-> out
+                      (< idx start) (conj (subs s idx start))
+                      true (conj node))]
+            (recur end out)))))))
+
+(defn property-title
+  [prop-key property-title-by-ident]
+  (cond
+    (string? prop-key) prop-key
+    (keyword? prop-key) (or (get property-title-by-ident prop-key)
+                            (name prop-key))
+    :else (str prop-key)))
+
+(defn property-value-empty?
+  [value]
+  (cond
+    (nil? value) true
+    (string? value) (string/blank? value)
+    (coll? value) (empty? value)
+    :else false))
+
+(defn format-datetime
+  [value]
+  (let [date (cond
+               (instance? js/Date value) value
+               (number? value) (js/Date. value)
+               (string? value) (js/Date. value)
+               :else nil)]
+    (when date
+      (-> (.toISOString date)
+          (string/replace "T" " ")
+          (string/replace "Z" "")))))
+
+(defn nodes-join
+  [nodes-list sep]
+  (reduce (fn [acc nodes]
+            (if (empty? nodes)
+              acc
+              (if (seq acc)
+                (into (conj acc sep) nodes)
+                (into [] nodes))))
+          []
+          nodes-list))
+
+(defn- normalize-nodes
+  [nodes]
+  (cond
+    (nil? nodes) []
+    (and (vector? nodes) (keyword? (first nodes))) [nodes]
+    :else nodes))
+
+(defn- icon-span
+  [icon]
+  (when (and (map? icon) (string? (:id icon)) (not (string/blank? (:id icon))))
+    [:span
+     (cond->
+      {:class "property-icon"
+       :data-icon-id (:id icon)
+       :data-icon-type (name (:type icon))}
+       (:color icon)
+       (assoc :style (str "color: " (:color icon) ";")))]))
+
+(defn- with-icon
+  [icon nodes]
+  (let [icon-node (icon-span icon)]
+    (if icon-node
+      (into [:span {:class "property-value-with-icon"} icon-node] nodes)
+      nodes)))
+
+(defn- page-title-node
+  [title icon]
+  (let [icon-node (icon-span icon)]
+    (if icon-node
+      [:h1 [:span {:class "property-value-with-icon"} icon-node title]]
+      [:h1 title])))
+
+(defn- theme-toggle-node
+  []
+  [:button.theme-toggle
+   {:type "button"
+    :role "switch"
+    :aria-checked "false"}
+   [:span.theme-toggle__icon.theme-toggle__icon--day {:aria-hidden "true"}]
+   [:span.theme-toggle__thumb {:aria-hidden "true"}]
+   [:span.theme-toggle__icon.theme-toggle__icon--night {:aria-hidden "true"}]])
+
+(defn- toolbar-node
+  [& nodes]
+  (into [:div.page-toolbar] nodes))
+
+(defn- search-node
+  [graph-uuid]
+  (let [graph-id (some-> graph-uuid str)]
+    [:div.publish-search {:data-graph-uuid graph-id}
+     [:button.publish-search-toggle
+      {:type "button"
+       :aria-label "Search"
+       :aria-expanded "false"}
+      [:span.ti.ti-search {:aria-hidden "true"}]]
+     [:input.publish-search-input
+      (cond->
+       {:id "publish-search-input"
+        :type "search"
+        :placeholder "Search graph (Cmd+K)"
+        :autocomplete "off"
+        :spellcheck "false"
+        :aria-label "Search graph"}
+        (string/blank? (or graph-id ""))
+        (assoc :disabled true :placeholder "Search unavailable"))]
+     [:div.publish-search-hint "Up/Down to navigate"]
+     [:div.publish-search-results
+      {:id "publish-search-results"
+       :hidden true}]]))
+
+(defn- theme-init-script
+  []
+  [:script
+   "(function(){try{var k='publish-theme';var t=localStorage.getItem(k);if(!t){t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();"])
+
+(defn- publish-script
+  []
+  [:script {:type "module" :src (str "/static/publish.js?v=" version)}])
+
+(defn- icon-runtime-script
+  []
+  [:script
+   "(function(){if(window.React&&window.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED){return;}var s='http://www.w3.org/2000/svg';var k=function(n){return n.replace(/[A-Z]/g,function(m){return'-'+m.toLowerCase();});};var a=function(el,key,val){if(key==='className'){el.setAttribute('class',val);return;}if(key==='style'&&val&&typeof val==='object'){for(var sk in val){el.style[sk]=val[sk];}return;}if(key==='ref'||key==='key'||key==='children'){return;}if(val===true){el.setAttribute(key,'');return;}if(val===false||val==null){return;}var attr=key;if(key==='strokeWidth'){attr='stroke-width';}else if(key==='strokeLinecap'){attr='stroke-linecap';}else if(key==='strokeLinejoin'){attr='stroke-linejoin';}else if(key!=='viewBox'&&/[A-Z]/.test(key)){attr=k(key);}el.setAttribute(attr,val);};var c=function(el,child){if(child==null||child===false){return;}if(Array.isArray(child)){child.forEach(function(n){c(el,n);});return;}if(typeof child==='string'||typeof child==='number'){el.appendChild(document.createTextNode(child));return;}if(child.nodeType){el.appendChild(child);} };var e=function(type,props){var children=Array.prototype.slice.call(arguments,2);if(type===Symbol.for('react.fragment')){var frag=document.createDocumentFragment();children.forEach(function(n){c(frag,n);});return frag;}if(typeof type==='function'){return type(Object.assign({},props,{children:children}));}var isSvg=type==='svg'||(props&&props.xmlns===s);var el=isSvg?document.createElementNS(s,type):document.createElement(type);if(props){for(var p in props){a(el,p,props[p]);}}children.forEach(function(n){c(el,n);});return el;};window.React={createElement:e,forwardRef:function(fn){return fn;},Fragment:Symbol.for('react.fragment'),__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED:{ReactCurrentOwner:{current:null}}};window.PropTypes=new Proxy({}, {get:function(){return function(){return null;};}});})();"])
+
+(defn- head-node
+  [title {:keys [description keywords topics tags url custom-css-hash graph-uuid]}]
+  (let [description (when (string? description)
+                      (string/trim description))
+        keywords (->> [keywords topics tags]
+                      (map #(when (string? %) (string/trim %)))
+                      (remove string/blank?)
+                      (string/join ", "))
+        meta-tags (remove nil?
+                          [[:meta {:name "description" :content description}]
+                           (when (seq keywords)
+                             [:meta {:name "keywords" :content keywords}])
+                           (when (string? tags)
+                             [:meta {:name "tags" :content tags}])
+                           (when (string? topics)
+                             [:meta {:name "topics" :content topics}])
+                           [:meta {:content "summary_large_image" :name "twitter:card"}]
+                           (when description
+                             [:meta {:content description :name "twitter:description"}])
+                           [:meta {:content "@logseq" :name "twitter:site"}]
+                           [:meta {:content title :name "twitter:title"}]
+                           [:meta {:content "https://asset.logseq.com/static/img/social-banner-230118.png"
+                                   :name "twitter:image:src"}]
+                           (when description
+                             [:meta {:content description :name "twitter:image:alt"}])
+                           [:meta {:content title :property "og:title"}]
+                           [:meta {:content "article" :property "og:type"}]
+                           (when url
+                             [:meta {:content url :property "og:url"}])
+                           [:meta {:content "https://asset.logseq.com/static/img/social-banner-230118.png"
+                                   :property "og:image"}]
+                           (when description
+                             [:meta {:content description :property "og:description"}])
+                           [:meta {:content "logseq" :property "og:site_name"}]])
+        custom-css (when (and (string? custom-css-hash) (string? graph-uuid))
+                     [:link {:rel "stylesheet"
+                             :href (str "/asset/" graph-uuid "/publish.css?v=" custom-css-hash)}])]
+    [:head
+     [:meta {:charset "UTF-8"}]
+     [:meta {:name "viewport"
+             :content "width=device-width, initial-scale=1.0, maximum-scale=5.0, minimum-scale=1.0"}]
+     [:meta {:http-equiv "X-UA-Compatible" :content "ie=edge"}]
+     [:title title]
+     [:link {:href "https://asset.logseq.com/static/img/logo.png"
+             :rel "shortcut icon"
+             :type "image/png"}]
+     [:link {:href "https://asset.logseq.com/static/img/logo.png"
+             :rel "shortcut icon"
+             :sizes "192x192"}]
+     [:link {:href "https://asset.logseq.com/static/img/logo.png"
+             :rel "apple-touch-icon"}]
+     [:meta {:content "Logseq" :name "apple-mobile-web-app-title"}]
+     [:meta {:content "yes" :name "apple-mobile-web-app-capable"}]
+     [:meta {:content "yes" :name "apple-touch-fullscreen"}]
+     [:meta {:content "black-translucent" :name "apple-mobile-web-app-status-bar-style"}]
+     [:meta {:content "yes" :name "mobile-web-app-capable"}]
+     (theme-init-script)
+     (icon-runtime-script)
+     [:script {:defer true :src "/static/tabler.ext.js"}]
+     [:link {:rel "stylesheet"
+             :href "https://cdn.jsdelivr.net/npm/@tabler/[email protected]/dist/tabler-icons.min.css"}]
+     [:link {:rel "stylesheet" :href (str "/static/tabler-extension.css?v=" version)}]
+     [:link {:rel "stylesheet" :href (str "/static/publish.css?v=" version)}]
+     custom-css
+     meta-tags]))
+
+(defn- render-head
+  ([title] (render-head title nil))
+  ([title opts]
+   (head-node title (or opts {}))))
+
+(defn- meta-value
+  [meta k]
+  (or (get meta k)
+      (get meta (name k))))
+
+(defn property-type
+  [prop-key property-type-by-ident]
+  (or (get property-type-by-ident prop-key)
+      (get-in db-property/built-in-properties [prop-key :schema :type])))
+
+(defn page-ref->uuid [name name->uuid]
+  (or (get name->uuid name)
+      (get name->uuid (common-util/page-name-sanity-lc name))))
+
+(defn entity->link-node
+  [entity ctx]
+  (let [title (publish-model/entity->title entity)
+        uuid (:block/uuid entity)
+        graph-uuid (:graph-uuid ctx)]
+    (cond
+      (and uuid graph-uuid (publish-model/page-entity? entity))
+      [[:a.page-ref {:href (str "/page/" graph-uuid "/" uuid)} title]]
+      (common-util/url? title)
+      [:a {:href title} title]
+      :else
+      [title])))
+
+(defn property-value->nodes
+  [value prop-key ctx entities]
+  (let [prop-type (property-type prop-key (:property-type-by-ident ctx))
+        ref-type? (contains? db-property-type/all-ref-property-types prop-type)]
+    (cond
+      (nil? value)
+      []
+
+      (string? value)
+      (cond
+        (= prop-type :datetime)
+        (if-let [formatted (format-datetime value)]
+          [formatted]
+          (content->nodes value (:uuid->title ctx) (:graph-uuid ctx)))
+
+        :else
+        (content->nodes value (:uuid->title ctx) (:graph-uuid ctx)))
+
+      (keyword? value)
+      [(name value)]
+
+      (map? value)
+      (if-let [eid (:db/id value)]
+        (property-value->nodes eid prop-key ctx entities)
+        (if-let [content (db-property/property-value-content value)]
+          (property-value->nodes content prop-key ctx entities)
+          [(pr-str value)]))
+
+      (or (set? value) (sequential? value))
+      (nodes-join (map #(property-value->nodes % prop-key ctx entities) value) ", ")
+
+      (number? value)
+      (cond
+        (= prop-type :datetime)
+        (if-let [formatted (format-datetime value)]
+          [formatted]
+          [(str value)])
+
+        (and ref-type? (get entities value))
+        (let [entity (get entities value)]
+          (with-icon (:logseq.property/icon entity)
+            (entity->link-node entity ctx)))
+
+        :else
+        [(str value)])
+
+      :else
+      [(str value)])))
+
+(defn built-in-tag?
+  [entity]
+  (when-let [ident (:db/ident entity)]
+    (= "logseq.class" (namespace ident))))
+
+(defn filter-tags
+  [values entities]
+  (let [values (if (sequential? values) values [values])]
+    (->> values
+         (remove (fn [value]
+                   (cond
+                     (keyword? value) (= "logseq.class" (namespace value))
+                     :else
+                     (let [entity (cond
+                                    (map? value) value
+                                    (number? value) (get entities value)
+                                    :else nil)]
+                       (and entity (built-in-tag? entity))))))
+         vec)))
+
+(defn entity-properties
+  [entity ctx entities]
+  (let [props (db-property/properties entity)
+        inline-props (:block/properties entity)
+        props (if (map? inline-props)
+                (merge props inline-props)
+                props)
+        props (->> props
+                   (remove (fn [[k _]]
+                             (true? (get (:property-hidden-by-ident ctx) k))))
+                   (map (fn [[k v]]
+                          (if (= k :block/tags)
+                            [k (filter-tags v entities)]
+                            [k v])))
+                   (remove (fn [[_ v]] (property-value-empty? v)))
+                   (remove (fn [[k v]]
+                             (and (= k :block/tags) (property-value-empty? v)))))
+        props (into {} props)]
+    props))
+
+(defn render-properties
+  [props ctx entities]
+  (when (seq props)
+    [:dl.properties
+     (for [[k v] (sort-by (fn [[prop-key _]]
+                            (string/lower-case
+                             (property-title prop-key (:property-title-by-ident ctx))))
+                          props)]
+       [:div.property
+        [:dt.property-name (property-title k (:property-title-by-ident ctx))]
+        [:dd.property-value
+         (into [:span] (normalize-nodes (property-value->nodes v k ctx entities)))]])]))
+
+(defn- property-ui-position
+  [prop-key ctx]
+  (when-let [property (get (:property-entity-by-ident ctx) prop-key)]
+    (:logseq.property/ui-position property)))
+
+(defn- split-properties-by-position
+  [props ctx]
+  (reduce (fn [acc [k v]]
+            (let [position (property-ui-position k ctx)
+                  bucket (case position
+                           (:block-left :block-right :block-below) position
+                           :properties)]
+              (update acc bucket assoc k v)))
+          {:properties {}
+           :block-left {}
+           :block-right {}
+           :block-below {}}
+          props))
+
+(defn- sorted-properties
+  [props ctx]
+  (sort-by (fn [[prop-key _]]
+             (get-in ctx [:property-entity-by-ident prop-key :block/order]))
+           props))
+
+(defn- class-has?
+  [class-name target]
+  (some #{target} (string/split (or class-name "") #"\s+")))
+
+(defn- node-has-class?
+  [node target]
+  (when (and (vector? node) (keyword? (first node)))
+    (let [attrs (second node)]
+      (and (map? attrs) (class-has? (:class attrs) target)))))
+
+(defn- strip-positioned-value
+  [node]
+  (if (node-has-class? node "property-value-with-icon")
+    (let [[tag attrs & children] node
+          icon-children (filter #(node-has-class? % "property-icon") children)]
+      (if (seq icon-children)
+        (into [tag attrs] icon-children)
+        node))
+    node))
+
+(defn- positioned-value-nodes
+  [value prop-key ctx entities]
+  (cond
+    (= prop-key :logseq.property/icon)
+    (let [icon-node (icon-span value)]
+      (if icon-node [icon-node] []))
+
+    (= prop-key :block/tags)
+    (normalize-nodes (property-value->nodes value prop-key ctx entities))
+
+    :else
+    (->> (property-value->nodes value prop-key ctx entities)
+         normalize-nodes
+         (map strip-positioned-value))))
+
+(defn- render-positioned-properties
+  [props ctx entities position]
+  (when (seq props)
+    (case position
+      :block-below
+      [:div.positioned-properties.block-below
+       (for [[k v] (sorted-properties props ctx)]
+         [:div.positioned-property
+          [:span.property-name (property-title k (:property-title-by-ident ctx))]
+          [:span.property-value
+           (into [:span] (positioned-value-nodes v k ctx entities))]])]
+
+      [:div {:class (str "positioned-properties " (name position))}
+       (for [[k v] (sorted-properties props ctx)]
+         [:span.positioned-property
+          (into [:span] (positioned-value-nodes v k ctx entities))])])))
+
+(def ^:private youtube-regex #"^((?:https?:)?//)?((?:www|m).)?((?:youtube.com|youtu.be|y2u.be|youtube-nocookie.com))(/(?:[\w-]+\?v=|embed/|v/)?)([\w-]+)([\S^\?]+)?$")
+(def ^:private vimeo-regex #"^((?:https?:)?//)?((?:www).)?((?:player.vimeo.com|vimeo.com))(/(?:video/)?)([\w-]+)(\S+)?$")
+(def ^:private bilibili-regex #"^((?:https?:)?//)?((?:www).)?((?:bilibili.com))(/(?:video/)?)([\w-]+)(\?p=(\d+))?(\S+)?$")
+(def ^:private loom-regex #"^((?:https?:)?//)?((?:www).)?((?:loom.com))(/(?:share/|embed/))([\w-]+)(\S+)?$")
+
+(defn- safe-match
+  [re value]
+  (when (and (string? value) (not (string/blank? value)))
+    (re-find re value)))
+
+(defn- macro-iframe
+  [src {:keys [class title]}]
+  (when (and (string? src) (not (string/blank? src)))
+    (let [class-name (string/join " " (remove nil? ["macro-embed" class]))]
+      [:div {:class class-name}
+       [:iframe {:src src
+                 :title (or title "Embedded content")
+                 :loading "lazy"
+                 :allow "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+                 :allowfullscreen true}]])))
+
+(defn- youtube-embed
+  [url]
+  (let [id (cond
+             (and (string? url) (= 11 (count url))) url
+             :else (nth (safe-match youtube-regex url) 5 nil))]
+    (when (and id (string? id))
+      (macro-iframe (str "https://www.youtube.com/embed/" id) {:class "macro-embed--video" :title "YouTube"}))))
+
+(defn- vimeo-embed
+  [url]
+  (let [id (nth (safe-match vimeo-regex url) 5 nil)]
+    (when (and id (string? id))
+      (macro-iframe (str "https://player.vimeo.com/video/" id) {:class "macro-embed--video" :title "Vimeo"}))))
+
+(defn- bilibili-embed
+  [url]
+  (let [id (if (<= (count (or url "")) 15)
+             url
+             (nth (safe-match bilibili-regex url) 5 nil))]
+    (when (and id (string? id) (not (string/blank? id)))
+      (macro-iframe (str "https://player.bilibili.com/player.html?bvid=" id "&high_quality=1&autoplay=0")
+                    {:class "macro-embed--video" :title "Bilibili"}))))
+
+(defn- video-embed
+  [url]
+  (when (common-util/url? url)
+    (let [matches (or (safe-match youtube-regex url)
+                      (safe-match loom-regex url)
+                      (safe-match vimeo-regex url)
+                      (safe-match bilibili-regex url))
+          src (cond
+                (and matches (contains? #{"youtube.com" "youtu.be" "y2u.be" "youtube-nocookie.com"} (nth matches 3)))
+                (let [id (nth matches 5)]
+                  (when (= 11 (count (or id "")))
+                    (str "https://www.youtube.com/embed/" id)))
+
+                (and matches (string/ends-with? (nth matches 3) "loom.com"))
+                (str "https://www.loom.com/embed/" (nth matches 5))
+
+                (and matches (string/ends-with? (nth matches 3) "vimeo.com"))
+                (str "https://player.vimeo.com/video/" (nth matches 5))
+
+                (and matches (= (nth matches 3) "bilibili.com"))
+                (str "https://player.bilibili.com/player.html?bvid=" (nth matches 5) "&high_quality=1&autoplay=0")
+
+                :else
+                url)]
+      (macro-iframe src {:class "macro-embed--video" :title "Video"}))))
+
+(defn- tweet-embed
+  [url]
+  (let [url (cond
+              (and (string? url) (<= (count url) 15)) (str "https://x.com/i/status/" url)
+              :else url)]
+    (when url
+      [:div.twitter-tweet
+       [:a {:href url} url]])))
+
+(defn- tweet-embed-from-html
+  [html]
+  (let [id (last (safe-match #"/status/(\d+)" html))]
+    (when (and id (string? id))
+      (tweet-embed id))))
+
+(defn- macro->nodes
+  [ctx {:keys [name arguments]}]
+  (let [name (string/lower-case (or name ""))
+        arguments (if (sequential? arguments) arguments [])
+        first-arg (first arguments)]
+    (cond
+      (= name "cloze")
+      [[:span.cloze (string/join ", " arguments)]]
+
+      (= name "youtube")
+      (when-let [node (youtube-embed first-arg)] [node])
+
+      (= name "vimeo")
+      (when-let [node (vimeo-embed first-arg)] [node])
+
+      (= name "bilibili")
+      (when-let [node (bilibili-embed first-arg)] [node])
+
+      (= name "video")
+      (when-let [node (video-embed first-arg)] [node])
+
+      (contains? #{"tweet" "twitter"} name)
+      (when-let [node (tweet-embed first-arg)] [node])
+
+      :else
+      (content->nodes (str "{{" name (when (seq arguments)
+                                       (str " " (string/join ", " arguments))) "}}")
+                      (:uuid->title ctx)
+                      (:graph-uuid ctx)))))
+
+(defn- parse-macro-text
+  [value]
+  (when-let [[_ name args] (and (string? value)
+                                (re-find #"\{\{\s*([^\s\}]+)\s*([^}]*)\}\}" value))]
+    (let [args (->> (string/split (or args "") #",")
+                    (map string/trim)
+                    (remove string/blank?)
+                    vec)]
+      {:name name
+       :arguments args})))
+
+(defn- normalize-macro-data
+  [data]
+  (cond
+    (map? data) data
+    (string? data) (parse-macro-text data)
+    (and (sequential? data) (seq data))
+    (let [name (first data)
+          args (second data)]
+      {:name (when (string? name) name)
+       :arguments (if (sequential? args) args [])})
+    :else nil))
+
+(defn- macro-embed-node?
+  [node]
+  (when (vector? node)
+    (let [tag (first node)
+          attrs (second node)]
+      (and (= tag :div)
+           (map? attrs)
+           (string? (:class attrs))
+           (string/includes? (:class attrs) "macro-embed")))))
+
+(defn inline->nodes [ctx item]
+  (let [[type data] item
+        {:keys [uuid->title name->uuid graph-uuid]} ctx]
+    (cond
+      (or (= "Plain" type) (= "Spaces" type))
+      (let [sub-ast (inline-ast data)
+            simple-plain? (and (= 1 (count sub-ast))
+                               (= "Plain" (ffirst sub-ast)))]
+        (if (and (seq sub-ast) (not simple-plain?))
+          (mapcat #(inline->nodes ctx %) sub-ast)
+          (content->nodes data uuid->title graph-uuid)))
+
+      (= "Break_Line" type)
+      [[:br]]
+
+      (= "Emphasis" type)
+      (let [[[kind] items] data
+            tag (case kind
+                  "Bold" :strong
+                  "Italic" :em
+                  "Underline" :ins
+                  "Strike_through" :del
+                  "Highlight" :mark
+                  :span)
+            children (mapcat #(inline->nodes ctx %) items)]
+        [(into [tag] children)])
+
+      (or (= "Verbatim" type) (= "Code" type))
+      [[:code data]]
+
+      (= "Link" type)
+      (let [url (:url data)
+            label (:label data)
+            [link-type link-value] url
+            label-nodes (cond
+                          (vector? label) (mapcat #(inline->nodes ctx %) label)
+                          (seq? label) (mapcat #(inline->nodes ctx %) label)
+                          (string? label) (content->nodes label uuid->title graph-uuid)
+                          :else [])
+            page-uuid (when (= "Page_ref" link-type)
+                        (or (page-ref->uuid link-value name->uuid)
+                            (when (common-util/uuid-string? link-value) link-value)))
+            page-title (when page-uuid
+                         (get uuid->title page-uuid))
+            label-nodes (cond
+                          (seq label-nodes) label-nodes
+                          page-title [page-title]
+                          (string? link-value) [link-value]
+                          :else [""])
+            href (cond
+                   page-uuid (str "/page/" graph-uuid "/" page-uuid)
+                   (= "Complex" link-type) (when (and (map? link-value)
+                                                      (string? (:protocol link-value))
+                                                      (string? (:link link-value)))
+                                             (str (:protocol link-value) "://" (:link link-value)))
+                   (string? link-value) link-value
+                   :else nil)]
+        (if href
+          [(into [:a {:class (when page-uuid "page-ref")
+                      :href href}] label-nodes)]
+          label-nodes))
+
+      (= "Tag" type)
+      (let [s (or (second data) "")
+            page-uuid (page-ref->uuid s name->uuid)]
+        (if page-uuid
+          [[:a.page-ref {:href (str "/page/" graph-uuid "/" page-uuid)} (str "#" s)]]
+          (if (and graph-uuid (not (string/blank? s)))
+            [[:a.page-ref {:href (str "/tag/" (js/encodeURIComponent s))} (str "#" s)]]
+            [(str "#" s)])))
+
+      (= "Macro" type)
+      (if-let [macro-data (normalize-macro-data data)]
+        (or (macro->nodes ctx macro-data) [])
+        (content->nodes (str data) uuid->title graph-uuid))
+
+      (= "Email" type)
+      (let [email (str (:local_part data) "@" (:domain data))]
+        [[:a {:href (str "mailto:" email)} email]])
+
+      (or (= "Inline_Html" type) (= "Export_Snippet" type))
+      (if-let [node (tweet-embed-from-html data)]
+        [node]
+        [])
+
+      :else
+      (content->nodes (str data) uuid->title graph-uuid))))
+
+(defn- inline-coll->nodes
+  [ctx inline-coll]
+  (mapcat #(inline->nodes ctx %) (or inline-coll [])))
+
+(declare block-ast->nodes)
+(defn- block-ast-coll->nodes
+  [ctx content]
+  (mapcat #(block-ast->nodes ctx %) (or content [])))
+
+(defn- list-items->node
+  [ctx items]
+  (into
+   [:ul]
+   (map (fn [item]
+          (let [content (let [content (:content item)]
+                          (if (and (sequential? content)
+                                   (every? #(and (vector? %) (string? (first %))) content))
+                            (block-ast-coll->nodes ctx content)
+                            (inline-coll->nodes ctx content)))
+                nested (when (seq (:items item))
+                         [(list-items->node ctx (:items item))])
+                children (concat content nested)]
+            (into [:li] children)))
+        items)))
+
+(defn- block-ast->nodes
+  [ctx block-ast]
+  (let [[type data] block-ast]
+    (case type
+      "Paragraph"
+      (let [children (inline-coll->nodes ctx data)]
+        (when (seq children)
+          [(into [:p] children)]))
+
+      "Heading"
+      (let [children (inline-coll->nodes ctx (:title data))]
+        (when (seq children)
+          [(into [:p] children)]))
+
+      "List"
+      (when (seq data)
+        [(list-items->node ctx data)])
+
+      "Quote"
+      (when (seq data)
+        [(into [:blockquote] (mapcat #(block-ast->nodes ctx %) data))])
+
+      "Example"
+      (when (seq data)
+        [[:pre (string/join "\n" data)]])
+
+      "Src"
+      (let [lines (:lines data)
+            code (if (sequential? lines) (string/join "\n" lines) (str lines))]
+        [[:pre [:code code]]])
+
+      "Paragraph_Sep"
+      [[:br]]
+
+      "Horizontal_Rule"
+      [[:hr]]
+
+      (let [fallback (content->nodes (str data) (:uuid->title ctx) (:graph-uuid ctx))]
+        (when (seq fallback)
+          [(into [:p] fallback)])))))
+
+(defn- block-ast-complex?
+  [block-asts]
+  (let [block-asts (seq block-asts)]
+    (and block-asts
+         (or (> (count block-asts) 1)
+             (some (fn [[type _]]
+                     (not (contains? #{"Paragraph" "Heading"} type)))
+                   block-asts)))))
+
+(defn- heading-level
+  [block depth]
+  (let [legacy (:block/heading-level block)
+        prop (:logseq.property/heading block)
+        legacy (when (and (number? legacy) (<= 1 legacy 6)) legacy)
+        prop (cond
+               (and (number? prop) (<= 1 prop 6)) prop
+               (true? prop) (min (inc depth) 6)
+               :else nil)]
+    (or legacy prop)))
+
+(defn- strip-heading-prefix
+  [s]
+  (string/replace s #"^\s*#+\s+" ""))
+
+(defn- property-value->text
+  [value ctx entities]
+  (cond
+    (nil? value) nil
+    (string? value) value
+    (keyword? value) (name value)
+    (number? value)
+    (if-let [entity (get entities value)]
+      (publish-model/entity->title entity)
+      (str value))
+    (map? value)
+    (if (:db/id value)
+      (publish-model/entity->title value)
+      (if-let [content (db-property/property-value-content value)]
+        (str content)
+        (str value)))
+    (sequential? value)
+    (->> value
+         (map #(property-value->text % ctx entities))
+         (remove string/blank?)
+         distinct
+         (string/join ", "))
+    :else (str value)))
+
+(defn block-content-nodes [block ctx depth]
+  (let [raw (or (:block/content block)
+                (:block/title block)
+                (:block/name block)
+                "")
+        heading (heading-level block depth)
+        raw (if heading
+              (strip-heading-prefix raw)
+              raw)
+        block-asts (when-not heading (block-ast raw))
+        block-level? (and (not heading) (block-ast-complex? block-asts))
+        content (if block-level?
+                  (mapcat #(block-ast->nodes ctx %) block-asts)
+                  (let [ast (inline-ast raw)]
+                    (if (seq ast)
+                      (mapcat #(inline->nodes ctx %) ast)
+                      (content->nodes raw (:uuid->title ctx) (:graph-uuid ctx)))))
+        container (cond
+                    heading (keyword (str "h" heading ".block-text.block-heading"))
+                    block-level? :div.block-text
+                    (some macro-embed-node? content) :div.block-text
+                    :else :span.block-text)]
+    (into [container] content)))
+
+(defn block-raw-content [block]
+  (or (:block/content block)
+      (:block/title block)
+      (:block/name block)
+      ""))
+
+(defn- asset-url [block ctx]
+  (let [asset-type (:logseq.property.asset/type block)
+        asset-uuid (:block/uuid block)
+        external-url (:logseq.property.asset/external-url block)
+        graph-uuid (:graph-uuid ctx)]
+    (cond
+      (string? external-url) external-url
+      (and asset-uuid asset-type graph-uuid)
+      (str "/asset/" graph-uuid "/" asset-uuid "." asset-type)
+      :else nil)))
+
+(def ^:private publish-image-variant-sizes
+  [1024 1600])
+
+(def ^:private publish-image-variant-types
+  #{"png" "jpg" "jpeg" "webp"})
+
+(def ^:private publish-image-sizes-attr
+  "(max-width: 640px) 92vw, (max-width: 1024px) 88vw, 920px")
+
+(defn- asset-variant-url
+  [graph-uuid asset-uuid asset-type variant]
+  (str "/asset/" graph-uuid "/" asset-uuid "@" variant "." asset-type))
+
+(defn- variant-width
+  [block size]
+  (let [asset-width (:logseq.property.asset/width block)
+        asset-height (:logseq.property.asset/height block)]
+    (if (and (number? asset-width)
+             (number? asset-height)
+             (pos? asset-width)
+             (pos? asset-height))
+      (let [max-dim (max asset-width asset-height)
+            scale (min 1 (/ size max-dim))]
+        (js/Math.round (* asset-width scale)))
+      size)))
+
+(defn- asset-node [block ctx]
+  (let [asset-type (:logseq.property.asset/type block)
+        asset-url (asset-url block ctx)
+        external-url (:logseq.property.asset/external-url block)
+        title (or (:block/title block) (str asset-type))
+        ext (string/lower-case (or asset-type ""))
+        graph-uuid (:graph-uuid ctx)
+        asset-uuid (:block/uuid block)
+        variant? (and (not (string? external-url))
+                      graph-uuid
+                      asset-uuid
+                      (contains? publish-image-variant-types ext))
+        srcset (when variant?
+                 (->> publish-image-variant-sizes
+                      (map (fn [size]
+                             (let [width (variant-width block size)]
+                               (str (asset-variant-url graph-uuid asset-uuid asset-type size)
+                                    " "
+                                    width
+                                    "w"))))
+                      (string/join ", ")))]
+    (when asset-url
+      (cond
+        (contains? #{"png" "jpg" "jpeg" "gif" "webp" "svg" "bmp" "avif"} ext)
+        [:img.asset-image (cond-> {:src asset-url :alt title}
+                            srcset (assoc :srcset srcset :sizes publish-image-sizes-attr))]
+
+        (contains? #{"mp4" "webm" "mov"} ext)
+        [:video.asset-video {:src asset-url :controls true}]
+
+        (contains? #{"mp3" "wav" "ogg"} ext)
+        [:audio.asset-audio {:src asset-url :controls true}]
+
+        :else
+        [:a.asset-link {:href asset-url :target "_blank"} title]))))
+
+(defn block-display-node [block ctx depth]
+  (let [display-type (:logseq.property.node/display-type block)
+        asset-node (when (:logseq.property.asset/type block)
+                     (asset-node block ctx))]
+    (case display-type
+      :asset asset-node
+      :code
+      (let [lang (:logseq.property.code/lang block)
+            attrs (cond-> {:class "code-block"}
+                    (string? lang) (assoc :data-lang lang))]
+        [:div attrs [:code (block-raw-content block)]])
+
+      :math
+      [:div.math-block (block-raw-content block)]
+
+      :quote
+      [:blockquote.quote-block (block-content-nodes block ctx depth)]
+
+      (or asset-node
+          (block-content-nodes block ctx depth)))))
+
+(defn block-content-from-ref [ref ctx]
+  (let [raw (or (get ref "source_block_content") "")
+        block-asts (block-ast raw)
+        block-level? (block-ast-complex? block-asts)
+        content (if block-level?
+                  (mapcat #(block-ast->nodes ctx %) block-asts)
+                  (let [ast (inline-ast raw)]
+                    (if (seq ast)
+                      (mapcat #(inline->nodes ctx %) ast)
+                      (content->nodes raw (:uuid->title ctx) (:graph-uuid ctx)))))]
+    (into [(if block-level? :div.block-text :span.block-text)] content)))
+
+(comment
+  (def ^:private void-tags
+    #{"area" "base" "br" "col" "embed" "hr" "img" "input" "link" "meta" "param" "source" "track" "wbr"}))
+
+(defn render-hiccup [node]
+  (hiccups.core/html node))
+
+(defn sort-blocks [blocks]
+  (sort-by (fn [block]
+             (or (:block/order block) (:block/uuid block) ""))
+           blocks))
+
+(defn- linked-block-entity
+  [block ctx visited]
+  (let [link (:block/link block)
+        linked-id (cond
+                    (map? link) (:db/id link)
+                    (number? link) link
+                    :else nil)]
+    (when (and linked-id (not (contains? visited linked-id)))
+      (get (:entities ctx) linked-id))))
+
+(defn render-block-tree
+  ([page-children-by-parent linked-children-by-parent parent-id ctx]
+   (render-block-tree page-children-by-parent linked-children-by-parent parent-id ctx #{} 1))
+  ([page-children-by-parent linked-children-by-parent parent-id ctx visited depth]
+   (let [children (get page-children-by-parent parent-id)]
+     (when (seq children)
+       [:ul.blocks
+        (map (fn [block]
+               (let [linked-block (linked-block-entity block ctx visited)
+                     display-block (or linked-block block)
+                     display-id (:db/id display-block)
+                     visited (cond-> visited linked-block (conj display-id))
+                     nested (render-block-tree
+                             (if linked-block linked-children-by-parent page-children-by-parent)
+                             linked-children-by-parent
+                             display-id
+                             ctx
+                             visited
+                             (inc depth))
+                     has-children? (boolean nested)
+                     raw-props (entity-properties display-block ctx (:entities ctx))
+                     icon-prop (get raw-props :logseq.property/icon)
+                     tags-prop (get raw-props :block/tags)
+                     raw-props (dissoc raw-props :logseq.property/icon :block/tags)
+                     {:keys [properties block-left block-right block-below]}
+                     (split-properties-by-position raw-props ctx)
+                     block-left (cond-> block-left
+                                  (and icon-prop (not (property-value-empty? icon-prop)))
+                                  (assoc :logseq.property/icon icon-prop))
+                     block-right (cond-> block-right
+                                   (and tags-prop (not (property-value-empty? tags-prop)))
+                                   (assoc :block/tags tags-prop))
+                     positioned-left (render-positioned-properties block-left ctx (:entities ctx) :block-left)
+                     positioned-right (render-positioned-properties block-right ctx (:entities ctx) :block-right)
+                     positioned-below (render-positioned-properties block-below ctx (:entities ctx) :block-below)
+                     properties (render-properties properties ctx (:entities ctx))
+                     block-uuid (:block/uuid display-block)
+                     block-uuid-str (some-> block-uuid str)]
+                 [:li.block
+                  (cond-> {:data-block-uuid block-uuid-str}
+                    block-uuid-str (assoc :id (str "block-" block-uuid-str)))
+                  [:div.block-content
+                   (when positioned-left positioned-left)
+                   (block-display-node display-block ctx depth)
+                   (when positioned-right positioned-right)
+                   (when has-children?
+                     [:button.block-toggle
+                      {:type "button" :aria-expanded "true"}
+                      "▾"])]
+                  (when positioned-below positioned-below)
+                  (when properties
+                    [:div.block-properties properties])
+                  (when nested
+                    [:div.block-children nested])]))
+             (sort-blocks children))]))))
+
+(defn linked-references
+  [ctx graph-uuid linked-by-page]
+  [:section.linked-refs
+   [:h2 "Linked references"]
+   (for [{:keys [page_uuid page_title blocks]} linked-by-page]
+     (let [ref-page-uuid page_uuid
+           ref-page-title page_title
+           href (when (and graph-uuid ref-page-uuid)
+                  (str "/page/" graph-uuid "/" ref-page-uuid))]
+       [:div.ref-page
+        (if href
+          [:a.page-ref {:href href} ref-page-title]
+          [:div.ref-title ref-page-title])
+        (when (seq blocks)
+          [:ul.ref-blocks
+           (for [block blocks]
+             [:li.ref-block [:div.block-content (block-content-from-ref block ctx)]])])]))])
+
+(defn tag-item-val [item k]
+  (cond
+    (map? item) (or (get item k)
+                    (get item (name k)))
+    (object? item) (or (aget item (name k))
+                       (aget item k))
+    :else nil))
+
+(defn format-timestamp
+  [ts]
+  (when (number? ts)
+    (.toLocaleString (js/Date. ts))))
+
+(defn render-tagged-item
+  [graph-uuid item]
+  (let [item-graph-uuid (tag-item-val item :graph_uuid)
+        graph-uuid (or item-graph-uuid graph-uuid)
+        source-page-uuid (tag-item-val item :source_page_uuid)
+        source-page-title (tag-item-val item :source_page_title)
+        source-block-uuid (tag-item-val item :source_block_uuid)
+        source-block-content (tag-item-val item :source_block_content)
+        updated-at (tag-item-val item :updated_at)
+        page? (and source-page-uuid (= source-page-uuid source-block-uuid))
+        href (when (and graph-uuid source-page-uuid)
+               (str "/page/" graph-uuid "/" source-page-uuid))]
+    [:li.tagged-item
+     [:div.tagged-main
+      (if href
+        [:a.page-ref {:href href} (or source-page-title source-page-uuid)]
+        [:span (or source-page-title source-page-uuid)])
+      (when (and source-block-content (not page?))
+        [:div.tagged-block source-block-content])]
+     [:span.tagged-meta (or (format-timestamp updated-at) "—")]]))
+
+(defn- author-usernames
+  [entities page-eid page-entity]
+  (let [author-ids (->> entities
+                        (keep (fn [[_e entity]]
+                                (when (= (:block/page entity) page-eid)
+                                  (publish-model/ref-eid (:logseq.property/created-by-ref entity)))))
+                        (concat [(publish-model/ref-eid (:logseq.property/created-by-ref page-entity))])
+                        (remove nil?)
+                        distinct)]
+    (->> author-ids
+         (map #(get entities %))
+         (keep publish-model/entity->title)
+         (remove string/blank?)
+         distinct
+         sort)))
+
+(defn render-page-html
+  [transit page-uuid-str refs-data tagged-nodes]
+  (let [payload (publish-common/read-transit-safe transit)
+        meta (publish-common/get-publish-meta payload)
+        graph-uuid (when meta
+                     (or (:graph meta)
+                         (:publish/graph meta)
+                         (get meta "graph")
+                         (get meta "publish/graph")))
+        datoms (:datoms payload)
+        entities (publish-model/datoms->entities datoms)
+        page-uuid (uuid page-uuid-str)
+        page-entity (some (fn [[_e entity]]
+                            (when (= (:block/uuid entity) page-uuid)
+                              entity))
+                          entities)
+        page-title (publish-model/entity->title page-entity)
+        page-updated-at (:block/updated-at page-entity)
+        page-eid (some (fn [[e entity]]
+                         (when (= (:block/uuid entity) page-uuid)
+                           e))
+                       entities)
+        authors (author-usernames entities page-eid page-entity)
+        uuid->title (reduce (fn [acc [_e entity]]
+                              (if-let [uuid-value (:block/uuid entity)]
+                                (assoc acc (str uuid-value) (publish-model/entity->title entity))
+                                acc))
+                            {}
+                            entities)
+        name->uuid (reduce (fn [acc [_e entity]]
+                             (if-let [uuid-value (:block/uuid entity)]
+                               (let [uuid-str (str uuid-value)
+                                     title (:block/title entity)]
+                                 (assoc acc title uuid-str))
+                               acc))
+                           {}
+                           entities)
+        property-title-by-ident (reduce (fn [acc [_e entity]]
+                                          (if-let [ident (:db/ident entity)]
+                                            (assoc acc ident (publish-model/entity->title entity))
+                                            acc))
+                                        {}
+                                        entities)
+        property-type-by-ident (reduce (fn [acc [_e entity]]
+                                         (if-let [ident (:db/ident entity)]
+                                           (assoc acc ident (:logseq.property/type entity))
+                                           acc))
+                                       {}
+                                       entities)
+        property-hidden-by-ident (reduce (fn [acc [_e entity]]
+                                           (if-let [ident (:db/ident entity)]
+                                             (assoc acc ident (true? (:logseq.property/hide? entity)))
+                                             acc))
+                                         {}
+                                         entities)
+        property-entity-by-ident (reduce (fn [acc [_e entity]]
+                                           (if-let [ident (:db/ident entity)]
+                                             (assoc acc ident entity)
+                                             acc))
+                                         {}
+                                         entities)
+        children-by-parent (->> entities
+                                (reduce (fn [acc [e entity]]
+                                          (if (and (= (:block/page entity) page-eid)
+                                                   (not= e page-eid)
+                                                   (not (:logseq.property/created-from-property entity)))
+                                            (let [parent (or (:block/parent entity) page-eid)]
+                                              (update acc parent (fnil conj []) entity))
+                                            acc))
+                                        {})
+                                (reduce-kv (fn [acc k v]
+                                             (assoc acc k (sort-blocks v)))
+                                           {}))
+        linked-children-by-parent (->> entities
+                                       (reduce (fn [acc [_e entity]]
+                                                 (if (and (:block/parent entity)
+                                                          (not (:logseq.property/created-from-property entity)))
+                                                   (update acc (:block/parent entity) (fnil conj []) entity)
+                                                   acc))
+                                               {})
+                                       (reduce-kv (fn [acc k v]
+                                                    (assoc acc k (sort-blocks v)))
+                                                  {}))
+        ctx {:uuid->title uuid->title
+             :name->uuid name->uuid
+             :graph-uuid graph-uuid
+             :property-title-by-ident property-title-by-ident
+             :property-type-by-ident property-type-by-ident
+             :property-hidden-by-ident property-hidden-by-ident
+             :property-entity-by-ident property-entity-by-ident
+             :entities entities}
+        page-props (entity-properties page-entity ctx entities)
+        page-properties (render-properties (dissoc page-props
+                                                   :logseq.property/icon
+                                                   :logseq.property.publish/published-url)
+                                           ctx
+                                           entities)
+        blocks (render-block-tree children-by-parent linked-children-by-parent page-eid ctx)
+        linked-by-page (when refs-data
+                         (->> (get refs-data "refs")
+                              (group-by #(get % "source_page_uuid"))
+                              (map (fn [[_ items]]
+                                     {:page_title (get (first items) "source_page_title")
+                                      :page_uuid (get (first items) "source_page_uuid")
+                                      :blocks items}))
+                              (sort-by (fn [{:keys [page_title]}]
+                                         (string/lower-case (or page_title ""))))))
+        linked-refs (when (seq linked-by-page)
+                      (linked-references ctx graph-uuid linked-by-page))
+        tagged-section (when (seq tagged-nodes)
+                         [:section.tagged-pages
+                          [:h2 "Tagged nodes"]
+                          [:ul.tagged-list
+                           (for [item tagged-nodes]
+                             (render-tagged-item graph-uuid item))]])
+        description (property-value->text (get page-props :logseq.property/description) ctx entities)
+        tags (property-value->text (or (get page-props :block/tags)
+                                       (get page-props :logseq.property/page-tags))
+                                   ctx
+                                   entities)
+        keywords (property-value->text (get page-props :logseq.property/keywords) ctx entities)
+        topics (property-value->text (get page-props :logseq.property/topics) ctx entities)
+        page-url (when (and graph-uuid page-uuid-str)
+                   (str "/page/" graph-uuid "/" page-uuid-str))
+        custom-css-hash (meta-value meta :custom_publish_css_hash)
+        custom-js-hash (meta-value meta :custom_publish_js_hash)
+        doc [:html
+             (render-head page-title {:description description
+                                      :keywords keywords
+                                      :topics topics
+                                      :tags tags
+                                      :url page-url
+                                      :custom-css-hash custom-css-hash
+                                      :graph-uuid graph-uuid})
+             [:body
+              [:main.wrap
+               (toolbar-node
+                (when graph-uuid
+                  [:a.toolbar-btn {:href (str "/graph/" graph-uuid)} "Home"])
+                (search-node graph-uuid)
+                (theme-toggle-node))
+
+               (page-title-node page-title (:logseq.property/icon page-entity))
+               [:div.page-meta
+                (when true
+                  [:div.page-authors (str "By: " (string/join ", " authors))])
+
+                (let [updated-at (format-timestamp page-updated-at)]
+                  [:div.page-updated-at updated-at])]
+
+               (when page-properties
+                 [:section.page-properties
+                  page-properties])
+
+               (when blocks blocks)
+               (when tagged-section tagged-section)
+               (when linked-refs linked-refs)]
+              (publish-script)
+              (when (and (string? custom-js-hash) (string? graph-uuid))
+                [:script {:defer true
+                          :src (str "/asset/" graph-uuid "/publish.js?v=" custom-js-hash)}])]]]
+    (str "<!doctype html>" (render-hiccup doc))))
+
+(defn render-graph-html
+  [graph-uuid pages]
+  (let [rows (->> pages
+                  (map (fn [page]
+                         (let [page-uuid (aget page "page_uuid")
+                               page-title (aget page "page_title")
+                               updated-at (aget page "updated_at")
+                               href (str "/page/" graph-uuid "/" page-uuid)
+                               short-id (aget page "short_id")]
+                           {:page-uuid page-uuid
+                            :page-title page-title
+                            :href href
+                            :short-id short-id
+                            :updated-at updated-at})))
+                  (sort-by (fn [row]
+                             (or (:updated-at row) 0)))
+                  reverse)
+        doc [:html
+             (render-head "Published pages")
+             [:body
+              [:main.wrap
+               (toolbar-node
+                (search-node graph-uuid)
+                (theme-toggle-node))
+               [:h1 "Published pages"]
+               (if (seq rows)
+                 [:ul.page-list
+                  (for [{:keys [page-uuid page-title href updated-at]} rows]
+                    [:li.page-item
+                     [:div.page-links
+                      [:a.page-link {:href href} (or page-title page-uuid)]]
+                     [:span.page-meta (or (format-timestamp updated-at) "—")]])]
+                 [:p "No pages have been published yet."])
+               (publish-script)]]]]
+    (str "<!doctype html>" (render-hiccup doc))))
+
+(defn render-user-html
+  [username user pages]
+  (let [username (or (aget user "username") username)
+        rows (->> pages
+                  (map (fn [page]
+                         (let [page-uuid (aget page "page_uuid")
+                               page-title (aget page "page_title")
+                               updated-at (aget page "updated_at")
+                               graph-uuid (aget page "graph_uuid")
+                               href (str "/page/" graph-uuid "/" page-uuid)
+                               short-id (aget page "short_id")]
+                           {:page-uuid page-uuid
+                            :page-title page-title
+                            :href href
+                            :short-id short-id
+                            :updated-at updated-at
+                            :graph-uuid graph-uuid})))
+                  (sort-by (fn [row]
+                             (or (:updated-at row) 0)))
+                  reverse)
+        title (str "Published by " username)
+        doc [:html
+             (render-head title)
+             [:body
+              [:main.wrap
+               (toolbar-node
+                (search-node nil)
+                (theme-toggle-node))
+               [:h1 title]
+               (if (seq rows)
+                 [:ul.page-list
+                  (for [{:keys [page-uuid page-title href updated-at]} rows]
+                    [:li.page-item
+                     [:div.page-links
+                      [:a.page-link {:href href} (or page-title page-uuid)]]
+                     [:span.page-meta
+                      (or (format-timestamp updated-at) "—")]])]
+                 [:p "No pages have been published yet."])
+               (publish-script)]]]]
+    (str "<!doctype html>" (render-hiccup doc))))
+
+(defn render-tag-html
+  [graph-uuid tag-uuid tag-title tag-items]
+  (let [rows tag-items
+        title (or tag-title tag-uuid)
+        doc [:html
+             (render-head (str "#" title))
+             [:body
+              [:main.wrap
+               (toolbar-node
+                (when graph-uuid
+                  [:a.toolbar-btn {:href (str "/graph/" graph-uuid)} "Home"])
+                (search-node graph-uuid)
+                (theme-toggle-node))
+               [:h1 (str "#" title)]
+               (if (seq rows)
+                 [:ul.page-list
+                  (for [item rows]
+                    (render-tagged-item graph-uuid item))]
+                 [:p "No published nodes use this tag yet."])
+               (publish-script)]]]]
+    (str "<!doctype html>" (render-hiccup doc))))
+
+(defn render-tag-name-html
+  [tag-name tag-title tag-items]
+  (let [rows tag-items
+        title (or tag-title tag-name)
+        doc [:html
+             (render-head (str "Tag - " title))
+             [:body
+              [:main.wrap
+               (toolbar-node
+                [:a.toolbar-btn {:href "/"} "Home"]
+                (theme-toggle-node))
+               [:h1 (str "#" title)]
+               (if (seq rows)
+                 [:ul.page-list
+                  (for [row rows]
+                    (render-tagged-item (tag-item-val row :graph_uuid) row))]
+                 [:p "No published pages use this tag yet."])
+               (publish-script)]]]]
+    (str "<!doctype html>" (render-hiccup doc))))
+
+(defn render-home-html
+  []
+  (let [doc [:html
+             (render-head "Logseq Publish")
+             [:body.publish-home
+              [:svg#publish-home-bg.publish-home-bg
+               {:aria-hidden "true"}]
+              [:main.publish-home-card
+               [:div.publish-home-logo "Logseq Publish"]
+               [:h1.publish-home-title
+                "Small notes,"
+                [:br]
+                [:strong "big "]
+                "connections!"]
+               [:p.publish-home-subtitle
+                "Publish your Logseq notes to the web. Each note links through "
+                [:code "#tag"]
+                " or "
+                [:code "[[page]] references"]
+                ", connecting your dots with others."]]
+              [:script
+               "(function(){\n"
+               "  const svg = document.getElementById('publish-home-bg');\n"
+               "  if (!svg) return;\n"
+               "  let width = window.innerWidth;\n"
+               "  let height = window.innerHeight;\n"
+               "  const POINTS_COUNT = 40;\n"
+               "  const MAX_DIST = 160;\n"
+               "  const pts = [];\n"
+               "  let circlesGroup;\n"
+               "  let linesGroup;\n"
+               "\n"
+               "  const cssVar = (name) =>\n"
+               "    getComputedStyle(document.documentElement)\n"
+               "      .getPropertyValue(name)\n"
+               "      .trim();\n"
+               "\n"
+               "  function resize() {\n"
+               "    width = window.innerWidth;\n"
+               "    height = window.innerHeight;\n"
+               "    svg.setAttribute('width', width);\n"
+               "    svg.setAttribute('height', height);\n"
+               "    svg.setAttribute('viewBox', `0 0 ${width} ${height}`);\n"
+               "  }\n"
+               "\n"
+               "  function createSvgElement(tag, attrs) {\n"
+               "    const el = document.createElementNS('http://www.w3.org/2000/svg', tag);\n"
+               "    for (const k in attrs) el.setAttribute(k, attrs[k]);\n"
+               "    return el;\n"
+               "  }\n"
+               "\n"
+               "  function init() {\n"
+               "    resize();\n"
+               "    svg.innerHTML = '';\n"
+               "\n"
+               "    const lineColor = cssVar('--muted') || '#6f6e69';\n"
+               "    const dotColor = cssVar('--ink') || '#282726';\n"
+               "\n"
+               "    linesGroup = createSvgElement('g', {\n"
+               "      stroke: lineColor,\n"
+               "      'stroke-width': 0.6,\n"
+               "      'stroke-linecap': 'round',\n"
+               "      opacity: 0.35\n"
+               "    });\n"
+               "    circlesGroup = createSvgElement('g', { fill: dotColor, opacity: 0.65 });\n"
+               "\n"
+               "    svg.appendChild(linesGroup);\n"
+               "    svg.appendChild(circlesGroup);\n"
+               "    pts.length = 0;\n"
+               "\n"
+               "    for (let i = 0; i < POINTS_COUNT; i++) {\n"
+               "      const x = Math.random() * width;\n"
+               "      const y = Math.random() * height;\n"
+               "      const speed = 0.15 + Math.random() * 0.25;\n"
+               "      const angle = Math.random() * Math.PI * 2;\n"
+               "      const vx = Math.cos(angle) * speed;\n"
+               "      const vy = Math.sin(angle) * speed;\n"
+               "\n"
+               "      const circle = createSvgElement('circle', {\n"
+               "        cx: x,\n"
+               "        cy: y,\n"
+               "        r: 2 + Math.random() * 1.2\n"
+               "      });\n"
+               "\n"
+               "      circlesGroup.appendChild(circle);\n"
+               "      pts.push({ x, y, vx, vy, circle });\n"
+               "    }\n"
+               "  }\n"
+               "\n"
+               "  function step() {\n"
+               "    linesGroup.innerHTML = '';\n"
+               "\n"
+               "    for (let i = 0; i < pts.length; i++) {\n"
+               "      const p = pts[i];\n"
+               "      p.x += p.vx;\n"
+               "      p.y += p.vy;\n"
+               "\n"
+               "      if (p.x < 0 || p.x > width) p.vx *= -1;\n"
+               "      if (p.y < 0 || p.y > height) p.vy *= -1;\n"
+               "\n"
+               "      p.circle.setAttribute('cx', p.x);\n"
+               "      p.circle.setAttribute('cy', p.y);\n"
+               "    }\n"
+               "\n"
+               "    for (let i = 0; i < pts.length; i++) {\n"
+               "      for (let j = i + 1; j < pts.length; j++) {\n"
+               "        const p1 = pts[i];\n"
+               "        const p2 = pts[j];\n"
+               "        const dx = p1.x - p2.x;\n"
+               "        const dy = p1.y - p2.y;\n"
+               "        const dist = Math.sqrt(dx * dx + dy * dy);\n"
+               "        if (dist < MAX_DIST) {\n"
+               "          const opacity = 0.35 * (1 - dist / MAX_DIST);\n"
+               "          const line = createSvgElement('line', {\n"
+               "            x1: p1.x,\n"
+               "            y1: p1.y,\n"
+               "            x2: p2.x,\n"
+               "            y2: p2.y,\n"
+               "            opacity: opacity.toString()\n"
+               "          });\n"
+               "          linesGroup.appendChild(line);\n"
+               "        }\n"
+               "      }\n"
+               "    }\n"
+               "\n"
+               "    requestAnimationFrame(step);\n"
+               "  }\n"
+               "\n"
+               "  window.addEventListener('resize', () => {\n"
+               "    init();\n"
+               "  });\n"
+               "\n"
+               "  init();\n"
+               "  requestAnimationFrame(step);\n"
+               "})();\n"]]]]
+    (str "<!doctype html>" (render-hiccup doc))))
+
+(defn render-ref-html
+  [graph-uuid ref-name ref-title ref-items]
+  (let [rows ref-items
+        title (or ref-title ref-name)
+        doc [:html
+             (render-head (str "Ref - " title))
+             [:body
+              [:main.wrap
+               (toolbar-node
+                [:a.toolbar-btn {:href "/"} "Home"]
+                (theme-toggle-node))
+               [:h1 title]
+               [:p.tag-sub (str "Reference: " ref-name)]
+               (if (seq rows)
+                 [:ul.page-list
+                  (for [row rows
+                        :let [graph-id (or (tag-item-val row :graph_uuid) graph-uuid)
+                              page-uuid (tag-item-val row :source_page_uuid)
+                              page-title (tag-item-val row :source_page_title)
+                              href (when (and graph-id page-uuid)
+                                     (str "/page/" graph-id "/" page-uuid))]]
+                    [:li.page-item
+                     [:div.page-links
+                      (if href
+                        [:a.page-ref {:href href} (or page-title page-uuid)]
+                        [:span (or page-title page-uuid)])]
+                     [:span.page-meta (or (format-timestamp (tag-item-val row :updated_at)) "—")]])]
+                 [:p "No published pages reference this yet."])
+               (publish-script)]]]]
+    (str "<!doctype html>" (render-hiccup doc))))
+
+(defn render-not-published-html
+  [graph-uuid]
+  (let [title "Page not published"
+        doc [:html
+             (render-head title)
+             [:body
+              [:main.wrap
+               (toolbar-node
+                (when graph-uuid
+                  [:a.toolbar-btn {:href (str "/graph/" graph-uuid)} "Home"])
+                (search-node graph-uuid)
+                (theme-toggle-node))
+               [:h1 title]
+               [:p.tag-sub "This page hasn't been published yet."]
+               (publish-script)]]]]
+    (str "<!doctype html>" (render-hiccup doc))))
+
+(defn render-password-html
+  [graph-uuid page-uuid wrong?]
+  (let [title "Protected page"
+        doc [:html
+             (render-head title)
+             [:body
+              [:main.wrap
+               (toolbar-node
+                (when graph-uuid
+                  [:a.toolbar-btn {:href (str "/graph/" graph-uuid)} "Home"])
+                (search-node graph-uuid)
+                (theme-toggle-node))
+               [:div.password-card
+                [:h1 title]
+                [:p.tag-sub "This page is password protected."]
+                (when wrong?
+                  [:p.password-error "Incorrect password."])
+                [:form.password-form {:method "GET"}
+                 (when page-uuid
+                   [:input {:type "hidden" :name "page" :value page-uuid}])
+                 [:label.password-label {:for "publish-password"} "Enter password"]
+                 [:input.password-input {:id "publish-password"
+                                         :name "password"
+                                         :type "password"
+                                         :placeholder "Password"
+                                         :required true}]
+                 [:button.toolbar-btn {:type "submit"} "Unlock"]]]
+               (publish-script)]]]]
+    (str "<!doctype html>" (render-hiccup doc))))
+
+(defn render-404-html
+  []
+  (let [title "Page not found"
+        doc [:html
+             (render-head title)
+             [:body
+              [:main.wrap
+               (toolbar-node
+                [:a.toolbar-btn {:href "/"} "Home"]
+                (theme-toggle-node))
+               [:div.not-found
+                [:p.not-found-eyebrow "404"]
+                [:h1 title]
+                [:p.tag-sub "We couldn't find that page. It may have been removed or never published."]]
+               (publish-script)]]]]
+    (str "<!doctype html>" (render-hiccup doc))))

+ 773 - 0
deps/publish/src/logseq/publish/routes.cljs

@@ -0,0 +1,773 @@
+(ns logseq.publish.routes
+  (:require [cljs-bean.core :as bean]
+            [clojure.string :as string]
+            [logseq.publish.assets :as publish-assets]
+            [logseq.publish.common :as publish-common]
+            [logseq.publish.index :as publish-index]
+            [logseq.publish.model :as publish-model]
+            [logseq.publish.render :as publish-render]
+            [shadow.resource :as resource])
+  (:require-macros [logseq.publish.async :refer [js-await]]))
+
+(def publish-css (resource/inline "logseq/publish/publish.css"))
+(def publish-js (resource/inline "logseq/publish/publish.js"))
+(def tabler-ext-js (resource/inline "js/tabler.ext.js"))
+(def tabler-extension-css (resource/inline "css/tabler-extension.css"))
+
+(defn- request-password
+  [request]
+  (let [url (js/URL. (.-url request))
+        query (.get (.-searchParams url) "password")
+        header (.get (.-headers request) "x-publish-password")]
+    (or header query)))
+
+(defn- fetch-page-password-hash
+  [graph-uuid page-uuid env]
+  (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+             do-id (.idFromName do-ns "index")
+             do-stub (.get do-ns do-id)
+             resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/password")
+                          #js {:method "GET"})]
+            (when (.-ok resp)
+              (js-await [data (.json resp)]
+                        (aget data "password_hash")))))
+
+(defn- check-page-password
+  [request graph-uuid page-uuid env]
+  (js-await [stored-hash (fetch-page-password-hash graph-uuid page-uuid env)]
+            (if (string/blank? stored-hash)
+              {:allowed? true :provided? false}
+              (let [provided (request-password request)]
+                (if (string? provided)
+                  (js-await [valid? (publish-common/verify-password provided stored-hash)]
+                            {:allowed? valid? :provided? true})
+                  {:allowed? false :provided? false})))))
+
+(defn- auth-claims
+  [request env]
+  (js-await [auth-header (.get (.-headers request) "authorization")
+             token (when (and auth-header (string/starts-with? auth-header "Bearer "))
+                     (subs auth-header 7))
+             claims (cond
+                      (nil? token) nil
+                      :else (publish-common/verify-jwt token env))]
+            {:claims claims}))
+
+(defn handle-post-pages [request env]
+  (js-await [auth-header (.get (.-headers request) "authorization")
+             token (when (and auth-header (string/starts-with? auth-header "Bearer "))
+                     (subs auth-header 7))
+             claims (cond
+                      (nil? token) nil
+                      :else (publish-common/verify-jwt token env))]
+            (if (nil? claims)
+              (publish-common/unauthorized)
+              (js-await [body (.arrayBuffer request)]
+                        (let [{:keys [content_hash content_length graph page_uuid schema_version block_count created_at] :as meta}
+                              (or (publish-common/parse-meta-header request)
+                                  (publish-common/meta-from-body body))
+                              payload (publish-common/read-transit-safe (.decode publish-common/text-decoder body))
+                              payload-entities (publish-model/datoms->entities (:datoms payload))
+                              page-eid (some (fn [[e entity]]
+                                               (when (= (:block/uuid entity) (uuid page_uuid))
+                                                 e))
+                                             payload-entities)
+                              page-title (or (:page-title payload)
+                                             (get payload "page-title")
+                                             (when page-eid
+                                               (publish-model/entity->title (get payload-entities page-eid))))
+                              blocks (or (:blocks payload)
+                                         (get payload "blocks"))
+                              page-password (or (:page-password payload)
+                                                (get payload "page-password"))
+                              refs (when (and page-eid page-title)
+                                     (publish-index/page-refs-from-payload payload page-eid page_uuid page-title graph))
+                              tagged-nodes (when (and page-eid page-title)
+                                             (publish-index/page-tagged-nodes-from-payload payload page-eid page_uuid page-title graph))]
+                          (cond
+                            (not (publish-common/valid-meta? meta))
+                            (publish-common/bad-request "missing publish metadata")
+
+                            :else
+                            (js-await [graph-uuid graph
+                                       r2-key (str "publish/" graph-uuid "/"
+                                                   content_hash ".transit")
+                                       r2 (aget env "PUBLISH_R2")
+                                       existing (.head r2 r2-key)
+                                       _ (when-not existing
+                                           (.put r2 r2-key body
+                                                 #js {:httpMetadata #js {:contentType "application/transit+json"}}))
+                                       ^js do-ns (aget env "PUBLISH_META_DO")
+                                       do-id (.idFromName do-ns
+                                                          (str graph-uuid
+                                                               ":"
+                                                               page_uuid))
+                                       do-stub (.get do-ns do-id)
+                                       page-tags (or (:page-tags payload)
+                                                     (get payload "page-tags"))
+                                       short-id (publish-common/short-id-for-page graph-uuid page_uuid)
+                                       owner-sub (:owner_sub meta)
+                                       owner-username (:owner_username meta)
+                                       updated-at (.now js/Date)
+                                       _ (when-not (and owner-sub owner-username)
+                                           (throw (ex-info "owner sub or username is missing"
+                                                           {:owner-sub owner-sub
+                                                            :owner-username owner-username})))
+                                       password-hash (when (and (string? page-password)
+                                                                (not (string/blank? page-password)))
+                                                       (publish-common/hash-password page-password))
+                                       payload (bean/->js
+                                                {:page_uuid page_uuid
+                                                 :page_title page-title
+                                                 :page_tags (when page-tags
+                                                              (js/JSON.stringify (clj->js page-tags)))
+                                                 :password_hash password-hash
+                                                 :graph graph-uuid
+                                                 :schema_version schema_version
+                                                 :block_count block_count
+                                                 :content_hash content_hash
+                                                 :content_length content_length
+                                                 :r2_key r2-key
+                                                 :owner_sub owner-sub
+                                                 :owner_username owner-username
+                                                 :created_at created_at
+                                                 :updated_at updated-at
+                                                 :short_id short-id
+                                                 :refs refs
+                                                 :tagged_nodes tagged-nodes
+                                                 :blocks (when (seq blocks)
+                                                           (map (fn [block]
+                                                                  (assoc block :updated_at updated-at))
+                                                                blocks))})
+                                       meta-resp (.fetch do-stub "https://publish/pages"
+                                                         #js {:method "POST"
+                                                              :headers #js {"content-type" "application/json"}
+                                                              :body (js/JSON.stringify payload)})]
+                                      (if-not (.-ok meta-resp)
+                                        (publish-common/json-response {:error "metadata store failed"} 500)
+                                        (js-await [index-id (.idFromName do-ns "index")
+                                                   index-stub (.get do-ns index-id)
+                                                   _ (.fetch index-stub "https://publish/pages"
+                                                             #js {:method "POST"
+                                                                  :headers #js {"content-type" "application/json"}
+                                                                  :body (js/JSON.stringify payload)})]
+                                                  (publish-common/json-response {:page_uuid page_uuid
+                                                                                 :graph_uuid graph-uuid
+                                                                                 :r2_key r2-key
+                                                                                 :short_id short-id
+                                                                                 :short_url (str "/p/" short-id)
+                                                                                 :updated_at (.now js/Date)}))))))))))
+
+(defn handle-tag-page-html [graph-uuid tag-uuid env]
+  (if (or (nil? graph-uuid) (nil? tag-uuid))
+    (publish-common/bad-request "missing graph uuid or tag uuid")
+    (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+               do-id (.idFromName do-ns "index")
+               do-stub (.get do-ns do-id)
+               tags-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" tag-uuid "/tagged_nodes")
+                                 #js {:method "GET"})]
+              (if-not (.-ok tags-resp)
+                (publish-common/not-found)
+                (js-await [raw (.json tags-resp)
+                           tag-items (js->clj (or (aget raw "tagged_nodes") #js [])
+                                              :keywordize-keys true)
+                           tag-title (or (some (fn [item]
+                                                 (let [title (publish-render/tag-item-val item :tag_title)]
+                                                   (when (and title (not (string/blank? title)))
+                                                     title)))
+                                               tag-items)
+                                         tag-uuid)]
+                          (js/Response.
+                           (publish-render/render-tag-html graph-uuid tag-uuid tag-title tag-items)
+                           #js {:headers (publish-common/merge-headers
+                                          #js {"content-type" "text/html; charset=utf-8"}
+                                          (publish-common/cors-headers))}))))))
+
+(defn handle-get-page [request env]
+  (let [url (js/URL. (.-url request))
+        parts (string/split (.-pathname url) #"/")
+        graph-uuid (nth parts 2 nil)
+        page-uuid (nth parts 3 nil)]
+    (if (or (nil? graph-uuid) (nil? page-uuid))
+      (publish-common/bad-request "missing graph uuid or page uuid")
+      (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+                 do-id (.idFromName do-ns (str graph-uuid ":" page-uuid))
+                 do-stub (.get do-ns do-id)
+                 meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid))]
+                (if-not (.-ok meta-resp)
+                  (handle-tag-page-html graph-uuid page-uuid env)
+                  (js-await [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)]
+                            (if-not allowed?
+                              (publish-common/json-response {:error "password required"} 401)
+                              (js-await [meta (.json meta-resp)
+                                         etag (aget meta "content_hash")
+                                         if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match"))]
+                                        (if (and etag if-none-match (= etag if-none-match))
+                                          (js/Response. nil #js {:status 304
+                                                                 :headers (publish-common/merge-headers
+                                                                           #js {:etag etag}
+                                                                           (publish-common/cors-headers))})
+                                          (publish-common/json-response (js->clj meta :keywordize-keys true) 200))))))))))
+
+(defn handle-get-page-transit [request env]
+  (let [url (js/URL. (.-url request))
+        parts (string/split (.-pathname url) #"/")
+        graph-uuid (nth parts 2 nil)
+        page-uuid (nth parts 3 nil)]
+    (if (or (nil? graph-uuid) (nil? page-uuid))
+      (publish-common/bad-request "missing graph uuid or page uuid")
+      (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+                 do-id (.idFromName do-ns (str graph-uuid ":" page-uuid))
+                 do-stub (.get do-ns do-id)
+                 meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid))]
+                (if-not (.-ok meta-resp)
+                  (js/Response.
+                   (publish-render/render-404-html)
+                   #js {:headers (publish-common/merge-headers
+                                  #js {"content-type" "text/html; charset=utf-8"}
+                                  (publish-common/cors-headers))})
+                  (js-await [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)]
+                            (if-not allowed?
+                              (publish-common/json-response {:error "password required"} 401)
+                              (js-await [meta (.json meta-resp)
+                                         r2-key (aget meta "r2_key")]
+                                        (if-not r2-key
+                                          (publish-common/json-response {:error "missing transit"} 404)
+                                          (js-await [etag (aget meta "content_hash")
+                                                     if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match"))
+                                                     signed-url (when-not (and etag if-none-match (= etag if-none-match))
+                                                                  (publish-common/presign-r2-url r2-key env))]
+                                                    (if (and etag if-none-match (= etag if-none-match))
+                                                      (js/Response. nil #js {:status 304
+                                                                             :headers (publish-common/merge-headers
+                                                                                       #js {:etag etag}
+                                                                                       (publish-common/cors-headers))})
+                                                      (publish-common/json-response {:url signed-url
+                                                                                     :expires_in 300
+                                                                                     :etag etag}
+                                                                                    200))))))))))))
+
+(defn handle-get-page-refs [request env]
+  (let [url (js/URL. (.-url request))
+        parts (string/split (.-pathname url) #"/")
+        graph-uuid (nth parts 2 nil)
+        page-uuid (nth parts 3 nil)]
+    (if (or (nil? graph-uuid) (nil? page-uuid))
+      (publish-common/bad-request "missing graph uuid or page uuid")
+      (js-await [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)]
+                (if-not allowed?
+                  (publish-common/json-response {:error "password required"} 401)
+                  (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+                             do-id (.idFromName do-ns "index")
+                             do-stub (.get do-ns do-id)
+                             refs-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/refs"))]
+                            (if-not (.-ok refs-resp)
+                              (js/Response.
+                               (publish-render/render-404-html)
+                               #js {:headers (publish-common/merge-headers
+                                              #js {"content-type" "text/html; charset=utf-8"}
+                                              (publish-common/cors-headers))})
+                              (js-await [refs (.json refs-resp)]
+                                        (publish-common/json-response (js->clj refs :keywordize-keys true) 200)))))))))
+
+(defn handle-get-page-tagged-nodes [request env]
+  (let [url (js/URL. (.-url request))
+        parts (string/split (.-pathname url) #"/")
+        graph-uuid (nth parts 2 nil)
+        page-uuid (nth parts 3 nil)]
+    (if (or (nil? graph-uuid) (nil? page-uuid))
+      (publish-common/bad-request "missing graph uuid or page uuid")
+      (js-await [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)]
+                (if-not allowed?
+                  (publish-common/json-response {:error "password required"} 401)
+                  (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+                             do-id (.idFromName do-ns "index")
+                             do-stub (.get do-ns do-id)
+                             tags-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes"))]
+                            (if-not (.-ok tags-resp)
+                              (publish-common/not-found)
+                              (js-await [tags (.json tags-resp)]
+                                        (publish-common/json-response (js->clj tags :keywordize-keys true) 200)))))))))
+
+(defn handle-list-pages [env]
+  (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+             do-id (.idFromName do-ns "index")
+             do-stub (.get do-ns do-id)
+             meta-resp (.fetch do-stub "https://publish/pages" #js {:method "GET"})]
+            (if-not (.-ok meta-resp)
+              (js/Response.
+               (publish-render/render-404-html)
+               #js {:headers (publish-common/merge-headers
+                              #js {"content-type" "text/html; charset=utf-8"}
+                              (publish-common/cors-headers))})
+              (js-await [meta (.json meta-resp)]
+                        (publish-common/json-response (js->clj meta :keywordize-keys true) 200)))))
+
+(defn handle-list-graph-pages-by-uuid [graph-uuid env]
+  (if-not graph-uuid
+    (publish-common/bad-request "missing graph uuid")
+    (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+               do-id (.idFromName do-ns "index")
+               do-stub (.get do-ns do-id)
+               meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid)
+                                 #js {:method "GET"})]
+              (if-not (.-ok meta-resp)
+                (js/Response.
+                 (publish-render/render-404-html)
+                 #js {:headers (publish-common/merge-headers
+                                #js {"content-type" "text/html; charset=utf-8"}
+                                (publish-common/cors-headers))})
+                (js-await [meta (.json meta-resp)]
+                          (publish-common/json-response (js->clj meta :keywordize-keys true) 200))))))
+
+(defn handle-graph-search [request env]
+  (let [url (js/URL. (.-url request))
+        parts (string/split (.-pathname url) #"/")
+        graph-uuid (nth parts 2 nil)
+        query (.get (.-searchParams url) "q")]
+    (if (or (string/blank? graph-uuid) (string/blank? query))
+      (publish-common/bad-request "missing graph uuid or query")
+      (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+                 do-id (.idFromName do-ns "index")
+                 do-stub (.get do-ns do-id)
+                 resp (.fetch do-stub
+                              (str "https://publish/search/" graph-uuid
+                                   "?q=" (js/encodeURIComponent query))
+                              #js {:method "GET"})]
+                (if-not (.-ok resp)
+                  (publish-common/not-found)
+                  (js-await [data (.json resp)]
+                            (publish-common/json-response (js->clj data :keywordize-keys true) 200)))))))
+
+(defn handle-graph-html [graph-uuid env]
+  (if-not graph-uuid
+    (publish-common/bad-request "missing graph uuid")
+    (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+               do-id (.idFromName do-ns "index")
+               do-stub (.get do-ns do-id)
+               meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid)
+                                 #js {:method "GET"})]
+              (if-not (.-ok meta-resp)
+                (js/Response.
+                 (publish-render/render-404-html)
+                 #js {:headers (publish-common/merge-headers
+                                #js {"content-type" "text/html; charset=utf-8"}
+                                (publish-common/cors-headers))})
+                (js-await [meta (.json meta-resp)
+                           pages (or (aget meta "pages") #js [])]
+                          (js/Response.
+                           (publish-render/render-graph-html graph-uuid pages)
+                           #js {:headers (publish-common/merge-headers
+                                          #js {"content-type" "text/html; charset=utf-8"}
+                                          (publish-common/cors-headers))}))))))
+
+(defn handle-tag-name-json [tag-name env]
+  (if-not tag-name
+    (publish-common/bad-request "missing tag name")
+    (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+               do-id (.idFromName do-ns "index")
+               do-stub (.get do-ns do-id)
+               resp (.fetch do-stub (str "https://publish/tag/" (js/encodeURIComponent tag-name))
+                            #js {:method "GET"})]
+              (if-not (.-ok resp)
+                (js/Response.
+                 (publish-render/render-404-html)
+                 #js {:headers (publish-common/merge-headers
+                                #js {"content-type" "text/html; charset=utf-8"}
+                                (publish-common/cors-headers))})
+                (js-await [data (.json resp)]
+                          (publish-common/json-response (js->clj data :keywordize-keys true) 200))))))
+
+(defn handle-tag-name-html [tag-name env]
+  (if-not tag-name
+    (publish-common/bad-request "missing tag name")
+    (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+               do-id (.idFromName do-ns "index")
+               do-stub (.get do-ns do-id)
+               resp (.fetch do-stub (str "https://publish/tag/" (js/encodeURIComponent tag-name))
+                            #js {:method "GET"})]
+              (if-not (.-ok resp)
+                (js/Response.
+                 (publish-render/render-404-html)
+                 #js {:headers (publish-common/merge-headers
+                                #js {"content-type" "text/html; charset=utf-8"}
+                                (publish-common/cors-headers))})
+                (js-await [data (.json resp)
+                           rows (or (aget data "tagged_nodes") #js [])
+                           title (or tag-name "Tag")]
+                          (js/Response.
+                           (publish-render/render-tag-name-html tag-name title rows)
+                           #js {:headers (publish-common/merge-headers
+                                          #js {"content-type" "text/html; charset=utf-8"}
+                                          (publish-common/cors-headers))}))))))
+
+(defn handle-ref-name-json [ref-name env]
+  (if-not ref-name
+    (publish-common/bad-request "missing ref name")
+    (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+               do-id (.idFromName do-ns "index")
+               do-stub (.get do-ns do-id)
+               resp (.fetch do-stub (str "https://publish/ref/" (js/encodeURIComponent ref-name))
+                            #js {:method "GET"})]
+              (if-not (.-ok resp)
+                (publish-common/not-found)
+                (js-await [data (.json resp)]
+                          (publish-common/json-response (js->clj data :keywordize-keys true) 200))))))
+
+(defn handle-ref-name-html [ref-name env]
+  (if-not ref-name
+    (publish-common/bad-request "missing ref name")
+    (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+               do-id (.idFromName do-ns "index")
+               do-stub (.get do-ns do-id)
+               resp (.fetch do-stub (str "https://publish/ref/" (js/encodeURIComponent ref-name))
+                            #js {:method "GET"})]
+              (if-not (.-ok resp)
+                (publish-common/not-found)
+                (js-await [data (.json resp)
+                           rows (or (aget data "pages") #js [])
+                           title (or ref-name "Reference")]
+                          (js/Response.
+                           (publish-render/render-ref-html "all" ref-name title rows)
+                           #js {:headers (publish-common/merge-headers
+                                          #js {"content-type" "text/html; charset=utf-8"}
+                                          (publish-common/cors-headers))}))))))
+
+(defn handle-list-graph-pages [request env]
+  (let [url (js/URL. (.-url request))
+        parts (string/split (.-pathname url) #"/")
+        graph-uuid (nth parts 2 nil)]
+    (handle-list-graph-pages-by-uuid graph-uuid env)))
+
+(defn handle-delete-page [request env]
+  (let [url (js/URL. (.-url request))
+        parts (string/split (.-pathname url) #"/")
+        graph-uuid (nth parts 2 nil)
+        page-uuid (nth parts 3 nil)]
+    (if (or (nil? graph-uuid) (nil? page-uuid))
+      (publish-common/bad-request "missing graph uuid or page uuid")
+      (js-await [{:keys [claims]} (auth-claims request env)]
+                (if (nil? claims)
+                  (publish-common/unauthorized)
+                  (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+                             page-id (.idFromName do-ns (str graph-uuid ":" page-uuid))
+                             page-stub (.get do-ns page-id)
+                             index-id (.idFromName do-ns "index")
+                             index-stub (.get do-ns index-id)
+                             meta-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid)
+                                               #js {:method "GET"})]
+                            (if-not (.-ok meta-resp)
+                              (publish-common/not-found)
+                              (js-await [meta (.json meta-resp)
+                                         owner-sub (aget meta "owner_sub")
+                                         subject (aget claims "sub")]
+                                        (if (and (or (string/blank? owner-sub)
+                                                     (not= owner-sub subject)))
+                                          (publish-common/forbidden)
+                                          (js-await [page-resp (.fetch page-stub (str "https://publish/pages/" graph-uuid "/" page-uuid)
+                                                                       #js {:method "DELETE"})
+                                                     index-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid)
+                                                                        #js {:method "DELETE"})]
+                                                    (if (or (not (.-ok page-resp)) (not (.-ok index-resp)))
+                                                      (publish-common/not-found)
+                                                      (publish-common/json-response {:ok true} 200))))))))))))
+
+(defn handle-delete-graph [request env]
+  (let [url (js/URL. (.-url request))
+        parts (string/split (.-pathname url) #"/")
+        graph-uuid (nth parts 2 nil)]
+    (if-not graph-uuid
+      (publish-common/bad-request "missing graph uuid")
+      (js-await [{:keys [claims]} (auth-claims request env)]
+                (if (nil? claims)
+                  (publish-common/unauthorized)
+                  (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+                             index-id (.idFromName do-ns "index")
+                             index-stub (.get do-ns index-id)
+                             list-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid)
+                                               #js {:method "GET"})]
+                            (if-not (.-ok list-resp)
+                              (publish-common/not-found)
+                              (js-await [data (.json list-resp)
+                                         pages (or (aget data "pages") #js [])
+                                         subject (aget claims "sub")
+                                         owner-mismatch? (some (fn [page]
+                                                                 (let [owner-sub (aget page "owner_sub")]
+                                                                   (or (string/blank? owner-sub)
+                                                                       (not= owner-sub subject))))
+                                                               (array-seq pages))]
+                                        (if owner-mismatch?
+                                          (publish-common/forbidden)
+                                          (js-await [_ (js/Promise.all
+                                                        (map (fn [page]
+                                                               (let [page-uuid (aget page "page_uuid")
+                                                                     page-id (.idFromName do-ns (str graph-uuid ":" page-uuid))
+                                                                     page-stub (.get do-ns page-id)]
+                                                                 (.fetch page-stub (str "https://publish/pages/" graph-uuid "/" page-uuid)
+                                                                         #js {:method "DELETE"})))
+                                                             pages))
+                                                     del-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid)
+                                                                      #js {:method "DELETE"})]
+                                                    (if-not (.-ok del-resp)
+                                                      (publish-common/not-found)
+                                                      (publish-common/json-response {:ok true} 200))))))))))))
+
+(defn handle-page-html [request env]
+  (let [url (js/URL. (.-url request))
+        parts (string/split (.-pathname url) #"/")
+        graph-uuid (nth parts 2 nil)
+        page-uuid (nth parts 3 nil)]
+    (if (or (nil? graph-uuid) (nil? page-uuid))
+      (publish-common/bad-request "missing graph uuid or page uuid")
+      (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+                 do-id (.idFromName do-ns (str graph-uuid ":" page-uuid))
+                 do-stub (.get do-ns do-id)
+                 meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid))]
+                (if-not (.-ok meta-resp)
+                  (js-await [index-id (.idFromName do-ns "index")
+                             index-stub (.get do-ns index-id)
+                             tags-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes")
+                                               #js {:method "GET"})]
+                            (if (and tags-resp (.-ok tags-resp))
+                              (js-await [raw (.json tags-resp)
+                                         tag-items (js->clj (or (aget raw "tagged_nodes") #js [])
+                                                            :keywordize-keys true)
+                                         tag-title (or (some (fn [item]
+                                                               (let [title (publish-render/tag-item-val item :tag_title)]
+                                                                 (when (and title (not (string/blank? title)))
+                                                                   title)))
+                                                             tag-items)
+                                                       page-uuid)]
+                                        (if (seq tag-items)
+                                          (js/Response.
+                                           (publish-render/render-tag-html graph-uuid page-uuid tag-title tag-items)
+                                           #js {:headers (publish-common/merge-headers
+                                                          #js {"content-type" "text/html; charset=utf-8"}
+                                                          (publish-common/cors-headers))})
+                                          (js/Response.
+                                           (publish-render/render-not-published-html graph-uuid)
+                                           #js {:headers (publish-common/merge-headers
+                                                          #js {"content-type" "text/html; charset=utf-8"}
+                                                          (publish-common/cors-headers))})))
+                              (js/Response.
+                               (publish-render/render-not-published-html graph-uuid)
+                               #js {:headers (publish-common/merge-headers
+                                              #js {"content-type" "text/html; charset=utf-8"}
+                                              (publish-common/cors-headers))})))
+                  (js-await [{:keys [allowed? provided?]} (check-page-password request graph-uuid page-uuid env)]
+                            (if-not allowed?
+                              (js/Response.
+                               (publish-render/render-password-html graph-uuid page-uuid provided?)
+                               #js {:status 401
+                                    :headers (publish-common/merge-headers
+                                              #js {"content-type" "text/html; charset=utf-8"}
+                                              (publish-common/cors-headers))})
+                              (js-await [meta (.json meta-resp)
+                                         etag (aget meta "content_hash")
+                                         if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match"))
+                                         index-id (.idFromName do-ns "index")
+                                         index-stub (.get do-ns index-id)
+                                         refs-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/refs"))
+                                         refs-json (when (and refs-resp (.-ok refs-resp))
+                                                     (js-await [raw (.json refs-resp)]
+                                                               (js->clj raw :keywordize-keys false)))
+                                         tags-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes")
+                                                           #js {:method "GET"})
+                                         tagged-nodes (when (and tags-resp (.-ok tags-resp))
+                                                        (js-await [raw (.json tags-resp)]
+                                                                  (js->clj (or (aget raw "tagged_nodes") #js [])
+                                                                           :keywordize-keys true)))
+                                         r2 (aget env "PUBLISH_R2")
+                                         object (.get r2 (aget meta "r2_key"))]
+                                        (if (and etag if-none-match (= etag if-none-match))
+                                          (js/Response. nil #js {:status 304
+                                                                 :headers (publish-common/merge-headers
+                                                                           #js {:etag etag
+                                                                                "cache-control" "public, max-age=300, must-revalidate"}
+                                                                           (publish-common/cors-headers))})
+                                          (if-not object
+                                            (publish-common/json-response {:error "missing transit blob"} 404)
+                                            (js-await [buffer (.arrayBuffer object)
+                                                       transit (.decode publish-common/text-decoder buffer)]
+                                                      (let [headers (publish-common/merge-headers
+                                                                     #js {"content-type" "text/html; charset=utf-8"
+                                                                          "cache-control" "public, max-age=300, must-revalidate"}
+                                                                     (publish-common/cors-headers))]
+                                                        (when etag
+                                                          (.set headers "etag" etag))
+                                                        (js/Response.
+                                                         (publish-render/render-page-html transit page-uuid refs-json tagged-nodes)
+                                                         #js {:headers headers})))))))))))))
+
+(defn handle-fetch [request env]
+  (let [url (js/URL. (.-url request))
+        path (.-pathname url)
+        method (.-method request)]
+    (cond
+      (= method "OPTIONS")
+      (js/Response. nil #js {:status 204 :headers (publish-common/cors-headers)})
+
+      (and (= path "/static/publish.css") (= method "GET"))
+      (js/Response.
+       publish-css
+       #js {:headers (publish-common/merge-headers
+                      #js {"content-type" "text/css; charset=utf-8"
+                           "cache-control" "public, max-age=31536000, immutable"}
+                      (publish-common/cors-headers))})
+
+      (and (= path "/static/publish.js") (= method "GET"))
+      (js/Response.
+       publish-js
+       #js {:headers (publish-common/merge-headers
+                      #js {"content-type" "text/javascript; charset=utf-8"
+                           "cache-control" "public, max-age=31536000, immutable"}
+                      (publish-common/cors-headers))})
+
+      (and (= path "/static/tabler.ext.js") (= method "GET"))
+      (js/Response.
+       tabler-ext-js
+       #js {:headers (publish-common/merge-headers
+                      #js {"content-type" "text/javascript; charset=utf-8"
+                           "cache-control" "public, max-age=31536000, immutable"}
+                      (publish-common/cors-headers))})
+
+      (and (= path "/") (= method "GET"))
+      (js/Response.
+       (publish-render/render-home-html)
+       #js {:headers (publish-common/merge-headers
+                      #js {"content-type" "text/html; charset=utf-8"
+                           "cache-control" "public, max-age=31536000, immutable"}
+                      (publish-common/cors-headers))})
+
+      (and (string/starts-with? path "/page/") (= method "GET"))
+      (handle-page-html request env)
+
+      (and (= path "/assets") (= method "POST"))
+      (publish-assets/handle-post-asset request env)
+
+      (and (= path "/pages") (= method "POST"))
+      (handle-post-pages request env)
+
+      (and (= path "/pages") (= method "GET"))
+      (handle-list-pages env)
+
+      (and (string/starts-with? path "/search/") (= method "GET"))
+      (handle-graph-search request env)
+
+      (and (string/starts-with? path "/graph/") (= method "GET"))
+      (let [parts (string/split path #"/")
+            graph-uuid (nth parts 2 nil)]
+        (if (= (nth parts 3 nil) "json")
+          (handle-list-graph-pages-by-uuid graph-uuid env)
+          (handle-graph-html graph-uuid env)))
+
+      (and (string/starts-with? path "/tag/") (= method "GET"))
+      (let [parts (string/split path #"/")
+            raw-name (nth parts 2 nil)
+            tag-name (when raw-name
+                       (js/decodeURIComponent raw-name))]
+        (if (= (nth parts 3 nil) "json")
+          (handle-tag-name-json tag-name env)
+          (handle-tag-name-html tag-name env)))
+
+      (and (string/starts-with? path "/ref/") (= method "GET"))
+      (let [parts (string/split path #"/")
+            raw-name (nth parts 2 nil)
+            ref-name (when raw-name
+                       (js/decodeURIComponent raw-name))]
+        (if (= (nth parts 3 nil) "json")
+          (handle-ref-name-json ref-name env)
+          (handle-ref-name-html ref-name env)))
+
+      (and (string/starts-with? path "/asset/") (= method "GET"))
+      (let [parts (string/split path #"/")
+            graph-uuid (nth parts 2 nil)
+            file-name (nth parts 3 nil)]
+        (if (or (string/blank? graph-uuid) (string/blank? file-name))
+          (publish-common/bad-request "missing asset id")
+          (let [ext-idx (string/last-index-of file-name ".")
+                asset-uuid (when (and ext-idx (pos? ext-idx))
+                             (subs file-name 0 ext-idx))
+                asset-type (when (and ext-idx (pos? ext-idx))
+                             (subs file-name (inc ext-idx)))]
+            (if (or (string/blank? asset-uuid) (string/blank? asset-type))
+              (publish-common/bad-request "invalid asset id")
+              (js-await [r2 (aget env "PUBLISH_R2")
+                         r2-key (str "publish/assets/" graph-uuid "/" asset-uuid "." asset-type)
+                         ^js object (.get r2 r2-key)]
+                        (if-not object
+                          (publish-common/not-found)
+                          (let [headers (publish-common/merge-headers
+                                         #js {"content-type" (or (some-> object .-httpMetadata .-contentType)
+                                                                 (publish-assets/asset-content-type asset-type))
+                                              "cache-control" "public, max-age=31536000, immutable"}
+                                         (publish-common/cors-headers))]
+                            (js/Response. (.-body object)
+                                          #js {:headers headers}))))))))
+
+      (and (string/starts-with? path "/p/") (= method "GET"))
+      (let [parts (string/split path #"/")
+            short-id (nth parts 2 nil)]
+        (if (string/blank? short-id)
+          (publish-common/bad-request "missing short id")
+          (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+                     do-id (.idFromName do-ns "index")
+                     do-stub (.get do-ns do-id)
+                     resp (.fetch do-stub (str "https://publish/short/" short-id)
+                                  #js {:method "GET"})]
+                    (if-not (.-ok resp)
+                      (publish-common/not-found)
+                      (js-await [data (.json resp)
+                                 row (aget data "page")]
+                                (if-not row
+                                  (publish-common/not-found)
+                                  (let [graph-uuid (aget row "graph_uuid")
+                                        page-uuid (aget row "page_uuid")
+                                        location (str "/page/" graph-uuid "/" page-uuid)]
+                                    (js/Response. nil #js {:status 302
+                                                           :headers (publish-common/merge-headers
+                                                                     #js {"location" location}
+                                                                     (publish-common/cors-headers))}))))))))
+
+      (and (string/starts-with? path "/u/") (= method "GET"))
+      (let [parts (string/split path #"/")
+            username (nth parts 2 nil)]
+        (if (string/blank? username)
+          (publish-common/bad-request "missing username")
+          (js-await [^js do-ns (aget env "PUBLISH_META_DO")
+                     index-id (.idFromName do-ns "index")
+                     index-stub (.get do-ns index-id)
+                     resp (.fetch index-stub (str "https://publish/user/" username)
+                                  #js {:method "GET"})]
+                    (if-not (.-ok resp)
+                      (publish-common/not-found)
+                      (js-await [data (.json resp)
+                                 user (aget data "user")
+                                 rows (or (aget data "pages") #js [])]
+                                (js/Response.
+                                 (publish-render/render-user-html username user rows)
+                                 #js {:headers (publish-common/merge-headers
+                                                #js {"content-type" "text/html; charset=utf-8"}
+                                                (publish-common/cors-headers))}))))))
+
+      (and (string/starts-with? path "/pages/") (= method "GET"))
+      (let [parts (string/split path #"/")]
+        (cond
+          (= (count parts) 3) (handle-list-graph-pages request env)
+          (= (nth parts 4 nil) "transit") (handle-get-page-transit request env)
+          (= (nth parts 4 nil) "refs") (handle-get-page-refs request env)
+          (= (nth parts 4 nil) "tagged_nodes") (handle-get-page-tagged-nodes request env)
+          :else (handle-get-page request env)))
+
+      (and (string/starts-with? path "/pages/") (= method "DELETE"))
+      (let [parts (string/split path #"/")]
+        (if (= (count parts) 3)
+          (handle-delete-graph request env)
+          (handle-delete-page request env)))
+
+      :else
+      (js/Response.
+       (publish-render/render-404-html)
+       #js {:headers (publish-common/merge-headers
+                      #js {"content-type" "text/html; charset=utf-8"}
+                      (publish-common/cors-headers))}))))

+ 22 - 0
deps/publish/src/logseq/publish/worker.cljs

@@ -0,0 +1,22 @@
+(ns logseq.publish.worker
+  (:require ["cloudflare:workers" :refer [DurableObject]]
+            [logseq.publish.meta-store :as meta-store]
+            [logseq.publish.routes :as publish-routes]
+            [shadow.cljs.modern :refer (defclass)]))
+
+(def worker
+  #js {:fetch (fn [request env _ctx]
+                (publish-routes/handle-fetch request env))})
+
+(defclass PublishMetaDO
+  (extends DurableObject)
+
+  (constructor [this ^js state env]
+               (super state env)
+               (set! (.-state this) state)
+               (set! (.-env this) env)
+               (set! (.-sql this) (.-sql ^js (.-storage state))))
+
+  Object
+  (fetch [this request]
+         (meta-store/do-fetch this request)))

+ 46 - 0
deps/publish/worker/README.md

@@ -0,0 +1,46 @@
+## Cloudflare Publish Worker (Skeleton)
+
+This worker accepts publish payloads and stores transit blobs in R2 while keeping
+metadata in a Durable Object backed by SQLite.
+
+### Bindings
+
+- `PUBLISH_META_DO`: Durable Object namespace
+- `PUBLISH_R2`: R2 bucket
+- `R2_ACCOUNT_ID`: Cloudflare account id for signing
+- `R2_BUCKET`: R2 bucket name for signing
+- `R2_ACCESS_KEY_ID`: R2 access key for signing
+- `R2_SECRET_ACCESS_KEY`: R2 secret key for signing
+- `COGNITO_JWKS_URL`: JWKS URL for Cognito user pool
+- `COGNITO_ISSUER`: Cognito issuer URL
+- `COGNITO_CLIENT_ID`: Cognito client ID
+- `DEV_SKIP_AUTH`: set to `true` to bypass JWT verification in local dev
+
+### Routes
+
+- `GET /p/:graph-uuid/:page-uuid`
+  - Returns server-rendered HTML for the page
+- `POST /pages`
+  - Requires `Authorization: Bearer <JWT>`
+  - Requires `x-publish-meta` header (JSON)
+  - Body is transit payload (stored in R2 as-is)
+- `GET /pages/:graph-uuid/:page-uuid`
+  - Returns metadata for the page
+- `GET /pages/:graph-uuid/:page-uuid/transit`
+  - Returns JSON with a signed R2 URL and `etag`
+- `DELETE /pages/:graph-uuid/:page-uuid`
+  - Deletes a published page
+- `DELETE /pages/:graph-uuid`
+  - Deletes all pages for a graph
+- `GET /pages`
+  - Lists metadata entries (from the index DO)
+
+### Notes
+
+- This is a starter implementation. Integrate with your deployment tooling
+  (wrangler, etc.) as needed.
+- For local testing, run `wrangler dev` and use `deps/publish/worker/scripts/dev_test.sh`.
+- If you switch schema versions, clear local DO state with
+  `deps/publish/worker/scripts/clear_dev_state.sh`.
+- Build the worker bundle with `clojure -M:cljs release publish-worker` before running the worker.
+- For dev, you can run `clojure -M:cljs watch publish-worker` in one terminal.

+ 12 - 0
deps/publish/worker/scripts/clear_dev_state.sh

@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+GRAPH_UUID=${GRAPH_UUID:-"00000000-0000-0000-0000-000000000000"}
+
+cat <<MSG
+To clear local Durable Object state, remove the miniflare state directory:
+  rm -rf .wrangler/state/v3/durable-objects/${GRAPH_UUID}
+
+If your dev environment uses a different state path, locate it under:
+  .wrangler/state/v3/
+MSG

+ 36 - 0
deps/publish/worker/scripts/dev_test.sh

@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+BASE_URL=${BASE_URL:-"http://127.0.0.1:8787"}
+GRAPH_UUID=${GRAPH_UUID:-"00000000-0000-0000-0000-000000000000"}
+PAGE_UUID=${PAGE_UUID:-"00000000-0000-0000-0000-000000000001"}
+
+META=$(cat <<JSON
+{"page-uuid":"${PAGE_UUID}","block-count":1,"schema-version":"0","publish/format":"transit","publish/compression":"none","publish/content-hash":"dev","publish/content-length":1,"publish/graph":"${GRAPH_UUID}","publish/created-at":0}
+JSON
+)
+
+PAYLOAD="{}"
+
+curl -sS -X POST "${BASE_URL}/pages" \
+  -H "content-type: application/transit+json" \
+  -H "x-publish-meta: ${META}" \
+  --data-binary "${PAYLOAD}"
+
+echo
+
+curl -sS "${BASE_URL}/pages/${GRAPH_UUID}/${PAGE_UUID}"
+
+echo
+
+curl -sS "${BASE_URL}/pages/${GRAPH_UUID}/${PAGE_UUID}/transit"
+
+echo
+
+curl -sS "${BASE_URL}/pages"
+
+echo
+
+curl -sS "${BASE_URL}/p/${GRAPH_UUID}/${PAGE_UUID}"
+
+echo

+ 71 - 0
deps/publish/worker/wrangler.toml

@@ -0,0 +1,71 @@
+name = "logseq-publish"
+main = "dist/worker/main.js"
+compatibility_date = "2025-02-04"
+compatibility_flags = ["nodejs_compat"]
+
+# Workers Logs
+# Docs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/
+# Configuration: https://developers.cloudflare.com/workers/observability/logs/workers-logs/#enable-workers-logs
+[observability]
+enabled = true
+
+[[durable_objects.bindings]]
+name = "PUBLISH_META_DO"
+class_name = "PublishMetaDO"
+
+[[migrations]]
+tag = "v2"
+new_sqlite_classes = ["PublishMetaDO"]
+
+[[r2_buckets]]
+binding = "PUBLISH_R2"
+bucket_name = "logseq-publish-dev"
+
+[vars]
+COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json"
+COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8"
+COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6"
+
+[env.staging]
+name = "logseq-publish-staging"
+
+[env.staging.vars]
+COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json"
+COGNITO_ISSUER   = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8"
+COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6"
+
+[[env.staging.durable_objects.bindings]]
+name = "PUBLISH_META_DO"
+class_name = "PublishMetaDO"
+
+[[env.staging.migrations]]
+tag = "v2"
+new_sqlite_classes = ["PublishMetaDO"]
+
+[[env.staging.r2_buckets]]
+binding = "PUBLISH_R2"
+bucket_name = "logseq-publish-dev"
+
+[env.prod]
+name = "logseq-publish-prod"
+
+[env.prod.vars]
+COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json"
+COGNITO_ISSUER   = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8"
+COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6"
+
+[[env.prod.durable_objects.bindings]]
+name = "PUBLISH_META_DO"
+class_name = "PublishMetaDO"
+
+[[env.prod.migrations]]
+tag = "v2"
+new_sqlite_classes = ["PublishMetaDO"]
+
+[[env.prod.r2_buckets]]
+binding = "PUBLISH_R2"
+bucket_name = "logseq-publish-prod"
+
+[[env.prod.routes]]
+pattern = "logseq.io"
+custom_domain = true

+ 84 - 0
deps/publish/yarn.lock

@@ -0,0 +1,84 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+base64-js@^1.3.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
+  integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
+
+buffer-from@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
+  integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
+
+buffer@^6.0.3:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
+  integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==
+  dependencies:
+    base64-js "^1.3.1"
+    ieee754 "^1.2.1"
+
+ieee754@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
+  integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
+
+isexe@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/isexe/-/isexe-3.1.1.tgz#4a407e2bd78ddfb14bea0c27c6f7072dde775f0d"
+  integrity sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==
+
+process@^0.11.10:
+  version "0.11.10"
+  resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
+  integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
+
+readline-sync@^1.4.10:
+  version "1.4.10"
+  resolved "https://registry.yarnpkg.com/readline-sync/-/readline-sync-1.4.10.tgz#41df7fbb4b6312d673011594145705bf56d8873b"
+  integrity sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==
+
[email protected]:
+  version "1.3.4"
+  resolved "https://registry.yarnpkg.com/shadow-cljs-jar/-/shadow-cljs-jar-1.3.4.tgz#0939d91c468b4bc5eab5a958f79e7ef5696fdf62"
+  integrity sha512-cZB2pzVXBnhpJ6PQdsjO+j/MksR28mv4QD/hP/2y1fsIa9Z9RutYgh3N34FZ8Ktl4puAXaIGlct+gMCJ5BmwmA==
+
+shadow-cljs@^3.3.4:
+  version "3.3.4"
+  resolved "https://registry.yarnpkg.com/shadow-cljs/-/shadow-cljs-3.3.4.tgz#d1593c1ad4eee1ed34f57aa68cdfc5caaf5696d9"
+  integrity sha512-xZV+Ek5TeQtqcY++Otpto5DW+gXu/znIJjtTZjhfQl1yYxnfQNSyC2pS9/XoI3kmmQza3oY5WA0b45gS7W7W5g==
+  dependencies:
+    buffer "^6.0.3"
+    process "^0.11.10"
+    readline-sync "^1.4.10"
+    shadow-cljs-jar "1.3.4"
+    source-map-support "^0.5.21"
+    which "^5.0.0"
+    ws "^8.18.1"
+
+source-map-support@^0.5.21:
+  version "0.5.21"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
+  integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
+  dependencies:
+    buffer-from "^1.0.0"
+    source-map "^0.6.0"
+
+source-map@^0.6.0:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+  integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
+which@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/which/-/which-5.0.0.tgz#d93f2d93f79834d4363c7d0c23e00d07c466c8d6"
+  integrity sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==
+  dependencies:
+    isexe "^3.1.1"
+
+ws@^8.18.1:
+  version "8.18.3"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472"
+  integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==

+ 2 - 0
scripts/nbb.edn

@@ -7,5 +7,7 @@
   ;; for config.edn
   logseq/common
   {:local/root "../deps/common"}
+  logseq/publish
+  {:local/root "../deps/publish"}
   logseq/publishing
   {:local/root "../deps/publishing"}}}

+ 1 - 1
scripts/src/logseq/tasks/dev/lint.clj

@@ -24,7 +24,7 @@
 (defn kondo-git-changes
   "Run clj-kondo across dirs and only for files that git diff detects as unstaged changes"
   []
-  (let [kondo-dirs ["src" "deps/common" "deps/db" "deps/graph-parser" "deps/outliner" "deps/publishing" "deps/cli"]
+  (let [kondo-dirs ["src" "deps/common" "deps/db" "deps/graph-parser" "deps/outliner" "deps/publish" "deps/publishing" "deps/cli"]
         dir-regex (re-pattern (str "^(" (string/join "|" kondo-dirs) ")"))
         dir-to-files (->> (shell {:out :string} "git diff --name-only")
                           :out

+ 1 - 0
shadow-cljs.edn

@@ -1,6 +1,7 @@
 ;; shadow-cljs configuration
 {:deps true
  :nrepl {:port 8701}
+ :source-paths ["src/main" "src/electron" "src/resources"]
 
  ;; :ssl   {:password "logseq"}
 

+ 3 - 0
src/main/frontend/common/missionary.cljs

@@ -4,6 +4,7 @@
   (:require [cljs.core.async.impl.channels]
             [clojure.core.async :as a]
             [lambdaisland.glogi :as log]
+            [logseq.db.common.entity-plus :as entity-plus]
             [missionary.core :as m]
             [promesa.protocols :as pt])
   (:import [missionary Cancelled]))
@@ -158,6 +159,8 @@
   [key']
   (contains? @*background-task-cancelers key'))
 
+(reset! entity-plus/*reset-cache-background-task-running-f background-task-running?)
+
 (comment
   (defn >!
     "Return a task that

+ 4 - 1
src/main/frontend/components/header.cljs

@@ -133,7 +133,10 @@
                                     (fn []
                                       (if favorited?
                                         (page-handler/<unfavorite-page! block-id-str)
-                                        (page-handler/<favorite-page! block-id-str)))}}])))
+                                        (page-handler/<favorite-page! block-id-str)))}}
+                         {:title   "Publish page"
+                          :options {:on-click #(shui/dialog-open! (fn [] (page-menu/publish-page-dialog page))
+                                                                  {:class "w-auto max-w-md"})}}])))
         page-menu-and-hr (concat page-menu [{:hr true}])
         login? (and (state/sub :auth/id-token) (user-handler/logged-in?))
         items (fn []

+ 41 - 1
src/main/frontend/components/page_menu.cljs

@@ -9,14 +9,49 @@
             [frontend.handler.db-based.page :as db-page-handler]
             [frontend.handler.notification :as notification]
             [frontend.handler.page :as page-handler]
+            [frontend.handler.publish :as publish-handler]
             [frontend.mobile.util :as mobile-util]
             [frontend.state :as state]
             [frontend.util :as util]
             [frontend.util.page :as page-util]
             [logseq.common.path :as path]
             [logseq.db :as ldb]
+            [logseq.shui.hooks :as hooks]
             [logseq.shui.ui :as shui]
-            [promesa.core :as p]))
+            [promesa.core :as p]
+            [rum.core :as rum]))
+
+(rum/defc publish-page-dialog
+  [page]
+  (let [[password set-password!] (hooks/use-state "")
+        [publishing? set-publishing!] (hooks/use-state false)
+        submit! (fn []
+                  (when-not publishing?
+                    (set-publishing! true)
+                    (-> (publish-handler/publish-page! page {:password password})
+                        (p/finally (fn []
+                                     (set-publishing! false)
+                                     (shui/dialog-close!))))))]
+    [:div.flex.flex-col.gap-4.p-2
+     [:div.text-lg.font-medium "Publish page"]
+     [:div.text-sm.opacity-70
+      "Optionally protect this page with a password. Leave empty for public access."]
+     (shui/toggle-password
+      {:placeholder "Optional password"
+       :value password
+       :on-change (fn [e]
+                    (set-password! (util/evalue e)))})
+     [:div.flex.justify-end.gap-2
+      (shui/button
+       {:variant "ghost"
+        :on-click #(shui/dialog-close!)}
+       "Cancel")
+      (shui/button
+       {:on-click submit!
+        :disabled publishing?}
+       (if publishing?
+         "Publishing..."
+         "Publish"))]]))
 
 (defn- delete-page!
   [page]
@@ -97,6 +132,11 @@
                                                                                  :export-type :page}))
                                    {:class "w-auto md:max-w-4xl max-h-[80vh] overflow-y-auto"})}})
 
+          (when (and page (not config/publishing?))
+            {:title   "Publish page"
+             :options {:on-click #(shui/dialog-open! (fn [] (publish-page-dialog page))
+                                                     {:class "w-auto max-w-md"})}})
+
           (when (util/electron?)
             {:title   (t (if public? :page/make-private :page/make-public))
              :options {:on-click

+ 22 - 2
src/main/frontend/components/property/value.cljs

@@ -20,6 +20,7 @@
             [frontend.handler.page :as page-handler]
             [frontend.handler.property :as property-handler]
             [frontend.handler.property.util :as pu]
+            [frontend.handler.publish :as publish-handler]
             [frontend.handler.route :as route-handler]
             [frontend.modules.outliner.ui :as ui-outliner-tx]
             [frontend.search :as search]
@@ -1237,17 +1238,36 @@
         {:on-click #(<create-new-block! block property "")}
         "Set default value"]
 
+       (= (:db/ident property) :logseq.property.publish/published-url)
+       [:div.flex.items-center.gap-2.w-full
+        [:a {:href (:block/title value)
+             :target "_blank"}
+         (:block/title value)]
+
+        (when-not config/publishing?
+          (shui/button
+           {:variant :text
+            :size :sm
+            :class "text-xs"
+            :on-click (fn [e]
+                        (util/stop e)
+                        (publish-handler/unpublish-page! block))}
+           "Unpublish"))]
+
        text-ref-type?
        (property-block-value value block property page-cp opts)
 
        :else
        (let [content (inline-text {} :markdown (macro-util/expand-value-if-macro (str value) (state/get-macros)))]
-         (if (contains? (set (keys string-value-on-click))
-                        (:db/ident property))
+         (cond
+           (contains? (set (keys string-value-on-click))
+                      (:db/ident property))
            [:div.w-full {:on-click (fn []
                                      (let [f (get string-value-on-click (:db/ident property))]
                                        (f block property)))}
             content]
+
+           :else
            content)))]))
 
 (rum/defc single-number-input

+ 6 - 2
src/main/frontend/config.cljs

@@ -25,6 +25,8 @@
 ;; when it launches (when pro plan launches) it should be removed
 (def ENABLE-SETTINGS-ACCOUNT-TAB false)
 
+;; (def PUBLISH-API-BASE "http://localhost:8787")
+
 (if ENABLE-FILE-SYNC-PRODUCTION
   (do (def LOGIN-URL
         "https://logseq-prod.auth.us-east-1.amazoncognito.com/login?client_id=3c7np6bjtb4r1k1bi9i049ops5&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback")
@@ -34,7 +36,8 @@
       (def REGION "us-east-1")
       (def USER-POOL-ID "us-east-1_dtagLnju8")
       (def IDENTITY-POOL-ID "us-east-1:d6d3b034-1631-402b-b838-b44513e93ee0")
-      (def OAUTH-DOMAIN "logseq-prod.auth.us-east-1.amazoncognito.com"))
+      (def OAUTH-DOMAIN "logseq-prod.auth.us-east-1.amazoncognito.com")
+      (def PUBLISH-API-BASE "https://logseq.io"))
 
   (do (def LOGIN-URL
         "https://logseq-test2.auth.us-east-2.amazoncognito.com/login?client_id=3ji1a0059hspovjq5fhed3uil8&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback")
@@ -44,7 +47,8 @@
       (def REGION "us-east-2")
       (def USER-POOL-ID "us-east-2_kAqZcxIeM")
       (def IDENTITY-POOL-ID "us-east-2:cc7d2ad3-84d0-4faf-98fe-628f6b52c0a5")
-      (def OAUTH-DOMAIN "logseq-test2.auth.us-east-2.amazoncognito.com")))
+      (def OAUTH-DOMAIN "logseq-test2.auth.us-east-2.amazoncognito.com")
+      (def PUBLISH-API-BASE "https://logseq-publish-staging.logseq.workers.dev")))
 
 (goog-define ENABLE-RTC-SYNC-PRODUCTION false)
 (if ENABLE-RTC-SYNC-PRODUCTION

+ 8 - 9
src/main/frontend/extensions/fsrs.cljs

@@ -215,16 +215,15 @@
            (component-block/blocks-container option [block-entity]))
          [:div.mt-8.pb-2
           (if (contains? #{:show-cloze :show-answer} next-phase)
-            (btn-with-shortcut {:btn-text (t
-                                           (case next-phase
-                                             :show-answer
-                                             :flashcards/modal-btn-show-answers
-                                             :show-cloze
-                                             :flashcards/modal-btn-show-clozes
-                                             :init
-                                             :flashcards/modal-btn-hide-answers))
+            (btn-with-shortcut {:btn-text (case next-phase
+                                            :show-answer
+                                            (t :flashcards/modal-btn-show-answers)
+                                            :show-cloze
+                                            (t :flashcards/modal-btn-show-clozes)
+                                            :init
+                                            (t :flashcards/modal-btn-hide-answers))
                                 :shortcut "s"
-                                :id (str "card-answers")
+                                :id "card-answers"
                                 :on-click #(swap! *phase
                                                   (fn [phase]
                                                     (phase->next-phase block-entity phase)))})

+ 431 - 0
src/main/frontend/handler/publish.cljs

@@ -0,0 +1,431 @@
+(ns frontend.handler.publish
+  "Prepare publish payloads for pages."
+  (:require [cljs-bean.core :as bean]
+            [clojure.string :as string]
+            [frontend.config :as config]
+            [frontend.db :as db]
+            [frontend.db.model :as db-model]
+            [frontend.fs :as fs]
+            [frontend.handler.notification :as notification]
+            [frontend.handler.property :as property-handler]
+            [frontend.handler.user :as user-handler]
+            [frontend.image :as image]
+            [frontend.state :as state]
+            [frontend.util :as util]
+            [logseq.common.path :as path]
+            [logseq.db :as ldb]
+            [promesa.core :as p]))
+
+(defn- <sha256-hex
+  [text]
+  (p/let [encoder (js/TextEncoder.)
+          data (.encode encoder text)
+          digest (.digest (.-subtle js/crypto) "SHA-256" data)
+          data (js/Uint8Array. digest)]
+    (->> data
+         (map (fn [b]
+                (.padStart (.toString b 16) 2 "0")))
+         (apply str))))
+
+(defn- publish-endpoint
+  []
+  (str config/PUBLISH-API-BASE "/pages"))
+
+(defn- publish-page-endpoint
+  [graph-uuid page-uuid]
+  (str config/PUBLISH-API-BASE "/pages/" graph-uuid "/" page-uuid))
+
+(defn- asset-upload-endpoint
+  []
+  (str config/PUBLISH-API-BASE "/assets"))
+
+(defn- asset-content-type
+  [ext]
+  (case (string/lower-case (or ext ""))
+    ("png") "image/png"
+    ("jpg" "jpeg") "image/jpeg"
+    ("gif") "image/gif"
+    ("webp") "image/webp"
+    ("svg") "image/svg+xml"
+    ("bmp") "image/bmp"
+    ("avif") "image/avif"
+    ("mp4") "video/mp4"
+    ("webm") "video/webm"
+    ("mov") "video/quicktime"
+    ("mp3") "audio/mpeg"
+    ("wav") "audio/wav"
+    ("ogg") "audio/ogg"
+    ("pdf") "application/pdf"
+    "application/octet-stream"))
+
+(def ^:private publish-image-variant-sizes
+  [1024 1600])
+
+(def ^:private publish-image-quality
+  0.9)
+
+(def ^:private publish-image-types
+  #{"png" "jpg" "jpeg" "webp"})
+
+(def ^:private custom-publish-assets
+  [{:path (path/path-join "logseq" "publish.css")
+    :type "css"
+    :content-type "text/css; charset=utf-8"
+    :meta-key :custom_publish_css_hash
+    :asset-name "publish.css"}
+   {:path (path/path-join "logseq" "publish.js")
+    :type "js"
+    :content-type "text/javascript; charset=utf-8"
+    :meta-key :custom_publish_js_hash
+    :asset-name "publish.js"}])
+
+(defn- image-asset?
+  [asset-type]
+  (contains? publish-image-types (string/lower-case (or asset-type ""))))
+
+(defn- asset-uuid-with-variant
+  [asset-uuid variant]
+  (if variant
+    (str asset-uuid "@" variant)
+    asset-uuid))
+
+(defn- <sha256-hex-buffer
+  [array-buffer]
+  (p/let [digest (.digest (.-subtle js/crypto) "SHA-256" array-buffer)
+          data (js/Uint8Array. digest)]
+    (->> data
+         (map (fn [b]
+                (.padStart (.toString b 16) 2 "0")))
+         (apply str))))
+
+(defn- <blob-checksum
+  [blob]
+  (p/let [buffer (.arrayBuffer blob)]
+    (<sha256-hex-buffer buffer)))
+
+(defn- <canvas->blob
+  [canvas content-type quality]
+  (p/create
+   (fn [resolve _reject]
+     (.toBlob canvas
+              (fn [blob]
+                (resolve blob))
+              content-type
+              quality))))
+
+(defn- <canvas-from-blob
+  [blob max-dim]
+  (if (exists? js/createImageBitmap)
+    (p/let [bitmap (js/createImageBitmap blob #js {:imageOrientation "from-image"})
+            width (.-width bitmap)
+            height (.-height bitmap)
+            scale (min 1 (/ max-dim (max width height)))
+            target-width (js/Math.round (* width scale))
+            target-height (js/Math.round (* height scale))
+            canvas (js/document.createElement "canvas")
+            ctx ^js (.getContext canvas "2d")]
+      (set! (.-width canvas) target-width)
+      (set! (.-height canvas) target-height)
+      (set! (.-imageSmoothingEnabled ctx) true)
+      (set! (.-imageSmoothingQuality ctx) "high")
+      (.drawImage ctx bitmap 0 0 target-width target-height)
+      (when (.-close bitmap)
+        (.close bitmap))
+      canvas)
+    (p/create
+     (fn [resolve reject]
+       (let [img (js/Image.)
+             url (js/URL.createObjectURL blob)]
+         (set! (.-onload img)
+               (fn []
+                 (image/get-orientation img
+                                        (fn [canvas]
+                                          (js/URL.revokeObjectURL url)
+                                          (resolve canvas))
+                                        max-dim
+                                        max-dim)))
+         (set! (.-onerror img)
+               (fn [error]
+                 (js/URL.revokeObjectURL url)
+                 (reject error)))
+         (set! (.-src img) url))))))
+
+(defn- <build-image-uploads
+  [asset-uuid asset-type title blob content-type]
+  (p/let [variant-promises (map (fn [size]
+                                  (p/let [canvas (<canvas-from-blob blob size)
+                                          blob' (<canvas->blob canvas content-type publish-image-quality)]
+                                    (when blob'
+                                      {:variant size
+                                       :blob blob'})))
+                                publish-image-variant-sizes)
+          variants (p/then (p/all variant-promises)
+                           (fn [entries]
+                             (->> entries (remove nil?) vec)))]
+    (when (seq variants)
+      (let [sorted (sort-by :variant variants)
+            largest (last sorted)
+            uploads (vec (concat [(assoc largest :variant nil)] sorted))]
+        (p/all
+         (map (fn [{:keys [variant blob]}]
+                (p/let [checksum (<blob-checksum blob)]
+                  {:asset_uuid (asset-uuid-with-variant asset-uuid variant)
+                   :asset_type asset-type
+                   :content_type content-type
+                   :checksum checksum
+                   :size (.-size blob)
+                   :title title
+                   :blob blob}))
+              uploads))))))
+
+(defn- <upload-blob-asset!
+  [graph-uuid asset-token {:keys [asset_uuid asset_type checksum size title content_type blob]}]
+  (let [meta {:graph graph-uuid
+              :asset_uuid asset_uuid
+              :asset_type asset_type
+              :checksum checksum
+              :size size
+              :title title
+              :content_type content_type}
+        headers (cond-> {"content-type" content_type
+                         "x-asset-meta" (js/JSON.stringify (clj->js meta))}
+                  asset-token (assoc "authorization" (str "Bearer " asset-token)))]
+    (js/fetch (asset-upload-endpoint)
+              (clj->js {:method "POST"
+                        :headers headers
+                        :body blob}))))
+
+(defn- <upload-raw-asset!
+  [asset-token asset-meta content-type content]
+  (let [headers (cond-> {"content-type" content-type
+                         "x-asset-meta" (js/JSON.stringify (clj->js asset-meta))}
+                  asset-token (assoc "authorization" (str "Bearer " asset-token)))]
+    (js/fetch (asset-upload-endpoint)
+              (clj->js {:method "POST"
+                        :headers headers
+                        :body content}))))
+
+(defn- merge-attr
+  [entity attr value]
+  (let [existing (get entity attr ::none)]
+    (cond
+      (= existing ::none) (assoc entity attr value)
+      (vector? existing) (assoc entity attr (conj existing value))
+      (set? existing) (assoc entity attr (conj existing value))
+      :else (assoc entity attr [existing value]))))
+
+(defn- datoms->entities
+  [datoms]
+  (reduce
+   (fn [acc datom]
+     (let [[e a v _tx added?] datom]
+       (if added?
+         (update acc e (fn [entity]
+                         (merge-attr (or entity {:db/id e}) a v)))
+         acc)))
+   {}
+   datoms))
+
+(defn- asset-entities-from-payload
+  [payload]
+  (let [entities (datoms->entities (:datoms payload))]
+    (->> entities
+         vals
+         (filter (fn [entity]
+                   (and (:logseq.property.asset/type entity)
+                        (:block/uuid entity)))))))
+
+(defn- <upload-asset!
+  [repo graph-uuid asset]
+  (let [asset-type (:logseq.property.asset/type asset)
+        asset-uuid (some-> (:block/uuid asset) str)
+        external-url (:logseq.property.asset/external-url asset)
+        token (state/get-auth-id-token)]
+    (if (or (not (string? asset-type)) (string/blank? asset-type) external-url (nil? asset-uuid))
+      (p/resolved nil)
+      (p/let [repo-dir (config/get-repo-dir repo)
+              asset-path (path/path-join "assets" (str asset-uuid "." asset-type))
+              content (fs/read-file-raw repo-dir asset-path {})
+              content-type (asset-content-type asset-type)]
+        (if (image-asset? asset-type)
+          (p/let [blob (js/Blob. (array content) (clj->js {:type content-type}))
+                  uploads (<build-image-uploads asset-uuid asset-type (:block/title asset) blob content-type)]
+            (if (seq uploads)
+              (p/let [responses (p/all (map (fn [upload]
+                                              (<upload-blob-asset! graph-uuid token upload))
+                                            uploads))]
+                (doseq [resp responses]
+                  (when-not (.-ok resp)
+                    (js/console.warn "Asset publish failed" {:asset asset-uuid :status (.-status resp)})))
+                (last responses))
+              (p/let [meta {:graph graph-uuid
+                            :asset_uuid asset-uuid
+                            :asset_type asset-type
+                            :checksum (:logseq.property.asset/checksum asset)
+                            :size (:logseq.property.asset/size asset)
+                            :title (:block/title asset)}
+                      resp (<upload-raw-asset! token meta content-type content)]
+                (when-not (.-ok resp)
+                  (js/console.warn "Asset publish failed" {:asset asset-uuid :status (.-status resp)}))
+                resp)))
+          (p/let [meta {:graph graph-uuid
+                        :asset_uuid asset-uuid
+                        :asset_type asset-type
+                        :checksum (:logseq.property.asset/checksum asset)
+                        :size (:logseq.property.asset/size asset)
+                        :title (:block/title asset)}
+                  resp (<upload-raw-asset! token meta content-type content)]
+            (when-not (.-ok resp)
+              (js/console.warn "Asset publish failed" {:asset asset-uuid :status (.-status resp)}))
+            resp))))))
+
+(defn- <upload-assets!
+  [repo graph-uuid payload]
+  (let [assets (asset-entities-from-payload payload)]
+    (when (seq assets)
+      (p/all (map (fn [asset]
+                    (p/catch (<upload-asset! repo graph-uuid asset)
+                             (fn [error]
+                               (js/console.warn "Asset publish error" error))))
+                  assets)))))
+
+(defn- <upload-custom-publish-assets!
+  [repo graph-uuid]
+  (let [token (state/get-auth-id-token)
+        asset-uuid "publish"]
+    (p/let [results (p/all
+                     (map (fn [{:keys [path type content-type meta-key asset-name]}]
+                            (p/let [content (db-model/get-file repo path)]
+                              (when (and (string? content) (not (string/blank? content)))
+                                (p/let [checksum (<sha256-hex content)
+                                        meta {:graph graph-uuid
+                                              :asset_uuid asset-uuid
+                                              :asset_type type
+                                              :content_type content-type
+                                              :checksum checksum
+                                              :title asset-name}
+                                        resp (<upload-raw-asset! token meta content-type content)]
+                                  (when-not (.-ok resp)
+                                    (js/console.warn "Custom publish asset upload failed"
+                                                     {:path path :status (.-status resp)}))
+                                  {meta-key checksum}))))
+                          custom-publish-assets))]
+      (apply merge (remove nil? results)))))
+
+(defn- <post-publish!
+  [payload {:keys [password custom-assets]}]
+  (let [token (state/get-auth-id-token)
+        headers (cond-> {"content-type" "application/transit+json"}
+                  token (assoc "authorization" (str "Bearer " token)))]
+    (p/let [page-password (some-> password string/trim)
+            page-password (when (and (string? page-password)
+                                     (not (string/blank? page-password)))
+                            page-password)
+            payload (cond-> payload
+                      page-password (assoc :page-password page-password))
+            body (ldb/write-transit-str payload)
+            content-hash (<sha256-hex body)
+            graph-uuid (or (:graph-uuid payload)
+                           (some-> (ldb/get-graph-rtc-uuid (db/get-db)) str))
+            _ (when-not graph-uuid
+                (throw (ex-info "Missing graph UUID" {:repo (state/get-current-repo)})))
+            publish-meta {:graph graph-uuid
+                          :page_uuid (str (:page-uuid payload))
+                          :block_count (:block-count payload)
+                          :schema_version (:schema-version payload)
+                          :format :transit
+                          :compression :none
+                          :content_hash content-hash
+                          :content_length (count body)
+                          :owner_sub (user-handler/user-uuid)
+                          :owner_username (user-handler/username)
+                          :created_at (util/time-ms)}
+            publish-meta (cond-> publish-meta
+                           (get custom-assets :custom_publish_css_hash)
+                           (assoc :custom_publish_css_hash (:custom_publish_css_hash custom-assets))
+                           (get custom-assets :custom_publish_js_hash)
+                           (assoc :custom_publish_js_hash (:custom_publish_js_hash custom-assets)))
+            publish-body (assoc payload :meta publish-meta)
+            headers (assoc headers "x-publish-meta" (js/JSON.stringify (clj->js publish-meta)))
+            resp (js/fetch (publish-endpoint)
+                           (clj->js {:method "POST"
+                                     :headers headers
+                                     :body (ldb/write-transit-str publish-body)}))]
+      (if (.-ok resp)
+        resp
+        (p/let [body (.text resp)]
+          (throw (ex-info "Publish failed"
+                          {:status (.-status resp)
+                           :body body})))))))
+
+(defn publish-page!
+  "Prepares and uploads the publish payload for a page."
+  [page & [{:keys [password]}]]
+  (let [repo (state/get-current-repo)]
+    (when-let [db* (and repo (db/get-db repo))]
+      (if (and page (:db/id page))
+        (p/let [graph-uuid (some-> (ldb/get-graph-rtc-uuid db*) str)
+                payload (state/<invoke-db-worker :thread-api/build-publish-page-payload
+                                                 repo
+                                                 (:db/id page)
+                                                 graph-uuid)]
+          (if payload
+            (-> (p/let [_ (<upload-assets! repo graph-uuid payload)
+                        custom-assets (<upload-custom-publish-assets! repo graph-uuid)]
+                  (<post-publish! payload {:password password
+                                           :custom-assets custom-assets}))
+                (p/then (fn [resp]
+                          (p/let [json (.json resp)
+                                  data (bean/->clj json)]
+                            (let [short-url (:short_url data)
+                                  graph-uuid (or (:graph-uuid payload)
+                                                 (some-> (ldb/get-graph-rtc-uuid db*) str))
+                                  page-uuid (str (:block/uuid page))
+                                  fallback-url (when (and graph-uuid page-uuid)
+                                                 (str config/PUBLISH-API-BASE "/page/" graph-uuid "/" page-uuid))
+                                  url (or (when short-url
+                                            (str config/PUBLISH-API-BASE short-url))
+                                          fallback-url)]
+                              (when (and url (:db/id page))
+                                (property-handler/set-block-property! (:db/id page)
+                                                                      :logseq.property.publish/published-url
+                                                                      url))
+                              (when url
+                                (notification/show!
+                                 [:div.inline
+                                  [:span "Published to: "]
+                                  [:a {:target "_blank"
+                                       :href url}
+                                   url]]
+                                 :success
+                                 false))))))
+                (p/catch (fn [error]
+                           (js/console.error error)
+                           (notification/show! "Publish failed." :error))))
+            (notification/show! "Publish failed." :error)))
+        (notification/show! "Publish failed: invalid page." :error)))))
+
+(defn unpublish-page!
+  [page]
+  (let [token (state/get-auth-id-token)
+        headers (cond-> {}
+                  token (assoc "authorization" (str "Bearer " token)))]
+    (p/let [graph-uuid (some-> (ldb/get-graph-rtc-uuid (db/get-db)) str)
+            page-uuid (some-> (:block/uuid page) str)]
+      (if (and graph-uuid page-uuid)
+        (-> (p/let [resp (js/fetch (publish-page-endpoint graph-uuid page-uuid)
+                                   (clj->js {:method "DELETE"
+                                             :headers headers}))]
+              (if (.-ok resp)
+                (do
+                  (property-handler/remove-block-property! (:db/id page)
+                                                           :logseq.property.publish/published-url)
+                  (notification/show! "Unpublished." :success false))
+                (p/let [body (.text resp)]
+                  (throw (ex-info "Unpublish failed"
+                                  {:status (.-status resp)
+                                   :body body})))))
+            (p/catch (fn [error]
+                       (js/console.error error)
+                       (notification/show! "Unpublish failed." :error))))
+        (notification/show! "Unpublish failed: missing page id." :error)))))

+ 10 - 1
src/main/frontend/worker/db/migrate.cljs

@@ -8,6 +8,7 @@
             [frontend.worker.db.rename-db-ident :as rename-db-ident]
             [logseq.common.config :as common-config]
             [logseq.common.util :as common-util]
+            [logseq.common.uuid :as common-uuid]
             [logseq.db :as ldb]
             [logseq.db.frontend.class :as db-class]
             [logseq.db.frontend.property :as db-property]
@@ -163,6 +164,12 @@
                  (when (:logseq.property/ui-position e)
                    [:db/retract (:e d) :logseq.property/ui-position]))))))
 
+(defn- ensure-graph-uuid
+  [db]
+  (let [graph-uuid (:kv/value (d/entity db :logseq.kv/graph-uuid))]
+    (when-not graph-uuid
+      [(sqlite-util/kv :logseq.kv/graph-uuid (common-uuid/gen-uuid))])))
+
 (def schema-version->updates
   "A vec of tuples defining datascript migrations. Each tuple consists of the
    schema version integer and a migration map. A migration map can have keys of :properties, :classes
@@ -179,7 +186,9 @@
    ["65.15" (rename-properties {:logseq.property.asset/external-src
                                 :logseq.property.asset/external-url}
                                {})]
-   ["65.16" {:properties [:logseq.property.asset/external-file-name]}]])
+   ["65.16" {:properties [:logseq.property.asset/external-file-name]}]
+   ["65.17" {:properties [:logseq.property.publish/published-url]}]
+   ["65.18" {:fix ensure-graph-uuid}]])
 
 (let [[major minor] (last (sort (map (comp (juxt :major :minor) db-schema/parse-schema-version first)
                                      schema-version->updates)))]

+ 1 - 0
src/main/frontend/worker/db_worker.cljs

@@ -23,6 +23,7 @@
             [frontend.worker.export :as worker-export]
             [frontend.worker.handler.page :as worker-page]
             [frontend.worker.pipeline :as worker-pipeline]
+            [frontend.worker.publish]
             [frontend.worker.rtc.asset-db-listener]
             [frontend.worker.rtc.client-op :as client-op]
             [frontend.worker.rtc.core :as rtc.core]

+ 231 - 0
src/main/frontend/worker/publish.cljs

@@ -0,0 +1,231 @@
+(ns frontend.worker.publish
+  "Publish"
+  (:require [clojure.string :as string]
+            [datascript.core :as d]
+            [frontend.common.thread-api :refer [def-thread-api]]
+            [frontend.worker.state :as worker-state]
+            [logseq.common.util :as common-util]
+            [logseq.db :as ldb]
+            [logseq.db.common.entity-util :as common-entity-util]
+            [logseq.db.frontend.content :as db-content]
+            [logseq.db.frontend.property :as db-property]
+            [logseq.db.frontend.schema :as db-schema]))
+
+(defn- publish-entity-title
+  [entity]
+  (or (:block/title entity)
+      "Untitled"))
+
+(defn- page-tags
+  [page-entity]
+  (let [tags (:block/tags page-entity)]
+    (->> tags
+         (remove (fn [tag]
+                   (contains? #{:logseq.class/Page} (:db/ident tag))))
+         (map (fn [tag]
+                {:tag_uuid (:block/uuid tag)
+                 :tag_title (:block/title tag)})))))
+
+(defn- publish-ref-eid [value]
+  (cond
+    (number? value) (when (pos? value) value)
+    (map? value) (let [eid (:db/id value)]
+                   (when (and (number? eid) (pos? eid))
+                     eid))
+    :else nil))
+
+(defn- publish-refs-from-blocks
+  [db blocks page-entity graph-uuid]
+  (let [page-uuid (:block/uuid page-entity)
+        page-title (publish-entity-title page-entity)
+        page? (common-entity-util/page? page-entity)
+        graph-uuid (str graph-uuid)]
+    (mapcat (fn [block]
+              (let [block-uuid (:block/uuid block)
+                    block-uuid-str (some-> block-uuid str)]
+                (when (and block-uuid-str
+                           (or (not page?)
+                               (not= block-uuid page-uuid)))
+                  (let [block-content (or (:block/content block)
+                                          (:block/title block)
+                                          (:block/name block)
+                                          "")
+                        block-format (name (or (:block/format block) :markdown))
+                        refs (:block/refs block)
+                        refs (if (sequential? refs) refs (when refs [refs]))
+                        targets (->> refs
+                                     (map publish-ref-eid)
+                                     (keep #(when % (d/entity db %)))
+                                     (keep :block/uuid)
+                                     (map str)
+                                     distinct)]
+                    (when (seq targets)
+                      (map (fn [target]
+                             {:graph_uuid graph-uuid
+                              :target_page_uuid target
+                              :source_page_uuid (str page-uuid)
+                              :source_page_title page-title
+                              :source_block_uuid block-uuid-str
+                              :source_block_content block-content
+                              :source_block_format block-format
+                              :updated_at (common-util/time-ms)})
+                           targets))))))
+            blocks)))
+
+(defn- collect-publish-blocks
+  [db entity]
+  (if (common-entity-util/page? entity)
+    (:block/_page entity)
+    (ldb/get-block-and-children db (:block/uuid entity))))
+
+(def ^:private publish-search-max-length 4096)
+
+(defn- block-page-eid
+  [block]
+  (let [page (:block/page block)]
+    (cond
+      (map? page) (:db/id page)
+      (number? page) page
+      :else nil)))
+
+(defn- block-search-content
+  [block]
+  (let [raw-content (or (:block/content block)
+                        (:block/title block)
+                        (:block/name block)
+                        "")
+        raw-content (string/trim raw-content)]
+    (when-not (string/blank? raw-content)
+      (let [content (db-content/recur-replace-uuid-in-block-title
+                     (assoc block :block/title raw-content))
+            content (if (> (count content) publish-search-max-length)
+                      (subs content 0 publish-search-max-length)
+                      content)]
+        (string/trim content)))))
+
+(defn- collect-search-blocks
+  [blocks page-eid page-uuid]
+  (->> blocks
+       (keep (fn [block]
+               (when (and (= (block-page-eid block) page-eid)
+                          (not= (:db/id block) page-eid)
+                          (not (:logseq.property/created-from-property block)))
+                 (when-let [block-uuid (some-> (:block/uuid block) str)]
+                   (when-let [content (block-search-content block)]
+                     {:page_uuid (str page-uuid)
+                      :block_uuid block-uuid
+                      :block_content content})))))))
+
+(defn- collect-embedded-blocks
+  [db blocks]
+  (let [linked-eids (->> blocks
+                         (map :block/link)
+                         (map publish-ref-eid)
+                         (remove nil?)
+                         distinct)]
+    (loop [queue (vec linked-eids)
+           visited #{}
+           acc []]
+      (if (empty? queue)
+        acc
+        (let [eid (first queue)
+              queue (subvec queue 1)]
+          (if (contains? visited eid)
+            (recur queue visited acc)
+            (let [entity (d/entity db eid)
+                  uuid (:block/uuid entity)
+                  children (when uuid
+                             (ldb/get-block-and-children db uuid))
+                  child-links (->> children
+                                   (map :block/link)
+                                   (map publish-ref-eid)
+                                   (remove nil?))]
+              (recur (into queue child-links)
+                     (conj visited eid)
+                     (into acc children)))))))))
+
+(defn- publish-collect-page-eids
+  [db entity]
+  (let [page-id (:db/id entity)
+        blocks (collect-publish-blocks db entity)
+        embedded-blocks (collect-embedded-blocks db blocks)
+        blocks (concat blocks embedded-blocks)
+        block-eids (map :db/id blocks)
+        ref-eids (->> blocks
+                      (mapcat :block/refs)
+                      (map publish-ref-eid)
+                      (remove nil?))
+        tag-eids (->> blocks
+                      (mapcat :block/tags)
+                      (map publish-ref-eid)
+                      (remove nil?))
+        page-tag-eids (->> (if-let [tags (:block/tags entity)]
+                             (if (sequential? tags) tags [tags])
+                             [])
+                           (map publish-ref-eid)
+                           (remove nil?))
+        page-eids (->> blocks (map :block/page) (keep :db/id))
+        property-eids (->> (cons entity blocks)
+                           (map db-property/properties)
+                           (mapcat (fn [props]
+                                     (mapcat (fn [[k v]]
+                                               (let [property (d/entity db k)
+                                                     pid (:db/id property)
+                                                     ref-type? (= :db.type/ref (:db/valueType property))
+                                                     many? (= :db.cardinality/many (:db/cardinality property))]
+                                                 (cons pid
+                                                       (when ref-type?
+                                                         (if many?
+                                                           (map :db/id v)
+                                                           (list (:db/id v)))))))
+                                             props)))
+                           (remove nil?))]
+    {:blocks blocks
+     :eids (->> (concat [page-id] block-eids ref-eids tag-eids page-tag-eids page-eids property-eids)
+                (remove nil?)
+                distinct)}))
+
+(defn- normalize-block-publish-datoms
+  [datoms block-eids root-eid]
+  (map (fn [[e a v tx added]]
+         (if (and (contains? block-eids e) (= a :block/page))
+           [e a root-eid tx added]
+           [e a v tx added]))
+       datoms))
+
+(defn- build-publish-page-payload
+  [db entity graph-uuid]
+  (let [{:keys [blocks eids]} (publish-collect-page-eids db entity)
+        graph-uuid (or graph-uuid (ldb/get-graph-rtc-uuid db))
+        refs (when graph-uuid
+               (publish-refs-from-blocks db blocks entity graph-uuid))
+        tags (page-tags entity)
+        search-blocks (collect-search-blocks blocks (:db/id entity) (:block/uuid entity))
+        raw-datoms (->>
+                    (mapcat (fn [eid]
+                              (map (fn [d] [(:e d) (:a d) (:v d) (:tx d) (:added d)])
+                                   (d/datoms db :eavt eid)))
+                            eids)
+                    (remove (fn [[_e a _v _tx _added]]
+                              (contains? #{:block/tx-id :logseq.property.user/email :logseq.property.embedding/hnsw-label-updated-at} a))))
+        datoms (if (common-entity-util/page? entity)
+                 raw-datoms
+                 (normalize-block-publish-datoms raw-datoms (set (map :db/id blocks)) (:db/id entity)))]
+    {:page (common-entity-util/entity->map entity)
+     :page-uuid (:block/uuid entity)
+     :page-title (publish-entity-title entity)
+     :graph-uuid (some-> graph-uuid str)
+     :block-count (count blocks)
+     :schema-version (db-schema/schema-version->string db-schema/version)
+     :refs refs
+     :page-tags tags
+     :blocks search-blocks
+     :datoms datoms}))
+
+(def-thread-api :thread-api/build-publish-page-payload
+  [repo eid graph-uuid]
+  (when-let [conn (worker-state/get-datascript-conn repo)]
+    (let [db @conn
+          page-entity (d/entity db eid)]
+      (when (and page-entity (:db/id page-entity))
+        (build-publish-page-payload db page-entity graph-uuid)))))

+ 1 - 0
src/main/frontend/worker/rtc/full_upload_download_graph.cljs

@@ -191,6 +191,7 @@
                                                           :graph-name remote-graph-name
                                                           :encrypted-aes-key
                                                           (ldb/write-transit-str encrypted-aes-key)}))]
+          ;; FIXME: use local graph uuid instead of creating new one
           (if-let [graph-uuid (:graph-uuid upload-resp)]
             (let [schema-version (ldb/get-graph-schema-version @conn)]
               (ldb/transact! conn

+ 4 - 0
src/resources/dicts/en.edn

@@ -247,6 +247,10 @@
  :flashcards/modal-welcome-title "Time to create a card!"
  :flashcards/modal-welcome-desc-1 "You can add \"{1}\" to any block to turn it into a card or trigger \"/cloze\" to add some clozes."
  :flashcards/modal-finished "Congrats, you've reviewed all the cards for this query, see you next time! 💯"
+ :flashcards/modal-btn-show-answers "Show answers"
+ :flashcards/modal-btn-hide-answers "Hide answers"
+ :flashcards/modal-btn-show-clozes "Show clozes"
+
  :home "Home"
  :new-page "New page:"
  :new-tag "New tag:"

+ 70 - 0
src/test/frontend/worker/publish_test.cljs

@@ -0,0 +1,70 @@
+(ns frontend.worker.publish-test
+  (:require [cljs.test :refer [deftest is testing]]
+            [datascript.core :as d]
+            [frontend.worker.publish :as worker-publish]
+            [logseq.db.test.helper :as db-test]))
+
+(deftest publish-payload-includes-embedded-blocks
+  (testing "embedded blocks and their children are included in publish payload"
+    (let [target-uuid (random-uuid)
+          child-uuid (random-uuid)
+          embed-uuid (random-uuid)
+          conn (db-test/create-conn-with-blocks
+                [{:page {:block/title "Page A"}
+                  :blocks [{:block/title "Embed"
+                            :block/uuid embed-uuid
+                            :build/keep-uuid? true}]}
+                 {:page {:block/title "Page B"}
+                  :blocks [{:block/title "Target"
+                            :block/uuid target-uuid
+                            :build/keep-uuid? true
+                            :build/children [{:block/title "Child"
+                                              :block/uuid child-uuid
+                                              :build/keep-uuid? true}]}]}])
+          db @conn
+          embed-eid (:db/id (d/entity db [:block/uuid embed-uuid]))
+          target-eid (:db/id (d/entity db [:block/uuid target-uuid]))
+          _ (d/transact! conn [{:db/id embed-eid :block/link target-eid}])
+          db @conn
+          page-a (db-test/find-page-by-title db "Page A")
+          payload (#'worker-publish/build-publish-page-payload db page-a nil)
+          datom-eids (->> (:datoms payload) (map first) set)
+          child-eid (:db/id (d/entity db [:block/uuid child-uuid]))]
+      (is (contains? datom-eids target-eid))
+      (is (contains? datom-eids child-eid)))))
+
+(deftest publish-payload-traverses-nested-embeds
+  (testing "embedded blocks can include linked blocks that also embed others"
+    (let [first-uuid (random-uuid)
+          second-uuid (random-uuid)
+          embed-uuid (random-uuid)
+          conn (db-test/create-conn-with-blocks
+                [{:page {:block/title "Root Page"}
+                  :blocks [{:block/title "Embed"
+                            :block/uuid embed-uuid
+                            :build/keep-uuid? true}]}
+                 {:page {:block/title "First Page"}
+                  :blocks [{:block/title "First"
+                            :block/uuid first-uuid
+                            :build/keep-uuid? true
+                            :build/children [{:block/title "First child"
+                                              :build/keep-uuid? true}]}]}
+                 {:page {:block/title "Second Page"}
+                  :blocks [{:block/title "Second"
+                            :block/uuid second-uuid
+                            :build/keep-uuid? true}]}])
+          db @conn
+          embed-eid (:db/id (d/entity db [:block/uuid embed-uuid]))
+          first-eid (:db/id (d/entity db [:block/uuid first-uuid]))
+          second-eid (:db/id (d/entity db [:block/uuid second-uuid]))
+          first-child (db-test/find-block-by-content db "First child")
+          _ (d/transact! conn [{:db/id embed-eid :block/link first-eid}
+                               {:db/id (:db/id first-child) :block/link second-eid}])
+          db @conn
+          root-page (db-test/find-page-by-title db "Root Page")
+          payload (#'worker-publish/build-publish-page-payload db root-page nil)
+          datom-eids (->> (:datoms payload) (map first) set)
+          first-eid (:db/id (d/entity db [:block/uuid first-uuid]))
+          second-eid (:db/id (d/entity db [:block/uuid second-uuid]))]
+      (is (contains? datom-eids first-eid))
+      (is (contains? datom-eids second-eid)))))