fix(w3c): feed + article remainder (S8-S10, F13, F14) — strict ratchet #25

Merged
michalvankodev merged 3 commits from w3c-feed-p-figure into w3c-html-fixes 2026-09-14 21:06:33 +02:00

Implements the remainder of specs/w3c-validation.md: S8 (<p><figure> restructure), S9 (feed iframe policy), S10 (CharacterData allowlist), the strict-by-default ratchet — plus two pre-existing bugs the work exposed (F13, F14). Stacked on #24 (w3c-html-fixes).

S8 — BlockFigureParagraphGate (src/filters/markdown.rs)

Streaming event filter wrapping the pulldown-cmark output:

  • <p> emission is deferred until inline content actually arrives → image-only paragraphs emit a bare <figure> at block level (no <p><figure>…</figure></p>)
  • A figure starting mid-paragraph closes the <p> first; trailing inline content reopens a fresh one (figures render block-level anyway, so visuals are unchanged)
  • Whitespace directly after a figure is dropped (no empty <p> materializes)
  • Fenced code needed no gating — pulldown-cmark never nests CodeBlock inside Paragraph events; only images were affected

F13 — external-image markup mismatch (pre-existing, exposed by S8)

Start(Tag::Image) renders non-/ URLs as a bare <img …>, but End(TagEnd::Image) unconditionally emitted </figcaption></figure> — plus the alt text leaked as visible duplicate text. Now in_bare_img state suppresses both for that path (title already lives in the alt attribute).

F14 — raw & in hand-built attributes

  • External image srcs like …?u=x&f=1&nofb=1 were interpolated verbatim into formatdoc! tags → invalid HTML
  • The S9 link-card href had the same issue: lol_html get_attribute() returns serialized values, before() inserts raw HTML
  • Fixed with escape_attr (markdown.rs) and entity-aware escape_raw_ampersands (feed.rs — never double-escapes existing &amp;)

S9 — feed embed policy

Every <iframe> in feed content becomes a host-labeled link card — ▶ watch on Twitch / ▶ watch on YouTube / ▶ listen on Spotify / ▶ open the embedded content — with the src absolutized; srcless shells dropped. The site keeps the live embeds (feed-only policy).

S10 + ratchet

CharacterData allowlisted (escape-form style preference, both well-formed XML). validate-feed is now strict by default (LAX=1 drops back to errors-only for investigating new warnings).

Verification

  • cargo test: 60 passed — 8 new markdown-shape tests, 3 iframe-policy tests, and corpus-wide audits (no <iframe>, no <p>-nested blocks in any rendered post)
  • just validate-feed local: VALID — zero non-allowlisted warnings, strict mode green
  • just validate-html local: 0 errors on all 5 default pages (incl. the article page that carried the F8 stray </p>)
  • lxml strict-parse of all 41 feed items: no entity/structure errors
  • Article screenshots: figures render with proper spacing; devbreak pages keep their Twitch embeds
