See the best posts from any Bluesky account
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add regression tests for URL-encoded @ in profile handles

AdonisJS auto-decodes %40 to @, so the existing canonical-redirect
logic already 301s these. Tests lock in that behavior.

Also drop the SEO-TODO.md scratchpad now that the audit is wrapped up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+13 -40
-40
SEO-TODO.md
··· 1 - # SEO Audit — favs.blue 2 - 3 - ## P0 — Critical 4 - 5 - 1. ~~**No `robots.txt` or `sitemap.xml`**~~ — **DONE.** `public/robots.txt` added; dynamic sitemap at `/sitemap.xml` (index) + `/sitemaps/:n.xml` (chunks, 25k profiles each). Only backfilled, non-deleted profiles included. 6 - 2. ~~**No `<meta name="description">`**~~ — **DONE.** Added a `description` prop to `layout.edge` with a sensible default; profile pages reuse `ogDescription`; about page passes its own. 7 - 3. **Duplicate-content risk on handle variants** — `/profile/@alice` 301s to `/profile/alice/likes` (`profile_controller.ts:205-212`), but URL-encoded `%40` variants may still get crawled. Consider a route-level constraint or middleware. 8 - 9 - ## P1 — High 10 - 11 - 4. ~~**Profile avatars use `alt=""`**~~ — **DONE.** Profile header avatar now uses `alt="@handle avatar"`. Search-ahead avatar left empty (decorative; handle rendered alongside). 12 - 5. ~~**Weak heading hierarchy**~~ — **DONE.** Display name (or `@handle` when no display name) is now an `<h1>`. 13 - 6. **No JSON-LD structured data** — add `Person` schema on profile pages, `ItemList` for post lists, to unlock rich results in Google SERPs. 14 - 15 - ## P2 — Medium 16 - 17 - 7. **Landing page title is generic** — uses the layout default (`resources/views/components/layout.edge:30`). Add a `@slot('title')` with a CTA-oriented title in `landing.edge` (e.g. "favs.blue — Find the best posts from any Bluesky account"). 18 - 8. **Missing `hreflang`** — only `lang="en"` on `<html>` (`layout.edge:2`). Low impact but easy win: add `<link rel="hreflang" hreflang="en" href="{{ canonicalUrl }}" />`. 19 - 9. **No preload/dns-prefetch** for fonts or `cdn.bsky.app` — add to `layout.edge` head. Affects Core Web Vitals (a ranking signal). Preconnect exists but no `preload`/`dns-prefetch`. 20 - 10. **Inconsistent `Cache-Control`** — landing/about have none; profile pages set `max-age=60` (`profile_controller.ts:367`); OG images set `max-age=86400` (`og_image_controller.ts:10,18`). Document and align. 21 - 11. **No per-post OG images** — route `/og/profile/:handle.png` exists (`start/routes.ts:102`) but no `/og/post/:did/:rkey.png`; shares of specific posts miss rich previews. 22 - 23 - ## Already solid 24 - 25 - - Dynamic OG/Twitter cards (commits e82e33c, 42c00e9) 26 - - Canonical URLs set per page (`profile_controller.ts:359`, `layout.edge:33-34`) 27 - - Handle normalization with 301 redirects (`profile_controller.ts:205-212`) 28 - - `loading="lazy"` on post images (`profile/show.edge:106,119`) 29 - - Server-rendered Edge templates (crawler-friendly) 30 - - Strict CSP via shield (`config/shield.ts:16-21`) 31 - - Vite build with tree-shaking 32 - 33 - ## Suggested implementation order 34 - 35 - 1. ~~robots.txt~~ (~5 min) — **done** 36 - 2. ~~Meta descriptions~~ (~10 min) — **done** 37 - 3. ~~Avatar alt text~~ (~5 min) — **done** 38 - 4. JSON-LD Person + ItemList (~30 min) 39 - 5. ~~Heading hierarchy fix~~ (~10 min) — **done** 40 - 6. ~~Dynamic sitemap.xml~~ (~20 min) — **done**
+13
tests/functional/profile_controller.spec.ts
··· 174 174 response.assertHeader('location', '/profile/dril.bsky.social/likes') 175 175 }) 176 176 177 + // GET /profile/%40dril (URL-encoded @) 301s to canonical (no duplicate-content risk) 178 + test('GET /profile/%40dril returns 301 to canonical', async ({ client }) => { 179 + const response = await client.get('/profile/%40dril').redirects(0) 180 + response.assertStatus(301) 181 + response.assertHeader('location', '/profile/dril.bsky.social/likes') 182 + }) 183 + 184 + test('GET /profile/%40dril.bsky.social/likes returns 301 to canonical', async ({ client }) => { 185 + const response = await client.get('/profile/%40dril.bsky.social/likes').redirects(0) 186 + response.assertStatus(301) 187 + response.assertHeader('location', '/profile/dril.bsky.social/likes') 188 + }) 189 + 177 190 // Test 9: GET /profile/Dril.bsky.social/likes returns 301 → lowercase 178 191 test('GET /profile/Dril.bsky.social/likes returns 301 lowercased', async ({ client }) => { 179 192 const response = await client.get('/profile/Dril.bsky.social/likes').redirects(0)