jskelet 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +287 -287
  7. package/docs/03-routing.md +480 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1209 -1202
  11. package/docs/08-build.md +366 -366
  12. package/docs/09-dev-araclari.md +335 -335
  13. package/docs/10-dagitim.md +329 -329
  14. package/docs/12-panel-ve-oturum.md +384 -384
  15. package/docs/README.md +105 -105
  16. package/docs/en/01-getting-started.md +292 -292
  17. package/docs/en/02-architecture.md +305 -305
  18. package/docs/en/03-routing.md +497 -497
  19. package/docs/en/04-rendering.md +504 -504
  20. package/docs/en/05-islands.md +492 -492
  21. package/docs/en/06-caching.md +1239 -1232
  22. package/docs/en/07-configuration.md +986 -986
  23. package/docs/en/08-build.md +383 -383
  24. package/docs/en/09-dev-tools.md +342 -342
  25. package/docs/en/10-deployment.md +332 -332
  26. package/docs/en/11-migration.md +359 -359
  27. package/docs/en/12-dashboards-and-sessions.md +392 -392
  28. package/docs/en/README.md +112 -112
  29. package/package.json +102 -102
  30. package/src/build/ensure-build.mjs +15 -15
  31. package/src/build/paths.mjs +143 -143
  32. package/src/build/resolve-peer.mjs +36 -36
  33. package/src/build/tasks/client.mjs +268 -268
  34. package/src/build/tasks/css.mjs +124 -124
  35. package/src/build/tasks/fonts.mjs +146 -146
  36. package/src/build/tasks/icons.mjs +224 -224
  37. package/src/build/tasks/images.mjs +244 -244
  38. package/src/build/tasks/precompress.mjs +78 -78
  39. package/src/client/cache-panel/i18n.js +670 -0
  40. package/src/client/cache-panel/login.html +74 -71
  41. package/src/client/cache-panel/panel.css +756 -740
  42. package/src/client/cache-panel/panel.html +308 -307
  43. package/src/client/cache-panel/panel.js +915 -808
  44. package/src/client/devtools/report.html +185 -185
  45. package/src/client/devtools/report.js +725 -725
  46. package/src/client/dom.js +95 -95
  47. package/src/client/form.js +192 -192
  48. package/src/client/index.js +35 -35
  49. package/src/client/registry.js +297 -297
  50. package/src/client/safe-image.js +91 -91
  51. package/src/client/store.js +36 -36
  52. package/src/client/swap.js +188 -188
  53. package/src/config/pattern.js +107 -107
  54. package/src/http/control-flow.js +71 -71
  55. package/src/http/cookies.js +257 -257
  56. package/src/http/request-cache.js +46 -46
  57. package/src/http/request-context.js +162 -162
  58. package/src/index.js +83 -83
  59. package/src/init.mjs +221 -221
  60. package/src/runtime/alias-hooks.mjs +119 -119
  61. package/src/runtime/register.mjs +4 -4
  62. package/src/server/assets.js +147 -147
  63. package/src/server/cache-deps.js +42 -42
  64. package/src/server/cache-panel.js +759 -738
  65. package/src/server/cloudflare.js +607 -595
  66. package/src/server/create-app.js +291 -291
  67. package/src/server/data-cache.js +462 -462
  68. package/src/server/dev/report.js +369 -369
  69. package/src/server/dev/socket.js +170 -170
  70. package/src/server/dev/version-check.mjs +139 -139
  71. package/src/server/html-cache.js +817 -817
  72. package/src/server/metadata.js +102 -102
  73. package/src/server/middleware/compression.js +205 -205
  74. package/src/server/middleware/csrf.js +134 -134
  75. package/src/server/middleware/dev-gate.js +62 -62
  76. package/src/server/middleware/headers.js +37 -37
  77. package/src/server/middleware/redirects.js +32 -32
  78. package/src/server/middleware/static-precompressed.js +100 -100
  79. package/src/server/middleware/upstream-proxy.js +141 -141
  80. package/src/server/prewarm.js +601 -601
  81. package/src/server/redis.js +569 -569
  82. package/src/server/router.js +128 -128
  83. package/src/server/status-page.js +164 -164
  84. package/src/server/upstream-limiter.js +376 -376
  85. package/src/server/upstream-tracking.js +166 -166
  86. package/src/start.mjs +7 -7
  87. package/src/templates/layout.ejs +44 -44
  88. package/src/version.mjs +31 -31
  89. package/src/views/components/loader.js +85 -85
  90. package/src/views/helpers/html.js +102 -102
  91. package/src/views/helpers/tags.js +245 -245
