@ozsarman/clarityjs 0.6.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,362 @@
1
+ /**
2
+ * Clarity.js Development Server
3
+ *
4
+ * Zero-config dev server for .clarity projects. Features:
5
+ * - Serves static files from the project directory
6
+ * - Watches .clarity files for changes and auto-recompiles
7
+ * - Live reload via Server-Sent Events (SSE) — no WebSocket needed
8
+ * - Injects a tiny client script that reconnects and reloads on change
9
+ * - Serves compiled .clarity files as on-the-fly .js responses
10
+ * - Color-coded console output with timestamps
11
+ *
12
+ * Usage:
13
+ * clarity dev [dir] [--port 3000]
14
+ *
15
+ * Author: Claude (Anthropic)
16
+ */
17
+
18
+ import http from 'http';
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import { compile } from './index.js';
22
+
23
+ // ─── MIME types ───────────────────────────────────────────────────────────────
24
+ const MIME = {
25
+ '.html': 'text/html; charset=utf-8',
26
+ '.js': 'application/javascript; charset=utf-8',
27
+ '.mjs': 'application/javascript; charset=utf-8',
28
+ '.css': 'text/css; charset=utf-8',
29
+ '.json': 'application/json; charset=utf-8',
30
+ '.svg': 'image/svg+xml',
31
+ '.png': 'image/png',
32
+ '.ico': 'image/x-icon',
33
+ '.woff2':'font/woff2',
34
+ '.clarity': 'application/javascript; charset=utf-8', // compiled on the fly
35
+ };
36
+
37
+ // ─── Dev client script (injected into HTML responses) ────────────────────────
38
+ //
39
+ // True HMR — when a .clarity file changes the server recompiles it and sends the
40
+ // new module code via SSE. The client imports the new module as a Blob URL and
41
+ // calls any registered accept handlers. If no handler is registered for that
42
+ // path, it falls back to a full location.reload().
43
+ //
44
+ // Usage in your main.js (optional, for zero-reload HMR):
45
+ //
46
+ // window.__clarity_hmr?.accept('/my-app/App.clarity', (newModule) => {
47
+ // unmount(); // unmount old root
48
+ // mount(newModule.default, document.getElementById('app'));
49
+ // });
50
+ //
51
+ // Without registering an accept handler the client still hot-reloads the page,
52
+ // which is already faster than a cold browser reload because the server has
53
+ // already recompiled before sending.
54
+ const DEV_CLIENT = `
55
+ <script>
56
+ /* Clarity.js HMR client — auto-injected, remove in production */
57
+ window.__clarity_hmr = window.__clarity_hmr || {
58
+ _handlers: new Map(),
59
+ accept(path, fn) { this._handlers.set(path, fn); },
60
+ };
61
+
62
+ (function() {
63
+ let retries = 0;
64
+ function connect() {
65
+ const es = new EventSource('/__clarity_hmr__');
66
+
67
+ // Module-level HMR: server sends compiled code + path
68
+ es.addEventListener('module-update', async (e) => {
69
+ try {
70
+ const { path, code } = JSON.parse(e.data);
71
+ const blob = new Blob([code], { type: 'application/javascript' });
72
+ const url = URL.createObjectURL(blob);
73
+ const mod = await import(url);
74
+ URL.revokeObjectURL(url);
75
+
76
+ const handler = window.__clarity_hmr._handlers.get(path);
77
+ if (handler) {
78
+ console.log('[Clarity HMR] ♻️ Hot-updating', path);
79
+ handler(mod);
80
+ } else {
81
+ console.log('[Clarity HMR] 🔄 No accept handler for', path, '— reloading');
82
+ location.reload();
83
+ }
84
+ } catch (err) {
85
+ console.error('[Clarity HMR] Failed to apply update:', err);
86
+ location.reload();
87
+ }
88
+ });
89
+
90
+ // Fallback: CSS / HTML changes → full reload
91
+ es.addEventListener('reload', () => {
92
+ console.log('[Clarity HMR] 🔄 Asset changed — reloading…');
93
+ location.reload();
94
+ });
95
+
96
+ es.addEventListener('error', () => {
97
+ es.close();
98
+ if (retries++ < 30) setTimeout(connect, 1000);
99
+ });
100
+ es.addEventListener('open', () => { retries = 0; });
101
+ }
102
+ connect();
103
+ })();
104
+ </script>
105
+ `;
106
+
107
+ // ─── SSE client registry ──────────────────────────────────────────────────────
108
+ const _sseClients = new Set();
109
+
110
+ function _broadcast(event, data = '') {
111
+ const msg = `event: ${event}\ndata: ${data}\n\n`;
112
+ for (const res of _sseClients) {
113
+ try { res.write(msg); } catch (_) { _sseClients.delete(res); }
114
+ }
115
+ }
116
+
117
+ // ─── Logger ───────────────────────────────────────────────────────────────────
118
+ const C = {
119
+ reset: '\x1b[0m',
120
+ green: '\x1b[32m',
121
+ yellow: '\x1b[33m',
122
+ cyan: '\x1b[36m',
123
+ red: '\x1b[31m',
124
+ gray: '\x1b[90m',
125
+ bold: '\x1b[1m',
126
+ };
127
+
128
+ function log(color, label, msg) {
129
+ const ts = new Date().toLocaleTimeString('en', { hour12: false });
130
+ console.log(`${C.gray}${ts}${C.reset} ${color}${label}${C.reset} ${msg}`);
131
+ }
132
+
133
+ // ─── File watcher ────────────────────────────────────────────────────────────
134
+ function watchDir(dir) {
135
+ // Use fs.watch recursively where supported (Node 18+ on Linux/macOS)
136
+ try {
137
+ fs.watch(dir, { recursive: true }, (event, filename) => {
138
+ if (!filename) return;
139
+ if (filename.endsWith('.clarity')) {
140
+ _handleClarityChange(path.join(dir, filename), filename, dir);
141
+ } else if (filename.endsWith('.css') || filename.endsWith('.html')) {
142
+ log(C.yellow, '◆ changed', filename);
143
+ _broadcast('reload');
144
+ }
145
+ });
146
+ log(C.cyan, '◆ watching', dir);
147
+ } catch (_) {
148
+ // Fallback: poll every 500ms
149
+ log(C.yellow, '◆ watch', 'recursive watch not available — falling back to polling');
150
+ const mtimes = new Map();
151
+ setInterval(() => _scanMtimes(dir, mtimes, dir), 500);
152
+ }
153
+ }
154
+
155
+ // Recompile a changed .clarity file and broadcast the new module code via SSE.
156
+ // This lets the HMR client hot-swap just the changed module without a full reload.
157
+ function _handleClarityChange(fullPath, filename, rootDir) {
158
+ log(C.yellow, '◆ changed', filename);
159
+ try {
160
+ const source = fs.readFileSync(fullPath, 'utf8');
161
+ const urlPath = '/' + path.relative(rootDir, fullPath).replace(/\\/g, '/');
162
+ const { code } = compile(source, {
163
+ filename: fullPath,
164
+ runtimePath: '/clarity-runtime.js',
165
+ routerPath: '/clarity-router.js',
166
+ sourceMap: true,
167
+ });
168
+ _broadcast('module-update', JSON.stringify({ path: urlPath, code }));
169
+ log(C.green, '♻ HMR', filename);
170
+ } catch (err) {
171
+ log(C.red, '✗ error', `${filename} — ${err.message}`);
172
+ // On compile error, send a reload so the browser shows the error overlay
173
+ _broadcast('reload');
174
+ }
175
+ }
176
+
177
+ function _scanMtimes(dir, mtimes, rootDir) {
178
+ try {
179
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
180
+ const full = path.join(dir, entry.name);
181
+ if (entry.isDirectory()) {
182
+ _scanMtimes(full, mtimes, rootDir);
183
+ } else if (entry.name.endsWith('.clarity')) {
184
+ const mtime = fs.statSync(full).mtimeMs;
185
+ if (mtimes.has(full) && mtimes.get(full) !== mtime) {
186
+ _handleClarityChange(full, entry.name, rootDir);
187
+ }
188
+ mtimes.set(full, mtime);
189
+ } else if (entry.name.endsWith('.css') || entry.name.endsWith('.html')) {
190
+ const mtime = fs.statSync(full).mtimeMs;
191
+ if (mtimes.has(full) && mtimes.get(full) !== mtime) {
192
+ log(C.yellow, '◆ changed', entry.name);
193
+ _broadcast('reload');
194
+ }
195
+ mtimes.set(full, mtime);
196
+ }
197
+ }
198
+ } catch (_) { /* skip unreadable dirs */ }
199
+ }
200
+
201
+ // ─── Request handler ──────────────────────────────────────────────────────────
202
+ function _handleRequest(req, res, rootDir) {
203
+ const url = new URL(req.url, 'http://localhost');
204
+
205
+ // SSE endpoint — keep connection open for live reload events
206
+ if (url.pathname === '/__clarity_hmr__') {
207
+ res.writeHead(200, {
208
+ 'Content-Type': 'text/event-stream',
209
+ 'Cache-Control': 'no-cache',
210
+ 'Connection': 'keep-alive',
211
+ 'Access-Control-Allow-Origin': '*',
212
+ });
213
+ res.write('event: connected\ndata: ok\n\n');
214
+ _sseClients.add(res);
215
+ req.on('close', () => _sseClients.delete(res));
216
+ return;
217
+ }
218
+
219
+ // Resolve file path
220
+ let filePath = path.join(rootDir, url.pathname);
221
+
222
+ // Directory → try index.html
223
+ try {
224
+ const stat = fs.statSync(filePath);
225
+ if (stat.isDirectory()) filePath = path.join(filePath, 'index.html');
226
+ } catch (_) { /* file doesn't exist — handle below */ }
227
+
228
+ const ext = path.extname(filePath).toLowerCase();
229
+
230
+ // .clarity files → compile on the fly and serve as JS
231
+ if (ext === '.clarity') {
232
+ try {
233
+ const source = fs.readFileSync(filePath, 'utf8');
234
+ const { code } = compile(source, {
235
+ filename: filePath,
236
+ runtimePath: '/clarity-runtime.js',
237
+ routerPath: '/clarity-router.js',
238
+ sourceMap: true,
239
+ });
240
+ res.writeHead(200, { 'Content-Type': MIME['.js'] });
241
+ res.end(code);
242
+ log(C.green, '✓ compiled', url.pathname);
243
+ } catch (err) {
244
+ const errMsg = `// Clarity compile error: ${err.message}\nconsole.error(${JSON.stringify(err.message)});`;
245
+ res.writeHead(200, { 'Content-Type': MIME['.js'] });
246
+ res.end(errMsg);
247
+ log(C.red, '✗ error', `${url.pathname} — ${err.message}`);
248
+ }
249
+ return;
250
+ }
251
+
252
+ // Serve runtime and router from the clarity-js package itself
253
+ if (url.pathname === '/clarity-runtime.js') {
254
+ _serveFile(res, new URL('../src/runtime.js', import.meta.url).pathname, '.js');
255
+ return;
256
+ }
257
+ if (url.pathname === '/clarity-router.js') {
258
+ try {
259
+ const routerPath = new URL('../src/router.js', import.meta.url).pathname;
260
+ let content = fs.readFileSync(routerPath, 'utf8');
261
+ // Rewrite the relative runtime import so it resolves correctly in the browser.
262
+ // src/router.js says: import { signal, effect } from './runtime.js'
263
+ // In the browser context that would resolve to /runtime.js (404).
264
+ // We rewrite it to the absolute dev-server path instead.
265
+ content = content.replace(/from ['"]\.\/runtime\.js['"]/g, "from '/clarity-runtime.js'");
266
+ res.writeHead(200, { 'Content-Type': MIME['.js'] });
267
+ res.end(content);
268
+ } catch (err) {
269
+ res.writeHead(500);
270
+ res.end(`// could not read router: ${err.message}`);
271
+ }
272
+ return;
273
+ }
274
+
275
+ // Regular static file
276
+ try {
277
+ let content = fs.readFileSync(filePath);
278
+
279
+ // Inject dev client into HTML files
280
+ if (ext === '.html') {
281
+ content = content.toString('utf8').replace('</body>', `${DEV_CLIENT}\n</body>`);
282
+ }
283
+
284
+ res.writeHead(200, { 'Content-Type': MIME[ext] ?? 'application/octet-stream' });
285
+ res.end(content);
286
+ log(C.gray, '→', url.pathname);
287
+ } catch (_) {
288
+ // 404
289
+ res.writeHead(404, { 'Content-Type': 'text/html' });
290
+ res.end(`<!DOCTYPE html><html><body>
291
+ <h1>404 — Not Found</h1>
292
+ <p><code>${url.pathname}</code> does not exist in <code>${rootDir}</code></p>
293
+ <p><a href="/">← Back</a></p>
294
+ </body></html>`);
295
+ log(C.yellow, '404', url.pathname);
296
+ }
297
+ }
298
+
299
+ function _serveFile(res, filePath, ext) {
300
+ try {
301
+ const content = fs.readFileSync(filePath, 'utf8');
302
+ res.writeHead(200, { 'Content-Type': MIME[ext] ?? 'application/javascript' });
303
+ res.end(content);
304
+ } catch (err) {
305
+ res.writeHead(500);
306
+ res.end(`// could not read file: ${err.message}`);
307
+ }
308
+ }
309
+
310
+ // ─── Public API ───────────────────────────────────────────────────────────────
311
+
312
+ /**
313
+ * Start the Clarity dev server.
314
+ *
315
+ * @param {object} opts
316
+ * @param {string} opts.dir — directory to serve (default: cwd)
317
+ * @param {number} opts.port — port (default: 3000)
318
+ * @param {boolean} opts.open — open browser on start
319
+ */
320
+ export function startDevServer({ dir = process.cwd(), port = 3000, open = false } = {}) {
321
+ const rootDir = path.resolve(dir);
322
+
323
+ const server = http.createServer((req, res) => {
324
+ _handleRequest(req, res, rootDir);
325
+ });
326
+
327
+ server.listen(port, () => {
328
+ console.log('');
329
+ console.log(` ${C.bold}${C.cyan}Clarity.js Dev Server${C.reset}`);
330
+ console.log(` ${C.green}➜${C.reset} Local: ${C.cyan}http://localhost:${port}${C.reset}`);
331
+ console.log(` ${C.green}➜${C.reset} Root: ${C.gray}${rootDir}${C.reset}`);
332
+ console.log(` ${C.green}➜${C.reset} Press ${C.bold}Ctrl+C${C.reset} to stop`);
333
+ console.log('');
334
+
335
+ watchDir(rootDir);
336
+
337
+ if (open) {
338
+ // Try to open browser cross-platform
339
+ const opener = process.platform === 'darwin' ? 'open'
340
+ : process.platform === 'win32' ? 'start'
341
+ : 'xdg-open';
342
+ import('child_process').then(({ exec }) => exec(`${opener} http://localhost:${port}`));
343
+ }
344
+ });
345
+
346
+ server.on('error', (err) => {
347
+ if (err.code === 'EADDRINUSE') {
348
+ console.error(`${C.red}✗ Port ${port} is already in use.${C.reset} Try --port ${port + 1}`);
349
+ } else {
350
+ console.error(`${C.red}✗ Server error:${C.reset}`, err.message);
351
+ }
352
+ process.exit(1);
353
+ });
354
+
355
+ // Graceful shutdown
356
+ process.on('SIGINT', () => {
357
+ console.log(`\n${C.yellow}◆ Shutting down…${C.reset}`);
358
+ server.close(() => process.exit(0));
359
+ });
360
+
361
+ return server;
362
+ }