This commit is contained in:
obvTiger 2025-01-21 14:00:09 +01:00
commit 47f67eea8c
43 changed files with 5819 additions and 0 deletions

343
lib/ASTBuilder.js Normal file
View file

@ -0,0 +1,343 @@
const BlueprintError = require("./BlueprintError");
const { ELEMENT_MAPPINGS } = require("./mappings");
class ASTBuilder {
/**
* Initializes a new instance of the ASTBuilder class.
*
* @param {Object} options - Configuration options for the ASTBuilder.
* @param {boolean} [options.debug=false] - If true, enables debug logging for the builder.
*/
constructor(options = {}) {
this.options = options;
if (this.options.debug) {
console.log(
"[ASTBuilder] Initialized with options:",
JSON.stringify(options, null, 2)
);
}
}
/**
* Converts a node object into a JSON string representation.
* Handles circular references by replacing them with a predefined string.
*
* @param {Object} node - The node object to stringify.
* @returns {string} - The JSON string representation of the node.
* If unable to stringify, returns an error message.
*/
debugStringify(node) {
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(node, getCircularReplacer(), 2);
} catch (err) {
return `[Unable to stringify: ${err.message}]`;
}
}
/**
* Constructs an Abstract Syntax Tree (AST) from a sequence of tokens.
*
* This function iterates over the provided tokens to build a hierarchical
* AST structure. It identifies elements, their properties, and any nested
* child elements, converting them into structured nodes. Each node is
* represented as an object containing type, tag, properties, children,
* and position information (line and column).
*
* Throws an error if unexpected tokens are encountered or if there are
* mismatched braces.
*
* @param {Array} tokens - The list of tokens to be parsed into an AST.
* @returns {Object} - The constructed AST root node with its children.
* Each child represents either an element or text node.
* @throws {BlueprintError} - If unexpected tokens or structural issues are found.
*/
buildAST(tokens) {
if (this.options.debug) {
console.log("\n[ASTBuilder] Starting AST construction");
console.log(`[ASTBuilder] Processing ${tokens.length} tokens`);
console.log(
"[ASTBuilder] First few tokens:",
tokens
.slice(0, 3)
.map((t) => this.debugStringify(t))
.join(", ")
);
}
let current = 0;
/**
* Walks the token list to construct a hierarchical AST structure.
*
* This function is responsible for processing each token and constructing
* the corresponding node in the AST. It handles elements, their properties,
* and any nested child elements, converting them into structured nodes.
* Each node is represented as an object containing type, tag, properties,
* children, and position information (line and column).
*
* Throws an error if unexpected tokens are encountered or if there are
* mismatched braces.
*
* @returns {Object} - The constructed AST node with its children.
* Each child represents either an element or text node.
* @throws {BlueprintError} - If unexpected tokens or structural issues are found.
*/
const walk = () => {
if (this.options.debug) {
console.log(
`\n[ASTBuilder] Walking tokens at position ${current}/${tokens.length}`
);
console.log(
"[ASTBuilder] Current token:",
this.debugStringify(tokens[current])
);
}
let token = tokens[current];
if (!token) {
if (this.options.debug) {
console.log(
"[ASTBuilder] Unexpected end of input while walking tokens"
);
console.log(
"[ASTBuilder] Last processed token:",
this.debugStringify(tokens[current - 1])
);
}
throw new BlueprintError(
"Unexpected end of input",
tokens[tokens.length - 1]?.line || 1,
tokens[tokens.length - 1]?.column || 0
);
}
if (token.type === "identifier") {
if (this.options.debug) {
console.log(
`\n[ASTBuilder] Processing identifier: "${token.value}" at line ${token.line}, column ${token.column}`
);
}
const elementType = token.value;
if (!ELEMENT_MAPPINGS[elementType]) {
if (this.options.debug) {
console.log(
`[ASTBuilder] Error: Unknown element type "${elementType}"`
);
console.log(
"[ASTBuilder] Available element types:",
Object.keys(ELEMENT_MAPPINGS).join(", ")
);
}
throw new BlueprintError(
`Unknown element type: ${elementType}`,
token.line,
token.column
);
}
const mapping = ELEMENT_MAPPINGS[elementType];
const node = {
type: "element",
tag: elementType,
props:
elementType === "page" ? [] : [...(mapping.defaultProps || [])],
children: [],
line: token.line,
column: token.column,
};
if (this.options.debug) {
console.log(`[ASTBuilder] Created node for element "${elementType}"`);
console.log(
"[ASTBuilder] Initial node state:",
this.debugStringify(node)
);
}
current++;
if (
current < tokens.length &&
tokens[current].type === "props"
) {
const props = tokens[current].value.split(",").map((p) => p.trim());
if (this.options.debug) {
console.log(
`[ASTBuilder] Processing ${props.length} properties for "${elementType}"`
);
console.log("[ASTBuilder] Properties:", props);
}
props.forEach((prop) => {
const [name, ...valueParts] = prop.split(":");
const value = valueParts.join(":").trim();
if (this.options.debug) {
console.log(
`[ASTBuilder] Processing property - name: "${name}", value: "${value}"`
);
}
if (value) {
if (elementType === "page") {
const processedProp = {
name,
value: value.replace(/^"|"$/g, ""),
};
node.props.push(processedProp);
if (this.options.debug) {
console.log(
`[ASTBuilder] Added page property:`,
processedProp
);
}
} else {
node.props.push(`${name}:${value}`);
if (this.options.debug) {
console.log(
`[ASTBuilder] Added property: "${name}:${value}"`
);
}
}
} else {
node.props.push(name);
if (this.options.debug) {
console.log(`[ASTBuilder] Added flag property: "${name}"`);
}
}
});
current++;
}
if (
current < tokens.length &&
tokens[current].type === "brace" &&
tokens[current].value === "{"
) {
if (this.options.debug) {
console.log(
`\n[ASTBuilder] Processing child elements for "${elementType}"`
);
}
current++;
while (
current < tokens.length &&
!(tokens[current].type === "brace" && tokens[current].value === "}")
) {
if (this.options.debug) {
console.log(
`[ASTBuilder] Processing child at position ${current}`
);
}
const child = walk();
child.parent = node;
node.children.push(child);
if (this.options.debug) {
console.log(
`[ASTBuilder] Added child to "${elementType}":`,
this.debugStringify(child)
);
}
}
if (current >= tokens.length) {
if (this.options.debug) {
console.log(
`[ASTBuilder] Error: Missing closing brace for "${elementType}"`
);
}
throw new BlueprintError(
"Missing closing brace",
node.line,
node.column
);
}
current++;
}
if (this.options.debug) {
console.log(`[ASTBuilder] Completed node for "${elementType}"`);
console.log(
"[ASTBuilder] Final node state:",
this.debugStringify(node)
);
}
return node;
}
if (token.type === "text") {
if (this.options.debug) {
console.log(
`[ASTBuilder] Processing text node at line ${token.line}, column ${token.column}`
);
console.log(`[ASTBuilder] Text content: "${token.value}"`);
}
current++;
return {
type: "text",
value: token.value,
line: token.line,
column: token.column,
};
}
if (this.options.debug) {
console.log(`[ASTBuilder] Error: Unexpected token type: ${token.type}`);
console.log("[ASTBuilder] Token details:", this.debugStringify(token));
}
throw new BlueprintError(
`Unexpected token type: ${token.type}`,
token.line,
token.column
);
};
const ast = {
type: "root",
children: [],
};
while (current < tokens.length) {
if (this.options.debug) {
console.log(
`\n[ASTBuilder] Processing root-level token at position ${current}`
);
}
ast.children.push(walk());
}
if (this.options.debug) {
console.log("\n[ASTBuilder] AST construction complete");
console.log(`[ASTBuilder] Total nodes: ${ast.children.length}`);
console.log(
"[ASTBuilder] Root children types:",
ast.children.map((c) => c.type).join(", ")
);
}
return ast;
}
}
module.exports = ASTBuilder;

144
lib/BlueprintBuilder.js Normal file
View file