@@ -1,383 +1,383 @@
1
- # 08 — Build
2
-
3
- This document describes every job `jskelet build` does and the order it does them
4
- in: font copying, icon sprite generation, Tailwind CSS compilation, the island
5
- bundle via esbuild, image optimisation, writing the manifest and precompression.
6
- It also covers how hashed assets reach the templates through
7
- `asset()`/`hasAsset()`, why Tailwind's `@source` directives are mandatory and how
8
- optional peer dependencies behave. How the output is served at runtime is in
9
- [02-architecture.md](./02-architecture.md), and the watch flow that triggers the
10
- build is in [09-dev-tools.md](./09-dev-tools.md).
11
-
12
- ## The pipeline and its order
13
-
14
- ```
15
- 1. Fonts if config.fonts is set
16
- 2. Icon sprite if config.icons !== false
17
- 3. CSS if the styles entry file exists
18
- 4. Client JS if client/entries/ exists
19
- 5. Images if config.images !== false, not watch, and sharp is installed
20
- 6. Manifest .jskelet/manifest.json
21
- 7. Precompress if not watch
22
- ```
23
-
24
- The order is not arbitrary:
25
-
26
- - **CSS comes after the icon sprite.** The sprite is an asset and produces no
27
- classes, but it does give a manifest key.
28
- - **Precompress is last:** everything that gets compressed must already be
29
- produced.
30
- - **Images never run on a watch pass:** re-encoding with `sharp` is expensive.
31
-
32
- Tasks only run if the relevant configuration exists. A project that does not
33
- define any fonts never sees the font step; this is the build-side counterpart of
34
- the principle that "the framework does not impose its own assumptions on every
35
- project".
36
-
37
- The terminal output gives aligned step lines and an `output` block at the end:
38
- the raw and brotli size of every asset, largest to smallest.
39
-
40
- ## The manifest and hashed assets
41
-
42
- The build output is written under `public/assets/` with **content-hashed** names,
43
- and the logical name → public URL mapping is put in the `.jskelet/manifest.json`
44
- file:
45
-
46
- ```json
47
- {
48
- "app.css": "/assets/app.4f2a1b9c07.css",
49
- "sprite.svg": "/assets/sprite.dc973997bd.svg",
50
- "main.js": "/assets/js/main.9E1AB2C3.js",
51
- "inter-400.woff2": "/fonts/inter-400.woff2"
52
- }
53
- ```
54
-
55
- The hash is the first 10 hex characters of sha256: more than enough against
56
- collisions and it keeps file names readable. Because they are hashed, these files
57
- can be given `Cache-Control: public, max-age=31536000, immutable`.
58
-
59
- ### `asset(name)` and `hasAsset(name)`
60
-
61
- They are passed to templates automatically; in server code,
62
- `import { asset, hasAsset } from "jskelet"`.
63
-
64
- ```ejs
65
- <% if (hasAsset('app.css')) { %>
66
- <link rel="stylesheet" href="<%= asset('app.css') %>">
67
- <% } %>
68
- ```
69
-
70
- - `asset(name)` returns the hashed URL if it is in the manifest, otherwise
71
- `/assets/<name>`.
72
- - `hasAsset(name)` tells you whether it is in the manifest.
73
-
74
- If the build has not run, the application still comes up: `hasAsset()` is false
75
- and the layout never emits the stylesheet and script tags. When `jskelet build`
76
- is forgotten you get an unstyled but working page instead of an error. If the
77
- manifest is missing entirely, a warning is printed once:
78
- ``[assets] no manifest — run `jskelet build`.``
79
-
80
- The manifest is re-read **on every request in dev** (watch builds change the
81
- hashes) and once in prod.
82
-
83
- ### Manifest consistency in watch mode
84
-
85
- On a watch pass, a recompiled asset is written to a new hash and the old one is
86
- deleted. That is why the manifest has to be updated too (`patchManifest`):
87
- otherwise the HTML asks for the deleted file, gets a 404, and the page stays
88
- unstyled or JS-less for the rest of the dev session. Both the CSS and the client
89
- tasks patch their own key on every pass; the other keys are preserved.
90
-
91
- ## CSS — Tailwind v4
92
-
93
- The entry file is `paths.styles` (default `styles/globals.css`). If the file does
94
- not exist, the step is skipped with a warning.
95
-
96
- The pipeline: PostCSS + `@tailwindcss/postcss` → minification with lightningcss
97
- (if present) → `writeAsset("app.css", …)`.
98
-
99
- - **The PostCSS pipeline is set up once:** Tailwind's own cache lives in the
100
- plugin instance; recreating it on every compile slows watch passes down
101
- noticeably.
102
- - **lightningcss is optional:** without it, Tailwind's own output is used, and it
103
- is only a few kB bigger.
104
- - The output is a single file and the layout loads it render-blocking. The
105
- measurement-based reasoning for not producing a separate "critical CSS" is in
106
- [02-architecture.md](./02-architecture.md).
107
-
108
- ### `@source` directives are mandatory
109
-
110
- Tailwind v4's class scanning depends on the `@source` directives inside
111
- `globals.css`. Automatic detection only scans the directory the stylesheet lives
112
- in, so the variants used in templates (things like `data-[active=false]:…`) get
113
- **silently dropped**.
114
-
115
- ```css
116
- @import "tailwindcss" source(none);
117
-
118
- @source "../views";
119
- @source "../client";
120
- @source "../routes";
121
-
122
- .wrapper {
123
- max-width: 48rem;
124
- margin-inline: auto;
125
- padding-inline: 1rem;
126
- padding-block: 2rem;
127
- }
128
- ```
129
-
130
- `source(none)` turns automatic detection off and makes the scanning fully
131
- explicit. **When you add a new top-level directory, add the `@source` line as
132
- well** — this is the most common reason for classes "sometimes not working".
133
-
134
- ### CSS watch scope
135
-
136
- In watch mode three targets are watched: the directory the stylesheet lives in,
137
- `views` and `client`. Template and island files are watched too because the
138
- Tailwind classes come from there; watching only `styles/` would not rebuild when
139
- a new utility is written. Changes are coalesced over 120 ms.
140
-
141
- ## Client JS — esbuild
142
-
143
- Every `.js` file inside `client/entries/*.js` is an entry. If the directory does
144
- not exist or is empty, the step is skipped.
145
-
146
- esbuild settings:
147
-
148
- | Setting | Value | Reason |
149
- | --- | --- | --- |
150
- | `bundle`, `splitting` | `true` | Shared modules move into a shared chunk |
151
- | `format` | `esm` | `type="module"` scripts |
152
- | `target` | `chrome111`, `edge111`, `firefox111`, `safari16.4` | The lower bound of the ESM + dynamic import + `IntersectionObserver` island model; transpiling to anything older grows the output without winning a single visitor |
153
- | `minify` | `true` | — |
154
- | `sourcemap` | `true` | Diagnostics in the browser |
155
- | `entryNames` | `[name].[hash]` | `immutable` cache |
156
- | `chunkNames` | `chunks/[name].[hash]` | — |
157
- | `legalComments` | `none` | — |
158
-
159
- The output lands under `public/assets/js/` and is cleaned first on every pass.
160
- `browserslist` is not read; the target list is hard-coded.
161
-
162
- ### The `@/` alias
163
-
164
- On the esbuild side, `@/` resolves to the project root and extension completion
165
- is performed (`.js`, `.mjs`, `.json`, `/index.js`). The same behaviour as
166
- `alias-hooks.mjs` on the Node side, so the modules under `lib/` can use the same
167
- import style both on the server and in the browser.
168
-
169
- ### Inlining `clientEnv`
170
-
171
- There is no `process` in the browser; modules shared with the server still read
172
- `process.env`. The keys declared through `config.clientEnv` plus `NODE_ENV` are
173
- defined as a single object at build time, which means that reading a key not in
174
- the list returns `undefined` instead of crashing. Details:
175
- [07-configuration.md](./07-configuration.md).
176
-
177
- ### Manifest keys
178
-
179
- Only **real entries** go into the manifest: dynamic imports also carry an
180
- `entryPoint`, and if they were not filtered out every island would become a
181
- separate manifest key. The key is the file name itself (`main.js`, `chart.js`),
182
- the value is the hashed URL.
183
-
184
- That is why a controller writing `entries: ["chart.js"]` does not have to know
185
- the hash ([05-islands.md](./05-islands.md)).
186
-
187
- ### `metafile.json`
188
-
189
- The esbuild metafile is written to the `.jskelet/metafile.json` file; the chunk
190
- analysis in the dev panel reads the input/output breakdown from there. If the
191
- write fails, the build does not go down — the analysis data is best-effort. **The
192
- runtime does not depend on this file.**
193
-
194
- ## Fonts
195
-
196
- Self-hosted font files instead of `next/font/google`.
197
-
198
- The files sit under `public/fonts/` with **fixed names** (no hash), because the
199
- `url()` paths inside `@font-face` are written by hand; hashing them would force
200
- the stylesheet to change on every build too.
201
-
202
- If a file is missing, it is downloaded from Google Fonts **once** and is
203
- **expected to be committed**: having the build depend on the network is fragile
204
- in CI. If the download fails, a warning is printed and the page falls back to the
205
- system font stack — the build does not stop.
206
-
207
- Only the latin subset (`U+0000-00FF`) is downloaded: the others are dead weight
208
- for most sites, and without `unicode-range` downloading all of them multiplies
209
- the font size.
210
-
211
- Usage is written by hand in the stylesheet:
212
-
213
- ```css
214
- @font-face {
215
- font-family: "Inter";
216
- font-style: normal;
217
- font-weight: 400;
218
- font-display: swap;
219
- src: url("/fonts/inter-400.woff2") format("woff2");
220
- }
221
- ```
222
-
223
- Because the `.woff2` extension and the `/fonts/` prefix are in the default
224
- `static` rules, these files automatically get an `immutable` cache.
225
-
226
- ## Icon sprite
227
-
228
- From the individual SVGs inside `@phosphor-icons/core`, it produces a `<symbol>`
229
- set for **only the icons actually used in the source**. Shipping the whole set
230
- means 1500+ icons, i.e. several megabytes; usage scanning typically keeps the
231
- sprite at 10-30 symbols.
232
-
233
- - Symbol id: `<kebab-name>-<weight>`, e.g. `arrow-right-bold`.
234
- - The package is resolved from the **application's** `node_modules` (the icon set
235
- is the application's devDependency); if it is not installed, the step is
236
- silently skipped.
237
- - The scanned directories default to `views`, `client`, `routes`, `lib`; they can
238
- be changed with `icons.scan`. Scanned extensions: `.ejs`, `.js`, `.mjs`.
239
- - Weights: `thin`, `light`, `regular`, `bold`, `fill`, `duotone`. An
240
- unrecognised weight counts as `regular`.
241
-
242
- ### What the scan finds
243
-
244
- | Form in the source | Is it found |
245
- | --- | --- |
246
- | `icon({ name: "ArrowRight", weight: "bold" })` | ✓ name + weight |
247
- | `icon({ name: cond ? "A" : "B" })` | ✓ both constant names |
248
- | `data-icon="flag:fill"` or `"data-icon": "flag:fill"` | ✓ |
249
- | `icon: "XLogo"` / `iconName: "XLogo"` (in configuration lists) | ✓ name; weights are the ones collected from indirect calls |
250
- | `icon({ name: item.icon })` | ✗ the name is not statically visible |
251
-
252
- There are two safety nets for the last row: configuration fields that carry a
253
- name (`icon: "XLogo"`) are also searched, and in development `icon()` reads the
254
- symbols in the sprite and prints a one-off warning for a missing one:
255
-
256
- ```
257
- [icon] missing from sprite: x-logo-regular — write the name as a literal or add
258
- it to the build/tasks/icons.mjs scan.
259
- ```
260
-
261
- If you see this warning, either write the name as a constant, or add the relevant
262
- directory to the `icons.scan` list, or keep the name in a configuration field in
263
- the form `icon: "XLogo"`.
264
-
265
- Names that cannot be found in Phosphor are warned about as a summary at the end
266
- of the build: `N icons missing → …`
267
-
268
- ## Image optimisation
269
-
270
- The build-time counterpart of the `next/image` optimizer. For the png/jpg files
271
- placed by hand under `public/`, it produces webp at a few widths and writes them
272
- to the `.jskelet/images.json` manifest. `image()` looks at that manifest and adds
273
- `srcset` plus intrinsic `width`/`height`; the calling side changes nothing
274
- ([04-rendering.md](./04-rendering.md)).
275
-
276
- - The outputs land hashed under `public/assets/img/`, which means they fall
277
- within the scope of the `immutable` cache and precompression.
278
- - **The source files stay where they are:** an image not in the manifest is
279
- always served as the original.
280
- - The `assets` and `fonts` directories are always skipped; additional ones with
281
- `images.skip`.
282
- - The widths are used with the ones larger than the source dropped, and the
283
- source's own width (at most 1920) always makes it into the list. Above 1920 is
284
- wasteful even on retina screens.
285
- - The variant hash is derived from the **source + the width**: the same content
286
- gives the same file name on every build, so the `immutable` cache does not go
287
- stale.
288
- - The encoder signature is written into the manifest (`webp-q78-e4`). When the
289
- quality setting changes, the signature changes with it and every image is
290
- re-encoded; otherwise outputs produced with the old setting would silently
291
- remain.
292
- - If the source has not changed and the outputs are still in place, nothing is
293
- re-encoded. In a large `public/` directory this brings the build time down from
294
- minutes to seconds.
295
- - A single corrupt/unreadable image does not bring the build down: a warning is
296
- printed and, because it is not in the manifest, the original file continues to
297
- be served.
298
- - Old outputs that no longer appear in the manifest are deleted.
299
-
300
- This step requires `sharp`. If it is not installed the step is silently skipped
301
- and `image()` falls back to the original file. It never runs on a watch pass.
302
-
303
- ## Precompress
304
-
305
- Produces brotli (quality 11) and gzip (level 9) copies of the built assets:
306
- `app.<hash>.css.br`, `app.<hash>.css.gz`, …
307
-
308
- - Only `public/assets/` is covered: the files there are hashed and `immutable`,
309
- meaning their contents never change and recompressing them on every request is
310
- wasted CPU. Compressing once at build time with quality 11 both zeroes out the
311
- server load and gives a ratio you could never afford at runtime (as against
312
- quality 5 at request time).
313
- - Files placed by hand under `public/` are left to runtime compression, because
314
- they are small and requested rarely.
315
- - Compressed extensions: `.css`, `.js`, `.mjs`, `.svg`, `.json`, `.xml`,
316
- `.txt`, `.map`. Already-compressed formats (woff2, png, jpg, webp) are skipped.
317
- - Files under 1 KB are skipped: the gain does not cover the header cost.
318
- - `.br`/`.gz` copies left over from the previous pass are deleted first, so they
319
- cannot go stale.
320
- - It does not run in watch mode: quality-11 brotli on every change is slow.
321
-
322
- These files are served by the `staticPrecompressed` middleware; if there is no
323
- copy, the request is handed over to `express.static`
324
- ([02-architecture.md](./02-architecture.md)).
325
-
326
- ## Optional peer dependencies
327
-
328
- | Package | The step that needs it | What happens without it |
329
- | --- | --- | --- |
330
- | `postcss` | CSS | The CSS step **throws** (a required import) |
331
- | `@tailwindcss/postcss` | CSS | The CSS step **throws** |
332
- | `tailwindcss` | CSS (peer) | Tailwind directives cannot be resolved |
333
- | `lightningcss` | CSS minification | Tailwind's output is used, a few kB bigger |
334
- | `sharp` | Image optimisation | The step is skipped; `image()` uses the original |
335
- | `@phosphor-icons/core` | Icon sprite | The step is skipped; `icon()` produces an empty `<use>` |
336
-
337
- If you are not going to use CSS, simply never create the `paths.styles` file: the
338
- step is skipped with a warning and postcss is not needed.
339
-
340
- The packages are resolved from the **application's** `node_modules`, not the
341
- framework's own. If the framework is installed via a `file:` or workspace link,
342
- its source files run in their own directory and a plain `import "postcss"` looks
343
- at the framework's tree — not yours. That is why resolution is started from the
344
- application root.
345
-
346
- ## Suggested `.gitignore`
347
-
348
- ```
349
- node_modules/
350
- .jskelet/
351
- public/assets/
352
- .env
353
- ```
354
-
355
- `public/fonts/` **should be committed** (so the build does not depend on the
356
- network), `public/assets/` should not be (it is regenerated on every build).
357
-
358
- ## `jskelet start` and a missing build
359
-
360
- `jskelet start` first looks at the `.jskelet/manifest.json` file; if it is
361
- missing, it runs the build itself. In a Docker image the build has already
362
- happened so this is a no-op; the point is that someone running `npm start`
363
- directly does not end up facing an unstyled page.
364
-
365
- ## Diagnostics: common situations
366
-
367
- - **No styles at all.** The build has not run (`hasAsset('app.css')` is false) or
368
- the `paths.styles` file does not exist. Check the `CSS` line in the build
369
- output.
370
- - **Some Tailwind classes do not work.** They were written in a directory whose
371
- `@source` directive is missing.
372
- - **An icon looks empty.** That symbol is not in the sprite; in dev, look for the
373
- `[icon] missing from sprite` warning.
374
- - **The islands never open.** `main.js` is not in the manifest (the entry
375
- directory is empty or the build was skipped) or there is a build error.
376
- - **The page suddenly went unstyled in dev.** The manifest and the file on disk
377
- have diverged; restarting `jskelet dev` is enough.
378
-
379
- ## What's next
380
-
381
- - The watch flow and CSS hot-swap: [09-dev-tools.md](./09-dev-tools.md)
382
- - Prod build + start and Docker: [10-deployment.md](./10-deployment.md)
383
- - Using `entries` and the island bundle: [05-islands.md](./05-islands.md)
1
+ # 08 — Build
2
+
3
+ This document describes every job `jskelet build` does and the order it does them
4
+ in: font copying, icon sprite generation, Tailwind CSS compilation, the island
5
+ bundle via esbuild, image optimisation, writing the manifest and precompression.
6
+ It also covers how hashed assets reach the templates through
7
+ `asset()`/`hasAsset()`, why Tailwind's `@source` directives are mandatory and how
8
+ optional peer dependencies behave. How the output is served at runtime is in
9
+ [02-architecture.md](./02-architecture.md), and the watch flow that triggers the
10
+ build is in [09-dev-tools.md](./09-dev-tools.md).
11
+
12
+ ## The pipeline and its order
13
+
14
+ ```
15
+ 1. Fonts if config.fonts is set
16
+ 2. Icon sprite if config.icons !== false
17
+ 3. CSS if the styles entry file exists
18
+ 4. Client JS if client/entries/ exists
19
+ 5. Images if config.images !== false, not watch, and sharp is installed
20
+ 6. Manifest .jskelet/manifest.json
21
+ 7. Precompress if not watch
22
+ ```
23
+
24
+ The order is not arbitrary:
25
+
26
+ - **CSS comes after the icon sprite.** The sprite is an asset and produces no
27
+ classes, but it does give a manifest key.
28
+ - **Precompress is last:** everything that gets compressed must already be
29
+ produced.
30
+ - **Images never run on a watch pass:** re-encoding with `sharp` is expensive.
31
+
32
+ Tasks only run if the relevant configuration exists. A project that does not
33
+ define any fonts never sees the font step; this is the build-side counterpart of
34
+ the principle that "the framework does not impose its own assumptions on every
35
+ project".
36
+
37
+ The terminal output gives aligned step lines and an `output` block at the end:
38
+ the raw and brotli size of every asset, largest to smallest.
39
+
40
+ ## The manifest and hashed assets
41
+
42
+ The build output is written under `public/assets/` with **content-hashed** names,
43
+ and the logical name → public URL mapping is put in the `.jskelet/manifest.json`
44
+ file:
45
+
46
+ ```json
47
+ {
48
+ "app.css": "/assets/app.4f2a1b9c07.css",
49
+ "sprite.svg": "/assets/sprite.dc973997bd.svg",
50
+ "main.js": "/assets/js/main.9E1AB2C3.js",
51
+ "inter-400.woff2": "/fonts/inter-400.woff2"
52
+ }
53
+ ```
54
+
55
+ The hash is the first 10 hex characters of sha256: more than enough against
56
+ collisions and it keeps file names readable. Because they are hashed, these files
57
+ can be given `Cache-Control: public, max-age=31536000, immutable`.
58
+
59
+ ### `asset(name)` and `hasAsset(name)`
60
+
61
+ They are passed to templates automatically; in server code,
62
+ `import { asset, hasAsset } from "jskelet"`.
63
+
64
+ ```ejs
65
+ <% if (hasAsset('app.css')) { %>
66
+ <link rel="stylesheet" href="<%= asset('app.css') %>">
67
+ <% } %>
68
+ ```
69
+
70
+ - `asset(name)` returns the hashed URL if it is in the manifest, otherwise
71
+ `/assets/<name>`.
72
+ - `hasAsset(name)` tells you whether it is in the manifest.
73
+
74
+ If the build has not run, the application still comes up: `hasAsset()` is false
75
+ and the layout never emits the stylesheet and script tags. When `jskelet build`
76
+ is forgotten you get an unstyled but working page instead of an error. If the
77
+ manifest is missing entirely, a warning is printed once:
78
+ ``[assets] no manifest — run `jskelet build`.``
79
+
80
+ The manifest is re-read **on every request in dev** (watch builds change the
81
+ hashes) and once in prod.
82
+
83
+ ### Manifest consistency in watch mode
84
+
85
+ On a watch pass, a recompiled asset is written to a new hash and the old one is
86
+ deleted. That is why the manifest has to be updated too (`patchManifest`):
87
+ otherwise the HTML asks for the deleted file, gets a 404, and the page stays
88
+ unstyled or JS-less for the rest of the dev session. Both the CSS and the client
89
+ tasks patch their own key on every pass; the other keys are preserved.
90
+
91
+ ## CSS — Tailwind v4
92
+
93
+ The entry file is `paths.styles` (default `styles/globals.css`). If the file does
94
+ not exist, the step is skipped with a warning.
95
+
96
+ The pipeline: PostCSS + `@tailwindcss/postcss` → minification with lightningcss
97
+ (if present) → `writeAsset("app.css", …)`.
98
+
99
+ - **The PostCSS pipeline is set up once:** Tailwind's own cache lives in the
100
+ plugin instance; recreating it on every compile slows watch passes down
101
+ noticeably.
102
+ - **lightningcss is optional:** without it, Tailwind's own output is used, and it
103
+ is only a few kB bigger.
104
+ - The output is a single file and the layout loads it render-blocking. The
105
+ measurement-based reasoning for not producing a separate "critical CSS" is in
106
+ [02-architecture.md](./02-architecture.md).
107
+
108
+ ### `@source` directives are mandatory
109
+
110
+ Tailwind v4's class scanning depends on the `@source` directives inside
111
+ `globals.css`. Automatic detection only scans the directory the stylesheet lives
112
+ in, so the variants used in templates (things like `data-[active=false]:…`) get
113
+ **silently dropped**.
114
+
115
+ ```css
116
+ @import "tailwindcss" source(none);
117
+
118
+ @source "../views";
119
+ @source "../client";
120
+ @source "../routes";
121
+
122
+ .wrapper {
123
+ max-width: 48rem;
124
+ margin-inline: auto;
125
+ padding-inline: 1rem;
126
+ padding-block: 2rem;
127
+ }
128
+ ```
129
+
130
+ `source(none)` turns automatic detection off and makes the scanning fully
131
+ explicit. **When you add a new top-level directory, add the `@source` line as
132
+ well** — this is the most common reason for classes "sometimes not working".
133
+
134
+ ### CSS watch scope
135
+
136
+ In watch mode three targets are watched: the directory the stylesheet lives in,
137
+ `views` and `client`. Template and island files are watched too because the
138
+ Tailwind classes come from there; watching only `styles/` would not rebuild when
139
+ a new utility is written. Changes are coalesced over 120 ms.
140
+
141
+ ## Client JS — esbuild
142
+
143
+ Every `.js` file inside `client/entries/*.js` is an entry. If the directory does
144
+ not exist or is empty, the step is skipped.
145
+
146
+ esbuild settings:
147
+
148
+ | Setting | Value | Reason |
149
+ | --- | --- | --- |
150
+ | `bundle`, `splitting` | `true` | Shared modules move into a shared chunk |
151
+ | `format` | `esm` | `type="module"` scripts |
152
+ | `target` | `chrome111`, `edge111`, `firefox111`, `safari16.4` | The lower bound of the ESM + dynamic import + `IntersectionObserver` island model; transpiling to anything older grows the output without winning a single visitor |
153
+ | `minify` | `true` | — |
154
+ | `sourcemap` | `true` | Diagnostics in the browser |
155
+ | `entryNames` | `[name].[hash]` | `immutable` cache |
156
+ | `chunkNames` | `chunks/[name].[hash]` | — |
157
+ | `legalComments` | `none` | — |
158
+
159
+ The output lands under `public/assets/js/` and is cleaned first on every pass.
160
+ `browserslist` is not read; the target list is hard-coded.
161
+
162
+ ### The `@/` alias
163
+
164
+ On the esbuild side, `@/` resolves to the project root and extension completion
165
+ is performed (`.js`, `.mjs`, `.json`, `/index.js`). The same behaviour as
166
+ `alias-hooks.mjs` on the Node side, so the modules under `lib/` can use the same
167
+ import style both on the server and in the browser.
168
+
169
+ ### Inlining `clientEnv`
170
+
171
+ There is no `process` in the browser; modules shared with the server still read
172
+ `process.env`. The keys declared through `config.clientEnv` plus `NODE_ENV` are
173
+ defined as a single object at build time, which means that reading a key not in
174
+ the list returns `undefined` instead of crashing. Details:
175
+ [07-configuration.md](./07-configuration.md).
176
+
177
+ ### Manifest keys
178
+
179
+ Only **real entries** go into the manifest: dynamic imports also carry an
180
+ `entryPoint`, and if they were not filtered out every island would become a
181
+ separate manifest key. The key is the file name itself (`main.js`, `chart.js`),
182
+ the value is the hashed URL.
183
+
184
+ That is why a controller writing `entries: ["chart.js"]` does not have to know
185
+ the hash ([05-islands.md](./05-islands.md)).
186
+
187
+ ### `metafile.json`
188
+
189
+ The esbuild metafile is written to the `.jskelet/metafile.json` file; the chunk
190
+ analysis in the dev panel reads the input/output breakdown from there. If the
191
+ write fails, the build does not go down — the analysis data is best-effort. **The
192
+ runtime does not depend on this file.**
193
+
194
+ ## Fonts
195
+
196
+ Self-hosted font files instead of `next/font/google`.
197
+
198
+ The files sit under `public/fonts/` with **fixed names** (no hash), because the
199
+ `url()` paths inside `@font-face` are written by hand; hashing them would force
200
+ the stylesheet to change on every build too.
201
+
202
+ If a file is missing, it is downloaded from Google Fonts **once** and is
203
+ **expected to be committed**: having the build depend on the network is fragile
204
+ in CI. If the download fails, a warning is printed and the page falls back to the
205
+ system font stack — the build does not stop.
206
+
207
+ Only the latin subset (`U+0000-00FF`) is downloaded: the others are dead weight
208
+ for most sites, and without `unicode-range` downloading all of them multiplies
209
+ the font size.
210
+
211
+ Usage is written by hand in the stylesheet:
212
+
213
+ ```css
214
+ @font-face {
215
+ font-family: "Inter";
216
+ font-style: normal;
217
+ font-weight: 400;
218
+ font-display: swap;
219
+ src: url("/fonts/inter-400.woff2") format("woff2");
220
+ }
221
+ ```
222
+
223
+ Because the `.woff2` extension and the `/fonts/` prefix are in the default
224
+ `static` rules, these files automatically get an `immutable` cache.
225
+
226
+ ## Icon sprite
227
+
228
+ From the individual SVGs inside `@phosphor-icons/core`, it produces a `<symbol>`
229
+ set for **only the icons actually used in the source**. Shipping the whole set
230
+ means 1500+ icons, i.e. several megabytes; usage scanning typically keeps the
231
+ sprite at 10-30 symbols.
232
+
233
+ - Symbol id: `<kebab-name>-<weight>`, e.g. `arrow-right-bold`.
234
+ - The package is resolved from the **application's** `node_modules` (the icon set
235
+ is the application's devDependency); if it is not installed, the step is
236
+ silently skipped.
237
+ - The scanned directories default to `views`, `client`, `routes`, `lib`; they can
238
+ be changed with `icons.scan`. Scanned extensions: `.ejs`, `.js`, `.mjs`.
239
+ - Weights: `thin`, `light`, `regular`, `bold`, `fill`, `duotone`. An
240
+ unrecognised weight counts as `regular`.
241
+
242
+ ### What the scan finds
243
+
244
+ | Form in the source | Is it found |
245
+ | --- | --- |
246
+ | `icon({ name: "ArrowRight", weight: "bold" })` | ✓ name + weight |
247
+ | `icon({ name: cond ? "A" : "B" })` | ✓ both constant names |
248
+ | `data-icon="flag:fill"` or `"data-icon": "flag:fill"` | ✓ |
249
+ | `icon: "XLogo"` / `iconName: "XLogo"` (in configuration lists) | ✓ name; weights are the ones collected from indirect calls |
250
+ | `icon({ name: item.icon })` | ✗ the name is not statically visible |
251
+
252
+ There are two safety nets for the last row: configuration fields that carry a
253
+ name (`icon: "XLogo"`) are also searched, and in development `icon()` reads the
254
+ symbols in the sprite and prints a one-off warning for a missing one:
255
+
256
+ ```
257
+ [icon] missing from sprite: x-logo-regular — write the name as a literal or add
258
+ it to the build/tasks/icons.mjs scan.
259
+ ```
260
+
261
+ If you see this warning, either write the name as a constant, or add the relevant
262
+ directory to the `icons.scan` list, or keep the name in a configuration field in
263
+ the form `icon: "XLogo"`.
264
+
265
+ Names that cannot be found in Phosphor are warned about as a summary at the end
266
+ of the build: `N icons missing → …`
267
+
268
+ ## Image optimisation
269
+
270
+ The build-time counterpart of the `next/image` optimizer. For the png/jpg files
271
+ placed by hand under `public/`, it produces webp at a few widths and writes them
272
+ to the `.jskelet/images.json` manifest. `image()` looks at that manifest and adds
273
+ `srcset` plus intrinsic `width`/`height`; the calling side changes nothing
274
+ ([04-rendering.md](./04-rendering.md)).
275
+
276
+ - The outputs land hashed under `public/assets/img/`, which means they fall
277
+ within the scope of the `immutable` cache and precompression.
278
+ - **The source files stay where they are:** an image not in the manifest is
279
+ always served as the original.
280
+ - The `assets` and `fonts` directories are always skipped; additional ones with
281
+ `images.skip`.
282
+ - The widths are used with the ones larger than the source dropped, and the
283
+ source's own width (at most 1920) always makes it into the list. Above 1920 is
284
+ wasteful even on retina screens.
285
+ - The variant hash is derived from the **source + the width**: the same content
286
+ gives the same file name on every build, so the `immutable` cache does not go
287
+ stale.
288
+ - The encoder signature is written into the manifest (`webp-q78-e4`). When the
289
+ quality setting changes, the signature changes with it and every image is
290
+ re-encoded; otherwise outputs produced with the old setting would silently
291
+ remain.
292
+ - If the source has not changed and the outputs are still in place, nothing is
293
+ re-encoded. In a large `public/` directory this brings the build time down from
294
+ minutes to seconds.
295
+ - A single corrupt/unreadable image does not bring the build down: a warning is
296
+ printed and, because it is not in the manifest, the original file continues to
297
+ be served.
298
+ - Old outputs that no longer appear in the manifest are deleted.
299
+
300
+ This step requires `sharp`. If it is not installed the step is silently skipped
301
+ and `image()` falls back to the original file. It never runs on a watch pass.
302
+
303
+ ## Precompress
304
+
305
+ Produces brotli (quality 11) and gzip (level 9) copies of the built assets:
306
+ `app.<hash>.css.br`, `app.<hash>.css.gz`, …
307
+
308
+ - Only `public/assets/` is covered: the files there are hashed and `immutable`,
309
+ meaning their contents never change and recompressing them on every request is
310
+ wasted CPU. Compressing once at build time with quality 11 both zeroes out the
311
+ server load and gives a ratio you could never afford at runtime (as against
312
+ quality 5 at request time).
313
+ - Files placed by hand under `public/` are left to runtime compression, because
314
+ they are small and requested rarely.
315
+ - Compressed extensions: `.css`, `.js`, `.mjs`, `.svg`, `.json`, `.xml`,
316
+ `.txt`, `.map`. Already-compressed formats (woff2, png, jpg, webp) are skipped.
317
+ - Files under 1 KB are skipped: the gain does not cover the header cost.
318
+ - `.br`/`.gz` copies left over from the previous pass are deleted first, so they
319
+ cannot go stale.
320
+ - It does not run in watch mode: quality-11 brotli on every change is slow.
321
+
322
+ These files are served by the `staticPrecompressed` middleware; if there is no
323
+ copy, the request is handed over to `express.static`
324
+ ([02-architecture.md](./02-architecture.md)).
325
+
326
+ ## Optional peer dependencies
327
+
328
+ | Package | The step that needs it | What happens without it |
329
+ | --- | --- | --- |
330
+ | `postcss` | CSS | The CSS step **throws** (a required import) |
331
+ | `@tailwindcss/postcss` | CSS | The CSS step **throws** |
332
+ | `tailwindcss` | CSS (peer) | Tailwind directives cannot be resolved |
333
+ | `lightningcss` | CSS minification | Tailwind's output is used, a few kB bigger |
334
+ | `sharp` | Image optimisation | The step is skipped; `image()` uses the original |
335
+ | `@phosphor-icons/core` | Icon sprite | The step is skipped; `icon()` produces an empty `<use>` |
336
+
337
+ If you are not going to use CSS, simply never create the `paths.styles` file: the
338
+ step is skipped with a warning and postcss is not needed.
339
+
340
+ The packages are resolved from the **application's** `node_modules`, not the
341
+ framework's own. If the framework is installed via a `file:` or workspace link,
342
+ its source files run in their own directory and a plain `import "postcss"` looks
343
+ at the framework's tree — not yours. That is why resolution is started from the
344
+ application root.
345
+
346
+ ## Suggested `.gitignore`
347
+
348
+ ```
349
+ node_modules/
350
+ .jskelet/
351
+ public/assets/
352
+ .env
353
+ ```
354
+
355
+ `public/fonts/` **should be committed** (so the build does not depend on the
356
+ network), `public/assets/` should not be (it is regenerated on every build).
357
+
358
+ ## `jskelet start` and a missing build
359
+
360
+ `jskelet start` first looks at the `.jskelet/manifest.json` file; if it is
361
+ missing, it runs the build itself. In a Docker image the build has already
362
+ happened so this is a no-op; the point is that someone running `npm start`
363
+ directly does not end up facing an unstyled page.
364
+
365
+ ## Diagnostics: common situations
366
+
367
+ - **No styles at all.** The build has not run (`hasAsset('app.css')` is false) or
368
+ the `paths.styles` file does not exist. Check the `CSS` line in the build
369
+ output.
370
+ - **Some Tailwind classes do not work.** They were written in a directory whose
371
+ `@source` directive is missing.
372
+ - **An icon looks empty.** That symbol is not in the sprite; in dev, look for the
373
+ `[icon] missing from sprite` warning.
374
+ - **The islands never open.** `main.js` is not in the manifest (the entry
375
+ directory is empty or the build was skipped) or there is a build error.
376
+ - **The page suddenly went unstyled in dev.** The manifest and the file on disk
377
+ have diverged; restarting `jskelet dev` is enough.
378
+
379
+ ## What's next
380
+
381
+ - The watch flow and CSS hot-swap: [09-dev-tools.md](./09-dev-tools.md)
382
+ - Prod build + start and Docker: [10-deployment.md](./10-deployment.md)
383
+ - Using `entries` and the island bundle: [05-islands.md](./05-islands.md)