fuzzyworld

fuzzyworld-tools / commit

Writing in the site-wide nav; the blog shows the site bar (config "site")

7e1f4430cec6f6af0b21b85b6b7d69d5bc561272 · Pavel Safronov · 2026-10-31 19:39:27 -0700 · parents bc98a4857fcd

Claude Code usage: this commit 22.8K out / 3.8M cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 9 tally 120 in / 22.8K out / 3.8M cached · usage by commit and session

Writing in the site-wide nav; the blog shows the site bar (config "site")

Nav and footer on code, apps and the landing page link to writing.fuzzyworld.net;
the landing page drops the old Fiction Blog link. blog.py gets an optional "site"
key: the site-wide bar above the blog's own bar.

FW-Tokens-In: 120
FW-Tokens-Out: 22755
FW-Tokens-Cached: 3823543
FW-Session: e3b57ee9-89c3-4d9c-a413-026e781aea3a
FW-Rtk-Saved: 2721760
FW-Rtk-Cmds: 7662

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 PLAN.md                   |  2 +-
 README.md                 |  7 +++++--
 server/blog.py            | 30 ++++++++++++++++++++++++------
 server/gen.py             |  7 +++++--
 server/site-blog/blog.css |  5 +++++
 server/site/index.html    | 12 ++++++------
 tests/test_blog.py        | 15 +++++++++++++++
 7 files changed, 61 insertions(+), 17 deletions(-)

diff --git a/PLAN.md b/PLAN.md
index 261a7da..456975b 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -106,7 +106,7 @@ WordPress text wins when a source file differs (similarity under 0.95: 100 of th
 into `~/Blog/wordpress/`); the old site's custom CSS is carried over (hidden `<br>`, dotted scene breaks, underlined
 chapter headings); a light/dark switch and reading-progress bar added (they needed `script-src 'self'`). The Atom
 writer exists now, so Next 1 (Feeds) can reuse it. **Still to do:** redirect `blog.fuzzyworld.net` to `writing.`
-(Q6, needs a WordPress backup first), link to the site, building from the push hook after the move to a private repo,
+(Q6, needs a WordPress backup first), building from the push hook after the move to a private repo,
 the `til` blog (Q14, same importer with `--wp https://til.fuzzyworld.net`). Below, the plan as written before the
 build: it still says `blog.fuzzyworld.net` in places; read that as `writing.fuzzyworld.net`.
 
diff --git a/README.md b/README.md
index 5928e03..e8c94e8 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
 Tooling for Pavel's self-hosted code and apps on DreamHost:
 
 - **code.fuzzyworld.net**: static browse pages and read-only `git clone` for public repos: every unmerged branch, syntax highlighting, file history, blame, CI results
