jskelet 0.7.1 → 0.7.3

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/docs/02-mimari.md +11 -3
  3. package/docs/04-render-ve-sablonlar.md +39 -9
  4. package/docs/05-islands.md +8 -4
  5. package/docs/06-cache.md +18 -16
  6. package/docs/07-yapilandirma.md +8 -7
  7. package/docs/08-build.md +5 -2
  8. package/docs/en/02-architecture.md +10 -3
  9. package/docs/en/04-rendering.md +44 -10
  10. package/docs/en/05-islands.md +8 -4
  11. package/docs/en/06-caching.md +18 -16
  12. package/docs/en/07-configuration.md +8 -6
  13. package/docs/en/08-build.md +7 -4
  14. package/package.json +1 -1
  15. package/src/build/build.mjs +23 -8
  16. package/src/build/tasks/css.mjs +2 -1
  17. package/src/build/tasks/precompress.mjs +51 -11
  18. package/src/client/registry.js +13 -3
  19. package/src/compile/compile-all.js +14 -8
  20. package/src/config/defaults.js +9 -5
  21. package/src/config/index.js +3 -3
  22. package/src/http/request-cache.js +19 -1
  23. package/src/index.js +2 -0
  24. package/src/server/cache-blob.js +33 -0
  25. package/src/server/create-app.js +5 -0
  26. package/src/server/data-cache.js +77 -11
  27. package/src/server/disk-cache.js +2 -2
  28. package/src/server/head-hints.js +21 -9
  29. package/src/server/html-cache.js +169 -39
  30. package/src/server/image-optimizer.js +72 -28
  31. package/src/server/redis.js +2 -2
  32. package/src/server/render.js +165 -53
  33. package/src/views/helpers/tags.js +38 -9
  34. package/types/client/registry.d.ts +2 -0
  35. package/types/config/defaults.d.ts +8 -4
  36. package/types/config/index.d.ts +1 -0
  37. package/types/index.d.ts +1 -1
  38. package/types/server/cache-blob.d.ts +8 -0
  39. package/types/server/data-cache.d.ts +1 -0
  40. package/types/server/html-cache.d.ts +4 -5
  41. package/types/server/render.d.ts +19 -0
  42. package/types/views/helpers/tags.d.ts +7 -1
package/CHANGELOG.md CHANGED
@@ -26,9 +26,46 @@ one is listed under a **Breaking** heading.
26
26
  variant URLs once at load time.
27
27
  - `scripts/measure-ssr.mjs` microbench for fixture `renderView` /
28
28
  `renderPage` and live cache MISS/HIT timings against a running app.
29
+ - HTML cache awaits brotli on write so the MISS response can reuse it;
30
+ `rememberHtmlEncoding` updates the byte budget by delta instead of
31
+ rescanning the store. Short negative cache skips empty L2 (Redis/disk)
32
+ lookups after a miss.
33
+ - Data cache early-refreshes near TTL (same lead formula as HTML) and skips
34
+ repeated empty L2 reads the same way; `produceMs` is stored on entries.
35
+ - Image optimizer coalesces concurrent work for the same `url+w+q` and caches
36
+ DNS safety checks for 60s.
37
+ - Disk/Redis L2 decode uses async brotli (no sync decompress on the event
38
+ loop). `cache()` skips `JSON.stringify` for a single primitive argument.
39
+ - `preconnectHints()` adds `build.assets.prefix` origin automatically. Build
40
+ runs Fonts and Icon sprite in parallel; precompress writes `.br`/`.gz` in
41
+ parallel per file.
42
+ - Cold SSR without prewarm: `warmRenderEngine()` runs before listen; page
43
+ `.jsk` templates load lazily (layout + `components/*` stay eager); production
44
+ `ensureTemplatesCompiled` returns on manifest without walking sources.
45
+ - L2 read races a 50ms budget then produces locally; Redis `storeEncoded`
46
+ defaults to on.
47
+ - `renderPage` runs the body with `page.metadata` in parallel with the
48
+ `metadata` hook + `layoutContext` chain.
49
+ - `sendHtml` always negotiates brotli/gzip for HTML (including
50
+ `fragment` / `private`), instead of relying on compression middleware.
51
+ - Transient upstream `notFound()` defaults to an immediate 503 with
52
+ background retry (`cache().transientRetry.background`; set `false` for the
53
+ old sync loop). `warmRenderEngine` / `resetRenderEngine` are exported.
54
+
55
+ ### Breaking
56
+
57
+ - Redis `storeEncoded` defaults to `true` (was `false`). Opt out with
58
+ `cache().redis.storeEncoded: false` if L2 size must stay minimal.
59
+ - Transient retry no longer delays the visitor by default
60
+ (`background: true`). Set `cache().transientRetry.background: false` to
61
+ wait for sync retries before responding.
29
62
 
30
63
  ### Fixed
31
64
 
65
+ - `hydrate()` mounts zero-box hosts (`hidden` / no layout box) through
66
+ `mountWhenReady()` instead of calling `mountIsland()` immediately, so
67
+ `data-island-idle` on a hidden host still waits for `load` plus idle time.
68
+ `data-island-eager` is unchanged (mounts right away).
32
69
  - S3 PutObject uses path-style URLs for bucket names that contain dots
33
70
  (e.g. `static.investvio.dev`). Virtual-hosted
34
71
  `bucket.s3.region.amazonaws.com` fails TLS because the cert only covers
