From 050d9634726152237c01150dd89ea19e704fe28d Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Thu, 2 Jul 2026 11:05:41 -0700 Subject: [PATCH] feat(site): add changelog section (#1793) Co-authored-by: Claude --- site/CLAUDE.md | 20 +++ site/integrations/llms-markdown.ts | 4 +- site/src/components/Footer.astro | 27 +++- site/src/components/FooterDocs.astro | 27 +++- site/src/components/blog/BlogPagination.astro | 5 +- .../changelog/ChangelogEntryCard.astro | 27 ++++ site/src/components/docs/SidebarItem.astro | 26 +++- site/src/content.config.ts | 20 +++ site/src/docs.config.ts | 1 + site/src/pages/changelog/[...page].astro | 49 +++++++ site/src/pages/changelog/[...slug].astro | 127 ++++++++++++++++++ site/src/pages/changelog/rss.xml.js | 24 ++++ site/src/types/docs.ts | 23 +++- site/src/utils/docs/__tests__/sidebar.test.ts | 45 ++++++- site/src/utils/docs/sidebar.ts | 16 +-- site/src/utils/og/title-entries.ts | 32 ++++- 16 files changed, 447 insertions(+), 26 deletions(-) create mode 100644 site/src/components/changelog/ChangelogEntryCard.astro create mode 100644 site/src/pages/changelog/[...page].astro create mode 100644 site/src/pages/changelog/[...slug].astro create mode 100644 site/src/pages/changelog/rss.xml.js 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) {