diff --git a/package.json b/package.json index 4da0dd778..c106f17b3 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "react-markdown": "^9.0.1", "react-select": "5.9.0", "react-transition-group": "^4.4.5", + "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", "sass": "^1.55.0", "sharp": "0.32.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fb428d29..bcf33a4e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: react-transition-group: specifier: ^4.4.5 version: 4.4.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 remark-gfm: specifier: ^4.0.0 version: 4.0.1 @@ -2982,12 +2985,27 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -3015,6 +3033,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@10.0.0: resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} @@ -4065,6 +4086,9 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -4628,12 +4652,18 @@ packages: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} engines: {'0': node >=0.6.0} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -7994,6 +8024,37 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -8014,10 +8075,28 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + help-me@5.0.0: {} highlight.js@11.11.1: {} @@ -8041,6 +8120,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + htmlparser2@10.0.0: dependencies: domelementtype: 2.3.0 @@ -9401,6 +9482,12 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -10102,6 +10189,11 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -10112,6 +10204,8 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + web-namespaces@2.0.1: {} + webidl-conversions@7.0.0: {} webpack-bundle-analyzer@4.10.1: diff --git a/src/app/(frontend)/(pages)/posts/releases/[slug]/page.tsx b/src/app/(frontend)/(pages)/posts/releases/[slug]/page.tsx new file mode 100644 index 000000000..f65178367 --- /dev/null +++ b/src/app/(frontend)/(pages)/posts/releases/[slug]/page.tsx @@ -0,0 +1,98 @@ +import type { Metadata } from 'next' + +import BreadcrumbsBar from '@components/Hero/BreadcrumbsBar/index' +import { PayloadRedirects } from '@components/PayloadRedirects/index' +import { RefreshRouteOnSave } from '@components/RefreshRouterOnSave/index' +import { Release } from '@components/Release/index' +import { fetchRelease, fetchReleases } from '@data' +import { mergeOpenGraph } from '@root/seo/mergeOpenGraph' +import { unstable_cache } from 'next/cache' +import { draftMode } from 'next/headers' +import React from 'react' + +const getRelease = async (slug: string, draft?: boolean) => + draft + ? await fetchRelease(slug) + : await unstable_cache(fetchRelease, ['release', `release-${slug}`])(slug) + +const ReleasePage = async ({ + params, +}: { + params: Promise<{ + slug: string + }> +}) => { + const { isEnabled: draft } = await draftMode() + const { slug } = await params + + const release = await getRelease(slug, draft) + + const url = `/posts/releases/${slug}` + + if (!release) { + return + } + + return ( + <> + + + + + > + ) +} + +export default ReleasePage + +export async function generateStaticParams() { + const getReleases = unstable_cache(fetchReleases, ['allReleases']) + const releases = await getReleases() + + return releases + .filter((release) => release.slug) + .map(({ slug }) => ({ + slug, + })) +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ + slug: string + }> +}): Promise { + const { isEnabled: draft } = await draftMode() + const { slug } = await params + const release = await getRelease(slug, draft) + + let ogImage: null | string = null + + if (release) { + if (release?.meta?.image && typeof release.meta.image !== 'string' && release.meta.image?.url) { + ogImage = release.meta.image.url + } else if (release.image && typeof release.image !== 'string' && release.image?.url) { + ogImage = release.image.url + } else { + ogImage = `${process.env.NEXT_PUBLIC_SITE_URL}/api/og?type=releases&title=${encodeURIComponent(release.title || '')}` + } + } + + return { + description: release?.meta?.description, + openGraph: mergeOpenGraph({ + description: release?.meta?.description ?? undefined, + images: ogImage + ? [ + { + url: ogImage, + }, + ] + : undefined, + title: release?.title ?? undefined, + url: `/posts/releases/${slug}`, + }), + title: release?.title ? `${release.title} | Payload` : 'Release | Payload', + } +} diff --git a/src/app/(frontend)/(pages)/posts/releases/page.tsx b/src/app/(frontend)/(pages)/posts/releases/page.tsx new file mode 100644 index 000000000..aaaa655b2 --- /dev/null +++ b/src/app/(frontend)/(pages)/posts/releases/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from 'next' + +import { BackgroundGrid } from '@components/BackgroundGrid' +import { BlockWrapper } from '@components/BlockWrapper' +import { ContentMediaCard } from '@components/cards/ContentMediaCard' +import { Gutter } from '@components/Gutter' +import { fetchReleases } from '@data' +import { unstable_cache } from 'next/cache' +import React from 'react' + +export default async function ReleasesPage() { + const getReleases = unstable_cache(fetchReleases, ['releases-archive']) + const releases = await getReleases() + + return ( + + + + Releases + {releases && Array.isArray(releases) && releases.length > 0 ? ( + + {releases.map((release) => { + if (typeof release === 'string' || !release.slug) { + return null + } + + return ( + + + + ) + })} + + ) : ( + No releases found. + )} + + + ) +} + +export const metadata: Metadata = { + description: 'Release notes for Payload CMS', + title: 'Releases | Payload', +} diff --git a/src/app/(frontend)/api/og/route.tsx b/src/app/(frontend)/api/og/route.tsx index 32fef6902..5e500b907 100644 --- a/src/app/(frontend)/api/og/route.tsx +++ b/src/app/(frontend)/api/og/route.tsx @@ -37,6 +37,7 @@ export async function GET(req: NextRequest): Promise { blog: 'Blog Post', docs: 'Documentation', guides: 'Guides & Tutorials', + releases: 'Release Notes', } return new ImageResponse( diff --git a/src/app/(frontend)/api/sync-releases/route.ts b/src/app/(frontend)/api/sync-releases/route.ts new file mode 100644 index 000000000..4f5d25b00 --- /dev/null +++ b/src/app/(frontend)/api/sync-releases/route.ts @@ -0,0 +1,15 @@ +import { syncReleases } from '@root/scripts/fetchReleases' +import { NextResponse } from 'next/server' + +export const maxDuration = 300 // 5 mins (max on vercel pro plan) +export const dynamic = 'force-dynamic' + +export async function GET(): Promise { + try { + const result = await syncReleases(20) + return NextResponse.json({ success: true, ...result }, { status: 200 }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return NextResponse.json({ error: message, success: false }, { status: 500 }) + } +} diff --git a/src/app/_data/index.ts b/src/app/_data/index.ts index 8fcab2d23..620abc1bc 100644 --- a/src/app/_data/index.ts +++ b/src/app/_data/index.ts @@ -17,6 +17,7 @@ import type { PartnerProgram, Post, Region, + Release, Specialty, TopBar, } from '../../payload-types' @@ -505,3 +506,69 @@ export const fetchForm = async (name: string): Promise => { return data.docs[0] } + +export const fetchReleases = async (): Promise[]> => { + const payload = await getPayload({ config }) + + const data = await payload.find({ + collection: 'releases', + depth: 1, + limit: 300, + select: { + slug: true, + authors: true, + githubTag: true, + image: true, + publishedOn: true, + title: true, + }, + sort: '-publishedOn', + where: { + _status: { + equals: 'published', + }, + }, + }) + + return data.docs +} + +export const fetchRelease = async (slug: string): Promise> => { + const { isEnabled: draft } = await draftMode() + const payload = await getPayload({ config }) + + const data = await payload.find({ + collection: 'releases', + depth: 2, + draft, + limit: 1, + select: { + slug: true, + authors: true, + content: true, + excerpt: true, + githubTag: true, + githubUrl: true, + image: true, + meta: true, + publishedOn: true, + title: true, + }, + where: { + and: [ + { slug: { equals: slug } }, + ...(draft + ? [] + : [ + { + _status: { + equals: 'published', + }, + }, + ]), + ], + }, + }) + + return data.docs[0] +} diff --git a/src/collections/Releases.ts b/src/collections/Releases.ts new file mode 100644 index 000000000..c2b8b8844 --- /dev/null +++ b/src/collections/Releases.ts @@ -0,0 +1,187 @@ +import type { CollectionConfig } from 'payload' + +import { revalidatePath } from 'next/cache' + +import { isAdmin } from '../access/isAdmin' +import { publishedOnly } from '../access/publishedOnly' +import { Banner } from '../blocks/Banner' +import richText from '../fields/richText' +import { formatPreviewURL } from '../utilities/formatPreviewURL' + +export const Releases: CollectionConfig = { + slug: 'releases', + access: { + create: isAdmin, + delete: isAdmin, + read: publishedOnly, + readVersions: isAdmin, + update: isAdmin, + }, + admin: { + defaultColumns: ['title', 'githubTag', 'publishedOn', '_status'], + livePreview: { + url: ({ data }) => formatPreviewURL('releases', data), + }, + preview: (doc) => formatPreviewURL('releases', doc), + useAsTitle: 'title', + }, + defaultPopulate: { + slug: true, + authors: true, + githubTag: true, + image: true, + publishedOn: true, + title: true, + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'image', + type: 'upload', + label: 'Header Image', + relationTo: 'media', + }, + richText({ + name: 'excerpt', + required: false, + }), + { + name: 'content', + type: 'blocks', + blockReferences: [ + Banner, + 'blogContent', + 'code', + 'blogMarkdown', + 'mediaBlock', + 'reusableContentBlock', + ], + blocks: [], + }, + { + name: 'authors', + type: 'relationship', + admin: { + position: 'sidebar', + }, + hasMany: true, + relationTo: 'users', + required: true, + }, + { + name: 'slug', + type: 'text', + admin: { + description: + 'Auto-generated from GitHub tag for imported releases. Enter manually for non-imported releases.', + position: 'sidebar', + }, + hooks: { + beforeValidate: [ + ({ data, operation, value }) => { + // If a slug is already provided (e.g. from importer or manual entry), keep it + if (typeof value === 'string' && value.length > 0) { + return value + } + + // On create, derive slug from githubTag only (never from title) + if (operation === 'create' && data?.githubTag) { + return data.githubTag + .replace(/ /g, '-') + .replace(/[^\w.-]+/g, '') + .toLowerCase() + } + + return value + }, + ], + }, + index: true, + label: 'Slug', + required: true, + unique: true, + }, + { + name: 'publishedOn', + type: 'date', + admin: { + date: { + pickerAppearance: 'dayAndTime', + }, + position: 'sidebar', + }, + required: true, + }, + // GitHub import metadata + { + name: 'githubReleaseId', + type: 'number', + admin: { + position: 'sidebar', + readOnly: true, + }, + index: true, + label: 'GitHub Release ID', + unique: true, + }, + { + name: 'githubTag', + type: 'text', + admin: { + position: 'sidebar', + readOnly: true, + }, + index: true, + label: 'GitHub Tag', + }, + { + name: 'githubUrl', + type: 'text', + admin: { + position: 'sidebar', + readOnly: true, + }, + label: 'GitHub URL', + }, + { + name: 'importedFromGitHub', + type: 'checkbox', + admin: { + position: 'sidebar', + readOnly: true, + }, + defaultValue: false, + label: 'Imported from GitHub', + }, + ], + hooks: { + afterChange: [ + ({ doc }) => { + try { + revalidatePath('/posts/releases') + revalidatePath(`/posts/releases/${doc.slug}`) + } catch { + // revalidatePath only works inside a Next.js request context; + // silently skip when running from scripts or Local API outside Next.js + } + }, + ], + afterDelete: [ + ({ doc }) => { + try { + revalidatePath('/posts/releases') + revalidatePath(`/posts/releases/${doc.slug}`) + } catch { + // silently skip outside Next.js request context + } + }, + ], + }, + versions: { + drafts: true, + }, +} diff --git a/src/components/Release/ReleaseMarkdown/index.tsx b/src/components/Release/ReleaseMarkdown/index.tsx new file mode 100644 index 000000000..5daa6b214 --- /dev/null +++ b/src/components/Release/ReleaseMarkdown/index.tsx @@ -0,0 +1,38 @@ +'use client' + +import Table from '@components/MDX/components/Table/index' +import React from 'react' +import ReactMarkdown from 'react-markdown' +import rehypeRaw from 'rehype-raw' +import remarkGFM from 'remark-gfm' + +const components = { + table: Table as any, // eslint-disable-line @typescript-eslint/no-explicit-any +} + +const remarkPlugins = [remarkGFM] +const rehypePlugins = [rehypeRaw] + +type Props = { + markdown: string +} + +/** + * Strip the leading H2 from GitHub release markdown. + * GitHub releases always start with `## [vX.Y.Z](compare-url) (date)` + * which duplicates the page title. + */ +function stripLeadingH2(md: string): string { + return md.replace(/^##\s+\[[^\]]*\][^\n]*\n+/, '') +} + +export const ReleaseMarkdown: React.FC = ({ markdown }) => { + return ( + + ) +} diff --git a/src/components/Release/index.module.scss b/src/components/Release/index.module.scss new file mode 100644 index 000000000..dae72e185 --- /dev/null +++ b/src/components/Release/index.module.scss @@ -0,0 +1,182 @@ +@use '@scss/common' as *; + +.release { + border-bottom: 1px solid var(--grid-line-dark); + position: relative; + overflow: hidden; + padding-bottom: 3rem; + + @include data-theme-selector('dark') { + border-color: var(--grid-line-dark); + } + + @include data-theme-selector('light') { + border-color: var(--grid-line-light); + } +} + +.titleWrap { + padding: 2.5rem 0; +} + +.title { + @include h1; + & { + margin: 0; + width: calc(var(--column) * 11); + } + + @include large-break { + @include h2; + margin: 0 !important; + } + + @include mid-break { + width: 100%; + } +} + +.breadcrumbs { + margin: 0 0 1rem; +} + +.allReleases { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + text-decoration: none; + + svg { + transform: rotate(180deg); + } +} + +.blogWrap { + display: flex; + flex-direction: column; +} + +.stickyColumn { + @include mid-break { + display: none; + } +} + +.stickyContent { + top: 0; + position: sticky; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.githubLink { + @include body; + & { + color: var(--theme-elevation-600); + text-decoration: none; + transition: color 150ms ease; + } + + &:hover { + color: var(--theme-elevation-900); + } +} + +.excerpt { + margin: 3rem 0 1rem; + + @include mid-break { + margin: 1rem 0; + } +} + +.heroImageWrap { + margin-inline: calc(var(--column) * -1) calc(var(--column) * -4); + @include mid-break { + display: none; + } +} + +.heroImage { + width: 100%; + + & img { + width: 100%; + } +} + +.blocks { + & > * { + & > * { + margin-top: 2rem; + + &:last-child { + margin-bottom: 2rem; + } + } + } +} + +.markdownContent { + :global { + p { + margin: 0.5em 0; + } + + h2, h3, h4 { + margin-top: 1.5em; + margin-bottom: 0.25em; + } + + ul, ol { + margin: 0.25em 0; + padding-left: 1.5em; + } + + li { + margin: 0.15em 0; + } + + img { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 0.5em 0; + } + + hr { + margin: 1em 0; + } + } +} + +.mobileAuthor { + display: none; + + @include mid-break { + margin-top: 1rem; + display: flex; + + > * { + width: 50%; + } + } +} + +@include small-break { + .titleWrap { + display: unset; + } + + .mobileAuthor { + margin-top: 1rem; + display: flex; + flex-direction: column; + } + + .heroImage { + padding-bottom: 1.5rem; + } +} diff --git a/src/components/Release/index.tsx b/src/components/Release/index.tsx new file mode 100644 index 000000000..e13406198 --- /dev/null +++ b/src/components/Release/index.tsx @@ -0,0 +1,106 @@ +import type { Release as ReleaseType } from '@root/payload-types' + +import { BackgroundGrid } from '@components/BackgroundGrid/index' +import { Breadcrumbs } from '@components/Breadcrumbs/index' +import { Gutter } from '@components/Gutter/index' +import { Media } from '@components/Media/index' +import { RenderBlocks } from '@components/RenderBlocks/index' +import { RichText } from '@components/RichText/index' +import { ArrowRightIcon } from '@icons/ArrowRightIcon/index' +import { formatDate } from '@utilities/format-date-time' +import Link from 'next/link' +import React from 'react' + +import { AuthorsList } from '../Post/AuthorsList/index' +import classes from './index.module.scss' +import { ReleaseMarkdown } from './ReleaseMarkdown/index' + +export const Release: React.FC> = (props) => { + const { authors, content, excerpt, githubUrl, image, publishedOn, title } = props + + const ogImageUrl = `/api/og?type=releases&title=${encodeURIComponent(title || '')}` + + return ( + + + + + + + + {githubUrl && ( + + View on GitHub → + + )} + + + + + + + + + Releases + + ), + url: '/posts/releases', + }, + { + ...(publishedOn && { + label: {formatDate({ date: publishedOn })}, + }), + }, + ]} + /> + {title} + + + + + + + {image && typeof image !== 'string' ? ( + + ) : ( + + )} + + {excerpt && } + + {(content || []).map((block, i) => { + if (block.blockType === 'blogMarkdown' && block.blogMarkdownFields?.markdown) { + return ( + + + + ) + } + return null + })} + b.blockType !== 'blogMarkdown')} + disableGrid + disableGutter + /> + + + + + + ) +} diff --git a/src/payload-types.ts b/src/payload-types.ts index a5cd35cec..7b33b7f19 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -125,6 +125,7 @@ export interface Config { media: Media; pages: Page; posts: Post; + releases: Release; categories: Category; 'reusable-content': ReusableContent; users: User; @@ -156,6 +157,7 @@ export interface Config { media: MediaSelect | MediaSelect; pages: PagesSelect | PagesSelect; posts: PostsSelect | PostsSelect; + releases: ReleasesSelect | ReleasesSelect; categories: CategoriesSelect | CategoriesSelect; 'reusable-content': ReusableContentSelect | ReusableContentSelect; users: UsersSelect | UsersSelect; @@ -3327,6 +3329,91 @@ export interface CommunityHelp { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "releases". + */ +export interface Release { + id: string; + title: string; + image?: (string | null) | Media; + excerpt?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + content?: + | ( + | { + bannerFields: { + settings?: { + /** + * Leave blank for system default + */ + theme?: ('light' | 'dark') | null; + background?: ('solid' | 'transparent' | 'gradientUp' | 'gradientDown') | null; + }; + type?: ('default' | 'success' | 'warning' | 'error') | null; + addCheckmark?: boolean | null; + content: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + }; + }; + id?: string | null; + blockName?: string | null; + blockType: 'banner'; + } + | BlogContent + | Code + | BlogMarkdown + | MediaBlock + | ReusableContentBlock + )[] + | null; + authors: (string | User)[]; + /** + * Auto-generated from GitHub tag for imported releases. Enter manually for non-imported releases. + */ + slug: string; + publishedOn: string; + githubReleaseId?: number | null; + githubTag?: string | null; + githubUrl?: string | null; + importedFromGitHub?: boolean | null; + meta?: { + title?: string | null; + description?: string | null; + /** + * Maximum upload file size: 12MB. Recommended file size for images is <500KB. + */ + image?: (string | null) | Media; + }; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "form-submissions". @@ -3366,6 +3453,10 @@ export interface Redirect { | ({ relationTo: 'posts'; value: string | Post; + } | null) + | ({ + relationTo: 'releases'; + value: string | Release; } | null); url?: string | null; }; @@ -3420,6 +3511,10 @@ export interface PayloadLockedDocument { relationTo: 'posts'; value: string | Post; } | null) + | ({ + relationTo: 'releases'; + value: string | Release; + } | null) | ({ relationTo: 'categories'; value: string | Category; @@ -3817,6 +3912,55 @@ export interface PostsSelect { createdAt?: T; _status?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "releases_select". + */ +export interface ReleasesSelect { + title?: T; + image?: T; + excerpt?: T; + content?: + | T + | { + banner?: + | T + | { + bannerFields?: + | T + | { + settings?: + | T + | { + theme?: T; + background?: T; + }; + type?: T; + addCheckmark?: T; + content?: T; + }; + id?: T; + blockName?: T; + }; + }; + authors?: T; + slug?: T; + publishedOn?: T; + githubReleaseId?: T; + githubTag?: T; + githubUrl?: T; + importedFromGitHub?: T; + meta?: + | T + | { + title?: T; + description?: T; + image?: T; + }; + updatedAt?: T; + createdAt?: T; + _status?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "categories_select". diff --git a/src/payload.config.ts b/src/payload.config.ts index ecf97c33e..0d32481ea 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -74,6 +74,7 @@ import { Pages } from './collections/Pages' import { Budgets, Industries, Regions, Specialties } from './collections/PartnerFilters' import { Partners } from './collections/Partners' import { Posts } from './collections/Posts' +import { Releases } from './collections/Releases' import { ReusableContent } from './collections/ReusableContent' import { Users } from './collections/Users' import { Footer } from './globals/Footer' @@ -289,6 +290,7 @@ export default buildConfig({ Media, Pages, Posts, + Releases, Categories, ReusableContent, Users, @@ -563,7 +565,7 @@ export default buildConfig({ }, }), seoPlugin({ - collections: ['case-studies', 'pages', 'posts'], + collections: ['case-studies', 'pages', 'posts', 'releases'], globals: ['get-started'], uploadsCollection: 'media', }), @@ -573,7 +575,7 @@ export default buildConfig({ generateURL: (docs) => docs.reduce((url, doc) => `${url}/${doc.slug as string}`, ''), }), redirectsPlugin({ - collections: ['case-studies', 'pages', 'posts'], + collections: ['case-studies', 'pages', 'posts', 'releases'], overrides: { hooks: { afterChange: [revalidateRedirects], diff --git a/src/scripts/fetchReleases.ts b/src/scripts/fetchReleases.ts new file mode 100644 index 000000000..011f06893 --- /dev/null +++ b/src/scripts/fetchReleases.ts @@ -0,0 +1,209 @@ +/* eslint-disable no-console */ +import config from '@payload-config' +import { getPayload, type Payload } from 'payload' + +interface GitHubRelease { + body: null | string + draft: boolean + html_url: string + id: number + name: null | string + prerelease: boolean + published_at: null | string + tag_name: string +} + +/** + * Fetch releases from the GitHub REST API. + * Returns up to `limit` releases sorted by most recent first. + */ +export async function fetchGitHubReleases(limit = 20): Promise { + const GITHUB_ACCESS_TOKEN = process.env.GITHUB_ACCESS_TOKEN + if (!GITHUB_ACCESS_TOKEN) { + throw new Error('[fetchReleases] GITHUB_ACCESS_TOKEN is not set. Cannot fetch GitHub releases.') + } + + const perPage = Math.min(limit, 100) + const releases: GitHubRelease[] = [] + let page = 1 + + while (releases.length < limit) { + const res = await fetch( + `https://api.github.com/repos/payloadcms/payload/releases?per_page=${perPage}&page=${page}`, + { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${GITHUB_ACCESS_TOKEN}`, + }, + }, + ) + + if (!res.ok) { + throw new Error(`[fetchReleases] GitHub API error: ${res.status} ${res.statusText}`) + } + + const batch: GitHubRelease[] = await res.json() + + if (batch.length === 0) { + break + } + + // Skip drafts and pre-releases + const published = batch.filter((r) => !r.draft && !r.prerelease && r.published_at) + releases.push(...published) + page++ + + // If the batch was smaller than perPage, there are no more pages + if (batch.length < perPage) { + break + } + } + + return releases.slice(0, limit) +} + +/** + * Derive a URL-safe slug from a GitHub tag name. + * Preserves the leading `v`, dots, and hyphens. + * Only strips characters that are not word chars, dots, or hyphens. + */ +function tagToSlug(tag: string): string { + return tag + .replace(/ /g, '-') + .replace(/[^\w.-]+/g, '') + .toLowerCase() +} + +/** + * Map a GitHub release to the Payload release data shape. + */ +function mapGitHubRelease(release: GitHubRelease, authorId: string) { + return { + slug: tagToSlug(release.tag_name), + _status: 'published' as const, + authors: [authorId], + content: release.body + ? [ + { + blockType: 'blogMarkdown' as const, + blogMarkdownFields: { + markdown: release.body, + }, + }, + ] + : [], + githubReleaseId: release.id, + githubTag: release.tag_name, + githubUrl: release.html_url, + importedFromGitHub: true, + publishedOn: release.published_at || new Date().toISOString(), + title: release.name || release.tag_name, + } +} + +/** + * Resolve the "Payload Team" user record. + * Fails with a clear error if the user cannot be found. + */ +async function resolvePayloadTeamAuthor(payload: Payload): Promise { + const result = await payload.find({ + collection: 'users', + depth: 0, + limit: 1, + overrideAccess: true, + where: { + and: [{ firstName: { equals: 'Payload' } }, { lastName: { equals: 'Team' } }], + }, + }) + + if (result.docs.length === 0) { + throw new Error( + '[fetchReleases] Could not find a user with firstName="Payload" and lastName="Team". ' + + 'Please create this user in the Users collection before syncing releases.', + ) + } + + return result.docs[0].id +} + +/** + * Sync GitHub releases into the Payload Releases collection. + * Idempotent: upserts by githubReleaseId, with githubTag as fallback. + */ +export async function syncReleases(limit = 20): Promise<{ created: number; updated: number }> { + console.log(`[syncReleases] Starting sync of up to ${limit} releases...`) + + const payload = await getPayload({ config }) + const authorId = await resolvePayloadTeamAuthor(payload) + const githubReleases = await fetchGitHubReleases(limit) + + console.log(`[syncReleases] Fetched ${githubReleases.length} releases from GitHub`) + + // Fetch all existing releases from Payload to match against + const existingResult = await payload.find({ + collection: 'releases', + depth: 0, + limit: 0, + overrideAccess: true, + select: { + githubReleaseId: true, + githubTag: true, + }, + }) + + const existingByGitHubId = new Map() + const existingByTag = new Map() + + for (const doc of existingResult.docs) { + if (doc.githubReleaseId) { + existingByGitHubId.set(doc.githubReleaseId, doc.id) + } + if (doc.githubTag) { + existingByTag.set(doc.githubTag, doc.id) + } + } + + let created = 0 + let updated = 0 + + for (const release of githubReleases) { + const data = mapGitHubRelease(release, authorId) + + // Primary lookup by githubReleaseId, fallback to githubTag + const existingDocId = existingByGitHubId.get(release.id) || existingByTag.get(release.tag_name) + + try { + if (existingDocId) { + await payload.update({ + id: existingDocId, + collection: 'releases', + data, + draft: false, + overrideAccess: true, + }) + updated++ + console.log(`[syncReleases] Updated: ${data.title} (${release.tag_name})`) + } else { + await payload.create({ + collection: 'releases', + data, + draft: false, + overrideAccess: true, + }) + created++ + console.log(`[syncReleases] Created: ${data.title} (${release.tag_name})`) + } + } catch (error) { + console.error( + `[syncReleases] Failed to process release "${data.title}" (${release.tag_name}):`, + error, + ) + } + } + + console.log( + `[syncReleases] Sync complete. Created: ${created}, Updated: ${updated}, Total processed: ${githubReleases.length}`, + ) + + return { created, updated } +} diff --git a/src/scripts/seedReleases.ts b/src/scripts/seedReleases.ts new file mode 100644 index 000000000..4dccaf2f0 --- /dev/null +++ b/src/scripts/seedReleases.ts @@ -0,0 +1,30 @@ +/** + * One-time seed script to backfill the latest 20 GitHub releases. + * + * Usage: + * pnpm payload run src/scripts/seedReleases.ts + * + * This script is idempotent — re-running it will update existing records + * rather than create duplicates. + */ +import { syncReleases } from './fetchReleases' + +async function seed() { + // eslint-disable-next-line no-console + console.log('[seedReleases] Starting one-time seed of 20 releases...') + + try { + const result = await syncReleases(20) + // eslint-disable-next-line no-console + console.log( + `[seedReleases] Seed complete. Created: ${result.created}, Updated: ${result.updated}`, + ) + } catch (error) { + // eslint-disable-next-line no-console + console.error('[seedReleases] Seed failed:', error) + process.exit(1) + } +} + +// @ts-expect-error - top-level await required by payload run +await seed() diff --git a/src/utilities/formatPagePath.ts b/src/utilities/formatPagePath.ts index a74f2576a..d577e4be7 100644 --- a/src/utilities/formatPagePath.ts +++ b/src/utilities/formatPagePath.ts @@ -24,6 +24,9 @@ export const formatPagePath = ( case 'posts': prefix = `/posts/${category}` break + case 'releases': + prefix = '/posts/releases' + break default: prefix = `/${collection}` } diff --git a/vercel.json b/vercel.json index 8e220f9f3..2c8c5775e 100644 --- a/vercel.json +++ b/vercel.json @@ -6,6 +6,10 @@ { "path": "/api/sync-ch", "schedule": "0 2 * * *" + }, + { + "path": "/api/sync-releases", + "schedule": "0 */4 * * *" } ], "headers": [
No releases found.