@constela/server 3.0.0 → 3.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.
package/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # @constela/server
2
+
3
+ Server-side rendering (SSR) for the Constela UI framework.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @constela/server
9
+ ```
10
+
11
+ **Peer Dependencies:**
12
+ - `@constela/compiler` ^0.7.0
13
+
14
+ ## Overview
15
+
16
+ This package provides SSR capabilities for Constela applications. Features:
17
+
18
+ - **HTML Generation** - Render programs to HTML strings
19
+ - **Route Context** - Pass route params and query strings
20
+ - **Markdown & Code** - Server-side Markdown and syntax highlighting
21
+ - **Dual Theme** - Light and dark theme code blocks
22
+
23
+ ## API Reference
24
+
25
+ ### renderToString
26
+
27
+ Main SSR function that renders a compiled program to HTML.
28
+
29
+ ```typescript
30
+ import { renderToString } from '@constela/server';
31
+
32
+ const html = await renderToString(compiledProgram, {
33
+ route: {
34
+ params: { id: '123' },
35
+ query: { tab: 'overview' },
36
+ path: '/users/123',
37
+ },
38
+ imports: {
39
+ config: { siteName: 'My Site' },
40
+ },
41
+ });
42
+ ```
43
+
44
+ **Parameters:**
45
+ - `program: CompiledProgram` - Compiled program from `@constela/compiler`
46
+ - `options?: RenderOptions` - Optional render configuration
47
+
48
+ **RenderOptions:**
49
+ ```typescript
50
+ interface RenderOptions {
51
+ route?: {
52
+ params?: Record<string, string>;
53
+ query?: Record<string, string>;
54
+ path?: string;
55
+ };
56
+ imports?: Record<string, unknown>;
57
+ }
58
+ ```
59
+
60
+ **Returns:** `Promise<string>` - Complete HTML string
61
+
62
+ ## Internal Features
63
+
64
+ The package internally handles:
65
+
66
+ **Markdown Rendering:**
67
+ - Async parsing with Shiki syntax highlighting
68
+ - Sanitization via DOMPurify
69
+
70
+ **Code Highlighting:**
71
+ - Dual theme support (github-light, github-dark)
72
+ - CSS custom properties for theme switching
73
+ - Preloaded languages: javascript, typescript, json, html, css, python, rust, go, java, bash, markdown
74
+
75
+ **CSS Variables:** Code blocks use CSS custom properties:
76
+ ```css
77
+ /* Light mode */
78
+ .shiki { background-color: var(--shiki-light-bg); }
79
+ .shiki span { color: var(--shiki-light); }
80
+
81
+ /* Dark mode */
82
+ .dark .shiki { background-color: var(--shiki-dark-bg); }
83
+ .dark .shiki span { color: var(--shiki-dark); }
84
+ ```
85
+
86
+ ## Output Structure
87
+
88
+ ### Code Block HTML
89
+
90
+ ```html
91
+ <div class="constela-code" data-code-content="...">
92
+ <div class="group relative">
93
+ <div class="language-badge">typescript</div>
94
+ <button class="constela-copy-btn">
95
+ <!-- Copy icon SVG -->
96
+ </button>
97
+ <pre><code class="shiki">...</code></pre>
98
+ </div>
99
+ </div>
100
+ ```
101
+
102
+ The `data-code-content` attribute contains the raw code for copy functionality.
103
+
104
+ ## Expression Evaluation
105
+
106
+ SSR evaluates expressions server-side with some limitations:
107
+
108
+ - **No DOM refs** - `ref` expressions return `null`
109
+ - **No safe globals** - Limited to basic operations
110
+ - **Static only** - No reactive updates
111
+
112
+ ## Security
113
+
114
+ - **HTML Escaping** - All text output is escaped
115
+ - **DOMPurify** - Markdown content is sanitized
116
+ - **Prototype Pollution Prevention** - Same as runtime
117
+
118
+ ## Example
119
+
120
+ ```typescript
121
+ import { compile } from '@constela/compiler';
122
+ import { renderToString } from '@constela/server';
123
+
124
+ const program = compile({
125
+ version: '1.0',
126
+ state: { name: { type: 'string', initial: 'World' } },
127
+ actions: [],
128
+ view: {
129
+ kind: 'element',
130
+ tag: 'h1',
131
+ children: [
132
+ { kind: 'text', value: { expr: 'lit', value: 'Hello, ' } },
133
+ { kind: 'text', value: { expr: 'state', name: 'name' } },
134
+ ],
135
+ },
136
+ });
137
+
138
+ if (program.ok) {
139
+ const html = await renderToString(program.program);
140
+ console.log(html); // '<h1>Hello, World</h1>'
141
+ }
142
+ ```
143
+
144
+ ## Integration with @constela/runtime
145
+
146
+ Server-rendered HTML can be hydrated on the client:
147
+
148
+ ```typescript
149
+ // Server
150
+ import { renderToString } from '@constela/server';
151
+ const html = await renderToString(program, { route, imports });
152
+
153
+ // Client
154
+ import { hydrateApp } from '@constela/runtime';
155
+ hydrateApp({ program, mount, route, imports });
156
+ ```
157
+
158
+ ## License
159
+
160
+ 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,24 @@ 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
+ }
162
216
  default: {
163
217
  const _exhaustiveCheck = expr;
164
218
  throw new Error(`Unknown expression type: ${JSON.stringify(_exhaustiveCheck)}`);
@@ -270,9 +324,11 @@ async function renderNode(node, ctx) {
270
324
  case "each":
271
325
  return await renderEach(node, ctx);
272
326
  case "markdown":
273
- return renderMarkdown(node, ctx);
327
+ return await renderMarkdown(node, ctx);
274
328
  case "code":
275
329
  return await renderCode(node, ctx);
330
+ case "slot":
331
+ return "";
276
332
  default: {
277
333
  const _exhaustiveCheck = node;
278
334
  throw new Error(`Unknown node kind: ${JSON.stringify(_exhaustiveCheck)}`);
@@ -350,25 +406,33 @@ async function renderEach(node, ctx) {
350
406
  }
351
407
  return result;
352
408
  }
353
- function renderMarkdown(node, ctx) {
409
+ async function renderMarkdown(node, ctx) {
354
410
  const content = evaluate(node.content, ctx);
355
- const html = parseMarkdownSSR(formatValue(content));
411
+ const html = await parseMarkdownSSRAsync(formatValue(content));
356
412
  return `<div class="constela-markdown">${html}</div>`;
357
413
  }
358
414
  async function renderCode(node, ctx) {
359
415
  const language = formatValue(evaluate(node.language, ctx));
360
416
  const content = formatValue(evaluate(node.content, ctx));
361
- const html = await renderCodeSSR(content, language);
362
- return `<div class="constela-code">${html}</div>`;
417
+ const highlightedCode = await renderCodeSSR(content, language);
418
+ 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>` : "";
419
+ 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>`;
420
+ return `<div class="constela-code" data-code-content="${escapeHtml(content)}"><div class="group relative">${languageBadge}${copyButton}${highlightedCode}</div></div>`;
363
421
  }
364
- async function renderToString(program) {
422
+ async function renderToString(program, options) {
365
423
  const state = /* @__PURE__ */ new Map();
366
424
  for (const [name, field] of Object.entries(program.state)) {
367
425
  state.set(name, field.initial);
368
426
  }
369
427
  const ctx = {
370
428
  state,
371
- locals: {}
429
+ locals: {},
430
+ route: options?.route ? {
431
+ params: options.route.params ?? {},
432
+ query: options.route.query ?? {},
433
+ path: options.route.path ?? ""
434
+ } : void 0,
435
+ imports: options?.imports ?? program.importData
372
436
  };
373
437
  return await renderNode(program.view, ctx);
374
438
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constela/server",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Server-side rendering for Constela UI framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,6 +20,7 @@
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": {