@mandujs/core 0.54.18 → 0.54.19

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.
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { deserializeProps, serializeProps } from "../props-serialization";
3
+
4
+ describe("props serialization", () => {
5
+ it("roundtrips complex browser hydration props through the shared deserializer", () => {
6
+ const input = {
7
+ date: new Date("2026-05-23T00:00:00.000Z"),
8
+ map: new Map<unknown, unknown>([
9
+ ["count", 3],
10
+ ["nested", { ok: true }],
11
+ ]),
12
+ set: new Set<unknown>(["a", "b"]),
13
+ url: new URL("https://mandu.dev/docs?phase=4"),
14
+ missing: undefined,
15
+ nested: {
16
+ list: [1, undefined, { value: "x" }],
17
+ },
18
+ };
19
+
20
+ const output = deserializeProps(serializeProps(input));
21
+
22
+ expect(output.date).toBeInstanceOf(Date);
23
+ expect((output.date as Date).toISOString()).toBe("2026-05-23T00:00:00.000Z");
24
+ expect(output.map).toBeInstanceOf(Map);
25
+ expect((output.map as Map<unknown, unknown>).get("count")).toBe(3);
26
+ expect((output.map as Map<unknown, unknown>).get("nested")).toEqual({ ok: true });
27
+ expect(output.set).toBeInstanceOf(Set);
28
+ expect(Array.from(output.set as Set<unknown>)).toEqual(["a", "b"]);
29
+ expect(output.url).toBeInstanceOf(URL);
30
+ expect((output.url as URL).href).toBe("https://mandu.dev/docs?phase=4");
31
+ expect(Object.prototype.hasOwnProperty.call(output, "missing")).toBe(true);
32
+ expect(output.missing).toBeUndefined();
33
+ expect(output.nested).toEqual({
34
+ list: [1, undefined, { value: "x" }],
35
+ });
36
+ });
37
+ });
@@ -16,8 +16,8 @@
16
16
  * 2. Unit tests can import it under happy-dom/JSDOM without triggering
17
17
  * global mutation (no `document.querySelectorAll` on module eval).
18
18
  *
19
- * The bundler-generated runtime (`bundler/build.ts::generateRuntimeSource`)
20
- * delegates strategy selection to `scheduleHydration()` here — SSR emits the
19
+ * The bundled runtime entry (`client/runtime-entry.ts`)
20
+ * delegates strategy selection to `scheduleHydration()` here — SSR emits the
21
21
  * `data-hydrate` attribute, the runtime reads it and dispatches.
22
22
  *
23
23
  * Design contract:
@@ -168,7 +168,7 @@ import { Link, NavLink } from "./Link";
168
168
 
169
169
  /**
170
170
  * Mandu Client namespace
171
- * v0.8.0: Hydration is handled automatically (generateRuntimeSource)
171
+ * v0.8.0: Hydration is handled automatically (runtime-entry)
172
172
  * Note: Use `ManduClient` to avoid conflict with other Mandu exports
173
173
  */
