jskelet 0.6.4 → 0.6.6

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
@@ -28,6 +28,16 @@ one is listed under a **Breaking** heading.
28
28
 
29
29
  ### Added
30
30
 
31
+ - `build.client` configures esbuild code splitting and chunk groups:
32
+ `splitting`, declarative `groups` (bare package / path / `@/` matchers), and
33
+ a `chunkGroup(id)` hook. Matched modules are co-located into one shared
34
+ chunk (webpack-`manualChunks` trade-off: weaker tree-shaking inside the
35
+ group). The client build step also prints the total JS file count.
36
+ - `ogImage({ fonts })` registers each `ttf` or `otf` (`path` and `family`)
37
+ with fontconfig so card and raw-SVG text use the site's own files. A slim
38
+ image still needs the `fontconfig` package. `woff` / `woff2` are not drawn
39
+ by the rasterizer (empty boxes); they are embedded only when the response
40
+ is SVG.
31
41
  - `robots.txt` responses gain a trailing JSkelet note that disallows framework
32
42
  endpoints (`/_jskelet/`, `/__jskelet/`, `/_fragment/`, plus a custom admin,
33
43
  image, handoff, or dev path when it sits outside those prefixes). Every
@@ -582,6 +582,39 @@ app.get("/og/custom.png", async (req, res) => {
582
582
  });
583
583
  ```
584
584
 
585
+ ### Fontlar
586
+
587
+ `bookworm-slim` gibi ince imajlarda sistem fontu yoktur. Hazır kart
588
+ `system-ui` istediği için librsvg her sitede aynı boş kareleri basar.
589
+ `fonts`, `ttf` veya `otf` dosyasını fontconfig üzerinden librsvg'ye
590
+ bağlar. `family` kart metninin kullandığı addır; dosyanın içindeki adla
591
+ aynı olmak zorunda değildir. Ham `svg`'de kendi `font-family` değerinizi
592
+ bırakın ve aynı `family` adını buraya verin — `@font-face` bloğu yazmayın.
593
+ librsvg onu yok sayar.
594
+
595
+ ```js
596
+ return {
597
+ title: post.title,
598
+ description: post.excerpt,
599
+ siteName: "Blog",
600
+ fonts: [
601
+ { path: "fonts/Inter-Regular.ttf", family: "Inter" },
602
+ { path: "fonts/Inter-Bold.ttf", family: "Inter" },
603
+ ],
604
+ };
605
+ ```
606
+
607
+ `path` uygulama köküne görelidir; mutlak yol da geçerlidir. PNG `ttf` ve
608
+ `otf` okur. librsvg `@font-face`'i yok sayar; `woff` / `woff2` boş kare
609
+ basar (yanıt SVG ise bu dosyalar yine gömülür, tarayıcı kullanabilir).
610
+ `weight` opsiyoneldir; her dosya içindeki ağırlığını korur, başlık gerçekten
611
+ kalın olsun diye regular ve bold dosyalarını birlikte verin. Dosya yoksa
612
+ görsel yine üretilir, o yüz atlanır.
613
+
614
+ Çalışma imajında `fontconfig` paketi yine gerekir: sharp'ın SVG metin çizimi
615
+ onsuz yüzleri yüklemez. Sistem font paketi (`fonts-dejavu` gibi) gerekmez.
616
+ PNG için `sharp` çalışma bağımlılığıdır.
617
+
585
618
  Varsayılan başlıklar (süreler HTML ayarına bağlı değildir):
586
619
 
587
620
  ```
