@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,332 @@
1
+ /**
2
+ * Clarity.js Build Bundler
3
+ *
4
+ * Compiles all .clarity files in a directory and produces a production bundle
5
+ * with tree-shaking, dead-code elimination, and minification.
6
+ *
7
+ * Strategy
8
+ * ─────────
9
+ * 1. Compile every .clarity → .js using the Clarity compiler
10
+ * 2. Bundle with esbuild (if available) or produce a concatenated
11
+ * plain-JS bundle (zero-dep fallback)
12
+ * 3. In production mode, minify with esbuild's built-in minifier
13
+ * 4. Output a single dist/bundle.js (+ optional source map)
14
+ *
15
+ * esbuild is an optional peer dependency:
16
+ * npm install --save-dev esbuild
17
+ *
18
+ * Without esbuild the bundler still works — it compiles + concatenates files
19
+ * with an ES-module shim for environments that support <script type="module">.
20
+ *
21
+ * CLI (via clarity cli):
22
+ * clarity bundle <entry> [--out dist/] [--minify] [--sourcemap] [--watch]
23
+ *
24
+ * Programmatic:
25
+ * import { bundle } from '@ozsarman/clarityjs/bundle'
26
+ * await bundle({ entry: './examples/demo/main.js', outdir: './dist' })
27
+ *
28
+ * Author: Claude (Anthropic)
29
+ */
30
+
31
+ import fs from 'fs';
32
+ import path from 'path';
33
+ import { compile } from './index.js';
34
+
35
+ const C = {
36
+ reset: '\x1b[0m', green: '\x1b[32m', yellow: '\x1b[33m',
37
+ red: '\x1b[31m', cyan: '\x1b[36m', gray: '\x1b[90m', bold: '\x1b[1m',
38
+ };
39
+
40
+ function log(color, label, msg) {
41
+ const ts = new Date().toLocaleTimeString('en', { hour12: false });
42
+ console.log(`${C.gray}${ts}${C.reset} ${color}${label}${C.reset} ${msg}`);
43
+ }
44
+
45
+ // ─── Step 1: Compile .clarity files to .js ───────────────────────────────────
46
+ /**
47
+ * Compile all .clarity files found in a directory tree.
48
+ * Returns a Map of { outputPath → compiledCode }.
49
+ *
50
+ * @param {string} dir Root directory to scan
51
+ * @param {object} opts Compile options (runtimePath, sourceMap)
52
+ * @returns {{ compiled: Map<string,string>, errors: {file,message}[] }}
53
+ */
54
+ export function compileDir(dir, opts = {}) {
55
+ const compiled = new Map();
56
+ const errors = [];
57
+
58
+ function scan(d) {
59
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
60
+ const full = path.join(d, entry.name);
61
+ if (entry.isDirectory()) { scan(full); continue; }
62
+ if (!entry.name.endsWith('.clarity')) continue;
63
+
64
+ try {
65
+ const src = fs.readFileSync(full, 'utf8');
66
+ const outPath = full.replace(/\.clarity$/, '.js');
67
+ const { code } = compile(src, {
68
+ filename: full,
69
+ sourceFile: full,
70
+ outputFile: outPath,
71
+ runtimePath: opts.runtimePath ?? './clarity-runtime.js',
72
+ routerPath: opts.routerPath ?? './clarity-router.js',
73
+ sourceMap: opts.sourceMap ?? false,
74
+ ...opts,
75
+ });
76
+ compiled.set(outPath, code);
77
+ log(C.green, '✓ compiled', path.relative(dir, full));
78
+ } catch (err) {
79
+ errors.push({ file: full, message: err.message });
80
+ log(C.red, '✗ error', `${path.relative(dir, full)}: ${err.message}`);
81
+ }
82
+ }
83
+ }
84
+
85
+ scan(dir);
86
+ return { compiled, errors };
87
+ }
88
+
89
+ // ─── Step 2: Write compiled .js files ────────────────────────────────────────
90
+ /**
91
+ * Write all compiled files to disk (replaces .clarity → .js in-place).
92
+ */
93
+ export function writeCompiled(compiled) {
94
+ for (const [outPath, code] of compiled) {
95
+ fs.writeFileSync(outPath, code, 'utf8');
96
+ }
97
+ }
98
+
99
+ // ─── Step 3: Bundle with esbuild ─────────────────────────────────────────────
100
+ /**
101
+ * Bundle an entry-point file with esbuild.
102
+ * esbuild is an optional peer dep — gracefully falls back if absent.
103
+ *
104
+ * @param {object} opts
105
+ * @param {string} opts.entry Entry-point JS file (after clarity compile)
106
+ * @param {string} opts.outfile Output bundle path (default: dist/bundle.js)
107
+ * @param {boolean} opts.minify Minify output (default: false)
108
+ * @param {boolean} opts.sourcemap Emit source map (default: false)
109
+ * @param {string} opts.target JS target (default: 'es2020')
110
+ * @param {string[]} opts.external External package names (default: [])
111
+ * @returns {Promise<{ok: boolean, outfile: string, fallback?: boolean}>}
112
+ */
113
+ export async function bundleWithEsbuild(opts = {}) {
114
+ const {
115
+ entry,
116
+ outfile = 'dist/bundle.js',
117
+ minify = false,
118
+ sourcemap = false,
119
+ target = 'es2020',
120
+ external = [],
121
+ } = opts;
122
+
123
+ // Ensure output directory exists
124
+ fs.mkdirSync(path.dirname(outfile), { recursive: true });
125
+
126
+ // Try esbuild
127
+ try {
128
+ const esbuild = await import('esbuild');
129
+ log(C.cyan, '⚙ esbuild', `${entry} → ${outfile}${minify ? ' (minified)' : ''}`);
130
+
131
+ const result = await esbuild.build({
132
+ entryPoints: [entry],
133
+ bundle: true,
134
+ outfile,
135
+ minify,
136
+ sourcemap,
137
+ target,
138
+ external,
139
+ format: 'esm',
140
+ treeShaking: true,
141
+ logLevel: 'warning',
142
+ });
143
+
144
+ if (result.errors.length > 0) {
145
+ result.errors.forEach(e => log(C.red, '✗ esbuild', e.text));
146
+ return { ok: false, outfile };
147
+ }
148
+
149
+ const size = fs.statSync(outfile).size;
150
+ log(C.green, '✓ bundle', `${outfile} (${_fmtBytes(size)}${minify ? ', minified' : ''})`);
151
+ return { ok: true, outfile };
152
+ } catch (err) {
153
+ if (err.code === 'ERR_MODULE_NOT_FOUND' || err.message?.includes('Cannot find module')) {
154
+ log(C.yellow, '⚠ esbuild', 'not installed — using fallback concatenation bundler');
155
+ log(C.yellow, ' tip', 'npm install --save-dev esbuild for full tree-shaking + minification');
156
+ return bundleFallback({ entry, outfile, sourcemap });
157
+ }
158
+ throw err;
159
+ }
160
+ }
161
+
162
+ // ─── Fallback: concatenation bundler (no esbuild) ────────────────────────────
163
+ /**
164
+ * Zero-dependency fallback bundler.
165
+ * Resolves local ES module imports recursively and concatenates into one file.
166
+ * Does NOT tree-shake or minify — suitable for development or small projects.
167
+ */
168
+ export async function bundleFallback({ entry, outfile = 'dist/bundle.js', sourcemap = false } = {}) {
169
+ fs.mkdirSync(path.dirname(outfile), { recursive: true });
170
+ const visited = new Set();
171
+ const chunks = [];
172
+
173
+ function collect(filePath) {
174
+ const abs = path.resolve(filePath);
175
+ if (visited.has(abs)) return;
176
+ visited.add(abs);
177
+
178
+ let code;
179
+ try {
180
+ code = fs.readFileSync(abs, 'utf8');
181
+ } catch (_) {
182
+ log(C.yellow, '⚠ skip', `cannot read ${abs}`);
183
+ return;
184
+ }
185
+
186
+ // Resolve local imports first (depth-first)
187
+ const importRe = /^import\s+.*?from\s+['"](\.[^'"]+)['"]/gm;
188
+ let m;
189
+ while ((m = importRe.exec(code)) !== null) {
190
+ const importPath = m[1];
191
+ const resolved = path.resolve(path.dirname(abs), importPath);
192
+ // Try with extension variants
193
+ for (const ext of ['', '.js', '.mjs', '/index.js']) {
194
+ if (fs.existsSync(resolved + ext)) {
195
+ collect(resolved + ext);
196
+ break;
197
+ }
198
+ }
199
+ }
200
+
201
+ // Strip import declarations (already collected above) and export keywords
202
+ const stripped = code
203
+ .replace(/^import\s+.*?from\s+['"][^'"]+['"];?\s*\n?/gm, '')
204
+ .replace(/^export\s+default\s+/gm, 'var _default_export = ')
205
+ .replace(/^export\s+\{[^}]*\};?\s*\n?/gm, '')
206
+ .replace(/^export\s+(function|class|const|let|var)\s+/gm, '$1 ');
207
+
208
+ chunks.push(`/* ${path.relative(process.cwd(), abs)} */\n${stripped}`);
209
+ }
210
+
211
+ collect(entry);
212
+ const bundle = chunks.join('\n\n');
213
+ fs.writeFileSync(outfile, bundle, 'utf8');
214
+ const size = fs.statSync(outfile).size;
215
+ log(C.green, '✓ bundle', `${outfile} (${_fmtBytes(size)}, fallback — no tree-shaking)`);
216
+ return { ok: true, outfile, fallback: true };
217
+ }
218
+
219
+ // ─── Main bundle() orchestrator ──────────────────────────────────────────────
220
+ /**
221
+ * Full build pipeline:
222
+ * 1. Compile all .clarity files in the project directory
223
+ * 2. Write compiled .js files
224
+ * 3. Bundle the entry-point with esbuild (or fallback)
225
+ * 4. Optionally watch for changes and rebuild
226
+ *
227
+ * @param {object} opts
228
+ * @param {string} opts.entry Entry-point .js file (e.g. './examples/demo/main.js')
229
+ * @param {string} opts.projectDir Directory containing .clarity sources (default: cwd)
230
+ * @param {string} opts.outfile Bundle output path (default: 'dist/bundle.js')
231
+ * @param {boolean} opts.minify Minify (default: false in dev, true in prod)
232
+ * @param {boolean} opts.sourcemap Emit source map (default: true)
233
+ * @param {boolean} opts.watch Re-bundle on file changes (default: false)
234
+ * @param {string} opts.mode 'development' | 'production' (default: 'development')
235
+ * @returns {Promise<{ ok: boolean, outfile: string, errors: [] }>}
236
+ */
237
+ export async function bundle(opts = {}) {
238
+ const {
239
+ entry,
240
+ projectDir = process.cwd(),
241
+ outfile = 'dist/bundle.js',
242
+ minify = (opts.mode === 'production'),
243
+ sourcemap = true,
244
+ watch = false,
245
+ mode = 'development',
246
+ } = opts;
247
+
248
+ if (!entry) throw new Error('[Clarity Bundle] opts.entry is required');
249
+
250
+ const startMs = Date.now();
251
+ console.log(`\n${C.bold}${C.cyan}Clarity.js Build${C.reset} (${mode})\n`);
252
+
253
+ // ── Phase 1: Compile .clarity files ──────────────────────────────────────
254
+ const { compiled, errors } = compileDir(projectDir, { sourceMap: sourcemap && mode !== 'production' });
255
+ writeCompiled(compiled);
256
+ console.log('');
257
+
258
+ if (errors.length > 0 && compiled.size === 0) {
259
+ console.error(`${C.red}Build failed — ${errors.length} compile error(s)${C.reset}`);
260
+ return { ok: false, outfile, errors };
261
+ }
262
+
263
+ // ── Phase 2: Bundle ───────────────────────────────────────────────────────
264
+ const result = await bundleWithEsbuild({ entry, outfile, minify, sourcemap, target: 'es2020' });
265
+
266
+ const elapsed = Date.now() - startMs;
267
+ if (result.ok) {
268
+ console.log(`\n${C.green}✓ Build complete${C.reset} in ${elapsed}ms`);
269
+ if (errors.length > 0) {
270
+ console.log(`${C.yellow}⚠ ${errors.length} compile error(s) — bundle may be incomplete${C.reset}`);
271
+ }
272
+ }
273
+
274
+ // ── Phase 3: Watch mode ───────────────────────────────────────────────────
275
+ if (watch) {
276
+ log(C.cyan, '◆ watching', projectDir);
277
+ try {
278
+ fs.watch(projectDir, { recursive: true }, async (event, filename) => {
279
+ if (!filename) return;
280
+ if (filename.endsWith('.clarity') || filename.endsWith('.js') && !filename.endsWith('.bundle.js')) {
281
+ log(C.yellow, '◆ changed', filename);
282
+ // Incremental compile + re-bundle
283
+ if (filename.endsWith('.clarity')) {
284
+ const full = path.join(projectDir, filename);
285
+ try {
286
+ const src = fs.readFileSync(full, 'utf8');
287
+ const outPath = full.replace(/\.clarity$/, '.js');
288
+ const { code } = compile(src, { filename: full, runtimePath: './clarity-runtime.js' });
289
+ fs.writeFileSync(outPath, code);
290
+ log(C.green, '✓ compiled', filename);
291
+ } catch (e) {
292
+ log(C.red, '✗ error', e.message);
293
+ return;
294
+ }
295
+ }
296
+ await bundleWithEsbuild({ entry, outfile, minify, sourcemap, target: 'es2020' });
297
+ }
298
+ });
299
+ } catch (_) {
300
+ log(C.yellow, '⚠ watch', 'recursive watch not available in this environment');
301
+ }
302
+ }
303
+
304
+ return { ok: result.ok, outfile, errors };
305
+ }
306
+
307
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
308
+ function _fmtBytes(bytes) {
309
+ if (bytes < 1024) return bytes + ' B';
310
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
311
+ return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
312
+ }
313
+
314
+ // ─── CLI entry (when called directly) ────────────────────────────────────────
315
+ // Usage: node src/clarity-bundle.js <entry> [--out dist/bundle.js] [--minify] [--watch]
316
+ if (import.meta.url === new URL(process.argv[1], 'file://').href) {
317
+ const args = process.argv.slice(2);
318
+ const entry = args.find(a => !a.startsWith('--'));
319
+ const outfile = args[args.indexOf('--out') + 1] ?? 'dist/bundle.js';
320
+ const minify = args.includes('--minify');
321
+ const watch = args.includes('--watch');
322
+ const mode = minify ? 'production' : 'development';
323
+
324
+ if (!entry) {
325
+ console.error('Usage: node clarity-bundle.js <entry.js> [--out dist/bundle.js] [--minify] [--watch]');
326
+ process.exit(1);
327
+ }
328
+
329
+ bundle({ entry, outfile, minify, watch, mode, sourcemap: true })
330
+ .then(({ ok }) => { if (!ok && !watch) process.exit(1); })
331
+ .catch(err => { console.error(err); process.exit(1); });
332
+ }