Highlight code in the tree and the diff, in grayscale

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, or a quote inside a comment, 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: no file here holds both defer and yield, and the alternative is eight tables saying almost the same thing. It is a highlighter for reading a diff, not a parser. There is no state between matches and no notion of scope, so a > in HTML prose reads as a tag bracket and an apostrophe in a shell comment is already inside a comment by the time it could do harm. Being wrong costs a bold word. With no colour to spend there are three tools - weight, slope and a wash - so there are three distinctions, which is about as many as a line of code can carry anyway: bold is what the language says, a wash is what the program says, italic and dim is what the author says. A fourth by dimming something else only makes the first three harder to see. The wash is translucent rather than a flat grey, because it has to sit on the page, on an added line and on a removed one and come out a shade darker than each. Diffs are highlighted a hunk at a time rather than a line at a time. A line on its own is not enough: a block comment that opened three lines up would go unnoticed and its prose would come back out as keywords. A hunk is two contiguous runs of text - the lines the new file has and the lines the old file had - so each side is highlighted whole and each line takes its share back. A comment that opened before the hunk is still lost, since git did not send those lines. GitCode renders a line as a render tree rather than as markup. The cell it lands in is white-space: pre, and any newline or indentation a .razor file left between two tokens would be rendered as part of the file. Both size guards are about page weight, not time. Every token becomes a span, so highlighted machine-generated markup is several times its own length: the commit that vendored droptables.html went from a 0.5 MB page to a 2.3 MB one. Past the limits the code is still shown, with its numbers and without its spans. GitBlob carries the file whole rather than pre-split into lines, because the highlighter needs all of it to see that a block comment or a fenced code block carries on past the end of a line. An empty repository's tree tab now says so instead of answering 404, which is what the log tab already did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-09-13 19:28 UTC
commit
9e99745e724af5b5262e0c9b2fa82cb0c84ca8ee
parent
1157d8790d
tree
browse at this commit

9 files changed +546 -26

Blog/Components/Pages/Git/GitCode.cs +66 -0

@@ -0,0 +1,66 @@
1 +using Blog.Models;
2 +using Microsoft.AspNetCore.Components;
3 +using Microsoft.AspNetCore.Components.Rendering;
4 +
5 +namespace Blog.Components.Pages.Git;
6 +
7 +/// <summary>
8 +/// One line of code, as the spans git.css can tell apart.
9 +/// </summary>
10 +/// <remarks>
11 +/// A render tree rather than markup because the cell this lands in is <c>white-space: pre</c>: any
12 +/// newline or indentation a .razor file left between two tokens would be rendered as part of the
13 +/// file. A render tree emits exactly the nodes it is handed, and <c>AddContent</c> escapes them.
14 +/// </remarks>
15 +public sealed class GitCode : ComponentBase
16 +{
17 + [Parameter]
18 + public IReadOnlyList<SyntaxToken> Tokens { get; set; } = [];
19 +
20 + /// <summary>
21 + /// A diff's <c>+</c> or <c>-</c>, repeated out of the gutter into the line so the diff still
22 + /// reads as one when it is copied or read without colour. git.css marks it unselectable, so
23 + /// copying gives back the code rather than the patch.
24 + /// </summary>
25 + [Parameter]
26 + public char? Sign { get; set; }
27 +
28 + protected override void BuildRenderTree(RenderTreeBuilder builder)
29 + {
30 + // Sequence numbers exist for diffing one render against the next, which these pages never
31 + // do - they are rendered once, statically, and thrown away.
32 + var sequence = 0;
33 +
34 + if (Sign is { } sign)
35 + {
36 + builder.OpenElement(sequence++, "span");
37 + builder.AddAttribute(sequence++, "class", "sign");
38 + builder.AddContent(sequence++, sign);
39 + builder.CloseElement();
40 + }
41 +
42 + foreach (var token in Tokens)
43 + {
44 + if (token.Kind is SyntaxKind.Text)
45 + {
46 + builder.AddContent(sequence++, token.Text);
47 + continue;
48 + }
49 +
50 + builder.OpenElement(sequence++, "span");
51 + builder.AddAttribute(sequence++, "class", Class(token.Kind));
52 + builder.AddContent(sequence++, token.Text);
53 + builder.CloseElement();
54 + }
55 + }
56 +
57 + private static string Class(SyntaxKind kind) => kind switch
58 + {
59 + SyntaxKind.Comment => "tok-comment",
60 + SyntaxKind.String => "tok-string",
61 + SyntaxKind.Number => "tok-number",
62 + SyntaxKind.Keyword => "tok-keyword",
63 + SyntaxKind.Meta => "tok-meta",
64 + _ => ""
65 + };
66 +}

