Skip to content

Commit e109999

Browse files
committed
feat(jsx): add jsx generator
1 parent ad8c212 commit e109999

20 files changed

+1234
-735
lines changed

package-lock.json

Lines changed: 515 additions & 674 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
"lint:fix": "eslint --fix . --no-warn-ignored",
1010
"format": "prettier .",
1111
"format:write": "prettier --write .",
12+
"format:check": "prettier --check .",
1213
"test": "node --test",
1314
"test:coverage": "node --experimental-test-coverage --test",
15+
"test:ci": "node --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info --test-reporter=junit --test-reporter-destination=junit.xml --test-reporter=@reporters/github --test-reporter-destination=stdout --test-reporter=spec --test-reporter-destination=stdout --test",
1416
"test:update-snapshots": "node --test --test-update-snapshots",
1517
"test:watch": "node --test --watch",
1618
"prepare": "husky",
@@ -23,6 +25,7 @@
2325
},
2426
"devDependencies": {
2527
"@eslint/js": "^9.27.0",
28+
"@reporters/github": "^1.7.2",
2629
"@types/mdast": "^4.0.4",
2730
"@types/node": "^22.15.3",
2831
"eslint": "^9.27.0",
@@ -36,17 +39,22 @@
3639
"dependencies": {
3740
"@actions/core": "^1.11.1",
3841
"@clack/prompts": "^0.10.1",
42+
"@node-core/rehype-shiki": "^1.0.1-1815fa769361b836fa52cfab9c5bd4991f571c95",
3943
"@orama/orama": "^3.1.6",
4044
"@orama/plugin-data-persistence": "^3.1.6",
4145
"acorn": "^8.14.1",
4246
"commander": "^13.1.0",
4347
"dedent": "^1.6.0",
48+
"estree-util-value-to-estree": "^3.4.0",
4449
"estree-util-visit": "^2.0.0",
4550
"github-slugger": "^2.0.0",
4651
"glob": "^11.0.2",
4752
"hast-util-to-string": "^3.0.1",
4853
"hastscript": "^9.0.1",
4954
"html-minifier-terser": "^7.2.0",
55+
"reading-time": "^1.5.0",
56+
"recma-jsx": "^1.0.0",
57+
"rehype-recma": "^1.0.0",
5058
"rehype-stringify": "^10.0.1",
5159
"remark-gfm": "^4.0.1",
5260
"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+
});

0 commit comments

Comments
 (0)