authoring server
All checks were successful
Build Org Website / build (push) Successful in 49s

This commit is contained in:
2026-05-07 12:19:23 +01:00
parent 82813556f2
commit 72cd3e89ad
9 changed files with 673 additions and 6 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.venv/
output/
backup/
*~
__pycache__/
*~

View File

@@ -1,4 +1,4 @@
.PHONY: all clean norm help
.PHONY: all clean norm author help
VENV := .venv
PY := $(VENV)/bin/python
@@ -36,6 +36,10 @@ norm:
@echo "sorting out the backups..."
find . -path ./backups -prune -o -type f -name '*~' -exec mv {} backups/ \;
author:
@echo "Starting local authoring UI..."
python3 authoring_server.py
# Show help message
help:
@echo "Available targets:"
@@ -45,5 +49,6 @@ help:
@echo " make clean-output - Remove output directory"
@echo " make clean-venv - Remove venv directory"
@echo " make norm - Move all files that end with ~ to the backup folder"
@echo " make author - Start the local authoring UI"
@echo " make search - creates the search index"
@echo " make help - Show this help message!"

View File

@@ -3,3 +3,15 @@
This repository contains code for the website https://zainezq.com, which serves as a central familial hub for me.
The site is written using Emacs and Org-mode, and exported using the `org-publish` functionality. See the `build-site.el` script for details on how the site is built and published.
## Local authoring UI
Run `make author` and open `http://127.0.0.1:8765` to create or edit blog and post Org files from a browser.
The editor writes normal `.org` pages with the metadata used by the publishing pipeline:
- blogs are created under `blogs/YYYY/MM-month/slug.org`
- posts are created under `posts/slug.org`, or `posts/section/slug.org` when a section is provided
- saving a page starts `emacs -Q --script build-site.el` and then regenerates the search index
While that build is running, the UI disables editing and the API rejects further saves. The status panel unlocks the editor when publishing finishes.

0
assets/scripts/zhd.js Normal file → Executable file
View File

0
assets/styles/style.css Normal file → Executable file
View File

649
authoring_server.py Normal file
View File