@ -0,0 +1,144 @@
const fs = require("fs");
const path = require("path");
const TokenParser = require("./TokenParser");
const ASTBuilder = require("./ASTBuilder");
const CSSGenerator = require("./CSSGenerator");
const HTMLGenerator = require("./HTMLGenerator");
const MetadataManager = require("./MetadataManager");
class BlueprintBuilder {
/**
* Create a new Blueprint builder 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);
}
/**
* Builds a Blueprint file.
* @param {string} inputPath - Path to the Blueprint file to build
* @param {string} outputDir - Directory to write the generated HTML and CSS files
* @returns {Object} - Build result object with `success` and `errors` properties
*/
build(inputPath, outputDir) {
if (this.options.debug) {
console.log(`[DEBUG] Starting build for ${inputPath}`);
}
try {
if (!inputPath.endsWith(".bp")) {
throw new Error("Input file must have .bp extension");
}
const input = fs.readFileSync(inputPath, "utf8");
const tokens = this.tokenParser.tokenize(input);
const ast = this.astBuilder.buildAST(tokens);
const pageNode = ast.children.find((node) => node.tag === "page");
if (pageNode) {
this.metadataManager.processPageMetadata(pageNode);
}
const html = this.htmlGenerator.generateHTML(ast);
const css = this.cssGenerator.generateCSS();
const baseName = path.basename(inputPath, ".bp");
const headContent = this.metadataManager.generateHeadContent(baseName);
const finalHtml = this.generateFinalHtml(headContent, html);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(path.join(outputDir, `${baseName}.html`), finalHtml);
fs.writeFileSync(path.join(outputDir, `${baseName}.css`), css);
if (this.options.debug) {
console.log("[DEBUG] Build completed successfully");
}
return {
success: true,
errors: [],
};
} catch (error) {
if (this.options.debug) {
console.log("[DEBUG] Build failed with error:", error);
}
return {
success: false,
errors: [
{
message: error.message,
type: error.name,
line: error.line,
column: error.column,
},
],
};
}
}
/**
* 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;

10
lib/BlueprintError.js Normal file
View file

@ -0,0 +1,10 @@
class BlueprintError extends Error {
constructor(message, line = null, column = null) {
super(message);
this.name = "BlueprintError";
this.line = line;
this.column = column;
}
}
module.exports = BlueprintError;

402
lib/CSSGenerator.js Normal file
View file

@ -0,0 +1,402 @@
const { STYLE_MAPPINGS } = require("./mappings");
class CSSGenerator {
/**
* Creates a new CSS generator instance.
* @param {Object} [options] - Options object
* @param {boolean} [options.minified=true] - Minify generated class names
* @param {boolean} [options.debug=false] - Enable debug logging
*/
constructor(options = {}) {
this.options = options;
this.cssRules = new Map();
this.classCounter = 0;
if (this.options.debug) {
console.log(
"[CSSGenerator] Initialized with options:",
JSON.stringify(options, null, 2)
);
}
}
/**
* Generates a class name for the given element type, based on the counter
* and the minified option. If minified is true, the class name will be a
* single lowercase letter (a-z), or a single uppercase letter (A-Z) if
* the counter is between 26 and 51. Otherwise, it will be a complex
* class name (e.g. "zabcdefg") with a counter starting from 52.
*
* @param {string} elementType - The type of the element for which to
* generate a class name.
* @return {string} The generated class name.
*/
generateClassName(elementType) {
if (this.options.debug) {
console.log(
`\n[CSSGenerator] Generating class name for element type: "${elementType}"`
);
console.log(`[CSSGenerator] Current class counter: ${this.classCounter}`);
}
let className;
if (!this.options.minified) {
className = `blueprint-${elementType}-${this.classCounter++}`;
if (this.options.debug) {
console.log(
`[CSSGenerator] Generated readable class name: "${className}"`
);
}
return className;
}
if (this.classCounter < 26) {
className = String.fromCharCode(97 + this.classCounter++);
if (this.options.debug) {
console.log(
`[CSSGenerator] Generated lowercase class name: "${className}" (counter: ${
this.classCounter - 1
})`
);
}
return className;
}
if (this.classCounter < 52) {
className = String.fromCharCode(65 + (this.classCounter++ - 26));
if (this.options.debug) {
console.log(
`[CSSGenerator] Generated uppercase class name: "${className}" (counter: ${
this.classCounter - 1
})`
);
}
return className;
}
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const base = chars.length;
let num = this.classCounter++ - 52;
let result = "";
do {
result = chars[num % base] + result;
num = Math.floor(num / base);
} while (num > 0);
result = "z" + result;
if (this.options.debug) {
console.log(
`[CSSGenerator] Generated complex class name: "${result}" (counter: ${
this.classCounter - 1
})`
);
}
return result;
}
/**
* Converts a node to CSS properties, using the style mappings to process
* the node's properties. The generated CSS properties are returned as a
* Map, where each key is a CSS property name and each value is the value
* for that property.
*
* @param {Object} node - The node to convert
* @return {Object} - The generated CSS properties and nested rules
*/
nodeToCSSProperties(node) {
if (this.options.debug) {
console.log(`\n[CSSGenerator] Converting node to CSS properties`);
console.log(`[CSSGenerator] Node tag: "${node.tag}"`);
console.log("[CSSGenerator] Node properties:", node.props);
}
const cssProps = new Map();
const nestedRules = new Map();
node.props.forEach((prop) => {
if (typeof prop === "object") {
if (this.options.debug) {
console.log(`[CSSGenerator] Skipping object property:`, prop);
}
return;
}
const [name, value] = prop.split(/[-:]/);
if (this.options.debug) {
console.log(
`\n[CSSGenerator] Processing property - name: "${name}", value: "${value}"`
);
}
// This is for customization of css properties
if (name === "width" && !isNaN(value)) {
cssProps.set("width", `${value}% !important`);
cssProps.set("max-width", "none !important");
if (this.options.debug) {
console.log(
`[CSSGenerator] Set width: ${value}% !important and max-width: none !important`
);
}
return;
}
if (name === "height" && !isNaN(value)) {
cssProps.set("height", `${value}% !important`);
cssProps.set("max-height", "none !important");
if (this.options.debug) {
console.log(
`[CSSGenerator] Set height: ${value}% !important and max-height: none !important`
);
}
return;
}
if (name === "padding" && !isNaN(value)) {
cssProps.set("padding", `${value}px !important`);
if (this.options.debug) {
console.log(`[CSSGenerator] Set padding: ${value}px !important`);
}
return;
}
if (name === "margin" && !isNaN(value)) {
cssProps.set("margin", `${value}px !important`);
if (this.options.debug) {
console.log(`[CSSGenerator] Set margin: ${value}px !important`);
}
return;
}
if (name === "marginTop" && !isNaN(value)) {
cssProps.set("margin-top", `${value}px !important`);
if (this.options.debug) {
console.log(`[CSSGenerator] Set margin-top: ${value}px !important`);
}
return;
}
if (name === "marginBottom" && !isNaN(value)) {
cssProps.set("margin-bottom", `${value}px !important`);
if (this.options.debug) {
console.log(`[CSSGenerator] Set margin-bottom: ${value}px !important`);
}
return;
}
if (name === "marginLeft" && !isNaN(value)) {
cssProps.set("margin-left", `${value}px !important`);
if (this.options.debug) {
console.log(`[CSSGenerator] Set margin-left: ${value}px !important`);
}
return;
}
if (name === "marginRight" && !isNaN(value)) {
cssProps.set("margin-right", `${value}px !important`);
if (this.options.debug) {
console.log(`[CSSGenerator] Set margin-right: ${value}px !important`);
}
return;
}
if (name === "color") {
cssProps.set("color", `${value} !important`);
if (this.options.debug) {
console.log(`[CSSGenerator] Set color: ${value} !important`);
}
return;
}
if (name === "backgroundColor") {
cssProps.set("background-color", `${value} !important`);
if (this.options.debug) {
console.log(`[CSSGenerator] Set background-color: ${value} !important`);
}
return;
}
const style = STYLE_MAPPINGS[name];
if (style) {
if (this.options.debug) {
console.log(`[CSSGenerator] Processing style mapping for: "${name}"`);
}
Object.entries(style).forEach(([key, baseValue]) => {
if (typeof baseValue === "object") {
if (key.startsWith(":") || key.startsWith(">")) {
nestedRules.set(key, baseValue);
if (this.options.debug) {
console.log(
`[CSSGenerator] Added nested rule: "${key}" =>`,
baseValue
);
}
} else {
let finalValue = baseValue;
if (value && key === "gridTemplateColumns" && !isNaN(value)) {
finalValue = `repeat(${value}, 1fr)`;
if (this.options.debug) {
console.log(
`[CSSGenerator] Set grid template columns: ${finalValue}`
);
}
}
cssProps.set(key, finalValue);
if (this.options.debug) {
console.log(
`[CSSGenerator] Set CSS property: "${key}" = "${finalValue}"`
);
}
}
} else {
let finalValue = baseValue;
if (value && key === "gridTemplateColumns" && !isNaN(value)) {
finalValue = `repeat(${value}, 1fr)`;
if (this.options.debug) {
console.log(
`[CSSGenerator] Set grid template columns: ${finalValue}`
);
}
}
cssProps.set(key, finalValue);
if (this.options.debug) {
console.log(
`[CSSGenerator] Set CSS property: "${key}" = "${finalValue}"`
);
}
}
});
}
});
if (this.options.debug) {
console.log("\n[CSSGenerator] CSS properties generation complete");
console.log(`[CSSGenerator] Generated ${cssProps.size} CSS properties`);
console.log(`[CSSGenerator] Generated ${nestedRules.size} nested rules`);
}
return { cssProps, nestedRules };
}
/**
* Generates the CSS code for the given style mappings. If minified is true,
* the generated CSS will be minified. Otherwise, it will be formatted with
* indentation and newlines.
*
* @return {string} The generated CSS code
*/
generateCSS() {
if (this.options.debug) {
console.log("\n[CSSGenerator] Starting CSS generation");
console.log(`[CSSGenerator] Processing ${this.cssRules.size} rule sets`);
}
/**
* Converts a camelCase string to kebab-case (lowercase with hyphens
* separating words)
*
* @param {string} str The string to convert
* @return {string} The converted string
*/
const toKebabCase = (str) =>
str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
let css = "";
this.cssRules.forEach((props, selector) => {
if (props.cssProps.size > 0) {
if (this.options.debug) {
console.log(
`\n[CSSGenerator] Generating CSS for selector: "${selector}"`
);
console.log(
`[CSSGenerator] Properties count: ${props.cssProps.size}`
);
}
css += `${selector} {${this.options.minified ? "" : "\n"}`;
props.cssProps.forEach((value, prop) => {
const cssProperty = toKebabCase(prop);
css += `${
this.options.minified ? "" : " "
}${cssProperty}: ${value};${this.options.minified ? "" : "\n"}`;
if (this.options.debug) {
console.log(
`[CSSGenerator] Added property: ${cssProperty}: ${value}`
);
}
});
css += `}${this.options.minified ? "" : "\n"}`;
}
if (props.nestedRules.size > 0) {
if (this.options.debug) {
console.log(
`\n[CSSGenerator] Processing ${props.nestedRules.size} nested rules for "${selector}"`
);
}
props.nestedRules.forEach((rules, nestedSelector) => {
const fullSelector = nestedSelector.startsWith(">")
? `${selector} ${nestedSelector}`
: `${selector}${nestedSelector}`;
if (this.options.debug) {
console.log(
`[CSSGenerator] Generating nested selector: "${fullSelector}"`
);
}
css += `${fullSelector} {${this.options.minified ? "" : "\n"}`;
Object.entries(rules).forEach(([prop, value]) => {
if (typeof value === "object") {
const pseudoSelector = `${fullSelector}${prop}`;
if (this.options.debug) {
console.log(
`[CSSGenerator] Generating pseudo-selector: "${pseudoSelector}"`
);
}
css += `}${this.options.minified ? "" : "\n"}${pseudoSelector} {${
this.options.minified ? "" : "\n"
}`;
Object.entries(value).forEach(([nestedProp, nestedValue]) => {
const cssProperty = toKebabCase(nestedProp);
css += `${
this.options.minified ? "" : " "
}${cssProperty}: ${nestedValue};${
this.options.minified ? "" : "\n"
}`;
if (this.options.debug) {
console.log(
`[CSSGenerator] Added nested property: ${cssProperty}: ${nestedValue}`
);
}
});
} else {
const cssProperty = toKebabCase(prop);
css += `${
this.options.minified ? "" : " "
}${cssProperty}: ${value};${this.options.minified ? "" : "\n"}`;
if (this.options.debug) {
console.log(
`[CSSGenerator] Added property: ${cssProperty}: ${value}`
);
}
}
});
css += `}${this.options.minified ? "" : "\n"}`;
});
}
});
if (this.options.debug) {
console.log("\n[CSSGenerator] CSS generation complete");
console.log(
`[CSSGenerator] Generated ${css.split("\n").length} lines of CSS`
);
}
return css;
}
}
module.exports = CSSGenerator;

