feat: website (#45)

This commit is contained in:
Darius Cepulis
2025-10-20 18:14:37 -05:00
committed by GitHub
parent 6d7325cedb
commit 7d2536a766
129 changed files with 7368 additions and 1054 deletions
@@ -0,0 +1,28 @@
---
import { isValidFramework, isValidStyleForFramework } from '@/types/docs';
import { resolveDocsLinkUrl } from '@/utils/docs/routing';
import type { HTMLAttributes } from 'astro/types';
interface Props extends Omit<HTMLAttributes<'a'>, 'href'> {
slug: string;
}
const { slug, ...rest } = Astro.props;
const { framework: paramFramework, style: paramStyle } = 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,
});
---
<a href={href} {...rest}>
<slot />
</a>
@@ -0,0 +1,63 @@
---
import type { Guide, SupportedFramework, SupportedStyle } from '@/types/docs';
import { ChevronDown } from 'lucide-react';
type Props<F extends SupportedFramework = SupportedFramework> = {
prev: Guide | null;
next: Guide | null;
framework: F;
style: SupportedStyle<F>;
docTitles: Map<string, string>;
};
const { prev, next, framework, style, docTitles } = Astro.props;
function getGuideUrl<F extends SupportedFramework>(framework: F, style: SupportedStyle<F>, slug: string): string {
return `/docs/framework/${framework}/style/${style}/${slug}`;
}
---
<nav class="w-full max-w-3xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-5">
{
prev ? (
<a
href={getGuideUrl(framework, style, prev.slug)}
class="grid items-center gap-x-2 p-4 border border-light-40 rounded-lg 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);"
>
<div class="text-sm text-dark-80" style="grid-area: direction;">
Previous
</div>
<div class="text-base font-medium text-balance" style="grid-area: title">
{prev.sidebarLabel || docTitles.get(prev.slug)}
</div>
<div style="grid-area: icon">
<ChevronDown size={12} className="rotate-90" />
</div>
</a>
) : (
<div />
)
}
{
next ? (
<a
href={getGuideUrl(framework, style, next.slug)}
class="grid items-center gap-x-2 p-4 border border-light-40 rounded-lg intent:border-dark-40 text-right"
style="grid-template-areas: 'direction empty' 'title icon';grid-template-columns: minmax(0,1fr) auto; grid-template-rows: auto minmax(0,1fr);"
>
<div class="text-sm text-dark-80" style="grid-area: direction;">
Next
</div>
<div class="text-base font-medium text-balance" style="grid-area: title">
{next.sidebarLabel || docTitles.get(next.slug)}
</div>
<div style="grid-area: icon">
<ChevronDown size={12} className="-rotate-90" />
</div>
</a>
) : (
<div />
)
}
</nav>
@@ -0,0 +1,23 @@
---
import type { SupportedFramework, SupportedStyle } from '@/types/docs';
import { PreferenceUpdater } from '@/components/docs/PreferenceUpdater';
import { Selectors } from '@/components/docs/Selectors';
import SidebarItem from '@/components/docs/SidebarItem.astro';
import { filterSidebar } from '@/utils/docs/sidebar';
type Props<F extends SupportedFramework = SupportedFramework> = {
framework: F;
style: SupportedStyle<F>;
docTitles: Map<string, string>;
};
const { framework, style, docTitles } = Astro.props;
const filteredSidebar = filterSidebar(framework, style);
---
<slot />
<PreferenceUpdater client:idle currentFramework={framework} currentStyle={style} />
<nav class="p-6 text-dark-80">
{filteredSidebar.map((item) => <SidebarItem item={item} framework={framework} style={style} docTitles={docTitles} />)}
</nav>
@@ -0,0 +1,16 @@
---
import { GITHUB_REPO_URL } from '@/consts';
type Props = {
filePath: string;
};
const { filePath } = Astro.props;
const editUrl = `${GITHUB_REPO_URL}edit/main/${filePath}`;
---
<div class="w-full max-w-3xl mx-auto">
<a href={editUrl} target="_blank" rel="noopener noreferrer" class="underline intent:no-underline text-sm">
Edit page
</a>
</div>
@@ -1,8 +1,8 @@
---
import { type SupportedFramework } from '@/types/docs';
import type { SupportedFramework } from '@/types/docs';
interface Props {
frameworks?: SupportedFramework[];
frameworks?: SupportedFramework[];
}
const { frameworks } = Astro.props;
@@ -0,0 +1,21 @@
import type { SupportedFramework, SupportedStyle } from '@/types/docs';
import { useEffect } from 'react';
import { setPreferenceClient } from '@/utils/docs/preferences';
interface PreferenceUpdaterProps<F extends SupportedFramework = SupportedFramework> {
currentFramework: F;
currentStyle: SupportedStyle<F>;
}
/**
* PreferenceUpdater component updates user preferences in cookies.
* This component is loaded with client:idle directive, making it non-blocking.
* It renders nothing but updates cookies whenever framework or style changes.
*/
export function PreferenceUpdater<F extends SupportedFramework = SupportedFramework>({ currentFramework, currentStyle }: PreferenceUpdaterProps<F>) {
useEffect(() => {
setPreferenceClient(currentFramework, currentStyle);
}, [currentFramework, currentStyle]);
return <></>;
}
+73 -123
View File
@@ -1,142 +1,92 @@
import type { AnySupportedStyle, SupportedFramework } from '@/types/docs';
import type { AnySupportedStyle, SupportedFramework, SupportedStyle } from '@/types/docs';
import { navigate } from 'astro:transitions/client';
import { Select } from '@/components/Select';
import { FRAMEWORK_STYLES, isValidFramework, isValidStyleForFramework, SUPPORTED_FRAMEWORKS } from '@/types/docs';
import { resolveFrameworkChange, resolveStyleChange } from '@/utils/docs/routing';
import { getAvailableStyles, getDefaultStyle, SUPPORTED_FRAMEWORKS } from '@/types/docs';
import { findFirstGuide, findGuideBySlug, getValidStylesForGuide } from '@/utils/docs/sidebar';
interface SelectorProps {
currentFramework: SupportedFramework;
currentStyle: AnySupportedStyle;
interface SelectorProps<T extends SupportedFramework> {
currentFramework: T;
currentStyle: SupportedStyle<T>;
currentSlug: string;
}
/**
* Extract the current guide slug from the docs URL.
* URL format: /docs/framework/{framework}/style/{style}/{slug}/
* @returns The guide slug (everything after the style parameter)
*/
function getCurrentGuideSlug(): string {
const pathParts = window.location.pathname.split('/').filter(Boolean);
const styleIndex = pathParts.indexOf('style');
const slugParts = pathParts.slice(styleIndex + 2); // Everything after the style value
return slugParts.join('/');
}
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
const handleFrameworkChange = (newFramework: SupportedFramework | null) => {
if (newFramework === null) return;
if (!isValidFramework(newFramework)) return;
export function Selectors({ currentFramework, currentStyle }: SelectorProps) {
const handleFrameworkChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const newFramework = event.target.value as SupportedFramework;
const { url, shouldReplace } = resolveFrameworkChange({
currentFramework,
currentStyle,
currentSlug,
newFramework,
});
// Get current guide slug from URL
const currentGuideSlug = getCurrentGuideSlug();
// Find the current guide in the sidebar
const currentGuide = findGuideBySlug(currentGuideSlug);
if (!currentGuide) {
// No current guide found, redirect to first guide of new framework
const firstGuide = findFirstGuide(newFramework, getDefaultStyle(newFramework));
if (firstGuide) {
navigate(`/docs/framework/${newFramework}/style/${getDefaultStyle(newFramework)}/${firstGuide}/`);
} else {
navigate('/docs/');
}
return;
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;
}
// Check if guide is available for the new framework
if (currentGuide.frameworks && !currentGuide.frameworks.includes(newFramework)) {
// Guide not available in new framework, redirect to first guide
const firstGuide = findFirstGuide(newFramework, getDefaultStyle(newFramework));
if (firstGuide) {
navigate(`/docs/framework/${newFramework}/style/${getDefaultStyle(newFramework)}/${firstGuide}/`);
} else {
navigate('/docs/');
}
return;
}
// Get valid styles for this guide in the new framework
const validStyles = getValidStylesForGuide(currentGuide, newFramework);
if (validStyles.length === 0) {
// Guide not available in new framework, go to first guide
const firstGuide = findFirstGuide(newFramework, getDefaultStyle(newFramework));
if (firstGuide) {
navigate(`/docs/framework/${newFramework}/style/${getDefaultStyle(newFramework)}/${firstGuide}/`);
} else {
navigate('/docs/');
}
return;
}
// Pick best style: current if still valid, otherwise first valid
const newStyle = validStyles.includes(currentStyle) ? currentStyle : validStyles[0];
// Navigate to same guide with adjusted framework/style
navigate(`/docs/framework/${newFramework}/style/${newStyle}/${currentGuideSlug}/`);
};
const handleStyleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const newStyle = event.target.value as AnySupportedStyle;
const handleStyleChange = (newStyle: AnySupportedStyle | null) => {
if (newStyle === null) return;
if (!isValidStyleForFramework(currentFramework, newStyle)) return;
// Get current guide slug from URL
const currentGuideSlug = getCurrentGuideSlug();
const { url, shouldReplace } = resolveStyleChange({
currentFramework,
currentStyle,
currentSlug,
newStyle,
});
// Find the current guide in the sidebar
const currentGuide = findGuideBySlug(currentGuideSlug);
if (!currentGuide) {
// No current guide found, redirect to first guide of new style
const firstGuide = findFirstGuide(currentFramework, newStyle);
if (firstGuide) {
navigate(`/docs/framework/${currentFramework}/style/${newStyle}/${firstGuide}/`);
} else {
navigate('/docs/');
}
return;
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;
}
// Check if guide is valid for current framework and new style
const validStyles = getValidStylesForGuide(currentGuide, currentFramework);
if (!validStyles.includes(newStyle)) {
// Guide not available for new style, go to first guide
const firstGuide = findFirstGuide(currentFramework, newStyle);
if (firstGuide) {
navigate(`/docs/framework/${currentFramework}/style/${newStyle}/${firstGuide}/`);
} else {
navigate('/docs/');
}
return;
}
// Guide supports the new style, navigate to it
navigate(`/docs/framework/${currentFramework}/style/${newStyle}/${currentGuideSlug}/`);
};
const availableStyles = getAvailableStyles(currentFramework);
const availableStyles = FRAMEWORK_STYLES[currentFramework];
const frameworkOptions = SUPPORTED_FRAMEWORKS.map(fw => ({
value: fw,
label: fw,
}));
const styleOptions = availableStyles.map(st => ({
value: st,
label: st,
}));
return (
<div className="mb-4">
<div>
<label htmlFor="framework-select">Framework:</label>
<select id="framework-select" value={currentFramework} onChange={handleFrameworkChange}>
{SUPPORTED_FRAMEWORKS.map(fw => (
<option key={fw} value={fw}>
{fw}
</option>
))}
</select>
</div>
<div>
<label htmlFor="style-select">Style:</label>
<select id="style-select" value={currentStyle} onChange={handleStyleChange}>
{availableStyles.map(st => (
<option key={st} value={st}>
{st}
</option>
))}
</select>
<div className="p-6 lg:py-2.5 xl:p-6 border-b border-light-40">
<div className="max-w-3xl mx-auto w-full grid gap-x-6 gap-y-2 items-center" style={{ gridTemplateColumns: 'auto minmax(0, 1fr)' }}>
<span>Framework</span>
<Select
value={currentFramework}
onChange={handleFrameworkChange}
options={frameworkOptions}
aria-label="Select framework"
data-testid="select-framework"
/>
<span>Style</span>
<Select
value={currentStyle}
onChange={handleStyleChange}
options={styleOptions}
aria-label="Select style"
data-testid="select-style"
/>
</div>
</div>
);
+64 -28
View File
@@ -1,40 +1,76 @@
---
import {
type SupportedFramework,
type AnySupportedStyle,
type Guide,
type Section,
isSection,
} from '@/types/docs';
import type { Guide, Section, SupportedFramework, SupportedStyle } from '@/types/docs';
import { isSection } from '@/types/docs';
import { ChevronDown } from 'lucide-react';
type Props = {
item: Guide | Section;
framework: SupportedFramework;
style: AnySupportedStyle;
docTitles: Map<string, string>;
type Props<F extends SupportedFramework = SupportedFramework> = {
item: Guide | Section;
framework: F;
style: SupportedStyle<F>;
docTitles: Map<string, string>;
depth?: number;
};
const { item, framework, style, docTitles } = Astro.props;
const { item, framework, style, docTitles, depth = 0 } = Astro.props;
const currentPath = Astro.url.pathname;
// Helper to check if this item or any of its children contains the active path
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}`;
return itemPath === currentPath;
}
}
const isActive = containsActivePath(item);
---
{
isSection(item) ? (
<details open>
<summary>{item.sidebarLabel}</summary>
{item.contents.map((contentItem) => (
<Astro.self
item={contentItem}
framework={framework}
style={style}
docTitles={docTitles}
/>
))}
<details open class="group" style={`padding-left: calc(var(--spacing) * ${depth * 4})`}>
<summary
class:list={[
'py-2 flex items-center gap-4 ',
'intent:text-dark-100 cursor-pointer',
depth === 0 && 'font-medium',
depth > 0 && 'border-l',
isActive ? 'text-dark-100 border-dark-100 ' : 'border-light-40',
]}
>
<span class="flex-1">{item.sidebarLabel}</span>
<ChevronDown size={12} className="group-open:rotate-180" />
</summary>
<div>
{item.contents.map((contentItem) => (
<Astro.self item={contentItem} framework={framework} style={style} docTitles={docTitles} depth={depth + 1} />
))}
</div>
</details>
) : (
<div class="sidebar-guide">
<a href={`/docs/framework/${framework}/style/${style}/${item.slug}/`}>
{item.sidebarLabel || docTitles.get(item.slug) || item.slug}
</a>
</div>
<a
class:list={[
'py-2 flex items-center gap-4 ',
'intent:text-dark-100 cursor-pointer',
depth === 0 && 'font-medium',
depth > 0 && 'border-l',
isActive ? 'text-dark-100 border-dark-100 ' : 'border-light-40',
]}
style={`padding-left: calc(var(--spacing) * ${depth * 4})`}
href={`/docs/framework/${framework}/style/${style}/${item.slug}`}
>
{item.devOnly ? '[Dev only] ' : ''}
{item.sidebarLabel || docTitles.get(item.slug) || item.slug}
</a>
)
}
<style>
summary {
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
</style>
+1 -1
View File
@@ -2,7 +2,7 @@
import type { AnySupportedStyle } from '@/types/docs';
interface Props {
styles?: AnySupportedStyle[];
styles?: AnySupportedStyle[];
}
const { styles } = Astro.props;
@@ -0,0 +1,48 @@
import type { MarkdownHeading } from 'astro';
import clsx from 'clsx';
import { useRef } from 'react';
import { useAutoScroll } from './utils';
interface TableOfContentsDesktopProps {
headings: MarkdownHeading[];
activeId: string;
onNavigate: (slug: string) => void;
className?: string;
}
export function TableOfContentsDesktop({ headings, activeId, onNavigate, className }: TableOfContentsDesktopProps) {
const navRef = useRef<HTMLElement>(null);
useAutoScroll({ activeId, containerRef: navRef });
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
e.preventDefault();
onNavigate(slug);
};
return (
<nav
ref={navRef}
className={clsx('', className)}
>
<div className="py-8 pr-6">
<h2 className="text-sm mb-3 font-semibold">On this page</h2>
<ul className="space-y-3">
{headings.map(heading => (
<li key={heading.slug}>
<a
href={`#${heading.slug}`}
onClick={e => handleClick(e, heading.slug)}
className={clsx('text-sm block', activeId === heading.slug ? 'text-dark-100' : 'text-dark-40 intent:text-dark-100',
)}
style={{ paddingLeft: `calc(${(heading.depth - 2)} * var(--spacing) * 4)` }}
>
{heading.text}
</a>
</li>
))}
</ul>
</div>
</nav>
);
}
@@ -0,0 +1,41 @@
import type { MarkdownHeading } from 'astro';
import clsx from 'clsx';
import { Select } from '@/components/Select';
interface TableOfContentsMobileProps {
headings: MarkdownHeading[];
activeId: string;
onNavigate: (slug: string) => void;
className?: string;
}
export function TableOfContentsMobile({ headings, activeId, onNavigate, className }: TableOfContentsMobileProps) {
const handleChange = (slug: string | null) => {
if (slug) onNavigate(slug);
};
const options = [
{ value: null, label: 'On this page…' },
...headings.map(heading => ({
value: heading.slug,
label: `${'\u00A0'.repeat((heading.depth - 2) * 2)}${heading.text}`,
})),
];
return (
<div
className={clsx('border-b border-light-40 bg-light-80 px-6 lg:px-12 h-(--h) flex items-center', className)}
style={{ '--h': 'var(--mobile-toc-h)' } as React.CSSProperties}
>
<div className="w-full max-w-3xl mx-auto">
<Select
value={activeId || null}
onChange={handleChange}
options={options}
aria-label="Table of contents"
className="w-full"
/>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
import type { MarkdownHeading } from 'astro';
import { TableOfContentsDesktop } from './TableOfContents.desktop';
import { TableOfContentsMobile } from './TableOfContents.mobile';
import { filterHeadingsByDepth, navigateToHeading, useActiveHeading } from './utils';
interface TableOfContentsProps {
headings: MarkdownHeading[];
}
export function TableOfContents({ headings }: TableOfContentsProps) {
const filteredHeadings = filterHeadingsByDepth(headings, 2, 3);
const activeId = useActiveHeading(filteredHeadings);
if (filteredHeadings.length === 0) return <></>;
return (
<>
<TableOfContentsMobile
headings={filteredHeadings}
activeId={activeId}
onNavigate={navigateToHeading}
className="xl:hidden"
/>
<TableOfContentsDesktop
headings={filteredHeadings}
activeId={activeId}
onNavigate={navigateToHeading}
className="hidden xl:block"
/>
</>
);
}
@@ -0,0 +1,156 @@
import type { MarkdownHeading } from 'astro';
import type { RefObject } from 'react';
import debounce from 'just-debounce-it';
import throttle from 'just-throttle';
import { useEffect, useState } from 'react';
/**
* Find the first scrollable ancestor of an element
*/
export function getScrollParent(element: HTMLElement): HTMLElement {
let current: HTMLElement | null = element;
while (current && current !== document.body) {
const style = window.getComputedStyle(current);
const overflowY = style.overflowY;
const overflow = style.overflow;
if (/auto|scroll/.test(overflow + overflowY)) {
if (current.scrollHeight > current.clientHeight) {
return current;
}
}
current = current.parentElement;
}
return document.body;
}
/**
* Check if an element is outside the visible bounds of its scroll container
*/
export function isElementOffscreen(
element: HTMLElement,
container: HTMLElement,
): boolean {
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const isAboveView = elementRect.top < containerRect.top;
const isBelowView = elementRect.bottom > containerRect.bottom;
return isAboveView || isBelowView;
}
/**
* Filter headings by depth range
*/
export function filterHeadingsByDepth(
headings: MarkdownHeading[],
minDepth: number,
maxDepth: number,
): MarkdownHeading[] {
return headings.filter(h => h.depth >= minDepth && h.depth <= maxDepth);
}
/**
* Navigate to a heading by scrolling it into view and updating the URL
*/
export function navigateToHeading(slug: string): void {
const element = document.getElementById(slug);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
window.history.pushState({}, '', `#${slug}`);
}
}
interface UseAutoScrollOptions {
activeId: string;
containerRef: RefObject<HTMLElement | null>;
}
/**
* Auto-scrolls the active link into view when it becomes active and is offscreen
*/
export function useAutoScroll({ activeId, containerRef }: UseAutoScrollOptions) {
useEffect(() => {
if (!activeId || !containerRef.current) return;
const activeLink = containerRef.current.querySelector<HTMLAnchorElement>(
`a[href="#${activeId}"]`,
);
if (!activeLink) return;
const scrollParent = getScrollParent(containerRef.current);
if (!scrollParent) return;
if (isElementOffscreen(activeLink, scrollParent)) {
activeLink.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, [activeId, containerRef]);
}
/**
* Tracks which heading is currently active based on scroll position
*/
export function useActiveHeading(headings: MarkdownHeading[]): string {
const [activeId, setActiveId] = useState<string>('');
useEffect(() => {
const handleScroll = () => {
// globals.css SHOULD define a scroll-margin-top for headings
// let's get the value of that, here
let scrollOffset = 125; // idk, a sensible default
const idElement = document.querySelector('main [id]');
if (idElement) {
const computedStyle = getComputedStyle(idElement);
const scrollMarginTop = computedStyle.scrollMarginTop;
if (scrollMarginTop) {
const parsed = Number.parseFloat(scrollMarginTop);
if (!Number.isNaN(parsed)) scrollOffset = parsed;
}
}
scrollOffset = scrollOffset + 1;
const scrollPosition = window.scrollY + scrollOffset;
// Find the last heading that's above the scroll position
let currentActiveId = '';
for (const heading of headings) {
const element = document.getElementById(heading.slug);
if (element) {
const elementTop = element.offsetTop;
if (elementTop <= scrollPosition) {
currentActiveId = heading.slug;
} else {
break;
}
}
}
// since this function is mostly only called in events...
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setActiveId(currentActiveId);
};
// Throttle to limit how often it runs during scrolling
const throttledHandleScroll = throttle(handleScroll, 100);
// Debounce to ensure it runs after scrolling stops
const debouncedHandleScroll = debounce(throttledHandleScroll, 50);
// Set initial active heading
handleScroll();
// Add scroll listeners
window.addEventListener('scroll', throttledHandleScroll);
window.addEventListener('scroll', debouncedHandleScroll);
return () => {
window.removeEventListener('scroll', throttledHandleScroll);
window.removeEventListener('scroll', debouncedHandleScroll);
};
}, [headings]);
return activeId;
}