Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";

import { GlobalBlockRenderer } from "@/blocks/GlobalSectionSlot/GlobalBlockRenderer";
import type { Locale } from "@/lib/types";

export const metadata: Metadata = {
robots: { follow: false, index: false },
title: "Global Block Preview",
};

interface Args {
params: Promise<{
id: string;
locale: Locale;
}>;
}

export default async function GlobalBlockPreviewPage({ params }: Args) {
const { id, locale } = await params;
const referenceId = Number(id);

if (!Number.isInteger(referenceId)) {
notFound();
}

return (
<main>
<GlobalBlockRenderer locale={locale} referenceId={referenceId} />
</main>
);
}
17 changes: 5 additions & 12 deletions apps/cms/src/blocks/GlobalSectionSlot/Component.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
import { draftMode } from "next/headers";

import { renderContentBlock } from "@/blocks/contentBlockComponents";
import { filterHiddenBlocks } from "@/lib/fields/section/filterHiddenBlocks";
import type { GlobalSectionSlotBlock } from "@/payload-types";

export const GlobalSectionSlotBlockComponent = async ({ reference }: GlobalSectionSlotBlock) => {
if (!reference || typeof reference === "number") return null;

const { isEnabled: draft } = await draftMode();
if (!draft && reference._status !== "published") return null;
import { GlobalBlockRenderer } from "./GlobalBlockRenderer";

const [inner] = filterHiddenBlocks(reference.block);
if (!inner) return null;
export const GlobalSectionSlotBlockComponent = ({ reference }: GlobalSectionSlotBlock) => {
const referenceId = typeof reference === "object" && reference ? reference.id : reference;
if (referenceId == null) return null;

return renderContentBlock(inner, inner.id ?? "global-section-inner");
return <GlobalBlockRenderer referenceId={referenceId} />;
};
39 changes: 39 additions & 0 deletions apps/cms/src/blocks/GlobalSectionSlot/GlobalBlockRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { draftMode } from "next/headers";

import { renderContentBlock } from "@/blocks/contentBlockComponents";
import { isPublishedGlobalBlock } from "@/collections/GlobalBlock/slots";
import { getPayloadClient, PAGE_RENDER_DEPTH } from "@/dal";
import { filterHiddenBlocks } from "@/lib/fields/section/filterHiddenBlocks";
import type { Locale } from "@/lib/types";
import { resolveLocale } from "@/lib/utils/resolveLocale";

// Re-fetched because the page query spends one depth level on the slot, and inner blocks need the full depth.
export const GlobalBlockRenderer = async ({
locale,
referenceId,
}: {
locale?: Locale;
referenceId: number;
}) => {
const { isEnabled: draft } = await draftMode();
const resolvedLocale = await resolveLocale(locale);
const payload = await getPayloadClient();

const globalBlock = await payload.findByID({
collection: "globalBlock",
depth: PAGE_RENDER_DEPTH,
disableErrors: true,
draft,
id: referenceId,
locale: resolvedLocale,
overrideAccess: true,
});

if (!globalBlock) return null;
if (!draft && !isPublishedGlobalBlock(globalBlock)) return null;

const [inner] = filterHiddenBlocks(globalBlock.block);
if (!inner) return null;

return renderContentBlock(inner, inner.id ?? "global-section-inner");
};
35 changes: 35 additions & 0 deletions apps/cms/src/blocks/RawHtml/extractText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { RawHtmlBlock } from "@/payload-types";

const TAG_BODY = String.raw`(?:"[^"]*"|'[^']*'|[^>"'])*`;
const SCRIPT_OR_STYLE = new RegExp(
String.raw`<(script|style)\b${TAG_BODY}>[\s\S]*?<\/\1\s*>`,
"giu"
);
const HTML_COMMENT = /<!--[\s\S]*?-->/gu;
const HTML_TAG = new RegExp(`<[a-zA-Z/!?]${TAG_BODY}>`, "gu");
const NAMED_ENTITIES: Record<string, string> = {
"&amp;": "&",
"&gt;": ">",
"&lt;": "<",
"&nbsp;": " ",
"&quot;": '"',
"&#39;": "'",
"&apos;": "'",
};

function decodeEntities(input: string): string {
return input.replace(
/&(?:amp|gt|lt|nbsp|quot|apos|#39);/gu,
(match) => NAMED_ENTITIES[match] ?? match
);
}

export function stripHtml(html: string): string {
const withoutCode = html.replace(SCRIPT_OR_STYLE, " ").replace(HTML_COMMENT, " ");
const withoutTags = withoutCode.replace(HTML_TAG, " ");
return decodeEntities(withoutTags).replace(/\s+/gu, " ").trim();
}

export function extractRawHtmlText(block: RawHtmlBlock): string {
return stripHtml(block.html ?? "");
}
19 changes: 19 additions & 0 deletions apps/cms/src/collections/GlobalBlock/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,21 @@ import type { CollectionConfig } from "payload";
import { contentBlocks } from "@/blocks/contentBlocks";
import { anyone, author, or, superAdmin, user } from "@/lib/access";
import { createLocalizedDefault } from "@/lib/utils/createLocalizedDefault";
import { generatePreviewPath } from "@/lib/utils/generatePreviewPath";
import { shouldIncludeLocalePrefix } from "@/lib/utils/localePrefix";

import { preventDeleteIfReferenced } from "./hooks/preventDeleteIfReferenced";
import { revalidatePagesUsingGlobalBlock } from "./hooks/revalidatePagesUsingGlobalBlock";

const globalBlockPreviewPath = (id: unknown, locale: string): string => {
const prefix = shouldIncludeLocalePrefix(locale) ? `/${locale}` : "";

return generatePreviewPath({
collection: "globalBlock",
path: `${prefix}/preview/global-block/${id}`,
slug: String(id ?? ""),
});
};

export const GlobalBlock: CollectionConfig<"globalBlock"> = {
access: {
Expand All @@ -16,6 +29,11 @@ export const GlobalBlock: CollectionConfig<"globalBlock"> = {
admin: {
defaultColumns: ["title", "block", "updatedAt"],
group: "Global Components",
livePreview: {
url: ({ data, locale }) =>
globalBlockPreviewPath(data?.id, locale.code ?? locale.fallbackLocale),
},
preview: (data, { locale }) => globalBlockPreviewPath(data?.id, locale),
useAsTitle: "title",
},
dbName: "gsec",
Expand Down Expand Up @@ -54,6 +72,7 @@ export const GlobalBlock: CollectionConfig<"globalBlock"> = {
},
],
hooks: {
afterChange: [revalidatePagesUsingGlobalBlock],
beforeDelete: [preventDeleteIfReferenced],
},
labels: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { PayloadRequest } from "payload";

import { I18N_CONFIG } from "@/lib/config/i18n";

export interface PageUsingGlobalBlock {
id: number;
title: string;
}

export async function findPagesUsingGlobalBlock({
id,
includeDrafts = false,
req,
}: {
id: number | string;
includeDrafts?: boolean;
req: PayloadRequest;
}): Promise<PageUsingGlobalBlock[]> {
const pages = new Map<number, PageUsingGlobalBlock>();

for (const { code: locale } of I18N_CONFIG.locales) {
for (const draft of includeDrafts ? [false, true] : [false]) {
const { docs } = await req.payload.find({
collection: "page",
depth: 0,
draft,
locale,
overrideAccess: true,
pagination: false,
req,
select: { title: true },
where: { "blocks.reference": { equals: id } },
});

for (const doc of docs) {
if (!pages.has(doc.id)) {
pages.set(doc.id, { id: doc.id, title: doc.title });
}
}
}
}

return [...pages.values()];
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,16 @@
import { APIError } from "payload";
import type { CollectionBeforeDeleteHook } from "payload";

import { findPagesUsingGlobalBlock } from "../findPagesUsingGlobalBlock";

export const preventDeleteIfReferenced: CollectionBeforeDeleteHook = async ({ id, req }) => {
const pages = await req.payload.find({
collection: "page",
depth: 0,
limit: 100,
overrideAccess: true,
pagination: false,
req,
where: {
"blocks.reference": { equals: id },
},
});
const pages = await findPagesUsingGlobalBlock({ id, includeDrafts: true, req });

if (pages.totalDocs > 0) {
const titles = pages.docs
.map((p) => (typeof p.title === "string" ? p.title : `#${p.id}`))
.join(", ");
if (pages.length > 0) {
const titles = pages.map((p) => p.title || `#${p.id}`).join(", ");

throw new APIError(
`Cannot delete this global block: it is still used by ${pages.totalDocs} page(s): ${titles}. Remove those references first.`,
`Cannot delete this global block: it is still used by ${pages.length} page(s): ${titles}. Remove those references first.`,
400,
undefined,
true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { CollectionAfterChangeHook, PayloadRequest } from "payload";

import { I18N_CONFIG } from "@/lib/config/i18n";
import { revalidatePageCache } from "@/lib/utils/revalidatePageCache";
import type { GlobalBlock } from "@/payload-types";

import { findPagesUsingGlobalBlock } from "../findPagesUsingGlobalBlock";
import { isPublishedGlobalBlock } from "../slots";

async function changesPublishedContent({
doc,
previousDoc,
req,
}: {
doc: GlobalBlock;
previousDoc?: GlobalBlock;
req: PayloadRequest;
}): Promise<boolean> {
if (isPublishedGlobalBlock(doc)) return true;
if (!isPublishedGlobalBlock(previousDoc)) return false;

const live = await req.payload.findByID({
collection: "globalBlock",
depth: 0,
id: doc.id,
overrideAccess: true,
req,
select: { _status: true },
});

return !isPublishedGlobalBlock(live);
}

export const revalidatePagesUsingGlobalBlock: CollectionAfterChangeHook<GlobalBlock> = async ({
doc,
previousDoc,
req,
}) => {
const { context, payload } = req;

if (context.disableRevalidate || !(await changesPublishedContent({ doc, previousDoc, req }))) {
return doc;
}

const pageIds = (await findPagesUsingGlobalBlock({ id: doc.id, req })).map((p) => p.id);
if (pageIds.length === 0) {
return doc;
}

for (const { code: locale } of I18N_CONFIG.locales) {
const { docs: publishedPages } = await payload.find({
collection: "page",
depth: 0,
locale,
overrideAccess: true,
pagination: false,
req,
select: { breadcrumbs: true, slug: true },
where: { _status: { equals: "published" }, id: { in: pageIds } },
});

for (const page of publishedPages) {
revalidatePageCache({ doc: page, locale, payload });
}
}

return doc;
};
21 changes: 21 additions & 0 deletions apps/cms/src/collections/GlobalBlock/slots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { relationId } from "@/lib/contentExtraction";
import type { GlobalBlock } from "@/payload-types";

interface BlockLike {
blockType: string;
reference?: unknown;
}

export function collectGlobalBlockIds(blocks: readonly BlockLike[]): (string | number)[] {
const ids = new Set<string | number>();

for (const block of blocks) {
const id = block.blockType === "globalSectionSlot" ? relationId(block.reference) : null;
if (id != null) ids.add(id);
}

return [...ids];
}

export const isPublishedGlobalBlock = (doc: Pick<GlobalBlock, "_status"> | null | undefined) =>
doc?._status === "published";
Loading
Loading