CLAUDE.md 12.3 K · 210 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 **Its own layout and stylesheet.** `Layout/GitLayout.razor` replaces `MainLayout` for everything in
192 `Components/Pages/Git/` (through that folder's `_Imports.razor`) and links `wwwroot/git.css` via
193 `<HeadContent>`, so no other page loads it. git.css *overrides* app.css down to the base rather
194 than extending it — root font size, spacing scale, table padding, link decoration — because
195 app.css is set for reading an 80ch column and this is a wall of rows you scan. New rules for these
196 pages belong there, not in app.css.
197
198 ### Styling
199
200 `wwwroot/app.css` is one hand-maintained stylesheet (no CSS framework, no build step),
201 organized into numbered sections (Tokens → Reset/base → Typography → Layout → Controls →
202 Components → Media) with CSS custom properties as the single source of design tokens
203 (colors, spacing scale, fonts). It uses `light-dark()` and `color-scheme` for automatic
204 dark mode — don't hardcode light/dark colors, extend the token set in section 1 instead.
205 There are no scoped `.razor.css` stylesheets, so `App.razor` links `app.css` only — the `/git`
206 pages pull in `wwwroot/git.css` from their own layout instead; adding a scoped stylesheet means
207 adding the `Blog.styles.css` bundle link back.
208
209 `<body>` carries no layout of its own: each layout wraps its page in a `div.page`, and
210 `MainLayout` adds `.center` to that for the 80ch measure. `/git` deliberately does not.