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,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;
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user