jskelet 0.7.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,25 @@ one is listed under a **Breaking** heading.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Changed
12
+
13
+ - `.jsk` codegen merges adjacent static HTML into fewer `+=` ops and drops
14
+ template indentation (newline-only whitespace). A lone space between
15
+ inline siblings is kept; `pre` / `textarea` / `script` / `style` / `code`
16
+ preserve whitespace. Framework default `layout.render.js` regenerated.
17
+ - `.jsk` page layout no longer copies the full helpers object into `data`
18
+ each render (EJS layouts still receive helpers as locals).
19
+ - `esc()` skips the regex replace when the string has no special characters;
20
+ `attrs()` builds the attribute string without an intermediate array.
21
+ - Compiled templates and JS components load with parallel `import` on first
22
+ render; production skips the `.jsk` mtime scan when a template manifest
23
+ already exists.
24
+ - `icon()` caches the sprite URL in production; `Image` `fill` without an
25
+ extra `class` skips `twMerge`; `optimizedImage()` materializes CDN-prefixed
26
+ variant URLs once at load time.
27
+ - `scripts/measure-ssr.mjs` microbench for fixture `renderView` /
28
+ `renderPage` and live cache MISS/HIT timings against a running app.
29
+
11
30
  ### Fixed
12
31
 
13
32
  - S3 PutObject uses path-style URLs for bucket names that contain dots
@@ -73,6 +73,12 @@ controller data → import edilmiş render(data, helpers) → HTML
73
73
  Atama, object literal ve rastgele fonksiyon çağrısı yok — mantık controller
74
74
  veya JS bileşende kalır.
75
75
 
76
+ Derleyici bitişik statik HTML’i tek literal’de birleştirir ve **şablon
77
+ girintisini** düşürür: satır sonu içeren salt-whitespace metin düğümleri
78
+ çıktıya girmez; etiketler arasında yalnızca boşluk/tab varsa tek boşluk
79
+ kalır (inline aralık). `pre`, `textarea`, `script`, `style` ve `code`
80
+ içinde whitespace olduğu gibi korunur.
81
+
76
82
  #### Şablon mu, bileşen mi?
77
83
 
78
84
  EJS’den geçerken sınırı erken çizmek işe yarar:
@@ -199,11 +205,15 @@ Dikkat edilecek noktalar:
199
205
  | `lang` | `string` | `context.lang` → `brand.lang` → `"en"` |
200
206
  | `devtools` | `boolean` | `NODE_ENV === "development"` |
201
207
  | `devBasePath` | `string` | `brand.devBasePath`, varsayılan `/__jskelet/dev` |
202
- | `asset`, `hasAsset` | fonksiyon | Manifest erişimi |
203
- | html/tags yardımcıları | fonksiyon | `esc`, `attrs`, `cx`, `cn`, `jsonScript`, `link`, `image`, `icon`, `preloadImage`, `toKebab` |
204
- | `views/components/**` export'ları | fonksiyon | Otomatik kayıt |
208
+ | `asset`, `hasAsset` | fonksiyon | Manifest erişimi (EJS local; `.jsk`’de ikinci `helpers` argümanı / yerleşik etiketler) |
209
+ | html/tags yardımcıları | fonksiyon | `esc`, `attrs`, `cx`, `cn`, `jsonScript`, `link`, `image`, `icon`, `preloadImage`, `toKebab` — aynı ayrım |
210
+ | `views/components/**` export'ları | fonksiyon | Otomatik kayıt (PascalCase etiket veya EJS local) |
205
211
  | `hooks.layoutContext()` çıktısındaki her alan | — | Doğrudan local olur |
206
212
 
213
+ `.jsk` layout `(data, helpers)` aldığı için framework helpers nesnesini her
214
+ render’da `data`’ya kopyalamaz; EJS layout hâlâ bare helper adlarını local
215
+ olarak alır.
216
+
207
217
  `pathname`'in boş varsayılanı bilinçli: `"/"` yazmak her sayfayı ana sayfa
208
218
  sanıp logoyu `<h1>` olarak bastıran türde hatalara yol açıyor.
209
219
 
@@ -74,6 +74,12 @@ The expression language is intentionally small (access, compare, ternary,
74
74
  `.length`). No assignments, object literals, or arbitrary calls — keep logic in
