jskelet 0.4.0 → 0.4.2

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.
@@ -7,6 +7,17 @@
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { toPascalCase } from "./codegen.js";
10
+ import { CompileError } from "./errors.js";
11
+ import { scanNamedExports } from "./scan-exports.js";
12
+
13
+ /**
14
+ * Runtime'ın camelCase → PascalCase alias'ı ile aynı kural.
15
+ * @param {string} name
16
+ * @returns {string}
17
+ */
18
+ export function toComponentTag(name) {
19
+ return name.charAt(0).toUpperCase() + name.slice(1);
20
+ }
10
21
 
11
22
  /**
12
23
  * @param {{ root: string, dirs: Record<string, string> }} config
@@ -106,6 +117,9 @@ export function componentNameFromViewId(viewId) {
106
117
  * Bilinen bileşen adları: JS named export'lar + derlenecek `.jsk` bileşenleri
107
118
  * + yerleşik etiketler.
108
119
  *
120
+ * JS tarafında dosya adı varsayılmaz; kaynak metinden `export` adları okunur.
121
+ * Aynı export (veya aynı PascalCase etiket) iki dosyada varsa derleme hatası.
122
+ *
109
123
  * @param {string[]} componentDirs
110
124
  * @param {Map<string, string>} jskFiles
111
125
  * @returns {Set<string>}
@@ -119,41 +133,76 @@ export function collectKnownComponents(componentDirs, jskFiles) {
119
133
  "PreloadImage",
120
134
  ]);
121
135
 
136
+ /** @type {Map<string, string>} export adı → göreli yol */
137
+ const byName = new Map();
138
+ /** @type {Map<string, string>} PascalCase etiket → göreli yol */
139
+ const byTag = new Map();
140
+
122
141
  for (const [viewId] of jskFiles) {
123
142
  const name = componentNameFromViewId(viewId);
124
- if (name) known.add(name);
143
+ if (!name) continue;
144
+ known.add(name);
145
+ byTag.set(name, `${viewId}.jsk`);
125
146
  }
126
147
 
127
148
  for (const dir of componentDirs) {
128
- collectJsComponentNames(dir, dir, known);
149
+ collectJsComponentNames(dir, known, byName, byTag);
129
150
  }
130
151
 
131
152
  return known;
132
153
  }
133
154
 
155
+ /**
156
+ * @param {string} name
157
+ * @param {string} origin
158
+ * @param {Map<string, string>} byName
159
+ * @param {Map<string, string>} byTag
160
+ */
161
+ function registerExportOrigin(name, origin, byName, byTag) {
162
+ const previousName = byName.get(name);
163
+ if (previousName && previousName !== origin) {
164
+ throw new CompileError(
165
+ `Component '${name}' is defined twice: ${previousName} and ${origin}`,
166
+ );
167
+ }
168
+ byName.set(name, origin);
169
+
170
+ const tag = toComponentTag(name);
171
+ const previousTag = byTag.get(tag);
172
+ if (previousTag && previousTag !== origin) {
173
+ throw new CompileError(
174
+ `Component '${tag}' is defined twice: ${previousTag} and ${origin}`,
175
+ );
176
+ }
177
+ byTag.set(tag, origin);
178
+ }
179
+
134
180
  /**
135
181
  * @param {string} dir
136
- * @param {string} root
137
182
  * @param {Set<string>} out
183
+ * @param {Map<string, string>} byName
184
+ * @param {Map<string, string>} byTag
138
185
  */
