jskelet 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -70,8 +70,34 @@ controller data → import edilmiş render(data, helpers) → HTML
70
70
  | Yerleşikler | `Link`, `Image`, `Icon`, `CsrfField`, `PreloadImage` |
71
71
 
72
72
  İfade dili kasıtlı olarak dardır (erişim, karşılaştırma, ternary, `.length`).
73
- Atama ve rastgele fonksiyon çağrısı yok — mantık controller veya JS bileşende
74
- kalır.
73
+ Atama, object literal ve rastgele fonksiyon çağrısı yok — mantık controller
74
+ veya JS bileşende kalır.
75
+
76
+ #### Şablon mu, bileşen mi?
77
+
78
+ EJS’den geçerken sınırı erken çizmek işe yarar:
79
+
80
+ | Burada kalsın (`.jsk`) | JS bileşene taşı |
81
+ | --- | --- |
82
+ | Metin, koşul, liste, prop bağlama | Fonksiyon çağrısı, nesne üretimi, biçimlendirme |
83
+ | Yerleşik etiketler (`Link`, `Image`, …) | Birden fazla yardımcıdan HTML birleştirme |
84
+ | Controller’dan gelen hazır veri | Upstream / hata ayırt eden UI (`LoadErrorState`) |
85
+
86
+ Şablonda `format(x)` veya `{ a: 1 }` yazılamıyorsa bu bir eksik değil: o iş
87
+ `views/components/*.js` veya controller’ındır. Karmaşık sayfalar bileşene
88
+ kaçıyorsa ifade dilini genişletmek yerine bileşen sınırını net tutmak tercih
89
+ edilir.
90
+
91
+ ### Editör desteği
92
+
93
+ Repo içinde `extensions/vscode-jsk` VS Code / Cursor uzantısı vardır: sözdizimi
94
+ renklendirme, dil yapılandırması ve snippet'ler. Yerel kurulum:
95
+
96
+ ```bash
97
+ code --install-extension extensions/vscode-jsk
98
+ ```
99
+
100
+ Ayrıntılar uzantı README'sinde.
75
101
 
76
102
  ### EJS ile birlikte yaşam
77
103
 
@@ -243,13 +269,18 @@ Kurallar:
243
269
 
244
270
  - Tarama özyinelemelidir; alt dizinler de kapsanır.
245
271
  - `default` export'lar yok sayılır — yalnızca named export'lar kaydedilir.
272
+ - Compile-time bilinen bileşen listesi **dosya adından değil**, kaynak
273
+ metindeki named export'lardan okunur. `ui.js` içindeki `sectionHead` →
274
+ şablonda `<SectionHead />` (runtime zaten camelCase export'a PascalCase
275
+ alias ekler). Dosya adına göre stub re-export eklemeye gerek yoktur.
246
276
  - `loader.js` ve `index.js` bileşen dosyası sayılmaz.
247
277
  - `views/components/index.js` varsa **barrel** olarak, en düşük öncelikle en
248
278
  önce yüklenir. Tek amacı `lib/` yeniden ihraçlarını şablon local'i yapmak;
249
279
  bileşenlerin kendi dosyaları sonradan gelip sessizce üzerine yazar.
250
- - Aynı ad iki farklı bileşen dosyasında tanımlıysa uyarı basılır ve **ikincisi
251
- kazanır**: `[components] 'card' is defined twice: a.js and b.js — the second
252
- one wins.`
280
+ - Aynı ad (veya aynı PascalCase etiket) iki farklı bileşen dosyasında
281
+ tanımlıysa **uyarı değil hata**: build ve sunucu açılışı
282
+ `Component 'card' is defined twice: …` ile durur. Barrel üzerine yazmak
283
+ bilinçli istisnadır.
253
284
  - `views/components` dizini yoksa bileşen kaydı boş kalır; bileşen kullanmayan
254
285
  bir proje de çalışır.
255
286
 
package/docs/06-cache.md CHANGED
@@ -380,6 +380,68 @@ export async function apiGet(path) {
380
380
  }
