jskelet 0.5.4 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -10,416 +10,291 @@ one is listed under a **Breaking** heading.
10
10
 
11
11
  ### Added
12
12
 
13
- - Shared cross-subdomain cookies: `brand.sharedCookieRoots` plus
14
- `writeSharedCookie` / `clearSharedCookie` on server (`jskelet/cookies`) and
15
- client (`jskelet/client`). `Secure` follows https / `x-forwarded-proto` (not
16
- `NODE_ENV`); the client read-back fails into handoff when the browser rejects
17
- `Domain`. Optional `auth.crossSubdomainHandoff` mounts
18
- `POST /_jskelet/auth/handoff` (one-time ticket → `?handoff=`) and documents a
19
- `window.name` bridge. Large tokens are refused — put a short session id in the
20
- cookie, not a JWT.
21
- - HTML cache key vary (`cache().vary`): `host: true` adds the public Host
22
- (`x-forwarded-host` or `Host`, lowercase, no port) as `h=…|` before the path;
23
- optional `headers` and `fn(req)` add further segments. Required on host-based
24
- locale sites so one locale's HTML is not served on another. Classic prewarm
25
- accepts `prewarm.origins` for multi-host warming when vary is on.
13
+ - Local flat `icons/` directory as the exclusive SVG sprite source when present
14
+ (`icons.dir`, default `"icons"`): `house.svg` / `house-bold.svg` file names,
15
+ XOR with `@phosphor-icons/core` (Phosphor only when the directory is absent).
16
+ The hashed sprite still lands under `public/assets/` and is precompressed.
17
+
18
+ ### Fixed
19
+
20
+ - Open Graph routes with a `.png` suffix (`/og/…/:slug.png`) now escape the
21
+ dot for Express 5 / path-to-regexp, so the handler matches again instead of
22
+ falling through to the HTML 404.
26
23
 
27
24
  ### Changed
28
25
 
29
- - Marketing example visual language: darker ink canvas, solid cyan primary
30
- CTAs, cyan-only glow/grid (indigo accents removed), and a measured trust
31
- bar on the homepage (payload gzip, Node, license, zero web fonts) instead
32
- of the marquee.
26
+ - Marketing example copy (EN/TR) reflects 0.5.x cache surfaces: host `vary`,
27
+ early refresh, classic vs `onVisit` prewarm, local `icons/`, shared cookies,
28
+ and `opengraph-image` → `ogHandler` on the migrate table. Pages now serve
29
+ per-locale dynamic OG cards at `/og/:locale/:page.png`.
30
+ - Marketing changelog page restyled like a release-notes browser: measured
31
+ summary cards, search and newest/oldest sort, paginated open release cards
32
+ with Latest / Released badges and GitHub links (still driven by `CHANGELOG.md`).
33
+
34
+ ## [0.5.4] - 2026-09-18
33
35
 
34
36
  ### Added
35
37
 
36
- - Dynamic Open Graph images (Next.js `ImageResponse` / `opengraph-image`):
37
- `ogImage`, `sendOgImage`, `ogHandler`, and `ImageResponse` turn card fields or
38
- raw SVG into PNG when `sharp` is installed (SVG fallback otherwise). Wired in
39
- `examples/blog` as `/og/blog/:slug.png` and `metadata.openGraph.image`.
40
- - Early HTML cache refresh before TTL expiry: the last successful produce time
41
- (`produceMs`) sets a lead window (`min(max(produceMs×2, 250ms), ttl/2)`). A
42
- still-fresh `HIT` in that window revalidates in the background; idle entries
43
- are soft-staled by a sweeper and drained over HTTP even without classic
44
- `prewarmPaths` (`PREWARM=0` disables both). In-flight refreshes no longer drop
45
- the entry when `staleUntil` elapses.
46
- - Route-level stylesheets: put files in `styles/pages/*.css` and load them from
47
- the controller with `styles: ["home.css"]` (same contract as island
48
- `entries`). The layout emits them after global `app.css`; dev hot-swaps any
49
- changed `.css` manifest key without a full reload.
50
- - Dev overlay Errors tab now lists failed SSR and browser `fetch` calls with
51
- page path, API URL, optional island name, and expandable response-body
52
- details (JSON instead of `[object Object]`). Server `console.error` /
53
- `console.warn` records also carry the current page when they fire during
54
- render.
55
- - Visit-driven HTML prewarm (`cache().prewarm.onVisit`): after each public
56
- cacheable page response, same-origin links in the HTML are warmed in the
57
- background (document order, `perPage` cap). Mutually exclusive with classic
58
- prewarm (`max` / `priority` / `rotate` / `hooks.prewarmPaths`, etc.) — mixing
59
- them fails at config load.
60
- - Runtime remote image optimizer: set `images.remote.allowHosts` to proxy
61
- allowlisted http(s) images through `/_jskelet/image?url=&w=&q=` as resized
62
- webp (disk cache under `.jskelet/image-cache/`). `image()` rewrites matching
63
- remote `src` values automatically; `remoteImageUrl()` builds URLs by hand.
64
- Requires `sharp` at runtime; without it the endpoint 302-redirects to the
65
- source.
38
+ - Shared cross-subdomain cookies: `brand.sharedCookieRoots` plus `writeSharedCookie` / `clearSharedCookie` on server (`jskelet/cookies`) and client (`jskelet/client`). `Secure` follows https / `x-forwarded-proto` (not `NODE_ENV`); the client read-back fails into handoff when the browser rejects `Domain`. Optional `auth.crossSubdomainHandoff` mounts `POST /_jskelet/auth/handoff` (one-time ticket → `?handoff=`) and documents a `window.name` bridge. Large tokens are refused — put a short session id in the cookie, not a JWT.
66
39
 
67
- ### Breaking
40
+ ## [0.5.3] - 2026-09-18
41
+
42
+ ### Added
43
+
44
+ - HTML cache key vary (`cache().vary`): `host: true` adds the public Host (`x-forwarded-host` or `Host`, lowercase, no port) as `h=…|` before the path; optional `headers` and `fn(req)` add further segments. Required on host-based locale sites so one locale's HTML is not served on another. Classic prewarm accepts `prewarm.origins` for multi-host warming when vary is on.
45
+
46
+ ### Changed
47
+
48
+ - Marketing example visual language: darker ink canvas, solid cyan primary CTAs, cyan-only glow/grid (indigo accents removed), and a measured trust bar on the homepage (payload gzip, Node, license, zero web fonts) instead of the marquee.
49
+
50
+ ## [0.5.2] - 2026-09-18
51
+
52
+ ### Added
53
+
54
+ - Dynamic Open Graph images (Next.js `ImageResponse` / `opengraph-image`): `ogImage`, `sendOgImage`, `ogHandler`, and `ImageResponse` turn card fields or raw SVG into PNG when `sharp` is installed (SVG fallback otherwise). Wired in `examples/blog` as `/og/blog/:slug.png` and `metadata.openGraph.image`.
55
+
56
+ ## [0.5.1] - 2026-09-07
57
+
58
+ ### Added
59
+
60
+ - Early HTML cache refresh before TTL expiry: the last successful produce time (`produceMs`) sets a lead window (`min(max(produceMs×2, 250ms), ttl/2)`). A still-fresh `HIT` in that window revalidates in the background; idle entries are soft-staled by a sweeper and drained over HTTP even without classic `prewarmPaths` (`PREWARM=0` disables both). In-flight refreshes no longer drop the entry when `staleUntil` elapses.
61
+
62
+ ## [0.5.0] - 2026-09-03
63
+
64
+ ### Added
65
+
66
+ - Route-level stylesheets: put files in `styles/pages/*.css` and load them from the controller with `styles: ["home.css"]` (same contract as island `entries`). The layout emits them after global `app.css`; dev hot-swaps any changed `.css` manifest key without a full reload.
67
+
68
+ ## [0.4.8] - 2026-09-03
69
+
70
+ ### Added
71
+
72
+ - Dev overlay Errors tab now lists failed SSR and browser `fetch` calls with page path, API URL, optional island name, and expandable response-body details (JSON instead of `[object Object]`). Server `console.error` / `console.warn` records also carry the current page when they fire during render.
73
+
74
+ ## [0.4.7] - 2026-09-03
75
+
76
+ ### Added
77
+
78
+ - Visit-driven HTML prewarm (`cache().prewarm.onVisit`): after each public cacheable page response, same-origin links in the HTML are warmed in the background (document order, `perPage` cap). Mutually exclusive with classic prewarm (`max` / `priority` / `rotate` / `hooks.prewarmPaths`, etc.) — mixing them fails at config load.
79
+
80
+ ## [0.4.6] - 2026-09-03
81
+
82
+ ### Fixed
83
+
84
+ - Remote image optimizer cache hits no longer 404. Disk cache lives under `.jskelet/image-cache/`; Express `sendFile` ignores dotfiles by default, so the file was written but the response still failed. `sendCached` now passes `dotfiles: "allow"`.
85
+
86
+ ## [0.4.5] - 2026-09-03
87
+
88
+ ### Added
89
+
90
+ - Runtime remote image optimizer: set `images.remote.allowHosts` to proxy allowlisted http(s) images through `/_jskelet/image?url=&w=&q=` as resized webp (disk cache under `.jskelet/image-cache/`). `image()` rewrites matching remote `src` values automatically; `remoteImageUrl()` builds URLs by hand. Requires `sharp` at runtime; without it the endpoint 302-redirects to the source.
91
+
92
+ ## [0.4.4] - 2026-09-02
93
+
94
+ ### Added
68
95
 
69
- - The cache admin panel moved to a top-level `admin()` config section at
70
- `/_jskelet/admin` (was `cache().panel` at `/_jskelet/cache`). Enable with
71
- `admin() { return { enabled: true } }` or `JSKELET_ADMIN=1`. `JSKELET_CACHE_PANEL` and
72
- `cache().panel` are removed. Auth is unchanged (per-process password in the
73
- server log, cookie session, 404 for strangers); the action CSRF header is now
74
- `X-JSkelet-Admin`.
96
+ - Devtools SEO check: an **SEO** tab in the overlay lists document, heading, image, link and social-tag issues with error/warning severity. Optional page highlights draw red or yellow boxes around the offending elements; the label shows the short title and a click opens the full explanation. Served only in development as `/__jskelet/dev/seo.js` beside the overlay.
97
+
98
+ ### Changed
99
+
100
+ - Marketing compare live latency demo now measures two same-sized fragments (cached vs `no-store`) with an explicit 80 ms simulated upstream inside the shared producer — a hit skips that wait so the gap is visible even when RTT dominates the wall clock. The island prints transferred bytes, Server-Timing `produce` duration, and a View Source section contrasts `__NEXT_DATA__` payload tax with plain JSkelet HTML. The measured-weight block also shows an estimated Next.js App Router first-load breakdown beside this site’s real gzip totals (clearly labelled estimate, not a build from this repo).
75
101
 
