Skip to content
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
171 changes: 83 additions & 88 deletions npm-shrinkwrap.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"remark-stringify": "^11.0.0",
"rolldown": "^1.0.0-beta.40",
"rolldown": "^1.0.0-beta.47",
"semver": "^7.7.2",
"shiki": "^3.15.0",
"unified": "^11.0.5",
Expand Down
15 changes: 15 additions & 0 deletions src/generators/web/constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,18 @@ export const JSX_IMPORTS = {
source: '@node-core/ui-components/Providers/NotificationProvider',
},
};

/**
* Specification rules for resource hints like prerendering and prefetching.
* @see https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API
*/
export const SPECULATION_RULES = {
// Eagerly prefetch all links that point to the API docs themselves
// in a moderate eagerness to improve resource loading
prefetch: [{ where: { href_matches: '/*' }, eagerness: 'eager' }],
prerender: [
// Eagerly prerender Sidebar links for faster navigation
// These will be done in a moderate eagerness (hover, likely next navigation)
{ where: { selector_matches: '[rel~=prefetch]' }, eagerness: 'moderate' },
],
};
72 changes: 36 additions & 36 deletions src/generators/web/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { createRequire } from 'node:module';
import { join } from 'node:path';

import createASTBuilder from './utils/generate.mjs';
import { processJSXEntry } from './utils/processing.mjs';
import { processJSXEntries } from './utils/processing.mjs';

