dalila 1.9.2 → 1.9.4
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 +35 -23
- package/dist/cli/check.d.ts +3 -0
- package/dist/cli/check.js +902 -0
- package/dist/cli/index.js +53 -11
- package/dist/cli/routes-generator.d.ts +25 -0
- package/dist/cli/routes-generator.js +28 -7
- package/dist/router/route-tables.d.ts +28 -7
- package/dist/router/route-tables.js +19 -0
- package/dist/router/router.js +87 -3
- package/dist/routes.generated.d.ts +2 -0
- package/dist/routes.generated.js +76 -0
- package/dist/routes.generated.manifest.d.ts +4 -0
- package/dist/routes.generated.manifest.js +32 -0
- package/dist/routes.generated.types.d.ts +11 -0
- package/dist/routes.generated.types.js +37 -0
- package/dist/runtime/bind.d.ts +47 -2
- package/dist/runtime/bind.js +702 -26
- package/dist/runtime/component.d.ts +74 -0
- package/dist/runtime/component.js +40 -0
- package/dist/runtime/fromHtml.d.ts +3 -2
- package/dist/runtime/fromHtml.js +0 -15
- package/dist/runtime/index.d.ts +4 -2
- package/dist/runtime/index.js +2 -1
- package/package.json +2 -2
- package/scripts/dev-server.cjs +47 -7
|
@@ -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
|
+
}
|