Initial import of LogDarioSeq baseline

Establishes version control for the personal Logseq DB fork before
starting the server-client architecture migration and domain features
described in docs/agent-guide and the approved implementation plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 15:32:15 +02:00
commit 40631362b3
2016 changed files with 543898 additions and 0 deletions

3
deps/publish/.carve/config.edn vendored Normal file
View File

@@ -0,0 +1,3 @@
{:paths ["src"]
:api-namespaces [logseq.publish.worker]
:report {:format :ignore}}

18
deps/publish/.clj-kondo/config.edn vendored Normal file
View File

@@ -0,0 +1,18 @@
{:linters
{:aliased-namespace-symbol {:level :warning}
:namespace-name-mismatch {:level :warning}
:used-underscored-binding {:level :warning}
:shadowed-var {:level :warning
:exclude [meta name key keys uuid type]}
:consistent-alias
{:aliases {clojure.pprint pprint
clojure.string string
datascript.core d
datascript.transit dt
logseq.publish.common publish-common
logseq.publish.model publish-model}}}
:lint-as {promesa.core/let clojure.core/let
shadow.cljs.modern/defclass clj-kondo.lint-as/def-catch-all}
:skip-comments true
:output {:progress true}}

1
deps/publish/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.clj-kondo/.cache

42
deps/publish/README.md vendored Normal file
View File

@@ -0,0 +1,42 @@
## Description
Shared library for page publish features (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 publish worker.
## Dev
Keep this module aligned with the main repo's linting and testing conventions.
Most of the same linters are used, with configurations that are specific to this
library. See [this library's CI file](/.github/workflows/deps-publish.yml) for
linting examples.
### Local Testing
For one-time setup, install the [CloudFlare cli wrangler](https://developers.cloudflare.com/workers/wrangler/) with `npm install -g wrangler@latest`.
To test the publish features locally, follow these steps:
* Run `pnpm watch` or `pnpm release` to build the publish worker js asset.
* Run `wrangler dev` in worker/ to start a local cloudflare worker server.
* In `frontend.config`, enable the commented out `PUBLISH-API-BASE` which points to a localhost url.
* Login on the desktop app.
* Go to any page and select `Publish` from its page menu.
### Deploy
When changes are ready to be deployed, run `pnpm deploy`.

31
deps/publish/bb.edn vendored Normal file
View File

@@ -0,0 +1,31 @@
{:min-bb-version "1.0.168"
:deps
{logseq/bb-tasks
#_{:local/root "../../../bb-tasks"}
{:git/url "https://github.com/logseq/bb-tasks"
:git/sha "7cc218286c03f093996539b8195de56a0ec5df6a"}}
:pods
{clj-kondo/clj-kondo {:version "2026.04.15"}}
:tasks
{test:load-all-namespaces-with-nbb
logseq.bb-tasks.nbb.test/load-all-namespaces
lint:large-vars
logseq.bb-tasks.lint.large-vars/-main
lint:carve
logseq.bb-tasks.lint.carve/-main
lint:ns-docstrings
logseq.bb-tasks.lint.ns-docstrings/-main
lint:minimize-public-vars
logseq.bb-tasks.lint.minimize-public-vars/-main}
:tasks/config
{:large-vars
{:metadata-exceptions #{:large-vars/cleanup-todo}
;; AI generated code has its tradeoffs
:max-lines-count 150}}}

26
deps/publish/deps.edn vendored Normal file
View File

@@ -0,0 +1,26 @@
{:paths ["src" "test" "../../resources"]
:deps
{org.clojure/clojure {:mvn/version "1.12.4"}
io.factorhouse/hsx {:git/url "https://github.com/logseq/hsx"
:sha "21615ffa2aa530cc830e3464b793a53ef4fdd18c"}
datascript/datascript {:git/url "https://github.com/logseq/datascript" ;; fork
:sha "3f141af97b70e1f14c65eaa119acd822ebece37e"}
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.4.4"}
logseq/common {:local/root "../common"}
logseq/graph-parser {:local/root "../graph-parser"}
logseq/db {:local/root "../db"}
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 "1.5.0"}
cider/cider-nrepl {:mvn/version "0.59.0"}
org.clojars.knubie/cljs-run-test {:mvn/version "1.0.1"}
tortue/spy {:mvn/version "2.15.0"}}
:main-opts ["-m" "shadow.cljs.devtools.cli"]}
:clj-kondo
{:replace-deps {clj-kondo/clj-kondo {:mvn/version "2026.04.15"}}
:main-opts ["-m" "clj-kondo.main"]}}}

19
deps/publish/package.json vendored Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "@logseq/publish",
"version": "1.0.0",
"packageManager": "pnpm@10.33.0",
"private": true,
"scripts": {
"dev": "cd ./worker && pnpm exec wrangler dev",
"watch": "clojure -M:cljs watch publish-worker",
"release": "clojure -M:cljs release publish-worker",
"test": "clojure -M:cljs compile publish-test && node worker/dist/worker-test.js",
"clean": "rm -rf ./worker/dist/",
"bump-publish-version": "node ./scripts/bump-publish-version.js",
"deploy": "pnpm bump-publish-version && pnpm clean && pnpm release && cd ./worker && pnpm exec wrangler deploy --env prod"
},
"dependencies": {
"mldoc": "^1.5.9",
"shadow-cljs": "^3.4.4"
}
}

530
deps/publish/pnpm-lock.yaml generated vendored Normal file
View File

@@ -0,0 +1,530 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
mldoc:
specifier: ^1.5.9
version: 1.5.9
shadow-cljs:
specifier: ^3.4.4
version: 3.4.4
packages:
ansi-regex@2.1.1:
resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==}
engines: {node: '>=0.10.0'}
ansi-regex@3.0.1:
resolution: {integrity: sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==}
engines: {node: '>=4'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
cliui@4.1.0:
resolution: {integrity: sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ==}
code-point-at@1.1.0:
resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==}
engines: {node: '>=0.10.0'}
cross-spawn@6.0.6:
resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==}
engines: {node: '>=4.8'}
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
execa@1.0.0:
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
engines: {node: '>=6'}
find-up@3.0.0:
resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
engines: {node: '>=6'}
get-caller-file@1.0.3:
resolution: {integrity: sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==}
get-stream@4.1.0:
resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==}
engines: {node: '>=6'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
invert-kv@2.0.0:
resolution: {integrity: sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==}
engines: {node: '>=4'}
is-fullwidth-code-point@1.0.0:
resolution: {integrity: sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==}
engines: {node: '>=0.10.0'}
is-fullwidth-code-point@2.0.0:
resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==}
engines: {node: '>=4'}
is-stream@1.1.0:
resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==}
engines: {node: '>=0.10.0'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isexe@3.1.1:
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
engines: {node: '>=16'}
lcid@2.0.0:
resolution: {integrity: sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==}
engines: {node: '>=6'}
locate-path@3.0.0:
resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==}
engines: {node: '>=6'}
map-age-cleaner@0.1.3:
resolution: {integrity: sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==}
engines: {node: '>=6'}
mem@4.3.0:
resolution: {integrity: sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==}
engines: {node: '>=6'}
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
mldoc@1.5.9:
resolution: {integrity: sha512-87FQ7hseS87tsk+VdpIigpu8LH+GwmbbFgpxgFwvnbH5oOjmIrc47laH4Dyggzqiy8/vMjDHkl7vsId0eXhCDQ==}
hasBin: true
nice-try@1.0.5:
resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==}
npm-run-path@2.0.2:
resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==}
engines: {node: '>=4'}
number-is-nan@1.0.1:
resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==}
engines: {node: '>=0.10.0'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
os-locale@3.1.0:
resolution: {integrity: sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==}
engines: {node: '>=6'}
p-defer@1.0.0:
resolution: {integrity: sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==}
engines: {node: '>=4'}
p-finally@1.0.0:
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
engines: {node: '>=4'}
p-is-promise@2.1.0:
resolution: {integrity: sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==}
engines: {node: '>=6'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-locate@3.0.0:
resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==}
engines: {node: '>=6'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
path-exists@3.0.0:
resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==}
engines: {node: '>=4'}
path-key@2.0.1:
resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==}
engines: {node: '>=4'}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
readline-sync@1.4.10:
resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==}
engines: {node: '>= 0.8.0'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@1.0.1:
resolution: {integrity: sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==}
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
shadow-cljs-jar@1.3.4:
resolution: {integrity: sha512-cZB2pzVXBnhpJ6PQdsjO+j/MksR28mv4QD/hP/2y1fsIa9Z9RutYgh3N34FZ8Ktl4puAXaIGlct+gMCJ5BmwmA==}
shadow-cljs@3.4.4:
resolution: {integrity: sha512-6/Z4XVWBavdDSbAR+ECbZTnq22Gws1zy+Ilfwcdl5SMf3ZdOYiYczqykfg/rLjlU0Ap9BvX8cEZGBExK4PGDaw==}
engines: {node: '>=6.0.0'}
hasBin: true
shebang-command@1.2.0:
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
engines: {node: '>=0.10.0'}
shebang-regex@1.0.0:
resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==}
engines: {node: '>=0.10.0'}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
source-map-support@0.5.21:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
string-width@1.0.2:
resolution: {integrity: sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==}
engines: {node: '>=0.10.0'}
string-width@2.1.1:
resolution: {integrity: sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==}
engines: {node: '>=4'}
strip-ansi@3.0.1:
resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==}
engines: {node: '>=0.10.0'}
strip-ansi@4.0.0:
resolution: {integrity: sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==}
engines: {node: '>=4'}
strip-eof@1.0.0:
resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==}
engines: {node: '>=0.10.0'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@1.3.1:
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
hasBin: true
which@5.0.0:
resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==}
engines: {node: ^18.17.0 || >=20.5.0}
hasBin: true
wrap-ansi@2.1.0:
resolution: {integrity: sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==}
engines: {node: '>=0.10.0'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yargs-parser@11.1.1:
resolution: {integrity: sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==}
yargs@12.0.5:
resolution: {integrity: sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==}
snapshots:
ansi-regex@2.1.1: {}
ansi-regex@3.0.1: {}
base64-js@1.5.1: {}
buffer-from@1.1.2: {}
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
camelcase@5.3.1: {}
cliui@4.1.0:
dependencies:
string-width: 2.1.1
strip-ansi: 4.0.0
wrap-ansi: 2.1.0
code-point-at@1.1.0: {}
cross-spawn@6.0.6:
dependencies:
nice-try: 1.0.5
path-key: 2.0.1
semver: 5.7.2
shebang-command: 1.2.0
which: 1.3.1
decamelize@1.2.0: {}
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
execa@1.0.0:
dependencies:
cross-spawn: 6.0.6
get-stream: 4.1.0
is-stream: 1.1.0
npm-run-path: 2.0.2
p-finally: 1.0.0
signal-exit: 3.0.7
strip-eof: 1.0.0
find-up@3.0.0:
dependencies:
locate-path: 3.0.0
get-caller-file@1.0.3: {}
get-stream@4.1.0:
dependencies:
pump: 3.0.4
ieee754@1.2.1: {}
invert-kv@2.0.0: {}
is-fullwidth-code-point@1.0.0:
dependencies:
number-is-nan: 1.0.1
is-fullwidth-code-point@2.0.0: {}
is-stream@1.1.0: {}
isexe@2.0.0: {}
isexe@3.1.1: {}
lcid@2.0.0:
dependencies:
invert-kv: 2.0.0
locate-path@3.0.0:
dependencies:
p-locate: 3.0.0
path-exists: 3.0.0
map-age-cleaner@0.1.3:
dependencies:
p-defer: 1.0.0
mem@4.3.0:
dependencies:
map-age-cleaner: 0.1.3
mimic-fn: 2.1.0
p-is-promise: 2.1.0
mimic-fn@2.1.0: {}
mldoc@1.5.9:
dependencies:
yargs: 12.0.5
nice-try@1.0.5: {}
npm-run-path@2.0.2:
dependencies:
path-key: 2.0.1
number-is-nan@1.0.1: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
os-locale@3.1.0:
dependencies:
execa: 1.0.0
lcid: 2.0.0
mem: 4.3.0
p-defer@1.0.0: {}
p-finally@1.0.0: {}
p-is-promise@2.1.0: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-locate@3.0.0:
dependencies:
p-limit: 2.3.0
p-try@2.2.0: {}
path-exists@3.0.0: {}
path-key@2.0.1: {}
process@0.11.10: {}
pump@3.0.4:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
readline-sync@1.4.10: {}
require-directory@2.1.1: {}
require-main-filename@1.0.1: {}
semver@5.7.2: {}
set-blocking@2.0.0: {}
shadow-cljs-jar@1.3.4: {}
shadow-cljs@3.4.4:
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.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
shebang-command@1.2.0:
dependencies:
shebang-regex: 1.0.0
shebang-regex@1.0.0: {}
signal-exit@3.0.7: {}
source-map-support@0.5.21:
dependencies:
buffer-from: 1.1.2
source-map: 0.6.1
source-map@0.6.1: {}
string-width@1.0.2:
dependencies:
code-point-at: 1.1.0
is-fullwidth-code-point: 1.0.0
strip-ansi: 3.0.1
string-width@2.1.1:
dependencies:
is-fullwidth-code-point: 2.0.0
strip-ansi: 4.0.0
strip-ansi@3.0.1:
dependencies:
ansi-regex: 2.1.1
strip-ansi@4.0.0:
dependencies:
ansi-regex: 3.0.1
strip-eof@1.0.0: {}
which-module@2.0.1: {}
which@1.3.1:
dependencies:
isexe: 2.0.0
which@5.0.0:
dependencies:
isexe: 3.1.1
wrap-ansi@2.1.0:
dependencies:
string-width: 1.0.2
strip-ansi: 3.0.1
wrappy@1.0.2: {}
ws@8.18.3: {}
y18n@4.0.3: {}
yargs-parser@11.1.1:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@12.0.5:
dependencies:
cliui: 4.1.0
decamelize: 1.2.0
find-up: 3.0.0
get-caller-file: 1.0.3
os-locale: 3.1.0
require-directory: 2.1.1
require-main-filename: 1.0.1
set-blocking: 2.0.0
string-width: 2.1.1
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 11.1.1

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

17
deps/publish/shadow-cljs.edn vendored Normal file
View File

@@ -0,0 +1,17 @@
;; 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}}
:publish-test {:target :node-test
:output-to "worker/dist/worker-test.js"
:devtools {:enabled false}
:compiler-options {:static-fns false}
:main logseq.publish.test-runner/main}}}

