jskelet 0.1.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.
Files changed (72) hide show
  1. package/AGENTS.md +127 -0
  2. package/CHANGELOG.md +40 -0
  3. package/LICENSE +21 -0
  4. package/README.md +342 -0
  5. package/bin/jskelet.mjs +104 -0
  6. package/docs/01-baslangic.md +285 -0
  7. package/docs/02-mimari.md +287 -0
  8. package/docs/03-routing.md +437 -0
  9. package/docs/04-render-ve-sablonlar.md +490 -0
  10. package/docs/05-islands.md +429 -0
  11. package/docs/06-cache.md +409 -0
  12. package/docs/07-yapilandirma.md +673 -0
  13. package/docs/08-build.md +366 -0
  14. package/docs/09-dev-araclari.md +302 -0
  15. package/docs/10-dagitim.md +329 -0
  16. package/docs/11-tasima.md +352 -0
  17. package/docs/README.md +82 -0
  18. package/package.json +97 -0
  19. package/src/build/build.mjs +138 -0
  20. package/src/build/ensure-build.mjs +15 -0
  21. package/src/build/paths.mjs +118 -0
  22. package/src/build/resolve-peer.mjs +36 -0
  23. package/src/build/tasks/client.mjs +268 -0
  24. package/src/build/tasks/css.mjs +124 -0
  25. package/src/build/tasks/fonts.mjs +146 -0
  26. package/src/build/tasks/icons.mjs +224 -0
  27. package/src/build/tasks/images.mjs +244 -0
  28. package/src/build/tasks/precompress.mjs +78 -0
  29. package/src/client/devtools/overlay.js +1763 -0
  30. package/src/client/devtools/report.html +185 -0
  31. package/src/client/devtools/report.js +712 -0
  32. package/src/client/dom.js +95 -0
  33. package/src/client/index.js +26 -0
  34. package/src/client/registry.js +223 -0
  35. package/src/client/safe-image.js +91 -0
  36. package/src/client/store.js +36 -0
  37. package/src/config/defaults.js +102 -0
  38. package/src/config/index.js +433 -0
  39. package/src/config/pattern.js +107 -0
  40. package/src/dev-server.mjs +383 -0
  41. package/src/http/control-flow.js +56 -0
  42. package/src/http/request-cache.js +46 -0
  43. package/src/index.js +35 -0
  44. package/src/init.mjs +220 -0
  45. package/src/log.mjs +332 -0
  46. package/src/logo.png +0 -0
  47. package/src/runtime/alias-hooks.mjs +119 -0
  48. package/src/runtime/register.mjs +4 -0
  49. package/src/server/assets.js +119 -0
  50. package/src/server/create-app.js +167 -0
  51. package/src/server/dev/devtools.js +383 -0
  52. package/src/server/dev/report.js +351 -0
  53. package/src/server/head-hints.js +132 -0
  54. package/src/server/html-cache.js +166 -0
  55. package/src/server/metadata.js +102 -0
  56. package/src/server/middleware/compression.js +205 -0
  57. package/src/server/middleware/dev-gate.js +62 -0
  58. package/src/server/middleware/headers.js +37 -0
  59. package/src/server/middleware/redirects.js +32 -0
  60. package/src/server/middleware/static-precompressed.js +100 -0
  61. package/src/server/middleware/upstream-proxy.js +141 -0
  62. package/src/server/prewarm.js +283 -0
  63. package/src/server/render.js +356 -0
  64. package/src/server/router.js +121 -0
  65. package/src/server/status-page.js +164 -0
  66. package/src/server/upstream-tracking.js +51 -0
  67. package/src/start.mjs +7 -0
  68. package/src/templates/layout.ejs +44 -0
  69. package/src/version.mjs +17 -0
  70. package/src/views/components/loader.js +85 -0
  71. package/src/views/helpers/html.js +102 -0
  72. package/src/views/helpers/tags.js +193 -0
