Skip to content
Open
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
7 changes: 4 additions & 3 deletions app/components/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { Link, useNavigate } from 'react-router'

import Icon from '~/components/Icon'
import StatusBadge from '~/components/StatusBadge'
import { formatRfdNum } from '~/utils/canonicalUrl'
import { useSteppedScroll } from '~/hooks/use-stepped-scroll'
import type { RfdItem } from '~/services/rfd.server'

Expand Down Expand Up @@ -170,7 +171,7 @@ const SearchWrapper = ({ dismissSearch }: { dismissSearch: () => void }) => {
if (e.key === 'Enter') {
const selectedItem = flattenedHits[selectedIdx]
if (!selectedItem) return
navigate(`/rfd/${selectedItem.rfd_number}#${selectedItem.anchor}`)
navigate(`/rfd/${formatRfdNum(selectedItem.rfd_number)}#${selectedItem.anchor}`)
// needed despite key={pathname + hash} logic in case we navigate
// to the page we're already on
dismissSearch()
Expand Down Expand Up @@ -377,7 +378,7 @@ const HitItem = ({ hit, isSelected }: { hit: RFDHit; isSelected: boolean }) => {
)}
/>
)}
<Link to={`/rfd/${hit.rfd_number}#${hit.anchor}`} className="block" prefetch="intent">
<Link to={`/rfd/${formatRfdNum(hit.rfd_number)}#${hit.anchor}`} className="block" prefetch="intent">
<li
className={cn(
'px-4 py-4',
Expand Down Expand Up @@ -466,7 +467,7 @@ const RFDPreview = ({ number }: { number: number }) => {
item.level === 1 && (
<div className="border-b-default w-full border-b py-2" key={item.id}>
<Link
to={`/rfd/${rfd.number}#${item.id}`}
to={`/rfd/${rfd.formattedNumber}#${item.id}`}
className="block"
prefetch="intent"
>
Expand Down
2 changes: 1 addition & 1 deletion app/components/rfd/MoreDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const MoreDropdown = () => {
</DropdownLink>
)}

<DropdownLink to={`/rfd/${rfd.number}/pdf`}>View PDF</DropdownLink>
<DropdownLink to={`/rfd/${rfd.formattedNumber}/pdf`}>View PDF</DropdownLink>
</DropdownMenu>
</Dropdown.Root>

Expand Down
7 changes: 5 additions & 2 deletions app/routes/$slug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@

import { redirect, type LoaderFunctionArgs } from 'react-router'

import { formatRfdNum } from '~/utils/canonicalUrl'
import { parseRfdNum } from '~/utils/parseRfdNum'

export async function loader({ params: { slug } }: LoaderFunctionArgs) {
if (parseRfdNum(slug)) {
return redirect(`/rfd/${slug}`)
const num = parseRfdNum(slug)
if (num) {
// Always redirect to canonical URL format (zero-padded)
return redirect(`/rfd/${formatRfdNum(num)}`)
} else {
throw new Response('Not Found', { status: 404 })
}
Expand Down
5 changes: 5 additions & 0 deletions app/routes/_index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type LoaderFunctionArgs,
} from 'react-router'

import { canonicalUrl } from '~/utils/canonicalUrl'
import { ClientOnly } from '~/components/ClientOnly'
import Container from '~/components/Container'
import { SortArrowBottom, SortArrowTop } from '~/components/CustomIcons'
Expand All @@ -37,6 +38,10 @@ import { sortBy } from '~/utils/array'
import { fuzz } from '~/utils/fuzz'
import { parseSortOrder, type SortAttr } from '~/utils/rfdSortOrder.server'

export const links = () => [
{ rel: 'canonical', href: canonicalUrl('/') },
]

export const loader = async ({ request }: LoaderFunctionArgs) => {
const cookieHeader = request.headers.get('Cookie')
return parseSortOrder(await rfdSortCookie.parse(cookieHeader))
Expand Down
5 changes: 5 additions & 0 deletions app/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,13 @@ import {
} from 'react-router'

import { auth, getUserFromSession } from '~/services/auth.server'
import { canonicalUrl } from '~/utils/canonicalUrl'
import { returnToCookie } from '~/services/cookies.server'

export const links = () => [
{ rel: 'canonical', href: canonicalUrl('/login') },
]

export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url)
const returnTo = url.searchParams.get('returnTo')
Expand Down
3 changes: 2 additions & 1 deletion app/routes/rfd.$slug.discussion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { redirect, type LoaderFunctionArgs } from 'react-router'

import { authenticate } from '~/services/auth.server'
import { fetchRfd } from '~/services/rfd.server'
import { formatRfdNum } from '~/utils/canonicalUrl'
import { parseRfdNum } from '~/utils/parseRfdNum'

import { resp404 } from './rfd.$slug'
Expand All @@ -24,7 +25,7 @@ export async function loader({ request, params: { slug } }: LoaderFunctionArgs)
// !rfd covers both non-existent and private RFDs for the logged-out user. In
// both cases, once they log in, if they have permission to read it, they'll
// get the redirect, otherwise they will get 404.
if (!rfd && !user) throw redirect(`/login?returnTo=/rfd/${num}/discussion`)
if (!rfd && !user) throw redirect(`/login?returnTo=/rfd/${formatRfdNum(num)}/discussion`)

// If you don't see an RFD but you are logged in, you can't tell whether you
// don't have access or it doesn't exist. That's fine.
Expand Down
14 changes: 12 additions & 2 deletions app/routes/rfd.$slug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import StatusBadge from '~/components/StatusBadge'
import { useRootLoaderData } from '~/root'
import { authenticate } from '~/services/auth.server'
import { fetchGroups, fetchRfd } from '~/services/rfd.server'
import { canonicalRfdUrl, formatRfdNum } from '~/utils/canonicalUrl'
import { parseRfdNum } from '~/utils/parseRfdNum'
import { can } from '~/utils/permission'

Expand Down Expand Up @@ -77,13 +78,19 @@ export async function loader({ request, params: { slug } }: LoaderFunctionArgs)
const num = parseRfdNum(slug)
if (!num) throw resp404()

// Redirect to canonical URL if the slug is not in the canonical format (zero-padded)
const canonicalSlug = formatRfdNum(num)
if (slug !== canonicalSlug) {
throw redirect(`/rfd/${canonicalSlug}`)
}

const user = await authenticate(request)

const rfd = await fetchRfd(num, user)

// If someone goes to a private RFD but they're not logged in, they will
// want to log in and see it.
if (!rfd && !user) throw redirect(`/login?returnTo=/rfd/${num}`)
if (!rfd && !user) throw redirect(`/login?returnTo=/rfd/${canonicalSlug}`)

// If you don't see an RFD but you are logged in, you can't tell whether you
// don't have access or it doesn't exist. That's fine.
Expand Down Expand Up @@ -114,7 +121,10 @@ export async function loader({ request, params: { slug } }: LoaderFunctionArgs)

export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (data && data.rfd) {
return [{ title: `${data.rfd.number} - ${data.rfd.title} / RFD / Oxide` }]
return [
{ title: `${data.rfd.number} - ${data.rfd.title} / RFD / Oxide` },
{ tagName: 'link', rel: 'canonical', href: canonicalRfdUrl(data.rfd.number) },
]
} else {
return [{ title: 'Page not found / Oxide' }]
}
Expand Down
36 changes: 36 additions & 0 deletions app/utils/canonicalUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/

import { expect, test } from 'vitest'

import { canonicalRfdUrl, canonicalUrl, formatRfdNum, SITE_URL } from './canonicalUrl'

test.each([
[1, '0001'],
[53, '0053'],
[321, '0321'],
[9999, '9999'],
])('formatRfdNum(%i) -> %s', (input: number, result: string) => {
expect(formatRfdNum(input)).toEqual(result)
})

test.each([
[1, `${SITE_URL}/rfd/0001`],
[53, `${SITE_URL}/rfd/0053`],
[321, `${SITE_URL}/rfd/0321`],
])('canonicalRfdUrl(%i) -> %s', (input: number, result: string) => {
expect(canonicalRfdUrl(input)).toEqual(result)
})

test.each([
['/', `${SITE_URL}/`],
['/login', `${SITE_URL}/login`],
['/rfd/0053', `${SITE_URL}/rfd/0053`],
])('canonicalUrl(%s) -> %s', (input: string, result: string) => {
expect(canonicalUrl(input)).toEqual(result)
})
25 changes: 25 additions & 0 deletions app/utils/canonicalUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/

export const SITE_URL = 'https://rfd.shared.oxide.computer'

/**
* Format an RFD number as a zero-padded 4-digit string (e.g., 53 -> "0053")
*/
export const formatRfdNum = (num: number): string => num.toString().padStart(4, '0')

/**
* Generate the canonical URL for an RFD page
*/
export const canonicalRfdUrl = (num: number): string =>
`${SITE_URL}/rfd/${formatRfdNum(num)}`

/**
* Generate a canonical URL for a given path (strips query params)
*/
export const canonicalUrl = (path: string): string => `${SITE_URL}${path}`