From 793f1623a5ba912c15a2eaebfa4047d385be4627 Mon Sep 17 00:00:00 2001 From: Nish <257724087+nish3451@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:30:10 +0530 Subject: [PATCH] fix(public): guard the live site against skipped heading levels on the Drishti support and Privacy Choices pages The 2026-08-08 dogfood finding (skipped heading levels on /drishti/support/ and /privacy-choices/) was repaired in source by PR #23 (card headings H3 -> H2, outline H1 -> H2s -> H3s with no jumps), and test-public-heading-hierarchy.mjs guards the worktree HTML. But the live site still serves the June-20 bundle: both deployed pages jump H1 -> H3 and the deployed stylesheet keeps the old .info-card h3-only rule, so the finding silently stays open against tinystudio.in until the deployment refreshes. Add scripts/test-public-live-heading-hierarchy.mjs: it fetches the deployed pages and stylesheet and re-asserts the repaired outline (single H1 first, H2 card titles inside .info-card, flat H2 band before footer H3s, no jumps greater than one) plus the shared .info-card :is(h2, h3) rule at the former card scale. Network-tolerant: skips when unreachable, fails loudly when the deployment serves stale pages. Wired as npm run site:check-live-heading-hierarchy and into the nightly live-site-check.yml staleness alarm; deliberately NOT part of npm run test/ci (blocking chains stay green on repo state alone, same convention as the soft-404 guard). Also extend the deploy lane's post-deploy verification (check-public-live-deploy.mjs) with the two finding pages, so the moment the Pages token is provisioned and the lane publishes, the live repair is verified as part of deploy acceptance. verify: node scripts/test-public-heading-hierarchy.mjs (62 checks, 0 failures); node scripts/test-public-live-heading-hierarchy.mjs fails loudly against the stale live site (17 checks, 11 failures, expected until redeploy); SKIP_LIVE_CHECKS=1 exits 0; npm test matches clean main (pre-existing retention-automation exit 1 unchanged); git diff --check clean. --- .github/workflows/live-site-check.yml | 14 +- package.json | 1 + scripts/check-public-live-deploy.mjs | 10 ++ .../test-public-live-heading-hierarchy.mjs | 145 ++++++++++++++++++ 4 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 scripts/test-public-live-heading-hierarchy.mjs diff --git a/.github/workflows/live-site-check.yml b/.github/workflows/live-site-check.yml index f9718f80..85e305f1 100644 --- a/.github/workflows/live-site-check.yml +++ b/.github/workflows/live-site-check.yml @@ -1,11 +1,12 @@ name: Live Site Check -# Guards the deployed tinystudio.in against soft-404s and stale bundles -# without blocking pull requests. The deploy lane (deploy-public-site.yml) -# verifies the live site after every publish; this workflow is the nightly -# net that catches a stale or misconfigured deployment within a day even -# when nothing merges. It is deliberately NOT part of `npm run ci` - that -# blocking chain must stay green on repo state alone. +# Guards the deployed tinystudio.in against soft-404s, stale bundles, and +# regressed heading hierarchies without blocking pull requests. The deploy +# lane (deploy-public-site.yml) verifies the live site after every publish; +# this workflow is the nightly net that catches a stale or misconfigured +# deployment within a day even when nothing merges. It is deliberately NOT +# part of `npm run ci` - that blocking chain must stay green on repo state +# alone. # # This job is expected to FAIL while the deployment is stale (currently the # June-20 bundle); the red run is the loud signal to re-deploy from @@ -36,3 +37,4 @@ jobs: cache: npm - run: npm ci --ignore-scripts - run: node scripts/check-public-live-soft-404.mjs + - run: node scripts/test-public-live-heading-hierarchy.mjs diff --git a/package.json b/package.json index 6c96a39a..2f2b9ff3 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "retention:automation-check": "node scripts/check-retention-automation.mjs", "site:prepare": "node scripts/prepare-static-site-bundle.mjs", "site:check-live": "node scripts/check-public-live-soft-404.mjs", + "site:check-live-heading-hierarchy": "node scripts/test-public-live-heading-hierarchy.mjs", "claims:check": "node scripts/check-outbound-claim-safety.mjs", "config:check": "node scripts/check-agency-defaults.mjs", "send:configure": "node scripts/configure-sender-setup.mjs", diff --git a/scripts/check-public-live-deploy.mjs b/scripts/check-public-live-deploy.mjs index 7cbfe538..68b49b62 100644 --- a/scripts/check-public-live-deploy.mjs +++ b/scripts/check-public-live-deploy.mjs @@ -11,6 +11,9 @@ // 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). +// 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). import { join } from "node:path" import { fileURLToPath } from "node:url" import { dirname } from "node:path" @@ -64,6 +67,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`) + } + console.log("B. /contact/ carries structured data (PR #19)") { const { status, body } = await get("/contact/") diff --git a/scripts/test-public-live-heading-hierarchy.mjs b/scripts/test-public-live-heading-hierarchy.mjs new file mode 100644 index 00000000..16ea319d --- /dev/null +++ b/scripts/test-public-live-heading-hierarchy.mjs @@ -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(/]*>/gi)].map((m) => Number(m[1])) + +// Count

used as .info-card card titles (inside
). +const infoCardTitleCount = (html) => + (html.match(/
]*>[\s\S]*? { + 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 + } + 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 + } +} + +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.`) + } +} + +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)