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