@@ -91,6 +91,11 @@ export default {
91
91
  icons: { dir: "icons", scan: ["views", "client", "routes", "lib"] },
92
92
  images: { widths: [400, 800, 1200], quality: 78, skip: ["indirmeler"] },
93
93
  clientEnv: ["PUBLIC_WS_URL"],
94
+ build: {
95
+ client: {
96
+ groups: { charts: ["chart.js"] },
97
+ },
98
+ },
94
99
 
95
100
  async headers() {
96
101
  return [
@@ -585,6 +590,42 @@ images: {
585
590
  ayarla `src` / `srcset`'i `/_jskelet/image?url=…&w=96` biçimine çevirir.
586
591
  Elle URL kurmak için `remoteImageUrl(src, { width })` (`jskelet`).
587
592
 
593
+ ## `build`
594
+
595
+ **Tip:** `{ client?: { splitting?, groups?, chunkGroup? } }` — **Varsayılan:**
596
+ `{ client: { splitting: true, groups: {}, chunkGroup: null } }`
597
+
598
+ Client JS (esbuild) bundling knobs. `fonts` / `icons` / `images` / `clientEnv`
599
+ hâlâ top-level kalır; bu bölüm yalnızca splitting ve chunk gruplaması içindir.
600
+
601
+ | Alan | Tip | Varsayılan | Anlamı |
602
+ | --- | --- | --- | --- |
603
+ | `client.splitting` | `boolean` | `true` | esbuild code splitting. `false` iken `groups` / `chunkGroup` yok sayılır (bir kez uyarı). |
604
+ | `client.groups` | `Record<string, string[]>` | `{}` | İsim → matcher listesi. Bare paket (`chart.js`), yol öneki (`./client/chrome/`) veya `@/` öneki. |
605
+ | `client.chunkGroup` | `(id: string) => string \| null \| undefined` | `null` | Modül kimliğini (mutlak yol veya bare specifier) grup adına map eder. **`groups` ile çakışınca hook kazanır.** |
606
+
607
+ esbuild native `manualChunks` sunmaz. Framework, gruba düşen import'ları bir
608
+ barrel üzerinden aynı paylaşılan chunk'a çeker. Bedel: grup içi tree-shaking
609
+ zayıflar — bir üyeyi kullanan sayfa grubun geri kalanını da indirebilir
610
+ (webpack `manualChunks` ile aynı takas). Çıktı dosya adı genelde
611
+ `chunks/chunk.<hash>.js` kalır; grup adı config anahtarıdır, dosya adı değil.
612
+ Ayrıntı: [08-build.md](./08-build.md).
613
+
614
+ ```js
615
+ build: {
616
+ client: {
617
+ splitting: true,
618
+ groups: {
619
+ charts: ["chart.js", "chartjs-adapter-date-fns"],
620
+ chrome: ["./client/chrome/", "@/client/chrome/"],
621
+ },
622
+ chunkGroup(id) {
623
+ if (id.includes("node_modules/lodash")) return "vendor";
624
+ },
625
+ },
626
+ },
627
+ ```
628
+
588
629
  ## `clientEnv`
589
630
 
590
631
  **Tip:** `string[]` — **Varsayılan:** `[]`
package/docs/08-build.md CHANGED
@@ -175,7 +175,8 @@ esbuild ayarları:
175
175
 
176
176
  | Ayar | Değer | Sebebi |
177
177
  | --- | --- | --- |
178
- | `bundle`, `splitting` | `true` | Ortak modüller paylaşılan chunk'a çıkar |
178
+ | `bundle` | `true` | — |
179
+ | `splitting` | `config.build.client.splitting` (varsayılan `true`) | Ortak modüller paylaşılan chunk'a çıkar; kapatılınca gruplama da düşer |
179
180
  | `format` | `esm` | `type="module"` script'ler |
180
181
  | `target` | `chrome111`, `edge111`, `firefox111`, `safari16.4` | ESM + dinamik import + `IntersectionObserver` island modelinin alt sınırı; daha eskisine transpile etmek çıktıyı büyütüp hiçbir ziyaretçi kazandırmıyor |
181
182
  | `minify` | `true` | — |
@@ -185,7 +186,26 @@ esbuild ayarları:
185
186
  | `legalComments` | `none` | — |
186
187
 
187
188
  Çıktı `public/assets/js/` altına düşer ve her turda önce temizlenir.
188
- `browserslist` okunmaz; hedef listesi kod içinde sabittir.
189
+ `browserslist` okunmaz; hedef listesi kod içinde sabittir. Adım satırı entry
190
+ sayısına ek olarak toplam JS dosya sayısını yazar (`1 entry, 48 files`) — çok
191
+ chunk teşhisini kolaylaştırır.
192
+
193
+ ### Chunk gruplama (`build.client.groups` / `chunkGroup`)
194
+
195
+ esbuild native `manualChunks` sunmaz. `build.client.groups` ve
196
+ `build.client.chunkGroup` bir plugin ile uygulanır:
197
+
198
+ 1. Eşleşen import bir re-export sarmalayıcısına yönlenir.
199
+ 2. Sarmalayıcı `jskelet-group:<name>` barrel'ını side-effect olarak çeker.
200
+ 3. Barrel gruptaki tüm bilinen üyeleri (bare paketler + yol önekindeki
201
+ `.{js,ts,mts}` dosyaları + resolve sırasında keşfedilenler) statik import
202
+ eder — gruba dokunan entry'ler aynı üye kümesine ulaşır ve tek paylaşılan
203
+ chunk oluşur.
204
+
205
+ **Trade-off:** grup içi tree-shaking zayıflar; bir üyeyi kullanan sayfa grubun
206
+ geri kalanını da indirebilir. Grup adı config anahtarıdır; çıktı dosyası
207
+ çoğunlukla `chunks/chunk.<hash>.js` kalır. Config sözlüğü:
208
+ [07-yapilandirma.md](./07-yapilandirma.md#build).
189
209
 
190
210
  ### `@/` alias'ı
191
211
 
@@ -136,7 +136,10 @@ Notlar:
136
136
  optimizasyonu için. `--omit=dev` ile dışarıda kalır (devDependency olarak
137
137
  kurulmuşsa). **`images.remote` açıksa** sharp runtime bağımlılığıdır —
138
138
  production `dependencies`'e alın ya da runtime imajında ayrıca kurun; yoksa
139
- optimizer kaynak URL'ye 302 yönlendirir.
139
+ optimizer kaynak URL'ye 302 yönlendirir. **Dinamik OG PNG** de sharp ister
140
+ ve imajda `fontconfig` paketi gerekir (`apt-get install -y fontconfig`).
141
+ `ogImage({ fonts })` site fontunu gömer; sistem font paketi şart değildir.
142
+ `fontconfig` yoksa metin boş kare basar.
140
143
  - `jskelet start`ı `npx` olmadan çağırmak isterseniz
141
144
  `CMD ["node", "node_modules/jskelet/bin/jskelet.mjs", "start"]` de çalışır.
142
145
 
@@ -588,6 +588,39 @@ app.get("/og/custom.png", async (req, res) => {
588
588
  });
589
589
  ```
590
590
 
591
+ ### Fonts
592
+
593
+ A slim image such as `bookworm-slim` has no system fonts. The built-in card
594
+ asks for `system-ui`, so librsvg draws the same empty boxes on every site.
595
+ `fonts` points librsvg at your `ttf` or `otf` through fontconfig. `family` is
596
+ the name the card text uses; it does not have to be the name stored in the
597
+ file. On a raw `svg`, keep your own `font-family` and pass the same `family`
598
+ here — do not write an `@font-face` block. librsvg ignores it.
599
+
600
+ ```js
601
+ return {
602
+ title: post.title,
603
+ description: post.excerpt,
604
+ siteName: "Blog",
605
+ fonts: [
606
+ { path: "fonts/Inter-Regular.ttf", family: "Inter" },
607
+ { path: "fonts/Inter-Bold.ttf", family: "Inter" },
608
+ ],
609
+ };
610
+ ```
611
+
612
+ `path` is relative to the application root; an absolute path is accepted too.
613
+ PNG rasterization reads `ttf` and `otf`. librsvg ignores `@font-face` and
614
+ draws empty boxes from `woff` / `woff2` (those files are still embedded when
615
+ the response is SVG, because the browser can use them). `weight` is optional;
616
+ each file keeps the weight stored inside it, so pass regular and bold files
617
+ if the title should be a real bold. A missing file is skipped and the image
618
+ is still produced.
619
+
620
+ The runtime image still needs the `fontconfig` package: sharp's SVG text
621
+ drawing will not load the faces without it. A system font package (such as
622
+ `fonts-dejavu`) is not required. PNG needs `sharp` as a runtime dependency.
623
+
591
624
  Default headers (the durations are not tied to the HTML setting):
592
625
 
593
626
  ```
@@ -95,6 +95,11 @@ export default {
95
95
  icons: { dir: "icons", scan: ["views", "client", "routes", "lib"] },
96
96
  images: { widths: [400, 800, 1200], quality: 78, skip: ["downloads"] },
97
97
  clientEnv: ["PUBLIC_WS_URL"],
98
+ build: {
99
+ client: {
100
+ groups: { charts: ["chart.js"] },
101
+ },
102
+ },
98
103
 
99
104
  async headers() {
100
105
  return [
@@ -598,6 +603,42 @@ images: {
598
603
  rewrites `src` / `srcset` to `/_jskelet/image?url=…&w=96`. To build URLs by
599
604
  hand, use `remoteImageUrl(src, { width })` from `jskelet`.
600
605
 
606
+ ## `build`
607
+
608
+ **Type:** `{ client?: { splitting?, groups?, chunkGroup? } }` — **Default:**
609
+ `{ client: { splitting: true, groups: {}, chunkGroup: null } }`
610
+
611
+ Client JS (esbuild) bundling knobs. `fonts` / `icons` / `images` / `clientEnv`
612
+ stay top-level; this section is only for splitting and chunk grouping.
613
+
614
+ | Field | Type | Default | Meaning |
615
+ | --- | --- | --- | --- |
616
+ | `client.splitting` | `boolean` | `true` | esbuild code splitting. When `false`, `groups` / `chunkGroup` are ignored (one warning). |
617
+ | `client.groups` | `Record<string, string[]>` | `{}` | Name → matcher list. Bare package (`chart.js`), path prefix (`./client/chrome/`), or `@/` prefix. |
618
+ | `client.chunkGroup` | `(id: string) => string \| null \| undefined` | `null` | Maps a module id (absolute path or bare specifier) to a group name. **When it conflicts with `groups`, the hook wins.** |
619
+
620
+ esbuild has no native `manualChunks`. The framework pulls imports that land in a
621
+ group into the same shared chunk via a barrel. Trade-off: tree-shaking inside a
622
+ group weakens — a page that uses one member may download the rest of the group
623
+ (same trade-off as webpack `manualChunks`). Output filenames usually stay
624
+ `chunks/chunk.<hash>.js`; the group name is a config key, not the file name.
625
+ Details: [08-build.md](./08-build.md).
626
+
627
+ ```js
628
+ build: {
629
+ client: {
630
+ splitting: true,
631
+ groups: {
632
+ charts: ["chart.js", "chartjs-adapter-date-fns"],
633
+ chrome: ["./client/chrome/", "@/client/chrome/"],
634
+ },
635
+ chunkGroup(id) {
636
+ if (id.includes("node_modules/lodash")) return "vendor";
637
+ },
638
+ },
639
+ },
640
+ ```
641
+
601
642
  ## `clientEnv`
602
643
 
603
644
  **Type:** `string[]` — **Default:** `[]`
@@ -183,7 +183,8 @@ esbuild settings:
183
183
 
184
184
  | Setting | Value | Reason |
185
185
  | --- | --- | --- |
186
- | `bundle`, `splitting` | `true` | Shared modules move into a shared chunk |
186
+ | `bundle` | `true` | — |
187
+ | `splitting` | `config.build.client.splitting` (default `true`) | Shared modules move into a shared chunk; turning it off also disables grouping |
187
188
  | `format` | `esm` | `type="module"` scripts |
188
189
  | `target` | `chrome111`, `edge111`, `firefox111`, `safari16.4` | The lower bound of the ESM + dynamic import + `IntersectionObserver` island model; transpiling to anything older grows the output without winning a single visitor |
189
190
  | `minify` | `true` | — |
@@ -193,7 +194,26 @@ esbuild settings:
193
194
  | `legalComments` | `none` | — |
194
195
 
195
196
  The output lands under `public/assets/js/` and is cleaned first on every pass.
196
- `browserslist` is not read; the target list is hard-coded.
197
+ `browserslist` is not read; the target list is hard-coded. The step line prints
198
+ the total JS file count next to the entry count (`1 entry, 48 files`) so large
199
+ chunk graphs are easier to spot.
200
+
201
+ ### Chunk grouping (`build.client.groups` / `chunkGroup`)
202
+
203
+ esbuild has no native `manualChunks`. `build.client.groups` and
204
+ `build.client.chunkGroup` are applied through a plugin:
205
+
206
+ 1. A matching import is redirected to a re-export wrapper.
207
+ 2. The wrapper side-effect-imports the `jskelet-group:<name>` barrel.
208
+ 3. The barrel statically imports every known member of the group (bare packages,
209
+ `.{js,ts,mts}` files under path prefixes, plus members discovered at resolve
210
+ time) — every entry that touches the group reaches the same member set and
211
+ one shared chunk is produced.
212
+
213
+ **Trade-off:** tree-shaking inside a group weakens; a page that uses one member
214
+ may download the rest of the group. The group name is a config key; the output
215
+ file is usually still `chunks/chunk.<hash>.js`. Config reference:
216
+ [07-configuration.md](./07-configuration.md#build).
197
217
 
198
218
  ### The `@/` alias
199
219
 
@@ -138,6 +138,10 @@ Notes:
138
138
  as a devDependency). **If `images.remote` is enabled**, sharp is a runtime
139
139
  dependency — move it to production `dependencies` or install it in the
140
140
  runtime image; otherwise the optimizer 302-redirects to the source URL.
141
+ **Dynamic OG PNG** needs sharp too, and the image needs the `fontconfig`
142
+ package (`apt-get install -y fontconfig`). `ogImage({ fonts })` embeds the
143
+ site's own faces; a system font package is not required. Without
144
+ `fontconfig`, text renders as empty boxes.
141
145
  - If you would rather call `jskelet start` without `npx`,
142
146
  `CMD ["node", "node_modules/jskelet/bin/jskelet.mjs", "start"]` works too.
143
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "A framework that feels like no framework: Express 5 + build-time .jsk SSR (optional EJS peer), vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,331 @@
1
+ /**
2
+ * esbuild manualChunks karşılığı: `build.client.groups` / `chunkGroup`.
3
+ *
4
+ * esbuild native grup ataması sunmaz. Bir gruba düşen import, re-export
5
+ * sarmalayıcısı üzerinden gider; sarmalayıcı gruba ait barrel'ı side-effect
6
+ * olarak çeker. Barrel tüm bilinen üyeleri statik import eder — gruba
7
+ * dokunan entry'ler aynı üye kümesine ulaşır ve paylaşılan tek chunk oluşur.
8
+ *
9
+ * Bedel: grup içi tree-shaking zayıflar (webpack manualChunks ile aynı
10
+ * takas). `splitting: false` iken plugin hiç eklenmez.
11
+ */
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+
15
+ const GROUP_NS = "jskelet-group";
16
+ const REEXPORT_NS = "jskelet-group-reexport";
17
+ const GROUP_PREFIX = `${GROUP_NS}:`;
18
+
19
+ /** Barrel / re-export sarmalayıcısının kendi import'ları tekrar sarılmasın. */
20
+ const SKIP_NS = new Set([GROUP_NS, REEXPORT_NS]);
21
+
22
+ /**
23
+ * @typedef {object} ChunkGroupOptions
24
+ * @property {string} root
25
+ * @property {Record<string, string[]>} groups
26
+ * @property {((id: string) => string | null | undefined) | null} [chunkGroup]
27
+ */
28
+
29
+ /**
30
+ * Grup adı dosya yoluna uygun mu (`chunks/<name>.[hash]`).
31
+ *
32
+ * @param {string} name
33
+ * @returns {boolean}
34
+ */
35
+ export function isSafeGroupName(name) {
36
+ return typeof name === "string" && /^[A-Za-z0-9_-]+$/.test(name);
37
+ }
38
+
39
+ /**
40
+ * Matcher'ın bare paket, `@/` veya göreli yol öneki olup olmadığını ayırır.
41
+ *
42
+ * @param {string} matcher
43
+ * @returns {"bare" | "path"}
44
+ */
45
+ export function matcherKind(matcher) {
46
+ if (matcher.startsWith("./") || matcher.startsWith("../") || matcher.startsWith("@/")) {
47
+ return "path";
48
+ }
49
+ return "bare";
50
+ }
51
+
52
+ /**
53
+ * Yol önekini proje köküne göre mutlak dizin / dosya önekine çevirir.
54
+ *
55
+ * @param {string} root
56
+ * @param {string} matcher
57
+ * @returns {string}
58
+ */
59
+ export function resolvePathMatcher(root, matcher) {
60
+ if (matcher.startsWith("@/")) {
61
+ return path.resolve(root, matcher.slice(2));
62
+ }
63
+ return path.resolve(root, matcher);
64
+ }
65
+
66
+ /**
67
+ * Bare paket: exact veya `pkg/...` alt yolu.
68
+ *
69
+ * @param {string} matcher
70
+ * @param {string} importPath
71
+ * @returns {boolean}
72
+ */
73
+ export function matchBarePackage(matcher, importPath) {
74
+ return importPath === matcher || importPath.startsWith(`${matcher}/`);
75
+ }
76
+
77
+ /**
78
+ * Mutlak yol, matcher önekiyle başlıyor mu (dizin sınırı korunur).
79
+ *
80
+ * @param {string} absMatcher
81
+ * @param {string} absPath
82
+ * @returns {boolean}
83
+ */
84
+ export function matchPathPrefix(absMatcher, absPath) {
85
+ const normalizedMatcher = path.resolve(absMatcher);
86
+ const normalizedPath = path.resolve(absPath);
87
+ if (normalizedPath === normalizedMatcher) return true;
88
+ const prefix = normalizedMatcher.endsWith(path.sep)
89
+ ? normalizedMatcher
90
+ : normalizedMatcher + path.sep;
91
+ return normalizedPath.startsWith(prefix);
92
+ }
93
+
94
+ /**
95
+ * Declarative path matcher altındaki `.{js,ts,mts}` dosyalarını listeler.
96
+ *
97
+ * @param {string} root
98
+ * @param {string} matcher
99
+ * @returns {string[]}
100
+ */
101
+ export function listPathMatcherMembers(root, matcher) {
102
+ const base = resolvePathMatcher(root, matcher);
103
+ if (!fs.existsSync(base)) return [];
104
+
105
+ const stat = fs.statSync(base);
106
+ if (stat.isFile()) return [base];
107
+
108
+ /** @type {string[]} */
109
+ const out = [];
110
+ walkJsFiles(base, out);
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * @param {string} dir
116
+ * @param {string[]} out
117
+ */
118
+ function walkJsFiles(dir, out) {
119
+ let entries;
120
+ try {
121
+ entries = fs.readdirSync(dir, { withFileTypes: true });
122
+ } catch {
123
+ return;
124
+ }
125
+ for (const entry of entries) {
126
+ const full = path.join(dir, entry.name);
127
+ if (entry.isDirectory()) {
128
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
129
+ walkJsFiles(full, out);
130
+ continue;
131
+ }
132
+ if (!entry.isFile()) continue;
133
+ const ext = path.extname(entry.name);
134
+ if (ext === ".js" || ext === ".ts" || ext === ".mts" || ext === ".mjs") {
135
+ out.push(full);
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Import yolu / çözümlenmiş dosya için grup adını bulur.
142
+ * `chunkGroup` hook'u `groups` map'inden önce gelir.
143
+ *
144
+ * @param {string} importPath
145
+ * @param {string | null} resolvedPath
146
+ * @param {ChunkGroupOptions} options
147
+ * @returns {string | null}
148
+ */
149
+ export function resolveGroupName(importPath, resolvedPath, options) {
150
+ const { root, groups, chunkGroup } = options;
151
+
152
+ if (typeof chunkGroup === "function") {
153
+ for (const id of [resolvedPath, importPath].filter(Boolean)) {
154
+ try {
155
+ const result = chunkGroup(/** @type {string} */ (id));
156
+ if (typeof result === "string" && isSafeGroupName(result.trim())) {
157
+ return result.trim();
158
+ }
159
+ } catch (error) {
160
+ console.warn(
161
+ "[build] build.client.chunkGroup() threw, ignoring this module",
162
+ error,
163
+ );
164
+ }
165
+ }
166
+ }
167
+
168
+ for (const [name, matchers] of Object.entries(groups)) {
169
+ for (const matcher of matchers) {
170
+ if (matcherKind(matcher) === "bare") {
171
+ if (matchBarePackage(matcher, importPath)) return name;
172
+ continue;
173
+ }
174
+ const absMatcher = resolvePathMatcher(root, matcher);
175
+ if (resolvedPath && matchPathPrefix(absMatcher, resolvedPath)) return name;
176
+ if (
177
+ importPath.startsWith("./") ||
178
+ importPath.startsWith("../") ||
179
+ importPath.startsWith("@/")
180
+ ) {
181
+ // resolveDir bilinmeden göreli yolu kesinleştiremeyiz; @/ yeter.
182
+ if (importPath.startsWith("@/")) {
183
+ const absImport = path.resolve(root, importPath.slice(2));
184
+ if (matchPathPrefix(absMatcher, absImport)) return name;
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ return null;
191
+ }
192
+
193
+ /**
194
+ * @param {ChunkGroupOptions} options
195
+ * @returns {import('esbuild').Plugin | null}
196
+ */
197
+ export function chunkGroupsPlugin(options) {
198
+ const { root, groups, chunkGroup } = options;
199
+ const hasGroups = Object.keys(groups).length > 0;
200
+ if (!hasGroups && typeof chunkGroup !== "function") return null;
201
+
202
+ /** @type {Map<string, Set<string>>} */
203
+ const members = new Map();
204
+
205
+ for (const name of Object.keys(groups)) {
206
+ if (!isSafeGroupName(name)) continue;
207
+ members.set(name, new Set());
208
+ }
209
+
210
+ // Declarative matcher'lardan bilinen üyeleri önceden doldur.
211
+ for (const [name, matchers] of Object.entries(groups)) {
212
+ if (!isSafeGroupName(name)) continue;
213
+ const set = members.get(name) ?? new Set();
214
+ members.set(name, set);
215
+ for (const matcher of matchers) {
216
+ if (matcherKind(matcher) === "bare") {
217
+ set.add(matcher);
218
+ } else {
219
+ for (const file of listPathMatcherMembers(root, matcher)) {
220
+ set.add(file);
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * @param {string} group
228
+ * @param {string} member
229
+ */
230
+ function addMember(group, member) {
231
+ if (!isSafeGroupName(group)) return;
232
+ let set = members.get(group);
233
+ if (!set) {
234
+ set = new Set();
235
+ members.set(group, set);
236
+ }
237
+ set.add(member);
238
+ }
239
+
240
+ return {
241
+ name: "jskelet-chunk-groups",
242
+ setup(build) {
243
+ build.onResolve({ filter: /.*/ }, async (args) => {
244
+ if (args.pluginData?.jskeletChunkGroupSkip) return undefined;
245
+
246
+ // Re-export sarmalayıcısı ve entry `jskelet-group:name` ister; namespace
247
+ // SKIP olsa bile bu specifier'ı biz çözmeliyiz.
248
+ if (args.path.startsWith(GROUP_PREFIX)) {
249
+ const name = args.path.slice(GROUP_PREFIX.length);
250
+ if (!isSafeGroupName(name)) return undefined;
251
+ if (!members.has(name)) members.set(name, new Set());
252
+ return { path: name, namespace: GROUP_NS };
253
+ }
254
+
255
+ // Barrel / re-export içindeki gerçek üye import'ları tekrar sarılmasın.
256
+ if (SKIP_NS.has(args.namespace)) return undefined;
257
+
258
+ // data:/http: vb. ve entry-point'leri dokunma.
259
+ if (args.kind === "entry-point") return undefined;
260
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(args.path)) return undefined;
261
+
262
+ // Önce hook / bare matcher ile ucuz eşleşme; path önekleri resolve ister.
263
+ let group = resolveGroupName(args.path, null, options);
264
+
265
+ const resolved = await build.resolve(args.path, {
266
+ importer: args.importer,
267
+ namespace: args.namespace,
268
+ resolveDir: args.resolveDir,
269
+ kind: args.kind,
270
+ pluginData: { jskeletChunkGroupSkip: true },
271
+ });
272
+
273
+ if (resolved.errors?.length || !resolved.path) {
274
+ return undefined;
275
+ }
276
+
277
+ // Virtual / zaten bizim namespace.
278
+ if (resolved.namespace && SKIP_NS.has(resolved.namespace)) {
279
+ return undefined;
280
+ }
281
+
282
+ group = resolveGroupName(args.path, resolved.path, options) ?? group;
283
+ if (!group) return undefined;
284
+
285
+ // Üye olarak çözümlenmiş yolu yaz — barrel aynı dosyayı çeker.
286
+ addMember(group, resolved.path);
287
+
288
+ return {
289
+ path: `${group}::${resolved.path}`,
290
+ namespace: REEXPORT_NS,
291
+ };
292
+ });
293
+
294
+ build.onLoad({ filter: /.*/, namespace: GROUP_NS }, (args) => {
295
+ const set = members.get(args.path) ?? new Set();
296
+ const lines = [...set].map((member) => `import ${JSON.stringify(member)};`);
297
+ return {
298
+ contents: lines.length ? `${lines.join("\n")}\n` : "export {}\n",
299
+ resolveDir: root,
300
+ loader: "js",
301
+ };
302
+ });
303
+
304
+ build.onLoad({ filter: /.*/, namespace: REEXPORT_NS }, (args) => {
305
+ const sep = args.path.indexOf("::");
306
+ if (sep === -1) {
307
+ return { contents: "export {}\n", loader: "js" };
308
+ }
309
+ const group = args.path.slice(0, sep);
310
+ const target = args.path.slice(sep + 2);
311
+ const targetLit = JSON.stringify(target);
312
+ const groupLit = JSON.stringify(`${GROUP_PREFIX}${group}`);
313
+
314
+ // Side-effect barrel + re-export. Named-only modüllerde
315
+ // `__ns.default` undefined olur (uyarı → logOverride ile susturulur);
316
+ // `export { default } from` ise hard error verdiği için kullanılmaz.
317
+ return {
318
+ contents: `
319
+ import ${groupLit};
320
+ import * as __ns from ${targetLit};
321
+ export * from ${targetLit};
322
+ export default __ns.default;
323
+ `,
324
+ resolveDir: root,
325
+ loader: "js",
326
+ };
327
+ });
328
+ },
329
+ };
330
+ }
331
+