76
102
  ### Fixed
77
103
 
78
- - Remote image optimizer cache hits no longer 404. Disk cache lives under
79
- `.jskelet/image-cache/`; Express `sendFile` ignores dotfiles by default, so
80
- the file was written but the response still failed. `sendCached` now passes
81
- `dotfiles: "allow"`.
82
- - Cloudflare analytics in the cache panel no longer asks for an open-ended
83
- window. Queries used only `datetime_geq`, so Cloudflare closed the range at
84
- query time and a default 24h lookback became `1d` plus network delay — Free
85
- zones reject anything wider than one day. Both ends are now pinned from the
86
- same clock (`datetime_leq` included).
87
- - Missing `/assets/*` responses no longer keep the long-lived `immutable`
88
- Cache-Control that `headersMiddleware` stamps for static prefixes. A deploy
89
- race (prune-before-write) could 404 a hashed CSS URL for a moment; a CDN then
90
- cached that HTML 404 for a year and browsers refused it as a stylesheet
91
- (`MIME type 'text/html'`). Catch-all and `notFound` handlers now set
92
- `Cache-Control: no-store`. CSS and sprite builds write the new file before
93
- pruning older hashes so the same content hash never has a gap.
104
+ - Missing `/assets/*` responses no longer keep the long-lived `immutable` Cache-Control that `headersMiddleware` stamps for static prefixes. A deploy race (prune-before-write) could 404 a hashed CSS URL for a moment; a CDN then cached that HTML 404 for a year and browsers refused it as a stylesheet (`MIME type 'text/html'`). Catch-all and `notFound` handlers now set `Cache-Control: no-store`. CSS and sprite builds write the new file before pruning older hashes so the same content hash never has a gap.
105
+
106
+ ## [0.4.3] - 2026-09-02
94
107
 
95
108
  ### Added
96
109
 
97
- - Devtools SEO check: an **SEO** tab in the overlay lists document, heading,
98
- image, link and social-tag issues with error/warning severity. Optional page
99
- highlights draw red or yellow boxes around the offending elements; the label
100
- shows the short title and a click opens the full explanation. Served only in
101
- development as `/__jskelet/dev/seo.js` beside the overlay.
102
- - Marketing homepage ops storyboard: Redis L2, `/_jskelet/admin` panel mock and
103
- Cloudflare purge flow, with tabbed visual scenes animated by the vanilla
104
- `motion` API (Framer Motion’s non-React package) via an `ops-story` island.
105
- - VS Code / Cursor extension skeleton under `extensions/vscode-jsk`: `.jsk`
106
- language id, TextMate highlighting (`{{ }}` / `{#if}` / `{#each}` /
107
- components), language config, and snippets. Install from that folder or
108
- launch **JSK: Extension** from the repo root. Bound attrs on HTML tags
109
- (`:src="… + '/path'"`) highlight nested single-quoted strings.
110
- - Compile-time known components are discovered from **named exports** in
111
- `views/components/**/*.js` (plus `.jsk` component files), not from the file
112
- basename — so `<SectionHead />` resolves when `sectionHead` lives in
113
- `ui.js` without a stub re-export. Docs cover the `.jsk` template-vs-component
114
- boundary and a `{ items, error }` loader / `LoadErrorState` pattern so
115
- upstream failures are not mistaken for empty data.
116
- - Build-time `.jsk` templates: declarative HTML-like syntax compiled to ESM
117
- render modules under `.jskelet/templates/` (no request-time parse, `eval`, or
118
- `new Function`). Coexists with EJS; compiled `.jsk` wins when both exist.
119
- Syntax: `{{ }}` / `{{{ }}}`, `{#if}` / `{#each}` / `{#include}`, PascalCase
120
- components (`:prop` bindings), built-ins `Link` / `Image` / `Icon` /
121
- `CsrfField` / `PreloadImage`.
122
- - Feature-first conventions: `paths.features` / `paths.shared`, multi-root
123
- views and components, `features/<name>/index.js` route registration after
124
- `routes/`. CLI: `jskelet generate feature|page|island`. `jskelet init`
125
- scaffolds a feature-first `.jsk` skeleton (`features/home/` with route,
126
- page, component and island; global `views/pages/not-found.jsk`).
127
- - Template compile step in `jskelet build`; icon scan and Tailwind docs cover
128
- `.jsk` / `features` / `shared`. Bench: `node scripts/bench-templates.mjs`.
129
- - Top-level `logs` config for persistent sinks: daily NDJSON files
130
- (`logs.file`) and batched S3 PutObject (`logs.s3`) with embedded SigV4 — no
131
- `@aws-sdk` dependency. `kinds` selects `http` / `event` / `error`; `console`
132
- toggles runtime stdout lines. `JSKELET_LOG_BUCKET` / `JSKELET_S3_BUCKET`
133
- (+ optional `JSKELET_S3_KEY_PREFIX`) may be a plain bucket or a
134
- `bucket/prefix/…` path; with credentials present the sink turns on without
135
- `enabled: true`. `JSKELET_S3_API_URL` sets the S3-compatible endpoint
136
- (region defaults to `auto`). Env: `JSKELET_LOG_BUCKET`, `JSKELET_S3_*`.
137
- - Admin panel pages under `/_jskelet/admin`: Overview, Cache, Routes, Views,
138
- Logs and System. Configurable `allowIps` (exact or CIDR), `blockBots` (default
139
- on — crawler UAs get 404 before login), and `logSize`. Live Logs use an
140
- in-process ring plus SSE (`/api/logs/stream`) with client-side filters for
141
- method, status, cache, kind, path/route and text. Routes and Views are
142
- read-only inventories; HTTP finish middleware records timings only while the
143
- panel is enabled.
144
- - `trailingSlash` in `jskelet.config.mjs` (default `false`). When `true`,
145
- canonical page URLs end with `/` and return 200; a request without the slash
146
- is sent to the slashed form with a 308 (not 301). File URLs and
147
- `/.well-known/**` are left alone. When `false`, no slash is enforced — unlike
148
- Next.js, the default does not strip trailing slashes.
149
- - `cache().query`, a pattern → allowlist mapping that decides which query
150
- parameters belong to the HTML cache key. An allowlist caches one entry per
151
- distinct value of the listed parameters and ignores the rest, so every
152
- `?utm_source=…` variant of a path shares one copy; `true` puts the whole query
153
- in the key and `[]` ignores it entirely. Parameters enter the key sorted, so
154
- `?a=1&b=2` and `?b=2&a=1` are one entry.
155
- - A cache admin panel surface (now under `admin()` — see Breaking) that lists
156
- what the in-process tier holds (key, size, status, remaining TTL, dependency
157
- count, precompressed bodies for HTML; key and TTL for data), reports whether
158
- the Redis tier is connected or bypassed, and runs the operations you would
159
- otherwise hand-write an admin route for: targeted invalidation with an
160
- optional hard mode, dropping a single entry, clearing either cache, unlinking
161
- the shared keys and triggering a prewarm pass. Unlike the dev overlay it does
162
- not look at `NODE_ENV`, because "why is this page stale" is a production
163
- question — but nothing is mounted until it is explicitly enabled, so the path
164
- does not exist by default. Access is a 32-character password regenerated on
165
- every process start and printed once to the server log; there is no persistent
166
- secret to leak and a deploy revokes old access on its own. The password is
167
- never accepted in a query string, three failed attempts ban the IP for 24
168
- hours, and every banned or unauthorised response is a `404` rather than a 401
169
- that would confirm the panel exists. The panel is excluded from indexing,
170
- prewarming and navigation speculation.
171
- - A language picker in the cache panel header, Turkish and English. The first
172
- visit follows the browser's language, the choice is kept in `localStorage` and
173
- carries over to the login page, and switching costs no request. To keep this
174
- from leaking UI concerns into the server, an `/action` response now returns
175
- `{ ok, code, params }` instead of an English sentence and the panel builds the
176
- text — the framework's log and API stay in one language while the panel
177
- speaks two.
178
- - Cloudflare cache management, from the panel and from code. Set
179
- `JSKELET_CLOUDFLARE_KEY` and `JSKELET_CLOUDFLARE_ZONE_ID` (or
180
- `cache().cloudflare`) and the panel gains the CDN tier next to the origin one:
181
- purge everything, purge every URL currently held in memory with one button or
182
- a single row with `cf purge`, purge by prefix, host or cache tag, toggle
183
- development mode, cache level, browser cache TTL, query string sorting,
184
- Always Online, Tiered Cache, Regional Tiered Cache and Cache Reserve, clear
185
- Cache Reserve, and read the cache hit ratio. This matters because
186
- `invalidateHtmlCache()` refreshes the origin while the copy your visitors get
187
- keeps being served from the edge until its TTL expires. The same surface is
188
- exported as `purgeCloudflare()`, `toCloudflareUrls()`,
189
- `fetchCloudflareOverview()`, `fetchCacheAnalytics()`, `fetchPathEdges()` and
190
- `getCloudflareStatus()`; none of them throw, so a CDN outage returns
191
- `{ ok: false, error }` instead of breaking a publish flow. Long purge lists
192
- are batched at Cloudflare's 100-keys-per-request limit and sent sequentially
193
- to stay inside the rate limit. The token is read from the environment, is
194
- never returned in a response, and only cache related zone settings can be
195
- changed.
196
- - An edge breakdown for a single path: `fetchPathEdges()` reports which
197
- Cloudflare colos served it from cache and which went to the origin. This is
198
- observation, not inventory — Cloudflare has no endpoint that lists which
199
- edges currently hold a URL, and no way to warm an edge you pick, so the panel
200
- says as much rather than implying otherwise.
201
- - `getRedisDetails()` reports where the shared tier actually points — address,
202
- TLS, database, namespace, which kinds are shared and whether the purge channel
203
- is subscribed — because "connected" alone does not explain a Redis that shares
204
- nothing because of a wrong namespace. The password is never part of the
205
- output. `inspectRedis()` counts the keys per kind plus `DBSIZE` and
206
- `used_memory`; it runs a `SCAN`, so the panel keeps it behind its own button
207
- instead of the refresh loop. When Redis is off, the panel explains what a
208
- shared tier would buy and shows the memory and disk state of the host instead,
209
- which is the number that decides whether `maxEntries` is too high.
210
- - `dropHtmlCacheKey()` and `dropDataCacheKey()` drop one exact cache key.
211
- `invalidateHtmlCache()` matches a path pattern and takes down every query
212
- variant of a path, which is the right default for a webhook but wrong when you
213
- want `/list?page=2` gone and `/list?page=3` left hot.
110
+ - Marketing homepage ops storyboard: Redis L2, `/_jskelet/admin` panel mock and Cloudflare purge flow, with tabbed visual scenes animated by the vanilla `motion` API (Framer Motion’s non-React package) via an `ops-story` island.
111
+
112
+ ## [0.4.2] - 2026-09-02
214
113
 
