diff --git a/apps/roboshield/payload-types.ts b/apps/roboshield/payload-types.ts
index 9ca401cb1..71e1419a5 100644
--- a/apps/roboshield/payload-types.ts
+++ b/apps/roboshield/payload-types.ts
@@ -390,6 +390,9 @@ export interface SettingsSite {
title: string;
embedCode: string;
};
+ analytics?: {
+ analyticsId?: string | null;
+ };
initiative: {
title: string;
description: {
@@ -411,6 +414,11 @@ export interface SettingsSite {
}[]
| null;
};
+ meta?: {
+ title?: string | null;
+ description?: string | null;
+ image?: string | Media | null;
+ };
updatedAt?: string | null;
createdAt?: string | null;
}
diff --git a/apps/roboshield/payload.config.ts b/apps/roboshield/payload.config.ts
index 487f2b6da..1b9a0c6d3 100644
--- a/apps/roboshield/payload.config.ts
+++ b/apps/roboshield/payload.config.ts
@@ -1,19 +1,19 @@
-import { buildConfig } from "payload/config";
-import { slateEditor } from "@payloadcms/richtext-slate";
-import { mongooseAdapter } from "@payloadcms/db-mongodb";
+import { loadEnvConfig } from "@next/env";
import { webpackBundler } from "@payloadcms/bundler-webpack";
-import { CollectionConfig, GlobalConfig } from "payload/types";
+import { mongooseAdapter } from "@payloadcms/db-mongodb";
import { cloudStorage } from "@payloadcms/plugin-cloud-storage";
-import Site from "./src/payload/globals/Site";
+import { s3Adapter } from "@payloadcms/plugin-cloud-storage/s3";
+import nestedDocs from "@payloadcms/plugin-nested-docs";
+import seo from "@payloadcms/plugin-seo";
+import { slateEditor } from "@payloadcms/richtext-slate";
+import { buildConfig } from "payload/config";
+import { CollectionConfig, GlobalConfig } from "payload/types";
+import { Config } from "./payload-types";
import Media from "./src/payload/collections/Media";
import Pages from "./src/payload/collections/Pages";
-import seo from "@payloadcms/plugin-seo";
-import nestedDocs from "@payloadcms/plugin-nested-docs";
-import { s3Adapter } from "@payloadcms/plugin-cloud-storage/s3";
import Users from "./src/payload/collections/Users";
+import Site from "./src/payload/globals/Site";
import { defaultLocale, locales } from "./src/payload/utils/locales";
-import { loadEnvConfig } from "@next/env";
-import { Config } from "./payload-types";
const projectDir = process.cwd();
loadEnvConfig(projectDir);
@@ -109,7 +109,7 @@ export default buildConfig({
}),
seo({
collections: ["pages"],
- globals: [],
+ globals: ["settings-site"],
uploadsCollection: "media",
generateTitle: ({ doc }: any) => doc?.title?.value as string,
generateURL: ({ doc }: any) =>
diff --git a/apps/roboshield/src/components/Page/Page.tsx b/apps/roboshield/src/components/Page/Page.tsx
index f2eb50082..8056f0d5e 100644
--- a/apps/roboshield/src/components/Page/Page.tsx
+++ b/apps/roboshield/src/components/Page/Page.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { NextSeo, NextSeoProps } from "next-seo";
import Footer from "@/roboshield/components/Footer";
import type { FooterProps } from "@/roboshield/components/Footer";
@@ -30,6 +31,7 @@ interface Props {
children?: React.ReactNode;
navbar?: Navbar;
footer?: FooterProps;
+ seo?: NextSeoProps;
slug?: string;
}
@@ -37,9 +39,10 @@ interface Props {
* While the layout (navbar, footer) remain the same, the main component
* changes from page to page. Use `slug` to track page changes.
*/
-function Page({ children, footer, navbar, slug }: Props) {
+function Page({ children, footer, navbar, seo, slug }: Props) {
return (
<>
+
{navbar ? : null}
{children ? {children} : null}
{footer ? : null}
diff --git a/apps/roboshield/src/lib/data/common/index.ts b/apps/roboshield/src/lib/data/common/index.ts
index e384b0ef8..f0c91b882 100644
--- a/apps/roboshield/src/lib/data/common/index.ts
+++ b/apps/roboshield/src/lib/data/common/index.ts
@@ -1,7 +1,8 @@
-import { blockify } from "../blockify";
-import { GetServerSidePropsContext } from "next";
+import { blockify } from "@/roboshield/lib/data/blockify";
+import getPageSeoFromMeta from "@/roboshield/lib/data/seo";
import { Api } from "@/roboshield/lib/payload";
import { SettingsSite } from "@/root/payload-types";
+import { GetServerSidePropsContext } from "next";
export function imageFromMedia(alt: string, url: string) {
return { alt, src: url };
@@ -52,13 +53,13 @@ export async function getPageProps(
) {
// For now, RoboShield only supports single paths i.e. /, /about, etc.,
// so params.slug[0] is good enough
- const {
- params: { slug: slugs },
- } = context;
+ const slugs = context.params?.slug as string[] | undefined;
const [slug] = slugs || ["index"];
+ const { draftMode = false } = context;
+ const options = { draft: draftMode };
const {
docs: [page],
- } = await api.findPage(slug);
+ } = await api.findPage(slug, options);
if (!page) {
return null;
@@ -67,12 +68,17 @@ export async function getPageProps(
const blocks = await blockify(page.blocks, api);
const siteSettings = await api.findGlobal("settings-site");
- const navbar = getNavBar(siteSettings);
+ const { analytics } = siteSettings;
const footer = getFooter(siteSettings);
+ const navbar = getNavBar(siteSettings);
+ const seo = getPageSeoFromMeta(page, siteSettings);
+
return {
+ analytics,
blocks,
footer,
navbar,
+ seo,
slug,
};
}
diff --git a/apps/roboshield/src/lib/data/seo.ts b/apps/roboshield/src/lib/data/seo.ts
new file mode 100644
index 000000000..3ba666d1f
--- /dev/null
+++ b/apps/roboshield/src/lib/data/seo.ts
@@ -0,0 +1,94 @@
+import site from "@/roboshield/utils/site";
+import { Media, Page, SettingsSite } from "@/root/payload-types";
+import { NextSeoProps } from "next-seo";
+
+type OpenGraphMedia = {
+ url: string;
+ width?: number | null;
+ height?: number | null;
+ alt?: string;
+ type?: string;
+ secureUrl?: string;
+};
+
+type RichTextProps = {
+ [k: string]: unknown;
+}[];
+
+function stringifyDescription(description: RichTextProps) {
+ if (!Array.isArray(description)) {
+ return "";
+ }
+ return description.reduce((result, item) => {
+ if (item.text) {
+ // eslint-disable-next-line no-param-reassign
+ result += item.text;
+ }
+
+ if (Array.isArray(item.children)) {
+ // eslint-disable-next-line no-param-reassign
+ result += stringifyDescription(item.children);
+ }
+ return result;
+ }, "");
+}
+
+function mediaToImage(
+ stringOrMediaImage: string | Media | null | undefined,
+ title: string | null,
+): OpenGraphMedia | null {
+ const media = stringOrMediaImage as Media;
+
+ if (!media?.url) {
+ return null;
+ }
+ const { height, mimeType: type, url, width } = media;
+ const image: OpenGraphMedia = {
+ height,
+ url,
+ width,
+ };
+ if (type) {
+ image.type = type;
+ }
+ const alt = media.alt || title;
+ if (alt) {
+ image.alt = alt;
+ }
+ return image;
+}
+
+export default function getPageSeoFromMeta(
+ page: Page,
+ settings: SettingsSite,
+): NextSeoProps {
+ const canonical = site.url.replace(/\/+$/, "");
+ const defaultTitle = settings.meta?.title || settings.title || site.name;
+ const title = page.meta?.title || page.title || defaultTitle;
+ const titleTemplate = defaultTitle && `%s | ${defaultTitle}`;
+ const description =
+ page.meta?.description ||
+ settings.meta?.description ||
+ stringifyDescription(settings.description);
+ const openGraph: Record = {
+ description,
+ type: "website",
+ siteName: defaultTitle,
+ };
+ const image =
+ mediaToImage(page.meta?.image, title) ||
+ mediaToImage(settings.meta?.image, title);
+ if (image) {
+ openGraph.images = [image];
+ }
+ const seo = {
+ title,
+ titleTemplate,
+ defaultTitle,
+ description,
+ canonical,
+ openGraph,
+ };
+
+ return Object.fromEntries(Object.entries(seo).filter(([key, val]) => val));
+}
diff --git a/apps/roboshield/src/lib/payload/index.ts b/apps/roboshield/src/lib/payload/index.ts
index 94178357f..237d9a7a2 100644
--- a/apps/roboshield/src/lib/payload/index.ts
+++ b/apps/roboshield/src/lib/payload/index.ts
@@ -8,8 +8,12 @@ export type CollectionConfig = keyof Config["collections"];
export type CollectionItemTypes = Config["collections"][CollectionConfig];
export type GlobalConfig = keyof Config["globals"];
-async function findPage(slug: string): Promise> {
+async function findPage(
+ slug: string,
+ options?: Partial>,
+): Promise> {
return payload.find({
+ ...options,
collection: "pages",
where: {
slug: {
diff --git a/apps/roboshield/src/next-seo.config.js b/apps/roboshield/src/next-seo.config.js
new file mode 100644
index 000000000..e5eedabb2
--- /dev/null
+++ b/apps/roboshield/src/next-seo.config.js
@@ -0,0 +1,26 @@
+import site from "@/roboshield/utils/site";
+
+const config = {
+ openGraph: {
+ type: "website",
+ locale: "en_GB",
+ url: site.environmentUrl,
+ site_name: site.name,
+ images: [
+ {
+ url: `${site.environmentUrl}image.jpg`,
+ width: 1600,
+ height: 800,
+ alt: site.name,
+ type: "image/jpeg",
+ },
+ ],
+ },
+ twitter: {
+ handle: "@Code4Africa",
+ site: "@Code4Africa",
+ cardType: "summary_large_image",
+ },
+};
+
+export default config;
diff --git a/apps/roboshield/src/utils/site.ts b/apps/roboshield/src/utils/site.ts
new file mode 100644
index 000000000..6064ddb21
--- /dev/null
+++ b/apps/roboshield/src/utils/site.ts
@@ -0,0 +1,25 @@
+const name = process.env.NEXT_PUBLIC_APP_NAME ?? "RoboShield";
+
+// see: https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname
+const ensureTrailingSlash = (str: string) => {
+ const url = new URL(str);
+ if (!url.pathname.endsWith("/")) {
+ url.pathname = `${url.pathname}/`;
+ }
+ return url.toString();
+};
+const url = ensureTrailingSlash(process.env.NEXT_PUBLIC_APP_URL as string);
+let environmentUrl = url;
+if (process.env.NEXT_PUBLIC_VERCEL_ENV === "preview") {
+ environmentUrl = ensureTrailingSlash(
+ `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`,
+ );
+}
+
+const site = {
+ environmentUrl,
+ name,
+ url,
+};
+
+export default site;