@@ -0,0 +1,433 @@
1
+ /**
2
+ * `jskelet.config.mjs` yükleyicisi ve çözümlenmiş proje durumu.
3
+ *
4
+ * Bu modül framework'ün **tek gerçek kaynağıdır**: proje kökü, dizin yolları,
5
+ * markalama, hook'lar ve `headers/redirects/rewrites/cache` kuralları burada
6
+ * normalize edilir. Diğer modüller yol hesaplamaz, `getConfig()` çağırır.
7
+ * Böylece framework `node_modules/` içine girdiğinde hiçbir dosyada
8
+ * `../..` sayma hatası oluşmaz.
9
+ *
10
+ * Config dosyası **zorunlu değildir**: yoksa ya da okunamıyorsa uyarı basılır
11
+ * ve sunucu varsayılanlarla ayağa kalkar. Bozuk bir düzenleme siteyi
12
+ * açılamaz hâle getirmemeli.
13
+ *
14
+ * Desteklenen bölümler (hepsi opsiyonel, hepsi `async` olabilir):
15
+ * headers() → [{ source, headers: [{ key, value }] }]
16
+ * redirects() → [{ source, destination, permanent?, statusCode? }]
17
+ * rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
18
+ * cache() → { html?: { [source]: saniye }, prewarm?: {...} }
19
+ */
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import process from "node:process";
23
+ import { pathToFileURL } from "node:url";
24
+ import { compilePattern } from "./pattern.js";
25
+ import {
26
+ DEFAULT_BRAND,
27
+ DEFAULT_DEV_GATE_BYPASS,
28
+ DEFAULT_DIRS,
29
+ DEFAULT_NAVIGATION,
30
+ DEFAULT_NAVIGATION_EXCLUDE,
31
+ DEFAULT_PREWARM,
32
+ DEFAULT_PREWARM_SKIP,
33
+ DEFAULT_STATIC,
34
+ } from "./defaults.js";
35
+
36
+ /** Framework paketinin kökü — kendi şablonlarına ve varlıklarına erişir. */
37
+ export const FRAMEWORK_ROOT = path.resolve(import.meta.dirname, "..", "..");
38
+
39
+ const CONFIG_FILE = "jskelet.config.mjs";
40
+
41
+ /**
42
+ * @typedef {"conservative" | "moderate" | "eager"} Eagerness
43
+ *
44
+ * @typedef {object} NavigationConfig
45
+ * @property {false | Eagerness} prefetch
46
+ * @property {false | Eagerness} prerender
47
+ * @property {boolean} viewTransition
48
+ * @property {string[]} exclude Spekülasyon dışı bırakılan href desenleri.
49
+ */
50
+
51
+ /**
52
+ * @typedef {import('./pattern.js').CompiledPattern} CompiledPattern
53
+ *
54
+ * @typedef {object} ResolvedConfig
55
+ * @property {string} root Proje kökü (mutlak).
56
+ * @property {boolean} loaded Config dosyası okundu mu.
57
+ * @property {Record<string, string>} dirs Mutlak dizin yolları.
58
+ * @property {{ pattern: CompiledPattern, headers: { key: string, value: string }[] }[]} headers
59
+ * @property {{ pattern: CompiledPattern, destination: string, statusCode: number }[]} redirects
60
+ * @property {{ phase: "beforeFiles" | "afterFiles", pattern: CompiledPattern, destination: string }[]} rewrites
61
+ * @property {{ pattern: CompiledPattern, seconds: number }[]} html
62
+ * @property {Record<string, unknown>} prewarm
63
+ * @property {Record<string, unknown>} brand
64
+ * @property {Record<string, Function>} hooks
65
+ * @property {string} layout Layout `.ejs` dosyasının mutlak yolu.
66
+ * @property {string[] | null} routes Açık route modülü listesi.
67
+ * @property {{ extensions: Set<string>, prefixes: string[] }} static
68
+ * @property {string[]} devGateBypass
69
+ * @property {string[]} preconnect
70
+ * @property {NavigationConfig} navigation
71
+ * @property {string[]} prewarmSkip
72
+ * @property {string[]} watch Dev sunucusunun izlediği ek dizinler.
73
+ * @property {{ family: string, slug?: string, weights: number[] }[]} fonts
74
+ * @property {{ scan?: string[] } | false} icons
75
+ * @property {{ widths?: number[], quality?: number, skip?: string[] } | false} images
76
+ * @property {string[]} clientEnv Client bundle'a gömülecek env anahtarları.
77
+ */
78
+
79
+ /** @type {ResolvedConfig | null} */
80
+ let config = null;
81
+
82
+ /**
83
+ * @param {unknown} value
84
+ * @param {string} label
85
+ * @returns {unknown[]}
86
+ */
87
+ function asArray(value, label) {
88
+ if (value == null) return [];
89
+ if (Array.isArray(value)) return value;
90
+ console.warn(`[config] ${label} bir dizi döndürmeli, yok sayıldı`);
91
+ return [];
92
+ }
93
+
94
+ /**
95
+ * @param {unknown} raw
96
+ * @returns {ResolvedConfig["headers"]}
97
+ */
98
+ function normalizeHeaders(raw) {
99
+ /** @type {ResolvedConfig["headers"]} */
100
+ const out = [];
101
+
102
+ for (const entry of asArray(raw, "headers()")) {
103
+ const pattern = compilePattern(entry?.source);
104
+ if (!pattern) continue;
105
+
106
+ const headers = asArray(entry?.headers, "headers()[].headers")
107
+ .filter((header) => header?.key && header?.value !== undefined)
108
+ .map((header) => ({ key: String(header.key), value: String(header.value) }));
109
+
110
+ if (headers.length) out.push({ pattern, headers });
111
+ }
112
+
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * @param {unknown} raw
118
+ * @returns {ResolvedConfig["redirects"]}
119
+ */
120
+ function normalizeRedirects(raw) {
121
+ /** @type {ResolvedConfig["redirects"]} */
122
+ const out = [];
123
+
124
+ for (const entry of asArray(raw, "redirects()")) {
125
+ const pattern = compilePattern(entry?.source);
126
+ if (!pattern || typeof entry?.destination !== "string") continue;
127
+
128
+ // Next semantiği: permanent → 308, geçici → 307. Farklı bir kod isteyen
129
+ // `statusCode` verebilir (ör. eski kurulumlarla uyum için 301).
130
+ const statusCode = Number(entry.statusCode) || (entry.permanent ? 308 : 307);
131
+
132
+ out.push({ pattern, destination: entry.destination, statusCode });
133
+ }
134
+
135
+ return out;
136
+ }
137
+
138
+ /**
139
+ * @param {unknown} raw
140
+ * @returns {ResolvedConfig["rewrites"]}
141
+ */
142
+ function normalizeRewrites(raw) {
143
+ /** @type {ResolvedConfig["rewrites"]} */
144
+ const out = [];
145
+
146
+ /** @type {[("beforeFiles" | "afterFiles"), unknown][]} */
147
+ const phases = Array.isArray(raw)
148
+ ? [["afterFiles", raw]]
149
+ : [
150
+ ["beforeFiles", raw?.beforeFiles],
151
+ ["afterFiles", raw?.afterFiles],
152
+ ];
153
+
154
+ for (const [phase, entries] of phases) {
155
+ for (const entry of asArray(entries, `rewrites().${phase}`)) {
156
+ const pattern = compilePattern(entry?.source);
157
+ if (!pattern || typeof entry?.destination !== "string") continue;
158
+ out.push({ phase, pattern, destination: entry.destination });
159
+ }
160
+ }
161
+
162
+ return out;
163
+ }
164
+
165
+ /**
166
+ * @param {unknown} raw
167
+ * @returns {{ html: ResolvedConfig["html"], prewarm: Record<string, unknown> }}
168
+ */
169
+ function normalizeCache(raw) {
170
+ /** @type {ResolvedConfig["html"]} */
171
+ const html = [];
172
+
173
+ for (const [source, seconds] of Object.entries(raw?.html ?? {})) {
174
+ const pattern = compilePattern(source);
175
+ const value = Number(seconds);
176
+ if (!pattern || !Number.isFinite(value) || value < 0) continue;
177
+ html.push({ pattern, seconds: value });
178
+ }
179
+
180
+ return { html, prewarm: { ...DEFAULT_PREWARM, ...(raw?.prewarm ?? {}) } };
181
+ }
182
+
183
+ /** Speculation Rules'un tanıdığı eagerness değerleri. */
184
+ const EAGERNESS = new Set(["conservative", "moderate", "eager"]);
185
+
186
+ /**
187
+ * `true` → varsayılan eagerness, `false` → kapalı, string → doğrulanır.
188
+ * Geçersiz bir değer siteyi düşürmemeli; uyarı basılıp varsayılana dönülür.
189
+ *
190
+ * @param {unknown} value
191
+ * @param {false | Eagerness} fallback
192
+ * @param {string} label
193
+ * @returns {false | Eagerness}
194
+ */
195
+ function normalizeEagerness(value, fallback, label) {
196
+ if (value === undefined) return fallback;
197
+ if (value === false) return false;
198
+ if (value === true) return fallback === false ? "moderate" : fallback;
199
+ if (typeof value === "string" && EAGERNESS.has(value)) {
200
+ return /** @type {Eagerness} */ (value);
201
+ }
202
+
203
+ console.warn(
204
+ `[config] navigation.${label} geçersiz (${String(value)}), varsayılana dönüldü`,
205
+ );
206
+ return fallback;
207
+ }
208
+
209
+ /**
210
+ * @param {unknown} raw
211
+ * @param {Record<string, unknown>} brand
212
+ * @returns {NavigationConfig}
213
+ */
214
+ function normalizeNavigation(raw, brand) {
215
+ const source = /** @type {Record<string, unknown>} */ (raw ?? {});
216
+
217
+ // Dev araçlarının yolu spekülasyona kapalı: overlay ve rapor uçları gerçek
218
+ // sayfa değil, önden getirilmelerinin hiçbir karşılığı yok.
219
+ const devBase = typeof brand.devBasePath === "string" ? brand.devBasePath : null;
220
+
221
+ return {
222
+ prefetch: normalizeEagerness(
223
+ source.prefetch,
224
+ DEFAULT_NAVIGATION.prefetch,
225
+ "prefetch",
226
+ ),
227
+ prerender: normalizeEagerness(
228
+ source.prerender,
229
+ DEFAULT_NAVIGATION.prerender,
230
+ "prerender",
231
+ ),
232
+ viewTransition: source.viewTransition === true,
233
+ exclude: [
234
+ ...DEFAULT_NAVIGATION_EXCLUDE,
235
+ ...(devBase ? [`${devBase}/*`] : []),
236
+ ...asArray(source.exclude, "navigation.exclude").filter(
237
+ (entry) => typeof entry === "string",
238
+ ),
239
+ ].map(String),
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Dizin adlarını mutlak yola çevirir. `styles` bir dosya yolu olduğu için
245
+ * de aynı çözümlemeden geçer; ayrı bir alan tutmaya değmez.
246
+ *
247
+ * @param {string} root
248
+ * @param {Record<string, string>} [overrides]
249
+ * @returns {Record<string, string>}
250
+ */
251
+ function resolveDirs(root, overrides) {
252
+ /** @type {Record<string, string>} */
253
+ const dirs = {};
254
+ const merged = { ...DEFAULT_DIRS, ...(overrides ?? {}) };
255
+
256
+ for (const [key, value] of Object.entries(merged)) {
257
+ dirs[key] = path.resolve(root, value);
258
+ }
259
+
260
+ // Build çıktısı `public/assets` altına yazılır; ayrı ayar gerektirmeyecek
261
+ // kadar sabit ama yol hesabı tek yerde kalsın.
262
+ dirs.assets = path.join(dirs.public, "assets");
263
+ dirs.fonts = path.join(dirs.public, "fonts");
264
+
265
+ return dirs;
266
+ }
267
+
268
+ /**
269
+ * Uygulamanın layout'u yoksa framework'ün minimal layout'u kullanılır. Bu
270
+ * sayede yeni bir proje tek bir route ile çalışır hâle gelir.
271
+ *
272
+ * @param {Record<string, string>} dirs
273
+ * @param {string} [override]
274
+ * @returns {string}
275
+ */
276
+ function resolveLayout(dirs, override) {
277
+ if (override) return path.resolve(dirs.views, "..", override);
278
+
279
+ const appLayout = path.join(dirs.views, "layout.ejs");
280
+ if (fs.existsSync(appLayout)) return appLayout;
281
+
282
+ return path.join(FRAMEWORK_ROOT, "src", "templates", "layout.ejs");
283
+ }
284
+
285
+ /**
286
+ * Config'i okur, normalize eder ve modül durumuna yazar. Sunucu ve build
287
+ * süreçleri açılışta bir kez çağırır.
288
+ *
289
+ * Aynı süreçte ikinci çağrı önbelleğe düşer: `jskelet start` hem
290
+ * `ensure-build` hem `createApp` üzerinden çağırıyor ve config'i iki kez
291
+ * okuyup iki kez loglamanın hiçbir faydası yok. Yeniden okumak gerekiyorsa
292
+ * `force: true`.
293
+ *
294
+ * @param {{ root?: string, configFile?: string, force?: boolean }} [options]
295
+ * @returns {Promise<ResolvedConfig>}
296
+ */
297
+ export async function loadConfig(options = {}) {
298
+ if (config && !options.force) return config;
299
+
300
+ const root = path.resolve(options.root ?? process.cwd());
301
+ const configFile = options.configFile ?? CONFIG_FILE;
302
+ const configPath = path.join(root, configFile);
303
+
304
+ /** @type {Record<string, any>} */
305
+ let source = {};
306
+ let loaded = false;
307
+
308
+ if (!fs.existsSync(configPath)) {
309
+ console.warn(
310
+ `[config] ${configFile} bulunamadı — yerleşik varsayılanlarla devam ediliyor.`,
311
+ );
312
+ } else {
313
+ try {
314
+ // Windows'ta mutlak yol import'u için file:// şeması gerekir.
315
+ const module = await import(pathToFileURL(configPath).href);
316
+ source = module.default ?? module;
317
+ loaded = true;
318
+ } catch (error) {
319
+ console.warn(`[config] ${configFile} yüklenemedi, yok sayıldı`, error);
320
+ }
321
+ }
322
+
323
+ /** @param {string} name */
324
+ const section = async (name) => {
325
+ const value = source?.[name];
326
+ if (value == null) return null;
327
+ try {
328
+ return typeof value === "function" ? await value.call(source) : value;
329
+ } catch (error) {
330
+ console.warn(`[config] ${name}() hata verdi, yok sayıldı`, error);
331
+ return null;
332
+ }
333
+ };
334
+
335
+ const [headers, redirects, rewrites, cache] = await Promise.all([
336
+ section("headers"),
337
+ section("redirects"),
338
+ section("rewrites"),
339
+ section("cache"),
340
+ ]);
341
+
342
+ const { html, prewarm } = normalizeCache(cache);
343
+ const dirs = resolveDirs(root, source.paths);
344
+ const brand = { ...DEFAULT_BRAND, ...(source.brand ?? {}) };
345
+
346
+ config = {
347
+ root,
348
+ loaded,
349
+ dirs,
350
+ headers: normalizeHeaders(headers),
351
+ redirects: normalizeRedirects(redirects),
352
+ rewrites: normalizeRewrites(rewrites),
353
+ html,
354
+ prewarm,
355
+ brand,
356
+ hooks: source.hooks ?? {},
357
+ layout: resolveLayout(dirs, source.layout),
358
+ routes: Array.isArray(source.routes) ? source.routes : null,
359
+ static: {
360
+ extensions: new Set(source.static?.extensions ?? DEFAULT_STATIC.extensions),
361
+ prefixes: source.static?.prefixes ?? DEFAULT_STATIC.prefixes,
362
+ },
363
+ devGateBypass: source.devGateBypass ?? DEFAULT_DEV_GATE_BYPASS,
364
+ preconnect: source.preconnect ?? [],
365
+ navigation: normalizeNavigation(source.navigation, brand),
366
+ prewarmSkip: source.prewarmSkip ?? DEFAULT_PREWARM_SKIP,
367
+ // `routes`, `views` ve `lib` zaten izlenir; buraya yalnızca ek dizinler.
368
+ watch: source.watch ?? [],
369
+ // Build tarafı ayarları. Sunucu bunları okumaz ama config tek dosya
370
+ // olsun diye aynı yerden geçer.
371
+ fonts: source.fonts ?? [],
372
+ icons: source.icons ?? {},
373
+ images: source.images ?? {},
374
+ clientEnv: source.clientEnv ?? [],
375
+ };
376
+
377
+ // Dev'de build ve sunucu ayrı alt süreçler; üçü de aynı özeti basınca satır
378
+ // banner'ın ve build bloğunun arasına üç kez giriyor. Özeti dış süreç basar.
379
+ if (loaded && !process.env.JSKELET_CHILD) {
380
+ const counts = [
381
+ config.headers.length && `${config.headers.length} header`,
382
+ config.redirects.length && `${config.redirects.length} redirect`,
383
+ config.rewrites.length && `${config.rewrites.length} rewrite`,
384
+ config.html.length && `${config.html.length} cache kuralı`,
385
+ ].filter(Boolean);
386
+
387
+ if (counts.length) {
388
+ console.log(`[config] ${configFile} yüklendi — ${counts.join(", ")}`);
389
+ }
390
+ }
391
+
392
+ return config;
393
+ }
394
+
395
+ /**
396
+ * Çözümlenmiş config. `loadConfig()` çağrılmadan erişilirse boş bir proje
397
+ * kökü varsayımıyla çalışmak yerine hata verir: sessiz yanlış yol,
398
+ * "stylesheet neden yok" gibi teşhisi zor sorunlara dönüşüyor.
399
+ *
400
+ * @returns {ResolvedConfig}
401
+ */
402
+ export function getConfig() {
403
+ if (!config) {
404
+ throw new Error(
405
+ "[config] loadConfig() çağrılmadan getConfig() kullanıldı. " +
406
+ "Sunucuyu `jskelet` CLI ile ya da createApp() üzerinden başlatın.",
407
+ );
408
+ }
409
+ return config;
410
+ }
411
+
412
+ /**
413
+ * Uygulamanın tanımladığı hook'u çalıştırır; yoksa `fallback` döner.
414
+ * Hook'un hata vermesi sayfayı düşürmemeli — framework kendi varsayılanına
415
+ * geri döner ve uyarır.
416
+ *
417
+ * @template T
418
+ * @param {string} name
419
+ * @param {T} fallback
420
+ * @param {unknown[]} args
421
+ * @returns {Promise<T>}
422
+ */
423
+ export async function hook(name, fallback, ...args) {
424
+ const fn = getConfig().hooks?.[name];
425
+ if (typeof fn !== "function") return fallback;
426
+
427
+ try {
428
+ return await fn(...args);
429
+ } catch (error) {
430
+ console.warn(`[config] hooks.${name}() hata verdi, varsayılan kullanıldı`, error);
431
+ return fallback;
432
+ }
433
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `next.config` `source`/`destination` sözdiziminin küçük bir derleyicisi.
3
+ *
4
+ * Desteklenen biçimler:
5
+ * `/haber/:slug` → tek segment yakalar
6
+ * `/:path*` → sıfır veya daha fazla segment yakalar
7
+ * `/:path*.svg` → joker + sabit son ek (uzantı kuralları böyle yazılır)
8
+ * `/etiket-:slug` → segment ortasında parametre
9
+ *
10
+ * Yakalanan değerler `destination` içindeki aynı adlı `:param`'lara yazılır.
11
+ * Next'in tam `path-to-regexp` yüzeyi değil; config'te fiilen kullanılan alt
12
+ * küme bilinçli olarak seçildi ve tanınmayan bir sözdizimi sessizce literal
13
+ * kabul edilmez, uyarı üretir.
14
+ */
15
+
16
+ /** @typedef {{ regex: RegExp, keys: string[], source: string }} CompiledPattern */
17
+
18
+ const PARAM = /^:([A-Za-z_][A-Za-z0-9_]*)(\*)?/;
19
+
20
+ /** @param {string} text */
21
+ function escapeLiteral(text) {
22
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23
+ }
24
+
25
+ /**
26
+ * @param {string} source
27
+ * @returns {CompiledPattern | null}
28
+ */
29
+ export function compilePattern(source) {
30
+ if (typeof source !== "string" || !source.startsWith("/")) {
31
+ console.warn(`[config] geçersiz source (\`/\` ile başlamalı): ${source}`);
32
+ return null;
33
+ }
34
+
35
+ /** @type {string[]} */
36
+ const keys = [];
37
+ let pattern = "";
38
+ let index = 0;
39
+
40
+ while (index < source.length) {
41
+ const char = source[index];
42
+
43
+ if (char !== ":") {
44
+ pattern += escapeLiteral(char);
45
+ index += 1;
46
+ continue;
47
+ }
48
+
49
+ const match = source.slice(index).match(PARAM);
50
+ if (!match) {
51
+ pattern += escapeLiteral(char);
52
+ index += 1;
53
+ continue;
54
+ }
55
+
56
+ const [raw, name, wildcard] = match;
57
+ keys.push(name);
58
+
59
+ if (!wildcard) {
60
+ // Tek segmentli parametre segment sınırını aşmaz.
61
+ pattern += "([^/]+)";
62
+ index += raw.length;
63
+ continue;
64
+ }
65
+
66
+ // Joker parametre segment sınırlarını aşar ve **sıfır** segment de
67
+ // yakalar: `/hesabim/:path*` kuralı `/hesabim`i de kapsamalı, aksi hâlde
68
+ // bir bölümün tamamını kapatmak isteyen kural kök yolu atlıyor. Bunun için
69
+ // hemen öncesindeki `/` de opsiyonele alınır.
70
+ const trailingSlash = pattern.endsWith("/");
71
+ pattern = trailingSlash ? `${pattern.slice(0, -1)}(?:/(.*))?` : `${pattern}(.*)`;
72
+ index += raw.length;
73
+ }
74
+
75
+ return { regex: new RegExp(`^${pattern}$`), keys, source };
76
+ }
77
+
78
+ /**
79
+ * @param {CompiledPattern} compiled
80
+ * @param {string} pathname
81
+ * @returns {Record<string, string> | null}
82
+ */
83
+ export function matchPattern(compiled, pathname) {
84
+ const match = compiled.regex.exec(pathname);
85
+ if (!match) return null;
86
+
87
+ /** @type {Record<string, string>} */
88
+ const params = {};
89
+ compiled.keys.forEach((key, i) => {
90
+ params[key] = match[i + 1] ?? "";
91
+ });
92
+ return params;
93
+ }
94
+
95
+ /**
96
+ * `destination` içindeki `:param` yer tutucularını doldurur.
97
+ *
98
+ * @param {string} destination
99
+ * @param {Record<string, string>} params
100
+ * @returns {string}
101
+ */
102
+ export function fillDestination(destination, params) {
103
+ return destination.replace(
104
+ /:([A-Za-z_][A-Za-z0-9_]*)(\*)?/g,
105
+ (raw, name) => params[name] ?? raw,
106
+ );
107
+ }