Restore gallery captions and items the conversion flattened - #55
Conversation
Closes#52. A gallery is a flex row whose column widths come from `.gallery-cols-N figure`, so anything inside it that is not a figure gets no width from that rule and is laid out as one more item in the row. Three variants of that survived the conversion, and no gate could see any of them: every media check reasons about a URL, and misplaced content resolves and is linked exactly as correct content is. Each was verified against the captured live site before being changed rather than inferred from the markup, and the first is why that matters. Eleven captions in the FLAIR post were written as text trailing the last figure shortcode. Reading only the source, the obvious fix is to move the text into that figure's caption parameter, which is what the review suggested. The capture shows all eleven were blocks-gallery-caption elements, captions for the set, so that fix would have attributed a caption describing four images to whichever one sorted last and would have looked right. They move to a caption on the gallery instead. The other two were gallery items the conversion emitted as plain markdown, two bare images and eight linked ones, against li.blocks-gallery-item > figure in the original. Both become figure shortcodes, the linked ones through the parameter the theme already has, which puts the anchor back inside the figure where it was. The container becomes a figure, because figcaption is only valid as a figure's child. The reset already sets figure { margin: 0 } and the theme already styles figure > figcaption, so a set caption needed one rule: a full-width flex basis, without which it packs onto the end of the last row and reads as a caption for whichever image it lands beside. check-url-parity.py gains the gate that would have caught all of this, parsing the built pages rather than matching text, since nested figures defeat a regex. Demonstrated failing before being trusted: against the pre-fix content it reports 27 stray nodes and exits 1, and it distinguishes the shapes rather than counting them, naming bare text and each stray element type. The gate found more than the review did. The linked-image variant is on a post neither the review nor the issue mentioned, and it was found only by running the check across all 19 pages that carry a gallery. Verified through make-release.sh and against the local mirror: PASS - 1245 URLs honored, with assets at 1049 and orphans at 98, both unmoved, so no media was lost or newly orphaned by the rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Restores WordPress-conversion-flattened gallery structure (set-level captions and non-figure gallery items) and adds a gate to prevent future regressions by validating the built HTML structure of galleries.
Changes:
- Update the
galleryshortcode to render as a<figure>with an optional gallery-level<figcaption>. - Convert affected posts’ stray gallery children (trailing caption text, bare images, linked images) into proper
gallery/figureshortcode structure. - Extend
check-url-parity.pywith an HTML-structure check that flags any non-figure/figcaptiondirect children inside a gallery, plus supporting docs and CSS.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
layouts/shortcodes/gallery.html | Switch gallery wrapper to <figure> and add optional gallery-level caption rendering. |
assets/css/extended/custom.css | Ensure gallery-level captions take full width in the flex layout. |
checks/check-url-parity.py | Add gallery structure validation by parsing built HTML and reporting stray nodes. |
checks/README.md | Document the new gallery structural check and the conversion artifact shapes it detects. |
content/posts/2022/10/30/installing-flair-smart-vents-to-keep-room-temperatures-balanced.md | Move gallery captions from trailing text into gallery shortcode caption. |
content/posts/2020/06/21/moving-from-unraid-to-proxmox-ve.md | Convert linked markdown images inside a gallery into figure shortcodes with link=. |
content/posts/2020/02/02/recovering-the-firmware-on-a-supermicro-bpn-sas3-846el1-backplane.md | Convert bare markdown images inside a gallery into figure shortcodes. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Both from review on #55. The cheap reject read `class="gallery`, which assumes the quotes survive and that the token sorts first. Minification drops the quotes around a value that does not need one and says nothing about class order, so `class=gallery` and `class="foo gallery"` both skipped the parser entirely and the gate passed vacuously on a page whose markup was merely spelled differently. It matches the bare word now, which cannot skip a page the parser would find, since the parser requires the class token and a page carrying it always contains the string. Matching a page that only mentions the word costs one parse. The printed count is now taken from what the parser found rather than from the reject, so the broader test does not turn "pages carrying a gallery" into "pages the word appears on". The shortcode comment claimed a set caption needed no rule of its own, while this branch adds one. It does need it, and for a reason worth keeping: the gallery is a flex container, so without a full-width basis the caption packs onto the end of the last row and reads as a caption for whichever image it lands beside. The comment now says so and says not to remove the rule as redundant. Verified on all three spellings, quoted, unquoted, and reordered, each carrying a known stray node: all three are now detected, and the first two were not before. The site itself is unchanged at 19 pages with galleries and 0 stray nodes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
checks/check-url-parity.py:200
- The
GalleryScandocstring says this logic catches “both conversion artifacts”, butchecks/README.md(and the PR description) describe three distinct markdown shapes (trailing caption text, bare, and linked[](...)). Consider updating this docstring to match that 3-shape framing (or explicitly explain that the scanner sees two HTML-level shapes but they originate from three markdown patterns) so future readers aren’t confused about what the check is intended to detect.
A gallery is a flex row of figures, so its column widths are set by `.gallery-cols-N figure`.
Anything else landing in there is not laid out by that rule and is rendered as one more item
in the row, which is how both conversion artifacts this catches presented: a set caption
written as trailing text after a figure shortcode became a bare text node, and a gallery item
the conversion emitted as a plain markdown image became a `<p>` wrapping two images.
From a suppressed finding on #55, which was right: the docstring said "both conversion artifacts" while the README and the description say three. It was written after two were known and never revised when the check itself found the third. Rewritten to say what it actually tests. Three source patterns reached it and they arrive as three different HTML shapes, so an enumeration is a list to extend every time the conversion surprises us again. The invariant is that a gallery holds figures and its own caption, and that is what covers the shape nobody has thought of yet, which is how the third was found after the first two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ptr727
commented
Aug 8, 2026
Answering the suppressed finding from the round on
Correct, and it was stale rather than a different framing: I wrote that docstring when two shapes were known, and never revised it when the check itself found the third on a post neither the review nor #52 had mentioned. Fixed in |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
checks/check-url-parity.py:243
- GalleryScan doesn’t implement HTMLParser.handle_startendtag, so XHTML-style void tags like
<br/>or<img/>won’t be reported as stray direct children (and won’t participate in depth tracking). That can let a gallery-structure regression slip through if the HTML serializer emits self-closing void tags.
def handle_starttag(self, tag, attrs):
classes = dict(attrs).get("class", "").split()
if self.depth is None:
if tag == "figure" and "gallery" in classes:
self.depth = 0
From a suppressed finding on #55, which reasoned correctly from the code and reached the wrong conclusion about the base class. HTMLParser forwards a self-closing tag to handle_starttag and then handle_endtag, so `<br/>`, `<br />` and `<img/>` are already reported as stray children and already leave the depth balanced. Verified on those three spellings and on a self-closing non-void `<figure/>`, which enters and exits correctly and lets the `<p>` after it be seen. Adding the override the finding asks for is what would break this, by counting a pair the base class already splits. That is worth a comment rather than a reply nobody reading the file later will find. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ptr727
commented
Aug 8, 2026
Answering the suppressed finding from the round on
Refuted, by running it rather than reading it.
Adding the override is in fact what would break it: The reasoning was sound and only the base class's behaviour was wrong, which is worth more than a reply nobody reading the file later will find, so |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
checks/check-url-parity.py:304
- The new gallery check returns a list of "stray nodes", but the shared failure summary prints
FAIL gallery: N missing, which is misleading when this check fails (the items aren’t missing). Consider special-casing the summary noun for the gallery label so failures read correctly without changing other checks’ wording.
("media", check_media(public)),
("assets", check_assets(public, refs)),
("orphans", check_orphans(public, refs)),
("gallery", check_galleries(public)),
):
From a suppressed finding on #55. The shared summary printed `FAIL gallery: N missing` for a check whose findings are nodes that are present, which is the entire complaint about them. Each label can now name its own noun, defaulting to "missing" so the four older checks are unchanged and a check added later reads the way they do unless it says otherwise. Verified by forcing a failure: `FAIL gallery: 15 stray nodes`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ptr727
commented
Aug 8, 2026
Answering the suppressed finding from the round on
Correct, and fixed in Taking the special-case shape you suggested. Each label can name its own noun and the default stays Verified by forcing a failure rather than reading the format string: |
Uh oh!
There was an error while loading. Please reload this page.
The gallery caption entry under Open decisions is gone: #55 answered the question it posed, against the captured live site, and closed#52 with it. Two findings from the VPS agent's §23.3 and §24.3 replace it. The robots.txt entry described a gap on the interim name and missed that the cutover makes it a loss. The live .com blog serves a robots.txt today carrying a Sitemap: line, and this site emits none, so M7b moves a site that has had crawl directives for years to having none and takes the sitemap pointer with them. That side found it from the outside, will not put a file in this bundle, and has made it block step 1 of the M7b checklist rather than be discovered after it. Recorded as deliberately undecided with the three options, and Open decisions points at it rather than restating it, so the two cannot drift. Nothing checks that media survived the trip to the server. A 3,095-request gate run fetched no image at all, which prompted the question of whether the legacy media list is wired in. It is, at build time, against files on disk. The live check requests pages and redirects and never an image, so a media tree lost between the build and the server is caught by neither, the build having passed before the loss and the live gate never asking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Record operations as recurring work, not as a finished milestone The backlog listed the log review and the off-host log pull among the one-off migration tasks, so a reader finishing that list would read the migration as done while neither duty had ever run on its cadence. Both move to a Recurring operations section that says outright that everything above it ends and this does not, and the State table gains an Operations row so the summary stops implying the pipeline is the whole system. The open decision behind them is that the tooling has no home in git. vps-backup-pull, its systemd units, and the variables naming both ends of the copy exist only on the Proxmox host, which is the host the backup runs from, so losing it loses both the copies and the means of making them. Two candidate homes are recorded without choosing between them. The same question covers the fourteen migration scripts in the capture directory, and one of them already shows the cost of leaving it open: build-redirects.py exists in three places, the two capture copies identical to each other at 115 lines and this repo's maintained copy at 225. The capture is not a git repository, so nothing detects that. Two open questions are closed with measurements rather than assumptions. The production environment's HUGO_BASEURL held the live WordPress address, which the deploy workflow both builds with and points the live check at, and it is now the interim name. And the first production deploy does not fix the robots.txt 404 the VPS agent flagged in its 22.10: hugo.yaml sets no enableRobotsTXT so the site emits none at all, while sitemap.xml is emitted and becomes fetchable at that same deploy. The VPS production row said the resource was deliberately disabled, which its own file contradicted twenty lines later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Record the robots.txt cutover loss and the media gate gap The gallery caption entry under Open decisions is gone: #55 answered the question it posed, against the captured live site, and closed#52 with it. Two findings from the VPS agent's §23.3 and §24.3 replace it. The robots.txt entry described a gap on the interim name and missed that the cutover makes it a loss. The live .com blog serves a robots.txt today carrying a Sitemap: line, and this site emits none, so M7b moves a site that has had crawl directives for years to having none and takes the sitemap pointer with them. That side found it from the outside, will not put a file in this bundle, and has made it block step 1 of the M7b checklist rather than be discovered after it. Recorded as deliberately undecided with the three options, and Open decisions points at it rather than restating it, so the two cannot drift. Nothing checks that media survived the trip to the server. A 3,095-request gate run fetched no image at all, which prompted the question of whether the legacy media list is wired in. It is, at build time, against files on disk. The live check requests pages and redirects and never an image, so a media tree lost between the build and the server is caught by neither, the build having passed before the loss and the live gate never asking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The table said nothing about whether the conversion lost anything, which is the one question a migration's record has to answer and the one a reader cannot check for themselves. Rebased onto develop and reconciled with two things that landed while this sat open. The volatile counts it originally carried in the Content and media row are gone, because develop deliberately replaced counts that content additions move with what they represent, and this row would have reintroduced 514 pages against that decision. And the claim is narrowed from "closed" to closed for the files themselves: the image loss was 19 and is restored, but #55 later found the conversion had also flattened how galleries present media, in three shapes across three posts. That was not a file loss and no file was missing, which is exactly why nothing saw it for so long, and it is now gated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The table said nothing about whether the conversion lost anything, which is the one question a migration's record has to answer and the one a reader cannot check for themselves. Rebased onto develop and reconciled with two things that landed while this sat open. The volatile counts it originally carried in the Content and media row are gone, because develop deliberately replaced counts that content additions move with what they represent, and this row would have reintroduced 514 pages against that decision. And the claim is narrowed from "closed" to closed for the files themselves: the image loss was 19 and is restored, but #55 later found the conversion had also flattened how galleries present media, in three shapes across three posts. That was not a file loss and no file was missing, which is exactly why nothing saw it for so long, and it is now gated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The table said nothing about whether the conversion lost anything, which is the one question a migration's record has to answer and the one a reader cannot check for themselves. Rebased onto develop and reconciled with two things that landed while this sat open. The volatile counts it originally carried in the Content and media row are gone, because develop deliberately replaced counts that content additions move with what they represent, and this row would have reintroduced 514 pages against that decision. And the claim is narrowed from "closed" to closed for the files themselves: the image loss was 19 and is restored, but #55 later found the conversion had also flattened how galleries present media, in three shapes across three posts. That was not a file loss and no file was missing, which is exactly why nothing saw it for so long, and it is now gated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The table said nothing about whether the conversion lost anything, which is the one question a migration's record has to answer and the one a reader cannot check for themselves. Rebased onto develop and reconciled with two things that landed while this sat open. The volatile counts it originally carried in the Content and media row are gone, because develop deliberately replaced counts that content additions move with what they represent, and this row would have reintroduced 514 pages against that decision. And the claim is narrowed from "closed" to closed for the files themselves: the image loss was 19 and is restored, but #55 later found the conversion had also flattened how galleries present media, in three shapes across three posts. That was not a file loss and no file was missing, which is exactly why nothing saw it for so long, and it is now gated. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes#52.
What was wrong
A gallery is a flex row whose column widths come from
.gallery-cols-N figure. Anything inside it that is not afiguregets no width from that rule and is laid out as one more item in the row. Three variants of that survived the WordPress conversion, and no gate could see any of them: every media check reasons about a URL, and content misplaced inside a gallery resolves and is linked exactly as correct content is.figureshortcode's}}<figcaption class="blocks-gallery-caption">— a caption for the setimage<li class="blocks-gallery-item"><figure>[](…)image<a>inside the figureWhy the capture was consulted rather than the markup
The first shape is why this is not a mechanical rewrite. Reading only the source, the obvious fix is to move the trailing text into that figure's
captionparameter — which is what the review on #51 suggested, three times, and what #52 recorded as an open question.The captured live site settles it: all eleven were
blocks-gallery-captionelements, and the repo's eleven trailing texts match them exactly, one for one, with nothing on either side unaccounted for. They are captions for the set. Moving one into the last figure would have attributed a caption describing four images to whichever image sorted last, and it would have looked correct.So the captions move to a
captionon the gallery, and the container becomes a<figure>, becausefigcaptionis only valid as a figure's child. The reset already setsfigure { margin: 0 }and the theme already stylesfigure > figcaption, so a set caption needed exactly one rule of its own: a full-width flex basis, without which it packs onto the end of the last row and reads as a caption for whichever image it lands beside.The other two shapes become
figureshortcodes, the linked ones through thelinkparameter the theme already has, which puts the anchor back inside the figure where the original had it.The gate
check-url-parity.pygains the check that would have caught all of this. It parses the built pages rather than matching text, because nested<figure>elements defeat a regex, and it fails on any direct child of a gallery that is not afigureor the gallery's ownfigcaption.Demonstrated failing before being trusted. Against the pre-fix content it reports 27 stray nodes and exits 1. It distinguishes the shapes rather than counting them, naming bare text and each stray element type separately, so a future finding says what kind it is.
The gate found more than the review did. The linked-image variant is on
moving-from-unraid-to-proxmox-ve, a post neither the Copilot review nor #52 mentions — it surfaced only by running the check across all 19 pages that carry a gallery. #52's own measurement of "12 across 2 pages" was low for the same reason: it looked for stray text, and eight of these are stray elements.Verification
Released through
make-release.shto the local mirror and checked against the running server: PASS - 1245 URLs honored, rules confirmed from the release just installed.assetsstays at 1049 andorphansstays at 98, both unmoved, which is the evidence that the rewrite lost no media and orphaned none. Build clean underhugo --gc --minify --panicOnWarning.markdownlint-cli2andeditorconfig-checkerclean on every file this touches. The MD012 warnings inside the edited post are pre-existing conversion artifacts, unchanged in count at 9 before and after, andcontent/**is excluded from the CI markdown glob by design.🤖 Generated with Claude Code