Skip to content

SEO remediation: docs URL split, CSP + caching headers, self-hosted fonts, WebP screenshots - #670

Merged
babltiga merged 15 commits into
mainfrom
fix/AF-seo-website-remediation
Aug 3, 2026
Merged

SEO remediation: docs URL split, CSP + caching headers, self-hosted fonts, WebP screenshots#670
babltiga merged 15 commits into
mainfrom
fix/AF-seo-website-remediation

Conversation

@babltiga

@babltiga babltiga commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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

Area Before After
Indexable URLs 2 13
Meta descriptions 403 / 242 chars all ≤ 160
Static-asset caching max-age=0, must-revalidate on everything 1 yr immutable / 7 d / 1 h by path
Security headers none HSTS, CSP, XCTO, XFO, Referrer-Policy, Permissions-Policy
Third-party requests Google Fonts (render-blocking) zero
Docs screenshot payload 8.1 MB PNG 2.4 MB lossless WebP
og-image v1.0 artwork, 147 KB current, 72 KB

Highlights

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.ts maps each anchor to its owning chapter, and website/app.js gained 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

  • The docs theme toggle never swapped screenshots for anyone on a light-themed OS: when a <source media> matches it wins over img.src, so rewriting img.src alone was a no-op. Confirmed by DOM test before and after.
  • assets.directory: "." was publishing README.md (31 KB of maintainer docs) and wrangler.jsonc at 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-src keeps '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 in website/README.md.
  • Nav/footer are duplicated across 13 files. Nothing removes that without a build step, which website/ explicitly forbids — so the risk is guarded instead.
  • No personal names published on the provenance strip; a named maintainer remains the stronger signal if wanted later.

Needs a human after deploy

  1. curl -sSI https://accessflow.bablsoft.com/.well-known/security.txtmust return 200. .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.
  2. curl -sSI .../db-icons/postgresql.svg → non-zero max-age, confirming _headers is being applied.
  3. Load the site → no requests to fonts.googleapis.com, no CSP violations in console.
  4. security.txt Expires is 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.

babltiga added 14 commits August 3, 2026 11:25
- 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.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Frontend Test Results

1 322 tests   1 322 ✅  4m 47s ⏱️
  172 suites      0 💤
    1 files        0 ❌

Results for commit 5be4fbb.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Frontend Coverage (frontend)

Status Category Percentage Covered / Total
🟢 Lines 93.83% (🎯 90%) 2087 / 2224
🟢 Statements 91.82% (🎯 90%) 2324 / 2531
🟢 Functions 90.98% (🎯 90%) 636 / 699
🟢 Branches 83.59% (🎯 80%) 1310 / 1567
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
frontend/src/config/docs.ts 100% 100% 100% 100%
Generated in workflow #853 for commit 5be4fbb by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Backend Test Results

5 592 tests  ±0   5 592 ✅ ±0   17m 34s ⏱️ +34s
  695 suites ±0       0 💤 ±0 
  695 files   ±0       0 ❌ ±0 

Results for commit 5be4fbb. ± Comparison against base commit afa1f23.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Backend Code Coverage

Overall Project 93.17% 🍏

There is no coverage information present for the Files changed

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.
@babltiga
babltiga merged commit 29fcac5 into main Aug 3, 2026
34 checks passed
@babltiga
babltiga deleted the fix/AF-seo-website-remediation branch August 3, 2026 09:43
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