Skip to content

test(ci): gate faq.html anchor uniqueness + JSON-LD script-breakout escaping - #172

Merged
forkwright merged 5 commits into
mainfrom
fix/92-faq-rendering-check
Aug 17, 2026
Merged

test(ci): gate faq.html anchor uniqueness + JSON-LD script-breakout escaping#172
forkwright merged 5 commits into
mainfrom
fix/92-faq-rendering-check

Conversation

@forkwright

Copy link
Copy Markdown
Owner

Summary

Salvages the genuinely non-duplicated portion of the interrupted branch
fix/92-audit-defects (two "wip: interrupted by an operator-requested
restart" commits — cut off mid-implementation, never reviewed). Adds a
permanent CI regression gate, ci/check-faq-rendering.py, for two of
typikon#92's 18 findings that PR #171 fixes at the template level but
does not gate with an automated check.

This complements PR #171 (fix/92-low-severity-batch); it does not
overlap it.
Comparing the interrupted branch's file set against #171's
diff, only four files were genuinely untouched by #171:
ci/check-faq-rendering.py, ci/run-fixtures.sh's wiring,
examples/sample-shop/content/faq.md's fixture content, and an edit to
schemas/page.schema.json. Everything else in the interrupted branch
duplicated #171's work and was discarded — not ported, not merged.

What the check does

ci/check-faq-rendering.py reads the already-built
examples/sample-shop/public/faq/index.html (built earlier in
ci/run-fixtures.sh by bin/typikon-check) and asserts two properties
the fixture is shaped to exercise:

  1. Anchor uniqueness — two fixture questions ("Do you ship
    worldwide?" / "Do you ship worldwide?!") auto-slugify to the same base
    id. The rendered page must carry the base id plus a distinct
    disambiguated sibling, and no faq-item id may repeat. The check does
    not hardcode the disambiguated suffix as a literal string — fix(bin,ci,schemas,templates): resolve 16 low-severity correctness and claim-accuracy defects #171's
    actual disambiguation scheme suffixes with the colliding item's
    absolute position in the questions array (loop.index), not a
    per-collision counter, so a literal -2 expectation would have been
    wrong the moment the fixture gained an earlier question. Asserted
    structurally instead: base id present, at least one sibling id sharing
    its <base>- prefix.
  2. JSON-LD script-breakout escaping — a fixture question containing a
    literal </script> must render as inert text everywhere, including
    inside the FAQPage <script type="application/ld+json"> block.
    Detection goes through json.loads() on each extracted JSON-LD block
    rather than a substring search for </script> inside the block body:
    the block-extraction regex is itself non-greedy up to </script>, so
    a genuine unescaped breakout is always consumed as the block's own
    terminator and can never appear inside an already-extracted body
    string — a raw substring check for it there is dead code that can
    never fire on the exact input it exists to catch (confirmed while
    writing this: the interrupted draft's original substring check never
    triggers, even against the live unfixed bug). A truncated body is
    instead syntactically invalid JSON, and json.loads() catches that
    directly with an actionable error.

Both properties were guessed at by the interrupted draft before #171's
actual implementation existed; both guesses needed correcting against
#171's real committed templates (the exact suffix scheme, and the dead
detection path above).

Which of #92's 18 findings this covers

Zero, newly. PR #171's own body states "18/18 accounted for" — all
18 of typikon#92's findings are already fixed, either by #171 itself or
by prior PRs it cites. This PR does not add a fix for anything still
open.

What it adds: permanent, automated CI coverage for 2 of those 18
(the templates/faq.html:36 auto-slugify collision, and the
templates/partials/ld-organization.html:19 JSON-LD script-breakout).
PR #171 verifies both via one-time manual "negative fixture" runs
described in its own PR body prose — real, but not standing. This PR
turns that into an enforced ci/run-fixtures.sh stage so a future
template change can't silently reintroduce either defect.

Using Refs #92, not a closing keyword, since this PR doesn't close
anything #171 hasn't already closed.

Dropped as obsolete

The interrupted branch also touched schemas/page.schema.json (a
one-line og_image extension-allowlist fix, adding .jpeg). Since that
branch's base commit, main split page.schema.json into
page.schema.json + a new page.core.schema.json (composed via $ref

Verification

Mutation test (fixture, not the checker) — watched it fail, then
confirmed a clean restore:

Baseline: PR #171's real templates (scratch build outside this branch,
not committed here) + this branch's fixture/checker/wiring →
check-faq-rendering: ok (anchors unique, 5 checked; script-breakout escaped), exit 0.

Mutated examples/sample-shop/content/faq.md's </script> question to
plain text (same scratch build):

FAIL: expected HTML-escaped question text '&lt;&#x2F;script&gt;' not found — fixture content missing
FAIL: escaped '\/script' not found in any valid JSON-LD block — fixture content missing

Exit 1, actionable. Restored the fixture, rebuilt: clean pass again,
exit 0. The same mutate → rebuild → confirm-RED → restore cycle was also
run directly in this branch's own worktree; git diff -- examples/sample-shop/content/faq.md
after the restore shows the identical 12-line addition as before the
mutation — a clean revert.

Clean pass on a correct implementation: confirmed above (exit 0)
against PR #171's actual committed templates in a scratch copy, and via
the full ci/run-fixtures.sh pipeline end-to-end (see below) — including
csp-enforce and pa11y, both of which independently flag the same two
live defects on unfixed main (see below), also going clean.

Against this branch's own tree (current main, no #171): the check
correctly goes RED — main has not merged #171 yet, so both defects are
genuinely still live:

FAIL: no disambiguated sibling of 'do-you-ship-worldwide' found (expected an id like 'do-you-ship-worldwide-<N>') — anchor collision not resolved
FAIL: duplicate element id(s) in rendered FAQ: ['do-you-ship-worldwide']
FAIL: application/ld+json block 1 is not valid JSON (Unterminated string starting at: line 35 column 15 (char 1180)) — an unescaped '</script>' in a field value likely truncated it early (script-breakout escaping regressed)
FAIL: escaped '\/script' not found in any valid JSON-LD block — fixture content missing

This is expected, not a defect in this PR — it is the check correctly
detecting that main genuinely still has both bugs.

bash ci/run-fixtures.sh from this branch (real exit code):
REAL_EXIT=1. Two pre-existing stages independently corroborate the
same two live defects before the pipeline ever reaches this PR's new
check (the script uses set -e, so it stops at the first failing
command — bin/typikon-check examples/sample-shop):

  • csp-enforce — flags a stray inline <script>...</script> body on
    faq/index.html:86, a direct side effect of the breakout truncating
    the FAQPage JSON-LD block early and desynchronizing the parser from
    the real BreadcrumbList block after it.
  • pa11y (axe-core) — flags Duplicate id attribute value "do-you-ship-worldwide" on the FAQ page — a real, independently-tooled
    accessibility violation caused by the same un-deduplicated anchor.

Both disappear cleanly once PR #171's templates are applied (see the
full-pipeline run below), which is exactly the corroboration this PR
exists to lock in.