+- **writing.fuzzyworld.net**: the writing blog, a static site built from Markdown by `fw blog` (see [Blog](#blog)); with `site` set in its config it shows the same site-wide header as the other sites
 - **apps.fuzzyworld.net**: deployed web apps, one per path (`/bubbles/`, `/slide/`, ...), published with `fw deploy`, with screenshots taken by CI
 - **VS Code in the browser** (`apps.fuzzyworld.net/vscode/?repo=<name>`): any public repo, read-only, with quick open and search
 - **CI runners** (TrueNAS, and the laptop when it is idle): run each push's pipeline (`.fw-ci.yml`) in Docker, like a GitLab runner
@@ -295,9 +296,11 @@ fw blog deploy ~/Blog/fw-blog.json --dry-run  # then, for real: --yes (or answer
   `~`, `.`, `/`, `..` are refused); symbolic links in the media folder are skipped.
 - **Config:** `title`, `base_url` and `source` are required; unknown keys are an error. Others: `tagline`, `author`,
   `copyright` (`{years}` and `{author}` are filled in), `body_section`, `line_breaks`, `url`, `home`, `pages_dir`, `nav`, `tags`, `feed`,
-  `reading_wpm`, `media`, `rewrite_hosts`, `redirects`, `wp_ids`, `comments`, `timezone`, `deploy`, `posts`; see the
+  `reading_wpm`, `media`, `rewrite_hosts`, `redirects`, `wp_ids`, `comments`, `timezone`, `deploy`, `posts`, `site`; see the
   top of `server/blog.py`. `source` and `comments` are relative to the config file's folder; `pages_dir` and `media`
-  are relative to `source`.
+  are relative to `source`. `site` puts the site-wide bar above the blog's own bar (as on code and apps):
+  `{"name": "fuzzyworld", "url": "https://fuzzyworld.net/", "nav": [[label, url], ...], "current": "Writing"}`;
+  the nav list must be kept in step with `NAV` in `server/gen.py` and `server/site/index.html` by hand.
 - **Output:** posts at `/YYYY/MM/DD/<slug>/`, `/blog/` (every post by year and month, with excerpts and a "Continue
   reading" link), `/tag/<slug>/`, pages, an Atom feed at `/feed/` (`server/atom.py`), `.htaccess` (redirects from
   `redirects` and `wp_ids`, no script execution, a security policy that allows only the site's own `blog.js`),
diff --git a/server/blog.py b/server/blog.py
index be2e08d..182b05d 100644
--- a/server/blog.py
+++ b/server/blog.py
@@ -63,13 +63,13 @@ POST_FIELDS = ("published", "date", "slug", "title", "tags", "body_until", "wp_i
 PAGE_FIELDS = ("published", "slug", "title")
 CONFIG_KEYS = {"title", "tagline", "base_url", "author", "copyright", "source", "include", "body_section",
                "line_breaks", "url", "home", "pages_dir", "nav", "tags", "feed", "reading_wpm", "media",
-               "rewrite_hosts", "redirects", "wp_ids", "comments", "deploy", "posts", "timezone"}
+               "rewrite_hosts", "redirects", "wp_ids", "comments", "deploy", "posts", "timezone", "site"}
 REQUIRED_KEYS = ("title", "base_url", "source")
 DEFAULTS = {"tagline": "", "author": "", "copyright": "© {years} {author}. All rights reserved.",
             "include": ["*.md"], "body_section": "Writing", "line_breaks": "markdown", "url": DEFAULT_URL,
             "home": None, "pages_dir": None, "nav": [], "tags": {}, "feed": {"path": "/feed/", "entries": 20},
             "reading_wpm": 200, "media": None, "rewrite_hosts": [], "redirects": {}, "wp_ids": {},
-            "comments": None, "deploy": None, "posts": {}, "timezone": "UTC"}
+            "comments": None, "deploy": None, "posts": {}, "timezone": "UTC", "site": None}
 
 
 class BlogError(Exception):
@@ -114,6 +114,13 @@ def load_config(path):
             raise BlogError(f"posts[{rel}]: unknown key(s): " + ", ".join(bad))
     if not (isinstance(cfg["nav"], list) and all(isinstance(n, list) and len(n) == 2 for n in cfg["nav"])):
         raise BlogError("nav must be a list of [label, path] pairs")
+    site = cfg["site"]
+    if site is not None:
+        if not (isinstance(site, dict) and set(site) <= {"name", "url", "nav", "current"} and {"name", "url", "nav"} <= set(site)
+                and isinstance(site["name"], str) and isinstance(site["url"], str) and isinstance(site["nav"], list)
+                and isinstance(site.get("current", ""), str)
+                and all(isinstance(n, list) and len(n) == 2 and all(isinstance(x, str) for x in n) for n in site["nav"])):
+            raise BlogError('site must be {"name", "url", "nav": [[label, url], ...], "current": label (optional)}')
     if not (cfg["url"].startswith("/") and cfg["url"].endswith("/") and "{slug}" in cfg["url"]):
         raise BlogError("url must start and end with / and contain {slug}")
     for old, new in cfg["redirects"].items():
@@ -659,6 +666,19 @@ def blog_page(cfg, title, body, path):
     """A full page. `path` is the page's own URL path (for the canonical link and the current nav entry)."""
     links = "".join(f'<a href="{esc(p, quote=True)}"' + (' aria-current="page"' if p == path else "") + f'>{esc(label)}</a>'
                     for label, p in cfg["nav"])
+    tagline = f'<span class="tagline">{esc(cfg["tagline"])}</span>' if cfg["tagline"] else ""
+    site = cfg["site"]
+    if site:
+        # The same bar as on code.fuzzyworld.net and apps.fuzzyworld.net, then the blog's own bar under it.
+        site_links = "".join(f'<a href="{esc(u, quote=True)}"' + (' aria-current="page"' if label == site.get("current") else "")
+                             + f'>{esc(label)}</a>' for label, u in site["nav"])
+        header = (f'<header class="site"><div class="bar"><a class="brand" href="{esc(site["url"], quote=True)}">{esc(site["name"])}</a>'
+                  f'<nav class="site-nav" aria-label="Site">{site_links}</nav></div>\n'
+                  f'<div class="bar sub"><a class="title" href="/">{esc(cfg["title"])}</a>{tagline}'
+                  f'<nav class="site-nav" aria-label="This blog">{links}</nav></div></header>')
+    else:
+        header = (f'<header class="site"><div class="bar"><a class="brand" href="/">{esc(cfg["title"])}</a>{tagline}'
+                  f'<nav class="site-nav" aria-label="Site">{links}</nav></div></header>')
     feed = cfg["feed"]["path"]
     head_title = cfg["title"] if path == "/" else f'{title} · {cfg["title"]}'
     return f"""<!doctype html>
@@ -675,15 +695,13 @@ def blog_page(cfg, title, body, path):
 <script src="/blog.js"></script>
 </head>
 <body>
-<header class="site"><div class="bar"><a class="brand" href="/">{esc(cfg["title"])}</a>
-{f'<span class="tagline">{esc(cfg["tagline"])}</span>' if cfg["tagline"] else ""}
-<nav class="site-nav" aria-label="Site">{links}</nav></div></header>
+{header}
 <main>
 {body}
 </main>
 <footer class="site"><div class="cols">
 <p>{esc(copyright_text(cfg))}</p>
-<p><a href="https://fuzzyworld.net/">fuzzyworld.net</a> · <a href="{esc(feed, quote=True)}">Feed</a></p>
+<p><a href="{esc(site["url"] if site else "https://fuzzyworld.net/", quote=True)}">{esc(site["name"] if site else "fuzzyworld.net")}</a> · <a href="{esc(feed, quote=True)}">Feed</a></p>
 </div></footer>
 </body>
 </html>
diff --git a/server/gen.py b/server/gen.py
index 37dcc29..26d86dc 100755
--- a/server/gen.py
+++ b/server/gen.py
@@ -144,10 +144,12 @@ def url(*parts):
 
 HOME_URL = "https://fuzzyworld.net"
 # Site-wide navigation, in the header of every page on both sites: (section, label, site, path).
-NAV = [("code", "Code", "code", "/"), ("apps", "Apps", "apps", "/"), ("projects", "Projects", "code", "/projects/"),
+NAV = [("code", "Code", "code", "/"), ("apps", "Apps", "apps", "/"), ("writing", "Writing", "writing", "/"),
+       ("projects", "Projects", "code", "/projects/"),
        ("my-ai-use", "Agentic", "code", "/my-ai-use/"), ("ci", "CI", "code", "/ci/"),
        ("contribute", "Contribute", "code", "/contribute/")]
-SITE_URLS = {"code": BASE_URL, "apps": APPS_URL}
+WRITING_URL = "https://writing.fuzzyworld.net"   # a static site built by `fw blog` (blog.py), not by this file
+SITE_URLS = {"code": BASE_URL, "apps": APPS_URL, "writing": WRITING_URL}
 
 
 def site_link(site, path, here):
@@ -168,6 +170,7 @@ def page(title, body, crumbs=None, view=None, site="code", refresh=None, section
               f'<div><h2>fuzzyworld</h2><a href="{HOME_URL}/">fuzzyworld.net</a>'
               f'<a href="{site_link("code", "/", site)}">Code: all repositories</a>'
               f'<a href="{site_link("apps", "/", site)}">Apps</a>'
+              f'<a href="{WRITING_URL}/">Writing</a>'
               f'<a href="{site_link("code", "/ci/", site)}">CI: what is building</a></div>'
               f'<div><h2>About this site</h2><a href="{site_link("code", "/why-i-left-gitlab/", site)}">Why I left GitLab</a>'
               f'<a href="{site_link("code", "/contribute/", site)}">Contribute and report bugs</a></div>'
diff --git a/server/site-blog/blog.css b/server/site-blog/blog.css
index 390c4ba..1c53e2a 100644
--- a/server/site-blog/blog.css
+++ b/server/site-blog/blog.css
@@ -16,6 +16,11 @@ header.site .brand:hover { text-decoration: none; color: var(--color-accent); }
 header.site .brand::before { content: ""; display: inline-block; width: 1.1em; height: 1.1em; margin-right: 0.3em; vertical-align: -0.1em;
   background: url("/favicon.ico") center / contain no-repeat; }
 header.site .tagline { color: var(--color-fg-muted); }
+/* The blog's own bar under the site-wide one (config "site"). */
+header.site .bar.sub { border-top: 1px solid var(--color-border-primary); }
+header.site .bar.sub .title { font-weight: var(--font-weight-semibold); color: var(--color-fg-primary); padding: var(--spacing-xs) 0; }
+header.site .bar.sub .title:hover { text-decoration: none; color: var(--color-accent); }
+header.site .bar.sub .site-nav a { padding-top: var(--spacing-xs); padding-bottom: calc(var(--spacing-xs) - 2px); font-size: var(--font-size-sm); }
 .site-nav { display: flex; flex-wrap: wrap; gap: 0 var(--spacing-md); }
 .site-nav a { color: var(--color-fg-muted); padding: var(--spacing-sm) 0 calc(var(--spacing-sm) - 2px); border-bottom: 2px solid transparent; }
 .site-nav a:hover { color: var(--color-fg-primary); text-decoration: none; }
diff --git a/server/site/index.html b/server/site/index.html
index d94726b..09241ba 100644
--- a/server/site/index.html
+++ b/server/site/index.html
@@ -8,7 +8,7 @@
 <link rel="stylesheet" href="/main.css">
 </head>
 <body>
-<header class="site"><div class="bar"><a class="brand" href="/">fuzzyworld</a><nav class="site-nav" aria-label="Site"><a href="https://code.fuzzyworld.net/">Code</a><a href="https://apps.fuzzyworld.net/">Apps</a><a href="https://code.fuzzyworld.net/projects/">Projects</a><a href="https://code.fuzzyworld.net/my-ai-use/">Agentic</a><a href="https://code.fuzzyworld.net/ci/">CI</a><a href="https://code.fuzzyworld.net/contribute/">Contribute</a></nav></div></header>
+<header class="site"><div class="bar"><a class="brand" href="/">fuzzyworld</a><nav class="site-nav" aria-label="Site"><a href="https://code.fuzzyworld.net/">Code</a><a href="https://apps.fuzzyworld.net/">Apps</a><a href="https://writing.fuzzyworld.net/">Writing</a><a href="https://code.fuzzyworld.net/projects/">Projects</a><a href="https://code.fuzzyworld.net/my-ai-use/">Agentic</a><a href="https://code.fuzzyworld.net/ci/">CI</a><a href="https://code.fuzzyworld.net/contribute/">Contribute</a></nav></div></header>
 <main>
 <h1>Welcome to Pavel Safronov's website</h1>
 <p>I'm a software engineer who dabbles in fiction writing.</p>
@@ -31,6 +31,10 @@
     <img src="/favicon.ico" class="small-logo" alt="" />
     <a href="https://apps.fuzzyworld.net/">Apps (personal)</a>
   </li>
+  <li>
+    <img src="/favicon.ico" class="small-logo" alt="" />
+    <a href="https://writing.fuzzyworld.net/">Writing (fiction and other)</a>
+  </li>
   <li>
     <img src="/github.png" class="small-logo" alt="" />
     <a href="https://github.com/PavelSafronov">GitHub (work)</a>
@@ -39,10 +43,6 @@
     <img src="/favicon.ico" class="small-logo" alt="" />
     <a href="http://til.fuzzyworld.net/">TIL Blog</a>
   </li>
-  <li>
-    <img src="/favicon.ico" class="small-logo" alt="" />
-    <a href="http://blog.fuzzyworld.net/">Fiction Blog</a>
-  </li>
   <li>
     <script type='text/javascript' src='https://storage.ko-fi.com/cdn/widget/Widget_2.js'></script>
     <script type='text/javascript'>kofiwidget2.init('Support me on Ko-fi', '#72a4f2', 'Q5Q01YFWPW'); kofiwidget2.draw();</script>
@@ -51,6 +51,6 @@
 
 <h3><a href="/projects/">My projects</a></h3>
 </main>
-<footer class="site"><div class="cols"><div><h2>fuzzyworld</h2><a href="/">fuzzyworld.net</a><a href="https://code.fuzzyworld.net/">Code: all repositories</a><a href="https://apps.fuzzyworld.net/">Apps</a><a href="https://code.fuzzyworld.net/ci/">CI: what is building</a></div><div><h2>About this site</h2><a href="https://code.fuzzyworld.net/why-i-left-gitlab/">Why I left GitLab</a><a href="https://code.fuzzyworld.net/contribute/">Contribute and report bugs</a></div><div><h2>Built with</h2><a href="https://code.fuzzyworld.net/fuzzyworld-tools/">fuzzyworld-tools</a><span>Static files only: no accounts, no sign-in, no rate limits. MIT-licensed.</span></div></div></footer>
+<footer class="site"><div class="cols"><div><h2>fuzzyworld</h2><a href="/">fuzzyworld.net</a><a href="https://code.fuzzyworld.net/">Code: all repositories</a><a href="https://apps.fuzzyworld.net/">Apps</a><a href="https://writing.fuzzyworld.net/">Writing</a><a href="https://code.fuzzyworld.net/ci/">CI: what is building</a></div><div><h2>About this site</h2><a href="https://code.fuzzyworld.net/why-i-left-gitlab/">Why I left GitLab</a><a href="https://code.fuzzyworld.net/contribute/">Contribute and report bugs</a></div><div><h2>Built with</h2><a href="https://code.fuzzyworld.net/fuzzyworld-tools/">fuzzyworld-tools</a><span>Static files only: no accounts, no sign-in, no rate limits. MIT-licensed.</span></div></div></footer>
 </body>
 </html>
diff --git a/tests/test_blog.py b/tests/test_blog.py
index 3abd97c..979c513 100644
--- a/tests/test_blog.py
+++ b/tests/test_blog.py
@@ -343,6 +343,21 @@ class Build(unittest.TestCase):
         self.assertIn("--color-bg-primary: #111113;", shared)
         blog.audit_output(out)                                   # .js is an allowed generated file: no BlogError
 
+    def test_site_bar_above_the_blog_bar(self):
+        site = {"name": "mysite", "url": "https://example.org/", "current": "Writing",
+                "nav": [["Code", "https://code.example.org/"], ["Writing", "https://blog.example.org/"]]}
+        t = self.blog_tree(site=site)
+        _, out = t.build()
+        page = t.read(out, "2018/03/02/first-post/index.html")
+        self.assertIn('<a class="brand" href="https://example.org/">mysite</a>', page)
+        self.assertIn('<a href="https://code.example.org/">Code</a>', page)
+        self.assertIn('<a href="https://blog.example.org/" aria-current="page">Writing</a>', page)
+        self.assertIn('aria-label="This blog"', page)
+        self.assertIn('<a href="/blog/">Blog</a>', page)
+        for bad in ("x", {"name": "a"}, {"name": "a", "url": "u", "nav": [["only one"]]}, {"name": "a", "url": "u", "nav": [], "x": 1}):
+            with self.assertRaises(blog.BlogError):
+                Tree(self, {"site": bad}).cfg()
+
     def test_index_pages_tags_and_home(self):
         t = self.blog_tree(pages_dir="pages", home="start", tags={"fiction": "Fiction"}, feed={"path": "/feed/", "entries": 1})
         t.file("pages/start.md", "---\npublished: true\ntitle: Start Here\n---\nWelcome.\n")