From 181f1b013910f34f2bce8a1494ada62bbf16e74a Mon Sep 17 00:00:00 2001 From: DocRAID Date: Sun, 30 Aug 2026 23:49:12 +0900 Subject: [PATCH] improve branch --- .github/workflows/deploy.yml | 56 +- Cargo.lock | 567 ++++++++++++++++++- Cargo.toml | 32 +- README.md | 54 +- Trunk.toml | 7 + assets/fonts/FiraCode-Regular.woff2 | Bin 0 -> 103240 bytes assets/fonts/OFL.txt | 93 ++++ favicon.svg | 4 + index.html | 55 +- rss.xml | 14 + rust-toolchain.toml | 4 + snapshot.json | 41 ++ src/app.rs | 231 +++++++- src/bin/fetch_content.rs | 216 +++++++ src/content.rs | 25 +- src/lib.rs | 156 ++++++ src/main.rs | 86 +-- src/module/catalog.rs | 183 ++++++ src/module/config.rs | 19 +- src/module/mod.rs | 3 + src/module/notion.rs | 671 ++++++++++++++++++++++ src/module/scraper.rs | 837 ++++------------------------ src/module/snapshot.rs | 43 ++ src/mouse.rs | 168 +++++- src/router.rs | 84 ++- src/theme.rs | 36 +- src/ui/about.rs | 19 +- src/ui/blog.rs | 237 +++++--- src/ui/intro.rs | 190 +++---- src/ui/mod.rs | 31 +- src/ui/not_found.rs | 18 +- src/width.rs | 83 +++ 32 files changed, 3134 insertions(+), 1129 deletions(-) create mode 100644 Trunk.toml create mode 100644 assets/fonts/FiraCode-Regular.woff2 create mode 100644 assets/fonts/OFL.txt create mode 100644 favicon.svg create mode 100644 rss.xml create mode 100644 rust-toolchain.toml create mode 100644 snapshot.json create mode 100644 src/bin/fetch_content.rs create mode 100644 src/lib.rs create mode 100644 src/module/catalog.rs create mode 100644 src/module/notion.rs create mode 100644 src/module/snapshot.rs create mode 100644 src/width.rs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6d781b4..275701b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,9 +1,10 @@ -name: Deploy to GitHub Pages +name: CI and GitHub Pages on: push: - branches: [main] # 기본 브랜치에 맞게 수정 - workflow_dispatch: # 수동 실행 가능 + branches: [main] + pull_request: + workflow_dispatch: permissions: contents: read @@ -11,11 +12,41 @@ permissions: id-token: write concurrency: - group: "pages" + group: pages-${{ github.ref }} cancel-in-progress: true +env: + TRUNK_VERSION: "0.21.14" + SITE_URL: "https://docraid.github.io" + jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + targets: wasm32-unknown-unknown + + - name: Cache Rust + uses: Swatinem/rust-cache@v2 + + - name: Format + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets --features fetch-bin -- -D warnings + + - name: Test + run: cargo test --features fetch-bin + deploy: + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + needs: test environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} @@ -32,17 +63,20 @@ jobs: - name: Cache Rust uses: Swatinem/rust-cache@v2 + - name: Snapshot Notion catalog + run: cargo run --features fetch-bin --bin fetch_content + - name: Install Trunk run: | - wget -qO- https://github.com/trunk-rs/trunk/releases/latest/download/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf- + wget -q "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" + tar -xzf trunk-x86_64-unknown-linux-gnu.tar.gz + chmod +x trunk - name: Build with Trunk run: ./trunk build --release --public-url "/" - # 프로젝트 페이지인 경우 (username.github.io/repo-name) - # run: ./trunk build --release --public-url "/${{ github.event.repository.name }}/" - # 유저/조직 페이지인 경우 (username.github.io) - # run: ./trunk build --release --public-url "/" + - name: SPA fallback for GitHub Pages + run: cp dist/index.html dist/404.html - name: Setup Pages uses: actions/configure-pages@v5 @@ -50,8 +84,8 @@ jobs: - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: './dist' + path: "./dist" - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 \ No newline at end of file + uses: actions/deploy-pages@v4 diff --git a/Cargo.lock b/Cargo.lock index b6871e5..0fb7aa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "beamterm-data" version = "0.7.0" @@ -21,7 +27,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9440270ceba7fccbd5aab22ffba8abafd13b63d30bc42c417b622fcc9643d7" dependencies = [ "compact_str 0.9.0", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -79,6 +85,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -133,6 +149,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + [[package]] name = "darling" version = "0.20.11" @@ -154,7 +179,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.110", ] [[package]] @@ -165,7 +190,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -177,6 +202,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "either" version = "1.15.0" @@ -189,6 +225,23 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" @@ -201,6 +254,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "funty" version = "2.0.0" @@ -271,6 +333,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -288,12 +361,114 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indoc" version = "2.0.7" @@ -313,7 +488,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -347,6 +522,12 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.28" @@ -377,6 +558,16 @@ dependencies = [ "adler2", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -404,12 +595,27 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -476,6 +682,55 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -515,7 +770,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -531,12 +786,36 @@ dependencies = [ "zmij", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -568,9 +847,15 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.110", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.110" @@ -582,6 +867,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "tap" version = "1.0.1" @@ -605,7 +912,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -629,6 +936,16 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tui_blog" version = "0.1.0" @@ -641,6 +958,8 @@ dependencies = [ "ratzilla", "serde", "serde_json", + "unicode-width 0.2.0", + "ureq", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -681,6 +1000,52 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasm-bindgen" version = "0.2.105" @@ -726,7 +1091,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.110", "wasm-bindgen-shared", ] @@ -749,6 +1114,103 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "wyz" version = "0.5.1" @@ -758,6 +1220,95 @@ dependencies = [ "tap", ] +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index f0dc540..c249add 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,26 @@ version = "0.1.0" authors = ["DocRAID "] license = "MIT" edition = "2021" +rust-version = "1.80" +default-run = "tui_blog" +description = "Personal technical blog rendered as a WASM TUI" + +[lib] +name = "tui_blog" +path = "src/lib.rs" + +[[bin]] +name = "tui_blog" +path = "src/main.rs" + +[[bin]] +name = "fetch_content" +path = "src/bin/fetch_content.rs" +required-features = ["fetch-bin"] + +[features] +default = [] +fetch-bin = ["dep:ureq"] [dependencies] ratatui = { version = "0.29.0", default-features = false } @@ -16,6 +36,8 @@ wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" futures = { version = "0.3", default-features = false, features = ["std"] } js-sys = "0.3" +unicode-width = "0.2" +ureq = { version = "2", optional = true } web-sys = { version = "0.3", features = [ "Request", "RequestInit", @@ -30,4 +52,12 @@ web-sys = { version = "0.3", features = [ "Navigator", "Clipboard", "WheelEvent", -] } \ No newline at end of file + "Element", + "HtmlElement", + "CssStyleDeclaration", + "DomRect", + "TouchEvent", + "Touch", + "TouchList", + "AddEventListenerOptions", +] } diff --git a/README.md b/README.md index e1219b4..7c41746 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,63 @@ # tui_blog -Personal technical blog site. Powered by Ratatui. +Personal technical blog rendered in the browser as a [Ratatui](https://ratatui.rs/) TUI via [Ratzilla](https://github.com/orhun/ratzilla). Hosted on GitHub Pages. Posts are read from public [Notion](https://www.notion.so/) pages listed in `notion.json`. + +Live: + +## Run locally + +Needs a Rust toolchain with the `wasm32-unknown-unknown` target and [Trunk](https://trunkrs.dev/) 0.21.x. -## Run & Deploy ```bash - trunk serve - trunk build --release +rustup target add wasm32-unknown-unknown +cargo run --features fetch-bin --bin fetch_content # snapshot Notion → snapshot.json + rss.xml +trunk serve ``` +`fetch_content` is optional for a first paint: if `snapshot.json` is empty the WASM app will scrape Notion in the browser once. Production builds always snapshot at deploy time so visitors do not depend on the unofficial Notion proxy. + +```bash +trunk build --release +``` + +## Content + +`notion.json` lists public Notion site URLs: + +| `role` | Meaning | +| --- | --- | +| `tags` | A page whose H2 / sub-header blocks are tags; nested pages (including inside toggles) become posts | +| `about` | Optional page whose body is shown on `/about` | + +Post titles may end with ` - YYYY.MM.DD` (dots, dashes, or slashes). That date is used for “recent posts” sorting and the list label. + +Rebuild the snapshot after editing Notion or `notion.json`. + +## Keyboard + +| Key | Action | +| --- | --- | +| `j` / `k` or arrows | Scroll, or move the blog list | +| `d` / `u` or PageDown / PageUp | Jump | +| `g` / `G` or Home / End | Top / bottom | +| Enter | Open the selected list row | +| Esc / `q` / Backspace | Back | +| `/` | Filter posts (on the blog list) | +| Tab / Shift+Tab | Cycle Intro / Blog / About | +| `1` `2` `3` | Intro / Blog / About | + +On a phone, drag to scroll. Tapping still follows hover/click targets. + +## Deploy + +Pushes to `main` run format, clippy, and tests, snapshot Notion, build with Trunk, copy `index.html` to `404.html` (so `/about` and `/blog/...` work on GitHub Pages), and publish. + ## License Copyright (c) DocRAID This project is licensed under the MIT license ([LICENSE] or ) +The bundled Fira Code Regular font is licensed under the SIL Open Font License 1.1. + [LICENSE]: ./LICENSE diff --git a/Trunk.toml b/Trunk.toml new file mode 100644 index 0000000..17037fd --- /dev/null +++ b/Trunk.toml @@ -0,0 +1,7 @@ +[build] +target = "index.html" +dist = "dist" +public_url = "/" + +[watch] +ignore = ["snapshot.json", "rss.xml", "dist", "target"] diff --git a/assets/fonts/FiraCode-Regular.woff2 b/assets/fonts/FiraCode-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f8b63fb0181781ce675391d1e6a3be35a039cb94 GIT binary patch literal 103240 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 z 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 + + >_ + diff --git a/index.html b/index.html index 0cf6dbc..1905a7d 100644 --- a/index.html +++ b/index.html @@ -2,40 +2,61 @@ + - + - tui_blog + + + + + + Dongju Lim — tui_blog + + + + - - - + diff --git a/rss.xml b/rss.xml new file mode 100644 index 0000000..8e2a158 --- /dev/null +++ b/rss.xml @@ -0,0 +1,14 @@ + + + + tui_blog + https://docraid.github.io/ + Dongju Lim's personal technical blog + + About this site - 2026.08.06 + https://docraid.github.io/blog/Linux/3beec5eb3d2280d8a0afe17efb72c2b4 + https://docraid.github.io/blog/Linux/3beec5eb3d2280d8a0afe17efb72c2b4 + Linux + + + diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..c38c8a8 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] +targets = ["wasm32-unknown-unknown"] diff --git a/snapshot.json b/snapshot.json new file mode 100644 index 0000000..c4d19b8 --- /dev/null +++ b/snapshot.json @@ -0,0 +1,41 @@ +{ + "saved_at": 1788041846938, + "sections": [ + { + "tag": "Linux", + "pages": [ + { + "title": "About this site - 2026.08.06", + "id": "3beec5eb3d2280d8a0afe17efb72c2b4", + "href": "/blog/Linux/3beec5eb3d2280d8a0afe17efb72c2b4" + } + ] + }, + { + "tag": "Rust", + "pages": [] + }, + { + "tag": "C++", + "pages": [] + }, + { + "tag": "DB", + "pages": [] + } + ], + "posts": { + "3beec5eb3d2280d8a0afe17efb72c2b4": [ + { + "Text": "created: 2026.08.26\ntag: RUST, WASM, TUI \n\nThis site’s " + }, + { + "Code": "int a;\nstd::cin>>a;" + }, + { + "Text": "\n" + } + ] + }, + "about": null +} \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index d6c778b..b974c51 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,12 +17,17 @@ pub struct App { scroll_post: RefCell>, content_height: RefCell, viewport_height: RefCell, + list_selected: RefCell>, + nav_items: RefCell>, + filter: RefCell, + filter_open: RefCell, + copied_until: RefCell, window: Window, } impl App { pub fn new(path: impl Into, window: Window) -> Self { - Self { + let app = Self { router: RefCell::new(Router::parse(path.into())), mouse: RefCell::new(MouseState::default()), hits: RefCell::new(HitMap::default()), @@ -30,8 +35,15 @@ impl App { scroll_post: RefCell::new(None), content_height: RefCell::new(0), viewport_height: RefCell::new(0), + list_selected: RefCell::new(None), + nav_items: RefCell::new(Vec::new()), + filter: RefCell::new(String::new()), + filter_open: RefCell::new(false), + copied_until: RefCell::new(0.0), window, - } + }; + app.sync_document_title(); + app } pub fn set_path(&self, path: impl Into) { @@ -40,11 +52,22 @@ impl App { if self.scroll_post.borrow().as_deref() != context.as_deref() { *self.scroll.borrow_mut() = 0; *self.scroll_post.borrow_mut() = context; + *self.list_selected.borrow_mut() = None; + self.filter.borrow_mut().clear(); + *self.filter_open.borrow_mut() = false; } *self.router.borrow_mut() = router; + self.sync_document_title(); } pub fn scroll_by(&self, delta: i32) { + if *self.filter_open.borrow() { + return; + } + if is_list(&self.router.borrow()) { + self.move_list(delta); + return; + } if !is_scrollable(&self.router.borrow()) { return; } @@ -57,27 +80,55 @@ impl App { } pub fn handle_key(&self, key_event: KeyEvent) { + if *self.filter_open.borrow() { + self.handle_filter_key(&key_event); + return; + } + match key_event.code { - KeyCode::Up => self.scroll_by(-1), - KeyCode::Down => self.scroll_by(1), - KeyCode::PageUp => self.scroll_by(-10), - KeyCode::PageDown => self.scroll_by(10), - KeyCode::Home => *self.scroll.borrow_mut() = 0, - KeyCode::End => { - let max = - (*self.content_height.borrow()).saturating_sub(*self.viewport_height.borrow()); - *self.scroll.borrow_mut() = max; + KeyCode::Char('/') + if is_list(&self.router.borrow()) && !key_event.ctrl && !key_event.alt => + { + *self.filter_open.borrow_mut() = true; } + KeyCode::Char('j') | KeyCode::Down => self.scroll_by(1), + KeyCode::Char('k') | KeyCode::Up => self.scroll_by(-1), + KeyCode::Char('d') | KeyCode::PageDown => self.scroll_by(10), + KeyCode::Char('u') | KeyCode::PageUp => self.scroll_by(-10), + KeyCode::Char('g') | KeyCode::Home => self.scroll_home(), + KeyCode::Char('G') | KeyCode::End => self.scroll_end(), + KeyCode::Char('1') => self.navigate(Route::Intro.path()), + KeyCode::Char('2') => self.navigate(Route::Blog.path()), + KeyCode::Char('3') => self.navigate(Route::About.path()), + KeyCode::Tab if key_event.shift => { + let next = self.router.borrow().route().prev(); + self.navigate(next.path()); + } + KeyCode::Tab => { + let next = self.router.borrow().route().next(); + self.navigate(next.path()); + } + KeyCode::Enter => self.activate_selection(), + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Backspace => self.go_back(), _ => {} } } pub fn render(&self, frame: &mut Frame<'_>) { + let hovering = self.paint(frame); + crate::mouse::sync_hover_cursor(hovering); + } + + fn paint(&self, frame: &mut Frame<'_>) -> bool { let router = self.router.borrow(); let mouse = self.mouse.borrow(); let mut hits = self.hits.borrow_mut(); hits.clear(); let mut metrics = (0_u16, 0_u16); + let mut nav_items = self.nav_items.borrow_mut(); + nav_items.clear(); + let filter = self.filter.borrow(); + let copied = js_sys::Date::now() < *self.copied_until.borrow(); let mut ctx = FrameCtx { router: &router, @@ -85,6 +136,11 @@ impl App { hits: &mut hits, scroll: *self.scroll.borrow(), scroll_metrics: Some(&mut metrics), + list_selected: *self.list_selected.borrow(), + nav_items: &mut nav_items, + filter: &filter, + filter_open: *self.filter_open.borrow(), + copied, }; ui::render(&mut ctx, frame); *self.content_height.borrow_mut() = metrics.0; @@ -93,10 +149,20 @@ impl App { if *self.scroll.borrow() > max { *self.scroll.borrow_mut() = max; } + let len = nav_items.len(); + if let Some(selected) = *self.list_selected.borrow() { + if len == 0 { + *self.list_selected.borrow_mut() = None; + } else if selected >= len { + *self.list_selected.borrow_mut() = Some(len - 1); + } + } + hits.hovering(mouse.pos()) } pub fn handle_mouse(&self, mouse_event: MouseEvent) { self.mouse.borrow_mut().update(&mouse_event); + self.sync_cursor(); if mouse_event.event == MouseEventKind::Pressed { let action = self .hits @@ -105,12 +171,98 @@ impl App { .cloned(); match action { Some(HitAction::Go(href)) => self.navigate(&href), - Some(HitAction::Copy(text)) => copy_to_clipboard(&text), + Some(HitAction::Copy(text)) => self.copy_to_clipboard(&text), None => {} } } } + fn handle_filter_key(&self, key_event: &KeyEvent) { + match key_event.code { + KeyCode::Esc => { + *self.filter_open.borrow_mut() = false; + self.filter.borrow_mut().clear(); + *self.list_selected.borrow_mut() = None; + } + KeyCode::Enter => { + *self.filter_open.borrow_mut() = false; + self.activate_selection(); + } + KeyCode::Backspace => { + self.filter.borrow_mut().pop(); + *self.list_selected.borrow_mut() = None; + } + KeyCode::Char(ch) if !key_event.ctrl && !key_event.alt => { + self.filter.borrow_mut().push(ch); + *self.list_selected.borrow_mut() = None; + } + _ => {} + } + } + + fn activate_selection(&self) { + let items = self.nav_items.borrow(); + if items.is_empty() { + return; + } + let index = self + .list_selected + .borrow() + .unwrap_or(0) + .min(items.len() - 1); + let href = items[index].clone(); + drop(items); + self.navigate(&href); + } + + fn go_back(&self) { + if *self.filter_open.borrow() { + *self.filter_open.borrow_mut() = false; + self.filter.borrow_mut().clear(); + return; + } + if let Some(href) = self.router.borrow().parent_href() { + self.navigate(&href); + } + } + + fn scroll_home(&self) { + if is_list(&self.router.borrow()) { + if !self.nav_items.borrow().is_empty() { + *self.list_selected.borrow_mut() = Some(0); + } + return; + } + if is_scrollable(&self.router.borrow()) { + *self.scroll.borrow_mut() = 0; + } + } + + fn scroll_end(&self) { + if is_list(&self.router.borrow()) { + let len = self.nav_items.borrow().len(); + if len > 0 { + *self.list_selected.borrow_mut() = Some(len - 1); + } + return; + } + if is_scrollable(&self.router.borrow()) { + let max = + (*self.content_height.borrow()).saturating_sub(*self.viewport_height.borrow()); + *self.scroll.borrow_mut() = max; + } + } + + fn move_list(&self, delta: i32) { + let len = self.nav_items.borrow().len(); + if len == 0 { + return; + } + let current = self.list_selected.borrow().unwrap_or(0) as i32; + let next = (current + delta).clamp(0, (len - 1) as i32) as usize; + *self.list_selected.borrow_mut() = Some(next); + } + fn navigate(&self, href: &str) { if href.starts_with("https://") || href.starts_with("http://") { if let Err(err) = self.window.location().set_href(href) { @@ -126,17 +278,53 @@ impl App { } self.set_path(href); } + + fn copy_to_clipboard(&self, text: &str) { + *self.copied_until.borrow_mut() = js_sys::Date::now() + 1_500.0; + let clipboard = self.window.navigator().clipboard(); + let promise = clipboard.write_text(text); + wasm_bindgen_futures::spawn_local(async move { + if let Err(err) = wasm_bindgen_futures::JsFuture::from(promise).await { + log::error!("clipboard write failed: {err:?}"); + } + }); + } + + fn sync_cursor(&self) { + let hovering = self.hits.borrow().hovering(self.mouse.borrow().pos()); + crate::mouse::sync_hover_cursor(hovering); + } + + fn sync_document_title(&self) { + let title = self.router.borrow().document_title(); + if let Some(document) = self.window.document() { + document.set_title(&title); + } + } } fn is_scrollable(router: &Router) -> bool { - matches!(router.route(), Route::Intro) || router.post().is_some() + matches!( + router.route(), + Route::Intro | Route::About | Route::NotFound + ) || router.post().is_some() +} + +fn is_list(router: &Router) -> bool { + router.route() == Route::Blog && router.post().is_none() } fn scroll_context(router: &Router) -> Option { if matches!(router.route(), Route::Intro) { Some("intro".to_string()) - } else { + } else if matches!(router.route(), Route::About) { + Some("about".to_string()) + } else if router.post().is_some() { router.post().map(str::to_string) + } else if router.route() == Route::Blog { + Some(format!("blog:{}", router.slug().unwrap_or(""))) + } else { + Some(router.route().path().to_string()) } } @@ -144,18 +332,3 @@ fn clamp_scroll(offset: i32, content_height: u16, viewport_height: u16) -> u16 { let max = i32::from(content_height.saturating_sub(viewport_height)); offset.clamp(0, max) as u16 } - -fn copy_to_clipboard(text: &str) { - let Some(window) = web_sys::window() else { - return; - }; - let clipboard = window.navigator().clipboard(); - let promise = clipboard.write_text(text); - wasm_bindgen_futures::spawn_local(async move { - if let Err(err) = wasm_bindgen_futures::JsFuture::from(promise).await { - log::error!("clipboard write failed: {err:?}"); - } else { - log::info!("copied code block to clipboard"); - } - }); -} diff --git a/src/bin/fetch_content.rs b/src/bin/fetch_content.rs new file mode 100644 index 0000000..4fffe36 --- /dev/null +++ b/src/bin/fetch_content.rs @@ -0,0 +1,216 @@ +//! Native snapshot builder. Fetches public Notion pages and writes +//! `snapshot.json` plus `rss.xml` for the WASM app to embed. + +use serde_json::Value; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use tui_blog::module::config::{dashed_id, page_id_from_url, NotionConfig, EMBEDDED_CONFIG}; +use tui_blog::module::notion::{ + block_map_owned, extract_catalog_from_blocks, extract_segments, merge_block_maps, + missing_content_ids, parse_block_map, segments_plain_text, TagSection, MAX_CHILD_FETCHES, +}; +use tui_blog::module::snapshot::Snapshot; + +const NOTION_LIVE_API: &str = "https://notion-api.splitbee.io/v1/page/"; +const DEFAULT_SITE: &str = "https://docraid.github.io"; + +fn main() { + if let Err(err) = run() { + eprintln!("fetch_content: {err}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config = NotionConfig::parse(EMBEDDED_CONFIG)?; + let mut snapshot = Snapshot { + saved_at: unix_ms(), + sections: Vec::new(), + posts: HashMap::new(), + about: None, + }; + + let mut errors = Vec::new(); + for page in config.tag_pages() { + match load_tag_page(&page.url) { + Ok(sections) => snapshot.sections.extend(sections), + Err(err) => { + eprintln!("tag page {}: {err}", page.url); + errors.push(err); + } + } + } + snapshot.sections = tui_blog::module::notion::dedupe_sections(snapshot.sections); + + for page in config.about_pages() { + match load_about(&page.url) { + Ok(text) => { + snapshot.about = Some(text); + break; + } + Err(err) => eprintln!("about page {}: {err}", page.url), + } + } + + if snapshot.sections.is_empty() && snapshot.about.is_none() && !errors.is_empty() { + return Err(errors.remove(0)); + } + + for section in &snapshot.sections { + for page in §ion.pages { + match load_post(&page.id) { + Ok(segments) => { + snapshot.posts.insert(page.id.replace('-', ""), segments); + } + Err(err) => eprintln!("post {}: {err}", page.id), + } + } + } + + let snapshot_path = root.join("snapshot.json"); + fs::write(&snapshot_path, snapshot.to_json()?).map_err(|err| err.to_string())?; + println!( + "wrote {} ({} tags, {} posts)", + snapshot_path.display(), + snapshot.sections.len(), + snapshot.posts.len() + ); + + let rss_path = root.join("rss.xml"); + fs::write(&rss_path, render_rss(&snapshot)).map_err(|err| err.to_string())?; + println!("wrote {}", rss_path.display()); + Ok(()) +} + +fn load_tag_page(url: &str) -> Result, String> { + let page_id = page_id_from_url(url) + .ok_or_else(|| format!("could not parse Notion page id from {url}"))?; + let blocks = load_blocks(&page_id)?; + Ok(extract_catalog_from_blocks( + &Value::Object(blocks), + &page_id, + )) +} + +fn load_about(url: &str) -> Result { + let page_id = page_id_from_url(url) + .ok_or_else(|| format!("could not parse Notion page id from {url}"))?; + let segments = load_post(&page_id)?; + Ok(segments_plain_text(&segments)) +} + +fn load_post(page_id: &str) -> Result, String> { + let root_id = dashed_id(page_id); + let blocks = load_blocks(&root_id)?; + let segments = extract_segments(&Value::Object(blocks), &root_id); + if segments.is_empty() { + Ok(vec![tui_blog::module::notion::PostSegment::Text( + "(no content)".to_string(), + )]) + } else { + Ok(segments) + } +} + +fn load_blocks(page_id: &str) -> Result, String> { + let json = fetch_page_json(page_id)?; + let mut blocks = parse_block_map(&json).or_else(|_| { + let root: Value = + serde_json::from_str(&json).map_err(|err| format!("invalid notion json: {err}"))?; + block_map_owned(root).ok_or_else(|| "notion response is missing a block map".to_string()) + })?; + resolve_missing(&mut blocks, page_id)?; + Ok(blocks) +} + +fn resolve_missing( + blocks: &mut serde_json::Map, + root_id: &str, +) -> Result<(), String> { + let mut fetched = 0; + loop { + if fetched >= MAX_CHILD_FETCHES { + let leftover = missing_content_ids(blocks, root_id).len(); + if leftover > 0 { + eprintln!( + "stopped resolving Notion children after {MAX_CHILD_FETCHES} fetches ({leftover} still missing)" + ); + } + break; + } + let mut missing = missing_content_ids(blocks, root_id); + if missing.is_empty() { + break; + } + missing.truncate(MAX_CHILD_FETCHES - fetched); + for id in missing { + fetched += 1; + match fetch_page_json(&id).and_then(|json| parse_block_map(&json)) { + Ok(extra) => merge_block_maps(blocks, extra), + Err(err) => { + eprintln!("skipping nested Notion block {id}: {err}"); + blocks.insert(id, Value::Null); + } + } + } + } + Ok(()) +} + +fn fetch_page_json(page_id: &str) -> Result { + let compact = page_id.replace('-', ""); + let url = format!("{NOTION_LIVE_API}{compact}"); + let response = ureq::get(&url) + .set("User-Agent", "tui_blog-fetch_content/0.1") + .call() + .map_err(|err| format!("{url}: {err}"))?; + if response.status() >= 400 { + return Err(format!("http {} from {url}", response.status())); + } + response.into_string().map_err(|err| err.to_string()) +} + +fn render_rss(snapshot: &Snapshot) -> String { + let site = std::env::var("SITE_URL").unwrap_or_else(|_| DEFAULT_SITE.to_string()); + let site = site.trim_end_matches('/'); + let mut items = String::new(); + for section in &snapshot.sections { + for page in §ion.pages { + let title = xml_escape(&page.title); + let link = format!("{site}{}", page.href); + items.push_str(&format!( + " \n {title}\n {}\n {}\n {}\n \n", + xml_escape(&link), + xml_escape(&link), + xml_escape(§ion.tag) + )); + } + } + format!( + r#" + + + tui_blog + {site}/ + Dongju Lim's personal technical blog +{items} + +"# + ) +} + +fn xml_escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} diff --git a/src/content.rs b/src/content.rs index 653e52e..eed109d 100644 --- a/src/content.rs +++ b/src/content.rs @@ -1,6 +1,10 @@ -use crate::module::scraper::{self, ContentPage, PostSegment}; +use crate::module::catalog; +use crate::module::notion::{ContentPage, PostSegment}; +use crate::module::scraper; use std::collections::HashSet; +pub use crate::module::catalog::CatalogStatus; + /// Sidebar tags as a string array. /// /// Filled in the browser on each page load from every `notion.json` @@ -104,12 +108,27 @@ pub fn ensure_post(page_id: &str) { scraper::request_post(page_id); } -/// Scraped post body, if the fetch has finished. +/// Scraped post body, if the fetch has finished successfully. pub fn post_segments(page_id: &str) -> Option> { scraper::current_post_segments(page_id) } -/// Start a browser fetch of the Notion catalog. Call once per site load. +/// `None` = still loading, `Some(Ok)` = body, `Some(Err)` = last failure. +pub fn post_state(page_id: &str) -> Option, String>> { + scraper::current_post_state(page_id) +} + +pub fn catalog_status() -> CatalogStatus { + catalog::bootstrap(); + catalog::status() +} + +pub fn about_text() -> Option { + catalog::bootstrap(); + catalog::about() +} + +/// Load the snapshot, then scrape Notion in the browser only if it is empty. pub fn refresh() { scraper::start_fetch(); } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..acb2fb5 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,156 @@ +//! WASM TUI blog rendered with Ratatui and Ratzilla. + +pub mod app; +pub mod content; +pub mod module; +pub mod mouse; +pub mod router; +pub mod theme; +pub mod ui; +pub mod width; + +use std::io; +use std::rc::Rc; + +use ratatui::Terminal; +use ratzilla::web_sys::window; +use ratzilla::{DomBackend, WebRenderer}; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::JsCast; +use web_sys::AddEventListenerOptions; + +use crate::app::App; +use crate::mouse::refresh_cell_metrics; + +pub fn start() -> io::Result<()> { + console_log::init().expect("console logger"); + + let backend = DomBackend::new()?; + let terminal = Terminal::new(backend)?; + + let window = window().expect("window"); + let path = window.location().pathname().expect("pathname"); + let state = Rc::new(App::new(path, window.clone())); + crate::content::refresh(); + refresh_cell_metrics(); + + let pop_state = Rc::clone(&state); + let on_pop = Closure::::new(move |_event: web_sys::Event| { + if let Some(path) = current_path() { + pop_state.set_path(path); + } + }); + if let Err(err) = + window.add_event_listener_with_callback("popstate", on_pop.as_ref().unchecked_ref()) + { + log::error!("failed to listen for popstate: {err:?}"); + } + on_pop.forget(); + + let mouse_state = Rc::clone(&state); + terminal.on_mouse_event(move |event| { + refresh_cell_metrics(); + mouse_state.handle_mouse(event); + }); + + let key_state = Rc::clone(&state); + terminal.on_key_event(move |event| { + key_state.handle_key(event); + }); + + let passive_false = AddEventListenerOptions::new(); + passive_false.set_passive(false); + + let wheel_state = Rc::clone(&state); + let on_wheel = + Closure::::new(move |event: web_sys::WheelEvent| { + event.prevent_default(); + let delta = event.delta_y(); + let mut lines = (delta / 40.0).round() as i32; + if lines == 0 { + lines = if delta > 0.0 { 1 } else { -1 }; + } + wheel_state.scroll_by(lines); + }); + if let Err(err) = window.add_event_listener_with_callback_and_add_event_listener_options( + "wheel", + on_wheel.as_ref().unchecked_ref(), + &passive_false, + ) { + log::error!("failed to listen for wheel: {err:?}"); + } + on_wheel.forget(); + + let touch_state = Rc::clone(&state); + let last_y = Rc::new(std::cell::Cell::new(None::)); + let last_y_start = Rc::clone(&last_y); + let on_touch_start = + Closure::::new(move |event: web_sys::TouchEvent| { + if let Some(touch) = event.touches().item(0) { + last_y_start.set(Some(touch.client_y() as f64)); + } + }); + let last_y_move = Rc::clone(&last_y); + let on_touch_move = + Closure::::new(move |event: web_sys::TouchEvent| { + let Some(touch) = event.touches().item(0) else { + return; + }; + let y = touch.client_y() as f64; + if let Some(prev) = last_y_move.get() { + let delta_px = prev - y; + let cell_h = f64::from(crate::mouse::cell_height_px()).max(1.0); + let lines = (delta_px / cell_h).round() as i32; + if lines != 0 { + event.prevent_default(); + touch_state.scroll_by(lines); + last_y_move.set(Some(y)); + } + } else { + last_y_move.set(Some(y)); + } + }); + let last_y_end = Rc::clone(&last_y); + let on_touch_end = Closure::::new(move |_event| { + last_y_end.set(None); + }); + for (name, closure) in [ + ("touchstart", &on_touch_start), + ("touchmove", &on_touch_move), + ("touchend", &on_touch_end), + ("touchcancel", &on_touch_end), + ] { + if let Err(err) = window.add_event_listener_with_callback_and_add_event_listener_options( + name, + closure.as_ref().unchecked_ref(), + &passive_false, + ) { + log::error!("failed to listen for {name}: {err:?}"); + } + } + on_touch_start.forget(); + on_touch_move.forget(); + on_touch_end.forget(); + + let on_resize = Closure::::new(move |_event: web_sys::Event| { + refresh_cell_metrics(); + }); + if let Err(err) = + window.add_event_listener_with_callback("resize", on_resize.as_ref().unchecked_ref()) + { + log::error!("failed to listen for resize: {err:?}"); + } + on_resize.forget(); + + let render_state = Rc::clone(&state); + terminal.draw_web(move |frame| { + refresh_cell_metrics(); + render_state.render(frame); + }); + + Ok(()) +} + +fn current_path() -> Option { + window()?.location().pathname().ok() +} diff --git a/src/main.rs b/src/main.rs index 001d4a4..97900cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,85 +1,3 @@ -//! WASM TUI blog rendered with Ratatui and Ratzilla. - -use std::io; -use std::rc::Rc; - -use ratatui::Terminal; -use ratzilla::web_sys::window; -use ratzilla::{DomBackend, WebRenderer}; -use wasm_bindgen::closure::Closure; -use wasm_bindgen::JsCast; - -use crate::app::App; - -mod app; -mod content; -mod module; -mod mouse; -mod router; -mod theme; -mod ui; - -fn main() -> io::Result<()> { - console_log::init().expect("console logger"); - - let backend = DomBackend::new()?; - let terminal = Terminal::new(backend)?; - - let window = window().expect("window"); - let path = window.location().pathname().expect("pathname"); - let state = Rc::new(App::new(path, window.clone())); - // Static host only: scrape Notion from the browser, not a server. - crate::content::refresh(); - - let pop_state = Rc::clone(&state); - let on_pop = Closure::::new(move |_event: web_sys::Event| { - if let Some(path) = current_path() { - pop_state.set_path(path); - } - }); - if let Err(err) = - window.add_event_listener_with_callback("popstate", on_pop.as_ref().unchecked_ref()) - { - log::error!("failed to listen for popstate: {err:?}"); - } - on_pop.forget(); - - let mouse_state = Rc::clone(&state); - terminal.on_mouse_event(move |event| { - mouse_state.handle_mouse(event); - }); - - let key_state = Rc::clone(&state); - terminal.on_key_event(move |event| { - key_state.handle_key(event); - }); - - let wheel_state = Rc::clone(&state); - let on_wheel = - Closure::::new(move |event: web_sys::WheelEvent| { - event.prevent_default(); - let delta = event.delta_y(); - let mut lines = (delta / 40.0).round() as i32; - if lines == 0 { - lines = if delta > 0.0 { 1 } else { -1 }; - } - wheel_state.scroll_by(lines); - }); - if let Err(err) = - window.add_event_listener_with_callback("wheel", on_wheel.as_ref().unchecked_ref()) - { - log::error!("failed to listen for wheel: {err:?}"); - } - on_wheel.forget(); - - let render_state = Rc::clone(&state); - terminal.draw_web(move |frame| { - render_state.render(frame); - }); - - Ok(()) -} - -fn current_path() -> Option { - window()?.location().pathname().ok() +fn main() -> std::io::Result<()> { + tui_blog::start() } diff --git a/src/module/catalog.rs b/src/module/catalog.rs new file mode 100644 index 0000000..8283bd6 --- /dev/null +++ b/src/module/catalog.rs @@ -0,0 +1,183 @@ +use super::notion::{ContentPage, PostSegment, TagSection}; +use super::snapshot::{Snapshot, EMBEDDED_SNAPSHOT}; +use std::cell::RefCell; +use std::collections::HashMap; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CatalogStatus { + Loading, + Ready, + Error(String), +} + +#[derive(Clone, Debug)] +enum PostSlot { + Ready(Vec), + Failed { message: String, at_ms: u64 }, +} + +thread_local! { + static CATALOG: RefCell> = const { RefCell::new(Vec::new()) }; + static POSTS: RefCell> = RefCell::new(HashMap::new()); + static ABOUT: RefCell> = const { RefCell::new(None) }; + static STATUS: RefCell = const { RefCell::new(CatalogStatus::Loading) }; + static BOOTSTRAPPED: RefCell = const { RefCell::new(false) }; +} + +pub fn bootstrap() { + if BOOTSTRAPPED.with(|slot| { + if *slot.borrow() { + true + } else { + *slot.borrow_mut() = true; + false + } + }) { + return; + } + match Snapshot::parse(EMBEDDED_SNAPSHOT) { + Ok(snapshot) if !snapshot.sections.is_empty() => { + apply_snapshot(snapshot); + } + Ok(_) => set_status(CatalogStatus::Loading), + Err(err) => { + log::error!("embedded snapshot: {err}"); + set_status(CatalogStatus::Loading); + } + } +} + +pub fn apply_snapshot(snapshot: Snapshot) { + CATALOG.with(|slot| *slot.borrow_mut() = snapshot.sections); + ABOUT.with(|slot| *slot.borrow_mut() = snapshot.about); + POSTS.with(|slot| { + let mut posts = slot.borrow_mut(); + for (id, segments) in snapshot.posts { + posts.insert(compact_id(&id), PostSlot::Ready(segments)); + } + }); + set_status(CatalogStatus::Ready); +} + +pub fn apply_catalog(catalog: Vec) { + CATALOG.with(|slot| *slot.borrow_mut() = catalog); + set_status(CatalogStatus::Ready); +} + +pub fn set_status(status: CatalogStatus) { + STATUS.with(|slot| *slot.borrow_mut() = status); +} + +pub fn status() -> CatalogStatus { + STATUS.with(|slot| slot.borrow().clone()) +} + +pub fn about() -> Option { + ABOUT.with(|slot| slot.borrow().clone()) +} + +pub fn set_about(text: Option) { + ABOUT.with(|slot| *slot.borrow_mut() = text); +} + +pub fn catalog_is_empty() -> bool { + CATALOG.with(|catalog| catalog.borrow().is_empty()) +} + +pub fn current_tags() -> Vec { + CATALOG.with(|catalog| { + catalog + .borrow() + .iter() + .map(|section| section.tag.clone()) + .collect() + }) +} + +pub fn current_posts(slug: Option<&str>) -> Vec { + CATALOG.with(|catalog| { + let catalog = catalog.borrow(); + match slug { + None => catalog + .iter() + .flat_map(|section| { + section.pages.iter().map(|page| ContentPage { + title: format!("{} — {}", section.tag, page.title), + id: page.id.clone(), + href: page.href.clone(), + }) + }) + .collect(), + Some(slug) => catalog + .iter() + .filter(|section| super::notion::tag_slug(§ion.tag) == slug) + .flat_map(|section| section.pages.iter().cloned()) + .collect(), + } + }) +} + +pub fn current_tagged_posts() -> Vec<(String, ContentPage)> { + CATALOG.with(|catalog| { + catalog + .borrow() + .iter() + .flat_map(|section| { + section + .pages + .iter() + .cloned() + .map(|page| (section.tag.clone(), page)) + }) + .collect() + }) +} + +pub fn post_ready(page_id: &str) -> bool { + let id = compact_id(page_id); + POSTS.with(|posts| matches!(posts.borrow().get(&id), Some(PostSlot::Ready(_)))) +} + +pub fn failed_at(page_id: &str) -> Option<(String, u64)> { + let id = compact_id(page_id); + POSTS.with(|posts| match posts.borrow().get(&id) { + Some(PostSlot::Failed { message, at_ms }) => Some((message.clone(), *at_ms)), + _ => None, + }) +} + +pub fn insert_post(page_id: &str, segments: Vec) { + let id = compact_id(page_id); + POSTS.with(|posts| { + posts.borrow_mut().insert(id, PostSlot::Ready(segments)); + }); +} + +pub fn insert_post_failure(page_id: &str, message: String, at_ms: u64) { + let id = compact_id(page_id); + POSTS.with(|posts| { + posts + .borrow_mut() + .insert(id, PostSlot::Failed { message, at_ms }); + }); +} + +pub fn current_post_state(page_id: &str) -> Option, String>> { + let id = compact_id(page_id); + POSTS.with(|posts| match posts.borrow().get(&id) { + Some(PostSlot::Ready(segments)) => Some(Ok(segments.clone())), + Some(PostSlot::Failed { message, .. }) => Some(Err(message.clone())), + None => None, + }) +} + +pub fn compact_id(page_id: &str) -> String { + page_id.replace('-', "") +} + +#[cfg(test)] +pub fn set_catalog_for_tests(catalog: Vec) { + BOOTSTRAPPED.with(|slot| *slot.borrow_mut() = true); + CATALOG.with(|slot| *slot.borrow_mut() = catalog); + set_status(CatalogStatus::Ready); +} diff --git a/src/module/config.rs b/src/module/config.rs index 958cabd..ee37be7 100644 --- a/src/module/config.rs +++ b/src/module/config.rs @@ -3,8 +3,7 @@ use serde::Deserialize; /// Embedded copy used when the same-origin `notion.json` cannot be fetched. pub const EMBEDDED_CONFIG: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/notion.json")); -/// Root-absolute so `/blog/linux` does not resolve this to `/blog/notion.json`. -#[allow(dead_code)] +/// Root-absolute path of the compile-time Notion source list (copied into dist). pub const CONFIG_PATH: &str = "/notion.json"; #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] @@ -27,15 +26,20 @@ impl NotionConfig { pub fn tag_pages(&self) -> impl Iterator { self.pages.iter().filter(|page| page.role == "tags") } + + pub fn about_pages(&self) -> impl Iterator { + self.pages.iter().filter(|page| page.role == "about") + } } /// Pull the Notion page id off the end of a public site URL. /// /// The last path segment is either a bare 32-digit id or `Slug-32hex`. /// Only that trailing id is used — hex letters in the slug (`Open`, -/// `Study`, …) must not be mixed in. +/// `Study`, …) must not be mixed in. Query strings and fragments are ignored. pub fn page_id_from_url(url: &str) -> Option { - let last = url.split(['/', '?']).rfind(|segment| !segment.is_empty())?; + let url = url.split(['?', '#']).next().unwrap_or(url); + let last = url.split('/').rfind(|segment| !segment.is_empty())?; if last.len() >= 36 { let tail = &last[last.len() - 36..]; @@ -133,6 +137,13 @@ mod tests { page_id_from_url(url).as_deref(), Some("158ec5eb-3d22-807e-a341-c9e5604113c3") ); + assert_eq!( + page_id_from_url( + "https://limdongju.notion.site/Linux-158ec5eb3d22807ea341c9e5604113c3#hash" + ) + .as_deref(), + Some("158ec5eb-3d22-807e-a341-c9e5604113c3") + ); } #[test] diff --git a/src/module/mod.rs b/src/module/mod.rs index cf06d55..147ae60 100644 --- a/src/module/mod.rs +++ b/src/module/mod.rs @@ -1,2 +1,5 @@ +pub mod catalog; pub mod config; +pub mod notion; pub mod scraper; +pub mod snapshot; diff --git a/src/module/notion.rs b/src/module/notion.rs new file mode 100644 index 0000000..491829d --- /dev/null +++ b/src/module/notion.rs @@ -0,0 +1,671 @@ +//! Notion unofficial-API block map parsing. + +use super::config::dashed_id; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::HashSet; + +const H2_TYPES: &[&str] = &["header", "sub_header"]; +pub const CONTAINER_TYPES: &[&str] = &[ + "toggle", + "column_list", + "column", + "bulleted_list", + "numbered_list", + "to_do", + "transclusion_container", +]; + +pub const MAX_CHILD_FETCHES: usize = 256; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContentPage { + pub title: String, + pub id: String, + pub href: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TagSection { + pub tag: String, + pub pages: Vec, +} + +/// A run of post body content. Whitespace inside each string is kept as-is. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PostSegment { + Text(String), + Code(String), +} + +pub fn tag_slug(tag: &str) -> String { + tag.replace(['/', ' '], "-") +} + +pub fn same_page_id(left: &str, right: &str) -> bool { + left.replace('-', "") == right.replace('-', "") +} + +pub fn extract_catalog_from_blocks(root: &Value, page_id: &str) -> Vec { + let blocks = block_map(root); + if !blocks.is_object() { + log::error!("notion response is missing a block map"); + return Vec::new(); + } + + let mut sections = Vec::new(); + for child_id in page_child_ids(blocks, page_id) { + let value = block_value(&blocks[&child_id]); + let Some(kind) = value["type"].as_str() else { + continue; + }; + if !H2_TYPES.contains(&kind) { + continue; + } + let tag = rich_text(&value["properties"]["title"]); + if tag.is_empty() { + continue; + } + let mut pages = Vec::new(); + collect_pages(blocks, &child_content_ids(value), page_id, &tag, &mut pages); + sections.push(TagSection { tag, pages }); + } + sections +} + +pub fn extract_segments(root: &Value, page_id: &str) -> Vec { + let blocks = block_map(root); + if !blocks.is_object() { + return Vec::new(); + } + let mut segments = Vec::new(); + collect_segments( + blocks, + &page_child_ids(blocks, page_id), + page_id, + &mut segments, + ); + merge_text_segments(segments) +} + +pub fn segments_plain_text(segments: &[PostSegment]) -> String { + let mut out = String::new(); + for segment in segments { + if !out.is_empty() { + out.push('\n'); + } + match segment { + PostSegment::Text(text) | PostSegment::Code(text) => out.push_str(text), + } + } + out +} + +pub fn merge_block_maps(dest: &mut Map, extra: Map) { + for (key, value) in extra { + dest.entry(key).or_insert(value); + } +} + +pub fn missing_content_ids(blocks: &Map, root_id: &str) -> Vec { + let root = Value::Object(blocks.clone()); + let mut missing = Vec::new(); + let mut seen = HashSet::new(); + let mut stack = page_child_ids(&root, root_id); + while let Some(id) = stack.pop() { + if !seen.insert(id.clone()) { + continue; + } + match blocks.get(&id) { + None => missing.push(id), + Some(Value::Null) => {} + Some(block) => { + let value = block_value(block); + let kind = value["type"].as_str().unwrap_or(""); + if kind == "page" && !same_page_id(&id, root_id) { + continue; + } + if H2_TYPES.contains(&kind) || kind == "toggle" || CONTAINER_TYPES.contains(&kind) { + stack.extend(child_content_ids(value)); + } + } + } + } + missing +} + +pub fn block_map_owned(root: Value) -> Option> { + match root { + Value::Object(map) => { + if let Some(Value::Object(record_map)) = map.get("recordMap").cloned() { + if let Some(Value::Object(block)) = record_map.get("block").cloned() { + return Some(block); + } + } + Some(map) + } + _ => None, + } +} + +pub fn parse_block_map(json: &str) -> Result, String> { + let root: Value = + serde_json::from_str(json).map_err(|err| format!("invalid notion json: {err}"))?; + block_map_owned(root).ok_or_else(|| "notion response is missing a block map".to_string()) +} + +pub fn dedupe_sections(sections: Vec) -> Vec { + let mut seen_tags = HashSet::new(); + let mut out: Vec = Vec::new(); + for mut section in sections { + if !seen_tags.insert(section.tag.clone()) { + if let Some(existing) = out.iter_mut().find(|item| item.tag == section.tag) { + existing.pages.append(&mut section.pages); + } + continue; + } + out.push(section); + } + out +} + +fn collect_pages( + blocks: &Value, + ids: &[String], + root_id: &str, + tag: &str, + out: &mut Vec, +) { + for id in ids { + if id == root_id { + continue; + } + let value = block_value(&blocks[id]); + let Some(kind) = value["type"].as_str() else { + continue; + }; + if kind == "page" { + let title = rich_text(&value["properties"]["title"]); + if !title.is_empty() { + let compact = id.replace('-', ""); + out.push(ContentPage { + title, + id: compact.clone(), + href: format!("/blog/{}/{}", tag_slug(tag), compact), + }); + } + continue; + } + if kind == "toggle" || CONTAINER_TYPES.contains(&kind) || H2_TYPES.contains(&kind) { + collect_pages(blocks, &child_content_ids(value), root_id, tag, out); + } + } +} + +fn collect_segments(blocks: &Value, ids: &[String], root_id: &str, out: &mut Vec) { + let mut numbered = 0_u32; + for id in ids { + let value = block_value(&blocks[id]); + let Some(kind) = value["type"].as_str() else { + continue; + }; + if kind == "page" && !same_page_id(id, root_id) { + continue; + } + if kind == "numbered_list" { + numbered += 1; + } else { + numbered = 0; + } + if kind == "code" { + out.push(PostSegment::Code(block_source_text(value))); + } else if let Some(text) = block_display_text(kind, value, numbered) { + out.push(PostSegment::Text(text)); + } + if kind == "toggle" || CONTAINER_TYPES.contains(&kind) || H2_TYPES.contains(&kind) { + collect_segments(blocks, &child_content_ids(value), root_id, out); + } + } +} + +fn block_source_text(value: &Value) -> String { + block_raw_text(&value["properties"]["title"]) +} + +fn block_display_text(kind: &str, value: &Value, numbered: u32) -> Option { + let text = block_raw_text(&value["properties"]["title"]); + match kind { + "text" | "quote" | "callout" | "header" | "sub_header" | "sub_sub_header" + | "bulleted_list" | "toggle" => Some(text), + "numbered_list" => { + if numbered == 0 { + Some(text) + } else { + Some(format!("{numbered}. {text}")) + } + } + "to_do" => { + let checked = is_checked(value); + let mark = if checked { "[x]" } else { "[ ]" }; + Some(format!("{mark} {text}")) + } + "divider" => Some("────────".to_string()), + "image" => Some(format!("[image] {}", first_link(value)).trim().to_string()), + "bookmark" | "link_preview" | "embed" => { + let link = first_link(value); + if link.is_empty() && text.is_empty() { + Some(format!("[{kind}]")) + } else if text.is_empty() { + Some(link) + } else if link.is_empty() { + Some(text) + } else { + Some(format!("{text} ({link})")) + } + } + "table" => Some("[table]".to_string()), + "table_row" => { + let cells = table_row_text(value); + if cells.is_empty() { + None + } else { + Some(cells) + } + } + _ => None, + } +} + +fn is_checked(value: &Value) -> bool { + value["properties"]["checked"] + .as_array() + .and_then(|parts| parts.first()) + .and_then(|part| part.get(0)) + .and_then(Value::as_str) + .is_some_and(|flag| flag.eq_ignore_ascii_case("Yes") || flag == "true") +} + +fn first_link(value: &Value) -> String { + for key in ["source", "link", "url", "title"] { + let text = block_raw_text(&value["properties"][key]); + if !text.is_empty() { + return text; + } + } + String::new() +} + +fn table_row_text(value: &Value) -> String { + let Some(cells) = value["properties"].as_object() else { + return String::new(); + }; + let mut cols: Vec<(String, String)> = cells + .iter() + .map(|(key, val)| (key.clone(), block_raw_text(val))) + .collect(); + cols.sort_by(|a, b| a.0.cmp(&b.0)); + cols.into_iter() + .map(|(_, text)| text) + .filter(|text| !text.is_empty()) + .collect::>() + .join(" | ") +} + +fn merge_text_segments(segments: Vec) -> Vec { + let mut out = Vec::new(); + for segment in segments { + match (out.last_mut(), segment) { + (Some(PostSegment::Text(existing)), PostSegment::Text(next)) => { + existing.push('\n'); + existing.push_str(&next); + } + (_, other) => out.push(other), + } + } + out +} + +fn child_content_ids(value: &Value) -> Vec { + value["content"] + .as_array() + .into_iter() + .flatten() + .filter_map(|id| id.as_str().map(str::to_owned)) + .collect() +} + +fn block_map(root: &Value) -> &Value { + let nested = &root["recordMap"]["block"]; + if nested.is_object() { + nested + } else { + root + } +} + +fn resolve_block_key(blocks: &Value, page_id: &str) -> String { + if blocks.get(page_id).is_some() { + return page_id.to_string(); + } + let dashed = dashed_id(page_id); + if blocks.get(&dashed).is_some() { + return dashed; + } + let compact = page_id.replace('-', ""); + if let Some(key) = blocks.as_object().and_then(|map| { + map.keys() + .find(|key| key.replace('-', "") == compact) + .cloned() + }) { + return key; + } + dashed +} + +fn page_child_ids(blocks: &Value, page_id: &str) -> Vec { + let key = resolve_block_key(blocks, page_id); + let page = block_value(&blocks[&key]); + if let Some(ids) = page["content"].as_array() { + return ids + .iter() + .filter_map(|id| id.as_str().map(str::to_owned)) + .collect(); + } + + let owned: Vec = blocks + .as_object() + .into_iter() + .flatten() + .filter_map(|(id, block)| { + let value = block_value(block); + let parent = value["parent_id"].as_str()?; + same_page_id(parent, &key).then(|| id.clone()) + }) + .collect(); + if !owned.is_empty() { + return owned; + } + + blocks + .as_object() + .into_iter() + .flatten() + .filter_map(|(id, block)| { + let value = block_value(block); + let kind = value["type"].as_str()?; + (H2_TYPES.contains(&kind) && !same_page_id(id, &key)).then(|| id.clone()) + }) + .collect() +} + +fn block_value(block: &Value) -> &Value { + if block.is_null() { + return block; + } + let value = &block["value"]; + if value.get("type").is_some() { + value + } else if value + .get("value") + .and_then(|inner| inner.get("type")) + .is_some() + { + &value["value"] + } else { + value + } +} + +fn rich_text(title: &Value) -> String { + block_raw_text(title) +} + +fn block_raw_text(title: &Value) -> String { + let Some(parts) = title.as_array() else { + return String::new(); + }; + parts + .iter() + .filter_map(|part| part.get(0).and_then(Value::as_str)) + .collect() +} + +#[cfg(test)] +pub fn extract_h2_titles(json: &str, page_id: &str) -> Vec { + extract_catalog(json, page_id) + .into_iter() + .map(|section| section.tag) + .collect() +} + +#[cfg(test)] +pub fn extract_catalog(json: &str, page_id: &str) -> Vec { + let Ok(root) = serde_json::from_str::(json) else { + log::error!("notion response is not valid JSON"); + return Vec::new(); + }; + extract_catalog_from_blocks(&root, page_id) +} + +#[cfg(test)] +mod tests { + use super::{extract_catalog, extract_h2_titles, extract_segments, PostSegment}; + + const PAGE_ID: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa0"; + + fn tree_fixture() -> String { + format!( + r#"{{ + "recordMap": {{ + "block": {{ + "{PAGE_ID}": {{ + "value": {{ + "value": {{ + "type": "page", + "content": [ + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2" + ] + }} + }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1": {{ + "value": {{ + "value": {{ + "type": "header", + "properties": {{ "title": [["TEST1"]] }}, + "content": ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3"] + }} + }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2": {{ + "value": {{ + "value": {{ + "type": "header", + "properties": {{ "title": [["TEST2"]] }}, + "content": ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa4"] + }} + }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3": {{ + "value": {{ + "value": {{ + "type": "toggle", + "properties": {{ "title": [["notes"]] }}, + "content": ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa5"] + }} + }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa4": {{ + "value": {{ + "value": {{ + "type": "page", + "properties": {{ "title": [["Direct page"]] }} + }} + }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa5": {{ + "value": {{ + "value": {{ + "type": "page", + "properties": {{ "title": [["TEST-contents"]] }} + }} + }} + }} + }} + }} + }}"# + ) + } + + #[test] + fn extracts_pages_under_toggles_and_headings() { + let catalog = extract_catalog(&tree_fixture(), PAGE_ID); + assert_eq!(catalog.len(), 2); + assert_eq!(catalog[0].tag, "TEST1"); + assert_eq!(catalog[0].pages[0].title, "TEST-contents"); + assert!(catalog[0].pages[0].href.starts_with("/blog/TEST1/")); + assert_eq!(catalog[1].tag, "TEST2"); + assert_eq!(catalog[1].pages[0].title, "Direct page"); + } + + #[test] + fn extracts_header_titles_as_tags() { + assert_eq!( + extract_h2_titles(&tree_fixture(), PAGE_ID), + ["TEST1", "TEST2"] + ); + } + + #[test] + fn ignores_invalid_json() { + assert!(extract_h2_titles("not-json", PAGE_ID).is_empty()); + } + + #[test] + fn extracts_plain_text_in_order() { + let json = format!( + r#"{{ + "recordMap": {{ + "block": {{ + "{PAGE_ID}": {{ + "value": {{ + "value": {{ + "type": "page", + "content": [ + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2" + ] + }} + }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1": {{ + "value": {{ + "value": {{ + "type": "quote", + "properties": {{ "title": [["created: 2025.12.12"]] }} + }} + }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2": {{ + "value": {{ + "value": {{ + "type": "text", + "properties": {{ "title": [["hello"]] }} + }} + }} + }} + }} + }} + }}"# + ); + let segments = extract_segments(&serde_json::from_str(&json).unwrap(), PAGE_ID); + assert_eq!( + segments, + [PostSegment::Text("created: 2025.12.12\nhello".into())] + ); + } + + #[test] + fn keeps_code_and_indentation() { + let json = format!( + r#"{{ + "recordMap": {{ + "block": {{ + "{PAGE_ID}": {{ + "value": {{ + "value": {{ + "type": "page", + "content": ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"] + }} + }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1": {{ + "value": {{ + "value": {{ + "type": "code", + "properties": {{ "title": [["int a;\n std::cin>>a;"]] }} + }} + }} + }} + }} + }} + }}"# + ); + let segments = extract_segments(&serde_json::from_str(&json).unwrap(), PAGE_ID); + assert_eq!( + segments, + [PostSegment::Code("int a;\n std::cin>>a;".into())] + ); + } + + #[test] + fn numbers_lists_and_todos() { + let json = format!( + r#"{{ + "recordMap": {{ + "block": {{ + "{PAGE_ID}": {{ + "value": {{ + "value": {{ + "type": "page", + "content": [ + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2", + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3" + ] + }} + }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1": {{ + "value": {{ "value": {{ + "type": "numbered_list", + "properties": {{ "title": [["one"]] }} + }} }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2": {{ + "value": {{ "value": {{ + "type": "numbered_list", + "properties": {{ "title": [["two"]] }} + }} }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3": {{ + "value": {{ "value": {{ + "type": "to_do", + "properties": {{ + "title": [["done"]], + "checked": [["Yes"]] + }} + }} }} + }} + }} + }} + }}"# + ); + let segments = extract_segments(&serde_json::from_str(&json).unwrap(), PAGE_ID); + assert_eq!( + segments, + [PostSegment::Text("1. one\n2. two\n[x] done".into())] + ); + } +} diff --git a/src/module/scraper.rs b/src/module/scraper.rs index abdbed0..d5d13f4 100644 --- a/src/module/scraper.rs +++ b/src/module/scraper.rs @@ -1,129 +1,69 @@ -//! Browser-only Notion scrape. Page URLs live in `notion.json`. -//! -//! H2 headings are tags. Pages nested under those headings (including -//! inside toggle / collapsible blocks) are the blog content pages. - -use crate::module::config::{dashed_id, page_id_from_url, NotionConfig, EMBEDDED_CONFIG}; +//! Browser Notion fetch used when the compile-time snapshot is empty, +//! and for post bodies that were not snapshotted. + +use super::catalog::{self, CatalogStatus}; +use super::config::{dashed_id, page_id_from_url, NotionConfig, EMBEDDED_CONFIG}; +use super::notion::{ + self, block_map_owned, extract_catalog_from_blocks, extract_segments, merge_block_maps, + missing_content_ids, parse_block_map, MAX_CHILD_FETCHES, +}; use futures::future::join_all; -use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; use web_sys::{Request, RequestCache, RequestInit, RequestMode, Response}; const NOTION_LIVE_API: &str = "https://notion-api.splitbee.io/v1/page/"; -const H2_TYPES: &[&str] = &["header", "sub_header"]; -const CONTAINER_TYPES: &[&str] = &[ - "toggle", - "column_list", - "column", - "bulleted_list", - "numbered_list", - "to_do", - "transclusion_container", -]; -const MAX_CHILD_FETCHES: usize = 32; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct ContentPage { - pub title: String, - pub id: String, - pub href: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct TagSection { - pub tag: String, - pub pages: Vec, -} - -/// A run of post body content. Whitespace inside each string is kept as-is. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PostSegment { - Text(String), - Code(String), -} - -const CACHE_KEY: &str = "tui_blog.catalog.v3"; -const REVALIDATE_MS: i32 = 45_000; +const RETRY_AFTER_MS: u64 = 3_000; thread_local! { - static CATALOG: RefCell> = const { RefCell::new(Vec::new()) }; - static REVALIDATE_BUSY: RefCell = const { RefCell::new(false) }; - static POLL_STARTED: RefCell = const { RefCell::new(false) }; - static POST_BODIES: RefCell>> = RefCell::new(HashMap::new()); + static LIVE_BUSY: RefCell = const { RefCell::new(false) }; static POST_FETCHING: RefCell> = RefCell::new(HashSet::new()); } +pub use notion::{same_page_id, tag_slug, ContentPage, PostSegment, TagSection}; + pub fn current_tags() -> Vec { - CATALOG.with(|catalog| { - catalog - .borrow() - .iter() - .map(|section| section.tag.clone()) - .collect() - }) + catalog::current_tags() } pub fn current_posts(slug: Option<&str>) -> Vec { - CATALOG.with(|catalog| { - let catalog = catalog.borrow(); - match slug { - None => catalog - .iter() - .flat_map(|section| { - section.pages.iter().map(|page| ContentPage { - title: format!("{} — {}", section.tag, page.title), - id: page.id.clone(), - href: page.href.clone(), - }) - }) - .collect(), - Some(slug) => catalog - .iter() - .filter(|section| tag_slug(§ion.tag) == slug) - .flat_map(|section| section.pages.iter().cloned()) - .collect(), - } - }) + catalog::current_posts(slug) } -/// Every content page with its parent tag, titles left as scraped. pub fn current_tagged_posts() -> Vec<(String, ContentPage)> { - CATALOG.with(|catalog| { - catalog - .borrow() - .iter() - .flat_map(|section| { - section - .pages - .iter() - .cloned() - .map(|page| (section.tag.clone(), page)) - }) - .collect() - }) + catalog::current_tagged_posts() } -pub fn tag_slug(tag: &str) -> String { - tag.replace('/', "-") +pub fn current_post_segments(page_id: &str) -> Option> { + match catalog::current_post_state(page_id) { + Some(Ok(segments)) => Some(segments), + Some(Err(_)) | None => None, + } } -pub fn same_page_id(left: &str, right: &str) -> bool { - left.replace('-', "") == right.replace('-', "") +pub fn current_post_state(page_id: &str) -> Option, String>> { + catalog::current_post_state(page_id) } /// Kick off a scrape of one Notion post. Safe to call every frame. pub fn request_post(page_id: &str) { - let id = page_id.replace('-', ""); + catalog::bootstrap(); + let id = catalog::compact_id(page_id); if id.is_empty() { return; } - let already = POST_BODIES.with(|bodies| bodies.borrow().contains_key(&id)) - || POST_FETCHING.with(|busy| !busy.borrow_mut().insert(id.clone())); - if already { + if catalog::post_ready(&id) { + return; + } + if let Some((_, at_ms)) = catalog::failed_at(&id) { + if now_ms().saturating_sub(at_ms) < RETRY_AFTER_MS { + return; + } + } + if !POST_FETCHING.with(|busy| busy.borrow_mut().insert(id.clone())) { return; } wasm_bindgen_futures::spawn_local(async move { @@ -132,55 +72,25 @@ pub fn request_post(page_id: &str) { busy.borrow_mut().remove(&id); }); match result { - Ok(segments) => { - POST_BODIES.with(|bodies| { - bodies.borrow_mut().insert(id, segments); - }); - } + Ok(segments) => catalog::insert_post(&id, segments), Err(err) => { log::error!("post scrape failed: {err}"); - POST_BODIES.with(|bodies| { - bodies.borrow_mut().insert( - id, - vec![PostSegment::Text(format!("(failed to load)\n{err}"))], - ); - }); + catalog::insert_post_failure(&id, err, now_ms()); } } }); } -pub fn current_post_segments(page_id: &str) -> Option> { - let id = page_id.replace('-', ""); - POST_BODIES.with(|bodies| bodies.borrow().get(&id).cloned()) -} - +/// Load the embedded snapshot, then live-fetch the catalog only if it is empty. pub fn start_fetch() { - if catalog_is_empty() { - if let Some(cached) = load_session_cache() { - log::info!("showing cached notion catalog ({} tags)", cached.len()); - apply_catalog(cached); - } + catalog::bootstrap(); + if catalog::catalog_is_empty() { + spawn_live_catalog(); } - spawn_revalidate(); - ensure_poller(); -} - -fn catalog_is_empty() -> bool { - CATALOG.with(|catalog| catalog.borrow().is_empty()) -} - -fn apply_catalog(catalog: Vec) { - CATALOG.with(|slot| *slot.borrow_mut() = catalog); } -#[cfg(test)] -pub fn set_catalog_for_tests(catalog: Vec) { - apply_catalog(catalog); -} - -fn spawn_revalidate() { - if REVALIDATE_BUSY.with(|busy| { +fn spawn_live_catalog() { + if LIVE_BUSY.with(|busy| { if *busy.borrow() { true } else { @@ -192,87 +102,43 @@ fn spawn_revalidate() { } wasm_bindgen_futures::spawn_local(async { let result = fetch_catalog().await; - REVALIDATE_BUSY.with(|busy| *busy.borrow_mut() = false); + LIVE_BUSY.with(|busy| *busy.borrow_mut() = false); match result { - Ok(catalog) => { - let changed = CATALOG.with(|slot| *slot.borrow() != catalog); - if changed { - log::info!( - "notion catalog updated ({} tags / {} posts)", - catalog.len(), - catalog - .iter() - .map(|section| section.pages.len()) - .sum::() - ); - save_session_cache(&catalog); - apply_catalog(catalog); - } else { - log::info!("notion catalog unchanged"); - save_session_cache(&catalog); + Ok((catalog, about)) => { + if catalog.is_empty() && about.is_none() { + if catalog::catalog_is_empty() { + catalog::set_status(CatalogStatus::Ready); + } + return; + } + log::info!( + "notion catalog updated ({} tags / {} posts)", + catalog.len(), + catalog + .iter() + .map(|section| section.pages.len()) + .sum::() + ); + catalog::apply_catalog(catalog); + if about.is_some() { + catalog::set_about(about); } } - Err(err) => log::error!("notion catalog fetch failed: {err}"), - } - }); -} - -fn ensure_poller() { - if POLL_STARTED.with(|started| { - if *started.borrow() { - true - } else { - *started.borrow_mut() = true; - false - } - }) { - return; - } - let Some(window) = web_sys::window() else { - return; - }; - - let on_tick = wasm_bindgen::closure::Closure::::new(spawn_revalidate); - if let Err(err) = window.set_interval_with_callback_and_timeout_and_arguments_0( - on_tick.as_ref().unchecked_ref(), - REVALIDATE_MS, - ) { - log::error!("failed to start notion poller: {err:?}"); - } - on_tick.forget(); - - if let Some(document) = window.document() { - let on_visible = wasm_bindgen::closure::Closure::::new( - move |_event: web_sys::Event| { - if document_is_visible() { - spawn_revalidate(); + Err(err) => { + log::error!("notion catalog fetch failed: {err}"); + if catalog::catalog_is_empty() { + catalog::set_status(CatalogStatus::Error(err)); } - }, - ); - if let Err(err) = document.add_event_listener_with_callback( - "visibilitychange", - on_visible.as_ref().unchecked_ref(), - ) { - log::error!("failed to listen for visibilitychange: {err:?}"); + } } - on_visible.forget(); - } -} - -fn document_is_visible() -> bool { - web_sys::window() - .and_then(|window| window.document()) - .map(|document| document.hidden()) - .map(|hidden| !hidden) - .unwrap_or(true) + }); } -async fn fetch_catalog() -> Result, String> { - let config = load_config()?; +async fn fetch_catalog() -> Result<(Vec, Option), String> { + let config = NotionConfig::parse(EMBEDDED_CONFIG)?; let sources: Vec<_> = config.tag_pages().cloned().collect(); if sources.is_empty() { log::warn!("notion.json has no pages with role \"tags\""); - return Ok(Vec::new()); } let jobs = sources.into_iter().map(|page| async move { @@ -287,20 +153,52 @@ async fn fetch_catalog() -> Result, String> { }); let mut catalog = Vec::new(); + let mut errors = Vec::new(); for result in join_all(jobs).await { - catalog.extend(result?); + match result { + Ok(sections) => catalog.extend(sections), + Err(err) => { + log::error!("tag page failed: {err}"); + errors.push(err); + } + } } - Ok(dedupe_sections(catalog)) + let catalog = notion::dedupe_sections(catalog); + + let mut about = None; + for page in config.about_pages() { + match load_about_page(&page.url).await { + Ok(text) => { + about = Some(text); + break; + } + Err(err) => log::warn!("about page failed: {err}"), + } + } + + if catalog.is_empty() && about.is_none() && !errors.is_empty() { + return Err(errors.remove(0)); + } + Ok((catalog, about)) } -fn load_config() -> Result { - NotionConfig::parse(EMBEDDED_CONFIG) +async fn load_about_page(url: &str) -> Result { + let page_id = page_id_from_url(url) + .ok_or_else(|| format!("could not parse Notion page id from {url}"))?; + let segments = fetch_post_plain(&page_id).await?; + Ok(notion::segments_plain_text(&segments)) } async fn resolve_missing(blocks: &mut Map, root_id: &str) { let mut fetched = 0; loop { if fetched >= MAX_CHILD_FETCHES { + let leftover = missing_content_ids(blocks, root_id).len(); + if leftover > 0 { + log::warn!( + "stopped resolving Notion children after {MAX_CHILD_FETCHES} fetches ({leftover} still missing)" + ); + } break; } let mut missing = missing_content_ids(blocks, root_id); @@ -315,11 +213,7 @@ async fn resolve_missing(blocks: &mut Map, root_id: &str) { for (id, result) in join_all(jobs).await { fetched += 1; match result { - Ok(extra) => { - for (key, value) in extra { - blocks.entry(key).or_insert(value); - } - } + Ok(extra) => merge_block_maps(blocks, extra), Err(err) => { log::warn!("skipping nested Notion block {id}: {err}"); blocks.insert(id, Value::Null); @@ -344,15 +238,16 @@ async fn fetch_post_plain(page_id: &str) -> Result, String> { async fn fetch_blocks(page_id: &str) -> Result, String> { let json = fetch_page_json(page_id).await?; - let root: Value = - serde_json::from_str(&json).map_err(|err| format!("invalid notion json: {err}"))?; - block_map_owned(root).ok_or_else(|| "notion response is missing a block map".to_string()) + parse_block_map(&json).or_else(|_| { + let root: Value = + serde_json::from_str(&json).map_err(|err| format!("invalid notion json: {err}"))?; + block_map_owned(root).ok_or_else(|| "notion response is missing a block map".to_string()) + }) } async fn fetch_page_json(page_id: &str) -> Result { let compact_id = page_id.replace('-', ""); - let cache_bust = js_sys::Date::now() as u64; - let url = format!("{NOTION_LIVE_API}{compact_id}?t={cache_bust}"); + let url = format!("{NOTION_LIVE_API}{compact_id}"); fetch_text(&url).await } @@ -360,7 +255,7 @@ async fn fetch_text(url: &str) -> Result { let opts = RequestInit::new(); opts.set_method("GET"); opts.set_mode(RequestMode::Cors); - opts.set_cache(RequestCache::Reload); + opts.set_cache(RequestCache::Default); let request = Request::new_with_str_and_init(url, &opts).map_err(js_err)?; let window = web_sys::window().ok_or_else(|| "missing window".to_string())?; @@ -382,507 +277,11 @@ fn js_err(err: wasm_bindgen::JsValue) -> String { err.as_string().unwrap_or_else(|| format!("{err:?}")) } -#[derive(Serialize, Deserialize)] -struct CachedCatalog { - saved_at: f64, - sections: Vec, -} - -fn load_session_cache() -> Option> { - let window = web_sys::window()?; - let storage = window.session_storage().ok().flatten()?; - let raw = storage.get_item(CACHE_KEY).ok().flatten()?; - let cached: CachedCatalog = serde_json::from_str(&raw).ok()?; - Some(cached.sections) -} - -fn save_session_cache(sections: &[TagSection]) { - let Some(window) = web_sys::window() else { - return; - }; - let Ok(Some(storage)) = window.session_storage() else { - return; - }; - let cached = CachedCatalog { - saved_at: js_sys::Date::now(), - sections: sections.to_vec(), - }; - if let Ok(raw) = serde_json::to_string(&cached) { - let _ = storage.set_item(CACHE_KEY, &raw); - } -} - -#[cfg(test)] -pub fn extract_h2_titles(json: &str, page_id: &str) -> Vec { - extract_catalog(json, page_id) - .into_iter() - .map(|section| section.tag) - .collect() +fn now_ms() -> u64 { + js_sys::Date::now() as u64 } #[cfg(test)] -pub fn extract_catalog(json: &str, page_id: &str) -> Vec { - let Ok(root) = serde_json::from_str::(json) else { - log::error!("notion response is not valid JSON"); - return Vec::new(); - }; - extract_catalog_from_blocks(&root, page_id) -} - -fn extract_catalog_from_blocks(root: &Value, page_id: &str) -> Vec { - let blocks = block_map(root); - if !blocks.is_object() { - log::error!("notion response is missing a block map"); - return Vec::new(); - } - - let mut sections = Vec::new(); - for child_id in page_child_ids(blocks, page_id) { - let value = block_value(&blocks[&child_id]); - let Some(kind) = value["type"].as_str() else { - continue; - }; - if !H2_TYPES.contains(&kind) { - continue; - } - let tag = rich_text(&value["properties"]["title"]); - if tag.is_empty() { - continue; - } - let mut pages = Vec::new(); - collect_pages(blocks, &child_content_ids(value), page_id, &tag, &mut pages); - sections.push(TagSection { tag, pages }); - } - sections -} - -fn collect_pages( - blocks: &Value, - ids: &[String], - root_id: &str, - tag: &str, - out: &mut Vec, -) { - for id in ids { - if id == root_id { - continue; - } - let value = block_value(&blocks[id]); - let Some(kind) = value["type"].as_str() else { - continue; - }; - if kind == "page" { - let title = rich_text(&value["properties"]["title"]); - if !title.is_empty() { - let compact = id.replace('-', ""); - out.push(ContentPage { - title, - id: compact.clone(), - href: format!("/blog/{}/{}", tag_slug(tag), compact), - }); - } - continue; - } - if kind == "toggle" || CONTAINER_TYPES.contains(&kind) || H2_TYPES.contains(&kind) { - collect_pages(blocks, &child_content_ids(value), root_id, tag, out); - } - } -} - -fn extract_segments(root: &Value, page_id: &str) -> Vec { - let blocks = block_map(root); - if !blocks.is_object() { - return Vec::new(); - } - let mut segments = Vec::new(); - collect_segments( - blocks, - &page_child_ids(blocks, page_id), - page_id, - &mut segments, - ); - merge_text_segments(segments) -} - -fn collect_segments(blocks: &Value, ids: &[String], root_id: &str, out: &mut Vec) { - for id in ids { - let value = block_value(&blocks[id]); - let Some(kind) = value["type"].as_str() else { - continue; - }; - if kind == "page" && !same_page_id(id, root_id) { - continue; - } - if kind == "code" { - out.push(PostSegment::Code(block_source_text(value))); - } else if let Some(text) = block_display_text(kind, value) { - out.push(PostSegment::Text(text)); - } - if kind == "toggle" || CONTAINER_TYPES.contains(&kind) || H2_TYPES.contains(&kind) { - collect_segments(blocks, &child_content_ids(value), root_id, out); - } - } -} - -fn block_source_text(value: &Value) -> String { - block_raw_text(&value["properties"]["title"]) -} - -fn block_display_text(kind: &str, value: &Value) -> Option { - let text = block_raw_text(&value["properties"]["title"]); - match kind { - "text" | "quote" | "callout" | "header" | "sub_header" | "sub_sub_header" - | "bulleted_list" | "numbered_list" | "to_do" | "toggle" => Some(text), - _ => None, - } -} - -fn merge_text_segments(segments: Vec) -> Vec { - let mut out = Vec::new(); - for segment in segments { - match (out.last_mut(), segment) { - (Some(PostSegment::Text(existing)), PostSegment::Text(next)) => { - existing.push('\n'); - existing.push_str(&next); - } - (_, other) => out.push(other), - } - } - out -} - -fn missing_content_ids(blocks: &Map, root_id: &str) -> Vec { - let root = Value::Object(blocks.clone()); - let mut missing = Vec::new(); - let mut seen = HashSet::new(); - let mut stack = page_child_ids(&root, root_id); - while let Some(id) = stack.pop() { - if !seen.insert(id.clone()) { - continue; - } - match blocks.get(&id) { - None => missing.push(id), - Some(Value::Null) => {} - Some(block) => { - let value = block_value(block); - let kind = value["type"].as_str().unwrap_or(""); - if kind == "page" && !same_page_id(&id, root_id) { - continue; - } - if H2_TYPES.contains(&kind) || kind == "toggle" || CONTAINER_TYPES.contains(&kind) { - stack.extend(child_content_ids(value)); - } - } - } - } - missing -} - -fn child_content_ids(value: &Value) -> Vec { - value["content"] - .as_array() - .into_iter() - .flatten() - .filter_map(|id| id.as_str().map(str::to_owned)) - .collect() -} - -fn block_map(root: &Value) -> &Value { - let nested = &root["recordMap"]["block"]; - if nested.is_object() { - nested - } else { - root - } -} - -fn block_map_owned(root: Value) -> Option> { - match root { - Value::Object(map) => { - if let Some(Value::Object(record_map)) = map.get("recordMap").cloned() { - if let Some(Value::Object(block)) = record_map.get("block").cloned() { - return Some(block); - } - } - Some(map) - } - _ => None, - } -} - -fn resolve_block_key(blocks: &Value, page_id: &str) -> String { - if blocks.get(page_id).is_some() { - return page_id.to_string(); - } - let dashed = dashed_id(page_id); - if blocks.get(&dashed).is_some() { - return dashed; - } - let compact = page_id.replace('-', ""); - if let Some(key) = blocks.as_object().and_then(|map| { - map.keys() - .find(|key| key.replace('-', "") == compact) - .cloned() - }) { - return key; - } - dashed -} - -fn page_child_ids(blocks: &Value, page_id: &str) -> Vec { - let key = resolve_block_key(blocks, page_id); - let page = block_value(&blocks[&key]); - if let Some(ids) = page["content"].as_array() { - return ids - .iter() - .filter_map(|id| id.as_str().map(str::to_owned)) - .collect(); - } - - let owned: Vec = blocks - .as_object() - .into_iter() - .flatten() - .filter_map(|(id, block)| { - let value = block_value(block); - let parent = value["parent_id"].as_str()?; - same_page_id(parent, &key).then(|| id.clone()) - }) - .collect(); - if !owned.is_empty() { - return owned; - } - - blocks - .as_object() - .into_iter() - .flatten() - .filter_map(|(id, block)| { - let value = block_value(block); - let kind = value["type"].as_str()?; - (H2_TYPES.contains(&kind) && !same_page_id(id, &key)).then(|| id.clone()) - }) - .collect() -} - -fn block_value(block: &Value) -> &Value { - if block.is_null() { - return block; - } - let value = &block["value"]; - if value.get("type").is_some() { - value - } else if value - .get("value") - .and_then(|inner| inner.get("type")) - .is_some() - { - &value["value"] - } else { - value - } -} - -fn rich_text(title: &Value) -> String { - block_raw_text(title) -} - -fn block_raw_text(title: &Value) -> String { - let Some(parts) = title.as_array() else { - return String::new(); - }; - parts - .iter() - .filter_map(|part| part.get(0).and_then(Value::as_str)) - .collect() -} - -fn dedupe_sections(sections: Vec) -> Vec { - let mut seen_tags = HashSet::new(); - let mut out: Vec = Vec::new(); - for mut section in sections { - if !seen_tags.insert(section.tag.clone()) { - if let Some(existing) = out.iter_mut().find(|item| item.tag == section.tag) { - existing.pages.append(&mut section.pages); - } - continue; - } - out.push(section); - } - out -} - -#[cfg(test)] -mod tests { - use super::{extract_catalog, extract_h2_titles}; - - const PAGE_ID: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa0"; - - fn tree_fixture() -> String { - format!( - r#"{{ - "recordMap": {{ - "block": {{ - "{PAGE_ID}": {{ - "value": {{ - "value": {{ - "type": "page", - "content": [ - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2" - ] - }} - }} - }}, - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1": {{ - "value": {{ - "value": {{ - "type": "header", - "properties": {{ "title": [["TEST1"]] }}, - "content": ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3"] - }} - }} - }}, - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2": {{ - "value": {{ - "value": {{ - "type": "header", - "properties": {{ "title": [["TEST2"]] }}, - "content": ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa4"] - }} - }} - }}, - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3": {{ - "value": {{ - "value": {{ - "type": "toggle", - "properties": {{ "title": [["notes"]] }}, - "content": ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa5"] - }} - }} - }}, - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa4": {{ - "value": {{ - "value": {{ - "type": "page", - "properties": {{ "title": [["Direct page"]] }} - }} - }} - }}, - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa5": {{ - "value": {{ - "value": {{ - "type": "page", - "properties": {{ "title": [["TEST-contents"]] }} - }} - }} - }} - }} - }} - }}"# - ) - } - - #[test] - fn extracts_pages_under_toggles_and_headings() { - let catalog = extract_catalog(&tree_fixture(), PAGE_ID); - assert_eq!(catalog.len(), 2); - assert_eq!(catalog[0].tag, "TEST1"); - assert_eq!(catalog[0].pages[0].title, "TEST-contents"); - assert!(catalog[0].pages[0].href.starts_with("/blog/TEST1/")); - assert_eq!(catalog[1].tag, "TEST2"); - assert_eq!(catalog[1].pages[0].title, "Direct page"); - } - - #[test] - fn extracts_header_titles_as_tags() { - assert_eq!( - extract_h2_titles(&tree_fixture(), PAGE_ID), - ["TEST1", "TEST2"] - ); - } - - #[test] - fn ignores_invalid_json() { - assert!(extract_h2_titles("not-json", PAGE_ID).is_empty()); - } - - #[test] - fn extracts_plain_text_in_order() { - let json = format!( - r#"{{ - "recordMap": {{ - "block": {{ - "{PAGE_ID}": {{ - "value": {{ - "value": {{ - "type": "page", - "content": [ - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2" - ] - }} - }} - }}, - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1": {{ - "value": {{ - "value": {{ - "type": "quote", - "properties": {{ "title": [["created: 2025.12.12"]] }} - }} - }} - }}, - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2": {{ - "value": {{ - "value": {{ - "type": "text", - "properties": {{ "title": [["hello"]] }} - }} - }} - }} - }} - }} - }}"# - ); - let segments = super::extract_segments(&serde_json::from_str(&json).unwrap(), PAGE_ID); - assert_eq!( - segments, - [super::PostSegment::Text( - "created: 2025.12.12\nhello".into() - )] - ); - } - - #[test] - fn keeps_code_and_indentation() { - let json = format!( - r#"{{ - "recordMap": {{ - "block": {{ - "{PAGE_ID}": {{ - "value": {{ - "value": {{ - "type": "page", - "content": ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"] - }} - }} - }}, - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1": {{ - "value": {{ - "value": {{ - "type": "code", - "properties": {{ "title": [["int a;\n std::cin>>a;"]] }} - }} - }} - }} - }} - }} - }}"# - ); - let segments = super::extract_segments(&serde_json::from_str(&json).unwrap(), PAGE_ID); - assert_eq!( - segments, - [super::PostSegment::Code("int a;\n std::cin>>a;".into())] - ); - } +pub fn set_catalog_for_tests(catalog: Vec) { + catalog::set_catalog_for_tests(catalog); } diff --git a/src/module/snapshot.rs b/src/module/snapshot.rs new file mode 100644 index 0000000..fb13f76 --- /dev/null +++ b/src/module/snapshot.rs @@ -0,0 +1,43 @@ +use super::notion::{PostSegment, TagSection}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +pub const EMBEDDED_SNAPSHOT: &str = + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/snapshot.json")); + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Snapshot { + #[serde(default)] + pub saved_at: u64, + #[serde(default)] + pub sections: Vec, + #[serde(default)] + pub posts: HashMap>, + #[serde(default)] + pub about: Option, +} + +impl Snapshot { + pub fn parse(json: &str) -> Result { + serde_json::from_str(json).map_err(|err| format!("invalid snapshot.json: {err}")) + } + + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self).map_err(|err| format!("serialize snapshot: {err}")) + } + + pub fn is_empty(&self) -> bool { + self.sections.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::{Snapshot, EMBEDDED_SNAPSHOT}; + + #[test] + fn embedded_snapshot_parses() { + let snapshot = Snapshot::parse(EMBEDDED_SNAPSHOT).unwrap(); + assert!(snapshot.posts.is_empty() || !snapshot.sections.is_empty() || snapshot.is_empty()); + } +} diff --git a/src/mouse.rs b/src/mouse.rs index c56f5ca..5bf5423 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -1,15 +1,86 @@ +use crate::width::display_width; use ratatui::layout::Rect; use ratzilla::event::MouseEvent; +use std::cell::{Cell, RefCell}; +use wasm_bindgen::JsCast; +use web_sys::HtmlElement; -/// Approximate CSS-pixel size of one terminal cell under the current -/// `index.html` stylesheet (Fira Code 16px). These are calibrated values -/// and should be replaced once Ratzilla exposes real cell metrics. -const CELL_WIDTH_PX: f32 = 9.84998; -const CELL_HEIGHT_PX: u16 = 22; +/// Fallback cell size used before the DOM grid can be measured. +/// Matches Ratzilla's `get_window_size` heuristic (innerWidth/10 × innerHeight/20). +const FALLBACK_CELL_WIDTH_PX: f32 = 10.0; +const FALLBACK_CELL_HEIGHT_PX: f32 = 20.0; -/// Extra cells added to a [`Rect`]'s right edge so sidebar hit-testing -/// still matches the previous sloppy bounds. -const RECT_WIDTH_SLOP: u16 = 1; +#[derive(Clone, Copy, Debug)] +struct CellMetrics { + origin_x: f32, + origin_y: f32, + cell_w: f32, + cell_h: f32, +} + +impl Default for CellMetrics { + fn default() -> Self { + Self { + origin_x: 0.0, + origin_y: 0.0, + cell_w: FALLBACK_CELL_WIDTH_PX, + cell_h: FALLBACK_CELL_HEIGHT_PX, + } + } +} + +thread_local! { + static METRICS: RefCell = const { RefCell::new(CellMetrics { + origin_x: 0.0, + origin_y: 0.0, + cell_w: FALLBACK_CELL_WIDTH_PX, + cell_h: FALLBACK_CELL_HEIGHT_PX, + }) }; + static CURSOR: Cell<&'static str> = const { Cell::new("default") }; +} + +pub fn cell_height_px() -> f32 { + METRICS.with(|slot| slot.borrow().cell_h) +} + +/// Measure the Ratzilla `#grid` so hit-testing tracks zoom, font, and centering. +pub fn refresh_cell_metrics() { + let Some(metrics) = measure_grid() else { + return; + }; + if metrics.cell_w < 1.0 || metrics.cell_h < 1.0 { + return; + } + METRICS.with(|slot| *slot.borrow_mut() = metrics); +} + +fn measure_grid() -> Option { + let document = web_sys::window()?.document()?; + let grid = document.get_element_by_id("grid")?; + let grid_rect = grid.get_bounding_client_rect(); + let pre = grid + .query_selector("pre") + .ok() + .flatten() + .or_else(|| grid.first_element_child())?; + let pre_rect = pre.get_bounding_client_rect(); + let span = pre.query_selector("span").ok().flatten(); + let cell_w = span + .map(|el| el.get_bounding_client_rect().width() as f32) + .filter(|w| *w > 1.0) + .unwrap_or(FALLBACK_CELL_WIDTH_PX); + let cell_h = if pre_rect.height() > 1.0 { + pre_rect.height() as f32 + } else { + FALLBACK_CELL_HEIGHT_PX + }; + Some(CellMetrics { + origin_x: grid_rect.left() as f32, + origin_y: grid_rect.top() as f32, + cell_w, + cell_h, + }) +} #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct MouseState { @@ -21,6 +92,10 @@ impl MouseState { self.pos = (event.x, event.y); } + pub fn pos(&self) -> (u32, u32) { + self.pos + } + pub fn hits(&self, span: CellSpan) -> bool { span.contains(self.pos) } @@ -32,8 +107,7 @@ impl MouseState { /// Cell-space box used for hover and click tests. /// -/// `x1`/`y1` follow the previous helpers: they are the far cell indices -/// whose left/top pixel edges form the inclusive max bound. +/// `x1`/`y1` are exclusive far-edge cell indices (same as a Ratatui `Rect`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct CellSpan { pub x0: u16, @@ -48,24 +122,21 @@ impl CellSpan { } pub fn contains(self, mouse: (u32, u32)) -> bool { - let px0 = f32::from(self.x0) * CELL_WIDTH_PX; - let px1 = f32::from(self.x1) * CELL_WIDTH_PX; - let py0 = self.y0.saturating_mul(CELL_HEIGHT_PX); - let py1 = self.y1.saturating_mul(CELL_HEIGHT_PX); - - (mouse.0 as f32) >= px0 - && (mouse.0 as f32) <= px1 - && (mouse.1 as u16) >= py0 - && (mouse.1 as u16) <= py1 + let metrics = METRICS.with(|slot| *slot.borrow()); + let px0 = metrics.origin_x + f32::from(self.x0) * metrics.cell_w; + let px1 = metrics.origin_x + f32::from(self.x1) * metrics.cell_w; + let py0 = metrics.origin_y + f32::from(self.y0) * metrics.cell_h; + let py1 = metrics.origin_y + f32::from(self.y1) * metrics.cell_h; + let x = mouse.0 as f32; + let y = mouse.1 as f32; + x >= px0 && x < px1 && y >= py0 && y < py1 } } pub fn rect_span(area: Rect) -> CellSpan { CellSpan::new( area.x, - area.x - .saturating_add(area.width) - .saturating_add(RECT_WIDTH_SLOP), + area.x.saturating_add(area.width), area.y, area.y.saturating_add(area.height), ) @@ -116,6 +187,10 @@ impl HitMap { .map(|region| ®ion.action) } + pub fn hovering(&self, mouse: (u32, u32)) -> bool { + self.action_at(mouse).is_some() + } + #[cfg(test)] pub fn href_at(&self, mouse: (u32, u32)) -> Option<&str> { match self.action_at(mouse) { @@ -137,16 +212,13 @@ impl HitMap { } /// Inclusive cell-x ranges for a centered row of labeled buttons. -/// -/// The `+ HEADER_HIT_NUDGE` fudge matches the visual center of a -/// `Paragraph::centered` row inside a padded bordered block. const HEADER_HIT_NUDGE: usize = 2; const BUTTON_PAD: usize = 4; // `" [{}] "` around the label pub fn header_button_ranges(area: Rect, labels: &[&str]) -> Vec<(u16, u16)> { let widths: Vec = labels .iter() - .map(|label| label.len().saturating_add(BUTTON_PAD)) + .map(|label| display_width(label).saturating_add(BUTTON_PAD)) .collect(); let total: usize = widths.iter().sum(); let mut offset = (area.width as usize / 2) @@ -157,9 +229,9 @@ pub fn header_button_ranges(area: Rect, labels: &[&str]) -> Vec<(u16, u16)> { let mut ranges = Vec::with_capacity(widths.len()); for width in widths { let start = offset; - let end = offset.saturating_add(width.saturating_sub(1)); + let end = offset.saturating_add(width); ranges.push((start as u16, end as u16)); - offset = offset.saturating_add(width); + offset = end; } ranges } @@ -170,6 +242,40 @@ pub fn list_row_y(area: Rect, index: usize, height: u16) -> (u16, u16) { (top, top.saturating_add(height)) } +/// Show a pointer over clickable hits (nav, lists, cards, copy). +pub fn sync_hover_cursor(hovering: bool) { + let next = if hovering { "pointer" } else { "default" }; + if CURSOR.with(|slot| { + if slot.get() == next { + true + } else { + slot.set(next); + false + } + }) { + return; + } + apply_cursor(next); +} + +fn apply_cursor(name: &str) { + let Some(document) = web_sys::window().and_then(|window| window.document()) else { + return; + }; + for element in [ + document.document_element(), + document.body().map(Into::into), + document.get_element_by_id("grid"), + ] + .into_iter() + .flatten() + { + if let Ok(html) = element.dyn_into::() { + let _ = html.style().set_property("cursor", name); + } + } +} + #[cfg(test)] mod tests { use super::{header_button_ranges, list_row_y, CellSpan, HitMap}; @@ -188,8 +294,8 @@ mod tests { let ranges = header_button_ranges(area, &["Intro", "Blog", "About"]); assert_eq!(ranges.len(), 3); assert!(ranges[0].0 < ranges[0].1); - assert_eq!(ranges[1].0, ranges[0].1 + 1); - assert_eq!(ranges[2].0, ranges[1].1 + 1); + assert_eq!(ranges[1].0, ranges[0].1); + assert_eq!(ranges[2].0, ranges[1].1); } #[test] @@ -205,5 +311,7 @@ mod tests { hits.add(CellSpan::new(0, 10, 0, 2), "/first"); hits.add(CellSpan::new(0, 10, 0, 2), "/second"); assert_eq!(hits.href_at((0, 0)), Some("/second")); + assert!(hits.hovering((0, 0))); + assert!(!hits.hovering((1_000, 1_000))); } } diff --git a/src/router.rs b/src/router.rs index c42eb68..e6ea29f 100644 --- a/src/router.rs +++ b/src/router.rs @@ -36,6 +36,22 @@ impl Route { Self::NotFound => "Err404", } } + + pub fn next(self) -> Self { + match self { + Self::Intro => Self::Blog, + Self::Blog => Self::About, + Self::About | Self::NotFound => Self::Intro, + } + } + + pub fn prev(self) -> Self { + match self { + Self::Intro | Self::NotFound => Self::About, + Self::Blog => Self::Intro, + Self::About => Self::Blog, + } + } } impl fmt::Display for Route { @@ -58,18 +74,25 @@ impl Router { pub fn parse(path: impl AsRef) -> Self { let path = path.as_ref(); let mut parts = path.trim_start_matches('/').split('/'); - let first = parts.next().unwrap_or(""); + let first = percent_decode(parts.next().unwrap_or("")); let second = parts .next() .filter(|segment| !segment.is_empty()) - .map(str::to_string); + .map(percent_decode); let third = parts .next() .filter(|segment| !segment.is_empty()) - .map(str::to_string); + .map(percent_decode); + let extra = parts.next().filter(|segment| !segment.is_empty()); + + let route = if extra.is_some() { + Route::NotFound + } else { + Route::from_segment(&first) + }; Self { - route: Route::from_segment(first), + route, slug: second, post: third, } @@ -94,11 +117,52 @@ impl Router { } } + pub fn document_title(&self) -> String { + match (self.route, self.slug(), self.post()) { + (Route::Intro, _, _) => "Dongju Lim — tui_blog".to_string(), + (Route::About, _, _) => "About — tui_blog".to_string(), + (Route::Blog, Some(slug), Some(_)) => format!("{slug} — tui_blog"), + (Route::Blog, Some(slug), None) => format!("{slug} — tui_blog"), + (Route::Blog, None, _) => "Blog — tui_blog".to_string(), + (Route::NotFound, _, _) => "Not found — tui_blog".to_string(), + } + } + + pub fn parent_href(&self) -> Option { + match (self.route, self.slug(), self.post()) { + (Route::Blog, Some(slug), Some(_)) => Some(Self::tag_href(slug)), + (Route::Blog, Some(_), None) => Some("/blog".to_string()), + (Route::Blog, None, _) => Some("/".to_string()), + (Route::About | Route::NotFound, _, _) => Some("/".to_string()), + (Route::Intro, _, _) => None, + } + } + pub fn tag_href(tag: &str) -> String { format!("/blog/{}", crate::module::scraper::tag_slug(tag)) } } +fn percent_decode(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(value) = + u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16) + { + out.push(value); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8(out).unwrap_or_else(|_| input.to_string()) +} + #[cfg(test)] mod tests { use super::{Route, Router}; @@ -141,6 +205,18 @@ mod tests { assert_eq!(router.title(), "Err404"); } + #[test] + fn extra_segments_are_not_found() { + let router = Router::parse("/blog/linux/id/extra"); + assert_eq!(router.route(), Route::NotFound); + } + + #[test] + fn percent_decodes_segments() { + let router = Router::parse("/blog/C%2FC%2B%2B"); + assert_eq!(router.slug(), Some("C/C++")); + } + #[test] fn tag_href_is_absolute() { assert_eq!(Router::tag_href("gcc"), "/blog/gcc"); diff --git a/src/theme.rs b/src/theme.rs index dd0b847..c7783c4 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,4 +1,4 @@ -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Color, Style}; /// Terminal background used by every panel. pub const BG: Color = Color::Rgb(28, 25, 22); @@ -6,34 +6,8 @@ pub const FG: Color = Color::White; pub const HOVER: Color = Color::Green; pub const DIM: Color = Color::Rgb(140, 130, 120); -/// Set to `Some(1..=4)` to lock a code style. `None` shows all four samples. -pub const CODE_STYLE_SAMPLE: Option = Some(1); - -/// Four numbered code-block looks. Change [`CODE_STYLE_SAMPLE`] to pick one. -pub fn code_block_style(sample: u8) -> Style { - match sample { - 1 => Style::new() - .fg(Color::Rgb(171, 178, 191)) - .bg(Color::Rgb(40, 44, 52)), - 2 => Style::new() - .fg(Color::Rgb(163, 190, 140)) - .bg(Color::Rgb(46, 52, 64)) - .add_modifier(Modifier::BOLD), - 3 => Style::new() - .fg(Color::Rgb(88, 86, 82)) - .bg(Color::Rgb(253, 246, 227)), - _ => Style::new() - .fg(Color::Rgb(230, 219, 116)) - .bg(Color::Rgb(39, 40, 34)), - } -} - -#[allow(dead_code)] -pub fn code_sample_label(sample: u8) -> &'static str { - match sample { - 1 => "1 one-dark", - 2 => "2 nord", - 3 => "3 solarized-light", - _ => "4 monokai", - } +pub fn code_block_style() -> Style { + Style::new() + .fg(Color::Rgb(171, 178, 191)) + .bg(Color::Rgb(40, 44, 52)) } diff --git a/src/ui/about.rs b/src/ui/about.rs index 1c3f04d..8473fdd 100644 --- a/src/ui/about.rs +++ b/src/ui/about.rs @@ -1,4 +1,5 @@ -use super::{render_page, FrameCtx}; +use super::{render_page, render_scrolling_page, FrameCtx}; +use crate::content; use ratatui::layout::Rect; use ratatui::Frame; @@ -7,6 +8,18 @@ const BODY: &str = "about me & contact\n\ DocRAID\n\ l01062506145@gmail.com\n"; -pub fn render(ctx: &FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { - render_page(frame, area, &ctx.router.title(), BODY); +pub fn render(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { + let owned; + let text = match content::about_text() { + Some(from_notion) if !from_notion.trim().is_empty() => { + owned = from_notion; + owned.as_str() + } + _ => BODY, + }; + if text.lines().count() > 8 || area.height < 16 { + render_scrolling_page(ctx, frame, area, &ctx.router.title(), text, false); + } else { + render_page(frame, area, &ctx.router.title(), text.to_string()); + } } diff --git a/src/ui/blog.rs b/src/ui/blog.rs index 6614f63..2120baa 100644 --- a/src/ui/blog.rs +++ b/src/ui/blog.rs @@ -1,11 +1,13 @@ use super::FrameCtx; -use crate::content; -use crate::module::scraper::{same_page_id, tag_slug, PostSegment}; +use crate::content::{self, CatalogStatus}; +use crate::module::notion::PostSegment; +use crate::module::scraper::{same_page_id, tag_slug}; use crate::mouse::{list_row_y, CellSpan}; use crate::router::Router; use crate::theme; +use crate::width::{display_width, wrapped_rows}; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; -use ratatui::style::{Modifier, Style, Stylize}; +use ratatui::style::{Style, Stylize}; use ratatui::text::{Line, Span}; use ratatui::widgets::{ Block, BorderType, HighlightSpacing, List, ListState, Paragraph, Scrollbar, @@ -15,12 +17,14 @@ use ratatui::Frame; /// Hide the tag/post sidebar below this many terminal columns. const COMPACT_WIDTH: u16 = 72; +const COPY_LABEL: &str = " Copy "; +const COPIED_LABEL: &str = " Copied "; pub fn render(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { if is_compact(area) { if let Some(post_id) = ctx.router.post() { content::ensure_post(post_id); - render_post_body(ctx, frame, area); + render_post_body(ctx, frame, area, true); } else { render_post_list(ctx, frame, area); } @@ -33,7 +37,7 @@ pub fn render(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { if let Some(post_id) = ctx.router.post() { content::ensure_post(post_id); render_category_posts(ctx, frame, sidebar); - render_post_body(ctx, frame, body); + render_post_body(ctx, frame, body, false); } else { render_tag_list(ctx, frame, sidebar); render_post_list(ctx, frame, body); @@ -44,6 +48,14 @@ fn is_compact(area: Rect) -> bool { area.width < COMPACT_WIDTH } +fn status_placeholder() -> String { + match content::catalog_status() { + CatalogStatus::Loading => "(loading…)".to_string(), + CatalogStatus::Error(err) => format!("(failed to load)\n{err}"), + CatalogStatus::Ready => "(no posts)".to_string(), + } +} + fn render_tag_list(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { let tags_block = Block::bordered() .title("{{ tags }}") @@ -51,21 +63,30 @@ fn render_tag_list(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { .border_type(BorderType::Plain); let tags = content::tags(); - let tag_list = List::new(tags.iter().map(String::as_str)) + let labels: Vec = if tags.is_empty() { + vec![status_placeholder()] + } else { + tags.clone() + }; + let tag_list = List::new(labels.iter().cloned()) .block(tags_block) .bg(theme::BG) .highlight_style(Style::new().fg(theme::HOVER)) - .highlight_spacing(HighlightSpacing::WhenSelected); + .highlight_spacing(HighlightSpacing::Never); let selected_slug = ctx.router.slug(); let mut hovered = None; let mut selected = None; + ctx.nav_items.clear(); for (index, tag) in tags.iter().enumerate() { let (y0, y1) = list_row_y(area, index, 1); - let x0 = area.x; - let x1 = area.x.saturating_add(area.width); - let span = CellSpan::new(x0, x1, y0, y1); - ctx.hits.add(span, Router::tag_href(tag)); + if y0 >= area.y.saturating_add(area.height.saturating_sub(1)) { + break; + } + let span = CellSpan::new(area.x, area.x.saturating_add(area.width), y0, y1); + let href = Router::tag_href(tag); + ctx.hits.add(span, href.clone()); + ctx.nav_items.push(href); if ctx.mouse.hits_rect(area) && ctx.mouse.hits(span) { hovered = Some(index); } @@ -74,16 +95,29 @@ fn render_tag_list(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { } } let mut tags_state = ListState::default(); - tags_state.select(hovered.or(selected)); + tags_state.select(hovered.or(ctx.list_selected).or(selected)); frame.render_stateful_widget(tag_list, area, &mut tags_state); } +fn filtered_posts(ctx: &FrameCtx<'_>) -> Vec { + let mut posts = content::posts(ctx.router.slug()); + if !ctx.filter.is_empty() { + let needle = ctx.filter.to_lowercase(); + posts.retain(|post| post.title.to_lowercase().contains(&needle)); + } + posts +} + fn render_post_list(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { - let posts = content::posts(ctx.router.slug()); + let posts = filtered_posts(ctx); let inner_width = area.width.saturating_sub(2); let labels: Vec = if posts.is_empty() { - vec!["(no posts)".to_string()] + vec![if ctx.filter.is_empty() { + status_placeholder() + } else { + "(no matches)".to_string() + }] } else { posts .iter() @@ -91,33 +125,40 @@ fn render_post_list(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { .collect() }; + let title = if ctx.filter_open { + format!("{{ {} }} /{}_", ctx.router.title(), ctx.filter) + } else if !ctx.filter.is_empty() { + format!("{{ {} }} /{}", ctx.router.title(), ctx.filter) + } else { + format!("{{ {} }}", ctx.router.title()) + }; + let body_block = Block::bordered() - .title(format!("{{ {} }}", ctx.router.title())) + .title(title) .title_alignment(Alignment::Center) .border_type(BorderType::Plain); let post_list = List::new(labels) .block(body_block) .bg(theme::BG) - .highlight_style( - Style::new() - .fg(theme::HOVER) - .add_modifier(Modifier::REVERSED), - ) - .highlight_spacing(HighlightSpacing::WhenSelected); + .highlight_style(Style::new().fg(theme::HOVER)) + .highlight_spacing(HighlightSpacing::Never); let mut posts_state = ListState::default(); + ctx.nav_items.clear(); if !posts.is_empty() { for (index, post) in posts.iter().enumerate() { let (y0, y1) = list_row_y(area, index, 1); - let x0 = area.x; - let x1 = area.x.saturating_add(area.width); - let span = CellSpan::new(x0, x1, y0, y1); + let span = CellSpan::new(area.x, area.x.saturating_add(area.width), y0, y1); ctx.hits.add(span, post.href.clone()); + ctx.nav_items.push(post.href.clone()); if ctx.mouse.hits_rect(area) && ctx.mouse.hits(span) { posts_state.select(Some(index)); } } + if posts_state.selected().is_none() { + posts_state.select(ctx.list_selected); + } } frame.render_stateful_widget(post_list, area, &mut posts_state); @@ -127,7 +168,7 @@ fn render_category_posts(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Re let posts = content::posts(ctx.router.slug()); let inner_width = area.width.saturating_sub(2); let labels: Vec = if posts.is_empty() { - vec!["(no posts)".to_string()] + vec![status_placeholder()] } else { posts .iter() @@ -144,15 +185,17 @@ fn render_category_posts(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Re ) .bg(theme::BG) .highlight_style(Style::new().fg(theme::HOVER)) - .highlight_spacing(HighlightSpacing::WhenSelected); + .highlight_spacing(HighlightSpacing::Never); let selected_post = ctx.router.post(); let mut hovered = None; let mut selected = None; + ctx.nav_items.clear(); for (index, post) in posts.iter().enumerate() { let (y0, y1) = list_row_y(area, index, 1); let span = CellSpan::new(area.x, area.x.saturating_add(area.width), y0, y1); ctx.hits.add(span, post.href.clone()); + ctx.nav_items.push(post.href.clone()); if ctx.mouse.hits_rect(area) && ctx.mouse.hits(span) { hovered = Some(index); } @@ -161,13 +204,11 @@ fn render_category_posts(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Re } } let mut state = ListState::default(); - state.select(hovered.or(selected)); + state.select(hovered.or(ctx.list_selected).or(selected)); frame.render_stateful_widget(list, area, &mut state); } -const COPY_LABEL: &str = " Copy "; - -fn render_post_body(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { +fn render_post_body(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect, compact: bool) { let post_id = ctx.router.post().unwrap_or(""); let title = content::posts(ctx.router.slug()) .into_iter() @@ -175,40 +216,78 @@ fn render_post_body(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { .map(|post| post.title) .unwrap_or_else(|| "post".to_string()); - let outer = Block::bordered() + let mut outer = Block::bordered() .title(format!("{{ {title} }}")) .title_alignment(Alignment::Center) .border_type(BorderType::Plain) .fg(theme::FG) .bg(theme::BG); + if compact { + if let Some(back) = ctx.router.parent_href() { + let back_label = " back "; + let span = CellSpan::new( + area.x.saturating_add(1), + area.x.saturating_add(1 + back_label.len() as u16), + area.y, + area.y.saturating_add(1), + ); + ctx.hits.add(span, back); + outer = outer.title(Line::from(Span::styled( + back_label, + if ctx.mouse.hits(span) { + Style::new().fg(theme::HOVER) + } else { + Style::new().fg(theme::DIM) + }, + ))); + } + } let inner = outer.inner(area); frame.render_widget(outer, area); - let Some(segments) = content::post_segments(post_id) else { - ctx.report_scroll(0, inner.height); - frame.render_widget( - Paragraph::new("(loading…)").fg(theme::FG).bg(theme::BG), - inner, - ); - return; - }; - if segments.is_empty() { - ctx.report_scroll(0, inner.height); - frame.render_widget( - Paragraph::new("(no content)").fg(theme::FG).bg(theme::BG), - inner, - ); - return; + match content::post_state(post_id) { + None => { + ctx.report_scroll(0, inner.height); + frame.render_widget( + Paragraph::new("(loading…)").fg(theme::FG).bg(theme::BG), + inner, + ); + } + Some(Err(err)) => { + ctx.report_scroll(0, inner.height); + frame.render_widget( + Paragraph::new(format!("(failed to load)\n{err}\nretrying…")) + .fg(theme::FG) + .bg(theme::BG), + inner, + ); + } + Some(Ok(segments)) if segments.is_empty() => { + ctx.report_scroll(0, inner.height); + frame.render_widget( + Paragraph::new("(no content)").fg(theme::FG).bg(theme::BG), + inner, + ); + } + Some(Ok(segments)) => render_segments(ctx, frame, inner, &segments), } +} - let content_height = content_height(&segments); - let viewport = inner.height; - ctx.report_scroll(content_height, viewport); - let offset = ctx.scroll.min(content_height.saturating_sub(viewport)); +fn render_segments( + ctx: &mut FrameCtx<'_>, + frame: &mut Frame<'_>, + inner: Rect, + segments: &[PostSegment], +) { let body_area = Rect { width: inner.width.saturating_sub(1), ..inner }; + let text_width = body_area.width; + let content_height = content_height(segments, text_width); + let viewport = inner.height; + ctx.report_scroll(content_height, viewport); + let offset = ctx.scroll.min(content_height.saturating_sub(viewport)); let mut cursor = 0_u16; for (index, segment) in segments.iter().enumerate() { @@ -216,24 +295,25 @@ fn render_post_body(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { cursor = cursor.saturating_add(1); } let content_y = cursor; - let height = segment_height(segment); + let height = segment_height(segment, text_width); cursor = cursor.saturating_add(height); let Some(dest) = visible_rect(body_area, content_y, height, offset, viewport) else { continue; }; + let skip = offset.saturating_sub(content_y); match segment { PostSegment::Text(text) => { frame.render_widget( Paragraph::new(text.as_str()) .wrap(Wrap { trim: false }) - .scroll((offset.saturating_sub(content_y), 0)) + .scroll((skip, 0)) .fg(theme::FG) .bg(theme::BG), dest, ); } PostSegment::Code(code) => { - render_code_block(ctx, frame, dest, code); + render_code_block(ctx, frame, dest, code, skip); } } } @@ -249,13 +329,13 @@ fn render_post_body(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { } } -fn content_height(segments: &[PostSegment]) -> u16 { +fn content_height(segments: &[PostSegment], width: u16) -> u16 { let mut height = 0_u16; for (index, segment) in segments.iter().enumerate() { if index > 0 { height = height.saturating_add(1); } - height = height.saturating_add(segment_height(segment)); + height = height.saturating_add(segment_height(segment, width)); } height } @@ -282,20 +362,28 @@ fn visible_rect( }) } -fn segment_height(segment: &PostSegment) -> u16 { +fn segment_height(segment: &PostSegment, width: u16) -> u16 { match segment { - PostSegment::Text(text) => text.split('\n').count().max(1) as u16, - PostSegment::Code(code) => (code.split('\n').count().max(1) as u16).saturating_add(2), + PostSegment::Text(text) => wrapped_rows(text, width.max(1)), + PostSegment::Code(code) => { + wrapped_rows(code, width.saturating_sub(2).max(1)).saturating_add(2) + } } } -fn render_code_block(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect, code: &str) { - let sample = theme::CODE_STYLE_SAMPLE.unwrap_or(1); - let style = theme::code_block_style(sample); +fn render_code_block( + ctx: &mut FrameCtx<'_>, + frame: &mut Frame<'_>, + area: Rect, + code: &str, + skip: u16, +) { + let style = theme::code_block_style(); + let copy_label = if ctx.copied { COPIED_LABEL } else { COPY_LABEL }; let copy_hovered = copy_hit_span(area) .map(|span| ctx.mouse.hits(span)) .unwrap_or(false); - let copy_style = if copy_hovered { + let copy_style = if copy_hovered || ctx.copied { Style::new().fg(theme::BG).bg(theme::HOVER) } else { Style::new() @@ -307,16 +395,20 @@ fn render_code_block(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect, .border_type(BorderType::Plain) .border_style(style) .title("code") - .title(Line::from(Span::styled(COPY_LABEL, copy_style)).right_aligned()) + .title(Line::from(Span::styled(copy_label, copy_style)).right_aligned()) .style(style); - if let Some(span) = copy_hit_span(area) { - ctx.hits.add_copy(span, code.to_string()); + if skip == 0 { + if let Some(span) = copy_hit_span(area) { + ctx.hits.add_copy(span, code.to_string()); + } } + let inner_skip = skip.saturating_sub(1); frame.render_widget( Paragraph::new(code) .wrap(Wrap { trim: false }) + .scroll((inner_skip, 0)) .style(style) .block(block), area, @@ -350,16 +442,11 @@ fn format_post_label(title: &str, width: u16) -> String { } } -fn display_width(text: &str) -> usize { - text.chars() - .map(|ch| if ch.is_ascii() { 1 } else { 2 }) - .sum() -} - #[cfg(test)] mod tests { - use super::format_post_label; + use super::{format_post_label, segment_height}; use crate::content::split_trailing_date; + use crate::module::notion::PostSegment; use ratatui::layout::Rect; #[test] @@ -388,4 +475,10 @@ mod tests { assert_eq!(label.chars().count(), 40); assert!(!label.contains(" - ")); } + + #[test] + fn text_height_includes_wrap() { + let segment = PostSegment::Text("abcdefghij".into()); + assert_eq!(segment_height(&segment, 5), 2); + } } diff --git a/src/ui/intro.rs b/src/ui/intro.rs index e13b526..79dea95 100644 --- a/src/ui/intro.rs +++ b/src/ui/intro.rs @@ -1,10 +1,10 @@ use super::FrameCtx; -use crate::content::{self, RecentPost}; +use crate::content::{self, CatalogStatus, RecentPost}; use crate::mouse::CellSpan; -use crate::router::Route; use crate::theme; +use crate::width::{display_width, truncate_display}; use ratatui::buffer::Buffer; -use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; +use ratatui::layout::{Alignment, Rect}; use ratatui::style::{Modifier, Style, Stylize}; use ratatui::text::{Line, Span}; use ratatui::widgets::{ @@ -37,8 +37,10 @@ pub fn render(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { let inner = outer.inner(area); frame.render_widget(outer, area); + let status = content::catalog_status(); let posts = content::recent_posts(RECENT_LIMIT); - let content_h = content_height(posts.len()); + let recent_count = if posts.is_empty() { 1 } else { posts.len() }; + let content_h = content_height(recent_count); let viewport = inner.height; ctx.report_scroll(content_h, viewport); @@ -62,12 +64,16 @@ pub fn render(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { return; } + let view = ViewPort { + inner, + offset, + pad_top, + viewport, + }; let mut buf = Buffer::empty(Rect::new(0, 0, body_width, content_h.max(1))); Block::new().bg(theme::BG).render(*buf.area(), &mut buf); - paint_content(ctx, &mut buf, &posts, inner, offset, pad_top, viewport); - blit_visible( - &buf, frame, inner, offset, pad_top, body_width, content_h, viewport, - ); + paint_content(ctx, &mut buf, &posts, status, view); + blit_visible(&buf, frame, view, body_width, content_h); if scrollable { let mut state = ScrollbarState::new(content_h.saturating_sub(viewport) as usize) @@ -98,14 +104,20 @@ fn content_height(post_count: usize) -> u16 { .saturating_add(FOOTER_HEIGHT) } -fn paint_content( - ctx: &mut FrameCtx<'_>, - buf: &mut Buffer, - posts: &[RecentPost], +#[derive(Clone, Copy)] +struct ViewPort { inner: Rect, offset: u16, pad_top: u16, viewport: u16, +} + +fn paint_content( + ctx: &mut FrameCtx<'_>, + buf: &mut Buffer, + posts: &[RecentPost], + status: CatalogStatus, + view: ViewPort, ) { let width = buf.area().width; let mut y = 0_u16; @@ -141,7 +153,6 @@ fn paint_content( .render(Rect::new(0, y, width, TAGLINE_HEIGHT), buf); y = y.saturating_add(TAGLINE_HEIGHT); - y = y.saturating_add(NULL_HEIGHT); Paragraph::new("{ recent posts }") @@ -152,14 +163,19 @@ fn paint_content( y = y.saturating_add(HEADING_HEIGHT); if posts.is_empty() { - Paragraph::new("no posts yet") + let empty = match status { + CatalogStatus::Loading => "loading posts…", + CatalogStatus::Error(_) => "failed to load posts", + CatalogStatus::Ready => "no posts yet", + }; + Paragraph::new(empty) .alignment(Alignment::Center) .fg(theme::DIM) .bg(theme::BG) .render(Rect::new(0, y, width, 1), buf); y = y.saturating_add(1); } else { - y = paint_recent_posts(ctx, buf, posts, y, width, inner, offset, pad_top, viewport); + y = paint_recent_posts(ctx, buf, posts, y, width, view); } y = y.saturating_add(SECTION_GAP); @@ -173,6 +189,22 @@ fn paint_content( .alignment(Alignment::Center) .bg(theme::BG) .render(Rect::new(0, y, width, FOOTER_HEIGHT), buf); + add_footer_hits(ctx, y, width, view); +} + +const FOOTER_TEXT: &str = "hover [Blog] or [About] · click to enter"; + +fn add_footer_hits(ctx: &mut FrameCtx<'_>, y: u16, width: u16, view: ViewPort) { + let total = display_width(FOOTER_TEXT) as u16; + let start = width.saturating_sub(total) / 2; + let blog = Rect::new(start.saturating_add(6), y, 6, 1); + let about = Rect::new(start.saturating_add(16), y, 7, 1); + if let Some(span) = screen_span(blog, view) { + ctx.hits.add(span, "/blog"); + } + if let Some(span) = screen_span(about, view) { + ctx.hits.add(span, "/about"); + } } fn paint_recent_posts( @@ -181,10 +213,7 @@ fn paint_recent_posts( posts: &[RecentPost], mut y: u16, width: u16, - inner: Rect, - offset: u16, - pad_top: u16, - viewport: u16, + view: ViewPort, ) -> u16 { let box_width = POST_BOX_MAX_WIDTH .min(width.saturating_sub(2)) @@ -204,10 +233,7 @@ fn paint_recent_posts( &post.href, &post.title, &post_subtitle(post, text_width), - inner, - offset, - pad_top, - viewport, + view, ); y = y.saturating_add(POST_BOX_HEIGHT); } @@ -221,12 +247,9 @@ fn paint_hit_card( href: &str, title: &str, subtitle: &str, - inner: Rect, - offset: u16, - pad_top: u16, - viewport: u16, + view: ViewPort, ) { - let hovered = if let Some(span) = screen_span(area, inner, offset, pad_top, viewport) { + let hovered = if let Some(span) = screen_span(area, view) { ctx.hits.add(span, href); ctx.mouse.hits(span) } else { @@ -235,59 +258,48 @@ fn paint_hit_card( paint_card(buf, area, hovered, title, subtitle); } -fn screen_span( - content: Rect, - inner: Rect, - offset: u16, - pad_top: u16, - viewport: u16, -) -> Option { - let start = content.y.max(offset); +fn screen_span(content: Rect, view: ViewPort) -> Option { + let start = content.y.max(view.offset); let end = content .y .saturating_add(content.height) - .min(offset.saturating_add(viewport)); + .min(view.offset.saturating_add(view.viewport)); if end <= start || content.width == 0 { return None; } Some(CellSpan::new( - inner.x.saturating_add(content.x), - inner + view.inner.x.saturating_add(content.x), + view.inner .x .saturating_add(content.x) .saturating_add(content.width), - inner + view.inner .y - .saturating_add(pad_top) - .saturating_add(start.saturating_sub(offset)), - inner + .saturating_add(view.pad_top) + .saturating_add(start.saturating_sub(view.offset)), + view.inner .y - .saturating_add(pad_top) - .saturating_add(end.saturating_sub(offset)), + .saturating_add(view.pad_top) + .saturating_add(end.saturating_sub(view.offset)), )) } -fn blit_visible( - src: &Buffer, - frame: &mut Frame<'_>, - inner: Rect, - offset: u16, - pad_top: u16, - width: u16, - content_h: u16, - viewport: u16, -) { +fn blit_visible(src: &Buffer, frame: &mut Frame<'_>, view: ViewPort, width: u16, content_h: u16) { let dest = frame.buffer_mut(); - let rows = content_h.saturating_sub(offset).min(viewport); + let rows = content_h.saturating_sub(view.offset).min(view.viewport); for row in 0..rows { - let dest_y = inner.y.saturating_add(pad_top).saturating_add(row); - if dest_y >= inner.y.saturating_add(inner.height) { + let dest_y = view + .inner + .y + .saturating_add(view.pad_top) + .saturating_add(row); + if dest_y >= view.inner.y.saturating_add(view.inner.height) { break; } - let src_y = offset.saturating_add(row); + let src_y = view.offset.saturating_add(row); for x in 0..width { - let dest_x = inner.x.saturating_add(x); - if dest_x >= inner.x.saturating_add(inner.width) { + let dest_x = view.inner.x.saturating_add(x); + if dest_x >= view.inner.x.saturating_add(view.inner.width) { break; } dest[(dest_x, dest_y)] = src[(x, src_y)].clone(); @@ -316,7 +328,7 @@ fn paint_card(buf: &mut Buffer, area: Rect, hovered: bool, title: &str, subtitle } let accent = if hovered { theme::HOVER } else { theme::DIM }; let title_style = if hovered { - Style::new().fg(theme::HOVER).add_modifier(Modifier::BOLD) + Style::new().fg(theme::HOVER) } else { Style::new().fg(theme::FG) }; @@ -346,50 +358,15 @@ fn paint_card(buf: &mut Buffer, area: Rect, hovered: bool, title: &str, subtitle .render(area, buf); } -fn truncate_display(text: &str, width: usize) -> String { - if width == 0 { - return String::new(); - } - if display_width(text) <= width { - return text.to_string(); - } - if width == 1 { - return "…".to_string(); - } - let budget = width.saturating_sub(1); - let mut out = String::new(); - let mut used = 0; - for ch in text.chars() { - let ch_width = char_width(ch); - if used + ch_width > budget { - break; - } - out.push(ch); - used += ch_width; - } - out.push('…'); - out -} - -fn display_width(text: &str) -> usize { - text.chars().map(char_width).sum() -} - -fn char_width(ch: char) -> usize { - if ch.is_ascii() || ch == '…' { - 1 - } else { - 2 - } -} - #[cfg(test)] mod tests { - use super::{display_width, render, truncate_display}; - use crate::module::scraper::{self, ContentPage, TagSection}; + use super::render; + use crate::module::notion::{ContentPage, TagSection}; + use crate::module::scraper; use crate::mouse::{HitMap, MouseState}; use crate::router::Router; use crate::ui::FrameCtx; + use crate::width::{display_width, truncate_display}; use ratatui::{backend::TestBackend, Terminal}; fn buffer_text(terminal: &Terminal) -> String { @@ -414,12 +391,18 @@ mod tests { let mut metrics = (0_u16, 0_u16); terminal .draw(|frame| { + let mut nav_items = Vec::new(); let mut ctx = FrameCtx { router: &router, mouse: &mouse, hits: &mut hits, scroll, scroll_metrics: Some(&mut metrics), + list_selected: None, + nav_items: &mut nav_items, + filter: "", + filter_open: false, + copied: false, }; render(&mut ctx, frame, frame.area()); }) @@ -498,9 +481,10 @@ mod tests { assert!(bottom.contains("click to enter")); assert!(bottom.contains("post2")); assert!(!bottom.contains("Dongju")); - assert!(top_hits.hrefs().contains(&"/blog")); + assert!(top_hits.hrefs().contains(&"/blog/linux/6")); assert!(bottom_hits.hrefs().contains(&"/blog/linux/2")); - assert!(!bottom_hits.hrefs().contains(&"/blog")); + assert!(bottom_hits.hrefs().contains(&"/blog")); + assert!(bottom_hits.hrefs().contains(&"/about")); } #[test] diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 728ab53..e756089 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -10,7 +10,7 @@ use crate::theme; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::Text; -use ratatui::widgets::{Block, BorderType, Paragraph}; +use ratatui::widgets::{Block, BorderType, Paragraph, Wrap}; use ratatui::Frame; /// Per-frame view of app state that widgets may read or write. @@ -20,6 +20,11 @@ pub struct FrameCtx<'a> { pub hits: &'a mut HitMap, pub scroll: u16, pub scroll_metrics: Option<&'a mut (u16, u16)>, + pub list_selected: Option, + pub nav_items: &'a mut Vec, + pub filter: &'a str, + pub filter_open: bool, + pub copied: bool, } impl FrameCtx<'_> { @@ -51,11 +56,31 @@ fn page_panel<'a>(title: &str, text: impl Into>) -> Paragraph<'a> { .title_alignment(Alignment::Center) .border_type(BorderType::Plain), ) + .wrap(Wrap { trim: false }) .fg(theme::FG) .bg(theme::BG) - .centered() } fn render_page(frame: &mut Frame<'_>, area: Rect, title: &str, text: impl Into>) { - frame.render_widget(page_panel(title, text), area); + frame.render_widget(page_panel(title, text).centered(), area); +} + +fn render_scrolling_page( + ctx: &mut FrameCtx<'_>, + frame: &mut Frame<'_>, + area: Rect, + title: &str, + text: &str, + centered: bool, +) { + let inner_h = area.height.saturating_sub(2); + let inner_w = area.width.saturating_sub(2); + let content_h = crate::width::wrapped_rows(text, inner_w.max(1)); + ctx.report_scroll(content_h, inner_h); + let offset = ctx.scroll.min(content_h.saturating_sub(inner_h)); + let mut paragraph = page_panel(title, text.to_string()).scroll((offset, 0)); + if centered { + paragraph = paragraph.centered(); + } + frame.render_widget(paragraph, area); } diff --git a/src/ui/not_found.rs b/src/ui/not_found.rs index 0b23912..c1b911f 100644 --- a/src/ui/not_found.rs +++ b/src/ui/not_found.rs @@ -2,7 +2,7 @@ use super::{render_page, FrameCtx}; use ratatui::layout::Rect; use ratatui::Frame; -const BODY: &str = r#" +const BODY_WIDE: &str = r#" @@ -25,6 +25,18 @@ const BODY: &str = r#" +=======================================================================================+ "#; -pub fn render(ctx: &FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { - render_page(frame, area, &ctx.router.title(), BODY); +const BODY_NARROW: &str = r#" + 404 + not found + + [Intro] [Blog] [About] +"#; + +pub fn render(ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, area: Rect) { + let body = if area.width < 90 { + BODY_NARROW + } else { + BODY_WIDE + }; + render_page(frame, area, &ctx.router.title(), body); } diff --git a/src/width.rs b/src/width.rs new file mode 100644 index 0000000..043b030 --- /dev/null +++ b/src/width.rs @@ -0,0 +1,83 @@ +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +/// Terminal display width of `text` (CJK = 2, ASCII = 1). +pub fn display_width(text: &str) -> usize { + UnicodeWidthStr::width(text) +} + +pub fn char_width(ch: char) -> usize { + UnicodeWidthChar::width(ch).unwrap_or(0) +} + +/// Truncate `text` to `width` columns, adding an ellipsis when needed. +pub fn truncate_display(text: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + if display_width(text) <= width { + return text.to_string(); + } + if width == 1 { + return "…".to_string(); + } + let budget = width.saturating_sub(1); + let mut out = String::new(); + let mut used = 0; + for ch in text.chars() { + let cw = char_width(ch); + if used + cw > budget { + break; + } + out.push(ch); + used += cw; + } + out.push('…'); + out +} + +/// Rows a paragraph occupies when wrapped to `width` columns (trim: false). +pub fn wrapped_rows(text: &str, width: u16) -> u16 { + let width = usize::from(width.max(1)); + let mut rows = 0_u16; + for line in text.split('\n') { + rows = rows.saturating_add(wrapped_line_rows(line, width)); + } + rows.max(1) +} + +fn wrapped_line_rows(line: &str, width: usize) -> u16 { + if line.is_empty() { + return 1; + } + let mut used = 0; + let mut rows = 1_u16; + for ch in line.chars() { + let cw = char_width(ch).max(1); + if used + cw > width && used > 0 { + rows = rows.saturating_add(1); + used = cw.min(width); + } else { + used = used.saturating_add(cw); + } + } + rows +} + +#[cfg(test)] +mod tests { + use super::{display_width, truncate_display, wrapped_rows}; + + #[test] + fn truncate_keeps_display_width() { + assert_eq!(truncate_display("abcdef", 10), "abcdef"); + assert_eq!(truncate_display("abcdef", 4), "abc…"); + assert_eq!(display_width(&truncate_display("한글제목", 5)), 5); + } + + #[test] + fn wrap_counts_soft_breaks() { + assert_eq!(wrapped_rows("abcd", 2), 2); + assert_eq!(wrapped_rows("ab\ncd", 10), 2); + assert_eq!(wrapped_rows("", 10), 1); + } +}