@constela/runtime 0.3.5 → 0.6.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/dist/index.d.ts CHANGED
@@ -100,4 +100,28 @@ interface AppInstance {
100
100
  }
101
101
  declare function createApp(program: CompiledProgram, mount: HTMLElement): AppInstance;
102
102
 
103
- export { type ActionContext, type AppInstance, type EvaluationContext, type RenderContext, type Signal, type StateStore, createApp, createEffect, createSignal, createStateStore, evaluate, executeAction, render };
103
+ /**
104
+ * Hydrate - Attach to existing SSR-rendered HTML
105
+ *
106
+ * This module provides client-side hydration for server-rendered Constela apps.
107
+ * It attaches event handlers and enables reactivity without reconstructing the DOM.
108
+ */
109
+
110
+ /**
111
+ * Options for hydrating an SSR-rendered application
112
+ */
113
+ interface HydrateOptions {
114
+ /** The compiled program to hydrate */
115
+ program: CompiledProgram;
116
+ /** The container element containing SSR-rendered HTML */
117
+ container: HTMLElement;
118
+ }
119
+ /**
120
+ * Hydrates a server-rendered application.
121
+ *
122
+ * @param options - Hydration options containing program and container
123
+ * @returns AppInstance for controlling the hydrated application
124
+ */
125
+ declare function hydrateApp(options: HydrateOptions): AppInstance;
126
+
127
+ export { type ActionContext, type AppInstance, type EvaluationContext, type HydrateOptions, type RenderContext, type Signal, type StateStore, createApp, createEffect, createSignal, createStateStore, evaluate, executeAction, hydrateApp, render };
package/dist/index.js CHANGED
@@ -381,6 +381,51 @@ async function executeFetchStep(step, ctx) {
381
381
  }
382
382
  }
383
383
 
384
+ // src/renderer/markdown.ts
385
+ import { marked } from "marked";
386
+ import DOMPurify from "dompurify";
387
+ marked.setOptions({
388
+ gfm: true,
389
+ breaks: false
390
+ });
391
+ function parseMarkdown(content) {
392
+ const rawHtml = marked.parse(content, { async: false });
393
+ return DOMPurify.sanitize(rawHtml, {
394
+ USE_PROFILES: { html: true },
395
+ FORBID_TAGS: ["script", "style", "iframe"],
396
+ FORBID_ATTR: ["onerror", "onload", "onclick"]
397
+ });
398
+ }
399
+
400
+ // src/renderer/code.ts
401
+ import { createHighlighter } from "shiki";
402
+ var highlighterPromise = null;
403
+ var DEFAULT_LANGUAGES = ["javascript", "typescript", "json", "html", "css"];
404
+ async function getHighlighter() {
405
+ if (!highlighterPromise) {
406
+ highlighterPromise = createHighlighter({
407
+ themes: ["github-dark", "github-light"],
408
+ langs: DEFAULT_LANGUAGES
409
+ });
410
+ }
411
+ return highlighterPromise;
412
+ }
413
+ async function highlightCode(code, language) {
414
+ const highlighter = await getHighlighter();
415
+ const loadedLangs = highlighter.getLoadedLanguages();
416
+ if (!loadedLangs.includes(language)) {
417
+ try {
418
+ await highlighter.loadLanguage(language);
419
+ } catch {
420
+ language = "plaintext";
421
+ }
422
+ }
423
+ return highlighter.codeToHtml(code, {
424
+ lang: language,
425
+ theme: "github-dark"
426
+ });
427
+ }
428
+
384
429
  // src/renderer/index.ts
385
430
  function isEventHandler(value) {
386
431
  return typeof value === "object" && value !== null && "event" in value && "action" in value;
@@ -395,6 +440,10 @@ function render(node, ctx) {
395
440
  return renderIf(node, ctx);
396
441
  case "each":
397
442
  return renderEach(node, ctx);
443
+ case "markdown":
444
+ return renderMarkdown(node, ctx);
445
+ case "code":
446
+ return renderCode(node, ctx);
398
447
  default:
399
448
  throw new Error("Unknown node kind");
400
449
  }
@@ -602,6 +651,44 @@ function renderEach(node, ctx) {
602
651
  }
603
652
  return fragment;
604
653
  }
