@constela/runtime 0.10.1 → 0.10.3
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 +306 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +59 -9
- package/package.json +3 -3
package/README.md
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# @constela/runtime
|
|
2
|
+
|
|
3
|
+
Runtime DOM renderer for the Constela UI framework with fine-grained reactivity.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @constela/runtime
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Overview
|
|
12
|
+
|
|
13
|
+
This package provides the client-side rendering engine for Constela applications. Key features:
|
|
14
|
+
|
|
15
|
+
- **Fine-grained Reactivity** - Signal-based updates without virtual DOM
|
|
16
|
+
- **Hydration** - Rehydrate server-rendered HTML
|
|
17
|
+
- **Markdown & Code** - Built-in Markdown and syntax highlighting support
|
|
18
|
+
|
|
19
|
+
## API Reference
|
|
20
|
+
|
|
21
|
+
### createApp
|
|
22
|
+
|
|
23
|
+
Creates and mounts a Constela application.
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { createApp } from '@constela/runtime';
|
|
27
|
+
|
|
28
|
+
const app = createApp(compiledProgram, document.getElementById('app'));
|
|
29
|
+
|
|
30
|
+
// Later: cleanup
|
|
31
|
+
app.destroy();
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Parameters:**
|
|
35
|
+
- `program: CompiledProgram` - Compiled program from `@constela/compiler`
|
|
36
|
+
- `mount: HTMLElement` - DOM element to mount to
|
|
37
|
+
|
|
38
|
+
**Returns:** `AppInstance`
|
|
39
|
+
|
|
40
|
+
### hydrateApp
|
|
41
|
+
|
|
42
|
+
Hydrates server-rendered HTML without DOM reconstruction.
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
import { hydrateApp } from '@constela/runtime';
|
|
46
|
+
|
|
47
|
+
const app = hydrateApp({
|
|
48
|
+
program: compiledProgram,
|
|
49
|
+
mount: document.getElementById('app'),
|
|
50
|
+
route: {
|
|
51
|
+
params: { id: '123' },
|
|
52
|
+
query: { tab: 'details' },
|
|
53
|
+
path: '/users/123'
|
|
54
|
+
},
|
|
55
|
+
imports: {
|
|
56
|
+
config: { apiUrl: 'https://api.example.com' }
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**HydrateOptions:**
|
|
62
|
+
- `program: CompiledProgram` - Compiled program
|
|
63
|
+
- `mount: HTMLElement` - Container element
|
|
64
|
+
- `route?: RouteContext` - Route parameters
|
|
65
|
+
- `imports?: Record<string, unknown>` - Import data
|
|
66
|
+
|
|
67
|
+
### AppInstance
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
interface AppInstance {
|
|
71
|
+
destroy(): void;
|
|
72
|
+
setState(name: string, value: unknown): void;
|
|
73
|
+
getState(name: string): unknown;
|
|
74
|
+
subscribe(name: string, fn: (value: unknown) => void): () => void;
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
#### destroy()
|
|
79
|
+
|
|
80
|
+
Cleans up the application, removes event listeners, and clears state.
|
|
81
|
+
|
|
82
|
+
#### setState(name, value)
|
|
83
|
+
|
|
84
|
+
Updates a state field programmatically.
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
app.setState('count', 10);
|
|
88
|
+
app.setState('user', { name: 'John', email: 'john@example.com' });
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
#### getState(name)
|
|
92
|
+
|
|
93
|
+
Reads current state value.
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
const count = app.getState('count');
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
#### subscribe(name, fn)
|
|
100
|
+
|
|
101
|
+
Subscribes to state changes. Returns an unsubscribe function.
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
const unsubscribe = app.subscribe('count', (value) => {
|
|
105
|
+
console.log('count changed:', value);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Later: stop listening
|
|
109
|
+
unsubscribe();
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Reactive Primitives
|
|
113
|
+
|
|
114
|
+
Low-level reactive APIs for advanced usage.
|
|
115
|
+
|
|
116
|
+
### createSignal
|
|
117
|
+
|
|
118
|
+
Creates a reactive signal with fine-grained dependency tracking.
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
import { createSignal } from '@constela/runtime';
|
|
122
|
+
|
|
123
|
+
const count = createSignal(0);
|
|
124
|
+
|
|
125
|
+
// Read value (auto-tracks in effects)
|
|
126
|
+
console.log(count.get()); // 0
|
|
127
|
+
|
|
128
|
+
// Update value
|
|
129
|
+
count.set(1);
|
|
130
|
+
|
|
131
|
+
// Subscribe to changes
|
|
132
|
+
const unsubscribe = count.subscribe((value) => {
|
|
133
|
+
console.log('Value:', value);
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### createEffect
|
|
138
|
+
|
|
139
|
+
Creates a reactive side effect that auto-tracks dependencies.
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
import { createSignal, createEffect } from '@constela/runtime';
|
|
143
|
+
|
|
144
|
+
const name = createSignal('World');
|
|
145
|
+
|
|
146
|
+
const cleanup = createEffect(() => {
|
|
147
|
+
console.log(`Hello, ${name.get()}!`);
|
|
148
|
+
|
|
149
|
+
// Optional cleanup function
|
|
150
|
+
return () => {
|
|
151
|
+
console.log('Effect cleaned up');
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
name.set('Constela'); // Logs: "Hello, Constela!"
|
|
156
|
+
|
|
157
|
+
cleanup(); // Stops the effect
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### createStateStore
|
|
161
|
+
|
|
162
|
+
Centralized state management with signal-based reactivity.
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
import { createStateStore } from '@constela/runtime';
|
|
166
|
+
|
|
167
|
+
const store = createStateStore({
|
|
168
|
+
count: { type: 'number', initial: 0 },
|
|
169
|
+
name: { type: 'string', initial: '' }
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
store.get('count'); // 0
|
|
173
|
+
store.set('count', 5);
|
|
174
|
+
store.subscribe('count', (value) => console.log(value));
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Expression Evaluation
|
|
178
|
+
|
|
179
|
+
### evaluate
|
|
180
|
+
|
|
181
|
+
Evaluates compiled expressions.
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
import { evaluate } from '@constela/runtime';
|
|
185
|
+
|
|
186
|
+
const result = evaluate(expression, {
|
|
187
|
+
state: stateStore,
|
|
188
|
+
locals: { item: { id: 1, name: 'Test' } },
|
|
189
|
+
route: { params: { id: '123' }, query: new URLSearchParams(), path: '/items/123' },
|
|
190
|
+
imports: { config: { apiUrl: '...' } },
|
|
191
|
+
data: { posts: [...] },
|
|
192
|
+
refs: { inputEl: document.querySelector('#input') }
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Supported Expressions:**
|
|
197
|
+
- Literals, state reads, variables
|
|
198
|
+
- Binary operations, logical not, conditionals
|
|
199
|
+
- Property access, array indexing
|
|
200
|
+
- Route parameters/query/path
|
|
201
|
+
- Imports and loaded data
|
|
202
|
+
- DOM refs
|
|
203
|
+
|
|
204
|
+
## Action Execution
|
|
205
|
+
|
|
206
|
+
### executeAction
|
|
207
|
+
|
|
208
|
+
Executes compiled actions.
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
import { executeAction } from '@constela/runtime';
|
|
212
|
+
|
|
213
|
+
await executeAction(action, {
|
|
214
|
+
state: stateStore,
|
|
215
|
+
locals: {},
|
|
216
|
+
route: { ... },
|
|
217
|
+
imports: { ... },
|
|
218
|
+
refs: { ... },
|
|
219
|
+
subscriptions: []
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Supported Steps:**
|
|
224
|
+
- `set`, `update` - State mutations
|
|
225
|
+
- `fetch` - HTTP requests
|
|
226
|
+
- `storage` - localStorage/sessionStorage
|
|
227
|
+
- `clipboard` - Clipboard operations
|
|
228
|
+
- `navigate` - Page navigation
|
|
229
|
+
- `import`, `call` - Dynamic imports and function calls
|
|
230
|
+
- `subscribe`, `dispose` - Event subscriptions
|
|
231
|
+
- `dom` - DOM manipulation
|
|
232
|
+
- `if` - Conditional execution
|
|
233
|
+
|
|
234
|
+
## Rendering
|
|
235
|
+
|
|
236
|
+
### render
|
|
237
|
+
|
|
238
|
+
Renders compiled nodes to DOM.
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
import { render } from '@constela/runtime';
|
|
242
|
+
|
|
243
|
+
const domNode = render(compiledNode, {
|
|
244
|
+
state: stateStore,
|
|
245
|
+
actions: compiledProgram.actions,
|
|
246
|
+
components: {},
|
|
247
|
+
locals: {},
|
|
248
|
+
route: { ... },
|
|
249
|
+
imports: { ... },
|
|
250
|
+
refs: {},
|
|
251
|
+
subscriptions: [],
|
|
252
|
+
cleanups: []
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Supported Nodes:**
|
|
257
|
+
- Element nodes with props and event handlers
|
|
258
|
+
- Text nodes with reactive updates
|
|
259
|
+
- Conditional rendering (`if/else`)
|
|
260
|
+
- List rendering (`each`)
|
|
261
|
+
- Markdown with sanitization
|
|
262
|
+
- Code blocks with Shiki highlighting
|
|
263
|
+
|
|
264
|
+
## Markdown & Code Blocks
|
|
265
|
+
|
|
266
|
+
The runtime includes built-in support for rendering Markdown and syntax-highlighted code.
|
|
267
|
+
|
|
268
|
+
### Markdown
|
|
269
|
+
|
|
270
|
+
Rendered using [marked](https://marked.js.org/) with [DOMPurify](https://github.com/cure53/DOMPurify) sanitization.
|
|
271
|
+
|
|
272
|
+
```json
|
|
273
|
+
{
|
|
274
|
+
"kind": "markdown",
|
|
275
|
+
"content": { "expr": "state", "name": "markdownContent" }
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Code Blocks
|
|
280
|
+
|
|
281
|
+
Rendered with [Shiki](https://shiki.style/) syntax highlighting.
|
|
282
|
+
|
|
283
|
+
```json
|
|
284
|
+
{
|
|
285
|
+
"kind": "code",
|
|
286
|
+
"code": { "expr": "lit", "value": "const x = 1;" },
|
|
287
|
+
"language": { "expr": "lit", "value": "typescript" }
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
**Features:**
|
|
292
|
+
- Dual theme support (light/dark)
|
|
293
|
+
- Copy button with feedback
|
|
294
|
+
- Dynamic language loading
|
|
295
|
+
|
|
296
|
+
## Security
|
|
297
|
+
|
|
298
|
+
The runtime includes security measures:
|
|
299
|
+
|
|
300
|
+
- **Prototype Pollution Prevention** - Blocks `__proto__`, `constructor`, `prototype`
|
|
301
|
+
- **Safe Globals** - Only exposes `JSON`, `Math`, `Date`, `Object`, `Array`, `String`, `Number`, `Boolean`, `console`
|
|
302
|
+
- **HTML Sanitization** - DOMPurify for Markdown content
|
|
303
|
+
|
|
304
|
+
## License
|
|
305
|
+
|
|
306
|
+
MIT
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -248,6 +248,9 @@ function evaluate(expr, ctx) {
|
|
|
248
248
|
}
|
|
249
249
|
return dataValue;
|
|
250
250
|
}
|
|
251
|
+
case "param": {
|
|
252
|
+
return void 0;
|
|
253
|
+
}
|
|
251
254
|
default: {
|
|
252
255
|
const _exhaustiveCheck = expr;
|
|
253
256
|
throw new Error(`Unknown expression type: ${JSON.stringify(_exhaustiveCheck)}`);
|
|
@@ -2418,12 +2421,12 @@ function createDOMPurify() {
|
|
|
2418
2421
|
let URI_SAFE_ATTRIBUTES = null;
|
|
2419
2422
|
const DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, ["alt", "class", "for", "id", "label", "name", "pattern", "placeholder", "role", "summary", "title", "value", "style", "xmlns"]);
|
|
2420
2423
|
const MATHML_NAMESPACE = "http://www.w3.org/1998/Math/MathML";
|
|
2421
|
-
const
|
|
2424
|
+
const SVG_NAMESPACE2 = "http://www.w3.org/2000/svg";
|
|
2422
2425
|
const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
|
|
2423
2426
|
let NAMESPACE = HTML_NAMESPACE;
|
|
2424
2427
|
let IS_EMPTY_INPUT = false;
|
|
2425
2428
|
let ALLOWED_NAMESPACES = null;
|
|
2426
|
-
const DEFAULT_ALLOWED_NAMESPACES = addToSet({}, [MATHML_NAMESPACE,
|
|
2429
|
+
const DEFAULT_ALLOWED_NAMESPACES = addToSet({}, [MATHML_NAMESPACE, SVG_NAMESPACE2, HTML_NAMESPACE], stringToString);
|
|
2427
2430
|
let MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, ["mi", "mo", "mn", "ms", "mtext"]);
|
|
2428
2431
|
let HTML_INTEGRATION_POINTS = addToSet({}, ["annotation-xml"]);
|
|
2429
2432
|
const COMMON_SVG_AND_HTML_ELEMENTS = addToSet({}, ["title", "style", "font", "a", "script"]);
|
|
@@ -2597,7 +2600,7 @@ function createDOMPurify() {
|
|
|
2597
2600
|
if (!ALLOWED_NAMESPACES[element2.namespaceURI]) {
|
|
2598
2601
|
return false;
|
|
2599
2602
|
}
|
|
2600
|
-
if (element2.namespaceURI ===
|
|
2603
|
+
if (element2.namespaceURI === SVG_NAMESPACE2) {
|
|
2601
2604
|
if (parent.namespaceURI === HTML_NAMESPACE) {
|
|
2602
2605
|
return tagName === "svg";
|
|
2603
2606
|
}
|
|
@@ -2610,13 +2613,13 @@ function createDOMPurify() {
|
|
|
2610
2613
|
if (parent.namespaceURI === HTML_NAMESPACE) {
|
|
2611
2614
|
return tagName === "math";
|
|
2612
2615
|
}
|
|
2613
|
-
if (parent.namespaceURI ===
|
|
2616
|
+
if (parent.namespaceURI === SVG_NAMESPACE2) {
|
|
2614
2617
|
return tagName === "math" && HTML_INTEGRATION_POINTS[parentTagName];
|
|
2615
2618
|
}
|
|
2616
2619
|
return Boolean(ALL_MATHML_TAGS[tagName]);
|
|
2617
2620
|
}
|
|
2618
2621
|
if (element2.namespaceURI === HTML_NAMESPACE) {
|
|
2619
|
-
if (parent.namespaceURI ===
|
|
2622
|
+
if (parent.namespaceURI === SVG_NAMESPACE2 && !HTML_INTEGRATION_POINTS[parentTagName]) {
|
|
2620
2623
|
return false;
|
|
2621
2624
|
}
|
|
2622
2625
|
if (parent.namespaceURI === MATHML_NAMESPACE && !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]) {
|
|
@@ -12828,6 +12831,40 @@ async function highlightCode(code, language) {
|
|
|
12828
12831
|
}
|
|
12829
12832
|
|
|
12830
12833
|
// src/renderer/index.ts
|
|
12834
|
+
var SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
12835
|
+
var SVG_TAGS = /* @__PURE__ */ new Set([
|
|
12836
|
+
"svg",
|
|
12837
|
+
"path",
|
|
12838
|
+
"line",
|
|
12839
|
+
"circle",
|
|
12840
|
+
"rect",
|
|
12841
|
+
"ellipse",
|
|
12842
|
+
"polyline",
|
|
12843
|
+
"polygon",
|
|
12844
|
+
"g",
|
|
12845
|
+
"defs",
|
|
12846
|
+
"use",
|
|
12847
|
+
"text",
|
|
12848
|
+
"tspan",
|
|
12849
|
+
"clipPath",
|
|
12850
|
+
"mask",
|
|
12851
|
+
"linearGradient",
|
|
12852
|
+
"radialGradient",
|
|
12853
|
+
"stop",
|
|
12854
|
+
"pattern",
|
|
12855
|
+
"symbol",
|
|
12856
|
+
"marker",
|
|
12857
|
+
"image",
|
|
12858
|
+
"filter",
|
|
12859
|
+
"foreignObject",
|
|
12860
|
+
"animate",
|
|
12861
|
+
"animateTransform",
|
|
12862
|
+
"desc",
|
|
12863
|
+
"title"
|
|
12864
|
+
]);
|
|
12865
|
+
function isSvgTag(tag) {
|
|
12866
|
+
return SVG_TAGS.has(tag);
|
|
12867
|
+
}
|
|
12831
12868
|
function isEventHandler(value) {
|
|
12832
12869
|
return typeof value === "object" && value !== null && "event" in value && "action" in value;
|
|
12833
12870
|
}
|
|
@@ -12850,7 +12887,10 @@ function render(node, ctx) {
|
|
|
12850
12887
|
}
|
|
12851
12888
|
}
|
|
12852
12889
|
function renderElement(node, ctx) {
|
|
12853
|
-
const
|
|
12890
|
+
const tag = node.tag;
|
|
12891
|
+
const inSvgContext = ctx.inSvg || tag === "svg";
|
|
12892
|
+
const useSvgNamespace = inSvgContext && isSvgTag(tag);
|
|
12893
|
+
const el = useSvgNamespace ? document.createElementNS(SVG_NAMESPACE, tag) : document.createElement(tag);
|
|
12854
12894
|
if (node.ref && ctx.refs) {
|
|
12855
12895
|
if (typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production" && ctx.refs[node.ref]) {
|
|
12856
12896
|
console.warn(`Duplicate ref name "${node.ref}" detected. The later element will overwrite the earlier one.`);
|
|
@@ -12893,21 +12933,31 @@ function renderElement(node, ctx) {
|
|
|
12893
12933
|
} else {
|
|
12894
12934
|
const cleanup = createEffect(() => {
|
|
12895
12935
|
const value = evaluate(propValue, { state: ctx.state, locals: ctx.locals, ...ctx.imports && { imports: ctx.imports } });
|
|
12896
|
-
applyProp(el, propName, value);
|
|
12936
|
+
applyProp(el, propName, value, useSvgNamespace);
|
|
12897
12937
|
});
|
|
12898
12938
|
ctx.cleanups?.push(cleanup);
|
|
12899
12939
|
}
|
|
12900
12940
|
}
|
|
12901
12941
|
}
|
|
12902
12942
|
if (node.children) {
|
|
12943
|
+
const childInSvg = tag === "foreignObject" ? false : inSvgContext;
|
|
12944
|
+
const childCtx = childInSvg !== ctx.inSvg ? { ...ctx, inSvg: childInSvg } : ctx;
|
|
12903
12945
|
for (const child of node.children) {
|
|
12904
|
-
const childNode = render(child,
|
|
12946
|
+
const childNode = render(child, childCtx);
|
|
12905
12947
|
el.appendChild(childNode);
|
|
12906
12948
|
}
|
|
12907
12949
|
}
|
|
12908
12950
|
return el;
|
|
12909
12951
|
}
|
|
12910
|
-
function applyProp(el, propName, value) {
|
|
12952
|
+
function applyProp(el, propName, value, isSvg = false) {
|
|
12953
|
+
if (isSvg && propName === "className") {
|
|
12954
|
+
if (value) {
|
|
12955
|
+
el.setAttribute("class", String(value));
|
|
12956
|
+
} else {
|
|
12957
|
+
el.removeAttribute("class");
|
|
12958
|
+
}
|
|
12959
|
+
return;
|
|
12960
|
+
}
|
|
12911
12961
|
if (propName === "className") {
|
|
12912
12962
|
el.className = String(value ?? "");
|
|
12913
12963
|
} else if (propName === "style" && typeof value === "string") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constela/runtime",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"description": "Runtime DOM renderer for Constela UI framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"dompurify": "^3.3.1",
|
|
19
19
|
"marked": "^17.0.1",
|
|
20
20
|
"shiki": "^3.20.0",
|
|
21
|
-
"@constela/compiler": "0.7.
|
|
21
|
+
"@constela/compiler": "0.7.1",
|
|
22
22
|
"@constela/core": "0.7.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"tsup": "^8.0.0",
|
|
30
30
|
"typescript": "^5.3.0",
|
|
31
31
|
"vitest": "^2.0.0",
|
|
32
|
-
"@constela/server": "3.0.
|
|
32
|
+
"@constela/server": "3.0.1"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
35
35
|
"node": ">=20.0.0"
|