diff --git a/implementations/nextjs-sdk_ssr/.env.example b/implementations/nextjs-sdk_ssr/.env.example index 07e791524..ef67a4e32 100644 --- a/implementations/nextjs-sdk_ssr/.env.example +++ b/implementations/nextjs-sdk_ssr/.env.example @@ -1,7 +1,6 @@ DOTENV_CONFIG_QUIET=true -# SKIP_NO_JS skips the JavaScript-disabled variant resolution suite (SSR, JavaScript disabled). -E2E_FLAGS=SSR,SKIP_NO_JS +E2E_FLAGS=CSR,HYDRATION,SSR APP_PORT=3001 PUBLIC_NINETAILED_CLIENT_ID="mock-client-id" diff --git a/implementations/nextjs-sdk_ssr/AGENTS.md b/implementations/nextjs-sdk_ssr/AGENTS.md index 64fe94551..4de7d0f24 100644 --- a/implementations/nextjs-sdk_ssr/AGENTS.md +++ b/implementations/nextjs-sdk_ssr/AGENTS.md @@ -17,8 +17,7 @@ server/client SDK composition; app code imports only Next.js SDK subpaths. - All E2E tests live in `lib/e2e-web`. Hydration-specific specs are gated with `runIf('HYDRATION')` and run automatically when `E2E_FLAGS=CSR,HYDRATION`. This implementation uses - `E2E_FLAGS=SSR,SKIP_NO_JS`: SSR enables the SSR suite, and `SKIP_NO_JS` skips the - JavaScript-disabled variant resolution suite (`Variant Resolution (SSR, JavaScript disabled)`). + `E2E_FLAGS=CSR,HYDRATION,SSR`. - Entry cards must expose `data-ctfl-entry-id` on the `content-*` element so shared selectors work. - `test:e2e` delegates to `lib/e2e-web`. diff --git a/implementations/nextjs-sdk_ssr/app/globals.css b/implementations/nextjs-sdk_ssr/app/globals.css deleted file mode 100644 index d4b507858..000000000 --- a/implementations/nextjs-sdk_ssr/app/globals.css +++ /dev/null @@ -1 +0,0 @@ -@import 'tailwindcss'; diff --git a/implementations/nextjs-sdk_ssr/app/layout.tsx b/implementations/nextjs-sdk_ssr/app/layout.tsx index a2c84a055..afbb86717 100644 --- a/implementations/nextjs-sdk_ssr/app/layout.tsx +++ b/implementations/nextjs-sdk_ssr/app/layout.tsx @@ -2,13 +2,12 @@ import { GlobalLiveUpdatesProvider } from '@/components/GlobalLiveUpdatesProvide import { PreviewPanel } from '@/components/PreviewPanel' import { TrackingLog } from '@/components/TrackingLog' import { appConfig } from '@/lib/config' -import { getAppConsent } from '@/lib/util' +import { optimization } from '@/lib/optimization' import { NextAppAutoPageTracker, OptimizationRoot } from '@contentful/optimization-nextjs/client' import 'e2e-web/theme.css' import type { Metadata } from 'next' -import { cookies } from 'next/headers' import Link from 'next/link' -import { Suspense, type ReactNode } from 'react' +import { type ReactNode } from 'react' export const metadata: Metadata = { title: 'Optimization Next.js SDK SSR', @@ -16,9 +15,11 @@ export const metadata: Metadata = { 'Next.js App Router reference: the Next.js SDK resolves entries server-side and handles client-side tracking and interactive controls.', } +export const dynamic = 'force-dynamic' + export default async function RootLayout({ children }: Readonly<{ children: ReactNode }>) { - const cookieStore = await cookies() - const appConsent = getAppConsent(cookieStore) + const { profile, selectedOptimizations, changes, initialPageEvent, hasConsent } = + await optimization.getServerState() return ( @@ -30,17 +31,20 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac api={appConfig.api} trackEntryInteraction={{ views: true, clicks: true, hovers: true }} logLevel="debug" + defaults={{ + consent: hasConsent, + persistenceConsent: hasConsent, + ...(profile ? { profile } : {}), + ...(hasConsent && selectedOptimizations ? { selectedOptimizations, changes } : {}), + }} app={{ name: 'Contentful Optimization Next.js SDK SSR (Client)', version: '0.1.0', }} - defaults={{ consent: appConsent, persistenceConsent: appConsent }} > - - - +
diff --git a/implementations/nextjs-sdk_ssr/components/EntryCard.client.tsx b/implementations/nextjs-sdk_ssr/components/EntryCard.client.tsx new file mode 100644 index 000000000..05bcb64c1 --- /dev/null +++ b/implementations/nextjs-sdk_ssr/components/EntryCard.client.tsx @@ -0,0 +1,29 @@ +'use client' + +import type { ContentEntry } from '@/lib/contentful' +import { OptimizedEntry } from '@contentful/optimization-nextjs/client' +import type { JSX } from 'react' + +interface EntryCardClientProps { + entry: ContentEntry + liveUpdates?: boolean + testId?: string +} + +export function EntryCardClient({ entry, liveUpdates, testId }: EntryCardClientProps): JSX.Element { + return ( + + {({ fields, sys: { id } }) => ( +
+
+

{String(fields.text ?? '')}

+

{`[Entry: ${id}]`}

+
+
+ )} +
+ ) +} diff --git a/implementations/nextjs-sdk_ssr/components/EntryCard.tsx b/implementations/nextjs-sdk_ssr/components/EntryCard.tsx index 93f17d363..44fdd1c34 100644 --- a/implementations/nextjs-sdk_ssr/components/EntryCard.tsx +++ b/implementations/nextjs-sdk_ssr/components/EntryCard.tsx @@ -1,58 +1,37 @@ -import type { ContentEntry, ContentEntrySkeleton } from '@/lib/contentful' -import { - isRecord, - isResolvedContentfulEntry, - isRichTextDocument, -} from '@contentful/optimization-nextjs/api-schemas' -import { - ServerOptimizedEntry, - type ServerTrackingResolvedData, -} from '@contentful/optimization-nextjs/server' -import { documentToReactComponents, type Options } from '@contentful/rich-text-react-renderer' -import { INLINES } from '@contentful/rich-text-types' +import { EntryCardClient } from '@/components/EntryCard.client' +import type { ContentEntry } from '@/lib/contentful' +import type { Entry } from '@/lib/optimization' +import { isRichTextField } from '@/lib/util' +import { ServerOptimizedEntry } from '@contentful/optimization-nextjs/server' +import { documentToReactComponents } from '@contentful/rich-text-react-renderer' import type { EntryClickScenario } from 'e2e-web' import type { JSX } from 'react' const HOVER_DURATION_UPDATE_INTERVAL_MS = 1000 -type MergeTagResolver = (entry: unknown) => string | undefined - interface EntryCardProps { - baselineEntry: ContentEntry + entry: ContentEntry | Entry + liveUpdates?: boolean + testId?: string clickScenario?: EntryClickScenario - getMergeTagValue?: MergeTagResolver - manualTracking: boolean - resolveEntry?: (entry: ContentEntry) => ServerTrackingResolvedData - resolvedData: ServerTrackingResolvedData + manualTracking?: boolean } -function buildRenderOptions(getMergeTagValue?: MergeTagResolver): Options { - return { - renderNode: { - [INLINES.EMBEDDED_ENTRY]: (node): string => { - const { data } = node - if (!isRecord(data) || !('target' in data)) return '' - return getMergeTagValue?.(data.target) ?? '' - }, - }, +export function EntryCard(props: EntryCardProps): JSX.Element { + const { entry, liveUpdates, testId, clickScenario, manualTracking } = props + + if (!('baselineEntry' in entry)) { + return ( +
+ +
+ ) } -} -export function EntryCard({ - baselineEntry, - clickScenario, - getMergeTagValue, - manualTracking, - resolveEntry, - resolvedData, -}: EntryCardProps): JSX.Element { - const resolvedEntry = resolvedData.entry as ContentEntry + const { baselineEntry, resolvedData, resolvedEntry } = entry const autoTrackViews = !manualTracking - const richText = Object.values(resolvedEntry.fields).find(isRichTextDocument) - const nested = Array.isArray(resolvedEntry.fields.nested) - ? resolvedEntry.fields.nested.filter(isResolvedContentfulEntry) - : [] - const renderOptions = buildRenderOptions(getMergeTagValue) + const richText = Object.values(resolvedEntry.fields).find(isRichTextField) + const nested: Entry[] = resolvedEntry.fields.nested ?? [] const content = (
@@ -62,7 +41,7 @@ export function EntryCard({ data-testid={`entry-text-${baselineEntry.sys.id}`} > {richText ? ( - <>{documentToReactComponents(richText, renderOptions)} + <>{documentToReactComponents(richText)} ) : (

{typeof resolvedEntry.fields.text === 'string' ? resolvedEntry.fields.text : ''}

)} @@ -77,12 +56,9 @@ export function EntryCard({
{nested.map((nestedEntry) => ( ))}
diff --git a/implementations/nextjs-sdk_ssr/components/GlobalLiveUpdatesProvider.tsx b/implementations/nextjs-sdk_ssr/components/GlobalLiveUpdatesProvider.tsx index a0c461594..35bfacf3a 100644 --- a/implementations/nextjs-sdk_ssr/components/GlobalLiveUpdatesProvider.tsx +++ b/implementations/nextjs-sdk_ssr/components/GlobalLiveUpdatesProvider.tsx @@ -1,7 +1,7 @@ 'use client' import { LiveUpdatesProvider } from '@contentful/optimization-nextjs/client' -import { createContext, useContext, useMemo, useState, type JSX, type ReactNode } from 'react' +import { createContext, useContext, useState, type JSX, type ReactNode } from 'react' interface GlobalLiveUpdatesContextValue { readonly globalLiveUpdates: boolean @@ -26,15 +26,12 @@ export function GlobalLiveUpdatesProvider({ readonly children: ReactNode }): JSX.Element { const [globalLiveUpdates, setGlobalLiveUpdates] = useState(false) - const value = useMemo( - () => ({ - globalLiveUpdates, - onToggleGlobalLiveUpdates: () => { - setGlobalLiveUpdates((current) => !current) - }, - }), - [globalLiveUpdates], - ) + const value: GlobalLiveUpdatesContextValue = { + globalLiveUpdates, + onToggleGlobalLiveUpdates: () => { + setGlobalLiveUpdates((current) => !current) + }, + } return ( diff --git a/implementations/nextjs-sdk_ssr/components/LiveEntryCard.tsx b/implementations/nextjs-sdk_ssr/components/LiveEntryCard.tsx deleted file mode 100644 index c0dd6d80a..000000000 --- a/implementations/nextjs-sdk_ssr/components/LiveEntryCard.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client' - -import type { ContentEntry } from '@/lib/contentful' -import { OptimizedEntry } from '@contentful/optimization-nextjs/client' -import type { JSX } from 'react' - -interface LiveEntryCardProps { - entry: ContentEntry - liveUpdates?: boolean - testId: string -} - -export function LiveEntryCard({ entry, liveUpdates, testId }: LiveEntryCardProps): JSX.Element { - return ( - - {(resolvedEntry) => { - const asCf = resolvedEntry as ContentEntry - const text = typeof asCf.fields.text === 'string' ? asCf.fields.text : '' - const fullLabel = `${text} [Entry: ${resolvedEntry.sys.id}]` - - return ( -
-
-

{text}

-

{`[Entry: ${resolvedEntry.sys.id}]`}

-
-
- ) - }} -
- ) -} diff --git a/implementations/nextjs-sdk_ssr/components/PreviewPanel.tsx b/implementations/nextjs-sdk_ssr/components/PreviewPanel.tsx index 4161f6fcf..5fc9a50bc 100644 --- a/implementations/nextjs-sdk_ssr/components/PreviewPanel.tsx +++ b/implementations/nextjs-sdk_ssr/components/PreviewPanel.tsx @@ -2,27 +2,28 @@ import { appConfig } from '@/lib/config' import { useOptimizationContext } from '@contentful/optimization-nextjs/client' -import { useEffect, type JSX } from 'react' +import { useEffect, useRef, type JSX } from 'react' export function PreviewPanel(): JSX.Element | null { const { isReady, sdk } = useOptimizationContext() + const started = useRef(false) useEffect(() => { - if (!appConfig.previewPanelEnabled || !isReady || sdk === undefined) { + if (!appConfig.previewPanelEnabled || !isReady || sdk === undefined || started.current) { return } + started.current = true + void Promise.all([ import('@contentful/optimization-web-preview-panel'), import('@/lib/contentful'), ]) .then(async ([{ default: attachOptimizationPreviewPanel }, { client }]) => { - await attachOptimizationPreviewPanel({ - contentful: client, - nonce: undefined, - }) + await attachOptimizationPreviewPanel({ contentful: client, nonce: undefined }) }) .catch((error: unknown) => { + started.current = false console.warn('Failed to attach the Contentful Optimization preview panel.', error) }) }, [isReady, sdk]) diff --git a/implementations/nextjs-sdk_ssr/components/TrackingLog.tsx b/implementations/nextjs-sdk_ssr/components/TrackingLog.tsx index 434d89538..7f6cde187 100644 --- a/implementations/nextjs-sdk_ssr/components/TrackingLog.tsx +++ b/implementations/nextjs-sdk_ssr/components/TrackingLog.tsx @@ -1,7 +1,7 @@ 'use client' import { useEventStream, useTick } from '@/lib/hooks' -import { isRecord } from '@contentful/optimization-nextjs/api-schemas' +import { isRecord } from '@/lib/util' import { type JSX } from 'react' const MS_PER_SECOND = 1000 diff --git a/implementations/nextjs-sdk_ssr/lib/contentful.ts b/implementations/nextjs-sdk_ssr/lib/contentful.ts index f7ef6413d..e27d7dde3 100644 --- a/implementations/nextjs-sdk_ssr/lib/contentful.ts +++ b/implementations/nextjs-sdk_ssr/lib/contentful.ts @@ -2,6 +2,7 @@ import type { Document } from '@contentful/rich-text-types' import type { Entry, EntryFieldTypes, EntrySkeletonType } from 'contentful' import { createClient } from 'contentful' import { appConfig } from './config' +import { isRecord } from './util' export interface ContentEntryFields { text?: EntryFieldTypes.Text | EntryFieldTypes.RichText @@ -25,18 +26,48 @@ export const client = createClient({ ...(basePath ? { basePath } : {}), }) -async function fetchEntry(entryId: string): Promise { +function isLink(value: unknown): value is { sys: { type: 'Link'; linkType: 'Entry'; id: string } } { + return ( + isRecord(value) && + isRecord(value.sys) && + value.sys.type === 'Link' && + value.sys.linkType === 'Entry' && + typeof value.sys.id === 'string' + ) +} + +async function resolveField(value: unknown, visited: Set): Promise { + if (isLink(value)) { + const fetched = await fetchEntry(value.sys.id) + if (!fetched) return value + return resolveLinks(fetched, visited) + } + if (Array.isArray(value)) return Promise.all(value.map((item) => resolveField(item, visited))) + return value +} + +async function resolveLinks(entry: ContentEntry, visited: Set): Promise { + if (visited.has(entry.sys.id)) return entry + visited.add(entry.sys.id) + const resolvedFields = Object.fromEntries( + await Promise.all( + Object.entries(entry.fields).map(async ([key, value]) => [ + key, + await resolveField(value, visited), + ]), + ), + ) as ContentEntry['fields'] + return { ...entry, fields: resolvedFields } +} + +export async function fetchEntry(entryId: string): Promise { try { - return await client.getEntry(entryId, { + const entry = await client.getEntry(entryId, { include: ENTRY_INCLUDE_DEPTH, locale: appConfig.locale, }) + return resolveLinks(entry, new Set()) } catch { return undefined } } - -export async function loadPageEntries(entryIds: readonly string[]): Promise { - const results = await Promise.all(entryIds.map(fetchEntry)) - return results.filter((entry): entry is ContentEntry => entry !== undefined) -} diff --git a/implementations/nextjs-sdk_ssr/lib/hooks.ts b/implementations/nextjs-sdk_ssr/lib/hooks.ts index 153c739c8..13cb8b8a2 100644 --- a/implementations/nextjs-sdk_ssr/lib/hooks.ts +++ b/implementations/nextjs-sdk_ssr/lib/hooks.ts @@ -5,20 +5,55 @@ import { useOptimization, useOptimizationActions, useOptimizationContext, + useProfileState, + useSelectedOptimizationsState, } from '@contentful/optimization-nextjs/client' import { useEffect, useReducer, useRef, useState } from 'react' import { setAppConsent } from './util' -export function useConsent(): { - consent: boolean | undefined - setConsent: (value: boolean) => void -} { - const consent = useConsentState() - const { consent: setConsent } = useOptimizationActions() +type Profile = ReturnType + +type ServerDefaults = { + readonly profile?: Profile + readonly selectedOptimizations?: readonly unknown[] +} + +export type ControlPanelServerState = ServerDefaults & { readonly hasConsent?: boolean } + +export function useControlPanel(serverState: ControlPanelServerState = {}) { + const sdk = useOptimization() + const { identify, reset, consent: setConsent } = useOptimizationActions() + const sdkConsent = useConsentState() + const clientProfile = useProfileState() + const selectedOptimizations = useSelectedOptimizationsState() + const { sdk: sdkCtx, isReady } = useOptimizationContext() + const profile = isReady ? clientProfile : (clientProfile ?? serverState.profile) + const [booleanFlag, setBooleanFlag] = useState(undefined) + + useEffect(() => { + if (!sdkCtx || !isReady) return + const subscription = sdkCtx.states.flag('boolean').subscribe(setBooleanFlag) + return () => subscription.unsubscribe() + }, [isReady, sdkCtx]) + + const consent = sdkConsent ?? serverState.hasConsent + const activeCount = + selectedOptimizations?.length ?? serverState.selectedOptimizations?.length ?? 0 + useEffect(() => { if (typeof consent === 'boolean') setAppConsent(consent) }, [consent]) - return { consent, setConsent } + + return { + sdk, + identify, + reset, + consent, + setConsent, + profile, + activeCount, + booleanFlag, + } } const MS_PER_SECOND = 1000 @@ -73,41 +108,3 @@ export function useEventStream( return { events, rawCount } } - -export function useFlagSubscription(flagName: string): unknown { - const { sdk, isReady } = useOptimizationContext() - const [value, setValue] = useState(undefined) - - useEffect(() => { - if (!sdk || !isReady) return - const subscription = sdk.states.flag(flagName).subscribe(setValue) - return () => { - subscription.unsubscribe() - } - }, [isReady, sdk, flagName]) - - return value -} - -export function useManualViewTracking( - manualTracking: boolean | undefined, -): (element: HTMLDivElement | null, entryId: string) => void { - const sdk = useOptimization() - const trackedElement = useRef(null) - - useEffect( - () => () => { - const { current } = trackedElement - if (current) sdk.tracking.clearElement('views', current) - }, - [sdk.tracking], - ) - - return (element: HTMLDivElement | null, entryId: string): void => { - const { current: previous } = trackedElement - if (previous && previous !== element) sdk.tracking.clearElement('views', previous) - trackedElement.current = element - if (!element || !manualTracking) return - sdk.tracking.enableElement('views', element, { data: { entryId } }) - } -} diff --git a/implementations/nextjs-sdk_ssr/lib/optimization.ts b/implementations/nextjs-sdk_ssr/lib/optimization.ts index 600335ec4..b7dd479f9 100644 --- a/implementations/nextjs-sdk_ssr/lib/optimization.ts +++ b/implementations/nextjs-sdk_ssr/lib/optimization.ts @@ -1,14 +1,150 @@ -import { createNextjsOptimization } from '@contentful/optimization-nextjs/server' +import { + createNextjsOptimization, + getNextjsServerOptimizationData, + type ServerTrackingResolvedData, +} from '@contentful/optimization-nextjs/server' +import { cookies, headers } from 'next/headers' +import { cache } from 'react' import { appConfig } from './config' +import { fetchEntry, type ContentEntry } from './contentful' +import { getAppConsent, isEntry, isMergeTagNode, isRecord, isRichTextField } from './util' -export const optimization = createNextjsOptimization({ - clientId: appConfig.clientId, - environment: appConfig.environment, - locale: appConfig.locale, - logLevel: 'debug', - api: appConfig.api, - app: { - name: 'Contentful Optimization Next.js SDK SSR (Server)', - version: '0.1.0', - }, -}) +type Profile = Parameters['getMergeTagValue']>[1] +type SelectedOptimizations = Parameters< + ReturnType['resolveOptimizedEntry'] +>[1] + +export interface ResolvedEntry extends Omit { + fields: Omit & { + nested?: Entry[] + } +} + +export interface Entry { + baselineEntry: ContentEntry + /** Raw resolved data for the SDK (ServerOptimizedEntry). entry is a vanilla ContentEntry. */ + resolvedData: ServerTrackingResolvedData + /** Resolved entry for rendering — fields.nested contains resolved Entry children. */ + resolvedEntry: ResolvedEntry +} + +class ServerOptimization { + // --- private --- + + private readonly sdk = createNextjsOptimization({ + clientId: appConfig.clientId, + environment: appConfig.environment, + locale: appConfig.locale, + logLevel: 'debug', + api: appConfig.api, + app: { + name: 'Contentful Optimization Next.js SDK SSR (Server)', + version: '0.1.0', + }, + }) + + private readonly fetchOptimizationData = cache(async () => { + const cookieStore = await cookies() + const headerStore = await headers() + const hasConsent = getAppConsent(cookieStore) + const { data } = await getNextjsServerOptimizationData(this.sdk, { + consent: { events: hasConsent, persistence: hasConsent }, + cookies: hasConsent ? cookieStore : undefined, + headers: headerStore, + locale: appConfig.locale, + }) + + return { data, hasConsent } + }) + + private resolveMergeTags( + fields: ContentEntry['fields'], + profile: Profile, + ): ContentEntry['fields'] { + const resolveNode = (node: unknown): unknown => { + if (!isRecord(node)) return node + if (isMergeTagNode(node)) { + return { + nodeType: 'text', + value: this.sdk.getMergeTagValue(node.data.target, profile) ?? '', + marks: [], + data: {}, + } + } + if (Array.isArray(node.content)) { + return { ...node, content: node.content.map(resolveNode) } + } + return node + } + + return Object.fromEntries( + Object.entries(fields).map(([key, value]) => [ + key, + isRichTextField(value) ? resolveNode(value) : value, + ]), + ) as ContentEntry['fields'] + } + + private buildEntry( + baselineEntry: ContentEntry, + selectedOptimizations: SelectedOptimizations, + profile: Profile, + visited: Set, + ): Entry { + visited.add(baselineEntry.sys.id) + + const resolved = this.sdk.resolveOptimizedEntry(baselineEntry, selectedOptimizations) + const resolvedData = resolved as ServerTrackingResolvedData + const resolvedEntry = resolvedData.entry as ContentEntry + const fields = this.resolveMergeTags(resolvedEntry.fields, profile) + + const nested = (Array.isArray(fields.nested) ? fields.nested.filter(isEntry) : []) + .filter((n) => !visited.has(n.sys.id)) + .map((n) => this.buildEntry(n, selectedOptimizations, profile, new Set(visited))) + + return { + baselineEntry, + resolvedData, + resolvedEntry: { + ...resolvedEntry, + fields: { + ...fields, + nested: nested.length > 0 ? nested : undefined, + } as unknown as ResolvedEntry['fields'], + }, + } + } + + // --- public --- + + public async getServerState() { + const { data, hasConsent } = await this.fetchOptimizationData() + const defaults = data + ? { + profile: data.profile, + ...(hasConsent + ? { selectedOptimizations: data.selectedOptimizations, changes: data.changes } + : {}), + } + : undefined + return { + ...defaults, + initialPageEvent: hasConsent ? ('skip' as const) : ('emit' as const), + hasConsent, + } + } + + public async getEntry(id: string): Promise { + const entry = await fetchEntry(id) + if (!entry) return undefined + const { data } = await this.fetchOptimizationData() + return this.buildEntry(entry, data?.selectedOptimizations, data?.profile, new Set()) + } + + public async getEntries(ids: readonly string[]): Promise { + const results = await Promise.all(ids.map((id) => this.getEntry(id))) + return results.filter((e): e is Entry => e !== undefined) + } +} + +export const optimization = new ServerOptimization() diff --git a/implementations/nextjs-sdk_ssr/lib/util.ts b/implementations/nextjs-sdk_ssr/lib/util.ts index aaffbca3d..e16fb4d6b 100644 --- a/implementations/nextjs-sdk_ssr/lib/util.ts +++ b/implementations/nextjs-sdk_ssr/lib/util.ts @@ -1,8 +1,9 @@ +import { type createNextjsOptimization } from '@contentful/optimization-nextjs/server' +import { INLINES } from '@contentful/rich-text-types' import { appConfig } from './config' +import type { ContentEntry, RichTextDocument } from './contentful' -export function toIdMap(items: T[]): Map { - return new Map(items.map((item) => [item.sys.id, item])) -} +type MergeTagEntry = Parameters['getMergeTagValue']>[0] export function setAppConsent(consented: boolean): void { const value = consented ? 'granted' : 'denied' @@ -12,3 +13,31 @@ export function setAppConsent(consented: boolean): void { export function getAppConsent(cookies: { get(i: string): { value: string } | undefined }): boolean { return cookies.get(appConfig.personalizationConsentCookie)?.value === 'granted' } + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function isEntry(value: unknown): value is ContentEntry { + return ( + isRecord(value) && + isRecord(value.sys) && + typeof value.sys.id === 'string' && + isRecord(value.fields) + ) +} + +export function isRichTextField(field: unknown): field is RichTextDocument { + return isRecord(field) && field.nodeType === 'document' && Array.isArray(field.content) +} + +export function isMergeTagNode( + node: unknown, +): node is Record & { data: Record & { target: MergeTagEntry } } { + return ( + isRecord(node) && + node.nodeType === INLINES.EMBEDDED_ENTRY && + isRecord(node.data) && + 'target' in node.data + ) +} diff --git a/implementations/nextjs-sdk_ssr/package.json b/implementations/nextjs-sdk_ssr/package.json index 15ac7ba2f..17ccc0ddc 100644 --- a/implementations/nextjs-sdk_ssr/package.json +++ b/implementations/nextjs-sdk_ssr/package.json @@ -38,7 +38,6 @@ "react-dom": "19.2.5" }, "devDependencies": { - "@tailwindcss/postcss": "4.1.11", "@types/node": "24.11.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", @@ -46,9 +45,7 @@ "eslint": "9.29.0", "eslint-config-next": "16.2.4", "pm2": "6.0.14", - "postcss": "8.5.10", "rimraf": "6.1.3", - "tailwindcss": "4.1.11", "typescript": "5.9.3" } } diff --git a/implementations/nextjs-sdk_ssr/postcss.config.mjs b/implementations/nextjs-sdk_ssr/postcss.config.mjs deleted file mode 100644 index ae85b2fe4..000000000 --- a/implementations/nextjs-sdk_ssr/postcss.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -const config = { - plugins: { - '@tailwindcss/postcss': {}, - }, -} - -export default config