Astro garden skeleton: Quartz-compatible URLs, Obsidian markdown, odometer homepage
- glob content loader over the vault's Digital_Garden with verbatim-path ids - remark plugin: wikilinks, image embeds (wiki + relative md), callouts - URL parity with live Quartz sitemap verified 102/102 (built set is a strict superset: +30 synthetic folder listings) - KaTeX, lazy mermaid, shiki dual themes, quoted-string draft handling - copy-assets resolves embeds vault-wide (fixes live-site 404s for Blog Scratch/assets images) into flat /assets/ - homepage odometer widget: fetches api.c0smere.net baseline, ticks at lifetime average rate, fail-closed hidden on error - deploy/: nginx try_files config + compose (port 18100) + containerized build script for cyrion Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
import { CONTENT_DIR } from './lib/quartz-compat.mjs';
|
||||
|
||||
const garden = defineCollection({
|
||||
loader: glob({
|
||||
pattern: ['**/*.md', '!**/assets/**'],
|
||||
base: CONTENT_DIR,
|
||||
// keep the vault-relative path verbatim (spaces + case) as the id;
|
||||
// slugging to Quartz-compatible URLs happens in quartz-compat.mjs
|
||||
generateId: ({ entry }) => entry.replace(/\.md$/, ''),
|
||||
}),
|
||||
schema: z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tags: z.union([z.array(z.string()), z.string()]).optional(),
|
||||
// vault uses quoted YAML ("true"/"false") in places; treat any
|
||||
// truthy value except the literal "false" as draft, like Quartz did
|
||||
draft: z
|
||||
.preprocess((v) => (typeof v === 'string' ? v !== 'false' : v), z.boolean())
|
||||
.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
});
|
||||
|
||||
export const collections = { garden };
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
const { title, description } = Astro.props;
|
||||
const isHome = Astro.url.pathname === '/';
|
||||
---
|
||||
|
||||
<!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>
|
||||
{description && <meta name="description" content={description} />}
|
||||
<script
|
||||
defer
|
||||
data-domain="garden.c0smere.net"
|
||||
src="https://plausible.io/js/script.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{!isHome && <nav><a href="/">← home</a></nav>}
|
||||
</header>
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<footer>
|
||||
<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">resume</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<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: matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'default',
|
||||
});
|
||||
await mermaid.run();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style is:global>
|
||||
:root {
|
||||
--bg: #faf8f8;
|
||||
--fg: #2b2b2b;
|
||||
--muted: #4e4e4e;
|
||||
--faint: #e5e5e5;
|
||||
--accent: #284b63;
|
||||
--accent2: #84a59d;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #161618;
|
||||
--fg: #ebebec;
|
||||
--muted: #d4d4d4;
|
||||
--faint: #393639;
|
||||
--accent: #7b97aa;
|
||||
--accent2: #84a59d;
|
||||
}
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 3rem;
|
||||
max-width: 44rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
line-height: 1.6;
|
||||
}
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
h1, h2, h3, h4 {
|
||||
line-height: 1.25;
|
||||
}
|
||||
header nav {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--faint);
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
pre {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
:not(pre) > code {
|
||||
background: var(--faint);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid var(--faint);
|
||||
padding: 0.35rem 0.6rem;
|
||||
}
|
||||
blockquote {
|
||||
margin: 1rem 0;
|
||||
padding: 0.1rem 1rem;
|
||||
border-left: 3px solid var(--accent2);
|
||||
color: var(--muted);
|
||||
}
|
||||
.callout {
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--accent2) 8%, transparent);
|
||||
}
|
||||
.callout-title {
|
||||
color: var(--accent);
|
||||
}
|
||||
.callout-warning, .callout-attention, .callout-important {
|
||||
border-left-color: #c9803a;
|
||||
}
|
||||
.callout-warning .callout-title,
|
||||
.callout-attention .callout-title,
|
||||
.callout-important .callout-title {
|
||||
color: #c9803a;
|
||||
}
|
||||
/* dark-mode-aware shiki dual themes */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.astro-code,
|
||||
.astro-code span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
// URL/slug compatibility with the outgoing Quartz v4 site.
|
||||
// Verified against the live sitemap 2026-07-15: case is preserved, every
|
||||
// space becomes "-", existing hyphens/em-dashes are kept, folder index
|
||||
// pages are served at "<Folder>/".
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const CONTENT_DIR =
|
||||
process.env.GARDEN_CONTENT ||
|
||||
'/home/wes/Documents/weeslahw_coppermind/Digital_Garden';
|
||||
|
||||
export const VAULT_ROOT = path.dirname(CONTENT_DIR);
|
||||
|
||||
export function slugSegment(seg) {
|
||||
return seg.replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
// id = vault-relative path without ".md", verbatim (spaces, case intact)
|
||||
export function slugForId(id) {
|
||||
return id.split('/').map(slugSegment).join('/');
|
||||
}
|
||||
|
||||
export function urlForSlug(slug) {
|
||||
if (slug === 'index') return '/';
|
||||
if (slug.endsWith('/index')) return '/' + slug.slice(0, -'index'.length);
|
||||
return '/' + slug;
|
||||
}
|
||||
|
||||
export function urlForId(id) {
|
||||
return urlForSlug(slugForId(id));
|
||||
}
|
||||
|
||||
export function walkMarkdown(dir, rel = '') {
|
||||
const out = [];
|
||||
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (ent.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, ent.name);
|
||||
const r = rel ? `${rel}/${ent.name}` : ent.name;
|
||||
if (ent.isDirectory()) {
|
||||
if (ent.name === 'assets') continue;
|
||||
out.push(...walkMarkdown(full, r));
|
||||
} else if (ent.name.endsWith('.md')) {
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Wikilink resolution map: lowercase full path and lowercase basename -> slug
|
||||
export function buildSlugMap(base = CONTENT_DIR) {
|
||||
const map = new Map();
|
||||
for (const f of walkMarkdown(base)) {
|
||||
const id = f.replace(/\.md$/, '');
|
||||
const slug = slugForId(id);
|
||||
map.set(id.toLowerCase(), slug);
|
||||
const basename = id.split('/').pop().toLowerCase();
|
||||
if (!map.has(basename)) map.set(basename, slug);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Obsidian-flavored markdown for the garden: wikilinks, image embeds,
|
||||
// callouts. Unresolvable wikilinks degrade to plain text — a bad link
|
||||
// must never break a page.
|
||||
import { visit } from 'unist-util-visit';
|
||||
import GithubSlugger from 'github-slugger';
|
||||
import { urlForSlug } from './quartz-compat.mjs';
|
||||
|
||||
const WIKI_RE = /(!?)\[\[([^\]]+)\]\]/g;
|
||||
const IMG_RE = /\.(png|jpe?g|gif|svg|webp)$/i;
|
||||
|
||||
function slugAnchor(text) {
|
||||
return new GithubSlugger().slug(text);
|
||||
}
|
||||
|
||||
function wikiNode(isEmbed, inner, slugMap) {
|
||||
let [targetPart, ...aliasParts] = inner.split('|');
|
||||
const alias = aliasParts.length ? aliasParts.join('|').trim() : null;
|
||||
const hash = targetPart.indexOf('#');
|
||||
const target = (hash === -1 ? targetPart : targetPart.slice(0, hash)).trim();
|
||||
const anchor = hash === -1 ? null : targetPart.slice(hash + 1).trim();
|
||||
|
||||
if (isEmbed && IMG_RE.test(target)) {
|
||||
const name = target.split('/').pop();
|
||||
return {
|
||||
type: 'image',
|
||||
url: '/assets/' + encodeURIComponent(name),
|
||||
alt: alias || name,
|
||||
};
|
||||
}
|
||||
|
||||
const display = alias || (target ? target.split('/').pop() : '#' + anchor);
|
||||
const frag = anchor ? '#' + slugAnchor(anchor) : '';
|
||||
|
||||
if (!target) {
|
||||
// same-page anchor: [[#Heading]]
|
||||
return { type: 'link', url: frag, children: [{ type: 'text', value: display }] };
|
||||
}
|
||||
|
||||
const key = target.replace(/\.md$/i, '').toLowerCase();
|
||||
const slug = slugMap.get(key) || slugMap.get(key.split('/').pop());
|
||||
if (!slug) {
|
||||
return { type: 'text', value: display };
|
||||
}
|
||||
return {
|
||||
type: 'link',
|
||||
url: urlForSlug(slug) + frag,
|
||||
children: [{ type: 'text', value: display }],
|
||||
};
|
||||
}
|
||||
|
||||
function transformCallout(node) {
|
||||
const first = node.children?.[0];
|
||||
if (!first || first.type !== 'paragraph') return;
|
||||
const t = first.children?.[0];
|
||||
if (!t || t.type !== 'text') return;
|
||||
|
||||
const nl = t.value.indexOf('\n');
|
||||
const firstLine = nl === -1 ? t.value : t.value.slice(0, nl);
|
||||
const m = /^\[!(\w+)\][+-]?\s*(.*)$/.exec(firstLine);
|
||||
if (!m) return;
|
||||
|
||||
const type = m[1].toLowerCase();
|
||||
const title = m[2].trim() || type.charAt(0).toUpperCase() + type.slice(1);
|
||||
|
||||
t.value = nl === -1 ? '' : t.value.slice(nl + 1);
|
||||
if (!t.value) {
|
||||
first.children.shift();
|
||||
if (first.children[0]?.type === 'break') first.children.shift();
|
||||
if (first.children.length === 0) node.children.shift();
|
||||
}
|
||||
|
||||
node.children.unshift({
|
||||
type: 'paragraph',
|
||||
data: { hProperties: { className: ['callout-title'] } },
|
||||
children: [{ type: 'strong', children: [{ type: 'text', value: title }] }],
|
||||
});
|
||||
node.data = {
|
||||
...node.data,
|
||||
hProperties: {
|
||||
className: ['callout', `callout-${type}`],
|
||||
'data-callout': type,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function remarkObsidian(options = {}) {
|
||||
const { slugMap = new Map() } = options;
|
||||
return (tree) => {
|
||||
visit(tree, 'blockquote', transformCallout);
|
||||
// relative markdown-syntax images (e.g. ) also go
|
||||
// through the flat /assets/ pipeline — keeps Astro's image resolver
|
||||
// out of it and fixes vault refs that point outside Digital_Garden
|
||||
visit(tree, 'image', (node) => {
|
||||
if (/^(?:[a-z]+:)?\/\//i.test(node.url) || node.url.startsWith('/')) return;
|
||||
const name = decodeURIComponent(node.url).split('/').pop();
|
||||
node.url = '/assets/' + encodeURIComponent(name);
|
||||
});
|
||||
visit(tree, 'text', (node, index, parent) => {
|
||||
if (!parent || parent.type === 'link' || index === undefined) return;
|
||||
WIKI_RE.lastIndex = 0;
|
||||
if (!WIKI_RE.test(node.value)) return;
|
||||
|
||||
const parts = [];
|
||||
let last = 0;
|
||||
let m;
|
||||
WIKI_RE.lastIndex = 0;
|
||||
while ((m = WIKI_RE.exec(node.value))) {
|
||||
if (m.index > last)
|
||||
parts.push({ type: 'text', value: node.value.slice(last, m.index) });
|
||||
parts.push(wikiNode(m[1] === '!', m[2], slugMap));
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < node.value.length)
|
||||
parts.push({ type: 'text', value: node.value.slice(last) });
|
||||
|
||||
parent.children.splice(index, 1, ...parts);
|
||||
return index + parts.length;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { slugForId, urlForId } from '../lib/quartz-compat.mjs';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const entries = await getCollection('garden', (e) => !e.data.draft);
|
||||
const ids = new Set(entries.map((e) => e.id));
|
||||
|
||||
const paths = entries
|
||||
.filter((e) => e.id !== 'index')
|
||||
.map((e) => ({
|
||||
params: { slug: slugForId(e.id) },
|
||||
props: { entry: e, listing: null },
|
||||
}));
|
||||
|
||||
// synthetic listing pages for folders that have no index.md
|
||||
const folders = new Set<string>();
|
||||
for (const id of ids) {
|
||||
let d = id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : '';
|
||||
while (d) {
|
||||
folders.add(d);
|
||||
d = d.includes('/') ? d.slice(0, d.lastIndexOf('/')) : '';
|
||||
}
|
||||
}
|
||||
for (const folder of folders) {
|
||||
if (ids.has(`${folder}/index`)) continue;
|
||||
const children = entries
|
||||
.filter((e) => {
|
||||
const dir = e.id.includes('/') ? e.id.slice(0, e.id.lastIndexOf('/')) : '';
|
||||
return dir === folder && !e.id.endsWith('/index');
|
||||
})
|
||||
.map((e) => ({
|
||||
title: e.data.title ?? e.id.split('/').pop(),
|
||||
url: urlForId(e.id),
|
||||
}));
|
||||
const subfolders = [...folders]
|
||||
.filter((f) => {
|
||||
const dir = f.includes('/') ? f.slice(0, f.lastIndexOf('/')) : '';
|
||||
return dir === folder;
|
||||
})
|
||||
.map((f) => ({
|
||||
title: f.split('/').pop(),
|
||||
url: '/' + slugForId(f) + '/',
|
||||
}));
|
||||
paths.push({
|
||||
params: { slug: slugForId(folder) + '/index' },
|
||||
props: {
|
||||
entry: null,
|
||||
listing: { name: folder.split('/').pop(), items: [...subfolders, ...children] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
const { entry, listing } = Astro.props;
|
||||
const title = entry
|
||||
? (entry.data.title ?? entry.id.split('/').pop())
|
||||
: listing.name;
|
||||
const Content = entry ? (await render(entry)).Content : null;
|
||||
---
|
||||
|
||||
<Base title={title} description={entry?.data.description}>
|
||||
<h1>{title}</h1>
|
||||
{Content && <Content />}
|
||||
{
|
||||
listing && (
|
||||
<ul>
|
||||
{listing.items.map((item) => (
|
||||
<li>
|
||||
<a href={item.url}>{item.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
</Base>
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import Base from '../layouts/Base.astro';
|
||||
|
||||
const entries = await getCollection('garden');
|
||||
const home = entries.find((e) => e.id === 'index');
|
||||
const Content = home ? (await render(home)).Content : null;
|
||||
const title = home?.data.title ?? 'Wesley Ray';
|
||||
---
|
||||
|
||||
<Base title={title} description={home?.data.description}>
|
||||
{Content && <Content />}
|
||||
|
||||
<section id="odometer" hidden>
|
||||
<h2>Bandwidth odometer</h2>
|
||||
<p class="odo-lede">
|
||||
Every byte in or out of the homelab since <span id="odo-since"></span>:
|
||||
</p>
|
||||
<div class="odo-display" id="odo-digits" aria-live="off"></div>
|
||||
<p class="odo-foot">
|
||||
ticking at the lifetime average rate · served by
|
||||
<a href="https://api.c0smere.net/docs">api.c0smere.net</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<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 section 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();
|
||||
// group into 3s for readability, each digit in its own cell
|
||||
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); // bytes/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: section stays hidden */
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#odometer {
|
||||
margin-top: 2.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--faint);
|
||||
}
|
||||
.odo-lede {
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.odo-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
font-family: ui-monospace, 'Cascadia Mono', 'JetBrains Mono', monospace;
|
||||
font-size: clamp(1.1rem, 4vw, 1.8rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.odo-display :global(.odo-digit) {
|
||||
display: inline-block;
|
||||
min-width: 1.35ch;
|
||||
padding: 0.15em 0.1em;
|
||||
text-align: center;
|
||||
background: #1c1c1e;
|
||||
color: #f2f2f3;
|
||||
border-radius: 4px;
|
||||
border-bottom: 2px solid #000;
|
||||
}
|
||||
.odo-display :global(.odo-sep) {
|
||||
width: 0.35ch;
|
||||
}
|
||||
.odo-foot {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
</Base>
|
||||
Reference in New Issue
Block a user