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
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
],
"ignoreRegExpList": [
"featuredVideoId:(.*)", // video hash
"youtubeId:(.*)", // video hash
"videoId=\"(.*)\"" // video hash
]
}
3 changes: 3 additions & 0 deletions website/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ the conventions in [README.md → Authoring Markdown Content](./README.md#author
It covers frontmatter, `structure.yaml` sidebars, links, images, YouTube videos,
admonitions, Mermaid diagrams, and MDX gotchas.

When creating or editing a video under `content/videos/`, also follow
[README.md → Videos](./README.md#videos) for the file name and frontmatter.

# Components

This site is built to be extended agent-first: adding a page or component should
Expand Down
43 changes: 43 additions & 0 deletions website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,46 @@ it preserves headings, prose, links, lists, admonitions, tables, and code while
excluding frontmatter, navigation, buttons, forms, SVG/canvas diagrams, and
elements marked with `data-llms-ignore`. Interactive MDX components contribute
their rendered textual content; purely visual components are omitted.

## Videos

The `/videos` section lists ChilliCream TV episodes. Each video is one Markdown
file in `content/videos/`, named `YYYY-MM-DD-slug.md`. The date prefix is the
publish date and the file name is the URL slug (`/videos/YYYY-MM-DD-slug`). The
slug is lowercase and hyphenated.

```markdown
---
title: "How to Use State in DataLoader for Context-Aware Fetching"
description: "One sentence shown on the video card and used for SEO."
youtubeId: "4Mw2A548OGM"
duration: "15:51"
example: "https://github.com/<org>/<repo>/tree/main/<folder>"
author: "michael-staib"
tags:
- dataloader
- hotchocolate
---

In this episode, we explore how to make your DataLoaders stateful.

Hot Chocolate version used in this video: [15.1.4](https://www.nuget.org/packages/HotChocolate/15.1.4)
```

| Field | Required | Meaning |
| ------------- | -------- | ------------------------------------------------------------------------------- |
| `title` | yes | Page title and card heading. |
| `description` | yes | One sentence for the card, meta description, and Open Graph. |
| `youtubeId` | yes | The 11-character YouTube video id. Quote it, so YAML always reads a string. |
| `duration` | yes | Running time as YouTube shows it, e.g. `"12:18"` or `"1:02:03"`. |
| `example` | no | `https://` link to the example source code. Adds the **Example source** button. |
| `author` | no | Slug of a presenter in `src/data/authors.ts`. |
| `tags` | no | Lowercase, hyphenated topics. They become the filter chips on `/videos`. |

- The body is the video description, rendered below the player. It follows the
same Markdown rules as docs and blog posts (links, images, admonitions, MDX
gotchas).
- The poster image is the YouTube thumbnail. `npm run optimize-images`
self-hosts it.
- An invalid file name or frontmatter fails the build with a message naming the
file.
3 changes: 3 additions & 0 deletions website/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@
--color-cc-youtube: #ff0000;
--color-cc-youtube-hover: #cc0000;

/* Videos section accent: the amber shared by the ChilliCream TV thumbnails. */
--color-cc-amber: #f5ad2b;

/* Docs sidebar: cool-slate nav text + a dimmer muted section-label tone. */
--color-cc-nav-text: #cad5e2;
--color-cc-nav-label: #62748e;
Expand Down
13 changes: 13 additions & 0 deletions website/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { listBlogPostSummaries } from "@/src/helpers/blogPosts";
import { getLastModifiedFromGit } from "@/src/helpers/gitMetadata";
import { readFrontmatter } from "@/src/helpers/readFrontmatter";
import { SITE_URL } from "@/src/helpers/siteUrl";
import { listVideos, VIDEOS_PATH } from "@/src/helpers/videos";
import { AUTHOR_PROFILES, authorPageUrl } from "@/src/data/authors";

export const dynamic = "force-static";
Expand All @@ -36,6 +37,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
...(await docsPages()),
...blogArchivePages(),
...(await blogPosts()),
...(await videoPages()),
...authorPages(),
];

Expand Down Expand Up @@ -134,6 +136,17 @@ async function blogPosts(): Promise<MetadataRoute.Sitemap> {
);
}

async function videoPages(): Promise<MetadataRoute.Sitemap> {
return [
sitemapEntry(VIDEOS_PATH),
...(await Promise.all(
listVideos().map(async (video) =>
sitemapEntry(video.href, await getLastModifiedFromGit(video.file)),
),
)),
];
}

