diff --git a/.gitignore b/.gitignore index 3d43d68..4ad938c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist/ .astro/ public/assets/ .build_state +.build.lock +.rebuild.log diff --git a/deploy/auto-build.sh b/deploy/auto-build.sh index 5eada2f..cfca273 100755 --- a/deploy/auto-build.sh +++ b/deploy/auto-build.sh @@ -1,13 +1,23 @@ #!/bin/sh # Timer target: rebuild the garden only when the vault or repo changed. # Successor to the Quartz update_quartz_docker.sh change-detection loop. +# Usage: auto-build.sh [--force] (--force skips change detection — used +# by the manual rebuild hook) set -eu REPO=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) VAULT="${VAULT:-/home/nox/docker/obsidian/vaults/weeslahw_coppermind}" STATE="$REPO/.build_state" +FORCE=0 +[ "${1:-}" = "--force" ] && FORCE=1 cd "$REPO" + +# serialize builds: the 5-min timer and the manual hook must never run +# docker/npm into the same checkout concurrently +exec 9>"$REPO/.build.lock" +flock 9 + # a dirty tree can make pull fail; still rebuild whatever is checked out git pull -q || echo "git pull failed (dirty tree?) — building local state" @@ -19,7 +29,7 @@ sig=$({ find "$VAULT" -name .obsidian -prune -o -type f -printf '%T@ %p\n' | sort } | sha256sum | cut -d' ' -f1) -if [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$sig" ]; then +if [ "$FORCE" -eq 0 ] && [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$sig" ]; then exit 0 fi diff --git a/deploy/garden-rebuild-hook.service b/deploy/garden-rebuild-hook.service new file mode 100644 index 0000000..4c1dece --- /dev/null +++ b/deploy/garden-rebuild-hook.service @@ -0,0 +1,11 @@ +[Unit] +Description=Garden manual rebuild HTTP trigger (garden-hook.c0smere.net) +After=network.target + +[Service] +ExecStart=/usr/bin/python3 /home/nox/docker/garden-astro/deploy/rebuild-hook.py +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/deploy/rebuild-hook.py b/deploy/rebuild-hook.py new file mode 100755 index 0000000..8fb5aab --- /dev/null +++ b/deploy/rebuild-hook.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""LAN-only HTTP trigger for manual garden rebuilds. + +Runs as a nox user service on cyrion (deploy/garden-rebuild-hook.service), +listening on :18101. Reached at https://garden-hook.c0smere.net via the +internal Caddy (Blocky maps the name; never published through NPM). The +Homepage dashboard embeds GET / as an iframe widget — that page is the +"redeploy" button. + + GET / button page (transparent bg, blends into the dashboard) + POST /rebuild start deploy/auto-build.sh --force; 409 while one runs + GET /status {"running", "started", "last_exit", "last_finished", "log_tail"} + GET /healthz 200 "ok" (for Gatus) + +Build concurrency with the 5-min timer is handled by the flock inside +auto-build.sh; this server only prevents stacking multiple manual runs. +""" + +import json +import os +import subprocess +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCRIPT = os.path.join(REPO, 'deploy', 'auto-build.sh') +LOG = os.path.join(REPO, '.rebuild.log') +PORT = int(os.environ.get('HOOK_PORT', '18101')) + +_lock = threading.Lock() +_state = {'proc': None, 'started': None, 'last_exit': None, 'last_finished': None} + + +def _reap(proc): + proc.wait() + with _lock: + _state['last_exit'] = proc.returncode + _state['last_finished'] = time.time() + + +def start_build(): + with _lock: + if _state['proc'] is not None and _state['proc'].poll() is None: + return False + logf = open(LOG, 'w') + _state['proc'] = subprocess.Popen( + [SCRIPT, '--force'], + stdout=logf, + stderr=subprocess.STDOUT, + cwd=REPO, + ) + logf.close() + _state['started'] = time.time() + threading.Thread(target=_reap, args=(_state['proc'],), daemon=True).start() + print(f'rebuild triggered {time.strftime("%F %T")}', flush=True) + return True + + +def status(): + with _lock: + running = _state['proc'] is not None and _state['proc'].poll() is None + tail = '' + try: + with open(LOG, encoding='utf-8', errors='replace') as f: + tail = ''.join(f.readlines()[-12:]) + except OSError: + pass + return { + 'running': running, + 'started': _state['started'], + 'last_exit': _state['last_exit'], + 'last_finished': _state['last_finished'], + 'log_tail': tail, + } + + +PAGE = """ + + + + +garden rebuild + + + + + + + +""" + + +class Handler(BaseHTTPRequestHandler): + server_version = 'garden-hook' + + def _send(self, code, body, ctype='application/json'): + data = body.encode() if isinstance(body, str) else body + self.send_response(code) + self.send_header('Content-Type', ctype) + self.send_header('Content-Length', str(len(data))) + self.send_header('Cache-Control', 'no-store') + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + path = self.path.split('?', 1)[0] + if path == '/': + self._send(200, PAGE, 'text/html; charset=utf-8') + elif path == '/status': + self._send(200, json.dumps(status())) + elif path == '/healthz': + self._send(200, 'ok', 'text/plain') + else: + self._send(404, '{"error": "not found"}') + + def do_POST(self): + path = self.path.split('?', 1)[0] + if path == '/rebuild': + if start_build(): + self._send(202, '{"started": true}') + else: + self._send(409, '{"started": false, "error": "already running"}') + else: + self._send(404, '{"error": "not found"}') + + def log_message(self, fmt, *args): + pass # journald noise; triggers are logged in start_build + + +if __name__ == '__main__': + srv = ThreadingHTTPServer(('0.0.0.0', PORT), Handler) + print(f'garden rebuild hook listening on :{PORT}', flush=True) + srv.serve_forever() diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 32cc4ac..5117377 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -455,7 +455,7 @@ const showToc = headings.filter((h) => h.depth <= 3).length >= 2; list-style: none; } .sidebar summary::before { - content: '▸'; + content: '›'; display: inline-block; width: 0.9em; color: var(--muted); diff --git a/src/pages/[...slug].astro b/src/pages/[...slug].astro index 93156ea..17080c2 100644 --- a/src/pages/[...slug].astro +++ b/src/pages/[...slug].astro @@ -61,6 +61,11 @@ const title = entry : listing.name; const rendered = entry ? await render(entry) : null; const Content = rendered?.Content ?? null; +// notes whose body already opens with an identical h1 (common vault +// pattern) would show a doubled title — skip the synthetic one +const contentLeadsWithTitle = + rendered?.headings?.[0]?.depth === 1 && + rendered.headings[0].text.trim() === String(title).trim(); --- -

{title}

+ {!contentLeadsWithTitle &&

{title}

} {Content && } { listing && (