Implements the remainder of `specs/w3c-validation.md`: S8 (`<p><figure>` restructure), S9 (feed iframe policy), S10 (CharacterData allowlist), the strict-by-default ratchet — plus two pre-existing bugs the work exposed (F13, F14). Stacked on #24 (`w3c-html-fixes`). ## S8 — `BlockFigureParagraphGate` (src/filters/markdown.rs) Streaming event filter wrapping the pulldown-cmark output: - `<p>` emission is deferred until inline content actually arrives → image-only paragraphs emit a bare `<figure>` at block level (no `<p><figure>…</figure></p>`) - A figure starting mid-paragraph closes the `<p>` first; trailing inline content reopens a fresh one (figures render block-level anyway, so visuals are unchanged) - Whitespace directly after a figure is dropped (no empty `<p>` materializes) - Fenced code needed no gating — pulldown-cmark never nests `CodeBlock` inside `Paragraph` events; only images were affected ## F13 — external-image markup mismatch (pre-existing, exposed by S8) `Start(Tag::Image)` renders non-`/` URLs as a bare `<img …>`, but `End(TagEnd::Image)` unconditionally emitted `</figcaption></figure>` — plus the alt text leaked as visible duplicate text. Now `in_bare_img` state suppresses both for that path (title already lives in the `alt` attribute). ## F14 — raw `&` in hand-built attributes - External image `src`s like `…?u=x&f=1&nofb=1` were interpolated verbatim into `formatdoc!` tags → invalid HTML - The S9 link-card `href` had the same issue: lol_html `get_attribute()` returns serialized values, `before()` inserts raw HTML - Fixed with `escape_attr` (markdown.rs) and entity-aware `escape_raw_ampersands` (feed.rs — never double-escapes existing `&amp;`) ## S9 — feed embed policy Every `<iframe>` in feed content becomes a host-labeled link card — `▶ watch on Twitch` / `▶ watch on YouTube` / `▶ listen on Spotify` / `▶ open the embedded content` — with the src absolutized; srcless shells dropped. The site keeps the live embeds (feed-only policy). ## S10 + ratchet `CharacterData` allowlisted (escape-form style preference, both well-formed XML). `validate-feed` is now **strict by default** (`LAX=1` drops back to errors-only for investigating new warnings). ## Verification - `cargo test`: 60 passed — 8 new markdown-shape tests, 3 iframe-policy tests, and corpus-wide audits (no `<iframe>`, no `<p>`-nested blocks in any rendered post) - `just validate-feed local`: **VALID — zero non-allowlisted warnings, strict mode green** - `just validate-html local`: **0 errors on all 5 default pages** (incl. the article page that carried the F8 stray `</p>`) - lxml strict-parse of all 41 feed items: no entity/structure errors - Article screenshots: figures render with proper spacing; devbreak pages keep their Twitch embeds
fix(w3c): clear remaining feed+article findings (S8-S10, F13, F14)
All checks were successful
preview / preview hostname (push) Successful in 0s
preview / build + deploy preview (push) Has been skipped
preview / teardown preview (push) Has been skipped
test / cargo test (push) Successful in 1m14s
preview / preview hostname (pull_request) Successful in 0s
preview / teardown preview (pull_request) Has been skipped
preview / build + deploy preview (pull_request) Successful in 1m18s
8ec7baed47
- S8 markdown restructure: BlockFigureParagraphGate keeps generated
  <figure> blocks out of the implicit <p> wrapper. Image-only paragraphs
  emit bare figures at block level; mid-paragraph figures close/reopen
  the paragraph; whitespace after a figure no longer materializes an
  empty <p>. Fenced code needed no gating (pulldown never nests CodeBlock
  in Paragraph events).
- F13 external-image fix (pre-existing, exposed by S8): bare-<img> path
  for non-/ URLs no longer emits the </figcaption></figure> closer or
  leaks alt text as visible duplicate content (in_bare_img state).
- F14 attribute escaping: escape_attr for hand-built markdown tags
  (external img src/alt); entity-aware escape_raw_ampersands for the
  feed link-card href (lol_html get_attribute returns serialized values,
  before() inserts raw HTML — raw & in ?video=1&parent=x was NotHtml x6).
- S9 feed embed policy: iframes replaced with host-labeled link cards
  (watch on Twitch/YouTube, listen on Spotify, ...); src absolutized;
  srcless shells dropped. Site keeps the live embeds.
- S10: CharacterData allowlisted in validate_feed.py (style preference,
  both escape forms well-formed XML).
- Ratchet: validate-feed strict by default (LAX=1 escape hatch).

Verification:
- cargo test: 60 passed (8 new markdown-shape tests, 3 iframe-policy
  tests, corpus audits: no <iframe> and no <p>-nested blocks in any
  rendered post)
- just validate-feed local: VALID, zero non-allowlisted warnings in
  strict mode
- just validate-html local: 0 errors on all 5 default pages (incl. the
  article page that carried the F8 stray </p>)
- article screenshots: figures render with proper spacing; devbreak
  page keeps its Twitch embed (feed-only policy)

🪦 Live preview

removed (PR closed)

<!-- preview-link --> ## 🪦 Live preview removed (PR closed)
fix(markdown): external images render the figure+caption the old workaround aimed for
All checks were successful
preview / preview hostname (push) Successful in 0s
preview / teardown preview (push) Has been skipped
test / cargo test (push) Successful in 1m51s
preview / preview hostname (pull_request) Successful in 0s
preview / teardown preview (pull_request) Has been skipped
preview / build + deploy preview (push) Successful in 1m19s
preview / build + deploy preview (pull_request) Successful in 1m10s
24679680cd
The in_bare_img suppression misread author intent: the alt text showing
after external images WAS the desired caption behavior (same as local
images) — the workaround was only missing the <figure><figcaption>
opener. External images and SVGs now share one simple-figure arm:

  <figure><img src alt><figcaption>…alt text…</figcaption></figure>