353
lib/HTMLGenerator.js Normal file
View file

@ -0,0 +1,353 @@
const { ELEMENT_MAPPINGS } = require("./mappings");
class HTMLGenerator {
/**
* Creates a new HTML generator instance.
* @param {Object} [options] - Options object
* @param {boolean} [options.minified=true] - Minify generated HTML
* @param {boolean} [options.debug=false] - Enable debug logging
* @param {CSSGenerator} cssGenerator - CSS generator instance
*/
constructor(options = {}, cssGenerator) {
this.options = options;
this.cssGenerator = cssGenerator;
if (this.options.debug) {
console.log(
"[HTMLGenerator] Initialized with options:",
JSON.stringify(options, null, 2)
);
}
}
/**
* Converts a node to a string for debugging purposes, avoiding circular
* references.
* @param {Object} node - Node to stringify
* @returns {string} String representation of the node
*/
debugStringify(node) {
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(node, getCircularReplacer(), 2);
} catch (err) {
return `[Unable to stringify: ${err.message}]`;
}
}
/**
* Converts a node to a string of HTML.
* @param {Object} node - Node to generate HTML for
* @returns {string} Generated HTML
*/
generateHTML(node) {
if (this.options.debug) {
console.log(`\n[HTMLGenerator] Generating HTML for node`);
console.log(`[HTMLGenerator] Node type: "${node.type}"`);
console.log("[HTMLGenerator] Node details:", this.debugStringify(node));
}
if (node.type === "text") {
if (node.parent?.tag === "codeblock") {
if (this.options.debug) {
console.log("[HTMLGenerator] Rendering raw text for codeblock");
console.log(`[HTMLGenerator] Raw text content: "${node.value}"`);
}
return node.value;
}
const escapedText = node.value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
if (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") {
if (node.tag === "page") {
if (this.options.debug) {
console.log("[HTMLGenerator] Skipping page node - metadata only");
}
return "";
}
const mapping = ELEMENT_MAPPINGS[node.tag];
let tag = mapping ? mapping.tag : "div";
const className = this.cssGenerator.generateClassName(node.tag);
const { cssProps, nestedRules } =
this.cssGenerator.nodeToCSSProperties(node);
if (this.options.debug) {
console.log(`\n[HTMLGenerator] Processing element node`);
console.log(`[HTMLGenerator] Tag: "${node.tag}" -> "${tag}"`);
console.log(`[HTMLGenerator] Generated class name: "${className}"`);
console.log(
"[HTMLGenerator] CSS properties:",
this.debugStringify(Object.fromEntries(cssProps))
);
console.log(
"[HTMLGenerator] Nested rules:",
this.debugStringify(Object.fromEntries(nestedRules))
);
}
let attributes = "";
if (tag === "input") {
if (node.tag === "checkbox") {
attributes = ' type="checkbox"';
} else if (node.tag === "radio") {
attributes = ' type="radio"';
} else if (node.tag === "switch") {
attributes = ' type="checkbox" role="switch"';
} else if (node.tag === "slider") {
attributes = ' type="range"';
}
if (this.options.debug) {
console.log(
`[HTMLGenerator] Added input attributes: "${attributes}"`
);
}
}
if (node.tag === "media") {
const srcProp = node.props.find((p) => p.startsWith("src:"));
const typeProp = node.props.find((p) => p.startsWith("type:"));
if (!srcProp) {
throw new BlueprintError("Media element requires src property", node.line, node.column);
}
const src = srcProp.substring(srcProp.indexOf(":") + 1).trim();
const type = typeProp ? typeProp.substring(typeProp.indexOf(":") + 1).trim() : "img";
if (type === "video") {
tag = "video";
attributes = ` src="${src}" controls`;
} else {
tag = "img";
attributes = ` src="${src}" alt="${node.children.map(child => this.generateHTML(child)).join("")}"`;
}
}
if (node.tag === "link") {
const linkInfo = this.processLink(node);
attributes += ` href="${linkInfo.href}"`;
if (
linkInfo.href.startsWith("http://") ||
linkInfo.href.startsWith("https://")
) {
attributes += ` target="_blank" rel="noopener noreferrer"`;
if (this.options.debug) {
console.log(
`[HTMLGenerator] Added external link attributes for: ${linkInfo.href}`
);
}
} else {
if (this.options.debug) {
console.log(
`[HTMLGenerator] Added internal link attributes for: ${linkInfo.href}`
);
}
}
}
if (
node.props.find((p) => typeof p === "string" && p.startsWith("data-"))
) {
const dataProps = node.props.filter(
(p) => typeof p === "string" && p.startsWith("data-")
);
attributes += " " + dataProps.map((p) => `${p}`).join(" ");
if (this.options.debug) {
console.log(
`[HTMLGenerator] Added data attributes:`,
this.debugStringify(dataProps)
);
}
}
this.cssGenerator.cssRules.set(`.${className}`, {
cssProps,
nestedRules,
});
if (this.options.debug) {
console.log(
`[HTMLGenerator] Registered CSS rules for class: .${className}`
);
}
if (node.tag === "button" || node.tag.startsWith("button-")) {
if (node.parent?.tag === "link") {
const linkInfo = this.processLink(node.parent);
if (
linkInfo.href.startsWith("http://") ||
linkInfo.href.startsWith("https://")
) {
attributes += ` onclick="window.open('${linkInfo.href}', '_blank', 'noopener,noreferrer')"`;
if (this.options.debug) {
console.log(
`[HTMLGenerator] Added external button click handler for: ${linkInfo.href}`
);
}
} else {
attributes += ` onclick="window.location.href='${linkInfo.href}'"`;
if (this.options.debug) {
console.log(
`[HTMLGenerator] Added internal button click handler for: ${linkInfo.href}`
);
}
}
}
html += `<button class="${className}"${attributes}>${
this.options.minified ? "" : "\n"
}`;
if (this.options.debug) {
console.log(
`[HTMLGenerator] Generated button opening tag with attributes:`,
this.debugStringify({ class: className, ...attributes })
);
}
node.children.forEach((child) => {
child.parent = node;
html += this.generateHTML(child);
});
html += `${this.options.minified ? "" : "\n"}</button>${
this.options.minified ? "" : "\n"
}`;
} else if (
node.tag === "link" &&
node.children.length === 1 &&
(node.children[0].tag === "button" ||
node.children[0].tag?.startsWith("button-"))
) {
if (this.options.debug) {
console.log(
"[HTMLGenerator] Processing button inside link - using button's HTML"
);
}
node.children[0].parent = node;
html += this.generateHTML(node.children[0]);
} else {
html += `<${tag} class="${className}"${attributes}>${
this.options.minified ? "" : "\n"
}`;
if (this.options.debug) {
console.log(
`[HTMLGenerator] Generated opening tag: <${tag}> with attributes:`,
this.debugStringify({ class: className, ...attributes })
);
}
node.children.forEach((child) => {
child.parent = node;
html += this.generateHTML(child);
});
html += `${this.options.minified ? "" : "\n"}</${tag}>${
this.options.minified ? "" : "\n"
}`;
if (this.options.debug) {
console.log(`[HTMLGenerator] Completed element: ${tag}`);
}
}
} else if (node.type === "root") {
if (this.options.debug) {
console.log(
`[HTMLGenerator] Processing root node with ${node.children.length} children`
);
}
node.children.forEach((child, index) => {
if (this.options.debug) {
console.log(
`[HTMLGenerator] Processing root child ${index + 1}/${
node.children.length
}`
);
}
html += this.generateHTML(child);
});
}
if (this.options.debug) {
console.log("[HTMLGenerator] Generated HTML:", html);
}
return html;
}
/**
* Processes a link node, extracting the href attribute and converting it
* to an internal link if it doesn't start with http:// or https://.
*
* If no href property is found, the default value of # is used.
*
* @param {Object} node - The link node to process
* @returns {Object} - An object containing the final href value
*/
processLink(node) {
if (this.options.debug) {
console.log("\n[HTMLGenerator] Processing link node");
console.log(
"[HTMLGenerator] Link properties:",
this.debugStringify(node.props)
);
}
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(
`[HTMLGenerator] Converted to internal link: "${hrefTarget}"`
);
}
} else {
if (this.options.debug) {
console.log(
`[HTMLGenerator] External link detected: "${hrefTarget}"`
);
}
}
href = hrefTarget;
} else {
if (this.options.debug) {
console.log(
"[HTMLGenerator] No href property found, using default: '#'"
);
}
}
if (this.options.debug) {
console.log(`[HTMLGenerator] Final href value: "${href}"`);
}
return { href };
}
}
module.exports = HTMLGenerator;

