Skip to content

Commit 3e302a1

Browse files
committed
feat(jsx): add jsx generator
1 parent 32834c4 commit 3e302a1

20 files changed

+1191
-62
lines changed

package-lock.json

Lines changed: 475 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,22 @@
3939
"dependencies": {
4040
"@actions/core": "^1.11.1",
4141
"@clack/prompts": "^0.10.1",
42+
"@node-core/rehype-shiki": "^1.0.1-1815fa769361b836fa52cfab9c5bd4991f571c95",
4243
"@orama/orama": "^3.1.6",
4344
"@orama/plugin-data-persistence": "^3.1.6",
4445
"acorn": "^8.14.1",
4546
"commander": "^13.1.0",
4647
"dedent": "^1.6.0",
48+
"estree-util-value-to-estree": "^3.4.0",
4749
"estree-util-visit": "^2.0.0",
4850
"github-slugger": "^2.0.0",
4951
"glob": "^11.0.2",
5052
"hast-util-to-string": "^3.0.1",
5153
"hastscript": "^9.0.1",
5254
"html-minifier-terser": "^7.2.0",
55+
"reading-time": "^1.5.0",
56+
"recma-jsx": "^1.0.0",
57+
"rehype-recma": "^1.0.0",
5358
"rehype-stringify": "^10.0.1",
5459
"remark-gfm": "^4.0.1",
5560
"remark-parse": "^11.0.0",

src/constants.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,14 @@ export const DOC_NODE_CHANGELOG_URL =
99

1010
// The base URL for the Node.js website
1111
export const BASE_URL = 'https://nodejs.org/';
12+
13+
// This is the Node.js Base URL for viewing a file within GitHub UI
14+
export const DOC_NODE_BLOB_BASE_URL =
15+
'https://github.com/nodejs/node/blob/HEAD/';
16+
17+
// This is the Node.js API docs base URL for editing a file on GitHub UI
18+
export const DOC_API_BLOB_EDIT_BASE_URL =
19+
'https://github.com/nodejs/node/edit/main/doc/api/';
20+
21+
// Base URL for a specific Node.js version within the Node.js API docs
22+
export const DOC_API_BASE_URL_VERSION = 'https://nodejs.org/docs/latest-v';

src/generators/index.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import apiLinks from './api-links/index.mjs';
1111
import oramaDb from './orama-db/index.mjs';
1212
import astJs from './ast-js/index.mjs';
1313
import llmsTxt from './llms-txt/index.mjs';
14+
import jsx from './jsx/index.mjs';
1415

1516
export const publicGenerators = {
1617
'json-simple': jsonSimple,
@@ -23,6 +24,7 @@ export const publicGenerators = {
2324
'api-links': apiLinks,
2425
'orama-db': oramaDb,
2526
'llms-txt': llmsTxt,
27+
jsx,
2628
};
2729

2830
export const allGenerators = {

src/generators/jsx/constants.mjs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* UI classes for Node.js API stability levels
3+
*
4+
* @see https://nodejs.org/api/documentation.html#stability-index
5+
*/
6+
export const STABILITY_LEVELS = [
7+
'danger', // (0) Deprecated
8+
'warning', // (1) Experimental
9+
'success', // (2) Stable
10+
'info', // (3) Legacy
11+
];
12+
13+
/**
14+
* HTML tag to UI component mappings
15+
*/
16+
export const TAG_TRANSFORMS = {
17+
pre: 'CodeBox',
18+
blockquote: 'Blockquote',
19+
};
20+
21+
/**
22+
* @see transformer.mjs's TODO comment
23+
*/
24+
export const TYPE_TRANSFORMS = {
25+
raw: 'text',
26+
};
27+
28+
/**
29+
* API type icon configurations
30+
*/
31+
export const API_ICONS = {
32+
event: { symbol: 'E', color: 'red' },
33+
method: { symbol: 'M', color: 'red' },
34+
property: { symbol: 'P', color: 'red' },
35+
class: { symbol: 'C', color: 'red' },
36+
module: { symbol: 'M', color: 'red' },
37+
classMethod: { symbol: 'S', color: 'red' },
38+
ctor: { symbol: 'C', color: 'red' },
39+
};
40+
41+
/**
42+
* API lifecycle change labels
43+
*/
44+
export const LIFECYCLE_LABELS = {
45+
added_in: 'Added in',
46+
deprecated_in: 'Deprecated in',
47+
removed_in: 'Removed in',
48+
introduced_in: 'Introduced in',
49+
};
50+
51+
// TODO(@avivkeller): These should be inherited from @node-core/website-i18n
52+
export const INTERNATIONALIZABLE = {
53+
sourceCode: 'Source Code: ',
54+
};
55+
56+
/**
57+
* Abstract Syntax Tree node type constants
58+
*/
59+
export const AST_NODE_TYPES = {
60+
MDX: {
61+
/**
62+
* Text-level JSX element
63+
*
64+
* @see https://github.com/syntax-tree/mdast-util-mdx-jsx#mdxjsxtextelement
65+
*/
66+
JSX_INLINE_ELEMENT: 'mdxJsxTextElement',
67+
68+
/**
69+
* Block-level JSX element
70+
*
71+
* @see https://github.com/syntax-tree/mdast-util-mdx-jsx#mdxjsxflowelement
72+
*/
73+
JSX_BLOCK_ELEMENT: 'mdxJsxFlowElement',
74+
75+
/**
76+
* JSX attribute
77+
*
78+
* @see https://github.com/syntax-tree/mdast-util-mdx-jsx#mdxjsxattribute
79+
*/
80+
JSX_ATTRIBUTE: 'mdxJsxAttribute',
81+
82+
/**
83+
* JSX expression attribute
84+
*
85+
* @see https://github.com/syntax-tree/mdast-util-mdx-jsx#mdxjsxattributevalueexpression
86+
*/
87+
JSX_ATTRIBUTE_EXPRESSION: 'mdxJsxAttributeValueExpression',
88+
},
89+
ESTREE: {
90+
/**
91+
* AST Program node
92+
*
93+
* @see https://github.com/estree/estree/blob/master/es5.md#programs
94+
*/
95+
PROGRAM: 'Program',
96+
97+
/**
98+
* Expression statement
99+
*
100+
* @see https://github.com/estree/estree/blob/master/es5.md#expressionstatement
101+
*/
102+
EXPRESSION_STATEMENT: 'ExpressionStatement',
103+
},
104+
// TODO(@avivkeller): These should be inherited from the elements themselves
105+
JSX: {
106+
ALERT_BOX: 'AlertBox',
107+
CHANGE_HISTORY: 'ChangeHistory',
108+
CIRCULAR_ICON: 'CircularIcon',
109+
NAV_BAR: 'NavBar',
110+
ARTICLE: 'Article',
111+
SIDE_BAR: 'SideBar',
112+
META_BAR: 'MetaBar',
113+
FOOTER: 'Footer',
114+
},
115+
};

src/generators/jsx/index.mjs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {
2+
getCompatibleVersions,
3+
groupNodesByModule,
4+
} from '../../utils/generators.mjs';
5+
import buildContent from './utils/buildContent.mjs';
6+
import { getRemarkRecma } from '../../utils/remark.mjs';
7+
import { buildSideBarDocPages } from './utils/buildBarProps.mjs';
8+
9+
/**
10+
* This generator generates a JSX AST from an input MDAST
11+
*
12+
* @typedef {Array<ApiDocMetadataEntry>} Input
13+
*
14+
* @type {GeneratorMetadata<Input, string>}
15+
*/
16+
export default {
17+
name: 'jsx',
18+
version: '1.0.0',
19+
description: 'Generates JSX from the input AST',
20+
dependsOn: 'ast',
21+
22+
/**
23+
* Generates a JSX AST
24+
*
25+
* @param {Input} entries
26+
* @param {Partial<GeneratorOptions>} options
27+
* @returns {Promise<Array<string>>} Array of generated content
28+
*/
29+
async generate(entries, { releases, version }) {
30+
const remarkRecma = getRemarkRecma();
31+
const groupedModules = groupNodesByModule(entries);
32+
33+
// Get sorted primary heading nodes
34+
const headNodes = entries
35+
.filter(node => node.heading.depth === 1)
36+
.sort((a, b) => a.heading.data.name.localeCompare(b.heading.data.name));
37+
38+
// Generate table of contents
39+
const docPages = buildSideBarDocPages(groupedModules, headNodes);
40+
41+
// Process each head node and build content
42+
const results = await Promise.all(
43+
headNodes.map(entry => {
44+
const versions = getCompatibleVersions(
45+
entry.introduced_in,
46+
releases,
47+
true
48+
);
49+
50+
const sideBarProps = {
51+
versions: versions.map(({ version }) => `v${version.version}`),
52+
currentVersion: `v${version.version}`,
53+
currentPage: `${entry.api}.html`,
54+
docPages,
55+
};
56+
57+
return buildContent(
58+
groupedModules.get(entry.api),
59+
entry,
60+
sideBarProps,
61+
remarkRecma
62+
);
63+
})
64+
);
65+
66+
return results;
67+
},
68+
};
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import {
4+
buildSideBarDocPages,
5+
buildMetaBarProps,
6+
} from '../utils/buildBarProps.mjs';
7+
import buildContent from '../utils/buildContent.mjs';
8+
import { createJSXElement } from '../utils/ast.mjs';
9+
import { AST_NODE_TYPES } from '../constants.mjs';
10+
import { unified } from 'unified';
11+
import remarkParse from 'remark-parse';
12+
import remarkStringify from 'remark-stringify';
13+
14+
const sampleEntry = {
15+
api: 'sample-api',
16+
heading: {
17+
depth: 2,
18+
data: { name: 'SampleFunc', slug: 'sample-func', type: 'function' },
19+
},
20+
content: {
21+
type: 'root',
22+
children: [
23+
{ type: 'text', value: 'Example text for testing reading time.' },
24+
],
25+
},
26+
added_in: 'v1.0.0',
27+
source_link: '/src/index.js',
28+
changes: [
29+
{
30+
version: 'v1.1.0',
31+
description: 'Improved performance',
32+
'pr-url': 'https://github.com/org/repo/pull/123',
33+
},
34+
],
35+
};
36+
37+
test('buildSideBarDocPages returns expected format', () => {
38+
const grouped = new Map([['sample-api', [sampleEntry]]]);
39+
const result = buildSideBarDocPages(grouped, [sampleEntry]);
40+
41+
assert.equal(result.length, 1);
42+
assert.equal(result[0].title, 'SampleFunc');
43+
assert.equal(result[0].doc, 'sample-api.html');
44+
assert.deepEqual(result[0].headings, [['SampleFunc', '#sample-func']]);
45+
});
46+
47+
test('buildMetaBarProps includes expected fields', () => {
48+
const result = buildMetaBarProps(sampleEntry, [sampleEntry]);
49+
50+
assert.equal(result.addedIn, 'v1.0.0');
51+
assert.deepEqual(result.viewAs, [['JSON', 'sample-api.json']]);
52+
assert.ok(result.readingTime.startsWith('1 min'));
53+
assert.ok(result.editThisPage.endsWith('sample-api.md'));
54+
assert.deepEqual(result.headings, [{ depth: 2, value: 'SampleFunc' }]);
55+
});
56+
57+
test('createJSXElement builds correct JSX tree', () => {
58+
const el = createJSXElement('TestComponent', {
59+
inline: false,
60+
children: 'Some content',
61+
dataAttr: { test: true },
62+
});
63+
64+
assert.equal(el.type, AST_NODE_TYPES.MDX.JSX_BLOCK_ELEMENT);
65+
assert.equal(el.name, 'TestComponent');
66+
assert.ok(Array.isArray(el.children));
67+
assert.ok(el.attributes.some(attr => attr.name === 'dataAttr'));
68+
});
69+
70+
test('buildContent processes entries and includes JSX wrapper elements', () => {
71+
const processor = unified().use(remarkParse).use(remarkStringify);
72+
const tree = buildContent([sampleEntry], sampleEntry, {}, processor);
73+
74+
const article = tree.children.find(
75+
child => child.name === AST_NODE_TYPES.JSX.ARTICLE
76+
);
77+
assert.ok(article);
78+
assert.ok(article.children.some(c => c.name === AST_NODE_TYPES.JSX.SIDE_BAR));
79+
assert.ok(article.children.some(c => c.name === AST_NODE_TYPES.JSX.FOOTER));
80+
});

src/generators/jsx/utils/ast.mjs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
'use strict';
2+
3+
import { u as createTree } from 'unist-builder';
4+
import { valueToEstree } from 'estree-util-value-to-estree';
5+
import { AST_NODE_TYPES } from '../constants.mjs';
6+
7+
/**
8+
* @typedef {Object} JSXOptions
9+
* @property {boolean} [inline] - Whether the element is inline
10+
* @property {(string | Array<import('unist').Node>)} [children] - Child content or nodes
11+
*/
12+
13+
/**
14+
* Creates an MDX JSX element with support for complex attribute values.
15+
*
16+
* @param {string} name - The name of the JSX element
17+
* @param {JSXOptions & Record<string, any>} [options={}] - Options including type, children, and JSX attributes
18+
* @returns {import('unist').Node} The created MDX JSX element node
19+
*/
20+
export const createJSXElement = (
21+
name,
22+
{ inline = true, children = [], ...attributes } = {}
23+
) => {
24+
// Convert string children to text node or use array directly
25+
const processedChildren =
26+
typeof children === 'string'
27+
? [createTree('text', { value: children })]
28+
: children;
29+
30+
const elementType = inline
31+
? AST_NODE_TYPES.MDX.JSX_INLINE_ELEMENT
32+
: AST_NODE_TYPES.MDX.JSX_BLOCK_ELEMENT;
33+
34+
const attrs = Object.entries(attributes).map(([key, value]) =>
35+
createAttributeNode(key, value)
36+
);
37+
38+
return createTree(elementType, {
39+
name,
40+
attributes: attrs,
41+
children: processedChildren,
42+
});
43+
};
44+
45+
/**
46+
* Creates an MDX JSX attribute node based on the value type.
47+
*
48+
* @param {string} name - The attribute name
49+
* @param {any} value - The attribute value
50+
* @returns {import('unist').Node} The MDX JSX attribute node
51+
*/
52+
function createAttributeNode(name, value) {
53+
// Use expression for objects and arrays
54+
if (value !== null && typeof value === 'object') {
55+
return createTree(AST_NODE_TYPES.MDX.JSX_ATTRIBUTE, {
56+
name,
57+
value: createTree(AST_NODE_TYPES.MDX.JSX_ATTRIBUTE_EXPRESSION, {
58+
data: {
59+
estree: {
60+
type: AST_NODE_TYPES.ESTREE.PROGRAM,
61+
body: [
62+
{
63+
type: AST_NODE_TYPES.ESTREE.EXPRESSION_STATEMENT,
64+
expression: valueToEstree(value),
65+
},
66+
],
67+
},
68+
},
69+
}),
70+
});
71+
}
72+
73+
// For primitives, use simple string conversion.
74+
// If undefined, pass nothing.
75+
return createTree(AST_NODE_TYPES.MDX.JSX_ATTRIBUTE, {
76+
name,
77+
value: value == null ? value : String(value),
78+
});
79+
}

0 commit comments

Comments
 (0)