jskelet 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +63 -0
- package/README.md +21 -7
- package/bin/jskelet.mjs +6 -6
- package/docs/03-routing.md +48 -9
- package/docs/04-render-ve-sablonlar.md +2 -2
- package/docs/05-islands.md +59 -6
- package/docs/06-cache.md +39 -7
- package/docs/07-yapilandirma.md +51 -1
- package/docs/08-build.md +4 -4
- package/docs/09-dev-araclari.md +5 -0
- package/docs/12-panel-ve-oturum.md +384 -0
- package/docs/README.md +25 -2
- package/docs/en/01-getting-started.md +292 -0
- package/docs/en/02-architecture.md +305 -0
- package/docs/en/03-routing.md +493 -0
- package/docs/en/04-rendering.md +504 -0
- package/docs/en/05-islands.md +492 -0
- package/docs/en/06-caching.md +454 -0
- package/docs/en/07-configuration.md +736 -0
- package/docs/en/08-build.md +383 -0
- package/docs/en/09-dev-tools.md +314 -0
- package/docs/en/10-deployment.md +332 -0
- package/docs/en/11-migration.md +360 -0
- package/docs/en/12-dashboards-and-sessions.md +392 -0
- package/docs/en/README.md +112 -0
- package/package.json +4 -2
- package/src/build/build.mjs +1 -1
- package/src/build/tasks/client.mjs +2 -2
- package/src/build/tasks/fonts.mjs +3 -3
- package/src/build/tasks/icons.mjs +1 -1
- package/src/build/tasks/images.mjs +2 -2
- package/src/client/devtools/overlay.js +196 -164
- package/src/client/devtools/report.js +96 -96
- package/src/client/form.js +192 -0
- package/src/client/index.js +10 -1
- package/src/client/registry.js +78 -4
- package/src/client/swap.js +188 -0
- package/src/config/defaults.js +34 -0
- package/src/config/index.js +68 -13
- package/src/config/pattern.js +1 -1
- package/src/dev-server.mjs +1 -1
- package/src/http/control-flow.js +16 -1
- package/src/http/cookies.js +257 -0
- package/src/http/request-context.js +162 -0
- package/src/index.js +19 -2
- package/src/init.mjs +32 -31
- package/src/log.mjs +8 -2
- package/src/logo.png +0 -0
- package/src/runtime/alias-hooks.mjs +1 -1
- package/src/server/assets.js +1 -1
- package/src/server/create-app.js +12 -4
- package/src/server/dev/devtools.js +6 -2
- package/src/server/dev/version-check.mjs +139 -0
- package/src/server/head-hints.js +1 -1
- package/src/server/html-cache.js +10 -4
- package/src/server/middleware/csrf.js +134 -0
- package/src/server/prewarm.js +6 -6
- package/src/server/render.js +199 -16
- package/src/server/router.js +14 -7
- package/src/server/status-page.js +1 -1
- package/src/version.mjs +9 -4
- package/src/views/components/loader.js +1 -1
- package/src/views/helpers/tags.js +53 -1
|
@@ -0,0 +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>'); // "<b>"x"</b>"
|
|
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)
|