jskelet 0.2.4 → 0.2.5

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 (91) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +8 -0
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +287 -287
  7. package/docs/03-routing.md +480 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1209 -1209
  11. package/docs/08-build.md +366 -366
  12. package/docs/09-dev-araclari.md +335 -335
  13. package/docs/10-dagitim.md +329 -329
  14. package/docs/12-panel-ve-oturum.md +384 -384
  15. package/docs/README.md +105 -105
  16. package/docs/en/01-getting-started.md +292 -292
  17. package/docs/en/02-architecture.md +305 -305
  18. package/docs/en/03-routing.md +497 -497
  19. package/docs/en/04-rendering.md +504 -504
  20. package/docs/en/05-islands.md +492 -492
  21. package/docs/en/06-caching.md +1239 -1239
  22. package/docs/en/07-configuration.md +986 -986
  23. package/docs/en/08-build.md +383 -383
  24. package/docs/en/09-dev-tools.md +342 -342
  25. package/docs/en/10-deployment.md +332 -332
  26. package/docs/en/11-migration.md +359 -359
  27. package/docs/en/12-dashboards-and-sessions.md +392 -392
  28. package/docs/en/README.md +112 -112
  29. package/package.json +102 -102
  30. package/src/build/ensure-build.mjs +15 -15
  31. package/src/build/paths.mjs +143 -143
  32. package/src/build/resolve-peer.mjs +36 -36
  33. package/src/build/tasks/client.mjs +268 -268
  34. package/src/build/tasks/css.mjs +124 -124
  35. package/src/build/tasks/fonts.mjs +146 -146
  36. package/src/build/tasks/icons.mjs +224 -224
  37. package/src/build/tasks/images.mjs +244 -244
  38. package/src/build/tasks/precompress.mjs +78 -78
  39. package/src/client/cache-panel/i18n.js +670 -670
  40. package/src/client/cache-panel/login.html +74 -74
  41. package/src/client/cache-panel/panel.css +756 -756
  42. package/src/client/cache-panel/panel.html +308 -308
  43. package/src/client/cache-panel/panel.js +915 -915
  44. package/src/client/devtools/report.html +185 -185
  45. package/src/client/devtools/report.js +725 -725
  46. package/src/client/dom.js +95 -95
  47. package/src/client/form.js +192 -192
  48. package/src/client/index.js +35 -35
  49. package/src/client/registry.js +297 -297
  50. package/src/client/safe-image.js +91 -91
  51. package/src/client/store.js +36 -36
  52. package/src/client/swap.js +188 -188
  53. package/src/config/pattern.js +107 -107
  54. package/src/http/control-flow.js +71 -71
  55. package/src/http/cookies.js +257 -257
  56. package/src/http/request-cache.js +46 -46
  57. package/src/http/request-context.js +162 -162
  58. package/src/index.js +83 -83
  59. package/src/init.mjs +221 -221
  60. package/src/runtime/alias-hooks.mjs +119 -119
  61. package/src/runtime/register.mjs +4 -4
  62. package/src/server/assets.js +147 -147
  63. package/src/server/cache-deps.js +42 -42
  64. package/src/server/cache-panel.js +759 -759
  65. package/src/server/cloudflare.js +607 -595
  66. package/src/server/create-app.js +291 -291
  67. package/src/server/data-cache.js +462 -462
  68. package/src/server/dev/report.js +369 -369
  69. package/src/server/dev/socket.js +170 -170
  70. package/src/server/dev/version-check.mjs +139 -139
  71. package/src/server/html-cache.js +817 -817
  72. package/src/server/metadata.js +102 -102
  73. package/src/server/middleware/compression.js +205 -205
  74. package/src/server/middleware/csrf.js +134 -134
  75. package/src/server/middleware/dev-gate.js +62 -62
  76. package/src/server/middleware/headers.js +37 -37
  77. package/src/server/middleware/redirects.js +32 -32
  78. package/src/server/middleware/static-precompressed.js +100 -100
  79. package/src/server/middleware/upstream-proxy.js +141 -141
  80. package/src/server/prewarm.js +601 -601
  81. package/src/server/redis.js +569 -569
  82. package/src/server/router.js +128 -128
  83. package/src/server/status-page.js +164 -164
  84. package/src/server/upstream-limiter.js +376 -376
  85. package/src/server/upstream-tracking.js +166 -166
  86. package/src/start.mjs +7 -7
  87. package/src/templates/layout.ejs +44 -44
  88. package/src/version.mjs +31 -31
  89. package/src/views/components/loader.js +85 -85
  90. package/src/views/helpers/html.js +102 -102
  91. package/src/views/helpers/tags.js +245 -245
