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.
- package/CHANGELOG.md +24 -1
- package/README.md +114 -85
- package/bin/jskelet.mjs +1 -1
- package/docs/01-baslangic.md +54 -49
- package/docs/04-render-ve-sablonlar.md +36 -5
- package/docs/06-cache.md +62 -0
- package/docs/en/01-getting-started.md +55 -50
- package/docs/en/04-rendering.md +37 -5
- package/docs/en/06-caching.md +133 -71
- package/package.json +1 -1
- package/src/compile/index.js +2 -0
- package/src/compile/resolve.js +64 -15
- package/src/compile/scan-exports.js +51 -0
- package/src/init.mjs +42 -35
- package/src/views/components/loader.js +8 -5
package/src/compile/resolve.js
CHANGED
|
@@ -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)
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
150
|
-
//
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
"
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
-
*
|
|
64
|
-
*
|
|
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/
|
|
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
|
-
*
|
|
99
|
-
*
|
|
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/
|
|
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,
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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,
|
|
80
|
+
origin.set(name, fileId);
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
}
|