pa11y note: pa11y-ci is installed on this box but ~/.npm-global/bin
is not on PATH by default — with PATH="$HOME/.npm-global/bin:$PATH"
added for the run, pa11y ran for real rather than skipping (an
environment gap on this box, not a defect; not touched).

Full pipeline against PR #171's templates (scratch, not committed
here):
bash ci/run-fixtures.sh with the same PATH addition —
REAL_EXIT=0, every stage including csp-enforce, pa11y, and this
PR's new check-faq-rendering stage passes clean end to end.

kanon lint — zero violations on all three changed/added files
(caught and fixed two PYTHON/empty-fstring findings in the checker
during review).

Merge order

This PR's own CI will show red — not from a defect here, but because the
fixture deliberately exercises two bugs that are only fixed on
fix/92-low-severity-batch. Recommend merging #171 first; this PR then
goes green with no further changes.

…scaping

Adds ci/check-faq-rendering.py, wired into ci/run-fixtures.sh, as a
permanent regression gate for two of typikon#92's 18 findings:

- faq.html's auto-slugify anchor collision (two questions that slugify
  to the same base id must still render distinct element ids).
- ld-organization.html's unescaped json_encode script-breakout (a field
  value containing a literal `</script>` must never terminate a JSON-LD
  <script> block early).

Both are template-level defects; the fixes belong to PR #171
(fix/92-low-severity-batch), which is not touched here. PR #171's own
verification of these two was manual and one-time; this PR adds the
missing permanent CI coverage instead: the fixture content in
examples/sample-shop/content/faq.md plus this check turn PR #171's
manual "negative fixture" narrative into a standing gate.

Because the fixture deliberately exercises both live bugs, this check
(and the pre-existing csp-enforce + pa11y stages, which independently
flag the same script-breakout and duplicate-id defects on the current,
unfixed templates) will not go green until PR #171 merges. That is
expected, not a defect in this change — verified against PR #171's
templates in a scratch build outside this branch: the identical check
and fixture pass clean, and the full `ci/run-fixtures.sh` pipeline exits
0.

The interrupted branch's schemas/page.schema.json edit is dropped as
obsolete: main split that file into page.schema.json + a new
page.core.schema.json since the interrupted branch's base commit, and
the og_image extension-allowlist fix it made now lives in
page.core.schema.json, which PR #171 already carries.
@forkwright
forkwright force-pushed the fix/92-faq-rendering-check branch from a1f1c91 to fd60f4c Compare August 17, 2026 15:05
forkwright and others added 4 commits August 17, 2026 11:15
…nvoking it bare

The derived fixture runner discovers ci/check-*.py by glob and invokes each with no
arguments. check-faq-rendering.py requires a built sample-shop public/ dir, so the
discovery call hit its usage line and exited 2, failing the gate:

  usage: ci/check-faq-rendering.py <built-sample-shop-public-dir>
  ##[error]Process completed with exit code 2.

It is now declared in ARG_TAKING_FIXTURES and invoked explicitly after the build,
the same shape check-xml-output.sh already uses.

The WHY comment above that map now states the general rule rather than just listing
members: a check needing BUILT output belongs there by construction, because
discovery runs before any example is built. Bare invocation either fails on usage --
which is what happened here and is the loud outcome -- or inspects a directory that
does not exist yet and reports a vacuous pass, which is the quiet one and worse.

Verified against a fresh sample-shop build: exit 0, 'anchors unique, 5 checked;
script-breakout escaped'. The stale build left over from before #171 merged gave an
unplanned failure proof -- the check reported the exact anchor collision and
unescaped </script> that #171 fixed, so it demonstrably fails on the state it exists
to catch.
@forkwright
forkwright merged commit b79487f into main Aug 17, 2026
4 checks passed
@forkwright
forkwright deleted the fix/92-faq-rendering-check branch August 17, 2026 17:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant