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.
@@ -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
+ }