@@ -1,5 +1,6 @@.env__pycache__/.venv//target/dist/frontend/node_modules.playwright-mcp/*.png
@@ -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 `/static/...`.**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.
@@ -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"
@@ -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
@@ -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"]
@@ -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
@@ -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
@@ -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
@@ -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, }, }, },});
@@ -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}
@@ -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(§ions); 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), }}
@@ -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}"); }}
@@ -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)}
@@ -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, })))}
@@ -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>"); }}
@@ -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}" ); } }}
@@ -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 `/` 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'&' => "&", b'<' => "<", b'>' => ">", b'"' => """, b'\'' => "'", _ => 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>
@@ -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" },]