release
This commit is contained in:
commit
47f67eea8c
43 changed files with 5819 additions and 0 deletions
343
lib/ASTBuilder.js
Normal file
343
lib/ASTBuilder.js
Normal 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
144
lib/BlueprintBuilder.js
Normal 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
10
lib/BlueprintError.js
Normal 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
402
lib/CSSGenerator.js
Normal 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
353
lib/HTMLGenerator.js
Normal 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, "&")
|
||||
.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") {
|
||||
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
333
lib/MetadataManager.js
Normal 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
370
lib/TokenParser.js
Normal 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
77
lib/build.js
Normal 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
15
lib/dev-server.js
Normal 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
648
lib/mappings.js
Normal 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
473
lib/server.js
Normal 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;
|
Loading…
Add table
Add a link
Reference in a new issue