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.
@@ -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
+ }
@@ -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
- /** EJS layout + sayfalar + bileşenler. */
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
 
@@ -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
 
@@ -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;