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