/* eslint "@typescript-eslint/ban-types": ["error", { "types": { "object": false }, "extendDefaults": true }] */ const { hasOwnProperty } = Object.prototype; const PARAM_BIND = "b"; const PARAM_SCOPE = "s"; const REF_START_CHARS = "hjkmoquxzABCDEFGHIJKLNPQRTUVWXYZ$_"; // Avoids chars that could evaluate to a reserved word. const REF_START_CHARS_LEN = REF_START_CHARS.length; const REF_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$_"; const REF_CHARS_LEN = REF_CHARS.length; const SYMBOL_REGISTRY_ID = Symbol("REGISTRY_ID"); const SYMBOL_SCOPE = Symbol("SCOPE"); const SYMBOL_SERIALIZE = Symbol("SERIALIZE"); class Scope {} export const serializedScope = (scopeId: string | number) => { const scope = new Scope(); if (MARKO_DEBUG) { (scope as any).id = scopeId; } return makeSerializable(scope, (s, a) => { s.value(s.scopeLookup.get(scopeId as number), a); }); }; export type Serializable = T & { [SYMBOL_REGISTRY_ID]?: string; [SYMBOL_SCOPE]?: number; [SYMBOL_SERIALIZE]?: (s: Serializer, accessor: string | number) => void; }; export function register( entry: T, registryId: string, scopeId?: number ): Serializable { (entry as Serializable)[SYMBOL_REGISTRY_ID] = registryId; (entry as Serializable)[SYMBOL_SCOPE] = scopeId; return entry as Serializable; } export function getRegistryInfo(entry: Serializable) { return [entry[SYMBOL_REGISTRY_ID], entry[SYMBOL_SCOPE]]; } export function makeSerializable( object: T, serialize: (s: Serializer, accessor: string | number) => void ): T { (object as Serializable)[SYMBOL_SERIALIZE] = serialize; return object; } export function stringify(root: unknown) { return new Serializer(new Map()).stringify(root); } export class Serializer { // TODO: hoist these back out? STACK: object[] = []; BUFFER: string[] = [""]; ASSIGNMENTS: Map = new Map(); INDEX_OR_REF: WeakMap = new WeakMap(); REF_COUNT = 0; // These stay PARENTS: WeakMap = new WeakMap(); KEYS: WeakMap = new WeakMap(); VALUES: Map = new Map(); scopeLookup: Map; constructor(scopeLookup: Map) { this.scopeLookup = scopeLookup; this.BUFFER.pop(); } stringify(root: unknown) { if (this.writeProp(root, "")) { const { BUFFER, REF_COUNT, ASSIGNMENTS, INDEX_OR_REF } = this; let result = BUFFER[0]; for (let i = 1, len = BUFFER.length; i < len; i++) { result += BUFFER[i]; } if (REF_COUNT) { if (ASSIGNMENTS.size) { let ref = INDEX_OR_REF.get(root as object); if (typeof ref === "number") { ref = toRefParam(this.REF_COUNT++); result = ref + "=" + result; } for (const [assignmentRef, assignments] of ASSIGNMENTS) { result += "," + assignments + assignmentRef; } result += "," + ref; this.ASSIGNMENTS = new Map(); } result = "(" + PARAM_BIND + "," + PARAM_SCOPE + "," + this.refParamsString() + ")=>(" + result + ")"; } else if (root && (root as object).constructor === Object) { result = "(" + PARAM_BIND + "," + PARAM_SCOPE + ")=>(" + result + ")"; } BUFFER.length = 0; this.INDEX_OR_REF = new WeakMap(); return result; } return "void 0"; } code(code: string) { this.BUFFER.push(code); return this; } value(value: unknown, accessor: string | number = "") { // TODO: this should not push the same value twice // this should be serialized in some way so we can access these values across flushes if ( !this.writeProp(value, accessor) && !this.STACK.includes(value as object) ) { this.BUFFER.push("void 0"); } return this; } writeProp(cur: unknown, accessor: string | number): boolean { const { BUFFER } = this; switch (typeof cur) { case "string": BUFFER.push(quote(cur, 0)); break; case "number": BUFFER.push(cur + ""); break; case "boolean": BUFFER.push(cur ? "!0" : "!1"); break; case "function": case "object": if (cur === null) { BUFFER.push("null"); } else { const ref = this.getRef(cur, accessor); switch (ref) { case true: return false; case false: switch (cur.constructor) { case Object: this.writeObject(cur as Record); break; case Array: this.writeArray(cur as unknown[]); break; case Date: BUFFER.push( 'new Date("' + (cur as Date).toISOString() + '")' ); break; case RegExp: BUFFER.push(cur + ""); break; case Map: BUFFER.push("new Map("); this.writeArray( Array.from(cur as Map | Set) ); BUFFER.push(")"); break; case Set: BUFFER.push("new Set("); this.writeArray( Array.from(cur as Map | Set) ); BUFFER.push(")"); break; case undefined: BUFFER.push("Object.assign(Object.create(null),"); this.writeObject(cur as Record); BUFFER.push("))"); break; default: return this.writeRegistered( cur as Serializable, accessor ); } break; default: BUFFER.push(ref); break; } } break; default: return false; } return true; } writeRegistered(value: Serializable, accessor: string | number) { const { [SYMBOL_REGISTRY_ID]: registryId, [SYMBOL_SCOPE]: scopeId, [SYMBOL_SERIALIZE]: serialize, } = value; const { BUFFER } = this; if (registryId) { // ASSERT: fnId and scopeId don't need `quote` escaping const scope = scopeId !== undefined ? this.scopeLookup.get(scopeId) ?? false : undefined; const ref = scope && this.getRef(scope, ""); if (ref === true || ref === false) { throw new Error( "The scope has not yet been defined or is circular. This needs to be fixed in the serializer." ); } BUFFER.push(`${PARAM_BIND}("${registryId}"${ref ? "," + ref : ""})`); return true; } else if (serialize) { const prevSize = BUFFER.length; serialize(this, accessor); return prevSize !== BUFFER.length; } return false; } writeObject(obj: Record) { const { STACK, BUFFER } = this; let sep = "{"; STACK.push(obj); for (const key in obj) { if (hasOwnProperty.call(obj, key)) { const val = obj[key]; const escapedKey = toObjectKey(key); BUFFER.push(sep + escapedKey + ":"); if (this.writeProp(val, escapedKey)) { sep = ","; } else { BUFFER.pop(); } } } if (sep === "{") { BUFFER.push("{}"); } else { BUFFER.push("}"); } STACK.pop(); } writeArray(arr: unknown[]) { const { STACK, BUFFER } = this; BUFFER.push("["); STACK.push(arr); this.writeProp(arr[0], 0); for (let i = 1, len = arr.length; i < len; i++) { BUFFER.push(","); this.writeProp(arr[i], i); } STACK.pop(); BUFFER.push("]"); } getRef(cur: object, accessor: string | number) { const { STACK, BUFFER, INDEX_OR_REF, ASSIGNMENTS, PARENTS, KEYS } = this; let ref = INDEX_OR_REF.get(cur); if (ref === undefined) { INDEX_OR_REF.set(cur, BUFFER.length); let knownParent = PARENTS.get(cur); if (knownParent === undefined) { const parent = STACK[STACK.length - 1]; if (!parent) { // this.VALUES.set(cur, undefined); } else { // this.VALUES.delete(cur); PARENTS.set(cur, parent!); KEYS.set(cur, toObjectKey(accessor)); } return false; } else { let ref = ""; while (knownParent) { ref = toPropertyAccess(KEYS.get(cur)!) + ref; knownParent = PARENTS.get((cur = knownParent)); } return PARAM_SCOPE + ref; } } if (typeof ref === "number") { ref = this.insertAndGetRef(cur, ref); // if (this.VALUES.has(cur)) { // this.VALUES.set(cur, ref); // } } if (STACK.includes(cur)) { const parent = STACK[STACK.length - 1]; let parentRef = INDEX_OR_REF.get(parent) as string | number; if (typeof parentRef === "number") { parentRef = this.insertAndGetRef(parent, parentRef); } ASSIGNMENTS.set( ref, (ASSIGNMENTS.get(ref) || "") + toAssignment(parentRef, accessor) + "=" ); return true; } return ref; } insertAndGetRef(obj: object, pos: number) { const ref = toRefParam(this.REF_COUNT++); this.INDEX_OR_REF.set(obj, ref); if (pos) { this.BUFFER[pos - 1] += ref + "="; } else { this.BUFFER[pos] = ref + "=" + this.BUFFER[pos]; } return ref; } refParamsString() { let result = REF_START_CHARS[0]; for (let i = 1; i < this.REF_COUNT; i++) { result += "," + toRefParam(i); } this.REF_COUNT = 0; return result; } } // function toObjectKey(name: string) { // const invalidPropertyPos = getInvalidPropertyPos(name); // return invalidPropertyPos === -1 ? name : quote(name, invalidPropertyPos); // } function toAssignment(parent: string, key: string | number) { return parent + toPropertyAccess(key); } function toPropertyAccess(key: string | number) { return typeof key === "number" || key[0] === '"' ? "[" + key + "]" : "." + key; } function toObjectKey(name: string | number) { if (typeof name !== "string") return name; let char = name[0]; if (char >= "0" && char <= "9") { // numeric for (let i = 1, len = name.length; i < len; i++) { char = name[i]; if (!(char >= "0" && char <= "9")) { return quote(name, i); } } return parseInt(name, 10); } else { // or valid identifier for (let i = 0, len = name.length; i < len; i++) { char = name[i]; if ( !( (char >= "a" && char <= "z") || (char >= "A" && char <= "Z") || (char >= "0" && char <= "9") || char === "$" || char === "_" ) ) { return quote(name, i); } } } return name; } // Creates a JavaScript double quoted string and escapes all characters not listed as DoubleStringCharacters on // Also includes "<" to escape "" and "\" to avoid invalid escapes in the output. // http://www.ecma-international.org/ecma-262/5.1/#sec-7.8.4 function quote(str: string, startPos: number): string { let result = ""; let lastPos = 0; for (let i = startPos, len = str.length; i < len; i++) { let replacement: string; switch (str[i]) { case '"': replacement = '\\"'; break; case "\\": replacement = "\\\\"; break; case "<": replacement = "\\x3C"; break; case "\n": replacement = "\\n"; break; case "\r": replacement = "\\r"; break; case "\u2028": replacement = "\\u2028"; break; case "\u2029": replacement = "\\u2029"; break; default: continue; } result += str.slice(lastPos, i) + replacement; lastPos = i + 1; } if (lastPos === startPos) { result = str; } else { result += str.slice(lastPos); } return '"' + result + '"'; } function toRefParam(index: number) { let mod = index % REF_START_CHARS_LEN; let ref = REF_START_CHARS[mod]; index = (index - mod) / REF_START_CHARS_LEN; while (index > 0) { mod = index % REF_CHARS_LEN; ref += REF_CHARS[mod]; index = (index - mod) / REF_CHARS_LEN; } return ref; }