654
+ function renderMarkdown(node, ctx) {
655
+ const container = document.createElement("div");
656
+ container.className = "constela-markdown";
657
+ const cleanup = createEffect(() => {
658
+ const content = evaluate(node.content, { state: ctx.state, locals: ctx.locals });
659
+ const html = parseMarkdown(String(content ?? ""));
660
+ container.innerHTML = html;
661
+ });
662
+ ctx.cleanups?.push(cleanup);
663
+ return container;
664
+ }
665
+ function renderCode(node, ctx) {
666
+ const container = document.createElement("div");
667
+ container.className = "constela-code";
668
+ const pre = document.createElement("pre");
669
+ const codeEl = document.createElement("code");
670
+ container.appendChild(pre);
671
+ pre.appendChild(codeEl);
672
+ const cleanup = createEffect(() => {
673
+ const language = String(evaluate(node.language, { state: ctx.state, locals: ctx.locals }) ?? "plaintext");
674
+ const content = String(evaluate(node.content, { state: ctx.state, locals: ctx.locals }) ?? "");
675
+ codeEl.className = `language-${language || "plaintext"}`;
676
+ codeEl.dataset["language"] = language || "plaintext";
677
+ container.dataset["language"] = language || "plaintext";
678
+ codeEl.textContent = content;
679
+ highlightCode(content, language || "plaintext").then((html) => {
680
+ container.innerHTML = html;
681
+ const newCode = container.querySelector("code");
682
+ if (newCode) {
683
+ newCode.classList.add(`language-${language || "plaintext"}`);
684
+ newCode.dataset["language"] = language || "plaintext";
685
+ }
686
+ container.dataset["language"] = language || "plaintext";
687
+ });
688
+ });
689
+ ctx.cleanups?.push(cleanup);
690
+ return container;
691
+ }
605
692
 
606
693
  // src/app.ts
607
694
  function createApp(program, mount) {
@@ -645,6 +732,410 @@ function createApp(program, mount) {
645
732
  }
646
733
  };
647
734
  }
