feat(site): search (#165)

This commit is contained in:
Darius Cepulis
2025-11-05 13:04:06 -06:00
committed by GitHub
parent a41cdba068
commit 8ba758ab5d
24 changed files with 581 additions and 40 deletions
+18 -18
View File
@@ -234,7 +234,7 @@ importers:
dependencies:
'@floating-ui/react':
specifier: ^0.27.16
version: 0.27.16(react-dom@19.2.0(react@18.3.1))(react@18.3.1)
version: 0.27.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@videojs/core':
specifier: workspace:*
version: link:../core
@@ -317,6 +317,9 @@ importers:
'@nanostores/react':
specifier: ^1.0.0
version: 1.0.0(nanostores@1.0.1)(react@18.3.1)
'@pagefind/default-ui':
specifier: ^1.4.0
version: 1.4.0
'@tailwindcss/vite':
specifier: ^4.1.14
version: 4.1.14(vite@7.1.6(@types/node@22.18.6)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1))
@@ -411,6 +414,9 @@ importers:
jsdom:
specifier: ^27.0.0
version: 27.0.0(postcss@8.5.6)
sirv:
specifier: ^3.0.2
version: 3.0.2
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.6)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(yaml@2.8.1)
@@ -1377,6 +1383,9 @@ packages:
'@oxc-project/types@0.95.0':
resolution: {integrity: sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ==}
'@pagefind/default-ui@1.4.0':
resolution: {integrity: sha512-wie82VWn3cnGEdIjh4YwNESyS1G6vRHwL6cNjy9CFgNnWW/PGRjsLq300xjVH5sfPFK3iK36UxvIBymtQIEiSQ==}
'@parcel/watcher-android-arm64@2.5.1':
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
engines: {node: '>= 10.0.0'}
@@ -7199,18 +7208,12 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@floating-ui/react-dom@2.1.6(react-dom@19.2.0(react@18.3.1))(react@18.3.1)':
'@floating-ui/react@0.27.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/dom': 1.7.4
react: 18.3.1
react-dom: 19.2.0(react@18.3.1)
'@floating-ui/react@0.27.16(react-dom@19.2.0(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@18.3.1))(react@18.3.1)
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@floating-ui/utils': 0.2.10
react: 18.3.1
react-dom: 19.2.0(react@18.3.1)
react-dom: 18.3.1(react@18.3.1)
tabbable: 6.2.0
'@floating-ui/utils@0.2.10': {}
@@ -7481,6 +7484,8 @@ snapshots:
'@oxc-project/types@0.95.0': {}
'@pagefind/default-ui@1.4.0': {}
'@parcel/watcher-android-arm64@2.5.1':
optional: true
@@ -9500,7 +9505,7 @@ snapshots:
eslint: 9.37.0(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.37.0(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.37.0(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.37.0(jiti@2.6.1))
eslint-plugin-react-hooks: 7.0.1(eslint@9.37.0(jiti@2.6.1))
@@ -9542,7 +9547,7 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.37.0(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
@@ -9618,7 +9623,7 @@ snapshots:
optionalDependencies:
typescript: 5.9.2
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.37.0(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -11953,11 +11958,6 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
react-dom@19.2.0(react@18.3.1):
dependencies:
react: 18.3.1
scheduler: 0.27.0
react-dom@19.2.0(react@19.2.0):
dependencies:
react: 19.2.0
-1
View File
@@ -8,5 +8,4 @@ not comprehensive. just the things I'm worried about forgetting
- animations
- docs features
- /docs/[slug] redirect ("share this guide")
- search
- update READMEs following dev
+2
View File
@@ -8,6 +8,7 @@ import vercel from '@astrojs/vercel';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig, fontProviders } from 'astro/config';
import pagefind from './integrations/pagefind';
import rehypePrepareCodeBlocks from './src/utils/rehypePrepareCodeBlocks';
import remarkConditionalHeadings from './src/utils/remarkConditionalHeadings';
import { remarkReadingTime } from './src/utils/remarkReadingTime.mjs';
@@ -21,6 +22,7 @@ export default defineConfig({
integrations: [
mdx({ extendMarkdownConfig: true }),
sitemap(),
pagefind(),
react({
babel: {
plugins: [['babel-plugin-react-compiler', { target: '18' }]],
+99
View File
@@ -0,0 +1,99 @@
import type { AstroIntegration } from 'astro';
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import sirv from 'sirv';
export interface PagefindOptions {
/**
* The path to the built site to index.
* Defaults to the Astro output directory.
*/
site?: string;
}
export default function pagefind(options: PagefindOptions = {}): AstroIntegration {
return {
name: 'pagefind',
hooks: {
'astro:server:setup': ({ server, logger }) => {
// Serve Pagefind index from previous build during development
const rootDir = server.config.root;
const indexDir = join(rootDir, 'dist', 'client');
const pagefindDir = join(indexDir, 'pagefind');
// Warn if index doesn't exist yet
if (!existsSync(pagefindDir)) {
logger.warn(
'Pagefind index not found. Run `pnpm build` first to generate '
+ 'the search index for development mode.',
);
} else {
logger.debug(`Serving Pagefind index from ${indexDir}`);
}
// Create sirv middleware to serve static files
// approach adapted from https://github.com/shishkin/astro-pagefind
const serve = sirv(indexDir, {
dev: true, // No caching in dev mode
etag: true, // Enable cache validation
});
// Mount middleware for /pagefind/* routes only
server.middlewares.use((req, res, next) => {
if (req.url?.startsWith('/pagefind/')) {
serve(req, res, next);
} else {
next();
}
});
},
'astro:build:done': async ({ dir, logger }) => {
// Determine the site directory to index
// The dir parameter already points to the correct static output directory
const siteDir = options.site || fileURLToPath(dir);
// Map Astro logger levels to Pagefind CLI flags
const logLevel = logger.options.level;
const logFlags: string[] = [];
if (logLevel === 'silent' || logLevel === 'error') {
logFlags.push('--silent');
} else if (logLevel === 'warn') {
logFlags.push('--quiet');
} else if (logLevel === 'debug') {
logFlags.push('--verbose');
}
// 'info' level uses no flag (default)
logger.info('Running Pagefind indexer...');
return new Promise<void>((resolve, reject) => {
const pagefindProcess = spawn(
'npx',
['-y', 'pagefind', ...logFlags, '--site', siteDir],
{
stdio: 'inherit',
shell: true,
},
);
pagefindProcess.on('close', (code) => {
if (code === 0) {
logger.info('Pagefind indexing complete');
resolve();
} else {
reject(new Error(`Pagefind process exited with code ${code}`));
}
});
pagefindProcess.on('error', (error) => {
reject(new Error(`Failed to start Pagefind: ${error.message}`));
});
});
},
},
};
}
+2
View File
@@ -20,6 +20,7 @@
"@astrojs/vercel": "^8.2.9",
"@base-ui-components/react": "1.0.0-beta.4",
"@nanostores/react": "^1.0.0",
"@pagefind/default-ui": "^1.4.0",
"@tailwindcss/vite": "^4.1.14",
"@videojs/html": "workspace:*",
"@videojs/react": "workspace:*",
@@ -53,6 +54,7 @@
"@vitest/ui": "^3.2.4",
"babel-plugin-react-compiler": "1.0.0",
"jsdom": "^27.0.0",
"sirv": "^3.0.2",
"vitest": "^3.2.4"
}
}
+5 -3
View File
@@ -1,12 +1,14 @@
---
interface Props {
import type { HTMLAttributes } from 'astro/types';
interface Props extends Omit<HTMLAttributes<'time'>, 'datetime'> {
date: Date;
}
const { date } = Astro.props;
const { date, ...rest } = Astro.props;
---
<time datetime={date.toISOString()}>
<time {...rest} datetime={date.toISOString()}>
{
date.toLocaleDateString('en-us', {
year: 'numeric',
+3
View File
@@ -5,6 +5,7 @@ import { ArrowUpRight } from 'lucide-react';
import Discord from '@/components/icons/Discord';
import GitHub from '@/components/icons/GitHub';
import FilmGrain from '../FilmGrain';
import Search from '@/components/Search';
export interface Props {
dark?: boolean;
@@ -41,6 +42,8 @@ const { class: className, dark = false } = Astro.props;
{/* Logo */}
<a href="/" class="flex items-center px-3 text-h5">Video.js v10</a>
<Search dark={dark} class="ml-auto mr-4 sm:self-center" />
{/* Desktop nav links */}
<div class="hidden sm:flex -mb-px pr-3">
{
+111
View File
@@ -0,0 +1,111 @@
---
/**
* We wrap Search.tsx in an .astro entry point for
* 1. easy CSS support, since we can't use Tailwind on pagefind-ui
* 2. a synchronous script to set the meta key (ctrl or cmd) without FOUC or hydration errors
*/
import SearchClient from './Search';
import { join } from 'node:path/posix';
import SearchIcon from './searchIcon.svg';
import searchIconString from './searchIcon.svg?raw';
import clsx from 'clsx';
const baseUrl = import.meta.env.BASE_URL || '/';
const bundlePath = join(baseUrl, 'pagefind/');
interface Props {
class?: string;
dark: boolean;
}
const { class: className, dark } = Astro.props;
---
<SearchClient
client:load
className={clsx(
'inline-flex items-center gap-2 cursor-pointer min-h-6 px-2 sm:border intent:border-current rounded-full',
dark ? 'border-dark-80' : 'border-light-40 dark:border-dark-80',
className,
)}
baseUrl={baseUrl}
bundlePath={bundlePath}
searchId="pagefind-ui"
searchStyle={{
'--search-icon-url': `url('data:image/svg+xml;utf8,${encodeURIComponent(searchIconString)}')`,
} as React.CSSProperties}
>
<SearchIcon class="w-4 h-4 sm:w-3 sm:h-3" />
<kbd data-platform-key="default" class="font-sans text-sm hidden sm:inline">Ctrl K</kbd>
<kbd data-platform-key="mac" class="font-sans text-sm hidden sm:inline">⌘K</kbd>
</SearchClient>
{
/**
* Adapted from https://github.com/withastro/starlight/blob/8a72a19e2cfec235941b4e1401b69b44e6695068/packages/starlight/components/Search.astro#L55C1-L75C1
* This is intentionally inlined to avoid briefly showing an invalid shortcut.
* Purposely using the deprecated `navigator.platform` property to detect Apple devices, as the
* user agent is spoofed by some browsers when opening the devtools.
*/
}
<script is:inline>
(() => {
const defaultPlatformKey = document.querySelector('kbd[data-platform-key="default"]');
const macPlatformKey = document.querySelector('kbd[data-platform-key="mac"]');
if (/Mac|iPhone|iPod|iPad/i.test(navigator.platform)) {
if (defaultPlatformKey) defaultPlatformKey.style.display = 'none';
} else {
if (macPlatformKey) macPlatformKey.style.display = 'none';
}
})();
</script>
<style is:global>
@import url('@pagefind/default-ui/css/ui.css');
#pagefind-ui {
--pagefind-ui-scale: 1;
--pagefind-ui-primary: var(--color-yellow);
--pagefind-ui-text: var(--color-dark-100);
--pagefind-ui-background: var(--color-light-100);
--pagefind-ui-border: var(--color-light-40);
--pagefind-ui-tag: green;
--pagefind-ui-border-width: 1px;
--pagefind-ui-border-radius: var(--radius-lg);
--pagefind-ui-font: inherit;
}
.dark #pagefind-ui {
--pagefind-ui-text: var(--color-light-80);
--pagefind-ui-background: var(--color-dark-100);
--pagefind-ui-border: var(--color-dark-80);
}
#pagefind-ui .pagefind-ui__form::before {
-webkit-mask-image: var(--search-icon-url);
mask-image: var(--search-icon-url);
}
#pagefind-ui .pagefind-ui__search-input {
font-size: var(--text-lg);
line-height: var(--text-lg--line-height);
letter-spacing: var(--text-lg--letter-spacing);
font-weight: var(--text-lg--font-weight);
}
#pagefind-ui .pagefind-ui__filter-block {
border-bottom: solid 1px var(--pagefind-ui-border);
}
#pagefind-ui .pagefind-ui__message {
font-weight: var(--font-weight-normal);
}
#pagefind-ui .pagefind-ui__result-title,
#pagefind-ui .pagefind-ui__filter-name {
font-weight: var(--font-weight-semibold);
}
#pagefind-ui .pagefind-ui__result-nested .pagefind-ui__result-link::before {
font-weight: var(--font-weight-normal);
font-size: 0.9em;
}
#pagefind-ui mark {
background-color: var(--color-yellow);
color: var(--color-dark-100);
}
</style>
+96
View File
@@ -0,0 +1,96 @@
import { Dialog } from '@base-ui-components/react/dialog';
import { useStore } from '@nanostores/react';
import { useEffect, useRef, useState } from 'react';
import { currentFramework, currentStyle } from '@/stores/preferences';
interface SearchProps {
children: React.ReactNode;
className?: string;
baseUrl: string;
bundlePath: string;
searchId: string;
searchStyle?: React.CSSProperties;
}
export default function Search({ children, className, baseUrl, bundlePath, searchId, searchStyle }: SearchProps) {
const [open, setOpen] = useState(false);
const framework = useStore(currentFramework);
const style = useStore(currentStyle);
const searchRef = useRef<HTMLDivElement>(null);
// load Pagefind when opened
useEffect(() => {
if (!open) return;
let search: any;
const init = async () => {
// default-ui is actually an ultra-lightweight svelte app
// ...which we're loading in this react component, in an astro app.
// this isn't relevant to dev. I just found it funny.
const { PagefindUI } = await import('@pagefind/default-ui');
// I'd love if we built our own ui with BaseUI/Autocomplete, but... time, ya know?
search = new PagefindUI({
element: `#${searchId}`,
baseUrl,
bundlePath,
showImages: false,
showSubResults: true,
autofocus: true,
});
search.triggerFilters({
framework: [framework],
style: [style],
});
};
init();
return () => {
search.destroy();
};
}, [baseUrl, bundlePath, framework, open, searchId, style]);
// open with cmd+k or ctrl+k
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setOpen(prev => !prev);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
return (
<Dialog.Root
open={open}
onOpenChange={(newOpen) => {
setOpen(newOpen);
}}
modal
>
<Dialog.Trigger
aria-label="Search"
className={className}
>
{children}
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop className="fixed inset-0 flex items-start justify-center bg-dark-110/20 backdrop-blur-xs z-50">
<Dialog.Popup
initialFocus={false} // pagefind.autofocus = true
id={searchId}
ref={searchRef}
className="w-full max-w-5xl mx-4 mb-4 p-4 rounded-3xl bg-light-80 dark:bg-dark-110 overflow-y-scroll overflow-x-hidden"
style={{
minHeight: '50svh',
maxHeight: '75svh',
marginTop: '12.5svh',
...searchStyle,
}}
/>
</Dialog.Backdrop>
</Dialog.Portal>
</Dialog.Root>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from './Search.astro';
+12
View File
@@ -0,0 +1,12 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 21l-4.34-4.34" vector-effect="non-scaling-stroke" />
<circle cx="11" cy="11" r="8" vector-effect="non-scaling-stroke" />
</svg>

After

Width:  |  Height:  |  Size: 319 B

+1 -1
View File
@@ -26,6 +26,6 @@ const shouldRender = !frameworks || frameworks.includes(framework as SupportedFr
So, while I debug those... let's do this
*/
}
<div class="contents" hidden={!shouldRender}>
<div class="contents" hidden={!shouldRender} data-pagefind-ignore={shouldRender ? undefined : 'all'}>
<slot />
</div>
@@ -0,0 +1,39 @@
import type { SupportedFramework, SupportedStyle } from '@/types/docs';
import { useStore } from '@nanostores/react';
import { useEffect } from 'react';
import { currentFramework, currentStyle } from '@/stores/preferences';
import { getPreferenceClient, setPreferenceClient } from '@/utils/docs/preferences';
/**
* PreferenceSync keeps the 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
* 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);
}
}, []);
// Sync store changes to cookies
useEffect(() => {
if (framework && style) {
setPreferenceClient(framework as SupportedFramework, style as SupportedStyle<typeof framework>);
}
}, [framework, style]);
return <></>;
}
@@ -1,6 +1,6 @@
import type { SupportedFramework, SupportedStyle } from '@/types/docs';
import { useEffect } from 'react';
import { setPreferenceClient } from '@/utils/docs/preferences';
import { currentFramework as frameworkStore, currentStyle as styleStore } from '@/stores/preferences';
interface PreferenceUpdaterProps<F extends SupportedFramework = SupportedFramework> {
currentFramework: F;
@@ -8,13 +8,15 @@ interface PreferenceUpdaterProps<F extends SupportedFramework = SupportedFramewo
}
/**
* 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.
* PreferenceUpdater component updates the preference nanostore based on URL params.
* 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.
*/
export function PreferenceUpdater<F extends SupportedFramework = SupportedFramework>({ currentFramework, currentStyle }: PreferenceUpdaterProps<F>) {
useEffect(() => {
setPreferenceClient(currentFramework, currentStyle);
frameworkStore.set(currentFramework);
styleStore.set(currentStyle);
}, [currentFramework, currentStyle]);
return <></>;
+1 -1
View File
@@ -26,6 +26,6 @@ const shouldRender = !styles || styles.includes(style as AnySupportedStyle);
So, while I debug those... let's do this
*/
}
<div class="contents" hidden={!shouldRender}>
<div class="contents" hidden={!shouldRender} data-pagefind-ignore={shouldRender ? undefined : 'all'}>
<slot />
</div>
+2
View File
@@ -10,6 +10,7 @@ import '@/styles/globals.css';
import FilmGrain from '@/components/FilmGrain';
import Posthog from '@/components/Posthog.astro';
import ThemeInit from '@/components/ThemeInit.astro';
import { PreferenceSync } from '@/components/docs/PreferenceSync';
interface Props {
title: string | string[];
@@ -95,6 +96,7 @@ const fullTitle = Array.isArray(title)
'bg-light-80 dark:bg-dark-100',
]}
>
<PreferenceSync client:load />
<FilmGrain currentPath={Astro.url.pathname} />
<slot />
</div>
+9 -9
View File
@@ -38,16 +38,13 @@ const jsonLdSchema = createBlogPostingSchema({
<Blog title={[post.data.title, 'Blog']} description={post.data.description}>
<JsonLd slot="head" schema={jsonLdSchema} />
<article class="@container">
<article class="@container" data-pagefind-body data-site="blog" data-pagefind-filter="site[data-site]">
<header class="border-b border-light-40 dark:border-dark-80 max-w-3xl mx-auto pb-10 mb-10">
<time
datetime={post.data.pubDate.toISOString()}
class="font-medium md:text-lg text-dark-40 dark:text-light-40 flex gap-4 mb-1"
>
<FormattedDate date={post.data.pubDate} />
<div class="font-medium md:text-lg text-dark-40 dark:text-light-40 flex gap-4 mb-1">
<FormattedDate data-pagefind-sort={`date:${post.data.pubDate.toISOString()}`} date={post.data.pubDate} />
<span>&bull;</span>
<span>{remarkPluginFrontmatter.minutesRead}</span>
</time>
<span data-pagefind-ignore>{remarkPluginFrontmatter.minutesRead}</span>
</div>
<h1 class="text-h4 md:text-h2 mb-2">{post.data.title}</h1>
<div class="font-medium md:text-lg text-dark-40 dark:text-light-40">
By {
@@ -59,11 +56,14 @@ const jsonLdSchema = createBlogPostingSchema({
<a
href={`/blog/authors/${authors.find((a) => a.data.name === part.value)?.id}`}
class="intent:underline inline-block"
data-pagefind-meta="author"
>
{part.value}
</a>
) : (
part.value
<span class="inline-block" data-pagefind-meta="author">
{part.value}
</span>
),
)
}
@@ -6,7 +6,7 @@ import { getCollection, render } from 'astro:content';
import DocsLayout from '@/layouts/Docs.astro';
import { ALL_FRAMEWORK_STYLE_COMBINATIONS } from '@/types/docs';
import { filterSidebar, getAllGuideSlugs, getAdjacentGuides } from '@/utils/docs/sidebar';
import { filterSidebar, getAllGuideSlugs, getAdjacentGuides, getSectionsForGuide } from '@/utils/docs/sidebar';
import defaultMarkdownComponents from '@/components/typography/defaultMarkdownComponents';
import { TableOfContents } from '@/components/docs/TableOfContents';
import H3 from '@/components/typography/H3.astro';
@@ -80,6 +80,9 @@ const { prev, next } = getAdjacentGuides(slug, framework, style);
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));
// Build JSON-LD schema for TechArticle
const pageUrl = new URL(Astro.url.pathname, Astro.site).toString();
const jsonLdSchema = createTechArticleSchema({
@@ -88,6 +91,7 @@ const jsonLdSchema = createTechArticleSchema({
url: pageUrl,
updatedDate: doc.data.updatedDate,
readingTime: remarkPluginFrontmatter.readingTimeMinutes,
articleSection: sections.length > 0 ? sections.join(' > ') : undefined,
});
---
@@ -119,7 +123,15 @@ const jsonLdSchema = createTechArticleSchema({
<TableOfContents client:idle headings={filteredHeadings} />
</div>
<div class="@container px-6 lg:px-12 flex flex-col" style="grid-area: article;">
<article class="mb-18 flex-1">
<article
class="mb-18 flex-1"
data-pagefind-body
data-framework={framework}
data-style={style}
data-site="docs"
data-category={sections[0]}
data-pagefind-filter="framework[data-framework], style[data-style], section[data-site]"
>
<header class="border-b border-light-40 dark:border-dark-80 max-w-3xl mx-auto py-8 mb-8">
<H3 as="h1" class="mt-0 mb-2">{getDocTitle(doc, framework)}</H3>
<p class="@lg:font-medium">{doc.data.description}</p>
+10
View File
@@ -0,0 +1,10 @@
import type { AnySupportedStyle, SupportedFramework } from '@/types/docs';
import { atom } from 'nanostores';
/**
* Nanostore atoms for current framework and style preferences.
* These are the runtime cache of cookie values, kept in sync by PreferenceSync.
* All React components should read from these stores for reactive updates.
*/
export const currentFramework = atom<SupportedFramework | null>(null);
export const currentStyle = atom<AnySupportedStyle | null>(null);
+10
View File
@@ -24,6 +24,16 @@
[id] {
scroll-margin-top: calc(var(--nav-h) + var(--spacing) * 5 + var(--scroll-mt, 0px));
}
/* misc */
b,
strong {
font-weight: var(--font-weight-semibold);
}
mark {
background-color: var(--color-yellow);
color: var(--color-dark-100);
}
/* Ok, now let's do some tailwind stuff */
@custom-variant intent (&:hover, &:focus-within);
@custom-variant supports-container {
@@ -5,6 +5,7 @@ import {
findFirstGuide,
findGuideBySlug,
getAllGuideSlugs,
getSectionsForGuide,
getValidStylesForGuide,
} from '../sidebar';
@@ -254,6 +255,95 @@ describe('sidebar utilities', () => {
});
});
describe('getSectionsForGuide', () => {
it('should return empty array for top-level guide', () => {
const result = getSectionsForGuide('guide-3', mockSidebar);
expect(result).toEqual([]);
});
it('should return section label for guide nested in one section', () => {
const result = getSectionsForGuide('guide-1', mockSidebar);
expect(result).toEqual(['Section 1']);
});
it('should return all ancestor section labels in order', () => {
const nestedSidebar: Sidebar = [
{
sidebarLabel: 'Parent',
contents: [
{
sidebarLabel: 'Child',
contents: [mockGuide1],
},
mockGuide2,
],
},
];
const result = getSectionsForGuide('guide-1', nestedSidebar);
expect(result).toEqual(['Parent', 'Child']);
const result2 = getSectionsForGuide('guide-2', nestedSidebar);
expect(result2).toEqual(['Parent']);
});
it('should return empty array if guide is not found', () => {
const result = getSectionsForGuide('non-existent', mockSidebar);
expect(result).toEqual([]);
});
it('should handle deeply nested guides', () => {
const deeplyNestedSidebar: Sidebar = [
{
sidebarLabel: 'Level 1',
contents: [
{
sidebarLabel: 'Level 2',
contents: [
{
sidebarLabel: 'Level 3',
contents: [mockGuide1],
},
],
},
],
},
];
const result = getSectionsForGuide('guide-1', deeplyNestedSidebar);
expect(result).toEqual(['Level 1', 'Level 2', 'Level 3']);
});
it('should return empty array for empty sidebar', () => {
const result = getSectionsForGuide('guide-1', []);
expect(result).toEqual([]);
});
it('should work with sidebar containing mixed top-level and nested guides', () => {
const mixedSidebar: Sidebar = [
mockGuide3, // top level
{
sidebarLabel: 'Getting Started',
contents: [
mockGuide1,
{
sidebarLabel: 'Advanced',
contents: [mockGuide2],
},
],
},
];
expect(getSectionsForGuide('guide-3', mixedSidebar)).toEqual([]);
expect(getSectionsForGuide('guide-1', mixedSidebar)).toEqual(['Getting Started']);
expect(getSectionsForGuide('guide-2', mixedSidebar)).toEqual(['Getting Started', 'Advanced']);
});
});
describe('getValidStylesForGuide', () => {
it('should return framework styles that guide supports', () => {
const result = getValidStylesForGuide(mockGuide1, 'html');
+19
View File
@@ -32,6 +32,25 @@ export function getPreferencesServer(cookies: AstroCookies): Preference {
* Client-side API: Works with document.cookie
*/
export function getPreferenceClient(): Preference {
if (typeof document === 'undefined') return { framework: null, style: null };
const cookies = document.cookie.split(';').reduce((acc, cookie) => {
const [key, value] = cookie.trim().split('=');
if (key) acc[key] = value;
return acc;
}, {} as Record<string, string>);
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;
}
export function setPreferenceClient<T extends SupportedFramework>(framework: T, style: SupportedStyle<T>) {
if (typeof document === 'undefined') return;
if (!isValidFramework(framework)) throw new Error(`Invalid framework: ${framework}`);
+28
View File
@@ -158,6 +158,34 @@ export function findGuideBySlug(slug: string, sidebarToSearch: Sidebar = sidebar
return null;
}
/**
* Get the ancestor section labels for a guide by its slug.
* Returns an array of sidebarLabel strings for all ancestor sections,
* in order from outermost to innermost. If the guide has no ancestors
* (i.e., it's at the top level) or if the guide is not found, returns an empty array.
*
* @param slug - The slug of the guide to find
* @param sidebarToSearch - Optional sidebar to search (defaults to main sidebar config)
* @returns An array of ancestor section labels, or empty array if none or guide not found
*/
export function getSectionsForGuide(slug: string, sidebarToSearch: Sidebar = sidebar): string[] {
function findInSidebar(items: Sidebar, path: string[]): string[] | null {
for (const item of items) {
if (isSection(item)) {
// Recursively search section contents with updated path
const result = findInSidebar(item.contents, [...path, item.sidebarLabel]);
if (result !== null) return result;
} else if (item.slug === slug) {
// Found the guide, return the accumulated path
return path;
}
}
return null;
}
return findInSidebar(sidebarToSearch, []) ?? [];
}
/**
* Get valid styles for a guide, optionally filtered by framework.
*
+2
View File
@@ -25,6 +25,7 @@ export function createTechArticleSchema(params: {
updatedDate?: Date;
wordCount?: number;
readingTime?: number;
articleSection?: string;
}): WithContext<TechArticle> {
return {
'@context': 'https://schema.org',
@@ -35,6 +36,7 @@ export function createTechArticleSchema(params: {
...(params.updatedDate && { dateModified: params.updatedDate.toISOString() }),
...(params.wordCount && { wordCount: params.wordCount }),
...(params.readingTime && { timeRequired: `PT${params.readingTime}M` }),
...(params.articleSection && { articleSection: params.articleSection }),
author: {
'@type': 'Organization',
name: 'Video.js',