Commit 23ed98a

HPCesia <me@hpcesia.com>
2026-08-10 17:22:59
Code block template and style
Assisted-by: deepseek-v4-flash opencode
1 parent f8fdd94
Changed files (6)
src
contents
_lib
hypraw
templates
pages
styles
src/contents/_lib/hypraw/hypraw.typ
@@ -0,0 +1,199 @@
+/// A minimal reimplementation of hypraw for typst 0.15+, tailored for
+/// this site. Line numbers, per-block overrides, and line highlight are
+/// kept; the copy button and style dedup machinery (which relied on
+/// `html.style` and broke in typst 0.15) are dropped.
+///
+/// Credit: https://github.com/QuadnucYard/typst-hypraw
+
+/// Settings for the next code block only. Settings reset after use.
+#let hypraw-state = state("hypraw-style", (:))
+
+/// Override settings for the next code block only. Settings reset after use.
+///
+/// - line-numbers (auto, bool, int, array, none): Line number configuration:
+///   - `none` or `false`: Disable line numbers
+///   - `true`: Enable with default start (1)
+///   - `int`: Start from this line number
+///   - `array`: Custom labels per line (must match line count)
+/// - highlight (auto, array, dictionary): Line highlighting configuration:
+///   - Array of indices/ranges: e.g., `(0, 2, (4, 6))` applies default "highlight" class
+///   - Dictionary mapping classes to indices: e.g., `(ins: (0, 2), del: (3,))` for ins/del/mark effects
+#let hypraw-set(line-numbers: auto, highlight: auto) = context {
+  if line-numbers != auto {
+    hypraw-state.update(s => {
+      s.line-numbers = line-numbers
+      s
+    })
+  }
+  if highlight != auto {
+    hypraw-state.update(s => {
+      s.highlight = highlight
+      s
+    })
+  }
+}
+
+#let resolve-line-numbers(line-numbers, line-count) = {
+  if line-numbers == false or line-numbers == none {
+    // No line number
+    none
+  } else if line-numbers == true {
+    // Use 1 as default start number
+    range(1, 1 + line-count)
+  } else if type(line-numbers) == int {
+    // As start number
+    range(line-numbers, line-numbers + line-count)
+  } else if type(line-numbers) == array {
+    // As specified numbers.
+    if line-numbers.len() != line-count {
+      panic(
+        "Should provide line numbers for each line. Expected "
+          + str(line-count)
+          + ", received "
+          + str(line-numbers.len())
+          + ".",
+      )
+    }
+    line-numbers
+  } else {
+    // Invalid
+    panic("Invalid line numbers format: " + repr(line-numbers))
+  }
+}
+
+#let _expand-indices(items) = {
+  let result = ()
+  for item in items {
+    if type(item) == int {
+      // Single line index (1-based)
+      result.push(item - 1)
+    } else if type(item) == array and item.len() == 2 {
+      // Range (start, end) inclusive
+      let (start, end) = item
+      result += range(start - 1, end)
+    } else {
+      panic("Invalid highlight item format: " + repr(item))
+    }
+  }
+  result.sorted().dedup()
+}
+
+#let resolve-highlights(highlight, line-count) = {
+  if highlight == none or highlight == false {
+    (:)
+  } else if type(highlight) == array {
+    // Simple array format - applies "highlight" class to all
+    (highlight: _expand-indices(highlight))
+  } else if type(highlight) == dictionary {
+    // Dictionary format - maps class names to line indices
+    let result = (:)
+    for (class, items) in highlight {
+      if type(items) != array {
+        panic("Highlight items must be an array for class: " + class)
+      }
+      result.insert(class, _expand-indices(items))
+    }
+    result
+  } else {
+    panic("highlight must be an array or dictionary")
+  }
+}
+
+#let class-list(..cls) = cls.pos().filter(it => it != none).join(" ")
+
+/// Renders inline code as HTML `<code>` elements with syntax highlighting.
+#let code-inline-rule(it) = {
+  let attrs = (
+    class: class-list("hypraw", if it.lang != none { "language-" + it.lang }),
+  )
+
+  html.elem("code", attrs: attrs, {
+    it.lines.join("\n")
+  })
+}
+
+/// Renders a single line with optional line number gutter.
+#let render-line(line, line-number, line-classes: ()) = {
+  let classes = ("ec-line",) + line-classes
+  html.div(class: classes.join(" "), {
+    // Add gutter with line number
+    html.div(class: "gutter", {
+      html.div(class: "ln", {
+        html.span(aria-hidden: true, str(line-number))
+      })
+    })
+    // Code content
+    html.div(class: "code", line)
+  })
+}
+
+/// Renders block code as HTML `<div><pre><code>` structure with syntax
+/// highlighting. Supports optional line numbers displayed in a gutter.
+#let code-rule(it, default-line-numbers: false) = {
+  let style-state = hypraw-state.get()
+  hypraw-state.update((:)) // Reset upon used
+  let line-numbers = resolve-line-numbers(
+    style-state.at("line-numbers", default: default-line-numbers),
+    it.lines.len(),
+  )
+  let highlights = resolve-highlights(
+    style-state.at("highlight", default: none),
+    it.lines.len(),
+  )
+
+  let attrs = (class: "hypraw")
+  // Add class to indicate line numbers are enabled
+  if line-numbers != none {
+    attrs.class = "hypraw has-line-numbers"
+
+    // Calculate the width needed for line numbers (number of digits)
+    let ln-width = if line-numbers.len() > 0 {
+      // Use the width of the first and last number
+      calc.max(str(line-numbers.first()).len(), str(line-numbers.last()).len())
+    } else {
+      0
+    }
+    // Set line number width as CSS variable when width > 2
+    if ln-width > 2 {
+      attrs.style = "--ln-width:" + str(ln-width) + "ch"
+    }
+  }
+
+  html.div(..attrs, {
+    html.pre({
+      let code-attrs = (:)
+      if it.lang != none {
+        code-attrs.data-lang = it.lang
+      }
+      html.elem("code", attrs: code-attrs, {
+        if line-numbers != none {
+          // Render with line structure for line numbers
+          for (i, (line, ln)) in it.lines.zip(line-numbers).enumerate() {
+            // Collect all classes for this line from highlights
+            let line-classes = ()
+            for (class, indices) in highlights {
+              if i in indices {
+                line-classes.push(class)
+              }
+            }
+            render-line(line, ln, line-classes: line-classes)
+          }
+        } else {
+          // Original simple rendering without line structure
+          it.lines.join("\n")
+        }
+      })
+    })
+  })
+}
+
+/// Enables enhanced HTML code block rendering. Transforms raw code blocks
+/// into structured HTML when targeting HTML, preserves normal behavior
+/// otherwise.
+///
+/// - line-numbers (bool): Show line numbers in a gutter (default: false)
+#let hypraw(body, line-numbers: false) = {
+  show raw.where(block: false): code-inline-rule
+  show raw.where(block: true): code-rule.with(default-line-numbers: line-numbers)
+  body
+}
src/contents/_lib/templates/raw.typ
@@ -0,0 +1,17 @@
+#import "@preview/bullseye:0.1.0": show-target
+#import "../hypraw/hypraw.typ": hypraw
+
+#let raw-template-html(body) = {
+  show: hypraw.with(line-numbers: true)
+  body
+}
+
+#let raw-template-paged(body) = {
+  // TODO: raw style in PDF target
+  body
+}
+
+#let raw-template(body) = {
+  show: show-target(paged: raw-template-paged, html: raw-template-html)
+  body
+}
src/contents/_lib/lib.typ
@@ -2,8 +2,10 @@
   set text(lang: lang, region: region)
 
   import "./templates/heading.typ": heading-template