735
+
736
+ // src/hydrate.ts
737
+ function isEventHandler2(value) {
738
+ return typeof value === "object" && value !== null && "event" in value && "action" in value;
739
+ }
740
+ function hydrateApp(options) {
741
+ const { program, container } = options;
742
+ const state = createStateStore(program.state);
743
+ let actions;
744
+ if (program.actions instanceof Map) {
745
+ actions = {};
746
+ program.actions.forEach((action, name) => {
747
+ actions[name] = action;
748
+ });
749
+ } else {
750
+ actions = program.actions;
751
+ }
752
+ const cleanups = [];
753
+ const ctx = {
754
+ state,
755
+ actions,
756
+ locals: {},
757
+ cleanups
758
+ };
759
+ const firstChild = container.firstElementChild;
760
+ if (firstChild) {
761
+ hydrate(program.view, firstChild, ctx);
762
+ }
763
+ let destroyed = false;
764
+ return {
765
+ destroy() {
766
+ if (destroyed) return;
767
+ destroyed = true;
768
+ for (const cleanup of cleanups) {
769
+ cleanup();
770
+ }
771
+ while (container.firstChild) {
772
+ container.removeChild(container.firstChild);
773
+ }
774
+ },
775
+ setState(name, value) {
776
+ if (destroyed) return;
777
+ state.set(name, value);
778
+ },
779
+ getState(name) {
780
+ return state.get(name);
781
+ }
782
+ };
783
+ }
784
+ function hydrate(node, domNode, ctx) {
785
+ switch (node.kind) {
786
+ case "element":
787
+ hydrateElement(node, domNode, ctx);
788
+ break;
789
+ case "text":
790
+ hydrateText(node, domNode, ctx);
791
+ break;
792
+ case "if":
793
+ hydrateIf(node, domNode, ctx);
794
+ break;
795
+ case "each":
796
+ hydrateEach(node, domNode, ctx);
797
+ break;
798
+ case "markdown":
799
+ case "code":
800
+ break;
801
+ default:
802
+ break;
803
+ }
804
+ }
805
+ function hydrateElement(node, el, ctx) {
806
+ if (node.props) {
807
+ for (const [propName, propValue] of Object.entries(node.props)) {
808
+ if (isEventHandler2(propValue)) {
809
+ const handler = propValue;
810
+ const eventName = handler.event;
811
+ el.addEventListener(eventName, async (event) => {
812
+ const action = ctx.actions[handler.action];
813
+ if (action) {
814
+ const eventLocals = {};
815
+ const target = event.target;
816
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) {
817
+ eventLocals["value"] = target.value;
818
+ if (target instanceof HTMLInputElement && target.type === "checkbox") {
819
+ eventLocals["checked"] = target.checked;
820
+ }
821
+ }
822
+ let payload = void 0;
823
+ if (handler.payload) {
824
+ payload = evaluate(handler.payload, {
825
+ state: ctx.state,
826
+ locals: { ...ctx.locals, ...eventLocals }
827
+ });
828
+ }
829
+ const actionCtx = {
830
+ state: ctx.state,
831
+ actions: ctx.actions,
832
+ locals: { ...ctx.locals, ...eventLocals, payload },
833
+ eventPayload: payload
834
+ };
835
+ await executeAction(action, actionCtx);
836
+ }
837
+ });
838
+ } else {
839
+ const cleanup = createEffect(() => {
840
+ const value = evaluate(propValue, {
841
+ state: ctx.state,
842
+ locals: ctx.locals
843
+ });
844
+ applyProp2(el, propName, value);
845
+ });
846
+ ctx.cleanups.push(cleanup);
847
+ }
848
+ }
849
+ }
850
+ if (node.children) {
851
+ hydrateChildren(node.children, el, ctx);
852
+ }
853
+ }
854
+ function hydrateChildren(children, parent, ctx) {
855
+ const domChildren = [];
856
+ for (let i = 0; i < parent.childNodes.length; i++) {
857
+ const child = parent.childNodes[i];
858
+ if (child.nodeType !== Node.COMMENT_NODE) {
859
+ domChildren.push(child);
860
+ }
861
+ }
862
+ let domIndex = 0;
863
+ for (let i = 0; i < children.length; i++) {
864
+ const childNode = children[i];
865
+ if (childNode.kind === "text") {
866
+ const textNodes = [childNode];
867
+ while (i + 1 < children.length && children[i + 1].kind === "text") {
868
+ i++;
869
+ textNodes.push(children[i]);
870
+ }
871
+ const domChild = domChildren[domIndex];
872
+ if (domChild && domChild.nodeType === Node.TEXT_NODE) {
873
+ hydrateTextGroup(textNodes, domChild, ctx);
874
+ domIndex++;
875
+ }
876
+ } else if (childNode.kind === "element") {
877
+ const domChild = domChildren[domIndex];
878
+ if (domChild && domChild.nodeType === Node.ELEMENT_NODE) {
879
+ hydrate(childNode, domChild, ctx);
880
+ domIndex++;
881
+ }
882
+ } else if (childNode.kind === "if") {
883
+ const domChild = domChildren[domIndex];
884
+ if (domChild) {
885
+ hydrate(childNode, domChild, ctx);
886
+ domIndex++;
887
+ }
888
+ } else if (childNode.kind === "each") {
889
+ const items = evaluate(childNode.items, {
890
+ state: ctx.state,
891
+ locals: ctx.locals
892
+ });
893
+ const itemCount = Array.isArray(items) ? items.length : 0;
894
+ if (itemCount > 0) {
895
+ const firstDomChild = domChildren[domIndex];
896
+ if (firstDomChild) {
897
+ hydrate(childNode, firstDomChild, ctx);
898
+ domIndex += itemCount;
899
+ }
900
+ }
901
+ } else {
902
+ const domChild = domChildren[domIndex];
903
+ if (domChild) {
904
+ hydrate(childNode, domChild, ctx);
905
+ domIndex++;
906
+ }
907
+ }
908
+ }
909
+ }
910
+ function hydrateTextGroup(nodes, textNode, ctx) {
911
+ const cleanup = createEffect(() => {
912
+ let combinedText = "";
913
+ for (const node of nodes) {
914
+ const value = evaluate(node.value, {
915
+ state: ctx.state,
916
+ locals: ctx.locals
917
+ });
918
+ combinedText += formatValue2(value);
919
+ }
920
+ textNode.textContent = combinedText;
921
+ });
922
+ ctx.cleanups.push(cleanup);
923
+ }
924
+ function applyProp2(el, propName, value) {
925
+ if (propName === "className") {
926
+ el.className = String(value ?? "");
927
+ } else if (propName === "style" && typeof value === "string") {
928
+ el.setAttribute("style", value);
929
+ } else if (propName === "disabled") {
930
+ if (value) {
931
+ el.setAttribute("disabled", "disabled");
932
+ el.disabled = true;
933
+ } else {
934
+ el.removeAttribute("disabled");
935
+ el.disabled = false;
936
+ }
937
+ } else if (propName === "value" && el instanceof HTMLInputElement) {
938
+ el.value = String(value ?? "");
939
+ } else if (propName.startsWith("data-")) {
940
+ el.setAttribute(propName, String(value ?? ""));
941
+ } else {
942
+ if (value === true) {
943
+ el.setAttribute(propName, "");
944
+ } else if (value === false || value === null || value === void 0) {
945
+ el.removeAttribute(propName);
946
+ } else {
947
+ el.setAttribute(propName, String(value));
948
+ }
949
+ }
950
+ }
951
+ function hydrateText(node, textNode, ctx) {
952
+ const cleanup = createEffect(() => {
953
+ const value = evaluate(node.value, {
954
+ state: ctx.state,
955
+ locals: ctx.locals
956
+ });
957
+ textNode.textContent = formatValue2(value);
958
+ });
959
+ ctx.cleanups.push(cleanup);
960
+ }
961
+ function formatValue2(value) {
962
+ if (value === null || value === void 0) {
963
+ return "";
964
+ }
965
+ if (typeof value === "object") {
966
+ return JSON.stringify(value);
967
+ }
968
+ return String(value);
969
+ }
970
+ function hydrateIf(node, initialDomNode, ctx) {
971
+ const anchor = document.createComment("if");
972
+ const parent = initialDomNode.parentNode;
973
+ if (!parent) return;
974
+ parent.insertBefore(anchor, initialDomNode);
975
+ let currentNode = initialDomNode;
976
+ let currentBranch = "none";
977
+ let branchCleanups = [];
978
+ let isFirstRun = true;
979
+ const initialCondition = evaluate(node.condition, {
980
+ state: ctx.state,
981
+ locals: ctx.locals
982
+ });
983
+ currentBranch = Boolean(initialCondition) ? "then" : node.else ? "else" : "none";
984
+ if (currentBranch === "then") {
985
+ const localCleanups = [];
986
+ const branchCtx = { ...ctx, cleanups: localCleanups };
987
+ hydrate(node.then, currentNode, branchCtx);
988
+ branchCleanups = localCleanups;
989
+ } else if (currentBranch === "else" && node.else) {
990
+ const localCleanups = [];
991
+ const branchCtx = { ...ctx, cleanups: localCleanups };
992
+ hydrate(node.else, currentNode, branchCtx);
993
+ branchCleanups = localCleanups;
994
+ }
995
+ const effectCleanup = createEffect(() => {
996
+ const condition = evaluate(node.condition, {
997
+ state: ctx.state,
998
+ locals: ctx.locals
999
+ });
1000
+ const shouldShowThen = Boolean(condition);
1001
+ const newBranch = shouldShowThen ? "then" : node.else ? "else" : "none";
1002
+ if (isFirstRun) {
1003
+ isFirstRun = false;
1004
+ return;
1005
+ }
1006
+ if (newBranch !== currentBranch) {
1007
+ for (const cleanup of branchCleanups) {
1008
+ cleanup();
1009
+ }
1010
+ branchCleanups = [];
1011
+ if (currentNode && currentNode.parentNode) {
1012
+ currentNode.parentNode.removeChild(currentNode);
1013
+ }
1014
+ const localCleanups = [];
1015
+ const branchCtx = {
1016
+ state: ctx.state,
1017
+ actions: ctx.actions,
1018
+ locals: ctx.locals,
1019
+ cleanups: localCleanups
1020
+ };
1021
+ if (newBranch === "then") {
1022
+ currentNode = render(node.then, branchCtx);
1023
+ branchCleanups = localCleanups;
1024
+ } else if (newBranch === "else" && node.else) {
1025
+ currentNode = render(node.else, branchCtx);
1026
+ branchCleanups = localCleanups;
1027
+ } else {
1028
+ currentNode = null;
1029
+ }
1030
+ if (currentNode && anchor.parentNode) {
1031
+ anchor.parentNode.insertBefore(currentNode, anchor.nextSibling);
1032
+ }
1033
+ currentBranch = newBranch;
1034
+ }
1035
+ });
1036
+ ctx.cleanups.push(effectCleanup);
1037
+ ctx.cleanups.push(() => {
1038
+ for (const cleanup of branchCleanups) {
1039
+ cleanup();
1040
+ }
1041
+ });
1042
+ }
1043
+ function hydrateEach(node, firstItemDomNode, ctx) {
1044
+ const parent = firstItemDomNode.parentNode;
1045
+ if (!parent) return;
1046
+ const anchor = document.createComment("each");
1047
+ parent.insertBefore(anchor, firstItemDomNode);
1048
+ let currentNodes = [];
1049
+ let itemCleanups = [];
1050
+ const initialItems = evaluate(node.items, {
1051
+ state: ctx.state,
1052
+ locals: ctx.locals
1053
+ });
1054
+ let isFirstRun = true;
1055
+ if (Array.isArray(initialItems) && initialItems.length > 0) {
1056
+ let domNode = firstItemDomNode;
1057
+ initialItems.forEach((item, index) => {
1058
+ if (!domNode) return;
1059
+ currentNodes.push(domNode);
1060
+ const itemLocals = {
1061
+ ...ctx.locals,
1062
+ [node.as]: item
1063
+ };
1064
+ if (node.index) {
1065
+ itemLocals[node.index] = index;
1066
+ }
1067
+ const localCleanups = [];
1068
+ const itemCtx = {
1069
+ ...ctx,
1070
+ locals: itemLocals,
1071
+ cleanups: localCleanups
1072
+ };
1073
+ hydrate(node.body, domNode, itemCtx);
1074
+ itemCleanups.push(...localCleanups);
1075
+ domNode = domNode.nextSibling;
1076
+ while (domNode && domNode.nodeType === Node.COMMENT_NODE) {
1077
+ domNode = domNode.nextSibling;
1078
+ }
1079
+ });
1080
+ }
1081
+ const effectCleanup = createEffect(() => {
1082
+ const items = evaluate(node.items, {
1083
+ state: ctx.state,
1084
+ locals: ctx.locals
1085
+ });
1086
+ if (isFirstRun) {
1087
+ isFirstRun = false;
1088
+ return;
1089
+ }
1090
+ for (const cleanup of itemCleanups) {
1091
+ cleanup();
1092
+ }
1093
+ itemCleanups = [];
1094
+ for (const oldNode of currentNodes) {
1095
+ if (oldNode.parentNode) {
1096
+ oldNode.parentNode.removeChild(oldNode);
1097
+ }
1098
+ }
1099
+ currentNodes = [];
1100
+ if (Array.isArray(items)) {
1101
+ items.forEach((item, index) => {
1102
+ const itemLocals = {
1103
+ ...ctx.locals,
1104
+ [node.as]: item
1105
+ };
1106
+ if (node.index) {
1107
+ itemLocals[node.index] = index;
1108
+ }
1109
+ const localCleanups = [];
1110
+ const itemCtx = {
1111
+ state: ctx.state,
1112
+ actions: ctx.actions,
1113
+ locals: itemLocals,
1114
+ cleanups: localCleanups
1115
+ };
1116
+ const itemNode = render(node.body, itemCtx);
1117
+ currentNodes.push(itemNode);
1118
+ itemCleanups.push(...localCleanups);
1119
+ if (anchor.parentNode) {
1120
+ let refNode = anchor.nextSibling;
1121
+ if (currentNodes.length > 1) {
1122
+ const lastExisting = currentNodes[currentNodes.length - 2];
1123
+ if (lastExisting) {
1124
+ refNode = lastExisting.nextSibling;
1125
+ }
1126
+ }
1127
+ anchor.parentNode.insertBefore(itemNode, refNode);
1128
+ }
1129
+ });
1130
+ }
1131
+ });
1132
+ ctx.cleanups.push(effectCleanup);
1133
+ ctx.cleanups.push(() => {
1134
+ for (const cleanup of itemCleanups) {
1135
+ cleanup();
1136
+ }
1137
+ });
1138
+ }
648
1139
  export {
649
1140
  createApp,
650
1141
  createEffect,
@@ -652,5 +1143,6 @@ export {
652
1143
  createStateStore,
653
1144
  evaluate,
654
1145
  executeAction,
1146
+ hydrateApp,
655
1147
  render
656
1148
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constela/runtime",
3
- "version": "0.3.5",
3
+ "version": "0.6.0",
4
4
  "description": "Runtime DOM renderer for Constela UI framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,16 +15,21 @@
15
15
  "dist"
16
16
  ],
17
17
  "dependencies": {
18
- "@constela/core": "0.3.3",
19
- "@constela/compiler": "0.3.3"
18
+ "dompurify": "^3.3.1",
19
+ "marked": "^17.0.1",
20
+ "shiki": "^3.20.0",
21
+ "@constela/compiler": "0.4.0",
22
+ "@constela/core": "0.4.0"
20
23
  },
21
24
  "devDependencies": {
25
+ "@types/dompurify": "^3.2.0",
26
+ "@types/jsdom": "^21.1.0",
22
27
  "@types/node": "^20.10.0",
23
28
  "jsdom": "^24.0.0",
24
- "@types/jsdom": "^21.1.0",
25
29
  "tsup": "^8.0.0",
26
30
  "typescript": "^5.3.0",
27
- "vitest": "^2.0.0"
31
+ "vitest": "^2.0.0",
32
+ "@constela/server": "0.1.2"
28
33
  },
29
34
  "engines": {
30
35
  "node": ">=20.0.0"