333
lib/MetadataManager.js Normal file
View file

@ -0,0 +1,333 @@
class MetadataManager {
/**
* Initializes a new instance of the MetadataManager class.
*
* @param {Object} options - Configuration options for the metadata manager.
* @param {boolean} [options.debug=false] - Enables debug logging if true.
*
* Sets up the pageMetadata object containing default title, faviconUrl, and an empty meta array.
* If debug mode is enabled, logs the initialization options and the initial metadata state.
*/
constructor(options = {}) {
this.options = options;
this.pageMetadata = {
title: "",
faviconUrl: "",
meta: [],
};
if (this.options.debug) {
console.log(
"[MetadataManager] Initialized with options:",
JSON.stringify(options, null, 2)
);
console.log(
"[MetadataManager] Initial metadata state:",
JSON.stringify(this.pageMetadata, null, 2)
);
}
}
/**
* Converts a node to a string for debugging purposes, avoiding circular
* references.
* @param {Object} node - Node to stringify
* @returns {string} String representation of the node
*/
debugStringify(node) {
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(node, getCircularReplacer(), 2);
} catch (err) {
return `[Unable to stringify: ${err.message}]`;
}
}
/**
* Processes the metadata of a given node object, updating the internal page metadata state.
*
* Iterates through the node's properties and children to extract metadata information such as
* title, favicon, description, keywords, and author. This information is used to populate
* the pageMetadata object.
*
* For each property or child, it handles known metadata fields directly and adds custom
* meta tags for any properties or children with a "meta-" prefix.
*
* @param {Object} node - The node containing properties and children to process for metadata.
*/
processPageMetadata(node) {
if (this.options.debug) {
console.log("\n[MetadataManager] Processing page metadata");
console.log("[MetadataManager] Node details:", this.debugStringify(node));
}
if (node.props) {
if (this.options.debug) {
console.log(
`\n[MetadataManager] Processing ${node.props.length} page properties`
);
console.log(
"[MetadataManager] Properties:",
this.debugStringify(node.props)
);
}
node.props.forEach((prop) => {
if (typeof prop === "object" && prop.name && prop.value) {
if (this.options.debug) {
console.log(
`\n[MetadataManager] Processing property:`,
this.debugStringify(prop)
);
}
switch (prop.name) {
case "title":
this.pageMetadata.title = prop.value;
if (this.options.debug) {
console.log(
`[MetadataManager] Set page title: "${prop.value}"`
);
}
break;
case "favicon":
this.pageMetadata.faviconUrl = prop.value;
if (this.options.debug) {
console.log(
`[MetadataManager] Set favicon URL: "${prop.value}"`
);
}
break;
case "description":
this.pageMetadata.meta.push({
name: "description",
content: prop.value,
});
if (this.options.debug) {
console.log(
`[MetadataManager] Added description meta tag: "${prop.value}"`
);
}
break;
case "keywords":
this.pageMetadata.meta.push({
name: "keywords",
content: prop.value,
});
if (this.options.debug) {
console.log(
`[MetadataManager] Added keywords meta tag: "${prop.value}"`
);
}
break;
case "author":
this.pageMetadata.meta.push({
name: "author",
content: prop.value,
});
if (this.options.debug) {
console.log(
`[MetadataManager] Added author meta tag: "${prop.value}"`
);
}
break;
default:
if (prop.name.startsWith("meta-")) {
const metaName = prop.name.substring(5);
this.pageMetadata.meta.push({
name: metaName,
content: prop.value,
});
if (this.options.debug) {
console.log(
`[MetadataManager] Added custom meta tag - ${metaName}: "${prop.value}"`
);
}
} else if (this.options.debug) {
console.log(
`[MetadataManager] Skipping unknown property: "${prop.name}"`
);
}
}
}
});
}
if (node.children) {
if (this.options.debug) {
console.log(
`\n[MetadataManager] Processing ${node.children.length} child nodes for metadata`
);
}
node.children.forEach((child, index) => {
if (child.tag) {
if (this.options.debug) {
console.log(
`\n[MetadataManager] Processing child ${index + 1}/${
node.children.length
}`
);
console.log(`[MetadataManager] Child tag: "${child.tag}"`);
console.log(
"[MetadataManager] Child details:",
this.debugStringify(child)
);
}
let content = "";
/**
* Recursively extracts the text content from a node tree.
*
* This function traverses the node tree and concatenates the text content of
* all text nodes. For non-text nodes, it recursively calls itself on the
* children of that node.
*
* @param {Object} node - The node for which to extract the text content
* @return {string} The extracted text content
*/
const getTextContent = (node) => {
if (node.type === "text") return node.value;
if (node.children) {
return node.children.map(getTextContent).join("");
}
return "";
};
content = getTextContent(child);
if (this.options.debug) {
console.log(`[MetadataManager] Extracted content: "${content}"`);
}
switch (child.tag) {
case "title":
this.pageMetadata.title = content;
if (this.options.debug) {
console.log(
`[MetadataManager] Set page title from child: "${content}"`
);
}
break;
case "description":
this.pageMetadata.meta.push({ name: "description", content });
if (this.options.debug) {
console.log(
`[MetadataManager] Added description meta tag from child: "${content}"`
);
}
break;
case "keywords":
this.pageMetadata.meta.push({ name: "keywords", content });
if (this.options.debug) {
console.log(
`[MetadataManager] Added keywords meta tag from child: "${content}"`
);
}
break;
case "author":
this.pageMetadata.meta.push({ name: "author", content });
if (this.options.debug) {
console.log(
`[MetadataManager] Added author meta tag from child: "${content}"`
);
}
break;
default:
if (child.tag.startsWith("meta-")) {
const metaName = child.tag.substring(5);
this.pageMetadata.meta.push({ name: metaName, content });
if (this.options.debug) {
console.log(
`[MetadataManager] Added custom meta tag from child - ${metaName}: "${content}"`
);
}
} else if (this.options.debug) {
console.log(
`[MetadataManager] Skipping unknown child tag: "${child.tag}"`
);
}
}
}
});
}
if (this.options.debug) {
console.log("\n[MetadataManager] Metadata processing complete");
console.log(
"[MetadataManager] Final metadata state:",
this.debugStringify(this.pageMetadata)
);
}
}
/**
* Generates the HTML head content for the page, based on the metadata
* previously collected. The generated content includes the page title,
* favicon link, meta tags, and stylesheet link.
*
* @param {string} baseName - The base name of the page (used for the
* stylesheet link)
* @return {string} The generated HTML head content
*/
generateHeadContent(baseName) {
if (this.options.debug) {
console.log("\n[MetadataManager] Generating head content");
console.log(`[MetadataManager] Base name: "${baseName}"`);
}
let content = "";
const title = this.pageMetadata.title || baseName;
content += ` <title>${title}</title>\n`;
if (this.options.debug) {
console.log(`[MetadataManager] Added title tag: "${title}"`);
}
if (this.pageMetadata.faviconUrl) {
content += ` <link rel="icon" href="${this.pageMetadata.faviconUrl}">\n`;
if (this.options.debug) {
console.log(
`[MetadataManager] Added favicon link: "${this.pageMetadata.faviconUrl}"`
);
}
}
if (this.options.debug) {
console.log(
`[MetadataManager] Processing ${this.pageMetadata.meta.length} meta tags`
);
}
this.pageMetadata.meta.forEach((meta, index) => {
content += ` <meta name="${meta.name}" content="${meta.content}">\n`;
if (this.options.debug) {
console.log(
`[MetadataManager] Added meta tag ${index + 1}: ${meta.name} = "${
meta.content
}"`
);
}
});
content += ` <link rel="stylesheet" href="${baseName}.css">\n`;
if (this.options.debug) {
console.log(`[MetadataManager] Added stylesheet link: "${baseName}.css"`);
console.log("\n[MetadataManager] Head content generation complete");
console.log("[MetadataManager] Generated content:", content);
}
return content;
}
}
module.exports = MetadataManager;

