blueprint/lib/HTMLGenerator.js
2025-03-27 17:28:51 +01:00

314 lines
10 KiB
JavaScript

const { ELEMENT_MAPPINGS } = require("./mappings");
const TextNodeGenerator = require("./generators/TextNodeGenerator");
const ButtonElementGenerator = require("./generators/ButtonElementGenerator");
const LinkElementGenerator = require("./generators/LinkElementGenerator");
const StandardElementGenerator = require("./generators/StandardElementGenerator");
const RootNodeGenerator = require("./generators/RootNodeGenerator");
const InputElementGenerator = require("./generators/InputElementGenerator");
const MediaElementGenerator = require("./generators/MediaElementGenerator");
const JavaScriptGenerator = require("./generators/JavaScriptGenerator");
const ServerCodeGenerator = require("./generators/ServerCodeGenerator");
const HTMLTemplate = require("./templates/HTMLTemplate");
const StringUtils = require("./utils/StringUtils");
class HTMLGenerator {
/**
* Creates a new HTML generator instance.
* @param {Object} [options] - Options object
* @param {boolean} [options.minified=true] - Minify generated HTML
* @param {boolean} [options.debug=false] - Enable debug logging
* @param {CSSGenerator} cssGenerator - CSS generator instance
*/
constructor(options = {}, cssGenerator) {
this.options = options;
this.cssGenerator = cssGenerator;
this.htmlTemplate = new HTMLTemplate(options);
this.serverGenerator = new ServerCodeGenerator(options);
this.jsGenerator = new JavaScriptGenerator(options, this.serverGenerator);
this.currentElement = null;
this.generators = [
new TextNodeGenerator(this.options),
new RootNodeGenerator(this.options, this),
new ButtonElementGenerator(this.options, this.cssGenerator, this),
new LinkElementGenerator(this.options, this.cssGenerator, this),
new InputElementGenerator(this.options, this.cssGenerator, this),
new MediaElementGenerator(this.options, this.cssGenerator, this),
new StandardElementGenerator(this.options, this.cssGenerator, this)
];
if (this.options.debug) {
console.log(
"[HTMLGenerator] Initialized with options:",
JSON.stringify(options, null, 2)
);
console.log(
"[HTMLGenerator] Registered generators:",
this.generators.map(g => g.constructor.name).join(", ")
);
}
}
/**
* Converts a node to a string for debugging purposes, avoiding circular
* references.
* @param {Object} node - Node to stringify
* @returns {string} String representation of the node
*/
debugStringify(node) {
const getCircularReplacer = () => {
const seen = new WeakSet();
return (key, value) => {
if (key === "parent") return "[Circular:Parent]";
if (typeof value === "object" && value !== null) {
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
}
return value;
};
};
try {
return JSON.stringify(node, getCircularReplacer(), 2);
} catch (err) {
return `[Unable to stringify: ${err.message}]`;
}
}
/**
* Generates HTML for a node, delegating to the appropriate generator.
* @param {Object} node - Node to generate HTML for
* @returns {string} Generated HTML
*/
generateHTML(node) {
if (this.options.debug) {
console.log(`\n[HTMLGenerator] Generating HTML for node`);
console.log(`[HTMLGenerator] Node type: "${node.type}"`);
console.log("[HTMLGenerator] Node details:", StringUtils.safeStringify(node));
}
// Handle client and server blocks
if (node.type === "client" || node.type === "server") {
return this.handleScriptBlock(node);
}
if (node.type === "element" && node.tag === "page") {
if (this.options.debug) {
console.log("[HTMLGenerator] Skipping page node - metadata only");
}
return "";
}
const prevElement = this.currentElement;
this.currentElement = node;
// Check if this element has an explicit ID in its props
if (node.type === "element") {
const idProp = node.props.find(p => typeof p === "string" && p.startsWith("id:"));
if (idProp) {
const idValue = idProp.substring(idProp.indexOf(":") + 1).trim().replace(/^"|"$/g, "");
node.elementId = idValue;
// Register this element as reactive
this.jsGenerator.registerReactiveElement(idValue);
if (this.options.debug) {
console.log(`[HTMLGenerator] Found explicit ID: ${idValue}, registered as reactive`);
}
}
}
let result = "";
for (const generator of this.generators) {
if (generator.canHandle(node)) {
if (this.options.debug) {
console.log(`[HTMLGenerator] Using ${generator.constructor.name} for node`);
}
// If this is an element that might have event handlers,
// add a unique ID to it for client scripts if it doesn't already have one
if (node.type === "element" && node.children.some(child => child.type === "client")) {
// Generate a unique ID for this element if it doesn't already have one
if (!node.elementId) {
node.elementId = this.jsGenerator.generateElementId();
if (this.options.debug) {
console.log(`[HTMLGenerator] Generated ID for element: ${node.elementId}`);
}
}
result = generator.generate(node);
// Process all client blocks inside this element
node.children
.filter(child => child.type === "client")
.forEach(clientBlock => {
this.handleScriptBlock(clientBlock, node.elementId);
});
} else {
result = generator.generate(node);
}
this.currentElement = prevElement;
return result;
}
}
if (this.options.debug) {
console.log(`[HTMLGenerator] No generator found for node type: ${node.type}`);
}
this.currentElement = prevElement;
return "";
}
/**
* Handles client and server script blocks.
* @param {Object} node - The script node to handle
* @param {string} [elementId] - The ID of the parent element, if any
* @returns {string} - Empty string as script blocks don't directly generate HTML
*/
handleScriptBlock(node, elementId = null) {
if (this.options.debug) {
console.log(`\n[HTMLGenerator] Processing ${node.type} script block`);
console.log(`[HTMLGenerator] Script content (first 50 chars): "${node.script.substring(0, 50)}..."`);
if (elementId) {
console.log(`[HTMLGenerator] Attaching to element: ${elementId}`);
}
}
if (node.type === "client") {
if (!elementId && this.currentElement) {
if (!this.currentElement.elementId) {
this.currentElement.elementId = this.jsGenerator.generateElementId();
}
elementId = this.currentElement.elementId;
}
if (elementId) {
this.jsGenerator.addClientScript(elementId, node.script);
} else {
if (this.options.debug) {
console.log(`[HTMLGenerator] Warning: Client script with no parent element`);
}
}
} else if (node.type === "server") {
if (!elementId && this.currentElement) {
if (!this.currentElement.elementId) {
this.currentElement.elementId = this.jsGenerator.generateElementId();
}
elementId = this.currentElement.elementId;
}
if (elementId) {
const params = node.params || [];
if (this.options.debug && params.length > 0) {
console.log(`[HTMLGenerator] Server block parameters: ${params.join(", ")}`);
}
this.jsGenerator.addServerScript(elementId, node.script, params);
} else {
if (this.options.debug) {
console.log(`[HTMLGenerator] Warning: Server script with no parent element`);
}
}
}
return "";
}
/**
* Processes a link node, extracting the href attribute and converting it
* to an internal link if it doesn't start with http:// or https://.
*
* If no href property is found, the default value of # is used.
*
* @param {Object} node - The link node to process
* @returns {Object} - An object containing the final href value
*/
processLink(node) {
if (this.options.debug) {
console.log("\n[HTMLGenerator] Processing link node");
console.log(
"[HTMLGenerator] Link properties:",
this.debugStringify(node.props)
);
}
const hrefProp = node.props.find((p) => p.startsWith("href:"));
let href = "#";
if (hrefProp) {
let hrefTarget = hrefProp
.substring(hrefProp.indexOf(":") + 1)
.trim()
.replace(/^"|"$/g, "");
if (
!hrefTarget.startsWith("http://") &&
!hrefTarget.startsWith("https://")
) {
hrefTarget = "/" + hrefTarget;
if (this.options.debug) {
console.log(
`[HTMLGenerator] Converted to internal link: "${hrefTarget}"`
);
}
} else {
if (this.options.debug) {
console.log(
`[HTMLGenerator] External link detected: "${hrefTarget}"`
);
}
}
href = hrefTarget;
} else {
if (this.options.debug) {
console.log(
"[HTMLGenerator] No href property found, using default: '#'"
);
}
}
if (this.options.debug) {
console.log(`[HTMLGenerator] Final href value: "${href}"`);
}
return { href };
}
/**
* Generates the final HTML document as a string.
*
* @param {string} headContent - The HTML content to be placed within the <head> tag.
* @param {string} bodyContent - The HTML content to be placed within the <body> tag.
* @returns {string} - A complete HTML document containing the provided head and body content.
*/
generateFinalHtml(headContent, bodyContent) {
const clientScripts = this.jsGenerator.generateClientScripts();
return this.htmlTemplate.generateDocument(
headContent,
bodyContent + clientScripts
);
}
/**
* Generates server-side code for Express.js API routes.
* @returns {string} - Express.js server code
*/
generateServerCode() {
return this.serverGenerator.generateServerCode();
}
/**
* Checks if there is any server code to generate.
* @returns {boolean} - Whether there is server code
*/
hasServerCode() {
return this.serverGenerator.hasServerCodeToGenerate();
}
}
module.exports = HTMLGenerator;