215
114
  ### Changed
216
115
 
217
- - README rewritten for the current surface: build-time `.jsk` as the default
218
- template story (EJS still supported), feature-first `init` examples, `mount`
219
- island contract, path-based `invalidateHtmlCache` (replacing the outdated
220
- “no targeted invalidation” claim), Redis / admin / data-cache callouts, and
221
- bilingual doc links under `docs/` and `docs/en/`.
222
- - Marketing compare live latency demo now measures two same-sized fragments
223
- (cached vs `no-store`) with an explicit 80 ms simulated upstream inside the
224
- shared producer — a hit skips that wait so the gap is visible even when RTT
225
- dominates the wall clock. The island prints transferred bytes, Server-Timing
226
- `produce` duration, and a View Source section contrasts `__NEXT_DATA__`
227
- payload tax with plain JSkelet HTML. The measured-weight block also shows an
228
- estimated Next.js App Router first-load breakdown beside this site’s real
229
- gzip totals (clearly labelled estimate, not a build from this repo).
230
- - Duplicate component named exports (or the same PascalCase tag in two files)
231
- now **fail** at build and at server startup instead of warning and letting
232
- the second definition win. Overwriting `components/index.js` barrel exports
233
- remains allowed.
234
- - `examples/minimal` pages moved to `.jsk`; adds `features/demo` as a
235
- co-located route + view sample.
236
- - Marketing compare/FAQ copy no longer claims targeted invalidation is missing;
237
- it points at `invalidateHtmlCache()` (and Redis pub/sub for multi-instance).
238
-
239
- - An adaptive per-host rate limit for upstream calls, `cache().upstream`. It sits
240
- in the `fetch` wrapper rather than in the prewarm pass, because what spends the
241
- quota is the API call, not the page: one render may make one call or twenty, so
242
- `prewarm.rps` could never bound the real thing. A token bucket caps the average
243
- rate, a concurrency limit caps the calls in flight, and `rate` is treated as a
244
- ceiling that the limiter pulls down on its own — a 429 or 503 halves the rate,
245
- `Retry-After` stops the bucket for exactly as long as the upstream asked, and
246
- clean windows climb back one step at a time. A host that returns
247
- `breakerFailures` rate limits in a row is bypassed for `breakerCooldownMs`,
248
- which stops the worst waste: because a 429 counts as transient, the HTML
249
- produced by a throttled call is never stored, so a pass in that state spends
250
- quota and keeps nothing. Only 429 and 503 penalise the rate; a 400 or 500 is
251
- not a quota problem. Off by default — set `rate` to turn it on.
252
- - `getUpstreamLimiterStatus()` reports the current rate, calls in flight, 429
253
- count and breaker state per host. The dev panel's Server tab shows the same.
254
- - `getDataCacheStats()` counts how the data cache was used: fresh hits, stale
255
- hits, misses, coalesced concurrent reads, values promoted from the shared tier
256
- and — the only number that reaches the quota — real producer runs. A prewarm
257
- pass now prints its own share of that (`12 upstream calls for 430 data reads
258
- (97% from the data cache)`), which is what tells you whether the fix is a
259
- longer TTL or a rate limit. The dev report has a Data cache card for it.
260
-
261
- - The dev overlay header now shows the installed JSkelet version next to the
262
- title, labelled `latest` when it matches npm and `outdated` with the newer
263
- version when it does not, so you can tell at a glance which version the
264
- project runs without opening the Server tab.
265
- - An optional Redis tier behind both caches, turned on with
266
- `cache().redis: { enabled: true, url }` and `npm install ioredis`. The
267
- in-process cache stays primary and every request still reads it; Redis only
268
- does the two things a single process cannot. An instance that has never seen a
269
- path finds the HTML another replica already produced, so a fresh container or a
270
- post-deploy replacement does not re-render and re-fetch everything from
271
- scratch. And `invalidateHtmlCache()`, `clearHtmlCache()` and
272
- `clearDataCache()` now reach every replica over pub/sub instead of only the one
273
- that received the webhook — until now the others waited out the TTL and a
274
- visitor saw old or new content depending on where they landed. Keys live under
275
- `_jskelet:{namespace}:{buildId}:…`, where the build id makes HTML from a
276
- previous deploy expire on its own rather than pointing at asset files that no
277
- longer exist. Personalised (`storable: false`), degraded and non-200 responses
278
- are never shared. If `ioredis` is missing, Redis is unreachable or it goes down
279
- mid-flight, a warning is printed and the site keeps serving from memory.
280
- - `getRedisStatus()` reports whether the shared tier is connected, which key
281
- prefix and build id it is using, and how many command failures there have
282
- been — usable from a healthcheck endpoint. The same summary appears in the dev
283
- panel report.
284
- - Servers started with `startServer()` now shut down on `SIGTERM`/`SIGINT`
285
- instead of being killed: the listener is closed and the Redis connection is
286
- drained so in-flight writes are not cut mid-command.
287
- - Targeted HTML invalidation: `invalidateHtmlCache(target, { hard })` takes a
288
- path, the config pattern syntax (`/news/:slug`), a regular expression or a list
289
- of them, and returns how many entries were affected. By default it **stales**
290
- the entries rather than deleting them, so a webhook that touches hundreds of
291
- pages does not turn into hundreds of cold renders at the worst possible moment:
292
- visitors keep getting the old HTML while the refresh runs in the background,
293
- once per key. Matching is done against the path, so every query variant of a
294
- page is covered by one call, and a render already in flight when the purge
295
- arrives is not stored.
296
- - `clearDataCache()` now refreshes the HTML too. The `withDataCache` keys read
297
- during a render are recorded, so dropping `news:abc` stales every page that
298
- actually read it — the article, the home page listing it and the tag page —
299
- without the application declaring any tags. Turn it off with
300
- `cache().trackDependencies: false`; `getHtmlCacheEntries()` reports the
301
- dependency count per page as `deps`.
302
- - Invalidated paths go to the front of the next prewarm pass, so an updated page
303
- is refreshed without waiting for a visitor, while still respecting the `rps`
304
- limit. The pass summary counts them separately.
305
- - An upstream data cache: `withDataCache(key, ttlSeconds, producer)` and the
306
- `dataCache(fn, { key, revalidate })` wrapper, with `clearDataCache(prefix?)`,
307
- `getDataCacheSize()` and `getDataCacheEntries()` alongside them. It keeps JSON
308
- rather than HTML, so its default limit is 10,000 entries: a long-tail page that
309
- was never prewarmed still renders without touching the API. Concurrent reads of
310
- the same key collapse into one upstream request, an expired entry is served
311
- immediately while it refreshes in the background, a failing producer falls back
312
- to the stale value, and empty answers (`null`/`undefined`) are not stored
313
- unless `storeEmpty: true` is passed.
314
- - `cache().prewarm.priority` decides the warm-up order and accepts both the
315
- config pattern syntax (`/news/:slug`) and plain regular expressions. Matching
316
- paths are warmed on every pass.
317
- - Drip prewarming for large sites: `cache().prewarm.rps` (also `PREWARM_RPS`)
318
- caps requests per second regardless of parallelism, and `rotate` (on by
319
- default) makes periodic passes continue through the queue where the previous
320
- one stopped instead of re-warming the same first slice. A pass is skipped while
321
- the previous one is still running.
322
- - `cache().prewarm.retryDelayMs` (also `PREWARM_RETRY_DELAY_MS`) waits before the
323
- retry pass, since rate limit windows are measured in seconds.
324
- - `cache().maxEntries` configures the HTML cache limit, which used to be a fixed 500.
325
- - Transient upstream failures are now detected without any application code:
326
- `globalThis.fetch` is wrapped during startup and `429`, `5xx` and network
327
- errors raised inside a render are reported on their own, so rate limits stop
328
- turning existing pages into 404s even when the data layer never calls
329
- `reportUpstreamFailure()`. Requests outside a render and requests to the
330
- server itself are ignored, deterministic answers such as `404` are not
331
- reported, and the wrapper can be turned off with `cache().trackUpstream:
332
- false`.
333
- - `cache().transientRetry` (`{ attempts: 1, delayMs: 300 }` by default) retries a
334
- page that called `notFound()` while upstream was failing. Each attempt runs in
335
- a fresh upstream and per-request cache scope, so a page whose data arrives on
336
- the second try is served and cached as usual instead of degrading to an error.
337
- - The dev report now includes the data cache entry count under `cache.data`.
116
+ - README rewritten for the current surface: build-time `.jsk` as the default template story (EJS still supported), feature-first `init` examples, `mount` island contract, path-based `invalidateHtmlCache` (replacing the outdated “no targeted invalidation” claim), Redis / admin / data-cache callouts, and bilingual doc links under `docs/` and `docs/en/`.
117
+
118
+ ## [0.4.1] - 2026-09-02
119
+
120
+ ### Added
121
+
122
+ - VS Code / Cursor extension skeleton under `extensions/vscode-jsk`: `.jsk` language id, TextMate highlighting (`{{ }}` / `{#if}` / `{#each}` / components), language config, and snippets. Install from that folder or launch **JSK: Extension** from the repo root. Bound attrs on HTML tags (`:src="… + '/path'"`) highlight nested single-quoted strings.
123
+ - Compile-time known components are discovered from **named exports** in `views/components/**/*.js` (plus `.jsk` component files), not from the file basename — so `<SectionHead />` resolves when `sectionHead` lives in `ui.js` without a stub re-export. Docs cover the `.jsk` template-vs-component boundary and a `{ items, error }` loader / `LoadErrorState` pattern so upstream failures are not mistaken for empty data.
124
+
125
+ ### Changed
126
+
127
+ - Duplicate component named exports (or the same PascalCase tag in two files) now **fail** at build and at server startup instead of warning and letting the second definition win. Overwriting `components/index.js` barrel exports remains allowed.
128
+ - Marketing compare/FAQ copy no longer claims targeted invalidation is missing; it points at `invalidateHtmlCache()` (and Redis pub/sub for multi-instance).
129
+
130
+ ## [0.4.0] - 2026-09-02
131
+
132
+ ### Added
133
+
134
+ - Build-time `.jsk` templates: declarative HTML-like syntax compiled to ESM render modules under `.jskelet/templates/` (no request-time parse, `eval`, or `new Function`). Coexists with EJS; compiled `.jsk` wins when both exist. Syntax: `{{ }}` / `{{{ }}}`, `{#if}` / `{#each}` / `{#include}`, PascalCase components (`:prop` bindings), built-ins `Link` / `Image` / `Icon` / `CsrfField` / `PreloadImage`.
135
+ - Feature-first conventions: `paths.features` / `paths.shared`, multi-root views and components, `features/<name>/index.js` route registration after `routes/`. CLI: `jskelet generate feature|page|island`. `jskelet init` scaffolds a feature-first `.jsk` skeleton (`features/home/` with route, page, component and island; global `views/pages/not-found.jsk`).
136
+ - Template compile step in `jskelet build`; icon scan and Tailwind docs cover `.jsk` / `features` / `shared`. Bench: `node scripts/bench-templates.mjs`.
137
+
138
+ ### Changed
139
+
140
+ - `examples/minimal` pages moved to `.jsk`; adds `features/demo` as a co-located route + view sample.
141
+
142
+ ## [0.3.5] - 2026-09-02
143
+
144
+ ### Changed
145
+
146
+ - S3 logging configuration refactored; docs clarified.
147
+
148
+ ## [0.3.4] - 2026-09-02
149
+
150
+ ### Changed
151
+
152
+ - S3 logging pipeline now prints connection details at boot.
153
+
154
+ ## [0.3.3] - 2026-09-02
155
+
156
+ ### Changed
157
+
158
+ - Internal packaging / release bump.
159
+
160
+ ## [0.3.2] - 2026-09-02
161
+
162
+ ### Added
163
+
164
+ - Top-level `logs` config for persistent sinks: daily NDJSON files (`logs.file`) and batched S3 PutObject (`logs.s3`) with embedded SigV4 — no `@aws-sdk` dependency. `kinds` selects `http` / `event` / `error`; `console` toggles runtime stdout lines. `JSKELET_LOG_BUCKET` (and `logs.s3.bucket`) may be a plain bucket or a `bucket/prefix/…` path. `JSKELET_S3_API_URL` sets the R2/MinIO endpoint (region defaults to `auto`). Missing credentials warn and disable the S3 sink without taking the site down. Env: `JSKELET_LOG_BUCKET`, `JSKELET_S3_ACCESS_KEY_ID`, `JSKELET_S3_SECRET_ACCESS_KEY`, `JSKELET_S3_SESSION_TOKEN`, `JSKELET_S3_REGION`, `JSKELET_S3_API_URL`.
165
+
166
+ ## [0.3.1] - 2026-09-02
167
+
168
+ ### Added
169
+
170
+ - Admin panel pages under `/_jskelet/admin`: Overview, Cache, Routes, Views, Logs and System. Configurable `allowIps` (exact or CIDR), `blockBots` (default on — crawler UAs get 404 before login), and `logSize`. Live Logs use an in-process ring plus SSE (`/api/logs/stream`) with client-side filters for method, status, cache, kind, path/route and text. Routes and Views are read-only inventories; HTTP finish middleware records timings only while the panel is enabled.
171
+ - `trailingSlash` in `jskelet.config.mjs` (default `false`). When `true`, canonical page URLs end with `/` and return 200; a request without the slash is sent to the slashed form with a 308 (not 301). File URLs and `/.well-known/**` are left alone. When `false`, no slash is enforced — unlike Next.js, the default does not strip trailing slashes.
172
+ - A cache admin panel surface (now under `admin()` — see Breaking) that lists what the in-process tier holds (key, size, status, remaining TTL, dependency count, precompressed bodies for HTML; key and TTL for data), reports whether the Redis tier is connected or bypassed, and runs the operations you would otherwise hand-write an admin route for: targeted invalidation with an optional hard mode, dropping a single entry, clearing either cache, unlinking the shared keys and triggering a prewarm pass. Unlike the dev overlay it does not look at `NODE_ENV`, because "why is this page stale" is a production question — but nothing is mounted until it is explicitly enabled, so the path does not exist by default. Access is a 32-character password regenerated on every process start and printed once to the server log; there is no persistent secret to leak and a deploy revokes old access on its own. The password is never accepted in a query string, three failed attempts ban the IP for 24 hours, and every banned or unauthorised response is a `404` rather than a 401 that would confirm the panel exists. The panel is excluded from indexing, prewarming and navigation speculation.
338
173
 