@@ -36,6 +73,10 @@ one is listed under a **Breaking** heading.
36
73
 
37
74
  ### Added
38
75
 
76
+ - `stylesheets({ blocking: false })` and `<Stylesheets :blocking="false" />`
77
+ emit each stylesheet with `media="print"` and an `onload` that switches it
78
+ to `all`, plus a `<noscript>` fallback, so the links do not block rendering.
79
+ The default stays render-blocking.
39
80
  - Dev tools **Server** tab can clear the HTML cache for the open page, every
40
81
  HTML entry, or the data cache. Live stats include HTML/data entry counts;
41
82
  new endpoints under `devBasePath`: `POST /cache/clear-html`,
package/docs/02-mimari.md CHANGED
@@ -155,9 +155,17 @@ kapsamadığı için sheet gelince sayfa yeniden akıyordu (bir liste sayfasınd
155
155
  ziyarette zaten `immutable` önbellekten geliyor.
156
156
 
157
157
  Sayfaya özel kurallar için `styles/pages/*.css` + controller `styles: [...]`
158
- eklenebilir ([08-build.md](./08-build.md)); bunlar da render-blocking basılır
159
- ama yalnızca ilgili sayfada. Tailwind utility'leri global sheet'te kalır —
160
- sayfa sheet'inde tam `@import "tailwindcss"` utility çıktısını tekrarlar.
158
+ eklenebilir ([08-build.md](./08-build.md)); bunlar da varsayılan olarak
159
+ render-blocking basılır ama yalnızca ilgili sayfada. Tailwind utility'leri
160
+ global sheet'te kalır — sayfa sheet'inde tam `@import "tailwindcss"` utility
161
+ çıktısını tekrarlar.
162
+
163
+ Erken boyama isteyen bir layout bu varsayılandan çıkabilir:
164
+ `stylesheets({ blocking: false })` veya şablonda
165
+ `<Stylesheets :blocking="false" />`. Link `media="print"` ile iner, `onload`
166
+ onu `all` yapar; JS kapalıyken `<noscript>` içindeki normal link uygulanır.
167
+ Bu, yukarıdaki CLS takasının tersidir: ilk boyama erken gelir, sheet gelince
168
+ düzen kayabilir.
161
169
 
162
170
  Aynı mantık ikonlarda da var: her ikon için ayrı istek yerine, build zamanında
163
171
  yalnızca kaynakta kullanılan sembollerden bir SVG sprite üretilir. Tüm Phosphor
@@ -15,17 +15,18 @@ route(controller)
15
15
  └─ produce()
16
16
  ├─ controller(ctx) → sayfa tanımı
17
17
  └─ renderPage(page)
18
- ├─ hooks.metadata(page) + page.metadata → metadata
19
18
  ├─ Promise.all([
20
- │ renderView(page.view, { …data, metadata }), → body
21
- │ hooks.layoutContext({ pathname, metadata }), → context
19
+ │ renderView(page.view, { …data, page.metadata }), → body
20
+ │ hooks.metadata(page) + page.metadata → metadata
21
+ │ └─ hooks.layoutContext({ pathname, metadata }) → context
22
22
  │ ])
23
23
  └─ layout (.jsk derlenmiş veya .ejs) → tam HTML
24
24
  ```
25
25
 
26
- Layout bağlamı ve gövde **paralel** üretilir. Sebebi ölçümden geliyor:
27
- navigasyon çoğu projede upstream'den geliyor ve gövde render'ıyla sırayla
28
- beklemek her sayfaya gereksiz gecikme ekliyor.
26
+ Gövde, `hooks.metadata` + `layoutContext` zinciriyle **paralel** üretilir.
27
+ Şablon locals'ında `metadata` önce controller'ın `page.metadata`'sıdır; layout
28
+ birleşik (hook + sayfa) metadata'yı alır. Navigasyon çoğu projede upstream'den
29
+ gelir — gövdeyi metadata zincirinin bitmesini bekletmek her MISS'e gecikme ekler.
29
30
 
30
31
  ## `.jsk` — build-time derlenmiş şablonlar
31
32
 
@@ -112,7 +113,7 @@ script ve JSON-LD döngüleri için yerleşikler:
112
113
 
113
114
  | Etiket | Props | Çıktı |
114
115
  | --- | --- | --- |
115
- | `Stylesheets` | `styles` | `app.css` + sayfa sheet’leri (`data-jskelet-css`) |
116
+ | `Stylesheets` | `styles`, `blocking` | `app.css` + sayfa sheet’leri (`data-jskelet-css`). `blocking` varsayılan `true` |
116
117
  | `BodyScripts` | `entries`, `devtools`, `devBasePath` | `main.js`, entry’ler, isteğe bağlı overlay |
117
118
  | `JsonLd` | `items` (`structuredData`) | `application/ld+json` script’leri |
118
119
 
@@ -180,9 +181,12 @@ Dikkat edilecek noktalar:
180
181
 
181
182
  - **`extraHead` en başta.** Kaynak ipuçlarını (`preconnect`, LCP `preload`)
182
183
  geciktirmek doğrudan LCP'ye yazılır.
183
- - **Global `app.css` render-blocking** ve gerekçesi
184
+ - **Global `app.css` varsayılan olarak render-blocking** ve gerekçesi
184
185
  [02-mimari.md](./02-mimari.md)'de. Controller `styles: [...]` ile ek sayfa
185
- sheet'leri de aynı şekilde basılır. Build çalışmadıysa `hasAsset` false olur
186
+ sheet'leri de aynı şekilde basılır. `<Stylesheets :blocking="false" />`
187
+ (`stylesheets({ blocking: false })`) linkleri `media="print"` + `onload`
188
+ ile basar; JS kapalıyken `<noscript>` yedeği uygulanır. Boyama beklenmez,
189
+ sheet gelince düzen kayabilir. Build çalışmadıysa `hasAsset` false olur
186
190
  ve etiket hiç basılmaz.
187
191
  - **`hasAsset` kontrolleri** build eksikken sayfanın 404 veren dosyaları
188
192
  istememesini sağlar (`Stylesheets` / `BodyScripts` içinde).
@@ -431,6 +435,32 @@ icon({ name: "ArrowRight", weight: "bold", size: 20, class: "text-slate-500" });
431
435
  çalışma anında hesaplanan bir çağrı eksik sembole işaret ederse ekranda
432
436
  sessizce boşluk kalır ([08-build.md](./08-build.md)).
433
437
 
438
+ ### `stylesheets(props)`
439
+
440
+ Layout `<head>` içindeki global `app.css` ve controller `styles` dizisi.
441
+ Doğrudan çağırmak yerine yerleşik etiket kullanılır:
442
+
443
+ ```jsk
444
+ <Stylesheets :styles="styles" />
445
+ <Stylesheets :styles="styles" :blocking="false" />
446
+ ```
447
+
448
+ ```js
449
+ stylesheets({ styles: ["home.css"] });
450
+ stylesheets({ styles: ["home.css"], blocking: false });
451
+ ```
452
+
453
+ - `styles` manifest anahtarlarıdır (`"home.css"` → `styles/pages/home.css`).
454
+ `hasAsset` false olanlar basılmaz.
455
+ - `blocking` varsayılan `true`: `<link rel="stylesheet" data-jskelet-css="…">`.
456
+ Yalnızca boolean `false` beklemeyi kapatır. Çıktı `media="print"` ve
457
+ `onload="this.media='all'"` olur; eşleşmeyen media boyamayı bekletmez.
458
+ `<noscript>` içindeki ikinci link, JS kapalıyken stillerin print'te
459
+ kalmamasını sağlar. Inline `onload` çalışmayan bir CSP'de (`script-src`
460
+ içinde `'unsafe-inline'` veya `script-src-attr` yok) sheet `print`'te kalır.
461
+ - `data-jskelet-css` dev hot-swap'ın doğru linki seçmesi içindir; her iki
462
+ modda da yazılır.
463
+
434
464
  ### `preloadImage(props)`
435
465
 
436
466
  ```js
@@ -108,10 +108,14 @@ ve doğrudan boş zamana kaydırılır.
108
108
  `hidden` bir drawer ya da dialog'un düzen kutusu yoktur ve
109
109
  `IntersectionObserver` onu **asla** bildirmez. Bu yüzden `hydrate()` ölçümleri
110
110
  tek seferde okur (`getClientRects().length > 0`) ve kutusu olmayan elementleri
111
- gözlemciye vermek yerine doğrudan bağlar. Ölçümlerin tek seferde okunması da
112
- bilinçli: araya yazma girmediği için tek reflow olur.
113
-
114
- Pratik sonucu: bir modal'ı `hidden` başlatabilirsiniz, island'ı yine bağlanır.
111
+ gözlemciye vermek yerine `mountWhenReady()` ile bağlar — yani
112
+ `data-island-idle` gizli host'ta da geçerlidir (`load` + boş zaman); idle
113
+ olmayanlar boş zamana kaydırılır. Ölçümlerin tek seferde okunması bilinçli:
114
+ araya yazma girmediği için tek reflow olur. `data-island-eager` hâlâ hemen
115
+ bağlanır.
116
+
117
+ Pratik sonucu: bir modal'ı `hidden` başlatabilirsiniz, island'ı yine bağlanır;
118
+ ağır bir gizli host için `data-island-idle` LCP ile yarışmayı keser.
115
119
 
116
120
  ## `client/` dizini
117
121
 
package/docs/06-cache.md CHANGED
@@ -560,39 +560,40 @@ geçici bir kota sorunu TTL boyunca "bu sayfa yok" cevabına dönüşür. Arama
560
560
  için bu kalıcı bir kayıp.
561
561
 
562
562
  Framework bu durumu ayırır: render sırasında **geçici** bir upstream hatası
563
- varsa `notFound()` 404 olarak servis edilmez. Sırayla:
563
+ varsa `notFound()` 404 olarak servis edilmez. Varsayılan (`background: true`):
564
564
 
565
- 1. Sayfa kısa bir beklemeden sonra **yeniden denenir** (varsayılan bir kez,
566
- 300 ms sonra). Deneme kendi upstream ve istek içi cache bağlamında koşar;
567
- ilk turun hatası da memoize edilmiş boş cevabı da ikinci turu etkilemez.
568
- 2. İkinci tur sayfayı üretebilirse ziyaretçi **gerçek içeriği** görür ve çıktı
569
- normal şekilde önbelleğe girer. Isıtma günlükleri bunun sık olduğunu
570
- gösteriyor: aynı yol saniyeler sonra 200 dönüyor.
571
- 3. Denemeler tükendiyse yanıt `503` olur — önbelleğe girmez, `Retry-After`
572
- taşır, sonraki istek yine gerçek içeriği üretebilir.
565
+ 1. Ziyaretçiye hemen önbelleğe girmeyen bir `503` + `Retry-After` gider.
566
+ 2. Aynı yol arka planda yeniden denenir (varsayılan bir kez, 300 ms sonra).
567
+ Deneme kendi upstream ve istek içi cache bağlamında koşar; ilk turun hatası
568
+ da memoize edilmiş boş cevabı da ikinci turu etkilemez. Başarılı olursa
569
+ sonraki istek L1/L2 HIT alır.
570
+ 3. `background: false` eski senkron döngüyü açar: önce bekle + dene, başarısızsa
571
+ 503; başarılıysa ziyaretçi gerçek içeriği görür.
573
572
 
574
573
  | Render sırasında | `notFound()` sonucu |
575
574
  | --- | --- |
576
- | Geçici hata var (`429`, `5xx`, ağ hatası) | Tekrar dene → başarılıysa sayfa; hâlâ olmuyorsa `503`, `Retry-After: 30`, `no-store` |
575
+ | Geçici hata + `background: true` (varsayılan) | Hemen `503`, `Retry-After: 30`, `no-store`; retry arka planda |
576
+ | Geçici hata + `background: false` | Tekrar dene → başarılıysa sayfa; değilse `503` |
577
577
  | Tekrar denemede upstream sağlam cevap verip "yok" dedi | Normal `404` |
578
578
  | Kalıcı hata var (`404`, `403`…) ya da hata yok | Normal `404`, tekrar denenmez |
579
579
 
580
580
  Log satırları:
581
581
 
582
582
  ```
583
- [render] /haber/x returned notFound() while upstream is failing (429 /api/...), retrying (1/1)
583
+ [render] /haber/x returned notFound() while upstream is failing (429 /api/...), retrying in background (1×)
584
584
  [render] /haber/x could not be produced, upstream is still failing (429 /api/...), serving an uncached 503 instead of a 404
585
585
  ```
586
586
 
587
587
  Yani **var olan bir sayfa hiçbir koşulda 404'e dönüşmez**: ya gerçek içerik
588
- gelir, ya önbelleğe girmeyen bir 503. Hiçbir şey "yok" olarak dondurulmaz.
588
+ gelir (senkron retry veya sonraki istek), ya önbelleğe girmeyen bir 503.
589
+ Hiçbir şey "yok" olarak dondurulmaz.
589
590
 
590
591
  Tekrar denemenin maliyeti upstream'e binen ikinci bir istek turudur; bu yüzden
591
592
  varsayılan tek deneme. Ayar `cache().transientRetry`:
592
593
 
593
594
  ```js
594
595
  cache: {
595
- transientRetry: { attempts: 2, delayMs: 500 },
596
+ transientRetry: { attempts: 2, delayMs: 500, background: true },
596
597
  }
597
598
  ```
598
599
 
@@ -886,9 +887,10 @@ bir sonraki yazışta siler. `clearHtmlCache()` ve invalidation dosyayı da dü
886
887
  bile geçerli olan bu kural paylaşımlı kademede daha da kritik: sızması, bir
887
888
  kullanıcının HTML'ini tüm kümeye servis etmek olur. `degraded` render ve 200
888
889
  dışındaki durum kodları da paylaşılmaz.
889
- - **Sıkıştırılmış gövdeler varsayılan olarak yerel kalır.** `storeEncoded: true`
890
- ile açılabilir, ama girdi başına boyutu iki-üç katına çıkarır; brotli'yi
891
- yeniden üretmek çoğu zaman Redis'ten indirmekten ucuzdur.
890
+ - **Sıkıştırılmış gövdeler varsayılan olarak paylaşılır.** `storeEncoded`
891
+ (varsayılan `true`) soğuk replica'nın L2 HIT'te yeniden brotli ödemesini
892
+ önler; girdi başına boyutu iki-üç katına çıkarır. L2 boyutunu kısıtlamak
893
+ için `storeEncoded: false` verin.
892
894
  - **Yumuşak invalidation Redis kopyasını siler.** Bayatlatmanın Redis karşılığı
893
895
  her anahtar için oku-değiştir-yaz turu demek ve bir webhook binlerce anahtarı
894
896
  birden düşürüyor. Silmenin bedeli, o yolu hiç görmemiş bir kopyanın bir kez
@@ -772,7 +772,7 @@ Ayrıntı: [03-routing.md](./03-routing.md).
772
772
  **Tip:**
773
773
  `() => { html?: Record<string, number>, staleWhileRevalidate?: number, query?: Record<string, string[] | true>, vary?: { host?: boolean, headers?: string[], fn?: (req) => string | null }, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, upstream?: object, redis?: object, prewarm?: false | object }` —
774
774
  **Varsayılan:**
775
- `{ html: {}, staleWhileRevalidate: 60, query: {}, vary: { host: false }, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, upstream: { rate: 0 }, redis: { enabled: false }, prewarm: { enabled: true, mode: "classic", max: 400, intervalSeconds: 0, origins: [] } }` (veya `prewarm: false` → `mode: "off"`)
775
+ `{ html: {}, staleWhileRevalidate: 60, query: {}, vary: { host: false }, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300, background: true }, upstream: { rate: 0 }, redis: { enabled: false, storeEncoded: true }, prewarm: { enabled: true, mode: "classic", max: 400, intervalSeconds: 0, origins: [] } }` (veya `prewarm: false` → `mode: "off"`)
776
776
 
777
777
  ### `cache().html`
778
778
 
@@ -902,13 +902,14 @@ kaydedilecek bir şey yok; kapatmak bağlam kurma maliyetini de kaldırır.
902
902
 
903
903
  ### `cache().transientRetry`
904
904
 
905
- **Tip:** `{ attempts?: number, delayMs?: number } | false` —
906
- **Varsayılan:** `{ attempts: 1, delayMs: 300 }`
905
+ **Tip:** `{ attempts?: number, delayMs?: number, background?: boolean } | false` —
906
+ **Varsayılan:** `{ attempts: 1, delayMs: 300, background: true }`
907
907
 
908
908
  Geçici bir upstream hatası yüzünden `notFound()` çağrılan sayfa kaç kez daha
909
- denenir. Amaç var olan bir sayfanın 404'e dönüşmemesi; denemeler tükenirse yanıt
910
- önbelleğe girmeyen bir 503 olur. `false` ya da `attempts: 0` tekrarı kapatır.
911
- Ayrıntı: [06-cache.md](./06-cache.md).
909
+ denenir. Amaç var olan bir sayfanın 404'e dönüşmemesi. `background: true`
910
+ (varsayılan) ziyaretçiye hemen 503 verir ve retry'yi arka planda koşturur;
911
+ `false` eski senkron döngüyü açar (önce bekle + dene, sonra yanıt). `false` ya
912
+ da `attempts: 0` tekrarı kapatır. Ayrıntı: [06-cache.md](./06-cache.md).
912
913
 
913
914
  ### `cache().upstream`
914
915
 
@@ -959,7 +960,7 @@ kurulmadıysa ya da bağlanılamıyorsa uyarı basılır ve site bellek içi ön
959
960
  | `keyPrefix` | `string` | `"_jskelet"` | Anahtar düzeninin kökü |
960
961
  | `html` | `boolean` | `true` | HTML gövdeleri paylaşılsın mı |
961
962
  | `data` | `boolean` | `true` | `withDataCache` girdileri paylaşılsın mı |
962
- | `storeEncoded` | `boolean` | `false` | Brotli/gzip gövdeleri de paylaşılsın mı; girdi başına boyutu iki-üç katına çıkarır |
963
+ | `storeEncoded` | `boolean` | `true` | Brotli/gzip gövdeleri de paylaşılsın mı; girdi başına boyutu iki-üç katına çıkarır |
963
964
  | `events` | `boolean` | `true` | pub/sub üzerinden invalidation yayını |
964
965
  | `commandTimeoutMs` | `number` | `200` | Tek bir komutun en fazla bekletebileceği süre |
965
966
 
package/docs/08-build.md CHANGED
@@ -141,8 +141,10 @@ minifikasyon → `writeAsset("app.css", …)`.
141
141
  şekilde yavaşlatıyor.
142
142
  - **lightningcss opsiyoneldir:** yoksa Tailwind'in kendi çıktısı kullanılır,
143
143
  yalnızca birkaç kB daha büyük olur.
144
- - Global çıktı `app.css`'tir ve layout onu her sayfada render-blocking olarak
145
- yükler. Ayrı bir "critical CSS" üretilmemesinin ölçüm gerekçesi
144
+ - Global çıktı `app.css`'tir ve layout onu her sayfada varsayılan olarak
145
+ render-blocking yükler. `<Stylesheets :blocking="false" />` bu beklemeyi
146
+ kapatır ([04-render-ve-sablonlar.md](./04-render-ve-sablonlar.md)). Ayrı bir
147
+ "critical CSS" üretilmemesinin ölçüm gerekçesi
146
148
  [02-mimari.md](./02-mimari.md)'de.
147
149
 
148
150
  ### Sayfa stylesheet'leri (`styles/pages/`)
@@ -166,6 +168,7 @@ kalmalı — dosyada tam `@import "tailwindcss"` utility çıktısını tekrarla
166
168
 
167
169
  Layout `app.css`ten sonra `styles` dizisindeki her sheet için
168
170
  `<link data-jskelet-css="…">` basar; `hasAsset` false ise etiket yok.
171
+ `blocking: false` aynı linklere `media="print"` ve `onload` ekler.
169
172
 
170
173
  ### `@source` direktifleri zorunludur
171
174
 
@@ -160,9 +160,16 @@ both faster and free of CLS; on the second visit it already comes from the
160
160
 
161
161
  Page-specific rules can use `styles/pages/*.css` plus controller
162
162
  `styles: [...]` ([08-build.md](./08-build.md)); those are also render-blocking
163
- but only on the pages that ask for them. Keep Tailwind utilities in the global
164
- sheet — a full `@import "tailwindcss"` in a page sheet duplicates utility
165
- output.
163
+ by default, but only on the pages that ask for them. Keep Tailwind utilities
164
+ in the global sheet — a full `@import "tailwindcss"` in a page sheet
165
+ duplicates utility output.
166
+
167
+ A layout that wants an earlier first paint can leave this default:
168
+ `stylesheets({ blocking: false })`, or `<Stylesheets :blocking="false" />`
169
+ in the template. The link downloads with `media="print"`, `onload` switches
170
+ it to `all`, and a plain link inside `<noscript>` applies when JS is off.
171
+ That is the reverse of the CLS tradeoff above: first paint arrives sooner,
172
+ and the layout can shift when the sheet arrives.
166
173
 
167
174
  The same logic applies to icons: instead of a separate request per icon, an SVG
168
175
  sprite is produced at build time from only the symbols actually used in the
@@ -16,17 +16,19 @@ route(controller)
16
16
  └─ produce()
17
17
  ├─ controller(ctx) → page definition
18
18
  └─ renderPage(page)
19
- ├─ hooks.metadata(page) + page.metadata → metadata
20
19
  ├─ Promise.all([
21
- │ renderView(page.view, { …data, metadata }), → body
22
- │ hooks.layoutContext({ pathname, metadata }), → context
20
+ │ renderView(page.view, { …data, page.metadata }), → body
21
+ │ hooks.metadata(page) + page.metadata → metadata
22
+ │ └─ hooks.layoutContext({ pathname, metadata }) → context
23
23
  │ ])
24
24
  └─ layout (.jsk compiled or .ejs) → full HTML
25
25
  ```
26
26
 
27
- The layout context and the body are produced **in parallel**. The reason comes
28
- from measurement: in most projects navigation comes from upstream, and waiting
29
- for it in sequence with the body render adds needless latency to every page.
27
+ The body runs **in parallel** with the `hooks.metadata` + `layoutContext`
28
+ chain. Template locals see the controller's `page.metadata` first; the layout
29
+ receives the merged (hook + page) metadata. Navigation often comes from
30
+ upstream — waiting for the metadata chain before starting the body adds latency
31
+ to every MISS.
30
32
 
31
33
  ## `.jsk` — build-time compiled templates
32
34
 
@@ -178,10 +180,14 @@ Points to watch:
178
180
 
179
181
  - **`extraHead` comes first.** Delaying resource hints (`preconnect`, LCP
180
182
  `preload`) writes straight into LCP.
181
- - **`<Stylesheets />` emits global `app.css` (render-blocking)** plus controller
182
- `styles: [...]`, with the reasoning in
183
- [02-architecture.md](./02-architecture.md). If the build has not run,
184
- `hasAsset` is false inside the tag and nothing is emitted.
183
+ - **`<Stylesheets />` emits global `app.css` render-blocking by default**,
184
+ plus controller `styles: [...]`, with the reasoning in
185
+ [02-architecture.md](./02-architecture.md).
186
+ `<Stylesheets :blocking="false" />` (`stylesheets({ blocking: false })`)
187
+ emits the links with `media="print"` plus `onload`, and a `<noscript>`
188
+ fallback when JS is off. Painting is not held, and the layout can shift
189
+ when the sheet arrives. If the build has not run, `hasAsset` is false
190
+ inside the tag and nothing is emitted.
185
191
  - **`<BodyScripts />` emits `main.js`, page `entries`, and the
186
192
  development-only overlay.** The overlay script exists only when
187
193
  `NODE_ENV=development`; it is absent from production output.
@@ -434,6 +440,34 @@ icon({ name: "ArrowRight", weight: "bold", size: 20, class: "text-slate-500" });
434
440
  points at a missing symbol, the screen is silently left blank
435
441
  ([08-build.md](./08-build.md)).
436
442
 
443
+ ### `stylesheets(props)`
444
+
445
+ Global `app.css` and the controller `styles` list for the layout `<head>`.
446
+ The built-in tag is the usual call site:
447
+
448
+ ```jsk
449
+ <Stylesheets :styles="styles" />
450
+ <Stylesheets :styles="styles" :blocking="false" />
451
+ ```
452
+
453
+ ```js
454
+ stylesheets({ styles: ["home.css"] });
455
+ stylesheets({ styles: ["home.css"], blocking: false });
456
+ ```
457
+
458
+ - `styles` are manifest keys (`"home.css"` → `styles/pages/home.css`). Names
459
+ for which `hasAsset` is false are omitted.
460
+ - `blocking` defaults to `true`:
461
+ `<link rel="stylesheet" data-jskelet-css="…">`. Only the boolean `false`
462
+ turns waiting off. The output is `media="print"` and
463
+ `onload="this.media='all'"`; a non-matching media does not hold painting.
464
+ A second link inside `<noscript>` keeps styles from staying on `print`
465
+ when JS is off. Under a CSP that blocks inline handlers (no
466
+ `'unsafe-inline'` in `script-src`, and no `script-src-attr`) the sheet
467
+ stays on `print`.
468
+ - `data-jskelet-css` is how dev hot-swap picks the right link; it is written
469
+ in both modes.
470
+
437
471
  ### `preloadImage(props)`
438
472
 
439
473
  ```js
@@ -111,12 +111,16 @@ skipped and it is deferred straight to idle time.
111
111
 
112
112
  A `hidden` drawer or dialog has no layout box, and `IntersectionObserver`
113
113
  **never** reports it. That is why `hydrate()` reads its measurements in one go
114
- (`getClientRects().length > 0`) and mounts elements without a box directly
115
- instead of handing them to the observer. Reading the measurements in one go is
116
- deliberate too: since no write comes in between there is only a single reflow.
114
+ (`getClientRects().length > 0`) and mounts zero-box elements via
115
+ `mountWhenReady()` instead of handing them to the observer — so
116
+ `data-island-idle` still applies on a hidden host (`load` plus idle time);
117
+ non-idle hosts are deferred to idle time. Reading the measurements in one go
118
+ is deliberate: since no write comes in between there is only a single reflow.
119
+ `data-island-eager` still mounts immediately.
117
120
 
118
121
  The practical consequence: you can start a modal as `hidden` and its island
119
- will still mount.
122
+ will still mount; use `data-island-idle` on a heavy hidden host so it does not
123
+ compete with LCP.
120
124
 
121
125
  ## The `client/` directory
122
126
 
@@ -570,39 +570,41 @@ cache, a temporary quota problem becomes a "this page does not exist" answer for
570
570
  the whole TTL. For a search engine that is a permanent loss.
571
571
 
572
572
  The framework separates the two cases: if a **transient** upstream failure
573
- happened during the render, `notFound()` is not served as a 404. In order:
573
+ happened during the render, `notFound()` is not served as a 404. Default
574
+ (`background: true`):
574
575
 
575
- 1. The page is **retried** after a short delay (once by default, after 300 ms).
576
+ 1. The visitor gets an immediate uncached `503` + `Retry-After`.
577
+ 2. The same path is retried in the background (once by default, after 300 ms).
576
578
  The retry runs in its own upstream and per-request cache scope, so neither
577
- the first round's failure nor its memoized empty answers affect it.
578
- 2. If the second round can produce the page, the visitor sees the **real
579
- content** and the output is cached normally. Warm-up logs show this is
580
- common: the same path returns 200 seconds later.
581
- 3. If the retries are exhausted the response is a `503` — not cached, carrying
582
- `Retry-After`, and the next request can still produce the real content.
579
+ the first round's failure nor its memoized empty answers affect it. On
580
+ success the next request is an L1/L2 HIT.
581
+ 3. `background: false` restores the old sync loop: wait + retry first; on
582
+ success the visitor sees real content, otherwise a 503.
583
583
 
584
584
  | During the render | Result of `notFound()` |
585
585
  | --- | --- |
586
- | A transient failure exists (`429`, `5xx`, network error) | Retry → the page if it succeeds; otherwise `503`, `Retry-After: 30`, `no-store` |
586
+ | Transient failure + `background: true` (default) | Immediate `503`, `Retry-After: 30`, `no-store`; retry in background |
587
+ | Transient failure + `background: false` | Retry → the page if it succeeds; otherwise `503` |
587
588
  | The retry got a clean answer saying "not there" | A normal `404` |
588
589
  | A permanent failure (`404`, `403`…) or no failure | A normal `404`, no retry |
589
590
 
590
591
  The log lines:
591
592
 
592
593
  ```
593
- [render] /news/x returned notFound() while upstream is failing (429 /api/...), retrying (1/1)
594
+ [render] /news/x returned notFound() while upstream is failing (429 /api/...), retrying in background (1×)
594
595
  [render] /news/x could not be produced, upstream is still failing (429 /api/...), serving an uncached 503 instead of a 404
595
596
  ```
596
597
 
597
- So **an existing page never turns into a 404**: either the real content arrives,
598
- or an uncached 503 does. Nothing is frozen as "missing".
598
+ So **an existing page never turns into a 404**: either the real content arrives
599
+ (sync retry or the next request), or an uncached 503 does. Nothing is frozen as
600
+ "missing".
599
601
 
600
602
  The cost of a retry is a second round of requests on upstream, which is why the
601
603
  default is a single attempt. The setting is `cache().transientRetry`:
602
604
 
603
605
  ```js
604
606
  cache: {
605
- transientRetry: { attempts: 2, delayMs: 500 },
607
+ transientRetry: { attempts: 2, delayMs: 500, background: true },
606
608
  }
607
609
  ```
608
610
 
@@ -905,9 +907,9 @@ file too.
905
907
  rule already holds in a single process, but it matters far more in a shared
906
908
  tier: a leak would mean serving one user's HTML to the whole cluster.
907
909
  `degraded` renders and non-200 status codes are not shared either.
908
- - **Compressed bodies stay local by default.** `storeEncoded: true` turns this
909
- on, but it doubles or triples the size per entry; recomputing brotli is
910
- usually cheaper than downloading it from Redis.
910
+ - **Compressed bodies are shared by default.** `storeEncoded` (default `true`)
911
+ keeps a cold replica from paying brotli again on an L2 HIT; it doubles or
912
+ triples the size per entry. Set `storeEncoded: false` to keep L2 lean.
911
913
  - **A soft invalidation deletes the Redis copy.** Staling in Redis would mean a
912
914
  read-modify-write round per key, and a webhook drops thousands of keys at
913
915
  once. The cost of deleting is one render on a replica that never saw that
@@ -786,7 +786,7 @@ Details: [03-routing.md](./03-routing.md).
786
786
  **Type:**
787
787
  `() => { html?: Record<string, number>, staleWhileRevalidate?: number, query?: Record<string, string[] | true>, vary?: { host?: boolean, headers?: string[], fn?: (req) => string | null }, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, upstream?: object, redis?: object, prewarm?: false | object }` —
788
788
  **Default:**
789
- `{ html: {}, staleWhileRevalidate: 60, query: {}, vary: { host: false }, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, upstream: { rate: 0 }, redis: { enabled: false }, prewarm: { enabled: true, mode: "classic", max: 400, intervalSeconds: 0, origins: [] } }` (or `prewarm: false` → `mode: "off"`)
789
+ `{ html: {}, staleWhileRevalidate: 60, query: {}, vary: { host: false }, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300, background: true }, upstream: { rate: 0 }, redis: { enabled: false, storeEncoded: true }, prewarm: { enabled: true, mode: "classic", max: 400, intervalSeconds: 0, origins: [] } }` (or `prewarm: false` → `mode: "off"`)
790
790
 
791
791
  ### `cache().html`
792
792
 
@@ -920,13 +920,15 @@ setting up the context.
920
920
 
921
921
  ### `cache().transientRetry`
922
922
 
923
- **Type:** `{ attempts?: number, delayMs?: number } | false` —
924
- **Default:** `{ attempts: 1, delayMs: 300 }`
923
+ **Type:** `{ attempts?: number, delayMs?: number, background?: boolean } | false` —
924
+ **Default:** `{ attempts: 1, delayMs: 300, background: true }`
925
925
 
926
926
  How many extra times a page is tried when `notFound()` was called because of a
927
927
  transient upstream failure. The point is that an existing page never turns into
928
- a 404; if the retries are exhausted the response is an uncached 503. `false` or
929
- `attempts: 0` disables the retry. Details: [06-caching.md](./06-caching.md).
928
+ a 404. `background: true` (default) returns 503 immediately and runs the retry
929
+ in the background; `false` restores the old sync loop (wait + retry, then
930
+ respond). `false` or `attempts: 0` disables the retry. Details:
931
+ [06-caching.md](./06-caching.md).
930
932
 
931
933
  ### `cache().upstream`
932
934
 
@@ -977,7 +979,7 @@ the site keeps running on the in-process cache.
977
979
  | `keyPrefix` | `string` | `"_jskelet"` | Root of the key layout |
978
980
  | `html` | `boolean` | `true` | Whether HTML bodies are shared |
979
981
  | `data` | `boolean` | `true` | Whether `withDataCache` entries are shared |
980
- | `storeEncoded` | `boolean` | `false` | Whether brotli/gzip bodies are shared too; doubles or triples the size per entry |
982
+ | `storeEncoded` | `boolean` | `true` | Whether brotli/gzip bodies are shared too; doubles or triples the size per entry |
981
983
  | `events` | `boolean` | `true` | Invalidation broadcast over pub/sub |
982
984
  | `commandTimeoutMs` | `number` | `200` | At most how long a single command may block |
983
985
 
@@ -145,9 +145,11 @@ The pipeline: PostCSS + `@tailwindcss/postcss` → minification with lightningcs
145
145
  noticeably.
146
146
  - **lightningcss is optional:** without it, Tailwind's own output is used, and it
147
147
  is only a few kB bigger.
148
- - The global output is `app.css` and the layout loads it render-blocking on every
149
- page. The measurement-based reasoning for not producing a separate "critical
150
- CSS" is in [02-architecture.md](./02-architecture.md).
148
+ - The global output is `app.css` and the layout loads it render-blocking on
149
+ every page by default. `<Stylesheets :blocking="false" />` turns that wait
150
+ off ([04-rendering.md](./04-rendering.md)). The measurement-based reasoning
151
+ for not producing a separate "critical CSS" is in
152
+ [02-architecture.md](./02-architecture.md).
151
153
 
152
154
  ### Page stylesheets (`styles/pages/`)
153
155
 
@@ -170,7 +172,8 @@ Page CSS is for page-specific rules. Keep Tailwind utilities in the global
170
172
  sheet — a full `@import "tailwindcss"` in a page file duplicates utility output.
171
173
 
172
174
  After `app.css`, the layout emits a `<link data-jskelet-css="…">` for each name
173
- in `styles`; if `hasAsset` is false the tag is omitted.
175
+ in `styles`; if `hasAsset` is false the tag is omitted. `blocking: false`
176
+ adds `media="print"` and `onload` to those same links.
174
177
 
175
178
  ### `@source` directives are mandatory
176
179
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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",
@@ -68,14 +68,29 @@ async function task(label, run) {
68
68
  }
69
69
  }
