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}