@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/README.md +178 -0
- package/package.json +168 -0
- package/src/analyze.js +534 -0
- package/src/async-state.js +555 -0
- package/src/bundle-runtime.js +35 -0
- package/src/clarity-bundle.js +332 -0
- package/src/clarity-test.js +622 -0
- package/src/cli.js +453 -0
- package/src/codegen.js +1934 -0
- package/src/dev-server.js +362 -0
- package/src/devtools.js +765 -0
- package/src/edge.js +606 -0
- package/src/error-overlay.js +535 -0
- package/src/file-conventions.js +472 -0
- package/src/font.js +513 -0
- package/src/game-loop.js +106 -0
- package/src/head.js +393 -0
- package/src/hydrate.js +292 -0
- package/src/i18n.js +403 -0
- package/src/image.js +352 -0
- package/src/index.js +193 -0
- package/src/islands.js +284 -0
- package/src/isr.js +306 -0
- package/src/layout.js +342 -0
- package/src/lexer.js +572 -0
- package/src/linter.js +547 -0
- package/src/pages-router.js +229 -0
- package/src/parser.js +1108 -0
- package/src/router.js +732 -0
- package/src/runtime.js +1465 -0
- package/src/scoped-css.js +641 -0
- package/src/server-actions.js +439 -0
- package/src/server-data.js +225 -0
- package/src/sourcemap.js +130 -0
- package/src/ssg.js +310 -0
- package/src/ssr.js +621 -0
- package/src/store.js +276 -0
- package/src/transitions.js +438 -0
- package/src/ts-plugin.js +613 -0
- package/src/typegen.js +240 -0
- package/src/vite-plugin.js +447 -0
- package/types/index.d.ts +366 -0
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
|
+
}
|