jskelet 0.4.0 → 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 CHANGED
@@ -27,6 +27,17 @@ one is listed under a **Breaking** heading.
27
27
 
28
28
  ### Added
29
29
 
30
+ - VS Code / Cursor extension skeleton under `extensions/vscode-jsk`: `.jsk`
31
+ language id, TextMate highlighting (`{{ }}` / `{#if}` / `{#each}` /
32
+ components), language config, and snippets. Install from that folder or
33
+ launch **JSK: Extension** from the repo root. Bound attrs on HTML tags
34
+ (`:src="… + '/path'"`) highlight nested single-quoted strings.
35
+ - Compile-time known components are discovered from **named exports** in
36
+ `views/components/**/*.js` (plus `.jsk` component files), not from the file
37
+ basename — so `<SectionHead />` resolves when `sectionHead` lives in
38
+ `ui.js` without a stub re-export. Docs cover the `.jsk` template-vs-component
39
+ boundary and a `{ items, error }` loader / `LoadErrorState` pattern so
40
+ upstream failures are not mistaken for empty data.
30
41
  - Build-time `.jsk` templates: declarative HTML-like syntax compiled to ESM
31
42
  render modules under `.jskelet/templates/` (no request-time parse, `eval`, or
32
43
  `new Function`). Coexists with EJS; compiled `.jsk` wins when both exist.
@@ -127,8 +138,14 @@ one is listed under a **Breaking** heading.
127
138
 
128
139
  ### Changed
129
140
 
141
+ - Duplicate component named exports (or the same PascalCase tag in two files)
142
+ now **fail** at build and at server startup instead of warning and letting
143
+ the second definition win. Overwriting `components/index.js` barrel exports
144
+ remains allowed.
130
145
  - `examples/minimal` pages moved to `.jsk`; adds `features/demo` as a
131
146
  co-located route + view sample.
147
+ - Marketing compare/FAQ copy no longer claims targeted invalidation is missing;
148
+ it points at `invalidateHtmlCache()` (and Redis pub/sub for multi-instance).
132
149
 
133
150
  - An adaptive per-host rate limit for upstream calls, `cache().upstream`. It sits
134
151
  in the `fetch` wrapper rather than in the prewarm pass, because what spends the
@@ -70,8 +70,34 @@ controller data → import edilmiş render(data, helpers) → HTML
70
70
  | Yerleşikler | `Link`, `Image`, `Icon`, `CsrfField`, `PreloadImage` |
71
71
 
72
72
  İfade dili kasıtlı olarak dardır (erişim, karşılaştırma, ternary, `.length`).
73
- Atama ve rastgele fonksiyon çağrısı yok — mantık controller veya JS bileşende
74
- kalır.
73
+ Atama, object literal ve rastgele fonksiyon çağrısı yok — mantık controller
74
+ veya JS bileşende kalır.
75
+
76
+ #### Şablon mu, bileşen mi?
77
+
78
+ EJS’den geçerken sınırı erken çizmek işe yarar:
79
+
80
+ | Burada kalsın (`.jsk`) | JS bileşene taşı |
81
+ | --- | --- |
82
+ | Metin, koşul, liste, prop bağlama | Fonksiyon çağrısı, nesne üretimi, biçimlendirme |
83
+ | Yerleşik etiketler (`Link`, `Image`, …) | Birden fazla yardımcıdan HTML birleştirme |
84
+ | Controller’dan gelen hazır veri | Upstream / hata ayırt eden UI (`LoadErrorState`) |
85
+
86
+ Şablonda `format(x)` veya `{ a: 1 }` yazılamıyorsa bu bir eksik değil: o iş
87
+ `views/components/*.js` veya controller’ındır. Karmaşık sayfalar bileşene
88
+ kaçıyorsa ifade dilini genişletmek yerine bileşen sınırını net tutmak tercih
89
+ edilir.
90
+
91
+ ### Editör desteği
92
+
93
+ Repo içinde `extensions/vscode-jsk` VS Code / Cursor uzantısı vardır: sözdizimi
94
+ renklendirme, dil yapılandırması ve snippet'ler. Yerel kurulum:
95
+
96
+ ```bash
97
+ code --install-extension extensions/vscode-jsk
98
+ ```
99
+
100
+ Ayrıntılar uzantı README'sinde.
75
101
 
