jskelet 0.6.2 → 0.6.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 (155) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +620 -596
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +130 -130
  5. package/docs/01-baslangic.md +291 -291
  6. package/docs/02-mimari.md +310 -309
  7. package/docs/03-routing.md +515 -515
  8. package/docs/04-render-ve-sablonlar.md +661 -661
  9. package/docs/05-islands.md +486 -486
  10. package/docs/06-cache.md +1443 -1423
  11. package/docs/07-yapilandirma.md +12 -6
  12. package/docs/08-build.md +429 -428
  13. package/docs/09-dev-araclari.md +364 -364
  14. package/docs/10-dagitim.md +338 -338
  15. package/docs/12-panel-ve-oturum.md +478 -478
  16. package/docs/README.md +83 -83
  17. package/docs/en/01-getting-started.md +298 -298
  18. package/docs/en/02-architecture.md +329 -328
  19. package/docs/en/03-routing.md +531 -531
  20. package/docs/en/04-rendering.md +669 -669
  21. package/docs/en/05-islands.md +497 -497
  22. package/docs/en/06-caching.md +1453 -1431
  23. package/docs/en/07-configuration.md +1219 -1214
  24. package/docs/en/08-build.md +447 -446
  25. package/docs/en/09-dev-tools.md +373 -373
  26. package/docs/en/10-deployment.md +340 -340
  27. package/docs/en/11-migration.md +398 -398
  28. package/docs/en/12-dashboards-and-sessions.md +488 -488
  29. package/docs/en/README.md +87 -87
  30. package/package.json +137 -137
  31. package/src/build/ensure-build.mjs +19 -19
  32. package/src/build/paths.mjs +153 -153
  33. package/src/build/resolve-peer.mjs +36 -36
  34. package/src/build/tasks/client.mjs +349 -349
  35. package/src/build/tasks/css.mjs +235 -235
  36. package/src/build/tasks/fonts.mjs +146 -146
  37. package/src/build/tasks/icons.mjs +357 -357
  38. package/src/build/tasks/images.mjs +244 -244
  39. package/src/build/tasks/precompress.mjs +78 -78
  40. package/src/build/tasks/templates.mjs +20 -20
  41. package/src/client/admin/i18n.js +764 -764
  42. package/src/client/admin/login.html +74 -74
  43. package/src/client/admin/panel.css +809 -809
  44. package/src/client/admin/panel.html +495 -495
  45. package/src/client/admin/panel.js +1251 -1251
  46. package/src/client/devtools/report.html +185 -185
  47. package/src/client/devtools/report.js +745 -745
  48. package/src/client/devtools/seo.js +628 -628
  49. package/src/client/dom.js +95 -95
  50. package/src/client/form.js +192 -192
  51. package/src/client/index.js +45 -45
  52. package/src/client/registry.js +305 -305
  53. package/src/client/safe-image.js +91 -91
  54. package/src/client/shared-cookie.js +225 -225
  55. package/src/client/store.js +36 -36
  56. package/src/client/swap.js +188 -188
  57. package/src/compile/codegen.js +336 -336
  58. package/src/compile/compile-all.js +149 -149
  59. package/src/compile/errors.js +66 -66
  60. package/src/compile/expr.js +409 -409
  61. package/src/compile/index.js +17 -17
  62. package/src/compile/parse.js +541 -541
  63. package/src/compile/resolve.js +211 -211
  64. package/src/compile/scan-exports.js +51 -51
  65. package/src/config/defaults.js +17 -1
  66. package/src/config/index.js +13 -0
  67. package/src/config/pattern.js +107 -107
  68. package/src/generate.mjs +163 -163
  69. package/src/http/control-flow.js +71 -71
  70. package/src/http/cookies-entry.js +21 -21
  71. package/src/http/cookies.js +277 -277
  72. package/src/http/request-cache.js +46 -46
  73. package/src/http/request-context.js +165 -165
  74. package/src/http/shared-cookie.js +178 -178
  75. package/src/index.js +101 -101
  76. package/src/init.mjs +230 -230
  77. package/src/migrate/apply.mjs +262 -262
  78. package/src/migrate/babel.mjs +79 -79
  79. package/src/migrate/classify.mjs +155 -155
  80. package/src/migrate/config.mjs +126 -126
  81. package/src/migrate/fs-walk.mjs +191 -191
  82. package/src/migrate/parse.mjs +26 -26
  83. package/src/migrate/scan.mjs +177 -177
  84. package/src/migrate/transform/expr-source.mjs +168 -168
  85. package/src/migrate/transform/island.mjs +67 -67
  86. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  87. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  88. package/src/migrate/transform/page-split.mjs +435 -435
  89. package/src/migrate/write.mjs +81 -81
  90. package/src/migrate.mjs +171 -171
  91. package/src/runtime/alias-hooks.mjs +119 -119
  92. package/src/runtime/register.mjs +4 -4
  93. package/src/server/admin/actions.js +229 -229
  94. package/src/server/admin/auth.js +125 -125
  95. package/src/server/admin/event-log.js +151 -151
  96. package/src/server/admin/gate.js +209 -209
  97. package/src/server/admin/inventory.js +188 -188
  98. package/src/server/admin/mount.js +56 -56
  99. package/src/server/admin/router.js +216 -216
  100. package/src/server/admin/snapshot.js +241 -241
  101. package/src/server/assets.js +147 -147
  102. package/src/server/auth/handoff.js +309 -309
  103. package/src/server/cache-blob.js +70 -0
  104. package/src/server/cache-deps.js +42 -42
  105. package/src/server/cache-vary.js +113 -113
  106. package/src/server/cloudflare.js +607 -607
  107. package/src/server/create-app.js +366 -366
  108. package/src/server/data-cache.js +553 -462
  109. package/src/server/dev/report.js +485 -485
  110. package/src/server/dev/socket.js +170 -170
  111. package/src/server/dev/version-check.mjs +139 -139
  112. package/src/server/disk-cache.js +233 -0
  113. package/src/server/ejs-adapter.js +59 -59
  114. package/src/server/html-cache.js +1196 -1122
  115. package/src/server/image-optimizer.js +500 -407
  116. package/src/server/logs/access-middleware.js +66 -66
  117. package/src/server/logs/file-sink.js +193 -66
  118. package/src/server/logs/pipeline.js +165 -158
  119. package/src/server/logs/s3-put.js +214 -214
  120. package/src/server/logs/s3-sink.js +112 -112
  121. package/src/server/metadata.js +102 -102
  122. package/src/server/middleware/compression.js +205 -205
  123. package/src/server/middleware/csrf.js +134 -134
  124. package/src/server/middleware/dev-gate.js +75 -75
  125. package/src/server/middleware/headers.js +37 -37
  126. package/src/server/middleware/redirects.js +32 -32
  127. package/src/server/middleware/robots-txt.js +341 -341
  128. package/src/server/middleware/static-precompressed.js +121 -100
  129. package/src/server/middleware/trailing-slash.js +53 -53
  130. package/src/server/middleware/upstream-proxy.js +141 -141
  131. package/src/server/og-image.js +356 -356
  132. package/src/server/port-guard.js +255 -255
  133. package/src/server/prewarm.js +1082 -1058
  134. package/src/server/redis.js +588 -569
  135. package/src/server/render.js +4 -4
  136. package/src/server/router.js +157 -157
  137. package/src/server/status-page.js +265 -265
  138. package/src/server/upstream-limiter.js +376 -376
  139. package/src/server/upstream-tracking.js +166 -166
  140. package/src/shared/cookie-domain.js +66 -66
  141. package/src/start.mjs +22 -22
  142. package/src/templates/layout.ejs +30 -30
  143. package/src/templates/layout.jsk +30 -30
  144. package/src/version.mjs +31 -31
  145. package/src/views/components/loader.js +101 -101
  146. package/src/views/helpers/html.js +102 -102
  147. package/src/views/helpers/tags.js +375 -375
  148. package/types/config/defaults.d.ts +15 -1
  149. package/types/config/index.d.ts +8 -0
  150. package/types/server/cache-blob.d.ts +13 -0
  151. package/types/server/data-cache.d.ts +9 -0
  152. package/types/server/disk-cache.d.ts +36 -0
  153. package/types/server/html-cache.d.ts +26 -3
  154. package/types/server/logs/file-sink.d.ts +16 -5
  155. package/types/server/redis.d.ts +2 -1
