fuzzyworld

fuzzyworld-tools / commit

Landing page text from landing.md; blog and til retired; Markdown installed by install.sh

73733e6e88d47ebabea2d942d88cf4d537b666bb · Pavel Safronov · 2026-11-01 08:35:29 -0800 · parents 0330e724d355

Claude Code usage: this commit 15.2K out / 2.4M cached · cost unknown (no per-model trailer) · session 10 tally 196 in / 45.5K out / 5.8M cached · usage by commit and session

Landing page text from landing.md; blog and til retired; Markdown installed by install.sh

- gen.py landing renders server/site/landing.md into <!--LANDING-TEXT--> in index.html
  (clear error if landing.md is missing); the TIL link is gone from the Links list.
- install.sh installs Python-Markdown (the landing page now needs it).
- PLAN.md: Q5/Q6 done (blog.fuzzyworld.net redirects to writing., til is 410, WordPress
  files kept and unserved); runner "built" date corrected to 2026-10-31.
- Tests: landing text rendering and the missing-file error (107 pass). README updated.

FW-Tokens-In: 196
FW-Tokens-Out: 45531
FW-Tokens-Cached: 5772129
FW-Session: e9fab6ca-4a16-4c13-991e-12307f775f92
FW-Rtk-Saved: 2793238
FW-Rtk-Cmds: 7926

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 PLAN.md                |  5 ++---
 README.md              |  4 +++-
 server/gen.py          | 18 ++++++++++++++----
 server/install.sh      |  2 ++
 server/site/index.html |  6 +-----
 server/site/landing.md | 26 ++++++++++++++++++++++++++
 tests/test_blog.py     | 23 +++++++++++++++++++++++
 7 files changed, 71 insertions(+), 13 deletions(-)

