Commit 23ed98a
Changed files (6)
src
contents
_lib
pages
posts
styles
article
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);
+ }
+ }
+ }
+}