+  import "./templates/raw.typ": raw-template
 
   show: heading-template
+  show: raw-template
 
   context {
     let frontmatter = query(<frontmatter>).first(default: (value: (title: "", date: auto))).value
src/pages/posts/[slug].astro
@@ -3,6 +3,7 @@ import { getCollection, render } from "astro:content";
 import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
 
 import BaseLayout from "@/layouts/BaseLayout.astro";
+import "@/styles/article/index.scss";
 
 type Props = InferGetStaticPropsType<typeof getStaticPaths>;
 
@@ -25,5 +26,7 @@ const { Content } = await render(entry);
   author={data.author}
   description={data.desc}
 >
-  <Content />
+  <article>
+    <Content />
+  </article>
 </BaseLayout>
src/styles/article/index.scss
@@ -0,0 +1,1 @@
+@use "./raw.scss";
src/styles/article/raw.scss
@@ -0,0 +1,112 @@
+article {
+  code:not(:is(pre > code)) {
+    font-size: 1rem;
+    line-height: 1.5rem;
+  }
+
+  div.hypraw {
+    position: relative;
+    font-size: 1rem;
+    line-height: 1.5rem;
+    margin-top: 1.25rem;
+    margin-bottom: 1.25rem;
+    border-top: 1px solid;
+    border-bottom: 1px solid;
+    border-color: color-mix(in oklab, var(--color-base-content) 60%,
+      transparent);
+
+    pre {
+      margin: 0;
+      overflow-x: auto;
+
+      > code {
+        display: block;
+        padding: 0.5rem;
+
+        &[data-lang]::before {
+          position: absolute;
+          top: 0.5rem;
+          right: 0.5rem;
+          z-index: 1;
+          padding-left: 0.25rem;
+          padding-right: 0.25rem;
+          border: 1px solid color-mix(in oklab, var(--color-base-content) 60%,
+            transparent);
+          border-radius: 0.25rem;
+          opacity: 1;
+          font-size: 0.875rem;
+          line-height: 1.25rem;
+          color: var(--color-base-content);
+          background-color: var(--color-base-300);
+          content: attr(data-lang);
+          transition: opacity 0.2s;
+        }
+
+        &:hover::before {
+          opacity: 0;
+        }
+      }
+
+      &::-webkit-scrollbar,
+      &::-webkit-scrollbar-track {
+        background-color: inherit;
+        border-bottom-left-radius: 0.25rem;
+        border-bottom-right-radius: 0.25rem;
+      }
+
+      &::-webkit-scrollbar-thumb {
+        background-color: var(--color-base-300);
+        border: 4px solid transparent;
+        border-radius: 0.75rem;
+        background-clip: content-box;
+      }
+
+      &::-webkit-scrollbar-thumb:hover {
+        background-color: var(--color-base-content);
+      }
+    }
+
+    &.has-line-numbers .ec-line {
+      position: relative;
+      display: grid;
+      grid-template-columns: auto 1fr;
+
+      .code {
+        position: relative;
+        padding-left: 0.5rem;
+      }
+    }
+
+    .gutter {
+      border-right: 1px solid color-mix(in oklab, var(--color-base-content) 40%,
+        transparent);
+      user-select: none;
+      color: var(--color-base-content);
+
+      .ln {
+        display: inline-flex;
+        justify-content: flex-end;
+        align-items: flex-start;
+        min-width: 2em;
+        padding-right: 0.5rem;
+      }
+    }
+
+    .ec-line {
+      &.highlight {
+        background-color: color-mix(in oklab, var(--color-base-content) 10%,
+          transparent);
+      }
+
+      &.ins {
+        background-color: color-mix(in oklab, var(--color-success) 25%,
+          transparent);
+      }
+
+      &.del {
+        background-color: color-mix(in oklab, var(--color-error) 25%,
+          transparent);
+      }
+    }
+  }
+}