174
174
  export const ManduClient = {
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Browser-safe Mandu props serialization.
3
+ *
4
+ * This module intentionally has no DOM, Bun, or Node imports. Runtime entry
5
+ * code may read from the document, but serialization semantics live here.
6
+ */
7
+
8
+ const TYPE_MARKERS = {
9
+ UNDEFINED: "\x00_",
10
+ DATE: "\x00D",
11
+ URL: "\x00U",
12
+ REGEXP: "\x00R",
13
+ MAP: "\x00M",
14
+ SET: "\x00S",
15
+ REF: "\x00$",
16
+ BIGINT: "\x00B",
17
+ SYMBOL: "\x00Y",
18
+ ERROR: "\x00E",
19
+ } as const;
20
+
21
+ interface SerializeContext {
22
+ seen: Map<object, number>;
23
+ refs: object[];
24
+ }
25
+
26
+ interface DeserializeContext {
27
+ refs: unknown[];
28
+ }
29
+
30
+ export function serializeProps(props: Record<string, unknown>): string {
31
+ const ctx: SerializeContext = { seen: new Map(), refs: [] };
32
+ return JSON.stringify(serialize(props, ctx));
33
+ }
34
+
35
+ function serialize(value: unknown, ctx: SerializeContext): unknown {
36
+ if (value === null) return null;
37
+ if (value === undefined) return TYPE_MARKERS.UNDEFINED;
38
+
39
+ if (typeof value === "boolean" || typeof value === "number") {
40
+ return value;
41
+ }
42
+
43
+ if (typeof value === "string") {
44
+ return value.startsWith("\x00") ? "\x00\x00" + value : value;
45
+ }
46
+
47
+ if (typeof value === "bigint") {
48
+ return TYPE_MARKERS.BIGINT + value.toString();
49
+ }
50
+
51
+ if (typeof value === "symbol") {
52
+ return TYPE_MARKERS.SYMBOL + (value.description ?? "");
53
+ }
54
+
55
+ if (typeof value === "function") {
56
+ console.warn("[Mandu Serialize] Functions cannot be serialized, skipping");
57
+ return undefined;
58
+ }
59
+
60
+ if (typeof value === "object") {
61
+ const existing = ctx.seen.get(value);
62
+ if (existing !== undefined) {
63
+ return TYPE_MARKERS.REF + existing;
64
+ }
65
+
66
+ const idx = ctx.refs.length;
67
+ ctx.seen.set(value, idx);
68
+ ctx.refs.push(value);
69
+ }
70
+
71
+ if (value instanceof Date) {
72
+ return TYPE_MARKERS.DATE + value.toISOString();
73
+ }
74
+
75
+ if (value instanceof URL) {
76
+ return TYPE_MARKERS.URL + value.href;
77
+ }
78
+
79
+ if (value instanceof RegExp) {
80
+ return TYPE_MARKERS.REGEXP + value.toString();
81
+ }
82
+
83
+ if (value instanceof Error) {
84
+ return [
85
+ TYPE_MARKERS.ERROR,
86
+ value.name,
87
+ value.message,
88
+ value.stack ?? "",
89
+ ];
90
+ }
91
+
92
+ if (value instanceof Map) {
93
+ const entries: [unknown, unknown][] = [];
94
+ for (const [key, nested] of value.entries()) {
95
+ entries.push([serialize(key, ctx), serialize(nested, ctx)]);
96
+ }
97
+ return [TYPE_MARKERS.MAP, ...entries];
98
+ }
99
+
100
+ if (value instanceof Set) {
101
+ const items: unknown[] = [];
102
+ for (const item of value) {
103
+ items.push(serialize(item, ctx));
104
+ }
105
+ return [TYPE_MARKERS.SET, ...items];
106
+ }
107
+
108
+ if (Array.isArray(value)) {
109
+ return value.map((item) => serialize(item, ctx));
110
+ }
111
+
112
+ const result: Record<string, unknown> = {};
113
+ for (const [key, nested] of Object.entries(value as object)) {
114
+ const serialized = serialize(nested, ctx);
115
+ if (serialized !== undefined) {
116
+ result[key] = serialized;
117
+ }
118
+ }
119
+ return result;
120
+ }
121
+
122
+ export function deserializeProps(json: string): Record<string, unknown> {
123
+ const ctx: DeserializeContext = { refs: [] };
124
+ const parsed = JSON.parse(json);
125
+ return deserialize(parsed, ctx) as Record<string, unknown>;
126
+ }
127
+
128
+ function deserialize(value: unknown, ctx: DeserializeContext): unknown {
129
+ if (value === null) return null;
130
+
131
+ if (typeof value === "string") {
132
+ if (value === TYPE_MARKERS.UNDEFINED) return undefined;
133
+ if (value.startsWith("\x00\x00")) return value.slice(2);
134
+ if (value.startsWith(TYPE_MARKERS.DATE)) return new Date(value.slice(2));
135
+ if (value.startsWith(TYPE_MARKERS.URL)) return new URL(value.slice(2));
136
+ if (value.startsWith(TYPE_MARKERS.REGEXP)) {
137
+ const str = value.slice(2);
138
+ const match = str.match(/^\/(.*)\/([gimsuy]*)$/);
139
+ return match ? new RegExp(match[1], match[2]) : str;
140
+ }
141
+ if (value.startsWith(TYPE_MARKERS.BIGINT)) return BigInt(value.slice(2));
142
+ if (value.startsWith(TYPE_MARKERS.SYMBOL)) return Symbol(value.slice(2));
143
+ if (value.startsWith(TYPE_MARKERS.REF)) {
144
+ return ctx.refs[parseInt(value.slice(2), 10)];
145
+ }
146
+ return value;
147
+ }
148
+
149
+ if (typeof value === "boolean" || typeof value === "number") {
150
+ return value;
151
+ }
152
+
153
+ if (Array.isArray(value)) {
154
+ const marker = value[0];
155
+
156
+ if (marker === TYPE_MARKERS.ERROR) {
157
+ const [, name, message, stack] = value as [string, string, string, string];
158
+ const error = new Error(message);
159
+ error.name = name;
160
+ if (stack) error.stack = stack;
161
+ ctx.refs.push(error);
162
+ return error;
163
+ }
164
+
165
+ if (marker === TYPE_MARKERS.MAP) {
166
+ const map = new Map();
167
+ ctx.refs.push(map);
168
+ for (let i = 1; i < value.length; i++) {
169
+ const [key, nested] = value[i] as [unknown, unknown];
170
+ map.set(deserialize(key, ctx), deserialize(nested, ctx));
171
+ }
172
+ return map;
173
+ }
174
+
175
+ if (marker === TYPE_MARKERS.SET) {
176
+ const set = new Set();
177
+ ctx.refs.push(set);
178
+ for (let i = 1; i < value.length; i++) {
179
+ set.add(deserialize(value[i], ctx));
180
+ }
181
+ return set;
182
+ }
183
+
184
+ const arr: unknown[] = [];
185
+ ctx.refs.push(arr);
186
+ for (const item of value) {
187
+ arr.push(deserialize(item, ctx));
188
+ }
189
+ return arr;
190
+ }
191
+
192
+ if (typeof value === "object") {
193
+ const obj: Record<string, unknown> = {};
194
+ ctx.refs.push(obj);
195
+ for (const [key, nested] of Object.entries(value)) {
196
+ obj[key] = deserialize(nested, ctx);
197
+ }
198
+ return obj;
199
+ }
200
+
201
+ return value;
202
+ }
203
+
204
+ export function isSerializable(value: unknown): boolean {
205
+ if (value === null || value === undefined) return true;
206
+
207
+ const type = typeof value;
208
+ if (type === "boolean" || type === "number" || type === "string" || type === "bigint") {
209
+ return true;
210
+ }
211
+
212
+ if (type === "function" || type === "symbol") {
213
+ return false;
214
+ }
215
+
216
+ if (value instanceof Date || value instanceof URL || value instanceof RegExp) {
217
+ return true;
218
+ }
219
+
220
+ if (value instanceof Map || value instanceof Set) {
221
+ return true;
222
+ }
223
+
224
+ if (Array.isArray(value)) {
225
+ return value.every(isSerializable);
226
+ }
227
+
228
+ if (type === "object") {
229
+ return Object.values(value as object).every(isSerializable);
230
+ }
231
+
232
+ return false;
233
+ }