From 73c8048c9d4a0de8b038fc470753d5730e117e05 Mon Sep 17 00:00:00 2001 From: obvTiger Date: Thu, 27 Mar 2025 17:29:28 +0100 Subject: [PATCH] feat: code blocks beta --- examples/reactive-example.bp | 190 ++++++++++++++++++++ examples/server-form-example.bp | 163 ++++++++++++++++++ lib/StandardElementGenerator.js | 179 +++++++++++++++++++ lib/generators/JavaScriptGenerator.js | 239 ++++++++++++++++++++++++++ lib/generators/ServerCodeGenerator.js | 209 ++++++++++++++++++++++ 5 files changed, 980 insertions(+) create mode 100644 examples/reactive-example.bp create mode 100644 examples/server-form-example.bp create mode 100644 lib/StandardElementGenerator.js create mode 100644 lib/generators/JavaScriptGenerator.js create mode 100644 lib/generators/ServerCodeGenerator.js diff --git a/examples/reactive-example.bp b/examples/reactive-example.bp new file mode 100644 index 0000000..67dc39a --- /dev/null +++ b/examples/reactive-example.bp @@ -0,0 +1,190 @@ +page(favicon:"/favicon.ico") { + title { "Blueprint - Reactive Example" } + description { "Example of reactive values in Blueprint" } + keywords { "blueprint, javascript, reactive, state" } + author { "Blueprint Team" } +} + +navbar { + horizontal { + link(href:index) { text(bold) { "Blueprint" } } + links { + link(href:index) { "Home" } + link(href:examples) { "Examples" } + link(href:docs) { "Docs" } + } + } +} + +section(wide, centered) { + vertical(centered) { + title(huge) { "Reactive Values Demo" } + text { "Demonstration of reactive state management in Blueprint" } + } +} + +section(wide) { + title { "Counter Example" } + + vertical(centered) { + // Notice the ID uses underscore format. - is not allowed + text(id:counter_value) { "0" } + + horizontal(centered) { + button-secondary(id:decrease_btn) { + "Decrease" + + @client { + // Use the reactive counter_value with numberValue + const currentValue = counter_value.numberValue; + counter_value.setNumber(currentValue - 1); + console.log("Counter decreased to", currentValue - 1); + } + } + + button(id:increase_btn) { + "Increase" + + @client { + // Use the reactive counter_value with numberValue + const currentValue = counter_value.numberValue; + counter_value.setNumber(currentValue + 1); + console.log("Counter increased to", currentValue + 1); + } + } + } + } +} + +section(wide) { + title { "Color Changer with Reactive Elements" } + + vertical(centered) { + // Element with explicit ID that will be styled + card(id:color_target, raised) { + title { "Change My Background" } + text { "Click the buttons below to change my background color" } + } + + // Display the current color + text(subtle, id:color_display) { "Current color: None" } + + horizontal(centered) { + button-secondary(id:red_btn) { + "Red" + + @client { + // Using reactive methods + color_target.setStyle("backgroundColor", "#e74c3c"); + color_display.set("Current color: Red"); + } + } + + button-secondary(id:green_btn) { + "Green" + + @client { + // Using reactive methods + color_target.setStyle("backgroundColor", "#2ecc71"); + color_display.set("Current color: Green"); + } + } + + button-secondary(id:blue_btn) { + "Blue" + + @client { + // Using reactive methods + color_target.setStyle("backgroundColor", "#3498db"); + color_display.set("Current color: Blue"); + } + } + + button-secondary(id:reset_btn) { + "Reset" + + @client { + // Using reactive methods + color_target.setStyle("backgroundColor", ""); + color_display.set("Current color: None"); + } + } + } + } +} + +section(wide) { + title { "Data Types Example" } + + vertical(centered) { + horizontal(centered) { + vertical { + text(bold) { "Number:" } + text(id:number_display) { "42" } + } + + vertical { + text(bold) { "Text:" } + text(id:text_display) { "Hello Blueprint" } + } + + vertical { + text(bold) { "Boolean:" } + text(id:boolean_display) { "true" } + } + } + + horizontal(centered) { + button-secondary(id:modify_values_btn) { + "Modify Values" + + @client { + // Use type-specific methods + number_display.setNumber(number_display.numberValue * 2); + text_display.set(text_display.value + "!"); + boolean_display.set(!boolean_display.booleanValue); + + console.log("Number value:", number_display.numberValue); + console.log("Text value:", text_display.textValue); + console.log("Boolean value:", boolean_display.booleanValue); + } + } + + button-secondary(id:reset_values_btn) { + "Reset Values" + + @client { + number_display.setNumber(42); + text_display.set("Hello Blueprint"); + boolean_display.set("true"); + } + } + } + } +} + +section(wide) { + title { "Subscription Example" } + + vertical(centered) { + // Input and subscribed elements + input(id:user_input) { "Type something..." } + + text(bold) { "Live Preview:" } + text(id:preview_output) { "Type something..." } + + // Add a client block to handle typing + @client { + // Set up the input to update the preview on input + user_input.element.addEventListener("input", function(e) { + // Use the reactive API to update the preview + preview_output.set(e.target.value); + }); + + // Example of subscription - will log changes to the console + preview_output.subscribe(function(newValue) { + console.log("Preview content changed to:", newValue); + }); + } + } +} \ No newline at end of file diff --git a/examples/server-form-example.bp b/examples/server-form-example.bp new file mode 100644 index 0000000..26c8fcf --- /dev/null +++ b/examples/server-form-example.bp @@ -0,0 +1,163 @@ +page(favicon:"/favicon.ico") { + title { "Blueprint - Server Form Example" } + description { "Example of server-side form processing in Blueprint" } + keywords { "blueprint, javascript, server, api, form" } + author { "Blueprint Team" } +} + +navbar { + horizontal { + link(href:index) { text(bold) { "Blueprint" } } + links { + link(href:index) { "Home" } + link(href:examples) { "Examples" } + link(href:docs) { "Docs" } + } + } +} + +section(wide, centered) { + vertical(centered) { + title(huge) { "Server-Side Form Processing" } + text { "Demonstration of server-side form handling with Blueprint" } + } +} + +section(wide) { + title { "Contact Form Example" } + + vertical(centered) { + card(raised) { + title { "Submit a Message" } + text { "Fill out the form below to submit a message to the server." } + + vertical { + text(bold) { "Your Name" } + input(id:user_name) { "John Doe" } + + text(bold) { "Your Email" } + input(id:user_email) { "john@example.com" } + + text(bold) { "Your Message" } + textarea(id:user_message) { "Hello from Blueprint!" } + + // Display the server response + text(id:form_result) { "" } + + button(id:submit_form) { + "Submit Form" + + // Server block with parameters specifying which input values to include + @server(user_name, user_email, user_message) { + console.log("Form submission received:"); + console.log("Name:", user_name); + console.log("Email:", user_email); + console.log("Message:", user_message); + + // Validate inputs + const errors = []; + + if (!user_name || user_name.length < 2) { + errors.push("Name is too short"); + } + + if (!user_email || !user_email.includes('@')) { + errors.push("Invalid email address"); + } + + if (!user_message || user_message.length < 5) { + errors.push("Message is too short"); + } + + // Return error message if validation fails + if (errors.length > 0) { + return res.status(400).json({ + form_result: "Error: " + errors.join(", ") + }); + } + + // Process the form (in a real app, this might save to a database) + const timestamp = new Date().toISOString(); + + // Return success response that will update the form_result element + return res.status(200).json({ + form_result: `Thank you, ${user_name}! Your message was received at ${timestamp}.` + }); + } + } + } + } + } +} + +section(wide) { + title { "User Registration Example" } + + vertical(centered) { + card(raised) { + title { "Register a New Account" } + text { "Fill out the form below to register a new account." } + + vertical { + text(bold) { "Username" } + input(id:username) { "newuser123" } + + text(bold) { "Email" } + input(id:email) { "newuser@example.com" } + + text(bold) { "Password" } + input(id:password) { "password123" } + + text(bold) { "Confirm Password" } + input(id:confirm_password) { "password123" } + + // Display the registration status + text(id:registration_status) { "" } + + button(id:register_user) { + "Register" + + @server(username, email, password, confirm_password) { + console.log("Registration request for username:", username); + + // Validate username + if (!username || username.length < 4) { + return res.status(400).json({ + registration_status: "Error: Username must be at least 4 characters" + }); + } + + // Validate email + if (!email || !email.includes('@')) { + return res.status(400).json({ + registration_status: "Error: Invalid email address" + }); + } + + // Validate password + if (!password || password.length < 8) { + return res.status(400).json({ + registration_status: "Error: Password must be at least 8 characters" + }); + } + + // Check password matching + if (password !== confirm_password) { + return res.status(400).json({ + registration_status: "Error: Passwords do not match" + }); + } + + // In a real app, this would create the user account + const userId = Math.floor(Math.random() * 10000); + + // Return success response + return res.status(200).json({ + registration_status: `Success! User ${username} registered with ID #${userId}` + }); + } + } + } + } + } +} \ No newline at end of file diff --git a/lib/StandardElementGenerator.js b/lib/StandardElementGenerator.js new file mode 100644 index 0000000..ad9499e --- /dev/null +++ b/lib/StandardElementGenerator.js @@ -0,0 +1,179 @@ +/** + * Generates HTML for standard HTML elements. + */ +class StandardElementGenerator { + /** + * Creates a new StandardElementGenerator instance. + * @param {Object} options - Generator options + * @param {CSSGenerator} cssGenerator - CSS generator instance + * @param {HTMLGenerator} htmlGenerator - HTML generator instance + */ + constructor(options = {}, cssGenerator, htmlGenerator) { + this.options = options; + this.cssGenerator = cssGenerator; + this.htmlGenerator = htmlGenerator; + this.parentGenerator = htmlGenerator; + } + + /** + * Determines whether this generator can handle a given node. + * @param {Object} node - Node to check + * @returns {boolean} - True if this generator can handle the node + */ + canHandle(node) { + return node.type === "element"; + } + + /** + * Generates HTML for a standard element. + * @param {Object} node - Node to generate HTML for + * @returns {string} - Generated HTML + */ + generate(node) { + if (this.options.debug) { + console.log(`[StandardElementGenerator] Processing element node: ${node.tag || node.name}`); + } + + const mapping = ELEMENT_MAPPINGS ? ELEMENT_MAPPINGS[node.tag] : null; + + const tagName = mapping ? mapping.tag : (node.name || node.tag || "div"); + + let className = ""; + if (this.cssGenerator) { + className = this.cssGenerator.generateClassName(node.tag); + const { cssProps, nestedRules } = this.cssGenerator.nodeToCSSProperties(node); + this.cssGenerator.cssRules.set(`.${className}`, { + cssProps, + nestedRules, + }); + } + + const fullClassName = this.generateClassName(node); + if (className && fullClassName) { + className = `${className} ${fullClassName}`; + } else if (fullClassName) { + className = fullClassName; + } + + const attributes = this.generateAttributes(node, node.elementId, className); + + let content = ""; + const children = node.children || []; + + for (const child of children) { + if ( + child.type === "client" || + child.type === "server" || + (child.type === "element" && (child.name === "script" || child.tag === "script")) + ) { + continue; + } + child.parent = node; + content += this.htmlGenerator.generateHTML(child); + if (!this.options.minified) { + content += "\n"; + } + } + + return `<${tagName}${attributes}>${this.options.minified ? "" : "\n"}${content}${this.options.minified ? "" : ""}${this.options.minified ? "" : "\n"}`; + } + + /** + * Generates a className string for the element. + * @param {Object} node - The node to generate class for + * @returns {string} - The generated class name + */ + generateClassName(node) { + let classNames = []; + + if (node.attributes && Array.isArray(node.attributes)) { + const classAttr = node.attributes.find(attr => attr.name === "class"); + if (classAttr && classAttr.value) { + classNames.push(classAttr.value); + } + } + + if (node.props && Array.isArray(node.props)) { + for (const prop of node.props) { + if (typeof prop === "string") { + if (prop.startsWith("class:")) { + classNames.push(prop.substring(prop.indexOf(":") + 1).trim().replace(/^"|"$/g, "")); + } + } + } + } + + return classNames.join(" "); + } + + /** + * Generates an attributes string for the element. + * @param {Object} node - The node to generate attributes for + * @param {string} id - The element ID + * @param {string} className - The element class name + * @returns {string} - The generated attributes string + */ + generateAttributes(node, id, className) { + let attributes = ""; + + if (id) { + attributes += ` id="${id}"`; + } else if (node.props && Array.isArray(node.props)) { + const idProp = node.props.find(p => typeof p === "string" && p.startsWith("id:")); + if (idProp) { + const idValue = idProp.substring(idProp.indexOf(":") + 1).trim().replace(/^"|"$/g, ""); + attributes += ` id="${idValue}"`; + node.elementId = idValue; + } + } + + if (className) { + attributes += ` class="${className}"`; + } + + if (node.props && Array.isArray(node.props)) { + const dataProps = node.props.filter(p => typeof p === "string" && p.startsWith("data-")); + if (dataProps.length) { + attributes += " " + dataProps.join(" "); + } + } + + if (node.attributes && Array.isArray(node.attributes)) { + for (const attr of node.attributes) { + if (attr.name === "id" || attr.name === "class") continue; + + if (attr.value === true) { + attributes += ` ${attr.name}`; + } else if (attr.value !== false && attr.value !== undefined && attr.value !== null) { + attributes += ` ${attr.name}="${attr.value}"`; + } + } + } + + if (node.props && Array.isArray(node.props)) { + for (const prop of node.props) { + if (typeof prop === "string") { + if (prop.startsWith("id:") || prop.startsWith("class:") || prop.startsWith("data-")) continue; + + const colonIndex = prop.indexOf(":"); + if (colonIndex !== -1) { + const name = prop.substring(0, colonIndex).trim(); + const value = prop.substring(colonIndex + 1).trim().replace(/^"|"$/g, ""); + + if (!attributes.includes(` ${name}="`)) { + attributes += ` ${name}="${value}"`; + } + } else { + if (!attributes.includes(` ${prop}`)) { + attributes += ` ${prop}`; + } + } + } + } + } + + return attributes; + } +} + +module.exports = StandardElementGenerator; \ No newline at end of file diff --git a/lib/generators/JavaScriptGenerator.js b/lib/generators/JavaScriptGenerator.js new file mode 100644 index 0000000..cc16c45 --- /dev/null +++ b/lib/generators/JavaScriptGenerator.js @@ -0,0 +1,239 @@ +const StringUtils = require("../utils/StringUtils"); + +/** + * Generates JavaScript code for client and server blocks with reactive data handling. + */ +class JavaScriptGenerator { + /** + * Creates a new JavaScript generator. + * @param {Object} options - Options for the generator + * @param {ServerCodeGenerator} [serverGenerator] - Server code generator instance + */ + constructor(options = {}, serverGenerator = null) { + this.options = options; + this.clientScripts = new Map(); + this.serverScripts = []; + this.reactiveElements = new Set(); + this.serverGenerator = serverGenerator; + } + + /** + * Sets the server code generator instance + * @param {ServerCodeGenerator} serverGenerator - Server code generator instance + */ + setServerGenerator(serverGenerator) { + this.serverGenerator = serverGenerator; + } + + /** + * Registers a client-side script to be executed when an element is clicked. + * @param {string} elementId - The ID of the element to attach the event to + * @param {string} code - The JavaScript code to execute + */ + addClientScript(elementId, code) { + if (this.options.debug) { + console.log(`[JavaScriptGenerator] Adding client script for element ${elementId}`); + } + this.clientScripts.set(elementId, code); + } + + /** + * Adds a server-side script to be executed on the server. + * @param {string} elementId - The ID of the element that triggers the server action + * @param {string} code - The JavaScript code to execute + * @param {Array} params - The input parameters to retrieve from the client + */ + addServerScript(elementId, code, params = []) { + if (this.options.debug) { + console.log(`[JavaScriptGenerator] Adding server script for element ${elementId}`); + if (params.length > 0) { + console.log(`[JavaScriptGenerator] Script parameters: ${params.join(", ")}`); + } + } + + this.serverScripts.push({ + elementId, + code, + params + }); + + if (this.serverGenerator) { + this.serverGenerator.addServerRoute(elementId, code, params); + } + + this.clientScripts.set(elementId, `_bp_serverAction_${elementId}(e);`); + } + + /** + * Registers an element as reactive, meaning it will have state management functions + * @param {string} elementId - The ID of the element to make reactive + */ + registerReactiveElement(elementId) { + if (this.options.debug) { + console.log(`[JavaScriptGenerator] Registering reactive element: ${elementId}`); + } + this.reactiveElements.add(elementId); + } + + /** + * Generates a unique element ID. + * @returns {string} - A unique element ID with bp_ prefix + */ + generateElementId() { + return StringUtils.generateRandomId(); + } + + /** + * Generates the reactive store and helper functions for state management + * @returns {string} - JavaScript code for reactive functionality + */ + generateReactiveStore() { + if (this.reactiveElements.size === 0) { + return ''; + } + + return ` +const _bp_store = { + listeners: new Map(), + subscribe: function(id, callback) { + if (!this.listeners.has(id)) { + this.listeners.set(id, []); + } + this.listeners.get(id).push(callback); + }, + notify: function(id, newValue) { + if (this.listeners.has(id)) { + this.listeners.get(id).forEach(callback => callback(newValue)); + } + } +}; + +function _bp_makeElementReactive(id) { + const element = document.getElementById(id); + if (!element) { + console.log(\`[Blueprint] Element with ID \${id} not found\`); + return null; + } + + return { + element: element, + get value() { + return element.textContent; + }, + set: function(newValue) { + const valueString = String(newValue); + element.textContent = valueString; + _bp_store.notify(id, valueString); + return this; + }, + setNumber: function(num) { + const valueString = String(Number(num)); + element.textContent = valueString; + _bp_store.notify(id, valueString); + return this; + }, + setHtml: function(html) { + element.innerHTML = html; + _bp_store.notify(id, html); + return this; + }, + setStyle: function(property, value) { + element.style[property] = value; + return this; + }, + setClass: function(className, add = true) { + if (add) { + element.classList.add(className); + } else { + element.classList.remove(className); + } + return this; + }, + on: function(event, callback) { + element.addEventListener(event, callback); + return this; + }, + subscribe: function(callback) { + _bp_store.subscribe(id, callback); + return this; + }, + get textValue() { + return element.textContent; + }, + get numberValue() { + return Number(element.textContent); + }, + get booleanValue() { + const text = element.textContent.toLowerCase(); + return text === 'true' || text === '1' || text === 'yes'; + } + }; +} + +// Initialize reactive elements`; + } + + /** + * Generates initialization code for reactive elements + * @returns {string} - JavaScript initialization code for reactive elements + */ + generateReactiveElementInit() { + if (this.reactiveElements.size === 0) { + return ''; + } + + const initCode = Array.from(this.reactiveElements) + .map(id => `const ${id} = _bp_makeElementReactive('${id}');`) + .join('\n '); + + return ` + ${initCode}`; + } + + /** + * Generates all client-side JavaScript code. + * @returns {string} - The generated JavaScript code + */ + generateClientScripts() { + if (this.clientScripts.size === 0 && this.reactiveElements.size === 0 && !this.serverGenerator) { + return ''; + } + + let initCode = ''; + if (this.reactiveElements.size > 0) { + initCode = this.generateReactiveElementInit(); + } + + let scripts = ''; + this.clientScripts.forEach((code, elementId) => { + scripts += ` document.getElementById('${elementId}').addEventListener('click', function(e) { + ${code} + });\n`; + }); + + let serverClientCode = ''; + if (this.serverGenerator) { + serverClientCode = this.serverGenerator.generateClientAPICalls(); + } + + return ``; + } + + /** + * Gets all server-side scripts. + * @returns {Array} - Array of server-side script objects + */ + getServerScripts() { + return this.serverScripts; + } +} + +module.exports = JavaScriptGenerator; \ No newline at end of file diff --git a/lib/generators/ServerCodeGenerator.js b/lib/generators/ServerCodeGenerator.js new file mode 100644 index 0000000..d7342df --- /dev/null +++ b/lib/generators/ServerCodeGenerator.js @@ -0,0 +1,209 @@ +const crypto = require('crypto'); +const StringUtils = require("../utils/StringUtils"); + +/** + * Generates server-side Express.js routes for server blocks. + */ +class ServerCodeGenerator { + /** + * Creates a new server code generator. + * @param {Object} options - Options for the generator + */ + constructor(options = {}) { + this.options = options; + this.serverRoutes = new Map(); + this.hasServerCode = false; + } + + /** + * Registers a server-side route to be executed when requested. + * @param {string} elementId - The ID of the element that triggers the route + * @param {string} code - The JavaScript code to execute + * @param {Array} params - The input parameters to retrieve from the client + */ + addServerRoute(elementId, code, params = []) { + if (this.options.debug) { + console.log(`[ServerCodeGenerator] Adding server route for element ${elementId}`); + if (params.length > 0) { + console.log(`[ServerCodeGenerator] Route parameters: ${params.join(", ")}`); + } + } + + const endpoint = this.generateEndpointPath(elementId); + + this.serverRoutes.set(elementId, { + endpoint, + code, + params + }); + + this.hasServerCode = true; + } + + /** + * Generates a unique endpoint path for a server route. + * @param {string} elementId - The element ID for the route + * @returns {string} - A unique endpoint path + */ + generateEndpointPath(elementId) { + const hash = crypto.createHash('sha256') + .update(elementId + Math.random().toString()) + .digest('hex') + .substring(0, 12); + + return `/api/${StringUtils.toKebabCase(elementId)}-${hash}`; + } + + /** + * Generates client-side JavaScript for making API calls to server routes. + * @returns {string} - JavaScript code for making API calls + */ + generateClientAPICalls() { + if (this.serverRoutes.size === 0) { + return ''; + } + + let apiCode = ` +const _bp_api = { + post: async function(url, data) { + try { + const serverPort = window.blueprintServerPort || 3001; + + const fullUrl = \`http://\${window.location.hostname}:\${serverPort}\${url}\`; + + const response = await fetch(fullUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + throw new Error(\`API request failed: \${response.status}\`); + } + + return await response.json(); + } catch (error) { + console.error('[Blueprint API]', error); + return { error: error.message }; + } + } +}; + + +`; + + + this.serverRoutes.forEach((route, elementId) => { + const { endpoint, params } = route; + + apiCode += `async function _bp_serverAction_${elementId}(e) { + const data = {}; +${params.map(param => ` data.${param} = ${param} ? ${param}.value : null;`).join('\n')} + + try { + const result = await _bp_api.post('${endpoint}', data); + console.log('[Blueprint API] Server response:', result); + + if (result && typeof result === 'object') { + Object.keys(result).forEach(key => { + if (window[key] && typeof window[key].set === 'function') { + window[key].set(result[key]); + } + else { + const element = document.getElementById(key); + if (element) { + element.textContent = result[key]; + console.log(\`[Blueprint API] Updated element #\${key} with value: \${result[key]}\`); + } + } + }); + } + + return result; + } catch (error) { + console.error('[Blueprint API] Error in server action:', error); + } +}\n`; + }); + + return apiCode; + } + + /** + * Generates Express.js server code for all registered server routes. + * @returns {string} - Express.js server code + */ + generateServerCode() { + if (this.serverRoutes.size === 0) { + return ''; + } + + let serverCode = ` +const express = require('express'); +const bodyParser = require('body-parser'); +const cors = require('cors'); + +function createBlueprintApiServer(port = 3001) { + const app = express(); + + app.use(cors()); + app.use(bodyParser.json()); + + app.use((req, res, next) => { + console.log(\`[\${new Date().toISOString()}] \${req.method} \${req.url}\`); + next(); + }); + +`; + + this.serverRoutes.forEach((route, elementId) => { + const { endpoint, code, params } = route; + + serverCode += ` + app.post('${endpoint}', async (req, res) => { + try { + ${params.map(param => `const ${param} = req.body.${param};`).join('\n ')} + + let result; + try { + ${code} + } catch (error) { + console.error(\`Error in server block \${error.message}\`); + return res.status(500).json({ error: error.message }); + } + + return res.json(result || {}); + } catch (error) { + console.error(\`Error processing request: \${error.message}\`); + return res.status(500).json({ error: error.message }); + } + });`; + }); + + serverCode += ` + + app.listen(port, () => { + console.log(\`Blueprint API server running at http://localhost:\${port}\`); + }); + + return app; +} + +module.exports = createBlueprintApiServer; +`; + + return serverCode; + } + + /** + * Checks if there is any server code to generate. + * @returns {boolean} - Whether there is server code + */ + hasServerCodeToGenerate() { + return this.hasServerCode; + } +} + +module.exports = ServerCodeGenerator; \ No newline at end of file