@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 +184 -0
- package/dist/index.d.ts +14 -2
- package/dist/index.js +88 -18
- package/package.json +4 -3
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
|
-
|
|
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 =
|
|
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
|
|
362
|
-
|
|
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
|
+
"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.
|
|
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.
|
|
31
|
+
"@constela/compiler": "0.8.0"
|
|
31
32
|
},
|
|
32
33
|
"engines": {
|
|
33
34
|
"node": ">=20.0.0"
|