370
lib/TokenParser.js Normal file
View file

@ -0,0 +1,370 @@
const BlueprintError = require("./BlueprintError");
class TokenParser {
/**
* Creates a new TokenParser instance.
* @param {Object} [options] - Options object
* @param {boolean} [options.debug=false] - Enable debug logging
*/
constructor(options = {}) {
this.options = options;
if (this.options.debug) {
console.log(
"[TokenParser] Initialized with options:",
JSON.stringify(options, null, 2)
);
}
}
/**
* Tokenizes the input string into an array of tokens.
* Tokens can be of the following types:
* - `identifier`: A sequence of letters, numbers, underscores, and hyphens.
* Represents a CSS selector or a property name.
* - `props`: A sequence of characters enclosed in parentheses.
* Represents a list of CSS properties.
* - `text`: A sequence of characters enclosed in quotes.
* Represents a string of text.
* - `brace`: A single character, either `{` or `}`.
* Represents a brace in the input.
*
* @param {string} input - Input string to tokenize
* @returns {Array<Object>} - Array of tokens
* @throws {BlueprintError} - If the input contains invalid syntax
*/
tokenize(input) {
if (this.options.debug) {
console.log("\n[TokenParser] Starting tokenization");
console.log(`[TokenParser] Input length: ${input.length} characters`);
console.log(`[TokenParser] First 100 chars: ${input.slice(0, 100)}...`);
}
const tokens = [];
let current = 0;
let line = 1;
let column = 1;
const startTime = Date.now();
const TIMEOUT_MS = 5000;
while (current < input.length) {
let char = input[current];
if (Date.now() - startTime > TIMEOUT_MS) {
if (this.options.debug) {
console.log(
`[TokenParser] Tokenization timeout at position ${current}, line ${line}, column ${column}`
);
}
throw new BlueprintError(
"Parsing timeout - check for unclosed brackets or quotes",
line,
column
);
}
if (char === "\n") {
if (this.options.debug) {
console.log(
`[TokenParser] Line break at position ${current}, moving to line ${
line + 1
}`
);
}
line++;
column = 1;
current++;
continue;
}
if (/\s/.test(char)) {
column++;
current++;
continue;
}
if (char === "/" && input[current + 1] === "/") {
if (this.options.debug) {
console.log(
`[TokenParser] Comment found at line ${line}, column ${column}`
);
const commentEnd = input.indexOf("\n", current);
const comment = input.slice(
current,
commentEnd !== -1 ? commentEnd : undefined
);
console.log(`[TokenParser] Comment content: ${comment}`);
}
while (current < input.length && input[current] !== "\n") {
current++;
column++;
}
continue;
}
if (/[a-zA-Z]/.test(char)) {
let value = "";
const startColumn = column;
const startPos = current;
while (current < input.length && /[a-zA-Z0-9_-]/.test(char)) {
value += char;
current++;
column++;
char = input[current];
}
if (this.options.debug) {
console.log(
`[TokenParser] Identifier found at line ${line}, column ${startColumn}`
);
console.log(`[TokenParser] Identifier value: "${value}"`);
console.log(
`[TokenParser] Context: ...${input.slice(
Math.max(0, startPos - 10),
startPos
)}[${value}]${input.slice(current, current + 10)}...`
);
}
tokens.push({
type: "identifier",
value,
line,
column: startColumn,
});
continue;
}
if (char === "(") {
if (this.options.debug) {
console.log(`[DEBUG] Starting property list at position ${current}`);
}
const startColumn = column;
let value = "";
let depth = 1;
let propLine = line;
let propColumn = column;
current++;
column++;
const propStartPos = current;
while (current < input.length && depth > 0) {
if (current - propStartPos > 1000) {
if (this.options.debug) {
console.log("[DEBUG] Property list too long or unclosed");
}
throw new BlueprintError(
"Property list too long or unclosed parenthesis",
propLine,
propColumn
);
}
char = input[current];
if (char === "(") depth++;
if (char === ")") depth--;
if (depth === 0) break;
value += char;
if (char === "\n") {
line++;
column = 1;
} else {
column++;
}
current++;
}
if (depth > 0) {
if (this.options.debug) {
console.log("[DEBUG] Unclosed parenthesis detected");
}
throw new BlueprintError(
"Unclosed parenthesis in property list",
propLine,
propColumn
);
}
tokens.push({
type: "props",
value: value.trim(),
line,
column: startColumn,
});
current++;
column++;
continue;
}
if (char === '"' || char === "'") {
if (this.options.debug) {
console.log(`[DEBUG] Starting string at position ${current}`);
}
const startColumn = column;
const startLine = line;
const quote = char;
let value = "";
const stringStartPos = current;
current++;
column++;
while (current < input.length) {
if (current - stringStartPos > 1000) {
if (this.options.debug) {
console.log("[DEBUG] String too long or unclosed");
}
throw new BlueprintError(
"String too long or unclosed quote",
startLine,
startColumn
);
}
char = input[current];
if (char === "\n") {
line++;
column = 1;
value += char;
} else if (char === quote && input[current - 1] !== "\\") {
break;
} else {
value += char;
column++;
}
current++;
}
tokens.push({
type: "text",
value,
line: startLine,
column: startColumn,
});
current++;
column++;
continue;
}
if (char === "{" || char === "}") {
if (this.options.debug) {
console.log(`[DEBUG] Found brace: ${char} at position ${current}`);
}
tokens.push({
type: "brace",
value: char,
line,
column,
});
current++;
column++;
continue;
}
if (this.options.debug) {
console.log(
`[DEBUG] Unexpected character at position ${current}: "${char}"`
);
}
throw new BlueprintError(`Unexpected character: ${char}`, line, column);
}
if (this.options.debug) {
console.log("\n[TokenParser] Tokenization complete");
console.log(`[TokenParser] Total tokens generated: ${tokens.length}`);
console.log(
"[TokenParser] Token summary:",
tokens.map((t) => `${t.type}:${t.value}`).join(", ")
);
}
this.validateBraces(tokens);
return tokens;
}
/**
* Validates that all braces in the token stream are properly matched.
* This function walks the token stream, counting the number of open and
* close braces. If it encounters an unmatched brace, it throws an error.
* If it encounters an extra closing brace, it throws an error.
* @throws {BlueprintError} - If there is a brace mismatch
*/
validateBraces(tokens) {
let braceCount = 0;
let lastOpenBrace = { line: 1, column: 1 };
const braceStack = [];
if (this.options.debug) {
console.log("\n[TokenParser] Starting brace validation");
}
for (const token of tokens) {
if (token.type === "brace") {
if (token.value === "{") {
braceCount++;
braceStack.push({ line: token.line, column: token.column });
lastOpenBrace = { line: token.line, column: token.column };
if (this.options.debug) {
console.log(
`[TokenParser] Opening brace at line ${token.line}, column ${token.column}, depth: ${braceCount}`
);
}
} else if (token.value === "}") {
braceCount--;
const matchingOpen = braceStack.pop();
if (this.options.debug) {
console.log(
`[TokenParser] Closing brace at line ${token.line}, column ${token.column}, depth: ${braceCount}`
);
if (matchingOpen) {
console.log(
`[TokenParser] Matches opening brace at line ${matchingOpen.line}, column ${matchingOpen.column}`
);
}
}
}
}
}
if (braceCount !== 0) {
if (this.options.debug) {
console.log(
`[TokenParser] Brace mismatch detected: ${
braceCount > 0 ? "unclosed" : "extra"
} braces`
);
console.log(`[TokenParser] Brace stack:`, braceStack);
}
if (braceCount > 0) {
throw new BlueprintError(
"Unclosed brace",
lastOpenBrace.line,
lastOpenBrace.column
);
} else {
throw new BlueprintError(
"Extra closing brace",
tokens[tokens.length - 1].line,
tokens[tokens.length - 1].column
);
}
}
if (this.options.debug) {
console.log("[TokenParser] Brace validation complete - all braces match");
}
}
}
module.exports = TokenParser;

77
lib/build.js Normal file
View file

