dalila 1.9.1 → 1.9.3

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 CHANGED
@@ -7,7 +7,7 @@ Dalila is a reactive framework built on signals. No virtual DOM, no JSX required
7
7
  ## Quick Start
8
8
 
9
9
  ```bash
10
- npm create dalila my-app
10
+ npm create dalila@latest my-app
11
11
  cd my-app
12
12
  npm install
13
13
  npm run dev
@@ -64,6 +64,7 @@ bind(document.getElementById('app')!, ctx);
64
64
  ### Routing
65
65
 
66
66
  - [Router](./docs/router.md) — Client-side routing with nested layouts, preloading, and file-based route generation
67
+ - [Template Check CLI](./docs/cli/check.md) — `dalila check` static analysis for template/context consistency
67
68
 
68
69
  ### UI Components
69
70
 
@@ -214,7 +215,7 @@ src/app/
214
215
  ```
215
216
 
216
217
  ```bash
217
- dalila routes generate
218
+ npx dalila routes generate
218
219
  ```
219
220
 
220
221
  ```ts
@@ -0,0 +1,3 @@
1
+ export declare function runCheck(appDir: string, options?: {
2
+ strict?: boolean;
3
+ }): Promise<number>;
@@ -0,0 +1,902 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { buildRouteTree, injectHtmlPathTemplates, findFile, findProjectRoot, extractParamKeys, } from './routes-generator.js';
4
+ // ============================================================================
5
+ // TypeScript Compiler API (dynamic import)
6
+ // ============================================================================
7
+ async function loadTypeScript() {
8
+ try {
9
+ return await import('typescript');
10
+ }
11
+ catch {
12
+ console.error('❌ TypeScript is required for `dalila check`.\n' +
13
+ ' Install it with: npm install -D typescript');
14
+ process.exit(1);
15
+ }
16
+ }
17
+ // ============================================================================
18
+ // Loader return type extraction via TS Compiler API
19
+ // ============================================================================
20
+ function extractLoaderReturnKeys(ts, checker, loaderSymbol, sourceFile) {
21
+ function isPlainObjectLikeType(type) {
22
+ if (type.isUnion()) {
23
+ return type.types.length > 0 && type.types.every(isPlainObjectLikeType);
24
+ }
25
+ if (type.isIntersection()) {
26
+ return type.types.length > 0 && type.types.every(isPlainObjectLikeType);
27
+ }
28
+ if ((type.flags & ts.TypeFlags.Object) === 0)
29
+ return false;
30
+ const objectType = type;
31
+ if (objectType.getCallSignatures().length > 0)
32
+ return false;
33
+ if (checker.isArrayType(type) || checker.isTupleType(type))
34
+ return false;
35
+ const objectFlags = objectType.objectFlags ?? 0;
36
+ if ((objectFlags & ts.ObjectFlags.Reference) !== 0) {
37
+ const target = objectType.target;
38
+ const targetName = target?.symbol?.getName();
39
+ if (targetName === 'Array' || targetName === 'ReadonlyArray')
40
+ return false;
41
+ }
42
+ return true;
43
+ }
44
+ const resolvedLoaderSymbol = loaderSymbol.flags & ts.SymbolFlags.Alias
45
+ ? checker.getAliasedSymbol(loaderSymbol)
46
+ : loaderSymbol;
47
+ const loaderLocation = resolvedLoaderSymbol.valueDeclaration ??
48
+ resolvedLoaderSymbol.declarations?.[0] ??
49
+ sourceFile;
50
+ const loaderType = checker.getTypeOfSymbolAtLocation(resolvedLoaderSymbol, loaderLocation);
51
+ const callSignatures = loaderType.getCallSignatures();
52
+ if (callSignatures.length === 0)
53
+ return null;
54
+ let returnType = checker.getReturnTypeOfSignature(callSignatures[0]);
55
+ // Unwrap Promise<T>
56
+ const typeSymbol = returnType.getSymbol();
57
+ const targetSymbol = returnType.target?.symbol;
58
+ if (typeSymbol?.getName() === 'Promise' ||
59
+ targetSymbol?.getName() === 'Promise') {
60
+ const typeArgs = checker.getTypeArguments(returnType);
61
+ if (typeArgs && typeArgs.length > 0) {
62
+ returnType = typeArgs[0];
63
+ }
64
+ }
65
+ if (!isPlainObjectLikeType(returnType))
66
+ return null;
67
+ return returnType.getProperties().map(p => p.getName());
68
+ }
69
+ function getLoaderExportSymbol(ts, checker, sourceFile) {
70
+ const symbol = checker.getSymbolAtLocation(sourceFile);
71
+ if (!symbol)
72
+ return null;
73
+ const moduleExports = checker.getExportsOfModule(symbol);
74
+ const loaderSymbol = moduleExports.find(s => s.getName() === 'loader');
75
+ if (!loaderSymbol)
76
+ return null;
77
+ const runtimeFlags = ts.SymbolFlags.Function |
78
+ ts.SymbolFlags.Variable |
79
+ ts.SymbolFlags.Property |
80
+ ts.SymbolFlags.Method |
81
+ ts.SymbolFlags.GetAccessor |
82
+ ts.SymbolFlags.Alias;
83
+ return (loaderSymbol.flags & runtimeFlags) !== 0 ? loaderSymbol : null;
84
+ }
85
+ // ============================================================================
86
+ // Template identifier extraction (regex-based)
87
+ // ============================================================================
88
+ const JS_KEYWORDS = new Set([
89
+ 'true', 'false', 'null', 'undefined', 'NaN', 'Infinity',
90
+ 'typeof', 'instanceof', 'void', 'delete', 'in', 'of',
91
+ 'new', 'this', 'if', 'else', 'return', 'switch', 'case',
92
+ 'break', 'continue', 'for', 'while', 'do', 'try', 'catch',
93
+ 'finally', 'throw', 'const', 'let', 'var', 'function', 'class',
94
+ ]);
95
+ function extractParamNames(segment) {
96
+ const result = [];
97
+ const seen = new Set();
98
+ const IDENT_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
99
+ function splitTopLevel(input) {
100
+ const parts = [];
101
+ let start = 0;
102
+ let depthParen = 0;
103
+ let depthBracket = 0;
104
+ let depthBrace = 0;
105
+ let inString = null;
106
+ let escaped = false;
107
+ for (let i = 0; i < input.length; i++) {
108
+ const ch = input[i];
109
+ if (inString) {
110
+ if (escaped) {
111
+ escaped = false;
112
+ }
113
+ else if (ch === '\\') {
114
+ escaped = true;
115
+ }
116
+ else if (ch === inString) {
117
+ inString = null;
118
+ }
119
+ continue;
120
+ }
121
+ if (ch === '"' || ch === "'" || ch === '`') {
122
+ inString = ch;
123
+ continue;
124
+ }
125
+ if (ch === '(')
126
+ depthParen++;
127
+ else if (ch === ')')
128
+ depthParen = Math.max(0, depthParen - 1);
129
+ else if (ch === '[')
130
+ depthBracket++;
131
+ else if (ch === ']')
132
+ depthBracket = Math.max(0, depthBracket - 1);
133
+ else if (ch === '{')
134
+ depthBrace++;
135
+ else if (ch === '}')
136
+ depthBrace = Math.max(0, depthBrace - 1);
137
+ else if (ch === ',' &&
138
+ depthParen === 0 &&
139
+ depthBracket === 0 &&
140
+ depthBrace === 0) {
141
+ parts.push(input.slice(start, i));
142
+ start = i + 1;
143
+ }
144
+ }
145
+ parts.push(input.slice(start));
146
+ return parts;
147
+ }
148
+ function topLevelIndexOf(input, target) {
149
+ let depthParen = 0;
150
+ let depthBracket = 0;
151
+ let depthBrace = 0;
152
+ let inString = null;
153
+ let escaped = false;
154
+ for (let i = 0; i < input.length; i++) {
155
+ const ch = input[i];
156
+ if (inString) {
157
+ if (escaped) {
158
+ escaped = false;
159
+ }
160
+ else if (ch === '\\') {
161
+ escaped = true;
162
+ }
163
+ else if (ch === inString) {
164
+ inString = null;
165
+ }
166
+ continue;
167
+ }
168
+ if (ch === '"' || ch === "'" || ch === '`') {
169
+ inString = ch;
170
+ continue;
171
+ }
172
+ if (ch === '(')
173
+ depthParen++;
174
+ else if (ch === ')')
175
+ depthParen = Math.max(0, depthParen - 1);
176
+ else if (ch === '[')
177
+ depthBracket++;
178
+ else if (ch === ']')
179
+ depthBracket = Math.max(0, depthBracket - 1);
180
+ else if (ch === '{')
181
+ depthBrace++;
182
+ else if (ch === '}')
183
+ depthBrace = Math.max(0, depthBrace - 1);
184
+ else if (ch === target &&
185
+ depthParen === 0 &&
186
+ depthBracket === 0 &&
187
+ depthBrace === 0) {
188
+ return i;
189
+ }
190
+ }
191
+ return -1;
192
+ }
193
+ function stripDefaultValue(input) {
194
+ const eqIdx = topLevelIndexOf(input, '=');
195
+ if (eqIdx >= 0)
196
+ return input.slice(0, eqIdx).trim();
197
+ return input.trim();
198
+ }
199
+ function collectFromPattern(pattern) {
200
+ let param = pattern.trim();
201
+ if (!param)
202
+ return;
203
+ param = param.replace(/^\.\.\./, '').trim();
204
+ param = stripDefaultValue(param);
205
+ if (!param)
206
+ return;
207
+ if (IDENT_RE.test(param)) {
208
+ if (!JS_KEYWORDS.has(param) && !seen.has(param)) {
209
+ seen.add(param);
210
+ result.push(param);
211
+ }
212
+ return;
213
+ }
214
+ if (param.startsWith('{') && param.endsWith('}')) {
215
+ const inner = param.slice(1, -1);
216
+ for (const rawEntry of splitTopLevel(inner)) {
217
+ const entry = rawEntry.trim();
218
+ if (!entry)
219
+ continue;
220
+ if (entry.startsWith('...')) {
221
+ collectFromPattern(entry.slice(3));
222
+ continue;
223
+ }
224
+ const colonIdx = topLevelIndexOf(entry, ':');
225
+ if (colonIdx >= 0) {
226
+ collectFromPattern(entry.slice(colonIdx + 1));
227
+ }
228
+ else {
229
+ collectFromPattern(entry);
230
+ }
231
+ }
232
+ return;
233
+ }
234
+ if (param.startsWith('[') && param.endsWith(']')) {
235
+ const inner = param.slice(1, -1);
236
+ for (const rawEntry of splitTopLevel(inner)) {
237
+ collectFromPattern(rawEntry);
238
+ }
239
+ }
240
+ }
241
+ for (const chunk of splitTopLevel(segment)) {
242
+ collectFromPattern(chunk);
243
+ }
244
+ return result;
245
+ }
246
+ function collectLocalIdentifiers(expr) {
247
+ const locals = new Set();
248
+ const ARROW_SINGLE_PARAM_RE = /(?<![\w$.])([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=>/g;
249
+ let match;
250
+ while ((match = ARROW_SINGLE_PARAM_RE.exec(expr))) {
251
+ locals.add(match[1]);
252
+ }
253
+ const ARROW_PARAMS_RE = /\(([^)]*)\)\s*=>/g;
254
+ while ((match = ARROW_PARAMS_RE.exec(expr))) {
255
+ for (const name of extractParamNames(match[1])) {
256
+ locals.add(name);
257
+ }
258
+ }
259
+ const FUNCTION_PARAMS_RE = /function(?:\s+[a-zA-Z_$][a-zA-Z0-9_$]*)?\s*\(([^)]*)\)/g;
260
+ while ((match = FUNCTION_PARAMS_RE.exec(expr))) {
261
+ for (const name of extractParamNames(match[1])) {
262
+ locals.add(name);
263
+ }
264
+ }
265
+ const CATCH_PARAM_RE = /catch\s*\(\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\)/g;
266
+ while ((match = CATCH_PARAM_RE.exec(expr))) {
267
+ locals.add(match[1]);
268
+ }
269
+ return locals;
270
+ }
271
+ /**
272
+ * Extract root identifiers from a template expression.
273
+ * Given `items.length`, returns `['items']`.
274
+ * Given `count`, returns `['count']`.
275
+ * Ignores string literals, numbers, and JS keywords.
276
+ */
277
+ function extractRootIdentifiers(expr) {
278
+ // Remove plain string literal contents while preserving template
279
+ // expression bodies inside `${...}` so identifiers are still validated.
280
+ const stripStringsPreserveTemplateExpressions = (input) => {
281
+ let i = 0;
282
+ let out = '';
283
+ const consumeQuoted = (quote) => {
284
+ out += ' ';
285
+ i++;
286
+ let escaped = false;
287
+ while (i < input.length) {
288
+ const ch = input[i];
289
+ out += ' ';
290
+ i++;
291
+ if (escaped) {
292
+ escaped = false;
293
+ continue;
294
+ }
295
+ if (ch === '\\') {
296
+ escaped = true;
297
+ continue;
298
+ }
299
+ if (ch === quote)
300
+ break;
301
+ }
302
+ };
303
+ const consumeTemplate = () => {
304
+ out += ' ';
305
+ i++;
306
+ let escaped = false;
307
+ while (i < input.length) {
308
+ const ch = input[i];
309
+ if (escaped) {
310
+ out += ' ';
311
+ escaped = false;
312
+ i++;
313
+ continue;
314
+ }
315
+ if (ch === '\\') {
316
+ out += ' ';
317
+ escaped = true;
318
+ i++;
319
+ continue;
320
+ }
321
+ if (ch === '`') {
322
+ out += ' ';
323
+ i++;
324
+ break;
325
+ }
326
+ if (ch === '$' && input[i + 1] === '{') {
327
+ out += '${';
328
+ i += 2;
329
+ let depth = 1;
330
+ while (i < input.length && depth > 0) {
331
+ const inner = input[i];
332
+ if (inner === '"' || inner === "'") {
333
+ consumeQuoted(inner);
334
+ continue;
335
+ }
336
+ if (inner === '`') {
337
+ consumeTemplate();
338
+ continue;
339
+ }
340
+ if (inner === '{') {
341
+ depth++;
342
+ out += inner;
343
+ i++;
344
+ continue;
345
+ }
346
+ if (inner === '}') {
347
+ depth--;
348
+ out += inner;
349
+ i++;
350
+ continue;
351
+ }
352
+ out += inner;
353
+ i++;
354
+ }
355
+ continue;
356
+ }
357
+ out += ' ';
358
+ i++;
359
+ }
360
+ };
361
+ while (i < input.length) {
362
+ const ch = input[i];
363
+ if (ch === '"' || ch === "'") {
364
+ consumeQuoted(ch);
365
+ continue;
366
+ }
367
+ if (ch === '`') {
368
+ consumeTemplate();
369
+ continue;
370
+ }
371
+ out += ch;
372
+ i++;
373
+ }
374
+ return out;
375
+ };
376
+ const cleaned = stripStringsPreserveTemplateExpressions(expr);
377
+ const localIdentifiers = collectLocalIdentifiers(cleaned);
378
+ // Match identifiers NOT preceded by a dot (member access)
379
+ const regex = /(?<![.\w$])([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
380
+ const seen = new Set();
381
+ const result = [];
382
+ const prevNonWs = (index) => {
383
+ for (let i = index; i >= 0; i--) {
384
+ if (!/\s/.test(cleaned[i]))
385
+ return cleaned[i];
386
+ }
387
+ return null;
388
+ };
389
+ const nextNonWs = (index) => {
390
+ for (let i = index; i < cleaned.length; i++) {
391
+ if (!/\s/.test(cleaned[i]))
392
+ return cleaned[i];
393
+ }
394
+ return null;
395
+ };
396
+ let match;
397
+ while ((match = regex.exec(cleaned))) {
398
+ const name = match[1];
399
+ const nextToken = nextNonWs(regex.lastIndex);
400
+ const prevToken = prevNonWs(match.index - 1);
401
+ const isObjectLiteralKey = nextToken === ':' && (prevToken === '{' || prevToken === ',');
402
+ if (!isObjectLiteralKey && !JS_KEYWORDS.has(name) && !seen.has(name) && !localIdentifiers.has(name)) {
403
+ seen.add(name);
404
+ result.push(name);
405
+ }
406
+ }
407
+ return result;
408
+ }
409
+ /**
410
+ * Extract all template identifiers from HTML content.
411
+ *
412
+ * Scans for:
413
+ * 1. Text interpolation `{expr}` — only outside HTML tags
414
+ * 2. Context-binding directives `d-*="value"` — specific set
415
+ */
416
+ function extractTemplateIdentifiers(html) {
417
+ const identifiers = [];
418
+ const lines = html.split('\n');
419
+ const lineOffsets = [];
420
+ let runningOffset = 0;
421
+ for (const line of lines) {
422
+ lineOffsets.push(runningOffset);
423
+ runningOffset += line.length + 1;
424
+ }
425
+ function offsetToLineCol(offset) {
426
+ let lineIdx = 0;
427
+ for (let i = 0; i < lineOffsets.length; i++) {
428
+ if (lineOffsets[i] <= offset) {
429
+ lineIdx = i;
430
+ }
431
+ else {
432
+ break;
433
+ }
434
+ }
435
+ return { line: lineIdx + 1, col: offset - lineOffsets[lineIdx] + 1 };
436
+ }
437
+ // --- 1. Text interpolation {expr} with state machine (supports multiline) ---
438
+ let inTag = false;
439
+ let tagQuote = null;
440
+ let i = 0;
441
+ while (i < html.length) {
442
+ const ch = html[i];
443
+ if (!inTag && ch === '<') {
444
+ inTag = true;
445
+ tagQuote = null;
446
+ i++;
447
+ continue;
448
+ }
449
+ if (inTag) {
450
+ if (tagQuote) {
451
+ if (ch === tagQuote) {
452
+ tagQuote = null;
453
+ }
454
+ i++;
455
+ continue;
456
+ }
457
+ if (ch === '"' || ch === "'") {
458
+ tagQuote = ch;
459
+ i++;
460
+ continue;
461
+ }
462
+ if (ch === '>') {
463
+ inTag = false;
464
+ tagQuote = null;
465
+ i++;
466
+ continue;
467
+ }
468
+ i++;
469
+ continue;
470
+ }
471
+ if (ch === '{') {
472
+ const start = i;
473
+ let depth = 1;
474
+ let j = i + 1;
475
+ let inString = null;
476
+ let escaped = false;
477
+ while (j < html.length && depth > 0) {
478
+ const ch = html[j];
479
+ if (inString) {
480
+ if (escaped) {
481
+ escaped = false;
482
+ }
483
+ else if (ch === '\\') {
484
+ escaped = true;
485
+ }
486
+ else if (ch === inString) {
487
+ inString = null;
488
+ }
489
+ j++;
490
+ continue;
491
+ }
492
+ if (ch === '"' || ch === "'" || ch === '`') {
493
+ inString = ch;
494
+ j++;
495
+ continue;
496
+ }
497
+ if (ch === '{')
498
+ depth++;
499
+ if (ch === '}')
500
+ depth--;
501
+ j++;
502
+ }
503
+ if (depth === 0) {
504
+ const expr = html.slice(start + 1, j - 1);
505
+ const roots = extractRootIdentifiers(expr);
506
+ const loc = offsetToLineCol(start);
507
+ for (const name of roots) {
508
+ identifiers.push({
509
+ name,
510
+ line: loc.line,
511
+ col: loc.col,
512
+ offset: start,
513
+ source: 'interpolation',
514
+ });
515
+ }
516
+ i = j;
517
+ continue;
518
+ }
519
+ }
520
+ i++;
521
+ }
522
+ // --- 2. Directive scanning (supports single and double quotes) ---
523
+ const DIRECTIVE_RE = /\b(d-each|d-virtual-each|d-virtual-height|d-virtual-item-height|d-virtual-overscan|d-if|d-when|d-match|d-html|d-attr-[a-zA-Z][\w-]*|d-on-[a-zA-Z][\w-]*|d-form-error|d-form|d-array)\s*=\s*(['"])([\s\S]*?)\2/g;
524
+ DIRECTIVE_RE.lastIndex = 0;
525
+ let match;
526
+ while ((match = DIRECTIVE_RE.exec(html))) {
527
+ const directive = match[1];
528
+ const value = match[3].trim();
529
+ if (!value)
530
+ continue;
531
+ const roots = extractRootIdentifiers(value);
532
+ const loc = offsetToLineCol(match.index);
533
+ for (const name of roots) {
534
+ identifiers.push({
535
+ name,
536
+ line: loc.line,
537
+ col: loc.col,
538
+ offset: match.index,
539
+ source: directive,
540
+ });
541
+ }
542
+ }
543
+ return identifiers;
544
+ }
545
+ function extractLoopRanges(html) {
546
+ const ranges = [];
547
+ const stack = [];
548
+ let i = 0;
549
+ while (i < html.length) {
550
+ if (html[i] !== '<') {
551
+ i++;
552
+ continue;
553
+ }
554
+ let j = i + 1;
555
+ let inString = null;
556
+ let escaped = false;
557
+ while (j < html.length) {
558
+ const ch = html[j];
559
+ if (inString) {
560
+ if (escaped) {
561
+ escaped = false;
562
+ }
563
+ else if (ch === '\\') {
564
+ escaped = true;
565
+ }
566
+ else if (ch === inString) {
567
+ inString = null;
568
+ }
569
+ j++;
570
+ continue;
571
+ }
572
+ if (ch === '"' || ch === "'") {
573
+ inString = ch;
574
+ j++;
575
+ continue;
576
+ }
577
+ if (ch === '>')
578
+ break;
579
+ j++;
580
+ }
581
+ if (j >= html.length)
582
+ break;
583
+ const fullTag = html.slice(i, j + 1);
584
+ const inner = html.slice(i + 1, j).trim();
585
+ const isClosingTag = inner.startsWith('/');
586
+ const normalized = isClosingTag ? inner.slice(1).trim() : inner;
587
+ const nameMatch = /^([a-zA-Z][\w:-]*)/.exec(normalized);
588
+ if (!nameMatch) {
589
+ i = j + 1;
590
+ continue;
591
+ }
592
+ const tagName = nameMatch[1];
593
+ const attrs = normalized.slice(tagName.length);
594
+ const isSelfClosingTag = !isClosingTag && /\/\s*$/.test(normalized);
595
+ if (isClosingTag) {
596
+ for (let stackIdx = stack.length - 1; stackIdx >= 0; stackIdx--) {
597
+ if (stack[stackIdx].tagName !== tagName)
598
+ continue;
599
+ const entry = stack.splice(stackIdx, 1)[0];
600
+ if (entry.isLoop) {
601
+ ranges.push({ start: entry.start, end: i + fullTag.length });
602
+ }
603
+ break;
604
+ }
605
+ i = j + 1;
606
+ continue;
607
+ }
608
+ const isLoopTag = /\bd-(?:virtual-)?each\s*=\s*(['"])([\s\S]*?)\1/.test(attrs);
609
+ if (isSelfClosingTag) {
610
+ if (isLoopTag) {
611
+ ranges.push({ start: i, end: i + fullTag.length });
612
+ }
613
+ i = j + 1;
614
+ continue;
615
+ }
616
+ stack.push({ tagName, isLoop: isLoopTag, start: i });
617
+ i = j + 1;
618
+ }
619
+ for (const entry of stack) {
620
+ if (entry.isLoop) {
621
+ ranges.push({ start: entry.start, end: html.length });
622
+ }
623
+ }
624
+ return ranges;
625
+ }
626
+ function isInsideLoopRange(offset, ranges) {
627
+ for (const range of ranges) {
628
+ if (offset >= range.start && offset < range.end)
629
+ return true;
630
+ }
631
+ return false;
632
+ }
633
+ // ============================================================================
634
+ // Levenshtein did-you-mean
635
+ // ============================================================================
636
+ function levenshtein(a, b) {
637
+ const m = a.length;
638
+ const n = b.length;
639
+ const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
640
+ for (let i = 0; i <= m; i++)
641
+ dp[i][0] = i;
642
+ for (let j = 0; j <= n; j++)
643
+ dp[0][j] = j;
644
+ for (let i = 1; i <= m; i++) {
645
+ for (let j = 1; j <= n; j++) {
646
+ if (a[i - 1] === b[j - 1]) {
647
+ dp[i][j] = dp[i - 1][j - 1];
648
+ }
649
+ else {
650
+ dp[i][j] =
651
+ 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
652
+ }
653
+ }
654
+ }
655
+ return dp[m][n];
656
+ }
657
+ function findSuggestion(identifier, validNames) {
658
+ let best;
659
+ let bestDist = Infinity;
660
+ const maxDist = Math.max(2, Math.floor(identifier.length * 0.4));
661
+ for (const name of validNames) {
662
+ const dist = levenshtein(identifier, name);
663
+ if (dist < bestDist && dist <= maxDist) {
664
+ bestDist = dist;
665
+ best = name;
666
+ }
667
+ }
668
+ return best;
669
+ }
670
+ // ============================================================================
671
+ // Diagnostic check
672
+ // ============================================================================
673
+ const BUILTIN_IDENTIFIERS = new Set([
674
+ 'params',
675
+ 'query',
676
+ 'path',
677
+ 'fullPath',
678
+ ]);
679
+ const LOOP_VARS = new Set([
680
+ '$index',
681
+ '$count',
682
+ '$first',
683
+ '$last',
684
+ '$odd',
685
+ '$even',
686
+ 'item',
687
+ 'key',
688
+ ]);
689
+ const LOOP_FORCED_CHECK_SOURCES = new Set([
690
+ 'd-each',
691
+ 'd-virtual-each',
692
+ 'd-virtual-height',
693
+ 'd-virtual-item-height',
694
+ 'd-virtual-overscan',
695
+ ]);
696
+ function checkHtmlContent(html, filePath, validIdentifiers, diagnostics) {
697
+ const ids = extractTemplateIdentifiers(html);
698
+ const loopRanges = extractLoopRanges(html);
699
+ for (const id of ids) {
700
+ if (validIdentifiers.has(id.name))
701
+ continue;
702
+ const insideLoop = isInsideLoopRange(id.offset, loopRanges);
703
+ if (insideLoop) {
704
+ if (LOOP_VARS.has(id.name))
705
+ continue;
706
+ // Loop runtime injects item fields into local scope, so unknown
707
+ // identifiers may be valid inside loop bodies.
708
+ if (!LOOP_FORCED_CHECK_SOURCES.has(id.source))
709
+ continue;
710
+ }
711
+ const suggestion = findSuggestion(id.name, [...validIdentifiers]);
712
+ diagnostics.push({
713
+ filePath,
714
+ line: id.line,
715
+ col: id.col,
716
+ identifier: id.name,
717
+ source: id.source,
718
+ suggestion,
719
+ });
720
+ }
721
+ }
722
+ // ============================================================================
723
+ // Route tree helpers
724
+ // ============================================================================
725
+ function collectRouteTsPaths(node, routesDir, out) {
726
+ for (const file of node.files) {
727
+ if (file.isHtml)
728
+ continue;
729
+ out.push(path.join(routesDir, file.path));
730
+ }
731
+ for (const child of node.children) {
732
+ collectRouteTsPaths(child, routesDir, out);
733
+ }
734
+ }
735
+ function computeFullPattern(node, parentPattern) {
736
+ if (!node.segment)
737
+ return parentPattern || '/';
738
+ const base = parentPattern === '/' ? '' : parentPattern;
739
+ return `${base}/${node.routePath}`;
740
+ }
741
+ function traverseAndCheck(node, routesDir, parentPattern, loaderKeysMap, uninferableLoaderPaths, diagnostics) {
742
+ const currentPattern = computeFullPattern(node, parentPattern);
743
+ const paramKeys = extractParamKeys(currentPattern);
744
+ // --- Check page.html ---
745
+ const pageHtml = findFile(node, 'page', true);
746
+ const pageTs = findFile(node, 'page', false);
747
+ if (pageHtml && pageHtml.htmlContent) {
748
+ const pageTsPath = pageTs ? path.join(routesDir, pageTs.path) : null;
749
+ const skipPageCheck = pageTsPath && uninferableLoaderPaths.has(pageTsPath);
750
+ if (!skipPageCheck) {
751
+ const validIds = new Set(BUILTIN_IDENTIFIERS);
752
+ for (const k of paramKeys)
753
+ validIds.add(k);
754
+ if (pageTs) {
755
+ const keys = loaderKeysMap.get(path.join(routesDir, pageTs.path));
756
+ if (keys)
757
+ for (const k of keys)
758
+ validIds.add(k);
759
+ }
760
+ checkHtmlContent(pageHtml.htmlContent, pageHtml.path, validIds, diagnostics);
761
+ }
762
+ }
763
+ // --- Check layout.html ---
764
+ const layoutHtml = findFile(node, 'layout', true);
765
+ const layoutTs = findFile(node, 'layout', false);
766
+ if (layoutHtml && layoutHtml.htmlContent) {
767
+ const layoutTsPath = layoutTs ? path.join(routesDir, layoutTs.path) : null;
768
+ const skipLayoutCheck = layoutTsPath && uninferableLoaderPaths.has(layoutTsPath);
769
+ if (!skipLayoutCheck) {
770
+ const validIds = new Set(BUILTIN_IDENTIFIERS);
771
+ for (const k of paramKeys)
772
+ validIds.add(k);
773
+ if (layoutTs) {
774
+ const keys = loaderKeysMap.get(path.join(routesDir, layoutTs.path));
775
+ if (keys)
776
+ for (const k of keys)
777
+ validIds.add(k);
778
+ }
779
+ checkHtmlContent(layoutHtml.htmlContent, layoutHtml.path, validIds, diagnostics);
780
+ }
781
+ }
782
+ // --- Check error.html, loading.html, not-found.html ---
783
+ const stateTypes = ['error', 'pending', 'notFound'];
784
+ for (const type of stateTypes) {
785
+ const html = findFile(node, type, true);
786
+ if (!html || !html.htmlContent)
787
+ continue;
788
+ const validIds = new Set(BUILTIN_IDENTIFIERS);
789
+ for (const k of paramKeys)
790
+ validIds.add(k);
791
+ if (type === 'error')
792
+ validIds.add('errorMessage');
793
+ checkHtmlContent(html.htmlContent, html.path, validIds, diagnostics);
794
+ }
795
+ // Recurse into children
796
+ for (const child of node.children) {
797
+ traverseAndCheck(child, routesDir, currentPattern, loaderKeysMap, uninferableLoaderPaths, diagnostics);
798
+ }
799
+ }
800
+ // ============================================================================
801
+ // Main entry point
802
+ // ============================================================================
803
+ export async function runCheck(appDir, options = {}) {
804
+ const ts = await loadTypeScript();
805
+ const strictMode = Boolean(options.strict);
806
+ console.log('');
807
+ console.log('🔍 Dalila Check');
808
+ console.log('');
809
+ const routesDir = path.resolve(appDir);
810
+ if (!fs.existsSync(routesDir)) {
811
+ console.error(`❌ App directory not found: ${routesDir}`);
812
+ return 1;
813
+ }
814
+ // 1. Build route tree (reuses routes-generator internals)
815
+ const tree = await buildRouteTree(routesDir, '', '');
816
+ const projectRoot = (await findProjectRoot(routesDir)) ?? process.cwd();
817
+ await injectHtmlPathTemplates(tree, routesDir, projectRoot);
818
+ // 2. Collect route .ts files, create shared TS Program, infer loader keys by symbols
819
+ const routeTsPaths = [];
820
+ collectRouteTsPaths(tree, routesDir, routeTsPaths);
821
+ const loaderKeysMap = new Map();
822
+ const uninferableLoaderPaths = new Set();
823
+ const strictIssues = [];
824
+ if (routeTsPaths.length > 0) {
825
+ const tsconfigPath = path.join(projectRoot, 'tsconfig.json');
826
+ let compilerOptions = {
827
+ target: ts.ScriptTarget.ES2020,
828
+ module: ts.ModuleKind.ESNext,
829
+ moduleResolution: ts.ModuleResolutionKind.NodeJs,
830
+ strict: true,
831
+ esModuleInterop: true,
832
+ skipLibCheck: true,
833
+ };
834
+ if (fs.existsSync(tsconfigPath)) {
835
+ const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
836
+ if (configFile.config) {
837
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, projectRoot);
838
+ compilerOptions = parsed.options;
839
+ }
840
+ }
841
+ const program = ts.createProgram(routeTsPaths, compilerOptions);
842
+ const checker = program.getTypeChecker();
843
+ for (const filePath of routeTsPaths) {
844
+ const sourceFile = program.getSourceFile(filePath);
845
+ if (!sourceFile)
846
+ continue;
847
+ const loaderSymbol = getLoaderExportSymbol(ts, checker, sourceFile);
848
+ if (!loaderSymbol)
849
+ continue;
850
+ const keys = extractLoaderReturnKeys(ts, checker, loaderSymbol, sourceFile);
851
+ if (keys) {
852
+ loaderKeysMap.set(filePath, keys);
853
+ }
854
+ else {
855
+ uninferableLoaderPaths.add(filePath);
856
+ if (strictMode) {
857
+ strictIssues.push(`${path.relative(process.cwd(), filePath)} exports "loader", but its return type could not be inferred`);
858
+ }
859
+ }
860
+ }
861
+ }
862
+ // 3. Traverse tree and check all HTML templates
863
+ const diagnostics = [];
864
+ traverseAndCheck(tree, routesDir, '/', loaderKeysMap, uninferableLoaderPaths, diagnostics);
865
+ // 4. Report results
866
+ if (strictIssues.length === 0 && diagnostics.length === 0) {
867
+ console.log('✅ No errors found');
868
+ console.log('');
869
+ return 0;
870
+ }
871
+ if (strictIssues.length > 0) {
872
+ console.log(' Strict mode');
873
+ for (const issue of strictIssues) {
874
+ console.log(` ❌ ${issue}`);
875
+ }
876
+ console.log('');
877
+ }
878
+ // Group by file
879
+ const grouped = new Map();
880
+ for (const d of diagnostics) {
881
+ const list = grouped.get(d.filePath) ?? [];
882
+ list.push(d);
883
+ grouped.set(d.filePath, list);
884
+ }
885
+ for (const [file, diags] of grouped) {
886
+ console.log(` ${file}`);
887
+ for (const d of diags) {
888
+ const loc = `${d.line}:${d.col}`;
889
+ let msg = `"${d.identifier}" is not defined in template context (${d.source})`;
890
+ if (d.suggestion) {
891
+ msg += `. Did you mean "${d.suggestion}"?`;
892
+ }
893
+ console.log(` ${loc.padEnd(8)} ❌ ${msg}`);
894
+ }
895
+ console.log('');
896
+ }
897
+ const totalErrors = diagnostics.length + strictIssues.length;
898
+ const fileCount = grouped.size + strictIssues.length;
899
+ console.log(`❌ Found ${totalErrors} error${totalErrors === 1 ? '' : 's'} in ${fileCount} file${fileCount === 1 ? '' : 's'}`);
900
+ console.log('');
901
+ return 1;
902
+ }
package/dist/cli/index.js CHANGED
@@ -16,15 +16,18 @@ Usage:
16
16
  dalila routes init Initialize app and generate routes outputs
17
17
  dalila routes watch [options] Watch routes and regenerate outputs on changes
18
18
  dalila routes --help Show routes command help
19
+ dalila check [path] [--strict] Static analysis of HTML templates against loaders
19
20
  dalila help Show this help message
20
21
 
21
22
  Options:
22
23
  --output <path> Output file (default: ./routes.generated.ts)
23
24
 
24
25
  Examples:
25
- dalila routes generate
26
- dalila routes generate --output src/routes.generated.ts
27
- dalila routes init
26
+ npx dalila routes generate
27
+ npx dalila routes generate --output src/routes.generated.ts
28
+ npx dalila routes init
29
+ npx dalila check
30
+ npx dalila check src/app --strict
28
31
  `);
29
32
  }
30
33
  function showRoutesHelp() {
@@ -41,10 +44,33 @@ Options:
41
44
  --output <path> Output file (default: ./routes.generated.ts)
42
45
 
43
46
  Examples:
44
- dalila routes generate
45
- dalila routes generate --output src/routes.generated.ts
46
- dalila routes watch
47
- dalila routes init
47
+ npx dalila routes generate
48
+ npx dalila routes generate --output src/routes.generated.ts
49
+ npx dalila routes watch
50
+ npx dalila routes init
51
+ `);
52
+ }
53
+ function showCheckHelp() {
54
+ console.log(`
55
+ Dalila CLI - Check
56
+
57
+ Usage:
58
+ dalila check [path] [options] Static analysis of HTML templates
59
+
60
+ Validates that identifiers used in HTML templates ({expr}, d-* directives)
61
+ match the return type of the corresponding loader() in TypeScript.
62
+
63
+ Arguments:
64
+ [path] App directory to check (default: src/app)
65
+
66
+ Options:
67
+ --strict Fail when exported loader return keys cannot be inferred
68
+ --help, -h Show this help message
69
+
70
+ Examples:
71
+ npx dalila check
72
+ npx dalila check src/app
73
+ npx dalila check --strict
48
74
  `);
49
75
  }
50
76
  function hasHelpFlag(list) {
@@ -331,6 +357,22 @@ async function main() {
331
357
  process.exit(1);
332
358
  }
333
359
  }
360
+ else if (command === 'check') {
361
+ const checkArgs = args.slice(1);
362
+ if (hasHelpFlag(checkArgs)) {
363
+ showCheckHelp();
364
+ }
365
+ else {
366
+ const strict = checkArgs.includes('--strict');
367
+ const positional = checkArgs.filter(a => !a.startsWith('--'));
368
+ const appDir = positional[0]
369
+ ? path.resolve(positional[0])
370
+ : resolveDefaultAppDir(process.cwd());
371
+ const { runCheck } = await import('./check.js');
372
+ const exitCode = await runCheck(appDir, { strict });
373
+ process.exit(exitCode);
374
+ }
375
+ }
334
376
  else if (command === '--help' || command === '-h') {
335
377
  showHelp();
336
378
  }
@@ -1,3 +1,28 @@
1
+ export type RouteFileType = 'middleware' | 'layout' | 'page' | 'error' | 'pending' | 'notFound';
2
+ export interface RouteFile {
3
+ path: string;
4
+ type: RouteFileType;
5
+ importName: string;
6
+ isHtml: boolean;
7
+ htmlContent?: string;
8
+ htmlPath?: string;
9
+ sourceContent?: string;
10
+ namedExports?: string[];
11
+ tags?: string[];
12
+ lazy?: boolean;
13
+ }
14
+ export interface RouteNode {
15
+ fsPath: string;
16
+ segment: string;
17
+ routePath: string;
18
+ files: RouteFile[];
19
+ children: RouteNode[];
20
+ }
21
+ export declare function extractParamKeys(routePattern: string): string[];
22
+ export declare function injectHtmlPathTemplates(node: RouteNode, routesDir: string, projectRoot: string): Promise<void>;
23
+ export declare function findProjectRoot(startDir: string): Promise<string | null>;
24
+ export declare function findFile(node: RouteNode, type: RouteFileType, isHtml?: boolean): RouteFile | undefined;
25
+ export declare function buildRouteTree(routesDir: string, currentPath?: string, currentSegment?: string): Promise<RouteNode>;
1
26
  export declare function collectHtmlPathDependencyDirs(routesDir: string): string[];
2
27
  /**
3
28
  * Generate route files from the app directory.
@@ -128,7 +128,7 @@ function parseRouteParamSegment(segment) {
128
128
  }
129
129
  return { key: raw, isCatchAll: false, isOptionalCatchAll: false };
130
130
  }
131
- function extractParamKeys(routePattern) {
131
+ export function extractParamKeys(routePattern) {
132
132
  const keys = [];
133
133
  const segments = normalizeRoutePath(routePattern).split('/').filter(Boolean);
134
134
  for (const segment of segments) {
@@ -257,7 +257,7 @@ function resolveHtmlPath(htmlPath, routesDir, filePath, projectRoot) {
257
257
  }
258
258
  return path.resolve(routeFileDir, htmlPath);
259
259
  }
260
- async function injectHtmlPathTemplates(node, routesDir, projectRoot) {
260
+ export async function injectHtmlPathTemplates(node, routesDir, projectRoot) {
261
261
  const syntheticHtmlFiles = [];
262
262
  for (const file of node.files) {
263
263
  if (file.isHtml || !file.htmlPath)
@@ -299,7 +299,7 @@ const DEFAULT_ROUTE_TAG_POLICY = {
299
299
  ],
300
300
  priority: ['auth', 'public']
301
301
  };
302
- async function findProjectRoot(startDir) {
302
+ export async function findProjectRoot(startDir) {
303
303
  let current = path.resolve(startDir);
304
304
  while (true) {
305
305
  if (await pathExists(path.join(current, 'package.json'))) {
@@ -409,7 +409,7 @@ function validateManifestTags(entries, policy) {
409
409
  }
410
410
  }
411
411
  }
412
- function findFile(node, type, isHtml) {
412
+ export function findFile(node, type, isHtml) {
413
413
  return node.files.find((file) => {
414
414
  if (file.type !== type)
415
415
  return false;
@@ -591,7 +591,7 @@ function buildRouteTreeSync(routesDir, currentPath = '', currentSegment = '') {
591
591
  }
592
592
  return node;
593
593
  }
594
- async function buildRouteTree(routesDir, currentPath = '', currentSegment = '') {
594
+ export async function buildRouteTree(routesDir, currentPath = '', currentSegment = '') {
595
595
  const node = {
596
596
  fsPath: currentPath.replace(/\\/g, '/'),
597
597
  segment: currentSegment,
@@ -40,19 +40,19 @@ export type RouteMiddlewareResolver = RouteMiddleware[] | ((ctx: RouteCtx) => Ro
40
40
  * data loading, state views, children, guards, middleware, redirects,
41
41
  * and params/query validation.
42
42
  */
43
- export interface RouteTable {
43
+ export interface RouteTable<T = any> {
44
44
  path: string;
45
45
  id?: string;
46
46
  score?: number;
47
47
  paramKeys?: string[];
48
48
  tags?: string[];
49
- view?: (ctx: RouteCtx, data: any) => Node | DocumentFragment | Node[];
50
- layout?: (ctx: RouteCtx, child: Node | DocumentFragment | Node[], data: any) => Node | DocumentFragment | Node[];
51
- loader?: (ctx: RouteCtx) => Promise<any>;
52
- preload?: (ctx: RouteCtx) => Promise<any>;
49
+ view?: (ctx: RouteCtx, data: T) => Node | DocumentFragment | Node[];
50
+ layout?: (ctx: RouteCtx, child: Node | DocumentFragment | Node[], data: T) => Node | DocumentFragment | Node[];
51
+ loader?: (ctx: RouteCtx) => Promise<T>;
52
+ preload?: (ctx: RouteCtx) => Promise<T>;
53
53
  onMount?: (root: HTMLElement) => void;
54
54
  pending?: (ctx: RouteCtx) => Node | DocumentFragment | Node[];
55
- error?: (ctx: RouteCtx, error: unknown, data?: any) => Node | DocumentFragment | Node[];
55
+ error?: (ctx: RouteCtx, error: unknown, data?: T) => Node | DocumentFragment | Node[];
56
56
  notFound?: (ctx: RouteCtx) => Node | DocumentFragment | Node[];
57
57
  children?: RouteTable[];
58
58
  middleware?: RouteMiddlewareResolver;
@@ -60,6 +60,23 @@ export interface RouteTable {
60
60
  redirect?: string | ((ctx: RouteCtx) => RouteRedirectResult);
61
61
  validation?: RouteValidationResolver;
62
62
  }
63
+ /**
64
+ * Helper to define a single route with full type inference between
65
+ * `loader` return type and the `view` / `layout` / `error` `data` parameter.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * const route = defineRoute({
70
+ * path: '/users',
71
+ * loader: async () => ({ users: await fetchUsers() }),
72
+ * view: (ctx, data) => {
73
+ * // data is inferred as { users: User[] }
74
+ * return fromHtml(tpl, { data });
75
+ * },
76
+ * });
77
+ * ```
78
+ */
79
+ export declare function defineRoute<T = any>(route: RouteTable<T>): RouteTable<T>;
63
80
  /** Immutable snapshot of the current navigation state. */
64
81
  export interface RouteState {
65
82
  path: string;
@@ -1,3 +1,22 @@
1
+ /**
2
+ * Helper to define a single route with full type inference between
3
+ * `loader` return type and the `view` / `layout` / `error` `data` parameter.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * const route = defineRoute({
8
+ * path: '/users',
9
+ * loader: async () => ({ users: await fetchUsers() }),
10
+ * view: (ctx, data) => {
11
+ * // data is inferred as { users: User[] }
12
+ * return fromHtml(tpl, { data });
13
+ * },
14
+ * });
15
+ * ```
16
+ */
17
+ export function defineRoute(route) {
18
+ return route;
19
+ }
1
20
  /** Normalize a path: ensure leading slash, collapse duplicates, strip trailing slash. */
2
21
  export function normalizePath(path) {
3
22
  if (!path)
@@ -41,7 +41,19 @@ export interface BindOptions {
41
41
  export interface BindContext {
42
42
  [key: string]: unknown;
43
43
  }
44
+ /**
45
+ * Convenience alias: any object whose values are `unknown`.
46
+ * Use the generic parameter on `bind<T>()` / `autoBind<T>()` / `fromHtml<T>()`
47
+ * to preserve the concrete type at call sites while still satisfying internal
48
+ * look-ups that index by string key.
49
+ */
50
+ export type BindData<T extends Record<string, unknown> = Record<string, unknown>> = T;
44
51
  export type DisposeFunction = () => void;
52
+ export interface BindHandle {
53
+ (): void;
54
+ getRef(name: string): Element | null;
55
+ getRefs(): Readonly<Record<string, Element>>;
56
+ }
45
57
  /**
46
58
  * Bind a DOM tree to a reactive context.
47
59
  *
@@ -65,7 +77,7 @@ export type DisposeFunction = () => void;
65
77
  * dispose();
66
78
  * ```
67
79
  */
68
- export declare function bind(root: Element, ctx: BindContext, options?: BindOptions): DisposeFunction;
80
+ export declare function bind<T extends Record<string, unknown> = BindContext>(root: Element, ctx: T, options?: BindOptions): BindHandle;
69
81
  /**
70
82
  * Automatically bind when DOM is ready.
71
83
  * Useful for simple pages without a build step.
@@ -78,4 +90,4 @@ export declare function bind(root: Element, ctx: BindContext, options?: BindOpti
78
90
  * </script>
79
91
  * ```
80
92
  */
81
- export declare function autoBind(selector: string, ctx: BindContext, options?: BindOptions): Promise<DisposeFunction>;
93
+ export declare function autoBind<T extends Record<string, unknown> = BindContext>(selector: string, ctx: T, options?: BindOptions): Promise<BindHandle>;
@@ -2445,6 +2445,24 @@ function bindArrayOperations(container, fieldArray, cleanups) {
2445
2445
  }
2446
2446
  }
2447
2447
  // ============================================================================
2448
+ // d-ref — declarative element references
2449
+ // ============================================================================
2450
+ function bindRef(root, refs) {
2451
+ const elements = qsaIncludingRoot(root, '[d-ref]');
2452
+ for (const el of elements) {
2453
+ const name = el.getAttribute('d-ref');
2454
+ if (!name || !name.trim()) {
2455
+ warn('d-ref: empty ref name ignored');
2456
+ continue;
2457
+ }
2458
+ const trimmed = name.trim();
2459
+ if (refs.has(trimmed)) {
2460
+ warn(`d-ref: duplicate ref name "${trimmed}" in the same scope`);
2461
+ }
2462
+ refs.set(trimmed, el);
2463
+ }
2464
+ }
2465
+ // ============================================================================
2448
2466
  // Main bind() Function
2449
2467
  // ============================================================================
2450
2468
  /**
@@ -2484,6 +2502,7 @@ export function bind(root, ctx, options = {}) {
2484
2502
  // Create a scope for this template binding
2485
2503
  const templateScope = createScope();
2486
2504
  const cleanups = [];
2505
+ const refs = new Map();
2487
2506
  linkScopeToDom(templateScope, root, describeBindRoot(root));
2488
2507
  // Run all bindings within the template scope
2489
2508
  withScope(templateScope, () => {
@@ -2495,24 +2514,26 @@ export function bind(root, ctx, options = {}) {
2495
2514
  bindVirtualEach(root, ctx, cleanups);
2496
2515
  // 4. d-each — must run early: removes templates before TreeWalker visits them
2497
2516
  bindEach(root, ctx, cleanups);
2498
- // 5. Text interpolation (template plan cache + lazy parser fallback)
2517
+ // 5. d-ref collect element references (after d-each removes templates)
2518
+ bindRef(root, refs);
2519
+ // 6. Text interpolation (template plan cache + lazy parser fallback)
2499
2520
  bindTextInterpolation(root, ctx, rawTextSelectors, templatePlanCacheConfig, benchSession);
2500
- // 6. d-attr bindings
2521
+ // 7. d-attr bindings
2501
2522
  bindAttrs(root, ctx, cleanups);
2502
- // 7. d-html bindings
2523
+ // 8. d-html bindings
2503
2524
  bindHtml(root, ctx, cleanups);
2504
- // 8. Form fields — register fields with form instances
2525
+ // 9. Form fields — register fields with form instances
2505
2526
  bindField(root, ctx, cleanups);
2506
- // 9. Event bindings
2527
+ // 10. Event bindings
2507
2528
  bindEvents(root, ctx, events, cleanups);
2508
- // 10. d-when directive
2529
+ // 11. d-when directive
2509
2530
  bindWhen(root, ctx, cleanups);
2510
- // 11. d-match directive
2531
+ // 12. d-match directive
2511
2532
  bindMatch(root, ctx, cleanups);
2512
- // 12. Form error displays — BEFORE d-if to bind errors in conditionally rendered sections
2533
+ // 13. Form error displays — BEFORE d-if to bind errors in conditionally rendered sections
2513
2534
  bindError(root, ctx, cleanups);
2514
2535
  bindFormError(root, ctx, cleanups);
2515
- // 13. d-if — must run last: elements are fully bound before conditional removal
2536
+ // 14. d-if — must run last: elements are fully bound before conditional removal
2516
2537
  bindIf(root, ctx, cleanups);
2517
2538
  });
2518
2539
  // Bindings complete: remove loading state and mark as ready.
@@ -2524,8 +2545,8 @@ export function bind(root, ctx, options = {}) {
2524
2545
  });
2525
2546
  }
2526
2547
  flushBindBenchSession(benchSession);
2527
- // Return dispose function
2528
- return () => {
2548
+ // Return BindHandle (callable dispose + ref accessors)
2549
+ const dispose = () => {
2529
2550
  // Run manual cleanups (event listeners)
2530
2551
  for (const cleanup of cleanups) {
2531
2552
  if (typeof cleanup === 'function') {
@@ -2540,6 +2561,7 @@ export function bind(root, ctx, options = {}) {
2540
2561
  }
2541
2562
  }
2542
2563
  cleanups.length = 0;
2564
+ refs.clear();
2543
2565
  // Dispose template scope (stops all effects)
2544
2566
  try {
2545
2567
  templateScope.dispose();
@@ -2550,6 +2572,15 @@ export function bind(root, ctx, options = {}) {
2550
2572
  }
2551
2573
  }
2552
2574
  };
2575
+ const handle = Object.assign(dispose, {
2576
+ getRef(name) {
2577
+ return refs.get(name) ?? null;
2578
+ },
2579
+ getRefs() {
2580
+ return Object.freeze(Object.fromEntries(refs));
2581
+ },
2582
+ });
2583
+ return handle;
2553
2584
  }
2554
2585
  // ============================================================================
2555
2586
  // Convenience: Auto-bind on DOMContentLoaded
@@ -9,9 +9,9 @@
9
9
  * @module dalila/runtime
10
10
  */
11
11
  import type { Scope } from '../core/scope.js';
12
- export interface FromHtmlOptions {
12
+ export interface FromHtmlOptions<T extends Record<string, unknown> = Record<string, unknown>> {
13
13
  /** Bind context — keys map to {placeholder} tokens in the HTML */
14
- data?: Record<string, unknown>;
14
+ data?: T;
15
15
  /** Child nodes to inject into [data-slot="children"] */
16
16
  children?: Node | DocumentFragment | Node[];
17
17
  /** Route scope — registers bind cleanup automatically */
@@ -32,4 +32,5 @@ export interface FromHtmlOptions {
32
32
  * const el = fromHtml('<div><div data-slot="children"></div></div>', { children });
33
33
  * ```
34
34
  */
35
+ export declare function fromHtml<T extends Record<string, unknown>>(html: string, options: FromHtmlOptions<T>): HTMLElement;
35
36
  export declare function fromHtml(html: string, options?: FromHtmlOptions): HTMLElement;
@@ -9,21 +9,6 @@
9
9
  * @module dalila/runtime
10
10
  */
11
11
  import { bind } from './bind.js';
12
- /**
13
- * Parse an HTML string into a bound DOM element.
14
- *
15
- * @example
16
- * ```ts
17
- * // Static HTML
18
- * const el = fromHtml('<div><h1>Hello</h1></div>');
19
- *
20
- * // With data binding
21
- * const el = fromHtml('<div>{name}</div>', { data: { name: 'Dalila' } });
22
- *
23
- * // Layout with children slot
24
- * const el = fromHtml('<div><div data-slot="children"></div></div>', { children });
25
- * ```
26
- */
27
12
  export function fromHtml(html, options = {}) {
28
13
  const { data, children, scope } = options;
29
14
  const template = document.createElement('template');
@@ -7,6 +7,6 @@
7
7
  * @module dalila/runtime
8
8
  */
9
9
  export { bind, autoBind } from './bind.js';
10
- export type { BindOptions, BindContext, DisposeFunction } from './bind.js';
10
+ export type { BindOptions, BindContext, BindData, DisposeFunction, BindHandle } from './bind.js';
11
11
  export { fromHtml } from './fromHtml.js';
12
12
  export type { FromHtmlOptions } from './fromHtml.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dalila",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "description": "DOM-first reactive framework based on signals",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -100,7 +100,7 @@
100
100
  "build": "tsc && node scripts/ensure-cli-executable.cjs",
101
101
  "dev": "tsc --watch",
102
102
  "serve": "node scripts/dev-server.cjs",
103
- "test": "npm run build && node --test",
103
+ "test": "npm run build && node --test --test-concurrency=1 test/*.test.js",
104
104
  "test:e2e": "npm run build && playwright test",
105
105
  "test:watch": "jest --watch",
106
106
  "clean": "rm -rf dist",