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,121 @@
1
+ /**
2
+ * Route modüllerini yükler ve Express'e bağlar.
3
+ *
4
+ * Dosya sistemine dayalı otomatik URL türetme **yok**: her modül kendi
5
+ * yollarını `app.get(...)` ile açıkça yazar. Sebep, Next.js'ten taşınırken
6
+ * öğrenilen bir şey — sıra önemli. `/:slug` gibi tek segmentli bir yakalayıcı
7
+ * `/about` rotasından önce kaydedilirse "about" bir slug sanılır. Sırayı
8
+ * dosya adına gizlemek yerine görünür kılmak, teşhisi kolaylaştırıyor.
9
+ *
10
+ * Sıra iki şekilde belirlenir:
11
+ * 1. `jskelet.config.mjs` → `routes: ["./routes/api.js", …]` (açık liste)
12
+ * 2. Liste yoksa `routes/` dizini alfabetik taranır. Bu durumda dosya adına
13
+ * sayısal önek verin: `10-pages.js`, `50-blog.js`, `99-catch-all.js`.
14
+ *
15
+ * Modül sözleşmesi: default export ya da `register` adlı named export,
16
+ * `(app, api) => void | Promise<void>` imzasıyla. `api` içinde `route`,
17
+ * `renderView`, `renderPage` ve `notFound`/`redirect` hazır gelir, böylece
18
+ * route dosyaları framework'ten tek tek import yapmak zorunda kalmaz.
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 { getConfig } from "../config/index.js";
25
+ import { renderPage, renderView, route } from "./render.js";
26
+ import { notFound, permanentRedirect, redirect } from "../http/control-flow.js";
27
+
28
+ const isDev = process.env.NODE_ENV === "development";
29
+
30
+ /**
31
+ * @param {string} dir
32
+ * @param {string[]} [out]
33
+ * @returns {string[]}
34
+ */
35
+ function discover(dir, out = []) {
36
+ if (!fs.existsSync(dir)) return out;
37
+
38
+ const entries = fs
39
+ .readdirSync(dir, { withFileTypes: true })
40
+ .sort((a, b) => a.name.localeCompare(b.name));
41
+
42
+ for (const entry of entries) {
43
+ const full = path.join(dir, entry.name);
44
+
45
+ if (entry.isDirectory()) {
46
+ discover(full, out);
47
+ continue;
48
+ }
49
+
50
+ if (!/\.(js|mjs)$/.test(entry.name)) continue;
51
+ if (entry.name.startsWith("_")) continue;
52
+
53
+ out.push(full);
54
+ }
55
+
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * Route dosyalarına geçilen framework yüzeyi.
61
+ */
62
+ const api = {
63
+ route,
64
+ renderView,
65
+ renderPage,
66
+ notFound,
67
+ redirect,
68
+ permanentRedirect,
69
+ };
70
+
71
+ /**
72
+ * @param {import('express').Express} app
73
+ * @returns {Promise<number>} Bağlanan modül sayısı.
74
+ */
75
+ export async function registerRoutes(app) {
76
+ const config = getConfig();
77
+
78
+ const files = config.routes
79
+ ? config.routes.map((entry) => path.resolve(config.root, entry))
80
+ : discover(config.dirs.routes);
81
+
82
+ if (!files.length) {
83
+ console.warn(
84
+ `[router] hiç route modülü bulunamadı — ${path.relative(config.root, config.dirs.routes)}/ boş mu?`,
85
+ );
86
+ return 0;
87
+ }
88
+
89
+ let registered = 0;
90
+
91
+ for (const file of files) {
92
+ /** @type {Record<string, unknown>} */
93
+ let module;
94
+
95
+ try {
96
+ module = await import(pathToFileURL(file).href);
97
+ } catch (error) {
98
+ // Dev'de eksik/bozuk bir modül tüm sunucuyu düşürmesin: uyar ve devam
99
+ // et. Üretimde fırlat — yarım route tablosuyla yayına çıkmak,
100
+ // sessizce 404 dönen sayfalar demek.
101
+ if (isDev) {
102
+ console.warn(`[router] ${path.basename(file)} yüklenemedi, atlandı`, error);
103
+ continue;
104
+ }
105
+ throw error;
106
+ }
107
+
108
+ const register = module.default ?? module.register;
109
+ if (typeof register !== "function") {
110
+ console.warn(
111
+ `[router] ${path.basename(file)} default ya da 'register' fonksiyonu dışa açmıyor, atlandı`,
112
+ );
113
+ continue;
114
+ }
115
+
116
+ await register(app, api);
117
+ registered += 1;
118
+ }
119
+
120
+ return registered;
121
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Framework'ün kendi hata sayfaları (404, 500, 503…).
3
+ *
4
+ * Uygulama kendi sayfasını vermediğinde ziyaretçinin Express'in düz metin
5
+ * "Internal Server Error" çıktısını görmesi istenmiyor; bu yüzden framework
6
+ * şablonsuz, tek dosyada duran minimal bir HTML üretir.
7
+ *
8
+ * Metin bilinçli olarak yalın: yalnızca bir şeyin ters gittiğini söyler.
9
+ * Marka adı, ürün tanıtımı ya da hata ayrıntısı yok — hata sayfası
10
+ * ziyaretçiye bir şey satmaz ve sunucunun içini dışa açmaz.
11
+ *
12
+ * Ezme yolları (öncelik sırasıyla):
13
+ * 1. `hooks.notFound()` — yalnızca 404 için, geriye dönük uyumluluk.
14
+ * 2. `hooks.error({ status })` — tüm durumlar için; sayfa tanımı ya da
15
+ * doğrudan HTML string döner.
16
+ * 3. Aşağıdaki gömülü HTML.
17
+ */
18
+ import { getConfig, hook } from "../config/index.js";
19
+ import { esc } from "../views/helpers/html.js";
20
+
21
+ /**
22
+ * Durum koduna karşılık gelen sayfayı üretir. Hiçbir koşulda fırlatmaz:
23
+ * hata sayfasının kendisi patlarsa ziyaretçi boş yanıt görür, bu yüzden her
24
+ * başarısızlık gömülü HTML'e düşer.
25
+ *
26
+ * @param {number} status
27
+ * @param {{ error?: unknown }} [options]
28
+ * @returns {Promise<string>}
29
+ */
30
+ export async function renderStatusPage(status, options = {}) {
31
+ const page =
32
+ (status === 404 ? await hook("notFound", null) : null) ??
33
+ (await hook("error", null, { status, error: options.error }));
34
+
35
+ if (typeof page === "string") return page;
36
+ if (!page) return fallbackPage(status);
37
+
38
+ try {
39
+ // Dinamik import: render.js bu modülü kendisi kullanıyor, statik ithal
40
+ // iki modül arasında döngü kurardı.
41
+ const { renderPage } = await import("./render.js");
42
+ return await renderPage({ pathname: `/${status}`, ...page });
43
+ } catch (error) {
44
+ console.error(`[render] ${status} sayfası render edilemedi`, error);
45
+ return fallbackPage(status);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Bir hatadan HTTP durum kodu çıkarır. Uygulama kodu `error.statusCode` ya da
51
+ * `error.status` ile kendi kodunu bildirebilir; tanınmayan her şey 500'dür.
52
+ *
53
+ * @param {unknown} error
54
+ * @returns {number}
55
+ */
56
+ export function statusFromError(error) {
57
+ const raw =
58
+ error && typeof error === "object"
59
+ ? /** @type {{ statusCode?: unknown, status?: unknown }} */ (error).statusCode ??
60
+ /** @type {{ status?: unknown }} */ (error).status
61
+ : undefined;
62
+
63
+ const status = Number(raw);
64
+ return Number.isInteger(status) && status >= 400 && status <= 599 ? status : 500;
65
+ }
66
+
67
+ /**
68
+ * Durum başlıkları. Yalnızca framework'ün kendi ürettiği yanıtlar için;
69
+ * uygulamanın diline ait metinler `hooks.error()` üzerinden gelir.
70
+ *
71
+ * @type {Record<string, Record<number | "4xx" | "5xx", [string, string]>>}
72
+ */
73
+ const MESSAGES = {
74
+ tr: {
75
+ 400: ["Geçersiz istek", "İstek anlaşılamadı."],
76
+ 401: ["Yetki gerekiyor", "Bu sayfayı görmek için oturum açmanız gerekiyor."],
77
+ 403: ["Erişim yok", "Bu sayfaya erişim izniniz yok."],
78
+ 404: ["Sayfa bulunamadı", "Aradığınız sayfa burada değil."],
79
+ 408: ["İstek zaman aşımına uğradı", "Lütfen tekrar deneyin."],
80
+ 410: ["Sayfa kaldırıldı", "Bu sayfa artık yayında değil."],
81
+ 429: ["Çok fazla istek", "Kısa bir süre sonra tekrar deneyin."],
82
+ 500: ["Bir hata oluştu", "Lütfen daha sonra tekrar deneyin."],
83
+ 503: ["Servis kullanılamıyor", "Lütfen daha sonra tekrar deneyin."],
84
+ "4xx": ["İstek karşılanamadı", "Lütfen adresi kontrol edin."],
85
+ "5xx": ["Bir hata oluştu", "Lütfen daha sonra tekrar deneyin."],
86
+ },
87
+ en: {
88
+ 400: ["Bad request", "The request could not be understood."],
89
+ 401: ["Sign in required", "You need to sign in to view this page."],
90
+ 403: ["No access", "You do not have permission to view this page."],
91
+ 404: ["Page not found", "The page you are looking for is not here."],
92
+ 408: ["Request timed out", "Please try again."],
93
+ 410: ["Page removed", "This page is no longer available."],
94
+ 429: ["Too many requests", "Please try again in a moment."],
95
+ 500: ["Something went wrong", "Please try again later."],
96
+ 503: ["Service unavailable", "Please try again later."],
97
+ "4xx": ["Request failed", "Please check the address."],
98
+ "5xx": ["Something went wrong", "Please try again later."],
99
+ },
100
+ };
101
+
102
+ /**
103
+ * @param {number} status
104
+ * @returns {{ lang: string, title: string, detail: string }}
105
+ */
106
+ function statusText(status) {
107
+ // Config yüklenmeden de çağrılabilir (ör. loadConfig() patladıysa);
108
+ // hata sayfası bu yüzden getConfig()'in fırlatmasına dayanmaz.
109
+ let lang = "en";
110
+ try {
111
+ lang = getConfig().brand.lang ?? "en";
112
+ } catch {
113
+ /* varsayılan kalır */
114
+ }
115
+
116
+ const table = MESSAGES[lang.slice(0, 2).toLowerCase()] ?? MESSAGES.en;
117
+ const [title, detail] =
118
+ table[status] ?? table[status >= 500 ? "5xx" : "4xx"];
119
+
120
+ return { lang, title, detail };
121
+ }
122
+
123
+ /**
124
+ * Şablonsuz, varlıksız hata sayfası: tek istekte biter, build çıktısına ve
125
+ * uygulamanın layout'una bağlı değildir. `noindex` bilinçli — hata sayfası
126
+ * arama sonuçlarında görünmemeli.
127
+ *
128
+ * @param {number} status
129
+ * @returns {string}
130
+ */
131
+ function fallbackPage(status) {
132
+ const { lang, title, detail } = statusText(status);
133
+
134
+ return `<!DOCTYPE html>
135
+ <html lang="${esc(lang)}">
136
+ <head>
137
+ <meta charset="utf-8">
138
+ <meta name="viewport" content="width=device-width, initial-scale=1">
139
+ <meta name="robots" content="noindex, nofollow">
140
+ <title>${status} — ${esc(title)}</title>
141
+ <style>
142
+ :root { color-scheme: light dark; --fg: #18181b; --muted: #71717a; --bg: #fafafa; }
143
+ @media (prefers-color-scheme: dark) {
144
+ :root { --fg: #f4f4f5; --muted: #a1a1aa; --bg: #09090b; }
145
+ }
146
+ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--fg); }
147
+ body {
148
+ display: grid; place-items: center; padding: 2rem; text-align: center;
149
+ font: 1rem/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
150
+ }
151
+ .code { font-size: 3.5rem; font-weight: 600; letter-spacing: -0.02em; margin: 0; }
152
+ h1 { font-size: 1.125rem; font-weight: 600; margin: 0.75rem 0 0; }
153
+ p { margin: 0.375rem 0 0; color: var(--muted); }
154
+ </style>
155
+ </head>
156
+ <body>
157
+ <main>
158
+ <p class="code">${status}</p>
159
+ <h1>${esc(title)}</h1>
160
+ <p>${esc(detail)}</p>
161
+ </main>
162
+ </body>
163
+ </html>`;
164
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Render başına upstream API hatalarını toplar.
3
+ *
4
+ * `render.js` her sayfayı bu bağlam içinde üretir; uygulamanın HTTP istemcisi
5
+ * başarısız bir upstream yanıtında `reportUpstreamFailure()` çağırır. Böylece
6
+ * HTML önbelleği "bu çıktı eksik veriyle üretildi" bilgisine sahip olur ve
7
+ * bozuk sayfayı saklamaz.
8
+ *
9
+ * Bağımlılık yönü bilinçli olarak tersine çevrilmiş: framework veri katmanını
10
+ * tanımaz, veri katmanı framework'e haber verir. Hiç çağıran olmazsa maliyet
11
+ * boş bir dizidir.
12
+ *
13
+ * Kullanım (uygulamanın `lib/api/client.js` içinde):
14
+ *
15
+ * import { reportUpstreamFailure } from "jskelet/server";
16
+ *
17
+ * if (!response.ok) {
18
+ * reportUpstreamFailure({ status: response.status, path: url });
19
+ * }
20
+ */
21
+ import { AsyncLocalStorage } from "node:async_hooks";
22
+
23
+ /**
24
+ * @typedef {{ status: number, path: string }} UpstreamFailure
25
+ * `status: 0` ağ hatası anlamına gelir (yanıt hiç gelmedi).
26
+ */
27
+
28
+ /** @type {AsyncLocalStorage<{ failures: UpstreamFailure[] }>} */
29
+ const storage = new AsyncLocalStorage();
30
+
31
+ /**
32
+ * @param {UpstreamFailure} failure
33
+ * @returns {void}
34
+ */
35
+ export function reportUpstreamFailure(failure) {
36
+ storage.getStore()?.failures.push(failure);
37
+ }
38
+
39
+ /**
40
+ * @param {() => T} run
41
+ * @returns {T}
42
+ * @template T
43
+ */
44
+ export function withUpstreamTracking(run) {
45
+ return storage.run({ failures: [] }, run);
46
+ }
47
+
48
+ /** @returns {UpstreamFailure[]} */
49
+ export function getUpstreamFailures() {
50
+ return storage.getStore()?.failures ?? [];
51
+ }
package/src/start.mjs ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Prod sunucu girişi. Build çıktısı yoksa önce üretir, sonra dinlemeye başlar.
3
+ */
4
+ import "./build/ensure-build.mjs";
5
+ import { startServer } from "./server/create-app.js";
6
+
7
+ await startServer();
@@ -0,0 +1,44 @@
1
+ <%#
2
+ JSkelet varsayılan layout'u.
3
+
4
+ Uygulama `views/layout.ejs` oluşturduğu anda bu dosya devre dışı kalır;
5
+ amacı yeni bir projenin tek route ile çalışabilmesi. Kopyalayıp başlangıç
6
+ noktası olarak kullanın.
7
+
8
+ Kullanılabilir local'ler: metadata, headMeta, extraHead, structuredData,
9
+ body, bodyClass, entries, pathname, lang, devtools, devBasePath, asset,
10
+ hasAsset + tüm html/tag helper'ları ve views/components/** export'ları.
11
+ `hooks.layoutContext()` döndürdüğü her alan da buraya eklenir.
12
+ -%>
13
+ <!DOCTYPE html>
14
+ <html lang="<%= lang %>">
15
+ <head>
16
+ <meta charset="utf-8">
17
+ <meta name="viewport" content="width=device-width, initial-scale=1">
18
+ <%# Kaynak ipuçları en başta: preconnect ve LCP preload'ını geciktirmek
19
+ doğrudan LCP'ye yazılır. Sayfaya özel head buradan gelir. %>
20
+ <%- extraHead %>
21
+ <%# Tek, render-blocking stylesheet — build çalışmadıysa hiç basılmaz. %>
22
+ <% if (hasAsset('app.css')) { %>
23
+ <link rel="stylesheet" href="<%= asset('app.css') %>">
24
+ <% } %>
25
+ <%- headMeta %>
26
+ <% structuredData.forEach(function (item) { %>
27
+ <script type="application/ld+json"><%- jsonScript(item) %></script>
28
+ <% }); %>
29
+ </head>
30
+ <body class="<%= bodyClass %>">
31
+ <%- body %>
32
+ <%# Island runtime; entry yoksa sayfa yine tam çalışır (sadece JS'siz). %>
33
+ <% if (hasAsset('main.js')) { %>
34
+ <script type="module" src="<%= asset('main.js') %>"></script>
35
+ <% } %>
36
+ <% entries.forEach(function (entry) { %>
37
+ <script type="module" src="<%= asset(entry) %>"></script>
38
+ <% }); %>
39
+ <%# Dev overlay: prod build'de yok, yalnızca `jskelet dev` sunucusundan. %>
40
+ <% if (devtools) { %>
41
+ <script type="module" src="<%= devBasePath %>/overlay.js"></script>
42
+ <% } %>
43
+ </body>
44
+ </html>
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Framework sürümü. `package.json`'dan okunur; paket bir kez yayınlandıktan
3
+ * sonra sürümü iki yerde tutmak kaçınılmaz olarak birbirinden ayrılıyor.
4
+ */
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { FRAMEWORK_ROOT } from "./config/index.js";
8
+
9
+ /** @type {string} */
10
+ export const FRAMEWORK_VERSION = (() => {
11
+ try {
12
+ const file = path.join(FRAMEWORK_ROOT, "package.json");
13
+ return JSON.parse(fs.readFileSync(file, "utf8")).version ?? "0.0.0";
14
+ } catch {
15
+ return "0.0.0";
16
+ }
17
+ })();
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `views/components/**` altındaki tüm modülleri yükler ve named export'larını
3
+ * tek bir nesnede birleştirir. Bu nesne EJS şablonlarına local olarak geçer,
4
+ * böylece `<%- card({ … }) %>` gibi çağrılar import gerektirmeden çalışır.
5
+ *
6
+ * Elle bakılan bir barrel dosyası yok: yeni bir bileşen eklemek için dosyayı
7
+ * oluşturmak yeterli.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+
13
+ /** Bileşen olmayan altyapı dosyaları. */
14
+ const SKIP_FILES = new Set(["loader.js", "index.js"]);
15
+
16
+ /**
17
+ * Barrel önce, en düşük öncelikle yüklenir: tek amacı `lib/` yeniden
18
+ * ihraçlarını şablon local'i yapmak. Bileşenlerin kendi dosyaları sonradan
19
+ * gelip sessizce üzerine yazar.
20
+ */
21
+ const BARREL = "index.js";
22
+
23
+ /**
24
+ * @param {string} dir
25
+ * @param {string[]} [out]
26
+ * @returns {string[]}
27
+ */
28
+ function collect(dir, out = []) {
29
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
30
+ const full = path.join(dir, entry.name);
31
+
32
+ if (entry.isDirectory()) {
33
+ collect(full, out);
34
+ continue;
35
+ }
36
+
37
+ if (!entry.name.endsWith(".js")) continue;
38
+ if (SKIP_FILES.has(entry.name)) continue;
39
+
40
+ out.push(full);
41
+ }
42
+
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * @param {string} dir `views/components` dizininin mutlak yolu. Dizin yoksa
48
+ * boş nesne döner: bileşen kullanmayan bir proje de çalışmalı.
49
+ * @returns {Promise<Record<string, unknown>>}
50
+ */
51
+ export async function loadComponents(dir) {
52
+ if (!dir || !fs.existsSync(dir)) return {};
53
+
54
+ /** @type {Record<string, unknown>} */
55
+ const components = {};
56
+ /** @type {Map<string, string>} */
57
+ const origin = new Map();
58
+
59
+ const barrel = path.join(dir, BARREL);
60
+ const files = [
61
+ ...(fs.existsSync(barrel) ? [barrel] : []),
62
+ ...collect(dir).sort(),
63
+ ];
64
+
65
+ for (const file of files) {
66
+ const relative = path.relative(dir, file).split(path.sep).join("/");
67
+ const module = await import(pathToFileURL(file).href);
68
+
69
+ for (const [name, value] of Object.entries(module)) {
70
+ if (name === "default") continue;
71
+
72
+ const previous = origin.get(name);
73
+ if (previous && previous !== BARREL && previous !== relative) {
74
+ console.warn(
75
+ `[components] '${name}' iki kez tanımlı: ${previous} ve ${relative} — ikincisi kazanıyor.`,
76
+ );
77
+ }
78
+
79
+ components[name] = value;
80
+ origin.set(name, relative);
81
+ }
82
+ }
83
+
84
+ return components;
85
+ }
@@ -0,0 +1,102 @@
1
+ /** HTML üretimi için ortak yardımcılar (React'in kaçış davranışının karşılığı). */
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ const ESCAPE_MAP = {
5
+ "&": "&amp;",
6
+ "<": "&lt;",
7
+ ">": "&gt;",
8
+ '"': "&quot;",
9
+ "'": "&#39;",
10
+ };
11
+
12
+ /**
13
+ * Metin içeriği ve attribute değerleri için kaçış.
14
+ * @param {unknown} value
15
+ * @returns {string}
16
+ */
17
+ export function esc(value) {
18
+ if (value == null || value === false) return "";
19
+ return String(value).replace(/[&<>"']/g, (char) => ESCAPE_MAP[char]);
20
+ }
21
+
22
+ /**
23
+ * `<script type="application/ld+json">` gövdesi için güvenli JSON.
24
+ * `</script`, `<!--` ve U+2028/2029 kaçırılır.
25
+ * @param {unknown} value
26
+ * @returns {string}
27
+ */
28
+ export function jsonScript(value) {
29
+ return JSON.stringify(value)
30
+ .replace(/</g, "\\u003c")
31
+ .replace(/>/g, "\\u003e")
32
+ .replace(/&/g, "\\u0026")
33
+ .replace(/\u2028/g, "\\u2028")
34
+ .replace(/\u2029/g, "\\u2029");
35
+ }
36
+
37
+ /**
38
+ * Attribute nesnesini string'e çevirir. `false`/`null`/`undefined` atlanır,
39
+ * `true` boolean attribute olarak yazılır.
40
+ * @param {Record<string, unknown>} attrs
41
+ * @returns {string}
42
+ */
43
+ export function attrs(attrs) {
44
+ const parts = [];
45
+ for (const [key, value] of Object.entries(attrs)) {
46
+ if (value == null || value === false) continue;
47
+ if (value === true) {
48
+ parts.push(key);
49
+ continue;
50
+ }
51
+ parts.push(`${key}="${esc(value)}"`);
52
+ }
53
+ return parts.length ? ` ${parts.join(" ")}` : "";
54
+ }
55
+
56
+ /**
57
+ * `clsx` karşılığı — koşullu sınıf birleştirme, çakışma çözümü yok.
58
+ * @param {...unknown} inputs
59
+ * @returns {string}
60
+ */
61
+ export function cx(...inputs) {
62
+ const out = [];
63
+
64
+ for (const input of inputs) {
65
+ if (!input) continue;
66
+
67
+ if (typeof input === "string" || typeof input === "number") {
68
+ out.push(String(input));
69
+ continue;
70
+ }
71
+
72
+ if (Array.isArray(input)) {
73
+ const nested = cx(...input);
74
+ if (nested) out.push(nested);
75
+ continue;
76
+ }
77
+
78
+ if (typeof input === "object") {
79
+ for (const [key, active] of Object.entries(input)) {
80
+ if (active) out.push(key);
81
+ }
82
+ }
83
+ }
84
+
85
+ return out.join(" ");
86
+ }
87
+
88
+ /**
89
+ * `lib/ui/cn.js` ile aynı davranış: birleştir, sonra Tailwind çakışmalarını çöz.
90
+ *
91
+ * `tailwind-merge` çalışma zamanı bağımlılığı olarak korunur çünkü sınıf
92
+ * hesabı **yalnızca sunucuda** yapılır — client bundle'a hiç girmez, dolayısıyla
93
+ * sayfa ağırlığına etkisi yoktur. Elle yazılmış bir grup tablosu ise
94
+ * `border-2` + `border-transparent` gibi genişlik/renk çiftlerini birbirine
95
+ * karıştırıp sınıf düşürdüğü için görsel regresyon üretiyordu.
96
+ *
97
+ * @param {...unknown} inputs
98
+ * @returns {string}
99
+ */
100
+ export function cn(...inputs) {
101
+ return twMerge(cx(...inputs));
102
+ }