339
174
  ### Changed
340
175
 
341
- - Admin panel System meters (CPU, memory, disk) show this process's share of
342
- the host — RSS and project disk footprint against machine totals, plus
343
- process CPU across all cores — instead of whole-machine fullness. The panel
344
- content width is wider (`1600px`) so Overview, Routes, Views and System use
345
- the screen better.
346
- - The release history page in `examples/marketing` now shows one release at a
347
- time: the newest one is expanded and older releases collapse to a single
348
- header row with their date, status and change count. Every release used to be
349
- printed open in a two-column grid, which made the page an unreadable wall as
350
- soon as a few versions piled up. Version links and the quick-jump strip still
351
- work, and they open the collapsed release they point at.
352
- - The prewarm retry pass no longer retries permanent failures. A `400`, `403` or
353
- `404` does not get better on the second try, so those paths are dropped from
354
- the retry round and counted as `N not retried (permanent)` in the summary. The
355
- wait before the round now also honours the upstream rate limit: if a
356
- `Retry-After` or an open circuit breaker is holding calls back, the pass waits
357
- that out instead of retrying into the same 429.
358
- - Errors and warnings raised during a prewarm pass are no longer logged one per
359
- page. Request errors and the per-page render warnings (`was produced with
360
- missing data`, `returned notFound() while upstream is failing`, `could not be
361
- produced`) are counted while the pass runs and printed as a single summary
362
- block afterwards, grouped by message with the most frequent kinds first, so a
363
- failing upstream can no longer bury the "warmed N/M pages" line under hundreds
364
- of near-identical lines. Real traffic logs as before, and the dev tools panel
365
- still shows the per-path detail.
366
- - The marketing example's changelog page is now a timeline: releases are laid out
367
- along a rail with a sticky version column, each change group gets its own card
368
- with a coloured rule and item count, and a row of version chips at the top
369
- jumps straight to a release.
370
- - The dev tools panel is now fed over a WebSocket (`<devBasePath>/ws`) instead of
371
- polling `/stats` every two seconds. The server pushes statistics as they change
372
- and sends live reload and CSS hot-swap events over the same connection, so an
373
- open tab no longer keeps hitting the server while the panel is closed. No new
374
- dependency is involved; if the socket cannot be opened, the panel falls back to
375
- the previous SSE plus polling path.
376
- - The server now binds to `::` instead of `0.0.0.0` when no `HOST` is given, so a
377
- single dual-stack socket answers both IPv6 and IPv4. Browsers resolve
378
- `localhost` to `::1` first and, unlike ordinary requests, a WebSocket handshake
379
- does not fall back to IPv4 — which made the dev panel's live channel fail on an
380
- IPv4-only socket. Where IPv6 is unavailable the bind falls back to `0.0.0.0`.
381
- - Prewarming no longer holds up the rest of the dev server. In development it now
382
- runs with a single worker and a default limit of 4 requests per second
383
- (`prewarm.rps` / `PREWARM_RPS` still override it), so page requests and the dev
384
- panel stay responsive while a warm-up round is going on. Production behaviour
385
- is unchanged.
386
- - `notFound()` is no longer served as a 404 when a transient upstream failure
387
- (`429`, `5xx`, network error) happened during the same render. The page is
388
- retried first and, if upstream is still failing, responds with an uncached
389
- `503` and `Retry-After`. A temporary rate limit is no longer frozen into "this
390
- page does not exist" for the whole TTL. A retry that gets a clean answer saying
391
- the page is gone still returns a normal 404.
392
- - Responses produced with missing data are no longer offered to shared caches:
393
- a `degraded` render is sent with `private, no-store` instead of
394
- `public, s-maxage=…`. The `X-JSkelet-Cache` diagnostic header is still written.
395
- - The prewarm summary distinguishes paths left for the next pass
396
- (`700 deferred to the next pass`) from paths dropped entirely
397
- (`700 over the limit`).
398
- - The changelog page of the marketing example is generated from the project's
399
- `CHANGELOG.md` instead of a hand-written list, and shows the version published
400
- on npm next to the installed one.
401
- - The marketing example reads its markdown (documentation and changelog) from
402
- the repository over GitHub's raw endpoint, falling back to the installed
403
- package when the network is unavailable, so a deployment that ships without
404
- `node_modules` can still serve the docs. In development the local file wins
405
- and nothing is cached. The branch is overridable with `DOCS_REF`.
176
+ - Admin panel System meters (CPU, memory, disk) show this process's share of the host — RSS and project disk footprint against machine totals, plus process CPU across all cores — instead of whole-machine fullness. The panel content width is wider (`1600px`) so Overview, Routes, Views and System use the screen better.
177
+
178
+ ### Breaking
179
+
180
+ - The cache admin panel moved to a top-level `admin()` config section at `/_jskelet/admin` (was `cache().panel` at `/_jskelet/cache`). Enable with `admin() { return { enabled: true } }` or `JSKELET_ADMIN=1`. `JSKELET_CACHE_PANEL` and `cache().panel` are removed. Auth is unchanged (per-process password in the server log, cookie session, 404 for strangers); the action CSRF header is now `X-JSkelet-Admin`.
181
+
182
+ ## [0.2.5] - 2026-09-01
406
183
 
407
184
  ### Fixed
408
185
 
