jskelet 0.3.4 → 0.4.0

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,332 @@
1
+ /**
2
+ * AST → ESM kaynak metni. `eval` / `new Function` yok; çıktı diskteki
3
+ * `.mjs` dosyasına yazılır ve runtime'da normal `import` ile yüklenir.
4
+ */
5
+ import { codegenExpr } from "./expr.js";
6
+ import { CompileError } from "./errors.js";
7
+
8
+ /**
9
+ * @typedef {import('./parse.js').AstNode} AstNode
10
+ * @typedef {import('./parse.js').AttrNode} AttrNode
11
+ */
12
+
13
+ /**
14
+ * @param {AstNode[]} ast
15
+ * @param {{ viewId: string, file?: string, knownComponents?: Set<string> }} options
16
+ * @returns {{ code: string, includes: string[], components: string[] }}
17
+ */
18
+ export function codegen(ast, options) {
19
+ const ctx = new CodegenContext(options);
20
+ ctx.emitNodes(ast);
21
+ return {
22
+ code: ctx.finish(),
23
+ includes: [...ctx.includes],
24
+ components: [...ctx.components],
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Include yolunu view id'ye çevir (uzantısız, `/` ayırıcılı).
30
+ * @param {string} includePath
31
+ * @returns {string}
32
+ */
33
+ export function normalizeIncludeId(includePath) {
34
+ return includePath.replace(/\\/g, "/").replace(/\.jsk$/i, "").replace(/^\.\//, "");
35
+ }
36
+
37
+ /**
38
+ * `market-hero` / `MarketHero` → `MarketHero`
39
+ * @param {string} name
40
+ * @returns {string}
41
+ */
42
+ export function toPascalCase(name) {
43
+ if (/^[A-Z]/.test(name) && !name.includes("-")) return name;
44
+ return name
45
+ .split(/[-_/]+/)
46
+ .filter(Boolean)
47
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
48
+ .join("");
49
+ }
50
+
51
+ class CodegenContext {
52
+ /**
53
+ * @param {{ viewId: string, file?: string, knownComponents?: Set<string> }} options
54
+ */
55
+ constructor(options) {
56
+ this.viewId = options.viewId;
57
+ this.file = options.file;
58
+ this.knownComponents = options.knownComponents ?? null;
59
+ /** @type {Set<string>} */
60
+ this.includes = new Set();
61
+ /** @type {Set<string>} */
62
+ this.components = new Set();
63
+ /** @type {string[]} */
64
+ this.body = [];
65
+ this.indent = 1;
66
+ this.temp = 0;
67
+ /** @type {Set<string>} */
68
+ this.locals = new Set();
69
+ /** Çocuk gövdesinde `s` yerine kullanılacak değişken. */
70
+ this.outVar = "s";
71
+ }
72
+
73
+ /**
74
+ * @param {string} line
75
+ */
76
+ line(line) {
77
+ this.body.push(`${" ".repeat(this.indent)}${line}`);
78
+ }
79
+
80
+ /**
81
+ * @param {string} exprJs
82
+ */
83
+ append(exprJs) {
84
+ this.line(`${this.outVar} += ${exprJs};`);
85
+ }
86
+
87
+ /** @returns {string} */
88
+ nextTemp() {
89
+ this.temp += 1;
90
+ return `__c${this.temp}`;
91
+ }
92
+
93
+ /**
94
+ * @param {AstNode[]} nodes
95
+ */
96
+ emitNodes(nodes) {
97
+ for (const node of nodes) this.emit(node);
98
+ }
99
+
100
+ /**
101
+ * @param {AstNode} node
102
+ */
103
+ emit(node) {
104
+ switch (node.type) {
105
+ case "Text":
106
+ if (node.value) this.append(JSON.stringify(node.value));
107
+ break;
108
+ case "Interp": {
109
+ const expr = codegenExpr(node.expr, this.locals);
110
+ if (node.raw) this.append(`(${expr} ?? "")`);
111
+ else this.append(`helpers.esc(${expr})`);
112
+ break;
113
+ }
114
+ case "Element":
115
+ this.emitElement(node);
116
+ break;
117
+ case "Component":
118
+ this.emitComponent(node);
119
+ break;
120
+ case "If":
121
+ this.emitIf(node);
122
+ break;
123
+ case "Each":
124
+ this.emitEach(node);
125
+ break;
126
+ case "Include":
127
+ this.emitInclude(node);
128
+ break;
129
+ default:
130
+ break;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * @param {import('./parse.js').ElementNode} node
136
+ */
137
+ emitElement(node) {
138
+ const bound = node.attrs.some((a) => a.bound);
139
+ if (bound) {
140
+ this.append(
141
+ `"<${node.name}" + helpers.attrs({${this.attrsObject(node.attrs)}}) + ">"`,
142
+ );
143
+ } else {
144
+ this.append(JSON.stringify(`<${node.name}${staticAttrString(node.attrs)}>`));
145
+ }
146
+
147
+ if (!node.selfClosing) {
148
+ this.emitNodes(node.children);
149
+ this.append(JSON.stringify(`</${node.name}>`));
150
+ }
151
+ }
152
+
153
+ /**
154
+ * @param {AttrNode[]} attrs
155
+ * @returns {string}
156
+ */
157
+ attrsObject(attrs) {
158
+ return attrs
159
+ .map((attr) => {
160
+ const key = JSON.stringify(attr.name);
161
+ if (attr.boolean && !attr.bound) return `${key}: true`;
162
+ if (attr.bound && attr.expr) {
163
+ return `${key}: ${codegenExpr(attr.expr, this.locals)}`;
164
+ }
165
+ return `${key}: ${JSON.stringify(attr.value ?? true)}`;
166
+ })
167
+ .join(", ");
168
+ }
169
+
170
+ /**
171
+ * @param {import('./parse.js').ComponentNode} node
172
+ */
173
+ emitComponent(node) {
174
+ if (this.knownComponents && !this.knownComponents.has(node.name)) {
175
+ throw new CompileError(`Unknown component "${node.name}"`, {
176
+ file: this.file,
177
+ index: node.index,
178
+ });
179
+ }
180
+ this.components.add(node.name);
181
+
182
+ const props = this.attrsObject(node.attrs);
183
+
184
+ if (node.children.length) {
185
+ const tmp = this.nextTemp();
186
+ this.line(`{`);
187
+ this.indent += 1;
188
+ this.line(`let ${tmp} = "";`);
189
+ const prevOut = this.outVar;
190
+ this.outVar = tmp;
191
+ this.emitNodes(node.children);
192
+ this.outVar = prevOut;
193
+ const propsWithChildren = props
194
+ ? `${props}, children: ${tmp}`
195
+ : `children: ${tmp}`;
196
+ this.append(`(helpers.${node.name}({ ${propsWithChildren} }) ?? "")`);
197
+ this.indent -= 1;
198
+ this.line(`}`);
199
+ } else {
200
+ this.append(`(helpers.${node.name}({ ${props} }) ?? "")`);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * @param {import('./parse.js').IfNode} node
206
+ */
207
+ emitIf(node) {
208
+ const test = codegenExpr(node.test, this.locals);
209
+ this.line(`if (${test}) {`);
210
+ this.indent += 1;
211
+ this.emitNodes(node.consequent);
212
+ this.indent -= 1;
213
+ if (node.alternate.length) {
214
+ this.line(`} else {`);
215
+ this.indent += 1;
216
+ this.emitNodes(node.alternate);
217
+ this.indent -= 1;
218
+ }
219
+ this.line(`}`);
220
+ }
221
+
222
+ /**
223
+ * @param {import('./parse.js').EachNode} node
224
+ */
225
+ emitEach(node) {
226
+ const list = codegenExpr(node.list, this.locals);
227
+ const idx = node.indexName ?? `__i${++this.temp}`;
228
+ this.line(`{`);
229
+ this.indent += 1;
230
+ this.line(`const __list = ${list} ?? [];`);
231
+ this.line(`for (let ${idx} = 0; ${idx} < __list.length; ${idx}++) {`);
232
+ this.indent += 1;
233
+ this.line(`const ${node.item} = __list[${idx}];`);
234
+ const prev = new Set(this.locals);
235
+ this.locals.add(node.item);
236
+ this.locals.add(idx);
237
+ if (node.indexName) this.locals.add(node.indexName);
238
+ this.emitNodes(node.children);
239
+ this.locals = prev;
240
+ this.indent -= 1;
241
+ this.line(`}`);
242
+ this.indent -= 1;
243
+ this.line(`}`);
244
+ }
245
+
246
+ /**
247
+ * @param {import('./parse.js').IncludeNode} node
248
+ */
249
+ emitInclude(node) {
250
+ const id = normalizeIncludeId(node.path);
251
+ this.includes.add(id);
252
+ const alias = includeAlias(id);
253
+ this.append(`(${alias}(data, helpers) ?? "")`);
254
+ }
255
+
256
+ /** @returns {string} */
257
+ finish() {
258
+ const imports = [...this.includes]
259
+ .map((id) => {
260
+ const alias = includeAlias(id);
261
+ const rel = relativeImport(this.viewId, id);
262
+ return `import { render as ${alias} } from ${JSON.stringify(rel)};`;
263
+ })
264
+ .join("\n");
265
+
266
+ const parts = [
267
+ "/** @generated by jskelet — do not edit */",
268
+ imports,
269
+ "/**",
270
+ " * @param {Record<string, unknown>} data",
271
+ " * @param {Record<string, any>} helpers",
272
+ " * @returns {string}",
273
+ " */",
274
+ "export function render(data, helpers) {",
275
+ ' let s = "";',
276
+ ...this.body,
277
+ " return s;",
278
+ "}",
279
+ "",
280
+ ];
281
+
282
+ return parts.filter((l, i) => !(l === "" && i === 1 && !imports)).join("\n");
283
+ }
284
+ }
285
+
286
+ /**
287
+ * @param {AttrNode[]} attrs
288
+ * @returns {string}
289
+ */
290
+ function staticAttrString(attrs) {
291
+ let out = "";
292
+ for (const attr of attrs) {
293
+ if (attr.boolean) {
294
+ out += ` ${attr.name}`;
295
+ continue;
296
+ }
297
+ out += ` ${attr.name}="${escapeAttr(attr.value ?? "")}"`;
298
+ }
299
+ return out;
300
+ }
301
+
302
+ /**
303
+ * @param {string} value
304
+ * @returns {string}
305
+ */
306
+ function escapeAttr(value) {
307
+ return String(value)
308
+ .replace(/&/g, "&amp;")
309
+ .replace(/"/g, "&quot;")
310
+ .replace(/</g, "&lt;");
311
+ }
312
+
313
+ /**
314
+ * @param {string} id
315
+ * @returns {string}
316
+ */
317
+ function includeAlias(id) {
318
+ return `__inc_${id.replace(/[^A-Za-z0-9]/g, "_")}`;
319
+ }
320
+
321
+ /**
322
+ * @param {string} fromViewId
323
+ * @param {string} toViewId
324
+ * @returns {string}
325
+ */
326
+ function relativeImport(fromViewId, toViewId) {
327
+ const fromParts = fromViewId.split("/");
328
+ fromParts.pop();
329
+ const depth = fromParts.length;
330
+ const prefix = depth === 0 ? "./" : "../".repeat(depth);
331
+ return `${prefix}${toViewId}.mjs`;
332
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Tüm `.jsk` dosyalarını derleyip `.jskelet/templates/` altına yazar.
3
+ */
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { parseTemplate } from "./parse.js";
7
+ import { codegen, normalizeIncludeId } from "./codegen.js";
8
+ import { CompileError } from "./errors.js";
9
+ import {
10
+ collectKnownComponents,
11
+ componentNameFromViewId,
12
+ discoverJskFiles,
13
+ getComponentDirs,
14
+ getViewRoots,
15
+ } from "./resolve.js";
16
+
17
+ /**
18
+ * @param {{ root: string, dirs: Record<string, string> }} config
19
+ * @param {{ quiet?: boolean }} [options]
20
+ * @returns {Promise<{ count: number, manifest: Record<string, string>, outDir: string }>}
21
+ */
22
+ export async function compileAll(config, options = {}) {
23
+ const outDir = path.join(config.dirs.generated, "templates");
24
+ const viewRoots = getViewRoots(config);
25
+ const jskFiles = discoverJskFiles(viewRoots);
26
+ const componentDirs = getComponentDirs(config);
27
+ const knownComponents = collectKnownComponents(componentDirs, jskFiles);
28
+
29
+ // Önceki çıktıyı temizle — silinmiş şablonlar kalmasın.
30
+ fs.rmSync(outDir, { recursive: true, force: true });
31
+ fs.mkdirSync(outDir, { recursive: true });
32
+
33
+ /** @type {Record<string, string>} */
34
+ const manifest = {};
35
+ /** @type {Map<string, { code: string, includes: string[] }>} */
36
+ const compiled = new Map();
37
+
38
+ for (const [viewId, file] of jskFiles) {
39
+ const source = fs.readFileSync(file, "utf8");
40
+ const relative = path.relative(config.root, file).split(path.sep).join("/");
41
+
42
+ let ast;
43
+ try {
44
+ ast = parseTemplate(source, { file: relative });
45
+ } catch (error) {
46
+ if (error instanceof CompileError) throw error;
47
+ throw error;
48
+ }
49
+
50
+ const { code, includes, components: usedComponents } = codegen(ast, {
51
+ viewId,
52
+ file: relative,
53
+ knownComponents: null,
54
+ });
55
+
56
+ for (const name of usedComponents) {
57
+ if (knownComponents.has(name)) continue;
58
+ const camel = name.charAt(0).toLowerCase() + name.slice(1);
59
+ if (knownComponents.has(camel)) continue;
60
+ if (!options.quiet) {
61
+ console.warn(`[jsk] ${relative}: component "${name}" was not found at compile time`);
62
+ }
63
+ }
64
+
65
+ compiled.set(viewId, { code, includes });
66
+ }
67
+
68
+ // Include hedefleri mevcut mu?
69
+ for (const [viewId, { includes }] of compiled) {
70
+ for (const id of includes) {
71
+ const normalized = normalizeIncludeId(id);
72
+ if (!compiled.has(normalized) && !jskFiles.has(normalized)) {
73
+ const file = jskFiles.get(viewId);
74
+ const relative = file
75
+ ? path.relative(config.root, file).split(path.sep).join("/")
76
+ : viewId;
77
+ throw new CompileError(`Unknown include "${id}"`, { file: relative });
78
+ }
79
+ }
80
+ }
81
+
82
+ for (const [viewId, { code }] of compiled) {
83
+ const outFile = path.join(outDir, `${viewId}.mjs`);
84
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
85
+ fs.writeFileSync(outFile, code, "utf8");
86
+ manifest[viewId] = `${viewId}.mjs`.split(path.sep).join("/");
87
+ }
88
+
89
+ const manifestPath = path.join(outDir, "manifest.json");
90
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
91
+
92
+ // Bileşen adı → view id haritası (runtime kayıt için)
93
+ /** @type {Record<string, string>} */
94
+ const components = {};
95
+ for (const viewId of Object.keys(manifest)) {
96
+ const name = componentNameFromViewId(viewId);
97
+ if (name) components[name] = viewId;
98
+ }
99
+ fs.writeFileSync(
100
+ path.join(outDir, "components.json"),
101
+ `${JSON.stringify(components, null, 2)}\n`,
102
+ "utf8",
103
+ );
104
+
105
+ return { count: compiled.size, manifest, outDir };
106
+ }
107
+
108
+ /**
109
+ * Manifest yoksa veya herhangi bir `.jsk` daha yeniyse yeniden derler.
110
+ * Dev sunucu restart'ında build watch kaçırmış olsa bile şablonlar güncel kalır.
111
+ *
112
+ * @param {{ root: string, dirs: Record<string, string> }} config
113
+ * @returns {Promise<{ count: number, rebuilt: boolean }>}
114
+ */
115
+ export async function ensureTemplatesCompiled(config) {
116
+ const viewRoots = getViewRoots(config);
117
+ const jskFiles = discoverJskFiles(viewRoots);
118
+ if (jskFiles.size === 0) {
119
+ return { count: 0, rebuilt: false };
120
+ }
121
+
122
+ const outDir = path.join(config.dirs.generated, "templates");
123
+ const manifestPath = path.join(outDir, "manifest.json");
124
+ let needs = !fs.existsSync(manifestPath);
125
+
126
+ if (!needs) {
127
+ const manifestMtime = fs.statSync(manifestPath).mtimeMs;
128
+ for (const file of jskFiles.values()) {
129
+ if (fs.statSync(file).mtimeMs > manifestMtime) {
130
+ needs = true;
131
+ break;
132
+ }
133
+ }
134
+ }
135
+
136
+ if (!needs) {
137
+ return { count: jskFiles.size, rebuilt: false };
138
+ }
139
+
140
+ const result = await compileAll(config, { quiet: true });
141
+ return { count: result.count, rebuilt: true };
142
+ }
143
+
144
+ /**
145
+ * Tek kaynak dizgisini derler (birim testleri).
146
+ * @param {string} source
147
+ * @param {{ viewId?: string, file?: string, knownComponents?: Set<string> }} [options]
148
+ * @returns {{ code: string, includes: string[], components: string[] }}
149
+ */
150
+ export function compileSource(source, options = {}) {
151
+ const viewId = options.viewId ?? "test";
152
+ const ast = parseTemplate(source, { file: options.file ?? "test.jsk" });
153
+ return codegen(ast, {
154
+ viewId,
155
+ file: options.file ?? "test.jsk",
156
+ knownComponents: options.knownComponents ?? null,
157
+ });
158
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Şablon derleme hataları. Satır/sütun korunur ki DX overlay ve CLI
3
+ * çıktısı kaynağa işaret edebilsin.
4
+ */
5
+
6
+ /**
7
+ * @param {string} source
8
+ * @param {number} index
9
+ * @returns {{ line: number, column: number }}
10
+ */
11
+ export function indexToLocation(source, index) {
12
+ let line = 1;
13
+ let column = 1;
14
+ const end = Math.min(index, source.length);
15
+ for (let i = 0; i < end; i++) {
16
+ if (source[i] === "\n") {
17
+ line += 1;
18
+ column = 1;
19
+ } else {
20
+ column += 1;
21
+ }
22
+ }
23
+ return { line, column };
24
+ }
25
+
26
+ /**
27
+ * @param {string} source
28
+ * @param {number} line
29
+ * @returns {string}
30
+ */
31
+ function lineSnippet(source, line) {
32
+ const lines = source.split(/\r?\n/);
33
+ return lines[line - 1] ?? "";
34
+ }
35
+
36
+ export class CompileError extends Error {
37
+ /**
38
+ * @param {string} message
39
+ * @param {{ file?: string, source?: string, index?: number,
40
+ * line?: number, column?: number }} [opts]
41
+ */
42
+ constructor(message, opts = {}) {
43
+ let line = opts.line;
44
+ let column = opts.column;
45
+ if ((line == null || column == null) && opts.source != null && opts.index != null) {
46
+ const loc = indexToLocation(opts.source, opts.index);
47
+ line = loc.line;
48
+ column = loc.column;
49
+ }
50
+
51
+ const where =
52
+ opts.file && line != null
53
+ ? `${opts.file}:${line}:${column ?? 1}`
54
+ : opts.file ?? "";
55
+ const snippet =
56
+ opts.source && line != null ? lineSnippet(opts.source, line).trimEnd() : "";
57
+ const head = where ? `${where}\n` : "";
58
+ const body = snippet ? `${message}\n\n ${snippet}\n ${" ".repeat((column ?? 1) - 1)}^` : message;
59
+
60
+ super(`${head}${body}`);
61
+ this.name = "CompileError";
62
+ this.file = opts.file;
63
+ this.line = line;
64
+ this.column = column;
65
+ }
66
+ }