View File

@@ -0,0 +1,68 @@
(ns logseq.publish.assets
"Handles publishing assets"
(:require [clojure.string :as string]
[logseq.common.authorization :as authorization]
[logseq.publish.common :as publish-common]
[promesa.core :as p]))
(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]
(p/let [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 (authorization/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")
(p/let [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,294 @@
(ns logseq.publish.common
"Provides common util fns"
(:require [clojure.string :as string]
[cognitect.transit :as transit]
[datascript.transit :as dt]
[logseq.db :as ldb]
[promesa.core :as p]))
(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]
(p/let [data (.encode text-encoder message)
digest (.digest js/crypto.subtle "SHA-256" data)]
(to-hex digest)))
(def password-kdf-max-iterations 90000)
(def password-kdf-iterations 90000)
(defn bytes->base64url [data]
(let [binary (apply str (map #(js/String.fromCharCode %) (array-seq data)))
b64 (js/btoa binary)]
(-> b64
(string/replace #"\+" "-")
(string/replace #"/" "_")
(string/replace #"=+$" ""))))
(defn hash-password [password]
(p/let [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"])
iterations (min password-kdf-iterations password-kdf-max-iterations)
derived (.deriveBits js/crypto.subtle
#js {:name "PBKDF2"
:hash "SHA-256"
:salt salt
:iterations iterations}
crypto-key
256)
derived-bytes (js/Uint8Array. derived)
salt-encoded (bytes->base64url salt)
hash-encoded (bytes->base64url derived-bytes)]
(str "pbkdf2$sha256$"
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
(p/let [iterations (js/parseInt (nth parts 2))]
(if (> iterations password-kdf-max-iterations)
false
(p/let [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]
(p/let [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]
(p/let [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]
(p/let [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 normalize-etag [etag]
(when etag
(string/replace etag #"\"" "")))
(defn short-id-for-page [graph-uuid page-uuid]
(p/let [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,93 @@
(ns logseq.publish.index
"Builds indices from payloads for features like tagged nodes"
(: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/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/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,469 @@
(ns logseq.publish.meta-store
"Handles storing Durable Object in SQLite"
(:require [clojure.string :as string]
[logseq.publish.common :as publish-common]
[promesa.core :as p]))
(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 ^:large-vars/cleanup-todo do-fetch [^js self request]
(let [sql (.-sql self)]
(init-schema! sql)
(cond
(= "POST" (.-method request))
(p/let [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 "
"INNER JOIN pages "
"ON pages.graph_uuid = page_tags.graph_uuid "
"AND pages.page_uuid = page_tags.source_page_uuid "
"WHERE page_tags.tag_title = ? "
"AND pages.password_hash IS NULL "
"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 "
"INNER JOIN pages "
"ON pages.graph_uuid = page_tags.graph_uuid "
"AND pages.page_uuid = page_tags.source_page_uuid "
"WHERE page_tags.tag_title = ? "
"AND pages.password_hash IS NULL "
"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 "
"INNER 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(?))) "
"AND pages.password_hash IS NULL "
"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 = ? "
"AND password_hash IS NULL "
"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 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 "
"FROM page_tags "
"INNER JOIN pages "
"ON pages.graph_uuid = page_tags.graph_uuid "
"AND pages.page_uuid = page_tags.source_page_uuid "
"WHERE page_tags.graph_uuid = ? AND page_tags.tag_page_uuid = ? "
"AND pages.password_hash IS NULL "
"ORDER BY page_tags.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,42 @@
(ns logseq.publish.model
"Handles entity/model operations like conversion from datoms")
(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,994 @@
@import url("https://cdn.jsdelivr.net/npm/katex@0.16.38/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;
}
.inline-flex {
display: inline-flex;
}
.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: none;
padding-left: 0 !important;
}
.block-children .block {
position: relative;
padding-left: 16px;
}
.block-children .block::before {
content: "";
position: absolute;
left: 0;
top: 11px;
width: 5px;
height: 5px;
border-radius: 999px;
background: var(--muted);
}
.block {
margin: 8px 0;
}
.block-content {
white-space: pre-wrap;
display: flex;
gap: 4px;
align-items: flex-start;
min-height: 26px;
}
.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;
}
.block-content > .positioned-properties.block-left {
align-self: flex-start;
margin-top: 6px;
}
.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: 18px;
height: 18px;
line-height: 1;
font-size: 18px;
color: currentColor;
}
.property-icon svg {
width: 18px;
height: 18px;
}
.positioned-properties .ls-icon-Backlog {
color: var(--rx-gray-05, #a8a29e);
}
.positioned-properties .ls-icon-Todo {
color: var(--rx-gray-10, #78716c);
}
.positioned-properties .ls-icon-InProgress50 {
color: var(--rx-yellow-08, #ca8a04);
}
.positioned-properties .ls-icon-InReview {
color: var(--rx-blue-09, #1d4ed8);
}
.positioned-properties .ls-icon-Done {
color: var(--rx-green-08, #16a34a);
}
.positioned-properties .ls-icon-Cancelled {
color: var(--rx-red-08, #dc2626);
}
.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.38?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: 18 });
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,784 @@
(ns logseq.publish.routes
"Handles routing and responses for worker"
(:require [cljs-bean.core :as bean]
[clojure.string :as string]
[logseq.common.authorization :as authorization]
[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]
[promesa.core :as p]
[shadow.resource :as resource]))
(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"))
;; Should this be used?
;; (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]
;; NOTE: DO stubs from (.get do-ns do-id) are thenable under the
;; new cloudflare:workers DurableObject RPC runtime. Binding them
;; in (p/let) causes promesa to await them, triggering
;; structured-clone of the stub (DataCloneError on RpcPromise).
;; Keep stub acquisition in a sync (let).
(let [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)]
(p/let [resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/password")
#js {:method "GET"})]
(when (.-ok resp)
(p/let [data (.json resp)]
(aget data "password_hash"))))))
(defn- check-page-password
[request graph-uuid page-uuid env]
(p/let [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)
(p/let [valid? (publish-common/verify-password provided stored-hash)]
{:allowed? valid? :provided? true})
{:allowed? false :provided? false})))))
(defn- auth-claims
[request env]
(p/let [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 (authorization/verify-jwt token env))]
{:claims claims}))
(defn handle-post-pages [request env]
(-> (p/let [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 (authorization/verify-jwt token env))]
(if (nil? claims)
(publish-common/unauthorized)
(p/let [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
;; NOTE: DO stubs from (.get do-ns do-id) are thenable under the
;; new cloudflare:workers DurableObject RPC runtime. Binding them
;; in a (p/let) causes promesa to await them, which triggers
;; structured-clone of the stub and crashes with
;; DataCloneError: Could not serialize object of type "RpcPromise".
;; Keep stubs in a sync (let) so promesa never awaits them.
(let [graph-uuid graph
r2-key (str "publish/" graph-uuid "/"
content_hash ".transit")
r2 (aget env "PUBLISH_R2")
^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"))
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})))
(p/let [existing (.head r2 r2-key)
_ (when-not existing
(.put r2 r2-key body
#js {:httpMetadata #js {:contentType "application/transit+json"}}))
short-id (publish-common/short-id-for-page graph-uuid page_uuid)
password-hash (when (and (string? page-password)
(not (string/blank? page-password)))
(publish-common/hash-password page-password))]
(let [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))})
body-str (js/JSON.stringify payload)]
(p/let [meta-resp (.fetch do-stub "https://publish/pages"
#js {:method "POST"
:headers #js {"content-type" "application/json"}
:body body-str})]
(if-not (.-ok meta-resp)
(publish-common/json-response {:error "metadata store failed"} 500)
(let [index-id (.idFromName do-ns "index")
index-stub (.get do-ns index-id)]
(p/let [_ (.fetch index-stub "https://publish/pages"
#js {:method "POST"
:headers #js {"content-type" "application/json"}
:body body-str})]
(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)})))))))))))))
(p/catch (fn [^js err]
(js/console.error "publish:post-pages error"
(some-> err .-name)
(some-> err .-message)
(some-> err .-stack))
(publish-common/json-response
{:error "internal"}
500)))))
(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")
(let [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)]
(p/let [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)
(p/let [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")
(let [^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)]
(p/let [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)
(p/let [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)]
(if-not allowed?
(publish-common/json-response {:error "password required"} 401)
(p/let [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")
(let [^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)]
(p/let [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))})
(p/let [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)]
(if-not allowed?
(publish-common/json-response {:error "password required"} 401)
(p/let [meta (.json meta-resp)
r2-key (aget meta "r2_key")]
(if-not r2-key
(publish-common/json-response {:error "missing transit"} 404)
(p/let [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")
(p/let [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)]
(if-not allowed?
(publish-common/json-response {:error "password required"} 401)
(let [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)]
(p/let [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))})
(p/let [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")
(p/let [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)]
(if-not allowed?
(publish-common/json-response {:error "password required"} 401)
(let [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)]
(p/let [tags-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes"))]
(if-not (.-ok tags-resp)
(publish-common/not-found)
(p/let [tags (.json tags-resp)]
(publish-common/json-response (js->clj tags :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")
(let [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)]
(p/let [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))})
(p/let [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")
(let [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)]
(p/let [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)
(p/let [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")
(let [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)]
(p/let [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))})
(p/let [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")
(let [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)]
(p/let [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))})
(p/let [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")
(let [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)]
(p/let [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))})
(p/let [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")
(let [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)]
(p/let [resp (.fetch do-stub (str "https://publish/ref/" (js/encodeURIComponent ref-name))
#js {:method "GET"})]
(if-not (.-ok resp)
(publish-common/not-found)
(p/let [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")
(let [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)]
(p/let [resp (.fetch do-stub (str "https://publish/ref/" (js/encodeURIComponent ref-name))
#js {:method "GET"})]
(if-not (.-ok resp)
(publish-common/not-found)
(p/let [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")
(p/let [{:keys [claims]} (auth-claims request env)]
(if (nil? claims)
(publish-common/unauthorized)
(let [^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)]
(p/let [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)
(p/let [meta (.json meta-resp)
owner-sub (aget meta "owner_sub")
subject (aget claims "sub")]
(if (or (string/blank? owner-sub)
(not= owner-sub subject))
(publish-common/forbidden)
(p/let [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")
(p/let [{:keys [claims]} (auth-claims request env)]
(if (nil? claims)
(publish-common/unauthorized)
(let [^js do-ns (aget env "PUBLISH_META_DO")
index-id (.idFromName do-ns "index")
index-stub (.get do-ns index-id)]
(p/let [list-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid)
#js {:method "GET"})]
(if-not (.-ok list-resp)
(publish-common/not-found)
(p/let [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)
(p/let [_ (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")
(let [^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)
index-id (.idFromName do-ns "index")
index-stub (.get do-ns index-id)]
(p/let [meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid))]
(if-not (.-ok meta-resp)
(p/let [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))
(p/let [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))})))
(p/let [{: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))})
(p/let [meta (.json meta-resp)
etag (aget meta "content_hash")
if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match"))
refs-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/refs"))
refs-json (when (and refs-resp (.-ok refs-resp))
(p/let [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))
(p/let [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)
(p/let [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- rewrite-request-path
[request new-path]
(let [request-url (js/URL. (.-url request))
new-url (js/URL. (str (.-origin request-url) new-path))]
(set! (.-search new-url) (.-search request-url))
(js/Request. (str new-url) request)))
(defn ^:large-vars/cleanup-todo 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 (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")
(p/let [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 (or (string/starts-with? path "/p/")
(string/starts-with? path "/s/"))
(= 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")
(let [^js do-ns (aget env "PUBLISH_META_DO")
do-id (.idFromName do-ns "index")
do-stub (.get do-ns do-id)]
(p/let [resp (.fetch do-stub (str "https://publish/short/" short-id)
#js {:method "GET"})]
(if-not (.-ok resp)
(publish-common/not-found)
(p/let [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")
page-path (str "/page/" graph-uuid "/" page-uuid)
page-request (rewrite-request-path request page-path)]
(handle-page-html page-request env)))))))))
(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")
(let [^js do-ns (aget env "PUBLISH_META_DO")
index-id (.idFromName do-ns "index")
index-stub (.get do-ns index-id)]
(p/let [resp (.fetch index-stub (str "https://publish/user/" username)
#js {:method "GET"})]
(if-not (.-ok resp)
(publish-common/not-found)
(p/let [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,23 @@
(ns logseq.publish.worker
"Main public ns for 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)))

View File

@@ -0,0 +1,100 @@
(ns logseq.publish.common-test
(:require [cljs.test :refer [async deftest is testing]]
[logseq.publish.common :as publish-common]
[promesa.core :as p]))
(deftest merge-headers-overrides-and-preserves-values
(let [headers (publish-common/merge-headers #js {"a" "1" "keep" "ok"}
#js {"a" "2" "b" "3"})]
(is (= "2" (.get headers "a")))
(is (= "3" (.get headers "b")))
(is (= "ok" (.get headers "keep")))))
(deftest parse-meta-header-valid-and-invalid-json
(testing "valid json meta header is parsed into keywordized map"
(let [request (js/Request. "https://publish.example/pages"
#js {:headers #js {"x-publish-meta" "{\"content_hash\":\"h\",\"graph\":\"g\",\"page_uuid\":\"p\"}"}})
meta (publish-common/parse-meta-header request)]
(is (= "h" (:content_hash meta)))
(is (= "g" (:graph meta)))
(is (= "p" (:page_uuid meta)))))
(testing "invalid json returns nil"
(let [request (js/Request. "https://publish.example/pages"
#js {:headers #js {"x-publish-meta" "{not-json"}})]
(is (nil? (publish-common/parse-meta-header request))))))
(deftest valid-meta-requires-core-fields
(is (some? (publish-common/valid-meta? {:content_hash "h" :graph "g" :page_uuid "p"})))
(is (nil? (publish-common/valid-meta? {:content_hash "h" :graph "g"})))
(is (nil? (publish-common/valid-meta? nil))))
(deftest get-sql-rows-handles-supported-shapes
(is (= [] (publish-common/get-sql-rows nil)))
(is (= [{"a" 1}]
(js->clj (publish-common/get-sql-rows #js {:rows #js [#js {"a" 1}]})
:keywordize-keys false)))
(let [row (js-obj)]
(aset row "rows" #js [#js {"b" 2}])
(is (= [{"rows" [{"b" 2}]}]
(js->clj (publish-common/get-sql-rows #js [row])
:keywordize-keys false)))))
(deftest json-error-response-helpers-set-status
(async done
(-> (p/let [unauthorized (publish-common/unauthorized)
forbidden (publish-common/forbidden)
bad-request (publish-common/bad-request "bad")
not-found (publish-common/not-found)
unauthorized-body (.json unauthorized)
forbidden-body (.json forbidden)
bad-request-body (.json bad-request)
not-found-body (.json not-found)]
(is (= 401 (.-status unauthorized)))
(is (= 403 (.-status forbidden)))
(is (= 400 (.-status bad-request)))
(is (= 404 (.-status not-found)))
(is (= "unauthorized" (aget unauthorized-body "error")))
(is (= "forbidden" (aget forbidden-body "error")))
(is (= "bad" (aget bad-request-body "error")))
(is (= "not found" (aget not-found-body "error")))
(done))
(p/catch (fn [error]
(is nil (str error))
(done))))))
(deftest normalize-etag-removes-double-quotes
(is (= "abc" (publish-common/normalize-etag "\"abc\"")))
(is (= "abc" (publish-common/normalize-etag "abc")))
(is (nil? (publish-common/normalize-etag nil))))
(deftest encode-path-encodes-path-segments
(is (= "with%20space/plus%2Bsign" (publish-common/encode-path "with space/plus+sign"))))
(deftest short-id-for-page-is-deterministic-and-fixed-length
(async done
(-> (p/let [a (publish-common/short-id-for-page "graph-1" "page-1")
b (publish-common/short-id-for-page "graph-1" "page-1")
c (publish-common/short-id-for-page "graph-1" "page-2")]
(is (= 10 (count a)))
(is (= a b))
(is (not= a c))
(done))
(p/catch (fn [error]
(is nil (str error))
(done))))))
(deftest hash-and-verify-password-roundtrip
(async done
(-> (p/let [hashed (publish-common/hash-password "secret-value")
ok? (publish-common/verify-password "secret-value" hashed)
wrong? (publish-common/verify-password "wrong-value" hashed)]
(is (string? hashed))
(is (true? ok?))
(is (false? wrong?))
(done))
(p/catch (fn [error]
(is nil (str error))
(done))))))
(deftest verify-password-rejects-invalid-hash-format
(is (false? (publish-common/verify-password "secret" "not-a-valid-hash"))))

View File

@@ -0,0 +1,58 @@
(ns logseq.publish.render-test
(:require [cljs.test :refer [deftest is testing]]
[logseq.publish.render :as render]))
(deftest entity-properties-filters-explicitly-hidden-properties
(testing "property is filtered when ctx marks it hidden"
(let [entity {:user.property/secret "value"}
ctx {:property-hidden-by-ident {:user.property/secret true}}
result (render/entity-properties entity ctx {})]
(is (nil? (get result :user.property/secret))))))
(deftest entity-properties-filters-built-in-hidden-properties
(testing "built-in properties with :hide? true are filtered without property entity metadata"
(let [entity {:logseq.property/created-from-property "some-value"}
ctx {:property-hidden-by-ident {}}
result (render/entity-properties entity ctx {})]
(is (nil? (get result :logseq.property/created-from-property))))))
(deftest filter-tags-removes-built-in-tag-values
(testing "built-in class keyword tags are removed"
(let [result (render/filter-tags [:logseq.class/Tag :user.property/custom] {})]
(is (= [:user.property/custom] result))))
(testing "built-in class entities are removed"
(let [entities {1 {:db/ident :logseq.class/Tag}
2 {:db/ident :user.property/custom}}
result (render/filter-tags [1 2] entities)]
(is (= [2] result)))))
(deftest block-content-nodes-use-auto-dir
(let [node (render/block-content-nodes
{:block/title "english then عربي"}
{:uuid->title {} :graph-uuid "g"}
1)]
(is (= "auto" (get-in node [1 :dir])))))
(deftest block-content-from-ref-uses-auto-dir
(let [node (render/block-content-from-ref
{"source_block_content" "كلام عربي then english"}
{:uuid->title {} :graph-uuid "g"})]
(is (= "auto" (get-in node [1 :dir])))))
(deftest render-properties-use-auto-dir
(let [props {:user.property/sample "english then عربي"}
ctx {:property-title-by-ident {}
:property-type-by-ident {}}
rendered (render/render-properties props ctx {})
entry (first (second rendered))
dd-node (nth entry 2)]
(is (= :dd.property-value (first dd-node)))
(is (= "auto" (get-in dd-node [1 :dir])))))
(deftest block-content-nodes-embeds-youtube-shorts-player
(let [node (render/block-content-nodes
{:block/title "{{video https://www.youtube.com/shorts/xu9p5ynlhZk}}"}
{:uuid->title {} :graph-uuid "g"}
1)
html (render/render-hiccup node)]
(is (re-find #"https://www\.youtube\.com/embed/xu9p5ynlhZk" html))))

View File

@@ -0,0 +1,393 @@
(ns logseq.publish.routes-test
(:require [cljs.test :refer [async deftest is testing]]
[logseq.common.authorization :as authorization]
[logseq.publish.common :as publish-common]
[logseq.publish.routes :as routes]
[promesa.core :as p]))
(defn- json-response
[data]
(js/Response.
(js/JSON.stringify data)
#js {:status 200
:headers #js {"content-type" "application/json"}}))
(defn- short-url-env
[]
(let [do-stub #js {:fetch (fn [url _opts]
(cond
(= "https://publish/short/abc123" url)
(js/Promise.resolve
(json-response
#js {:page #js {"graph_uuid" "graph-1"
"page_uuid" "page-1"}}))
(re-matches #"https://publish/pages/[^/]+/[^/]+/password" url)
(js/Promise.resolve (json-response #js {}))
(re-matches #"https://publish/pages/[^/]+/[^/]+" url)
(js/Promise.resolve
(json-response
#js {"content_hash" "etag-1"
"r2_key" "publish/graph-1/page-1.transit"}))
:else
(js/Promise.resolve
(js/Response. "not found" #js {:status 404}))))}
do-ns #js {:idFromName (fn [_name] "index")
:get (fn [_id] do-stub)}
r2 #js {:get (fn [_key]
(js/Promise.resolve
#js {:arrayBuffer (fn []
(let [payload (.encode (js/TextEncoder.) "{}")]
(js/Promise.resolve (.-buffer payload))))}))}]
#js {"PUBLISH_META_DO" do-ns
"PUBLISH_R2" r2}))
(defn- empty-env []
#js {})
(defn- json-error-response
[status message]
(js/Response.
(js/JSON.stringify #js {"error" message})
#js {:status status
:headers #js {"content-type" "application/json"}}))
(defn- ok-json-response
[data]
(js/Response.
(js/JSON.stringify data)
#js {:status 200
:headers #js {"content-type" "application/json"}}))
(defn- method-from-opts
[opts]
(or (some-> opts (aget "method"))
"GET"))
(defn- permission-env
[route-dispatch]
(let [do-ns #js {:idFromName (fn [name] name)
:get (fn [id]
#js {:fetch (fn [url opts]
(js/Promise.resolve
(route-dispatch id url (method-from-opts opts))))})}]
#js {"PUBLISH_META_DO" do-ns}))
(deftest short-url-does-not-redirect-to-uuid-url
(testing "short URL should not redirect to /page/:graph/:page"
(async done
(let [request (js/Request. "https://publish.example/p/abc123?password=s3cr3t")
env (short-url-env)]
(-> (p/let [response (routes/handle-fetch request env)
body (.text response)]
(is (= 200 (.-status response)))
(is (nil? (.get (.-headers response) "location")))
(is (re-find #"<!doctype html>" body))
(is (not (re-find #"Page not found" body)))
(done))
(p/catch (fn [error]
(is nil (str error))
(done))))))))
(deftest legacy-s-short-url-is-supported
(testing "legacy /s/:short-id should render page html"
(async done
(let [request (js/Request. "https://publish.example/s/abc123")
env (short-url-env)]
(-> (p/let [response (routes/handle-fetch request env)
body (.text response)]
(is (= 200 (.-status response)))
(is (nil? (.get (.-headers response) "location")))
(is (re-find #"<!doctype html>" body))
(is (not (re-find #"Page not found" body)))
(done))
(p/catch (fn [error]
(is nil (str error))
(done))))))))
(deftest page-uuid-route-is-still-supported
(testing "legacy /page/:graph-uuid/:page-uuid should still render page html"
(async done
(let [request (js/Request. "https://publish.example/page/3bc00ad3-f421-41e7-8c65-40861c298be5/6954ee2a-506b-4dd9-bd6d-0dc24db9c055")
env (short-url-env)]
(-> (p/let [response (routes/handle-fetch request env)
body (.text response)]
(is (= 200 (.-status response)))
(is (nil? (.get (.-headers response) "location")))
(is (re-find #"<!doctype html>" body))
(is (not (re-find #"Page not found" body)))
(done))
(p/catch (fn [error]
(is nil (str error))
(done))))))))
(deftest options-route-returns-cors-no-content
(async done
(let [request (js/Request. "https://publish.example/any"
#js {:method "OPTIONS"})]
(-> (p/let [response (routes/handle-fetch request (empty-env))]
(is (= 204 (.-status response)))
(is (= "*" (.get (.-headers response) "access-control-allow-origin")))
(done))
(p/catch (fn [error]
(is nil (str error))
(done)))))))
(deftest home-route-renders-html
(async done
(let [request (js/Request. "https://publish.example/")]
(-> (p/let [response (routes/handle-fetch request (empty-env))
body (.text response)]
(is (= 200 (.-status response)))
(is (re-find #"<!doctype html>" body))
(is (re-find #"Logseq Publish" body))
(done))
(p/catch (fn [error]
(is nil (str error))
(done)))))))
(deftest static-assets-return-content-types
(async done
(-> (p/let [css-resp (routes/handle-fetch (js/Request. "https://publish.example/static/publish.css") (empty-env))
js-resp (routes/handle-fetch (js/Request. "https://publish.example/static/publish.js") (empty-env))
ext-resp (routes/handle-fetch (js/Request. "https://publish.example/static/tabler.ext.js") (empty-env))]
(is (= "text/css; charset=utf-8" (.get (.-headers css-resp) "content-type")))
(is (= "text/javascript; charset=utf-8" (.get (.-headers js-resp) "content-type")))
(is (= "text/javascript; charset=utf-8" (.get (.-headers ext-resp) "content-type")))
(done))
(p/catch (fn [error]
(is nil (str error))
(done))))))
(deftest short-url-missing-id-returns-bad-request
(async done
(-> (p/let [response (routes/handle-fetch (js/Request. "https://publish.example/p/") (empty-env))
body (.json response)]
(is (= 400 (.-status response)))
(is (= "missing short id" (aget body "error")))
(done))
(p/catch (fn [error]
(is nil (str error))
(done))))))
(deftest short-url-not-found-returns-not-found
(async done
(let [do-stub #js {:fetch (fn [_url _opts]
(js/Promise.resolve (js/Response. "nope" #js {:status 404})))}
do-ns #js {:idFromName (fn [_name] "index")
:get (fn [_id] do-stub)}
env #js {"PUBLISH_META_DO" do-ns}]
(-> (p/let [response (routes/handle-fetch (js/Request. "https://publish.example/p/abc") env)
body (.json response)]
(is (= 404 (.-status response)))
(is (= "not found" (aget body "error")))
(done))
(p/catch (fn [error]
(is nil (str error))
(done)))))))
(deftest asset-route-validates-missing-or-invalid-id
(async done
(-> (p/let [missing-id-resp (routes/handle-fetch (js/Request. "https://publish.example/asset//") (empty-env))
missing-id-body (.json missing-id-resp)
invalid-id-resp (routes/handle-fetch (js/Request. "https://publish.example/asset/g/noext") (empty-env))
invalid-id-body (.json invalid-id-resp)]
(is (= 400 (.-status missing-id-resp)))
(is (= "missing asset id" (aget missing-id-body "error")))
(is (= 400 (.-status invalid-id-resp)))
(is (= "invalid asset id" (aget invalid-id-body "error")))
(done))
(p/catch (fn [error]
(is nil (str error))
(done))))))
(deftest user-route-validates-missing-username
(async done
(-> (p/let [response (routes/handle-fetch (js/Request. "https://publish.example/u/") (empty-env))
body (.json response)]
(is (= 400 (.-status response)))
(is (= "missing username" (aget body "error")))
(done))
(p/catch (fn [error]
(is nil (str error))
(done))))))
(deftest post-pages-without-auth-is-unauthorized
(async done
(-> (p/let [request (js/Request. "https://publish.example/pages"
#js {:method "POST"
:headers #js {"content-type" "application/transit+json"}
:body "{}"})
response (routes/handle-fetch request (empty-env))
body (.json response)]
(is (= 401 (.-status response)))
(is (= "unauthorized" (aget body "error")))
(done))
(p/catch (fn [error]
(is nil (str error))
(done))))))
(deftest delete-page-without-auth-is-unauthorized
(async done
(-> (p/let [request (js/Request. "https://publish.example/pages/graph-1/page-1"
#js {:method "DELETE"})
response (routes/handle-fetch request (empty-env))
body (.json response)]
(is (= 401 (.-status response)))
(is (= "unauthorized" (aget body "error")))
(done))
(p/catch (fn [error]
(is nil (str error))
(done))))))
(deftest delete-page-owner-mismatch-is-forbidden
(async done
(let [env (permission-env
(fn [id url method]
(cond
(and (= id "index")
(= method "GET")
(= url "https://publish/pages/graph-1/page-1"))
(ok-json-response #js {"owner_sub" "owner-a"})
:else
(json-error-response 404 "not found"))))
request (js/Request. "https://publish.example/pages/graph-1/page-1"
#js {:method "DELETE"
:headers #js {"authorization" "Bearer token"}})]
(-> (p/let [response (p/with-redefs [authorization/verify-jwt (fn [_ _] #js {"sub" "owner-b"})]
(routes/handle-fetch request env))
body (.json response)]
(is (= 403 (.-status response)))
(is (= "forbidden" (aget body "error")))
(done))
(p/catch (fn [error]
(is nil (str error))
(done)))))))
(deftest delete-page-owner-match-succeeds
(async done
(let [env (permission-env
(fn [id url method]
(cond
(and (= id "index")
(= method "GET")
(= url "https://publish/pages/graph-1/page-1"))
(ok-json-response #js {"owner_sub" "owner-a"})
(and (= id "index")
(= method "DELETE")
(= url "https://publish/pages/graph-1/page-1"))
(ok-json-response #js {"ok" true})
(and (= id "graph-1:page-1")
(= method "DELETE")
(= url "https://publish/pages/graph-1/page-1"))
(ok-json-response #js {"ok" true})
:else
(json-error-response 404 "not found"))))
request (js/Request. "https://publish.example/pages/graph-1/page-1"
#js {:method "DELETE"
:headers #js {"authorization" "Bearer token"}})]
(-> (p/let [response (p/with-redefs [authorization/verify-jwt (fn [_ _] #js {"sub" "owner-a"})]
(routes/handle-fetch request env))
body (.json response)]
(is (= 200 (.-status response)))
(is (true? (aget body "ok")))
(done))
(p/catch (fn [error]
(is nil (str error))
(done)))))))
(deftest delete-graph-owner-mismatch-is-forbidden
(async done
(let [env (permission-env
(fn [id url method]
(cond
(and (= id "index")
(= method "GET")
(= url "https://publish/pages/graph-1"))
(ok-json-response #js {"pages" #js [#js {"owner_sub" "owner-a" "page_uuid" "page-1"}]})
:else
(json-error-response 404 "not found"))))
request (js/Request. "https://publish.example/pages/graph-1"
#js {:method "DELETE"
:headers #js {"authorization" "Bearer token"}})]
(-> (p/let [response (p/with-redefs [authorization/verify-jwt (fn [_ _] #js {"sub" "owner-b"})]
(routes/handle-fetch request env))
body (.json response)]
(is (= 403 (.-status response)))
(is (= "forbidden" (aget body "error")))
(done))
(p/catch (fn [error]
(is nil (str error))
(done)))))))
(deftest delete-graph-owner-match-succeeds
(async done
(let [env (permission-env
(fn [id url method]
(cond
(and (= id "index")
(= method "GET")
(= url "https://publish/pages/graph-1"))
(ok-json-response #js {"pages" #js [#js {"owner_sub" "owner-a" "page_uuid" "page-1"}
#js {"owner_sub" "owner-a" "page_uuid" "page-2"}]})
(and (= id "index")
(= method "DELETE")
(= url "https://publish/pages/graph-1"))
(ok-json-response #js {"ok" true})
(and (= method "DELETE")
(or (and (= id "graph-1:page-1") (= url "https://publish/pages/graph-1/page-1"))
(and (= id "graph-1:page-2") (= url "https://publish/pages/graph-1/page-2"))))
(ok-json-response #js {"ok" true})
:else
(json-error-response 404 "not found"))))
request (js/Request. "https://publish.example/pages/graph-1"
#js {:method "DELETE"
:headers #js {"authorization" "Bearer token"}})]
(-> (p/let [response (p/with-redefs [authorization/verify-jwt (fn [_ _] #js {"sub" "owner-a"})]
(routes/handle-fetch request env))
body (.json response)]
(is (= 200 (.-status response)))
(is (true? (aget body "ok")))
(done))
(p/catch (fn [error]
(is nil (str error))
(done)))))))
(deftest get-page-requires-correct-password-when-protected
(async done
(let [env (permission-env
(fn [id url method]
(cond
(and (= id "graph-1:page-1")
(= method "GET")
(= url "https://publish/pages/graph-1/page-1"))
(ok-json-response #js {"content_hash" "h-1"
"r2_key" "publish/graph-1/page-1.transit"})
(and (= id "index")
(= method "GET")
(= url "https://publish/pages/graph-1/page-1/password"))
(ok-json-response #js {"password_hash" "pbkdf2$sha256$90000$x$y"})
:else
(json-error-response 404 "not found"))))
request (js/Request. "https://publish.example/pages/graph-1/page-1")]
(-> (p/let [response (p/with-redefs [publish-common/verify-password (fn [_ _] (p/resolved false))]
(routes/handle-fetch request env))
body (.json response)]
(is (= 401 (.-status response)))
(is (= "password required" (aget body "error")))
(done))
(p/catch (fn [error]
(is nil (str error))
(done)))))))

View File

@@ -0,0 +1,23 @@
(ns logseq.publish.test-runner
(:require [cljs.test :as ct]
[logseq.publish.common-test]
[logseq.publish.render-test]
[logseq.publish.routes-test]
[shadow.test :as st]
[shadow.test.env :as env]))
(derive ::node ::ct/default)
(defmethod ct/report [::node :end-run-tests] [m]
(if (ct/successful? m)
(js/process.exit 0)
(js/process.exit 1)))
(defn ^:dev/after-load reset-test-data! []
(-> (env/get-test-data)
(env/reset-test-data!)))
(defn main [& _args]
(reset-test-data!)
(let [test-env (ct/empty-env ::node)]
(st/run-all-tests test-env nil)))

50
deps/publish/worker/README.md vendored Normal file
View File

@@ -0,0 +1,50 @@
## 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 /tag/:tag-name`
- List all pages tagged with `:tag-name`
- `GET /ref/:page-name`
- List all pages that reference `:page-name`
- `GET /user/:user-name`
- List all pages for `:user-name`
### 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-05-05"
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