Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions src/generators/web/__tests__/generate.test.mjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'node:test';

import { setConfig } from '../../../utils/configuration/index.mjs';
import buildContent from '../../jsx-ast/utils/buildContent.mjs';
import { buildNotFoundPage } from '../../jsx-ast/utils/synthetic/404.mjs';
import { generate } from '../generate.mjs';

const createEntry = (api, name) => {
const createEntry = (api, name, { depth = 1 } = {}) => {
const heading = {
type: 'heading',
depth: 1,
depth,
children: [{ type: 'text', value: name }],
data: { name, text: name, slug: api },
};
Expand Down Expand Up @@ -83,4 +86,26 @@ describe('web generate', () => {
assert.match(fsPage.html, /property=og:type content=website/);
assert.match(fsPage.html, /href=https:\/\/fonts\.googleapis\.com/);
});

it('hydrates active status for right-side table of contents links', async () => {
const output = await mkdtemp(join(tmpdir(), 'doc-kit-web-'));
const config = await setConfig({});
config.web.output = output;

try {
const fs = createEntry('fs', 'File system');
const readFileEntry = createEntry('fs-read-file', 'File system readFile', { depth: 2 });

await generate([await buildContent([fs, readFileEntry], fs)]);

const clientBundle = await readFile(join(output, 'fs.js'), 'utf8');

for (const token of ['data-active-heading', 'aria-current', 'hashchange', 'scroll']) {
assert.match(clientBundle, new RegExp(token));
}
} finally {
config.web.output = undefined;
await rm(output, { recursive: true, force: true });
}
});
});
85 changes: 85 additions & 0 deletions src/generators/web/ui/components/MetaBar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CodeBracketIcon, DocumentIcon } from '@heroicons/react/24/outline';
import Badge from '@node-core/ui-components/Common/Badge';
import MetaBar from '@node-core/ui-components/Containers/MetaBar';
import GitHubIcon from '@node-core/ui-components/Icons/Social/GitHub';
import { useEffect, useState } from 'react';

import styles from './index.module.css';

Expand All @@ -15,6 +16,72 @@ const iconMap = {
const STABILITY_KINDS = ['error', 'warning', null, 'info'];
const STABILITY_LABELS = ['D', 'E', null, 'L'];
const STABILITY_TOOLTIPS = ['Deprecated', 'Experimental', null, 'Legacy'];
const SCROLL_OFFSET = 96;

const toHeadingIds = headings =>
headings
.map(heading => heading.data?.id)
.filter(id => typeof id === 'string' && id.length > 0);

const useActiveHeadingId = headings => {
const [activeHeadingId, setActiveHeadingId] = useState('');

useEffect(() => {
const headingIds = toHeadingIds(headings);

if (headingIds.length === 0) {
setActiveHeadingId('');
return;
}

let frame = 0;

const updateActiveHeading = () => {
let nextActiveHeadingId = '';
frame = 0;

for (const id of headingIds) {
const element = document.getElementById(id);

if (!element) {
continue;
}

if (
!nextActiveHeadingId ||
element.getBoundingClientRect().top <= SCROLL_OFFSET
) {
nextActiveHeadingId = id;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Last section not highlighted

Medium Severity

Scroll tracking only promotes a heading when its getBoundingClientRect().top is at or above SCROLL_OFFSET. On the last section, that heading often sits lower on the screen while earlier headings are still above the threshold, so the table of contents keeps the previous link active instead of the section in view.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 14e26cf. Configure here.


setActiveHeadingId(nextActiveHeadingId);
};

const scheduleActiveHeadingUpdate = () => {
if (frame === 0) {
frame = window.requestAnimationFrame(updateActiveHeading);
}
};

updateActiveHeading();
window.addEventListener('hashchange', scheduleActiveHeadingUpdate);
window.addEventListener('scroll', scheduleActiveHeadingUpdate, {
passive: true,
});

return () => {
window.removeEventListener('hashchange', scheduleActiveHeadingUpdate);
window.removeEventListener('scroll', scheduleActiveHeadingUpdate);

if (frame !== 0) {
window.cancelAnimationFrame(frame);
}
};
}, [headings]);

return activeHeadingId;
};

/**
* Renders a heading value with an optional stability badge
Expand Down Expand Up @@ -53,14 +120,32 @@ const HeadingValue = ({ value, stability }) => {
*/
export default ({ metadata, headings = [], readingTime }) => {
const editThisPage = editURL.replace('{path}', metadata.path);
const activeHeadingId = useActiveHeadingId(headings);

const viewAs = [
['JSON', `${metadata.basename}.json`],
['MD', `${metadata.basename}.md`],
];

const ActiveHeadingLink = ({ href, className, ...props }) => {
const headingId = href?.startsWith('#') ? href.slice(1) : '';
const isActive = headingId === activeHeadingId;
const activeClassName = isActive ? styles.activeHeading : '';

return (
<a
{...props}
href={href}
className={[className, activeClassName].filter(Boolean).join(' ')}
aria-current={isActive ? 'location' : undefined}
data-active-heading={isActive ? true : undefined}
/>
);
};

return (
<MetaBar
as={ActiveHeadingLink}
heading="Table of Contents"
headings={{
items: headings.map(({ value, stability, ...heading }) => ({
Expand Down
6 changes: 6 additions & 0 deletions src/generators/web/ui/components/MetaBar/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@
display: inline-block;
margin-left: 0.25rem;
}

.activeHeading.activeHeading {
border-left: 0.25rem solid currentColor; padding-left: 0.4rem;
color: var(--color-green-700, #2c682c);
font-weight: 700; text-decoration-line: none;
}