heartwood every commit a ring

the almanac trades flask for rust

75fcfce4 by Isaac Bythewood · 6 days ago

modified .gitignore
@@ -1,5 +1,6 @@.env__pycache__/.venv//target/dist/frontend/node_modules.playwright-mcp/*.png
modified CLAUDE.md
@@ -9,6 +9,10 @@ to harvest, what the sky is doing, what our ancestors called this week.The project is an art piece as much as it is a tool. It is a single livingpage that breathes with the seasons.This is a Rust port of the original Flask version (now deleted). Performance:~30-50x lower memory, ~10-20x higher RPS, sub-millisecond per-request latencyin release mode.## Design philosophy- Text-forward, minimal, dark aesthetic. Like reading by firelight.
@@ -18,35 +22,69 @@ page that breathes with the seasons.- No feeds, no accounts, no engagement patterns.- The page should feel quiet, warm, and cozy.## Technical notes- Flask backend with Jinja2 templates. Python handles all content  assembly, calculations, and markdown rendering (via mistune).- Client-side JavaScript is for presentation only: color palette,  animations, navigation interactions, and auto-refresh.- Dependencies managed with uv. See pyproject.toml.- All content data lives in markdown files under data/. No hardcoded  content in Python code.- `make run` starts the Flask dev server on 0.0.0.0:8000.- Production runs via `docker-compose` (Gunicorn, 2 workers) bound to  `127.0.0.1:${PORT}` where `PORT` comes from `.env` (8500 on the deployed host).- Routes: `/` renders the page; `/api/content` returns the same content as JSON  for the client-side auto-refresh. Both accept `?season=` for previewing other  seasons. There is no `?time=` override anymore.- The site lives at darkfurrow.com.- Target planting zone is 7a (North Carolina) to start, but the structure  should allow for expansion to other zones later.## Development tools- Playwright MCP is available for browser testing. Use it to take  screenshots and verify visual changes after modifying styles,  templates, or content. Start the dev server first with `make run`.- Clean up screenshot files (*.png) after reviewing them. Delete them  once you have confirmed the result to avoid clutter in the project  directory.- The dev environment runs inside a Docker container with port 8000  mapped to the host.## Commands- **Dev server:** `make run` (Vite watch + cargo run concurrently on port 8000)- **Production build:** `make build` (Vite assets + release binary)- **Run release binary:** `make start`- **Tests:** `cargo test` (parity tests for rng, moon math, sun math, markdown)- **Docker build:** `sudo docker build .`There are no linters configured.## Architecture**Backend:** Single-binary axum app (`src/main.rs`). Two routes: `/` rendersthe page, `/api/content` returns the same content as JSON for the client-sideauto-refresh. Both accept `?season=` for previewing other seasons. Data isloaded once at startup from `data/` and held in `AppState`.**Frontend pipeline:** Vite (run from `frontend/`) builds `frontend/static_src/`into `dist/`. Entry point is `frontend/static_src/index.js` which imports SCSSand JS. Output filenames are content-hashed (`base-[hash].js`, `base-[hash].css`)and a Vite manifest (`dist/.vite/manifest.json`) is read at runtime so templatesresolve hashed names via `{{ vite_asset(...) }}`. Files in`frontend/static_src/public/` (favicon, og.svg, sw.js, woff2 fonts) are copiedto `dist/` unchanged and served at `/static/`.**Templates:** `templates/index.html` is rendered by minijinja with a customformatter (`src/templates.rs::jinja2_html_formatter`) that matches Jinja2'sHTML escape (does NOT escape `/`), so Vite asset URLs come through as`/static/base-[hash].js` rather than `&#x2f;static&#x2f;...`.**Manifest reload:** `templates::build_env` re-reads `dist/.vite/manifest.json`per `vite_asset()` call in debug builds (so Vite watcher rebuilds are pickedup immediately). Release builds load it once at startup. Gated on`cfg(debug_assertions)`.**Markdown:** Rendered through comrak (`src/markdown.rs`). `render_inline`strips a single wrapping `<p>...</p>` for inline contexts (matches Mistune'soutput for the original flask version); `render_block` keeps block tags.**Astronomical math:** `src/astro.rs`. Moon phase + illumination via Meeus'slunar series (table 47.A perturbations). Sunrise/sunset/daylight via NOAA'sSpencer fourier series. Locked to zone 7a (lat 35.78, lon -78.64). Local-tzhandling uses chrono-tz with America/New_York. Accurate to ~1 minute.**Daily-stable seeded RNG:** `src/rng.rs` is a mulberry32 PRNG withJS-Math.imul-compatible semantics. Keyed by day-of-year so picks shift dayto day but are stable across refreshes within a day. Locked against theoriginal python implementation in unit tests.**Section assembly:** `src/almanac.rs` is the engine. Builds five sections(sky, garden, kitchen, foraging, folklore), pulls bullets and prose fromthe relevant `data/<topic>/<season>.md` files, picks items via the seededRNG, and renders to a single HTML string for the template + JSON API.**Request logging:** custom middleware in `src/main.rs::log_requests` prints`time METHOD STATUS latency path` per request, with ANSI-colored status codes(green 2xx, cyan 3xx, yellow 4xx, red 5xx). Always-on, costs sub-microsecondper request. The `.layer()` is applied after all routes so it covers the`nest_service` static-file mount too.**Content:** `data/` holds markdown for every topic-season combination plusseasons, haiku, moods, and moon-tips. The `data/wisdom/` files exist on diskbut are not read by anything (they were tied to the removed time rotation).## Page structure
@@ -63,14 +101,40 @@ part they care about. The current sections, in order, are:   italic lore beneath.5. **folklore** - a short paragraph each from old names, remedies, and bugs.Daily-stable seeded RNG (`seeded_random` keyed by day-of-year) picks whichitems surface per section, so content shifts day to day but is stable acrossrefreshes within a day.The time-of-day rotation that used to swap which categories appeared wasremoved. Time of day still tints the background palette (in `static/almanac.js`)removed. Time of day still tints the background palette (in `static_src/scripts/almanac.js`)but no longer hides content.## Layout```darkfurrow.com/├── Cargo.toml, Cargo.lock        # rust deps├── Makefile, README.md           # top-level├── src/                          # rust source│   ├── main.rs       # axum routes, AppState│   ├── almanac.rs    # assemble_content + section builders│   ├── astro.rs      # moon + sun math│   ├── content.rs    # frontmatter, list parsers, data loaders│   ├── markdown.rs   # comrak wrappers (inline + block)│   ├── rng.rs        # mulberry32 with imul/signed32 semantics│   └── templates.rs  # minijinja env, vite_asset, Jinja2-compat formatter├── frontend/                     # JS pipeline (package.json, vite.config.js, static_src/, node_modules/)│   └── static_src/│       ├── index.js              # entry: imports styles + scripts│       ├── scripts/almanac.js    # client-side palette + animations│       ├── styles/base.scss      # @font-face + the whole stylesheet│       └── public/               # copied as-is to dist/ (favicon, og, sw, fonts/)├── templates/                    # minijinja-compatible jinja2├── data/                         # markdown content (one file per topic-season)├── dist/                         # vite build output (gitignored, served at /static/)├── target/                       # cargo build output (gitignored)└── samplefiles/                  # Caddyfile.sample, env.sample, post-receive.sample```The binary reads `templates/`, `dist/`, and `data/` from the current workingdirectory by default. Override the project root with `DARKFURROW_ROOT=<path>`.## Content the page should surface- What's in season to plant and harvest right now
@@ -79,9 +143,6 @@ but no longer hides content.- Old folk names for storms, stars, and time periods- Practical wisdom that used to be passed down but no longer isThe `data/wisdom/<time>.md` files are no longer rendered (they were tied to theremoved time rotation) but remain on disk in case they come back.## Voice and tone- Poetic but not pretentious
@@ -93,3 +154,29 @@ removed time rotation) but remain on disk in case they come back.- Avoid em dashes and en dashes in all writing- Keep things lowercase where it feels right## Tooling- **Rust deps:** managed with `cargo` (see `Cargo.toml`, `Cargo.lock`)- **JS deps:** managed with `bun`, run from `frontend/` (see `frontend/package.json`, `frontend/bun.lock`)- **Production:** Docker (Alpine-based multi-stage, `rust:alpine` builder +  `alpine:3.23` runtime), deployed via `docker-compose`. No external runtime  deps (no Chromium, no fonts apk; woff2 are self-hosted from  `frontend/static_src/public/fonts/`).## Development tools- Playwright MCP is available for browser testing. Use it to take  screenshots and verify visual changes after modifying styles,  templates, or content. Start the dev server first with `make run`.- Clean up screenshot files (*.png) after reviewing them. Delete them  once you have confirmed the result to avoid clutter in the project  directory.- The dev environment runs inside a Docker container with port 8000  mapped to the host.## Targeting- Target planting zone is 7a (North Carolina) to start, but the structure  should allow for expansion to other zones later.- The site lives at darkfurrow.com.
added Cargo.lock
@@ -0,0 +1,1586 @@# 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 = "aho-corasick"version = "1.1.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"dependencies = [ "memchr",][[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",][[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",][[package]]name = "anyhow"version = "1.0.102"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"[[package]]name = "atomic-waker"version = "1.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"[[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.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"dependencies = [ "axum-core", "bytes", "form_urlencoded", "futures-util", "http", "http-body", "http-body-util", "hyper", "hyper-util", "itoa", "matchit", "memchr", "mime", "percent-encoding", "pin-project-lite", "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", "sync_wrapper", "tokio", "tower", "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 = "base64"version = "0.22.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"[[package]]name = "bincode"version = "1.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"dependencies = [ "serde",][[package]]name = "bit-set"version = "0.8.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"dependencies = [ "bit-vec",][[package]]name = "bit-vec"version = "0.8.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"[[package]]name = "bitflags"version = "2.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"[[package]]name = "bon"version = "3.9.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe"dependencies = [ "bon-macros", "rustversion",][[package]]name = "bon-macros"version = "3.9.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c"dependencies = [ "darling", "ident_case", "prettyplease", "proc-macro2", "quote", "rustversion", "syn",][[package]]name = "bumpalo"version = "3.20.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"[[package]]name = "bytes"version = "1.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"[[package]]name = "caseless"version = "0.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"dependencies = [ "unicode-normalization",][[package]]name = "cc"version = "1.2.61"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"dependencies = [ "find-msvc-tools", "shlex",][[package]]name = "cfg-if"version = "1.0.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"[[package]]name = "chrono"version = "0.4.44"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"dependencies = [ "iana-time-zone", "num-traits", "serde", "windows-link",][[package]]name = "chrono-tz"version = "0.10.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"dependencies = [ "chrono", "phf",][[package]]name = "clap"version = "4.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"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", "terminal_size",][[package]]name = "clap_derive"version = "4.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"dependencies = [ "heck", "proc-macro2", "quote", "syn",][[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 = "comrak"version = "0.30.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "949f8e6b02ebac005a8be2df9ec0876cafc83fdb9c510796c37f0fadf92dcd0e"dependencies = [ "bon", "caseless", "clap", "entities", "memchr", "once_cell", "regex", "shell-words", "slug", "syntect", "typed-arena", "unicode_categories", "xdg",][[package]]name = "core-foundation-sys"version = "0.8.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"[[package]]name = "crc32fast"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"dependencies = [ "cfg-if",][[package]]name = "darkfurrow"version = "0.1.0"dependencies = [ "anyhow", "axum", "chrono", "chrono-tz", "comrak", "minijinja", "serde", "serde_json", "tokio", "tower", "tower-http",][[package]]name = "darling"version = "0.23.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"dependencies = [ "darling_core", "darling_macro",][[package]]name = "darling_core"version = "0.23.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", "syn",][[package]]name = "darling_macro"version = "0.23.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"dependencies = [ "darling_core", "quote", "syn",][[package]]name = "deranged"version = "0.5.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"dependencies = [ "powerfmt",][[package]]name = "deunicode"version = "1.6.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"[[package]]name = "entities"version = "1.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"[[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",][[package]]name = "fancy-regex"version = "0.16.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"dependencies = [ "bit-set", "regex-automata", "regex-syntax",][[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 = "fnv"version = "1.0.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"[[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 = "futures-channel"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"dependencies = [ "futures-core",][[package]]name = "futures-core"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"[[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-util"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"dependencies = [ "futures-core", "futures-task", "pin-project-lite", "slab",][[package]]name = "hashbrown"version = "0.17.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"[[package]]name = "heck"version = "0.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"[[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 = "hyper"version = "1.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "http", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", "smallvec", "tokio",][[package]]name = "hyper-util"version = "0.1.20"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"dependencies = [ "bytes", "http", "http-body", "hyper", "pin-project-lite", "tokio", "tower-service",][[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 = "ident_case"version = "1.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"[[package]]name = "indexmap"version = "2.14.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"dependencies = [ "equivalent", "hashbrown",][[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 = "js-sys"version = "0.3.97"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"dependencies = [ "cfg-if", "futures-util", "once_cell", "wasm-bindgen",][[package]]name = "libc"version = "0.2.186"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"[[package]]name = "linked-hash-map"version = "0.5.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"[[package]]name = "linux-raw-sys"version = "0.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"[[package]]name = "lock_api"version = "0.4.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"dependencies = [ "scopeguard",][[package]]name = "log"version = "0.4.29"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"[[package]]name = "matchit"version = "0.8.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"[[package]]name = "memchr"version = "2.8.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"[[package]]name = "memo-map"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"[[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 = "minijinja"version = "2.19.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "805bfd7352166bae857ee569628b52bcd85a1cecf7810861ebceb1686b72b75d"dependencies = [ "memo-map", "serde",][[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.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"dependencies = [ "libc", "wasi", "windows-sys",][[package]]name = "num-conv"version = "0.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"[[package]]name = "num-traits"version = "0.2.19"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"dependencies = [ "autocfg",][[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 = "onig"version = "6.5.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"dependencies = [ "bitflags", "libc", "once_cell", "onig_sys",][[package]]name = "onig_sys"version = "69.9.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"dependencies = [ "cc", "pkg-config",][[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", "smallvec", "windows-link",][[package]]name = "percent-encoding"version = "2.3.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"[[package]]name = "phf"version = "0.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"dependencies = [ "phf_shared",][[package]]name = "phf_shared"version = "0.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"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 = "pkg-config"version = "0.3.33"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"[[package]]name = "plist"version = "1.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"dependencies = [ "base64", "indexmap", "quick-xml", "serde", "time",][[package]]name = "powerfmt"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"[[package]]name = "prettyplease"version = "0.2.37"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"dependencies = [ "proc-macro2", "syn",][[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 = "quick-xml"version = "0.39.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b"dependencies = [ "memchr",][[package]]name = "quote"version = "1.0.45"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"dependencies = [ "proc-macro2",][[package]]name = "redox_syscall"version = "0.5.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"dependencies = [ "bitflags",][[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 = "rustix"version = "1.1.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", "windows-sys",][[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 = "scopeguard"version = "1.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"[[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",][[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_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 = "shell-words"version = "1.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"[[package]]name = "shlex"version = "1.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"[[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 = "simd-adler32"version = "0.3.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"[[package]]name = "siphasher"version = "1.0.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"[[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"[[package]]name = "socket2"version = "0.6.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"dependencies = [ "libc", "windows-sys",][[package]]name = "strsim"version = "0.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"[[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"[[package]]name = "syntect"version = "5.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"dependencies = [ "bincode", "fancy-regex", "flate2", "fnv", "once_cell", "onig", "plist", "regex-syntax", "serde", "serde_derive", "serde_json", "thiserror", "walkdir", "yaml-rust",][[package]]name = "terminal_size"version = "0.4.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"dependencies = [ "rustix", "windows-sys",][[package]]name = "thiserror"version = "2.0.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"dependencies = [ "thiserror-impl",][[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",][[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 = "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.52.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386"dependencies = [ "bytes", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", "windows-sys",][[package]]name = "tokio-macros"version = "2.7.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"dependencies = [ "proc-macro2", "quote", "syn",][[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 = "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.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a28f0d049ccfaa566e14e9663d304d8577427b368cb4710a20528690287a738b"dependencies = [ "bitflags", "bytes", "futures-core", "futures-util", "http", "http-body", "http-body-util", "http-range-header", "httpdate", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", "tower-layer", "tower-service",][[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-core",][[package]]name = "tracing-core"version = "0.1.36"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"dependencies = [ "once_cell",][[package]]name = "typed-arena"version = "2.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"[[package]]name = "unicase"version = "2.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"[[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_categories"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"[[package]]name = "utf8parse"version = "0.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"[[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 = "wasi"version = "0.11.1+wasi-snapshot-preview1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"[[package]]name = "wasm-bindgen"version = "0.2.120"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared",][[package]]name = "wasm-bindgen-macro"version = "0.2.120"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"dependencies = [ "quote", "wasm-bindgen-macro-support",][[package]]name = "wasm-bindgen-macro-support"version = "0.2.120"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"dependencies = [ "bumpalo", "proc-macro2", "quote", "syn", "wasm-bindgen-shared",][[package]]name = "wasm-bindgen-shared"version = "0.2.120"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"dependencies = [ "unicode-ident",][[package]]name = "winapi-util"version = "0.1.11"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"dependencies = [ "windows-sys",][[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",][[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",][[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.61.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"dependencies = [ "windows-link",][[package]]name = "xdg"version = "2.5.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546"[[package]]name = "yaml-rust"version = "0.4.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"dependencies = [ "linked-hash-map",][[package]]name = "zmij"version = "1.0.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
added Cargo.toml
@@ -0,0 +1,22 @@[package]name = "darkfurrow"version = "0.1.0"edition = "2021"[dependencies]axum = "0.8"tokio = { version = "1", features = ["full"] }tower = { version = "0.5", features = ["util"] }tower-http = { version = "0.6", features = ["fs", "set-header"] }minijinja = { version = "2", features = ["loader", "loop_controls"] }comrak = "0.30"serde = { version = "1", features = ["derive"] }serde_json = "1"chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }chrono-tz = "0.10"anyhow = "1"[profile.release]lto = truecodegen-units = 1strip = true
modified Dockerfile
@@ -1,20 +1,35 @@FROM python:3.13-alpine# ----- builder -----FROM rust:alpine AS builderCOPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/RUN apk add --no-cache musl-devRUN addgroup -S -g 1000 app && \    adduser -S -h /app -s /sbin/nologin -u 1000 -G app appCOPY --from=oven/bun:alpine /usr/local/bin/bun /usr/local/bin/bunWORKDIR /appCOPY pyproject.toml uv.lock ./RUN uv sync --frozen --no-devCOPY Cargo.toml Cargo.lock ./COPY src ./srcCOPY frontend ./frontendRUN cd frontend && bun install --frozen-lockfile && bun run buildRUN cargo build --releaseCOPY app.py almanac.py ./COPY data/ ./data/COPY static/ ./static/COPY templates/ ./templates/# ----- runtime -----FROM alpine:3.23WORKDIR /appRUN chown -R app:app /appCOPY --from=builder /app/target/release/darkfurrow ./darkfurrowCOPY --from=builder /app/dist ./distCOPY templates ./templatesCOPY data ./dataRUN addgroup -S -g 1000 app && \    adduser -S -h /app -s /sbin/nologin -u 1000 -G app app && \    chown -R app:app /appUSER appENV PORT=8000EXPOSE 8000CMD ["./darkfurrow"]
modified Makefile
@@ -1,7 +1,33 @@.PHONY: run pushCARGO ?= $(HOME)/.cargo/bin/cargoPORT  ?= 8000run:	uv run flask --app app run --host 0.0.0.0 --port 8000 --debug.DEFAULT_GOAL := run.PHONY: run build start clean push# Dev: Vite watch + cargo run concurrently. Both die on Ctrl+C.run: frontend/node_modules dist/.vite/manifest.json	@trap 'kill 0' EXIT INT TERM; \	(cd frontend && bun run dev) & \	PORT=$(PORT) $(CARGO) run# Production build (Vite assets + release binary)build: frontend/node_modules	cd frontend && bun run build	$(CARGO) build --release# Run the release binary (after `make build`)start:	PORT=$(PORT) ./target/release/darkfurrowclean:	$(CARGO) clean	rm -rf dist frontend/node_modulespush:	git remote | xargs -I R git push R masterfrontend/node_modules:	cd frontend && bun installdist/.vite/manifest.json: frontend/node_modules	cd frontend && bun run build
deleted almanac.py
@@ -1,799 +0,0 @@"""almanac.pythe engine beneath the soil.reads the clock and the calendar, assembles what belongs to this moment."""import jsonimport mathimport osimport reimport mistuneLAT = 35.78   # north carolina, zone 7a (raleigh)LON = -78.64  # degrees east; negative for west_md = mistune.create_markdown()def render_md(text):    """render markdown to html, stripping outer <p> tags for inline use."""    html = _md(text).strip()    # strip single wrapping <p>...</p> for inline contexts    if html.startswith('<p>') and html.endswith('</p>') and html.count('<p>') == 1:        html = html[3:-4]    return htmldef render_md_block(text):    """render markdown to html, keeping block-level tags."""    return _md(text).strip()# --- seeded randomness ---# same picks all day, different tomorrowdef day_hash(date):    doy = date.timetuple().tm_yday    return date.year * 1000 + doydef _imul(a, b):    """replicate javascript Math.imul: 32-bit integer multiply."""    a &= 0xFFFFFFFF    b &= 0xFFFFFFFF    ah = (a >> 16) & 0xFFFF    al = a & 0xFFFF    bh = (b >> 16) & 0xFFFF    bl = b & 0xFFFF    result = (al * bl) + (((ah * bl + al * bh) & 0xFFFF) << 16)    return result & 0xFFFFFFFFdef _to_signed32(n):    n &= 0xFFFFFFFF    if n >= 0x80000000:        return n - 0x100000000    return ndef seeded_random(seed):    s = _to_signed32(seed)    def next_val():        nonlocal s        s = _to_signed32(s + 0x6D2B79F5)        t = _imul((s ^ ((s & 0xFFFFFFFF) >> 15)), (1 | s) & 0xFFFFFFFF)        t = _to_signed32(t)        t = _to_signed32(t + _to_signed32(_imul(            (t ^ ((t & 0xFFFFFFFF) >> 7)) & 0xFFFFFFFF,            (61 | t) & 0xFFFFFFFF        )))        t = t ^ ((t & 0xFFFFFFFF) >> 14)        return (t & 0xFFFFFFFF) / 4294967296    return next_valdef pick_items(lst, count, rng):    if len(lst) <= count:        return list(lst)    copy = list(lst)    result = []    for _ in range(count):        idx = int(rng() * len(copy))        result.append(copy[idx])        del copy[idx]    return result# --- time ---TIMES = [    {'name': 'night',     'start': 0,  'end': 5},    {'name': 'dawn',      'start': 5,  'end': 8},    {'name': 'morning',   'start': 8,  'end': 12},    {'name': 'afternoon', 'start': 12, 'end': 17},    {'name': 'evening',   'start': 17, 'end': 21},    {'name': 'night',     'start': 21, 'end': 24},]def get_time_of_day(date):    h = date.hour    for t in TIMES:        if t['start'] <= h < t['end']:            return t['name']    return 'night'# --- markdown parsing ---def parse_frontmatter(text):    match = re.match(r'^---\n(.*?)\n---\n(.*)$', text, re.DOTALL)    if not match:        return {'meta': {}, 'body': text}    meta = {}    for line in match.group(1).split('\n'):        parts = line.split(':', 1)        if len(parts) == 2:            meta[parts[0].strip()] = parts[1].strip()    return {'meta': meta, 'body': match.group(2).strip()}def parse_list_items(body):    lines = body.split('\n')    bullets = []    prose = []    in_prose = False    current_prose = ''    for line in lines:        trimmed = line.strip()        if trimmed.startswith('- '):            if in_prose and current_prose:                prose.append(current_prose.strip())                current_prose = ''                in_prose = False            bullets.append(trimmed[2:])        elif trimmed == '':            if in_prose and current_prose:                prose.append(current_prose.strip())                current_prose = ''                in_prose = False        else:            in_prose = True            current_prose += (' ' if current_prose else '') + trimmed    if in_prose and current_prose:        prose.append(current_prose.strip())    return {'bullets': bullets, 'prose': prose}# --- sky calculations ---SYNODIC_MONTH = 29.53058867def _julian_day(dt_utc):    """julian day for a utc datetime (gregorian)."""    y, m = dt_utc.year, dt_utc.month    d = dt_utc.day + (dt_utc.hour + (dt_utc.minute + dt_utc.second / 60) / 60) / 24    if m <= 2:        y -= 1        m += 12    a = y // 100    b = 2 - a + a // 4    return int(365.25 * (y + 4716)) + int(30.6001 * (m + 1)) + d + b - 1524.5def _to_utc(date):    from datetime import timezone    return date.replace(tzinfo=timezone.utc) if date.tzinfo is None else date.astimezone(timezone.utc)def _moon_state(date):    """return (age_days, illuminated_fraction) using meeus's lunar series.    accurate to ~0.5° in elongation and ~0.5% in illumination."""    T = (_julian_day(_to_utc(date)) - 2451545.0) / 36525.0    D  = (297.8501921 + 445267.1114034 * T) % 360    Ms = (357.5291092 +  35999.0502909 * T) % 360  # sun's mean anomaly    Mm = (134.9633964 + 477198.8675055 * T) % 360  # moon's mean anomaly    F  = ( 93.2720950 + 483202.0175233 * T) % 360  # argument of latitude    Dr, Msr, Mmr, Fr = map(math.radians, (D, Ms, Mm, F))    # selected longitude perturbations from meeus table 47.A    dL_moon = (        6.288774 * math.sin(Mmr)        + 1.274027 * math.sin(2 * Dr - Mmr)        + 0.658314 * math.sin(2 * Dr)        + 0.213618 * math.sin(2 * Mmr)        - 0.185116 * math.sin(Msr)        - 0.114332 * math.sin(2 * Fr)        + 0.058793 * math.sin(2 * Dr - 2 * Mmr)        + 0.057066 * math.sin(2 * Dr - Msr - Mmr)        + 0.053322 * math.sin(2 * Dr + Mmr)        + 0.045758 * math.sin(2 * Dr - Msr)        - 0.040923 * math.sin(Msr - Mmr)        - 0.034720 * math.sin(Dr)        - 0.030383 * math.sin(Msr + Mmr)    )    # sun's equation of center    dL_sun = (        1.914602 * math.sin(Msr)        + 0.019993 * math.sin(2 * Msr)        + 0.000289 * math.sin(3 * Msr)    )    elong = (D + dL_moon - dL_sun) % 360    age = elong / 360.0 * SYNODIC_MONTH    illum = (1 - math.cos(math.radians(elong))) / 2    return age, illumdef moon_phase(date):    """days into the lunation cycle (0..29.53), based on true elongation."""    return _moon_state(date)[0]def moon_illumination(date):    """illuminated fraction of the moon's disc (0..1)."""    return _moon_state(date)[1]def moon_name(phase):    if phase < 1.85:        return 'new moon'    if phase < 7.38:        return 'waxing crescent'    if phase < 9.23:        return 'first quarter'    if phase < 14.77:        return 'waxing gibbous'    if phase < 16.61:        return 'full moon'    if phase < 22.15:        return 'waning gibbous'    if phase < 23.99:        return 'last quarter'    if phase < 27.68:        return 'waning crescent'    return 'new moon'def sun_times(local_date):    """return (sunrise_local_hours, sunset_local_hours, day_length_hours) for the    given local-tz date. uses noaa's spencer fourier series; accounts for    longitude, equation of time, atmospheric refraction (sun's center 0.833°    below horizon), and converts to the date's local timezone (handles dst).    accurate to ~1 minute."""    from datetime import datetime, timedelta, timezone    import calendar    year = local_date.year    doy = local_date.timetuple().tm_yday    year_len = 366 if calendar.isleap(year) else 365    gamma = (2 * math.pi / year_len) * (doy - 1)    eot = 229.18 * (        0.000075        + 0.001868 * math.cos(gamma)        - 0.032077 * math.sin(gamma)        - 0.014615 * math.cos(2 * gamma)        - 0.040849 * math.sin(2 * gamma)    )    decl = (        0.006918        - 0.399912 * math.cos(gamma)        + 0.070257 * math.sin(gamma)        - 0.006758 * math.cos(2 * gamma)        + 0.000907 * math.sin(2 * gamma)        - 0.002697 * math.cos(3 * gamma)        + 0.001480 * math.sin(3 * gamma)    )    lat_rad = math.radians(LAT)    cos_ha = (        math.cos(math.radians(90.833))        - math.sin(lat_rad) * math.sin(decl)    ) / (math.cos(lat_rad) * math.cos(decl))    cos_ha = max(-1, min(1, cos_ha))    ha = math.degrees(math.acos(cos_ha))    solar_noon_min = 720 - 4 * LON - eot    sr_min = solar_noon_min - 4 * ha    ss_min = solar_noon_min + 4 * ha    tz = local_date.tzinfo    base = datetime(year, local_date.month, local_date.day, tzinfo=timezone.utc)    sr = (base + timedelta(minutes=sr_min)).astimezone(tz)    ss = (base + timedelta(minutes=ss_min)).astimezone(tz)    sr_h = sr.hour + sr.minute / 60 + sr.second / 3600    ss_h = ss.hour + ss.minute / 60 + ss.second / 3600    return sr_h, ss_h, (ss_min - sr_min) / 60def daylight_hours(date):    return sun_times(date)[2]def format_hm(hours):    h = int(hours)    m = round((hours - h) * 60)    return f'{h}h {m}m'def format_clock(hours):    h = int(hours)    m = round((hours - h) * 60)    if m == 60:        h += 1        m = 0    suffix = 'pm' if h >= 12 else 'am'    display = h - 12 if h > 12 else (12 if h == 0 else h)    return f'{display}:{m:02d} {suffix}'MONTHS = [    'january', 'february', 'march', 'april', 'may', 'june',    'july', 'august', 'september', 'october', 'november', 'december',]ORDINALS = [    '', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth',    'seventh', 'eighth', 'ninth', 'tenth', 'eleventh', 'twelfth',    'thirteenth', 'fourteenth', 'fifteenth', 'sixteenth', 'seventeenth',    'eighteenth', 'nineteenth', 'twentieth', 'twenty-first', 'twenty-second',    'twenty-third', 'twenty-fourth', 'twenty-fifth', 'twenty-sixth',    'twenty-seventh', 'twenty-eighth', 'twenty-ninth', 'thirtieth',    'thirty-first',]def written_date(date):    time = get_time_of_day(date)    return f'{time}, the {ORDINALS[date.day]} of {MONTHS[date.month - 1]}'def sky_data_lines(now):    """three short lines: moon, sun, daylight. shown in the sky section."""    from datetime import timedelta    phase = moon_phase(now)    name = moon_name(phase)    illum = round(moon_illumination(now) * 100)    sunrise, sunset, hours = sun_times(now)    gained = (hours - sun_times(now - timedelta(days=1))[2]) * 60    sign = '+' if gained > 0 else ''    return [        f'<strong>{name}</strong>, {illum}% lit',        f'sunrise <strong>{format_clock(sunrise)}</strong> \u00b7 sunset <strong>{format_clock(sunset)}</strong>',        f'<strong>{format_hm(hours)}</strong> of daylight ({sign}{gained:.1f} minutes from yesterday)',    ]# --- data loading ---# all content lives in markdown files under data/def _parse_date(s):    """parse 'm/d' into (month, day) tuple."""    parts = s.split('/')    return (int(parts[0]), int(parts[1]))def load_seasons(data_dir):    """load season definitions from data/seasons/*.md"""    seasons_dir = os.path.join(data_dir, 'seasons')    seasons = []    for filename in sorted(os.listdir(seasons_dir)):        if not filename.endswith('.md'):            continue        with open(os.path.join(seasons_dir, filename)) as f:            parsed = parse_frontmatter(f.read())        meta = parsed['meta']        name = meta['name']        note = parsed['body'].strip()        entry = {            'name': name,            'label': meta['label'],            'start': _parse_date(meta['start']),            'end': _parse_date(meta['end']),            'note': note,        }        seasons.append(entry)        # winter has a second range (dec)        if 'start-alt' in meta:            seasons.append({                'name': name,                'label': meta['label'],                'start': _parse_date(meta['start-alt']),                'end': _parse_date(meta['end-alt']),                'note': note,            })    # sort by start month/day so lookup order is correct    seasons.sort(key=lambda s: (s['start'][0], s['start'][1]))    # build the canonical-order map after sorting so the nav reads    # winter -> early spring -> ... -> late fall instead of alphabetical    seen = {}    for s in seasons:        if s['name'] not in seen:            seen[s['name']] = s    return seasons, seendef load_haiku(data_dir):    """load haiku from data/haiku/*.md"""    haiku_dir = os.path.join(data_dir, 'haiku')    haiku = {}    for filename in os.listdir(haiku_dir):        if not filename.endswith('.md'):            continue        with open(os.path.join(haiku_dir, filename)) as f:            parsed = parse_frontmatter(f.read())        season = parsed['meta']['season']        poems = []        for block in parsed['body'].split('---'):            lines = [l.strip() for l in block.strip().split('\n') if l.strip()]            if len(lines) == 3:                poems.append(lines)        haiku[season] = poems    return haikudef load_moods(data_dir):    """load weather moods from data/moods/*.md"""    moods_dir = os.path.join(data_dir, 'moods')    moods = {}    for filename in os.listdir(moods_dir):        if not filename.endswith('.md'):            continue        with open(os.path.join(moods_dir, filename)) as f:            parsed = parse_frontmatter(f.read())        season = parsed['meta']['season']        season_moods = {}        for line in parsed['body'].split('\n'):            line = line.strip()            if line.startswith('- '):                line = line[2:]                colon = line.index(':')                time_name = line[:colon].strip()                mood_text = line[colon + 1:].strip()                season_moods[time_name] = mood_text        moods[season] = season_moods    return moodsdef load_moon_tips(data_dir):    """load moon gardening tips from data/moon-tips.md"""    path = os.path.join(data_dir, 'moon-tips.md')    with open(path) as f:        parsed = parse_frontmatter(f.read())    tips = []    for line in parsed['body'].split('\n'):        line = line.strip()        if not line.startswith('- '):            continue        line = line[2:]        colon = line.index(':')        range_str = line[:colon].strip()        tip_text = line[colon + 1:].strip()        lo, hi = range_str.split('-')        tips.append((float(lo), float(hi), tip_text))    return tipsdef load_data(data_dir):    """load manifest, markdown files, and all structured content."""    manifest_path = os.path.join(data_dir, 'manifest.json')    with open(manifest_path) as f:        manifest = json.load(f)    files = {}    for entry in manifest:        path = os.path.join(data_dir, entry['path'])        try:            with open(path) as f:                files[entry['path']] = f.read()        except FileNotFoundError:            pass    seasons, seasons_map = load_seasons(data_dir)    haiku = load_haiku(data_dir)    moods = load_moods(data_dir)    moon_tips = load_moon_tips(data_dir)    return {        'manifest': manifest,        'files': files,        'seasons': seasons,        'seasons_map': seasons_map,        'haiku': haiku,        'moods': moods,        'moon_tips': moon_tips,    }# --- season lookup ---def get_season(date, seasons):    m = date.month    d = date.day    for s in seasons:        after_start = m > s['start'][0] or (m == s['start'][0] and d >= s['start'][1])        before_end = m < s['end'][0] or (m == s['end'][0] and d <= s['end'][1])        if after_start and before_end:            return s    return seasons[0]def get_season_by_name(name, seasons_map):    return seasons_map.get(name, list(seasons_map.values())[0])def days_until_next_season(date, seasons):    from datetime import datetime    current = get_season(date, seasons)    for s in seasons:        s_date = datetime(date.year, s['start'][0], s['start'][1])        if s_date.date() > date.date() and s['name'] != current['name']:            diff = (s_date.date() - date.date()).days            return {'days': diff, 'label': s['label']}    first = seasons[0]    next_date = datetime(date.year + 1, first['start'][0], first['start'][1])    return {'days': (next_date.date() - date.date()).days, 'label': first['label']}# --- haiku lookup ---def get_haiku(season_name, date, haiku):    poems = haiku.get(season_name)    if not poems:        return None    doy = date.timetuple().tm_yday    return poems[doy % len(poems)]# --- mood lookup ---def get_weather_mood(season_name, time, moods):    season_moods = moods.get(season_name, {})    text = season_moods.get(time, '')    return render_md(text) if text else ''# --- moon garden tip lookup ---def moon_garden_tip(phase, moon_tips):    for lo, hi, tip in moon_tips:        if lo <= phase < hi:            return tip    return moon_tips[-1][2] if moon_tips else ''# --- section builders ---# each builder returns a dict: {key, title, intro, groups, lore}# - groups: [{label, items: [html, ...]}] rendered as labeled bullet lists# - lore: [html, ...] rendered as short prose paragraphsdef _read_md(path, files):    body = files.get(path)    if not body:        return None    return parse_frontmatter(body)def _section_sky(now, season, data, rng):    files = data['files']    intro = get_weather_mood(season['name'], get_time_of_day(now), data['moods'])    lore = []    tip = moon_garden_tip(moon_phase(now), data['moon_tips'])    if tip:        lore.append(render_md(tip))    for path in (f"sky/{season['name']}.md", f"storms/{season['name']}.md"):        parsed = _read_md(path, files)        if not parsed:            continue        items = parse_list_items(parsed['body'])        candidates = items['bullets'] + items['prose']        if candidates:            pick = pick_items(candidates, 1, rng)[0]            lore.append(render_md(pick))    return {        'key': 'sky',        'title': 'sky',        'intro': intro,        'groups': [{'label': '', 'items': sky_data_lines(now)}],        'lore': lore,    }def _section_garden(season, data, rng):    files = data['files']    groups = []    parsed = _read_md(f"planting/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        if items['bullets']:            picks = pick_items(items['bullets'], min(4, len(items['bullets'])), rng)            groups.append({                'label': 'in the ground now',                'items': [render_md(p) for p in picks],            })    parsed = _read_md(f"planting/{season['name']}-indoors.md", files)    if parsed:        items = parse_list_items(parsed['body'])        if items['bullets']:            picks = pick_items(items['bullets'], min(3, len(items['bullets'])), rng)            groups.append({                'label': 'starting indoors',                'items': [render_md(p) for p in picks],            })    parsed = _read_md(f"chores/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        if items['bullets']:            picks = pick_items(items['bullets'], min(2, len(items['bullets'])), rng)            groups.append({                'label': 'this week',                'items': [render_md(p) for p in picks],            })    return {'key': 'garden', 'title': 'garden', 'intro': '', 'groups': groups, 'lore': []}def _section_kitchen(season, data, rng):    files = data['files']    groups = []    parsed = _read_md(f"kitchen/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        bullets = list(items['bullets'])        if bullets:            picks = pick_items(bullets, min(4, len(bullets)), rng)            groups.append({                'label': 'in season',                'items': [render_md(p) for p in picks],            })            remaining = [b for b in bullets if b not in picks]            tonight = pick_items(remaining, 1, rng)[0] if remaining else picks[-1]            groups.append({                'label': 'tonight',                'items': [render_md(tonight)],            })    parsed = _read_md(f"preserving/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        if items['bullets']:            picks = pick_items(items['bullets'], min(2, len(items['bullets'])), rng)            groups.append({                'label': 'putting up',                'items': [render_md(p) for p in picks],            })    return {'key': 'kitchen', 'title': 'kitchen', 'intro': '', 'groups': groups, 'lore': []}def _section_foraging(season, data, rng):    files = data['files']    groups = []    lore = []    parsed = _read_md(f"foraging/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        if items['bullets']:            picks = pick_items(items['bullets'], min(4, len(items['bullets'])), rng)            groups.append({'label': '', 'items': [render_md(p) for p in picks]})        if items['prose']:            lore.append(render_md(items['prose'][0]))    return {'key': 'foraging', 'title': 'foraging', 'intro': '', 'groups': groups, 'lore': lore}def _section_folklore(season, data, rng):    files = data['files']    lore = []    parsed = _read_md(f"names/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        if items['prose']:            lore.append(render_md(items['prose'][0]))        elif items['bullets']:            picks = pick_items(items['bullets'], min(2, len(items['bullets'])), rng)            lore.append(' '.join(render_md(p) for p in picks))    parsed = _read_md(f"remedies/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        parts = []        if items['bullets']:            parts.append(render_md(pick_items(items['bullets'], 1, rng)[0]))        if items['prose']:            parts.append(render_md(items['prose'][0]))        if parts:            lore.append(' '.join(parts))    parsed = _read_md(f"bugs/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        if items['bullets']:            lore.append(render_md(pick_items(items['bullets'], 1, rng)[0]))    return {'key': 'folklore', 'title': 'folklore', 'intro': '', 'groups': [], 'lore': lore}SECTION_BUILDERS = [_section_sky, _section_garden, _section_kitchen, _section_foraging, _section_folklore]def render_sections_html(sections):    """render the section list to a single HTML string used by both    the server-side template and the JSON API response."""    parts = []    for s in sections:        if not s['groups'] and not s['lore'] and not s.get('intro'):            continue        parts.append(f'<section class="bucket bucket-{s["key"]}">')        parts.append(f'<h2>{s["title"]}</h2>')        if s.get('intro'):            parts.append(f'<p class="bucket-intro">{s["intro"]}</p>')        for g in s['groups']:            if g.get('label'):                parts.append(f'<p class="bucket-label">{g["label"]}</p>')            parts.append('<ul class="bucket-list">')            for item in g['items']:                parts.append(f'<li>{item}</li>')            parts.append('</ul>')        for line in s.get('lore', []):            parts.append(f'<p class="bucket-lore">{line}</p>')        parts.append('</section>')    return ''.join(parts)# --- content assembly ---def assemble_content(now, data, season_override=None):    """assemble the full page content for a given moment."""    seasons = data['seasons']    seasons_map = data['seasons_map']    season = get_season_by_name(season_override, seasons_map) if season_override else get_season(now, seasons)    real_time = get_time_of_day(now)    note_html = render_md_block(season['note'])    nxt = days_until_next_season(now, seasons)    if nxt['days'] <= 7:        note_html += '<p>' + nxt['label'] + ' begins in ' + str(nxt['days']) + (' day.' if nxt['days'] == 1 else ' days.') + '</p>'    haiku_lines = get_haiku(season['name'], now, data['haiku'])    haiku_html = ''    if haiku_lines:        haiku_html = ''.join(f'<span class="haiku-line">{line}</span>' for line in haiku_lines)    rng = seeded_random(day_hash(now))    sections = [build(now, season, data, rng) if build is _section_sky else build(season, data, rng)                for build in SECTION_BUILDERS]    sections_html = render_sections_html(sections)    footer_text = f"{nxt['days']} days until {nxt['label']} \u00b7 zone 7a \u00b7 north carolina"    return {        'date_line': written_date(now),        'season_name': season['label'],        'season_note': note_html,        'season_key': season['name'],        'time_key': real_time,        'haiku_html': haiku_html,        'sections_html': sections_html,        'footer_text': footer_text,        'season_nav_html': build_season_nav(season, seasons_map),    }# --- navigation ---def build_season_nav(active_season, seasons_map):    html = ''    for name, s in seasons_map.items():        cls = ' class="active" aria-current="true"' if s['name'] == active_season['name'] else ''        html += f'<a data-season="{s["name"]}"{cls}>{s["label"]}</a>'    return html
deleted app.py
@@ -1,34 +0,0 @@"""app.pythe root that holds the page together."""import osfrom datetime import datetimefrom zoneinfo import ZoneInfofrom flask import Flask, jsonify, render_template, requestfrom almanac import assemble_content, load_dataapp = Flask(__name__)DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')DATA = load_data(DATA_DIR)@app.route('/')def index():    now = datetime.now(ZoneInfo('America/New_York'))    season_override = request.args.get('season')    content = assemble_content(now, DATA, season_override=season_override)    return render_template('index.html', **content)@app.route('/api/content')def api_content():    now = datetime.now(ZoneInfo('America/New_York'))    season_override = request.args.get('season')    content = assemble_content(now, DATA, season_override=season_override)    return jsonify(content)
modified docker-compose.yml
@@ -5,5 +5,6 @@ services:    env_file: .env    ports:      - "127.0.0.1:${PORT}:${PORT}"    command: uv run gunicorn -b 0.0.0.0:${PORT} -w 2 app:app    environment:      PORT: ${PORT}    restart: unless-stopped
added frontend/bun.lock
@@ -0,0 +1,183 @@{  "lockfileVersion": 1,  "configVersion": 1,  "workspaces": {    "": {      "devDependencies": {        "sass": "^1.83.0",        "vite": "^6.3.1",      },    },  },  "packages": {    "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],    "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],    "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],    "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],    "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],    "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],    "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],    "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],    "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],    "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],    "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],    "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],    "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],    "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],    "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],    "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],    "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],    "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],    "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],    "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],    "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],    "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],    "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],    "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],    "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],    "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],    "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],    "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],    "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],    "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],    "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],    "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],    "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],    "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],    "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],    "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],    "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],    "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],    "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],    "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],    "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="],    "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="],    "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="],    "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="],    "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="],    "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="],    "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="],    "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="],    "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="],    "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="],    "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="],    "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="],    "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="],    "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="],    "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="],    "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="],    "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="],    "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="],    "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="],    "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="],    "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="],    "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="],    "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="],    "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="],    "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="],    "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],    "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],    "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],    "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],    "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],    "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],    "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="],    "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],    "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],    "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],    "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],    "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],    "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],    "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],    "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],    "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="],    "sass": ["sass@1.99.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="],    "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],    "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],    "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],  }}
added frontend/package.json
@@ -0,0 +1,12 @@{  "private": true,  "type": "module",  "scripts": {    "dev": "vite build --watch",    "build": "vite build"  },  "devDependencies": {    "sass": "^1.83.0",    "vite": "^6.3.1"  }}
added frontend/static_src/index.js
@@ -0,0 +1,5 @@// scriptsimport "./scripts/almanac.js";// stylesimport "./styles/base.scss";
renamed static/favicon.svg → frontend/static_src/public/favicon.svg
renamed static/fonts/fraunces-latin-full-italic.woff2 → frontend/static_src/public/fonts/fraunces-latin-full-italic.woff2
renamed static/fonts/fraunces-latin-full-normal.woff2 → frontend/static_src/public/fonts/fraunces-latin-full-normal.woff2
renamed static/fonts/jetbrains-mono-latin-wght-normal.woff2 → frontend/static_src/public/fonts/jetbrains-mono-latin-wght-normal.woff2
renamed static/og.svg → frontend/static_src/public/og.svg
renamed static/sw.js → frontend/static_src/public/sw.js
renamed static/almanac.js → frontend/static_src/scripts/almanac.js
renamed static/style.css → frontend/static_src/styles/base.scss
added frontend/vite.config.js
@@ -0,0 +1,35 @@import { resolve } from "path";import { defineConfig } from "vite";export default defineConfig({  root: "static_src",  base: "/static/",  publicDir: "public",  build: {    outDir: resolve(__dirname, "../dist"),    emptyOutDir: true,    manifest: true,    rollupOptions: {      input: resolve(__dirname, "static_src/index.js"),      output: {        entryFileNames: "base-[hash].js",        assetFileNames: (assetInfo) => {          if (/\.(woff2?|eot|ttf|otf)$/.test(assetInfo.name)) {            return "fonts/[name][extname]";          }          if (/\.css$/.test(assetInfo.name)) {            return "base-[hash].css";          }          return "assets/[name]-[hash][extname]";        },      },    },  },  css: {    preprocessorOptions: {      scss: {        quietDeps: true,      },    },  },});
deleted pyproject.toml
@@ -1,9 +0,0 @@[project]name = "darkfurrow"version = "0.1.0"requires-python = ">=3.13"dependencies = [    "flask>=3.1",    "gunicorn>=23.0",    "mistune>=3.1",]
added samplefiles/Caddyfile.sample
@@ -0,0 +1,35 @@# Caddyfile for dark furrow## I like to use Caddy Server for simple reverse proxies since it has built in# automatic HTTPS support.{  servers {    protocol {      experimental_http3    }  }}(common) {  header /static/* {    Cache-Control "public, max-age=315360000"  }  header /* {    Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"    X-XSS-Protection "1; mode=block"    X-Frame-Options DENY    X-Content-Type-Options nosniff    -Server    -X-Powered-By  }  encode zstd gzip}darkfurrow.example.com {  reverse_proxy localhost:8500  import common}
added src/almanac.rs
@@ -0,0 +1,452 @@use chrono::{DateTime, Datelike, NaiveDate, Timelike};use chrono_tz::Tz;use crate::astro::{moon_phase, sky_data_lines};use crate::content::{parse_frontmatter, parse_list_items, ListItems, MoonTip, Season, SiteData};use crate::markdown::{render_block, render_inline};use crate::rng::{day_hash, pick_items, Mulberry32};pub struct Assembled {    pub date_line: String,    pub season_name: String,    pub season_note: String,    pub season_key: String,    pub time_key: String,    pub haiku_html: String,    pub sections_html: String,    pub footer_text: String,    pub season_nav_html: String,}struct Time {    name: &'static str,    start: u32,    end: u32,}const TIMES: &[Time] = &[    Time { name: "night", start: 0, end: 5 },    Time { name: "dawn", start: 5, end: 8 },    Time { name: "morning", start: 8, end: 12 },    Time { name: "afternoon", start: 12, end: 17 },    Time { name: "evening", start: 17, end: 21 },    Time { name: "night", start: 21, end: 24 },];fn time_of_day(date: DateTime<Tz>) -> &'static str {    let h = date.hour();    for t in TIMES {        if h >= t.start && h < t.end {            return t.name;        }    }    "night"}const MONTHS: [&str; 12] = [    "january", "february", "march", "april", "may", "june", "july", "august", "september",    "october", "november", "december",];const ORDINALS: [&str; 32] = [    "", "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth",    "tenth", "eleventh", "twelfth", "thirteenth", "fourteenth", "fifteenth", "sixteenth",    "seventeenth", "eighteenth", "nineteenth", "twentieth", "twenty-first", "twenty-second",    "twenty-third", "twenty-fourth", "twenty-fifth", "twenty-sixth", "twenty-seventh",    "twenty-eighth", "twenty-ninth", "thirtieth", "thirty-first",];fn written_date(date: DateTime<Tz>) -> String {    let time = time_of_day(date);    let day = date.day() as usize;    let month = (date.month() - 1) as usize;    format!("{time}, the {} of {}", ORDINALS[day], MONTHS[month])}fn get_season_for_date(date: DateTime<Tz>, seasons: &[Season]) -> &Season {    let m = date.month();    let d = date.day();    for s in seasons {        let after_start = m > s.start.0 || (m == s.start.0 && d >= s.start.1);        let before_end = m < s.end.0 || (m == s.end.0 && d <= s.end.1);        if after_start && before_end {            return s;        }    }    &seasons[0]}struct NextSeason {    days: i64,    label: String,}fn days_until_next_season(date: DateTime<Tz>, seasons: &[Season]) -> NextSeason {    let current = get_season_for_date(date, seasons);    let today = date.date_naive();    for s in seasons {        let s_date = NaiveDate::from_ymd_opt(date.year(), s.start.0, s.start.1).unwrap();        if s_date > today && s.name != current.name {            return NextSeason {                days: (s_date - today).num_days(),                label: s.label.clone(),            };        }    }    let first = &seasons[0];    let next_date = NaiveDate::from_ymd_opt(date.year() + 1, first.start.0, first.start.1).unwrap();    NextSeason {        days: (next_date - today).num_days(),        label: first.label.clone(),    }}fn moon_garden_tip(phase: f64, tips: &[MoonTip]) -> String {    for t in tips {        if t.lo <= phase && phase < t.hi {            return t.text.clone();        }    }    tips.last().map(|t| t.text.clone()).unwrap_or_default()}fn weather_mood(season_name: &str, time: &str, moods: &std::collections::HashMap<String, std::collections::HashMap<String, String>>) -> String {    if let Some(season_moods) = moods.get(season_name) {        if let Some(text) = season_moods.get(time) {            if !text.is_empty() {                return render_inline(text);            }        }    }    String::new()}fn read_md_parts<'a>(path: &str, files: &'a std::collections::HashMap<String, String>) -> Option<ListItems> {    let body = files.get(path)?;    let parsed = parse_frontmatter(body);    Some(parse_list_items(&parsed.body))}#[derive(Debug)]struct Group {    label: &'static str,    items: Vec<String>,}#[derive(Debug)]struct Section {    key: &'static str,    title: &'static str,    intro: String,    groups: Vec<Group>,    lore: Vec<String>,}fn section_sky(now: DateTime<Tz>, season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {    let intro = weather_mood(&season.name, time_of_day(now), &data.moods);    let mut lore = Vec::new();    let tip = moon_garden_tip(moon_phase(now), &data.moon_tips);    if !tip.is_empty() {        lore.push(render_inline(&tip));    }    for path in [        format!("sky/{}.md", season.name),        format!("storms/{}.md", season.name),    ] {        let Some(items) = read_md_parts(&path, &data.files) else {            continue;        };        let mut candidates: Vec<String> = items.bullets.clone();        candidates.extend(items.prose.clone());        if !candidates.is_empty() {            let pick = pick_items(&candidates, 1, rng).remove(0);            lore.push(render_inline(&pick));        }    }    Section {        key: "sky",        title: "sky",        intro,        groups: vec![Group {            label: "",            items: sky_data_lines(now),        }],        lore,    }}fn section_garden(season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {    let mut groups = Vec::new();    if let Some(items) = read_md_parts(&format!("planting/{}.md", season.name), &data.files) {        if !items.bullets.is_empty() {            let n = items.bullets.len().min(4);            let picks = pick_items(&items.bullets, n, rng);            groups.push(Group {                label: "in the ground now",                items: picks.iter().map(|s| render_inline(s)).collect(),            });        }    }    if let Some(items) = read_md_parts(        &format!("planting/{}-indoors.md", season.name),        &data.files,    ) {        if !items.bullets.is_empty() {            let n = items.bullets.len().min(3);            let picks = pick_items(&items.bullets, n, rng);            groups.push(Group {                label: "starting indoors",                items: picks.iter().map(|s| render_inline(s)).collect(),            });        }    }    if let Some(items) = read_md_parts(&format!("chores/{}.md", season.name), &data.files) {        if !items.bullets.is_empty() {            let n = items.bullets.len().min(2);            let picks = pick_items(&items.bullets, n, rng);            groups.push(Group {                label: "this week",                items: picks.iter().map(|s| render_inline(s)).collect(),            });        }    }    Section {        key: "garden",        title: "garden",        intro: String::new(),        groups,        lore: Vec::new(),    }}fn section_kitchen(season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {    let mut groups = Vec::new();    if let Some(items) = read_md_parts(&format!("kitchen/{}.md", season.name), &data.files) {        if !items.bullets.is_empty() {            let bullets = items.bullets.clone();            let n = bullets.len().min(4);            let picks = pick_items(&bullets, n, rng);            groups.push(Group {                label: "in season",                items: picks.iter().map(|s| render_inline(s)).collect(),            });            let remaining: Vec<String> = bullets                .iter()                .filter(|b| !picks.contains(b))                .cloned()                .collect();            let tonight = if remaining.is_empty() {                picks.last().cloned().unwrap_or_default()            } else {                pick_items(&remaining, 1, rng).remove(0)            };            groups.push(Group {                label: "tonight",                items: vec![render_inline(&tonight)],            });        }    }    if let Some(items) = read_md_parts(&format!("preserving/{}.md", season.name), &data.files) {        if !items.bullets.is_empty() {            let n = items.bullets.len().min(2);            let picks = pick_items(&items.bullets, n, rng);            groups.push(Group {                label: "putting up",                items: picks.iter().map(|s| render_inline(s)).collect(),            });        }    }    Section {        key: "kitchen",        title: "kitchen",        intro: String::new(),        groups,        lore: Vec::new(),    }}fn section_foraging(season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {    let mut groups = Vec::new();    let mut lore = Vec::new();    if let Some(items) = read_md_parts(&format!("foraging/{}.md", season.name), &data.files) {        if !items.bullets.is_empty() {            let n = items.bullets.len().min(4);            let picks = pick_items(&items.bullets, n, rng);            groups.push(Group {                label: "",                items: picks.iter().map(|s| render_inline(s)).collect(),            });        }        if let Some(first) = items.prose.first() {            lore.push(render_inline(first));        }    }    Section {        key: "foraging",        title: "foraging",        intro: String::new(),        groups,        lore,    }}fn section_folklore(season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {    let mut lore = Vec::new();    if let Some(items) = read_md_parts(&format!("names/{}.md", season.name), &data.files) {        if let Some(first) = items.prose.first() {            lore.push(render_inline(first));        } else if !items.bullets.is_empty() {            let n = items.bullets.len().min(2);            let picks = pick_items(&items.bullets, n, rng);            let joined: Vec<String> = picks.iter().map(|s| render_inline(s)).collect();            lore.push(joined.join(" "));        }    }    if let Some(items) = read_md_parts(&format!("remedies/{}.md", season.name), &data.files) {        let mut parts = Vec::new();        if !items.bullets.is_empty() {            let pick = pick_items(&items.bullets, 1, rng).remove(0);            parts.push(render_inline(&pick));        }        if let Some(first) = items.prose.first() {            parts.push(render_inline(first));        }        if !parts.is_empty() {            lore.push(parts.join(" "));        }    }    if let Some(items) = read_md_parts(&format!("bugs/{}.md", season.name), &data.files) {        if !items.bullets.is_empty() {            let pick = pick_items(&items.bullets, 1, rng).remove(0);            lore.push(render_inline(&pick));        }    }    Section {        key: "folklore",        title: "folklore",        intro: String::new(),        groups: Vec::new(),        lore,    }}fn render_sections_html(sections: &[Section]) -> String {    let mut out = String::new();    for s in sections {        if s.groups.is_empty() && s.lore.is_empty() && s.intro.is_empty() {            continue;        }        out.push_str(&format!("<section class=\"bucket bucket-{}\">", s.key));        out.push_str(&format!("<h2>{}</h2>", s.title));        if !s.intro.is_empty() {            out.push_str(&format!("<p class=\"bucket-intro\">{}</p>", s.intro));        }        for g in &s.groups {            if !g.label.is_empty() {                out.push_str(&format!("<p class=\"bucket-label\">{}</p>", g.label));            }            out.push_str("<ul class=\"bucket-list\">");            for item in &g.items {                out.push_str(&format!("<li>{item}</li>"));            }            out.push_str("</ul>");        }        for line in &s.lore {            out.push_str(&format!("<p class=\"bucket-lore\">{line}</p>"));        }        out.push_str("</section>");    }    out}fn build_season_nav(active: &Season, data: &SiteData) -> String {    let mut out = String::new();    for name in &data.seasons_order {        let s = &data.seasons_by_name[name];        let cls = if s.name == active.name {            " class=\"active\" aria-current=\"true\""        } else {            ""        };        out.push_str(&format!(            "<a data-season=\"{}\"{cls}>{}</a>",            s.name, s.label        ));    }    out}fn get_haiku<'a>(season_name: &str, date: DateTime<Tz>, data: &'a SiteData) -> Option<&'a [String; 3]> {    let poems = data.haiku.get(season_name)?;    if poems.is_empty() {        return None;    }    let doy = date.ordinal() as usize;    Some(&poems[doy % poems.len()])}pub fn assemble_content(now: DateTime<Tz>, data: &SiteData, season_override: Option<&str>) -> Assembled {    let season = match season_override.and_then(|n| data.seasons_by_name.get(n)) {        Some(s) => s.clone(),        None => get_season_for_date(now, &data.seasons).clone(),    };    let real_time = time_of_day(now);    let mut note_html = render_block(&season.note);    let nxt = days_until_next_season(now, &data.seasons);    if nxt.days <= 7 {        let unit = if nxt.days == 1 { "day" } else { "days" };        note_html.push_str(&format!("<p>{} begins in {} {unit}.</p>", nxt.label, nxt.days));    }    let haiku_html = match get_haiku(&season.name, now, data) {        Some(lines) => lines            .iter()            .map(|l| format!("<span class=\"haiku-line\">{l}</span>"))            .collect::<Vec<_>>()            .join(""),        None => String::new(),    };    let mut rng = Mulberry32::new(day_hash(now));    let sky = section_sky(now, &season, data, &mut rng);    let garden = section_garden(&season, data, &mut rng);    let kitchen = section_kitchen(&season, data, &mut rng);    let foraging = section_foraging(&season, data, &mut rng);    let folklore = section_folklore(&season, data, &mut rng);    let sections = vec![sky, garden, kitchen, foraging, folklore];    let sections_html = render_sections_html(&sections);    let footer_text = format!(        "{} days until {} \u{00b7} zone 7a \u{00b7} north carolina",        nxt.days, nxt.label    );    Assembled {        date_line: written_date(now),        season_name: season.label.clone(),        season_note: note_html,        season_key: season.name.clone(),        time_key: real_time.to_string(),        haiku_html,        sections_html,        footer_text,        season_nav_html: build_season_nav(&season, data),    }}
added src/astro.rs
@@ -0,0 +1,216 @@use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc};use chrono_tz::Tz;const LAT: f64 = 35.78;const LON: f64 = -78.64;const SYNODIC_MONTH: f64 = 29.53058867;fn julian_day(dt_utc: DateTime<Utc>) -> f64 {    let mut y = dt_utc.year();    let mut m = dt_utc.month() as i32;    let d = dt_utc.day() as f64        + (dt_utc.hour() as f64            + (dt_utc.minute() as f64 + dt_utc.second() as f64 / 60.0) / 60.0)            / 24.0;    if m <= 2 {        y -= 1;        m += 12;    }    let a = (y as f64 / 100.0).floor() as i32;    let b = 2 - a + (a as f64 / 4.0).floor() as i32;    (365.25 * (y as f64 + 4716.0)).floor()        + (30.6001 * (m as f64 + 1.0)).floor()        + d        + b as f64        - 1524.5}/// (age in days, illumination 0..1)fn moon_state(date: DateTime<Tz>) -> (f64, f64) {    let dt_utc = date.with_timezone(&Utc);    let t = (julian_day(dt_utc) - 2451545.0) / 36525.0;    let d = (297.8501921_f64 + 445267.1114034 * t).rem_euclid(360.0);    let ms = (357.5291092_f64 + 35999.0502909 * t).rem_euclid(360.0);    let mm = (134.9633964_f64 + 477198.8675055 * t).rem_euclid(360.0);    let f = (93.2720950_f64 + 483202.0175233 * t).rem_euclid(360.0);    let dr = d.to_radians();    let msr = ms.to_radians();    let mmr = mm.to_radians();    let fr = f.to_radians();    let dl_moon = 6.288774 * mmr.sin()        + 1.274027 * (2.0 * dr - mmr).sin()        + 0.658314 * (2.0 * dr).sin()        + 0.213618 * (2.0 * mmr).sin()        - 0.185116 * msr.sin()        - 0.114332 * (2.0 * fr).sin()        + 0.058793 * (2.0 * dr - 2.0 * mmr).sin()        + 0.057066 * (2.0 * dr - msr - mmr).sin()        + 0.053322 * (2.0 * dr + mmr).sin()        + 0.045758 * (2.0 * dr - msr).sin()        - 0.040923 * (msr - mmr).sin()        - 0.034720 * dr.sin()        - 0.030383 * (msr + mmr).sin();    let dl_sun = 1.914602 * msr.sin()        + 0.019993 * (2.0 * msr).sin()        + 0.000289 * (3.0 * msr).sin();    let elong = (d + dl_moon - dl_sun).rem_euclid(360.0);    let age = elong / 360.0 * SYNODIC_MONTH;    let illum = (1.0 - elong.to_radians().cos()) / 2.0;    (age, illum)}pub fn moon_phase(date: DateTime<Tz>) -> f64 {    moon_state(date).0}pub fn moon_illumination(date: DateTime<Tz>) -> f64 {    moon_state(date).1}pub fn moon_name(phase: f64) -> &'static str {    if phase < 1.85 {        "new moon"    } else if phase < 7.38 {        "waxing crescent"    } else if phase < 9.23 {        "first quarter"    } else if phase < 14.77 {        "waxing gibbous"    } else if phase < 16.61 {        "full moon"    } else if phase < 22.15 {        "waning gibbous"    } else if phase < 23.99 {        "last quarter"    } else if phase < 27.68 {        "waning crescent"    } else {        "new moon"    }}fn is_leap(year: i32) -> bool {    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0}/// (sunrise local hours, sunset local hours, day length hours)./// uses NOAA Spencer fourier series; accounts for longitude, equation of/// time, atmospheric refraction; converts to local-tz of `local_date`.pub fn sun_times(local_date: DateTime<Tz>) -> (f64, f64, f64) {    let year = local_date.year();    let doy = local_date.ordinal() as i32;    let year_len = if is_leap(year) { 366.0 } else { 365.0 };    let gamma = (2.0 * std::f64::consts::PI / year_len) * (doy as f64 - 1.0);    let eot = 229.18        * (0.000075 + 0.001868 * gamma.cos()            - 0.032077 * gamma.sin()            - 0.014615 * (2.0 * gamma).cos()            - 0.040849 * (2.0 * gamma).sin());    let decl = 0.006918 - 0.399912 * gamma.cos()        + 0.070257 * gamma.sin()        - 0.006758 * (2.0 * gamma).cos()        + 0.000907 * (2.0 * gamma).sin()        - 0.002697 * (3.0 * gamma).cos()        + 0.001480 * (3.0 * gamma).sin();    let lat_rad = LAT.to_radians();    let mut cos_ha = (90.833_f64.to_radians().cos() - lat_rad.sin() * decl.sin())        / (lat_rad.cos() * decl.cos());    cos_ha = cos_ha.clamp(-1.0, 1.0);    let ha = cos_ha.acos().to_degrees();    let solar_noon_min = 720.0 - 4.0 * LON - eot;    let sr_min = solar_noon_min - 4.0 * ha;    let ss_min = solar_noon_min + 4.0 * ha;    let tz = local_date.timezone();    let base = Utc        .with_ymd_and_hms(year, local_date.month(), local_date.day(), 0, 0, 0)        .unwrap();    let sr = (base + Duration::nanoseconds((sr_min * 60.0 * 1e9) as i64)).with_timezone(&tz);    let ss = (base + Duration::nanoseconds((ss_min * 60.0 * 1e9) as i64)).with_timezone(&tz);    let sr_h = sr.hour() as f64 + sr.minute() as f64 / 60.0 + sr.second() as f64 / 3600.0;    let ss_h = ss.hour() as f64 + ss.minute() as f64 / 60.0 + ss.second() as f64 / 3600.0;    (sr_h, ss_h, (ss_min - sr_min) / 60.0)}pub fn format_hm(hours: f64) -> String {    let h = hours.trunc() as i64;    let m = ((hours - h as f64) * 60.0).round() as i64;    format!("{h}h {m}m")}pub fn format_clock(hours: f64) -> String {    let mut h = hours.trunc() as i64;    let mut m = ((hours - h as f64) * 60.0).round() as i64;    if m == 60 {        h += 1;        m = 0;    }    let suffix = if h >= 12 { "pm" } else { "am" };    let display = if h > 12 {        h - 12    } else if h == 0 {        12    } else {        h    };    format!("{display}:{m:02} {suffix}")}pub fn sky_data_lines(now: DateTime<Tz>) -> Vec<String> {    let phase = moon_phase(now);    let name = moon_name(phase);    let illum = (moon_illumination(now) * 100.0).round() as i64;    let (sunrise, sunset, hours) = sun_times(now);    let yesterday = now - Duration::days(1);    let gained = (hours - sun_times(yesterday).2) * 60.0;    let sign = if gained > 0.0 { "+" } else { "" };    vec![        format!("<strong>{name}</strong>, {illum}% lit"),        format!(            "sunrise <strong>{}</strong> \u{00b7} sunset <strong>{}</strong>",            format_clock(sunrise),            format_clock(sunset)        ),        format!(            "<strong>{}</strong> of daylight ({sign}{:.1} minutes from yesterday)",            format_hm(hours),            gained        ),    ]}#[cfg(test)]mod tests {    use super::*;    use chrono_tz::America::New_York;    fn fixed_now() -> DateTime<Tz> {        New_York.with_ymd_and_hms(2026, 5, 6, 19, 0, 0).unwrap()    }    #[test]    fn moon_matches_python() {        let now = fixed_now();        let phase = moon_phase(now);        let illum = moon_illumination(now);        assert!((phase - 19.474334776875626).abs() < 1e-9, "phase={phase}");        assert!((illum - 0.7693359060289093).abs() < 1e-9, "illum={illum}");        assert_eq!(moon_name(phase), "waning gibbous");    }    #[test]    fn sun_matches_python() {        let now = fixed_now();        let (sr, ss, hours) = sun_times(now);        assert!((sr - 6.298611111111111).abs() < 1e-6, "sr={sr}");        assert!((ss - 20.067777777777778).abs() < 1e-6, "ss={ss}");        assert!((hours - 13.768966853824192).abs() < 1e-9, "hours={hours}");    }}
added src/content.rs
@@ -0,0 +1,291 @@use anyhow::{Context, Result};use serde::Deserialize;use std::collections::HashMap;use std::path::Path;#[derive(Debug, Clone)]pub struct Frontmatter {    pub meta: HashMap<String, String>,    pub body: String,}#[derive(Debug, Default)]pub struct ListItems {    pub bullets: Vec<String>,    pub prose: Vec<String>,}pub fn parse_frontmatter(text: &str) -> Frontmatter {    if let Some(rest) = text.strip_prefix("---\n") {        if let Some(end) = rest.find("\n---\n") {            let meta_str = &rest[..end];            let body = &rest[end + 5..];            let mut meta = HashMap::new();            for line in meta_str.split('\n') {                if let Some((k, v)) = line.split_once(':') {                    meta.insert(k.trim().to_string(), v.trim().to_string());                }            }            return Frontmatter {                meta,                body: body.trim().to_string(),            };        }    }    Frontmatter {        meta: HashMap::new(),        body: text.to_string(),    }}/// Walk lines, splitting `- ` bullets from free-form prose paragraphs./// Mirrors python `parse_list_items` exactly.pub fn parse_list_items(body: &str) -> ListItems {    let mut bullets = Vec::new();    let mut prose = Vec::new();    let mut in_prose = false;    let mut current = String::new();    for line in body.split('\n') {        let trimmed = line.trim();        if let Some(rest) = trimmed.strip_prefix("- ") {            if in_prose && !current.is_empty() {                prose.push(std::mem::take(&mut current).trim().to_string());                in_prose = false;            }            bullets.push(rest.to_string());        } else if trimmed.is_empty() {            if in_prose && !current.is_empty() {                prose.push(std::mem::take(&mut current).trim().to_string());                in_prose = false;            }        } else {            in_prose = true;            if !current.is_empty() {                current.push(' ');            }            current.push_str(trimmed);        }    }    if in_prose && !current.is_empty() {        prose.push(current.trim().to_string());    }    ListItems { bullets, prose }}#[derive(Debug, Clone)]pub struct Season {    pub name: String,    pub label: String,    pub start: (u32, u32),    pub end: (u32, u32),    pub note: String,}#[derive(Debug, Clone)]pub struct MoonTip {    pub lo: f64,    pub hi: f64,    pub text: String,}#[derive(Deserialize, Debug)]pub struct ManifestEntry {    pub path: String,}pub struct SiteData {    pub files: HashMap<String, String>,    pub seasons: Vec<Season>,    /// canonical-order map (insertion order preserved) so the nav reads    /// winter -> early spring -> ... -> late fall.    pub seasons_order: Vec<String>,    pub seasons_by_name: HashMap<String, Season>,    pub haiku: HashMap<String, Vec<[String; 3]>>,    pub moods: HashMap<String, HashMap<String, String>>,    pub moon_tips: Vec<MoonTip>,}fn parse_md_date(s: &str) -> Result<(u32, u32)> {    let (m, d) = s.split_once('/').context("expected m/d")?;    Ok((m.parse()?, d.parse()?))}pub fn load_data(data_dir: &Path) -> Result<SiteData> {    let manifest_path = data_dir.join("manifest.json");    let manifest_text = std::fs::read_to_string(&manifest_path)        .with_context(|| format!("read manifest: {manifest_path:?}"))?;    let manifest: Vec<ManifestEntry> = serde_json::from_str(&manifest_text)?;    let mut files = HashMap::new();    for entry in &manifest {        let p = data_dir.join(&entry.path);        if let Ok(text) = std::fs::read_to_string(&p) {            files.insert(entry.path.clone(), text);        }    }    let (seasons, seasons_order, seasons_by_name) = load_seasons(data_dir)?;    let haiku = load_haiku(data_dir)?;    let moods = load_moods(data_dir)?;    let moon_tips = load_moon_tips(data_dir)?;    Ok(SiteData {        files,        seasons,        seasons_order,        seasons_by_name,        haiku,        moods,        moon_tips,    })}fn load_seasons(    data_dir: &Path,) -> Result<(Vec<Season>, Vec<String>, HashMap<String, Season>)> {    let dir = data_dir.join("seasons");    let mut entries: Vec<_> = std::fs::read_dir(&dir)?        .filter_map(Result::ok)        .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("md"))        .collect();    entries.sort_by_key(|e| e.file_name());    let mut seasons: Vec<Season> = Vec::new();    for entry in entries {        let text = std::fs::read_to_string(entry.path())?;        let parsed = parse_frontmatter(&text);        let name = parsed            .meta            .get("name")            .cloned()            .context("season missing name")?;        let label = parsed            .meta            .get("label")            .cloned()            .context("season missing label")?;        let start = parse_md_date(parsed.meta.get("start").context("season missing start")?)?;        let end = parse_md_date(parsed.meta.get("end").context("season missing end")?)?;        let note = parsed.body.trim().to_string();        seasons.push(Season {            name: name.clone(),            label: label.clone(),            start,            end,            note: note.clone(),        });        if let (Some(sa), Some(ea)) = (parsed.meta.get("start-alt"), parsed.meta.get("end-alt")) {            seasons.push(Season {                name,                label,                start: parse_md_date(sa)?,                end: parse_md_date(ea)?,                note,            });        }    }    seasons.sort_by_key(|s| (s.start.0, s.start.1));    let mut seasons_order = Vec::new();    let mut seasons_by_name: HashMap<String, Season> = HashMap::new();    for s in &seasons {        if !seasons_by_name.contains_key(&s.name) {            seasons_order.push(s.name.clone());            seasons_by_name.insert(s.name.clone(), s.clone());        }    }    Ok((seasons, seasons_order, seasons_by_name))}fn load_haiku(data_dir: &Path) -> Result<HashMap<String, Vec<[String; 3]>>> {    let dir = data_dir.join("haiku");    let mut out: HashMap<String, Vec<[String; 3]>> = HashMap::new();    for entry in std::fs::read_dir(&dir)? {        let entry = entry?;        if entry.path().extension().and_then(|s| s.to_str()) != Some("md") {            continue;        }        let text = std::fs::read_to_string(entry.path())?;        let parsed = parse_frontmatter(&text);        let season = parsed            .meta            .get("season")            .cloned()            .context("haiku missing season")?;        let mut poems = Vec::new();        for block in parsed.body.split("---") {            let lines: Vec<String> = block                .trim()                .split('\n')                .map(|l| l.trim().to_string())                .filter(|l| !l.is_empty())                .collect();            if lines.len() == 3 {                poems.push([lines[0].clone(), lines[1].clone(), lines[2].clone()]);            }        }        out.insert(season, poems);    }    Ok(out)}fn load_moods(data_dir: &Path) -> Result<HashMap<String, HashMap<String, String>>> {    let dir = data_dir.join("moods");    let mut out: HashMap<String, HashMap<String, String>> = HashMap::new();    for entry in std::fs::read_dir(&dir)? {        let entry = entry?;        if entry.path().extension().and_then(|s| s.to_str()) != Some("md") {            continue;        }        let text = std::fs::read_to_string(entry.path())?;        let parsed = parse_frontmatter(&text);        let season = parsed            .meta            .get("season")            .cloned()            .context("mood missing season")?;        let mut by_time = HashMap::new();        for line in parsed.body.split('\n') {            let line = line.trim();            if let Some(rest) = line.strip_prefix("- ") {                if let Some((time, mood)) = rest.split_once(':') {                    by_time.insert(time.trim().to_string(), mood.trim().to_string());                }            }        }        out.insert(season, by_time);    }    Ok(out)}fn load_moon_tips(data_dir: &Path) -> Result<Vec<MoonTip>> {    let path = data_dir.join("moon-tips.md");    let text = std::fs::read_to_string(&path)?;    let parsed = parse_frontmatter(&text);    let mut tips = Vec::new();    for line in parsed.body.split('\n') {        let line = line.trim();        let Some(rest) = line.strip_prefix("- ") else {            continue;        };        let Some((range_str, tip_text)) = rest.split_once(':') else {            continue;        };        let Some((lo, hi)) = range_str.trim().split_once('-') else {            continue;        };        tips.push(MoonTip {            lo: lo.parse()?,            hi: hi.parse()?,            text: tip_text.trim().to_string(),        });    }    Ok(tips)}
added src/main.rs
@@ -0,0 +1,160 @@mod almanac;mod astro;mod content;mod markdown;mod rng;mod templates;use axum::{    extract::{Query, Request, State},    http::{header, StatusCode},    middleware::{self, Next},    response::{Html, IntoResponse, Json, Response},    routing::get,    Router,};use chrono::Local;use chrono_tz::America::New_York;use minijinja::{context, Environment};use serde::Deserialize;use std::net::SocketAddr;use std::path::PathBuf;use std::sync::Arc;use std::time::Instant;use tower_http::services::ServeDir;use tower_http::set_header::SetResponseHeaderLayer;use content::SiteData;#[derive(Clone)]struct AppState {    env: Arc<Environment<'static>>,    data: Arc<SiteData>,}#[derive(Deserialize)]struct ContentQuery {    #[serde(default)]    season: Option<String>,}#[tokio::main]async fn main() {    let project_root: PathBuf = std::env::var("DARKFURROW_ROOT")        .map(PathBuf::from)        .unwrap_or_else(|_| PathBuf::from("."));    let templates_dir = project_root.join("templates");    let dist_dir = project_root.join("dist");    let data_dir = project_root.join("data");    let manifest_path = dist_dir.join(".vite/manifest.json");    let env = templates::build_env(&templates_dir, &manifest_path);    let data = content::load_data(&data_dir).expect("failed to load data");    let port: u16 = std::env::var("PORT")        .ok()        .and_then(|v| v.parse().ok())        .unwrap_or(8000);    let state = AppState {        env: Arc::new(env),        data: Arc::new(data),    };    let app = Router::new()        .route("/", get(index))        .route("/api/content", get(api_content))        .nest_service(            "/static",            tower::ServiceBuilder::new()                .layer(SetResponseHeaderLayer::if_not_present(                    header::CACHE_CONTROL,                    header::HeaderValue::from_static("public, max-age=31536000"),                ))                .service(ServeDir::new(&dist_dir)),        )        .layer(middleware::from_fn(log_requests))        .with_state(state);    let addr = SocketAddr::from(([0, 0, 0, 0], port));    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();    eprintln!("darkfurrow listening on http://{addr}");    axum::serve(listener, app).await.unwrap();}async fn log_requests(req: Request, next: Next) -> Response {    let method = req.method().clone();    let path = req        .uri()        .path_and_query()        .map(|p| p.as_str().to_string())        .unwrap_or_else(|| req.uri().path().to_string());    let start = Instant::now();    let response = next.run(req).await;    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;    let status = response.status().as_u16();    let now = Local::now().format("%H:%M:%S");    let color = match status {        200..=299 => "\x1b[32m",        300..=399 => "\x1b[36m",        400..=499 => "\x1b[33m",        _ => "\x1b[31m",    };    eprintln!("{now} {method:<5} {color}{status}\x1b[0m {elapsed_ms:>7.2}ms  {path}");    response}struct AppError(StatusCode, String);impl<E: std::fmt::Display> From<E> for AppError {    fn from(e: E) -> Self {        AppError(StatusCode::INTERNAL_SERVER_ERROR, format!("internal error: {e}"))    }}impl IntoResponse for AppError {    fn into_response(self) -> Response {        (self.0, self.1).into_response()    }}async fn index(    State(state): State<AppState>,    Query(q): Query<ContentQuery>,) -> Result<Html<String>, AppError> {    let now = chrono::Utc::now().with_timezone(&New_York);    let content = almanac::assemble_content(now, &state.data, q.season.as_deref());    let tmpl = state.env.get_template("index.html")?;    let body = tmpl.render(context! {        date_line => content.date_line,        season_name => content.season_name,        season_note => content.season_note,        season_key => content.season_key,        time_key => content.time_key,        haiku_html => content.haiku_html,        sections_html => content.sections_html,        footer_text => content.footer_text,        season_nav_html => content.season_nav_html,    })?;    Ok(Html(body))}async fn api_content(    State(state): State<AppState>,    Query(q): Query<ContentQuery>,) -> Result<Json<serde_json::Value>, AppError> {    let now = chrono::Utc::now().with_timezone(&New_York);    let content = almanac::assemble_content(now, &state.data, q.season.as_deref());    Ok(Json(serde_json::json!({        "date_line": content.date_line,        "season_name": content.season_name,        "season_note": content.season_note,        "season_key": content.season_key,        "time_key": content.time_key,        "haiku_html": content.haiku_html,        "sections_html": content.sections_html,        "footer_text": content.footer_text,        "season_nav_html": content.season_nav_html,    })))}
added src/markdown.rs
@@ -0,0 +1,51 @@use comrak::{markdown_to_html, ComrakOptions};fn options() -> ComrakOptions {    let mut opts = ComrakOptions::default();    opts.render.unsafe_ = true;    opts}/// render markdown, then strip the single wrapping `<p>...</p>` if there's/// exactly one. matches `render_md` in the original almanac.py.pub fn render_inline(text: &str) -> String {    let html = markdown_to_html(text, &options()).trim().to_string();    if html.starts_with("<p>") && html.ends_with("</p>") && count_substr(&html, "<p>") == 1 {        html[3..html.len() - 4].to_string()    } else {        html    }}/// render markdown keeping block-level tags. matches `render_md_block`.pub fn render_block(text: &str) -> String {    markdown_to_html(text, &options()).trim().to_string()}fn count_substr(s: &str, needle: &str) -> usize {    s.matches(needle).count()}#[cfg(test)]mod tests {    use super::*;    #[test]    fn inline_strips_single_p() {        assert_eq!(render_inline("simple text"), "simple text");    }    #[test]    fn inline_keeps_bold() {        assert_eq!(            render_inline("**plant leafy things**: lettuce"),            "<strong>plant leafy things</strong>: lettuce"        );    }    #[test]    fn block_keeps_paragraphs() {        let out = render_block("paragraph one\n\nparagraph two");        assert_eq!(out, "<p>paragraph one</p>\n<p>paragraph two</p>");    }}
added src/rng.rs
@@ -0,0 +1,83 @@/// mulberry32 with javascript Math.imul / signed-32 semantics. matches the/// python implementation in the original almanac.py exactly so seasonal/// picks are stable across the python and rust versions.pub struct Mulberry32 {    state: i32,}impl Mulberry32 {    pub fn new(seed: i64) -> Self {        Mulberry32 { state: seed as i32 }    }    pub fn next(&mut self) -> f64 {        // s = to_signed32(s + 0x6D2B79F5)        self.state = self.state.wrapping_add(0x6D2B79F5_u32 as i32);        // t = imul(s ^ (s >>> 15), 1 | s)        let s = self.state as u32;        let mut t = imul(s ^ (s >> 15), 1u32 | s) as i32;        // t = to_signed32(t + to_signed32(imul(t ^ (t >>> 7), 61 | t)))        let tu = t as u32;        let inner = imul(tu ^ (tu >> 7), 61u32 | tu) as i32;        t = t.wrapping_add(inner);        // t = t ^ (t >>> 14)        let tu = t as u32;        let final_u = tu ^ (tu >> 14);        final_u as f64 / 4294967296.0    }}#[inline]fn imul(a: u32, b: u32) -> u32 {    a.wrapping_mul(b)}pub fn pick_items<T: Clone>(items: &[T], count: usize, rng: &mut Mulberry32) -> Vec<T> {    if items.len() <= count {        return items.to_vec();    }    let mut copy: Vec<T> = items.to_vec();    let mut out = Vec::with_capacity(count);    for _ in 0..count {        let idx = (rng.next() * copy.len() as f64) as usize;        out.push(copy.remove(idx));    }    out}pub fn day_hash(date: chrono::DateTime<chrono_tz::Tz>) -> i64 {    use chrono::Datelike;    let doy = date.ordinal() as i64;    date.year() as i64 * 1000 + doy}#[cfg(test)]mod tests {    use super::*;    #[test]    fn matches_python_seed_2026126() {        // Captured live from python: seeded_random(2026126) for 10 calls.        let expected = [            0.9106675076764077,            0.03512798482552171,            0.27484832773916423,            0.24498416110873222,            0.6522165813948959,            0.8708663478028029,            0.8258189295884222,            0.6256361070554703,            0.3541836692020297,            0.3059297795407474,        ];        let mut rng = Mulberry32::new(2026126);        for (i, &want) in expected.iter().enumerate() {            let got = rng.next();            assert!(                (got - want).abs() < 1e-12,                "iter {i}: got {got}, want {want}"            );        }    }}
added src/templates.rs
@@ -0,0 +1,112 @@use minijinja::{path_loader, AutoEscape, Environment, Error, Output, State};use minijinja::value::Value;use serde_json::Value as JsonValue;use std::path::Path;/// Custom formatter that matches Jinja2's HTML escape (does NOT escape `/`)./// Without this, minijinja escapes `/` in URLs as `&#x2f;` which is ugly even/// though browsers parse it the same.fn jinja2_html_formatter(out: &mut Output, state: &State, value: &Value) -> Result<(), Error> {    if value.is_safe() {        write!(out, "{value}").map_err(Error::from)?;        return Ok(());    }    let auto_escape = match state.auto_escape() {        AutoEscape::Html => true,        AutoEscape::None => false,        _ => return minijinja::escape_formatter(out, state, value),    };    if !auto_escape {        write!(out, "{value}").map_err(Error::from)?;        return Ok(());    }    if let Some(s) = value.as_str() {        write_jinja2_html(out, s).map_err(Error::from)?;    } else if value.is_undefined() || value.is_none() {        // emit nothing    } else {        let stringified = value.to_string();        write_jinja2_html(out, &stringified).map_err(Error::from)?;    }    Ok(())}fn write_jinja2_html(out: &mut Output, s: &str) -> std::fmt::Result {    let mut last = 0;    for (i, b) in s.bytes().enumerate() {        let escape = match b {            b'&' => "&amp;",            b'<' => "&lt;",            b'>' => "&gt;",            b'"' => "&#34;",            b'\'' => "&#39;",            _ => continue,        };        if last < i {            out.write_str(&s[last..i])?;        }        out.write_str(escape)?;        last = i + 1;    }    if last < s.len() {        out.write_str(&s[last..])?;    }    Ok(())}fn read_manifest(path: &Path) -> JsonValue {    let text = std::fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string());    serde_json::from_str(&text).unwrap_or(JsonValue::Null)}fn lookup_asset(manifest: &JsonValue, entry: &str, kind: &str) -> String {    if let Some(chunk) = manifest.get(entry) {        if kind == "css" {            if let Some(css_arr) = chunk.get("css").and_then(|v| v.as_array()) {                if let Some(first) = css_arr.first().and_then(|v| v.as_str()) {                    return format!("/static/{first}");                }            }        }        if let Some(file) = chunk.get("file").and_then(|v| v.as_str()) {            return format!("/static/{file}");        }    }    format!("/static/{entry}")}pub fn build_env(templates_dir: &Path, manifest_path: &Path) -> Environment<'static> {    let mut env = Environment::new();    env.set_loader(path_loader(templates_dir));    env.set_formatter(jinja2_html_formatter);    // Vite manifest:    // - debug builds re-read on every call so Vite watcher rebuilds show up    //   immediately    // - release builds load once at startup and reuse the cached value    #[cfg(debug_assertions)]    {        let path = manifest_path.to_path_buf();        env.add_function(            "vite_asset",            move |entry: String, kind: Option<String>| -> Result<String, Error> {                let kind = kind.unwrap_or_else(|| "file".to_string());                let manifest = read_manifest(&path);                Ok(lookup_asset(&manifest, &entry, &kind))            },        );    }    #[cfg(not(debug_assertions))]    {        let manifest = read_manifest(manifest_path);        env.add_function(            "vite_asset",            move |entry: String, kind: Option<String>| -> Result<String, Error> {                let kind = kind.unwrap_or_else(|| "file".to_string());                Ok(lookup_asset(&manifest, &entry, &kind))            },        );    }    env}
modified templates/index.html
@@ -31,8 +31,8 @@  <link rel="icon" href="/static/favicon.svg" type="image/svg+xml">  <!-- typography is self-hosted from /static/fonts/ via @font-face in       style.css. no third-party font cdn calls at runtime. -->  <link rel="stylesheet" href="/static/style.css">       base.scss. no third-party font cdn calls at runtime. -->  <link rel="stylesheet" href="{{ vite_asset('index.js', 'css') }}"></head><body data-time="{{ time_key }}" data-season="{{ season_key }}">
@@ -76,6 +76,6 @@    </footer>  </main>  <script src="/static/almanac.js"></script>  <script src="{{ vite_asset('index.js') }}"></script></body></html>
deleted uv.lock
@@ -1,182 +0,0 @@version = 1revision = 3requires-python = ">=3.13"[[package]]name = "blinker"version = "1.9.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },][[package]]name = "click"version = "8.3.2"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "colorama", marker = "sys_platform == 'win32'" },]sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },][[package]]name = "colorama"version = "0.4.6"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },][[package]]name = "darkfurrow"version = "0.1.0"source = { virtual = "." }dependencies = [    { name = "flask" },    { name = "gunicorn" },    { name = "mistune" },][package.metadata]requires-dist = [    { name = "flask", specifier = ">=3.1" },    { name = "gunicorn", specifier = ">=23.0" },    { name = "mistune", specifier = ">=3.1" },][[package]]name = "flask"version = "3.1.3"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "blinker" },    { name = "click" },    { name = "itsdangerous" },    { name = "jinja2" },    { name = "markupsafe" },    { name = "werkzeug" },]sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },][[package]]name = "gunicorn"version = "25.3.0"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "packaging" },]sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" },][[package]]name = "itsdangerous"version = "2.2.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },][[package]]name = "jinja2"version = "3.1.6"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "markupsafe" },]sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },][[package]]name = "markupsafe"version = "3.0.3"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },    { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },    { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },    { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },    { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },    { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },    { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },    { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },    { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },    { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },    { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },    { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },    { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },    { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },    { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },    { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },    { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },    { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },    { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },    { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },    { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },    { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },    { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },    { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },    { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },    { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },    { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },    { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },    { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },    { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },    { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },    { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },    { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },    { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },    { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },    { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },    { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },    { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },    { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },    { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },    { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },    { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },    { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },    { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },][[package]]name = "mistune"version = "3.2.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" },][[package]]name = "packaging"version = "26.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },][[package]]name = "werkzeug"version = "3.1.8"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "markupsafe" },]sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },]