Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions apps/cow-fi/app/(main)/resources/[campaign]/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { ReactNode } from 'react'

import { notFound, permanentRedirect } from 'next/navigation'

import { getAllResourceSlugs, getResourceBySlug, SharedRichTextComponent } from '../../../../../services/cms'

import type { Metadata } from 'next'

import { ResourcePageComponent } from '@/components/ResourcePageComponent'
import { getPageMetadata } from '@/util/getPageMetadata'
import { stripHtmlTags } from '@/util/stripHTMLTags'

export const revalidate = 43200

const METADATA_DESCRIPTION_MAX_LENGTH = 150
const METADATA_DESCRIPTION_TRUNCATE_LENGTH = METADATA_DESCRIPTION_MAX_LENGTH - 3

function isRichTextComponent(block: unknown): block is SharedRichTextComponent {
return (
typeof block === 'object' &&
block !== null &&
'body' in block &&
typeof (block as { body?: unknown }).body === 'string'
)
}

type Props = {
params: Promise<{ campaign: string; slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params

if (!slug) return {}

try {
const resource = await getResourceBySlug(slug)
if (!resource?.attributes) {
return getPageMetadata({
title: 'Resource Not Found',
description: 'The requested resource could not be found.',
})
}

const attributes = resource.attributes
const { title, blocks, description, cover } = attributes
const coverImageUrl = cover?.data?.attributes?.url
const content =
blocks?.map((block: SharedRichTextComponent) => (isRichTextComponent(block) ? block.body : '')).join(' ') || ''
const plainContent = stripHtmlTags(content)

return getPageMetadata({
absoluteTitle: `${title} - CoW DAO`,
description: description
? stripHtmlTags(description)
: plainContent.length > METADATA_DESCRIPTION_MAX_LENGTH
? stripHtmlTags(plainContent.substring(0, METADATA_DESCRIPTION_TRUNCATE_LENGTH)) + '...'
: stripHtmlTags(plainContent),
image: coverImageUrl,
})
} catch (error) {
console.error(`Error generating metadata for resource ${slug}:`, error)
return getPageMetadata({
title: 'Resource',
description: 'Loading resource...',
})
}
}

export async function generateStaticParams(): Promise<{ campaign: string; slug: string }[]> {
try {
return await getAllResourceSlugs()
} catch (error) {
console.error('Error generating resource static params:', error)
return []
}
}

export default async function ResourcePage({ params }: Props): Promise<ReactNode> {
const { campaign, slug } = await params

let resource
try {
resource = await getResourceBySlug(slug)
} catch (error) {
console.error(`Error fetching resource ${slug}:`, error)
return notFound()
}

if (!resource?.attributes) {
return notFound()
}

// Keep outside try/catch — permanentRedirect throws a control-flow error Next must handle
if (resource.attributes.campaign !== campaign) {
permanentRedirect(`/resources/${resource.attributes.campaign}/${slug}`)
}
Comment on lines +90 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Missing null check for resource.attributes.campaign before redirect comparison.

If campaign is undefined or null, the condition resource.attributes.campaign !== campaign evaluates to true (since campaign from the URL is always a string), causing a redirect to /resources/undefined/${slug}. The downstream ResourcePageComponent explicitly guards against falsy campaign (if (!campaign || !attributes?.slug) return null), confirming this case is reachable.

🐛 Proposed fix: guard against missing campaign before redirect
   if (!resource?.attributes) {
     return notFound()
   }

+  if (!resource.attributes.campaign) {
+    return notFound()
+  }
+
   // Keep outside try/catch — permanentRedirect throws a control-flow error Next must handle
   if (resource.attributes.campaign !== campaign) {
     permanentRedirect(`/resources/${resource.attributes.campaign}/${slug}`)
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!resource?.attributes) {
return notFound()
}
// Keep outside try/catch — permanentRedirect throws a control-flow error Next must handle
if (resource.attributes.campaign !== campaign) {
permanentRedirect(`/resources/${resource.attributes.campaign}/${slug}`)
}
if (!resource?.attributes) {
return notFound()
}
if (!resource.attributes.campaign) {
return notFound()
}
// Keep outside try/catch — permanentRedirect throws a control-flow error Next must handle
if (resource.attributes.campaign !== campaign) {
permanentRedirect(`/resources/${resource.attributes.campaign}/${slug}`)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/cow-fi/app/`(main)/resources/[campaign]/[slug]/page.tsx around lines 90
- 97, Update the redirect condition in the resource page flow to handle a
missing resource.attributes.campaign before comparing it with campaign,
preventing redirects to an undefined campaign path. Preserve the existing
permanentRedirect behavior for valid campaign values that differ from the URL
campaign.


return <ResourcePageComponent resource={resource} />
}
49 changes: 49 additions & 0 deletions apps/cow-fi/app/(main)/resources/[campaign]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { ReactNode } from 'react'

import { notFound } from 'next/navigation'

import { getCampaignSummaries, getResources } from '../../../../services/cms'

import type { Metadata } from 'next'

import { ResourcesCampaignComponent } from '@/components/ResourcesCampaignComponent'
import { getCampaignLabel } from '@/const/resources'
import { getPageMetadata } from '@/util/getPageMetadata'

export const revalidate = 43200

type Props = {
params: Promise<{ campaign: string }>
}

export async function generateStaticParams(): Promise<{ campaign: string }[]> {
const campaigns = await getCampaignSummaries()
return campaigns.map(({ campaign }) => ({ campaign }))
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const campaign = (await params).campaign
const label = getCampaignLabel(campaign)

return getPageMetadata({
title: label,
description: `Programmatic ${label.toLowerCase()} resources from CoW DAO.`,
})
}

export default async function ResourcesCampaignPage({ params }: Props): Promise<ReactNode> {
const campaign = (await params).campaign
const resourcesResponse = await getResources({
filters: {
campaign: {
$eq: campaign,
},
},
})

if (resourcesResponse.data.length === 0) {
return notFound()
}

return <ResourcesCampaignComponent campaign={campaign} resources={resourcesResponse.data} />
}
23 changes: 23 additions & 0 deletions apps/cow-fi/app/(main)/resources/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react'

import { getCampaignSummaries } from '../../../services/cms'

import type { Metadata } from 'next'

import { ResourcesHubComponent } from '@/components/ResourcesHubComponent'
import { getPageMetadata } from '@/util/getPageMetadata'

export const revalidate = 43200

export async function generateMetadata(): Promise<Metadata> {
return getPageMetadata({
title: 'Resources',
description: 'Programmatic reference content published by CoW DAO.',
})
}

export default async function ResourcesPage(): Promise<ReactNode> {
const campaigns = await getCampaignSummaries()

return <ResourcesHubComponent campaigns={campaigns} />
}
5 changes: 5 additions & 0 deletions apps/cow-fi/app/api/revalidate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
// Revalidate the dynamic article route (this updates the manifest)
revalidatePath('/learn/[article]')

// Revalidate resources pages
revalidatePath('/resources')
revalidatePath('/resources/[campaign]')
revalidatePath('/resources/[campaign]/[slug]')

// If a specific path was provided, revalidate it to update the route manifest
if (path) revalidatePath(path)

Expand Down
4 changes: 2 additions & 2 deletions apps/cow-fi/components/CmsImage/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CMS_BASE_URL } from '@cowprotocol/core'

import Image, { ImageProps } from 'next/image'

import { CMS_BASE_URL } from '../../services/cms/config'

const CMS_BASE_URL_ROOT = CMS_BASE_URL.replace('/api', '') // TODO: fix this, base url should not have /api

// TODO: Add proper return type annotation
Expand Down
135 changes: 135 additions & 0 deletions apps/cow-fi/components/ResourcePageComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
'use client'

import type { ImgHTMLAttributes, ReactNode } from 'react'

import { Media } from '@cowprotocol/ui'

import { usePathname } from 'next/navigation'
import ReactMarkdown from 'react-markdown'
import styled from 'styled-components/macro'

import { Resource, SharedRichTextComponent } from '../services/cms'

import { LazyImage } from '@/components/LazyImage'
import { Link } from '@/components/Link'
import { ShareBlock } from '@/components/ShareBlock'
import { getCampaignLabel } from '@/const/resources'
import { ArticleContent, ArticleMainTitle, ArticleSubtitleWrapper, BodyContent, Breadcrumbs } from '@/styles/styled'
import { formatDate } from '@/util/formatDate'
import { remarkAllowedHtmlImages, sanitizeCmsMarkdown } from '@/util/markdownHtmlImages'

const SITE_ORIGIN = process.env.NEXT_PUBLIC_SITE_URL || ''

const Wrapper = styled.div`
display: flex;
flex-flow: column wrap;
justify-content: center;
width: 100%;
margin: 24px auto 0;
gap: 34px;
max-width: 1760px;

${Media.upToMedium()} {
margin: 0 auto;
gap: 24px;
}
`

interface ResourcePageComponentProps {
resource: Resource
}

export function ResourcePageComponent({ resource }: ResourcePageComponentProps): ReactNode {
const attributes = resource.attributes
const title = attributes?.title
const campaign = attributes?.campaign
const blocks = attributes?.blocks
const publishedAt = attributes?.publishedAt
const publishDate = attributes?.publishDate || null
const publishDateVisible = attributes?.publishDateVisible ?? true
const description = attributes?.description || ''
const content =
blocks?.map((block: SharedRichTextComponent) => (isRichTextComponent(block) ? block.body : '')).join(' ') || ''
const pathname = usePathname()
const fallbackUrl = buildFallbackUrl(pathname)
const shareTitle = title || 'CoW DAO Resource'

if (!campaign || !attributes?.slug) {
return null
}

const campaignLabel = getCampaignLabel(campaign)
const dateIso = publishDate || publishedAt || ''
const date = dateIso ? new Date(dateIso) : null
const showDate = Boolean(publishDateVisible && date && !Number.isNaN(date.getTime()))
const formattedDate = showDate && date ? formatDate(date) : null

return (
<Wrapper>
<ArticleContent>
<Breadcrumbs>
<Link href="/">Home</Link>
<Link href="/resources">Resources</Link>
<Link href={`/resources/${campaign}`}>{campaignLabel}</Link>
<span>{title}</span>
</Breadcrumbs>

<ArticleMainTitle>{title}</ArticleMainTitle>

<ArticleSubtitleWrapper>
{description && <div>{description}</div>}
{formattedDate && (
<>
{description && <div>·</div>}
<div>
<span>Published {formattedDate}</span>
</div>
</>
)}
</ArticleSubtitleWrapper>

<BodyContent>
{blocks?.map((block) =>
isRichTextComponent(block) ? <ResourceRichText key={block.id} sharedRichText={block} /> : null,
)}
<ShareBlock url={fallbackUrl} title={shareTitle} onShare={() => undefined} />
</BodyContent>
</ArticleContent>
</Wrapper>
)
}

function buildFallbackUrl(pathname: string): string {
if (!SITE_ORIGIN) return ''
try {
return new URL(pathname, SITE_ORIGIN).toString()
} catch {
return ''
}
}

function isRichTextComponent(block: unknown): block is SharedRichTextComponent {
return (
typeof block === 'object' &&
block !== null &&
'body' in block &&
typeof (block as { body?: unknown }).body === 'string'
)
}

function MarkdownImage({ src, alt, ...props }: ImgHTMLAttributes<HTMLImageElement>): ReactNode {
const dataSrc = (props as Record<string, unknown>)['data-src']
const resolvedSrc = typeof dataSrc === 'string' ? dataSrc : src
if (!resolvedSrc) return null
return <LazyImage src={resolvedSrc} alt={alt || ''} {...props} width={725} height={400} />
}

function ResourceRichText({ sharedRichText }: { sharedRichText: SharedRichTextComponent }): ReactNode {
const content = sanitizeCmsMarkdown(sharedRichText.body || '')

return (
<ReactMarkdown skipHtml remarkPlugins={[remarkAllowedHtmlImages]} components={{ img: MarkdownImage }}>
{content}
</ReactMarkdown>
)
}
Loading
Loading