From 9c9423edfc23e76f5a9a000dae7b1d13c885ccb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kan=20Str=C3=B6berg?= Date: Mon, 14 Apr 2025 08:45:12 +0200 Subject: [PATCH 1/2] fix(web): fix hook order exception closed COD-316 --- apps/web/app/routes/_index.tsx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/web/app/routes/_index.tsx b/apps/web/app/routes/_index.tsx index 4b26518dd..b795b8c01 100644 --- a/apps/web/app/routes/_index.tsx +++ b/apps/web/app/routes/_index.tsx @@ -1,4 +1,5 @@ import { RenderBlocks } from '@codeware/shared/ui/payload-components'; +import type { SiteSetting } from '@codeware/shared/util/payload-types'; import { type MetaFunction, useRouteError } from '@remix-run/react'; import { Container } from '../components/container'; @@ -10,10 +11,22 @@ type LoaderError = { status: number; }; -// TODO: How to use it properly? -export const meta: MetaFunction = () => { - const { landingPage } = useSiteSettings(); - return [{ title: landingPage?.name }]; +export const meta: MetaFunction = ({ matches }) => { + // Get loading page from root loader data + const rootData = matches.find((match) => match.id === 'root')?.data as Record< + string, + SiteSetting + >; + + let title = 'Home'; + + if (rootData && 'siteSettings' in rootData) { + if (typeof rootData.siteSettings.general.landingPage === 'object') { + title = rootData.siteSettings.general.landingPage.meta?.title ?? title; + } + } + + return [{ title }]; }; export default function Index() { From e1656255fc7f9a0299a38c14091549a3536b44de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kan=20Str=C3=B6berg?= Date: Mon, 14 Apr 2025 12:02:02 +0200 Subject: [PATCH 2/2] feat(web): apply meta from cms data closed COD-303 --- apps/web/app/components/render-pages-doc.tsx | 2 +- apps/web/app/components/render-posts-doc.tsx | 2 +- apps/web/app/root.tsx | 12 +---- apps/web/app/routes/($collection).$slug.tsx | 20 ++++---- apps/web/app/routes/_index.tsx | 22 ++++----- apps/web/app/utils/default-app-name.ts | 4 ++ .../app/utils/get-site-settings-from-root.ts | 18 +++++++ libs/shared/util/payload-api/src/index.ts | 1 - .../src/lib/find-navigation-doc.ts | 19 +++++++- .../util/payload-api/src/lib/utils/types.ts | 21 +-------- .../payload-types/src/lib/custom-types.ts | 34 ++++++++++++++ .../util/payload-utils/eslint.config.mjs | 3 ++ libs/shared/util/payload-utils/project.json | 8 ++++ libs/shared/util/payload-utils/src/index.ts | 1 + .../payload-utils/src/lib/resolve-meta.ts | 47 +++++++++++++++++++ libs/shared/util/payload-utils/tsconfig.json | 22 +++++++++ .../util/payload-utils/tsconfig.lib.json | 17 +++++++ .../util/payload-utils/tsconfig.spec.json | 22 +++++++++ .../shared/util/payload-utils/vite.config.mts | 21 +++++++++ libs/shared/util/typesafe/src/lib/typesafe.ts | 9 ++++ tsconfig.base.json | 3 ++ 21 files changed, 250 insertions(+), 58 deletions(-) create mode 100644 apps/web/app/utils/default-app-name.ts create mode 100644 apps/web/app/utils/get-site-settings-from-root.ts create mode 100644 libs/shared/util/payload-utils/eslint.config.mjs create mode 100644 libs/shared/util/payload-utils/project.json create mode 100644 libs/shared/util/payload-utils/src/index.ts create mode 100644 libs/shared/util/payload-utils/src/lib/resolve-meta.ts create mode 100644 libs/shared/util/payload-utils/tsconfig.json create mode 100644 libs/shared/util/payload-utils/tsconfig.lib.json create mode 100644 libs/shared/util/payload-utils/tsconfig.spec.json create mode 100644 libs/shared/util/payload-utils/vite.config.mts diff --git a/apps/web/app/components/render-pages-doc.tsx b/apps/web/app/components/render-pages-doc.tsx index d4967dd91..31aec49ec 100644 --- a/apps/web/app/components/render-pages-doc.tsx +++ b/apps/web/app/components/render-pages-doc.tsx @@ -1,5 +1,5 @@ import { RenderBlocks } from '@codeware/shared/ui/payload-components'; -import type { NavigationDoc } from '@codeware/shared/util/payload-api'; +import type { NavigationDoc } from '@codeware/shared/util/payload-types'; /** * Render a pages collection document. diff --git a/apps/web/app/components/render-posts-doc.tsx b/apps/web/app/components/render-posts-doc.tsx index 220bd7e48..b06b52c02 100644 --- a/apps/web/app/components/render-posts-doc.tsx +++ b/apps/web/app/components/render-posts-doc.tsx @@ -1,5 +1,5 @@ import { RichText } from '@codeware/shared/ui/payload-components'; -import type { NavigationDoc } from '@codeware/shared/util/payload-api'; +import type { NavigationDoc } from '@codeware/shared/util/payload-types'; /** * Render a posts collection document. diff --git a/apps/web/app/root.tsx b/apps/web/app/root.tsx index 65d8e7bbb..3404096f7 100644 --- a/apps/web/app/root.tsx +++ b/apps/web/app/root.tsx @@ -9,11 +9,7 @@ import { getSiteSettings } from '@codeware/shared/util/payload-api'; import type { SiteSetting } from '@codeware/shared/util/payload-types'; -import type { - LinksFunction, - LoaderFunctionArgs, - MetaFunction -} from '@remix-run/node'; +import type { LinksFunction, LoaderFunctionArgs } from '@remix-run/node'; import { Link, Links, @@ -40,12 +36,6 @@ import { ClientHintCheck, getHints } from './utils/client-hints'; import { getPayloadRequestOptions } from './utils/get-payload-request-options'; import { type Theme, getTheme } from './utils/theme.server'; -export const meta: MetaFunction = ({ data }) => [ - { - title: data?.siteSettings?.general?.appName ?? '' - } -]; - export const links: LinksFunction = () => [ { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, { diff --git a/apps/web/app/routes/($collection).$slug.tsx b/apps/web/app/routes/($collection).$slug.tsx index fb193eeb8..a844b4161 100644 --- a/apps/web/app/routes/($collection).$slug.tsx +++ b/apps/web/app/routes/($collection).$slug.tsx @@ -1,4 +1,5 @@ import { findNavigationDoc } from '@codeware/shared/util/payload-api'; +import { resolveMeta } from '@codeware/shared/util/payload-utils'; import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node'; import { json, useLoaderData, useRouteError } from '@remix-run/react'; @@ -6,22 +7,23 @@ import { Container } from '../components/container'; import { ErrorContainer } from '../components/error-container'; import { RenderPagesDoc } from '../components/render-pages-doc'; import { RenderPostsDoc } from '../components/render-posts-doc'; +import { defaultAppName } from '../utils/default-app-name'; import { getPayloadRequestOptions } from '../utils/get-payload-request-options'; +import { getSiteSettingsFromRoot } from '../utils/get-site-settings-from-root'; type LoaderError = { message: string; status: number; }; -// TODO: How to use it properly? -export const meta: MetaFunction = ({ data }) => { - if (data?.doc.collection === 'pages') { - return [{ title: data.doc.name }]; - } - if (data?.doc.collection === 'posts') { - return [{ title: data.doc.title }]; - } - return [{ title: 'Page Not Found' }]; +export const meta: MetaFunction = ({ data, matches }) => { + // Get site settings from root loader data + const siteSettings = getSiteSettingsFromRoot(matches); + const appName = siteSettings?.general?.appName ?? defaultAppName; + + const meta = resolveMeta(data?.doc); + + return [{ title: `${appName} - ${meta?.title ?? 'Page'}` }]; }; /** diff --git a/apps/web/app/routes/_index.tsx b/apps/web/app/routes/_index.tsx index b795b8c01..b055922bd 100644 --- a/apps/web/app/routes/_index.tsx +++ b/apps/web/app/routes/_index.tsx @@ -1,9 +1,11 @@ import { RenderBlocks } from '@codeware/shared/ui/payload-components'; -import type { SiteSetting } from '@codeware/shared/util/payload-types'; +import { resolveMeta } from '@codeware/shared/util/payload-utils'; import { type MetaFunction, useRouteError } from '@remix-run/react'; import { Container } from '../components/container'; import { ErrorContainer } from '../components/error-container'; +import { defaultAppName } from '../utils/default-app-name'; +import { getSiteSettingsFromRoot } from '../utils/get-site-settings-from-root'; import { useSiteSettings } from '../utils/use-site-settings'; type LoaderError = { @@ -12,21 +14,13 @@ type LoaderError = { }; export const meta: MetaFunction = ({ matches }) => { - // Get loading page from root loader data - const rootData = matches.find((match) => match.id === 'root')?.data as Record< - string, - SiteSetting - >; + // Get site settings from root loader data + const siteSettings = getSiteSettingsFromRoot(matches); + const appName = siteSettings?.general?.appName ?? defaultAppName; - let title = 'Home'; + const meta = resolveMeta(siteSettings); - if (rootData && 'siteSettings' in rootData) { - if (typeof rootData.siteSettings.general.landingPage === 'object') { - title = rootData.siteSettings.general.landingPage.meta?.title ?? title; - } - } - - return [{ title }]; + return [{ title: `${appName} - ${meta?.title ?? 'Home'}` }]; }; export default function Index() { diff --git a/apps/web/app/utils/default-app-name.ts b/apps/web/app/utils/default-app-name.ts new file mode 100644 index 000000000..8bf36c352 --- /dev/null +++ b/apps/web/app/utils/default-app-name.ts @@ -0,0 +1,4 @@ +/** + * The default app name when the site settings are not found. + */ +export const defaultAppName = 'App' as const; diff --git a/apps/web/app/utils/get-site-settings-from-root.ts b/apps/web/app/utils/get-site-settings-from-root.ts new file mode 100644 index 000000000..181e70d29 --- /dev/null +++ b/apps/web/app/utils/get-site-settings-from-root.ts @@ -0,0 +1,18 @@ +import type { SiteSetting } from '@codeware/shared/util/payload-types'; +import type { MetaFunction } from '@remix-run/react'; + +/** + * Server side utility to get the site settings from the root loader data. + * + * @param matches - The `matches` object from the root loader. + * @returns The site settings or `null` if not found. + */ +export const getSiteSettingsFromRoot = ( + matches: Parameters[0]['matches'] +) => { + const rootData = matches.find((match) => match.id === 'root')?.data as + | Record<'siteSettings', SiteSetting> + | undefined; + + return rootData?.siteSettings ?? null; +}; diff --git a/libs/shared/util/payload-api/src/index.ts b/libs/shared/util/payload-api/src/index.ts index 82b8ee8bb..496c2f6f1 100644 --- a/libs/shared/util/payload-api/src/index.ts +++ b/libs/shared/util/payload-api/src/index.ts @@ -1,7 +1,6 @@ export { apiKeyPrefix, authorizationHeader } from './lib/utils/definitions'; export type { MethodOptions, - NavigationDoc, NavigationItem, RequestBaseOptions, RequestMethod diff --git a/libs/shared/util/payload-api/src/lib/find-navigation-doc.ts b/libs/shared/util/payload-api/src/lib/find-navigation-doc.ts index bf53fff9a..31f085d53 100644 --- a/libs/shared/util/payload-api/src/lib/find-navigation-doc.ts +++ b/libs/shared/util/payload-api/src/lib/find-navigation-doc.ts @@ -1,11 +1,12 @@ import type { + NavigationDoc, NavigationReferenceCollection, Page, Post } from '@codeware/shared/util/payload-types'; import { invokeRequest } from './utils/invoke-request'; -import type { NavigationDoc, RequestBaseOptions } from './utils/types'; +import type { RequestBaseOptions } from './utils/types'; /** * Find a navigation document by the URL collection and slug parameters. @@ -45,6 +46,14 @@ export const findNavigationDoc = async ( collection: lookupCollection, header: page.header, layout: page.layout, + meta: { + description: page.meta?.description ?? undefined, + image: + (typeof page.meta?.image === 'object' + ? page.meta?.image + : undefined) ?? undefined, + title: page.meta?.title ?? undefined + }, name: page.name }; } @@ -56,6 +65,14 @@ export const findNavigationDoc = async ( content: post.content, heroImage: typeof post.heroImage === 'object' ? post.heroImage : undefined, + meta: { + description: post.meta?.description ?? undefined, + image: + (typeof post.meta?.image === 'object' + ? post.meta?.image + : undefined) ?? undefined, + title: post.meta?.title ?? undefined + }, title: post.title }; } diff --git a/libs/shared/util/payload-api/src/lib/utils/types.ts b/libs/shared/util/payload-api/src/lib/utils/types.ts index 811aa1957..71d091bcd 100644 --- a/libs/shared/util/payload-api/src/lib/utils/types.ts +++ b/libs/shared/util/payload-api/src/lib/utils/types.ts @@ -1,9 +1,4 @@ -import type { - Media, - NavigationReferenceCollection, - Page, - Post -} from '@codeware/shared/util/payload-types'; +import type { NavigationReferenceCollection } from '@codeware/shared/util/payload-types'; /** * Available request options depending on the request method. @@ -36,20 +31,6 @@ export type MethodOptions = T extends 'GET' body: Record; }; -/** - * Document details for a navigation item. - */ -export type NavigationDoc = - // limit what can be exposed client side - | ({ - collection: 'pages'; - } & Pick) - | ({ - collection: 'posts'; - } & Pick & { - heroImage?: Media | null | undefined; - }); - /** * Navigation tree item. */ diff --git a/libs/shared/util/payload-types/src/lib/custom-types.ts b/libs/shared/util/payload-types/src/lib/custom-types.ts index bfaf1d820..b2f693a3e 100644 --- a/libs/shared/util/payload-types/src/lib/custom-types.ts +++ b/libs/shared/util/payload-types/src/lib/custom-types.ts @@ -1,3 +1,4 @@ +import type { StripTypes } from '@codeware/shared/util/typesafe'; import type { ClientUser, User } from 'payload'; import type { @@ -6,7 +7,10 @@ import type { ContentBlock, Form, FormSubmission, + Media, Navigation, + Page, + Post, Tenant, TenantsArrayField } from './payload-types'; @@ -69,6 +73,36 @@ export type FormSubmissionData = NonNullable< NonNullable >; +type PageMetaDefined = NonNullable; +type PostMetaDefined = NonNullable; + +/** Page meta type */ +export type PageMeta = { + [K in keyof PageMetaDefined]: StripTypes; +}; + +/** Post meta type */ +export type PostMeta = { + [K in keyof PostMetaDefined]: StripTypes; +}; + +/** + * Document details for a navigation item. + */ +export type NavigationDoc = + // limit what can be exposed client side + | ({ + collection: 'pages'; + } & Pick & { + meta: PageMeta; + }) + | ({ + collection: 'posts'; + } & Pick & { + heroImage?: Media | null | undefined; + meta: PostMeta; + }); + /** Navigation reference collection */ export type NavigationReferenceCollection = NonNullable< NonNullable[number] diff --git a/libs/shared/util/payload-utils/eslint.config.mjs b/libs/shared/util/payload-utils/eslint.config.mjs new file mode 100644 index 000000000..4c373a675 --- /dev/null +++ b/libs/shared/util/payload-utils/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from '../../../../eslint.config.mjs'; + +export default [...baseConfig]; diff --git a/libs/shared/util/payload-utils/project.json b/libs/shared/util/payload-utils/project.json new file mode 100644 index 000000000..eb74bd4f1 --- /dev/null +++ b/libs/shared/util/payload-utils/project.json @@ -0,0 +1,8 @@ +{ + "name": "shared-util-payload-utils", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/shared/util/payload-utils/src", + "projectType": "library", + "tags": ["scope:shared", "type:util", "domain:payload"], + "targets": {} +} diff --git a/libs/shared/util/payload-utils/src/index.ts b/libs/shared/util/payload-utils/src/index.ts new file mode 100644 index 000000000..7e6aa8198 --- /dev/null +++ b/libs/shared/util/payload-utils/src/index.ts @@ -0,0 +1 @@ +export { resolveMeta } from './lib/resolve-meta'; diff --git a/libs/shared/util/payload-utils/src/lib/resolve-meta.ts b/libs/shared/util/payload-utils/src/lib/resolve-meta.ts new file mode 100644 index 000000000..3d8b2df3e --- /dev/null +++ b/libs/shared/util/payload-utils/src/lib/resolve-meta.ts @@ -0,0 +1,47 @@ +import type { + NavigationDoc, + PageMeta, + PostMeta, + SiteSetting +} from '@codeware/shared/util/payload-types'; + +/** + * Resolve the meta for a navigation document or the landing page from site settings. + * + * @param data - The navigation document or site settings to resolve the meta for. + * @returns Page or post meta or `null` if the meta data is not found. + */ +export const resolveMeta = ( + data: NavigationDoc | SiteSetting | null | undefined +): PageMeta | PostMeta | null => { + if (!data) { + return null; + } + + // Resolve site settings landing page meta + if ('general' in data) { + const { landingPage } = data.general; + if (typeof landingPage === 'object') { + const { description, image, title } = landingPage.meta ?? {}; + return { + description: description ?? undefined, + image: (typeof image === 'object' ? image : undefined) ?? undefined, + title: title ?? undefined + }; + } + } + + // Resolve collection page or post meta + if ('collection' in data) { + const { collection } = data; + switch (collection) { + case 'pages': + case 'posts': + return data.meta; + default: + return null; + } + } + + return null; +}; diff --git a/libs/shared/util/payload-utils/tsconfig.json b/libs/shared/util/payload-utils/tsconfig.json new file mode 100644 index 000000000..13eea9f7e --- /dev/null +++ b/libs/shared/util/payload-utils/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/shared/util/payload-utils/tsconfig.lib.json b/libs/shared/util/payload-utils/tsconfig.lib.json new file mode 100644 index 000000000..6cbb4f3e2 --- /dev/null +++ b/libs/shared/util/payload-utils/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] +} diff --git a/libs/shared/util/payload-utils/tsconfig.spec.json b/libs/shared/util/payload-utils/tsconfig.spec.json new file mode 100644 index 000000000..a10e2fb63 --- /dev/null +++ b/libs/shared/util/payload-utils/tsconfig.spec.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.spec.ts", + "src/**/*.spec.tsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/shared/util/payload-utils/vite.config.mts b/libs/shared/util/payload-utils/vite.config.mts new file mode 100644 index 000000000..43d5f7c92 --- /dev/null +++ b/libs/shared/util/payload-utils/vite.config.mts @@ -0,0 +1,21 @@ +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../../../node_modules/.vite/libs/shared/util/payload-utils', + plugins: [nxViteTsPaths()], + test: { + name: 'shared-util-payload-utils', + watch: false, + globals: true, + environment: 'node', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../../../coverage/libs/shared/util/payload-utils', + provider: 'v8' + }, + passWithNoTests: true + } +}); diff --git a/libs/shared/util/typesafe/src/lib/typesafe.ts b/libs/shared/util/typesafe/src/lib/typesafe.ts index a10ea318b..7e25d3a12 100644 --- a/libs/shared/util/typesafe/src/lib/typesafe.ts +++ b/libs/shared/util/typesafe/src/lib/typesafe.ts @@ -5,3 +5,12 @@ export type DeepRequired = { export type Prettify = { [K in keyof T]: T[K]; } & {}; + +/** + * Strip types from a type + * + * @example + * type Stripped = StripTypes<{ a: number | null }, null> + * // { a: number } + */ +export type StripTypes = T extends U ? never : T; diff --git a/tsconfig.base.json b/tsconfig.base.json index cceff564b..01b6b9420 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -90,6 +90,9 @@ "@codeware/shared/util/payload-types": [ "libs/shared/util/payload-types/src/index.ts" ], + "@codeware/shared/util/payload-utils": [ + "libs/shared/util/payload-utils/src/index.ts" + ], "@codeware/shared/util/pure": ["libs/shared/util/pure/src/index.ts"], "@codeware/shared/util/seed": ["libs/shared/util/seed/src/index.ts"], "@codeware/shared/util/signature": [