function sitemapEntry(
urlPath: string,
lastModified?: Date,
Expand Down
176 changes: 176 additions & 0 deletions website/app/videos/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { PageStructuredData } from "@/src/components/PageStructuredData";
import { MoreVideos } from "@/src/components/videos/MoreVideos";
import { VideoByline } from "@/src/components/videos/VideoByline";
import { YouTubeVideo } from "@/src/components/YouTubeVideo";
import { OutlineButton, SolidButton } from "@/src/design-system/Button";
import { Card } from "@/src/design-system/Card";
import { Eyebrow } from "@/src/design-system/Eyebrow";
import { TagList } from "@/src/design-system/TagList";
import { createAuthorPersonNode } from "@/src/data/authors";
import { compileDoc } from "@/src/helpers/compileDoc";
import { getLastModifiedFromGit } from "@/src/helpers/gitMetadata";
import { SITE_NAME, TWITTER_HANDLE } from "@/src/helpers/site";
import { toAbsoluteUrl } from "@/src/helpers/siteUrl";
import { schemaRef } from "@/src/helpers/structuredData";
import {
createVideoObjectNode,
videoObjectId,
} from "@/src/helpers/videoStructuredData";
import {
findRelatedVideos,
formatEpisode,
listVideos,
topicLabel,
VIDEOS_PATH,
videoPosterUrl,
youtubeWatchUrl,
} from "@/src/helpers/videos";
import { ArrowLeftIcon } from "@/src/icons/ArrowLeft";
import { BranchIcon } from "@/src/icons/BranchIcon";
import { YouTubeIcon } from "@/src/icons/YouTube";

type Params = { slug: string };
type PageProps = { params: Promise<Params> };

export const dynamicParams = false;

export function generateStaticParams(): Params[] {
const params = listVideos().map((video) => ({ slug: video.stem }));
// output: export requires at least one prerendered path; the placeholder
// renders a 404 via notFound() when there are no videos.
return params.length > 0 ? params : [{ slug: "__empty__" }];
}

export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { slug } = await params;
const video = listVideos().find((candidate) => candidate.stem === slug);
if (!video) {
return {};
}

const images = [toAbsoluteUrl(videoPosterUrl(video.youtubeId))];
return {
title: video.title,
description: video.description,
alternates: { canonical: video.href },
openGraph: {
type: "video.other",
siteName: SITE_NAME,
title: video.title,
description: video.description,
url: video.href,
images,
videos: [
{
url: `https://www.youtube.com/embed/${video.youtubeId}`,
type: "text/html",
width: 1280,
height: 720,
},
],
},
twitter: {
card: "summary_large_image",
site: TWITTER_HANDLE,
title: video.title,
description: video.description,
images,
},
};
}

