SEO remediation: docs URL split, CSP + caching headers, self-hosted fonts, WebP screenshots - #670
Merged
Merged
Conversation
- Cut meta descriptions to <=160 rendered chars (were 403 / 242, past which Google discards the tag and writes its own snippet) - Repoint 21 docs->home links from ../index.html#x to /#x, dropping a 307 redirect hop on every in-site nav click - Remove all heading-level skips (h2->h4, h2->h5) by demoting card, step and footer-column titles one level; CSS selectors moved in lockstep so styling is unchanged - Add Organization.logo, dateModified on SoftwareApplication and TechArticle, bump softwareVersion 2.1 -> 2.1.1 - Refresh sitemap lastmod and document the hand-maintained SEO obligations in the CLAUDE.md website drift rule
Cloudflare's static-asset default is `Cache-Control: public, max-age=0, must-revalidate` on every file, so repeat visitors revalidated all 35 assets on every navigation. _headers overrides it per path. Deliberately NOT `immutable` for /images/*: docs screenshots are regenerated under the same filenames at release time, so a one-year pin would strand viewers on stale images. Vendor logos and the favicon are safe to pin hard; the unhashed styles.css / app.js get a short window. Also sets HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy and Permissions-Policy on /* — the site had no security headers at all. CSP is deliberately deferred until the Google Fonts dependency is removed, so the policy can be written tight rather than loosened later. .assetsignore fixes a separate issue found while verifying: with assets.directory "." the deploy was publishing README.md (31 KB of maintainer-only docs) and wrangler.jsonc, both HTTP 200.
Both pages loaded Geist + Geist Mono from fonts.googleapis.com via a render-blocking stylesheet — two extra DNS+TLS handshakes before first paint on a site whose own TTFB is 39ms. Vendor the fonts instead (SIL OFL 1.1). Both are variable fonts, so one file covers each family's whole weight range: 4 files, not one per weight. Cyrillic/Vietnamese/symbol subsets are dropped (English-only site), leaving ~52 KB actually fetched. Verified in Chrome: both families report `loaded` from our origin and the page makes zero external requests. With the third-party origin gone, the CSP can be written tight rather than loosened later: default-src 'self' with a sha256 for the inline theme-bootstrap script. JSON-LD blocks need no hash (browsers never execute non-JS MIME types). style-src keeps 'unsafe-inline' only for the 83 inline style="" attributes that remain; README documents how to regenerate the hash, since nothing in a no-build site would catch it going stale. Also fixes a stale "v1.0 generally available" in the docs footer that disagreed with the homepage's v2.1.
Replaces all 69 docs screenshots with lossless WebP: 8.1 MB -> 2.4 MB (70% smaller) at pixel-identical quality. Lossless beat q82 lossy on these flat-colour UI captures (48 KB vs 68 KB on a sample), so there is no text-sharpness tradeoff to weigh. PNGs are deleted rather than kept as <picture> fallbacks. Siblings would have been a standing drift hazard: capture.ts regenerates screenshots at release time under the same filenames, so a hand-made sibling set goes stale silently. capture.ts now encodes WebP via sharp (Playwright emits only PNG/JPEG) and is the single writer. Also fixes a real theme bug found while verifying this. swapDocsImages() rewrote only <img src>, but whenever a <source media> matches it wins over img.src — so on a light-themed OS the theme toggle never swapped the screenshots at all. Confirmed by DOM test before and after: with a matching <source>, img.src alone does nothing; rewriting the srcset too fixes it. Verified in Chrome across both toggle directions. Root README image embeds repointed to .webp so they keep rendering on GitHub. e2e lockfile regenerated inside a Linux container so all 26 sharp platform binaries (incl. linux-x64/arm64/musl) are recorded and CI's `npm ci` resolves — the diff is pure insertions, nothing pruned.
The docs were one 17,300-word page with 53 anchors. Google ranks URLs, not fragments, so that page could only compete for one query, and AI engines citing a source had exactly two URLs to cite. Split into 12 indexable chapters, each with its own title, description, canonical, TechArticle + BreadcrumbList JSON-LD, and cross-chapter sidebar. Grouping is driven by measured section weights, not intuition: Configuration alone was 13,103 words and held all 27 in-app anchors, while four chapters were under 400 words. Splitting purely at <h2> would have produced thin pages, so Configuration is broken into 8 topic pages and the small chapters are merged (install = running + first-run). Every page is now >= 700 words with a <=160-char description. Two contracts had to move in lockstep: - frontend/src/config/docs.ts now maps each anchor to its owning chapter instead of assuming one page, so in-app "View docs" links land on the right chapter with no redirect. - website/app.js gains a PERMANENT legacy-anchor forwarder. AccessFlow is self-hosted: already-released frontends emit /docs/#cfg-<x> forever and never update. All 50 old anchors forward to their new chapter; verified in-browser that every one resolves to a real id on a real page. The test now asserts app.js and docs.ts agree, so they cannot drift apart. '#configuration' is deliberately unmapped — it headed a section that is now 8 pages, so the hub (which lists them all) is the honest landing. Also: the 340-word IaC chapter was too thin to ship as its own URL, so it is expanded from docs/16-iac.md with the resource table, CI example and local-dev commands (340 -> 720 words). Sidebar group labels became <p class="docs-toc-label"> — as <h5> inside <main> they put stray headings ahead of every page's <h1>. Verified: no duplicate ids, no dead fragments within or across pages, balanced tags, every outline a single h1 with no level skips, all 13 sitemap URLs backed by files, 33 frontend tests and typecheck green.
The docs split moved every in-app View-docs link from /docs/#anchor to /docs/<chapter>/#anchor. PageHeader already produced the new URL via docsUrl(); only this assertion still hardcoded the old one-page form.
The social card was still the v1.0 artwork: "v1.0 — Apache 2.0", "Governed database access" (the site says "database & API access"), and "Open-source SQL proxy" with a PostgreSQL/MySQL/Oracle/SQL Server chip row. It named none of the NoSQL engines, warehouses, or API governance. That image is what every social share, Slack unfurl and link preview of the site shows, so it was misrepresenting the product on first contact. Rebuilt from the live hero copy and design tokens, then compressed: 147 KB -> 72 KB. pngquant at q90-100 measures RMSE 0.25 with a max channel delta of 8/255 — imperceptible on flat design art — and keeps it PNG, which is the least surprising format for social crawlers and avoids JPEG ringing around the headline. Nothing regenerates this file automatically, which is how it went stale for a whole major version, so README now documents the full re-cut recipe (template -> headless Chrome -> pngquant) and says explicitly when to re-run it.
Points vulnerability reports at GitHub private advisories — the same security tab the footer already links publicly, so this discloses no new contact surface. Closes the "no security.txt" half of the audit's thin Trust-signals finding; the named-author/About half is a copy decision and stays open. Two caveats documented in README rather than left to rot: - Expires is mandatory under RFC 9116 and set to 2027-08-03. After that date the file is INVALID, not just old — worse than not shipping one. Nothing here automates the renewal, so it needs an annual human bump. - .well-known is a dot-directory and Cloudflare's docs do not state whether hidden paths are uploaded. This could not be verified without deploying, so README carries the one-line curl to confirm it returns 200 after the next deploy. Until then, treat it as unproven.
The site answered "who builds this?" with an org handle and a GitHub
link. For a product that sits in front of production data, that is the
question a security reviewer asks first, and the audit flagged Trust as
the weakest E-E-A-T dimension.
Adds a #provenance section above the footer stating how the project is
built — history, release cadence, contribution model, licence — with
every claim linked to the GitHub page that proves it, plus a pointer to
private vulnerability reporting.
Counts are floors ("900+", "20+"), not exact figures, so they stay true
as the repo grows instead of rotting into false claims the way the
og-image and sitemap lastmod already did.
"20+ tagged releases" counts GA releases only. The raw tag count is 36,
but 14 of those are -beta/-rc pre-releases, so quoting it would have
overstated the release cadence by ~60%. README records the distinction
and the command to recount.
No personal names published — that was a deliberate choice, and a named
maintainer remains the stronger version of this signal if wanted later.
Styling uses existing theme tokens, so it works in both themes, and adds
no inline style attributes (those still block tightening the CSP).
The CSP pins a sha256 for the inline theme-bootstrap script. website/ has no build step and no test runner, so a one-character edit to that script would silently invalidate the hash: the browser blocks the script, the theme flash returns, and nothing in the repo notices until someone sees it in production. The frontend suite already reads website/ files (docs.test.ts), so this guard lives there. It hashes every inline script across all 13 pages and fails if _headers does not allow it, printing the replacement hash to paste in. Verified by mutating the script: the test fails and names the exact hash, then passes again once restored. It also pins the policy's shape — script-src can never regain 'unsafe-inline' or 'unsafe-eval', and no third-party font origin can creep back now that the fonts are self-hosted. This replaces the manual "remember to regenerate the hash" note in the README, which was the one genuinely brittle thing left in the CSP work.
…ities 44 of the 83 inline style attributes were a single colour or margin-left declaration repeated across the markup. They become .t-* text-colour utilities and .ml-auto. (.c-* was unavailable — it is already the code-block syntax-highlight prefix.) This does NOT let the CSP drop style-src 'unsafe-inline': 39 multi-declaration layout styles remain and the directive is all-or-nothing. That is a deliberate stopping point, and README now records why keeping 'unsafe-inline' is acceptable here — no forms, no query-param rendering, no user content, so there is no injection vector for CSS to be injected through — and what would change the answer. Verified in-browser that the affected sections (#how, #use-cases, #install) render identically: accent, warn and muted text all correct, ml-auto still right-aligning the panel headers.
Splitting the docs into 13 hand-maintained files made a nav or footer change a 13-file edit, with no build step to keep them in sync. Editing 12 and missing the 13th ships an inconsistent site and nothing notices. Nothing can remove that edit cost without a build step, but the risk it creates can be guarded. This pins what I previously only verified by hand, once: identical nav and footer across every chapter, complete cross-chapter sidebar links, correct self-referencing canonicals, one h1 with no skipped levels, descriptions under 160 chars, no duplicate ids, no dead same-page or cross-chapter links, and full sitemap coverage. Verified it fires: editing the nav in 1 of 12 chapters fails the byte-identical assertion while the other 10 checks stay green. Editing all 13 files is still on the author; forgetting one is now on CI.
Both predate this branch and survived because CI never typechecks e2e/: the `npm run typecheck` step lives in the frontend job, and the e2e job only installs Playwright and runs specs. The script exists but nothing calls it. - custom-roles.spec.ts imported createRoleViaApi without using it. - query-list.spec.ts read datasourceA.name in a test block where the module-level nullable was not narrowed. Narrowed explicitly with an expect rather than leaving a bare assertion. `tsc --noEmit` across e2e/ is now clean, which matters beyond tidiness: capture.ts gained a sharp import in this branch and nothing in CI would have caught it failing to compile.
e2e/ has shipped a `typecheck` script since it was created, but no job ever ran it — the typecheck step lives in the frontend job. That is why two type errors sat in the specs unnoticed, and it left screenshots/capture.ts free to stop compiling silently after it gained a sharp dependency. Runs before the Playwright browser install so a type error fails in seconds rather than after a full stack boot, and is gated to the `main` matrix variant since all three check an identical tree.
Contributor
Frontend Test Results1 322 tests 1 322 ✅ 4m 47s ⏱️ Results for commit 5be4fbb. ♻️ This comment has been updated with latest results. |
Contributor
Coverage Report for Frontend Coverage (frontend)
File Coverage
|
||||||||||||||||||||||||||||||||||||||
Contributor
Contributor
Backend Code Coverage
|
The docs split moved cfg-slack from /docs/#cfg-slack to /docs/configuration/notifications/#cfg-slack. I updated the PageHeader unit test for this and missed its e2e twin, which asserts the same href as a literal — so CI caught it after a 16-minute run instead of in the frontend suite. Exactly the drift CLAUDE.md's "do not let e2e/ fall behind the frontend" rule exists to prevent. To stop it recurring, docs.test.ts now scans every e2e spec for hardcoded accessflow.bablsoft.com/docs URLs and checks each against docsUrl(). Verified by reverting the spec to the stale URL: the guard fails in ~1s and prints the exact replacement, rather than surfacing after a full stack boot. That spec deliberately never fetches the URL — it asserts attributes so the suite stays off the network — so this never depended on production being deployed.
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Acts on an SEO audit of https://accessflow.bablsoft.com/ (health score 70/100, no critical findings). Fourteen commits, each independently shippable and verified.
What changed
max-age=0, must-revalidateon everythingHighlights
Docs split (
5e99cd2b) — one 17,300-word page became 13 chapters. Grouping was driven by measured section weights: Configuration alone was 13,103 words and held all 27 in-app anchors, while four chapters were under 400 words, so a naive per-<h2>split would have produced thin pages.Two contracts moved in lockstep:
frontend/src/config/docs.tsmaps each anchor to its owning chapter, andwebsite/app.jsgained a permanent legacy-anchor forwarder — AccessFlow is self-hosted, so already-released frontends emit/docs/#cfg-<x>forever and never update. All 50 old anchors were verified in-browser to resolve to a real id on a real page.Two real bugs found while verifying
<source media>matches it wins overimg.src, so rewritingimg.srcalone was a no-op. Confirmed by DOM test before and after.assets.directory: "."was publishingREADME.md(31 KB of maintainer docs) andwrangler.jsoncat HTTP 200. Fixed via.assetsignore.Guards, not just fixes.
website/has no build step, so three things that would otherwise rot silently now fail CI: the CSP script hash, cross-chapter nav/footer consistency, and the docs anchor contract. Each was verified to actually fire by deliberately breaking it.e2e/is also now typechecked — it never was, which is why two type errors sat there unnoticed.Deliberate non-goals
style-srckeeps'unsafe-inline'. 39 multi-declaration inline styles remain and the directive is all-or-nothing. This is static HTML with no forms, no query-param rendering and no user content, so there is no injection vector; reasoning and revisit criteria are inwebsite/README.md.website/explicitly forbids — so the risk is guarded instead.Needs a human after deploy
curl -sSI https://accessflow.bablsoft.com/.well-known/security.txt→ must return 200..well-knownis a dot-directory and Cloudflare's docs do not state whether hidden paths are uploaded; this could not be verified without deploying.curl -sSI .../db-icons/postgresql.svg→ non-zeromax-age, confirming_headersis being applied.fonts.googleapis.com, no CSP violations in console.security.txtExpiresis 2027-08-03 — it becomes invalid, not merely stale, after that date.Verification
1321 frontend tests, typecheck, lint (0 errors) and production build all pass.
e2e/typechecks clean on Linux. All 13 sitemap URLs are backed by files, with no duplicate ids, dead fragments, or heading-level skips on any page.