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 0000000..f8b63fb Binary files /dev/null and b/assets/fonts/FiraCode-Regular.woff2 differ diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt new file mode 100644 index 0000000..805e0b3 --- /dev/null +++ b/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2014, The Fira Code Project Authors (https://github.com/tonsky/FiraCode) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..f3982c4 --- /dev/null +++ b/favicon.svg @@ -0,0 +1,4 @@ + + + >_ + 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); + } +}