# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is `bes.is` — a Blazor Server app (single ASP.NET Core project, no test project) hosting a collection of small, mostly self-contained JS/HTML experiments/tools (RPN calculator, QR code scan/generate, a Netflix→Letterboxd converter, a JSON query playground, BRP lookup, etc.), each on its own route, plus a browser for the git repositories on the same server at `/git`. ## Commands Run from the repo root or `Blog/`: ``` dotnet build # build dotnet run --project Blog # run locally (http://localhost:5296) dotnet watch --project Blog # run with hot reload ``` There is no test project in the solution — don't invent `dotnet test` commands or test files unless asked to add a test project first. Publishing is done via the Rider run configuration "Publish Blog to custom server" (self-contained `linux-x64`, Release, ReadyToRun) — not part of normal dev workflow. It has two before-run tasks, both Rider External Tools (which live in Rider's own config, not in this repo): "update droptables" runs `scripts/update-droptables.sh`, then "restore linux" restores for the target runtime. ## Architecture **One project, one layout, many independent page-features.** `Blog/Components/Pages/*.razor` are the routes (`@page "/Xyz"`), each linked from `SiteFooter.razor`. Most pages are a `.razor` file paired with a `.razor.js` file of the same name — this is the pattern to follow for any new interactive page. The one exception is `Pages/Git/`, a folder of pages that share a layout and a stylesheet of their own — see below. ### The `.razor` + `.razor.js` pairing There is **no Blazor client runtime**. Every page is statically server-rendered and its JS is loaded as a plain ES module: ```razor ``` `.razor.js` files colocated with a component are static web assets, so `@Assets[...]` resolves them to a fingerprinted URL and `` in `App.razor` maps the bare `/common.module.js` style imports onto their fingerprinted files too. Never hand-write the plain path — go through `@Assets` or the module ships uncached. The module body *is* the page's setup code: it runs once, at top level, after the DOM is parsed (`type="module"` is deferred). There are no `onLoad`/`onUpdate`/`onDispose` hooks, because navigation is a real page load — the browser tears down listeners, timers and module state for you. Page logic is vanilla JS DOM manipulation, not Blazor data binding: grab elements with `getById` at top level and wire up listeners directly. If you ever reintroduce interactivity or enhanced navigation, this stops being true — module state would then outlive the DOM it points at, and every page script would need teardown again. `Log.razor`, `Panel.razor`, and the `StackOp*.razor` components live in `Components/_Shared/` and are reused across pages (e.g. `Log` renders a debug/error log panel that JS writes into via `writeDebug`/`writeError`; `Panel` is a bordered fieldset-with-legend used for docs/credits/ grouped controls). ### `wwwroot/common.module.js` Shared JS helpers imported by page scripts: - `h(tag, attrs?, children?)` / `t(text)` — a small hyperscript-style DOM builder (no framework, just `document.createElement`/`appendChild`). Prefer this over manual DOM building or template strings in new page scripts. - `getById(id)` — like `document.getElementById` but throws instead of returning null. - `writeError` / `writeInfo` / `writeDebug` / `resetLog` — write into the `#log` element that `Log.razor` renders. - `debounce(fn, wait)`. Other `wwwroot/*.js` files (`dactal.js`, `qrcode.js`, `lz-string.module.js`, `jsonql-js/`) are third-party or semi-vendored libraries used by specific pages (e.g. `Query.razor.js` uses both `dactal.js` and `jsonql-js` to run two query languages against the same mock BRP dataset in `wwwroot/brp.json`). ### rvrb feature (stats read off another BEAM node) `Rvrb.razor`/`.razor.cs`/`.razor.js` (route `/rvrb`) shows the status and stats of the rvrb Elixir bot (`~/Developer/elixir/rvrb`), which runs on the same server. `Services/RvrbService.cs` gets them by joining the bot's Erlang cluster: [BeamSharp](https://github.com/Besselking/SendSharp) (referenced as a project from the sibling checkout, it is not on NuGet yet) makes this site a hidden Erlang node and calls `Rvrb.Stats.snapshot/0` on the bot the way any BEAM node would — no HTTP endpoint on the Elixir side. The node is started on the *first request* rather than at boot, and a failure is a value (`RvrbStatus.Unreachable`) rather than an exception: the site must not fail to start, or a page fail to render, because EPMD is down or the bot was deployed without distribution. `Models/RvrbSnapshot.cs` holds the shapes and `Services/RvrbSnapshotReader.cs` decodes the Erlang term into them by hand — the term is an Elixir map written by Elixir, not a serialized C# type. Configuration lives under `Rvrb` (`RvrbOptions`): `Node`, `LocalNode`, `CallTimeout`, `CacheFor` in `appsettings.json`, and `Cookie` — the shared Erlang cookie — from user secrets in development (`dotnet user-secrets set "Rvrb:Cookie" ...`) or `Rvrb__Cookie` in the environment. The bot's own side of this (enabling distribution on its release) is documented in the rvrb repo's README. ### BRP feature (an external API behind a page) `BRP.razor`/`.razor.cs` and `BrpTestData.razor` are backed by `Services/BrpService.cs`, which calls an external "Haal Centraal BRP" lookup API (base address configured in `Program.cs` as `https://brp.bes.is/`) and caches responses via `HybridCache` (`Microsoft.Extensions.Caching.Hybrid`). `Models/BRPEntry.cs` and `Models/RaadpleegMetBurgerservicenummer.cs` model the request/response shapes. Test/mock data lives in `Resources/test-data.json` and `wwwroot/brp.json`. ### Warframe drops feature (`/Warframe`) `Warframe.razor`/`.razor.cs`/`.razor.js` searches Digital Extremes' published drop tables: given a part it shows where it drops sorted by chance, and for prime parts the relics holding it plus the best places to farm those relics. **The drop table page is vendored, not fetched.** warframe.com blocks requests from the server, so `Blog/Resources/droptables.html` is committed and read from disk; nothing about this page talks to the network at runtime. `scripts/update-droptables.sh` refreshes that file, and the Rider publish configuration runs it as a before-run task so every deploy ships current data — commit the result. The script refuses a download that isn't the drop table page (an error page, a truncated body), and treats a failed refresh as a warning so an outage at DE can't block an unrelated deploy. Two things that are easy to get wrong here, both of which break only the *deployed* site: - The Web SDK's default content glob covers `wwwroot/**`, `**/*.config` and `**/*.json` only, which is why `Resources/test-data.json` publishes for free and the `.html` does not. `Blog.csproj` names it explicitly with `CopyToPublishDirectory`. - The path resolves against `AppContext.BaseDirectory`, not `ContentRootPath` or the working directory. Those two follow wherever the process was started, so a unit file without a `WorkingDirectory` sends it looking in `/`. `Services/DropTableParser.cs` turns that page into `Models/WarframeDrops.cs`. The source is 4 MB of machine-generated HTML: twenty `

