From 72f4cee3f40287f854ed88caf9728caf706047ac Mon Sep 17 00:00:00 2001 From: karitham Date: Sun, 19 Jul 2026 17:26:14 +0200 Subject: [PATCH] posts: add anchor links to headers --- priv/static/style.css | 437 +++++++++++++------------------ src/data/frontmatter.gleam | 54 +++- test/data/frontmatter_test.gleam | 31 +++ 3 files changed, 264 insertions(+), 258 deletions(-) diff --git a/priv/static/style.css b/priv/static/style.css index 21f73d0..fd0cb06 100644 --- a/priv/static/style.css +++ b/priv/static/style.css @@ -62,63 +62,51 @@ body { line-height: 1.6; } -h1, -h2, -h3, -h4, -h5, -h6 { +/* Headings */ +:is(h1, h2, h3, h4, h5, h6) { font-family: inherit; font-weight: 600; margin: 0 0 0.75rem; text-wrap: balance; } -h1 { - font-size: 1.5rem; -} +h1 { font-size: 1.5rem; } -h2 { - font-size: 1.2rem; -} +h2 { font-size: 1.2rem; } -h3 { - font-size: 1.05rem; -} +h3 { font-size: 1.05rem; } +/* Links — terminal-style invert on hover */ a { color: var(--accent); text-decoration: none; - transition-property: color, background; - transition-duration: 120ms; - transition-timing-function: ease-out; -} + transition: color 120ms ease-out, background 120ms ease-out; -a:hover { - color: var(--bg); - background: var(--accent); -} + &:hover { + color: var(--bg); + background: var(--accent); + } -/* Inline elements inside an anchor should integrate with the link on - hover — otherwise the anchor's hover highlight gets hidden behind - their own background (e.g. nix). */ -a code, -a strong, -a em, -a i { - color: inherit; -} + /* Inline elements inside an anchor should integrate with the link on + hover — otherwise the anchor's hover highlight gets hidden behind + their own background (e.g. nix). */ + & code, + & strong, + & em, + & i { + color: inherit; + } -a:hover code { - background: transparent; + &:hover code { + background: transparent; + } } strong { color: var(--blue); } -i, -em { +:is(i, em) { color: var(--peach); } @@ -151,9 +139,7 @@ blockquote { color: var(--text-dim); } -p, -ul, -ol { +:is(p, ul, ol) { line-height: 1.65; margin: 0.85rem 0; text-wrap: pretty; @@ -177,9 +163,7 @@ video { display: block; } -figure { - margin: 0; -} +figure { margin: 0; } figcaption { font-size: 0.8rem; @@ -187,13 +171,13 @@ figcaption { margin-top: 0.35rem; } -.dim { - color: var(--text-dim); -} +/* ============================================================ + Utility classes + ============================================================ */ -.dimmer { - color: var(--text-dimmer); -} +.dim { color: var(--text-dim); } + +.dimmer { color: var(--text-dimmer); } .emph { color: var(--blue); @@ -235,17 +219,17 @@ figcaption { top: 0; z-index: 10; padding: 0.7rem 1.5rem; -} -#nav a { - color: var(--text-dim); - font-size: 0.9rem; - font-weight: 500; -} + & a { + color: var(--text-dim); + font-size: 0.9rem; + font-weight: 500; -#nav a:hover { - color: var(--text); - background: none; + &:hover { + color: var(--text); + background: none; + } + } } /* Footer */ @@ -266,14 +250,14 @@ footer { margin-bottom: 1rem; border-radius: 4px; overflow: hidden; -} -#banner img { - display: block; - width: 100%; - height: 220px; - object-fit: cover; - outline: none; + & img { + display: block; + width: 100%; + height: 220px; + object-fit: cover; + outline: none; + } } /* ============================================================ @@ -284,23 +268,21 @@ footer { margin-bottom: 1.75rem; border-radius: 4px; overflow: hidden; -} -.hero-image img { - display: block; - width: 100%; - height: 280px; - object-fit: cover; - outline: none; + & img { + display: block; + width: 100%; + height: 280px; + object-fit: cover; + outline: none; + } } /* ============================================================ Profile ============================================================ */ -#profile { - padding: 0; -} +#profile { padding: 0; } /* Avatar + text row */ .profile-header { @@ -310,9 +292,7 @@ footer { padding: 0.5rem 0 0 1.5rem; } -.profile-info { - padding-bottom: 2px; -} +.profile-info { padding-bottom: 2px; } #profile .avatar { width: 64px; @@ -329,18 +309,16 @@ footer { margin: 0; } -.handle { - margin: 0.15rem 0 0; -} +.handle { margin: 0.15rem 0 0; } .handle a { font-size: 0.85rem; color: var(--blue); -} -.handle a:hover { - background: var(--blue); - color: var(--bg); + &:hover { + background: var(--blue); + color: var(--bg); + } } .handle .pronouns { @@ -366,24 +344,20 @@ footer { margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid var(--border); -} -.profile-stats strong { - font-variant-numeric: tabular-nums; - color: var(--text); + & strong { + font-variant-numeric: tabular-nums; + color: var(--text); + } } /* ============================================================ Sections ============================================================ */ -.section { - margin: 2.5rem 0; -} +.section { margin: 2.5rem 0; } -#plays h2::before, -#repos h2::before, -.section-header h2::before { +:is(#plays, #repos, .section-header) h2::before { content: "$ "; color: var(--accent); } @@ -400,14 +374,8 @@ footer { } @keyframes stale-pulse { - 0%, - 100% { - opacity: 0.25; - } - - 50% { - opacity: 1; - } + 0%, 100% { opacity: 0.25; } + 50% { opacity: 1; } } /* No border-bottom — the rows below carry their own dividers. */ @@ -416,32 +384,29 @@ footer { justify-content: space-between; align-items: baseline; margin-bottom: 0.5rem; -} -.section-header h2 { - margin-bottom: 0; - font-size: 1.05rem; + & h2 { + margin-bottom: 0; + font-size: 1.05rem; + } } /* ============================================================ Cards (articles + projects) — flat rows ============================================================ */ -.card { - padding: 0.85rem 0.5rem; +/* Shared card/play-row structure */ +:is(.card, .play-row) { border-bottom: 1px solid var(--border); - margin-bottom: 0; - transition-property: background; - transition-duration: 120ms; - transition-timing-function: ease-out; -} + transition: background 120ms ease-out; -.card:last-child { - border-bottom: none; + &:last-child { border-bottom: none; } + &:hover { background: var(--bg-alt); } } -.card:hover { - background: var(--bg-alt); +.card { + padding: 0.85rem 0.5rem; + margin-bottom: 0; } .card-head { @@ -456,11 +421,11 @@ footer { font-weight: 600; font-size: 1rem; color: var(--accent); -} -.card-title:hover { - background: var(--accent); - color: var(--bg); + &:hover { + background: var(--accent); + color: var(--bg); + } } .card-date { @@ -484,9 +449,7 @@ footer { font-size: 0.78rem; } -.card-tag { - color: var(--blue); -} +.card-tag { color: var(--blue); } .card-tag::before { content: "#"; @@ -502,17 +465,6 @@ footer { align-items: baseline; gap: 1rem; padding: 0.3rem 0.5rem; - border-bottom: 1px solid var(--border); - transition-property: background; - transition-duration: 120ms; -} - -.play-row:last-child { - border-bottom: none; -} - -.play-row:hover { - background: var(--bg-alt); } .play-time { @@ -559,11 +511,11 @@ footer { .post h1 { margin-bottom: 0.4rem; color: var(--text); -} -.post h1::before { - content: "# "; - color: var(--blue); + &::before { + content: "# "; + color: var(--blue); + } } .post .post-meta { @@ -581,51 +533,63 @@ footer { line-height: 1.7; font-size: 1rem; text-wrap: pretty; -} -.post-content h2 { - margin-top: 2.5rem; -} + & h2 { + margin-top: 2.5rem; -.post-content h2::before { - content: "## "; - color: var(--accent); -} + &::before { + content: "## "; + color: var(--accent); + } + } -.post-content h3 { - margin-top: 1.75rem; -} + & h3 { + margin-top: 1.75rem; -.post-content h3::before { - content: "### "; - color: var(--green); -} + &::before { + content: "### "; + color: var(--green); + } + } -.post-content h4 { - margin-top: 1.25rem; - font-size: 1rem; -} + & h4 { + margin-top: 1.25rem; + font-size: 1rem; -.post-content h4::before { - content: "#### "; - color: var(--peach); -} + &::before { + content: "#### "; + color: var(--peach); + } + } -.post-content pre { - margin: 1.25rem 0; -} + /* Heading permalinks — text is the clickable link, + hit area extended to cover ::before markers */ + & :is(h2, h3, h4) { + scroll-margin-top: 5rem; + } -.post-content p { - margin: 1rem 0; -} + & .anchor { + color: inherit; + text-decoration: none; + /* Push hit area left to cover ::before (## / ### / ####) */ + padding-left: 3.5em; + margin-left: -3.5em; + + &:hover { + color: inherit; + background: transparent; + } + } -.post-content img { - margin: 1rem 0; -} + & :is(h2, h3, h4):hover .anchor { + text-decoration: underline; + text-underline-offset: 4px; + } -.post-content ul, -.post-content ol { - padding-left: 1.5rem; + & pre { margin: 1.25rem 0; } + & p { margin: 1rem 0; } + & img { margin: 1rem 0; } + & :is(ul, ol) { padding-left: 1.5rem; } } /* ============================================================ @@ -636,128 +600,87 @@ footer { display: flex; justify-content: center; padding: 1rem 0 0.5rem; -} -#socials a { - display: flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - color: var(--text-dimmer); - border: 1px solid var(--border); - margin-left: -1px; - transition-property: color, border-color, background; - transition-duration: 120ms; - transition-timing-function: ease-out; -} - -#socials a:first-child { - margin-left: 0; -} - -#socials a:hover { - color: var(--accent); - border-color: var(--accent); - background: var(--bg-alt); -} + & a { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + color: var(--text-dimmer); + border: 1px solid var(--border); + margin-left: -1px; + transition: color 120ms ease-out, border-color 120ms ease-out, + background 120ms ease-out; + + &:first-child { margin-left: 0; } + + &:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--bg-alt); + position: relative; + z-index: 1; + } + } -#socials svg { - width: 18px; - height: 18px; - fill: currentColor; + & svg { + width: 18px; + height: 18px; + fill: currentColor; + } } /* ============================================================ Syntax tokens (highlight.js, Catppuccin Macchiato) ============================================================ */ -.hljs { - color: var(--text); - background: transparent; -} +.hljs { color: var(--text); background: transparent; } -.hljs-comment, -.hljs-quote { +:is(.hljs-comment, .hljs-quote) { color: var(--text-dimmer); font-style: italic; } -.hljs-keyword, -.hljs-selector-tag, -.hljs-literal, -.hljs-section, -.hljs-link { +:is(.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link) { color: var(--accent); } -.hljs-string, -.hljs-attr, -.hljs-template-tag, -.hljs-template-variable, -.hljs-addition { +:is(.hljs-string, .hljs-attr, .hljs-template-tag, .hljs-template-variable, .hljs-addition) { color: var(--green); } -.hljs-number, -.hljs-symbol, -.hljs-bullet, -.hljs-meta .hljs-meta-keyword { +:is(.hljs-number, .hljs-symbol, .hljs-bullet, .hljs-meta .hljs-meta-keyword) { color: var(--peach); } -.hljs-type, -.hljs-class .hljs-title, -.hljs-title.class_, -.hljs-tag { +:is(.hljs-type, .hljs-class .hljs-title, .hljs-title.class_, .hljs-tag) { color: var(--yellow); } -.hljs-built_in, -.hljs-builtin-name { +:is(.hljs-built_in, .hljs-builtin-name) { color: var(--teal); } -.hljs-title, -.hljs-title.function_, -.hljs-function .hljs-title { +:is(.hljs-title, .hljs-title.function_, .hljs-function .hljs-title) { color: var(--blue); } -.hljs-variable, -.hljs-name { - color: var(--red); -} +:is(.hljs-variable, .hljs-name) { color: var(--red); } -.hljs-property, -.hljs-attribute { - color: var(--blue); -} +:is(.hljs-property, .hljs-attribute) { color: var(--blue); } -.hljs-regexp, -.hljs-deletion { - color: var(--pink); -} +:is(.hljs-regexp, .hljs-deletion) { color: var(--pink); } -.hljs-meta, -.hljs-meta-string { - color: var(--lavender); -} +:is(.hljs-meta, .hljs-meta-string) { color: var(--lavender); } -.hljs-params, -.hljs-punctuation, -.hljs-operator { +:is(.hljs-params, .hljs-punctuation, .hljs-operator) { color: var(--text-dim); } -.hljs-doctag, -.hljs-strong { - font-weight: 700; -} +:is(.hljs-doctag, .hljs-strong) { font-weight: 700; } -.hljs-emphasis { - font-style: italic; -} +.hljs-emphasis { font-style: italic; } /* ============================================================ Reduced motion diff --git a/src/data/frontmatter.gleam b/src/data/frontmatter.gleam index 589a721..bf9b552 100644 --- a/src/data/frontmatter.gleam +++ b/src/data/frontmatter.gleam @@ -69,11 +69,14 @@ fn parse_with_frontmatter( Ok(post) -> { case parse_article_date(post.date) { Ok(_) -> { + let options = mork.configure() |> mork.heading_ids(True) + let html_body = body |> string.trim - |> mork.parse + |> mork.parse_with_options(options, _) |> mork.to_html + |> inject_heading_anchors Ok(Post(..post, content: html_body)) } Error(_) -> Error(InvalidDate(slug, post.date)) @@ -90,6 +93,55 @@ fn parse_article_date(s: String) -> Result(timestamp.Timestamp, Nil) { timestamp.parse_rfc3339(s <> "T00:00:00Z") } +/// Post-process mork HTML to inject anchor links inside headings +/// that have an `id` attribute, so section headings become +/// clickable permalinks. +fn inject_heading_anchors(html: String) -> String { + ["2", "3", "4"] + |> list.fold(html, fn(acc, level) { inject_for_level(acc, level) }) +} + +fn inject_for_level(html: String, level: String) -> String { + let tag = " level <> " id=\"" + inject_rec(html, tag, level) +} + +fn inject_rec(html: String, tag: String, level: String) -> String { + case string.split_once(html, on: tag) { + Error(_) -> html + Ok(#(before, after)) -> { + // `after` starts with the id value (right after `id="`) + case string.split_once(after, on: "\"") { + Error(_) -> html + Ok(#(id, after_id)) -> { + // `after_id` starts with `>...` (the closing `>` of the tag) + case string.split_once(after_id, on: ">") { + Error(_) -> html + Ok(#(_, content)) -> { + let closing = " level <> ">" + case string.split_once(content, on: closing) { + Error(_) -> html + Ok(#(inner, rest)) -> { + let a = " id <> "\" class=\"anchor\">" + before + <> tag + <> id + <> "\">" + <> a + <> inner + <> "" + <> closing + <> inject_rec(rest, tag, level) + } + } + } + } + } + } + } + } +} + const slug_chars = "abcdefghijklmnopqrstuvwxyz0123456789-" const slug_start_chars = "abcdefghijklmnopqrstuvwxyz0123456789" diff --git a/test/data/frontmatter_test.gleam b/test/data/frontmatter_test.gleam index b98faaf..496593b 100644 --- a/test/data/frontmatter_test.gleam +++ b/test/data/frontmatter_test.gleam @@ -75,6 +75,37 @@ pub fn parse_malformed_date_fails_test() { }) } +pub fn parse_headings_get_anchor_links_test() { + let content = + "---\ntitle: T\ndate: 2026-07-18\n---\n" + <> "\n" + <> "## Prometheus\n" + <> "\n" + <> "Some text.\n" + <> "\n" + <> "### Alertmanager\n" + <> "\n" + <> "More text.\n" + + let assert Ok(post) = frontmatter.parse("t", content) + + // Each heading gets wrapped in an anchor link (text becomes the link) + string.contains( + post.content, + "

Prometheus

", + ) + |> should.be_true() + + string.contains( + post.content, + "

Alertmanager

", + ) + |> should.be_true() + + // Non-heading content is unaffected + string.contains(post.content, "

Some text.

") |> should.be_true() +} + pub fn parse_valid_iso_date_test() { let cases = ["2024-01-01", "2024-12-31", "2024-02-29", "1999-09-09"] list.each(cases, fn(good) { -- 2.51.2