@@ -1,497 +1,497 @@
1
- # 05 — Islands
2
-
3
- This document explains how interactivity is added: the `data-island` contract,
4
- passing props, the three hydration strategies and the IntersectionObserver
5
- logic, the structure of `client/entries/*` and loading extra entries per page,
6
- the runtime API (`register`, `registerAll`, `hydrate`, `observeDocument`,
7
- `start`), `createStore` for sharing state between islands, the DOM helpers,
8
- `startSafeImages` and the deferred panel (fragment) pattern. *Why* the model
9
- looks like this is in [02-architecture.md](./02-architecture.md), and how the
10
- bundle is produced is in [08-build.md](./08-build.md).
11
-
12
- ## The contract
13
-
14
- The server HTML is complete; an island only adds behaviour. There are three
15
- pieces.
16
-
17
- **1. A marker in the template.**
18
-
19
- ```ejs
20
- <div data-island="counter" data-island-props='{"start":5}'></div>
21
- ```
22
-
23
- **2. The island module — a named export called `mount`.**
24
-
25
- ```js
26
- // client/islands/counter.js
27
- /**
28
- * @param {HTMLElement} element
29
- * @param {{ start?: number }} props
30
- * @returns {void | (() => void)} cleanup function (optional)
31
- */
32
- export function mount(element, props) {
33
- let value = props.start ?? 0;
34
- // …
35
- }
36
- ```
37
-
38
- **3. Registration in an entry.**
39
-
40
- ```js
41
- // client/entries/main.js
42
- import { registerAll, start } from "jskelet/client";
43
-
44
- registerAll({
45
- counter: () => import("../islands/counter.js"),
46
- });
47
-
48
- start();
49
- ```
50
-
51
- The loader being a dynamic import is the core of the model: the module is
52
- downloaded only if that island actually exists on the page **and** its mount
53
- condition is met. Growing this map does not grow the initial load.
54
-
55
- ## HTML attributes
56
-
57
- | Attribute | Meaning |
58
- | --- | --- |
59
- | `data-island="name"` | The registered name of the island to mount. Required. |
60
- | `data-island-props='{"…":…}'` | JSON props. If it cannot be parsed an error is printed to the console and `{}` is passed. |
61
- | `data-island-eager` | Mount immediately, independent of visibility. |
62
- | `data-island-idle` | Wait until `load` plus idle time even if it is visible. |
63
- | `data-island-ready="true"` | **Written by the framework.** Added after `mount()` returns successfully; CSS and tests can read it. |
64
-
65
- Since `data-island-props` is an HTML attribute, wrapping it in single quotes is
66
- the easiest way. If you produce the values on the server, using `jsonScript()`
67
- or `attrs()` avoids escaping mistakes:
68
-
69
- ```ejs
70
- <div <%- attrs({ "data-island": "chart", "data-island-props": JSON.stringify({ symbol }) }) %>></div>
71
- ```
72
-
73
- ## Hydration strategies
74
-
75
- ### The default: tied to visibility
76
-
77
- Every island is handed to an `IntersectionObserver`
78
- (`rootMargin: "200px 0px"`). Ones already on screen fire on the first
79
- observation anyway; ones off screen are never downloaded until they are
80
- scrolled to. Once an element becomes visible it is unobserved.
81
-
82
- The mounting work is also deferred to idle time (`requestIdleCallback`,
83
- `timeout: 500`; `setTimeout(fn, 0)` if it is not supported): if many islands
84
- that become visible at the same time turn into a single long task, TBT and INP
85
- suffer.
86
-
87
- ### `data-island-eager`
88
-
89
- Visibility is not awaited, it mounts directly. For islands that apply to the
90
- whole page, such as header behaviour, a cookie banner or a theme switcher.
91
-
92
- ```ejs
93
- <header data-island="header" data-island-eager></header>
94
- ```
95
-
96
- ### `data-island-idle`
97
-
98
- Held back until the `load` event completes and the main thread frees up, even
99
- if it is visible. For heavy but non-critical modules that appear in the first
100
- viewport — for example a mini chart that pulls in a charting library — so that
101
- they do not compete with LCP.
102
-
103
- ```ejs
104
- <div data-island="sparkline" data-island-idle></div>
105
- ```
106
-
107
- If `document.readyState` is already `complete` when the page loads, the wait is
108
- skipped and it is deferred straight to idle time.
109
-
110
- ### Hidden elements
111
-
112
- A `hidden` drawer or dialog has no layout box, and `IntersectionObserver`
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.
117
-
118
- The practical consequence: you can start a modal as `hidden` and its island
119
- will still mount.
120
-
121
- ## The `client/` directory
122
-
123
- ```
124
- client/
125
- ├── entries/
126
- │ ├── main.js shared bootstrap on every page (or main.ts)
127
- │ └── chart.js only on the pages that ask for it
128
- └── islands/
129
- ├── counter.ts .js or .ts
130
- └── chart.js
131
- ```
132
-
133
- **Every file** under `client/entries/*.{js,ts,mts}` **is an esbuild entry**.
134
- `main.js` (or `main.ts`) is loaded by the layout on every page (if it is in the
135
- manifest). Extra entries are loaded only on the pages that ask for them. Two
136
- extensions for the same stem (`main.js` + `main.ts`) fail the build.
137
-
138
- ```js
139
- // controller — the manifest key is always *.js
140
- return { view: "pages/markets", entries: ["chart.js"] };
141
- ```
142
-
143
- The layout resolves every name in the `entries` array with `asset(entry)` and
144
- emits a `<script type="module">`. The name is the manifest key (`chart.js`);
145
- even when the source is `chart.ts`, the unhashed key stays `.js`.
146
-
147
- Shared `@/lib` modules imported on the server must stay **`.js`** — the Node
148
- runtime does not resolve `.ts`; TypeScript is compiled only on the esbuild
149
- client path.
150
-
151
- Code splitting (`splitting: true`) is on: modules shared by two entries end up
152
- in a common chunk and are not downloaded twice.
153
-
154
- `client/islands/` is not a requirement, only the common layout; island modules
155
- can live anywhere reachable from an entry. The `@/` alias works both on the
156
- server and in the bundle, so shared modules under `lib/` can use the same
157
- import style.
158
-
159
- ## Runtime API — `jskelet/client`
160
-
161
- ### `register(name, loader)`
162
-
163
- Registers a single island. `loader` must be a function returning
164
- `Promise<{ mount }>`.
165
-
166
- ```js
167
- import { register } from "jskelet/client";
168
-
169
- register("counter", () => import("../islands/counter.js"));
170
- ```
171
-
172
- ### `registerAll(entries)`
173
-
174
- Bulk registration in object form. The preferred form in practice.
175
-
176
- ```js
177
- registerAll({
178
- counter: () => import("../islands/counter.js"),
179
- drawer: () => import("../islands/drawer.js"),
180
- });
181
- ```
182
-
183
- ### `hydrate(root?)`
184
-
185
- Scans all `[data-island]` elements under `root` (defaults to `document`) and
186
- processes them according to their mount strategy. Already mounted elements are
187
- skipped.
188
-
189
- You can call it directly when you need to rescan for islands by hand (for
190
- example if you added DOM with your own code):
191
-
192
- ```js
193
- container.innerHTML = html;
194
- hydrate(container);
195
- ```
196
-
197
- ### `observeDocument()`
198
-
199
- Sets up a `MutationObserver` on `document.body` and also catches islands added
200
- to the DOM later (infinite scroll, portals, fragment loading). It returns the
201
- `MutationObserver` instance so it can be `disconnect()`ed if needed.
202
-
203
- ### `start()`
204
-
205
- The typical bootstrap: it waits for `DOMContentLoaded` (if necessary), then
206
- calls `hydrate()` and `observeDocument()`.
207
-
208
- ```js
209
- registerAll({ /* … */ });
210
- start();
211
- ```
212
-
213
- ### Mount behaviour and errors
214
-
215
- - An element is **not mounted twice** with the same island name; the record is
216
- kept per element in a `WeakMap`.
217
- - A warning is printed to the console for a name that is not registered:
218
- `[island] not registered: <name>`.
219
- - If the module import or `mount()` throws, an error is printed to the console
220
- (`[island] <name> failed to load`) and **the rest of the page is unaffected**.
221
- - If `mount()` returns successfully, `data-island-ready="true"` is written on
222
- the element.
223
- - `mount()` may return a cleanup function; the framework stores it and runs it
224
- when `unmount()` is called (see below).
225
-
226
- ### `unmount(root?)`
227
-
228
- Unmounts the islands under `root`: it runs the stored cleanup functions, removes
229
- the `data-island-ready` marker and clears the registration, so the same node can
230
- be hydrated again if it re-enters the DOM. `root` itself may be an island.
231
-
232
- It is **required** when you replace a region of the DOM:
233
-
234
- ```js
235
- import { hydrate, unmount } from "jskelet/client";
236
-
237
- unmount(container);
238
- container.innerHTML = html;
239
- hydrate(container);
240
- ```
241
-
242
- Skipping it produces the leak that is easiest to miss. The islands inside a
243
- region replaced with `innerHTML` leave the DOM, but the listeners they installed
244
- on `document`/`window` and their `setInterval` timers keep running; after a few
245
- swaps the same work runs dozens of times.
246
-
247
- ```js
248
- export function mount(element) {
249
- const timer = setInterval(() => tick(element), 1000);
250
- const onResize = () => layout(element);
251
- window.addEventListener("resize", onResize);
252
-
253
- return () => {
254
- clearInterval(timer);
255
- window.removeEventListener("resize", onResize);
256
- };
257
- }
258
- ```
259
-
260
- `swap()` and the form helpers call `unmount()` themselves; you call it wherever
261
- you change the DOM by hand.
262
-
263
- ### `swap(target, url, options?)` and `startSwapLinks(root?)`
264
-
265
- Replaces a region with a partial from the server: it unmounts the old subtree,
266
- writes the content, hydrates it again and restores focus if it was lost.
267
-
268
- ```html
269
- <a href="/_fragment/rows?page=2" data-swap="#rows">Next</a>
270
- ```
271
-
272
- The server side and the full set of options are in
273
- [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
274
-
275
- ### `enhanceForm(form)` and `startForms(root?)`
276
-
277
- Submits forms carrying `data-enhance` without a page reload, while the normal
278
- POST + redirect flow keeps working with JavaScript disabled. The whole contract
279
- is in [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
280
-
281
- ## Sharing state: `createStore`
282
-
283
- A minimal pub/sub used in place of React Context. It replaces the
284
- `useSyncExternalStore` bridge: you just `subscribe`.
285
-
286
- ```js
287
- // client/stores/theme.js
288
- import { createStore } from "jskelet/client";
289
-
290
- export const theme = createStore("light");
291
- ```
292
-
293
- ```js
294
- // client/islands/theme-toggle.js
295
- import { theme } from "../stores/theme.js";
296
-
297
- export function mount(element) {
298
- const paint = (value) => {
299
- element.textContent = value === "light" ? "Dark theme" : "Light theme";
300
- };
301
-
302
- const unsubscribe = theme.subscribe(paint);
303
- paint(theme.get());
304
-
305
- element.addEventListener("click", () => {
306
- theme.set((prev) => (prev === "light" ? "dark" : "light"));
307
- });
308
-
309
- return unsubscribe;
310
- }
311
- ```
312
-
313
- API:
314
-
315
- | Member | Behaviour |
316
- | --- | --- |
317
- | `get()` | The current value |
318
- | `set(next)` | A value or a `(prev) => next` function. If the value is **the same** (`===`) listeners are not fired. |
319
- | `subscribe(listener)` | Adds a listener and returns the function that removes it. It is not called with the current value on subscribe — do the first paint yourself. |
320
-
321
- ## DOM helpers
322
-
323
- `jskelet/client` provides a small set of helpers that islands share.
324
-
325
- | Function | Signature | Behaviour |
326
- | --- | --- | --- |
327
- | `qs` | `(root, selector) => HTMLElement \| null` | `querySelector` |
328
- | `qsa` | `(root, selector) => HTMLElement[]` | `querySelectorAll`, as a real array |
329
- | `on` | `(target, type, handler, options?) => () => void` | Adds a listener and **returns the function that removes it** |
330
- | `onClick` | `(root, selector, handler) => () => void` | Delegated click; `handler(event, target)` |
331
- | `debounce` | `(ms, fn) => fn` | Runs `ms` after the last call |
332
- | `raf` | `(fn) => fn` | Coalesces calls into a single `requestAnimationFrame` |
333
- | `toggleClass` | `(element, name, active) => void` | `classList.toggle` |
334
- | `getOverlayRoot` | `() => HTMLElement` | `#jskelet-overlays` or `body` |
335
-
336
- `on()` and `onClick()` returning a remover pairs naturally with `mount()`'s
337
- cleanup function:
338
-
339
- ```js
340
- import { on, onClick, raf } from "jskelet/client";
341
-
342
- export function mount(element) {
343
- const offClick = onClick(element, "[data-tab]", (event, target) => {
344
- selectTab(target.dataset.tab);
345
- });
346
-
347
- const offScroll = on(window, "scroll", raf(() => updateShadow(element)), {
348
- passive: true,
349
- });
350
-
351
- return () => {
352
- offClick();
353
- offScroll();
354
- };
355
- }
356
- ```
357
-
358
- `getOverlayRoot()` is for moving modal/drawer content: if the layout has
359
- `<div id="jskelet-overlays"></div>` it goes there, otherwise into `body`. The
360
- portal prevents an ancestor element carrying `overflow` or `transform` from
361
- clipping a `position: fixed` overlay.
362
-
363
- ## `startSafeImages()`
364
-
365
- A single document listener for images that fail to load. It is **deliberately
366
- not an island:** an image-heavy page can have 80+ `<img>` elements, and
367
- attaching a separate island to each one (observer + dynamic import + mount) is
368
- a serious hydration cost just for the possibility of an error.
369
-
370
- ```js
371
- // client/entries/main.js
372
- import { registerAll, start, startSafeImages } from "jskelet/client";
373
-
374
- registerAll({ /* … */ });
375
- startSafeImages();
376
- start();
377
- ```
378
-
379
- Usage, on the template side:
380
-
381
- ```ejs
382
- <%# 1. Minimal: the framework swaps in a block that preserves the dimensions %>
383
- <img src="/kapak.png" alt="Kapak" width="640" height="360" data-safe-image>
384
-
385
- <%# 2. Your own error view %>
386
- <div data-safe-image-host>
387
- <img src="/kapak.png" alt="Kapak" data-safe-image>
388
- <template data-safe-image-fallback>
389
- <div class="flex h-40 items-center justify-center bg-slate-100">No image</div>
390
- </template>
391
- </div>
392
- ```
393
-
394
- How it works:
395
-
396
- - A single `error` listener is installed on the document **in the capture
397
- phase**. The `error` event does not bubble but it can be seen in the capture
398
- phase; that is why a single listener covers all images and ones added to the
399
- DOM later are covered automatically.
400
- - If there is a `data-safe-image-host` wrapper **and** a
401
- `<template data-safe-image-fallback>` inside it, the whole wrapper is
402
- replaced with the template content. The framework imposes no styling.
403
- - Otherwise a minimal block is put in place of the image: `role="img"`, the
404
- `alt` (or `data-fallback-label`) value as `aria-label`, the image's
405
- `className` plus `data-fallback-class`, and, if `width`/`height` exist, the
406
- same dimensions as an inline style. Preserving the dimensions prevents layout
407
- shift (CLS) during the swap.
408
- - Images that failed before JS ran produce no event; that is why a single scan
409
- is performed (`requestIdleCallback`, `timeout: 2000`): the ones that are
410
- `complete` with `naturalWidth === 0` are replaced.
411
-
412
- ## The deferred panel (fragment) pattern
413
-
414
- When you want to remove a heavy, secondary section (comments, related articles,
415
- a long table) from the initial HTML response entirely, the combination of an
416
- island plus a layout-less render is used. The framework has no special API for
417
- this; it is a combination of two pieces you already have:
418
-
419
- **1. A layout-less fragment endpoint on the server** (`renderView`, see
420
- [03-routing.md](./03-routing.md)):
421
-
422
- ```js
423
- // routes/80-fragments.mjs
424
- export default function register(app, { renderView }) {
425
- app.get("/_fragment/comments/:id", async (req, res) => {
426
- const comments = await getComments(req.params.id);
427
- res.type("html").send(await renderView("fragments/comments", { comments }));
428
- });
429
- }
430
- ```
431
-
432
- **2. A placeholder island on the page.** Because it mounts on visibility,
433
- neither the module nor the fragment is downloaded if the visitor never scrolls
434
- to that section:
435
-
436
- ```ejs
437
- <div data-island="deferred" data-island-props='{"src":"/_fragment/comments/42"}'></div>
438
- ```
439
-
440
- **3. The island fetches the fragment, inserts it and hydrates the islands
441
- inside it:**
442
-
443
- ```js
444
- // client/islands/deferred.js
445
- import { hydrate } from "jskelet/client";
446
-
447
- export async function mount(element, { src }) {
448
- try {
449
- const response = await fetch(src, { headers: { accept: "text/html" } });
450
- if (!response.ok) return;
451
-
452
- element.innerHTML = await response.text();
453
- hydrate(element);
454
- } catch {
455
- // Secondary content: give up silently, don't affect the rest of the page.
456
- }
457
- }
458
- ```
459
-
460
- If `observeDocument()` is already running the `hydrate()` call on the last line
461
- is unnecessary; still, calling it explicitly makes it behave correctly in a
462
- setup that does not use `start()` either.
463
-
464
- The `/_fragment/` prefix is recommended for fragment paths: because it is in
465
- the default `prewarmSkip` list, the prewarm round does not scan those endpoints
466
- ([06-caching.md](./06-caching.md)).
467
-
468
- ## Environment variables and `clientEnv`
469
-
470
- There is no `process` in the browser, but modules shared with the server may
471
- still read `process.env`. The keys declared through `jskelet.config.mjs` →
472
- `clientEnv` are inlined into the bundle at build time:
473
-
474
- ```js
475
- export default {
476
- clientEnv: ["PUBLIC_WS_URL", "PUBLIC_CDN"],
477
- };
478
- ```
479
-
480
- The same contract as `NEXT_PUBLIC_*` in Next, except which key is public is
481
- clear from the config rather than from the name. All of `process.env` is
482
- defined as a single object, so reading a key that is not in the list returns
483
- `undefined` instead of crashing. `NODE_ENV` is always inlined.
484
-
485
- ## Browser support
486
-
487
- The bundle target is fixed: `chrome111`, `edge111`, `firefox111`,
488
- `safari16.4`. ESM + dynamic import + `IntersectionObserver` is already the
489
- lower bound of the island model; transpiling to anything older grows the output
490
- and gains no visitors. Even if JS never runs, the page stays readable because
491
- the server HTML is complete.
492
-
493
- ## What's next
494
-
495
- - The bundle, hashes and the `entries` manifest: [08-build.md](./08-build.md)
496
- - The controller side of the `entries` field: [03-routing.md](./03-routing.md)
497
- - Watching island state from the dev panel: [09-dev-tools.md](./09-dev-tools.md)
1
+ # 05 — Islands
2
+
3
+ This document explains how interactivity is added: the `data-island` contract,
4
+ passing props, the three hydration strategies and the IntersectionObserver
5
+ logic, the structure of `client/entries/*` and loading extra entries per page,
6
+ the runtime API (`register`, `registerAll`, `hydrate`, `observeDocument`,
7
+ `start`), `createStore` for sharing state between islands, the DOM helpers,
8
+ `startSafeImages` and the deferred panel (fragment) pattern. *Why* the model
9
+ looks like this is in [02-architecture.md](./02-architecture.md), and how the
10
+ bundle is produced is in [08-build.md](./08-build.md).
11
+
12
+ ## The contract
13
+
14
+ The server HTML is complete; an island only adds behaviour. There are three
15
+ pieces.
16
+
17
+ **1. A marker in the template.**
18
+
19
+ ```ejs
20
+ <div data-island="counter" data-island-props='{"start":5}'></div>
21
+ ```
22
+
23
+ **2. The island module — a named export called `mount`.**
24
+
25
+ ```js
26
+ // client/islands/counter.js
27
+ /**
28
+ * @param {HTMLElement} element
29
+ * @param {{ start?: number }} props
30
+ * @returns {void | (() => void)} cleanup function (optional)
31
+ */
32
+ export function mount(element, props) {
33
+ let value = props.start ?? 0;
34
+ // …
35
+ }
36
+ ```
37
+
38
+ **3. Registration in an entry.**
39
+
40
+ ```js
41
+ // client/entries/main.js
42
+ import { registerAll, start } from "jskelet/client";
43
+
44
+ registerAll({
45
+ counter: () => import("../islands/counter.js"),
46
+ });
47
+
48
+ start();
49
+ ```
50
+
51
+ The loader being a dynamic import is the core of the model: the module is
52
+ downloaded only if that island actually exists on the page **and** its mount
53
+ condition is met. Growing this map does not grow the initial load.
54
+
55
+ ## HTML attributes
56
+
57
+ | Attribute | Meaning |
58
+ | --- | --- |
59
+ | `data-island="name"` | The registered name of the island to mount. Required. |
60
+ | `data-island-props='{"…":…}'` | JSON props. If it cannot be parsed an error is printed to the console and `{}` is passed. |
61
+ | `data-island-eager` | Mount immediately, independent of visibility. |
62
+ | `data-island-idle` | Wait until `load` plus idle time even if it is visible. |
63
+ | `data-island-ready="true"` | **Written by the framework.** Added after `mount()` returns successfully; CSS and tests can read it. |
64
+
65
+ Since `data-island-props` is an HTML attribute, wrapping it in single quotes is
66
+ the easiest way. If you produce the values on the server, using `jsonScript()`
67
+ or `attrs()` avoids escaping mistakes:
68
+
69
+ ```ejs
70
+ <div <%- attrs({ "data-island": "chart", "data-island-props": JSON.stringify({ symbol }) }) %>></div>
71
+ ```
72
+
73
+ ## Hydration strategies
74
+
75
+ ### The default: tied to visibility
76
+
77
+ Every island is handed to an `IntersectionObserver`
78
+ (`rootMargin: "200px 0px"`). Ones already on screen fire on the first
79
+ observation anyway; ones off screen are never downloaded until they are
80
+ scrolled to. Once an element becomes visible it is unobserved.
81
+
82
+ The mounting work is also deferred to idle time (`requestIdleCallback`,
83
+ `timeout: 500`; `setTimeout(fn, 0)` if it is not supported): if many islands
84
+ that become visible at the same time turn into a single long task, TBT and INP
85
+ suffer.
86
+
87
+ ### `data-island-eager`
88
+
89
+ Visibility is not awaited, it mounts directly. For islands that apply to the
90
+ whole page, such as header behaviour, a cookie banner or a theme switcher.
91
+
92
+ ```ejs
93
+ <header data-island="header" data-island-eager></header>
94
+ ```
95
+
96
+ ### `data-island-idle`
97
+
98
+ Held back until the `load` event completes and the main thread frees up, even
99
+ if it is visible. For heavy but non-critical modules that appear in the first
100
+ viewport — for example a mini chart that pulls in a charting library — so that
101
+ they do not compete with LCP.
102
+
103
+ ```ejs
104
+ <div data-island="sparkline" data-island-idle></div>
105
+ ```
106
+
107
+ If `document.readyState` is already `complete` when the page loads, the wait is
108
+ skipped and it is deferred straight to idle time.
109
+
110
+ ### Hidden elements
111
+
112
+ A `hidden` drawer or dialog has no layout box, and `IntersectionObserver`
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.
117
+
118
+ The practical consequence: you can start a modal as `hidden` and its island
119
+ will still mount.
120
+
121
+ ## The `client/` directory
122
+
123
+ ```
124
+ client/
125
+ ├── entries/
126
+ │ ├── main.js shared bootstrap on every page (or main.ts)
127
+ │ └── chart.js only on the pages that ask for it
128
+ └── islands/
129
+ ├── counter.ts .js or .ts
130
+ └── chart.js
131
+ ```
132
+
133
+ **Every file** under `client/entries/*.{js,ts,mts}` **is an esbuild entry**.
134
+ `main.js` (or `main.ts`) is loaded by the layout on every page (if it is in the
135
+ manifest). Extra entries are loaded only on the pages that ask for them. Two
136
+ extensions for the same stem (`main.js` + `main.ts`) fail the build.
137
+
138
+ ```js
139
+ // controller — the manifest key is always *.js
140
+ return { view: "pages/markets", entries: ["chart.js"] };
141
+ ```
142
+
143
+ The layout resolves every name in the `entries` array with `asset(entry)` and
144
+ emits a `<script type="module">`. The name is the manifest key (`chart.js`);
145
+ even when the source is `chart.ts`, the unhashed key stays `.js`.
146
+
147
+ Shared `@/lib` modules imported on the server must stay **`.js`** — the Node
148
+ runtime does not resolve `.ts`; TypeScript is compiled only on the esbuild
149
+ client path.
150
+
151
+ Code splitting (`splitting: true`) is on: modules shared by two entries end up
152
+ in a common chunk and are not downloaded twice.
153
+
154
+ `client/islands/` is not a requirement, only the common layout; island modules
155
+ can live anywhere reachable from an entry. The `@/` alias works both on the
156
+ server and in the bundle, so shared modules under `lib/` can use the same
157
+ import style.
158
+
159
+ ## Runtime API — `jskelet/client`
160
+
161
+ ### `register(name, loader)`
162
+
163
+ Registers a single island. `loader` must be a function returning
164
+ `Promise<{ mount }>`.
165
+
166
+ ```js
167
+ import { register } from "jskelet/client";
168
+
169
+ register("counter", () => import("../islands/counter.js"));
170
+ ```
171
+
172
+ ### `registerAll(entries)`
173
+
174
+ Bulk registration in object form. The preferred form in practice.
175
+
176
+ ```js
177
+ registerAll({
178
+ counter: () => import("../islands/counter.js"),
179
+ drawer: () => import("../islands/drawer.js"),
180
+ });
181
+ ```
182
+
183
+ ### `hydrate(root?)`
184
+
185
+ Scans all `[data-island]` elements under `root` (defaults to `document`) and
186
+ processes them according to their mount strategy. Already mounted elements are
187
+ skipped.
188
+
189
+ You can call it directly when you need to rescan for islands by hand (for
190
+ example if you added DOM with your own code):
191
+
192
+ ```js
193
+ container.innerHTML = html;
194
+ hydrate(container);
195
+ ```
196
+
197
+ ### `observeDocument()`
198
+
199
+ Sets up a `MutationObserver` on `document.body` and also catches islands added
200
+ to the DOM later (infinite scroll, portals, fragment loading). It returns the
201
+ `MutationObserver` instance so it can be `disconnect()`ed if needed.
202
+
203
+ ### `start()`
204
+
205
+ The typical bootstrap: it waits for `DOMContentLoaded` (if necessary), then
206
+ calls `hydrate()` and `observeDocument()`.
207
+
208
+ ```js
209
+ registerAll({ /* … */ });
210
+ start();
211
+ ```
212
+
213
+ ### Mount behaviour and errors
214
+
215
+ - An element is **not mounted twice** with the same island name; the record is
216
+ kept per element in a `WeakMap`.
217
+ - A warning is printed to the console for a name that is not registered:
218
+ `[island] not registered: <name>`.
219
+ - If the module import or `mount()` throws, an error is printed to the console
220
+ (`[island] <name> failed to load`) and **the rest of the page is unaffected**.
221
+ - If `mount()` returns successfully, `data-island-ready="true"` is written on
222
+ the element.
223
+ - `mount()` may return a cleanup function; the framework stores it and runs it
224
+ when `unmount()` is called (see below).
225
+
226
+ ### `unmount(root?)`
227
+
228
+ Unmounts the islands under `root`: it runs the stored cleanup functions, removes
229
+ the `data-island-ready` marker and clears the registration, so the same node can
230
+ be hydrated again if it re-enters the DOM. `root` itself may be an island.
231
+
232
+ It is **required** when you replace a region of the DOM:
233
+
234
+ ```js
235
+ import { hydrate, unmount } from "jskelet/client";
236
+
237
+ unmount(container);
238
+ container.innerHTML = html;
239
+ hydrate(container);
240
+ ```
241
+
242
+ Skipping it produces the leak that is easiest to miss. The islands inside a
243
+ region replaced with `innerHTML` leave the DOM, but the listeners they installed
244
+ on `document`/`window` and their `setInterval` timers keep running; after a few
245
+ swaps the same work runs dozens of times.
246
+
247
+ ```js
248
+ export function mount(element) {
249
+ const timer = setInterval(() => tick(element), 1000);
250
+ const onResize = () => layout(element);
251
+ window.addEventListener("resize", onResize);
252
+
253
+ return () => {
254
+ clearInterval(timer);
255
+ window.removeEventListener("resize", onResize);
256
+ };
257
+ }
258
+ ```
259
+
260
+ `swap()` and the form helpers call `unmount()` themselves; you call it wherever
261
+ you change the DOM by hand.
262
+
263
+ ### `swap(target, url, options?)` and `startSwapLinks(root?)`
264
+
265
+ Replaces a region with a partial from the server: it unmounts the old subtree,
266
+ writes the content, hydrates it again and restores focus if it was lost.
267
+
268
+ ```html
269
+ <a href="/_fragment/rows?page=2" data-swap="#rows">Next</a>
270
+ ```
271
+
272
+ The server side and the full set of options are in
273
+ [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
274
+
275
+ ### `enhanceForm(form)` and `startForms(root?)`
276
+
277
+ Submits forms carrying `data-enhance` without a page reload, while the normal
278
+ POST + redirect flow keeps working with JavaScript disabled. The whole contract
279
+ is in [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
280
+
281
+ ## Sharing state: `createStore`
282
+
283
+ A minimal pub/sub used in place of React Context. It replaces the
284
+ `useSyncExternalStore` bridge: you just `subscribe`.
285
+
286
+ ```js
287
+ // client/stores/theme.js
288
+ import { createStore } from "jskelet/client";
289
+
290
+ export const theme = createStore("light");
291
+ ```
292
+
293
+ ```js
294
+ // client/islands/theme-toggle.js
295
+ import { theme } from "../stores/theme.js";
296
+
297
+ export function mount(element) {
298
+ const paint = (value) => {
299
+ element.textContent = value === "light" ? "Dark theme" : "Light theme";
300
+ };
301
+
302
+ const unsubscribe = theme.subscribe(paint);
303
+ paint(theme.get());
304
+
305
+ element.addEventListener("click", () => {
306
+ theme.set((prev) => (prev === "light" ? "dark" : "light"));
307
+ });
308
+
309
+ return unsubscribe;
310
+ }
311
+ ```
312
+
313
+ API:
314
+
315
+ | Member | Behaviour |
316
+ | --- | --- |
317
+ | `get()` | The current value |
318
+ | `set(next)` | A value or a `(prev) => next` function. If the value is **the same** (`===`) listeners are not fired. |
319
+ | `subscribe(listener)` | Adds a listener and returns the function that removes it. It is not called with the current value on subscribe — do the first paint yourself. |
320
+
321
+ ## DOM helpers
322
+
323
+ `jskelet/client` provides a small set of helpers that islands share.
324
+
325
+ | Function | Signature | Behaviour |
326
+ | --- | --- | --- |
327
+ | `qs` | `(root, selector) => HTMLElement \| null` | `querySelector` |
328
+ | `qsa` | `(root, selector) => HTMLElement[]` | `querySelectorAll`, as a real array |
329
+ | `on` | `(target, type, handler, options?) => () => void` | Adds a listener and **returns the function that removes it** |
330
+ | `onClick` | `(root, selector, handler) => () => void` | Delegated click; `handler(event, target)` |
331
+ | `debounce` | `(ms, fn) => fn` | Runs `ms` after the last call |
332
+ | `raf` | `(fn) => fn` | Coalesces calls into a single `requestAnimationFrame` |
333
+ | `toggleClass` | `(element, name, active) => void` | `classList.toggle` |
334
+ | `getOverlayRoot` | `() => HTMLElement` | `#jskelet-overlays` or `body` |
335
+
336
+ `on()` and `onClick()` returning a remover pairs naturally with `mount()`'s
337
+ cleanup function:
338
+
339
+ ```js
340
+ import { on, onClick, raf } from "jskelet/client";
341
+
342
+ export function mount(element) {
343
+ const offClick = onClick(element, "[data-tab]", (event, target) => {
344
+ selectTab(target.dataset.tab);
345
+ });
346
+
347
+ const offScroll = on(window, "scroll", raf(() => updateShadow(element)), {
348
+ passive: true,
349
+ });
350
+
351
+ return () => {
352
+ offClick();
353
+ offScroll();
354
+ };
355
+ }
356
+ ```
357
+
358
+ `getOverlayRoot()` is for moving modal/drawer content: if the layout has
359
+ `<div id="jskelet-overlays"></div>` it goes there, otherwise into `body`. The
360
+ portal prevents an ancestor element carrying `overflow` or `transform` from
361
+ clipping a `position: fixed` overlay.
362
+
363
+ ## `startSafeImages()`
364
+
365
+ A single document listener for images that fail to load. It is **deliberately
366
+ not an island:** an image-heavy page can have 80+ `<img>` elements, and
367
+ attaching a separate island to each one (observer + dynamic import + mount) is
368
+ a serious hydration cost just for the possibility of an error.
369
+
370
+ ```js
371
+ // client/entries/main.js
372
+ import { registerAll, start, startSafeImages } from "jskelet/client";
373
+
374
+ registerAll({ /* … */ });
375
+ startSafeImages();
376
+ start();
377
+ ```
378
+
379
+ Usage, on the template side:
380
+
381
+ ```ejs
382
+ <%# 1. Minimal: the framework swaps in a block that preserves the dimensions %>
383
+ <img src="/kapak.png" alt="Kapak" width="640" height="360" data-safe-image>
384
+
385
+ <%# 2. Your own error view %>
386
+ <div data-safe-image-host>
387
+ <img src="/kapak.png" alt="Kapak" data-safe-image>
388
+ <template data-safe-image-fallback>
389
+ <div class="flex h-40 items-center justify-center bg-slate-100">No image</div>
390
+ </template>
391
+ </div>
392
+ ```
393
+
394
+ How it works:
395
+
396
+ - A single `error` listener is installed on the document **in the capture
397
+ phase**. The `error` event does not bubble but it can be seen in the capture
398
+ phase; that is why a single listener covers all images and ones added to the
399
+ DOM later are covered automatically.
400
+ - If there is a `data-safe-image-host` wrapper **and** a
401
+ `<template data-safe-image-fallback>` inside it, the whole wrapper is
402
+ replaced with the template content. The framework imposes no styling.
403
+ - Otherwise a minimal block is put in place of the image: `role="img"`, the
404
+ `alt` (or `data-fallback-label`) value as `aria-label`, the image's
405
+ `className` plus `data-fallback-class`, and, if `width`/`height` exist, the
406
+ same dimensions as an inline style. Preserving the dimensions prevents layout
407
+ shift (CLS) during the swap.
408
+ - Images that failed before JS ran produce no event; that is why a single scan
409
+ is performed (`requestIdleCallback`, `timeout: 2000`): the ones that are
410
+ `complete` with `naturalWidth === 0` are replaced.
411
+
412
+ ## The deferred panel (fragment) pattern
413
+
414
+ When you want to remove a heavy, secondary section (comments, related articles,
415
+ a long table) from the initial HTML response entirely, the combination of an
416
+ island plus a layout-less render is used. The framework has no special API for
417
+ this; it is a combination of two pieces you already have:
418
+
419
+ **1. A layout-less fragment endpoint on the server** (`renderView`, see
420
+ [03-routing.md](./03-routing.md)):
421
+
422
+ ```js
423
+ // routes/80-fragments.mjs
424
+ export default function register(app, { renderView }) {
425
+ app.get("/_fragment/comments/:id", async (req, res) => {
426
+ const comments = await getComments(req.params.id);
427
+ res.type("html").send(await renderView("fragments/comments", { comments }));
428
+ });
429
+ }
430
+ ```
431
+
432
+ **2. A placeholder island on the page.** Because it mounts on visibility,
433
+ neither the module nor the fragment is downloaded if the visitor never scrolls
434
+ to that section:
435
+
436
+ ```ejs
437
+ <div data-island="deferred" data-island-props='{"src":"/_fragment/comments/42"}'></div>
438
+ ```
439
+
440
+ **3. The island fetches the fragment, inserts it and hydrates the islands
441
+ inside it:**
442
+
443
+ ```js
444
+ // client/islands/deferred.js
445
+ import { hydrate } from "jskelet/client";
446
+
447
+ export async function mount(element, { src }) {
448
+ try {
449
+ const response = await fetch(src, { headers: { accept: "text/html" } });
450
+ if (!response.ok) return;
451
+
452
+ element.innerHTML = await response.text();
453
+ hydrate(element);
454
+ } catch {
455
+ // Secondary content: give up silently, don't affect the rest of the page.
456
+ }
457
+ }
458
+ ```
459
+
460
+ If `observeDocument()` is already running the `hydrate()` call on the last line
461
+ is unnecessary; still, calling it explicitly makes it behave correctly in a
462
+ setup that does not use `start()` either.
463
+
464
+ The `/_fragment/` prefix is recommended for fragment paths: because it is in
465
+ the default `prewarmSkip` list, the prewarm round does not scan those endpoints
466
+ ([06-caching.md](./06-caching.md)).
467
+
468
+ ## Environment variables and `clientEnv`
469
+
470
+ There is no `process` in the browser, but modules shared with the server may
471
+ still read `process.env`. The keys declared through `jskelet.config.mjs` →
472
+ `clientEnv` are inlined into the bundle at build time:
473
+
474
+ ```js
475
+ export default {
476
+ clientEnv: ["PUBLIC_WS_URL", "PUBLIC_CDN"],
477
+ };
478
+ ```
479
+
480
+ The same contract as `NEXT_PUBLIC_*` in Next, except which key is public is
481
+ clear from the config rather than from the name. All of `process.env` is
482
+ defined as a single object, so reading a key that is not in the list returns
483
+ `undefined` instead of crashing. `NODE_ENV` is always inlined.
484
+
485
+ ## Browser support
486
+
487
+ The bundle target is fixed: `chrome111`, `edge111`, `firefox111`,
488
+ `safari16.4`. ESM + dynamic import + `IntersectionObserver` is already the
489
+ lower bound of the island model; transpiling to anything older grows the output
490
+ and gains no visitors. Even if JS never runs, the page stays readable because
491
+ the server HTML is complete.
492
+
493
+ ## What's next
494
+
495
+ - The bundle, hashes and the `entries` manifest: [08-build.md](./08-build.md)
496
+ - The controller side of the `entries` field: [03-routing.md](./03-routing.md)
497
+ - Watching island state from the dev panel: [09-dev-tools.md](./09-dev-tools.md)