` sections of one flat table each, no classes or ids on the rows. It walks rows with regexes instead of an HTML parser, using three row grammars (two-column reward tables, three-column bounty tables, three-column "by source" tables) that cover all twenty sections. Rows matching no grammar are skipped rather than thrown over. Current output: 3,481 items, about 200 ms to parse. `Services/WarframeDropService.cs` holds one parsed copy in a field behind a `SemaphoreSlim`, not in `HybridCache` like the rest of the site, because HybridCache serializes what it stores. This depends on the service being registered as a **singleton**, or it would re-parse 4 MB per request. The copy is keyed on the file's last write time, so `dotnet watch` picks up a refresh without a restart. Configuration lives under `Warframe` (`WarframeOptions`): `FilePath`. Every item and relic name carries a `[wiki]` link beside it. It goes through MediaWiki's *go* search (`wiki.warframe.com/index.php?search=...&go=Go`), **not** a direct `/w/Title` link: the wiki documents the thing rather than the piece, so there is a `Gyre Prime` page but no `Gyre Prime Neuroptics Blueprint` one, and a direct link to the item name 404s. `WikiTitle` trims a name back to a likely title (everything up to `Prime`, minus relic/cache suffixes, amounts, and Railjack marks) and the go-search does the rest: an exact match redirects to the page, anything the trimming gets wrong lands on search results for it. That is about 96% direct hits on a sample of the real names, and the remainder degrade to a search rather than a dead end. The page is server-rendered and driven by the query string (`?q=` search, `?item=` selection), so results are linkable and work without JS. `Warframe.razor.js` only fills the search box's `` from `/api/warframe/names` (mapped in `Program.cs`). It does *not* import `/common.module.js`: that module binds to a `#log` element this page doesn't have. ### Send (`/Send`) — a file from one browser to another `Send.razor`/`.razor.js` sends a file over a **WebRTC data channel**. The site's only part is the introduction: `Services/SignalingService.cs` behind a WebSocket at `/api/send/{code}` (mapped in `Program.cs`, and the only reason `app.UseWebSockets()` is there) relays the offer, the answer and the ICE candidates between the two browsers holding the same code. The file never passes through this process, and closing the signalling socket does not interrupt a transfer. The server never mints or stores a code: the page generates twelve Crockford base32 characters from `crypto.getRandomValues`, a room exists while a socket is in it, and a third peer is refused. Messages are relayed verbatim — nothing parses SDP, which is why a browser can restart ICE or change codecs without this code learning a new message type. Rooms are a `ConcurrentDictionary` in a **singleton**, so the rooms *are* the service. Configuration lives under `Send` (`SendOptions`): `IceServers`, `MaxRooms`, `MaxMessageBytes`, `RoomLifetime`. STUN only, deliberately — a TURN server would relay the bytes, which is the one thing the page is for not doing, so a pair with no route between them fails rather than quietly going through a third party. The URLs reach the script as a `data-` attribute rather than a `