Repository navigation
fix(public): guard the live site against skipped heading levels on Drishti support and Privacy Choices pages #118
Changes from 7 commits
793f162
249383f
360238a
9f32d69
87d472f
54b2af5
58498c7
b2f618d
085354b
1eafc21
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,10 @@ | |
| // 3. unknown URLs get a real 404, not the homepage (PR #34) | ||
| // 4. homepage stays portfolio-only: brand-disambiguation copy (#29) live, | ||
| // and no managed-service buyer-path content (#10/#11, snoozed). | ||
| // Proof 2b covers the 2026-08-08 dogfood finding page: | ||
| // The fifth proof covers the 2026-08-08 dogfood finding pages: | ||
| // 5. /drishti/support/ and /privacy-choices/ render H2 after H1 | ||
| // (the skipped-heading-levels repair, PR #23). | ||
| // Proof 2b covers the contact finding page: | ||
| // 2b. /contact/ renders H2 after H1 (the heading-hierarchy repair, PR #18). | ||
| import { join } from "node:path" | ||
| import { fileURLToPath } from "node:url" | ||
|
|
@@ -66,6 +69,13 @@ try { | |
| ok(h2AfterFirstH1(body), "H2 follows the first H1 before any H3") | ||
| } | ||
|
|
||
| console.log("A2. finding pages render H2 after H1 (skipped-heading-levels repair, PR #23)") | ||
| for (const path of ["/drishti/support/", "/privacy-choices/"]) { | ||
| const { status, body } = await get(path) | ||
| ok(status === 200, `${path} returns 200 (got ${status})`) | ||
| ok(h2AfterFirstH1(body), `${path} has H2 following the first H1 before any H3`) | ||
| } | ||
|
Comment on lines
+104
to
+109
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Require H2 to be the next heading after the first H1. The current predicate ignores H4-H6 and additional H1 tags. A sequence such as H1 → H4 → H2 → H3 passes this check. Parse the heading sequence and require the heading immediately after the first H1 to be H2. 🤖 Prompt for AI Agents |
||
|
|
||
| console.log("B. /contact/ carries structured data (PR #19)") | ||
| { | ||
| const { status, body } = await get("/contact/") | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,145 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Guard the LIVE public site against skipped heading levels on the pages | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // that carry the repaired outline in source. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The 2026-08-08 dogfood finding "Repair skipped heading levels on Drishti | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // support and Privacy Choices pages" was repaired in source by PR #23 (card | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // headings promoted from H3 to H2, outline H1 -> H2s -> H3s with no jumps), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // and scripts/test-public-heading-hierarchy.mjs guards the worktree HTML. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // This check re-asserts the repaired outline against the pages the live | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // site actually serves, so a stale deployment (like the June-20 bundle | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // still serving H3 cards) fails loudly instead of silently re-opening the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // finding. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // It runs as `npm run site:check-live-heading-hierarchy`, from the nightly | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // live-site-check workflow (the loud staleness alarm while the site is | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // stale) and on demand. It is deliberately NOT part of `npm run test` / | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `npm run ci`: those blocking chains must stay green on repo state alone, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // while the live site is deployed by an external mechanism (Cloudflare | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Pages). Blocking CI on the live site would keep every pull request red | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // whenever the deployment is stale, and would deadlock the deploy lane's | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // pre-deploy `npm run check` gate. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Network-tolerant: a page or stylesheet that is unreachable is skipped | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // (ok, no assertion); a page that is reachable but stale fails loudly. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Escape hatch for machines without network access: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // SKIP_LIVE_CHECKS=1 npm run site:check-live-heading-hierarchy | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Only public tinystudio.in URLs and the local source files are referenced | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // here; there is no per-environment configuration. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { readFileSync } from "node:fs" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { fileURLToPath } from "node:url" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { dirname, join } from "node:path" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const read = (p) => readFileSync(join(ROOT, p), "utf8") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (process.env.SKIP_LIVE_CHECKS === "1") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log("test-public-live-heading-hierarchy: SKIP_LIVE_CHECKS=1, skipping live site checks") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| process.exit(0) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let failures = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let checks = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ok = (cond, msg) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| checks++ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (cond) console.log(` ok ${msg}`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| failures++ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error(` FAIL ${msg}`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The deployed pages must keep the repaired heading outline (H1 -> H2 cards | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // -> H2 footer -> H3 footer columns, no skipped levels). The local suite | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // (test-public-heading-hierarchy.mjs) asserts the same outline against the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // worktree HTML; this guard re-asserts it against what the live site | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // serves. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const LIVE_PAGES = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: "Drishti support", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| url: "https://tinystudio.in/drishti/support/", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| source: "public/drishti/support/index.html" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: "Privacy Choices", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| url: "https://tinystudio.in/privacy-choices/", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| source: "public/privacy-choices/index.html" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const LIVE_CSS_URL = "https://tinystudio.in/styles.css" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const FETCH_TIMEOUT_MS = 10_000 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Heading levels in document order, e.g. [1, 2, 2, 2, 2, 3, 3, 3]. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const headingLevelsOf = (html) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [...html.matchAll(/<h([1-6])\b[^>]*>/gi)].map((m) => Number(m[1])) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Count <h2> used as .info-card card titles (inside <article class="info-card">). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const infoCardTitleCount = (html) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (html.match(/<article class="info-card[^"]*"[^>]*>[\s\S]*?<h2\b/gi) || []).length | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const fetchLive = async (url) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!res.ok) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(` ok skipped: ${url} answered ${res.status}, deployment not reachable - no assertions run for it`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+84
to
+86
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When either monitored page or Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return await res.text() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(` ok skipped: ${url} unreachable (${err?.cause?.code ?? err?.name ?? "network error"}) - no assertions run for it`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+81
to
+93
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Fail when a live resource returns a non-2xx response. A reachable Proposed fix if (!res.ok) {
- console.log(` ok skipped: ${url} answered ${res.status}, deployment not reachable - no assertions run for it`)
+ ok(false, `${url} returned HTTP ${res.status}`)
return null
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const assertRepairedOutline = (name, source, html) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const levels = headingLevelsOf(html) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ok(levels.length > 0, `live ${name} page contains at least one heading`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ok(levels.filter((l) => l === 1).length === 1, `live ${name} page has exactly one H1`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ok(levels[0] === 1, `live ${name} page has the H1 as the first heading in the outline`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ok(infoCardTitleCount(html) === 3, `live ${name} page has the three card headings as H2s inside .info-card articles`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cardH2s = levels.filter((l) => l === 2).length | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ok(cardH2s >= 4, `live ${name} page keeps the flat H2 band (card H2s plus the footer H2) before the footer H3s`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let jumps = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (let i = 1; i < levels.length; i++) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (levels[i] - levels[i - 1] > 1) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| jumps++ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error(` bad transition H${levels[i - 1]} -> H${levels[i]} on ${name}`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ok(jumps === 0, `live ${name} page has no heading-level jump greater than one (no H1 -> H3 skip)`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (failures > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error(` the deployed ${source} is stale: it misses the heading-hierarchy repair that the worktree copy of ${source} already has. Refresh the live deployment from origin/main.`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+111
to
+112
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an earlier page increments the global Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+111
to
+113
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Report stale diagnostics only for the resource that failed. Both checks use the global
📍 Affects 1 file
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log("test-public-live-heading-hierarchy: the deployed tinystudio.in pages keep the repaired heading outline (no skipped levels)") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log("A. live pages carry the repaired heading hierarchy") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const page of LIVE_PAGES) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const html = await fetchLive(page.url) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (html !== null) assertRepairedOutline(page.name, page.source, html) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log("B. live stylesheet keeps the shared card-heading rule at the former card scale") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const css = await fetchLive(LIVE_CSS_URL) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (css !== null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ruleStart = css.indexOf(".info-card :is(h2, h3) {") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ok(ruleStart !== -1, `live styles.css defines .info-card :is(h2, h3) {`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ruleEnd = ruleStart === -1 ? -1 : css.indexOf("}", ruleStart) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ruleBody = ruleStart === -1 ? "" : css.slice(ruleStart, ruleEnd) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const decl of ["margin-top: 12px", "font-size: clamp(1.65rem, 2vw, 2.35rem)", "max-width: none"]) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ok(ruleBody.includes(decl), `live card rule keeps ${decl}`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ok( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| !/\.info-card\s+h3\s*{/.test(css), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "live styles.css replaced the old .info-card h3-only rule with the shared :is(h2, h3) rule" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (failures > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error(" the deployed stylesheet is stale: it misses the card-heading pairing that public/styles.css already has. Refresh the live deployment from origin/main.") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log("\nLive heading-hierarchy guard result: the finding stays open against tinystudio.in until a refresh of the live deployment lands on origin/main.") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`\n${checks} checks, ${failures} failures`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| process.exit(failures === 0 ? 0 : 1) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the workflow's documented stale-deployment state, the preceding
check-public-live-soft-404.mjsstep exits nonzero, so GitHub Actions' implicitsuccess()condition skips this new step entirely. The job is still red, but the heading assertions and their diagnostics never run precisely while the stale bundle is deployed; run the two guards independently or otherwise aggregate their exit statuses so both execute while preserving a failed job result.Useful? React with 👍 / 👎.