fuzzyworld-tools / commit
Writing in the site-wide nav; the blog shows the site bar (config "site")
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")