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,669 +1,675 @@
1
- # 04 — Rendering and templates
2
-
3
- This document explains how server HTML is produced: the EJS engine settings,
4
- how the layout file is resolved and which locals it can use, the page templates
5
- under `views/pages`, the automatic registration of the components under
6
- `views/components/**`, the `html`/`tags` helpers that templates receive for
7
- free, the translation of the `metadata` object into `<head>` tags, and the
8
- three render hooks. What the controller sends into this layer is covered in
9
- [03-routing.md](./03-routing.md), and `asset()`/`hasAsset()`, which produce
10
- asset URLs, in [08-build.md](./08-build.md).
11
-
12
- ## The render pipeline
13
-
14
- ```
15
- route(controller)
16
- └─ produce()
17
- ├─ controller(ctx) → page definition
18
- └─ renderPage(page)
19
- ├─ hooks.metadata(page) + page.metadata → metadata
20
- ├─ Promise.all([
21
- │ renderView(page.view, { …data, metadata }), → body
22
- │ hooks.layoutContext({ pathname, metadata }), → context
23
- │ ])
24
- └─ layout (.jsk compiled or .ejs) → full HTML
25
- ```
26
-
27
- The layout context and the body are produced **in parallel**. The reason comes
28
- from measurement: in most projects navigation comes from upstream, and waiting
29
- for it in sequence with the body render adds needless latency to every page.
30
-
31
- ## `.jsk` — build-time compiled templates
32
-
33
- New apps default to `.jsk`. At build time they become normal ESM modules under
34
- `.jskelet/templates/*.mjs`. There is **no request-time parsing, `eval`, or
35
- `new Function`**. Production path:
36
-
37
- ```
38
- controller data → imported render(data, helpers) → HTML
39
- ```
40
-
41
- ### Syntax summary
42
-
43
- ```html
44
- <section class="wrapper">
45
- <h1>{{ title }}</h1>
46
- <div>{{{ trustedHtml }}}</div>
47
-
48
- {#if items.length}
49
- <List :items="items" />
50
- {#else}
51
- <p>Empty</p>
52
- {/if}
53
-
54
- {#each items as item, i}
55
- <li :data-i="i">{{ item }}</li>
56
- {/each}
57
-
58
- <Link href="/" text="Home" />
59
- <div data-island="counter" data-island-props='{"start":0}'></div>
60
- </section>
61
- ```
62
-
63
- | Feature | Form |
64
- | --- | --- |
65
- | Escaped text | `{{ expr }}` |
66
- | Raw HTML | `{{{ expr }}}` |
67
- | Conditional | `{#if expr}` … `{#else}` … `{/if}` |
68
- | Loop | `{#each list as item}` or `as item, i` |
69
- | Include | `{#include "partials/header"}` (compiled `.jsk`) |
70
- | Component | PascalCase tag; `:prop="expr"`, `prop="literal"`, boolean `disabled` |
71
- | Built-ins | `Link`, `Image`, `Icon`, `CsrfField`, `PreloadImage`, `Stylesheets`, `BodyScripts`, `JsonLd` |
72
-
73
- The expression language is intentionally small (access, compare, ternary,
74
- `.length`). No assignments, object literals, or arbitrary calls — keep logic in
75
- controllers or JS components.
76
-
77
- #### Template or component?
78
-
79
- When moving off EJS, draw the line early:
80
-
81
- | Stay in `.jsk` | Move to a JS component |
82
- | --- | --- |
83
- | Text, conditionals, lists, prop binding | Function calls, object construction, formatting |
84
- | Built-in tags (`Link`, `Image`, …) | Composing HTML from several helpers |
85
- | Ready-made data from the controller | Upstream / error-aware UI (`LoadErrorState`) |
86
-
87
- If the template cannot write `format(x)` or `{ a: 1 }`, that is intentional: the
88
- work belongs in `views/components/*.js` or the controller. Prefer a clear
89
- component boundary over widening the expression language when complex pages
90
- “escape” into JS.
91
-
92
- ### Editor support
93
-
94
- `extensions/vscode-jsk` is a VS Code / Cursor extension in this repo: syntax
95
- highlighting, language configuration, and snippets. Local install:
96
-
97
- ```bash
98
- code --install-extension extensions/vscode-jsk
99
- ```
100
-
101
- See the extension README for details.
102
-
103
- ### Coexistence with EJS
104
-
105
- If a compiled `.jsk` exists for a view id it wins; otherwise `.ejs` is rendered
106
- with EJS. Existing apps keep working unchanged. `jskelet init` scaffolds `.jsk`.
107
- Legacy `.ejs` needs the optional `ejs` peer installed in the application
108
- (`npm install ejs`); without it only `.jsk` templates run.
109
-
110
- ## The EJS engine (legacy)
111
-
112
- EJS remains supported as an **optional peer dependency** for legacy templates.
113
- The engine is set up once on the first render; the component scan touches the
114
- file system, so it cannot be done on every request and cannot be computed
115
- before the config is loaded.
116
-
117
- Settings:
118
-
119
- | Setting | Value | Reason |
120
- | --- | --- | --- |
121
- | `root`, `views` | the `views` directory | `include('partials/header')` calls resolve from the views root |
122
- | `cache` | `false` in dev, `true` in prod | so template edits show up instantly in dev |
123
- | `rmWhitespace` | `true` | output size |
124
- | `async` | `true` | `await` can be used inside templates |
125
-
126
- For embedded uses (tests, scripts) `resetRenderEngine()` is exported: it
127
- refreshes the registry when component files change. It is not needed in the
128
- normal flow because the dev server restarts the process.
129
-
130
- ## Layout
131
-
132
- ### How the layout file is found
133
-
134
- 1. `jskelet.config.mjs` → if `layout` is given, it is used. The path is
135
- resolved relative to the **parent directory of the views directory**: if
136
- `views` is the default, `layout: "views/custom.jsk"` → `<root>/views/custom.jsk`.
137
- 2. If it is not given and `views/layout.jsk` exists (compiled), that is used.
138
- 3. Else if `views/layout.ejs` exists (legacy), that is used.
139
- 4. If that does not exist either, the framework's own minimal layout is used
140
- (`node_modules/jskelet/src/templates/layout.jsk`, also reachable through the
141
- `jskelet/layout` specifier).
142
-
143
- These fallbacks exist so that a new project can work with a single route. The
144
- most practical way to move to your own layout is to copy that file to
145
- `views/layout.jsk`.
146
-
147
- ### The framework's default layout
148
-
149
- ```html
150
- <!DOCTYPE html>
151
- <html :lang="lang">
152
- <head>
153
- <meta charset="utf-8">
154
- <meta name="viewport" content="width=device-width, initial-scale=1">
155
- {{{ extraHead }}}
156
- <Stylesheets :styles="styles" />
157
- {{{ headMeta }}}
158
- <JsonLd :items="structuredData" />
159
- </head>
160
- <body :class="bodyClass">
161
- {{{ body }}}
162
- <BodyScripts :entries="entries" :devtools="devtools" :devBasePath="devBasePath" />
163
- </body>
164
- </html>
165
- ```
166
-
167
- The `.jsk` expression language has no function calls, so asset loops live in the
168
- built-in `<Stylesheets />`, `<BodyScripts />` and `<JsonLd />` tags instead of
169
- inline `hasAsset` / `asset` / `forEach` in the layout.
170
-
171
- Points to watch:
172
-
173
- - **`extraHead` comes first.** Delaying resource hints (`preconnect`, LCP
174
- `preload`) writes straight into LCP.
175
- - **`<Stylesheets />` emits global `app.css` (render-blocking)** plus controller
176
- `styles: [...]`, with the reasoning in
177
- [02-architecture.md](./02-architecture.md). If the build has not run,
178
- `hasAsset` is false inside the tag and nothing is emitted.
179
- - **`<BodyScripts />` emits `main.js`, page `entries`, and the
180
- development-only overlay.** The overlay script exists only when
181
- `NODE_ENV=development`; it is absent from production output.
182
- - **`<JsonLd />` turns `structuredData` into safe
183
- `application/ld+json` scripts.**
184
-
185
- ### Layout locals
186
-
187
- | Local | Type | Source |
188
- | --- | --- | --- |
189
- | `metadata` | `object` | `hooks.metadata()` + controller `metadata` (the controller wins) |
190
- | `headMeta` | `string` | ready-made `<head>` tags produced from `metadata` |
191
- | `extraHead` | `string` | `preconnect` hints + `navigation` hints + controller `head` + `context.extraHead` |
192
- | `structuredData` | `unknown[]` | `hooks.layoutContext()` → `structuredData`; defaults to `[]` |
193
- | `body` | `string` | The render output of the page template |
194
- | `bodyClass` | `string` | controller `bodyClass` → `context.bodyClass` → `""` |
195
- | `entries` | `string[]` | controller `entries`; defaults to `[]` |
196
- | `styles` | `string[]` | controller `styles`; defaults to `[]` |
197
- | `pathname` | `string` | `req.path`; **defaults to the empty string** |
198
- | `lang` | `string` | `context.lang` → `brand.lang` → `"en"` |
199
- | `devtools` | `boolean` | `NODE_ENV === "development"` |
200
- | `devBasePath` | `string` | `brand.devBasePath`, defaults to `/__jskelet/dev` |
201
- | `asset`, `hasAsset` | function | Manifest access |
202
- | html/tags helpers | function | `esc`, `attrs`, `cx`, `cn`, `jsonScript`, `link`, `image`, `icon`, `preloadImage`, `toKebab` |
203
- | exports of `views/components/**` | function | Automatic registration |
204
- | every field returned by `hooks.layoutContext()` | — | Becomes a local directly |
205
-
206
- The empty default for `pathname` is deliberate: writing `"/"` leads to the kind
207
- of bug where every page thinks it is the home page and renders the logo as an
208
- `<h1>`.
209
-
210
- ## Page templates
211
-
212
- The `view` field gives the path under `views/` without an extension:
213
- `"pages/home"` → `views/pages/home.jsk` (else legacy `home.ejs`). The locals
214
- passed to the template are the contents of the `data` field plus `metadata` —
215
- **not** the layout locals. The page template still has access to all helpers
216
- and components.
217
-
218
- ```html
219
- {# views/pages/home.jsk #}
220
- <section class="wrapper">
221
- <h1 class="text-3xl font-bold">{{ heading }}</h1>
222
-
223
- {# `list` is defined in views/components/list.js; no import needed. #}
224
- <List :items="items" />
225
-
226
- <div class="mt-8" data-island="counter" data-island-props='{"start":5}'></div>
227
- </section>
228
- ```
229
-
230
- In `.jsk`, `{{ }}` escapes and `{{{ }}}` emits raw HTML (trusted strings only).
231
- Legacy EJS keeps `<%= %>` / `<%- %>` with the same meaning; because `async: true`
232
- is on there, `await` can also be used inside an `.ejs` template, but keeping
233
- data fetching in the controller makes diagnosis easier.
234
-
235
- ## Components: `views/components/**`
236
-
237
- Components are not EJS partials but **functions that return HTML strings**.
238
- Every `.js` file under `views/components/**` is scanned and **every named
239
- export** becomes a template local. There is no hand-maintained barrel file:
240
- creating the file is enough to add a new component.
241
-
242
- ```js
243
- // views/components/list.js
244
- import { esc } from "jskelet/html";
245
-
246
- /**
247
- * @param {{ items: string[] }} props
248
- * @returns {string}
249
- */
250
- export function list({ items }) {
251
- if (!items?.length) return "";
252
-
253
- const rows = items.map((item) => `<li class="py-1">${esc(item)}</li>`).join("");
254
- return `<ul class="mt-6 list-disc pl-6">${rows}</ul>`;
255
- }
256
- ```
257
-
258
- In the template:
259
-
260
- ```ejs
261
- <%- list({ items }) %>
262
- ```
263
-
264
- Rules:
265
-
266
- - The scan is recursive; subdirectories are covered too.
267
- - `default` exports are ignored — only named exports are registered.
268
- - The compile-time known-component set is read from **named exports in the
269
- source**, not from the file basename. `sectionHead` in `ui.js` →
270
- `<SectionHead />` in the template (runtime already adds a PascalCase alias
271
- for camelCase exports). You do not need a stub re-export named after the
272
- file.
273
- - `loader.js` and `index.js` do not count as component files.
274
- - If `views/components/index.js` exists it is loaded first as a **barrel**,
275
- with the lowest priority. Its only purpose is to turn `lib/` re-exports into
276
- template locals; the components' own files come later and silently overwrite
277
- it.
278
- - If the same name (or the same PascalCase tag) is defined in two different
279
- component files, that is an **error, not a warning**: build and server
280
- startup stop with `Component 'card' is defined twice: …`. Overwriting the
281
- barrel is the deliberate exception.
282
- - If the `views/components` directory does not exist the component registry
283
- stays empty; a project that uses no components works fine too.
284
-
285
- ## Helpers: `jskelet/html`
286
-
287
- They are passed to templates automatically; in component files you get them
288
- with `import { … } from "jskelet/html"`.
289
-
290
- ### `esc(value)`
291
-
292
- Escaping for text content and attribute values (`&`, `<`, `>`, `"`, `'`).
293
- `null`, `undefined` and `false` are turned into the empty string — so in
294
- conditional rendering an expression like `false && "…"` does not print
295
- `"false"`.
296
-
297
- ```js
298
- esc('<b>"x"</b>'); // "&lt;b&gt;&quot;x&quot;&lt;/b&gt;"
299
- ```
300
-
301
- ### `attrs(object)`
302
-
303
- Turns an attribute object into a string. `null`/`undefined`/`false` are
304
- skipped, `true` is written as a boolean attribute, and the remaining values are
305
- escaped. If the output is not empty it comes back **with a leading space**, so
306
- `<div${attrs(...)}>` is always formatted correctly.
307
-
308
- ```js
309
- `<input${attrs({ type: "text", required: true, value: null })}>`;
310
- // '<input type="text" required>'
311
- ```
312
-
313
- ### `cx(...inputs)`
314
-
315
- The `clsx` equivalent: it accepts strings, numbers, arrays and
316
- `{ className: condition }` objects, and drops falsy values. It does **not**
317
- resolve Tailwind conflicts.
318
-
319
- ```js
320
- cx("btn", isActive && "btn-active", { "btn-lg": size === "lg" });
321
- ```
322
-
323
- ### `cn(...inputs)`
324
-
325
- Merges with `cx()`, then resolves Tailwind conflicts with `tailwind-merge`. Use
326
- this when a component's default classes need to be overridable by the caller.
327
-
328
- ```js
329
- cn("px-4 py-2 bg-slate-100", className); // if className is "bg-white", bg-slate-100 drops
330
- ```
331
-
332
- `tailwind-merge` is kept as a runtime dependency because class computation
333
- happens only on the server; it never enters the client bundle.
334
-
335
- ### `jsonScript(value)`
336
-
337
- Safe JSON for the body of a `<script type="application/ld+json">`: `<`, `>`,
338
- `&` and U+2028/U+2029 are escaped, so a `</script` or `<!--` sequence cannot
339
- close the body.
340
-
341
- ```ejs
342
- <script type="application/ld+json"><%- jsonScript(article) %></script>
343
- ```
344
-
345
- ## Helpers: `jskelet/tags`
346
-
347
- The equivalents of `next/link`, `next/image` and `@phosphor-icons/react`. They
348
- all return HTML strings and are emitted from EJS with `<%- %>`.
349
-
350
- ### `link(props)`
351
-
352
- ```js
353
- link({
354
- href: "/about",
355
- text: "About",
356
- class: "font-semibold",
357
- // optional: html, title, ariaLabel, target, rel, attrs
358
- });
359
- ```
360
-
361
- - If `title` is not given it is filled in automatically in the order
362
- `ariaLabel` → `text` → `href`.
363
- - If `href` starts with `http://` or `https://`, `target="_blank"` and
364
- `rel="noopener noreferrer"` are added automatically; if you give them
365
- explicitly your values are used.
366
- - If `html` is given the content is emitted raw; if `text` is given it is
367
- escaped.
368
- - The `attrs` object passes extra attributes through and overrides the previous
369
- ones.
370
-
371
- ### `image(props)`
372
-
373
- ```js
374
- image({
375
- src: "/hero.png",
376
- alt: "Kapak",
377
- priority: true,
378
- // optional: width, height, class, sizes, srcset, fill, loading,
379
- // unoptimized, attrs
380
- });
381
- ```
382
-
383
- Behaviour:
384
-
385
- - For local raster images under `public/`, the webp variants generated at build
386
- time (`.jskelet/images.json`) are added automatically as `srcset` plus
387
- intrinsic `width`/`height`. Local paths missing from the manifest are emitted
388
- as-is.
389
- - When `images.remote.allowHosts` is set, remote `http(s)` URLs are rewritten to
390
- the `/_jskelet/image?url=&w=` proxy (webp). If `width` is set, `srcset`
391
- includes 1x/2x plus config `widths`.
392
- - If `srcset` is given by hand, or `unoptimized: true` is set, neither the
393
- manifest nor the remote proxy is used.
394
- - If only **one** variant was produced (because the source is already small),
395
- `srcset`/`sizes` are not written; they would be pure noise. For remote images,
396
- a single width still rewrites `src` to the optimized URL.
397
- - If `sizes` is not given a reasonable default is produced: the image is not
398
- scaled beyond its own intrinsic width, and it fills the viewport on narrow
399
- screens (`(max-width: Npx) 100vw, Npx`).
400
- - `priority: true` → `loading="eager"`, `decoding="sync"`,
401
- `fetchpriority="high"`. For the LCP image.
402
- - Without `priority` → `loading="lazy"`, `decoding="async"`.
403
- - `fill: true` → `width`/`height` are not written and the classes
404
- `absolute inset-0 h-full w-full object-cover` are merged in with `cn()`.
405
-
406
- ### `icon(props)`
407
-
408
- Emits a `<use>` from the SVG sprite generated at build time.
409
-
410
- ```js
411
- icon({ name: "ArrowRight", weight: "bold", size: 20, class: "text-slate-500" });
412
- // <svg width="20" height="20" class="…" aria-hidden="true" focusable="false"
413
- // fill="currentColor" viewBox="0 0 256 256"><use href="/assets/sprite.<hash>.svg#arrow-right-bold"></use></svg>
414
- ```
415
-
416
- - `name` is the Phosphor name; the forms `ArrowRightIcon` and `ArrowRight` are
417
- accepted too and converted to `arrow-right` (`toKebab()`).
418
- - `weight` is part of the sprite id: `thin`, `light`, `regular` (the default),
419
- `bold`, `fill`, `duotone`.
420
- - `size` defaults to 24; it is written as `width` and `height`.
421
- - In development a one-time warning is printed when a symbol that is not in the
422
- sprite is requested. The sprite contains only the names that are visible
423
- **statically** in the source; if a call whose name is computed at runtime
424
- points at a missing symbol, the screen is silently left blank
425
- ([08-build.md](./08-build.md)).
426
-
427
- ### `preloadImage(props)`
428
-
429
- ```js
430
- preloadImage({ href: "/assets/img/hero-1280.abc.webp", imagesrcset, imagesizes });
431
- // <link rel="preload" as="image" href="…" fetchpriority="high">
432
- ```
433
-
434
- In practice `headHints()` is used rather than calling this directly:
435
-
436
- ```js
437
- import { headHints } from "jskelet";
438
-
439
- return {
440
- view: "pages/article",
441
- head: headHints({ href: cover, imageSrcSet, imageSizes }),
442
- };
443
- ```
444
-
445
- `headHints()` returns the empty string when there is no `href`, so you do not
446
- need to write a condition. Preconnects are not repeated here because the layout
447
- already emits them on every page.
448
-
449
- ## Metadata → `<head>`
450
-
451
- The controller returns `metadata` and the framework turns it into tags (the
452
- equivalent of Next.js's Metadata API). The schema is deliberately small; if you
453
- need more, raw HTML is added through `extraTags`, so the framework does not
454
- have to cut a release for every new kind of meta tag.
455
-
456
- | Field | Type | Meaning |
457
- | --- | --- | --- |
458
- | `title` | `string` | `<title>` |
459
- | `titleTemplate` | `string` | `"%s \| Site"` — `title` is embedded into it. Applied only if `title` is also present. |
460
- | `description` | `string` | `<meta name="description">` |
461
- | `canonical` | `string` | Absolute or relative URL |
462
- | `siteUrl` | `string` | Base for making a relative `canonical` absolute |
463
- | `robots` | `{ index?: boolean, follow?: boolean }` | Defaults to `index, follow` |
464
- | `locale` | `string` | `og:locale` |
465
- | `openGraph` | `{ title, description, url, type, siteName, image, imageWidth, imageHeight }` | `og:*` tags |
466
- | `twitter` | `{ card, site, creator, title, description, image }` | `twitter:*` tags |
467
- | `extraTags` | `string[]` | Raw tags to be emitted as-is |
468
-
469
- Generation rules:
470
-
471
- - **The robots default is indexable.** Hiding a page should be an explicit
472
- decision: `robots: { index: false }` → `noindex, follow`.
473
- - **OpenGraph uses `property`, not `name`.** Some scrapers ignore og tags
474
- written with `name`.
475
- - **Inheritance chain:** if there is no `og:title` then `title`, no
476
- `og:description` then `description`, no `og:url` then the absolutised
477
- `canonical`, no `twitter:title` then `og:title` → `title`, no
478
- `twitter:image` then `og:image`.
479
- - **`twitter:card`**, if not given, is `summary_large_image` when there is an
480
- `og:image` and `summary` otherwise.
481
- - **Empty values are never emitted:** fields that are `null`, `undefined` or
482
- `""` produce no tag.
483
- - If `og:type` is not given it is `website`.
484
-
485
- Example:
486
-
487
- ```js
488
- return {
489
- view: "pages/article",
490
- metadata: {
491
- title: article.title,
492
- description: article.summary,
493
- canonical: `/news/${article.slug}`,
494
- openGraph: {
495
- type: "article",
496
- image: article.cover,
497
- imageWidth: 1200,
498
- imageHeight: 630,
499
- },
500
- extraTags: [`<meta property="article:published_time" content="${article.date}">`],
501
- },
502
- };
503
- ```
504
-
505
- Put fields that are the same on every page, such as `titleTemplate` and
506
- `siteUrl`, into `hooks.metadata()`; the controller only supplies what is
507
- specific to the page.
508
-
509
- The `renderHeadMeta(metadata)` function is exported; it can be used when you
510
- need to produce the same tags outside the layout (for example in a fragment or
511
- an email).
512
-
513
- ## robots.txt
514
-
515
- The application writes `robots.txt`: `public/robots.txt` or a plain route.
516
- The framework does not change that body; it appends a JSkelet note and
517
- `Disallow` rules **under** a successful text response. If there is no file
518
- and no route, the framework does not invent a `robots.txt`.
519
-
520
- Paths added:
521
-
522
- - `/_jskelet/` — admin panel, remote image proxy, auth handoff
523
- - `/__jskelet/` — development tools
524
- - `/_fragment/` — partial responses without a layout
525
-
526
- An endpoint moved off those prefixes is added too, but only when it is
527
- actually mounted: `admin.basePath`, `images.remote.path`,
528
- `auth.crossSubdomainHandoff.path`. `brand.devBasePath` is written only in
529
- development; in production that path may be the application's own page.
530
-
531
- The note starts with the configured brand name (`brand.name`, default
532
- `JSkelet`). The trailing group repeats `User-agent: *` together with every
533
- other agent already named in the file. Google does not merge a
534
- crawler-specific group with `*`; it does merge a second group for the same
535
- agent. If the note is already in the file, it is not appended again.
536
-
537
- ## Dynamic OG images
538
-
539
- Counterpart to Next.js `ImageResponse` / `opengraph-image.tsx`. There is no JSX:
540
- pass card fields (`title`, `description`, `siteName`, colours) or a raw `svg`.
541
- With the optional `sharp` peer installed the response is PNG; otherwise SVG.
542
- Most social scrapers expect PNG, so install `sharp` in production.
543
-
544
- Because the response is an image, not HTML, do not use `route()` — `ogHandler`
545
- returns a plain Express handler. `notFound()` and a `null` return yield 404.
546
-
547
- ```js
548
- // routes/35-og.mjs
549
- export default function register(app, { ogHandler, notFound }) {
550
- app.get(
551
- "/og/blog/:slug.png",
552
- ogHandler(async ({ params }) => {
553
- const post = getPost(params.slug);
554
- if (!post) notFound();
555
- return {
556
- title: post.title,
557
- description: post.excerpt,
558
- siteName: "Blog",
559
- };
560
- }),
561
- );
562
- }
563
- ```
564
-
565
- Point page metadata at the absolute URL and size:
566
-
567
- ```js
568
- openGraph: {
569
- type: "article",
570
- image: `${SITE_URL}/og/blog/${post.slug}.png`,
571
- imageWidth: 1200,
572
- imageHeight: 630,
573
- },
574
- ```
575
-
576
- Raw SVG or a Next-like class:
577
-
578
- ```js
579
- import { ImageResponse, sendOgImage, OG_SIZE } from "jskelet";
580
-
581
- app.get("/og/custom.png", async (req, res) => {
582
- const image = new ImageResponse(
583
- `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">…</svg>`,
584
- OG_SIZE,
585
- );
586
- await image.send(res);
587
- // or: await sendOgImage(res, { title: "…", format: "svg" });
588
- });
589
- ```
590
-
591
- Default `Cache-Control`:
592
- `public, max-age=0, s-maxage=86400, stale-while-revalidate=604800`.
593
- Override with `cacheControl`. Working example: `examples/blog/routes/35-og.mjs`.
594
-
595
- ## Hooks
596
-
597
- Hooks are defined in `jskelet.config.mjs` under `hooks`. They are all optional
598
- and they can all be `async`. **A failing hook does not take the page down:**
599
- the framework falls back to its own default and warns.
600
-
601
- ### `hooks.metadata(page)`
602
-
603
- The metadata default for every page. It receives the page definition being
604
- rendered as its argument and returns a metadata object. The controller's
605
- `metadata` field is layered **on top of it** (field by field, shallow merge).
606
-
607
- ```js
608
- hooks: {
609
- metadata() {
610
- return {
611
- titleTemplate: "%s | JSkelet",
612
- description: "A site built with JSkelet.",
613
- siteUrl: "https://example.com",
614
- };
615
- },
616
- }
617
- ```
618
-
619
- ### `hooks.layoutContext({ pathname, metadata })`
620
-
621
- The locals added to the layout on every render. **Every field** of the returned
622
- object becomes a layout local; in addition three fields are interpreted
623
- specially:
624
-
625
- - `lang` → `<html lang>`
626
- - `structuredData` → JSON-LD scripts (an array)
627
- - `extraHead` → appended to `<head>` (after the controller's `head`)
628
- - `bodyClass` → used if the controller did not supply a `bodyClass`
629
-
630
- ```js
631
- hooks: {
632
- async layoutContext({ pathname }) {
633
- return {
634
- bodyClass: "min-h-full",
635
- navigation: await getNavigation(),
636
- isHome: pathname === "/",
637
- };
638
- },
639
- }
640
- ```
641
-
642
- This hook runs **in parallel** with the body render; calling upstream inside it
643
- does not add sequential latency to the page.
644
-
645
- ### `hooks.notFound()`
646
-
647
- The 404 page definition. The object it returns is handed to `renderPage` with
648
- `pathname: "/404"`. Details: [03-routing.md](./03-routing.md).
649
-
650
- ### Other hooks
651
-
652
- `hooks.prewarmPaths()` belongs to prewarming rather than the render layer; see
653
- [06-caching.md](./06-caching.md).
654
-
655
- ## The overlay portal point
656
-
657
- `jskelet/client` → `getOverlayRoot()` gives the target that modal and drawer
658
- content will be moved into: if the layout has
659
- `<div id="jskelet-overlays"></div>` it goes there, otherwise into `body`. The
660
- portal prevents an ancestor element carrying `overflow` or `transform` from
661
- clipping a `position: fixed` overlay. If you are going to use modals, adding
662
- this div at the end of the layout's `<body>` is enough
663
- ([05-islands.md](./05-islands.md)).
664
-
665
- ## What's next
666
-
667
- - Islands and `entries`: [05-islands.md](./05-islands.md)
668
- - `asset()`, the manifest and the Tailwind scan: [08-build.md](./08-build.md)
669
- - Where hooks live in the config: [07-configuration.md](./07-configuration.md)
1
+ # 04 — Rendering and templates
2
+
3
+ This document explains how server HTML is produced: the EJS engine settings,
4
+ how the layout file is resolved and which locals it can use, the page templates
5
+ under `views/pages`, the automatic registration of the components under
6
+ `views/components/**`, the `html`/`tags` helpers that templates receive for
7
+ free, the translation of the `metadata` object into `<head>` tags, and the
8
+ three render hooks. What the controller sends into this layer is covered in
9
+ [03-routing.md](./03-routing.md), and `asset()`/`hasAsset()`, which produce
10
+ asset URLs, in [08-build.md](./08-build.md).
11
+
12
+ ## The render pipeline
13
+
14
+ ```
15
+ route(controller)
16
+ └─ produce()
17
+ ├─ controller(ctx) → page definition
18
+ └─ renderPage(page)
19
+ ├─ hooks.metadata(page) + page.metadata → metadata
20
+ ├─ Promise.all([
21
+ │ renderView(page.view, { …data, metadata }), → body
22
+ │ hooks.layoutContext({ pathname, metadata }), → context
23
+ │ ])
24
+ └─ layout (.jsk compiled or .ejs) → full HTML
25
+ ```
26
+
27
+ The layout context and the body are produced **in parallel**. The reason comes
28
+ from measurement: in most projects navigation comes from upstream, and waiting
29
+ for it in sequence with the body render adds needless latency to every page.
30
+
31
+ ## `.jsk` — build-time compiled templates
32
+
33
+ New apps default to `.jsk`. At build time they become normal ESM modules under
34
+ `.jskelet/templates/*.mjs`. There is **no request-time parsing, `eval`, or
35
+ `new Function`**. Production path:
36
+
37
+ ```
38
+ controller data → imported render(data, helpers) → HTML
39
+ ```
40
+
41
+ ### Syntax summary
42
+
43
+ ```html
44
+ <section class="wrapper">
45
+ <h1>{{ title }}</h1>
46
+ <div>{{{ trustedHtml }}}</div>
47
+
48
+ {#if items.length}
49
+ <List :items="items" />
50
+ {#else}
51
+ <p>Empty</p>
52
+ {/if}
53
+
54
+ {#each items as item, i}
55
+ <li :data-i="i">{{ item }}</li>
56
+ {/each}
57
+
58
+ <Link href="/" text="Home" />
59
+ <div data-island="counter" data-island-props='{"start":0}'></div>
60
+ </section>
61
+ ```
62
+
63
+ | Feature | Form |
64
+ | --- | --- |
65
+ | Escaped text | `{{ expr }}` |
66
+ | Raw HTML | `{{{ expr }}}` |
67
+ | Conditional | `{#if expr}` … `{#else}` … `{/if}` |
68
+ | Loop | `{#each list as item}` or `as item, i` |
69
+ | Include | `{#include "partials/header"}` (compiled `.jsk`) |
70
+ | Component | PascalCase tag; `:prop="expr"`, `prop="literal"`, boolean `disabled` |
71
+ | Built-ins | `Link`, `Image`, `Icon`, `CsrfField`, `PreloadImage`, `Stylesheets`, `BodyScripts`, `JsonLd` |
72
+
73
+ The expression language is intentionally small (access, compare, ternary,
74
+ `.length`). No assignments, object literals, or arbitrary calls — keep logic in
75
+ controllers or JS components.
76
+
77
+ #### Template or component?
78
+
79
+ When moving off EJS, draw the line early:
80
+
81
+ | Stay in `.jsk` | Move to a JS component |
82
+ | --- | --- |
83
+ | Text, conditionals, lists, prop binding | Function calls, object construction, formatting |
84
+ | Built-in tags (`Link`, `Image`, …) | Composing HTML from several helpers |
85
+ | Ready-made data from the controller | Upstream / error-aware UI (`LoadErrorState`) |
86
+
87
+ If the template cannot write `format(x)` or `{ a: 1 }`, that is intentional: the
88
+ work belongs in `views/components/*.js` or the controller. Prefer a clear
89
+ component boundary over widening the expression language when complex pages
90
+ “escape” into JS.
91
+
92
+ ### Editor support
93
+
94
+ `extensions/vscode-jsk` is a VS Code / Cursor extension in this repo: syntax
95
+ highlighting, language configuration, and snippets. Local install:
96
+
97
+ ```bash
98
+ code --install-extension extensions/vscode-jsk
99
+ ```
100
+
101
+ See the extension README for details.
102
+
103
+ ### Coexistence with EJS
104
+
105
+ If a compiled `.jsk` exists for a view id it wins; otherwise `.ejs` is rendered
106
+ with EJS. Existing apps keep working unchanged. `jskelet init` scaffolds `.jsk`.
107
+ Legacy `.ejs` needs the optional `ejs` peer installed in the application
108
+ (`npm install ejs`); without it only `.jsk` templates run.
109
+
110
+ ## The EJS engine (legacy)
111
+
112
+ EJS remains supported as an **optional peer dependency** for legacy templates.
113
+ The engine is set up once on the first render; the component scan touches the
114
+ file system, so it cannot be done on every request and cannot be computed
115
+ before the config is loaded.
116
+
117
+ Settings:
118
+
119
+ | Setting | Value | Reason |
120
+ | --- | --- | --- |
121
+ | `root`, `views` | the `views` directory | `include('partials/header')` calls resolve from the views root |
122
+ | `cache` | `false` in dev, `true` in prod | so template edits show up instantly in dev |
123
+ | `rmWhitespace` | `true` | output size |
124
+ | `async` | `true` | `await` can be used inside templates |
125
+
126
+ For embedded uses (tests, scripts) `resetRenderEngine()` is exported: it
127
+ refreshes the registry when component files change. It is not needed in the
128
+ normal flow because the dev server restarts the process.
129
+
130
+ ## Layout
131
+
132
+ ### How the layout file is found
133
+
134
+ 1. `jskelet.config.mjs` → if `layout` is given, it is used. The path is
135
+ resolved relative to the **parent directory of the views directory**: if
136
+ `views` is the default, `layout: "views/custom.jsk"` → `<root>/views/custom.jsk`.
137
+ 2. If it is not given and `views/layout.jsk` exists (compiled), that is used.
138
+ 3. Else if `views/layout.ejs` exists (legacy), that is used.
139
+ 4. If that does not exist either, the framework's own minimal layout is used
140
+ (`node_modules/jskelet/src/templates/layout.jsk`, also reachable through the
141
+ `jskelet/layout` specifier).
142
+
143
+ These fallbacks exist so that a new project can work with a single route. The
144
+ most practical way to move to your own layout is to copy that file to
145
+ `views/layout.jsk`.
146
+
147
+ ### The framework's default layout
148
+
149
+ ```html
150
+ <!DOCTYPE html>
151
+ <html :lang="lang">
152
+ <head>
153
+ <meta charset="utf-8">
154
+ <meta name="viewport" content="width=device-width, initial-scale=1">
155
+ {{{ extraHead }}}
156
+ <Stylesheets :styles="styles" />
157
+ {{{ headMeta }}}
158
+ <JsonLd :items="structuredData" />
159
+ </head>
160
+ <body :class="bodyClass">
161
+ {{{ body }}}
162
+ <BodyScripts :entries="entries" :devtools="devtools" :devBasePath="devBasePath" />
163
+ </body>
164
+ </html>
165
+ ```
166
+
167
+ The `.jsk` expression language has no function calls, so asset loops live in the
168
+ built-in `<Stylesheets />`, `<BodyScripts />` and `<JsonLd />` tags instead of
169
+ inline `hasAsset` / `asset` / `forEach` in the layout.
170
+
171
+ Points to watch:
172
+
173
+ - **`extraHead` comes first.** Delaying resource hints (`preconnect`, LCP
174
+ `preload`) writes straight into LCP.
175
+ - **`<Stylesheets />` emits global `app.css` (render-blocking)** plus controller
176
+ `styles: [...]`, with the reasoning in
177
+ [02-architecture.md](./02-architecture.md). If the build has not run,
178
+ `hasAsset` is false inside the tag and nothing is emitted.
179
+ - **`<BodyScripts />` emits `main.js`, page `entries`, and the
180
+ development-only overlay.** The overlay script exists only when
181
+ `NODE_ENV=development`; it is absent from production output.
182
+ - **`<JsonLd />` turns `structuredData` into safe
183
+ `application/ld+json` scripts.**
184
+
185
+ ### Layout locals
186
+
187
+ | Local | Type | Source |
188
+ | --- | --- | --- |
189
+ | `metadata` | `object` | `hooks.metadata()` + controller `metadata` (the controller wins) |
190
+ | `headMeta` | `string` | ready-made `<head>` tags produced from `metadata` |
191
+ | `extraHead` | `string` | `preconnect` hints + `navigation` hints + controller `head` + `context.extraHead` |
192
+ | `structuredData` | `unknown[]` | `hooks.layoutContext()` → `structuredData`; defaults to `[]` |
193
+ | `body` | `string` | The render output of the page template |
194
+ | `bodyClass` | `string` | controller `bodyClass` → `context.bodyClass` → `""` |
195
+ | `entries` | `string[]` | controller `entries`; defaults to `[]` |
196
+ | `styles` | `string[]` | controller `styles`; defaults to `[]` |
197
+ | `pathname` | `string` | `req.path`; **defaults to the empty string** |
198
+ | `lang` | `string` | `context.lang` → `brand.lang` → `"en"` |
199
+ | `devtools` | `boolean` | `NODE_ENV === "development"` |
200
+ | `devBasePath` | `string` | `brand.devBasePath`, defaults to `/__jskelet/dev` |
201
+ | `asset`, `hasAsset` | function | Manifest access |
202
+ | html/tags helpers | function | `esc`, `attrs`, `cx`, `cn`, `jsonScript`, `link`, `image`, `icon`, `preloadImage`, `toKebab` |
203
+ | exports of `views/components/**` | function | Automatic registration |
204
+ | every field returned by `hooks.layoutContext()` | — | Becomes a local directly |
205
+
206
+ The empty default for `pathname` is deliberate: writing `"/"` leads to the kind
207
+ of bug where every page thinks it is the home page and renders the logo as an
208
+ `<h1>`.
209
+
210
+ ## Page templates
211
+
212
+ The `view` field gives the path under `views/` without an extension:
213
+ `"pages/home"` → `views/pages/home.jsk` (else legacy `home.ejs`). The locals
214
+ passed to the template are the contents of the `data` field plus `metadata` —
215
+ **not** the layout locals. The page template still has access to all helpers
216
+ and components.
217
+
218
+ ```html
219
+ {# views/pages/home.jsk #}
220
+ <section class="wrapper">
221
+ <h1 class="text-3xl font-bold">{{ heading }}</h1>
222
+
223
+ {# `list` is defined in views/components/list.js; no import needed. #}
224
+ <List :items="items" />
225
+
226
+ <div class="mt-8" data-island="counter" data-island-props='{"start":5}'></div>
227
+ </section>
228
+ ```
229
+
230
+ In `.jsk`, `{{ }}` escapes and `{{{ }}}` emits raw HTML (trusted strings only).
231
+ Legacy EJS keeps `<%= %>` / `<%- %>` with the same meaning; because `async: true`
232
+ is on there, `await` can also be used inside an `.ejs` template, but keeping
233
+ data fetching in the controller makes diagnosis easier.
234
+
235
+ ## Components: `views/components/**`
236
+
237
+ Components are not EJS partials but **functions that return HTML strings**.
238
+ Every `.js` file under `views/components/**` is scanned and **every named
239
+ export** becomes a template local. There is no hand-maintained barrel file:
240
+ creating the file is enough to add a new component.
241
+
242
+ ```js
243
+ // views/components/list.js
244
+ import { esc } from "jskelet/html";
245
+
246
+ /**
247
+ * @param {{ items: string[] }} props
248
+ * @returns {string}
249
+ */
250
+ export function list({ items }) {
251
+ if (!items?.length) return "";
252
+
253
+ const rows = items.map((item) => `<li class="py-1">${esc(item)}</li>`).join("");
254
+ return `<ul class="mt-6 list-disc pl-6">${rows}</ul>`;
255
+ }
256
+ ```
257
+
258
+ In the template:
259
+
260
+ ```ejs
261
+ <%- list({ items }) %>
262
+ ```
263
+
264
+ Rules:
265
+
266
+ - The scan is recursive; subdirectories are covered too.
267
+ - `default` exports are ignored — only named exports are registered.
268
+ - The compile-time known-component set is read from **named exports in the
269
+ source**, not from the file basename. `sectionHead` in `ui.js` →
270
+ `<SectionHead />` in the template (runtime already adds a PascalCase alias
271
+ for camelCase exports). You do not need a stub re-export named after the
272
+ file.
273
+ - `loader.js` and `index.js` do not count as component files.
274
+ - If `views/components/index.js` exists it is loaded first as a **barrel**,
275
+ with the lowest priority. Its only purpose is to turn `lib/` re-exports into
276
+ template locals; the components' own files come later and silently overwrite
277
+ it.
278
+ - If the same name (or the same PascalCase tag) is defined in two different
279
+ component files, that is an **error, not a warning**: build and server
280
+ startup stop with `Component 'card' is defined twice: …`. Overwriting the
281
+ barrel is the deliberate exception.
282
+ - If the `views/components` directory does not exist the component registry
283
+ stays empty; a project that uses no components works fine too.
284
+
285
+ ## Helpers: `jskelet/html`
286
+
287
+ They are passed to templates automatically; in component files you get them
288
+ with `import { … } from "jskelet/html"`.
289
+
290
+ ### `esc(value)`
291
+
292
+ Escaping for text content and attribute values (`&`, `<`, `>`, `"`, `'`).
293
+ `null`, `undefined` and `false` are turned into the empty string — so in
294
+ conditional rendering an expression like `false && "…"` does not print
295
+ `"false"`.
296
+
297
+ ```js
298
+ esc('<b>"x"</b>'); // "&lt;b&gt;&quot;x&quot;&lt;/b&gt;"
299
+ ```
300
+
301
+ ### `attrs(object)`
302
+
303
+ Turns an attribute object into a string. `null`/`undefined`/`false` are
304
+ skipped, `true` is written as a boolean attribute, and the remaining values are
305
+ escaped. If the output is not empty it comes back **with a leading space**, so
306
+ `<div${attrs(...)}>` is always formatted correctly.
307
+
308
+ ```js
309
+ `<input${attrs({ type: "text", required: true, value: null })}>`;
310
+ // '<input type="text" required>'
311
+ ```
312
+
313
+ ### `cx(...inputs)`
314
+
315
+ The `clsx` equivalent: it accepts strings, numbers, arrays and
316
+ `{ className: condition }` objects, and drops falsy values. It does **not**
317
+ resolve Tailwind conflicts.
318
+
319
+ ```js
320
+ cx("btn", isActive && "btn-active", { "btn-lg": size === "lg" });
321
+ ```
322
+
323
+ ### `cn(...inputs)`
324
+
325
+ Merges with `cx()`, then resolves Tailwind conflicts with `tailwind-merge`. Use
326
+ this when a component's default classes need to be overridable by the caller.
327
+
328
+ ```js
329
+ cn("px-4 py-2 bg-slate-100", className); // if className is "bg-white", bg-slate-100 drops
330
+ ```
331
+
332
+ `tailwind-merge` is kept as a runtime dependency because class computation
333
+ happens only on the server; it never enters the client bundle.
334
+
335
+ ### `jsonScript(value)`
336
+
337
+ Safe JSON for the body of a `<script type="application/ld+json">`: `<`, `>`,
338
+ `&` and U+2028/U+2029 are escaped, so a `</script` or `<!--` sequence cannot
339
+ close the body.
340
+
341
+ ```ejs
342
+ <script type="application/ld+json"><%- jsonScript(article) %></script>
343
+ ```
344
+
345
+ ## Helpers: `jskelet/tags`
346
+
347
+ The equivalents of `next/link`, `next/image` and `@phosphor-icons/react`. They
348
+ all return HTML strings and are emitted from EJS with `<%- %>`.
349
+
350
+ ### `link(props)`
351
+
352
+ ```js
353
+ link({
354
+ href: "/about",
355
+ text: "About",
356
+ class: "font-semibold",
357
+ // optional: html, title, ariaLabel, target, rel, attrs
358
+ });
359
+ ```
360
+
361
+ - If `title` is not given it is filled in automatically in the order
362
+ `ariaLabel` → `text` → `href`.
363
+ - If `href` starts with `http://` or `https://`, `target="_blank"` and
364
+ `rel="noopener noreferrer"` are added automatically; if you give them
365
+ explicitly your values are used.
366
+ - If `html` is given the content is emitted raw; if `text` is given it is
367
+ escaped.
368
+ - The `attrs` object passes extra attributes through and overrides the previous
369
+ ones.
370
+
371
+ ### `image(props)`
372
+
373
+ ```js
374
+ image({
375
+ src: "/hero.png",
376
+ alt: "Kapak",
377
+ priority: true,
378
+ // optional: width, height, class, sizes, srcset, fill, loading,
379
+ // unoptimized, attrs
380
+ });
381
+ ```
382
+
383
+ Behaviour:
384
+
385
+ - For local raster images under `public/`, the webp variants generated at build
386
+ time (`.jskelet/images.json`) are added automatically as `srcset` plus
387
+ intrinsic `width`/`height`. Local paths missing from the manifest are emitted
388
+ as-is.
389
+ - When `images.remote.allowHosts` is set, remote `http(s)` URLs are rewritten to
390
+ the `/_jskelet/image?url=&w=` proxy (webp). If `width` is set, `srcset`
391
+ includes 1x/2x plus config `widths`.
392
+ - If `srcset` is given by hand, or `unoptimized: true` is set, neither the
393
+ manifest nor the remote proxy is used.
394
+ - If only **one** variant was produced (because the source is already small),
395
+ `srcset`/`sizes` are not written; they would be pure noise. For remote images,
396
+ a single width still rewrites `src` to the optimized URL.
397
+ - If `sizes` is not given a reasonable default is produced: the image is not
398
+ scaled beyond its own intrinsic width, and it fills the viewport on narrow
399
+ screens (`(max-width: Npx) 100vw, Npx`).
400
+ - `priority: true` → `loading="eager"`, `decoding="sync"`,
401
+ `fetchpriority="high"`. For the LCP image.
402
+ - Without `priority` → `loading="lazy"`, `decoding="async"`.
403
+ - `fill: true` → `width`/`height` are not written and the classes
404
+ `absolute inset-0 h-full w-full object-cover` are merged in with `cn()`.
405
+
406
+ ### `icon(props)`
407
+
408
+ Emits a `<use>` from the SVG sprite generated at build time.
409
+
410
+ ```js
411
+ icon({ name: "ArrowRight", weight: "bold", size: 20, class: "text-slate-500" });
412
+ // <svg width="20" height="20" class="…" aria-hidden="true" focusable="false"
413
+ // fill="currentColor" viewBox="0 0 256 256"><use href="/assets/sprite.<hash>.svg#arrow-right-bold"></use></svg>
414
+ ```
415
+
416
+ - `name` is the Phosphor name; the forms `ArrowRightIcon` and `ArrowRight` are
417
+ accepted too and converted to `arrow-right` (`toKebab()`).
418
+ - `weight` is part of the sprite id: `thin`, `light`, `regular` (the default),
419
+ `bold`, `fill`, `duotone`.
420
+ - `size` defaults to 24; it is written as `width` and `height`.
421
+ - In development a one-time warning is printed when a symbol that is not in the
422
+ sprite is requested. The sprite contains only the names that are visible
423
+ **statically** in the source; if a call whose name is computed at runtime
424
+ points at a missing symbol, the screen is silently left blank
425
+ ([08-build.md](./08-build.md)).
426
+
427
+ ### `preloadImage(props)`
428
+
429
+ ```js
430
+ preloadImage({ href: "/assets/img/hero-1280.abc.webp", imagesrcset, imagesizes });
431
+ // <link rel="preload" as="image" href="…" fetchpriority="high">
432
+ ```
433
+
434
+ In practice `headHints()` is used rather than calling this directly:
435
+
436
+ ```js
437
+ import { headHints } from "jskelet";
438
+
439
+ return {
440
+ view: "pages/article",
441
+ head: headHints({ href: cover, imageSrcSet, imageSizes }),
442
+ };
443
+ ```
444
+
445
+ `headHints()` returns the empty string when there is no `href`, so you do not
446
+ need to write a condition. Preconnects are not repeated here because the layout
447
+ already emits them on every page.
448
+
449
+ ## Metadata → `<head>`
450
+
451
+ The controller returns `metadata` and the framework turns it into tags (the
452
+ equivalent of Next.js's Metadata API). The schema is deliberately small; if you
453
+ need more, raw HTML is added through `extraTags`, so the framework does not
454
+ have to cut a release for every new kind of meta tag.
455
+
456
+ | Field | Type | Meaning |
457
+ | --- | --- | --- |
458
+ | `title` | `string` | `<title>` |
459
+ | `titleTemplate` | `string` | `"%s \| Site"` — `title` is embedded into it. Applied only if `title` is also present. |
460
+ | `description` | `string` | `<meta name="description">` |
461
+ | `canonical` | `string` | Absolute or relative URL |
462
+ | `siteUrl` | `string` | Base for making a relative `canonical` absolute |
463
+ | `robots` | `{ index?: boolean, follow?: boolean }` | Defaults to `index, follow` |
464
+ | `locale` | `string` | `og:locale` |
465
+ | `openGraph` | `{ title, description, url, type, siteName, image, imageWidth, imageHeight }` | `og:*` tags |
466
+ | `twitter` | `{ card, site, creator, title, description, image }` | `twitter:*` tags |
467
+ | `extraTags` | `string[]` | Raw tags to be emitted as-is |
468
+
469
+ Generation rules:
470
+
471
+ - **The robots default is indexable.** Hiding a page should be an explicit
472
+ decision: `robots: { index: false }` → `noindex, follow`.
473
+ - **OpenGraph uses `property`, not `name`.** Some scrapers ignore og tags
474
+ written with `name`.
475
+ - **Inheritance chain:** if there is no `og:title` then `title`, no
476
+ `og:description` then `description`, no `og:url` then the absolutised
477
+ `canonical`, no `twitter:title` then `og:title` → `title`, no
478
+ `twitter:image` then `og:image`.
479
+ - **`twitter:card`**, if not given, is `summary_large_image` when there is an
480
+ `og:image` and `summary` otherwise.
481
+ - **Empty values are never emitted:** fields that are `null`, `undefined` or
482
+ `""` produce no tag.
483
+ - If `og:type` is not given it is `website`.
484
+
485
+ Example:
486
+
487
+ ```js
488
+ return {
489
+ view: "pages/article",
490
+ metadata: {
491
+ title: article.title,
492
+ description: article.summary,
493
+ canonical: `/news/${article.slug}`,
494
+ openGraph: {
495
+ type: "article",
496
+ image: article.cover,
497
+ imageWidth: 1200,
498
+ imageHeight: 630,
499
+ },
500
+ extraTags: [`<meta property="article:published_time" content="${article.date}">`],
501
+ },
502
+ };
503
+ ```
504
+
505
+ Put fields that are the same on every page, such as `titleTemplate` and
506
+ `siteUrl`, into `hooks.metadata()`; the controller only supplies what is
507
+ specific to the page.
508
+
509
+ The `renderHeadMeta(metadata)` function is exported; it can be used when you
510
+ need to produce the same tags outside the layout (for example in a fragment or
511
+ an email).
512
+
513
+ ## robots.txt
514
+
515
+ The application writes `robots.txt`: `public/robots.txt` or a plain route.
516
+ The framework does not change that body; it appends a JSkelet note and
517
+ `Disallow` rules **under** a successful text response. If there is no file
518
+ and no route, the framework does not invent a `robots.txt`.
519
+
520
+ Paths added:
521
+
522
+ - `/_jskelet/` — admin panel, remote image proxy, auth handoff
523
+ - `/__jskelet/` — development tools
524
+ - `/_fragment/` — partial responses without a layout
525
+
526
+ An endpoint moved off those prefixes is added too, but only when it is
527
+ actually mounted: `admin.basePath`, `images.remote.path`,
528
+ `auth.crossSubdomainHandoff.path`. `brand.devBasePath` is written only in
529
+ development; in production that path may be the application's own page.
530
+
531
+ The note starts with the configured brand name (`brand.name`, default
532
+ `JSkelet`). The trailing group repeats `User-agent: *` together with every
533
+ other agent already named in the file. Google does not merge a
534
+ crawler-specific group with `*`; it does merge a second group for the same
535
+ agent. If the note is already in the file, it is not appended again.
536
+
537
+ ## Dynamic OG images
538
+
539
+ Counterpart to Next.js `ImageResponse` / `opengraph-image.tsx`. There is no JSX:
540
+ pass card fields (`title`, `description`, `siteName`, colours) or a raw `svg`.
541
+ With the optional `sharp` peer installed the response is PNG; otherwise SVG.
542
+ Most social scrapers expect PNG, so install `sharp` in production.
543
+
544
+ Because the response is an image, not HTML, do not use `route()` — `ogHandler`
545
+ returns a plain Express handler. `notFound()` and a `null` return yield 404.
546
+
547
+ ```js
548
+ // routes/35-og.mjs
549
+ export default function register(app, { ogHandler, notFound }) {
550
+ app.get(
551
+ "/og/blog/:slug.png",
552
+ ogHandler(async ({ params }) => {
553
+ const post = getPost(params.slug);
554
+ if (!post) notFound();
555
+ return {
556
+ title: post.title,
557
+ description: post.excerpt,
558
+ siteName: "Blog",
559
+ };
560
+ }),
561
+ );
562
+ }
563
+ ```
564
+
565
+ Point page metadata at the absolute URL and size:
566
+
567
+ ```js
568
+ openGraph: {
569
+ type: "article",
570
+ image: `${SITE_URL}/og/blog/${post.slug}.png`,
571
+ imageWidth: 1200,
572
+ imageHeight: 630,
573
+ },
574
+ ```
575
+
576
+ Raw SVG or a Next-like class:
577
+
578
+ ```js
579
+ import { ImageResponse, sendOgImage, OG_SIZE } from "jskelet";
580
+
581
+ app.get("/og/custom.png", async (req, res) => {
582
+ const image = new ImageResponse(
583
+ `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">…</svg>`,
584
+ OG_SIZE,
585
+ );
586
+ await image.send(res);
587
+ // or: await sendOgImage(res, { title: "…", format: "svg" });
588
+ });
589
+ ```
590
+
591
+ Default headers (the durations are not tied to the HTML setting):
592
+
593
+ ```
594
+ Cache-Control: public, max-age=0
595
+ CDN-Cache-Control: max-age=86400, stale-while-revalidate=604800
596
+ ```
597
+
598
+ `cacheControl` overrides `Cache-Control`; in that case `CDN-Cache-Control` is
599
+ not written. Working example: `examples/blog/routes/35-og.mjs`.
600
+
601
+ ## Hooks
602
+
603
+ Hooks are defined in `jskelet.config.mjs` under `hooks`. They are all optional
604
+ and they can all be `async`. **A failing hook does not take the page down:**
605
+ the framework falls back to its own default and warns.
606
+
607
+ ### `hooks.metadata(page)`
608
+
609
+ The metadata default for every page. It receives the page definition being
610
+ rendered as its argument and returns a metadata object. The controller's
611
+ `metadata` field is layered **on top of it** (field by field, shallow merge).
612
+
613
+ ```js
614
+ hooks: {
615
+ metadata() {
616
+ return {
617
+ titleTemplate: "%s | JSkelet",
618
+ description: "A site built with JSkelet.",
619
+ siteUrl: "https://example.com",
620
+ };
621
+ },
622
+ }
623
+ ```
624
+
625
+ ### `hooks.layoutContext({ pathname, metadata })`
626
+
627
+ The locals added to the layout on every render. **Every field** of the returned
628
+ object becomes a layout local; in addition three fields are interpreted
629
+ specially:
630
+
631
+ - `lang` → `<html lang>`
632
+ - `structuredData` → JSON-LD scripts (an array)
633
+ - `extraHead` → appended to `<head>` (after the controller's `head`)
634
+ - `bodyClass` → used if the controller did not supply a `bodyClass`
635
+
636
+ ```js
637
+ hooks: {
638
+ async layoutContext({ pathname }) {
639
+ return {
640
+ bodyClass: "min-h-full",
641
+ navigation: await getNavigation(),
642
+ isHome: pathname === "/",
643
+ };
644
+ },
645
+ }
646
+ ```
647
+
648
+ This hook runs **in parallel** with the body render; calling upstream inside it
649
+ does not add sequential latency to the page.
650
+
651
+ ### `hooks.notFound()`
652
+
653
+ The 404 page definition. The object it returns is handed to `renderPage` with
654
+ `pathname: "/404"`. Details: [03-routing.md](./03-routing.md).
655
+
656
+ ### Other hooks
657
+
658
+ `hooks.prewarmPaths()` belongs to prewarming rather than the render layer; see
659
+ [06-caching.md](./06-caching.md).
660
+
661
+ ## The overlay portal point
662
+
663
+ `jskelet/client` → `getOverlayRoot()` gives the target that modal and drawer
664
+ content will be moved into: if the layout has
665
+ `<div id="jskelet-overlays"></div>` it goes there, otherwise into `body`. The
666
+ portal prevents an ancestor element carrying `overflow` or `transform` from
667
+ clipping a `position: fixed` overlay. If you are going to use modals, adding
668
+ this div at the end of the layout's `<body>` is enough
669
+ ([05-islands.md](./05-islands.md)).
670
+
671
+ ## What's next
672
+
673
+ - Islands and `entries`: [05-islands.md](./05-islands.md)
674
+ - `asset()`, the manifest and the Tailwind scan: [08-build.md](./08-build.md)
675
+ - Where hooks live in the config: [07-configuration.md](./07-configuration.md)