139
- function collectJsComponentNames(dir, root, out) {
186
+ function collectJsComponentNames(dir, out, byName, byTag) {
140
187
  if (!fs.existsSync(dir)) return;
141
188
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
142
189
  const full = path.join(dir, entry.name);
143
190
  if (entry.isDirectory()) {
144
- collectJsComponentNames(full, root, out);
191
+ collectJsComponentNames(full, out, byName, byTag);
145
192
  continue;
146
193
  }
147
194
  if (!entry.name.endsWith(".js")) continue;
148
- if (entry.name === "loader.js") continue;
149
- // Dosya adından PascalCase tahmin — export adı dosya içinde olabilir;
150
- // bilinmeyen bileşen uyarısını azaltmak için her iki biçimi ekle.
151
- const base = entry.name.replace(/\.js$/, "");
152
- if (base === "index") continue;
153
- out.add(toPascalCase(base));
154
- // camelCase export'lar da yaygın: `list` → List ve list
155
- out.add(base);
156
- const pascal = toPascalCase(base);
157
- out.add(pascal.charAt(0).toLowerCase() + pascal.slice(1));
195
+ if (entry.name === "loader.js" || entry.name === "index.js") continue;
196
+
197
+ // Kimlik mutlak yol: çoklu kökte iki `list.js` aynı göreli ada sahip olabilir.
198
+ const origin = full.split(path.sep).join("/");
199
+ const source = fs.readFileSync(full, "utf8");
200
+ const exports = scanNamedExports(source);
201
+
202
+ for (const name of exports) {
203
+ registerExportOrigin(name, origin, byName, byTag);
204
+ out.add(name);
205
+ out.add(toComponentTag(name));
206
+ }
158
207
  }
159
208
  }
@@ -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
+ }
package/src/init.mjs CHANGED
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * `jskelet init` — bulunduğun dizine çalışan bir minimum iskelet kurar.
3
3
  *
4
- * Var olan dosyaların üzerine yazmaz: komutu ikinci kez çalıştırmak yalnızca
5
- * eksikleri tamamlar. Amaç, "kurulum yaptım ama hiçbir şey çalışmıyor"
6
- * aşamasını tamamen atlamak — `jskelet dev` hemen ardından çalışır.
4
+ * Varsayılan düzen feature-first'tir: sayfa, bileşen ve island
5
+ * `features/<name>/` altında toplanır; URL kaydı yine açıkça yazılır.
6
+ * Şablonlar `.jsk`. Var olan dosyaların üzerine yazmaz: komutu ikinci kez
7
+ * çalıştırmak yalnızca eksikleri tamamlar. Amaç, "kurulum yaptım ama hiçbir
8
+ * şey çalışmıyor" aşamasını tamamen atlamak — `jskelet dev` hemen ardından
9
+ * çalışır.
7
10
  */
8
11
  import fs from "node:fs";
9
12
  import path from "node:path";
@@ -55,13 +58,12 @@ export default {
55
58
  };
56
59
  `,
57
60
 
58
- "routes/10-pages.mjs": `/**
59
- * Route module. The default export receives \`(app, api)\`; \`api.route()\` wraps
60
- * the controller with the HTML cache, the notFound/redirect flow and
61
- * compression.
61
+ "features/home/index.js": `/**
62
+ * Feature route registration. Explicit paths only — no filesystem URL routing.
63
+ * Loaded after \`routes/\` (alphabetically among features).
62
64
  *
63
- * The numeric prefix in the file name sets load order: catch-all routes
64
- * (like "/:slug") belong to a higher number.
65
+ * @param {import('express').Express} app
66
+ * @param {{ route: Function }} api
65
67
  */
