CLAUDE.md 17.7 K · 286 lines · raw · history

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