409
- - The dev panel's WebSocket handshake was answered with a `Sec-WebSocket-Accept`
410
- value derived from a mistyped protocol constant. Browsers verify that value and
411
- closed the connection immediately with "Incorrect 'Sec-WebSocket-Accept' header
412
- value", so the panel silently fell back to polling.
186
+ - Cloudflare analytics in the cache panel no longer asks for an open-ended window. Queries used only `datetime_geq`, so Cloudflare closed the range at query time and a default 24h lookback became `1d` plus network delay — Free zones reject anything wider than one day. Both ends are now pinned from the same clock (`datetime_leq` included).
187
+
188
+ ## [0.2.4] - 2026-08-31
189
+
190
+ ### Added
191
+
192
+ - A language picker in the cache panel header, Turkish and English. The first visit follows the browser's language, the choice is kept in `localStorage` and carries over to the login page, and switching costs no request. To keep this from leaking UI concerns into the server, an `/action` response now returns `{ ok, code, params }` instead of an English sentence and the panel builds the text — the framework's log and API stay in one language while the panel speaks two.
193
+
194
+ ## [0.2.3] - 2026-08-31
195
+
196
+ ### Added
197
+
198
+ - `cache().query`, a pattern → allowlist mapping that decides which query parameters belong to the HTML cache key. An allowlist caches one entry per distinct value of the listed parameters and ignores the rest, so every `?utm_source=…` variant of a path shares one copy; `true` puts the whole query in the key and `[]` ignores it entirely. Parameters enter the key sorted, so `?a=1&b=2` and `?b=2&a=1` are one entry.
199
+ - Cloudflare cache management, from the panel and from code. Set `JSKELET_CLOUDFLARE_KEY` and `JSKELET_CLOUDFLARE_ZONE_ID` (or `cache().cloudflare`) and the panel gains the CDN tier next to the origin one: purge everything, purge every URL currently held in memory with one button or a single row with `cf purge`, purge by prefix, host or cache tag, toggle development mode, cache level, browser cache TTL, query string sorting, Always Online, Tiered Cache, Regional Tiered Cache and Cache Reserve, clear Cache Reserve, and read the cache hit ratio. This matters because `invalidateHtmlCache()` refreshes the origin while the copy your visitors get keeps being served from the edge until its TTL expires. The same surface is exported as `purgeCloudflare()`, `toCloudflareUrls()`, `fetchCloudflareOverview()`, `fetchCacheAnalytics()`, `fetchPathEdges()` and `getCloudflareStatus()`; none of them throw, so a CDN outage returns `{ ok: false, error }` instead of breaking a publish flow. Long purge lists are batched at Cloudflare's 100-keys-per-request limit and sent sequentially to stay inside the rate limit. The token is read from the environment, is never returned in a response, and only cache related zone settings can be changed.
200
+ - An edge breakdown for a single path: `fetchPathEdges()` reports which Cloudflare colos served it from cache and which went to the origin. This is observation, not inventory — Cloudflare has no endpoint that lists which edges currently hold a URL, and no way to warm an edge you pick, so the panel says as much rather than implying otherwise.
201
+ - `getRedisDetails()` reports where the shared tier actually points — address, TLS, database, namespace, which kinds are shared and whether the purge channel is subscribed — because "connected" alone does not explain a Redis that shares nothing because of a wrong namespace. The password is never part of the output. `inspectRedis()` counts the keys per kind plus `DBSIZE` and `used_memory`; it runs a `SCAN`, so the panel keeps it behind its own button instead of the refresh loop. When Redis is off, the panel explains what a shared tier would buy and shows the memory and disk state of the host instead, which is the number that decides whether `maxEntries` is too high.
202
+
203
+ ### Changed
204
+
205
+ - The release history page in `examples/marketing` now shows one release at a time: the newest one is expanded and older releases collapse to a single header row with their date, status and change count. Every release used to be printed open in a two-column grid, which made the page an unreadable wall as soon as a few versions piled up. Version links and the quick-jump strip still work, and they open the collapsed release they point at.
413
206
 
414
207
  ### Breaking
415
208
 
416
- - A request that carries a query parameter is now dynamic by default: it is not
417
- written to the HTML cache and the response is sent with `private, no-store`,
418
- even when a `cache().html` pattern covers the path. Every query variant used
419
- to become its own cache entry, which let campaign parameters
420
- (`?utm_source=…`) mint unbounded keys and evict real pages from a 500-entry
421
- store. Pages whose output genuinely depends on the query keep their cache by
422
- listing the relevant parameters under `cache().query`.
209
+ - A request that carries a query parameter is now dynamic by default: it is not written to the HTML cache and the response is sent with `private, no-store`, even when a `cache().html` pattern covers the path. Every query variant used to become its own cache entry, which let campaign parameters (`?utm_source=…`) mint unbounded keys and evict real pages from a 500-entry store. Pages whose output genuinely depends on the query keep their cache by listing the relevant parameters under `cache().query`.
210
+
211
+ ## [0.2.2] - 2026-08-31
212
+
213
+ ### Added
214
+
215
+ - A cache admin panel at `/_jskelet/cache`, turned on with `cache().panel: { enabled: true }` or `JSKELET_CACHE_PANEL=1`. It lists what the in-process tier holds (key, size, status, remaining TTL, dependency count, precompressed bodies for HTML; key and TTL for data), reports whether the Redis tier is connected or bypassed, and runs the operations you would otherwise hand-write an admin route for: targeted invalidation with an optional hard mode, dropping a single entry, clearing either cache, unlinking the shared keys and triggering a prewarm pass. Unlike the dev overlay it does not look at `NODE_ENV`, because "why is this page stale" is a production question — but nothing is mounted until it is explicitly enabled, so the path does not exist by default. Access is a 32-character password regenerated on every process start and printed once to the server log; there is no persistent secret to leak and a deploy revokes old access on its own. The password is never accepted in a query string, three failed attempts ban the IP for 24 hours, and every banned or unauthorised response is a `404` rather than a 401 that would confirm the panel exists. The panel is excluded from indexing, prewarming and navigation speculation.
216
+ - `dropHtmlCacheKey()` and `dropDataCacheKey()` drop one exact cache key. `invalidateHtmlCache()` matches a path pattern and takes down every query variant of a path, which is the right default for a webhook but wrong when you want `/list?page=2` gone and `/list?page=3` left hot.
217
+ - An adaptive per-host rate limit for upstream calls, `cache().upstream`. It sits in the `fetch` wrapper rather than in the prewarm pass, because what spends the quota is the API call, not the page: one render may make one call or twenty, so `prewarm.rps` could never bound the real thing. A token bucket caps the average rate, a concurrency limit caps the calls in flight, and `rate` is treated as a ceiling that the limiter pulls down on its own — a 429 or 503 halves the rate, `Retry-After` stops the bucket for exactly as long as the upstream asked, and clean windows climb back one step at a time. A host that returns `breakerFailures` rate limits in a row is bypassed for `breakerCooldownMs`, which stops the worst waste: because a 429 counts as transient, the HTML produced by a throttled call is never stored, so a pass in that state spends quota and keeps nothing. Only 429 and 503 penalise the rate; a 400 or 500 is not a quota problem. Off by default — set `rate` to turn it on.
218
+ - `getUpstreamLimiterStatus()` reports the current rate, calls in flight, 429 count and breaker state per host. The dev panel's Server tab shows the same.
219
+ - `getDataCacheStats()` counts how the data cache was used: fresh hits, stale hits, misses, coalesced concurrent reads, values promoted from the shared tier and — the only number that reaches the quota — real producer runs. A prewarm pass now prints its own share of that (`12 upstream calls for 430 data reads (97% from the data cache)`), which is what tells you whether the fix is a longer TTL or a rate limit. The dev report has a Data cache card for it.
220
+ - The dev overlay header now shows the installed JSkelet version next to the title, labelled `latest` when it matches npm and `outdated` with the newer version when it does not, so you can tell at a glance which version the project runs without opening the Server tab.
221
+
222
+ ### Changed
223
+
224
+ - The prewarm retry pass no longer retries permanent failures. A `400`, `403` or `404` does not get better on the second try, so those paths are dropped from the retry round and counted as `N not retried (permanent)` in the summary. The wait before the round now also honours the upstream rate limit: if a `Retry-After` or an open circuit breaker is holding calls back, the pass waits that out instead of retrying into the same 429.
225
+
226
+ ## [0.2.1] - 2026-08-31
227
+
228
+ ### Changed
229
+
230
+ - Errors and warnings raised during a prewarm pass are no longer logged one per page. Request errors and the per-page render warnings (`was produced with missing data`, `returned notFound() while upstream is failing`, `could not be produced`) are counted while the pass runs and printed as a single summary block afterwards, grouped by message with the most frequent kinds first, so a failing upstream can no longer bury the "warmed N/M pages" line under hundreds of near-identical lines. Real traffic logs as before, and the dev tools panel still shows the per-path detail.
231
+
232
+ ## [0.2.0] - 2026-08-31
233
+
234
+ ### Added
235
+
236
+ - An optional Redis tier behind both caches, turned on with `cache().redis: { enabled: true, url }` and `npm install ioredis`. The in-process cache stays primary and every request still reads it; Redis only does the two things a single process cannot. An instance that has never seen a path finds the HTML another replica already produced, so a fresh container or a post-deploy replacement does not re-render and re-fetch everything from scratch. And `invalidateHtmlCache()`, `clearHtmlCache()` and `clearDataCache()` now reach every replica over pub/sub instead of only the one that received the webhook — until now the others waited out the TTL and a visitor saw old or new content depending on where they landed. Keys live under `_jskelet:{namespace}:{buildId}:…`, where the build id makes HTML from a previous deploy expire on its own rather than pointing at asset files that no longer exist. Personalised (`storable: false`), degraded and non-200 responses are never shared. If `ioredis` is missing, Redis is unreachable or it goes down mid-flight, a warning is printed and the site keeps serving from memory.
237
+ - `getRedisStatus()` reports whether the shared tier is connected, which key prefix and build id it is using, and how many command failures there have been — usable from a healthcheck endpoint. The same summary appears in the dev panel report.
238
+ - Servers started with `startServer()` now shut down on `SIGTERM`/`SIGINT` instead of being killed: the listener is closed and the Redis connection is drained so in-flight writes are not cut mid-command.
239
+
240
+ ### Changed
241
+
242
+ - Request errors raised during a prewarm pass are no longer logged one by one. They are counted while the pass runs and printed as a single summary line afterwards, grouped by status and message with the most frequent kinds first, so a flaky upstream can no longer bury the "warmed N/M pages" line under hundreds of stack traces. Errors from real traffic are logged as before, and the dev tools panel still shows the per-path detail.
243
+
244
+ ## [0.1.9] - 2026-08-31
245
+
246
+ ### Fixed
247
+
248
+ - The dev panel's WebSocket handshake was answered with a `Sec-WebSocket-Accept` value derived from a mistyped protocol constant. Browsers verify that value and closed the connection immediately with "Incorrect 'Sec-WebSocket-Accept' header value", so the panel silently fell back to polling.
249
+
250
+ ## [0.1.8] - 2026-08-31
251
+
252
+ ### Changed
253
+
254
+ - The marketing example's changelog page is now a timeline: releases are laid out along a rail with a sticky version column, each change group gets its own card with a coloured rule and item count, and a row of version chips at the top jumps straight to a release.
255
+ - The server now binds to `::` instead of `0.0.0.0` when no `HOST` is given, so a single dual-stack socket answers both IPv6 and IPv4. Browsers resolve `localhost` to `::1` first and, unlike ordinary requests, a WebSocket handshake does not fall back to IPv4 — which made the dev panel's live channel fail on an IPv4-only socket. Where IPv6 is unavailable the bind falls back to `0.0.0.0`.
256
+
257
+ ## [0.1.7] - 2026-08-31
258
+
259
+ ### Changed
260
+
261
+ - Devtools WebSocket connection handling hardened.
262
+
263
+ ## [0.1.6] - 2026-08-31
264
+
265
+ ### Added
266
+
267
+ - Targeted HTML invalidation: `invalidateHtmlCache(target, { hard })` takes a path, the config pattern syntax (`/news/:slug`), a regular expression or a list of them, and returns how many entries were affected. By default it **stales** the entries rather than deleting them, so a webhook that touches hundreds of pages does not turn into hundreds of cold renders at the worst possible moment: visitors keep getting the old HTML while the refresh runs in the background, once per key. Matching is done against the path, so every query variant of a page is covered by one call, and a render already in flight when the purge arrives is not stored.
268
+ - `clearDataCache()` now refreshes the HTML too. The `withDataCache` keys read during a render are recorded, so dropping `news:abc` stales every page that actually read it — the article, the home page listing it and the tag page — without the application declaring any tags. Turn it off with `cache().trackDependencies: false`; `getHtmlCacheEntries()` reports the dependency count per page as `deps`.
269
+ - Invalidated paths go to the front of the next prewarm pass, so an updated page is refreshed without waiting for a visitor, while still respecting the `rps` limit. The pass summary counts them separately.
270
+
271
+ ### Changed
272
+
273
+ - Prewarming no longer holds up the rest of the dev server. In development it now runs with a single worker and a default limit of 4 requests per second (`prewarm.rps` / `PREWARM_RPS` still override it), so page requests and the dev panel stay responsive while a warm-up round is going on. Production behaviour is unchanged.
274
+
275
+ ## [0.1.5] - 2026-08-30
276
+
277
+ ### Changed
278
+
279
+ - The dev tools panel is now fed over a WebSocket (`<devBasePath>/ws`) instead of polling `/stats` every two seconds. The server pushes statistics as they change and sends live reload and CSS hot-swap events over the same connection, so an open tab no longer keeps hitting the server while the panel is closed. No new dependency is involved; if the socket cannot be opened, the panel falls back to the previous SSE plus polling path.
280
+
281
+ ## [0.1.4] - 2026-08-30
282
+
283
+ ### Added
284
+
285
+ - Transient upstream failures are now detected without any application code: `globalThis.fetch` is wrapped during startup and `429`, `5xx` and network errors raised inside a render are reported on their own, so rate limits stop turning existing pages into 404s even when the data layer never calls `reportUpstreamFailure()`. Requests outside a render and requests to the server itself are ignored, deterministic answers such as `404` are not reported, and the wrapper can be turned off with `cache().trackUpstream: false`.
286
+ - `cache().transientRetry` (`{ attempts: 1, delayMs: 300 }` by default) retries a page that called `notFound()` while upstream was failing. Each attempt runs in a fresh upstream and per-request cache scope, so a page whose data arrives on the second try is served and cached as usual instead of degrading to an error.
287
+
288
+ ### Changed
289
+
290
+ - The changelog page of the marketing example is generated from the project's `CHANGELOG.md` instead of a hand-written list, and shows the version published on npm next to the installed one.
291
+ - The marketing example reads its markdown (documentation and changelog) from the repository over GitHub's raw endpoint, falling back to the installed package when the network is unavailable, so a deployment that ships without `node_modules` can still serve the docs. In development the local file wins and nothing is cached. The branch is overridable with `DOCS_REF`.
292
+
293
+ ## [0.1.3] - 2026-08-30
294
+
295
+ ### Changed
296
+
297
+ - Patch release; no user-facing changelog entries beyond 0.1.2.
423
298
 