66
68
  export default function register(app, { route }) {
67
69
  app.get(
@@ -78,25 +80,19 @@ export default function register(app, { route }) {
78
80
  }
79
81
  `,
80
82
 
81
- "views/pages/home.jsk": `<section class="wrapper">
83
+ "features/home/views/pages/home.jsk": `<section class="wrapper">
82
84
  <h1>{{ metadata.title }}</h1>
83
85
  <p>{{ message }}</p>
86
+ <Button text="Example component" />
84
87
  <div data-island="counter" data-island-props='{"start":0}'></div>
85
88
  </section>
86
89
  `,
87
90
 
88
- "views/pages/not-found.jsk": `<section class="wrapper">
89
- <h1>404</h1>
90
- <p>The page you are looking for was not found.</p>
91
- <p><Link href="/" text="Back to home" /></p>
92
- </section>
93
- `,
94
-
95
- "views/components/button.js": `import { attrs, esc } from "jskelet/html";
91
+ "features/home/views/components/button.js": `import { attrs, esc } from "jskelet/html";
96
92
 
97
93
  /**
98
- * Every named export under \`views/components/**\` is usable directly in
99
- * templates: \`<Button text="Save" />\` in \`.jsk\` or \`<%- button({ text }) %>\` in EJS.
94
+ * Named exports under \`views/components/**\` (including feature views) become
95
+ * PascalCase tags in \`.jsk\`: \`<Button text="Save" />\`.
100
96
  *
101
97
  * @param {{ text: string, href?: string, class?: string }} props
102
98
  * @returns {string}
@@ -107,22 +103,10 @@ export function button({ text, href, class: className }) {
107
103
  }
108
104
  `,
109
105
 
110
- "client/entries/main.js": `import { registerAll, start } from "jskelet/client";
111
-
112
- /**
113
- * Island registry. Values are dynamic imports: a module is downloaded only if
114
- * that island is actually on the page and becomes visible.
115
- */
116
- registerAll({
117
- counter: () => import("../islands/counter.js"),
118
- });
119
-
120
- start();
121
- `,
122
-
123
- "client/islands/counter.js": `/**
106
+ "features/home/client/counter.js": `/**
124
107
  * Island contract: a named export called \`mount(element, props)\`.
125
- * The returned function, if any, is reserved for cleanup.
108
+ * Register it from \`client/entries/main.js\`. The returned function, if any,
109
+ * is reserved for cleanup.
126
110
  *
127
111
  * @param {HTMLElement} element
128
112
  * @param {{ start?: number }} props
@@ -145,6 +129,28 @@ export function mount(element, props) {
145
129
  paint();
146
130
  element.append(button);
147
131
  }
132
+ `,
133
+
134
+ "features/home/server/.gitkeep": "",
135
+
136
+ "views/pages/not-found.jsk": `<section class="wrapper">
137
+ <h1>404</h1>
138
+ <p>The page you are looking for was not found.</p>
139
+ <p><Link href="/" text="Back to home" /></p>
140
+ </section>
141
+ `,
142
+
143
+ "client/entries/main.js": `import { registerAll, start } from "jskelet/client";
144
+
145
+ /**
146
+ * Island registry. Values are dynamic imports: a module is downloaded only if
147
+ * that island is actually on the page and becomes visible.
148
+ */
149
+ registerAll({
150
+ counter: () => import("../../features/home/client/counter.js"),
151
+ });
152
+
153
+ start();
148
154
  `,
149
155
 
150
156
  "styles/globals.css": `@import "tailwindcss" source(none);
@@ -220,4 +226,5 @@ export async function init(root) {
220
226
 
221
227
  log.line("");
222
228
  log.line("next step: npx jskelet dev");
229
+ log.line("grow with: npx jskelet generate feature <name>");
223
230
  }
@@ -60,21 +60,24 @@ async function loadDir(dir, components, origin) {
60
60
  ];
61
61
 
62
62
  for (const file of files) {
63
- const relative = path.relative(dir, file).split(path.sep).join("/");
63
+ const isBarrel = path.basename(file) === BARREL;
64
+ // Kimlik mutlak yol — çoklu components kökünde göreli ad çakışmasın.
65
+ const fileId = isBarrel ? BARREL : file.split(path.sep).join("/");
64
66
  const module = await import(pathToFileURL(file).href);
65
67
 
66
68
  for (const [name, value] of Object.entries(module)) {
67
69
  if (name === "default") continue;
68
70
 
69
71
  const previous = origin.get(name);
70
- if (previous && previous !== BARREL && previous !== relative) {
71
- console.warn(
72
- `[components] '${name}' is defined twice: ${previous} and ${relative} — the second one wins.`,
72
+ // Barrel üzerine yazmak bilinçli; iki gerçek bileşen dosyası çakışması hata.
73
+ if (previous && previous !== BARREL && previous !== fileId) {
74
+ throw new Error(
75
+ `[components] '${name}' is defined twice: ${previous} and ${fileId}`,
73
76
  );
74
77
  }
75
78
 
76
79
  components[name] = value;
77
- origin.set(name, relative);
80
+ origin.set(name, fileId);
78
81
  }
79
82
  }
80
83
  }