70
70
 
71
- if (config.fonts?.length) {
72
- const { copyFonts } = await import("./tasks/fonts.mjs");
73
- Object.assign(manifest, await task("Fonts", () => copyFonts(config)));
74
- }
75
-
76
- if (config.icons !== false) {
77
- const { buildIconSprite } = await import("./tasks/icons.mjs");
78
- Object.assign(manifest, await task("Icon sprite", () => buildIconSprite(config)));
71
+ // Fonts ve icon sprite birbirine bağlı değil — paralel.
72
+ {
73
+ /** @type {Promise<Record<string, string> | void>[]} */
74
+ const parallel = [];
75
+ if (config.fonts?.length) {
76
+ parallel.push(
77
+ (async () => {
78
+ const { copyFonts } = await import("./tasks/fonts.mjs");
79
+ return task("Fonts", () => copyFonts(config));
80
+ })(),
81
+ );
82
+ }
83
+ if (config.icons !== false) {
84
+ parallel.push(
85
+ (async () => {
86
+ const { buildIconSprite } = await import("./tasks/icons.mjs");
87
+ return task("Icon sprite", () => buildIconSprite(config));
88
+ })(),
89
+ );
90
+ }
91
+ for (const part of await Promise.all(parallel)) {
92
+ if (part) Object.assign(manifest, part);
93
+ }
79
94
  }
80
95
 
81
96
  if (fs.existsSync(config.dirs.styles)) {