jskelet 0.6.17 → 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
@@ -17,6 +36,10 @@ one is listed under a **Breaking** heading.
17
36
 
18
37
  ### Added
19
38
 
39
+ - Dev tools **Server** tab can clear the HTML cache for the open page, every
40
+ HTML entry, or the data cache. Live stats include HTML/data entry counts;
41
+ new endpoints under `devBasePath`: `POST /cache/clear-html`,
42
+ `/cache/clear-data`, and `/cache/invalidate`.
20
43
  - On-demand HTML cache via `cache().prewarm: false`: visited pages still
21
44
  write to L1 and Redis/disk L2 with the usual `html` TTLs, but classic
22
45
  prewarm, `onVisit` link warming, and early-expiry HTTP refresh stay off
@@ -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
 
@@ -208,6 +208,11 @@ Gösterdikleri:
208
208
  bloke süresi.
209
209
  - **Prewarm:** ısıtma turunun ilerlemesi; panelden elle tetiklenebilir, tek tek
210
210
  yollar tekrar denenebilir.
211
+ - **Önbellek (Server sekmesi):** HTML ve veri girdisi sayıları; **Clear this
212
+ page** açık yolun bütün varyantlarını sert düşürüp sayfayı yeniler, **Clear
213
+ all HTML** / **Clear data cache** süreç önbelleğini boşaltır (onay ister).
214
+ Aynı işlemler admin panelindeki `html:invalidate` / `html:clear` /
215
+ `data:clear` ile paylaşılır.
211
216
  - **Süreç:** pid, Node sürümü, uptime, RSS ve heap kullanımı.
212
217
  - **Sürüm:** kurulu JSkelet sürümü ve npm'deki `latest` ile karşılaştırması.
213
218
  Yeni bir sürüm varsa **Server** sekmesinde `update` rozeti ve yükseltme
@@ -297,6 +302,9 @@ Rapor katmanı yalnızca development'ta yüklenir, üretim çıktısına hiç gi
297
302
  | `/report/clear` | POST | Sayfa ölçümlerini ve sunucu API kayıtlarını sıfırlar |
298
303
  | `/prewarm` | POST | Isıtmayı elle tetikler. Gövdede `paths` varsa yalnızca o yollar; ısıtma zaten çalışıyorsa 409. |
299
304
  | `/clear` | POST | İstek ve hata günlüklerini sıfırlar |
305
+ | `/cache/clear-html` | POST | Tüm HTML önbelleğini boşaltır; `{ cleared }` döner |
306
+ | `/cache/clear-data` | POST | Veri önbelleğini boşaltır. Gövdede isteğe bağlı `prefix`; `{ cleared }` döner |
307
+ | `/cache/invalidate` | POST | Gövdede `path` (zorunlu, `/` ile başlamalı) ve isteğe bağlı `hard` (varsayılan `true`). Etkilenen girdi sayısıyla `{ count }` döner |
300
308
 
301
309
  Bu uçların tamamı `mountDevtools()` tarafından yalnızca
302
310
  `NODE_ENV=development` iken bağlanır; prod sürecine dinamik import sayesinde
