Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v3 - manual Sanity-controlled redirects #562

Merged
merged 12 commits into from
Sep 10, 2024
Merged
49 changes: 49 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { HTTP_STATUSES } from "./utils/http";
import { RedirectDestinationSlugPage } from "studio/lib/payloads/redirect";
import { REDIRECT_BY_SOURCE_SLUG_QUERY } from "../studio/lib/queries/redirects";

export async function middleware(request: NextRequest) {
const slug = request.nextUrl.pathname;
const slugQueryParam = slug.replace(/^\/+/, "");
/*
fetching redirect data via API route to avoid token leaking to client
(middleware should run on server, but `experimental_taintUniqueValue` begs to differ...)
*/
const redirectRes = await fetch(
new URL("/api/fetchData", process.env.NEXT_PUBLIC_URL),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: REDIRECT_BY_SOURCE_SLUG_QUERY,
params: { slug: slugQueryParam },
}),
},
);
if (redirectRes.ok) {
const redirect: RedirectDestinationSlugPage | null =
await redirectRes.json();
if (redirect?.destination) {
return NextResponse.redirect(
new URL(redirect.destination, request.url),
HTTP_STATUSES.TEMPORARY_REDIRECT,
);
}
}
}

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).*)",
],
};
3 changes: 3 additions & 0 deletions src/utils/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const HTTP_STATUSES = {
TEMPORARY_REDIRECT: 307,
};
21 changes: 21 additions & 0 deletions studio/components/PrefixedSlugInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { SlugInputProps } from "sanity";
import styles from "./prefixedSlugInput.module.css";
import { useTheme } from "@sanity/ui";

type PrefixedSlugInputProps = SlugInputProps & {
prefix: string;
};

const PrefixedSlugInput = ({ prefix, ...props }: PrefixedSlugInputProps) => {
const theme = useTheme();
const prefersDark = theme.sanity.v2?.color._dark ?? false;

return (
<div className={`${prefersDark ? `${styles.dark} ` : ""}${styles.wrapper}`}>
<span className={styles.prefixWrapper}>{prefix}</span>
{props.renderDefault(props)}
</div>
);
};

export default PrefixedSlugInput;
28 changes: 28 additions & 0 deletions studio/components/prefixedSlugInput.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.wrapper {
display: flex;
}

.wrapper input {
padding-left: 0.5rem;
border-left: none;
}

.prefixWrapper + * * {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}

.prefixWrapper {
background-color: rgb(246, 246, 248);
border-radius: 4px 0 0 4px;
padding: 0.375rem 0.5rem 0.375rem 0.5rem;
border: 1px solid rgb(225, 226, 230);
border-right: none;
}

[data-scheme="dark"] .prefixWrapper {
background-color: rgb(25, 26, 36);
mathiazom marked this conversation as resolved.
Show resolved Hide resolved
border: 1px solid rgb(41, 44, 61);
border-right: none;
color: white;
}
3 changes: 3 additions & 0 deletions studio/lib/payloads/redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface RedirectDestinationSlugPage {
destination: string;
}
10 changes: 10 additions & 0 deletions studio/lib/queries/redirects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { groq } from "next-sanity";

export const REDIRECT_BY_SOURCE_SLUG_QUERY = groq`
*[_type == "redirect" && source.current == $slug][0]{
"destination": select(
destination.type == "reference" => destination.reference->slug.current,
destination.type == "external" => destination.external
)
}
`;
2 changes: 2 additions & 0 deletions studio/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import benefit from "./schemas/documents/benefit";
import companyLocation from "./schemas/documents/companyLocation";
import compensations from "./schemas/documents/compensations";
import siteSettings from "./schemas/documents/siteSettings";
import redirect from "./schemas/documents/redirect";

export const schema: { types: SchemaTypeDefinition[] } = {
types: [
Expand All @@ -33,6 +34,7 @@ export const schema: { types: SchemaTypeDefinition[] } = {
legalDocument,
compensations,
benefit,
redirect,
companyLocation,
],
};
6 changes: 6 additions & 0 deletions studio/schemas/deskStructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import {
InfoOutlineIcon,
HeartIcon,
CaseIcon,
DoubleChevronRightIcon,
PinIcon,
} from "@sanity/icons";
import { soMeLinksID } from "./documents/socialMediaProfiles";
import { companyInfoID } from "./documents/companyInfo";
import { legalDocumentID } from "./documents/legalDocuments";
import { compensationsId } from "./documents/compensations";
import { redirectId } from "./documents/redirect";
import { companyLocationID } from "./documents/companyLocation";

export default (S: StructureBuilder) =>
Expand Down Expand Up @@ -97,4 +99,8 @@ export default (S: StructureBuilder) =>
),
]),
),
S.listItem()
.title("Redirects")
.icon(DoubleChevronRightIcon)
.child(S.documentTypeList(redirectId).title("Redirects")),
]);
139 changes: 139 additions & 0 deletions studio/schemas/documents/redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { defineField, defineType, type Slug } from "sanity";
import { SanityDocument, SlugRule } from "@sanity/types";
import { pageBuilderID } from "../builders/pageBuilder";
import { blogId } from "./blog";
import { compensationsId } from "./compensations";
import PrefixedSlugInput from "../../components/PrefixedSlugInput";

