diff --git a/.gitignore b/.gitignore index 16d54bb..a1973fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,11 @@ -# build output -dist/ -# generated types -.astro/ +.codex/ +.vscode/ +.windsurf/ -# dependencies -node_modules/ +frontend/.astro/ +frontend/dist/ +frontend/node_modules/ -# logs -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - - -# environment variables -.env -.env.production - -# macOS-specific files -.DS_Store - -# jetbrains setting folder -.idea/ +backend/target/ +backend/.loco-start.err.log +backend/.loco-start.out.log diff --git a/README.md b/README.md index 87b813a..506429b 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,35 @@ -# Astro Starter Kit: Minimal +# termi-blog -```sh -npm create astro@latest -- --template minimal -``` +Monorepo for the Termi blog system. -> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! - -## 🚀 Project Structure - -Inside of your Astro project, you'll see the following folders and files: +## Structure ```text -/ -├── public/ -├── src/ -│ └── pages/ -│ └── index.astro -└── package.json +. +├─ frontend/ # Astro blog frontend +├─ backend/ # Loco.rs backend and admin +├─ .codex/ # Codex workspace config +└─ .vscode/ # Editor workspace config ``` -Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. +## Run -There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. +### Frontend -Any static assets, like images, can be placed in the `public/` directory. +```powershell +cd frontend +npm install +npm run dev +``` -## 🧞 Commands +### Backend -All commands are run from the root of the project, from a terminal: +```powershell +cd backend +$env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development" +cargo loco start 2>&1 +``` -| Command | Action | -| :------------------------ | :----------------------------------------------- | -| `npm install` | Installs dependencies | -| `npm run dev` | Starts local dev server at `localhost:4321` | -| `npm run build` | Build your production site to `./dist/` | -| `npm run preview` | Preview your build locally, before deploying | -| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | -| `npm run astro -- --help` | Get help using the Astro CLI | +## Repo Name -## 👀 Want to learn more? - -Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). +Recommended repository name: `termi-blog` diff --git a/astro.config.mjs b/astro.config.mjs deleted file mode 100644 index e762ba5..0000000 --- a/astro.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-check -import { defineConfig } from 'astro/config'; - -// https://astro.build/config -export default defineConfig({}); diff --git a/backend/.cargo/config.toml b/backend/.cargo/config.toml new file mode 100644 index 0000000..9c77464 --- /dev/null +++ b/backend/.cargo/config.toml @@ -0,0 +1,11 @@ +[alias] +loco = "run --" +loco-tool = "run --bin tool --" + + +playground = "run --example playground" + +# https://github.com/rust-lang/rust/issues/141626 +# (can be removed once link.exe is fixed) +[target.x86_64-pc-windows-msvc] +linker = "rust-lld" diff --git a/backend/.github/workflows/ci.yaml b/backend/.github/workflows/ci.yaml new file mode 100644 index 0000000..75ba8a5 --- /dev/null +++ b/backend/.github/workflows/ci.yaml @@ -0,0 +1,102 @@ +name: CI +on: + push: + branches: + - master + - main + pull_request: + +env: + RUST_TOOLCHAIN: stable + TOOLCHAIN_PROFILE: minimal + +jobs: + rustfmt: + name: Check Style + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + components: rustfmt + - name: Run cargo fmt + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check + + clippy: + name: Run Clippy + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + - name: Run cargo clippy + uses: actions-rs/cargo@v1 + with: + command: clippy + args: --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W rust-2018-idioms + + test: + name: Run Tests + runs-on: ubuntu-latest + + permissions: + contents: read + + services: + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - "6379:6379" + postgres: + image: postgres + env: + POSTGRES_DB: postgres_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + # Set health checks to wait until postgres has started + options: --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + - name: Run cargo test + uses: actions-rs/cargo@v1 + with: + command: test + args: --all-features --all + env: + REDIS_URL: redis://localhost:${{job.services.redis.ports[6379]}} + DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres_test + diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..510f9fb --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,20 @@ +**/config/local.yaml +**/config/*.local.yaml +**/config/production.yaml + +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# include cargo lock +!Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +*.sqlite +*.sqlite-* \ No newline at end of file diff --git a/backend/.rustfmt.toml b/backend/.rustfmt.toml new file mode 100644 index 0000000..d862e08 --- /dev/null +++ b/backend/.rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 100 +use_small_heuristics = "Default" diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..5dde918 --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,5992 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto-future" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "serde_html_form", + "serde_path_to_error", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "axum-test" +version = "17.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb1dfb84bd48bad8e4aa1acb82ed24c2bb5e855b659959b4e03b4dca118fcac" +dependencies = [ + "anyhow", + "assert-json-diff", + "auto-future", + "axum", + "bytes", + "bytesize", + "cookie", + "http", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower 0.5.3", + "url", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "backtrace_printer" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d28de81c708c843640982b66573df0f0168d87e42854b563971f326745aab7" +dependencies = [ + "btparse-stable", + "colored 2.2.0", + "regex", + "thiserror 1.0.69", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "btparse-stable" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d75b8252ed252f881d1dc4482ae3c3854df6ee8183c1906bac50ff358f4f89f" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byte-unit" +version = "4.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da78b32057b8fdfc352504708feeba7216dcd65a2c9ab02978cbd288d1279b6c" +dependencies = [ + "serde", + "utf8-width", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07" +dependencies = [ + "chrono", + "nom 7.1.3", + "once_cell", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "cruet" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113a9e83d8f614be76de8df1f25bf9d0ea6e85ea573710a3d3f3abe1438ae49c" +dependencies = [ + "once_cell", + "regex", +] + +[[package]] +name = "cruet" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6132609543972496bc97b1e01f1ce6586768870aeb4cabeb3385f4e05b5caead" +dependencies = [ + "once_cell", + "regex", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "duct" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e66e9c0c03d094e1a0ba1be130b849034aa80c3a2ab8ee94316bc809f3fa684" +dependencies = [ + "libc", + "os_pipe", + "shared_child", + "shared_thread", +] + +[[package]] +name = "duct_sh" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8139179d1d133ab7153920ba3812915b17c61e2514a6f98b1fd03f2c07668d1" +dependencies = [ + "duct", +] + +[[package]] +name = "ego-tree" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c6ba7d4eec39eaa9ab24d44a0e73a7949a1095a8b3f3abb11eddf27dbb56a53" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "english-to-cron" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3d16f6dc9dc43a9a2fd5bce09b6cf8df250dcf77cffdaa66be21c527e2d05c" +dependencies = [ + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.18", +] + +[[package]] +name = "fluent-template-macros" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "748050b3fb6fd97b566aedff8e9e021389c963e73d5afbeb92752c2b8d686c6c" +dependencies = [ + "flume", + "ignore", + "proc-macro2", + "quote", + "syn 2.0.117", + "unic-langid", +] + +[[package]] +name = "fluent-templates" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56264446a01f404469aef9cc5fd4a4d736f68a0f52482bf6d1a54d6e9bbd9476" +dependencies = [ + "fluent-bundle", + "fluent-langneg", + "fluent-syntax", + "fluent-template-macros", + "flume", + "heck 0.5.0", + "ignore", + "intl-memoizer", + "log", + "serde_json", + "tera", + "thiserror 2.0.18", + "unic-langid", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +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 = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.11.0", + "ignore", + "walkdir", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[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 = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[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 = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inherent" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "insta" +version = "1.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f40e41efb5f592d3a0764f818e2f08e5e21c4f368126f74f37c81bd4af7a0c6" +dependencies = [ + "console", + "once_cell", + "pest", + "pest_derive", + "regex", + "serde", + "similar", + "tempfile", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "iri-string" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "async-trait", + "base64", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2 0.6.3", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.6", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "loco-gen" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d679b1be38125acaf885f942c2a532890e791c51969373d318754f95ccec7e57" +dependencies = [ + "chrono", + "clap", + "colored 3.1.1", + "cruet 0.14.0", + "duct", + "heck 0.4.1", + "include_dir", + "regex", + "rrgen", + "serde", + "serde_json", + "tera", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "loco-rs" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "429275ae7bba093bd98f121715670a7164e965969d6f78b55d2c223b96d16025" +dependencies = [ + "argon2", + "async-trait", + "axum", + "axum-extra", + "axum-test", + "backtrace_printer", + "byte-unit", + "bytes", + "chrono", + "clap", + "colored 3.1.1", + "cruet 0.13.3", + "dashmap", + "duct", + "duct_sh", + "english-to-cron", + "futures-util", + "heck 0.4.1", + "include_dir", + "ipnetwork", + "jsonwebtoken", + "lettre", + "loco-gen", + "moka", + "notify", + "opendal", + "rand 0.9.2", + "redis", + "regex", + "scraper", + "sea-orm", + "sea-orm-migration", + "semver", + "serde", + "serde_json", + "serde_variant", + "serde_yaml", + "sqlx", + "tera", + "thiserror 1.0.69", + "tokio", + "tokio-cron-scheduler", + "tokio-util", + "toml", + "tower 0.4.13", + "tower-http", + "tracing", + "tracing-appender", + "tracing-subscriber", + "tree-fs", + "ulid", + "uuid", + "validator", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "migration" +version = "0.1.0" +dependencies = [ + "loco-rs", + "sea-orm-migration", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opendal" +version = "0.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42afda58fa2cf50914402d132cc1caacff116a85d10c72ab2082bb7c50021754" +dependencies = [ + "anyhow", + "backon", + "base64", + "bytes", + "chrono", + "futures", + "getrandom 0.2.17", + "http", + "http-body", + "log", + "md-5", + "percent-encoding", + "quick-xml", + "reqwest", + "serde", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pgvector" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b" +dependencies = [ + "serde", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.8+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", + "yansi", +] + +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "redis" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc1ea653e0b2e097db3ebb5b7f678be339620b8041f66b30a308c1d45d36a7f" +dependencies = [ + "bytes", + "cfg-if", + "combine", + "futures-util", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-util", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "reserve-port" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94070964579245eb2f76e62a7668fe87bd9969ed6c41256f3bf614e3323dd3cc" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rrgen" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee7a7ede035354391a37e42aa4935b3d8921f0ded896d2ce44bb1a3b6dd76bab" +dependencies = [ + "cruet 0.13.3", + "fs-err", + "glob", + "heck 0.4.1", + "regex", + "serde", + "serde_json", + "serde_regex", + "serde_yaml", + "tera", + "thiserror 1.0.69", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rstest" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.117", + "unicode-ident", +] + +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "rand 0.9.2", + "thiserror 2.0.18", +] + +[[package]] +name = "rust_decimal" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0e749d29b2064585327af5038a5a8eb73aeebad4a3472e83531a436563f7208" +dependencies = [ + "ahash 0.8.12", + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "indexmap", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + +[[package]] +name = "sea-bae" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f694a6ab48f14bc063cfadff30ab551d3c7e46d8f81836c51989d548f44a2a25" +dependencies = [ + "heck 0.4.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sea-orm" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d945f62558fac19e5988680d2fdf747b734c2dbc6ce2cb81ba33ed8dde5b103" +dependencies = [ + "async-stream", + "async-trait", + "bigdecimal", + "chrono", + "derive_more 2.1.1", + "futures-util", + "log", + "ouroboros", + "pgvector", + "rust_decimal", + "sea-orm-macros", + "sea-query", + "sea-query-binder", + "serde", + "serde_json", + "sqlx", + "strum", + "thiserror 2.0.18", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sea-orm-cli" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94492e2ab6c045b4cc38013809ce255d14c3d352c9f0d11e6b920e2adc948ad" +dependencies = [ + "chrono", + "clap", + "dotenvy", + "glob", + "regex", + "sea-schema", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "sea-orm-macros" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c2e64a50a9cc8339f10a27577e10062c7f995488e469f2c95762c5ee847832" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "sea-bae", + "syn 2.0.117", + "unicode-ident", +] + +[[package]] +name = "sea-orm-migration" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7315c0cadb7e60fb17ee2bb282aa27d01911fc2a7e5836ec1d4ac37d19250bb4" +dependencies = [ + "async-trait", + "clap", + "dotenvy", + "sea-orm", + "sea-orm-cli", + "sea-schema", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sea-query" +version = "0.32.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" +dependencies = [ + "bigdecimal", + "chrono", + "inherent", + "ordered-float", + "rust_decimal", + "sea-query-derive", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "sea-query-binder" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" +dependencies = [ + "bigdecimal", + "chrono", + "rust_decimal", + "sea-query", + "serde_json", + "sqlx", + "time", + "uuid", +] + +[[package]] +name = "sea-query-derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab" +dependencies = [ + "darling", + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.117", + "thiserror 2.0.18", +] + +[[package]] +name = "sea-schema" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2239ff574c04858ca77485f112afea1a15e53135d3097d0c86509cef1def1338" +dependencies = [ + "futures", + "sea-query", + "sea-query-binder", + "sea-schema-derive", + "sqlx", +] + +[[package]] +name = "sea-schema-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdc8729c37fdbf88472f97fd470393089f997a909e535ff67c544d18cfccf0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "selectors" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" +dependencies = [ + "bitflags 2.11.0", + "cssparser", + "derive_more 0.99.20", + "fxhash", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_html_form" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_regex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +dependencies = [ + "regex", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_variant" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a0068df419f9d9b6488fdded3f1c818522cdea328e02ce9d9f147380265a432" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + +[[package]] +name = "shared_thread" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b86057fcb5423f5018e331ac04623e32d6b5ce85e33300f92c79a1973928b0" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bigdecimal", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rust_decimal", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bigdecimal", + "bitflags 2.11.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "rust_decimal", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bigdecimal", + "bitflags 2.11.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "num-bigint", + "once_cell", + "rand 0.8.5", + "rust_decimal", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[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.117", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "tera" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.5", + "regex", + "serde", + "serde_json", + "slug", + "unicode-segmentation", +] + +[[package]] +name = "termi-api" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "axum-extra", + "chrono", + "fluent-templates", + "include_dir", + "insta", + "loco-rs", + "migration", + "regex", + "rstest", + "sea-orm", + "serde", + "serde_json", + "serde_yaml", + "serial_test", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "unic-langid", + "uuid", + "validator", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-cron-scheduler" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2594dd7c2abbbafbb1c78d167fd10860dc7bd75f814cb051a1e0d3e796b9702" +dependencies = [ + "chrono", + "cron", + "num-derive", + "num-traits", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap", + "toml_datetime 1.1.0+spec-1.1.0", + "toml_parser", + "winnow 1.0.0", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags 2.11.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "tree-fs" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6115680fa5fdb99b4ff19c9c3217e75116d2bb0eae82458c4e1818be6a10c7" +dependencies = [ + "rand 0.9.2", + "serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.2", + "web-time", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn 2.0.117", + "unic-langid-impl", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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 = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "rand 0.10.0", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "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.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[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.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +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.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[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 = [ + "serde", + "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.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..f5b31e9 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,59 @@ +[workspace] + +[package] +name = "termi-api" +version = "0.1.0" +edition = "2021" +publish = false +default-run = "termi_api-cli" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[workspace.dependencies] +loco-rs = { version = "0.16" } + +[dependencies] +loco-rs = { workspace = true } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1" } +serde_yaml = { version = "0.9" } +tokio = { version = "1.45", default-features = false, features = [ + "rt-multi-thread", +] } +async-trait = { version = "0.1" } +axum = { version = "0.8", features = ["multipart"] } +tracing = { version = "0.1" } +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +regex = { version = "1.11" } +migration = { path = "migration" } +sea-orm = { version = "1.1", features = [ + "sqlx-sqlite", + "sqlx-postgres", + "runtime-tokio-rustls", + "macros", +] } +chrono = { version = "0.4" } +validator = { version = "0.20" } +uuid = { version = "1.6", features = ["v4"] } +include_dir = { version = "0.7" } +# view engine i18n +fluent-templates = { version = "0.13", features = ["tera"] } +unic-langid = { version = "0.9" } +# /view engine +axum-extra = { version = "0.10", features = ["form"] } +tower-http = { version = "0.6", features = ["cors"] } + +[[bin]] +name = "termi_api-cli" +path = "src/bin/main.rs" +required-features = [] +[[bin]] +name = "tool" +path = "src/bin/tool.rs" +required-features = [] + +[dev-dependencies] +loco-rs = { workspace = true, features = ["testing"] } +serial_test = { version = "3.1.1" } +rstest = { version = "0.25" } +insta = { version = "1.34", features = ["redactions", "yaml", "filters"] } diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..43b9bdd --- /dev/null +++ b/backend/README.md @@ -0,0 +1,58 @@ +# Welcome to Loco :train: + +[Loco](https://loco.rs) is a web and API framework running on Rust. + +This is the **SaaS starter** which includes a `User` model and authentication based on JWT. +It also include configuration sections that help you pick either a frontend or a server-side template set up for your fullstack server. + + +## Quick Start + +```sh +cargo loco start +``` + +```sh +$ cargo loco start +Finished dev [unoptimized + debuginfo] target(s) in 21.63s + Running `target/debug/myapp start` + + : + : + : + +controller/app_routes.rs:203: [Middleware] Adding log trace id + + ▄ ▀ + ▀ ▄ + ▄ ▀ ▄ ▄ ▄▀ + ▄ ▀▄▄ + ▄ ▀ ▀ ▀▄▀█▄ + ▀█▄ +▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█ + ██████ █████ ███ █████ ███ █████ ███ ▀█ + ██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄ + ██████ █████ ███ █████ █████ ███ ████▄ + ██████ █████ ███ █████ ▄▄▄ █████ ███ █████ + ██████ █████ ███ ████ ███ █████ ███ ████▀ + ▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + https://loco.rs + +environment: development + database: automigrate + logger: debug +compilation: debug + modes: server + +listening on http://localhost:5150 +``` + +## Full Stack Serving + +You can check your [configuration](config/development.yaml) to pick either frontend setup or server-side rendered template, and activate the relevant configuration sections. + + +## Getting help + +Check out [a quick tour](https://loco.rs/docs/getting-started/tour/) or [the complete guide](https://loco.rs/docs/getting-started/guide/). diff --git a/backend/assets/seeds/comments.yaml b/backend/assets/seeds/comments.yaml new file mode 100644 index 0000000..d2c4931 --- /dev/null +++ b/backend/assets/seeds/comments.yaml @@ -0,0 +1,48 @@ +- id: 1 + pid: 1 + author: "Alice" + email: "alice@example.com" + content: "Great introduction! Looking forward to more content." + approved: true + +- id: 2 + pid: 1 + author: "Bob" + email: "bob@example.com" + content: "The terminal UI looks amazing. Love the design!" + approved: true + +- id: 3 + pid: 2 + author: "Charlie" + email: "charlie@example.com" + content: "Thanks for the Rust tips! The ownership concept finally clicked for me." + approved: true + +- id: 4 + pid: 3 + author: "Diana" + email: "diana@example.com" + content: "Astro is indeed fast. I've been using it for my personal blog too." + approved: true + +- id: 5 + pid: 4 + author: "Eve" + email: "eve@example.com" + content: "The color palette you shared is perfect. Using it for my terminal theme now!" + approved: true + +- id: 6 + pid: 5 + author: "Frank" + email: "frank@example.com" + content: "Loco.rs looks promising. Might use it for my next project." + approved: false + +- id: 7 + pid: 2 + author: "Grace" + email: "grace@example.com" + content: "Would love to see more advanced Rust patterns in future posts." + approved: true diff --git a/backend/assets/seeds/friend_links.yaml b/backend/assets/seeds/friend_links.yaml new file mode 100644 index 0000000..50e9946 --- /dev/null +++ b/backend/assets/seeds/friend_links.yaml @@ -0,0 +1,38 @@ +- id: 1 + site_name: "Tech Blog Daily" + site_url: "https://techblog.example.com" + avatar_url: "https://techblog.example.com/avatar.png" + description: "Daily tech news and tutorials" + category: "tech" + status: "approved" + +- id: 2 + site_name: "Rustacean Station" + site_url: "https://rustacean.example.com" + avatar_url: "https://rustacean.example.com/logo.png" + description: "All things Rust programming" + category: "tech" + status: "approved" + +- id: 3 + site_name: "Design Patterns" + site_url: "https://designpatterns.example.com" + avatar_url: "https://designpatterns.example.com/icon.png" + description: "UI/UX design inspiration" + category: "design" + status: "approved" + +- id: 4 + site_name: "Code Snippets" + site_url: "https://codesnippets.example.com" + description: "Useful code snippets for developers" + category: "dev" + status: "pending" + +- id: 5 + site_name: "Web Dev Weekly" + site_url: "https://webdevweekly.example.com" + avatar_url: "https://webdevweekly.example.com/favicon.png" + description: "Weekly web development newsletter" + category: "dev" + status: "pending" diff --git a/backend/assets/seeds/posts.yaml b/backend/assets/seeds/posts.yaml new file mode 100644 index 0000000..75295c4 --- /dev/null +++ b/backend/assets/seeds/posts.yaml @@ -0,0 +1,191 @@ +- id: 1 + pid: 1 + title: "Welcome to Termi Blog" + slug: "welcome-to-termi" + content: | + # Welcome to Termi Blog + + This is the first post on our new blog built with Astro and Loco.rs backend. + + ## Features + + - 🚀 Fast performance with Astro + - 🎨 Terminal-style UI design + - 💬 Comments system + - 🔗 Friend links + - 🏷️ Tags and categories + + ## Code Example + + ```rust + fn main() { + println!("Hello, Termi!"); + } + ``` + + Stay tuned for more posts! + excerpt: "Welcome to our new blog built with Astro and Loco.rs backend." + category: "general" + published: true + pinned: true + tags: + - welcome + - astro + - loco-rs + +- id: 2 + pid: 2 + title: "Rust Programming Tips" + slug: "rust-programming-tips" + content: | + # Rust Programming Tips + + Here are some essential tips for Rust developers: + + ## 1. Ownership and Borrowing + + Understanding ownership is crucial in Rust. Every value has an owner, and there can only be one owner at a time. + + ## 2. Pattern Matching + + Use `match` expressions for exhaustive pattern matching: + + ```rust + match result { + Ok(value) => println!("Success: {}", value), + Err(e) => println!("Error: {}", e), + } + ``` + + ## 3. Error Handling + + Use `Result` and `Option` types effectively with the `?` operator. + + Happy coding! + excerpt: "Essential tips for Rust developers including ownership, pattern matching, and error handling." + category: "tech" + published: true + pinned: false + tags: + - rust + - programming + - tips + +- id: 3 + pid: 3 + title: "Building a Blog with Astro" + slug: "building-blog-with-astro" + content: | + # Building a Blog with Astro + + Astro is a modern static site generator that delivers lightning-fast performance. + + ## Why Astro? + + - **Zero JavaScript by default**: Ships less JavaScript to the client + - **Island Architecture**: Hydrate only interactive components + - **Framework Agnostic**: Use React, Vue, Svelte, or vanilla JS + - **Great DX**: Excellent developer experience with hot module replacement + + ## Getting Started + + ```bash + npm create astro@latest + cd my-astro-project + npm install + npm run dev + ``` + + ## Conclusion + + Astro is perfect for content-focused websites like blogs. + excerpt: "Learn why Astro is the perfect choice for building fast, content-focused blogs." + category: "tech" + published: true + pinned: false + tags: + - astro + - web-dev + - static-site + +- id: 4 + pid: 4 + title: "Terminal UI Design Principles" + slug: "terminal-ui-design" + content: | + # Terminal UI Design Principles + + Terminal-style interfaces are making a comeback in modern web design. + + ## Key Elements + + 1. **Monospace Fonts**: Use fonts like Fira Code, JetBrains Mono + 2. **Dark Themes**: Black or dark backgrounds with vibrant text colors + 3. **Command Prompts**: Use `$` or `>` as visual indicators + 4. **ASCII Art**: Decorative elements using text characters + 5. **Blinking Cursor**: The iconic terminal cursor + + ## Color Palette + + - Background: `#0d1117` + - Text: `#c9d1d9` + - Accent: `#58a6ff` + - Success: `#3fb950` + - Warning: `#d29922` + - Error: `#f85149` + + ## Implementation + + Use CSS to create the terminal aesthetic while maintaining accessibility. + excerpt: "Learn the key principles of designing beautiful terminal-style user interfaces." + category: "design" + published: true + pinned: false + tags: + - design + - terminal + - ui + +- id: 5 + pid: 5 + title: "Loco.rs Backend Framework" + slug: "loco-rs-framework" + content: | + # Introduction to Loco.rs + + Loco.rs is a web and API framework for Rust inspired by Rails. + + ## Features + + - **MVC Architecture**: Model-View-Controller pattern + - **SeaORM Integration**: Powerful ORM for database operations + - **Background Jobs**: Built-in job processing + - **Authentication**: Ready-to-use auth system + - **CLI Generator**: Scaffold resources quickly + + ## Quick Start + + ```bash + cargo install loco + loco new myapp + cd myapp + cargo loco start + ``` + + ## Why Loco.rs? + + - Opinionated but flexible + - Production-ready defaults + - Excellent documentation + - Active community + + Perfect for building APIs and web applications in Rust. + excerpt: "An introduction to Loco.rs, the Rails-inspired web framework for Rust." + category: "tech" + published: true + pinned: false + tags: + - rust + - loco-rs + - backend + - api diff --git a/backend/assets/seeds/reviews.yaml b/backend/assets/seeds/reviews.yaml new file mode 100644 index 0000000..73f63e9 --- /dev/null +++ b/backend/assets/seeds/reviews.yaml @@ -0,0 +1,59 @@ +- id: 1 + title: "塞尔达传说:王国之泪" + review_type: "game" + rating: 5 + review_date: "2024-03-20" + status: "completed" + description: "开放世界的巅峰之作,究极手能力带来无限创意空间" + tags: ["Switch", "开放世界", "冒险"] + cover: "🎮" + +- id: 2 + title: "进击的巨人" + review_type: "anime" + rating: 5 + review_date: "2023-11-10" + status: "completed" + description: "史诗级完结,剧情反转令人震撼" + tags: ["热血", "悬疑", "神作"] + cover: "🎭" + +- id: 3 + title: "赛博朋克 2077" + review_type: "game" + rating: 4 + review_date: "2024-01-15" + status: "completed" + description: "夜之城的故事,虽然首发有问题但后续更新很棒" + tags: ["PC", "RPG", "科幻"] + cover: "🎮" + +- id: 4 + title: "三体" + review_type: "book" + rating: 5 + review_date: "2023-08-05" + status: "completed" + description: "硬科幻巅峰,宇宙社会学的黑暗森林法则" + tags: ["科幻", "经典", "雨果奖"] + cover: "📚" + +- id: 5 + title: "星际穿越" + review_type: "movie" + rating: 5 + review_date: "2024-02-14" + status: "completed" + description: "诺兰神作,五维空间和黑洞的视觉奇观" + tags: ["科幻", "IMAX", "诺兰"] + cover: "🎬" + +- id: 6 + title: "博德之门3" + review_type: "game" + rating: 5 + review_date: "2024-04-01" + status: "in-progress" + description: "CRPG的文艺复兴,骰子决定命运" + tags: ["PC", "CRPG", "多人"] + cover: "🎮" diff --git a/backend/assets/seeds/tags.yaml b/backend/assets/seeds/tags.yaml new file mode 100644 index 0000000..b6b01ff --- /dev/null +++ b/backend/assets/seeds/tags.yaml @@ -0,0 +1,39 @@ +- id: 1 + name: "Welcome" + slug: "welcome" + +- id: 2 + name: "Astro" + slug: "astro" + +- id: 3 + name: "Rust" + slug: "rust" + +- id: 4 + name: "Programming" + slug: "programming" + +- id: 5 + name: "Tech" + slug: "tech" + +- id: 6 + name: "Design" + slug: "design" + +- id: 7 + name: "Terminal" + slug: "terminal" + +- id: 8 + name: "Loco.rs" + slug: "loco-rs" + +- id: 9 + name: "Backend" + slug: "backend" + +- id: 10 + name: "Web Dev" + slug: "web-dev" diff --git a/backend/assets/static/404.html b/backend/assets/static/404.html new file mode 100644 index 0000000..66e78fb --- /dev/null +++ b/backend/assets/static/404.html @@ -0,0 +1,3 @@ + +not found :-( + diff --git a/backend/assets/static/image.png b/backend/assets/static/image.png new file mode 100644 index 0000000..fa5a095 Binary files /dev/null and b/backend/assets/static/image.png differ diff --git a/backend/assets/static/index.html b/backend/assets/static/index.html new file mode 100644 index 0000000..a52000f --- /dev/null +++ b/backend/assets/static/index.html @@ -0,0 +1,11 @@ + + + + + + Redirecting... + + +

Redirecting to Admin Dashboard...

+ + diff --git a/backend/assets/views/admin/base.html b/backend/assets/views/admin/base.html new file mode 100644 index 0000000..f5652f2 --- /dev/null +++ b/backend/assets/views/admin/base.html @@ -0,0 +1,682 @@ + + + + + + {{ page_title | default(value="Termi Admin") }} · Termi Admin + + + + {% block body %} +
+ + +
+
+
+ Unified Admin +

{{ page_title | default(value="Termi Admin") }}

+

{{ page_description | default(value="统一处理后台数据与前台联调。") }}

+
+
+ {% for item in header_actions | default(value=[]) %} + + {{ item.label }} + + {% endfor %} + 退出后台 +
+
+ +
+ {% block main_content %}{% endblock %} +
+
+
+ {% endblock %} + + + {% block page_scripts %}{% endblock %} + + diff --git a/backend/assets/views/admin/categories.html b/backend/assets/views/admin/categories.html new file mode 100644 index 0000000..560ed0b --- /dev/null +++ b/backend/assets/views/admin/categories.html @@ -0,0 +1,85 @@ +{% extends "admin/base.html" %} + +{% block main_content %} +
+
+
+

新增分类

+
这里维护分类字典。文章 Markdown 导入时会优先复用这里的分类,不存在才自动创建。
+
+
+ +
+
+ + +
+
+ +
+
+
+ +
+
+
+

分类列表

+
分类名称会作为文章展示名称使用,文章数来自当前已同步的真实内容。
+
+
+ + {% if rows | length > 0 %} +
+ + + + + + + + + + + + {% for row in rows %} + + + + + + + + {% endfor %} + +
ID分类文章数最近文章操作
#{{ row.id }} +
+
+ + +
+
+ + API +
+
+
{{ row.count }} 篇 + {% if row.latest_frontend_url %} + {{ row.latest_title }} + {% else %} + {{ row.latest_title }} + {% endif %} + +
+ 前台分类页 + 前台筛选 +
+ +
+
+
+
+ {% else %} +
暂无分类数据。
+ {% endif %} +
+{% endblock %} diff --git a/backend/assets/views/admin/comments.html b/backend/assets/views/admin/comments.html new file mode 100644 index 0000000..dd925a4 --- /dev/null +++ b/backend/assets/views/admin/comments.html @@ -0,0 +1,63 @@ +{% extends "admin/base.html" %} + +{% block main_content %} +
+
+
+

评论队列

+
处理前台真实评论,并能一键跳到对应文章页核对展示。
+
+
+ + {% if rows | length > 0 %} +
+ + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% endfor %} + +
ID作者 / 文章内容状态时间操作
#{{ row.id }} +
+ {{ row.author }} + {{ row.post_slug }} + {% if row.frontend_url %} + 跳到前台文章 + {% endif %} +
+
{{ row.content }} + {% if row.approved %} + 已审核 + {% else %} + 待审核 + {% endif %} + {{ row.created_at }} +
+ + + API +
+
+
+ {% else %} +
暂无评论数据。
+ {% endif %} +
+{% endblock %} diff --git a/backend/assets/views/admin/friend_links.html b/backend/assets/views/admin/friend_links.html new file mode 100644 index 0000000..59de45d --- /dev/null +++ b/backend/assets/views/admin/friend_links.html @@ -0,0 +1,64 @@ +{% extends "admin/base.html" %} + +{% block main_content %} +
+
+
+

友链审核

+
前台提交后会进入这里,你可以审核状态,再跳去前台友链页确认展示。
+
+
+ + {% if rows | length > 0 %} +
+ + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% endfor %} + +
ID站点分类状态时间操作
#{{ row.id }} +
+ {{ row.site_name }} + {{ row.site_url }} + {{ row.description }} +
+
{{ row.category_name }} + {% if row.status == "已通过" %} + {{ row.status }} + {% elif row.status == "已拒绝" %} + {{ row.status }} + {% else %} + {{ row.status }} + {% endif %} + {{ row.created_at }} +
+ + + + 前台友链页 +
+
+
+ {% else %} +
暂无友链申请数据。
+ {% endif %} +
+{% endblock %} diff --git a/backend/assets/views/admin/index.html b/backend/assets/views/admin/index.html new file mode 100644 index 0000000..ea4bbf7 --- /dev/null +++ b/backend/assets/views/admin/index.html @@ -0,0 +1,29 @@ +{% extends "admin/base.html" %} + +{% block main_content %} +
+ {% for stat in stats %} +
+
{{ stat.label }}
+
{{ stat.value }}
+
{{ stat.note }}
+
+ {% endfor %} +
+ +
+

{{ site_profile.site_name }}

+

{{ site_profile.site_description }}

+ {{ site_profile.site_url }} +
+ +
+ {% for card in nav_cards %} + +

{{ card.title }}

+

{{ card.description }}

+ {{ card.meta }} +
+ {% endfor %} +
+{% endblock %} diff --git a/backend/assets/views/admin/login.html b/backend/assets/views/admin/login.html new file mode 100644 index 0000000..8d49fb5 --- /dev/null +++ b/backend/assets/views/admin/login.html @@ -0,0 +1,35 @@ +{% extends "admin/base.html" %} + +{% block body %} +
+
+ Termi Admin +
/>
+

后台管理入口

+

评论审核、友链申请、分类标签检查和站点设置都在这里统一处理。当前后台界面已经走 Tera 模板,不再在 Rust 里硬拼整页 HTML。

+ + + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+

默认测试账号

+

admin / admin123

+
+
+
+{% endblock %} diff --git a/backend/assets/views/admin/post_editor.html b/backend/assets/views/admin/post_editor.html new file mode 100644 index 0000000..fc3614f --- /dev/null +++ b/backend/assets/views/admin/post_editor.html @@ -0,0 +1,70 @@ +{% extends "admin/base.html" %} + +{% block main_content %} +
+
+
+

{{ editor.title }}

+
当前源文件:{{ editor.file_path }}
+
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+
这里保存的是服务器上的原始 Markdown 文件。你也可以直接在服务器用编辑器打开这个路径修改。
+
+
+
+
+{% endblock %} + +{% block page_scripts %} + +{% endblock %} diff --git a/backend/assets/views/admin/posts.html b/backend/assets/views/admin/posts.html new file mode 100644 index 0000000..87cdc0d --- /dev/null +++ b/backend/assets/views/admin/posts.html @@ -0,0 +1,199 @@ +{% extends "admin/base.html" %} + +{% block main_content %} +
+
+
+

新建 Markdown 文章

+
直接生成 `content/posts/*.md` 文件,后端会自动解析 frontmatter、同步分类和标签。
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+
+
+ +
+
+
+

导入 Markdown 文件

+
支持选择单个 `.md/.markdown` 文件,也支持直接选择一个本地 Markdown 文件夹批量导入。
+
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+
导入时会从 frontmatter 和正文里提取标题、slug、摘要、分类、标签与内容,并写入服务器 `content/posts`。
+
+
+
+
+ +
+
+
+

内容列表

+
直接跳到前台文章、分类筛选和 API 明细。
+
+
+ + {% if rows | length > 0 %} +
+ + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% endfor %} + +
ID文章分类标签时间跳转
#{{ row.id }} +
+ {{ row.title }} + {{ row.slug }} + {{ row.file_path }} +
+
+
+ {{ row.category_name }} + 查看该分类文章 +
+
+ + {{ row.created_at }} + +
+
+ {% else %} +
当前没有可管理的文章数据。
+ {% endif %} +
+{% endblock %} + +{% block page_scripts %} + +{% endblock %} diff --git a/backend/assets/views/admin/reviews.html b/backend/assets/views/admin/reviews.html new file mode 100644 index 0000000..ca7cdf8 --- /dev/null +++ b/backend/assets/views/admin/reviews.html @@ -0,0 +1,108 @@ +{% extends "admin/base.html" %} + +{% block main_content %} +
+
+
+

新增评价

+
这里创建的评价会立刻出现在前台 `/reviews` 页面。
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+ +
+
+
+

评价列表

+
这里的每一行都可以直接编辑,保存后前台评价页会读取最新数据。
+
+
+ + {% if rows | length > 0 %} +
+ + + + + + + + + + + {% for row in rows %} + + + + + + + {% endfor %} + +
ID评价内容状态操作
#{{ row.id }} +
+
+ + + + + + + + +
+
+ + API +
+
+
{{ row.status }} +
+ 前台查看 +
+ +
+
+
+
+ {% else %} +
暂无评价数据。
+ {% endif %} +
+{% endblock %} diff --git a/backend/assets/views/admin/site_settings.html b/backend/assets/views/admin/site_settings.html new file mode 100644 index 0000000..1f5cdbe --- /dev/null +++ b/backend/assets/views/admin/site_settings.html @@ -0,0 +1,143 @@ +{% extends "admin/base.html" %} + +{% block main_content %} +
+
+
+

站点资料

+
保存后首页、关于页、页脚和友链页中的本站信息会直接读取这里的配置。
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
保存后可直接点击顶部“预览首页 / 预览关于页 / 预览友链页”确认前台展示。
+
+
+
+
+{% endblock %} + +{% block page_scripts %} + +{% endblock %} diff --git a/backend/assets/views/admin/tags.html b/backend/assets/views/admin/tags.html new file mode 100644 index 0000000..12aa77b --- /dev/null +++ b/backend/assets/views/admin/tags.html @@ -0,0 +1,77 @@ +{% extends "admin/base.html" %} + +{% block main_content %} +
+
+
+

新增标签

+
这里维护标签字典。文章 Markdown 导入时会优先复用这里的标签,不存在才自动创建。
+
+
+ +
+
+ + +
+
+ +
+
+
+ +
+
+
+

标签映射

+
标签名称会作为文章展示名称使用,使用次数来自当前已同步的真实文章内容。
+
+
+ + {% if rows | length > 0 %} +
+ + + + + + + + + + + {% for row in rows %} + + + + + + + {% endfor %} + +
ID标签使用次数跳转
#{{ row.id }} +
+
+ + +
+
+ + API +
+
+
{{ row.usage_count }} 篇文章 +
+ 前台标签页 + 前台筛选 +
+ +
+
+
+
+ {% else %} +
暂无标签数据。
+ {% endif %} +
+{% endblock %} diff --git a/backend/assets/views/home/hello.html b/backend/assets/views/home/hello.html new file mode 100644 index 0000000..6b97c39 --- /dev/null +++ b/backend/assets/views/home/hello.html @@ -0,0 +1,12 @@ + + +
+ find this tera template at assets/views/home/hello.html: +
+
+ {{ t(key="hello-world", lang="en-US") }}, +
+ {{ t(key="hello-world", lang="de-DE") }} + + + \ No newline at end of file diff --git a/backend/config/development.yaml b/backend/config/development.yaml new file mode 100644 index 0000000..99cae81 --- /dev/null +++ b/backend/config/development.yaml @@ -0,0 +1,108 @@ +# Loco configuration file documentation + +# Application logging configuration +logger: + # Enable or disable logging. + enable: true + # Enable pretty backtrace (sets RUST_BACKTRACE=1) + pretty_backtrace: true + # Log level, options: trace, debug, info, warn or error. + level: debug + # Define the logging format. options: compact, pretty or json + format: compact + # By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries + # Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters. + # override_filter: trace + +# Web server configuration +server: + # Port on which the server will listen. the server binding is 0.0.0.0:{PORT} + port: 5150 + # Binding for the server (which interface to bind to) + binding: localhost + # The UI hostname or IP address that mailers will point to. + host: http://localhost + # Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block + middlewares: + static: + enable: true + must_exist: true + precompressed: false + folder: + uri: "/static" + path: "assets/static" + # fallback to index.html which redirects to /admin + fallback: "assets/static/index.html" + +# Worker Configuration +workers: + # specifies the worker mode. Options: + # - BackgroundQueue - Workers operate asynchronously in the background, processing queued. + # - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed. + # - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities. + mode: BackgroundQueue + + +# Queue Configuration +queue: + kind: Redis + # Redis connection URI + uri: {{ get_env(name="REDIS_URL", default="redis://127.0.0.1") }} + # Dangerously flush all data in Redis on startup. dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_flush: false + +# Mailer Configuration. +mailer: + # SMTP mailer configuration. + smtp: + # Enable/Disable smtp mailer. + enable: false + # SMTP server host. e.x localhost, smtp.gmail.com + host: localhost + # SMTP server port + port: 1025 + # Use secure connection (SSL/TLS). + secure: false + # auth: + # user: + # password: + # Override the SMTP hello name (default is the machine's hostname) + # hello_name: + +# Initializers Configuration +# initializers: +# oauth2: +# authorization_code: # Authorization code grant type +# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config. +# ... other fields + +# Database Configuration +database: + # Database connection URI - Set DATABASE_URL env var for proper password handling + # Example: set DATABASE_URL=postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development + uri: {{ get_env(name="DATABASE_URL") }} + # When enabled, the sql query will be logged. + enable_logging: false + # Set the timeout duration when acquiring a connection. + connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }} + # Set the idle duration before closing a connection. + idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }} + # Minimum number of connections for a pool. + min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }} + # Maximum number of connections for a pool. + max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="1") }} + # Run migration up when application loaded + auto_migrate: true + # Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_truncate: false + # Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_recreate: false + +# Authentication Configuration +auth: + # JWT authentication + jwt: + # Secret key for token generation and verification + secret: BkiGyoCGICNNg07cLWyS + # Token expiration time in seconds + expiration: 604800 # 7 days diff --git a/backend/config/test.yaml b/backend/config/test.yaml new file mode 100644 index 0000000..67149fe --- /dev/null +++ b/backend/config/test.yaml @@ -0,0 +1,103 @@ +# Loco configuration file documentation + +# Application logging configuration +logger: + # Enable or disable logging. + enable: false + # Enable pretty backtrace (sets RUST_BACKTRACE=1) + pretty_backtrace: true + # Log level, options: trace, debug, info, warn or error. + level: debug + # Define the logging format. options: compact, pretty or json + format: compact + # By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries + # Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters. + # override_filter: trace + +# Web server configuration +server: + # Port on which the server will listen. the server binding is 0.0.0.0:{PORT} + port: 5150 + # The UI hostname or IP address that mailers will point to. + host: http://localhost + # Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block + middlewares: + static: + enable: true + must_exist: true + precompressed: false + folder: + uri: "/static" + path: "assets/static" + fallback: "assets/static/404.html" + +# Worker Configuration +workers: + # specifies the worker mode. Options: + # - BackgroundQueue - Workers operate asynchronously in the background, processing queued. + # - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed. + # - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities. + mode: ForegroundBlocking + + +# Queue Configuration +queue: + kind: Redis + # Redis connection URI + uri: {{ get_env(name="REDIS_URL", default="redis://127.0.0.1") }} + # Dangerously flush all data in Redis on startup. dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_flush: false + +# Mailer Configuration. +mailer: + stub: true + # SMTP mailer configuration. + smtp: + # Enable/Disable smtp mailer. + enable: true + # SMTP server host. e.x localhost, smtp.gmail.com + host: localhost + # SMTP server port + port: 1025 + # Use secure connection (SSL/TLS). + secure: false + # auth: + # user: + # password: + +# Initializers Configuration +# initializers: +# oauth2: +# authorization_code: # Authorization code grant type +# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config. +# ... other fields + +# Database Configuration +database: + # Database connection URI + uri: {{ get_env(name="DATABASE_URL", default="postgres://loco:loco@localhost:5432/termi-api_test") }} + # When enabled, the sql query will be logged. + enable_logging: false + # Set the timeout duration when acquiring a connection. + connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }} + # Set the idle duration before closing a connection. + idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }} + # Minimum number of connections for a pool. + min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }} + # Maximum number of connections for a pool. + max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="1") }} + # Run migration up when application loaded + auto_migrate: true + # Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_truncate: true + # Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_recreate: true + +# Authentication Configuration +auth: + # JWT authentication + jwt: + # Secret key for token generation and verification + secret: bm6m9nQtkBXdIvc8BPqj + # Token expiration time in seconds + expiration: 604800 # 7 days diff --git a/backend/content/posts/building-blog-with-astro.md b/backend/content/posts/building-blog-with-astro.md new file mode 100644 index 0000000..f66cb70 --- /dev/null +++ b/backend/content/posts/building-blog-with-astro.md @@ -0,0 +1,37 @@ +--- +title: Building a Blog with Astro +slug: building-blog-with-astro +description: Learn why Astro is the perfect choice for building fast, content-focused blogs. +category: tech +post_type: article +pinned: false +published: true +tags: + - astro + - web-dev + - static-site +--- + +# Building a Blog with Astro + +Astro is a modern static site generator that delivers lightning-fast performance. + +## Why Astro? + +- Zero JavaScript by default +- Island Architecture +- Framework Agnostic +- Great DX + +## Getting Started + +```bash +npm create astro@latest +cd my-astro-project +npm install +npm run dev +``` + +## Conclusion + +Astro is perfect for content-focused websites like blogs. diff --git a/backend/content/posts/loco-rs-framework.md b/backend/content/posts/loco-rs-framework.md new file mode 100644 index 0000000..89fc686 --- /dev/null +++ b/backend/content/posts/loco-rs-framework.md @@ -0,0 +1,44 @@ +--- +title: Loco.rs Backend Framework +slug: loco-rs-framework +description: An introduction to Loco.rs, the Rails-inspired web framework for Rust. +category: tech +post_type: article +pinned: false +published: true +tags: + - rust + - loco-rs + - backend + - api +--- + +# Introduction to Loco.rs + +Loco.rs is a web and API framework for Rust inspired by Rails. + +## Features + +- MVC Architecture +- SeaORM Integration +- Background Jobs +- Authentication +- CLI Generator + +## Quick Start + +```bash +cargo install loco +loco new myapp +cd myapp +cargo loco start +``` + +## Why Loco.rs? + +- Opinionated but flexible +- Production-ready defaults +- Excellent documentation +- Active community + +Perfect for building APIs and web applications in Rust. diff --git a/backend/content/posts/rust-programming-tips.md b/backend/content/posts/rust-programming-tips.md new file mode 100644 index 0000000..e51f1f1 --- /dev/null +++ b/backend/content/posts/rust-programming-tips.md @@ -0,0 +1,38 @@ +--- +title: Rust Programming Tips +slug: rust-programming-tips +description: Essential tips for Rust developers including ownership, pattern matching, and error handling. +category: tech +post_type: article +pinned: false +published: true +tags: + - rust + - programming + - tips +--- + +# Rust Programming Tips + +Here are some essential tips for Rust developers: + +## 1. Ownership and Borrowing + +Understanding ownership is crucial in Rust. Every value has an owner, and there can only be one owner at a time. + +## 2. Pattern Matching + +Use `match` expressions for exhaustive pattern matching: + +```rust +match result { + Ok(value) => println!("Success: {}", value), + Err(e) => println!("Error: {}", e), +} +``` + +## 3. Error Handling + +Use `Result` and `Option` types effectively with the `?` operator. + +Happy coding! diff --git a/backend/content/posts/terminal-ui-design.md b/backend/content/posts/terminal-ui-design.md new file mode 100644 index 0000000..69978ce --- /dev/null +++ b/backend/content/posts/terminal-ui-design.md @@ -0,0 +1,38 @@ +--- +title: Terminal UI Design Principles +slug: terminal-ui-design +description: Learn the key principles of designing beautiful terminal-style user interfaces. +category: design +post_type: article +pinned: false +published: true +tags: + - design + - terminal + - ui +--- + +# Terminal UI Design Principles + +Terminal-style interfaces are making a comeback in modern web design. + +## Key Elements + +1. Monospace fonts +2. Dark themes +3. Command prompts +4. ASCII art +5. Blinking cursor + +## Color Palette + +- Background: `#0d1117` +- Text: `#c9d1d9` +- Accent: `#58a6ff` +- Success: `#3fb950` +- Warning: `#d29922` +- Error: `#f85149` + +## Implementation + +Use CSS to create the terminal aesthetic while maintaining accessibility. diff --git a/backend/content/posts/welcome-to-termi.md b/backend/content/posts/welcome-to-termi.md new file mode 100644 index 0000000..0c2dc7c --- /dev/null +++ b/backend/content/posts/welcome-to-termi.md @@ -0,0 +1,35 @@ +--- +title: Welcome to Termi Blog +slug: welcome-to-termi +description: Welcome to our new blog built with Astro and Loco.rs backend. +category: general +post_type: article +pinned: true +published: true +tags: + - welcome + - astro + - loco-rs +--- + +# Welcome to Termi Blog + +This is the first post on our new blog built with Astro and Loco.rs backend. + +## Features + +- Fast performance with Astro +- Terminal-style UI design +- Comments system +- Friend links +- Tags and categories + +## Code Example + +```rust +fn main() { + println!("Hello, Termi!"); +} +``` + +Stay tuned for more posts! diff --git a/backend/examples/playground.rs b/backend/examples/playground.rs new file mode 100644 index 0000000..060813d --- /dev/null +++ b/backend/examples/playground.rs @@ -0,0 +1,21 @@ +#[allow(unused_imports)] +use loco_rs::{cli::playground, prelude::*}; +use termi_api::app::App; + +#[tokio::main] +async fn main() -> loco_rs::Result<()> { + let _ctx = playground::().await?; + + // let active_model: articles::ActiveModel = articles::ActiveModel { + // title: Set(Some("how to build apps in 3 steps".to_string())), + // content: Set(Some("use Loco: https://loco.rs".to_string())), + // ..Default::default() + // }; + // active_model.insert(&ctx.db).await.unwrap(); + + // let res = articles::Entity::find().all(&ctx.db).await.unwrap(); + // println!("{:?}", res); + println!("welcome to playground. edit me at `examples/playground.rs`"); + + Ok(()) +} diff --git a/backend/migration/Cargo.toml b/backend/migration/Cargo.toml new file mode 100644 index 0000000..d191b87 --- /dev/null +++ b/backend/migration/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "migration" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "migration" +path = "src/lib.rs" + +[dependencies] +loco-rs = { workspace = true } + + +[dependencies.sea-orm-migration] +version = "1.1.0" +features = [ + # Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI. + # View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime. + # e.g. + "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature +] diff --git a/backend/migration/src/lib.rs b/backend/migration/src/lib.rs new file mode 100644 index 0000000..93cf863 --- /dev/null +++ b/backend/migration/src/lib.rs @@ -0,0 +1,36 @@ +#![allow(elided_lifetimes_in_paths)] +#![allow(clippy::wildcard_imports)] +pub use sea_orm_migration::prelude::*; +mod m20220101_000001_users; + +mod m20260327_060643_posts; +mod m20260327_061007_comments; +mod m20260327_061008_tags; +mod m20260327_061234_friend_links; +mod m20260327_061300_reviews; +mod m20260328_000001_add_post_slug_to_comments; +mod m20260328_000002_create_site_settings; +mod m20260328_000003_add_site_url_to_site_settings; +mod m20260328_000004_add_posts_search_index; +mod m20260328_000005_categories; +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m20220101_000001_users::Migration), + Box::new(m20260327_060643_posts::Migration), + Box::new(m20260327_061007_comments::Migration), + Box::new(m20260327_061008_tags::Migration), + Box::new(m20260327_061234_friend_links::Migration), + Box::new(m20260327_061300_reviews::Migration), + Box::new(m20260328_000001_add_post_slug_to_comments::Migration), + Box::new(m20260328_000002_create_site_settings::Migration), + Box::new(m20260328_000003_add_site_url_to_site_settings::Migration), + Box::new(m20260328_000004_add_posts_search_index::Migration), + Box::new(m20260328_000005_categories::Migration), + // inject-above (do not remove this comment) + ] + } +} diff --git a/backend/migration/src/m20220101_000001_users.rs b/backend/migration/src/m20220101_000001_users.rs new file mode 100644 index 0000000..27d4b94 --- /dev/null +++ b/backend/migration/src/m20220101_000001_users.rs @@ -0,0 +1,41 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + create_table( + m, + "users", + &[ + ("id", ColType::PkAuto), + ("pid", ColType::Uuid), + ("email", ColType::StringUniq), + ("password", ColType::String), + ("api_key", ColType::StringUniq), + ("name", ColType::String), + ("reset_token", ColType::StringNull), + ("reset_sent_at", ColType::TimestampWithTimeZoneNull), + ("email_verification_token", ColType::StringNull), + ( + "email_verification_sent_at", + ColType::TimestampWithTimeZoneNull, + ), + ("email_verified_at", ColType::TimestampWithTimeZoneNull), + ("magic_link_token", ColType::StringNull), + ("magic_link_expiration", ColType::TimestampWithTimeZoneNull), + ], + &[], + ) + .await?; + Ok(()) + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "users").await?; + Ok(()) + } +} diff --git a/backend/migration/src/m20260327_060643_posts.rs b/backend/migration/src/m20260327_060643_posts.rs new file mode 100644 index 0000000..da6ac3f --- /dev/null +++ b/backend/migration/src/m20260327_060643_posts.rs @@ -0,0 +1,33 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + create_table( + m, + "posts", + &[ + ("id", ColType::PkAuto), + ("title", ColType::StringNull), + ("slug", ColType::String), + ("description", ColType::StringNull), + ("content", ColType::TextNull), + ("category", ColType::StringNull), + ("tags", ColType::JsonBinaryNull), + ("post_type", ColType::StringNull), + ("image", ColType::StringNull), + ("pinned", ColType::BooleanNull), + ], + &[], + ) + .await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "posts").await + } +} diff --git a/backend/migration/src/m20260327_061007_comments.rs b/backend/migration/src/m20260327_061007_comments.rs new file mode 100644 index 0000000..49f4b97 --- /dev/null +++ b/backend/migration/src/m20260327_061007_comments.rs @@ -0,0 +1,31 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + create_table( + m, + "comments", + &[ + ("id", ColType::PkAuto), + ("post_id", ColType::UuidNull), + ("author", ColType::StringNull), + ("email", ColType::StringNull), + ("avatar", ColType::StringNull), + ("content", ColType::TextNull), + ("reply_to", ColType::UuidNull), + ("approved", ColType::BooleanNull), + ], + &[], + ) + .await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "comments").await + } +} diff --git a/backend/migration/src/m20260327_061008_tags.rs b/backend/migration/src/m20260327_061008_tags.rs new file mode 100644 index 0000000..892f395 --- /dev/null +++ b/backend/migration/src/m20260327_061008_tags.rs @@ -0,0 +1,26 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + create_table( + m, + "tags", + &[ + ("id", ColType::PkAuto), + ("name", ColType::StringNull), + ("slug", ColType::String), + ], + &[], + ) + .await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "tags").await + } +} diff --git a/backend/migration/src/m20260327_061234_friend_links.rs b/backend/migration/src/m20260327_061234_friend_links.rs new file mode 100644 index 0000000..8c7e06e --- /dev/null +++ b/backend/migration/src/m20260327_061234_friend_links.rs @@ -0,0 +1,30 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + create_table( + m, + "friend_links", + &[ + ("id", ColType::PkAuto), + ("site_name", ColType::StringNull), + ("site_url", ColType::String), + ("avatar_url", ColType::StringNull), + ("description", ColType::StringNull), + ("category", ColType::StringNull), + ("status", ColType::StringNull), + ], + &[], + ) + .await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "friend_links").await + } +} diff --git a/backend/migration/src/m20260327_061300_reviews.rs b/backend/migration/src/m20260327_061300_reviews.rs new file mode 100644 index 0000000..3b427d5 --- /dev/null +++ b/backend/migration/src/m20260327_061300_reviews.rs @@ -0,0 +1,32 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + create_table( + m, + "reviews", + &[ + ("id", ColType::PkAuto), + ("title", ColType::StringNull), + ("review_type", ColType::StringNull), + ("rating", ColType::IntegerNull), + ("review_date", ColType::StringNull), + ("status", ColType::StringNull), + ("description", ColType::StringNull), + ("tags", ColType::StringNull), + ("cover", ColType::StringNull), + ], + &[], + ) + .await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "reviews").await + } +} diff --git a/backend/migration/src/m20260328_000001_add_post_slug_to_comments.rs b/backend/migration/src/m20260328_000001_add_post_slug_to_comments.rs new file mode 100644 index 0000000..2996662 --- /dev/null +++ b/backend/migration/src/m20260328_000001_add_post_slug_to_comments.rs @@ -0,0 +1,31 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("comments")) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("post_slug")).string().null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("comments")) + .drop_column(Alias::new("post_slug")) + .to_owned(), + ) + .await + } +} diff --git a/backend/migration/src/m20260328_000002_create_site_settings.rs b/backend/migration/src/m20260328_000002_create_site_settings.rs new file mode 100644 index 0000000..08103b6 --- /dev/null +++ b/backend/migration/src/m20260328_000002_create_site_settings.rs @@ -0,0 +1,40 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + create_table( + m, + "site_settings", + &[ + ("id", ColType::PkAuto), + ("site_name", ColType::StringNull), + ("site_short_name", ColType::StringNull), + ("site_url", ColType::StringNull), + ("site_title", ColType::StringNull), + ("site_description", ColType::StringNull), + ("hero_title", ColType::StringNull), + ("hero_subtitle", ColType::StringNull), + ("owner_name", ColType::StringNull), + ("owner_title", ColType::StringNull), + ("owner_bio", ColType::TextNull), + ("owner_avatar_url", ColType::StringNull), + ("social_github", ColType::StringNull), + ("social_twitter", ColType::StringNull), + ("social_email", ColType::StringNull), + ("location", ColType::StringNull), + ("tech_stack", ColType::JsonBinaryNull), + ], + &[], + ) + .await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "site_settings").await + } +} diff --git a/backend/migration/src/m20260328_000003_add_site_url_to_site_settings.rs b/backend/migration/src/m20260328_000003_add_site_url_to_site_settings.rs new file mode 100644 index 0000000..6bc92ef --- /dev/null +++ b/backend/migration/src/m20260328_000003_add_site_url_to_site_settings.rs @@ -0,0 +1,37 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + if manager.has_column("site_settings", "site_url").await? { + return Ok(()); + } + + manager + .alter_table( + Table::alter() + .table(Alias::new("site_settings")) + .add_column(ColumnDef::new(Alias::new("site_url")).string().null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + if !manager.has_column("site_settings", "site_url").await? { + return Ok(()); + } + + manager + .alter_table( + Table::alter() + .table(Alias::new("site_settings")) + .drop_column(Alias::new("site_url")) + .to_owned(), + ) + .await + } +} diff --git a/backend/migration/src/m20260328_000004_add_posts_search_index.rs b/backend/migration/src/m20260328_000004_add_posts_search_index.rs new file mode 100644 index 0000000..a250b2f --- /dev/null +++ b/backend/migration/src/m20260328_000004_add_posts_search_index.rs @@ -0,0 +1,46 @@ +use sea_orm_migration::prelude::*; +use sea_orm_migration::sea_orm::DbBackend; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + if manager.get_database_backend() != DbBackend::Postgres { + return Ok(()); + } + + manager + .get_connection() + .execute_unprepared( + r#" + CREATE INDEX IF NOT EXISTS idx_posts_search_fts + ON posts + USING GIN ( + ( + setweight(to_tsvector('simple', coalesce(title, '')), 'A') || + setweight(to_tsvector('simple', coalesce(description, '')), 'B') || + setweight(to_tsvector('simple', coalesce(category, '')), 'C') || + setweight(to_tsvector('simple', coalesce(tags::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(content, '')), 'D') + ) + ); + "#, + ) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + if manager.get_database_backend() != DbBackend::Postgres { + return Ok(()); + } + + manager + .get_connection() + .execute_unprepared("DROP INDEX IF EXISTS idx_posts_search_fts;") + .await?; + Ok(()) + } +} diff --git a/backend/migration/src/m20260328_000005_categories.rs b/backend/migration/src/m20260328_000005_categories.rs new file mode 100644 index 0000000..484ba0f --- /dev/null +++ b/backend/migration/src/m20260328_000005_categories.rs @@ -0,0 +1,26 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + create_table( + m, + "categories", + &[ + ("id", ColType::PkAuto), + ("name", ColType::StringNull), + ("slug", ColType::String), + ], + &[], + ) + .await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "categories").await + } +} diff --git a/backend/src/app.rs b/backend/src/app.rs new file mode 100644 index 0000000..9d7d7b7 --- /dev/null +++ b/backend/src/app.rs @@ -0,0 +1,368 @@ +use async_trait::async_trait; +use axum::{http::Method, Router as AxumRouter}; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{BackgroundWorker, Queue}, + boot::{create_app, BootResult, StartMode}, + config::Config, + controller::AppRoutes, + db::{self, truncate_table}, + environment::Environment, + task::Tasks, + Result, +}; +use migration::Migrator; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set, +}; +use std::path::Path; +use tower_http::cors::{Any, CorsLayer}; + +#[allow(unused_imports)] +use crate::{ + controllers, initializers, + models::_entities::{categories, comments, friend_links, posts, reviews, site_settings, tags, users}, + tasks, + workers::downloader::DownloadWorker, +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot( + mode: StartMode, + environment: &Environment, + config: Config, + ) -> Result { + create_app::(mode, environment, config).await + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![ + Box::new(initializers::content_sync::ContentSyncInitializer), + Box::new(initializers::view_engine::ViewEngineInitializer), + ]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::admin::routes()) + .add_route(controllers::review::routes()) + .add_route(controllers::category::routes()) + .add_route(controllers::friend_link::routes()) + .add_route(controllers::tag::routes()) + .add_route(controllers::comment::routes()) + .add_route(controllers::post::routes()) + .add_route(controllers::search::routes()) + .add_route(controllers::site_settings::routes()) + .add_route(controllers::auth::routes()) + } + async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result { + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE]) + .allow_headers(Any); + + Ok(router.layer(cors)) + } + async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { + queue.register(DownloadWorker::build(ctx)).await?; + Ok(()) + } + + #[allow(unused_variables)] + fn register_tasks(tasks: &mut Tasks) { + // tasks-inject (do not remove) + } + async fn truncate(ctx: &AppContext) -> Result<()> { + truncate_table(&ctx.db, users::Entity).await?; + Ok(()) + } + async fn seed(ctx: &AppContext, base: &Path) -> Result<()> { + // Seed users - use loco's default seed which handles duplicates + let users_file = base.join("users.yaml"); + if users_file.exists() { + if let Err(e) = + db::seed::(&ctx.db, &users_file.display().to_string()).await + { + tracing::warn!("Users seed skipped or failed: {}", e); + } + } + + // Seed tags first (no foreign key dependencies) - use Unchanged to ignore conflicts + if let Ok(seed_data) = std::fs::read_to_string(base.join("tags.yaml")) { + let tags_data: Vec = + serde_yaml::from_str(&seed_data).unwrap_or_default(); + for tag in tags_data { + let id = tag["id"].as_i64().unwrap_or(0) as i32; + let name = tag["name"].as_str().unwrap_or("").to_string(); + let slug = tag["slug"].as_str().unwrap_or("").to_string(); + + let existing = tags::Entity::find_by_id(id).one(&ctx.db).await?; + if existing.is_none() { + let new_tag = tags::ActiveModel { + id: Set(id), + name: Set(Some(name)), + slug: Set(slug), + ..Default::default() + }; + let _ = new_tag.insert(&ctx.db).await; + } + } + } + + // Seed posts + if let Ok(seed_data) = std::fs::read_to_string(base.join("posts.yaml")) { + let posts_data: Vec = + serde_yaml::from_str(&seed_data).unwrap_or_default(); + for post in posts_data { + let pid = post["pid"].as_i64().unwrap_or(0) as i32; + let title = post["title"].as_str().unwrap_or("").to_string(); + let slug = post["slug"].as_str().unwrap_or("").to_string(); + let content = post["content"].as_str().unwrap_or("").to_string(); + let excerpt = post["excerpt"].as_str().unwrap_or("").to_string(); + let category = post["category"].as_str().unwrap_or("").to_string(); + let pinned = post["pinned"].as_bool().unwrap_or(false); + let post_type = post["post_type"].as_str().unwrap_or("article").to_string(); + let tags_vec = post["tags"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(|s| s.to_string()) + .collect::>() + }) + .unwrap_or_default(); + let tags_json = if tags_vec.is_empty() { + None + } else { + Some(serde_json::json!(tags_vec)) + }; + + let existing = posts::Entity::find_by_id(pid).one(&ctx.db).await?; + let has_existing = existing.is_some(); + let mut post_model = existing + .map(|model| model.into_active_model()) + .unwrap_or_else(|| posts::ActiveModel { + id: Set(pid), + ..Default::default() + }); + + post_model.title = Set(Some(title)); + post_model.slug = Set(slug); + post_model.content = Set(Some(content)); + post_model.description = Set(Some(excerpt)); + post_model.category = Set(Some(category)); + post_model.tags = Set(tags_json); + post_model.pinned = Set(Some(pinned)); + post_model.post_type = Set(Some(post_type)); + + if has_existing { + let _ = post_model.update(&ctx.db).await; + } else { + let _ = post_model.insert(&ctx.db).await; + } + } + } + + // Seed comments + if let Ok(seed_data) = std::fs::read_to_string(base.join("comments.yaml")) { + let comments_data: Vec = + serde_yaml::from_str(&seed_data).unwrap_or_default(); + for comment in comments_data { + let id = comment["id"].as_i64().unwrap_or(0) as i32; + let pid = comment["pid"].as_i64().unwrap_or(0) as i32; + let author = comment["author"].as_str().unwrap_or("").to_string(); + let email = comment["email"].as_str().unwrap_or("").to_string(); + let content_text = comment["content"].as_str().unwrap_or("").to_string(); + let approved = comment["approved"].as_bool().unwrap_or(false); + let post_slug = posts::Entity::find_by_id(pid) + .one(&ctx.db) + .await? + .map(|post| post.slug); + + let existing = comments::Entity::find_by_id(id).one(&ctx.db).await?; + let has_existing = existing.is_some(); + let mut comment_model = existing + .map(|model| model.into_active_model()) + .unwrap_or_else(|| comments::ActiveModel { + id: Set(id), + ..Default::default() + }); + + comment_model.author = Set(Some(author)); + comment_model.email = Set(Some(email)); + comment_model.content = Set(Some(content_text)); + comment_model.approved = Set(Some(approved)); + comment_model.post_slug = Set(post_slug); + + if has_existing { + let _ = comment_model.update(&ctx.db).await; + } else { + let _ = comment_model.insert(&ctx.db).await; + } + } + } + + // Seed friend links + if let Ok(seed_data) = std::fs::read_to_string(base.join("friend_links.yaml")) { + let links_data: Vec = + serde_yaml::from_str(&seed_data).unwrap_or_default(); + for link in links_data { + let site_name = link["site_name"].as_str().unwrap_or("").to_string(); + let site_url = link["site_url"].as_str().unwrap_or("").to_string(); + let avatar_url = link["avatar_url"].as_str().map(|s: &str| s.to_string()); + let description = link["description"].as_str().unwrap_or("").to_string(); + let category = link["category"].as_str().unwrap_or("").to_string(); + let status = link["status"].as_str().unwrap_or("pending").to_string(); + + let existing = friend_links::Entity::find() + .filter(friend_links::Column::SiteUrl.eq(&site_url)) + .one(&ctx.db) + .await?; + if existing.is_none() { + let new_link = friend_links::ActiveModel { + site_name: Set(Some(site_name)), + site_url: Set(site_url), + avatar_url: Set(avatar_url), + description: Set(Some(description)), + category: Set(Some(category)), + status: Set(Some(status)), + ..Default::default() + }; + let _ = new_link.insert(&ctx.db).await; + } + } + } + + // Seed site settings + if let Ok(seed_data) = std::fs::read_to_string(base.join("site_settings.yaml")) { + let settings_data: Vec = + serde_yaml::from_str(&seed_data).unwrap_or_default(); + if let Some(settings) = settings_data.first() { + let existing = site_settings::Entity::find() + .order_by_asc(site_settings::Column::Id) + .one(&ctx.db) + .await?; + + if existing.is_none() { + let tech_stack = settings["tech_stack"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str()) + .map(ToString::to_string) + .collect::>() + }) + .filter(|items| !items.is_empty()) + .map(|items| serde_json::json!(items)); + + let item = site_settings::ActiveModel { + id: Set(settings["id"].as_i64().unwrap_or(1) as i32), + site_name: Set(settings["site_name"].as_str().map(ToString::to_string)), + site_short_name: Set( + settings["site_short_name"].as_str().map(ToString::to_string), + ), + site_url: Set(settings["site_url"].as_str().map(ToString::to_string)), + site_title: Set(settings["site_title"].as_str().map(ToString::to_string)), + site_description: Set( + settings["site_description"].as_str().map(ToString::to_string), + ), + hero_title: Set(settings["hero_title"].as_str().map(ToString::to_string)), + hero_subtitle: Set( + settings["hero_subtitle"].as_str().map(ToString::to_string), + ), + owner_name: Set(settings["owner_name"].as_str().map(ToString::to_string)), + owner_title: Set( + settings["owner_title"].as_str().map(ToString::to_string), + ), + owner_bio: Set(settings["owner_bio"].as_str().map(ToString::to_string)), + owner_avatar_url: Set( + settings["owner_avatar_url"].as_str().and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }), + ), + social_github: Set( + settings["social_github"].as_str().map(ToString::to_string), + ), + social_twitter: Set( + settings["social_twitter"].as_str().map(ToString::to_string), + ), + social_email: Set( + settings["social_email"].as_str().map(ToString::to_string), + ), + location: Set(settings["location"].as_str().map(ToString::to_string)), + tech_stack: Set(tech_stack), + ..Default::default() + }; + let _ = item.insert(&ctx.db).await; + } + } + } + + // Seed reviews + if let Ok(seed_data) = std::fs::read_to_string(base.join("reviews.yaml")) { + let reviews_data: Vec = + serde_yaml::from_str(&seed_data).unwrap_or_default(); + for review in reviews_data { + let title = review["title"].as_str().unwrap_or("").to_string(); + let review_type = review["review_type"].as_str().unwrap_or("").to_string(); + let rating = review["rating"].as_i64().unwrap_or(0) as i32; + let review_date = review["review_date"].as_str().unwrap_or("").to_string(); + let status = review["status"].as_str().unwrap_or("completed").to_string(); + let description = review["description"].as_str().unwrap_or("").to_string(); + let cover = review["cover"].as_str().unwrap_or("📝").to_string(); + let tags_vec = review["tags"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(|s| s.to_string()) + .collect::>() + }) + .unwrap_or_default(); + + let existing = reviews::Entity::find() + .filter(reviews::Column::Title.eq(&title)) + .one(&ctx.db) + .await?; + if existing.is_none() { + let new_review = reviews::ActiveModel { + title: Set(Some(title)), + review_type: Set(Some(review_type)), + rating: Set(Some(rating)), + review_date: Set(Some(review_date)), + status: Set(Some(status)), + description: Set(Some(description)), + cover: Set(Some(cover)), + tags: Set(Some(serde_json::to_string(&tags_vec).unwrap_or_default())), + ..Default::default() + }; + let _ = new_review.insert(&ctx.db).await; + } + } + } + + Ok(()) + } +} diff --git a/backend/src/bin/main.rs b/backend/src/bin/main.rs new file mode 100644 index 0000000..cc236f2 --- /dev/null +++ b/backend/src/bin/main.rs @@ -0,0 +1,8 @@ +use loco_rs::cli; +use migration::Migrator; +use termi_api::app::App; + +#[tokio::main] +async fn main() -> loco_rs::Result<()> { + cli::main::().await +} diff --git a/backend/src/bin/tool.rs b/backend/src/bin/tool.rs new file mode 100644 index 0000000..cc236f2 --- /dev/null +++ b/backend/src/bin/tool.rs @@ -0,0 +1,8 @@ +use loco_rs::cli; +use migration::Migrator; +use termi_api::app::App; + +#[tokio::main] +async fn main() -> loco_rs::Result<()> { + cli::main::().await +} diff --git a/backend/src/controllers/admin.rs b/backend/src/controllers/admin.rs new file mode 100644 index 0000000..44a27ce --- /dev/null +++ b/backend/src/controllers/admin.rs @@ -0,0 +1,1231 @@ +use axum::{extract::{Multipart, Query, State}, Form}; +use loco_rs::prelude::*; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, Set}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::models::_entities::{categories, comments, friend_links, posts, reviews, site_settings, tags}; +use crate::services::content; + +static ADMIN_LOGGED_IN: AtomicBool = AtomicBool::new(false); +const FRONTEND_BASE_URL: &str = "http://localhost:4321"; + +#[derive(Deserialize)] +pub struct LoginForm { + username: String, + password: String, +} + +#[derive(Default, Deserialize)] +pub struct LoginQuery { + error: Option, +} + +#[derive(Serialize)] +struct HeaderAction { + label: String, + href: String, + variant: String, + external: bool, +} + +#[derive(Serialize)] +struct StatCard { + label: String, + value: String, + note: String, + tone: String, +} + +#[derive(Serialize)] +struct NavCard { + title: String, + description: String, + href: String, + meta: String, +} + +#[derive(Serialize)] +struct SiteProfile { + site_name: String, + site_description: String, + site_url: String, +} + +#[derive(Serialize)] +struct PostRow { + id: i32, + title: String, + slug: String, + file_path: String, + created_at: String, + category_name: String, + category_frontend_url: String, + frontend_url: String, + api_url: String, + edit_url: String, + tags: Vec, +} + +#[derive(Deserialize)] +pub struct PostCreateForm { + title: String, + slug: String, + description: String, + category: String, + tags: String, + post_type: String, + image: String, + pinned: Option, + published: Option, + content: String, +} + +#[derive(Serialize)] +struct CommentRow { + id: i32, + author: String, + post_slug: String, + content: String, + approved: bool, + created_at: String, + frontend_url: Option, + api_url: String, +} + +#[derive(Serialize)] +struct TagRow { + id: i32, + name: String, + slug: String, + usage_count: usize, + frontend_url: String, + articles_url: String, + api_url: String, +} + +#[derive(Serialize)] +struct CategoryRow { + id: i32, + name: String, + slug: String, + count: usize, + latest_title: String, + latest_frontend_url: Option, + frontend_url: String, + articles_url: String, + api_url: String, +} + +#[derive(Serialize)] +struct ReviewRow { + id: i32, + title: String, + review_type: String, + rating: i32, + review_date: String, + status: String, + description: String, + tags_input: String, + cover: String, + api_url: String, +} + +#[derive(Serialize)] +struct FriendLinkRow { + id: i32, + site_name: String, + site_url: String, + description: String, + category_name: String, + status: String, + created_at: String, + frontend_page_url: String, + api_url: String, +} + +#[derive(Deserialize)] +pub struct CategoryForm { + name: String, + slug: String, +} + +#[derive(Deserialize)] +pub struct TagForm { + name: String, + slug: String, +} + +#[derive(Deserialize)] +pub struct ReviewForm { + title: String, + review_type: String, + rating: i32, + review_date: String, + status: String, + description: String, + tags: String, + cover: String, +} + +fn url_encode(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.as_bytes() { + match byte { + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'_' + | b'.' + | b'~' => encoded.push(*byte as char), + b' ' => encoded.push_str("%20"), + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} + +fn frontend_path(path: &str) -> String { + format!("{FRONTEND_BASE_URL}{path}") +} + +fn frontend_query_url(path: &str, key: &str, value: &str) -> String { + format!("{}{path}?{key}={}", FRONTEND_BASE_URL, url_encode(value)) +} + +fn slugify(value: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + + for ch in value.trim().chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + last_was_dash = false; + } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash { + slug.push('-'); + last_was_dash = true; + } + } + + slug.trim_matches('-').to_string() +} + +fn non_empty(value: Option<&str>, fallback: &str) -> String { + value + .map(str::trim) + .filter(|text| !text.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +fn normalize_admin_text(value: &str) -> String { + value.trim().to_string() +} + +fn normalized_slug_or_name(slug: &str, name: &str) -> String { + let normalized_slug = slug.trim(); + if normalized_slug.is_empty() { + slugify(name) + } else { + slugify(normalized_slug) + } +} + +fn parse_review_tags(input: &str) -> Vec { + input + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn parse_tag_input(input: &str) -> Vec { + input + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn review_tags_input(value: Option<&str>) -> String { + value + .and_then(|item| serde_json::from_str::>(item).ok()) + .unwrap_or_default() + .join(", ") +} + +fn json_string_array(value: &Option) -> Vec { + value + .as_ref() + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|item| item.as_str().map(ToString::to_string)) + .collect() +} + +fn matches_tag(post_tag: &str, tag_name: &str, tag_slug: &str) -> bool { + let left = post_tag.trim().to_lowercase(); + let name = tag_name.trim().to_lowercase(); + let slug = tag_slug.trim().to_lowercase(); + + left == name || left == slug +} + +fn link_status_text(status: &str) -> &'static str { + match status { + "approved" => "已通过", + "rejected" => "已拒绝", + _ => "待审核", + } +} + +fn page_context(title: &str, description: &str, active_nav: &str) -> Map { + let mut context = Map::new(); + context.insert("page_title".into(), json!(title)); + context.insert("page_description".into(), json!(description)); + context.insert("active_nav".into(), json!(active_nav)); + context.insert("frontend_base_url".into(), json!(FRONTEND_BASE_URL)); + context +} + +fn action(label: &str, href: String, variant: &str, external: bool) -> HeaderAction { + HeaderAction { + label: label.to_string(), + href, + variant: variant.to_string(), + external, + } +} + +fn render_admin( + view_engine: &ViewEngine, + template: &str, + context: Map, +) -> Result { + format::view(&view_engine.0, template, Value::Object(context)) +} + +fn check_auth() -> Result<()> { + if !ADMIN_LOGGED_IN.load(Ordering::SeqCst) { + return Err(Error::Unauthorized("Not logged in".to_string())); + } + Ok(()) +} + +pub async fn root() -> Result { + if ADMIN_LOGGED_IN.load(Ordering::SeqCst) { + Ok(format::redirect("/admin")) + } else { + Ok(format::redirect("/admin/login")) + } +} + +pub async fn login_page( + view_engine: ViewEngine, + Query(query): Query, +) -> Result { + format::view( + &view_engine.0, + "admin/login.html", + json!({ + "page_title": "后台登录", + "page_description": "登录后可进入统一后台,审核评论和友链、检查分类标签,并管理站点信息。", + "show_error": query.error.is_some(), + }), + ) +} + +pub async fn login_submit(Form(form): Form) -> Result { + if form.username == "admin" && form.password == "admin123" { + ADMIN_LOGGED_IN.store(true, Ordering::SeqCst); + return Ok(format::redirect("/admin")); + } + Ok(format::redirect("/admin/login?error=1")) +} + +pub async fn logout() -> Result { + ADMIN_LOGGED_IN.store(false, Ordering::SeqCst); + Ok(format::redirect("/admin/login")) +} + +pub async fn index( + view_engine: ViewEngine, + State(ctx): State, +) -> Result { + check_auth()?; + content::sync_markdown_posts(&ctx).await?; + + let posts = posts::Entity::find() + .order_by_desc(posts::Column::CreatedAt) + .all(&ctx.db) + .await?; + let comments_count = comments::Entity::find().count(&ctx.db).await?; + let reviews_count = reviews::Entity::find().count(&ctx.db).await?; + let categories_count = categories::Entity::find().count(&ctx.db).await?; + let tags_count = tags::Entity::find().count(&ctx.db).await?; + let friend_links_count = friend_links::Entity::find().count(&ctx.db).await?; + let pending_comments = comments::Entity::find() + .filter(comments::Column::Approved.eq(false)) + .count(&ctx.db) + .await?; + let pending_links = friend_links::Entity::find() + .filter(friend_links::Column::Status.eq("pending")) + .count(&ctx.db) + .await?; + + let site = site_settings::Entity::find() + .order_by_asc(site_settings::Column::Id) + .one(&ctx.db) + .await?; + + let stats = vec![ + StatCard { + label: "文章".to_string(), + value: posts.len().to_string(), + note: "当前前台文章与推文总数".to_string(), + tone: "blue".to_string(), + }, + StatCard { + label: "评论".to_string(), + value: comments_count.to_string(), + note: format!("待审核 {pending_comments} 条"), + tone: "gold".to_string(), + }, + StatCard { + label: "分类".to_string(), + value: categories_count.to_string(), + note: "导入文章会自动同步分类".to_string(), + tone: "green".to_string(), + }, + StatCard { + label: "标签".to_string(), + value: tags_count.to_string(), + note: "支持前台标签页与筛选".to_string(), + tone: "pink".to_string(), + }, + StatCard { + label: "评价".to_string(), + value: reviews_count.to_string(), + note: "后台可创建并同步前台".to_string(), + tone: "blue".to_string(), + }, + StatCard { + label: "友链".to_string(), + value: friend_links_count.to_string(), + note: format!("待审核 {pending_links} 条"), + tone: "violet".to_string(), + }, + ]; + + let nav_cards = vec![ + NavCard { + title: "文章管理".to_string(), + description: "查看文章、跳转前台详情,并联动分类和标签。".to_string(), + href: "/admin/posts".to_string(), + meta: format!("{} 篇内容", posts.len()), + }, + NavCard { + title: "评论审核".to_string(), + description: "审核前台真实评论,并直接跳到对应文章。".to_string(), + href: "/admin/comments".to_string(), + meta: format!("{pending_comments} 条待审核"), + }, + NavCard { + title: "分类管理".to_string(), + description: "维护分类字典,文章导入时会自动匹配或创建。".to_string(), + href: "/admin/categories".to_string(), + meta: format!("{categories_count} 个分类"), + }, + NavCard { + title: "标签管理".to_string(), + description: "维护标签字典,文章导入时会自动匹配或创建。".to_string(), + href: "/admin/tags".to_string(), + meta: format!("{tags_count} 个标签"), + }, + NavCard { + title: "评价管理".to_string(), + description: "创建和编辑评价内容,前台评价页直接读取真实数据。".to_string(), + href: "/admin/reviews".to_string(), + meta: format!("{reviews_count} 条评价"), + }, + NavCard { + title: "友链申请".to_string(), + description: "审核友链状态,查看前台列表与外部站点。".to_string(), + href: "/admin/friend_links".to_string(), + meta: format!("{pending_links} 条待处理"), + }, + NavCard { + title: "站点设置".to_string(), + description: "修改首页、关于页、页脚与友链页读取的站点信息。".to_string(), + href: "/admin/site-settings".to_string(), + meta: "支持实时预览入口".to_string(), + }, + ]; + + let profile = SiteProfile { + site_name: non_empty(site.as_ref().and_then(|item| item.site_name.as_deref()), "未配置站点"), + site_description: non_empty( + site.as_ref() + .and_then(|item| item.site_description.as_deref()), + "站点简介尚未设置", + ), + site_url: non_empty(site.as_ref().and_then(|item| item.site_url.as_deref()), "未配置站点链接"), + }; + + let mut context = page_context("后台总览", "前后台共用同一份数据,这里可以快速处理内容和跳转前台。", "dashboard"); + context.insert( + "header_actions".into(), + json!([ + action("打开前台首页", frontend_path("/"), "ghost", true), + action("查看关于页", frontend_path("/about"), "ghost", true), + action("查看友链页", frontend_path("/friends"), "primary", true), + ]), + ); + context.insert("stats".into(), json!(stats)); + context.insert("nav_cards".into(), json!(nav_cards)); + context.insert("site_profile".into(), json!(profile)); + + render_admin(&view_engine, "admin/index.html", context) +} + +pub async fn posts_admin( + view_engine: ViewEngine, + State(ctx): State, +) -> Result { + check_auth()?; + let markdown_posts = content::sync_markdown_posts(&ctx).await?; + + let items = posts::Entity::find() + .order_by_desc(posts::Column::CreatedAt) + .all(&ctx.db) + .await?; + + let file_path_by_slug = markdown_posts + .into_iter() + .map(|post| (post.slug, post.file_path)) + .collect::>(); + + let rows = items + .iter() + .map(|post| { + let category_name = non_empty(post.category.as_deref(), "未分类"); + PostRow { + id: post.id, + title: non_empty(post.title.as_deref(), "未命名文章"), + slug: post.slug.clone(), + file_path: file_path_by_slug + .get(&post.slug) + .cloned() + .unwrap_or_else(|| content::markdown_post_path(&post.slug).to_string_lossy().to_string()), + created_at: post.created_at.format("%Y-%m-%d %H:%M").to_string(), + category_name: category_name.clone(), + category_frontend_url: frontend_query_url("/articles", "category", &category_name), + frontend_url: frontend_path(&format!("/articles/{}", post.slug)), + api_url: format!("/api/posts/{}", post.id), + edit_url: format!("/admin/posts/{}/edit", post.slug), + tags: json_string_array(&post.tags), + } + }) + .collect::>(); + + let mut context = page_context("文章管理", "核对文章、分类和标签,并可直接跳到前台详情页。", "posts"); + context.insert( + "header_actions".into(), + json!([ + action("前台文章列表", frontend_path("/articles"), "primary", true), + action("前台分类页", frontend_path("/categories"), "ghost", true), + action("文章 API", "/api/posts".to_string(), "ghost", true), + ]), + ); + context.insert( + "create_form".into(), + json!({ + "title": "", + "slug": "", + "description": "", + "category": "", + "tags": "", + "post_type": "article", + "image": "", + "content": "# 新文章\n\n在这里开始写作。\n", + }), + ); + context.insert("rows".into(), json!(rows)); + + render_admin(&view_engine, "admin/posts.html", context) +} + +pub async fn posts_create( + State(ctx): State, + Form(form): Form, +) -> Result { + check_auth()?; + + let _ = content::create_markdown_post( + &ctx, + content::MarkdownPostDraft { + title: normalize_admin_text(&form.title), + slug: Some(normalized_slug_or_name(&form.slug, &form.title)), + description: Some(normalize_admin_text(&form.description)), + content: normalize_admin_text(&form.content), + category: Some(normalize_admin_text(&form.category)), + tags: parse_tag_input(&form.tags), + post_type: normalize_admin_text(&form.post_type), + image: Some(normalize_admin_text(&form.image)), + pinned: form.pinned.is_some(), + published: form.published.is_some(), + }, + ) + .await?; + + Ok(format::redirect("/admin/posts")) +} + +pub async fn posts_import( + State(ctx): State, + mut multipart: Multipart, +) -> Result { + check_auth()?; + + let mut files = Vec::new(); + + while let Some(field) = multipart.next_field().await.map_err(|error| Error::BadRequest(error.to_string()))? { + let file_name = field + .file_name() + .map(ToString::to_string) + .unwrap_or_else(|| "imported.md".to_string()); + let bytes = field + .bytes() + .await + .map_err(|error| Error::BadRequest(error.to_string()))?; + let content = String::from_utf8(bytes.to_vec()) + .map_err(|_| Error::BadRequest("markdown file must be utf-8".to_string()))?; + + files.push(content::MarkdownImportFile { file_name, content }); + } + + let imported = content::import_markdown_documents(&ctx, files).await?; + format::json(json!({ + "count": imported.len(), + "slugs": imported.iter().map(|item| item.slug.clone()).collect::>(), + })) +} + +pub async fn post_editor( + view_engine: ViewEngine, + Path(slug): Path, + State(ctx): State, +) -> Result { + check_auth()?; + content::sync_markdown_posts(&ctx).await?; + + let (file_path, markdown) = content::read_markdown_document(&slug)?; + let post = posts::Entity::find() + .filter(posts::Column::Slug.eq(&slug)) + .one(&ctx.db) + .await? + .ok_or(Error::NotFound)?; + + let mut context = page_context( + "Markdown 编辑器", + "这里保存的就是服务器上的 Markdown 文件。你也可以直接用本地编辑器改这个文件,刷新后后台和前台都会同步。", + "posts", + ); + context.insert( + "header_actions".into(), + json!([ + action("返回文章管理", "/admin/posts".to_string(), "ghost", false), + action("前台预览", frontend_path(&format!("/articles/{}", slug)), "primary", true), + action("文章 API", format!("/api/posts/slug/{slug}"), "ghost", true), + action("Markdown API", format!("/api/posts/slug/{slug}/markdown"), "ghost", true), + ]), + ); + context.insert( + "editor".into(), + json!({ + "title": non_empty(post.title.as_deref(), "未命名文章"), + "slug": slug, + "file_path": file_path, + "markdown": markdown, + }), + ); + + render_admin(&view_engine, "admin/post_editor.html", context) +} + +pub async fn comments_admin( + view_engine: ViewEngine, + State(ctx): State, +) -> Result { + check_auth()?; + content::sync_markdown_posts(&ctx).await?; + + let items = comments::Entity::find() + .order_by_desc(comments::Column::CreatedAt) + .all(&ctx.db) + .await?; + + let rows = items + .iter() + .map(|comment| { + let post_slug = non_empty(comment.post_slug.as_deref(), "未关联文章"); + CommentRow { + id: comment.id, + author: non_empty(comment.author.as_deref(), "匿名"), + post_slug: post_slug.clone(), + content: non_empty(comment.content.as_deref(), "-"), + approved: comment.approved.unwrap_or(false), + created_at: comment.created_at.format("%Y-%m-%d %H:%M").to_string(), + frontend_url: comment + .post_slug + .as_deref() + .filter(|slug| !slug.trim().is_empty()) + .map(|slug| frontend_path(&format!("/articles/{slug}"))), + api_url: format!("/api/comments/{}", comment.id), + } + }) + .collect::>(); + + let mut context = page_context("评论审核", "前台真实评论会先进入这里,审核通过后再展示到文章页。", "comments"); + context.insert( + "header_actions".into(), + json!([ + action("前台文章列表", frontend_path("/articles"), "primary", true), + action("评论 API", "/api/comments".to_string(), "ghost", true), + ]), + ); + context.insert("rows".into(), json!(rows)); + + render_admin(&view_engine, "admin/comments.html", context) +} + +pub async fn categories_admin( + view_engine: ViewEngine, + State(ctx): State, +) -> Result { + check_auth()?; + content::sync_markdown_posts(&ctx).await?; + + let category_items = categories::Entity::find() + .order_by_asc(categories::Column::Slug) + .all(&ctx.db) + .await?; + let post_items = posts::Entity::find() + .order_by_desc(posts::Column::CreatedAt) + .all(&ctx.db) + .await?; + + let mut rows = category_items + .into_iter() + .map(|category| { + let name = non_empty(category.name.as_deref(), "未命名分类"); + let latest = post_items + .iter() + .find(|post| post.category.as_deref().map(str::trim) == Some(name.as_str())); + let count = post_items + .iter() + .filter(|post| post.category.as_deref().map(str::trim) == Some(name.as_str())) + .count(); + + CategoryRow { + id: category.id, + slug: category.slug, + count, + latest_title: latest + .and_then(|post| post.title.as_deref()) + .unwrap_or("最近文章") + .to_string(), + latest_frontend_url: latest.map(|post| frontend_path(&format!("/articles/{}", post.slug))), + frontend_url: frontend_path("/categories"), + articles_url: frontend_query_url("/articles", "category", &name), + api_url: format!("/api/categories/{}", category.id), + name, + } + }) + .collect::>(); + + rows.sort_by(|left, right| { + right + .count + .cmp(&left.count) + .then_with(|| left.name.cmp(&right.name)) + }); + + let mut context = page_context("分类管理", "维护分类字典。Markdown 导入文章时,如果分类不存在会自动创建;已存在则复用现有分类。", "categories"); + context.insert( + "header_actions".into(), + json!([ + action("前台分类页", frontend_path("/categories"), "primary", true), + action("分类 API", "/api/categories".to_string(), "ghost", true), + action("前台文章列表", frontend_path("/articles"), "ghost", true), + ]), + ); + context.insert( + "create_form".into(), + json!({ + "name": "", + "slug": "", + }), + ); + context.insert("rows".into(), json!(rows)); + + render_admin(&view_engine, "admin/categories.html", context) +} + +pub async fn categories_create( + State(ctx): State, + Form(form): Form, +) -> Result { + check_auth()?; + + let name = normalize_admin_text(&form.name); + if name.is_empty() { + return Ok(format::redirect("/admin/categories")); + } + + let slug = normalized_slug_or_name(&form.slug, &name); + let existing = categories::Entity::find() + .filter(categories::Column::Slug.eq(&slug)) + .one(&ctx.db) + .await?; + + if let Some(item) = existing { + let mut model = item.into_active_model(); + model.name = Set(Some(name)); + model.slug = Set(slug); + let _ = model.update(&ctx.db).await?; + } else { + let _ = categories::ActiveModel { + name: Set(Some(name)), + slug: Set(slug), + ..Default::default() + } + .insert(&ctx.db) + .await?; + } + + Ok(format::redirect("/admin/categories")) +} + +pub async fn categories_update( + Path(id): Path, + State(ctx): State, + Form(form): Form, +) -> Result { + check_auth()?; + + let name = normalize_admin_text(&form.name); + if name.is_empty() { + return Ok(format::redirect("/admin/categories")); + } + + let slug = normalized_slug_or_name(&form.slug, &name); + let item = categories::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or(Error::NotFound)?; + + let mut model = item.into_active_model(); + model.name = Set(Some(name)); + model.slug = Set(slug); + let _ = model.update(&ctx.db).await?; + + Ok(format::redirect("/admin/categories")) +} + +pub async fn categories_delete( + Path(id): Path, + State(ctx): State, +) -> Result { + check_auth()?; + + if let Some(item) = categories::Entity::find_by_id(id).one(&ctx.db).await? { + let _ = item.delete(&ctx.db).await?; + } + + Ok(format::redirect("/admin/categories")) +} + +pub async fn tags_admin( + view_engine: ViewEngine, + State(ctx): State, +) -> Result { + check_auth()?; + content::sync_markdown_posts(&ctx).await?; + + let tag_items = tags::Entity::find() + .order_by_asc(tags::Column::Slug) + .all(&ctx.db) + .await?; + let post_items = posts::Entity::find().all(&ctx.db).await?; + + let rows = tag_items + .iter() + .map(|tag| { + let tag_name = non_empty(tag.name.as_deref(), "未命名标签"); + let tag_key = if tag.slug.trim().is_empty() { + tag_name.clone() + } else { + tag.slug.clone() + }; + let usage_count = post_items + .iter() + .filter(|post| { + json_string_array(&post.tags) + .iter() + .any(|item| matches_tag(item, &tag_name, &tag.slug)) + }) + .count(); + + TagRow { + id: tag.id, + name: tag_name.clone(), + slug: tag.slug.clone(), + usage_count, + frontend_url: frontend_query_url("/tags", "tag", &tag_key), + articles_url: frontend_query_url("/articles", "tag", &tag_key), + api_url: format!("/api/tags/{}", tag.id), + } + }) + .collect::>(); + + let mut context = page_context("标签管理", "维护标签字典。Markdown 导入文章时,如果标签不存在会自动创建;已存在则复用现有标签。", "tags"); + context.insert( + "header_actions".into(), + json!([ + action("前台标签页", frontend_path("/tags"), "primary", true), + action("标签 API", "/api/tags".to_string(), "ghost", true), + action("前台文章列表", frontend_path("/articles"), "ghost", true), + ]), + ); + context.insert( + "create_form".into(), + json!({ + "name": "", + "slug": "", + }), + ); + context.insert("rows".into(), json!(rows)); + + render_admin(&view_engine, "admin/tags.html", context) +} + +pub async fn tags_create( + State(ctx): State, + Form(form): Form, +) -> Result { + check_auth()?; + + let name = normalize_admin_text(&form.name); + if name.is_empty() { + return Ok(format::redirect("/admin/tags")); + } + + let slug = normalized_slug_or_name(&form.slug, &name); + let existing = tags::Entity::find() + .filter(tags::Column::Slug.eq(&slug)) + .one(&ctx.db) + .await?; + + if let Some(item) = existing { + let mut model = item.into_active_model(); + model.name = Set(Some(name)); + model.slug = Set(slug); + let _ = model.update(&ctx.db).await?; + } else { + let _ = tags::ActiveModel { + name: Set(Some(name)), + slug: Set(slug), + ..Default::default() + } + .insert(&ctx.db) + .await?; + } + + Ok(format::redirect("/admin/tags")) +} + +pub async fn tags_update( + Path(id): Path, + State(ctx): State, + Form(form): Form, +) -> Result { + check_auth()?; + + let name = normalize_admin_text(&form.name); + if name.is_empty() { + return Ok(format::redirect("/admin/tags")); + } + + let slug = normalized_slug_or_name(&form.slug, &name); + let item = tags::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or(Error::NotFound)?; + + let mut model = item.into_active_model(); + model.name = Set(Some(name)); + model.slug = Set(slug); + let _ = model.update(&ctx.db).await?; + + Ok(format::redirect("/admin/tags")) +} + +pub async fn tags_delete( + Path(id): Path, + State(ctx): State, +) -> Result { + check_auth()?; + + if let Some(item) = tags::Entity::find_by_id(id).one(&ctx.db).await? { + let _ = item.delete(&ctx.db).await?; + } + + Ok(format::redirect("/admin/tags")) +} + +pub async fn reviews_admin( + view_engine: ViewEngine, + State(ctx): State, +) -> Result { + check_auth()?; + content::sync_markdown_posts(&ctx).await?; + + let items = reviews::Entity::find() + .order_by_desc(reviews::Column::CreatedAt) + .all(&ctx.db) + .await?; + + let rows = items + .iter() + .map(|review| ReviewRow { + id: review.id, + title: non_empty(review.title.as_deref(), "未命名评价"), + review_type: non_empty(review.review_type.as_deref(), "game"), + rating: review.rating.unwrap_or(0), + review_date: non_empty(review.review_date.as_deref(), ""), + status: non_empty(review.status.as_deref(), "completed"), + description: non_empty(review.description.as_deref(), ""), + tags_input: review_tags_input(review.tags.as_deref()), + cover: non_empty(review.cover.as_deref(), "🎮"), + api_url: format!("/api/reviews/{}", review.id), + }) + .collect::>(); + + let mut context = page_context("评价管理", "创建和编辑评价内容,前台评价页直接读取数据库里的真实数据。", "reviews"); + context.insert( + "header_actions".into(), + json!([ + action("前台评价页", frontend_path("/reviews"), "primary", true), + action("评价 API", "/api/reviews".to_string(), "ghost", true), + ]), + ); + context.insert( + "create_form".into(), + json!({ + "title": "", + "review_type": "game", + "rating": 5, + "review_date": "", + "status": "completed", + "description": "", + "tags": "", + "cover": "🎮", + }), + ); + context.insert("rows".into(), json!(rows)); + + render_admin(&view_engine, "admin/reviews.html", context) +} + +pub async fn reviews_create( + State(ctx): State, + Form(form): Form, +) -> Result { + check_auth()?; + + let _ = reviews::ActiveModel { + title: Set(Some(normalize_admin_text(&form.title))), + review_type: Set(Some(normalize_admin_text(&form.review_type))), + rating: Set(Some(form.rating)), + review_date: Set(Some(normalize_admin_text(&form.review_date))), + status: Set(Some(normalize_admin_text(&form.status))), + description: Set(Some(normalize_admin_text(&form.description))), + tags: Set(Some(serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default())), + cover: Set(Some(normalize_admin_text(&form.cover))), + ..Default::default() + } + .insert(&ctx.db) + .await?; + + Ok(format::redirect("/admin/reviews")) +} + +pub async fn reviews_update( + Path(id): Path, + State(ctx): State, + Form(form): Form, +) -> Result { + check_auth()?; + + let item = reviews::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or(Error::NotFound)?; + + let mut model = item.into_active_model(); + model.title = Set(Some(normalize_admin_text(&form.title))); + model.review_type = Set(Some(normalize_admin_text(&form.review_type))); + model.rating = Set(Some(form.rating)); + model.review_date = Set(Some(normalize_admin_text(&form.review_date))); + model.status = Set(Some(normalize_admin_text(&form.status))); + model.description = Set(Some(normalize_admin_text(&form.description))); + model.tags = Set(Some(serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default())); + model.cover = Set(Some(normalize_admin_text(&form.cover))); + let _ = model.update(&ctx.db).await?; + + Ok(format::redirect("/admin/reviews")) +} + +pub async fn reviews_delete( + Path(id): Path, + State(ctx): State, +) -> Result { + check_auth()?; + + if let Some(item) = reviews::Entity::find_by_id(id).one(&ctx.db).await? { + let _ = item.delete(&ctx.db).await?; + } + + Ok(format::redirect("/admin/reviews")) +} + +pub async fn friend_links_admin( + view_engine: ViewEngine, + State(ctx): State, +) -> Result { + check_auth()?; + content::sync_markdown_posts(&ctx).await?; + + let items = friend_links::Entity::find() + .order_by_desc(friend_links::Column::CreatedAt) + .all(&ctx.db) + .await?; + + let rows = items + .iter() + .map(|link| FriendLinkRow { + id: link.id, + site_name: non_empty(link.site_name.as_deref(), "未命名站点"), + site_url: link.site_url.clone(), + description: non_empty(link.description.as_deref(), "暂无描述"), + category_name: non_empty(link.category.as_deref(), "未分类"), + status: link_status_text(link.status.as_deref().unwrap_or("pending")).to_string(), + created_at: link.created_at.format("%Y-%m-%d %H:%M").to_string(), + frontend_page_url: frontend_path("/friends"), + api_url: format!("/api/friend_links/{}", link.id), + }) + .collect::>(); + + let mut context = page_context("友链申请", "处理前台友链申请状态,并跳转到前台友链页或目标站点。", "friend_links"); + context.insert( + "header_actions".into(), + json!([ + action("前台友链页", frontend_path("/friends"), "primary", true), + action("友链 API", "/api/friend_links".to_string(), "ghost", true), + ]), + ); + context.insert("rows".into(), json!(rows)); + + render_admin(&view_engine, "admin/friend_links.html", context) +} + +fn tech_stack_text(item: &site_settings::Model) -> String { + item.tech_stack + .as_ref() + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .iter() + .filter_map(Value::as_str) + .collect::>() + .join("\n") +} + +pub async fn site_settings_admin( + view_engine: ViewEngine, + State(ctx): State, +) -> Result { + check_auth()?; + content::sync_markdown_posts(&ctx).await?; + + let item = site_settings::Entity::find() + .order_by_asc(site_settings::Column::Id) + .one(&ctx.db) + .await? + .ok_or(Error::NotFound)?; + + let mut context = page_context("站点设置", "修改首页、关于页、页脚和友链页读取的站点信息,并直接跳到前台预览。", "site_settings"); + context.insert( + "header_actions".into(), + json!([ + action("预览首页", frontend_path("/"), "primary", true), + action("预览关于页", frontend_path("/about"), "ghost", true), + action("预览友链页", frontend_path("/friends"), "ghost", true), + action("设置 API", "/api/site_settings".to_string(), "ghost", true), + ]), + ); + context.insert( + "form".into(), + json!({ + "site_name": non_empty(item.site_name.as_deref(), ""), + "site_short_name": non_empty(item.site_short_name.as_deref(), ""), + "site_url": non_empty(item.site_url.as_deref(), ""), + "site_title": non_empty(item.site_title.as_deref(), ""), + "site_description": non_empty(item.site_description.as_deref(), ""), + "hero_title": non_empty(item.hero_title.as_deref(), ""), + "hero_subtitle": non_empty(item.hero_subtitle.as_deref(), ""), + "owner_name": non_empty(item.owner_name.as_deref(), ""), + "owner_title": non_empty(item.owner_title.as_deref(), ""), + "owner_avatar_url": non_empty(item.owner_avatar_url.as_deref(), ""), + "location": non_empty(item.location.as_deref(), ""), + "social_github": non_empty(item.social_github.as_deref(), ""), + "social_twitter": non_empty(item.social_twitter.as_deref(), ""), + "social_email": non_empty(item.social_email.as_deref(), ""), + "owner_bio": non_empty(item.owner_bio.as_deref(), ""), + "tech_stack": tech_stack_text(&item), + }), + ); + + render_admin(&view_engine, "admin/site_settings.html", context) +} + +pub fn routes() -> Routes { + Routes::new() + .add("/", get(root)) + .add("/admin/login", get(login_page).post(login_submit)) + .add("/admin/logout", get(logout)) + .add("/admin", get(index)) + .add("/admin/posts", get(posts_admin).post(posts_create)) + .add("/admin/posts/import", post(posts_import)) + .add("/admin/posts/{slug}/edit", get(post_editor)) + .add("/admin/comments", get(comments_admin)) + .add("/admin/categories", get(categories_admin).post(categories_create)) + .add("/admin/categories/{id}/update", post(categories_update)) + .add("/admin/categories/{id}/delete", post(categories_delete)) + .add("/admin/tags", get(tags_admin).post(tags_create)) + .add("/admin/tags/{id}/update", post(tags_update)) + .add("/admin/tags/{id}/delete", post(tags_delete)) + .add("/admin/reviews", get(reviews_admin).post(reviews_create)) + .add("/admin/reviews/{id}/update", post(reviews_update)) + .add("/admin/reviews/{id}/delete", post(reviews_delete)) + .add("/admin/friend_links", get(friend_links_admin)) + .add("/admin/site-settings", get(site_settings_admin)) +} diff --git a/backend/src/controllers/auth.rs b/backend/src/controllers/auth.rs new file mode 100644 index 0000000..e66d448 --- /dev/null +++ b/backend/src/controllers/auth.rs @@ -0,0 +1,273 @@ +use crate::{ + mailers::auth::AuthMailer, + models::{ + _entities::users, + users::{LoginParams, RegisterParams}, + }, + views::auth::{CurrentResponse, LoginResponse}, +}; +use loco_rs::prelude::*; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::sync::OnceLock; + +pub static EMAIL_DOMAIN_RE: OnceLock = OnceLock::new(); + +fn get_allow_email_domain_re() -> &'static Regex { + EMAIL_DOMAIN_RE.get_or_init(|| { + Regex::new(r"@example\.com$|@gmail\.com$").expect("Failed to compile regex") + }) +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ForgotParams { + pub email: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResetParams { + pub token: String, + pub password: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct MagicLinkParams { + pub email: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResendVerificationParams { + pub email: String, +} + +/// Register function creates a new user with the given parameters and sends a +/// welcome email to the user +#[debug_handler] +async fn register( + State(ctx): State, + Json(params): Json, +) -> Result { + let res = users::Model::create_with_password(&ctx.db, ¶ms).await; + + let user = match res { + Ok(user) => user, + Err(err) => { + tracing::info!( + message = err.to_string(), + user_email = ¶ms.email, + "could not register user", + ); + return format::json(()); + } + }; + + let user = user + .into_active_model() + .set_email_verification_sent(&ctx.db) + .await?; + + AuthMailer::send_welcome(&ctx, &user).await?; + + format::json(()) +} + +/// Verify register user. if the user not verified his email, he can't login to +/// the system. +#[debug_handler] +async fn verify(State(ctx): State, Path(token): Path) -> Result { + let Ok(user) = users::Model::find_by_verification_token(&ctx.db, &token).await else { + return unauthorized("invalid token"); + }; + + if user.email_verified_at.is_some() { + tracing::info!(pid = user.pid.to_string(), "user already verified"); + } else { + let active_model = user.into_active_model(); + let user = active_model.verified(&ctx.db).await?; + tracing::info!(pid = user.pid.to_string(), "user verified"); + } + + format::json(()) +} + +/// In case the user forgot his password this endpoints generate a forgot token +/// and send email to the user. In case the email not found in our DB, we are +/// returning a valid request for for security reasons (not exposing users DB +/// list). +#[debug_handler] +async fn forgot( + State(ctx): State, + Json(params): Json, +) -> Result { + let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { + // we don't want to expose our users email. if the email is invalid we still + // returning success to the caller + return format::json(()); + }; + + let user = user + .into_active_model() + .set_forgot_password_sent(&ctx.db) + .await?; + + AuthMailer::forgot_password(&ctx, &user).await?; + + format::json(()) +} + +/// reset user password by the given parameters +#[debug_handler] +async fn reset(State(ctx): State, Json(params): Json) -> Result { + let Ok(user) = users::Model::find_by_reset_token(&ctx.db, ¶ms.token).await else { + // we don't want to expose our users email. if the email is invalid we still + // returning success to the caller + tracing::info!("reset token not found"); + + return format::json(()); + }; + user.into_active_model() + .reset_password(&ctx.db, ¶ms.password) + .await?; + + format::json(()) +} + +/// Creates a user login and returns a token +#[debug_handler] +async fn login(State(ctx): State, Json(params): Json) -> Result { + let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { + tracing::debug!( + email = params.email, + "login attempt with non-existent email" + ); + return unauthorized("Invalid credentials!"); + }; + + let valid = user.verify_password(¶ms.password); + + if !valid { + return unauthorized("unauthorized!"); + } + + let jwt_secret = ctx.config.get_jwt_config()?; + + let token = user + .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) + .or_else(|_| unauthorized("unauthorized!"))?; + + format::json(LoginResponse::new(&user, &token)) +} + +#[debug_handler] +async fn current(auth: auth::JWT, State(ctx): State) -> Result { + let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?; + format::json(CurrentResponse::new(&user)) +} + +/// Magic link authentication provides a secure and passwordless way to log in to the application. +/// +/// # Flow +/// 1. **Request a Magic Link**: +/// A registered user sends a POST request to `/magic-link` with their email. +/// If the email exists, a short-lived, one-time-use token is generated and sent to the user's email. +/// For security and to avoid exposing whether an email exists, the response always returns 200, even if the email is invalid. +/// +/// 2. **Click the Magic Link**: +/// The user clicks the link (/magic-link/{token}), which validates the token and its expiration. +/// If valid, the server generates a JWT and responds with a [`LoginResponse`]. +/// If invalid or expired, an unauthorized response is returned. +/// +/// This flow enhances security by avoiding traditional passwords and providing a seamless login experience. +async fn magic_link( + State(ctx): State, + Json(params): Json, +) -> Result { + let email_regex = get_allow_email_domain_re(); + if !email_regex.is_match(¶ms.email) { + tracing::debug!( + email = params.email, + "The provided email is invalid or does not match the allowed domains" + ); + return bad_request("invalid request"); + } + + let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { + // we don't want to expose our users email. if the email is invalid we still + // returning success to the caller + tracing::debug!(email = params.email, "user not found by email"); + return format::empty_json(); + }; + + let user = user.into_active_model().create_magic_link(&ctx.db).await?; + AuthMailer::send_magic_link(&ctx, &user).await?; + + format::empty_json() +} + +/// Verifies a magic link token and authenticates the user. +async fn magic_link_verify( + Path(token): Path, + State(ctx): State, +) -> Result { + let Ok(user) = users::Model::find_by_magic_token(&ctx.db, &token).await else { + // we don't want to expose our users email. if the email is invalid we still + // returning success to the caller + return unauthorized("unauthorized!"); + }; + + let user = user.into_active_model().clear_magic_link(&ctx.db).await?; + + let jwt_secret = ctx.config.get_jwt_config()?; + + let token = user + .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) + .or_else(|_| unauthorized("unauthorized!"))?; + + format::json(LoginResponse::new(&user, &token)) +} + +#[debug_handler] +async fn resend_verification_email( + State(ctx): State, + Json(params): Json, +) -> Result { + let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { + tracing::info!( + email = params.email, + "User not found for resend verification" + ); + return format::json(()); + }; + + if user.email_verified_at.is_some() { + tracing::info!( + pid = user.pid.to_string(), + "User already verified, skipping resend" + ); + return format::json(()); + } + + let user = user + .into_active_model() + .set_email_verification_sent(&ctx.db) + .await?; + + AuthMailer::send_welcome(&ctx, &user).await?; + tracing::info!(pid = user.pid.to_string(), "Verification email re-sent"); + + format::json(()) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("/api/auth") + .add("/register", post(register)) + .add("/verify/{token}", get(verify)) + .add("/login", post(login)) + .add("/forgot", post(forgot)) + .add("/reset", post(reset)) + .add("/current", get(current)) + .add("/magic-link", post(magic_link)) + .add("/magic-link/{token}", get(magic_link_verify)) + .add("/resend-verification-mail", post(resend_verification_email)) +} diff --git a/backend/src/controllers/category.rs b/backend/src/controllers/category.rs new file mode 100644 index 0000000..0d75f08 --- /dev/null +++ b/backend/src/controllers/category.rs @@ -0,0 +1,166 @@ +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use loco_rs::prelude::*; +use sea_orm::{ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set}; +use serde::{Deserialize, Serialize}; + +use crate::models::_entities::{categories, posts}; +use crate::services::content; + +#[derive(Clone, Debug, Serialize)] +pub struct CategorySummary { + pub id: i32, + pub name: String, + pub slug: String, + pub count: usize, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Params { + pub name: Option, + pub slug: Option, +} + +fn slugify(value: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + + for ch in value.trim().chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + last_was_dash = false; + } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash { + slug.push('-'); + last_was_dash = true; + } + } + + slug.trim_matches('-').to_string() +} + +fn normalized_name(params: &Params) -> Result { + let name = params + .name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| Error::BadRequest("category name is required".to_string()))?; + + Ok(name.to_string()) +} + +fn normalized_slug(params: &Params, fallback: &str) -> String { + params + .slug + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| slugify(fallback)) +} + +async fn load_item(ctx: &AppContext, id: i32) -> Result { + let item = categories::Entity::find_by_id(id).one(&ctx.db).await?; + item.ok_or(Error::NotFound) +} + +#[debug_handler] +pub async fn list(State(ctx): State) -> Result { + content::sync_markdown_posts(&ctx).await?; + + let category_items = categories::Entity::find() + .order_by_asc(categories::Column::Slug) + .all(&ctx.db) + .await?; + let post_items = posts::Entity::find().all(&ctx.db).await?; + + let categories = category_items + .into_iter() + .map(|category| { + let name = category + .name + .clone() + .unwrap_or_else(|| category.slug.clone()); + let count = post_items + .iter() + .filter(|post| post.category.as_deref().map(str::trim) == Some(name.as_str())) + .count(); + + CategorySummary { + id: category.id, + name, + slug: category.slug, + count, + } + }) + .collect::>(); + + format::json(categories) +} + +#[debug_handler] +pub async fn add(State(ctx): State, Json(params): Json) -> Result { + let name = normalized_name(¶ms)?; + let slug = normalized_slug(¶ms, &name); + + let existing = categories::Entity::find() + .filter(categories::Column::Slug.eq(&slug)) + .one(&ctx.db) + .await?; + + let item = if let Some(existing_category) = existing { + let mut model = existing_category.into_active_model(); + model.name = Set(Some(name)); + model.slug = Set(slug); + model.update(&ctx.db).await? + } else { + categories::ActiveModel { + name: Set(Some(name)), + slug: Set(slug), + ..Default::default() + } + .insert(&ctx.db) + .await? + }; + + format::json(item) +} + +#[debug_handler] +pub async fn update( + Path(id): Path, + State(ctx): State, + Json(params): Json, +) -> Result { + let name = normalized_name(¶ms)?; + let slug = normalized_slug(¶ms, &name); + let item = load_item(&ctx, id).await?; + let mut item = item.into_active_model(); + item.name = Set(Some(name)); + item.slug = Set(slug); + let item = item.update(&ctx.db).await?; + format::json(item) +} + +#[debug_handler] +pub async fn remove(Path(id): Path, State(ctx): State) -> Result { + load_item(&ctx, id).await?.delete(&ctx.db).await?; + format::empty() +} + +#[debug_handler] +pub async fn get_one(Path(id): Path, State(ctx): State) -> Result { + format::json(load_item(&ctx, id).await?) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("/api/categories") + .add("/", get(list)) + .add("/", post(add)) + .add("/{id}", get(get_one)) + .add("/{id}", delete(remove)) + .add("/{id}", put(update)) + .add("/{id}", patch(update)) +} diff --git a/backend/src/controllers/comment.rs b/backend/src/controllers/comment.rs new file mode 100644 index 0000000..5440792 --- /dev/null +++ b/backend/src/controllers/comment.rs @@ -0,0 +1,193 @@ +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use loco_rs::prelude::*; +use sea_orm::{ColumnTrait, QueryFilter, QueryOrder}; +use serde::{Deserialize, Serialize}; + +use crate::models::_entities::{ + comments::{ActiveModel, Column, Entity, Model}, + posts, +}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Params { + pub post_id: Option, + pub post_slug: Option, + pub author: Option, + pub email: Option, + pub avatar: Option, + pub content: Option, + pub reply_to: Option, + pub approved: Option, +} + +impl Params { + fn update(&self, item: &mut ActiveModel) { + if let Some(post_id) = self.post_id { + item.post_id = Set(Some(post_id)); + } + if let Some(post_slug) = &self.post_slug { + item.post_slug = Set(Some(post_slug.clone())); + } + if let Some(author) = &self.author { + item.author = Set(Some(author.clone())); + } + if let Some(email) = &self.email { + item.email = Set(Some(email.clone())); + } + if let Some(avatar) = &self.avatar { + item.avatar = Set(Some(avatar.clone())); + } + if let Some(content) = &self.content { + item.content = Set(Some(content.clone())); + } + if let Some(reply_to) = self.reply_to { + item.reply_to = Set(Some(reply_to)); + } + if let Some(approved) = self.approved { + item.approved = Set(Some(approved)); + } + } +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct ListQuery { + pub post_id: Option, + pub post_slug: Option, + pub approved: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct CreateCommentRequest { + #[serde(default, alias = "postId")] + pub post_id: Option, + #[serde(default, alias = "postSlug")] + pub post_slug: Option, + #[serde(default, alias = "nickname")] + pub author: Option, + #[serde(default)] + pub email: Option, + #[serde(default)] + pub avatar: Option, + #[serde(default)] + pub content: Option, + #[serde(default, alias = "replyTo")] + pub reply_to: Option, + #[serde(default)] + pub approved: Option, +} + +async fn load_item(ctx: &AppContext, id: i32) -> Result { + let item = Entity::find_by_id(id).one(&ctx.db).await?; + item.ok_or_else(|| Error::NotFound) +} + +async fn resolve_post_slug(ctx: &AppContext, raw: &str) -> Result> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + if let Ok(id) = trimmed.parse::() { + let post = posts::Entity::find_by_id(id).one(&ctx.db).await?; + return Ok(post.map(|item| item.slug)); + } + + Ok(Some(trimmed.to_string())) +} + +#[debug_handler] +pub async fn list( + Query(query): Query, + State(ctx): State, +) -> Result { + let mut db_query = Entity::find().order_by_asc(Column::CreatedAt); + + let post_slug = if let Some(post_slug) = query.post_slug { + Some(post_slug) + } else if let Some(post_id) = query.post_id { + resolve_post_slug(&ctx, &post_id).await? + } else { + None + }; + + if let Some(post_slug) = post_slug { + db_query = db_query.filter(Column::PostSlug.eq(post_slug)); + } + + if let Some(approved) = query.approved { + db_query = db_query.filter(Column::Approved.eq(approved)); + } + + format::json(db_query.all(&ctx.db).await?) +} + +#[debug_handler] +pub async fn add( + State(ctx): State, + Json(params): Json, +) -> Result { + let post_slug = if let Some(post_slug) = params.post_slug.as_deref() { + Some(post_slug.to_string()) + } else if let Some(post_id) = params.post_id.as_deref() { + resolve_post_slug(&ctx, post_id).await? + } else { + None + }; + + let mut item = ActiveModel { + ..Default::default() + }; + item.post_id = Set(params + .post_id + .as_deref() + .and_then(|value| Uuid::parse_str(value).ok())); + item.post_slug = Set(post_slug); + item.author = Set(params.author); + item.email = Set(params.email); + item.avatar = Set(params.avatar); + item.content = Set(params.content); + item.reply_to = Set(params + .reply_to + .as_deref() + .and_then(|value| Uuid::parse_str(value).ok())); + item.approved = Set(Some(params.approved.unwrap_or(false))); + let item = item.insert(&ctx.db).await?; + format::json(item) +} + +#[debug_handler] +pub async fn update( + Path(id): Path, + State(ctx): State, + Json(params): Json, +) -> Result { + let item = load_item(&ctx, id).await?; + let mut item = item.into_active_model(); + params.update(&mut item); + let item = item.update(&ctx.db).await?; + format::json(item) +} + +#[debug_handler] +pub async fn remove(Path(id): Path, State(ctx): State) -> Result { + load_item(&ctx, id).await?.delete(&ctx.db).await?; + format::empty() +} + +#[debug_handler] +pub async fn get_one(Path(id): Path, State(ctx): State) -> Result { + format::json(load_item(&ctx, id).await?) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("api/comments/") + .add("/", get(list)) + .add("/", post(add)) + .add("{id}", get(get_one)) + .add("{id}", delete(remove)) + .add("{id}", put(update)) + .add("{id}", patch(update)) +} diff --git a/backend/src/controllers/friend_link.rs b/backend/src/controllers/friend_link.rs new file mode 100644 index 0000000..4edc11d --- /dev/null +++ b/backend/src/controllers/friend_link.rs @@ -0,0 +1,137 @@ +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use loco_rs::prelude::*; +use sea_orm::{ColumnTrait, QueryFilter, QueryOrder}; +use serde::{Deserialize, Serialize}; + +use crate::models::_entities::friend_links::{ActiveModel, Column, Entity, Model}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Params { + pub site_name: Option, + pub site_url: String, + pub avatar_url: Option, + pub description: Option, + pub category: Option, + pub status: Option, +} + +impl Params { + fn update(&self, item: &mut ActiveModel) { + item.site_url = Set(self.site_url.clone()); + if let Some(site_name) = &self.site_name { + item.site_name = Set(Some(site_name.clone())); + } + if let Some(avatar_url) = &self.avatar_url { + item.avatar_url = Set(Some(avatar_url.clone())); + } + if let Some(description) = &self.description { + item.description = Set(Some(description.clone())); + } + if let Some(category) = &self.category { + item.category = Set(Some(category.clone())); + } + if let Some(status) = &self.status { + item.status = Set(Some(status.clone())); + } + } +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct ListQuery { + pub status: Option, + pub category: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct CreateFriendLinkRequest { + #[serde(default, alias = "siteName")] + pub site_name: Option, + #[serde(alias = "siteUrl")] + pub site_url: String, + #[serde(default, alias = "avatarUrl")] + pub avatar_url: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub category: Option, + #[serde(default)] + pub status: Option, +} + +async fn load_item(ctx: &AppContext, id: i32) -> Result { + let item = Entity::find_by_id(id).one(&ctx.db).await?; + item.ok_or_else(|| Error::NotFound) +} + +#[debug_handler] +pub async fn list( + Query(query): Query, + State(ctx): State, +) -> Result { + let mut db_query = Entity::find().order_by_desc(Column::CreatedAt); + + if let Some(status) = query.status { + db_query = db_query.filter(Column::Status.eq(status)); + } + + if let Some(category) = query.category { + db_query = db_query.filter(Column::Category.eq(category)); + } + + format::json(db_query.all(&ctx.db).await?) +} + +#[debug_handler] +pub async fn add( + State(ctx): State, + Json(params): Json, +) -> Result { + let mut item = ActiveModel { + ..Default::default() + }; + item.site_name = Set(params.site_name); + item.site_url = Set(params.site_url); + item.avatar_url = Set(params.avatar_url); + item.description = Set(params.description); + item.category = Set(params.category); + item.status = Set(Some(params.status.unwrap_or_else(|| "pending".to_string()))); + let item = item.insert(&ctx.db).await?; + format::json(item) +} + +#[debug_handler] +pub async fn update( + Path(id): Path, + State(ctx): State, + Json(params): Json, +) -> Result { + let item = load_item(&ctx, id).await?; + let mut item = item.into_active_model(); + params.update(&mut item); + let item = item.update(&ctx.db).await?; + format::json(item) +} + +#[debug_handler] +pub async fn remove(Path(id): Path, State(ctx): State) -> Result { + load_item(&ctx, id).await?.delete(&ctx.db).await?; + format::empty() +} + +#[debug_handler] +pub async fn get_one(Path(id): Path, State(ctx): State) -> Result { + format::json(load_item(&ctx, id).await?) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("api/friend_links/") + .add("/", get(list)) + .add("/", post(add)) + .add("{id}", get(get_one)) + .add("{id}", delete(remove)) + .add("{id}", put(update)) + .add("{id}", patch(update)) +} diff --git a/backend/src/controllers/mod.rs b/backend/src/controllers/mod.rs new file mode 100644 index 0000000..bf678f9 --- /dev/null +++ b/backend/src/controllers/mod.rs @@ -0,0 +1,10 @@ +pub mod admin; +pub mod auth; +pub mod category; +pub mod comment; +pub mod friend_link; +pub mod post; +pub mod review; +pub mod search; +pub mod site_settings; +pub mod tag; diff --git a/backend/src/controllers/post.rs b/backend/src/controllers/post.rs new file mode 100644 index 0000000..c4767ff --- /dev/null +++ b/backend/src/controllers/post.rs @@ -0,0 +1,263 @@ +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use loco_rs::prelude::*; +use sea_orm::QueryOrder; +use serde::{Deserialize, Serialize}; + +use crate::models::_entities::posts::{ActiveModel, Column, Entity, Model}; +use crate::services::content; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Params { + pub title: Option, + pub slug: String, + pub description: Option, + pub content: Option, + pub category: Option, + pub tags: Option, + pub post_type: Option, + pub image: Option, + pub pinned: Option, +} + +impl Params { + fn update(&self, item: &mut ActiveModel) { + item.title = Set(self.title.clone()); + item.slug = Set(self.slug.clone()); + item.description = Set(self.description.clone()); + item.content = Set(self.content.clone()); + item.category = Set(self.category.clone()); + item.tags = Set(self.tags.clone()); + item.post_type = Set(self.post_type.clone()); + item.image = Set(self.image.clone()); + item.pinned = Set(self.pinned); + } +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct ListQuery { + pub slug: Option, + pub category: Option, + pub tag: Option, + pub search: Option, + #[serde(alias = "type")] + pub post_type: Option, + pub pinned: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct MarkdownUpdateParams { + pub markdown: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct MarkdownDocumentResponse { + pub slug: String, + pub path: String, + pub markdown: String, +} + +async fn load_item(ctx: &AppContext, id: i32) -> Result { + let item = Entity::find_by_id(id).one(&ctx.db).await?; + item.ok_or_else(|| Error::NotFound) +} + +async fn load_item_by_slug(ctx: &AppContext, slug: &str) -> Result { + let item = Entity::find() + .filter(Column::Slug.eq(slug)) + .one(&ctx.db) + .await?; + + item.ok_or_else(|| Error::NotFound) +} + +fn post_has_tag(post: &Model, wanted_tag: &str) -> bool { + let wanted = wanted_tag.trim().to_lowercase(); + + post.tags + .as_ref() + .and_then(|value| value.as_array()) + .map(|tags| { + tags.iter().filter_map(|tag| tag.as_str()).any(|tag| { + let normalized = tag.trim().to_lowercase(); + normalized == wanted + }) + }) + .unwrap_or(false) +} + +#[debug_handler] +pub async fn list( + Query(query): Query, + State(ctx): State, +) -> Result { + content::sync_markdown_posts(&ctx).await?; + + let posts = Entity::find() + .order_by_desc(Column::CreatedAt) + .all(&ctx.db) + .await?; + + let filtered: Vec = posts + .into_iter() + .filter(|post| { + if let Some(slug) = &query.slug { + if post.slug != *slug { + return false; + } + } + + if let Some(category) = &query.category { + if post + .category + .as_deref() + .map(|value| !value.eq_ignore_ascii_case(category)) + .unwrap_or(true) + { + return false; + } + } + + if let Some(post_type) = &query.post_type { + if post + .post_type + .as_deref() + .map(|value| !value.eq_ignore_ascii_case(post_type)) + .unwrap_or(true) + { + return false; + } + } + + if let Some(pinned) = query.pinned { + if post.pinned.unwrap_or(false) != pinned { + return false; + } + } + + if let Some(tag) = &query.tag { + if !post_has_tag(post, tag) { + return false; + } + } + + if let Some(search) = &query.search { + let wanted = search.trim().to_lowercase(); + let haystack = [ + post.title.as_deref().unwrap_or_default(), + post.description.as_deref().unwrap_or_default(), + post.content.as_deref().unwrap_or_default(), + post.category.as_deref().unwrap_or_default(), + &post.slug, + ] + .join("\n") + .to_lowercase(); + + if !haystack.contains(&wanted) + && !post + .tags + .as_ref() + .and_then(|value| value.as_array()) + .map(|tags| { + tags.iter() + .filter_map(|tag| tag.as_str()) + .any(|tag| tag.to_lowercase().contains(&wanted)) + }) + .unwrap_or(false) + { + return false; + } + } + + true + }) + .collect(); + + format::json(filtered) +} + +#[debug_handler] +pub async fn add(State(ctx): State, Json(params): Json) -> Result { + let mut item = ActiveModel { + ..Default::default() + }; + params.update(&mut item); + let item = item.insert(&ctx.db).await?; + format::json(item) +} + +#[debug_handler] +pub async fn update( + Path(id): Path, + State(ctx): State, + Json(params): Json, +) -> Result { + let item = load_item(&ctx, id).await?; + let mut item = item.into_active_model(); + params.update(&mut item); + let item = item.update(&ctx.db).await?; + format::json(item) +} + +#[debug_handler] +pub async fn remove(Path(id): Path, State(ctx): State) -> Result { + load_item(&ctx, id).await?.delete(&ctx.db).await?; + format::empty() +} + +#[debug_handler] +pub async fn get_one(Path(id): Path, State(ctx): State) -> Result { + content::sync_markdown_posts(&ctx).await?; + format::json(load_item(&ctx, id).await?) +} + +#[debug_handler] +pub async fn get_by_slug( + Path(slug): Path, + State(ctx): State, +) -> Result { + content::sync_markdown_posts(&ctx).await?; + format::json(load_item_by_slug(&ctx, &slug).await?) +} + +#[debug_handler] +pub async fn get_markdown_by_slug( + Path(slug): Path, + State(ctx): State, +) -> Result { + content::sync_markdown_posts(&ctx).await?; + let (path, markdown) = content::read_markdown_document(&slug)?; + format::json(MarkdownDocumentResponse { slug, path, markdown }) +} + +#[debug_handler] +pub async fn update_markdown_by_slug( + Path(slug): Path, + State(ctx): State, + Json(params): Json, +) -> Result { + let updated = content::write_markdown_document(&ctx, &slug, ¶ms.markdown).await?; + let (path, markdown) = content::read_markdown_document(&updated.slug)?; + + format::json(MarkdownDocumentResponse { + slug: updated.slug, + path, + markdown, + }) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("api/posts/") + .add("/", get(list)) + .add("/", post(add)) + .add("slug/{slug}/markdown", get(get_markdown_by_slug)) + .add("slug/{slug}/markdown", put(update_markdown_by_slug)) + .add("slug/{slug}/markdown", patch(update_markdown_by_slug)) + .add("slug/{slug}", get(get_by_slug)) + .add("{id}", get(get_one)) + .add("{id}", delete(remove)) + .add("{id}", put(update)) + .add("{id}", patch(update)) +} diff --git a/backend/src/controllers/review.rs b/backend/src/controllers/review.rs new file mode 100644 index 0000000..6757444 --- /dev/null +++ b/backend/src/controllers/review.rs @@ -0,0 +1,133 @@ +use axum::extract::{Path, State}; +use loco_rs::prelude::*; +use sea_orm::{EntityTrait, QueryOrder, Set}; +use serde::{Deserialize, Serialize}; + +use crate::models::_entities::reviews::{self, Entity as ReviewEntity}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateReviewRequest { + pub title: String, + pub review_type: String, + pub rating: i32, + pub review_date: String, + pub status: String, + pub description: String, + pub tags: Vec, + pub cover: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UpdateReviewRequest { + pub title: Option, + pub review_type: Option, + pub rating: Option, + pub review_date: Option, + pub status: Option, + pub description: Option, + pub tags: Option>, + pub cover: Option, +} + +pub async fn list(State(ctx): State) -> Result { + let reviews = ReviewEntity::find() + .order_by_desc(reviews::Column::CreatedAt) + .all(&ctx.db) + .await?; + + format::json(reviews) +} + +pub async fn get_one( + Path(id): Path, + State(ctx): State, +) -> Result { + let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?; + + match review { + Some(r) => format::json(r), + None => Err(Error::NotFound), + } +} + +pub async fn create( + State(ctx): State, + Json(req): Json, +) -> Result { + let new_review = reviews::ActiveModel { + title: Set(Some(req.title)), + review_type: Set(Some(req.review_type)), + rating: Set(Some(req.rating)), + review_date: Set(Some(req.review_date)), + status: Set(Some(req.status)), + description: Set(Some(req.description)), + tags: Set(Some(serde_json::to_string(&req.tags).unwrap_or_default())), + cover: Set(Some(req.cover)), + ..Default::default() + }; + + let review = new_review.insert(&ctx.db).await?; + format::json(review) +} + +pub async fn update( + Path(id): Path, + State(ctx): State, + Json(req): Json, +) -> Result { + let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?; + + let Some(mut review) = review.map(|r| r.into_active_model()) else { + return Err(Error::NotFound); + }; + + if let Some(title) = req.title { + review.title = Set(Some(title)); + } + if let Some(review_type) = req.review_type { + review.review_type = Set(Some(review_type)); + } + if let Some(rating) = req.rating { + review.rating = Set(Some(rating)); + } + if let Some(review_date) = req.review_date { + review.review_date = Set(Some(review_date)); + } + if let Some(status) = req.status { + review.status = Set(Some(status)); + } + if let Some(description) = req.description { + review.description = Set(Some(description)); + } + if let Some(tags) = req.tags { + review.tags = Set(Some(serde_json::to_string(&tags).unwrap_or_default())); + } + if let Some(cover) = req.cover { + review.cover = Set(Some(cover)); + } + + let review = review.update(&ctx.db).await?; + format::json(review) +} + +pub async fn remove( + Path(id): Path, + State(ctx): State, +) -> Result { + let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?; + + match review { + Some(r) => { + r.delete(&ctx.db).await?; + format::empty() + } + None => Err(Error::NotFound), + } +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("/api/reviews") + .add("/", get(list).post(create)) + .add("/{id}", get(get_one).put(update).delete(remove)) +} diff --git a/backend/src/controllers/search.rs b/backend/src/controllers/search.rs new file mode 100644 index 0000000..4322bf1 --- /dev/null +++ b/backend/src/controllers/search.rs @@ -0,0 +1,190 @@ +use loco_rs::prelude::*; +use sea_orm::{ConnectionTrait, DatabaseBackend, DbBackend, FromQueryResult, Statement}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::models::_entities::posts; +use crate::services::content; + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct SearchQuery { + pub q: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, Serialize, FromQueryResult)] +pub struct SearchResult { + pub id: i32, + pub title: Option, + pub slug: String, + pub description: Option, + pub content: Option, + pub category: Option, + pub tags: Option, + pub post_type: Option, + pub pinned: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub rank: f64, +} + +fn search_sql() -> &'static str { + r#" + SELECT + p.id, + p.title, + p.slug, + p.description, + p.content, + p.category, + p.tags, + p.post_type, + p.pinned, + p.created_at, + p.updated_at, + ts_rank_cd( + setweight(to_tsvector('simple', coalesce(p.title, '')), 'A') || + setweight(to_tsvector('simple', coalesce(p.description, '')), 'B') || + setweight(to_tsvector('simple', coalesce(p.category, '')), 'C') || + setweight(to_tsvector('simple', coalesce(p.tags::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(p.content, '')), 'D'), + plainto_tsquery('simple', $1) + )::float8 AS rank + FROM posts p + WHERE ( + setweight(to_tsvector('simple', coalesce(p.title, '')), 'A') || + setweight(to_tsvector('simple', coalesce(p.description, '')), 'B') || + setweight(to_tsvector('simple', coalesce(p.category, '')), 'C') || + setweight(to_tsvector('simple', coalesce(p.tags::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(p.content, '')), 'D') + ) @@ plainto_tsquery('simple', $1) + ORDER BY rank DESC, p.created_at DESC + LIMIT $2 + "# +} + +fn app_level_rank(post: &posts::Model, wanted: &str) -> f64 { + let wanted_lower = wanted.to_lowercase(); + let mut rank = 0.0; + + if post + .title + .as_deref() + .unwrap_or_default() + .to_lowercase() + .contains(&wanted_lower) + { + rank += 4.0; + } + + if post + .description + .as_deref() + .unwrap_or_default() + .to_lowercase() + .contains(&wanted_lower) + { + rank += 2.5; + } + + if post + .content + .as_deref() + .unwrap_or_default() + .to_lowercase() + .contains(&wanted_lower) + { + rank += 1.0; + } + + if post + .category + .as_deref() + .unwrap_or_default() + .to_lowercase() + .contains(&wanted_lower) + { + rank += 1.5; + } + + if post + .tags + .as_ref() + .and_then(Value::as_array) + .map(|tags| { + tags.iter() + .filter_map(Value::as_str) + .any(|tag| tag.to_lowercase().contains(&wanted_lower)) + }) + .unwrap_or(false) + { + rank += 2.0; + } + + rank +} + +async fn fallback_search(ctx: &AppContext, q: &str, limit: u64) -> Result> { + let mut results = posts::Entity::find().all(&ctx.db).await?; + results.sort_by(|left, right| right.created_at.cmp(&left.created_at)); + + Ok(results + .into_iter() + .map(|post| { + let rank = app_level_rank(&post, q); + (post, rank) + }) + .filter(|(_, rank)| *rank > 0.0) + .take(limit as usize) + .map(|(post, rank)| SearchResult { + id: post.id, + title: post.title, + slug: post.slug, + description: post.description, + content: post.content, + category: post.category, + tags: post.tags, + post_type: post.post_type, + pinned: post.pinned, + created_at: post.created_at.into(), + updated_at: post.updated_at.into(), + rank, + }) + .collect()) +} + +#[debug_handler] +pub async fn search( + Query(query): Query, + State(ctx): State, +) -> Result { + content::sync_markdown_posts(&ctx).await?; + + let q = query.q.unwrap_or_default().trim().to_string(); + if q.is_empty() { + return format::json(Vec::::new()); + } + + let limit = query.limit.unwrap_or(20).clamp(1, 100); + + let results = if ctx.db.get_database_backend() == DatabaseBackend::Postgres { + let statement = Statement::from_sql_and_values( + DbBackend::Postgres, + search_sql(), + [q.clone().into(), (limit as i64).into()], + ); + + match SearchResult::find_by_statement(statement).all(&ctx.db).await { + Ok(rows) => rows, + Err(_) => fallback_search(&ctx, &q, limit).await?, + } + } else { + fallback_search(&ctx, &q, limit).await? + }; + + format::json(results) +} + +pub fn routes() -> Routes { + Routes::new().prefix("api/search/").add("/", get(search)) +} diff --git a/backend/src/controllers/site_settings.rs b/backend/src/controllers/site_settings.rs new file mode 100644 index 0000000..cb850af --- /dev/null +++ b/backend/src/controllers/site_settings.rs @@ -0,0 +1,179 @@ +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] + +use loco_rs::prelude::*; +use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set}; +use serde::{Deserialize, Serialize}; + +use crate::models::_entities::site_settings::{self, ActiveModel, Entity, Model}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct SiteSettingsPayload { + #[serde(default, alias = "siteName")] + pub site_name: Option, + #[serde(default, alias = "siteShortName")] + pub site_short_name: Option, + #[serde(default, alias = "siteUrl")] + pub site_url: Option, + #[serde(default, alias = "siteTitle")] + pub site_title: Option, + #[serde(default, alias = "siteDescription")] + pub site_description: Option, + #[serde(default, alias = "heroTitle")] + pub hero_title: Option, + #[serde(default, alias = "heroSubtitle")] + pub hero_subtitle: Option, + #[serde(default, alias = "ownerName")] + pub owner_name: Option, + #[serde(default, alias = "ownerTitle")] + pub owner_title: Option, + #[serde(default, alias = "ownerBio")] + pub owner_bio: Option, + #[serde(default, alias = "ownerAvatarUrl")] + pub owner_avatar_url: Option, + #[serde(default, alias = "socialGithub")] + pub social_github: Option, + #[serde(default, alias = "socialTwitter")] + pub social_twitter: Option, + #[serde(default, alias = "socialEmail")] + pub social_email: Option, + #[serde(default)] + pub location: Option, + #[serde(default, alias = "techStack")] + pub tech_stack: Option>, +} + +fn normalize_optional_string(value: Option) -> Option { + value.and_then(|item| { + let trimmed = item.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +impl SiteSettingsPayload { + fn apply(self, item: &mut ActiveModel) { + if let Some(site_name) = self.site_name { + item.site_name = Set(normalize_optional_string(Some(site_name))); + } + if let Some(site_short_name) = self.site_short_name { + item.site_short_name = Set(normalize_optional_string(Some(site_short_name))); + } + if let Some(site_url) = self.site_url { + item.site_url = Set(normalize_optional_string(Some(site_url))); + } + if let Some(site_title) = self.site_title { + item.site_title = Set(normalize_optional_string(Some(site_title))); + } + if let Some(site_description) = self.site_description { + item.site_description = Set(normalize_optional_string(Some(site_description))); + } + if let Some(hero_title) = self.hero_title { + item.hero_title = Set(normalize_optional_string(Some(hero_title))); + } + if let Some(hero_subtitle) = self.hero_subtitle { + item.hero_subtitle = Set(normalize_optional_string(Some(hero_subtitle))); + } + if let Some(owner_name) = self.owner_name { + item.owner_name = Set(normalize_optional_string(Some(owner_name))); + } + if let Some(owner_title) = self.owner_title { + item.owner_title = Set(normalize_optional_string(Some(owner_title))); + } + if let Some(owner_bio) = self.owner_bio { + item.owner_bio = Set(normalize_optional_string(Some(owner_bio))); + } + if let Some(owner_avatar_url) = self.owner_avatar_url { + item.owner_avatar_url = Set(normalize_optional_string(Some(owner_avatar_url))); + } + if let Some(social_github) = self.social_github { + item.social_github = Set(normalize_optional_string(Some(social_github))); + } + if let Some(social_twitter) = self.social_twitter { + item.social_twitter = Set(normalize_optional_string(Some(social_twitter))); + } + if let Some(social_email) = self.social_email { + item.social_email = Set(normalize_optional_string(Some(social_email))); + } + if let Some(location) = self.location { + item.location = Set(normalize_optional_string(Some(location))); + } + if let Some(tech_stack) = self.tech_stack { + item.tech_stack = Set(Some(serde_json::json!(tech_stack))); + } + } +} + +fn default_payload() -> SiteSettingsPayload { + SiteSettingsPayload { + site_name: Some("InitCool".to_string()), + site_short_name: Some("Termi".to_string()), + site_url: Some("https://termi.dev".to_string()), + site_title: Some("InitCool - 终端风格的内容平台".to_string()), + site_description: Some("一个基于终端美学的个人内容站,记录代码、设计和生活。".to_string()), + hero_title: Some("欢迎来到我的极客终端博客".to_string()), + hero_subtitle: Some("这里记录技术、代码和生活点滴".to_string()), + owner_name: Some("InitCool".to_string()), + owner_title: Some("前端开发者 / 技术博主".to_string()), + owner_bio: Some( + "一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。" + .to_string(), + ), + owner_avatar_url: None, + social_github: Some("https://github.com".to_string()), + social_twitter: Some("https://twitter.com".to_string()), + social_email: Some("mailto:hello@termi.dev".to_string()), + location: Some("Hong Kong".to_string()), + tech_stack: Some(vec![ + "Astro".to_string(), + "Svelte".to_string(), + "Tailwind CSS".to_string(), + "TypeScript".to_string(), + ]), + } +} + +async fn load_current(ctx: &AppContext) -> Result { + if let Some(settings) = Entity::find() + .order_by_asc(site_settings::Column::Id) + .one(&ctx.db) + .await? + { + return Ok(settings); + } + + let mut item = ActiveModel { + id: Set(1), + ..Default::default() + }; + default_payload().apply(&mut item); + Ok(item.insert(&ctx.db).await?) +} + +#[debug_handler] +pub async fn show(State(ctx): State) -> Result { + format::json(load_current(&ctx).await?) +} + +#[debug_handler] +pub async fn update( + State(ctx): State, + Json(params): Json, +) -> Result { + let current = load_current(&ctx).await?; + let mut item = current.into_active_model(); + params.apply(&mut item); + format::json(item.update(&ctx.db).await?) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("api/site_settings/") + .add("/", get(show)) + .add("/", put(update)) + .add("/", patch(update)) +} diff --git a/backend/src/controllers/tag.rs b/backend/src/controllers/tag.rs new file mode 100644 index 0000000..1e75473 --- /dev/null +++ b/backend/src/controllers/tag.rs @@ -0,0 +1,77 @@ +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use loco_rs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::models::_entities::tags::{ActiveModel, Entity, Model}; +use crate::services::content; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Params { + pub name: Option, + pub slug: String, +} + +impl Params { + fn update(&self, item: &mut ActiveModel) { + item.name = Set(self.name.clone()); + item.slug = Set(self.slug.clone()); + } +} + +async fn load_item(ctx: &AppContext, id: i32) -> Result { + let item = Entity::find_by_id(id).one(&ctx.db).await?; + item.ok_or_else(|| Error::NotFound) +} + +#[debug_handler] +pub async fn list(State(ctx): State) -> Result { + content::sync_markdown_posts(&ctx).await?; + format::json(Entity::find().all(&ctx.db).await?) +} + +#[debug_handler] +pub async fn add(State(ctx): State, Json(params): Json) -> Result { + let mut item = ActiveModel { + ..Default::default() + }; + params.update(&mut item); + let item = item.insert(&ctx.db).await?; + format::json(item) +} + +#[debug_handler] +pub async fn update( + Path(id): Path, + State(ctx): State, + Json(params): Json, +) -> Result { + let item = load_item(&ctx, id).await?; + let mut item = item.into_active_model(); + params.update(&mut item); + let item = item.update(&ctx.db).await?; + format::json(item) +} + +#[debug_handler] +pub async fn remove(Path(id): Path, State(ctx): State) -> Result { + load_item(&ctx, id).await?.delete(&ctx.db).await?; + format::empty() +} + +#[debug_handler] +pub async fn get_one(Path(id): Path, State(ctx): State) -> Result { + format::json(load_item(&ctx, id).await?) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("api/tags/") + .add("/", get(list)) + .add("/", post(add)) + .add("{id}", get(get_one)) + .add("{id}", delete(remove)) + .add("{id}", put(update)) + .add("{id}", patch(update)) +} diff --git a/backend/src/data/mod.rs b/backend/src/data/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/src/data/mod.rs @@ -0,0 +1 @@ + diff --git a/backend/src/fixtures/comments.yaml b/backend/src/fixtures/comments.yaml new file mode 100644 index 0000000..d2c4931 --- /dev/null +++ b/backend/src/fixtures/comments.yaml @@ -0,0 +1,48 @@ +- id: 1 + pid: 1 + author: "Alice" + email: "alice@example.com" + content: "Great introduction! Looking forward to more content." + approved: true + +- id: 2 + pid: 1 + author: "Bob" + email: "bob@example.com" + content: "The terminal UI looks amazing. Love the design!" + approved: true + +- id: 3 + pid: 2 + author: "Charlie" + email: "charlie@example.com" + content: "Thanks for the Rust tips! The ownership concept finally clicked for me." + approved: true + +- id: 4 + pid: 3 + author: "Diana" + email: "diana@example.com" + content: "Astro is indeed fast. I've been using it for my personal blog too." + approved: true + +- id: 5 + pid: 4 + author: "Eve" + email: "eve@example.com" + content: "The color palette you shared is perfect. Using it for my terminal theme now!" + approved: true + +- id: 6 + pid: 5 + author: "Frank" + email: "frank@example.com" + content: "Loco.rs looks promising. Might use it for my next project." + approved: false + +- id: 7 + pid: 2 + author: "Grace" + email: "grace@example.com" + content: "Would love to see more advanced Rust patterns in future posts." + approved: true diff --git a/backend/src/fixtures/friend_links.yaml b/backend/src/fixtures/friend_links.yaml new file mode 100644 index 0000000..50e9946 --- /dev/null +++ b/backend/src/fixtures/friend_links.yaml @@ -0,0 +1,38 @@ +- id: 1 + site_name: "Tech Blog Daily" + site_url: "https://techblog.example.com" + avatar_url: "https://techblog.example.com/avatar.png" + description: "Daily tech news and tutorials" + category: "tech" + status: "approved" + +- id: 2 + site_name: "Rustacean Station" + site_url: "https://rustacean.example.com" + avatar_url: "https://rustacean.example.com/logo.png" + description: "All things Rust programming" + category: "tech" + status: "approved" + +- id: 3 + site_name: "Design Patterns" + site_url: "https://designpatterns.example.com" + avatar_url: "https://designpatterns.example.com/icon.png" + description: "UI/UX design inspiration" + category: "design" + status: "approved" + +- id: 4 + site_name: "Code Snippets" + site_url: "https://codesnippets.example.com" + description: "Useful code snippets for developers" + category: "dev" + status: "pending" + +- id: 5 + site_name: "Web Dev Weekly" + site_url: "https://webdevweekly.example.com" + avatar_url: "https://webdevweekly.example.com/favicon.png" + description: "Weekly web development newsletter" + category: "dev" + status: "pending" diff --git a/backend/src/fixtures/posts.yaml b/backend/src/fixtures/posts.yaml new file mode 100644 index 0000000..75295c4 --- /dev/null +++ b/backend/src/fixtures/posts.yaml @@ -0,0 +1,191 @@ +- id: 1 + pid: 1 + title: "Welcome to Termi Blog" + slug: "welcome-to-termi" + content: | + # Welcome to Termi Blog + + This is the first post on our new blog built with Astro and Loco.rs backend. + + ## Features + + - 🚀 Fast performance with Astro + - 🎨 Terminal-style UI design + - 💬 Comments system + - 🔗 Friend links + - 🏷️ Tags and categories + + ## Code Example + + ```rust + fn main() { + println!("Hello, Termi!"); + } + ``` + + Stay tuned for more posts! + excerpt: "Welcome to our new blog built with Astro and Loco.rs backend." + category: "general" + published: true + pinned: true + tags: + - welcome + - astro + - loco-rs + +- id: 2 + pid: 2 + title: "Rust Programming Tips" + slug: "rust-programming-tips" + content: | + # Rust Programming Tips + + Here are some essential tips for Rust developers: + + ## 1. Ownership and Borrowing + + Understanding ownership is crucial in Rust. Every value has an owner, and there can only be one owner at a time. + + ## 2. Pattern Matching + + Use `match` expressions for exhaustive pattern matching: + + ```rust + match result { + Ok(value) => println!("Success: {}", value), + Err(e) => println!("Error: {}", e), + } + ``` + + ## 3. Error Handling + + Use `Result` and `Option` types effectively with the `?` operator. + + Happy coding! + excerpt: "Essential tips for Rust developers including ownership, pattern matching, and error handling." + category: "tech" + published: true + pinned: false + tags: + - rust + - programming + - tips + +- id: 3 + pid: 3 + title: "Building a Blog with Astro" + slug: "building-blog-with-astro" + content: | + # Building a Blog with Astro + + Astro is a modern static site generator that delivers lightning-fast performance. + + ## Why Astro? + + - **Zero JavaScript by default**: Ships less JavaScript to the client + - **Island Architecture**: Hydrate only interactive components + - **Framework Agnostic**: Use React, Vue, Svelte, or vanilla JS + - **Great DX**: Excellent developer experience with hot module replacement + + ## Getting Started + + ```bash + npm create astro@latest + cd my-astro-project + npm install + npm run dev + ``` + + ## Conclusion + + Astro is perfect for content-focused websites like blogs. + excerpt: "Learn why Astro is the perfect choice for building fast, content-focused blogs." + category: "tech" + published: true + pinned: false + tags: + - astro + - web-dev + - static-site + +- id: 4 + pid: 4 + title: "Terminal UI Design Principles" + slug: "terminal-ui-design" + content: | + # Terminal UI Design Principles + + Terminal-style interfaces are making a comeback in modern web design. + + ## Key Elements + + 1. **Monospace Fonts**: Use fonts like Fira Code, JetBrains Mono + 2. **Dark Themes**: Black or dark backgrounds with vibrant text colors + 3. **Command Prompts**: Use `$` or `>` as visual indicators + 4. **ASCII Art**: Decorative elements using text characters + 5. **Blinking Cursor**: The iconic terminal cursor + + ## Color Palette + + - Background: `#0d1117` + - Text: `#c9d1d9` + - Accent: `#58a6ff` + - Success: `#3fb950` + - Warning: `#d29922` + - Error: `#f85149` + + ## Implementation + + Use CSS to create the terminal aesthetic while maintaining accessibility. + excerpt: "Learn the key principles of designing beautiful terminal-style user interfaces." + category: "design" + published: true + pinned: false + tags: + - design + - terminal + - ui + +- id: 5 + pid: 5 + title: "Loco.rs Backend Framework" + slug: "loco-rs-framework" + content: | + # Introduction to Loco.rs + + Loco.rs is a web and API framework for Rust inspired by Rails. + + ## Features + + - **MVC Architecture**: Model-View-Controller pattern + - **SeaORM Integration**: Powerful ORM for database operations + - **Background Jobs**: Built-in job processing + - **Authentication**: Ready-to-use auth system + - **CLI Generator**: Scaffold resources quickly + + ## Quick Start + + ```bash + cargo install loco + loco new myapp + cd myapp + cargo loco start + ``` + + ## Why Loco.rs? + + - Opinionated but flexible + - Production-ready defaults + - Excellent documentation + - Active community + + Perfect for building APIs and web applications in Rust. + excerpt: "An introduction to Loco.rs, the Rails-inspired web framework for Rust." + category: "tech" + published: true + pinned: false + tags: + - rust + - loco-rs + - backend + - api diff --git a/backend/src/fixtures/reviews.yaml b/backend/src/fixtures/reviews.yaml new file mode 100644 index 0000000..73f63e9 --- /dev/null +++ b/backend/src/fixtures/reviews.yaml @@ -0,0 +1,59 @@ +- id: 1 + title: "塞尔达传说:王国之泪" + review_type: "game" + rating: 5 + review_date: "2024-03-20" + status: "completed" + description: "开放世界的巅峰之作,究极手能力带来无限创意空间" + tags: ["Switch", "开放世界", "冒险"] + cover: "🎮" + +- id: 2 + title: "进击的巨人" + review_type: "anime" + rating: 5 + review_date: "2023-11-10" + status: "completed" + description: "史诗级完结,剧情反转令人震撼" + tags: ["热血", "悬疑", "神作"] + cover: "🎭" + +- id: 3 + title: "赛博朋克 2077" + review_type: "game" + rating: 4 + review_date: "2024-01-15" + status: "completed" + description: "夜之城的故事,虽然首发有问题但后续更新很棒" + tags: ["PC", "RPG", "科幻"] + cover: "🎮" + +- id: 4 + title: "三体" + review_type: "book" + rating: 5 + review_date: "2023-08-05" + status: "completed" + description: "硬科幻巅峰,宇宙社会学的黑暗森林法则" + tags: ["科幻", "经典", "雨果奖"] + cover: "📚" + +- id: 5 + title: "星际穿越" + review_type: "movie" + rating: 5 + review_date: "2024-02-14" + status: "completed" + description: "诺兰神作,五维空间和黑洞的视觉奇观" + tags: ["科幻", "IMAX", "诺兰"] + cover: "🎬" + +- id: 6 + title: "博德之门3" + review_type: "game" + rating: 5 + review_date: "2024-04-01" + status: "in-progress" + description: "CRPG的文艺复兴,骰子决定命运" + tags: ["PC", "CRPG", "多人"] + cover: "🎮" diff --git a/backend/src/fixtures/site_settings.yaml b/backend/src/fixtures/site_settings.yaml new file mode 100644 index 0000000..4f9adbd --- /dev/null +++ b/backend/src/fixtures/site_settings.yaml @@ -0,0 +1,21 @@ +- id: 1 + site_name: "InitCool" + site_short_name: "Termi" + site_url: "https://termi.dev" + site_title: "InitCool - 终端风格的内容平台" + site_description: "一个基于终端美学的个人内容站,记录代码、设计和生活。" + hero_title: "欢迎来到我的极客终端博客" + hero_subtitle: "这里记录技术、代码和生活点滴" + owner_name: "InitCool" + owner_title: "前端开发者 / 技术博主" + owner_bio: "一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。" + owner_avatar_url: "" + social_github: "https://github.com" + social_twitter: "https://twitter.com" + social_email: "mailto:hello@termi.dev" + location: "Hong Kong" + tech_stack: + - "Astro" + - "Svelte" + - "Tailwind CSS" + - "TypeScript" diff --git a/backend/src/fixtures/tags.yaml b/backend/src/fixtures/tags.yaml new file mode 100644 index 0000000..b6b01ff --- /dev/null +++ b/backend/src/fixtures/tags.yaml @@ -0,0 +1,39 @@ +- id: 1 + name: "Welcome" + slug: "welcome" + +- id: 2 + name: "Astro" + slug: "astro" + +- id: 3 + name: "Rust" + slug: "rust" + +- id: 4 + name: "Programming" + slug: "programming" + +- id: 5 + name: "Tech" + slug: "tech" + +- id: 6 + name: "Design" + slug: "design" + +- id: 7 + name: "Terminal" + slug: "terminal" + +- id: 8 + name: "Loco.rs" + slug: "loco-rs" + +- id: 9 + name: "Backend" + slug: "backend" + +- id: 10 + name: "Web Dev" + slug: "web-dev" diff --git a/backend/src/fixtures/users.yaml b/backend/src/fixtures/users.yaml new file mode 100644 index 0000000..8f5b5ed --- /dev/null +++ b/backend/src/fixtures/users.yaml @@ -0,0 +1,17 @@ +--- +- id: 1 + pid: 11111111-1111-1111-1111-111111111111 + email: user1@example.com + password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc" + api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758 + name: user1 + created_at: "2023-11-12T12:34:56.789Z" + updated_at: "2023-11-12T12:34:56.789Z" +- id: 2 + pid: 22222222-2222-2222-2222-222222222222 + email: user2@example.com + password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc" + api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e + name: user2 + created_at: "2023-11-12T12:34:56.789Z" + updated_at: "2023-11-12T12:34:56.789Z" diff --git a/backend/src/initializers/content_sync.rs b/backend/src/initializers/content_sync.rs new file mode 100644 index 0000000..1be52c0 --- /dev/null +++ b/backend/src/initializers/content_sync.rs @@ -0,0 +1,191 @@ +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Initializer}, + Result, +}; +use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set}; +use std::path::{Path, PathBuf}; + +use crate::models::_entities::{comments, posts, site_settings}; +use crate::services::content; + +const FIXTURES_DIR: &str = "src/fixtures"; + +pub struct ContentSyncInitializer; + +#[async_trait] +impl Initializer for ContentSyncInitializer { + fn name(&self) -> String { + "content-sync".to_string() + } + + async fn before_run(&self, app_context: &AppContext) -> Result<()> { + sync_content(app_context, Path::new(FIXTURES_DIR)).await + } +} + +async fn sync_content(ctx: &AppContext, base: &Path) -> Result<()> { + content::sync_markdown_posts(ctx).await?; + sync_site_settings(ctx, base).await?; + sync_comment_post_slugs(ctx, base).await?; + Ok(()) +} + +fn read_fixture_rows(base: &Path, file_name: &str) -> Vec { + let path: PathBuf = base.join(file_name); + let seed_data = match std::fs::read_to_string(path) { + Ok(data) => data, + Err(_) => return vec![], + }; + + serde_yaml::from_str(&seed_data).unwrap_or_default() +} + +fn as_optional_string(value: &serde_json::Value) -> Option { + value.as_str().and_then(|item| { + let trimmed = item.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn is_blank(value: &Option) -> bool { + value.as_deref().map(str::trim).unwrap_or("").is_empty() +} + +async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { + let rows = read_fixture_rows(base, "site_settings.yaml"); + let Some(seed) = rows.first() else { + return Ok(()); + }; + + let tech_stack = seed["tech_stack"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str()) + .map(ToString::to_string) + .collect::>() + }) + .filter(|items| !items.is_empty()) + .map(|items| serde_json::json!(items)); + + let existing = site_settings::Entity::find() + .order_by_asc(site_settings::Column::Id) + .one(&ctx.db) + .await?; + + if let Some(existing) = existing { + let mut model = existing.clone().into_active_model(); + + if is_blank(&existing.site_name) { + model.site_name = Set(as_optional_string(&seed["site_name"])); + } + if is_blank(&existing.site_short_name) { + model.site_short_name = Set(as_optional_string(&seed["site_short_name"])); + } + if is_blank(&existing.site_url) { + model.site_url = Set(as_optional_string(&seed["site_url"])); + } + if is_blank(&existing.site_title) { + model.site_title = Set(as_optional_string(&seed["site_title"])); + } + if is_blank(&existing.site_description) { + model.site_description = Set(as_optional_string(&seed["site_description"])); + } + if is_blank(&existing.hero_title) { + model.hero_title = Set(as_optional_string(&seed["hero_title"])); + } + if is_blank(&existing.hero_subtitle) { + model.hero_subtitle = Set(as_optional_string(&seed["hero_subtitle"])); + } + if is_blank(&existing.owner_name) { + model.owner_name = Set(as_optional_string(&seed["owner_name"])); + } + if is_blank(&existing.owner_title) { + model.owner_title = Set(as_optional_string(&seed["owner_title"])); + } + if is_blank(&existing.owner_bio) { + model.owner_bio = Set(as_optional_string(&seed["owner_bio"])); + } + if is_blank(&existing.owner_avatar_url) { + model.owner_avatar_url = Set(as_optional_string(&seed["owner_avatar_url"])); + } + if is_blank(&existing.social_github) { + model.social_github = Set(as_optional_string(&seed["social_github"])); + } + if is_blank(&existing.social_twitter) { + model.social_twitter = Set(as_optional_string(&seed["social_twitter"])); + } + if is_blank(&existing.social_email) { + model.social_email = Set(as_optional_string(&seed["social_email"])); + } + if is_blank(&existing.location) { + model.location = Set(as_optional_string(&seed["location"])); + } + if existing.tech_stack.is_none() { + model.tech_stack = Set(tech_stack); + } + + let _ = model.update(&ctx.db).await; + return Ok(()); + } + + let model = site_settings::ActiveModel { + id: Set(seed["id"].as_i64().unwrap_or(1) as i32), + site_name: Set(as_optional_string(&seed["site_name"])), + site_short_name: Set(as_optional_string(&seed["site_short_name"])), + site_url: Set(as_optional_string(&seed["site_url"])), + site_title: Set(as_optional_string(&seed["site_title"])), + site_description: Set(as_optional_string(&seed["site_description"])), + hero_title: Set(as_optional_string(&seed["hero_title"])), + hero_subtitle: Set(as_optional_string(&seed["hero_subtitle"])), + owner_name: Set(as_optional_string(&seed["owner_name"])), + owner_title: Set(as_optional_string(&seed["owner_title"])), + owner_bio: Set(as_optional_string(&seed["owner_bio"])), + owner_avatar_url: Set(as_optional_string(&seed["owner_avatar_url"])), + social_github: Set(as_optional_string(&seed["social_github"])), + social_twitter: Set(as_optional_string(&seed["social_twitter"])), + social_email: Set(as_optional_string(&seed["social_email"])), + location: Set(as_optional_string(&seed["location"])), + tech_stack: Set(tech_stack), + ..Default::default() + }; + + let _ = model.insert(&ctx.db).await; + Ok(()) +} + +async fn sync_comment_post_slugs(ctx: &AppContext, base: &Path) -> Result<()> { + let rows = read_fixture_rows(base, "comments.yaml"); + + for seed in rows { + let id = seed["id"].as_i64().unwrap_or(0) as i32; + let pid = seed["pid"].as_i64().unwrap_or(0) as i32; + if id == 0 || pid == 0 { + continue; + } + + let Some(existing) = comments::Entity::find_by_id(id).one(&ctx.db).await? else { + continue; + }; + + if existing.post_slug.is_some() { + continue; + } + + let Some(post) = posts::Entity::find_by_id(pid).one(&ctx.db).await? else { + continue; + }; + + let mut model = existing.into_active_model(); + model.post_slug = Set(Some(post.slug)); + let _ = model.update(&ctx.db).await; + } + + Ok(()) +} diff --git a/backend/src/initializers/mod.rs b/backend/src/initializers/mod.rs new file mode 100644 index 0000000..a415ed2 --- /dev/null +++ b/backend/src/initializers/mod.rs @@ -0,0 +1,2 @@ +pub mod content_sync; +pub mod view_engine; diff --git a/backend/src/initializers/view_engine.rs b/backend/src/initializers/view_engine.rs new file mode 100644 index 0000000..b6a6855 --- /dev/null +++ b/backend/src/initializers/view_engine.rs @@ -0,0 +1,43 @@ +use async_trait::async_trait; +use axum::{Extension, Router as AxumRouter}; +use fluent_templates::{ArcLoader, FluentLoader}; +use loco_rs::{ + app::{AppContext, Initializer}, + controller::views::{engines, ViewEngine}, + Error, Result, +}; +use tracing::info; + +const I18N_DIR: &str = "assets/i18n"; +const I18N_SHARED: &str = "assets/i18n/shared.ftl"; +#[allow(clippy::module_name_repetitions)] +pub struct ViewEngineInitializer; + +#[async_trait] +impl Initializer for ViewEngineInitializer { + fn name(&self) -> String { + "view-engine".to_string() + } + + async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result { + let tera_engine = if std::path::Path::new(I18N_DIR).exists() { + let arc = std::sync::Arc::new( + ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en-US")) + .shared_resources(Some(&[I18N_SHARED.into()])) + .customize(|bundle| bundle.set_use_isolating(false)) + .build() + .map_err(|e| Error::string(&e.to_string()))?, + ); + info!("locales loaded"); + + engines::TeraView::build()?.post_process(move |tera| { + tera.register_function("t", FluentLoader::new(arc.clone())); + Ok(()) + })? + } else { + engines::TeraView::build()? + }; + + Ok(router.layer(Extension(ViewEngine::from(tera_engine)))) + } +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..619e0bb --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,10 @@ +pub mod app; +pub mod controllers; +pub mod data; +pub mod initializers; +pub mod mailers; +pub mod models; +pub mod services; +pub mod tasks; +pub mod views; +pub mod workers; diff --git a/backend/src/mailers/auth.rs b/backend/src/mailers/auth.rs new file mode 100644 index 0000000..88b949a --- /dev/null +++ b/backend/src/mailers/auth.rs @@ -0,0 +1,90 @@ +// auth mailer +#![allow(non_upper_case_globals)] + +use loco_rs::prelude::*; +use serde_json::json; + +use crate::models::users; + +static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome"); +static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot"); +static magic_link: Dir<'_> = include_dir!("src/mailers/auth/magic_link"); + +#[allow(clippy::module_name_repetitions)] +pub struct AuthMailer {} +impl Mailer for AuthMailer {} +impl AuthMailer { + /// Sending welcome email the the given user + /// + /// # Errors + /// + /// When email sending is failed + pub async fn send_welcome(ctx: &AppContext, user: &users::Model) -> Result<()> { + Self::mail_template( + ctx, + &welcome, + mailer::Args { + to: user.email.to_string(), + locals: json!({ + "name": user.name, + "verifyToken": user.email_verification_token, + "domain": ctx.config.server.full_url() + }), + ..Default::default() + }, + ) + .await?; + + Ok(()) + } + + /// Sending forgot password email + /// + /// # Errors + /// + /// When email sending is failed + pub async fn forgot_password(ctx: &AppContext, user: &users::Model) -> Result<()> { + Self::mail_template( + ctx, + &forgot, + mailer::Args { + to: user.email.to_string(), + locals: json!({ + "name": user.name, + "resetToken": user.reset_token, + "domain": ctx.config.server.full_url() + }), + ..Default::default() + }, + ) + .await?; + + Ok(()) + } + + /// Sends a magic link authentication email to the user. + /// + /// # Errors + /// + /// When email sending is failed + pub async fn send_magic_link(ctx: &AppContext, user: &users::Model) -> Result<()> { + Self::mail_template( + ctx, + &magic_link, + mailer::Args { + to: user.email.to_string(), + locals: json!({ + "name": user.name, + "token": user.magic_link_token.clone().ok_or_else(|| Error::string( + "the user model not contains magic link token", + ))?, + "host": ctx.config.server.full_url() + }), + ..Default::default() + }, + ) + .await?; + + Ok(()) + } +} diff --git a/backend/src/mailers/auth/forgot/html.t b/backend/src/mailers/auth/forgot/html.t new file mode 100644 index 0000000..221dd60 --- /dev/null +++ b/backend/src/mailers/auth/forgot/html.t @@ -0,0 +1,11 @@ +; + + + Hey {{name}}, + Forgot your password? No worries! You can reset it by clicking the link below: + Reset Your Password + If you didn't request a password reset, please ignore this email. + Best regards,
The Loco Team
+ + + diff --git a/backend/src/mailers/auth/forgot/subject.t b/backend/src/mailers/auth/forgot/subject.t new file mode 100644 index 0000000..4938df1 --- /dev/null +++ b/backend/src/mailers/auth/forgot/subject.t @@ -0,0 +1 @@ +Your reset password link diff --git a/backend/src/mailers/auth/forgot/text.t b/backend/src/mailers/auth/forgot/text.t new file mode 100644 index 0000000..58c30fd --- /dev/null +++ b/backend/src/mailers/auth/forgot/text.t @@ -0,0 +1,3 @@ +Reset your password with this link: + +http://localhost/reset#{{resetToken}} diff --git a/backend/src/mailers/auth/magic_link/html.t b/backend/src/mailers/auth/magic_link/html.t new file mode 100644 index 0000000..56eb252 --- /dev/null +++ b/backend/src/mailers/auth/magic_link/html.t @@ -0,0 +1,8 @@ +; + +

Magic link example:

+ +Verify Your Account + + + diff --git a/backend/src/mailers/auth/magic_link/subject.t b/backend/src/mailers/auth/magic_link/subject.t new file mode 100644 index 0000000..93eaba7 --- /dev/null +++ b/backend/src/mailers/auth/magic_link/subject.t @@ -0,0 +1 @@ +Magic link example diff --git a/backend/src/mailers/auth/magic_link/text.t b/backend/src/mailers/auth/magic_link/text.t new file mode 100644 index 0000000..b33d331 --- /dev/null +++ b/backend/src/mailers/auth/magic_link/text.t @@ -0,0 +1,2 @@ +Magic link with this link: +{{host}}/api/auth/magic-link/{{token}} \ No newline at end of file diff --git a/backend/src/mailers/auth/welcome/html.t b/backend/src/mailers/auth/welcome/html.t new file mode 100644 index 0000000..dcca19e --- /dev/null +++ b/backend/src/mailers/auth/welcome/html.t @@ -0,0 +1,13 @@ +; + + + Dear {{name}}, + Welcome to Loco! You can now log in to your account. + Before you get started, please verify your account by clicking the link below: + + Verify Your Account + +

Best regards,
The Loco Team

+ + + diff --git a/backend/src/mailers/auth/welcome/subject.t b/backend/src/mailers/auth/welcome/subject.t new file mode 100644 index 0000000..82cc6fb --- /dev/null +++ b/backend/src/mailers/auth/welcome/subject.t @@ -0,0 +1 @@ +Welcome {{name}} diff --git a/backend/src/mailers/auth/welcome/text.t b/backend/src/mailers/auth/welcome/text.t new file mode 100644 index 0000000..2a73f2c --- /dev/null +++ b/backend/src/mailers/auth/welcome/text.t @@ -0,0 +1,4 @@ +Welcome {{name}}, you can now log in. + Verify your account with the link below: + + {{domain}}/api/auth/verify/{{verifyToken}} diff --git a/backend/src/mailers/mod.rs b/backend/src/mailers/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/backend/src/mailers/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/backend/src/models/_entities/categories.rs b/backend/src/models/_entities/categories.rs new file mode 100644 index 0000000..8c7e523 --- /dev/null +++ b/backend/src/models/_entities/categories.rs @@ -0,0 +1,16 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "categories")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub name: Option, + pub slug: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/backend/src/models/_entities/comments.rs b/backend/src/models/_entities/comments.rs new file mode 100644 index 0000000..6ee8da7 --- /dev/null +++ b/backend/src/models/_entities/comments.rs @@ -0,0 +1,25 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "comments")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub post_id: Option, + pub post_slug: Option, + pub author: Option, + pub email: Option, + pub avatar: Option, + #[sea_orm(column_type = "Text", nullable)] + pub content: Option, + pub reply_to: Option, + pub approved: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/backend/src/models/_entities/friend_links.rs b/backend/src/models/_entities/friend_links.rs new file mode 100644 index 0000000..054df1d --- /dev/null +++ b/backend/src/models/_entities/friend_links.rs @@ -0,0 +1,22 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "friend_links")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub site_name: Option, + pub site_url: String, + pub avatar_url: Option, + pub description: Option, + pub category: Option, + pub status: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/backend/src/models/_entities/mod.rs b/backend/src/models/_entities/mod.rs new file mode 100644 index 0000000..70bcd16 --- /dev/null +++ b/backend/src/models/_entities/mod.rs @@ -0,0 +1,12 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10 + +pub mod prelude; + +pub mod categories; +pub mod comments; +pub mod friend_links; +pub mod posts; +pub mod reviews; +pub mod site_settings; +pub mod tags; +pub mod users; diff --git a/backend/src/models/_entities/posts.rs b/backend/src/models/_entities/posts.rs new file mode 100644 index 0000000..489f197 --- /dev/null +++ b/backend/src/models/_entities/posts.rs @@ -0,0 +1,27 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "posts")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub title: Option, + pub slug: String, + pub description: Option, + #[sea_orm(column_type = "Text", nullable)] + pub content: Option, + pub category: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub tags: Option, + pub post_type: Option, + pub image: Option, + pub pinned: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/backend/src/models/_entities/prelude.rs b/backend/src/models/_entities/prelude.rs new file mode 100644 index 0000000..a6dfc59 --- /dev/null +++ b/backend/src/models/_entities/prelude.rs @@ -0,0 +1,10 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10 + +pub use super::categories::Entity as Categories; +pub use super::comments::Entity as Comments; +pub use super::friend_links::Entity as FriendLinks; +pub use super::posts::Entity as Posts; +pub use super::reviews::Entity as Reviews; +pub use super::site_settings::Entity as SiteSettings; +pub use super::tags::Entity as Tags; +pub use super::users::Entity as Users; diff --git a/backend/src/models/_entities/reviews.rs b/backend/src/models/_entities/reviews.rs new file mode 100644 index 0000000..1f15361 --- /dev/null +++ b/backend/src/models/_entities/reviews.rs @@ -0,0 +1,24 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "reviews")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub title: Option, + pub review_type: Option, + pub rating: Option, + pub review_date: Option, + pub status: Option, + pub description: Option, + pub tags: Option, + pub cover: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/_entities/site_settings.rs b/backend/src/models/_entities/site_settings.rs new file mode 100644 index 0000000..4795054 --- /dev/null +++ b/backend/src/models/_entities/site_settings.rs @@ -0,0 +1,36 @@ +//! `SeaORM` Entity, manually maintained + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "site_settings")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub site_name: Option, + pub site_short_name: Option, + pub site_url: Option, + pub site_title: Option, + pub site_description: Option, + pub hero_title: Option, + pub hero_subtitle: Option, + pub owner_name: Option, + pub owner_title: Option, + #[sea_orm(column_type = "Text", nullable)] + pub owner_bio: Option, + pub owner_avatar_url: Option, + pub social_github: Option, + pub social_twitter: Option, + pub social_email: Option, + pub location: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub tech_stack: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/_entities/tags.rs b/backend/src/models/_entities/tags.rs new file mode 100644 index 0000000..e3f1f37 --- /dev/null +++ b/backend/src/models/_entities/tags.rs @@ -0,0 +1,18 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "tags")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub name: Option, + pub slug: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/backend/src/models/_entities/users.rs b/backend/src/models/_entities/users.rs new file mode 100644 index 0000000..507021a --- /dev/null +++ b/backend/src/models/_entities/users.rs @@ -0,0 +1,30 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "users")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub pid: Uuid, + #[sea_orm(unique)] + pub email: String, + pub password: String, + #[sea_orm(unique)] + pub api_key: String, + pub name: String, + pub reset_token: Option, + pub reset_sent_at: Option, + pub email_verification_token: Option, + pub email_verification_sent_at: Option, + pub email_verified_at: Option, + pub magic_link_token: Option, + pub magic_link_expiration: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/backend/src/models/categories.rs b/backend/src/models/categories.rs new file mode 100644 index 0000000..80acaff --- /dev/null +++ b/backend/src/models/categories.rs @@ -0,0 +1,23 @@ +pub use super::_entities::categories::{ActiveModel, Entity, Model}; +use sea_orm::entity::prelude::*; +pub type Categories = Entity; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + if !insert && self.updated_at.is_unchanged() { + let mut this = self; + this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into()); + Ok(this) + } else { + Ok(self) + } + } +} + +impl Model {} +impl ActiveModel {} +impl Entity {} diff --git a/backend/src/models/comments.rs b/backend/src/models/comments.rs new file mode 100644 index 0000000..0095dff --- /dev/null +++ b/backend/src/models/comments.rs @@ -0,0 +1,28 @@ +pub use super::_entities::comments::{ActiveModel, Entity, Model}; +use sea_orm::entity::prelude::*; +pub type Comments = Entity; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + if !insert && self.updated_at.is_unchanged() { + let mut this = self; + this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into()); + Ok(this) + } else { + Ok(self) + } + } +} + +// implement your read-oriented logic here +impl Model {} + +// implement your write-oriented logic here +impl ActiveModel {} + +// implement your custom finders, selectors oriented logic here +impl Entity {} diff --git a/backend/src/models/friend_links.rs b/backend/src/models/friend_links.rs new file mode 100644 index 0000000..2ed4f9d --- /dev/null +++ b/backend/src/models/friend_links.rs @@ -0,0 +1,28 @@ +pub use super::_entities::friend_links::{ActiveModel, Entity, Model}; +use sea_orm::entity::prelude::*; +pub type FriendLinks = Entity; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + if !insert && self.updated_at.is_unchanged() { + let mut this = self; + this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into()); + Ok(this) + } else { + Ok(self) + } + } +} + +// implement your read-oriented logic here +impl Model {} + +// implement your write-oriented logic here +impl ActiveModel {} + +// implement your custom finders, selectors oriented logic here +impl Entity {} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs new file mode 100644 index 0000000..bf149a6 --- /dev/null +++ b/backend/src/models/mod.rs @@ -0,0 +1,8 @@ +pub mod _entities; +pub mod categories; +pub mod comments; +pub mod friend_links; +pub mod posts; +pub mod site_settings; +pub mod tags; +pub mod users; diff --git a/backend/src/models/posts.rs b/backend/src/models/posts.rs new file mode 100644 index 0000000..82acde2 --- /dev/null +++ b/backend/src/models/posts.rs @@ -0,0 +1,28 @@ +pub use super::_entities::posts::{ActiveModel, Entity, Model}; +use sea_orm::entity::prelude::*; +pub type Posts = Entity; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + if !insert && self.updated_at.is_unchanged() { + let mut this = self; + this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into()); + Ok(this) + } else { + Ok(self) + } + } +} + +// implement your read-oriented logic here +impl Model {} + +// implement your write-oriented logic here +impl ActiveModel {} + +// implement your custom finders, selectors oriented logic here +impl Entity {} diff --git a/backend/src/models/site_settings.rs b/backend/src/models/site_settings.rs new file mode 100644 index 0000000..6dad84d --- /dev/null +++ b/backend/src/models/site_settings.rs @@ -0,0 +1,3 @@ +pub use super::_entities::site_settings::{ActiveModel, Entity, Model}; + +pub type SiteSettings = Entity; diff --git a/backend/src/models/tags.rs b/backend/src/models/tags.rs new file mode 100644 index 0000000..eb2c72d --- /dev/null +++ b/backend/src/models/tags.rs @@ -0,0 +1,28 @@ +pub use super::_entities::tags::{ActiveModel, Entity, Model}; +use sea_orm::entity::prelude::*; +pub type Tags = Entity; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + if !insert && self.updated_at.is_unchanged() { + let mut this = self; + this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into()); + Ok(this) + } else { + Ok(self) + } + } +} + +// implement your read-oriented logic here +impl Model {} + +// implement your write-oriented logic here +impl ActiveModel {} + +// implement your custom finders, selectors oriented logic here +impl Entity {} diff --git a/backend/src/models/users.rs b/backend/src/models/users.rs new file mode 100644 index 0000000..2292ded --- /dev/null +++ b/backend/src/models/users.rs @@ -0,0 +1,369 @@ +use async_trait::async_trait; +use chrono::{offset::Local, Duration}; +use loco_rs::{auth::jwt, hash, prelude::*}; +use serde::{Deserialize, Serialize}; +use serde_json::Map; +use uuid::Uuid; + +pub use super::_entities::users::{self, ActiveModel, Entity, Model}; + +pub const MAGIC_LINK_LENGTH: i8 = 32; +pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5; + +#[derive(Debug, Deserialize, Serialize)] +pub struct LoginParams { + pub email: String, + pub password: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct RegisterParams { + pub email: String, + pub password: String, + pub name: String, +} + +#[derive(Debug, Validate, Deserialize)] +pub struct Validator { + #[validate(length(min = 2, message = "Name must be at least 2 characters long."))] + pub name: String, + #[validate(email(message = "invalid email"))] + pub email: String, +} + +impl Validatable for ActiveModel { + fn validator(&self) -> Box { + Box::new(Validator { + name: self.name.as_ref().to_owned(), + email: self.email.as_ref().to_owned(), + }) + } +} + +#[async_trait::async_trait] +impl ActiveModelBehavior for super::_entities::users::ActiveModel { + async fn before_save(self, _db: &C, insert: bool) -> Result + where + C: ConnectionTrait, + { + self.validate()?; + if insert { + let mut this = self; + this.pid = ActiveValue::Set(Uuid::new_v4()); + this.api_key = ActiveValue::Set(format!("lo-{}", Uuid::new_v4())); + Ok(this) + } else { + Ok(self) + } + } +} + +#[async_trait] +impl Authenticable for Model { + async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult { + let user = users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::ApiKey, api_key) + .build(), + ) + .one(db) + .await?; + user.ok_or_else(|| ModelError::EntityNotFound) + } + + async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult { + Self::find_by_pid(db, claims_key).await + } +} + +impl Model { + /// finds a user by the provided email + /// + /// # Errors + /// + /// When could not find user by the given token or DB query error + pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult { + let user = users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::Email, email) + .build(), + ) + .one(db) + .await?; + user.ok_or_else(|| ModelError::EntityNotFound) + } + + /// finds a user by the provided verification token + /// + /// # Errors + /// + /// When could not find user by the given token or DB query error + pub async fn find_by_verification_token( + db: &DatabaseConnection, + token: &str, + ) -> ModelResult { + let user = users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::EmailVerificationToken, token) + .build(), + ) + .one(db) + .await?; + user.ok_or_else(|| ModelError::EntityNotFound) + } + + /// finds a user by the magic token and verify and token expiration + /// + /// # Errors + /// + /// When could not find user by the given token or DB query error ot token expired + pub async fn find_by_magic_token(db: &DatabaseConnection, token: &str) -> ModelResult { + let user = users::Entity::find() + .filter( + query::condition() + .eq(users::Column::MagicLinkToken, token) + .build(), + ) + .one(db) + .await?; + + let user = user.ok_or_else(|| ModelError::EntityNotFound)?; + if let Some(expired_at) = user.magic_link_expiration { + if expired_at >= Local::now() { + Ok(user) + } else { + tracing::debug!( + user_pid = user.pid.to_string(), + token_expiration = expired_at.to_string(), + "magic token expired for the user." + ); + Err(ModelError::msg("magic token expired")) + } + } else { + tracing::error!( + user_pid = user.pid.to_string(), + "magic link expiration time not exists" + ); + Err(ModelError::msg("expiration token not exists")) + } + } + + /// finds a user by the provided reset token + /// + /// # Errors + /// + /// When could not find user by the given token or DB query error + pub async fn find_by_reset_token(db: &DatabaseConnection, token: &str) -> ModelResult { + let user = users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::ResetToken, token) + .build(), + ) + .one(db) + .await?; + user.ok_or_else(|| ModelError::EntityNotFound) + } + + /// finds a user by the provided pid + /// + /// # Errors + /// + /// When could not find user or DB query error + pub async fn find_by_pid(db: &DatabaseConnection, pid: &str) -> ModelResult { + let parse_uuid = Uuid::parse_str(pid).map_err(|e| ModelError::Any(e.into()))?; + let user = users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::Pid, parse_uuid) + .build(), + ) + .one(db) + .await?; + user.ok_or_else(|| ModelError::EntityNotFound) + } + + /// finds a user by the provided api key + /// + /// # Errors + /// + /// When could not find user by the given token or DB query error + pub async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult { + let user = users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::ApiKey, api_key) + .build(), + ) + .one(db) + .await?; + user.ok_or_else(|| ModelError::EntityNotFound) + } + + /// Verifies whether the provided plain password matches the hashed password + /// + /// # Errors + /// + /// when could not verify password + #[must_use] + pub fn verify_password(&self, password: &str) -> bool { + hash::verify_password(password, &self.password) + } + + /// Asynchronously creates a user with a password and saves it to the + /// database. + /// + /// # Errors + /// + /// When could not save the user into the DB + pub async fn create_with_password( + db: &DatabaseConnection, + params: &RegisterParams, + ) -> ModelResult { + let txn = db.begin().await?; + + if users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::Email, ¶ms.email) + .build(), + ) + .one(&txn) + .await? + .is_some() + { + return Err(ModelError::EntityAlreadyExists {}); + } + + let password_hash = + hash::hash_password(¶ms.password).map_err(|e| ModelError::Any(e.into()))?; + let user = users::ActiveModel { + email: ActiveValue::set(params.email.to_string()), + password: ActiveValue::set(password_hash), + name: ActiveValue::set(params.name.to_string()), + ..Default::default() + } + .insert(&txn) + .await?; + + txn.commit().await?; + + Ok(user) + } + + /// Creates a JWT + /// + /// # Errors + /// + /// when could not convert user claims to jwt token + pub fn generate_jwt(&self, secret: &str, expiration: u64) -> ModelResult { + jwt::JWT::new(secret) + .generate_token(expiration, self.pid.to_string(), Map::new()) + .map_err(ModelError::from) + } +} + +impl ActiveModel { + /// Sets the email verification information for the user and + /// updates it in the database. + /// + /// This method is used to record the timestamp when the email verification + /// was sent and generate a unique verification token for the user. + /// + /// # Errors + /// + /// when has DB query error + pub async fn set_email_verification_sent( + mut self, + db: &DatabaseConnection, + ) -> ModelResult { + self.email_verification_sent_at = ActiveValue::set(Some(Local::now().into())); + self.email_verification_token = ActiveValue::Set(Some(Uuid::new_v4().to_string())); + self.update(db).await.map_err(ModelError::from) + } + + /// Sets the information for a reset password request, + /// generates a unique reset password token, and updates it in the + /// database. + /// + /// This method records the timestamp when the reset password token is sent + /// and generates a unique token for the user. + /// + /// # Arguments + /// + /// # Errors + /// + /// when has DB query error + pub async fn set_forgot_password_sent(mut self, db: &DatabaseConnection) -> ModelResult { + self.reset_sent_at = ActiveValue::set(Some(Local::now().into())); + self.reset_token = ActiveValue::Set(Some(Uuid::new_v4().to_string())); + self.update(db).await.map_err(ModelError::from) + } + + /// Records the verification time when a user verifies their + /// email and updates it in the database. + /// + /// This method sets the timestamp when the user successfully verifies their + /// email. + /// + /// # Errors + /// + /// when has DB query error + pub async fn verified(mut self, db: &DatabaseConnection) -> ModelResult { + self.email_verified_at = ActiveValue::set(Some(Local::now().into())); + self.update(db).await.map_err(ModelError::from) + } + + /// Resets the current user password with a new password and + /// updates it in the database. + /// + /// This method hashes the provided password and sets it as the new password + /// for the user. + /// + /// # Errors + /// + /// when has DB query error or could not hashed the given password + pub async fn reset_password( + mut self, + db: &DatabaseConnection, + password: &str, + ) -> ModelResult { + self.password = + ActiveValue::set(hash::hash_password(password).map_err(|e| ModelError::Any(e.into()))?); + self.reset_token = ActiveValue::Set(None); + self.reset_sent_at = ActiveValue::Set(None); + self.update(db).await.map_err(ModelError::from) + } + + /// Creates a magic link token for passwordless authentication. + /// + /// Generates a random token with a specified length and sets an expiration time + /// for the magic link. This method is used to initiate the magic link authentication flow. + /// + /// # Errors + /// - Returns an error if database update fails + pub async fn create_magic_link(mut self, db: &DatabaseConnection) -> ModelResult { + let random_str = hash::random_string(MAGIC_LINK_LENGTH as usize); + let expired = Local::now() + Duration::minutes(MAGIC_LINK_EXPIRATION_MIN.into()); + + self.magic_link_token = ActiveValue::set(Some(random_str)); + self.magic_link_expiration = ActiveValue::set(Some(expired.into())); + self.update(db).await.map_err(ModelError::from) + } + + /// Verifies and invalidates the magic link after successful authentication. + /// + /// Clears the magic link token and expiration time after the user has + /// successfully authenticated using the magic link. + /// + /// # Errors + /// - Returns an error if database update fails + pub async fn clear_magic_link(mut self, db: &DatabaseConnection) -> ModelResult { + self.magic_link_token = ActiveValue::set(None); + self.magic_link_expiration = ActiveValue::set(None); + self.update(db).await.map_err(ModelError::from) + } +} diff --git a/backend/src/services/content.rs b/backend/src/services/content.rs new file mode 100644 index 0000000..54a4955 --- /dev/null +++ b/backend/src/services/content.rs @@ -0,0 +1,648 @@ +use loco_rs::prelude::*; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::models::_entities::{categories, posts, tags}; + +pub const MARKDOWN_POSTS_DIR: &str = "content/posts"; +const FIXTURE_POSTS_FILE: &str = "src/fixtures/posts.yaml"; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +struct MarkdownFrontmatter { + title: Option, + slug: Option, + description: Option, + category: Option, + tags: Option>, + post_type: Option, + image: Option, + pinned: Option, + published: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MarkdownPost { + pub title: String, + pub slug: String, + pub description: Option, + pub content: String, + pub category: Option, + pub tags: Vec, + pub post_type: String, + pub image: Option, + pub pinned: bool, + pub published: bool, + pub file_path: String, +} + +#[derive(Debug, Clone)] +pub struct MarkdownPostDraft { + pub title: String, + pub slug: Option, + pub description: Option, + pub content: String, + pub category: Option, + pub tags: Vec, + pub post_type: String, + pub image: Option, + pub pinned: bool, + pub published: bool, +} + +#[derive(Debug, Clone)] +pub struct MarkdownImportFile { + pub file_name: String, + pub content: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct LegacyFixturePost { + title: String, + slug: String, + content: String, + excerpt: Option, + category: Option, + tags: Option>, + pinned: Option, + published: Option, +} + +fn io_error(err: std::io::Error) -> Error { + Error::string(&err.to_string()) +} + +fn yaml_error(err: serde_yaml::Error) -> Error { + Error::string(&err.to_string()) +} + +fn posts_dir() -> PathBuf { + PathBuf::from(MARKDOWN_POSTS_DIR) +} + +pub fn markdown_post_path(slug: &str) -> PathBuf { + posts_dir().join(format!("{slug}.md")) +} + +fn normalize_newlines(input: &str) -> String { + input.replace("\r\n", "\n") +} + +fn trim_to_option(input: Option) -> Option { + input.and_then(|value| { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn slugify(value: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + + for ch in value.trim().chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + last_was_dash = false; + } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash { + slug.push('-'); + last_was_dash = true; + } + } + + slug.trim_matches('-').to_string() +} + +fn excerpt_from_content(content: &str) -> Option { + let mut in_code_block = false; + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("```") { + in_code_block = !in_code_block; + continue; + } + + if in_code_block || trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + let excerpt = trimmed.chars().take(180).collect::(); + return if excerpt.is_empty() { None } else { Some(excerpt) }; + } + + None +} + +fn title_from_content(content: &str) -> Option { + content.lines().find_map(|line| { + line.trim() + .strip_prefix("# ") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) +} + +fn split_frontmatter(raw: &str) -> Result<(MarkdownFrontmatter, String)> { + let normalized = normalize_newlines(raw); + + if !normalized.starts_with("---\n") { + return Ok((MarkdownFrontmatter::default(), normalized)); + } + + let rest = &normalized[4..]; + let Some(end_index) = rest.find("\n---\n") else { + return Err(Error::string("Markdown frontmatter is not closed")); + }; + + let frontmatter = &rest[..end_index]; + let content = rest[end_index + 5..].to_string(); + let parsed = serde_yaml::from_str::(frontmatter).map_err(yaml_error)?; + + Ok((parsed, content)) +} + +fn parse_markdown_post(path: &Path) -> Result { + let raw = fs::read_to_string(path).map_err(io_error)?; + let file_stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("post") + .to_string(); + + parse_markdown_source(&file_stem, &raw, &path.to_string_lossy()) +} + +fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result { + let (frontmatter, content) = split_frontmatter(raw)?; + + let slug = trim_to_option(frontmatter.slug.clone()).unwrap_or_else(|| file_stem.to_string()); + let title = trim_to_option(frontmatter.title.clone()) + .or_else(|| title_from_content(&content)) + .unwrap_or_else(|| slug.clone()); + let description = trim_to_option(frontmatter.description.clone()).or_else(|| excerpt_from_content(&content)); + let category = trim_to_option(frontmatter.category.clone()); + let tags = frontmatter + .tags + .unwrap_or_default() + .into_iter() + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect::>(); + + Ok(MarkdownPost { + title, + slug, + description, + content: content.trim_start_matches('\n').to_string(), + category, + tags, + post_type: trim_to_option(frontmatter.post_type.clone()).unwrap_or_else(|| "article".to_string()), + image: trim_to_option(frontmatter.image.clone()), + pinned: frontmatter.pinned.unwrap_or(false), + published: frontmatter.published.unwrap_or(true), + file_path: file_path.to_string(), + }) +} + +fn build_markdown_document(post: &MarkdownPost) -> String { + let mut lines = vec![ + "---".to_string(), + format!("title: {}", serde_yaml::to_string(&post.title).unwrap_or_else(|_| format!("{:?}", post.title)).trim()), + format!("slug: {}", post.slug), + ]; + + if let Some(description) = &post.description { + lines.push(format!( + "description: {}", + serde_yaml::to_string(description) + .unwrap_or_else(|_| format!("{description:?}")) + .trim() + )); + } + + if let Some(category) = &post.category { + lines.push(format!("category: {}", category)); + } + + lines.push(format!("post_type: {}", post.post_type)); + lines.push(format!("pinned: {}", post.pinned)); + lines.push(format!("published: {}", post.published)); + + if let Some(image) = &post.image { + lines.push(format!("image: {}", image)); + } + + if !post.tags.is_empty() { + lines.push("tags:".to_string()); + for tag in &post.tags { + lines.push(format!(" - {}", tag)); + } + } + + lines.push("---".to_string()); + lines.push(String::new()); + lines.push(post.content.trim().to_string()); + lines.push(String::new()); + + lines.join("\n") +} + +fn ensure_markdown_posts_bootstrapped() -> Result<()> { + let dir = posts_dir(); + fs::create_dir_all(&dir).map_err(io_error)?; + + let has_markdown = fs::read_dir(&dir) + .map_err(io_error)? + .filter_map(|entry| entry.ok()) + .any(|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("md")); + + if has_markdown { + return Ok(()); + } + + let raw = fs::read_to_string(FIXTURE_POSTS_FILE).map_err(io_error)?; + let fixtures = serde_yaml::from_str::>(&raw).map_err(yaml_error)?; + + for fixture in fixtures { + let post = MarkdownPost { + title: fixture.title, + slug: fixture.slug.clone(), + description: trim_to_option(fixture.excerpt), + content: fixture.content, + category: trim_to_option(fixture.category), + tags: fixture.tags.unwrap_or_default(), + post_type: "article".to_string(), + image: None, + pinned: fixture.pinned.unwrap_or(false), + published: fixture.published.unwrap_or(true), + file_path: markdown_post_path(&fixture.slug).to_string_lossy().to_string(), + }; + + fs::write(markdown_post_path(&fixture.slug), build_markdown_document(&post)).map_err(io_error)?; + } + + Ok(()) +} + +fn load_markdown_posts_from_disk() -> Result> { + ensure_markdown_posts_bootstrapped()?; + + let mut posts = fs::read_dir(posts_dir()) + .map_err(io_error)? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("md")) + .map(|path| parse_markdown_post(&path)) + .collect::>>()?; + + posts.sort_by(|left, right| left.slug.cmp(&right.slug)); + Ok(posts) +} + +async fn sync_tags_from_posts(ctx: &AppContext, posts: &[MarkdownPost]) -> Result<()> { + for post in posts { + for tag_name in &post.tags { + let slug = slugify(tag_name); + let existing = tags::Entity::find() + .filter(tags::Column::Slug.eq(&slug)) + .one(&ctx.db) + .await?; + + if existing.is_none() { + let item = tags::ActiveModel { + name: Set(Some(tag_name.clone())), + slug: Set(slug), + ..Default::default() + }; + let _ = item.insert(&ctx.db).await; + } + } + } + + Ok(()) +} + +async fn ensure_category(ctx: &AppContext, raw_name: &str) -> Result> { + let name = raw_name.trim(); + if name.is_empty() { + return Ok(None); + } + + let slug = slugify(name); + let existing = categories::Entity::find() + .filter(categories::Column::Slug.eq(&slug)) + .one(&ctx.db) + .await?; + + if let Some(category) = existing { + if let Some(existing_name) = category.name.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + return Ok(Some(existing_name.to_string())); + } + + let mut category_model = category.into_active_model(); + category_model.name = Set(Some(name.to_string())); + let updated = category_model.update(&ctx.db).await?; + return Ok(updated.name.or_else(|| Some(name.to_string()))); + } + + let created = categories::ActiveModel { + name: Set(Some(name.to_string())), + slug: Set(slug), + ..Default::default() + } + .insert(&ctx.db) + .await?; + + Ok(created.name.or_else(|| Some(name.to_string()))) +} + +async fn canonicalize_tags(ctx: &AppContext, raw_tags: &[String]) -> Result> { + let mut canonical_tags = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for tag_name in raw_tags { + let trimmed = tag_name.trim(); + if trimmed.is_empty() { + continue; + } + + let slug = slugify(trimmed); + if slug.is_empty() || !seen.insert(slug.clone()) { + continue; + } + + let existing = tags::Entity::find() + .filter(tags::Column::Slug.eq(&slug)) + .one(&ctx.db) + .await?; + + let canonical_name = if let Some(tag) = existing { + if let Some(existing_name) = tag.name.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + existing_name.to_string() + } else { + let mut tag_model = tag.into_active_model(); + tag_model.name = Set(Some(trimmed.to_string())); + tag_model + .update(&ctx.db) + .await? + .name + .unwrap_or_else(|| trimmed.to_string()) + } + } else { + tags::ActiveModel { + name: Set(Some(trimmed.to_string())), + slug: Set(slug), + ..Default::default() + } + .insert(&ctx.db) + .await? + .name + .unwrap_or_else(|| trimmed.to_string()) + }; + + canonical_tags.push(canonical_name); + } + + Ok(canonical_tags) +} + +async fn dedupe_tags(ctx: &AppContext) -> Result<()> { + let existing_tags = tags::Entity::find() + .order_by_asc(tags::Column::Id) + .all(&ctx.db) + .await?; + + let mut seen = std::collections::HashSet::new(); + + for tag in existing_tags { + let key = if tag.slug.trim().is_empty() { + tag.name + .as_deref() + .map(slugify) + .unwrap_or_default() + } else { + slugify(&tag.slug) + }; + + if key.is_empty() || seen.insert(key) { + continue; + } + + let _ = tag.delete(&ctx.db).await; + } + + Ok(()) +} + +async fn dedupe_categories(ctx: &AppContext) -> Result<()> { + let existing_categories = categories::Entity::find() + .order_by_asc(categories::Column::Id) + .all(&ctx.db) + .await?; + + let mut seen = std::collections::HashSet::new(); + + for category in existing_categories { + let key = if category.slug.trim().is_empty() { + category + .name + .as_deref() + .map(slugify) + .unwrap_or_default() + } else { + slugify(&category.slug) + }; + + if key.is_empty() || seen.insert(key) { + continue; + } + + let _ = category.delete(&ctx.db).await; + } + + Ok(()) +} + +pub async fn sync_markdown_posts(ctx: &AppContext) -> Result> { + let markdown_posts = load_markdown_posts_from_disk()?; + + for post in &markdown_posts { + let canonical_category = match post.category.as_deref() { + Some(category) => ensure_category(ctx, category).await?, + None => None, + }; + let canonical_tags = canonicalize_tags(ctx, &post.tags).await?; + + let existing = posts::Entity::find() + .filter(posts::Column::Slug.eq(&post.slug)) + .one(&ctx.db) + .await?; + let has_existing = existing.is_some(); + + let mut model = existing + .map(|item| item.into_active_model()) + .unwrap_or_default(); + + model.title = Set(Some(post.title.clone())); + model.slug = Set(post.slug.clone()); + model.description = Set(post.description.clone()); + model.content = Set(Some(post.content.clone())); + model.category = Set(canonical_category); + model.tags = Set(if canonical_tags.is_empty() { + None + } else { + Some(Value::Array( + canonical_tags.into_iter().map(Value::String).collect(), + )) + }); + model.post_type = Set(Some(post.post_type.clone())); + model.image = Set(post.image.clone()); + model.pinned = Set(Some(post.pinned)); + + if has_existing { + let _ = model.update(&ctx.db).await; + } else { + let _ = model.insert(&ctx.db).await; + } + } + + sync_tags_from_posts(ctx, &markdown_posts).await?; + dedupe_tags(ctx).await?; + dedupe_categories(ctx).await?; + Ok(markdown_posts) +} + +pub fn read_markdown_document(slug: &str) -> Result<(String, String)> { + let path = markdown_post_path(slug); + if !path.exists() { + return Err(Error::NotFound); + } + + let raw = fs::read_to_string(&path).map_err(io_error)?; + Ok((path.to_string_lossy().to_string(), raw)) +} + +pub async fn write_markdown_document( + ctx: &AppContext, + slug: &str, + markdown: &str, +) -> Result { + ensure_markdown_posts_bootstrapped()?; + let path = markdown_post_path(slug); + fs::write(&path, normalize_newlines(markdown)).map_err(io_error)?; + + let updated = parse_markdown_post(&path)?; + sync_markdown_posts(ctx).await?; + Ok(updated) +} + +pub async fn create_markdown_post( + ctx: &AppContext, + draft: MarkdownPostDraft, +) -> Result { + ensure_markdown_posts_bootstrapped()?; + + let title = draft.title.trim().to_string(); + if title.is_empty() { + return Err(Error::BadRequest("title is required".to_string())); + } + + let slug = draft + .slug + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| slugify(&title)); + + if slug.is_empty() { + return Err(Error::BadRequest("slug is required".to_string())); + } + + let post = MarkdownPost { + title, + slug: slug.clone(), + description: trim_to_option(draft.description), + content: draft.content.trim().to_string(), + category: trim_to_option(draft.category), + tags: draft + .tags + .into_iter() + .map(|tag| tag.trim().to_string()) + .filter(|tag| !tag.is_empty()) + .collect(), + post_type: { + let normalized = draft.post_type.trim(); + if normalized.is_empty() { + "article".to_string() + } else { + normalized.to_string() + } + }, + image: trim_to_option(draft.image), + pinned: draft.pinned, + published: draft.published, + file_path: markdown_post_path(&slug).to_string_lossy().to_string(), + }; + + fs::write(markdown_post_path(&slug), build_markdown_document(&post)).map_err(io_error)?; + sync_markdown_posts(ctx).await?; + parse_markdown_post(&markdown_post_path(&slug)) +} + +pub async fn import_markdown_documents( + ctx: &AppContext, + files: Vec, +) -> Result> { + ensure_markdown_posts_bootstrapped()?; + + let mut imported_slugs = Vec::new(); + + for file in files { + let path = Path::new(&file.file_name); + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + + if extension != "md" && extension != "markdown" { + continue; + } + + let file_stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("imported-post") + .to_string(); + let parsed = parse_markdown_source(&file_stem, &file.content, &file.file_name)?; + let slug = if parsed.slug.trim().is_empty() { + slugify(&file_stem) + } else { + parsed.slug.clone() + }; + + if slug.is_empty() { + continue; + } + + fs::write(markdown_post_path(&slug), normalize_newlines(&file.content)).map_err(io_error)?; + imported_slugs.push(slug); + } + + sync_markdown_posts(ctx).await?; + + imported_slugs + .into_iter() + .map(|slug| parse_markdown_post(&markdown_post_path(&slug))) + .collect() +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs new file mode 100644 index 0000000..ee90b90 --- /dev/null +++ b/backend/src/services/mod.rs @@ -0,0 +1 @@ +pub mod content; diff --git a/backend/src/tasks/mod.rs b/backend/src/tasks/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/src/tasks/mod.rs @@ -0,0 +1 @@ + diff --git a/backend/src/views/auth.rs b/backend/src/views/auth.rs new file mode 100644 index 0000000..3d2d74f --- /dev/null +++ b/backend/src/views/auth.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; + +use crate::models::_entities::users; + +#[derive(Debug, Deserialize, Serialize)] +pub struct LoginResponse { + pub token: String, + pub pid: String, + pub name: String, + pub is_verified: bool, +} + +impl LoginResponse { + #[must_use] + pub fn new(user: &users::Model, token: &String) -> Self { + Self { + token: token.to_string(), + pid: user.pid.to_string(), + name: user.name.clone(), + is_verified: user.email_verified_at.is_some(), + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct CurrentResponse { + pub pid: String, + pub name: String, + pub email: String, +} + +impl CurrentResponse { + #[must_use] + pub fn new(user: &users::Model) -> Self { + Self { + pid: user.pid.to_string(), + name: user.name.clone(), + email: user.email.clone(), + } + } +} diff --git a/backend/src/views/mod.rs b/backend/src/views/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/backend/src/views/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/backend/src/workers/downloader.rs b/backend/src/workers/downloader.rs new file mode 100644 index 0000000..1abafa4 --- /dev/null +++ b/backend/src/workers/downloader.rs @@ -0,0 +1,23 @@ +use loco_rs::prelude::*; +use serde::{Deserialize, Serialize}; + +pub struct DownloadWorker { + pub ctx: AppContext, +} + +#[derive(Deserialize, Debug, Serialize)] +pub struct DownloadWorkerArgs { + pub user_guid: String, +} + +#[async_trait] +impl BackgroundWorker for DownloadWorker { + fn build(ctx: &AppContext) -> Self { + Self { ctx: ctx.clone() } + } + async fn perform(&self, _args: DownloadWorkerArgs) -> Result<()> { + // TODO: Some actual work goes here... + + Ok(()) + } +} diff --git a/backend/src/workers/mod.rs b/backend/src/workers/mod.rs new file mode 100644 index 0000000..acb5733 --- /dev/null +++ b/backend/src/workers/mod.rs @@ -0,0 +1 @@ +pub mod downloader; diff --git a/backend/tests/mod.rs b/backend/tests/mod.rs new file mode 100644 index 0000000..b42f234 --- /dev/null +++ b/backend/tests/mod.rs @@ -0,0 +1,4 @@ +mod models; +mod requests; +mod tasks; +mod workers; diff --git a/backend/tests/models/comments.rs b/backend/tests/models/comments.rs new file mode 100644 index 0000000..8c04d7d --- /dev/null +++ b/backend/tests/models/comments.rs @@ -0,0 +1,31 @@ +use loco_rs::testing::prelude::*; +use serial_test::serial; +use termi_api::app::App; + +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn test_model() { + configure_insta!(); + + let boot = boot_test::().await.unwrap(); + seed::(&boot.app_context).await.unwrap(); + + // query your model, e.g.: + // + // let item = models::posts::Model::find_by_pid( + // &boot.app_context.db, + // "11111111-1111-1111-1111-111111111111", + // ) + // .await; + + // snapshot the result: + // assert_debug_snapshot!(item); +} diff --git a/backend/tests/models/friend_links.rs b/backend/tests/models/friend_links.rs new file mode 100644 index 0000000..8c04d7d --- /dev/null +++ b/backend/tests/models/friend_links.rs @@ -0,0 +1,31 @@ +use loco_rs::testing::prelude::*; +use serial_test::serial; +use termi_api::app::App; + +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn test_model() { + configure_insta!(); + + let boot = boot_test::().await.unwrap(); + seed::(&boot.app_context).await.unwrap(); + + // query your model, e.g.: + // + // let item = models::posts::Model::find_by_pid( + // &boot.app_context.db, + // "11111111-1111-1111-1111-111111111111", + // ) + // .await; + + // snapshot the result: + // assert_debug_snapshot!(item); +} diff --git a/backend/tests/models/mod.rs b/backend/tests/models/mod.rs new file mode 100644 index 0000000..424cee0 --- /dev/null +++ b/backend/tests/models/mod.rs @@ -0,0 +1,6 @@ +mod users; + +mod comments; +mod friend_links; +mod posts; +mod tags; diff --git a/backend/tests/models/posts.rs b/backend/tests/models/posts.rs new file mode 100644 index 0000000..8c04d7d --- /dev/null +++ b/backend/tests/models/posts.rs @@ -0,0 +1,31 @@ +use loco_rs::testing::prelude::*; +use serial_test::serial; +use termi_api::app::App; + +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn test_model() { + configure_insta!(); + + let boot = boot_test::().await.unwrap(); + seed::(&boot.app_context).await.unwrap(); + + // query your model, e.g.: + // + // let item = models::posts::Model::find_by_pid( + // &boot.app_context.db, + // "11111111-1111-1111-1111-111111111111", + // ) + // .await; + + // snapshot the result: + // assert_debug_snapshot!(item); +} diff --git a/backend/tests/models/snapshots/can_create_with_password@users.snap b/backend/tests/models/snapshots/can_create_with_password@users.snap new file mode 100644 index 0000000..9811362 --- /dev/null +++ b/backend/tests/models/snapshots/can_create_with_password@users.snap @@ -0,0 +1,23 @@ +--- +source: tests/models/users.rs +expression: res +--- +Ok( + Model { + created_at: DATE, + updated_at: DATE, + id: ID + pid: PID, + email: "test@framework.com", + password: "PASSWORD", + api_key: "lo-PID", + name: "framework", + reset_token: None, + reset_sent_at: None, + email_verification_token: None, + email_verification_sent_at: None, + email_verified_at: None, + magic_link_token: None, + magic_link_expiration: None, + }, +) diff --git a/backend/tests/models/snapshots/can_find_by_email@users-2.snap b/backend/tests/models/snapshots/can_find_by_email@users-2.snap new file mode 100644 index 0000000..25c700a --- /dev/null +++ b/backend/tests/models/snapshots/can_find_by_email@users-2.snap @@ -0,0 +1,7 @@ +--- +source: tests/models/users.rs +expression: non_existing_user_results +--- +Err( + EntityNotFound, +) diff --git a/backend/tests/models/snapshots/can_find_by_email@users.snap b/backend/tests/models/snapshots/can_find_by_email@users.snap new file mode 100644 index 0000000..518753a --- /dev/null +++ b/backend/tests/models/snapshots/can_find_by_email@users.snap @@ -0,0 +1,23 @@ +--- +source: tests/models/users.rs +expression: existing_user +--- +Ok( + Model { + created_at: 2023-11-12T12:34:56.789+00:00, + updated_at: 2023-11-12T12:34:56.789+00:00, + id: 1, + pid: 11111111-1111-1111-1111-111111111111, + email: "user1@example.com", + password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc", + api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758", + name: "user1", + reset_token: None, + reset_sent_at: None, + email_verification_token: None, + email_verification_sent_at: None, + email_verified_at: None, + magic_link_token: None, + magic_link_expiration: None, + }, +) diff --git a/backend/tests/models/snapshots/can_find_by_pid@users-2.snap b/backend/tests/models/snapshots/can_find_by_pid@users-2.snap new file mode 100644 index 0000000..25c700a --- /dev/null +++ b/backend/tests/models/snapshots/can_find_by_pid@users-2.snap @@ -0,0 +1,7 @@ +--- +source: tests/models/users.rs +expression: non_existing_user_results +--- +Err( + EntityNotFound, +) diff --git a/backend/tests/models/snapshots/can_find_by_pid@users.snap b/backend/tests/models/snapshots/can_find_by_pid@users.snap new file mode 100644 index 0000000..518753a --- /dev/null +++ b/backend/tests/models/snapshots/can_find_by_pid@users.snap @@ -0,0 +1,23 @@ +--- +source: tests/models/users.rs +expression: existing_user +--- +Ok( + Model { + created_at: 2023-11-12T12:34:56.789+00:00, + updated_at: 2023-11-12T12:34:56.789+00:00, + id: 1, + pid: 11111111-1111-1111-1111-111111111111, + email: "user1@example.com", + password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc", + api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758", + name: "user1", + reset_token: None, + reset_sent_at: None, + email_verification_token: None, + email_verification_sent_at: None, + email_verified_at: None, + magic_link_token: None, + magic_link_expiration: None, + }, +) diff --git a/backend/tests/models/snapshots/can_validate_model@users.snap b/backend/tests/models/snapshots/can_validate_model@users.snap new file mode 100644 index 0000000..add2079 --- /dev/null +++ b/backend/tests/models/snapshots/can_validate_model@users.snap @@ -0,0 +1,9 @@ +--- +source: tests/models/users.rs +expression: res +--- +Err( + Custom( + "{\"email\":[{\"code\":\"email\",\"message\":\"invalid email\"}],\"name\":[{\"code\":\"length\",\"message\":\"Name must be at least 2 characters long.\"}]}", + ), +) diff --git a/backend/tests/models/snapshots/handle_create_with_password_with_duplicate@users.snap b/backend/tests/models/snapshots/handle_create_with_password_with_duplicate@users.snap new file mode 100644 index 0000000..ff28ea1 --- /dev/null +++ b/backend/tests/models/snapshots/handle_create_with_password_with_duplicate@users.snap @@ -0,0 +1,7 @@ +--- +source: tests/models/users.rs +expression: new_user +--- +Err( + EntityAlreadyExists, +) diff --git a/backend/tests/models/tags.rs b/backend/tests/models/tags.rs new file mode 100644 index 0000000..8c04d7d --- /dev/null +++ b/backend/tests/models/tags.rs @@ -0,0 +1,31 @@ +use loco_rs::testing::prelude::*; +use serial_test::serial; +use termi_api::app::App; + +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn test_model() { + configure_insta!(); + + let boot = boot_test::().await.unwrap(); + seed::(&boot.app_context).await.unwrap(); + + // query your model, e.g.: + // + // let item = models::posts::Model::find_by_pid( + // &boot.app_context.db, + // "11111111-1111-1111-1111-111111111111", + // ) + // .await; + + // snapshot the result: + // assert_debug_snapshot!(item); +} diff --git a/backend/tests/models/users.rs b/backend/tests/models/users.rs new file mode 100644 index 0000000..619c3d2 --- /dev/null +++ b/backend/tests/models/users.rs @@ -0,0 +1,360 @@ +use chrono::{offset::Local, Duration}; +use insta::assert_debug_snapshot; +use loco_rs::testing::prelude::*; +use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel}; +use serial_test::serial; +use termi_api::{ + app::App, + models::users::{self, Model, RegisterParams}, +}; + +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("users"); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn test_can_validate_model() { + configure_insta!(); + + let boot = boot_test::() + .await + .expect("Failed to boot test application"); + + let invalid_user = users::ActiveModel { + name: ActiveValue::set("1".to_string()), + email: ActiveValue::set("invalid-email".to_string()), + ..Default::default() + }; + + let res = invalid_user.insert(&boot.app_context.db).await; + + assert_debug_snapshot!(res); +} + +#[tokio::test] +#[serial] +async fn can_create_with_password() { + configure_insta!(); + + let boot = boot_test::() + .await + .expect("Failed to boot test application"); + + let params = RegisterParams { + email: "test@framework.com".to_string(), + password: "1234".to_string(), + name: "framework".to_string(), + }; + + let res = Model::create_with_password(&boot.app_context.db, ¶ms).await; + + insta::with_settings!({ + filters => cleanup_user_model() + }, { + assert_debug_snapshot!(res); + }); +} +#[tokio::test] +#[serial] +async fn handle_create_with_password_with_duplicate() { + configure_insta!(); + + let boot = boot_test::() + .await + .expect("Failed to boot test application"); + seed::(&boot.app_context) + .await + .expect("Failed to seed database"); + + let new_user = Model::create_with_password( + &boot.app_context.db, + &RegisterParams { + email: "user1@example.com".to_string(), + password: "1234".to_string(), + name: "framework".to_string(), + }, + ) + .await; + + assert_debug_snapshot!(new_user); +} + +#[tokio::test] +#[serial] +async fn can_find_by_email() { + configure_insta!(); + + let boot = boot_test::() + .await + .expect("Failed to boot test application"); + seed::(&boot.app_context) + .await + .expect("Failed to seed database"); + + let existing_user = Model::find_by_email(&boot.app_context.db, "user1@example.com").await; + let non_existing_user_results = + Model::find_by_email(&boot.app_context.db, "un@existing-email.com").await; + + assert_debug_snapshot!(existing_user); + assert_debug_snapshot!(non_existing_user_results); +} + +#[tokio::test] +#[serial] +async fn can_find_by_pid() { + configure_insta!(); + + let boot = boot_test::() + .await + .expect("Failed to boot test application"); + seed::(&boot.app_context) + .await + .expect("Failed to seed database"); + + let existing_user = + Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111").await; + let non_existing_user_results = + Model::find_by_pid(&boot.app_context.db, "23232323-2323-2323-2323-232323232323").await; + + assert_debug_snapshot!(existing_user); + assert_debug_snapshot!(non_existing_user_results); +} + +#[tokio::test] +#[serial] +async fn can_verification_token() { + configure_insta!(); + + let boot = boot_test::() + .await + .expect("Failed to boot test application"); + seed::(&boot.app_context) + .await + .expect("Failed to seed database"); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .expect("Failed to find user by PID"); + + assert!( + user.email_verification_sent_at.is_none(), + "Expected no email verification sent timestamp" + ); + assert!( + user.email_verification_token.is_none(), + "Expected no email verification token" + ); + + let result = user + .into_active_model() + .set_email_verification_sent(&boot.app_context.db) + .await; + + assert!(result.is_ok(), "Failed to set email verification sent"); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .expect("Failed to find user by PID after setting verification sent"); + + assert!( + user.email_verification_sent_at.is_some(), + "Expected email verification sent timestamp to be present" + ); + assert!( + user.email_verification_token.is_some(), + "Expected email verification token to be present" + ); +} + +#[tokio::test] +#[serial] +async fn can_set_forgot_password_sent() { + configure_insta!(); + + let boot = boot_test::() + .await + .expect("Failed to boot test application"); + seed::(&boot.app_context) + .await + .expect("Failed to seed database"); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .expect("Failed to find user by PID"); + + assert!( + user.reset_sent_at.is_none(), + "Expected no reset sent timestamp" + ); + assert!(user.reset_token.is_none(), "Expected no reset token"); + + let result = user + .into_active_model() + .set_forgot_password_sent(&boot.app_context.db) + .await; + + assert!(result.is_ok(), "Failed to set forgot password sent"); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .expect("Failed to find user by PID after setting forgot password sent"); + + assert!( + user.reset_sent_at.is_some(), + "Expected reset sent timestamp to be present" + ); + assert!( + user.reset_token.is_some(), + "Expected reset token to be present" + ); +} + +#[tokio::test] +#[serial] +async fn can_verified() { + configure_insta!(); + + let boot = boot_test::() + .await + .expect("Failed to boot test application"); + seed::(&boot.app_context) + .await + .expect("Failed to seed database"); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .expect("Failed to find user by PID"); + + assert!( + user.email_verified_at.is_none(), + "Expected email to be unverified" + ); + + let result = user + .into_active_model() + .verified(&boot.app_context.db) + .await; + + assert!(result.is_ok(), "Failed to mark email as verified"); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .expect("Failed to find user by PID after verification"); + + assert!( + user.email_verified_at.is_some(), + "Expected email to be verified" + ); +} + +#[tokio::test] +#[serial] +async fn can_reset_password() { + configure_insta!(); + + let boot = boot_test::() + .await + .expect("Failed to boot test application"); + seed::(&boot.app_context) + .await + .expect("Failed to seed database"); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .expect("Failed to find user by PID"); + + assert!( + user.verify_password("12341234"), + "Password verification failed for original password" + ); + + let result = user + .clone() + .into_active_model() + .reset_password(&boot.app_context.db, "new-password") + .await; + + assert!(result.is_ok(), "Failed to reset password"); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .expect("Failed to find user by PID after password reset"); + + assert!( + user.verify_password("new-password"), + "Password verification failed for new password" + ); +} + +#[tokio::test] +#[serial] +async fn magic_link() { + let boot = boot_test::().await.unwrap(); + seed::(&boot.app_context).await.unwrap(); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .unwrap(); + + assert!( + user.magic_link_token.is_none(), + "Magic link token should be initially unset" + ); + assert!( + user.magic_link_expiration.is_none(), + "Magic link expiration should be initially unset" + ); + + let create_result = user + .into_active_model() + .create_magic_link(&boot.app_context.db) + .await; + + assert!( + create_result.is_ok(), + "Failed to create magic link: {:?}", + create_result.unwrap_err() + ); + + let updated_user = + Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .expect("Failed to refetch user after magic link creation"); + + assert!( + updated_user.magic_link_token.is_some(), + "Magic link token should be set after creation" + ); + + let magic_link_token = updated_user.magic_link_token.unwrap(); + assert_eq!( + magic_link_token.len(), + users::MAGIC_LINK_LENGTH as usize, + "Magic link token length does not match expected length" + ); + + assert!( + updated_user.magic_link_expiration.is_some(), + "Magic link expiration should be set after creation" + ); + + let now = Local::now(); + let should_expired_at = now + Duration::minutes(users::MAGIC_LINK_EXPIRATION_MIN.into()); + let actual_expiration = updated_user.magic_link_expiration.unwrap(); + + assert!( + actual_expiration >= now, + "Magic link expiration should be in the future or now" + ); + + assert!( + actual_expiration <= should_expired_at, + "Magic link expiration exceeds expected maximum expiration time" + ); +} diff --git a/backend/tests/requests/auth.rs b/backend/tests/requests/auth.rs new file mode 100644 index 0000000..3b649f4 --- /dev/null +++ b/backend/tests/requests/auth.rs @@ -0,0 +1,502 @@ +use insta::{assert_debug_snapshot, with_settings}; +use loco_rs::testing::prelude::*; +use rstest::rstest; +use serial_test::serial; +use termi_api::{app::App, models::users}; + +use super::prepare_data; + +// TODO: see how to dedup / extract this to app-local test utils +// not to framework, because that would require a runtime dep on insta +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("auth_request"); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn can_register() { + configure_insta!(); + + request::(|request, ctx| async move { + let email = "test@loco.com"; + let payload = serde_json::json!({ + "name": "loco", + "email": email, + "password": "12341234" + }); + + let response = request.post("/api/auth/register").json(&payload).await; + assert_eq!( + response.status_code(), + 200, + "Register request should succeed" + ); + let saved_user = users::Model::find_by_email(&ctx.db, email).await; + + with_settings!({ + filters => cleanup_user_model() + }, { + assert_debug_snapshot!(saved_user); + }); + + let deliveries = ctx.mailer.unwrap().deliveries(); + assert_eq!(deliveries.count, 1, "Exactly one email should be sent"); + + // with_settings!({ + // filters => cleanup_email() + // }, { + // assert_debug_snapshot!(ctx.mailer.unwrap().deliveries()); + // }); + }) + .await; +} + +#[rstest] +#[case("login_with_valid_password", "12341234")] +#[case("login_with_invalid_password", "invalid-password")] +#[tokio::test] +#[serial] +async fn can_login_with_verify(#[case] test_name: &str, #[case] password: &str) { + configure_insta!(); + + request::(|request, ctx| async move { + let email = "test@loco.com"; + let register_payload = serde_json::json!({ + "name": "loco", + "email": email, + "password": "12341234" + }); + + //Creating a new user + let register_response = request + .post("/api/auth/register") + .json(®ister_payload) + .await; + + assert_eq!( + register_response.status_code(), + 200, + "Register request should succeed" + ); + + let user = users::Model::find_by_email(&ctx.db, email).await.unwrap(); + let email_verification_token = user + .email_verification_token + .expect("Email verification token should be generated"); + request + .get(&format!("/api/auth/verify/{email_verification_token}")) + .await; + + //verify user request + let response = request + .post("/api/auth/login") + .json(&serde_json::json!({ + "email": email, + "password": password + })) + .await; + + // Make sure email_verified_at is set + let user = users::Model::find_by_email(&ctx.db, email) + .await + .expect("Failed to find user by email"); + + assert!( + user.email_verified_at.is_some(), + "Expected the email to be verified, but it was not. User: {:?}", + user + ); + + with_settings!({ + filters => cleanup_user_model() + }, { + assert_debug_snapshot!(test_name, (response.status_code(), response.text())); + }); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn login_with_un_existing_email() { + configure_insta!(); + + request::(|request, _ctx| async move { + + let login_response = request + .post("/api/auth/login") + .json(&serde_json::json!({ + "email": "un_existing@loco.rs", + "password": "1234" + })) + .await; + + assert_eq!(login_response.status_code(), 401, "Login request should return 401"); + login_response.assert_json(&serde_json::json!({"error": "unauthorized", "description": "You do not have permission to access this resource"})); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_login_without_verify() { + configure_insta!(); + + request::(|request, _ctx| async move { + let email = "test@loco.com"; + let password = "12341234"; + let register_payload = serde_json::json!({ + "name": "loco", + "email": email, + "password": password + }); + + //Creating a new user + let register_response = request + .post("/api/auth/register") + .json(®ister_payload) + .await; + + assert_eq!( + register_response.status_code(), + 200, + "Register request should succeed" + ); + + //verify user request + let login_response = request + .post("/api/auth/login") + .json(&serde_json::json!({ + "email": email, + "password": password + })) + .await; + + assert_eq!( + login_response.status_code(), + 200, + "Login request should succeed" + ); + + with_settings!({ + filters => cleanup_user_model() + }, { + assert_debug_snapshot!(login_response.text()); + }); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn invalid_verification_token() { + configure_insta!(); + + request::(|request, _ctx| async move { + let response = request.get("/api/auth/verify/invalid-token").await; + + assert_eq!(response.status_code(), 401, "Verify request should reject"); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_reset_password() { + configure_insta!(); + + request::(|request, ctx| async move { + let login_data = prepare_data::init_user_login(&request, &ctx).await; + + let forgot_payload = serde_json::json!({ + "email": login_data.user.email, + }); + let forget_response = request.post("/api/auth/forgot").json(&forgot_payload).await; + assert_eq!( + forget_response.status_code(), + 200, + "Forget request should succeed" + ); + + let user = users::Model::find_by_email(&ctx.db, &login_data.user.email) + .await + .expect("Failed to find user by email"); + + assert!( + user.reset_token.is_some(), + "Expected reset_token to be set, but it was None. User: {user:?}" + ); + assert!( + user.reset_sent_at.is_some(), + "Expected reset_sent_at to be set, but it was None. User: {user:?}" + ); + + let new_password = "new-password"; + let reset_payload = serde_json::json!({ + "token": user.reset_token, + "password": new_password, + }); + + let reset_response = request.post("/api/auth/reset").json(&reset_payload).await; + assert_eq!( + reset_response.status_code(), + 200, + "Reset password request should succeed" + ); + + let user = users::Model::find_by_email(&ctx.db, &user.email) + .await + .unwrap(); + + assert!(user.reset_token.is_none()); + assert!(user.reset_sent_at.is_none()); + + assert_debug_snapshot!(reset_response.text()); + + let login_response = request + .post("/api/auth/login") + .json(&serde_json::json!({ + "email": user.email, + "password": new_password + })) + .await; + + assert_eq!( + login_response.status_code(), + 200, + "Login request should succeed" + ); + + let deliveries = ctx.mailer.unwrap().deliveries(); + assert_eq!(deliveries.count, 2, "Exactly one email should be sent"); + // with_settings!({ + // filters => cleanup_email() + // }, { + // assert_debug_snapshot!(deliveries.messages); + // }); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_get_current_user() { + configure_insta!(); + + request::(|request, ctx| async move { + let user = prepare_data::init_user_login(&request, &ctx).await; + + let (auth_key, auth_value) = prepare_data::auth_header(&user.token); + let response = request + .get("/api/auth/current") + .add_header(auth_key, auth_value) + .await; + + assert_eq!( + response.status_code(), + 200, + "Current request should succeed" + ); + + with_settings!({ + filters => cleanup_user_model() + }, { + assert_debug_snapshot!((response.status_code(), response.text())); + }); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_auth_with_magic_link() { + configure_insta!(); + request::(|request, ctx| async move { + seed::(&ctx).await.unwrap(); + + let payload = serde_json::json!({ + "email": "user1@example.com", + }); + let response = request.post("/api/auth/magic-link").json(&payload).await; + assert_eq!( + response.status_code(), + 200, + "Magic link request should succeed" + ); + + let deliveries = ctx.mailer.unwrap().deliveries(); + assert_eq!(deliveries.count, 1, "Exactly one email should be sent"); + + // let redact_token = format!("[a-zA-Z0-9]{{{}}}", users::MAGIC_LINK_LENGTH); + // with_settings!({ + // filters => { + // let mut combined_filters = cleanup_email().clone(); + // combined_filters.extend(vec![(r"(\\r\\n|=\\r\\n)", ""), (redact_token.as_str(), "[REDACT_TOKEN]") ]); + // combined_filters + // } + // }, { + // assert_debug_snapshot!(deliveries.messages); + // }); + + let user = users::Model::find_by_email(&ctx.db, "user1@example.com") + .await + .expect("User should be found"); + + let magic_link_token = user + .magic_link_token + .expect("Magic link token should be generated"); + let magic_link_response = request + .get(&format!("/api/auth/magic-link/{magic_link_token}")) + .await; + assert_eq!( + magic_link_response.status_code(), + 200, + "Magic link authentication should succeed" + ); + + with_settings!({ + filters => cleanup_user_model() + }, { + assert_debug_snapshot!(magic_link_response.text()); + }); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_reject_invalid_email() { + configure_insta!(); + request::(|request, _ctx| async move { + let invalid_email = "user1@temp-mail.com"; + let payload = serde_json::json!({ + "email": invalid_email, + }); + let response = request.post("/api/auth/magic-link").json(&payload).await; + assert_eq!( + response.status_code(), + 400, + "Expected request with invalid email '{invalid_email}' to be blocked, but it was allowed." + ); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_reject_invalid_magic_link_token() { + configure_insta!(); + request::(|request, ctx| async move { + seed::(&ctx).await.unwrap(); + + let magic_link_response = request.get("/api/auth/magic-link/invalid-token").await; + assert_eq!( + magic_link_response.status_code(), + 401, + "Magic link authentication should be rejected" + ); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_resend_verification_email() { + configure_insta!(); + + request::(|request, ctx| async move { + let email = "test@loco.com"; + let payload = serde_json::json!({ + "name": "loco", + "email": email, + "password": "12341234" + }); + + let response = request.post("/api/auth/register").json(&payload).await; + assert_eq!( + response.status_code(), + 200, + "Register request should succeed" + ); + + let resend_payload = serde_json::json!({ "email": email }); + + let resend_response = request + .post("/api/auth/resend-verification-mail") + .json(&resend_payload) + .await; + + assert_eq!( + resend_response.status_code(), + 200, + "Resend verification email should succeed" + ); + + let deliveries = ctx.mailer.unwrap().deliveries(); + + assert_eq!( + deliveries.count, 2, + "Two emails should have been sent: welcome and re-verification" + ); + + let user = users::Model::find_by_email(&ctx.db, email) + .await + .expect("User should exist"); + + with_settings!({ + filters => cleanup_user_model() + }, { + assert_debug_snapshot!("resend_verification_user", user); + }); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn cannot_resend_email_if_already_verified() { + configure_insta!(); + + request::(|request, ctx| async move { + let email = "verified@loco.com"; + let payload = serde_json::json!({ + "name": "verified", + "email": email, + "password": "12341234" + }); + + request.post("/api/auth/register").json(&payload).await; + + // Verify user + let user = users::Model::find_by_email(&ctx.db, email).await.unwrap(); + if let Some(token) = user.email_verification_token.clone() { + request.get(&format!("/api/auth/verify/{token}")).await; + } + + // Try resending verification email + let resend_payload = serde_json::json!({ "email": email }); + + let resend_response = request + .post("/api/auth/resend-verification-mail") + .json(&resend_payload) + .await; + + assert_eq!( + resend_response.status_code(), + 200, + "Should return 200 even if already verified" + ); + + let deliveries = ctx.mailer.unwrap().deliveries(); + assert_eq!( + deliveries.count, 1, + "Only the original welcome email should be sent" + ); + }) + .await; +} diff --git a/backend/tests/requests/category.rs b/backend/tests/requests/category.rs new file mode 100644 index 0000000..d7a08d2 --- /dev/null +++ b/backend/tests/requests/category.rs @@ -0,0 +1,19 @@ +use loco_rs::testing::prelude::*; +use serial_test::serial; +use termi_api::app::App; + +#[tokio::test] +#[serial] +async fn can_get_categories() { + request::(|request, ctx| async move { + seed::(&ctx).await.unwrap(); + + let res = request.get("/api/categories/").await; + assert_eq!(res.status_code(), 200); + + let body = res.text(); + assert!(body.contains("\"name\":\"tech\"")); + assert!(body.contains("\"count\":3")); + }) + .await; +} diff --git a/backend/tests/requests/comment.rs b/backend/tests/requests/comment.rs new file mode 100644 index 0000000..229d22c --- /dev/null +++ b/backend/tests/requests/comment.rs @@ -0,0 +1,35 @@ +use loco_rs::testing::prelude::*; +use serial_test::serial; +use termi_api::app::App; + +#[tokio::test] +#[serial] +async fn can_get_comments() { + request::(|request, ctx| async move { + seed::(&ctx).await.unwrap(); + + let res = request.get("/api/comments/").await; + assert_eq!(res.status_code(), 200); + assert!(res.text().contains("\"author\":\"Alice\"")); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_filter_comments_by_post_slug() { + request::(|request, ctx| async move { + seed::(&ctx).await.unwrap(); + + let res = request + .get("/api/comments/?post_slug=rust-programming-tips") + .await; + assert_eq!(res.status_code(), 200); + + let body = res.text(); + assert!(body.contains("\"author\":\"Charlie\"")); + assert!(body.contains("\"author\":\"Grace\"")); + assert!(!body.contains("\"author\":\"Alice\"")); + }) + .await; +} diff --git a/backend/tests/requests/friend_link.rs b/backend/tests/requests/friend_link.rs new file mode 100644 index 0000000..dab8d9f --- /dev/null +++ b/backend/tests/requests/friend_link.rs @@ -0,0 +1,32 @@ +use loco_rs::testing::prelude::*; +use serial_test::serial; +use termi_api::app::App; + +#[tokio::test] +#[serial] +async fn can_get_friend_links() { + request::(|request, ctx| async move { + seed::(&ctx).await.unwrap(); + + let res = request.get("/api/friend_links/").await; + assert_eq!(res.status_code(), 200); + assert!(res.text().contains("\"site_name\":\"Tech Blog Daily\"")); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_filter_friend_links_by_status() { + request::(|request, ctx| async move { + seed::(&ctx).await.unwrap(); + + let res = request.get("/api/friend_links/?status=approved").await; + assert_eq!(res.status_code(), 200); + + let body = res.text(); + assert!(body.contains("\"site_name\":\"Tech Blog Daily\"")); + assert!(!body.contains("\"site_name\":\"Code Snippets\"")); + }) + .await; +} diff --git a/backend/tests/requests/mod.rs b/backend/tests/requests/mod.rs new file mode 100644 index 0000000..0a58229 --- /dev/null +++ b/backend/tests/requests/mod.rs @@ -0,0 +1,8 @@ +mod auth; +mod prepare_data; + +pub mod category; +pub mod comment; +pub mod friend_link; +pub mod post; +pub mod tag; diff --git a/backend/tests/requests/post.rs b/backend/tests/requests/post.rs new file mode 100644 index 0000000..777278c --- /dev/null +++ b/backend/tests/requests/post.rs @@ -0,0 +1,33 @@ +use loco_rs::testing::prelude::*; +use serial_test::serial; +use termi_api::app::App; + +#[tokio::test] +#[serial] +async fn can_get_posts() { + request::(|request, ctx| async move { + seed::(&ctx).await.unwrap(); + + let res = request.get("/api/posts/").await; + assert_eq!(res.status_code(), 200); + assert!(res.text().contains("\"slug\":\"welcome-to-termi\"")); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_filter_posts_by_tag() { + request::(|request, ctx| async move { + seed::(&ctx).await.unwrap(); + + let res = request.get("/api/posts/?tag=Rust").await; + assert_eq!(res.status_code(), 200); + + let body = res.text(); + assert!(body.contains("\"slug\":\"rust-programming-tips\"")); + assert!(body.contains("\"slug\":\"loco-rs-framework\"")); + assert!(!body.contains("\"slug\":\"terminal-ui-design\"")); + }) + .await; +} diff --git a/backend/tests/requests/prepare_data.rs b/backend/tests/requests/prepare_data.rs new file mode 100644 index 0000000..7273f69 --- /dev/null +++ b/backend/tests/requests/prepare_data.rs @@ -0,0 +1,57 @@ +use axum::http::{HeaderName, HeaderValue}; +use loco_rs::{app::AppContext, TestServer}; +use termi_api::{models::users, views::auth::LoginResponse}; + +const USER_EMAIL: &str = "test@loco.com"; +const USER_PASSWORD: &str = "1234"; + +pub struct LoggedInUser { + pub user: users::Model, + pub token: String, +} + +pub async fn init_user_login(request: &TestServer, ctx: &AppContext) -> LoggedInUser { + let register_payload = serde_json::json!({ + "name": "loco", + "email": USER_EMAIL, + "password": USER_PASSWORD + }); + + //Creating a new user + request + .post("/api/auth/register") + .json(®ister_payload) + .await; + let user = users::Model::find_by_email(&ctx.db, USER_EMAIL) + .await + .unwrap(); + + let verify_payload = serde_json::json!({ + "token": user.email_verification_token, + }); + + request.post("/api/auth/verify").json(&verify_payload).await; + + let response = request + .post("/api/auth/login") + .json(&serde_json::json!({ + "email": USER_EMAIL, + "password": USER_PASSWORD + })) + .await; + + let login_response: LoginResponse = serde_json::from_str(&response.text()).unwrap(); + + LoggedInUser { + user: users::Model::find_by_email(&ctx.db, USER_EMAIL) + .await + .unwrap(), + token: login_response.token, + } +} + +pub fn auth_header(token: &str) -> (HeaderName, HeaderValue) { + let auth_header_value = HeaderValue::from_str(&format!("Bearer {}", &token)).unwrap(); + + (HeaderName::from_static("authorization"), auth_header_value) +} diff --git a/backend/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap b/backend/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap new file mode 100644 index 0000000..1999857 --- /dev/null +++ b/backend/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap @@ -0,0 +1,5 @@ +--- +source: tests/requests/auth.rs +expression: magic_link_response.text() +--- +"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"user1\",\"is_verified\":false}" diff --git a/backend/tests/requests/snapshots/can_get_current_user@auth_request.snap b/backend/tests/requests/snapshots/can_get_current_user@auth_request.snap new file mode 100644 index 0000000..74f7e71 --- /dev/null +++ b/backend/tests/requests/snapshots/can_get_current_user@auth_request.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/auth.rs +expression: "(response.status_code(), response.text())" +--- +( + 200, + "{\"pid\":\"PID\",\"name\":\"loco\",\"email\":\"test@loco.com\"}", +) diff --git a/backend/tests/requests/snapshots/can_login_without_verify@auth_request.snap b/backend/tests/requests/snapshots/can_login_without_verify@auth_request.snap new file mode 100644 index 0000000..1fac2fd --- /dev/null +++ b/backend/tests/requests/snapshots/can_login_without_verify@auth_request.snap @@ -0,0 +1,5 @@ +--- +source: tests/requests/auth.rs +expression: login_response.text() +--- +"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":false}" diff --git a/backend/tests/requests/snapshots/can_register@auth_request.snap b/backend/tests/requests/snapshots/can_register@auth_request.snap new file mode 100644 index 0000000..687580c --- /dev/null +++ b/backend/tests/requests/snapshots/can_register@auth_request.snap @@ -0,0 +1,27 @@ +--- +source: tests/requests/auth.rs +expression: saved_user +--- +Ok( + Model { + created_at: DATE, + updated_at: DATE, + id: ID + pid: PID, + email: "test@loco.com", + password: "PASSWORD", + api_key: "lo-PID", + name: "loco", + reset_token: None, + reset_sent_at: None, + email_verification_token: Some( + "PID", + ), + email_verification_sent_at: Some( + DATE, + ), + email_verified_at: None, + magic_link_token: None, + magic_link_expiration: None, + }, +) diff --git a/backend/tests/requests/snapshots/can_reset_password@auth_request.snap b/backend/tests/requests/snapshots/can_reset_password@auth_request.snap new file mode 100644 index 0000000..d426079 --- /dev/null +++ b/backend/tests/requests/snapshots/can_reset_password@auth_request.snap @@ -0,0 +1,5 @@ +--- +source: tests/requests/auth.rs +expression: "(reset_response.status_code(), reset_response.text())" +--- +"null" \ No newline at end of file diff --git a/backend/tests/requests/snapshots/login_with_invalid_password@auth_request.snap b/backend/tests/requests/snapshots/login_with_invalid_password@auth_request.snap new file mode 100644 index 0000000..eb6e89f --- /dev/null +++ b/backend/tests/requests/snapshots/login_with_invalid_password@auth_request.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/auth.rs +expression: "(response.status_code(), response.text())" +--- +( + 401, + "{\"error\":\"unauthorized\",\"description\":\"You do not have permission to access this resource\"}", +) diff --git a/backend/tests/requests/snapshots/login_with_valid_password@auth_request.snap b/backend/tests/requests/snapshots/login_with_valid_password@auth_request.snap new file mode 100644 index 0000000..f06fbaa --- /dev/null +++ b/backend/tests/requests/snapshots/login_with_valid_password@auth_request.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/auth.rs +expression: "(response.status_code(), response.text())" +--- +( + 200, + "{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":true}", +) diff --git a/backend/tests/requests/snapshots/resend_verification_user@auth_request.snap b/backend/tests/requests/snapshots/resend_verification_user@auth_request.snap new file mode 100644 index 0000000..055b6ca --- /dev/null +++ b/backend/tests/requests/snapshots/resend_verification_user@auth_request.snap @@ -0,0 +1,26 @@ +--- +source: tests/requests/auth.rs +assertion_line: 414 +expression: user +--- +Model { + created_at: DATE, + updated_at: DATE, + id: ID + pid: PID, + email: "test@loco.com", + password: "PASSWORD", + api_key: "lo-PID", + name: "loco", + reset_token: None, + reset_sent_at: None, + email_verification_token: Some( + "PID", + ), + email_verification_sent_at: Some( + DATE, + ), + email_verified_at: None, + magic_link_token: None, + magic_link_expiration: None, +} diff --git a/backend/tests/requests/tag.rs b/backend/tests/requests/tag.rs new file mode 100644 index 0000000..41a500d --- /dev/null +++ b/backend/tests/requests/tag.rs @@ -0,0 +1,16 @@ +use loco_rs::testing::prelude::*; +use serial_test::serial; +use termi_api::app::App; + +#[tokio::test] +#[serial] +async fn can_get_tags() { + request::(|request, _ctx| async move { + let res = request.get("/api/tags/").await; + assert_eq!(res.status_code(), 200); + + // you can assert content like this: + // assert_eq!(res.text(), "content"); + }) + .await; +} diff --git a/backend/tests/tasks/mod.rs b/backend/tests/tasks/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/tests/tasks/mod.rs @@ -0,0 +1 @@ + diff --git a/backend/tests/workers/mod.rs b/backend/tests/workers/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/tests/workers/mod.rs @@ -0,0 +1 @@ + diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..16d54bb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..87b813a --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,43 @@ +# Astro Starter Kit: Minimal + +```sh +npm create astro@latest -- --template minimal +``` + +> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! + +## 🚀 Project Structure + +Inside of your Astro project, you'll see the following folders and files: + +```text +/ +├── public/ +├── src/ +│ └── pages/ +│ └── index.astro +└── package.json +``` + +Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. + +There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. + +Any static assets, like images, can be placed in the `public/` directory. + +## 🧞 Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +| :------------------------ | :----------------------------------------------- | +| `npm install` | Installs dependencies | +| `npm run dev` | Starts local dev server at `localhost:4321` | +| `npm run build` | Build your production site to `./dist/` | +| `npm run preview` | Preview your build locally, before deploying | +| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | +| `npm run astro -- --help` | Get help using the Astro CLI | + +## 👀 Want to learn more? + +Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). diff --git a/frontend/astro.config.mjs b/frontend/astro.config.mjs new file mode 100644 index 0000000..441ef21 --- /dev/null +++ b/frontend/astro.config.mjs @@ -0,0 +1,14 @@ +// @ts-check +import { defineConfig } from 'astro/config'; +import svelte from '@astrojs/svelte'; +import tailwind from '@astrojs/tailwind'; + +// https://astro.build/config +export default defineConfig({ + integrations: [ + svelte(), + tailwind({ + applyBaseStyles: false + }) + ] +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..287f5ae --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6868 @@ +{ + "name": "termi-astro", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "termi-astro", + "version": "0.0.1", + "dependencies": { + "@astrojs/svelte": "^8.0.3", + "@astrojs/tailwind": "^6.0.2", + "@tailwindcss/typography": "^0.5.19", + "astro": "^6.0.8", + "autoprefixer": "^10.4.27", + "lucide-astro": "^0.556.0", + "postcss": "^8.5.8", + "svelte": "^5.55.0", + "tailwindcss": "^3.4.19" + }, + "devDependencies": { + "@astrojs/check": "^0.9.8", + "typescript": "^6.0.2" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@astrojs/check": { + "version": "0.9.8", + "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.8.tgz", + "integrity": "sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@astrojs/language-server": "^2.16.5", + "chokidar": "^4.0.3", + "kleur": "^4.1.5", + "yargs": "^17.7.2" + }, + "bin": { + "astro-check": "bin/astro-check.js" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } + }, + "node_modules/@astrojs/check/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@astrojs/check/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@astrojs/compiler": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-3.0.1.tgz", + "integrity": "sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA==", + "license": "MIT" + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.8.0.tgz", + "integrity": "sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w==", + "license": "MIT", + "dependencies": { + "picomatch": "^4.0.3" + } + }, + "node_modules/@astrojs/language-server": { + "version": "2.16.6", + "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.6.tgz", + "integrity": "sha512-N990lu+HSFiG57owR0XBkr02BYMgiLCshLf+4QG4v6jjSWkBeQGnzqi+E1L08xFPPJ7eEeXnxPXGLaVv5pa4Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^2.13.1", + "@astrojs/yaml2ts": "^0.2.3", + "@jridgewell/sourcemap-codec": "^1.5.5", + "@volar/kit": "~2.4.28", + "@volar/language-core": "~2.4.28", + "@volar/language-server": "~2.4.28", + "@volar/language-service": "~2.4.28", + "muggle-string": "^0.4.1", + "tinyglobby": "^0.2.15", + "volar-service-css": "0.0.70", + "volar-service-emmet": "0.0.70", + "volar-service-html": "0.0.70", + "volar-service-prettier": "0.0.70", + "volar-service-typescript": "0.0.70", + "volar-service-typescript-twoslash-queries": "0.0.70", + "volar-service-yaml": "0.0.70", + "vscode-html-languageservice": "^5.6.2", + "vscode-uri": "^3.1.0" + }, + "bin": { + "astro-ls": "bin/nodeServer.js" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "prettier-plugin-astro": ">=0.11.0" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + } + } + }, + "node_modules/@astrojs/language-server/node_modules/@astrojs/compiler": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz", + "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@astrojs/markdown-remark": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.0.1.tgz", + "integrity": "sha512-zAfLJmn07u9SlDNNHTpjv0RT4F8D4k54NR7ReRas8CO4OeGoqSvOuKwqCFg2/cqN3wHwdWlK/7Yv/lMXlhVIaw==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.8.0", + "@astrojs/prism": "4.0.1", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "shiki": "^4.0.0", + "smol-toml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.1.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/prism": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.1.tgz", + "integrity": "sha512-nksZQVjlferuWzhPsBpQ1JE5XuKAf1id1/9Hj4a9KG4+ofrlzxUUwX4YGQF/SuDiuiGKEnzopGOt38F3AnVWsQ==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/svelte": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@astrojs/svelte/-/svelte-8.0.3.tgz", + "integrity": "sha512-R9vUtQGV+j4Zs3cPm2zRHCyYxQR4DRDEl7rgwIu5i4UpAlVBUYIu34onpgNZEpvws1rxvLhrA/N10qLrFNTYyw==", + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "svelte2tsx": "^0.7.51", + "vite": "^7.3.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "astro": "^6.0.0", + "svelte": "^5.43.6", + "typescript": "^5.3.3" + } + }, + "node_modules/@astrojs/tailwind": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/tailwind/-/tailwind-6.0.2.tgz", + "integrity": "sha512-j3mhLNeugZq6A8dMNXVarUa8K6X9AW+QHU9u3lKNrPLMHhOQ0S7VeWhHwEeJFpEK1BTKEUY1U78VQv2gN6hNGg==", + "license": "MIT", + "dependencies": { + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "postcss-load-config": "^4.0.2" + }, + "peerDependencies": { + "astro": "^3.0.0 || ^4.0.0 || ^5.0.0", + "tailwindcss": "^3.0.24" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz", + "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.2.0", + "debug": "^4.4.0", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "is-docker": "^3.0.0", + "is-wsl": "^3.1.0", + "which-pm-runs": "^1.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@astrojs/yaml2ts": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.3.tgz", + "integrity": "sha512-PJzRmgQzUxI2uwpdX2lXSHtP4G8ocp24/t+bZyf5Fy0SZLSF9f9KXZoMlFM/XCGue+B0nH/2IZ7FpBYQATBsCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.8.2" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", + "integrity": "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@clack/core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.1.0.tgz", + "integrity": "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==", + "license": "MIT", + "dependencies": { + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.1.0.tgz", + "integrity": "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.1.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@emmetio/abbreviation": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz", + "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/scanner": "^1.0.4" + } + }, + "node_modules/@emmetio/css-abbreviation": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz", + "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/scanner": "^1.0.4" + } + }, + "node_modules/@emmetio/css-parser": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz", + "integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/stream-reader": "^2.2.0", + "@emmetio/stream-reader-utils": "^0.1.0" + } + }, + "node_modules/@emmetio/html-matcher": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz", + "integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@emmetio/scanner": "^1.0.0" + } + }, + "node_modules/@emmetio/scanner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz", + "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emmetio/stream-reader": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz", + "integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emmetio/stream-reader-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz", + "integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", + "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", + "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", + "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", + "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@volar/kit": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz", + "integrity": "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-service": "2.4.28", + "@volar/typescript": "2.4.28", + "typesafe-path": "^0.2.2", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/language-server": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.28.tgz", + "integrity": "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@volar/language-service": "2.4.28", + "@volar/typescript": "2.4.28", + "path-browserify": "^1.0.1", + "request-light": "^0.7.0", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@volar/language-service": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.28.tgz", + "integrity": "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vscode/emmet-helper": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz", + "integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "emmet": "^2.4.3", + "jsonc-parser": "^2.3.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.15.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/astro": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/astro/-/astro-6.0.8.tgz", + "integrity": "sha512-DCPeb8GKOoFWh+8whB7Qi/kKWD/6NcQ9nd1QVNzJFxgHkea3WYrNroQRq4whmBdjhkYPTLS/1gmUAl2iA2Es2g==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^3.0.0", + "@astrojs/internal-helpers": "0.8.0", + "@astrojs/markdown-remark": "7.0.1", + "@astrojs/telemetry": "3.3.0", + "@capsizecss/unpack": "^4.0.0", + "@clack/prompts": "^1.0.1", + "@oslojs/encoding": "^1.1.0", + "@rollup/pluginutils": "^5.3.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "ci-info": "^4.4.0", + "clsx": "^2.1.1", + "common-ancestor-path": "^2.0.0", + "cookie": "^1.1.1", + "devalue": "^5.6.3", + "diff": "^8.0.3", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "es-module-lexer": "^2.0.0", + "esbuild": "^0.27.3", + "flattie": "^1.1.1", + "fontace": "~0.4.1", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "js-yaml": "^4.1.1", + "magic-string": "^0.30.21", + "magicast": "^0.5.2", + "mrmime": "^2.0.1", + "neotraverse": "^0.6.18", + "obug": "^2.1.1", + "p-limit": "^7.3.0", + "p-queue": "^9.1.0", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.3", + "rehype": "^13.0.2", + "semver": "^7.7.4", + "shiki": "^4.0.0", + "smol-toml": "^1.6.0", + "svgo": "^4.0.0", + "tinyclip": "^0.1.6", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tsconfck": "^3.1.6", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.4", + "unist-util-visit": "^5.1.0", + "unstorage": "^1.17.4", + "vfile": "^6.0.3", + "vite": "^7.3.1", + "vitefu": "^1.1.2", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^22.0.0", + "zod": "^4.3.6" + }, + "bin": { + "astro": "bin/astro.mjs" + }, + "engines": { + "node": ">=22.12.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dedent-js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz", + "integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.325", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", + "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", + "license": "ISC" + }, + "node_modules/emmet": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz", + "integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "./packages/scanner", + "./packages/abbreviation", + "./packages/css-abbreviation", + "./" + ], + "dependencies": { + "@emmetio/abbreviation": "^2.3.3", + "@emmetio/css-abbreviation": "^2.1.8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/h3": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.10.tgz", + "integrity": "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.2", + "crossws": "^0.3.5", + "defu": "^6.1.4", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonc-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", + "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", + "dev": true, + "license": "MIT" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lucide-astro": { + "version": "0.556.0", + "resolved": "https://registry.npmjs.org/lucide-astro/-/lucide-astro-0.556.0.tgz", + "integrity": "sha512-ugMjPb45AMfkLCaduNSbyy5NQEKvB1TxVVMmUS4S6L807PMESnX0Qp+DIKHjbyjJmPXOyLRbrzvR3YikTK7brg==", + "deprecated": "Deprecated: Use `@lucide/astro`", + "license": "MIT", + "peerDependencies": { + "astro": ">=2.7.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", + "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", + "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz", + "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/request-light": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz", + "integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shiki": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", + "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.0.2", + "@shikijs/engine-javascript": "4.0.2", + "@shikijs/engine-oniguruma": "4.0.2", + "@shikijs/langs": "4.0.2", + "@shikijs/themes": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz", + "integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/svelte2tsx": { + "version": "0.7.52", + "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.52.tgz", + "integrity": "sha512-svdT1FTrCLpvlU62evO5YdJt/kQ7nxgQxII/9BpQUvKr+GJRVdAXNVw8UWOt0fhoe5uWKyU0WsUTMRVAtRbMQg==", + "license": "MIT", + "dependencies": { + "dedent-js": "^1.0.1", + "scule": "^1.3.0" + }, + "peerDependencies": { + "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", + "typescript": "^4.9.4 || ^5.0.0" + } + }, + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tailwindcss/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyclip": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.12.tgz", + "integrity": "sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >= 17.3.0" + } + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typesafe-path": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz", + "integrity": "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-auto-import-cache": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz", + "integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.8" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", + "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", + "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ofetch": "^1.5.1", + "ohash": "^2.0.11" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.4.tgz", + "integrity": "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.5", + "lru-cache": "^11.2.0", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/volar-service-css": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz", + "integrity": "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-css-languageservice": "^6.3.0", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-emmet": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.70.tgz", + "integrity": "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/css-parser": "^0.4.1", + "@emmetio/html-matcher": "^1.3.0", + "@vscode/emmet-helper": "^2.9.3", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-html": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.70.tgz", + "integrity": "sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-html-languageservice": "^5.3.0", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-prettier": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.70.tgz", + "integrity": "sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0", + "prettier": "^2.2 || ^3.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + }, + "prettier": { + "optional": true + } + } + }, + "node_modules/volar-service-typescript": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.70.tgz", + "integrity": "sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-browserify": "^1.0.1", + "semver": "^7.6.2", + "typescript-auto-import-cache": "^0.3.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-nls": "^5.2.0", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-typescript-twoslash-queries": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.70.tgz", + "integrity": "sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-yaml": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.70.tgz", + "integrity": "sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-uri": "^3.0.8", + "yaml-language-server": "~1.20.0" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/vscode-css-languageservice": { + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz", + "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-html-languageservice": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz", + "integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-json-languageservice": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.1.8.tgz", + "integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsonc-parser": "^3.0.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.16.0", + "vscode-nls": "^5.0.0", + "vscode-uri": "^3.0.2" + }, + "engines": { + "npm": ">=7.0.0" + } + }, + "node_modules/vscode-json-languageservice/node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-nls": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", + "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yaml-language-server": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.20.0.tgz", + "integrity": "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "prettier": "^3.5.0", + "request-light": "^0.5.7", + "vscode-json-languageservice": "4.1.8", + "vscode-languageserver": "^9.0.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.16.0", + "vscode-uri": "^3.0.2", + "yaml": "2.7.1" + }, + "bin": { + "yaml-language-server": "bin/yaml-language-server" + } + }, + "node_modules/yaml-language-server/node_modules/request-light": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz", + "integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml-language-server/node_modules/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9177d90 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "termi-astro", + "type": "module", + "version": "0.0.1", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/svelte": "^8.0.3", + "@astrojs/tailwind": "^6.0.2", + "@tailwindcss/typography": "^0.5.19", + "astro": "^6.0.8", + "autoprefixer": "^10.4.27", + "lucide-astro": "^0.556.0", + "postcss": "^8.5.8", + "svelte": "^5.55.0", + "tailwindcss": "^3.4.19" + }, + "devDependencies": { + "@astrojs/check": "^0.9.8", + "typescript": "^6.0.2" + } +} diff --git a/public/favicon.ico b/frontend/public/favicon.ico similarity index 100% rename from public/favicon.ico rename to frontend/public/favicon.ico diff --git a/public/favicon.svg b/frontend/public/favicon.svg similarity index 100% rename from public/favicon.svg rename to frontend/public/favicon.svg diff --git a/frontend/src/components/BackToTop.astro b/frontend/src/components/BackToTop.astro new file mode 100644 index 0000000..4b201b4 --- /dev/null +++ b/frontend/src/components/BackToTop.astro @@ -0,0 +1,46 @@ +--- +// Back to Top Button Component +--- + + + + diff --git a/frontend/src/components/CodeBlock.astro b/frontend/src/components/CodeBlock.astro new file mode 100644 index 0000000..d6fa631 --- /dev/null +++ b/frontend/src/components/CodeBlock.astro @@ -0,0 +1,41 @@ +--- +interface Props { + code: string; + language?: string; + class?: string; +} + +const { code, language = 'bash', class: className = '' } = Astro.props; + +// Simple syntax highlighting classes +const languageColors: Record = { + bash: 'text-[var(--primary)]', + javascript: 'text-[#f7df1e]', + typescript: 'text-[#3178c6]', + python: 'text-[#3776ab]', + rust: 'text-[#dea584]', + go: 'text-[#00add8]', + html: 'text-[#e34c26]', + css: 'text-[#264de4]', + json: 'text-[var(--secondary)]', +}; + +const langColor = languageColors[language] || 'text-[var(--text)]'; +--- + +
+ +
+ {language} + +
+ + +
{code}
+
diff --git a/frontend/src/components/CodeCopyButton.astro b/frontend/src/components/CodeCopyButton.astro new file mode 100644 index 0000000..bffc3d6 --- /dev/null +++ b/frontend/src/components/CodeCopyButton.astro @@ -0,0 +1,61 @@ +--- +// Code Block Copy Button Component +// Adds copy functionality to all code blocks +--- + + diff --git a/frontend/src/components/Comments.astro b/frontend/src/components/Comments.astro new file mode 100644 index 0000000..de48e8c --- /dev/null +++ b/frontend/src/components/Comments.astro @@ -0,0 +1,334 @@ +--- +import { API_BASE_URL, apiClient } from '../lib/api/client'; +import type { Comment } from '../lib/api/client'; + +interface Props { + postSlug: string; + class?: string; +} + +const { postSlug, class: className = '' } = Astro.props; + +let comments: Comment[] = []; +let error: string | null = null; + +try { + comments = await apiClient.getComments(postSlug, { approved: true }); +} catch (e) { + error = e instanceof Error ? e.message : '加载评论失败'; + console.error('Failed to fetch comments:', e); +} + +function formatCommentDate(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) return '今天'; + if (days === 1) return '昨天'; + if (days < 7) return `${days} 天前`; + if (days < 30) return `${Math.floor(days / 7)} 周前`; + return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); +} +--- + +
+
+
+ + + discussion buffer + +
+ + + +
+

评论终端

+

+ 当前缓冲区共有 {comments.length} 条已展示评论,新的留言提交后会进入审核队列。 +

+
+
+
+ + +
+ + + +
+ {error ? ( +
+ {error} +
+ ) : comments.length === 0 ? ( +
+
+ + + +

暂无评论

+

+ 当前还没有留言。可以打开上面的输入面板,作为第一个在这个终端缓冲区里发言的人。 +

+
+
+ ) : ( + comments.map(comment => ( +
+
+
+
+ +
+
+ +
+
+ {comment.author || '匿名'} + + + {formatCommentDate(comment.created_at)} + +
+ +

{comment.content}

+ +
+ + +
+
+
+
+ )) + )} +
+
+ + diff --git a/frontend/src/components/Footer.astro b/frontend/src/components/Footer.astro new file mode 100644 index 0000000..2724a56 --- /dev/null +++ b/frontend/src/components/Footer.astro @@ -0,0 +1,79 @@ +--- +import { terminalConfig } from '../lib/config/terminal'; +import { DEFAULT_SITE_SETTINGS } from '../lib/api/client'; +import type { SiteSettings } from '../lib/types'; + +interface Props { + siteSettings?: SiteSettings; +} + +const { siteSettings = DEFAULT_SITE_SETTINGS } = Astro.props; +const social = siteSettings.social; +const currentYear = new Date().getFullYear(); +--- + +
+
+
+
+
+
+
session
+

© {currentYear} {siteSettings.siteName}. All rights reserved.

+
+
+ +
+ {terminalConfig.tools.map(tool => ( + + + + ))} +
+ +
+ {social.github && ( + + + + )} + {social.twitter && ( + + + + )} + {social.email && ( + + + + )} +
+
+
+

+ user@{siteSettings.siteShortName.toLowerCase()}:~$ echo "{siteSettings.siteDescription}" +

+
+
+
+
diff --git a/frontend/src/components/FriendLinkApplication.astro b/frontend/src/components/FriendLinkApplication.astro new file mode 100644 index 0000000..4f80daf --- /dev/null +++ b/frontend/src/components/FriendLinkApplication.astro @@ -0,0 +1,258 @@ +--- +import { API_BASE_URL, DEFAULT_SITE_SETTINGS } from '../lib/api/client'; +import type { SiteSettings } from '../lib/types'; + +interface Props { + class?: string; + siteSettings?: SiteSettings; +} + +const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.props; +--- + + + + + + diff --git a/frontend/src/components/FriendLinkCard.astro b/frontend/src/components/FriendLinkCard.astro new file mode 100644 index 0000000..2478500 --- /dev/null +++ b/frontend/src/components/FriendLinkCard.astro @@ -0,0 +1,70 @@ +--- +import type { FriendLink } from '../lib/types'; + +interface Props { + friend: FriendLink; +} + +const { friend } = Astro.props; +--- + + +
+ {friend.avatar ? ( +
+ {friend.name} + +
+ ) : ( +
+ {friend.name.charAt(0).toUpperCase()} +
+ )} +
+ +
+
+

+ {friend.name} +

+ +
+ + {friend.description && ( +

{friend.description}

+ )} + +
+ {friend.category ? ( + + + {friend.category} + + ) : ( + external link + )} + + + 访问 + + +
+
+
diff --git a/frontend/src/components/Header.astro b/frontend/src/components/Header.astro new file mode 100644 index 0000000..e305918 --- /dev/null +++ b/frontend/src/components/Header.astro @@ -0,0 +1,507 @@ +--- +import { terminalConfig } from '../lib/config/terminal'; +import type { SiteSettings } from '../lib/types'; + +interface Props { + siteName?: string; + siteSettings?: SiteSettings; +} + +const { + siteName = Astro.props.siteSettings?.siteShortName || terminalConfig.branding?.shortName || 'Termi' +} = Astro.props; + +const navItems = terminalConfig.navLinks; +const currentPath = Astro.url.pathname; +--- + +
+
+
+
+
+ + + + + + root@termi + {siteName} + + + + + + + + + + +
+ + +
+
+ + + +
+ + diff --git a/frontend/src/components/Lightbox.astro b/frontend/src/components/Lightbox.astro new file mode 100644 index 0000000..299cd2f --- /dev/null +++ b/frontend/src/components/Lightbox.astro @@ -0,0 +1,141 @@ +--- +// Image Lightbox Component +--- + + + + diff --git a/frontend/src/components/PostCard.astro b/frontend/src/components/PostCard.astro new file mode 100644 index 0000000..7dca5f1 --- /dev/null +++ b/frontend/src/components/PostCard.astro @@ -0,0 +1,112 @@ +--- +import type { Post } from '../lib/types'; +import TerminalButton from './ui/TerminalButton.astro'; +import CodeBlock from './CodeBlock.astro'; +import { resolveFileRef, getPostTypeColor } from '../lib/utils'; + +interface Props { + post: Post; + selectedTag?: string; + highlightTerm?: string; +} + +const { post, selectedTag = '', highlightTerm = '' } = Astro.props; + +const typeColor = getPostTypeColor(post.type); + +const escapeHtml = (value: string) => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +const highlightText = (value: string, query: string) => { + const escapedValue = escapeHtml(value || ''); + const normalizedQuery = query.trim(); + if (!normalizedQuery) { + return escapedValue; + } + + const escapedQuery = normalizedQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return escapedValue.replace( + new RegExp(`(${escapedQuery})`, 'ig'), + '$1' + ); +}; + +const normalizedSelectedTag = selectedTag.trim().toLowerCase(); +--- + +
+ +
+ +
+
+
+ +

+

+

+ {post.date} | 阅读时间: {post.readTime} +

+
+ + #{post.category} + +
+ +

+ + {post.code && ( +

+ +
+ )} + + {post.images && post.images.length > 0 && ( +
= 3 ? 'grid-cols-2 md:grid-cols-3' : + 'grid-cols-1' + ]}> + {post.images.map((img, index) => ( +
+ {`${post.title} +
+ ))} +
+ )} + + +
+ {post.tags?.map(tag => ( + + + + + ))} +
+
diff --git a/frontend/src/components/ReadingProgress.astro b/frontend/src/components/ReadingProgress.astro new file mode 100644 index 0000000..964be88 --- /dev/null +++ b/frontend/src/components/ReadingProgress.astro @@ -0,0 +1,45 @@ +--- +// Reading Progress Bar Component +--- + +
+ + diff --git a/frontend/src/components/RelatedPosts.astro b/frontend/src/components/RelatedPosts.astro new file mode 100644 index 0000000..72d8797 --- /dev/null +++ b/frontend/src/components/RelatedPosts.astro @@ -0,0 +1,98 @@ +--- +import { apiClient } from '../lib/api/client'; + +interface Props { + currentSlug: string; + currentCategory: string; + currentTags: string[]; +} + +const { currentSlug, currentCategory, currentTags } = Astro.props; + +const allPosts = await apiClient.getPosts(); + +const relatedPosts = allPosts + .filter(post => post.slug !== currentSlug) + .map(post => { + let score = 0; + + if (post.category === currentCategory) { + score += 3; + } + + const sharedTags = post.tags.filter(tag => currentTags.includes(tag)); + score += sharedTags.length * 2; + + return { ...post, score, sharedTags }; + }) + .sort((a, b) => b.score - a.score) + .slice(0, 3); +--- + +{relatedPosts.length > 0 && ( +
+
+
+
+ + + related traces + +
+ + + +
+

相关文章

+

+ 基于当前分类与标签关联出的相近内容,延续同一条阅读链路。 +

+
+
+
+ + + + {relatedPosts.length} linked + +
+ + +
+
+)} diff --git a/frontend/src/components/StatsList.astro b/frontend/src/components/StatsList.astro new file mode 100644 index 0000000..694b126 --- /dev/null +++ b/frontend/src/components/StatsList.astro @@ -0,0 +1,21 @@ +--- +import type { SystemStat } from '../lib/types'; +import InfoTile from './ui/InfoTile.astro'; + +interface Props { + stats: SystemStat[]; +} + +const { stats } = Astro.props; +--- + +
    + {stats.map(stat => ( +
  • + + {stat.label} + {stat.value} + +
  • + ))} +
diff --git a/frontend/src/components/TableOfContents.astro b/frontend/src/components/TableOfContents.astro new file mode 100644 index 0000000..cf07865 --- /dev/null +++ b/frontend/src/components/TableOfContents.astro @@ -0,0 +1,97 @@ +--- +// Table of Contents Component - Extracts headings from article content +--- + + + + diff --git a/frontend/src/components/TechStackList.astro b/frontend/src/components/TechStackList.astro new file mode 100644 index 0000000..d67f6ef --- /dev/null +++ b/frontend/src/components/TechStackList.astro @@ -0,0 +1,28 @@ +--- +import type { TechStackItem } from '../lib/types'; +import InfoTile from './ui/InfoTile.astro'; + +interface Props { + items: TechStackItem[]; +} + +const { items } = Astro.props; +--- + +
    + {items.map(item => ( +
  • + + + + + + {item.name} + {item.level && ( + {item.level} + )} + + +
  • + ))} +
diff --git a/frontend/src/components/interactive/BackToTop.svelte b/frontend/src/components/interactive/BackToTop.svelte new file mode 100644 index 0000000..fd71190 --- /dev/null +++ b/frontend/src/components/interactive/BackToTop.svelte @@ -0,0 +1,38 @@ + + +{#if showButton} +
+ +
+{/if} diff --git a/frontend/src/components/interactive/ThemeToggle.svelte b/frontend/src/components/interactive/ThemeToggle.svelte new file mode 100644 index 0000000..026449f --- /dev/null +++ b/frontend/src/components/interactive/ThemeToggle.svelte @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/components/ui/CommandPrompt.astro b/frontend/src/components/ui/CommandPrompt.astro new file mode 100644 index 0000000..9ca0fa3 --- /dev/null +++ b/frontend/src/components/ui/CommandPrompt.astro @@ -0,0 +1,146 @@ +--- +interface Props { + command: string; + path?: string; + clickable?: boolean; + href?: string; + typing?: boolean; +} + +const { command, path = '~/', clickable = false, href = '/', typing = true } = Astro.props; +const uniqueId = Math.random().toString(36).slice(2, 11); +--- + +
+ {clickable ? ( + + user@blog + : + {path} + $ + + _ + + ) : ( + <> + user@blog + : + {path} + $ + + _ + + )} +
+ + + + diff --git a/frontend/src/components/ui/FilterPill.astro b/frontend/src/components/ui/FilterPill.astro new file mode 100644 index 0000000..03ed2dd --- /dev/null +++ b/frontend/src/components/ui/FilterPill.astro @@ -0,0 +1,33 @@ +--- +interface Props { + href?: string; + active?: boolean; + tone?: 'blue' | 'amber' | 'teal' | 'violet' | 'neutral'; + class?: string; +} + +const { + href, + active = false, + tone = 'neutral', + class: className = '', + ...rest +} = Astro.props; + +const classes = [ + 'ui-filter-pill', + `ui-filter-pill--${tone}`, + active && 'is-active', + className, +].filter(Boolean).join(' '); +--- + +{href ? ( + + + +) : ( + +)} diff --git a/frontend/src/components/ui/InfoTile.astro b/frontend/src/components/ui/InfoTile.astro new file mode 100644 index 0000000..3111b69 --- /dev/null +++ b/frontend/src/components/ui/InfoTile.astro @@ -0,0 +1,35 @@ +--- +interface Props { + href?: string; + tone?: 'blue' | 'amber' | 'teal' | 'violet' | 'neutral'; + layout?: 'row' | 'grid' | 'stack'; + class?: string; + target?: string; + rel?: string; +} + +const { + href, + tone = 'neutral', + layout = 'grid', + class: className = '', + ...rest +} = Astro.props; + +const classes = [ + 'ui-info-tile', + `ui-info-tile--${tone}`, + `ui-info-tile--${layout}`, + className, +].filter(Boolean).join(' '); +--- + +{href ? ( + + + +) : ( +
+ +
+)} diff --git a/frontend/src/components/ui/TerminalButton.astro b/frontend/src/components/ui/TerminalButton.astro new file mode 100644 index 0000000..54b39bd --- /dev/null +++ b/frontend/src/components/ui/TerminalButton.astro @@ -0,0 +1,44 @@ +--- +interface Props { + variant?: 'primary' | 'secondary' | 'neutral'; + size?: 'xs' | 'sm' | 'md' | 'lg'; + href?: string; + class?: string; + onclick?: string; +} + +const { + variant = 'neutral', + size = 'md', + href, + onclick, + class: className = '' +} = Astro.props; + +const baseStyles = 'inline-flex items-center gap-1.5 rounded-lg font-mono transition-all duration-300'; + +const variantStyles = { + primary: 'border border-[var(--primary)] bg-[var(--primary-light)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-[var(--terminal-bg)]', + secondary: 'border border-[var(--secondary)] bg-[var(--secondary-light)] text-[var(--secondary)] hover:bg-[var(--secondary)] hover:text-[var(--terminal-bg)]', + neutral: 'border border-[var(--border-color)] bg-[var(--header-bg)] text-[var(--text)] hover:border-[var(--primary)] hover:text-[var(--primary)]' +}; + +const sizeStyles = { + xs: 'px-2 py-1 text-xs', + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-3 text-base' +}; + +const classes = `${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`; +--- + +{href ? ( + + + +) : ( + +)} diff --git a/frontend/src/components/ui/TerminalWindow.astro b/frontend/src/components/ui/TerminalWindow.astro new file mode 100644 index 0000000..ff77540 --- /dev/null +++ b/frontend/src/components/ui/TerminalWindow.astro @@ -0,0 +1,34 @@ +--- +interface Props { + title?: string; + showControls?: boolean; + class?: string; +} + +const { + title = '~/blog', + showControls = true, + class: className = '' +} = Astro.props; +--- + +
+ +
+
+ {showControls && ( +
+ + + +
+ )} + {title} +
+
+ + +
+ +
+
diff --git a/frontend/src/components/ui/ViewMoreLink.astro b/frontend/src/components/ui/ViewMoreLink.astro new file mode 100644 index 0000000..633da7d --- /dev/null +++ b/frontend/src/components/ui/ViewMoreLink.astro @@ -0,0 +1,16 @@ +--- +interface Props { + href: string; + text: string; +} + +const { href, text } = Astro.props; +--- + + + {text} + + diff --git a/frontend/src/layouts/BaseLayout.astro b/frontend/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..ed1c69d --- /dev/null +++ b/frontend/src/layouts/BaseLayout.astro @@ -0,0 +1,220 @@ +--- +import '../styles/global.css'; +import Header from '../components/Header.astro'; +import Footer from '../components/Footer.astro'; +import BackToTop from '../components/interactive/BackToTop.svelte'; +import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client'; + +interface Props { + title?: string; + description?: string; +} + +const props = Astro.props; + +let siteSettings = DEFAULT_SITE_SETTINGS; + +try { + siteSettings = await api.getSiteSettings(); +} catch (error) { + console.error('Failed to load site settings:', error); +} + +const title = props.title || siteSettings.siteTitle; +const description = props.description || siteSettings.siteDescription; +--- + + + + + + + + + {title} + + + + + + + + + + + +
+
+
+
+ +
+ +
+ +
+ +
+ +
+ + + + diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..7fce2a2 --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,404 @@ +import type { + Category as UiCategory, + FriendLink as UiFriendLink, + Post as UiPost, + SiteSettings, + Tag as UiTag, +} from '../types'; + +export const API_BASE_URL = 'http://localhost:5150/api'; + +export interface ApiPost { + id: number; + title: string; + slug: string; + description: string; + content: string; + category: string; + tags: string[]; + post_type: 'article' | 'tweet'; + image: string | null; + pinned: boolean; + created_at: string; + updated_at: string; +} + +export interface Comment { + id: number; + post_id: string | null; + post_slug: string | null; + author: string | null; + email: string | null; + avatar: string | null; + content: string | null; + reply_to: string | null; + approved: boolean | null; + created_at: string; + updated_at: string; +} + +export interface CreateCommentInput { + postSlug: string; + nickname: string; + email?: string; + content: string; + replyTo?: string | null; +} + +export interface ApiTag { + id: number; + name: string; + slug: string; + created_at: string; + updated_at: string; +} + +export interface ApiCategory { + id: number; + name: string; + slug: string; + count: number; +} + +export interface ApiFriendLink { + id: number; + site_name: string; + site_url: string; + avatar_url: string | null; + description: string | null; + category: string | null; + status: 'pending' | 'approved' | 'rejected'; + created_at: string; + updated_at: string; +} + +export interface CreateFriendLinkInput { + siteName: string; + siteUrl: string; + avatarUrl?: string; + description: string; + category?: string; +} + +export interface ApiSiteSettings { + id: number; + site_name: string | null; + site_short_name: string | null; + site_url: string | null; + site_title: string | null; + site_description: string | null; + hero_title: string | null; + hero_subtitle: string | null; + owner_name: string | null; + owner_title: string | null; + owner_bio: string | null; + owner_avatar_url: string | null; + social_github: string | null; + social_twitter: string | null; + social_email: string | null; + location: string | null; + tech_stack: string[] | null; +} + +export interface ApiSearchResult { + id: number; + title: string | null; + slug: string; + description: string | null; + content: string | null; + category: string | null; + tags: string[] | null; + post_type: 'article' | 'tweet' | null; + image: string | null; + pinned: boolean | null; + created_at: string; + updated_at: string; + rank: number; +} + +export interface Review { + id: number; + title: string; + review_type: 'game' | 'anime' | 'music' | 'book' | 'movie'; + rating: number; + review_date: string; + status: 'completed' | 'in-progress' | 'dropped'; + description: string; + tags: string; + cover: string; + created_at: string; + updated_at: string; +} + +export type AppFriendLink = UiFriendLink & { + status: ApiFriendLink['status']; +}; + +export const DEFAULT_SITE_SETTINGS: SiteSettings = { + id: '1', + siteName: 'InitCool', + siteShortName: 'Termi', + siteUrl: 'https://termi.dev', + siteTitle: 'InitCool - 终端风格的内容平台', + siteDescription: '一个基于终端美学的个人内容站,记录代码、设计和生活。', + heroTitle: '欢迎来到我的极客终端博客', + heroSubtitle: '这里记录技术、代码和生活点滴', + ownerName: 'InitCool', + ownerTitle: '前端开发者 / 技术博主', + ownerBio: '一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。', + location: 'Hong Kong', + social: { + github: 'https://github.com', + twitter: 'https://twitter.com', + email: 'mailto:hello@termi.dev', + }, + techStack: ['Astro', 'Svelte', 'Tailwind CSS', 'TypeScript'], +}; + +const formatPostDate = (dateString: string) => dateString.slice(0, 10); + +const estimateReadTime = (content: string | null | undefined) => { + const text = content?.trim() || ''; + const minutes = Math.max(1, Math.ceil(text.length / 300)); + return `${minutes} 分钟`; +}; + +const normalizePost = (post: ApiPost): UiPost => ({ + id: String(post.id), + slug: post.slug, + title: post.title, + description: post.description, + content: post.content, + date: formatPostDate(post.created_at), + readTime: estimateReadTime(post.content || post.description), + type: post.post_type, + tags: post.tags ?? [], + category: post.category, + image: post.image ?? undefined, + pinned: post.pinned, +}); + +const normalizeTag = (tag: ApiTag): UiTag => ({ + id: String(tag.id), + name: tag.name, + slug: tag.slug, +}); + +const normalizeCategory = (category: ApiCategory): UiCategory => ({ + id: String(category.id), + name: category.name, + slug: category.slug, + count: category.count, +}); + +const normalizeAvatarUrl = (value: string | null | undefined) => { + if (!value) { + return undefined; + } + + try { + const host = new URL(value).hostname.toLowerCase(); + const isReservedExampleHost = + host === 'example.com' || + host === 'example.org' || + host === 'example.net' || + host.endsWith('.example.com') || + host.endsWith('.example.org') || + host.endsWith('.example.net'); + + return isReservedExampleHost ? undefined : value; + } catch { + return undefined; + } +}; + +const normalizeTagToken = (value: string) => value.trim().toLowerCase(); + +const normalizeFriendLink = (friendLink: ApiFriendLink): AppFriendLink => ({ + id: String(friendLink.id), + name: friendLink.site_name, + url: friendLink.site_url, + avatar: normalizeAvatarUrl(friendLink.avatar_url), + description: friendLink.description ?? undefined, + category: friendLink.category ?? undefined, + status: friendLink.status, +}); + +const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({ + id: String(settings.id), + siteName: settings.site_name || DEFAULT_SITE_SETTINGS.siteName, + siteShortName: settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName, + siteUrl: settings.site_url || DEFAULT_SITE_SETTINGS.siteUrl, + siteTitle: settings.site_title || DEFAULT_SITE_SETTINGS.siteTitle, + siteDescription: settings.site_description || DEFAULT_SITE_SETTINGS.siteDescription, + heroTitle: settings.hero_title || DEFAULT_SITE_SETTINGS.heroTitle, + heroSubtitle: settings.hero_subtitle || DEFAULT_SITE_SETTINGS.heroSubtitle, + ownerName: settings.owner_name || DEFAULT_SITE_SETTINGS.ownerName, + ownerTitle: settings.owner_title || DEFAULT_SITE_SETTINGS.ownerTitle, + ownerBio: settings.owner_bio || DEFAULT_SITE_SETTINGS.ownerBio, + ownerAvatarUrl: settings.owner_avatar_url ?? undefined, + location: settings.location || DEFAULT_SITE_SETTINGS.location, + social: { + github: settings.social_github || DEFAULT_SITE_SETTINGS.social.github, + twitter: settings.social_twitter || DEFAULT_SITE_SETTINGS.social.twitter, + email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email, + }, + techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack, +}); + +class ApiClient { + private baseUrl: string; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl; + } + + private async fetch(path: string, options?: RequestInit): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new Error(errorText || `API error: ${response.status} ${response.statusText}`); + } + + if (response.status === 204) { + return undefined as T; + } + + return response.json() as Promise; + } + + async getRawPosts(): Promise { + return this.fetch('/posts'); + } + + async getPosts(): Promise { + const posts = await this.getRawPosts(); + return posts.map(normalizePost); + } + + async getPost(id: number): Promise { + const post = await this.fetch(`/posts/${id}`); + return normalizePost(post); + } + + async getPostBySlug(slug: string): Promise { + const posts = await this.getPosts(); + return posts.find(post => post.slug === slug) || null; + } + + async getComments(postSlug: string, options?: { approved?: boolean }): Promise { + const params = new URLSearchParams({ post_slug: postSlug }); + if (options?.approved !== undefined) { + params.set('approved', String(options.approved)); + } + return this.fetch(`/comments?${params.toString()}`); + } + + async createComment(comment: CreateCommentInput): Promise { + return this.fetch('/comments', { + method: 'POST', + body: JSON.stringify({ + postSlug: comment.postSlug, + nickname: comment.nickname, + email: comment.email, + content: comment.content, + replyTo: comment.replyTo, + }), + }); + } + + async getReviews(): Promise { + return this.fetch('/reviews'); + } + + async getReview(id: number): Promise { + return this.fetch(`/reviews/${id}`); + } + + async getRawFriendLinks(): Promise { + return this.fetch('/friend_links'); + } + + async getFriendLinks(): Promise { + const friendLinks = await this.getRawFriendLinks(); + return friendLinks.map(normalizeFriendLink); + } + + async createFriendLink(friendLink: CreateFriendLinkInput): Promise { + return this.fetch('/friend_links', { + method: 'POST', + body: JSON.stringify(friendLink), + }); + } + + async getRawTags(): Promise { + return this.fetch('/tags'); + } + + async getTags(): Promise { + const tags = await this.getRawTags(); + return tags.map(normalizeTag); + } + + async getRawSiteSettings(): Promise { + return this.fetch('/site_settings'); + } + + async getSiteSettings(): Promise { + const settings = await this.getRawSiteSettings(); + return normalizeSiteSettings(settings); + } + + async getCategories(): Promise { + const categories = await this.fetch('/categories'); + return categories.map(normalizeCategory); + } + + async getPostsByCategory(category: string): Promise { + const posts = await this.getPosts(); + return posts.filter(post => post.category?.toLowerCase() === category.toLowerCase()); + } + + async getPostsByTag(tag: string): Promise { + const posts = await this.getPosts(); + const normalizedTag = normalizeTagToken(tag); + return posts.filter(post => + post.tags?.some(item => normalizeTagToken(item) === normalizedTag) + ); + } + + async searchPosts(query: string, limit = 20): Promise { + const params = new URLSearchParams({ + q: query, + limit: String(limit), + }); + const results = await this.fetch(`/search?${params.toString()}`); + + return results.map(result => + normalizePost({ + id: result.id, + title: result.title || 'Untitled', + slug: result.slug, + description: result.description || '', + content: result.content || '', + category: result.category || '', + tags: result.tags ?? [], + post_type: result.post_type || 'article', + image: result.image, + pinned: result.pinned ?? false, + created_at: result.created_at, + updated_at: result.updated_at, + }) + ); + } +} + +export const api = new ApiClient(API_BASE_URL); +export const apiClient = api; diff --git a/frontend/src/lib/config/terminal.ts b/frontend/src/lib/config/terminal.ts new file mode 100644 index 0000000..eb06567 --- /dev/null +++ b/frontend/src/lib/config/terminal.ts @@ -0,0 +1,167 @@ +export interface TerminalConfig { + defaultCategory: string; + welcomeMessage: string; + prompt: { + prefix: string; + separator: string; + path: string; + suffix: string; + mobile: string; + }; + asciiArt: string; + title: string; + welcome: { + title: string; + subtitle: string; + }; + navLinks: Array<{ + icon: string; + text: string; + href: string; + }>; + categories: { + [key: string]: { + title: string; + description: string; + items: Array<{ + command: string; + description: string; + shortDesc?: string; + url?: string; + }>; + }; + }; + postTypes: { + article: { color: string; label: string }; + tweet: { color: string; label: string }; + }; + pinnedPost?: { + title: string; + description: string; + date: string; + readTime: string; + type: 'article' | 'tweet'; + tags: string[]; + link: string; + }; + socialLinks: { + github: string; + twitter: string; + email: string; + }; + tools: Array<{ + icon: string; + href: string; + title: string; + }>; + search?: { + placeholders: { + default: string; + small: string; + medium: string; + }; + promptText: string; + emptyResultText: string; + }; + terminal?: { + defaultWindowTitle: string; + controls: { + colors: { + close: string; + minimize: string; + expand: string; + }; + }; + animation?: { + glowDuration: string; + }; + }; + branding?: { + name: string; + shortName?: string; + }; +} + +export const terminalConfig: TerminalConfig = { + defaultCategory: 'blog', + welcomeMessage: '欢迎来到我的博客!', + prompt: { + prefix: 'user@blog', + separator: ':', + path: '~/', + suffix: '$', + mobile: '~$' + }, + asciiArt: ` +I N N I TTTTT CCCC OOO OOO L +I NN N I T C O O O O L +I N N N I T C O O O O L +I N NN I T C O O O O L +I N N I T CCCC OOO OOO LLLLL`, + title: '~/blog', + welcome: { + title: '欢迎来到我的极客终端博客', + subtitle: '这里记录技术、代码和生活点滴' + }, + navLinks: [ + { icon: 'fa-file-code', text: '文章', href: '/articles' }, + { icon: 'fa-folder', text: '分类', href: '/categories' }, + { icon: 'fa-tags', text: '标签', href: '/tags' }, + { icon: 'fa-stream', text: '时间轴', href: '/timeline' }, + { icon: 'fa-star', text: '评价', href: '/reviews' }, + { icon: 'fa-link', text: '友链', href: '/friends' }, + { icon: 'fa-user-secret', text: '关于', href: '/about' } + ], + categories: { + blog: { + title: '博客', + description: '我的个人博客文章', + items: [ + { + command: 'help', + description: '显示帮助信息', + shortDesc: '显示帮助信息' + } + ] + } + }, + postTypes: { + article: { color: '#00ff9d', label: '博客文章' }, + tweet: { color: '#00b8ff', label: '推文' } + }, + socialLinks: { + github: '', + twitter: '', + email: '' + }, + tools: [ + { icon: 'fa-sitemap', href: '/sitemap.xml', title: '站点地图' }, + { icon: 'fa-rss', href: '/rss.xml', title: 'RSS订阅' } + ], + search: { + placeholders: { + default: "'关键词' articles/*.md", + small: "搜索...", + medium: "搜索文章..." + }, + promptText: "grep -i", + emptyResultText: "输入关键词搜索文章" + }, + terminal: { + defaultWindowTitle: 'user@terminal: ~/blog', + controls: { + colors: { + close: '#ff5f56', + minimize: '#ffbd2e', + expand: '#27c93f' + } + }, + animation: { + glowDuration: '4s' + } + }, + branding: { + name: 'InitCool', + shortName: 'Termi' + } +}; diff --git a/frontend/src/lib/styles/theme.css b/frontend/src/lib/styles/theme.css new file mode 100644 index 0000000..68d3b66 --- /dev/null +++ b/frontend/src/lib/styles/theme.css @@ -0,0 +1,435 @@ +/* 现代化主题系统 - 使用 CSS 变量 + 媒体查询 */ + +:root { + /* 声明支持的颜色方案 */ + color-scheme: light dark; + + /* 全局变量 */ + --transition-duration: 0.3s; + + /* 亮色模式默认 */ + --primary: #4285f4; + --primary-rgb: 66 133 244; + --primary-light: #4285f433; + --primary-dark: #3367d6; + + --secondary: #ea580c; + --secondary-rgb: 234 88 12; + --secondary-light: #ea580c33; + + --bg: #f3f4f6; + --bg-rgb: 243 244 246; + --bg-secondary: #e5e7eb; + --bg-tertiary: #d1d5db; + --terminal-bg: #ffffff; + + --text: #1a1a1a; + --text-rgb: 26 26 26; + --text-secondary: #9ca3af; + --text-tertiary: #cbd5e1; + --terminal-text: #1a1a1a; + --title-color: #1a1a1a; + --button-text: #1a1a1a; + + --border-color: #e5e7eb; + --border-color-rgb: 229 231 235; + --terminal-border: #e5e7eb; + + --tag-bg: #f3f4f6; + --tag-text: #1a1a1a; + + --header-bg: #f9fafb; + --code-bg: #f3f4f6; + + /* 终端窗口控制按钮 */ + --btn-close: #ff5f56; + --btn-minimize: #ffbd2e; + --btn-expand: #27c93f; + + /* 状态颜色 */ + --success: #10b981; + --success-rgb: 16 185 129; + --success-light: #d1fae5; + --success-dark: #065f46; + + --warning: #f59e0b; + --warning-rgb: 245 158 11; + --warning-light: #fef3c7; + --warning-dark: #92400e; + + --danger: #ef4444; + --danger-rgb: 239 68 68; + --danger-light: #fee2e2; + --danger-dark: #991b1b; + + --gray-light: #f3f4f6; + --gray-dark: #374151; + + /* 全局样式变量 */ + --border-radius: 0.5rem; + --box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; +} + +/* 暗色模式 */ +@media (prefers-color-scheme: dark) { + :root { + --primary: #00ff9d; + --primary-rgb: 0 255 157; + --primary-light: #00ff9d33; + --primary-dark: #00b8ff; + + --secondary: #00b8ff; + --secondary-rgb: 0 184 255; + --secondary-light: #00b8ff33; + + --bg: #0a0e17; + --bg-rgb: 10 14 23; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --terminal-bg: #0d1117; + + --text: #e6e6e6; + --text-rgb: 230 230 230; + --text-secondary: #d1d5db; + --text-tertiary: #6b7280; + --terminal-text: #e6e6e6; + --title-color: #ffffff; + --button-text: #e6e6e6; + + --border-color: rgba(255, 255, 255, 0.1); + --border-color-rgb: 255 255 255; + --terminal-border: rgba(255, 255, 255, 0.1); + + --tag-bg: #161b22; + --tag-text: #e6e6e6; + + --header-bg: #161b22; + --code-bg: #161b22; + + --success-light: #064e3b; + --success-dark: #d1fae5; + + --warning-light: #78350f; + --warning-dark: #fef3c7; + + --danger-light: #7f1d1d; + --danger-dark: #fee2e2; + + --gray-light: #1f2937; + --gray-dark: #e5e7eb; + } +} + +/* 手动暗色模式覆盖 - 使用 html.dark */ +html.dark { + --primary: #00ff9d; + --primary-rgb: 0 255 157; + --primary-light: #00ff9d33; + --primary-dark: #00b8ff; + + --secondary: #00b8ff; + --secondary-rgb: 0 184 255; + --secondary-light: #00b8ff33; + + --bg: #0a0e17; + --bg-rgb: 10 14 23; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --terminal-bg: #0d1117; + + --text: #e6e6e6; + --text-rgb: 230 230 230; + --text-secondary: #d1d5db; + --text-tertiary: #6b7280; + --terminal-text: #e6e6e6; + --title-color: #ffffff; + --button-text: #e6e6e6; + + --border-color: rgba(255, 255, 255, 0.1); + --border-color-rgb: 255 255 255; + --terminal-border: rgba(255, 255, 255, 0.1); + + --tag-bg: #161b22; + --tag-text: #e6e6e6; + + --header-bg: #161b22; + --code-bg: #161b22; + + --success-light: #064e3b; + --success-dark: #d1fae5; + + --warning-light: #78350f; + --warning-dark: #fef3c7; + + --danger-light: #7f1d1d; + --danger-dark: #fee2e2; + + --gray-light: #1f2937; + --gray-dark: #e5e7eb; +} + +/* 优化的平滑过渡 - 只应用到需要的元素 */ +body, +button, +input, +select, +textarea, +a { + transition: background-color var(--transition-duration) ease, + color var(--transition-duration) ease, + border-color var(--transition-duration) ease; +} + +/* 主题切换按钮动画 */ +.theme-toggle { + transition: all 0.3s ease; +} + +.theme-toggle i { + transition: transform 0.3s ease; +} + +.theme-toggle:hover i { + transform: rotate(30deg); +} + +.terminal-input { + width: 100%; + background-color: color-mix(in oklab, var(--terminal-bg) 84%, var(--bg-secondary)) !important; + background-image: + linear-gradient(to bottom, rgba(var(--primary-rgb), 0.04), rgba(var(--primary-rgb), 0.0)); + border: 1px solid rgba(var(--primary-rgb), 0.42) !important; + outline: 1px solid rgba(var(--primary-rgb), 0.22); + outline-offset: -1px; + color: var(--text) !important; + font-family: var(--font-mono); + padding: 0.55rem 0.7rem; + border-radius: var(--border-radius); + font-size: 0.9rem; + line-height: 1.25; + letter-spacing: 0.01em; + caret-color: var(--primary); + appearance: none; + display: block; + box-shadow: + inset 0 0 0 1px rgba(var(--primary-rgb), 0.16), + inset 0 10px 20px rgba(0, 0, 0, 0.10), + inset 0 0 26px rgba(var(--primary-rgb), 0.08), + 0 0 0 1px rgba(0, 0, 0, 0.14); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +@media (prefers-color-scheme: dark) { + .terminal-input { + background-color: color-mix(in oklab, var(--bg-tertiary) 88%, var(--terminal-bg)) !important; + background-image: + linear-gradient(to bottom, rgba(var(--primary-rgb), 0.06), rgba(var(--primary-rgb), 0.0)), + repeating-linear-gradient( + to bottom, + rgba(var(--primary-rgb), 0.06) 0px, + rgba(var(--primary-rgb), 0.06) 1px, + transparent 1px, + transparent 6px + ); + border: 1px solid rgba(var(--primary-rgb), 0.48) !important; + outline: 1px solid rgba(var(--primary-rgb), 0.22); + box-shadow: + inset 0 0 0 1px rgba(var(--primary-rgb), 0.16), + inset 0 10px 20px rgba(0, 0, 0, 0.32), + inset 0 0 26px rgba(var(--primary-rgb), 0.12), + 0 0 0 1px rgba(0, 0, 0, 0.26); + } +} + +html.dark .terminal-input { + background-color: color-mix(in oklab, var(--bg-tertiary) 88%, var(--terminal-bg)) !important; + background-image: + linear-gradient(to bottom, rgba(var(--primary-rgb), 0.06), rgba(var(--primary-rgb), 0.0)), + repeating-linear-gradient( + to bottom, + rgba(var(--primary-rgb), 0.06) 0px, + rgba(var(--primary-rgb), 0.06) 1px, + transparent 1px, + transparent 6px + ); + border: 1px solid rgba(var(--primary-rgb), 0.48) !important; + outline: 1px solid rgba(var(--primary-rgb), 0.22); + box-shadow: + inset 0 0 0 1px rgba(var(--primary-rgb), 0.16), + inset 0 10px 20px rgba(0, 0, 0, 0.32), + inset 0 0 26px rgba(var(--primary-rgb), 0.12), + 0 0 0 1px rgba(0, 0, 0, 0.26); +} + +.terminal-input::placeholder { + color: rgba(0, 0, 0, 0.45); + letter-spacing: 0.02em; +} + +@media (prefers-color-scheme: dark) { + .terminal-input::placeholder { + color: rgba(255, 255, 255, 0.45); + } +} + +html.dark .terminal-input::placeholder { + color: rgba(255, 255, 255, 0.45); +} + +.terminal-input:focus { + outline: none; + border-color: var(--primary) !important; + box-shadow: + inset 0 0 0 1px rgba(var(--primary-rgb), 0.22), + inset 0 0 26px rgba(var(--primary-rgb), 0.14), + 0 0 0 2px rgba(var(--primary-rgb), 0.22), + 0 0 28px rgba(var(--primary-rgb), 0.24); +} + +.terminal-input.textarea { + resize: vertical; + min-height: 4.5rem; +} + +/* Terminal Window Glow Effects */ +.terminal-window { + background-color: var(--terminal-bg); + border-radius: 8px !important; + border: 1px solid var(--primary) !important; + box-shadow: + 0 0 8px rgba(var(--primary-rgb), 0.4), + 0 0 20px rgba(var(--primary-rgb), 0.2), + 0 10px 30px rgba(var(--primary-rgb), 0.2) !important; + overflow: hidden; + position: relative; + animation: terminal-glow 4s ease-in-out infinite alternate !important; +} + +@keyframes terminal-glow { + 0% { + box-shadow: + 0 0 8px rgba(var(--primary-rgb), 0.4), + 0 0 20px rgba(var(--primary-rgb), 0.2), + 0 10px 30px rgba(var(--primary-rgb), 0.2); + } + 100% { + box-shadow: + 0 0 12px rgba(var(--primary-rgb), 0.5), + 0 0 25px rgba(var(--primary-rgb), 0.3), + 0 10px 30px rgba(var(--primary-rgb), 0.2); + } +} + +/* Dark mode glow adjustments */ +@media (prefers-color-scheme: dark) { + .terminal-window { + animation: terminal-glow-dark 4s ease-in-out infinite alternate !important; + } + + @keyframes terminal-glow-dark { + 0% { + box-shadow: + 0 0 8px rgba(var(--primary-rgb), 0.3), + 0 0 20px rgba(var(--primary-rgb), 0.15), + 0 10px 40px rgba(var(--primary-rgb), 0.1); + border-color: rgba(var(--primary-rgb), 0.5) !important; + } + 100% { + box-shadow: + 0 0 15px rgba(var(--primary-rgb), 0.5), + 0 0 30px rgba(var(--primary-rgb), 0.25), + 0 10px 50px rgba(var(--primary-rgb), 0.15); + border-color: rgba(var(--primary-rgb), 0.8) !important; + } + } +} + +html.dark .terminal-window { + animation: terminal-glow-dark 4s ease-in-out infinite alternate !important; +} + +/* Terminal Header */ +.terminal-header { + border-bottom: 1px solid var(--primary) !important; + box-shadow: 0 1px 5px rgba(var(--primary-rgb), 0.2) !important; +} + +/* Glow Text Effect */ +.glow-text { + text-shadow: 0 0 10px rgba(var(--primary-rgb), 0.5); +} + +/* Post Card Hover Effects */ +.post-card { + position: relative; + transition: all 0.3s ease; +} + +.post-card::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + border-radius: 4px 0 0 4px; + opacity: 0; + transition: opacity 0.3s ease; + background-color: var(--post-border-color, var(--primary)); +} + +.post-card:hover { + transform: translateX(8px); +} + +.post-card:hover::before { + opacity: 1; +} + +/* Cursor Blink Animation */ +.cursor { + display: inline-block; + width: 10px; + height: 18px; + background-color: var(--primary); + animation: blink 1s infinite; + vertical-align: middle; + margin-left: 2px; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* ASCII Art Styling */ +.ascii-art { + font-family: 'Courier New', monospace; + font-size: 0.85rem; + line-height: 1.2; + color: var(--primary); + white-space: pre; + margin: 0; + padding: 0; + text-align: left; + letter-spacing: 0; + overflow-x: auto; +} + +@media (max-width: 640px) { + .ascii-art { + font-size: 0.625rem; + } +} + +/* Link Hover Effects */ +a { + transition: all 0.3s ease; +} + +/* Button Hover Glow */ +button:hover, +a:hover { + text-shadow: 0 0 8px rgba(var(--primary-rgb), 0.3); +} diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts new file mode 100644 index 0000000..8ca3145 --- /dev/null +++ b/frontend/src/lib/types/index.ts @@ -0,0 +1,88 @@ +export interface Post { + id: string; + slug: string; + title: string; + description: string; + content?: string; + date: string; + readTime: string; + type: 'article' | 'tweet'; + tags: string[]; + category: string; + image?: string; + images?: string[]; + code?: string; + language?: string; + pinned?: boolean; +} + +export interface Category { + id: string; + name: string; + slug: string; + description?: string; + icon?: string; + count?: number; +} + +export interface Tag { + id: string; + name: string; + slug: string; + count?: number; +} + +export interface FriendLink { + id: string; + name: string; + url: string; + avatar?: string; + description?: string; + category?: string; +} + +export interface SiteSettings { + id: string; + siteName: string; + siteShortName: string; + siteUrl: string; + siteTitle: string; + siteDescription: string; + heroTitle: string; + heroSubtitle: string; + ownerName: string; + ownerTitle: string; + ownerBio: string; + ownerAvatarUrl?: string; + location?: string; + social: { + github?: string; + twitter?: string; + email?: string; + }; + techStack: string[]; +} + +export interface SiteConfig { + name: string; + description: string; + author: string; + url: string; + social: { + github?: string; + twitter?: string; + email?: string; + }; +} + +export interface SystemStat { + label: string; + value: string; + icon?: string; +} + +export interface TechStackItem { + name: string; + icon?: string; + level?: string; +} diff --git a/frontend/src/lib/utils/data.ts b/frontend/src/lib/utils/data.ts new file mode 100644 index 0000000..caf3729 --- /dev/null +++ b/frontend/src/lib/utils/data.ts @@ -0,0 +1,194 @@ +import type { Post, Category, Tag, FriendLink } from '../types'; + +// Mock data for static site generation +export const mockPosts: Post[] = [ + { + id: '1', + slug: 'welcome-to-termi', + title: '欢迎来到 Termi 终端博客', + description: '这是一个基于终端风格的现代博客平台,结合了极客美学与极致性能。', + date: '2024-03-20', + readTime: '3 分钟', + type: 'article', + tags: ['astro', 'svelte', 'tailwind'], + category: '技术', + pinned: true, + image: 'https://picsum.photos/1200/600?random=1' + }, + { + id: '2', + slug: 'astro-ssg-guide', + title: 'Astro 静态站点生成指南', + description: '学习如何使用 Astro 构建高性能的静态网站,掌握群岛架构的核心概念。', + date: '2024-03-18', + readTime: '5 分钟', + type: 'article', + tags: ['astro', 'ssg', 'performance'], + category: '前端' + }, + { + id: '3', + slug: 'tailwind-v4-features', + title: 'Tailwind CSS v4 新特性解析', + description: '探索 Tailwind CSS v4 带来的全新特性,包括改进的性能和更简洁的配置。', + date: '2024-03-15', + readTime: '4 分钟', + type: 'article', + tags: ['tailwind', 'css', 'design'], + category: '前端' + }, + { + id: '4', + slug: 'daily-thought-1', + title: '关于代码与咖啡的思考', + description: '写代码就像冲咖啡,需要耐心和恰到好处的温度。今天尝试了几款新豆子,每一杯都有不同的风味。', + date: '2024-03-14', + readTime: '1 分钟', + type: 'tweet', + tags: ['life', 'coding'], + category: '随笔', + images: [ + 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=400&h=400&fit=crop', + 'https://images.unsplash.com/photo-1509042239860-f550ce710b93?w=400&h=400&fit=crop', + 'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?w=400&h=400&fit=crop' + ] + }, + { + id: '5', + slug: 'svelte-5-runes', + title: 'Svelte 5 Runes 完全指南', + description: '深入了解 Svelte 5 的 Runes 系统,掌握下一代响应式编程范式。', + date: '2024-03-10', + readTime: '8 分钟', + type: 'article', + tags: ['svelte', 'javascript', 'frontend'], + category: '前端' + } +]; + +export const mockCategories: Category[] = [ + { id: '1', name: '技术', slug: 'tech', icon: 'fa-code', count: 3 }, + { id: '2', name: '前端', slug: 'frontend', icon: 'fa-laptop-code', count: 3 }, + { id: '3', name: '随笔', slug: 'essay', icon: 'fa-pen', count: 1 }, + { id: '4', name: '生活', slug: 'life', icon: 'fa-coffee', count: 1 } +]; + +export const mockTags: Tag[] = [ + { id: '1', name: 'astro', slug: 'astro', count: 1 }, + { id: '2', name: 'svelte', slug: 'svelte', count: 2 }, + { id: '3', name: 'tailwind', slug: 'tailwind', count: 2 }, + { id: '4', name: 'frontend', slug: 'frontend', count: 2 }, + { id: '5', name: 'ssg', slug: 'ssg', count: 1 }, + { id: '6', name: 'css', slug: 'css', count: 1 }, + { id: '7', name: 'javascript', slug: 'javascript', count: 1 }, + { id: '8', name: 'life', slug: 'life', count: 1 }, + { id: '9', name: 'coding', slug: 'coding', count: 1 } +]; + +export const mockFriendLinks: FriendLink[] = [ + { + id: '1', + name: 'Astro 官网', + url: 'https://astro.build', + avatar: 'https://astro.build/favicon.svg', + description: '极速内容驱动的网站框架', + category: '技术博客' + }, + { + id: '2', + name: 'Svelte 官网', + url: 'https://svelte.dev', + avatar: 'https://svelte.dev/favicon.png', + description: '控制论增强的 Web 应用', + category: '技术博客' + }, + { + id: '3', + name: 'Tailwind CSS', + url: 'https://tailwindcss.com', + avatar: 'https://tailwindcss.com/favicons/favicon-32x32.png', + description: '实用优先的 CSS 框架', + category: '技术博客' + } +]; + +export const mockSiteConfig = { + name: 'Termi', + description: '终端风格的内容平台', + author: 'InitCool', + url: 'https://termi.dev', + social: { + github: 'https://github.com', + twitter: 'https://twitter.com', + email: 'mailto:hello@termi.dev' + } +}; + +export const mockSystemStats = [ + { label: 'Last Update', value: '2024-03-20' }, + { label: 'Posts', value: '12' }, + { label: 'Visitors', value: '1.2k' } +]; + +export const mockTechStack = [ + { name: 'Astro' }, + { name: 'Svelte' }, + { name: 'Tailwind CSS' }, + { name: 'TypeScript' }, + { name: 'Vercel' } +]; + +export const mockHomeAboutIntro = '一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。'; + +// Helper functions +export function getPinnedPost(): Post | null { + return mockPosts.find(p => p.pinned) || null; +} + +export function getRecentPosts(limit: number = 5): Post[] { + return mockPosts.slice(0, limit); +} + +export function getAllPosts(): Post[] { + return mockPosts; +} + +export function getPostBySlug(slug: string): Post | undefined { + return mockPosts.find(p => p.slug === slug); +} + +export function getPostsByTag(tag: string): Post[] { + return mockPosts.filter(p => p.tags.includes(tag)); +} + +export function getPostsByCategory(category: string): Post[] { + return mockPosts.filter(p => p.category === category); +} + +export function getAllCategories(): Category[] { + return mockCategories; +} + +export function getAllTags(): Tag[] { + return mockTags; +} + +export function getAllFriendLinks(): FriendLink[] { + return mockFriendLinks; +} + +export function getSiteConfig() { + return mockSiteConfig; +} + +export function getSystemStats() { + return mockSystemStats; +} + +export function getTechStack() { + return mockTechStack; +} + +export function getHomeAboutIntro() { + return mockHomeAboutIntro; +} diff --git a/frontend/src/lib/utils/index.ts b/frontend/src/lib/utils/index.ts new file mode 100644 index 0000000..c444606 --- /dev/null +++ b/frontend/src/lib/utils/index.ts @@ -0,0 +1,94 @@ +/** + * Format a date string to a more readable format + */ +export function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); +} + +/** + * Truncate text to a specified length with ellipsis + */ +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.slice(0, maxLength) + '...'; +} + +/** + * Normalize FontAwesome icon class + */ +export function normalizeFaIcon(icon: unknown): string { + const raw = typeof icon === 'string' ? icon.trim() : ''; + if (!raw) return 'fa-folder'; + + if (raw.includes('fa-')) { + const parts = raw.split(/\s+/); + for (let i = parts.length - 1; i >= 0; i -= 1) { + const t = parts[i]; + if (t?.startsWith('fa-')) return t; + } + return 'fa-folder'; + } + + return 'fa-folder'; +} + +/** + * Resolve file reference (for images) + */ +export function resolveFileRef(ref: string): string { + if (ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('/')) { + return ref; + } + return `/uploads/${ref}`; +} + +/** + * Generate a unique ID + */ +export function generateId(): string { + return Math.random().toString(36).substring(2, 9); +} + +/** + * Debounce function + */ +export function debounce unknown>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: ReturnType | null = null; + return (...args: Parameters) => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +} + +/** + * Filter posts by type and tag + */ +export function filterPosts( + posts: Array<{ + type: string; + tags: string[]; + }>, + postType: string, + tag: string +): typeof posts { + return posts.filter(post => { + if (postType !== 'all' && post.type !== postType) return false; + if (tag && !post.tags.includes(tag)) return false; + return true; + }); +} + +/** + * Get color for post type + */ +export function getPostTypeColor(type: string): string { + return type === 'article' ? 'var(--primary)' : 'var(--secondary)'; +} diff --git a/frontend/src/pages/404.astro b/frontend/src/pages/404.astro new file mode 100644 index 0000000..9e0f28a --- /dev/null +++ b/frontend/src/pages/404.astro @@ -0,0 +1,156 @@ +--- +import Layout from '../layouts/BaseLayout.astro'; +import TerminalWindow from '../components/ui/TerminalWindow.astro'; +import { terminalConfig } from '../lib/config/terminal'; +import { api } from '../lib/api/client'; + +const fullPrompt = `${terminalConfig.prompt.prefix}${terminalConfig.prompt.separator}${terminalConfig.prompt.path}${terminalConfig.prompt.suffix}`; + +let popularPosts: Awaited> = []; + +try { + popularPosts = (await api.getPosts()).slice(0, 4); +} catch (error) { + console.error('Failed to load fallback posts:', error); +} +--- + + +
+ +
+
+
+
+ + + missing route + +
+
EEEEE  RRRR   RRRR    OOO   RRRR
+E      R   R  R   R  O   O  R   R
+EEE    RRRR   RRRR   O   O  RRRR
+E      R R    R R    O   O  R R
+EEEEE  R  R   R  R    OOO   R  R
+
+

404 - 页面未找到

+

+ 当前请求没有命中任何内容节点。下面保留了终端化错误信息、可执行操作,以及可回退到的真实文章入口。 +

+
+
+
+ +
+
+ {fullPrompt} + find ./ -name "*.html" +
+
+
terminal_error.log
+
error: requested route not found
+
path:
+
time:
+
+
+
+ +
+
+
+ + + +
+

可执行操作

+

像命令面板一样,优先给出直接可走的恢复路径。

+
+
+ + + +
+

+ 也可以直接使用顶部的搜索输入框,在 `articles/*.md` 里重新 grep 一次相关关键字。 +

+
+
+ +
+
+ + + +
+

推荐入口

+

使用真实文章数据,避免 404 页面再把人带进不存在的地址。

+
+
+ +
+ {popularPosts.length > 0 ? ( + popularPosts.map(post => ( + +
+

{post.title}

+

{post.description}

+
+ +
+ )) + ) : ( +
+

暂时无法读取文章列表。

+
+ )} +
+
+
+
+
+
+
+ + +
+ + diff --git a/frontend/src/pages/about/index.astro b/frontend/src/pages/about/index.astro new file mode 100644 index 0000000..9998fd3 --- /dev/null +++ b/frontend/src/pages/about/index.astro @@ -0,0 +1,172 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import TerminalWindow from '../../components/ui/TerminalWindow.astro'; +import CommandPrompt from '../../components/ui/CommandPrompt.astro'; +import StatsList from '../../components/StatsList.astro'; +import TechStackList from '../../components/TechStackList.astro'; +import InfoTile from '../../components/ui/InfoTile.astro'; +import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client'; + +let siteSettings = DEFAULT_SITE_SETTINGS; +let systemStats = []; +let techStack = []; + +try { + const [settings, posts, tags, friendLinks] = await Promise.all([ + api.getSiteSettings(), + api.getPosts(), + api.getTags(), + api.getFriendLinks(), + ]); + + siteSettings = settings; + techStack = siteSettings.techStack.map(name => ({ name })); + systemStats = [ + { label: 'Posts', value: String(posts.length) }, + { label: 'Tags', value: String(tags.length) }, + { label: 'Friends', value: String(friendLinks.filter(friend => friend.status === 'approved').length) }, + { label: 'Location', value: siteSettings.location || 'Unknown' }, + ]; +} catch (error) { + console.error('Failed to load about data:', error); + techStack = siteSettings.techStack.map(name => ({ name })); + systemStats = [ + { label: 'Posts', value: '0' }, + { label: 'Tags', value: '0' }, + { label: 'Friends', value: '0' }, + { label: 'Location', value: siteSettings.location || 'Unknown' }, + ]; +} + +const ownerInitial = siteSettings.ownerName.charAt(0) || 'T'; +--- + + +
+ +
+ +
+
identity profile
+
+ + + +
+

关于我

+

+ 这里汇总站点主人、技术栈、系统状态和联系方式,现在整体语言会更接近首页与评价页。 +

+
+
+
+ + + {siteSettings.location || 'Unknown'} + + + + {techStack.length} 项技术栈 + +
+
+
+ +
+
+
+ +
+
+
+ {siteSettings.ownerAvatarUrl ? ( + {siteSettings.ownerName} + ) : ( +
+ {ownerInitial} +
+ )} +
+

{siteSettings.ownerName}

+

{siteSettings.ownerTitle}

+
+
+

{siteSettings.ownerBio}

+
+
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+ +
+
+ {siteSettings.social.github && ( + + + GitHub + + )} + {siteSettings.social.twitter && ( + + + Twitter + + )} + {siteSettings.social.email && ( + + + Email + + )} + + + Website + +
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/admin.astro b/frontend/src/pages/admin.astro new file mode 100644 index 0000000..0bfb7c0 --- /dev/null +++ b/frontend/src/pages/admin.astro @@ -0,0 +1,331 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import TerminalWindow from '../components/ui/TerminalWindow.astro'; +import CommandPrompt from '../components/ui/CommandPrompt.astro'; +import { api } from '../lib/api/client'; + +let posts: Awaited> = []; +let tags: Awaited> = []; +let friendLinks: Awaited> = []; +let reviews: Awaited> = []; +let categories: Awaited> = []; +let error: string | null = null; + +try { + posts = await api.getRawPosts(); + tags = await api.getRawTags(); + friendLinks = await api.getRawFriendLinks(); + reviews = await api.getReviews(); + categories = await api.getCategories(); +} catch (e) { + error = e instanceof Error ? e.message : 'API unavailable'; +} + +const pendingFriendLinks = friendLinks.filter(friendLink => friendLink.status === 'pending'); +const pinnedPosts = posts.filter(post => post.pinned); +const recentPosts = [...posts].sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, 6); +const activeCategories = [...categories] + .sort((a, b) => (b.count ?? 0) - (a.count ?? 0)) + .slice(0, 6); +const tagSamples = tags.slice(0, 12); +const recentReviews = [...reviews].sort((a, b) => b.review_date.localeCompare(a.review_date)).slice(0, 4); +--- + + +
+ +
+ + +
+
+
+ + + operator console + +
+

前台控制台

+

+ 这个页面现在作为终端风格的运营工作台使用,用来查看内容库存、友链审核、标签分类和近期评价, + 保持和整站统一的 geek / terminal 气质。 +

+
+
+ +
+ + + posts {posts.length} + + + + tags {tags.length} + + + + links {friendLinks.length} + + + + reviews {reviews.length} + +
+
+
+
+ + {error && ( +
+
+ API 连接失败: {error} +
+
+ )} + +
+
+
+
posts
+
{posts.length}
+

内容总量

+
+
+
pinned
+
{pinnedPosts.length}
+

置顶文章

+
+
+
pending links
+
{pendingFriendLinks.length}
+

待处理友链

+
+
+
categories
+
{categories.length}
+

分类数量

+
+
+
+ +
+
+
+
+
+ + + content queue + +
+ + + +
+

最近文章

+

按创建时间排列,方便快速检查最新导入和置顶状态。

+
+
+
+ + + + open feed + +
+ +
+ {recentPosts.map(post => ( +
+
+
+ + {post.title} + {post.pinned && ( + + + pinned + + )} +
+
+ {post.slug} + / + {post.category} +
+
+ +
+ + + {post.created_at.slice(0, 10)} + + + + view + +
+
+ ))} +
+
+ +
+
+
+ + + +
+

友链队列

+

突出待审核项目,让控制台页面更像真实的处理台。

+
+
+ +
+ {pendingFriendLinks.length > 0 ? ( + pendingFriendLinks.map(link => ( +
+
+

{link.site_name}

+

{link.site_url}

+
+ + + pending + +
+ )) + ) : ( +
+

当前没有待审核友链。

+
+ )} +
+
+ +
+
+ + + +
+

近期评价

+

评价模块也纳入控制台视野,保持内容维度统一。

+
+
+ +
+ {recentReviews.map(review => ( +
+
+

{review.title}

+

{review.review_type} / {review.status}

+
+ + + {review.rating}/5 + +
+ ))} +
+
+
+
+
+ +
+
+
+
+ + + +
+

分类与标签

+

展示当前内容分布,方便观察导入后的归类情况。

+
+
+ +
+
+ {activeCategories.map(category => ( + + + {category.name} + {category.count} + + ))} +
+ +
+ {tagSamples.map(tag => ( + + + {tag.name} + + ))} +
+
+
+ +
+
+ + + +
+

快捷入口

+

把常用页面作为控制台指令入口来呈现,弱化“默认后台模板感”。

+
+
+ + + +
+
api endpoint
+

http://localhost:5150/api

+
+
+
+
+
+
+
diff --git a/frontend/src/pages/articles/[slug].astro b/frontend/src/pages/articles/[slug].astro new file mode 100644 index 0000000..215391a --- /dev/null +++ b/frontend/src/pages/articles/[slug].astro @@ -0,0 +1,165 @@ +--- +import { createMarkdownProcessor } from '@astrojs/markdown-remark'; +import BaseLayout from '../../layouts/BaseLayout.astro'; +import TerminalWindow from '../../components/ui/TerminalWindow.astro'; +import CommandPrompt from '../../components/ui/CommandPrompt.astro'; +import TableOfContents from '../../components/TableOfContents.astro'; +import RelatedPosts from '../../components/RelatedPosts.astro'; +import ReadingProgress from '../../components/ReadingProgress.astro'; +import BackToTop from '../../components/BackToTop.astro'; +import Lightbox from '../../components/Lightbox.astro'; +import CodeCopyButton from '../../components/CodeCopyButton.astro'; +import Comments from '../../components/Comments.astro'; +import { apiClient } from '../../lib/api/client'; +import { resolveFileRef, getPostTypeColor } from '../../lib/utils'; + +export const prerender = false; + +const { slug } = Astro.params; + +let post = null; + +try { + post = await apiClient.getPostBySlug(slug ?? ''); +} catch (error) { + console.error('API Error:', error); +} + +if (!post) { + return new Response(null, { status: 404 }); +} + +const typeColor = getPostTypeColor(post.type || 'article'); +const contentText = post.content || post.description || ''; +const wordCount = contentText.length; +const readTimeMinutes = Math.ceil(wordCount / 300); +const articleMarkdown = contentText.replace(/^#\s+.+\r?\n+/, ''); + +const markdownProcessor = await createMarkdownProcessor(); +const renderedContent = await markdownProcessor.render(articleMarkdown); +--- + + + + + + + +
+
+
+ +
+
+
+
+ + + 返回文章索引 + + +
+ + + document session + + + + {post.type === 'article' ? 'article' : 'tweet'} + + + + {post.category} + +
+
+ +
+ + + {post.date} + + + + {readTimeMinutes} min + + + + {wordCount} chars + +
+
+ +
+

{post.title}

+

{post.description}

+
+ + {post.tags?.length > 0 && ( +
+ {post.tags.map(tag => ( + + + {tag} + + ))} +
+ )} +
+
+ +
+ + +
+ {post.image && ( +
+ {post.title} +
+ )} + +
+
+
+ +
+
+ + file://content/posts/{post.slug}.md + +
+ + + back to index + + +
+
+
+
+ + + +
+ +
+
+ + +
+
+
diff --git a/frontend/src/pages/articles/index.astro b/frontend/src/pages/articles/index.astro new file mode 100644 index 0000000..d321c41 --- /dev/null +++ b/frontend/src/pages/articles/index.astro @@ -0,0 +1,258 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import TerminalWindow from '../../components/ui/TerminalWindow.astro'; +import CommandPrompt from '../../components/ui/CommandPrompt.astro'; +import FilterPill from '../../components/ui/FilterPill.astro'; +import PostCard from '../../components/PostCard.astro'; +import { terminalConfig } from '../../lib/config/terminal'; +import { api } from '../../lib/api/client'; +import type { Category, Post, Tag } from '../../lib/types'; + +export const prerender = false; + +let allPosts: Post[] = []; +let allTags: Tag[] = []; +let allCategories: Category[] = []; +const url = new URL(Astro.request.url); +const selectedSearch = url.searchParams.get('search') || ''; + +try { + allPosts = selectedSearch ? await api.searchPosts(selectedSearch) : await api.getPosts(); + allCategories = await api.getCategories(); + const rawTags = await api.getTags(); + const seenTagIds = new Set(); + allTags = rawTags.filter(tag => { + const key = `${tag.slug}:${tag.name}`.toLowerCase(); + if (seenTagIds.has(key)) return false; + seenTagIds.add(key); + return true; + }); +} catch (error) { + console.error('API Error:', error); +} + +const selectedType = url.searchParams.get('type') || 'all'; +const selectedTag = url.searchParams.get('tag') || ''; +const selectedCategory = url.searchParams.get('category') || ''; +const currentPage = parseInt(url.searchParams.get('page') || '1'); +const postsPerPage = 10; +const normalizedSelectedTag = selectedTag.trim().toLowerCase(); +const isMatchingTag = (value: string) => value.trim().toLowerCase() === normalizedSelectedTag; +const isSelectedTag = (tag: Tag) => + tag.name.trim().toLowerCase() === normalizedSelectedTag || tag.slug.trim().toLowerCase() === normalizedSelectedTag; + +const filteredPosts = allPosts.filter(post => { + if (selectedType !== 'all' && post.type !== selectedType) return false; + if (selectedTag && !post.tags?.some(isMatchingTag)) return false; + if (selectedCategory && post.category?.toLowerCase() !== selectedCategory.toLowerCase()) return false; + return true; +}); + +const totalPosts = filteredPosts.length; +const totalPages = Math.ceil(totalPosts / postsPerPage); +const startIndex = (currentPage - 1) * postsPerPage; +const paginatedPosts = filteredPosts.slice(startIndex, startIndex + postsPerPage); + +const postTypeFilters = [ + { id: 'all', name: '全部', icon: 'fa-stream' }, + { id: 'article', name: terminalConfig.postTypes.article.label, icon: 'fa-file-alt' }, + { id: 'tweet', name: terminalConfig.postTypes.tweet.label, icon: 'fa-comment-dots' } +]; + +const typePromptCommand = `./filter --type ${selectedType || 'all'}`; +const categoryPromptCommand = `./filter --category ${selectedCategory ? `"${selectedCategory}"` : 'all'}`; +const tagPromptCommand = `./filter --tag ${selectedTag ? `"${selectedTag}"` : 'all'}`; + +const buildArticlesUrl = ({ + type = selectedType, + search = selectedSearch, + tag = selectedTag, + category = selectedCategory, + page, +}: { + type?: string; + search?: string; + tag?: string; + category?: string; + page?: number; +}) => { + const params = new URLSearchParams(); + + if (type && type !== 'all') params.set('type', type); + if (search) params.set('search', search); + if (tag) params.set('tag', tag); + if (category) params.set('category', category); + if (page && page > 1) params.set('page', String(page)); + + const queryString = params.toString(); + return queryString ? `/articles?${queryString}` : '/articles'; +}; +--- + + +
+ +
+ + +
+

文章索引

+

+ 按类型、分类和标签筛选内容。这里保留更轻的 prompt 标题结构,下方筛选拆成独立区域。 +

+
+ + + 共 {filteredPosts.length} 篇 + + {selectedSearch && ( + + + grep: {selectedSearch} + + )} + {selectedCategory && ( + + + {selectedCategory} + + )} + {selectedTag && ( + + + {selectedTag} + + )} +
+
+
+ +
+
+ +
+ {postTypeFilters.map(filter => ( + + + {filter.name} + + ))} +
+
+ + {allCategories.length > 0 && ( +
+ +
+ + + 全部分类 + + {allCategories.map(category => ( + + + {category.name} + {category.count} + + ))} +
+
+ )} + + {allTags.length > 0 && ( +
+ +
+ + + 全部标签 + + {allTags.map(tag => ( + + + {tag.name} + + ))} +
+
+ )} +
+ +
+ {paginatedPosts.length > 0 ? ( +
+ {paginatedPosts.map(post => ( + + ))} +
+ ) : ( +
+
+ + + +

没有匹配结果

+

+ 当前筛选条件下没有找到文章。可以清空标签或关键字,重新浏览整个内容目录。 +

+ + + reset filters + +
+
+ )} +
+ + {totalPages > 1 && ( +
+
+ + page {currentPage}/{totalPages} · {totalPosts} results + +
+ {currentPage > 1 && ( + + + prev + + )} + {currentPage < totalPages && ( + + next + + + )} +
+
+
+ )} +
+
+
diff --git a/frontend/src/pages/categories/index.astro b/frontend/src/pages/categories/index.astro new file mode 100644 index 0000000..27ea3b5 --- /dev/null +++ b/frontend/src/pages/categories/index.astro @@ -0,0 +1,94 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import TerminalWindow from '../../components/ui/TerminalWindow.astro'; +import CommandPrompt from '../../components/ui/CommandPrompt.astro'; +import { api } from '../../lib/api/client'; + +let categories: Awaited> = []; + +try { + categories = await api.getCategories(); +} catch (error) { + console.error('Failed to fetch categories:', error); +} +--- + + +
+ +
+ +
+
content taxonomy
+
+ + + +
+

文章分类

+

+ 按内容主题浏览文章,分类页现在和其他列表页保持同一套终端面板语言。 +

+
+
+
+ + + {categories.length} 个分类 + + + + 快速跳转分类文章 + +
+
+
+ + +
+
+
diff --git a/frontend/src/pages/friends/index.astro b/frontend/src/pages/friends/index.astro new file mode 100644 index 0000000..0dbc436 --- /dev/null +++ b/frontend/src/pages/friends/index.astro @@ -0,0 +1,127 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import TerminalWindow from '../../components/ui/TerminalWindow.astro'; +import CommandPrompt from '../../components/ui/CommandPrompt.astro'; +import FriendLinkCard from '../../components/FriendLinkCard.astro'; +import FriendLinkApplication from '../../components/FriendLinkApplication.astro'; +import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client'; +import type { AppFriendLink } from '../../lib/api/client'; + +let siteSettings = DEFAULT_SITE_SETTINGS; +let friendLinks: AppFriendLink[] = []; +let error: string | null = null; + +try { + [siteSettings, friendLinks] = await Promise.all([ + api.getSiteSettings(), + api.getFriendLinks(), + ]); + friendLinks = friendLinks.filter(friend => friend.status === 'approved'); +} catch (e) { + error = e instanceof Error ? e.message : 'Failed to fetch friend links'; + console.error('Failed to fetch friend links:', e); +} + +const categories = [...new Set(friendLinks.map(friend => friend.category || '其他'))]; +const groupedLinks = categories.map(category => ({ + category, + links: friendLinks.filter(friend => (friend.category || '其他') === category) +})); +--- + + +
+ +
+ +
+
network map
+
+ + + +
+

友情链接

+

+ 这里聚合已经通过审核的站点,也提供统一风格的申请面板,避免列表区和表单区像两个页面。 +

+
+
+
+ + + {friendLinks.length} 个友链 + + + + 仅展示已通过审核 + +
+
+
+ + {error && ( +
+
+ {error} +
+
+ )} + +
+ {groupedLinks.map(group => ( +
+
+
+ + + +
+

{group.category}

+

friend collection

+
+
+ ({group.links.length}) +
+
+ {group.links.map(friend => ( + + ))} +
+
+ ))} +
+ +
+ +
+ +
+
+ +
+ +
+
exchange rules
+

友链交换

+

+ 欢迎交换友情链接!请确保您的网站满足以下条件: +

+
    +
  • 原创内容为主
  • +
  • 网站稳定运行
  • +
  • 无不良内容
  • +
+
+

+ 本站信息:
+ 名称: {siteSettings.siteName}
+ 描述: {siteSettings.siteDescription}
+ 链接: {siteSettings.siteUrl} +

+
+
+
+
+
+
diff --git a/frontend/src/pages/index.astro b/frontend/src/pages/index.astro new file mode 100644 index 0000000..3feff54 --- /dev/null +++ b/frontend/src/pages/index.astro @@ -0,0 +1,208 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import TerminalWindow from '../components/ui/TerminalWindow.astro'; +import CommandPrompt from '../components/ui/CommandPrompt.astro'; +import FilterPill from '../components/ui/FilterPill.astro'; +import PostCard from '../components/PostCard.astro'; +import FriendLinkCard from '../components/FriendLinkCard.astro'; +import ViewMoreLink from '../components/ui/ViewMoreLink.astro'; +import StatsList from '../components/StatsList.astro'; +import TechStackList from '../components/TechStackList.astro'; +import { terminalConfig } from '../lib/config/terminal'; +import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client'; +import type { AppFriendLink } from '../lib/api/client'; +import type { Post } from '../lib/types'; + +export const prerender = false; + +const url = new URL(Astro.request.url); +const selectedType = url.searchParams.get('type') || 'all'; + +let siteSettings = DEFAULT_SITE_SETTINGS; +let allPosts: Post[] = []; +let recentPosts: Post[] = []; +let pinnedPost: Post | null = null; +let tags: string[] = []; +let friendLinks: AppFriendLink[] = []; +let categories: Awaited> = []; +let apiError: string | null = null; + +try { + siteSettings = await api.getSiteSettings(); + allPosts = await api.getPosts(); + const filteredPosts = selectedType === 'all' + ? allPosts + : allPosts.filter(post => post.type === selectedType); + + recentPosts = filteredPosts.slice(0, 5); + pinnedPost = filteredPosts.find(post => post.pinned) || null; + tags = (await api.getTags()).map(tag => tag.name); + friendLinks = (await api.getFriendLinks()).filter(friend => friend.status === 'approved'); + categories = await api.getCategories(); +} catch (error) { + apiError = error instanceof Error ? error.message : 'API unavailable'; + console.error('API Error:', error); +} + +const systemStats = [ + { label: '文章', value: String(allPosts.length) }, + { label: '标签', value: String(tags.length) }, + { label: '分类', value: String(categories.length) }, + { label: '友链', value: String(friendLinks.length) }, +]; + +const techStack = siteSettings.techStack.map(name => ({ name })); + +const postTypeFilters = [ + { id: 'all', name: '全部', icon: 'fa-stream' }, + { id: 'article', name: terminalConfig.postTypes.article.label, icon: 'fa-file-alt' }, + { id: 'tweet', name: terminalConfig.postTypes.tweet.label, icon: 'fa-comment-dots' } +]; +--- + + +
+ +
+
{terminalConfig.asciiArt}
+
+ +
+ +
+

{siteSettings.heroTitle}

+

{siteSettings.heroSubtitle}

+
+
+ +
+ +
+ {terminalConfig.navLinks.map(link => ( + + + {link.text} + + ))} +
+
+ + {apiError && ( +
+
+ {apiError} +
+
+ )} + +
+ +
+ {categories.map(category => ( + + + {category.name} + + ))} +
+
+ +
+ +
+ {postTypeFilters.map(filter => ( + + + {filter.name} + + ))} +
+
+ + {pinnedPost && ( +
+ +
+
+
+ 置顶 + +

{pinnedPost.title}

+
+

{pinnedPost.date} | 阅读时间: {pinnedPost.readTime}

+

{pinnedPost.description}

+
+
+
+ )} + +
+ +
+
+ {recentPosts.map(post => ( + + ))} +
+
+ +
+
+
+ +
+ +
+ {tags.map(tag => ( + + + {tag} + + ))} +
+
+ +
+ +
+ +
+ {friendLinks.map(friend => ( + + ))} +
+
+ +
+
+ +
+ +
+ +
+
+
+

关于我

+

{siteSettings.ownerBio}

+ +

技术栈

+ +
+ +
+

系统状态

+ +
+
+
+
+
+
+
diff --git a/frontend/src/pages/reviews/index.astro b/frontend/src/pages/reviews/index.astro new file mode 100644 index 0000000..51f52c4 --- /dev/null +++ b/frontend/src/pages/reviews/index.astro @@ -0,0 +1,299 @@ +--- +import Layout from '../../layouts/BaseLayout.astro'; +import TerminalWindow from '../../components/ui/TerminalWindow.astro'; +import CommandPrompt from '../../components/ui/CommandPrompt.astro'; +import FilterPill from '../../components/ui/FilterPill.astro'; +import InfoTile from '../../components/ui/InfoTile.astro'; +import { apiClient } from '../../lib/api/client'; +import type { Review } from '../../lib/api/client'; + +type ParsedReview = Omit & { + tags: string[]; +}; + +// Fetch reviews from backend API +let reviews: Awaited> = []; +const url = new URL(Astro.request.url); +const selectedType = url.searchParams.get('type') || 'all'; +try { + reviews = await apiClient.getReviews(); +} catch (error) { + console.error('Failed to fetch reviews:', error); +} + +// Parse tags from JSON string +const parsedReviews: ParsedReview[] = reviews.map(r => ({ + ...r, + tags: r.tags ? JSON.parse(r.tags) as string[] : [] +})); + +const filteredReviews = selectedType === 'all' + ? parsedReviews + : parsedReviews.filter(review => review.review_type === selectedType); + +const stats = { + total: filteredReviews.length, + avgRating: filteredReviews.length > 0 + ? (filteredReviews.reduce((sum, r) => sum + (r.rating || 0), 0) / filteredReviews.length).toFixed(1) + : '0', + completed: filteredReviews.filter(r => r.status === 'completed').length, + inProgress: filteredReviews.filter(r => r.status === 'in-progress').length +}; + +const filters = [ + { id: 'all', name: '全部', icon: 'fa-list', count: parsedReviews.length }, + { id: 'game', name: '游戏', icon: 'fa-gamepad', count: parsedReviews.filter(r => r.review_type === 'game').length }, + { id: 'anime', name: '动画', icon: 'fa-tv', count: parsedReviews.filter(r => r.review_type === 'anime').length }, + { id: 'music', name: '音乐', icon: 'fa-music', count: parsedReviews.filter(r => r.review_type === 'music').length }, + { id: 'book', name: '书籍', icon: 'fa-book', count: parsedReviews.filter(r => r.review_type === 'book').length }, + { id: 'movie', name: '影视', icon: 'fa-film', count: parsedReviews.filter(r => r.review_type === 'movie').length } +]; + +const typeLabels: Record = { + game: '游戏', + anime: '动画', + music: '音乐', + book: '书籍', + movie: '影视' +}; + +const typeColors: Record = { + game: '#4285f4', + anime: '#ff6b6b', + music: '#00ff9d', + book: '#f59e0b', + movie: '#9b59b6' +}; +--- + + +
+ + +
+ + +
+ +
+
review ledger
+
+ + + +
+

评价

+

+ 记录游戏、音乐、动画、书籍的体验与感悟 + {selectedType !== 'all' && ` · 当前筛选: ${typeLabels[selectedType] || selectedType}`} +

+
+
+
+
+ +
+ +
+ +
{stats.total}
+
总评价
+
+ +
{stats.avgRating}
+
平均评分
+
+ +
{stats.completed}
+
已完成
+
+ +
{stats.inProgress}
+
进行中
+
+
+
+ +
+ +
+ {filters.map(filter => ( + + + {filter.name} + [{filter.count}] + + ))} +
+
+ +
+ +
+ {filteredReviews.length === 0 ? ( +
+
+ +
+
+ {parsedReviews.length === 0 ? '暂无评价数据,请检查后端 API 连接' : '当前筛选下暂无评价'} +
+
+ ) : ( + <> + {filteredReviews.map(review => ( +
+
+
+ {review.cover} +
+
+
+ + {typeLabels[review.review_type] || review.review_type} + +

{review.title}

+
+

{review.description}

+
+ + {review.review_date} + + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + {review.rating || 0}/5 + +
+
+ {review.tags.map((tag: string) => ( + + #{tag} + + ))} +
+
+
+
+ ))} + + + )} +
+
+ + + +
+
+
+ +
diff --git a/frontend/src/pages/tags/index.astro b/frontend/src/pages/tags/index.astro new file mode 100644 index 0000000..fc4a417 --- /dev/null +++ b/frontend/src/pages/tags/index.astro @@ -0,0 +1,157 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import TerminalWindow from '../../components/ui/TerminalWindow.astro'; +import CommandPrompt from '../../components/ui/CommandPrompt.astro'; +import FilterPill from '../../components/ui/FilterPill.astro'; +import { apiClient } from '../../lib/api/client'; +import type { Post, Tag } from '../../lib/types'; + +export const prerender = false; + +// Fetch tags from backend +let tags: Tag[] = []; +let filteredPosts: Post[] = []; + +try { + tags = await apiClient.getTags(); +} catch (error) { + console.error('Failed to fetch tags:', error); +} + +// Get URL params +const url = new URL(Astro.request.url); +const selectedTag = url.searchParams.get('tag') || ''; +const selectedTagToken = selectedTag.trim().toLowerCase(); +const isSelectedTag = (tag: Tag) => + tag.name.trim().toLowerCase() === selectedTagToken || tag.slug.trim().toLowerCase() === selectedTagToken; + +// Fetch posts by tag from API if tag is selected +if (selectedTag) { + try { + filteredPosts = await apiClient.getPostsByTag(selectedTag); + } catch (error) { + console.error('Failed to fetch posts by tag:', error); + } +} +--- + + +
+ +
+ +
+
tag index
+
+ + + +
+

标签云

+

+ 用更轻量的关键词维度检索文章。选中标签时,下方结果区会延续同一套终端卡片风格。 +

+
+
+
+ + + {tags.length} 个标签 + + {selectedTag && ( + + + 当前: #{selectedTag} + + )} +
+
+
+ + {selectedTag && ( +
+ +
+
+

+ 标签 #{selectedTag} + 找到 {filteredPosts.length} 篇文章 +

+ + + 清除筛选 + +
+
+
+ )} + +
+
+
+ browse tags +
+
+ {tags.length === 0 ? ( +
+ 暂无标签数据 +
+ ) : ( + tags.map(tag => ( + + + {tag.name} + + )) + )} +
+
+
+ + {selectedTag && filteredPosts.length > 0 && ( + + )} + + {selectedTag && filteredPosts.length === 0 && ( +
+
+
+ +

没有找到该标签的文章

+
+
+
+ )} +
+
+
diff --git a/frontend/src/pages/timeline/index.astro b/frontend/src/pages/timeline/index.astro new file mode 100644 index 0000000..7385459 --- /dev/null +++ b/frontend/src/pages/timeline/index.astro @@ -0,0 +1,172 @@ +--- +import Layout from '../../layouts/BaseLayout.astro'; +import TerminalWindow from '../../components/ui/TerminalWindow.astro'; +import CommandPrompt from '../../components/ui/CommandPrompt.astro'; +import FilterPill from '../../components/ui/FilterPill.astro'; +import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client'; +import type { Post } from '../../lib/types'; + +let siteSettings = DEFAULT_SITE_SETTINGS; +let posts: Post[] = []; + +try { + [siteSettings, posts] = await Promise.all([ + api.getSiteSettings(), + api.getPosts(), + ]); +} catch (error) { + console.error('Failed to load timeline:', error); +} + +const groupedByYear = posts.reduce((acc: Record, post) => { + const year = new Date(post.date).getFullYear(); + if (!acc[year]) acc[year] = []; + acc[year].push(post); + return acc; +}, {}); + +const years = Object.keys(groupedByYear).sort((a, b) => Number(b) - Number(a)); +const latestYear = years[0] || 'all'; +--- + + +
+ +
+
+ +
+
activity trace
+
+ + + +
+

时间轴

+

+ 共 {posts.length} 篇内容 · 记录 {siteSettings.ownerName} 的技术成长与生活点滴 +

+
+
+
+
+ +
+ +
+ + 全部 + + {years.map(year => ( + + + {year} + [{groupedByYear[Number(year)].length}] + + ))} +
+
+ + + + +
+
+
+
+ + diff --git a/frontend/src/styles/animations.css b/frontend/src/styles/animations.css new file mode 100644 index 0000000..0be759b --- /dev/null +++ b/frontend/src/styles/animations.css @@ -0,0 +1,67 @@ +/* Animations */ +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +@keyframes terminal-glow { + 0% { + box-shadow: + 0 0 8px rgba(var(--primary-rgb), 0.4), + 0 0 20px rgba(var(--primary-rgb), 0.2), + 0 10px 30px rgba(var(--primary-rgb), 0.2); + } + 100% { + box-shadow: + 0 0 12px rgba(var(--primary-rgb), 0.5), + 0 0 25px rgba(var(--primary-rgb), 0.3), + 0 10px 30px rgba(var(--primary-rgb), 0.2); + } +} + +@keyframes terminal-glow-dark { + 0% { + box-shadow: + 0 0 8px rgba(var(--primary-rgb), 0.3), + 0 0 20px rgba(var(--primary-rgb), 0.15), + 0 10px 40px rgba(var(--primary-rgb), 0.1); + } + 100% { + box-shadow: + 0 0 15px rgba(var(--primary-rgb), 0.5), + 0 0 30px rgba(var(--primary-rgb), 0.25), + 0 10px 50px rgba(var(--primary-rgb), 0.15); + } +} + +/* Smooth transitions for theme changes */ +html { + transition: background-color 0.3s ease, color 0.3s ease; +} + +body, button, input, select, textarea, a { + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; +} + +/* Theme toggle animation */ +.theme-toggle { + transition: all 0.3s ease; +} + +.theme-toggle i { + transition: transform 0.3s ease; +} + +.theme-toggle:hover i { + transform: rotate(30deg); +} + +/* Fade in animation */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.fade-in { + animation: fadeIn 0.5s ease-out forwards; +} diff --git a/frontend/src/styles/colors.css b/frontend/src/styles/colors.css new file mode 100644 index 0000000..7b93424 --- /dev/null +++ b/frontend/src/styles/colors.css @@ -0,0 +1,161 @@ +/* Color variables - Light/Dark mode */ +:root { + color-scheme: light dark; + + /* Light mode (default) */ + --primary: #4285f4; + --primary-rgb: 66 133 244; + --primary-light: #4285f433; + --primary-dark: #3367d6; + + --secondary: #ea580c; + --secondary-rgb: 234 88 12; + --secondary-light: #ea580c33; + + --bg: #f3f4f6; + --bg-rgb: 243 244 246; + --bg-secondary: #e5e7eb; + --bg-tertiary: #d1d5db; + --terminal-bg: #ffffff; + + --text: #1a1a1a; + --text-rgb: 26 26 26; + --text-secondary: #6b7280; + --text-tertiary: #9ca3af; + --terminal-text: #1a1a1a; + --title-color: #1a1a1a; + --button-text: #1a1a1a; + + --border-color: #e5e7eb; + --border-color-rgb: 229 231 235; + --terminal-border: #e5e7eb; + + --tag-bg: #f3f4f6; + --tag-text: #1a1a1a; + + --header-bg: rgba(249 250 251 / 0.9); + --code-bg: #f3f4f6; + + /* Status colors */ + --success: #10b981; + --success-rgb: 16 185 129; + --success-light: #d1fae5; + --success-dark: #065f46; + + --warning: #f59e0b; + --warning-rgb: 245 158 11; + --warning-light: #fef3c7; + --warning-dark: #92400e; + + --danger: #ef4444; + --danger-rgb: 239 68 68; + --danger-light: #fee2e2; + --danger-dark: #991b1b; + + --gray-light: #f3f4f6; + --gray-dark: #374151; + + /* Terminal buttons */ + --btn-close: #ff5f56; + --btn-minimize: #ffbd2e; + --btn-expand: #27c93f; +} + +/* Dark mode via class */ +html.dark { + --primary: #00ff9d; + --primary-rgb: 0 255 157; + --primary-light: #00ff9d33; + --primary-dark: #00b8ff; + + --secondary: #00b8ff; + --secondary-rgb: 0 184 255; + --secondary-light: #00b8ff33; + + --bg: #0a0e17; + --bg-rgb: 10 14 23; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --terminal-bg: #0d1117; + + --text: #e6e6e6; + --text-rgb: 230 230 230; + --text-secondary: #d1d5db; + --text-tertiary: #6b7280; + --terminal-text: #e6e6e6; + --title-color: #ffffff; + --button-text: #e6e6e6; + + --border-color: rgba(255 255 255 / 0.1); + --border-color-rgb: 255 255 255; + --terminal-border: rgba(255 255 255 / 0.1); + + --tag-bg: #161b22; + --tag-text: #e6e6e6; + + --header-bg: rgba(22 27 34 / 0.9); + --code-bg: #161b22; + + /* Status colors - dark mode */ + --success-light: #064e3b; + --success-dark: #d1fae5; + + --warning-light: #78350f; + --warning-dark: #fef3c7; + + --danger-light: #7f1d1d; + --danger-dark: #fee2e2; + + --gray-light: #1f2937; + --gray-dark: #e5e7eb; +} + +/* System preference dark mode */ +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --primary: #00ff9d; + --primary-rgb: 0 255 157; + --primary-light: #00ff9d33; + --primary-dark: #00b8ff; + + --secondary: #00b8ff; + --secondary-rgb: 0 184 255; + --secondary-light: #00b8ff33; + + --bg: #0a0e17; + --bg-rgb: 10 14 23; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --terminal-bg: #0d1117; + + --text: #e6e6e6; + --text-rgb: 230 230 230; + --text-secondary: #d1d5db; + --text-tertiary: #6b7280; + --terminal-text: #e6e6e6; + --title-color: #ffffff; + --button-text: #e6e6e6; + + --border-color: rgba(255 255 255 / 0.1); + --border-color-rgb: 255 255 255; + --terminal-border: rgba(255 255 255 / 0.1); + + --tag-bg: #161b22; + --tag-text: #e6e6e6; + + --header-bg: rgba(22 27 34 / 0.9); + --code-bg: #161b22; + + --success-light: #064e3b; + --success-dark: #d1fae5; + + --warning-light: #78350f; + --warning-dark: #fef3c7; + + --danger-light: #7f1d1d; + --danger-dark: #fee2e2; + + --gray-light: #1f2937; + --gray-dark: #e5e7eb; + } +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css new file mode 100644 index 0000000..2a5e883 --- /dev/null +++ b/frontend/src/styles/global.css @@ -0,0 +1,703 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Base styles */ +@layer base { + :root { + --radius: 0.5rem; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + + /* Light mode colors */ + --primary: #2563eb; + --primary-rgb: 37 99 235; + --primary-light: rgba(37 99 235 / 0.14); + --primary-dark: #1d4ed8; + + --secondary: #f97316; + --secondary-rgb: 249 115 22; + --secondary-light: rgba(249 115 22 / 0.14); + + --bg: #eef3f8; + --bg-rgb: 238 243 248; + --bg-secondary: #e2e8f0; + --bg-tertiary: #cbd5e1; + --terminal-bg: #f8fbff; + + --text: #0f172a; + --text-rgb: 15 23 42; + --text-secondary: #475569; + --text-tertiary: #7c8aa0; + --terminal-text: #0f172a; + --title-color: #0f172a; + --button-text: #0f172a; + + --border-color: #d6e0ea; + --border-color-rgb: 214 224 234; + --terminal-border: #d6e0ea; + + --tag-bg: #edf3f8; + --tag-text: #0f172a; + + --header-bg: rgba(244 248 252 / 0.92); + --code-bg: #eef3f8; + + --success: #10b981; + --success-rgb: 16 185 129; + --success-light: #d1fae5; + --success-dark: #065f46; + + --warning: #f59e0b; + --warning-rgb: 245 158 11; + --warning-light: #fef3c7; + --warning-dark: #92400e; + + --danger: #ef4444; + --danger-rgb: 239 68 68; + --danger-light: #fee2e2; + --danger-dark: #991b1b; + + --gray-light: #f3f4f6; + --gray-dark: #374151; + + --btn-close: #ff5f56; + --btn-minimize: #ffbd2e; + --btn-expand: #27c93f; + } + + html { + scroll-behavior: smooth; + } + + body { + @apply bg-bg text-text antialiased; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } +} + +/* Dark mode via class */ +html.dark { + --primary: #00ff9d; + --primary-rgb: 0 255 157; + --primary-light: #00ff9d33; + --primary-dark: #00b8ff; + + --secondary: #00b8ff; + --secondary-rgb: 0 184 255; + --secondary-light: #00b8ff33; + + --bg: #0a0e17; + --bg-rgb: 10 14 23; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --terminal-bg: #0d1117; + + --text: #e6e6e6; + --text-rgb: 230 230 230; + --text-secondary: #d1d5db; + --text-tertiary: #6b7280; + --terminal-text: #e6e6e6; + --title-color: #ffffff; + --button-text: #e6e6e6; + + --border-color: rgba(255 255 255 / 0.1); + --border-color-rgb: 255 255 255; + --terminal-border: rgba(255 255 255 / 0.1); + + --tag-bg: #161b22; + --tag-text: #e6e6e6; + + --header-bg: rgba(22 27 34 / 0.9); + --code-bg: #161b22; + + --success-light: #064e3b; + --success-dark: #d1fae5; + + --warning-light: #78350f; + --warning-dark: #fef3c7; + + --danger-light: #7f1d1d; + --danger-dark: #fee2e2; + + --gray-light: #1f2937; + --gray-dark: #e5e7eb; +} + +/* System preference dark mode */ +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --primary: #00ff9d; + --primary-rgb: 0 255 157; + --primary-light: #00ff9d33; + --primary-dark: #00b8ff; + + --secondary: #00b8ff; + --secondary-rgb: 0 184 255; + --secondary-light: #00b8ff33; + + --bg: #0a0e17; + --bg-rgb: 10 14 23; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --terminal-bg: #0d1117; + + --text: #e6e6e6; + --text-rgb: 230 230 230; + --text-secondary: #d1d5db; + --text-tertiary: #6b7280; + --terminal-text: #e6e6e6; + --title-color: #ffffff; + --button-text: #e6e6e6; + + --border-color: rgba(255 255 255 / 0.1); + --border-color-rgb: 255 255 255; + --terminal-border: rgba(255 255 255 / 0.1); + + --tag-bg: #161b22; + --tag-text: #e6e6e6; + + --header-bg: rgba(22 27 34 / 0.9); + --code-bg: #161b22; + + --success-light: #064e3b; + --success-dark: #d1fae5; + + --warning-light: #78350f; + --warning-dark: #fef3c7; + + --danger-light: #7f1d1d; + --danger-dark: #fee2e2; + + --gray-light: #1f2937; + --gray-dark: #e5e7eb; + } +} + +/* Components */ +@layer components { + /* Terminal window glow */ + .terminal-window { + @apply relative overflow-hidden rounded-2xl; + border: 1px solid color-mix(in oklab, var(--primary) 16%, var(--terminal-border)); + box-shadow: + 0 18px 40px rgba(var(--text-rgb), 0.06), + 0 0 0 1px rgba(255, 255, 255, 0.45); + } + + /* Post card hover effect */ + .post-card { + @apply relative transition-all duration-300; + } + + .post-card::before { + content: ''; + @apply absolute left-0 top-0 bottom-0 w-1 rounded-l opacity-0 transition-opacity duration-300; + background-color: var(--post-border-color, var(--primary)); + } + + .post-card:hover { + @apply translate-x-2; + } + + .post-card:hover::before { + @apply opacity-100; + } + + /* ASCII art */ + .ascii-art { + @apply font-mono text-xs sm:text-sm text-primary whitespace-pre; + } + + /* Cursor blink */ + .cursor { + @apply inline-block w-2.5 h-4 align-middle ml-0.5; + background-color: var(--primary); + animation: blink 1s infinite; + } + + /* Theme toggle */ + .theme-toggle { + @apply transition-all duration-300; + } + + .theme-toggle i { + @apply transition-transform duration-300; + } + + .theme-toggle:hover i { + @apply rotate-[30deg]; + } + + .terminal-panel { + @apply rounded-xl border p-5; + border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color)); + background: + linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 98%, transparent), color-mix(in oklab, var(--header-bg) 92%, transparent)), + linear-gradient(90deg, rgba(var(--primary-rgb), 0.035), transparent 22%, rgba(var(--primary-rgb), 0.02) 78%, transparent); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.5), + 0 10px 26px rgba(var(--text-rgb), 0.045); + } + + .terminal-panel-muted { + @apply rounded-xl border p-4; + border-color: color-mix(in oklab, var(--primary) 6%, var(--border-color)); + background: + linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 96%, transparent), color-mix(in oklab, var(--header-bg) 90%, transparent)); + } + + .terminal-kicker { + @apply inline-flex items-center gap-2 rounded-md border px-2.5 py-1 text-[10px] font-mono uppercase tracking-[0.28em]; + color: var(--text-tertiary); + border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color)); + background: color-mix(in oklab, var(--terminal-bg) 96%, transparent); + } + + .terminal-stat-pill { + @apply inline-flex items-center gap-2 rounded-md border px-2.5 py-1 text-[11px] font-medium; + border-color: color-mix(in oklab, var(--primary) 14%, var(--border-color)); + background: color-mix(in oklab, var(--primary) 5%, var(--terminal-bg)); + color: var(--text-secondary); + } + + .terminal-section-title { + @apply flex flex-wrap items-center gap-3; + } + + .terminal-section-icon { + @apply flex h-10 w-10 items-center justify-center rounded-lg text-base; + color: var(--primary); + background: color-mix(in oklab, var(--primary) 8%, var(--terminal-bg)); + border: 1px solid color-mix(in oklab, var(--primary) 14%, var(--border-color)); + } + + .terminal-empty { + @apply rounded-xl border border-dashed px-6 py-10 text-center; + border-color: color-mix(in oklab, var(--primary) 12%, var(--border-color)); + background: color-mix(in oklab, var(--header-bg) 78%, transparent); + } + + .terminal-chip { + @apply inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] transition-all; + border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color)); + background: color-mix(in oklab, var(--terminal-bg) 96%, transparent); + color: var(--text-secondary); + } + + .terminal-chip:hover { + border-color: color-mix(in oklab, var(--primary) 20%, var(--border-color)); + color: var(--title-color); + } + + .terminal-link-arrow { + @apply inline-flex items-center gap-2 text-sm font-medium; + color: var(--primary); + } + + .terminal-toolbar-shell { + @apply relative overflow-hidden rounded-[1.35rem] border p-3; + border-color: color-mix(in oklab, var(--primary) 12%, var(--border-color)); + background: + linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 97%, transparent), color-mix(in oklab, var(--header-bg) 90%, transparent)), + linear-gradient(90deg, rgba(var(--primary-rgb), 0.03), transparent 20%, rgba(var(--primary-rgb), 0.015) 78%, transparent); + box-shadow: + 0 12px 30px rgba(var(--text-rgb), 0.05), + inset 0 1px 0 rgba(255, 255, 255, 0.42); + } + + .terminal-toolbar-shell::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: linear-gradient(rgba(var(--primary-rgb), 0.04) 1px, transparent 1px); + background-size: 100% 14px; + opacity: 0.18; + } + + .terminal-toolbar-module { + @apply relative flex items-center gap-3 rounded-lg border px-3 py-2; + border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color)); + background: color-mix(in oklab, var(--terminal-bg) 97%, transparent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38); + } + + .terminal-toolbar-label { + @apply text-[10px] font-mono uppercase tracking-[0.28em]; + color: var(--text-tertiary); + } + + .terminal-toolbar-iconbtn { + @apply inline-flex h-8 w-8 items-center justify-center rounded-md border transition-all; + border-color: color-mix(in oklab, var(--primary) 6%, var(--border-color)); + color: var(--text-secondary); + background: color-mix(in oklab, var(--header-bg) 84%, transparent); + } + + .terminal-toolbar-iconbtn:hover { + border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color)); + color: var(--primary); + background: color-mix(in oklab, var(--primary) 7%, var(--terminal-bg)); + } + + .terminal-console-input { + @apply min-w-0 flex-1 bg-transparent font-mono text-sm outline-none; + color: var(--text); + } + + .terminal-console-input::placeholder { + color: var(--text-tertiary); + } + + .terminal-nav-link { + @apply inline-flex shrink-0 items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm transition-all; + border-color: transparent; + color: var(--text-secondary); + background: transparent; + } + + .terminal-nav-link:hover { + border-color: color-mix(in oklab, var(--primary) 14%, var(--border-color)); + background: color-mix(in oklab, var(--primary) 5%, var(--terminal-bg)); + color: var(--title-color); + } + + .terminal-nav-link.is-active { + border-color: color-mix(in oklab, var(--primary) 24%, var(--border-color)); + background: color-mix(in oklab, var(--primary) 8%, var(--terminal-bg)); + color: var(--primary); + } + + .terminal-filter { + @apply inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-[12px] font-mono transition-all; + border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color)); + background: color-mix(in oklab, var(--terminal-bg) 97%, transparent); + color: var(--text-secondary); + letter-spacing: 0.01em; + } + + .terminal-filter:hover { + border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color)); + color: var(--title-color); + background: color-mix(in oklab, var(--primary) 6%, var(--terminal-bg)); + } + + .terminal-filter.is-active { + border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color)); + color: var(--primary); + background: color-mix(in oklab, var(--primary) 9%, var(--terminal-bg)); + box-shadow: inset 0 0 0 1px rgba(var(--primary-rgb), 0.08); + } + + .terminal-action-button { + @apply inline-flex items-center gap-2 rounded-md border px-3 py-2 font-mono text-[12px] uppercase tracking-[0.18em] transition-all; + border-color: color-mix(in oklab, var(--primary) 12%, var(--border-color)); + background: color-mix(in oklab, var(--terminal-bg) 97%, transparent); + color: var(--text-secondary); + } + + .terminal-action-button:hover { + border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color)); + color: var(--title-color); + } + + .terminal-action-button-primary { + border-color: color-mix(in oklab, var(--primary) 24%, var(--border-color)); + background: + linear-gradient(180deg, color-mix(in oklab, var(--primary) 10%, var(--terminal-bg)), color-mix(in oklab, var(--primary) 6%, var(--terminal-bg))); + color: var(--primary); + box-shadow: 0 8px 18px rgba(var(--primary-rgb), 0.08); + } + + .terminal-action-button-primary:hover { + color: var(--title-color); + background: + linear-gradient(180deg, color-mix(in oklab, var(--primary) 14%, var(--terminal-bg)), color-mix(in oklab, var(--primary) 8%, var(--terminal-bg))); + } + + .terminal-form-label { + @apply mb-2 block text-xs font-mono uppercase tracking-[0.24em]; + color: var(--text-tertiary); + } + + .terminal-form-input, + .terminal-form-textarea { + @apply w-full rounded-xl border px-3.5 py-2.5 text-sm transition-all outline-none; + border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color)); + background: + linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 97%, transparent), color-mix(in oklab, var(--header-bg) 92%, transparent)); + color: var(--text); + font-family: var(--font-mono); + } + + .terminal-form-input::placeholder, + .terminal-form-textarea::placeholder { + color: var(--text-tertiary); + } + + .terminal-form-input:focus, + .terminal-form-textarea:focus { + border-color: color-mix(in oklab, var(--primary) 38%, var(--border-color)); + box-shadow: + 0 0 0 4px rgba(var(--primary-rgb), 0.08), + inset 0 0 0 1px rgba(var(--primary-rgb), 0.08); + } + + .terminal-document { + @apply rounded-[1.35rem] border px-5 py-6 sm:px-8 sm:py-8; + border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color)); + background: + linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 97%, transparent), color-mix(in oklab, var(--header-bg) 92%, transparent)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.28), + 0 18px 36px rgba(var(--text-rgb), 0.05); + } + + .terminal-document > *:first-child { + @apply mt-0; + } + + .terminal-document h1, + .terminal-document h2, + .terminal-document h3, + .terminal-document h4 { + color: var(--title-color); + font-weight: 700; + letter-spacing: -0.02em; + } + + .terminal-document h1 { + @apply mt-0 text-3xl; + } + + .terminal-document h2 { + @apply mt-10 border-t pt-6 text-2xl; + border-color: color-mix(in oklab, var(--primary) 14%, var(--border-color)); + } + + .terminal-document h3 { + @apply mt-8 text-xl; + } + + .terminal-document p, + .terminal-document ul, + .terminal-document ol, + .terminal-document blockquote, + .terminal-document pre, + .terminal-document table { + @apply mt-5; + } + + .terminal-document p, + .terminal-document li { + @apply leading-8; + color: var(--text-secondary); + } + + .terminal-document ul, + .terminal-document ol { + @apply space-y-3 pl-6; + } + + .terminal-document ul { + list-style: square; + } + + .terminal-document ol { + list-style: decimal; + } + + .terminal-document li::marker { + color: var(--primary); + } + + .terminal-document a { + color: var(--primary); + text-decoration: underline; + text-decoration-color: color-mix(in oklab, var(--primary) 40%, transparent); + text-underline-offset: 0.22em; + } + + .terminal-document strong { + color: var(--title-color); + } + + .terminal-document code:not(pre code) { + @apply rounded-md px-1.5 py-0.5 text-[0.92em]; + background: color-mix(in oklab, var(--primary) 10%, var(--header-bg)); + color: var(--primary); + font-family: var(--font-mono); + } + + .terminal-document pre, + .terminal-document .astro-code { + @apply overflow-x-auto rounded-2xl border p-4; + border-color: color-mix(in oklab, var(--primary) 16%, var(--border-color)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + } + + .terminal-document blockquote { + @apply rounded-r-2xl border-l-4 px-5 py-4; + border-left-color: var(--primary); + background: color-mix(in oklab, var(--primary) 8%, var(--header-bg)); + color: var(--text); + } + + .terminal-document hr { + @apply my-8 border-0 border-t; + border-color: color-mix(in oklab, var(--primary) 12%, var(--border-color)); + } + + .terminal-document table { + @apply w-full overflow-hidden rounded-2xl border; + border-color: var(--border-color); + } + + .terminal-document th, + .terminal-document td { + @apply px-4 py-3 text-left text-sm; + border-bottom: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color)); + } + + .terminal-document th { + background: color-mix(in oklab, var(--primary) 8%, var(--header-bg)); + color: var(--title-color); + } + + .terminal-document td { + color: var(--text-secondary); + } + + .terminal-console-list-item { + @apply flex items-start justify-between gap-4 rounded-xl border px-4 py-3; + border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color)); + background: color-mix(in oklab, var(--terminal-bg) 97%, transparent); + } + + .ui-filter-pill { + --pill-rgb: var(--primary-rgb); + --pill-fg: var(--text-secondary); + @apply inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-[12px] transition-all; + border-color: color-mix(in oklab, rgb(var(--pill-rgb)) 12%, var(--border-color)); + background: + linear-gradient(180deg, color-mix(in oklab, rgb(var(--pill-rgb)) 3%, var(--terminal-bg)), color-mix(in oklab, rgb(var(--pill-rgb)) 1%, var(--header-bg))); + color: var(--pill-fg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.32); + font-family: var(--font-mono); + letter-spacing: 0.01em; + } + + .ui-filter-pill:hover { + border-color: color-mix(in oklab, rgb(var(--pill-rgb)) 18%, var(--border-color)); + background: + linear-gradient(180deg, color-mix(in oklab, rgb(var(--pill-rgb)) 5%, var(--terminal-bg)), color-mix(in oklab, rgb(var(--pill-rgb)) 3%, var(--header-bg))); + color: var(--title-color); + } + + .ui-filter-pill.is-active, + .ui-filter-pill:has(input:checked) { + border-color: color-mix(in oklab, rgb(var(--pill-rgb)) 28%, var(--border-color)); + background: + linear-gradient(180deg, color-mix(in oklab, rgb(var(--pill-rgb)) 9%, var(--terminal-bg)), color-mix(in oklab, rgb(var(--pill-rgb)) 5%, var(--header-bg))); + box-shadow: + inset 0 0 0 1px rgba(var(--pill-rgb), 0.08), + 0 6px 14px rgba(var(--text-rgb), 0.03); + color: color-mix(in oklab, rgb(var(--pill-rgb)) 72%, var(--title-color)); + } + + .ui-filter-pill--blue { + --pill-rgb: 59 130 246; + --pill-fg: #315ea8; + } + + .ui-filter-pill--amber { + --pill-rgb: 217 119 6; + --pill-fg: #9a5a12; + } + + .ui-filter-pill--teal { + --pill-rgb: 13 148 136; + --pill-fg: #0f766e; + } + + .ui-filter-pill--violet { + --pill-rgb: 124 58 237; + --pill-fg: #6d46c3; + } + + .ui-filter-pill--neutral { + --pill-rgb: 100 116 139; + --pill-fg: var(--text-secondary); + } + + .ui-info-tile { + --tile-rgb: var(--primary-rgb); + @apply rounded-xl border transition-all; + border-color: color-mix(in oklab, rgb(var(--tile-rgb)) 10%, var(--border-color)); + background: + linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 98%, transparent), color-mix(in oklab, rgb(var(--tile-rgb)) 1%, var(--header-bg))); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3); + } + + .ui-info-tile:hover { + border-color: color-mix(in oklab, rgb(var(--tile-rgb)) 18%, var(--border-color)); + } + + .ui-info-tile--row { + @apply flex items-center justify-between gap-3 px-3.5 py-2.5; + } + + .ui-info-tile--grid { + @apply flex items-center gap-3 px-3.5 py-3; + } + + .ui-info-tile--stack { + @apply px-3.5 py-3 text-left; + } + + .ui-info-tile--blue { + --tile-rgb: 59 130 246; + } + + .ui-info-tile--amber { + --tile-rgb: 217 119 6; + } + + .ui-info-tile--teal { + --tile-rgb: 13 148 136; + } + + .ui-info-tile--violet { + --tile-rgb: 124 58 237; + } + + .ui-info-tile--neutral { + --tile-rgb: 100 116 139; + } +} + +/* Animations */ +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +@keyframes terminal-glow { + 0% { + box-shadow: + 0 0 8px rgba(var(--primary-rgb), 0.4), + 0 0 20px rgba(var(--primary-rgb), 0.2), + 0 10px 30px rgba(var(--primary-rgb), 0.2); + } + 100% { + box-shadow: + 0 0 12px rgba(var(--primary-rgb), 0.5), + 0 0 25px rgba(var(--primary-rgb), 0.3), + 0 10px 30px rgba(var(--primary-rgb), 0.2); + } +} + +/* Smooth theme transitions */ +html, body, button, input, select, textarea, a { + @apply transition-colors duration-300 ease-out; +} diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 0000000..dcd6773 --- /dev/null +++ b/frontend/src/styles/main.css @@ -0,0 +1,88 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@import './colors.css'; +@import './animations.css'; + +:root { + --radius: 0.5rem; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + @apply bg-[var(--bg)] text-[var(--text)]; +} + +/* Terminal glow effect */ +.terminal-window { + @apply relative overflow-hidden; + border: 1px solid var(--primary); + box-shadow: + 0 0 8px rgba(var(--primary-rgb), 0.4), + 0 0 20px rgba(var(--primary-rgb), 0.2), + 0 10px 30px rgba(var(--primary-rgb), 0.2); + animation: terminal-glow 4s ease-in-out infinite alternate; +} + +@keyframes terminal-glow { + 0% { + box-shadow: + 0 0 8px rgba(var(--primary-rgb), 0.4), + 0 0 20px rgba(var(--primary-rgb), 0.2), + 0 10px 30px rgba(var(--primary-rgb), 0.2); + } + 100% { + box-shadow: + 0 0 12px rgba(var(--primary-rgb), 0.5), + 0 0 25px rgba(var(--primary-rgb), 0.3), + 0 10px 30px rgba(var(--primary-rgb), 0.2); + } +} + +/* Smooth transitions */ +.transition-theme { + @apply transition-colors duration-300 ease-out; +} + +/* Post card hover */ +.post-card { + @apply relative; +} + +.post-card::before { + content: ''; + @apply absolute left-0 top-0 bottom-0 w-1 rounded-l opacity-0 transition-opacity duration-300; + background-color: var(--post-border-color, var(--primary)); +} + +.post-card:hover { + @apply translate-x-2; +} + +.post-card:hover::before { + @apply opacity-100; +} + +/* ASCII art */ +.ascii-art { + font-family: 'Courier New', monospace; + @apply text-xs sm:text-sm text-[var(--primary)] whitespace-pre; +} + +/* Cursor blink */ +.cursor { + @apply inline-block w-2.5 h-4 align-middle ml-0.5; + background-color: var(--primary); + animation: blink 1s infinite; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..729709a --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,40 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: 'class', + content: [ + './src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}', + ], + theme: { + extend: { + colors: { + primary: { + DEFAULT: 'var(--primary)', + rgb: 'var(--primary-rgb)', + light: 'var(--primary-light)', + dark: 'var(--primary-dark)', + }, + bg: { + DEFAULT: 'var(--bg)', + secondary: 'var(--bg-secondary)', + tertiary: 'var(--bg-tertiary)', + terminal: 'var(--terminal-bg)', + }, + text: { + DEFAULT: 'var(--text)', + secondary: 'var(--text-secondary)', + tertiary: 'var(--text-tertiary)', + }, + border: { + DEFAULT: 'var(--border-color)', + terminal: 'var(--terminal-border)', + }, + }, + fontFamily: { + mono: ['JetBrains Mono', 'Fira Code', 'monospace'], + }, + }, + }, + plugins: [ + require('@tailwindcss/typography'), + ], +} diff --git a/tsconfig.json b/frontend/tsconfig.json similarity index 70% rename from tsconfig.json rename to frontend/tsconfig.json index 8bf91d3..1fff7a2 100644 --- a/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,5 +1,5 @@ { "extends": "astro/tsconfigs/strict", "include": [".astro/types.d.ts", "**/*"], - "exclude": ["dist"] + "exclude": ["dist", "src_backup"] } diff --git a/package.json b/package.json deleted file mode 100644 index 608612c..0000000 --- a/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "termi-astro", - "type": "module", - "version": "0.0.1", - "engines": { - "node": ">=22.12.0" - }, - "scripts": { - "dev": "astro dev", - "build": "astro build", - "preview": "astro preview", - "astro": "astro" - }, - "dependencies": { - "astro": "^6.0.8" - } -} \ No newline at end of file diff --git a/src/pages/index.astro b/src/pages/index.astro deleted file mode 100644 index 561196b..0000000 --- a/src/pages/index.astro +++ /dev/null @@ -1,17 +0,0 @@ ---- - ---- - - - - - - - - - Astro - - -

Astro

- -