From 0c530765d5a89fce1f578e497b64b93a23da187d Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Mon, 2 Feb 2026 15:54:24 -0600 Subject: [PATCH] feat(site): remove style from urls (#378) --- site/CLAUDE.md | 53 ++- site/astro.config.mjs | 3 +- site/src/components/docs/DocsLink.astro | 9 +- site/src/components/docs/DocsNavigation.astro | 18 +- site/src/components/docs/DocsSidebar.astro | 20 +- site/src/components/docs/PreferenceSync.tsx | 28 +- .../src/components/docs/PreferenceUpdater.tsx | 28 +- site/src/components/docs/Selectors.tsx | 41 +- site/src/components/docs/SidebarItem.astro | 16 +- site/src/components/docs/StyleCase.astro | 55 +-- site/src/components/docs/StyleInit.astro | 63 +++ site/src/layouts/Docs.astro | 19 +- .../{style/[style] => }/[...slug].astro | 49 +-- .../docs/framework/[framework]/index.astro | 1 - .../[framework]/style/[style]/index.astro | 30 -- .../framework/[framework]/style/index.astro | 26 -- site/src/pages/docs/framework/index.astro | 1 - site/src/pages/docs/index.astro | 1 - site/src/stores/preferences.ts | 3 +- site/src/types/docs.ts | 2 - .../utils/docs/__tests__/preferences.test.ts | 178 ++++---- site/src/utils/docs/__tests__/routing.test.ts | 399 ++---------------- site/src/utils/docs/__tests__/sidebar.test.ts | 133 ++---- site/src/utils/docs/preferences.ts | 78 ++-- site/src/utils/docs/routing.ts | 264 ++---------- site/src/utils/docs/sidebar.ts | 127 ++---- 26 files changed, 504 insertions(+), 1141 deletions(-) create mode 100644 site/src/components/docs/StyleInit.astro rename site/src/pages/docs/framework/[framework]/{style/[style] => }/[...slug].astro (76%) delete mode 100644 site/src/pages/docs/framework/[framework]/style/[style]/index.astro delete mode 100644 site/src/pages/docs/framework/[framework]/style/index.astro diff --git a/site/CLAUDE.md b/site/CLAUDE.md index f5e1306b..61ff329d 100644 --- a/site/CLAUDE.md +++ b/site/CLAUDE.md @@ -145,13 +145,15 @@ Documentation is generated for **multiple framework and style combinations** fro **URL pattern:** ``` -/docs/framework/{framework}/style/{style}/{...slug}/ +/docs/framework/{framework}/{...slug}/ ``` +**Style handling:** Style is a **client-side preference** stored in localStorage per-framework (`vjs_docs_style_html`, `vjs_docs_style_react`). The `StyleInit.astro` component reads localStorage before paint and sets `html[data-style]`. CSS rules control content visibility via `[data-for-style]` attributes on `` wrapped content. + **Example:** - `src/content/docs/how-to/installation.mdx` generates: - - `/docs/framework/html/style/css/how-to/installation/` - - `/docs/framework/react/style/css/how-to/installation/` + - `/docs/framework/html/how-to/installation/` + - `/docs/framework/react/how-to/installation/` ### Content Restriction Mechanisms @@ -171,21 +173,25 @@ Use `` or `` components to show framework/style-specif **2. In sidebar config (`src/docs.config.ts`):** +Restrict entire guides to specific frameworks: + ```ts const sidebar: Sidebar = [ { sidebarLabel: 'Getting started', contents: [ - { slug: 'how-to/installation' }, // Available to all + { slug: 'how-to/installation' }, // Available to all frameworks { slug: 'how-to/react-hooks', - frameworks: ['react'] // Only for React + frameworks: ['react'] // Only visible when viewing React docs }, ], }, ]; ``` +**Note:** Style restrictions on sidebar items are no longer supported. All docs are visible to all styles; use `` within docs to show style-specific content. + ### Sidebar Configuration **Structure** (`src/docs.config.ts`): @@ -195,7 +201,6 @@ const sidebar: Sidebar = [ - `slug`: Path relative to `src/content/docs/` (without `.mdx`) - `sidebarLabel` (optional): Override display name - `frameworks` (optional): Restrict to specific frameworks - - `styles` (optional): Restrict to specific styles - `devOnly` (optional): Show only in development mode **Example:** @@ -216,11 +221,10 @@ export const sidebar: Sidebar = [ ### Key Utility Functions (`src/utils/docs/`) **`sidebar.ts`** — Sidebar filtering and navigation: -- `filterSidebar()`: Filter sidebar by framework/style, remove empty sections -- `findFirstGuide()`: Get first available guide for framework/style combo +- `filterSidebar()`: Filter sidebar by framework, remove empty sections +- `findFirstGuide()`: Get first available guide for framework - `findGuideBySlug()`: Search sidebar recursively for a guide - `getAdjacentGuides()`: Get prev/next guides for navigation -- `getValidStylesForGuide()`: Determine valid styles for a guide - `getSectionsForGuide()`: Get breadcrumb trail to a guide **`routing.ts`** — URL building and redirect logic: @@ -234,19 +238,19 @@ export const sidebar: Sidebar = [ **Nested index pages** handle redirects at each level: ``` -/docs/ → redirect to first guide -/docs/framework/ → redirect to first guide -/docs/framework/{framework}/ → redirect to first guide -/docs/framework/{framework}/style/ → redirect to first guide -/docs/framework/{framework}/style/{style}/ → redirect to first guide -/docs/framework/{framework}/style/{style}/{...slug} → render guide +/docs/ → redirect to first guide +/docs/framework/ → redirect to first guide +/docs/framework/{framework}/ → redirect to first guide +/docs/framework/{framework}/{slug} → render guide ``` Each index page uses `resolveIndexRedirect()` to determine where to redirect based on: -1. URL params (framework, style) -2. User preferences (from localStorage via Nanostores) +1. URL params (framework) +2. User preferences (framework from cookies) 3. Defaults (when invalid or missing) +**Style is not part of the URL.** Style preference is stored per-framework in localStorage and applied client-side via CSS. + ## Content Collections Defined in `src/content.config.ts` using Astro's Content Collections API. @@ -453,18 +457,13 @@ Sidebar filtering is recursive because sections can contain guides or nested sec ```ts export function filterSidebar( - sidebar: Sidebar, framework: SupportedFramework, - style: AnySupportedStyle, + sidebarToFilter?: Sidebar, ): Sidebar { - return sidebar - .map((section) => ({ - ...section, - contents: section.contents.filter((item) => - isItemVisible(item, framework, style) - ), - })) - .filter((section) => section.contents.length > 0); + const root = sidebarToFilter ?? sidebar; + return root + .map((item) => filterItem(item, framework)) + .filter(isNotFalsy); } ``` diff --git a/site/astro.config.mjs b/site/astro.config.mjs index f23a9d2e..a5919487 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -74,9 +74,10 @@ export default defineConfig({ vite: { plugins: [tailwindcss()], optimizeDeps: { - exclude: ['@vjs/react'], + exclude: ['@videojs/react-preview'], }, resolve: { + dedupe: ['react', 'react-dom'], alias: { '@': new URL('./src', import.meta.url).pathname, }, diff --git a/site/src/components/docs/DocsLink.astro b/site/src/components/docs/DocsLink.astro index e915fd5a..d5b9cec7 100644 --- a/site/src/components/docs/DocsLink.astro +++ b/site/src/components/docs/DocsLink.astro @@ -1,7 +1,6 @@ --- - import type { HTMLAttributes } from 'astro/types'; -import { isValidFramework, isValidStyleForFramework } from '@/types/docs'; +import { isValidFramework } from '@/types/docs'; import { resolveDocsLinkUrl } from '@/utils/docs/routing'; import A from '../typography/A.astro'; @@ -10,18 +9,14 @@ interface Props extends Omit, 'href'> { } const { slug } = Astro.props; -const { framework: paramFramework, style: paramStyle } = Astro.params; +const { framework: paramFramework } = Astro.params; if (!paramFramework || !isValidFramework(paramFramework)) { throw new Error(`Invalid or missing framework param "${paramFramework ?? 'undefined'}".`); } -if (!paramStyle || !isValidStyleForFramework(paramFramework, paramStyle)) { - throw new Error(`Invalid style param "${paramStyle}" for framework "${paramFramework}".`); -} const { url: href } = resolveDocsLinkUrl({ targetSlug: slug, contextFramework: paramFramework, - contextStyle: paramStyle, }); --- diff --git a/site/src/components/docs/DocsNavigation.astro b/site/src/components/docs/DocsNavigation.astro index 0e976946..d1b6f236 100644 --- a/site/src/components/docs/DocsNavigation.astro +++ b/site/src/components/docs/DocsNavigation.astro @@ -1,20 +1,18 @@ --- - import { ChevronDown } from 'lucide-react'; -import type { Guide, SupportedFramework, SupportedStyle } from '@/types/docs'; +import type { Guide, SupportedFramework } from '@/types/docs'; -type Props = { +type Props = { prev: Guide | null; next: Guide | null; - framework: F; - style: SupportedStyle; + framework: SupportedFramework; docTitles: Map; }; -const { prev, next, framework, style, docTitles } = Astro.props; +const { prev, next, framework, docTitles } = Astro.props; -function getGuideUrl(framework: F, style: SupportedStyle, slug: string): string { - return `/docs/framework/${framework}/style/${style}/${slug}`; +function getGuideUrl(framework: SupportedFramework, slug: string): string { + return `/docs/framework/${framework}/${slug}`; } --- @@ -22,7 +20,7 @@ function getGuideUrl(framework: F, style: Supporte { prev ? ( @@ -43,7 +41,7 @@ function getGuideUrl(framework: F, style: Supporte { next ? ( diff --git a/site/src/components/docs/DocsSidebar.astro b/site/src/components/docs/DocsSidebar.astro index ebb9161d..8ccfca42 100644 --- a/site/src/components/docs/DocsSidebar.astro +++ b/site/src/components/docs/DocsSidebar.astro @@ -1,31 +1,25 @@ --- - import clsx from 'clsx'; import { PreferenceUpdater } from '@/components/docs/PreferenceUpdater'; import SidebarItem from '@/components/docs/SidebarItem.astro'; -import type { SupportedFramework, SupportedStyle } from '@/types/docs'; +import type { SupportedFramework } from '@/types/docs'; import { filterSidebar } from '@/utils/docs/sidebar'; -type Props = { - framework: F; - style: SupportedStyle; +type Props = { + framework: SupportedFramework; docTitles: Map; class?: string; }; -const { framework, style, docTitles, class: className } = Astro.props; -const filteredSidebar = filterSidebar(framework, style); +const { framework, docTitles, class: className } = Astro.props; +const filteredSidebar = filterSidebar(framework); ---
- +
diff --git a/site/src/components/docs/PreferenceSync.tsx b/site/src/components/docs/PreferenceSync.tsx index 2c6e28a4..2bd0a62d 100644 --- a/site/src/components/docs/PreferenceSync.tsx +++ b/site/src/components/docs/PreferenceSync.tsx @@ -1,39 +1,37 @@ import { useStore } from '@nanostores/react'; import { useEffect } from 'react'; -import { currentFramework, currentStyle } from '@/stores/preferences'; -import type { SupportedFramework, SupportedStyle } from '@/types/docs'; -import { getPreferenceClient, setPreferenceClient } from '@/utils/docs/preferences'; +import { currentFramework } from '@/stores/preferences'; +import type { SupportedFramework } from '@/types/docs'; +import { getFrameworkPreferenceClient, setFrameworkPreferenceClient } from '@/utils/docs/preferences'; /** - * PreferenceSync keeps the nanostore in sync with cookies. + * PreferenceSync keeps the framework nanostore in sync with cookies. * * On mount: Reads cookies → initializes store * On store change: Writes to cookies * - * This component should be loaded with client:load in the base layout + * Style preferences are handled via localStorage by StyleInit and PreferenceUpdater. + * + * This component should be loaded with client:idle in the base layout * to ensure preferences are available immediately. */ export function PreferenceSync() { const framework = useStore(currentFramework); - const style = useStore(currentStyle); // Initialize store from cookies on mount useEffect(() => { - const prefs = getPreferenceClient(); - if (prefs.framework) { - currentFramework.set(prefs.framework); - } - if (prefs.style) { - currentStyle.set(prefs.style); + const prefs = getFrameworkPreferenceClient(); + if (prefs) { + currentFramework.set(prefs); } }, []); // Sync store changes to cookies useEffect(() => { - if (framework && style) { - setPreferenceClient(framework as SupportedFramework, style as SupportedStyle); + if (framework) { + setFrameworkPreferenceClient(framework as SupportedFramework); } - }, [framework, style]); + }, [framework]); return null; } diff --git a/site/src/components/docs/PreferenceUpdater.tsx b/site/src/components/docs/PreferenceUpdater.tsx index e1463947..736a14a2 100644 --- a/site/src/components/docs/PreferenceUpdater.tsx +++ b/site/src/components/docs/PreferenceUpdater.tsx @@ -1,26 +1,28 @@ import { useEffect } from 'react'; import { currentFramework as frameworkStore, currentStyle as styleStore } from '@/stores/preferences'; -import type { SupportedFramework, SupportedStyle } from '@/types/docs'; +import { getDefaultStyle, type SupportedFramework } from '@/types/docs'; +import { getStylePreferenceClient } from '@/utils/docs/preferences'; -interface PreferenceUpdaterProps { - currentFramework: F; - currentStyle: SupportedStyle; +interface PreferenceUpdaterProps { + currentFramework: SupportedFramework; } /** - * PreferenceUpdater component updates the preference nanostore based on URL params. + * PreferenceUpdater component updates the preference nanostore based on URL params and localStorage. * This component is loaded with client:idle directive on docs pages, making it non-blocking. - * It updates the nanostore whenever framework or style from URL changes. - * PreferenceSync handles persisting to cookies. + * It updates the framework store from URL params and style store from localStorage. + * PreferenceSync handles persisting framework to cookies. */ -export function PreferenceUpdater({ - currentFramework, - currentStyle, -}: PreferenceUpdaterProps) { +export function PreferenceUpdater({ currentFramework }: PreferenceUpdaterProps) { useEffect(() => { + // Update framework store from URL frameworkStore.set(currentFramework); - styleStore.set(currentStyle); - }, [currentFramework, currentStyle]); + + // Read style from localStorage (StyleInit guarantees a valid value exists) + // Fallback to default if React hydrates before StyleInit completes + const style = getStylePreferenceClient(currentFramework) ?? getDefaultStyle(currentFramework); + styleStore.set(style); + }, [currentFramework]); return null; } diff --git a/site/src/components/docs/Selectors.tsx b/site/src/components/docs/Selectors.tsx index 892b6008..20872e5b 100644 --- a/site/src/components/docs/Selectors.tsx +++ b/site/src/components/docs/Selectors.tsx @@ -1,23 +1,28 @@ +import { useStore } from '@nanostores/react'; import { Select } from '@/components/Select'; -import type { AnySupportedStyle, SupportedFramework, SupportedStyle } from '@/types/docs'; +import { currentStyle as styleStore } from '@/stores/preferences'; +import type { AnySupportedStyle, SupportedFramework } from '@/types/docs'; import { FRAMEWORK_STYLES, isValidFramework, isValidStyleForFramework, SUPPORTED_FRAMEWORKS } from '@/types/docs'; -import { resolveFrameworkChange, resolveStyleChange } from '@/utils/docs/routing'; +import { setStylePreferenceClient, updateStyleAttribute } from '@/utils/docs/preferences'; +import { resolveFrameworkChange } from '@/utils/docs/routing'; -interface SelectorProps { - currentFramework: T; - currentStyle: SupportedStyle; +interface SelectorProps { + currentFramework: SupportedFramework; currentSlug: string; } -export function Selectors({ currentFramework, currentStyle, currentSlug }: SelectorProps) { - // TODO: use astro view transitions to preserve scroll position when switching from the same slug to the same slug +export function Selectors({ currentFramework, currentSlug }: SelectorProps) { + // Read style from nanostore (StyleInit + PreferenceUpdater guarantee a valid value) + // Guard against React hydrating before style is initialized + const currentStyle = useStore(styleStore); + if (!currentStyle) return null; + const handleFrameworkChange = (newFramework: SupportedFramework | null) => { if (newFramework === null) return; if (!isValidFramework(newFramework)) return; const { url, shouldReplace } = resolveFrameworkChange({ currentFramework, - currentStyle, currentSlug, newFramework, }); @@ -35,20 +40,12 @@ export function Selectors({ currentFramework, currentStyle, currentSlug }: Selec if (newStyle === null) return; if (!isValidStyleForFramework(currentFramework, newStyle)) return; - const { url, shouldReplace } = resolveStyleChange({ - currentFramework, - currentStyle, - currentSlug, - newStyle, - }); - - if (shouldReplace) { - // Maintaining the current slug, navigate without pushing onto the history stack - window.location.replace(url); - } else { - // Changing slug, use normal navigation - window.location.href = url; - } + // Update localStorage for this framework + setStylePreferenceClient(currentFramework, newStyle); + // Update DOM attribute + updateStyleAttribute(newStyle); + // Update nanostore for React components + styleStore.set(newStyle); }; const availableStyles = FRAMEWORK_STYLES[currentFramework]; diff --git a/site/src/components/docs/SidebarItem.astro b/site/src/components/docs/SidebarItem.astro index 778203f2..10a1a16e 100644 --- a/site/src/components/docs/SidebarItem.astro +++ b/site/src/components/docs/SidebarItem.astro @@ -1,19 +1,17 @@ --- - import GithubSlugger from 'github-slugger'; import { ChevronDown } from 'lucide-react'; -import type { Guide, Section, SupportedFramework, SupportedStyle } from '@/types/docs'; +import type { Guide, Section, SupportedFramework } from '@/types/docs'; import { isSection } from '@/types/docs'; -type Props = { +type Props = { item: Guide | Section; - framework: F; - style: SupportedStyle; + framework: SupportedFramework; docTitles: Map; depth?: number; }; -const { item, framework, style, docTitles, depth = 0 } = Astro.props; +const { item, framework, docTitles, depth = 0 } = Astro.props; const currentPath = Astro.url.pathname; const slugger = new GithubSlugger(); @@ -22,7 +20,7 @@ function containsActivePath(item: Guide | Section): boolean { if (isSection(item)) { return item.contents.some((child) => containsActivePath(child)); } else { - const itemPath = `/docs/framework/${framework}/style/${style}/${item.slug}`; + const itemPath = `/docs/framework/${framework}/${item.slug}`; return itemPath === currentPath; } } @@ -52,7 +50,7 @@ const isActive = containsActivePath(item);
{item.contents.map((contentItem) => ( - + ))}
@@ -67,7 +65,7 @@ const isActive = containsActivePath(item); isActive ? 'text-dark-100 dark:text-light-100 border-current ' : 'border-light-40 dark:border-dark-40', ]} style={`padding-left: calc(var(--spacing) * ${depth * 4})`} - href={`/docs/framework/${framework}/style/${style}/${item.slug}`} + href={`/docs/framework/${framework}/${item.slug}`} > {item.devOnly ? '[Dev only] ' : ''} {item.sidebarLabel || docTitles.get(item.slug) || item.slug} diff --git a/site/src/components/docs/StyleCase.astro b/site/src/components/docs/StyleCase.astro index 1dfbfc81..9f7e788f 100644 --- a/site/src/components/docs/StyleCase.astro +++ b/site/src/components/docs/StyleCase.astro @@ -1,37 +1,38 @@ --- - -import type { AnySupportedStyle } from '@/types/docs'; +/** + * Conditionally shows content based on the user's selected style preference. + * + * When `styles` is provided: Content is wrapped in a div with `data-for-style` + * attribute. CSS rules in StyleInit.astro control visibility based on `html[data-style]`. + * + * When `styles` is omitted: Content is rendered directly without any wrapper, + * making it visible regardless of style preference. + * + * @example + * + * CSS-specific content + * + * + * Shared content + * + * + * Universal content + */ +import type { AnySupportedStyle } from "@/types/docs"; interface Props { - styles?: AnySupportedStyle[]; + styles?: AnySupportedStyle[]; } const { styles } = Astro.props; -const { style } = Astro.params; - -// Only render if current style matches, or if no styles specified (all styles) -const shouldRender = !styles || styles.includes(style as AnySupportedStyle); --- { - /* - What I WANT to do is - ``` - {shouldRender && } - ``` - - But I'm running into some crazy problems with hydration. - Putting client:load in one of these conditionals causes Astro to just give up hydrating the whole app for some reason. - TODO: fix this - - So, while I debug those... let's do this - */ + !styles ? ( + + ) : ( +
+ +
+ ) } - diff --git a/site/src/components/docs/StyleInit.astro b/site/src/components/docs/StyleInit.astro new file mode 100644 index 00000000..dbb2d25a --- /dev/null +++ b/site/src/components/docs/StyleInit.astro @@ -0,0 +1,63 @@ +--- +import { FRAMEWORK_STYLES, getDefaultStyle, SUPPORTED_FRAMEWORKS } from '@/types/docs'; + +const STYLE_KEY_PREFIX = 'vjs_docs_style_'; + +// Derive values from the single source of truth (FRAMEWORK_STYLES) +// Pass as plain objects since define:vars serializes to JSON +const frameworkStylesConfig = Object.fromEntries(SUPPORTED_FRAMEWORKS.map((fw) => [fw, [...FRAMEWORK_STYLES[fw]]])); +const defaultStyleConfig = getDefaultStyle(SUPPORTED_FRAMEWORKS[0]); + +// Collect all unique styles across all frameworks for CSS generation +const allStyles = [...new Set(Object.values(FRAMEWORK_STYLES).flat())]; +--- + +{/* Style visibility CSS rules - generated from FRAMEWORK_STYLES to stay in sync */} + + +{/* + Style initialization script - runs before paint to prevent FOUC. + + Supports ?style= query param for deep-linking (e.g., ?style=tailwind). + This allows external links to specify a style preference. The style is + validated against valid styles for the current framework and persisted + to localStorage. +*/} + diff --git a/site/src/layouts/Docs.astro b/site/src/layouts/Docs.astro index 93637f11..198ff7f9 100644 --- a/site/src/layouts/Docs.astro +++ b/site/src/layouts/Docs.astro @@ -4,22 +4,22 @@ import { type CollectionEntry, getCollection } from 'astro:content'; import DocsSidebar from '@/components/docs/DocsSidebar.astro'; import DocsSidebarRestoration from '@/components/docs/DocsSidebarRestoration.astro'; import { Selectors } from '@/components/docs/Selectors'; +import StyleInit from '@/components/docs/StyleInit.astro'; import FilmGrain from '@/components/FilmGrain'; import Footer from '@/components/Footer.astro'; import FooterEasterEgg from '@/components/FooterEasterEgg.astro'; import NavBar from '@/components/NavBar/NavBar.astro'; -import type { SupportedFramework, SupportedStyle } from '@/types/docs'; +import type { SupportedFramework } from '@/types/docs'; import { getDocTitle } from '@/utils/docs/title'; import Base from './Base.astro'; -type Props = { +type Props = { doc: CollectionEntry<'docs'>; - framework: F; - style: SupportedStyle; + framework: SupportedFramework; slug: string; }; -const { doc, framework, style, slug } = Astro.props; +const { doc, framework, slug } = Astro.props; // Fetch all docs to get their framework-specific titles const allDocs = await getCollection('docs'); @@ -29,6 +29,9 @@ const docsSidebarId = 'docs-sidebar'; --- + + + @@ -38,7 +41,7 @@ const docsSidebarId = 'docs-sidebar'; style="--lg-grid-cols: calc(var(--spacing) * 75) 1fr;grid-template-rows:auto minmax(0,1fr);" > - +
diff --git a/site/src/pages/docs/framework/[framework]/style/[style]/[...slug].astro b/site/src/pages/docs/framework/[framework]/[...slug].astro similarity index 76% rename from site/src/pages/docs/framework/[framework]/style/[style]/[...slug].astro rename to site/src/pages/docs/framework/[framework]/[...slug].astro index 270e1457..9b5af518 100644 --- a/site/src/pages/docs/framework/[framework]/style/[style]/[...slug].astro +++ b/site/src/pages/docs/framework/[framework]/[...slug].astro @@ -1,5 +1,4 @@ --- - import type { CollectionEntry } from 'astro:content'; import { getCollection, render } from 'astro:content'; import CopyMarkdownButton from '@/components/CopyMarkdownButton'; @@ -11,8 +10,8 @@ import JsonLd from '@/components/JsonLd.astro'; import defaultMarkdownComponents from '@/components/typography/defaultMarkdownComponents'; import H3 from '@/components/typography/H3.astro'; import DocsLayout from '@/layouts/Docs.astro'; -import type { SupportedFramework, SupportedStyle } from '@/types/docs'; -import { ALL_FRAMEWORK_STYLE_COMBINATIONS } from '@/types/docs'; +import type { SupportedFramework } from '@/types/docs'; +import { SUPPORTED_FRAMEWORKS } from '@/types/docs'; import { filterSidebar, getAdjacentGuides, getAllGuideSlugs, getSectionsForGuide } from '@/utils/docs/sidebar'; import { getDocTitle } from '@/utils/docs/title'; import { createTechArticleSchema } from '@/utils/jsonLd/schemas'; @@ -21,29 +20,28 @@ export async function getStaticPaths() { const docsCollection = await getCollection('docs'); /** - * Build a map of allowed slugs for each framework/style combination. + * Build a map of allowed slugs for each framework. * Only docs that appear in the filtered sidebar will be allowed to generate pages. */ - type AllowedSlugsMap = Map>; + type AllowedSlugsMap = Map>; const allowedSlugsMap: AllowedSlugsMap = new Map(); - for (const { framework, style, key } of ALL_FRAMEWORK_STYLE_COMBINATIONS) { - const sidebarForFrameworkAndStyle = filterSidebar(framework, style); - const slugsForFrameworkAndStyle = getAllGuideSlugs(sidebarForFrameworkAndStyle); - allowedSlugsMap.set(key, new Set(slugsForFrameworkAndStyle)); + for (const framework of SUPPORTED_FRAMEWORKS) { + const sidebarForFramework = filterSidebar(framework); + const slugsForFramework = getAllGuideSlugs(sidebarForFramework); + allowedSlugsMap.set(framework, new Set(slugsForFramework)); } - // Generate a path for each doc that's visible in at least one framework/style combination - const staticPaths = ALL_FRAMEWORK_STYLE_COMBINATIONS.flatMap(({ framework, style, key }) => { - const allowedSlugs = allowedSlugsMap.get(key)!; + // Generate a path for each doc that's visible for each framework + const staticPaths = SUPPORTED_FRAMEWORKS.flatMap((framework) => { + const allowedSlugs = allowedSlugsMap.get(framework)!; - // Filter docs to only those visible in this combination's sidebar + // Filter docs to only those visible in this framework's sidebar return docsCollection .filter((doc) => allowedSlugs.has(doc.id)) .map((doc) => ({ params: { framework, - style, slug: doc.id, }, props: { doc }, @@ -53,13 +51,12 @@ export async function getStaticPaths() { return staticPaths; } -type Props = { +type Props = { doc: CollectionEntry<'docs'>; - framework: F; - style: SupportedStyle; + framework: SupportedFramework; }; -const { framework, style, slug } = Astro.params; +const { framework, slug } = Astro.params; const { doc } = Astro.props; const { Content, headings, remarkPluginFrontmatter } = await render(doc); @@ -68,20 +65,19 @@ const conditionalHeadings = remarkPluginFrontmatter?.conditionalHeadings; const filteredHeadings = conditionalHeadings ? conditionalHeadings.filter((h: any) => { const matchesFramework = !h.frameworks || h.frameworks.includes(framework); - const matchesStyle = !h.styles || h.styles.includes(style); - return matchesFramework && matchesStyle; + return matchesFramework; }) : headings; // Get adjacent guides for navigation -const { prev, next } = getAdjacentGuides(slug, framework, style); +const { prev, next } = getAdjacentGuides(slug, framework); // Fetch all docs to get their framework-specific titles const allDocs = await getCollection('docs'); const docTitles = new Map(allDocs.map((d) => [d.id, getDocTitle(d, framework)])); // Get the section hierarchy for this guide -const sections = getSectionsForGuide(slug, filterSidebar(framework, style)); +const sections = getSectionsForGuide(slug, filterSidebar(framework)); // Build JSON-LD schema for TechArticle const pageUrl = new URL(Astro.url.pathname, Astro.site).toString(); @@ -95,7 +91,7 @@ const jsonLdSchema = createTechArticleSchema({ }); --- - +
- +
- +
diff --git a/site/src/pages/docs/framework/[framework]/index.astro b/site/src/pages/docs/framework/[framework]/index.astro index e7cfdd89..dd2de1ca 100644 --- a/site/src/pages/docs/framework/[framework]/index.astro +++ b/site/src/pages/docs/framework/[framework]/index.astro @@ -1,5 +1,4 @@ --- - export const prerender = false; // since we're reading cookies import { isValidFramework } from '@/types/docs'; diff --git a/site/src/pages/docs/framework/[framework]/style/[style]/index.astro b/site/src/pages/docs/framework/[framework]/style/[style]/index.astro deleted file mode 100644 index fbed4e8a..00000000 --- a/site/src/pages/docs/framework/[framework]/style/[style]/index.astro +++ /dev/null @@ -1,30 +0,0 @@ ---- - -export const prerender = false; // since we're reading cookies - -import { isValidFramework, isValidStyleForFramework } from '@/types/docs'; -import { getPreferencesServer } from '@/utils/docs/preferences'; -import { resolveIndexRedirect } from '@/utils/docs/routing'; - -const { framework, style } = Astro.params; - -if (!isValidFramework(framework)) { - return new Response('Not Found', { status: 404 }); -} - -if (!style) { - return Astro.redirect(`/docs/framework/${framework}`, 307); -} - -if (!isValidStyleForFramework(framework, style)) { - return new Response('Not Found', { status: 404 }); -} - -const preferences = getPreferencesServer(Astro.cookies); -const { url } = resolveIndexRedirect({ - preferences, - params: { framework, style }, -}); - -return Astro.redirect(url, 307); ---- diff --git a/site/src/pages/docs/framework/[framework]/style/index.astro b/site/src/pages/docs/framework/[framework]/style/index.astro deleted file mode 100644 index e7cfdd89..00000000 --- a/site/src/pages/docs/framework/[framework]/style/index.astro +++ /dev/null @@ -1,26 +0,0 @@ ---- - -export const prerender = false; // since we're reading cookies - -import { isValidFramework } from '@/types/docs'; -import { getPreferencesServer } from '@/utils/docs/preferences'; -import { resolveIndexRedirect } from '@/utils/docs/routing'; - -const { framework } = Astro.params; - -if (!framework) { - return Astro.redirect('/docs', 307); -} - -if (!isValidFramework(framework)) { - return new Response('Not Found', { status: 404 }); -} - -const preferences = getPreferencesServer(Astro.cookies); -const { url } = resolveIndexRedirect({ - preferences, - params: { framework }, -}); - -return Astro.redirect(url, 307); ---- diff --git a/site/src/pages/docs/framework/index.astro b/site/src/pages/docs/framework/index.astro index 4d3e8ffc..3f9736db 100644 --- a/site/src/pages/docs/framework/index.astro +++ b/site/src/pages/docs/framework/index.astro @@ -1,5 +1,4 @@ --- - export const prerender = false; // since we're reading cookies import { getPreferencesServer } from '@/utils/docs/preferences'; diff --git a/site/src/pages/docs/index.astro b/site/src/pages/docs/index.astro index 4d3e8ffc..3f9736db 100644 --- a/site/src/pages/docs/index.astro +++ b/site/src/pages/docs/index.astro @@ -1,5 +1,4 @@ --- - export const prerender = false; // since we're reading cookies import { getPreferencesServer } from '@/utils/docs/preferences'; diff --git a/site/src/stores/preferences.ts b/site/src/stores/preferences.ts index 7b02ac56..95c5e300 100644 --- a/site/src/stores/preferences.ts +++ b/site/src/stores/preferences.ts @@ -3,7 +3,8 @@ import type { AnySupportedStyle, SupportedFramework } from '@/types/docs'; /** * Nanostore atoms for current framework and style preferences. - * These are the runtime cache of cookie values, kept in sync by PreferenceSync. + * Framework preference is kept in sync with cookies by PreferenceSync. + * Style preference is read from localStorage and synced to DOM data-style. * All React components should read from these stores for reactive updates. */ export const currentFramework = atom(null); diff --git a/site/src/types/docs.ts b/site/src/types/docs.ts index 1fdf27ae..3707f6df 100644 --- a/site/src/types/docs.ts +++ b/site/src/types/docs.ts @@ -40,14 +40,12 @@ export interface Guide { slug: string; sidebarLabel?: string; // defaults to guide title frameworks?: SupportedFramework[]; - styles?: AnySupportedStyle[]; devOnly?: boolean; // only visible in development mode } export interface Section { sidebarLabel: string; frameworks?: SupportedFramework[]; - styles?: AnySupportedStyle[]; devOnly?: boolean; // only visible in development mode contents: Array; } diff --git a/site/src/utils/docs/__tests__/preferences.test.ts b/site/src/utils/docs/__tests__/preferences.test.ts index f240b095..7b50b104 100644 --- a/site/src/utils/docs/__tests__/preferences.test.ts +++ b/site/src/utils/docs/__tests__/preferences.test.ts @@ -1,16 +1,20 @@ import type { AstroCookies } from 'astro'; import { describe, expect, it, vi } from 'vitest'; -import { ALL_FRAMEWORK_STYLE_COMBINATIONS } from '@/types/docs'; -import { FRAMEWORK_COOKIE, getPreferencesServer, STYLE_COOKIE, setPreferenceClient } from '../preferences'; +import { SUPPORTED_FRAMEWORKS } from '@/types/docs'; +import { + FRAMEWORK_COOKIE, + getPreferencesServer, + STYLE_STORAGE_KEY_PREFIX, + setFrameworkPreferenceClient, + setStylePreferenceClient, +} from '../preferences'; describe('preferences utilities', () => { // Derive test values from actual configuration to stay independent of supported languages - const firstCombo = ALL_FRAMEWORK_STYLE_COMBINATIONS[0]; - const firstFramework = firstCombo.framework; - const firstStyle = firstCombo.style; + const firstFramework = SUPPORTED_FRAMEWORKS[0]; describe('getPreferencesServer', () => { - it('should return null preferences when no cookies are set', () => { + it('should return null preference when no cookies are set', () => { const mockCookies = { has: vi.fn().mockReturnValue(false), get: vi.fn(), @@ -18,10 +22,10 @@ describe('preferences utilities', () => { const result = getPreferencesServer(mockCookies); - expect(result).toEqual({ framework: null, style: null }); + expect(result).toEqual({ framework: null }); }); - it('should return framework preference when only framework cookie is set', () => { + it('should return framework preference when cookie is set', () => { const mockCookies = { has: vi.fn((name: string) => name === FRAMEWORK_COOKIE), get: vi.fn((name: string) => { @@ -34,26 +38,7 @@ describe('preferences utilities', () => { const result = getPreferencesServer(mockCookies); - expect(result).toEqual({ framework: firstFramework, style: null }); - }); - - it('should return both preferences when both cookies are set with valid values', () => { - const mockCookies = { - has: vi.fn().mockReturnValue(true), - get: vi.fn((name: string) => { - if (name === FRAMEWORK_COOKIE) { - return { value: firstFramework }; - } - if (name === STYLE_COOKIE) { - return { value: firstStyle }; - } - return null; - }), - } as unknown as AstroCookies; - - const result = getPreferencesServer(mockCookies); - - expect(result).toEqual({ framework: firstFramework, style: firstStyle }); + expect(result).toEqual({ framework: firstFramework }); }); it('should ignore invalid framework cookie', () => { @@ -63,79 +48,36 @@ describe('preferences utilities', () => { if (name === FRAMEWORK_COOKIE) { return { value: 'invalid-framework' }; } - if (name === STYLE_COOKIE) { - return { value: firstStyle }; - } return null; }), } as unknown as AstroCookies; const result = getPreferencesServer(mockCookies); - expect(result).toEqual({ framework: null, style: null }); + expect(result).toEqual({ framework: null }); }); - it('should ignore style cookie when framework is null', () => { - const mockCookies = { - has: vi.fn((name: string) => name === STYLE_COOKIE), - get: vi.fn((name: string) => { - if (name === STYLE_COOKIE) { - return { value: firstStyle }; - } - return null; - }), - } as unknown as AstroCookies; - - const result = getPreferencesServer(mockCookies); - - expect(result).toEqual({ framework: null, style: null }); - }); - - it('should ignore style cookie when it is invalid for the framework', () => { - const mockCookies = { - has: vi.fn().mockReturnValue(true), - get: vi.fn((name: string) => { - if (name === FRAMEWORK_COOKIE) { - return { value: firstFramework }; - } - if (name === STYLE_COOKIE) { - return { value: 'invalid-style' }; // Not valid for any framework - } - return null; - }), - } as unknown as AstroCookies; - - const result = getPreferencesServer(mockCookies); - - expect(result).toEqual({ framework: firstFramework, style: null }); - }); - - it('should accept valid framework/style combinations', () => { - const testCases = ALL_FRAMEWORK_STYLE_COMBINATIONS; - - for (const { framework, style } of testCases) { + it('should accept valid framework values', () => { + for (const framework of SUPPORTED_FRAMEWORKS) { const mockCookies = { has: vi.fn().mockReturnValue(true), get: vi.fn((name: string) => { if (name === FRAMEWORK_COOKIE) { return { value: framework }; } - if (name === STYLE_COOKIE) { - return { value: style }; - } return null; }), } as unknown as AstroCookies; const result = getPreferencesServer(mockCookies); - expect(result).toEqual({ framework, style }); + expect(result).toEqual({ framework }); } }); }); - describe('setPreferenceClient', () => { - it('should set both framework and style cookies', () => { + describe('setFrameworkPreferenceClient', () => { + it('should set framework cookie', () => { // Mock document.cookie const cookies: string[] = []; Object.defineProperty(document, 'cookie', { @@ -146,34 +88,24 @@ describe('preferences utilities', () => { configurable: true, }); - setPreferenceClient(firstFramework, firstStyle); + setFrameworkPreferenceClient(firstFramework); - expect(cookies).toHaveLength(2); + expect(cookies).toHaveLength(1); expect(cookies[0]).toContain(`vjs_docs_framework=${firstFramework}`); expect(cookies[0]).toContain('max-age=31536000'); expect(cookies[0]).toContain('path=/'); expect(cookies[0]).toContain('samesite=lax'); - expect(cookies[1]).toContain(`vjs_docs_style=${firstStyle}`); }); it('should throw error for invalid framework', () => { expect(() => { // @ts-expect-error Testing invalid input - setPreferenceClient('invalid-framework', 'css'); + setFrameworkPreferenceClient('invalid-framework'); }).toThrow('Invalid framework: invalid-framework'); }); - it('should throw error for invalid style for framework', () => { - expect(() => { - // @ts-expect-error Testing invalid input - setPreferenceClient(firstFramework, 'invalid-style'); - }).toThrow(`Invalid style "invalid-style" for framework "${firstFramework}"`); - }); - - it('should accept all valid framework/style combinations', () => { - const testCases = ALL_FRAMEWORK_STYLE_COMBINATIONS; - - for (const { framework, style } of testCases) { + it('should accept all valid frameworks', () => { + for (const framework of SUPPORTED_FRAMEWORKS) { // Mock document.cookie const cookies: string[] = []; Object.defineProperty(document, 'cookie', { @@ -185,7 +117,7 @@ describe('preferences utilities', () => { }); expect(() => { - setPreferenceClient(framework, style); + setFrameworkPreferenceClient(framework); }).not.toThrow(); } }); @@ -196,10 +128,68 @@ describe('preferences utilities', () => { globalThis.document = undefined; expect(() => { - setPreferenceClient(firstFramework, firstStyle); + setFrameworkPreferenceClient(firstFramework); }).not.toThrow(); globalThis.document = originalDocument; }); }); + + describe('setStylePreferenceClient', () => { + it('should set style in localStorage', () => { + const mockStorage: Record = {}; + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: (key: string) => mockStorage[key] ?? null, + setItem: (key: string, value: string) => { + mockStorage[key] = value; + }, + }, + configurable: true, + }); + + setStylePreferenceClient(firstFramework, 'css'); + + const expectedKey = STYLE_STORAGE_KEY_PREFIX + firstFramework; + expect(mockStorage[expectedKey]).toBe('css'); + }); + + it('should throw error for invalid style', () => { + const mockStorage: Record = {}; + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: (key: string) => mockStorage[key] ?? null, + setItem: (key: string, value: string) => { + mockStorage[key] = value; + }, + }, + configurable: true, + }); + + expect(() => { + // @ts-expect-error Testing invalid input + setStylePreferenceClient(firstFramework, 'invalid-style'); + }).toThrow(`Invalid style "invalid-style" for framework "${firstFramework}"`); + }); + + it('should do nothing when localStorage is undefined (SSR)', () => { + // Use Object.defineProperty to make localStorage temporarily undefined + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + + Object.defineProperty(globalThis, 'localStorage', { + value: undefined, + configurable: true, + writable: true, + }); + + expect(() => { + setStylePreferenceClient(firstFramework, 'css'); + }).not.toThrow(); + + // Restore original descriptor + if (descriptor) { + Object.defineProperty(globalThis, 'localStorage', descriptor); + } + }); + }); }); diff --git a/site/src/utils/docs/__tests__/routing.test.ts b/site/src/utils/docs/__tests__/routing.test.ts index 485f56a3..bf58d7d2 100644 --- a/site/src/utils/docs/__tests__/routing.test.ts +++ b/site/src/utils/docs/__tests__/routing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { Guide, Sidebar } from '../../../types/docs'; -import { resolveDocsLinkUrl, resolveFrameworkChange, resolveIndexRedirect, resolveStyleChange } from '../routing'; +import { resolveDocsLinkUrl, resolveFrameworkChange, resolveIndexRedirect } from '../routing'; // Mock the validation functions from @/types/docs to use mock framework/style configuration // Note: This mock is hoisted, so we define MOCK_FRAMEWORK_STYLES inside the factory @@ -15,7 +15,6 @@ vi.mock('@/types/docs', async () => { } as const; type MockFramework = keyof typeof MOCK_FRAMEWORK_STYLES; - type MockStyle = (typeof MOCK_FRAMEWORK_STYLES)[MockFramework][number]; return { ...actual, @@ -30,17 +29,6 @@ vi.mock('@/types/docs', async () => { if (!value) return false; return value === 'html' || value === 'react'; }, - - // Mock isValidStyleForFramework to check against mock styles - isValidStyleForFramework: (framework: MockFramework, style: string | undefined | null): style is MockStyle => { - if (!style) return false; - return MOCK_FRAMEWORK_STYLES[framework]?.includes(style as MockStyle) ?? false; - }, - - // Mock getDefaultStyle to return first style from mock config - getDefaultStyle: (framework: F): MockStyle => { - return MOCK_FRAMEWORK_STYLES[framework][0]; - }, }; }); @@ -52,7 +40,6 @@ const _MOCK_FRAMEWORK_STYLES = { } as const; type MockFramework = keyof typeof _MOCK_FRAMEWORK_STYLES; -type MockStyle = (typeof _MOCK_FRAMEWORK_STYLES)[MockFramework][number]; describe('routing utilities', () => { // Test fixtures - comprehensive mock sidebar for testing @@ -66,21 +53,9 @@ describe('routing utilities', () => { frameworks: ['react'] satisfies MockFramework[], }; - const guideTailwindOnly: Guide = { - slug: 'concepts/tailwind-only', - styles: ['tailwind'] satisfies MockStyle[], - }; - - const guideHtmlCssOnly: Guide = { - slug: 'how-to/html-css-only', + const guideHtmlOnly: Guide = { + slug: 'how-to/html-only', frameworks: ['html'] satisfies MockFramework[], - styles: ['css'] satisfies MockStyle[], - }; - - const guideReactTailwind: Guide = { - slug: 'how-to/react-tailwind', - frameworks: ['react'] satisfies MockFramework[], - styles: ['tailwind'] satisfies MockStyle[], }; const mockSidebar: Sidebar = [ @@ -90,95 +65,32 @@ describe('routing utilities', () => { }, { sidebarLabel: 'Concepts', - contents: [guideReactOnly, guideTailwindOnly, guideReactTailwind], + contents: [guideReactOnly], }, - guideHtmlCssOnly, + guideHtmlOnly, ]; describe('resolveIndexRedirect', () => { - describe('with both framework and style params', () => { - it('should use validated params for both framework and style', () => { + describe('with framework param', () => { + it('should use validated params.framework', () => { const result = resolveIndexRedirect( { - preferences: { framework: null, style: null }, - params: { framework: 'react', style: 'tailwind' }, - }, - mockSidebar - ); - - expect(result.selectedFramework).toBe('react'); - expect(result.selectedStyle).toBe('tailwind'); - expect(result.selectedSlug).toBeTruthy(); - expect(result.url).toContain('/docs/framework/react/style/tailwind/'); - expect(result.reason).toContain('params.framework and params.style'); - }); - - it('should throw error for invalid framework param', () => { - expect(() => { - resolveIndexRedirect({ - preferences: { framework: null, style: null }, - params: { framework: 'invalid', style: 'css' }, - }); - }).toThrow('Invalid framework param: invalid'); - }); - - it('should throw error for invalid style param', () => { - expect(() => { - resolveIndexRedirect( - { - preferences: { framework: null, style: null }, - params: { framework: 'html', style: 'invalid-style' }, - }, - mockSidebar - ); - }).toThrow('Invalid style param "invalid-style" for framework "html"'); - }); - }); - - describe('with only framework param', () => { - it('should use param framework and preference style when valid', () => { - const result = resolveIndexRedirect( - { - preferences: { framework: 'html', style: 'tailwind' }, + preferences: { framework: null }, params: { framework: 'react' }, }, mockSidebar ); expect(result.selectedFramework).toBe('react'); - expect(result.selectedStyle).toBe('tailwind'); // preference is valid for react - expect(result.reason).toContain('params.framework and preferences.style'); - }); - - it('should use param framework and default style when preference invalid', () => { - const result = resolveIndexRedirect( - { - preferences: { framework: 'react', style: 'invalid-style' }, - params: { framework: 'html' }, - }, - mockSidebar - ); - - expect(result.selectedFramework).toBe('html'); - expect(result.selectedStyle).toBe('css'); // default for html - expect(result.reason).toContain('default style'); - }); - - it('should use param framework and default style when no preference', () => { - const result = resolveIndexRedirect({ - preferences: { framework: null, style: null }, - params: { framework: 'html' }, - }); - - expect(result.selectedFramework).toBe('html'); - expect(result.selectedStyle).toBe('css'); // default for html - expect(result.reason).toContain('default style'); + expect(result.selectedSlug).toBeTruthy(); + expect(result.url).toContain('/docs/framework/react/'); + expect(result.reason).toContain('params.framework'); }); it('should throw error for invalid framework param', () => { expect(() => { resolveIndexRedirect({ - preferences: { framework: null, style: null }, + preferences: { framework: null }, params: { framework: 'invalid' }, }); }).toThrow('Invalid framework param: invalid'); @@ -186,61 +98,44 @@ describe('routing utilities', () => { }); describe('with no params', () => { - it('should use both preferences when valid', () => { + it('should use framework preference when valid', () => { const result = resolveIndexRedirect( { - preferences: { framework: 'react', style: 'tailwind' }, - params: {}, - }, - mockSidebar - ); - - expect(result.selectedFramework).toBe('react'); - expect(result.selectedStyle).toBe('tailwind'); - expect(result.reason).toContain('preferences.framework and preferences.style'); - }); - - it('should use preference framework and default style when style preference invalid', () => { - const result = resolveIndexRedirect( - { - preferences: { framework: 'html', style: 'invalid-style' }, + preferences: { framework: 'html' }, params: {}, }, mockSidebar ); expect(result.selectedFramework).toBe('html'); - expect(result.selectedStyle).toBe('css'); // default for html - expect(result.reason).toContain('preferences.framework and default style'); + expect(result.reason).toContain('preferences.framework'); }); - it('should use default framework and style when no preferences', () => { + it('should use default framework when no preferences', () => { const result = resolveIndexRedirect({ - preferences: { framework: null, style: null }, + preferences: { framework: null }, params: {}, }); expect(result.selectedFramework).toBe('react'); // DEFAULT_FRAMEWORK - expect(result.selectedStyle).toBe('css'); // default for react - expect(result.reason).toContain('default framework and default style'); + expect(result.reason).toContain('default framework'); }); - it('should use default framework and style when framework preference invalid', () => { + it('should use default framework when framework preference invalid', () => { const result = resolveIndexRedirect({ - preferences: { framework: 'invalid', style: 'css' }, + preferences: { framework: 'invalid' }, params: {}, }); expect(result.selectedFramework).toBe('react'); // DEFAULT_FRAMEWORK - expect(result.selectedStyle).toBe('css'); // default for react - expect(result.reason).toContain('default framework and default style'); + expect(result.reason).toContain('default framework'); }); }); describe('slug selection', () => { - it('should always select a valid slug for the framework/style combination', () => { + it('should always select a valid slug for the framework', () => { const result = resolveIndexRedirect({ - preferences: { framework: 'react', style: 'css' }, + preferences: { framework: 'react' }, params: {}, }); @@ -250,59 +145,21 @@ describe('routing utilities', () => { it('should build correct URL', () => { const result = resolveIndexRedirect({ - preferences: { framework: 'react', style: 'tailwind' }, + preferences: { framework: 'react' }, params: {}, }); - expect(result.url).toBe(`/docs/framework/react/style/tailwind/${result.selectedSlug}`); + expect(result.url).toBe(`/docs/framework/react/${result.selectedSlug}`); }); }); }); describe('resolveFrameworkChange', () => { - describe('style adjustment', () => { - it('should keep current style when valid for new framework', () => { - const result = resolveFrameworkChange( - { - currentFramework: 'html', - currentStyle: 'css', - currentSlug: 'everyone', - newFramework: 'react', - }, - mockSidebar - ); - - expect(result.selectedFramework).toBe('react'); - expect(result.selectedStyle).toBe('css'); // css is valid for both - expect(result.reason).toContain('kept style'); - }); - - it('should change style to default when current style invalid for new framework', () => { - // Since both html and react support css and tailwind in our mock, we'll test - // the logic by verifying that valid styles are kept - const result = resolveFrameworkChange( - { - currentFramework: 'react', - currentStyle: 'tailwind', - currentSlug: 'concepts/everyone', - newFramework: 'html', - }, - mockSidebar - ); - - expect(result.selectedFramework).toBe('html'); - // tailwind is valid for both frameworks, so it will be kept - expect(result.selectedStyle).toBe('tailwind'); - expect(result.reason).toContain('kept style'); - }); - }); - describe('slug retention', () => { - it('should keep slug and use replace when slug visible in new context', () => { + it('should keep slug and use replace when slug visible in new framework', () => { const result = resolveFrameworkChange( { currentFramework: 'html', - currentStyle: 'css', currentSlug: 'concepts/everyone', // visible to all newFramework: 'react', }, @@ -312,40 +169,23 @@ describe('routing utilities', () => { expect(result.selectedSlug).toBe('concepts/everyone'); expect(result.slugChanged).toBe(false); expect(result.shouldReplace).toBe(true); + expect(result.reason).toContain('kept slug'); }); - it('should change slug and not use replace when slug not visible in new context', () => { + it('should change slug and not use replace when slug not visible in new framework', () => { const result = resolveFrameworkChange( { currentFramework: 'html', - currentStyle: 'css', - currentSlug: 'how-to/html-css-only', + currentSlug: 'how-to/html-only', newFramework: 'react', }, mockSidebar ); - expect(result.selectedSlug).not.toBe('how-to/html-css-only'); + expect(result.selectedSlug).not.toBe('how-to/html-only'); expect(result.slugChanged).toBe(true); expect(result.shouldReplace).toBe(false); - }); - - it('should change slug when style adjustment makes slug invisible', () => { - const result = resolveFrameworkChange( - { - currentFramework: 'react', - currentStyle: 'tailwind', - currentSlug: 'concepts/tailwind-only', - newFramework: 'html', // html defaults to css, tailwind-only needs tailwind - }, - mockSidebar - ); - - // Since style changes to css (default) and tailwind-only requires tailwind - // we need to check if the guide becomes invisible - expect(result.selectedFramework).toBe('html'); - // The slug might change or the style might stay as tailwind if valid for html - expect(result.selectedSlug).toBeTruthy(); + expect(result.reason).toContain('changed slug'); }); }); @@ -354,7 +194,6 @@ describe('routing utilities', () => { expect(() => { resolveFrameworkChange({ currentFramework: 'react', - currentStyle: 'css', currentSlug: 'concepts/everyone', // @ts-expect-error Testing invalid input newFramework: 'invalid', @@ -368,202 +207,62 @@ describe('routing utilities', () => { const result = resolveFrameworkChange( { currentFramework: 'html', - currentStyle: 'css', currentSlug: 'concepts/everyone', newFramework: 'react', }, mockSidebar ); - expect(result.url).toBe(`/docs/framework/react/style/css/concepts/everyone`); - }); - }); - }); - - describe('resolveStyleChange', () => { - describe('slug retention', () => { - it('should keep slug and use replace when slug visible with new style', () => { - const result = resolveStyleChange( - { - currentFramework: 'react', - currentStyle: 'css', - currentSlug: 'concepts/everyone', - newStyle: 'tailwind', - }, - mockSidebar - ); - - expect(result.selectedSlug).toBe('concepts/everyone'); - expect(result.slugChanged).toBe(false); - expect(result.shouldReplace).toBe(true); - expect(result.reason).toContain('kept slug'); - }); - - it('should change slug and not use replace when slug not visible with new style', () => { - const result = resolveStyleChange( - { - currentFramework: 'react', - currentStyle: 'tailwind', - currentSlug: 'concepts/tailwind-only', - newStyle: 'css', // tailwind-only requires tailwind, not visible with css - }, - mockSidebar - ); - - expect(result.selectedSlug).not.toBe('concepts/tailwind-only'); - expect(result.slugChanged).toBe(true); - expect(result.shouldReplace).toBe(false); - expect(result.reason).toContain('changed slug'); - }); - }); - - describe('framework and style pinning', () => { - it('should keep framework and use new style', () => { - const result = resolveStyleChange( - { - currentFramework: 'react', - currentStyle: 'css', - currentSlug: 'concepts/everyone', - newStyle: 'tailwind', - }, - mockSidebar - ); - - expect(result.selectedFramework).toBe('react'); - expect(result.selectedStyle).toBe('tailwind'); - }); - }); - - describe('validation', () => { - it('should throw error for invalid style for framework', () => { - expect(() => { - resolveStyleChange( - { - currentFramework: 'html', - currentStyle: 'css', - currentSlug: 'concepts/everyone', - // @ts-expect-error Testing invalid input - newStyle: 'invalid-style', - }, - mockSidebar - ); - }).toThrow('Invalid style "invalid-style" for framework "html"'); - }); - }); - - describe('url building', () => { - it('should build correct URL', () => { - const result = resolveStyleChange( - { - currentFramework: 'react', - currentStyle: 'css', - currentSlug: 'concepts/everyone', - newStyle: 'tailwind', - }, - mockSidebar - ); - - expect(result.url).toBe('/docs/framework/react/style/tailwind/concepts/everyone'); + expect(result.url).toBe('/docs/framework/react/concepts/everyone'); }); }); }); describe('resolveDocsLinkUrl', () => { - describe('priority 1: keep both framework and style', () => { - it('should keep both when slug visible in current context', () => { + describe('priority 1: keep framework', () => { + it('should keep framework when slug visible in current context', () => { const result = resolveDocsLinkUrl( { targetSlug: 'concepts/everyone', contextFramework: 'react', - contextStyle: 'css', }, mockSidebar ); expect(result.selectedFramework).toBe('react'); - expect(result.selectedStyle).toBe('css'); expect(result.selectedSlug).toBe('concepts/everyone'); expect(result.priorityLevel).toBe(1); expect(result.reason).toContain('Priority 1'); }); - it('should use priority 1 for guide with matching restrictions', () => { + it('should use priority 1 for guide with matching framework restriction', () => { const result = resolveDocsLinkUrl( { targetSlug: 'concepts/react-only', contextFramework: 'react', - contextStyle: 'tailwind', }, mockSidebar ); expect(result.selectedFramework).toBe('react'); - expect(result.selectedStyle).toBe('tailwind'); expect(result.priorityLevel).toBe(1); }); }); - describe('priority 2: keep framework, change style', () => { - it("should keep framework and change to guide's first valid style", () => { - const result = resolveDocsLinkUrl( - { - targetSlug: 'concepts/tailwind-only', - contextFramework: 'react', - contextStyle: 'css', // tailwind-only not visible with css - }, - mockSidebar - ); - - expect(result.selectedFramework).toBe('react'); - expect(result.selectedStyle).toBe('tailwind'); // guide's first valid style for react - expect(result.selectedSlug).toBe('concepts/tailwind-only'); - expect(result.priorityLevel).toBe(2); - expect(result.reason).toContain('Priority 2'); - }); - }); - - describe('priority 3: change framework, keep style', () => { - it("should change to guide's first framework that supports current style", () => { - // concepts/react-only has frameworks:['react'] restriction - // Context: html + css -> should find react that supports css + describe('priority 2: change framework', () => { + it("should change to guide's first valid framework", () => { const result = resolveDocsLinkUrl( { targetSlug: 'concepts/react-only', contextFramework: 'html', // react-only not visible in html - contextStyle: 'css', // but css is valid for react }, mockSidebar ); - // Based on the real data, concepts/react-only has frameworks: ['react'] - // So getValidFrameworksForGuide will return ['react'] - // And since react supports css, priority 3 should apply - expect(result.selectedFramework).toBe('react'); // Should switch to react - expect(result.selectedStyle).toBe('css'); // Should keep css - expect(result.selectedSlug).toBe('concepts/react-only'); - expect(result.priorityLevel).toBe(3); - expect(result.reason).toContain('Priority 3'); - }); - }); - - describe('priority 4: change both framework and style', () => { - it("should use guide's first valid framework and style as fallback", () => { - const result = resolveDocsLinkUrl( - { - targetSlug: 'concepts/react-only', - contextFramework: 'html', // doesn't support react-only - contextStyle: 'tailwind', // react supports tailwind, so priority 3 would apply - }, - mockSidebar - ); - - // This will actually be priority 3, not 4 - // For a true priority 4 test, we'd need a guide that requires a framework/style combo - // that doesn't match the context at all expect(result.selectedFramework).toBe('react'); expect(result.selectedSlug).toBe('concepts/react-only'); - // Priority could be 3 or 4 depending on style compatibility - expect([3, 4]).toContain(result.priorityLevel); + expect(result.priorityLevel).toBe(2); + expect(result.reason).toContain('Priority 2'); }); }); @@ -573,7 +272,6 @@ describe('routing utilities', () => { { targetSlug: 'concepts/everyone', contextFramework: 'html', - contextStyle: 'css', }, mockSidebar ); @@ -589,7 +287,6 @@ describe('routing utilities', () => { { targetSlug: 'non-existent', contextFramework: 'react', - contextStyle: 'css', }, mockSidebar ); @@ -603,26 +300,11 @@ describe('routing utilities', () => { targetSlug: 'concepts/everyone', // @ts-expect-error Testing invalid input contextFramework: 'invalid', - contextStyle: 'css', }, mockSidebar ); }).toThrow('Invalid context framework: invalid'); }); - - it('should throw error for invalid context style', () => { - expect(() => { - resolveDocsLinkUrl( - { - targetSlug: 'concepts/everyone', - contextFramework: 'html', - // @ts-expect-error Testing invalid input - contextStyle: 'invalid-style', - }, - mockSidebar - ); - }).toThrow('Invalid context style "invalid-style" for framework "html"'); - }); }); describe('url building', () => { @@ -631,12 +313,11 @@ describe('routing utilities', () => { { targetSlug: 'concepts/everyone', contextFramework: 'react', - contextStyle: 'tailwind', }, mockSidebar ); - expect(result.url).toBe('/docs/framework/react/style/tailwind/concepts/everyone'); + expect(result.url).toBe('/docs/framework/react/concepts/everyone'); }); }); }); diff --git a/site/src/utils/docs/__tests__/sidebar.test.ts b/site/src/utils/docs/__tests__/sidebar.test.ts index 61aec81c..6c586f9d 100644 --- a/site/src/utils/docs/__tests__/sidebar.test.ts +++ b/site/src/utils/docs/__tests__/sidebar.test.ts @@ -6,7 +6,7 @@ import { findGuideBySlug, getAllGuideSlugs, getSectionsForGuide, - getValidStylesForGuide, + getValidFrameworksForGuide, } from '../sidebar'; // Mock FRAMEWORK_STYLES from @/types/docs to use our test config @@ -21,19 +21,10 @@ vi.mock('@/types/docs', async () => { react: ['css', 'tailwind'], } as const; - type MockFramework = keyof typeof MOCK_FRAMEWORK_STYLES; - type MockStyle = (typeof MOCK_FRAMEWORK_STYLES)[MockFramework][number]; - return { ...actual, // Mock FRAMEWORK_STYLES to match our test config FRAMEWORK_STYLES: MOCK_FRAMEWORK_STYLES, - - // Mock isValidStyleForFramework to check against mock styles - isValidStyleForFramework: (framework: MockFramework, style: string | undefined | null): style is MockStyle => { - if (!style) return false; - return MOCK_FRAMEWORK_STYLES[framework]?.includes(style as MockStyle) ?? false; - }, }; }); @@ -45,20 +36,17 @@ const _MOCK_FRAMEWORK_STYLES = { } as const; type MockFramework = keyof typeof _MOCK_FRAMEWORK_STYLES; -type MockStyle = (typeof _MOCK_FRAMEWORK_STYLES)[MockFramework][number]; describe('sidebar utilities', () => { - // Test fixtures using mock framework/style values + // Test fixtures using mock framework values const mockGuide1: Guide = { slug: 'guide-1', frameworks: ['html', 'react'] satisfies MockFramework[], - styles: ['css', 'tailwind'] satisfies MockStyle[], }; const mockGuide2: Guide = { slug: 'guide-2', frameworks: ['react'] satisfies MockFramework[], - styles: ['tailwind'] satisfies MockStyle[], }; const mockGuide3: Guide = { @@ -76,7 +64,7 @@ describe('sidebar utilities', () => { describe('filterSidebar', () => { it('should filter guides based on framework', () => { - const result = filterSidebar('html', 'css', mockSidebar); + const result = filterSidebar('html', mockSidebar); expect(result).toHaveLength(2); expect(result[0]).toMatchObject({ @@ -86,8 +74,8 @@ describe('sidebar utilities', () => { expect(result[1]).toEqual(mockGuide3); }); - it('should filter guides based on style', () => { - const result = filterSidebar('react', 'tailwind', mockSidebar); + it('should include all guides for framework that supports them', () => { + const result = filterSidebar('react', mockSidebar); expect(result).toHaveLength(2); const section = result[0] as Section; @@ -104,13 +92,13 @@ describe('sidebar utilities', () => { }; const sidebar: Sidebar = [mockEmptySection]; - const result = filterSidebar('html', 'css', sidebar); + const result = filterSidebar('html', sidebar); expect(result).toHaveLength(0); }); - it('should keep guides with no framework/style restrictions', () => { - const result = filterSidebar('html', 'css', [mockGuide3]); + it('should keep guides with no framework restrictions', () => { + const result = filterSidebar('html', [mockGuide3]); expect(result).toHaveLength(1); expect(result[0]).toEqual(mockGuide3); @@ -127,7 +115,7 @@ describe('sidebar utilities', () => { ], }; - const result = filterSidebar('html', 'css', [nestedSection]); + const result = filterSidebar('html', [nestedSection]); expect(result).toHaveLength(1); const parent = result[0] as Section; @@ -140,14 +128,14 @@ describe('sidebar utilities', () => { describe('findFirstGuide', () => { it('should find the first visible guide in a sidebar', () => { - const result = findFirstGuide('html', 'css', mockSidebar); + const result = findFirstGuide('html', mockSidebar); expect(result).toBe('guide-1'); }); - it('should skip guides that do not match framework/style', () => { + it('should skip guides that do not match framework', () => { const sidebar: Sidebar = [mockGuide2, mockGuide1]; // guide-2 is react-only - const result = findFirstGuide('html', 'css', sidebar); + const result = findFirstGuide('html', sidebar); expect(result).toBe('guide-1'); }); @@ -159,21 +147,21 @@ describe('sidebar utilities', () => { contents: [mockGuide1], }, ]; - const result = findFirstGuide('html', 'css', nestedSidebar); + const result = findFirstGuide('html', nestedSidebar); expect(result).toBe('guide-1'); }); it('should throw error if no guide matches', () => { expect(() => { - findFirstGuide('html', 'css', [mockGuide2]); // guide-2 is react-only - }).toThrow('No guide found for valid combination framework "html" and style "css"'); + findFirstGuide('html', [mockGuide2]); // guide-2 is react-only + }).toThrow('No guide found for valid combination framework "html"'); }); it('should throw error for empty sidebar', () => { expect(() => { - findFirstGuide('html', 'css', []); - }).toThrow('No guide found for valid combination framework "html" and style "css"'); + findFirstGuide('html', []); + }).toThrow('No guide found for valid combination framework "html"'); }); }); @@ -341,91 +329,30 @@ describe('sidebar utilities', () => { }); }); - describe('getValidStylesForGuide', () => { - it('should return framework styles that guide supports', () => { - const result = getValidStylesForGuide(mockGuide1, 'html'); + describe('getValidFrameworksForGuide', () => { + it('should return all frameworks when guide has no restrictions', () => { + const result = getValidFrameworksForGuide(mockGuide3); - // Should return the intersection of mockGuide1 styles and html framework styles - // With our mock, both have ['css', 'tailwind'], so intersection is both - expect(result).toEqual(['css', 'tailwind']); + expect(result).toEqual(expect.arrayContaining(['html', 'react'])); }); - it('should return only styles that both framework and guide support', () => { - const result = getValidStylesForGuide(mockGuide1, 'react'); + it('should return only restricted frameworks', () => { + const result = getValidFrameworksForGuide(mockGuide2); - // Should return the intersection of mockGuide1 styles and react framework styles - // With our mock, both have ['css', 'tailwind'], so intersection is both - expect(result).toEqual(['css', 'tailwind']); - }); - - it('should return all framework styles if guide has no restrictions', () => { - const result = getValidStylesForGuide(mockGuide3, 'react'); - - // Since mockGuide3 has no style restrictions, it should return all framework styles - // With our mock, react supports ['css', 'tailwind'] - expect(result).toEqual(['css', 'tailwind']); - }); - - it('should handle guide with limited style support', () => { - const cssOnlyGuide: Guide = { - slug: 'css-only', - styles: ['css'] satisfies MockStyle[], - }; - const result = getValidStylesForGuide(cssOnlyGuide, 'react'); - - expect(result).toEqual(['css']); - }); - - it('should return empty array if guide does not support any framework styles', () => { - // Guide that only supports html framework, but we check with react - const cssOnlyGuide: Guide = { - slug: 'css-only-test', - styles: ['css'] satisfies MockStyle[], - frameworks: ['html'] satisfies MockFramework[], - }; - const result = getValidStylesForGuide(cssOnlyGuide, 'react'); - - expect(result).toEqual([]); - }); - - describe('without framework parameter', () => { - it('should return all guide styles when guide has style restrictions', () => { - const result = getValidStylesForGuide(mockGuide1); - - expect(result).toEqual(['css', 'tailwind'] satisfies MockStyle[]); - }); - - it('should return all possible styles when guide has no restrictions', () => { - const result = getValidStylesForGuide(mockGuide3); - - // Should include all styles from MOCK_FRAMEWORK_STYLES (deduplicated) - // Both frameworks support ['css', 'tailwind'], so we get both - expect(result).toEqual(expect.arrayContaining(['css', 'tailwind'])); - expect(result).toHaveLength(2); - }); - - it('should return guide-specific styles for limited support guides', () => { - const tailwindOnlyGuide: Guide = { - slug: 'tailwind-only', - styles: ['tailwind'] satisfies MockStyle[], - }; - const result = getValidStylesForGuide(tailwindOnlyGuide); - - expect(result).toEqual(['tailwind']); - }); + expect(result).toEqual(['react']); }); }); describe('findFirstGuide with real sidebar config', () => { - it('should return a guide for every valid framework/style combination', async () => { + it('should return a guide for every valid framework', async () => { // Import the real sidebar config const { sidebar: realSidebar } = await import('../../../docs.config'); - const { ALL_FRAMEWORK_STYLE_COMBINATIONS } = await import('../../../types/docs'); + const { SUPPORTED_FRAMEWORKS } = await import('../../../types/docs'); - // Test each valid combination - for (const { framework, style } of ALL_FRAMEWORK_STYLE_COMBINATIONS) { - const result = findFirstGuide(framework, style, realSidebar); - expect(result, `findFirstGuide should return a guide for ${framework}/${style}`).toBeTruthy(); + // Test each valid framework + for (const framework of SUPPORTED_FRAMEWORKS) { + const result = findFirstGuide(framework, realSidebar); + expect(result, `findFirstGuide should return a guide for ${framework}`).toBeTruthy(); expect(typeof result).toBe('string'); } }); diff --git a/site/src/utils/docs/preferences.ts b/site/src/utils/docs/preferences.ts index c6fbaf75..98d0f100 100644 --- a/site/src/utils/docs/preferences.ts +++ b/site/src/utils/docs/preferences.ts @@ -2,9 +2,11 @@ import type { AstroCookies } from 'astro'; import type { AnySupportedStyle, SupportedFramework, SupportedStyle } from '@/types/docs'; import { isValidFramework, isValidStyleForFramework } from '@/types/docs'; -// Cookie names +// Cookie name for framework (server-side redirects) export const FRAMEWORK_COOKIE = 'vjs_docs_framework'; -export const STYLE_COOKIE = 'vjs_docs_style'; + +// LocalStorage key prefix for style (per-framework, client-side only) +export const STYLE_STORAGE_KEY_PREFIX = 'vjs_docs_style_'; // Cookie options for client-side (1 year expiration) const COOKIE_MAX_AGE = 31536000; // 1 year in seconds @@ -16,34 +18,25 @@ const COOKIE_OPTIONS = `max-age=${COOKIE_MAX_AGE}; path=/; samesite=lax`; interface NoPreference { framework: null; - style: null; } -interface NoStylePreference { +interface FrameworkPreference { framework: SupportedFramework; - style: null; } -interface FullPreference { - framework: SupportedFramework; - style: AnySupportedStyle; -} -export type Preference = NoPreference | NoStylePreference | FullPreference; +export type Preference = NoPreference | FrameworkPreference; export function getPreferencesServer(cookies: AstroCookies): Preference { const frameworkCookie = cookies.has(FRAMEWORK_COOKIE) ? cookies.get(FRAMEWORK_COOKIE) : null; - const styleCookie = cookies.has(STYLE_COOKIE) ? cookies.get(STYLE_COOKIE) : null; const framework = frameworkCookie && isValidFramework(frameworkCookie.value) ? frameworkCookie.value : null; - const style = - styleCookie && framework && isValidStyleForFramework(framework, styleCookie.value) ? styleCookie.value : null; - return { framework, style } as Preference; + return { framework } as Preference; } /** - * Client-side API: Works with document.cookie + * Client-side API: Works with document.cookie and localStorage */ -export function getPreferenceClient(): Preference { - if (typeof document === 'undefined') return { framework: null, style: null }; +export function getFrameworkPreferenceClient(): SupportedFramework | null { + if (typeof document === 'undefined') return null; const cookies = document.cookie.split(';').reduce( (acc, cookie) => { @@ -54,22 +47,49 @@ export function getPreferenceClient(): Preference { {} as Record ); - const framework = - cookies[FRAMEWORK_COOKIE] && isValidFramework(cookies[FRAMEWORK_COOKIE]) ? cookies[FRAMEWORK_COOKIE] : null; - const style = - framework && cookies[STYLE_COOKIE] && isValidStyleForFramework(framework, cookies[STYLE_COOKIE]) - ? cookies[STYLE_COOKIE] - : null; - - return { framework, style } as Preference; + const framework = cookies[FRAMEWORK_COOKIE]; + return framework && isValidFramework(framework) ? framework : null; } -export function setPreferenceClient(framework: T, style: SupportedStyle) { +export function setFrameworkPreferenceClient(framework: SupportedFramework): void { if (typeof document === 'undefined') return; if (!isValidFramework(framework)) throw new Error(`Invalid framework: ${framework}`); - if (!isValidStyleForFramework(framework, style)) - throw new Error(`Invalid style "${style}" for framework "${framework}"`); document.cookie = `${FRAMEWORK_COOKIE}=${framework}; ${COOKIE_OPTIONS}`; - document.cookie = `${STYLE_COOKIE}=${style}; ${COOKIE_OPTIONS}`; +} + +/** + * Get style preference from localStorage for a specific framework + */ +export function getStylePreferenceClient(framework: F): SupportedStyle | null { + if (typeof localStorage === 'undefined') return null; + + const storageKey = STYLE_STORAGE_KEY_PREFIX + framework; + const style = localStorage.getItem(storageKey); + + if (style && isValidStyleForFramework(framework, style)) { + return style as SupportedStyle; + } + return null; +} + +/** + * Set style preference in localStorage for a specific framework + */ +export function setStylePreferenceClient(framework: F, style: SupportedStyle): void { + if (typeof localStorage === 'undefined') return; + if (!isValidStyleForFramework(framework, style)) { + throw new Error(`Invalid style "${style}" for framework "${framework}"`); + } + + const storageKey = STYLE_STORAGE_KEY_PREFIX + framework; + localStorage.setItem(storageKey, style); +} + +/** + * Update the DOM data-style attribute to match the current style + */ +export function updateStyleAttribute(style: AnySupportedStyle): void { + if (typeof document === 'undefined') return; + document.documentElement.dataset.style = style; } diff --git a/site/src/utils/docs/routing.ts b/site/src/utils/docs/routing.ts index c2dfe1d5..1644607c 100644 --- a/site/src/utils/docs/routing.ts +++ b/site/src/utils/docs/routing.ts @@ -1,19 +1,13 @@ import { sidebar as defaultSidebar } from '@/docs.config'; -import type { AnySupportedStyle, Sidebar, SupportedFramework } from '@/types/docs'; -import { DEFAULT_FRAMEWORK, getDefaultStyle, isValidFramework, isValidStyleForFramework } from '@/types/docs'; -import { - findFirstGuide, - findGuideBySlug, - getValidFrameworksForGuide, - getValidStylesForGuide, - isItemVisible, -} from './sidebar'; +import type { Sidebar, SupportedFramework } from '@/types/docs'; +import { DEFAULT_FRAMEWORK, isValidFramework } from '@/types/docs'; +import { findFirstGuide, findGuideBySlug, getValidFrameworksForGuide, isItemVisible } from './sidebar'; /** - * Build a docs URL from framework, style, and guide slug components. + * Build a docs URL from framework and guide slug components. */ -export function buildDocsUrl(framework: SupportedFramework, style: AnySupportedStyle, guideSlug: string): string { - return `/docs/framework/${framework}/style/${style}/${guideSlug}`; +export function buildDocsUrl(framework: SupportedFramework, guideSlug: string): string { + return `/docs/framework/${framework}/${guideSlug}`; } /** @@ -22,11 +16,9 @@ export function buildDocsUrl(framework: SupportedFramework, style: AnySupportedS export interface IndexRedirectInput { preferences: { framework: string | null; - style: string | null; }; params: { framework?: string; - style?: string; }; } @@ -36,19 +28,17 @@ export interface IndexRedirectInput { export interface IndexRedirectResult { url: string; selectedFramework: SupportedFramework; - selectedStyle: AnySupportedStyle; selectedSlug: string; reason: string; } /** - * Resolve redirect for index pages (/docs, /docs/framework/X, /docs/framework/X/style/Y). - * Nothing is pinned - we must select framework, style, AND slug. + * Resolve redirect for index pages (/docs, /docs/framework/X). + * Nothing is pinned - we must select framework AND slug. * * Logic: - * 1. If params.framework AND params.style → validate both → find first guide - * 2. If params.framework only → validate → get style from preference (if valid for framework) or default → find first guide - * 3. If neither param → get both from preferences or defaults → find first guide + * 1. If params.framework → validate → find first guide + * 2. If no param → get from preferences or defaults → find first guide * * @param input - The input containing preferences and params * @param sidebar - Optional sidebar to search (defaults to main sidebar config) @@ -60,65 +50,34 @@ export function resolveIndexRedirect( const { preferences, params } = input; let selectedFramework: SupportedFramework; - let selectedStyle: AnySupportedStyle; let reason: string; - // Case 1: Both framework and style in params - if (params.framework && params.style) { - if (!isValidFramework(params.framework)) { - throw new Error(`Invalid framework param: ${params.framework}`); - } - if (!isValidStyleForFramework(params.framework, params.style)) { - throw new Error(`Invalid style param "${params.style}" for framework "${params.framework}"`); - } - selectedFramework = params.framework; - selectedStyle = params.style as AnySupportedStyle; - reason = 'Using validated params.framework and params.style'; - } else if (params.framework) { - // Case 2: Only framework in params + if (params.framework) { + // Framework in params - validate it if (!isValidFramework(params.framework)) { throw new Error(`Invalid framework param: ${params.framework}`); } selectedFramework = params.framework; - - // Try to use style preference if valid for this framework - if (preferences.style && isValidStyleForFramework(selectedFramework, preferences.style)) { - selectedStyle = preferences.style as AnySupportedStyle; - reason = 'Using params.framework and preferences.style'; - } else { - selectedStyle = getDefaultStyle(selectedFramework); - reason = 'Using params.framework and default style (preference invalid or missing)'; - } + reason = 'Using validated params.framework'; } else { - // Case 3: No params - use preferences or defaults - // Try to use framework preference + // No params - use preferences or defaults if (preferences.framework && isValidFramework(preferences.framework)) { selectedFramework = preferences.framework; - - // Try to use style preference if valid for this framework - if (preferences.style && isValidStyleForFramework(selectedFramework, preferences.style)) { - selectedStyle = preferences.style as AnySupportedStyle; - reason = 'Using preferences.framework and preferences.style'; - } else { - selectedStyle = getDefaultStyle(selectedFramework); - reason = 'Using preferences.framework and default style (style preference invalid or missing)'; - } + reason = 'Using preferences.framework'; } else { // Use all defaults selectedFramework = DEFAULT_FRAMEWORK; - selectedStyle = getDefaultStyle(selectedFramework); - reason = 'Using default framework and default style (no valid preferences)'; + reason = 'Using default framework (no valid preferences)'; } } - // Find the first guide for the selected framework and style - const selectedSlug = findFirstGuide(selectedFramework, selectedStyle, sidebar); - const url = buildDocsUrl(selectedFramework, selectedStyle, selectedSlug); + // Find the first guide for the selected framework + const selectedSlug = findFirstGuide(selectedFramework, sidebar); + const url = buildDocsUrl(selectedFramework, selectedSlug); return { url, selectedFramework, - selectedStyle, selectedSlug, reason, }; @@ -129,7 +88,6 @@ export function resolveIndexRedirect( */ export interface FrameworkChangeInput { currentFramework: SupportedFramework; - currentStyle: AnySupportedStyle; currentSlug: string; newFramework: SupportedFramework; } @@ -141,7 +99,6 @@ export interface FrameworkChangeResult { url: string; shouldReplace: boolean; selectedFramework: SupportedFramework; - selectedStyle: AnySupportedStyle; selectedSlug: string; slugChanged: boolean; reason: string; @@ -153,10 +110,8 @@ export interface FrameworkChangeResult { * * Logic: * 1. framework = newFramework (PINNED) - * 2. If currentStyle valid for newFramework → style = currentStyle - * Else → style = default style for newFramework - * 3. If currentSlug visible in (newFramework, style) → slug = currentSlug, shouldReplace = true - * Else → slug = first guide in (newFramework, style), shouldReplace = false + * 2. If currentSlug visible in newFramework → slug = currentSlug, shouldReplace = true + * Else → slug = first guide in newFramework, shouldReplace = false * * @param input - The input containing current state and new framework * @param sidebar - Optional sidebar to search (defaults to main sidebar config) @@ -165,7 +120,7 @@ export function resolveFrameworkChange( input: FrameworkChangeInput, sidebar: Sidebar = defaultSidebar ): FrameworkChangeResult { - const { currentSlug, currentStyle, newFramework } = input; + const { currentSlug, newFramework } = input; if (!isValidFramework(newFramework)) { throw new Error(`Invalid framework: ${newFramework}`); @@ -173,17 +128,6 @@ export function resolveFrameworkChange( const selectedFramework = newFramework; // PINNED - // Determine the style to use - let selectedStyle: AnySupportedStyle; - let styleAdjusted = false; - - if (isValidStyleForFramework(newFramework, currentStyle)) { - selectedStyle = currentStyle; - } else { - selectedStyle = getDefaultStyle(newFramework); - styleAdjusted = true; - } - // Determine the slug to use let selectedSlug: string; let shouldReplace: boolean; @@ -191,111 +135,26 @@ export function resolveFrameworkChange( let reason: string; const guide = findGuideBySlug(currentSlug, sidebar); - if (guide && isItemVisible(guide, selectedFramework, selectedStyle)) { - // Current slug is visible in the new framework/style combo + if (guide && isItemVisible(guide, selectedFramework)) { + // Current slug is visible in the new framework selectedSlug = currentSlug; shouldReplace = true; slugChanged = false; - reason = styleAdjusted - ? 'Changed framework and style (current style invalid), kept slug (visible)' - : 'Changed framework, kept style and slug (both valid)'; + reason = 'Changed framework, kept slug (visible in new framework)'; } else { // Current slug is not visible, find first guide - selectedSlug = findFirstGuide(selectedFramework, selectedStyle, sidebar); + selectedSlug = findFirstGuide(selectedFramework, sidebar); shouldReplace = false; slugChanged = true; - reason = styleAdjusted - ? 'Changed framework and style (current style invalid), changed slug (not visible)' - : 'Changed framework, kept style, changed slug (slug not visible)'; + reason = 'Changed framework, changed slug (slug not visible in new framework)'; } - const url = buildDocsUrl(selectedFramework, selectedStyle, selectedSlug); + const url = buildDocsUrl(selectedFramework, selectedSlug); return { url, shouldReplace, selectedFramework, - selectedStyle, - selectedSlug, - slugChanged, - reason, - }; -} - -/** - * Input for resolveStyleChange - */ -export interface StyleChangeInput { - currentFramework: SupportedFramework; - currentStyle: AnySupportedStyle; - currentSlug: string; - newStyle: AnySupportedStyle; -} - -/** - * Output from resolveStyleChange - */ -export interface StyleChangeResult { - url: string; - shouldReplace: boolean; - selectedFramework: SupportedFramework; - selectedStyle: AnySupportedStyle; - selectedSlug: string; - slugChanged: boolean; - reason: string; -} - -/** - * Resolve URL when user changes style selector. - * newStyle is PINNED (must keep), slug MAY change if not visible. - * - * Logic: - * 1. framework = currentFramework (stays same) - * 2. style = newStyle (PINNED) - * 3. If currentSlug visible in (currentFramework, newStyle) → slug = currentSlug, shouldReplace = true - * Else → slug = first guide in (currentFramework, newStyle), shouldReplace = false - * - * @param input - The input containing current state and new style - * @param sidebar - Optional sidebar to search (defaults to main sidebar config) - */ -export function resolveStyleChange(input: StyleChangeInput, sidebar: Sidebar = defaultSidebar): StyleChangeResult { - const { currentFramework, currentSlug, newStyle } = input; - - if (!isValidStyleForFramework(currentFramework, newStyle)) { - throw new Error(`Invalid style "${newStyle}" for framework "${currentFramework}"`); - } - - const selectedFramework = currentFramework; // stays same - const selectedStyle = newStyle; // PINNED - - // Determine the slug to use - let selectedSlug: string; - let shouldReplace: boolean; - let slugChanged: boolean; - let reason: string; - - const guide = findGuideBySlug(currentSlug, sidebar); - if (guide && isItemVisible(guide, selectedFramework, selectedStyle)) { - // Current slug is visible in the new style - selectedSlug = currentSlug; - shouldReplace = true; - slugChanged = false; - reason = 'Changed style, kept slug (visible in new style)'; - } else { - // Current slug is not visible, find first guide - selectedSlug = findFirstGuide(selectedFramework, selectedStyle, sidebar); - shouldReplace = false; - slugChanged = true; - reason = 'Changed style, changed slug (slug not visible in new style)'; - } - - const url = buildDocsUrl(selectedFramework, selectedStyle, selectedSlug); - - return { - url, - shouldReplace, - selectedFramework, - selectedStyle, selectedSlug, slugChanged, reason, @@ -308,7 +167,6 @@ export function resolveStyleChange(input: StyleChangeInput, sidebar: Sidebar = d export interface DocsLinkInput { targetSlug: string; contextFramework: SupportedFramework; - contextStyle: AnySupportedStyle; } /** @@ -317,29 +175,26 @@ export interface DocsLinkInput { export interface DocsLinkResult { url: string; selectedFramework: SupportedFramework; - selectedStyle: AnySupportedStyle; selectedSlug: string; - priorityLevel: 1 | 2 | 3 | 4; + priorityLevel: 1 | 2; reason: string; } /** * Resolve the best URL for a guide slug link given current context. - * targetSlug is PINNED (must keep), framework and style MAY change. + * targetSlug is PINNED (must keep), framework MAY change. * - * Logic (4-level priority cascade): + * Logic (2-level priority cascade): * 1. slug = targetSlug (PINNED) - * 2. Try to find best (framework, style) that supports targetSlug: - * - Priority 1: If targetSlug visible in (contextFramework, contextStyle) → use both (best UX) - * - Priority 2: If targetSlug visible in (contextFramework, X) for some style X → use (contextFramework, guide's first valid style for contextFramework) - * - Priority 3: If targetSlug visible in (X, contextStyle) for some framework X → use (guide's first framework that supports contextStyle, contextStyle) - * - Priority 4: Use (guide's first valid framework, guide's first valid style for that framework) + * 2. Try to find best framework that supports targetSlug: + * - Priority 1: If targetSlug visible in contextFramework → use it (best UX) + * - Priority 2: Use guide's first valid framework * * @param input - The input containing target slug and context * @param sidebar - Optional sidebar to search (defaults to main sidebar config) */ export function resolveDocsLinkUrl(input: DocsLinkInput, sidebar: Sidebar = defaultSidebar): DocsLinkResult { - const { targetSlug, contextFramework, contextStyle } = input; + const { targetSlug, contextFramework } = input; const guide = findGuideBySlug(targetSlug, sidebar); if (!guide) { @@ -350,58 +205,29 @@ export function resolveDocsLinkUrl(input: DocsLinkInput, sidebar: Sidebar = defa throw new Error(`Invalid context framework: ${contextFramework}`); } - if (!isValidStyleForFramework(contextFramework, contextStyle)) { - throw new Error(`Invalid context style "${contextStyle}" for framework "${contextFramework}"`); - } - const selectedSlug = targetSlug; // PINNED let selectedFramework: SupportedFramework; - let selectedStyle: AnySupportedStyle; - let priorityLevel: 1 | 2 | 3 | 4; + let priorityLevel: 1 | 2; let reason: string; - // Priority 1: Try current framework + current style - const validStylesForContextFramework = getValidStylesForGuide(guide, contextFramework); - if (validStylesForContextFramework.includes(contextStyle as any)) { + // Priority 1: Try current framework + const validFrameworks = getValidFrameworksForGuide(guide); + if (validFrameworks.includes(contextFramework)) { selectedFramework = contextFramework; - selectedStyle = contextStyle; priorityLevel = 1; - reason = 'Priority 1: Kept both framework and style (slug visible in current context)'; - } else if (validStylesForContextFramework.length > 0) { - // Priority 2: Try current framework + guide's first valid style for that framework - selectedFramework = contextFramework; - selectedStyle = validStylesForContextFramework[0]; - priorityLevel = 2; - reason = 'Priority 2: Kept framework, changed style (slug not visible with current style)'; + reason = 'Priority 1: Kept framework (slug visible in current context)'; } else { - // Priority 3: Try guide's first valid framework that supports current style - const validFrameworks = getValidFrameworksForGuide(guide); - const frameworkThatSupportsContextStyle = validFrameworks.find((fw) => - getValidStylesForGuide(guide, fw).includes(contextStyle as any) - ); - - if (frameworkThatSupportsContextStyle) { - selectedFramework = frameworkThatSupportsContextStyle; - selectedStyle = contextStyle; - priorityLevel = 3; - reason = 'Priority 3: Changed framework, kept style (slug not visible with current framework)'; - } else { - // Priority 4: Fallback - use guide's first valid framework + its first valid style - const fallbackFramework = validFrameworks[0]; - const fallbackStyle = getValidStylesForGuide(guide, fallbackFramework)[0]; - selectedFramework = fallbackFramework; - selectedStyle = fallbackStyle; - priorityLevel = 4; - reason = 'Priority 4: Changed both framework and style (slug not visible in current context)'; - } + // Priority 2: Fallback to guide's first valid framework + selectedFramework = validFrameworks[0]; + priorityLevel = 2; + reason = 'Priority 2: Changed framework (slug not visible in current context)'; } - const url = buildDocsUrl(selectedFramework, selectedStyle, selectedSlug); + const url = buildDocsUrl(selectedFramework, selectedSlug); return { url, selectedFramework, - selectedStyle, selectedSlug, priorityLevel, reason, diff --git a/site/src/utils/docs/sidebar.ts b/site/src/utils/docs/sidebar.ts index 59b2ad0f..864c97dd 100644 --- a/site/src/utils/docs/sidebar.ts +++ b/site/src/utils/docs/sidebar.ts @@ -1,23 +1,20 @@ -import type { AnySupportedStyle, Guide, Section, Sidebar, SupportedFramework, SupportedStyle } from '@/types/docs'; +import type { Guide, Section, Sidebar, SupportedFramework } from '@/types/docs'; +import { FRAMEWORK_STYLES, isSection } from '@/types/docs'; import { sidebar } from '../../docs.config'; -import { FRAMEWORK_STYLES, isSection, isValidStyleForFramework } from '../../types/docs'; /** - * Check if an item (Guide or Section) should be shown based on framework and style. + * Check if an item (Guide or Section) should be shown based on framework. * If no frameworks are specified, the item is visible to all frameworks. - * If no styles are specified, the item is visible to all styles. * * @param item - The guide or section to check * @param framework - The currently selected framework - * @param style - The currently selected style * @param isDev - Whether in development mode (defaults to import.meta.env.DEV) * @returns true if the item should be visible */ -export function isItemVisible( +export function isItemVisible( item: Guide | Section, - framework: F, - style: SupportedStyle, + framework: SupportedFramework, isDev: boolean = import.meta.env.DEV ): boolean { // Filter out dev-only items in production @@ -25,34 +22,30 @@ export function isItemVisible( return false; } - const frameworkMatch = !item.frameworks || item.frameworks.includes(framework); - const styleMatch = !item.styles || item.styles.includes(style as AnySupportedStyle); - return frameworkMatch && styleMatch; + return !item.frameworks || item.frameworks.includes(framework); } /** - * Filter sidebar items based on selected framework and style. + * Filter sidebar items based on selected framework. * Recursively filters sections and guides to only include - * those that are visible for the given framework and style combination. + * those that are visible for the given framework. * Removes empty sections after filtering. * * @param framework - The framework to filter for - * @param style - The style to filter for * @param sidebarToFilter - Optional sidebar to filter (defaults to main sidebar config) * @param isDev - Whether in development mode (defaults to import.meta.env.DEV) * @returns A new filtered sidebar with only visible content */ -export function filterSidebar( - framework: F, - style: SupportedStyle, +export function filterSidebar( + framework: SupportedFramework, sidebarToFilter: Sidebar = sidebar, isDev: boolean = import.meta.env.DEV ): Sidebar { return sidebarToFilter - .filter((item) => isItemVisible(item, framework, style, isDev)) + .filter((item) => isItemVisible(item, framework, isDev)) .map((item) => { if (isSection(item)) { - const filteredContents = filterSidebar(framework, style, item.contents, isDev); + const filteredContents = filterSidebar(framework, item.contents, isDev); return { ...item, contents: filteredContents, @@ -72,46 +65,42 @@ export function filterSidebar( } /** - * Find the first guide in the sidebar that matches the framework and style. + * Find the first guide in the sidebar that matches the framework. * Recursively searches through sections and guides in order, * returning the slug of the first visible guide found. - * A test validates that this function always returns a guide for any valid framework/style combo, - * since the sidebar always includes at least one guide that has no fw/style restrictions. + * A test validates that this function always returns a guide for any valid framework, + * since the sidebar always includes at least one guide that has no framework restrictions. * * @param framework - The framework to match - * @param style - The style to match * @param sidebarToSearch - Optional sidebar to search (defaults to main sidebar config) * @param isDev - Whether in development mode (defaults to import.meta.env.DEV) - * @returns The slug of the first visible guide, or null if none found + * @returns The slug of the first visible guide, or throws if none found */ -export function findFirstGuide( - framework: F, - style: SupportedStyle, +export function findFirstGuide( + framework: SupportedFramework, sidebarToSearch: Sidebar = sidebar, isDev: boolean = import.meta.env.DEV ): string { - if (!isValidStyleForFramework(framework, style as AnySupportedStyle)) { - throw new Error(`Invalid style "${style}" for framework "${framework}".`); - } - for (const item of sidebarToSearch) { - if (!isItemVisible(item, framework, style, isDev)) { + if (!isItemVisible(item, framework, isDev)) { continue; } if (isSection(item)) { // Recursively search section contents - const guide = findFirstGuide(framework, style, item.contents, isDev); - if (guide) return guide; + try { + const guide = findFirstGuide(framework, item.contents, isDev); + if (guide) return guide; + } catch { + // Continue searching other sections + } } else { // It's a Guide, return its slug return item.slug; } } - throw new Error( - `No guide found for valid combination framework "${framework}" and style "${style}". This should never happen.` - ); + throw new Error(`No guide found for valid combination framework "${framework}". This should never happen.`); } /** @@ -188,58 +177,6 @@ export function getSectionsForGuide(slug: string, sidebarToSearch: Sidebar = sid return findInSidebar(sidebarToSearch, []) ?? []; } -/** - * Get valid styles for a guide, optionally filtered by framework. - * - * When framework is provided: - * - First checks if guide supports the framework (returns empty array if not) - * - Returns the intersection of styles the framework supports and styles the guide supports - * - If the guide has no style restrictions, returns all framework styles - * - * When framework is omitted: - * - Returns all styles the guide supports - * - If the guide has no style restrictions, returns all possible styles across all frameworks - * - * @param guide - The guide to check - * @param framework - Optional framework to check against - * @returns Array of valid styles for this guide (in the specified framework, if provided) - */ -export function getValidStylesForGuide( - guide: Guide, - framework?: F -): F extends undefined ? readonly AnySupportedStyle[] : readonly SupportedStyle[]; -export function getValidStylesForGuide( - guide: Guide, - framework?: F -): readonly AnySupportedStyle[] { - // If no framework specified, return all styles the guide supports (or all styles if no restrictions) - if (framework === undefined) { - if (!guide.styles) { - // Guide has no style restrictions, return all possible styles (deduplicated) - const allStyles = Object.values(FRAMEWORK_STYLES).flat(); - return [...new Set(allStyles)] as readonly AnySupportedStyle[]; - } - return guide.styles; - } - - // Framework specified - first check if guide supports this framework - if (guide.frameworks && !guide.frameworks.includes(framework)) { - // Guide doesn't support this framework, return empty array - return []; - } - - // Framework specified, filter by framework styles - const frameworkStyles = FRAMEWORK_STYLES[framework]; - - // If guide has no style restrictions, all framework styles are valid - if (!guide.styles) { - return frameworkStyles; - } - - // Return intersection of framework styles and guide styles - return frameworkStyles.filter((s) => guide.styles!.includes(s)); -} - /** * Get all valid frameworks for a guide. * Returns the frameworks the guide is restricted to, or all frameworks if it has no restrictions. @@ -258,20 +195,18 @@ export function getValidFrameworksForGuide(guide: Guide): SupportedFramework[] { /** * Get the previous and next guides for a given guide slug. - * Returns the adjacent guides in the filtered sidebar for the given framework and style. + * Returns the adjacent guides in the filtered sidebar for the given framework. * * @param currentSlug - The slug of the current guide * @param framework - The framework to filter for - * @param style - The style to filter for * @returns Object with prev and next guides (null if at start/end) */ -export function getAdjacentGuides( +export function getAdjacentGuides( currentSlug: string, - framework: F, - style: SupportedStyle + framework: SupportedFramework ): { prev: Guide | null; next: Guide | null } { - // Get the filtered sidebar for this framework/style combination - const filteredSidebar = filterSidebar(framework, style); + // Get the filtered sidebar for this framework + const filteredSidebar = filterSidebar(framework); // Flatten the sidebar to get all guides in order const allGuides = getAllGuideSlugs(filteredSidebar);