@ -0,0 +1,77 @@
const BlueprintBuilder = require("./BlueprintBuilder");
const fs = require("fs");
const path = require("path");
const args = process.argv.slice(2);
const options = {
minified: !args.includes("--readable"),
srcDir: "./src",
outDir: "./dist",
debug: args.includes("--debug"),
};
const builder = new BlueprintBuilder(options);
function ensureDirectoryExistence(filePath) {
const dirname = path.dirname(filePath);
if (fs.existsSync(dirname)) {
return true;
}
ensureDirectoryExistence(dirname);
fs.mkdirSync(dirname);
}
function getAllFiles(dirPath, arrayOfFiles) {
const files = fs.readdirSync(dirPath);
arrayOfFiles = arrayOfFiles || [];
files.forEach((file) => {
if (fs.statSync(path.join(dirPath, file)).isDirectory()) {
arrayOfFiles = getAllFiles(path.join(dirPath, file), arrayOfFiles);
} else if (file.endsWith(".bp")) {
arrayOfFiles.push(path.join(dirPath, file));
}
});
return arrayOfFiles;
}
const files = getAllFiles(options.srcDir);
let success = true;
const errors = [];
console.log("Building Blueprint files...");
const startTime = Date.now();
for (const file of files) {
const relativePath = path.relative(options.srcDir, file);
const outputPath = path.join(
options.outDir,
relativePath.replace(/\.bp$/, ".html")
);
ensureDirectoryExistence(outputPath);
console.log(`Building ${file}...`);
const result = builder.build(file, path.dirname(outputPath));
if (!result.success) {
success = false;
errors.push({ file, errors: result.errors });
}
}
const totalTime = Date.now() - startTime;
if (success) {
console.log(`All files built successfully in ${totalTime}ms!`);
} else {
console.error("Build failed with errors:");
errors.forEach(({ file, errors }) => {
console.error(`\nFile: ${file}`);
errors.forEach((err) => {
console.error(` ${err.message} (${err.line}:${err.column})`);
});
});
process.exit(1);
}

15
lib/dev-server.js Normal file
View file

@ -0,0 +1,15 @@
const BlueprintServer = require("./server");
const args = process.argv.slice(2);
const options = {
port: args.includes("--port")
? parseInt(args[args.indexOf("--port") + 1])
: 3000,
liveReload: args.includes("--live"),
minified: !args.includes("--readable"),
srcDir: "./src",
outDir: "./dist",
};
const server = new BlueprintServer(options);
server.start();

648
lib/mappings.js Normal file
View file

@ -0,0 +1,648 @@
const STYLE_MAPPINGS = {
centered: {
display: "flex",
justifyContent: "center",
alignItems: "center",
textAlign: "center",
padding: "2rem",
width: "100%",
},
spaced: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "1.5rem",
width: "100%",
},
responsive: {
flexWrap: "wrap",
gap: "2rem",
},
horizontal: {
display: "flex",
flexDirection: "row",
gap: "1.5rem",
alignItems: "center",
width: "100%",
},
vertical: {
display: "flex",
flexDirection: "column",
gap: "1.5rem",
width: "100%",
},
grid: {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "2rem",
width: "100%",
padding: "2rem 0",
},
wide: {
width: "100%",
maxWidth: "1200px",
margin: "0 auto",
padding: "0 2rem",
},
alternate: {
backgroundColor: "#0d1117",
padding: "5rem 0",
width: "100%",
},
sticky: {
position: "fixed",
top: "0",
left: "0",
right: "0",
zIndex: "1000",
backgroundColor: "rgba(13, 17, 23, 0.95)",
backdropFilter: "blur(12px)",
borderBottom: "1px solid rgba(48, 54, 61, 0.6)",
},
huge: {
fontSize: "clamp(2.5rem, 5vw, 4rem)",
fontWeight: "800",
lineHeight: "1.1",
letterSpacing: "-0.02em",
color: "#ffffff",
marginBottom: "1.5rem",
textAlign: "center",
},
large: {
fontSize: "clamp(1.5rem, 3vw, 2rem)",
lineHeight: "1.3",
color: "#ffffff",
fontWeight: "600",
marginBottom: "1rem",
},
small: {
fontSize: "0.875rem",
lineHeight: "1.5",
color: "#8b949e",
},
bold: {
fontWeight: "600",
color: "#ffffff",
},
subtle: {
color: "#8b949e",
lineHeight: "1.6",
marginBottom: "0.5rem",
},
light: {
backgroundColor: "transparent",
color: "#8b949e",
padding: "0.875rem 1.75rem",
borderRadius: "12px",
border: "1px solid rgba(48, 54, 61, 0.6)",
cursor: "pointer",
fontWeight: "500",
fontSize: "0.95rem",
transition: "all 0.2s ease",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
textDecoration: "none",
":hover": {
color: "#e6edf3",
backgroundColor: "rgba(255, 255, 255, 0.1)",
borderColor: "#6b7280",
},
},
raised: {
backgroundColor: "#111827",
borderRadius: "16px",
border: "1px solid rgba(48, 54, 61, 0.6)",
padding: "2rem",
transition: "all 0.2s ease",
":hover": {
transform: "translateY(-2px)",
boxShadow: "0 8px 16px rgba(0,0,0,0.2)",
borderColor: "#3b82f6",
},
},
prominent: {
backgroundColor: "#3b82f6",
color: "#ffffff",
padding: "0.875rem 1.75rem",
borderRadius: "12px",
border: "none",
cursor: "pointer",
fontWeight: "500",
fontSize: "0.95rem",
transition: "all 0.2s ease",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
textDecoration: "none",
":hover": {
backgroundColor: "#2563eb",
transform: "translateY(-1px)",
boxShadow: "0 4px 12px rgba(59, 130, 246, 0.3)",
},
},
secondary: {
backgroundColor: "#1f2937",
color: "#e6edf3",
padding: "0.875rem 1.75rem",
borderRadius: "12px",
border: "1px solid rgba(48, 54, 61, 0.6)",
cursor: "pointer",
fontWeight: "500",
fontSize: "0.95rem",
transition: "all 0.2s ease",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
textDecoration: "none",
":hover": {
backgroundColor: "#374151",
borderColor: "#6b7280",
transform: "translateY(-1px)",
},
},
compact: {
padding: "0.75rem",
borderRadius: "12px",
backgroundColor: "#111827",
border: "1px solid rgba(48, 54, 61, 0.6)",
color: "#e6edf3",
cursor: "pointer",
transition: "all 0.2s ease",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
":hover": {
backgroundColor: "#1f2937",
borderColor: "#3b82f6",
transform: "translateY(-1px)",
},
},
navbar: {
backgroundColor: "rgba(13, 17, 23, 0.95)",
backdropFilter: "blur(12px)",
padding: "1rem 2rem",
width: "100%",
borderBottom: "1px solid rgba(48, 54, 61, 0.6)",
position: "fixed",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
"> *": {
maxWidth: "1200px",
margin: "0 auto",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
},
},
section: {
padding: "5rem 0",
backgroundColor: "#0d1117",
marginTop: "5rem",
"> *": {
maxWidth: "1200px",
margin: "0 auto",
},
},
card: {
display: "flex",
flexDirection: "column",
gap: "1.5rem",
height: "100%",
backgroundColor: "#111827",
borderRadius: "16px",
border: "1px solid rgba(48, 54, 61, 0.6)",
padding: "2rem",
transition: "all 0.2s ease",
marginBottom: "1rem",
"> title": {
fontSize: "1.25rem",
fontWeight: "600",
color: "#ffffff",
marginBottom: "0.5rem",
},
"> text": {
color: "#8b949e",
lineHeight: "1.6",
},
cursor: "default",
},
links: {
display: "flex",
gap: "2rem",
alignItems: "center",
"> *": {
color: "#8b949e",
textDecoration: "none",
transition: "all 0.2s ease",
fontSize: "0.95rem",
padding: "0.5rem 0.75rem",
borderRadius: "8px",
cursor: "pointer",
":hover": {
color: "#e6edf3",
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
},
},
input: {
backgroundColor: "#111827",
border: "1px solid rgba(48, 54, 61, 0.6)",
borderRadius: "12px",
padding: "0.875rem 1.25rem",
color: "#e6edf3",
width: "100%",
transition: "all 0.2s ease",
outline: "none",
fontSize: "0.95rem",
":focus": {
borderColor: "#3b82f6",
boxShadow: "0 0 0 3px rgba(59, 130, 246, 0.15)",
},
"::placeholder": {
color: "#8b949e",
},
},
textarea: {
backgroundColor: "#111827",
border: "1px solid rgba(48, 54, 61, 0.6)",
borderRadius: "12px",
padding: "0.875rem 1.25rem",
color: "#e6edf3",
width: "100%",
minHeight: "120px",
resize: "vertical",
transition: "all 0.2s ease",
outline: "none",
fontSize: "0.95rem",
":focus": {
borderColor: "#3b82f6",
boxShadow: "0 0 0 3px rgba(59, 130, 246, 0.15)",
},
},
select: {
backgroundColor: "#111827",
border: "1px solid rgba(48, 54, 61, 0.6)",
borderRadius: "12px",
padding: "0.875rem 2.5rem 0.875rem 1.25rem",
color: "#e6edf3",
width: "100%",
cursor: "pointer",
appearance: "none",
fontSize: "0.95rem",
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%238b949e' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E\")",
backgroundRepeat: "no-repeat",
backgroundPosition: "right 1rem center",
backgroundSize: "1.5em 1.5em",
transition: "all 0.2s ease",
":focus": {
borderColor: "#3b82f6",
boxShadow: "0 0 0 3px rgba(59, 130, 246, 0.15)",
},
},
checkbox: {
appearance: "none",
width: "1.25rem",
height: "1.25rem",
borderRadius: "6px",
border: "1px solid rgba(48, 54, 61, 0.6)",
backgroundColor: "#111827",
cursor: "pointer",
transition: "all 0.2s ease",
position: "relative",
marginRight: "0.75rem",
":checked": {
backgroundColor: "#3b82f6",
borderColor: "#3b82f6",
"::after": {
content: '"✓"',
position: "absolute",
color: "white",
fontSize: "0.85rem",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
},
},
":hover": {
borderColor: "#3b82f6",
},
},
radio: {
appearance: "none",
width: "1.25rem",
height: "1.25rem",
borderRadius: "50%",
border: "1px solid rgba(48, 54, 61, 0.6)",
backgroundColor: "#111827",
cursor: "pointer",
transition: "all 0.2s ease",
marginRight: "0.75rem",
":checked": {
borderColor: "#3b82f6",
borderWidth: "4px",
backgroundColor: "#ffffff",
},
":hover": {
borderColor: "#3b82f6",
},
},
progress: {
appearance: "none",
width: "100%",
height: "0.75rem",
borderRadius: "999px",
overflow: "hidden",
backgroundColor: "#111827",
border: "1px solid rgba(48, 54, 61, 0.6)",
"::-webkit-progress-bar": {
backgroundColor: "#111827",
},
"::-webkit-progress-value": {
backgroundColor: "#3b82f6",
transition: "width 0.3s ease",
},
"::-moz-progress-bar": {
backgroundColor: "#3b82f6",
transition: "width 0.3s ease",
},
},
slider: {
appearance: "none",
width: "100%",
height: "0.5rem",
borderRadius: "999px",
backgroundColor: "#111827",
border: "1px solid rgba(48, 54, 61, 0.6)",
cursor: "pointer",
"::-webkit-slider-thumb": {
appearance: "none",
width: "1.25rem",
height: "1.25rem",
borderRadius: "50%",
backgroundColor: "#3b82f6",
border: "2px solid #ffffff",
cursor: "pointer",
transition: "all 0.2s ease",
":hover": {
transform: "scale(1.1)",
},
},
},
switch: {
appearance: "none",
position: "relative",
width: "3.5rem",
height: "1.75rem",
backgroundColor: "#111827",
border: "1px solid rgba(48, 54, 61, 0.6)",
borderRadius: "999px",
cursor: "pointer",
transition: "all 0.2s ease",
marginRight: "0.75rem",
":checked": {
backgroundColor: "#3b82f6",
borderColor: "#3b82f6",
"::after": {
transform: "translateX(1.75rem)",
},
},
"::after": {
content: '""',
position: "absolute",
top: "0.2rem",
left: "0.2rem",
width: "1.25rem",
height: "1.25rem",
borderRadius: "50%",
backgroundColor: "#ffffff",
transition: "transform 0.2s ease",
},
},
badge: {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: "0.375rem 0.875rem",
borderRadius: "999px",
fontSize: "0.875rem",
fontWeight: "500",
backgroundColor: "#111827",
color: "#e6edf3",
border: "1px solid rgba(48, 54, 61, 0.6)",
minWidth: "4rem",
transition: "all 0.2s ease",
},
alert: {
padding: "1rem 1.5rem",
borderRadius: "12px",
border: "1px solid rgba(48, 54, 61, 0.6)",
backgroundColor: "#111827",
color: "#e6edf3",
display: "flex",
alignItems: "center",
gap: "0.75rem",
fontSize: "0.95rem",
},
tooltip: {
position: "relative",
display: "inline-block",
":hover::after": {
content: "attr(data-tooltip)",
position: "absolute",
bottom: "120%",
left: "50%",
transform: "translateX(-50%)",
padding: "0.5rem 1rem",
borderRadius: "8px",
backgroundColor: "#111827",
color: "#e6edf3",
fontSize: "0.875rem",
whiteSpace: "nowrap",
zIndex: "1000",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
border: "1px solid rgba(48, 54, 61, 0.6)",
},
},
link: {
color: "#e6edf3",
textDecoration: "none",
transition: "all 0.2s ease",
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
":hover": {
color: "#3b82f6",
},
},
media: {
display: "block",
maxWidth: "100%",
height: "auto",
borderRadius: "8px",
transition: "all 0.2s ease",
":hover": {
transform: "scale(1.01)",
},
},
};
const ELEMENT_MAPPINGS = {
page: {
tag: "meta",
defaultProps: [],
},
section: {
tag: "section",
defaultProps: ["wide"],
},
title: {
tag: "h1",
defaultProps: ["bold"],
},
subtitle: {
tag: "h2",
defaultProps: ["bold", "large"],
},
text: {
tag: "p",
defaultProps: [],
},
button: {
tag: "button",
defaultProps: ["prominent"],
},
"button-secondary": {
tag: "button",
defaultProps: ["secondary"],
},
"button-light": {
tag: "button",
defaultProps: ["light"],
},
"button-compact": {
tag: "button",
defaultProps: ["compact"],
},
link: {
tag: "a",
defaultProps: ["link"],
},
card: {
tag: "div",
defaultProps: ["raised", "card"],
},
grid: {
tag: "div",
defaultProps: ["grid", "responsive"],
},
horizontal: {
tag: "div",
defaultProps: ["horizontal", "spaced"],
},
vertical: {
tag: "div",
defaultProps: ["vertical"],
},
list: {
tag: "ul",
defaultProps: ["bullet"],
},
cell: {
tag: "td",
defaultProps: [],
},
row: {
tag: "tr",
defaultProps: [],
},
table: {
tag: "table",
defaultProps: ["table"],
},
codeblock: {
tag: "pre",
defaultProps: ["code"],
},
navbar: {
tag: "nav",
defaultProps: ["navbar", "sticky"],
},
links: {
tag: "div",
defaultProps: ["links"],
},
input: {
tag: "input",
defaultProps: ["input"],
},
textarea: {
tag: "textarea",
defaultProps: ["textarea"],
},
checkbox: {
tag: "input",
defaultProps: ["checkbox"],
},
radio: {
tag: "input",
defaultProps: ["radio"],
},
select: {
tag: "select",
defaultProps: ["select"],
},
progress: {
tag: "progress",
defaultProps: ["progress"],
},
slider: {
tag: "input",
defaultProps: ["slider"],
},
switch: {
tag: "input",
defaultProps: ["switch"],
},
badge: {
tag: "span",
defaultProps: ["badge"],
},
alert: {
tag: "div",
defaultProps: ["alert"],
},
tooltip: {
tag: "span",
defaultProps: ["tooltip"],
},
description: {
tag: "meta",
defaultProps: [],
},
keywords: {
tag: "meta",
defaultProps: [],
},
author: {
tag: "meta",
defaultProps: [],
},
media: {
tag: "media",
defaultProps: ["media"],
},
};
module.exports = {
STYLE_MAPPINGS,
ELEMENT_MAPPINGS,
};