@@ -1,504 +1,504 @@
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.ejs render → 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
- ## The EJS engine
32
-
33
- The engine is set up once on the first render; the component scan touches the
34
- file system, so it cannot be done on every request and cannot be computed
35
- before the config is loaded.
36
-
37
- Settings:
38
-
39
- | Setting | Value | Reason |
40
- | --- | --- | --- |
41
- | `root`, `views` | the `views` directory | `include('partials/header')` calls resolve from the views root |
42
- | `cache` | `false` in dev, `true` in prod | so template edits show up instantly in dev |
43
- | `rmWhitespace` | `true` | output size |
44
- | `async` | `true` | `await` can be used inside templates |
45
-
46
- For embedded uses (tests, scripts) `resetRenderEngine()` is exported: it
47
- refreshes the registry when component files change. It is not needed in the
48
- normal flow because the dev server restarts the process.
49
-
50
- ## Layout
51
-
52
- ### How the layout file is found
53
-
54
- 1. `jskelet.config.mjs` → if `layout` is given, it is used. The path is
55
- resolved relative to the **parent directory of the views directory**: if
56
- `views` is the default, `layout: "views/custom.ejs"` → `<root>/views/custom.ejs`.
57
- 2. If it is not given and `views/layout.ejs` exists, that is used.
58
- 3. If that does not exist either, the framework's own minimal layout is used
59
- (`node_modules/jskelet/src/templates/layout.ejs`, also reachable through the
60
- `jskelet/layout` specifier).
61
-
62
- The third option exists so that a new project can work with a single route. The
63
- most practical way to move to your own layout is to copy that file to
64
- `views/layout.ejs`.
65
-
66
- ### The framework's default layout
67
-
68
- ```ejs
69
- <!DOCTYPE html>
70
- <html lang="<%= lang %>">
71
- <head>
72
- <meta charset="utf-8">
73
- <meta name="viewport" content="width=device-width, initial-scale=1">
74
- <%- extraHead %>
75
- <% if (hasAsset('app.css')) { %>
76
- <link rel="stylesheet" href="<%= asset('app.css') %>">
77
- <% } %>
78
- <%- headMeta %>
79
- <% structuredData.forEach(function (item) { %>
80
- <script type="application/ld+json"><%- jsonScript(item) %></script>
81
- <% }); %>
82
- </head>
83
- <body class="<%= bodyClass %>">
84
- <%- body %>
85
- <% if (hasAsset('main.js')) { %>
86
- <script type="module" src="<%= asset('main.js') %>"></script>
87
- <% } %>
88
- <% entries.forEach(function (entry) { %>
89
- <script type="module" src="<%= asset(entry) %>"></script>
90
- <% }); %>
91
- <% if (devtools) { %>
92
- <script type="module" src="<%= devBasePath %>/overlay.js"></script>
93
- <% } %>
94
- </body>
95
- </html>
96
- ```
97
-
98
- Points to watch:
99
-
100
- - **`extraHead` comes first.** Delaying resource hints (`preconnect`, LCP
101
- `preload`) writes straight into LCP.
102
- - **A single, render-blocking stylesheet**, with the reasoning in
103
- [02-architecture.md](./02-architecture.md). If the build has not run,
104
- `hasAsset('app.css')` is false and the tag is never emitted.
105
- - **The `hasAsset` checks** keep the page from requesting files that 404 when
106
- the build is missing.
107
- - **The devtools script** is emitted only when `NODE_ENV=development`; it does
108
- not exist at all in production output.
109
-
110
- ### Layout locals
111
-
112
- | Local | Type | Source |
113
- | --- | --- | --- |
114
- | `metadata` | `object` | `hooks.metadata()` + controller `metadata` (the controller wins) |
115
- | `headMeta` | `string` | ready-made `<head>` tags produced from `metadata` |
116
- | `extraHead` | `string` | `preconnect` hints + `navigation` hints + controller `head` + `context.extraHead` |
117
- | `structuredData` | `unknown[]` | `hooks.layoutContext()` → `structuredData`; defaults to `[]` |
118
- | `body` | `string` | The render output of the page template |
119
- | `bodyClass` | `string` | controller `bodyClass` → `context.bodyClass` → `""` |
120
- | `entries` | `string[]` | controller `entries`; defaults to `[]` |
121
- | `pathname` | `string` | `req.path`; **defaults to the empty string** |
122
- | `lang` | `string` | `context.lang` → `brand.lang` → `"en"` |
123
- | `devtools` | `boolean` | `NODE_ENV === "development"` |
124
- | `devBasePath` | `string` | `brand.devBasePath`, defaults to `/__jskelet/dev` |
125
- | `asset`, `hasAsset` | function | Manifest access |
126
- | html/tags helpers | function | `esc`, `attrs`, `cx`, `cn`, `jsonScript`, `link`, `image`, `icon`, `preloadImage`, `toKebab` |
127
- | exports of `views/components/**` | function | Automatic registration |
128
- | every field returned by `hooks.layoutContext()` | — | Becomes a local directly |
129
-
130
- The empty default for `pathname` is deliberate: writing `"/"` leads to the kind
131
- of bug where every page thinks it is the home page and renders the logo as an
132
- `<h1>`.
133
-
134
- ## Page templates
135
-
136
- The `view` field gives the path under `views/` without an extension:
137
- `"pages/home"` → `views/pages/home.ejs`. The locals passed to the template are
138
- the contents of the `data` field plus `metadata` — **not** the layout locals.
139
- The page template still has access to all helpers and components.
140
-
141
- ```ejs
142
- <%# views/pages/home.ejs %>
143
- <section class="wrapper">
144
- <h1 class="text-3xl font-bold"><%= heading %></h1>
145
-
146
- <%# `list` is defined in views/components/list.js; no import needed. %>
147
- <%- list({ items }) %>
148
-
149
- <div class="mt-8" data-island="counter" data-island-props='{"start":5}'></div>
150
- </section>
151
- ```
152
-
153
- Do not mix up the two output forms in EJS:
154
-
155
- - `<%= value %>` — HTML escaped. **Always** this for user/upstream data.
156
- - `<%- html %>` — raw. Only for HTML strings you produced yourself and know to
157
- be safe (component calls, `headMeta`, `body`).
158
-
159
- Because `async: true` is on, `await` can also be used inside a template, but
160
- keeping data fetching in the controller makes diagnosis easier.
161
-
162
- ## Components: `views/components/**`
163
-
164
- Components are not EJS partials but **functions that return HTML strings**.
165
- Every `.js` file under `views/components/**` is scanned and **every named
166
- export** becomes a template local. There is no hand-maintained barrel file:
167
- creating the file is enough to add a new component.
168
-
169
- ```js
170
- // views/components/list.js
171
- import { esc } from "jskelet/html";
172
-
173
- /**
174
- * @param {{ items: string[] }} props
175
- * @returns {string}
176
- */
177
- export function list({ items }) {
178
- if (!items?.length) return "";
179
-
180
- const rows = items.map((item) => `<li class="py-1">${esc(item)}</li>`).join("");
181
- return `<ul class="mt-6 list-disc pl-6">${rows}</ul>`;
182
- }
183
- ```
184
-
185
- In the template:
186
-
187
- ```ejs
188
- <%- list({ items }) %>
189
- ```
190
-
191
- Rules:
192
-
193
- - The scan is recursive; subdirectories are covered too.
194
- - `default` exports are ignored — only named exports are registered.
195
- - `loader.js` and `index.js` do not count as component files.
196
- - If `views/components/index.js` exists it is loaded first as a **barrel**,
197
- with the lowest priority. Its only purpose is to turn `lib/` re-exports into
198
- template locals; the components' own files come later and silently overwrite
199
- it.
200
- - If the same name is defined in two different component files a warning is
201
- printed and **the second one wins**: `[components] 'card' is defined twice:
202
- a.js and b.js — the second one wins.`
203
- - If the `views/components` directory does not exist the component registry
204
- stays empty; a project that uses no components works fine too.
205
-
206
- ## Helpers: `jskelet/html`
207
-
208
- They are passed to templates automatically; in component files you get them
209
- with `import { … } from "jskelet/html"`.
210
-
211
- ### `esc(value)`
212
-
213
- Escaping for text content and attribute values (`&`, `<`, `>`, `"`, `'`).
214
- `null`, `undefined` and `false` are turned into the empty string — so in
215
- conditional rendering an expression like `false && "…"` does not print
216
- `"false"`.
217
-
218
- ```js
219
- esc('<b>"x"</b>'); // "&lt;b&gt;&quot;x&quot;&lt;/b&gt;"
220
- ```
221
-
222
- ### `attrs(object)`
223
-
224
- Turns an attribute object into a string. `null`/`undefined`/`false` are
225
- skipped, `true` is written as a boolean attribute, and the remaining values are
226
- escaped. If the output is not empty it comes back **with a leading space**, so
227
- `<div${attrs(...)}>` is always formatted correctly.
228
-
229
- ```js
230
- `<input${attrs({ type: "text", required: true, value: null })}>`;
231
- // '<input type="text" required>'
232
- ```
233
-
234
- ### `cx(...inputs)`
235
-
236
- The `clsx` equivalent: it accepts strings, numbers, arrays and
237
- `{ className: condition }` objects, and drops falsy values. It does **not**
238
- resolve Tailwind conflicts.
239
-
240
- ```js
241
- cx("btn", isActive && "btn-active", { "btn-lg": size === "lg" });
242
- ```
243
-
244
- ### `cn(...inputs)`
245
-
246
- Merges with `cx()`, then resolves Tailwind conflicts with `tailwind-merge`. Use
247
- this when a component's default classes need to be overridable by the caller.
248
-
249
- ```js
250
- cn("px-4 py-2 bg-slate-100", className); // if className is "bg-white", bg-slate-100 drops
251
- ```
252
-
253
- `tailwind-merge` is kept as a runtime dependency because class computation
254
- happens only on the server; it never enters the client bundle.
255
-
256
- ### `jsonScript(value)`
257
-
258
- Safe JSON for the body of a `<script type="application/ld+json">`: `<`, `>`,
259
- `&` and U+2028/U+2029 are escaped, so a `</script` or `<!--` sequence cannot
260
- close the body.
261
-
262
- ```ejs
263
- <script type="application/ld+json"><%- jsonScript(article) %></script>
264
- ```
265
-
266
- ## Helpers: `jskelet/tags`
267
-
268
- The equivalents of `next/link`, `next/image` and `@phosphor-icons/react`. They
269
- all return HTML strings and are emitted from EJS with `<%- %>`.
270
-
271
- ### `link(props)`
272
-
273
- ```js
274
- link({
275
- href: "/about",
276
- text: "About",
277
- class: "font-semibold",
278
- // optional: html, title, ariaLabel, target, rel, attrs
279
- });
280
- ```
281
-
282
- - If `title` is not given it is filled in automatically in the order
283
- `ariaLabel` → `text` → `href`.
284
- - If `href` starts with `http://` or `https://`, `target="_blank"` and
285
- `rel="noopener noreferrer"` are added automatically; if you give them
286
- explicitly your values are used.
287
- - If `html` is given the content is emitted raw; if `text` is given it is
288
- escaped.
289
- - The `attrs` object passes extra attributes through and overrides the previous
290
- ones.
291
-
292
- ### `image(props)`
293
-
294
- ```js
295
- image({
296
- src: "/hero.png",
297
- alt: "Kapak",
298
- priority: true,
299
- // optional: width, height, class, sizes, srcset, fill, loading,
300
- // unoptimized, attrs
301
- });
302
- ```
303
-
304
- Behaviour:
305
-
306
- - For local raster images under `public/`, the webp variants generated at build
307
- time (`.jskelet/images.json`) are added automatically as `srcset` plus
308
- intrinsic `width`/`height`. Images that are not in the manifest, or remote
309
- ones, are emitted as-is.
310
- - If `srcset` is given by hand, or `unoptimized: true` is set, the manifest is
311
- not consulted at all.
312
- - If only **one** variant was produced (because the source is already small),
313
- `srcset`/`sizes` are not written; they would be pure noise.
314
- - If `sizes` is not given a reasonable default is produced: the image is not
315
- scaled beyond its own intrinsic width, and it fills the viewport on narrow
316
- screens (`(max-width: Npx) 100vw, Npx`).
317
- - `priority: true` → `loading="eager"`, `decoding="sync"`,
318
- `fetchpriority="high"`. For the LCP image.
319
- - Without `priority` → `loading="lazy"`, `decoding="async"`.
320
- - `fill: true` → `width`/`height` are not written and the classes
321
- `absolute inset-0 h-full w-full object-cover` are merged in with `cn()`.
322
-
323
- ### `icon(props)`
324
-
325
- Emits a `<use>` from the SVG sprite generated at build time.
326
-
327
- ```js
328
- icon({ name: "ArrowRight", weight: "bold", size: 20, class: "text-slate-500" });
329
- // <svg width="20" height="20" class="…" aria-hidden="true" focusable="false"
330
- // fill="currentColor" viewBox="0 0 256 256"><use href="/assets/sprite.<hash>.svg#arrow-right-bold"></use></svg>
331
- ```
332
-
333
- - `name` is the Phosphor name; the forms `ArrowRightIcon` and `ArrowRight` are
334
- accepted too and converted to `arrow-right` (`toKebab()`).
335
- - `weight` is part of the sprite id: `thin`, `light`, `regular` (the default),
336
- `bold`, `fill`, `duotone`.
337
- - `size` defaults to 24; it is written as `width` and `height`.
338
- - In development a one-time warning is printed when a symbol that is not in the
339
- sprite is requested. The sprite contains only the names that are visible
340
- **statically** in the source; if a call whose name is computed at runtime
341
- points at a missing symbol, the screen is silently left blank
342
- ([08-build.md](./08-build.md)).
343
-
344
- ### `preloadImage(props)`
345
-
346
- ```js
347
- preloadImage({ href: "/assets/img/hero-1280.abc.webp", imagesrcset, imagesizes });
348
- // <link rel="preload" as="image" href="…" fetchpriority="high">
349
- ```
350
-
351
- In practice `headHints()` is used rather than calling this directly:
352
-
353
- ```js
354
- import { headHints } from "jskelet";
355
-
356
- return {
357
- view: "pages/article",
358
- head: headHints({ href: cover, imageSrcSet, imageSizes }),
359
- };
360
- ```
361
-
362
- `headHints()` returns the empty string when there is no `href`, so you do not
363
- need to write a condition. Preconnects are not repeated here because the layout
364
- already emits them on every page.
365
-
366
- ## Metadata → `<head>`
367
-
368
- The controller returns `metadata` and the framework turns it into tags (the
369
- equivalent of Next.js's Metadata API). The schema is deliberately small; if you
370
- need more, raw HTML is added through `extraTags`, so the framework does not
371
- have to cut a release for every new kind of meta tag.
372
-
373
- | Field | Type | Meaning |
374
- | --- | --- | --- |
375
- | `title` | `string` | `<title>` |
376
- | `titleTemplate` | `string` | `"%s \| Site"` — `title` is embedded into it. Applied only if `title` is also present. |
377
- | `description` | `string` | `<meta name="description">` |
378
- | `canonical` | `string` | Absolute or relative URL |
379
- | `siteUrl` | `string` | Base for making a relative `canonical` absolute |
380
- | `robots` | `{ index?: boolean, follow?: boolean }` | Defaults to `index, follow` |
381
- | `locale` | `string` | `og:locale` |
382
- | `openGraph` | `{ title, description, url, type, siteName, image, imageWidth, imageHeight }` | `og:*` tags |
383
- | `twitter` | `{ card, site, creator, title, description, image }` | `twitter:*` tags |
384
- | `extraTags` | `string[]` | Raw tags to be emitted as-is |
385
-
386
- Generation rules:
387
-
388
- - **The robots default is indexable.** Hiding a page should be an explicit
389
- decision: `robots: { index: false }` → `noindex, follow`.
390
- - **OpenGraph uses `property`, not `name`.** Some scrapers ignore og tags
391
- written with `name`.
392
- - **Inheritance chain:** if there is no `og:title` then `title`, no
393
- `og:description` then `description`, no `og:url` then the absolutised
394
- `canonical`, no `twitter:title` then `og:title` → `title`, no
395
- `twitter:image` then `og:image`.
396
- - **`twitter:card`**, if not given, is `summary_large_image` when there is an
397
- `og:image` and `summary` otherwise.
398
- - **Empty values are never emitted:** fields that are `null`, `undefined` or
399
- `""` produce no tag.
400
- - If `og:type` is not given it is `website`.
401
-
402
- Example:
403
-
404
- ```js
405
- return {
406
- view: "pages/article",
407
- metadata: {
408
- title: article.title,
409
- description: article.summary,
410
- canonical: `/news/${article.slug}`,
411
- openGraph: {
412
- type: "article",
413
- image: article.cover,
414
- imageWidth: 1200,
415
- imageHeight: 630,
416
- },
417
- extraTags: [`<meta property="article:published_time" content="${article.date}">`],
418
- },
419
- };
420
- ```
421
-
422
- Put fields that are the same on every page, such as `titleTemplate` and
423
- `siteUrl`, into `hooks.metadata()`; the controller only supplies what is
424
- specific to the page.
425
-
426
- The `renderHeadMeta(metadata)` function is exported; it can be used when you
427
- need to produce the same tags outside the layout (for example in a fragment or
428
- an email).
429
-
430
- ## Hooks
431
-
432
- Hooks are defined in `jskelet.config.mjs` under `hooks`. They are all optional
433
- and they can all be `async`. **A failing hook does not take the page down:**
434
- the framework falls back to its own default and warns.
435
-
436
- ### `hooks.metadata(page)`
437
-
438
- The metadata default for every page. It receives the page definition being
439
- rendered as its argument and returns a metadata object. The controller's
440
- `metadata` field is layered **on top of it** (field by field, shallow merge).
441
-
442
- ```js
443
- hooks: {
444
- metadata() {
445
- return {
446
- titleTemplate: "%s | JSkelet",
447
- description: "A site built with JSkelet.",
448
- siteUrl: "https://example.com",
449
- };
450
- },
451
- }
452
- ```
453
-
454
- ### `hooks.layoutContext({ pathname, metadata })`
455
-
456
- The locals added to the layout on every render. **Every field** of the returned
457
- object becomes a layout local; in addition three fields are interpreted
458
- specially:
459
-
460
- - `lang` → `<html lang>`
461
- - `structuredData` → JSON-LD scripts (an array)
462
- - `extraHead` → appended to `<head>` (after the controller's `head`)
463
- - `bodyClass` → used if the controller did not supply a `bodyClass`
464
-
465
- ```js
466
- hooks: {
467
- async layoutContext({ pathname }) {
468
- return {
469
- bodyClass: "min-h-full",
470
- navigation: await getNavigation(),
471
- isHome: pathname === "/",
472
- };
473
- },
474
- }
475
- ```
476
-
477
- This hook runs **in parallel** with the body render; calling upstream inside it
478
- does not add sequential latency to the page.
479
-
480
- ### `hooks.notFound()`
481
-
482
- The 404 page definition. The object it returns is handed to `renderPage` with
483
- `pathname: "/404"`. Details: [03-routing.md](./03-routing.md).
484
-
485
- ### Other hooks
486
-
487
- `hooks.prewarmPaths()` belongs to prewarming rather than the render layer; see
488
- [06-caching.md](./06-caching.md).
489
-
490
- ## The overlay portal point
491
-
492
- `jskelet/client` → `getOverlayRoot()` gives the target that modal and drawer
493
- content will be moved into: if the layout has
494
- `<div id="jskelet-overlays"></div>` it goes there, otherwise into `body`. The
495
- portal prevents an ancestor element carrying `overflow` or `transform` from
496
- clipping a `position: fixed` overlay. If you are going to use modals, adding
497
- this div at the end of the layout's `<body>` is enough
498
- ([05-islands.md](./05-islands.md)).
499
-
500
- ## What's next
501
-
502
- - Islands and `entries`: [05-islands.md](./05-islands.md)
503
- - `asset()`, the manifest and the Tailwind scan: [08-build.md](./08-build.md)
504
- - 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.ejs render → 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
+ ## The EJS engine
32
+
33
+ The engine is set up once on the first render; the component scan touches the
34
+ file system, so it cannot be done on every request and cannot be computed
35
+ before the config is loaded.
36
+
37
+ Settings:
38
+
39
+ | Setting | Value | Reason |
40
+ | --- | --- | --- |
41
+ | `root`, `views` | the `views` directory | `include('partials/header')` calls resolve from the views root |
42
+ | `cache` | `false` in dev, `true` in prod | so template edits show up instantly in dev |
43
+ | `rmWhitespace` | `true` | output size |
44
+ | `async` | `true` | `await` can be used inside templates |
45
+
46
+ For embedded uses (tests, scripts) `resetRenderEngine()` is exported: it
47
+ refreshes the registry when component files change. It is not needed in the
48
+ normal flow because the dev server restarts the process.
49
+
50
+ ## Layout
51
+
52
+ ### How the layout file is found
53
+
54
+ 1. `jskelet.config.mjs` → if `layout` is given, it is used. The path is
55
+ resolved relative to the **parent directory of the views directory**: if
56
+ `views` is the default, `layout: "views/custom.ejs"` → `<root>/views/custom.ejs`.
57
+ 2. If it is not given and `views/layout.ejs` exists, that is used.
58
+ 3. If that does not exist either, the framework's own minimal layout is used
59
+ (`node_modules/jskelet/src/templates/layout.ejs`, also reachable through the
60
+ `jskelet/layout` specifier).
61
+
62
+ The third option exists so that a new project can work with a single route. The
63
+ most practical way to move to your own layout is to copy that file to
64
+ `views/layout.ejs`.
65
+
66
+ ### The framework's default layout
67
+
68
+ ```ejs
69
+ <!DOCTYPE html>
70
+ <html lang="<%= lang %>">
71
+ <head>
72
+ <meta charset="utf-8">
73
+ <meta name="viewport" content="width=device-width, initial-scale=1">
74
+ <%- extraHead %>
75
+ <% if (hasAsset('app.css')) { %>
76
+ <link rel="stylesheet" href="<%= asset('app.css') %>">
77
+ <% } %>
78
+ <%- headMeta %>
79
+ <% structuredData.forEach(function (item) { %>
80
+ <script type="application/ld+json"><%- jsonScript(item) %></script>
81
+ <% }); %>
82
+ </head>
83
+ <body class="<%= bodyClass %>">
84
+ <%- body %>
85
+ <% if (hasAsset('main.js')) { %>
86
+ <script type="module" src="<%= asset('main.js') %>"></script>
87
+ <% } %>
88
+ <% entries.forEach(function (entry) { %>
89
+ <script type="module" src="<%= asset(entry) %>"></script>
90
+ <% }); %>
91
+ <% if (devtools) { %>
92
+ <script type="module" src="<%= devBasePath %>/overlay.js"></script>
93
+ <% } %>
94
+ </body>
95
+ </html>
96
+ ```
97
+
98
+ Points to watch:
99
+
100
+ - **`extraHead` comes first.** Delaying resource hints (`preconnect`, LCP
101
+ `preload`) writes straight into LCP.
102
+ - **A single, render-blocking stylesheet**, with the reasoning in
103
+ [02-architecture.md](./02-architecture.md). If the build has not run,
104
+ `hasAsset('app.css')` is false and the tag is never emitted.
105
+ - **The `hasAsset` checks** keep the page from requesting files that 404 when
106
+ the build is missing.
107
+ - **The devtools script** is emitted only when `NODE_ENV=development`; it does
108
+ not exist at all in production output.
109
+
110
+ ### Layout locals
111
+
112
+ | Local | Type | Source |
113
+ | --- | --- | --- |
114
+ | `metadata` | `object` | `hooks.metadata()` + controller `metadata` (the controller wins) |
115
+ | `headMeta` | `string` | ready-made `<head>` tags produced from `metadata` |
116
+ | `extraHead` | `string` | `preconnect` hints + `navigation` hints + controller `head` + `context.extraHead` |
117
+ | `structuredData` | `unknown[]` | `hooks.layoutContext()` → `structuredData`; defaults to `[]` |
118
+ | `body` | `string` | The render output of the page template |
119
+ | `bodyClass` | `string` | controller `bodyClass` → `context.bodyClass` → `""` |
120
+ | `entries` | `string[]` | controller `entries`; defaults to `[]` |
121
+ | `pathname` | `string` | `req.path`; **defaults to the empty string** |
122
+ | `lang` | `string` | `context.lang` → `brand.lang` → `"en"` |
123
+ | `devtools` | `boolean` | `NODE_ENV === "development"` |
124
+ | `devBasePath` | `string` | `brand.devBasePath`, defaults to `/__jskelet/dev` |
125
+ | `asset`, `hasAsset` | function | Manifest access |
126
+ | html/tags helpers | function | `esc`, `attrs`, `cx`, `cn`, `jsonScript`, `link`, `image`, `icon`, `preloadImage`, `toKebab` |
127
+ | exports of `views/components/**` | function | Automatic registration |
128
+ | every field returned by `hooks.layoutContext()` | — | Becomes a local directly |
129
+
130
+ The empty default for `pathname` is deliberate: writing `"/"` leads to the kind
131
+ of bug where every page thinks it is the home page and renders the logo as an
132
+ `<h1>`.
133
+
134
+ ## Page templates
135
+
136
+ The `view` field gives the path under `views/` without an extension:
137
+ `"pages/home"` → `views/pages/home.ejs`. The locals passed to the template are
138
+ the contents of the `data` field plus `metadata` — **not** the layout locals.
139
+ The page template still has access to all helpers and components.
140
+
141
+ ```ejs
142
+ <%# views/pages/home.ejs %>
143
+ <section class="wrapper">
144
+ <h1 class="text-3xl font-bold"><%= heading %></h1>
145
+
146
+ <%# `list` is defined in views/components/list.js; no import needed. %>
147
+ <%- list({ items }) %>
148
+
149
+ <div class="mt-8" data-island="counter" data-island-props='{"start":5}'></div>
150
+ </section>
151
+ ```
152
+
153
+ Do not mix up the two output forms in EJS:
154
+
155
+ - `<%= value %>` — HTML escaped. **Always** this for user/upstream data.
156
+ - `<%- html %>` — raw. Only for HTML strings you produced yourself and know to
157
+ be safe (component calls, `headMeta`, `body`).
158
+
159
+ Because `async: true` is on, `await` can also be used inside a template, but
160
+ keeping data fetching in the controller makes diagnosis easier.
161
+
162
+ ## Components: `views/components/**`
163
+
164
+ Components are not EJS partials but **functions that return HTML strings**.
165
+ Every `.js` file under `views/components/**` is scanned and **every named
166
+ export** becomes a template local. There is no hand-maintained barrel file:
167
+ creating the file is enough to add a new component.
168
+
169
+ ```js
170
+ // views/components/list.js
171
+ import { esc } from "jskelet/html";
172
+
173
+ /**
174
+ * @param {{ items: string[] }} props
175
+ * @returns {string}
176
+ */
177
+ export function list({ items }) {
178
+ if (!items?.length) return "";
179
+
180
+ const rows = items.map((item) => `<li class="py-1">${esc(item)}</li>`).join("");
181
+ return `<ul class="mt-6 list-disc pl-6">${rows}</ul>`;
182
+ }
183
+ ```
184
+
185
+ In the template:
186
+
187
+ ```ejs
188
+ <%- list({ items }) %>
189
+ ```
190
+
191
+ Rules:
192
+
193
+ - The scan is recursive; subdirectories are covered too.
194
+ - `default` exports are ignored — only named exports are registered.
195
+ - `loader.js` and `index.js` do not count as component files.
196
+ - If `views/components/index.js` exists it is loaded first as a **barrel**,
197
+ with the lowest priority. Its only purpose is to turn `lib/` re-exports into
198
+ template locals; the components' own files come later and silently overwrite
199
+ it.
200
+ - If the same name is defined in two different component files a warning is
201
+ printed and **the second one wins**: `[components] 'card' is defined twice:
202
+ a.js and b.js — the second one wins.`
203
+ - If the `views/components` directory does not exist the component registry
204
+ stays empty; a project that uses no components works fine too.
205
+
206
+ ## Helpers: `jskelet/html`
207
+
208
+ They are passed to templates automatically; in component files you get them
209
+ with `import { … } from "jskelet/html"`.
210
+
211
+ ### `esc(value)`
212
+
213
+ Escaping for text content and attribute values (`&`, `<`, `>`, `"`, `'`).
214
+ `null`, `undefined` and `false` are turned into the empty string — so in
215
+ conditional rendering an expression like `false && "…"` does not print
216
+ `"false"`.
217
+
218
+ ```js
219
+ esc('<b>"x"</b>'); // "&lt;b&gt;&quot;x&quot;&lt;/b&gt;"
220
+ ```
221
+
222
+ ### `attrs(object)`
223
+
224
+ Turns an attribute object into a string. `null`/`undefined`/`false` are
225
+ skipped, `true` is written as a boolean attribute, and the remaining values are
226
+ escaped. If the output is not empty it comes back **with a leading space**, so
227
+ `<div${attrs(...)}>` is always formatted correctly.
228
+
229
+ ```js
230
+ `<input${attrs({ type: "text", required: true, value: null })}>`;
231
+ // '<input type="text" required>'
232
+ ```
233
+
234
+ ### `cx(...inputs)`
235
+
236
+ The `clsx` equivalent: it accepts strings, numbers, arrays and
237
+ `{ className: condition }` objects, and drops falsy values. It does **not**
238
+ resolve Tailwind conflicts.
239
+
240
+ ```js
241
+ cx("btn", isActive && "btn-active", { "btn-lg": size === "lg" });
242
+ ```
243
+
244
+ ### `cn(...inputs)`
245
+
246
+ Merges with `cx()`, then resolves Tailwind conflicts with `tailwind-merge`. Use
247
+ this when a component's default classes need to be overridable by the caller.
248
+
249
+ ```js
250
+ cn("px-4 py-2 bg-slate-100", className); // if className is "bg-white", bg-slate-100 drops
251
+ ```
252
+
253
+ `tailwind-merge` is kept as a runtime dependency because class computation
254
+ happens only on the server; it never enters the client bundle.
255
+
256
+ ### `jsonScript(value)`
257
+
258
+ Safe JSON for the body of a `<script type="application/ld+json">`: `<`, `>`,
259
+ `&` and U+2028/U+2029 are escaped, so a `</script` or `<!--` sequence cannot
260
+ close the body.
261
+
262
+ ```ejs
263
+ <script type="application/ld+json"><%- jsonScript(article) %></script>
264
+ ```
265
+
266
+ ## Helpers: `jskelet/tags`
267
+
268
+ The equivalents of `next/link`, `next/image` and `@phosphor-icons/react`. They
269
+ all return HTML strings and are emitted from EJS with `<%- %>`.
270
+
271
+ ### `link(props)`
272
+
273
+ ```js
274
+ link({
275
+ href: "/about",
276
+ text: "About",
277
+ class: "font-semibold",
278
+ // optional: html, title, ariaLabel, target, rel, attrs
279
+ });
280
+ ```
281
+
282
+ - If `title` is not given it is filled in automatically in the order
283
+ `ariaLabel` → `text` → `href`.
284
+ - If `href` starts with `http://` or `https://`, `target="_blank"` and
285
+ `rel="noopener noreferrer"` are added automatically; if you give them
286
+ explicitly your values are used.
287
+ - If `html` is given the content is emitted raw; if `text` is given it is
288
+ escaped.
289
+ - The `attrs` object passes extra attributes through and overrides the previous
290
+ ones.
291
+
292
+ ### `image(props)`
293
+
294
+ ```js
295
+ image({
296
+ src: "/hero.png",
297
+ alt: "Kapak",
298
+ priority: true,
299
+ // optional: width, height, class, sizes, srcset, fill, loading,
300
+ // unoptimized, attrs
301
+ });
302
+ ```
303
+
304
+ Behaviour:
305
+
306
+ - For local raster images under `public/`, the webp variants generated at build
307
+ time (`.jskelet/images.json`) are added automatically as `srcset` plus
308
+ intrinsic `width`/`height`. Images that are not in the manifest, or remote
309
+ ones, are emitted as-is.
310
+ - If `srcset` is given by hand, or `unoptimized: true` is set, the manifest is
311
+ not consulted at all.
312
+ - If only **one** variant was produced (because the source is already small),
313
+ `srcset`/`sizes` are not written; they would be pure noise.
314
+ - If `sizes` is not given a reasonable default is produced: the image is not
315
+ scaled beyond its own intrinsic width, and it fills the viewport on narrow
316
+ screens (`(max-width: Npx) 100vw, Npx`).
317
+ - `priority: true` → `loading="eager"`, `decoding="sync"`,
318
+ `fetchpriority="high"`. For the LCP image.
319
+ - Without `priority` → `loading="lazy"`, `decoding="async"`.
320
+ - `fill: true` → `width`/`height` are not written and the classes
321
+ `absolute inset-0 h-full w-full object-cover` are merged in with `cn()`.
322
+
323
+ ### `icon(props)`
324
+
325
+ Emits a `<use>` from the SVG sprite generated at build time.
326
+
327
+ ```js
328
+ icon({ name: "ArrowRight", weight: "bold", size: 20, class: "text-slate-500" });
329
+ // <svg width="20" height="20" class="…" aria-hidden="true" focusable="false"
330
+ // fill="currentColor" viewBox="0 0 256 256"><use href="/assets/sprite.<hash>.svg#arrow-right-bold"></use></svg>
331
+ ```
332
+
333
+ - `name` is the Phosphor name; the forms `ArrowRightIcon` and `ArrowRight` are
334
+ accepted too and converted to `arrow-right` (`toKebab()`).
335
+ - `weight` is part of the sprite id: `thin`, `light`, `regular` (the default),
336
+ `bold`, `fill`, `duotone`.
337
+ - `size` defaults to 24; it is written as `width` and `height`.
338
+ - In development a one-time warning is printed when a symbol that is not in the
339
+ sprite is requested. The sprite contains only the names that are visible
340
+ **statically** in the source; if a call whose name is computed at runtime
341
+ points at a missing symbol, the screen is silently left blank
342
+ ([08-build.md](./08-build.md)).
343
+
344
+ ### `preloadImage(props)`
345
+
346
+ ```js
347
+ preloadImage({ href: "/assets/img/hero-1280.abc.webp", imagesrcset, imagesizes });
348
+ // <link rel="preload" as="image" href="…" fetchpriority="high">
349
+ ```
350
+
351
+ In practice `headHints()` is used rather than calling this directly:
352
+
353
+ ```js
354
+ import { headHints } from "jskelet";
355
+
356
+ return {
357
+ view: "pages/article",
358
+ head: headHints({ href: cover, imageSrcSet, imageSizes }),
359
+ };
360
+ ```
361
+
362
+ `headHints()` returns the empty string when there is no `href`, so you do not
363
+ need to write a condition. Preconnects are not repeated here because the layout
364
+ already emits them on every page.
365
+
366
+ ## Metadata → `<head>`
367
+
368
+ The controller returns `metadata` and the framework turns it into tags (the
369
+ equivalent of Next.js's Metadata API). The schema is deliberately small; if you
370
+ need more, raw HTML is added through `extraTags`, so the framework does not
371
+ have to cut a release for every new kind of meta tag.
372
+
373
+ | Field | Type | Meaning |
374
+ | --- | --- | --- |
375
+ | `title` | `string` | `<title>` |
376
+ | `titleTemplate` | `string` | `"%s \| Site"` — `title` is embedded into it. Applied only if `title` is also present. |
377
+ | `description` | `string` | `<meta name="description">` |
378
+ | `canonical` | `string` | Absolute or relative URL |
379
+ | `siteUrl` | `string` | Base for making a relative `canonical` absolute |
380
+ | `robots` | `{ index?: boolean, follow?: boolean }` | Defaults to `index, follow` |
381
+ | `locale` | `string` | `og:locale` |
382
+ | `openGraph` | `{ title, description, url, type, siteName, image, imageWidth, imageHeight }` | `og:*` tags |
383
+ | `twitter` | `{ card, site, creator, title, description, image }` | `twitter:*` tags |
384
+ | `extraTags` | `string[]` | Raw tags to be emitted as-is |
385
+
386
+ Generation rules:
387
+
388
+ - **The robots default is indexable.** Hiding a page should be an explicit
389
+ decision: `robots: { index: false }` → `noindex, follow`.
390
+ - **OpenGraph uses `property`, not `name`.** Some scrapers ignore og tags
391
+ written with `name`.
392
+ - **Inheritance chain:** if there is no `og:title` then `title`, no
393
+ `og:description` then `description`, no `og:url` then the absolutised
394
+ `canonical`, no `twitter:title` then `og:title` → `title`, no
395
+ `twitter:image` then `og:image`.
396
+ - **`twitter:card`**, if not given, is `summary_large_image` when there is an
397
+ `og:image` and `summary` otherwise.
398
+ - **Empty values are never emitted:** fields that are `null`, `undefined` or
399
+ `""` produce no tag.
400
+ - If `og:type` is not given it is `website`.
401
+
402
+ Example:
403
+
404
+ ```js
405
+ return {
406
+ view: "pages/article",
407
+ metadata: {
408
+ title: article.title,
409
+ description: article.summary,
410
+ canonical: `/news/${article.slug}`,
411
+ openGraph: {
412
+ type: "article",
413
+ image: article.cover,
414
+ imageWidth: 1200,
415
+ imageHeight: 630,
416
+ },
417
+ extraTags: [`<meta property="article:published_time" content="${article.date}">`],
418
+ },
419
+ };
420
+ ```
421
+
422
+ Put fields that are the same on every page, such as `titleTemplate` and
423
+ `siteUrl`, into `hooks.metadata()`; the controller only supplies what is
424
+ specific to the page.
425
+
426
+ The `renderHeadMeta(metadata)` function is exported; it can be used when you
427
+ need to produce the same tags outside the layout (for example in a fragment or
428
+ an email).
429
+
430
+ ## Hooks
431
+
432
+ Hooks are defined in `jskelet.config.mjs` under `hooks`. They are all optional
433
+ and they can all be `async`. **A failing hook does not take the page down:**
434
+ the framework falls back to its own default and warns.
435
+
436
+ ### `hooks.metadata(page)`
437
+
438
+ The metadata default for every page. It receives the page definition being
439
+ rendered as its argument and returns a metadata object. The controller's
440
+ `metadata` field is layered **on top of it** (field by field, shallow merge).
441
+
442
+ ```js
443
+ hooks: {
444
+ metadata() {
445
+ return {
446
+ titleTemplate: "%s | JSkelet",
447
+ description: "A site built with JSkelet.",
448
+ siteUrl: "https://example.com",
449
+ };
450
+ },
451
+ }
452
+ ```
453
+
454
+ ### `hooks.layoutContext({ pathname, metadata })`
455
+
456
+ The locals added to the layout on every render. **Every field** of the returned
457
+ object becomes a layout local; in addition three fields are interpreted
458
+ specially:
459
+
460
+ - `lang` → `<html lang>`
461
+ - `structuredData` → JSON-LD scripts (an array)
462
+ - `extraHead` → appended to `<head>` (after the controller's `head`)
463
+ - `bodyClass` → used if the controller did not supply a `bodyClass`
464
+
465
+ ```js
466
+ hooks: {
467
+ async layoutContext({ pathname }) {
468
+ return {
469
+ bodyClass: "min-h-full",
470
+ navigation: await getNavigation(),
471
+ isHome: pathname === "/",
472
+ };
473
+ },
474
+ }
475
+ ```
476
+
477
+ This hook runs **in parallel** with the body render; calling upstream inside it
478
+ does not add sequential latency to the page.
479
+
480
+ ### `hooks.notFound()`
481
+
482
+ The 404 page definition. The object it returns is handed to `renderPage` with
483
+ `pathname: "/404"`. Details: [03-routing.md](./03-routing.md).
484
+
485
+ ### Other hooks
486
+
487
+ `hooks.prewarmPaths()` belongs to prewarming rather than the render layer; see
488
+ [06-caching.md](./06-caching.md).
489
+
490
+ ## The overlay portal point
491
+
492
+ `jskelet/client` → `getOverlayRoot()` gives the target that modal and drawer
493
+ content will be moved into: if the layout has
494
+ `<div id="jskelet-overlays"></div>` it goes there, otherwise into `body`. The
495
+ portal prevents an ancestor element carrying `overflow` or `transform` from
496
+ clipping a `position: fixed` overlay. If you are going to use modals, adding
497
+ this div at the end of the layout's `<body>` is enough
498
+ ([05-islands.md](./05-islands.md)).
499
+
500
+ ## What's next
501
+
502
+ - Islands and `entries`: [05-islands.md](./05-islands.md)
503
+ - `asset()`, the manifest and the Tailwind scan: [08-build.md](./08-build.md)
504
+ - Where hooks live in the config: [07-configuration.md](./07-configuration.md)