fuzzyworld

fuzzyworld-tools / commit

One site-wide footer (gen.site_footer) for code, apps, landing page and blog

d62e3357586e1c4458b67c13e075781f41393c91 · Pavel Safronov · 2026-11-01 09:35:31 -0800 · parents 9c347342ba27

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

One site-wide footer (gen.site_footer) for code, apps, landing page and blog

The blog now shows the four-column footer when site.nav is "gen", with its
own copyright and Feed line under it. The landing page gets it through a
<!--SITE-FOOTER--> placeholder; a missing placeholder is an error. The
internal "footer" key is rejected in a hand-written site config.

FW-Tokens-In: 100
FW-Tokens-Out: 22137
FW-Tokens-Cached: 2459381
FW-Session: a767c294-c653-462c-808d-fa538e6aa380
FW-Rtk-Saved: 2798870
FW-Rtk-Cmds: 8094

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 README.md                 |  5 +++--
 server/blog.py            | 18 ++++++++++++------
 server/gen.py             | 40 +++++++++++++++++++++++++---------------
 server/install.sh         |  2 +-
 server/site-blog/blog.css |  5 +++++
 server/site/index.html    |  2 +-
 tests/test_blog.py        | 30 +++++++++++++++++++++++++++---
 7 files changed, 74 insertions(+), 28 deletions(-)

diff --git a/README.md b/README.md
index 1b99d3a..5b0ce7d 100644
--- a/README.md
+++ b/README.md
@@ -300,8 +300,9 @@ fw blog deploy ~/Blog/fw-blog.json --dry-run  # then, for real: --yes (or answer
   top of `server/blog.py`. `source` and `comments` are relative to the config file's folder; `pages_dir` and `media`
   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"}`;
