summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-11 11:42:23 -0600
committert <t@tjp.lol>2026-07-11 11:43:04 -0600
commit5943b3063676d68381545e36a9903e1df278e923 (patch)
tree07fc2abc28182202c5cec0cc79b5a0531ae3e616
initial commit: claude-designed site and project doc to staticly generate it going forward
-rw-r--r--.thumbnailbin0 -> 6344 bytes
-rw-r--r--_docs/PROJECT_PLAN.md245
-rw-r--r--about/index.html69
-rw-r--r--feed.xml37
-rw-r--r--index.html86
-rw-r--r--notes/index.html67
-rw-r--r--notes/plain-html.html70
-rw-r--r--projects/index.html66
-rw-r--r--style.css111
-rw-r--r--weblog/index.html61
-rw-r--r--weblog/moving-off-gemini.html91
11 files changed, 903 insertions, 0 deletions
diff --git a/.thumbnail b/.thumbnail
new file mode 100644
index 0000000..336e6d1
--- /dev/null
+++ b/.thumbnail
Binary files differ
diff --git a/_docs/PROJECT_PLAN.md b/_docs/PROJECT_PLAN.md
new file mode 100644
index 0000000..5a8abec
--- /dev/null
+++ b/_docs/PROJECT_PLAN.md
@@ -0,0 +1,245 @@
+# tjp.lol project plan
+
+## Goals
+
+- Preserve the existing visual design and `style.css` unchanged.
+- Replace duplicated HTML with a small Go static-site generator.
+- Make Markdown the source format for public content.
+- Support public Obsidian notes without nonstandard Obsidian syntax.
+- Publish an Atom feed at `feed.xml`.
+- Remove fictional public content without replacing it with AI-written prose.
+- Keep all published post prose human-written; disclose AI coding/refactoring assistance in the colophon.
+
+## Content and placeholder cleanup
+
+- Prefix retained fictional example files with `_`.
+ - They remain repository references only.
+ - The generator does not scan, render, list, or otherwise expose underscore-prefixed files.
+ - An underscore-prefixed directory excludes its complete subtree, enabling `_private/` in the vault.
+- Remove links to fictional weblog posts, notes, projects, tags, and feed entries.
+- Remove tags everywhere; there is no tag system.
+- Comment out:
+ - Notes navigation in the shared header.
+ - Homepage “from the notes.”
+ - Webring markup in the shared footer.
+- Keep Notes hidden until real vault content is publishing successfully.
+- Replace the fake feed with a valid, initially empty Atom feed rather than invented entries.
+- During migration, enumerate every existing prose location for manual rewrite:
+ - page body copy;
+ - titles and descriptions;
+ - feed title/subtitle text;
+ - project descriptions;
+ - link labels where applicable;
+ - colophon text;
+ - placeholder entries and summaries.
+- Do not generate replacement prose.
+
+## Licence and colophon
+
+- Site content is licensed **CC BY 4.0 unless noted**.
+- Put a consistent content-licence notice in the shared footer, linking directly to Creative Commons.
+- Use the colophon to explain the default licence and exceptions.
+- No separate prose `LICENSE` file or registration is required.
+- Generator code may receive a separate OSS licence later.
+- Replace outdated colophon claims about hand-written HTML and having no build step.
+- Retain the no-JavaScript claim if it remains true.
+- The human-written colophon should state that AI may be used for coding and batch refactoring, but not to generate published post prose.
+
+## Generator
+
+### Implementation
+
+- A Go command-line binary.
+- Use a maintained CommonMark parser with familiar GFM extensions.
+- Disable raw HTML in Markdown.
+- Use Go template syntax:
+ - `html/template` for HTML outputs;
+ - XML-safe rendering for Atom output.
+- Follow the `sw-convert` model: load a named set of reusable templates/partials, rather than serving literal header/footer files.
+
+### Source and output rules
+
+- Run recursively from one defined site root.
+- Markdown conversion is in place:
+
+ ```text
+ path/page.md → path/page.html
+ ```
+
+- Page-output templates use explicit destination extensions:
+
+ ```text
+ index.html.tmpl → index.html
+ feed.xml.tmpl → feed.xml
+ ```
+
+- Reject collisions, such as both `index.md` and `index.html.tmpl` targeting `index.html`.
+- Static assets such as `style.css` are untouched.
+- Markdown sources may remain downloadable.
+- Template sources should not be publicly served.
+
+### Template structure
+
+- Markdown pages use path-based site layouts rather than requiring layout frontmatter:
+ - weblog pages receive the existing post structure/classes;
+ - notes receive the existing note structure/classes;
+ - ordinary pages receive a general page layout.
+- Complex pages such as the homepage, weblog index, notes index, and feed can be written directly as templates.
+- Templates receive helpers for relative paths, sorting, filtering, slicing, and joining rendered sections.
+- Header/footer templates calculate correct relative links such as `../style.css`, preserving portable relative URLs.
+
+## Markdown parsing and page model
+
+### Links
+
+Rewrite only site-local Markdown link destinations:
+
+```text
+[example](other.md) → [example](other.html)
+[example](other.md#part) → [example](other.html#part)
+```
+
+Leave external URLs, mail links, fragments, asset links, and code blocks unchanged.
+
+### Titles and sections
+
+- The first H1 supplies the title for ordinary Markdown pages.
+- The title is used for document metadata, page headings, listings, and Atom entry titles.
+- The extracted H1 is not duplicated in rendered body content.
+- Title fallback order:
+
+ 1. first H1;
+ 2. optional frontmatter title where unavoidable;
+ 3. filename-derived fallback with a build warning.
+
+- Parsed Markdown body is exposed as rendered top-level sections:
+
+ ```go
+ Sections []template.HTML
+ ```
+
+- This is the primary content abstraction; no separate `Lede` field is needed.
+- Weblog convention: the first section after the opening H1 should be a short paragraph. The weblog layout renders that first section in a wrapper with inline `font-size:1.2em`, then renders the remainder normally.
+- Notes and ordinary pages do not receive special lede treatment.
+
+### Metadata
+
+- YAML frontmatter is supported and exposed as `.Meta`.
+- Frontmatter is optional except where genuine metadata is needed.
+- Weblog posts use `post_date` for chronological listings and Atom dates.
+- An optional updated date may override the original post date.
+- Notes can sort by source file modification time.
+
+## Page inventory and indexes
+
+Before rendering anything:
+
+1. Discover all eligible Markdown and page-template sources.
+2. Parse frontmatter and Markdown.
+3. Derive page paths, titles, metadata, source modification times, and rendered sections.
+4. Build the complete page inventory.
+5. Render all HTML pages and `feed.xml`.
+
+The template data includes:
+
+```go
+Pages map[string][]*Page
+```
+
+Each `*Page` is inserted into every ancestor directory collection. For example:
+
+```text
+notes/recipes/cocktails/old_fashioned.md
+```
+
+is available through:
+
+```text
+Pages["notes"]
+Pages["notes/recipes"]
+Pages["notes/recipes/cocktails"]
+```
+
+All entries point to the same `Page` object, which carries path information, metadata, title, modification time, and rendered sections.
+
+Templates can therefore build lists without special generator features:
+
+- weblog index: weblog pages sorted by `Meta.post_date`;
+- homepage: recent posts;
+- notes index: notes sorted by modification time;
+- future manually authored “good starting points” sections.
+
+Only real pages enter these collections. Underscore-prefixed files, directories, and non-page outputs such as `feed.xml` do not.
+
+## Feed
+
+- Retain Atom 1.0 at `feed.xml`.
+- Label the navigation link “feed.”
+- Include weblog posts only.
+- Sort by post date.
+- Emit full rendered post content in each entry.
+- Use absolute canonical URLs under `https://tjp.lol/`.
+- Emit a valid zero-entry feed until real posts exist.
+
+## Syncthing and notes
+
+- Generated HTML remains beside Markdown on the server.
+- Prevent generated HTML from syncing through the vault with Syncthing ignore rules.
+
+Each device has a local, non-synced `.stignore`:
+
+```text
+#include .stignore.shared
+```
+
+The synchronized `.stignore.shared` contains:
+
+```text
+*.html
+```
+
+This prevents HTML synchronization in either direction at every depth while allowing Markdown synchronization.
+
+Notes rollout:
+
+1. Comment out Notes links and homepage note links.
+2. Connect the synced vault to the site source tree and build note HTML.
+3. Validate generated note pages and privacy behavior.
+4. Restore Notes navigation only when public notes are ready.
+
+## Caddy follow-up
+
+Not part of the generator implementation, but record a server task:
+
+- Deny requests for any path containing an underscore-prefixed component.
+- Deny template-source requests such as `*.tmpl`.
+- This ensures `_private/` Markdown and internal template files cannot be downloaded even though Markdown is otherwise publicly available.
+
+## Reliability and validation
+
+- Do not overwrite non-generated HTML.
+- Track generated outputs so deleted/renamed Markdown does not leave accidental stale public pages; cleanup must only remove tracked generated files.
+- Write output safely so a failed build preserves existing pages.
+- Cron execution should use a lock, log failures, and run after Syncthing has had time to settle.
+- Add tests for:
+ - Markdown/GFM conversion and raw-HTML rejection;
+ - local-link rewriting;
+ - underscore exclusions;
+ - output collisions;
+ - title/section extraction;
+ - recursive page inventory;
+ - relative-path generation;
+ - Atom validity and full-content entries.
+- Validate internal links and feed XML before deployment.
+
+## Delivery sequence
+
+1. Archive fictional examples under underscore-prefixed names and remove public references.
+2. Establish source conventions, templates, and generator tests.
+3. Implement parsing, page inventory, Markdown rendering, relative links, and output writing.
+4. Migrate current HTML structure into Markdown and `.html.tmpl` sources.
+5. Audit all inherited prose for manual replacement.
+6. Generate the Atom feed and validate the public site.
+7. Configure Syncthing ignores and Caddy protection.
+8. Connect and publish the selected notes vault content.
+9. Restore Notes links when the published notes are real and ready.
diff --git a/about/index.html b/about/index.html
new file mode 100644
index 0000000..9b58299
--- /dev/null
+++ b/about/index.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>About &amp; colophon — tjp.lol</title>
+<meta name="description" content="About tjp and how this site is built.">
+<link rel="stylesheet" href="../style.css">
+</head>
+<body>
+<div class="wrap">
+
+ <header class="site">
+ <h1 class="brand"><a href="../index.html">~tjp<span class="cur"> $_</span></a></h1>
+ <nav class="main">
+ <a href="../weblog/index.html">weblog</a>
+ <a href="../notes/index.html">notes</a>
+ <a href="../projects/index.html">projects</a>
+ <a href="../about/index.html" aria-current="page">about</a>
+ <a href="../feed.xml">rss</a>
+ </nav>
+ </header>
+
+ <main>
+ <article class="post">
+ <div class="post-head">
+ <h1>About</h1>
+ </div>
+ <div class="note-body">
+ <p>I'm tjp &mdash; an elder-millennial software engineer living in Salt Lake City, Utah. I write mostly Python, spend too long in the terminal, and care about software that's small, legible, and built to last.</p>
+ <p>This site is where I keep my <a href="../weblog/index.html">writing</a> and a public cut of my <a href="../notes/index.html">notes</a>. If you want to reach me, <a href="mailto:t@tjp.lol">t@tjp.lol</a> is the best way.</p>
+
+ <h2>Elsewhere</h2>
+ <ul>
+ <li><span class="mono">email</span> &mdash; <a href="mailto:t@tjp.lol">t@tjp.lol</a></li>
+ <li><span class="mono">code</span> &nbsp;&mdash; <a href="https://github.com/teepark">github.com/teepark</a></li>
+ <li><span class="mono">fedi</span> &nbsp;&mdash; <a href="https://tilde.zone/@tjpcc" rel="me">@tjpcc@tilde.zone</a></li>
+ <li><span class="mono">gemini</span> &mdash; <code>gemini://gemini.ctrl-c.club/~tjp/</code></li>
+ </ul>
+ </div>
+
+ <div class="backlinks">
+ <h2>Colophon</h2>
+ <div class="note-body">
+ <p>This site is hand-written HTML and a single <a href="../style.css">stylesheet</a>. There is no JavaScript anywhere &mdash; not deferred, not optional, none. It renders identically with scripts disabled because there is nothing to disable.</p>
+ <ul>
+ <li><strong>Type:</strong> your system's UI font for prose, your system's monospace for headings and code. No web fonts, no network requests for type.</li>
+ <li><strong>Colour:</strong> light and dark themes via <code>prefers-color-scheme</code> &mdash; it follows your OS setting, in pure CSS.</li>
+ <li><strong>Weblog:</strong> written as gemtext in my <a href="../projects/index.html">capsule</a>, converted to HTML with a small script.</li>
+ <li><strong>Notes:</strong> authored in Obsidian, synced across my devices with Syncthing, published as static pages.</li>
+ <li><strong>Hosting:</strong> static files. Nothing to run, nothing to patch.</li>
+ <li><strong>Licence:</strong> content is <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a> unless a page says otherwise. Take it, remix it, just credit me.</li>
+ </ul>
+ </div>
+ </div>
+ </article>
+ </main>
+
+ <footer>
+ <div class="ring">
+ <span>webring:</span>
+ <a href="#">&larr; prev</a><a href="#">rand</a><a href="#">next &rarr;</a>
+ </div>
+ <p>hand-written in slc &middot; &copy;2026 tjp &middot; <a href="../feed.xml">feed</a></p>
+ </footer>
+
+</div>
+</body>
+</html>
diff --git a/feed.xml b/feed.xml
new file mode 100644
index 0000000..7dbd364
--- /dev/null
+++ b/feed.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+ <title>tjp.lol</title>
+ <subtitle>Notes, code, and longer thoughts from an elder-millennial software engineer in Salt Lake City.</subtitle>
+ <link href="https://tjp.lol/feed.xml" rel="self"/>
+ <link href="https://tjp.lol/"/>
+ <updated>2026-06-30T00:00:00Z</updated>
+ <id>https://tjp.lol/</id>
+ <author>
+ <name>tjp</name>
+ <email>t@tjp.lol</email>
+ </author>
+
+ <entry>
+ <title>Moving my weblog off Gemini (without leaving it)</title>
+ <link href="https://tjp.lol/weblog/moving-off-gemini.html"/>
+ <id>https://tjp.lol/weblog/moving-off-gemini.html</id>
+ <updated>2026-06-30T00:00:00Z</updated>
+ <summary>How I mirror my Gemini capsule to the web with a tiny gemtext-to-HTML converter, without abandoning geminispace.</summary>
+ </entry>
+
+ <entry>
+ <title>A static site generator in 100 lines of bash</title>
+ <link href="https://tjp.lol/weblog/index.html"/>
+ <id>https://tjp.lol/weblog/static-site-generator-bash</id>
+ <updated>2026-05-18T00:00:00Z</updated>
+ <summary>Placeholder entry &mdash; replace with the real post.</summary>
+ </entry>
+
+ <entry>
+ <title>Syncthing is the personal cloud I actually wanted</title>
+ <link href="https://tjp.lol/weblog/index.html"/>
+ <id>https://tjp.lol/weblog/syncthing</id>
+ <updated>2026-04-02T00:00:00Z</updated>
+ <summary>Placeholder entry &mdash; replace with the real post.</summary>
+ </entry>
+</feed>
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..7bfab0a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,86 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>tjp.lol</title>
+<meta name="description" content="Personal site of tjp — software engineer in Salt Lake City. Weblog, notes, projects.">
+<link rel="stylesheet" href="style.css">
+<link rel="alternate" type="application/atom+xml" title="tjp.lol" href="feed.xml">
+</head>
+<body>
+<div class="wrap">
+
+ <header class="site">
+ <h1 class="brand"><a href="index.html">~tjp<span class="cur"> $_</span></a></h1>
+ <p class="tag">Elder-millennial software engineer in Salt Lake City, Utah. Writing about code, tools, and the small web.</p>
+ <nav class="main">
+ <a href="weblog/index.html">weblog</a>
+ <a href="notes/index.html">notes</a>
+ <a href="projects/index.html">projects</a>
+ <a href="about/index.html">about</a>
+ <a href="feed.xml">rss</a>
+ </nav>
+ </header>
+
+ <main>
+ <section class="intro">
+ <p>Welcome in. This site is my writing desk: weblog posts moving over from my <span class="mono">gemini://</span> capsule, plus a public cut of the notes I keep in Obsidian and sync across my machines with Syncthing.</p>
+ <p>Plain HTML and CSS. No JavaScript, no build step, no tracking &mdash; it reads exactly the same with scripts turned off. That's the point.</p>
+ </section>
+
+ <section>
+ <h2 class="sec">recent posts</h2>
+ <ul class="posts">
+ <li><time datetime="2026-06-30">2026-06-30</time><a href="weblog/moving-off-gemini.html">Moving my weblog off Gemini (without leaving it)</a></li>
+ <li><time datetime="2026-05-18">2026-05-18</time><a href="weblog/index.html">A static site generator in 100 lines of bash</a></li>
+ <li><time datetime="2026-04-02">2026-04-02</time><a href="weblog/index.html">Syncthing is the personal cloud I actually wanted</a></li>
+ <li><time datetime="2026-02-14">2026-02-14</time><a href="weblog/index.html">Notes on green-thread concurrency in Python</a></li>
+ <li><time datetime="2026-01-09">2026-01-09</time><a href="weblog/index.html">The quiet case for plain HTML</a></li>
+ </ul>
+ <p style="margin-top:.9rem;font-size:.9rem;"><a href="weblog/index.html">all posts &rarr;</a></p>
+ </section>
+
+ <section>
+ <h2 class="sec">tags</h2>
+ <div class="tags">
+ <a href="weblog/index.html">python</a><a href="weblog/index.html">small-web</a><a href="weblog/index.html">systems</a>
+ <a href="weblog/index.html">tools</a><a href="weblog/index.html">gemini</a><a href="weblog/index.html">slc</a><a href="weblog/index.html">reading</a>
+ </div>
+ </section>
+
+ <div class="cols">
+ <section>
+ <h2 class="sec">elsewhere</h2>
+ <ul class="links">
+ <li><span class="k">email</span><a href="mailto:t@tjp.lol">t@tjp.lol</a></li>
+ <li><span class="k">code</span><a href="https://github.com/teepark">github.com/teepark</a></li>
+ <li><span class="k">fedi</span><a href="https://tilde.zone/@tjpcc" rel="me">@tjpcc@tilde.zone</a></li>
+ <li><span class="k">gemini</span><a href="about/index.html">gemini://…/~tjp</a></li>
+ </ul>
+ </section>
+ <section>
+ <h2 class="sec">from the notes</h2>
+ <ul class="links">
+ <li><a href="notes/plain-html.html">The case for plain HTML</a></li>
+ <li><a href="notes/index.html">Home-lab &amp; self-hosting</a></li>
+ <li><a href="notes/index.html">Emacs config, annotated</a></li>
+ <li><a href="notes/index.html">Books I keep re-reading</a></li>
+ </ul>
+ </section>
+ </div>
+ </main>
+
+ <footer>
+ <div class="ring">
+ <span>webring:</span>
+ <a href="#">&larr; prev</a>
+ <a href="#">rand</a>
+ <a href="#">next &rarr;</a>
+ </div>
+ <p>hand-written in slc &middot; &copy;2026 tjp &middot; content <a href="about/index.html">CC BY 4.0</a> unless noted &middot; <a href="feed.xml">feed</a></p>
+ </footer>
+
+</div>
+</body>
+</html>
diff --git a/notes/index.html b/notes/index.html
new file mode 100644
index 0000000..7c5265f
--- /dev/null
+++ b/notes/index.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Notes — tjp.lol</title>
+<meta name="description" content="A public cut of my Obsidian notes — a small digital garden.">
+<link rel="stylesheet" href="../style.css">
+</head>
+<body>
+<div class="wrap">
+
+ <header class="site">
+ <h1 class="brand"><a href="../index.html">~tjp<span class="cur"> $_</span></a></h1>
+ <nav class="main">
+ <a href="../weblog/index.html">weblog</a>
+ <a href="../notes/index.html" aria-current="page">notes</a>
+ <a href="../projects/index.html">projects</a>
+ <a href="../about/index.html">about</a>
+ <a href="../feed.xml">rss</a>
+ </nav>
+ </header>
+
+ <main>
+ <section class="intro">
+ <p>This is a digital garden &mdash; a public slice of the notes I keep in Obsidian. Unlike the <a href="../weblog/index.html">weblog</a>, these aren't essays. They're living pages: some are a paragraph, some are years old, most link to each other. Start anywhere and follow the threads.</p>
+ </section>
+
+ <section>
+ <h2 class="sec">start here</h2>
+ <ul class="posts">
+ <li><a href="plain-html.html">The case for plain HTML</a></li>
+ <li><a href="plain-html.html">Home-lab &amp; self-hosting</a></li>
+ <li><a href="plain-html.html">How I take notes</a></li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 class="sec">all notes</h2>
+ <nav class="note-index">
+ <a href="plain-html.html">Bread notes</a>
+ <a href="plain-html.html">Books I keep re-reading</a>
+ <a href="plain-html.html">CPython internals</a>
+ <a href="plain-html.html">Emacs config, annotated</a>
+ <a href="plain-html.html">Gemtext &amp; the small web</a>
+ <a href="plain-html.html">Green threads</a>
+ <a href="plain-html.html">Home-lab &amp; self-hosting</a>
+ <a href="plain-html.html">How I take notes</a>
+ <a href="plain-html.html">Makefiles I reach for</a>
+ <a href="plain-html.html">The case for plain HTML</a>
+ <a href="plain-html.html">Syncthing setup</a>
+ <a href="plain-html.html">Terminal &amp; shell dotfiles</a>
+ </nav>
+ </section>
+ </main>
+
+ <footer>
+ <div class="ring">
+ <span>webring:</span>
+ <a href="#">&larr; prev</a><a href="#">rand</a><a href="#">next &rarr;</a>
+ </div>
+ <p>hand-written in slc &middot; &copy;2026 tjp &middot; <a href="../feed.xml">feed</a></p>
+ </footer>
+
+</div>
+</body>
+</html>
diff --git a/notes/plain-html.html b/notes/plain-html.html
new file mode 100644
index 0000000..d60275e
--- /dev/null
+++ b/notes/plain-html.html
@@ -0,0 +1,70 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>The case for plain HTML — notes — tjp.lol</title>
+<meta name="description" content="A note on writing plain, durable HTML.">
+<link rel="stylesheet" href="../style.css">
+</head>
+<body>
+<div class="wrap">
+
+ <header class="site">
+ <h1 class="brand"><a href="../index.html">~tjp<span class="cur"> $_</span></a></h1>
+ <nav class="main">
+ <a href="../weblog/index.html">weblog</a>
+ <a href="../notes/index.html" aria-current="page">notes</a>
+ <a href="../projects/index.html">projects</a>
+ <a href="../about/index.html">about</a>
+ <a href="../feed.xml">rss</a>
+ </nav>
+ </header>
+
+ <main>
+ <article class="post">
+ <div class="post-head">
+ <p class="meta"><a href="../notes/index.html" style="text-decoration:none;">&larr; notes</a> &nbsp;/&nbsp; tended <time datetime="2026-05-30">2026-05-30</time> &middot; planted <time datetime="2024-08-11">2024-08-11</time></p>
+ <h1>The case for plain HTML</h1>
+ </div>
+
+ <div class="note-body">
+ <p><em>A note, not an essay &mdash; expect it to change. This page shows how a wiki note reads: internal <a class="wiki" href="index.html">[[wiki links]]</a> render as underlined-dotted links, and a backlinks panel sits at the bottom.</em></p>
+
+ <p>A hand-written HTML page is the most durable document format I know of that still looks like a document. It has no runtime, no dependencies, and no version that can go out of date. A file I wrote in 2004 opens today, pixel-for-pixel, in a browser that didn't exist then.</p>
+
+ <p>This connects to <a class="wiki" href="index.html">Gemtext &amp; the small web</a> &mdash; both are bets that <em>constraint outlives capability</em>. See also <a class="wiki" href="index.html">How I take notes</a> for why I keep the source in plain text.</p>
+
+ <h2>Things I no longer do</h2>
+ <ul>
+ <li>Reach for a framework before there's a second page.</li>
+ <li>Add a build step to save five keystrokes.</li>
+ <li>Load a web font when the reader already owns a good one.</li>
+ </ul>
+
+ <h2>The test</h2>
+ <p>Turn JavaScript off. If the page is worse, I've added fragility I have to maintain forever. If it's identical, I'm done. This whole site passes that test &mdash; see the <a class="wiki" href="../about/index.html">colophon</a>.</p>
+ </div>
+
+ <div class="backlinks">
+ <h2>Linked from</h2>
+ <ul class="posts">
+ <li><a href="index.html">Gemtext &amp; the small web</a></li>
+ <li><a href="index.html">Home-lab &amp; self-hosting</a></li>
+ <li><a href="../weblog/moving-off-gemini.html">Moving my weblog off Gemini (without leaving it)</a></li>
+ </ul>
+ </div>
+ </article>
+ </main>
+
+ <footer>
+ <div class="ring">
+ <span>webring:</span>
+ <a href="#">&larr; prev</a><a href="#">rand</a><a href="#">next &rarr;</a>
+ </div>
+ <p>hand-written in slc &middot; &copy;2026 tjp &middot; <a href="../feed.xml">feed</a></p>
+ </footer>
+
+</div>
+</body>
+</html>
diff --git a/projects/index.html b/projects/index.html
new file mode 100644
index 0000000..4e004e6
--- /dev/null
+++ b/projects/index.html
@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Projects — tjp.lol</title>
+<meta name="description" content="Things tjp has built.">
+<link rel="stylesheet" href="../style.css">
+</head>
+<body>
+<div class="wrap">
+
+ <header class="site">
+ <h1 class="brand"><a href="../index.html">~tjp<span class="cur"> $_</span></a></h1>
+ <nav class="main">
+ <a href="../weblog/index.html">weblog</a>
+ <a href="../notes/index.html">notes</a>
+ <a href="../projects/index.html" aria-current="page">projects</a>
+ <a href="../about/index.html">about</a>
+ <a href="../feed.xml">rss</a>
+ </nav>
+ </header>
+
+ <main>
+ <section class="intro">
+ <p>Small things I've built and still maintain. Most live on <a href="https://github.com/teepark">GitHub</a> or <a href="#">tildegit</a>. Placeholder entries for now &mdash; swap in the real ones whenever.</p>
+ </section>
+
+ <section>
+ <h2 class="sec">maintained</h2>
+ <ul class="links" style="display:block;">
+ <li style="display:block;padding:.7rem 0;border-bottom:1px dashed var(--rule);">
+ <a href="https://github.com/teepark" style="font-weight:600;">greenhouse</a> &mdash; <span style="color:var(--muted);">green-thread concurrency for Python, built on greenlet.</span>
+ <div class="tags" style="margin-top:.4rem;"><a href="#">python</a><a href="#">concurrency</a></div>
+ </li>
+ <li style="display:block;padding:.7rem 0;border-bottom:1px dashed var(--rule);">
+ <a href="https://github.com/teepark" style="font-weight:600;">gmi2html</a> &mdash; <span style="color:var(--muted);">the tiny gemtext-to-HTML converter that publishes this weblog.</span>
+ <div class="tags" style="margin-top:.4rem;"><a href="#">gemini</a><a href="#">bash</a></div>
+ </li>
+ <li style="display:block;padding:.7rem 0;border-bottom:1px dashed var(--rule);">
+ <a href="https://github.com/teepark" style="font-weight:600;">dotfiles</a> &mdash; <span style="color:var(--muted);">my shell, editor, and terminal config, documented.</span>
+ <div class="tags" style="margin-top:.4rem;"><a href="#">tools</a><a href="#">shell</a></div>
+ </li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 class="sec">archived / for fun</h2>
+ <ul class="posts">
+ <li><a href="https://github.com/teepark">A terminal RGB guessing game</a></li>
+ <li><a href="https://github.com/teepark">Pageview stats in 20 lines of bash</a></li>
+ </ul>
+ </section>
+ </main>
+
+ <footer>
+ <div class="ring">
+ <span>webring:</span>
+ <a href="#">&larr; prev</a><a href="#">rand</a><a href="#">next &rarr;</a>
+ </div>
+ <p>hand-written in slc &middot; &copy;2026 tjp &middot; <a href="../feed.xml">feed</a></p>
+ </footer>
+
+</div>
+</body>
+</html>
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..03c224c
--- /dev/null
+++ b/style.css
@@ -0,0 +1,111 @@
+/* tjp.lol — shared styles. Plain CSS, no JS anywhere on this site. */
+
+:root{
+ --bg:#f4f4f1; --fg:#232322; --muted:#77776f; --rule:#d8d8d1;
+ --accent:#2f6f4f; --accent-hover:#1f513a; --card:#ecece7;
+ --measure:46rem;
+ --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
+ --sans:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+}
+@media (prefers-color-scheme:dark){
+ :root{
+ --bg:#131413; --fg:#d7d9d2; --muted:#82857b; --rule:#2a2c28;
+ --accent:#6fbf93; --accent-hover:#8fd6ac; --card:#1b1d1a;
+ }
+}
+
+*{box-sizing:border-box;}
+html{font-size:17px;}
+body{
+ margin:0; background:var(--bg); color:var(--fg);
+ font-family:var(--sans); line-height:1.62; -webkit-text-size-adjust:100%;
+}
+code,kbd,samp,pre,.mono,nav.main a,time,.k,h1.brand,h2.sec{font-family:var(--mono);}
+
+a{color:var(--accent); text-decoration:underline; text-underline-offset:2px; text-decoration-thickness:1px;}
+a:hover{color:var(--accent-hover);}
+a:visited{color:var(--accent);}
+
+.wrap{max-width:var(--measure); margin:0 auto; padding:0 1.4rem;}
+img{max-width:100%; height:auto;}
+hr{border:0; border-top:1px dashed var(--rule); margin:2rem 0;}
+
+/* ---- masthead ---- */
+header.site{padding:2.4rem 0 1.2rem;}
+h1.brand{font-size:1.5rem; margin:0; font-weight:700; letter-spacing:-.02em;}
+h1.brand a{color:var(--fg); text-decoration:none;}
+h1.brand .cur{color:var(--accent); font-weight:400;}
+header.site .tag{color:var(--muted); margin:.45rem 0 0; font-size:.98rem;}
+nav.main{margin-top:1.1rem; padding-top:1rem; border-top:1px dashed var(--rule);
+ display:flex; flex-wrap:wrap; gap:.4rem 1rem; font-size:.92rem;}
+nav.main a{color:var(--fg); text-decoration:none;}
+nav.main a::before{content:"["; color:var(--muted); margin-right:.15rem;}
+nav.main a::after{content:"]"; color:var(--muted); margin-left:.15rem;}
+nav.main a:hover{color:var(--accent);}
+nav.main a[aria-current="page"]{color:var(--accent);}
+
+main{padding:1.4rem 0 1rem;}
+section{margin:2.2rem 0;}
+h2.sec{font-size:.8rem; letter-spacing:.02em; color:var(--muted); font-weight:400; margin:0 0 .9rem;}
+h2.sec::before{content:"## "; color:var(--accent);}
+
+.intro p{margin:0 0 .9rem; font-size:1.06rem;}
+
+/* ---- post/note lists ---- */
+ul.posts{list-style:none; margin:0; padding:0; font-size:.98rem;}
+ul.posts li{display:flex; gap:1rem; align-items:baseline; padding:.34rem 0;}
+ul.posts time{color:var(--muted); font-size:.82rem; white-space:nowrap; min-width:6rem; font-variant-numeric:tabular-nums;}
+ul.posts a{text-decoration:none; color:var(--fg);}
+ul.posts a:hover{color:var(--accent); text-decoration:underline;}
+@media (max-width:30rem){ul.posts li{flex-direction:column; gap:.05rem;}}
+
+.tags{display:flex; flex-wrap:wrap; gap:.4rem; font-family:var(--mono); font-size:.85rem;}
+.tags a{text-decoration:none; color:var(--muted);}
+.tags a::before{content:"#";}
+.tags a:hover{color:var(--accent);}
+
+.cols{display:grid; grid-template-columns:1fr 1fr; gap:1.6rem 2rem;}
+@media (max-width:34rem){.cols{grid-template-columns:1fr;}}
+.links{list-style:none; margin:0; padding:0; font-size:.95rem;}
+.links li{padding:.24rem 0; display:flex; gap:.8rem;}
+.links .k{color:var(--muted); font-size:.8rem; min-width:4.6rem;}
+.links a{text-decoration:none;} .links a:hover{text-decoration:underline;}
+
+/* ---- long-form article ---- */
+article.post{font-size:1.08rem; line-height:1.7;}
+article.post .post-head{margin:0 0 2rem;}
+article.post h1{font-size:1.85rem; line-height:1.2; letter-spacing:-.02em; margin:.2rem 0 .5rem; font-family:var(--sans);}
+article.post .meta{color:var(--muted); font-size:.85rem; font-family:var(--mono);}
+article.post h2{font-size:1.3rem; margin:2.2rem 0 .7rem; letter-spacing:-.01em;}
+article.post h3{font-size:1.08rem; margin:1.8rem 0 .5rem;}
+article.post p{margin:0 0 1.1rem;}
+article.post ul,article.post ol{margin:0 0 1.1rem; padding-left:1.3rem;}
+article.post li{margin:.3rem 0;}
+article.post blockquote{margin:1.4rem 0; padding:.2rem 0 .2rem 1.1rem; border-left:3px solid var(--accent);
+ color:var(--muted); font-style:italic;}
+article.post code{background:var(--card); padding:.1rem .35rem; border-radius:3px; font-size:.9em;}
+article.post pre{background:var(--card); border:1px solid var(--rule); border-radius:6px;
+ padding:1rem 1.1rem; overflow-x:auto; font-size:.86rem; line-height:1.55;}
+article.post pre code{background:none; padding:0;}
+article.post a{text-underline-offset:3px;}
+.post-nav{display:flex; justify-content:space-between; gap:1rem; margin-top:2.4rem;
+ padding-top:1.2rem; border-top:1px dashed var(--rule); font-size:.9rem;}
+.post-nav a{text-decoration:none;}
+
+/* ---- notes / wiki ---- */
+.note-body{font-size:1.05rem; line-height:1.7;}
+.note-body a.wiki{text-decoration:none; border-bottom:1px solid var(--rule);}
+.note-body a.wiki:hover{border-bottom-color:var(--accent);}
+.backlinks{margin-top:2.4rem; padding-top:1.2rem; border-top:1px dashed var(--rule);}
+.backlinks h2{font-size:.8rem; font-family:var(--mono); color:var(--muted); font-weight:400; margin:0 0 .6rem;}
+.note-index{columns:2; column-gap:2rem; font-size:.98rem;}
+@media (max-width:34rem){.note-index{columns:1;}}
+.note-index a{display:block; padding:.2rem 0; text-decoration:none;}
+.note-index a:hover{text-decoration:underline;}
+
+/* ---- footer ---- */
+footer{border-top:1px dashed var(--rule); margin-top:2.4rem; padding:1.4rem 0 3rem;
+ color:var(--muted); font-size:.85rem;}
+.ring{display:flex; gap:.9rem; align-items:center; flex-wrap:wrap; margin-bottom:.7rem; font-family:var(--mono);}
+.ring a{text-decoration:none;}
+footer a{color:var(--muted);} footer a:hover{color:var(--accent);}
diff --git a/weblog/index.html b/weblog/index.html
new file mode 100644
index 0000000..4484805
--- /dev/null
+++ b/weblog/index.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Weblog — tjp.lol</title>
+<meta name="description" content="Weblog posts by tjp.">
+<link rel="stylesheet" href="../style.css">
+<link rel="alternate" type="application/atom+xml" title="tjp.lol" href="../feed.xml">
+</head>
+<body>
+<div class="wrap">
+
+ <header class="site">
+ <h1 class="brand"><a href="../index.html">~tjp<span class="cur"> $_</span></a></h1>
+ <nav class="main">
+ <a href="../weblog/index.html" aria-current="page">weblog</a>
+ <a href="../notes/index.html">notes</a>
+ <a href="../projects/index.html">projects</a>
+ <a href="../about/index.html">about</a>
+ <a href="../feed.xml">rss</a>
+ </nav>
+ </header>
+
+ <main>
+ <section class="intro">
+ <p>Longer-form writing. Most of these are migrating over from my Gemini capsule, so dates reflect the original posting. <a href="../feed.xml">Subscribe via RSS</a>.</p>
+ </section>
+
+ <section>
+ <h2 class="sec">2026</h2>
+ <ul class="posts">
+ <li><time datetime="2026-06-30">2026-06-30</time><a href="moving-off-gemini.html">Moving my weblog off Gemini (without leaving it)</a></li>
+ <li><time datetime="2026-05-18">2026-05-18</time><a href="moving-off-gemini.html">A static site generator in 100 lines of bash</a></li>
+ <li><time datetime="2026-04-02">2026-04-02</time><a href="moving-off-gemini.html">Syncthing is the personal cloud I actually wanted</a></li>
+ <li><time datetime="2026-02-14">2026-02-14</time><a href="moving-off-gemini.html">Notes on green-thread concurrency in Python</a></li>
+ <li><time datetime="2026-01-09">2026-01-09</time><a href="moving-off-gemini.html">The quiet case for plain HTML</a></li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 class="sec">2025</h2>
+ <ul class="posts">
+ <li><time datetime="2025-11-22">2025-11-22</time><a href="moving-off-gemini.html">A year of writing in plain text</a></li>
+ <li><time datetime="2025-09-03">2025-09-03</time><a href="moving-off-gemini.html">Reading the CPython source for fun</a></li>
+ <li><time datetime="2025-07-15">2025-07-15</time><a href="moving-off-gemini.html">Why I still like Makefiles</a></li>
+ </ul>
+ </section>
+ </main>
+
+ <footer>
+ <div class="ring">
+ <span>webring:</span>
+ <a href="#">&larr; prev</a><a href="#">rand</a><a href="#">next &rarr;</a>
+ </div>
+ <p>hand-written in slc &middot; &copy;2026 tjp &middot; <a href="../feed.xml">feed</a></p>
+ </footer>
+
+</div>
+</body>
+</html>
diff --git a/weblog/moving-off-gemini.html b/weblog/moving-off-gemini.html
new file mode 100644
index 0000000..f84a797
--- /dev/null
+++ b/weblog/moving-off-gemini.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Moving my weblog off Gemini (without leaving it) — tjp.lol</title>
+<meta name="description" content="How I mirror my Gemini capsule to the web without abandoning geminispace.">
+<link rel="stylesheet" href="../style.css">
+<link rel="alternate" type="application/atom+xml" title="tjp.lol" href="../feed.xml">
+</head>
+<body>
+<div class="wrap">
+
+ <header class="site">
+ <h1 class="brand"><a href="../index.html">~tjp<span class="cur"> $_</span></a></h1>
+ <nav class="main">
+ <a href="../weblog/index.html" aria-current="page">weblog</a>
+ <a href="../notes/index.html">notes</a>
+ <a href="../projects/index.html">projects</a>
+ <a href="../about/index.html">about</a>
+ <a href="../feed.xml">rss</a>
+ </nav>
+ </header>
+
+ <main>
+ <article class="post">
+ <div class="post-head">
+ <p class="meta"><a href="../weblog/index.html" style="text-decoration:none;">&larr; weblog</a> &nbsp;/&nbsp; <time datetime="2026-06-30">2026-06-30</time></p>
+ <h1>Moving my weblog off Gemini (without leaving it)</h1>
+ <div class="tags" style="margin-top:.5rem;">
+ <a href="../weblog/index.html">gemini</a><a href="../weblog/index.html">small-web</a><a href="../weblog/index.html">tools</a>
+ </div>
+ </div>
+
+ <p><em>This is placeholder text laid out so you can see how a full post reads. Replace it with the real <span class="mono">.gmi</span> content when you export the capsule &mdash; the structure below covers everything a converted Gemini post will need: headings, lists, quotes, code, and links.</em></p>
+
+ <p>I've kept a capsule at <code>gemini://gemini.ctrl-c.club/~tjp/</code> for a few years now. Gemini is a lovely place to write: the format is so constrained that there's nothing to fiddle with, and the whole thing rewards plain, linear prose. But I wanted a web presence too &mdash; one that fits in with the personal sites I keep finding in webrings.</p>
+
+ <h2>The plan</h2>
+ <p>The goal was never to abandon geminispace. It was to have both, from one source of truth. Gemtext is close enough to Markdown that a small converter gets you most of the way there:</p>
+
+ <ul>
+ <li>Keep writing <code>.gmi</code> files in my capsule.</li>
+ <li>Run a converter that emits HTML with the same structure.</li>
+ <li>Wrap each page in the site template you're reading now.</li>
+ </ul>
+
+ <h2>The converter</h2>
+ <p>Gemtext has exactly one block-level construct per line, which makes parsing almost insultingly simple. Here's the core of it:</p>
+
+ <pre><code>while IFS= read -r line; do
+ case "$line" in
+ "### "*) echo "&lt;h3&gt;${line#### }&lt;/h3&gt;" ;;
+ "## "*) echo "&lt;h2&gt;${line### }&lt;/h2&gt;" ;;
+ "# "*) echo "&lt;h1&gt;${line## }&lt;/h1&gt;" ;;
+ "=> "*) emit_link "$line" ;;
+ "* "*) emit_list_item "$line" ;;
+ "&gt; "*) echo "&lt;blockquote&gt;${line#&gt; }&lt;/blockquote&gt;" ;;
+ "") echo "" ;;
+ *) echo "&lt;p&gt;$line&lt;/p&gt;" ;;
+ esac
+done &lt; "$1"</code></pre>
+
+ <p>That's genuinely most of it. The fiddly parts are link lines (<code>=&gt;</code> can be a bare URL or have link text) and collapsing runs of <code>* </code> lines into a single <code>&lt;ul&gt;</code>.</p>
+
+ <blockquote>The best format is the one that outlives the tool you wrote it in. Gemtext will still be readable when this converter is long gone.</blockquote>
+
+ <h2>What I kept</h2>
+ <p>No client-side anything. The web version renders identically with JavaScript disabled, because there is no JavaScript to disable. Fonts are whatever your system already has. The whole page is a few kilobytes.</p>
+
+ <p>If you want the source, it lives with the rest of my <a href="../projects/index.html">projects</a>. And the capsule is still there &mdash; see the <a href="../about/index.html">colophon</a> for how it all fits together.</p>
+ </article>
+
+ <nav class="post-nav">
+ <a href="../weblog/index.html">&larr; Older</a>
+ <a href="../weblog/index.html">All posts</a>
+ <a href="../weblog/index.html">Newer &rarr;</a>
+ </nav>
+ </main>
+
+ <footer>
+ <div class="ring">
+ <span>webring:</span>
+ <a href="#">&larr; prev</a><a href="#">rand</a><a href="#">next &rarr;</a>
+ </div>
+ <p>hand-written in slc &middot; &copy;2026 tjp &middot; content <a href="../about/index.html">CC BY 4.0</a> &middot; <a href="../feed.xml">feed</a></p>
+ </footer>
+
+</div>
+</body>
+</html>