From 5d8d27950408f29df4b9f1f807ac075d51c91a1f Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Tue, 3 Nov 2026 17:01:22 +0530 Subject: [PATCH 1/2] feat(web): launch /videos with a page per video and fix the SEO audit findings Make the page public (VIDEOS_PAGE_PUBLIC = true): indexable, in the sitemap and linked from the Resources menu. Give every video its own page at /videos/[slug] with the player, summary, description, topic links, related videos and VideoObject schema. Card, Quick starts, theatre and featured titles now link to it, so crawlers reach every video from the HTML. The thumbnail still plays inline. Also from the audit: - Page title "Container Security Videos: SBOM and VEX", H1 "Container security videos", longer meta description and lead. - Quick starts is an H2; the filter sheet title is no longer a heading. - Listing schema is a CollectionPage plus an ItemList of links to the video pages. - Sitemap lists /videos (dated by the newest video) and each video page. - llms.txt lists Videos; page registry gets a /videos/[slug] template row. --- .../cms/src/payload/lib/page-registry-seed.ts | 3 +- apps/web/public/llms.txt | 1 + apps/web/src/app/sitemap.test.ts | 7 +- apps/web/src/app/sitemap.ts | 18 +- apps/web/src/app/videos/[slug]/page.tsx | 205 ++++++++++++++++++ apps/web/src/app/videos/page.metadata.test.ts | 16 +- apps/web/src/app/videos/page.tsx | 46 ++-- .../sections/videos/FilterSheet.tsx | 2 +- .../sections/videos/QuickStarts.tsx | 22 +- .../sections/videos/RelatedVideos.tsx | 44 ++++ .../components/sections/videos/VideoCard.tsx | 7 +- .../sections/videos/VideoTheatre.tsx | 7 +- .../components/sections/videos/VideosHero.tsx | 19 +- apps/web/src/lib/videos-launch.ts | 9 +- apps/web/src/lib/videos-utils.test.ts | 35 +++ apps/web/src/lib/videos-utils.ts | 18 ++ apps/web/src/lib/videos.ts | 4 + 17 files changed, 400 insertions(+), 63 deletions(-) create mode 100644 apps/web/src/app/videos/[slug]/page.tsx create mode 100644 apps/web/src/components/sections/videos/RelatedVideos.tsx diff --git a/apps/cms/src/payload/lib/page-registry-seed.ts b/apps/cms/src/payload/lib/page-registry-seed.ts index 1d96cf03..75e166ea 100644 --- a/apps/cms/src/payload/lib/page-registry-seed.ts +++ b/apps/cms/src/payload/lib/page-registry-seed.ts @@ -133,8 +133,9 @@ export const PAGE_REGISTRY_SEED: readonly PageRegistrySeedRow[] = [ { path: '/advisories', title: 'Advisories', kind: 'cms-listing', order: 59, backingCollection: 'advisories' }, { path: '/advisories/[slug]', title: 'Advisory (template)', kind: 'cms-template', order: 60, backingCollection: 'advisories' }, // Videos: editorial collection fed by the YouTube sync (apps/cms videos + videoTopics). The page plays - // videos inline, so there is no per-video template route. + // videos inline, so there was no per-video route until each video got its own page for search. { path: '/videos', title: 'Videos', kind: 'cms-listing', order: 61, backingCollection: 'videos' }, + { path: '/videos/[slug]', title: 'Video (template)', kind: 'cms-template', order: 62, backingCollection: 'videos' }, ] as const; /** Fail-fast guard: paths unique + well-formed, templates carry a collection. */ diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index b5de308c..bb2f515a 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -35,6 +35,7 @@ - [Newsroom](https://www.cleanstart.com/news): Press releases, product launches, and company milestones. - [Events](https://www.cleanstart.com/events): DevSecOps and cybersecurity conference appearances and meetups. - [Webinars](https://www.cleanstart.com/webinars): On-demand and upcoming webinars on container security topics. +- [Videos](https://www.cleanstart.com/videos): Short explainers and product demos on container security, SBOMs, VEX, provenance and the software supply chain. - [Podcast](https://www.cleanstart.com/podcast): Leadership Exchange: industry leaders on container security and software supply chain risk. - [Knowledge Hub](https://www.cleanstart.com/knowledge-hub): Academy-style articles covering container fundamentals through advanced supply chain security. diff --git a/apps/web/src/app/sitemap.test.ts b/apps/web/src/app/sitemap.test.ts index 9d15e1c0..cfd7bf3d 100644 --- a/apps/web/src/app/sitemap.test.ts +++ b/apps/web/src/app/sitemap.test.ts @@ -46,12 +46,13 @@ describe("sitemap", () => { expect(urls).not.toContain("https://www.cleanstart.com/blogs/hidden"); }); - it("lists /videos only once the page is public, never per-video URLs, and never reads the videos collection", async () => { - // Videos play inline on /videos; there is no page per video to list. + it("lists /videos and every video page only once the page is public, without reading the videos collection", async () => { fetchMock.mockImplementation(async () => ok([])); const urls = (await (await load())()).map((e) => e.url); expect(urls.includes("https://www.cleanstart.com/videos")).toBe(VIDEOS_PAGE_PUBLIC); - expect(urls.filter((u) => u.includes("/videos/"))).toEqual([]); + const videoPages = urls.filter((u) => u.includes("/videos/")); + expect(videoPages.length > 0).toBe(VIDEOS_PAGE_PUBLIC); + expect(urls.filter((u) => u.endsWith("/videos/")).length).toBe(0); expect(fetchMock.mock.calls.map(([url]) => collectionOf(url as string))).not.toContain("videos"); }); diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index abdf57e5..060f6c25 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -2,7 +2,9 @@ import { fetchCMS } from '@/lib/cms-fetch'; import { effectiveModifiedAt } from '@/lib/published-date'; import { SITE_URL } from '@/lib/seo/canonical'; import { legalHref } from '@/lib/legal'; +import { getVideos } from '@/lib/videos'; import { VIDEOS_PAGE_PUBLIC } from '@/lib/videos-launch'; +import { videoHref, type Video } from '@/lib/videos-utils'; import { isIndexingAllowed } from '@/lib/seo/indexing'; import type { MetadataRoute } from 'next'; @@ -142,7 +144,6 @@ const STATIC_ROUTES: ReadonlyArray<{ path: string }> = [ { path: '/software-bill-materials' }, { path: '/teams' }, { path: '/tricorder' }, - ...(VIDEOS_PAGE_PUBLIC ? [{ path: '/videos' }] : []), { path: '/vulnerability-remediation' }, { path: '/webinars' }, ]; @@ -167,6 +168,16 @@ function entry(path: string, lastModified?: string): MetadataRoute.Sitemap[numbe }; } +/** Empty until the page is public; a YouTube or CMS outage leaves the rest of the sitemap intact. */ +async function publicVideos(): Promise { + if (!VIDEOS_PAGE_PUBLIC) return []; + try { + return await getVideos(); + } catch { + return []; + } +} + export default async function sitemap(): Promise { if (!isIndexingAllowed()) return []; @@ -182,6 +193,7 @@ export default async function sitemap(): Promise { legal, knowledgeBase, advisories, + videos, ] = await Promise.all([ fetchDocs('blogs', BLOG_FILTER), fetchDocs('case-studies', BLOG_FILTER), @@ -194,10 +206,14 @@ export default async function sitemap(): Promise { fetchDocs('legalDocuments', BLOG_FILTER), fetchDocs('knowledgeBase', BLOG_FILTER), fetchDocs('advisories', BLOG_FILTER), + publicVideos(), ]); return [ ...STATIC_ROUTES.map((r) => entry(r.path)), + // `getVideos` is newest first, so the first video dates the listing. + ...(videos.length > 0 ? [entry('/videos', videos[0]?.publishedAt)] : []), + ...videos.map((v) => entry(videoHref(v), v.publishedAt)), ...knowledgeBase.map((k) => entry(`/knowledge-hub/${k.slug}`, effectiveModifiedAt(k))), ...blogs.map((b) => entry(`/blogs/${b.slug}`, effectiveModifiedAt(b))), ...advisories.map((a) => entry(`/advisories/${a.slug}`, effectiveModifiedAt(a))), diff --git a/apps/web/src/app/videos/[slug]/page.tsx b/apps/web/src/app/videos/[slug]/page.tsx new file mode 100644 index 00000000..820948da --- /dev/null +++ b/apps/web/src/app/videos/[slug]/page.tsx @@ -0,0 +1,205 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { JsonLdGraph } from "@/components/JsonLdGraph"; +import { Header } from "@/components/nav/Header"; +import { Footer } from "@/components/sections/Footer"; +import { ReadyToSecureCTA } from "@/components/sections/home/ReadyToSecureCTA"; +import { RelatedVideos } from "@/components/sections/videos/RelatedVideos"; +import { VideoMeta } from "@/components/sections/videos/VideoMeta"; +import { VideoPlayer } from "@/components/sections/videos/VideoPlayer"; +import { HERO_GRADIENT } from "@/components/sections/videos/VideosHero"; +import { toIso8601Duration } from "@/lib/clean-libraries-video"; +import { youtubeThumbnail, youtubeWatchUrl } from "@/lib/podcast-utils"; +import { buildPageMetadata, clampMetaDescription } from "@/lib/seo/canonical"; +import { buildPageGraph } from "@/lib/seo/compose-page"; +import { breadcrumbSchema, videoObjectSchema, webPageSchema } from "@/lib/seo/jsonld"; +import { getVideoBySlug, getVideos } from "@/lib/videos"; +import { VIDEOS_PAGE_PUBLIC } from "@/lib/videos-launch"; +import { descriptionParagraphs, upNext, videoHref, videoFiltersQuery, DEFAULT_VIDEO_FILTERS } from "@/lib/videos-utils"; + +interface VideoPageProps { + params: Promise<{ slug: string }>; +} + +// Slugs not returned here still render on first request, then cache (ISR), so a video +// published after the last deploy gets its page without one. +export const dynamicParams = true; +export const revalidate = 3600; + +const RELATED_COUNT = 6; + +export async function generateStaticParams(): Promise> { + try { + return (await getVideos()).map((v) => ({ slug: v.slug })); + } catch { + return []; + } +} + +export async function generateMetadata({ params }: VideoPageProps): Promise { + const { slug } = await params; + const video = await getVideoBySlug(slug); + if (!video) { + return buildPageMetadata({ + title: "Video", + description: "CleanStart video.", + path: `/videos/${slug}`, + noindex: true, + }); + } + const thumb = video.thumb === "hq" ? "hqdefault" : "maxresdefault"; + return buildPageMetadata({ + title: video.title, + description: clampMetaDescription(video.summary || `Watch ${video.title} from CleanStart.`), + path: videoHref(video), + eyebrow: "Video", + image: { + url: youtubeThumbnail(video.id, thumb), + width: thumb === "hqdefault" ? 480 : 1280, + height: thumb === "hqdefault" ? 360 : 720, + alt: video.title, + }, + noindex: !VIDEOS_PAGE_PUBLIC, + nofollow: !VIDEOS_PAGE_PUBLIC, + }); +} + +export default async function VideoPage({ params }: VideoPageProps): Promise { + const { slug } = await params; + const [video, videos] = await Promise.all([getVideoBySlug(slug), getVideos()]); + if (!video) notFound(); + + const related = upNext(videos, video, RELATED_COUNT); + const paragraphs = descriptionParagraphs(video); + const thumb = video.thumb === "hq" ? "hqdefault" : "maxresdefault"; + + return ( + <> + +
+
+
+
+
+ +
+ +

+ {video.title} +

+
+ +
+
+ +
+
+

+ About this video +

+ {video.summary ? ( +

+ {video.summary} +

+ ) : null} + {paragraphs.map((paragraph) => ( +

+ {paragraph} +

+ ))} + {video.topics.length > 0 ? ( +
    + {video.topics.map((topic) => ( +
  • + + {topic.label} + +
  • + ))} +
+ ) : null} +
+ +

+ + All videos + +

+
+
+