424
299
  ## [0.1.2] - 2026-08-30
425
300
 
@@ -533,7 +408,39 @@ Initial release.
533
408
  - Documentation under `docs/` and three examples: `minimal`, `blog`,
534
409
  `marketing`.
535
410
 
536
- [Unreleased]: https://github.com/ayberkenis/jskelet/compare/v0.1.2...HEAD
411
+ [Unreleased]: https://github.com/ayberkenis/jskelet/compare/v0.5.4...HEAD
412
+ [0.5.4]: https://github.com/ayberkenis/jskelet/compare/v0.5.3...v0.5.4
413
+ [0.5.3]: https://github.com/ayberkenis/jskelet/compare/v0.5.2...v0.5.3
414
+ [0.5.2]: https://github.com/ayberkenis/jskelet/compare/v0.5.1...v0.5.2
415
+ [0.5.1]: https://github.com/ayberkenis/jskelet/compare/v0.5.0...v0.5.1
416
+ [0.5.0]: https://github.com/ayberkenis/jskelet/compare/v0.4.8...v0.5.0
417
+ [0.4.8]: https://github.com/ayberkenis/jskelet/compare/v0.4.7...v0.4.8
418
+ [0.4.7]: https://github.com/ayberkenis/jskelet/compare/v0.4.6...v0.4.7
419
+ [0.4.6]: https://github.com/ayberkenis/jskelet/compare/v0.4.5...v0.4.6
420
+ [0.4.5]: https://github.com/ayberkenis/jskelet/compare/v0.4.4...v0.4.5
421
+ [0.4.4]: https://github.com/ayberkenis/jskelet/compare/v0.4.3...v0.4.4
422
+ [0.4.3]: https://github.com/ayberkenis/jskelet/compare/v0.4.2...v0.4.3
423
+ [0.4.2]: https://github.com/ayberkenis/jskelet/compare/v0.4.1...v0.4.2
424
+ [0.4.1]: https://github.com/ayberkenis/jskelet/compare/v0.4.0...v0.4.1
425
+ [0.4.0]: https://github.com/ayberkenis/jskelet/compare/v0.3.5...v0.4.0
426
+ [0.3.5]: https://github.com/ayberkenis/jskelet/compare/v0.3.4...v0.3.5
427
+ [0.3.4]: https://github.com/ayberkenis/jskelet/compare/v0.3.3...v0.3.4
428
+ [0.3.3]: https://github.com/ayberkenis/jskelet/compare/v0.3.2...v0.3.3
429
+ [0.3.2]: https://github.com/ayberkenis/jskelet/compare/v0.3.1...v0.3.2
430
+ [0.3.1]: https://github.com/ayberkenis/jskelet/compare/v0.2.5...v0.3.1
431
+ [0.2.5]: https://github.com/ayberkenis/jskelet/compare/v0.2.4...v0.2.5
432
+ [0.2.4]: https://github.com/ayberkenis/jskelet/compare/v0.2.3...v0.2.4
433
+ [0.2.3]: https://github.com/ayberkenis/jskelet/compare/v0.2.2...v0.2.3
434
+ [0.2.2]: https://github.com/ayberkenis/jskelet/compare/v0.2.1...v0.2.2
435
+ [0.2.1]: https://github.com/ayberkenis/jskelet/compare/v0.2.0...v0.2.1
436
+ [0.2.0]: https://github.com/ayberkenis/jskelet/compare/v0.1.9...v0.2.0
437
+ [0.1.9]: https://github.com/ayberkenis/jskelet/compare/v0.1.8...v0.1.9
438
+ [0.1.8]: https://github.com/ayberkenis/jskelet/compare/v0.1.7...v0.1.8
439
+ [0.1.7]: https://github.com/ayberkenis/jskelet/compare/v0.1.6...v0.1.7
440
+ [0.1.6]: https://github.com/ayberkenis/jskelet/compare/v0.1.5...v0.1.6
441
+ [0.1.5]: https://github.com/ayberkenis/jskelet/compare/v0.1.4...v0.1.5
442
+ [0.1.4]: https://github.com/ayberkenis/jskelet/compare/v0.1.3...v0.1.4
443
+ [0.1.3]: https://github.com/ayberkenis/jskelet/compare/v0.1.2...v0.1.3
537
444
  [0.1.2]: https://github.com/ayberkenis/jskelet/compare/v0.1.1...v0.1.2
538
445
  [0.1.1]: https://github.com/ayberkenis/jskelet/compare/v0.1.0...v0.1.1
539
446
  [0.1.0]: https://github.com/ayberkenis/jskelet/releases/tag/v0.1.0
