Compare commits
4 commits
be9b30fbdc
...
1b6fdfd9cd
Author | SHA1 | Date | |
---|---|---|---|
1b6fdfd9cd | |||
d125640fe7 | |||
362b7aa15e | |||
ff7bb041ef |
36 changed files with 2899 additions and 401 deletions
190
examples/reactive-example.bp
Normal file
190
examples/reactive-example.bp
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
163
examples/server-form-example.bp
Normal file
163
examples/server-form-example.bp
Normal 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}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
File diff suppressed because one or more lines are too long
|
@ -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
|
@ -38,6 +38,10 @@ const elements = [
|
||||||
'card', 'badge', 'alert', 'tooltip', 'input', 'textarea', 'select',
|
'card', 'badge', 'alert', 'tooltip', 'input', 'textarea', 'select',
|
||||||
'checkbox', 'radio', 'switch', 'list', 'table', 'progress', 'slider'
|
'checkbox', 'radio', 'switch', 'list', 'table', 'progress', 'slider'
|
||||||
];
|
];
|
||||||
|
// Script blocks
|
||||||
|
const scriptBlocks = [
|
||||||
|
'client', 'server'
|
||||||
|
];
|
||||||
// Single instance elements
|
// Single instance elements
|
||||||
const singleElements = ['page', 'navbar'];
|
const singleElements = ['page', 'navbar'];
|
||||||
// Blueprint properties
|
// Blueprint properties
|
||||||
|
@ -52,6 +56,8 @@ const properties = [
|
||||||
];
|
];
|
||||||
// Page configuration properties
|
// Page configuration properties
|
||||||
const pageProperties = ['title', 'description', 'keywords', 'author'];
|
const pageProperties = ['title', 'description', 'keywords', 'author'];
|
||||||
|
// ID attribute suggestion - using underscore format
|
||||||
|
const idAttributeTemplate = 'id:$1_$2';
|
||||||
// Container elements that can have children
|
// Container elements that can have children
|
||||||
const containerElements = [
|
const containerElements = [
|
||||||
'horizontal', 'vertical', 'section', 'grid', 'navbar',
|
'horizontal', 'vertical', 'section', 'grid', 'navbar',
|
||||||
|
@ -85,6 +91,18 @@ connection.onCompletion((textDocumentPosition) => {
|
||||||
const position = textDocumentPosition.position;
|
const position = textDocumentPosition.position;
|
||||||
const line = lines[position.line];
|
const line = lines[position.line];
|
||||||
const linePrefix = line.slice(0, position.character);
|
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
|
// Check if this is a template completion trigger
|
||||||
if (linePrefix.trim() === '!') {
|
if (linePrefix.trim() === '!') {
|
||||||
return [{
|
return [{
|
||||||
|
@ -114,13 +132,22 @@ connection.onCompletion((textDocumentPosition) => {
|
||||||
documentation: `Add ${prop} to the page configuration`
|
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('(')) {
|
if (linePrefix.trim().endsWith('(')) {
|
||||||
return properties.map(prop => ({
|
return [
|
||||||
|
...properties.map(prop => ({
|
||||||
label: prop,
|
label: prop,
|
||||||
kind: node_1.CompletionItemKind.Property,
|
kind: node_1.CompletionItemKind.Property,
|
||||||
documentation: `Apply ${prop} 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
|
// After a container element's opening brace, suggest child elements
|
||||||
const containerMatch = /\b(horizontal|vertical|section|grid|navbar|links|card)\s*{\s*$/.exec(linePrefix);
|
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'];
|
suggestedElements = ['title', 'text', 'button', 'image'];
|
||||||
break;
|
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 => ({
|
return suggestedElements.map(element => ({
|
||||||
label: element,
|
label: element,
|
||||||
kind: node_1.CompletionItemKind.Class,
|
kind: node_1.CompletionItemKind.Class,
|
||||||
|
@ -147,6 +193,26 @@ connection.onCompletion((textDocumentPosition) => {
|
||||||
documentation: `Create a ${element} block inside ${parentElement}`
|
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
|
// Get available single instance elements
|
||||||
const availableSingleElements = singleElements.filter(element => !elementExists(text, element));
|
const availableSingleElements = singleElements.filter(element => !elementExists(text, element));
|
||||||
// Combine regular elements with available single instance elements
|
// Combine regular elements with available single instance elements
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -58,6 +58,11 @@ const elements = [
|
||||||
'checkbox', 'radio', 'switch', 'list', 'table', 'progress', 'slider'
|
'checkbox', 'radio', 'switch', 'list', 'table', 'progress', 'slider'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Script blocks
|
||||||
|
const scriptBlocks = [
|
||||||
|
'client', 'server'
|
||||||
|
];
|
||||||
|
|
||||||
// Single instance elements
|
// Single instance elements
|
||||||
const singleElements = ['page', 'navbar'];
|
const singleElements = ['page', 'navbar'];
|
||||||
|
|
||||||
|
@ -75,6 +80,9 @@ const properties = [
|
||||||
// Page configuration properties
|
// Page configuration properties
|
||||||
const pageProperties = ['title', 'description', 'keywords', 'author'];
|
const pageProperties = ['title', 'description', 'keywords', 'author'];
|
||||||
|
|
||||||
|
// ID attribute suggestion - using underscore format
|
||||||
|
const idAttributeTemplate = 'id:$1_$2';
|
||||||
|
|
||||||
// Container elements that can have children
|
// Container elements that can have children
|
||||||
const containerElements = [
|
const containerElements = [
|
||||||
'horizontal', 'vertical', 'section', 'grid', 'navbar',
|
'horizontal', 'vertical', 'section', 'grid', 'navbar',
|
||||||
|
@ -114,6 +122,19 @@ connection.onCompletion(
|
||||||
const line = lines[position.line];
|
const line = lines[position.line];
|
||||||
const linePrefix = line.slice(0, position.character);
|
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
|
// Check if this is a template completion trigger
|
||||||
if (linePrefix.trim() === '!') {
|
if (linePrefix.trim() === '!') {
|
||||||
return [{
|
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('(')) {
|
if (linePrefix.trim().endsWith('(')) {
|
||||||
return properties.map(prop => ({
|
return [
|
||||||
|
...properties.map(prop => ({
|
||||||
label: prop,
|
label: prop,
|
||||||
kind: CompletionItemKind.Property,
|
kind: CompletionItemKind.Property,
|
||||||
documentation: `Apply ${prop} 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
|
// After a container element's opening brace, suggest child elements
|
||||||
|
@ -173,6 +203,26 @@ connection.onCompletion(
|
||||||
break;
|
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 => ({
|
return suggestedElements.map(element => ({
|
||||||
label: element,
|
label: element,
|
||||||
kind: CompletionItemKind.Class,
|
kind: CompletionItemKind.Class,
|
||||||
|
@ -182,6 +232,27 @@ connection.onCompletion(
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Get available single instance elements
|
||||||
const availableSingleElements = singleElements.filter(element => !elementExists(text, element));
|
const availableSingleElements = singleElements.filter(element => !elementExists(text, element));
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,9 @@
|
||||||
{
|
{
|
||||||
"include": "#strings"
|
"include": "#strings"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"include": "#script-blocks"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"include": "#punctuation"
|
"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": {
|
"properties": {
|
||||||
"patterns": [
|
"patterns": [
|
||||||
{
|
{
|
||||||
|
@ -71,7 +92,7 @@
|
||||||
"name": "support.type.property-name.blueprint"
|
"name": "support.type.property-name.blueprint"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"match": "(?<!:)(src|type|href|\\w+)\\s*:",
|
"match": "(?<!:)(src|type|href|id|\\w+)\\s*:",
|
||||||
"captures": {
|
"captures": {
|
||||||
"1": { "name": "support.type.property-name.blueprint" }
|
"1": { "name": "support.type.property-name.blueprint" }
|
||||||
}
|
}
|
||||||
|
|
23
index.js
Normal file
23
index.js
Normal 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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -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 (token.type === "identifier") {
|
||||||
if (this.options.debug) {
|
if (this.options.debug) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const TokenParser = require("./TokenParser");
|
const BlueprintCompiler = require("./BlueprintCompiler");
|
||||||
const ASTBuilder = require("./ASTBuilder");
|
const BlueprintFileHandler = require("./BlueprintFileHandler");
|
||||||
const CSSGenerator = require("./CSSGenerator");
|
|
||||||
const HTMLGenerator = require("./HTMLGenerator");
|
|
||||||
const MetadataManager = require("./MetadataManager");
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlueprintBuilder coordinates the entire build process from reading Blueprint files
|
||||||
|
* to writing compiled HTML and CSS files.
|
||||||
|
*/
|
||||||
class BlueprintBuilder {
|
class BlueprintBuilder {
|
||||||
/**
|
/**
|
||||||
* Create a new Blueprint builder instance.
|
* Create a new Blueprint builder instance.
|
||||||
|
@ -20,14 +21,10 @@ class BlueprintBuilder {
|
||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.tokenParser = new TokenParser(this.options);
|
this.compiler = new BlueprintCompiler(this.options);
|
||||||
this.astBuilder = new ASTBuilder(this.options);
|
this.fileHandler = new BlueprintFileHandler(this.options);
|
||||||
this.cssGenerator = new CSSGenerator(this.options);
|
|
||||||
this.htmlGenerator = new HTMLGenerator(this.options, this.cssGenerator);
|
|
||||||
this.metadataManager = new MetadataManager(this.options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a Blueprint file.
|
* Builds a Blueprint file.
|
||||||
* @param {string} inputPath - Path to the Blueprint file to build
|
* @param {string} inputPath - Path to the Blueprint file to build
|
||||||
|
@ -40,42 +37,38 @@ class BlueprintBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!inputPath.endsWith(".bp")) {
|
const input = this.fileHandler.readBlueprintFile(inputPath);
|
||||||
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 baseName = path.basename(inputPath, ".bp");
|
const baseName = path.basename(inputPath, ".bp");
|
||||||
const headContent = this.metadataManager.generateHeadContent(baseName);
|
|
||||||
const finalHtml = this.generateFinalHtml(headContent, html);
|
|
||||||
|
|
||||||
if (!fs.existsSync(outputDir)) {
|
const result = this.compiler.compile(input, baseName);
|
||||||
fs.mkdirSync(outputDir, { recursive: true });
|
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(path.join(outputDir, `${baseName}.html`), finalHtml);
|
const serverFilePath = path.join(serverDir, `${baseName}-server.js`);
|
||||||
fs.writeFileSync(path.join(outputDir, `${baseName}.css`), css);
|
fs.writeFileSync(serverFilePath, result.serverCode, 'utf8');
|
||||||
|
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log(`[DEBUG] Server code written to ${serverFilePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.options.debug) {
|
if (this.options.debug) {
|
||||||
console.log("[DEBUG] Build completed successfully");
|
console.log("[DEBUG] Build completed successfully");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: result.success,
|
||||||
errors: [],
|
errors: result.errors,
|
||||||
|
hasServerCode: result.hasServerCode
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.options.debug) {
|
if (this.options.debug) {
|
||||||
|
@ -83,6 +76,7 @@ class BlueprintBuilder {
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
hasServerCode: false,
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
message: error.message,
|
message: error.message,
|
||||||
|
@ -94,51 +88,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;
|
module.exports = BlueprintBuilder;
|
||||||
|
|
98
lib/BlueprintCompiler.js
Normal file
98
lib/BlueprintCompiler.js
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
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 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) {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log("[DEBUG] Compilation failed with error:", error);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
html: null,
|
||||||
|
css: null,
|
||||||
|
hasServerCode: false,
|
||||||
|
serverCode: null,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: error.message,
|
||||||
|
type: error.name,
|
||||||
|
line: error.line,
|
||||||
|
column: error.column,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BlueprintCompiler;
|
55
lib/BlueprintFileHandler.js
Normal file
55
lib/BlueprintFileHandler.js
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
const path = require("path");
|
||||||
|
const BlueprintFileReader = require("./file/BlueprintFileReader");
|
||||||
|
const BlueprintFileWriter = require("./file/BlueprintFileWriter");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlueprintFileHandler coordinates file I/O operations for the Blueprint compiler.
|
||||||
|
*/
|
||||||
|
class BlueprintFileHandler {
|
||||||
|
/**
|
||||||
|
* Create a new BlueprintFileHandler instance.
|
||||||
|
* @param {Object} [options] - Options object
|
||||||
|
* @param {boolean} [options.debug=false] - Enable debug logging
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = {
|
||||||
|
debug: false,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.reader = new BlueprintFileReader(this.options);
|
||||||
|
this.writer = new BlueprintFileWriter(this.options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a Blueprint file from the file system.
|
||||||
|
* @param {string} inputPath - Path to the Blueprint file
|
||||||
|
* @returns {string} - The content of the file
|
||||||
|
* @throws {Error} - If the file does not exist or has an invalid extension
|
||||||
|
*/
|
||||||
|
readBlueprintFile(inputPath) {
|
||||||
|
return this.reader.readFile(inputPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the compiled HTML and CSS to the file system.
|
||||||
|
* @param {string} outputDir - Directory to write the files to
|
||||||
|
* @param {string} baseName - Base name for the files
|
||||||
|
* @param {string} html - HTML content to write
|
||||||
|
* @param {string} css - CSS content to write
|
||||||
|
* @throws {Error} - If the output directory cannot be created or the files cannot be written
|
||||||
|
*/
|
||||||
|
writeCompiledFiles(outputDir, baseName, html, css) {
|
||||||
|
this.writer.writeCompiledFiles(outputDir, baseName, html, css);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that a directory exists, creating it if it does not.
|
||||||
|
* @param {string} dir - Directory path
|
||||||
|
*/
|
||||||
|
ensureDirectoryExists(dir) {
|
||||||
|
this.writer.ensureDirectoryExists(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BlueprintFileHandler;
|
|
@ -1,4 +1,15 @@
|
||||||
const { ELEMENT_MAPPINGS } = require("./mappings");
|
const { ELEMENT_MAPPINGS } = require("./mappings");
|
||||||
|
const TextNodeGenerator = require("./generators/TextNodeGenerator");
|
||||||
|
const ButtonElementGenerator = require("./generators/ButtonElementGenerator");
|
||||||
|
const LinkElementGenerator = require("./generators/LinkElementGenerator");
|
||||||
|
const StandardElementGenerator = require("./generators/StandardElementGenerator");
|
||||||
|
const RootNodeGenerator = require("./generators/RootNodeGenerator");
|
||||||
|
const InputElementGenerator = require("./generators/InputElementGenerator");
|
||||||
|
const MediaElementGenerator = require("./generators/MediaElementGenerator");
|
||||||
|
const JavaScriptGenerator = require("./generators/JavaScriptGenerator");
|
||||||
|
const ServerCodeGenerator = require("./generators/ServerCodeGenerator");
|
||||||
|
const HTMLTemplate = require("./templates/HTMLTemplate");
|
||||||
|
const StringUtils = require("./utils/StringUtils");
|
||||||
|
|
||||||
class HTMLGenerator {
|
class HTMLGenerator {
|
||||||
/**
|
/**
|
||||||
|
@ -11,11 +22,32 @@ class HTMLGenerator {
|
||||||
constructor(options = {}, cssGenerator) {
|
constructor(options = {}, cssGenerator) {
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.cssGenerator = cssGenerator;
|
this.cssGenerator = cssGenerator;
|
||||||
|
this.htmlTemplate = new HTMLTemplate(options);
|
||||||
|
this.serverGenerator = new ServerCodeGenerator(options);
|
||||||
|
this.jsGenerator = new JavaScriptGenerator(options, this.serverGenerator);
|
||||||
|
this.currentElement = null;
|
||||||
|
|
||||||
|
|
||||||
|
this.generators = [
|
||||||
|
new TextNodeGenerator(this.options),
|
||||||
|
new RootNodeGenerator(this.options, this),
|
||||||
|
new ButtonElementGenerator(this.options, this.cssGenerator, this),
|
||||||
|
new LinkElementGenerator(this.options, this.cssGenerator, this),
|
||||||
|
new InputElementGenerator(this.options, this.cssGenerator, this),
|
||||||
|
new MediaElementGenerator(this.options, this.cssGenerator, this),
|
||||||
|
|
||||||
|
new StandardElementGenerator(this.options, this.cssGenerator, this)
|
||||||
|
];
|
||||||
|
|
||||||
if (this.options.debug) {
|
if (this.options.debug) {
|
||||||
console.log(
|
console.log(
|
||||||
"[HTMLGenerator] Initialized with options:",
|
"[HTMLGenerator] Initialized with options:",
|
||||||
JSON.stringify(options, null, 2)
|
JSON.stringify(options, null, 2)
|
||||||
);
|
);
|
||||||
|
console.log(
|
||||||
|
"[HTMLGenerator] Registered generators:",
|
||||||
|
this.generators.map(g => g.constructor.name).join(", ")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +80,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
|
* @param {Object} node - Node to generate HTML for
|
||||||
* @returns {string} Generated HTML
|
* @returns {string} Generated HTML
|
||||||
*/
|
*/
|
||||||
|
@ -56,239 +88,136 @@ class HTMLGenerator {
|
||||||
if (this.options.debug) {
|
if (this.options.debug) {
|
||||||
console.log(`\n[HTMLGenerator] Generating HTML for node`);
|
console.log(`\n[HTMLGenerator] Generating HTML for node`);
|
||||||
console.log(`[HTMLGenerator] Node type: "${node.type}"`);
|
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") {
|
// Handle client and server blocks
|
||||||
if (node.parent?.tag === "codeblock") {
|
if (node.type === "client" || node.type === "server") {
|
||||||
if (this.options.debug) {
|
return this.handleScriptBlock(node);
|
||||||
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 (this.options.debug) {
|
|
||||||
console.log("[HTMLGenerator] Generated escaped text");
|
|
||||||
console.log(`[HTMLGenerator] Original: "${node.value}"`);
|
|
||||||
console.log(`[HTMLGenerator] Escaped: "${escapedText}"`);
|
|
||||||
}
|
|
||||||
return escapedText;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = "";
|
if (node.type === "element" && node.tag === "page") {
|
||||||
if (node.type === "element") {
|
|
||||||
if (node.tag === "page") {
|
|
||||||
if (this.options.debug) {
|
if (this.options.debug) {
|
||||||
console.log("[HTMLGenerator] Skipping page node - metadata only");
|
console.log("[HTMLGenerator] Skipping page node - metadata only");
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapping = ELEMENT_MAPPINGS[node.tag];
|
const prevElement = this.currentElement;
|
||||||
let tag = mapping ? mapping.tag : "div";
|
this.currentElement = node;
|
||||||
const className = this.cssGenerator.generateClassName(node.tag);
|
|
||||||
const { cssProps, nestedRules } =
|
// Check if this element has an explicit ID in its props
|
||||||
this.cssGenerator.nodeToCSSProperties(node);
|
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) {
|
if (this.options.debug) {
|
||||||
console.log(`\n[HTMLGenerator] Processing element node`);
|
console.log(`[HTMLGenerator] Found explicit ID: ${idValue}, registered as reactive`);
|
||||||
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 = "";
|
let result = "";
|
||||||
if (tag === "input") {
|
for (const generator of this.generators) {
|
||||||
if (node.tag === "checkbox") {
|
if (generator.canHandle(node)) {
|
||||||
attributes = ' type="checkbox"';
|
|
||||||
} else if (node.tag === "radio") {
|
|
||||||
attributes = ' type="radio"';
|
|
||||||
} else if (node.tag === "switch") {
|
|
||||||
attributes = ' type="checkbox" role="switch"';
|
|
||||||
} else if (node.tag === "slider") {
|
|
||||||
attributes = ' type="range"';
|
|
||||||
}
|
|
||||||
if (this.options.debug) {
|
if (this.options.debug) {
|
||||||
console.log(
|
console.log(`[HTMLGenerator] Using ${generator.constructor.name} for node`);
|
||||||
`[HTMLGenerator] Added input attributes: "${attributes}"`
|
}
|
||||||
);
|
|
||||||
|
// 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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.tag === "media") {
|
result = generator.generate(node);
|
||||||
const srcProp = node.props.find((p) => p.startsWith("src:"));
|
|
||||||
const typeProp = node.props.find((p) => p.startsWith("type:"));
|
|
||||||
|
|
||||||
if (!srcProp) {
|
// Process all client blocks inside this element
|
||||||
throw new BlueprintError("Media element requires src property", node.line, node.column);
|
node.children
|
||||||
}
|
.filter(child => child.type === "client")
|
||||||
|
.forEach(clientBlock => {
|
||||||
const src = srcProp.substring(srcProp.indexOf(":") + 1).trim();
|
this.handleScriptBlock(clientBlock, node.elementId);
|
||||||
const type = typeProp ? typeProp.substring(typeProp.indexOf(":") + 1).trim() : "img";
|
});
|
||||||
|
|
||||||
if (type === "video") {
|
|
||||||
tag = "video";
|
|
||||||
attributes = ` src="${src}" controls`;
|
|
||||||
} else {
|
} else {
|
||||||
tag = "img";
|
result = generator.generate(node);
|
||||||
attributes = ` src="${src}" alt="${node.children.map(child => this.generateHTML(child)).join("")}"`;
|
}
|
||||||
|
|
||||||
|
this.currentElement = prevElement;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (this.options.debug) {
|
||||||
console.log(
|
console.log(`[HTMLGenerator] No generator found for node type: ${node.type}`);
|
||||||
`[HTMLGenerator] Added external link attributes for: ${linkInfo.href}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
if (this.options.debug) {
|
if (this.options.debug) {
|
||||||
console.log(
|
console.log(`[HTMLGenerator] Warning: Client script with no parent element`);
|
||||||
`[HTMLGenerator] Added internal link attributes for: ${linkInfo.href}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (node.type === "server") {
|
||||||
|
if (!elementId && this.currentElement) {
|
||||||
|
if (!this.currentElement.elementId) {
|
||||||
|
this.currentElement.elementId = this.jsGenerator.generateElementId();
|
||||||
|
}
|
||||||
|
elementId = this.currentElement.elementId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (elementId) {
|
||||||
node.props.find((p) => typeof p === "string" && p.startsWith("data-"))
|
const params = node.params || [];
|
||||||
) {
|
if (this.options.debug && params.length > 0) {
|
||||||
const dataProps = node.props.filter(
|
console.log(`[HTMLGenerator] Server block parameters: ${params.join(", ")}`);
|
||||||
(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}`, {
|
this.jsGenerator.addServerScript(elementId, node.script, params);
|
||||||
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 {
|
} else {
|
||||||
attributes += ` onclick="window.location.href='${linkInfo.href}'"`;
|
|
||||||
if (this.options.debug) {
|
if (this.options.debug) {
|
||||||
console.log(
|
console.log(`[HTMLGenerator] Warning: Server script with no parent element`);
|
||||||
`[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) {
|
return "";
|
||||||
console.log("[HTMLGenerator] Generated HTML:", html);
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -348,6 +277,38 @@ class HTMLGenerator {
|
||||||
}
|
}
|
||||||
return { href };
|
return { href };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the final HTML document as a string.
|
||||||
|
*
|
||||||
|
* @param {string} headContent - The HTML content to be placed within the <head> tag.
|
||||||
|
* @param {string} bodyContent - The HTML content to be placed within the <body> tag.
|
||||||
|
* @returns {string} - A complete HTML document containing the provided head and body content.
|
||||||
|
*/
|
||||||
|
generateFinalHtml(headContent, bodyContent) {
|
||||||
|
const clientScripts = this.jsGenerator.generateClientScripts();
|
||||||
|
|
||||||
|
return this.htmlTemplate.generateDocument(
|
||||||
|
headContent,
|
||||||
|
bodyContent + clientScripts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates server-side code for Express.js API routes.
|
||||||
|
* @returns {string} - Express.js server code
|
||||||
|
*/
|
||||||
|
generateServerCode() {
|
||||||
|
return this.serverGenerator.generateServerCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if there is any server code to generate.
|
||||||
|
* @returns {boolean} - Whether there is server code
|
||||||
|
*/
|
||||||
|
hasServerCode() {
|
||||||
|
return this.serverGenerator.hasServerCodeToGenerate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = HTMLGenerator;
|
module.exports = HTMLGenerator;
|
||||||
|
|
179
lib/StandardElementGenerator.js
Normal file
179
lib/StandardElementGenerator.js
Normal 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;
|
|
@ -102,6 +102,142 @@ class TokenParser {
|
||||||
continue;
|
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)) {
|
if (/[a-zA-Z]/.test(char)) {
|
||||||
let value = "";
|
let value = "";
|
||||||
const startColumn = column;
|
const startColumn = column;
|
||||||
|
|
|
@ -9,6 +9,7 @@ const options = {
|
||||||
minified: !args.includes("--readable"),
|
minified: !args.includes("--readable"),
|
||||||
srcDir: "./src",
|
srcDir: "./src",
|
||||||
outDir: "./dist",
|
outDir: "./dist",
|
||||||
|
debug: args.includes("--debug"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const server = new BlueprintServer(options);
|
const server = new BlueprintServer(options);
|
||||||
|
|
42
lib/file/BlueprintFileReader.js
Normal file
42
lib/file/BlueprintFileReader.js
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles reading Blueprint files from the file system.
|
||||||
|
*/
|
||||||
|
class BlueprintFileReader {
|
||||||
|
/**
|
||||||
|
* Creates a new BlueprintFileReader instance.
|
||||||
|
* @param {Object} [options] - Options object
|
||||||
|
* @param {boolean} [options.debug=false] - Enable debug logging
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = {
|
||||||
|
debug: false,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a Blueprint file from the file system.
|
||||||
|
* @param {string} inputPath - Path to the Blueprint file
|
||||||
|
* @returns {string} - The content of the file
|
||||||
|
* @throws {Error} - If the file does not exist or has an invalid extension
|
||||||
|
*/
|
||||||
|
readFile(inputPath) {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log(`[DEBUG] Reading Blueprint file: ${inputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inputPath.endsWith(".bp")) {
|
||||||
|
throw new Error("Input file must have .bp extension");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(inputPath)) {
|
||||||
|
throw new Error(`File not found: ${inputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.readFileSync(inputPath, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BlueprintFileReader;
|
78
lib/file/BlueprintFileWriter.js
Normal file
78
lib/file/BlueprintFileWriter.js
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles writing compiled Blueprint files to the file system.
|
||||||
|
*/
|
||||||
|
class BlueprintFileWriter {
|
||||||
|
/**
|
||||||
|
* Creates a new BlueprintFileWriter instance.
|
||||||
|
* @param {Object} [options] - Options object
|
||||||
|
* @param {boolean} [options.debug=false] - Enable debug logging
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = {
|
||||||
|
debug: false,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes HTML content to a file.
|
||||||
|
* @param {string} outputPath - Path to write the HTML file
|
||||||
|
* @param {string} content - HTML content to write
|
||||||
|
*/
|
||||||
|
writeHtmlFile(outputPath, content) {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log(`[DEBUG] Writing HTML file: ${outputPath}`);
|
||||||
|
}
|
||||||
|
this.ensureDirectoryExists(path.dirname(outputPath));
|
||||||
|
fs.writeFileSync(outputPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes CSS content to a file.
|
||||||
|
* @param {string} outputPath - Path to write the CSS file
|
||||||
|
* @param {string} content - CSS content to write
|
||||||
|
*/
|
||||||
|
writeCssFile(outputPath, content) {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log(`[DEBUG] Writing CSS file: ${outputPath}`);
|
||||||
|
}
|
||||||
|
this.ensureDirectoryExists(path.dirname(outputPath));
|
||||||
|
fs.writeFileSync(outputPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes both HTML and CSS files for a Blueprint compilation result.
|
||||||
|
* @param {string} outputDir - Directory to write the files to
|
||||||
|
* @param {string} baseName - Base name for the files (without extension)
|
||||||
|
* @param {string} html - HTML content to write
|
||||||
|
* @param {string} css - CSS content to write
|
||||||
|
*/
|
||||||
|
writeCompiledFiles(outputDir, baseName, html, css) {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log(`[DEBUG] Writing compiled files to: ${outputDir}/${baseName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ensureDirectoryExists(outputDir);
|
||||||
|
|
||||||
|
this.writeHtmlFile(path.join(outputDir, `${baseName}.html`), html);
|
||||||
|
this.writeCssFile(path.join(outputDir, `${baseName}.css`), css);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that a directory exists, creating it if it does not.
|
||||||
|
* @param {string} dir - Directory path
|
||||||
|
*/
|
||||||
|
ensureDirectoryExists(dir) {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log(`[DEBUG] Created directory: ${dir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BlueprintFileWriter;
|
97
lib/generators/ButtonElementGenerator.js
Normal file
97
lib/generators/ButtonElementGenerator.js
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
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 = "";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
105
lib/generators/InputElementGenerator.js
Normal file
105
lib/generators/InputElementGenerator.js
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
/**
|
||||||
|
* 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"';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
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}${idAttr}>`;
|
||||||
|
|
||||||
|
node.children.forEach((child) => {
|
||||||
|
child.parent = node;
|
||||||
|
html += this.parentGenerator.generateHTML(child);
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</label>`;
|
||||||
|
return html;
|
||||||
|
} else {
|
||||||
|
return `<input class="${className}"${attributes}${idAttr}>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = InputElementGenerator;
|
239
lib/generators/JavaScriptGenerator.js
Normal file
239
lib/generators/JavaScriptGenerator.js
Normal 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;
|
78
lib/generators/LinkElementGenerator.js
Normal file
78
lib/generators/LinkElementGenerator.js
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
const LinkProcessor = require("../utils/LinkProcessor");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates HTML for link elements.
|
||||||
|
*/
|
||||||
|
class LinkElementGenerator {
|
||||||
|
/**
|
||||||
|
* Creates a new link element generator.
|
||||||
|
* @param {Object} options - Options for the generator
|
||||||
|
* @param {CSSGenerator} cssGenerator - CSS generator instance
|
||||||
|
* @param {Object} parentGenerator - Parent HTML generator for recursion
|
||||||
|
*/
|
||||||
|
constructor(options, cssGenerator, parentGenerator) {
|
||||||
|
this.options = options;
|
||||||
|
this.cssGenerator = cssGenerator;
|
||||||
|
this.parentGenerator = parentGenerator;
|
||||||
|
this.linkProcessor = new LinkProcessor(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if this generator can handle the given node.
|
||||||
|
* @param {Object} node - The node to check
|
||||||
|
* @returns {boolean} - True if this generator can handle the node
|
||||||
|
*/
|
||||||
|
canHandle(node) {
|
||||||
|
return node.type === "element" && node.tag === "link";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates HTML for a link element.
|
||||||
|
* @param {Object} node - The node to generate HTML for
|
||||||
|
* @returns {string} - The generated HTML
|
||||||
|
*/
|
||||||
|
generate(node) {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log(`\n[LinkElementGenerator] Processing link`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
node.children.length === 1 &&
|
||||||
|
(node.children[0].tag === "button" || node.children[0].tag?.startsWith("button-"))
|
||||||
|
) {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log("[LinkElementGenerator] Processing button inside link - using button's HTML");
|
||||||
|
}
|
||||||
|
node.children[0].parent = node;
|
||||||
|
return this.parentGenerator.generateHTML(node.children[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const className = this.cssGenerator.generateClassName(node.tag);
|
||||||
|
const { cssProps, nestedRules } = this.cssGenerator.nodeToCSSProperties(node);
|
||||||
|
|
||||||
|
this.cssGenerator.cssRules.set(`.${className}`, {
|
||||||
|
cssProps,
|
||||||
|
nestedRules,
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkInfo = this.linkProcessor.processLink(node);
|
||||||
|
const attributes = this.linkProcessor.getLinkAttributes(linkInfo);
|
||||||
|
|
||||||
|
let html = `<a class="${className}"${attributes}>${
|
||||||
|
this.options.minified ? "" : "\n"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
node.children.forEach((child) => {
|
||||||
|
child.parent = node;
|
||||||
|
html += this.parentGenerator.generateHTML(child);
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `${this.options.minified ? "" : "\n"}</a>${
|
||||||
|
this.options.minified ? "" : "\n"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LinkElementGenerator;
|
121
lib/generators/MediaElementGenerator.js
Normal file
121
lib/generators/MediaElementGenerator.js
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
const BlueprintError = require("../BlueprintError");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates HTML for media elements (images and videos).
|
||||||
|
*/
|
||||||
|
class MediaElementGenerator {
|
||||||
|
/**
|
||||||
|
* Creates a new media element generator.
|
||||||
|
* @param {Object} options - Options for the generator
|
||||||
|
* @param {CSSGenerator} cssGenerator - CSS generator instance
|
||||||
|
* @param {Object} parentGenerator - Parent HTML generator for recursion
|
||||||
|
*/
|
||||||
|
constructor(options, cssGenerator, parentGenerator) {
|
||||||
|
this.options = options;
|
||||||
|
this.cssGenerator = cssGenerator;
|
||||||
|
this.parentGenerator = parentGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if this generator can handle the given node.
|
||||||
|
* @param {Object} node - The node to check
|
||||||
|
* @returns {boolean} - True if this generator can handle the node
|
||||||
|
*/
|
||||||
|
canHandle(node) {
|
||||||
|
return node.type === "element" && node.tag === "media";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates HTML for a media element.
|
||||||
|
* @param {Object} node - The node to generate HTML for
|
||||||
|
* @returns {string} - The generated HTML
|
||||||
|
*/
|
||||||
|
generate(node) {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log(`\n[MediaElementGenerator] Processing media element`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const className = this.cssGenerator.generateClassName(node.tag);
|
||||||
|
const { cssProps, nestedRules } = this.cssGenerator.nodeToCSSProperties(node);
|
||||||
|
|
||||||
|
this.cssGenerator.cssRules.set(`.${className}`, {
|
||||||
|
cssProps,
|
||||||
|
nestedRules,
|
||||||
|
});
|
||||||
|
|
||||||
|
const srcProp = node.props.find((p) => p.startsWith("src:"));
|
||||||
|
const typeProp = node.props.find((p) => p.startsWith("type:"));
|
||||||
|
|
||||||
|
if (!srcProp) {
|
||||||
|
throw new BlueprintError("Media element requires src property", node.line, node.column);
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = srcProp.substring(srcProp.indexOf(":") + 1).trim();
|
||||||
|
const type = typeProp ? typeProp.substring(typeProp.indexOf(":") + 1).trim() : "img";
|
||||||
|
|
||||||
|
let tag, attributes;
|
||||||
|
|
||||||
|
if (type === "video") {
|
||||||
|
tag = "video";
|
||||||
|
attributes = ` src="${src}" controls`;
|
||||||
|
|
||||||
|
const autoProp = node.props.find((p) => p === "autoplay");
|
||||||
|
if (autoProp) {
|
||||||
|
attributes += ` autoplay`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loopProp = node.props.find((p) => p === "loop");
|
||||||
|
if (loopProp) {
|
||||||
|
attributes += ` loop`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutedProp = node.props.find((p) => p === "muted");
|
||||||
|
if (mutedProp) {
|
||||||
|
attributes += ` muted`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tag = "img";
|
||||||
|
const altText = node.children.length > 0
|
||||||
|
? node.children.map(child =>
|
||||||
|
this.parentGenerator.generateHTML(child)).join("")
|
||||||
|
: src.split('/').pop();
|
||||||
|
|
||||||
|
attributes = ` src="${src}" alt="${altText}"`;
|
||||||
|
|
||||||
|
const loadingProp = node.props.find((p) => p.startsWith("loading:"));
|
||||||
|
if (loadingProp) {
|
||||||
|
const loadingValue = loadingProp.substring(loadingProp.indexOf(":") + 1).trim();
|
||||||
|
if (["lazy", "eager"].includes(loadingValue)) {
|
||||||
|
attributes += ` loading="${loadingValue}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.props.find((p) => typeof p === "string" && p.startsWith("data-"))) {
|
||||||
|
const dataProps = node.props.filter(
|
||||||
|
(p) => typeof p === "string" && p.startsWith("data-")
|
||||||
|
);
|
||||||
|
attributes += " " + dataProps.map((p) => `${p}`).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === "img") {
|
||||||
|
return `<${tag} class="${className}"${attributes}>`;
|
||||||
|
} else {
|
||||||
|
let html = `<${tag} class="${className}"${attributes}>${
|
||||||
|
this.options.minified ? "" : "\n"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
if (node.children.length > 0 && tag === "video") {
|
||||||
|
html += `<p>Your browser doesn't support video playback.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `${this.options.minified ? "" : "\n"}</${tag}>${
|
||||||
|
this.options.minified ? "" : "\n"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MediaElementGenerator;
|
47
lib/generators/RootNodeGenerator.js
Normal file
47
lib/generators/RootNodeGenerator.js
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
/**
|
||||||
|
* Generates HTML for the root node of the AST.
|
||||||
|
*/
|
||||||
|
class RootNodeGenerator {
|
||||||
|
/**
|
||||||
|
* Creates a new root node generator.
|
||||||
|
* @param {Object} options - Options for the generator
|
||||||
|
* @param {Object} parentGenerator - Parent HTML generator for recursion
|
||||||
|
*/
|
||||||
|
constructor(options, parentGenerator) {
|
||||||
|
this.options = options;
|
||||||
|
this.parentGenerator = parentGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if this generator can handle the given node.
|
||||||
|
* @param {Object} node - The node to check
|
||||||
|
* @returns {boolean} - True if this generator can handle the node
|
||||||
|
*/
|
||||||
|
canHandle(node) {
|
||||||
|
return node.type === "root";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates HTML for the root node.
|
||||||
|
* @param {Object} node - The node to generate HTML for
|
||||||
|
* @returns {string} - The generated HTML
|
||||||
|
*/
|
||||||
|
generate(node) {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log(`\n[RootNodeGenerator] Processing root node with ${node.children.length} children`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = "";
|
||||||
|
|
||||||
|
node.children.forEach((child, index) => {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log(`[RootNodeGenerator] Processing child ${index + 1}/${node.children.length}`);
|
||||||
|
}
|
||||||
|
html += this.parentGenerator.generateHTML(child);
|
||||||
|
});
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = RootNodeGenerator;
|
236
lib/generators/ServerCodeGenerator.js
Normal file
236
lib/generators/ServerCodeGenerator.js
Normal 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;
|
97
lib/generators/StandardElementGenerator.js
Normal file
97
lib/generators/StandardElementGenerator.js
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
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 = "";
|
||||||
|
|
||||||
|
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-")
|
||||||
|
);
|
||||||
|
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;
|
51
lib/generators/TextNodeGenerator.js
Normal file
51
lib/generators/TextNodeGenerator.js
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
const StringUtils = require("../utils/StringUtils");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates HTML for text nodes.
|
||||||
|
*/
|
||||||
|
class TextNodeGenerator {
|
||||||
|
/**
|
||||||
|
* Creates a new text node generator.
|
||||||
|
* @param {Object} options - Options for the generator
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if this generator can handle the given node.
|
||||||
|
* @param {Object} node - The node to check
|
||||||
|
* @returns {boolean} - True if this generator can handle the node
|
||||||
|
*/
|
||||||
|
canHandle(node) {
|
||||||
|
return node.type === "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates HTML for a text node.
|
||||||
|
* @param {Object} node - The node to generate HTML for
|
||||||
|
* @returns {string} - The generated HTML
|
||||||
|
*/
|
||||||
|
generate(node) {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log(`\n[TextNodeGenerator] Processing text node`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.parent?.tag === "codeblock") {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log("[TextNodeGenerator] Raw text content for codeblock");
|
||||||
|
}
|
||||||
|
return node.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapedText = StringUtils.escapeHTML(node.value);
|
||||||
|
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log("[TextNodeGenerator] Generated escaped text");
|
||||||
|
}
|
||||||
|
|
||||||
|
return escapedText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = TextNodeGenerator;
|
|
@ -538,7 +538,7 @@ const ELEMENT_MAPPINGS = {
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
tag: "div",
|
tag: "div",
|
||||||
defaultProps: ["raised", "card"],
|
defaultProps: ["card"],
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
tag: "div",
|
tag: "div",
|
||||||
|
|
279
lib/server.js
279
lib/server.js
|
@ -11,6 +11,7 @@ class BlueprintServer {
|
||||||
this.wsInstance = expressWs(this.app);
|
this.wsInstance = expressWs(this.app);
|
||||||
this.options = {
|
this.options = {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
apiPort: 3001,
|
||||||
srcDir: "./src",
|
srcDir: "./src",
|
||||||
outDir: "./dist",
|
outDir: "./dist",
|
||||||
liveReload: false,
|
liveReload: false,
|
||||||
|
@ -19,6 +20,8 @@ class BlueprintServer {
|
||||||
};
|
};
|
||||||
this.clients = new Map();
|
this.clients = new Map();
|
||||||
this.filesWithErrors = new Set();
|
this.filesWithErrors = new Set();
|
||||||
|
this.apiServers = new Map();
|
||||||
|
this.apiPorts = new Map();
|
||||||
this.setupServer();
|
this.setupServer();
|
||||||
if (this.options.liveReload) {
|
if (this.options.liveReload) {
|
||||||
const watcher = chokidar.watch([], {
|
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() {
|
async buildAll() {
|
||||||
this.log("INFO", "Building all Blueprint files...", "lightGray");
|
this.log("INFO", "Building all Blueprint files...", "lightGray");
|
||||||
if (fs.existsSync(this.options.outDir)) {
|
if (fs.existsSync(this.options.outDir)) {
|
||||||
|
@ -70,8 +94,17 @@ class BlueprintServer {
|
||||||
);
|
);
|
||||||
this.ensureDirectoryExistence(outputPath);
|
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));
|
const result = builder.build(file, path.dirname(outputPath));
|
||||||
|
|
||||||
|
if (result.success && result.hasServerCode) {
|
||||||
|
const fileName = relativePath.replace(/\.bp$/, "");
|
||||||
|
this.startApiServer(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
success = false;
|
success = false;
|
||||||
errors.push({ file, errors: result.errors });
|
errors.push({ file, errors: result.errors });
|
||||||
|
@ -138,8 +171,19 @@ class BlueprintServer {
|
||||||
fs.readFile(htmlPath, "utf8", (err, data) => {
|
fs.readFile(htmlPath, "utf8", (err, data) => {
|
||||||
if (err) return next();
|
if (err) return next();
|
||||||
let html = data;
|
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 = `
|
const script = `
|
||||||
<script>
|
<script>
|
||||||
|
window.blueprintServerPort = ${apiPort};
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
let currentPage = window.location.pathname.replace(/^\\//, '') || 'index.html';
|
let currentPage = window.location.pathname.replace(/^\\//, '') || 'index.html';
|
||||||
console.log('Current page:', currentPage);
|
console.log('Current page:', currentPage);
|
||||||
|
@ -240,7 +284,33 @@ class BlueprintServer {
|
||||||
|
|
||||||
const htmlPath = path.join(this.options.outDir, req.path + ".html");
|
const htmlPath = path.join(this.options.outDir, req.path + ".html");
|
||||||
if (fs.existsSync(htmlPath)) {
|
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 === "/") {
|
} else if (req.path === "/") {
|
||||||
const pages = fs
|
const pages = fs
|
||||||
.readdirSync(this.options.outDir)
|
.readdirSync(this.options.outDir)
|
||||||
|
@ -311,83 +381,35 @@ class BlueprintServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
setupWatcher(watcher) {
|
setupWatcher(watcher) {
|
||||||
watcher.on("change", async (filepath) => {
|
watcher.on("change", async (filePath) => {
|
||||||
if (filepath.endsWith(".bp")) {
|
if (!filePath.endsWith(".bp")) return;
|
||||||
this.log("INFO", `File ${filepath} has been changed`, "blue");
|
this.log("INFO", `File changed: ${filePath}`, "blue");
|
||||||
try {
|
|
||||||
const builder = new BlueprintBuilder({
|
const result = await this.buildFile(filePath);
|
||||||
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) {
|
if (result.success) {
|
||||||
this.log("SUCCESS", "Rebuilt successfully", "green");
|
this.log("SUCCESS", `Rebuilt ${filePath}`, "green");
|
||||||
|
this.filesWithErrors.delete(filePath);
|
||||||
|
|
||||||
this.filesWithErrors.delete(filepath);
|
if (result.hasServerCode) {
|
||||||
|
const relativePath = path.relative(this.options.srcDir, filePath);
|
||||||
const htmlFile = relativePath.replace(/\.bp$/, ".html");
|
const fileName = relativePath.replace(/\.bp$/, "");
|
||||||
const htmlPath = path.join(this.options.outDir, htmlFile);
|
this.startApiServer(fileName);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
if (this.options.liveReload) {
|
||||||
} catch (error) {
|
this.notifyClients(filePath);
|
||||||
this.log("ERROR", "Error reading new content:", "red");
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.filesWithErrors.add(filepath);
|
this.log("ERROR", `Build failed for ${filePath}`, "red");
|
||||||
this.log("ERROR", `Build failed: ${result.errors.map(e => e.message).join(", ")}`, "red");
|
result.errors.forEach((err) => {
|
||||||
this.log("INFO", "Waiting for next file change...", "orange");
|
this.log(
|
||||||
|
"ERROR",
|
||||||
for (const [client, page] of this.clients.entries()) {
|
`${err.type} at line ${err.line}, column ${err.column}: ${err.message}`,
|
||||||
const htmlFile = relativePath.replace(/\.bp$/, ".html");
|
"red"
|
||||||
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.filesWithErrors.add(filePath);
|
||||||
this.clients.delete(client);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.log("ERROR", "Unexpected error during build:", "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;
|
module.exports = BlueprintServer;
|
||||||
|
|
61
lib/templates/HTMLTemplate.js
Normal file
61
lib/templates/HTMLTemplate.js
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* HTMLTemplate provides templates for generating the final HTML document.
|
||||||
|
*/
|
||||||
|
class HTMLTemplate {
|
||||||
|
/**
|
||||||
|
* Creates a new HTML template instance.
|
||||||
|
* @param {Object} [options] - Options object
|
||||||
|
* @param {boolean} [options.minified=true] - Whether to minify the output
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = {
|
||||||
|
minified: true,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the final HTML document using the provided head and body content.
|
||||||
|
* @param {string} headContent - HTML content for the <head> section
|
||||||
|
* @param {string} bodyContent - HTML content for the <body> section
|
||||||
|
* @returns {string} - Complete HTML document
|
||||||
|
*/
|
||||||
|
generateDocument(headContent, bodyContent) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
${headContent}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--navbar-height: 4rem;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
padding-top: var(--navbar-height);
|
||||||
|
background-color: #0d1117;
|
||||||
|
color: #e6edf3;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
::selection {
|
||||||
|
background-color: rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${bodyContent}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = HTMLTemplate;
|
88
lib/utils/LinkProcessor.js
Normal file
88
lib/utils/LinkProcessor.js
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
/**
|
||||||
|
* LinkProcessor provides utilities for processing link elements.
|
||||||
|
*/
|
||||||
|
class LinkProcessor {
|
||||||
|
/**
|
||||||
|
* Creates a new link processor instance.
|
||||||
|
* @param {Object} [options] - Options object
|
||||||
|
* @param {boolean} [options.debug=false] - Enable debug logging
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = {
|
||||||
|
debug: false,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a link node and extracts the href attribute.
|
||||||
|
* Converts to an internal link if it doesn't start with http:// or https://.
|
||||||
|
*
|
||||||
|
* @param {Object} node - The link node to process
|
||||||
|
* @returns {Object} - An object containing link properties
|
||||||
|
*/
|
||||||
|
processLink(node) {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log("\n[LinkProcessor] Processing link node");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hrefProp = node.props.find((p) => p.startsWith("href:"));
|
||||||
|
let href = "#";
|
||||||
|
|
||||||
|
if (hrefProp) {
|
||||||
|
let hrefTarget = hrefProp
|
||||||
|
.substring(hrefProp.indexOf(":") + 1)
|
||||||
|
.trim()
|
||||||
|
.replace(/^"|"$/g, "");
|
||||||
|
|
||||||
|
if (!hrefTarget.startsWith("http://") && !hrefTarget.startsWith("https://")) {
|
||||||
|
hrefTarget = "/" + hrefTarget;
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log(`[LinkProcessor] Converted to internal link: "${hrefTarget}"`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log(`[LinkProcessor] External link detected: "${hrefTarget}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
href = hrefTarget;
|
||||||
|
} else {
|
||||||
|
if (this.options.debug) {
|
||||||
|
console.log("[LinkProcessor] No href property found, using default: '#'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
href,
|
||||||
|
isExternal: href.startsWith("http://") || href.startsWith("https://")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets appropriate HTML attributes for a link based on its type
|
||||||
|
* @param {Object} linkInfo - Link information object
|
||||||
|
* @returns {string} - HTML attributes for the link
|
||||||
|
*/
|
||||||
|
getLinkAttributes(linkInfo) {
|
||||||
|
if (linkInfo.isExternal) {
|
||||||
|
return ` href="${linkInfo.href}" target="_blank" rel="noopener noreferrer"`;
|
||||||
|
} else {
|
||||||
|
return ` href="${linkInfo.href}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the click handler for a button inside a link
|
||||||
|
* @param {Object} linkInfo - Link information object
|
||||||
|
* @returns {string} - onclick attribute value
|
||||||
|
*/
|
||||||
|
getButtonClickHandler(linkInfo) {
|
||||||
|
if (linkInfo.isExternal) {
|
||||||
|
return ` onclick="window.open('${linkInfo.href}', '_blank', 'noopener,noreferrer')"`;
|
||||||
|
} else {
|
||||||
|
return ` onclick="window.location.href='${linkInfo.href}'"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LinkProcessor;
|
74
lib/utils/StringUtils.js
Normal file
74
lib/utils/StringUtils.js
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
/**
|
||||||
|
* 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}]`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
generateRandomId
|
||||||
|
};
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "blueprint",
|
"name": "blueprint",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "A modern UI component compiler with live reload support",
|
"description": "A modern UI component compiler with live reload support",
|
||||||
"main": "lib/BlueprintBuilder.js",
|
"main": "lib/BlueprintBuilder.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-ws": "^5.0.2",
|
"express-ws": "^5.0.2",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue