feat: reorganize builder

This commit is contained in:
obvTiger 2025-03-27 13:28:12 +01:00
parent ff7bb041ef
commit 362b7aa15e
18 changed files with 1094 additions and 310 deletions

View file

@ -1,11 +1,12 @@
const fs = require("fs");
const path = require("path");
const TokenParser = require("./TokenParser");
const ASTBuilder = require("./ASTBuilder");
const CSSGenerator = require("./CSSGenerator");
const HTMLGenerator = require("./HTMLGenerator");
const MetadataManager = require("./MetadataManager");
const BlueprintCompiler = require("./BlueprintCompiler");
const BlueprintFileHandler = require("./BlueprintFileHandler");
/**
* BlueprintBuilder coordinates the entire build process from reading Blueprint files
* to writing compiled HTML and CSS files.
*/
class BlueprintBuilder {
/**
* Create a new Blueprint builder instance.
@ -20,14 +21,10 @@ class BlueprintBuilder {
...options,
};
this.tokenParser = new TokenParser(this.options);
this.astBuilder = new ASTBuilder(this.options);
this.cssGenerator = new CSSGenerator(this.options);
this.htmlGenerator = new HTMLGenerator(this.options, this.cssGenerator);
this.metadataManager = new MetadataManager(this.options);
this.compiler = new BlueprintCompiler(this.options);
this.fileHandler = new BlueprintFileHandler(this.options);
}
/**
* Builds a Blueprint file.
* @param {string} inputPath - Path to the Blueprint file to build
@ -40,42 +37,23 @@ class BlueprintBuilder {
}
try {
if (!inputPath.endsWith(".bp")) {
throw new Error("Input file must have .bp extension");
}
const input = fs.readFileSync(inputPath, "utf8");
const tokens = this.tokenParser.tokenize(input);
const ast = this.astBuilder.buildAST(tokens);
const pageNode = ast.children.find((node) => node.tag === "page");
if (pageNode) {
this.metadataManager.processPageMetadata(pageNode);
}
const html = this.htmlGenerator.generateHTML(ast);
const css = this.cssGenerator.generateCSS();
const input = this.fileHandler.readBlueprintFile(inputPath);
const baseName = path.basename(inputPath, ".bp");
const headContent = this.metadataManager.generateHeadContent(baseName);
const finalHtml = this.generateFinalHtml(headContent, html);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const result = this.compiler.compile(input, baseName);
fs.writeFileSync(path.join(outputDir, `${baseName}.html`), finalHtml);
fs.writeFileSync(path.join(outputDir, `${baseName}.css`), css);
if (result.success) {
this.fileHandler.writeCompiledFiles(outputDir, baseName, result.html, result.css);
if (this.options.debug) {
console.log("[DEBUG] Build completed successfully");
if (this.options.debug) {
console.log("[DEBUG] Build completed successfully");
}
}
return {
success: true,
errors: [],
success: result.success,
errors: result.errors,
};
} catch (error) {
if (this.options.debug) {
@ -94,51 +72,6 @@ class BlueprintBuilder {
};
}
}
/**
* 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) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${headContent}
<style>
:root {
--navbar-height: 4rem;
}
body {
margin: 0;
padding: 0;
padding-top: var(--navbar-height);
background-color: #0d1117;
color: #e6edf3;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.5;
min-height: 100vh;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
::selection {
background-color: rgba(59, 130, 246, 0.2);
}
</style>
</head>
<body>
${bodyContent}
</body>
</html>`;
}
}
module.exports = BlueprintBuilder;

89
lib/BlueprintCompiler.js Normal file
View file

@ -0,0 +1,89 @@
const TokenParser = require("./TokenParser");
const ASTBuilder = require("./ASTBuilder");
const CSSGenerator = require("./CSSGenerator");
const HTMLGenerator = require("./HTMLGenerator");
const MetadataManager = require("./MetadataManager");
/**
* BlueprintCompiler handles the core compilation process of transforming Blueprint syntax
* into HTML and CSS, without handling file I/O operations.
*/
class BlueprintCompiler {
/**
* Create a new Blueprint compiler instance.
* @param {Object} [options] - Options object
* @param {boolean} [options.minified=true] - Minify generated HTML and CSS
* @param {boolean} [options.debug=false] - Enable debug logging
*/
constructor(options = {}) {
this.options = {
minified: true,
debug: false,
...options,
};
this.tokenParser = new TokenParser(this.options);
this.astBuilder = new ASTBuilder(this.options);
this.cssGenerator = new CSSGenerator(this.options);
this.htmlGenerator = new HTMLGenerator(this.options, this.cssGenerator);
this.metadataManager = new MetadataManager(this.options);
}
/**
* Compiles Blueprint code into HTML and CSS.
* @param {string} blueprintCode - Blueprint source code to compile
* @param {string} baseName - Base name for the generated files
* @returns {Object} - Compilation result with HTML and CSS content
*/
compile(blueprintCode, baseName) {
if (this.options.debug) {
console.log(`[DEBUG] Starting compilation`);
}
try {
const tokens = this.tokenParser.tokenize(blueprintCode);
const ast = this.astBuilder.buildAST(tokens);
const pageNode = ast.children.find((node) => node.tag === "page");
if (pageNode) {
this.metadataManager.processPageMetadata(pageNode);
}
const html = this.htmlGenerator.generateHTML(ast);
const css = this.cssGenerator.generateCSS();
const headContent = this.metadataManager.generateHeadContent(baseName);
const finalHtml = this.htmlGenerator.generateFinalHtml(headContent, html);
if (this.options.debug) {
console.log("[DEBUG] Compilation completed successfully");
}
return {
success: true,
html: finalHtml,
css: css,
errors: [],
};
} catch (error) {
if (this.options.debug) {
console.log("[DEBUG] Compilation failed with error:", error);
}
return {
success: false,
html: null,
css: null,
errors: [
{
message: error.message,
type: error.name,
line: error.line,
column: error.column,
},
],
};
}
}
}
module.exports = BlueprintCompiler;

View file

@ -0,0 +1,55 @@
const path = require("path");
const BlueprintFileReader = require("./file/BlueprintFileReader");
const BlueprintFileWriter = require("./file/BlueprintFileWriter");
/**
* BlueprintFileHandler coordinates file I/O operations for the Blueprint compiler.
*/
class BlueprintFileHandler {
/**
* Create a new BlueprintFileHandler instance.
* @param {Object} [options] - Options object
* @param {boolean} [options.debug=false] - Enable debug logging
*/
constructor(options = {}) {
this.options = {
debug: false,
...options,
};
this.reader = new BlueprintFileReader(this.options);
this.writer = new BlueprintFileWriter(this.options);
}
/**
* Reads a Blueprint file from the file system.
* @param {string} inputPath - Path to the Blueprint file
* @returns {string} - The content of the file
* @throws {Error} - If the file does not exist or has an invalid extension
*/
readBlueprintFile(inputPath) {
return this.reader.readFile(inputPath);
}
/**
* Writes the compiled HTML and CSS to the file system.
* @param {string} outputDir - Directory to write the files to
* @param {string} baseName - Base name for the files
* @param {string} html - HTML content to write
* @param {string} css - CSS content to write
* @throws {Error} - If the output directory cannot be created or the files cannot be written
*/
writeCompiledFiles(outputDir, baseName, html, css) {
this.writer.writeCompiledFiles(outputDir, baseName, html, css);
}
/**
* Ensures that a directory exists, creating it if it does not.
* @param {string} dir - Directory path
*/
ensureDirectoryExists(dir) {
this.writer.ensureDirectoryExists(dir);
}
}
module.exports = BlueprintFileHandler;

View file

@ -1,4 +1,13 @@
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 HTMLTemplate = require("./templates/HTMLTemplate");
const StringUtils = require("./utils/StringUtils");
class HTMLGenerator {
/**
@ -11,11 +20,29 @@ class HTMLGenerator {
constructor(options = {}, cssGenerator) {
this.options = options;
this.cssGenerator = cssGenerator;
this.htmlTemplate = new HTMLTemplate(options);
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(", ")
);
}
}
@ -48,7 +75,7 @@ class HTMLGenerator {
}
/**
* Converts a node to a string of HTML.
* Generates HTML for a node, delegating to the appropriate generator.
* @param {Object} node - Node to generate HTML for
* @returns {string} Generated HTML
*/
@ -56,239 +83,32 @@ class HTMLGenerator {
if (this.options.debug) {
console.log(`\n[HTMLGenerator] Generating HTML for node`);
console.log(`[HTMLGenerator] Node type: "${node.type}"`);
console.log("[HTMLGenerator] Node details:", this.debugStringify(node));
console.log("[HTMLGenerator] Node details:", StringUtils.safeStringify(node));
}
if (node.type === "text") {
if (node.parent?.tag === "codeblock") {
if (this.options.debug) {
console.log("[HTMLGenerator] Rendering raw text for codeblock");
console.log(`[HTMLGenerator] Raw text content: "${node.value}"`);
}
return node.value;
}
const escapedText = node.value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
if (node.type === "element" && node.tag === "page") {
if (this.options.debug) {
console.log("[HTMLGenerator] Generated escaped text");
console.log(`[HTMLGenerator] Original: "${node.value}"`);
console.log(`[HTMLGenerator] Escaped: "${escapedText}"`);
console.log("[HTMLGenerator] Skipping page node - metadata only");
}
return escapedText;
return "";
}
let html = "";
if (node.type === "element") {
if (node.tag === "page") {
for (const generator of this.generators) {
if (generator.canHandle(node)) {
if (this.options.debug) {
console.log("[HTMLGenerator] Skipping page node - metadata only");
console.log(`[HTMLGenerator] Using ${generator.constructor.name} for node`);
}
return "";
return generator.generate(node);
}
const mapping = ELEMENT_MAPPINGS[node.tag];
let tag = mapping ? mapping.tag : "div";
const className = this.cssGenerator.generateClassName(node.tag);
const { cssProps, nestedRules } =
this.cssGenerator.nodeToCSSProperties(node);
if (this.options.debug) {
console.log(`\n[HTMLGenerator] Processing element node`);
console.log(`[HTMLGenerator] Tag: "${node.tag}" -> "${tag}"`);
console.log(`[HTMLGenerator] Generated class name: "${className}"`);
console.log(
"[HTMLGenerator] CSS properties:",
this.debugStringify(Object.fromEntries(cssProps))
);
console.log(
"[HTMLGenerator] Nested rules:",
this.debugStringify(Object.fromEntries(nestedRules))
);
}
let attributes = "";
if (tag === "input") {
if (node.tag === "checkbox") {
attributes = ' type="checkbox"';
} else if (node.tag === "radio") {
attributes = ' type="radio"';
} else if (node.tag === "switch") {
attributes = ' type="checkbox" role="switch"';
} else if (node.tag === "slider") {
attributes = ' type="range"';
}
if (this.options.debug) {
console.log(
`[HTMLGenerator] Added input attributes: "${attributes}"`
);
}
}
if (node.tag === "media") {
const srcProp = node.props.find((p) => p.startsWith("src:"));
const typeProp = node.props.find((p) => p.startsWith("type:"));
if (!srcProp) {
throw new BlueprintError("Media element requires src property", node.line, node.column);
}
const src = srcProp.substring(srcProp.indexOf(":") + 1).trim();
const type = typeProp ? typeProp.substring(typeProp.indexOf(":") + 1).trim() : "img";
if (type === "video") {
tag = "video";
attributes = ` src="${src}" controls`;
} else {
tag = "img";
attributes = ` src="${src}" alt="${node.children.map(child => this.generateHTML(child)).join("")}"`;
}
}
if (node.tag === "link") {
const linkInfo = this.processLink(node);
attributes += ` href="${linkInfo.href}"`;
if (
linkInfo.href.startsWith("http://") ||
linkInfo.href.startsWith("https://")
) {
attributes += ` target="_blank" rel="noopener noreferrer"`;
if (this.options.debug) {
console.log(
`[HTMLGenerator] Added external link attributes for: ${linkInfo.href}`
);
}
} else {
if (this.options.debug) {
console.log(
`[HTMLGenerator] Added internal link attributes for: ${linkInfo.href}`
);
}
}
}
if (
node.props.find((p) => typeof p === "string" && p.startsWith("data-"))
) {
const dataProps = node.props.filter(
(p) => typeof p === "string" && p.startsWith("data-")
);
attributes += " " + dataProps.map((p) => `${p}`).join(" ");
if (this.options.debug) {
console.log(
`[HTMLGenerator] Added data attributes:`,
this.debugStringify(dataProps)
);
}
}
this.cssGenerator.cssRules.set(`.${className}`, {
cssProps,
nestedRules,
});
if (this.options.debug) {
console.log(
`[HTMLGenerator] Registered CSS rules for class: .${className}`
);
}
if (node.tag === "button" || node.tag.startsWith("button-")) {
if (node.parent?.tag === "link") {
const linkInfo = this.processLink(node.parent);
if (
linkInfo.href.startsWith("http://") ||
linkInfo.href.startsWith("https://")
) {
attributes += ` onclick="window.open('${linkInfo.href}', '_blank', 'noopener,noreferrer')"`;
if (this.options.debug) {
console.log(
`[HTMLGenerator] Added external button click handler for: ${linkInfo.href}`
);
}
} else {
attributes += ` onclick="window.location.href='${linkInfo.href}'"`;
if (this.options.debug) {
console.log(
`[HTMLGenerator] Added internal button click handler for: ${linkInfo.href}`
);
}
}
}
html += `<button class="${className}"${attributes}>${
this.options.minified ? "" : "\n"
}`;
if (this.options.debug) {
console.log(
`[HTMLGenerator] Generated button opening tag with attributes:`,
this.debugStringify({ class: className, ...attributes })
);
}
node.children.forEach((child) => {
child.parent = node;
html += this.generateHTML(child);
});
html += `${this.options.minified ? "" : "\n"}</button>${
this.options.minified ? "" : "\n"
}`;
} else if (
node.tag === "link" &&
node.children.length === 1 &&
(node.children[0].tag === "button" ||
node.children[0].tag?.startsWith("button-"))
) {
if (this.options.debug) {
console.log(
"[HTMLGenerator] Processing button inside link - using button's HTML"
);
}
node.children[0].parent = node;
html += this.generateHTML(node.children[0]);
} else {
html += `<${tag} class="${className}"${attributes}>${
this.options.minified ? "" : "\n"
}`;
if (this.options.debug) {
console.log(
`[HTMLGenerator] Generated opening tag: <${tag}> with attributes:`,
this.debugStringify({ class: className, ...attributes })
);
}
node.children.forEach((child) => {
child.parent = node;
html += this.generateHTML(child);
});
html += `${this.options.minified ? "" : "\n"}</${tag}>${
this.options.minified ? "" : "\n"
}`;
if (this.options.debug) {
console.log(`[HTMLGenerator] Completed element: ${tag}`);
}
}
} else if (node.type === "root") {
if (this.options.debug) {
console.log(
`[HTMLGenerator] Processing root node with ${node.children.length} children`
);
}
node.children.forEach((child, index) => {
if (this.options.debug) {
console.log(
`[HTMLGenerator] Processing root child ${index + 1}/${
node.children.length
}`
);
}
html += this.generateHTML(child);
});
}
if (this.options.debug) {
console.log("[HTMLGenerator] Generated HTML:", html);
console.log(`[HTMLGenerator] No generator found for node type: ${node.type}`);
}
return html;
return "";
}
/**
@ -348,6 +168,17 @@ class HTMLGenerator {
}
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) {
return this.htmlTemplate.generateDocument(headContent, bodyContent);
}
}
module.exports = HTMLGenerator;

View file

@ -9,6 +9,7 @@ const options = {
minified: !args.includes("--readable"),
srcDir: "./src",
outDir: "./dist",
debug: args.includes("--debug"),
};
const server = new BlueprintServer(options);

View file

@ -0,0 +1,42 @@
const fs = require("fs");
/**
* Handles reading Blueprint files from the file system.
*/
class BlueprintFileReader {
/**
* Creates a new BlueprintFileReader instance.
* @param {Object} [options] - Options object
* @param {boolean} [options.debug=false] - Enable debug logging
*/
constructor(options = {}) {
this.options = {
debug: false,
...options,
};
}
/**
* Reads a Blueprint file from the file system.
* @param {string} inputPath - Path to the Blueprint file
* @returns {string} - The content of the file
* @throws {Error} - If the file does not exist or has an invalid extension
*/
readFile(inputPath) {
if (this.options.debug) {
console.log(`[DEBUG] Reading Blueprint file: ${inputPath}`);
}
if (!inputPath.endsWith(".bp")) {
throw new Error("Input file must have .bp extension");
}
if (!fs.existsSync(inputPath)) {
throw new Error(`File not found: ${inputPath}`);
}
return fs.readFileSync(inputPath, "utf8");
}
}
module.exports = BlueprintFileReader;

View file

@ -0,0 +1,78 @@
const fs = require("fs");
const path = require("path");
/**
* Handles writing compiled Blueprint files to the file system.
*/
class BlueprintFileWriter {
/**
* Creates a new BlueprintFileWriter instance.
* @param {Object} [options] - Options object
* @param {boolean} [options.debug=false] - Enable debug logging
*/
constructor(options = {}) {
this.options = {
debug: false,
...options,
};
}
/**
* Writes HTML content to a file.
* @param {string} outputPath - Path to write the HTML file
* @param {string} content - HTML content to write
*/
writeHtmlFile(outputPath, content) {
if (this.options.debug) {
console.log(`[DEBUG] Writing HTML file: ${outputPath}`);
}
this.ensureDirectoryExists(path.dirname(outputPath));
fs.writeFileSync(outputPath, content);
}
/**
* Writes CSS content to a file.
* @param {string} outputPath - Path to write the CSS file
* @param {string} content - CSS content to write
*/
writeCssFile(outputPath, content) {
if (this.options.debug) {
console.log(`[DEBUG] Writing CSS file: ${outputPath}`);
}
this.ensureDirectoryExists(path.dirname(outputPath));
fs.writeFileSync(outputPath, content);
}
/**
* Writes both HTML and CSS files for a Blueprint compilation result.
* @param {string} outputDir - Directory to write the files to
* @param {string} baseName - Base name for the files (without extension)
* @param {string} html - HTML content to write
* @param {string} css - CSS content to write
*/
writeCompiledFiles(outputDir, baseName, html, css) {
if (this.options.debug) {
console.log(`[DEBUG] Writing compiled files to: ${outputDir}/${baseName}`);
}
this.ensureDirectoryExists(outputDir);
this.writeHtmlFile(path.join(outputDir, `${baseName}.html`), html);
this.writeCssFile(path.join(outputDir, `${baseName}.css`), css);
}
/**
* Ensures that a directory exists, creating it if it does not.
* @param {string} dir - Directory path
*/
ensureDirectoryExists(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
if (this.options.debug) {
console.log(`[DEBUG] Created directory: ${dir}`);
}
}
}
}
module.exports = BlueprintFileWriter;

View file

@ -0,0 +1,81 @@
const LinkProcessor = require("../utils/LinkProcessor");
/**
* Generates HTML for button elements.
*/
class ButtonElementGenerator {
/**
* Creates a new button element generator.
* @param {Object} options - Options for the generator
* @param {CSSGenerator} cssGenerator - CSS generator instance
* @param {Object} parentGenerator - Parent HTML generator for recursion
*/
constructor(options, cssGenerator, parentGenerator) {
this.options = options;
this.cssGenerator = cssGenerator;
this.parentGenerator = parentGenerator;
this.linkProcessor = new LinkProcessor(options);
}
/**
* Determines if this generator can handle the given node.
* @param {Object} node - The node to check
* @returns {boolean} - True if this generator can handle the node
*/
canHandle(node) {
return (
node.type === "element" &&
(node.tag === "button" || node.tag.startsWith("button-"))
);
}
/**
* Generates HTML for a button element.
* @param {Object} node - The node to generate HTML for
* @returns {string} - The generated HTML
*/
generate(node) {
if (this.options.debug) {
console.log(`\n[ButtonElementGenerator] Processing button: ${node.tag}`);
}
const className = this.cssGenerator.generateClassName(node.tag);
const { cssProps, nestedRules } = this.cssGenerator.nodeToCSSProperties(node);
this.cssGenerator.cssRules.set(`.${className}`, {
cssProps,
nestedRules,
});
let attributes = "";
if (node.parent?.tag === "link") {
const linkInfo = this.linkProcessor.processLink(node.parent);
attributes += this.linkProcessor.getButtonClickHandler(linkInfo);
}
if (node.props.find((p) => typeof p === "string" && p.startsWith("data-"))) {
const dataProps = node.props.filter(
(p) => typeof p === "string" && p.startsWith("data-")
);
attributes += " " + dataProps.map((p) => `${p}`).join(" ");
}
let html = `<button class="${className}"${attributes}>${
this.options.minified ? "" : "\n"
}`;
node.children.forEach((child) => {
child.parent = node;
html += this.parentGenerator.generateHTML(child);
});
html += `${this.options.minified ? "" : "\n"}</button>${
this.options.minified ? "" : "\n"
}`;
return html;
}
}
module.exports = ButtonElementGenerator;

View file

@ -0,0 +1,88 @@
/**
* Generates HTML for input elements (checkbox, radio, switch, slider).
*/
class InputElementGenerator {
/**
* Creates a new input element generator.
* @param {Object} options - Options for the generator
* @param {CSSGenerator} cssGenerator - CSS generator instance
* @param {Object} parentGenerator - Parent HTML generator for recursion
*/
constructor(options, cssGenerator, parentGenerator) {
this.options = options;
this.cssGenerator = cssGenerator;
this.parentGenerator = parentGenerator;
}
/**
* Determines if this generator can handle the given node.
* @param {Object} node - The node to check
* @returns {boolean} - True if this generator can handle the node
*/
canHandle(node) {
return (
node.type === "element" &&
["checkbox", "radio", "switch", "slider"].includes(node.tag)
);
}
/**
* Generates HTML for an input element.
* @param {Object} node - The node to generate HTML for
* @returns {string} - The generated HTML
*/
generate(node) {
if (this.options.debug) {
console.log(`\n[InputElementGenerator] Processing input: ${node.tag}`);
}
const className = this.cssGenerator.generateClassName(node.tag);
const { cssProps, nestedRules } = this.cssGenerator.nodeToCSSProperties(node);
this.cssGenerator.cssRules.set(`.${className}`, {
cssProps,
nestedRules,
});
let attributes = "";
if (node.tag === "checkbox") {
attributes = ' type="checkbox"';
} else if (node.tag === "radio") {
attributes = ' type="radio"';
} else if (node.tag === "switch") {
attributes = ' type="checkbox" role="switch"';
} else if (node.tag === "slider") {
attributes = ' type="range"';
}
const valueProp = node.props.find((p) => p.startsWith("value:"));
if (valueProp) {
const value = valueProp.substring(valueProp.indexOf(":") + 1).trim();
attributes += ` value="${value}"`;
}
if (node.props.find((p) => typeof p === "string" && p.startsWith("data-"))) {
const dataProps = node.props.filter(
(p) => typeof p === "string" && p.startsWith("data-")
);
attributes += " " + dataProps.map((p) => `${p}`).join(" ");
}
if (node.children.length > 0) {
let html = `<label class="${className}-container">`;
html += `<input class="${className}"${attributes}>`;
node.children.forEach((child) => {
child.parent = node;
html += this.parentGenerator.generateHTML(child);
});
html += `</label>`;
return html;
} else {
return `<input class="${className}"${attributes}>`;
}
}
}
module.exports = InputElementGenerator;

View file

@ -0,0 +1,78 @@
const LinkProcessor = require("../utils/LinkProcessor");
/**
* Generates HTML for link elements.
*/
class LinkElementGenerator {
/**
* Creates a new link element generator.
* @param {Object} options - Options for the generator
* @param {CSSGenerator} cssGenerator - CSS generator instance
* @param {Object} parentGenerator - Parent HTML generator for recursion
*/
constructor(options, cssGenerator, parentGenerator) {
this.options = options;
this.cssGenerator = cssGenerator;
this.parentGenerator = parentGenerator;
this.linkProcessor = new LinkProcessor(options);
}
/**
* Determines if this generator can handle the given node.
* @param {Object} node - The node to check
* @returns {boolean} - True if this generator can handle the node
*/
canHandle(node) {
return node.type === "element" && node.tag === "link";
}
/**
* Generates HTML for a link element.
* @param {Object} node - The node to generate HTML for
* @returns {string} - The generated HTML
*/
generate(node) {
if (this.options.debug) {
console.log(`\n[LinkElementGenerator] Processing link`);
}
if (
node.children.length === 1 &&
(node.children[0].tag === "button" || node.children[0].tag?.startsWith("button-"))
) {
if (this.options.debug) {
console.log("[LinkElementGenerator] Processing button inside link - using button's HTML");
}
node.children[0].parent = node;
return this.parentGenerator.generateHTML(node.children[0]);
}
const className = this.cssGenerator.generateClassName(node.tag);
const { cssProps, nestedRules } = this.cssGenerator.nodeToCSSProperties(node);
this.cssGenerator.cssRules.set(`.${className}`, {
cssProps,
nestedRules,
});
const linkInfo = this.linkProcessor.processLink(node);
const attributes = this.linkProcessor.getLinkAttributes(linkInfo);
let html = `<a class="${className}"${attributes}>${
this.options.minified ? "" : "\n"
}`;
node.children.forEach((child) => {
child.parent = node;
html += this.parentGenerator.generateHTML(child);
});
html += `${this.options.minified ? "" : "\n"}</a>${
this.options.minified ? "" : "\n"
}`;
return html;
}
}
module.exports = LinkElementGenerator;

View file

@ -0,0 +1,121 @@
const BlueprintError = require("../BlueprintError");
/**
* Generates HTML for media elements (images and videos).
*/
class MediaElementGenerator {
/**
* Creates a new media element generator.
* @param {Object} options - Options for the generator
* @param {CSSGenerator} cssGenerator - CSS generator instance
* @param {Object} parentGenerator - Parent HTML generator for recursion
*/
constructor(options, cssGenerator, parentGenerator) {
this.options = options;
this.cssGenerator = cssGenerator;
this.parentGenerator = parentGenerator;
}
/**
* Determines if this generator can handle the given node.
* @param {Object} node - The node to check
* @returns {boolean} - True if this generator can handle the node
*/
canHandle(node) {
return node.type === "element" && node.tag === "media";
}
/**
* Generates HTML for a media element.
* @param {Object} node - The node to generate HTML for
* @returns {string} - The generated HTML
*/
generate(node) {
if (this.options.debug) {
console.log(`\n[MediaElementGenerator] Processing media element`);
}
const className = this.cssGenerator.generateClassName(node.tag);
const { cssProps, nestedRules } = this.cssGenerator.nodeToCSSProperties(node);
this.cssGenerator.cssRules.set(`.${className}`, {
cssProps,
nestedRules,
});
const srcProp = node.props.find((p) => p.startsWith("src:"));
const typeProp = node.props.find((p) => p.startsWith("type:"));
if (!srcProp) {
throw new BlueprintError("Media element requires src property", node.line, node.column);
}
const src = srcProp.substring(srcProp.indexOf(":") + 1).trim();
const type = typeProp ? typeProp.substring(typeProp.indexOf(":") + 1).trim() : "img";
let tag, attributes;
if (type === "video") {
tag = "video";
attributes = ` src="${src}" controls`;
const autoProp = node.props.find((p) => p === "autoplay");
if (autoProp) {
attributes += ` autoplay`;
}
const loopProp = node.props.find((p) => p === "loop");
if (loopProp) {
attributes += ` loop`;
}
const mutedProp = node.props.find((p) => p === "muted");
if (mutedProp) {
attributes += ` muted`;
}
} else {
tag = "img";
const altText = node.children.length > 0
? node.children.map(child =>
this.parentGenerator.generateHTML(child)).join("")
: src.split('/').pop();
attributes = ` src="${src}" alt="${altText}"`;
const loadingProp = node.props.find((p) => p.startsWith("loading:"));
if (loadingProp) {
const loadingValue = loadingProp.substring(loadingProp.indexOf(":") + 1).trim();
if (["lazy", "eager"].includes(loadingValue)) {
attributes += ` loading="${loadingValue}"`;
}
}
}
if (node.props.find((p) => typeof p === "string" && p.startsWith("data-"))) {
const dataProps = node.props.filter(
(p) => typeof p === "string" && p.startsWith("data-")
);
attributes += " " + dataProps.map((p) => `${p}`).join(" ");
}
if (tag === "img") {
return `<${tag} class="${className}"${attributes}>`;
} else {
let html = `<${tag} class="${className}"${attributes}>${
this.options.minified ? "" : "\n"
}`;
if (node.children.length > 0 && tag === "video") {
html += `<p>Your browser doesn't support video playback.</p>`;
}
html += `${this.options.minified ? "" : "\n"}</${tag}>${
this.options.minified ? "" : "\n"
}`;
return html;
}
}
}
module.exports = MediaElementGenerator;

View file

@ -0,0 +1,47 @@
/**
* Generates HTML for the root node of the AST.
*/
class RootNodeGenerator {
/**
* Creates a new root node generator.
* @param {Object} options - Options for the generator
* @param {Object} parentGenerator - Parent HTML generator for recursion
*/
constructor(options, parentGenerator) {
this.options = options;
this.parentGenerator = parentGenerator;
}
/**
* Determines if this generator can handle the given node.
* @param {Object} node - The node to check
* @returns {boolean} - True if this generator can handle the node
*/
canHandle(node) {
return node.type === "root";
}
/**
* Generates HTML for the root node.
* @param {Object} node - The node to generate HTML for
* @returns {string} - The generated HTML
*/
generate(node) {
if (this.options.debug) {
console.log(`\n[RootNodeGenerator] Processing root node with ${node.children.length} children`);
}
let html = "";
node.children.forEach((child, index) => {
if (this.options.debug) {
console.log(`[RootNodeGenerator] Processing child ${index + 1}/${node.children.length}`);
}
html += this.parentGenerator.generateHTML(child);
});
return html;
}
}
module.exports = RootNodeGenerator;

View file

@ -0,0 +1,81 @@
const { ELEMENT_MAPPINGS } = require("../mappings");
const StringUtils = require("../utils/StringUtils");
/**
* Generates HTML for standard elements.
*/
class StandardElementGenerator {
/**
* Creates a new standard element generator.
* @param {Object} options - Options for the generator
* @param {CSSGenerator} cssGenerator - CSS generator instance
* @param {Object} parentGenerator - Parent HTML generator for recursion
*/
constructor(options, cssGenerator, parentGenerator) {
this.options = options;
this.cssGenerator = cssGenerator;
this.parentGenerator = parentGenerator;
}
/**
* Determines if this generator can handle the given node.
* @param {Object} node - The node to check
* @returns {boolean} - True if this generator can handle the node
*/
canHandle(node) {
return (
node.type === "element" &&
node.tag !== "page" &&
!node.tag.startsWith("button") &&
node.tag !== "link" &&
node.tag !== "media"
);
}
/**
* Generates HTML for a standard element.
* @param {Object} node - The node to generate HTML for
* @returns {string} - The generated HTML
*/
generate(node) {
if (this.options.debug) {
console.log(`\n[StandardElementGenerator] Processing element node: ${node.tag}`);
}
const mapping = ELEMENT_MAPPINGS[node.tag];
const tag = mapping ? mapping.tag : "div";
const className = this.cssGenerator.generateClassName(node.tag);
const { cssProps, nestedRules } = this.cssGenerator.nodeToCSSProperties(node);
this.cssGenerator.cssRules.set(`.${className}`, {
cssProps,
nestedRules,
});
let attributes = "";
if (node.props.find((p) => typeof p === "string" && p.startsWith("data-"))) {
const dataProps = node.props.filter(
(p) => typeof p === "string" && p.startsWith("data-")
);
attributes += " " + dataProps.map((p) => `${p}`).join(" ");
}
let html = `<${tag} class="${className}"${attributes}>${
this.options.minified ? "" : "\n"
}`;
node.children.forEach((child) => {
child.parent = node;
html += this.parentGenerator.generateHTML(child);
});
html += `${this.options.minified ? "" : "\n"}</${tag}>${
this.options.minified ? "" : "\n"
}`;
return html;
}
}
module.exports = StandardElementGenerator;

View file

@ -0,0 +1,51 @@
const StringUtils = require("../utils/StringUtils");
/**
* Generates HTML for text nodes.
*/
class TextNodeGenerator {
/**
* Creates a new text node generator.
* @param {Object} options - Options for the generator
*/
constructor(options) {
this.options = options;
}
/**
* Determines if this generator can handle the given node.
* @param {Object} node - The node to check
* @returns {boolean} - True if this generator can handle the node
*/
canHandle(node) {
return node.type === "text";
}
/**
* Generates HTML for a text node.
* @param {Object} node - The node to generate HTML for
* @returns {string} - The generated HTML
*/
generate(node) {
if (this.options.debug) {
console.log(`\n[TextNodeGenerator] Processing text node`);
}
if (node.parent?.tag === "codeblock") {
if (this.options.debug) {
console.log("[TextNodeGenerator] Raw text content for codeblock");
}
return node.value;
}
const escapedText = StringUtils.escapeHTML(node.value);
if (this.options.debug) {
console.log("[TextNodeGenerator] Generated escaped text");
}
return escapedText;
}
}
module.exports = TextNodeGenerator;

View file

@ -0,0 +1,61 @@
/**
* HTMLTemplate provides templates for generating the final HTML document.
*/
class HTMLTemplate {
/**
* Creates a new HTML template instance.
* @param {Object} [options] - Options object
* @param {boolean} [options.minified=true] - Whether to minify the output
*/
constructor(options = {}) {
this.options = {
minified: true,
...options,
};
}
/**
* Generates the final HTML document using the provided head and body content.
* @param {string} headContent - HTML content for the <head> section
* @param {string} bodyContent - HTML content for the <body> section
* @returns {string} - Complete HTML document
*/
generateDocument(headContent, bodyContent) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${headContent}
<style>
:root {
--navbar-height: 4rem;
}
body {
margin: 0;
padding: 0;
padding-top: var(--navbar-height);
background-color: #0d1117;
color: #e6edf3;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.5;
min-height: 100vh;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
::selection {
background-color: rgba(59, 130, 246, 0.2);
}
</style>
</head>
<body>
${bodyContent}
</body>
</html>`;
}
}
module.exports = HTMLTemplate;

View file

@ -0,0 +1,88 @@
/**
* LinkProcessor provides utilities for processing link elements.
*/
class LinkProcessor {
/**
* Creates a new link processor instance.
* @param {Object} [options] - Options object
* @param {boolean} [options.debug=false] - Enable debug logging
*/
constructor(options = {}) {
this.options = {
debug: false,
...options,
};
}
/**
* Processes a link node and extracts the href attribute.
* Converts to an internal link if it doesn't start with http:// or https://.
*
* @param {Object} node - The link node to process
* @returns {Object} - An object containing link properties
*/
processLink(node) {
if (this.options.debug) {
console.log("\n[LinkProcessor] Processing link node");
}
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(`[LinkProcessor] Converted to internal link: "${hrefTarget}"`);
}
} else {
if (this.options.debug) {
console.log(`[LinkProcessor] External link detected: "${hrefTarget}"`);
}
}
href = hrefTarget;
} else {
if (this.options.debug) {
console.log("[LinkProcessor] No href property found, using default: '#'");
}
}
return {
href,
isExternal: href.startsWith("http://") || href.startsWith("https://")
};
}
/**
* Gets appropriate HTML attributes for a link based on its type
* @param {Object} linkInfo - Link information object
* @returns {string} - HTML attributes for the link
*/
getLinkAttributes(linkInfo) {
if (linkInfo.isExternal) {
return ` href="${linkInfo.href}" target="_blank" rel="noopener noreferrer"`;
} else {
return ` href="${linkInfo.href}"`;
}
}
/**
* Gets the click handler for a button inside a link
* @param {Object} linkInfo - Link information object
* @returns {string} - onclick attribute value
*/
getButtonClickHandler(linkInfo) {
if (linkInfo.isExternal) {
return ` onclick="window.open('${linkInfo.href}', '_blank', 'noopener,noreferrer')"`;
} else {
return ` onclick="window.location.href='${linkInfo.href}'"`;
}
}
}
module.exports = LinkProcessor;

59
lib/utils/StringUtils.js Normal file
View file

@ -0,0 +1,59 @@
/**
* Utilities for string operations used throughout the Blueprint compiler.
*/
/**
* Escapes special HTML characters in a string to prevent XSS attacks.
* @param {string} text - The text to escape
* @returns {string} - The escaped text
*/
const escapeHTML = (text) => {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
/**
* Converts a camelCase string to kebab-case (lowercase with hyphens).
* @param {string} str - The string to convert
* @returns {string} - The converted string
*/
const toKebabCase = (str) => {
return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
};
/**
* Stringify an object while handling circular references.
* @param {Object} obj - The object to stringify
* @returns {string} - JSON string representation
*/
const safeStringify = (obj) => {
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(obj, getCircularReplacer(), 2);
} catch (err) {
return `[Unable to stringify: ${err.message}]`;
}
};
module.exports = {
escapeHTML,
toKebabCase,
safeStringify
};

View file

@ -1,6 +1,6 @@
{
"name": "blueprint",
"version": "1.0.0",
"version": "1.1.0",
"description": "A modern UI component compiler with live reload support",
"main": "lib/BlueprintBuilder.js",
"scripts": {