const slugRequired = (rule: SlugRule) =>
rule.required().custom((value: Slug | undefined) => {
if (!value || !value.current) return "Can't be blank";
return true;
});

const requiredIfDestinationType = (
type: string,
document: SanityDocument | undefined,
value: unknown,
) => {
const destination = document?.destination;
if (
typeof destination === "object" &&
destination !== null &&
"type" in destination &&
destination.type === type &&
value === undefined
) {
return "Can't be blank";
}
return true;
};

export const redirectId = "redirect";

const redirect = defineType({
name: redirectId,
title: "Redirect",
type: "document",
fields: [
defineField({
name: "source",
title: "Source",
description: "The url slug to be redirected to the destination",
type: "slug",
validation: (rule) => slugRequired(rule),
components: {
input: (props) => PrefixedSlugInput({ prefix: "/", ...props }),
},
}),
defineField({
name: "destination",
title: "Destination",
type: "object",
fields: [
defineField({
name: "type",
title: "Type",
type: "string",
initialValue: "reference",
options: {
layout: "radio",
list: [
{ value: "reference", title: "Internal Page" },
{ value: "external", title: "External URL" },
],
},
}),
defineField({
name: "reference",
title: "Internal Page",
description: "Where should the user be redirected?",
type: "reference",
to: [
{ type: pageBuilderID },
{ type: blogId },
{ type: compensationsId },
],
hidden: ({ parent }) => parent?.type !== "reference",
validation: (rule) =>
rule.custom((value, { document }) =>
requiredIfDestinationType("reference", document, value),
),
}),
defineField({
name: "external",
title: "External URL",
description: "Where should the user be redirected?",
type: "url",
hidden: ({ parent }) => parent?.type !== "external",
validation: (rule) =>
rule.custom((value, { document }) =>
requiredIfDestinationType("external", document, value),
),
}),
],
}),
],
preview: {
select: {
sourceSlug: "source.current",
destinationType: "destination.type",
destinationReferenceSlug: "destination.reference.slug.current",
destinationExternalURL: "destination.external",
},
prepare({
sourceSlug,
destinationType,
destinationReferenceSlug,
destinationExternalURL,
}) {
if (
typeof sourceSlug !== "string" ||
typeof destinationType !== "string" ||
(destinationType === "reference" &&
typeof destinationReferenceSlug !== "string") ||
(destinationType === "external" &&
typeof destinationExternalURL !== "string")
) {
return {};
}
const destination = {
reference: `/${destinationReferenceSlug}`,
external: destinationExternalURL,
}[destinationType];
const title =
sourceSlug && destination
? `/${sourceSlug} → ${destination}`
: undefined;
return {
title,
subtitle: {
reference: "Internal",
external: "External",
}[destinationType],
};
},
},
});

export default redirect;
9 changes: 7 additions & 2 deletions studio/schemas/schemaTypes/slug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ function createSlugField(source: string) {
name: "slug",
title: "URL Path (slug)",
description:
"Enter a unique URL path for the page. This path will be used in the website's address bar. A URL path, also known as a slug, is a URL-friendly version of the page title, used to create a human-readable and search engine optimized URL for the content. Legal characters include latin letters, digits, hyphen (-), underscore (_), full stop (.) and tilde (~)",
"Enter a unique URL path for the page. This path will be used in the website's address bar. " +
"A URL path, also known as a slug, is a URL-friendly version of the page title, used to create " +
"a human-readable and search engine optimized URL for the content. " +
"Note that the slug can not be changed after publication, but alternative slugs can be defined via Redirects.",
options: {
source,
maxLength: SLUG_MAX_LENGTH,
Expand All @@ -49,13 +52,15 @@ function createSlugField(source: string) {
if (value?.current === undefined) return true;
return (
encodeURIComponent(value.current) === value.current ||
"Slug can only consist of latin letters, digits, hyphen (-), underscore (_), full stop (.) and tilde (~)"
"Slug can only consist of latin letters (a-z, A-Z), digits (0-9), hyphen (-), underscore (_), full stop (.) and tilde (~)"
);
}),
readOnly: (ctx) => {
/*
make slugs read-only after initial publish
to avoid breaking shared links

if new slugs are needed, redirects can be used instead
mathiazom marked this conversation as resolved.
Show resolved Hide resolved
*/
return ctx.document !== undefined && isPublished(ctx.document);
},
Expand Down