@@ -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>`.
@@ -213,6 +213,11 @@ What it shows:
213
213
  blocking time.
214
214
  - **Prewarm:** the progress of the warming round; it can be triggered manually
215
215
  from the panel, and individual paths can be retried.
216
+ - **Cache (Server tab):** HTML and data entry counts; **Clear this page**
217
+ hard-drops every variant of the open path and reloads, **Clear all HTML** /
218
+ **Clear data cache** empty the in-process caches (with a confirm). Same
219
+ core APIs as the admin panel's `html:invalidate` / `html:clear` /
220
+ `data:clear`.
216
221
  - **Process:** pid, Node version, uptime, RSS and heap usage.
217
222
  - **Version:** the installed JSkelet version compared against the `latest` tag
218
223
  on npm. When a newer release exists, the **Server** tab grows an `update` chip
@@ -302,6 +307,9 @@ Under `brand.devBasePath` (default `/__jskelet/dev`):
302
307
  | `/report/clear` | POST | Resets page measurements and server API records |
303
308
  | `/prewarm` | POST | Triggers warming manually. If the body has `paths`, only those paths; 409 if warming is already running. |
304
309
  | `/clear` | POST | Resets the request and error logs |
310
+ | `/cache/clear-html` | POST | Clears the entire HTML cache; returns `{ cleared }` |
311
+ | `/cache/clear-data` | POST | Clears the data cache. Optional `prefix` in the body; returns `{ cleared }` |
312
+ | `/cache/invalidate` | POST | Body: required `path` (must start with `/`) and optional `hard` (default `true`). Returns `{ count }` for affected entries |
305
313
 
306
314
  All of these endpoints are mounted by `mountDevtools()` only when
307
315
  `NODE_ENV=development`; thanks to the dynamic import, nothing is loaded into the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.6.17",
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",
@@ -861,6 +861,93 @@ async function clearAll() {
861
861
  pollServer();
862
862
  }
863
863
 
864
+ /** Son önbellek eyleminin kısa geri bildirimi; sonraki poll'da silinmez. */
865
+ let cacheNotice = null;
866
+ /** @type {ReturnType<typeof setTimeout> | null} */
867
+ let cacheNoticeTimer = null;
868
+
869
+ /**
870
+ * @param {string} message
871
+ */
872
+ function setCacheNotice(message) {
873
+ cacheNotice = message;
874
+ if (cacheNoticeTimer) clearTimeout(cacheNoticeTimer);
875
+ cacheNoticeTimer = setTimeout(() => {
876
+ cacheNotice = null;
877
+ cacheNoticeTimer = null;
878
+ lastBody = "";
879
+ render();
880
+ }, 4000);
881
+ lastBody = "";
882
+ render();
883
+ }
884
+
885
+ /**
886
+ * Bu sekmenin HTML önbelleğini düşürür ve sayfayı yeniler — aksi hâlde
887
+ * tarayıcıdaki DOM eski kalır, soğuk render görülmez.
888
+ */
889
+ async function clearThisPageCache() {
890
+ const path = location.pathname || "/";
891
+ try {
892
+ await fetch(`${BASE}/cache/invalidate`, {
893
+ method: "POST",
894
+ headers: { "content-type": "application/json" },
895
+ body: JSON.stringify({ path, hard: true }),
896
+ });
897
+ } catch {
898
+ setCacheNotice("Could not reach the server to clear this page.");
899
+ return;
900
+ }
901
+ saveState();
902
+ location.reload();
903
+ }
904
+
905
+ async function clearHtmlCacheAll() {
906
+ if (
907
+ !confirm(
908
+ "Clear the entire HTML cache? Every page goes cold until the next render or prewarm.",
909
+ )
910
+ ) {
911
+ return;
912
+ }
913
+
914
+ try {
915
+ const response = await fetch(`${BASE}/cache/clear-html`, { method: "POST" });
916
+ const body = await response.json().catch(() => ({}));
917
+ setCacheNotice(
918
+ `HTML cache cleared (${typeof body.cleared === "number" ? body.cleared : "?"} entries).`,
919
+ );
920
+ } catch {
921
+ setCacheNotice("Could not clear the HTML cache.");
922
+ }
923
+ pollServer();
924
+ }
925
+
926
+ async function clearDataCacheAll() {
927
+ if (
928
+ !confirm(
929
+ "Clear every data-cache entry? Upstream traffic will spike on the next renders.",
930
+ )
931
+ ) {
932
+ return;
933
+ }
934
+
935
+ try {
936
+ const response = await fetch(`${BASE}/cache/clear-data`, {
937
+ method: "POST",
938
+ headers: { "content-type": "application/json" },
939
+ body: JSON.stringify({}),
940
+ });
941
+ const body = await response.json().catch(() => ({}));
942
+ setCacheNotice(
943
+ `Data cache cleared (${typeof body.cleared === "number" ? body.cleared : "?"} entries).`,
944
+ );
945
+ } catch {
946
+ setCacheNotice("Could not clear the data cache.");
947
+ }
948
+ pollServer();
949
+ }
950
+
864
951
  /* ---------------------------------------------------------------- arayüz */
865
952
 
866
953
  const CSS = `
@@ -1162,6 +1249,7 @@ h4 .head-action { float: inline-end; text-transform: none; letter-spacing: 0; }
1162
1249
  .status.warn { background: rgba(251,191,36,.14); color: var(--mid); }
1163
1250
  .status.bad { background: rgba(251,113,133,.14); color: var(--bad); }
1164
1251
  .hint { color: var(--muted); font-size: 10.5px; }
1252
+ .hint.block { display: block; margin: 0 0 8px; line-height: 1.45; }
1165
1253
  .row .good { color: var(--good); } .row .mid { color: var(--mid); } .row .bad { color: var(--bad); }
1166
1254
  .row.clickable { cursor: pointer; grid-template-columns: auto auto 1fr auto auto; }
1167
1255
  .row.clickable.selected { border-color: rgba(255,255,255,.22); background: var(--surface-hover); }
@@ -1200,9 +1288,14 @@ h4 .head-action { float: inline-end; text-transform: none; letter-spacing: 0; }
1200
1288
  .btn.mini { display: inline-flex; align-items: center; gap: 6px; padding: 4px 9px; font-size: 11px; }
1201
1289
  .btn.mini svg { width: 12px; height: 12px; }
1202
1290
  .btn[disabled] { opacity: .5; cursor: default; }
