Files
garden-astro/src/layouts/Base.astro
T
wesandClaude Fable 5 8f48656e91 Build stamp, live listening strip, OG cards, closer shiki ground
- footer: 'built <ts> · <hash> · <n> notes', hash linking to Gitea
  (GARDEN_COMMIT passed in by build.sh; '+' marks dirty-tree builds)
- homepage header: live strip fed by api.c0smere.net/spotify/summary
  (weekly aggregate + monthly top artist/genre), fail-closed like the
  odometer and header quote
- /og/<slug>.png satori+resvg cards for every note + the homepage;
  og:/twitter: meta in Base
- shiki github-dark-default (#0d1117 sits closer to the page ground)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:08:53 -04:00

820 lines
23 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import 'katex/dist/katex.min.css';
import { getCollection } from 'astro:content';
import { buildNavTree } from '../lib/nav.mjs';
import { COMMIT, BUILT_AT } from '../lib/build-info.mjs';
import NavTree from '../components/NavTree.astro';
import Toc from '../components/Toc.astro';
interface Props {
title: string;
description?: string;
headings?: { depth: number; slug: string; text: string }[];
// which /og/<slug>.png card this page advertises; synthetic listing
// pages fall back to the site card
ogSlug?: string;
}
const { title, description, headings = [], ogSlug = 'index' } = Astro.props;
const isHome = Astro.url.pathname === '/';
const entries = await getCollection('garden');
const navTree = buildNavTree(entries);
const noteCount = entries.length;
const showToc = headings.filter((h) => h.depth <= 3).length >= 2;
const ogImage = new URL(`/og/${ogSlug}.png`, Astro.site);
const pageUrl = new URL(Astro.url.pathname, Astro.site);
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}{isHome ? '' : ' // Wesley Ray'}</title>
<meta name="theme-color" content="#0b0d12" />
{description && <meta name="description" content={description} />}
<meta property="og:title" content={title} />
<meta property="og:type" content={isHome ? 'website' : 'article'} />
<meta property="og:url" content={pageUrl} />
{description && <meta property="og:description" content={description} />}
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<link
rel="preload"
href="/fonts/jetbrains-mono-v13-latin-regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/jetbrains-mono-v13-latin-700.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<script
defer
data-domain="garden.c0smere.net"
src="https://plausible.io/js/script.js"></script>
</head>
<body class={isHome ? 'home' : undefined}>
<div class="layout">
<div class="content-col">
<header>
<div class="site-head">
<a class="site-logo" href="/">Wesley Ray</a>
<nav class="site-nav">
<a href="https://blog.c0smere.net">blog</a>
<a href="https://git.c0smere.net/wes">git</a>
<a href="https://resume.c0smere.net/raywes88/easy-bronze-gazelle"
>resume</a
>
</nav>
</div>
<div id="hq" class="header-quote" hidden>
<span id="hq-text"></span>
<span id="hq-attr" class="hq-attr"></span>
</div>
{
isHome && (
<div id="live-stats" class="live-stats" hidden>
<span class="live-dot" aria-hidden="true" />
<span id="live-spotify" />
</div>
)
}
</header>
<main>
<slot />
</main>
<footer>
<div class="footer-quotes">
<p>
Failure is unique among sin, in that we can turn it into a
virtue when it drives us to succeed.
</p>
<p>The living diminish, but the machine endures...</p>
</div>
<p>
Wesley Ray · <a href="https://blog.c0smere.net">blog</a> ·
<a href="https://git.c0smere.net/wes">git</a> ·
<a href="https://resume.c0smere.net/raywes88/easy-bronze-gazelle">resume</a>
</p>
<div id="odometer" hidden>
<div class="odo-display" id="odo-digits" aria-live="off"></div>
<p class="odo-caption">
bytes in + out of the homelab since <span id="odo-since"></span> ·
ticking at the lifetime average
</p>
</div>
{
COMMIT && (
<p class="build-stamp">
built {BUILT_AT} ET ·{' '}
<a
href={`https://git.c0smere.net/wes/garden-astro/commit/${COMMIT.replace('+', '')}`}
>
{COMMIT}
</a>{' '}
· {noteCount} notes
</p>
)
}
</footer>
</div>
<aside class="sidebar">
<p class="sidebar-title"><a href="/">the garden</a></p>
<NavTree nodes={navTree} currentPath={Astro.url.pathname} />
</aside>
{
showToc && (
<aside class="toc-col">
<Toc headings={headings} />
</aside>
)
}
</div>
<script>
// Header quote: one random reading highlight per page view.
// Fail-closed: any error leaves the slot hidden.
try {
const res = await fetch(
'https://api.c0smere.net/highlights/random?max_len=280',
);
if (!res.ok) throw new Error(String(res.status));
const q = await res.json();
document.getElementById('hq-text')!.textContent = `“${q.text}”`;
document.getElementById('hq-attr')!.textContent = q.author
? `— ${q.author}, ${q.book}`
: `— ${q.book}`;
document.getElementById('hq')!.hidden = false;
} catch {
/* API down or blocked: slot stays hidden */
}
</script>
<script>
// Homepage live strip: weekly listening aggregates from the API.
// Fail-closed: any error (or non-home page) leaves the slot hidden.
const strip = document.getElementById('live-stats');
if (strip) {
try {
const res = await fetch('https://api.c0smere.net/spotify/summary');
if (!res.ok) throw new Error(String(res.status));
const s = await res.json();
const slot = document.getElementById('live-spotify')!;
const put = (text: string, strong = false) => {
const el = document.createElement(strong ? 'span' : 'span');
if (strong) el.className = 'v';
el.textContent = text;
slot.append(el);
};
put(String(s.week_minutes), true);
put(' min of listening this week');
if (s.top_artist_month) {
put(' · lately: ');
put(s.top_artist_month, true);
}
if (s.top_genre_month) {
put(' · ');
put(s.top_genre_month, true);
}
strip.hidden = false;
} catch {
/* API down or blocked: strip stays hidden */
}
}
</script>
<script>
// Odometer: fetch totals once, then animate forward at the lifetime
// average rate. Looks live, but deliberately never exposes current
// throughput. Fail-closed: any error leaves the footer widget hidden.
const API = 'https://api.c0smere.net/bandwidth/odometer';
const section = document.getElementById('odometer')!;
const digitsEl = document.getElementById('odo-digits')!;
const sinceEl = document.getElementById('odo-since')!;
function renderDigits(total: bigint) {
const s = total.toString();
let html = '';
for (let i = 0; i < s.length; i++) {
if (i > 0 && (s.length - i) % 3 === 0)
html += '<span class="odo-sep"></span>';
html += `<span class="odo-digit">${s[i]}</span>`;
}
digitsEl.innerHTML = html;
}
try {
const res = await fetch(API);
if (!res.ok) throw new Error(String(res.status));
const data = await res.json();
const base = BigInt(data.total_bytes);
const rate = Number(data.avg_bytes_per_sec);
const asOf = Date.parse(data.as_of);
if (!Number.isFinite(rate) || !Number.isFinite(asOf))
throw new Error('bad payload');
sinceEl.textContent = new Date(data.since).toLocaleDateString(
undefined,
{ year: 'numeric', month: 'long', day: 'numeric' },
);
const tick = () => {
const elapsed = (Date.now() - asOf) / 1000;
renderDigits(base + BigInt(Math.floor(elapsed * rate)));
};
tick();
section.hidden = false;
setInterval(tick, 100);
} catch {
/* API down or blocked: widget stays hidden */
}
</script>
<script>
// mermaid: only load the (large) module on pages that need it
const blocks = document.querySelectorAll('pre > code.language-mermaid');
if (blocks.length) {
const { default: mermaid } = await import('mermaid');
blocks.forEach((code) => {
const pre = document.createElement('pre');
pre.className = 'mermaid';
pre.textContent = code.textContent ?? '';
code.parentElement?.replaceWith(pre);
});
mermaid.initialize({ startOnLoad: false, theme: 'dark' });
await mermaid.run();
}
</script>
<style is:global>
/* Same font files the Ghost blog (Source theme) serves — one property,
one set of faces: JetBrains Mono everywhere, Libre Baskerville for
quoted matter. */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/jetbrains-mono-v13-latin-regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/jetbrains-mono-v13-latin-700.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url('/fonts/jetbrains-mono-v13-latin-800.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/fonts/jetbrains-mono-v13-latin-italic.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('/fonts/jetbrains-mono-v13-latin-700italic.woff2')
format('woff2');
}
@font-face {
font-family: 'Libre Baskerville';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/libre-baskerville-v14-latin-regular.woff2')
format('woff2');
}
@font-face {
font-family: 'Libre Baskerville';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/fonts/libre-baskerville-v14-latin-italic.woff2')
format('woff2');
}
@font-face {
font-family: 'Libre Baskerville';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/libre-baskerville-v14-latin-700.woff2') format('woff2');
}
/* Tokens lifted from blog.c0smere.net (Ghost Source theme + code
injection): bg #0b0d12, white text, 64%-white secondary, accent
#218107. --link is the same green lifted for AA contrast on the
dark ground; --accent stays the blog's exact brand green for
borders and fills. Fixed dark — the blog has no light mode. */
:root {
--bg: #0b0d12;
--fg: #ffffff;
--muted: hsla(0, 0%, 100%, 0.64);
--faint: hsla(0, 0%, 100%, 0.16);
--hair: hsla(0, 0%, 100%, 0.08);
--accent: #218107;
--link: #2fa50f;
--link-bright: #46c421;
--mono:
'JetBrains Mono', ui-monospace, 'Cascadia Mono', Menlo, Consolas,
monospace;
--serif: 'Libre Baskerville', Georgia, serif;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--mono);
line-height: 1.7;
}
::selection {
background: rgba(33, 129, 7, 0.45);
}
.layout {
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
max-width: 44rem;
}
/* mobile: sidebar flows after the footer as a full site index */
.sidebar {
margin-top: 2.5rem;
padding-top: 1rem;
border-top: 1px solid var(--hair);
}
/* TOC only exists as a right column on wide screens */
.toc-col {
display: none;
}
@media (min-width: 72rem) {
.layout {
display: grid;
grid-template-columns: 17rem minmax(0, 44rem);
gap: 3rem;
max-width: 66rem;
}
.sidebar {
order: -1;
margin-top: 0;
padding-top: 0;
border-top: none;
position: sticky;
top: 0;
align-self: start;
max-height: 100vh;
overflow-y: auto;
padding: 1.5rem 0.5rem 1.5rem 0;
}
}
@media (min-width: 88rem) {
.layout {
grid-template-columns: 17rem minmax(0, 44rem) 13rem;
max-width: 80rem;
}
.toc-col {
display: block;
position: sticky;
top: 0;
align-self: start;
max-height: 100vh;
overflow-y: auto;
padding: 1.5rem 0 1.5rem 0.5rem;
}
}
/* ---- links ---- */
a {
color: var(--link);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--link) 40%, transparent);
text-underline-offset: 3px;
}
a:hover {
color: var(--link-bright);
text-decoration-color: currentColor;
}
/* ---- site header (mirrors the blog's gh-head) ---- */
.site-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem 1.5rem;
margin-bottom: 1.25rem;
}
.site-logo {
font-size: 1.05rem;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--fg);
text-decoration: none;
}
.site-logo:hover {
color: var(--link-bright);
}
.site-nav {
display: flex;
gap: 1.2rem;
font-size: 0.85rem;
}
.site-nav a {
color: var(--muted);
text-decoration: none;
}
.site-nav a:hover {
color: var(--fg);
}
.header-quote {
margin-bottom: 1.75rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--hair);
font-family: var(--serif);
font-style: italic;
font-size: 0.9rem;
line-height: 1.7;
color: var(--muted);
}
.header-quote .hq-attr {
font-family: var(--mono);
font-style: normal;
white-space: nowrap;
margin-left: 0.4rem;
font-size: 0.75rem;
}
.live-stats {
display: flex;
align-items: baseline;
gap: 0.55rem;
margin: -0.75rem 0 1.75rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid var(--hair);
font-size: 0.78rem;
color: var(--muted);
}
.live-stats .v {
color: var(--fg);
}
.live-dot {
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--link);
align-self: center;
animation: live-pulse 2.4s ease-in-out infinite;
}
@keyframes live-pulse {
50% {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
.live-dot {
animation: none;
}
}
/* ---- headings ---- */
h1, h2, h3, h4, h5, h6 {
line-height: 1.3;
letter-spacing: -0.02em;
scroll-margin-top: 1rem;
}
h1 {
font-size: 1.7rem;
font-weight: 800;
}
h2 {
font-size: 1.3rem;
font-weight: 700;
margin-top: 2.4em;
}
h3 {
font-size: 1.1rem;
font-weight: 700;
}
h4, h5, h6 {
font-size: 1rem;
font-weight: 700;
}
/* ---- sidebar ---- */
.sidebar-title {
margin: 0 0 0.5rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.sidebar-title a {
color: var(--fg);
text-decoration: none;
}
.sidebar ul {
list-style: none;
margin: 0;
padding-left: 0.85rem;
font-size: 0.8rem;
}
.sidebar > ul,
.sidebar-title + ul {
padding-left: 0;
}
.sidebar li {
margin: 0.15rem 0;
}
.sidebar a {
color: var(--muted);
text-decoration: none;
}
.sidebar a:hover {
color: var(--fg);
text-decoration: underline;
}
.sidebar a[aria-current='page'] {
color: var(--link);
font-weight: 700;
}
.sidebar summary {
cursor: pointer;
list-style: none;
}
.sidebar summary::before {
content: '';
display: inline-block;
width: 0.9em;
color: var(--muted);
font-size: 0.8em;
transition: transform 0.15s;
}
.sidebar details[open] > summary::before {
transform: rotate(90deg);
}
.sidebar summary a {
font-weight: 700;
color: var(--fg);
}
/* ---- table of contents ---- */
.toc-title {
margin: 0 0 0.5rem;
font-weight: 700;
font-size: 0.8rem;
}
.toc ul {
list-style: none;
margin: 0;
padding: 0;
font-size: 0.75rem;
border-left: 1px solid var(--hair);
}
.toc li {
margin: 0.2rem 0;
}
.toc li a {
display: block;
padding-left: 0.75rem;
color: var(--muted);
text-decoration: none;
border-left: 2px solid transparent;
margin-left: -1px;
}
.toc li.toc-d1 a {
padding-left: 1.5rem;
}
.toc li.toc-d2 a {
padding-left: 2.25rem;
}
.toc li a:hover {
color: var(--fg);
}
.toc li a.toc-active {
color: var(--link);
border-left-color: var(--link);
}
/* ---- content ---- */
img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
pre {
padding: 1rem 1.1rem;
border-radius: 6px;
border: 1px solid var(--hair);
overflow-x: auto;
font-size: 0.82rem;
line-height: 1.55;
}
:not(pre) > code {
background: var(--hair);
padding: 0.1em 0.35em;
border-radius: 4px;
font-size: 0.92em;
}
/* tables: transparent with hairline row borders, matching the blog's
table code injection */
table {
border-collapse: collapse;
display: block;
overflow-x: auto;
max-width: 100%;
font-size: 0.9rem;
}
th, td {
border: 0;
border-bottom: 1px solid var(--faint);
padding: 0.5em 0.85em;
text-align: left;
}
hr {
border: 0;
border-top: 1px solid var(--faint);
margin: 2.5rem 0;
}
.katex-display {
overflow-x: auto;
overflow-y: hidden;
}
/* quoted matter goes serif-italic, like the blog's serif accents */
blockquote {
margin: 1.5rem 0;
padding: 0.1rem 0 0.1rem 1.25rem;
border-left: 3px solid var(--accent);
font-family: var(--serif);
font-style: italic;
font-size: 0.95rem;
line-height: 1.8;
color: var(--muted);
}
/* Obsidian callouts are blockquotes too — keep them mono/upright */
.callout {
font-family: var(--mono);
font-style: normal;
font-size: 1rem;
line-height: 1.7;
color: var(--fg);
border-left: 4px solid var(--accent);
border-radius: 4px;
background: rgba(33, 129, 7, 0.1);
padding: 0.1rem 1rem;
}
.callout-title {
color: var(--link);
}
.callout-warning, .callout-attention, .callout-important {
border-left-color: #c9803a;
background: rgba(201, 128, 58, 0.1);
}
.callout-warning .callout-title,
.callout-attention .callout-title,
.callout-important .callout-title {
color: #d89a55;
}
/* ---- footer ---- */
footer {
margin-top: 4rem;
padding-top: 1.25rem;
border-top: 1px solid var(--hair);
font-size: 0.8rem;
color: var(--muted);
}
footer a {
color: var(--muted);
}
footer a:hover {
color: var(--fg);
}
.footer-quotes {
font-family: var(--serif);
font-style: italic;
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.footer-quotes p {
margin: 0.25rem 0;
}
.odo-display {
display: flex;
flex-wrap: wrap;
gap: 2px;
font-family: var(--mono);
font-size: 0.95rem;
font-variant-numeric: tabular-nums;
}
.odo-display .odo-digit {
display: inline-block;
min-width: 1.3ch;
padding: 0.12em 0.08em;
text-align: center;
background: #11150e;
color: #56dd33;
border-radius: 3px;
border: 1px solid var(--hair);
border-bottom: 2px solid #000;
}
.odo-display .odo-sep {
width: 0.3ch;
}
.odo-caption {
margin-top: 0.35rem;
font-size: 0.72rem;
}
.build-stamp {
margin-top: 1.25rem;
font-size: 0.72rem;
color: hsla(0, 0%, 100%, 0.4);
}
.build-stamp a {
color: inherit;
}
.build-stamp a:hover {
color: var(--link-bright);
}
/* ---- homepage: typographic feed, like the blog's index ---- */
.home main h1 {
font-size: 1.35rem;
font-weight: 800;
margin: 2.4rem 0 0.75rem;
}
/* the vault-authored links row at the very top: render as a small
uppercase link bar, not a display heading */
.home main > h1:first-child {
margin: 0 0 0.5rem;
font-size: 0.8rem;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.home main > h1:first-child a {
color: var(--muted);
text-decoration: none;
}
.home main > h1:first-child a:hover {
color: var(--link-bright);
text-decoration: underline;
}
.home main h2 {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--muted);
margin: 1.9rem 0 0.4rem;
}
.home main hr {
margin: 2rem 0;
}
.home main > ul {
list-style: none;
margin: 0.5rem 0 1.5rem;
padding: 0;
}
.home main > ul > li {
padding: 0.6rem 0;
border-bottom: 1px solid var(--hair);
color: var(--muted);
font-size: 0.9rem;
line-height: 1.6;
}
.home main > ul > li:last-child {
border-bottom: 0;
}
.home main > ul > li > a {
font-weight: 700;
}
.home main ul ul {
list-style: none;
margin: 0.4rem 0 0;
padding-left: 1.25rem;
font-size: 0.85rem;
}
.home main ul ul li {
padding: 0.15rem 0;
border: 0;
}
</style>
</body>
</html>