Skip to content

Commit

Permalink
refactor(slug): extract validation logic out of schema definition
Browse files Browse the repository at this point in the history
  • Loading branch information
mathiazom committed Sep 23, 2024
1 parent b5e0aa9 commit 37c260d
Show file tree
Hide file tree
Showing 3 changed files with 90 additions and 51 deletions.
5 changes: 2 additions & 3 deletions studio/components/CustomCallToActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {

import { fetchWithToken } from "studio/lib/fetchWithToken";
import { LANDING_PAGE_REF_QUERY } from "studio/lib/queries/navigation";
import { buildPublishedId } from "studio/utils/documentUtils";

type CustomCallToActionsProps = ArrayOfObjectsInputProps<
{ _key: string },
Expand Down Expand Up @@ -44,9 +45,7 @@ const CustomCallToActions: React.FC<CustomCallToActionsProps> = (props) => {
useEffect(() => {
if (!landingPageId) return;

const currentPageId = documentId?.startsWith("drafts.")
? documentId.split("drafts.")[1]
: documentId;
const currentPageId = buildPublishedId(documentId);

const isLanding = landingPageId === currentPageId;

Expand Down
114 changes: 67 additions & 47 deletions studio/schemas/schemaTypes/slug.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,72 @@
import { SlugValidationContext, defineField, isSlug } from "sanity";
import {
SlugValidationContext,
SlugValue,
ValidationContext,
defineField,
isSlug,
} from "sanity";

import { apiVersion } from "studio/env";
import { isPublished } from "studio/utils/documentUtils";
import {
buildDraftId,
buildPublishedId,
isPublished,
} from "studio/utils/documentUtils";

async function isSlugUniqueAcrossAllDocuments(
function isSlugUniqueAcrossAllDocuments(
slug: string,
context: SlugValidationContext,
{ document, getClient }: SlugValidationContext,
) {
const { document, getClient } = context;
const client = getClient({ apiVersion: "2022-12-07" });
const id = document?._id.replace(/^drafts\./, "");
const params = {
draft: `drafts.${id}`,
published: id,
slug,
};
const SLUG_QUERY =
"!defined(*[!(_id in [$draft, $published]) && slug.current == $slug][0]._id)";
if (document === undefined) {
return true;
}
return getClient({ apiVersion }).fetch(
"!defined(*[!(_id in [$draft, $published]) && slug.current == $slug][0]._id)",
{
draft: buildDraftId(document._id),
published: buildPublishedId(document._id),
slug,
},
);
}

/**
Validate that slug has not been changed after initial publication
*/
async function validateSlugNotChangedAfterPublication(
value: SlugValue | undefined,
{ document, getClient }: ValidationContext,
) {
if (document === undefined || isPublished(document)) {
return true;
}
const publishedDocument = await getClient({ apiVersion }).getDocument(
buildPublishedId(document._id),
);
if (
publishedDocument !== undefined &&
"slug" in publishedDocument &&
isSlug(publishedDocument.slug) &&
publishedDocument.slug.current !== value?.current
) {
return "Can not be changed after publication";
}
return true;
}

const result = await client.fetch(SLUG_QUERY, params);
return result;
function slugify(input: string): string {
return (
input
.toLowerCase()
// replace æøå according to https://sprakradet.no/spraksporsmal-og-svar/ae-o-og-a-i-internasjonal-sammenheng/
.replace(/[æ,å]/g, "a")
.replace(/ø/g, "o")
// remove non-whitespace URL-unsafe chars (section 2.3 in https://www.ietf.org/rfc/rfc3986.txt)
.replace(/[^a-zA-Z0-9-_.~\s]/g, "")
.trim()
.replace(/\s+/g, "-")
.slice(0, SLUG_MAX_LENGTH)
);
}

const SLUG_MAX_LENGTH = 200;
Expand All @@ -37,48 +84,21 @@ function createSlugField(source: string) {
options: {
source,
maxLength: SLUG_MAX_LENGTH,
slugify: (input) =>
input
.toLowerCase()
// replace æøå according to https://sprakradet.no/spraksporsmal-og-svar/ae-o-og-a-i-internasjonal-sammenheng/
.replace(/[æ,å]/g, "a")
.replace(/ø/g, "o")
.replace(/[^a-zA-Z0-9-_.~\s]/g, "") // remove non-whitespace URL-unsafe chars (section 2.3 in https://www.ietf.org/rfc/rfc3986.txt)
.trim()
.replace(/\s+/g, "-")
.slice(0, SLUG_MAX_LENGTH),
slugify,
isUnique: isSlugUniqueAcrossAllDocuments,
},
validation: (rule) =>
rule
.required()
.custom(validateSlugNotChangedAfterPublication)
.custom((value) => {
if (value?.current === undefined) return true;
return (
encodeURIComponent(value.current) === value.current ||
"Slug can only consist of latin letters (a-z, A-Z), digits (0-9), hyphen (-), underscore (_), full stop (.) and tilde (~)"
);
})
.custom(async (value, { document, getClient }) => {
/*
prevent slug changes after initial publication
*/
if (document === undefined || isPublished(document)) return true;
const publishedDocument = await getClient({ apiVersion }).getDocument(
document._id.replace(/^drafts\./, ""),
);
if (
publishedDocument !== undefined &&
"slug" in publishedDocument &&
isSlug(publishedDocument.slug) &&
(value === undefined ||
publishedDocument.slug.current !== value.current)
) {
return "Can not be changed after publication";
}
return true;
}),
readOnly: (ctx) => {
readOnly: ({ document }) => {
/*
make slugs read-only after initial publish
to avoid breaking shared links
Expand All @@ -87,7 +107,7 @@ function createSlugField(source: string) {
if new slugs are needed, redirects can be used instead
*/
return ctx.document !== undefined && isPublished(ctx.document);
return document !== undefined && isPublished(document);
},
});
}
Expand Down
22 changes: 21 additions & 1 deletion studio/utils/documentUtils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
import type { SanityDocument } from "@sanity/types";

export const DRAFTS_PREFIX = "drafts.";

export function isPublished(document: SanityDocument) {
return !isDraft(document);
}

export function isDraft(document: SanityDocument) {
return document._id.startsWith("drafts.") || document._rev === undefined;
return isDraftId(document._id) || document._rev === undefined;
}

export function buildDraftId(documentId: string): string {
return isDraftId(documentId) ? documentId : `${DRAFTS_PREFIX}${documentId}`;
}

export function buildPublishedId(documentId: string): string {
return isPublishedId(documentId)
? documentId
: documentId.slice(DRAFTS_PREFIX.length);
}

function isPublishedId(documentId: string) {
return !isDraftId(documentId);
}

function isDraftId(documentId: string) {
return documentId.startsWith(DRAFTS_PREFIX);
}

0 comments on commit 37c260d

Please sign in to comment.