website

Personal website
git clone https://github.com/michaelneuper/website.git
README | Files | Log | Refs

commit 2bf76e2ee6f1e03a1c2f0dfa8411924082a6660a
parent 50b459cd6d822d87d5c9981389689e1fa235487e
Author: Michael Neuper <michael@michaelneuper.com>
Date:   Mon, 12 Oct 2026 16:06:40 +0200

style posts as org buffers

Diffstat:
Mnetlify.toml | 11+++++++++++
Msrc/layouts/Base.astro | 19++++++++++++++++++-
Msrc/pages/404.astro | 3---
Msrc/pages/index.astro | 5-----
Msrc/pages/posts/[slug].astro | 40+++++++++++++++++++---------------------
Msrc/scripts/page.js | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/styles/style.css | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
7 files changed, 357 insertions(+), 81 deletions(-)

diff --git a/netlify.toml b/netlify.toml @@ -16,3 +16,14 @@ for = "/*" for = "/_astro/*" [headers.values] Cache-Control = "public, max-age=31536000, immutable" + +# The comments' frame on giscus.app loads its stylesheet and our fonts from here. +[[headers]] +for = "/giscus/*" + [headers.values] + Access-Control-Allow-Origin = "https://giscus.app" + +[[headers]] +for = "/fonts/*" + [headers.values] + Access-Control-Allow-Origin = "https://giscus.app" diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro @@ -25,6 +25,9 @@ const { skip, } = Astro.props; const at = home ? "" : "/"; +// Google Analytics, as on the Hugo site: production builds only, so local work isn't counted. +const GA_ID = "G-BLX24KP7CS"; +const year = new Date().getFullYear(); --- <!doctype html> <html lang="en" data-theme="doom-gruvbox"> @@ -47,6 +50,17 @@ const at = home ? "" : "/"; if (t) document.documentElement.dataset.theme = t; } catch (e) {} </script> +{import.meta.env.PROD && ( + <Fragment> + <script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}></script> + <script is:inline define:vars={{ GA_ID }}> + window.dataLayer = window.dataLayer || []; + function gtag() { dataLayer.push(arguments); } + gtag("js", new Date()); + gtag("config", GA_ID); + </script> + </Fragment> +)} <slot name="head" /> </head> <!-- Reading the source? Good instinct. flag{view_source_is_recon} --> @@ -59,7 +73,6 @@ const at = home ? "" : "/"; <span>michael neuper</span> </a> <nav class="nav" id="nav" aria-label="Sections"> - <a href={`${at}#top`}>pinout</a> <a href={`${at}#about`}>about</a> <a href={`${at}#projects`}>projects</a> <a href={`${at}#posts`}>posts</a> @@ -71,6 +84,10 @@ const at = home ? "" : "/"; <slot /> +<footer class="foot"> + <p>© {year} Michael Neuper</p> +</footer> + <!-- ───────────────────────── modeline and minibuffer ───────────────────────── --> <div class="which-key" hidden aria-live="polite"></div> <div class="frame-bottom"> diff --git a/src/pages/404.astro b/src/pages/404.astro @@ -13,8 +13,5 @@ import Base from "../layouts/Base.astro"; <p class="lede">Nothing lives at this address. If it was a page on my old site, it didn't make the move, but every post did, at the same address as before.</p> <p class="missing-links"><a href="/">Home</a> <a href="/#posts">Posts</a></p> </section> - <footer class="foot"> - <p class="dim">Press <kbd>SPC</kbd> for commands or <kbd>M-x</kbd> to run one by name.</p> - </footer> </main> </Base> diff --git a/src/pages/index.astro b/src/pages/index.astro @@ -236,7 +236,6 @@ yearly Vac Weeks.</div></div> <ul class="elsewhere"> <li><a href="https://github.com/michaelneuper">GitHub</a></li> <li><a href="https://www.linkedin.com/in/michaelneuper/">LinkedIn</a></li> - <li><a href="https://michaelneuper.com">michaelneuper.com</a></li> <li><a href="https://buymeacoffee.com/michaelneuper" target="_blank" rel="noopener">Buy me a coffee</a></li> </ul> </div> @@ -249,10 +248,6 @@ yearly Vac Weeks.</div></div> </div> </section> - <footer class="foot"> - <p>Made with Astro and Emacs, without cookies.</p> - <p class="dim">Press <kbd>SPC</kbd> for commands or <kbd>M-x</kbd> to run one by name.</p> - </footer> </main> <!-- ───────────────────────── coffee ───────────────────────── --> diff --git a/src/pages/posts/[slug].astro b/src/pages/posts/[slug].astro @@ -1,7 +1,8 @@ --- -// One post, shown as its Markdown buffer: front matter, then the text, with an -// imenu-list window of its headings beside it when the post asks for a table of -// contents, and the giscus comments from the old site below. +// One post, shown as an Org buffer: #+ keywords, then the text under starred +// headings that fold like Org's, with an imenu-list window of the headings +// beside it when the post asks for a table of contents, and the giscus +// comments from the old site below. The posts are Markdown underneath. import { render } from "astro:content"; import "katex/dist/katex.min.css"; import Base from "../../layouts/Base.astro"; @@ -19,37 +20,37 @@ export async function getStaticPaths() { const { post, newer, older } = Astro.props; const { Content, headings } = await render(post); const { title, date, tags, toc } = post.data; -const file = `${post.id}.md`; +const file = `${post.id}.org`; const words = wordCount(post.body ?? ""); const minutes = Math.max(1, Math.round(words / 230)); // (the footnotes' heading is only there for screen readers) const outline = toc ? headings.filter((h) => (h.depth === 2 || h.depth === 3) && h.slug !== "footnote-label") : []; --- -<Base title={`${title} - Michael Neuper`} description={summary(post.body ?? "")} buf={file} mode="Markdown" skip="#post"> +<Base title={`${title} - Michael Neuper`} description={summary(post.body ?? "")} buf={file} mode="Org" skip="#post"> <Fragment slot="head"> <script src="../../scripts/page.js"></script> </Fragment> <Fragment slot="help"> - <div><dt><code>imenu</code></dt><dd>jump to a heading in this post</dd></div> + <div><dt><kbd>TAB</kbd></dt><dd>fold the heading whose stars you clicked</dd></div> + <div><dt><kbd>S-TAB</kbd></dt><dd>cycle the whole buffer: overview, contents, show all</dd></div> + <div><dt><kbd>SPC i</kbd></dt><dd>imenu: jump to a heading</dd></div> <div><dt><kbd>SPC n</kbd></dt><dd>back to the list of posts</dd></div> </Fragment> <main> - <section class="sec post" id="post" data-file={file} data-mode="Markdown" aria-labelledby="post-title"> + <section class="sec post" id="post" data-file={file} data-mode="Org" aria-labelledby="post-title"> <p class="winbar"><span class="file">{file}</span><span class="rule" aria-hidden="true"></span><span class="meta">{words.toLocaleString("en")} words, {minutes} min</span></p> - <div class="fm"> - <p class="fm-fence" aria-hidden="true">---</p> - <p><span class="fm-k">date:</span> <time class="fm-ts" datetime={ymd(date)}>{ymd(date)} {weekday(date)}</time></p> + <header class="org-head"> + <h1 class="post-title" id="post-title"><span class="org-key" aria-hidden="true">#+title:</span> {title}</h1> + <p class="org-kw"><span class="org-key">#+author:</span> <span class="v">Michael Neuper</span></p> + <p class="org-kw"><span class="org-key">#+date:</span> <time class="org-ts" datetime={ymd(date)}>&lt;{ymd(date)} {weekday(date)}&gt;</time></p> {tags.length > 0 && ( - <p><span class="fm-k">tags:</span> [{tags.map((t, i) => ( - <Fragment>{i > 0 && ", "}<span class="t" style={`--tag: ${TAG_COLORS[t] ?? "var(--dim)"}`}>{t}</span></Fragment> - ))}]</p> + <p class="org-kw"><span class="org-key">#+filetags:</span> <span class="org-filetags">:{tags.map((t) => ( + <Fragment><span class="t" style={`--tag: ${TAG_COLORS[t] ?? "var(--dim)"}`}>{t}</span>:</Fragment> + ))}</span></p> )} - <p class="fm-fence" aria-hidden="true">---</p> - </div> - - <h1 class="post-title" id="post-title">{title}</h1> + </header> <div class:list={["post-cols", { "has-toc": outline.length > 0 }]}> {outline.length > 0 && ( @@ -57,7 +58,7 @@ const outline = toc ? headings.filter((h) => (h.depth === 2 || h.depth === 3) && <p class="winbar"><span class="file" id="toc-title">*Ilist*</span><span class="rule" aria-hidden="true"></span><span class="meta">contents</span></p> <ol> {outline.map((h) => ( - <li class={`d${h.depth}`}><a href={`#${h.slug}`}><span class="mk" aria-hidden="true">{"#".repeat(h.depth)}</span> {h.text}</a></li> + <li class={`d${h.depth}`}><a href={`#${h.slug}`}><span class="mk" aria-hidden="true">{"*".repeat(h.depth - 1)}</span> {h.text}</a></li> ))} </ol> </aside> @@ -80,8 +81,5 @@ const outline = toc ? headings.filter((h) => (h.depth === 2 || h.depth === 3) && <p class="dim giscus-note">Comments load from GitHub Discussions through giscus.<noscript> They need JavaScript.</noscript></p> </section> - <footer class="foot"> - <p class="dim">Press <kbd>SPC</kbd> for commands or <kbd>M-x</kbd> to run one by name.</p> - </footer> </main> </Base> diff --git a/src/scripts/page.js b/src/scripts/page.js @@ -1,10 +1,11 @@ // Pages other than the home page get the same Emacs (modeline, M-x, themes, -// which-key), plus, on posts: imenu over the headings, the *Ilist* window -// following along, and the giscus comments the old site had. +// which-key), plus, on posts: Org folding, imenu over the headings, the +// *Ilist* window following along, and the giscus comments the old site had. import { $, $$, on, themeColors, reducedMotion } from "./util.js"; -import { defcommand, completingRead } from "./shell.js"; +import { defcommand, completingRead, message } from "./shell.js"; import { initCtf } from "./ctf.js"; +import { DEFAULT_THEME } from "./themes.js"; // SPC a, p, n and m still go to the home page's sections. for (const id of ["top", "about", "projects", "posts", "contact"]) { @@ -13,15 +14,121 @@ for (const id of ["top", "about", "projects", "posts", "contact"]) { try { initCtf(); } catch (err) { console.error(err); } +const body = $(".post-body"); +const heads = $$(".post-body :is(h2, h3)[id]:not(.sr-only)"); +const titles = new Map(heads.map((h) => [h, h.textContent.trim()])); + +// ── folding, like Org ───────────────────────────────────────────────── +// Each heading owns what follows it, up to the next heading at its level or +// above. Clicking its stars folds that away. With the stars focused, TAB does +// the same and S-TAB cycles the whole buffer (overview, contents, show all), +// as in the posts list on the home page. Folding toggles a class rather than +// moving anything, so the videos keep playing. + +const outline = []; // { h, level, folded, owners, stars, ell, hasContent } +const owned = []; // [element, owners] for everything under a heading +const nodeOf = new Map(); +let contentsOnly = false; + +if (body) { + const levelOf = (el) => (/^H[234]$/.test(el.tagName) && !el.classList.contains("sr-only") ? +el.tagName[1] - 1 : 0); + const open = []; // the headings we're inside, outermost first + for (const el of body.children) { + if (el.matches(".footnotes")) break; + const level = levelOf(el); + if (!level) { + owned.push([el, [...open]]); + if (open.length) open.at(-1).hasContent = true; + continue; + } + while (open.length && open.at(-1).level >= level) open.pop(); + const node = { h: el, level, folded: false, owners: [...open], hasContent: false }; + node.stars = Object.assign(document.createElement("button"), { type: "button", className: "org-stars", tabIndex: -1, textContent: "*".repeat(level) }); + node.stars.setAttribute("aria-label", "Fold or unfold"); + node.ell = Object.assign(document.createElement("span"), { className: "org-ellipsis", textContent: "...", hidden: true }); + node.ell.setAttribute("aria-hidden", "true"); + el.prepend(node.stars); + el.append(node.ell); + el.classList.add("has-stars"); + node.stars.addEventListener("click", () => { + toggle(node); + node.stars.focus({ preventScroll: true }); + }); + node.stars.addEventListener("keydown", (e) => { + if (e.key !== "Tab") return; + e.preventDefault(); + if (e.shiftKey) cycle(); + else toggle(node); + }); + outline.push(node); + nodeOf.set(el, node); + open.push(node); + } +} + +function update() { + const away = (owners) => owners.some((o) => o.folded); + for (const n of outline) { + n.h.classList.toggle("is-folded-away", away(n.owners)); + n.stars.setAttribute("aria-expanded", String(!n.folded)); + n.ell.hidden = !(n.folded || (contentsOnly && n.hasContent)); + } + for (const [el, owners] of owned) { + el.classList.toggle("is-folded-away", away(owners) || (contentsOnly && owners.length > 0)); + } +} + +function toggle(node) { + if (contentsOnly) { + // Leaving the contents view: keep only the path to this heading open. + contentsOnly = false; + const path = new Set([node, ...node.owners]); + for (const n of outline) n.folded = !path.has(n); + node.folded = false; + } else { + node.folded = !node.folded; + } + update(); +} + +const STATES = ["OVERVIEW", "CONTENTS", "SHOW ALL"]; +let state = 2; +function cycle() { + state = (state + 1) % 3; + contentsOnly = state === 1; + for (const n of outline) n.folded = state === 0 && n.level === 1; + update(); + message(STATES[state]); +} + +// Unfold whatever hides a heading, before jumping to it. +function reveal(h) { + const node = nodeOf.get(h); + if (!node) return; + contentsOnly = false; + for (const o of node.owners) o.folded = false; + update(); +} + +if (outline.length) { + defcommand("org-shifttab", cycle, { doc: "Cycle the whole post: overview, contents, show all" }); + // S-TAB with nothing focused cycles too, like in the home page's Org buffer. + addEventListener("keydown", (e) => { + if (e.key === "Tab" && e.shiftKey && document.activeElement === document.body) { + e.preventDefault(); + cycle(); + } + }); +} + // ── imenu ───────────────────────────────────────────────────────────── -const heads = $$(".post-body :is(h2, h3)[id]:not(.sr-only)"); if (heads.length) { defcommand("imenu", async () => { - const text = (h) => h.textContent.trim(); - const pick = await completingRead("imenu: ", heads.map((h) => ({ value: text(h), ann: h.tagName === "H3" ? "subsection" : "" }))); - const h = heads.find((x) => text(x) === pick); + const pick = await completingRead("imenu: ", heads.map((h) => ({ value: titles.get(h), ann: h.tagName === "H3" ? "subsection" : "" }))); + const h = heads.find((x) => titles.get(x) === pick); if (!h) return; + reveal(h); h.scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth", block: "start" }); history.replaceState(null, "", `#${h.id}`); }, { doc: "Jump to a heading in this post", key: "SPC i" }); @@ -38,6 +145,7 @@ if (tocLinks.length) { const line = innerHeight * 0.3; let cur = null; for (const h of heads) { + if (h.offsetParent === null) continue; // folded away if (h.getBoundingClientRect().top > line) break; cur = h; } @@ -52,19 +160,23 @@ if (tocLinks.length) { }; addEventListener("scroll", () => { raf ||= requestAnimationFrame(mark); }, { passive: true }); mark(); + for (const a of tocLinks) { + a.addEventListener("click", () => reveal(document.getElementById(decodeURIComponent(a.hash.slice(1))))); + } } // ── comments ────────────────────────────────────────────────────────── // Threads are GitHub Discussions matched by path, so the old site's comments // still show up. The script only loads once you scroll near the comments. -const GISCUS_THEMES = { - "doom-gruvbox": "gruvbox_dark", - "doom-gruvbox-light": "gruvbox_light", - catppuccin: "catppuccin_mocha", +// The comments use a stylesheet made from the current theme's colours +// (src/pages/giscus/[theme].css.js). giscus fetches it from its own origin, +// which only the deployed site allows, so the dev server uses a built-in look. +const giscusTheme = () => { + const id = document.documentElement.dataset.theme || DEFAULT_THEME; + if (!import.meta.env.DEV) return `${location.origin}/giscus/${id}.css`; + return themeColors().light ? "noborder_light" : "transparent_dark"; }; -const giscusTheme = () => GISCUS_THEMES[document.documentElement.dataset.theme] - ?? (themeColors().light ? "noborder_light" : "transparent_dark"); const box = $(".giscus"); if (box) { diff --git a/src/styles/style.css b/src/styles/style.css @@ -2248,10 +2248,11 @@ input[type="range"]::-moz-range-thumb { } /* ───────────────────────────── posts ───────────────────────────── - A post is its Markdown buffer: the front matter in mono, then the text in - the serif, with headings keeping their # markers the way markdown-mode - shows them. Code is coloured from the current theme through the variables - Shiki's css-variables theme reads. */ + A post is an Org buffer the way Doom shows one with mixed-pitch-mode: #+ + keywords and markup in mono, the text in the serif, starred headings in + the org-level colours (the same as the posts list on the home page), and + code between #+begin_src and #+end_src. Code is coloured from the current + theme through the variables Shiki's css-variables theme reads. */ :root { --astro-code-foreground: var(--fg); @@ -2283,37 +2284,39 @@ input[type="range"]::-moz-range-thumb { white-space: nowrap; } -.fm { - margin: 0 0 32px; - color: var(--faint); +.org-head { + margin: 0 0 56px; font-size: 13px; - line-height: 1.7; -} - -.fm p { - margin: 0; + line-height: 1.75; } -.fm-k { - color: var(--blue); +.org-head .org-key { + color: var(--faint); } -.fm-ts { +.org-ts { color: var(--cyan); } -.fm .t { +.org-filetags .t { color: var(--tag, var(--dim)); } .post-title { max-width: 18em; - margin: 0 0 56px; + margin: 0 0 20px; font: 400 clamp(34px, 4.8vw, 54px)/1.08 var(--serif); letter-spacing: -0.01em; text-wrap: balance; } +.post-title .org-key { + margin-right: 0.3ch; + font: 400 15px/1 var(--mono); + letter-spacing: 0; + vertical-align: 0.5em; +} + .post-body { max-width: 680px; font: 400 19px/1.6 var(--serif); @@ -2335,14 +2338,24 @@ input[type="range"]::-moz-range-thumb { padding-left: 1.3em; } +.post-body ul { + list-style-type: "- "; +} + .post-body li { margin: 0.25em 0; } +.post-body li::marker { + color: var(--faint); + font: 400 15px var(--mono); +} + .post-body li > p { margin: 0; } +/* headings, coloured like org-level-1, -2 and -3 */ .post-body h2, .post-body h3, .post-body h4 { @@ -2353,60 +2366,145 @@ input[type="range"]::-moz-range-thumb { } .post-body h2 { + color: var(--blue); font-size: 30px; } .post-body h3 { + color: var(--magenta); font-size: 24px; } .post-body h4 { + color: var(--violet); font-size: 20px; } -.post-body :is(h2, h3, h4)::before { +/* The stars. page.js swaps them for buttons that fold the heading. */ +.post-body :is(h2, h3, h4)::before, +.post-body .org-stars { margin-right: 0.6ch; - color: var(--faint); font: 400 15px/1 var(--mono); vertical-align: 0.2em; } -.post-body h2::before { content: "##"; } -.post-body h3::before { content: "###"; } -.post-body h4::before { content: "####"; } +.post-body h2::before { content: "*"; } +.post-body h3::before { content: "**"; } +.post-body h4::before { content: "***"; } + +.post-body .has-stars::before { + content: none; +} +.post-body .org-stars { + cursor: pointer; +} + +.post-body .org-ellipsis { + margin-left: 0.5ch; + font: 400 15px/1 var(--mono); +} + +.post-body .is-folded-away { + display: none !important; +} + +/* emphasis keeps its Org markers: ~code~ /italic/ *bold* +strike+ */ .post-body :not(pre) > code { - padding: 0.05em 0.3em; - border-radius: 2px; - background: var(--hl); + color: var(--orange); font-size: 0.74em; } +.post-body :not(pre) > code::before, +.post-body :not(pre) > code::after { + content: "~"; +} + +.post-body em::before, +.post-body em::after { + content: "/"; +} + +.post-body strong::before, +.post-body strong::after { + content: "*"; +} + +.post-body :is(s, del)::before, +.post-body :is(s, del)::after { + content: "+"; +} + +.post-body :not(pre) > code::before, +.post-body :not(pre) > code::after, +.post-body :is(em, strong, s, del)::before, +.post-body :is(em, strong, s, del)::after { + color: var(--faint); + font-style: normal; + font-weight: 400; + text-decoration: none; +} + .post-body kbd { font: 400 0.72em/1 var(--mono); vertical-align: 0.1em; } +/* #+begin_src lang ... #+end_src */ .post-body pre { margin: 1.4em 0; - padding: 14px 16px; + padding: 0 16px; overflow-x: auto; background: var(--hl); font: 400 13px/1.6 var(--mono); tab-size: 4; } +.post-body pre::before, +.post-body pre::after { + display: block; + color: var(--faint); +} + +.post-body pre::before { + content: "#+begin_src " attr(data-language); + padding-top: 8px; +} + +.post-body pre[data-language="py"]::before { + content: "#+begin_src python"; +} + +.post-body pre::after { + content: "#+end_src"; + padding-bottom: 8px; +} + .post-body pre code { font-size: inherit; } .post-body blockquote { margin: 1.4em 0; - padding-left: 1em; - border-left: 2px solid var(--rule); color: var(--dim); } +.post-body blockquote::before, +.post-body blockquote::after { + display: block; + margin-left: -40px; + color: var(--faint); + font: 400 13px/1.6 var(--mono); +} + +.post-body blockquote::before { + content: "#+begin_quote"; +} + +.post-body blockquote::after { + content: "#+end_quote"; +} + .post-body img, .post-body video { display: block; @@ -2416,11 +2514,17 @@ input[type="range"]::-moz-range-thumb { } .post-body hr { - margin: 2.4em 0; + margin: 2em 0; border: 0; - border-top: 1px solid var(--rule); } +.post-body hr::before { + content: "-----"; + color: var(--faint); + font: 400 13px/1 var(--mono); +} + +/* org tables: | a | b | */ .post-body table { display: block; margin: 1.4em 0; @@ -2431,14 +2535,14 @@ input[type="range"]::-moz-range-thumb { .post-body th, .post-body td { - padding: 6px 16px 6px 0; - border-bottom: 1px solid var(--rule); + padding: 4px 1ch; + border: 1px solid var(--rule); text-align: left; vertical-align: top; } .post-body th { - color: var(--dim); + color: var(--blue); font-weight: 400; } @@ -2454,16 +2558,58 @@ input[type="range"]::-moz-range-thumb { overflow-y: hidden; } +/* [fn:1] references, and the notes under a * Footnotes heading */ +.post-body sup:has(> a[data-footnote-ref]) { + font: 400 13px/1 var(--mono); + vertical-align: baseline; +} + +.post-body a[data-footnote-ref] { + color: var(--cyan); + text-decoration: none; +} + +.post-body a[data-footnote-ref]::before { + content: "[fn:"; +} + +.post-body a[data-footnote-ref]::after { + content: "]"; +} + .post-body .footnotes { - margin-top: 3em; - padding-top: 1em; - border-top: 1px solid var(--rule); - color: var(--dim); - font-size: 15px; + margin-top: 1.7em; + font-size: 17px; +} + +.post-body .footnotes h2.sr-only { + position: static; + width: auto; + height: auto; + overflow: visible; + clip-path: none; + white-space: normal; } .post-body .footnotes ol { margin: 0; + padding: 0; + list-style: none; + counter-reset: fn; +} + +.post-body .footnotes li { + counter-increment: fn; +} + +.post-body .footnotes li::before { + content: "[fn:" counter(fn) "] "; + color: var(--cyan); + font: 400 13px var(--mono); +} + +.post-body .footnotes li > p { + display: inline; } /* imenu-list: the post's headings in a window beside it */