1203
- .toolbar { display: flex; justify-content: flex-end; margin-bottom: 10px; }
1291
+ .toolbar { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; margin-bottom: 10px; }
1292
+ .toolbar.start { justify-content: flex-start; }
1204
1293
  .btn.danger { color: #ffc9d2; border-color: rgba(251,113,133,.28); background: rgba(251,113,133,.1); }
1205
1294
  .btn.danger:hover { color: #fff; border-color: rgba(251,113,133,.55); background: rgba(251,113,133,.22); }
1295
+ .notice {
1296
+ margin: 0 0 10px; padding: 8px 10px; border-radius: 8px; font-size: 12px; line-height: 1.4;
1297
+ color: #b9f2dc; background: rgba(52,211,153,.1); border: 1px solid rgba(52,211,153,.28);
1298
+ }
1206
1299
  `;
1207
1300
 
1208
1301
  let shadow = null;
@@ -2022,6 +2115,14 @@ function serverTab() {
2022
2115
  { label: "Avg. render", value: ms(average), tone: grade(average, [150, 500]) },
2023
2116
  { label: "Slowest", value: ms(slowest), tone: grade(slowest, [300, 1000]) },
2024
2117
  { label: "Restart", value: String(restarts), tone: offline ? "mid" : "" },
2118
+ {
2119
+ label: "HTML cache",
2120
+ value: String(serverStats.cache?.html ?? 0),
2121
+ },
2122
+ {
2123
+ label: "Data cache",
2124
+ value: String(serverStats.cache?.data ?? 0),
2125
+ },
2025
2126
  ...(serverStats.prewarm?.total
2026
2127
  ? [
2027
2128
  {
@@ -2033,6 +2134,7 @@ function serverTab() {
2033
2134
  ]
2034
2135
  : []),
2035
2136
  ])}
2137
+ ${cacheSection()}
2036
2138
  ${upstreamSection()}
2037
2139
  <h4>Recent requests</h4>
2038
2140
  ${
@@ -2052,6 +2154,42 @@ function serverTab() {
2052
2154
  `;
2053
2155
  }
2054
2156
 
2157
+ /**
2158
+ * HTML / veri önbelleğini panelden boşaltma. Admin panelindeki aynı işlemlerin
2159
+ * kısa yolu; "bu sayfa" mevcut `location.pathname` için sert düşürme yapar.
2160
+ *
2161
+ * @returns {string}
2162
+ */
2163
+ function cacheSection() {
2164
+ const path = location.pathname || "/";
2165
+ const html = serverStats?.cache?.html ?? 0;
2166
+ const data = serverStats?.cache?.data ?? 0;
2167
+
2168
+ return `
2169
+ <h4>Cache</h4>
2170
+ ${
2171
+ cacheNotice
2172
+ ? `<div class="notice">${escapeHtml(cacheNotice)}</div>`
2173
+ : ""
2174
+ }
2175
+ <p class="hint block">
2176
+ In-process HTML (${html}) and data (${data}) entries.
2177
+ Clearing this page drops every variant of <code>${escapeHtml(path)}</code> and reloads.
2178
+ </p>
2179
+ <div class="toolbar start">
2180
+ <button class="btn mini" data-action="cache-page" title="Hard-drop HTML for ${escapeHtml(path)}">
2181
+ ${TRASH_ICON} Clear this page
2182
+ </button>
2183
+ <button class="btn mini danger" data-action="cache-html" title="Clear every HTML cache entry">
2184
+ ${TRASH_ICON} Clear all HTML
2185
+ </button>
2186
+ <button class="btn mini danger" data-action="cache-data" title="Clear every data-cache entry">
2187
+ ${TRASH_ICON} Clear data cache
2188
+ </button>
2189
+ </div>
2190
+ `;
2191
+ }
2192
+
2055
2193
  /**
2056
2194
  * Upstream hız freninin durumu. Fren kapalıysa (varsayılan) hiçbir şey
2057
2195
  * basılmaz; açıkken 429 fırtınasında hızın nereye indiğini görmek, ayarı
@@ -2224,7 +2362,7 @@ const ABOUT = {
2224
2362
  <div class="step"><span class="n">1</span><span><b>Route</b><span><code>server/routes/</code> matches the URL and hands the request to the right controller.</span></span></div>
2225
2363
  <div class="step"><span class="n">2</span><span><b>Controller</b><span><code>server/controllers/</code> gathers the data it needs from the <code>lib/</code> services and prepares the model for the template.</span></span></div>
2226
2364
  <div class="step"><span class="n">3</span><span><b>Render</b><span><code>server/render.js</code> runs the EJS template and prints manifest entries and head hints into the layout.</span></span></div>
2227
- <div class="step"><span class="n">4</span><span><b>HTML cache</b><span><code>server/html-cache.js</code> stores the output; if the same URL is requested again the template never runs. The <code>cached</code> label on the Server tab shows this.</span></span></div>
2365
+ <div class="step"><span class="n">4</span><span><b>HTML cache</b><span><code>server/html-cache.js</code> stores the output; if the same URL is requested again the template never runs. The Server tab shows entry counts and can clear this page, all HTML, or the data cache.</span></span></div>
2228
2366
  <div class="step"><span class="n">5</span><span><b>Response</b><span>Compression and cache headers are decided in <code>server/middleware/</code>; responses like event streams skip compression.</span></span></div>
2229
2367
  </div>
2230
2368
  <p><strong>Prewarming:</strong> <code>server/prewarm.js</code> visits the most travelled
@@ -2734,6 +2872,21 @@ function bind(root) {
2734
2872
  }
2735
2873
 
2736
2874
  if (target.dataset.action === "clear") clearAll();
2875
+
2876
+ if (target.dataset.action === "cache-page") {
2877
+ void clearThisPageCache();
2878
+ return;
2879
+ }
2880
+
2881
+ if (target.dataset.action === "cache-html") {
2882
+ void clearHtmlCacheAll();
2883
+ return;
2884
+ }
2885
+
2886
+ if (target.dataset.action === "cache-data") {
2887
+ void clearDataCacheAll();
2888
+ return;
2889
+ }
2737
2890
  });
2738
2891
  }
2739
2892
 
@@ -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} */
@@ -15,7 +15,12 @@ import * as log from "../../log.mjs";
15
15
  import { FRAMEWORK_ROOT, getConfig } from "../../config/index.js";
16
16
  import { getRequestContext } from "../../http/request-context.js";
17
17
  import { prewarm, prewarmProgress } from "../prewarm.js";
18
- import { clearHtmlCache } from "../html-cache.js";
18
+ import {
19
+ clearHtmlCache,
20
+ getHtmlCacheSize,
21
+ invalidateHtmlCache,
22
+ } from "../html-cache.js";
23
+ import { clearDataCache, getDataCacheSize } from "../data-cache.js";
19
24
  import {
20
25
  buildReport,
21
26
  clearPageReports,
@@ -325,6 +330,12 @@ function statsPayload() {
325
330
  version: versionStatus(),
326
331
  memory: { rss: usage.rss, heapUsed: usage.heapUsed },
327
332
  prewarm: { ...prewarmProgress },
333
+ // Overlay Server sekmesindeki önbellek sayaçları; her kalp atışında
334
+ // güncellenir ki temizleme sonrası yenilemeye gerek kalmasın.
335
+ cache: {
336
+ html: getHtmlCacheSize(),
337
+ data: getDataCacheSize(),
338
+ },
328
339
  requests: requests.slice(-25).reverse(),
329
340
  errors: errors.slice(-25).reverse(),
330
341
  // SSR dış çağrıları: overlay API sekmesi hangi sayfanın hangi
@@ -557,6 +568,41 @@ function router() {
557
568
  res.json({ ok: true });
558
569
  });
559
570
 
571
+ // Panelden HTML / veri önbelleğini elle boşaltma. Admin panelindeki
572
+ // `html:clear` / `html:invalidate` / `data:clear` ile aynı çekirdek API;
573
+ // yalnızca development'ta ve CSRF'siz — overlay zaten aynı origin'den gelir.
574
+ api.post("/cache/clear-html", (req, res) => {
575
+ const cleared = getHtmlCacheSize();
576
+ clearHtmlCache();
577
+ pushStats();
578
+ res.json({ ok: true, cleared });
579
+ });
580
+
581
+ api.post("/cache/clear-data", express.json({ limit: "8kb" }), (req, res) => {
582
+ const prefix =
583
+ typeof req.body?.prefix === "string" && req.body.prefix
584
+ ? req.body.prefix
585
+ : undefined;
586
+ const cleared = clearDataCache(prefix);
587
+ pushStats();
588
+ res.json({ ok: true, cleared });
589
+ });
590
+
591
+ api.post("/cache/invalidate", express.json({ limit: "8kb" }), (req, res) => {
592
+ const target = String(req.body?.path ?? "");
593
+ if (!target.startsWith("/")) {
594
+ res.status(400).json({ ok: false, reason: "path" });
595
+ return;
596
+ }
597
+
598
+ // Dev panelinde "bu sayfayı sil" soğuk render bekler; yumuşak bayatlatma
599
+ // eski HTML'i servis etmeye devam eder ve etkisi görünmez kalır.
600
+ const hard = req.body?.hard !== false;
601
+ const count = invalidateHtmlCache(target, { hard });
602
+ pushStats();
603
+ res.json({ ok: true, count, path: target, hard });
604
+ });
605
+
560
606
  return api;
561
607
  }
562
608
 
@@ -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";