main
1/// A minimal reimplementation of hypraw for typst 0.15+, tailored for
2/// this site. Line numbers, per-block overrides, and line highlight are
3/// kept; the copy button and style dedup machinery (which relied on
4/// `html.style` and broke in typst 0.15) are dropped.
5///
6/// Credit: https://github.com/QuadnucYard/typst-hypraw
7
8/// Settings for the next code block only. Settings reset after use.
9#let hypraw-state = state("hypraw-style", (:))
10
11/// Override settings for the next code block only. Settings reset after use.
12///
13/// - line-numbers (auto, bool, int, array, none): Line number configuration:
14/// - `none` or `false`: Disable line numbers
15/// - `true`: Enable with default start (1)
16/// - `int`: Start from this line number
17/// - `array`: Custom labels per line (must match line count)
18/// - highlight (auto, array, dictionary): Line highlighting configuration:
19/// - Array of indices/ranges: e.g., `(0, 2, (4, 6))` applies default "highlight" class
20/// - Dictionary mapping classes to indices: e.g., `(ins: (0, 2), del: (3,))` for ins/del/mark effects
21#let hypraw-set(line-numbers: auto, highlight: auto) = context {
22 if line-numbers != auto {
23 hypraw-state.update(s => {
24 s.line-numbers = line-numbers
25 s
26 })
27 }
28 if highlight != auto {
29 hypraw-state.update(s => {
30 s.highlight = highlight
31 s
32 })
33 }
34}
35
36#let resolve-line-numbers(line-numbers, line-count) = {
37 if line-numbers == false or line-numbers == none {
38 // No line number
39 none
40 } else if line-numbers == true {
41 // Use 1 as default start number
42 range(1, 1 + line-count)
43 } else if type(line-numbers) == int {
44 // As start number
45 range(line-numbers, line-numbers + line-count)
46 } else if type(line-numbers) == array {
47 // As specified numbers.
48 if line-numbers.len() != line-count {
49 panic(
50 "Should provide line numbers for each line. Expected "
51 + str(line-count)
52 + ", received "
53 + str(line-numbers.len())
54 + ".",
55 )
56 }
57 line-numbers
58 } else {
59 // Invalid
60 panic("Invalid line numbers format: " + repr(line-numbers))
61 }
62}
63
64#let _expand-indices(items) = {
65 let result = ()
66 for item in items {
67 if type(item) == int {
68 // Single line index (1-based)
69 result.push(item - 1)
70 } else if type(item) == array and item.len() == 2 {
71 // Range (start, end) inclusive
72 let (start, end) = item
73 result += range(start - 1, end)
74 } else {
75 panic("Invalid highlight item format: " + repr(item))
76 }
77 }
78 result.sorted().dedup()
79}
80
81#let resolve-highlights(highlight, line-count) = {
82 if highlight == none or highlight == false {
83 (:)
84 } else if type(highlight) == array {
85 // Simple array format - applies "highlight" class to all
86 (highlight: _expand-indices(highlight))
87 } else if type(highlight) == dictionary {
88 // Dictionary format - maps class names to line indices
89 let result = (:)
90 for (class, items) in highlight {
91 if type(items) != array {
92 panic("Highlight items must be an array for class: " + class)
93 }
94 result.insert(class, _expand-indices(items))
95 }
96 result
97 } else {
98 panic("highlight must be an array or dictionary")
99 }
100}
101
102#let class-list(..cls) = cls.pos().filter(it => it != none).join(" ")
103
104/// Renders inline code as HTML `<code>` elements with syntax highlighting.
105#let code-inline-rule(it) = {
106 let attrs = (
107 class: class-list("hypraw", if it.lang != none { "language-" + it.lang }),
108 )
109
110 html.elem("code", attrs: attrs, {
111 it.lines.join("\n")
112 })
113}
114
115/// Renders a single line with optional line number gutter.
116#let render-line(line, line-number, line-classes: ()) = {
117 let classes = ("ec-line",) + line-classes
118 html.div(class: classes.join(" "), {
119 // Add gutter with line number
120 html.div(class: "gutter", {
121 html.div(class: "ln", {
122 html.span(aria-hidden: true, str(line-number))
123 })
124 })
125 // Code content
126 html.div(class: "code", line)
127 })
128}
129
130/// Renders block code as HTML `<div><pre><code>` structure with syntax
131/// highlighting. Supports optional line numbers displayed in a gutter.
132#let code-rule(it, default-line-numbers: false) = {
133 let style-state = hypraw-state.get()
134 hypraw-state.update((:)) // Reset upon used
135 let line-numbers = resolve-line-numbers(
136 style-state.at("line-numbers", default: default-line-numbers),
137 it.lines.len(),
138 )
139 let highlights = resolve-highlights(
140 style-state.at("highlight", default: none),
141 it.lines.len(),
142 )
143
144 let attrs = (class: "hypraw")
145 // Add class to indicate line numbers are enabled
146 if line-numbers != none {
147 attrs.class = "hypraw has-line-numbers"
148
149 // Calculate the width needed for line numbers (number of digits)
150 let ln-width = if line-numbers.len() > 0 {
151 // Use the width of the first and last number
152 calc.max(str(line-numbers.first()).len(), str(line-numbers.last()).len())
153 } else {
154 0
155 }
156 // Set line number width as CSS variable when width > 2
157 if ln-width > 2 {
158 attrs.style = "--ln-width:" + str(ln-width) + "ch"
159 }
160 }
161
162 html.div(..attrs, {
163 html.pre({
164 let code-attrs = (:)
165 if it.lang != none {
166 code-attrs.data-lang = it.lang
167 }
168 html.elem("code", attrs: code-attrs, {
169 if line-numbers != none {
170 // Render with line structure for line numbers
171 for (i, (line, ln)) in it.lines.zip(line-numbers).enumerate() {
172 // Collect all classes for this line from highlights
173 let line-classes = ()
174 for (class, indices) in highlights {
175 if i in indices {
176 line-classes.push(class)
177 }
178 }
179 render-line(line, ln, line-classes: line-classes)
180 }
181 } else {
182 // Original simple rendering without line structure
183 it.lines.join("\n")
184 }
185 })
186 })
187 })
188}
189
190/// Enables enhanced HTML code block rendering. Transforms raw code blocks
191/// into structured HTML when targeting HTML, preserves normal behavior
192/// otherwise.
193///
194/// - line-numbers (bool): Show line numbers in a gutter (default: false)
195#let hypraw(body, line-numbers: false) = {
196 show raw.where(block: false): code-inline-rule
197 show raw.where(block: true): code-rule.with(
198 default-line-numbers: line-numbers,
199 )
200 body
201}