this repo has no description
10
fork

Configure Feed

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

fix: trailing-slash URLs 404 broke link previews; tighten OG tags

Trailing slash on project URLs (e.g. /explore/handle/) returned 404, so
Bluesky Cardyb and other unfurlers saw no HTML and produced empty cards.
Add middleware to 308-redirect GET/HEAD to the canonical path without a
trailing slash.

Project pages: set og:url + link rel=canonical to the share URL, use
og:type website for broader parser compatibility, and emit twitter:title /
twitter:description alongside existing Twitter card image tags.

Made-with: Cursor

+51 -14
+19
lib/trailing-slash-redirect.ts
··· 1 + /** 2 + * Many clients normalize URLs with a trailing slash. Our Fresh routes only 3 + * match paths without one, so `/explore/foo/` was a 404 — link unfurlers 4 + * (Bluesky Cardyb, Slack, etc.) then saw no HTML metadata and produced empty 5 + * preview cards. Redirect GET/HEAD to the canonical no-slash URL. 6 + */ 7 + import { define } from "../utils.ts"; 8 + 9 + export const trailingSlashRedirectMiddleware = define.middleware((ctx) => { 10 + if (ctx.req.method !== "GET" && ctx.req.method !== "HEAD") { 11 + return ctx.next(); 12 + } 13 + const url = new URL(ctx.req.url); 14 + if (url.pathname.length <= 1 || !url.pathname.endsWith("/")) { 15 + return ctx.next(); 16 + } 17 + url.pathname = url.pathname.replace(/\/+$/, "") || "/"; 18 + return Response.redirect(url.toString(), 308); 19 + });
+2
main.ts
··· 3 3 import type { State } from "./utils.ts"; 4 4 import { localeMiddleware } from "./i18n/mod.ts"; 5 5 import { wellKnownMiddleware } from "./lib/wellknown.ts"; 6 + import { trailingSlashRedirectMiddleware } from "./lib/trailing-slash-redirect.ts"; 6 7 import { sessionMiddleware } from "./lib/session.ts"; 7 8 8 9 export const app = new App<State>(); 9 10 10 11 app.use(staticFiles()); 12 + app.use(trailingSlashRedirectMiddleware); 11 13 app.use(wellKnownMiddleware); 12 14 app.use(localeMiddleware); 13 15 app.use(sessionMiddleware);
+14
routes/_app.tsx
··· 298 298 /> 299 299 <meta property="og:locale" content={locale} /> 300 300 <meta property="og:type" content={pageOgType} /> 301 + {pageMeta.canonicalUrl && ( 302 + <> 303 + <link rel="canonical" href={pageMeta.canonicalUrl} /> 304 + <meta property="og:url" content={pageMeta.canonicalUrl} /> 305 + </> 306 + )} 307 + <meta 308 + name="twitter:title" 309 + content={pageMeta.title ?? t.meta.ogTitle} 310 + /> 311 + <meta 312 + name="twitter:description" 313 + content={pageMeta.description ?? t.meta.ogDescription} 314 + /> 301 315 <meta property="og:image" content={pageOgImage} /> 302 316 <meta property="og:image:secure_url" content={pageOgImage} /> 303 317 <meta
+10 -13
routes/explore/[handle].tsx
··· 73 73 [] as ProfileUpdateRow[], 74 74 ]; 75 75 const displayReviews = profile ? await enrichReviews(reviews) : []; 76 + const shareUrl = profile 77 + ? new URL( 78 + `/explore/${encodeURIComponent(profile.handle)}`, 79 + ctx.url.origin, 80 + ).href 81 + : ctx.url.href; 76 82 /** 77 83 * Per-page social meta. When the project has a banner, the 78 84 * Bluesky CDN URL is used as the OG/Twitter image so the project ··· 104 110 ctx.state.pageMeta = { 105 111 title: pageTitle, 106 112 description: pageDescription, 107 - ogType: "profile", 113 + // "website" unfurls more reliably than "profile" (fewer parsers expect 114 + // profile:* sub-properties). Same visible link card everywhere. 115 + ogType: "website", 116 + canonicalUrl: shareUrl, 108 117 imageUrl: ogImageUrl, 109 118 imageAlt: profile.bannerCid 110 119 ? messages.detail.share.bannerAlt(profile.name) ··· 114 123 imageHeight: 630, 115 124 }; 116 125 } 117 - /** 118 - * Build the absolute canonical URL once on the server. The Web 119 - * Share API + clipboard fallback both want a fully-qualified URL, 120 - * and computing it from the request keeps it correct across 121 - * preview deployments / custom domains. 122 - */ 123 - const shareUrl = profile 124 - ? new URL( 125 - `/explore/${encodeURIComponent(profile.handle)}`, 126 - ctx.url.origin, 127 - ).toString() 128 - : ctx.url.toString(); 129 126 return ctx.render( 130 127 <ProfileDetailPage 131 128 profile={profile}
+6 -1
utils.ts
··· 28 28 /** OG image dimensions, when known. Defaults match the site-wide OG image. */ 29 29 imageWidth?: number; 30 30 imageHeight?: number; 31 - /** Override og:type (defaults to "website"; project pages use "profile"). */ 31 + /** Override og:type (defaults to "website"). */ 32 32 ogType?: string; 33 + /** 34 + * Canonical page URL for og:url and link[rel=canonical]. Use on share-heavy 35 + * pages so crawlers dedupe trailing-slash vs non-slash variants. 36 + */ 37 + canonicalUrl?: string; 33 38 } 34 39 35 40 export interface State {