diff --git a/app/plugins/hashScroll.client.ts b/app/plugins/hashScroll.client.ts new file mode 100644 index 00000000..95357b5a --- /dev/null +++ b/app/plugins/hashScroll.client.ts @@ -0,0 +1,15 @@ +import { scrollToHashWhenReady } from '~/router.options'; + +export default defineNuxtPlugin((nuxtApp) => { + const route = useRoute(); + + const scrollToRouteHash = async () => { + if (!route.hash) return; + await nextTick(); + scrollToHashWhenReady(route.hash); + }; + + nuxtApp.hook('app:mounted', scrollToRouteHash); + nuxtApp.hook('page:finish', scrollToRouteHash); + watch(() => route.fullPath, scrollToRouteHash, { flush: 'post' }); +}); diff --git a/app/router.options.ts b/app/router.options.ts index fac292b1..f129b365 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(); @@ -12,15 +13,23 @@ function scrollToHash(scroller: HTMLElement | null, hash: string): boolean { const target = getHashTarget(hash); if (!target) return false; - if (scroller) { - scroller.scrollTo({ top: Math.max(0, target.offsetTop - 80), behavior: 'smooth' }); - } - else { - window.scrollTo({ top: Math.max(0, target.offsetTop - 80), behavior: 'smooth' }); - } + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); return true; } +export function scrollToHashWhenReady(hash: string) { + const scroller = document.getElementById('docs-scroll'); + 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 +45,7 @@ export default { if (to.hash) { await nextTick(); - requestAnimationFrame(() => scrollToHash(scroller, to.hash)); + scrollToHashWhenReady(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 dcd0f45d..bc67fbe2 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..ecba117c --- /dev/null +++ b/tests/router.options.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import routerOptions, { scrollToHashWhenReady } 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; + + scrollToHashWhenReady('#deep'); + + const target = document.createElement('h2'); + target.id = 'deep'; + target.scrollIntoView = vi.fn(); + scroller.appendChild(target); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }); + }); + + it('uses native hash scrolling so heading scroll margin applies', async () => { + document.body.innerHTML = '
'; + const scroller = document.getElementById('docs-scroll') as HTMLElement; + + const target = document.createElement('h2'); + target.id = 'deep'; + target.scrollIntoView = vi.fn(); + scroller.appendChild(target); + + await routerOptions.scrollBehavior?.( + { path: '/guides/connect/query-parameters', hash: '#deep' } as never, + { path: '/' } as never, + null as never, + ); + + expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }); + }); +}); 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'); + }); });