jskelet 0.6.3 → 0.6.4

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 (153) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +628 -620
  3. package/LICENSE +21 -21
  4. package/README.md +2 -0
  5. package/bin/jskelet.mjs +130 -130
  6. package/docs/01-baslangic.md +291 -291
  7. package/docs/02-mimari.md +310 -310
  8. package/docs/03-routing.md +515 -515
  9. package/docs/04-render-ve-sablonlar.md +667 -661
  10. package/docs/05-islands.md +486 -486
  11. package/docs/06-cache.md +1467 -1443
  12. package/docs/07-yapilandirma.md +1208 -1197
  13. package/docs/08-build.md +429 -429
  14. package/docs/09-dev-araclari.md +364 -364
  15. package/docs/10-dagitim.md +348 -338
  16. package/docs/12-panel-ve-oturum.md +479 -478
  17. package/docs/README.md +83 -83
  18. package/docs/en/01-getting-started.md +298 -298
  19. package/docs/en/02-architecture.md +329 -329
  20. package/docs/en/03-routing.md +531 -531
  21. package/docs/en/04-rendering.md +675 -669
  22. package/docs/en/05-islands.md +497 -497
  23. package/docs/en/06-caching.md +1476 -1453
  24. package/docs/en/07-configuration.md +1229 -1219
  25. package/docs/en/08-build.md +447 -447
  26. package/docs/en/09-dev-tools.md +373 -373
  27. package/docs/en/10-deployment.md +351 -340
  28. package/docs/en/11-migration.md +398 -398
  29. package/docs/en/12-dashboards-and-sessions.md +489 -488
  30. package/docs/en/README.md +87 -87
  31. package/package.json +137 -137
  32. package/src/build/ensure-build.mjs +19 -19
  33. package/src/build/paths.mjs +153 -153
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +349 -349
  36. package/src/build/tasks/css.mjs +235 -235
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +357 -357
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/build/tasks/templates.mjs +20 -20
  42. package/src/client/admin/i18n.js +764 -764
  43. package/src/client/admin/login.html +74 -74
  44. package/src/client/admin/panel.css +809 -809
  45. package/src/client/admin/panel.html +495 -495
  46. package/src/client/admin/panel.js +1251 -1251
  47. package/src/client/devtools/report.html +185 -185
  48. package/src/client/devtools/report.js +745 -745
  49. package/src/client/devtools/seo.js +628 -628
  50. package/src/client/dom.js +95 -95
  51. package/src/client/form.js +192 -192
  52. package/src/client/index.js +45 -45
  53. package/src/client/registry.js +305 -305
  54. package/src/client/safe-image.js +91 -91
  55. package/src/client/shared-cookie.js +225 -225
  56. package/src/client/store.js +36 -36
  57. package/src/client/swap.js +188 -188
  58. package/src/compile/codegen.js +336 -336
  59. package/src/compile/compile-all.js +149 -149
  60. package/src/compile/errors.js +66 -66
  61. package/src/compile/expr.js +409 -409
  62. package/src/compile/index.js +17 -17
  63. package/src/compile/parse.js +541 -541
  64. package/src/compile/resolve.js +211 -211
  65. package/src/compile/scan-exports.js +51 -51
  66. package/src/config/defaults.js +541 -534
  67. package/src/config/index.js +1500 -1469
  68. package/src/config/pattern.js +107 -107
  69. package/src/generate.mjs +163 -163
  70. package/src/http/control-flow.js +71 -71
  71. package/src/http/cookies-entry.js +21 -21
  72. package/src/http/cookies.js +277 -277
  73. package/src/http/request-cache.js +46 -46
  74. package/src/http/request-context.js +165 -165
  75. package/src/http/shared-cookie.js +178 -178
  76. package/src/index.js +101 -101
  77. package/src/init.mjs +232 -230
  78. package/src/migrate/apply.mjs +262 -262
  79. package/src/migrate/babel.mjs +79 -79
  80. package/src/migrate/classify.mjs +155 -155
  81. package/src/migrate/config.mjs +126 -126
  82. package/src/migrate/fs-walk.mjs +191 -191
  83. package/src/migrate/parse.mjs +26 -26
  84. package/src/migrate/scan.mjs +177 -177
  85. package/src/migrate/transform/expr-source.mjs +168 -168
  86. package/src/migrate/transform/island.mjs +67 -67
  87. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  88. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  89. package/src/migrate/transform/page-split.mjs +435 -435
  90. package/src/migrate/write.mjs +81 -81
  91. package/src/migrate.mjs +171 -171
  92. package/src/runtime/alias-hooks.mjs +119 -119
  93. package/src/runtime/register.mjs +4 -4
  94. package/src/server/admin/actions.js +229 -229
  95. package/src/server/admin/auth.js +125 -125
  96. package/src/server/admin/event-log.js +151 -151
  97. package/src/server/admin/gate.js +209 -209
  98. package/src/server/admin/inventory.js +188 -188
  99. package/src/server/admin/mount.js +56 -56
  100. package/src/server/admin/router.js +216 -216
  101. package/src/server/admin/snapshot.js +241 -241
  102. package/src/server/assets.js +147 -147
  103. package/src/server/auth/handoff.js +309 -309
  104. package/src/server/cache-blob.js +70 -70
  105. package/src/server/cache-control.js +45 -0
  106. package/src/server/cache-deps.js +42 -42
  107. package/src/server/cache-vary.js +113 -113
  108. package/src/server/cloudflare.js +607 -607
  109. package/src/server/create-app.js +366 -366
  110. package/src/server/data-cache.js +553 -553
  111. package/src/server/dev/report.js +485 -485
  112. package/src/server/dev/socket.js +170 -170
  113. package/src/server/dev/version-check.mjs +139 -139
  114. package/src/server/disk-cache.js +233 -233
  115. package/src/server/ejs-adapter.js +59 -59
  116. package/src/server/html-cache.js +1196 -1196
  117. package/src/server/image-optimizer.js +500 -500
  118. package/src/server/logs/access-middleware.js +66 -66
  119. package/src/server/logs/file-sink.js +193 -193
  120. package/src/server/logs/pipeline.js +165 -165
  121. package/src/server/logs/s3-put.js +214 -214
  122. package/src/server/logs/s3-sink.js +112 -112
  123. package/src/server/metadata.js +102 -102
  124. package/src/server/middleware/compression.js +205 -205
  125. package/src/server/middleware/csrf.js +134 -134
  126. package/src/server/middleware/dev-gate.js +75 -75
  127. package/src/server/middleware/headers.js +37 -37
  128. package/src/server/middleware/redirects.js +32 -32
  129. package/src/server/middleware/robots-txt.js +341 -341
  130. package/src/server/middleware/static-precompressed.js +121 -121
  131. package/src/server/middleware/trailing-slash.js +53 -53
  132. package/src/server/middleware/upstream-proxy.js +141 -141
  133. package/src/server/og-image.js +369 -356
  134. package/src/server/port-guard.js +255 -255
  135. package/src/server/prewarm.js +1082 -1082
  136. package/src/server/redis.js +588 -588
  137. package/src/server/render.js +910 -910
  138. package/src/server/router.js +157 -157
  139. package/src/server/status-page.js +265 -265
  140. package/src/server/upstream-limiter.js +376 -376
  141. package/src/server/upstream-tracking.js +166 -166
  142. package/src/shared/cookie-domain.js +66 -66
  143. package/src/start.mjs +22 -22
  144. package/src/templates/layout.ejs +30 -30
  145. package/src/templates/layout.jsk +30 -30
  146. package/src/version.mjs +31 -31
  147. package/src/views/components/loader.js +101 -101
  148. package/src/views/helpers/html.js +102 -102
  149. package/src/views/helpers/tags.js +375 -375
  150. package/types/config/defaults.d.ts +6 -0
  151. package/types/config/index.d.ts +6 -0
  152. package/types/server/cache-control.d.ts +28 -0
  153. package/types/server/og-image.d.ts +5 -0
@@ -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)