jskelet 0.4.0 → 0.4.1
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 +17 -0
- package/docs/04-render-ve-sablonlar.md +36 -5
- package/docs/06-cache.md +62 -0
- package/docs/en/04-rendering.md +37 -5
- package/docs/en/06-caching.md +133 -71
- package/package.json +1 -1
- package/src/compile/index.js +2 -0
- package/src/compile/resolve.js +64 -15
- package/src/compile/scan-exports.js +51 -0
- package/src/views/components/loader.js +8 -5
package/CHANGELOG.md
CHANGED
|
@@ -27,6 +27,17 @@ one is listed under a **Breaking** heading.
|
|
|
27
27
|
|
|
28
28
|
### Added
|
|
29
29
|
|
|
30
|
+
- VS Code / Cursor extension skeleton under `extensions/vscode-jsk`: `.jsk`
|
|
31
|
+
language id, TextMate highlighting (`{{ }}` / `{#if}` / `{#each}` /
|
|
32
|
+
components), language config, and snippets. Install from that folder or
|
|
33
|
+
launch **JSK: Extension** from the repo root. Bound attrs on HTML tags
|
|
34
|
+
(`:src="… + '/path'"`) highlight nested single-quoted strings.
|
|
35
|
+
- Compile-time known components are discovered from **named exports** in
|
|
36
|
+
`views/components/**/*.js` (plus `.jsk` component files), not from the file
|
|
37
|
+
basename — so `<SectionHead />` resolves when `sectionHead` lives in
|
|
38
|
+
`ui.js` without a stub re-export. Docs cover the `.jsk` template-vs-component
|
|
39
|
+
boundary and a `{ items, error }` loader / `LoadErrorState` pattern so
|
|
40
|
+
upstream failures are not mistaken for empty data.
|
|
30
41
|
- Build-time `.jsk` templates: declarative HTML-like syntax compiled to ESM
|
|
31
42
|
render modules under `.jskelet/templates/` (no request-time parse, `eval`, or
|
|
32
43
|
`new Function`). Coexists with EJS; compiled `.jsk` wins when both exist.
|
|
@@ -127,8 +138,14 @@ one is listed under a **Breaking** heading.
|
|
|
127
138
|
|
|
128
139
|
### Changed
|
|
129
140
|
|
|
141
|
+
- Duplicate component named exports (or the same PascalCase tag in two files)
|
|
142
|
+
now **fail** at build and at server startup instead of warning and letting
|
|
143
|
+
the second definition win. Overwriting `components/index.js` barrel exports
|
|
144
|
+
remains allowed.
|
|
130
145
|
- `examples/minimal` pages moved to `.jsk`; adds `features/demo` as a
|
|
131
146
|
co-located route + view sample.
|
|
147
|
+
- Marketing compare/FAQ copy no longer claims targeted invalidation is missing;
|
|
148
|
+
it points at `invalidateHtmlCache()` (and Redis pub/sub for multi-instance).
|
|
132
149
|
|
|
133
150
|
- An adaptive per-host rate limit for upstream calls, `cache().upstream`. It sits
|
|
134
151
|
in the `fetch` wrapper rather than in the prewarm pass, because what spends the
|
|
@@ -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
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
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ç |
|
package/docs/en/04-rendering.md
CHANGED
|
@@ -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
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
package/docs/en/06-caching.md
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|
package/src/compile/index.js
CHANGED
|
@@ -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";
|
package/src/compile/resolve.js
CHANGED
|
@@ -7,6 +7,17 @@
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { toPascalCase } from "./codegen.js";
|
|
10
|
+
import { CompileError } from "./errors.js";
|
|
11
|
+
import { scanNamedExports } from "./scan-exports.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runtime'ın camelCase → PascalCase alias'ı ile aynı kural.
|
|
15
|
+
* @param {string} name
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function toComponentTag(name) {
|
|
19
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
20
|
+
}
|
|
10
21
|
|
|
11
22
|
/**
|
|
12
23
|
* @param {{ root: string, dirs: Record<string, string> }} config
|
|
@@ -106,6 +117,9 @@ export function componentNameFromViewId(viewId) {
|
|
|
106
117
|
* Bilinen bileşen adları: JS named export'lar + derlenecek `.jsk` bileşenleri
|
|
107
118
|
* + yerleşik etiketler.
|
|
108
119
|
*
|
|
120
|
+
* JS tarafında dosya adı varsayılmaz; kaynak metinden `export` adları okunur.
|
|
121
|
+
* Aynı export (veya aynı PascalCase etiket) iki dosyada varsa derleme hatası.
|
|
122
|
+
*
|
|
109
123
|
* @param {string[]} componentDirs
|
|
110
124
|
* @param {Map<string, string>} jskFiles
|
|
111
125
|
* @returns {Set<string>}
|
|
@@ -119,41 +133,76 @@ export function collectKnownComponents(componentDirs, jskFiles) {
|
|
|
119
133
|
"PreloadImage",
|
|
120
134
|
]);
|
|
121
135
|
|
|
136
|
+
/** @type {Map<string, string>} export adı → göreli yol */
|
|
137
|
+
const byName = new Map();
|
|
138
|
+
/** @type {Map<string, string>} PascalCase etiket → göreli yol */
|
|
139
|
+
const byTag = new Map();
|
|
140
|
+
|
|
122
141
|
for (const [viewId] of jskFiles) {
|
|
123
142
|
const name = componentNameFromViewId(viewId);
|
|
124
|
-
if (name)
|
|
143
|
+
if (!name) continue;
|
|
144
|
+
known.add(name);
|
|
145
|
+
byTag.set(name, `${viewId}.jsk`);
|
|
125
146
|
}
|
|
126
147
|
|
|
127
148
|
for (const dir of componentDirs) {
|
|
128
|
-
collectJsComponentNames(dir,
|
|
149
|
+
collectJsComponentNames(dir, known, byName, byTag);
|
|
129
150
|
}
|
|
130
151
|
|
|
131
152
|
return known;
|
|
132
153
|
}
|
|
133
154
|
|
|
155
|
+
/**
|
|
156
|
+
* @param {string} name
|
|
157
|
+
* @param {string} origin
|
|
158
|
+
* @param {Map<string, string>} byName
|
|
159
|
+
* @param {Map<string, string>} byTag
|
|
160
|
+
*/
|
|
161
|
+
function registerExportOrigin(name, origin, byName, byTag) {
|
|
162
|
+
const previousName = byName.get(name);
|
|
163
|
+
if (previousName && previousName !== origin) {
|
|
164
|
+
throw new CompileError(
|
|
165
|
+
`Component '${name}' is defined twice: ${previousName} and ${origin}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
byName.set(name, origin);
|
|
169
|
+
|
|
170
|
+
const tag = toComponentTag(name);
|
|
171
|
+
const previousTag = byTag.get(tag);
|
|
172
|
+
if (previousTag && previousTag !== origin) {
|
|
173
|
+
throw new CompileError(
|
|
174
|
+
`Component '${tag}' is defined twice: ${previousTag} and ${origin}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
byTag.set(tag, origin);
|
|
178
|
+
}
|
|
179
|
+
|
|
134
180
|
/**
|
|
135
181
|
* @param {string} dir
|
|
136
|
-
* @param {string} root
|
|
137
182
|
* @param {Set<string>} out
|
|
183
|
+
* @param {Map<string, string>} byName
|
|
184
|
+
* @param {Map<string, string>} byTag
|
|
138
185
|
*/
|
|
139
|
-
function collectJsComponentNames(dir,
|
|
186
|
+
function collectJsComponentNames(dir, out, byName, byTag) {
|
|
140
187
|
if (!fs.existsSync(dir)) return;
|
|
141
188
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
142
189
|
const full = path.join(dir, entry.name);
|
|
143
190
|
if (entry.isDirectory()) {
|
|
144
|
-
collectJsComponentNames(full,
|
|
191
|
+
collectJsComponentNames(full, out, byName, byTag);
|
|
145
192
|
continue;
|
|
146
193
|
}
|
|
147
194
|
if (!entry.name.endsWith(".js")) continue;
|
|
148
|
-
if (entry.name === "loader.js") continue;
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
195
|
+
if (entry.name === "loader.js" || entry.name === "index.js") continue;
|
|
196
|
+
|
|
197
|
+
// Kimlik mutlak yol: çoklu kökte iki `list.js` aynı göreli ada sahip olabilir.
|
|
198
|
+
const origin = full.split(path.sep).join("/");
|
|
199
|
+
const source = fs.readFileSync(full, "utf8");
|
|
200
|
+
const exports = scanNamedExports(source);
|
|
201
|
+
|
|
202
|
+
for (const name of exports) {
|
|
203
|
+
registerExportOrigin(name, origin, byName, byTag);
|
|
204
|
+
out.add(name);
|
|
205
|
+
out.add(toComponentTag(name));
|
|
206
|
+
}
|
|
158
207
|
}
|
|
159
208
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kaynak metinden named export adlarını çıkarır — modülü çalıştırmadan.
|
|
3
|
+
* Compile-time bilinen bileşen listesi için; `default` yok sayılır.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Yorumları kaba şekilde siler; string içindeki sahte eşleşmeler nadirdir.
|
|
8
|
+
* @param {string} source
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
function stripComments(source) {
|
|
12
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `export { a, b as C, default as X }` listesinden dışa verilen adları toplar.
|
|
17
|
+
* @param {string} clause
|
|
18
|
+
* @param {Set<string>} out
|
|
19
|
+
*/
|
|
20
|
+
function addExportList(clause, out) {
|
|
21
|
+
for (const part of clause.split(",")) {
|
|
22
|
+
const trimmed = part.trim();
|
|
23
|
+
if (!trimmed) continue;
|
|
24
|
+
const bits = trimmed.split(/\s+as\s+/i);
|
|
25
|
+
const exported = (bits[1] ?? bits[0]).trim();
|
|
26
|
+
if (!exported || exported === "default") continue;
|
|
27
|
+
out.add(exported);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} source
|
|
33
|
+
* @returns {string[]}
|
|
34
|
+
*/
|
|
35
|
+
export function scanNamedExports(source) {
|
|
36
|
+
const text = stripComments(source);
|
|
37
|
+
/** @type {Set<string>} */
|
|
38
|
+
const names = new Set();
|
|
39
|
+
|
|
40
|
+
for (const match of text.matchAll(
|
|
41
|
+
/\bexport\s+(?:async\s+)?(?:function\*?|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g,
|
|
42
|
+
)) {
|
|
43
|
+
names.add(match[1]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const match of text.matchAll(/\bexport\s*\{([^}]+)\}/g)) {
|
|
47
|
+
addExportList(match[1], names);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return [...names];
|
|
51
|
+
}
|
|
@@ -60,21 +60,24 @@ async function loadDir(dir, components, origin) {
|
|
|
60
60
|
];
|
|
61
61
|
|
|
62
62
|
for (const file of files) {
|
|
63
|
-
const
|
|
63
|
+
const isBarrel = path.basename(file) === BARREL;
|
|
64
|
+
// Kimlik mutlak yol — çoklu components kökünde göreli ad çakışmasın.
|
|
65
|
+
const fileId = isBarrel ? BARREL : file.split(path.sep).join("/");
|
|
64
66
|
const module = await import(pathToFileURL(file).href);
|
|
65
67
|
|
|
66
68
|
for (const [name, value] of Object.entries(module)) {
|
|
67
69
|
if (name === "default") continue;
|
|
68
70
|
|
|
69
71
|
const previous = origin.get(name);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
// Barrel üzerine yazmak bilinçli; iki gerçek bileşen dosyası çakışması hata.
|
|
73
|
+
if (previous && previous !== BARREL && previous !== fileId) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`[components] '${name}' is defined twice: ${previous} and ${fileId}`,
|
|
73
76
|
);
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
components[name] = value;
|
|
77
|
-
origin.set(name,
|
|
80
|
+
origin.set(name, fileId);
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
}
|