Skip to content

feat(jsx): add jsx generator #273

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 28, 2025
Merged
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
476 changes: 475 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,22 @@
"dependencies": {
"@actions/core": "^1.11.1",
"@clack/prompts": "^0.10.1",
"@node-core/rehype-shiki": "^1.0.1-1815fa769361b836fa52cfab9c5bd4991f571c95",
"@orama/orama": "^3.1.6",
"@orama/plugin-data-persistence": "^3.1.6",
"acorn": "^8.14.1",
"commander": "^13.1.0",
"dedent": "^1.6.0",
"estree-util-value-to-estree": "^3.4.0",
"estree-util-visit": "^2.0.0",
"github-slugger": "^2.0.0",
"glob": "^11.0.2",
"hast-util-to-string": "^3.0.1",
"hastscript": "^9.0.1",
"html-minifier-terser": "^7.2.0",
"reading-time": "^1.5.0",
"recma-jsx": "^1.0.0",
"rehype-recma": "^1.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
Expand Down
11 changes: 11 additions & 0 deletions src/constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,14 @@ export const DOC_NODE_CHANGELOG_URL =

// The base URL for the Node.js website
export const BASE_URL = 'https://nodejs.org/';

// This is the Node.js Base URL for viewing a file within GitHub UI
export const DOC_NODE_BLOB_BASE_URL =
'https://github.com/nodejs/node/blob/HEAD/';

// This is the Node.js API docs base URL for editing a file on GitHub UI
export const DOC_API_BLOB_EDIT_BASE_URL =
'https://github.com/nodejs/node/edit/main/doc/api/';

// Base URL for a specific Node.js version within the Node.js API docs
export const DOC_API_BASE_URL_VERSION = 'https://nodejs.org/docs/latest-v';
2 changes: 2 additions & 0 deletions src/generators/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import apiLinks from './api-links/index.mjs';
import oramaDb from './orama-db/index.mjs';
import astJs from './ast-js/index.mjs';
import llmsTxt from './llms-txt/index.mjs';
import jsxAst from './jsx-ast/index.mjs';

export const publicGenerators = {
'json-simple': jsonSimple,
Expand All @@ -23,6 +24,7 @@ export const publicGenerators = {
'api-links': apiLinks,
'orama-db': oramaDb,
'llms-txt': llmsTxt,
'jsx-ast': jsxAst,
};

export const allGenerators = {
Expand Down
115 changes: 115 additions & 0 deletions src/generators/jsx-ast/constants.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* UI classes for Node.js API stability levels
*
* @see https://nodejs.org/api/documentation.html#stability-index
*/
export const STABILITY_LEVELS = [
'danger', // (0) Deprecated
'warning', // (1) Experimental
'success', // (2) Stable
'info', // (3) Legacy
];

/**
* HTML tag to UI component mappings
*/
export const TAG_TRANSFORMS = {
pre: 'CodeBox',
blockquote: 'Blockquote',
};

/**
* @see transformer.mjs's TODO comment
*/
export const TYPE_TRANSFORMS = {
raw: 'text',
};

/**
* API type icon configurations
*/
export const API_ICONS = {
event: { symbol: 'E', color: 'red' },
method: { symbol: 'M', color: 'red' },
property: { symbol: 'P', color: 'red' },
class: { symbol: 'C', color: 'red' },
module: { symbol: 'M', color: 'red' },
classMethod: { symbol: 'S', color: 'red' },
ctor: { symbol: 'C', color: 'red' },
};

/**
* API lifecycle change labels
*/
export const LIFECYCLE_LABELS = {
added_in: 'Added in',
deprecated_in: 'Deprecated in',
removed_in: 'Removed in',
introduced_in: 'Introduced in',
};

// TODO(@avivkeller): These should be inherited from @node-core/website-i18n
export const INTERNATIONALIZABLE = {
sourceCode: 'Source Code: ',
};

/**
* Abstract Syntax Tree node type constants
*/
export const AST_NODE_TYPES = {
MDX: {
/**
* Text-level JSX element
*
* @see https://github.com/syntax-tree/mdast-util-mdx-jsx#mdxjsxtextelement
*/
JSX_INLINE_ELEMENT: 'mdxJsxTextElement',

/**
* Block-level JSX element
*
* @see https://github.com/syntax-tree/mdast-util-mdx-jsx#mdxjsxflowelement
*/
JSX_BLOCK_ELEMENT: 'mdxJsxFlowElement',

/**
* JSX attribute
*
* @see https://github.com/syntax-tree/mdast-util-mdx-jsx#mdxjsxattribute
*/
JSX_ATTRIBUTE: 'mdxJsxAttribute',

/**
* JSX expression attribute
*
* @see https://github.com/syntax-tree/mdast-util-mdx-jsx#mdxjsxattributevalueexpression
*/
JSX_ATTRIBUTE_EXPRESSION: 'mdxJsxAttributeValueExpression',
},
ESTREE: {
/**
* AST Program node
*
* @see https://github.com/estree/estree/blob/master/es5.md#programs
*/
PROGRAM: 'Program',

/**
* Expression statement
*
* @see https://github.com/estree/estree/blob/master/es5.md#expressionstatement
*/
EXPRESSION_STATEMENT: 'ExpressionStatement',
},
// TODO(@avivkeller): These should be inherited from the elements themselves
JSX: {
ALERT_BOX: 'AlertBox',
CHANGE_HISTORY: 'ChangeHistory',
CIRCULAR_ICON: 'CircularIcon',
NAV_BAR: 'NavBar',
ARTICLE: 'Article',
SIDE_BAR: 'SideBar',
META_BAR: 'MetaBar',
FOOTER: 'Footer',
},
};
68 changes: 68 additions & 0 deletions src/generators/jsx-ast/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
getCompatibleVersions,
groupNodesByModule,
} from '../../utils/generators.mjs';
import buildContent from './utils/buildContent.mjs';
import { getRemarkRecma } from '../../utils/remark.mjs';
import { buildSideBarDocPages } from './utils/buildBarProps.mjs';

/**
* This generator generates a JSX AST from an input MDAST
*
* @typedef {Array<ApiDocMetadataEntry>} Input
*
* @type {GeneratorMetadata<Input, string>}
*/
export default {
name: 'jsx-ast',
version: '1.0.0',
description: 'Generates JSX AST from the input MDAST',
dependsOn: 'ast',

/**
* Generates a JSX AST
*
* @param {Input} entries
* @param {Partial<GeneratorOptions>} options
* @returns {Promise<Array<string>>} Array of generated content
*/
async generate(entries, { releases, version }) {
const remarkRecma = getRemarkRecma();
const groupedModules = groupNodesByModule(entries);

// Get sorted primary heading nodes
const headNodes = entries
.filter(node => node.heading.depth === 1)
.sort((a, b) => a.heading.data.name.localeCompare(b.heading.data.name));

// Generate table of contents
const docPages = buildSideBarDocPages(groupedModules, headNodes);

// Process each head node and build content
const results = await Promise.all(
headNodes.map(entry => {
const versions = getCompatibleVersions(
entry.introduced_in,
releases,
true
);

const sideBarProps = {
versions: versions.map(({ version }) => `v${version.version}`),
currentVersion: `v${version.version}`,
currentPage: `${entry.api}.html`,
docPages,
};

return buildContent(
groupedModules.get(entry.api),
entry,
sideBarProps,
remarkRecma
);
})
);

return results;
},
};
80 changes: 80 additions & 0 deletions src/generators/jsx-ast/test/utils.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildSideBarDocPages,
buildMetaBarProps,
} from '../utils/buildBarProps.mjs';
import buildContent from '../utils/buildContent.mjs';
import { createJSXElement } from '../utils/ast.mjs';
import { AST_NODE_TYPES } from '../constants.mjs';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkStringify from 'remark-stringify';

const sampleEntry = {
api: 'sample-api',
heading: {
depth: 2,
data: { name: 'SampleFunc', slug: 'sample-func', type: 'function' },
},
content: {
type: 'root',
children: [
{ type: 'text', value: 'Example text for testing reading time.' },
],
},
added_in: 'v1.0.0',
source_link: '/src/index.js',
changes: [
{
version: 'v1.1.0',
description: 'Improved performance',
'pr-url': 'https://github.com/org/repo/pull/123',
},
],
};

test('buildSideBarDocPages returns expected format', () => {
const grouped = new Map([['sample-api', [sampleEntry]]]);
const result = buildSideBarDocPages(grouped, [sampleEntry]);

assert.equal(result.length, 1);
assert.equal(result[0].title, 'SampleFunc');
assert.equal(result[0].doc, 'sample-api.html');
assert.deepEqual(result[0].headings, [['SampleFunc', '#sample-func']]);
});

test('buildMetaBarProps includes expected fields', () => {
const result = buildMetaBarProps(sampleEntry, [sampleEntry]);

assert.equal(result.addedIn, 'v1.0.0');
assert.deepEqual(result.viewAs, [['JSON', 'sample-api.json']]);
assert.ok(result.readingTime.startsWith('1 min'));
assert.ok(result.editThisPage.endsWith('sample-api.md'));
assert.deepEqual(result.headings, [{ depth: 2, value: 'SampleFunc' }]);
});

test('createJSXElement builds correct JSX tree', () => {
const el = createJSXElement('TestComponent', {
inline: false,
children: 'Some content',
dataAttr: { test: true },
});

assert.equal(el.type, AST_NODE_TYPES.MDX.JSX_BLOCK_ELEMENT);
assert.equal(el.name, 'TestComponent');
assert.ok(Array.isArray(el.children));
assert.ok(el.attributes.some(attr => attr.name === 'dataAttr'));
});

test('buildContent processes entries and includes JSX wrapper elements', () => {
const processor = unified().use(remarkParse).use(remarkStringify);
const tree = buildContent([sampleEntry], sampleEntry, {}, processor);

const article = tree.children.find(
child => child.name === AST_NODE_TYPES.JSX.ARTICLE
);
assert.ok(article);
assert.ok(article.children.some(c => c.name === AST_NODE_TYPES.JSX.SIDE_BAR));
assert.ok(article.children.some(c => c.name === AST_NODE_TYPES.JSX.FOOTER));
});
79 changes: 79 additions & 0 deletions src/generators/jsx-ast/utils/ast.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use strict';

import { u as createTree } from 'unist-builder';
import { valueToEstree } from 'estree-util-value-to-estree';
import { AST_NODE_TYPES } from '../constants.mjs';

/**
* @typedef {Object} JSXOptions
* @property {boolean} [inline] - Whether the element is inline
* @property {(string | Array<import('unist').Node>)} [children] - Child content or nodes
*/

/**
* Creates an MDX JSX element with support for complex attribute values.
*
* @param {string} name - The name of the JSX element
* @param {JSXOptions & Record<string, any>} [options={}] - Options including type, children, and JSX attributes
* @returns {import('unist').Node} The created MDX JSX element node
*/
export const createJSXElement = (
name,
{ inline = true, children = [], ...attributes } = {}
) => {
// Convert string children to text node or use array directly
const processedChildren =
typeof children === 'string'
? [createTree('text', { value: children })]
: children;

const elementType = inline
? AST_NODE_TYPES.MDX.JSX_INLINE_ELEMENT
: AST_NODE_TYPES.MDX.JSX_BLOCK_ELEMENT;

const attrs = Object.entries(attributes).map(([key, value]) =>
createAttributeNode(key, value)
);

return createTree(elementType, {
name,
attributes: attrs,
children: processedChildren,
});
};

/**
* Creates an MDX JSX attribute node based on the value type.
*
* @param {string} name - The attribute name
* @param {any} value - The attribute value
* @returns {import('unist').Node} The MDX JSX attribute node
*/
function createAttributeNode(name, value) {
// Use expression for objects and arrays
if (value !== null && typeof value === 'object') {
return createTree(AST_NODE_TYPES.MDX.JSX_ATTRIBUTE, {
name,
value: createTree(AST_NODE_TYPES.MDX.JSX_ATTRIBUTE_EXPRESSION, {
data: {
estree: {
type: AST_NODE_TYPES.ESTREE.PROGRAM,
body: [
{
type: AST_NODE_TYPES.ESTREE.EXPRESSION_STATEMENT,
expression: valueToEstree(value),
},
],
},
},
}),
});
}

// For primitives, use simple string conversion.
// If undefined, pass nothing.
return createTree(AST_NODE_TYPES.MDX.JSX_ATTRIBUTE, {
name,
value: value == null ? value : String(value),
});
}
Loading
Loading