Restoring Replayable Navigation: Moving from Quadratic Overhead to O(1) Data-Driven Layouts
Setting the Stage: Context for the Curious Book Reader
This important essay documents an interesting recovery operation: restoring fast, O(1) previous and next navigation links to a static blog with over 1,400 posts without falling back into slow, quadratic Liquid build times. It highlights how a small Python script can act as a reproducible data projector, keeping site navigation perfectly synchronized with manifest structures while avoiding runtime bottlenecks.
TL;DR: A short entry restoring previous/next navigation links to a Jekyll blog of ~1,440 posts. The original implementation walked every post in Liquid on every page render — quadratic build cost, and the reason it had been dropped. The replacement is a small Python script that computes each post’s older and newer neighbour once (newest first, with a per-day sort_order field breaking ties, the same ordering the site’s llms.txt already uses) and writes them to a Jekyll data file; the post layout then does a single dictionary lookup keyed by page URL. Two mistakes along the way, both caught by before/after command readings rather than by inspection: the script shipped without its __main__ entry point and ran as a silent no-op, and the local preview server did not re-render posts after the data file appeared until a layout file was touched. The script was then added to the site’s publishing pipeline via the Nix configuration that materializes it.
Technical Journal Entry Begins
MikeLev.in: Let’s put my previous/next arrows back into the design.
Fable 5.1 Medium: Context verified — with one caveat: scripts/articles/lsa.py is not in the payload, but its contract is evidenced by two callers here (generate_llms_txt.py, generate_ai_context.py): get_holographic_article_data(path) returns items with permalink, filename, date, sort_order, title, already sorted date DESC then sort_order DESC. That is exactly the flat index post-old.html rebuilt in Liquid, so I’m proceeding on it and gating with a probe.
Why not just paste the old Liquid back
post-old.html walks site.posts once per page to find the current position. At 1,441 posts that is ~2M Liquid iterations per build, on top of the related-articles loop — the same class of cost that forced feed.limit: 10 in _config.yml. The order it computes (newest first, sort_order descending within a day) is also not what Jekyll’s native page.previous/page.next gives you, which sorts same-day posts alphabetically by filename.
The Cost of Quadratic Build Times in Static Sites
So: compute the prev/next map once in Python (a tiny projector, same shape as generate_llms_txt.py), write it to trimnoir/_data/post_nav.json, and have post.html do an O(1) site.data.post_nav[page.url] lookup. Same convention as the old design: Previous = older, Next = newer, with a Home link between them, rendered above and below the article body. If a page’s URL isn’t in the map, nothing renders — fail-safe, not broken.
1. PROBES
rg -n "def get_holographic_article_data|sort_order|'permalink'|\"permalink\"" scripts/articles/lsa.py | head -20
ls -la ~/repos/trimnoir/_data 2>/dev/null || echo "no _data dir yet"
rg -n "site\.data|post_nav" ~/repos/trimnoir/_layouts ~/repos/trimnoir/_includes 2>/dev/null | head
head -c 400 ~/repos/trimnoir/_data/post_nav.json 2>/dev/null || echo "no post_nav.json yet"
The first proves the lsa contract I’m relying on (the key names and the date/sort_order ordering); if sort_order never appears in lsa.py, the generator still runs but same-day order is unverified — say so and I’ll adjust. The second and third check that _data/ doesn’t already exist with a colliding post_nav key or a layout already reading it. The fourth is the BEFORE reading of the straddle; the AFTER should show JSON.
2. NEXT CONTEXT
scripts/articles/lsa.py
scripts/articles/generate_post_nav.py
/home/mike/repos/trimnoir/_layouts/post.html
/home/mike/repos/trimnoir/assets/main.css
! rg -n "def get_holographic_article_data|sort_order|'permalink'|\"permalink\"" scripts/articles/lsa.py | head -20
! ls -la ~/repos/trimnoir/_data 2>/dev/null || echo "no _data dir yet"
! rg -n "site\.data|post_nav" ~/repos/trimnoir/_layouts ~/repos/trimnoir/_includes 2>/dev/null | head
! head -c 400 ~/repos/trimnoir/_data/post_nav.json 2>/dev/null || echo "no post_nav.json yet"
3. PATCHES
Car 1 — the projector (new file).
Target: scripts/articles/generate_post_nav.py
[[[WRITE_FILE]]]
#!/usr/bin/env python3
"""
generate_post_nav.py
Precomputes previous/next post links as a Jekyll data file so the post layout
does an O(1) `site.data.post_nav[page.url]` lookup instead of walking
`site.posts` once per page (O(n^2) Liquid at 1,400+ posts).
Ordering is the same flat index the old post layout rebuilt in Liquid:
newest first, and within a day highest sort_order first. That ordering comes
from lsa.get_holographic_article_data(), which is also what llms.txt uses, so
the nav and the manifest can never disagree.
Convention (matches the retired design):
prev = the OLDER post, next = the NEWER post.
Output: <target repo root>/_data/post_nav.json, keyed by page URL.
Usage:
python scripts/articles/generate_post_nav.py -t 1
"""
import re
import sys
import json
import argparse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lsa
import common
OUTPUT_NAME = "post_nav.json"
def article_url(item: dict) -> str:
"""Mirror the site's URL contract: honor an explicit permalink, else /:slug/."""
permalink = str(item.get("permalink") or "").strip()
if permalink:
if not permalink.startswith("/"):
permalink = "/" + permalink
if not permalink.endswith("/"):
permalink += "/"
return permalink
stem = Path(item["filename"]).stem
slug = re.sub(r"^\d{4}-\d{2}-\d{2}-", "", stem)
return f"/{slug}/"
def build_nav(metadata: list) -> dict:
"""metadata is newest-first; neighbours are taken from that single ordering."""
nav = {}
total = len(metadata)
for i, item in enumerate(metadata):
entry = {}
if i + 1 < total: # older neighbour
older = metadata[i + 1]
entry["prev"] = {"url": article_url(older), "title": older.get("title", "")}
if i > 0: # newer neighbour
newer = metadata[i - 1]
entry["next"] = {"url": article_url(newer), "title": newer.get("title", "")}
entry["position"] = total - i # 1 = oldest, total = newest
nav[article_url(item)] = entry
return nav
def main():
parser = argparse.ArgumentParser(description="Generate _data/post_nav.json (prev/next links).")
common.add_standard_arguments(parser)
args = parser.parse_args()
posts_dir = common.get_target_path(args)
repo_root = posts_dir.parent
data_dir = repo_root / "_data"
output_file = data_dir / OUTPUT_NAME
metadata = lsa.get_holographic_article_data(str(posts_dir)) # newest-first
if not metadata:
print(f"❌ No articles found under {posts_dir}", file=sys.stderr)
sys.exit(1)
nav = build_nav(metadata)
if len(nav) != len(metadata):
print(f"⚠️ URL collision: {len(metadata)} posts collapsed to {len(nav)} nav keys.")
payload = {"_meta": {"total": len(metadata)}, **nav}
data_dir.mkdir(parents=True, exist_ok=True)
with open(output_file, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=1, ensure_ascii=False, sort_keys=True)
f.write("\n")
newest = article_url(metadata[0])
print(f"✅ Wrote {output_file} ({len(nav)} entries).")
print(f" Newest: {newest} -> prev={nav[newest].get('prev', {}).get('url', '-')}")
[[[END_WRITE_FILE]]]
Car 2 — the layout.
Target: /home/mike/repos/trimnoir/_layouts/post.html
[[[SEARCH]]]
---
layout: default
---
<article itemscope itemtype="http://schema.org/BlogPosting">
[[[DIVIDER]]]
---
layout: default
---
{%- assign nav = site.data.post_nav[page.url] -%}
{%- capture post_nav -%}
{%- if nav %}
<nav class="post-navigation" aria-label="Post navigation">
{%- if nav.prev %}
<a class="nav-prev" href="{{ nav.prev.url | relative_url }}" rel="prev">
<span class="nav-label">← Previous Post</span>
<span class="nav-title">{{ nav.prev.title | escape }}</span>
</a>
{%- else %}
<span class="nav-prev placeholder"></span>
{%- endif %}
<a class="nav-home" href="{{ "/" | relative_url }}">Home</a>
{%- if nav.next %}
<a class="nav-next" href="{{ nav.next.url | relative_url }}" rel="next">
<span class="nav-label">Next Post →</span>
<span class="nav-title">{{ nav.next.title | escape }}</span>
</a>
{%- else %}
<span class="nav-next placeholder"></span>
{%- endif %}
</nav>
{%- endif %}
{%- endcapture -%}
<article itemscope itemtype="http://schema.org/BlogPosting">
[[[REPLACE]]]
Target: /home/mike/repos/trimnoir/_layouts/post.html
[[[SEARCH]]]
<div itemprop="articleBody">
{{ content }}
</div>
[[[DIVIDER]]]
{{ post_nav }}
<div itemprop="articleBody">
{{ content }}
</div>
{{ post_nav }}
[[[REPLACE]]]
Car 3 — the CSS.
Target: /home/mike/repos/trimnoir/assets/main.css
[[[SEARCH]]]
[data-theme="dark"] .stealth-breadcrumbs .current-crumb {
color: #ccc;
}
[[[DIVIDER]]]
[data-theme="dark"] .stealth-breadcrumbs .current-crumb {
color: #ccc;
}
/* Previous / Next post navigation (data-driven: _data/post_nav.json) */
.post-navigation {
display: flex;
justify-content: space-between;
align-items: stretch;
gap: 15px;
margin: 20px 0;
}
.post-navigation .nav-prev,
.post-navigation .nav-next {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
padding: 8px 12px;
background-color: var(--code-bg);
border: 1px solid var(--code-bg);
border-radius: 4px;
color: var(--text-color);
text-decoration: none;
transition: background-color 0.2s, color 0.2s;
}
.post-navigation .nav-next {
text-align: right;
}
.post-navigation a.nav-prev:hover,
.post-navigation a.nav-next:hover {
background-color: var(--link-color);
color: #fff;
text-decoration: none;
}
.post-navigation .placeholder {
background: none;
border-color: transparent;
}
.post-navigation .nav-home {
flex: 0 0 auto;
align-self: center;
padding: 8px 12px;
}
.post-navigation .nav-label {
font-size: 0.8em;
opacity: 0.7;
}
.post-navigation .nav-title {
font-weight: 500;
overflow-wrap: anywhere;
}
@media screen and (max-width: 600px) {
.post-navigation {
flex-direction: column;
}
.post-navigation .nav-next {
text-align: left;
}
}
[[[REPLACE]]]
Ignition (required): the layout reads a data file that does not exist until the generator runs, and jekyll serve only picks up _data/ changes on regeneration.
.venv/bin/python scripts/articles/generate_post_nav.py -t 1
Then restart (or let regenerate) the port-4001 preview and open any post. Commit _data/post_nav.json in the trimnoir repo alongside the layout and CSS.
4. PROMPT
Prev/next nav is back as a data-file projector (generate_post_nav.py -> trimnoir/_data/post_nav.json) read by post.html. Check the AFTER receipts: does the lsa.py probe confirm sort_order ordering, does post_nav.json exist and look right, and does the latest post render Previous (older) / Home / Next (newer) at top and bottom? If any post shows no nav, its page.url is not matching the JSON key — report the URL so we can fix article_url(). Then wire generate_post_nav.py into the target-1 pipeline in blogs.nix.
5. EXTERNAL DELIVERABLES
~/repos/nixos/blogs.nix (manual edit, then sudo nixos-rebuild switch): add "generate_post_nav.py" to the target-1 pipeline list, after build_knowledge_graph.py and before link_injector.py, so every publishizer.py run refreshes the nav map. I did not patch ~/.config/pipulate/blogs.json directly because it is materialized from Nix and would be overwritten on the next rebuild.
MikeLev.in:
Same commands, run twice, one change between them. Where the readings differ is what the change did; the diff in the middle is the receipt.
1: Probe (BEFORE: hand-run, nothing changed yet):
$ git status
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
(nix) pipulate $ rg -n "def get_holographic_article_data|sort_order|'permalink'|\"permalink\"" scripts/articles/lsa.py | head -20
ls -la ~/repos/trimnoir/_data 2>/dev/null || echo "no _data dir yet"
rg -n "site\.data|post_nav" ~/repos/trimnoir/_layouts ~/repos/trimnoir/_includes 2>/dev/null | head
head -c 400 ~/repos/trimnoir/_data/post_nav.json 2>/dev/null || echo "no post_nav.json yet"
6:with the deep structural sort_order analysis of list_articles.py.
120:def fast_get_sort_order(filepath):
121: """Reads only the YAML frontmatter to extract sort_order extremely fast."""
135: return int(fm.get('sort_order', 0)), (fm.get('permalink') or '')
139:def analyze_sort_order_contiguity(metadata):
140: """Analyzes sort_order for gaps, duplicates, and late starts."""
145: posts_by_day[item['date']].append(item['sort_order'])
284: parser.add_argument('-g', '--gaps', action='store_true', help="Run and display the sort_order contiguity gap report")
331: # (path, mtime) -> [sort_order, permalink]. Served by the shared
368: sort_order, permalink = cached_fm[0], cached_fm[1]
370: sort_order, permalink = fast_get_sort_order(filepath)
371: fm_memo.store(filepath, fm_mtime, [sort_order, permalink])
376: 'sort_order': sort_order,
377: 'permalink': permalink
391: # Sort first by date, then by the YAML sort_order
392: metadata.sort(key=lambda p: (p['date'], p['sort_order']), reverse=args.reverse)
532: permalink = item.get('permalink', '').rstrip('/')
556: order = item['sort_order']
568:def get_holographic_article_data(target_dir: str) -> list[dict]:
600: sort_order = int(fm.get('sort_order', 0))
total 8
drwxr-xr-x 2 mike users 4096 Jan 4 2026 .
drwxr-xr-x 16 mike users 4096 Sep 3 05:43 ..
/home/mike/repos/trimnoir/_includes/graph_hud.html:9: // We use 'site.data.graph' because we copied graph.json to _data/graph.json
/home/mike/repos/trimnoir/_includes/graph_hud.html:10: const rawGraph = {{ site.data.graph | jsonify }};
no post_nav.json yet
(nix) pipulate $
2: Context (AFTER: the same probes re-run by the compiler as ! lines):
# adhoc.txt _ _ _ to set context____ _ _ ___ ____ _ Simpson Couch Gag Here (explain anything to the audience you feel needs it explained)G
# / \ __| | | | | | ___ ___ / ___| | | |/ _ \| _ \| |
# ahe/ _ \ / _` | | |_| |/ _ \ / __| | | | |_| | | | | |_) | | Bringing blog arrows back
# ahc ___ \ (_| | | _ | (_) | (__ | |___| _ | |_| | __/|_|
# /_/ \_\__,_| |_| |_|\___/ \___| \____|_| |_|\___/|_| (_)
# Ad Hoc CHOP: The Not-Managed-by-Git Safe-for-Client-Data place
# OPTIONAL BUT BIG FOR FULL CONTEXT-WINDOW STORYTELLING
# ! python scripts/articles/lsa.py -t 1 --reverse --fmt dated-slugs # <-- The "Rolling Pin" that gives the 40K foot book-spine view of book-ore.
# GLOSSARY.md # <-- I think this glossary goes well with the book-ore spine to do world building.
# scripts/articles/lsa.py # <-- Useful for refining commands like `posts`, critical to Second Brain concept.
# ~/repos/nixos/autognome.py # <-- Letting the AIs really understand my environment (The Brave Little Tailor punches above Their Weight Class proving the dunning-kruger effect the gate-keeper's (lower-case) lament.)
# init.lua # <-- Daily driver hot-keys that overlap with aliases in flake.nix
# STILL BIG BUT LESS OPTIONAL (especially flake.nix)
# flake.nix # <-- THE ONE BIG THING TO INCLUDE Infrastructure as Code (IaC) tells LLM about your system down to the metal
# prompt_foo.py # <-- This very content-compiling system
# foo_files.py # <-- This is the router, evolving book outline and the things you pin-up to produced the recursive self-improvement loops
# TINY ILLUMINATING (OK to include every time / automatically = `apply.py`, `.gitignore`, `.gitattributes`)
# requirements.in # <-- All known dependencies and (necessary) version pinning. WORA gotcha's exposed.
# __init__.py # <-- Master versioning
# pyproject.toml # <-- The PyPI Packaging details
# OPTIONAL ACTUATORS (cheap and good to include to expand the AI's capabilities)
# cli.py # <-- Catch-all actuator for PyPI envs, Python anchoring, MCP tool-call (plus alternatives) and **kwargs like wrapping for CLI
# scripts/xp.py # <-- Transforms host OS copy-paste buffer player-piano music into context-payload.
# scripts/ai.py # <-- How I constantly use local AI to write git commit messages with `m` alias.
# scripts/crawl.py # <-- Feel free to ask for something to be crawled and included in the next turn.
# scripts/weblogin.py # <-- Lets the user "warm up" the cache for their web logins at their leisure on a profile that persists.
# MISCELLANEOUS (rare to include but sometimes critical)
# scripts/foo_cartridge.py # Needs description
# scripts/foo_replay.py # Needs description
# release.py # <-- How everything ends up where it does (GitHub, PyPI, etc.)
# imports/voice_synthesis.py # <-- The wand can talk to you
# imports/ascii_displays.py # <-- Where all the ASCII Art lives
# scripts/release/version_sync.py # <-- Needs to be wrapped into release.py and eliminated, I think.
# --- Under this line is were you paste what the AI gives you ---
# --- We call it context but it's really just the right-hand ---
# --- blast-radius of the "probes" to make this all science. ---
# --- END `adhoc.txt` TEMPLATE ---
# STICKBUG & MOTHER CAT KATA (WORKING ON THE CHAPTER)
# assets/installer/mck.sh
# assets/installer/replay.sh
# scripts/walk.py
# scripts/walk_cartridge.py
# scripts/walk_compile.py
# assets/trails/first_context.yaml
# assets/trails/practice.yaml
# assets/trails/public_walk.yaml
# assets/trails/botify_pageworkers.yaml
# scripts/connectors/README.md
# scripts/connectors/botify.py
# scripts/connectors/confluence.py
# scripts/connectors/gmail.py
# scripts/connectors/gsc.py
# scripts/connectors/jira.py
# scripts/connectors/mcp.py
# scripts/connectors/mcp_warm.py
# scripts/connectors/sheets.py
# scripts/connectors/slack.py
# scripts/connectors/wallet.py
# scripts/bookmark_import.py
# scripts/boot_menu.py
# scripts/mother_cat.py
# scripts/sources_menu.py
# scripts/weblogin.py
# tools/scraper_tools.py
# --- START THIS DISCUSSION ---
# CONTEXT: 1
# ~/.config/pipulate/blogs.json # <-- CAUTION! Derived from ~/repos/nixos/blogs.nix
# scripts/articles/publishizer.py # <-- Orchestrates different publishing workflows per target blog.
# scripts/articles/common.py # <-- Self-explanatory
# scripts/articles/articleizer.py # <-- Transforms raw article.txt to formal Jekyll markdown format
# scripts/articles/editing_prompt.txt # <-- Forcing response into strict JSON data structure
# scripts/articles/sanitizer.py # <-- Scrubs PII
# scripts/articles/gsc_historical_fetch.py
# scripts/articles/contextualizer.py # <-- Builds JSON summaries of articles in `_posts/context/` called "Holographic Shards".
# scripts/articles/confluenceizer.py # <-- Idempotent Jekyll-to-Confluence corporate wiki
# scripts/articles/googledocizer.py # <-- Just added
# scripts/articles/build_knowledge_graph.py # <-- Topically load-balances site using hierarchical K-Means keyword clustering groups
# scripts/articles/generate_ai_context.py # <-- AIs WILL interrogate your repo. This gives epic context of article URLs for drill-down.
# scripts/articles/generate_hubs.py # <-- Uses just-produced link-graph data to generate each of the new hubs it suggests
# scripts/articles/generate_llms_txt.py # <-- Builds an llms.txt based on the auto-organized structure suggested here
# scripts/articles/generate_redirects.py # <-- Generates redirect map above hub-churn suggests is needed
# scripts/articles/sanitize_redirects.py # <-- Deals with follow-up meticulous pedantic detail required for a good Nginx redirect map
# /home/mike/repos/MikeLev.in/_layouts/default-old.html
# /home/mike/repos/trimnoir/_layouts/default.html
# /home/mike/repos/MikeLev.in/_layouts/post-old.html
# /home/mike/repos/trimnoir/_layouts/post.html
# /home/mike/repos/trimnoir/assets/main.css
# /home/mike/repos/trimnoir/_config.yml
# CONTEXT: 2
scripts/articles/lsa.py
scripts/articles/generate_post_nav.py
/home/mike/repos/trimnoir/_layouts/post.html
/home/mike/repos/trimnoir/assets/main.css
! rg -n "def get_holographic_article_data|sort_order|'permalink'|\"permalink\"" scripts/articles/lsa.py | head -20
! ls -la ~/repos/trimnoir/_data 2>/dev/null || echo "no _data dir yet"
! rg -n "site\.data|post_nav" ~/repos/trimnoir/_layouts ~/repos/trimnoir/_includes 2>/dev/null | head
! head -c 400 ~/repos/trimnoir/_data/post_nav.json 2>/dev/null || echo "no post_nav.json yet"
ls /home/mike/repos/trimnoir/_data
3: Patches (the one change between the readings):
Okay, the one thing in the Pipulate repo:
(nix) pipulate $ g
Blast Radius Check to establish bisection Left-hand Causal Boundary. It is a Popper-thing. Science.
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
(nix) pipulate $ patch
(nix) pipulate $ app
✅ WHOLE-FILE WRITE: CREATED 'scripts/articles/generate_post_nav.py'.
(nix) pipulate $ d
--- UNTRACKED (invisible to the diff above; m will stage these) ---
+ scripts/articles/generate_post_nav.py
(nix) pipulate $ git add scripts/articles/generate_post_nav.py
(nix) pipulate $ m
📝 Committing: refactor: generate post nav script
[main 90144a70] refactor: generate post nav script
1 file changed, 93 insertions(+)
create mode 100644 scripts/articles/generate_post_nav.py
(nix) pipulate $ git push
Enumerating objects: 8, done.
Counting objects: 100% (8/8), done.
Delta compression using up to 48 threads
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 1.90 KiB | 1.90 MiB/s, done.
Total 5 (delta 3), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (3/3), completed with 3 local objects.
To github.com:pipulate/pipulate.git
05a29e6e..90144a70 main -> main
(nix) pipulate $
And now for the Trimnoir repo:
(nix) trimnoir $ git status
On branch main
Your branch is ahead of 'home/main' by 1 commit.
(use "git push" to publish your local commits)
nothing to commit, working tree clean
(nix) trimnoir $ git status
On branch main
Your branch is ahead of 'home/main' by 1 commit.
(use "git push" to publish your local commits)
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: _layouts/post.html
modified: assets/main.css
no changes added to commit (use "git add" and/or "git commit -a")
(nix) trimnoir $ git --no-pager diff
diff --git a/_layouts/post.html b/_layouts/post.html
index 917b832f2..06194f663 100644
--- a/_layouts/post.html
+++ b/_layouts/post.html
@@ -1,6 +1,30 @@
---
layout: default
---
+{%- assign nav = site.data.post_nav[page.url] -%}
+{%- capture post_nav -%}
+{%- if nav %}
+<nav class="post-navigation" aria-label="Post navigation">
+ {%- if nav.prev %}
+ <a class="nav-prev" href="{{ nav.prev.url | relative_url }}" rel="prev">
+ <span class="nav-label">← Previous Post</span>
+ <span class="nav-title">{{ nav.prev.title | escape }}</span>
+ </a>
+ {%- else %}
+ <span class="nav-prev placeholder"></span>
+ {%- endif %}
+ <a class="nav-home" href="{{ "/" | relative_url }}">Home</a>
+ {%- if nav.next %}
+ <a class="nav-next" href="{{ nav.next.url | relative_url }}" rel="next">
+ <span class="nav-label">Next Post →</span>
+ <span class="nav-title">{{ nav.next.title | escape }}</span>
+ </a>
+ {%- else %}
+ <span class="nav-next placeholder"></span>
+ {%- endif %}
+</nav>
+{%- endif %}
+{%- endcapture -%}
<article itemscope itemtype="http://schema.org/BlogPosting">
<header>
@@ -21,9 +45,11 @@ layout: default
• <a href="{{ page.gdoc_url }}" rel="alternate" type="application/vnd.google-apps.document">📄 Google Doc (Try: Tools/Audio/Listen to document summary)</a>
{%- endif %}
</div>
+ {{ post_nav }}
<div itemprop="articleBody">
{{ content }}
</div>
+ {{ post_nav }}
{%- if site.disqus.shortname -%}
{%- include disqus_comments.html -%}
diff --git a/assets/main.css b/assets/main.css
index 604dc4554..c4e7055eb 100644
--- a/assets/main.css
+++ b/assets/main.css
@@ -779,3 +779,60 @@ footer {
[data-theme="dark"] .stealth-breadcrumbs .current-crumb {
color: #ccc;
}
+
+/* Previous / Next post navigation (data-driven: _data/post_nav.json) */
+.post-navigation {
+ display: flex;
+ justify-content: space-between;
+ align-items: stretch;
+ gap: 15px;
+ margin: 20px 0;
+}
+.post-navigation .nav-prev,
+.post-navigation .nav-next {
+ flex: 1 1 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ padding: 8px 12px;
+ background-color: var(--code-bg);
+ border: 1px solid var(--code-bg);
+ border-radius: 4px;
+ color: var(--text-color);
+ text-decoration: none;
+ transition: background-color 0.2s, color 0.2s;
+}
+.post-navigation .nav-next {
+ text-align: right;
+}
+.post-navigation a.nav-prev:hover,
+.post-navigation a.nav-next:hover {
+ background-color: var(--link-color);
+ color: #fff;
+ text-decoration: none;
+}
+.post-navigation .placeholder {
+ background: none;
+ border-color: transparent;
+}
+.post-navigation .nav-home {
+ flex: 0 0 auto;
+ align-self: center;
+ padding: 8px 12px;
+}
+.post-navigation .nav-label {
+ font-size: 0.8em;
+ opacity: 0.7;
+}
+.post-navigation .nav-title {
+ font-weight: 500;
+ overflow-wrap: anywhere;
+}
+@media screen and (max-width: 600px) {
+ .post-navigation {
+ flex-direction: column;
+ }
+ .post-navigation .nav-next {
+ text-align: left;
+ }
+}
(nix) trimnoir $ git commit -am "Bringing back blog arrows"
[main affd3f585] Bringing back blog arrows
2 files changed, 83 insertions(+)
(nix) trimnoir $
Ignition (what makes the patched code run before the AFTER reading – <F2>, nix develop, a re-ride – or none required):
We ignite it.
(nix) pipulate $ .venv/bin/python scripts/articles/generate_post_nav.py -t 1
(nix) pipulate $
Okay, I do that but I don’t see any new diffs on either Trimnoir or Pipulate. Hmm. Okay, it’s there somewhere. Not here where I expect:
(nix) _data $ ls
(nix) _data $ pwd
/home/mike/repos/trimnoir/_data
(nix) _data $
4: Prompt (the next question, written before the AFTER reading is in):
Prev/next nav is back as a data-file projector (generate_post_nav.py -> trimnoir/_data/post_nav.json) read by post.html. Check the AFTER receipts: does the lsa.py probe confirm sort_order ordering, does post_nav.json exist and look right, and does the latest post render Previous (older) / Home / Next (newer) at top and bottom? If any post shows no nav, its page.url is not matching the JSON key — report the URL so we can fix article_url(). Then wire generate_post_nav.py into the target-1 pipeline in blogs.nix.
5: Deliverables: [external artifacts, updates to this system or ‘None this turn’]
An easier to navigate Public website (but not much - more on that later).
Fable 5.1 Medium: Context verified. The receipts settle both open questions: lsa.get_holographic_article_data sorts on (date, sort_order) with reverse=True (lines 391–392 and 600 of the receipt, plus the function body itself), so the ordering assumption holds. And the “silent ignition” is fully explained by the file body now sitting in the Codebase section.
Why the generator produced nothing
Look at the tail of generate_post_nav.py as written to disk: it ends at the last print inside main(). There is no if __name__ == "__main__": main() guard — I dropped it from the WRITE_FILE body. Python imported lsa and common, defined three functions, and exited. That is also why you saw no 🎯 Default target auto-selected line, which common.get_target_path() would have printed on the first line of a real run: main() was never called. Nothing in _data/ because nothing ran, not because the path is wrong.
Empty _data/ is otherwise good news: no colliding key, and graph_hud.html already proves the site.data.* lookup pattern works in this Jekyll setup.
One compiler nit from the Summary log: adhoc.txt still has a bare ls /home/mike/repos/trimnoir/_data line without the ! prefix, so it was treated as a (nonexistent) file path and skipped. Either prefix it or drop it; the head -c 400 probe already covers it.
1. PROBES
rg -n "__main__" scripts/articles/generate_post_nav.py || echo "NO MAIN GUARD"
head -c 400 ~/repos/trimnoir/_data/post_nav.json 2>/dev/null || echo "no post_nav.json yet"
.venv/bin/python -c "import json;d=json.load(open('/home/mike/repos/trimnoir/_data/post_nav.json'));k=[x for x in d if x!='_meta'];print(d['_meta'],len(k),k[0])" 2>/dev/null || echo "no json to parse yet"
grep -m1 '^permalink:' "$(ls -t ~/repos/trimnoir/_posts/*.md | head -1)"
The first is the conviction: BEFORE it prints NO MAIN GUARD, AFTER it prints the guard line. The second and third are the straddle on the artifact itself — after ignition the third should print {'total': 1441}, a count of 1441, and a sample key. The fourth prints the newest post’s frontmatter permalink; compare it by eye to the JSON keys (they must be byte-identical, with leading and trailing slash) since page.url for an explicit-permalink post is exactly that string.
2. NEXT CONTEXT
scripts/articles/generate_post_nav.py
/home/mike/repos/trimnoir/_layouts/post.html
! rg -n "__main__" scripts/articles/generate_post_nav.py || echo "NO MAIN GUARD"
! head -c 400 ~/repos/trimnoir/_data/post_nav.json 2>/dev/null || echo "no post_nav.json yet"
! .venv/bin/python -c "import json;d=json.load(open('/home/mike/repos/trimnoir/_data/post_nav.json'));k=[x for x in d if x!='_meta'];print(d['_meta'],len(k),k[0])" 2>/dev/null || echo "no json to parse yet"
! grep -m1 '^permalink:' "$(ls -t ~/repos/trimnoir/_posts/*.md | head -1)"
3. PATCHES
Car 1 — the missing entry point.
Target: scripts/articles/generate_post_nav.py
[[[SEARCH]]]
newest = article_url(metadata[0])
print(f"✅ Wrote {output_file} ({len(nav)} entries).")
print(f" Newest: {newest} -> prev={nav[newest].get('prev', {}).get('url', '-')}")
[[[DIVIDER]]]
newest = article_url(metadata[0])
print(f"✅ Wrote {output_file} ({len(nav)} entries).")
print(f" Newest: {newest} -> prev={nav[newest].get('prev', {}).get('url', '-')}")
if __name__ == "__main__":
main()
[[[REPLACE]]]
Ignition (required): same command as before, but this time main() will actually execute. Expect the 🎯 target line, ✅ Wrote .../_data/post_nav.json (1441 entries)., and a Newest: line.
.venv/bin/python scripts/articles/generate_post_nav.py -t 1
Then in trimnoir: git add _data/post_nav.json && git commit -m "Add post_nav.json" and check the port-4001 preview on the newest post (it should show Previous on the left, a transparent placeholder on the right since nothing is newer).
4. PROMPT
The generator was missing its __main__ guard, so the first ignition was a no-op; the guard is patched and the generator re-run. Check the AFTER receipts: does post_nav.json exist with total == 1441, and does the newest post's permalink match its JSON key exactly? Then confirm in the browser that the newest post renders Previous (older) / Home at top and bottom. If the nav is missing on any post, paste that post's page.url and the nearest JSON key so we can fix article_url(). After that: wire generate_post_nav.py into the target-1 pipeline in blogs.nix.
5. EXTERNAL DELIVERABLES
Unchanged from last turn: ~/repos/nixos/blogs.nix (manual edit, then sudo nixos-rebuild switch) — add "generate_post_nav.py" to the target-1 pipeline list after build_knowledge_graph.py, so every publishizer.py run refreshes the nav map. Hold off until the AFTER receipts confirm the JSON keys match page.url.
MikeLev.in:
Same commands, run twice, one change between them. Where the readings differ is what the change did; the diff in the middle is the receipt.
1: Probe (BEFORE: hand-run, nothing changed yet):
$ git status
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
(nix) pipulate $ rg -n "__main__" scripts/articles/generate_post_nav.py || echo "NO MAIN GUARD"
head -c 400 ~/repos/trimnoir/_data/post_nav.json 2>/dev/null || echo "no post_nav.json yet"
.venv/bin/python -c "import json;d=json.load(open('/home/mike/repos/trimnoir/_data/post_nav.json'));k=[x for x in d if x!='_meta'];print(d['_meta'],len(k),k[0])" 2>/dev/null || echo "no json to parse yet"
grep -m1 '^permalink:' "$(ls -t ~/repos/trimnoir/_posts/*.md | head -1)"
NO MAIN GUARD
no post_nav.json yet
no json to parse yet
permalink: /futureproof/fixing-google-doc-titles-pipeline-repairs/
(nix) pipulate $
2: Context (AFTER: the same probes re-run by the compiler as ! lines):
# adhoc.txt _ _ _ to set context____ _ _ ___ ____ _ Simpson Couch Gag Here (explain anything to the audience you feel needs it explained)G
# / \ __| | | | | | ___ ___ / ___| | | |/ _ \| _ \| |
# ahe/ _ \ / _` | | |_| |/ _ \ / __| | | | |_| | | | | |_) | | Bringing blog arrows back
# ahc ___ \ (_| | | _ | (_) | (__ | |___| _ | |_| | __/|_| Take two
# /_/ \_\__,_| |_| |_|\___/ \___| \____|_| |_|\___/|_| (_)
# Ad Hoc CHOP: The Not-Managed-by-Git Safe-for-Client-Data place
# OPTIONAL BUT BIG FOR FULL CONTEXT-WINDOW STORYTELLING
# ! python scripts/articles/lsa.py -t 1 --reverse --fmt dated-slugs # <-- The "Rolling Pin" that gives the 40K foot book-spine view of book-ore.
# GLOSSARY.md # <-- I think this glossary goes well with the book-ore spine to do world building.
# scripts/articles/lsa.py # <-- Useful for refining commands like `posts`, critical to Second Brain concept.
# ~/repos/nixos/autognome.py # <-- Letting the AIs really understand my environment (The Brave Little Tailor punches above Their Weight Class proving the dunning-kruger effect the gate-keeper's (lower-case) lament.)
# init.lua # <-- Daily driver hot-keys that overlap with aliases in flake.nix
# STILL BIG BUT LESS OPTIONAL (especially flake.nix)
# flake.nix # <-- THE ONE BIG THING TO INCLUDE Infrastructure as Code (IaC) tells LLM about your system down to the metal
# prompt_foo.py # <-- This very content-compiling system
# foo_files.py # <-- This is the router, evolving book outline and the things you pin-up to produced the recursive self-improvement loops
# TINY ILLUMINATING (OK to include every time / automatically = `apply.py`, `.gitignore`, `.gitattributes`)
# requirements.in # <-- All known dependencies and (necessary) version pinning. WORA gotcha's exposed.
# __init__.py # <-- Master versioning
# pyproject.toml # <-- The PyPI Packaging details
# OPTIONAL ACTUATORS (cheap and good to include to expand the AI's capabilities)
# cli.py # <-- Catch-all actuator for PyPI envs, Python anchoring, MCP tool-call (plus alternatives) and **kwargs like wrapping for CLI
# scripts/xp.py # <-- Transforms host OS copy-paste buffer player-piano music into context-payload.
# scripts/ai.py # <-- How I constantly use local AI to write git commit messages with `m` alias.
# scripts/crawl.py # <-- Feel free to ask for something to be crawled and included in the next turn.
# scripts/weblogin.py # <-- Lets the user "warm up" the cache for their web logins at their leisure on a profile that persists.
# MISCELLANEOUS (rare to include but sometimes critical)
# scripts/foo_cartridge.py # Needs description
# scripts/foo_replay.py # Needs description
# release.py # <-- How everything ends up where it does (GitHub, PyPI, etc.)
# imports/voice_synthesis.py # <-- The wand can talk to you
# imports/ascii_displays.py # <-- Where all the ASCII Art lives
# scripts/release/version_sync.py # <-- Needs to be wrapped into release.py and eliminated, I think.
# --- Under this line is were you paste what the AI gives you ---
# --- We call it context but it's really just the right-hand ---
# --- blast-radius of the "probes" to make this all science. ---
# --- END `adhoc.txt` TEMPLATE ---
# STICKBUG & MOTHER CAT KATA (WORKING ON THE CHAPTER)
# assets/installer/mck.sh
# assets/installer/replay.sh
# scripts/walk.py
# scripts/walk_cartridge.py
# scripts/walk_compile.py
# assets/trails/first_context.yaml
# assets/trails/practice.yaml
# assets/trails/public_walk.yaml
# assets/trails/botify_pageworkers.yaml
# scripts/connectors/README.md
# scripts/connectors/botify.py
# scripts/connectors/confluence.py
# scripts/connectors/gmail.py
# scripts/connectors/gsc.py
# scripts/connectors/jira.py
# scripts/connectors/mcp.py
# scripts/connectors/mcp_warm.py
# scripts/connectors/sheets.py
# scripts/connectors/slack.py
# scripts/connectors/wallet.py
# scripts/bookmark_import.py
# scripts/boot_menu.py
# scripts/mother_cat.py
# scripts/sources_menu.py
# scripts/weblogin.py
# tools/scraper_tools.py
# --- START THIS DISCUSSION ---
# CONTEXT: 1
# ~/.config/pipulate/blogs.json # <-- CAUTION! Derived from ~/repos/nixos/blogs.nix
# scripts/articles/publishizer.py # <-- Orchestrates different publishing workflows per target blog.
# scripts/articles/common.py # <-- Self-explanatory
# scripts/articles/articleizer.py # <-- Transforms raw article.txt to formal Jekyll markdown format
# scripts/articles/editing_prompt.txt # <-- Forcing response into strict JSON data structure
# scripts/articles/sanitizer.py # <-- Scrubs PII
# scripts/articles/gsc_historical_fetch.py
# scripts/articles/contextualizer.py # <-- Builds JSON summaries of articles in `_posts/context/` called "Holographic Shards".
# scripts/articles/confluenceizer.py # <-- Idempotent Jekyll-to-Confluence corporate wiki
# scripts/articles/googledocizer.py # <-- Just added
# scripts/articles/build_knowledge_graph.py # <-- Topically load-balances site using hierarchical K-Means keyword clustering groups
# scripts/articles/generate_ai_context.py # <-- AIs WILL interrogate your repo. This gives epic context of article URLs for drill-down.
# scripts/articles/generate_hubs.py # <-- Uses just-produced link-graph data to generate each of the new hubs it suggests
# scripts/articles/generate_llms_txt.py # <-- Builds an llms.txt based on the auto-organized structure suggested here
# scripts/articles/generate_redirects.py # <-- Generates redirect map above hub-churn suggests is needed
# scripts/articles/sanitize_redirects.py # <-- Deals with follow-up meticulous pedantic detail required for a good Nginx redirect map
# /home/mike/repos/MikeLev.in/_layouts/default-old.html
# /home/mike/repos/trimnoir/_layouts/default.html
# /home/mike/repos/MikeLev.in/_layouts/post-old.html
# /home/mike/repos/trimnoir/_layouts/post.html
# /home/mike/repos/trimnoir/assets/main.css
# /home/mike/repos/trimnoir/_config.yml
# CONTEXT: 2
# scripts/articles/lsa.py
# scripts/articles/generate_post_nav.py
# /home/mike/repos/trimnoir/_layouts/post.html
# /home/mike/repos/trimnoir/assets/main.css
# ! rg -n "def get_holographic_article_data|sort_order|'permalink'|\"permalink\"" scripts/articles/lsa.py | head -20
# ! ls -la ~/repos/trimnoir/_data 2>/dev/null || echo "no _data dir yet"
# ! rg -n "site\.data|post_nav" ~/repos/trimnoir/_layouts ~/repos/trimnoir/_includes 2>/dev/null | head
# ! head -c 400 ~/repos/trimnoir/_data/post_nav.json 2>/dev/null || echo "no post_nav.json yet"
# ls /home/mike/repos/trimnoir/_data
# CONTEXT: 3
scripts/articles/generate_post_nav.py
/home/mike/repos/trimnoir/_layouts/post.html
! rg -n "__main__" scripts/articles/generate_post_nav.py || echo "NO MAIN GUARD"
! head -c 400 ~/repos/trimnoir/_data/post_nav.json 2>/dev/null || echo "no post_nav.json yet"
! .venv/bin/python -c "import json;d=json.load(open('/home/mike/repos/trimnoir/_data/post_nav.json'));k=[x for x in d if x!='_meta'];print(d['_meta'],len(k),k[0])" 2>/dev/null || echo "no json to parse yet"
! grep -m1 '^permalink:' "$(ls -t ~/repos/trimnoir/_posts/*.md | head -1)"
/home/mike/repos/trimnoir/_posts/2026-09-03-fixing-google-doc-titles-pipeline-repairs.md
/home/mike/repos/MikeLev.in/_layouts/post.html
3: Patches (the one change between the readings):
Blast Radius Check to establish bisection Left-hand Causal Boundary. It is a Popper-thing. Science.
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
(nix) pipulate $ patch
(nix) pipulate $ app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/articles/generate_post_nav.py'.
(nix) pipulate $ d
diff --git a/scripts/articles/generate_post_nav.py b/scripts/articles/generate_post_nav.py
index 05b0f721..09cc0672 100644
--- a/scripts/articles/generate_post_nav.py
+++ b/scripts/articles/generate_post_nav.py
@@ -91,3 +91,7 @@ def main():
newest = article_url(metadata[0])
print(f"✅ Wrote {output_file} ({len(nav)} entries).")
print(f" Newest: {newest} -> prev={nav[newest].get('prev', {}).get('url', '-')}")
+
+
+if __name__ == "__main__":
+ main()
(nix) pipulate $ m
📝 Committing: chore: Add main function entrypoint
[main f157e5b4] chore: Add main function entrypoint
1 file changed, 4 insertions(+)
(nix) pipulate $ git push
Enumerating objects: 9, done.
Counting objects: 100% (9/9), done.
Delta compression using up to 48 threads
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 467 bytes | 467.00 KiB/s, done.
Total 5 (delta 4), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (4/4), completed with 4 local objects.
To github.com:pipulate/pipulate.git
bd4daf1f..f157e5b4 main -> main
(nix) pipulate $
Ignition (what makes the patched code run before the AFTER reading – <F2>, nix develop, a re-ride – or none required):
(nix) pipulate $ .venv/bin/python scripts/articles/generate_post_nav.py -t 1
🎯 Target set via CLI: MikeLev.in (Public)
✅ Wrote /home/mike/repos/trimnoir/_data/post_nav.json (1441 entries).
Newest: /futureproof/fixing-google-doc-titles-pipeline-repairs/ -> prev=/futureproof/scientific-method-vibe-coding-replayable-workflows/
(nix) pipulate $
Projecting Navigation Maps via Python Automation
And we look at the repo.
(nix) _data $ git status
On branch main
Your branch is up to date with 'home/main'.
nothing to commit, working tree clean
(nix) _data $ git status
On branch main
Your branch is up to date with 'home/main'.
Untracked files:
(use "git add <file>..." to include in what will be committed)
./
nothing added to commit but untracked files present (use "git add" to track)
(nix) _data $ cd ..
(nix) trimnoir $ git status
On branch main
Your branch is up to date with 'home/main'.
Untracked files:
(use "git add <file>..." to include in what will be committed)
_data/
nothing added to commit but untracked files present (use "git add" to track)
(nix) trimnoir $ cd _data/
(nix) _data $ ls
post_nav.json
(nix) _data $ git add post_nav.json
(nix) _data $ git commit -am "Added post_nav.json"
[main 75eb5e054] Added post_nav.json
1 file changed, 15848 insertions(+)
create mode 100644 _data/post_nav.json
(nix) _data $
Okay, there we go. This is going to change on every generating and will have to be in the publishing pipeline.
4: Prompt (the next question, written before the AFTER reading is in):
The generator was missing its main guard, so the first ignition was a no-op; the guard is patched and the generator re-run. Check the AFTER receipts: does post_nav.json exist with total == 1441, and does the newest post’s permalink match its JSON key exactly? Then confirm in the browser that the newest post renders Previous (older) / Home at top and bottom. If the nav is missing on any post, paste that post’s page.url and the nearest JSON key so we can fix article_url(). After that: wire generate_post_nav.py into the target-1 pipeline in blogs.nix.
5: Deliverables: [external artifacts, updates to this system or ‘None this turn’]
Okay so I check the latest post:
(nix) pipulate $ posts 1
# 🎯 Target: MikeLev.in (Public) [Oldest First]
/home/mike/repos/trimnoir/_posts/2026-09-03-fixing-google-doc-titles-pipeline-repairs.md # [Idx: 1 | Order: 3 | Tokens: 8,726 | Bytes: 35,134]
(nix) pipulate $
And that URL is:
http://localhost:4001/futureproof/fixing-google-doc-titles-pipeline-repairs/
I don’t see any arrows there. Are you sure the layers don’t have to be layered
in as per /home/mike/repos/MikeLev.in/_layouts/post.html?
I might have shown you a bad one to get started with and made you figure out a
solution on your own to something I already solved in the past, a complex
problem I remember it wasn’t easy. Oh! I saw you thinking it through when I
submitted this (doing a Mulligan) and I touchd the default.html file to
force a full site re-render and you were right. The arrow is there now. Wow,
that is so slick! Nice. I also see what you mean about where I put the
flattening and did it inefficiently last time. Your solution is much better (and
your arrows look nicer), so it’s just a pipeline thing now maybe unless you
already took care of that too.
Fable 5.1 Medium: Context verified. The receipts close the straddle: post_nav.json exists with {'total': 1441} and 1441 keys, and the newest post’s frontmatter permalink: /futureproof/fixing-google-doc-titles-pipeline-repairs/ is byte-identical to the key format the generator emits (leading and trailing slash, no date prefix). No article_url() fix needed.
Why the arrows didn’t appear until you touched default.html
That’s a jekyll serve incremental-regeneration quirk, not a layering problem. Jekyll’s watcher noticed _data/post_nav.json changed and reloaded site.data, but incremental mode only re-renders pages whose own source or dependencies changed; it doesn’t know that every post’s layout now reads site.data.post_nav. Touching the layout invalidated every page, and the lookup did the rest. A full jekyll build (which is what production does) has no such shortcut, so this only bites the local preview. The old MikeLev.in/_layouts/post.html design didn’t have this problem for a different reason: it read site.posts directly, at O(n²) cost, which is exactly what we retired. No layering needed; the capture at the top of post.html and the two {{ post_nav }} emissions are the whole mechanism.
What’s left
Only the pipeline wiring, and it’s external: generate_post_nav.py writes straight into trimnoir/_data/, so once it’s in the target-1 pipeline list every publish regenerates the map before the site builds, and the changed JSON rides along in the trimnoir commit. Position in the list doesn’t matter (it depends only on _posts/, not on navgraph.json), so appending it last keeps it out of the way of the graph steps.
1. PROBES
rg -n "generate_post_nav" ~/repos/nixos/blogs.nix ~/.config/pipulate/blogs.json 2>/dev/null || echo "NOT WIRED"
git -C ~/repos/trimnoir log --oneline -3 -- _data/post_nav.json
The first is the straddle for this turn: BEFORE prints NOT WIRED; AFTER (post-edit, post-rebuild) shows the line in both the Nix source and the materialized JSON — if it appears in blogs.nix but not blogs.json, the rebuild hasn’t run. The second is the ledger showing the map is committed, which is what makes the arrows survive a deploy.
2. NEXT CONTEXT
scripts/articles/generate_post_nav.py
scripts/articles/publishizer.py
! rg -n "generate_post_nav" ~/repos/nixos/blogs.nix ~/.config/pipulate/blogs.json 2>/dev/null || echo "NOT WIRED"
! git -C ~/repos/trimnoir log --oneline -3 -- _data/post_nav.json
3. PATCHES
No repo patches required. The generator and layout are both proven by this compile’s receipts; the remaining step lives outside this repo. Ignition for that step is sudo nixos-rebuild switch (it’s what materializes blogs.json), then the first ! rg probe reads the result at call time.
4. PROMPT
generate_post_nav.py is wired into the target-1 pipeline in blogs.nix and the system rebuilt. Confirm the AFTER receipt shows the script in both blogs.nix and the materialized blogs.json. Then run one publish end to end and confirm the trimnoir commit includes a refreshed _data/post_nav.json alongside the new post. If it does, this ride is done and can be notarized.
5. EXTERNAL DELIVERABLES
~/repos/nixos/blogs.nix (manual edit, not patchable from here since the file isn’t in context): in the target-1 entry, append "generate_post_nav.py" to the pipeline list so it reads, in effect:
pipeline = [ "sanitizer.py" "contextualizer.py" "gsc_historical_fetch.py" "build_knowledge_graph.py" "generate_llms_txt.py" "link_injector.py" "generate_hubs.py" "generate_redirects.py" "sanitize_redirects.py" "generate_post_nav.py" ];
Then sudo nixos-rebuild switch, and verify with the first probe. Local-preview reminder for future template work: after a _data/ regeneration, touch ~/repos/trimnoir/_layouts/default.html forces the incremental server to re-render every post.
MikeLev.in:
Same commands, run twice, one change between them. Where the readings differ is what the change did; the diff in the middle is the receipt.
1: Probe (BEFORE: hand-run, nothing changed yet):
$ git status
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
(nix) pipulate $ rg -n "generate_post_nav" ~/repos/nixos/blogs.nix ~/.config/pipulate/blogs.json 2>/dev/null || echo "NOT WIRED"
git -C ~/repos/trimnoir log --oneline -3 -- _data/post_nav.json
NOT WIRED
75eb5e054 (HEAD -> main) Added post_nav.json
(nix) pipulate $
2: Context (AFTER: the same probes re-run by the compiler as ! lines):
# adhoc.txt _ _ _ to set context____ _ _ ___ ____ _ Simpson Couch Gag Here (explain anything to the audience you feel needs it explained)G
# / \ __| | | | | | ___ ___ / ___| | | |/ _ \| _ \| |
# ahe/ _ \ / _` | | |_| |/ _ \ / __| | | | |_| | | | | |_) | | Bringing blog arrows back
# ahc ___ \ (_| | | _ | (_) | (__ | |___| _ | |_| | __/|_| Take two
# /_/ \_\__,_| |_| |_|\___/ \___| \____|_| |_|\___/|_| (_) We've got blog arrows!
# Ad Hoc CHOP: The Not-Managed-by-Git Safe-for-Client-Data place
# OPTIONAL BUT BIG FOR FULL CONTEXT-WINDOW STORYTELLING
# ! python scripts/articles/lsa.py -t 1 --reverse --fmt dated-slugs # <-- The "Rolling Pin" that gives the 40K foot book-spine view of book-ore.
# GLOSSARY.md # <-- I think this glossary goes well with the book-ore spine to do world building.
# scripts/articles/lsa.py # <-- Useful for refining commands like `posts`, critical to Second Brain concept.
# ~/repos/nixos/autognome.py # <-- Letting the AIs really understand my environment (The Brave Little Tailor punches above Their Weight Class proving the dunning-kruger effect the gate-keeper's (lower-case) lament.)
# init.lua # <-- Daily driver hot-keys that overlap with aliases in flake.nix
# STILL BIG BUT LESS OPTIONAL (especially flake.nix)
# flake.nix # <-- THE ONE BIG THING TO INCLUDE Infrastructure as Code (IaC) tells LLM about your system down to the metal
# prompt_foo.py # <-- This very content-compiling system
# foo_files.py # <-- This is the router, evolving book outline and the things you pin-up to produced the recursive self-improvement loops
# TINY ILLUMINATING (OK to include every time / automatically = `apply.py`, `.gitignore`, `.gitattributes`)
# requirements.in # <-- All known dependencies and (necessary) version pinning. WORA gotcha's exposed.
# __init__.py # <-- Master versioning
# pyproject.toml # <-- The PyPI Packaging details
# OPTIONAL ACTUATORS (cheap and good to include to expand the AI's capabilities)
# cli.py # <-- Catch-all actuator for PyPI envs, Python anchoring, MCP tool-call (plus alternatives) and **kwargs like wrapping for CLI
# scripts/xp.py # <-- Transforms host OS copy-paste buffer player-piano music into context-payload.
# scripts/ai.py # <-- How I constantly use local AI to write git commit messages with `m` alias.
# scripts/crawl.py # <-- Feel free to ask for something to be crawled and included in the next turn.
# scripts/weblogin.py # <-- Lets the user "warm up" the cache for their web logins at their leisure on a profile that persists.
# MISCELLANEOUS (rare to include but sometimes critical)
# scripts/foo_cartridge.py # Needs description
# scripts/foo_replay.py # Needs description
# release.py # <-- How everything ends up where it does (GitHub, PyPI, etc.)
# imports/voice_synthesis.py # <-- The wand can talk to you
# imports/ascii_displays.py # <-- Where all the ASCII Art lives
# scripts/release/version_sync.py # <-- Needs to be wrapped into release.py and eliminated, I think.
# --- Under this line is were you paste what the AI gives you ---
# --- We call it context but it's really just the right-hand ---
# --- blast-radius of the "probes" to make this all science. ---
# --- END `adhoc.txt` TEMPLATE ---
# STICKBUG & MOTHER CAT KATA (WORKING ON THE CHAPTER)
# assets/installer/mck.sh
# assets/installer/replay.sh
# scripts/walk.py
# scripts/walk_cartridge.py
# scripts/walk_compile.py
# assets/trails/first_context.yaml
# assets/trails/practice.yaml
# assets/trails/public_walk.yaml
# assets/trails/botify_pageworkers.yaml
# scripts/connectors/README.md
# scripts/connectors/botify.py
# scripts/connectors/confluence.py
# scripts/connectors/gmail.py
# scripts/connectors/gsc.py
# scripts/connectors/jira.py
# scripts/connectors/mcp.py
# scripts/connectors/mcp_warm.py
# scripts/connectors/sheets.py
# scripts/connectors/slack.py
# scripts/connectors/wallet.py
# scripts/bookmark_import.py
# scripts/boot_menu.py
# scripts/mother_cat.py
# scripts/sources_menu.py
# scripts/weblogin.py
# tools/scraper_tools.py
# --- START THIS DISCUSSION ---
# CONTEXT: 1
# ~/.config/pipulate/blogs.json # <-- CAUTION! Derived from ~/repos/nixos/blogs.nix
# scripts/articles/publishizer.py # <-- Orchestrates different publishing workflows per target blog.
# scripts/articles/common.py # <-- Self-explanatory
# scripts/articles/articleizer.py # <-- Transforms raw article.txt to formal Jekyll markdown format
# scripts/articles/editing_prompt.txt # <-- Forcing response into strict JSON data structure
# scripts/articles/sanitizer.py # <-- Scrubs PII
# scripts/articles/gsc_historical_fetch.py
# scripts/articles/contextualizer.py # <-- Builds JSON summaries of articles in `_posts/context/` called "Holographic Shards".
# scripts/articles/confluenceizer.py # <-- Idempotent Jekyll-to-Confluence corporate wiki
# scripts/articles/googledocizer.py # <-- Just added
# scripts/articles/build_knowledge_graph.py # <-- Topically load-balances site using hierarchical K-Means keyword clustering groups
# scripts/articles/generate_ai_context.py # <-- AIs WILL interrogate your repo. This gives epic context of article URLs for drill-down.
# scripts/articles/generate_hubs.py # <-- Uses just-produced link-graph data to generate each of the new hubs it suggests
# scripts/articles/generate_llms_txt.py # <-- Builds an llms.txt based on the auto-organized structure suggested here
# scripts/articles/generate_redirects.py # <-- Generates redirect map above hub-churn suggests is needed
# scripts/articles/sanitize_redirects.py # <-- Deals with follow-up meticulous pedantic detail required for a good Nginx redirect map
# /home/mike/repos/MikeLev.in/_layouts/default-old.html
# /home/mike/repos/trimnoir/_layouts/default.html
# /home/mike/repos/MikeLev.in/_layouts/post-old.html
# /home/mike/repos/trimnoir/_layouts/post.html
# /home/mike/repos/trimnoir/assets/main.css
# /home/mike/repos/trimnoir/_config.yml
# CONTEXT: 2
# scripts/articles/lsa.py
# scripts/articles/generate_post_nav.py
# /home/mike/repos/trimnoir/_layouts/post.html
# /home/mike/repos/trimnoir/assets/main.css
# ! rg -n "def get_holographic_article_data|sort_order|'permalink'|\"permalink\"" scripts/articles/lsa.py | head -20
# ! ls -la ~/repos/trimnoir/_data 2>/dev/null || echo "no _data dir yet"
# ! rg -n "site\.data|post_nav" ~/repos/trimnoir/_layouts ~/repos/trimnoir/_includes 2>/dev/null | head
# ! head -c 400 ~/repos/trimnoir/_data/post_nav.json 2>/dev/null || echo "no post_nav.json yet"
# ls /home/mike/repos/trimnoir/_data
# CONTEXT: 3
# scripts/articles/generate_post_nav.py
# /home/mike/repos/trimnoir/_layouts/post.html
# ! rg -n "__main__" scripts/articles/generate_post_nav.py || echo "NO MAIN GUARD"
# ! head -c 400 ~/repos/trimnoir/_data/post_nav.json 2>/dev/null || echo "no post_nav.json yet"
# ! .venv/bin/python -c "import json;d=json.load(open('/home/mike/repos/trimnoir/_data/post_nav.json'));k=[x for x in d if x!='_meta'];print(d['_meta'],len(k),k[0])" 2>/dev/null || echo "no json to parse yet"
# ! grep -m1 '^permalink:' "$(ls -t ~/repos/trimnoir/_posts/*.md | head -1)"
# /home/mike/repos/trimnoir/_posts/2026-09-03-fixing-google-doc-titles-pipeline-repairs.md
# /home/mike/repos/MikeLev.in/_layouts/post.html
# CONTEXT: 4
scripts/articles/generate_post_nav.py
scripts/articles/publishizer.py
! rg -n "generate_post_nav" ~/repos/nixos/blogs.nix ~/.config/pipulate/blogs.json 2>/dev/null || echo "NOT WIRED"
! git -C ~/repos/trimnoir log --oneline -3 -- _data/post_nav.json
3: Patches (the one change between the readings): None
Ignition (what makes the patched code run before the AFTER reading – <F2>, nix develop, a re-ride – or none required):
An edit to a file:
(sys) nixos $ git --no-pager diff -- blogs.nix
diff --git a/blogs.nix b/blogs.nix
index f7e685b..290354b 100644
--- a/blogs.nix
+++ b/blogs.nix
@@ -37,6 +37,7 @@ let
"generate_hubs.py"
"generate_redirects.py"
"sanitize_redirects.py"
+ "generate_post_nav.py"
];
};
"2" = {
(sys) nixos $
And because all my non-secret configuration files for things (in general, not just these blogs) is kept in Nix configuration files for instantiating my system on different hardware purposes, I do a nix rebuild of my system which is a lot faster than it sounds to activate this.
(sys) nixos $ n
building Nix...
building the system configuration...
unpacking 'https://github.com/NixOS/nixpkgs/archive/nixos-unstable.tar.gz' into the Git cache...
this derivation will be built:
/nix/store/mh56xcmxnkk60zfh0ds2mrhrrkmw1wz4-nixos-system-nixos-25.05.813814.ac62194c3917.drv
building '/nix/store/mh56xcmxnkk60zfh0ds2mrhrrkmw1wz4-nixos-system-nixos-25.05.813814.ac62194c3917.drv'...
activating the configuration...
setting up /etc...
reloading user units for mike...
restarting sysinit-reactivation.target
the following new units were started: NetworkManager-dispatcher.service
Done. The new configuration is /nix/store/4i9yghrcyszfk3mrlg57y8bni96gidgm-nixos-system-nixos-25.05.813814.ac62194c3917
(sys) nixos $
There, now it’s in the publishing pipeline and if I do a forced publish I should
have arrows on the published public blog. I now also realize why the Fable
wanted to see my lsa.py program for lack of showing it the actual Liquid
Template arrow implementation from my old site; it had to use the same handling
of YAML frontmatter to order the multiple posts from the same day as the arrows
themselves so that was a perfectly fine place to life the reasoning from.
4: Prompt (the next question, written before the AFTER reading is in):
generate_post_nav.py is wired into the target-1 pipeline in blogs.nix and the system rebuilt. Confirm the AFTER receipt shows the script in both blogs.nix and the materialized blogs.json. Then run one publish end to end and confirm the trimnoir commit includes a refreshed _data/post_nav.json alongside the new post. If it does, this ride is done and can be notarized.
5: Deliverables: [external artifacts, updates to this system or ‘None this turn’]
Alright, the arrows are on the public website. This is done.
Hop off the ride. This ride’s stated goal is reached — dismount. This is the NOTARY BEAT: the ride ends here, is witnessed here, and is sealed here. Answer all seven beats, briefly:
- TL;DR: a short, dry, neutral abstract for the TOP of the published article — written for an unfamiliar reader or AI summarizer who has never seen this system. No hype, no insider handles unexplained.
- VERIFY: restate the goal from the top of this article and confirm (or deny) it was met, citing THIS compile’s receipts, not memory. Name any ignition this ride required that never fired – an AFTER tap taken without one is a stale BEFORE wearing the AFTER’s label.
- BANK: name everything that graduates — rule, earmark, todo, pin — as exact paste-ready lines, plus the exact lines to delete.
- DANGLING: what carries forward unbanked? One line each, no essays.
- SEED: the adhoc.txt lines (and TODO_SLUGS if narrative context is needed) for the next ride’s first compile.
- CLOSING: a closing summary for the BOTTOM of the article — the final take-away, tied to the book’s larger arc where it fits naturally, never forced. Storytelling over inventory.
- NOTARIZE: name the sealed artifact of record — the newest
hash-stamped cartridge (foo-
-NN.zip) — and state that its CRC-sealed, byte-reproducible archive is the witnessed receipt of this ride. The seal is the signature; the archive is the deed.
FINALITY: after beat 6, this discussion is CLOSED. Emit NO five-car train, NO probes, NO patches, NO next-turn prompt beyond the SEED lines in beat 4. Any reader or model encountering this article later should treat it as a finished, notarized document — an archive entry, not an open thread.
Fable 5.1 Medium: Context verified. Two live receipts in this compile, and together they are the AFTER reading this ride was built to take.
1. VERIFY
Goal: put the previous/next arrows back into the design. Met. This compile’s rg receipt shows generate_post_nav.py at line 40 of blogs.nix and as the last entry of target 1’s pipeline array in the materialized blogs.json — so the nixos-rebuild ignition fired and landed. The git log receipt shows 75eb5e054 Added post_nav.json in trimnoir, so the map survives a deploy. Your hand-run BEFORE of the identical rg printed NOT WIRED; the instrument moved where predicted. Arrows on the public site are your eyewitness claim, consistent with every receipt but not a receipt in this payload — n=1 by eye, labeled as such. Prior ignitions all fired: the generator re-run after the guard patch printed 1441 entries, and the local touch default.html was the preview’s re-render trigger, not production’s. No ignition is outstanding.
2. BANK
Receipt line, top of the RECEIPTS block in foo_files.py, delete the bottom line to hold 20:
# 2026-09-03 blog arrows restored: generate_post_nav.py -> trimnoir/_data/post_nav.json (1441 keys), post.html does O(1) site.data lookup; wired into blogs.nix target-1 pipeline (rebuild receipt: present in blogs.nix:40 AND materialized blogs.json). Two convictions: WRITE_FILE body shipped without __main__ guard (silent no-op, caught by 🎯-line absence); jekyll serve incremental does not re-render posts on _data change (touch a layout).
Rules, into the constitution block:
# THE ENTRY-POINT CHECK (banked 2026-09-03). Every WRITE_FILE of a runnable .py script MUST end with the __main__ guard, and the first probe after ignition MUST be one that can only print if main() actually ran (a target line, a wrote-N line). A script that imports cleanly and exits 0 is indistinguishable from success by exit code alone.
# THE DATA-FILE RE-RENDER RULE (banked 2026-09-03). When a layout gains a site.data.* lookup, jekyll serve's incremental mode will not re-render existing pages when only the data file changes. Local preview needs `touch _layouts/default.html`; production `jekyll build` needs nothing.
Delete: the bare ls /home/mike/repos/trimnoir/_data line in adhoc.txt (never prefixed, never ran; the compiler flagged it).
3. DANGLING
blogs.nixpipeline entry is last in the list; fine today, but iflink_injector.pyever rewrites permalinks,generate_post_nav.pymust stay after it.post_nav.jsoncarries apositionfield nobody reads yet — the old design’s “Post #N of M” counter is a one-line Liquid add if wanted.- Old
MikeLev.in/_layouts/post.htmlstill holds the related-articles block; that feature was not ported and is a separate ride. rel="prev"/rel="next"are on the anchors but not as<link>tags in<head>; small SEO nicety, unbanked.- The
common.add_target_argumentlegacy alias is still used by half the pipeline scripts; harmless, unrelated, noted.
Wiring the Artifact into the Production Pipeline
4. SEED
# CONTEXT: 5 -- optional follow-ups from the arrows ride: post counter and related-articles port
/home/mike/repos/trimnoir/_layouts/post.html
/home/mike/repos/MikeLev.in/_layouts/post.html
scripts/articles/generate_post_nav.py
! .venv/bin/python -c "import json;d=json.load(open('/home/mike/repos/trimnoir/_data/post_nav.json'));print(d['_meta'])"
5. CLOSING
The old arrows were never really gone; they were sitting in a layout file in a retired repo, working perfectly and costing two million Liquid iterations a build, which is why they got left behind when the site went lean. Putting them back meant refusing to put them back the same way. The trick, if there is one, is that the hard part — deciding which post comes after which when three land on the same day — had already been solved once, in lsa.py, for a different consumer. The arrows just needed to ask the same oracle and write the answer down where Jekyll could read it without thinking. What the ride actually cost was two silences: a script that ran and did nothing, and a server that read the new data and rendered nothing. Neither would have been found by reading code. Both were found by running the same command twice with one change between. That’s the whole book, really: the receipt in the middle is the only thing that tells the silence apart from the success.
6. NOTARIZE
The artifact of record is the newest foo-<hash8>-NN.zip in the repo root, rotated by this compile; its hash is not in this payload and I won’t invent it. Its CRC-sealed, byte-reproducible archive holds generate_post_nav.py with its entry point, publishizer.py unchanged as the consumer of the now-wired pipeline, and the two receipts proving the wiring reached both the Nix source and the materialized config. The seal is the signature; the archive is the deed; this discussion is closed.
Book Analysis
Ai Editorial Take
What is most fascinating about this entry is how human intuition about software speed is often derailed by hidden framework caching behaviors. The realization that an incremental server needs a manual layout touch to reflect data changes perfectly captures the friction of modern local-first tooling.
🐦 X.com Promo Tweet
Tired of quadratic build times on large static sites? Learn how to replace sluggish Liquid loops with an O(1) Python data projector for replayable blog navigation. https://mikelev.in/futureproof/restoring-replayable-navigation-data-driven-layouts/ #WebDev #Python #Jekyll
Title Brainstorm
- Title Option: Restoring Replayable Navigation: Moving from Quadratic Overhead to O(1) Data-Driven Layouts
- Filename:
restoring-replayable-navigation-data-driven-layouts - Rationale: Focuses on performance and architectural clarity without using forbidden terms.
- Filename:
- Title Option: Building a Replayable Navigation Pipeline for Large Static Sites
- Filename:
building-replayable-navigation-pipeline-static-sites - Rationale: Emphasizes the engineering pipeline and reproducibility aspects.
- Filename:
- Title Option: O(1) Site Navigation: Engineering Checkable Metadata Workflows
- Filename:
o1-site-navigation-engineering-checkable-metadata-workflows - Rationale: Highlights the efficiency gain and metadata synchronization.
- Filename:
Content Potential And Polish
- Core Strengths:
- Clear demonstration of solving quadratic performance issues in static site generators.
- Transparent documentation of debugging silent script failures via bisection thinking.
- Excellent integration of automated build pipelines into infrastructure definitions.
- Suggestions For Polish:
- Ensure the distinction between local preview incremental caching and production builds is highlighted for clarity.
- Keep the focus on the data projector pattern as a reusable architectural pattern.
Next Step Prompts
- Explore how to integrate automated link validation tests directly into the publish pipeline.
- Investigate how to compute related-articles metadata using similar O(1) JSON lookup projections.