diff --git a/PLAN.md b/PLAN.md
index 456975b..6e76a03 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -46,7 +46,7 @@ The order goes from cheap to expensive, and each step reuses what the one before
 
 0. **Runners without TrueNAS** (decided 2026-10-28; see [Runners](#runners)): runner tags, the laptop as a runner when
    idle and plugged in, secrets in a folder on each runner, and "no runners" as a normal state. Goes first.
-   **Built 2026-10-28; deployed 2026-10-31** (server, then the TrueNAS runner, then the laptop runner with `--authorize`,
+   **Built and committed 2026-10-31 (a499715); deployed 2026-10-31** (server, then the TrueNAS runner, then the laptop runner with `--authorize`,
    in that order: a new runner against the old server has every heartbeat refused). Smoke-tested: both runners poll
    DreamHost and find nothing to run; no real job has gone through them yet. Review follow-ups (claim check on `done`,
    tolerant `release`) deployed the same day.
@@ -105,8 +105,7 @@ the source); footer "All rights reserved" notice with an AI-training line and a
 WordPress text wins when a source file differs (similarity under 0.95: 100 of the 142 posts are written from WordPress
 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), building from the push hook after the move to a private repo,
+writer exists now, so Next 1 (Feeds) can reuse it. **Done 2026-11-01 (Q5/Q6):** `blog.fuzzyworld.net` 301-redirects every path (query kept) to `writing.`; `til.fuzzyworld.net` answers 410 Gone. Both WordPress folders stay on the server, unserved; the original `.htaccess` of each is saved as `.htaccess.wp-20261101`. Backup: a WordPress export of the blog (Pavel, `~/Downloads`). The ancient `old.blog.fuzzyworld.net` copy was moved to `~/old/` on the server (it was not served). **Still to do:** 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 21fcac9..1b99d3a 100644
--- a/README.md
+++ b/README.md
@@ -109,7 +109,7 @@ Other settings in the repo config: `fuzzyworld.description`, `fuzzyworld.ci` (`o
 | `~/bin/fw` | symlink to `~/fw/tools/server/fw` |
 | `~/fw/state/` | per-repo page manifests and locks |
 | `~/fw/logs/<name>.log` | output of each push's publish run |
-| `~/fw/pylib/` | Python-Markdown 3.7 (README rendering) and Pygments 2.21.0 (syntax highlighting), `pip install --target`; `install.sh` adds Pygments |
+| `~/fw/pylib/` | Python-Markdown 3.7 (README rendering) and Pygments 2.21.0 (syntax highlighting), `pip install --target`; `install.sh` adds both (the landing page needs Markdown) |
 | `~/fw/queue/pending/`, `running/` | CI jobs, one JSON file each (see [CI](#ci)); `running/<job>.beat` is the runner's heartbeat, `declined/` the runners that gave a job back |
 | `~/fw/runners/<runner>` | one file per runner: its tags, and (by its time) when it last checked in |
 | `~/fw/ci/<name>/<sha>/` | CI results uploaded by runners: `pipeline.json` and one log per job |
@@ -302,6 +302,8 @@ fw blog deploy ~/Blog/fw-blog.json --dry-run  # then, for real: --yes (or answer
   `{"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`).
+  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
   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/gen.py b/server/gen.py
index c980f54..1696f70 100755
--- a/server/gen.py
+++ b/server/gen.py
@@ -4,15 +4,15 @@
     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
+    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 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)
     gen.py ci-status      the same, as text for the terminal, private repos included (fw ci-watch)
 
 Only the git CLI and the standard library are required. From ~/fw/pylib, when
-importable: Python-Markdown renders README/.md files (otherwise plain text), and
-Pygments colors source code (otherwise uncolored).
+importable: Python-Markdown renders README/.md files (otherwise plain text; `landing`
+and `content` need it) and Pygments colors source code (otherwise uncolored).
 
 Pages are incremental: a manifest per repo (~/fw/state/<name>.json) records the
 blob SHA behind every file page, so a push rewrites only what changed.
@@ -159,12 +159,22 @@ def site_nav():
 
 
 def build_landing(src, dest):
-    """Writes the landing page: `src` with <!--SITE-NAV--> replaced by the nav links built from NAV."""
+    """Writes the landing page: `src` with <!--SITE-NAV--> replaced by the nav links built from NAV 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")
+    if "<!--LANDING-TEXT-->" in text:
+        if not markdown:
+            raise SystemExit("gen.py landing needs Python-Markdown in " + PYLIB)
+        md_path = os.path.join(os.path.dirname(src), "landing.md")
+        if not os.path.exists(md_path):
+            raise SystemExit(f"{src}: has <!--LANDING-TEXT--> but {md_path} does not exist")
+        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)
     write(dest, text.replace("<!--SITE-NAV-->", links))
 
 
diff --git a/server/install.sh b/server/install.sh
index a17e9bf..1a33f4a 100755
--- a/server/install.sh
+++ b/server/install.sh
@@ -21,6 +21,8 @@ git config --global core.bigFileThreshold 16m
 mkdir -p ~/bin ~/fw/state ~/fw/logs ~/fw/queue/pending ~/fw/queue/running ~/fw/ci
 chmod +x ~/fw/tools/server/runner-api ~/fw/tools/server/mirror-only
 # Pygments colors source code on the file pages (gen.py works without it, uncolored).
+# Python-Markdown renders the README pages and the landing page text (`gen.py landing` needs it).
+[ -d ~/fw/pylib/markdown ] || python3 -m pip install --quiet --target ~/fw/pylib "Markdown==3.7"
 [ -d ~/fw/pylib/pygments ] || python3 -m pip install --quiet --target ~/fw/pylib "Pygments==2.21.0"
 ln -sfn ~/fw/tools/server/fw ~/bin/fw
 site=~/code.fuzzyworld.net
diff --git a/server/site/index.html b/server/site/index.html
index b266c40..0585247 100644
--- a/server/site/index.html
+++ b/server/site/index.html
@@ -11,7 +11,7 @@
 <header class="site"><div class="bar"><a class="brand" href="/">fuzzyworld</a><nav class="site-nav" aria-label="Site"><!--SITE-NAV--></nav></div></header>
 <main>
 <h1>Welcome to Pavel Safronov's website</h1>
-<p>I'm a software engineer who dabbles in fiction writing.</p>
+<!--LANDING-TEXT-->
 
 <h2>Links</h2>
 <ul class="list-vertical">
@@ -39,10 +39,6 @@
     <img src="/github.png" class="small-logo" alt="" />
     <a href="https://github.com/PavelSafronov">GitHub (work)</a>
   </li>
-  <li>
-    <img src="/favicon.ico" class="small-logo" alt="" />
-    <a href="http://til.fuzzyworld.net/">TIL 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>
diff --git a/server/site/landing.md b/server/site/landing.md
new file mode 100644
index 0000000..ff7df1c
--- /dev/null
+++ b/server/site/landing.md
@@ -0,0 +1,26 @@
+My name is Pavel Safronov and I'm a software engineer and I write fiction.
+This website started out as a personal hub for finding me and for some small projects, though recently I've started transitioning various dependencies here as well. About a week ago I moved my code development from GitLab to this website, complete with a simple UI, git repos, and even CI.
+[Why I left GitLab](https://code.fuzzyworld.net/why-i-left-gitlab/) is a short write-up about the move.
+
+Recently it became so easy for me to add more content and move other parts of my digital life here.
+So a week after the GitLab move (today, as I write this, November 1, 2026), I moved my writing site from WordPress to a custom approach that publishes my Markdown files directly to this site whenever I want.
+
+As I move away from relying on third-party platforms (GitLab and WordPress, so far), I am writing the tools to do this, and these tools are entirely open source: [fuzzyworld-tools](https://code.fuzzyworld.net/fuzzyworld-tools/).
+That is the package that powers this site, so aside from the content, everything you see here is built with it.
+
+I also write fiction.
+Mostly it's just short sci-fi stories, but I am currently (November 1, 2026) working on four different manuscripts that I'm going to try to get published.
+You can see some of my [past highlights](https://writing.fuzzyworld.net/) and read about my [upcoming work](https://writing.fuzzyworld.net/upcoming/).
+
+You can find my code, web-apps, and writing by following the menu at the top of the page, or by going directly to:
+
+- [Code](https://code.fuzzyworld.net/)
+- [Apps and games](https://apps.fuzzyworld.net/)
+- [Writing](https://writing.fuzzyworld.net/)
+
+And if you want to get in touch, there are many ways:
+
+- Email: <pavel@fuzzyworld.net>
+- [LinkedIn](https://www.linkedin.com/in/psafronov/)
+- [GitHub](https://github.com/PavelSafronov) (mostly work-related)
+- [Schedule a meeting with me](https://calendly.com/pavel-safronov/) using Calendly
diff --git a/tests/test_blog.py b/tests/test_blog.py
index 42620dd..65bdc7e 100644
--- a/tests/test_blog.py
+++ b/tests/test_blog.py
@@ -381,6 +381,29 @@ class Build(unittest.TestCase):
         for label, url in gen.site_nav():
             self.assertIn(f'<a href="{url}">{label}</a>', text)
 
+    def test_landing_text_without_landing_md_is_an_error(self):
+        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-->")
+            with self.assertRaises(SystemExit):
+                gen.build_landing(src, os.path.join(d, "out.html"))
+
+    @needs_markdown
+    def test_landing_text_is_rendered_from_landing_md(self):
+        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>")
+            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)
+            with open(dest) as f:
+                text = f.read()
+        self.assertNotIn("LANDING-TEXT", text)
+        self.assertIn('<a href="https://example.com/">', text)
+        self.assertIn("<li>two</li>", text)
+
     def test_landing_page_without_the_placeholder_is_an_error(self):
         with tempfile.TemporaryDirectory() as d:
             src = os.path.join(d, "in.html")