feat(site): remove style from urls (#378)

This commit is contained in:
Darius Cepulis
2026-02-02 15:54:24 -06:00
committed by GitHub
parent 9ed608d6d4
commit 0c530765d5
26 changed files with 504 additions and 1141 deletions
+2 -7
View File
@@ -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<HTMLAttributes<'a'>, '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,
});
---
+8 -10
View File
@@ -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<F extends SupportedFramework = SupportedFramework> = {
type Props = {
prev: Guide | null;
next: Guide | null;
framework: F;
style: SupportedStyle<F>;
framework: SupportedFramework;
docTitles: Map<string, string>;
};
const { prev, next, framework, style, docTitles } = Astro.props;
const { prev, next, framework, docTitles } = Astro.props;
function getGuideUrl<F extends SupportedFramework>(framework: F, style: SupportedStyle<F>, 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<F extends SupportedFramework>(framework: F, style: Supporte
{
prev ? (
<a
href={getGuideUrl(framework, style, prev.slug)}
href={getGuideUrl(framework, prev.slug)}
class="grid items-center gap-x-2 p-4 border border-light-40 dark:border-dark-80 rounded-lg intent:border-dark-40 dark:intent:border-dark-40"
style="grid-template-areas: 'empty direction' 'icon title'; grid-template-columns: auto minmax(0,1fr); grid-template-rows: auto minmax(0,1fr);"
>
@@ -43,7 +41,7 @@ function getGuideUrl<F extends SupportedFramework>(framework: F, style: Supporte
{
next ? (
<a
href={getGuideUrl(framework, style, next.slug)}
href={getGuideUrl(framework, next.slug)}
class="grid items-center gap-x-2 p-4 border border-light-40 dark:border-dark-80 rounded-lg intent:border-dark-40 text-right dark:intent:border-dark-40"
style="grid-template-areas: 'direction empty' 'title icon';grid-template-columns: minmax(0,1fr) auto; grid-template-rows: auto minmax(0,1fr);"
>
+7 -13
View File
@@ -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<F extends SupportedFramework = SupportedFramework> = {
framework: F;
style: SupportedStyle<F>;
type Props = {
framework: SupportedFramework;
docTitles: Map<string, string>;
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);
---
<div class={clsx('bg-light-80 dark:bg-dark-100', className)}>
<slot />
<PreferenceUpdater client:idle currentFramework={framework} currentStyle={style} />
<PreferenceUpdater client:idle currentFramework={framework} />
<nav class="p-6">
{
filteredSidebar.map((item) => (
<SidebarItem item={item} framework={framework} style={style} docTitles={docTitles} />
))
}
{filteredSidebar.map((item) => <SidebarItem item={item} framework={framework} docTitles={docTitles} />)}
</nav>
</div>
+13 -15
View File
@@ -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<typeof framework>);
if (framework) {
setFrameworkPreferenceClient(framework as SupportedFramework);
}
}, [framework, style]);
}, [framework]);
return null;
}
+15 -13
View File
@@ -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<F extends SupportedFramework = SupportedFramework> {
currentFramework: F;
currentStyle: SupportedStyle<F>;
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<F extends SupportedFramework = SupportedFramework>({
currentFramework,
currentStyle,
}: PreferenceUpdaterProps<F>) {
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;
}
+19 -22
View File
@@ -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<T extends SupportedFramework> {
currentFramework: T;
currentStyle: SupportedStyle<T>;
interface SelectorProps {
currentFramework: SupportedFramework;
currentSlug: string;
}
export function Selectors({ currentFramework, currentStyle, currentSlug }: SelectorProps<SupportedFramework>) {
// 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];
+7 -9
View File
@@ -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<F extends SupportedFramework = SupportedFramework> = {
type Props = {
item: Guide | Section;
framework: F;
style: SupportedStyle<F>;
framework: SupportedFramework;
docTitles: Map<string, string>;
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);
</summary>
<div>
{item.contents.map((contentItem) => (
<Astro.self item={contentItem} framework={framework} style={style} docTitles={docTitles} depth={depth + 1} />
<Astro.self item={contentItem} framework={framework} docTitles={docTitles} depth={depth + 1} />
))}
</div>
</details>
@@ -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}
+28 -27
View File
@@ -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
* <!-- Show only for CSS style -->
* <StyleCase styles={['css']}>CSS-specific content</StyleCase>
*
* <!-- Show for multiple styles -->
* <StyleCase styles={['css', 'tailwind']}>Shared content</StyleCase>
*
* <!-- Always visible (no styles prop) -->
* <StyleCase>Universal content</StyleCase>
*/
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 && <slot />}
```
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 ? (
<slot />
) : (
<div class="contents" data-for-style={styles.join(" ")}>
<slot />
</div>
)
}
<div
class="contents"
hidden={!shouldRender}
data-pagefind-ignore={shouldRender ? undefined : 'all'}
data-llms-ignore={shouldRender ? undefined : 'all'}
>
<slot />
</div>
+63
View File
@@ -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 set:html={`
[data-for-style] { display: none; }
${allStyles.map(style => `html[data-style='${style}'] [data-for-style~='${style}'] { display: contents; }`).join('\n ')}
`}></style>
{/*
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.
*/}
<script
is:inline
define:vars={{ keyPrefix: STYLE_KEY_PREFIX, frameworkStyles: frameworkStylesConfig, defaultStyle: defaultStyleConfig }}
>
if (typeof localStorage !== 'undefined' && typeof document !== 'undefined') {
// Extract framework from URL: /docs/framework/{framework}/...
const pathMatch = window.location.pathname.match(/\/docs\/framework\/([^/]+)/);
const framework = pathMatch?.[1];
const validStyles = frameworkStyles[framework];
if (framework && validStyles) {
const storageKey = keyPrefix + framework;
// Check for ?style= query param (enables deep-linking)
const urlParams = new URLSearchParams(window.location.search);
const queryStyle = urlParams.get('style');
let style;
if (queryStyle && validStyles.includes(queryStyle)) {
// Query param overrides and persists to localStorage for this framework
style = queryStyle;
localStorage.setItem(storageKey, style);
} else {
// Fall back to localStorage or default
style = localStorage.getItem(storageKey);
if (!style || !validStyles.includes(style)) {
style = defaultStyle;
localStorage.setItem(storageKey, style);
}
}
document.documentElement.dataset.style = style;
}
}
</script>