Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
186 changes: 98 additions & 88 deletions npm-shrinkwrap.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@orama/orama": "^3.1.16",
"@orama/react-components": "^0.8.1",
"@rollup/plugin-virtual": "^3.0.2",
"@shikijs/twoslash": "^3.14.0",
"acorn": "^8.15.0",
"commander": "^14.0.2",
"dedent": "^1.7.0",
Expand All @@ -71,7 +72,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
2 changes: 1 addition & 1 deletion src/generators/legacy-html/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import HTMLMinifier from '@minify-html/node';

import buildContent from './utils/buildContent.mjs';
import dropdowns from './utils/buildDropdowns.mjs';
import { safeCopy } from './utils/safeCopy.mjs';
import tableOfContents from './utils/tableOfContents.mjs';
import { groupNodesByModule } from '../../utils/generators.mjs';
import { getRemarkRehype } from '../../utils/remark.mjs';
import { safeCopy } from '../../utils/safeCopy.mjs';

/**
* @typedef {{
Expand Down
55 changes: 25 additions & 30 deletions src/generators/web/index.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { readFile, writeFile } from 'node:fs/promises';
import { readFile } from 'node:fs/promises';
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';
import { safeWrite } from '../../utils/safeWrite.mjs';

/**
* This generator transforms JSX AST (Abstract Syntax Tree) entries into a complete
Expand All @@ -26,49 +27,43 @@ export default {
* @param {Partial<GeneratorOptions>} options
*/
async generate(entries, { output, version }) {
// Load the HTML template.
// Load the HTML template
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.
const astBuilders = createASTBuilder();

// This is necessary for the `executeServerCode` function to resolve modules
// within the dynamically executed server-side 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 at once
const { results, css, jsChunks } = await processJSXEntries(
entries,
template,
astBuilders,
requireFn,
{ version }
);

// Capture the main CSS bundle from the first processed entry.
if (!mainCss && css) {
mainCss = css;
// Write all files if output directory is specified
if (output) {
// Write all HTML files
for (const { html, api } of results) {
safeWrite(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 all JS chunks
for (const chunk of jsChunks) {
safeWrite(join(output, chunk.fileName), chunk.code, 'utf-8');
}
}

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

return results;
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>

{{importMap}}
</head>

<body>
<div id="root">{{dehydrated}}</div>
<script>{{clientBundleJs}}</script>
<script type="module">{{mainJsCode}}</script>
</body>
</html>
22 changes: 14 additions & 8 deletions src/generators/web/ui/components/CodeBox.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ export const getLanguageDisplayName = language => {
};

/** @param {import('react').PropsWithChildren<{ className: string }>} props */
export default ({ className, ...props }) => {
export default ({ className, children, ...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 @@ -41,11 +42,16 @@ export default ({ className, ...props }) => {
};

return (
<BaseCodeBox
onCopy={onCopy}
language={getLanguageDisplayName(language)}
{...props}
buttonText="Copy to clipboard"
/>
<>
<BaseCodeBox
onCopy={onCopy}
language={getLanguageDisplayName(language)}
{...props}
className={className}
buttonText="Copy to clipboard"
>
{children}
</BaseCodeBox>
</>
);
};
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
162 changes: 127 additions & 35 deletions src/generators/web/utils/bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,57 @@ 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 {Map<string, string> | string} codeOrMap - Map of {fileName: code} or single code string for server builds.
* @param {{ server: boolean }} options - Build configuration object.
*/
export default async function bundleCode(code, { server = false } = {}) {
export default async function bundleCode(codeOrMap, { server = false } = {}) {
// Store the import map HTML for later extraction
let importMapHtml = '';

// Convert input to Map format
const codeMap =
codeOrMap instanceof Map
? codeOrMap
: new Map([['entrypoint.jsx', codeOrMap]]);

/** @type {import('rolldown').OutputOptions} */
const serverOutputConfig = {
inlineDynamicImports: true,
};

/** @type {import('rolldown').InputOptions['experimental']} */
const clientExperimentalConfig = {
chunkImportMap: !server && {
baseUrl: './',
fileName: 'importmap.json',
},
};

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',
// Define the entry point module names — these are virtual (not real files).
// The virtual plugin will provide the actual code string under these names.
input: Array.from(codeMap.keys()),

// Enable experimental chunk import map for cache-busted module resolution
// https://rolldown.rs/options/experimental#chunkimportmap
// Also enable incremental builds for faster rebuilds (similar to Rollup's cache)
// https://rolldown.rs/options/experimental#incrementalbuild
experimental: server ? {} : clientExperimentalConfig,

// Configuration for the output bundle
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 with dynamic imports (allows 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,

// Enable code splitting for client builds to allow dynamic imports
// For server builds, inline everything into a single bundle
...(server ? serverOutputConfig : {}),
},

// Platform informs Rolldown of the environment-specific code behavior:
Expand All @@ -41,25 +73,28 @@ 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 configuration
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,16 +108,48 @@ 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,
}),
// The virtual plugin lets us define virtual files
// with the contents provided by the `codeMap` argument.
// These become the root modules for the bundler.
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(),

// Extract import map from Rolldown's chunkImportMap output
// https://rolldown.rs/options/experimental#chunkimportmap
{
name: 'extract-import-map',
/**
* Extract import map from bundle
* @param {*} _ - Options (unused)
* @param {*} bundle - Bundle object
*/
generateBundle(_, bundle) {
const chunkImportMap = bundle['importmap.json'];

if (chunkImportMap?.type === 'asset') {
// Parse the import map and filter out virtual entries
const importMapData = JSON.parse(chunkImportMap.source);

// Remove any references to _virtual_ or virtual entrypoint files
if (importMapData.imports) {
for (const key in importMapData.imports) {
if (key.includes('_virtual_') || key.includes('entrypoint')) {
delete importMapData.imports[key];
}
}
}

// Extract the import map and convert to HTML script tag
importMapHtml = `<script type="importmap">${JSON.stringify(importMapData)}</script>`;

// Remove from bundle so it's not written as a separate file
delete bundle['importmap.json'];
}
},
},
],

// Enable tree-shaking to eliminate unused imports, functions, and branches.
Expand All @@ -96,12 +163,37 @@ export default async function bundleCode(code, { server = 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 entry chunks from other chunks (CSS and code-split JS)
const entryChunks = [];
const otherChunks = [];

// Separate the entrypoints from remaining JavaScript files
for (const chunk of result.output) {
const chunkTarget =
chunk.type === 'chunk' && chunk.isEntry ? entryChunks : otherChunks;

chunkTarget['push'](chunk);
}

// Separate CSS files from JS chunks
const cssFiles = otherChunks.filter(chunk => chunk.type === 'asset');
const jsChunks = otherChunks.filter(chunk => chunk.type === 'chunk');

// For client builds, create a map of entry code by original fileName
const jsMap = {};

for (const chunk of entryChunks) {
// Map back to original fileName from facadeModuleId
const originalFileName =
chunk.facadeModuleId?.split('/').pop() || chunk.fileName;

jsMap[originalFileName.replace('\x00virtual:', '')] = chunk.code;
}

return {
js: js.code,
jsMap,
jsChunks: jsChunks.map(({ fileName, code }) => ({ fileName, code })),
css: cssFiles.map(f => f.source).join(''),
importMapHtml,
};
}
Loading
Loading