@masumdev/markforge 0.2.5 → 0.4.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.
@@ -0,0 +1,893 @@
1
+ #!/usr/bin/env node
2
+ try {
3
+ if (typeof globalThis !== "undefined" && (!globalThis.localStorage || typeof globalThis.localStorage.getItem !== "function")) {
4
+ Object.defineProperty(globalThis, "localStorage", {
5
+ value: { getItem: () => null, setItem: () => {}, removeItem: () => {}, clear: () => {}, key: () => null, length: 0 },
6
+ configurable: true, writable: true,
7
+ });
8
+ }
9
+ } catch {}
10
+ import {
11
+ buildDocxDocument,
12
+ buildHtmlDocument,
13
+ buildPdfDocument,
14
+ parseMarkdownDocument
15
+ } from "./chunk-CRV7R2BG.mjs";
16
+ import {
17
+ loadConfig
18
+ } from "./chunk-BPHDY6VB.mjs";
19
+ import "./chunk-YZHGBG4N.mjs";
20
+
21
+ // src/server/previewServer.ts
22
+ import * as http from "http";
23
+ import * as fs from "fs";
24
+ import * as path from "path";
25
+ async function startPreviewServer(options) {
26
+ const absoluteFilePath = path.resolve(process.cwd(), options.filePath);
27
+ if (!fs.existsSync(absoluteFilePath)) {
28
+ throw new Error(`MarkForge preview error: File not found at "${absoluteFilePath}"`);
29
+ }
30
+ const baseDir = path.dirname(absoluteFilePath);
31
+ const { config: fileConfig } = await loadConfig(void 0, baseDir);
32
+ const baseConfig = options.config || fileConfig;
33
+ const port = options.port || 3e3;
34
+ const sseClients = /* @__PURE__ */ new Set();
35
+ const broadcastReload = () => {
36
+ sseClients.forEach((client) => {
37
+ try {
38
+ client.write(`event: reload
39
+ data: ${Date.now()}
40
+
41
+ `);
42
+ } catch {
43
+ sseClients.delete(client);
44
+ }
45
+ });
46
+ };
47
+ let debounceTimer = null;
48
+ const watcher = fs.watch(baseDir, { recursive: false }, (_event, filename) => {
49
+ if (!filename) return;
50
+ const changedPath = path.resolve(baseDir, filename);
51
+ if (changedPath === absoluteFilePath || filename.includes("markforge") || filename.endsWith(".css")) {
52
+ if (debounceTimer) clearTimeout(debounceTimer);
53
+ debounceTimer = setTimeout(() => {
54
+ broadcastReload();
55
+ }, 150);
56
+ }
57
+ });
58
+ const server = http.createServer(async (req, res) => {
59
+ const url = new URL(req.url || "/", `http://localhost:${port}`);
60
+ if (url.pathname === "/events") {
61
+ res.writeHead(200, {
62
+ "Content-Type": "text/event-stream",
63
+ "Cache-Control": "no-cache, no-transform",
64
+ Connection: "keep-alive"
65
+ });
66
+ res.write(`data: connected
67
+
68
+ `);
69
+ sseClients.add(res);
70
+ req.on("close", () => {
71
+ sseClients.delete(res);
72
+ });
73
+ return;
74
+ }
75
+ if (url.pathname === "/api/file-content" && req.method === "GET") {
76
+ try {
77
+ const content = fs.readFileSync(absoluteFilePath, "utf-8");
78
+ res.writeHead(200, { "Content-Type": "application/json" });
79
+ res.end(
80
+ JSON.stringify({
81
+ content,
82
+ fileName: path.basename(absoluteFilePath),
83
+ filePath: absoluteFilePath
84
+ })
85
+ );
86
+ } catch (err) {
87
+ const msg = err instanceof Error ? err.message : String(err);
88
+ res.writeHead(500, { "Content-Type": "application/json" });
89
+ res.end(JSON.stringify({ error: msg }));
90
+ }
91
+ return;
92
+ }
93
+ if (url.pathname === "/api/save-content" && req.method === "POST") {
94
+ let body = "";
95
+ req.on("data", (chunk) => {
96
+ body += chunk;
97
+ });
98
+ req.on("end", () => {
99
+ try {
100
+ const parsed = JSON.parse(body);
101
+ if (typeof parsed.content === "string") {
102
+ fs.writeFileSync(absoluteFilePath, parsed.content, "utf-8");
103
+ broadcastReload();
104
+ res.writeHead(200, { "Content-Type": "application/json" });
105
+ res.end(JSON.stringify({ success: true, savedAt: Date.now() }));
106
+ } else {
107
+ res.writeHead(400, { "Content-Type": "application/json" });
108
+ res.end(JSON.stringify({ error: "Missing content field in request body" }));
109
+ }
110
+ } catch (err) {
111
+ const msg = err instanceof Error ? err.message : String(err);
112
+ res.writeHead(500, { "Content-Type": "application/json" });
113
+ res.end(JSON.stringify({ error: msg }));
114
+ }
115
+ });
116
+ return;
117
+ }
118
+ if (url.pathname === "/api/export" && (req.method === "GET" || req.method === "POST")) {
119
+ const format = url.searchParams.get("format") || "docx";
120
+ try {
121
+ const mdContent = fs.readFileSync(absoluteFilePath, "utf-8");
122
+ const doc = parseMarkdownDocument(mdContent);
123
+ const { config: resolvedConfig } = await loadConfig(void 0, baseDir);
124
+ const mergedConfig = { ...baseConfig, ...resolvedConfig };
125
+ const fileBase = path.basename(absoluteFilePath, path.extname(absoluteFilePath));
126
+ if (format === "docx") {
127
+ const buffer = await buildDocxDocument(doc, mergedConfig, baseDir);
128
+ res.writeHead(200, {
129
+ "Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
130
+ "Content-Disposition": `attachment; filename="${fileBase}.docx"`
131
+ });
132
+ res.end(buffer);
133
+ return;
134
+ } else if (format === "pdf") {
135
+ const buffer = await buildPdfDocument(doc, mergedConfig, baseDir);
136
+ res.writeHead(200, {
137
+ "Content-Type": "application/pdf",
138
+ "Content-Disposition": `attachment; filename="${fileBase}.pdf"`
139
+ });
140
+ res.end(buffer);
141
+ return;
142
+ } else {
143
+ const html = await buildHtmlDocument(doc, mergedConfig, baseDir);
144
+ res.writeHead(200, {
145
+ "Content-Type": "text/html; charset=utf-8",
146
+ "Content-Disposition": `attachment; filename="${fileBase}.html"`
147
+ });
148
+ res.end(html);
149
+ return;
150
+ }
151
+ } catch (err) {
152
+ const msg = err instanceof Error ? err.message : String(err);
153
+ res.writeHead(500, { "Content-Type": "text/plain" });
154
+ res.end(`Export failed: ${msg}`);
155
+ return;
156
+ }
157
+ }
158
+ if (url.pathname === "/document-content") {
159
+ try {
160
+ const mdContent = fs.readFileSync(absoluteFilePath, "utf-8");
161
+ const doc = parseMarkdownDocument(mdContent);
162
+ const { config: resolvedConfig } = await loadConfig(void 0, baseDir);
163
+ const html = await buildHtmlDocument(doc, { ...baseConfig, ...resolvedConfig }, baseDir);
164
+ const injectedScript = `
165
+ <script>
166
+ (function() {
167
+ var evtSource = new EventSource('/events');
168
+ evtSource.addEventListener('reload', function() {
169
+ var scrollPos = window.scrollY;
170
+ sessionStorage.setItem('markforge_scroll', scrollPos);
171
+ window.location.reload();
172
+ });
173
+ window.addEventListener('load', function() {
174
+ var saved = sessionStorage.getItem('markforge_scroll');
175
+ if (saved) {
176
+ window.scrollTo(0, parseInt(saved, 10));
177
+ }
178
+ });
179
+ })();
180
+ </script>
181
+ `;
182
+ const finalHtml = html.replace("</body>", `${injectedScript}</body>`);
183
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
184
+ res.end(finalHtml);
185
+ } catch (err) {
186
+ const msg = err instanceof Error ? err.message : String(err);
187
+ res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
188
+ res.end(`<div style="padding:2rem;font-family:sans-serif;color:#ef4444;background:#fef2f2;border:1px solid #f87171;border-radius:8px;"><h3>MarkForge Compilation Error</h3><pre>${escapeHtml(msg)}</pre></div>`);
189
+ }
190
+ return;
191
+ }
192
+ if (url.pathname === "/" || url.pathname === "/index.html") {
193
+ const fileName = path.basename(absoluteFilePath);
194
+ const initialContent = fs.readFileSync(absoluteFilePath, "utf-8");
195
+ const appHtml = `<!DOCTYPE html>
196
+ <html lang="en">
197
+ <head>
198
+ <meta charset="UTF-8">
199
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
200
+ <title>MarkForge Live Studio - ${escapeHtml(fileName)}</title>
201
+ <link rel="preconnect" href="https://fonts.googleapis.com">
202
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
203
+ <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
204
+ <style>
205
+ :root {
206
+ --mf-primary: #0D998D;
207
+ --mf-primary-dark: #008277;
208
+ --mf-primary-light: #ECFDFD;
209
+ --mf-primary-border: #33CDCF;
210
+ --mf-dark: #0F172A;
211
+ --mf-slate: #1E293B;
212
+ --mf-editor-bg: #0F172A;
213
+ --mf-editor-gutter: #1E293B;
214
+ --mf-editor-text: #F8FAFC;
215
+ --mf-muted: #64748B;
216
+ --mf-light-border: #E2E8F0;
217
+ --mf-bg: #F1F5F9;
218
+ }
219
+ * { box-sizing: border-box; margin: 0; padding: 0; }
220
+ body {
221
+ font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
222
+ background: var(--mf-bg);
223
+ color: var(--mf-dark);
224
+ display: flex;
225
+ flex-direction: column;
226
+ height: 100vh;
227
+ overflow: hidden;
228
+ }
229
+ header {
230
+ background: #FFFFFF;
231
+ border-bottom: 1px solid var(--mf-light-border);
232
+ min-height: 56px;
233
+ display: flex;
234
+ flex-wrap: wrap;
235
+ align-items: center;
236
+ justify-content: space-between;
237
+ padding: 0.4rem 1.2rem;
238
+ z-index: 10;
239
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
240
+ gap: 0.75rem;
241
+ }
242
+ .brand-section {
243
+ display: flex;
244
+ align-items: center;
245
+ gap: 0.75rem;
246
+ }
247
+ .brand-badge {
248
+ font-size: 0.72rem;
249
+ font-weight: 800;
250
+ letter-spacing: 0.08em;
251
+ background: var(--mf-dark);
252
+ color: #FFFFFF;
253
+ padding: 0.25rem 0.55rem;
254
+ border-radius: 4px;
255
+ text-transform: uppercase;
256
+ }
257
+ .file-name {
258
+ font-size: 0.9rem;
259
+ font-weight: 700;
260
+ color: var(--mf-dark);
261
+ }
262
+ .sync-status {
263
+ display: flex;
264
+ align-items: center;
265
+ gap: 0.35rem;
266
+ font-size: 0.75rem;
267
+ font-weight: 600;
268
+ color: var(--mf-primary-dark);
269
+ background: var(--mf-primary-light);
270
+ padding: 0.2rem 0.55rem;
271
+ border-radius: 9999px;
272
+ border: 1px solid var(--mf-primary-border);
273
+ }
274
+ .sync-dot {
275
+ width: 7px;
276
+ height: 7px;
277
+ background-color: var(--mf-primary);
278
+ border-radius: 50%;
279
+ box-shadow: 0 0 0 2px rgba(13, 153, 141, 0.2);
280
+ }
281
+ .toolbar-section {
282
+ display: flex;
283
+ align-items: center;
284
+ gap: 0.3rem;
285
+ background: #F8FAFC;
286
+ padding: 0.25rem 0.4rem;
287
+ border-radius: 6px;
288
+ border: 1px solid var(--mf-light-border);
289
+ }
290
+ .tool-btn {
291
+ font-family: 'JetBrains Mono', monospace;
292
+ font-size: 0.75rem;
293
+ font-weight: 600;
294
+ padding: 0.25rem 0.45rem;
295
+ background: transparent;
296
+ border: 1px solid transparent;
297
+ border-radius: 4px;
298
+ cursor: pointer;
299
+ color: var(--mf-slate);
300
+ transition: all 0.1s ease;
301
+ }
302
+ .tool-btn:hover {
303
+ background: #FFFFFF;
304
+ border-color: var(--mf-light-border);
305
+ color: var(--mf-primary-dark);
306
+ }
307
+ .tool-divider {
308
+ width: 1px;
309
+ height: 16px;
310
+ background: var(--mf-light-border);
311
+ margin: 0 0.15rem;
312
+ }
313
+ .controls {
314
+ display: flex;
315
+ align-items: center;
316
+ gap: 0.5rem;
317
+ }
318
+ .view-toggles {
319
+ display: flex;
320
+ background: #F1F5F9;
321
+ padding: 2px;
322
+ border-radius: 6px;
323
+ border: 1px solid var(--mf-light-border);
324
+ }
325
+ .toggle-btn {
326
+ font-family: inherit;
327
+ font-size: 0.74rem;
328
+ font-weight: 600;
329
+ padding: 0.25rem 0.55rem;
330
+ border: none;
331
+ background: transparent;
332
+ border-radius: 4px;
333
+ cursor: pointer;
334
+ color: var(--mf-muted);
335
+ transition: all 0.15s ease;
336
+ }
337
+ .toggle-btn.active {
338
+ background: #FFFFFF;
339
+ color: var(--mf-dark);
340
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
341
+ }
342
+ .btn {
343
+ font-family: inherit;
344
+ font-size: 0.78rem;
345
+ font-weight: 600;
346
+ padding: 0.35rem 0.75rem;
347
+ border-radius: 6px;
348
+ cursor: pointer;
349
+ text-decoration: none;
350
+ transition: all 0.15s ease;
351
+ display: inline-flex;
352
+ align-items: center;
353
+ gap: 0.3rem;
354
+ border: 1px solid var(--mf-light-border);
355
+ background: #FFFFFF;
356
+ color: var(--mf-dark);
357
+ }
358
+ .btn:hover {
359
+ background: #F8FAFC;
360
+ border-color: #CBD5E1;
361
+ }
362
+ .btn-primary {
363
+ background: var(--mf-primary);
364
+ color: #FFFFFF;
365
+ border-color: var(--mf-primary);
366
+ }
367
+ .btn-primary:hover {
368
+ background: var(--mf-primary-dark);
369
+ border-color: var(--mf-primary-dark);
370
+ }
371
+ .save-indicator {
372
+ font-size: 0.75rem;
373
+ font-weight: 600;
374
+ color: var(--mf-muted);
375
+ min-width: 65px;
376
+ text-align: right;
377
+ }
378
+ .save-indicator.saved {
379
+ color: var(--mf-primary-dark);
380
+ }
381
+ .save-indicator.saving {
382
+ color: #D97706;
383
+ }
384
+ .save-indicator.unsaved {
385
+ color: #E11D48;
386
+ }
387
+
388
+ /* Main Workspace Splitter Layout */
389
+ main.workspace {
390
+ flex: 1;
391
+ display: flex;
392
+ height: calc(100vh - 56px);
393
+ overflow: hidden;
394
+ background: var(--mf-bg);
395
+ position: relative;
396
+ }
397
+ .editor-pane {
398
+ width: 50%;
399
+ height: 100%;
400
+ display: flex;
401
+ flex-direction: column;
402
+ background: var(--mf-editor-bg);
403
+ border-right: 1px solid #334155;
404
+ overflow: hidden;
405
+ }
406
+ .editor-header {
407
+ background: #090D16;
408
+ border-bottom: 1px solid #1E293B;
409
+ padding: 0.4rem 0.8rem;
410
+ display: flex;
411
+ align-items: center;
412
+ justify-content: space-between;
413
+ color: #94A3B8;
414
+ font-size: 0.74rem;
415
+ font-weight: 500;
416
+ }
417
+ .editor-container {
418
+ flex: 1;
419
+ display: flex;
420
+ position: relative;
421
+ overflow: hidden;
422
+ background: var(--mf-editor-bg);
423
+ }
424
+ .line-numbers {
425
+ width: 44px;
426
+ padding: 0.8rem 0.4rem;
427
+ font-family: 'JetBrains Mono', monospace;
428
+ font-size: 13px;
429
+ line-height: 1.55;
430
+ color: #475569;
431
+ text-align: right;
432
+ user-select: none;
433
+ background: var(--mf-editor-gutter);
434
+ overflow: hidden;
435
+ border-right: 1px solid #1E293B;
436
+ }
437
+ .code-editor {
438
+ flex: 1;
439
+ padding: 0.8rem 1rem;
440
+ font-family: 'JetBrains Mono', monospace;
441
+ font-size: 13px;
442
+ line-height: 1.55;
443
+ color: var(--mf-editor-text);
444
+ background: transparent;
445
+ border: none;
446
+ outline: none;
447
+ resize: none;
448
+ white-space: pre;
449
+ overflow-wrap: normal;
450
+ overflow: auto;
451
+ tab-size: 2;
452
+ }
453
+
454
+ /* Draggable Splitter Handle */
455
+ .splitter {
456
+ width: 8px;
457
+ cursor: col-resize;
458
+ background: #E2E8F0;
459
+ transition: background 0.15s ease;
460
+ position: relative;
461
+ z-index: 5;
462
+ }
463
+ .splitter:hover, .splitter.active {
464
+ background: var(--mf-primary);
465
+ }
466
+
467
+ /* Right Preview Pane */
468
+ .preview-pane {
469
+ width: 50%;
470
+ height: 100%;
471
+ display: flex;
472
+ flex-direction: column;
473
+ background: #FFFFFF;
474
+ overflow: hidden;
475
+ }
476
+ .preview-header {
477
+ background: #FFFFFF;
478
+ border-bottom: 1px solid var(--mf-light-border);
479
+ padding: 0.35rem 0.8rem;
480
+ display: flex;
481
+ align-items: center;
482
+ justify-content: space-between;
483
+ color: var(--mf-muted);
484
+ font-size: 0.74rem;
485
+ font-weight: 600;
486
+ }
487
+ .viewport-selector {
488
+ display: flex;
489
+ gap: 0.25rem;
490
+ }
491
+ .vp-btn {
492
+ font-size: 0.72rem;
493
+ padding: 0.15rem 0.4rem;
494
+ border: 1px solid var(--mf-light-border);
495
+ background: #F8FAFC;
496
+ border-radius: 4px;
497
+ cursor: pointer;
498
+ color: var(--mf-muted);
499
+ }
500
+ .vp-btn.active {
501
+ background: var(--mf-primary-light);
502
+ color: var(--mf-primary-dark);
503
+ border-color: var(--mf-primary-border);
504
+ }
505
+ .preview-wrapper {
506
+ flex: 1;
507
+ display: flex;
508
+ justify-content: center;
509
+ align-items: stretch;
510
+ background: #F1F5F9;
511
+ overflow: hidden;
512
+ }
513
+ iframe {
514
+ width: 100%;
515
+ height: 100%;
516
+ border: none;
517
+ background: #FFFFFF;
518
+ transition: max-width 0.2s ease;
519
+ }
520
+ .author-footer {
521
+ font-size: 0.72rem;
522
+ color: var(--mf-muted);
523
+ padding-right: 0.5rem;
524
+ }
525
+ .author-footer a {
526
+ color: var(--mf-primary-dark);
527
+ text-decoration: none;
528
+ font-weight: 600;
529
+ }
530
+ </style>
531
+ </head>
532
+ <body>
533
+ <header>
534
+ <div class="brand-section">
535
+ <span class="brand-badge">MARKFORGE STUDIO</span>
536
+ <span class="file-name" title="${escapeHtml(absoluteFilePath)}">${escapeHtml(fileName)}</span>
537
+ <div class="sync-status">
538
+ <div class="sync-dot"></div>
539
+ <span>Live Sync Active</span>
540
+ </div>
541
+ </div>
542
+
543
+ <!-- Quick Formatting Toolbar -->
544
+ <div class="toolbar-section">
545
+ <button class="tool-btn" onclick="insertFormat('h1')" title="Heading 1">H1</button>
546
+ <button class="tool-btn" onclick="insertFormat('h2')" title="Heading 2">H2</button>
547
+ <button class="tool-btn" onclick="insertFormat('h3')" title="Heading 3">H3</button>
548
+ <div class="tool-divider"></div>
549
+ <button class="tool-btn" onclick="insertFormat('bold')" title="Bold">B</button>
550
+ <button class="tool-btn" onclick="insertFormat('italic')" title="Italic">I</button>
551
+ <button class="tool-btn" onclick="insertFormat('code')" title="Inline Code">&lt;&gt;</button>
552
+ <button class="tool-btn" onclick="insertFormat('quote')" title="Blockquote">&gt;</button>
553
+ <div class="tool-divider"></div>
554
+ <button class="tool-btn" onclick="insertFormat('table')" title="GFM Table">Table</button>
555
+ <button class="tool-btn" onclick="insertFormat('list')" title="List">List</button>
556
+ <button class="tool-btn" onclick="insertFormat('task')" title="Task Checklist">Task</button>
557
+ <div class="tool-divider"></div>
558
+ <button class="tool-btn" onclick="insertFormat('callout')" title="Callout Box">Callout</button>
559
+ <button class="tool-btn" onclick="insertFormat('math')" title="LaTeX Math">Math</button>
560
+ <button class="tool-btn" onclick="insertFormat('columns')" title="Multi-Columns">Columns</button>
561
+ <button class="tool-btn" onclick="insertFormat('footnote')" title="Footnote">Footnote</button>
562
+ <button class="tool-btn" onclick="insertFormat('mermaid')" title="Mermaid Diagram">Mermaid</button>
563
+ </div>
564
+
565
+ <!-- Controls & View Mode -->
566
+ <div class="controls">
567
+ <div class="view-toggles">
568
+ <button class="toggle-btn active" id="btn-split" onclick="setViewMode('split')">Split</button>
569
+ <button class="toggle-btn" id="btn-edit" onclick="setViewMode('edit')">Editor</button>
570
+ <button class="toggle-btn" id="btn-prev" onclick="setViewMode('prev')">Preview</button>
571
+ </div>
572
+ <span class="save-indicator saved" id="save-status">Saved</span>
573
+ <button class="btn btn-primary" onclick="saveContentManual()" title="Save (Ctrl+S)">Save</button>
574
+ <button class="btn" onclick="exportDoc('docx')" title="Download Word Document">DOCX</button>
575
+ <button class="btn" onclick="exportDoc('pdf')" title="Download PDF Document">PDF</button>
576
+ <button class="btn" onclick="printDoc()" title="Print / PDF dialog">Print</button>
577
+ </div>
578
+ </header>
579
+
580
+ <main class="workspace" id="workspace">
581
+ <!-- Left: Code Editor Pane -->
582
+ <div class="editor-pane" id="editor-pane">
583
+ <div class="editor-header">
584
+ <span>MARKDOWN SOURCE</span>
585
+ <span id="editor-stats">Lines: 1 | Words: 0 | UTF-8</span>
586
+ </div>
587
+ <div class="editor-container">
588
+ <div class="line-numbers" id="line-numbers">1</div>
589
+ <textarea class="code-editor" id="code-editor" spellcheck="false" placeholder="Write markdown here...">${escapeHtml(initialContent)}</textarea>
590
+ </div>
591
+ </div>
592
+
593
+ <!-- Middle: Draggable Splitter Handle -->
594
+ <div class="splitter" id="splitter"></div>
595
+
596
+ <!-- Right: Rendered Preview Pane -->
597
+ <div class="preview-pane" id="preview-pane">
598
+ <div class="preview-header">
599
+ <span>RENDERED PREVIEW</span>
600
+ <div class="viewport-selector">
601
+ <button class="vp-btn active" onclick="setViewport('100%')" id="vp-full">100% Full</button>
602
+ <button class="vp-btn" onclick="setViewport('820px')" id="vp-a4">A4 (820px)</button>
603
+ <button class="vp-btn" onclick="setViewport('440px')" id="vp-mob">Mobile</button>
604
+ </div>
605
+ <span class="author-footer">Created by <a href="https://github.com/masumrpg" target="_blank">Ma'sum (@masumrpg)</a></span>
606
+ </div>
607
+ <div class="preview-wrapper">
608
+ <iframe id="preview-frame" src="/document-content"></iframe>
609
+ </div>
610
+ </div>
611
+ </main>
612
+
613
+ <script>
614
+ var editor = document.getElementById('code-editor');
615
+ var lineNumbers = document.getElementById('line-numbers');
616
+ var stats = document.getElementById('editor-stats');
617
+ var saveStatus = document.getElementById('save-status');
618
+ var previewFrame = document.getElementById('preview-frame');
619
+ var editorPane = document.getElementById('editor-pane');
620
+ var previewPane = document.getElementById('preview-pane');
621
+ var splitter = document.getElementById('splitter');
622
+ var isDirty = false;
623
+ var autoSaveTimeout = null;
624
+
625
+ // Update Line Numbers & Stats
626
+ function updateStatsAndLines() {
627
+ var lines = editor.value.split('\\n');
628
+ var lineCount = lines.length;
629
+ var numHtml = '';
630
+ for (var i = 1; i <= lineCount; i++) {
631
+ numHtml += i + '<br>';
632
+ }
633
+ lineNumbers.innerHTML = numHtml;
634
+
635
+ var words = editor.value.trim().length > 0 ? editor.value.trim().split(/\\s+/).length : 0;
636
+ var chars = editor.value.length;
637
+ stats.textContent = 'Lines: ' + lineCount + ' | Words: ' + words + ' | Chars: ' + chars + ' | UTF-8';
638
+ }
639
+
640
+ // Synchronize vertical scroll between Line Numbers and Textarea
641
+ editor.addEventListener('scroll', function() {
642
+ lineNumbers.scrollTop = editor.scrollTop;
643
+ });
644
+
645
+ // Handle Input & Debounced Auto-Save
646
+ editor.addEventListener('input', function() {
647
+ updateStatsAndLines();
648
+ setSaveState('unsaved');
649
+ if (autoSaveTimeout) clearTimeout(autoSaveTimeout);
650
+ autoSaveTimeout = setTimeout(function() {
651
+ saveContent();
652
+ }, 600);
653
+ });
654
+
655
+ function setSaveState(state) {
656
+ if (state === 'saved') {
657
+ saveStatus.textContent = 'Saved';
658
+ saveStatus.className = 'save-indicator saved';
659
+ isDirty = false;
660
+ } else if (state === 'saving') {
661
+ saveStatus.textContent = 'Saving...';
662
+ saveStatus.className = 'save-indicator saving';
663
+ } else {
664
+ saveStatus.textContent = 'Changes...';
665
+ saveStatus.className = 'save-indicator unsaved';
666
+ isDirty = true;
667
+ }
668
+ }
669
+
670
+ // Save Content via API
671
+ function saveContent(callback) {
672
+ setSaveState('saving');
673
+ fetch('/api/save-content', {
674
+ method: 'POST',
675
+ headers: { 'Content-Type': 'application/json' },
676
+ body: JSON.stringify({ content: editor.value }),
677
+ })
678
+ .then(function(res) { return res.json(); })
679
+ .then(function(data) {
680
+ if (data.success) {
681
+ setSaveState('saved');
682
+ if (callback) callback();
683
+ } else {
684
+ saveStatus.textContent = 'Save Error';
685
+ }
686
+ })
687
+ .catch(function() {
688
+ saveStatus.textContent = 'Save Error';
689
+ });
690
+ }
691
+
692
+ function saveContentManual() {
693
+ saveContent();
694
+ }
695
+
696
+ // Keyboard Shortcuts: Tab (2 spaces), Shift+Tab, Ctrl+S
697
+ editor.addEventListener('keydown', function(e) {
698
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
699
+ e.preventDefault();
700
+ saveContent();
701
+ return;
702
+ }
703
+
704
+ if (e.key === 'Tab') {
705
+ e.preventDefault();
706
+ var start = this.selectionStart;
707
+ var end = this.selectionEnd;
708
+ this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
709
+ this.selectionStart = this.selectionEnd = start + 2;
710
+ updateStatsAndLines();
711
+ setSaveState('unsaved');
712
+ if (autoSaveTimeout) clearTimeout(autoSaveTimeout);
713
+ autoSaveTimeout = setTimeout(saveContent, 600);
714
+ }
715
+ });
716
+
717
+ // Formatting Snippet Injector
718
+ function insertFormat(type) {
719
+ var start = editor.selectionStart;
720
+ var end = editor.selectionEnd;
721
+ var selected = editor.value.substring(start, end);
722
+ var replacement = '';
723
+
724
+ switch (type) {
725
+ case 'h1': replacement = '# ' + (selected || 'Heading 1'); break;
726
+ case 'h2': replacement = '## ' + (selected || 'Heading 2'); break;
727
+ case 'h3': replacement = '### ' + (selected || 'Heading 3'); break;
728
+ case 'bold': replacement = '**' + (selected || 'bold text') + '**'; break;
729
+ case 'italic': replacement = '*' + (selected || 'italic text') + '*'; break;
730
+ case 'code': replacement = '\`' + (selected || 'inline code') + '\`'; break;
731
+ case 'quote': replacement = '> ' + (selected || 'Quote text'); break;
732
+ case 'table':
733
+ replacement = '\\n| Column 1 | Column 2 | Column 3 |\\n| :--- | :---: | ---: |\\n| Data A | Data B | Data C |\\n| Data D | Data E | Data F |\\n';
734
+ break;
735
+ case 'list': replacement = '- ' + (selected || 'List item'); break;
736
+ case 'task': replacement = '- [ ] ' + (selected || 'Task item'); break;
737
+ case 'callout':
738
+ replacement = '> [!NOTE]\\n> ' + (selected || 'This is an important callout note.');
739
+ break;
740
+ case 'math':
741
+ replacement = '$$\\n' + (selected || '\\\\int_{-\\\\infty}^{\\\\infty} e^{-x^2} dx = \\\\sqrt{\\\\pi}') + '\\n$$';
742
+ break;
743
+ case 'columns':
744
+ replacement = ':::columns 2\\n:::col\\n### Left Column\\n' + (selected || 'Content on the left.') + '\\n:::\\n:::col\\n### Right Column\\nContent on the right.\\n:::\\n:::';
745
+ break;
746
+ case 'footnote':
747
+ replacement = (selected || 'Statement with footnote') + '[^1]\\n\\n[^1]: Note description text.';
748
+ break;
749
+ case 'mermaid':
750
+ replacement = '\\n\`\`\`mermaid\\ngraph TD\\n A[Start] --> B(Process)\\n B --> C{Decision}\\n C -->|Yes| D[Done]\\n C -->|No| B\\n\`\`\`\\n';
751
+ break;
752
+ }
753
+
754
+ editor.value = editor.value.substring(0, start) + replacement + editor.value.substring(end);
755
+ editor.selectionStart = editor.selectionEnd = start + replacement.length;
756
+ editor.focus();
757
+ updateStatsAndLines();
758
+ setSaveState('unsaved');
759
+ if (autoSaveTimeout) clearTimeout(autoSaveTimeout);
760
+ autoSaveTimeout = setTimeout(saveContent, 600);
761
+ }
762
+
763
+ // View Mode Toggle (Split / Editor Only / Preview Only)
764
+ function setViewMode(mode) {
765
+ document.getElementById('btn-split').classList.remove('active');
766
+ document.getElementById('btn-edit').classList.remove('active');
767
+ document.getElementById('btn-prev').classList.remove('active');
768
+
769
+ if (mode === 'split') {
770
+ document.getElementById('btn-split').classList.add('active');
771
+ editorPane.style.display = 'flex';
772
+ editorPane.style.width = '50%';
773
+ previewPane.style.display = 'flex';
774
+ previewPane.style.width = '50%';
775
+ splitter.style.display = 'block';
776
+ } else if (mode === 'edit') {
777
+ document.getElementById('btn-edit').classList.add('active');
778
+ editorPane.style.display = 'flex';
779
+ editorPane.style.width = '100%';
780
+ previewPane.style.display = 'none';
781
+ splitter.style.display = 'none';
782
+ } else if (mode === 'prev') {
783
+ document.getElementById('btn-prev').classList.add('active');
784
+ editorPane.style.display = 'none';
785
+ previewPane.style.display = 'flex';
786
+ previewPane.style.width = '100%';
787
+ splitter.style.display = 'none';
788
+ }
789
+ }
790
+
791
+ // Viewport Width Resizer
792
+ function setViewport(width) {
793
+ document.getElementById('vp-full').classList.remove('active');
794
+ document.getElementById('vp-a4').classList.remove('active');
795
+ document.getElementById('vp-mob').classList.remove('active');
796
+
797
+ if (width === '100%') {
798
+ document.getElementById('vp-full').classList.add('active');
799
+ previewFrame.style.maxWidth = '100%';
800
+ } else if (width === '820px') {
801
+ document.getElementById('vp-a4').classList.add('active');
802
+ previewFrame.style.maxWidth = '820px';
803
+ } else if (width === '440px') {
804
+ document.getElementById('vp-mob').classList.add('active');
805
+ previewFrame.style.maxWidth = '440px';
806
+ }
807
+ }
808
+
809
+ // Draggable Splitter Handle Logic
810
+ var isDragging = false;
811
+ splitter.addEventListener('mousedown', function(e) {
812
+ isDragging = true;
813
+ splitter.classList.add('active');
814
+ document.body.style.cursor = 'col-resize';
815
+ document.body.style.userSelect = 'none';
816
+ });
817
+
818
+ window.addEventListener('mousemove', function(e) {
819
+ if (!isDragging) return;
820
+ var totalWidth = document.getElementById('workspace').clientWidth;
821
+ var newEditorWidth = (e.clientX / totalWidth) * 100;
822
+ if (newEditorWidth > 15 && newEditorWidth < 85) {
823
+ editorPane.style.width = newEditorWidth + '%';
824
+ previewPane.style.width = (100 - newEditorWidth) + '%';
825
+ }
826
+ });
827
+
828
+ window.addEventListener('mouseup', function() {
829
+ if (isDragging) {
830
+ isDragging = false;
831
+ splitter.classList.remove('active');
832
+ document.body.style.cursor = '';
833
+ document.body.style.userSelect = '';
834
+ }
835
+ });
836
+
837
+ // Document Print Action
838
+ function printDoc() {
839
+ previewFrame.contentWindow.print();
840
+ }
841
+
842
+ // Document Export Action
843
+ function exportDoc(format) {
844
+ saveContent(function() {
845
+ window.location.href = '/api/export?format=' + format;
846
+ });
847
+ }
848
+
849
+ // Initialize line stats
850
+ updateStatsAndLines();
851
+ </script>
852
+ </body>
853
+ </html>`;
854
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
855
+ res.end(appHtml);
856
+ return;
857
+ }
858
+ res.writeHead(404, { "Content-Type": "text/plain" });
859
+ res.end("Not Found");
860
+ });
861
+ return new Promise((resolve2, reject) => {
862
+ server.listen(port, () => {
863
+ const url = `http://localhost:${port}`;
864
+ resolve2({
865
+ server,
866
+ port,
867
+ url,
868
+ close: async () => {
869
+ watcher.close();
870
+ sseClients.forEach((client) => {
871
+ try {
872
+ client.end();
873
+ } catch {
874
+ }
875
+ });
876
+ sseClients.clear();
877
+ return new Promise((res) => {
878
+ server.close(() => res());
879
+ });
880
+ }
881
+ });
882
+ });
883
+ server.on("error", (err) => {
884
+ reject(err);
885
+ });
886
+ });
887
+ }
888
+ function escapeHtml(str) {
889
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
890
+ }
891
+ export {
892
+ startPreviewServer
893
+ };