@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.
package/src/typegen.js ADDED
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Clarity.js TypeScript Declaration Generator
3
+ *
4
+ * Produces .d.ts files from a Clarity AST.
5
+ * This gives IDE autocomplete, error checking, and documentation
6
+ * for Clarity components used in TypeScript projects.
7
+ *
8
+ * Generated types include:
9
+ * - Component function signatures with prop types
10
+ * - Signal types for all state/computed declarations
11
+ * - AI contract annotations as JSDoc
12
+ * - Lifecycle hook presence as documentation
13
+ *
14
+ * Author: Claude (Anthropic)
15
+ */
16
+
17
+ // ─── Type Inference ───────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Infer a TypeScript type from a Clarity literal/expression node.
21
+ * We can't always know the type statically, so we fall back to 'unknown'.
22
+ */
23
+ function inferType(node) {
24
+ if (!node) return 'unknown';
25
+
26
+ switch (node.type) {
27
+ case 'Literal':
28
+ if (typeof node.value === 'number') return 'number';
29
+ if (typeof node.value === 'string') return 'string';
30
+ if (typeof node.value === 'boolean') return 'boolean';
31
+ if (node.value === null) return 'null';
32
+ return 'unknown';
33
+
34
+ case 'ArrayExpr':
35
+ if (node.elements.length === 0) return 'unknown[]';
36
+ // Infer from first element
37
+ return `${inferType(node.elements[0])}[]`;
38
+
39
+ case 'ObjectExpr':
40
+ if (node.properties.length === 0) return 'Record<string, unknown>';
41
+ const props = node.properties.map(p => `${p.key}: ${inferType(p.value)}`).join('; ');
42
+ return `{ ${props} }`;
43
+
44
+ case 'BinaryExpr':
45
+ // Arithmetic → number, comparison → boolean, string concat → string
46
+ if (['+', '-', '*', '/', '%'].includes(node.op)) return 'number';
47
+ if (['===', '!==', '==', '!=', '<', '>', '<=', '>=', '&&', '||'].includes(node.op)) return 'boolean';
48
+ return 'unknown';
49
+
50
+ case 'TernaryExpr':
51
+ return inferType(node.then);
52
+
53
+ case 'Ident':
54
+ // Can't know the type of an identifier without tracking scope
55
+ return 'unknown';
56
+
57
+ case 'CallExpr':
58
+ return 'unknown';
59
+
60
+ default:
61
+ return 'unknown';
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Convert a Clarity param type annotation to a TypeScript type.
67
+ * Clarity uses: String, Number, Boolean, Array, Object
68
+ */
69
+ function mapParamType(clarityType) {
70
+ if (!clarityType) return 'unknown';
71
+ const map = {
72
+ 'String': 'string',
73
+ 'Number': 'number',
74
+ 'Boolean': 'boolean',
75
+ 'Array': 'unknown[]',
76
+ 'Object': 'Record<string, unknown>',
77
+ 'Function': '(...args: unknown[]) => unknown',
78
+ 'Any': 'unknown',
79
+ 'Node': 'HTMLElement | null',
80
+ 'Signal': 'Signal<unknown>',
81
+ 'Computed': 'Computed<unknown>',
82
+ 'Context': 'ClarityContext<unknown>',
83
+ };
84
+ return map[clarityType] ?? clarityType;
85
+ }
86
+
87
+ /**
88
+ * Map a Clarity type annotation string that may include generics.
89
+ * e.g. "Signal<Number>" → "Signal<number>"
90
+ */
91
+ function mapParamTypeGeneric(clarityType) {
92
+ if (!clarityType) return 'unknown';
93
+ // Handle generics: Signal<String> → Signal<string>
94
+ const generic = clarityType.match(/^(\w+)<(.+)>$/);
95
+ if (generic) {
96
+ const outer = mapParamType(generic[1]);
97
+ const inner = mapParamTypeGeneric(generic[2]);
98
+ return `${outer.replace('unknown', '')}${outer.includes('<') ? inner : `<${inner}>`}`;
99
+ }
100
+ return mapParamType(clarityType);
101
+ }
102
+
103
+ // ─── Type Generator ───────────────────────────────────────────────────────────
104
+
105
+ export class TypeGenerator {
106
+ constructor(options = {}) {
107
+ this.runtimePath = options.runtimePath ?? '@ozsarman/clarityjs/src/runtime.js';
108
+ }
109
+
110
+ /**
111
+ * Generate a complete .d.ts file from the AST.
112
+ */
113
+ generate(ast, options = {}) {
114
+ const { filename = '<anonymous>' } = options;
115
+
116
+ const lines = [];
117
+
118
+ // Header
119
+ lines.push(`// Type declarations generated by Clarity.js compiler v0.3`);
120
+ lines.push(`// Source: ${filename}`);
121
+ lines.push(`// DO NOT EDIT — edit the .clarity source file instead`);
122
+ lines.push('');
123
+
124
+ // Import Signal/Computed/Context types from runtime
125
+ lines.push(`import type { Signal, Computed } from '${this.runtimePath}';`);
126
+ lines.push('');
127
+ // ClarityContext type — inline since it's small
128
+ lines.push(`/** Context token created by createContext() */`);
129
+ lines.push(`interface ClarityContext<T> { id: symbol; _type: 'context'; _default: T; }`);
130
+ lines.push('');
131
+
132
+ // One declaration block per component
133
+ for (const comp of ast.components) {
134
+ lines.push(...this._genComponentTypes(comp));
135
+ lines.push('');
136
+ }
137
+
138
+ return lines.join('\n');
139
+ }
140
+
141
+ _genComponentTypes(comp) {
142
+ const lines = [];
143
+ const stateDecls = comp.body.filter(n => n.type === 'StateDecl');
144
+ const computedDecls = comp.body.filter(n => n.type === 'ComputedDecl');
145
+ const effectDecls = comp.body.filter(n => n.type === 'EffectDecl');
146
+ const aiDecls = comp.body.filter(n => n.type === 'AIDecl');
147
+ const beforeMount = comp.body.some(n => n.type === 'BeforeMountBlock');
148
+ const onMount = comp.body.some(n => n.type === 'OnMountBlock');
149
+ const onCleanup = comp.body.some(n => n.type === 'OnCleanupBlock');
150
+
151
+ // ── Generic type parameter string e.g. "<T, U extends string>" ──
152
+ const typeParams = comp.typeParams ?? [];
153
+ const typeParamStr = typeParams.length > 0
154
+ ? `<${typeParams.map(p => p.constraint ? `${p.name} extends ${p.constraint}` : p.name).join(', ')}>`
155
+ : '';
156
+ // For the Props interface name we use <T> without constraints: e.g. ListProps<T>
157
+ const typeParamRef = typeParams.length > 0
158
+ ? `<${typeParams.map(p => p.name).join(', ')}>`
159
+ : '';
160
+
161
+ // ── Props interface ──
162
+ const propsName = `${comp.name}Props`;
163
+ // Always emit a Props interface (even for zero-param components) so
164
+ // consumers can extend it and for consistent tooling output.
165
+ lines.push(`export interface ${propsName}${typeParamStr} {`);
166
+ for (const param of comp.params) {
167
+ const tsType = mapParamTypeGeneric(param.type);
168
+ // A param with a default value is optional; without default it is required.
169
+ const hasDefault = param.default !== undefined && param.default !== null;
170
+ const optional = hasDefault ? '?' : '';
171
+ // Inline JSDoc from inferred type
172
+ const inferredComment = param.type ? `` : ` // inferred: ${inferType(param.default)}`;
173
+ lines.push(` ${param.name}${optional}: ${tsType};${inferredComment}`);
174
+ }
175
+ lines.push(` /** Children passed as <${comp.name}>...</${comp.name}> */`);
176
+ lines.push(` children?: Node | Node[];`);
177
+ lines.push(`}`);
178
+ lines.push('');
179
+
180
+ // ── AI Contract (as JSDoc) ──
181
+ const jsdocLines = [];
182
+ if (beforeMount) jsdocLines.push(` * @lifecycle beforeMount — runs after render, before DOM insertion`);
183
+ if (onMount) jsdocLines.push(` * @lifecycle onMount — runs after DOM insertion`);
184
+ if (onCleanup) jsdocLines.push(` * @lifecycle onCleanup — runs on unmount, disposes all effects`);
185
+ for (const ai of aiDecls) {
186
+ jsdocLines.push(` * @ai:${ai.capability} [${ai.targets.join(', ')}]`);
187
+ }
188
+ if (typeParams.length > 0) {
189
+ for (const tp of typeParams) {
190
+ const constraint = tp.constraint ? ` (extends ${tp.constraint})` : '';
191
+ jsdocLines.push(` * @typeParam ${tp.name}${constraint}`);
192
+ }
193
+ }
194
+
195
+ if (jsdocLines.length > 0) {
196
+ lines.push(`/**`);
197
+ lines.push(` * ${comp.name} component`);
198
+ jsdocLines.forEach(l => lines.push(l));
199
+ lines.push(` */`);
200
+ }
201
+
202
+ // ── Component function — with generics if declared ──
203
+ const propsRef = `${propsName}${typeParamRef}`;
204
+ lines.push(`export declare function ${comp.name}${typeParamStr}(props?: ${propsRef}): HTMLElement;`);
205
+ lines.push(`export default ${comp.name}${typeParamStr};`);
206
+ lines.push('');
207
+
208
+ // ── Internal signal types (useful for testing / advanced tooling) ──
209
+ if (stateDecls.length > 0 || computedDecls.length > 0) {
210
+ lines.push(`/** Internal signals exposed for testing and AI tooling */`);
211
+ lines.push(`export interface ${comp.name}Internals {`);
212
+
213
+ for (const s of stateDecls) {
214
+ const t = inferType(s.init);
215
+ lines.push(` /** state ${s.name} = ${t} */`);
216
+ lines.push(` ${s.name}: Signal<${t}>;`);
217
+ }
218
+ for (const c of computedDecls) {
219
+ const t = inferType(c.expr);
220
+ lines.push(` /** computed ${c.name} */`);
221
+ lines.push(` ${c.name}: Computed<${t}>;`);
222
+ }
223
+
224
+ // AI contract in internals
225
+ for (const ai of aiDecls) {
226
+ lines.push(` /** ai:${ai.capability} [${ai.targets.join(', ')}] */`);
227
+ lines.push(` __ai_${ai.capability}__: readonly string[];`);
228
+ }
229
+
230
+ lines.push(`}`);
231
+ }
232
+
233
+ return lines;
234
+ }
235
+ }
236
+
237
+ // ─── Convenience export ───────────────────────────────────────────────────────
238
+ export function generateTypes(ast, options = {}) {
239
+ return new TypeGenerator(options).generate(ast, options);
240
+ }
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Clarity.js Vite Plugin — with Stateful HMR + Route-level Code Splitting
3
+ *
4
+ * Integrates .clarity files into Vite. On file change:
5
+ * 1. Recompiles the .clarity file
6
+ * 2. Uses Vite's HMR API to hot-swap the module
7
+ * 3. Re-mounts components PRESERVING their signal state
8
+ *
9
+ * Stateful HMR strategy:
10
+ * - Before hot swap: snapshot all signal values from mounted components
11
+ * - After swap: re-mount with the snapshot values as initial state
12
+ * - This means state survives code changes — only the template/logic updates
13
+ *
14
+ * Route-level code splitting (opt-in via codeSplitting: true):
15
+ * - Files in pages/ are automatically placed in separate Rollup chunks
16
+ * - Each route chunk is named route-<slug>.js for human-readable filenames
17
+ * - A route manifest (clarity-route-manifest.json) is emitted to disk
18
+ * - <link rel="modulepreload"> tags are injected for the current route chunk
19
+ * - Link hover → prefetch via import() before the user even clicks
20
+ *
21
+ * Usage in vite.config.js:
22
+ * import clarityPlugin from '@ozsarman/clarityjs/vite'
23
+ * export default { plugins: [clarityPlugin({ codeSplitting: true, pagesDir: 'pages' })] }
24
+ *
25
+ * Author: Claude (Anthropic)
26
+ */
27
+
28
+ import { compile } from './index.js';
29
+ import { extractStyles, scopeCSS, injectScopeAttr, collectStyles, cssModules } from './scoped-css.js';
30
+ import { filePathToRoutePattern, routeScore } from './pages-router.js';
31
+ import { resolve, relative, join, basename } from 'node:path';
32
+ import { existsSync, readdirSync, statSync } from 'node:fs';
33
+
34
+ // ─── virtual:clarity-pages ────────────────────────────────────────────────────
35
+ const VIRTUAL_PAGES_ID = 'virtual:clarity-pages';
36
+ const RESOLVED_VIRTUAL_PAGES = '\0' + VIRTUAL_PAGES_ID;
37
+
38
+ // Special page files that are NOT routes (conventions / layout).
39
+ const PAGE_SPECIAL_BASENAMES = new Set(['loading', 'error', 'not-found', '_layout']);
40
+
41
+ function _isSpecialPageFile(file) {
42
+ const base = basename(file).replace(/\.(clarity|jsx?|mjs|tsx?)$/i, '');
43
+ return PAGE_SPECIAL_BASENAMES.has(base) || base.startsWith('_');
44
+ }
45
+
46
+ /** Find a convention file (loading/error/not-found) at the pages root, if any. */
47
+ function _findConvention(pagesAbs, name) {
48
+ for (const ext of ['clarity', 'js', 'mjs', 'ts', 'tsx', 'jsx']) {
49
+ const p = join(pagesAbs, `${name}.${ext}`);
50
+ if (existsSync(p)) return p;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Generate the source of the `virtual:clarity-pages` module: a lazy, code-split
57
+ * route table plus a ready-to-mount `Routes` component.
58
+ */
59
+ function _generatePagesModule(pagesAbs, runtimePagesImport = '@ozsarman/clarityjs/pages-router') {
60
+ if (!existsSync(pagesAbs)) {
61
+ return `import { createPagesRouter } from '${runtimePagesImport}';\n` +
62
+ `export const routes = [];\n` +
63
+ `export const Routes = createPagesRouter({ routes });\n` +
64
+ `export default Routes;\n`;
65
+ }
66
+
67
+ const files = _scanPageFiles(pagesAbs).filter(f => !_isSpecialPageFile(f));
68
+
69
+ // Build route entries (sorted most-specific first for deterministic output).
70
+ const entries = files
71
+ .map(file => ({ file, rel: relative(pagesAbs, file).replace(/\\/g, '/') }))
72
+ .map(e => ({ ...e, pattern: filePathToRoutePattern(e.rel) }))
73
+ .sort((a, b) => routeScore(b.pattern) - routeScore(a.pattern));
74
+
75
+ const routeLines = entries.map(e =>
76
+ ` { pattern: ${JSON.stringify(e.pattern)}, file: ${JSON.stringify(e.rel)}, ` +
77
+ `load: () => import(${JSON.stringify(e.file)}) },`
78
+ ).join('\n');
79
+
80
+ // Convention components (eager — they are small and shown synchronously).
81
+ const loadingFile = _findConvention(pagesAbs, 'loading');
82
+ const errorFile = _findConvention(pagesAbs, 'error');
83
+ const notFoundFile = _findConvention(pagesAbs, 'not-found');
84
+
85
+ const imports = [`import { createPagesRouter } from '${runtimePagesImport}';`];
86
+ const opts = ['routes'];
87
+ if (loadingFile) { imports.push(`import _Loading from ${JSON.stringify(loadingFile)};`); opts.push('loading: _Loading'); }
88
+ if (errorFile) { imports.push(`import _Error from ${JSON.stringify(errorFile)};`); opts.push('error: _Error'); }
89
+ if (notFoundFile) { imports.push(`import _NotFound from ${JSON.stringify(notFoundFile)};`); opts.push('notFound: _NotFound'); }
90
+
91
+ return [
92
+ '// Generated by clarity-js Vite plugin (scanPages) — do not edit.',
93
+ ...imports,
94
+ '',
95
+ 'export const routes = [',
96
+ routeLines,
97
+ '];',
98
+ '',
99
+ `export const Routes = createPagesRouter({ ${opts.join(', ')} });`,
100
+ 'export default Routes;',
101
+ '',
102
+ ].join('\n');
103
+ }
104
+
105
+ // ─── HMR client code (appended to every compiled .clarity module) ─────────────
106
+ // This runs in the browser and wires up Vite's HMR callbacks.
107
+ const HMR_CLIENT_RUNTIME = `
108
+ // ── Clarity Stateful HMR ────────────────────────────────────────────────────
109
+ if (import.meta.hot) {
110
+ // Registry: maps component name → { element, unmount, props }
111
+ // Populated by the patched mount() call below.
112
+ const _hmrRegistry = new Map();
113
+
114
+ // Intercept mount() to track all mounted components
115
+ const _origMount = typeof mount !== 'undefined' ? mount : null;
116
+ if (_origMount) {
117
+ globalThis.__clarity_hmr_mount__ = function(ComponentFn, container, props) {
118
+ const unmount = _origMount(ComponentFn, container, props);
119
+ const name = ComponentFn.name || ComponentFn.displayName || 'Unknown';
120
+ _hmrRegistry.set(name, { ComponentFn, container, props: props ?? {}, unmount });
121
+ return unmount;
122
+ };
123
+ }
124
+
125
+ import.meta.hot.accept((newModule) => {
126
+ if (!newModule) return;
127
+
128
+ for (const [name, entry] of _hmrRegistry) {
129
+ const NewComponent = newModule[name] ?? newModule.default;
130
+ if (!NewComponent) continue;
131
+
132
+ // 1. Snapshot current AI-readable state (if any)
133
+ let snapshot = {};
134
+ try {
135
+ const rootEl = entry.container.firstElementChild;
136
+ if (rootEl?.__clarity_ai__) snapshot = rootEl.__clarity_ai__.snapshot();
137
+ } catch (_) {}
138
+
139
+ // 2. Unmount the old component
140
+ try { entry.unmount(); } catch (_) {}
141
+
142
+ // 3. Re-mount the new component — merge snapshot into props as initial values
143
+ const newProps = { ...entry.props, ...snapshot };
144
+ const unmount = (globalThis.__clarity_hmr_mount__ ?? _origMount)(NewComponent, entry.container, newProps);
145
+
146
+ // 4. Update registry with new component fn and unmount handle
147
+ _hmrRegistry.set(name, { ...entry, ComponentFn: NewComponent, unmount });
148
+
149
+ console.log(\`[Clarity HMR] 🔥 \${name} updated (state preserved)\`);
150
+ }
151
+ });
152
+
153
+ import.meta.hot.dispose(() => {
154
+ for (const { unmount } of _hmrRegistry.values()) {
155
+ try { unmount(); } catch (_) {}
156
+ }
157
+ _hmrRegistry.clear();
158
+ });
159
+ }
160
+ // ────────────────────────────────────────────────────────────────────────────
161
+ `;
162
+
163
+ // ─── Plugin ───────────────────────────────────────────────────────────────────
164
+ /**
165
+ * @param {object} opts
166
+ * @param {string} opts.runtimePath — what the compiled JS imports from (default: '@ozsarman/clarityjs/runtime')
167
+ * @param {string} opts.routerPath — what the compiled JS imports router from (default: '@ozsarman/clarityjs/router')
168
+ * @param {boolean} opts.sourceMap — emit source maps (default: follows Vite mode)
169
+ * @param {boolean} opts.types — emit .d.ts on build (default: false)
170
+ * @param {boolean} opts.hmr — enable stateful HMR (default: true)
171
+ * @param {boolean} opts.verbose — log compile times (default: false)
172
+ * @param {boolean} opts.codeSplitting — split pages/ into separate route chunks (default: false)
173
+ * @param {string} opts.pagesDir — relative path to pages directory (default: 'pages')
174
+ */
175
+ export default function clarityPlugin(opts = {}) {
176
+ const {
177
+ runtimePath = '@ozsarman/clarityjs/runtime',
178
+ routerPath = '@ozsarman/clarityjs/router',
179
+ pagesRouterPath = '@ozsarman/clarityjs/pages-router',
180
+ types = false,
181
+ hmr = true,
182
+ verbose = false,
183
+ codeSplitting = false,
184
+ scanPages = false,
185
+ pagesDir = 'pages',
186
+ } = opts;
187
+
188
+ // Will be set in configResolved
189
+ let _root = process.cwd();
190
+ let _pagesAbs = '';
191
+ let _ws = null; // Vite WebSocket server (set in configureServer)
192
+ // Map from absolute page file path → chunk name (e.g. 'route-about')
193
+ const _routeChunks = new Map();
194
+
195
+ return {
196
+ name: '@ozsarman/clarityjs',
197
+ enforce: 'pre', // run before other transforms
198
+
199
+ // ── Config: inject manualChunks for pages/ ────────────────────────────────
200
+ config(config, { command }) {
201
+ if (!codeSplitting || command !== 'build') return;
202
+
203
+ const root = config.root ?? process.cwd();
204
+ const pgDir = resolve(root, pagesDir);
205
+ if (!existsSync(pgDir)) return;
206
+
207
+ // Pre-scan pages directory to build chunk map
208
+ const pageFiles = _scanPageFiles(pgDir);
209
+ for (const file of pageFiles) {
210
+ const rel = relative(pgDir, file).replace(/\\/g, '/').replace(/\.(js|mjs|clarity)$/, '');
211
+ const chunkName = 'route-' + rel.replace(/\//g, '-').replace(/[\[\]]/g, '').replace(/^-+|-+$/g, '') || 'route-index';
212
+ _routeChunks.set(file, chunkName);
213
+ }
214
+
215
+ // Inject manualChunks — merges with any user-provided value
216
+ config.build ??= {};
217
+ config.build.rollupOptions ??= {};
218
+ config.build.rollupOptions.output ??= {};
219
+
220
+ const userManual = config.build.rollupOptions.output.manualChunks;
221
+ config.build.rollupOptions.output.manualChunks = (id) => {
222
+ // Respect user override first
223
+ if (typeof userManual === 'function') {
224
+ const u = userManual(id);
225
+ if (u) return u;
226
+ }
227
+ // Our route chunks
228
+ const chunkName = _routeChunks.get(id);
229
+ if (chunkName) return chunkName;
230
+ return null;
231
+ };
232
+ },
233
+
234
+ // ── ConfigResolved: capture final root ───────────────────────────────────
235
+ configResolved(config) {
236
+ _root = config.root;
237
+ _pagesAbs = resolve(_root, pagesDir);
238
+ },
239
+
240
+ // ── ConfigureServer: capture WS + watch pages/ for add/remove ────────────
241
+ configureServer(server) {
242
+ _ws = server.ws;
243
+ if (!scanPages) return;
244
+
245
+ // When a page file is added or removed, the route table changes, so the
246
+ // virtual module must be regenerated. Editing a page's contents is handled
247
+ // by normal .clarity HMR and does not need invalidation here.
248
+ const invalidatePages = (file) => {
249
+ if (!file.startsWith(_pagesAbs)) return;
250
+ const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_PAGES);
251
+ if (mod) {
252
+ server.moduleGraph.invalidateModule(mod);
253
+ server.ws.send({ type: 'full-reload', path: '*' });
254
+ if (verbose) console.log('[clarity] pages changed → route table reloaded');
255
+ }
256
+ };
257
+ server.watcher.on('add', invalidatePages);
258
+ server.watcher.on('unlink', invalidatePages);
259
+ server.watcher.on('addDir', invalidatePages);
260
+ server.watcher.on('unlinkDir', invalidatePages);
261
+ },
262
+
263
+ // ── Resolve + load the virtual pages module ──────────────────────────────
264
+ resolveId(id) {
265
+ if (scanPages && id === VIRTUAL_PAGES_ID) return RESOLVED_VIRTUAL_PAGES;
266
+ return null;
267
+ },
268
+
269
+ load(id) {
270
+ if (scanPages && id === RESOLVED_VIRTUAL_PAGES) {
271
+ return _generatePagesModule(_pagesAbs, pagesRouterPath);
272
+ }
273
+ return null;
274
+ },
275
+
276
+ // ── Transform .clarity files ──────────────────────────────────────────────
277
+ transform(rawSource, id) {
278
+ if (!id.endsWith('.clarity')) return null;
279
+
280
+ const t0 = Date.now();
281
+
282
+ // Is this a dev server build? Vite sets this.environment.mode
283
+ const isDev = this.environment?.mode !== 'production';
284
+
285
+ // ── 1. Extract <style scoped>, <style module>, and <style> blocks ───────
286
+ const { scoped, global: globalCSS, module: moduleCSS, scopeId, source } = extractStyles(rawSource, id);
287
+
288
+ let result;
289
+ try {
290
+ result = compile(source, {
291
+ filename: id,
292
+ runtimePath,
293
+ routerPath,
294
+ sourceMap: isDev,
295
+ types,
296
+ scopeId, // passed to CodeGenerator so root element gets data-c-{scopeId}
297
+ });
298
+ } catch (err) {
299
+ // Broadcast rich error to the Clarity error overlay (dev only)
300
+ if (_ws) {
301
+ _ws.send({
302
+ type: 'custom',
303
+ event: 'clarity:error',
304
+ data: {
305
+ type: 'compiler',
306
+ message: err.message,
307
+ file: id,
308
+ line: err.line,
309
+ col: err.col,
310
+ frame: err.frame ?? null,
311
+ plugin: '@ozsarman/clarityjs',
312
+ },
313
+ });
314
+ }
315
+ // Also surface as a Vite build error (shows in terminal + Vite overlay)
316
+ this.error({
317
+ message: err.message,
318
+ id,
319
+ loc: err.line ? { line: err.line, column: err.col ?? 0 } : undefined,
320
+ });
321
+ return null;
322
+ }
323
+
324
+ if (verbose) {
325
+ console.log(`[clarity] compiled ${id.split('/').pop()} in ${Date.now() - t0}ms`);
326
+ }
327
+
328
+ // ── 2. Inject scope attribute onto root element if scoped CSS exists ──
329
+ let code = scoped.trim() ? injectScopeAttr(result.code, scopeId) : result.code;
330
+
331
+ // ── 2b. CSS Modules — process <style module> and export classMap ──────
332
+ let moduleCSSTransformed = '';
333
+ let classMap = {};
334
+ if (moduleCSS.trim()) {
335
+ const modResult = cssModules(moduleCSS, scopeId);
336
+ moduleCSSTransformed = modResult.css;
337
+ classMap = modResult.classMap;
338
+ // Export the class map as a named export `styles`
339
+ code += `\nexport const styles = ${JSON.stringify(classMap)};\n`;
340
+ }
341
+
342
+ // ── 3. Append scoped + module + global CSS as an injected <style> tag ──
343
+ // In production builds, the CSS is collected and emitted as a file.
344
+ if (scoped.trim() || globalCSS.trim() || moduleCSSTransformed) {
345
+ const css = [
346
+ globalCSS.trim(),
347
+ scopeCSS(scoped, scopeId),
348
+ moduleCSSTransformed,
349
+ ].filter(Boolean).join('\n');
350
+
351
+ if (isDev) {
352
+ // Inject into <head> at runtime via a JS snippet
353
+ const escaped = css.replace(/`/g, '\\`').replace(/\$/g, '\\$');
354
+ code += `\n// <style> injection (Clarity dev)\n` +
355
+ `(function(){` +
356
+ ` const _s = document.createElement('style');` +
357
+ ` _s.setAttribute('data-clarity-style', '${scopeId}');` +
358
+ ` _s.textContent = \`${escaped}\`;` +
359
+ ` document.head.appendChild(_s);` +
360
+ `})();\n`;
361
+ } else {
362
+ // In production, store for generateBundle to collect into one CSS file
363
+ result._clarityCSS = css;
364
+ }
365
+ }
366
+
367
+ // In dev mode, append the HMR client runtime so Vite can hot-swap modules
368
+ if (isDev && hmr) {
369
+ code += HMR_CLIENT_RUNTIME;
370
+ }
371
+
372
+ return {
373
+ code,
374
+ map: result.map ?? null,
375
+ _clarityDts: result.dts,
376
+ _clarityCSS: result._clarityCSS,
377
+ };
378
+ },
379
+
380
+ // ── Generate .d.ts + route manifest (build only) ─────────────────────────
381
+ generateBundle(options, bundle) {
382
+ // ── TypeScript declarations ─────────────────────────────────────────────
383
+ if (types) {
384
+ for (const [fileName, chunk] of Object.entries(bundle)) {
385
+ if (chunk.type !== 'chunk' || !chunk.facadeModuleId?.endsWith('.clarity')) continue;
386
+ const dts = chunk._clarityDts;
387
+ if (dts) {
388
+ this.emitFile({
389
+ type: 'asset',
390
+ fileName: fileName.replace(/\.js$/, '.d.ts'),
391
+ source: dts,
392
+ });
393
+ }
394
+ }
395
+ }
396
+
397
+ // ── Route manifest for modulepreload + prefetch ─────────────────────────
398
+ if (codeSplitting) {
399
+ const manifest = {};
400
+ for (const [fileName, chunk] of Object.entries(bundle)) {
401
+ if (chunk.type !== 'chunk') continue;
402
+ const facadeId = chunk.facadeModuleId ?? '';
403
+ if (!facadeId) continue;
404
+
405
+ const chunkName = _routeChunks.get(facadeId);
406
+ if (!chunkName) continue;
407
+
408
+ // Derive route path from chunkName: 'route-users-id' → '/users/:id'
409
+ const routePath = '/' + chunkName
410
+ .replace(/^route-/, '')
411
+ .replace(/-/g, '/')
412
+ .replace(/index$/, '')
413
+ .replace(/\/+$/, '') || '/';
414
+
415
+ manifest[routePath] = {
416
+ chunk: fileName,
417
+ imports: chunk.imports ?? [],
418
+ };
419
+ }
420
+
421
+ this.emitFile({
422
+ type: 'asset',
423
+ fileName: 'clarity-route-manifest.json',
424
+ source: JSON.stringify(manifest, null, 2),
425
+ });
426
+ }
427
+ },
428
+ };
429
+ }
430
+
431
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
432
+
433
+ /** Recursively collect all page files from a directory */
434
+ function _scanPageFiles(dir, files = []) {
435
+ if (!existsSync(dir)) return files;
436
+ for (const entry of readdirSync(dir)) {
437
+ if (entry.startsWith('_')) continue; // skip _layout.js etc.
438
+ const full = join(dir, entry);
439
+ const info = statSync(full);
440
+ if (info.isDirectory()) {
441
+ _scanPageFiles(full, files);
442
+ } else if (/\.(js|mjs|clarity)$/.test(entry)) {
443
+ files.push(full);
444
+ }
445
+ }
446
+ return files;
447
+ }