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:
3
deps/publish/.carve/config.edn
vendored
Normal file
3
deps/publish/.carve/config.edn
vendored
Normal 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
18
deps/publish/.clj-kondo/config.edn
vendored
Normal 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
1
deps/publish/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.clj-kondo/.cache
|
||||
42
deps/publish/README.md
vendored
Normal file
42
deps/publish/README.md
vendored
Normal 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
31
deps/publish/bb.edn
vendored
Normal 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
26
deps/publish/deps.edn
vendored
Normal 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
19
deps/publish/package.json
vendored
Normal 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
530
deps/publish/pnpm-lock.yaml
generated
vendored
Normal 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
|
||||
17
deps/publish/scripts/bump-publish-version.js
vendored
Normal file
17
deps/publish/scripts/bump-publish-version.js
vendored
Normal 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
17
deps/publish/shadow-cljs.edn
vendored
Normal 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}}}
|
||||
68
deps/publish/src/logseq/publish/assets.cljs
vendored
Normal file
68
deps/publish/src/logseq/publish/assets.cljs
vendored
Normal 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)})))))))
|
||||
294
deps/publish/src/logseq/publish/common.cljs
vendored
Normal file
294
deps/publish/src/logseq/publish/common.cljs
vendored
Normal 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))))
|
||||
93
deps/publish/src/logseq/publish/index.cljs
vendored
Normal file
93
deps/publish/src/logseq/publish/index.cljs
vendored
Normal 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)))))
|
||||
469
deps/publish/src/logseq/publish/meta_store.cljs
vendored
Normal file
469
deps/publish/src/logseq/publish/meta_store.cljs
vendored
Normal 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))))
|
||||
42
deps/publish/src/logseq/publish/model.cljs
vendored
Normal file
42
deps/publish/src/logseq/publish/model.cljs
vendored
Normal 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))
|
||||
994
deps/publish/src/logseq/publish/publish.css
vendored
Normal file
994
deps/publish/src/logseq/publish/publish.css
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
712
deps/publish/src/logseq/publish/publish.js
vendored
Normal file
712
deps/publish/src/logseq/publish/publish.js
vendored
Normal 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();
|
||||
}
|
||||
1611
deps/publish/src/logseq/publish/render.cljs
vendored
Normal file
1611
deps/publish/src/logseq/publish/render.cljs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
784
deps/publish/src/logseq/publish/routes.cljs
vendored
Normal file
784
deps/publish/src/logseq/publish/routes.cljs
vendored
Normal 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))}))))
|
||||
23
deps/publish/src/logseq/publish/worker.cljs
vendored
Normal file
23
deps/publish/src/logseq/publish/worker.cljs
vendored
Normal 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)))
|
||||
100
deps/publish/test/logseq/publish/common_test.cljs
vendored
Normal file
100
deps/publish/test/logseq/publish/common_test.cljs
vendored
Normal 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"))))
|
||||
58
deps/publish/test/logseq/publish/render_test.cljs
vendored
Normal file
58
deps/publish/test/logseq/publish/render_test.cljs
vendored
Normal 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))))
|
||||
393
deps/publish/test/logseq/publish/routes_test.cljs
vendored
Normal file
393
deps/publish/test/logseq/publish/routes_test.cljs
vendored
Normal 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)))))))
|
||||
23
deps/publish/test/logseq/publish/test_runner.cljs
vendored
Normal file
23
deps/publish/test/logseq/publish/test_runner.cljs
vendored
Normal 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
50
deps/publish/worker/README.md
vendored
Normal 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.
|
||||
12
deps/publish/worker/scripts/clear_dev_state.sh
vendored
Executable file
12
deps/publish/worker/scripts/clear_dev_state.sh
vendored
Executable 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
36
deps/publish/worker/scripts/dev_test.sh
vendored
Executable 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
71
deps/publish/worker/wrangler.toml
vendored
Normal 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
|
||||
Reference in New Issue
Block a user