diff --git a/web/app/globals.css b/web/app/globals.css index 96f8a77..6983dcd 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -493,6 +493,50 @@ main { line-height: 1; text-transform: uppercase; } +/* + * The copy-link button next to a group's chart count (GroupPermalink). A bare + * icon button with the info icon's muted-to-foreground hover treatment; the + * `--copied` state swaps in an accent-colored checkmark as confirmation. + */ +.group-permalink { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + cursor: pointer; + flex-shrink: 0; + transition: + color 0.15s ease, + background-color 0.15s ease; +} +.group-permalink:hover, +.group-permalink:focus-visible { + color: var(--fg); + background: var(--border); +} +.group-permalink--copied, +.group-permalink--copied:hover, +.group-permalink--copied:focus-visible { + color: var(--accent); +} +/* Screen-reader-only text (the permalink button's copied announcement). */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} /* * The small ⓘ next to a group title that surfaces the editorial blurb on * hover and on focus. The icon is keyboard-focusable. In the JS-free server diff --git a/web/app/page.tsx b/web/app/page.tsx index 01c6da7..86c7523 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -5,6 +5,7 @@ import { Footer } from '@/components/Footer'; import { GroupNav } from '@/components/GroupNav'; import { GroupSection } from '@/components/GroupSection'; import { Header } from '@/components/Header'; +import { groupAnchors } from '@/lib/anchor'; import { parseFilterCsv, singleSearchParam } from '@/lib/chart-format'; import { cachedFilterUniverse, cachedGroups } from '@/lib/data-cache'; @@ -42,23 +43,33 @@ export default async function Home({ const initialFormats = parseFilterCsv(singleSearchParam(params.format)); const [groups, universe] = await Promise.all([cachedGroups(), cachedFilterUniverse()]); + // Human-readable permalink anchors, one per section in render order (see + // lib/anchor.ts for why these are not the opaque API slugs). + const anchors = groupAnchors(groups.map((group) => group.name)); let nextIndex = 0; return ( <>
- ({ name: group.name, slug: group.slug }))} /> + ({ + name: group.name, + slug: group.slug, + anchor: anchors[i], + }))} + />
{groups.length === 0 ? (

No data ingested yet.

) : ( <> - {groups.map((group) => { + {groups.map((group, i) => { const startIndex = nextIndex; nextIndex += group.charts.length; return ( diff --git a/web/components/GroupNav.test.tsx b/web/components/GroupNav.test.tsx index dfb0791..142d2a7 100644 --- a/web/components/GroupNav.test.tsx +++ b/web/components/GroupNav.test.tsx @@ -3,14 +3,21 @@ // @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; import { renderToStaticMarkup } from 'react-dom/server'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { GroupNav, jumpToGroup, type GroupNavItem } from '@/components/GroupNav'; +import { + GroupNav, + jumpToGroup, + jumpToLocationHash, + type GroupNavItem, +} from '@/components/GroupNav'; const GROUPS: GroupNavItem[] = [ - { name: 'Random Access', slug: 'random_access.abc' }, - { name: 'PolarSignals Profiling', slug: 'qmg.polar' }, + { name: 'Random Access', slug: 'random_access.abc', anchor: 'random-access' }, + { name: 'PolarSignals Profiling', slug: 'qmg.polar', anchor: 'polarsignals-profiling' }, ]; describe('GroupNav markup', () => { @@ -38,10 +45,11 @@ describe('jumpToGroup', () => { function seedSections(): void { document.body.innerHTML = ` -
+
+
PolarSignals
-
+
Random Access
`; } @@ -74,4 +82,129 @@ describe('jumpToGroup', () => { expect(jumpToGroup('does-not-exist', document)).toBe(false); expect(scrollSpy).not.toHaveBeenCalled(); }); + + describe('jumpToLocationHash', () => { + afterEach(() => { + window.history.replaceState(null, '', '/'); + }); + + it('expands and scrolls to the group whose anchor id the fragment names', () => { + seedSections(); + const scrollSpy = vi.fn(); + Element.prototype.scrollIntoView = scrollSpy; + window.history.replaceState(null, '', '#random-access'); + + expect(jumpToLocationHash(document)).toBe(true); + const disclosure = document + .querySelector('#random-access') + ?.querySelector('details.group-disclosure'); + expect(disclosure?.open).toBe(true); + expect(scrollSpy).toHaveBeenCalledTimes(1); + }); + + it('falls back to the opaque-slug match for legacy fragments, decoding them', () => { + seedSections(); + const scrollSpy = vi.fn(); + Element.prototype.scrollIntoView = scrollSpy; + // Links copied before anchors became readable carried the API slug, + // percent-encoded by GroupPermalink; the jump decodes symmetrically. + window.history.replaceState(null, '', `#${encodeURIComponent('random_access.abc')}`); + + expect(jumpToLocationHash(document)).toBe(true); + const disclosure = document + .querySelector('[data-group-slug="random_access.abc"]') + ?.querySelector('details.group-disclosure'); + expect(disclosure?.open).toBe(true); + expect(scrollSpy).toHaveBeenCalledTimes(1); + }); + + it('is a no-op for an empty or unknown fragment', () => { + seedSections(); + const scrollSpy = vi.fn(); + Element.prototype.scrollIntoView = scrollSpy; + + expect(jumpToLocationHash(document)).toBe(false); + window.history.replaceState(null, '', '#does-not-exist'); + expect(jumpToLocationHash(document)).toBe(false); + expect(scrollSpy).not.toHaveBeenCalled(); + }); + + it('ignores fragments naming a non-group element id', () => { + seedSections(); + const scrollSpy = vi.fn(); + Element.prototype.scrollIntoView = scrollSpy; + window.history.replaceState(null, '', '#group-nav-panel'); + + expect(jumpToLocationHash(document)).toBe(false); + expect(scrollSpy).not.toHaveBeenCalled(); + }); + }); +}); + +describe('GroupNav jump-menu clicks', () => { + let container: HTMLElement; + let root: Root | null = null; + + afterEach(() => { + act(() => root?.unmount()); + root = null; + container.remove(); + document.body.innerHTML = ''; + window.history.replaceState(null, '', '/'); + }); + + it('expands the group, mirrors its anchor into the URL, and closes the panel', () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + document.body.innerHTML = ` +
+
Random Access
+
`; + const scrollSpy = vi.fn(); + Element.prototype.scrollIntoView = scrollSpy; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render(); + }); + + const toggle = container.querySelector('.group-nav-toggle'); + act(() => { + toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + }); + expect(container.querySelector('.group-nav-panel--open')).not.toBeNull(); + + const link = Array.from(container.querySelectorAll('.group-nav-link')).find( + (b) => b.textContent === 'Random Access', + ); + act(() => { + link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + }); + + expect(window.location.hash).toBe('#random-access'); + const disclosure = document + .querySelector('#random-access') + ?.querySelector('details.group-disclosure'); + expect(disclosure?.open).toBe(true); + expect(scrollSpy).toHaveBeenCalledTimes(1); + expect(container.querySelector('.group-nav-panel--open')).toBeNull(); + }); + + it('does not touch the URL when the jump target is missing', () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + // No sections seeded: every jump misses. + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render(); + }); + + const link = container.querySelector('.group-nav-link'); + act(() => { + link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + }); + + expect(window.location.hash).toBe(''); + }); }); diff --git a/web/components/GroupNav.tsx b/web/components/GroupNav.tsx index af48b1e..88dc7ed 100644 --- a/web/components/GroupNav.tsx +++ b/web/components/GroupNav.tsx @@ -6,12 +6,15 @@ import { useEffect, useRef, useState } from 'react'; /** - * A group entry for the jump menu: its display name and the `data-group-slug` - * carried by its landing-page `
` (see [`GroupSection`]). + * A group entry for the jump menu: its display name, the `data-group-slug` + * carried by its landing-page `
` (see [`GroupSection`]), and the + * readable permalink anchor (`lib/anchor.ts`) that section carries as its + * `id` — mirrored into the address bar when the entry is clicked. */ export interface GroupNavItem { name: string; slug: string; + anchor: string; } /** @@ -33,19 +36,61 @@ export function jumpToGroup(slug: string, doc: Document = document): boolean { if (section === undefined) { return false; } + expandAndScroll(section, doc); + return true; +} + +/** Open a group section's disclosure and scroll it into view (the shared tail + * of [`jumpToGroup`] and [`jumpToLocationHash`]). */ +function expandAndScroll(section: HTMLElement, doc: Document): void { const disclosure = section.querySelector('details.group-disclosure'); if (disclosure !== null) { disclosure.open = true; } const reduceMotion = doc.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)').matches; section.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' }); - return true; +} + +/** + * Expand and scroll to the group named by the window's URL fragment, if any. + * + * This is the landing half of the group-permalink flow: [`GroupPermalink`] + * copies `/#` (percent-encoded, decoded symmetrically here), and this + * turns that fragment back into an opened, scrolled-to group — the browser's + * native anchor scroll cannot open the `
` disclosure on its own. + * + * The fragment resolves against the section `id` (the readable anchor from + * `lib/anchor.ts`), guarded to group sections so a stray fragment naming some + * other element's id (e.g. `#group-nav-panel`) is not treated as a group. + * Fragments carrying the opaque API slug — links copied before anchors became + * readable — fall back to the [`jumpToGroup`] slug match. An unknown or absent + * fragment is a no-op. + * + * Exported so the fragment handling is unit-testable without rendering. + */ +export function jumpToLocationHash(doc: Document = document): boolean { + const hash = doc.defaultView?.location.hash ?? ''; + if (!hash.startsWith('#') || hash.length === 1) { + return false; + } + const fragment = decodeURIComponent(hash.slice(1)); + const section = doc.getElementById(fragment); + if (section !== null && section.dataset.groupSlug !== undefined) { + expandAndScroll(section, doc); + return true; + } + return jumpToGroup(fragment, doc); } /** * A left-side "Jump to group" menu: a fixed toggle button that opens a panel * listing every group, each a button that expands and scrolls to that group's - * section (via [`jumpToGroup`]) and then closes the panel. + * section (via [`jumpToGroup`]), mirrors the group's permalink anchor into + * the address bar, and then closes the panel. + * + * Also hosts the hash-jump effect: on mount and on every later `hashchange`, + * [`jumpToLocationHash`] expands and scrolls to the group a `/#` + * permalink points at. * * Toggle-driven (not hover) so it works on touch and keyboard, mirroring the * header's hamburger nav ([`Header`]): `aria-expanded` / `aria-controls` on the @@ -57,6 +102,18 @@ export function GroupNav({ groups }: { groups: GroupNavItem[] }) { const panelRef = useRef(null); const toggleRef = useRef(null); + // Land group permalinks: expand and scroll to the fragment's group on mount + // (the copied-link entry path) and on later `hashchange` (back/forward + // between fragments, or an in-page anchor click). + useEffect(() => { + jumpToLocationHash(); + const onHashChange = (): void => { + jumpToLocationHash(); + }; + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + // Close on outside click and Escape while open (the header nav's pattern). useEffect(() => { if (!open) { @@ -115,7 +172,14 @@ export function GroupNav({ groups }: { groups: GroupNavItem[] }) { className="group-nav-link" type="button" onClick={() => { - jumpToGroup(group.slug); + // Mirror the group's anchor into the address bar so a jump + // leaves a shareable URL, exactly like the header copy-link + // button. replaceState (not `location.hash =`) avoids firing + // `hashchange`, which would re-trigger the hash-jump effect + // and double-scroll. + if (jumpToGroup(group.slug)) { + window.history.replaceState(null, '', `#${encodeURIComponent(group.anchor)}`); + } setOpen(false); }} > diff --git a/web/components/GroupPermalink.test.tsx b/web/components/GroupPermalink.test.tsx new file mode 100644 index 0000000..627caeb --- /dev/null +++ b/web/components/GroupPermalink.test.tsx @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GroupPermalink, groupPermalinkUrl } from '@/components/GroupPermalink'; + +describe('groupPermalinkUrl', () => { + it('joins origin, pathname, and the readable anchor fragment', () => { + const loc = { origin: 'https://bench.vortex.dev', pathname: '/' }; + expect(groupPermalinkUrl('random-access', loc)).toBe('https://bench.vortex.dev/#random-access'); + }); + + it('percent-encodes fragment-hostile anchor characters defensively', () => { + const loc = { origin: 'https://bench.vortex.dev', pathname: '/' }; + expect(groupPermalinkUrl('a#b c', loc)).toBe('https://bench.vortex.dev/#a%23b%20c'); + }); +}); + +describe('GroupPermalink markup', () => { + it('renders a labelled icon button', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('class="group-permalink"'); + expect(html).toContain('type="button"'); + expect(html).toContain('aria-label="Copy link to Random Access"'); + // The resting state carries no copied styling. + expect(html).not.toContain('group-permalink--copied'); + }); +}); + +describe('GroupPermalink click behavior', () => { + let container: HTMLElement; + let root: Root | null = null; + let copiedText: string[]; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers(); + copiedText = []; + // jsdom has no Clipboard API; install a capturing stub. + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: (text: string) => { + copiedText.push(text); + return Promise.resolve(); + }, + }, + configurable: true, + }); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + container.remove(); + vi.useRealTimers(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (navigator as any).clipboard; + window.history.replaceState(null, '', '/'); + }); + + /** Mount the button inside a real `
`/`` host, as on the + * landing page, and return the pieces the assertions need. */ + function mount(): { button: HTMLButtonElement; details: HTMLDetailsElement } { + const details = document.createElement('details'); + const summary = document.createElement('summary'); + details.appendChild(summary); + container.appendChild(details); + root = createRoot(summary); + act(() => { + root?.render(); + }); + const button = container.querySelector('button.group-permalink'); + if (!button) { + throw new Error('permalink button did not render'); + } + return { button, details }; + } + + it('copies the permalink, mirrors the fragment, and never toggles the disclosure', () => { + const { button, details } = mount(); + + const click = new MouseEvent('click', { bubbles: true, cancelable: true }); + act(() => { + button.dispatchEvent(click); + }); + + expect(copiedText).toEqual([`${window.location.origin}/#random-access`]); + expect(window.location.hash).toBe('#random-access'); + // preventDefault suppresses the activation that would otherwise + // toggle the group open. + expect(click.defaultPrevented).toBe(true); + expect(details.open).toBe(false); + }); + + it('shows the copied state, then reverts after the feedback window', () => { + const { button } = mount(); + + act(() => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + }); + expect(button.className).toContain('group-permalink--copied'); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(button.className).not.toContain('group-permalink--copied'); + }); +}); diff --git a/web/components/GroupPermalink.tsx b/web/components/GroupPermalink.tsx new file mode 100644 index 0000000..c271641 --- /dev/null +++ b/web/components/GroupPermalink.tsx @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +/** How long the copied-confirmation state stays visible, in milliseconds. */ +const COPIED_FEEDBACK_MS = 1500; + +/** + * Build the shareable URL for a group: the landing page with the group's + * human-readable anchor (see `lib/anchor.ts`) as the fragment. + * [`GroupSection`] stamps the anchor as the section's `id`, so the fragment is + * a real anchor even without JavaScript; with JavaScript, [`GroupNav`]'s + * hash-jump effect also expands the group's disclosure on load. + * + * Anchors are already fragment-safe (`[a-z0-9-]`), but the fragment is + * percent-encoded anyway so a future anchor shape cannot silently produce an + * invalid URL; the hash-jump effect decodes symmetrically. + * + * Exported so the URL shape is unit-testable without a clipboard. + */ +export function groupPermalinkUrl( + anchor: string, + loc: Pick, +): string { + return `${loc.origin}${loc.pathname}#${encodeURIComponent(anchor)}`; +} + +/** + * The copy-link button in a group's summary header. Clicking it copies the + * group's permalink (see [`groupPermalinkUrl`]) to the clipboard, mirrors the + * fragment into the address bar via `history.replaceState` (so the URL is + * shareable even where the Clipboard API is unavailable, e.g. plain-HTTP dev + * hosts), and swaps the link glyph for a checkmark for a moment as + * confirmation. + * + * The button lives inside the disclosure's ``, where any unhandled + * click toggles the group open/closed; `preventDefault` suppresses that + * native toggle so copying a link never collapses the group. + */ +export function GroupPermalink({ anchor, groupName }: { anchor: string; groupName: string }) { + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + + // Cancel a pending feedback reset on unmount so it never fires after the + // island is gone. + useEffect( + () => () => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + } + }, + [], + ); + + return ( + + ); +} + +/** A chain-link glyph, matching the header icons' stroke style. */ +function LinkIcon() { + return ( + + ); +} + +/** The copied-confirmation checkmark. */ +function CheckIcon() { + return ( + + ); +} diff --git a/web/components/GroupSection.test.tsx b/web/components/GroupSection.test.tsx index 5da563e..992b955 100644 --- a/web/components/GroupSection.test.tsx +++ b/web/components/GroupSection.test.tsx @@ -32,11 +32,19 @@ const RANDOM_ACCESS: Group = { describe('GroupSection', () => { it('renders the group-details shell with a collapsed disclosure', () => { const html = renderToStaticMarkup( - , + , ); expect(html).toContain('class="group-details"'); expect(html).toContain('data-group-name="Random Access"'); expect(html).toContain(`data-group-slug="${RANDOM_ACCESS.slug}"`); + // The readable anchor is the section's id, the target of group + // permalinks (`/#random-access`) - NOT the opaque API slug. + expect(html).toContain('id="random-access"'); // The disclosure is collapsed by default (no `open` attribute on the tag). expect(html).toContain('
'); expect(html).toContain('Random Access'); @@ -44,16 +52,39 @@ describe('GroupSection', () => { it('renders the description info-icon and a pluralized chart count', () => { const html = renderToStaticMarkup( - , + , ); expect(html).toContain('class="group-info-icon"'); expect(html).toContain('data-tooltip="Tests selecting arbitrary row indices on NVMe"'); expect(html).toContain('2 charts'); }); + it('renders the copy-link button in the summary header', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('class="group-permalink"'); + expect(html).toContain('aria-label="Copy link to Random Access"'); + }); + it('renders the summary card inside the section', () => { const html = renderToStaticMarkup( - , + , ); expect(html).toContain('class="benchmark-scores-summary"'); expect(html).toContain('Random Access Performance'); @@ -61,7 +92,12 @@ describe('GroupSection', () => { it('renders one chart-card shell per chart with page-wide indices and permalinks', () => { const html = renderToStaticMarkup( - , + , ); expect(html).toContain('class="chart-grid"'); // First chart takes startIndex, second takes startIndex + 1. @@ -81,7 +117,7 @@ describe('GroupSection', () => { charts: [{ name: 'recall', slug: 'vs.eyJhIjoxfQ' }], }; const html = renderToStaticMarkup( - , + , ); expect(html).toContain('1 chart'); expect(html).not.toContain('class="group-info-icon"'); diff --git a/web/components/GroupSection.tsx b/web/components/GroupSection.tsx index cdd79b9..fa1d10f 100644 --- a/web/components/GroupSection.tsx +++ b/web/components/GroupSection.tsx @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors import { Chart } from '@/components/Chart'; +import { GroupPermalink } from '@/components/GroupPermalink'; import { GroupToolbar } from '@/components/GroupToolbar'; import { SummaryCard } from '@/components/SummaryCard'; import type { FilterUniverse } from '@/lib/chart-format'; @@ -29,19 +30,33 @@ import type { Group } from '@/lib/queries'; * * `startIndex` is the running page-wide chart index of this group's first * chart, so `data-chart-index` is unique across every chart on the page. + * + * The section carries `id={anchor}` — the human-readable anchor the page + * derives from the group name via `lib/anchor.ts` (`#tpc-h-nvme-sf-1`, not the + * opaque API slug) — so each group has a stable, legible URL fragment: the + * summary row's [`GroupPermalink`] button copies `/#`, the fragment + * scrolls natively even without JavaScript, and [`GroupNav`]'s hash-jump + * effect expands the disclosure on load. */ export function GroupSection({ group, + anchor, startIndex, universe, }: { group: Group; + anchor: string; startIndex: number; universe: FilterUniverse; }) { const chartCount = group.charts.length; return ( -
+
@@ -60,6 +75,7 @@ export function GroupSection({ {chartCount} chart{chartCount !== 1 ? 's' : ''} +
diff --git a/web/lib/anchor.test.ts b/web/lib/anchor.test.ts new file mode 100644 index 0000000..d81b7e0 --- /dev/null +++ b/web/lib/anchor.test.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import { describe, expect, it } from 'vitest'; + +import { groupAnchor, groupAnchors } from '@/lib/anchor'; + +describe('groupAnchor', () => { + it('slugifies real group display names into readable anchors', () => { + expect(groupAnchor('Compression')).toBe('compression'); + expect(groupAnchor('Random Access')).toBe('random-access'); + expect(groupAnchor('TPC-H (NVMe) (SF=1)')).toBe('tpc-h-nvme-sf-1'); + expect(groupAnchor('TPC-DS (NVMe) (SF=1)')).toBe('tpc-ds-nvme-sf-1'); + expect(groupAnchor('Statistical and Population Genetics')).toBe( + 'statistical-and-population-genetics', + ); + expect(groupAnchor('fineweb [nvme]')).toBe('fineweb-nvme'); + expect(groupAnchor('fineweb [s3]')).toBe('fineweb-s3'); + }); + + it('collapses punctuation runs and trims edge dashes', () => { + expect(groupAnchor(' A -- weird // name! ')).toBe('a-weird-name'); + }); + + it('never returns an empty anchor', () => { + expect(groupAnchor('***')).toBe('group'); + }); +}); + +describe('groupAnchors', () => { + it('anchors names in order and de-duplicates collisions with suffixes', () => { + expect(groupAnchors(['Random Access', 'random access', 'Random-Access!'])).toEqual([ + 'random-access', + 'random-access-2', + 'random-access-3', + ]); + }); + + it('keeps the full production group list collision-free', () => { + const names = [ + 'Compression', + 'Compression Size', + 'Clickbench', + 'TPC-H (NVMe) (SF=1)', + 'TPC-H (S3) (SF=1)', + 'TPC-H (NVMe) (SF=10)', + 'TPC-H (S3) (SF=10)', + 'TPC-H (NVMe) (SF=100)', + 'TPC-H (S3) (SF=100)', + 'TPC-DS (NVMe) (SF=1)', + 'Random Access', + 'Statistical and Population Genetics', + 'PolarSignals Profiling', + 'fineweb [nvme]', + 'fineweb [s3]', + 'appian [nvme]', + ]; + expect(groupAnchors(names)).toEqual([ + 'compression', + 'compression-size', + 'clickbench', + 'tpc-h-nvme-sf-1', + 'tpc-h-s3-sf-1', + 'tpc-h-nvme-sf-10', + 'tpc-h-s3-sf-10', + 'tpc-h-nvme-sf-100', + 'tpc-h-s3-sf-100', + 'tpc-ds-nvme-sf-1', + 'random-access', + 'statistical-and-population-genetics', + 'polarsignals-profiling', + 'fineweb-nvme', + 'fineweb-s3', + 'appian-nvme', + ]); + }); +}); diff --git a/web/lib/anchor.ts b/web/lib/anchor.ts new file mode 100644 index 0000000..15befe9 --- /dev/null +++ b/web/lib/anchor.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +/** + * Human-readable URL anchors for the landing page's group sections. + * + * Group permalinks (`/#tpc-h-nvme-sf-1`) use these anchors, NOT the opaque + * API slugs from [`./slug`]: a fragment only has to match a section `id` on + * the page, so it never needs the slug's machine-decodable base64 payload, + * and the display name — stable and unique per page — makes a far friendlier + * URL. The API keeps the opaque slug. + */ + +/** + * Derive a URL anchor from a group display name: lowercase, every run of + * non-alphanumerics collapsed to a single `-`, leading/trailing dashes + * trimmed. `TPC-H (NVMe) (SF=1)` becomes `tpc-h-nvme-sf-1`. A name with no + * alphanumerics at all falls back to `group` so the anchor is never empty. + */ +export function groupAnchor(name: string): string { + const anchor = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return anchor === '' ? 'group' : anchor; +} + +/** + * Anchor every group name on a page, de-duplicating collisions with `-2`, + * `-3`, … suffixes in encounter order. Names are unique today, but two names + * differing only in punctuation would slugify identically; suffixing keeps + * every section `id` (and thus every permalink) unambiguous rather than + * silently landing on the first collision. + */ +export function groupAnchors(names: readonly string[]): string[] { + const seen = new Set(); + return names.map((name) => { + const base = groupAnchor(name); + let anchor = base; + for (let i = 2; seen.has(anchor); i++) { + anchor = `${base}-${i}`; + } + seen.add(anchor); + return anchor; + }); +}