diff --git a/README.md b/README.md
index 81dd536..4f041cb 100644
--- a/README.md
+++ b/README.md
@@ -29,9 +29,9 @@ pointing the `shareCss` relation at the matching note – see
|---|---|---|---|
| **Coffee** | `theme/blog_share_theme_coffee.css` | espresso `#1d150f` / latte-cream `#f0e2d0`, caramel `#e0a15c` · cream `#E8E3C3` / ink `#2D1A1D`, berry `#8e2d53` | [megabyte0x/omarchy-coffee-theme](https://github.com/megabyte0x/omarchy-coffee-theme) (MIT) |
| **Harbor** | `theme/blog_share_theme_harbor.css` | near-black `#1b1b1b` / cream `#efebdc`, coral `#e75a50` · paper `#dfe4c4` / ink `#1c2d28`, nord-blue `#5e81ac` | [HANCORE-linux/omarchy-harbor-theme](https://github.com/HANCORE-linux/omarchy-harbordark-theme) (MIT) |
-| **Gruvbox** | `theme/blog_share_theme_gruvbox.css` | bg `#282828` / fg `#ebdbb2`, accents red/green/yellow/blue | [morhetz/gruvbox](https://github.com/morhetz/gruvbox) (MIT) |
+| **Gruvbox** (active) | `theme/blog_share_theme_gruvbox.css` | bg `#282828` / fg `#ebdbb2`, accents red/green/yellow/blue | [morhetz/gruvbox](https://github.com/morhetz/gruvbox) (MIT) |
| **Solarized** | `theme/blog_share_theme_solarized.css` | bg `#002b36` / fg `#839496`, blue `#268bd2` cyan `#2aa198` · bg `#fdf6e3` / fg `#657b83` | [Solarized by Ethan Schoonover](https://ethanschoonover.com/solarized/) (MIT) |
-| **Tokyo Night** (active) | `theme/blog_share_theme_tokyonight.css` | storm `#24283b` / fg `#c0caf5`, blue `#7aa2f7` · day `#e1e2e7` / fg `#3760bf` | [tokyonight.nvim](https://github.com/folke/tokyonight.nvim) (Apache-2.0) |
+| **Tokyo Night** | `theme/blog_share_theme_tokyonight.css` | storm `#24283b` / fg `#c0caf5`, blue `#7aa2f7` · day `#e1e2e7` / fg `#3760bf` | [tokyonight.nvim](https://github.com/folke/tokyonight.nvim) (Apache-2.0) |


@@ -84,7 +84,8 @@ pointing the `shareCss` relation at the matching note – see
|---|---|---|
| `blog_generator.js` | backend script that generates index, feed, sitemap, search data, category & tag pages | code, `application/javascript;env=backend` |
| `blog_publish_manager.js` | backend script that mirrors notes between `Unpublished` and the blog | code, `application/javascript;env=backend` |
-| `theme/blog_share_theme_coffee.css` | Coffee / Coffee Latte share theme (active by default) | code, `text/css` |
+| `theme/blog_share_base.css` | **Base stylesheet** (layout/components, palette-independent, var()-driven) | code, `text/css` |
+| `theme/blog_share_theme_coffee.css` | Coffee / Coffee Latte share theme (palette overrides) | code, `text/css` |
| `theme/blog_share_theme_harbor.css` | Harbor / Harbordark share theme | code, `text/css` |
| `theme/blog_share_theme_gruvbox.css` | Gruvbox light/dark share theme | code, `text/css` |
| `theme/blog_share_theme_solarized.css` | Solarized dark/light share theme | code, `text/css` |
@@ -168,6 +169,7 @@ Under **`.blog`** create one code note per file, using the file name as the note
| `prev next` | `scripts/prev_next.js` | `text/html` |
| `reading time` | `scripts/reading_time.js` | `text/html` |
| `search header` | `scripts/search_header.js` | `text/html` |
+| `related posts` | `scripts/related_posts.js` | `text/html` |
| `note fallback` | `scripts/note_fallback.js` | `text/html` |
| `menu date sort` | `scripts/menu_date_sort.js` | `text/html` |
| `3d model viewer` | `scripts/3d_model_viewer.js` | `text/html` |
diff --git a/blog_generator.js b/blog_generator.js
index a4c3b0b..103d740 100644
--- a/blog_generator.js
+++ b/blog_generator.js
@@ -47,6 +47,38 @@ if (api.originEntity && isGeneratedNoteId(api.originEntity.noteId)) {
return;
}
+// ---- P2: only rebuild on events that can affect the generated pages ----
+// (avoids full rebuild on every snippet/CSS edit, image upload, self-set shareAlias, ...)
+const GEN_RELEVANT_LABELS = new Set([
+ "publish", "date", "category", "tags", "summary", "state",
+ "blogGarden", "blogCategory", "blogNoArticles", "iconClass"
+]);
+const GEN_SKIP_RELATIONS = new Set([
+ "shareHtml", "shareCss", "shareTemplate", "shareFavicon", "shareOpenGraphImage",
+ "runOnNoteContentChange", "runOnAttributeChange", "runOnAttributeCreation",
+ "runOnNoteTitleChange", "runOnNoteCreation", "runOnChildNoteCreation",
+ "runOnBranchDeletion", "runOnEntityChange"
+]);
+function shouldRebuild() {
+ const o = api.originEntity;
+ const ev = api.originEvent || "";
+ if (!o) return true;
+ if (/contentChange/i.test(ev)) {
+ // Article body edits don't change the index/feed/tag pages (only summary labels do).
+ // Keep the refresh only for thumbnail image replacements (index shows the thumb).
+ const title = o.title || "";
+ if (!/^(?:thumb|.+_thumb)\.[A-Za-z0-9]+$/i.test(title)) return false;
+ }
+ if (o.type === "label" && !GEN_RELEVANT_LABELS.has(o.name)) return false;
+ if (o.type === "relation" && GEN_SKIP_RELATIONS.has(o.name)) return false;
+ if (o.type === "branch") return false; // publish/unpublish covers clone+move via label events
+ return true;
+}
+if (!shouldRebuild()) {
+ api.log("blog_generator: event skipped (no index impact)");
+ return;
+}
+
try {
run();
} catch (e) {
@@ -392,6 +424,19 @@ function run() {
})(blogRoot, 0);
}
+ function plainText(a) {
+ try {
+ return String(a.getContent() || "")
+ .replace(/`;
if (dataNote.getContent() !== html) {
diff --git a/probe.txt b/probe.txt
new file mode 100644
index 0000000..e873aea
--- /dev/null
+++ b/probe.txt
@@ -0,0 +1,130 @@
+
+
+
+ Richard's Blog
+ https://richard.familie-zink.org/blog
+
+ Project documentation & technical blog by Richard Zink
+ en
+ Thu, 10 Sep 2026 07:25:48 GMT
+ -
+ Inside this blog: a share-notes engine with a themable frontend
+ https://richard.familie-zink.org/blog/inside-this-blog-a-share-notes-engine-with-a-themable-frontend
+ inDtoyyz9thb
+ Tue, 08 Sep 2026 00:00:00 GMT
+ How this blog runs on Trilium's public share: a custom EJS template, five note-based theme families with matching palettes, and the same colors for the Trilium app and Gitea - all in two repos.
+
+ -
+ FAST-LIO with loop closing on IGNIS-8
+ https://richard.familie-zink.org/blog/ignis8-fast-lio-loop-closing
+ v778JORAnpij
+ Wed, 02 Sep 2026 00:00:00 GMT
+ A code-level tour of IGNIS-8's LiDAR-inertial odometry: a FAST-LIO-style error-state Kalman filter fusing the L1 PM's 250 Hz IMU with its 20 Hz point cloud into a 6-DOF pose, plus a keyframe-based loop closer that detects revisits, confirms them by scan match and rebuilds the four-level voxel map without drift.
+
+ -
+ Arming the drive: a fail-safe by default
+ https://richard.familie-zink.org/blog/ignis8-drive-arm-gate
+ 7EyHklclcGVO
+ Sun, 30 Aug 2026 00:00:00 GMT
+ Why IGNIS-8's drive starts disarmed like a drone: a fail-safe-by-default arm gate that only lets the vehicle move after the operator deliberately enables it, and that re-disarms automatically on every watchdog timeout or emergency stop.
+
+ -
+ Emergency stop: a latch that never releases itself
+ https://richard.familie-zink.org/blog/ignis8-emergency-stop
+ kRTOgbiwjhfI
+ Sat, 29 Aug 2026 00:00:00 GMT
+ Why IGNIS-8's emergency stop is a latched, multi-source system that never resets itself: two independent latches, battery and operator triggers, a manual-only release, and a stop that is selective — the drives release while the camera gimbal and sensors stay live.
+
+ -
+ Cutting the thermal stream and SLAM load on IGNIS-8
+ https://richard.familie-zink.org/blog/cutting-thermal-stream-and-slam-load
+ uzmCVAsFjcJB
+ Thu, 27 Aug 2026 00:00:00 GMT
+ Why my reconnaissance UGV kept throwing OVERLOAD, and how a lookup-table palette, a frame-rate cap and an offscreen raster blit took the thermal stream and the SLAM map from a saturated CPU to a smooth ride.
+
+ -
+ Battery rethink: prismatic PHEV1 vs cylindrical M50L
+ https://richard.familie-zink.org/blog/ignis8-battery-phev1-m50l
+ S3wsaj19dS5L
+ Thu, 27 Aug 2026 00:00:00 GMT
+ Comparing the Samsung PHEV1 prismatic 6S2P pack against a 6S21P LG INR21700M50L cylindrical candidate for IGNIS-8: OCV and power-limit curves, weight and space, and how both cell models are selectable in the VCU config.
+
+ -
+ The technical hurdles between the drawing board and the first drive
+ https://richard.familie-zink.org/blog/ignis8-technical-hurdles
+ ozevCW5vvOtE
+ Sun, 16 Aug 2026 00:00:00 GMT
+ The problems between the drawing board and a working vehicle: a browser-fed watchdog that flipped the OVERLOAD badge, a palette filter that ate nine cores, a camera that refused to follow orders, and a map that stalled the handheld.
+
+ -
+ Project start
+ https://richard.familie-zink.org/blog/ignis8-project-start
+ 0ixEFpiXl6Lg
+ Sat, 08 Aug 2026 00:00:00 GMT
+ How IGNIS-8 came to be: a swollen cell on a battery production floor, a phone call, and the idea of a remotely operated platform that could look at it from a safe distance.
+
+ -
+ Leather Notebook Cover
+ https://richard.familie-zink.org/blog/leather-notebook-cover
+ k6aslDxBHulY
+ Sat, 21 Feb 2026 00:00:00 GMT
+ Hand sewn notebook cover made from goat skin leather
+
+ -
+ !NEC MPi400 Namechange
+ https://richard.familie-zink.org/blog/nec-mpi400-namechange
+ QaHi3lULbcum
+ Tue, 28 Jan 2025 00:00:00 GMT
+ To directly go out of harms way with legal problems, I changed the name of the project.
+
+ -
+ !NEC MPi400: Reverse Engineering the Keyboard Matrix
+ https://richard.familie-zink.org/blog/nec-mpi400-reverse-engineering-the-keyboard-matrix
+ Bx7hKarVZlGf
+ Thu, 25 Jul 2024 00:00:00 GMT
+ Create PCB; Do measurements; Use QMK; Have Keyboard! 😎
+
+ -
+ !NEC MPi400: Disassembly
+ https://richard.familie-zink.org/blog/nec-mpi400-project-start
+ 9mVAVwtlNCTb
+ Tue, 16 Jul 2024 00:00:00 GMT
+ Complete disassembly of the MobilePro 900
+
+ -
+ SoC/OCV Calculation
+ https://richard.familie-zink.org/blog/soc-ocv-calculation
+ TxlNPtkaHjjA
+ Sun, 07 Jul 2024 07:01:00 GMT
+ C source for SoC calculation based on open circuit voltage
+
+ -
+ DLG INR18650-320 cell data
+ https://richard.familie-zink.org/blog/dlg-inr18650-320
+ 9zxBjebNKoVJ
+ Sun, 25 Feb 2024 08:31:00 GMT
+ SoC/OCV liion cell data
+
+ -
+ Open Strainwave Mount (OSWM) planning
+ https://richard.familie-zink.org/blog/oswm-planning
+ 3nwNkCrqAMS9
+ Sat, 02 Dec 2023 08:51:00 GMT
+ Planning a mount with strainwave gears for use with OnStep
+
+ -
+ NGC 281: Pacman Nebula
+ https://richard.familie-zink.org/blog/ngc-281-pacman-nebula
+ obZwmWDJdzK6
+ Thu, 23 Mar 2023 07:43:00 GMT
+ Image of Pacman Nebula taken with Star Adventurer
+
+ -
+ Revisiting old astrophotographies
+ https://richard.familie-zink.org/blog/revisiting-astrophotographies
+ 7Mevqxav5uXv
+ Wed, 22 Jun 2022 11:28:00 GMT
+ Revisiting old photographies of M31 and M42 with a refreshed software process
+
+
+
\ No newline at end of file
diff --git a/scripts/3d_model_viewer.js b/scripts/3d_model_viewer.js
index 27334ab..183e4f9 100644
--- a/scripts/3d_model_viewer.js
+++ b/scripts/3d_model_viewer.js
@@ -323,6 +323,8 @@
}
function showCanvas() {
wrap.innerHTML = "";
+ renderer.domElement.setAttribute("role", "img");
+ renderer.domElement.setAttribute("aria-label", "Interactive 3D model preview");
wrap.appendChild(renderer.domElement);
var hint = document.createElement("div");
hint.className = "model-viewer-hint";
diff --git a/scripts/related_posts.js b/scripts/related_posts.js
new file mode 100644
index 0000000..38bf4f8
--- /dev/null
+++ b/scripts/related_posts.js
@@ -0,0 +1,31 @@
+
diff --git a/scripts/search_header.js b/scripts/search_header.js
index ef2e30f..44627fc 100644
--- a/scripts/search_header.js
+++ b/scripts/search_header.js
@@ -3,46 +3,46 @@
var old = document.querySelector(".search-item .search-input");
if (!old) return;
var box = old.closest(".search-item") || old.parentNode;
-
var input = document.createElement("input");
input.type = "text";
input.className = "blog-search-header-input";
input.placeholder = old.placeholder || "Search...";
old.replaceWith(input);
-
var overlay = document.createElement("div");
overlay.className = "blog-search-overlay";
box.appendChild(overlay);
-
- function render(q) {
- q = (q || "").toLowerCase().trim();
- if (!q) { overlay.innerHTML = ""; overlay.classList.remove("open"); return; }
- var articles = (window.blogData && window.blogData.articles) || [];
- var hits = articles.filter(function (a) {
- var hay = (a.title + " " + (a.summary || "") + " " + (a.tags || []).join(" ")).toLowerCase();
- return hay.indexOf(q) !== -1;
- });
- if (!hits.length) {
- overlay.innerHTML = '
No results.
';
- } else {
- overlay.innerHTML = hits.slice(0, 8).map(function (a) {
- return '' + a.title + '' +
- (a.summary ? '' + a.summary + '' : '') + '';
- }).join("");
+ function esc(s){return String(s).replace(/[&<>"]/g,function(c){return({"&":"&","<":"<",">":">",'"':"""})[c];});}
+ function snippet(text,word){if(!text)return"";var i=text.toLowerCase().indexOf(word);if(i<0)return"";var s=Math.max(0,i-32),e=Math.min(text.length,i+word.length+48);return(s>0?"\u2026":"")+esc(text.slice(s,e))+(e=0){score+=6;hit++;}
+ if(tag.indexOf(word)>=0){score+=4;hit++;}
+ if(sum.indexOf(word)>=0){score+=3;hit++;}
+ if(body.indexOf(word)>=0){score+=1;hit++;}
+ if(!hit){ok=false;break;}
+ }
+ if(ok&&score>0)scored.push({a:a,score:score});
}
+ scored.sort(function(x,y){return y.score-x.score;});
+ if(!scored.length){overlay.innerHTML='No results.
';}
+ else{var first=words[0];overlay.innerHTML=scored.slice(0,8).map(function(s){var a=s.a;var sub=snippet(a.text,first)||a.summary;return ''+esc(a.title)+''+(sub?''+esc(sub)+'':'')+'';}).join("");}
overlay.classList.add("open");
}
-
var t;
- input.addEventListener("input", function () {
- clearTimeout(t);
- t = setTimeout(function () { render(input.value); }, 150);
- });
- input.addEventListener("focus", function () { if (input.value.trim()) render(input.value); });
- input.addEventListener("blur", function () { setTimeout(function () { overlay.classList.remove("open"); }, 150); });
- input.addEventListener("keydown", function (e) { if (e.key === "Escape") { overlay.classList.remove("open"); } });
- document.addEventListener("click", function (e) {
- if (!box.contains(e.target)) overlay.classList.remove("open");
- });
+ input.addEventListener("input",function(){clearTimeout(t);t=setTimeout(function(){render(input.value);},150);});
+ input.addEventListener("focus",function(){if(input.value.trim())render(input.value);});
+ input.addEventListener("blur",function(){setTimeout(function(){overlay.classList.remove("open");},150);});
+ input.addEventListener("keydown",function(e){if(e.key==="Escape")overlay.classList.remove("open");});
+ document.addEventListener("click",function(e){if(!box.contains(e.target))overlay.classList.remove("open");});
})();
-
\ No newline at end of file
+
diff --git a/theme/blog_share_base.css b/theme/blog_share_base.css
index cd63141..d2916bb 100644
--- a/theme/blog_share_base.css
+++ b/theme/blog_share_base.css
@@ -505,3 +505,9 @@ pre.code-processed .code-copy-btn:hover,
/* ---------- a11y: skip link ---------- */
.skip-link{position:absolute;left:-9999px;top:0;z-index:2000;background:var(--background-active);color:var(--background-secondary);padding:8px 14px;border-radius:0 0 8px 0;font-size:.9em;text-decoration:none}
.skip-link:focus{left:0}
+
+/* ---------- Related posts ---------- */
+.blog-related{margin-top:2em;padding-top:1.25em;border-top:1px solid var(--background-highlight)}
+.blog-related ul{list-style:none;padding:0;margin:.5em 0 0;display:flex;flex-wrap:wrap;gap:8px}
+.blog-related a{display:inline-block;padding:4px 12px;border:1px solid var(--background-highlight);border-radius:8px;background:var(--blog-card-bg);color:var(--text-heading);text-decoration:none;font-size:.9em}
+.blog-related a:hover{border-color:var(--text-link);color:var(--text-link);text-decoration:none}