export default async function VideoPage({ params }: PageProps) {
const { slug } = await params;
const videos = listVideos();
const video = videos.find((candidate) => candidate.stem === slug);
if (!video) {
notFound();
}

const [{ content }, lastModified] = await Promise.all([
compileDoc(video.file),
getLastModifiedFromGit(video.file),
]);
const related = findRelatedVideos(video, videos);

const additionalNodes = [createVideoObjectNode(video)];
if (video.author) {
additionalNodes.push(createAuthorPersonNode(video.author));
}

return (
<div className="px-5 py-8 sm:px-12">
<PageStructuredData
title={video.title}
description={video.description}
dateModified={lastModified?.toISOString()}
path={video.href}
pageType="ItemPage"
breadcrumbs={[
{ name: "Home", path: "/" },
{ name: "Videos", path: VIDEOS_PATH },
{ name: video.title },
]}
mainEntity={schemaRef(videoObjectId(video))}
additionalNodes={additionalNodes}
/>
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-12 lg:grid-cols-[minmax(0,1fr)_20rem] xl:gap-16">
<article className="min-w-0">
<Link
href={VIDEOS_PATH}
prefetch={false}
data-llms-ignore
className="text-cc-ink-dim hover:text-cc-heading inline-flex items-center gap-1.5 text-sm no-underline transition-colors print:hidden"
>
<ArrowLeftIcon className="size-3.5" />
All videos
</Link>
<YouTubeVideo
videoId={video.youtubeId}
playlabel={`Play ${video.title}`}
autoload
sizes="(max-width: 1023px) calc(100vw - 2.5rem), 880px"
className="mt-5 rounded-2xl"
/>
<Eyebrow size="2xs" color="amber" className="mt-8">
Episode {formatEpisode(video.episode)}
</Eyebrow>
<h1 className="font-heading text-h5 text-cc-heading sm:text-h4 mt-2 tracking-tight">
{video.title}
</h1>
<div className="mt-4 flex flex-wrap items-center justify-between gap-4">
<VideoByline video={video} />
<div className="grid w-full gap-3 sm:inline-grid sm:w-auto sm:auto-cols-fr sm:grid-flow-col print:hidden">
{video.example ? (
<SolidButton
href={video.example}
className="gap-2 border border-transparent"
>
<BranchIcon />
Example source
</SolidButton>
) : null}
<OutlineButton
href={youtubeWatchUrl(video.youtubeId)}
className="gap-2"
>
<YouTubeIcon className="text-cc-youtube h-4 w-auto fill-current" />
Watch on YouTube
</OutlineButton>
</div>
</div>
<Card className="mt-8 px-6 py-2 sm:px-8">
{content}
<TagList tags={video.tags.map(topicLabel)} className="mt-2 mb-6" />
</Card>
</article>
<MoreVideos videos={related} className="lg:pt-10" />
</div>
</div>
);
}
14 changes: 14 additions & 0 deletions website/app/videos/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as page from "./page";
import {
createPageShareCardImage,
shareCardContentType,
shareCardSize,
} from "@/src/og/pageShareCardImage";

export const dynamic = "force-static";

export const alt = "ChilliCream TV videos on Hot Chocolate and GraphQL in .NET";
export const size = shareCardSize;
export const contentType = shareCardContentType;

export default createPageShareCardImage(page);
84 changes: 84 additions & 0 deletions website/app/videos/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { PageStructuredData } from "@/src/components/PageStructuredData";
import { SectionHeading } from "@/src/components/SectionHeading";
import { VideoCard } from "@/src/components/videos/VideoCard";
import { VideoCatalog } from "@/src/components/videos/VideoCatalog";
import { VideosHero } from "@/src/components/videos/VideosHero";
import { pageMetadata } from "@/src/helpers/pageMetadata";
import { schemaRef } from "@/src/helpers/structuredData";
import {
createVideoItemListNode,
VIDEOS_DESCRIPTION,
} from "@/src/helpers/videoStructuredData";
import {
listVideos,
listVideoTags,
topicLabel,
VIDEOS_PATH,
} from "@/src/helpers/videos";

const PAGE = {
title: "Videos",
description: VIDEOS_DESCRIPTION,
path: VIDEOS_PATH,
} as const;

export const metadata = pageMetadata(PAGE);

export default function VideosIndex() {
const videos = listVideos();
const topics = listVideoTags(videos);
const videoList = createVideoItemListNode(
PAGE.path,
"ChilliCream TV episodes",
videos,
);

return (
<div className="px-5 py-8 sm:px-12">
<PageStructuredData
{...PAGE}
pageType="CollectionPage"
breadcrumbs={[{ name: "Home", path: "/" }, { name: "Videos" }]}
mainEntity={schemaRef(videoList["@id"]!)}
additionalNodes={[videoList]}
/>
<div className="mx-auto max-w-7xl">
<VideosHero videos={videos} browseHref="#episodes" />

<section
id="episodes"
aria-labelledby="episodes-title"
className="scroll-mt-8 pt-16 pb-8"
>
<div className="mb-10">
<SectionHeading titleId="episodes-title" title="All episodes" />
</div>
{videos.length > 0 ? (
<VideoCatalog
topicLabels={Object.fromEntries(
topics.map((topic) => [topic, topicLabel(topic)]),
)}
items={videos.map((video) => ({
key: video.stem,
topics: video.tags,
year: video.date.slice(0, 4),
presenter: video.author?.name ?? null,
hasExample: video.example !== null,
searchText: [
video.title,
video.description,
...video.tags.map(topicLabel),
]
.join(" ")
.toLowerCase(),
card: <VideoCard video={video} />,
}))}
/>
) : (
<p className="text-cc-ink-dim">No videos yet. Check back soon.</p>
)}
</section>
</div>
</div>
);
}
Loading
Loading