Blog/Components/Pages/Git/GitCommit.razor +66 -3

@@ -121,6 +121,7 @@
121 121 @for (var index = 0; index < view.Diff.Files.Count; index++)
122 122 {
123 123 var file = view.Diff.Files[index];
124 + var grammar = Highlighted ? SyntaxHighlighter.GrammarFor(file.Path) : null;
124 125
125 126 <section class="block diff-file" id="@Anchor(index)">
126 127 <h2 class="block-head">
@@ -159,12 +160,16 @@
159 160 <tbody>
160 161 @foreach (var hunk in file.Hunks)
161 162 {
163 + var tokens = Highlight(hunk, grammar);
164 +
162 165 <tr class="hunk">
163 166 <td colspan="3">@hunk.Header</td>
164 167 </tr>
165 168
166 - @foreach (var line in hunk.Lines)
169 + @for (var row = 0; row < hunk.Lines.Count; row++)
167 170 {
171 + var line = hunk.Lines[row];
172 +
168 173 @if (line.Origin == '\\')
169 174 {
170 175 <tr class="note">
@@ -176,7 +181,7 @@
176 181 <tr class="@Row(line.Origin)">
177 182 <td class="ln">@line.OldNumber</td>
178 183 <td class="ln">@line.NewNumber</td>
179 - <td class="code"><span class="sign">@line.Origin</span>@line.Text</td>
184 + <td class="code"><GitCode Tokens="@(tokens[row])" Sign="@line.Origin"/></td>
180 185 </tr>
181 186 }
182 187 }
@@ -191,11 +196,23 @@
191 196 </main>
192 197
193 198 @code {
199 + /// <summary>
200 + /// A diff longer than this is shown without highlighting. Every token in a highlighted line
201 + /// becomes a span, which is several times the length of the line itself, and a diff of
202 + /// machine-generated markup is almost entirely tokens — the commit that vendored the Warframe
203 + /// drop tables turned a 0.5 MB page into a 2.3 MB one. Nobody is reading a diff that size
204 + /// line by line, so it keeps its numbers and loses its spans.
205 + /// </summary>
206 + private const int MaxHighlightCharacters = 60_000;
207 +
194 208 [Parameter]
195 209 public string Sha { get; set; } = "";
196 210
197 211 private GitCommitView? View { get; set; }
198 212
213 + /// <summary>Whether this diff is small enough to be worth marking up. See above.</summary>
214 + private bool Highlighted { get; set; }
215 +
199 216 private string Short => Shorten(View?.Commit.Sha ?? Sha);
200 217
201 218 private string Title => View is { } view ? $"{view.Commit.Summary} · {Repository}" : Repository;
@@ -204,7 +221,15 @@
204 221 {
205 222 View = Service.GetCommit(Repository, Sha);
206 223
207 - if (View is null) NotFound();
224 + if (View is null)
225 + {
226 + NotFound();
227 + return;
228 + }
229 +
230 + Highlighted = View.Diff.Files
231 + .Sum(file => file.Hunks.Sum(hunk => hunk.Lines.Sum(line => line.Text.Length)))
232 + <= MaxHighlightCharacters;
208 233 }
209 234
210 235 private static string Shorten(string sha) => sha.Length >= 10 ? sha[..10] : sha;
@@ -218,6 +243,44 @@
218 243
219 244 private static string Anchor(int index) => $"f{index}";
220 245
246 + /// <summary>Tokens for every line of a hunk, in the order the hunk lists them.</summary>
247 + /// <remarks>
248 + /// A diff line on its own is not enough to highlight with. A block comment or a template
249 + /// literal that opened three lines above it would go unnoticed, and the prose inside it would
250 + /// come back out as keywords.
251 + ///
252 + /// A hunk is enough, because a hunk is two contiguous runs of text: the lines the new file has
253 + /// (context and added) and the lines the old file had (context and removed). Each side is
254 + /// highlighted whole and each line takes its share back. Context lines belong to both, so the
255 + /// new side runs second and wins — the text is the same either way.
256 + ///
257 + /// A comment that opened before the hunk began is still lost. Git did not send those lines,
258 + /// so there is nothing here that could know about it.
259 + /// </remarks>
260 + private static IReadOnlyList<IReadOnlyList<SyntaxToken>> Highlight(GitDiffHunk hunk, SyntaxGrammar? grammar)
261 + {
262 + var tokens = new IReadOnlyList<SyntaxToken>[hunk.Lines.Count];
263 + Array.Fill(tokens, []);
264 +
265 + Side('-');
266 + Side('+');
267 +
268 + return tokens;
269 +
270 + void Side(char origin)
271 + {
272 + var rows = Enumerable.Range(0, hunk.Lines.Count)
273 + .Where(row => hunk.Lines[row].Origin == origin || hunk.Lines[row].Origin == ' ')
274 + .ToArray();
275 +
276 + var lines = SyntaxHighlighter.Highlight(
277 + string.Join('\n', rows.Select(row => hunk.Lines[row].Text)),
278 + grammar);
279 +
280 + for (var row = 0; row < rows.Length && row < lines.Count; row++) tokens[rows[row]] = lines[row];
281 + }
282 + }
283 +
221 284 private static string Row(char origin) => origin switch
222 285 {
223 286 '+' => "add",

Blog/Components/Pages/Git/GitTree.razor +32 -7

@@ -10,7 +10,11 @@
10 10 @if (View is not { } view)
11 11 {
12 12 <section class="block">
13 - <p class="empty">No revision @(Reference ?? "HEAD") in @Repository.</p>
13 + <p class="empty">
14 + @(Empty
15 + ? $"Nothing has been pushed to {Repository} yet."
16 + : $"No revision called {Reference ?? "HEAD"} in {Repository}.")
17 + </p>
14 18 </section>
15 19 }
16 20 else if (view.Entries is { } entries)
@@ -71,7 +75,7 @@
71 75 <h2 class="block-head">
72 76 <span>@blob.Path</span>
73 77 <span class="block-note">
74 - @Size(blob.Size)@(blob.Lines is { } counted ? $" · {Counted(counted.Count, "line")}" : "")
78 + @Size(blob.Size)@(Code.Count > 0 ? $" · {Counted(Code.Count, "line")}" : "")
75 79 · <a href="@RawLink(blob.Path, At)">raw</a>
76 80 · <a href="@LogLink(At, blob.Path)">history</a>
77 81 </span>
@@ -89,22 +93,22 @@
89 93 Too large to print here. <a href="@RawLink(blob.Path, At)">Read it raw</a>.
90 94 </p>
91 95 }
92 - else if (blob.Lines is { Count: 0 })
96 + else if (Code.Count == 0)
93 97 {
94 98 <p class="empty">Empty file.</p>
95 99 }
96 - else if (blob.Lines is { } lines)
100 + else
97 101 {
98 102 @* Every line is addressable: #L42 scrolls to it and marks it, so a line of code
99 103 can be linked to from anywhere. *@
100 104 <div class="scroll">
101 105 <table class="code-table">
102 106 <tbody>
103 - @for (var number = 1; number <= lines.Count; number++)
107 + @for (var number = 1; number <= Code.Count; number++)
104 108 {
105 109 <tr id="L@(number)">
106 110 <td class="ln"><a href="#L@(number)">@number</a></td>
107 - <td class="code">@lines[number - 1]</td>
111 + <td class="code"><GitCode Tokens="@Code[number - 1]"/></td>
108 112 </tr>
109 113 }
110 114 </tbody>
@@ -139,6 +143,12 @@
139 143
140 144 private IReadOnlyList<GitBar.Crumb> Crumbs { get; set; } = [];
141 145
146 + /// <summary>Set when the repository is real but has nothing in it yet.</summary>
147 + private bool Empty { get; set; }
148 +
149 + /// <summary>The file being shown, one list of tokens per line. Empty unless this is a blob.</summary>
150 + private IReadOnlyList<IReadOnlyList<SyntaxToken>> Code { get; set; } = [];
151 +
142 152 /// <summary>The directory above, or null at the root.</summary>
143 153 private string? Parent
144 154 {
@@ -155,7 +165,22 @@
155 165 Path = Path?.Trim('/');
156 166 View = Service.GetPath(Repository, Reference, Path);
157 167
158 - if (View is null || (View.Entries is null && View.Blob is null)) NotFound();
168 + if (View is null)
169 + {
170 + // Nothing resolved, which is two different things. A repository that has refs was
171 + // reached by a bad revision; one with none is a repository nobody has pushed to, and
172 + // its tree tab should say so rather than answer 404.
173 + Empty = Service.GetRefs(Repository) is { Branches.Count: 0 };
174 + if (!Empty) NotFound();
175 + }
176 + else if (View.Entries is null && View.Blob is null)
177 + {
178 + NotFound();
179 + }
180 +
181 + Code = View?.Blob is { Text: { } text } blob
182 + ? SyntaxHighlighter.Highlight(text, SyntaxHighlighter.GrammarFor(blob.Path))
183 + : [];
159 184
160 185 Crumbs = BuildCrumbs();
161 186 }

Blog/Models/GitRepositories.cs +6 -2

@@ -69,14 +69,18 @@ public sealed record GitTreeEntry(string Name, string Path, GitEntryKind Kind, l
69 69 /// Decided the way git decides it: a NUL byte anywhere in the first few kilobytes. Binary blobs
70 70 /// are linked to the raw endpoint instead of being printed.
71 71 /// </param>
72 -/// <param name="Lines">The blob's text split for numbering, or null when it is not shown.</param>
72 +/// <param name="Text">
73 +/// The file, newlines normalised to <c>\n</c> and the final one removed, or null when there is
74 +/// nothing to print. Whole rather than split into lines: the highlighter needs the whole of it to
75 +/// see that a block comment or a fenced code block carries on past the end of a line.
76 +/// </param>
73 77 public sealed record GitBlob(
74 78 string Path,
75 79 string Sha,
76 80 long Size,
77 81 bool IsBinary,
78 82 bool TooLarge,
79 - IReadOnlyList<string>? Lines);
83 + string? Text);
80 84
81 85 /// <summary>
82 86 /// One path in one revision: a directory listing, or a file. Which of the two is set says which

Blog/Models/SyntaxToken.cs +35 -0

@@ -0,0 +1,35 @@
1 +namespace Blog.Models;
2 +
3 +/// <summary>
4 +/// What a run of characters is, as far as a grayscale highlighter cares.
5 +/// </summary>
6 +/// <remarks>
7 +/// Deliberately short. With no colour to spend, a line of code can carry about three distinctions
8 +/// before they stop being distinctions: what the language says (<see cref="Keyword"/>), what the
9 +/// program says (<see cref="String"/> and <see cref="Number"/>), and what the author says
10 +/// (<see cref="Comment"/>). Everything else is <see cref="Text"/>.
11 +/// </remarks>
12 +public enum SyntaxKind
13 +{
14 + /// <summary>Identifiers, operators, whitespace — anything with no treatment of its own.</summary>
15 + Text,
16 +
17 + Comment,
18 +
19 + /// <summary>A string, character or template literal, quotes included.</summary>
20 + String,
21 +
22 + Number,
23 +
24 + /// <summary>A word the language reserves, and in markup the tag brackets around a name.</summary>
25 + Keyword,
26 +
27 + /// <summary>
28 + /// The line that isn't code: a preprocessor directive, a shell variable, a doctype, a
29 + /// markdown heading, a JSON key.
30 + /// </summary>
31 + Meta
32 +}
33 +
34 +/// <summary>One run of characters from a line, and what it is.</summary>
35 +public sealed record SyntaxToken(SyntaxKind Kind, string Text);

Blog/Services/GitService.cs +8 -14

@@ -409,20 +409,14 @@ public sealed partial class GitService(IOptions<GitOptions> options, ILogger<Git
409 409 if (blob.IsBinary) return new GitBlob(path, blob.Sha, blob.Size, true, false, null);
410 410 if (blob.Size > _options.MaxBlobBytes) return new GitBlob(path, blob.Sha, blob.Size, false, true, null);
411 411
412 - var text = blob.GetContentText();
413 - var lines = text.Split('\n');
414 -
415 - // A text file ends with a newline, which splits into a trailing empty element that is not
416 - // a line of the file.
417 - var count = lines.Length > 0 && lines[^1].Length == 0 ? lines.Length - 1 : lines.Length;
418 -
419 - return new GitBlob(
420 - path,
421 - blob.Sha,
422 - blob.Size,
423 - false,
424 - false,
425 - lines.Take(count).Select(line => line.TrimEnd('\r')).ToArray());
412 + // Normalised here rather than wherever it is split: a file committed with CRLF would
413 + // otherwise end every line with a stray carriage return. The last newline ends the last
414 + // line, it does not begin another one - but only the last, since a file may genuinely end
415 + // on a blank line.
416 + var text = blob.GetContentText().ReplaceLineEndings("\n");
417 + if (text.EndsWith('\n')) text = text[..^1];
418 +
419 + return new GitBlob(path, blob.Sha, blob.Size, false, false, text);
426 420 }
427 421
428 422 private GitDiff ToDiff(Patch patch)

Blog/Services/SyntaxHighlighter.cs +265 -0

@@ -0,0 +1,265 @@
1 +using System.Text.RegularExpressions;
2 +using Blog.Models;
3 +
4 +namespace Blog.Services;
5 +
6 +/// <summary>A language's rules, resolved once from a path and reused for every line of it.</summary>
7 +public sealed class SyntaxGrammar
8 +{
9 + internal SyntaxGrammar(Regex pattern) => Pattern = pattern;
10 +
11 + internal Regex Pattern { get; }
12 +}
13 +
14 +/// <summary>
15 +/// Splits source text into <see cref="SyntaxToken"/>s for the pages under <c>/git</c>.
16 +/// </summary>
17 +/// <remarks>
18 +/// One regex per language, alternating over named groups, matched left to right: whatever the
19 +/// engine finds first wins, so the order of the alternatives *is* the precedence. Comments and
20 +/// strings come first in every grammar, which is what keeps a keyword inside a string, or a quote
21 +/// inside a comment, from being read as code.
22 +///
23 +/// This highlights a diff for reading; it does not parse anything. There is no state between
24 +/// matches and no notion of scope, so it gets a few things wrong on purpose: a <c>&gt;</c> in HTML
25 +/// prose reads as a tag bracket, and the curly-brace languages share one keyword union rather than
26 +/// a table each, because no file in these repositories holds both <c>defer</c> and <c>yield</c>
27 +/// and the alternative is eight tables saying almost the same thing. Being wrong costs a bold word.
28 +///
29 +/// Not <c>[GeneratedRegex]</c>, which the rest of this site uses: these patterns are assembled
30 +/// from shared fragments and a source generator needs a constant. They are built once, compiled,
31 +/// and given a timeout — a highlighter is not worth a hung request.
32 +/// </remarks>
33 +public static class SyntaxHighlighter
34 +{
35 + /// <summary>
36 + /// Past this the text comes back as plain lines. Two reasons, and the second is the binding
37 + /// one: a pathological file should not be able to hold a request open, and a highlighted line
38 + /// is several times its own length in markup, because every token it holds becomes a span. A
39 + /// file of machine-generated HTML tokenises into almost nothing else.
40 + /// </summary>
41 + private const int MaxCharacters = 160_000;
42 +
43 + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(1);
44 +
45 + // -- Shared fragments ----------------------------------------------------
46 +
47 + /// <summary>Three quote marks. A C# raw string cannot hold its own delimiter.</summary>
48 + private const string Triple = "\"\"\"";
49 +
50 + /// <summary>
51 + /// Quotes that have to close on the line they opened on. An apostrophe in prose would
52 + /// otherwise open a string that runs to the end of the file.
53 + /// </summary>
54 + private const string Quotes = @"""(?:\\.|[^""\\\n])*""|'(?:\\.|[^'\\\n])*'";
55 +
56 + /// <summary>Decimal, hex and binary, with a trailing unit or suffix: 10px, 1.5f, 0xFF, 50%.</summary>
57 + private const string Number =
58 + @"\b(?:0[xX][0-9a-fA-F_]+|0[bB][01_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?)[a-zA-Z%]*";
59 +
60 + // -- Grammars ------------------------------------------------------------
61 +
62 + /// <summary>
63 + /// C, C#, Java, JavaScript, Go, Rust and the rest of the curly-brace family. One keyword
64 + /// union: see the note on being wrong, above.
65 + /// </summary>
66 + private static readonly SyntaxGrammar CFamily = Grammar(
67 + @"(?<comment>//[^\n]*|/\*[\s\S]*?\*/)",
68 + // C# raw and verbatim strings and JS templates all cross lines, so they come first and
69 + // are the only ones allowed to.
70 + $@"(?<string>{Triple}[\s\S]*?{Triple}|@""(?:""""|[^""])*""|`(?:\\.|[^`\\])*`|{Quotes})",
71 + // A preprocessor directive, or a C# attribute alone on its line. Anchored, because
72 + // [Something] in the middle of a line is an index, not an attribute.
73 + @"(?<meta>^[ \t]*\#[ \t]*\w+|^[ \t]*\[[A-Za-z][\w.]*(?:\([^\n]*\))?\][ \t]*$)",
74 + $"(?<number>{Number})",
75 + Keywords(
76 + "abstract alias and as assert async await base bool break byte case catch char class",
77 + "comptime const constexpr continue decimal default defer delegate do double dynamic",
78 + "else enum event explicit export extends extern false final finally float fn for",
79 + "foreach from func function global goto if impl implements implicit import in init",
80 + "inline instanceof int interface internal is let lock long match namespace new nil not",
81 + "null nullptr object operator or out override package params partial private protected",
82 + "public readonly record ref required return sbyte sealed select short sizeof stackalloc",
83 + "static string struct super switch this throw throws trait true try typedef typeof",
84 + "uint ulong unsafe ushort using var virtual void volatile when where while yield"));
85 +
86 + /// <summary>Shell, Python, Ruby, Elixir, YAML — anything whose comments start with a hash.</summary>
87 + private static readonly SyntaxGrammar Hash = Grammar(
88 + @"(?<comment>#[^\n]*)",
89 + $@"(?<string>{Triple}[\s\S]*?{Triple}|'''[\s\S]*?'''|{Quotes})",
90 + // $VAR, ${VAR}, $1, $@ — what a shell line is usually about.
91 + @"(?<meta>\$\{[^}\n]*\}|\$[A-Za-z_]\w*|\$[0-9@*#?!$-])",
92 + $"(?<number>{Number})",
93 + Keywords(
94 + "after alias and as break case class cond continue def defmacro defmodule defp do done",
95 + "elif else elsif end esac eval exec exit export false fi finally fn for from function",
96 + "global if import in lambda local next nil not or pass raise receive require rescue",
97 + "return select set shift source then trap true try unset until use while yield"));
98 +
99 + /// <summary>HTML, XML, SVG, Razor, csproj — everything shaped like a tag.</summary>
100 + private static readonly SyntaxGrammar Markup = Grammar(
101 + @"(?<comment><!--[\s\S]*?-->)",
102 + @"(?<meta><\?[\s\S]*?\?>|<!\[CDATA\[[\s\S]*?\]\]>|<![A-Za-z][^>\n]*>|&\#?\w+;|@[A-Za-z_][\w.]*)",
103 + @"(?<string>""[^""\n]*""|'[^'\n]*')",
104 + // The brackets and the tag name, not the attributes: a name in bold with its attributes
105 + // left plain is what makes the shape of a document readable at a glance.
106 + @"(?<keyword></?[A-Za-z][\w:.-]*|/?>)");
107 +
108 + private static readonly SyntaxGrammar Css = Grammar(
109 + @"(?<comment>/\*[\s\S]*?\*/)",
110 + @"(?<string>""[^""\n]*""|'[^'\n]*')",
111 + // At-rules, custom properties and !important. In this stylesheet the custom properties
112 + // are the design system, so they are worth marking as something other than a property.
113 + @"(?<meta>@[\w-]+|--[\w-]+|![\w-]+)",
114 + $@"(?<number>\#[0-9a-fA-F]{{3,8}}\b|{Number})",
115 + // A property name is whatever is about to be followed by a colon. That also catches the
116 + // pseudo-class in a:hover, which is no great loss: both are the language, not the value.
117 + @"(?<keyword>\b[-a-zA-Z]+(?=\s*:))");
118 +
119 + private static readonly SyntaxGrammar Json = Grammar(
120 + @"(?<meta>""(?:\\.|[^""\\\n])*""(?=\s*:))",
121 + @"(?<string>""(?:\\.|[^""\\\n])*"")",
122 + @"(?<number>-?\b\d[\d]*(?:\.\d+)?(?:[eE][+-]?\d+)?)",
123 + @"(?<keyword>\b(?:true|false|null)\b)");
124 +
125 + private static readonly SyntaxGrammar Markdown = Grammar(
126 + @"(?<comment>^[ \t]{0,3}>[^\n]*)",
127 + @"(?<string>```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]+`)",
128 + @"(?<keyword>^\#{1,6}[ \t][^\n]*|^[ \t]{0,3}(?:[-*+]|\d{1,9}[.)])[ \t])",
129 + @"(?<meta>\[[^\]\n]*\]\([^)\n]*\)|^[ \t]{0,3}(?:[-*_][ \t]*){3,}$)");
130 +
131 + // -- Language by filename ------------------------------------------------
132 +
133 + /// <summary>
134 + /// The grammar for a path, or null for a file this knows nothing about — which renders as
135 + /// plain text rather than as a guess.
136 + /// </summary>
137 + public static SyntaxGrammar? GrammarFor(string path)
138 + {
139 + var name = Path.GetFileName(path).ToLowerInvariant();
140 +
141 + // Files whose name is their type. GetExtension calls the whole of ".gitignore" an
142 + // extension, so these have to be settled before it is asked.
143 + switch (name)
144 + {
145 + case "dockerfile" or "makefile" or "justfile" or "procfile":
146 + case ".gitignore" or ".gitattributes" or ".editorconfig" or ".gitmodules":
147 + return Hash;
148 + case "license" or "licence" or "authors" or "notice":
149 + return null;
150 + }
151 +
152 + return Path.GetExtension(name) switch
153 + {
154 + ".cs" or ".csx" or ".c" or ".h" or ".cpp" or ".cc" or ".hpp" or ".java" or ".js"
155 + or ".mjs" or ".cjs" or ".ts" or ".jsx" or ".tsx" or ".go" or ".rs" or ".swift"
156 + or ".kt" or ".kts" or ".zig" or ".scala" or ".dart" or ".php" or ".m" or ".gradle"
157 + => CFamily,
158 +
159 + ".sh" or ".bash" or ".zsh" or ".fish" or ".py" or ".rb" or ".ex" or ".exs" or ".erl"
160 + or ".pl" or ".yml" or ".yaml" or ".toml" or ".ini" or ".conf" or ".cfg" or ".env"
161 + or ".service" or ".nix"
162 + => Hash,
163 +
164 + ".html" or ".htm" or ".xhtml" or ".xml" or ".svg" or ".razor" or ".cshtml" or ".xaml"
165 + or ".csproj" or ".props" or ".targets" or ".config" or ".plist" or ".resx"
166 + or ".xsd" or ".xsl" or ".vue"
167 + => Markup,
168 +
169 + ".css" or ".scss" or ".sass" or ".less" => Css,
170 + ".json" or ".jsonc" or ".webmanifest" => Json,
171 + ".md" or ".markdown" or ".mdx" => Markdown,
172 +
173 + _ => null
174 + };
175 + }
176 +
177 + // -- Tokenising ----------------------------------------------------------
178 +
179 + /// <summary>
180 + /// Splits <paramref name="text"/> into one list of tokens per line. A null
181 + /// <paramref name="grammar"/>, text too long to be worth scanning, and a regex that runs out
182 + /// of time all give the same answer: the lines, untouched.
183 + /// </summary>
184 + public static IReadOnlyList<IReadOnlyList<SyntaxToken>> Highlight(string text, SyntaxGrammar? grammar)
185 + {
186 + if (text.Length == 0) return [];
187 + if (grammar is null || text.Length > MaxCharacters) return Plain(text);
188 +
189 + try
190 + {
191 + return Scan(text, grammar.Pattern);
192 + }
193 + catch (RegexMatchTimeoutException)
194 + {
195 + return Plain(text);
196 + }
197 + }
198 +
199 + private static IReadOnlyList<IReadOnlyList<SyntaxToken>> Scan(string text, Regex pattern)
200 + {
201 + var lines = new List<IReadOnlyList<SyntaxToken>>();
202 + var line = new List<SyntaxToken>();
203 + var written = 0;
204 +
205 + // A token can cross a newline — a block comment, a verbatim string, a fenced code block —
206 + // so nothing reaches a line until it has been cut at every newline inside it.
207 + void Emit(SyntaxKind kind, ReadOnlySpan<char> value)
208 + {
209 + while (value.IndexOf('\n') is var cut and >= 0)
210 + {
211 + if (cut > 0) line.Add(new SyntaxToken(kind, value[..cut].ToString()));
212 + lines.Add(line);
213 + line = [];
214 + value = value[(cut + 1)..];
215 + }
216 +
217 + if (value.Length > 0) line.Add(new SyntaxToken(kind, value.ToString()));
218 + }
219 +
220 + foreach (Match match in pattern.Matches(text))
221 + {
222 + if (match.Index > written) Emit(SyntaxKind.Text, text.AsSpan(written..match.Index));
223 +
224 + Emit(KindOf(match), match.ValueSpan);
225 + written = match.Index + match.Length;
226 + }
227 +
228 + if (written < text.Length) Emit(SyntaxKind.Text, text.AsSpan(written));
229 +
230 + lines.Add(line);
231 + return lines;
232 + }
233 +
234 + /// <summary>
235 + /// Which alternative matched. Every grammar names its groups from the same set, and a match
236 + /// can only have come from one of them.
237 + /// </summary>
238 + private static SyntaxKind KindOf(Match match)
239 + {
240 + if (match.Groups["comment"].Success) return SyntaxKind.Comment;
241 + if (match.Groups["string"].Success) return SyntaxKind.String;
242 + if (match.Groups["keyword"].Success) return SyntaxKind.Keyword;
243 + if (match.Groups["number"].Success) return SyntaxKind.Number;
244 + if (match.Groups["meta"].Success) return SyntaxKind.Meta;
245 + return SyntaxKind.Text;
246 + }
247 +
248 + private static IReadOnlyList<IReadOnlyList<SyntaxToken>> Plain(string text) =>
249 + text.Split('\n')
250 + .Select(IReadOnlyList<SyntaxToken> (value) =>
251 + value.Length == 0 ? [] : [new SyntaxToken(SyntaxKind.Text, value)])
252 + .ToArray();
253 +
254 + // -- Building ------------------------------------------------------------
255 +
256 + private static SyntaxGrammar Grammar(params string[] alternatives) =>
257 + new(new Regex(
258 + string.Join('|', alternatives),
259 + RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.Multiline,
260 + Timeout));
261 +
262 + /// <summary>A keyword alternative out of space-separated words, kept readable in the source.</summary>
263 + private static string Keywords(params string[] words) =>
264 + $@"(?<keyword>\b(?:{string.Join('|', words.SelectMany(line => line.Split(' ')))})\b)";
265 +}

Blog/wwwroot/git.css +42 -0

@@ -18,6 +18,7 @@
18 18 5. Tables the shape most of these pages are
19 19 6. Code trees, blobs, diffs
20 20 7. Small parts badges, pagers, key/value lists
21 + 8. Syntax what a highlighter has left once you take the colour away
21 22 ========================================================================== */
22 23
23 24
@@ -53,6 +54,11 @@ html {
53 54 /* A row the URL points at: #L42, or the file a diff was opened for. */
54 55 --g-mark: light-dark(#fff6cc, #2c2611);
55 56
57 + /* The wash behind a literal. Translucent rather than a flat grey, because it has to sit on
58 + three different backgrounds - the page, an added line, a removed one - and come out a shade
59 + darker than each of them. */
60 + --g-wash: light-dark(rgb(0 0 0 / 7%), rgb(255 255 255 / 9%));
61 +
56 62 /* Diff colours carry meaning, so they are stated rather than tinted: a
57 63 wash behind the line, and a stronger shade for the sign in the gutter. */
58 64 --g-add: light-dark(#e4f5e4, #0e2410);
@@ -578,3 +584,39 @@ html {
578 584 min-inline-size: 0;
579 585 padding-inline: var(--g2);
580 586 }
587 +
588 +
589 +/* 8. Syntax =============================================================== */
590 +
591 +/* Grayscale highlighting has three tools - weight, slope and a wash - so it gets three
592 + distinctions, which is about as many as a line of code can carry anyway: what the language says,
593 + what the program says, and what the author says. Adding a fourth by dimming or underlining
594 + something else only makes the first three harder to see.
595 +
596 + Nothing here sets a foreground colour except to dim one, so every token stays legible on an
597 + added line, a removed line, and a line the URL has marked. */
598 +
599 +/* The language: `if`, `public`, `<section`, a CSS property, a JSON literal. */
600 +.tok-keyword {
601 + font-weight: 700;
602 +}
603 +
604 +/* The program's own data: strings, numbers, lengths, colours, a fenced code block. */
605 +.tok-string,
606 +.tok-number {
607 + background-color: var(--g-wash);
608 +}
609 +
610 +/* The author, talking past the compiler. Italic and quiet: a comment is worth reading second. */
611 +.tok-comment {
612 + color: var(--g-dim);
613 + font-style: italic;
614 +}
615 +
616 +/* The line that isn't code: a preprocessor directive, a shell variable, a doctype, a markdown
617 + link, a JSON key, a CSS custom property. Bold like the language, dimmed because it is beside
618 + it rather than part of it. */
619 +.tok-meta {
620 + font-weight: 700;
621 + color: var(--g-dim);
622 +}

CLAUDE.md +26 -0

@@ -188,6 +188,32 @@ The pages are server-rendered and driven by the route and query string (`?h=` re
188 188 and `?ofs=` on the log), so everything is linkable and there is **no JavaScript at all** — no
189 189 `.razor.js` beside any of them.
190 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 +
191 217 **Its own layout and stylesheet.** `Layout/GitLayout.razor` replaces `MainLayout` for everything in
192 218 `Components/Pages/Git/` (through that folder's `_Imports.razor`) and links `wwwroot/git.css` via
193 219 `<HeadContent>`, so no other page loads it. git.css *overrides* app.css down to the base rather