jskelet 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -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 +60 -6
- 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 +61 -9
- 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 +15 -0
- package/src/compile/parse.js +485 -0
- package/src/compile/resolve.js +159 -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 +26 -13
|
@@ -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,159 @@
|
|
|
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
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {{ root: string, dirs: Record<string, string> }} config
|
|
13
|
+
* @returns {string[]} Mutlak view kökleri (var olanlar).
|
|
14
|
+
*/
|
|
15
|
+
export function getViewRoots(config) {
|
|
16
|
+
/** @type {string[]} */
|
|
17
|
+
const roots = [];
|
|
18
|
+
const { dirs, root } = config;
|
|
19
|
+
|
|
20
|
+
if (dirs.views && fs.existsSync(dirs.views)) {
|
|
21
|
+
roots.push(dirs.views);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const sharedViews = dirs.shared
|
|
25
|
+
? path.join(dirs.shared, "views")
|
|
26
|
+
: path.join(root, "shared", "views");
|
|
27
|
+
if (fs.existsSync(sharedViews)) roots.push(sharedViews);
|
|
28
|
+
|
|
29
|
+
const featuresDir = dirs.features ?? path.join(root, "features");
|
|
30
|
+
if (fs.existsSync(featuresDir)) {
|
|
31
|
+
const features = fs
|
|
32
|
+
.readdirSync(featuresDir, { withFileTypes: true })
|
|
33
|
+
.filter((e) => e.isDirectory())
|
|
34
|
+
.map((e) => e.name)
|
|
35
|
+
.sort((a, b) => a.localeCompare(b));
|
|
36
|
+
|
|
37
|
+
for (const name of features) {
|
|
38
|
+
const views = path.join(featuresDir, name, "views");
|
|
39
|
+
if (fs.existsSync(views)) roots.push(views);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return roots;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Bileşen dizinleri: her view kökü altındaki `components/`.
|
|
48
|
+
* @param {{ root: string, dirs: Record<string, string> }} config
|
|
49
|
+
* @returns {string[]}
|
|
50
|
+
*/
|
|
51
|
+
export function getComponentDirs(config) {
|
|
52
|
+
return getViewRoots(config)
|
|
53
|
+
.map((root) => path.join(root, "components"))
|
|
54
|
+
.filter((dir) => fs.existsSync(dir));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string[]} viewRoots
|
|
59
|
+
* @returns {Map<string, string>} viewId → mutlak `.jsk` yolu
|
|
60
|
+
*/
|
|
61
|
+
export function discoverJskFiles(viewRoots) {
|
|
62
|
+
/** @type {Map<string, string>} */
|
|
63
|
+
const map = new Map();
|
|
64
|
+
|
|
65
|
+
for (const root of viewRoots) {
|
|
66
|
+
collectJsk(root, root, map);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return map;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} dir
|
|
74
|
+
* @param {string} root
|
|
75
|
+
* @param {Map<string, string>} out
|
|
76
|
+
*/
|
|
77
|
+
function collectJsk(dir, root, out) {
|
|
78
|
+
if (!fs.existsSync(dir)) return;
|
|
79
|
+
|
|
80
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
81
|
+
const full = path.join(dir, entry.name);
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
collectJsk(full, root, out);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!entry.name.endsWith(".jsk")) continue;
|
|
87
|
+
const rel = path.relative(root, full).split(path.sep).join("/");
|
|
88
|
+
const viewId = rel.replace(/\.jsk$/i, "");
|
|
89
|
+
out.set(viewId, full);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `components/foo-bar.jsk` → `FooBar`
|
|
95
|
+
* @param {string} viewId
|
|
96
|
+
* @returns {string | null}
|
|
97
|
+
*/
|
|
98
|
+
export function componentNameFromViewId(viewId) {
|
|
99
|
+
const parts = viewId.split("/");
|
|
100
|
+
if (parts[0] !== "components" || parts.length < 2) return null;
|
|
101
|
+
const base = parts[parts.length - 1];
|
|
102
|
+
return toPascalCase(base);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Bilinen bileşen adları: JS named export'lar + derlenecek `.jsk` bileşenleri
|
|
107
|
+
* + yerleşik etiketler.
|
|
108
|
+
*
|
|
109
|
+
* @param {string[]} componentDirs
|
|
110
|
+
* @param {Map<string, string>} jskFiles
|
|
111
|
+
* @returns {Set<string>}
|
|
112
|
+
*/
|
|
113
|
+
export function collectKnownComponents(componentDirs, jskFiles) {
|
|
114
|
+
const known = new Set([
|
|
115
|
+
"Link",
|
|
116
|
+
"Image",
|
|
117
|
+
"Icon",
|
|
118
|
+
"CsrfField",
|
|
119
|
+
"PreloadImage",
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
for (const [viewId] of jskFiles) {
|
|
123
|
+
const name = componentNameFromViewId(viewId);
|
|
124
|
+
if (name) known.add(name);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const dir of componentDirs) {
|
|
128
|
+
collectJsComponentNames(dir, dir, known);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return known;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {string} dir
|
|
136
|
+
* @param {string} root
|
|
137
|
+
* @param {Set<string>} out
|
|
138
|
+
*/
|
|
139
|
+
function collectJsComponentNames(dir, root, out) {
|
|
140
|
+
if (!fs.existsSync(dir)) return;
|
|
141
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
142
|
+
const full = path.join(dir, entry.name);
|
|
143
|
+
if (entry.isDirectory()) {
|
|
144
|
+
collectJsComponentNames(full, root, out);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (!entry.name.endsWith(".js")) continue;
|
|
148
|
+
if (entry.name === "loader.js") continue;
|
|
149
|
+
// Dosya adından PascalCase tahmin — export adı dosya içinde olabilir;
|
|
150
|
+
// bilinmeyen bileşen uyarısını azaltmak için her iki biçimi ekle.
|
|
151
|
+
const base = entry.name.replace(/\.js$/, "");
|
|
152
|
+
if (base === "index") continue;
|
|
153
|
+
out.add(toPascalCase(base));
|
|
154
|
+
// camelCase export'lar da yaygın: `list` → List ve list
|
|
155
|
+
out.add(base);
|
|
156
|
+
const pascal = toPascalCase(base);
|
|
157
|
+
out.add(pascal.charAt(0).toLowerCase() + pascal.slice(1));
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/config/defaults.js
CHANGED
|
@@ -14,8 +14,12 @@ export const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
|
|
14
14
|
* tek tek ezilebilir.
|
|
15
15
|
*/
|
|
16
16
|
export const DEFAULT_DIRS = {
|
|
17
|
-
/**
|
|
17
|
+
/** Layout + sayfalar + bileşenler (klasik kök). */
|
|
18
18
|
views: "views",
|
|
19
|
+
/** Feature-first dikey dilimler (`features/<name>/{server,views,client}`). */
|
|
20
|
+
features: "features",
|
|
21
|
+
/** Özellikler arası paylaşılan server/views/client. */
|
|
22
|
+
shared: "shared",
|
|
19
23
|
/** Statik dosyalar; build çıktısı da buraya yazılır. */
|
|
20
24
|
public: "public",
|
|
21
25
|
/** Island runtime ve entry'ler. */
|
|
@@ -24,7 +28,7 @@ export const DEFAULT_DIRS = {
|
|
|
24
28
|
routes: "routes",
|
|
25
29
|
/** Tailwind/PostCSS giriş dosyası. */
|
|
26
30
|
styles: "styles/globals.css",
|
|
27
|
-
/** Build ara çıktıları (manifest, metafile, images). */
|
|
31
|
+
/** Build ara çıktıları (manifest, metafile, images, templates). */
|
|
28
32
|
generated: ".jskelet",
|
|
29
33
|
};
|
|
30
34
|
|
package/src/config/index.js
CHANGED
|
@@ -801,6 +801,10 @@ function resolveDirs(root, overrides) {
|
|
|
801
801
|
* Uygulamanın layout'u yoksa framework'ün minimal layout'u kullanılır. Bu
|
|
802
802
|
* sayede yeni bir proje tek bir route ile çalışır hâle gelir.
|
|
803
803
|
*
|
|
804
|
+
* Öncelik: config `layout` → `layout.jsk` (derlenmiş) → `layout.ejs` →
|
|
805
|
+
* framework varsayılanı. Dönüş değeri kaynak dosya yoludur; `.jsk` için
|
|
806
|
+
* render katmanı derlenmiş modülü kullanır.
|
|
807
|
+
*
|
|
804
808
|
* @param {Record<string, string>} dirs
|
|
805
809
|
* @param {string} [override]
|
|
806
810
|
* @returns {string}
|
|
@@ -808,6 +812,9 @@ function resolveDirs(root, overrides) {
|
|
|
808
812
|
function resolveLayout(dirs, override) {
|
|
809
813
|
if (override) return path.resolve(dirs.views, "..", override);
|
|
810
814
|
|
|
815
|
+
const jskLayout = path.join(dirs.views, "layout.jsk");
|
|
816
|
+
if (fs.existsSync(jskLayout)) return jskLayout;
|
|
817
|
+
|
|
811
818
|
const appLayout = path.join(dirs.views, "layout.ejs");
|
|
812
819
|
if (fs.existsSync(appLayout)) return appLayout;
|
|
813
820
|
|
package/src/dev-server.mjs
CHANGED
|
@@ -236,11 +236,13 @@ function run(args, label, passthrough) {
|
|
|
236
236
|
const WATCH_DIRS = [
|
|
237
237
|
config.dirs.routes,
|
|
238
238
|
config.dirs.views,
|
|
239
|
+
config.dirs.features,
|
|
240
|
+
config.dirs.shared,
|
|
239
241
|
path.join(ROOT, "lib"),
|
|
240
242
|
...(config.watch ?? []).map((dir) => path.resolve(ROOT, dir)),
|
|
241
|
-
];
|
|
243
|
+
].filter(Boolean);
|
|
242
244
|
|
|
243
|
-
const WATCH_EXTENSIONS = /\.(?:js|mjs|json|ejs)$/;
|
|
245
|
+
const WATCH_EXTENSIONS = /\.(?:js|mjs|json|ejs|jsk)$/;
|
|
244
246
|
|
|
245
247
|
/** @type {import('node:child_process').ChildProcess | null} */
|
|
246
248
|
let server = null;
|