react-to-html
Version:
A react package to generate HTML for conversion to Email or PDF from shared components
179 lines (178 loc) • 6.97 kB
JavaScript
import ts from 'typescript';
import fs, { readFileSync } from 'fs';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import path from 'path';
import postcss from 'postcss';
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
import pkg from 'js-beautify';
import { minify } from 'html-minifier';
const { html: beautify } = pkg;
// Console colors
const colors = {
reset: '\x1b[0m',
blue: '\x1b[34m',
green: '\x1b[32m',
red: '\x1b[31m',
};
// Replace camelCase with kebab-case
function toKebabCase(str) {
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
}
// Transpile modules
function transpileModule(filePath) {
const tsConfig = {
jsx: ts.JsxEmit.React,
esModuleInterop: true,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ESNext,
};
const fileContents = fs.readFileSync(filePath, 'utf8');
const transpiled = ts.transpileModule(fileContents, { compilerOptions: tsConfig });
return transpiled.outputText;
}
// Function to generate critical CSS using Tailwind and PurgeCSS
async function generateCriticalCSS(htmlContent, userSettings) {
// Load global styles if specified
let globalStyles = '';
if (userSettings.globalStyles) {
console.log(`→ Loading global styles from ${userSettings.globalStyles}`);
try {
globalStyles = readFileSync(userSettings.globalStyles, 'utf8');
}
catch (err) {
console.error(`Error loading global styles from ${userSettings.globalStyles}:`, err);
}
}
const css = `${globalStyles} @tailwind base; @tailwind components; @tailwind utilities;`;
return postcss([
tailwindcss(),
autoprefixer({
overrideBrowserslist: ['last 2 versions', '> 1%'],
grid: true
}),
])
.process(css, { from: undefined })
.then((result) => result.css);
}
export const renderReactToHTML = async () => {
console.log(`${colors.blue}Building...${colors.reset}`);
/**
* Load the user settings from the tohtml.config.js file
* located in the root of the project
*/
const userSettingsPath = `${process.cwd()}/tohtml.config.js`;
let userSettings;
try {
const module = await import(userSettingsPath);
userSettings = module.settings;
}
catch (error) {
console.error(`Error loading the react-to-html configuration file from ${userSettingsPath}`);
console.error(error);
process.exit(1);
}
/**
* Create the build directory if it doesn't exist
* This is where the generated HTML files will be saved
*/
const outputDir = userSettings.outputDir || 'build-html';
if (!fs.existsSync(outputDir)) {
console.log(`${colors.blue}Creating the build directory...${colors.reset}`);
fs.mkdirSync(outputDir);
}
console.log(' ');
/**
* Loop through the content array in the user settings
* and process each component
*/
for (const [index, contentItem] of userSettings.content.entries()) {
const componentBaseName = contentItem.path.split('/').pop()?.replace(/\.tsx$/, '') || `Component ${index + 1}`;
console.log(`${colors.reset}∴ Processing ${componentBaseName}...${colors.reset}`);
/**
* Transpile the component file to JavaScript
*/
console.log("→ Transpiling the component...");
const componentFullPath = path.resolve(process.cwd(), contentItem.path);
const componentDir = path.dirname(componentFullPath); // Get directory of the component
const tempFilePath = path.join(componentDir, `tempComponent${index}.mjs`); // Create temp file path in the same directory
const transpiledCode = transpileModule(componentFullPath);
fs.writeFileSync(tempFilePath, transpiledCode);
/**
* Render the component to HTML
* and save the output to a file
*/
try {
// Start timer
const startTime = Date.now();
// Load the component
const Component = (await import(tempFilePath)).default;
const componentContent = ReactDOMServer.renderToStaticMarkup(React.createElement(Component, contentItem.props));
// Generate CSS
const css = await generateCriticalCSS(componentContent, userSettings);
console.log("✔ Generated critical CSS");
// Default decorator
const defaultDecorator = (content, styles) => {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body, html {
height: ${userSettings.pageHeight ? userSettings.pageHeight : 'auto'};
width: ${userSettings.pageWidth ? userSettings.pageWidth : '100%'};
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: system-ui, sans-serif;
}
${styles}
</style>
</head>
<body>
${content}
</body>
</html>
`;
};
// Decorate the component
const decorate = userSettings.decorator ? userSettings.decorator : defaultDecorator;
let htmlOutput = decorate(componentContent, css);
// Format the output
if (userSettings.outputFormat === 'minified') {
htmlOutput = minify(htmlOutput, {
removeAttributeQuotes: true,
collapseWhitespace: true,
removeComments: true,
minifyCSS: true,
});
}
else if (userSettings.outputFormat === 'pretty') {
htmlOutput = beautify(htmlOutput, { indent_size: 2 });
}
// Rename the component file to kebab-case
const kebabComponentName = toKebabCase(componentBaseName);
const filename = `${kebabComponentName}.html`;
// Save the output to a file
fs.writeFileSync(`${outputDir}/${filename}`, htmlOutput);
// End timer
const duration = Date.now() - startTime;
// Log success message
console.log(`${colors.green}✔ Successfully processed ${colors.reset}${componentBaseName} ${colors.green}in ${duration}ms${colors.reset}`);
console.log(' ');
}
finally {
if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath);
}
}
}
// Log completion message
console.log(' ');
console.log(`${colors.green}✔ Complete!${colors.reset}`);
console.log(`→ Your components have been built to ./${outputDir}/`);
console.log(' ');
};