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,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
public/assets/
|
||||
@@ -0,0 +1,45 @@
|
||||
# garden-astro
|
||||
|
||||
Astro rebuild of the digital garden at `garden.c0smere.net`, replacing
|
||||
Quartz v4. Content stays in the Obsidian vault (`Digital_Garden/` folder);
|
||||
this repo is only the rendering pipeline.
|
||||
|
||||
## URL compatibility
|
||||
|
||||
Built to be a drop-in replacement — every URL on the live Quartz site
|
||||
resolves identically here (verified against the live sitemap, 102/102):
|
||||
|
||||
- case preserved, spaces become `-`, hyphens/em-dashes kept
|
||||
- leaf pages: `/Folder/Note-Name` (`build.format: 'file'` + nginx
|
||||
`try_files $uri $uri.html`)
|
||||
- folder indexes: `/Folder/` (from `Folder/index.md`, or a synthetic
|
||||
listing page when no index exists)
|
||||
|
||||
## Obsidian compatibility (`src/lib/remark-obsidian.mjs`)
|
||||
|
||||
- `[[wikilinks]]` with aliases/anchors, resolved vault-path-first then by
|
||||
basename; unresolved links degrade to plain text
|
||||
- `![[image embeds]]` and relative `` both rewrite to flat
|
||||
`/assets/<name>` URLs; `scripts/copy-assets.mjs` resolves the files
|
||||
(Digital_Garden first, then vault-wide) and copies them in at build time
|
||||
- `> [!type]` callouts, KaTeX math, mermaid (lazy-loaded client-side only
|
||||
on pages that use it), shiki dual light/dark themes
|
||||
- `draft:` frontmatter excluded, including Quartz-style quoted `"true"`
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
GARDEN_CONTENT=/path/to/vault/Digital_Garden npm run build
|
||||
```
|
||||
|
||||
Defaults to the kotov vault copy. On cyrion use `deploy/build.sh`
|
||||
(containerized node:24, vault mounted read-only), then
|
||||
`docker compose -f deploy/docker-compose.yml up -d` serves `dist/` on
|
||||
port 18100 (`garden-next.c0smere.net` while staging).
|
||||
|
||||
## Homepage extras
|
||||
|
||||
`src/pages/index.astro` renders the vault's `index.md` and appends the
|
||||
bandwidth odometer, fed by `https://api.c0smere.net/bandwidth/odometer`.
|
||||
The widget animates at the lifetime average rate from a fetched baseline —
|
||||
it never reflects live throughput, and hides itself if the API is down.
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import { remarkObsidian } from './src/lib/remark-obsidian.mjs';
|
||||
import { buildSlugMap, CONTENT_DIR } from './src/lib/quartz-compat.mjs';
|
||||
|
||||
const slugMap = buildSlugMap(CONTENT_DIR);
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://garden.c0smere.net',
|
||||
// 'file' => /A/B.html served as /A/B — matches Quartz's URL shape
|
||||
build: { format: 'file' },
|
||||
markdown: {
|
||||
remarkPlugins: [[remarkObsidian, { slugMap }], remarkMath],
|
||||
rehypePlugins: [rehypeKatex],
|
||||
shikiConfig: {
|
||||
themes: { light: 'github-light', dark: 'github-dark' },
|
||||
},
|
||||
},
|
||||
});
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Containerized build for cyrion: mounts the Obsidian vault read-only and
|
||||
# writes dist/ into the repo checkout. Run from the repo root.
|
||||
set -eu
|
||||
|
||||
VAULT="${VAULT:-/home/nox/docker/obsidian/vaults/weeslahw_coppermind}"
|
||||
|
||||
docker run --rm \
|
||||
-v "$(pwd):/app" \
|
||||
-v "$VAULT:/content:ro" \
|
||||
-e GARDEN_CONTENT=/content/Digital_Garden \
|
||||
-e ASTRO_TELEMETRY_DISABLED=1 \
|
||||
-w /app \
|
||||
node:24-slim \
|
||||
sh -c "npm ci && npm run build"
|
||||
@@ -0,0 +1,11 @@
|
||||
# Serves the prebuilt dist/ — run deploy/build.sh first.
|
||||
services:
|
||||
garden-next:
|
||||
image: nginx:alpine
|
||||
container_name: garden-next
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18100:80"
|
||||
volumes:
|
||||
- ../dist:/usr/share/nginx/html:ro
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
charset utf-8;
|
||||
|
||||
# Quartz-compatible URLs: /A/B -> A/B.html, /Folder/ -> Folder/index.html
|
||||
location / {
|
||||
try_files $uri $uri.html $uri/index.html =404;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /_astro/ {
|
||||
# content-hashed filenames — safe to cache hard
|
||||
expires 365d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
Generated
+6737
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "garden-astro",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "node scripts/copy-assets.mjs && astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.0.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
"katex": "^0.16.10",
|
||||
"mermaid": "^11.0.0",
|
||||
"rehype-katex": "^7.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.27.7": true,
|
||||
"esbuild@0.25.12": true,
|
||||
"sharp@0.34.5": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Collect every image referenced by an Obsidian embed (![[...]]) in the
|
||||
// garden content and copy it to public/assets/ flat by basename — matching
|
||||
// the /assets/<name> URLs the remark plugin emits. Resolution order:
|
||||
// Digital_Garden first, then the wider vault (some embeds point at files
|
||||
// that live outside the published folder; the live Quartz site 404s those —
|
||||
// we fix them, but loudly, so stray references are visible).
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
CONTENT_DIR,
|
||||
VAULT_ROOT,
|
||||
walkMarkdown,
|
||||
} from '../src/lib/quartz-compat.mjs';
|
||||
|
||||
const OUT_DIR = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'public',
|
||||
'assets',
|
||||
);
|
||||
const EMBED_RE = /!\[\[([^\]|]+?\.(?:png|jpe?g|gif|svg|webp))(?:\|[^\]]*)?\]\]/gi;
|
||||
const MD_IMG_RE = /!\[[^\]]*\]\(([^)\s]+?\.(?:png|jpe?g|gif|svg|webp))\)/gi;
|
||||
|
||||
function walkFiles(dir) {
|
||||
const out = [];
|
||||
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (ent.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, ent.name);
|
||||
if (ent.isDirectory()) out.push(...walkFiles(full));
|
||||
else out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// referenced image names (basename, as Obsidian resolves vault-wide)
|
||||
const wanted = new Map(); // lowercase basename -> name as referenced
|
||||
for (const rel of walkMarkdown(CONTENT_DIR)) {
|
||||
const text = fs.readFileSync(path.join(CONTENT_DIR, rel), 'utf8');
|
||||
for (const m of text.matchAll(EMBED_RE)) {
|
||||
const name = m[1].split('/').pop().trim();
|
||||
wanted.set(name.toLowerCase(), name);
|
||||
}
|
||||
for (const m of text.matchAll(MD_IMG_RE)) {
|
||||
if (/^(?:[a-z]+:)?\/\//i.test(m[1])) continue;
|
||||
const name = decodeURIComponent(m[1]).split('/').pop().trim();
|
||||
wanted.set(name.toLowerCase(), name);
|
||||
}
|
||||
}
|
||||
|
||||
// index every file in the vault by lowercase basename
|
||||
const inDG = new Map();
|
||||
const inVault = new Map();
|
||||
for (const f of walkFiles(VAULT_ROOT)) {
|
||||
const key = path.basename(f).toLowerCase();
|
||||
if (f.startsWith(CONTENT_DIR + path.sep)) {
|
||||
if (!inDG.has(key)) inDG.set(key, f);
|
||||
} else if (!inVault.has(key)) {
|
||||
inVault.set(key, f);
|
||||
}
|
||||
}
|
||||
|
||||
fs.rmSync(OUT_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
let copied = 0;
|
||||
let missing = 0;
|
||||
for (const [key, name] of wanted) {
|
||||
const src = inDG.get(key) ?? inVault.get(key);
|
||||
if (!src) {
|
||||
console.warn(`copy-assets: UNRESOLVED image embed: ${name}`);
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
if (!inDG.has(key)) {
|
||||
console.warn(
|
||||
`copy-assets: ${name} lives outside Digital_Garden (${path.relative(VAULT_ROOT, src)})`,
|
||||
);
|
||||
}
|
||||
fs.copyFileSync(src, path.join(OUT_DIR, name));
|
||||
copied++;
|
||||
}
|
||||
|
||||
console.log(`copy-assets: ${copied} copied, ${missing} unresolved -> ${OUT_DIR}`);
|
||||
if (missing) process.exitCode = 0; // warn, don't fail the build
|
||||
@@ -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