381
381
  ```
382
382
 
383
+ ### Loader sözleşmesi: boş liste ≠ hata
384
+
385
+ `catch → []` (veya `null`) ile yutulan bir upstream hatası, yanlış mapping ile
386
+ aynı görünür: boş UI. Rate limit ve geçici hatalar logda doğru yönde
387
+ işaretlense bile ziyaretçi “veri yok” sanır. Widget loader’ları sessiz
388
+ `[]`’ye gömülmek yerine sonucu ayırsın:
389
+
390
+ ```js
391
+ /**
392
+ * @returns {Promise<{ items: object[], error: Error | null }>}
393
+ */
394
+ export async function loadTickerItems() {
395
+ try {
396
+ const items = await apiGet("/ticker");
397
+ if (!items) {
398
+ return { items: [], error: new Error("Upstream returned no data") };
399
+ }
400
+ return { items, error: null };
401
+ } catch (error) {
402
+ return {
403
+ items: [],
404
+ error: error instanceof Error ? error : new Error(String(error)),
405
+ };
406
+ }
407
+ }
408
+ ```
409
+
410
+ Uygulama tarafında ortak bir `LoadErrorState` bileşeni (veya eşdeğeri) bu
411
+ `error` alanını göstersin; her widget kendi boş hâline düşmesin:
412
+
413
+ ```js
414
+ // views/components/load-error-state.js
415
+ import { esc } from "jskelet/html";
416
+
417
+ /**
418
+ * @param {{ message?: string, title?: string }} props
419
+ * @returns {string}
420
+ */
421
+ export function LoadErrorState({ message, title = "Veri yüklenemedi" }) {
422
+ return `<div role="alert" data-load-error class="…">
423
+ <p>${esc(title)}</p>
424
+ ${message ? `<p>${esc(message)}</p>` : ""}
425
+ </div>`;
426
+ }
427
+ ```
428
+
429
+ ```html
430
+ {#if error}
431
+ <LoadErrorState :message="error.message" />
432
+ {#else if items.length}
433
+ {#each items as item}
434
+ …
435
+ {/each}
436
+ {#else}
437
+ <p>Kayıt yok</p>
438
+ {/if}
439
+ ```
440
+
441
+ Framework markaya özel UI taşımaz; `LoadErrorState` uygulama bileşenidir.
442
+ Önemli olan sözleşme: `{ items, error }` (veya eşdeğeri) ve hata ile “gerçekten
443
+ boş”un şablonda ayrı kolları.
444
+
383
445
  ### Geçici ve kalıcı hata ayrımı
384
446
 
385
447
  | Durum | Sayılır | Sonuç |
@@ -2,8 +2,8 @@
2
2
 
3
3
  This document explains how to get JSkelet running from scratch: installing the
4
4
  package, scaffolding the skeleton with `jskelet init`, writing your first route
5
- and your first island, what the resulting directory layout means, and the CLI's
6
- four commands. By the end you will have a page in the browser that is rendered
5
+ and your first island, what the resulting directory layout means, and the CLI
6
+ commands. By the end you will have a page in the browser that is rendered
7
7
  on the server, cached, and whose island hydrates on visibility. For the
8
8
  *reasons* behind the decisions see
9
9
  [02-architecture.md](./02-architecture.md), and for the full reference of every
@@ -58,21 +58,24 @@ what is missing and prints the number of skipped files as a warning. The goal is
58
58
  to skip the "I installed it but nothing works" stage entirely — `jskelet dev`
59
59
  runs right afterwards.
60
60
 
61
- The files it creates:
61
+ The files it creates (feature-first + `.jsk`):
62
62
 
63
63
  ```
64
- jskelet.config.mjs config: brand, preconnect, cache(), hooks
65
- routes/10-pages.mjs the "/" route
66
- views/pages/home.ejs home page template
67
- views/pages/not-found.ejs 404 template
68
- views/components/button.js example component (a function returning an HTML string)
69
- client/entries/main.js island bootstrap
70
- client/islands/counter.js example island
71
- styles/globals.css Tailwind entry + @source directives
72
- jsconfig.json checkJs + the "@/*" alias
73
- .gitignore node_modules/, .jskelet/, public/assets/, .env
64
+ jskelet.config.mjs config: brand, preconnect, cache(), hooks
65
+ features/home/index.js the "/" route
66
+ features/home/views/pages/home.jsk home page template
67
+ features/home/views/components/button.js example component (<Button />)
68
+ features/home/client/counter.js example island
69
+ features/home/server/.gitkeep
70
+ views/pages/not-found.jsk app-wide 404
71
+ client/entries/main.js island bootstrap
72
+ styles/globals.css Tailwind entry + @source directives
73
+ jsconfig.json checkJs + the "@/*" alias
74
+ .gitignore node_modules/, .jskelet/, public/assets/, .env
74
75
  ```
75
76
 
77
+ To grow: `npx jskelet generate feature <name>` (or `page` / `island`).
78
+
76
79
  Then:
77
80
 
78
81
  ```bash
@@ -92,44 +95,44 @@ None of the directory names are fixed; all of them can be overridden via
92
95
 
93
96
  | Directory | Default | Contents |
94
97
  | --- | --- | --- |
95
- | `views` | `views` | EJS layout, pages and components |
98
+ | `views` | `views` | App-wide layout, pages and components |
99
+ | `features` | `features` | Feature slices (`<name>/{server,views,client}`) |
100
+ | `shared` | `shared` | Cross-feature server/views/client |
96
101
  | `public` | `public` | Static files; build output is written here too |
97
102
  | `client` | `client` | Island runtime sources and entries |
98
- | `routes` | `routes` | Route modules |
103
+ | `routes` | `routes` | Route modules (loaded before features) |
99
104
  | `styles` | `styles/globals.css` | Tailwind/PostCSS entry **file** |
100
- | `generated` | `.jskelet` | Intermediate build output: `manifest.json`, `metafile.json`, `images.json` |
105
+ | `generated` | `.jskelet` | Intermediate build output: `manifest.json`, `metafile.json`, `images.json`, `templates/` |
101
106
 
102
107
  In addition to these the framework always derives two paths and accepts no
103
108
  separate setting for them: `public/assets` (hashed build output) and
104
109
  `public/fonts` (self-hosted fonts).
105
110
 
106
- A typical project:
111
+ A typical project (close to what `jskelet init` writes):
107
112
 
108
113
  ```
109
114
  my-site/
110
115
  ├── jskelet.config.mjs
111
116
  ├── jsconfig.json
112
- ├── routes/
113
- │ ├── 10-pages.mjs
114
- │ └── 90-catch-all.mjs
117
+ ├── features/
118
+ │ └── home/
119
+ │ ├── index.js
120
+ │ ├── server/
121
+ │ ├── views/
122
+ │ │ ├── pages/home.jsk
123
+ │ │ └── components/button.js
124
+ │ └── client/counter.js
115
125
  ├── views/
116
- │ ├── layout.ejs
117
- │ ├── pages/
118
- │ │ ├── home.ejs
119
- │ │ └── not-found.ejs
120
- │ └── components/
121
- │ └── card.js
126
+ │ └── pages/not-found.jsk
122
127
  ├── client/
123
- │ ├── entries/
124
- │ │ └── main.js
125
- │ └── islands/
126
- │ └── counter.js
128
+ │ └── entries/main.js
127
129
  ├── styles/
128
130
  │ └── globals.css
129
131
  ├── public/
130
132
  │ └── (static files; build → public/assets)
131
133
  └── .jskelet/
132
- └── manifest.json
134
+ ├── manifest.json
135
+ └── templates/
133
136
  ```
134
137
 
135
138
  ## Your first route
@@ -140,7 +143,7 @@ a default export or a named export called `register`, with the signature
140
143
  `(app, api)`.
141
144
 
142
145
  ```js
143
- // routes/10-pages.mjs
146
+ // features/home/index.js
144
147
  export default function register(app, { route }) {
145
148
  app.get(
146
149
  "/",
@@ -148,7 +151,7 @@ export default function register(app, { route }) {
148
151
  async () => ({
149
152
  view: "pages/home",
150
153
  metadata: { title: "Home" },
151
- data: { heading: "JSkelet is running", items: ["One", "Two"] },
154
+ data: { message: "JSkelet is running" },
152
155
  }),
153
156
  { revalidate: 60 },
154
157
  ),
@@ -163,25 +166,26 @@ HTML cache, the notFound/redirect control flow, compression and the
163
166
  `X-JSkelet-Cache` header all come from it. The controller's only job is to
164
167
  return a page definition.
165
168
 
166
- The `10-` prefix in the file name determines the load order. Because `routes/`
167
- is scanned alphabetically, you should put catch-all routes such as `/:slug` in a
168
- file with a higher number; otherwise `/about` will be mistaken for a slug.
169
- Details: [03-routing.md](./03-routing.md).
169
+ If you use `routes/`, the `10-` prefix in the file name determines load order;
170
+ put catch-alls such as `/:slug` in a higher-numbered file. Feature `index.js`
171
+ files are appended alphabetically after the `routes/` scan. Details:
172
+ [03-routing.md](./03-routing.md).
170
173
 
171
- The template side is plain EJS:
174
+ The template side is `.jsk` (compiled at build time):
172
175
 
173
- ```ejs
174
- <%# views/pages/home.ejs %>
176
+ ```html
177
+ {# features/home/views/pages/home.jsk #}
175
178
  <section class="wrapper">
176
- <h1 class="text-3xl font-bold"><%= heading %></h1>
177
- <%- list({ items }) %>
178
- <div data-island="counter" data-island-props='{"start":5}'></div>
179
+ <h1>{{ metadata.title }}</h1>
180
+ <p>{{ message }}</p>
181
+ <Button text="Example component" />
182
+ <div data-island="counter" data-island-props='{"start":0}'></div>
179
183
  </section>
180
184
  ```
181
185
 
182
- Here `list` is a function defined in `views/components/list.js` and it has not
183
- been imported: every named export under `views/components/**` automatically
184
- becomes a template local ([04-rendering.md](./04-rendering.md)).
186
+ `Button` comes from the `button` named export in
187
+ `features/home/views/components/button.js` — PascalCase tag, no import
188
+ ([04-rendering.md](./04-rendering.md)).
185
189
 
186
190
  ## Your first island
187
191
 
@@ -199,7 +203,7 @@ carried as JSON inside `data-island-props`.
199
203
  `mount(element, props)`.
200
204
 
201
205
  ```js
202
- // client/islands/counter.js
206
+ // features/home/client/counter.js
203
207
  /**
204
208
  * @param {HTMLElement} element
205
209
  * @param {{ start?: number }} props
@@ -231,7 +235,7 @@ import and starts the runtime.
231
235
  import { registerAll, start } from "jskelet/client";
232
236
 
233
237
  registerAll({
234
- counter: () => import("../islands/counter.js"),
238
+ counter: () => import("../../features/home/client/counter.js"),
235
239
  });
236
240
 
237
241
  start();
@@ -245,7 +249,7 @@ runtime API are in [05-islands.md](./05-islands.md).
245
249
 
246
250
  ## CLI commands
247
251
 
248
- `bin/jskelet.mjs` offers four subcommands. Each runs in a separate Node process;
252
+ `bin/jskelet.mjs` offers these subcommands. Each runs in a separate Node process;
249
253
  the reason is that `dev` manages two long-lived processes and the server needs
250
254
  ESM resolve hooks (`--import`) at process start.
251
255
 
@@ -254,7 +258,8 @@ ESM resolve hooks (`--import`) at process start.
254
258
  | `jskelet dev` | Build watch + server, in a single terminal. Live reload, CSS hot-swap, dev overlay. `NODE_ENV=development`. |
255
259
  | `jskelet build` | One-shot prod build: fonts → icon sprite → CSS → client JS → images → manifest → precompress. `production` if `NODE_ENV` is not given. |
256
260
  | `jskelet start` | Prod server. If there is no build output it produces it first. `production` if `NODE_ENV` is not given. |
257
- | `jskelet init` | Installs a minimal skeleton into the current directory; leaves existing files alone. |
261
+ | `jskelet init` | Installs a feature-first `.jsk` skeleton into the current directory; leaves existing files alone. |
262
+ | `jskelet generate` | Scaffolds a `feature` / `page` / `island`. |
258
263
 
259
264
  An unknown command, or a call with no arguments, prints the usage text.
260
265
 
@@ -71,8 +71,34 @@ controller data → imported render(data, helpers) → HTML
71
71
  | Built-ins | `Link`, `Image`, `Icon`, `CsrfField`, `PreloadImage` |
72
72
 
73
73
  The expression language is intentionally small (access, compare, ternary,
74
- `.length`). No assignments or arbitrary calls — keep logic in controllers or JS
75
- components.
74
+ `.length`). No assignments, object literals, or arbitrary calls — keep logic in
75
+ controllers or JS components.
76
+
77
+ #### Template or component?
78
+
79
+ When moving off EJS, draw the line early:
80
+
81
+ | Stay in `.jsk` | Move to a JS component |
82
+ | --- | --- |
83
+ | Text, conditionals, lists, prop binding | Function calls, object construction, formatting |
84
+ | Built-in tags (`Link`, `Image`, …) | Composing HTML from several helpers |
85
+ | Ready-made data from the controller | Upstream / error-aware UI (`LoadErrorState`) |
86
+
87
+ If the template cannot write `format(x)` or `{ a: 1 }`, that is intentional: the
88
+ work belongs in `views/components/*.js` or the controller. Prefer a clear
89
+ component boundary over widening the expression language when complex pages
90
+ “escape” into JS.
91
+
92
+ ### Editor support
93
+
94
+ `extensions/vscode-jsk` is a VS Code / Cursor extension in this repo: syntax
95
+ highlighting, language configuration, and snippets. Local install:
96
+
97
+ ```bash
98
+ code --install-extension extensions/vscode-jsk
99
+ ```
100
+
101
+ See the extension README for details.
76
102
 
77
103
  ### Coexistence with EJS
78
104
 
@@ -244,14 +270,20 @@ Rules:
244
270
 
245
271
  - The scan is recursive; subdirectories are covered too.
246
272
  - `default` exports are ignored — only named exports are registered.
273
+ - The compile-time known-component set is read from **named exports in the
274
+ source**, not from the file basename. `sectionHead` in `ui.js` →
275
+ `<SectionHead />` in the template (runtime already adds a PascalCase alias
276
+ for camelCase exports). You do not need a stub re-export named after the
277
+ file.
247
278
  - `loader.js` and `index.js` do not count as component files.
248
279
  - If `views/components/index.js` exists it is loaded first as a **barrel**,
249
280
  with the lowest priority. Its only purpose is to turn `lib/` re-exports into
250
281
  template locals; the components' own files come later and silently overwrite
251
282
  it.
252
- - If the same name is defined in two different component files a warning is
253
- printed and **the second one wins**: `[components] 'card' is defined twice:
254
- a.js and b.js — the second one wins.`
283
+ - If the same name (or the same PascalCase tag) is defined in two different
284
+ component files, that is an **error, not a warning**: build and server
285
+ startup stop with `Component 'card' is defined twice: …`. Overwriting the
286
+ barrel is the deliberate exception.
255
287
  - If the `views/components` directory does not exist the component registry
256
288
  stays empty; a project that uses no components works fine too.
257
289
 
@@ -390,6 +390,68 @@ export async function apiGet(path) {
390
390
  }
391
391
  ```
392
392
 
393
+ ### Loader contract: empty list ≠ error
394
+
395
+ Swallowing an upstream failure with `catch → []` (or `null`) looks the same as
396
+ a wrong mapping: empty UI. Even when rate limits are logged correctly, the
397
+ visitor sees “no data”. Widget loaders should separate the result instead of
398
+ burying a silent `[]`:
399
+
400
+ ```js
401
+ /**
402
+ * @returns {Promise<{ items: object[], error: Error | null }>}
403
+ */
404
+ export async function loadTickerItems() {
405
+ try {
406
+ const items = await apiGet("/ticker");
407
+ if (!items) {
408
+ return { items: [], error: new Error("Upstream returned no data") };
409
+ }
410
+ return { items, error: null };
411
+ } catch (error) {
412
+ return {
413
+ items: [],
414
+ error: error instanceof Error ? error : new Error(String(error)),
415
+ };
416
+ }
417
+ }
418
+ ```
419
+
420
+ An app-level shared `LoadErrorState` (or equivalent) should render that `error`
421
+ field so each widget does not fall back to its own empty state:
422
+
423
+ ```js
424
+ // views/components/load-error-state.js
425
+ import { esc } from "jskelet/html";
426
+
427
+ /**
428
+ * @param {{ message?: string, title?: string }} props
429
+ * @returns {string}
430
+ */
431
+ export function LoadErrorState({ message, title = "Could not load data" }) {
432
+ return `<div role="alert" data-load-error class="…">
433
+ <p>${esc(title)}</p>
434
+ ${message ? `<p>${esc(message)}</p>` : ""}
435
+ </div>`;
436
+ }
437
+ ```
438
+
439
+ ```html
440
+ {#if error}
441
+ <LoadErrorState :message="error.message" />
442
+ {#else if items.length}
443
+ {#each items as item}
444
+ …
445
+ {/each}
446
+ {#else}
447
+ <p>No records</p>
448
+ {/if}
449
+ ```
450
+
451
+ The framework does not ship brand-specific UI; `LoadErrorState` is an
452
+ application component. What matters is the contract: `{ items, error }` (or
453
+ equivalent) and separate template branches for failure vs truly empty.
454
+
393
455
  ### Distinguishing transient and permanent failures
394
456
 
395
457
  | State | Counts as | Result |
@@ -778,77 +840,77 @@ Two more diagnostic surfaces:
778
840
 
779
841
  The full list of settings: [07-configuration.md](./07-configuration.md).
780
842
 
781
- ## The admin panel
782
-
783
- Instead of hand-writing the `getHtmlCacheEntries()` / `getRedisStatus()`
784
- endpoints above, the framework ships a panel. It is deliberately separate from
785
- the dev overlay: the overlay only exists while `NODE_ENV=development`, while the
786
- panel does not look at the environment — "why is this page stale", "did the
787
- webhook purge land", "is Redis actually connected" are production questions.
788
-
789
- The panel is enabled with top-level `admin()` (not inside `cache()`) at
790
- `/_jskelet/admin`, with Overview, Cache, Routes, Views, Logs and System pages.
791
- The Cache page carries the same operations as the former single-page panel.
792
-
793
- ```js
794
- // jskelet.config.mjs
795
- export default {
796
- admin() {
797
- return {
798
- enabled: process.env.JSKELET_ADMIN === "1",
799
- allowIps: ["10.0.0.0/8"], // empty = no IP restriction
800
- blockBots: true,
801
- };
802
- },
803
- cache() {
804
- return {
805
- html: { "/news/:slug": 300 },
806
- };
807
- },
808
- };
809
- ```
810
-
811
- Without `enabled` **nothing is mounted**: the path does not exist, the module is
812
- never loaded and it costs the production process nothing. The environment
813
- variable (`JSKELET_ADMIN=1`) overrides the config, because the panel is
814
- usually opened once during an incident and editing the config file and
815
- redeploying is the last thing you want at that moment.
816
-
817
- When the panel is on, the server log prints the password in an `ADMIN` box at
818
- `http://localhost:3000/_jskelet/admin`.
819
-
820
- ### Access and hardening
821
-
822
- - **The password is regenerated on every process start** (32 hex characters) and
823
- only ever appears in the log. There is no persistent secret to leak.
824
- - **The password is not accepted in the query string.**
825
- - **`allowIps`** (exact IP or CIDR), when set, returns `404` for every request
826
- outside the list — including the login page.
827
- - **`blockBots`** (default `true`) rejects known crawler UAs with `404`.
828
- - **Three failed attempts ban the IP for 24 hours** (`banAttempts`, `banHours`).
829
- - **Banned and unauthorised requests get a `404`.**
830
- - **Nothing is indexable:** `X-Robots-Tag`, `Cache-Control: no-store`,
831
- `Referrer-Policy: no-referrer`; exempt from prewarming and navigation speculation.
832
- - Actions require an `X-JSkelet-Admin` header — the panel's own CSRF brake.
833
- - Sessions and ban counters live in process memory.
834
-
835
- ### What the panel shows
836
-
837
- | Area | Contents |
838
- | --- | --- |
839
- | Overview | HTML/data/Redis/prewarm cards and upstream limiter summary |
840
- | Cache | Shared tier, Cloudflare, actions, entry list (former panel) |
841
- | Routes | Express path/method inventory, route modules, last-request summary |
842
- | Views | Template inventory under `views/` |
843
- | Logs | Live SSE queue with method/status/cache/kind/path and text filters |
844
- | System | Host RAM / disk |
845
-
846
- The list is **filtered by key** and the filter runs on the server: a data cache
847
- can hold tens of thousands of keys. At most 500 rows come back per request and
848
- the counter in the heading says how many matches were cut. HTML bodies and
849
- cached values are **never returned** — the panel's job is to show state, not to
850
- export content.
851
-
843
+ ## The admin panel
844
+
845
+ Instead of hand-writing the `getHtmlCacheEntries()` / `getRedisStatus()`
846
+ endpoints above, the framework ships a panel. It is deliberately separate from
847
+ the dev overlay: the overlay only exists while `NODE_ENV=development`, while the
848
+ panel does not look at the environment — "why is this page stale", "did the
849
+ webhook purge land", "is Redis actually connected" are production questions.
850
+
851
+ The panel is enabled with top-level `admin()` (not inside `cache()`) at
852
+ `/_jskelet/admin`, with Overview, Cache, Routes, Views, Logs and System pages.
853
+ The Cache page carries the same operations as the former single-page panel.
854
+
855
+ ```js
856
+ // jskelet.config.mjs
857
+ export default {
858
+ admin() {
859
+ return {
860
+ enabled: process.env.JSKELET_ADMIN === "1",
861
+ allowIps: ["10.0.0.0/8"], // empty = no IP restriction
862
+ blockBots: true,
863
+ };
864
+ },
865
+ cache() {
866
+ return {
867
+ html: { "/news/:slug": 300 },
868
+ };
869
+ },
870
+ };
871
+ ```
872
+
873
+ Without `enabled` **nothing is mounted**: the path does not exist, the module is
874
+ never loaded and it costs the production process nothing. The environment
875
+ variable (`JSKELET_ADMIN=1`) overrides the config, because the panel is
876
+ usually opened once during an incident and editing the config file and
877
+ redeploying is the last thing you want at that moment.
878
+
879
+ When the panel is on, the server log prints the password in an `ADMIN` box at
880
+ `http://localhost:3000/_jskelet/admin`.
881
+
882
+ ### Access and hardening
883
+
884
+ - **The password is regenerated on every process start** (32 hex characters) and
885
+ only ever appears in the log. There is no persistent secret to leak.
886
+ - **The password is not accepted in the query string.**
887
+ - **`allowIps`** (exact IP or CIDR), when set, returns `404` for every request
888
+ outside the list — including the login page.
889
+ - **`blockBots`** (default `true`) rejects known crawler UAs with `404`.
890
+ - **Three failed attempts ban the IP for 24 hours** (`banAttempts`, `banHours`).
891
+ - **Banned and unauthorised requests get a `404`.**
892
+ - **Nothing is indexable:** `X-Robots-Tag`, `Cache-Control: no-store`,
893
+ `Referrer-Policy: no-referrer`; exempt from prewarming and navigation speculation.
894
+ - Actions require an `X-JSkelet-Admin` header — the panel's own CSRF brake.
895
+ - Sessions and ban counters live in process memory.
896
+
897
+ ### What the panel shows
898
+
899
+ | Area | Contents |
900
+ | --- | --- |
901
+ | Overview | HTML/data/Redis/prewarm cards and upstream limiter summary |
902
+ | Cache | Shared tier, Cloudflare, actions, entry list (former panel) |
903
+ | Routes | Express path/method inventory, route modules, last-request summary |
904
+ | Views | Template inventory under `views/` |
905
+ | Logs | Live SSE queue with method/status/cache/kind/path and text filters |
906
+ | System | Host RAM / disk |
907
+
908
+ The list is **filtered by key** and the filter runs on the server: a data cache
909
+ can hold tens of thousands of keys. At most 500 rows come back per request and
910
+ the counter in the heading says how many matches were cut. HTML bodies and
911
+ cached values are **never returned** — the panel's job is to show state, not to
912
+ export content.
913
+
852
914
  ### What you can do from it
853
915
 
854
916
  | Action | Equivalent call |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "A framework that feels like no framework: Express 5 + build-time .jsk (or EJS) SSR, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,5 +11,7 @@ export {
11
11
  discoverJskFiles,
12
12
  componentNameFromViewId,
13
13
  collectKnownComponents,
14
+ toComponentTag,
14
15
  } from "./resolve.js";
16
+ export { scanNamedExports } from "./scan-exports.js";
15
17
  export { compileAll, compileSource, ensureTemplatesCompiled } from "./compile-all.js";