Public Access
Site: the posts link to each other wherever they refer to one; check_site.py checks every link of the site
Site / build (pull_request) Successful in 8s
Site / build (pull_request) Successful in 8s
"The last post" and "the first post" in the LoRa, Gemini and S1 posts are now links. check_site.py follows every link to another page of the site and the #fragment it names, so a broken one fails the Site job. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -90,6 +90,7 @@ Not one of the planned phases: the blog's seven roro9stack posts, imported into
|
|||||||
- **The posts' parts** (the sign, the cast, the steps, asides, folded sections, diagrams, captions) are shortcodes in `site/templates/shortcodes/`, restyled in `static/css/devlog.css`: the site's palette, notched boxes, DM Mono and Hanken Grotesk. The two older posts about other subjects (a vinyl remote, a ZFS rescue) stay on the blog.
|
- **The posts' parts** (the sign, the cast, the steps, asides, folded sections, diagrams, captions) are shortcodes in `site/templates/shortcodes/`, restyled in `static/css/devlog.css`: the site's palette, notched boxes, DM Mono and Hanken Grotesk. The two older posts about other subjects (a vinyl remote, a ZFS rescue) stay on the blog.
|
||||||
- **The 17 diagrams are inline SVG**, and carried `<style>` blocks and `style` attributes that the site's Content-Security-Policy refuses. Their rules moved to `static/css/devlog-diagrams.css` (one block per diagram, plus colour classes for what the attributes did), and a diagram's minimum width is a class, not a style attribute. The Caddy policy needs no change.
|
- **The 17 diagrams are inline SVG**, and carried `<style>` blocks and `style` attributes that the site's Content-Security-Policy refuses. Their rules moved to `static/css/devlog-diagrams.css` (one block per diagram, plus colour classes for what the attributes did), and a diagram's minimum width is a class, not a style attribute. The Caddy policy needs no change.
|
||||||
- **The diagrams' four colours** (red, green, yellow, accent) are defined for `.devlog` on the site's RGB332 grid, one value for each theme.
|
- **The diagrams' four colours** (red, green, yellow, accent) are defined for `.devlog` on the site's RGB332 grid, one value for each theme.
|
||||||
|
- **Links between posts:** every post's reference to another ("the last post", "the first post", the milestone lists) is a link to it. `check_site.py` now also checks every link inside the site, and its #fragment: a broken one fails the Site job. External links are checked by `zola check` run by hand (without `--skip-external-links`, which CI uses); its only complaints today are line-range and heading anchors on Gitea, which Gitea resolves in the browser.
|
||||||
- **An Atom feed** at `/devlog/atom.xml`, linked from every devlog page.
|
- **An Atom feed** at `/devlog/atom.xml`, linked from every devlog page.
|
||||||
- **Checked** in Chromium with the production CSP applied to every response: the index and the seven posts, at 1100 and 390 px, no policy violation, no broken image, no sideways scroll; `check_site.py` 24 pages, 0 problems.
|
- **Checked** in Chromium with the production CSP applied to every response: the index and the seven posts, at 1100 and 390 px, no policy violation, no broken image, no sideways scroll; `check_site.py` 24 pages, 0 problems.
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ The start page lists them by capsule, newest first. Inside a Saved Page, a link
|
|||||||
|
|
||||||
## Where it hurt
|
## Where it hurt
|
||||||
|
|
||||||
- **The bug from the first post, again.** A status message ("Not Gemini: https://…") was cleared before it was ever drawn. It was timestamped with `millis()` after the main loop had read the clock, and in unsigned arithmetic, three milliseconds in the future is 49 days ago. That's exactly the bug that made toasts expire before they appeared in [the first post](/devlog/roro9stack/). Same fix: a signed comparison. Same feeling.
|
- **The bug from [the first post](/devlog/roro9stack/), again.** A status message ("Not Gemini: https://…") was cleared before it was ever drawn. It was timestamped with `millis()` after the main loop had read the clock, and in unsigned arithmetic, three milliseconds in the future is 49 days ago. That's exactly the bug that made toasts expire before they appeared in [the first post](/devlog/roro9stack/). Same fix: a signed comparison. Same feeling.
|
||||||
- **A space where none was.** Wrapped link and list lines are indented, which meant re-wrapping their continuation rows. I rebuilt that text by joining the rows with spaces, so a long URL the first wrap had cut mid-word came out as `faq.gm i`. Now it re-wraps the exact rest of the line.
|
- **A space where none was.** Wrapped link and list lines are indented, which meant re-wrapping their continuation rows. I rebuilt that text by joining the rows with spaces, so a long URL the first wrap had cut mid-word came out as `faq.gm i`. Now it re-wraps the exact rest of the line.
|
||||||
- **436 bytes.** The refresh job needed to know where a Saved Page came from, so it opened the page, all of it, next to the App's copy of the same page and a fresh TLS connection. The heap's lowest point since boot: 436 bytes. Nothing crashed, which is less reassuring than it sounds. Now it reads one line, and the 55 KB start floor guards every fetch, not just the ones the App asks for. Lowest afterwards: 53.8 KB.
|
- **436 bytes.** The refresh job needed to know where a Saved Page came from, so it opened the page, all of it, next to the App's copy of the same page and a fresh TLS connection. The heap's lowest point since boot: 436 bytes. Nothing crashed, which is less reassuring than it sounds. Now it reads one line, and the 55 KB start floor guards every fetch, not just the ones the App asks for. Lowest afterwards: 53.8 KB.
|
||||||
- **The same trap, twice in one milestone.** A forward declaration of `NetworkClientSecure` inside the project's namespace declares a different class that doesn't exist. I'd made that mistake in the Debug Console two milestones ago. I made it again, in the same way, and the compiler explained it again, at the same length.
|
- **The same trap, twice in one milestone.** A forward declaration of `NetworkClientSecure` inside the project's namespace declares a different class that doesn't exist. I'd made that mistake in the Debug Console two milestones ago. I made it again, in the same way, and the compiler explained it again, at the same length.
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ text = "Broadband, flat across the band, with a comb of steady spikes on top. It
|
|||||||
|
|
||||||
## Why it only listens
|
## Why it only listens
|
||||||
|
|
||||||
The plan from the first post put the mesh last: receiving in M4, transmitting in M5, and before that, a second Meshtastic device to test against. M3 is the radio coming up: drivers, the shared bus, a scanner. It needs nothing to talk to, which is lucky, because I have nothing to talk to.
|
The plan from [the first post](/devlog/roro9stack/) put the mesh last: receiving in M4, transmitting in M5, and before that, a second Meshtastic device to test against. M3 is the radio coming up: drivers, the shared bus, a scanner. It needs nothing to talk to, which is lucky, because I have nothing to talk to.
|
||||||
|
|
||||||
I checked anyway. meshmap.net publishes the nodes that report to the internet. I downloaded the whole list and filtered it on my own machine, so my position stayed home. Within 10 km of my desk there are two nodes; within 30 km, six, all seen that day. The map blurs positions by a few kilometres, and it only shows nodes that report online, so there are probably more. Whether any of them reaches a small antenna in a city is another question. Spoiler: no.
|
I checked anyway. meshmap.net publishes the nodes that report to the internet. I downloaded the whole list and filtered it on my own machine, so my position stayed home. Within 10 km of my desk there are two nodes; within 30 km, six, all seen that day. The map blurs positions by a few kilometres, and it only shows nodes that report online, so there are probably more. Whether any of them reaches a small antenna in a city is another question. Spoiler: no.
|
||||||
|
|
||||||
|
|||||||
@@ -57,11 +57,11 @@ So the backlog got sorted into milestones, and the first one is the boring kind:
|
|||||||
|
|
||||||
## The card that never said no
|
## The card that never said no
|
||||||
|
|
||||||
The last post left it here: about once in five uploads, a write to the card failed, the retry code papered over the hole with zeros, and I'd fixed the papering but not the failing. The card "refuses a write now and then", I wrote, like it was weather.
|
[The last post](/devlog/roro9stack-lora/) left it here: about once in five uploads, a write to the card failed, the retry code papered over the hole with zeros, and I'd fixed the papering but not the failing. The card "refuses a write now and then", I wrote, like it was weather.
|
||||||
|
|
||||||
### Four wrong guesses
|
### Four wrong guesses
|
||||||
|
|
||||||
**The radio.** It shares the card's SPI bus, so it was the obvious suspect. Cleared in the last post: the failures came with the radio asleep too.
|
**The radio.** It shares the card's SPI bus, so it was the obvious suspect. Cleared in [the last post](/devlog/roro9stack-lora/): the failures came with the radio asleep too.
|
||||||
|
|
||||||
**A timeout.** The driver waits at most 500 ms for a busy card and then gives up, silently. Cards do pause for housekeeping. So I timed every write. Good ones took up to 107 ms. The failing one came back after **4 ms**. Nothing waits 500 ms in 4 ms.
|
**A timeout.** The driver waits at most 500 ms for a busy card and then gives up, silently. Cards do pause for housekeeping. So I timed every write. Good ones took up to 107 ms. The failing one came back after **4 ms**. Nothing waits 500 ms in 4 ms.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Usage: site/tools/check_site.py <built site folder>
|
|||||||
from elsewhere (links a visitor follows are fine).
|
from elsewhere (links a visitor follows are fine).
|
||||||
- Every page has a title, a description and a language, and every image has an alt attribute.
|
- Every page has a title, a description and a language, and every image has an alt attribute.
|
||||||
- Every file a page references on this site exists.
|
- Every file a page references on this site exists.
|
||||||
|
- Every link to another page of the site lands on a page that exists, and so does the #fragment it names.
|
||||||
"""
|
"""
|
||||||
import html.parser
|
import html.parser
|
||||||
import os
|
import os
|
||||||
@@ -29,9 +30,16 @@ class Page(html.parser.HTMLParser):
|
|||||||
self.lang = None
|
self.lang = None
|
||||||
self.loads = [] # (tag, url) of everything the page fetches by itself
|
self.loads = [] # (tag, url) of everything the page fetches by itself
|
||||||
self.images_without_alt = 0
|
self.images_without_alt = 0
|
||||||
|
self.links = [] # href of every <a>
|
||||||
|
self.ids = set() # every id and name: what a #fragment can name
|
||||||
|
|
||||||
def handle_starttag(self, tag, attrs):
|
def handle_starttag(self, tag, attrs):
|
||||||
a = dict(attrs)
|
a = dict(attrs)
|
||||||
|
for key in ("id", "name"):
|
||||||
|
if a.get(key):
|
||||||
|
self.ids.add(a[key])
|
||||||
|
if tag == "a" and a.get("href"):
|
||||||
|
self.links.append(a["href"])
|
||||||
if tag == "html":
|
if tag == "html":
|
||||||
self.lang = a.get("lang")
|
self.lang = a.get("lang")
|
||||||
if tag == "title":
|
if tag == "title":
|
||||||
@@ -56,6 +64,7 @@ class Page(html.parser.HTMLParser):
|
|||||||
|
|
||||||
problems = []
|
problems = []
|
||||||
pages = 0
|
pages = 0
|
||||||
|
parsed = {} # path of a page, from the site's root -> its Page
|
||||||
for folder, _, files in os.walk(ROOT):
|
for folder, _, files in os.walk(ROOT):
|
||||||
for name in files:
|
for name in files:
|
||||||
if not name.endswith(".html"):
|
if not name.endswith(".html"):
|
||||||
@@ -65,6 +74,7 @@ for folder, _, files in os.walk(ROOT):
|
|||||||
p = Page()
|
p = Page()
|
||||||
p.feed(open(path, encoding="utf-8").read())
|
p.feed(open(path, encoding="utf-8").read())
|
||||||
pages += 1
|
pages += 1
|
||||||
|
parsed["/" + shown.replace(os.sep, "/")] = (p, folder)
|
||||||
if not p.has_title: problems.append(f"{shown}: no title")
|
if not p.has_title: problems.append(f"{shown}: no title")
|
||||||
if not p.has_description: problems.append(f"{shown}: no description")
|
if not p.has_description: problems.append(f"{shown}: no description")
|
||||||
if not p.lang: problems.append(f"{shown}: no language")
|
if not p.lang: problems.append(f"{shown}: no language")
|
||||||
@@ -82,6 +92,31 @@ for folder, _, files in os.walk(ROOT):
|
|||||||
if not os.path.exists(target):
|
if not os.path.exists(target):
|
||||||
problems.append(f"{shown}: {tag} {url} does not exist")
|
problems.append(f"{shown}: {tag} {url} does not exist")
|
||||||
|
|
||||||
|
def page_of(url_path):
|
||||||
|
"""The built page a path of the site names: /guide/ is /guide/index.html."""
|
||||||
|
if url_path.endswith("/"):
|
||||||
|
return url_path + "index.html"
|
||||||
|
if os.path.isdir(os.path.join(ROOT, url_path.lstrip("/"))):
|
||||||
|
return url_path + "/index.html"
|
||||||
|
return url_path
|
||||||
|
|
||||||
|
|
||||||
|
for shown_path, (p, folder) in parsed.items():
|
||||||
|
for href in p.links:
|
||||||
|
u = urllib.parse.urlparse(href)
|
||||||
|
if u.scheme in ("mailto", "tel") or (u.netloc and u.netloc != HOST):
|
||||||
|
continue
|
||||||
|
if not u.path: # "#fragment": this page
|
||||||
|
target = shown_path
|
||||||
|
elif u.path.startswith("/"):
|
||||||
|
target = page_of(urllib.parse.unquote(u.path))
|
||||||
|
else:
|
||||||
|
target = page_of(os.path.normpath(os.path.join(os.path.dirname(shown_path), urllib.parse.unquote(u.path))).replace(os.sep, "/"))
|
||||||
|
if not os.path.exists(os.path.join(ROOT, target.lstrip("/"))):
|
||||||
|
problems.append(f"{shown_path.lstrip('/')}: link {href} goes nowhere")
|
||||||
|
elif u.fragment and target in parsed and u.fragment not in parsed[target][0].ids:
|
||||||
|
problems.append(f"{shown_path.lstrip('/')}: link {href}: no #{u.fragment} on that page")
|
||||||
|
|
||||||
if not pages:
|
if not pages:
|
||||||
problems.append(f"no pages found in {ROOT}")
|
problems.append(f"no pages found in {ROOT}")
|
||||||
for p in problems:
|
for p in problems:
|
||||||
|
|||||||
Reference in New Issue
Block a user