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:
wes
2026-07-15 14:18:59 -04:00
co-authored by Claude Opus 4.8
commit 04c9aa2fb2
15 changed files with 7527 additions and 0 deletions
+60
View File
@@ -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;
}
+120
View File
@@ -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. ![x](assets/foo.svg)) 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;
});
};
}