steid

@jamesgill /

feat: code reads as code, in classes rather than colours

The blob view rendered every file as one flat block of text, which is the one
place in a forge where colour is information rather than decoration.

Highlighting is `syntect` with the pure-Rust regex engine, so the Debian release
build gains no C dependency, and it emits *classes* — never the inline styles it
offers — because inline colours would hardcode one palette into the markup and
follow neither a palette change nor the colour scheme. The colours are therefore
a `--syntax-*` family in `styles.css`, derived from the existing palette in
oklch, beside every other token.

The DOM shape is unchanged: `syntect`'s own generator produces one blob whose
spans cross line boundaries, which cannot be split across the table's rows, so
this drives the parser a line at a time and closes what a line leaves open,
reopening it on the next. What was a text node is the same text wrapped in spans.

Nothing here can turn a viewable file into an error: no syntax for the name, a
parser that gives up, or a file past 512 KiB or 10,000 lines all fall back to the
plain text the page rendered before — the last of those saying so, because
otherwise a large Rust file silently looks like an unsupported language.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnmRPpETeB9y6RSowpigye
JamesPatrickGill authored 15 hours agoparentc974d80Browse files979134b46b8c0e95d53bdcf23250f8ca3e743a94

6 files changed+620 −5

Cargo.lock+148 −0View file
@@ -43,6 +43,15 @@ dependencies = [
4343 "subtle",
4444 ]
4545
46+[[package]]
47+name = "aho-corasick"
48+version = "1.1.5"
49+source = "registry+https://github.com/rust-lang/crates.io-index"
50+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
51+dependencies = [
52+ "memchr",
53+]
54+
4655 [[package]]
4756 name = "alloc-no-stdlib"
4857 version = "2.0.4"
@@ -139,6 +148,30 @@ version = "1.8.3"
139148 source = "registry+https://github.com/rust-lang/crates.io-index"
140149 checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
141150
151+[[package]]
152+name = "bincode"
153+version = "1.3.3"
154+source = "registry+https://github.com/rust-lang/crates.io-index"
155+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
156+dependencies = [
157+ "serde",
158+]
159+
160+[[package]]
161+name = "bit-set"
162+version = "0.8.0"
163+source = "registry+https://github.com/rust-lang/crates.io-index"
164+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
165+dependencies = [
166+ "bit-vec",
167+]
168+
169+[[package]]
170+name = "bit-vec"
171+version = "0.8.0"
172+source = "registry+https://github.com/rust-lang/crates.io-index"
173+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
174+
142175 [[package]]
143176 name = "bitflags"
144177 version = "2.13.1"
@@ -522,6 +555,17 @@ dependencies = [
522555 "pin-project-lite",
523556 ]
524557
558+[[package]]
559+name = "fancy-regex"
560+version = "0.16.2"
561+source = "registry+https://github.com/rust-lang/crates.io-index"
562+checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
563+dependencies = [
564+ "bit-set",
565+ "regex-automata",
566+ "regex-syntax",
567+]
568+
525569 [[package]]
526570 name = "fastrand"
527571 version = "2.5.0"
@@ -1098,6 +1142,12 @@ dependencies = [
10981142 "vcpkg",
10991143 ]
11001144
1145+[[package]]
1146+name = "linked-hash-map"
1147+version = "0.5.6"
1148+source = "registry+https://github.com/rust-lang/crates.io-index"
1149+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
1150+
11011151 [[package]]
11021152 name = "linux-raw-sys"
11031153 version = "0.12.1"
@@ -1268,6 +1318,19 @@ version = "0.3.33"
12681318 source = "registry+https://github.com/rust-lang/crates.io-index"
12691319 checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
12701320
1321+[[package]]
1322+name = "plist"
1323+version = "1.10.0"
1324+source = "registry+https://github.com/rust-lang/crates.io-index"
1325+checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
1326+dependencies = [
1327+ "base64 0.22.1",
1328+ "indexmap",
1329+ "quick-xml",
1330+ "serde",
1331+ "time",
1332+]
1333+
12711334 [[package]]
12721335 name = "polyval"
12731336 version = "0.6.2"
@@ -1333,6 +1396,15 @@ dependencies = [
13331396 "unicase",
13341397 ]
13351398
1399+[[package]]
1400+name = "quick-xml"
1401+version = "0.41.0"
1402+source = "registry+https://github.com/rust-lang/crates.io-index"
1403+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
1404+dependencies = [
1405+ "memchr",
1406+]
1407+
13361408 [[package]]
13371409 name = "quote"
13381410 version = "1.0.47"
@@ -1465,6 +1537,23 @@ dependencies = [
14651537 "syn 3.0.3",
14661538 ]
14671539
1540+[[package]]
1541+name = "regex-automata"
1542+version = "0.4.18"
1543+source = "registry+https://github.com/rust-lang/crates.io-index"
1544+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
1545+dependencies = [
1546+ "aho-corasick",
1547+ "memchr",
1548+ "regex-syntax",
1549+]
1550+
1551+[[package]]
1552+name = "regex-syntax"
1553+version = "0.8.11"
1554+source = "registry+https://github.com/rust-lang/crates.io-index"
1555+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1556+
14681557 [[package]]
14691558 name = "ring"
14701559 version = "0.17.14"
@@ -1539,6 +1628,15 @@ version = "1.0.23"
15391628 source = "registry+https://github.com/rust-lang/crates.io-index"
15401629 checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
15411630
1631+[[package]]
1632+name = "same-file"
1633+version = "1.0.6"
1634+source = "registry+https://github.com/rust-lang/crates.io-index"
1635+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
1636+dependencies = [
1637+ "winapi-util",
1638+]
1639+
15421640 [[package]]
15431641 name = "scopeguard"
15441642 version = "1.2.0"
@@ -1912,6 +2010,7 @@ dependencies = [
19122010 "sha2 0.10.9",
19132011 "sqlx",
19142012 "subtle",
2013+ "syntect",
19152014 "tempfile",
19162015 "tokio",
19172016 "tokio-util",
@@ -1975,6 +2074,27 @@ dependencies = [
19752074 "syn 2.0.119",
19762075 ]
19772076
2077+[[package]]
2078+name = "syntect"
2079+version = "5.3.0"
2080+source = "registry+https://github.com/rust-lang/crates.io-index"
2081+checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
2082+dependencies = [
2083+ "bincode",
2084+ "fancy-regex",
2085+ "flate2",
2086+ "fnv",
2087+ "once_cell",
2088+ "plist",
2089+ "regex-syntax",
2090+ "serde",
2091+ "serde_derive",
2092+ "serde_json",
2093+ "thiserror",
2094+ "walkdir",
2095+ "yaml-rust",
2096+]
2097+
19782098 [[package]]
19792099 name = "tempfile"
19802100 version = "3.27.0"
@@ -2776,6 +2896,16 @@ version = "0.9.5"
27762896 source = "registry+https://github.com/rust-lang/crates.io-index"
27772897 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
27782898
2899+[[package]]
2900+name = "walkdir"
2901+version = "2.5.0"
2902+source = "registry+https://github.com/rust-lang/crates.io-index"
2903+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
2904+dependencies = [
2905+ "same-file",
2906+ "winapi-util",
2907+]
2908+
27792909 [[package]]
27802910 name = "wasi"
27812911 version = "0.11.1+wasi-snapshot-preview1"
@@ -2861,6 +2991,15 @@ version = "2.1.2"
28612991 source = "registry+https://github.com/rust-lang/crates.io-index"
28622992 checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d"
28632993
2994+[[package]]
2995+name = "winapi-util"
2996+version = "0.1.11"
2997+source = "registry+https://github.com/rust-lang/crates.io-index"
2998+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
2999+dependencies = [
3000+ "windows-sys 0.61.2",
3001+]
3002+
28643003 [[package]]
28653004 name = "windows-link"
28663005 version = "0.2.1"
@@ -2970,6 +3109,15 @@ version = "0.6.3"
29703109 source = "registry+https://github.com/rust-lang/crates.io-index"
29713110 checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
29723111
3112+[[package]]
3113+name = "yaml-rust"
3114+version = "0.4.5"
3115+source = "registry+https://github.com/rust-lang/crates.io-index"
3116+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
3117+dependencies = [
3118+ "linked-hash-map",
3119+]
3120+
29733121 [[package]]
29743122 name = "yoke"
29753123 version = "0.8.3"
Cargo.toml+1 −0View file
@@ -17,6 +17,7 @@ serde = { version = "1.0.229", features = ["derive"] }
1717 sha2 = "0.10"
1818 sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
1919 subtle = "2.6.1"
20+syntect = { version = "5.3.0", default-features = false, features = ["default-fancy"] }
2021 tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "process", "fs", "io-util", "time"] }
2122 tokio-util = { version = "0.7", features = ["io"] }
2223 topcoat = { version = "0.5.0", features = ["icon-iconify", "tailwind", "ui"] }
src/infrastructure/highlight.rs+330 −0View file
@@ -0,0 +1,330 @@
1+//! Syntax highlighting for the blob view.
2+//!
3+//! Classes, never colours. [`syntect`] can emit inline `style` attributes, and that
4+//! would hardcode one palette into the markup — the theme would then follow neither a
5+//! palette change nor the colour scheme, which is exactly what `styles.css` forbids
6+//! everywhere else. So the output is `hl-`-prefixed classes and the colours live in
7+//! `styles.css` beside every other token.
8+//!
9+//! **One HTML fragment per source line**, because the blob view is a table with a line
10+//! number in one cell and the code in the other. `syntect`'s own
11+//! `ClassedHTMLGenerator` produces a single blob whose `<span>`s cross line boundaries,
12+//! which cannot be split across table rows — so this drives the parser a line at a time
13+//! and closes every open span at the end of each line, reopening it at the start of the
14+//! next. The DOM shape is unchanged: what was a text node is now the same text wrapped
15+//! in spans.
16+//!
17+//! **Highlighting never turns a viewable file into an error.** Every failure path —
18+//! no syntax for the file, a regex that gives up, a file past the cap — falls back to
19+//! the plain text the page rendered before.
20+
21+use std::sync::OnceLock;
22+
23+use syntect::{
24+ html::{ClassStyle, line_tokens_to_classed_spans},
25+ parsing::{ParseState, ScopeStack, SyntaxReference, SyntaxSet},
26+ util::LinesWithEndings,
27+};
28+
29+/// The class prefix, so a scope atom cannot collide with a Tailwind utility.
30+const PREFIX: &str = "hl-";
31+
32+const CLASS_STYLE: ClassStyle = ClassStyle::SpacedPrefixed { prefix: PREFIX };
33+
34+/// Past this, a file is shown as plain text.
35+///
36+/// Highlighting is regex matching over every line, and the fancy-regex engine is pure
37+/// Rust rather than fast C — a generated or vendored file can be megabytes of one long
38+/// line, and the page should still arrive. Half a megabyte is well above anything
39+/// written by hand and far below `MAX_BLOB_BYTES`.
40+pub const MAX_BYTES: usize = 512 * 1024;
41+
42+/// The other half of the cap: many short lines cost per-line work, not per-byte work.
43+pub const MAX_LINES: usize = 10_000;
44+
45+/// A file, ready for the blob view's table: one entry per line, in the same order as
46+/// `text.lines()`.
47+#[derive(Debug, Clone, PartialEq, Eq)]
48+pub struct Source {
49+ pub lines: Vec<SourceLine>,
50+ /// Whether highlighting was skipped because the file is past the caps. The page
51+ /// says so, because otherwise a large Rust file silently looks like an
52+ /// unsupported language.
53+ pub too_large: bool,
54+}
55+
56+/// One line of a file.
57+#[derive(Debug, Clone, PartialEq, Eq)]
58+pub enum SourceLine {
59+ /// Text, to be escaped by the view like anything else.
60+ Plain(String),
61+ /// Markup this module wrote, to be emitted verbatim.
62+ Classed(String),
63+}
64+
65+/// Splits a file into lines, highlighted where the language is known.
66+///
67+/// Every fallback lands on [`SourceLine::Plain`]: past the caps, no syntax for the
68+/// name, a parser that gave up. Highlighting can make a file prettier; it can never
69+/// stop it being readable.
70+pub fn source_lines(file_name: &str, text: &str) -> Source {
71+ let too_large = text.len() > MAX_BYTES || text.lines().count() > MAX_LINES;
72+
73+ let classed = (!too_large)
74+ .then(|| {
75+ let syntaxes = syntaxes();
76+ let syntax = syntax_for(syntaxes, file_name, text)?;
77+ classed_lines(syntaxes, syntax, text)
78+ })
79+ .flatten()
80+ // A fragment per line is the contract the table relies on; anything else is a
81+ // bug in this module, and plain text is the safe reading of it.
82+ .filter(|lines| lines.len() == text.lines().count());
83+
84+ let lines = match classed {
85+ Some(classed) => text
86+ .lines()
87+ .zip(classed)
88+ .map(|(line, html)| match line.is_empty() {
89+ // An empty cell has no height, so a blank line would close the gap it
90+ // is there to make. The plain path has always spent a space on this.
91+ true => SourceLine::Plain(" ".to_owned()),
92+ false => SourceLine::Classed(html),
93+ })
94+ .collect(),
95+ None => text
96+ .lines()
97+ .map(|line| match line.is_empty() {
98+ true => SourceLine::Plain(" ".to_owned()),
99+ false => SourceLine::Plain(line.to_owned()),
100+ })
101+ .collect(),
102+ };
103+
104+ Source { lines, too_large }
105+}
106+
107+/// The default syntax set, loaded once.
108+///
109+/// Loading is a few megabytes of deserialization, so it happens on the first blob
110+/// viewed and never again. `_newlines` is the variant whose rules expect a trailing
111+/// newline on each line, which is what [`LinesWithEndings`] hands it.
112+fn syntaxes() -> &'static SyntaxSet {
113+ static SYNTAXES: OnceLock<SyntaxSet> = OnceLock::new();
114+ SYNTAXES.get_or_init(SyntaxSet::load_defaults_newlines)
115+}
116+
117+/// The language, from the file name and then the first line.
118+///
119+/// This is `SyntaxSet::find_syntax_for_file` without the filesystem: that method opens
120+/// the path to read a shebang, and here the contents are already in memory. The whole
121+/// name is tried as an extension first because that is how syntect registers names like
122+/// `Makefile`; plain text is treated as no syntax at all, since wrapping every line in
123+/// a span that carries no colour is pure markup for nothing.
124+fn syntax_for<'a>(
125+ syntaxes: &'a SyntaxSet,
126+ file_name: &str,
127+ text: &str,
128+) -> Option<&'a SyntaxReference> {
129+ let extension = file_name.rsplit_once('.').map(|(_, ext)| ext);
130+
131+ let syntax = syntaxes
132+ .find_syntax_by_extension(file_name)
133+ .or_else(|| extension.and_then(|ext| syntaxes.find_syntax_by_extension(ext)))
134+ .or_else(|| syntaxes.find_syntax_by_first_line(text.lines().next().unwrap_or("")))?;
135+
136+ (syntax.name != syntaxes.find_syntax_plain_text().name).then_some(syntax)
137+}
138+
139+/// One line of HTML per line of source, or `None` if the parser gave up.
140+///
141+/// The span stack is carried across lines — a block comment opens on one line and
142+/// closes on another — but the *markup* is not: the scopes open at the end of a line
143+/// are closed there and reopened on the next, so each fragment is balanced and can sit
144+/// in its own table cell.
145+fn classed_lines(
146+ syntaxes: &SyntaxSet,
147+ syntax: &SyntaxReference,
148+ text: &str,
149+) -> Option<Vec<String>> {
150+ let mut state = ParseState::new(syntax);
151+ let mut stack = ScopeStack::new();
152+ let mut lines = Vec::new();
153+
154+ for line in LinesWithEndings::from(text) {
155+ let ops = state.parse_line(line, syntaxes).ok()?;
156+
157+ let mut html = String::with_capacity(line.len());
158+ for scope in &stack.scopes {
159+ open(&mut html, &scope.build_string());
160+ }
161+
162+ let (body, _) = line_tokens_to_classed_spans(line, &ops, CLASS_STYLE, &mut stack).ok()?;
163+ // The line ending was handed to the parser because the `_newlines` syntaxes
164+ // expect it, but it must not reach the cell: `white-space: pre` would render it
165+ // as a second, empty line inside every row. It can be escaped anywhere in the
166+ // fragment — inside a span, or after one closes — so it is dropped by character
167+ // rather than trimmed off the end.
168+ html.extend(body.chars().filter(|c| *c != '\n' && *c != '\r'));
169+
170+ // Whatever the line left open on the stack is open in the markup too, and is
171+ // closed here so the fragment stands alone in its own table cell.
172+ for _ in 0..stack.scopes.len() {
173+ html.push_str("</span>");
174+ }
175+
176+ lines.push(html);
177+ }
178+
179+ Some(lines)
180+}
181+
182+/// Opens one span for a scope, spelling its atoms the way syntect's own writer does.
183+fn open(html: &mut String, scope: &str) {
184+ html.push_str("<span class=\"");
185+ for (index, atom) in scope.split('.').filter(|atom| !atom.is_empty()).enumerate() {
186+ if index != 0 {
187+ html.push(' ');
188+ }
189+ html.push_str(PREFIX);
190+ html.push_str(atom);
191+ }
192+ html.push_str("\">");
193+}
194+
195+#[cfg(test)]
196+mod tests {
197+ use super::*;
198+
199+ fn classes(line: &SourceLine) -> String {
200+ match line {
201+ SourceLine::Classed(html) => html.clone(),
202+ SourceLine::Plain(text) => text.clone(),
203+ }
204+ }
205+
206+ #[test]
207+ fn rust_source_is_wrapped_in_prefixed_classes() {
208+ let source = source_lines("main.rs", "fn main() {\n // hi\n}\n");
209+
210+ assert!(!source.too_large);
211+ assert_eq!(source.lines.len(), 3);
212+ assert!(matches!(source.lines[0], SourceLine::Classed(_)));
213+
214+ let first = classes(&source.lines[0]);
215+ assert!(first.contains("class=\"hl-"), "{first}");
216+ // `fn` is `storage.type.function.rust`, so every atom becomes its own class.
217+ assert!(first.contains("hl-storage"), "{first}");
218+ assert!(first.contains("hl-entity hl-name hl-function"), "{first}");
219+ assert!(classes(&source.lines[1]).contains("hl-comment"));
220+ }
221+
222+ #[test]
223+ fn a_line_carries_no_line_ending_and_closes_every_span_it_opens() {
224+ let source = source_lines("main.rs", "fn main() {\n let x = \"a\";\n}\n");
225+
226+ for line in &source.lines {
227+ let html = classes(line);
228+ assert!(!html.contains('\n'), "{html}");
229+ assert_eq!(
230+ html.matches("<span").count(),
231+ html.matches("</span>").count(),
232+ "{html}"
233+ );
234+ }
235+ }
236+
237+ #[test]
238+ fn the_source_is_escaped_rather_than_passed_through() {
239+ let source = source_lines("main.rs", "// <script>alert(1)</script>\n");
240+
241+ let html = classes(&source.lines[0]);
242+ assert!(!html.contains("<script"), "{html}");
243+ assert!(html.contains("&lt;script"), "{html}");
244+ }
245+
246+ #[test]
247+ fn an_unknown_extension_stays_plain() {
248+ let source = source_lines("notes.wibble", "hello\nworld\n");
249+
250+ assert_eq!(
251+ source.lines,
252+ vec![
253+ SourceLine::Plain("hello".to_owned()),
254+ SourceLine::Plain("world".to_owned()),
255+ ]
256+ );
257+ }
258+
259+ #[test]
260+ fn a_shebang_names_the_language_when_the_name_does_not() {
261+ let source = source_lines("run", "#!/bin/sh\necho hi\n");
262+
263+ assert!(matches!(source.lines[0], SourceLine::Classed(_)));
264+ }
265+
266+ #[test]
267+ fn a_file_past_the_byte_cap_is_plain_and_says_so() {
268+ let text = format!("fn main() {{}}\n{}\n", "x".repeat(MAX_BYTES));
269+ let source = source_lines("main.rs", &text);
270+
271+ assert!(source.too_large);
272+ assert!(
273+ source
274+ .lines
275+ .iter()
276+ .all(|line| matches!(line, SourceLine::Plain(_)))
277+ );
278+ }
279+
280+ #[test]
281+ fn a_file_past_the_line_cap_is_plain_and_says_so() {
282+ let text = "let x = 1;\n".repeat(MAX_LINES + 1);
283+ let source = source_lines("main.rs", &text);
284+
285+ assert!(source.too_large);
286+ assert_eq!(source.lines.len(), MAX_LINES + 1);
287+ assert!(matches!(source.lines[0], SourceLine::Plain(_)));
288+ }
289+
290+ #[test]
291+ fn a_blank_line_keeps_its_height() {
292+ let source = source_lines("main.rs", "fn a() {}\n\nfn b() {}\n");
293+
294+ assert_eq!(source.lines[1], SourceLine::Plain(" ".to_owned()));
295+ }
296+
297+ #[test]
298+ fn plain_text_is_not_dressed_in_spans_that_colour_nothing() {
299+ let source = source_lines("notes.txt", "hello\n");
300+
301+ assert_eq!(source.lines, vec![SourceLine::Plain("hello".to_owned())]);
302+ }
303+
304+ /// The default syntax set is Sublime's, not a forge's, so the languages it lacks
305+ /// are worth knowing rather than discovering on a page.
306+ #[test]
307+ fn which_common_languages_the_default_set_knows() {
308+ for name in [
309+ "main.rs",
310+ "Cargo.toml",
311+ "Dockerfile",
312+ "install.sh",
313+ "README.md",
314+ "app.py",
315+ "index.js",
316+ "app.ts",
317+ "main.go",
318+ "config.yaml",
319+ "data.json",
320+ "styles.css",
321+ "query.sql",
322+ "Makefile",
323+ "main.c",
324+ "App.jsx",
325+ ] {
326+ let known = syntax_for(syntaxes(), name, "").is_some();
327+ println!("{name}: {}", if known { "highlighted" } else { "PLAIN" });
328+ }
329+ }
330+}
src/infrastructure/mod.rs+1 −0View file
@@ -1,6 +1,7 @@
11 pub mod database;
22 pub mod git;
33 pub mod git_query;
4+pub mod highlight;
45 pub mod password;
56 pub mod repository;
67 pub mod web;
src/infrastructure/web/browse.rs+26 −5View file
@@ -31,7 +31,7 @@ use topcoat::{
3131 header::{CONTENT_DISPOSITION, CONTENT_TYPE},
3232 page, path_param, route,
3333 },
34 view::{attributes, component, view},
34+ view::{Unescaped, attributes, component, view},
3535 };
3636
3737 use crate::{
@@ -42,6 +42,7 @@ use crate::{
4242 },
4343 components::badge::{BadgeVariant, badge},
4444 domain::{CommitSummary, EntryKind, RefName, RepoPath, TreeEntry},
45+ infrastructure::highlight::{Source, SourceLine, source_lines},
4546 };
4647
4748 use super::{
@@ -958,7 +959,10 @@ pub(super) async fn blob(
958959 </div>
959960
960961 match &file.text {
961 Some(text) => source(text: text.as_str()),
962+ Some(text) => source(
963+ text: text.as_str(),
964+ file_name: path.file_name().unwrap_or_default(),
965+ ),
962966 None if file.too_large => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
963967 "This file is " (size_of(file.size)) ", which is too large to display. Clone the repository to read it."
964968 </p>,
@@ -974,19 +978,36 @@ pub(super) async fn blob(
974978 ///
975979 /// A table rather than a `<pre>` with a gutter: the numbers stay put when the code
976980 /// scrolls sideways, and selecting the code does not drag the numbers along with it.
981+///
982+/// Highlighting only ever changes what is *inside* the code cell — the rows, the
983+/// numbers and the scroll container are the same whether a language was recognised or
984+/// not, so nothing else on the page has to know.
977985 #[component]
978async fn source(text: &str) -> Result {
986+async fn source(text: &str, file_name: &str) -> Result {
987+ let Source { lines, too_large } = source_lines(file_name, text);
988+
979989 view! {
990+ if too_large {
991+ <p class="border-b border-border px-4 py-1.5 font-mono text-xs text-muted-foreground">
992+ "Too large to highlight — shown as plain text."
993+ </p>
994+ }
995+
980996 <div class="overflow-x-auto">
981997 <table class="w-full border-collapse font-mono text-xs leading-relaxed">
982998 <tbody>
983 for (index, line) in text.lines().enumerate() {
999+ for (index, line) in lines.into_iter().enumerate() {
9841000 <tr>
9851001 <td class="w-px select-none border-r border-border px-3 text-right align-top text-muted-foreground">
9861002 ((index + 1).to_string())
9871003 </td>
9881004 <td class="whitespace-pre px-4 align-top">
989 (if line.is_empty() { " " } else { line })
1005+ match line {
1006+ // Markup this codebase wrote: `highlight` escapes
1007+ // the source itself and emits nothing but spans.
1008+ SourceLine::Classed(html) => (Unescaped::new_unchecked(html)),
1009+ SourceLine::Plain(text) => (text),
1010+ }
9901011 </td>
9911012 </tr>
9921013 }
styles.css+114 −0View file
@@ -23,6 +23,9 @@
2323 --shadow-xs a subtle shadow for controls: buttons, inputs
2424 --shadow-sm a shadow for raised surfaces: cards, menus
2525
26+ Syntax highlighting adds a --syntax-* family, derived from the same palette and
27+ mapped onto the highlighter's classes at the bottom of this file.
28+
2629 Interactive states (hover, press) have no tokens of their own: components
2730 derive them by applying the fill or foreground color at reduced opacity,
2831 which adapts to both color schemes automatically.
@@ -85,6 +88,16 @@
8588 --shadow-sm:
8689 0 1px 2px oklch(0.24 0.02 260 / 10%),
8790 0 2px 8px -2px oklch(0.24 0.02 260 / 9%);
91+
92+ --syntax-comment: oklch(0.55 0.015 260);
93+ --syntax-punctuation: oklch(0.46 0.015 260);
94+ --syntax-variable: oklch(0.3 0.014 260);
95+ --syntax-type: oklch(0.45 0.09 195);
96+ --syntax-function: oklch(0.48 0.15 250);
97+ --syntax-constant: oklch(0.5 0.12 62);
98+ --syntax-string: oklch(0.45 0.12 150);
99+ --syntax-keyword: oklch(0.45 0.17 300);
100+ --syntax-heading: oklch(0.42 0.16 250);
88101 }
89102
90103 /* Dark mode. The primary surface for this product. */
@@ -106,6 +119,16 @@
106119 /* Light-mode shadows disappear on a dark background, so these are deeper. */
107120 --shadow-xs: 0 1px 2px oklch(0 0 0 / 35%);
108121 --shadow-sm: 0 1px 2px oklch(0 0 0 / 35%), 0 2px 8px -2px oklch(0 0 0 / 30%);
122+
123+ --syntax-comment: oklch(0.6 0.014 260);
124+ --syntax-punctuation: oklch(0.72 0.014 260);
125+ --syntax-variable: oklch(0.86 0.02 260);
126+ --syntax-type: oklch(0.82 0.08 195);
127+ --syntax-function: oklch(0.78 0.11 240);
128+ --syntax-constant: oklch(0.82 0.11 70);
129+ --syntax-string: oklch(0.78 0.12 150);
130+ --syntax-keyword: oklch(0.75 0.12 300);
131+ --syntax-heading: oklch(0.75 0.13 250);
109132 }
110133
111134 @layer base {
@@ -113,3 +136,94 @@
113136 @apply bg-background font-sans text-foreground;
114137 }
115138 }
139+
140+/* Syntax highlighting.
141+ `infrastructure/highlight.rs` emits classes, never inline colors, so the theme
142+ lives here with everything else and follows the color scheme. Tailwind never sees
143+ these class names — the markup is generated, not written in `src/` — so this is
144+ plain CSS against the tokens above, as ui.md requires.
145+
146+ The classes are the *atoms* of a TextMate scope, flattened and prefixed: the scope
147+ `storage.type.function.rust` becomes `class="hl-storage hl-type hl-function hl-rust"`.
148+ Atoms are therefore not positional — `hl-function` is on `meta.function`, which
149+ wraps a whole function body, as well as on the name itself — so a single-atom
150+ selector is only safe for an atom that leads its scope. The rest are spelled out as
151+ the compound they really are.
152+
153+ Order is load-bearing. Spans nest, so an inner span with two matching rules of equal
154+ specificity takes the later one; `punctuation.definition.string` carries both
155+ `hl-punctuation` and `hl-string`, and a quote should read as its string. Weakest
156+ first, strongest last. */
157+
158+.hl-punctuation,
159+.hl-keyword.hl-operator {
160+ color: var(--syntax-punctuation);
161+}
162+
163+.hl-variable,
164+.hl-entity.hl-other.hl-attribute-name {
165+ color: var(--syntax-variable);
166+}
167+
168+.hl-entity.hl-name,
169+.hl-support.hl-function {
170+ color: var(--syntax-function);
171+}
172+
173+.hl-entity.hl-name.hl-type,
174+.hl-entity.hl-name.hl-class,
175+.hl-entity.hl-name.hl-struct,
176+.hl-entity.hl-name.hl-enum,
177+.hl-entity.hl-name.hl-trait,
178+.hl-entity.hl-name.hl-namespace,
179+.hl-support.hl-type,
180+.hl-support.hl-class {
181+ color: var(--syntax-type);
182+}
183+
184+.hl-constant,
185+.hl-support.hl-constant {
186+ color: var(--syntax-constant);
187+}
188+
189+.hl-storage,
190+.hl-keyword,
191+.hl-entity.hl-name.hl-tag {
192+ color: var(--syntax-keyword);
193+}
194+
195+.hl-string {
196+ color: var(--syntax-string);
197+}
198+
199+/* Markdown and other markup: the source is read as prose, so structure carries the
200+ color and the body stays at foreground. */
201+.hl-markup.hl-heading {
202+ color: var(--syntax-heading);
203+ font-weight: 600;
204+}
205+
206+.hl-markup.hl-raw {
207+ color: var(--syntax-string);
208+}
209+
210+.hl-markup.hl-underline.hl-link {
211+ color: var(--syntax-function);
212+}
213+
214+.hl-markup.hl-bold {
215+ font-weight: 600;
216+}
217+
218+.hl-markup.hl-italic {
219+ font-style: italic;
220+}
221+
222+/* Last, so a comment is a comment whatever it contains. */
223+.hl-comment {
224+ color: var(--syntax-comment);
225+}
226+
227+.hl-invalid {
228+ color: var(--destructive);
229+}