@constela/runtime 0.4.0 → 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 +25 -1
- package/dist/index.js +405 -0
- package/package.json +3 -2
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
|
-
|
|
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
|
@@ -732,6 +732,410 @@ function createApp(program, mount) {
|
|
|
732
732
|
}
|
|
733
733
|
};
|
|
734
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
|
+
}
|
|
735
1139
|
export {
|
|
736
1140
|
createApp,
|
|
737
1141
|
createEffect,
|
|
@@ -739,5 +1143,6 @@ export {
|
|
|
739
1143
|
createStateStore,
|
|
740
1144
|
evaluate,
|
|
741
1145
|
executeAction,
|
|
1146
|
+
hydrateApp,
|
|
742
1147
|
render
|
|
743
1148
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constela/runtime",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"jsdom": "^24.0.0",
|
|
29
29
|
"tsup": "^8.0.0",
|
|
30
30
|
"typescript": "^5.3.0",
|
|
31
|
-
"vitest": "^2.0.0"
|
|
31
|
+
"vitest": "^2.0.0",
|
|
32
|
+
"@constela/server": "0.1.2"
|
|
32
33
|
},
|
|
33
34
|
"engines": {
|
|
34
35
|
"node": ">=20.0.0"
|