/**
* This generator transforms JSX AST (Abstract Syntax Tree) entries into a complete
* web bundle, including server-side rendered HTML, client-side JavaScript, and CSS.
* Web generator - transforms JSX AST entries into complete web bundles.
*
* This generator processes JSX AST entries and produces:
* - Server-side rendered HTML pages
* - Client-side JavaScript with code splitting
* - Bundled CSS styles
*
* @type {GeneratorMetadata<Input, string>}
*/
Expand All @@ -18,57 +22,53 @@ export default {
dependsOn: 'jsx-ast',

/**
* The main generation function for the 'web' generator.
* It processes an array of JSX AST entries, converting each into a standalone HTML page
* with embedded client-side JavaScript and linked CSS.
* Main generation function that processes JSX AST entries into web bundles.
*
* @param {import('../jsx-ast/utils/buildContent.mjs').JSXContent[]} entries
* @param {Partial<GeneratorOptions>} options
* @param {import('../jsx-ast/utils/buildContent.mjs').JSXContent[]} entries - JSX AST entries to process.
* @param {Partial<GeneratorOptions>} options - Generator options.
* @param {string} [options.output] - Output directory for generated files.
* @param {string} options.version - Documentation version string.
* @returns {Promise<Array<{html: Buffer, css: string}>>} Generated HTML and CSS.
*/
async generate(entries, { output, version }) {
// Load the HTML template.
// Load the HTML template with placeholders
const template = await readFile(
new URL('template.html', import.meta.url),
'utf-8'
);

// These builders are responsible for converting the JSX AST into executable
// JavaScript code for both server-side rendering and client-side hydration.
// Create AST builders for server and client programs
const astBuilders = createASTBuilder();

// This is necessary for the `executeServerCode` function to resolve modules
// within the dynamically executed server-side code.
// Create require function for resolving external packages in server code
const requireFn = createRequire(import.meta.url);

const results = [];
let mainCss = '';

for (const entry of entries) {
const { html, css } = await processJSXEntry(
entry,
template,
astBuilders,
requireFn,
version
);
results.push({ html, css });
// Process all entries: convert JSX to HTML/CSS/JS
const { results, css, chunks } = await processJSXEntries(
entries,
template,
astBuilders,
requireFn,
{ version }
);

// Capture the main CSS bundle from the first processed entry.
if (!mainCss && css) {
mainCss = css;
// Write files to disk if output directory is specified
if (output) {
// Write HTML files
for (const { html, api } of results) {
await writeFile(join(output, `${api}.html`), html, 'utf-8');
}

// Write HTML file if output directory is specified
if (output) {
await writeFile(join(output, `${entry.data.api}.html`), html, 'utf-8');
// Write code-split JavaScript chunks
for (const chunk of chunks) {
await writeFile(join(output, chunk.fileName), chunk.code, 'utf-8');
}
}

if (output && mainCss) {
const filePath = join(output, 'styles.css');
await writeFile(filePath, mainCss, 'utf-8');
// Write CSS bundle
await writeFile(join(output, 'styles.css'), css, 'utf-8');
}

return results;
// Return HTML and CSS for each entry
return results.map(({ html }) => ({ html, css }));
},
};
4 changes: 3 additions & 1 deletion src/generators/web/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@

<!-- Apply theme before paint to avoid Flash of Unstyled Content -->
<script>document.documentElement.setAttribute("data-theme",localStorage.getItem("theme")||(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"));</script>
<script type="importmap">{{importMap}}</script>
<script type="speculationrules">{{speculationRules}}</script>
</head>

<body>
<div id="root">{{dehydrated}}</div>
<script>{{clientBundleJs}}</script>
<script type="module" src="{{entrypoint}}"></script>
</body>
</html>
6 changes: 4 additions & 2 deletions src/generators/web/ui/components/CodeBox.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const getLanguageDisplayName = language => {
/** @param {import('react').PropsWithChildren<{ className: string }>} props */
export default ({ className, ...props }) => {
const matches = className?.match(/language-(?<language>[a-zA-Z]+)/);

const language = matches?.groups?.language ?? '';

const notify = useNotification();
Expand All @@ -30,7 +31,7 @@ export default ({ className, ...props }) => {
await navigator.clipboard.writeText(text);

notify({
duration: 300,
duration: 3000,
message: (
<div className="flex items-center gap-3">
<CodeBracketIcon className={styles.icon} />
Expand All @@ -44,8 +45,9 @@ export default ({ className, ...props }) => {
<BaseCodeBox
onCopy={onCopy}
language={getLanguageDisplayName(language)}
{...props}
className={className}
buttonText="Copy to clipboard"
{...props}
/>
);
};
3 changes: 2 additions & 1 deletion src/generators/web/ui/components/SideBar/index.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Select from '@node-core/ui-components/Common/Select/index.js';
import Select from '@node-core/ui-components/Common/Select';
import SideBar from '@node-core/ui-components/Containers/Sidebar';

import styles from './index.module.css';
Expand Down Expand Up @@ -31,6 +31,7 @@ export default ({ versions, pathname, currentVersion, docPages }) => (
},
]}
onSelect={redirect}
as={props => <a {...props} rel="prefetch" />}
>
<div>
<Select
Expand Down
1 change: 1 addition & 0 deletions src/generators/web/ui/index.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@import '@node-core/ui-components/styles/index.css';
@import '@node-core/rehype-shiki/index.css';

/* Fonts */
:root {
Expand Down
100 changes: 56 additions & 44 deletions src/generators/web/utils/bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,33 @@ import staticData from './data.mjs';
* Asynchronously bundles JavaScript source code (and its CSS imports),
* targeting either browser (client) or server (Node.js) environments.
*
* @param {string} code - JavaScript/JSX source code to bundle.
* @param {{ server: boolean }} options - Build configuration object.
* @param {Map<string, string>} codeMap - Map of {fileName: code} for all builds.
* @param {Object} [options] - Build configuration object.
* @param {boolean} [options.server=false] - Whether this is a server-side build.
*/
export default async function bundleCode(code, { server = false } = {}) {
export default async function bundleCode(codeMap, { server = false } = {}) {
const result = await build({
// Define the entry point module name — this is virtual (not a real file).
// The virtual plugin will provide the actual code string under this name.
input: 'entrypoint.jsx',
// Entry points: array of virtual module names that the virtual plugin provides
input: Array.from(codeMap.keys()),

// Configuration for the output bundle
// Experimental features: import maps for client, none for server
experimental: {
chunkImportMap: !server,
},

// Output configuration
output: {
// Output module format:
// - "cjs" for CommonJS (used in Node.js environments)
// - "iife" for browser environments (self-contained script tag)
format: server ? 'cjs' : 'iife',
// - "esm" for browser environments (Using Chunk Code-Splitting)
format: server ? 'cjs' : 'esm',

// Minify output only for browser builds to optimize file size.
// Server builds are usually not minified to preserve stack traces and debuggability.
minify: !server,

// Within server builds we want to ensure dynamic imports get inlined whenever possible.
inlineDynamicImports: server,
},

// Platform informs Rolldown of the environment-specific code behavior:
Expand All @@ -41,25 +49,27 @@ export default async function bundleCode(code, { server = false } = {}) {
? ['preact', 'preact-render-to-string', '@node-core/ui-components']
: [],

// Inject global compile-time constants that will be replaced in code.
// These are useful for tree-shaking and conditional branching.
// Be sure to update type declarations (`types.d.ts`) if these change.
define: {
// Static data injected directly into the bundle (as a literal or serialized JSON).
__STATIC_DATA__: staticData,

// Boolean flags used for conditional logic in source code:
// Example: `if (SERVER) {...}` or `if (CLIENT) {...}`
// These flags help split logic for server/client environments.
// Unused branches will be removed via tree-shaking.
SERVER: String(server),
CLIENT: String(!server),
},
transform: {
// Inject global compile-time constants that will be replaced in code.
// These are useful for tree-shaking and conditional branching.
// Be sure to update type declarations (`types.d.ts`) if these change.
define: {
// Static data injected directly into the bundle (as a literal or serialized JSON).
__STATIC_DATA__: staticData,

// Boolean flags used for conditional logic in source code:
// Example: `if (SERVER) {...}` or `if (CLIENT) {...}`
// These flags help split logic for server/client environments.
// Unused branches will be removed via tree-shaking.
SERVER: String(server),
CLIENT: String(!server),
},

// JSX transformation configuration.
// `'react-jsx'` enables the automatic JSX runtime, which doesn't require `import React`.
// Since we're using Preact via aliasing, this setting works well with `preact/compat`.
jsx: 'react-jsx',
// JSX transformation configuration.
// `'react-jsx'` enables the automatic JSX runtime, which doesn't require `import React`.
// Since we're using Preact via aliasing, this setting works well with `preact/compat`.
jsx: 'react-jsx',
},

// Module resolution aliases.
// This tells the bundler to use `preact/compat` wherever `react` or `react-dom` is imported.
Expand All @@ -73,35 +83,37 @@ export default async function bundleCode(code, { server = false } = {}) {

// Array of plugins to apply during the build.
plugins: [
// The virtual plugin lets us define a virtual file called 'entrypoint.jsx'
// with the contents provided by the `code` argument.
// This becomes the root module for the bundler.
virtual({
'entrypoint.jsx': code,
}),
// Virtual plugin: provides in-memory modules from codeMap
virtual(Object.fromEntries(codeMap)),

// Load CSS imports via the custom plugin.
// This plugin will collect imported CSS files and return them as `source` chunks.
cssLoader(),
],

// Enable tree-shaking to eliminate unused imports, functions, and branches.
// This works best when all dependencies are marked as having no side effects.
// `sideEffects: false` in the package.json confirms this is safe to do.
// Enable tree-shaking to remove unused code
treeshake: true,

// Disable writing output to disk.
// Instead, the compiled chunks are returned in memory (ideal for dev tools or sandboxing).
// Return chunks in memory instead of writing to disk
write: false,
});

// Destructure the result to get the output chunks.
// The first output is always the JavaScript entrypoint.
// Any additional chunks are styles (CSS).
const [js, ...cssFiles] = result.output;
// Separate CSS assets from JavaScript chunks
const assets = result.output.filter(c => c.type === 'asset');
const chunks = result.output.filter(c => c.type === 'chunk');

const importMap = assets.find(c => c.fileName === 'importmap.json');

return {
js: js.code,
css: cssFiles.map(f => f.source).join(''),
css: assets
.filter(c => c.fileName.endsWith('.css'))
.map(f => f.source)
.join(''),
chunks: chunks.map(({ fileName, code, isEntry }) => ({
fileName: fileName.replace('_virtual_', ''),
isEntry,
code,
})),
importMap: importMap?.source.toString(),
};
}
43 changes: 43 additions & 0 deletions src/generators/web/utils/chunks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Creates an enhanced require function that can resolve code-split chunks
* from a virtual file system before falling back to Node.js require.
*
* @param {Array<{fileName: string, code: string}>} chunks - Array of code-split chunks from bundler.
* @param {ReturnType<import('node:module').createRequire>} requireFn - Node.js require function for external packages.
*/
export function createChunkedRequire(chunks, requireFn) {
// Create a virtual file system from code-split chunks
const chunkModules = Object.fromEntries(
chunks.map(c => [`./${c.fileName}`, c.code])
);

/**
* Enhanced require function that resolves code-split chunks from virtual file system.
*
* @param {string} modulePath - Module path to require.
* @returns {*} Module exports.
*/
const chunkedRequire = modulePath => {
// Check virtual file system first for code-split chunks
if (chunkModules[modulePath]) {
const mod = { exports: {} };

// Execute chunk code in isolated context with its own module.exports
const chunkFn = new Function(
'module',
'exports',
'require',
chunkModules[modulePath]
);

chunkFn(mod, mod.exports, chunkedRequire);

return mod.exports;
}

// Fall back to Node.js require for external packages
return requireFn(modulePath);
};

return chunkedRequire;
}
Loading
Loading