docs/content/features/highlighting.md (5597B)
1 +++
2 title = "Syntax highlighting"
3 description = "Highlighted at build time, as classes, in two themes"
4 weight = 10
5 toc = true
6 +++
7
8 Code blocks are highlighted while the site is built. No JavaScript runs in the
9 reader's browser to colour them, so the page is coloured on first paint.
10
11 ```toml
12 [highlight]
13 enabled = true
14 theme_light = "InspiredGitHub"
15 theme_dark = "base16-ocean.dark"
16 ```
17
18 ## Classes, never inline styles
19
20 A highlighted block looks like this:
21
22 ```html
23 <pre class="highlight"><code class="language-rust" data-lang="rust"><span class="hl-source hl-rust">…</span></code></pre>
24 ```
25
26 Not `style="color:#268bd2"`. The distinction decides three things.
27
28 **Dark mode is possible at all.** Inline colours pin one theme into every
29 document you have ever generated. Changing it means rebuilding the site;
30 following the reader's preference means shipping both themes inside the markup.
31 With classes, the two themes are two blocks of CSS, and switching is a CSS
32 switch.
33
34 **A strict `style-src` stays available.** A Content-Security-Policy that
35 forbids inline styles is off the table the moment your HTML is full of them.
36
37 **The stylesheet is one file.** Change a colour and every page changes, without
38 rebuilding a single page of HTML.
39
40 ## Two themes, one stylesheet
41
42 `theme_light` and `theme_dark` are both compiled into the generated stylesheet.
43 The dark rules are emitted twice — once under `prefers-color-scheme: dark` for
44 readers who never touch a toggle, and once under `[data-theme="dark"]` for
45 sites like this one that offer a switch.
46
47 Any theme name from syntect's default set works. A name that does not exist is
48 a build error, and the message lists the ones that do.
49
50 ## The prefix
51
52 Classes are prefixed `hl-`. Without a prefix, syntect emits class names like
53 `source`, `keyword` and `string` — words general enough to collide with your
54 own stylesheet on a site about programming.
55
56 ## Marking lines and naming files
57
58 Options go after the language, space-separated, as `key=value`:
59
60 ````markdown
61 ```rust hl_lines=2-3 name=src/main.rs
62 fn main() {
63 let marked = 2;
64 let also_marked = 3;
65 }
66 ```
67 ````
68
69 And rendered, on this site's stylesheet:
70
71 ```rust hl_lines=2-3 name=src/main.rs
72 fn main() {
73 let marked = 2;
74 let also_marked = 3;
75 }
76 ```
77
78 `hl_lines` takes 1-based line numbers and closed ranges, comma-separated:
79 `hl_lines=2-4,7`. Each listed line is wrapped in `<mark class="hl-line">` —
80 `<mark>` is visible with no CSS at all, because browsers style it out of the
81 box, and a site restyles it with `.highlight mark`. Highlighting continues
82 across the boundary: marking the middle line of a block comment keeps it a
83 comment.
84
85 `name` labels the block with a file name. It ships as an attribute, not as
86 markup — `<code … data-name="src/main.rs">` — and your CSS decides whether to
87 show it:
88
89 ```css
90 .prose pre code { display: block; width: max-content; min-width: 100%; }
91 .prose pre code[data-name]::before {
92 content: attr(data-name);
93 display: block;
94 }
95 ```
96
97 The first rule widens `code` to the longest line. It is what keeps the label —
98 and `hl_lines` marks — running to the end of a block that scrolls sideways,
99 instead of stopping at the visible width.
100
101 Why this syntax and not Zola's `rust,hl_lines=2-4`: the comma glues the options
102 onto the language token, which then fails syntax lookup and pollutes
103 `class="language-…"`. After the first space the info string is free, and that
104 is where comrak itself splits it.
105
106 A typo is a build error, not a silent no-op. `hl_line=3`, `linenos=true`,
107 `hl_lines=9` on a three-line block, a key given twice — each stops the build
108 and names the file; the alternative is a page that ships unhighlighted while
109 looking finished. So does a fence that starts with options instead of a
110 language: `=` never appears in a language name, so `` ```hl_lines=2 `` is an
111 option in the language slot, not an unknown language. The options belong to
112 the highlighter, so with `enabled = false` they are neither applied nor
113 checked.
114
115 ## Line numbers and a copy button are yours
116
117 Neither is a configuration key, and `line_numbers` was deleted rather than
118 shipped inert — a setting that does nothing is worse than an absent one, because
119 someone sets it and waits.
120
121 Ordinary blocks carry no per-line markup to hang a CSS counter on — a wrapper
122 around every line of every block would tax all pages for a feature most blocks
123 never use. A gutter is a few lines of JS over the line count:
124
125 ```js
126 document.querySelectorAll(".prose pre > code").forEach(function (code) {
127 var lines = code.textContent.split("\n").length - 1;
128 var gutter = document.createElement("span");
129 gutter.className = "linenos";
130 for (var i = 1; i <= lines; i++) gutter.textContent += i + "\n";
131 code.parentElement.prepend(gutter);
132 });
133 ```
134
135 ```css
136 .prose pre { display: flex; gap: 1em; }
137 .prose pre .linenos { text-align: right; color: var(--ink-3); user-select: none; }
138 ```
139
140 A copy button is about ten lines, and the language is already on the element:
141
142 ```js
143 document.querySelectorAll(".prose pre").forEach(function (pre) {
144 var b = document.createElement("button");
145 b.textContent = "copy";
146 b.addEventListener("click", function () {
147 navigator.clipboard.writeText(pre.textContent);
148 });
149 pre.appendChild(b);
150 });
151 ```
152
153 Both live in your `static/`, both are yours to restyle, and neither adds a key
154 to a configuration file that has to keep meaning the same thing forever.
155
156 ## Turning it off
157
158 `enabled = false` skips highlighting and emits no stylesheet. Code blocks still
159 get `class="language-rust"`, so a client-side highlighter can pick them up.