diff --git a/lib/BlueprintBuilder.js b/lib/BlueprintBuilder.js index 6342d73..71f1139 100644 --- a/lib/BlueprintBuilder.js +++ b/lib/BlueprintBuilder.js @@ -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); + + if (result.success) { + this.fileHandler.writeCompiledFiles(outputDir, baseName, result.html, result.css); + + if (this.options.debug) { + console.log("[DEBUG] Build completed successfully"); + } } - - fs.writeFileSync(path.join(outputDir, `${baseName}.html`), finalHtml); - fs.writeFileSync(path.join(outputDir, `${baseName}.css`), css); - - 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; diff --git a/lib/BlueprintCompiler.js b/lib/BlueprintCompiler.js new file mode 100644 index 0000000..c875b2b --- /dev/null +++ b/lib/BlueprintCompiler.js @@ -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; \ No newline at end of file diff --git a/lib/BlueprintFileHandler.js b/lib/BlueprintFileHandler.js new file mode 100644 index 0000000..9521586 --- /dev/null +++ b/lib/BlueprintFileHandler.js @@ -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; \ No newline at end of file diff --git a/lib/HTMLGenerator.js b/lib/HTMLGenerator.js index 1261b50..9dc8bdd 100644 --- a/lib/HTMLGenerator.js +++ b/lib/HTMLGenerator.js @@ -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, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + + 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") { - if (this.options.debug) { - console.log("[HTMLGenerator] Skipping page node - metadata only"); - } - return ""; - } - 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"'; - } + for (const generator of this.generators) { + if (generator.canHandle(node)) { if (this.options.debug) { - console.log( - `[HTMLGenerator] Added input attributes: "${attributes}"` - ); + console.log(`[HTMLGenerator] Using ${generator.constructor.name} for node`); } + return generator.generate(node); } - - 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; diff --git a/lib/dev-server.js b/lib/dev-server.js index 1470615..419d4f6 100644 --- a/lib/dev-server.js +++ b/lib/dev-server.js @@ -9,6 +9,7 @@ const options = { minified: !args.includes("--readable"), srcDir: "./src", outDir: "./dist", + debug: args.includes("--debug"), }; const server = new BlueprintServer(options); diff --git a/lib/file/BlueprintFileReader.js b/lib/file/BlueprintFileReader.js new file mode 100644 index 0000000..a9671cb --- /dev/null +++ b/lib/file/BlueprintFileReader.js @@ -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; \ No newline at end of file diff --git a/lib/file/BlueprintFileWriter.js b/lib/file/BlueprintFileWriter.js new file mode 100644 index 0000000..7c536d1 --- /dev/null +++ b/lib/file/BlueprintFileWriter.js @@ -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; \ No newline at end of file diff --git a/lib/generators/ButtonElementGenerator.js b/lib/generators/ButtonElementGenerator.js new file mode 100644 index 0000000..2312585 --- /dev/null +++ b/lib/generators/ButtonElementGenerator.js @@ -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; \ No newline at end of file diff --git a/lib/generators/InputElementGenerator.js b/lib/generators/InputElementGenerator.js new file mode 100644 index 0000000..70a9cb4 --- /dev/null +++ b/lib/generators/InputElementGenerator.js @@ -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; \ No newline at end of file diff --git a/lib/generators/LinkElementGenerator.js b/lib/generators/LinkElementGenerator.js new file mode 100644 index 0000000..461c2bc --- /dev/null +++ b/lib/generators/LinkElementGenerator.js @@ -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; \ No newline at end of file diff --git a/lib/generators/MediaElementGenerator.js b/lib/generators/MediaElementGenerator.js new file mode 100644 index 0000000..909358b --- /dev/null +++ b/lib/generators/MediaElementGenerator.js @@ -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; \ No newline at end of file diff --git a/lib/generators/RootNodeGenerator.js b/lib/generators/RootNodeGenerator.js new file mode 100644 index 0000000..d90dc89 --- /dev/null +++ b/lib/generators/RootNodeGenerator.js @@ -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; \ No newline at end of file diff --git a/lib/generators/StandardElementGenerator.js b/lib/generators/StandardElementGenerator.js new file mode 100644 index 0000000..e899423 --- /dev/null +++ b/lib/generators/StandardElementGenerator.js @@ -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; \ No newline at end of file diff --git a/lib/generators/TextNodeGenerator.js b/lib/generators/TextNodeGenerator.js new file mode 100644 index 0000000..49a0389 --- /dev/null +++ b/lib/generators/TextNodeGenerator.js @@ -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; \ No newline at end of file diff --git a/lib/templates/HTMLTemplate.js b/lib/templates/HTMLTemplate.js new file mode 100644 index 0000000..43e69ee --- /dev/null +++ b/lib/templates/HTMLTemplate.js @@ -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; \ No newline at end of file diff --git a/lib/utils/LinkProcessor.js b/lib/utils/LinkProcessor.js new file mode 100644 index 0000000..ac4321b --- /dev/null +++ b/lib/utils/LinkProcessor.js @@ -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; \ No newline at end of file diff --git a/lib/utils/StringUtils.js b/lib/utils/StringUtils.js new file mode 100644 index 0000000..e609ad2 --- /dev/null +++ b/lib/utils/StringUtils.js @@ -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, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +}; + +/** + * 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 +}; \ No newline at end of file diff --git a/package.json b/package.json index 8346fe0..c0c6ed3 100644 --- a/package.json +++ b/package.json @@ -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": {