-  `"nav": "gen"` takes the fuzzyworld nav from `NAV` in `server/gen.py`, the one source (the landing page's nav is
-  built from it too, by `gen.py landing` in `install.sh`).
+  `"nav": "gen"` takes the fuzzyworld nav from `NAV` in `server/gen.py`, the one source, and also shows the shared
+  four-column footer (`gen.site_footer()`, with the blog's copyright and Feed line under it). The landing page's nav and
+  footer are built from the same functions by `gen.py landing` in `install.sh` (`<!--SITE-NAV-->`, `<!--SITE-FOOTER-->`).
   The landing page's text is Markdown in `server/site/landing.md`, rendered into the `<!--LANDING-TEXT-->` spot of
   `server/site/index.html` by the same command.
 - **Output:** posts at `/YYYY/MM/DD/<slug>/`, `/blog/` (every post by year and month, with excerpts and a "Continue
diff --git a/server/blog.py b/server/blog.py
index b38cbb2..13afe6c 100644
--- a/server/blog.py
+++ b/server/blog.py
@@ -116,9 +116,11 @@ def load_config(path):
         raise BlogError("nav must be a list of [label, path] pairs")
     site = cfg["site"]
     if site is not None:
+        if isinstance(site, dict) and "footer" in site:
+            raise BlogError('site: "footer" is set by "nav": "gen", not in the config')
         if isinstance(site, dict) and site.get("nav") == "gen":
-            site = cfg["site"] = {**site, "nav": gen.site_nav()}   # "gen": the fuzzyworld nav, built from NAV in gen.py
-        if not (isinstance(site, dict) and set(site) <= {"name", "url", "nav", "current"} and {"name", "url", "nav"} <= set(site)
+            site = cfg["site"] = {**site, "nav": gen.site_nav(), "footer": True}   # "gen": the fuzzyworld nav and footer, built in gen.py
+        if not (isinstance(site, dict) and set(site) <= {"name", "url", "nav", "current", "footer"} 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"])):
@@ -682,6 +684,13 @@ def blog_page(cfg, title, body, path):
         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"]
+    legal = (f'<p>{esc(copyright_text(cfg))}</p>\n'
+             f'<p><a href="{esc(site["url"] if site else "https://fuzzyworld.net/", quote=True)}">{esc(site["name"] if site else "fuzzyworld.net")}</a>'
+             f' · <a href="{esc(feed, quote=True)}">Feed</a></p>')
+    if site and site.get("footer"):   # the site-wide four-column footer, then the blog's own line under it
+        footer = f'<footer class="site"><div class="cols">{gen.site_footer("writing")}</div>\n<div class="cols legal">\n{legal}\n</div></footer>'
+    else:
+        footer = f'<footer class="site"><div class="cols">\n{legal}\n</div></footer>'
     head_title = cfg["title"] if path == "/" else f'{title} · {cfg["title"]}'
     return f"""<!doctype html>
 <html lang="en">
@@ -701,10 +710,7 @@ def blog_page(cfg, title, body, path):
 <main>
 {body}
 </main>
-<footer class="site"><div class="cols">
-<p>{esc(copyright_text(cfg))}</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>
+{footer}
 </body>
 </html>
 """
diff --git a/server/gen.py b/server/gen.py
index 1696f70..eacb455 100755
--- a/server/gen.py
+++ b/server/gen.py
@@ -4,7 +4,7 @@
     gen.py repo <name>    build or update the pages of one public repo
     gen.py index          rebuild the site index (public repos only)
     gen.py apps           rebuild the apps.fuzzyworld.net index from deployed apps
-    gen.py landing <src> <dest>   write the landing page: src with <!--SITE-NAV--> filled in from NAV and <!--LANDING-TEXT--> from landing.md
+    gen.py landing <src> <dest>   write the landing page: src with <!--SITE-NAV--> filled in from NAV, <!--SITE-FOOTER--> from site_footer() and <!--LANDING-TEXT--> from landing.md
     gen.py content        render server/content/<name>.md to code.fuzzyworld.net/<name>/ (site pages written in Markdown)
     gen.py ci <name>      rebuild one public repo's CI pages, then the CI dashboard
     gen.py ci-dashboard   rebuild code.fuzzyworld.net/ci/ (queue, running, recent; public repos only)
@@ -158,14 +158,33 @@ def site_nav():
     return [[label, SITE_URLS[st] + path] for _, label, st, path in NAV]
 
 
+def site_footer(here):
+    """The columns of the site-wide footer (inside <div class="cols">), with links relative to the site `here`
+    ("code", "apps", "writing", or "landing" for fuzzyworld.net). One source for the code and apps pages, the
+    landing page and the blog."""
+    def link(st, path):
+        return SITE_URLS[st] + path if st != here else path
+    return (f'<div><h2>fuzzyworld</h2><a href="{HOME_URL}/">fuzzyworld.net</a>'
+            f'<a href="{link("code", "/")}">Code: all repositories</a>'
+            f'<a href="{link("apps", "/")}">Apps</a>'
+            f'<a href="{WRITING_URL}/">Writing</a>'
+            f'<a href="{link("code", "/ci/")}">CI: what is building</a></div>'
+            f'<div><h2>About this site</h2><a href="{link("code", "/why-i-left-gitlab/")}">Why I left GitLab</a>'
+            f'<a href="{link("code", "/contribute/")}">Contribute and report bugs</a></div>'
+            f'<div><h2>Built with</h2><a href="{link("code", "/fuzzyworld-tools/")}">fuzzyworld-tools</a>'
+            '<span>Static files only: no accounts, no sign-in, no rate limits. MIT-licensed.</span></div>')
+
+
 def build_landing(src, dest):
-    """Writes the landing page: `src` with <!--SITE-NAV--> replaced by the nav links built from NAV and, if the
+    """Writes the landing page: `src` with <!--SITE-NAV--> replaced by the nav links built from NAV,
+    <!--SITE-FOOTER--> by site_footer("landing") and, if the
     page has a <!--LANDING-TEXT-->, that replaced by landing.md (next to `src`) rendered from Markdown."""
     links = "".join(f'<a href="{html.escape(url, quote=True)}">{html.escape(label)}</a>' for label, url in site_nav())
     with open(src, encoding="utf-8") as f:
         text = f.read()
-    if "<!--SITE-NAV-->" not in text:
-        raise SystemExit(f"{src}: no <!--SITE-NAV--> placeholder")
+    for marker in ("<!--SITE-NAV-->", "<!--SITE-FOOTER-->"):
+        if marker not in text:
+            raise SystemExit(f"{src}: no {marker} placeholder")
     if "<!--LANDING-TEXT-->" in text:
         if not markdown:
             raise SystemExit("gen.py landing needs Python-Markdown in " + PYLIB)
@@ -175,6 +194,7 @@ def build_landing(src, dest):
         with open(md_path, encoding="utf-8") as f:
             body = markdown.markdown(f.read(), extensions=["sane_lists"], output_format="html")
         text = text.replace("<!--LANDING-TEXT-->", body)
+    text = text.replace("<!--SITE-FOOTER-->", site_footer("landing"))
     write(dest, text.replace("<!--SITE-NAV-->", links))
 
 
@@ -192,17 +212,7 @@ def page(title, body, crumbs=None, view=None, site="code", refresh=None, section
         for key, label, st, path in NAV)
     header = (f'<header class="site"><div class="bar"><a class="brand" href="{HOME_URL}/">fuzzyworld</a>'
               f'<nav class="site-nav" aria-label="Site">{links}</nav></div></header>')
-    footer = ('<footer class="site"><div class="cols">'
-              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>'
-              f'<div><h2>Built with</h2><a href="{site_link("code", "/fuzzyworld-tools/", site)}">fuzzyworld-tools</a>'
-              '<span>Static files only: no accounts, no sign-in, no rate limits. MIT-licensed.</span></div>'
-              '</div></footer>')
+    footer = f'<footer class="site"><div class="cols">{site_footer(site)}</div></footer>'
     nav = ""
     if view:
         nav = (f'<nav class="repo-nav"><a href="{url(view.name)}/">Summary</a>'
diff --git a/server/install.sh b/server/install.sh
index 1a33f4a..6edd2a9 100755
--- a/server/install.sh
+++ b/server/install.sh
@@ -35,7 +35,7 @@ cp ~/fw/tools/server/site/_shared.css ~/fw/tools/server/site/_icons.css ~/fw/too
 chmod 644 "$apps"/_shared.css "$apps"/_icons.css "$apps"/style.css "$apps"/favicon.ico
 # Main fuzzyworld.net site
 main_site=~/fuzzyworld.net
-python3 ~/fw/tools/server/gen.py landing ~/fw/tools/server/site/index.html "$main_site/index.html"   # nav built from NAV in gen.py
+python3 ~/fw/tools/server/gen.py landing ~/fw/tools/server/site/index.html "$main_site/index.html"   # nav and footer built in gen.py
 cp ~/fw/tools/server/site/main.css "$main_site/"
 mkdir -p "$main_site/projects"
 cp ~/fw/tools/server/site/projects-redirect.html "$main_site/projects/index.html"  # the old projects page, now /projects/ on code.
diff --git a/server/site-blog/blog.css b/server/site-blog/blog.css
index 1c53e2a..5c1e975 100644
--- a/server/site-blog/blog.css
+++ b/server/site-blog/blog.css
@@ -32,6 +32,11 @@ footer.site { margin-top: var(--spacing-xl); border-top: 1px solid var(--color-b
   color: var(--color-fg-muted); font-size: var(--font-size-sm); }
 footer.site .cols { padding-top: var(--spacing-md); padding-bottom: var(--spacing-lg); }
 footer.site p { margin: var(--spacing-xs) 0; }
+/* The site-wide footer columns (gen.site_footer), same look as on fuzzyworld.net; the blog's own line sits under them. */
+footer.site .cols:has(> div) { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: var(--spacing-lg); padding-top: var(--spacing-lg); }
+footer.site h2 { font-size: var(--font-size-xs); text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-fg-muted); margin: 0 0 var(--spacing-xs); }
+footer.site .cols > div a, footer.site .cols > div span { display: block; padding: var(--spacing-xs) 0; }
+footer.site .legal { padding-top: 0; }
 
 /* Plain lists: room for the numbers and bullets, which sit outside the list box (the reset in _shared.css
    removes all list padding). Same fix as on code.fuzzyworld.net. */
diff --git a/server/site/index.html b/server/site/index.html
index d5d8d86..5ee556d 100644
--- a/server/site/index.html
+++ b/server/site/index.html
@@ -14,6 +14,6 @@
 <!--LANDING-TEXT-->
 
 </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://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>
+<footer class="site"><div class="cols"><!--SITE-FOOTER--></div></footer>
 </body>
 </html>
diff --git a/tests/test_blog.py b/tests/test_blog.py
index 65bdc7e..20bfa72 100644
--- a/tests/test_blog.py
+++ b/tests/test_blog.py
@@ -368,12 +368,36 @@ class Build(unittest.TestCase):
             self.assertIn(f'<a href="{url}"', page)
             self.assertIn(f">{label}</a>", page)
 
+    def test_site_footer_is_shared_by_blog_and_landing_page(self):
+        t = self.blog_tree(site={"name": "fuzzyworld", "url": "https://fuzzyworld.net/", "nav": "gen", "current": "Writing"})
+        _, out = t.build()
+        page = t.read(out, "2018/03/02/first-post/index.html")
+        self.assertIn(gen.site_footer("writing"), page)
+        self.assertIn("All rights reserved", page)   # the blog's own line stays under the shared columns
+        with tempfile.TemporaryDirectory() as d:
+            src, dest = os.path.join(d, "in.html"), os.path.join(d, "out.html")
+            with open(src, "w") as f:
+                f.write("<nav><!--SITE-NAV--></nav><footer><!--SITE-FOOTER--></footer>")
+            gen.build_landing(src, dest)
+            with open(dest) as f:
+                self.assertIn(gen.site_footer("landing"), f.read())
+        self.assertIn('href="/ci/"', gen.site_footer("code"))   # own site stays relative
+        self.assertIn('href="https://apps.fuzzyworld.net/"', gen.site_footer("code"))
+        self.assertIn('href="/"', gen.site_footer("apps"))
+        self.assertIn('href="https://code.fuzzyworld.net/"', gen.site_footer("apps"))
+        t = self.blog_tree(site={"name": "x", "url": "https://x.org/", "nav": [["Code", "https://c.x.org/"]]})
+        _, out = t.build()
+        self.assertNotIn("Built with", t.read(out, "2018/03/02/first-post/index.html"))   # explicit nav: no shared footer
+        with self.assertRaises(blog.BlogError):
+            Tree(self, {"site": {"name": "x", "url": "u", "nav": [], "footer": True}}).cfg()
+        self.assertIn('href="https://code.fuzzyworld.net/ci/"', gen.site_footer("apps"))
+
     def test_landing_page_nav_comes_from_nav(self):
         import tempfile
         with tempfile.TemporaryDirectory() as d:
             src, dest = os.path.join(d, "in.html"), os.path.join(d, "out.html")
             with open(src, "w") as f:
-                f.write('<nav><!--SITE-NAV--></nav>')
+                f.write('<nav><!--SITE-NAV--></nav><!--SITE-FOOTER-->')
             gen.build_landing(src, dest)
             with open(dest) as f:
                 text = f.read()
@@ -385,7 +409,7 @@ class Build(unittest.TestCase):
         with tempfile.TemporaryDirectory() as d:
             src = os.path.join(d, "in.html")
             with open(src, "w") as f:
-                f.write("<nav><!--SITE-NAV--></nav><!--LANDING-TEXT-->")
+                f.write("<nav><!--SITE-NAV--></nav><!--SITE-FOOTER--><!--LANDING-TEXT-->")
             with self.assertRaises(SystemExit):
                 gen.build_landing(src, os.path.join(d, "out.html"))
 
@@ -394,7 +418,7 @@ class Build(unittest.TestCase):
         with tempfile.TemporaryDirectory() as d:
             src, dest = os.path.join(d, "in.html"), os.path.join(d, "out.html")
             with open(src, "w") as f:
-                f.write("<nav><!--SITE-NAV--></nav><main><!--LANDING-TEXT--></main>")
+                f.write("<nav><!--SITE-NAV--></nav><!--SITE-FOOTER--><main><!--LANDING-TEXT--></main>")
             with open(os.path.join(d, "landing.md"), "w") as f:
                 f.write("Hello <https://example.com/>\n\n- one\n- two\n")
             gen.build_landing(src, dest)