Manual rebuild hook + polish: single title h1, '›' tree marker
- deploy/rebuild-hook.py: tiny stdlib HTTP service (:18101) — button page for the Homepage dashboard iframe, POST /rebuild runs auto-build.sh --force, /status + /healthz for feedback/monitoring - auto-build.sh: --force flag + flock so timer and manual builds serialize - [...slug].astro: skip the synthetic h1 when the note body already opens with an identical h1 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,3 +3,5 @@ dist/
|
|||||||
.astro/
|
.astro/
|
||||||
public/assets/
|
public/assets/
|
||||||
.build_state
|
.build_state
|
||||||
|
.build.lock
|
||||||
|
.rebuild.log
|
||||||
|
|||||||
+11
-1
@@ -1,13 +1,23 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Timer target: rebuild the garden only when the vault or repo changed.
|
# Timer target: rebuild the garden only when the vault or repo changed.
|
||||||
# Successor to the Quartz update_quartz_docker.sh change-detection loop.
|
# 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
|
set -eu
|
||||||
|
|
||||||
REPO=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
REPO=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
VAULT="${VAULT:-/home/nox/docker/obsidian/vaults/weeslahw_coppermind}"
|
VAULT="${VAULT:-/home/nox/docker/obsidian/vaults/weeslahw_coppermind}"
|
||||||
STATE="$REPO/.build_state"
|
STATE="$REPO/.build_state"
|
||||||
|
FORCE=0
|
||||||
|
[ "${1:-}" = "--force" ] && FORCE=1
|
||||||
|
|
||||||
cd "$REPO"
|
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
|
# 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"
|
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
|
find "$VAULT" -name .obsidian -prune -o -type f -printf '%T@ %p\n' | sort
|
||||||
} | sha256sum | cut -d' ' -f1)
|
} | sha256sum | cut -d' ' -f1)
|
||||||
|
|
||||||
if [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$sig" ]; then
|
if [ "$FORCE" -eq 0 ] && [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$sig" ]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Executable
+179
@@ -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 = """<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>garden rebuild</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 10px 12px; background: transparent;
|
||||||
|
color: #e6e6e6; font-family: 'JetBrains Mono', ui-monospace,
|
||||||
|
Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; }
|
||||||
|
button { font: inherit; font-weight: 700; color: #fff; background: #218107;
|
||||||
|
border: 0; border-radius: 6px; padding: 6px 14px; cursor: pointer; }
|
||||||
|
button:hover { background: #2fa50f; }
|
||||||
|
button:disabled { opacity: .55; cursor: default; }
|
||||||
|
#st { margin-left: .7em; color: rgba(230,230,230,.64); }
|
||||||
|
.ok { color: #46c421; }
|
||||||
|
.err { color: #e0563c; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button id="go">rebuild garden ▸</button><span id="st">…</span>
|
||||||
|
<script>
|
||||||
|
const st = document.getElementById('st');
|
||||||
|
const go = document.getElementById('go');
|
||||||
|
const fmt = (t) => (t ? new Date(t * 1000).toLocaleTimeString() : '?');
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const s = await (await fetch('status')).json();
|
||||||
|
go.disabled = s.running;
|
||||||
|
if (s.running) {
|
||||||
|
st.textContent = 'building\\u2026 started ' + fmt(s.started);
|
||||||
|
st.className = '';
|
||||||
|
} else if (s.last_exit === null) {
|
||||||
|
st.textContent = 'idle';
|
||||||
|
st.className = '';
|
||||||
|
} else if (s.last_exit === 0) {
|
||||||
|
st.textContent = 'ok \\u00b7 finished ' + fmt(s.last_finished);
|
||||||
|
st.className = 'ok';
|
||||||
|
} else {
|
||||||
|
st.textContent = 'FAILED (exit ' + s.last_exit + ') \\u00b7 ' +
|
||||||
|
fmt(s.last_finished);
|
||||||
|
st.className = 'err';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
st.textContent = 'hook unreachable';
|
||||||
|
st.className = 'err';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go.addEventListener('click', async () => {
|
||||||
|
go.disabled = true;
|
||||||
|
try { await fetch('rebuild', { method: 'POST' }); } catch {}
|
||||||
|
poll();
|
||||||
|
});
|
||||||
|
poll();
|
||||||
|
setInterval(poll, 3000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -455,7 +455,7 @@ const showToc = headings.filter((h) => h.depth <= 3).length >= 2;
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
.sidebar summary::before {
|
.sidebar summary::before {
|
||||||
content: '▸';
|
content: '›';
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 0.9em;
|
width: 0.9em;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ const title = entry
|
|||||||
: listing.name;
|
: listing.name;
|
||||||
const rendered = entry ? await render(entry) : null;
|
const rendered = entry ? await render(entry) : null;
|
||||||
const Content = rendered?.Content ?? 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();
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base
|
<Base
|
||||||
@@ -68,7 +73,7 @@ const Content = rendered?.Content ?? null;
|
|||||||
description={entry?.data.description}
|
description={entry?.data.description}
|
||||||
headings={rendered?.headings ?? []}
|
headings={rendered?.headings ?? []}
|
||||||
>
|
>
|
||||||
<h1>{title}</h1>
|
{!contentLeadsWithTitle && <h1>{title}</h1>}
|
||||||
{Content && <Content />}
|
{Content && <Content />}
|
||||||
{
|
{
|
||||||
listing && (
|
listing && (
|
||||||
|
|||||||
Reference in New Issue
Block a user