76
102
  ### EJS ile birlikte yaşam
77
103
 
@@ -243,13 +269,18 @@ Kurallar:
243
269
 
244
270
  - Tarama özyinelemelidir; alt dizinler de kapsanır.
245
271
  - `default` export'lar yok sayılır — yalnızca named export'lar kaydedilir.
272
+ - Compile-time bilinen bileşen listesi **dosya adından değil**, kaynak
273
+ metindeki named export'lardan okunur. `ui.js` içindeki `sectionHead` →
274
+ şablonda `<SectionHead />` (runtime zaten camelCase export'a PascalCase
275
+ alias ekler). Dosya adına göre stub re-export eklemeye gerek yoktur.
246
276
  - `loader.js` ve `index.js` bileşen dosyası sayılmaz.
247
277
  - `views/components/index.js` varsa **barrel** olarak, en düşük öncelikle en
248
278
  önce yüklenir. Tek amacı `lib/` yeniden ihraçlarını şablon local'i yapmak;
249
279
  bileşenlerin kendi dosyaları sonradan gelip sessizce üzerine yazar.
250
- - Aynı ad iki farklı bileşen dosyasında tanımlıysa uyarı basılır ve **ikincisi
251
- kazanır**: `[components] 'card' is defined twice: a.js and b.js — the second
252
- one wins.`
280
+ - Aynı ad (veya aynı PascalCase etiket) iki farklı bileşen dosyasında
281
+ tanımlıysa **uyarı değil hata**: build ve sunucu açılışı
282
+ `Component 'card' is defined twice: …` ile durur. Barrel üzerine yazmak
283
+ bilinçli istisnadır.
253
284
  - `views/components` dizini yoksa bileşen kaydı boş kalır; bileşen kullanmayan
254
285
  bir proje de çalışır.
255
286
 
package/docs/06-cache.md CHANGED
@@ -380,6 +380,68 @@ export async function apiGet(path) {
380
380
  }
