@constela/server 2.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 +160 -0
- package/dist/index.d.ts +14 -2
- package/dist/index.js +82 -18
- package/package.json +4 -3
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
|
-
|
|
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 =
|
|
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
|
|
362
|
-
|
|
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
|
+
"version": "3.0.1",
|
|
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.
|
|
18
|
+
"@constela/compiler": "^0.7.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.
|
|
31
|
+
"@constela/compiler": "0.7.0"
|
|
31
32
|
},
|
|
32
33
|
"engines": {
|
|
33
34
|
"node": ">=20.0.0"
|