75
75
  controllers or JS components.
76
76
 
77
+ The compiler merges adjacent static HTML into fewer `+=` ops and strips
78
+ **template indentation**: whitespace-only text nodes that contain a newline are
79
+ dropped; a space/tab-only gap between siblings stays as a single space (inline
80
+ separation). Inside `pre`, `textarea`, `script`, `style`, and `code`, whitespace
81
+ is left unchanged.
82
+
77
83
  #### Template or component?
78
84
 
79
85
  When moving off EJS, draw the line early:
@@ -198,11 +204,15 @@ Points to watch:
198
204
  | `lang` | `string` | `context.lang` → `brand.lang` → `"en"` |
199
205
  | `devtools` | `boolean` | `NODE_ENV === "development"` |
200
206
  | `devBasePath` | `string` | `brand.devBasePath`, defaults to `/__jskelet/dev` |
201
- | `asset`, `hasAsset` | function | Manifest access |
202
- | html/tags helpers | function | `esc`, `attrs`, `cx`, `cn`, `jsonScript`, `link`, `image`, `icon`, `preloadImage`, `toKebab` |
203
- | exports of `views/components/**` | function | Automatic registration |
207
+ | `asset`, `hasAsset` | function | Manifest access (EJS local; in `.jsk` the second `helpers` argument / built-in tags) |
208
+ | html/tags helpers | function | `esc`, `attrs`, `cx`, `cn`, `jsonScript`, `link`, `image`, `icon`, `preloadImage`, `toKebab` — same split |
209
+ | exports of `views/components/**` | function | Automatic registration (PascalCase tags or EJS locals) |
204
210
  | every field returned by `hooks.layoutContext()` | — | Becomes a local directly |
205
211
 
212
+ Because a `.jsk` layout receives `(data, helpers)`, the framework does not copy
213
+ the helpers object into `data` on every render; EJS layouts still get bare
214
+ helper names as locals.
215
+
206
216
  The empty default for `pathname` is deliberate: writing `"/"` leads to the kind
207
217
  of bug where every page thinks it is the home page and renders the logo as an
208
218
  `<h1>`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "A framework that feels like no framework: Express 5 + build-time .jsk SSR (optional EJS peer), vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * AST → ESM kaynak metni. `eval` / `new Function` yok; çıktı diskteki
3
3
  * `.mjs` dosyasına yazılır ve runtime'da normal `import` ile yüklenir.
4
+ *
5
+ * Statik HTML parçaları bitişik `s +=` yerine tek literal'de birleşir;
6
+ * şablon girintisi (satır sonu içeren salt-whitespace) derlemede düşer.
7
+ * `pre` / `textarea` / `script` / `style` / `code` içinde whitespace korunur.
4
8
  */
5
9
  import { codegenExpr } from "./expr.js";
6
10
  import { CompileError } from "./errors.js";
@@ -10,6 +14,9 @@ import { CompileError } from "./errors.js";
10
14
  * @typedef {import('./parse.js').AttrNode} AttrNode
11
15
  */
12
16
 
17
+ /** İçinde girinti sıkıştırması yapılmayan etiketler. */
18
+ const PRESERVE_WS = new Set(["pre", "textarea", "script", "style", "code"]);
19
+
13
20
  /**
14
21
  * @param {AstNode[]} ast
15
22
  * @param {{ viewId: string, file?: string, knownComponents?: Set<string> }} options
@@ -49,6 +56,21 @@ export function toPascalCase(name) {
49
56
  .join("");
50
57
  }
51
58
 
59
+ /**
60
+ * Şablon girintisini düşür: satır sonu içeren salt-whitespace → boş;
61
+ * yalnızca boşluk/tab → tek boşluk (inline aralık). Koruma alanı dışında.
62
+ *
63
+ * @param {string} value
64
+ * @param {boolean} preserve
65
+ * @returns {string}
66
+ */
67
+ export function collapseTemplateWhitespace(value, preserve) {
68
+ if (preserve || !value) return value;
69
+ if (!/^[\t\n\r\f\v ]+$/.test(value)) return value;
70
+ if (/[\n\r]/.test(value)) return "";
71
+ return " ";
72
+ }
73
+
52
74
  class CodegenContext {
53
75
  /**
54
76
  * @param {{ viewId: string, file?: string, knownComponents?: Set<string> }} options
@@ -71,6 +93,10 @@ class CodegenContext {
71
93
  this.locals = new Set();
72
94
  /** Çocuk gövdesinde `s` yerine kullanılacak değişken. */
73
95
  this.outVar = "s";
96
+ /** Bitişik statik parçalar — flush edilene kadar birikir. */
97
+ this.staticBuf = "";
98
+ /** `pre` vb. iç içe derinliği. */
99
+ this.preserveWs = 0;
74
100
  }
