# 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/BeamSharp)
(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
`