@jxsuite/compiler 0.0.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.
@@ -0,0 +1,619 @@
1
+ /**
2
+ * Compile-element.js — Custom element compilation with lit-html
3
+ *
4
+ * Compiles Jx documents into self-registering custom element ES modules using @vue/reactivity for
5
+ * state and lit-html for rendering.
6
+ */
7
+
8
+ import { camelToKebab, RESERVED_KEYS } from "@jxsuite/runtime";
9
+ import { escapeHtml, tagNameToClassName, isSchemaOnly } from "../shared.js";
10
+
11
+ /**
12
+ * Compile a Jx custom element document to a JS module string.
13
+ *
14
+ * @param {string | any} sourcePath - Path to .json file or raw object
15
+ * @param {any} [opts]
16
+ * @returns {Promise<{ files: { path: string; content: string; tagName: string }[] }>}
17
+ */
18
+ export async function compileElement(sourcePath, opts = {}) {
19
+ const { resolveElementPath } = opts;
20
+ /** @type {{ path: string; content: string; tagName: string }[]} */
21
+ const files = [];
22
+ /** @type {Set<string>} */
23
+ const visited = new Set();
24
+
25
+ /**
26
+ * @param {any} srcPath
27
+ * @param {string | null} parentDir
28
+ */
29
+ async function processElement(srcPath, parentDir) {
30
+ /** @type {any} */
31
+ let doc;
32
+ /** @type {string | null} */
33
+ let filePath;
34
+ if (typeof srcPath === "string") {
35
+ const { readFileSync } = await import("node:fs");
36
+ const { resolve } = await import("node:path");
37
+ filePath = parentDir ? resolve(parentDir, srcPath) : resolve(srcPath);
38
+ if (visited.has(filePath)) return;
39
+ visited.add(filePath);
40
+ doc = JSON.parse(readFileSync(filePath, "utf8"));
41
+ } else {
42
+ doc = srcPath;
43
+ filePath = null;
44
+ if (visited.has(doc.tagName)) return;
45
+ visited.add(doc.tagName);
46
+ }
47
+
48
+ const tagName = doc.tagName;
49
+ if (!tagName || !tagName.includes("-")) {
50
+ throw new Error(`compileElement: tagName "${tagName}" must contain a hyphen`);
51
+ }
52
+
53
+ const { dirname: dn } = await import("node:path");
54
+ const currentDir = filePath ? dn(filePath) : null;
55
+
56
+ // Process $elements dependencies depth-first
57
+ /** @type {string[]} */
58
+ const elementImports = [];
59
+ if (Array.isArray(doc.$elements)) {
60
+ for (const elRef of doc.$elements) {
61
+ const refPath = elRef.$ref ?? elRef;
62
+ if (typeof refPath !== "string") continue;
63
+
64
+ if (currentDir) {
65
+ await processElement(refPath, currentDir);
66
+ }
67
+
68
+ /** @type {string} */
69
+ let importPath;
70
+ if (resolveElementPath) {
71
+ importPath = resolveElementPath(refPath, currentDir);
72
+ } else {
73
+ importPath = refPath.replace(/\.json$/, ".js");
74
+ }
75
+ elementImports.push(importPath);
76
+ }
77
+ }
78
+
79
+ const className = tagNameToClassName(tagName);
80
+ const jsContent = emitElementModule(doc, className, elementImports);
81
+ const outputPath = filePath ? filePath.replace(/\.json$/, ".js") : `${tagName}.js`;
82
+ files.push({ path: outputPath, content: jsContent, tagName });
83
+ }
84
+
85
+ await processElement(sourcePath, opts.basePath ?? null);
86
+ return { files };
87
+ }
88
+
89
+ /**
90
+ * Compile a Jx custom element document to a complete HTML page with an import map for CDN
91
+ * dependencies.
92
+ *
93
+ * @param {string | any} sourcePath
94
+ * @param {any} [opts]
95
+ * @returns {Promise<{
96
+ * html: string;
97
+ * files: { path: string; content: string; tagName: string }[];
98
+ * }>}
99
+ */
100
+ export async function compileElementPage(sourcePath, opts = {}) {
101
+ const {
102
+ title = "Jx App",
103
+ reactivitySrc = "https://esm.sh/@vue/reactivity@3.5.13",
104
+ litHtmlSrc = "https://esm.sh/lit-html@3.3.0",
105
+ } = opts;
106
+
107
+ const result = await compileElement(sourcePath, opts);
108
+ const root = result.files[result.files.length - 1];
109
+
110
+ const { basename } = await import("node:path");
111
+ const rootScript = basename(root.path);
112
+
113
+ const htmlContent = `<!DOCTYPE html>
114
+ <html lang="en">
115
+ <head>
116
+ <meta charset="utf-8">
117
+ <meta name="viewport" content="width=device-width, initial-scale=1">
118
+ <title>${escapeHtml(title)}</title>
119
+ <script type="importmap">
120
+ {
121
+ "imports": {
122
+ "@vue/reactivity": "${reactivitySrc}",
123
+ "lit-html": "${litHtmlSrc}"
124
+ }
125
+ }
126
+ </script>
127
+ </head>
128
+ <body>
129
+ <${root.tagName}></${root.tagName}>
130
+ <script type="module" src="./${rootScript}"></script>
131
+ </body>
132
+ </html>`;
133
+
134
+ return { html: htmlContent, files: result.files };
135
+ }
136
+
137
+ // ─── Element code generation helpers ──────────────────────────────────────────
138
+
139
+ /**
140
+ * Extract the initial value for a state entry to use in reactive({}). Bug fix: expanded signals
141
+ * like { type, default, description } now correctly extract the `default` value instead of dumping
142
+ * the whole object.
143
+ *
144
+ * @param {any} def
145
+ * @returns {string | undefined}
146
+ */
147
+ function extractInitialValue(def) {
148
+ if (def === null || typeof def !== "object" || Array.isArray(def)) {
149
+ return JSON.stringify(def);
150
+ }
151
+ // Expanded signal with explicit default
152
+ if ("default" in def) {
153
+ return JSON.stringify(def.default);
154
+ }
155
+ // Pure schema-only type definitions — skip (no runtime value)
156
+ if (isSchemaOnly(def)) {
157
+ return undefined; // caller should skip this entry
158
+ }
159
+ // $prototype entries (LocalStorage, SessionStorage, Request, etc.)
160
+ if (def.$prototype === "LocalStorage" || def.$prototype === "SessionStorage") {
161
+ return JSON.stringify(def.default ?? null);
162
+ }
163
+ if (def.$prototype === "Request") {
164
+ return "null";
165
+ }
166
+ // Plain object → treat as initial state value
167
+ return JSON.stringify(def);
168
+ }
169
+
170
+ /**
171
+ * Generate a complete ES module string for a custom element.
172
+ *
173
+ * @param {any} doc
174
+ * @param {string} className
175
+ * @param {string[]} elementImports
176
+ * @returns {string}
177
+ */
178
+ export function emitElementModule(doc, className, elementImports) {
179
+ /** @type {string[]} */
180
+ const lines = [];
181
+
182
+ lines.push("// Generated by @jxsuite/compiler — do not edit manually");
183
+ if (doc.$id) lines.push(`// Source: ${doc.$id}`);
184
+
185
+ for (const imp of elementImports) {
186
+ lines.push(`import '${imp}';`);
187
+ }
188
+
189
+ // Collect $src imports from state entries before emitting other imports
190
+ /** @type {Map<string, string[]>} */
191
+ const srcImportMap = new Map();
192
+ const defs = doc.state ?? {};
193
+ for (const [key, def] of Object.entries(defs)) {
194
+ const d = /** @type {any} */ (def);
195
+ if (d && typeof d === "object" && !Array.isArray(d) && d.$prototype === "Function" && d.$src) {
196
+ const srcPath = d.$src;
197
+ if (!srcImportMap.has(srcPath)) srcImportMap.set(srcPath, []);
198
+ /** @type {string[]} */ (srcImportMap.get(srcPath)).push(key);
199
+ }
200
+ }
201
+ for (const [srcPath, names] of srcImportMap) {
202
+ lines.push(`import { ${names.join(", ")} } from '${srcPath}';`);
203
+ }
204
+
205
+ lines.push(`import { reactive, computed, effect } from '@vue/reactivity';`);
206
+ lines.push(`import { render, html } from 'lit-html';`);
207
+ lines.push("");
208
+ lines.push(`class ${className} extends HTMLElement {`);
209
+ lines.push(" #dispose = null;");
210
+ lines.push("");
211
+
212
+ // Constructor: build reactive state
213
+ lines.push(" constructor() {");
214
+ lines.push(" super();");
215
+
216
+ /** @type {[string, string][]} */
217
+ const stateEntries = [];
218
+ /** @type {[string, any][]} */
219
+ const computedEntries = [];
220
+ /** @type {[string, any][]} */
221
+ const functionEntries = [];
222
+
223
+ for (const [key, def] of Object.entries(defs)) {
224
+ const d = /** @type {any} */ (def);
225
+ if (d && typeof d === "object" && !Array.isArray(d) && d.$prototype === "Function") {
226
+ if (typeof d.body === "string" && d.body.includes("return")) {
227
+ computedEntries.push([key, d]);
228
+ } else {
229
+ functionEntries.push([key, d]);
230
+ }
231
+ } else {
232
+ // Use extractInitialValue to get the correct initial value
233
+ const initVal = extractInitialValue(d);
234
+ if (initVal !== undefined) {
235
+ stateEntries.push([key, initVal]);
236
+ }
237
+ }
238
+ }
239
+
240
+ // Emit reactive({...}) with initial state values
241
+ lines.push(" this.state = reactive({");
242
+ for (const [key, initVal] of stateEntries) {
243
+ lines.push(` ${key}: ${initVal},`);
244
+ }
245
+ lines.push(" });");
246
+
247
+ // Emit functions: this.state.fnName = (state) => { body } or imported $src function
248
+ for (const [key, def] of functionEntries) {
249
+ lines.push("");
250
+ if (def.$src) {
251
+ // $src function — wrap imported function so it receives state
252
+ const args = def.parameters ?? def.arguments ?? ["state"];
253
+ const paramList = args.join(", ");
254
+ lines.push(` this.state.${key} = (${paramList}) => ${key}(${paramList});`);
255
+ } else {
256
+ const args = def.parameters ?? def.arguments ?? ["state"];
257
+ const paramList = args.join(", ");
258
+ lines.push(` this.state.${key} = (${paramList}) => {`);
259
+ lines.push(` ${def.body}`);
260
+ lines.push(" };");
261
+ }
262
+ }
263
+
264
+ // Emit computed signals — $src or inline body
265
+ for (const [key, def] of computedEntries) {
266
+ lines.push("");
267
+ if (def.$src) {
268
+ lines.push(` this.state.${key} = computed(() => ${key}(this.state));`);
269
+ } else {
270
+ lines.push(` this.state.${key} = computed(() => {`);
271
+ const body = def.body.replace(/state\./g, "this.state.");
272
+ lines.push(` ${body}`);
273
+ lines.push(" });");
274
+ }
275
+ }
276
+
277
+ lines.push(" }"); // end constructor
278
+ lines.push("");
279
+
280
+ // Template method
281
+ lines.push(" template() {");
282
+ lines.push(" const s = this.state;");
283
+ lines.push(" return html`");
284
+ lines.push(emitLitChildren(doc.children, doc.style, " "));
285
+ lines.push(" `;");
286
+ lines.push(" }");
287
+ lines.push("");
288
+
289
+ // connectedCallback
290
+ lines.push(" connectedCallback() {");
291
+ lines.push(" for (const key of Object.keys(this.state)) {");
292
+ lines.push(" if (key in this && this[key] !== undefined) {");
293
+ lines.push(" this.state[key] = this[key];");
294
+ lines.push(" }");
295
+ lines.push(" }");
296
+ if (doc.style && typeof doc.style === "object") {
297
+ /** @type {[string, any][]} */
298
+ const staticStyles = [];
299
+ /** @type {[string, string][]} */
300
+ const dynamicStyles = [];
301
+ for (const [prop, value] of Object.entries(doc.style)) {
302
+ if (
303
+ prop.startsWith(":") ||
304
+ prop.startsWith(".") ||
305
+ prop.startsWith("&") ||
306
+ prop.startsWith("[") ||
307
+ prop.startsWith("@")
308
+ )
309
+ continue;
310
+ if (value === null || typeof value === "object") continue;
311
+ const cssProp = camelToKebab(prop);
312
+ if (typeof value === "string" && value.includes("${")) {
313
+ dynamicStyles.push([cssProp, value]);
314
+ } else {
315
+ staticStyles.push([cssProp, value]);
316
+ }
317
+ }
318
+ if (staticStyles.length > 0) {
319
+ for (const [cssProp, value] of staticStyles) {
320
+ lines.push(` this.style['${cssProp}'] = ${JSON.stringify(value)};`);
321
+ }
322
+ }
323
+ if (dynamicStyles.length > 0) {
324
+ lines.push(" effect(() => {");
325
+ for (const [cssProp, value] of dynamicStyles) {
326
+ const expr = value.replace(
327
+ /\$\{([^}]+)\}/g,
328
+ (/** @type {string} */ _, /** @type {string} */ e) =>
329
+ "${" + e.replace(/state\./g, "this.state.") + "}",
330
+ );
331
+ lines.push(` this.style['${cssProp}'] = \`${expr}\`;`);
332
+ }
333
+ lines.push(" });");
334
+ }
335
+ }
336
+ lines.push(" this.#dispose = effect(() => render(this.template(), this));");
337
+ lines.push(" }");
338
+ lines.push("");
339
+
340
+ // disconnectedCallback
341
+ lines.push(" disconnectedCallback() {");
342
+ lines.push(" if (this.#dispose) { this.#dispose(); this.#dispose = null; }");
343
+ lines.push(" }");
344
+
345
+ lines.push("}");
346
+ lines.push("");
347
+ lines.push(`customElements.define('${doc.tagName}', ${className});`);
348
+ lines.push("");
349
+
350
+ return lines.join("\n");
351
+ }
352
+
353
+ /**
354
+ * Convert Jx children to lit-html template content.
355
+ *
356
+ * @param {any} children
357
+ * @param {any} parentStyle
358
+ * @param {string} indent
359
+ * @returns {string}
360
+ */
361
+ function emitLitChildren(children, parentStyle, indent) {
362
+ if (!children) return "";
363
+
364
+ if (children.$prototype === "Array") {
365
+ return emitMappedArray(children, indent);
366
+ }
367
+
368
+ if (!Array.isArray(children)) return "";
369
+
370
+ return children.map((/** @type {any} */ child) => emitLitNode(child, indent)).join("\n");
371
+ }
372
+
373
+ /**
374
+ * @param {any} def
375
+ * @param {string} indent
376
+ * @returns {string}
377
+ */
378
+ function emitLitNode(def, indent) {
379
+ // String children are text nodes
380
+ if (typeof def === "string") {
381
+ return `${indent}${escapeHtml(def)}`;
382
+ }
383
+ if (typeof def === "number" || typeof def === "boolean") {
384
+ return `${indent}${escapeHtml(String(def))}`;
385
+ }
386
+ if (!def || typeof def !== "object") return "";
387
+
388
+ const tag = def.tagName ?? "div";
389
+
390
+ /** @type {string[]} */
391
+ const parts = [];
392
+
393
+ if (def.attributes) {
394
+ for (const [key, val] of Object.entries(def.attributes)) {
395
+ if (typeof val === "string" && val.includes("${")) {
396
+ parts.push(`${key}="${toLitExpr(val)}"`);
397
+ } else {
398
+ parts.push(`${key}="${val}"`);
399
+ }
400
+ }
401
+ }
402
+
403
+ if (def.id) parts.push(`id="${def.id}"`);
404
+ if (def.className) parts.push(`class="${def.className}"`);
405
+
406
+ for (const [key, val] of Object.entries(def)) {
407
+ if (
408
+ RESERVED_KEYS.has(key) ||
409
+ key.startsWith("$") ||
410
+ key.startsWith("on") ||
411
+ key === "tagName" ||
412
+ key === "id" ||
413
+ key === "className" ||
414
+ key === "style" ||
415
+ key === "children" ||
416
+ key === "textContent" ||
417
+ key === "innerHTML" ||
418
+ key === "attributes"
419
+ )
420
+ continue;
421
+
422
+ if (val && typeof val === "object" && /** @type {any} */ (val).$ref) {
423
+ parts.push(`.${key}="\${${refToExpr(/** @type {any} */ (val).$ref)}}"`);
424
+ } else if (typeof val === "string" && val.includes("${")) {
425
+ parts.push(`.${key}="${toLitExpr(val)}"`);
426
+ }
427
+ }
428
+
429
+ if (def.$props) {
430
+ for (const [key, val] of Object.entries(def.$props)) {
431
+ if (val && typeof val === "object" && /** @type {any} */ (val).$ref) {
432
+ parts.push(`.${key}="\${${refToExpr(/** @type {any} */ (val).$ref)}}"`);
433
+ } else {
434
+ parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
435
+ }
436
+ }
437
+ }
438
+
439
+ for (const [key, val] of Object.entries(def)) {
440
+ if (!key.startsWith("on") || key === "observedAttributes") continue;
441
+ const eventName = key.slice(2).toLowerCase();
442
+ if (val && typeof val === "object" && /** @type {any} */ (val).$ref) {
443
+ parts.push(`@${eventName}="\${(e) => ${refToExpr(/** @type {any} */ (val).$ref)}(s, e)}"`);
444
+ } else if (
445
+ val &&
446
+ typeof val === "object" &&
447
+ /** @type {any} */ (val).$prototype === "Function"
448
+ ) {
449
+ parts.push(`@${eventName}="\${(e) => { ${inlineHandlerBody(/** @type {any} */ (val))} }}"`);
450
+ }
451
+ }
452
+
453
+ const styleStr = emitStyleString(def.style);
454
+ if (styleStr) parts.push(`style="${styleStr}"`);
455
+
456
+ const attrsStr = parts.length > 0 ? "\n" + indent + " " + parts.join("\n" + indent + " ") : "";
457
+
458
+ const selfClosing = new Set(["input", "br", "hr", "img", "meta", "link"]);
459
+ if (selfClosing.has(tag)) {
460
+ return `${indent}<${tag}${attrsStr}\n${indent}>`;
461
+ }
462
+
463
+ let inner = "";
464
+ if (def.textContent !== undefined) {
465
+ inner = toLitTextContent(def.textContent);
466
+ } else if (def.innerHTML !== undefined) {
467
+ inner = def.innerHTML;
468
+ } else if (def.children) {
469
+ inner = "\n" + emitLitChildren(def.children, def.style, indent + " ") + "\n" + indent;
470
+ }
471
+
472
+ return `${indent}<${tag}${attrsStr}\n${indent}>${inner}</${tag}>`;
473
+ }
474
+
475
+ /**
476
+ * @param {any} arrayDef
477
+ * @param {string} indent
478
+ * @returns {string}
479
+ */
480
+ function emitMappedArray(arrayDef, indent) {
481
+ const itemsExpr = arrayDef.items?.$ref ? refToExpr(arrayDef.items.$ref) : "ITEMS";
482
+ const mapDef = arrayDef.map;
483
+
484
+ if (!mapDef) return "";
485
+
486
+ const tag = mapDef.tagName ?? "div";
487
+ /** @type {string[]} */
488
+ const parts = [];
489
+
490
+ if (mapDef.$props) {
491
+ for (const [key, val] of Object.entries(mapDef.$props)) {
492
+ if (val && typeof val === "object" && /** @type {any} */ (val).$ref) {
493
+ parts.push(`.${key}="\${${mapRefToExpr(/** @type {any} */ (val).$ref)}}"`);
494
+ } else {
495
+ parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
496
+ }
497
+ }
498
+ }
499
+
500
+ const styleStr = emitStyleString(mapDef.style);
501
+ if (styleStr) parts.push(`style="${styleStr}"`);
502
+
503
+ for (const [key, val] of Object.entries(mapDef)) {
504
+ if (!key.startsWith("on")) continue;
505
+ const eventName = key.slice(2).toLowerCase();
506
+ if (val && typeof val === "object" && /** @type {any} */ (val).$ref) {
507
+ parts.push(`@${eventName}="\${(e) => ${refToExpr(/** @type {any} */ (val).$ref)}(s, e)}"`);
508
+ }
509
+ }
510
+
511
+ const attrsStr =
512
+ parts.length > 0 ? "\n" + indent + " " + parts.join("\n" + indent + " ") : "";
513
+
514
+ let inner = "";
515
+ if (mapDef.textContent !== undefined) {
516
+ inner = toLitTextContent(mapDef.textContent);
517
+ } else if (mapDef.children) {
518
+ inner =
519
+ "\n" + emitLitChildren(mapDef.children, null, indent + " ") + "\n" + indent + " ";
520
+ }
521
+
522
+ return `${indent}\${${itemsExpr}.map((item, index) => html\`\n${indent} <${tag}${attrsStr}\n${indent} >${inner}</${tag}>\n${indent}\`)}`;
523
+ }
524
+
525
+ /**
526
+ * Convert a $ref string to a JS expression using `s` (this.state alias).
527
+ *
528
+ * @param {string} ref
529
+ * @returns {string}
530
+ */
531
+ function refToExpr(ref) {
532
+ if (ref.startsWith("#/state/")) {
533
+ const path = ref.slice("#/state/".length);
534
+ return "s." + path.replace(/\//g, ".");
535
+ }
536
+ if (ref.startsWith("$map/")) {
537
+ const path = ref.slice("$map/".length);
538
+ return path.replace(/\//g, ".");
539
+ }
540
+ return "s." + ref;
541
+ }
542
+
543
+ /**
544
+ * @param {string} ref
545
+ * @returns {string}
546
+ */
547
+ function mapRefToExpr(ref) {
548
+ if (ref.startsWith("$map/")) {
549
+ return ref.slice("$map/".length).replace(/\//g, ".");
550
+ }
551
+ return refToExpr(ref);
552
+ }
553
+
554
+ /**
555
+ * @param {string} str
556
+ * @returns {string}
557
+ */
558
+ function toLitExpr(str) {
559
+ return str.replace(/state\./g, "s.");
560
+ }
561
+
562
+ /**
563
+ * Convert textContent value to lit-html text content. Bug fix: handles $ref objects, which
564
+ * previously produced [object Object].
565
+ *
566
+ * @param {any} value
567
+ * @returns {string}
568
+ */
569
+ function toLitTextContent(value) {
570
+ // Handle $ref objects → emit as lit expression
571
+ if (value !== null && typeof value === "object" && typeof value.$ref === "string") {
572
+ return `\${${refToExpr(value.$ref)}}`;
573
+ }
574
+ if (typeof value === "string" && value.includes("${")) {
575
+ return toLitExpr(value);
576
+ }
577
+ return String(value);
578
+ }
579
+
580
+ /**
581
+ * @param {any} def
582
+ * @returns {string}
583
+ */
584
+ function inlineHandlerBody(def) {
585
+ const body = def.body ?? "";
586
+ return body.replace(/(?<!this\.)state\./g, "s.").replace(/(?<!this\.)state(?!\.)/g, "s");
587
+ }
588
+
589
+ /**
590
+ * @param {any} styleDef
591
+ * @returns {string}
592
+ */
593
+ function emitStyleString(styleDef) {
594
+ if (!styleDef || typeof styleDef !== "object") return "";
595
+
596
+ /** @type {string[]} */
597
+ const parts = [];
598
+ for (const [prop, value] of Object.entries(styleDef)) {
599
+ if (
600
+ prop.startsWith(":") ||
601
+ prop.startsWith(".") ||
602
+ prop.startsWith("&") ||
603
+ prop.startsWith("[") ||
604
+ prop.startsWith("@")
605
+ )
606
+ continue;
607
+
608
+ if (value === null || typeof value === "object") continue;
609
+
610
+ const cssProp = camelToKebab(prop);
611
+ if (typeof value === "string" && value.includes("${")) {
612
+ parts.push(`${cssProp}: ${toLitExpr(value)}`);
613
+ } else {
614
+ parts.push(`${cssProp}: ${value}`);
615
+ }
616
+ }
617
+
618
+ return parts.join("; ");
619
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Compile-server.js — Hono server handler compilation
3
+ *
4
+ * Compiles `timing: "server"` entries from a Jx document into a deployable Hono server handler
5
+ * file.
6
+ */
7
+
8
+ import $RefParser from "@apidevtools/json-schema-ref-parser";
9
+ import { collectServerEntries } from "../shared.js";
10
+
11
+ /**
12
+ * Compile a Jx document to a Hono server handler file. The handler exposes each `timing: "server"`
13
+ * entry as a POST endpoint under `/_jx/server/$export`. Returns null if no server entries are
14
+ * found.
15
+ *
16
+ * @example
17
+ * const server = await compileServer("./dashboard.json");
18
+ * if (server) fs.writeFileSync("dist/server.js", server);
19
+ *
20
+ * @param {string | object} sourcePath - Path to .json file, URL, or raw object
21
+ * @param {object} [opts]
22
+ * @param {string} [opts.baseUrl] - Base path for server endpoints. Default is `'/_jx/server'`
23
+ * @returns {Promise<string | null>} Hono server handler source string, or null
24
+ */
25
+ export async function compileServer(sourcePath, opts = {}) {
26
+ const { baseUrl = "/_jx/server" } = opts;
27
+ const doc = await $RefParser.dereference(sourcePath);
28
+ const entries = collectServerEntries(doc);
29
+ if (entries.length === 0) return null;
30
+
31
+ const imports = entries
32
+ .map(({ exportName, src }) => `import { ${exportName} } from '${src}'`)
33
+ .join("\n");
34
+
35
+ const routes = entries
36
+ .map(
37
+ ({ exportName }) => `
38
+ app.post('${baseUrl}/${exportName}', async (c) => {
39
+ const args = await c.req.json().catch(() => ({}))
40
+ return c.json(await ${exportName}(args))
41
+ })`,
42
+ )
43
+ .join("\n");
44
+
45
+ return `// Generated by @jxsuite/compiler — do not edit manually
46
+ // Deploy as a Cloudflare Worker, Node server, or Bun process.
47
+ // Requires: npm install hono
48
+
49
+ import { Hono } from 'hono'
50
+ ${imports}
51
+
52
+ const app = new Hono()
53
+ ${routes}
54
+
55
+ export default app
56
+ `;
57
+ }