Skip to content
Merged
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
44 changes: 23 additions & 21 deletions apps/cms/src/app/(payload)/admin/importMap.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions apps/cms/src/payload/admin/components/VideoThumbCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<img
src={src}
Expand All @@ -21,6 +21,14 @@ export const VideoThumbCell = ({ rowData }: { rowData?: Row }): ReactElement =>
) : (
<span style={{ display: 'block', width: 64, height: 36, borderRadius: 4, background: 'var(--theme-elevation-100)' }} />
);
// A custom cell in the first column replaces Payload's own row link, so the thumbnail links itself.
return rowData?.id == null ? (
frame
) : (
<a href={`/admin/collections/videos/${encodeURIComponent(String(rowData.id))}`} aria-label="Open video">
{frame}
</a>
);
};

export default VideoThumbCell;
18 changes: 18 additions & 0 deletions apps/cms/src/payload/admin/components/VideoTitleCell.tsx
Original file line number Diff line number Diff line change
@@ -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 <span>{title}</span>;
return <a href={`/admin/collections/videos/${encodeURIComponent(String(rowData.id))}`}>{title}</a>;
};

export default VideoTitleCell;
13 changes: 9 additions & 4 deletions apps/cms/src/payload/collections/Videos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {};
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion apps/cms/src/payload/lib/page-registry-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
1 change: 1 addition & 0 deletions apps/web/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/app/sitemap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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' },
];
Expand All @@ -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<Video[]> {
if (!VIDEOS_PAGE_PUBLIC) return [];
try {
return await getVideos();
} catch {
return [];
}
}

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
if (!isIndexingAllowed()) return [];

Expand All @@ -182,6 +193,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
legal,
knowledgeBase,
advisories,
videos,
] = await Promise.all([
fetchDocs('blogs', BLOG_FILTER),
fetchDocs('case-studies', BLOG_FILTER),
Expand All @@ -194,10 +206,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
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))),
Expand Down
Loading
Loading