75
101
 
76
102
  /**
@@ -81,9 +107,28 @@ class CodegenContext {
81
107
  }
82
108
 
83
109
  /**
110
+ * Birikmiş statik HTML'i tek `+=` olarak yazar.
111
+ * @returns {void}
112
+ */
113
+ flushStatic() {
114
+ if (!this.staticBuf) return;
115
+ this.line(`${this.outVar} += ${JSON.stringify(this.staticBuf)};`);
116
+ this.staticBuf = "";
117
+ }
118
+
119
+ /**
120
+ * @param {string} text
121
+ */
122
+ appendStatic(text) {
123
+ if (text) this.staticBuf += text;
124
+ }
125
+
126
+ /**
127
+ * Dinamik ifade — önce statik tamponu boşaltır.
84
128
  * @param {string} exprJs
85
129
  */
86
130
  append(exprJs) {
131
+ this.flushStatic();
87
132
  this.line(`${this.outVar} += ${exprJs};`);
88
133
  }
89
134
 
@@ -105,9 +150,14 @@ class CodegenContext {
105
150
  */
106
151
  emit(node) {
107
152
  switch (node.type) {
108
- case "Text":
109
- if (node.value) this.append(JSON.stringify(node.value));
153
+ case "Text": {
154
+ const value = collapseTemplateWhitespace(
155
+ node.value,
156
+ this.preserveWs > 0,
157
+ );
158
+ if (value) this.appendStatic(value);
110
159
  break;
160
+ }
111
161
  case "Interp": {
112
162
  const expr = codegenExpr(node.expr, this.locals);
113
163
  if (node.raw) this.append(`(${expr} ?? "")`);
@@ -139,17 +189,21 @@ class CodegenContext {
139
189
  */
140
190
  emitElement(node) {
141
191
  const bound = node.attrs.some((a) => a.bound);
192
+ const preserve = PRESERVE_WS.has(node.name.toLowerCase());
193
+
142
194
  if (bound) {
143
195
  this.append(
144
196
  `"<${node.name}" + helpers.attrs({${this.attrsObject(node.attrs)}}) + ">"`,
145
197
  );
146
198
  } else {
147
- this.append(JSON.stringify(`<${node.name}${staticAttrString(node.attrs)}>`));
199
+ this.appendStatic(`<${node.name}${staticAttrString(node.attrs)}>`);
148
200
  }
149
201
 
150
202
  if (!node.selfClosing) {
203
+ if (preserve) this.preserveWs += 1;
151
204
  this.emitNodes(node.children);
152
- this.append(JSON.stringify(`</${node.name}>`));
205
+ if (preserve) this.preserveWs -= 1;
206
+ this.appendStatic(`</${node.name}>`);
153
207
  }
154
208
  }
155
209
 
@@ -186,13 +240,18 @@ class CodegenContext {
186
240
 
187
241
  if (node.children.length) {
188
242
  const tmp = this.nextTemp();
243
+ this.flushStatic();
189
244
  this.line(`{`);
190
245
  this.indent += 1;
191
246
  this.line(`let ${tmp} = "";`);
192
247
  const prevOut = this.outVar;
248
+ const prevStatic = this.staticBuf;
193
249
  this.outVar = tmp;
250
+ this.staticBuf = "";
194
251
  this.emitNodes(node.children);
252
+ this.flushStatic();
195
253
  this.outVar = prevOut;
254
+ this.staticBuf = prevStatic;
196
255
  const propsWithChildren = props
197
256
  ? `${props}, children: ${tmp}`
198
257
  : `children: ${tmp}`;
@@ -209,14 +268,17 @@ class CodegenContext {
209
268
  */
210
269
  emitIf(node) {
211
270
  const test = codegenExpr(node.test, this.locals);
271
+ this.flushStatic();
212
272
  this.line(`if (${test}) {`);
213
273
  this.indent += 1;
214
274
  this.emitNodes(node.consequent);
275
+ this.flushStatic();
215
276
  this.indent -= 1;
216
277
  if (node.alternate.length) {
217
278
  this.line(`} else {`);
218
279
  this.indent += 1;
219
280
  this.emitNodes(node.alternate);
281
+ this.flushStatic();
220
282
  this.indent -= 1;
221
283
  }
222
284
  this.line(`}`);
@@ -228,6 +290,7 @@ class CodegenContext {
228
290
  emitEach(node) {
229
291
  const list = codegenExpr(node.list, this.locals);
230
292
  const idx = node.indexName ?? `__i${++this.temp}`;
293
+ this.flushStatic();
231
294
  this.line(`{`);
232
295
  this.indent += 1;
233
296
  this.line(`const __list = ${list} ?? [];`);
@@ -239,6 +302,7 @@ class CodegenContext {
239
302
  this.locals.add(idx);
240
303
  if (node.indexName) this.locals.add(node.indexName);
241
304
  this.emitNodes(node.children);
305
+ this.flushStatic();
242
306
  this.locals = prev;
243
307
  this.indent -= 1;
244
308
  this.line(`}`);
@@ -259,6 +323,8 @@ class CodegenContext {
259
323
 
260
324
  /** @returns {string} */
261
325
  finish() {
326
+ this.flushStatic();
327
+
262
328
  const imports = [...this.includes]
263
329
  .map((id) => {
264
330
  const alias = includeAlias(id);
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
+ import process from "node:process";
6
7
  import { parseTemplate } from "./parse.js";
7
8
  import { codegen, normalizeIncludeId } from "./codegen.js";
8
9
  import { CompileError } from "./errors.js";
@@ -97,7 +98,9 @@ export async function compileAll(config, _options = {}) {
97
98
  }
98
99
 
99
100
  /**
100
- * Manifest yoksa veya herhangi bir `.jsk` daha yeniyse yeniden derler.
101
+ * Manifest yoksa veya (dev'de) herhangi bir `.jsk` daha yeniyse yeniden derler.
102
+ * Production'da manifest varsa mtime taraması atlanır — `jskelet build` CI
103
+ * güvencesi; ilk istekte yüzlerce `statSync` cold start'ı şişirmesin.
101
104
  * Dev sunucu restart'ında build watch kaçırmış olsa bile şablonlar güncel kalır.
102
105
  *
103
106
  * @param {{ root: string, dirs: Record<string, string> }} config
@@ -112,6 +115,12 @@ export async function ensureTemplatesCompiled(config) {
112
115
 
113
116
  const outDir = path.join(config.dirs.generated, "templates");
114
117
  const manifestPath = path.join(outDir, "manifest.json");
118
+ const isProd = process.env.NODE_ENV === "production";
119
+
120
+ if (fs.existsSync(manifestPath) && isProd) {
121
+ return { count: jskFiles.size, rebuilt: false };
122
+ }
123
+
115
124
  let needs = !fs.existsSync(manifestPath);
116
125
 
117
126
  if (!needs) {
@@ -4,7 +4,12 @@
4
4
  export { CompileError, indexToLocation } from "./errors.js";
5
5
  export { parseExpr, codegenExpr } from "./expr.js";
6
6
  export { parseTemplate, isComponentTag } from "./parse.js";
7
- export { codegen, toPascalCase, normalizeIncludeId } from "./codegen.js";
7
+ export {
8
+ codegen,
9
+ toPascalCase,
10
+ normalizeIncludeId,
11
+ collapseTemplateWhitespace,
12
+ } from "./codegen.js";
8
13
  export {
9
14
  getViewRoots,
10
15
  getComponentDirs,
@@ -151,6 +151,55 @@ export function getBuildId() {
151
151
  /** @type {Record<string, OptimizedImage> | null} */
152
152
  let images = null;
153
153
 
154
+ /** Prefix uygulanmış kopya — her `optimizedImage` çağrısında map/spread yok. */
155
+ /** @type {Record<string, OptimizedImage> | null} */
156
+ let imagesPrefixed = null;
157
+
158
+ /** @type {string | null} */
159
+ let imagesPrefix = null;
160
+
161
+ /**
162
+ * `images.json` yükler; CDN prefix varsa varyant URL'lerini bir kez materialize eder.
163
+ * @returns {Record<string, OptimizedImage>}
164
+ */
165
+ function loadImages() {
166
+ const prefix = assetPrefix();
167
+ if (!isDev && images && imagesPrefix === prefix) {
168
+ return imagesPrefixed ?? images;
169
+ }
170
+
171
+ /** @type {Record<string, OptimizedImage>} */
172
+ let raw = {};
173
+ try {
174
+ const file = path.join(getConfig().dirs.generated, "images.json");
175
+ raw = JSON.parse(fs.readFileSync(file, "utf8")).images ?? {};
176
+ } catch {
177
+ raw = {};
178
+ }
179
+
180
+ images = raw;
181
+ imagesPrefix = prefix;
182
+
183
+ if (!prefix) {
184
+ imagesPrefixed = raw;
185
+ return raw;
186
+ }
187
+
188
+ /** @type {Record<string, OptimizedImage>} */
189
+ const prefixed = {};
190
+ for (const [key, entry] of Object.entries(raw)) {
191
+ prefixed[key] = {
192
+ ...entry,
193
+ variants: entry.variants.map((variant) => ({
194
+ ...variant,
195
+ url: applyAssetPrefix(prefix, variant.url),
196
+ })),
197
+ };
198
+ }
199
+ imagesPrefixed = prefixed;
200
+ return prefixed;
201
+ }
202
+
154
203
  /**
155
204
  * `build/tasks/images.mjs` çıktısı: kaynak yolundan webp varyantlarına.
156
205
  * Build çalışmadıysa boş kalır ve görseller orijinalleriyle servis edilir.
@@ -159,29 +208,9 @@ let images = null;
159
208
  * @returns {OptimizedImage | undefined}
160
209
  */
161
210
  export function optimizedImage(src) {
162
- if (isDev || !images) {
163
- try {
164
- const file = path.join(getConfig().dirs.generated, "images.json");
165
- images = JSON.parse(fs.readFileSync(file, "utf8")).images ?? {};
166
- } catch {
167
- images = {};
168
- }
169
- }
170
-
211
+ const table = loadImages();
171
212
  if (!src) return undefined;
172
- const entry = images[src];
173
- if (!entry) return undefined;
174
-
175
- const prefix = assetPrefix();
176
- if (!prefix) return entry;
177
-
178
- return {
179
- ...entry,
180
- variants: entry.variants.map((variant) => ({
181
- ...variant,
182
- url: applyAssetPrefix(prefix, variant.url),
183
- })),
184
- };
213
+ return table[src];
185
214
  }
186
215
 
187
216
  /** @type {{ url: string, ids: Set<string> } | null} */
@@ -161,11 +161,21 @@ async function loadCompiledTemplates(config, helpers) {
161
161
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
162
162
  const bust = isDev ? `?t=${Date.now()}` : "";
163
163
 
164
- for (const [viewId, rel] of Object.entries(manifest)) {
165
- const file = path.join(templatesDir, rel);
166
- if (!fs.existsSync(file)) continue;
167
- const mod = await import(pathToFileURL(file).href + bust);
168
- if (typeof mod.render !== "function") continue;
164
+ // Paralel import — sıralı await cold start'ı şişiriyordu.
165
+ const entries = await Promise.all(
166
+ Object.entries(manifest).map(async ([viewId, rel]) => {
167
+ const file = path.join(templatesDir, rel);
168
+ try {
169
+ const mod = await import(pathToFileURL(file).href + bust);
170
+ return /** @type {const} */ ([viewId, mod]);
171
+ } catch {
172
+ return /** @type {const} */ ([viewId, null]);
173
+ }
174
+ }),
175
+ );
176
+
177
+ for (const [viewId, mod] of entries) {
178
+ if (!mod || typeof mod.render !== "function") continue;
169
179
  compiled.set(viewId, mod.render);
170
180
 
171
181
  // `components/*.jsk` → helpers.PascalName
@@ -259,8 +269,9 @@ export async function renderPage(page) {
259
269
  hook("layoutContext", {}, { pathname: page.pathname ?? "", metadata }),
260
270
  ]);
261
271
 
262
- const locals = {
263
- ...helpers,
272
+ // Layout local'leri. `.jsk` `(data, helpers)` aldığı için helpers'ı data'ya
273
+ // kopyalamak gereksiz allocation; EJS layout hâlâ bare helper adları bekler.
274
+ const pageLocals = {
264
275
  ...context,
265
276
  metadata,
266
277
  // Boş varsayılan bilinçli: "/" yazmak her sayfayı ana sayfa sanıp
@@ -286,7 +297,7 @@ export async function renderPage(page) {
286
297
  };
287
298
 
288
299
  if (layoutRender) {
289
- return layoutRender(locals, helpers);
300
+ return layoutRender(pageLocals, helpers);
290
301
  }
291
302
 
292
303
  if (layout.endsWith(".jsk")) {
@@ -296,7 +307,7 @@ export async function renderPage(page) {
296
307
  );
297
308
  }
298
309
 
299
- return renderEjsFile(layout, locals, options);
310
+ return renderEjsFile(layout, { ...helpers, ...pageLocals }, options);
300
311
  }
301
312
 
302
313
  /**
@@ -5,10 +5,10 @@
5
5
  anda bu dosya devre dışı kalır; amacı yeni bir projenin tek route ile
6
6
  çalışabilmesi. Kopyalayıp başlangıç noktası olarak kullanın.
7
7
 
8
- Kullanılabilir local'ler: metadata, headMeta, extraHead, structuredData,
9
- body, bodyClass, entries, styles, pathname, lang, devtools, devBasePath,
10
- asset, hasAsset + tüm html/tag helper'ları ve views/components/** export'ları.
11
- `hooks.layoutContext()` döndürdüğü her alan da buraya eklenir.
8
+ Kullanılabilir data alanları: metadata, headMeta, extraHead, structuredData,
9
+ body, bodyClass, entries, styles, pathname, lang, devtools, devBasePath.
10
+ Yardımcılar ikinci `helpers` argümanında (PascalCase etiketler /
11
+ helpers.esc). `hooks.layoutContext()` alanları data'ya eklenir.
12
12
 
13
13
  Stylesheets / JsonLd / BodyScripts: `.jsk` ifade dilinde fonksiyon çağrısı
14
14
  olmadığı için asset döngüleri bu yerleşik etiketlere taşındı.
@@ -6,36 +6,17 @@
6
6
  */
7
7
  export function render(data, helpers) {
8
8
  let s = "";
9
- s += "\r\n";
10
9
  s += "<!DOCTYPE html>";
11
- s += "\r\n";
12
10
  s += "<html" + helpers.attrs({"lang": data.lang}) + ">";
13
- s += "\r\n ";
14
- s += "<head>";
15
- s += "\r\n ";
16
- s += "<meta charset=\"utf-8\">";
17
- s += "\r\n ";
18
- s += "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">";
19
- s += "\r\n ";
11
+ s += "<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">";
20
12
  s += (data.extraHead ?? "");
21
- s += "\r\n ";
22
13
  s += (helpers.Stylesheets({ "styles": data.styles }) ?? "");
23
- s += "\r\n ";
24
14
  s += (data.headMeta ?? "");
25
- s += "\r\n ";
26
15
  s += (helpers.JsonLd({ "items": data.structuredData }) ?? "");
27
- s += "\r\n ";
28
16
  s += "</head>";
29
- s += "\r\n ";
30
17
  s += "<body" + helpers.attrs({"class": data.bodyClass}) + ">";
31
- s += "\r\n ";
32
18
  s += (data.body ?? "");
33
- s += "\r\n ";
34
19
  s += (helpers.BodyScripts({ "entries": data.entries, "devtools": data.devtools, "devBasePath": data.devBasePath }) ?? "");
35
- s += "\r\n ";
36
- s += "</body>";
37
- s += "\r\n";
38
- s += "</html>";
39
- s += "\r\n";
20
+ s += "</body></html>";
40
21
  return s;
41
22
  }
@@ -43,7 +43,8 @@ function collect(dir, out = []) {
43
43
  }
44
44
 
45
45
  /**
46
- * Tek bir `components/` dizinini yükler.
46
+ * Modülleri paralel import eder; export uygulama sırası dosya listesiyle
47
+ * aynı kalır (barrel önce, sonra sıralı bileşenler).
47
48
  *
48
49
  * @param {string} dir
49
50
  * @param {Record<string, unknown>} components
@@ -59,12 +60,16 @@ async function loadDir(dir, components, origin) {
59
60
  ...collect(dir).sort(),
60
61
  ];
61
62
 
62
- for (const file of files) {
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("/");
66
- const module = await import(pathToFileURL(file).href);
63
+ const loaded = await Promise.all(
64
+ files.map(async (file) => {
65
+ const isBarrel = path.basename(file) === BARREL;
66
+ const fileId = isBarrel ? BARREL : file.split(path.sep).join("/");
67
+ const module = await import(pathToFileURL(file).href);
68
+ return { fileId, module };
69
+ }),
70
+ );
67
71
 
72
+ for (const { fileId, module } of loaded) {
68
73
  for (const [name, value] of Object.entries(module)) {
69
74
  if (name === "default") continue;
70
75
 
@@ -9,6 +9,11 @@ const ESCAPE_MAP = {
9
9
  "'": "&#39;",
10
10
  };
11
11
 
12
+ /** Kaçış gerektiren karakter var mı — yoksa `replace` callback'ine girilmez. */
13
+ const NEEDS_ESCAPE = /[&<>"']/;
14
+ /** Tüm eşleşmeleri değiştirmek için ayrı global kopya (`test` lastIndex'i kirletmesin). */
15
+ const ESCAPE_ALL = /[&<>"']/g;
16
+
12
17
  /**
13
18
  * Metin içeriği ve attribute değerleri için kaçış.
14
19
  * @param {unknown} value
@@ -16,7 +21,9 @@ const ESCAPE_MAP = {
16
21
  */
17
22
  export function esc(value) {
18
23
  if (value == null || value === false) return "";
19
- return String(value).replace(/[&<>"']/g, (char) => ESCAPE_MAP[char]);
24
+ const str = typeof value === "string" ? value : String(value);
25
+ if (!NEEDS_ESCAPE.test(str)) return str;
26
+ return str.replace(ESCAPE_ALL, (char) => ESCAPE_MAP[char]);
20
27
  }
21
28
 
22
29
  /**
@@ -41,16 +48,17 @@ export function jsonScript(value) {
41
48
  * @returns {string}
42
49
  */
43
50
  export function attrs(attrs) {
44
- const parts = [];
51
+ // Dizi + join yerine doğrudan birikim: tipik etiket 2–6 attribute.
52
+ let out = "";
45
53
  for (const [key, value] of Object.entries(attrs)) {
46
54
  if (value == null || value === false) continue;
47
55
  if (value === true) {
48
- parts.push(key);
56
+ out += ` ${key}`;
49
57
  continue;
50
58
  }
51
- parts.push(`${key}="${esc(value)}"`);
59
+ out += ` ${key}="${esc(value)}"`;
52
60
  }
53
- return parts.length ? ` ${parts.join(" ")}` : "";
61
+ return out;
54
62
  }
55
63
 
56
64
  /**
@@ -24,6 +24,12 @@ const isDev = process.env.NODE_ENV === "development";
24
24
  /** Aynı eksik ikon için tek uyarı. */
25
25
  const warnedIcons = new Set();
26
26
 
27
+ /** `fill` Image varsayılan sınıfları — `cn`/`twMerge` olmadan sabit. */
28
+ const IMAGE_FILL_CLASS = "absolute inset-0 h-full w-full object-cover";
29
+
30
+ /** Sprite URL process ömründe bir kez çözülür (hash değişince process yenilenir). */
31
+ let cachedSpritePath = /** @type {string | null} */ (null);
32
+
27
33
  /**
28
34
  * `SeoLink` karşılığı: title otomatik doldurulur.
29
35
  * @param {{ href: string, text?: string, html?: string, class?: string,
@@ -101,14 +107,19 @@ export function image(props) {
101
107
  (optimized && optimized.variants.length > 1) ||
102
108
  (remote && remote.srcset);
103
109
 
110
+ // `fill` + ek sınıf yoksa twMerge'e girme; ek sınıf varsa çakışma çözümü gerekir.
111
+ const fillClass = fill
112
+ ? className
113
+ ? cn(IMAGE_FILL_CLASS, className)
114
+ : IMAGE_FILL_CLASS
115
+ : className;
116
+
104
117
  const attributes = attrs({
105
118
  src: largest?.url ?? remote?.src ?? src,
106
119
  alt: alt ?? "",
107
120
  width: fill ? undefined : (width ?? optimized?.width),
108
121
  height: fill ? undefined : (height ?? optimized?.height),
109
- class: fill
110
- ? cn("absolute inset-0 h-full w-full object-cover", className)
111
- : className,
122
+ class: fillClass,
112
123
  sizes: responsive
113
124
  ? (sizes ?? (optimized ? defaultSizes(optimized) : remote?.sizes))
114
125
  : sizes,
@@ -213,7 +224,13 @@ export function icon(props) {
213
224
  attrs: extra = {},
214
225
  } = props;
215
226
 
216
- const spritePath = asset("sprite.svg", { origin: "document" });
227
+ // Prod'da hash deploy ile birlikte değişir (process yenilenir); dev'de
228
+ // watch rebuild sprite yolunu güncelleyebildiği için her seferinde çözülür.
229
+ let spritePath = cachedSpritePath;
230
+ if (spritePath == null || isDev) {
231
+ spritePath = asset("sprite.svg", { origin: "document" });
232
+ if (!isDev) cachedSpritePath = spritePath;
233
+ }
217
234
  const id = `${toKebab(name)}-${weight}`;
218
235
 
219
236
  if (isDev && !warnedIcons.has(id)) {
@@ -1,9 +1,5 @@
1
1
  export type AstNode = import('./parse.js').AstNode;
2
2
  export type AttrNode = import('./parse.js').AttrNode;
3
- /**
4
- * @typedef {import('./parse.js').AstNode} AstNode
5
- * @typedef {import('./parse.js').AttrNode} AttrNode
6
- */
7
3
  /**
8
4
  * @param {AstNode[]} ast
9
5
  * @param {{ viewId: string, file?: string, knownComponents?: Set<string> }} options
@@ -30,3 +26,12 @@ export declare function normalizeIncludeId(includePath: string): string;
30
26
  * @returns {string}
31
27
  */
32
28
  export declare function toPascalCase(name: string): string;
29
+ /**
30
+ * Şablon girintisini düşür: satır sonu içeren salt-whitespace → boş;
31
+ * yalnızca boşluk/tab → tek boşluk (inline aralık). Koruma alanı dışında.
32
+ *
33
+ * @param {string} value
34
+ * @param {boolean} preserve
35
+ * @returns {string}
36
+ */
37
+ export declare function collapseTemplateWhitespace(value: string, preserve: boolean): string;
@@ -12,7 +12,9 @@ export declare function compileAll(config: {
12
12
  outDir: string;
13
13
  }>;
14
14
  /**
15
- * Manifest yoksa veya herhangi bir `.jsk` daha yeniyse yeniden derler.
15
+ * Manifest yoksa veya (dev'de) herhangi bir `.jsk` daha yeniyse yeniden derler.
16
+ * Production'da manifest varsa mtime taraması atlanır — `jskelet build` CI
17
+ * güvencesi; ilk istekte yüzlerce `statSync` cold start'ı şişirmesin.
16
18
  * Dev sunucu restart'ında build watch kaçırmış olsa bile şablonlar güncel kalır.
17
19
  *
18
20
  * @param {{ root: string, dirs: Record<string, string> }} config
@@ -4,7 +4,7 @@
4
4
  export { CompileError, indexToLocation } from "./errors.js";
5
5
  export { parseExpr, codegenExpr } from "./expr.js";
6
6
  export { parseTemplate, isComponentTag } from "./parse.js";
7
- export { codegen, toPascalCase, normalizeIncludeId } from "./codegen.js";
7
+ export { codegen, toPascalCase, normalizeIncludeId, collapseTemplateWhitespace, } from "./codegen.js";
8
8
  export { getViewRoots, getComponentDirs, discoverJskFiles, componentNameFromViewId, collectKnownComponents, toComponentTag, } from "./resolve.js";
9
9
  export { scanNamedExports } from "./scan-exports.js";
10
10
  export { compileAll, compileSource, ensureTemplatesCompiled } from "./compile-all.js";