diff --git a/.changeset/fancy-webs-sneeze.md b/.changeset/fancy-webs-sneeze.md
new file mode 100644
index 000000000..54ccc16cc
--- /dev/null
+++ b/.changeset/fancy-webs-sneeze.md
@@ -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 `` 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.
diff --git a/packages/gitbook/e2e/select.spec.ts b/packages/gitbook/e2e/select.spec.ts
new file mode 100644
index 000000000..1417f87e0
--- /dev/null
+++ b/packages/gitbook/e2e/select.spec.ts
@@ -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 ``, 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) =>
+ `
${slug}
`
+ )
+ .join('');
+
+ await page.setContent(
+ `
${panes}
`
+ );
+}
+
+/**
+ * Apply the recency list to `` 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(
+ `
js 1
js 2
ts
`
+ );
+ 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(
+ `
js 1
js 2
`
+ );
+ 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
+ * ``, 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) => ``)
+ .join('');
+
+ const markup = groups
+ .map((group) => {
+ const scope = selectSetClassName(group.slugs);
+ const buttons = group.slugs
+ .map(
+ (slug) =>
+ ``
+ )
+ .join('');
+ const panes = group.slugs
+ .map(
+ (slug, index) =>
+ `
${slug}
`
+ )
+ .join('');
+ return `
${buttons}
${panes}
`;
+ })
+ .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${styles}${markup}`
+ );
+}
+
+/** 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');
+ });
+});
diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json
index 71fa52924..8a4c28b6e 100644
--- a/packages/gitbook/package.json
+++ b/packages/gitbook/package.json
@@ -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/",
diff --git a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx
index 6ba79f951..3702c755b 100644
--- a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx
+++ b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx
@@ -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 ``. 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(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 (
);
});
@@ -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
- );
-}
diff --git a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx
index 4151d0e9e..5308f586b 100644
--- a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx
+++ b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx
@@ -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) {
const { block, ancestorBlocks, document, style, context } = props;
@@ -15,9 +16,7 @@ export function Tabs(props: BlockProps) {
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) {
};
});
- // 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 ;
- });
+ return tabs.map((tab) => (
+
+ ));
}
- return ;
+ const slugs = tabs.map((tab) => tab.slug);
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+/**
+ * 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 `` (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 (
+
+ );
+}
+
+/**
+ * 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(
+ items: T[]
+): Array {
+ return items.map((item) => ({
+ ...item,
+ slug: slugifySelectValue(item.title) || slugifySelectValue(item.id) || item.id,
+ }));
}
diff --git a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx
index ee6e74323..bf7878295 100644
--- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx
+++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx
@@ -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: {
+ {/* Apply the visitor's content selection to before first paint (no flash) */}
+
+
{/* Inject custom font @font-face rules */}
{fontData.type === 'custom' ? : null}
{monospaceFontData.type === 'custom' ? (
diff --git a/packages/gitbook/src/components/Select/SelectProvider.tsx b/packages/gitbook/src/components/Select/SelectProvider.tsx
new file mode 100644
index 000000000..d614b2ab9
--- /dev/null
+++ b/packages/gitbook/src/components/Select/SelectProvider.tsx
@@ -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(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 `` 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;
+}
diff --git a/packages/gitbook/src/components/Select/SelectStateScript.tsx b/packages/gitbook/src/components/Select/SelectStateScript.tsx
new file mode 100644
index 000000000..d30b9451d
--- /dev/null
+++ b/packages/gitbook/src/components/Select/SelectStateScript.tsx
@@ -0,0 +1,23 @@
+import { SELECT_LIST_CAP, SELECT_STORAGE_KEY, SELECT_URL_PARAM } from '@/lib/select';
+import { applySelectStateScript } from './script';
+
+/**
+ * Inline `` script that applies the visitor's `select` state to `` 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 (
+
+ );
+}
diff --git a/packages/gitbook/src/components/Select/index.ts b/packages/gitbook/src/components/Select/index.ts
new file mode 100644
index 000000000..de8582f1a
--- /dev/null
+++ b/packages/gitbook/src/components/Select/index.ts
@@ -0,0 +1,3 @@
+export { SelectStateScript } from './SelectStateScript';
+export { SelectProvider } from './SelectProvider';
+export { useSelect, useResolvedSlug } from './useSelect';
diff --git a/packages/gitbook/src/components/Select/script.ts b/packages/gitbook/src/components/Select/script.ts
new file mode 100644
index 000000000..53d8f9d44
--- /dev/null
+++ b/packages/gitbook/src/components/Select/script.ts
@@ -0,0 +1,64 @@
+/**
+ * Read the `select` state (URL `?select=` + localStorage) and apply it to `` 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 `` before `` 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();
+ 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.
+ }
+}
diff --git a/packages/gitbook/src/components/Select/useSelect.ts b/packages/gitbook/src/components/Select/useSelect.ts
new file mode 100644
index 000000000..841fb3f0f
--- /dev/null
+++ b/packages/gitbook/src/components/Select/useSelect.ts
@@ -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);
+}
diff --git a/packages/gitbook/src/components/Select/useSelectAnchor.ts b/packages/gitbook/src/components/Select/useSelectAnchor.ts
new file mode 100644
index 000000000..9f3f83df9
--- /dev/null
+++ b/packages/gitbook/src/components/Select/useSelectAnchor.ts
@@ -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]);
+}
diff --git a/packages/gitbook/src/components/SiteLayout/SiteLayoutClientContexts.tsx b/packages/gitbook/src/components/SiteLayout/SiteLayoutClientContexts.tsx
index 0e020075c..4332f3d11 100644
--- a/packages/gitbook/src/components/SiteLayout/SiteLayoutClientContexts.tsx
+++ b/packages/gitbook/src/components/SiteLayout/SiteLayoutClientContexts.tsx
@@ -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}
>
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
);
diff --git a/packages/gitbook/src/lib/select/constants.ts b/packages/gitbook/src/lib/select/constants.ts
new file mode 100644
index 000000000..4df966fbd
--- /dev/null
+++ b/packages/gitbook/src/lib/select/constants.ts
@@ -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 `` 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-';
diff --git a/packages/gitbook/src/lib/select/generateSelectCSS.test.ts b/packages/gitbook/src/lib/select/generateSelectCSS.test.ts
new file mode 100644
index 000000000..06aa2457f
--- /dev/null
+++ b/packages/gitbook/src/lib/select/generateSelectCSS.test.ts
@@ -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"');
+ });
+});
diff --git a/packages/gitbook/src/lib/select/generateSelectCSS.ts b/packages/gitbook/src/lib/select/generateSelectCSS.ts
new file mode 100644
index 000000000..2854080ce
--- /dev/null
+++ b/packages/gitbook/src/lib/select/generateSelectCSS.ts
@@ -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 `` 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('')}}`;
+}
diff --git a/packages/gitbook/src/lib/select/index.ts b/packages/gitbook/src/lib/select/index.ts
new file mode 100644
index 000000000..c7cd1d425
--- /dev/null
+++ b/packages/gitbook/src/lib/select/index.ts
@@ -0,0 +1,5 @@
+export * from './constants';
+export * from './slug';
+export * from './generateSelectCSS';
+export * as selectStore from './store';
+export type { SelectState } from './store';
diff --git a/packages/gitbook/src/lib/select/slug.test.ts b/packages/gitbook/src/lib/select/slug.test.ts
new file mode 100644
index 000000000..b76c67fb5
--- /dev/null
+++ b/packages/gitbook/src/lib/select/slug.test.ts
@@ -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);
+ }
+ });
+});
diff --git a/packages/gitbook/src/lib/select/slug.ts b/packages/gitbook/src/lib/select/slug.ts
new file mode 100644
index 000000000..0518b4077
--- /dev/null
+++ b/packages/gitbook/src/lib/select/slug.ts
@@ -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, '');
+}
diff --git a/packages/gitbook/src/lib/select/store.test.ts b/packages/gitbook/src/lib/select/store.test.ts
new file mode 100644
index 000000000..c1b8f68c0
--- /dev/null
+++ b/packages/gitbook/src/lib/select/store.test.ts
@@ -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();
+ });
+ });
+});
diff --git a/packages/gitbook/src/lib/select/store.ts b/packages/gitbook/src/lib/select/store.ts
new file mode 100644
index 000000000..2cfde7756
--- /dev/null
+++ b/packages/gitbook/src/lib/select/store.ts
@@ -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();
+ 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 `` before this runs, so we just adopt it;
+ * we re-mirror to `` too, to stay correct after a client-side navigation.
+ */
+export function init() {
+ if (initialized) {
+ return;
+ }
+ initialized = true;
+ const stored = getLocalStorageItem(SELECT_STORAGE_KEY, []);
+ state = { slugs: normalize(Array.isArray(stored) ? stored : []) };
+ mirrorToHtml(state.slugs);
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+/**
+ * Write the recency list onto `` 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);
+ }
+ }
+}