From 8f7fb1dca3ae9d3940359f8a111d9eb1988ca901 Mon Sep 17 00:00:00 2001 From: bryantgillespie Date: Thu, 11 Jun 2026 17:56:41 -0400 Subject: [PATCH 1/2] Fix search result header navigation --- app/router.options.ts | 15 +++++++++- package.json | 1 + pnpm-lock.yaml | 3 ++ scripts/index-docs-chunker.ts | 13 +++++++-- tests/router.options.test.ts | 37 ++++++++++++++++++++++++ tests/scripts/index-docs-chunker.test.ts | 18 ++++++++++++ 6 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 tests/router.options.test.ts diff --git a/app/router.options.ts b/app/router.options.ts index fac292b1..a56051c9 100644 --- a/app/router.options.ts +++ b/app/router.options.ts @@ -1,4 +1,5 @@ import type { RouterConfig } from '@nuxt/schema'; +import { useMutationObserver } from '@vueuse/core'; import { nextTick } from 'vue'; export const scrollPositions = new Map(); @@ -21,6 +22,18 @@ function scrollToHash(scroller: HTMLElement | null, hash: string): boolean { return true; } +function scrollToHashWhenReady(scroller: HTMLElement | null, hash: string) { + if (scrollToHash(scroller, hash)) return; + + const root = scroller ?? document.body; + const { stop } = useMutationObserver(root, () => { + if (window.location.hash !== hash || !scrollToHash(scroller, hash)) return; + stop(); + window.clearTimeout(timeout); + }, { childList: true, subtree: true }); + const timeout = window.setTimeout(stop, 3000); +} + export default { scrollBehavior: async (to, from, savedPosition) => { const scroller = document.getElementById('docs-scroll'); @@ -36,7 +49,7 @@ export default { if (to.hash) { await nextTick(); - requestAnimationFrame(() => scrollToHash(scroller, to.hash)); + scrollToHashWhenReady(scroller, to.hash); return false; } diff --git a/package.json b/package.json index af748813..0c070716 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@types/node": "^22", "@vue/test-utils": "^2.4.10", "dotenv": "^17.4.2", + "github-slugger": "2.0.0", "gray-matter": "^4.0.3", "happy-dom": "^20.9.0", "js-yaml": "^4.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b52d000..2e6b3070 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: dotenv: specifier: ^17.4.2 version: 17.4.2 + github-slugger: + specifier: 2.0.0 + version: 2.0.0 gray-matter: specifier: ^4.0.3 version: 4.0.3 diff --git a/scripts/index-docs-chunker.ts b/scripts/index-docs-chunker.ts index 924090fc..c029e9b3 100644 --- a/scripts/index-docs-chunker.ts +++ b/scripts/index-docs-chunker.ts @@ -1,12 +1,12 @@ import fs from 'node:fs'; import path from 'node:path'; import matter from 'gray-matter'; +import GithubSlugger from 'github-slugger'; import { load as loadYaml } from 'js-yaml'; import { remark } from 'remark'; import remarkParse from 'remark-parse'; import remarkMdc from 'remark-mdc'; import { findSectionByPath } from '../shared/utils/docsSections.ts'; -import slugify from '../app/utils/slugify.ts'; import { listRoutableContentFiles } from './_content-lib.ts'; const CONTENT_DIR = path.resolve('content'); @@ -171,6 +171,14 @@ function buildChunkSearchTitle(pageSearchTitle: string, heading?: string) { return heading?.trim() || pageSearchTitle; } +function buildSectionAnchors(sections: MarkdownSection[]) { + const slugger = new GithubSlugger(); + return sections.map((section) => { + const heading = section.h3 ?? section.h2; + return heading ? slugger.slug(heading) : undefined; + }); +} + function normalizeText(value: string): string { return value .replace(/\u00a0/g, ' ') @@ -520,6 +528,7 @@ export function chunkMarkdownPage({ sourcePath, updatedAt, partials }: ChunkMark const tree = remark().use(remarkParse).use(remarkMdc).parse(parsed.content) as { children: MdastNode[] }; const blocks = extractBlocks(tree.children, partials); const sections = blocksToSections(blocks); + const sectionAnchors = buildSectionAnchors(sections); const rawChunks: RawChunk[] = []; const summaryChunk = createSummaryChunk(routePath, pageSearchTitle, frontmatter.description, sectionLabel, sections); if (summaryChunk) rawChunks.push(summaryChunk); @@ -529,7 +538,7 @@ export function chunkMarkdownPage({ sourcePath, updatedAt, partials }: ChunkMark ? sectionBlock.textParts : [sectionBlock.h3 ?? sectionBlock.h2 ?? title]; const contentChunks = chunkSectionText(textParts); - const anchor = sectionBlock.h3 ? slugify(sectionBlock.h3) : sectionBlock.h2 ? slugify(sectionBlock.h2) : undefined; + const anchor = sectionAnchors[sectionIndex]; const heading = sectionBlock.h3 ?? sectionBlock.h2; const hierarchy = buildSectionHierarchy(sectionLabel, pageSearchTitle, sectionBlock); const codeBlocks = [...new Set(sectionBlock.codeBlocks.map(normalizeCode).filter(Boolean))]; diff --git a/tests/router.options.test.ts b/tests/router.options.test.ts new file mode 100644 index 00000000..b693dfe1 --- /dev/null +++ b/tests/router.options.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import routerOptions from '../app/router.options'; + +describe('router scroll behavior', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + window.history.pushState({}, '', '/docs/guides/connect/query-parameters#deep'); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('waits for a routed hash target rendered after navigation', async () => { + const scroller = document.getElementById('docs-scroll') as HTMLElement; + scroller.scrollTo = vi.fn(); + + await routerOptions.scrollBehavior?.( + { path: '/guides/connect/query-parameters', hash: '#deep' } as never, + { path: '/' } as never, + null as never, + ); + + expect(scroller.scrollTo).not.toHaveBeenCalled(); + + const target = document.createElement('h2'); + target.id = 'deep'; + Object.defineProperty(target, 'offsetTop', { value: 320 }); + scroller.appendChild(target); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(scroller.scrollTo).toHaveBeenCalledWith({ top: 240, behavior: 'smooth' }); + }); +}); diff --git a/tests/scripts/index-docs-chunker.test.ts b/tests/scripts/index-docs-chunker.test.ts index 3ec7a9ef..0ebec3b4 100644 --- a/tests/scripts/index-docs-chunker.test.ts +++ b/tests/scripts/index-docs-chunker.test.ts @@ -38,4 +38,22 @@ describe('index-docs chunker', () => { expect(combined).toContain('Operations'); expect(combined).not.toContain('shiny-card'); }); + + it('matches rendered heading anchors for duplicate and punctuated headings', () => { + const sourcePath = path.resolve('content/guides/06.flows/4.operations.md'); + const partials = loadPartials(); + const documents = chunkMarkdownPage({ + sourcePath, + updatedAt: Math.round(fs.statSync(sourcePath).mtimeMs), + partials, + }); + + const optionsAnchors = [...new Set(documents + .filter(document => document.heading === 'Options') + .map(document => document.anchor))]; + + expect(optionsAnchors.slice(0, 4)).toEqual(['options', 'options-1', 'options-2', 'options-3']); + expect(optionsAnchors).toHaveLength(new Set(optionsAnchors).size); + expect(documents.find(document => document.heading === 'Webhook / Request URL')?.anchor).toBe('webhook--request-url'); + }); }); From c252ba3a56c53bbbd786e4befef72521e78972b2 Mon Sep 17 00:00:00 2001 From: bryantgillespie Date: Thu, 11 Jun 2026 20:33:54 -0400 Subject: [PATCH 2/2] Account for subnav in hash scrolling --- app/router.options.ts | 22 ++++++++++++++++++++-- tests/router.options.test.ts | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/app/router.options.ts b/app/router.options.ts index a56051c9..879de1f8 100644 --- a/app/router.options.ts +++ b/app/router.options.ts @@ -4,6 +4,24 @@ import { nextTick } from 'vue'; export const scrollPositions = new Map(); +const DEFAULT_HASH_OFFSET = 80; +const HASH_SCROLL_PADDING = 16; + +function parseCssPixels(value: string): number { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function getHashScrollOffset() { + const pane = document.querySelector('.docs-pane') as HTMLElement | null; + const styles = getComputedStyle(pane ?? document.documentElement); + const headerHeight = parseCssPixels(styles.getPropertyValue('--ui-header-height')); + const subnavHeight = parseCssPixels(styles.getPropertyValue('--ui-subnav-height')); + const measuredOffset = headerHeight + subnavHeight + HASH_SCROLL_PADDING; + + return Math.max(DEFAULT_HASH_OFFSET, measuredOffset); +} + function getHashTarget(hash: string): HTMLElement | null { const id = decodeURIComponent(hash.replace(/^#/, '')); return document.getElementById(id) ?? document.querySelector(hash) as HTMLElement | null; @@ -14,10 +32,10 @@ function scrollToHash(scroller: HTMLElement | null, hash: string): boolean { if (!target) return false; if (scroller) { - scroller.scrollTo({ top: Math.max(0, target.offsetTop - 80), behavior: 'smooth' }); + scroller.scrollTo({ top: Math.max(0, target.offsetTop - getHashScrollOffset()), behavior: 'smooth' }); } else { - window.scrollTo({ top: Math.max(0, target.offsetTop - 80), behavior: 'smooth' }); + window.scrollTo({ top: Math.max(0, target.offsetTop - getHashScrollOffset()), behavior: 'smooth' }); } return true; } diff --git a/tests/router.options.test.ts b/tests/router.options.test.ts index b693dfe1..3838934d 100644 --- a/tests/router.options.test.ts +++ b/tests/router.options.test.ts @@ -34,4 +34,23 @@ describe('router scroll behavior', () => { expect(scroller.scrollTo).toHaveBeenCalledWith({ top: 240, behavior: 'smooth' }); }); + + it('includes sticky subnav height in the hash scroll offset', async () => { + document.body.innerHTML = '
'; + const scroller = document.getElementById('docs-scroll') as HTMLElement; + scroller.scrollTo = vi.fn(); + + const target = document.createElement('h2'); + target.id = 'deep'; + Object.defineProperty(target, 'offsetTop', { value: 320 }); + scroller.appendChild(target); + + await routerOptions.scrollBehavior?.( + { path: '/guides/connect/query-parameters', hash: '#deep' } as never, + { path: '/' } as never, + null as never, + ); + + expect(scroller.scrollTo).toHaveBeenCalledWith({ top: 192, behavior: 'smooth' }); + }); });