@@ -0,0 +1,649 @@
#!/usr/bin/env python3
"""Local authoring UI for the Org published website."""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from email.utils import formatdate
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
ROOT = Path(__file__).resolve().parent
BLOGS_DIR = ROOT / "blogs"
POSTS_DIR = ROOT / "posts"
GENERATED_ORG_NAMES = {
"blogs-list.org",
"posts-list.org",
"career-list.org",
"sitemap.org",
"recently-updated.org",
"wip.org",
}
MONTH_NAMES = [
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december",
]
def slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or "untitled"
def normalise_tags(value: Any) -> list[str]:
if isinstance(value, str):
raw = re.split(r"[,:\s]+", value)
elif isinstance(value, list):
raw = [str(item) for item in value]
else:
raw = []
tags = []
for tag in raw:
if not tag.strip():
continue
clean = slugify(tag)
if clean and clean not in tags:
tags.append(clean)
return tags
def org_date(dt: datetime) -> str:
return dt.strftime("<%Y-%m-%d %a %H:%M>")
def parse_org_datetime(value: str | None) -> datetime | None:
if not value:
return None
match = re.search(r"<(\d{4})-(\d{2})-(\d{2})(?:\s+\w+)?(?:\s+(\d{2}):(\d{2}))?>", value)
if not match:
return None
year, month, day, hour, minute = match.groups()
return datetime(
int(year),
int(month),
int(day),
int(hour or 12),
int(minute or 0),
)
def html_escape(value: str) -> str:
return (
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
@dataclass
class OrgPage:
path: str
page_type: str
title: str
slug: str
tags: list[str]
content: str
date: str
comments: bool
options: str
wip: str | None = None
class BuildState:
def __init__(self) -> None:
self._lock = threading.Lock()
self.running = False
self.started_at: float | None = None
self.finished_at: float | None = None
self.ok: bool | None = None
self.message = "No build has run yet."
self.log = ""
def snapshot(self) -> dict[str, Any]:
with self._lock:
return {
"running": self.running,
"startedAt": self.started_at,
"finishedAt": self.finished_at,
"ok": self.ok,
"message": self.message,
"log": self.log[-12000:],
}
def start(self) -> bool:
with self._lock:
if self.running:
return False
self.running = True
self.started_at = time.time()
self.finished_at = None
self.ok = None
self.message = "Build started. The editor is locked until publishing finishes."
self.log = ""
return True
def finish(self, ok: bool, message: str, log: str) -> None:
with self._lock:
self.running = False
self.finished_at = time.time()
self.ok = ok
self.message = message
self.log = log
BUILD_STATE = BuildState()
def safe_relative_path(path: str) -> Path:
rel = Path(path)
if rel.is_absolute() or ".." in rel.parts:
raise ValueError("Path must stay inside this repository.")
full = (ROOT / rel).resolve()
if not full.is_relative_to(ROOT):
raise ValueError("Path must stay inside this repository.")
if full.suffix != ".org":
raise ValueError("Only .org files can be edited.")
if not (full.is_relative_to(BLOGS_DIR) or full.is_relative_to(POSTS_DIR)):
raise ValueError("Only blogs/ and posts/ files can be edited here.")
return full
def read_page(path: Path) -> OrgPage:
text = path.read_text(encoding="utf-8")
meta: dict[str, str] = {}
body_lines: list[str] = []
in_header = True
for line in text.splitlines():
if in_header and line.startswith("#+"):
key, _, value = line[2:].partition(":")
meta[key.strip().upper()] = value.strip()
else:
in_header = False
body_lines.append(line)
rel = path.relative_to(ROOT).as_posix()
page_type = "blog" if path.is_relative_to(BLOGS_DIR) else "post"
slug = meta.get("SLUG") or path.stem
tags = normalise_tags(meta.get("FILETAGS", ""))
return OrgPage(
path=rel,
page_type=page_type,
title=meta.get("TITLE", path.stem),
slug=slug,
tags=tags,
content="\n".join(body_lines).lstrip("\n"),
date=meta.get("DATE", org_date(datetime.fromtimestamp(path.stat().st_mtime))),
comments=meta.get("COMMENTS", "t").lower() == "t",
options=meta.get("OPTIONS", "num:nil"),
wip=meta.get("WIP"),
)
def page_to_dict(page: OrgPage) -> dict[str, Any]:
return {
"path": page.path,
"pageType": page.page_type,
"title": page.title,
"slug": page.slug,
"tags": page.tags,
"content": page.content,
"date": page.date,
"comments": page.comments,
"options": page.options,
"wip": page.wip or "",
}
def list_pages() -> list[dict[str, Any]]:
pages = []
for base, page_type in ((BLOGS_DIR, "blog"), (POSTS_DIR, "post")):
if not base.exists():
continue
for path in sorted(base.rglob("*.org")):
if path.name in GENERATED_ORG_NAMES or "sync-conflict" in path.name:
continue
try:
page = read_page(path)
except UnicodeDecodeError:
continue
parsed = parse_org_datetime(page.date)
pages.append(
{
"path": page.path,
"pageType": page_type,
"title": page.title,
"slug": page.slug,
"tags": page.tags,
"date": page.date,
"timestamp": parsed.timestamp() if parsed else path.stat().st_mtime,
}
)
return sorted(pages, key=lambda item: item["timestamp"], reverse=True)
def target_path(data: dict[str, Any], existing_path: str | None) -> Path:
if existing_path:
return safe_relative_path(existing_path)
title = str(data.get("title") or "").strip()
slug = slugify(str(data.get("slug") or title))
page_type = str(data.get("pageType") or "blog")
if page_type == "blog":
dt = parse_org_datetime(str(data.get("date") or "")) or datetime.now()
folder = BLOGS_DIR / str(dt.year) / f"{dt.month:02d}-{MONTH_NAMES[dt.month - 1]}"
return folder / f"{slug}.org"
if page_type == "post":
raw_section = str(data.get("section") or "").strip()
section = slugify(raw_section) if raw_section else ""
folder = POSTS_DIR / section if section else POSTS_DIR
return folder / f"{slug}.org"
raise ValueError("pageType must be blog or post.")
def render_org(data: dict[str, Any], previous: OrgPage | None) -> str:
title = str(data.get("title") or "").strip()
if not title:
raise ValueError("Title is required.")
slug = slugify(str(data.get("slug") or title))
tags = normalise_tags(data.get("tags", []))
content = str(data.get("content") or "").replace("\r\n", "\n").strip()
date = str(data.get("date") or "").strip()
if not parse_org_datetime(date):
date = previous.date if previous else org_date(datetime.now())
options = str(data.get("options") or (previous.options if previous else "num:nil")).strip()
comments = bool(data.get("comments", True))
lines = [
f"#+TITLE: {title}",
f"#+OPTIONS: {options}",
f"#+DATE: {date}",
f"#+filetags: {''.join(f':{tag}' for tag in tags)}:",
]
wip = str(data.get("wip") or (previous.wip if previous else "") or "").strip()
if wip:
lines.append(f"#+WIP: {wip}")
lines.extend(
[
f"#+COMMENTS: {'t' if comments else ''}",
f"#+SLUG: {slug}",
"",
content,
"",
]
)
return "\n".join(lines)
def save_page(data: dict[str, Any]) -> dict[str, Any]:
existing_path = data.get("path") or None
target = target_path(data, str(existing_path) if existing_path else None)
previous = read_page(target) if target.exists() else None
if not target.parent.exists():
target.parent.mkdir(parents=True)
if target.exists() and not existing_path:
raise ValueError(f"{target.relative_to(ROOT)} already exists.")
target.write_text(render_org(data, previous), encoding="utf-8")
return page_to_dict(read_page(target))
def run_build() -> None:
venv_python = ROOT / ".venv" / "bin" / "python"
venv_pip = ROOT / ".venv" / "bin" / "pip"
commands = [["emacs", "-Q", "--script", "build-site.el"]]
if not venv_python.exists():
commands.extend(
[
[sys.executable, "-m", "venv", ".venv"],
[str(venv_pip), "install", "-r", "requirements.txt"],
]
)
commands.append([str(venv_python), "search-index-json.py"])
combined = []
ok = True
for command in commands:
combined.append(f"$ {' '.join(command)}\n")
proc = subprocess.run(
command,
cwd=ROOT,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
combined.append(proc.stdout)
if proc.returncode != 0:
ok = False
combined.append(f"\nCommand exited with {proc.returncode}.\n")
break
message = "Build complete. The site output and search index were regenerated." if ok else "Build failed. Check the log below."
BUILD_STATE.finish(ok, message, "".join(combined))
def trigger_build() -> None:
if not BUILD_STATE.start():
return
thread = threading.Thread(target=run_build, daemon=True)
thread.start()
APP_HTML = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Org Site Authoring</title>
<style>
:root { color-scheme: light; --bg: #f6f3ed; --panel: #fffdf8; --ink: #222; --muted: #64615b; --line: #d8d1c3; --accent: #18615b; --accent-2: #8f3f2b; --disabled: #ece7dc; }
* { box-sizing: border-box; }
body { margin: 0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--ink); }
button, input, textarea, select { font: inherit; }
button { border: 1px solid var(--line); background: #fff; color: var(--ink); min-height: 38px; padding: 0 12px; border-radius: 6px; cursor: pointer; }
button.primary { background: var(--accent); color: white; border-color: var(--accent); }
button:disabled, input:disabled, textarea:disabled, select:disabled { cursor: not-allowed; background: var(--disabled); color: var(--muted); }
.app { min-height: 100vh; display: grid; grid-template-columns: minmax(260px, 360px) 1fr; }
aside { border-right: 1px solid var(--line); background: #eee8dc; padding: 18px; overflow: auto; max-height: 100vh; }
main { padding: 20px clamp(18px, 3vw, 42px); overflow: auto; max-height: 100vh; }
.topbar { display: flex; align-items: center; gap: 10px; justify-content: space-between; margin-bottom: 16px; }
h1 { font-size: 20px; margin: 0; font-weight: 700; }
h2 { font-size: 15px; margin: 18px 0 8px; color: var(--muted); font-weight: 700; }
.status { border: 1px solid var(--line); background: var(--panel); padding: 10px 12px; border-radius: 6px; font-size: 14px; margin-bottom: 14px; }
.status.running { border-color: var(--accent-2); }
.status.ok { border-color: var(--accent); }
.status.fail { border-color: #a12a2a; }
.filters { display: grid; gap: 8px; margin-bottom: 14px; }
.page-list { display: grid; gap: 7px; }
.page-item { width: 100%; text-align: left; min-height: auto; padding: 9px 10px; background: var(--panel); }
.page-item strong { display: block; font-size: 14px; margin-bottom: 2px; overflow-wrap: anywhere; }
.page-item span { display: block; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
form { display: grid; gap: 14px; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
label { display: grid; gap: 5px; font-size: 13px; font-weight: 700; color: var(--muted); }
input, textarea, select { width: 100%; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); padding: 9px 10px; }
textarea { min-height: 48vh; resize: vertical; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; line-height: 1.45; font-size: 14px; }
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.hint { color: var(--muted); font-size: 13px; }
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag { background: #e5eee9; color: #134d48; padding: 2px 7px; border-radius: 999px; font-size: 12px; }
pre { white-space: pre-wrap; overflow: auto; max-height: 280px; background: #201f1d; color: #f7f1e4; padding: 12px; border-radius: 6px; font-size: 12px; }
@media (max-width: 860px) { .app { grid-template-columns: 1fr; } aside, main { max-height: none; } aside { border-right: 0; border-bottom: 1px solid var(--line); } .grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="app">
<aside>
<div class="topbar">
<h1>Authoring</h1>
<button id="newBtn" type="button">New</button>
</div>
<div id="status" class="status"></div>
<div class="filters">
<input id="search" type="search" placeholder="Filter pages" />
<select id="typeFilter">
<option value="">Blogs and posts</option>
<option value="blog">Blogs</option>
<option value="post">Posts</option>
</select>
</div>
<div id="pages" class="page-list"></div>
</aside>
<main>
<form id="editor">
<div class="topbar">
<h1 id="formTitle">New page</h1>
<span id="pathLabel" class="hint"></span>
</div>
<div class="grid">
<label>Page
<select name="pageType">
<option value="blog">Blog</option>
<option value="post">Post</option>
</select>
</label>
<label>Post section
<input name="section" placeholder="Optional, for example career" />
</label>
<label>Title
<input name="title" required />
</label>
<label>Slug
<input name="slug" required />
</label>
<label>Tags
<input name="tags" placeholder="life, review" />
</label>
<label>Date
<input name="date" placeholder="<2026-05-07 Thu 12:00>" />
</label>
</div>
<label>Content
<textarea name="content" spellcheck="true"></textarea>
</label>
<label><span><input name="comments" type="checkbox" checked style="width:auto" /> Comments enabled</span></label>
<div class="actions">
<button class="primary" id="saveBtn" type="submit">Save and build</button>
<button id="resetBtn" type="button">Reset</button>
<span id="saveMessage" class="hint"></span>
</div>
</form>
<h2>Build log</h2>
<pre id="buildLog"></pre>
</main>
</div>
<script>
const state = { pages: [], currentPath: "", build: null };
const $ = (selector) => document.querySelector(selector);
const editor = $("#editor");
const statusBox = $("#status");
const pagesBox = $("#pages");
const buildLog = $("#buildLog");
const saveMessage = $("#saveMessage");
function slugify(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "untitled";
}
function currentOrgDate() {
const d = new Date();
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const pad = (n) => String(n).padStart(2, "0");
return `<${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${days[d.getDay()]} ${pad(d.getHours())}:${pad(d.getMinutes())}>`;
}
async function api(path, options = {}) {
const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Request failed");
return data;
}
function setLocked(locked) {
editor.querySelectorAll("input, textarea, select, button").forEach((el) => el.disabled = locked);
$("#newBtn").disabled = locked;
pagesBox.querySelectorAll("button").forEach((el) => el.disabled = locked);
}
function renderStatus() {
const build = state.build || {};
statusBox.className = "status" + (build.running ? " running" : build.ok === true ? " ok" : build.ok === false ? " fail" : "");
statusBox.textContent = build.message || "Checking build status.";
buildLog.textContent = build.log || "";
setLocked(Boolean(build.running));
}
function renderPages() {
const query = $("#search").value.toLowerCase();
const type = $("#typeFilter").value;
pagesBox.innerHTML = "";
state.pages
.filter((page) => (!type || page.pageType === type))
.filter((page) => `${page.title} ${page.path} ${page.tags.join(" ")}`.toLowerCase().includes(query))
.slice(0, 120)
.forEach((page) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "page-item";
btn.innerHTML = `<strong>${html(page.title)}</strong><span>${html(page.path)}</span><span class="tags">${page.tags.map((tag) => `<span class="tag">${html(tag)}</span>`).join("")}</span>`;
btn.onclick = () => loadPage(page.path);
pagesBox.appendChild(btn);
});
renderStatus();
}
function html(value) {
return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
}
function setForm(page) {
state.currentPath = page.path || "";
editor.pageType.value = page.pageType || "blog";
editor.section.value = "";
editor.title.value = page.title || "";
editor.slug.value = page.slug || slugify(page.title || "");
editor.tags.value = (page.tags || []).join(", ");
editor.date.value = page.date || currentOrgDate();
editor.content.value = page.content || "";
editor.comments.checked = page.comments !== false;
$("#formTitle").textContent = state.currentPath ? "Edit page" : "New page";
$("#pathLabel").textContent = state.currentPath;
saveMessage.textContent = "";
}
async function loadPage(path) {
const page = await api(`/api/page?path=${encodeURIComponent(path)}`);
setForm(page);
}
async function refreshPages() {
state.pages = await api("/api/pages");
renderPages();
}
async function refreshBuild() {
state.build = await api("/api/build");
renderStatus();
if (!state.build.running) await refreshPages();
}
editor.title.addEventListener("input", () => {
if (!state.currentPath) editor.slug.value = slugify(editor.title.value);
});
$("#search").addEventListener("input", renderPages);
$("#typeFilter").addEventListener("change", renderPages);
$("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
$("#resetBtn").addEventListener("click", () => state.currentPath ? loadPage(state.currentPath) : setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
editor.addEventListener("submit", async (event) => {
event.preventDefault();
saveMessage.textContent = "Saving and starting build.";
const payload = Object.fromEntries(new FormData(editor).entries());
payload.path = state.currentPath;
payload.comments = editor.comments.checked;
try {
const saved = await api("/api/page", { method: "POST", body: JSON.stringify(payload) });
setForm(saved);
saveMessage.textContent = "Build started. Editing is locked until it finishes.";
await refreshBuild();
} catch (err) {
saveMessage.textContent = err.message;
}
});
setForm({ pageType: "blog", date: currentOrgDate(), comments: true });
refreshPages();
refreshBuild();
setInterval(refreshBuild, 2500);
</script>
</body>
</html>
"""
class Handler(BaseHTTPRequestHandler):
server_version = "OrgAuthoring/1.0"
def log_message(self, fmt: str, *args: Any) -> None:
sys.stderr.write("%s - %s\n" % (formatdate(time.time()), fmt % args))
def send_json(self, data: Any, status: HTTPStatus = HTTPStatus.OK) -> None:
body = json.dumps(data).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/":
body = APP_HTML.encode("utf-8")
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if parsed.path == "/api/pages":
self.send_json(list_pages())
return
if parsed.path == "/api/page":
query = parse_qs(parsed.query)
try:
path = safe_relative_path(query.get("path", [""])[0])
self.send_json(page_to_dict(read_page(path)))
except Exception as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/build":
self.send_json(BUILD_STATE.snapshot())
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_POST(self) -> None:
if self.path != "/api/page":
self.send_error(HTTPStatus.NOT_FOUND)
return
if BUILD_STATE.snapshot()["running"]:
self.send_json({"error": "A build is already running. Wait for it to finish before editing."}, HTTPStatus.CONFLICT)
return
try:
length = int(self.headers.get("Content-Length", "0"))
data = json.loads(self.rfile.read(length).decode("utf-8"))
saved = save_page(data)
trigger_build()
self.send_json(saved)
except Exception as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def main() -> None:
port = int(os.environ.get("AUTHOR_PORT", "8765"))
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
print(f"Authoring UI running at http://127.0.0.1:{port}")
print("Press Ctrl-C to stop.")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()

0
build-site.el Normal file → Executable file
View File

View File

@@ -4,7 +4,7 @@
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
* Posts:
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">07-05-2026 10:25</span>@@
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">07-05-2026 12:18</span>@@
- [[file:posts-list.sync-conflict-20260417-233432-VT6366A.org][Posts List]] @@html:<span class="post-date">14-04-2026 16:36</span>@@
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@

View File

@@ -59,10 +59,10 @@
- [[file:tags/notes.org][Tag: notes]]
- [[file:tags/review.org][Tag: review]]
- [[file:tags/website.org][Tag: website]]
- [[file:tags/update.org][Tag: update]]
- [[file:tags/life.org][Tag: life]]
- [[file:tags/education.org][Tag: education]]
- [[file:tags/update.org][Tag: update]]
- [[file:tags/insights.org][Tag: insights]]
- [[file:tags/emacs.org][Tag: emacs]]
- [[file:tags/education.org][Tag: education]]
- [[file:tags/reading.org][Tag: reading]]
- [[file:tags/emacs.org][Tag: emacs]]
- [[file:tags/maths.org][Tag: maths]]