jskelet 0.6.5 → 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,11 @@ 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.
31
36
  - `ogImage({ fonts })` registers each `ttf` or `otf` (`path` and `family`)
32
37
  with fontconfig so card and raw-SVG text use the site's own files. A slim
33
38
  image still needs the `fontconfig` package. `woff` / `woff2` are not drawn
@@ -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
 
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.6.5",
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
+
@@ -15,6 +15,7 @@ import fs from "node:fs";
15
15
  import path from "node:path";
16
16
  import process from "node:process";
17
17
  import * as esbuild from "esbuild";
18
+ import { chunkGroupsPlugin } from "../chunk-groups.mjs";
18
19
  import { paths, patchManifest, pruneAssets } from "../paths.mjs";
19
20
  import * as log from "../../log.mjs";
20
21
 
@@ -187,13 +188,34 @@ export async function buildClient(config, { watch = false } = {}) {
187
188
  .split(path.sep)
188
189
  .join("/");
189
190
 
191
+ const clientBuild = config.build?.client ?? {
192
+ splitting: true,
193
+ groups: {},
194
+ chunkGroup: null,
195
+ };
196
+ const splitting = clientBuild.splitting !== false;
197
+ const groups = splitting ? (clientBuild.groups ?? {}) : {};
198
+ const chunkGroup = splitting ? (clientBuild.chunkGroup ?? null) : null;
199
+
200
+ const groupPlugin = splitting
201
+ ? chunkGroupsPlugin({
202
+ root: config.root,
203
+ groups,
204
+ chunkGroup,
205
+ })
206
+ : null;
207
+
208
+ /** @type {esbuild.Plugin[]} */
209
+ const plugins = [aliasPlugin(config.root)];
210
+ if (groupPlugin) plugins.push(groupPlugin);
211
+
190
212
  /** @type {esbuild.BuildOptions} */
191
213
  const options = {
192
214
  entryPoints,
193
215
  outdir: outDir,
194
216
  absWorkingDir: config.root,
195
217
  bundle: true,
196
- splitting: true,
218
+ splitting,
197
219
  format: "esm",
198
220
  target: ["chrome111", "edge111", "firefox111", "safari16.4"],
199
221
  minify: true,
@@ -203,10 +225,15 @@ export async function buildClient(config, { watch = false } = {}) {
203
225
  entryNames: "[name].[hash]",
204
226
  chunkNames: "chunks/[name].[hash]",
205
227
  legalComments: "none",
206
- plugins: [aliasPlugin(config.root)],
228
+ plugins,
207
229
  define: {
208
230
  "process.env": JSON.stringify(publicEnv(config.clientEnv)),
209
231
  },
232
+ // Grup sarmalayıcısı her üyede `export { default }` yazar; named-only
233
+ // modüllerde bu uyarı gürültü — davranış yine doğru (default undefined).
234
+ ...(groupPlugin
235
+ ? { logOverride: { "import-is-undefined": "silent" } }
236
+ : {}),
210
237
  };
211
238
 
212
239
  if (watch) {
@@ -218,8 +245,7 @@ export async function buildClient(config, { watch = false } = {}) {
218
245
  await context.watch();
219
246
  writeMetafile(result.metafile);
220
247
  const manifest = toManifest(result.metafile, config, entryRoot);
221
- const entries = Object.keys(manifest).length;
222
- log.detail(`${entries} ${entries === 1 ? "entry" : "entries"}`);
248
+ log.detail(summarizeClientBuild(manifest, result.metafile));
223
249
  return manifest;
224
250
  }
225
251
 
@@ -230,11 +256,32 @@ export async function buildClient(config, { watch = false } = {}) {
230
256
  (sum, output) => sum + output.bytes,
231
257
  0,
232
258
  );
233
- const entries = Object.keys(manifest).length;
234
- log.detail(`${entries} ${entries === 1 ? "entry" : "entries"}, ${log.size(bytes)}`);
259
+ log.detail(
260
+ `${summarizeClientBuild(manifest, result.metafile)}, ${log.size(bytes)}`,
261
+ );
235
262
  return manifest;
236
263
  }
237
264
 
265
+ /**
266
+ * Entry + inject edilen group entry'ler metafile'da durur; teşhis için
267
+ * hem gerçek manifest entry sayısını hem toplam çıktı dosyasını yaz.
268
+ *
269
+ * @param {Record<string, string>} manifest
270
+ * @param {esbuild.Metafile} [metafile]
271
+ * @returns {string}
272
+ */
273
+ function summarizeClientBuild(manifest, metafile) {
274
+ const entries = Object.keys(manifest).length;
275
+ const entryLabel = `${entries} ${entries === 1 ? "entry" : "entries"}`;
276
+ if (!metafile) return entryLabel;
277
+
278
+ const outputs = Object.keys(metafile.outputs).filter(
279
+ (file) => file.endsWith(".js") || file.endsWith(".mjs"),
280
+ );
281
+ const chunks = outputs.length;
282
+ return `${entryLabel}, ${chunks} ${chunks === 1 ? "file" : "files"}`;
283
+ }
284
+
238
285
  /**
239
286
  * Metafile diske yazılır: dev panelindeki chunk analizi giriş/çıkış
240
287
  * kırılımını buradan okur. Çalışma zamanı bu dosyaya bağımlı değildir.
@@ -470,6 +470,31 @@ export const DEFAULT_SECURITY = {
470
470
  },
471
471
  };
472
472
 
473
+ /**
474
+ * Client bundle (esbuild) ayarları.
475
+ *
476
+ * `groups` / `chunkGroup` esbuild'in native manualChunks'ı olmadığı için
477
+ * framework plugin'i ile uygulanır — ayrıntı `src/build/chunk-groups.mjs`.
478
+ */
479
+ export const DEFAULT_BUILD = {
480
+ client: {
481
+ /** esbuild `splitting`; kapalıyken gruplama yok sayılır. */
482
+ splitting: true,
483
+ /**
484
+ * İsim → matcher listesi. Bare paket (`chart.js`), yol öneki
485
+ * (`./client/chrome/`) veya `@/` öneki.
486
+ * @type {Record<string, string[]>}
487
+ */
488
+ groups: {},
489
+ /**
490
+ * Modül kimliğini (mutlak yol veya bare specifier) grup adına map eder.
491
+ * `groups` ile çakışınca hook kazanır.
492
+ * @type {((id: string) => string | null | undefined) | null}
493
+ */
494
+ chunkGroup: null,
495
+ },
496
+ };
497
+
473
498
  /**
474
499
  * Build-zamanı görsel adımı + opsiyonel runtime uzak görsel proxy.
475
500
  *
@@ -47,6 +47,7 @@ import {
47
47
  ON_VISIT_CONCURRENCY_CEILING,
48
48
  ON_VISIT_PER_PAGE_CEILING,
49
49
  ON_VISIT_RPS_CEILING,
50
+ DEFAULT_BUILD,
50
51
  DEFAULT_IMAGES,
51
52
  DEFAULT_LOGS,
52
53
  DEFAULT_NAVIGATION,
@@ -160,6 +161,17 @@ const CONFIG_FILE = "jskelet.config.mjs";
160
161
  * @property {{ scan?: string[], dir: string } | false} icons
161
162
  * @property {ImagesConfig | false} images
162
163
  * @property {string[]} clientEnv Client bundle'a gömülecek env anahtarları.
164
+ * @property {BuildConfig} build Client bundling (esbuild splitting / chunk groups).
165
+ */
166
+
167
+ /**
168
+ * @typedef {object} BuildClientConfig
169
+ * @property {boolean} splitting
170
+ * @property {Record<string, string[]>} groups
171
+ * @property {((id: string) => string | null | undefined) | null} chunkGroup
172
+ *
173
+ * @typedef {object} BuildConfig
174
+ * @property {BuildClientConfig} client
163
175
  */
164
176
 
165
177
  /**
@@ -1139,6 +1151,118 @@ function normalizeSecurity(raw) {
1139
1151
  };
1140
1152
  }
1141
1153
 
1154
+ /**
1155
+ * Client build ayarları (`build.client`). Bozuk tipler uyarıyla varsayılana düşer.
1156
+ *
1157
+ * @param {unknown} raw
1158
+ * @returns {BuildConfig}
1159
+ */
1160
+ export function normalizeBuild(raw) {
1161
+ if (raw == null) {
1162
+ return {
1163
+ client: {
1164
+ splitting: DEFAULT_BUILD.client.splitting,
1165
+ groups: {},
1166
+ chunkGroup: null,
1167
+ },
1168
+ };
1169
+ }
1170
+
1171
+ if (typeof raw !== "object" || Array.isArray(raw)) {
1172
+ console.warn("[config] build must be an object, ignoring it");
1173
+ return {
1174
+ client: {
1175
+ splitting: DEFAULT_BUILD.client.splitting,
1176
+ groups: {},
1177
+ chunkGroup: null,
1178
+ },
1179
+ };
1180
+ }
1181
+
1182
+ const source = /** @type {Record<string, any>} */ (raw);
1183
+ const clientRaw = source.client;
1184
+
1185
+ if (clientRaw == null) {
1186
+ return {
1187
+ client: {
1188
+ splitting: DEFAULT_BUILD.client.splitting,
1189
+ groups: {},
1190
+ chunkGroup: null,
1191
+ },
1192
+ };
1193
+ }
1194
+
1195
+ if (typeof clientRaw !== "object" || Array.isArray(clientRaw)) {
1196
+ console.warn("[config] build.client must be an object, ignoring it");
1197
+ return {
1198
+ client: {
1199
+ splitting: DEFAULT_BUILD.client.splitting,
1200
+ groups: {},
1201
+ chunkGroup: null,
1202
+ },
1203
+ };
1204
+ }
1205
+
1206
+ const client = /** @type {Record<string, any>} */ (clientRaw);
1207
+ const splitting = client.splitting !== false;
1208
+
1209
+ /** @type {Record<string, string[]>} */
1210
+ const groups = {};
1211
+ if (client.groups != null) {
1212
+ if (typeof client.groups !== "object" || Array.isArray(client.groups)) {
1213
+ console.warn("[config] build.client.groups must be an object, ignoring it");
1214
+ } else {
1215
+ for (const [name, matchers] of Object.entries(client.groups)) {
1216
+ if (!isSafeGroupName(name)) {
1217
+ console.warn(
1218
+ `[config] build.client.groups key "${name}" is invalid ` +
1219
+ "(use letters, digits, _ or -); skipping",
1220
+ );
1221
+ continue;
1222
+ }
1223
+ const list = asArray(matchers, `build.client.groups.${name}`)
1224
+ .filter((entry) => typeof entry === "string" && entry.trim())
1225
+ .map((entry) => String(entry).trim());
1226
+ if (list.length) groups[name] = list;
1227
+ }
1228
+ }
1229
+ }
1230
+
1231
+ /** @type {((id: string) => string | null | undefined) | null} */
1232
+ let chunkGroup = null;
1233
+ if (client.chunkGroup != null) {
1234
+ if (typeof client.chunkGroup === "function") {
1235
+ chunkGroup = client.chunkGroup;
1236
+ } else {
1237
+ console.warn(
1238
+ "[config] build.client.chunkGroup must be a function, ignoring it",
1239
+ );
1240
+ }
1241
+ }
1242
+
1243
+ if (
1244
+ !splitting &&
1245
+ (Object.keys(groups).length > 0 || chunkGroup)
1246
+ ) {
1247
+ console.warn(
1248
+ "[config] build.client.groups / chunkGroup require splitting; " +
1249
+ "they are ignored while splitting is false",
1250
+ );
1251
+ }
1252
+
1253
+ return { client: { splitting, groups, chunkGroup } };
1254
+ }
1255
+
1256
+ /**
1257
+ * Chunk dosya adına güvenli grup adı.
1258
+ *
1259
+ * @param {string} name
1260
+ * @returns {boolean}
1261
+ */
1262
+ function isSafeGroupName(name) {
1263
+ return typeof name === "string" && /^[A-Za-z0-9_-]+$/.test(name);
1264
+ }
1265
+
1142
1266
  /**
1143
1267
  * İkon sprite ayarları. `false` → adım atlanır. `dir` varsayılanı `"icons"`:
1144
1268
  * o dizin varsa yalnızca yerel SVG'ler; yoksa Phosphor.
@@ -1423,6 +1547,7 @@ export async function loadConfig(options = {}) {
1423
1547
  icons: normalizeIcons(source.icons),
1424
1548
  images: normalizeImages(source.images),
1425
1549
  clientEnv: source.clientEnv ?? [],
1550
+ build: normalizeBuild(source.build),
1426
1551
  };
1427
1552
 
1428
1553
  if (
@@ -420,6 +420,30 @@ export declare const DEFAULT_SECURITY: {
420
420
  headerName: string;
421
421
  };
422
422
  };
423
+ /**
424
+ * Client bundle (esbuild) ayarları.
425
+ *
426
+ * `groups` / `chunkGroup` esbuild'in native manualChunks'ı olmadığı için
427
+ * framework plugin'i ile uygulanır — ayrıntı `src/build/chunk-groups.mjs`.
428
+ */
429
+ export declare const DEFAULT_BUILD: {
430
+ client: {
431
+ /** esbuild `splitting`; kapalıyken gruplama yok sayılır. */
432
+ splitting: boolean;
433
+ /**
434
+ * İsim → matcher listesi. Bare paket (`chart.js`), yol öneki
435
+ * (`./client/chrome/`) veya `@/` öneki.
436
+ * @type {Record<string, string[]>}
437
+ */
438
+ groups: Record<string, string[]>;
439
+ /**
440
+ * Modül kimliğini (mutlak yol veya bare specifier) grup adına map eder.
441
+ * `groups` ile çakışınca hook kazanır.
442
+ * @type {((id: string) => string | null | undefined) | null}
443
+ */
444
+ chunkGroup: ((id: string) => string | null | undefined) | null;
445
+ };
446
+ };
423
447
  /**
424
448
  * Build-zamanı görsel adımı + opsiyonel runtime uzak görsel proxy.
425
449
  *
@@ -220,6 +220,18 @@ export type ResolvedConfig = {
220
220
  * Client bundle'a gömülecek env anahtarları.
221
221
  */
222
222
  clientEnv: string[];
223
+ /**
224
+ * Client bundling (esbuild splitting / chunk groups).
225
+ */
226
+ build: BuildConfig;
227
+ };
228
+ export type BuildClientConfig = {
229
+ splitting: boolean;
230
+ groups: Record<string, string[]>;
231
+ chunkGroup: ((id: string) => string | null | undefined) | null;
232
+ };
233
+ export type BuildConfig = {
234
+ client: BuildClientConfig;
223
235
  };
224
236
  export type ImagesRemoteConfig = {
225
237
  enabled: boolean;
@@ -279,6 +291,13 @@ export type SecurityConfig = {
279
291
  headerName: string;
280
292
  };
281
293
  };
294
+ /**
295
+ * Client build ayarları (`build.client`). Bozuk tipler uyarıyla varsayılana düşer.
296
+ *
297
+ * @param {unknown} raw
298
+ * @returns {BuildConfig}
299
+ */
300
+ export declare function normalizeBuild(raw: unknown): BuildConfig;
282
301
  /**
283
302
  * Config'i okur, normalize eder ve modül durumuna yazar. Sunucu ve build
284
303
  * süreçleri açılışta bir kez çağırır.