381
381
  ```
382
382
 
383
+ ### Loader sözleşmesi: boş liste ≠ hata
384
+
385
+ `catch → []` (veya `null`) ile yutulan bir upstream hatası, yanlış mapping ile
386
+ aynı görünür: boş UI. Rate limit ve geçici hatalar logda doğru yönde
387
+ işaretlense bile ziyaretçi “veri yok” sanır. Widget loader’ları sessiz
388
+ `[]`’ye gömülmek yerine sonucu ayırsın:
389
+
390
+ ```js
391
+ /**
392
+ * @returns {Promise<{ items: object[], error: Error | null }>}
393
+ */
394
+ export async function loadTickerItems() {
395
+ try {
396
+ const items = await apiGet("/ticker");
397
+ if (!items) {
398
+ return { items: [], error: new Error("Upstream returned no data") };
399
+ }
400
+ return { items, error: null };
401
+ } catch (error) {
402
+ return {
403
+ items: [],
404
+ error: error instanceof Error ? error : new Error(String(error)),
405
+ };
406
+ }
407
+ }
408
+ ```
409
+
410
+ Uygulama tarafında ortak bir `LoadErrorState` bileşeni (veya eşdeğeri) bu
411
+ `error` alanını göstersin; her widget kendi boş hâline düşmesin:
412
+
413
+ ```js
414
+ // views/components/load-error-state.js
415
+ import { esc } from "jskelet/html";
416
+
417
+ /**
418
+ * @param {{ message?: string, title?: string }} props
419
+ * @returns {string}
420
+ */
421
+ export function LoadErrorState({ message, title = "Veri yüklenemedi" }) {
422
+ return `<div role="alert" data-load-error class="…">
423
+ <p>${esc(title)}</p>
424
+ ${message ? `<p>${esc(message)}</p>` : ""}
425
+ </div>`;
426
+ }
427
+ ```
428
+
429
+ ```html
430
+ {#if error}
431
+ <LoadErrorState :message="error.message" />
432
+ {#else if items.length}
433
+ {#each items as item}
434
+ …
435
+ {/each}
436
+ {#else}
437
+ <p>Kayıt yok</p>
438
+ {/if}
439
+ ```
440
+
441
+ Framework markaya özel UI taşımaz; `LoadErrorState` uygulama bileşenidir.
442
+ Önemli olan sözleşme: `{ items, error }` (veya eşdeğeri) ve hata ile “gerçekten
443
+ boş”un şablonda ayrı kolları.
444
+
383
445
  ### Geçici ve kalıcı hata ayrımı
384
446
 
385
447
  | Durum | Sayılır | Sonuç |
@@ -71,8 +71,34 @@ controller data → imported render(data, helpers) → HTML
71
71
  | Built-ins | `Link`, `Image`, `Icon`, `CsrfField`, `PreloadImage` |
72
72
 
73
73
  The expression language is intentionally small (access, compare, ternary,
74
- `.length`). No assignments or arbitrary calls — keep logic in controllers or JS
75
- components.
74
+ `.length`). No assignments, object literals, or arbitrary calls — keep logic in
75
+ controllers or JS components.
76
+
77
+ #### Template or component?
78
+
79
+ When moving off EJS, draw the line early:
80
+
81
+ | Stay in `.jsk` | Move to a JS component |
82
+ | --- | --- |
83
+ | Text, conditionals, lists, prop binding | Function calls, object construction, formatting |
84
+ | Built-in tags (`Link`, `Image`, …) | Composing HTML from several helpers |
85
+ | Ready-made data from the controller | Upstream / error-aware UI (`LoadErrorState`) |
86
+
87
+ If the template cannot write `format(x)` or `{ a: 1 }`, that is intentional: the
88
+ work belongs in `views/components/*.js` or the controller. Prefer a clear
89
+ component boundary over widening the expression language when complex pages
90
+ “escape” into JS.
91
+
92
+ ### Editor support
93
+
94
+ `extensions/vscode-jsk` is a VS Code / Cursor extension in this repo: syntax
95
+ highlighting, language configuration, and snippets. Local install:
96
+
97
+ ```bash
98
+ code --install-extension extensions/vscode-jsk
99
+ ```
100
+
101
+ See the extension README for details.
76
102
 
77
103
  ### Coexistence with EJS
78
104
 
@@ -244,14 +270,20 @@ Rules:
244
270
 
245
271
  - The scan is recursive; subdirectories are covered too.
246
272
  - `default` exports are ignored — only named exports are registered.
273
+ - The compile-time known-component set is read from **named exports in the
274
+ source**, not from the file basename. `sectionHead` in `ui.js` →
275
+ `<SectionHead />` in the template (runtime already adds a PascalCase alias
276
+ for camelCase exports). You do not need a stub re-export named after the
277
+ file.
247
278
  - `loader.js` and `index.js` do not count as component files.
248
279
  - If `views/components/index.js` exists it is loaded first as a **barrel**,
249
280
  with the lowest priority. Its only purpose is to turn `lib/` re-exports into
250
281
  template locals; the components' own files come later and silently overwrite
251
282
  it.
252
- - If the same name is defined in two different component files a warning is
253
- printed and **the second one wins**: `[components] 'card' is defined twice:
254
- a.js and b.js — the second one wins.`
283
+ - If the same name (or the same PascalCase tag) is defined in two different
284
+ component files, that is an **error, not a warning**: build and server
285
+ startup stop with `Component 'card' is defined twice: …`. Overwriting the
286
+ barrel is the deliberate exception.
255
287
  - If the `views/components` directory does not exist the component registry
256
288
  stays empty; a project that uses no components works fine too.
257
289
 
@@ -390,6 +390,68 @@ export async function apiGet(path) {
390
390
  }
391
391
  ```
392
392
 
393
+ ### Loader contract: empty list ≠ error
394
+
395
+ Swallowing an upstream failure with `catch → []` (or `null`) looks the same as
396
+ a wrong mapping: empty UI. Even when rate limits are logged correctly, the
397
+ visitor sees “no data”. Widget loaders should separate the result instead of
398
+ burying a silent `[]`:
399
+
400
+ ```js
401
+ /**
402
+ * @returns {Promise<{ items: object[], error: Error | null }>}
403
+ */
404
+ export async function loadTickerItems() {
405
+ try {
406
+ const items = await apiGet("/ticker");
407
+ if (!items) {
408
+ return { items: [], error: new Error("Upstream returned no data") };
409
+ }
410
+ return { items, error: null };
411
+ } catch (error) {
412
+ return {
413
+ items: [],
414
+ error: error instanceof Error ? error : new Error(String(error)),
415
+ };
416
+ }
417
+ }
418
+ ```
419
+
420
+ An app-level shared `LoadErrorState` (or equivalent) should render that `error`
421
+ field so each widget does not fall back to its own empty state:
422
+
423
+ ```js
424
+ // views/components/load-error-state.js
425
+ import { esc } from "jskelet/html";
426
+
427
+ /**
428
+ * @param {{ message?: string, title?: string }} props
429
+ * @returns {string}
430
+ */
431
+ export function LoadErrorState({ message, title = "Could not load data" }) {
432
+ return `<div role="alert" data-load-error class="…">
433
+ <p>${esc(title)}</p>
434
+ ${message ? `<p>${esc(message)}</p>` : ""}
435
+ </div>`;
436
+ }
437
+ ```
438
+
439
+ ```html
440
+ {#if error}
441
+ <LoadErrorState :message="error.message" />
442
+ {#else if items.length}
443
+ {#each items as item}
444
+ …
445
+ {/each}
446
+ {#else}
447
+ <p>No records</p>
448
+ {/if}
449
+ ```
450
+
451
+ The framework does not ship brand-specific UI; `LoadErrorState` is an
452
+ application component. What matters is the contract: `{ items, error }` (or
453
+ equivalent) and separate template branches for failure vs truly empty.
454
+
393
455
  ### Distinguishing transient and permanent failures
394
456
 
395
457
  | State | Counts as | Result |
@@ -778,77 +840,77 @@ Two more diagnostic surfaces:
778
840
 
779
841
  The full list of settings: [07-configuration.md](./07-configuration.md).
780
842
 
781
- ## The admin panel
782
-
783
- Instead of hand-writing the `getHtmlCacheEntries()` / `getRedisStatus()`
784
- endpoints above, the framework ships a panel. It is deliberately separate from
785
- the dev overlay: the overlay only exists while `NODE_ENV=development`, while the
786
- panel does not look at the environment — "why is this page stale", "did the
787
- webhook purge land", "is Redis actually connected" are production questions.
788
-
789
- The panel is enabled with top-level `admin()` (not inside `cache()`) at
790
- `/_jskelet/admin`, with Overview, Cache, Routes, Views, Logs and System pages.
791
- The Cache page carries the same operations as the former single-page panel.
792
-
793
- ```js
794
- // jskelet.config.mjs
795
- export default {
796
- admin() {
797
- return {
798
- enabled: process.env.JSKELET_ADMIN === "1",
799
- allowIps: ["10.0.0.0/8"], // empty = no IP restriction
800
- blockBots: true,
801
- };
802
- },
803
- cache() {
804
- return {
805
- html: { "/news/:slug": 300 },
806
- };
807
- },
808
- };
809
- ```
810
-
811
- Without `enabled` **nothing is mounted**: the path does not exist, the module is
812
- never loaded and it costs the production process nothing. The environment
813
- variable (`JSKELET_ADMIN=1`) overrides the config, because the panel is
814
- usually opened once during an incident and editing the config file and
815
- redeploying is the last thing you want at that moment.
816
-
817
- When the panel is on, the server log prints the password in an `ADMIN` box at
818
- `http://localhost:3000/_jskelet/admin`.
819
-
820
- ### Access and hardening
821
-
822
- - **The password is regenerated on every process start** (32 hex characters) and
823
- only ever appears in the log. There is no persistent secret to leak.
824
- - **The password is not accepted in the query string.**
825
- - **`allowIps`** (exact IP or CIDR), when set, returns `404` for every request
826
- outside the list — including the login page.
827
- - **`blockBots`** (default `true`) rejects known crawler UAs with `404`.
828
- - **Three failed attempts ban the IP for 24 hours** (`banAttempts`, `banHours`).
829
- - **Banned and unauthorised requests get a `404`.**
830
- - **Nothing is indexable:** `X-Robots-Tag`, `Cache-Control: no-store`,
831
- `Referrer-Policy: no-referrer`; exempt from prewarming and navigation speculation.
832
- - Actions require an `X-JSkelet-Admin` header — the panel's own CSRF brake.
833
- - Sessions and ban counters live in process memory.
834
-
835
- ### What the panel shows
836
-
837
- | Area | Contents |
838
- | --- | --- |
839
- | Overview | HTML/data/Redis/prewarm cards and upstream limiter summary |
840
- | Cache | Shared tier, Cloudflare, actions, entry list (former panel) |
841
- | Routes | Express path/method inventory, route modules, last-request summary |
842
- | Views | Template inventory under `views/` |
843
- | Logs | Live SSE queue with method/status/cache/kind/path and text filters |
844
- | System | Host RAM / disk |
845
-
846
- The list is **filtered by key** and the filter runs on the server: a data cache
847
- can hold tens of thousands of keys. At most 500 rows come back per request and
848
- the counter in the heading says how many matches were cut. HTML bodies and
849
- cached values are **never returned** — the panel's job is to show state, not to
850
- export content.
851
-
843
+ ## The admin panel
844
+
845
+ Instead of hand-writing the `getHtmlCacheEntries()` / `getRedisStatus()`
846
+ endpoints above, the framework ships a panel. It is deliberately separate from
847
+ the dev overlay: the overlay only exists while `NODE_ENV=development`, while the
848
+ panel does not look at the environment — "why is this page stale", "did the
849
+ webhook purge land", "is Redis actually connected" are production questions.
850
+
851
+ The panel is enabled with top-level `admin()` (not inside `cache()`) at
852
+ `/_jskelet/admin`, with Overview, Cache, Routes, Views, Logs and System pages.
853
+ The Cache page carries the same operations as the former single-page panel.
854
+
855
+ ```js
856
+ // jskelet.config.mjs
857
+ export default {
858
+ admin() {
859
+ return {
860
+ enabled: process.env.JSKELET_ADMIN === "1",
861
+ allowIps: ["10.0.0.0/8"], // empty = no IP restriction
862
+ blockBots: true,
863
+ };
864
+ },
865
+ cache() {
866
+ return {
867
+ html: { "/news/:slug": 300 },
868
+ };
869
+ },
870
+ };
871
+ ```
872
+
873
+ Without `enabled` **nothing is mounted**: the path does not exist, the module is
874
+ never loaded and it costs the production process nothing. The environment
875
+ variable (`JSKELET_ADMIN=1`) overrides the config, because the panel is
876
+ usually opened once during an incident and editing the config file and
877
+ redeploying is the last thing you want at that moment.
878
+
879
+ When the panel is on, the server log prints the password in an `ADMIN` box at
880
+ `http://localhost:3000/_jskelet/admin`.
881
+
882
+ ### Access and hardening
883
+
884
+ - **The password is regenerated on every process start** (32 hex characters) and
885
+ only ever appears in the log. There is no persistent secret to leak.
886
+ - **The password is not accepted in the query string.**
887
+ - **`allowIps`** (exact IP or CIDR), when set, returns `404` for every request
888
+ outside the list — including the login page.
889
+ - **`blockBots`** (default `true`) rejects known crawler UAs with `404`.
890
+ - **Three failed attempts ban the IP for 24 hours** (`banAttempts`, `banHours`).
891
+ - **Banned and unauthorised requests get a `404`.**
892
+ - **Nothing is indexable:** `X-Robots-Tag`, `Cache-Control: no-store`,
893
+ `Referrer-Policy: no-referrer`; exempt from prewarming and navigation speculation.
894
+ - Actions require an `X-JSkelet-Admin` header — the panel's own CSRF brake.
895
+ - Sessions and ban counters live in process memory.
896
+
897
+ ### What the panel shows
898
+
899
+ | Area | Contents |
900
+ | --- | --- |
901
+ | Overview | HTML/data/Redis/prewarm cards and upstream limiter summary |
902
+ | Cache | Shared tier, Cloudflare, actions, entry list (former panel) |
903
+ | Routes | Express path/method inventory, route modules, last-request summary |
904
+ | Views | Template inventory under `views/` |
905
+ | Logs | Live SSE queue with method/status/cache/kind/path and text filters |
906
+ | System | Host RAM / disk |
907
+
908
+ The list is **filtered by key** and the filter runs on the server: a data cache
909
+ can hold tens of thousands of keys. At most 500 rows come back per request and
910
+ the counter in the heading says how many matches were cut. HTML bodies and
911
+ cached values are **never returned** — the panel's job is to show state, not to
912
+ export content.
913
+
852
914
  ### What you can do from it
853
915
 
854
916
  | Action | Equivalent call |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "A framework that feels like no framework: Express 5 + build-time .jsk (or EJS) SSR, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,5 +11,7 @@ export {
11
11
  discoverJskFiles,
12
12
  componentNameFromViewId,
13
13
  collectKnownComponents,
14
+ toComponentTag,
14
15
  } from "./resolve.js";
16
+ export { scanNamedExports } from "./scan-exports.js";
15
17
  export { compileAll, compileSource, ensureTemplatesCompiled } from "./compile-all.js";
@@ -7,6 +7,17 @@
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
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
+ }
10
21
 
11
22
  /**
12
23
  * @param {{ root: string, dirs: Record<string, string> }} config
@@ -106,6 +117,9 @@ export function componentNameFromViewId(viewId) {
106
117
  * Bilinen bileşen adları: JS named export'lar + derlenecek `.jsk` bileşenleri
107
118
  * + yerleşik etiketler.
108
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
+ *
109
123
  * @param {string[]} componentDirs
110
124
  * @param {Map<string, string>} jskFiles
111
125
  * @returns {Set<string>}
@@ -119,41 +133,76 @@ export function collectKnownComponents(componentDirs, jskFiles) {
119
133
  "PreloadImage",
120
134
  ]);
121
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
+
122
141
  for (const [viewId] of jskFiles) {
123
142
  const name = componentNameFromViewId(viewId);
124
- if (name) known.add(name);
143
+ if (!name) continue;
144
+ known.add(name);
145
+ byTag.set(name, `${viewId}.jsk`);
125
146
  }
126
147
 
127
148
  for (const dir of componentDirs) {
128
- collectJsComponentNames(dir, dir, known);
149
+ collectJsComponentNames(dir, known, byName, byTag);
129
150
  }
130
151
 
131
152
  return known;
132
153
  }
133
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
+
134
180
  /**
135
181
  * @param {string} dir
136
- * @param {string} root
137
182
  * @param {Set<string>} out
183
+ * @param {Map<string, string>} byName
184
+ * @param {Map<string, string>} byTag
138
185
  */
139
- function collectJsComponentNames(dir, root, out) {
186
+ function collectJsComponentNames(dir, out, byName, byTag) {
140
187
  if (!fs.existsSync(dir)) return;
141
188
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
142
189
  const full = path.join(dir, entry.name);
143
190
  if (entry.isDirectory()) {
144
- collectJsComponentNames(full, root, out);
191
+ collectJsComponentNames(full, out, byName, byTag);
145
192
  continue;
146
193
  }
147
194
  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));
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
+ }
158
207
  }
159
208
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Kaynak metinden named export adlarını çıkarır — modülü çalıştırmadan.
3
+ * Compile-time bilinen bileşen listesi için; `default` yok sayılır.
4
+ */
5
+
6
+ /**
7
+ * Yorumları kaba şekilde siler; string içindeki sahte eşleşmeler nadirdir.
8
+ * @param {string} source
9
+ * @returns {string}
10
+ */
11
+ function stripComments(source) {
12
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
13
+ }
14
+
15
+ /**
16
+ * `export { a, b as C, default as X }` listesinden dışa verilen adları toplar.
17
+ * @param {string} clause
18
+ * @param {Set<string>} out
19
+ */
20
+ function addExportList(clause, out) {
21
+ for (const part of clause.split(",")) {
22
+ const trimmed = part.trim();
23
+ if (!trimmed) continue;
24
+ const bits = trimmed.split(/\s+as\s+/i);
25
+ const exported = (bits[1] ?? bits[0]).trim();
26
+ if (!exported || exported === "default") continue;
27
+ out.add(exported);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * @param {string} source
33
+ * @returns {string[]}
34
+ */
35
+ export function scanNamedExports(source) {
36
+ const text = stripComments(source);
37
+ /** @type {Set<string>} */
38
+ const names = new Set();
39
+
40
+ for (const match of text.matchAll(
41
+ /\bexport\s+(?:async\s+)?(?:function\*?|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g,
42
+ )) {
43
+ names.add(match[1]);
44
+ }
45
+
46
+ for (const match of text.matchAll(/\bexport\s*\{([^}]+)\}/g)) {
47
+ addExportList(match[1], names);
48
+ }
49
+
50
+ return [...names];
51
+ }
@@ -60,21 +60,24 @@ async function loadDir(dir, components, origin) {
60
60
  ];
61
61
 
62
62
  for (const file of files) {
63
- const relative = path.relative(dir, file).split(path.sep).join("/");
63
+ const isBarrel = path.basename(file) === BARREL;
64
+ // Kimlik mutlak yol — çoklu components kökünde göreli ad çakışmasın.
65
+ const fileId = isBarrel ? BARREL : file.split(path.sep).join("/");
64
66
  const module = await import(pathToFileURL(file).href);
65
67
 
66
68
  for (const [name, value] of Object.entries(module)) {
67
69
  if (name === "default") continue;
68
70
 
69
71
  const previous = origin.get(name);
70
- if (previous && previous !== BARREL && previous !== relative) {
71
- console.warn(
72
- `[components] '${name}' is defined twice: ${previous} and ${relative} — the second one wins.`,
72
+ // Barrel üzerine yazmak bilinçli; iki gerçek bileşen dosyası çakışması hata.
73
+ if (previous && previous !== BARREL && previous !== fileId) {
74
+ throw new Error(
75
+ `[components] '${name}' is defined twice: ${previous} and ${fileId}`,
73
76
  );
74
77
  }
75
78
 
76
79
  components[name] = value;
77
- origin.set(name, relative);
80
+ origin.set(name, fileId);
78
81
  }
79
82
  }
80
83
  }