@@ -88,7 +88,7 @@ export default {
88
88
  watch: ["data"],
89
89
 
90
90
  fonts: [{ family: "Inter", weights: [400, 600, 700] }],
91
- icons: { scan: ["views", "client", "routes", "lib"] },
91
+ icons: { dir: "icons", scan: ["views", "client", "routes", "lib"] },
92
92
  images: { widths: [400, 800, 1200], quality: 78, skip: ["indirmeler"] },
93
93
  clientEnv: ["PUBLIC_WS_URL"],
94
94
 
@@ -487,21 +487,27 @@ fonts: [
487
487
 
488
488
  ## `icons`
489
489
 
490
- **Tip:** `{ scan?: string[] } | false` — **Varsayılan:** `{}`
490
+ **Tip:** `{ scan?: string[], dir?: string } | false` — **Varsayılan:** `{ dir: "icons" }`
491
491
 
492
- Phosphor SVG sprite üretimi.
492
+ SVG ikon sprite üretimi. Kaynak **XOR** seçilir: `icons.dir` dizini varsa
493
+ yalnızca oradaki düz SVG'ler; yoksa `@phosphor-icons/core` (kuruluysa).
493
494
 
494
495
  | Değer | Sonuç |
495
496
  | --- | --- |
496
- | `{}` (varsayılan) | Sprite üretilir; taranan dizinler `["views", "client", "routes", "lib", "features", "shared"]` |
497
+ | `{}` (varsayılan) | `dir: "icons"`; taranan dizinler `["views", "client", "routes", "lib", "features", "shared"]` |
498
+ | `{ dir: "assets/icons" }` | Yerel SVG kökü değiştirilir |
497
499
  | `{ scan: [...] }` | Taranan dizinler değiştirilir |
498
500
  | `false` | Sprite adımı tamamen atlanır |
499
501
 
500
- `@phosphor-icons/core` uygulamanın `node_modules`'ünde yoksa adım sessizce
501
- atlanır. Ayrıntı: [08-build.md](./08-build.md).
502
+ Yerel dizin (varsa) düz dosya adları kullanır: `house.svg` → `house:regular`,
503
+ `house-bold.svg` → `house:bold`. Boş bir `icons/` dizini Phosphor'a düşmez —
504
+ dizini silmek fallback'i açar. Ayrıntı: [08-build.md](./08-build.md).
502
505
 
503
506
  ```js
504
- icons: { scan: ["views", "client", "routes", "lib", "content"] }
507
+ icons: {
508
+ dir: "icons",
509
+ scan: ["views", "client", "routes", "lib", "content"],
510
+ }
505
511
  ```
506
512
 
507
513
  ## `images`
package/docs/08-build.md CHANGED
@@ -249,16 +249,32 @@ bu dosyalara otomatik olarak `immutable` cache yazılır.
249
249
 
250
250
  ## İkon sprite
251
251
 
252
- `@phosphor-icons/core` içindeki tek tek SVG'lerden, **yalnızca kaynakta
253
- kullanılan** ikonlar için `<symbol>` seti üretir. Tüm seti göndermek 1500+ ikon,
254
- yani birkaç megabayt; kullanım taraması sprite'ı tipik olarak 10-30 sembolde
255
- tutuyor.
252
+ **Yalnızca kaynakta kullanılan** ikonlar için bir `<symbol>` seti üretir. Tüm
253
+ seti göndermek 1500+ ikon, yani birkaç megabayt; kullanım taraması sprite'ı
254
+ tipik olarak 10-30 sembolde tutuyor. Çıktı hash'li `sprite.svg` olarak
255
+ `public/assets/` altına yazılır ve precompress kapsamına girer.
256
+
257
+ Kaynak **XOR** seçilir — ikisi birleştirilmez:
258
+
259
+ 1. `icons.dir` (varsayılan `icons/`) **dizin olarak varsa** yalnızca oradaki
260
+ düz SVG'ler. Boş dizin Phosphor'a düşmez; fallback için dizini silin.
261
+ 2. Aksi hâlde `@phosphor-icons/core` (uygulamanın `node_modules`'ünden). Kurulu
262
+ değilse adım sessizce atlanır.
263
+
264
+ Yerel dosya adları:
265
+
266
+ | Dosya | Sprite anahtarı |
267
+ | --- | --- |
268
+ | `icons/house.svg` | `house:regular` |
269
+ | `icons/house-regular.svg` | `house:regular` |
270
+ | `icons/arrow-right-bold.svg` | `arrow-right:bold` |
256
271
 
257
272
  - Sembol id'si: `<kebab-ad>-<weight>`, örn. `arrow-right-bold`.
258
- - Paket **uygulamanın** `node_modules`'ünden çözülür (ikon seti uygulamanın
259
- devDependency'si); kurulu değilse adım sessizce atlanır.
260
- - Taranan dizinler varsayılan olarak `views`, `client`, `routes`, `lib`;
261
- `icons.scan` ile değiştirilebilir. Taranan uzantılar: `.ejs`, `.js`, `.mjs`.
273
+ - `viewBox` kaynak SVG'den `<symbol>`'e taşınır; yoksa `0 0 256 256`
274
+ (Phosphor ve `icon()` ile uyum için önerilen kutu).
275
+ - Taranan dizinler varsayılan olarak `views`, `client`, `routes`, `lib`,
276
+ `features`, `shared`; `icons.scan` ile değiştirilebilir. Taranan uzantılar:
277
+ `.ejs`, `.jsk`, `.js`, `.mjs`.
262
278
  - Ağırlıklar: `thin`, `light`, `regular`, `bold`, `fill`, `duotone`. Tanınmayan
263
279
  bir ağırlık `regular` sayılır.
264
280
 
@@ -285,7 +301,7 @@ Bu uyarıyı görürseniz ya adı sabit yazın, ya `icons.scan` listesine ilgili
285
301
  dizini ekleyin, ya da adı bir yapılandırma alanında `icon: "XLogo"` biçiminde
286
302
  tutun.
287
303
 
288
- Phosphor'da bulunamayan adlar build sonunda özet olarak uyarılır:
304
+ Kaynakta bulunamayan adlar build sonunda özet olarak uyarılır:
289
305
  `N icons missing → …`
290
306
 
291
307
  ## Görsel optimizasyonu
@@ -355,7 +371,7 @@ Bu dosyaları `staticPrecompressed` middleware'i servis eder; kopya yoksa istek
355
371
  | `tailwindcss` | CSS (peer) | Tailwind direktifleri çözülemez |
356
372
  | `lightningcss` | CSS minifikasyonu | Tailwind çıktısı kullanılır, birkaç kB daha büyük |
357
373
  | `sharp` | Görsel optimizasyonu | Adım atlanır; `image()` orijinali kullanır |
358
- | `@phosphor-icons/core` | İkon sprite | Adım atlanır; `icon()` boş `<use>` üretir |
374
+ | `@phosphor-icons/core` | İkon sprite (yerel `icons/` yoksa) | Adım atlanır; `icon()` boş `<use>` üretir |
359
375
 
360
376
  CSS kullanmayacaksanız `paths.styles` dosyasını hiç oluşturmayın: adım uyarıyla
361
377
  atlanır ve postcss'e ihtiyaç kalmaz.
@@ -92,7 +92,7 @@ export default {
92
92
  watch: ["data"],
93
93
 
94
94
  fonts: [{ family: "Inter", weights: [400, 600, 700] }],
95
- icons: { scan: ["views", "client", "routes", "lib"] },
95
+ icons: { dir: "icons", scan: ["views", "client", "routes", "lib"] },
96
96
  images: { widths: [400, 800, 1200], quality: 78, skip: ["downloads"] },
97
97
  clientEnv: ["PUBLIC_WS_URL"],
98
98
 
@@ -500,21 +500,29 @@ fonts: [
500
500
 
501
501
  ## `icons`
502
502
 
503
- **Type:** `{ scan?: string[] } | false` — **Default:** `{}`
503
+ **Type:** `{ scan?: string[], dir?: string } | false` — **Default:** `{ dir: "icons" }`
504
504
 
505
- Phosphor SVG sprite generation.
505
+ SVG icon sprite generation. The source is chosen **XOR**: if the `icons.dir`
506
+ directory exists, only the flat SVGs there are used; otherwise
507
+ `@phosphor-icons/core` (when installed).
506
508
 
507
509
  | Value | Result |
508
510
  | --- | --- |
509
- | `{}` (default) | The sprite is generated; scanned directories are `["views", "client", "routes", "lib"]` |
511
+ | `{}` (default) | `dir: "icons"`; scanned directories are `["views", "client", "routes", "lib", "features", "shared"]` |
512
+ | `{ dir: "assets/icons" }` | Changes the local SVG root |
510
513
  | `{ scan: [...] }` | Changes the scanned directories |
511
514
  | `false` | The sprite step is skipped entirely |
512
515
 
513
- If `@phosphor-icons/core` is not in the application's `node_modules`, the step is
514
- silently skipped. Details: [08-build.md](./08-build.md).
516
+ A local directory (when present) uses flat file names: `house.svg` →
517
+ `house:regular`, `house-bold.svg` → `house:bold`. An empty `icons/` directory
518
+ does not fall back to Phosphor — delete the directory to open the fallback.
519
+ Details: [08-build.md](./08-build.md).
515
520
 
516
521
  ```js
517
- icons: { scan: ["views", "client", "routes", "lib", "content"] }
522
+ icons: {
523
+ dir: "icons",
524
+ scan: ["views", "client", "routes", "lib", "content"],
525
+ }
518
526
  ```
519
527
 
520
528
  ## `images`
@@ -259,17 +259,33 @@ Because the `.woff2` extension and the `/fonts/` prefix are in the default
259
259
 
260
260
  ## Icon sprite
261
261
 
262
- From the individual SVGs inside `@phosphor-icons/core`, it produces a `<symbol>`
263
- set for **only the icons actually used in the source**. Shipping the whole set
264
- means 1500+ icons, i.e. several megabytes; usage scanning typically keeps the
265
- sprite at 10-30 symbols.
262
+ Produces a `<symbol>` set for **only the icons actually used in the source**.
263
+ Shipping the whole set means 1500+ icons, i.e. several megabytes; usage scanning
264
+ typically keeps the sprite at 10-30 symbols. The hashed `sprite.svg` is written
265
+ under `public/assets/` and is covered by precompress.
266
+
267
+ The source is chosen **XOR** — the two are never merged:
268
+
269
+ 1. If `icons.dir` (default `icons/`) **exists as a directory**, only the flat
270
+ SVGs there. An empty directory does not fall back to Phosphor; delete the
271
+ directory to open the fallback.
272
+ 2. Otherwise `@phosphor-icons/core` (from the application's `node_modules`). If
273
+ it is not installed, the step is silently skipped.
274
+
275
+ Local file names:
276
+
277
+ | File | Sprite key |
278
+ | --- | --- |
279
+ | `icons/house.svg` | `house:regular` |
280
+ | `icons/house-regular.svg` | `house:regular` |
281
+ | `icons/arrow-right-bold.svg` | `arrow-right:bold` |
266
282
 
267
283
  - Symbol id: `<kebab-name>-<weight>`, e.g. `arrow-right-bold`.
268
- - The package is resolved from the **application's** `node_modules` (the icon set
269
- is the application's devDependency); if it is not installed, the step is
270
- silently skipped.
271
- - The scanned directories default to `views`, `client`, `routes`, `lib`; they can
272
- be changed with `icons.scan`. Scanned extensions: `.ejs`, `.js`, `.mjs`.
284
+ - `viewBox` is copied from the source SVG onto the `<symbol>`; if missing,
285
+ `0 0 256 256` (recommended for Phosphor / `icon()` compatibility).
286
+ - The scanned directories default to `views`, `client`, `routes`, `lib`,
287
+ `features`, `shared`; they can be changed with `icons.scan`. Scanned
288
+ extensions: `.ejs`, `.jsk`, `.js`, `.mjs`.
273
289
  - Weights: `thin`, `light`, `regular`, `bold`, `fill`, `duotone`. An
274
290
  unrecognised weight counts as `regular`.
275
291
 
@@ -296,8 +312,8 @@ If you see this warning, either write the name as a constant, or add the relevan
296
312
  directory to the `icons.scan` list, or keep the name in a configuration field in
297
313
  the form `icon: "XLogo"`.
298
314
 
299
- Names that cannot be found in Phosphor are warned about as a summary at the end
300
- of the build: `N icons missing → …`
315
+ Names that cannot be found in the chosen source are warned about as a summary at
316
+ the end of the build: `N icons missing → …`
301
317
 
302
318
  ## Image optimisation
303
319
 
@@ -373,7 +389,7 @@ copy, the request is handed over to `express.static`
373
389
  | `tailwindcss` | CSS (peer) | Tailwind directives cannot be resolved |
374
390
  | `lightningcss` | CSS minification | Tailwind's output is used, a few kB bigger |
375
391
  | `sharp` | Image optimisation | The step is skipped; `image()` uses the original |
376
- | `@phosphor-icons/core` | Icon sprite | The step is skipped; `icon()` produces an empty `<use>` |
392
+ | `@phosphor-icons/core` | Icon sprite (when no local `icons/` dir) | The step is skipped; `icon()` produces an empty `<use>` |
377
393
 
378
394
  If you are not going to use CSS, simply never create the `paths.styles` file: the
379
395
  step is skipped with a warning and postcss is not needed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
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",
@@ -1,12 +1,17 @@
1
1
  /**
2
- * Phosphor SVG sprite üretimi.
2
+ * SVG ikon sprite üretimi.
3
3
  *
4
- * `@phosphor-icons/core` içindeki tek tek SVG'lerden, **yalnızca kaynakta
5
- * kullanılan** ikonlar için `<symbol>` seti üretir. Tüm seti göndermek 1500+
6
- * ikon, yani birkaç megabayt; kullanım taraması sprite'ı tipik olarak 10-30
7
- * sembolde tutuyor.
4
+ * Kaynak XOR seçilir: uygulama kökünde `icons/` (veya `icons.dir`) dizini
5
+ * varsa yalnızca oradaki düz SVG'ler; yoksa `@phosphor-icons/core`. İkisi
6
+ * birleştirilmez — boş bir `icons/` dizini Phosphor'a düşmez.
7
+ *
8
+ * Yalnızca kaynakta kullanılan ikonlar `<symbol>` olur. Tüm seti göndermek
9
+ * 1500+ ikon, yani birkaç megabayt; kullanım taraması sprite'ı tipik olarak
10
+ * 10-30 sembolde tutuyor. Çıktı `public/assets/` altında hash'li
11
+ * `sprite.svg` olduğu için mevcut precompress kapsamına girer.
8
12
  *
9
13
  * Sembol id'si: `<kebab-name>-<weight>` (örn. `arrow-right-bold`).
14
+ * Yerel dosya adı: `house.svg` → `house:regular`, `house-bold.svg` → `house:bold`.
10
15
  *
11
16
  * Tarama statik metin üzerinden yapıldığı için adı çalışma anında hesaplanan
12
17
  * bir `icon()` çağrısı sprite'a girmez. Bu yüzden ad taşıyan yapılandırma
@@ -41,6 +46,8 @@ const QUOTED_NAME = /["']([A-Z][A-Za-z0-9]*)["']/g;
41
46
 
42
47
  const WEIGHTS = new Set(["thin", "light", "regular", "bold", "fill", "duotone"]);
43
48
 
49
+ const DEFAULT_VIEWBOX = "0 0 256 256";
50
+
44
51
  /**
45
52
  * `ArrowRightIcon` → `arrow-right`
46
53
  * @param {string} name
@@ -54,6 +61,50 @@ function toKebab(name) {
54
61
  .toLowerCase();
55
62
  }
56
63
 
64
+ /**
65
+ * Düz dizin dosya adı → `name` + `weight`.
66
+ * `house.svg` / `house-regular.svg` → regular; `arrow-right-bold.svg` → bold.
67
+ *
68
+ * @param {string} fileName uzantılı veya uzantısız
69
+ * @returns {{ name: string, weight: string } | null}
70
+ */
71
+ export function parseIconFileName(fileName) {
72
+ const base = String(fileName).replace(/\.svg$/i, "");
73
+ if (!base) return null;
74
+
75
+ let name = base;
76
+ let weight = "regular";
77
+
78
+ const dash = base.lastIndexOf("-");
79
+ if (dash > 0) {
80
+ const maybeWeight = base.slice(dash + 1);
81
+ if (WEIGHTS.has(maybeWeight)) {
82
+ name = base.slice(0, dash);
83
+ weight = maybeWeight;
84
+ }
85
+ }
86
+
87
+ // kebab: harf/rakam segmentleri, başta/sonda tire yok
88
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) return null;
89
+
90
+ return { name, weight };
91
+ }
92
+
93
+ /**
94
+ * @param {import('../../config/index.js').ResolvedConfig} config
95
+ * @returns {string | null} mutlak dizin; yoksa veya dizin değilse null
96
+ */
97
+ function resolveLocalIconsDir(config) {
98
+ const relative = config.icons?.dir ?? "icons";
99
+ const dir = path.resolve(config.root, relative);
100
+ try {
101
+ if (fs.statSync(dir).isDirectory()) return dir;
102
+ } catch {
103
+ // yok
104
+ }
105
+ return null;
106
+ }
107
+
57
108
  /**
58
109
  * Paket, framework'ün değil **uygulamanın** node_modules'ünden çözülür:
59
110
  * ikon seti uygulamanın devDependency'si.
@@ -160,21 +211,80 @@ function scanUsedIcons(scanDirs) {
160
211
  return used;
161
212
  }
162
213
 
214
+ /**
215
+ * @param {string} svg
216
+ * @returns {{ body: string, viewBox: string }}
217
+ */
218
+ function parseSvgParts(svg) {
219
+ const open = svg.match(/<svg\b[^>]*>/i)?.[0] ?? "";
220
+ const viewBox =
221
+ open.match(/\bviewBox\s*=\s*["']([^"']+)["']/i)?.[1]?.trim() ||
222
+ DEFAULT_VIEWBOX;
223
+ const body = svg
224
+ .replace(/^[\s\S]*?<svg[^>]*>/i, "")
225
+ .replace(/<\/svg>\s*$/i, "")
226
+ .trim();
227
+ return { body, viewBox };
228
+ }
229
+
230
+ /**
231
+ * Düz `icons/` altındaki `*.svg` dosyalarından `name:weight` → parça haritası.
232
+ *
233
+ * @param {string} dir
234
+ * @returns {Map<string, { body: string, viewBox: string }>}
235
+ */
236
+ function indexLocalIcons(dir) {
237
+ /** @type {Map<string, { body: string, viewBox: string }>} */
238
+ const index = new Map();
239
+
240
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
241
+ if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== ".svg") {
242
+ continue;
243
+ }
244
+
245
+ const parsed = parseIconFileName(entry.name);
246
+ if (!parsed) {
247
+ log.warn(`icon file ignored (bad name) → ${entry.name}`);
248
+ continue;
249
+ }
250
+
251
+ const key = `${parsed.name}:${parsed.weight}`;
252
+ const svg = fs.readFileSync(path.join(dir, entry.name), "utf8");
253
+ index.set(key, parseSvgParts(svg));
254
+ }
255
+
256
+ return index;
257
+ }
258
+
163
259
  /**
164
260
  * @param {string} coreAssets
165
261
  * @param {string} name kebab
166
262
  * @param {string} weight
167
- * @returns {string | null}
263
+ * @returns {{ body: string, viewBox: string } | null}
168
264
  */
169
- function readIconBody(coreAssets, name, weight) {
265
+ function readPhosphorIcon(coreAssets, name, weight) {
170
266
  const fileName = weight === "regular" ? `${name}.svg` : `${name}-${weight}.svg`;
171
267
  const filePath = path.join(coreAssets, weight, fileName);
172
268
 
173
269
  if (!fs.existsSync(filePath)) return null;
174
270
 
175
- const svg = fs.readFileSync(filePath, "utf8");
176
- const inner = svg.replace(/^[\s\S]*?<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "");
177
- return inner.trim();
271
+ return parseSvgParts(fs.readFileSync(filePath, "utf8"));
272
+ }
273
+
274
+ /**
275
+ * @param {Map<string, { body: string, viewBox: string }>} index
276
+ * @param {string} name
277
+ * @param {string} weight
278
+ * @returns {{ body: string, viewBox: string } | null}
279
+ */
280
+ function readLocalIcon(index, name, weight) {
281
+ const exact = index.get(`${name}:${weight}`);
282
+ if (exact) return exact;
283
+
284
+ // `house.svg` regular sayılır; tarama `house:regular` ister.
285
+ if (weight === "regular") return index.get(`${name}:regular`) ?? null;
286
+
287
+ return null;
178
288
  }
179
289
 
180
290
  /**
@@ -182,10 +292,22 @@ function readIconBody(coreAssets, name, weight) {
182
292
  * @returns {Promise<Record<string, string>>}
183
293
  */
184
294
  export async function buildIconSprite(config) {
185
- const coreAssets = resolveIconAssets(config.root);
186
- if (!coreAssets) {
187
- log.detail("@phosphor-icons/core not installed, skipped");
188
- return {};
295
+ const localDir = resolveLocalIconsDir(config);
296
+ /** @type {Map<string, { body: string, viewBox: string }> | null} */
297
+ let localIndex = null;
298
+ /** @type {string | null} */
299
+ let coreAssets = null;
300
+
301
+ if (localDir) {
302
+ localIndex = indexLocalIcons(localDir);
303
+ const rel = path.relative(config.root, localDir) || ".";
304
+ log.detail(`icons from ${rel} (${localIndex.size} files)`);
305
+ } else {
306
+ coreAssets = resolveIconAssets(config.root);
307
+ if (!coreAssets) {
308
+ log.detail("@phosphor-icons/core not installed, skipped");
309
+ return {};
310
+ }
189
311
  }
190
312
 
191
313
  const scanDirs = (
@@ -198,15 +320,17 @@ export async function buildIconSprite(config) {
198
320
 
199
321
  for (const entry of used) {
200
322
  const [name, weight] = entry.split(":");
201
- const body = readIconBody(coreAssets, name, weight);
323
+ const parts = localIndex
324
+ ? readLocalIcon(localIndex, name, weight)
325
+ : readPhosphorIcon(/** @type {string} */ (coreAssets), name, weight);
202
326
 
203
- if (!body) {
327
+ if (!parts?.body) {
204
328
  missing.push(entry);
205
329
  continue;
206
330
  }
207
331
 
208
332
  symbols.push(
209
- `<symbol id="${name}-${weight}" viewBox="0 0 256 256">${body}</symbol>`,
333
+ `<symbol id="${name}-${weight}" viewBox="${parts.viewBox}">${parts.body}</symbol>`,
210
334
  );
211
335
  }
212
336
 
@@ -141,7 +141,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
141
141
  * @property {string[]} prewarmSkip
142
142
  * @property {string[]} watch Dev sunucusunun izlediği ek dizinler.
143
143
  * @property {{ family: string, slug?: string, weights: number[] }[]} fonts
144
- * @property {{ scan?: string[] } | false} icons
144
+ * @property {{ scan?: string[], dir: string } | false} icons
145
145
  * @property {ImagesConfig | false} images
146
146
  * @property {string[]} clientEnv Client bundle'a gömülecek env anahtarları.
147
147
  */
@@ -987,6 +987,34 @@ function normalizeSecurity(raw) {
987
987
  };
988
988
  }
989
989
 
990
+ /**
991
+ * İkon sprite ayarları. `false` → adım atlanır. `dir` varsayılanı `"icons"`:
992
+ * o dizin varsa yalnızca yerel SVG'ler; yoksa Phosphor.
993
+ *
994
+ * @param {unknown} raw
995
+ * @returns {{ scan?: string[], dir: string } | false}
996
+ */
997
+ function normalizeIcons(raw) {
998
+ if (raw === false) return false;
999
+
1000
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
1001
+ const dir =
1002
+ typeof source.dir === "string" && source.dir.trim()
1003
+ ? source.dir.trim()
1004
+ : "icons";
1005
+
1006
+ /** @type {{ scan?: string[], dir: string }} */
1007
+ const icons = { dir };
1008
+
1009
+ if (source.scan != null) {
1010
+ icons.scan = asArray(source.scan, "icons.scan")
1011
+ .filter((entry) => typeof entry === "string" && entry.trim())
1012
+ .map((entry) => String(entry).trim());
1013
+ }
1014
+
1015
+ return icons;
1016
+ }
1017
+
990
1018
  /**
991
1019
  * Build + runtime görsel ayarları. `false` → her iki yüzey de kapalı.
992
1020
  * `remote.allowHosts` boşsa remote kapalı kalır (açık proxy olmasın).
@@ -1237,7 +1265,7 @@ export async function loadConfig(options = {}) {
1237
1265
  // Build tarafı ayarları. Sunucu bunları okumaz ama config tek dosya
1238
1266
  // olsun diye aynı yerden geçer.
1239
1267
  fonts: source.fonts ?? [],
1240
- icons: source.icons ?? {},
1268
+ icons: normalizeIcons(source.icons),
1241
1269
  images: normalizeImages(source.images),
1242
1270
  clientEnv: source.clientEnv ?? [],
1243
1271
  };