beta/code-blocks (#1)

Reviewed-on: #1
Co-authored-by: obvTiger <obvtiger@epilogue.team>
Co-committed-by: obvTiger <obvtiger@epilogue.team>
This commit is contained in:
obvTiger 2025-04-01 15:22:15 +02:00 committed by obvtiger
parent 362b7aa15e
commit d125640fe7
26 changed files with 1816 additions and 102 deletions

View file

@ -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);
});
}
}
}

View file

@ -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}`
});
}
}
}
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
{"root":["../client/src/extension.ts","../server/src/server.ts"],"version":"5.7.3"}
{"root":["../client/src/extension.ts","../server/src/server.ts"],"version":"5.8.2"}

File diff suppressed because one or more lines are too long

View file

@ -38,6 +38,10 @@ const elements = [
'card', 'badge', 'alert', 'tooltip', 'input', 'textarea', 'select',
'checkbox', 'radio', 'switch', 'list', 'table', 'progress', 'slider'
];
// Script blocks
const scriptBlocks = [
'client', 'server'
];
// Single instance elements
const singleElements = ['page', 'navbar'];
// Blueprint properties
@ -52,6 +56,8 @@ const properties = [
];
// Page configuration properties
const pageProperties = ['title', 'description', 'keywords', 'author'];
// ID attribute suggestion - using underscore format
const idAttributeTemplate = 'id:$1_$2';
// Container elements that can have children
const containerElements = [
'horizontal', 'vertical', 'section', 'grid', 'navbar',
@ -85,6 +91,18 @@ connection.onCompletion((textDocumentPosition) => {
const position = textDocumentPosition.position;
const line = lines[position.line];
const linePrefix = line.slice(0, position.character);
// Suggest script blocks after @ symbol
if (linePrefix.trim().endsWith('@')) {
return scriptBlocks.map(block => ({
label: `@${block}`,
kind: node_1.CompletionItemKind.Snippet,
insertText: `@${block} {\n $1\n}`,
insertTextFormat: node_1.InsertTextFormat.Snippet,
documentation: block === 'client' ?
'Create a client-side JavaScript block that runs when the element is clicked. The "e" event object is available.' :
'Create a server-side JavaScript block that runs on the server.'
}));
}
// Check if this is a template completion trigger
if (linePrefix.trim() === '!') {
return [{
@ -114,13 +132,22 @@ connection.onCompletion((textDocumentPosition) => {
documentation: `Add ${prop} to the page configuration`
}));
}
// After an opening parenthesis, suggest properties
// After an opening parenthesis, suggest properties including ID with underscore format
if (linePrefix.trim().endsWith('(')) {
return properties.map(prop => ({
label: prop,
kind: node_1.CompletionItemKind.Property,
documentation: `Apply ${prop} property`
}));
return [
...properties.map(prop => ({
label: prop,
kind: node_1.CompletionItemKind.Property,
documentation: `Apply ${prop} property`
})),
{
label: 'id',
kind: node_1.CompletionItemKind.Property,
insertText: idAttributeTemplate,
insertTextFormat: node_1.InsertTextFormat.Snippet,
documentation: 'Add an ID to the element (use underscores instead of hyphens for JavaScript compatibility)'
}
];
}
// After a container element's opening brace, suggest child elements
const containerMatch = /\b(horizontal|vertical|section|grid|navbar|links|card)\s*{\s*$/.exec(linePrefix);
@ -139,6 +166,25 @@ connection.onCompletion((textDocumentPosition) => {
suggestedElements = ['title', 'text', 'button', 'image'];
break;
}
// Include client/server block suggestions for interactive elements
if (['button', 'button-light', 'button-secondary', 'button-compact'].includes(parentElement)) {
return [
...suggestedElements.map(element => ({
label: element,
kind: node_1.CompletionItemKind.Class,
insertText: `${element} {\n $1\n}`,
insertTextFormat: node_1.InsertTextFormat.Snippet,
documentation: `Create a ${element} block inside ${parentElement}`
})),
{
label: '@client',
kind: node_1.CompletionItemKind.Snippet,
insertText: `@client {\n $1\n}`,
insertTextFormat: node_1.InsertTextFormat.Snippet,
documentation: 'Create a client-side JavaScript block that runs when the element is clicked. The "e" event object is available.'
}
];
}
return suggestedElements.map(element => ({
label: element,
kind: node_1.CompletionItemKind.Class,
@ -147,6 +193,26 @@ connection.onCompletion((textDocumentPosition) => {
documentation: `Create a ${element} block inside ${parentElement}`
}));
}
// Inside interactive elements, suggest @client blocks
const interactiveElementMatch = /\b(button|button-light|button-secondary|button-compact|input|textarea|select|checkbox|radio|switch)\s*(?:\([^)]*\))?\s*{\s*$/.exec(linePrefix);
if (interactiveElementMatch) {
return [
{
label: '@client',
kind: node_1.CompletionItemKind.Snippet,
insertText: `@client {\n $1\n}`,
insertTextFormat: node_1.InsertTextFormat.Snippet,
documentation: 'Create a client-side JavaScript block that runs when the element is clicked. The "e" event object is available.'
},
{
label: 'text',
kind: node_1.CompletionItemKind.Class,
insertText: `"$1"`,
insertTextFormat: node_1.InsertTextFormat.Snippet,
documentation: 'Add text content to the element'
}
];
}
// Get available single instance elements
const availableSingleElements = singleElements.filter(element => !elementExists(text, element));
// Combine regular elements with available single instance elements

File diff suppressed because one or more lines are too long

View file

@ -58,6 +58,11 @@ const elements = [
'checkbox', 'radio', 'switch', 'list', 'table', 'progress', 'slider'
];
// Script blocks
const scriptBlocks = [
'client', 'server'
];
// Single instance elements
const singleElements = ['page', 'navbar'];
@ -75,6 +80,9 @@ const properties = [
// Page configuration properties
const pageProperties = ['title', 'description', 'keywords', 'author'];
// ID attribute suggestion - using underscore format
const idAttributeTemplate = 'id:$1_$2';
// Container elements that can have children
const containerElements = [
'horizontal', 'vertical', 'section', 'grid', 'navbar',
@ -114,6 +122,19 @@ connection.onCompletion(
const line = lines[position.line];
const linePrefix = line.slice(0, position.character);
// Suggest script blocks after @ symbol
if (linePrefix.trim().endsWith('@')) {
return scriptBlocks.map(block => ({
label: `@${block}`,
kind: CompletionItemKind.Snippet,
insertText: `@${block} {\n $1\n}`,
insertTextFormat: InsertTextFormat.Snippet,
documentation: block === 'client' ?
'Create a client-side JavaScript block that runs when the element is clicked. The "e" event object is available.' :
'Create a server-side JavaScript block that runs on the server.'
}));
}
// Check if this is a template completion trigger
if (linePrefix.trim() === '!') {
return [{
@ -145,13 +166,22 @@ connection.onCompletion(
}));
}
// After an opening parenthesis, suggest properties
// After an opening parenthesis, suggest properties including ID with underscore format
if (linePrefix.trim().endsWith('(')) {
return properties.map(prop => ({
label: prop,
kind: CompletionItemKind.Property,
documentation: `Apply ${prop} property`
}));
return [
...properties.map(prop => ({
label: prop,
kind: CompletionItemKind.Property,
documentation: `Apply ${prop} property`
})),
{
label: 'id',
kind: CompletionItemKind.Property,
insertText: idAttributeTemplate,
insertTextFormat: InsertTextFormat.Snippet,
documentation: 'Add an ID to the element (use underscores instead of hyphens for JavaScript compatibility)'
}
];
}
// After a container element's opening brace, suggest child elements
@ -173,6 +203,26 @@ connection.onCompletion(
break;
}
// Include client/server block suggestions for interactive elements
if (['button', 'button-light', 'button-secondary', 'button-compact'].includes(parentElement)) {
return [
...suggestedElements.map(element => ({
label: element,
kind: CompletionItemKind.Class,
insertText: `${element} {\n $1\n}`,
insertTextFormat: InsertTextFormat.Snippet,
documentation: `Create a ${element} block inside ${parentElement}`
})),
{
label: '@client',
kind: CompletionItemKind.Snippet,
insertText: `@client {\n $1\n}`,
insertTextFormat: InsertTextFormat.Snippet,
documentation: 'Create a client-side JavaScript block that runs when the element is clicked. The "e" event object is available.'
}
];
}
return suggestedElements.map(element => ({
label: element,
kind: CompletionItemKind.Class,
@ -181,6 +231,27 @@ connection.onCompletion(
documentation: `Create a ${element} block inside ${parentElement}`
}));
}
// Inside interactive elements, suggest @client blocks
const interactiveElementMatch = /\b(button|button-light|button-secondary|button-compact|input|textarea|select|checkbox|radio|switch)\s*(?:\([^)]*\))?\s*{\s*$/.exec(linePrefix);
if (interactiveElementMatch) {
return [
{
label: '@client',
kind: CompletionItemKind.Snippet,
insertText: `@client {\n $1\n}`,
insertTextFormat: InsertTextFormat.Snippet,
documentation: 'Create a client-side JavaScript block that runs when the element is clicked. The "e" event object is available.'
},
{
label: 'text',
kind: CompletionItemKind.Class,
insertText: `"$1"`,
insertTextFormat: InsertTextFormat.Snippet,
documentation: 'Add text content to the element'
}
];
}
// Get available single instance elements
const availableSingleElements = singleElements.filter(element => !elementExists(text, element));

View file

@ -18,6 +18,9 @@
{
"include": "#strings"
},
{
"include": "#script-blocks"
},
{
"include": "#punctuation"
}
@ -64,6 +67,24 @@
}
]
},
"script-blocks": {
"patterns": [
{
"begin": "@(client|server)\\s*\\{",
"end": "\\}",
"beginCaptures": {
"0": { "name": "keyword.control.blueprint" },
"1": { "name": "entity.name.function.blueprint" }
},
"contentName": "source.js.embedded.blueprint",
"patterns": [
{
"include": "source.js"
}
]
}
]
},
"properties": {
"patterns": [
{
@ -71,7 +92,7 @@
"name": "support.type.property-name.blueprint"
},
{
"match": "(?<!:)(src|type|href|\\w+)\\s*:",
"match": "(?<!:)(src|type|href|id|\\w+)\\s*:",
"captures": {
"1": { "name": "support.type.property-name.blueprint" }
}

23
index.js Normal file
View file

@ -0,0 +1,23 @@
module.exports = (app, prisma) => {
app.get("/", async (req, res) => {
try {
const data = req.body;
// input valiation
// business logic
// output validation
// output
const response = {
};
res.json({ response });
} catch (error) {
console.log('Error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
}

View file

@ -129,6 +129,41 @@ class ASTBuilder {
);
}
if (token.type === "client" || token.type === "server") {
if (this.options.debug) {
console.log(
`\n[ASTBuilder] Processing ${token.type} block at line ${token.line}, column ${token.column}`
);
}
const node = {
type: token.type,
script: token.value,
line: token.line,
column: token.column,
};
if (token.type === "server" && token.params) {
node.params = token.params;
if (this.options.debug) {
console.log(
`[ASTBuilder] Server block parameters: ${node.params.join(", ")}`
);
}
}
if (this.options.debug) {
console.log(`[ASTBuilder] Created node for ${token.type} block`);
console.log(
"[ASTBuilder] Script content (first 50 chars):",
node.script.substring(0, 50) + (node.script.length > 50 ? "..." : "")
);
}
current++;
return node;
}
if (token.type === "identifier") {
if (this.options.debug) {
console.log(

View file

@ -46,6 +46,20 @@ class BlueprintBuilder {
if (result.success) {
this.fileHandler.writeCompiledFiles(outputDir, baseName, result.html, result.css);
if (result.hasServerCode && result.serverCode) {
const serverDir = path.join(outputDir, 'server');
if (!fs.existsSync(serverDir)) {
fs.mkdirSync(serverDir, { recursive: true });
}
const serverFilePath = path.join(serverDir, `${baseName}-server.js`);
fs.writeFileSync(serverFilePath, result.serverCode, 'utf8');
if (this.options.debug) {
console.log(`[DEBUG] Server code written to ${serverFilePath}`);
}
}
if (this.options.debug) {
console.log("[DEBUG] Build completed successfully");
}
@ -54,6 +68,7 @@ class BlueprintBuilder {
return {
success: result.success,
errors: result.errors,
hasServerCode: result.hasServerCode
};
} catch (error) {
if (this.options.debug) {
@ -61,6 +76,7 @@ class BlueprintBuilder {
}
return {
success: false,
hasServerCode: false,
errors: [
{
message: error.message,

View file

@ -51,18 +51,25 @@ class BlueprintCompiler {
const html = this.htmlGenerator.generateHTML(ast);
const css = this.cssGenerator.generateCSS();
const hasServerCode = this.htmlGenerator.hasServerCode();
const serverCode = hasServerCode ? this.htmlGenerator.generateServerCode() : '';
const headContent = this.metadataManager.generateHeadContent(baseName);
const finalHtml = this.htmlGenerator.generateFinalHtml(headContent, html);
if (this.options.debug) {
console.log("[DEBUG] Compilation completed successfully");
if (hasServerCode) {
console.log("[DEBUG] Server code generated");
}
}
return {
success: true,
html: finalHtml,
css: css,
hasServerCode: hasServerCode,
serverCode: serverCode,
errors: [],
};
} catch (error) {
@ -73,6 +80,8 @@ class BlueprintCompiler {
success: false,
html: null,
css: null,
hasServerCode: false,
serverCode: null,
errors: [
{
message: error.message,

View file

@ -6,6 +6,8 @@ const StandardElementGenerator = require("./generators/StandardElementGenerator"
const RootNodeGenerator = require("./generators/RootNodeGenerator");
const InputElementGenerator = require("./generators/InputElementGenerator");
const MediaElementGenerator = require("./generators/MediaElementGenerator");
const JavaScriptGenerator = require("./generators/JavaScriptGenerator");
const ServerCodeGenerator = require("./generators/ServerCodeGenerator");
const HTMLTemplate = require("./templates/HTMLTemplate");
const StringUtils = require("./utils/StringUtils");
@ -21,6 +23,9 @@ class HTMLGenerator {
this.options = options;
this.cssGenerator = cssGenerator;
this.htmlTemplate = new HTMLTemplate(options);
this.serverGenerator = new ServerCodeGenerator(options);
this.jsGenerator = new JavaScriptGenerator(options, this.serverGenerator);
this.currentElement = null;
this.generators = [
@ -86,6 +91,10 @@ class HTMLGenerator {
console.log("[HTMLGenerator] Node details:", StringUtils.safeStringify(node));
}
// Handle client and server blocks
if (node.type === "client" || node.type === "server") {
return this.handleScriptBlock(node);
}
if (node.type === "element" && node.tag === "page") {
if (this.options.debug) {
@ -94,20 +103,120 @@ class HTMLGenerator {
return "";
}
const prevElement = this.currentElement;
this.currentElement = node;
// Check if this element has an explicit ID in its props
if (node.type === "element") {
const idProp = node.props.find(p => typeof p === "string" && p.startsWith("id:"));
if (idProp) {
const idValue = idProp.substring(idProp.indexOf(":") + 1).trim().replace(/^"|"$/g, "");
node.elementId = idValue;
// Register this element as reactive
this.jsGenerator.registerReactiveElement(idValue);
if (this.options.debug) {
console.log(`[HTMLGenerator] Found explicit ID: ${idValue}, registered as reactive`);
}
}
}
let result = "";
for (const generator of this.generators) {
if (generator.canHandle(node)) {
if (this.options.debug) {
console.log(`[HTMLGenerator] Using ${generator.constructor.name} for node`);
}
return generator.generate(node);
// If this is an element that might have event handlers,
// add a unique ID to it for client scripts if it doesn't already have one
if (node.type === "element" && node.children.some(child => child.type === "client")) {
// Generate a unique ID for this element if it doesn't already have one
if (!node.elementId) {
node.elementId = this.jsGenerator.generateElementId();
if (this.options.debug) {
console.log(`[HTMLGenerator] Generated ID for element: ${node.elementId}`);
}
}
result = generator.generate(node);
// Process all client blocks inside this element
node.children
.filter(child => child.type === "client")
.forEach(clientBlock => {
this.handleScriptBlock(clientBlock, node.elementId);
});
} else {
result = generator.generate(node);
}
this.currentElement = prevElement;
return result;
}
}
if (this.options.debug) {
console.log(`[HTMLGenerator] No generator found for node type: ${node.type}`);
}
this.currentElement = prevElement;
return "";
}
/**
* Handles client and server script blocks.
* @param {Object} node - The script node to handle
* @param {string} [elementId] - The ID of the parent element, if any
* @returns {string} - Empty string as script blocks don't directly generate HTML
*/
handleScriptBlock(node, elementId = null) {
if (this.options.debug) {
console.log(`\n[HTMLGenerator] Processing ${node.type} script block`);
console.log(`[HTMLGenerator] Script content (first 50 chars): "${node.script.substring(0, 50)}..."`);
if (elementId) {
console.log(`[HTMLGenerator] Attaching to element: ${elementId}`);
}
}
if (node.type === "client") {
if (!elementId && this.currentElement) {
if (!this.currentElement.elementId) {
this.currentElement.elementId = this.jsGenerator.generateElementId();
}
elementId = this.currentElement.elementId;
}
if (elementId) {
this.jsGenerator.addClientScript(elementId, node.script);
} else {
if (this.options.debug) {
console.log(`[HTMLGenerator] Warning: Client script with no parent element`);
}
}
} else if (node.type === "server") {
if (!elementId && this.currentElement) {
if (!this.currentElement.elementId) {
this.currentElement.elementId = this.jsGenerator.generateElementId();
}
elementId = this.currentElement.elementId;
}
if (elementId) {
const params = node.params || [];
if (this.options.debug && params.length > 0) {
console.log(`[HTMLGenerator] Server block parameters: ${params.join(", ")}`);
}
this.jsGenerator.addServerScript(elementId, node.script, params);
} else {
if (this.options.debug) {
console.log(`[HTMLGenerator] Warning: Server script with no parent element`);
}
}
}
return "";
}
@ -177,7 +286,28 @@ class HTMLGenerator {
* @returns {string} - A complete HTML document containing the provided head and body content.
*/
generateFinalHtml(headContent, bodyContent) {
return this.htmlTemplate.generateDocument(headContent, bodyContent);
const clientScripts = this.jsGenerator.generateClientScripts();
return this.htmlTemplate.generateDocument(
headContent,
bodyContent + clientScripts
);
}
/**
* Generates server-side code for Express.js API routes.
* @returns {string} - Express.js server code
*/
generateServerCode() {
return this.serverGenerator.generateServerCode();
}
/**
* Checks if there is any server code to generate.
* @returns {boolean} - Whether there is server code
*/
hasServerCode() {
return this.serverGenerator.hasServerCodeToGenerate();
}
}

View file

@ -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 ? "" : ""}</${tagName}>${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;

View file

@ -102,6 +102,142 @@ class TokenParser {
continue;
}
if (char === "@") {
const startPos = current;
const startColumn = column;
const startLine = line;
current++;
column++;
let blockType = "";
char = input[current];
while (current < input.length && /[a-zA-Z]/.test(char)) {
blockType += char;
current++;
column++;
char = input[current];
}
if (blockType === "client" || blockType === "server") {
if (this.options.debug) {
console.log(`[TokenParser] ${blockType} block found at line ${startLine}, column ${startColumn}`);
}
while (current < input.length && /\s/.test(char)) {
if (char === "\n") {
line++;
column = 1;
} else {
column++;
}
current++;
char = input[current];
}
let params = [];
if (blockType === "server" && char === "(") {
current++;
column++;
let paramString = "";
let depth = 1;
while (current < input.length && depth > 0) {
char = input[current];
if (char === "(") depth++;
if (char === ")") depth--;
if (depth === 0) break;
paramString += char;
if (char === "\n") {
line++;
column = 1;
} else {
column++;
}
current++;
}
current++;
column++;
params = paramString.split(",").map(p => p.trim()).filter(p => p);
if (this.options.debug) {
console.log(`[TokenParser] Server block parameters: ${params.join(", ")}`);
}
char = input[current];
while (current < input.length && /\s/.test(char)) {
if (char === "\n") {
line++;
column = 1;
} else {
column++;
}
current++;
char = input[current];
}
}
if (char === "{") {
current++;
column++;
let script = "";
let braceCount = 1;
while (current < input.length && braceCount > 0) {
char = input[current];
if (char === "{") braceCount++;
if (char === "}") braceCount--;
if (braceCount === 0) break;
script += char;
if (char === "\n") {
line++;
column = 1;
} else {
column++;
}
current++;
}
current++;
column++;
tokens.push({
type: blockType,
value: script.trim(),
params: params,
line: startLine,
column: startColumn,
});
if (this.options.debug) {
console.log(`[TokenParser] ${blockType} block script: "${script.trim().substring(0, 50)}..."`);
}
continue;
} else {
throw new BlueprintError(
`Expected opening brace after @${blockType}${params.length ? '(...)' : ''}`,
line,
column
);
}
} else {
throw new BlueprintError(
`Unknown block type: @${blockType}`,
startLine,
startColumn
);
}
}
if (/[a-zA-Z]/.test(char)) {
let value = "";
const startColumn = column;

View file

@ -49,6 +49,22 @@ class ButtonElementGenerator {
let attributes = "";
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 (this.options.debug) {
console.log(`[ButtonElementGenerator] Added explicit ID attribute: ${idValue}`);
}
}
else if (node.elementId) {
attributes += ` id="${node.elementId}"`;
if (this.options.debug) {
console.log(`[ButtonElementGenerator] Adding generated ID attribute: ${node.elementId}`);
}
}
if (node.parent?.tag === "link") {
const linkInfo = this.linkProcessor.processLink(node.parent);
attributes += this.linkProcessor.getButtonClickHandler(linkInfo);

View file

@ -55,6 +55,23 @@ class InputElementGenerator {
attributes = ' type="range"';
}
// Extract and handle ID attribute
let idAttr = "";
const idProp = node.props.find(p => typeof p === "string" && p.startsWith("id:"));
if (idProp) {
const idValue = idProp.substring(idProp.indexOf(":") + 1).trim().replace(/^"|"$/g, "");
idAttr = ` id="${idValue}"`;
node.elementId = idValue;
// Register as reactive element with the parent generator
if (this.parentGenerator && this.parentGenerator.jsGenerator) {
this.parentGenerator.jsGenerator.registerReactiveElement(idValue);
if (this.options.debug) {
console.log(`[InputElementGenerator] Registered checkbox with ID: ${idValue} as reactive`);
}
}
}
const valueProp = node.props.find((p) => p.startsWith("value:"));
if (valueProp) {
const value = valueProp.substring(valueProp.indexOf(":") + 1).trim();
@ -70,7 +87,7 @@ class InputElementGenerator {
if (node.children.length > 0) {
let html = `<label class="${className}-container">`;
html += `<input class="${className}"${attributes}>`;
html += `<input class="${className}"${attributes}${idAttr}>`;
node.children.forEach((child) => {
child.parent = node;
@ -80,7 +97,7 @@ class InputElementGenerator {
html += `</label>`;
return html;
} else {
return `<input class="${className}"${attributes}>`;
return `<input class="${className}"${attributes}${idAttr}>`;
}
}
}

View file

@ -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<string>} 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 `<script>
${this.generateReactiveStore()}
${serverClientCode}
document.addEventListener('DOMContentLoaded', function() {
${initCode}
${scripts}});
</script>`;
}
/**
* Gets all server-side scripts.
* @returns {Array<Object>} - Array of server-side script objects
*/
getServerScripts() {
return this.serverScripts;
}
}
module.exports = JavaScriptGenerator;

View file

@ -0,0 +1,236 @@
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<string>} 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 => ` const ${param}_element = document.getElementById('${param}');
if (${param}_element) {
console.log('Found element: ${param}', ${param}_element);
if (${param}_element.type === 'checkbox') {
data.${param} = ${param}_element.checked;
console.log('Checkbox ${param} value:', ${param}_element.checked);
} else if (${param}_element.type === 'radio') {
data.${param} = ${param}_element.checked;
} else if (${param}_element.value !== undefined) {
data.${param} = ${param}_element.value;
} else {
data.${param} = ${param}_element.textContent;
}
} else {
console.error('[Blueprint] Element with ID ${param} not found');
data.${param} = null;
}`).join('\n')}
console.log('Submitting data:', data);
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) {
if (element.tagName.toLowerCase() === 'input') {
element.value = result[key];
} else {
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 => {
return `const ${param} = req.body.${param} !== undefined ? req.body.${param} : null;
if (${param} === null) {
console.error(\`Missing parameter: ${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;

View file

@ -54,6 +54,22 @@ class StandardElementGenerator {
let attributes = "";
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 (this.options.debug) {
console.log(`[StandardElementGenerator] Added explicit ID attribute: ${idValue}`);
}
}
else if (node.elementId) {
attributes += ` id="${node.elementId}"`;
if (this.options.debug) {
console.log(`[StandardElementGenerator] Adding generated ID attribute: ${node.elementId}`);
}
}
if (node.props.find((p) => typeof p === "string" && p.startsWith("data-"))) {
const dataProps = node.props.filter(
(p) => typeof p === "string" && p.startsWith("data-")

View file

@ -538,7 +538,7 @@ const ELEMENT_MAPPINGS = {
},
card: {
tag: "div",
defaultProps: ["raised", "card"],
defaultProps: ["card"],
},
grid: {
tag: "div",

View file

@ -11,6 +11,7 @@ class BlueprintServer {
this.wsInstance = expressWs(this.app);
this.options = {
port: 3000,
apiPort: 3001,
srcDir: "./src",
outDir: "./dist",
liveReload: false,
@ -19,6 +20,8 @@ class BlueprintServer {
};
this.clients = new Map();
this.filesWithErrors = new Set();
this.apiServers = new Map();
this.apiPorts = new Map();
this.setupServer();
if (this.options.liveReload) {
const watcher = chokidar.watch([], {
@ -51,6 +54,27 @@ class BlueprintServer {
);
}
async buildFile(filePath) {
const relativePath = path.relative(this.options.srcDir, filePath);
const outputPath = path.join(
this.options.outDir,
relativePath.replace(/\.bp$/, ".html")
);
this.ensureDirectoryExistence(outputPath);
const builder = new BlueprintBuilder({
minified: this.options.minified,
debug: this.options.debug,
});
const buildResult = builder.build(filePath, path.dirname(outputPath));
if (buildResult.success && buildResult.hasServerCode) {
this.startApiServer(relativePath.replace(/\.bp$/, ""));
}
return buildResult;
}
async buildAll() {
this.log("INFO", "Building all Blueprint files...", "lightGray");
if (fs.existsSync(this.options.outDir)) {
@ -70,8 +94,17 @@ class BlueprintServer {
);
this.ensureDirectoryExistence(outputPath);
const builder = new BlueprintBuilder({ minified: this.options.minified });
const builder = new BlueprintBuilder({
minified: this.options.minified,
debug: this.options.debug
});
const result = builder.build(file, path.dirname(outputPath));
if (result.success && result.hasServerCode) {
const fileName = relativePath.replace(/\.bp$/, "");
this.startApiServer(fileName);
}
if (!result.success) {
success = false;
errors.push({ file, errors: result.errors });
@ -138,8 +171,19 @@ class BlueprintServer {
fs.readFile(htmlPath, "utf8", (err, data) => {
if (err) return next();
let html = data;
const filePath = req.path.endsWith(".html")
? req.path.slice(0, -5)
: req.path === "/"
? "index"
: req.path.replace(/^\//, "");
const apiPort = this.apiPorts.get(filePath) || this.options.apiPort;
const script = `
<script>
window.blueprintServerPort = ${apiPort};
(function() {
let currentPage = window.location.pathname.replace(/^\\//, '') || 'index.html';
console.log('Current page:', currentPage);
@ -240,7 +284,33 @@ class BlueprintServer {
const htmlPath = path.join(this.options.outDir, req.path + ".html");
if (fs.existsSync(htmlPath)) {
res.sendFile(htmlPath);
fs.readFile(htmlPath, "utf8", (err, data) => {
if (err) return res.sendFile(htmlPath);
let html = data;
const filePath = req.path === "/"
? "index"
: req.path.replace(/^\//, "");
const apiPort = this.apiPorts.get(filePath) || this.options.apiPort;
if (!html.includes('window.blueprintServerPort =')) {
const script = `
<script>
// Inject the Blueprint server port for API calls
window.blueprintServerPort = ${apiPort};
</script>
`;
html = html.replace("</head>", script + "</head>");
}
res.setHeader("Content-Type", "text/html");
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
return res.send(html);
});
} else if (req.path === "/") {
const pages = fs
.readdirSync(this.options.outDir)
@ -311,83 +381,35 @@ class BlueprintServer {
}
setupWatcher(watcher) {
watcher.on("change", async (filepath) => {
if (filepath.endsWith(".bp")) {
this.log("INFO", `File ${filepath} has been changed`, "blue");
try {
const builder = new BlueprintBuilder({
minified: this.options.minified,
debug: this.options.debug,
});
const relativePath = path.relative(this.options.srcDir, filepath);
const outputPath = path.join(
this.options.outDir,
relativePath.replace(/\.bp$/, ".html")
);
this.ensureDirectoryExistence(outputPath);
const result = builder.build(filepath, path.dirname(outputPath));
if (result.success) {
this.log("SUCCESS", "Rebuilt successfully", "green");
this.filesWithErrors.delete(filepath);
const htmlFile = relativePath.replace(/\.bp$/, ".html");
const htmlPath = path.join(this.options.outDir, htmlFile);
try {
const newContent = fs.readFileSync(htmlPath, "utf8");
for (const [client, page] of this.clients.entries()) {
if (
page === htmlFile.replace(/\\/g, "/") &&
client.readyState === 1
) {
try {
client.send(
JSON.stringify({
type: "reload",
content: newContent,
})
);
} catch (error) {
this.log("ERROR", "Error sending content:", "red");
this.clients.delete(client);
}
}
}
} catch (error) {
this.log("ERROR", "Error reading new content:", "red");
}
} else {
this.filesWithErrors.add(filepath);
this.log("ERROR", `Build failed: ${result.errors.map(e => e.message).join(", ")}`, "red");
this.log("INFO", "Waiting for next file change...", "orange");
for (const [client, page] of this.clients.entries()) {
const htmlFile = relativePath.replace(/\.bp$/, ".html");
if (
page === htmlFile.replace(/\\/g, "/") &&
client.readyState === 1
) {
try {
client.send(
JSON.stringify({
type: "buildError",
errors: result.errors,
})
);
} catch (error) {
this.log("ERROR", "Error sending error notification:", "red");
this.clients.delete(client);
}
}
}
}
} catch (error) {
this.log("ERROR", "Unexpected error during build:", "red");
this.filesWithErrors.add(filepath);
watcher.on("change", async (filePath) => {
if (!filePath.endsWith(".bp")) return;
this.log("INFO", `File changed: ${filePath}`, "blue");
const result = await this.buildFile(filePath);
if (result.success) {
this.log("SUCCESS", `Rebuilt ${filePath}`, "green");
this.filesWithErrors.delete(filePath);
if (result.hasServerCode) {
const relativePath = path.relative(this.options.srcDir, filePath);
const fileName = relativePath.replace(/\.bp$/, "");
this.startApiServer(fileName);
}
if (this.options.liveReload) {
this.notifyClients(filePath);
}
} else {
this.log("ERROR", `Build failed for ${filePath}`, "red");
result.errors.forEach((err) => {
this.log(
"ERROR",
`${err.type} at line ${err.line}, column ${err.column}: ${err.message}`,
"red"
);
});
this.filesWithErrors.add(filePath);
}
});
@ -468,6 +490,119 @@ class BlueprintServer {
}
});
}
startApiServer(fileName) {
const serverFilePath = path.join(this.options.outDir, 'server', `${fileName}-server.js`);
if (!fs.existsSync(serverFilePath)) {
this.log("ERROR", `API server file not found: ${serverFilePath}`, "red");
return;
}
let apiPort;
if (this.apiPorts.has(fileName)) {
apiPort = this.apiPorts.get(fileName);
this.log("INFO", `Reusing port ${apiPort} for ${fileName}`, "blue");
} else {
apiPort = this.options.apiPort;
this.options.apiPort++;
this.log("INFO", `Assigning new port ${apiPort} for ${fileName}`, "blue");
}
const startNewServer = () => {
try {
delete require.cache[require.resolve(path.resolve(serverFilePath))];
const createApiServer = require(path.resolve(serverFilePath));
const apiServer = createApiServer(apiPort);
this.apiServers.set(fileName, apiServer);
this.apiPorts.set(fileName, apiPort);
this.log("SUCCESS", `API server started for ${fileName} on port ${apiPort}`, "green");
} catch (error) {
this.log("ERROR", `Failed to start API server: ${error.message}`, "red");
console.error(error);
}
};
if (this.apiServers.has(fileName)) {
const existingServer = this.apiServers.get(fileName);
this.log("INFO", `Stopping previous API server for ${fileName}`, "blue");
try {
if (existingServer && typeof existingServer.close === 'function') {
existingServer.close(() => {
this.log("INFO", `Previous server closed, starting new one`, "blue");
setTimeout(startNewServer, 300);
});
return;
}
if (existingServer && existingServer.server && existingServer.server.close) {
existingServer.server.close(() => {
this.log("INFO", `Previous server closed, starting new one`, "blue");
setTimeout(startNewServer, 300);
});
return;
}
this.log("WARNING", `Could not properly close previous server, waiting longer`, "orange");
setTimeout(startNewServer, 1000);
} catch (err) {
this.log("WARNING", `Error closing previous server: ${err.message}`, "orange");
setTimeout(startNewServer, 2000);
}
} else {
startNewServer();
}
}
notifyClients(filePath) {
const relativePath = path.relative(this.options.srcDir, filePath);
const htmlFile = relativePath.replace(/\.bp$/, ".html");
const htmlPath = path.join(this.options.outDir, htmlFile);
const fileName = relativePath.replace(/\.bp$/, "");
try {
let newContent = fs.readFileSync(htmlPath, "utf8");
const apiPort = this.apiPorts.get(fileName) || this.options.apiPort;
if (!newContent.includes('window.blueprintServerPort =')) {
newContent = newContent.replace(
'<head>',
`<head>
<script>
window.blueprintServerPort = ${apiPort};
</script>`
);
}
for (const [client, page] of this.clients.entries()) {
if (
page === htmlFile.replace(/\\/g, "/") &&
client.readyState === 1
) {
try {
client.send(
JSON.stringify({
type: "reload",
content: newContent,
})
);
this.log("INFO", `Sent update to client for ${htmlFile}`, "blue");
} catch (error) {
this.log("ERROR", "Error sending content:", "red");
this.clients.delete(client);
}
}
}
} catch (error) {
this.log("ERROR", `Error reading new content from ${htmlPath}: ${error.message}`, "red");
}
}
}
module.exports = BlueprintServer;

View file

@ -52,8 +52,23 @@ const safeStringify = (obj) => {
}
};
/**
* Generates a random ID string suitable for use as an HTML element ID.
* @param {number} [length=8] - The length of the random ID
* @returns {string} - A random alphanumeric ID with bp_ prefix
*/
const generateRandomId = (length = 8) => {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = 'bp_';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};
module.exports = {
escapeHTML,
toKebabCase,
safeStringify
safeStringify,
generateRandomId
};

View file

@ -10,6 +10,7 @@
"dependencies": {
"axios": "^1.7.9",
"chokidar": "^3.5.3",
"cors": "^2.8.5",
"express": "^4.18.2",
"express-ws": "^5.0.2",
"ws": "^8.18.0"