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.
- package/AGENTS.md +136 -136
- package/CHANGELOG.md +628 -620
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/bin/jskelet.mjs +130 -130
- package/docs/01-baslangic.md +291 -291
- package/docs/02-mimari.md +310 -310
- package/docs/03-routing.md +515 -515
- package/docs/04-render-ve-sablonlar.md +667 -661
- package/docs/05-islands.md +486 -486
- package/docs/06-cache.md +1467 -1443
- package/docs/07-yapilandirma.md +1208 -1197
- package/docs/08-build.md +429 -429
- package/docs/09-dev-araclari.md +364 -364
- package/docs/10-dagitim.md +348 -338
- package/docs/12-panel-ve-oturum.md +479 -478
- package/docs/README.md +83 -83
- package/docs/en/01-getting-started.md +298 -298
- package/docs/en/02-architecture.md +329 -329
- package/docs/en/03-routing.md +531 -531
- package/docs/en/04-rendering.md +675 -669
- package/docs/en/05-islands.md +497 -497
- package/docs/en/06-caching.md +1476 -1453
- package/docs/en/07-configuration.md +1229 -1219
- package/docs/en/08-build.md +447 -447
- package/docs/en/09-dev-tools.md +373 -373
- package/docs/en/10-deployment.md +351 -340
- package/docs/en/11-migration.md +398 -398
- package/docs/en/12-dashboards-and-sessions.md +489 -488
- package/docs/en/README.md +87 -87
- package/package.json +137 -137
- package/src/build/ensure-build.mjs +19 -19
- package/src/build/paths.mjs +153 -153
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +349 -349
- package/src/build/tasks/css.mjs +235 -235
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +357 -357
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/build/tasks/templates.mjs +20 -20
- package/src/client/admin/i18n.js +764 -764
- package/src/client/admin/login.html +74 -74
- package/src/client/admin/panel.css +809 -809
- package/src/client/admin/panel.html +495 -495
- package/src/client/admin/panel.js +1251 -1251
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +745 -745
- package/src/client/devtools/seo.js +628 -628
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +45 -45
- package/src/client/registry.js +305 -305
- package/src/client/safe-image.js +91 -91
- package/src/client/shared-cookie.js +225 -225
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/compile/codegen.js +336 -336
- package/src/compile/compile-all.js +149 -149
- package/src/compile/errors.js +66 -66
- package/src/compile/expr.js +409 -409
- package/src/compile/index.js +17 -17
- package/src/compile/parse.js +541 -541
- package/src/compile/resolve.js +211 -211
- package/src/compile/scan-exports.js +51 -51
- package/src/config/defaults.js +541 -534
- package/src/config/index.js +1500 -1469
- package/src/config/pattern.js +107 -107
- package/src/generate.mjs +163 -163
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies-entry.js +21 -21
- package/src/http/cookies.js +277 -277
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +165 -165
- package/src/http/shared-cookie.js +178 -178
- package/src/index.js +101 -101
- package/src/init.mjs +232 -230
- package/src/migrate/apply.mjs +262 -262
- package/src/migrate/babel.mjs +79 -79
- package/src/migrate/classify.mjs +155 -155
- package/src/migrate/config.mjs +126 -126
- package/src/migrate/fs-walk.mjs +191 -191
- package/src/migrate/parse.mjs +26 -26
- package/src/migrate/scan.mjs +177 -177
- package/src/migrate/transform/expr-source.mjs +168 -168
- package/src/migrate/transform/island.mjs +67 -67
- package/src/migrate/transform/jsx-to-component.mjs +302 -302
- package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
- package/src/migrate/transform/page-split.mjs +435 -435
- package/src/migrate/write.mjs +81 -81
- package/src/migrate.mjs +171 -171
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/admin/actions.js +229 -229
- package/src/server/admin/auth.js +125 -125
- package/src/server/admin/event-log.js +151 -151
- package/src/server/admin/gate.js +209 -209
- package/src/server/admin/inventory.js +188 -188
- package/src/server/admin/mount.js +56 -56
- package/src/server/admin/router.js +216 -216
- package/src/server/admin/snapshot.js +241 -241
- package/src/server/assets.js +147 -147
- package/src/server/auth/handoff.js +309 -309
- package/src/server/cache-blob.js +70 -70
- package/src/server/cache-control.js +45 -0
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-vary.js +113 -113
- package/src/server/cloudflare.js +607 -607
- package/src/server/create-app.js +366 -366
- package/src/server/data-cache.js +553 -553
- package/src/server/dev/report.js +485 -485
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/disk-cache.js +233 -233
- package/src/server/ejs-adapter.js +59 -59
- package/src/server/html-cache.js +1196 -1196
- package/src/server/image-optimizer.js +500 -500
- package/src/server/logs/access-middleware.js +66 -66
- package/src/server/logs/file-sink.js +193 -193
- package/src/server/logs/pipeline.js +165 -165
- package/src/server/logs/s3-put.js +214 -214
- package/src/server/logs/s3-sink.js +112 -112
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +75 -75
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/robots-txt.js +341 -341
- package/src/server/middleware/static-precompressed.js +121 -121
- package/src/server/middleware/trailing-slash.js +53 -53
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/og-image.js +369 -356
- package/src/server/port-guard.js +255 -255
- package/src/server/prewarm.js +1082 -1082
- package/src/server/redis.js +588 -588
- package/src/server/render.js +910 -910
- package/src/server/router.js +157 -157
- package/src/server/status-page.js +265 -265
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/shared/cookie-domain.js +66 -66
- package/src/start.mjs +22 -22
- package/src/templates/layout.ejs +30 -30
- package/src/templates/layout.jsk +30 -30
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +101 -101
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +375 -375
- package/types/config/defaults.d.ts +6 -0
- package/types/config/index.d.ts +6 -0
- package/types/server/cache-control.d.ts +28 -0
- package/types/server/og-image.d.ts +5 -0
package/docs/en/04-rendering.md
CHANGED
|
@@ -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>'); // "<b>"x"</b>"
|
|
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
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
(
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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>'); // "<b>"x"</b>"
|
|
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)
|