@mahe_pkm/buzl-html-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ Copyright (c) 2026 Buzl. All rights reserved.
2
+
3
+ This package is proprietary software. Publication to a package registry does
4
+ not grant permission to copy, modify, redistribute, sublicense, or sell the
5
+ software. Use is limited to people and organizations authorized by Buzl.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # Buzl HTML Editor
2
+
3
+ A local visual editor and static-site server for ordinary multi-page HTML, CSS,
4
+ and JavaScript websites. It includes Live Edit, image uploads, AVIF image
5
+ optimization, AI-assisted text, and AI image generation.
6
+
7
+ The server is local-only by default. It edits the website folder from which it
8
+ is started; the editor's own files stay inside the npm package.
9
+
10
+ ## Requirements
11
+
12
+ - Node.js 18 or newer
13
+ - Node.js 20 LTS or newer is recommended
14
+ - A static website containing one or more `.html` files
15
+
16
+ ## Install
17
+
18
+ Run this inside the website root:
19
+
20
+ ```bash
21
+ npm install --save-dev @mahe_pkm/buzl-html-editor
22
+ ```
23
+
24
+ ## Start the website and editor
25
+
26
+ ```bash
27
+ npx buzl-editor
28
+ ```
29
+
30
+ Default addresses:
31
+
32
+ - Website: `http://localhost:4000/`
33
+ - Editor: `http://localhost:4000/admin/`
34
+
35
+ Use another port or website folder when needed:
36
+
37
+ ```bash
38
+ npx buzl-editor --port 4500
39
+ npx buzl-editor --root "C:\Websites\client-site" --port 4500
40
+ ```
41
+
42
+ Prevent automatic browser opening:
43
+
44
+ ```bash
45
+ npx buzl-editor --no-open
46
+ ```
47
+
48
+ ## Website-only mode
49
+
50
+ ```bash
51
+ npx buzl-site
52
+ npx buzl-site --port 3500
53
+ ```
54
+
55
+ Website-only mode blocks the editor, APIs, dependencies, configuration,
56
+ development files, and local secrets.
57
+
58
+ ## Setup and diagnostics
59
+
60
+ Initialize optional configuration in the current website:
61
+
62
+ ```bash
63
+ npx buzl-editor init
64
+ ```
65
+
66
+ This creates `.buzl/config.json`, `.env.example`, and safe `.gitignore` rules
67
+ without replacing existing files.
68
+
69
+ Inspect a website before editing:
70
+
71
+ ```bash
72
+ npx buzl-editor doctor
73
+ ```
74
+
75
+ ## Optional configuration
76
+
77
+ `.buzl/config.json`:
78
+
79
+ ```json
80
+ {
81
+ "editorPort": 4000,
82
+ "websitePort": 3000,
83
+ "host": "127.0.0.1",
84
+ "open": true
85
+ }
86
+ ```
87
+
88
+ Command-line options take priority over environment variables, configuration,
89
+ and defaults.
90
+
91
+ ## AI configuration
92
+
93
+ Create `.env` in the client website root. Never commit this file.
94
+
95
+ ```dotenv
96
+ ANTHROPIC_API_KEY=
97
+ OPENROUTER_API_KEY=
98
+ ```
99
+
100
+ The editor and image uploads work without the AI keys. Only the related AI
101
+ actions require them.
102
+
103
+ ## Safety
104
+
105
+ - The default bind address is `127.0.0.1`.
106
+ - Non-local binding requires both `--host` and `--allow-network`.
107
+ - The editor can only load and save HTML files inside the selected website.
108
+ - Each save creates a recoverable copy under `.buzl/backups/`.
109
+ - Uploaded raster images are optimized to AVIF under `assets/images/`.
110
+ - `.env`, `.git`, `.buzl`, `node_modules`, tests, package metadata, and server
111
+ source are not publicly served.
112
+
113
+ Press `Ctrl+C` to stop either server.
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const path = require('path');
6
+ const {
7
+ askForPort,
8
+ checkPort,
9
+ ensureSiteRoot,
10
+ initializeSite,
11
+ openBrowser,
12
+ parseArgs,
13
+ resolveRuntimeOptions,
14
+ runDoctor,
15
+ } = require('../lib/cli');
16
+
17
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
18
+ const DEFAULTS = { configPortKey: 'editorPort', host: '127.0.0.1', open: true, port: 4000 };
19
+
20
+ function printHelp() {
21
+ console.log(`
22
+ Buzl HTML Editor
23
+
24
+ Usage:
25
+ buzl-editor [--root <folder>] [--port <number>] [--no-open]
26
+ buzl-editor init [--root <folder>]
27
+ buzl-editor doctor [--root <folder>] [--port <number>]
28
+
29
+ Options:
30
+ --root <folder> Website root (default: current folder)
31
+ --port, -p Local port (default: 4000)
32
+ --host <address> Bind address (default: 127.0.0.1)
33
+ --allow-network Required for a non-local bind address
34
+ --open Open the editor in the default browser
35
+ --no-open Do not open a browser
36
+ --version, -v Show package version
37
+ --help, -h Show this help
38
+ `);
39
+ }
40
+
41
+ async function main() {
42
+ try {
43
+ if (Number(process.versions.node.split('.')[0]) < 18) {
44
+ throw new Error('Node.js 18 or newer is required. Node.js 20 LTS is recommended.');
45
+ }
46
+
47
+ const parsed = parseArgs(process.argv.slice(2), DEFAULTS);
48
+ if (parsed.command === 'help') return printHelp();
49
+ if (parsed.command === 'version') {
50
+ console.log(require('../package.json').version);
51
+ return;
52
+ }
53
+
54
+ ensureSiteRoot(parsed.root);
55
+ let options = resolveRuntimeOptions(parsed, DEFAULTS);
56
+ if (!options.portConfigured) {
57
+ options.port = await askForPort('editor', DEFAULTS.port);
58
+ }
59
+
60
+ const envPath = path.join(options.root, '.env');
61
+ process.env.BUZL_SITE_ROOT = options.root;
62
+ process.env.BUZL_ENV_FILE = envPath;
63
+ process.env.BUZL_CONTEXT_FILE = path.join(options.root, 'website_context.json');
64
+ process.env.HOST = options.host;
65
+ process.env.PORT = String(options.port);
66
+ require('dotenv').config({ path: envPath });
67
+
68
+ if (parsed.command === 'init') {
69
+ initializeSite(options.root);
70
+ return;
71
+ }
72
+ if (parsed.command === 'doctor') {
73
+ runDoctor(options.root, options.port, options.host);
74
+ await checkPort(options.port, options.host)
75
+ .then(() => console.log(`Port ${options.port}: available`))
76
+ .catch((error) => console.warn(`Port ${options.port}: ${error.message}`));
77
+ return;
78
+ }
79
+
80
+ await checkPort(options.port, options.host);
81
+ const app = require(path.join(PACKAGE_ROOT, 'dist', 'server.js'));
82
+ const server = app.listen(options.port, options.host, () => {
83
+ const siteUrl = `http://localhost:${options.port}/`;
84
+ const editorUrl = `http://localhost:${options.port}/admin/`;
85
+ console.log('\nBuzl website and editor are running.');
86
+ console.log(`Website: ${siteUrl}`);
87
+ console.log(`Editor: ${editorUrl}`);
88
+ console.log(`Root: ${options.root}`);
89
+ console.log('Press Ctrl+C to stop.\n');
90
+ if (options.open) openBrowser(editorUrl);
91
+ });
92
+
93
+ const stop = () => server.close(() => process.exit(0));
94
+ process.once('SIGINT', stop);
95
+ process.once('SIGTERM', stop);
96
+ } catch (error) {
97
+ console.error(`\nUnable to start Buzl Editor: ${error.message}\n`);
98
+ process.exitCode = 1;
99
+ }
100
+ }
101
+
102
+ main();
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const path = require('path');
6
+ const {
7
+ askForPort,
8
+ checkPort,
9
+ ensureSiteRoot,
10
+ openBrowser,
11
+ parseArgs,
12
+ resolveRuntimeOptions,
13
+ runDoctor,
14
+ } = require('../lib/cli');
15
+
16
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
17
+ const DEFAULTS = { configPortKey: 'websitePort', host: '127.0.0.1', open: true, port: 3000 };
18
+
19
+ function printHelp() {
20
+ console.log(`
21
+ Buzl static website server
22
+
23
+ Usage:
24
+ buzl-site [--root <folder>] [--port <number>] [--no-open]
25
+ buzl-site doctor [--root <folder>] [--port <number>]
26
+
27
+ The editor, APIs, package files and private configuration are unavailable in
28
+ website-only mode.
29
+ `);
30
+ }
31
+
32
+ async function main() {
33
+ try {
34
+ const parsed = parseArgs(process.argv.slice(2), DEFAULTS);
35
+ if (parsed.command === 'help') return printHelp();
36
+ if (parsed.command === 'version') {
37
+ console.log(require('../package.json').version);
38
+ return;
39
+ }
40
+ if (parsed.command === 'init') {
41
+ throw new Error('Use "buzl-editor init" to initialize a website.');
42
+ }
43
+
44
+ ensureSiteRoot(parsed.root);
45
+ let options = resolveRuntimeOptions(parsed, DEFAULTS);
46
+ if (!options.portConfigured) {
47
+ options.port = await askForPort('website', DEFAULTS.port);
48
+ }
49
+ if (parsed.command === 'doctor') {
50
+ runDoctor(options.root, options.port, options.host);
51
+ return;
52
+ }
53
+
54
+ await checkPort(options.port, options.host);
55
+ process.env.BUZL_SITE_ROOT = options.root;
56
+ const { createSiteServer } = require(path.join(PACKAGE_ROOT, 'dist', 'site-server.js'));
57
+ const server = createSiteServer({ siteRoot: options.root });
58
+ server.listen(options.port, options.host, () => {
59
+ const siteUrl = `http://localhost:${options.port}/`;
60
+ console.log('\nBuzl public website is running.');
61
+ console.log(`Website: ${siteUrl}`);
62
+ console.log(`Root: ${options.root}`);
63
+ console.log('The editor and APIs are disabled in this mode.');
64
+ console.log('Press Ctrl+C to stop.\n');
65
+ if (options.open) openBrowser(siteUrl);
66
+ });
67
+
68
+ const stop = () => server.close(() => process.exit(0));
69
+ process.once('SIGINT', stop);
70
+ process.once('SIGTERM', stop);
71
+ } catch (error) {
72
+ console.error(`\nUnable to start Buzl website: ${error.message}\n`);
73
+ process.exitCode = 1;
74
+ }
75
+ }
76
+
77
+ main();
@@ -0,0 +1,68 @@
1
+ const sharp = require('sharp');
2
+ const fs = require('fs');
3
+
4
+ const MAX_DIMENSION = 2048;
5
+ const MAX_SIZE_BYTES = 1 * 1024 * 1024; // 1 MB cap
6
+ const AVIF_QUALITY_LADDER = [70, 60, 50, 40, 30];
7
+
8
+ /**
9
+ * Compresses an image (buffer or file path) strictly to AVIF.
10
+ * Loops through quality ladder to fit within MAX_SIZE_BYTES.
11
+ * If it doesn't fit, writes the lowest quality (30) AVIF.
12
+ *
13
+ * @param {Buffer|string} inputBufferOrPath - Input image buffer or absolute file path
14
+ * @param {string} outPathWithoutExt - Target output path without the .avif extension
15
+ * @returns {Promise<{format: 'avif', path: string, size: number}>}
16
+ */
17
+ async function compressImage(inputBufferOrPath, outPathWithoutExt) {
18
+ // Read metadata
19
+ const pipeline = sharp(inputBufferOrPath, { failOn: 'none' });
20
+ const meta = await pipeline.metadata();
21
+
22
+ const originalWidth = meta.width;
23
+ const originalHeight = meta.height;
24
+ if (!originalWidth || !originalHeight) {
25
+ throw new Error('Could not read image dimensions');
26
+ }
27
+
28
+ const needsResize = Math.max(originalWidth, originalHeight) > MAX_DIMENSION;
29
+ const buildPipeline = () => {
30
+ let p = sharp(inputBufferOrPath, { failOn: 'none' });
31
+ if (needsResize) {
32
+ p = p.resize({
33
+ width: MAX_DIMENSION,
34
+ height: MAX_DIMENSION,
35
+ fit: 'inside',
36
+ withoutEnlargement: true
37
+ });
38
+ }
39
+ return p;
40
+ };
41
+
42
+ const finalPath = outPathWithoutExt + '.avif';
43
+ let smallestBuffer = null;
44
+
45
+ // Try AVIF quality levels
46
+ for (const quality of AVIF_QUALITY_LADDER) {
47
+ const { data } = await buildPipeline()
48
+ .avif({ quality, effort: 4 })
49
+ .toBuffer({ resolveWithObject: true });
50
+
51
+ if (!smallestBuffer || data.length < smallestBuffer.length) {
52
+ smallestBuffer = data;
53
+ }
54
+
55
+ if (data.length <= MAX_SIZE_BYTES) {
56
+ await fs.promises.writeFile(finalPath, data);
57
+ return { format: 'avif', path: finalPath, size: data.length };
58
+ }
59
+ }
60
+
61
+ // If nothing fit within 1MB budget, write the smallest AVIF buffer (quality 30)
62
+ await fs.promises.writeFile(finalPath, smallestBuffer);
63
+ return { format: 'avif', path: finalPath, size: smallestBuffer.length };
64
+ }
65
+
66
+ module.exports = {
67
+ compressImage
68
+ };
@@ -0,0 +1,242 @@
1
+ /**
2
+ * imageService.js
3
+ *
4
+ * Image generation service using OpenRouter APIs.
5
+ * JS port of tools/imageService.ts — simplified for the admin backend.
6
+ *
7
+ * Supports:
8
+ * - Image generation with model fallback
9
+ * - Request cancellation via AbortController
10
+ * - Saving generated images to disk
11
+ *
12
+ * Environment variables (.env):
13
+ * OPENROUTER_API_KEY
14
+ * OPENROUTER_BASE_URL (default: https://openrouter.ai/api/v1)
15
+ * OPENROUTER_IMAGE_MODEL (default: google/gemini-3.1-flash-image-preview)
16
+ * OPENROUTER_IMAGE_FALLBACK_MODELS (comma-separated)
17
+ */
18
+
19
+ // ─── OpenRouterClient ───────────────────────────────────────────────────────
20
+
21
+ class OpenRouterClient {
22
+ constructor({ apiKey, baseUrl }) {
23
+ this.apiKey = apiKey;
24
+ this.baseUrl = baseUrl;
25
+ }
26
+
27
+ /**
28
+ * Generate an image from a text prompt.
29
+ * Returns base64 image data.
30
+ * @param {string} prompt
31
+ * @param {string} model
32
+ * @param {AbortSignal} [signal] - optional AbortSignal for cancellation
33
+ */
34
+ async generateImage(prompt, model, signal) {
35
+ console.log(` 🖼️ ImageGen → model: ${model}`);
36
+
37
+ const data = await this._post('/chat/completions', {
38
+ model,
39
+ stream: false,
40
+ messages: [{ role: 'user', content: prompt }],
41
+ }, signal);
42
+
43
+ const choices = data?.choices;
44
+ if (!choices || choices.length === 0) {
45
+ throw new Error('No choices returned from image generation API');
46
+ }
47
+
48
+ const message = choices[0].message;
49
+ return this._extractBase64(message?.content, message?.images);
50
+ }
51
+
52
+ /**
53
+ * POST to OpenRouter using native fetch (Node 18+).
54
+ * Handles large responses (image base64 payloads can be 200KB+) reliably.
55
+ */
56
+ async _post(path, body, signal) {
57
+ // Ensure baseUrl ends with '/' so relative path appends correctly
58
+ const base = this.baseUrl.endsWith('/') ? this.baseUrl : this.baseUrl + '/';
59
+ const url = new URL(path.replace(/^\//, ''), base).toString();
60
+
61
+ const res = await fetch(url, {
62
+ method: 'POST',
63
+ headers: {
64
+ 'Authorization': `Bearer ${this.apiKey}`,
65
+ 'Content-Type': 'application/json',
66
+ },
67
+ body: JSON.stringify(body),
68
+ signal,
69
+ });
70
+
71
+ const raw = await res.text();
72
+ let json;
73
+ try {
74
+ json = JSON.parse(raw);
75
+ } catch (e) {
76
+ // OpenRouter may return SSE streaming format even when stream:false is set.
77
+ // Detect and parse SSE "data: {...}" lines, merging them into one response.
78
+ if (raw.trimStart().startsWith('data:')) {
79
+ console.log(` ℹ️ Received SSE stream response, parsing chunks…`);
80
+ json = this._parseSSE(raw);
81
+ }
82
+ if (!json) {
83
+ console.error(` ❌ OpenRouter parse error: status=${res.status}, body length=${raw.length}, first 300 chars: ${raw.substring(0, 300)}`);
84
+ throw new Error(`Failed to parse OpenRouter response (status ${res.status}, ${raw.length} bytes)`);
85
+ }
86
+ }
87
+
88
+ if (!res.ok) {
89
+ const err = new Error(json?.error?.message || `HTTP ${res.status}`);
90
+ err.status = res.status;
91
+ throw err;
92
+ }
93
+
94
+ return json;
95
+ }
96
+
97
+ /**
98
+ * Parse SSE (Server-Sent Events) stream into a merged chat completion object.
99
+ * OpenRouter may return SSE format unexpectedly for image generation requests.
100
+ */
101
+ _parseSSE(raw) {
102
+ const lines = raw.split('\n');
103
+ let merged = null;
104
+
105
+ for (const line of lines) {
106
+ const trimmed = line.trim();
107
+ if (!trimmed.startsWith('data:')) continue;
108
+ const payload = trimmed.slice(5).trim();
109
+ if (payload === '[DONE]') break;
110
+
111
+ try {
112
+ const chunk = JSON.parse(payload);
113
+ if (!merged) {
114
+ // Use the first chunk as the base structure
115
+ merged = chunk;
116
+ // SSE chunks use "delta" instead of "message" in choices
117
+ if (merged.choices?.[0]?.delta && !merged.choices[0].message) {
118
+ merged.choices[0].message = { ...merged.choices[0].delta };
119
+ }
120
+ } else {
121
+ // Merge subsequent delta content into the message
122
+ const delta = chunk.choices?.[0]?.delta;
123
+ if (delta && merged.choices?.[0]?.message) {
124
+ const msg = merged.choices[0].message;
125
+ if (delta.content) {
126
+ msg.content = (msg.content || '') + delta.content;
127
+ }
128
+ if (delta.images) {
129
+ msg.images = (msg.images || []).concat(delta.images);
130
+ }
131
+ }
132
+ }
133
+ } catch (parseErr) {
134
+ // Skip malformed chunks
135
+ continue;
136
+ }
137
+ }
138
+
139
+ return merged;
140
+ }
141
+
142
+ /**
143
+ * Extract base64 data from API response.
144
+ * Checks both `images` array and `content` field.
145
+ */
146
+ _extractBase64(content, images) {
147
+ // Check `images` array (OpenRouter Gemini models return data here)
148
+ if (Array.isArray(images)) {
149
+ for (const item of images) {
150
+ if (item.type === 'image_url' && item.image_url?.url) {
151
+ const url = item.image_url.url;
152
+ const match = url.match(/^data:image\/[^;]+;base64,(.+)$/s);
153
+ if (match) return match[1];
154
+ return url;
155
+ }
156
+ }
157
+ }
158
+
159
+ // Content is array with image_url objects
160
+ if (Array.isArray(content)) {
161
+ for (const item of content) {
162
+ if (item.type === 'image_url' && item.image_url?.url) {
163
+ const url = item.image_url.url;
164
+ const match = url.match(/^data:image\/[^;]+;base64,(.+)$/s);
165
+ if (match) return match[1];
166
+ return url;
167
+ }
168
+ }
169
+ }
170
+
171
+ // Content is a string data URI
172
+ if (typeof content === 'string') {
173
+ const match = content.match(/^data:image\/[^;]+;base64,(.+)$/s);
174
+ if (match) return match[1];
175
+ }
176
+
177
+ throw new Error('Unable to extract image data from API response');
178
+ }
179
+ }
180
+
181
+ // ─── ImageGenerator ─────────────────────────────────────────────────────────
182
+
183
+ class ImageGenerator {
184
+ /**
185
+ * @param {OpenRouterClient} client
186
+ * @param {{ primaryModel: string, fallbackModels: string[] }} config
187
+ */
188
+ constructor(client, config) {
189
+ this.client = client;
190
+ this.primaryModel = config.primaryModel;
191
+ this.fallbackModels = config.fallbackModels || [];
192
+ }
193
+
194
+ /**
195
+ * Generate an image. Tries primary model, then fallbacks.
196
+ * @param {string} prompt
197
+ * @param {AbortSignal} [signal]
198
+ * @returns {Promise<{ imageBase64: string, model: string }>}
199
+ */
200
+ async generate(prompt, signal) {
201
+ const models = [this.primaryModel, ...this.fallbackModels];
202
+
203
+ for (const model of models) {
204
+ try {
205
+ console.log(` 🎨 Trying model: ${model}`);
206
+ const imageBase64 = await this.client.generateImage(prompt, model, signal);
207
+ return { imageBase64, model };
208
+ } catch (err) {
209
+ // Propagate cancellation immediately
210
+ if (err.name === 'AbortError' || err.message === 'Request cancelled') {
211
+ throw new Error('Request cancelled');
212
+ }
213
+ console.warn(` ⚠️ Model ${model} failed: ${err.message}`);
214
+ // Fall through to try next model
215
+ }
216
+ }
217
+
218
+ throw new Error(`All models failed: [${models.join(', ')}]`);
219
+ }
220
+ }
221
+
222
+ // ─── Factory ────────────────────────────────────────────────────────────────
223
+
224
+ /**
225
+ * Create an ImageGenerator from environment variables.
226
+ */
227
+ function createImageGenerator() {
228
+ const apiKey = process.env.OPENROUTER_API_KEY;
229
+ if (!apiKey) {
230
+ throw new Error('OPENROUTER_API_KEY is required. Set it in admin/.env');
231
+ }
232
+
233
+ const baseUrl = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
234
+ const primaryModel = process.env.OPENROUTER_IMAGE_MODEL || 'google/gemini-3.1-flash-image-preview';
235
+ const fallbackModelsRaw = process.env.OPENROUTER_IMAGE_FALLBACK_MODELS || 'google/gemini-2.5-flash-image';
236
+ const fallbackModels = fallbackModelsRaw.split(',').map(m => m.trim()).filter(Boolean);
237
+
238
+ const client = new OpenRouterClient({ apiKey, baseUrl });
239
+ return new ImageGenerator(client, { primaryModel, fallbackModels });
240
+ }
241
+
242
+ module.exports = { OpenRouterClient, ImageGenerator, createImageGenerator };