| 1 |
# CLAUDE.md |
| 2 |
|
| 3 |
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. |
| 4 |
|
| 5 |
## What this is |
| 6 |
|
| 7 |
`bes.is` — a Blazor Server app (single ASP.NET Core project, no test project) hosting a |
| 8 |
collection of small, mostly self-contained JS/HTML experiments/tools (RPN calculator, |
| 9 |
QR code scan/generate, a Netflix→Letterboxd converter, a JSON query playground, BRP |
| 10 |
lookup, etc.), each on its own route, plus a browser for the git repositories on the |
| 11 |
same server at `/git`. |
| 12 |
|
| 13 |
## Commands |
| 14 |
|
| 15 |
Run from the repo root or `Blog/`: |
| 16 |
|
| 17 |
``` |
| 18 |
dotnet build # build |
| 19 |
dotnet run --project Blog # run locally (http://localhost:5296) |
| 20 |
dotnet watch --project Blog # run with hot reload |
| 21 |
``` |
| 22 |
|
| 23 |
There is no test project in the solution — don't invent `dotnet test` commands or test |
| 24 |
files unless asked to add a test project first. |
| 25 |
|
| 26 |
Publishing is done via the Rider run configuration "Publish Blog to custom server" (self-contained |
| 27 |
`linux-x64`, Release, ReadyToRun) — not part of normal dev workflow. It has two before-run tasks, |
| 28 |
both Rider External Tools (which live in Rider's own config, not in this repo): "update droptables" |
| 29 |
runs `scripts/update-droptables.sh`, then "restore linux" restores for the target runtime. |
| 30 |
|
| 31 |
## Architecture |
| 32 |
|
| 33 |
**One project, one layout, many independent page-features.** `Blog/Components/Pages/*.razor` |
| 34 |
are the routes (`@page "/Xyz"`), each linked from `SiteFooter.razor`. Most pages are a |
| 35 |
`.razor` file paired with a `.razor.js` file of the same name — this is the pattern to |
| 36 |
follow for any new interactive page. The one exception is `Pages/Git/`, a folder of pages |
| 37 |
that share a layout and a stylesheet of their own — see below. |
| 38 |
|
| 39 |
### The `.razor` + `.razor.js` pairing |
| 40 |
|
| 41 |
There is **no Blazor client runtime**. Every page is statically server-rendered and its JS is |
| 42 |
loaded as a plain ES module: |
| 43 |
|
| 44 |
```razor |
| 45 |
<script type="module" src="@Assets["Components/Pages/Foo.razor.js"]"></script> |
| 46 |
``` |
| 47 |
|
| 48 |
`.razor.js` files colocated with a component are static web assets, so `@Assets[...]` resolves |
| 49 |
them to a fingerprinted URL and `<ImportMap/>` in `App.razor` maps the bare `/common.module.js` |
| 50 |
style imports onto their fingerprinted files too. Never hand-write the plain path — go through |
| 51 |
`@Assets` or the module ships uncached. |
| 52 |
|
| 53 |
The module body *is* the page's setup code: it runs once, at top level, after the DOM is parsed |
| 54 |
(`type="module"` is deferred). There are no `onLoad`/`onUpdate`/`onDispose` hooks, because |
| 55 |
navigation is a real page load — the browser tears down listeners, timers and module state for |
| 56 |
you. Page logic is vanilla JS DOM manipulation, not Blazor data binding: grab elements with |
| 57 |
`getById` at top level and wire up listeners directly. |
| 58 |
|
| 59 |
If you ever reintroduce interactivity or enhanced navigation, this stops being true — module |
| 60 |
state would then outlive the DOM it points at, and every page script would need teardown again. |
| 61 |
|
| 62 |
`Log.razor`, `Panel.razor`, and the `StackOp*.razor` components live in `Components/_Shared/` |
| 63 |
and are reused across pages (e.g. `Log` renders a debug/error log panel that JS writes into via |
| 64 |
`writeDebug`/`writeError`; `Panel` is a bordered fieldset-with-legend used for docs/credits/ |
| 65 |
grouped controls). |
| 66 |
|
| 67 |
### `wwwroot/common.module.js` |
| 68 |
|
| 69 |
Shared JS helpers imported by page scripts: |
| 70 |
- `h(tag, attrs?, children?)` / `t(text)` — a small hyperscript-style DOM builder (no |
| 71 |
framework, just `document.createElement`/`appendChild`). Prefer this over manual DOM |
| 72 |
building or template strings in new page scripts. |
| 73 |
- `getById(id)` — like `document.getElementById` but throws instead of returning null. |
| 74 |
- `writeError` / `writeInfo` / `writeDebug` / `resetLog` — write into the `#log` element |
| 75 |
that `Log.razor` renders. |
| 76 |
- `debounce(fn, wait)`. |
| 77 |
|
| 78 |
Other `wwwroot/*.js` files (`dactal.js`, `qrcode.js`, `lz-string.module.js`, `jsonql-js/`) |
| 79 |
are third-party or semi-vendored libraries used by specific pages (e.g. `Query.razor.js` |
| 80 |
uses both `dactal.js` and `jsonql-js` to run two query languages against the same mock |
| 81 |
BRP dataset in `wwwroot/brp.json`). |
| 82 |
|
| 83 |
### rvrb feature (stats read off another BEAM node) |
| 84 |
|
| 85 |
`Rvrb.razor`/`.razor.cs`/`.razor.js` (route `/rvrb`) shows the status and stats of the |
| 86 |
rvrb Elixir bot (`~/Developer/elixir/rvrb`), which runs on the same server. `Services/RvrbService.cs` |
| 87 |
gets them by joining the bot's Erlang cluster: [BeamSharp](https://github.com/Besselking/SendSharp) |
| 88 |
(referenced as a project from the sibling checkout, it is not on NuGet yet) makes this site a |
| 89 |
hidden Erlang node and calls `Rvrb.Stats.snapshot/0` on the bot the way any BEAM node would — |
| 90 |
no HTTP endpoint on the Elixir side. |
| 91 |
|
| 92 |
The node is started on the *first request* rather than at boot, and a failure is a value |
| 93 |
(`RvrbStatus.Unreachable`) rather than an exception: the site must not fail to start, or a page |
| 94 |
fail to render, because EPMD is down or the bot was deployed without distribution. `Models/RvrbSnapshot.cs` |
| 95 |
holds the shapes and `Services/RvrbSnapshotReader.cs` decodes the Erlang term into them by hand — |
| 96 |
the term is an Elixir map written by Elixir, not a serialized C# type. |
| 97 |
|
| 98 |
Configuration lives under `Rvrb` (`RvrbOptions`): `Node`, `LocalNode`, `CallTimeout`, `CacheFor` |
| 99 |
in `appsettings.json`, and `Cookie` — the shared Erlang cookie — from user secrets in development |
| 100 |
(`dotnet user-secrets set "Rvrb:Cookie" ...`) or `Rvrb__Cookie` in the environment. The bot's own |
| 101 |
side of this (enabling distribution on its release) is documented in the rvrb repo's README. |
| 102 |
|
| 103 |
### BRP feature (an external API behind a page) |
| 104 |
|
| 105 |
`BRP.razor`/`.razor.cs` and `BrpTestData.razor` are backed by `Services/BrpService.cs`, |
| 106 |
which calls an external "Haal Centraal BRP" lookup API (base address configured in |
| 107 |
`Program.cs` as `https://brp.bes.is/`) and caches responses via `HybridCache` |
| 108 |
(`Microsoft.Extensions.Caching.Hybrid`). `Models/BRPEntry.cs` and |
| 109 |
`Models/RaadpleegMetBurgerservicenummer.cs` model the request/response shapes. Test/mock |
| 110 |
data lives in `Resources/test-data.json` and `wwwroot/brp.json`. |
| 111 |
|
| 112 |
### Warframe drops feature (`/Warframe`) |
| 113 |
|
| 114 |
`Warframe.razor`/`.razor.cs`/`.razor.js` searches Digital Extremes' published drop tables: given a |
| 115 |
part it shows where it drops sorted by chance, and for prime parts the relics holding it plus the |
| 116 |
best places to farm those relics. |
| 117 |
|
| 118 |
**The drop table page is vendored, not fetched.** warframe.com blocks requests from the server, so |
| 119 |
`Blog/Resources/droptables.html` is committed and read from disk; nothing about this page talks to |
| 120 |
the network at runtime. `scripts/update-droptables.sh` refreshes that file, and the Rider publish |
| 121 |
configuration runs it as a before-run task so every deploy ships current data — commit the result. |
| 122 |
The script refuses a download that isn't the drop table page (an error page, a truncated body), and |
| 123 |
treats a failed refresh as a warning so an outage at DE can't block an unrelated deploy. |
| 124 |
|
| 125 |
Two things that are easy to get wrong here, both of which break only the *deployed* site: |
| 126 |
|
| 127 |
- The Web SDK's default content glob covers `wwwroot/**`, `**/*.config` and `**/*.json` only, which |
| 128 |
is why `Resources/test-data.json` publishes for free and the `.html` does not. `Blog.csproj` names |
| 129 |
it explicitly with `CopyToPublishDirectory`. |
| 130 |
- The path resolves against `AppContext.BaseDirectory`, not `ContentRootPath` or the working |
| 131 |
directory. Those two follow wherever the process was started, so a unit file without a |
| 132 |
`WorkingDirectory` sends it looking in `/`. |
| 133 |
|
| 134 |
`Services/DropTableParser.cs` turns that page into `Models/WarframeDrops.cs`. The source is 4 MB of |
| 135 |
machine-generated HTML: twenty `<h3 id>` sections of one flat table each, no classes or ids on the |
| 136 |
rows. It walks rows with regexes instead of an HTML parser, using three row grammars (two-column |
| 137 |
reward tables, three-column bounty tables, three-column "by source" tables) that cover all twenty |
| 138 |
sections. Rows matching no grammar are skipped rather than thrown over. Current output: 3,481 items, |
| 139 |
about 200 ms to parse. |
| 140 |
|
| 141 |
`Services/WarframeDropService.cs` holds one parsed copy in a field behind a `SemaphoreSlim`, not in |
| 142 |
`HybridCache` like the rest of the site, because HybridCache serializes what it stores. This depends |
| 143 |
on the service being registered as a **singleton**, or it would re-parse 4 MB per request. The copy |
| 144 |
is keyed on the file's last write time, so `dotnet watch` picks up a refresh without a restart. |
| 145 |
|
| 146 |
Configuration lives under `Warframe` (`WarframeOptions`): `FilePath`. |
| 147 |
|
| 148 |
Every item and relic name carries a `[wiki]` link beside it. It goes through MediaWiki's *go* |
| 149 |
search (`wiki.warframe.com/index.php?search=...&go=Go`), **not** a direct `/w/Title` link: the wiki |
| 150 |
documents the thing rather than the piece, so there is a `Gyre Prime` page but no |
| 151 |
`Gyre Prime Neuroptics Blueprint` one, and a direct link to the item name 404s. `WikiTitle` trims a |
| 152 |
name back to a likely title (everything up to `Prime`, minus relic/cache suffixes, amounts, and |
| 153 |
Railjack marks) and the go-search does the rest: an exact match redirects to the page, anything the |
| 154 |
trimming gets wrong lands on search results for it. That is about 96% direct hits on a sample of the |
| 155 |
real names, and the remainder degrade to a search rather than a dead end. |
| 156 |
|
| 157 |
The page is server-rendered and driven by the query string (`?q=` search, `?item=` selection), so |
| 158 |
results are linkable and work without JS. `Warframe.razor.js` only fills the search box's |
| 159 |
`<datalist>` from `/api/warframe/names` (mapped in `Program.cs`). It does *not* import |
| 160 |
`/common.module.js`: that module binds to a `#log` element this page doesn't have. |
| 161 |
|
| 162 |
### Send (`/Send`) — a file from one browser to another |
| 163 |
|
| 164 |
`Send.razor`/`.razor.js` sends a file over a **WebRTC data channel**. The site's only part is the |
| 165 |
introduction: `Services/SignalingService.cs` behind a WebSocket at `/api/send/{code}` (mapped in |
| 166 |
`Program.cs`, and the only reason `app.UseWebSockets()` is there) relays the offer, the answer and |
| 167 |
the ICE candidates between the two browsers holding the same code. The file never passes through |
| 168 |
this process, and closing the signalling socket does not interrupt a transfer. |
| 169 |
|
| 170 |
The server never mints or stores a code: the page generates twelve Crockford base32 characters |
| 171 |
from `crypto.getRandomValues`, a room exists while a socket is in it, and a third peer is refused. |
| 172 |
Messages are relayed verbatim — nothing parses SDP, which is why a browser can restart ICE or |
| 173 |
change codecs without this code learning a new message type. Rooms are a `ConcurrentDictionary` |
| 174 |
in a **singleton**, so the rooms *are* the service. |
| 175 |
|
| 176 |
Configuration lives under `Send` (`SendOptions`): `IceServers`, `MaxRooms`, `MaxMessageBytes`, |
| 177 |
`RoomLifetime`. STUN only, deliberately — a TURN server would relay the bytes, which is the one |
| 178 |
thing the page is for not doing, so a pair with no route between them fails rather than quietly |
| 179 |
going through a third party. The URLs reach the script as a `data-` attribute rather than a |
| 180 |
`<script type="application/json">` block, because Razor HTML-encodes element content. |
| 181 |
|
| 182 |
Three things about the client that are easy to undo by accident: |
| 183 |
|
| 184 |
- The code lives in the URL **fragment**, and `App.razor` sets `<base href="/">`. A bare |
| 185 |
`history.replaceState(null, "", "#code")` resolves against that base and silently moves the page |
| 186 |
to the site root — pass the whole URL. |
| 187 |
- Following a link to another room from an already-open page changes only the fragment, so the |
| 188 |
browser does not reload and the module body — which *is* this page's setup — never re-runs. |
| 189 |
`hashchange` reloads on purpose. |
| 190 |
- Signalling messages are handled one at a time through a promise chain. Handling an offer is |
| 191 |
several awaits long, and a candidate that overtook it would be added against a connection that |
| 192 |
is still half-described. |
| 193 |
|
| 194 |
Sending is chunked with backpressure (`bufferedAmountLowThreshold`, a high-water mark) and one |
| 195 |
file at a time, because the receiver's side of the protocol assumes it; a received file is held in |
| 196 |
memory as `Blob` parts until it is saved. Both ends can send — the page is symmetric. |
| 197 |
|
| 198 |
### Git browser (`/git`) |
| 199 |
|
| 200 |
`Components/Pages/Git/*.razor` replace the cgit that used to run at git.bes.is: an index, a |
| 201 |
repository summary, log, commit with diff, tree/blob and refs. They read through |
| 202 |
`Services/GitService.cs`, which uses **LibGit2Sharp** — the only feature here with a native |
| 203 |
dependency, and the reason the self-contained `linux-x64` publish has to carry |
| 204 |
`LibGit2Sharp.NativeBinaries`' `linux-x64` asset. |
| 205 |
|
| 206 |
Every method on the service opens a `Repository`, copies what it needs into the plain records in |
| 207 |
`Models/GitRepositories.cs`, and closes it again. libgit2's objects are handles into an open |
| 208 |
repository and `Repository` is not thread-safe, so nothing is held between requests and nothing is |
| 209 |
cached: a push is visible immediately, with nothing to invalidate. That is the opposite of |
| 210 |
`WarframeDropService` on purpose — its input is one file that only changes on deploy. |
| 211 |
|
| 212 |
Repository names come out of the URL, so `GitService.Resolve(name)` is the only thing that turns |
| 213 |
one into a path: it has to match `[A-Za-z0-9_][A-Za-z0-9._+-]*` and resolve to a directory whose |
| 214 |
parent is the root exactly. Paths *inside* a repository never reach the filesystem at all — they |
| 215 |
are tree lookups. |
| 216 |
|
| 217 |
`/git/{repo}/raw/{**path}` (mapped in `Program.cs`) serves a file as committed, and must never |
| 218 |
send `text/html`: these repositories hold `.html` and `.svg` files, and serving one inline from |
| 219 |
this origin would run whatever a commit put in it as a page of mb.bes.is. Text goes out as |
| 220 |
`text/plain` with `nosniff`; everything else is an attachment. |
| 221 |
|
| 222 |
Diffs are parsed back out of libgit2's patch text (`GitService.ParsePatch`) rather than printed |
| 223 |
raw, because the line numbers exist only in the `@@` headers. `MaxDiffLines` and |
| 224 |
`MaxDiffCharacters` both matter and run out independently: the commit that vendored |
| 225 |
`droptables.html` is 22k added lines of long machine-generated HTML, and it exhausts the character |
| 226 |
budget long before the line one. |
| 227 |
|
| 228 |
Configuration lives under `Git` (`GitOptions`): `RepositoryRoot` (`/home/git` in production, |
| 229 |
`~/Developer/csharp` in development — a leading `~/` is expanded), `CloneUrl`, and the display |
| 230 |
ceilings above. |
| 231 |
|
| 232 |
The pages are server-rendered and driven by the route and query string (`?h=` revision, `?path=` |
| 233 |
and `?ofs=` on the log), so everything is linkable and there is **no JavaScript at all** — no |
| 234 |
`.razor.js` beside any of them. |
| 235 |
|
| 236 |
**Syntax highlighting is grayscale on purpose.** `Services/SyntaxHighlighter.cs` is one regex per |
| 237 |
language, alternating over named groups and matched left to right, so the order of the alternatives |
| 238 |
*is* the precedence — comments and strings come first in every grammar, which is what keeps a |
| 239 |
keyword inside a string from being read as code. Six grammars cover everything in these |
| 240 |
repositories; the curly-brace languages share one keyword union rather than a table each. It is not |
| 241 |
a parser and gets things wrong on purpose, which is documented on the class. |
| 242 |
|
| 243 |
There are only three distinctions, because without colour there is only weight, slope and a wash to |
| 244 |
spend: **bold** is the language, a grey wash is a literal, *italic and dim* is a comment (plus |
| 245 |
bold-and-dim for the line that isn't code — a directive, a shell variable, a JSON key). The wash is |
| 246 |
translucent rather than a flat grey so it reads the same over an added line, a removed one and a |
| 247 |
marked one. Extend `git.css` section 8 rather than reaching for a colour. |
| 248 |
|
| 249 |
`Pages/Git/GitCode.cs` renders a line as a render tree instead of markup, because the cell is |
| 250 |
`white-space: pre` and any newline a .razor file left between two tokens would be part of the file. |
| 251 |
|
| 252 |
Diffs are highlighted a **hunk** at a time, not a line at a time: a hunk is two contiguous runs of |
| 253 |
text (the lines the new file has, and the lines the old file had), and highlighting each side whole |
| 254 |
is what stops the prose inside a block comment coming back out as keywords. A comment that opened |
| 255 |
before the hunk is still lost, because git did not send those lines. |
| 256 |
|
| 257 |
Both size guards — `SyntaxHighlighter.MaxCharacters` and `GitCommit.MaxHighlightCharacters` — are |
| 258 |
about page weight rather than time. Every token becomes a span, so a diff of machine-generated |
| 259 |
markup is several times its own length once highlighted: the droptables commit was a 0.5 MB page |
| 260 |
and a 2.3 MB one with it on. Past the limits the code is still shown, just without the spans. |
| 261 |
|
| 262 |
**Its own layout and stylesheet.** `Layout/GitLayout.razor` replaces `MainLayout` for everything in |
| 263 |
`Components/Pages/Git/` (through that folder's `_Imports.razor`) and links `wwwroot/git.css` via |
| 264 |
`<HeadContent>`, so no other page loads it. git.css *overrides* app.css down to the base rather |
| 265 |
than extending it — root font size, spacing scale, table padding, link decoration — because |
| 266 |
app.css is set for reading an 80ch column and this is a wall of rows you scan. New rules for these |
| 267 |
pages belong there, not in app.css. |
| 268 |
|
| 269 |
### Styling |
| 270 |
|
| 271 |
`wwwroot/app.css` is one hand-maintained stylesheet (no CSS framework, no build step), |
| 272 |
organized into numbered sections (Tokens → Reset/base → Typography → Layout → Controls → |
| 273 |
Components → Media) with CSS custom properties as the single source of design tokens |
| 274 |
(colors, spacing scale, fonts). It uses `light-dark()` and `color-scheme` for automatic |
| 275 |
dark mode — don't hardcode light/dark colors, extend the token set in section 1 instead. |
| 276 |
There are no scoped `.razor.css` stylesheets, so `App.razor` links `app.css` only — the `/git` |
| 277 |
pages pull in `wwwroot/git.css` from their own layout instead; adding a scoped stylesheet means |
| 278 |
adding the `Blog.styles.css` bundle link back. |
| 279 |
|
| 280 |
`<body>` carries no layout of its own: each layout wraps its page in a `div.page`, and |
| 281 |
`MainLayout` adds `.center` to that for the 80ch measure. `/git` deliberately does not. |