@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 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
@@ -105,6 +105,7 @@ interface RenderContext {
105
105
  imports?: Record<string, unknown>;
106
106
  cleanups?: (() => void)[];
107
107
  refs?: Record<string, Element>;
108
+ inSvg?: boolean;
108
109
  }
109
110
  declare function render(node: CompiledNode, ctx: RenderContext): Node;
110
111
 
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 SVG_NAMESPACE = "http://www.w3.org/2000/svg";
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, SVG_NAMESPACE, HTML_NAMESPACE], stringToString);
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 === SVG_NAMESPACE) {
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 === SVG_NAMESPACE) {
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 === SVG_NAMESPACE && !HTML_INTEGRATION_POINTS[parentTagName]) {
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 el = document.createElement(node.tag);
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, ctx);
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.1",
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.0",
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.0"
32
+ "@constela/server": "3.0.1"
33
33
  },
34
34
  "engines": {
35
35
  "node": ">=20.0.0"