jskelet 0.3.5 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Kaynak metinden named export adlarını çıkarır — modülü çalıştırmadan.
3
+ * Compile-time bilinen bileşen listesi için; `default` yok sayılır.
4
+ */
5
+
6
+ /**
7
+ * Yorumları kaba şekilde siler; string içindeki sahte eşleşmeler nadirdir.
8
+ * @param {string} source
9
+ * @returns {string}
10
+ */
11
+ function stripComments(source) {
12
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
13
+ }
14
+
15
+ /**
16
+ * `export { a, b as C, default as X }` listesinden dışa verilen adları toplar.
17
+ * @param {string} clause
18
+ * @param {Set<string>} out
19
+ */
20
+ function addExportList(clause, out) {
21
+ for (const part of clause.split(",")) {
22
+ const trimmed = part.trim();
23
+ if (!trimmed) continue;
24
+ const bits = trimmed.split(/\s+as\s+/i);
25
+ const exported = (bits[1] ?? bits[0]).trim();
26
+ if (!exported || exported === "default") continue;
27
+ out.add(exported);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * @param {string} source
33
+ * @returns {string[]}
34
+ */
35
+ export function scanNamedExports(source) {
36
+ const text = stripComments(source);
37
+ /** @type {Set<string>} */
38
+ const names = new Set();
39
+
40
+ for (const match of text.matchAll(
41
+ /\bexport\s+(?:async\s+)?(?:function\*?|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g,
42
+ )) {
43
+ names.add(match[1]);
44
+ }
45
+
46
+ for (const match of text.matchAll(/\bexport\s*\{([^}]+)\}/g)) {
47
+ addExportList(match[1], names);
48
+ }
49
+
50
+ return [...names];
51
+ }
@@ -14,8 +14,12 @@ export const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
14
14
  * tek tek ezilebilir.
15
15
  */
16
16
  export const DEFAULT_DIRS = {
17
- /** EJS layout + sayfalar + bileşenler. */
17
+ /** Layout + sayfalar + bileşenler (klasik kök). */
18
18
  views: "views",
19
+ /** Feature-first dikey dilimler (`features/<name>/{server,views,client}`). */
20
+ features: "features",
21
+ /** Özellikler arası paylaşılan server/views/client. */
22
+ shared: "shared",
19
23
  /** Statik dosyalar; build çıktısı da buraya yazılır. */
20
24
  public: "public",
21
25
  /** Island runtime ve entry'ler. */
@@ -24,7 +28,7 @@ export const DEFAULT_DIRS = {
24
28
  routes: "routes",
25
29
  /** Tailwind/PostCSS giriş dosyası. */
26
30
  styles: "styles/globals.css",
27
- /** Build ara çıktıları (manifest, metafile, images). */
31
+ /** Build ara çıktıları (manifest, metafile, images, templates). */
28
32
  generated: ".jskelet",
29
33
  };
30
34
 
@@ -801,6 +801,10 @@ function resolveDirs(root, overrides) {
801
801
  * Uygulamanın layout'u yoksa framework'ün minimal layout'u kullanılır. Bu
802
802
  * sayede yeni bir proje tek bir route ile çalışır hâle gelir.
803
803
  *
804
+ * Öncelik: config `layout` → `layout.jsk` (derlenmiş) → `layout.ejs` →
805
+ * framework varsayılanı. Dönüş değeri kaynak dosya yoludur; `.jsk` için
806
+ * render katmanı derlenmiş modülü kullanır.
807
+ *
804
808
  * @param {Record<string, string>} dirs
805
809
  * @param {string} [override]
806
810
  * @returns {string}
@@ -808,6 +812,9 @@ function resolveDirs(root, overrides) {
808
812
  function resolveLayout(dirs, override) {
809
813
  if (override) return path.resolve(dirs.views, "..", override);
810
814
 
815
+ const jskLayout = path.join(dirs.views, "layout.jsk");
816
+ if (fs.existsSync(jskLayout)) return jskLayout;
817
+
811
818
  const appLayout = path.join(dirs.views, "layout.ejs");
812
819
  if (fs.existsSync(appLayout)) return appLayout;
813
820
 
@@ -236,11 +236,13 @@ function run(args, label, passthrough) {
236
236
  const WATCH_DIRS = [
237
237
  config.dirs.routes,
238
238
  config.dirs.views,
239
+ config.dirs.features,
240
+ config.dirs.shared,
239
241
  path.join(ROOT, "lib"),
240
242
  ...(config.watch ?? []).map((dir) => path.resolve(ROOT, dir)),
241
- ];
243
+ ].filter(Boolean);
242
244
 
243
- const WATCH_EXTENSIONS = /\.(?:js|mjs|json|ejs)$/;
245
+ const WATCH_EXTENSIONS = /\.(?:js|mjs|json|ejs|jsk)$/;
244
246
 
245
247
  /** @type {import('node:child_process').ChildProcess | null} */
246
248
  let server = null;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * `jskelet generate` — feature / page / island iskeleti.
3
+ *
4
+ * Var olan dosyaların üzerine yazmaz. Route kaydı hâlâ açık `register`
5
+ * sözleşmesine bağlıdır; filesystem URL türetme yoktur.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import process from "node:process";
10
+ import * as log from "./log.mjs";
11
+
12
+ /**
13
+ * @param {string} name
14
+ * @returns {string}
15
+ */
16
+ function toPascal(name) {
17
+ return name
18
+ .split(/[-_/]+/)
19
+ .filter(Boolean)
20
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
21
+ .join("");
22
+ }
23
+
24
+ /**
25
+ * @param {string} file
26
+ * @param {string} contents
27
+ * @returns {boolean} yazıldı mı
28
+ */
29
+ function writeIfMissing(file, contents) {
30
+ if (fs.existsSync(file)) {
31
+ log.line(`skip ${path.relative(process.cwd(), file)} (exists)`);
32
+ return false;
33
+ }
34
+ fs.mkdirSync(path.dirname(file), { recursive: true });
35
+ fs.writeFileSync(file, contents, "utf8");
36
+ log.line(`create ${path.relative(process.cwd(), file)}`);
37
+ return true;
38
+ }
39
+
40
+ /**
41
+ * @param {string} root
42
+ * @param {string[]} args
43
+ * @returns {Promise<void>}
44
+ */
45
+ export async function generate(root, args) {
46
+ const [kind, target] = args;
47
+ if (!kind || !target) {
48
+ throw new Error(
49
+ "usage: jskelet generate <feature|page|island> <name>\n" +
50
+ " feature features/<name>/{index.js,server,views,client}\n" +
51
+ " page features/<feature>/views/pages/<name>.jsk (name: feature/page)\n" +
52
+ " island features/<feature>/client/<name>.js (name: feature/island)",
53
+ );
54
+ }
55
+
56
+ if (kind === "feature") {
57
+ await generateFeature(root, target);
58
+ return;
59
+ }
60
+ if (kind === "page") {
61
+ const [feature, page] = splitTarget(target);
62
+ await generatePage(root, feature, page);
63
+ return;
64
+ }
65
+ if (kind === "island") {
66
+ const [feature, island] = splitTarget(target);
67
+ await generateIsland(root, feature, island);
68
+ return;
69
+ }
70
+
71
+ throw new Error(`unknown generate kind: ${kind}`);
72
+ }
73
+
74
+ /**
75
+ * @param {string} target
76
+ * @returns {[string, string]}
77
+ */
78
+ function splitTarget(target) {
79
+ const parts = target.split("/").filter(Boolean);
80
+ if (parts.length < 2) {
81
+ throw new Error(`expected <feature>/<name>, got "${target}"`);
82
+ }
83
+ return [parts[0], parts.slice(1).join("/")];
84
+ }
85
+
86
+ /**
87
+ * @param {string} root
88
+ * @param {string} name
89
+ */
90
+ async function generateFeature(root, name) {
91
+ const base = path.join(root, "features", name);
92
+ writeIfMissing(
93
+ path.join(base, "index.js"),
94
+ `/**
95
+ * Feature route registration. Explicit paths only — no filesystem URL routing.
96
+ * @param {import('express').Express} app
97
+ * @param {{ route: Function }} api
98
+ */
99
+ export default function register(app, { route }) {
100
+ app.get(
101
+ "/${name}",
102
+ route(async () => ({
103
+ view: "pages/${name}",
104
+ metadata: { title: "${toPascal(name)}" },
105
+ data: {},
106
+ })),
107
+ );
108
+ }
109
+ `,
110
+ );
111
+ writeIfMissing(path.join(base, "server", ".gitkeep"), "");
112
+ writeIfMissing(
113
+ path.join(base, "views", "pages", `${name}.jsk`),
114
+ `<section class="wrapper">
115
+ <h1>{{ metadata.title }}</h1>
116
+ </section>
117
+ `,
118
+ );
119
+ writeIfMissing(path.join(base, "client", ".gitkeep"), "");
120
+ log.ready({ label: `Feature ${name}` });
121
+ }
122
+
123
+ /**
124
+ * @param {string} root
125
+ * @param {string} feature
126
+ * @param {string} page
127
+ */
128
+ async function generatePage(root, feature, page) {
129
+ const file = path.join(root, "features", feature, "views", "pages", `${page}.jsk`);
130
+ writeIfMissing(
131
+ file,
132
+ `<section class="wrapper">
133
+ <h1>{{ metadata.title }}</h1>
134
+ </section>
135
+ `,
136
+ );
137
+ log.ready({ label: `Page ${feature}/${page}` });
138
+ }
139
+
140
+ /**
141
+ * @param {string} root
142
+ * @param {string} feature
143
+ * @param {string} island
144
+ */
145
+ async function generateIsland(root, feature, island) {
146
+ const file = path.join(root, "features", feature, "client", `${island}.js`);
147
+ writeIfMissing(
148
+ file,
149
+ `/**
150
+ * @param {HTMLElement} element
151
+ * @param {Record<string, unknown>} props
152
+ * @returns {void | (() => void)}
153
+ */
154
+ export function mount(element, props) {
155
+ element.textContent = String(props.label ?? "${island}");
156
+ }
157
+ `,
158
+ );
159
+ log.line(
160
+ `Register in client/entries/main.js: "${island}": () => import("../../features/${feature}/client/${island}.js")`,
161
+ );
162
+ log.ready({ label: `Island ${feature}/${island}` });
163
+ }
package/src/init.mjs CHANGED
@@ -78,17 +78,17 @@ export default function register(app, { route }) {
78
78
  }
79
79
  `,
80
80
 
81
- "views/pages/home.ejs": `<section class="wrapper">
82
- <h1><%= metadata.title %></h1>
83
- <p><%= message %></p>
81
+ "views/pages/home.jsk": `<section class="wrapper">
82
+ <h1>{{ metadata.title }}</h1>
83
+ <p>{{ message }}</p>
84
84
  <div data-island="counter" data-island-props='{"start":0}'></div>
85
85
  </section>
86
86
  `,
87
87
 
88
- "views/pages/not-found.ejs": `<section class="wrapper">
88
+ "views/pages/not-found.jsk": `<section class="wrapper">
89
89
  <h1>404</h1>
90
90
  <p>The page you are looking for was not found.</p>
91
- <p><a href="/">Back to home</a></p>
91
+ <p><Link href="/" text="Back to home" /></p>
92
92
  </section>
93
93
  `,
94
94
 
@@ -96,7 +96,7 @@ export default function register(app, { route }) {
96
96
 
97
97
  /**
98
98
  * Every named export under \`views/components/**\` is usable directly in
99
- * templates: \`<%- button({ text: "Save" }) %>\`. No import needed.
99
+ * templates: \`<Button text="Save" />\` in \`.jsk\` or \`<%- button({ text }) %>\` in EJS.
100
100
  *
101
101
  * @param {{ text: string, href?: string, class?: string }} props
102
102
  * @returns {string}
@@ -157,6 +157,8 @@ export function mount(element, props) {
157
157
  @source "../views";
158
158
  @source "../client";
159
159
  @source "../routes";
160
+ @source "../features";
161
+ @source "../shared";
160
162
 
161
163
  body {
162
164
  margin: 0;
@@ -14,6 +14,8 @@
14
14
  */
15
15
  import path from "node:path";
16
16
  import process from "node:process";
17
+ import fs from "node:fs";
18
+ import { pathToFileURL } from "node:url";
17
19
  import ejs from "ejs";
18
20
  import { withHtmlCache } from "./html-cache.js";
19
21
  import { getConfig, hook } from "../config/index.js";
@@ -40,6 +42,11 @@ import * as tags from "../views/helpers/tags.js";
40
42
  import { loadComponents } from "../views/components/loader.js";
41
43
  import { renderStatusPage } from "./status-page.js";
42
44
  import { suppressForPrewarm } from "./prewarm.js";
45
+ import {
46
+ ensureTemplatesCompiled,
47
+ getComponentDirs,
48
+ getViewRoots,
49
+ } from "../compile/index.js";
43
50
 
44
51
  const isDev = process.env.NODE_ENV === "development";
45
52
 
@@ -49,7 +56,10 @@ const isDev = process.env.NODE_ENV === "development";
49
56
  * hesaplanamaz, bu yüzden ilk render'da kurulur.
50
57
  *
51
58
  * @type {{ helpers: Record<string, unknown>, options: ejs.Options,
52
- * viewsDir: string, layout: string } | null}
59
+ * viewsDir: string, viewRoots: string[], layout: string,
60
+ * compiled: Map<string, (data: object, helpers: object) => string>,
61
+ * layoutRender: ((data: object, helpers: object) => string) | null
62
+ * } | null}
53
63
  */
54
64
  let engine = null;
55
65
 
@@ -61,21 +71,46 @@ async function getEngine() {
61
71
 
62
72
  const config = getConfig();
63
73
  const viewsDir = config.dirs.views;
74
+ const viewRoots = getViewRoots(config);
75
+
76
+ await ensureTemplatesCompiled(config);
77
+
78
+ /** @type {Record<string, unknown>} */
79
+ const helpers = {
80
+ ...html,
81
+ ...tags,
82
+ ...(await loadComponents(getComponentDirs(config))),
83
+ asset,
84
+ hasAsset,
85
+ // Yerleşik PascalCase etiketler (.jsk bileşen sözdizimi).
86
+ Link: (props) => tags.link(props),
87
+ Image: (props) => tags.image(props),
88
+ Icon: (props) => tags.icon(props),
89
+ CsrfField: () => tags.csrfField(),
90
+ PreloadImage: (props) => tags.preloadImage(props),
91
+ };
92
+
93
+ // camelCase JS bileşenlerini PascalCase etiket adıyla da erişilebilir yap.
94
+ for (const [name, value] of Object.entries(helpers)) {
95
+ if (typeof value !== "function") continue;
96
+ if (/^[A-Z]/.test(name)) continue;
97
+ const pascal = name.charAt(0).toUpperCase() + name.slice(1);
98
+ if (!(pascal in helpers)) helpers[pascal] = value;
99
+ }
100
+
101
+ const { compiled, layoutRender } = await loadCompiledTemplates(config, helpers);
64
102
 
65
103
  engine = {
66
104
  viewsDir,
105
+ viewRoots,
67
106
  layout: config.layout,
68
- helpers: {
69
- ...html,
70
- ...tags,
71
- ...(await loadComponents(path.join(viewsDir, "components"))),
72
- asset,
73
- hasAsset,
74
- },
107
+ helpers,
108
+ compiled,
109
+ layoutRender,
75
110
  options: {
76
111
  // `include('partials/header')` gibi çağrılar views kökünden çözülür.
77
112
  root: viewsDir,
78
- views: [viewsDir],
113
+ views: viewRoots.length ? viewRoots : [viewsDir],
79
114
  cache: !isDev,
80
115
  rmWhitespace: true,
81
116
  async: true,
@@ -85,6 +120,66 @@ async function getEngine() {
85
120
  return engine;
86
121
  }
87
122
 
123
+ /**
124
+ * Derlenmiş `.jsk` modüllerini yükler. İstek anında parse/compile yok.
125
+ *
126
+ * @param {import('../config/index.js').ResolvedConfig} config
127
+ * @param {Record<string, unknown>} helpers
128
+ * @returns {Promise<{ compiled: Map<string, Function>, layoutRender: Function | null }>}
129
+ */
130
+ async function loadCompiledTemplates(config, helpers) {
131
+ /** @type {Map<string, (data: object, helpers: object) => string>} */
132
+ const compiled = new Map();
133
+ const templatesDir = path.join(config.dirs.generated, "templates");
134
+ const manifestPath = path.join(templatesDir, "manifest.json");
135
+
136
+ if (!fs.existsSync(manifestPath)) {
137
+ return { compiled, layoutRender: null };
138
+ }
139
+
140
+ /** @type {Record<string, string>} */
141
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
142
+ const bust = isDev ? `?t=${Date.now()}` : "";
143
+
144
+ for (const [viewId, rel] of Object.entries(manifest)) {
145
+ const file = path.join(templatesDir, rel);
146
+ if (!fs.existsSync(file)) continue;
147
+ const mod = await import(pathToFileURL(file).href + bust);
148
+ if (typeof mod.render !== "function") continue;
149
+ compiled.set(viewId, mod.render);
150
+
151
+ // `components/*.jsk` → helpers.PascalName
152
+ if (viewId.startsWith("components/")) {
153
+ const base = viewId.slice("components/".length).split("/").pop() ?? "";
154
+ const pascal = base
155
+ .split(/[-_/]+/)
156
+ .filter(Boolean)
157
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
158
+ .join("");
159
+ if (pascal) {
160
+ helpers[pascal] = (props) => mod.render(props ?? {}, helpers);
161
+ }
162
+ }
163
+ }
164
+
165
+ const layoutRender = compiled.get("layout") ?? null;
166
+ return { compiled, layoutRender };
167
+ }
168
+
169
+ /**
170
+ * View id için kaynak `.ejs` dosyasını çoklu kökte ara.
171
+ * @param {string[]} viewRoots
172
+ * @param {string} view
173
+ * @returns {string | null}
174
+ */
175
+ function findEjsView(viewRoots, view) {
176
+ for (const root of viewRoots) {
177
+ const file = path.join(root, `${view}.ejs`);
178
+ if (fs.existsSync(file)) return file;
179
+ }
180
+ return null;
181
+ }
182
+
88
183
  /**
89
184
  * Uygulama geliştirirken bileşen dosyaları değişince kayıt yenilenmeli.
90
185
  * Dev sunucusu süreci yeniden başlattığı için normalde gerekmez; gömülü
@@ -100,13 +195,22 @@ export function resetRenderEngine() {
100
195
  * Layout kullanmadan tek bir şablon render eder. Fragment/partial uçları
101
196
  * ve e-posta şablonları bunu kullanır.
102
197
  *
198
+ * Öncelik: derlenmiş `.jsk` → `.ejs`. İstek anında şablon derlenmez.
199
+ *
103
200
  * @param {string} view `views/` altındaki yol, uzantısız (örn. "pages/home")
104
201
  * @param {object} [data]
105
202
  * @returns {Promise<string>}
106
203
  */
107
204
  export async function renderView(view, data = {}) {
108
- const { viewsDir, helpers, options } = await getEngine();
109
- const file = path.join(viewsDir, `${view}.ejs`);
205
+ const { helpers, options, compiled, viewRoots, viewsDir } = await getEngine();
206
+ const renderFn = compiled.get(view);
207
+ if (renderFn) {
208
+ return renderFn({ ...data }, helpers);
209
+ }
210
+
211
+ const file =
212
+ findEjsView(viewRoots.length ? viewRoots : [viewsDir], view) ??
213
+ path.join(viewsDir, `${view}.ejs`);
110
214
  return ejs.renderFile(file, { ...helpers, ...data }, options);
111
215
  }
112
216
 
@@ -118,7 +222,7 @@ export async function renderView(view, data = {}) {
118
222
  * @returns {Promise<string>}
119
223
  */
120
224
  export async function renderPage(page) {
121
- const { helpers, options, layout } = await getEngine();
225
+ const { helpers, options, layout, layoutRender } = await getEngine();
122
226
  const config = getConfig();
123
227
 
124
228
  const metadata = {
@@ -134,34 +238,43 @@ export async function renderPage(page) {
134
238
  hook("layoutContext", {}, { pathname: page.pathname ?? "", metadata }),
135
239
  ]);
136
240
 
137
- return ejs.renderFile(
138
- layout,
139
- {
140
- ...helpers,
141
- ...context,
142
- metadata,
143
- // Boş varsayılan bilinçli: "/" yazmak her sayfayı ana sayfa sanıp
144
- // logoyu <h1> olarak bastıran türde hatalara yol açıyor.
145
- pathname: page.pathname ?? "",
146
- lang: context.lang ?? config.brand.lang ?? "en",
147
- headMeta: renderHeadMeta(metadata),
148
- structuredData: context.structuredData ?? [],
149
- // Preconnect her sayfada aynı; LCP preload'ını sayfa kendisi ekler.
150
- // Gezinme ipuçları preconnect'ten sonra: spekülasyon bir sonraki sayfayı
151
- // ilgilendiriyor, bu sayfanın LCP'sinin önüne geçmemeli.
152
- extraHead:
153
- preconnectHints() +
154
- navigationHints() +
155
- (page.head ?? "") +
156
- (context.extraHead ?? ""),
157
- bodyClass: page.bodyClass ?? context.bodyClass ?? "",
158
- entries: page.entries ?? [],
159
- devtools: isDev,
160
- devBasePath: config.brand.devBasePath,
161
- body,
162
- },
163
- options,
164
- );
241
+ const locals = {
242
+ ...helpers,
243
+ ...context,
244
+ metadata,
245
+ // Boş varsayılan bilinçli: "/" yazmak her sayfayı ana sayfa sanıp
246
+ // logoyu <h1> olarak bastıran türde hatalara yol açıyor.
247
+ pathname: page.pathname ?? "",
248
+ lang: context.lang ?? config.brand.lang ?? "en",
249
+ headMeta: renderHeadMeta(metadata),
250
+ structuredData: context.structuredData ?? [],
251
+ // Preconnect her sayfada aynı; LCP preload'ını sayfa kendisi ekler.
252
+ // Gezinme ipuçları preconnect'ten sonra: spekülasyon bir sonraki sayfayı
253
+ // ilgilendiriyor, bu sayfanın LCP'sinin önüne geçmemeli.
254
+ extraHead:
255
+ preconnectHints() +
256
+ navigationHints() +
257
+ (page.head ?? "") +
258
+ (context.extraHead ?? ""),
259
+ bodyClass: page.bodyClass ?? context.bodyClass ?? "",
260
+ entries: page.entries ?? [],
261
+ devtools: isDev,
262
+ devBasePath: config.brand.devBasePath,
263
+ body,
264
+ };
265
+
266
+ if (layoutRender) {
267
+ return layoutRender(locals, helpers);
268
+ }
269
+
270
+ if (layout.endsWith(".jsk")) {
271
+ // Kaynak var ama derlenmemiş — ensureTemplates sonrası olmamalı.
272
+ throw new Error(
273
+ `Layout ${path.relative(config.root, layout)} is not compiled — run jskelet build`,
274
+ );
275
+ }
276
+
277
+ return ejs.renderFile(layout, locals, options);
165
278
  }
166
279
 
167
280
  /**
@@ -9,8 +9,8 @@
9
9
  *
10
10
  * Sıra iki şekilde belirlenir:
11
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`.
12
+ * 2. Liste yoksa `routes/` alfabetik taranır, ardından her feature'ın
13
+ * `index.js` dosyası alfabetik eklenir. Catch-all'lar için sayısal önek.
14
14
  *
15
15
  * Modül sözleşmesi: default export ya da `register` adlı named export,
16
16
  * `(app, api) => void | Promise<void>` imzasıyla. `api` içinde `route`,
@@ -61,6 +61,29 @@ function discover(dir, out = []) {
61
61
  return out;
62
62
  }
63
63
 
64
+ /**
65
+ * `features/<name>/index.js` (veya `.mjs`) — feature-first kayıt noktası.
66
+ * @param {string} featuresDir
67
+ * @returns {string[]}
68
+ */
69
+ function discoverFeatures(featuresDir) {
70
+ if (!featuresDir || !fs.existsSync(featuresDir)) return [];
71
+
72
+ return fs
73
+ .readdirSync(featuresDir, { withFileTypes: true })
74
+ .filter((e) => e.isDirectory())
75
+ .map((e) => e.name)
76
+ .sort((a, b) => a.localeCompare(b))
77
+ .flatMap((name) => {
78
+ const dir = path.join(featuresDir, name);
79
+ for (const file of ["index.js", "index.mjs"]) {
80
+ const full = path.join(dir, file);
81
+ if (fs.existsSync(full)) return [full];
82
+ }
83
+ return [];
84
+ });
85
+ }
86
+
64
87
  /**
65
88
  * Route dosyalarına geçilen framework yüzeyi.
66
89
  */
@@ -84,7 +107,7 @@ export async function registerRoutes(app) {
84
107
 
85
108
  const files = config.routes
86
109
  ? config.routes.map((entry) => path.resolve(config.root, entry))
87
- : discover(config.dirs.routes);
110
+ : [...discover(config.dirs.routes), ...discoverFeatures(config.dirs.features)];
88
111
 
89
112
  if (!files.length) {
90
113
  console.warn(