diff --git a/site/CLAUDE.md b/site/CLAUDE.md
index 83e8a7d8..0a438910 100644
--- a/site/CLAUDE.md
+++ b/site/CLAUDE.md
@@ -462,6 +462,26 @@ ogImage: '../../assets/blog/2026-03-10-my-post/og.png'
Without a manual override, `Base.astro` derives `/og/{slug}.png` and `/og/twitter/{slug}.png` automatically. Those images are rendered on demand by `src/pages/og/[...path].png.ts`, limited to known internal page paths, and cached by Netlify until the next deploy.
+### Changelog Collection (`src/content/changelog/`)
+
+**Exception to the MDX-only rule:** changelog entries are plain `.md` files generated by CI (the release workflow writes raw git-cliff bullets; the changelog-prose workflow rewrites them into prose). Generated content never contains components, and `.md` is robust against PR titles that would break MDX parsing (`<`, `{`).
+
+**Filename convention:** `{version}.md` (e.g. `10.0.0-beta.24.md`) — the filename is the entry id and URL slug: `/changelog/10.0.0-beta.24/`.
+
+**Schema:**
+```ts
+{
+ description: string; // One-sentence summary (empty until prose exists)
+ date: Date;
+ version: string;
+ prerelease: boolean;
+ breaking: boolean;
+ compareUrl: string; // GitHub compare URL for the release
+}
+```
+
+**Rendering caveat:** Astro's `components` prop on `` is MDX-only, so the typography components don't apply to changelog bodies. `src/pages/changelog/[...slug].astro` mirrors their styles in a scoped `
diff --git a/site/src/pages/changelog/rss.xml.js b/site/src/pages/changelog/rss.xml.js
new file mode 100644
index 00000000..6f46fe2f
--- /dev/null
+++ b/site/src/pages/changelog/rss.xml.js
@@ -0,0 +1,24 @@
+import { getCollection } from 'astro:content';
+import rss from '@astrojs/rss';
+import { SITE_TITLE } from '@/consts';
+
+export async function GET(context) {
+ const entries = (await getCollection('changelog')).sort(
+ (a, b) =>
+ b.data.date.valueOf() - a.data.date.valueOf() ||
+ b.data.version.localeCompare(a.data.version, undefined, { numeric: true })
+ );
+
+ return rss({
+ title: `${SITE_TITLE} Changelog`,
+ description: 'New features, fixes, and improvements in every Video.js release',
+ site: context.site,
+ trailingSlash: false,
+ items: entries.map((entry) => ({
+ title: `v${entry.data.version}`,
+ pubDate: entry.data.date,
+ description: entry.data.description || undefined,
+ link: `/changelog/${entry.id}`,
+ })),
+ });
+}
diff --git a/site/src/types/docs.ts b/site/src/types/docs.ts
index 47ff8a0b..8aaea0c5 100644
--- a/site/src/types/docs.ts
+++ b/site/src/types/docs.ts
@@ -52,20 +52,35 @@ export interface Guide {
devOnly?: boolean; // only visible in development mode
}
+// Plain link to a page outside the docs (e.g. /changelog) — rendered with an
+// outbound arrow, excluded from guide navigation (prev/next, slugs, llms index)
+export interface SidebarLink {
+ href: string;
+ sidebarLabel: string;
+ frameworks?: SupportedFramework[];
+ devOnly?: boolean; // only visible in development mode
+}
+
export interface Section {
sidebarLabel: string;
llmsDescription?: string;
frameworks?: SupportedFramework[];
devOnly?: boolean; // only visible in development mode
defaultOpen?: boolean;
- contents: Array;
+ contents: Array;
}
-export type Sidebar = Array;
+export type SidebarItem = Guide | Section | SidebarLink;
+
+export type Sidebar = Array;
/**
- * Type guard to check if an item is a Section (vs a Guide)
+ * Type guard to check if an item is a Section (vs a Guide or SidebarLink)
*/
-export function isSection(item: Guide | Section): item is Section {
+export function isSection(item: SidebarItem): item is Section {
return 'contents' in item;
}
+
+export function isLink(item: SidebarItem): item is SidebarLink {
+ return 'href' in item;
+}
diff --git a/site/src/utils/docs/__tests__/sidebar.test.ts b/site/src/utils/docs/__tests__/sidebar.test.ts
index 6ec40654..b988f024 100644
--- a/site/src/utils/docs/__tests__/sidebar.test.ts
+++ b/site/src/utils/docs/__tests__/sidebar.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
-import type { Guide, Section, Sidebar } from '../../../types/docs';
+import type { Guide, Section, Sidebar, SidebarLink } from '../../../types/docs';
import {
filterSidebar,
findFirstGuide,
@@ -419,6 +419,49 @@ describe('sidebar utilities', () => {
});
});
+ describe('SidebarLink handling', () => {
+ const mockLink: SidebarLink = {
+ href: '/changelog',
+ sidebarLabel: 'Changelog',
+ };
+
+ it('should pass links through filterSidebar', () => {
+ const sidebar: Sidebar = [{ sidebarLabel: 'Section', contents: [mockGuide1, mockLink] }];
+ const result = filterSidebar('html', sidebar);
+
+ expect((result[0] as Section).contents).toContainEqual(mockLink);
+ });
+
+ it('should filter out devOnly links in production', () => {
+ const devLink: SidebarLink = { ...mockLink, devOnly: true };
+ const sidebar: Sidebar = [{ sidebarLabel: 'Section', contents: [mockGuide1, devLink] }];
+ const result = filterSidebar('html', sidebar, false);
+
+ expect((result[0] as Section).contents).toEqual([mockGuide1]);
+ });
+
+ it('should skip links in findFirstGuide', () => {
+ const sidebar: Sidebar = [mockLink, mockGuide3];
+ const result = findFirstGuide('html', sidebar);
+
+ expect(result).toBe('guide-3');
+ });
+
+ it('should exclude links from getAllGuideSlugs', () => {
+ const sidebar: Sidebar = [{ sidebarLabel: 'Section', contents: [mockGuide1, mockLink] }, mockGuide3];
+ const result = getAllGuideSlugs(sidebar);
+
+ expect(result).toEqual(['guide-1', 'guide-3']);
+ });
+
+ it('should ignore links in findGuideBySlug', () => {
+ const sidebar: Sidebar = [mockLink, mockGuide3];
+ const result = findGuideBySlug('guide-3', sidebar);
+
+ expect(result).toEqual(mockGuide3);
+ });
+ });
+
describe('sidebar config validation', () => {
it('should not have duplicate slugs in sidebar config', async () => {
// Import the real sidebar config
diff --git a/site/src/utils/docs/sidebar.ts b/site/src/utils/docs/sidebar.ts
index 8233a866..38d1d715 100644
--- a/site/src/utils/docs/sidebar.ts
+++ b/site/src/utils/docs/sidebar.ts
@@ -1,5 +1,5 @@
-import type { Guide, Section, Sidebar, SupportedFramework } from '@/types/docs';
-import { FRAMEWORK_STYLES, isSection } from '@/types/docs';
+import type { Guide, Sidebar, SidebarItem, SupportedFramework } from '@/types/docs';
+import { FRAMEWORK_STYLES, isLink, isSection } from '@/types/docs';
import { sidebar } from '../../docs.config';
@@ -13,7 +13,7 @@ import { sidebar } from '../../docs.config';
* @returns true if the item should be visible
*/
export function isItemVisible(
- item: Guide | Section,
+ item: SidebarItem,
framework: SupportedFramework,
isDev: boolean = import.meta.env.DEV
): boolean {
@@ -94,7 +94,7 @@ export function findFirstGuide(
} catch {
// Continue searching other sections
}
- } else {
+ } else if (!isLink(item)) {
// It's a Guide, return its slug
return item.slug;
}
@@ -119,7 +119,7 @@ export function getAllGuideSlugs(sidebarToExtract: Sidebar = sidebar): string[]
if (isSection(item)) {
// Recursively get slugs from section contents
slugs.push(...getAllGuideSlugs(item.contents));
- } else {
+ } else if (!isLink(item)) {
// It's a Guide, add its slug
slugs.push(item.slug);
}
@@ -141,7 +141,7 @@ export function findGuideBySlug(slug: string, sidebarToSearch: Sidebar = sidebar
// Recursively search section contents
const guide = findGuideBySlug(slug, item.contents);
if (guide) return guide;
- } else if (item.slug === slug) {
+ } else if (!isLink(item) && item.slug === slug) {
// Found the guide
return item;
}
@@ -166,7 +166,7 @@ export function getSectionsForGuide(slug: string, sidebarToSearch: Sidebar = sid
// Recursively search section contents with updated path
const result = findInSidebar(item.contents, [...path, item.sidebarLabel]);
if (result !== null) return result;
- } else if (item.slug === slug) {
+ } else if (!isLink(item) && item.slug === slug) {
// Found the guide, return the accumulated path
return path;
}
@@ -191,7 +191,7 @@ export function getValidFrameworksForGuide(guide: Guide, sidebarToSearch: Sideba
const sectionFrameworks = item.frameworks ? inherited.filter((f) => item.frameworks!.includes(f)) : inherited;
const result = findWithRestrictions(item.contents, sectionFrameworks);
if (result) return result;
- } else if (item.slug === guide.slug) {
+ } else if (!isLink(item) && item.slug === guide.slug) {
return item.frameworks ? inherited.filter((f) => item.frameworks!.includes(f)) : inherited;
}
}
diff --git a/site/src/utils/og/title-entries.ts b/site/src/utils/og/title-entries.ts
index c6954700..e0e3e05d 100644
--- a/site/src/utils/og/title-entries.ts
+++ b/site/src/utils/og/title-entries.ts
@@ -13,7 +13,7 @@ const STATIC_PAGES: { path: string; title: string }[] = [
{ path: 'blog', title: 'Blog' },
];
-export type OgTitleEntryKind = 'static' | 'blog' | 'blog-index' | 'author' | 'docs';
+export type OgTitleEntryKind = 'static' | 'blog' | 'blog-index' | 'author' | 'docs' | 'changelog' | 'changelog-index';
export type OgTitleSource = 'static' | 'title' | 'ogTitle' | 'frameworkTitle' | 'name';
export interface OgTitleEntry {
@@ -60,6 +60,36 @@ export async function listOgTitleEntries(): Promise {
});
}
+ const changelogEntries = await getCollection('changelog');
+
+ entries.push({
+ kind: 'changelog-index',
+ path: 'changelog',
+ title: 'Changelog',
+ source: 'static',
+ });
+
+ for (const entry of changelogEntries) {
+ entries.push({
+ kind: 'changelog',
+ path: `changelog/${entry.id}`,
+ title: `v${entry.data.version} Changelog`,
+ source: 'title',
+ collectionId: entry.id,
+ });
+ }
+
+ const totalChangelogPages = Math.ceil(changelogEntries.length / BLOG_PAGE_SIZE);
+
+ for (let page = 2; page <= totalChangelogPages; page += 1) {
+ entries.push({
+ kind: 'changelog-index',
+ path: `changelog/${page}`,
+ title: 'Changelog',
+ source: 'static',
+ });
+ }
+
const authors = await getCollection('authors');
for (const author of authors) {