@constela/server 3.0.0 → 4.0.0

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/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # @constela/server
2
+
3
+ Server-side rendering (SSR) for Constela JSON programs.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @constela/server
9
+ ```
10
+
11
+ **Peer Dependencies:**
12
+ - `@constela/compiler` ^0.7.0
13
+
14
+ ## How It Works
15
+
16
+ JSON program → HTML string
17
+
18
+ ```json
19
+ {
20
+ "version": "1.0",
21
+ "state": { "name": { "type": "string", "initial": "World" } },
22
+ "view": {
23
+ "kind": "element",
24
+ "tag": "h1",
25
+ "children": [
26
+ { "kind": "text", "value": { "expr": "lit", "value": "Hello, " } },
27
+ { "kind": "text", "value": { "expr": "state", "name": "name" } }
28
+ ]
29
+ }
30
+ }
31
+ ```
32
+
33
+ ↓ SSR
34
+
35
+ ```html
36
+ <h1>Hello, World</h1>
37
+ ```
38
+
39
+ ## Features
40
+
41
+ ### Markdown Rendering
42
+
43
+ ```json
44
+ {
45
+ "kind": "markdown",
46
+ "content": { "expr": "data", "name": "article", "path": "content" }
47
+ }
48
+ ```
49
+
50
+ Rendered with async parsing and Shiki syntax highlighting.
51
+
52
+ ### Code Highlighting
53
+
54
+ ```json
55
+ {
56
+ "kind": "code",
57
+ "code": { "expr": "lit", "value": "const x = 1;" },
58
+ "language": { "expr": "lit", "value": "typescript" }
59
+ }
60
+ ```
61
+
62
+ Features:
63
+ - Dual theme support (github-light, github-dark)
64
+ - CSS custom properties for theme switching
65
+ - Preloaded languages: javascript, typescript, json, html, css, python, rust, go, java, bash, markdown
66
+
67
+ ### Route Context
68
+
69
+ Pass route parameters for dynamic pages:
70
+
71
+ ```json
72
+ {
73
+ "route": { "path": "/users/:id" },
74
+ "view": {
75
+ "kind": "text",
76
+ "value": { "expr": "route", "name": "id", "source": "param" }
77
+ }
78
+ }
79
+ ```
80
+
81
+ ### Import Data
82
+
83
+ Pass external data at render time:
84
+
85
+ ```json
86
+ {
87
+ "imports": { "config": "./data/config.json" },
88
+ "view": {
89
+ "kind": "text",
90
+ "value": { "expr": "import", "name": "config", "path": "siteName" }
91
+ }
92
+ }
93
+ ```
94
+
95
+ ## Output Structure
96
+
97
+ ### Code Block HTML
98
+
99
+ ```html
100
+ <div class="constela-code" data-code-content="...">
101
+ <div class="group relative">
102
+ <div class="language-badge">typescript</div>
103
+ <button class="constela-copy-btn"><!-- Copy icon --></button>
104
+ <pre><code class="shiki">...</code></pre>
105
+ </div>
106
+ </div>
107
+ ```
108
+
109
+ ### CSS Variables
110
+
111
+ ```css
112
+ /* Light mode */
113
+ .shiki { background-color: var(--shiki-light-bg); }
114
+ .shiki span { color: var(--shiki-light); }
115
+
116
+ /* Dark mode */
117
+ .dark .shiki { background-color: var(--shiki-dark-bg); }
118
+ .dark .shiki span { color: var(--shiki-dark); }
119
+ ```
120
+
121
+ ## Security
122
+
123
+ - **HTML Escaping** - All text output is escaped
124
+ - **DOMPurify** - Markdown content is sanitized
125
+ - **Prototype Pollution Prevention** - Same as runtime
126
+
127
+ ## Internal API
128
+
129
+ > For framework developers only. End users should use the CLI.
130
+
131
+ ### renderToString
132
+
133
+ ```typescript
134
+ import { renderToString } from '@constela/server';
135
+
136
+ const html = await renderToString(compiledProgram, {
137
+ route: {
138
+ params: { id: '123' },
139
+ query: { tab: 'overview' },
140
+ path: '/users/123',
141
+ },
142
+ imports: {
143
+ config: { siteName: 'My Site' },
144
+ },
145
+ });
146
+ ```
147
+
148
+ **RenderOptions:**
149
+
150
+ ```typescript
151
+ interface RenderOptions {
152
+ route?: {
153
+ params?: Record<string, string>;
154
+ query?: Record<string, string>;
155
+ path?: string;
156
+ };
157
+ imports?: Record<string, unknown>;
158
+ }
159
+ ```
160
+
161
+ ## Integration with @constela/runtime
162
+
163
+ Server-rendered HTML can be hydrated on the client:
164
+
165
+ ```json
166
+ {
167
+ "version": "1.0",
168
+ "lifecycle": { "onMount": "initializeClient" },
169
+ "state": { ... },
170
+ "actions": [
171
+ {
172
+ "name": "initializeClient",
173
+ "steps": [
174
+ { "do": "storage", "operation": "get", "key": { "expr": "lit", "value": "preferences" }, ... }
175
+ ]
176
+ }
177
+ ],
178
+ "view": { ... }
179
+ }
180
+ ```
181
+
182
+ ## License
183
+
184
+ MIT
package/dist/index.d.ts CHANGED
@@ -6,12 +6,24 @@ import { CompiledProgram } from '@constela/compiler';
6
6
  * Renders CompiledProgram to HTML string for Server-Side Rendering.
7
7
  */
8
8
 
9
+ /**
10
+ * Options for renderToString
11
+ */
12
+ interface RenderOptions {
13
+ route?: {
14
+ params?: Record<string, string>;
15
+ query?: Record<string, string>;
16
+ path?: string;
17
+ };
18
+ imports?: Record<string, unknown>;
19
+ }
9
20
  /**
10
21
  * Renders a CompiledProgram to an HTML string.
11
22
  *
12
23
  * @param program - The compiled program to render
24
+ * @param options - Optional render options including route context
13
25
  * @returns Promise that resolves to HTML string representation
14
26
  */
15
- declare function renderToString(program: CompiledProgram): Promise<string>;
27
+ declare function renderToString(program: CompiledProgram, options?: RenderOptions): Promise<string>;
16
28
 
17
- export { renderToString };
29
+ export { type RenderOptions, renderToString };
package/dist/index.js CHANGED
@@ -1,15 +1,7 @@
1
1
  // src/markdown.ts
2
2
  import { marked } from "marked";
3
+ import markedShiki from "marked-shiki";
3
4
  import DOMPurify from "isomorphic-dompurify";
4
- marked.setOptions({ gfm: true, breaks: false });
5
- function parseMarkdownSSR(content) {
6
- const rawHtml = marked.parse(content, { async: false });
7
- return DOMPurify.sanitize(rawHtml, {
8
- USE_PROFILES: { html: true },
9
- FORBID_TAGS: ["script", "style", "iframe"],
10
- FORBID_ATTR: ["onerror", "onload", "onclick"]
11
- });
12
- }
13
5
 
14
6
  // src/code.ts
15
7
  import { createHighlighter } from "shiki";
@@ -45,7 +37,7 @@ var PRELOAD_LANGS = [
45
37
  async function getHighlighter() {
46
38
  if (!highlighter) {
47
39
  highlighter = await createHighlighter({
48
- themes: ["github-dark"],
40
+ themes: ["github-light", "github-dark"],
49
41
  langs: PRELOAD_LANGS
50
42
  });
51
43
  }
@@ -59,7 +51,15 @@ async function renderCodeSSR(code, language) {
59
51
  await hl.loadLanguage(language);
60
52
  }
61
53
  const langToUse = language || "text";
62
- return hl.codeToHtml(code, { lang: langToUse, theme: "github-dark" });
54
+ const html = hl.codeToHtml(code, {
55
+ lang: langToUse,
56
+ themes: {
57
+ light: "github-light",
58
+ dark: "github-dark"
59
+ },
60
+ defaultColor: false
61
+ });
62
+ return html.replace(/background-color:[^;]+;?/g, "");
63
63
  } catch {
64
64
  const escapedCode = escapeHtml(code);
65
65
  const langClass = language ? ` class="language-${language}"` : "";
@@ -67,6 +67,44 @@ async function renderCodeSSR(code, language) {
67
67
  }
68
68
  }
69
69
 
70
+ // src/markdown.ts
71
+ var markedConfigured = false;
72
+ async function configureMarked() {
73
+ if (markedConfigured) return;
74
+ const highlighter2 = await getHighlighter();
75
+ marked.use(
76
+ markedShiki({
77
+ highlight: async (code, lang) => {
78
+ const loadedLangs = highlighter2.getLoadedLanguages();
79
+ let langToUse = lang || "text";
80
+ if (lang && !loadedLangs.includes(lang) && lang !== "text") {
81
+ try {
82
+ await highlighter2.loadLanguage(lang);
83
+ } catch {
84
+ langToUse = "text";
85
+ }
86
+ }
87
+ const html = highlighter2.codeToHtml(code, {
88
+ lang: langToUse,
89
+ theme: "github-dark"
90
+ });
91
+ return html.replace(/background-color:[^;]+;?/g, "");
92
+ }
93
+ })
94
+ );
95
+ marked.setOptions({ gfm: true, breaks: false });
96
+ markedConfigured = true;
97
+ }
98
+ async function parseMarkdownSSRAsync(content) {
99
+ await configureMarked();
100
+ const rawHtml = await marked.parse(content, { async: true });
101
+ return DOMPurify.sanitize(rawHtml, {
102
+ USE_PROFILES: { html: true },
103
+ FORBID_TAGS: ["script", "style", "iframe"],
104
+ FORBID_ATTR: ["onerror", "onload", "onclick"]
105
+ });
106
+ }
107
+
70
108
  // src/renderer.ts
71
109
  var VOID_ELEMENTS = /* @__PURE__ */ new Set([
72
110
  "area",
@@ -157,8 +195,30 @@ function evaluate(expr, ctx) {
157
195
  }
158
196
  return importData;
159
197
  }
198
+ case "data": {
199
+ const dataValue = ctx.imports?.[expr.name];
200
+ if (dataValue === void 0) return void 0;
201
+ if (expr.path) {
202
+ return getNestedValue(dataValue, expr.path);
203
+ }
204
+ return dataValue;
205
+ }
160
206
  case "ref":
161
207
  return null;
208
+ case "index": {
209
+ const forbiddenKeys = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
210
+ const base = evaluate(expr.base, ctx);
211
+ const key = evaluate(expr.key, ctx);
212
+ if (base == null || key == null) return void 0;
213
+ if (typeof key === "string" && forbiddenKeys.has(key)) return void 0;
214
+ return base[key];
215
+ }
216
+ case "param": {
217
+ return void 0;
218
+ }
219
+ case "style": {
220
+ return "";
221
+ }
162
222
  default: {
163
223
  const _exhaustiveCheck = expr;
164
224
  throw new Error(`Unknown expression type: ${JSON.stringify(_exhaustiveCheck)}`);
@@ -270,9 +330,11 @@ async function renderNode(node, ctx) {
270
330
  case "each":
271
331
  return await renderEach(node, ctx);
272
332
  case "markdown":
273
- return renderMarkdown(node, ctx);
333
+ return await renderMarkdown(node, ctx);
274
334
  case "code":
275
335
  return await renderCode(node, ctx);
336
+ case "slot":
337
+ return "";
276
338
  default: {
277
339
  const _exhaustiveCheck = node;
278
340
  throw new Error(`Unknown node kind: ${JSON.stringify(_exhaustiveCheck)}`);
@@ -350,25 +412,33 @@ async function renderEach(node, ctx) {
350
412
  }
351
413
  return result;
352
414
  }
353
- function renderMarkdown(node, ctx) {
415
+ async function renderMarkdown(node, ctx) {
354
416
  const content = evaluate(node.content, ctx);
355
- const html = parseMarkdownSSR(formatValue(content));
417
+ const html = await parseMarkdownSSRAsync(formatValue(content));
356
418
  return `<div class="constela-markdown">${html}</div>`;
357
419
  }
358
420
  async function renderCode(node, ctx) {
359
421
  const language = formatValue(evaluate(node.language, ctx));
360
422
  const content = formatValue(evaluate(node.content, ctx));
361
- const html = await renderCodeSSR(content, language);
362
- return `<div class="constela-code">${html}</div>`;
423
+ const highlightedCode = await renderCodeSSR(content, language);
424
+ const languageBadge = language ? `<div class="absolute right-12 top-3 z-10 rounded bg-muted-foreground/20 px-2 py-0.5 text-xs font-medium text-muted-foreground">${escapeHtml(language)}</div>` : "";
425
+ const copyButton = `<button class="constela-copy-btn absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-md border border-border bg-background/80 opacity-0 transition-opacity hover:bg-muted group-hover:opacity-100" data-copy-target="code" aria-label="Copy code"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>`;
426
+ return `<div class="constela-code" data-code-content="${escapeHtml(content)}"><div class="group relative">${languageBadge}${copyButton}${highlightedCode}</div></div>`;
363
427
  }
364
- async function renderToString(program) {
428
+ async function renderToString(program, options) {
365
429
  const state = /* @__PURE__ */ new Map();
366
430
  for (const [name, field] of Object.entries(program.state)) {
367
431
  state.set(name, field.initial);
368
432
  }
369
433
  const ctx = {
370
434
  state,
371
- locals: {}
435
+ locals: {},
436
+ route: options?.route ? {
437
+ params: options.route.params ?? {},
438
+ query: options.route.query ?? {},
439
+ path: options.route.path ?? ""
440
+ } : void 0,
441
+ imports: options?.imports ?? program.importData
372
442
  };
373
443
  return await renderNode(program.view, ctx);
374
444
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constela/server",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Server-side rendering for Constela UI framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,11 +15,12 @@
15
15
  "dist"
16
16
  ],
17
17
  "peerDependencies": {
18
- "@constela/compiler": "^0.7.0"
18
+ "@constela/compiler": "^0.8.0"
19
19
  },
20
20
  "dependencies": {
21
21
  "isomorphic-dompurify": "^2.35.0",
22
22
  "marked": "^17.0.1",
23
+ "marked-shiki": "^1.2.0",
23
24
  "shiki": "^1.0.0"
24
25
  },
25
26
  "devDependencies": {
@@ -27,7 +28,7 @@
27
28
  "tsup": "^8.0.0",
28
29
  "typescript": "^5.3.0",
29
30
  "vitest": "^2.0.0",
30
- "@constela/compiler": "0.7.0"
31
+ "@constela/compiler": "0.8.0"
31
32
  },
32
33
  "engines": {
33
34
  "node": ">=20.0.0"