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.
@@ -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"` | EJS layout, pages, components |
159
- | `public` | `"public"` | Static files; the build output is written here too |
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.
@@ -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.3.5",
4
- "description": "A framework that feels like no framework: Express 5 + EJS server rendering, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
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",
@@ -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
- if (!fs.existsSync(path.join(config.dirs.generated, "manifest.json"))) {
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, "&amp;")
309
+ .replace(/"/g, "&quot;")
310
+ .replace(/</g, "&lt;");
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
+ }