From 6f913e91dcffd069bd7a90eb87da4d1bac4191d6 Mon Sep 17 00:00:00 2001 From: Wes Reid Date: Mon, 3 Aug 2026 15:24:10 -0700 Subject: [PATCH] Add draft docs mechanism (VITE_SHOW_DRAFTS) Pages with `draft: true` in their frontmatter are hidden from the nav and return 404 when accessed directly unless VITE_SHOW_DRAFTS=true is set. In preview mode, draft nav items render in warning amber and draft pages show a callout banner. --- packages/docs/.env.example | 3 +++ packages/docs/app/components/DocsSidebar.tsx | 16 ++++++++++++++++ packages/docs/app/components/docs-content.ts | 2 ++ packages/docs/app/components/docsNavItems.ts | 19 +++++++++++++++---- .../docs/app/routes/docs.$locale.$slug.tsx | 19 +++++++++++++++++++ packages/docs/app/routes/docs.$slug.tsx | 19 +++++++++++++++++++ 6 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 packages/docs/.env.example diff --git a/packages/docs/.env.example b/packages/docs/.env.example new file mode 100644 index 0000000000..02d2d08332 --- /dev/null +++ b/packages/docs/.env.example @@ -0,0 +1,3 @@ +# Show draft documentation pages in the sidebar and allow direct URL access. +# Pages marked `draft: true` are hidden in production builds. +# VITE_SHOW_DRAFTS=true diff --git a/packages/docs/app/components/DocsSidebar.tsx b/packages/docs/app/components/DocsSidebar.tsx index 89615af632..876382835c 100644 --- a/packages/docs/app/components/DocsSidebar.tsx +++ b/packages/docs/app/components/DocsSidebar.tsx @@ -12,6 +12,7 @@ import { const ALWAYS_OPEN_SECTION_INDEX = 0; + function normalizePath(pathname: string) { return pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname; } @@ -193,6 +194,11 @@ export default function DocsSidebar() { current === item.id ? null : item.id, ) } + style={ + item.draft + ? { color: "var(--approaches-warn)" } + : undefined + } > {item.label} {item.label} @@ -236,6 +247,11 @@ export default function DocsSidebar() { tabIndex={ childrenTabbable ? undefined : -1 } + style={ + child.draft + ? { color: "var(--approaches-warn)" } + : undefined + } > {child.label} diff --git a/packages/docs/app/components/docs-content.ts b/packages/docs/app/components/docs-content.ts index 5421b0c688..866655795b 100644 --- a/packages/docs/app/components/docs-content.ts +++ b/packages/docs/app/components/docs-content.ts @@ -50,6 +50,7 @@ export interface DocEntry { title: string; description: string; search: string; + draft?: boolean; body: string; // markdown body (without frontmatter) headings: { id: string; label: string; level: number }[]; } @@ -150,6 +151,7 @@ function docEntryFromPath(path: string, raw: string): DocEntry { title: data.title || slug, description: data.description || "", search: data.search || "", + draft: data.draft === "true" || undefined, body, headings, }; diff --git a/packages/docs/app/components/docsNavItems.ts b/packages/docs/app/components/docsNavItems.ts index bd31f7138a..f4559bb4ec 100644 --- a/packages/docs/app/components/docsNavItems.ts +++ b/packages/docs/app/components/docsNavItems.ts @@ -9,6 +9,7 @@ export type NavItem = { id: string; label: string; to?: string; + draft?: boolean; children?: NavItem[]; }; export type NavSection = { id: string; title: string; items: NavItem[] }; @@ -19,6 +20,7 @@ type NavItemConfig = { id: string; labelKey: keyof typeof enUS.nav; slug?: string; + draft?: boolean; children?: NavItemConfig[]; }; @@ -892,17 +894,24 @@ function navLabel(t: Translate, key: keyof typeof enUS.nav): string { return t(`nav.${key}`) || enMessage(`nav.${key}`); } +const SHOW_DRAFTS = import.meta.env.VITE_SHOW_DRAFTS === "true"; + function toNavItem( config: NavItemConfig, locale: DocsLocale, t: Translate, -): NavItem { +): NavItem | null { + if (config.draft && !SHOW_DRAFTS) return null; const slug = config.slug; + const children = config.children + ?.map((child) => toNavItem(child, locale, t)) + .filter((item): item is NavItem => item !== null); return { id: config.id, label: navLabel(t, config.labelKey), to: slug ? docsPathForSlug(slug, locale) : undefined, - children: config.children?.map((child) => toNavItem(child, locale, t)), + draft: config.draft || undefined, + children, }; } @@ -913,8 +922,10 @@ export function getDocsNavSections( return NAV_SECTION_CONFIG.map((section) => ({ id: section.id, title: navLabel(t, section.titleKey), - items: section.items.map((item) => toNavItem(item, locale, t)), - })); + items: section.items + .map((item) => toNavItem(item, locale, t)) + .filter((item): item is NavItem => item !== null), + })).filter((section) => section.items.length > 0); } // Flat list for prev/next navigation and current-item lookups. Nested diff --git a/packages/docs/app/routes/docs.$locale.$slug.tsx b/packages/docs/app/routes/docs.$locale.$slug.tsx index aa98352e18..64b80318ac 100644 --- a/packages/docs/app/routes/docs.$locale.$slug.tsx +++ b/packages/docs/app/routes/docs.$locale.$slug.tsx @@ -34,6 +34,21 @@ const SLUG_REDIRECTS: Record = { "migration-workbench": "code-agents-ui", }; +function DraftBanner() { + return ( +
+ Draft — This page is a work in progress. Content may be + incomplete or subject to change before publication. +
+ ); +} + function requireLocale(value: unknown): DocsLocale { if (isDocsLocale(value)) return value; throw new Response("Not Found", { status: 404 }); @@ -61,6 +76,9 @@ export async function loader({ params, request, url }: LoaderFunctionArgs) { if (!doc) { throw new Response("Not Found", { status: 404 }); } + if (doc.draft && import.meta.env.VITE_SHOW_DRAFTS !== "true") { + throw new Response("Not Found", { status: 404 }); + } return doc; } @@ -106,6 +124,7 @@ export default function LocalizedDocPage() { toc={toc} markdownUrl={docsMarkdownPathForDoc(doc.slug, locale) ?? undefined} > + {doc.draft && } ); diff --git a/packages/docs/app/routes/docs.$slug.tsx b/packages/docs/app/routes/docs.$slug.tsx index 45a59079ae..e2015bf089 100644 --- a/packages/docs/app/routes/docs.$slug.tsx +++ b/packages/docs/app/routes/docs.$slug.tsx @@ -29,6 +29,21 @@ const SLUG_REDIRECTS: Record = { "migration-workbench": "code-agents-ui", }; +function DraftBanner() { + return ( +
+ Draft — This page is a work in progress. Content may be + incomplete or subject to change before publication. +
+ ); +} + export async function loader({ params }: LoaderFunctionArgs) { const slug = params.slug!; if (isDocsLocale(slug)) { @@ -43,6 +58,9 @@ export async function loader({ params }: LoaderFunctionArgs) { if (!doc) { throw new Response("Not Found", { status: 404 }); } + if (doc.draft && import.meta.env.VITE_SHOW_DRAFTS !== "true") { + throw new Response("Not Found", { status: 404 }); + } return doc; } @@ -84,6 +102,7 @@ export default function DocPage() { docsMarkdownPathForDoc(doc.slug, DEFAULT_DOCS_LOCALE) ?? undefined } > + {doc.draft && } );