jskelet 0.3.5 → 0.4.1
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/CHANGELOG.md +34 -0
- package/bin/jskelet.mjs +20 -7
- package/docs/02-mimari.md +3 -0
- package/docs/03-routing.md +19 -3
- package/docs/04-render-ve-sablonlar.md +94 -9
- package/docs/06-cache.md +62 -0
- package/docs/07-yapilandirma.md +7 -5
- package/docs/08-build.md +5 -0
- package/docs/en/02-architecture.md +3 -0
- package/docs/en/03-routing.md +19 -4
- package/docs/en/04-rendering.md +96 -12
- package/docs/en/06-caching.md +133 -71
- package/docs/en/07-configuration.md +5 -3
- package/docs/en/08-build.md +5 -0
- package/package.json +2 -2
- package/src/build/build.mjs +6 -0
- package/src/build/ensure-build.mjs +5 -1
- package/src/build/tasks/icons.mjs +2 -2
- package/src/build/tasks/templates.mjs +20 -0
- package/src/compile/codegen.js +332 -0
- package/src/compile/compile-all.js +158 -0
- package/src/compile/errors.js +66 -0
- package/src/compile/expr.js +404 -0
- package/src/compile/index.js +17 -0
- package/src/compile/parse.js +485 -0
- package/src/compile/resolve.js +208 -0
- package/src/compile/scan-exports.js +51 -0
- package/src/config/defaults.js +6 -2
- package/src/config/index.js +7 -0
- package/src/dev-server.mjs +4 -2
- package/src/generate.mjs +163 -0
- package/src/init.mjs +8 -6
- package/src/server/render.js +153 -40
- package/src/server/router.js +26 -3
- package/src/views/components/loader.js +34 -18
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.jsk` şablon ayrıştırıcı. HTML'e yakın sözdizimi + blok direktifleri.
|
|
3
|
+
* İstek anında çalışmaz; yalnızca build-time.
|
|
4
|
+
*/
|
|
5
|
+
import { CompileError } from "./errors.js";
|
|
6
|
+
import { parseExpr } from "./expr.js";
|
|
7
|
+
|
|
8
|
+
/** Self-closing HTML void elements. */
|
|
9
|
+
const VOID = new Set([
|
|
10
|
+
"area",
|
|
11
|
+
"base",
|
|
12
|
+
"br",
|
|
13
|
+
"col",
|
|
14
|
+
"embed",
|
|
15
|
+
"hr",
|
|
16
|
+
"img",
|
|
17
|
+
"input",
|
|
18
|
+
"link",
|
|
19
|
+
"meta",
|
|
20
|
+
"param",
|
|
21
|
+
"source",
|
|
22
|
+
"track",
|
|
23
|
+
"wbr",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {import('./expr.js').ExprNode} ExprNode
|
|
28
|
+
* @typedef {{ type: 'Text', value: string }} TextNode
|
|
29
|
+
* @typedef {{ type: 'Interp', expr: ExprNode, raw: boolean, index: number }} InterpNode
|
|
30
|
+
* @typedef {{ name: string, value: string | null, expr: ExprNode | null, bound: boolean, boolean: boolean, index: number }} AttrNode
|
|
31
|
+
* @typedef {{ type: 'Element', name: string, attrs: AttrNode[], children: AstNode[], selfClosing: boolean, index: number }} ElementNode
|
|
32
|
+
* @typedef {{ type: 'Component', name: string, attrs: AttrNode[], children: AstNode[], selfClosing: boolean, index: number }} ComponentNode
|
|
33
|
+
* @typedef {{ type: 'If', test: ExprNode, consequent: AstNode[], alternate: AstNode[], index: number }} IfNode
|
|
34
|
+
* @typedef {{ type: 'Each', list: ExprNode, item: string, indexName: string | null, children: AstNode[], index: number }} EachNode
|
|
35
|
+
* @typedef {{ type: 'Include', path: string, index: number }} IncludeNode
|
|
36
|
+
* @typedef {TextNode | InterpNode | ElementNode | ComponentNode | IfNode | EachNode | IncludeNode} AstNode
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} source
|
|
41
|
+
* @param {{ file?: string }} [options]
|
|
42
|
+
* @returns {AstNode[]}
|
|
43
|
+
*/
|
|
44
|
+
export function parseTemplate(source, options = {}) {
|
|
45
|
+
const parser = new TemplateParser(source, options.file);
|
|
46
|
+
return parser.parseNodes();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* PascalCase bileşen mi?
|
|
51
|
+
* @param {string} name
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
export function isComponentTag(name) {
|
|
55
|
+
return /^[A-Z]/.test(name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class TemplateParser {
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} source
|
|
61
|
+
* @param {string} [file]
|
|
62
|
+
*/
|
|
63
|
+
constructor(source, file) {
|
|
64
|
+
this.source = source;
|
|
65
|
+
this.file = file;
|
|
66
|
+
this.i = 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} message
|
|
71
|
+
* @param {number} [index]
|
|
72
|
+
* @returns {CompileError}
|
|
73
|
+
*/
|
|
74
|
+
error(message, index = this.i) {
|
|
75
|
+
return new CompileError(message, {
|
|
76
|
+
file: this.file,
|
|
77
|
+
source: this.source,
|
|
78
|
+
index,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
eof() {
|
|
83
|
+
return this.i >= this.source.length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
peek(n = 0) {
|
|
87
|
+
return this.source[this.i + n] ?? "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
startsWith(value) {
|
|
91
|
+
return this.source.startsWith(value, this.i);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {string} [until]
|
|
96
|
+
* @returns {AstNode[]}
|
|
97
|
+
*/
|
|
98
|
+
parseNodes(until) {
|
|
99
|
+
/** @type {AstNode[]} */
|
|
100
|
+
const nodes = [];
|
|
101
|
+
while (!this.eof()) {
|
|
102
|
+
if (until && this.startsWith(until)) break;
|
|
103
|
+
|
|
104
|
+
if (this.startsWith("<!--")) {
|
|
105
|
+
this.skipComment();
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (this.startsWith("{#")) {
|
|
110
|
+
nodes.push(this.parseDirective());
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (this.startsWith("{{")) {
|
|
115
|
+
nodes.push(this.parseInterp());
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (this.startsWith("</")) {
|
|
120
|
+
// Parent closes — leave for caller.
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (this.peek() === "<" && /[A-Za-z]/.test(this.peek(1))) {
|
|
125
|
+
nodes.push(this.parseTag());
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
nodes.push(this.parseText(until));
|
|
130
|
+
}
|
|
131
|
+
return nodes;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
skipComment() {
|
|
135
|
+
const end = this.source.indexOf("-->", this.i + 4);
|
|
136
|
+
if (end === -1) throw this.error("Unterminated HTML comment");
|
|
137
|
+
this.i = end + 3;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {string} [until]
|
|
142
|
+
* @returns {TextNode}
|
|
143
|
+
*/
|
|
144
|
+
parseText(until) {
|
|
145
|
+
const start = this.i;
|
|
146
|
+
while (!this.eof()) {
|
|
147
|
+
if (this.startsWith("{{") || this.startsWith("{#") || this.startsWith("<!--")) break;
|
|
148
|
+
if (this.peek() === "<") break;
|
|
149
|
+
if (until && this.startsWith(until)) break;
|
|
150
|
+
this.i += 1;
|
|
151
|
+
}
|
|
152
|
+
return { type: "Text", value: this.source.slice(start, this.i) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** @returns {InterpNode} */
|
|
156
|
+
parseInterp() {
|
|
157
|
+
const index = this.i;
|
|
158
|
+
let raw = false;
|
|
159
|
+
if (this.startsWith("{{{")) {
|
|
160
|
+
raw = true;
|
|
161
|
+
this.i += 3;
|
|
162
|
+
} else {
|
|
163
|
+
this.i += 2;
|
|
164
|
+
}
|
|
165
|
+
const close = raw ? "}}}" : "}}";
|
|
166
|
+
const end = this.source.indexOf(close, this.i);
|
|
167
|
+
if (end === -1) throw this.error("Unterminated interpolation", index);
|
|
168
|
+
const exprSrc = this.source.slice(this.i, end).trim();
|
|
169
|
+
if (!exprSrc) throw this.error("Empty interpolation", index);
|
|
170
|
+
const expr = parseExpr(exprSrc, 0, exprSrc.length, {
|
|
171
|
+
file: this.file,
|
|
172
|
+
templateSource: this.source,
|
|
173
|
+
templateIndex: this.i,
|
|
174
|
+
});
|
|
175
|
+
this.i = end + close.length;
|
|
176
|
+
return { type: "Interp", expr, raw, index };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** @returns {IfNode | EachNode | IncludeNode} */
|
|
180
|
+
parseDirective() {
|
|
181
|
+
const index = this.i;
|
|
182
|
+
if (this.startsWith("{#if")) {
|
|
183
|
+
return this.parseIf(index);
|
|
184
|
+
}
|
|
185
|
+
if (this.startsWith("{#each")) {
|
|
186
|
+
return this.parseEach(index);
|
|
187
|
+
}
|
|
188
|
+
if (this.startsWith("{#include")) {
|
|
189
|
+
return this.parseInclude(index);
|
|
190
|
+
}
|
|
191
|
+
// `{# comment #}` — kapanış `#}` zorunlu; yoksa bilinmeyen direktif.
|
|
192
|
+
if (this.startsWith("{#")) {
|
|
193
|
+
const end = this.source.indexOf("#}", this.i + 2);
|
|
194
|
+
if (end === -1) {
|
|
195
|
+
throw this.error("Unknown directive", index);
|
|
196
|
+
}
|
|
197
|
+
this.i = end + 2;
|
|
198
|
+
return { type: "Text", value: "" };
|
|
199
|
+
}
|
|
200
|
+
throw this.error("Unknown directive", index);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {number} index
|
|
205
|
+
* @returns {IfNode}
|
|
206
|
+
*/
|
|
207
|
+
parseIf(index) {
|
|
208
|
+
this.i += 4; // {#if
|
|
209
|
+
const closeBrace = this.source.indexOf("}", this.i);
|
|
210
|
+
if (closeBrace === -1) throw this.error("Unterminated {#if}", index);
|
|
211
|
+
const testSrc = this.source.slice(this.i, closeBrace).trim();
|
|
212
|
+
if (!testSrc) throw this.error("Empty {#if} condition", index);
|
|
213
|
+
const test = parseExpr(testSrc, 0, testSrc.length, {
|
|
214
|
+
file: this.file,
|
|
215
|
+
templateSource: this.source,
|
|
216
|
+
templateIndex: this.i,
|
|
217
|
+
});
|
|
218
|
+
this.i = closeBrace + 1;
|
|
219
|
+
|
|
220
|
+
const consequent = this.parseNodesUntil(["{#else}", "{/if}"]);
|
|
221
|
+
/** @type {AstNode[]} */
|
|
222
|
+
let alternate = [];
|
|
223
|
+
|
|
224
|
+
if (this.startsWith("{#else}")) {
|
|
225
|
+
this.i += "{#else}".length;
|
|
226
|
+
alternate = this.parseNodesUntil(["{/if}"]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!this.startsWith("{/if}")) {
|
|
230
|
+
throw this.error("Expected {/if}", this.i);
|
|
231
|
+
}
|
|
232
|
+
this.i += "{/if}".length;
|
|
233
|
+
|
|
234
|
+
return { type: "If", test, consequent, alternate, index };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param {string[]} markers
|
|
239
|
+
* @returns {AstNode[]}
|
|
240
|
+
*/
|
|
241
|
+
parseNodesUntil(markers) {
|
|
242
|
+
/** @type {AstNode[]} */
|
|
243
|
+
const nodes = [];
|
|
244
|
+
while (!this.eof()) {
|
|
245
|
+
if (markers.some((m) => this.startsWith(m))) break;
|
|
246
|
+
|
|
247
|
+
if (this.startsWith("<!--")) {
|
|
248
|
+
this.skipComment();
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (this.startsWith("{#if") || this.startsWith("{#each") || this.startsWith("{#include")) {
|
|
252
|
+
nodes.push(this.parseDirective());
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (this.startsWith("{#")) {
|
|
256
|
+
// comment or unknown — parseDirective handles
|
|
257
|
+
const node = this.parseDirective();
|
|
258
|
+
if (node.type !== "Text" || node.value) nodes.push(node);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (this.startsWith("{{")) {
|
|
262
|
+
nodes.push(this.parseInterp());
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (this.startsWith("</")) break;
|
|
266
|
+
if (this.peek() === "<" && /[A-Za-z]/.test(this.peek(1))) {
|
|
267
|
+
nodes.push(this.parseTag());
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
// Text until next special or marker
|
|
271
|
+
const start = this.i;
|
|
272
|
+
while (!this.eof()) {
|
|
273
|
+
if (markers.some((m) => this.startsWith(m))) break;
|
|
274
|
+
if (this.startsWith("{{") || this.startsWith("{#") || this.startsWith("<!--")) break;
|
|
275
|
+
if (this.peek() === "<") break;
|
|
276
|
+
this.i += 1;
|
|
277
|
+
}
|
|
278
|
+
if (this.i > start) {
|
|
279
|
+
nodes.push({ type: "Text", value: this.source.slice(start, this.i) });
|
|
280
|
+
} else if (!this.eof() && !markers.some((m) => this.startsWith(m))) {
|
|
281
|
+
// Stuck on unexpected char
|
|
282
|
+
throw this.error(`Unexpected character '${this.peek()}'`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return nodes;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* @param {number} index
|
|
290
|
+
* @returns {EachNode}
|
|
291
|
+
*/
|
|
292
|
+
parseEach(index) {
|
|
293
|
+
this.i += 6; // {#each
|
|
294
|
+
const closeBrace = this.source.indexOf("}", this.i);
|
|
295
|
+
if (closeBrace === -1) throw this.error("Unterminated {#each}", index);
|
|
296
|
+
const head = this.source.slice(this.i, closeBrace).trim();
|
|
297
|
+
// `items as item` or `items as item, i`
|
|
298
|
+
const asMatch = head.match(/^(.+?)\s+as\s+([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*))?\s*$/);
|
|
299
|
+
if (!asMatch) {
|
|
300
|
+
throw this.error(
|
|
301
|
+
'Invalid {#each}: expected `{#each items as item}` or `{#each items as item, i}`',
|
|
302
|
+
index,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
const list = parseExpr(asMatch[1].trim(), 0, asMatch[1].trim().length, {
|
|
306
|
+
file: this.file,
|
|
307
|
+
templateSource: this.source,
|
|
308
|
+
templateIndex: this.i,
|
|
309
|
+
});
|
|
310
|
+
this.i = closeBrace + 1;
|
|
311
|
+
const children = this.parseNodesUntil(["{/each}"]);
|
|
312
|
+
if (!this.startsWith("{/each}")) {
|
|
313
|
+
throw this.error("Expected {/each}", this.i);
|
|
314
|
+
}
|
|
315
|
+
this.i += "{/each}".length;
|
|
316
|
+
return {
|
|
317
|
+
type: "Each",
|
|
318
|
+
list,
|
|
319
|
+
item: asMatch[2],
|
|
320
|
+
indexName: asMatch[3] ?? null,
|
|
321
|
+
children,
|
|
322
|
+
index,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* @param {number} index
|
|
328
|
+
* @returns {IncludeNode}
|
|
329
|
+
*/
|
|
330
|
+
parseInclude(index) {
|
|
331
|
+
this.i += 9; // {#include
|
|
332
|
+
const closeBrace = this.source.indexOf("}", this.i);
|
|
333
|
+
if (closeBrace === -1) throw this.error("Unterminated {#include}", index);
|
|
334
|
+
const raw = this.source.slice(this.i, closeBrace).trim();
|
|
335
|
+
const strMatch = raw.match(/^["']([^"']+)["']$/);
|
|
336
|
+
if (!strMatch) {
|
|
337
|
+
throw this.error('{#include} path must be a string literal', index);
|
|
338
|
+
}
|
|
339
|
+
this.i = closeBrace + 1;
|
|
340
|
+
return { type: "Include", path: strMatch[1], index };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** @returns {ElementNode | ComponentNode} */
|
|
344
|
+
parseTag() {
|
|
345
|
+
const index = this.i;
|
|
346
|
+
this.i += 1; // <
|
|
347
|
+
const nameStart = this.i;
|
|
348
|
+
while (!this.eof() && /[A-Za-z0-9_-]/.test(this.peek())) this.i += 1;
|
|
349
|
+
const name = this.source.slice(nameStart, this.i);
|
|
350
|
+
if (!name) throw this.error("Expected tag name", index);
|
|
351
|
+
|
|
352
|
+
const attrs = this.parseAttrs();
|
|
353
|
+
this.skipWs();
|
|
354
|
+
|
|
355
|
+
let selfClosing = false;
|
|
356
|
+
if (this.startsWith("/>")) {
|
|
357
|
+
selfClosing = true;
|
|
358
|
+
this.i += 2;
|
|
359
|
+
} else if (this.peek() === ">") {
|
|
360
|
+
this.i += 1;
|
|
361
|
+
} else {
|
|
362
|
+
throw this.error("Expected '>' or '/>'", this.i);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const component = isComponentTag(name);
|
|
366
|
+
const voidEl = !component && VOID.has(name.toLowerCase());
|
|
367
|
+
|
|
368
|
+
/** @type {AstNode[]} */
|
|
369
|
+
let children = [];
|
|
370
|
+
if (!selfClosing && !voidEl) {
|
|
371
|
+
children = this.parseNodesUntil([`</${name}>`]);
|
|
372
|
+
// Also allow parseNodes style — check closing tag
|
|
373
|
+
if (!this.startsWith(`</${name}>`)) {
|
|
374
|
+
// try case-sensitive exact
|
|
375
|
+
throw this.error(`Expected </${name}>`, this.i);
|
|
376
|
+
}
|
|
377
|
+
this.i += `</${name}>`.length;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (component) {
|
|
381
|
+
return { type: "Component", name, attrs, children, selfClosing, index };
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
type: "Element",
|
|
385
|
+
name,
|
|
386
|
+
attrs,
|
|
387
|
+
children,
|
|
388
|
+
selfClosing: selfClosing || voidEl,
|
|
389
|
+
index,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
skipWs() {
|
|
394
|
+
while (!this.eof() && /\s/.test(this.peek())) this.i += 1;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** @returns {AttrNode[]} */
|
|
398
|
+
parseAttrs() {
|
|
399
|
+
/** @type {AttrNode[]} */
|
|
400
|
+
const attrs = [];
|
|
401
|
+
for (;;) {
|
|
402
|
+
this.skipWs();
|
|
403
|
+
if (this.peek() === ">" || this.startsWith("/>") || this.eof()) break;
|
|
404
|
+
|
|
405
|
+
const index = this.i;
|
|
406
|
+
let bound = false;
|
|
407
|
+
if (this.peek() === ":") {
|
|
408
|
+
bound = true;
|
|
409
|
+
this.i += 1;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!/[A-Za-z_]/.test(this.peek())) {
|
|
413
|
+
throw this.error("Expected attribute name", this.i);
|
|
414
|
+
}
|
|
415
|
+
const nameStart = this.i;
|
|
416
|
+
while (!this.eof() && /[A-Za-z0-9_:-]/.test(this.peek())) this.i += 1;
|
|
417
|
+
let name = this.source.slice(nameStart, this.i);
|
|
418
|
+
// Strip leading colon already handled; name may include data-*
|
|
419
|
+
if (bound && name.startsWith(":")) name = name.slice(1);
|
|
420
|
+
|
|
421
|
+
this.skipWs();
|
|
422
|
+
if (this.peek() !== "=") {
|
|
423
|
+
attrs.push({
|
|
424
|
+
name,
|
|
425
|
+
value: null,
|
|
426
|
+
expr: null,
|
|
427
|
+
bound: false,
|
|
428
|
+
boolean: true,
|
|
429
|
+
index,
|
|
430
|
+
});
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
this.i += 1;
|
|
434
|
+
this.skipWs();
|
|
435
|
+
|
|
436
|
+
if (bound) {
|
|
437
|
+
// :name="expr" or :name='expr'
|
|
438
|
+
const quote = this.peek();
|
|
439
|
+
if (quote !== '"' && quote !== "'") {
|
|
440
|
+
throw this.error("Bound attribute value must be quoted", this.i);
|
|
441
|
+
}
|
|
442
|
+
this.i += 1;
|
|
443
|
+
const end = this.source.indexOf(quote, this.i);
|
|
444
|
+
if (end === -1) throw this.error("Unterminated attribute value", index);
|
|
445
|
+
const exprSrc = this.source.slice(this.i, end).trim();
|
|
446
|
+
const expr = parseExpr(exprSrc, 0, exprSrc.length, {
|
|
447
|
+
file: this.file,
|
|
448
|
+
templateSource: this.source,
|
|
449
|
+
templateIndex: this.i,
|
|
450
|
+
});
|
|
451
|
+
this.i = end + 1;
|
|
452
|
+
attrs.push({ name, value: null, expr, bound: true, boolean: false, index });
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Literal attribute
|
|
457
|
+
const quote = this.peek();
|
|
458
|
+
if (quote === '"' || quote === "'") {
|
|
459
|
+
this.i += 1;
|
|
460
|
+
const end = this.source.indexOf(quote, this.i);
|
|
461
|
+
if (end === -1) throw this.error("Unterminated attribute value", index);
|
|
462
|
+
const value = this.source.slice(this.i, end);
|
|
463
|
+
this.i = end + 1;
|
|
464
|
+
// Allow {{ }} inside attribute values? Skip for v1 — use :bind
|
|
465
|
+
if (value.includes("{{")) {
|
|
466
|
+
throw this.error(
|
|
467
|
+
`Interpolation inside attribute "${name}" is not supported; use :${name}="..."`,
|
|
468
|
+
index,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
attrs.push({ name, value, expr: null, bound: false, boolean: false, index });
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Unquoted
|
|
476
|
+
const vStart = this.i;
|
|
477
|
+
while (!this.eof() && !/[\s>]/.test(this.peek()) && !this.startsWith("/>")) {
|
|
478
|
+
this.i += 1;
|
|
479
|
+
}
|
|
480
|
+
const value = this.source.slice(vStart, this.i);
|
|
481
|
+
attrs.push({ name, value, expr: null, bound: false, boolean: false, index });
|
|
482
|
+
}
|
|
483
|
+
return attrs;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* View kökleri ve `.jsk` dosya keşfi.
|
|
3
|
+
*
|
|
4
|
+
* Sıra: views, sonra shared/views, sonra her feature altındaki views.
|
|
5
|
+
* Aynı view id birden fazla kökte varsa son bulunan kazanır.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { toPascalCase } from "./codegen.js";
|
|
10
|
+
import { CompileError } from "./errors.js";
|
|
11
|
+
import { scanNamedExports } from "./scan-exports.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runtime'ın camelCase → PascalCase alias'ı ile aynı kural.
|
|
15
|
+
* @param {string} name
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function toComponentTag(name) {
|
|
19
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{ root: string, dirs: Record<string, string> }} config
|
|
24
|
+
* @returns {string[]} Mutlak view kökleri (var olanlar).
|
|
25
|
+
*/
|
|
26
|
+
export function getViewRoots(config) {
|
|
27
|
+
/** @type {string[]} */
|
|
28
|
+
const roots = [];
|
|
29
|
+
const { dirs, root } = config;
|
|
30
|
+
|
|
31
|
+
if (dirs.views && fs.existsSync(dirs.views)) {
|
|
32
|
+
roots.push(dirs.views);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sharedViews = dirs.shared
|
|
36
|
+
? path.join(dirs.shared, "views")
|
|
37
|
+
: path.join(root, "shared", "views");
|
|
38
|
+
if (fs.existsSync(sharedViews)) roots.push(sharedViews);
|
|
39
|
+
|
|
40
|
+
const featuresDir = dirs.features ?? path.join(root, "features");
|
|
41
|
+
if (fs.existsSync(featuresDir)) {
|
|
42
|
+
const features = fs
|
|
43
|
+
.readdirSync(featuresDir, { withFileTypes: true })
|
|
44
|
+
.filter((e) => e.isDirectory())
|
|
45
|
+
.map((e) => e.name)
|
|
46
|
+
.sort((a, b) => a.localeCompare(b));
|
|
47
|
+
|
|
48
|
+
for (const name of features) {
|
|
49
|
+
const views = path.join(featuresDir, name, "views");
|
|
50
|
+
if (fs.existsSync(views)) roots.push(views);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return roots;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Bileşen dizinleri: her view kökü altındaki `components/`.
|
|
59
|
+
* @param {{ root: string, dirs: Record<string, string> }} config
|
|
60
|
+
* @returns {string[]}
|
|
61
|
+
*/
|
|
62
|
+
export function getComponentDirs(config) {
|
|
63
|
+
return getViewRoots(config)
|
|
64
|
+
.map((root) => path.join(root, "components"))
|
|
65
|
+
.filter((dir) => fs.existsSync(dir));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string[]} viewRoots
|
|
70
|
+
* @returns {Map<string, string>} viewId → mutlak `.jsk` yolu
|
|
71
|
+
*/
|
|
72
|
+
export function discoverJskFiles(viewRoots) {
|
|
73
|
+
/** @type {Map<string, string>} */
|
|
74
|
+
const map = new Map();
|
|
75
|
+
|
|
76
|
+
for (const root of viewRoots) {
|
|
77
|
+
collectJsk(root, root, map);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return map;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} dir
|
|
85
|
+
* @param {string} root
|
|
86
|
+
* @param {Map<string, string>} out
|
|
87
|
+
*/
|
|
88
|
+
function collectJsk(dir, root, out) {
|
|
89
|
+
if (!fs.existsSync(dir)) return;
|
|
90
|
+
|
|
91
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
92
|
+
const full = path.join(dir, entry.name);
|
|
93
|
+
if (entry.isDirectory()) {
|
|
94
|
+
collectJsk(full, root, out);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!entry.name.endsWith(".jsk")) continue;
|
|
98
|
+
const rel = path.relative(root, full).split(path.sep).join("/");
|
|
99
|
+
const viewId = rel.replace(/\.jsk$/i, "");
|
|
100
|
+
out.set(viewId, full);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `components/foo-bar.jsk` → `FooBar`
|
|
106
|
+
* @param {string} viewId
|
|
107
|
+
* @returns {string | null}
|
|
108
|
+
*/
|
|
109
|
+
export function componentNameFromViewId(viewId) {
|
|
110
|
+
const parts = viewId.split("/");
|
|
111
|
+
if (parts[0] !== "components" || parts.length < 2) return null;
|
|
112
|
+
const base = parts[parts.length - 1];
|
|
113
|
+
return toPascalCase(base);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Bilinen bileşen adları: JS named export'lar + derlenecek `.jsk` bileşenleri
|
|
118
|
+
* + yerleşik etiketler.
|
|
119
|
+
*
|
|
120
|
+
* JS tarafında dosya adı varsayılmaz; kaynak metinden `export` adları okunur.
|
|
121
|
+
* Aynı export (veya aynı PascalCase etiket) iki dosyada varsa derleme hatası.
|
|
122
|
+
*
|
|
123
|
+
* @param {string[]} componentDirs
|
|
124
|
+
* @param {Map<string, string>} jskFiles
|
|
125
|
+
* @returns {Set<string>}
|
|
126
|
+
*/
|
|
127
|
+
export function collectKnownComponents(componentDirs, jskFiles) {
|
|
128
|
+
const known = new Set([
|
|
129
|
+
"Link",
|
|
130
|
+
"Image",
|
|
131
|
+
"Icon",
|
|
132
|
+
"CsrfField",
|
|
133
|
+
"PreloadImage",
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
/** @type {Map<string, string>} export adı → göreli yol */
|
|
137
|
+
const byName = new Map();
|
|
138
|
+
/** @type {Map<string, string>} PascalCase etiket → göreli yol */
|
|
139
|
+
const byTag = new Map();
|
|
140
|
+
|
|
141
|
+
for (const [viewId] of jskFiles) {
|
|
142
|
+
const name = componentNameFromViewId(viewId);
|
|
143
|
+
if (!name) continue;
|
|
144
|
+
known.add(name);
|
|
145
|
+
byTag.set(name, `${viewId}.jsk`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const dir of componentDirs) {
|
|
149
|
+
collectJsComponentNames(dir, known, byName, byTag);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return known;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {string} name
|
|
157
|
+
* @param {string} origin
|
|
158
|
+
* @param {Map<string, string>} byName
|
|
159
|
+
* @param {Map<string, string>} byTag
|
|
160
|
+
*/
|
|
161
|
+
function registerExportOrigin(name, origin, byName, byTag) {
|
|
162
|
+
const previousName = byName.get(name);
|
|
163
|
+
if (previousName && previousName !== origin) {
|
|
164
|
+
throw new CompileError(
|
|
165
|
+
`Component '${name}' is defined twice: ${previousName} and ${origin}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
byName.set(name, origin);
|
|
169
|
+
|
|
170
|
+
const tag = toComponentTag(name);
|
|
171
|
+
const previousTag = byTag.get(tag);
|
|
172
|
+
if (previousTag && previousTag !== origin) {
|
|
173
|
+
throw new CompileError(
|
|
174
|
+
`Component '${tag}' is defined twice: ${previousTag} and ${origin}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
byTag.set(tag, origin);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {string} dir
|
|
182
|
+
* @param {Set<string>} out
|
|
183
|
+
* @param {Map<string, string>} byName
|
|
184
|
+
* @param {Map<string, string>} byTag
|
|
185
|
+
*/
|
|
186
|
+
function collectJsComponentNames(dir, out, byName, byTag) {
|
|
187
|
+
if (!fs.existsSync(dir)) return;
|
|
188
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
189
|
+
const full = path.join(dir, entry.name);
|
|
190
|
+
if (entry.isDirectory()) {
|
|
191
|
+
collectJsComponentNames(full, out, byName, byTag);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (!entry.name.endsWith(".js")) continue;
|
|
195
|
+
if (entry.name === "loader.js" || entry.name === "index.js") continue;
|
|
196
|
+
|
|
197
|
+
// Kimlik mutlak yol: çoklu kökte iki `list.js` aynı göreli ada sahip olabilir.
|
|
198
|
+
const origin = full.split(path.sep).join("/");
|
|
199
|
+
const source = fs.readFileSync(full, "utf8");
|
|
200
|
+
const exports = scanNamedExports(source);
|
|
201
|
+
|
|
202
|
+
for (const name of exports) {
|
|
203
|
+
registerExportOrigin(name, origin, byName, byTag);
|
|
204
|
+
out.add(name);
|
|
205
|
+
out.add(toComponentTag(name));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|