jskelet 0.3.5 → 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 +34 -0
- package/bin/jskelet.mjs +20 -7
- package/docs/02-mimari.md +3 -0
- package/docs/03-routing.md +19 -3
- package/docs/04-render-ve-sablonlar.md +94 -9
- package/docs/06-cache.md +62 -0
- package/docs/07-yapilandirma.md +7 -5
- package/docs/08-build.md +5 -0
- package/docs/en/02-architecture.md +3 -0
- package/docs/en/03-routing.md +19 -4
- package/docs/en/04-rendering.md +96 -12
- package/docs/en/06-caching.md +133 -71
- package/docs/en/07-configuration.md +5 -3
- package/docs/en/08-build.md +5 -0
- package/package.json +2 -2
- package/src/build/build.mjs +6 -0
- package/src/build/ensure-build.mjs +5 -1
- package/src/build/tasks/icons.mjs +2 -2
- package/src/build/tasks/templates.mjs +20 -0
- package/src/compile/codegen.js +332 -0
- package/src/compile/compile-all.js +158 -0
- package/src/compile/errors.js +66 -0
- package/src/compile/expr.js +404 -0
- package/src/compile/index.js +17 -0
- package/src/compile/parse.js +485 -0
- package/src/compile/resolve.js +208 -0
- package/src/compile/scan-exports.js +51 -0
- package/src/config/defaults.js +6 -2
- package/src/config/index.js +7 -0
- package/src/dev-server.mjs +4 -2
- package/src/generate.mjs +163 -0
- package/src/init.mjs +8 -6
- package/src/server/render.js +153 -40
- package/src/server/router.js +26 -3
- package/src/views/components/loader.js +34 -18
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 |
|
|
@@ -155,12 +155,14 @@ internally.
|
|
|
155
155
|
|
|
156
156
|
| Key | Default | Contents |
|
|
157
157
|
| --- | --- | --- |
|
|
158
|
-
| `views` | `"views"` |
|
|
159
|
-
| `
|
|
158
|
+
| `views` | `"views"` | Layout, pages, components (classic root; `.jsk` / `.ejs`) |
|
|
159
|
+
| `features` | `"features"` | Feature-first slices (`<name>/{server,views,client}`) |
|
|
160
|
+
| `shared` | `"shared"` | Cross-feature server/views/client |
|
|
161
|
+
| `public` | `"public"` | Static files; build output lands here too |
|
|
160
162
|
| `client` | `"client"` | Island runtime sources and entries |
|
|
161
163
|
| `routes` | `"routes"` | Route modules |
|
|
162
164
|
| `styles` | `"styles/globals.css"` | Tailwind/PostCSS entry **file** |
|
|
163
|
-
| `generated` | `".jskelet"` | `manifest.json`, `metafile.json`, `images.json` |
|
|
165
|
+
| `generated` | `".jskelet"` | `manifest.json`, `templates/`, `metafile.json`, `images.json` |
|
|
164
166
|
|
|
165
167
|
Even though `styles` is a file path it goes through the same resolution; keeping
|
|
166
168
|
a separate field for it is not worth it.
|
package/docs/en/08-build.md
CHANGED
|
@@ -12,6 +12,7 @@ build is in [09-dev-tools.md](./09-dev-tools.md).
|
|
|
12
12
|
## The pipeline and its order
|
|
13
13
|
|
|
14
14
|
```
|
|
15
|
+
0. Templates .jsk → .jskelet/templates/*.mjs (always; no-op if none)
|
|
15
16
|
1. Fonts if config.fonts is set
|
|
16
17
|
2. Icon sprite if config.icons !== false
|
|
17
18
|
3. CSS if the styles entry file exists
|
|
@@ -21,6 +22,10 @@ build is in [09-dev-tools.md](./09-dev-tools.md).
|
|
|
21
22
|
7. Precompress if not watch
|
|
22
23
|
```
|
|
23
24
|
|
|
25
|
+
Template compilation finishes **before** asset scanning; there is no parse on
|
|
26
|
+
the request path. Tailwind `@source` and the icon scan read source `.jsk` files
|
|
27
|
+
(not the generated `.mjs`).
|
|
28
|
+
|
|
24
29
|
The order is not arbitrary:
|
|
25
30
|
|
|
26
31
|
- **CSS comes after the icon sprite.** The sprite is an asset and produces no
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jskelet",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A framework that feels like no framework: Express 5 + EJS
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|
|
7
7
|
"author": "Ayberk Enis",
|
package/src/build/build.mjs
CHANGED
|
@@ -40,6 +40,12 @@ if (!child) {
|
|
|
40
40
|
|
|
41
41
|
log.section("build");
|
|
42
42
|
|
|
43
|
+
// Şablonlar asset taramasından önce derlenir; istek anında parse yok.
|
|
44
|
+
await task("Templates", async () => {
|
|
45
|
+
const { buildTemplates } = await import("./tasks/templates.mjs");
|
|
46
|
+
await buildTemplates(config);
|
|
47
|
+
});
|
|
48
|
+
|
|
43
49
|
/** @type {Record<string, string>} */
|
|
44
50
|
const manifest = {};
|
|
45
51
|
|
|
@@ -10,6 +10,10 @@ import { loadConfig } from "../config/index.js";
|
|
|
10
10
|
|
|
11
11
|
const config = await loadConfig();
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
const generated = config.dirs.generated;
|
|
14
|
+
const needsAssets = !fs.existsSync(path.join(generated, "manifest.json"));
|
|
15
|
+
const needsTemplates = !fs.existsSync(path.join(generated, "templates", "manifest.json"));
|
|
16
|
+
|
|
17
|
+
if (needsAssets || needsTemplates) {
|
|
14
18
|
await import("./build.mjs");
|
|
15
19
|
}
|
|
@@ -19,7 +19,7 @@ import { createRequire } from "node:module";
|
|
|
19
19
|
import { pruneAssets, writeAsset } from "../paths.mjs";
|
|
20
20
|
import * as log from "../../log.mjs";
|
|
21
21
|
|
|
22
|
-
const SCAN_EXTENSIONS = new Set([".ejs", ".js", ".mjs"]);
|
|
22
|
+
const SCAN_EXTENSIONS = new Set([".ejs", ".jsk", ".js", ".mjs"]);
|
|
23
23
|
|
|
24
24
|
/** `icon({ … })` çağrısının tamamı; `name:` ifadesi ayrıca çözümlenir. */
|
|
25
25
|
const ICON_CALL = /icon\(\s*\{([^}]*)\}/g;
|
|
@@ -189,7 +189,7 @@ export async function buildIconSprite(config) {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
const scanDirs = (
|
|
192
|
-
config.icons?.scan ?? ["views", "client", "routes", "lib"]
|
|
192
|
+
config.icons?.scan ?? ["views", "client", "routes", "lib", "features", "shared"]
|
|
193
193
|
).map((dir) => path.resolve(config.root, dir));
|
|
194
194
|
|
|
195
195
|
pruneAssets(["sprite."]);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.jsk` şablonlarını `.jskelet/templates/` altına derler.
|
|
3
|
+
* Asset pipeline'dan önce çalışır; Tailwind/ikon taraması kaynak `.jsk`'yi okur.
|
|
4
|
+
*/
|
|
5
|
+
import { compileAll } from "../../compile/compile-all.js";
|
|
6
|
+
import * as log from "../../log.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {import('../../config/index.js').ResolvedConfig} config
|
|
10
|
+
* @returns {Promise<number>} Derlenen dosya sayısı.
|
|
11
|
+
*/
|
|
12
|
+
export async function buildTemplates(config) {
|
|
13
|
+
const result = await compileAll(config);
|
|
14
|
+
if (result.count === 0) {
|
|
15
|
+
log.line("no .jsk templates");
|
|
16
|
+
} else {
|
|
17
|
+
log.line(`${result.count} template${result.count === 1 ? "" : "s"} → .jskelet/templates`);
|
|
18
|
+
}
|
|
19
|
+
return result.count;
|
|
20
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST → ESM kaynak metni. `eval` / `new Function` yok; çıktı diskteki
|
|
3
|
+
* `.mjs` dosyasına yazılır ve runtime'da normal `import` ile yüklenir.
|
|
4
|
+
*/
|
|
5
|
+
import { codegenExpr } from "./expr.js";
|
|
6
|
+
import { CompileError } from "./errors.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {import('./parse.js').AstNode} AstNode
|
|
10
|
+
* @typedef {import('./parse.js').AttrNode} AttrNode
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {AstNode[]} ast
|
|
15
|
+
* @param {{ viewId: string, file?: string, knownComponents?: Set<string> }} options
|
|
16
|
+
* @returns {{ code: string, includes: string[], components: string[] }}
|
|
17
|
+
*/
|
|
18
|
+
export function codegen(ast, options) {
|
|
19
|
+
const ctx = new CodegenContext(options);
|
|
20
|
+
ctx.emitNodes(ast);
|
|
21
|
+
return {
|
|
22
|
+
code: ctx.finish(),
|
|
23
|
+
includes: [...ctx.includes],
|
|
24
|
+
components: [...ctx.components],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Include yolunu view id'ye çevir (uzantısız, `/` ayırıcılı).
|
|
30
|
+
* @param {string} includePath
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeIncludeId(includePath) {
|
|
34
|
+
return includePath.replace(/\\/g, "/").replace(/\.jsk$/i, "").replace(/^\.\//, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `market-hero` / `MarketHero` → `MarketHero`
|
|
39
|
+
* @param {string} name
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function toPascalCase(name) {
|
|
43
|
+
if (/^[A-Z]/.test(name) && !name.includes("-")) return name;
|
|
44
|
+
return name
|
|
45
|
+
.split(/[-_/]+/)
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
48
|
+
.join("");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class CodegenContext {
|
|
52
|
+
/**
|
|
53
|
+
* @param {{ viewId: string, file?: string, knownComponents?: Set<string> }} options
|
|
54
|
+
*/
|
|
55
|
+
constructor(options) {
|
|
56
|
+
this.viewId = options.viewId;
|
|
57
|
+
this.file = options.file;
|
|
58
|
+
this.knownComponents = options.knownComponents ?? null;
|
|
59
|
+
/** @type {Set<string>} */
|
|
60
|
+
this.includes = new Set();
|
|
61
|
+
/** @type {Set<string>} */
|
|
62
|
+
this.components = new Set();
|
|
63
|
+
/** @type {string[]} */
|
|
64
|
+
this.body = [];
|
|
65
|
+
this.indent = 1;
|
|
66
|
+
this.temp = 0;
|
|
67
|
+
/** @type {Set<string>} */
|
|
68
|
+
this.locals = new Set();
|
|
69
|
+
/** Çocuk gövdesinde `s` yerine kullanılacak değişken. */
|
|
70
|
+
this.outVar = "s";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} line
|
|
75
|
+
*/
|
|
76
|
+
line(line) {
|
|
77
|
+
this.body.push(`${" ".repeat(this.indent)}${line}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} exprJs
|
|
82
|
+
*/
|
|
83
|
+
append(exprJs) {
|
|
84
|
+
this.line(`${this.outVar} += ${exprJs};`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @returns {string} */
|
|
88
|
+
nextTemp() {
|
|
89
|
+
this.temp += 1;
|
|
90
|
+
return `__c${this.temp}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {AstNode[]} nodes
|
|
95
|
+
*/
|
|
96
|
+
emitNodes(nodes) {
|
|
97
|
+
for (const node of nodes) this.emit(node);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {AstNode} node
|
|
102
|
+
*/
|
|
103
|
+
emit(node) {
|
|
104
|
+
switch (node.type) {
|
|
105
|
+
case "Text":
|
|
106
|
+
if (node.value) this.append(JSON.stringify(node.value));
|
|
107
|
+
break;
|
|
108
|
+
case "Interp": {
|
|
109
|
+
const expr = codegenExpr(node.expr, this.locals);
|
|
110
|
+
if (node.raw) this.append(`(${expr} ?? "")`);
|
|
111
|
+
else this.append(`helpers.esc(${expr})`);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case "Element":
|
|
115
|
+
this.emitElement(node);
|
|
116
|
+
break;
|
|
117
|
+
case "Component":
|
|
118
|
+
this.emitComponent(node);
|
|
119
|
+
break;
|
|
120
|
+
case "If":
|
|
121
|
+
this.emitIf(node);
|
|
122
|
+
break;
|
|
123
|
+
case "Each":
|
|
124
|
+
this.emitEach(node);
|
|
125
|
+
break;
|
|
126
|
+
case "Include":
|
|
127
|
+
this.emitInclude(node);
|
|
128
|
+
break;
|
|
129
|
+
default:
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {import('./parse.js').ElementNode} node
|
|
136
|
+
*/
|
|
137
|
+
emitElement(node) {
|
|
138
|
+
const bound = node.attrs.some((a) => a.bound);
|
|
139
|
+
if (bound) {
|
|
140
|
+
this.append(
|
|
141
|
+
`"<${node.name}" + helpers.attrs({${this.attrsObject(node.attrs)}}) + ">"`,
|
|
142
|
+
);
|
|
143
|
+
} else {
|
|
144
|
+
this.append(JSON.stringify(`<${node.name}${staticAttrString(node.attrs)}>`));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!node.selfClosing) {
|
|
148
|
+
this.emitNodes(node.children);
|
|
149
|
+
this.append(JSON.stringify(`</${node.name}>`));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param {AttrNode[]} attrs
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
attrsObject(attrs) {
|
|
158
|
+
return attrs
|
|
159
|
+
.map((attr) => {
|
|
160
|
+
const key = JSON.stringify(attr.name);
|
|
161
|
+
if (attr.boolean && !attr.bound) return `${key}: true`;
|
|
162
|
+
if (attr.bound && attr.expr) {
|
|
163
|
+
return `${key}: ${codegenExpr(attr.expr, this.locals)}`;
|
|
164
|
+
}
|
|
165
|
+
return `${key}: ${JSON.stringify(attr.value ?? true)}`;
|
|
166
|
+
})
|
|
167
|
+
.join(", ");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {import('./parse.js').ComponentNode} node
|
|
172
|
+
*/
|
|
173
|
+
emitComponent(node) {
|
|
174
|
+
if (this.knownComponents && !this.knownComponents.has(node.name)) {
|
|
175
|
+
throw new CompileError(`Unknown component "${node.name}"`, {
|
|
176
|
+
file: this.file,
|
|
177
|
+
index: node.index,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
this.components.add(node.name);
|
|
181
|
+
|
|
182
|
+
const props = this.attrsObject(node.attrs);
|
|
183
|
+
|
|
184
|
+
if (node.children.length) {
|
|
185
|
+
const tmp = this.nextTemp();
|
|
186
|
+
this.line(`{`);
|
|
187
|
+
this.indent += 1;
|
|
188
|
+
this.line(`let ${tmp} = "";`);
|
|
189
|
+
const prevOut = this.outVar;
|
|
190
|
+
this.outVar = tmp;
|
|
191
|
+
this.emitNodes(node.children);
|
|
192
|
+
this.outVar = prevOut;
|
|
193
|
+
const propsWithChildren = props
|
|
194
|
+
? `${props}, children: ${tmp}`
|
|
195
|
+
: `children: ${tmp}`;
|
|
196
|
+
this.append(`(helpers.${node.name}({ ${propsWithChildren} }) ?? "")`);
|
|
197
|
+
this.indent -= 1;
|
|
198
|
+
this.line(`}`);
|
|
199
|
+
} else {
|
|
200
|
+
this.append(`(helpers.${node.name}({ ${props} }) ?? "")`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {import('./parse.js').IfNode} node
|
|
206
|
+
*/
|
|
207
|
+
emitIf(node) {
|
|
208
|
+
const test = codegenExpr(node.test, this.locals);
|
|
209
|
+
this.line(`if (${test}) {`);
|
|
210
|
+
this.indent += 1;
|
|
211
|
+
this.emitNodes(node.consequent);
|
|
212
|
+
this.indent -= 1;
|
|
213
|
+
if (node.alternate.length) {
|
|
214
|
+
this.line(`} else {`);
|
|
215
|
+
this.indent += 1;
|
|
216
|
+
this.emitNodes(node.alternate);
|
|
217
|
+
this.indent -= 1;
|
|
218
|
+
}
|
|
219
|
+
this.line(`}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {import('./parse.js').EachNode} node
|
|
224
|
+
*/
|
|
225
|
+
emitEach(node) {
|
|
226
|
+
const list = codegenExpr(node.list, this.locals);
|
|
227
|
+
const idx = node.indexName ?? `__i${++this.temp}`;
|
|
228
|
+
this.line(`{`);
|
|
229
|
+
this.indent += 1;
|
|
230
|
+
this.line(`const __list = ${list} ?? [];`);
|
|
231
|
+
this.line(`for (let ${idx} = 0; ${idx} < __list.length; ${idx}++) {`);
|
|
232
|
+
this.indent += 1;
|
|
233
|
+
this.line(`const ${node.item} = __list[${idx}];`);
|
|
234
|
+
const prev = new Set(this.locals);
|
|
235
|
+
this.locals.add(node.item);
|
|
236
|
+
this.locals.add(idx);
|
|
237
|
+
if (node.indexName) this.locals.add(node.indexName);
|
|
238
|
+
this.emitNodes(node.children);
|
|
239
|
+
this.locals = prev;
|
|
240
|
+
this.indent -= 1;
|
|
241
|
+
this.line(`}`);
|
|
242
|
+
this.indent -= 1;
|
|
243
|
+
this.line(`}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {import('./parse.js').IncludeNode} node
|
|
248
|
+
*/
|
|
249
|
+
emitInclude(node) {
|
|
250
|
+
const id = normalizeIncludeId(node.path);
|
|
251
|
+
this.includes.add(id);
|
|
252
|
+
const alias = includeAlias(id);
|
|
253
|
+
this.append(`(${alias}(data, helpers) ?? "")`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** @returns {string} */
|
|
257
|
+
finish() {
|
|
258
|
+
const imports = [...this.includes]
|
|
259
|
+
.map((id) => {
|
|
260
|
+
const alias = includeAlias(id);
|
|
261
|
+
const rel = relativeImport(this.viewId, id);
|
|
262
|
+
return `import { render as ${alias} } from ${JSON.stringify(rel)};`;
|
|
263
|
+
})
|
|
264
|
+
.join("\n");
|
|
265
|
+
|
|
266
|
+
const parts = [
|
|
267
|
+
"/** @generated by jskelet — do not edit */",
|
|
268
|
+
imports,
|
|
269
|
+
"/**",
|
|
270
|
+
" * @param {Record<string, unknown>} data",
|
|
271
|
+
" * @param {Record<string, any>} helpers",
|
|
272
|
+
" * @returns {string}",
|
|
273
|
+
" */",
|
|
274
|
+
"export function render(data, helpers) {",
|
|
275
|
+
' let s = "";',
|
|
276
|
+
...this.body,
|
|
277
|
+
" return s;",
|
|
278
|
+
"}",
|
|
279
|
+
"",
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
return parts.filter((l, i) => !(l === "" && i === 1 && !imports)).join("\n");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {AttrNode[]} attrs
|
|
288
|
+
* @returns {string}
|
|
289
|
+
*/
|
|
290
|
+
function staticAttrString(attrs) {
|
|
291
|
+
let out = "";
|
|
292
|
+
for (const attr of attrs) {
|
|
293
|
+
if (attr.boolean) {
|
|
294
|
+
out += ` ${attr.name}`;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
out += ` ${attr.name}="${escapeAttr(attr.value ?? "")}"`;
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* @param {string} value
|
|
304
|
+
* @returns {string}
|
|
305
|
+
*/
|
|
306
|
+
function escapeAttr(value) {
|
|
307
|
+
return String(value)
|
|
308
|
+
.replace(/&/g, "&")
|
|
309
|
+
.replace(/"/g, """)
|
|
310
|
+
.replace(/</g, "<");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* @param {string} id
|
|
315
|
+
* @returns {string}
|
|
316
|
+
*/
|
|
317
|
+
function includeAlias(id) {
|
|
318
|
+
return `__inc_${id.replace(/[^A-Za-z0-9]/g, "_")}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* @param {string} fromViewId
|
|
323
|
+
* @param {string} toViewId
|
|
324
|
+
* @returns {string}
|
|
325
|
+
*/
|
|
326
|
+
function relativeImport(fromViewId, toViewId) {
|
|
327
|
+
const fromParts = fromViewId.split("/");
|
|
328
|
+
fromParts.pop();
|
|
329
|
+
const depth = fromParts.length;
|
|
330
|
+
const prefix = depth === 0 ? "./" : "../".repeat(depth);
|
|
331
|
+
return `${prefix}${toViewId}.mjs`;
|
|
332
|
+
}
|