Highlight code in the tree and the diff, in grayscale
- 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 | added | +66 -0 |
| Blog/Components/Pages/Git/GitCommit.razor | +66 -3 | |
| Blog/Components/Pages/Git/GitTree.razor | +32 -7 | |
| Blog/Models/GitRepositories.cs | +6 -2 | |
| Blog/Models/SyntaxToken.cs | added | +35 -0 |
| Blog/Services/GitService.cs | +8 -14 | |
| Blog/Services/SyntaxHighlighter.cs | added | +265 -0 |
| Blog/wwwroot/git.css | +42 -0 | |
| CLAUDE.md | +26 -0 |
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 | + | |
| 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 | + | |
| 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 | (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 | (var hunk in file.Hunks) |
| 161 | 162 | { |
| 163 | + var tokens = Highlight(hunk, grammar); | |
| 164 | + | |
| 162 | 165 | <tr class="hunk"> |
| 163 | 166 | <td colspan="3"></td> |
| 164 | 167 | </tr> |
| 165 | 168 | |
| 166 | - (var line in hunk.Lines) | |
| 169 | + (var row = 0; row < hunk.Lines.Count; row++) | |
| 167 | 170 | { |
| 171 | + var line = hunk.Lines[row]; | |
| 172 | + | |
| 168 | 173 | (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"></td> |
| 178 | 183 | <td class="ln"></td> |
| 179 | - <td class="code"><span class="sign"></span></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 | { |
| 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 | (View is not { } view) |
| 11 | 11 | { |
| 12 | 12 | <section class="block"> |
| 13 | - <p class="empty">No revision @(Reference ?? "HEAD") in </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></span> |
| 73 | 77 | <span class="block-note"> |
| 74 | - (blob.Size)@(blob.Lines is { } counted ? $" · {Counted(counted.Count, "line")}" : "") | |
| 78 | + (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 | - (var number = 1; number <= lines.Count; number++) | |
| 107 | + (var number = 1; number <= Code.Count; number++) | |
| 104 | 108 | { |
| 105 | 109 | <tr id="L@(number)"> |
| 106 | 110 | <td class="ln"><a href="#L@(number)"></a></td> |
| 107 | - <td class="code">[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>></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 | : 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 | + : 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 | : light-dark(#e4f5e4, #0e2410); |
| @@ -578,3 +584,39 @@ html { | ||
| 578 | 584 | min-inline-size: 0; |
| 579 | 585 | padding-inline: var(); |
| 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(); | |
| 608 | +} | |
| 609 | + | |
| 610 | +/* The author, talking past the compiler. Italic and quiet: a comment is worth reading second. */ | |
| 611 | +.tok-comment { | |
| 612 | + color: var(); | |
| 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(); | |
| 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 |