473
lib/server.js Normal file
View file

@ -0,0 +1,473 @@
const express = require("express");
const expressWs = require("express-ws");
const chokidar = require("chokidar");
const path = require("path");
const fs = require("fs");
const BlueprintBuilder = require("./BlueprintBuilder");
class BlueprintServer {
constructor(options = {}) {
this.app = express();
this.wsInstance = expressWs(this.app);
this.options = {
port: 3000,
srcDir: "./src",
outDir: "./dist",
liveReload: false,
minified: true,
...options,
};
this.clients = new Map();
this.filesWithErrors = new Set();
this.setupServer();
if (this.options.liveReload) {
const watcher = chokidar.watch([], {
ignored: /(^|[\/\\])\../,
persistent: true,
ignoreInitial: true,
});
setTimeout(() => {
watcher.add(this.options.srcDir);
this.setupWatcher(watcher);
}, 1000);
}
}
log(tag, message, color) {
const colorCodes = {
blue: "\x1b[34m",
green: "\x1b[32m",
red: "\x1b[31m",
orange: "\x1b[33m",
lightGray: "\x1b[90m",
reset: "\x1b[0m",
bgBlue: "\x1b[44m",
};
console.log(
`${colorCodes.bgBlue} BP ${colorCodes.reset} ${
colorCodes[color] || ""
}${message}${colorCodes.reset}`
);
}
async buildAll() {
this.log("INFO", "Building all Blueprint files...", "lightGray");
if (fs.existsSync(this.options.outDir)) {
fs.rmSync(this.options.outDir, { recursive: true });
}
fs.mkdirSync(this.options.outDir, { recursive: true });
const files = this.getAllFiles(this.options.srcDir);
let success = true;
const errors = [];
const startTime = Date.now();
for (const file of files) {
const relativePath = path.relative(this.options.srcDir, file);
const outputPath = path.join(
this.options.outDir,
relativePath.replace(/\.bp$/, ".html")
);
this.ensureDirectoryExistence(outputPath);
const builder = new BlueprintBuilder({ minified: this.options.minified });
const result = builder.build(file, path.dirname(outputPath));
if (!result.success) {
success = false;
errors.push({ file, errors: result.errors });
}
}
const totalTime = Date.now() - startTime;
if (success) {
this.log(
"SUCCESS",
`All files built successfully in ${totalTime}ms!`,
"green"
);
} else {
this.log("ERROR", "Build failed with errors:", "red");
errors.forEach(({ file, errors }) => {
this.log("ERROR", `File: ${file}`, "red");
errors.forEach((err) => {
this.log(
"ERROR",
`${err.type} at line ${err.line}, column ${err.column}: ${err.message}`,
"red"
);
});
});
process.exit(1);
}
}
ensureDirectoryExistence(filePath) {
const dirname = path.dirname(filePath);
if (fs.existsSync(dirname)) {
return true;
}
this.ensureDirectoryExistence(dirname);
fs.mkdirSync(dirname);
}
getAllFiles(dirPath, arrayOfFiles) {
const files = fs.readdirSync(dirPath);
arrayOfFiles = arrayOfFiles || [];
files.forEach((file) => {
if (fs.statSync(path.join(dirPath, file)).isDirectory()) {
arrayOfFiles = this.getAllFiles(path.join(dirPath, file), arrayOfFiles);
} else if (file.endsWith(".bp")) {
arrayOfFiles.push(path.join(dirPath, file));
}
});
return arrayOfFiles;
}
setupServer() {
this.app.use((req, res, next) => {
const isHtmlRequest =
req.path.endsWith(".html") || !path.extname(req.path);
if (this.options.liveReload && isHtmlRequest) {
const htmlPath = req.path.endsWith(".html")
? path.join(this.options.outDir, req.path)
: path.join(this.options.outDir, req.path + ".html");
fs.readFile(htmlPath, "utf8", (err, data) => {
if (err) return next();
let html = data;
const script = `
<script>
(function() {
let currentPage = window.location.pathname.replace(/^\\//, '') || 'index.html';
console.log('Current page:', currentPage);
const ws = new WebSocket('ws://' + window.location.host + '/live-reload');
ws.onopen = () => {
console.log('Live reload connected');
ws.send(JSON.stringify({ type: 'register', page: currentPage+".html" }));
};
ws.onmessage = (event) => {
console.log('Received message:', event.data);
const data = JSON.parse(event.data);
if (data.type === 'reload' && data.content) {
console.log('Received new content, updating DOM...');
const parser = new DOMParser();
const newDoc = parser.parseFromString(data.content, 'text/html');
if (document.title !== newDoc.title) {
document.title = newDoc.title;
}
const stylePromises = [];
const newStyles = Array.from(newDoc.getElementsByTagName('link'))
.filter(link => link.rel === 'stylesheet');
newStyles.forEach(style => {
const newHref = style.href + '?t=' + Date.now();
stylePromises.push(new Promise((resolve, reject) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = newHref;
link.onload = () => resolve(link);
link.onerror = reject;
link.media = 'print';
document.head.appendChild(link);
}));
});
Promise.all(stylePromises)
.then(newLinks => {
Array.from(document.getElementsByTagName('link'))
.forEach(link => {
if (link.rel === 'stylesheet' && !newLinks.includes(link)) {
link.remove();
}
});
newLinks.forEach(link => {
link.media = 'all';
});
document.body.innerHTML = newDoc.body.innerHTML;
Array.from(newDoc.getElementsByTagName('script'))
.forEach(script => {
if (!script.src && script.parentElement.tagName === 'BODY') {
const newScript = document.createElement('script');
newScript.textContent = script.textContent;
document.body.appendChild(newScript);
}
});
console.log('DOM update complete');
})
.catch(error => {
console.error('Error loading new stylesheets:', error);
window.location.reload();
});
}
};
ws.onclose = () => {
console.log('Live reload connection closed, attempting to reconnect...');
setTimeout(() => {
window.location.reload();
}, 1000);
};
})();
</script>
`;
html = html.replace("</head>", script + "</head>");
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
return res.send(html);
});
} else {
next();
}
});
this.app.use(express.static(this.options.outDir));
this.app.get("*", (req, res, next) => {
if (path.extname(req.path)) return next();
const htmlPath = path.join(this.options.outDir, req.path + ".html");
if (fs.existsSync(htmlPath)) {
res.sendFile(htmlPath);
} else if (req.path === "/") {
const pages = fs
.readdirSync(this.options.outDir)
.filter((f) => f.endsWith(".html"))
.map((f) => f.replace(".html", ""));
res.send(`
<html>
<head>
<title>Blueprint Pages</title>
<style>
body {
font-family: -apple-system, system-ui, sans-serif;
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
background: #0d1117;
color: #e6edf3;
}
h1 { margin-bottom: 2rem; }
ul { list-style: none; padding: 0; }
li { margin: 1rem 0; }
a {
color: #3b82f6;
text-decoration: none;
font-size: 1.1rem;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<h1>Blueprint Pages</h1>
<ul>
${pages
.map((page) => `<li><a href="/${page}">${page}</a></li>`)
.join("")}
</ul>
</body>
</html>
`);
} else {
next();
}
});
if (this.options.liveReload) {
this.app.ws("/live-reload", (ws, req) => {
ws.on("message", (msg) => {
try {
const data = JSON.parse(msg);
if (data.type === "register" && data.page) {
this.clients.set(ws, data.page);
}
} catch (error) {}
});
ws.on("close", () => {
this.clients.delete(ws);
});
ws.on("error", (error) => {
this.log("ERROR", "WebSocket error:", "red");
this.clients.delete(ws);
});
});
}
}
setupWatcher(watcher) {
watcher.on("change", async (filepath) => {
if (filepath.endsWith(".bp")) {
this.log("INFO", `File ${filepath} has been changed`, "blue");
try {
const builder = new BlueprintBuilder({
minified: this.options.minified,
debug: this.options.debug,
});
const relativePath = path.relative(this.options.srcDir, filepath);
const outputPath = path.join(
this.options.outDir,
relativePath.replace(/\.bp$/, ".html")
);
this.ensureDirectoryExistence(outputPath);
const result = builder.build(filepath, path.dirname(outputPath));
if (result.success) {
this.log("SUCCESS", "Rebuilt successfully", "green");
this.filesWithErrors.delete(filepath);
const htmlFile = relativePath.replace(/\.bp$/, ".html");
const htmlPath = path.join(this.options.outDir, htmlFile);
try {
const newContent = fs.readFileSync(htmlPath, "utf8");
for (const [client, page] of this.clients.entries()) {
if (
page === htmlFile.replace(/\\/g, "/") &&
client.readyState === 1
) {
try {
client.send(
JSON.stringify({
type: "reload",
content: newContent,
})
);
} catch (error) {
this.log("ERROR", "Error sending content:", "red");
this.clients.delete(client);
}
}
}
} catch (error) {
this.log("ERROR", "Error reading new content:", "red");
}
} else {
this.filesWithErrors.add(filepath);
this.log("ERROR", `Build failed: ${result.errors.map(e => e.message).join(", ")}`, "red");
this.log("INFO", "Waiting for next file change...", "orange");
for (const [client, page] of this.clients.entries()) {
const htmlFile = relativePath.replace(/\.bp$/, ".html");
if (
page === htmlFile.replace(/\\/g, "/") &&
client.readyState === 1
) {
try {
client.send(
JSON.stringify({
type: "buildError",
errors: result.errors,
})
);
} catch (error) {
this.log("ERROR", "Error sending error notification:", "red");
this.clients.delete(client);
}
}
}
}
} catch (error) {
this.log("ERROR", "Unexpected error during build:", "red");
this.filesWithErrors.add(filepath);
}
}
});
watcher.on("add", async (filepath) => {
if (filepath.endsWith(".bp")) {
this.log("INFO", `New file detected: ${filepath}`, "lightGray");
try {
const builder = new BlueprintBuilder({
minified: this.options.minified,
debug: this.options.debug,
});
const relativePath = path.relative(this.options.srcDir, filepath);
const outputPath = path.join(
this.options.outDir,
relativePath.replace(/\.bp$/, ".html")
);
this.ensureDirectoryExistence(outputPath);
const result = builder.build(filepath, path.dirname(outputPath));
if (result.success) {
this.log("SUCCESS", "Built new file successfully", "green");
this.filesWithErrors.delete(filepath);
} else {
this.filesWithErrors.add(filepath);
this.log("ERROR", "Build failed for new file", "red");
}
} catch (error) {
this.log("ERROR", "Unexpected error building new file:", "red");
this.filesWithErrors.add(filepath);
}
}
});
watcher.on("unlink", async (filepath) => {
if (filepath.endsWith(".bp")) {
this.log("INFO", `File ${filepath} removed`, "orange");
const relativePath = path.relative(this.options.srcDir, filepath);
const htmlPath = path.join(
this.options.outDir,
relativePath.replace(/\.bp$/, ".html")
);
const cssPath = path.join(
this.options.outDir,
relativePath.replace(/\.bp$/, ".css")
);
if (fs.existsSync(htmlPath)) {
fs.unlinkSync(htmlPath);
}
if (fs.existsSync(cssPath)) {
fs.unlinkSync(cssPath);
}
const dirPath = path.dirname(htmlPath);
if (fs.existsSync(dirPath) && fs.readdirSync(dirPath).length === 0) {
fs.rmdirSync(dirPath);
}
}
});
}
async start() {
await this.buildAll();
this.app.listen(this.options.port, () => {
this.log(
"INFO",
`Blueprint dev server running at http://localhost:${this.options.port}`,
"green"
);
this.log(
"INFO",
`Mode: ${this.options.minified ? "Minified" : "Human Readable"}`,
"lightGray"
);
if (this.options.liveReload) {
this.log(
"INFO",
"Live reload enabled - watching for changes...",
"lightGray"
);
}
});
}
}
module.exports = BlueprintServer;