diff --git a/src/generators/web/__tests__/generate.test.mjs b/src/generators/web/__tests__/generate.test.mjs
index cde610ae..efff1f49 100644
--- a/src/generators/web/__tests__/generate.test.mjs
+++ b/src/generators/web/__tests__/generate.test.mjs
@@ -1,4 +1,7 @@
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';
@@ -6,10 +9,10 @@ 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 },
};
@@ -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 });
+ }
+ });
});
diff --git a/src/generators/web/ui/components/MetaBar/index.jsx b/src/generators/web/ui/components/MetaBar/index.jsx
index 261498ea..4a04228f 100644
--- a/src/generators/web/ui/components/MetaBar/index.jsx
+++ b/src/generators/web/ui/components/MetaBar/index.jsx
@@ -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';
@@ -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;
+ }
+ }
+
+ 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
@@ -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 (
+
+ );
+ };
+
return (
({
diff --git a/src/generators/web/ui/components/MetaBar/index.module.css b/src/generators/web/ui/components/MetaBar/index.module.css
index 0f3163ee..c512fd5c 100644
--- a/src/generators/web/ui/components/MetaBar/index.module.css
+++ b/src/generators/web/ui/components/MetaBar/index.module.css
@@ -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;
+}