Skip to content

Commit 4a22c85

Browse files
authored
chore: improved index page (#1045)
* chore: improved index page * chore: improved index page * chore: improved index page * fixup! * fixup!
1 parent 81b6224 commit 4a22c85

22 files changed

Lines changed: 418 additions & 402 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@doc-kit/generator-react': patch
3+
---
4+
5+
Add a `<DocumentationIndex />` MDX component that renders the stability overview of every module, backed by a new `documentationIndex` export on `#theme/config` (replaces the `<!-- DOCUMENTATION_INDEX -->` comment)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@doc-kit/core': patch
3+
---
4+
5+
MDX nodes in the HTML-string pipelines are now dropped, and do not crash.

packages/core/src/utils/__tests__/generators.test.mjs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,71 @@ import assert from 'node:assert/strict';
22
import { describe, it } from 'node:test';
33

44
import {
5+
getEntryDescription,
56
groupNodesByModule,
67
getVersionFromSemVer,
78
coerceSemVer,
89
getCompatibleVersions,
910
} from '../generators.mjs';
1011

12+
describe('getEntryDescription', () => {
13+
it('returns llm_description when available', () => {
14+
const entry = {
15+
llm_description: 'LLM generated description',
16+
content: { children: [] },
17+
};
18+
19+
const result = getEntryDescription(entry);
20+
assert.equal(result, 'LLM generated description');
21+
});
22+
23+
it('extracts first paragraph when no llm_description', () => {
24+
const entry = {
25+
content: {
26+
children: [
27+
{
28+
type: 'paragraph',
29+
children: [{ type: 'text', value: 'First paragraph' }],
30+
},
31+
],
32+
},
33+
};
34+
35+
const result = getEntryDescription(entry);
36+
assert.ok(result.length > 0);
37+
});
38+
39+
it('returns empty string when no paragraph found', () => {
40+
const entry = {
41+
content: {
42+
children: [
43+
{ type: 'heading', children: [{ type: 'text', value: 'Title' }] },
44+
],
45+
},
46+
};
47+
48+
const result = getEntryDescription(entry);
49+
assert.equal(result, '');
50+
});
51+
52+
it('removes newlines from description', () => {
53+
const entry = {
54+
content: {
55+
children: [
56+
{
57+
type: 'paragraph',
58+
children: [{ type: 'text', value: 'Line 1\nLine 2\r\nLine 3' }],
59+
},
60+
],
61+
},
62+
};
63+
64+
const result = getEntryDescription(entry);
65+
assert.equal(result.includes('\n'), false);
66+
assert.equal(result.includes('\r'), false);
67+
});
68+
});
69+
1170
describe('groupNodesByModule', () => {
1271
it('groups nodes by api property', () => {
1372
const nodes = [
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import assert from 'node:assert/strict';
2+
import { describe, it } from 'node:test';
3+
4+
import { getRemarkRehype } from '../remark.mjs';
5+
6+
describe('getRemarkRehype', () => {
7+
it('degrades MDX nodes instead of crashing rehype-stringify', () => {
8+
const processor = getRemarkRehype();
9+
10+
const tree = {
11+
type: 'root',
12+
children: [
13+
{
14+
type: 'paragraph',
15+
children: [
16+
{ type: 'text', value: 'before ' },
17+
{
18+
type: 'mdxJsxTextElement',
19+
name: 'Tooltip',
20+
attributes: [],
21+
children: [{ type: 'text', value: 'inner' }],
22+
},
23+
{ type: 'mdxTextExpression', value: '1 + 1' },
24+
],
25+
},
26+
{
27+
type: 'mdxJsxFlowElement',
28+
name: 'DocumentationIndex',
29+
attributes: [],
30+
children: [],
31+
},
32+
],
33+
};
34+
35+
const output = processor.stringify(processor.runSync(tree));
36+
37+
// JSX elements degrade to their children; expressions are dropped.
38+
assert.equal(output, '<p>before inner</p>');
39+
});
40+
});

packages/core/src/utils/generators.mjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,36 @@
22

33
import { coerce, major } from 'semver';
44

5+
import { transformNodeToString } from './unist.mjs';
6+
7+
/**
8+
* Retrieves the description of a given API doc entry. It first checks whether
9+
* the entry has a llm_description property. If not, it extracts the first
10+
* paragraph from the entry's content.
11+
*
12+
* @param {import('../generators/metadata/types').MetadataEntry} entry
13+
* @returns {string}
14+
*/
15+
export const getEntryDescription = entry => {
16+
if (entry.llm_description) {
17+
return entry.llm_description.trim();
18+
}
19+
20+
const descriptionNode = entry.content.children.find(
21+
child => child.type === 'paragraph'
22+
);
23+
24+
if (!descriptionNode) {
25+
return '';
26+
}
27+
28+
return (
29+
transformNodeToString(descriptionNode)
30+
// Remove newlines and extra spaces
31+
.replace(/[\r\n]+/g, '')
32+
);
33+
};
34+
535
/**
636
* Groups all the API metadata nodes by module (`api` property) so that we can process each different file
737
* based on the module it belongs to.

packages/core/src/utils/remark.mjs

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,33 @@ import { lazy } from './misc.mjs';
1313
import { typeAnnotationToHast } from './type-annotations/hast.mjs';
1414
import remarkTypeAnnotations from './type-annotations/remark.mjs';
1515

16-
// MDX node types that may appear in trees parsed by `getRemarkMdx`; the
17-
// rehype pipelines pass them through untouched.
18-
const passThrough = [
19-
'element',
20-
'mdxJsxTextElement',
21-
'mdxJsxFlowElement',
22-
'mdxJsxAttribute',
23-
'mdxJsxAttributeValueExpression',
24-
'mdxFlowExpression',
25-
'mdxTextExpression',
26-
'mdxjsEsm',
27-
];
16+
// Nodes the rehype pipelines pass through untouched.
17+
const passThrough = ['element'];
18+
19+
/**
20+
* Renders an MDX JSX element as just its children, so the surrounding prose
21+
* still renders in HTML-string output.
22+
*
23+
* @param {import('mdast-util-to-hast').State} state
24+
* @param {import('unist').Parent} node
25+
*/
26+
const mdxElementToChildren = (state, node) => state.all(node);
27+
28+
/**
29+
* Drops a node from HTML-string output.
30+
*/
31+
const dropNode = () => undefined;
32+
33+
// The HTML-string pipelines cannot render MDX nodes (rendering those is the
34+
// React generators' job): JSX elements degrade to their children so the
35+
// surrounding prose still renders, and expressions/ESM are dropped.
36+
const mdxToHastHandlers = {
37+
mdxJsxTextElement: mdxElementToChildren,
38+
mdxJsxFlowElement: mdxElementToChildren,
39+
mdxFlowExpression: dropNode,
40+
mdxTextExpression: dropNode,
41+
mdxjsEsm: dropNode,
42+
};
2843

2944
/**
3045
* Retrieves an instance of Remark configured to parse GFM (GitHub Flavored Markdown)
@@ -64,7 +79,7 @@ export const getRemarkRehype = lazy(() =>
6479
.use(remarkRehype, {
6580
allowDangerousHtml: true,
6681
passThrough,
67-
handlers: { typeAnnotation: typeAnnotationToHast },
82+
handlers: { typeAnnotation: typeAnnotationToHast, ...mdxToHastHandlers },
6883
})
6984
// We allow dangerous HTML to be passed through, since we have HTML within our Markdown
7085
// and we trust the sources of the Markdown files
@@ -86,7 +101,7 @@ export const getRemarkRehypeWithShiki = lazy(() =>
86101
allowDangerousHtml: true,
87102
passThrough,
88103
// legacy-html gets the minimal (unhighlighted) type rendering
89-
handlers: { typeAnnotation: typeAnnotationToHast },
104+
handlers: { typeAnnotation: typeAnnotationToHast, ...mdxToHastHandlers },
90105
})
91106
// This is a custom ad-hoc within the Shiki Rehype plugin, used to highlight code
92107
// and transform them into HAST nodes

packages/react/src/html/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,11 @@ title: Welcome
358358
There are {stats.length} APIs documented.
359359
```
360360
361+
The built-in components are available without registration. Notably,
362+
`<DocumentationIndex />` renders an index of every documented module with its
363+
stability badge and description, sourced from the `documentationIndex` export
364+
of [`#theme/config`](#themeconfig-virtual-module).
365+
361366
## `#theme/config` virtual module
362367
363368
The `html` generator provides a `#theme/config` virtual module that exposes pre-computed configuration as named exports. Any component (including custom overrides) can import the values it needs, and tree-shaking removes the rest.
@@ -377,6 +382,9 @@ import { project, repository, editURL } from '#theme/config';
377382
- `editURL` {string} Partially populated "edit this page" URL template (only
378383
`{path}` remains).
379384
- `pages` {Array} Sorted `[name, path]` tuples for sidebar navigation.
385+
- `documentationIndex` {Array} Entries rendered by the built-in
386+
`<DocumentationIndex />` component — every page with a stability index, each
387+
`{ api, name, index, description }`.
380388
- `navigation` {Object} Mirrors the configured `navigation` (consumed by the
381389
built-in `SideBar` and `NavBar`).
382390
- `useAbsoluteURLs` {boolean} Whether internal links use absolute URLs (mirrors

packages/react/src/html/constants.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export const JSX_IMPORTS = {
2626
name: 'CodeTabs',
2727
source: resolve(ROOT, './ui/components/CodeTabs'),
2828
},
29+
DocumentationIndex: {
30+
name: 'DocumentationIndex',
31+
source: resolve(ROOT, './ui/components/DocumentationIndex'),
32+
},
2933
MDXTooltip: {
3034
name: 'MDXTooltip',
3135
isDefaultExport: false,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Badge from '@node-core/ui-components/Common/Badge';
2+
3+
import styles from './index.module.css';
4+
import { STABILITY_KINDS, STABILITY_LABELS } from '../constants.mjs';
5+
6+
import { documentationIndex } from '#theme/config';
7+
8+
/**
9+
* @typedef {Object} DocumentationIndexEntry
10+
* @property {string} api - Basename of the document, linked as `${api}.html`
11+
* @property {string} name - Human-readable name from the document's heading
12+
* @property {string} index - Stability index (e.g. `'2'` or `'1.1'`)
13+
* @property {string} [description] - The document's `llm_description`, or its first paragraph
14+
*/
15+
16+
/**
17+
* @param {DocumentationIndexEntry} props
18+
*/
19+
const IndexEntry = ({ api, name, index, description }) => {
20+
const level = parseInt(index, 10);
21+
const label = STABILITY_LABELS[level] ?? index;
22+
23+
return (
24+
<a className={styles.entry} href={`${api}.html`}>
25+
<span className={styles.title}>
26+
<span className={styles.name}>{name}</span>
27+
28+
<Badge
29+
size="small"
30+
kind={STABILITY_KINDS[level] ?? 'neutral'}
31+
aria-label={`Stability: ${index}`}
32+
>
33+
{label}
34+
</Badge>
35+
</span>
36+
37+
{description && <span className={styles.summary}>{description}</span>}
38+
</a>
39+
);
40+
};
41+
42+
export default () => (
43+
<nav className={styles.documentationIndex} aria-label="Documentation index">
44+
{documentationIndex.map(entry => (
45+
<IndexEntry key={entry.api} {...entry} />
46+
))}
47+
</nav>
48+
);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
.documentationIndex {
2+
display: grid;
3+
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
4+
gap: 1rem;
5+
margin-block: 1.5rem;
6+
}
7+
8+
.entry {
9+
display: flex;
10+
flex-direction: column;
11+
gap: 0.375rem;
12+
padding: 1rem;
13+
border: 1px solid var(--color-neutral-200);
14+
border-radius: 0.75rem;
15+
color: inherit;
16+
text-decoration: none;
17+
transition:
18+
border-color 0.15s ease,
19+
background-color 0.15s ease;
20+
}
21+
22+
.entry:hover,
23+
.entry:focus-visible {
24+
border-color: var(--color-neutral-400);
25+
background-color: var(--color-neutral-100);
26+
}
27+
28+
:where([data-theme='dark'], [data-theme='dark'] *) .entry {
29+
border-color: var(--color-neutral-900);
30+
}
31+
32+
:where([data-theme='dark'], [data-theme='dark'] *) .entry:hover,
33+
:where([data-theme='dark'], [data-theme='dark'] *) .entry:focus-visible {
34+
border-color: var(--color-neutral-700);
35+
background-color: var(--color-neutral-950);
36+
}
37+
38+
.title {
39+
display: flex;
40+
align-items: center;
41+
justify-content: space-between;
42+
gap: 0.5rem;
43+
}
44+
45+
.name {
46+
font-weight: 600;
47+
color: var(--color-neutral-900);
48+
}
49+
50+
:where([data-theme='dark'], [data-theme='dark'] *) .name {
51+
color: var(--color-white);
52+
}
53+
54+
.summary {
55+
font-size: 0.875rem;
56+
color: var(--color-neutral-800);
57+
}
58+
59+
:where([data-theme='dark'], [data-theme='dark'] *) .summary {
60+
color: var(--color-neutral-600);
61+
}

0 commit comments

Comments
 (0)