# 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`. 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. ### Git browser (`/git`) `Components/Pages/Git/*.razor` replace the cgit that used to run at git.bes.is: an index, a repository summary, log, commit with diff, tree/blob and refs. They read through `Services/GitService.cs`, which uses **LibGit2Sharp** — the only feature here with a native dependency, and the reason the self-contained `linux-x64` publish has to carry `LibGit2Sharp.NativeBinaries`' `linux-x64` asset. Every method on the service opens a `Repository`, copies what it needs into the plain records in `Models/GitRepositories.cs`, and closes it again. libgit2's objects are handles into an open repository and `Repository` is not thread-safe, so nothing is held between requests and nothing is cached: a push is visible immediately, with nothing to invalidate. That is the opposite of `WarframeDropService` on purpose — its input is one file that only changes on deploy. Repository names come out of the URL, so `GitService.Resolve(name)` is the only thing that turns one into a path: it has to match `[A-Za-z0-9_][A-Za-z0-9._+-]*` and resolve to a directory whose parent is the root exactly. Paths *inside* a repository never reach the filesystem at all — they are tree lookups. `/git/{repo}/raw/{**path}` (mapped in `Program.cs`) serves a file as committed, and must never send `text/html`: these repositories hold `.html` and `.svg` files, and serving one inline from this origin would run whatever a commit put in it as a page of mb.bes.is. Text goes out as `text/plain` with `nosniff`; everything else is an attachment. Diffs are parsed back out of libgit2's patch text (`GitService.ParsePatch`) rather than printed raw, because the line numbers exist only in the `@@` headers. `MaxDiffLines` and `MaxDiffCharacters` both matter and run out independently: the commit that vendored `droptables.html` is 22k added lines of long machine-generated HTML, and it exhausts the character budget long before the line one. Configuration lives under `Git` (`GitOptions`): `RepositoryRoot` (`/home/git` in production, `~/Developer/csharp` in development — a leading `~/` is expanded), `CloneUrl`, and the display ceilings above. The pages are server-rendered and driven by the route and query string (`?h=` revision, `?path=` and `?ofs=` on the log), so everything is linkable and there is **no JavaScript at all** — no `.razor.js` beside any of them. **Syntax highlighting is grayscale on purpose.** `Services/SyntaxHighlighter.cs` is one regex per language, alternating over named groups and matched left to right, so the order of the alternatives *is* the precedence — comments and strings come first in every grammar, which is what keeps a keyword inside a string from being read as code. Six grammars cover everything in these repositories; the curly-brace languages share one keyword union rather than a table each. It is not a parser and gets things wrong on purpose, which is documented on the class. There are only three distinctions, because without colour there is only weight, slope and a wash to spend: **bold** is the language, a grey wash is a literal, *italic and dim* is a comment (plus bold-and-dim for the line that isn't code — a directive, a shell variable, a JSON key). The wash is translucent rather than a flat grey so it reads the same over an added line, a removed one and a marked one. Extend `git.css` section 8 rather than reaching for a colour. `Pages/Git/GitCode.cs` renders a line as a render tree instead of markup, because the cell is `white-space: pre` and any newline a .razor file left between two tokens would be part of the file. Diffs are highlighted a **hunk** at a time, not a line at a time: a hunk is two contiguous runs of text (the lines the new file has, and the lines the old file had), and highlighting each side whole is what stops the prose inside a block comment coming back out as keywords. A comment that opened before the hunk is still lost, because git did not send those lines. Both size guards — `SyntaxHighlighter.MaxCharacters` and `GitCommit.MaxHighlightCharacters` — are about page weight rather than time. Every token becomes a span, so a diff of machine-generated markup is several times its own length once highlighted: the droptables commit was a 0.5 MB page and a 2.3 MB one with it on. Past the limits the code is still shown, just without the spans. **Its own layout and stylesheet.** `Layout/GitLayout.razor` replaces `MainLayout` for everything in `Components/Pages/Git/` (through that folder's `_Imports.razor`) and links `wwwroot/git.css` via ``, so no other page loads it. git.css *overrides* app.css down to the base rather than extending it — root font size, spacing scale, table padding, link decoration — because app.css is set for reading an 80ch column and this is a wall of rows you scan. New rules for these pages belong there, not in app.css. ### Styling `wwwroot/app.css` is one hand-maintained stylesheet (no CSS framework, no build step), organized into numbered sections (Tokens → Reset/base → Typography → Layout → Controls → Components → Media) with CSS custom properties as the single source of design tokens (colors, spacing scale, fonts). It uses `light-dark()` and `color-scheme` for automatic dark mode — don't hardcode light/dark colors, extend the token set in section 1 instead. There are no scoped `.razor.css` stylesheets, so `App.razor` links `app.css` only — the `/git` pages pull in `wwwroot/git.css` from their own layout instead; adding a scoped stylesheet means adding the `Blog.styles.css` bundle link back. `` carries no layout of its own: each layout wraps its page in a `div.page`, and `MainLayout` adds `.center` to that for the 80ch measure. `/git` deliberately does not.