jskelet 0.6.3 → 0.6.4

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