RND-11830: select store URL param (#4439)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brett Jephson
2026-07-30 16:05:36 +01:00
committed by GitHub
parent 332089eca9
commit f9ad9b5356
21 changed files with 1213 additions and 228 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"gitbook": patch
---
Introduce client-side content selection (`select`): a site-wide, recency-ordered list of selected slugs, persisted in localStorage and shareable via `?select=`, applied to `<html>` before first paint so the right variant renders with no flash. All variants stay server-rendered, so pages are byte-identical for every visitor (no cache impact).
Tabs now use it: switching a tab activates its slug, and every tab group offering that slug follows, across pages. Tabs no longer write to the URL fragment (`#` returns to anchors only); deep-links into a tab still activate and scroll to it.
+238
View File
@@ -0,0 +1,238 @@
import { type Page, expect, test } from '@playwright/test';
// Import the specific modules (not the package barrel) so this stays free of the `@/` path alias
// that the store pulls in — Playwright's loader doesn't resolve it.
import { SELECT_LIST_CAP, selectRankAttribute } from '../src/lib/select/constants';
import { generateSelectCSS, selectSetClassName } from '../src/lib/select/generateSelectCSS';
/**
* Behaviour tests for the `select` CSS: given a recency-ordered selection applied to `<html>`, a
* group must show exactly the most-recently-activated of its options (its "first ranking"
* selection), falling back to its default when none are active. These run in a real browser against
* the actual generated CSS, so they assert observable visibility — not how the selectors are built.
*/
/** Render a single group of option panes with the generated stylesheet. First slug = default. */
async function renderGroup(page: Page, slugs: string[]) {
const css = generateSelectCSS(slugs);
const scope = selectSetClassName(slugs);
const panes = slugs
.map(
(slug, index) =>
`<div data-testid="pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
)
.join('');
await page.setContent(
`<!doctype html><html><head><style>${css}</style></head><body><div class="${scope}" data-select-group>${panes}</div></body></html>`
);
}
/**
* Apply the recency list to `<html>` as `data-sel-*` attributes (most-recent first), via the shared
* attribute-name helper — mirroring what the pre-paint script / store do at runtime.
*/
async function applySelection(page: Page, active: string[]) {
for (const [rank, value] of active.entries()) {
await page.evaluate(
({ attr, value }) => document.documentElement.setAttribute(attr, value),
{ attr: selectRankAttribute(rank), value }
);
}
}
/** Assert exactly one pane is visible, and it is the expected slug. */
async function expectOnlyVisible(page: Page, slugs: string[], expectedSlug: string) {
for (const slug of slugs) {
const pane = page.getByTestId(`pane-${slug}`);
if (slug === expectedSlug) {
await expect(pane).toBeVisible();
} else {
await expect(pane).toBeHidden();
}
}
}
async function setup(page: Page, slugs: string[], active: string[]) {
await renderGroup(page, slugs);
await applySelection(page, active);
}
test.describe('select CSS visibility', () => {
const slugs = ['python', 'go', 'java'];
test('shows the default when nothing is selected', async ({ page }) => {
await setup(page, slugs, []);
await expectOnlyVisible(page, slugs, 'python'); // first pane is the default
});
test('shows the selected option and hides the rest', async ({ page }) => {
await setup(page, slugs, ['go']);
await expectOnlyVisible(page, slugs, 'go');
});
test('shows the most-recently-activated option of the group', async ({ page }) => {
// Recency list is most-recent-first: `go` is more recent than `python`.
await setup(page, slugs, ['go', 'python']);
await expectOnlyVisible(page, slugs, 'go');
await setup(page, slugs, ['python', 'go']);
await expectOnlyVisible(page, slugs, 'python');
});
test('ignores more-recent selections that are not in the group', async ({ page }) => {
// `dark` is more recent but not one of this group's options, so `go` still wins.
await setup(page, slugs, ['dark', 'go', 'python']);
await expectOnlyVisible(page, slugs, 'go');
});
test('falls back to the default when no active slug is in the group', async ({ page }) => {
await setup(page, slugs, ['dark', 'light']);
await expectOnlyVisible(page, slugs, 'python');
});
test('keeps symbol-bearing slugs (c / c++ / c#) distinct through the CSS selectors', async ({
page,
}) => {
// Slugs can contain `+` and `#` (see slugifySelectValue); they must survive quoted attribute
// selectors without collapsing together.
const symbols = ['c', 'c++', 'c#'];
await setup(page, symbols, ['c++']);
await expectOnlyVisible(page, symbols, 'c++');
});
test('shows only the first pane when a group repeats a slug (duplicate tab names)', async ({
page,
}) => {
// Two panes share the slug `js`; activating it must reveal only the first, never both.
const scope = selectSetClassName(['js', 'ts']);
await page.setContent(
`<!doctype html><html><head><style>${generateSelectCSS(['js', 'ts'])}</style></head><body><div class="${scope}" data-select-group><div data-testid="js-first" data-select-option="js" data-select-default>js 1</div><div data-testid="js-second" data-select-option="js">js 2</div><div data-testid="ts" data-select-option="ts">ts</div></div></body></html>`
);
await applySelection(page, ['js']);
await expect(page.getByTestId('js-first')).toBeVisible();
await expect(page.getByTestId('js-second')).toBeHidden();
await expect(page.getByTestId('ts')).toBeHidden();
});
test('a pinned pane overrides first-match (the duplicate the visitor clicked)', async ({
page,
}) => {
// The client marks the clicked pane data-select-pinned and its same-slug sibling unpinned;
// the pinned one must win over the first-match default.
const scope = selectSetClassName(['js', 'ts']);
await page.setContent(
`<!doctype html><html><head><style>${generateSelectCSS(['js', 'ts'])}</style></head><body><div class="${scope}" data-select-group><div data-testid="js-first" data-select-option="js" data-select-default data-select-unpinned>js 1</div><div data-testid="js-second" data-select-option="js" data-select-pinned>js 2</div></div></body></html>`
);
await applySelection(page, ['js']);
await expect(page.getByTestId('js-second')).toBeVisible();
await expect(page.getByTestId('js-first')).toBeHidden();
});
});
interface GroupSpec {
id: string;
slugs: string[];
}
/**
* Render several tab groups, each with clickable tab buttons wired to `__select` — an in-page
* stand-in for the store's `activate()`/`mirrorToHtml()` (whose recency/dedupe/cap logic is unit
* tested in store.test.ts). It prepends the clicked slug onto the `data-sel-*` recency list on
* `<html>`, most-recent first. This keeps the test focused on the observable behaviour a visitor
* sees — a real click switching every group that offers that option — driven by real browser CSS.
*/
async function renderGroups(page: Page, groups: GroupSpec[]) {
const styles = [
...new Map(
groups.map((group) => [selectSetClassName(group.slugs), generateSelectCSS(group.slugs)])
).values(),
]
.map((css) => `<style>${css}</style>`)
.join('');
const markup = groups
.map((group) => {
const scope = selectSetClassName(group.slugs);
const buttons = group.slugs
.map(
(slug) =>
`<button data-testid="${group.id}-btn-${slug}" onclick="__select('${slug}')">${slug}</button>`
)
.join('');
const panes = group.slugs
.map(
(slug, index) =>
`<div data-testid="${group.id}-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
)
.join('');
return `<div class="${scope}" data-select-group><div role="tablist">${buttons}</div>${panes}</div>`;
})
.join('');
const selectScript = `window.__select=function(slug){var el=document.documentElement,cur=[],i,v;for(i=0;i<${SELECT_LIST_CAP};i++){v=el.getAttribute('data-sel-'+i);if(v)cur.push(v);}var next=[slug];for(i=0;i<cur.length;i++){if(cur[i]!==slug)next.push(cur[i]);}next=next.slice(0,${SELECT_LIST_CAP});for(i=0;i<${SELECT_LIST_CAP};i++){if(next[i])el.setAttribute('data-sel-'+i,next[i]);else el.removeAttribute('data-sel-'+i);}};`;
await page.setContent(
`<!doctype html><html><head>${styles}<script>${selectScript}</script></head><body>${markup}</body></html>`
);
}
/** Assert a specific group shows exactly `expectedSlug` and hides its other options. */
async function expectGroupShows(
page: Page,
groupId: string,
slugs: string[],
expectedSlug: string
) {
for (const slug of slugs) {
const pane = page.getByTestId(`${groupId}-pane-${slug}`);
if (slug === expectedSlug) {
await expect(pane).toBeVisible();
} else {
await expect(pane).toBeHidden();
}
}
}
test.describe('select syncing across groups (click-driven)', () => {
test('clicking a tab syncs every group offering that option', async ({ page }) => {
const slugs = ['python', 'go'];
await renderGroups(page, [
{ id: 'a', slugs },
{ id: 'b', slugs },
]);
// Both groups start on their default (first) pane.
await expectGroupShows(page, 'a', slugs, 'python');
await expectGroupShows(page, 'b', slugs, 'python');
// Clicking a tab in group A switches group B too.
await page.getByTestId('a-btn-go').click();
await expectGroupShows(page, 'a', slugs, 'go');
await expectGroupShows(page, 'b', slugs, 'go');
// And the sync works from either group.
await page.getByTestId('b-btn-python').click();
await expectGroupShows(page, 'a', slugs, 'python');
await expectGroupShows(page, 'b', slugs, 'python');
});
test('only groups that share the clicked option follow along', async ({ page }) => {
const shared = ['python', 'go'];
const other = ['go', 'rust'];
await renderGroups(page, [
{ id: 'a', slugs: shared },
{ id: 'b', slugs: other },
]);
// `rust` exists only in group B, so clicking it leaves group A on its default.
await page.getByTestId('b-btn-rust').click();
await expectGroupShows(page, 'b', other, 'rust');
await expectGroupShows(page, 'a', shared, 'python');
// `go` is shared, so clicking it in A moves both groups.
await page.getByTestId('a-btn-go').click();
await expectGroupShows(page, 'a', shared, 'go');
await expectGroupShows(page, 'b', other, 'go');
});
});
+1 -1
View File
@@ -132,7 +132,7 @@
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts --project=chromium",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.spec.ts --project=chromium",
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
"e2e-browserless": "bun test ./tests/",
@@ -1,213 +1,107 @@
'use client';
import React, {
memo,
useCallback,
useMemo,
useRef,
useState,
type ComponentPropsWithRef,
} from 'react';
import type React from 'react';
import { type ComponentPropsWithRef, memo, useCallback, useMemo, useState } from 'react';
import { NavigationStatusContext, useListOverflow } from '@/components/hooks';
import { useResolvedSlug, useSelect } from '@/components/Select';
import { useListOverflow } from '@/components/hooks';
import { DropdownMenu, DropdownMenuItem } from '@/components/primitives';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
import {
SELECT_DEFAULT_ATTR,
SELECT_GROUP_ATTR,
SELECT_OPTION_ATTR,
SELECT_PINNED_ATTR,
SELECT_UNPINNED_ATTR,
} from '@/lib/select';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import { useRouter } from 'next/navigation';
interface TabsState {
activeIds: {
[tabsBlockId: string]: string;
};
activeTitles: string[];
}
const defaultTabsState: TabsState = {
activeIds: {},
activeTitles: [],
};
let globalTabsState = getLocalStorageItem('@gitbook/tabsState', defaultTabsState);
const listeners = new Set<() => void>();
function useTabsState() {
const subscribe = useCallback((callback: () => void) => {
listeners.add(callback);
return () => listeners.delete(callback);
}, []);
const getSnapshot = useCallback(() => globalTabsState, []);
const setTabsState = useCallback((updater: (previous: TabsState) => TabsState) => {
globalTabsState = updater(globalTabsState);
setLocalStorageItem('@gitbook/tabsState', globalTabsState);
listeners.forEach((listener) => listener());
}, []);
const state = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return [state, setTabsState] as const;
}
// How many titles are remembered:
const TITLES_MAX = 5;
export interface TabsItem {
id: string;
title: string;
/** The `select` slug for this tab, derived from its title (see Tabs.tsx). */
slug: string;
icon?: IconName;
body: React.ReactNode;
}
interface TabsState {
activeIds: {
[tabsBlockId: string]: string;
};
activeTitles: string[];
}
/**
* Client side component for the tabs, taking care of interactions.
*
* Pane visibility is driven entirely by CSS (see generateSelectCSS): each pane carries its slug as
* `data-select-option`, and the generated stylesheet shows the most-recently-activated one based on
* the `data-sel-*` attributes on `<html>`. That means the correct pane is visible before hydration
* (no flash) and with JS disabled.
*
* The one thing CSS can't decide is *which* of several same-named tabs in one group the visitor
* clicked — by slug they're identical, so the stylesheet falls back to the first. After an explicit
* click we pin the exact pane via `data-select-pinned`/`-unpinned` (a client-only override that
* reverts to first-match on reload). The tablist highlight follows the same resolved tab.
*/
export function DynamicTabs(props: {
id: string;
tabs: TabsItem[];
setClassName: string;
className?: string;
}) {
const { id, tabs, className } = props;
const router = useRouter();
const { tabs, setClassName, className } = props;
const { activate } = useSelect();
// The tab the visitor explicitly clicked this session (not persisted — reload reverts to CSS).
const [manualId, setManualId] = useState<string | null>(null);
const { onNavigationClick, hash } = React.useContext(NavigationStatusContext);
const [initialized, setInitialized] = useState(false);
const [tabsState, setTabsState] = useTabsState();
const activeState = useMemo(() => {
const input = { id, tabs };
return (
getTabBySelection(input, tabsState) ?? getTabByTitle(input, tabsState) ?? input.tabs[0]
);
}, [id, tabs, tabsState]);
const candidateSlugs = useMemo(() => tabs.map((tab) => tab.slug), [tabs]);
const activeSlug = useResolvedSlug(candidateSlugs, tabs[0]?.slug ?? null);
// Track if the tab has been touched by the user.
const touchedRef = useRef(false);
// Resolve which tab is shown. Default: the first tab of the active slug — matching the CSS
// first-match. A manual click pins a specific tab, but only while its slug is the active one; if
// the active slug is a duplicate and the pinned tab isn't the first of it, `override` carries the
// pin so the panes below can steer CSS past first-match.
const { activeTabId, override } = useMemo(() => {
const firstMatch = tabs.find((tab) => tab.slug === activeSlug) ?? tabs[0];
const manual = manualId ? tabs.find((tab) => tab.id === manualId) : undefined;
const pinned =
manual && manual.slug === activeSlug && manual.id !== firstMatch?.id ? manual : null;
return { activeTabId: pinned?.id ?? firstMatch?.id ?? null, override: pinned };
}, [tabs, activeSlug, manualId]);
// To avoid issue with hydration, we only use the state from localStorage
// once the component has been initialized (=mounted).
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
// and get stuck into an inconsistent state.
const active = initialized ? activeState : tabs[0];
// When clicking to select a tab, we:
// - update the URL hash
// - mark this specific ID as selected
// - store the ID to auto-select other tabs with the same title
const selectTab = useCallback(
(tabId: string, manual = true) => {
const tab = tabs.find((tab) => tab.id === tabId);
if (!tab) {
return;
(tabId: string) => {
const tab = tabs.find((item) => item.id === tabId);
if (tab?.slug) {
activate(tab.slug);
setManualId(tabId);
}
if (manual) {
touchedRef.current = true;
const href = `#${tab.id}`;
if (window.location.hash !== href) {
onNavigationClick(href);
router.replace(href, { scroll: false });
}
}
setTabsState((prev) => {
if (prev.activeIds[id] === tab.id) {
return prev;
}
return {
activeIds: {
...prev.activeIds,
[id]: tab.id,
},
activeTitles: tab.title
? prev.activeTitles
.filter((t) => t !== tab.title)
.concat([tab.title])
.slice(-TITLES_MAX)
: prev.activeTitles,
};
});
},
[router, setTabsState, tabs, id]
[tabs, activate]
);
// When the hash changes, we try to select the tab containing the targetted element.
React.useLayoutEffect(() => {
setInitialized(true);
if (hash) {
// First check if the hash matches a tab ID.
const hashIsTab = tabs.some((tab) => tab.id === hash);
if (hashIsTab) {
selectTab(hash, false);
return;
}
// Then check if the hash matches an element inside a tab.
const activeElement = document.getElementById(hash);
if (!activeElement) {
return;
}
const tabPanel = activeElement.closest('[role="tabpanel"]');
if (!tabPanel) {
return;
}
selectTab(tabPanel.id, false);
}
}, [selectTab, tabs, hash]);
// Scroll to active element in the tab.
React.useLayoutEffect(() => {
// If there is no hash or active tab, nothing to scroll.
if (!hash || hash !== '' || !active) {
return;
}
// If the tab is touched, we don't want to scroll.
if (touchedRef.current) {
return;
}
// If the hash matches a tab, then the scroll is already done.
const hashIsTab = tabs.some((tab) => tab.id === hash);
if (hashIsTab) {
return;
}
const activeElement = document.getElementById(hash);
if (!activeElement) {
return;
}
activeElement.scrollIntoView({
block: 'start',
behavior: 'instant',
});
}, [active, tabs, hash]);
return (
<div
{...{ [SELECT_GROUP_ATTR]: '' }}
className={tcls(
'rounded-lg',
'straight-corners:rounded-xs',
'ring-1 ring-tint-subtle ring-inset',
'flex min-w-0 flex-col',
setClassName,
className
)}
>
<TabItemList tabs={tabs} activeTabId={active?.id ?? null} onSelect={selectTab} />
{tabs.map((tab) => (
<TabPanel key={tab.id} tab={tab} isActive={tab.id === active?.id} />
<TabItemList tabs={tabs} activeTabId={activeTabId} onSelect={selectTab} />
{tabs.map((tab, index) => (
<TabPanel
key={tab.id}
tab={tab}
isDefault={index === 0}
pin={
override && tab.slug === override.slug
? tab.id === override.id
? 'pinned'
: 'unpinned'
: undefined
}
/>
))}
</div>
);
@@ -215,19 +109,24 @@ export function DynamicTabs(props: {
const TabPanel = memo(function TabPanel(props: {
tab: TabsItem;
isActive: boolean;
isDefault: boolean;
pin?: 'pinned' | 'unpinned';
}) {
const { tab, isActive } = props;
const { tab, isDefault, pin } = props;
return (
<div
{...{
[SELECT_OPTION_ATTR]: tab.slug,
...(isDefault ? { [SELECT_DEFAULT_ATTR]: '' } : {}),
...(pin === 'pinned' ? { [SELECT_PINNED_ATTR]: '' } : {}),
...(pin === 'unpinned' ? { [SELECT_UNPINNED_ATTR]: '' } : {}),
}}
role="tabpanel"
id={tab.id}
aria-labelledby={getTabButtonId(tab.id)}
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
>
<div className="p-4" hidden={!isActive}>
{tab.body}
</div>
<div className="p-4">{tab.body}</div>
</div>
);
});
@@ -438,42 +337,3 @@ function getTabIdFromButtonId(buttonId: string) {
}
return buttonId;
}
/**
* Get explicitly selected tab in a set of tabs.
*/
function getTabBySelection(
input: {
id: string;
tabs: TabsItem[];
},
state: TabsState
): TabsItem | null {
const activeId = state.activeIds[input.id];
return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null;
}
/**
* Get the best selected tab in a set of tabs by taking only title into account.
*/
function getTabByTitle(
input: {
id: string;
tabs: TabsItem[];
},
state: TabsState
): TabsItem | null {
return (
input.tabs
.map((item) => {
return {
item,
score: state.activeTitles.indexOf(item.title),
};
})
.filter(({ score }) => score >= 0)
// .sortBy(({ score }) => -score)
.sort(({ score: a }, { score: b }) => b - a)
.map(({ item }) => item)[0] ?? null
);
}
@@ -2,11 +2,12 @@ import type { DocumentBlockTabs } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { validateIconName } from '@gitbook/icons/icons';
import { generateSelectCSS, selectSetClassName, slugifySelectValue } from '@/lib/select';
import { tcls } from '@/lib/tailwind';
import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { DynamicTabs, type TabsItem } from './DynamicTabs';
import { DynamicTabs } from './DynamicTabs';
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
const { block, ancestorBlocks, document, style, context } = props;
@@ -15,9 +16,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
throw new Error('Tabs block is missing a key');
}
const id = block.key;
const tabs: TabsItem[] = block.nodes.map((tab) => {
const items = block.nodes.map((tab) => {
if (!tab.key) {
throw new Error('Tab block is missing a key');
}
@@ -43,12 +42,72 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
};
});
// When printing, we display the tab, one after the other
const tabs = withSelectSlugs(items);
// When printing, we display the tabs one after the other, each as its own single-tab group so
// every variant is visible (no selection to hide them).
// When printing we show every tab, one after another, so there's no selection to resolve — skip
// the generated stylesheet entirely (each single-tab group's pane is its own default and stays
// visible on its own).
if (context.mode === 'print') {
return tabs.map((tab) => {
return <DynamicTabs key={tab.id} id={id} tabs={[tab]} className={tcls(style)} />;
});
return tabs.map((tab) => (
<DynamicTabs
key={tab.id}
tabs={[tab]}
setClassName={selectSetClassName([tab.slug])}
className={tcls(style)}
/>
));
}
return <DynamicTabs id={id} tabs={tabs} className={tcls(style)} />;
const slugs = tabs.map((tab) => tab.slug);
return (
<>
<SelectGroupStyle slugs={slugs} />
<DynamicTabs
tabs={tabs}
setClassName={selectSetClassName(slugs)}
className={tcls(style)}
/>
</>
);
}
/**
* Stylesheet that resolves which pane a tab group shows, purely in CSS (see generateSelectCSS).
* Byte-identical for every visitor, so it has no cache impact.
*
* `href` + `precedence` opt into React's stylesheet hoisting: the tag is moved to `<head>` (out of
* the content flow, so sibling/child selectors like Tailwind's `space-y-*` never count it as a
* phantom node) and deduped by `href`, so identical option-sets across the page share one sheet.
*/
function SelectGroupStyle({ slugs }: { slugs: string[] }) {
const css = generateSelectCSS(slugs);
if (!css) {
return null;
}
return (
<style href={selectSetClassName(slugs)} precedence="high">
{css}
</style>
);
}
/**
* Derive a `select` slug for each tab from its title. Untitled tabs fall back to their (stable) id
* so they stay selectable.
*
* Same-named tabs deliberately share a slug — selecting one syncs every tab of that name, here and
* on other pages, which is the whole point of name-based selection. We don't disambiguate duplicates
* with a positional suffix: that would desync the duplicate and, because the slug rides in the
* frozen `?select=` URL, a shared link would silently retarget when tabs are renamed or reordered.
*/
function withSelectSlugs<T extends { id: string; title: string }>(
items: T[]
): Array<T & { slug: string }> {
return items.map((item) => ({
...item,
slug: slugifySelectValue(item.title) || slugifySelectValue(item.id) || item.id,
}));
}
@@ -40,6 +40,7 @@ import {
} from '@/lib/icons/inline';
import { defaultCustomization } from '@/lib/utils';
import { AnnouncementDismissedScript } from '../Announcement';
import { SelectStateScript } from '../Select';
import { OperatingSystemClassScript } from './OperatingSystemClassScript';
function preloadFont(fontData: FontData) {
@@ -147,6 +148,9 @@ export async function CustomizationRootLayout(props: {
<OperatingSystemClassScript />
{/* Apply the visitor's content selection to <html> before first paint (no flash) */}
<SelectStateScript />
{/* Inject custom font @font-face rules */}
{fontData.type === 'custom' ? <style>{fontData.fontFaceRules}</style> : null}
{monospaceFontData.type === 'custom' ? (
@@ -0,0 +1,67 @@
'use client';
import { SELECT_URL_PARAM, selectStore } from '@/lib/select';
import { parseAsString, useQueryState } from 'nuqs';
import type React from 'react';
import { useEffect, useLayoutEffect, useRef } from 'react';
import { useSelect } from './useSelect';
import { useSelectAnchor } from './useSelectAnchor';
// `useLayoutEffect` runs before paint but warns during SSR (effects don't run on the server anyway),
// so fall back to `useEffect` there.
const useIsomorphicLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect;
function parseSelectParam(value: string | null): string[] {
if (!value) {
return [];
}
return value
.split(',')
.map((slug) => slug.trim())
.filter(Boolean);
}
/**
* Wires the `select` store to the `?select=` URL param and hydrates it from localStorage. Mounted
* once at the site layout level (inside NuqsAdapter). Provides no React context — the store is a
* module singleton — so it simply renders its children.
*/
export function SelectProvider(props: { children: React.ReactNode }) {
const [param, setParam] = useQueryState(SELECT_URL_PARAM, parseAsString);
const { slugs } = useSelect();
// The last value we wrote to the URL, so we can tell our own writes apart from external ones.
const mirroredRef = useRef<string | null>(null);
useSelectAnchor();
// Adopt whatever the pre-paint script already merged (URL + storage) into the in-memory store.
// Layout effect so the store (and the tab highlight it drives) is settled before first paint,
// matching the `<html data-sel-*>` the pre-paint script already applied.
useIsomorphicLayoutEffect(() => {
selectStore.init();
}, []);
// URL → store: a shared link or client-side navigation carrying ?select= prepends its slugs, so
// the link wins while the visitor's other preferences survive.
useEffect(() => {
if (param === mirroredRef.current) {
return;
}
const fromUrl = parseSelectParam(param);
if (fromUrl.length > 0) {
selectStore.setSlugs([...fromUrl, ...selectStore.getState().slugs]);
}
}, [param]);
// store → URL: keep ?select= as a shareable mirror of the recency list (replaceState, no history spam).
useEffect(() => {
const desired = slugs.length > 0 ? slugs.join(',') : null;
if ((param ?? null) === desired) {
return;
}
mirroredRef.current = desired;
setParam(desired);
}, [slugs, param, setParam]);
return props.children;
}
@@ -0,0 +1,23 @@
import { SELECT_LIST_CAP, SELECT_STORAGE_KEY, SELECT_URL_PARAM } from '@/lib/select';
import { applySelectStateScript } from './script';
/**
* Inline `<head>` script that applies the visitor's `select` state to `<html>` before first paint,
* so the right content variant renders with no flash. Mounted once in the root layout head.
*/
export function SelectStateScript() {
const scriptArgs = JSON.stringify([
SELECT_STORAGE_KEY,
SELECT_URL_PARAM,
SELECT_LIST_CAP,
]).slice(1, -1);
return (
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `(${applySelectStateScript.toString()})(${scriptArgs})`,
}}
/>
);
}
@@ -0,0 +1,3 @@
export { SelectStateScript } from './SelectStateScript';
export { SelectProvider } from './SelectProvider';
export { useSelect, useResolvedSlug } from './useSelect';
@@ -0,0 +1,64 @@
/**
* Read the `select` state (URL `?select=` + localStorage) and apply it to `<html>` as `data-sel-N`
* attributes as early as possible, so the correct content variant is visible before hydration — no
* flash, and it works on cached/static HTML.
*
* NOTE: this runs in `<head>` before `<body>` exists, and is stringified and injected — so it must be
* self-contained (no imports/closures) and touch only `document.documentElement`. The attribute name
* and merge rules mirror `lib/select` (`selectRankAttribute`, the store's `normalize`); keep them in
* sync. URL slugs are prepended so a shared link wins while the visitor's other preferences survive.
*/
export function applySelectStateScript(storageKey: string, urlParam: string, cap: number) {
try {
const slugs: string[] = [];
// A Set (not a plain object) so slugs like "constructor"/"toString" aren't treated as
// already-seen via Object.prototype — matching the runtime store's dedupe.
const seen = new Set<string>();
const push = (value: string | null | undefined) => {
if (!value) {
return;
}
const slug = String(value).trim();
if (!slug || seen.has(slug) || slugs.length >= cap) {
return;
}
seen.add(slug);
slugs.push(slug);
};
const fromUrl = new URLSearchParams(window.location.search).get(urlParam);
if (fromUrl) {
const parts = fromUrl.split(',');
for (let i = 0; i < parts.length; i++) {
push(parts[i]);
}
}
const storedStr = window.localStorage.getItem(storageKey);
if (storedStr) {
const stored = JSON.parse(storedStr);
// Only trust a real array — corrupted storage (a string, or an object with `length`)
// would otherwise iterate per character/index. Matches the runtime store's handling.
if (Array.isArray(stored)) {
for (let j = 0; j < stored.length; j++) {
push(stored[j]);
}
}
}
const el = document.documentElement;
for (let rank = 0; rank < cap; rank++) {
const attribute = `data-sel-${rank}`;
const slug = slugs[rank];
if (slug) {
el.setAttribute(attribute, slug);
} else {
el.removeAttribute(attribute);
}
}
window.localStorage.setItem(storageKey, JSON.stringify(slugs));
} catch {
// localStorage blocked (private mode) or malformed state — fall through to block defaults.
}
}
@@ -0,0 +1,36 @@
'use client';
import { selectStore } from '@/lib/select';
import { useCallback, useSyncExternalStore } from 'react';
/**
* Subscribe to the site-wide `select` state. Returns the current recency list plus the setters.
* Consumers that only need "which of my options is active" should prefer {@link useResolvedSlug}.
*/
export function useSelect() {
const slugs = useSyncExternalStore(
selectStore.subscribe,
selectStore.getState,
selectStore.getState
).slugs;
return {
slugs,
activate: selectStore.activate,
deactivate: selectStore.deactivate,
};
}
/**
* Resolve which of a block's candidate slugs is active, falling back to `defaultSlug`. Recomputes
* whenever the selection changes.
*/
export function useResolvedSlug(candidateSlugs: string[], defaultSlug: string | null = null) {
// `candidateSlugs` is a fresh array each render; key on its contents to keep the snapshot stable.
const key = candidateSlugs.join(',');
const getResolved = useCallback(() => {
const candidates = key ? key.split(',') : [];
return selectStore.resolveActiveSlug(candidates) ?? defaultSlug;
}, [key, defaultSlug]);
return useSyncExternalStore(selectStore.subscribe, getResolved, getResolved);
}
@@ -0,0 +1,53 @@
'use client';
import { useHash } from '@/components/hooks';
import { SELECT_OPTION_ATTR, selectStore } from '@/lib/select';
import { useLayoutEffect } from 'react';
/**
* Make anchors work across `select` variants: when the URL points at an element inside an inactive
* pane (e.g. a heading in a non-selected tab), activate the slugs of its `data-select-option`
* ancestors so the pane becomes visible, then scroll to it after the panes reflow.
*
* Runs once globally (mounted by SelectProvider) so nested groups resolve in a single pass — the
* outermost pane is activated first, leaving the innermost (the actual target) most-recent.
*/
export function useSelectAnchor() {
const hash = useHash();
useLayoutEffect(() => {
if (!hash) {
return;
}
const target = document.getElementById(hash);
if (!target) {
return;
}
const slugs: string[] = [];
let node: Element | null = target.closest(`[${SELECT_OPTION_ATTR}]`);
while (node) {
const slug = node.getAttribute(SELECT_OPTION_ATTR);
if (slug) {
slugs.push(slug);
}
node = node.parentElement?.closest(`[${SELECT_OPTION_ATTR}]`) ?? null;
}
if (slugs.length === 0) {
return;
}
// Outermost first, so the innermost target ends up most-recent and wins in its group.
for (let i = slugs.length - 1; i >= 0; i--) {
const slug = slugs[i];
if (slug) {
selectStore.activate(slug);
}
}
requestAnimationFrame(() => {
target.scrollIntoView({ block: 'start', behavior: 'instant' });
});
}, [hash]);
}
@@ -7,6 +7,7 @@ import { NuqsAdapter } from 'nuqs/adapters/next/app';
import type React from 'react';
import { useMemo } from 'react';
import { SearchContextProvider } from '../Search';
import { SelectProvider } from '../Select';
import { useClearRouterCache } from '../hooks/useClearRouterCache';
import { LinkContext, type LinkContextType } from '../primitives';
import { isExternalLink } from '../utils/link';
@@ -62,11 +63,13 @@ export function SiteLayoutClientContexts(props: {
storageKey={themeStorageKey}
>
<NuqsAdapter>
<LinkContext.Provider value={linkContext}>
<SearchContextProvider>
<ReducedMotionProvider>{children}</ReducedMotionProvider>
</SearchContextProvider>
</LinkContext.Provider>
<SelectProvider>
<LinkContext.Provider value={linkContext}>
<SearchContextProvider>
<ReducedMotionProvider>{children}</ReducedMotionProvider>
</SearchContextProvider>
</LinkContext.Provider>
</SelectProvider>
</NuqsAdapter>
</ThemeProvider>
);
@@ -0,0 +1,50 @@
/**
* localStorage key holding the visitor's selection: a JSON array of slugs, most-recent-first.
* Not namespaced per site — a slug is just a key, so a selection ("python") is meant to follow the
* visitor across pages and spaces, exactly like the tabs store it generalizes.
*/
export const SELECT_STORAGE_KEY = '@gitbook/select';
/**
* Single query parameter carrying shareable selection state, e.g. `?select=python,cloud`
* (most-recent-first). A fixed key so author-chosen names never collide with reserved params.
*/
export const SELECT_URL_PARAM = 'select';
/**
* How many slugs are remembered, most-recent-first. This is also the depth of the CSS "rank ladder"
* (see generateSelectCSS): since pane visibility is CSS-only, the ladder must cover every stored
* rank, so the two are one knob. The generated CSS is linear in this value, so it's cheap to tune;
* 8 comfortably covers realistic stacking of distinct preferences.
*/
export const SELECT_LIST_CAP = 8;
/**
* Attribute written on `<html>` for the slug at a given recency rank, e.g. `data-sel-0="python"`.
* The pre-paint script and the store both write these; the generated CSS reads them.
*/
export function selectRankAttribute(rank: number): string {
return `data-sel-${rank}`;
}
// DOM contract applied by consumer blocks (tabs, cards, …) and read by the generated CSS.
/** Marks a group of mutually-exclusive options (e.g. a tab group). */
export const SELECT_GROUP_ATTR = 'data-select-group';
/** Carries a pane's slug, e.g. `data-select-option="python"`. */
export const SELECT_OPTION_ATTR = 'data-select-option';
/** Marks the pane shown when none of the group's slugs are active. */
export const SELECT_DEFAULT_ATTR = 'data-select-default';
/**
* Set by the client on an explicitly-clicked pane to pin it (with `data-select-unpinned` on its
* same-slug siblings), overriding the first-match default so the visitor sees exactly the duplicate
* they picked. Only applied after a real click — the pre-paint/reload path stays purely CSS-driven.
*/
export const SELECT_PINNED_ATTR = 'data-select-pinned';
export const SELECT_UNPINNED_ATTR = 'data-select-unpinned';
/**
* Class prefix (followed by a set hash) identifying a distinct candidate-set so identical sets share
* one stylesheet. Uses the `gb-` namespace like GitBook's other own classes (`gb-page-cover`, …) to
* avoid colliding with author or Tailwind classes; `sel` matches the `data-sel-*` rank attributes.
*/
export const SELECT_SET_CLASS_PREFIX = 'gb-sel-';
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'bun:test';
import { generateSelectCSS, selectSetClassName } from './generateSelectCSS';
// The actual show/hide behaviour of this CSS (most-recent option wins, others hidden, default
// fallback) is verified in a real browser in e2e/select.spec.ts. These unit tests only cover the
// pure contract of the helpers, independent of how the selectors are constructed.
describe('selectSetClassName', () => {
it('is independent of candidate order', () => {
expect(selectSetClassName(['python', 'go'])).toBe(selectSetClassName(['go', 'python']));
});
it('ignores duplicates and empty slugs', () => {
expect(selectSetClassName(['python', '', 'python', 'go'])).toBe(
selectSetClassName(['go', 'python'])
);
});
it('differs for different sets', () => {
expect(selectSetClassName(['python', 'go'])).not.toBe(selectSetClassName(['python', 'js']));
});
});
describe('generateSelectCSS', () => {
it('returns nothing for a degenerate set', () => {
expect(generateSelectCSS([])).toBe('');
expect(generateSelectCSS(['', ''])).toBe('');
});
it('scopes the generated rules to the set class', () => {
const css = generateSelectCSS(['python', 'go']);
expect(css).toContain(selectSetClassName(['python', 'go']));
});
it('keeps the safelisted symbols usable in attribute selectors', () => {
// `+` and `#` are valid inside a quoted attribute value; they must appear verbatim.
const css = generateSelectCSS(['c++', 'c#']);
expect(css).toContain('[data-select-option="c++"]');
expect(css).toContain('[data-select-option="c#"]');
});
it('escapes CSS-string metacharacters so a widened charset stays well-formed', () => {
// slugifySelectValue can't produce these today; this guards future widening.
const css = generateSelectCSS(['a"b', 'c\\d']);
expect(css).toContain('[data-select-option="a\\"b"]');
expect(css).toContain('[data-select-option="c\\\\d"]');
// The raw, unescaped quote must never leak into the stylesheet.
expect(css).not.toContain('="a"b"');
});
});
@@ -0,0 +1,125 @@
import {
SELECT_DEFAULT_ATTR,
SELECT_LIST_CAP,
SELECT_OPTION_ATTR,
SELECT_PINNED_ATTR,
SELECT_SET_CLASS_PREFIX,
SELECT_UNPINNED_ATTR,
selectRankAttribute,
} from './constants';
// FNV-1a (32-bit) constants — see https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
const FNV_OFFSET_BASIS_32 = 0x811c9dc5;
const FNV_PRIME_32 = 0x01000193;
/**
* Stable, order-independent hash of a candidate set, so two groups offering the same options
* (e.g. `npm`/`yarn`/`pnpm` repeated across a docs site) share one generated stylesheet.
*/
function hashSlugSet(slugs: string[]): string {
const key = [...slugs].sort().join(' ');
// FNV-1a: deterministic and dependency-free. Collision risk is irrelevant here since a clash
// only means two identical-looking sets share CSS, which is exactly what we want anyway.
let hash = FNV_OFFSET_BASIS_32;
for (let i = 0; i < key.length; i++) {
hash ^= key.charCodeAt(i);
hash = Math.imul(hash, FNV_PRIME_32);
}
return (hash >>> 0).toString(36);
}
/**
* The class a consumer puts on a group element to scope the generated CSS to it. Keyed on the
* candidate set (not its order), matching {@link generateSelectCSS}.
*/
export function selectSetClassName(candidateSlugs: string[]): string {
return `${SELECT_SET_CLASS_PREFIX}${hashSlugSet(uniqueSlugs(candidateSlugs))}`;
}
function uniqueSlugs(slugs: string[]): string[] {
return [...new Set(slugs.filter(Boolean))];
}
/**
* Escape a slug for interpolation into a CSS string literal (a quoted attribute-selector value).
* `slugifySelectValue` can't currently produce `"` or `\`, so this is defensive — it keeps the
* generated CSS well-formed if the slug charset is ever widened.
*/
function escapeCssString(value: string): string {
return value.replace(/["\\]/g, '\\$&');
}
/**
* Generate the CSS that makes a group show the most-recently-activated of its options — the same
* rule as `resolveActiveSlug`, expressed purely in CSS so it works before hydration and with
* JavaScript disabled.
*
* CSS can't compare two dynamic attributes, but it can compare a dynamic `<html>` rank attribute
* (`data-sel-i`, written by the pre-paint script/store) against the set's slugs, which the server
* knows as literals. We encode the "most recent wins" priority in **source order** rather than in
* selector specificity: rank rules are emitted worst-first (highest rank down to rank 0), so the
* most-recent match appears last and wins the cascade among these equal-specificity rules. At each
* rank one rule hides the group's panes and the next reveals whichever option sits there, so a lower
* (more recent) rank cleanly overrides a higher one. When no option of the set is active anywhere,
* the default pane shows.
*
* A single hide isn't possible: a group can have several of its own options active at once, so each
* rank must re-hide to let the most recent win. But the output stays compact via two modern-CSS
* levers (both within our Tailwind v4 browser baseline): the whole thing nests under the set's scope
* class (`&`) so it isn't repeated in every selector, and the per-rank hide-all — which is
* uncorrelated ("hide the group if *any* of its slugs is at this rank") — folds into one `:is()`
* selector. The per-rank show can't fold that way (each entry correlates rank-value → option-value,
* which `:is()` can't express), so it stays a comma list. Result: `2·depth + 2` rules, no `:not()`
* chains. `depth` must cover every rank the store can produce — visibility is CSS-only, so a winner
* beyond `depth` would fall back to its default — hence it defaults to {@link SELECT_LIST_CAP}.
*
* Returns `''` for an empty/degenerate set.
*/
export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_CAP): string {
const slugs = uniqueSlugs(candidateSlugs);
if (slugs.length === 0) {
return '';
}
const option = `[${SELECT_OPTION_ATTR}]`;
// All rules nest under the scope class; `&` stands in for it (see nesting note above).
const rules: string[] = [
// Hide every option, then reveal the default. Both are overridden below when a slug is active.
`${option}{display:none}`,
`[${SELECT_DEFAULT_ATTR}]{display:block}`,
];
for (let rank = depth - 1; rank >= 0; rank--) {
const attr = selectRankAttribute(rank);
const anyAtRank = slugs.map((slug) => `[${attr}="${escapeCssString(slug)}"]`).join(',');
// When any of the set's options sits at this rank, hide the group's panes...
rules.push(`html:is(${anyAtRank}) & ${option}{display:none}`);
// ...then reveal whichever one matches (correlated, so a per-option list).
const show = slugs
.map((slug) => {
const value = escapeCssString(slug);
return `html[${attr}="${value}"] & [${SELECT_OPTION_ATTR}="${value}"]`;
})
.join(',');
rules.push(`${show}{display:block}`);
}
// Duplicate tab names in one group would otherwise reveal two panes at once. Keep only the first:
// hide any option pane preceded by a same-slug sibling. Emitted last and prefixed with `html` so
// it beats the show rules above (equal specificity, later source order). The slug stays shared, so
// syncing and the `?select=` URL are unaffected — only the second pane's visibility changes.
for (const slug of slugs) {
const value = escapeCssString(slug);
const pane = `[${SELECT_OPTION_ATTR}="${value}"]`;
rules.push(`html & ${pane} ~ ${pane}{display:none}`);
}
// A client click can override that first-match default: it pins the picked pane and unpins its
// same-slug siblings so the visitor sees exactly the duplicate they clicked (reload reverts to
// first-match since these attributes aren't persisted). Emitted last to win at equal specificity.
rules.push(`html & ${option}[${SELECT_PINNED_ATTR}]{display:block}`);
rules.push(`html & ${option}[${SELECT_UNPINNED_ATTR}]{display:none}`);
return `.${selectSetClassName(slugs)}{${rules.join('')}}`;
}
+5
View File
@@ -0,0 +1,5 @@
export * from './constants';
export * from './slug';
export * from './generateSelectCSS';
export * as selectStore from './store';
export type { SelectState } from './store';
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'bun:test';
import { SLUG_MAX_CODE_POINTS, slugifySelectValue } from './slug';
describe('slugifySelectValue', () => {
// This table IS the frozen public contract for `?select=` URLs — see SLUG_ALGO_VERSION.
// Changing any expectation here is a breaking change to already-shared links.
const cases: Array<[input: string, expected: string]> = [
['Python', 'python'],
['JavaScript', 'javascript'],
['JS', 'js'], // note: does NOT equal "javascript" — the near-duplicate lint case
// Technical symbols in the safelist keep otherwise-colliding names distinct.
['C', 'c'],
['C++', 'c++'],
['C#', 'c#'],
['.NET', '.net'],
['Node.js', 'node.js'],
['on_prem', 'on_prem'],
// Letters/numbers/marks from every script survive.
['café', 'café'],
['naïve', 'naïve'],
['安装', '安装'],
['日本語', '日本語'],
['Ελληνικά', 'ελληνικά'],
// Whitespace and other symbols collapse to single dashes and trim.
[' npm ', 'npm'],
['Two Words', 'two-words'],
['on-prem', 'on-prem'],
['On-Prem', 'on-prem'],
['🚀 Launch', 'launch'],
['', ''],
['---', ''],
['🚀', ''],
];
for (const [input, expected] of cases) {
it(`${JSON.stringify(input)}${JSON.stringify(expected)}`, () => {
expect(slugifySelectValue(input)).toBe(expected);
});
}
it('drops control/format characters instead of turning them into dashes', () => {
const zeroWidthSpace = String.fromCodePoint(0x200b);
const nul = String.fromCodePoint(0);
expect(slugifySelectValue(`a${zeroWidthSpace}b`)).toBe('ab');
expect(slugifySelectValue(`a${nul}b`)).toBe('ab');
});
it('never produces the reserved comma delimiter', () => {
expect(slugifySelectValue('a, b, c')).not.toContain(',');
});
describe('length cap', () => {
it('caps to SLUG_MAX_CODE_POINTS code points', () => {
expect(slugifySelectValue('a'.repeat(200))).toHaveLength(SLUG_MAX_CODE_POINTS);
});
it('counts code points, not UTF-16 units, so astral chars are not cut in half', () => {
const astral = '𠀀'; // U+20000, a CJK Extension B ideograph = one surrogate pair
const result = slugifySelectValue(astral.repeat(200));
expect([...result]).toHaveLength(SLUG_MAX_CODE_POINTS);
expect(result).toBe(astral.repeat(SLUG_MAX_CODE_POINTS));
});
});
it('is idempotent', () => {
for (const [input] of cases) {
const once = slugifySelectValue(input);
expect(slugifySelectValue(once)).toBe(once);
}
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Version of the slugification algorithm below.
*
* The slugs it produces are the keys that sync content across the site AND the values that appear
* in the public `?select=` URL parameter. Once those URLs are in the wild the algorithm is frozen:
* changing it would silently re-resolve links people have already shared. Any future change must
* bump this version and be gated behind it, never applied in place.
*/
export const SLUG_ALGO_VERSION = 1;
/**
* Maximum slug length, counted in code points (not UTF-16 units, so we never cleave a surrogate
* pair). Guards against pathological titles — 30 CJK characters is already ~270 bytes once
* percent-encoded into `?select=`. Part of the frozen contract (see {@link SLUG_ALGO_VERSION}).
*/
export const SLUG_MAX_CODE_POINTS = 64;
/**
* Turn an author-typed name (a tab title, button label, picker option…) into a `select` slug.
*
* DO NOT CHANGE — this is the frozen public `?select=` URL contract (see {@link SLUG_ALGO_VERSION}).
* The output must be byte-identical on the server (baking slugs into markup/CSS) and the client
* (parsing URLs/storage), so it relies only on locale-independent primitives: Unicode NFKC
* normalization + `String.prototype.toLowerCase` (Unicode default case folding, not locale-sensitive).
*
* It keeps letters, numbers and marks from every script (so `café`, `安装`, `日本語` survive) plus a
* small safelist of symbols — `+ # . _` — that distinguish technical names that would otherwise
* collide (`c` vs `c++` vs `c#`, `node.js`, `on_prem`). Every other run of characters collapses to a
* single `-`, and leading/trailing `-` are trimmed. A slug can never contain the `,` that delimits
* `?select=`, and none of these characters need escaping in a URL-encoded query param — but the
* safelist widens the set beyond bare word characters, so consumers that interpolate a slug into
* another syntax must still escape for it (see the CSS escaping in generateSelectCSS).
*
* Control, format, bidi and lone-surrogate characters (`\p{C}`) are dropped outright rather than
* turned into a `-`, and the string is re-normalized after `toLowerCase` (case mapping can leave it
* un-normalized), so the same visible name always yields the same bytes on server and client. The
* result is capped to {@link SLUG_MAX_CODE_POINTS} code points. Names that reduce to nothing (e.g. an
* emoji-only title) return `''`; callers treat an empty slug as "no slug" and fall back to default.
*/
export function slugifySelectValue(name: string): string {
const slug = name
.normalize('NFKC')
.replace(/\p{C}+/gu, '')
.toLowerCase()
.normalize('NFKC')
.replace(/[^\p{L}\p{N}\p{M}+#._]+/gu, '-')
.replace(/^-+|-+$/gu, '');
// Slice by code point so a surrogate pair (e.g. astral CJK) is never cut in half, then re-trim a
// trailing `-` the cut may have exposed.
return [...slug].slice(0, SLUG_MAX_CODE_POINTS).join('').replace(/-+$/u, '');
}
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it } from 'bun:test';
import { SELECT_LIST_CAP } from './constants';
import { activate, deactivate, getState, resolveActiveSlug, setSlugs, subscribe } from './store';
beforeEach(() => {
setSlugs([]);
});
describe('select store', () => {
it('activates slugs most-recent-first', () => {
activate('python');
activate('cloud');
expect(getState().slugs).toEqual(['cloud', 'python']);
});
it('moves an already-active slug back to the front', () => {
setSlugs(['go', 'python', 'cloud']);
activate('cloud');
expect(getState().slugs).toEqual(['cloud', 'go', 'python']);
});
it('dedupes and drops empty slugs', () => {
setSlugs(['python', '', 'python', 'go']);
expect(getState().slugs).toEqual(['python', 'go']);
});
it('caps the list and evicts the oldest', () => {
const many = Array.from({ length: SELECT_LIST_CAP + 5 }, (_, i) => `s${i}`);
setSlugs(many);
expect(getState().slugs).toHaveLength(SELECT_LIST_CAP);
expect(getState().slugs).toEqual(many.slice(0, SELECT_LIST_CAP));
});
it('deactivates a slug', () => {
setSlugs(['python', 'cloud']);
deactivate('python');
expect(getState().slugs).toEqual(['cloud']);
});
describe('resolveActiveSlug', () => {
it('returns the most recently active candidate', () => {
setSlugs(['cloud', 'python', 'go']);
expect(resolveActiveSlug(['go', 'python'])).toBe('python');
});
it('returns null when no candidate is active', () => {
setSlugs(['cloud']);
expect(resolveActiveSlug(['python', 'go'])).toBeNull();
});
});
describe('change notifications', () => {
it('notifies subscribers on a real change', () => {
let calls = 0;
const unsubscribe = subscribe(() => {
calls++;
});
activate('python');
expect(calls).toBe(1);
unsubscribe();
});
it('does not notify when the list is unchanged (prevents URL mirror loops)', () => {
setSlugs(['python', 'go']);
let calls = 0;
const unsubscribe = subscribe(() => {
calls++;
});
setSlugs(['python', 'go']);
activate('python'); // already at front → no change
expect(calls).toBe(0);
unsubscribe();
});
});
});
+141
View File
@@ -0,0 +1,141 @@
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
import { SELECT_LIST_CAP, SELECT_STORAGE_KEY, selectRankAttribute } from './constants';
/**
* The one piece of `select` state: a site-wide, recency-ordered list of active slugs
* (most-recent-first, deduped, capped). Setters `activate`/`deactivate` slugs; consumers read the
* list through `resolveActiveSlug`. Everything is client-side — the store is never rendered into
* SSR HTML, so pages stay byte-identical for every visitor.
*/
export interface SelectState {
slugs: string[];
}
let state: SelectState = { slugs: [] };
const listeners = new Set<() => void>();
let initialized = false;
/** Current selection. Server-side this is always the empty list. */
export function getState(): SelectState {
return state;
}
/** Subscribe to selection changes. Returns an unsubscribe function. */
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** Dedupe, drop empties, and cap to the most-recent {@link SELECT_LIST_CAP} slugs. */
function normalize(slugs: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const slug of slugs) {
if (!slug || seen.has(slug)) {
continue;
}
seen.add(slug);
out.push(slug);
if (out.length >= SELECT_LIST_CAP) {
break;
}
}
return out;
}
function sameList(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((slug, index) => slug === b[index]);
}
function commit(nextSlugs: string[]) {
const slugs = normalize(nextSlugs);
// No-op when nothing changed — this is what keeps the store⇄URL mirror from looping.
if (sameList(slugs, state.slugs)) {
return;
}
state = { slugs };
setLocalStorageItem(SELECT_STORAGE_KEY, slugs);
mirrorToHtml(slugs);
for (const listener of listeners) {
listener();
}
}
/** Move a slug to the front (most-recent). No-op for an empty slug. */
export function activate(slug: string) {
if (!slug) {
return;
}
commit([slug, ...state.slugs]);
}
/** Remove a slug from the list (used by filters). */
export function deactivate(slug: string) {
if (!slug) {
return;
}
commit(state.slugs.filter((s) => s !== slug));
}
/** Replace the whole list (used when hydrating from URL + storage). */
export function setSlugs(slugs: string[]) {
commit(slugs);
}
/**
* The single shared resolution rule every consumer uses: given the slugs a block contains, return
* the one that is most recently active (smallest index), or `null` so the caller can fall back to
* its own default.
*/
export function resolveActiveSlug(candidates: string[]): string | null {
let best: string | null = null;
let bestIndex = Number.POSITIVE_INFINITY;
for (const candidate of candidates) {
const index = state.slugs.indexOf(candidate);
if (index >= 0 && index < bestIndex) {
bestIndex = index;
best = candidate;
}
}
return best;
}
/**
* Hydrate the in-memory store from localStorage (once per full page load). The pre-paint script has
* already merged `?select=` into storage and written `<html>` before this runs, so we just adopt it;
* we re-mirror to `<html>` too, to stay correct after a client-side navigation.
*/
export function init() {
if (initialized) {
return;
}
initialized = true;
const stored = getLocalStorageItem<string[]>(SELECT_STORAGE_KEY, []);
state = { slugs: normalize(Array.isArray(stored) ? stored : []) };
mirrorToHtml(state.slugs);
for (const listener of listeners) {
listener();
}
}
/**
* Write the recency list onto `<html>` as `data-sel-0…N` attributes — the same attributes the
* pre-paint script writes — so runtime updates re-drive the exact CSS that handled the first paint.
*/
function mirrorToHtml(slugs: string[]) {
if (typeof document === 'undefined') {
return;
}
const el = document.documentElement;
for (let rank = 0; rank < SELECT_LIST_CAP; rank++) {
const attribute = selectRankAttribute(rank);
const slug = slugs[rank];
if (slug) {
el.setAttribute(attribute, slug);
} else {
el.removeAttribute(attribute);
}
}
}