diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 000000000..07f28fc25 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { client } from "../studio/lib/client"; +import { RedirectSparsePage } from '../studio/lib/payloads/redirect'; + +export async function middleware(request: NextRequest) { + const slug = request.nextUrl.pathname; + const redirect = await client.fetch( + `*[_type == "redirect" && source.current == "${slug}"][0]{ + "destination":destination.current, + permanent + }` + ); + if (redirect !== null) { + return NextResponse.redirect( + new URL(redirect.destination, request.url), + redirect.permanent ? 308 : 307, + ); + } +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico, sitemap.xml, robots.txt (metadata files) + */ + '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)', + ], +} diff --git a/studio/components/RedirectThumbnail.tsx b/studio/components/RedirectThumbnail.tsx new file mode 100644 index 000000000..04c1fd0ce --- /dev/null +++ b/studio/components/RedirectThumbnail.tsx @@ -0,0 +1,5 @@ +const RedirectThumbnail = ({ permanent }: { permanent: boolean }) => { + return {permanent ? "⮕" : "⇢"}; +}; + +export default RedirectThumbnail; diff --git a/studio/lib/payloads/redirect.ts b/studio/lib/payloads/redirect.ts new file mode 100644 index 000000000..f3b11e53a --- /dev/null +++ b/studio/lib/payloads/redirect.ts @@ -0,0 +1,8 @@ +export interface RedirectPage extends RedirectSparsePage { + source: string; +} + +export interface RedirectSparsePage { + destination: string; + permanent: boolean; +} diff --git a/studio/schema.ts b/studio/schema.ts index e8480e95a..96ee11b6a 100644 --- a/studio/schema.ts +++ b/studio/schema.ts @@ -16,6 +16,7 @@ import office from "./schemas/documents/office"; import compensations from "./schemas/documents/compensations"; import salaryAndBenefits from "./schemas/documents/salaryAndBenefits"; import siteSettings from "./schemas/documents/siteSettings"; +import { redirect } from "./schemas/documents/redirect"; export const schema: { types: SchemaTypeDefinition[] } = { types: [ @@ -36,5 +37,6 @@ export const schema: { types: SchemaTypeDefinition[] } = { salaryAndBenefits, benefit, office, + redirect, ], }; diff --git a/studio/schemas/deskStructure.ts b/studio/schemas/deskStructure.ts index 2f19b3bcd..165341e7d 100644 --- a/studio/schemas/deskStructure.ts +++ b/studio/schemas/deskStructure.ts @@ -10,12 +10,14 @@ import { StackCompactIcon, HeartIcon, CaseIcon, + DoubleChevronRightIcon, } from "@sanity/icons"; import { soMeLinksID } from "./documents/socialMediaProfiles"; import { companyInfoID } from "./documents/companyInfo"; import { postId } from "./documents/post"; import { legalDocumentID } from "./documents/legalDocuments"; import { compensationsId } from "./documents/compensations"; +import { redirectId } from "./documents/redirect"; export default (S: StructureBuilder) => S.list() @@ -75,4 +77,8 @@ export default (S: StructureBuilder) => ), ]), ), + S.listItem() + .title("Redirects") + .icon(DoubleChevronRightIcon) + .child(S.documentTypeList(redirectId).title("Redirects")), ]); diff --git a/studio/schemas/documents/redirect.ts b/studio/schemas/documents/redirect.ts new file mode 100644 index 000000000..6b21f28b2 --- /dev/null +++ b/studio/schemas/documents/redirect.ts @@ -0,0 +1,80 @@ +import { defineField, defineType, type Slug } from "sanity"; +import { SlugRule } from "@sanity/types"; +import RedirectThumbnail from "../../components/RedirectThumbnail"; + +const slugValidator = (rule: SlugRule) => + rule.required().custom((value: Slug | undefined) => { + if (!value || !value.current) return "Can't be blank"; + if (!value.current.startsWith("/")) { + return "The path must start with a forward slash ('/')"; + } + return true; + }); + +export const redirectId = "redirect"; + +export const redirect = defineType({ + name: redirectId, + title: "Redirect", + type: "document", + readOnly: (ctx) => { + /* + make permanent redirects read-only after initial publish + this is a soft guardrail that is possible to bypass + */ + return ( + (ctx.document?.permanent ?? false) && + !ctx.document?._id.startsWith("drafts.") + ); + }, + fields: [ + defineField({ + name: "source", + description: "Which url should this redirect apply for", + type: "slug", + validation: slugValidator, + }), + defineField({ + name: "destination", + description: "Where should the user be redirected?", + type: "slug", + validation: slugValidator, + options: { + isUnique: () => { + /* + does not need to be unique since multiple source paths + can point to the same destination path + */ + return true; + }, + }, + }), + defineField({ + name: "permanent", + description: + "Will this redirect exist throughout the foreseeable future?", + type: "boolean", + }), + ], + initialValue: { + permanent: false, + }, + preview: { + select: { + source: "source", + destination: "destination", + permanent: "permanent", + }, + prepare({ source, destination, permanent }) { + const title = + source && destination + ? `${source.current} → ${destination.current}` + : undefined; + return { + title, + subtitle: permanent ? "Permanent" : "Temporary", + media: RedirectThumbnail({ permanent }), + }; + }, + }, +});