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

feat: page publish
This commit is contained in:
Tienson Qin
2025-12-30 22:12:38 +08:00
committed by GitHub
46 changed files with 6277 additions and 41 deletions

4
.gitignore vendored
View File

@@ -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

View File

@@ -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"}

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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
deps/publish/README.md vendored Normal file
View File

@@ -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
deps/publish/deps.edn vendored Normal file
View File

@@ -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
deps/publish/package.json vendored Normal file
View File

@@ -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"
}
}

View File

@@ -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
deps/publish/shadow-cljs.edn vendored Normal file
View File

@@ -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}}}}

View File

@@ -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)})))))))

View File

@@ -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))))

View File

@@ -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))))

View File

@@ -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)))))

View File

@@ -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))))

View File

@@ -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))

View File

@@ -0,0 +1,945 @@
@import url("https://cdn.jsdelivr.net/npm/katex@0.16.10/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;
}
}

View File

@@ -0,0 +1,712 @@
import katexPkg from "https://esm.sh/katex@0.16.10?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();
}

File diff suppressed because it is too large Load Diff

View File

@@ -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))}))))

View File

@@ -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
deps/publish/worker/README.md vendored Normal file
View File

@@ -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.

View File

@@ -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
deps/publish/worker/scripts/dev_test.sh vendored Executable file
View File

@@ -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
deps/publish/worker/wrangler.toml vendored Normal file
View File

@@ -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
deps/publish/yarn.lock vendored Normal file
View File

@@ -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==
shadow-cljs-jar@1.3.4:
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==

View File

@@ -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"}}}

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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 []

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)))})

View File

@@ -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)))))

View File

@@ -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)))]

View File

@@ -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]

View File

@@ -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)))))

View File

@@ -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

View File

@@ -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:"

View File

@@ -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)))))