Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/worker/client/client-router.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { addEventListeners, type Handle } from 'remix/ui'
import { createMultiMatcher } from 'remix/route-pattern/match'
import { type AppLoaderData } from '#app/loader-data.ts'
import { applyDocumentTitle } from './document-title.ts'
import {
abortIntentPrefetch,
prefetchRouteOnIntent,
Expand Down Expand Up @@ -502,6 +503,12 @@ async function runNavigationWithLoader(
setPreloadedNavigationData(nextPath, loadedData)
}

// Document title lives outside the hydrated `#root` tree, so SSR
// `<title>` would otherwise stick across SPA navigations. Resolve from
// the shared registry (+ loader data for dynamic routes) here so every
// route gets a title update without per-page wiring.
applyDocumentTitle(destination.pathname, loadedData)

if (options?.skipPushState) {
notify()
} else {
Expand All @@ -515,6 +522,7 @@ async function runNavigationWithLoader(
// current URL) have no href change to trigger a route's fallback
// refetch, so mark the destination stale for routes to consume.
markNavigationDataStale(nextPath)
applyDocumentTitle(destination.pathname)
if (options?.skipPushState) {
notify()
} else {
Expand Down
17 changes: 17 additions & 0 deletions packages/worker/client/document-title.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { resolveDocumentTitle } from '#app/document-title.ts'
import { type AppLoaderData } from '#app/loader-data.ts'

export {
DEFAULT_DOCUMENT_TITLE,
NOT_FOUND_DOCUMENT_TITLE,
resolveDocumentTitle,
} from '#app/document-title.ts'

/** Sync `document.title` from the current route + optional loader data. */
export function applyDocumentTitle(
pathname: string,
loaderData?: Partial<AppLoaderData>,
): void {
if (typeof document === 'undefined') return
document.title = resolveDocumentTitle(pathname, loaderData)
}
1 change: 1 addition & 0 deletions packages/worker/client/routes/community-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export async function communityDetailRouteLoader(
communityDetailShell: {
ok: true,
listingId,
name: payload.listing.name,
forkPrompt: payload.forkPrompt,
loggedIn: payload.loggedIn,
viewerIsAdmin: payload.viewerIsAdmin,
Expand Down
92 changes: 92 additions & 0 deletions packages/worker/src/app/document-title.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { expect, test } from 'vitest'
import {
DEFAULT_DOCUMENT_TITLE,
NOT_FOUND_DOCUMENT_TITLE,
resolveDocumentTitle,
} from './document-title.ts'

test('resolveDocumentTitle returns static titles for known routes', () => {
expect(resolveDocumentTitle('/')).toBe(DEFAULT_DOCUMENT_TITLE)
expect(resolveDocumentTitle('/account')).toBe('Account')
expect(resolveDocumentTitle('/blog')).toBe('Blog')
expect(resolveDocumentTitle('/community')).toBe('Community packages')
expect(resolveDocumentTitle('/timeline')).toBe('Timeline')
expect(resolveDocumentTitle('/admin/users')).toBe('Admin users')
expect(resolveDocumentTitle('/privacy')).toBe('Privacy')
})

test('resolveDocumentTitle prefers more specific account routes', () => {
expect(resolveDocumentTitle('/account/secrets')).toBe('Secrets')
expect(resolveDocumentTitle('/account/secrets/new')).toBe('Secrets')
expect(resolveDocumentTitle('/account/billing')).toBe('Billing')
expect(
resolveDocumentTitle('/account/package-invocation-tokens/token-1'),
).toBe('Package invocation tokens')
})

test('resolveDocumentTitle derives dynamic titles from loader data', () => {
expect(
resolveDocumentTitle('/blog/hello-world', {
blogPost: {
ok: true,
slug: 'hello-world',
title: 'Hello World',
date: '2026-01-01',
description: 'A post',
body: '# Hello',
},
}),
).toBe('Hello World — Kody Blog')

expect(
resolveDocumentTitle('/community/listing-1', {
communityDetailShell: {
ok: true,
listingId: 'listing-1',
name: 'Calendar helper',
forkPrompt: 'fork',
loggedIn: false,
viewerIsAdmin: false,
trusted: false,
featured: false,
readmeContent: null,
starCount: 0,
starredByViewer: false,
},
}),
).toBe('Calendar helper — Kody community package')

expect(
resolveDocumentTitle('/@kent', {
profileShell: {
ok: true,
username: 'kent',
displayName: 'Kent C. Dodds',
isSelf: false,
loggedIn: false,
isFollowing: false,
visibility: 'public',
},
}),
).toBe('Kent C. Dodds')

expect(
resolveDocumentTitle('/@missing', {
profileShell: { ok: false, unavailable: true },
}),
).toBe('Profile unavailable')
})

test('resolveDocumentTitle falls back when dynamic loader data is missing', () => {
expect(resolveDocumentTitle('/blog/hello-world')).toBe('Blog')
expect(resolveDocumentTitle('/community/listing-1')).toBe(
'Community packages',
)
expect(resolveDocumentTitle('/@kent')).toBe('@kent')
})

test('resolveDocumentTitle returns not-found for unknown paths', () => {
expect(resolveDocumentTitle('/this-route-does-not-exist')).toBe(
NOT_FOUND_DOCUMENT_TITLE,
)
})
120 changes: 120 additions & 0 deletions packages/worker/src/app/document-title.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { createMultiMatcher } from 'remix/route-pattern/match'
import { type AppLoaderData } from '#app/loader-data.ts'

export const DEFAULT_DOCUMENT_TITLE = 'kody'
export const NOT_FOUND_DOCUMENT_TITLE = 'Not found'

const documentTitleOrigin = 'https://kody.local'

type DocumentTitleContext = {
params: Record<string, string | undefined>
loaderData?: Partial<AppLoaderData>
}

type DocumentTitleResolver =
| string
| ((context: DocumentTitleContext) => string)

/**
* Single registry of document titles for app routes. The client router applies
* the resolved title on every SPA navigation; SSR can use the same helper so
* titles stay consistent without per-route `document.title` updates.
*/
const routeDocumentTitles = {
'/': DEFAULT_DOCUMENT_TITLE,
'/account': 'Account',
'/account/billing': 'Billing',
'/account/integrations': 'Integrations',
'/account/mcp-servers': 'MCP servers',
'/account/package-invocation-tokens': 'Package invocation tokens',
'/account/package-invocation-tokens/new': 'Package invocation tokens',
'/account/package-invocation-tokens/:tokenId': 'Package invocation tokens',
'/account/packages': 'Packages',
'/account/packages/:packageId': 'Packages',
'/account/stars': 'Starred packages',
'/account/passkeys': 'Passkeys',
'/account/remote-connectors': 'Remote connectors',
'/account/secrets': 'Secrets',
'/account/secrets/new': 'Secrets',
'/account/secrets/approve': 'Secrets',
'/account/secrets/user/:secretName': 'Secrets',
'/account/secrets/package/:packageId/:secretName': 'Secrets',
'/account/secrets/session/:sessionId/:secretName': 'Secrets',
'/account/two-factor': 'Two-factor authentication',
'/admin': 'Admin users',
'/admin/users': 'Admin users',
'/admin/invites': 'Admin invites',
'/admin/feature-flags': 'Admin feature flags',
'/admin/roles': 'Admin roles',
'/admin/community-reports': 'Community reports',
'/admin/insights': 'Admin insights',
'/admin/platform-feedback': 'Admin platform feedback',
'/admin/system-email': 'Admin system email',
'/blog': 'Blog',
'/blog/:slug': ({ loaderData }) => {
const post = loaderData?.blogPost
if (post?.ok) return `${post.title} — Kody Blog`
return 'Blog'
},
'/community': 'Community packages',
'/community/:listingId': ({ loaderData }) => {
const shell = loaderData?.communityDetailShell
if (shell?.ok && shell.name) {
return `${shell.name} — Kody community package`
}
return 'Community packages'
},
'/@:username': ({ loaderData, params }) => {
const shell = loaderData?.profileShell
if (shell && !shell.ok) return 'Profile unavailable'
if (shell?.ok) return shell.displayName
const username = params.username
return username ? `@${username}` : 'Profile'
},
'/timeline': 'Timeline',
'/login': DEFAULT_DOCUMENT_TITLE,
'/signup': DEFAULT_DOCUMENT_TITLE,
'/onboarding': 'Get started',
'/pending-verification': 'Verify your email',
'/privacy': 'Privacy',
'/reset-password': 'Reset password',
'/verify': 'Two-factor authentication',
'/verify-email': ({ loaderData }) => {
const verification = loaderData?.emailVerification
if (verification?.ok) return 'Email verified'
return 'Verify email'
},
'/verify-email-change': ({ loaderData }) => {
const verification = loaderData?.emailVerification
if (verification?.ok) return 'Email changed'
return 'Verify email change'
},
'/connect/oauth': 'Connect OAuth',
'/oauth/authorize': 'Authorize access',
'/oauth/callback': 'OAuth callback',
} as const satisfies Record<string, DocumentTitleResolver>

const documentTitleMatcher = (() => {
const matcher = createMultiMatcher<DocumentTitleResolver>()
for (const [pattern, resolver] of Object.entries(routeDocumentTitles)) {
matcher.add(pattern, resolver)
}
return matcher
})()

export function resolveDocumentTitle(
pathname: string,
loaderData?: Partial<AppLoaderData>,
): string {
const match = documentTitleMatcher.match(
new URL(pathname, documentTitleOrigin),
)
if (!match) return NOT_FOUND_DOCUMENT_TITLE

const resolver = match.data
if (typeof resolver === 'string') return resolver
return resolver({
params: match.params,
loaderData,
})
}
1 change: 1 addition & 0 deletions packages/worker/src/app/handlers/community-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export function createCommunityDetailHandler(env: Env) {
communityDetailShell: {
ok: true,
listingId,
name: detail.listing.name,
forkPrompt: detail.forkPrompt,
loggedIn: detail.loggedIn,
viewerIsAdmin: detail.viewerIsAdmin,
Expand Down
1 change: 1 addition & 0 deletions packages/worker/src/app/loader-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export type CommunityDetailLoaderData = {
export type CommunityDetailShellLoaderData = {
ok: true
listingId: string
name: string
forkPrompt: string
loggedIn: boolean
viewerIsAdmin: boolean
Expand Down
6 changes: 5 additions & 1 deletion packages/worker/src/app/ssr-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '#app/client-build-id.ts'
import { setAuthSessionSecret } from '#app/auth-session.ts'
import { getEnv } from '#app/env.ts'
import { resolveDocumentTitle } from '#app/document-title.ts'
import { type AppLoaderData, getRequestUrl } from '#app/loader-data.ts'
import { getRequestDataCacheLookup } from '#app/request-cache.ts'
import { applyFirstPartySecurityHeaders } from '#app/security-headers.ts'
Expand Down Expand Up @@ -48,11 +49,14 @@ export async function renderAppPage(input: RenderAppPageInput) {
const clientEntryHref = buildClientEntryHref(getClientBuildId(getEnv(env)))
const stylesheetHref = buildStylesheetHref(getClientBuildId(getEnv(env)))

const documentTitle =
title ?? resolveDocumentTitle(requestUrl.pathname, loaderData)

const stream = renderToStream(
// Remix server components accept props via handle.props; JSX typing is loose here.
(
<SsrDocument
title={title}
title={documentTitle}
extraHead={extraHead}
url={url}
session={session}
Expand Down
1 change: 1 addition & 0 deletions packages/worker/tsconfig-client.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"./src/app/community-activity-display.ts",
"./src/app/community-display.ts",
"./src/app/blog-display.ts",
"./src/app/document-title.ts",
"./src/app/loader-data.ts",
"./src/app/safe-redirect.ts",
"./src/feature-flags/registry.ts",
Expand Down
Loading