jskelet 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/docs/04-render-ve-sablonlar.md +20 -9
- package/docs/05-islands.md +8 -4
- package/docs/06-cache.md +18 -16
- package/docs/07-yapilandirma.md +8 -7
- package/docs/en/04-rendering.md +21 -9
- package/docs/en/05-islands.md +8 -4
- package/docs/en/06-caching.md +18 -16
- package/docs/en/07-configuration.md +8 -6
- package/package.json +1 -1
- package/src/build/build.mjs +23 -8
- package/src/build/tasks/precompress.mjs +51 -11
- package/src/client/registry.js +13 -3
- package/src/compile/codegen.js +70 -4
- package/src/compile/compile-all.js +18 -3
- package/src/compile/index.js +6 -1
- package/src/config/defaults.js +9 -5
- package/src/config/index.js +3 -3
- package/src/http/request-cache.js +19 -1
- package/src/index.js +2 -0
- package/src/server/assets.js +51 -22
- package/src/server/cache-blob.js +33 -0
- package/src/server/create-app.js +5 -0
- package/src/server/data-cache.js +77 -11
- package/src/server/disk-cache.js +2 -2
- package/src/server/head-hints.js +21 -9
- package/src/server/html-cache.js +169 -39
- package/src/server/image-optimizer.js +72 -28
- package/src/server/redis.js +2 -2
- package/src/server/render.js +183 -60
- package/src/templates/layout.jsk +4 -4
- package/src/templates/layout.render.js +2 -21
- package/src/views/components/loader.js +11 -6
- package/src/views/helpers/html.js +13 -5
- package/src/views/helpers/tags.js +21 -4
- package/types/client/registry.d.ts +2 -0
- package/types/compile/codegen.d.ts +9 -4
- package/types/compile/compile-all.d.ts +3 -1
- package/types/compile/index.d.ts +1 -1
- package/types/config/defaults.d.ts +8 -4
- package/types/config/index.d.ts +1 -0
- package/types/index.d.ts +1 -1
- package/types/server/cache-blob.d.ts +8 -0
- package/types/server/data-cache.d.ts +1 -0
- package/types/server/html-cache.d.ts +4 -5
- package/types/server/render.d.ts +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,8 +8,64 @@ 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
|
+
- 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.
|
|
62
|
+
|
|
11
63
|
### Fixed
|
|
12
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).
|
|
13
69
|
- S3 PutObject uses path-style URLs for bucket names that contain dots
|
|
14
70
|
(e.g. `static.investvio.dev`). Virtual-hosted
|
|
15
71
|
`bucket.s3.region.amazonaws.com` fails TLS because the cert only covers
|
|
@@ -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 }),
|
|
21
|
-
│ hooks.
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
|
@@ -73,6 +74,12 @@ controller data → import edilmiş render(data, helpers) → HTML
|
|
|
73
74
|
Atama, object literal ve rastgele fonksiyon çağrısı yok — mantık controller
|
|
74
75
|
veya JS bileşende kalır.
|
|
75
76
|
|
|
77
|
+
Derleyici bitişik statik HTML’i tek literal’de birleştirir ve **şablon
|
|
78
|
+
girintisini** düşürür: satır sonu içeren salt-whitespace metin düğümleri
|
|
79
|
+
çıktıya girmez; etiketler arasında yalnızca boşluk/tab varsa tek boşluk
|
|
80
|
+
kalır (inline aralık). `pre`, `textarea`, `script`, `style` ve `code`
|
|
81
|
+
içinde whitespace olduğu gibi korunur.
|
|
82
|
+
|
|
76
83
|
#### Şablon mu, bileşen mi?
|
|
77
84
|
|
|
78
85
|
EJS’den geçerken sınırı erken çizmek işe yarar:
|
|
@@ -199,11 +206,15 @@ Dikkat edilecek noktalar:
|
|
|
199
206
|
| `lang` | `string` | `context.lang` → `brand.lang` → `"en"` |
|
|
200
207
|
| `devtools` | `boolean` | `NODE_ENV === "development"` |
|
|
201
208
|
| `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 |
|
|
209
|
+
| `asset`, `hasAsset` | fonksiyon | Manifest erişimi (EJS local; `.jsk`’de ikinci `helpers` argümanı / yerleşik etiketler) |
|
|
210
|
+
| html/tags yardımcıları | fonksiyon | `esc`, `attrs`, `cx`, `cn`, `jsonScript`, `link`, `image`, `icon`, `preloadImage`, `toKebab` — aynı ayrım |
|
|
211
|
+
| `views/components/**` export'ları | fonksiyon | Otomatik kayıt (PascalCase etiket veya EJS local) |
|
|
205
212
|
| `hooks.layoutContext()` çıktısındaki her alan | — | Doğrudan local olur |
|
|
206
213
|
|
|
214
|
+
`.jsk` layout `(data, helpers)` aldığı için framework helpers nesnesini her
|
|
215
|
+
render’da `data`’ya kopyalamaz; EJS layout hâlâ bare helper adlarını local
|
|
216
|
+
olarak alır.
|
|
217
|
+
|
|
207
218
|
`pathname`'in boş varsayılanı bilinçli: `"/"` yazmak her sayfayı ana sayfa
|
|
208
219
|
sanıp logoyu `<h1>` olarak bastıran türde hatalara yol açıyor.
|
|
209
220
|
|
package/docs/05-islands.md
CHANGED
|
@@ -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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
563
|
+
varsa `notFound()` 404 olarak servis edilmez. Varsayılan (`background: true`):
|
|
564
564
|
|
|
565
|
-
1.
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
890
|
-
|
|
891
|
-
|
|
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
|
package/docs/07-yapilandirma.md
CHANGED
|
@@ -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
|
|
910
|
-
|
|
911
|
-
|
|
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` | `
|
|
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/en/04-rendering.md
CHANGED
|
@@ -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 }),
|
|
22
|
-
│ hooks.
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
|
@@ -74,6 +76,12 @@ The expression language is intentionally small (access, compare, ternary,
|
|
|
74
76
|
`.length`). No assignments, object literals, or arbitrary calls — keep logic in
|
|
75
77
|
controllers or JS components.
|
|
76
78
|
|
|
79
|
+
The compiler merges adjacent static HTML into fewer `+=` ops and strips
|
|
80
|
+
**template indentation**: whitespace-only text nodes that contain a newline are
|
|
81
|
+
dropped; a space/tab-only gap between siblings stays as a single space (inline
|
|
82
|
+
separation). Inside `pre`, `textarea`, `script`, `style`, and `code`, whitespace
|
|
83
|
+
is left unchanged.
|
|
84
|
+
|
|
77
85
|
#### Template or component?
|
|
78
86
|
|
|
79
87
|
When moving off EJS, draw the line early:
|
|
@@ -198,11 +206,15 @@ Points to watch:
|
|
|
198
206
|
| `lang` | `string` | `context.lang` → `brand.lang` → `"en"` |
|
|
199
207
|
| `devtools` | `boolean` | `NODE_ENV === "development"` |
|
|
200
208
|
| `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 |
|
|
209
|
+
| `asset`, `hasAsset` | function | Manifest access (EJS local; in `.jsk` the second `helpers` argument / built-in tags) |
|
|
210
|
+
| html/tags helpers | function | `esc`, `attrs`, `cx`, `cn`, `jsonScript`, `link`, `image`, `icon`, `preloadImage`, `toKebab` — same split |
|
|
211
|
+
| exports of `views/components/**` | function | Automatic registration (PascalCase tags or EJS locals) |
|
|
204
212
|
| every field returned by `hooks.layoutContext()` | — | Becomes a local directly |
|
|
205
213
|
|
|
214
|
+
Because a `.jsk` layout receives `(data, helpers)`, the framework does not copy
|
|
215
|
+
the helpers object into `data` on every render; EJS layouts still get bare
|
|
216
|
+
helper names as locals.
|
|
217
|
+
|
|
206
218
|
The empty default for `pathname` is deliberate: writing `"/"` leads to the kind
|
|
207
219
|
of bug where every page thinks it is the home page and renders the logo as an
|
|
208
220
|
`<h1>`.
|
package/docs/en/05-islands.md
CHANGED
|
@@ -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
|
|
115
|
-
instead of handing them to the observer
|
|
116
|
-
|
|
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
|
|
package/docs/en/06-caching.md
CHANGED
|
@@ -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.
|
|
573
|
+
happened during the render, `notFound()` is not served as a 404. Default
|
|
574
|
+
(`background: true`):
|
|
574
575
|
|
|
575
|
-
1. The
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
|
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
|
|
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
|
|
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
|
|
909
|
-
|
|
910
|
-
|
|
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
|
|
929
|
-
|
|
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` | `
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jskelet",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
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",
|
package/src/build/build.mjs
CHANGED
|
@@ -68,14 +68,29 @@ async function task(label, run) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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)) {
|
|
@@ -49,6 +49,33 @@ function collect(dir) {
|
|
|
49
49
|
return files;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* @param {Buffer} source
|
|
54
|
+
* @param {object} options
|
|
55
|
+
* @returns {Promise<Buffer>}
|
|
56
|
+
*/
|
|
57
|
+
function brotli(source, options) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
zlib.brotliCompress(source, options, (error, out) => {
|
|
60
|
+
if (error) reject(error);
|
|
61
|
+
else resolve(out);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {Buffer} source
|
|
68
|
+
* @returns {Promise<Buffer>}
|
|
69
|
+
*/
|
|
70
|
+
function gzip(source) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
zlib.gzip(source, { level: 9 }, (error, out) => {
|
|
73
|
+
if (error) reject(error);
|
|
74
|
+
else resolve(out);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
52
79
|
export async function precompressAssets() {
|
|
53
80
|
// Önceki turdan kalan kopyalar kaynak değişince bayatlamasın.
|
|
54
81
|
for (const file of collect(paths.assets)) {
|
|
@@ -57,21 +84,34 @@ export async function precompressAssets() {
|
|
|
57
84
|
}
|
|
58
85
|
}
|
|
59
86
|
|
|
60
|
-
|
|
61
|
-
|
|
87
|
+
const candidates = collect(paths.assets).filter((file) => {
|
|
88
|
+
try {
|
|
89
|
+
return fs.statSync(file).size >= MIN_BYTES;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
62
94
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
95
|
+
const results = await Promise.all(
|
|
96
|
+
candidates.map(async (file) => {
|
|
97
|
+
const source = await fs.promises.readFile(file);
|
|
98
|
+
if (source.length < MIN_BYTES) return { count: 0, saved: 0 };
|
|
66
99
|
|
|
67
|
-
|
|
68
|
-
|
|
100
|
+
const [br, gz] = await Promise.all([brotli(source, BROTLI_OPTIONS), gzip(source)]);
|
|
101
|
+
await Promise.all([
|
|
102
|
+
fs.promises.writeFile(`${file}.br`, br),
|
|
103
|
+
fs.promises.writeFile(`${file}.gz`, gz),
|
|
104
|
+
]);
|
|
69
105
|
|
|
70
|
-
|
|
71
|
-
|
|
106
|
+
return { count: 1, saved: source.length - br.length };
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
72
109
|
|
|
73
|
-
|
|
74
|
-
|
|
110
|
+
let count = 0;
|
|
111
|
+
let saved = 0;
|
|
112
|
+
for (const row of results) {
|
|
113
|
+
count += row.count;
|
|
114
|
+
saved += row.saved;
|
|
75
115
|
}
|
|
76
116
|
|
|
77
117
|
log.detail(`${count} files, ${log.size(saved)}`);
|
package/src/client/registry.js
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* `data-island-idle` görünür olsa bile sayfa sakinleşene (load + boş zaman)
|
|
17
17
|
* kadar bekletir. İlk ekranda görünen ama kritik olmayan ağır modüller
|
|
18
18
|
* (ör. grafik kütüphanesi çeken mini grafik) LCP ile yarışmasın diye.
|
|
19
|
+
* Gizli (zero-box) host'larda da aynı yol kullanılır — `hydrate()` onları
|
|
20
|
+
* `mountWhenReady` ile bağlar, hemen `mountIsland` çağırmaz.
|
|
19
21
|
*
|
|
20
22
|
* `data-island-props` JSON ise parse edilip props olarak geçer.
|
|
21
23
|
*/
|
|
@@ -263,13 +265,21 @@ export function hydrate(root = document) {
|
|
|
263
265
|
const boxed = pending.map((node) => node.getClientRects().length > 0);
|
|
264
266
|
|
|
265
267
|
pending.forEach((node, i) => {
|
|
266
|
-
// `
|
|
267
|
-
|
|
268
|
-
if ("islandEager" in node.dataset || !boxed[i]) {
|
|
268
|
+
// `data-island-eager`: görünürlük / idle yok — hemen bağla.
|
|
269
|
+
if ("islandEager" in node.dataset) {
|
|
269
270
|
mountIsland(node);
|
|
270
271
|
return;
|
|
271
272
|
}
|
|
272
273
|
|
|
274
|
+
// `hidden` drawer/dialog'un düzen kutusu yoktur; IntersectionObserver
|
|
275
|
+
// onu asla bildirmez. Gözlemciye vermek yerine bağlarız, ama yol
|
|
276
|
+
// `mountWhenReady` — `data-island-idle` olan gizli host'lar `load` +
|
|
277
|
+
// boş zamana kadar bekler (LCP ile yarışmasın).
|
|
278
|
+
if (!boxed[i]) {
|
|
279
|
+
mountWhenReady(node);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
273
283
|
getLazyObserver().observe(node);
|
|
274
284
|
});
|
|
275
285
|
}
|