diff --git a/apps/cms/src/app/(payload)/admin/importMap.js b/apps/cms/src/app/(payload)/admin/importMap.js
index 32c7fdeb..13b981db 100644
--- a/apps/cms/src/app/(payload)/admin/importMap.js
+++ b/apps/cms/src/app/(payload)/admin/importMap.js
@@ -78,6 +78,17 @@ import { PreviewMenu as PreviewMenu_50b93d8a0176cb6161ede3dcf4f0f442 } from '@/p
import { DocKebabExtras as DocKebabExtras_6380547e5dd048a503ca7dedfff6bec0 } from '@/payload/admin/components/DocKebabExtras.tsx'
import { CmsPublishButton as CmsPublishButton_c651128bf4837d4639a448413057b93f } from '@/payload/admin/components/CmsPublishButton.tsx'
import { CmsListView as CmsListView_339f71bcd64a39b547313d733bae8857 } from '@/payload/admin/components/views/list/CmsListView.tsx'
+import { RowField as RowField_1ef7316c3d3f9450222b0eb086a8e243 } from '@/payload/admin/components/fields/RowField.tsx'
+import { CatalogImagePicker as CatalogImagePicker_2327e75ccd37996d4075a1fbf8a8a049 } from '@/payload/admin/components/CatalogImagePicker.tsx'
+import { VideoSourceSummary as VideoSourceSummary_c96fbcdf17e1ea6b0ec94f543434cd27 } from '@/payload/admin/components/VideoSourceSummary.tsx'
+import { VideoTitleCell as VideoTitleCell_97af48f797dcdd14d8fbf98424a4fbcf } from '@/payload/admin/components/VideoTitleCell.tsx'
+import { RadioField as RadioField_31aae9b520654e49d40e444571bafe41 } from '@/payload/admin/components/fields/RadioField.tsx'
+import { VideoTopicSuggestions as VideoTopicSuggestions_354abe4af848e2bbd6afc4620ae30591 } from '@/payload/admin/components/VideoTopicSuggestions.tsx'
+import { UseDescriptionSummary as UseDescriptionSummary_19de247a3719b8cb4c2371ecb84ed232 } from '@/payload/admin/components/UseDescriptionSummary.tsx'
+import { CollapsibleField as CollapsibleField_e8aff66100db6bf10de9866e9f9ca5a8 } from '@/payload/admin/components/fields/CollapsibleField.tsx'
+import { VideoThumbCell as VideoThumbCell_a6fc69bad4fd684c1000e3675bb5a77a } from '@/payload/admin/components/VideoThumbCell.tsx'
+import { VideoLengthCell as VideoLengthCell_4ae28695c16ecdf12c15a176eb6e0774 } from '@/payload/admin/components/VideoLengthCell.tsx'
+import { VideoStateCell as VideoStateCell_70d255ecab4ca7e768a3cff708566031 } from '@/payload/admin/components/VideoStateCell.tsx'
import { GateFormLabel as GateFormLabel_c191dd670871074ae1a1cea086f78af8 } from '@/payload/admin/components/GateFormLabel.tsx'
import { DateField as DateField_a74145b29e7a0acd050f8f68af83aab6 } from '@/payload/admin/components/fields/DateField.tsx'
import { JoinField as JoinField_bd3b4cc9a58da6642d24527d78f5c3b6 } from '@/payload/admin/components/fields/JoinField.tsx'
@@ -118,7 +129,6 @@ import { HealthBadge as HealthBadge_89f48d3894398c6d1021775cfdeaf211 } from '@/p
import { TestButton as TestButton_0a975e83e531dadfb4f8fa6589a5704a } from '@/payload/admin/components/integrations/TestButton.tsx'
import { AuditTrail as AuditTrail_eca9fb91faa70a942f7e3507305e451c } from '@/payload/admin/components/integrations/AuditTrail.tsx'
import { GlobalSchemaView as GlobalSchemaView_962751a33d0bde2910843d19f617ee83 } from '../../../payload/admin/components/SchemaManager/GlobalSchemaView.tsx'
-import { CollapsibleField as CollapsibleField_e8aff66100db6bf10de9866e9f9ca5a8 } from '@/payload/admin/components/fields/CollapsibleField.tsx'
import { Icon as Icon_c822e198d4078a1912cce52e024df304 } from '../../../payload/admin/Icon.tsx'
import { Logo as Logo_dcbc6ddf8fe8d700973cf459e9b44aa4 } from '../../../payload/admin/Logo.tsx'
import { SkipLink as SkipLink_27a678dd284d4042d03686d8b9a9b8e9 } from '../../../payload/admin/components/SkipLink.tsx'
@@ -147,16 +157,7 @@ import { LeadAttributionView as LeadAttributionView_09cce16329a31c9c0551fcad8db2
import { CacheView as CacheView_53dd99c40bf5dfa0411c06d06a42a42b } from '../../../payload/admin/components/cache/CacheView.tsx'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
import { PointField as PointField_dc800e7ca3d2eb7ea39fd4bc6138c687 } from '@/payload/admin/components/fields/PointField.tsx'
-import { RadioField as RadioField_31aae9b520654e49d40e444571bafe41 } from '@/payload/admin/components/fields/RadioField.tsx'
import { TabsField as TabsField_c014faddfba6da88b9efedda256f4990 } from '@/payload/admin/components/fields/TabsField.tsx'
-import { RowField as RowField_1ef7316c3d3f9450222b0eb086a8e243 } from '@/payload/admin/components/fields/RowField.tsx'
-import { VideoSourceSummary as VideoSourceSummary_c96fbcdf17e1ea6b0ec94f543434cd27 } from '@/payload/admin/components/VideoSourceSummary.tsx'
-import { VideoThumbCell as VideoThumbCell_a6fc69bad4fd684c1000e3675bb5a77a } from '@/payload/admin/components/VideoThumbCell.tsx'
-import { VideoLengthCell as VideoLengthCell_4ae28695c16ecdf12c15a176eb6e0774 } from '@/payload/admin/components/VideoLengthCell.tsx'
-import { VideoStateCell as VideoStateCell_70d255ecab4ca7e768a3cff708566031 } from '@/payload/admin/components/VideoStateCell.tsx'
-import { VideoTopicSuggestions as VideoTopicSuggestions_354abe4af848e2bbd6afc4620ae30591 } from '@/payload/admin/components/VideoTopicSuggestions.tsx'
-import { UseDescriptionSummary as UseDescriptionSummary_19de247a3719b8cb4c2371ecb84ed232 } from '@/payload/admin/components/UseDescriptionSummary.tsx'
-import { CatalogImagePicker as CatalogImagePicker_2327e75ccd37996d4075a1fbf8a8a049 } from '@/payload/admin/components/CatalogImagePicker.tsx'
/** @type import('payload').ImportMap */
export const importMap = {
@@ -240,6 +241,17 @@ export const importMap = {
"@/payload/admin/components/DocKebabExtras.tsx#DocKebabExtras": DocKebabExtras_6380547e5dd048a503ca7dedfff6bec0,
"@/payload/admin/components/CmsPublishButton.tsx#CmsPublishButton": CmsPublishButton_c651128bf4837d4639a448413057b93f,
"@/payload/admin/components/views/list/CmsListView.tsx#CmsListView": CmsListView_339f71bcd64a39b547313d733bae8857,
+ "@/payload/admin/components/fields/RowField.tsx#RowField": RowField_1ef7316c3d3f9450222b0eb086a8e243,
+ "@/payload/admin/components/CatalogImagePicker.tsx#CatalogImagePicker": CatalogImagePicker_2327e75ccd37996d4075a1fbf8a8a049,
+ "@/payload/admin/components/VideoSourceSummary.tsx#VideoSourceSummary": VideoSourceSummary_c96fbcdf17e1ea6b0ec94f543434cd27,
+ "@/payload/admin/components/VideoTitleCell.tsx#VideoTitleCell": VideoTitleCell_97af48f797dcdd14d8fbf98424a4fbcf,
+ "@/payload/admin/components/fields/RadioField.tsx#RadioField": RadioField_31aae9b520654e49d40e444571bafe41,
+ "@/payload/admin/components/VideoTopicSuggestions.tsx#VideoTopicSuggestions": VideoTopicSuggestions_354abe4af848e2bbd6afc4620ae30591,
+ "@/payload/admin/components/UseDescriptionSummary.tsx#UseDescriptionSummary": UseDescriptionSummary_19de247a3719b8cb4c2371ecb84ed232,
+ "@/payload/admin/components/fields/CollapsibleField.tsx#CollapsibleField": CollapsibleField_e8aff66100db6bf10de9866e9f9ca5a8,
+ "@/payload/admin/components/VideoThumbCell.tsx#VideoThumbCell": VideoThumbCell_a6fc69bad4fd684c1000e3675bb5a77a,
+ "@/payload/admin/components/VideoLengthCell.tsx#VideoLengthCell": VideoLengthCell_4ae28695c16ecdf12c15a176eb6e0774,
+ "@/payload/admin/components/VideoStateCell.tsx#VideoStateCell": VideoStateCell_70d255ecab4ca7e768a3cff708566031,
"@/payload/admin/components/GateFormLabel.tsx#GateFormLabel": GateFormLabel_c191dd670871074ae1a1cea086f78af8,
"@/payload/admin/components/fields/DateField.tsx#DateField": DateField_a74145b29e7a0acd050f8f68af83aab6,
"@/payload/admin/components/fields/JoinField.tsx#JoinField": JoinField_bd3b4cc9a58da6642d24527d78f5c3b6,
@@ -280,7 +292,6 @@ export const importMap = {
"@/payload/admin/components/integrations/TestButton.tsx#TestButton": TestButton_0a975e83e531dadfb4f8fa6589a5704a,
"@/payload/admin/components/integrations/AuditTrail.tsx#AuditTrail": AuditTrail_eca9fb91faa70a942f7e3507305e451c,
"./payload/admin/components/SchemaManager/GlobalSchemaView.tsx#GlobalSchemaView": GlobalSchemaView_962751a33d0bde2910843d19f617ee83,
- "@/payload/admin/components/fields/CollapsibleField.tsx#CollapsibleField": CollapsibleField_e8aff66100db6bf10de9866e9f9ca5a8,
"./payload/admin/Icon.tsx#Icon": Icon_c822e198d4078a1912cce52e024df304,
"./payload/admin/Logo.tsx#Logo": Logo_dcbc6ddf8fe8d700973cf459e9b44aa4,
"./payload/admin/components/SkipLink.tsx#SkipLink": SkipLink_27a678dd284d4042d03686d8b9a9b8e9,
@@ -309,14 +320,5 @@ export const importMap = {
"./payload/admin/components/cache/CacheView.tsx#CacheView": CacheView_53dd99c40bf5dfa0411c06d06a42a42b,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
"@/payload/admin/components/fields/PointField.tsx#PointField": PointField_dc800e7ca3d2eb7ea39fd4bc6138c687,
- "@/payload/admin/components/fields/RadioField.tsx#RadioField": RadioField_31aae9b520654e49d40e444571bafe41,
- "@/payload/admin/components/fields/TabsField.tsx#TabsField": TabsField_c014faddfba6da88b9efedda256f4990,
- "@/payload/admin/components/fields/RowField.tsx#RowField": RowField_1ef7316c3d3f9450222b0eb086a8e243,
- "@/payload/admin/components/VideoSourceSummary.tsx#VideoSourceSummary": VideoSourceSummary_c96fbcdf17e1ea6b0ec94f543434cd27,
- "@/payload/admin/components/VideoThumbCell.tsx#VideoThumbCell": VideoThumbCell_a6fc69bad4fd684c1000e3675bb5a77a,
- "@/payload/admin/components/VideoLengthCell.tsx#VideoLengthCell": VideoLengthCell_4ae28695c16ecdf12c15a176eb6e0774,
- "@/payload/admin/components/VideoStateCell.tsx#VideoStateCell": VideoStateCell_70d255ecab4ca7e768a3cff708566031,
- "@/payload/admin/components/VideoTopicSuggestions.tsx#VideoTopicSuggestions": VideoTopicSuggestions_354abe4af848e2bbd6afc4620ae30591,
- "@/payload/admin/components/UseDescriptionSummary.tsx#UseDescriptionSummary": UseDescriptionSummary_19de247a3719b8cb4c2371ecb84ed232,
- "@/payload/admin/components/CatalogImagePicker.tsx#CatalogImagePicker": CatalogImagePicker_2327e75ccd37996d4075a1fbf8a8a049,
+ "@/payload/admin/components/fields/TabsField.tsx#TabsField": TabsField_c014faddfba6da88b9efedda256f4990
}
diff --git a/apps/cms/src/payload/admin/components/VideoThumbCell.tsx b/apps/cms/src/payload/admin/components/VideoThumbCell.tsx
index bf89b1cd..29c1044b 100644
--- a/apps/cms/src/payload/admin/components/VideoThumbCell.tsx
+++ b/apps/cms/src/payload/admin/components/VideoThumbCell.tsx
@@ -2,13 +2,13 @@
import type { ReactElement } from 'react';
-type Row = { externalId?: string | null; source?: { thumbnailUrl?: string | null } | null };
+type Row = { id?: string | number; externalId?: string | null; source?: { thumbnailUrl?: string | null } | null };
export const VideoThumbCell = ({ rowData }: { rowData?: Row }): ReactElement => {
const src =
rowData?.source?.thumbnailUrl ||
(rowData?.externalId ? `https://i.ytimg.com/vi/${rowData.externalId}/mqdefault.jpg` : null);
- return src ? (
+ const frame = src ? (
// eslint-disable-next-line @next/next/no-img-element
) : (
);
+ // A custom cell in the first column replaces Payload's own row link, so the thumbnail links itself.
+ return rowData?.id == null ? (
+ frame
+ ) : (
+
+ {frame}
+
+ );
};
export default VideoThumbCell;
diff --git a/apps/cms/src/payload/admin/components/VideoTitleCell.tsx b/apps/cms/src/payload/admin/components/VideoTitleCell.tsx
new file mode 100644
index 00000000..c20ca46d
--- /dev/null
+++ b/apps/cms/src/payload/admin/components/VideoTitleCell.tsx
@@ -0,0 +1,18 @@
+'use client';
+
+import type { ReactElement } from 'react';
+
+type Row = { id?: string | number };
+
+/**
+ * Title cell for the Videos list. The linked column is the thumbnail, whose
+ * custom cell Payload does not wrap in a link, so the title carries one too and
+ * a click anywhere on the row's text opens the video.
+ */
+export const VideoTitleCell = ({ cellData, rowData }: { cellData?: string | null; rowData?: Row }): ReactElement => {
+ const title = cellData?.trim() || 'Untitled video';
+ if (rowData?.id == null) return {title};
+ return {title};
+};
+
+export default VideoTitleCell;
diff --git a/apps/cms/src/payload/collections/Videos.ts b/apps/cms/src/payload/collections/Videos.ts
index 7230d42d..30b278b5 100644
--- a/apps/cms/src/payload/collections/Videos.ts
+++ b/apps/cms/src/payload/collections/Videos.ts
@@ -77,7 +77,7 @@ export const singleFeaturedHook: CollectionAfterChangeHook = async ({ doc, req,
return doc;
};
-/** The slug has no public meaning (videos play inline), so it is generated from the title and not shown. */
+/** The slug is the video page's URL (/videos/[slug]). It is filled once from the title and hidden, so editing the title never moves a published URL. */
const hiddenSlugField = (): Field => {
const field = slugField({ source: 'title' }) as TextField;
const { components: _components, position: _position, description: _description, ...admin } = field.admin ?? {};
@@ -87,8 +87,9 @@ const hiddenSlugField = (): Field => {
/**
* Editorial videos for /videos. Imported from YouTube by the hourly sync as
* needs-review drafts (see `lib/videos`). The `source` group is owned by the
- * sync, everything else by editors; neither overwrites the other. Videos play
- * inline on the listing, so there is no per-video page, URL or SEO block here.
+ * sync, everything else by editors; neither overwrites the other. Each video has
+ * its own page at /videos/[slug], whose title and description come from the
+ * title and summary fields, so there is no separate SEO block here.
*/
export const Videos: CollectionConfig = {
slug: 'videos',
@@ -137,7 +138,11 @@ export const Videos: CollectionConfig = {
'Paste the link of the video. It is saved as the video ID and cannot be changed afterwards. The video shows on /videos only once it has a category, a topic and a summary and is published.',
},
},
- { ...contentTitleField, label: 'Title on the website' },
+ {
+ ...contentTitleField,
+ label: 'Title on the website',
+ admin: { components: { Cell: '@/payload/admin/components/VideoTitleCell.tsx#VideoTitleCell' } },
+ },
hiddenSlugField(),
{
name: 'format',
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