End(Image) closer is unconditional again (every path opens a figure).
Verified live: first-weekly shows the beyerdynamic photo with its
centered caption; feed (strict) and all 5 Nu pages still clean.
Author
Owner

Follow-up (2467968): the F13 in_bare_img suppression misread author intent — the visible alt text after external images was the desired caption behavior (mirroring local images); the old workaround just lacked the <figure><figcaption> opener. External + SVG paths now share one simple-figure arm, the caption text flows into <figcaption> naturally, and the End(Image) closer is unconditional again. Net: −15 lines. External images verified live with centered captions (first-weekly beyerdynamic photo); feed-strict and all 5 Nu pages still clean.

Follow-up (2467968): the F13 `in_bare_img` suppression misread author intent — the visible alt text after external images **was the desired caption behavior** (mirroring local images); the old workaround just lacked the `<figure><figcaption>` opener. External + SVG paths now share one simple-figure arm, the caption text flows into `<figcaption>` naturally, and the End(Image) closer is unconditional again. Net: −15 lines. External images verified live with centered captions (first-weekly beyerdynamic photo); feed-strict and all 5 Nu pages still clean.
fix(markdown): raw source <figure> blocks must not trip the paragraph gate
All checks were successful
preview / preview hostname (push) Successful in 0s
preview / teardown preview (push) Has been skipped
test / cargo test (push) Successful in 1m11s
preview / build + deploy preview (push) Successful in 1m13s
preview / preview hostname (pull_request) Successful in 0s
preview / build + deploy preview (pull_request) Has been skipped
preview / teardown preview (pull_request) Successful in 0s
6c4a33ee0d
The gate matched ANY Event::Html starting with <figure — including
hand-written figure blocks in post sources, which pulldown emits at
block level (outside Paragraph events). Those close with a plain
</figure> that never matches our </figcaption></figure> end matcher,
so the in_figure flag stuck and swallowed the <p> of every following
paragraph — text rendered as one unwrapped wall (seen on
/blog/2026-04-01-week-with-my-pi-agent, PR preview).

Inside a Paragraph span an Event::Html figure-start can only be one of
ours (source block HTML is never wrapped in Paragraph events; inline
raw HTML uses InlineHtml) — so the flag is now only set there, plus a
defensive reset on paragraph start.

Regression tests: the exact corpus shape (raw figure block followed by
paragraphs) and a corpus-wide guard in the feed audit (no long bare
text runs directly after </figure>).
Author
Owner

Fix for the deformed paragraphs reported on the preview (6c4a33e): the paragraph gate matched any Event::Html starting with <figure — including hand-written <figure> blocks in post sources (like the pi-logo one in week-with-my-pi-agent). Those are emitted by pulldown at block level (outside Paragraph events) and close with a plain </figure> that never matches the gate's exact end matcher → the figure flag stuck and every following paragraph lost its <p> wrapper. The flag is now only set while inside a paragraph span, where a figure-start Event::Html is provably one of our generated ones. Guarded by a regression test for the exact corpus shape and a corpus-wide feed audit (no long bare text runs after </figure>). Re-verified: page 0 Nu errors, 0 bare runs, feed strict VALID, 61 tests pass. Preview should update shortly.

Fix for the deformed paragraphs reported on the preview (6c4a33e): the paragraph gate matched **any** `Event::Html` starting with `<figure` — including hand-written `<figure>` blocks in post sources (like the pi-logo one in *week-with-my-pi-agent*). Those are emitted by pulldown at block level (outside Paragraph events) and close with a plain `</figure>` that never matches the gate's exact end matcher → the figure flag stuck and every following paragraph lost its `<p>` wrapper. The flag is now only set while inside a paragraph span, where a figure-start `Event::Html` is provably one of our generated ones. Guarded by a regression test for the exact corpus shape and a corpus-wide feed audit (no long bare text runs after `</figure>`). Re-verified: page 0 Nu errors, 0 bare runs, feed strict VALID, 61 tests pass. Preview should update shortly.
michalvankodev merged commit ce70865cb1 into w3c-html-fixes 2026-09-14 21:06:33 +02:00
michalvankodev deleted branch w3c-feed-p-figure 2026-09-14 21:06:33 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
michalvankodev/michalvankodev-site!25
No description provided.