diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 90377b0d1..f69de1451 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -931,6 +931,10 @@ "resolved": "packages/common-html", "link": true }, + "node_modules/@commontools/common-propagator": { + "resolved": "packages/common-propagator", + "link": true + }, "node_modules/@commontools/common-ui": { "resolved": "packages/common-ui", "link": true @@ -1059,10 +1063,12 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" ], + "peer": true, "engines": { "node": ">=12" } @@ -1074,10 +1080,12 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=12" } @@ -1089,10 +1097,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=12" } @@ -1104,10 +1114,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=12" } @@ -1119,10 +1131,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=12" } @@ -1134,10 +1148,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=12" } @@ -1149,10 +1165,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -1164,10 +1182,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -1179,10 +1199,12 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -1194,10 +1216,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -1209,10 +1233,12 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -1224,10 +1250,12 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -1239,10 +1267,12 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -1254,10 +1284,12 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -1269,10 +1301,12 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -1284,10 +1318,12 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -1299,10 +1335,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -1314,10 +1352,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -1329,10 +1369,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -1344,10 +1386,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=12" } @@ -1359,10 +1403,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=12" } @@ -1374,10 +1420,12 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=12" } @@ -1389,10 +1437,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=12" } @@ -1562,7 +1612,7 @@ "version": "0.3.6", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "devOptional": true, + "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -2571,10 +2621,12 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.18.0", @@ -2583,10 +2635,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.18.0", @@ -2595,10 +2649,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.18.0", @@ -2607,10 +2663,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.18.0", @@ -2619,10 +2677,12 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.18.0", @@ -2631,10 +2691,12 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.18.0", @@ -2643,10 +2705,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.18.0", @@ -2655,10 +2719,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "version": "4.18.0", @@ -2667,10 +2733,12 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.18.0", @@ -2679,10 +2747,12 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.18.0", @@ -2691,10 +2761,12 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.18.0", @@ -2703,10 +2775,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.18.0", @@ -2715,10 +2789,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.18.0", @@ -2727,10 +2803,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.18.0", @@ -2739,10 +2817,12 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.18.0", @@ -2751,10 +2831,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rushstack/node-core-library": { "version": "4.0.2", @@ -4496,7 +4578,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "devOptional": true + "dev": true }, "node_modules/builtin-modules": { "version": "3.3.0", @@ -5737,6 +5819,7 @@ "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -6205,6 +6288,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -9275,6 +9359,7 @@ "version": "4.18.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz", "integrity": "sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg==", + "dev": true, "dependencies": { "@types/estree": "1.0.5" }, @@ -9595,7 +9680,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "devOptional": true, + "dev": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -9605,7 +9690,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "devOptional": true, + "dev": true, "engines": { "node": ">=0.10.0" } @@ -10038,7 +10123,7 @@ "version": "5.31.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.1.tgz", "integrity": "sha512-37upzU1+viGvuFtBo9NPufCb9dwM0+l9hMxYyWfBA+fbwrPqNJAhbZ6W47bBFnZHKHTUBnMvi87434qq+qnxOg==", - "devOptional": true, + "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -10056,7 +10141,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "devOptional": true + "dev": true }, "node_modules/text-decoder": { "version": "1.1.0", @@ -10328,6 +10413,7 @@ "version": "5.3.4", "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.4.tgz", "integrity": "sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==", + "dev": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.39", @@ -11089,11 +11175,11 @@ } }, "packages/common-html": { + "name": "@commontools/common-html", "version": "0.0.1", "license": "UNLICENSED", "dependencies": { - "htmlparser2": "^9.1.0", - "vite": "^5.3.3" + "htmlparser2": "^9.1.0" }, "devDependencies": { "@types/mocha": "^10.0.7", @@ -11103,6 +11189,7 @@ "mocha": "^10.6.0", "tslib": "^2.6.2", "typescript": "^5.2.2", + "vite": "^5.3.3", "wireit": "^0.14.4" } }, @@ -11130,6 +11217,28 @@ "wireit": "^0.14.4" } }, + "packages/common-propagator": { + "name": "@commontools/common-propagator", + "version": "0.0.1", + "license": "UNLICENSED", + "devDependencies": { + "@types/mocha": "^10.0.7", + "@types/node": "^20.14.12", + "mocha": "^10.6.0", + "tslib": "^2.6.2", + "typescript": "^5.2.2", + "wireit": "^0.14.4" + } + }, + "packages/common-propagator/node_modules/@types/node": { + "version": "20.14.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", + "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, "packages/common-ui": { "name": "@commontools/common-ui", "version": "0.0.1", diff --git a/typescript/package.json b/typescript/package.json index 262e9ee81..ee05c2ae6 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -40,6 +40,7 @@ "./packages/common-html:build", "./packages/common-frp:build", "./packages/common-frp-lit:build", + "./packages/common-propagator:build", "./packages/common-ui:build", "./packages/llm-client:build", "./common/data:build", @@ -62,6 +63,7 @@ "./packages/common-html:clean", "./packages/common-frp:clean", "./packages/common-frp-lit:clean", + "./packages/common-propagator:clean", "./packages/common-ui:clean", "./packages/llm-client:clean", "./common/data:clean", diff --git a/typescript/packages/common-html/example/main.ts b/typescript/packages/common-html/example/main.ts index 5d14f7021..24bccb69e 100644 --- a/typescript/packages/common-html/example/main.ts +++ b/typescript/packages/common-html/example/main.ts @@ -1,27 +1,23 @@ -import render, { setNodeSanitizer, setEventSanitizer } from "../src/render.js"; +import { cell } from "@commontools/common-propagator"; import view from "../src/view.js"; import html from "../src/html.js"; -import { state, stream } from "../src/state.js"; +import render from "../src/render.js"; import { setDebug } from "../src/logger.js"; setDebug(true); -// setNodeSanitizer(...); -// setEventSanitizer(...); +const inputState = cell({ text: "Hello, world!" }); +const inputEvents = cell(null); -const text = state("Hello, world!"); -const input = stream(); - -input.sink((event) => { - console.log("input", event); - const target = event.target as HTMLInputElement | null; - const value = target?.value ?? null; - if (value !== null) { - text.send(value); +inputEvents.sink((event) => { + const target = event?.target as HTMLInputElement | null; + const value = target?.value; + if (value != null) { + inputState.send({ text: value }); } }); -const time = state(new Date().toLocaleTimeString()); +const time = cell(new Date().toLocaleTimeString()); setInterval(() => { time.send(new Date().toLocaleTimeString()); @@ -33,17 +29,15 @@ const timeView = view(`
{{time}}
`, { time }); const titleGroup = view( `
-

{{text}}

- +

{{input.text}}

+
`, - { text, input }, + { input: inputState, oninput: inputEvents }, ); const container = html`
${timeView} ${titleGroup}
`; -const dom = render(container); - -document.body.appendChild(dom); +const _cancel = render(document.body, container); diff --git a/typescript/packages/common-html/package.json b/typescript/packages/common-html/package.json index 56c729d4a..a540011bb 100644 --- a/typescript/packages/common-html/package.json +++ b/typescript/packages/common-html/package.json @@ -11,7 +11,7 @@ "test-browser": "npm run build && web-test-runner \"lib/test-browser/**/*.test.js\" --node-resolve", "build": "wireit", "clean": "wireit", - "dev": "vite example" + "dev": "npm run build && vite example" }, "repository": { "type": "git", @@ -26,10 +26,10 @@ "./lib/index.js" ], "dependencies": { - "htmlparser2": "^9.1.0", - "vite": "^5.3.3" + "htmlparser2": "^9.1.0" }, "devDependencies": { + "vite": "^5.3.3", "@types/mocha": "^10.0.7", "@types/node": "^20.14.10", "@types/sax": "^1.2.7", @@ -41,7 +41,9 @@ }, "wireit": { "build": { - "dependencies": [], + "dependencies": [ + "../common-propagator:build" + ], "files": [ "./src/**/*" ], diff --git a/typescript/packages/common-html/src/hole.ts b/typescript/packages/common-html/src/hole.ts deleted file mode 100644 index 32d6192fd..000000000 --- a/typescript/packages/common-html/src/hole.ts +++ /dev/null @@ -1,54 +0,0 @@ -export type Hole = { - type: "hole"; - name: string; -}; - -const holeNameRegex = /^(\w+)$/; - -export const create = (name: string): Hole => { - if (name.match(holeNameRegex) == null) { - throw TypeError("Template hole names must be alphanumeric"); - } - return { - type: "hole", - name, - }; -}; - -export const isHole = (value: unknown): value is Hole => { - return ( - typeof value === "object" && - value !== null && - (value as Hole).type === "hole" - ); -}; - -export const markup = (name: string) => { - if (name.match(holeNameRegex) == null) { - throw TypeError("Template hole names must be alphanumeric"); - } - return `{{${name}}}`; -}; - -const mustacheRegex = /{{(\w+)}}/g; - -/** Parse mustaches in free text, returning an array of text and objects */ -export const parse = (text: string) => { - const result = []; - let lastIndex = 0; - let match: RegExpMatchArray | null = null; - - while ((match = mustacheRegex.exec(text)) !== null) { - if (match.index > lastIndex) { - result.push(text.slice(lastIndex, match.index)); - } - result.push(create(match[1])); - lastIndex = mustacheRegex.lastIndex; - } - - if (lastIndex < text.length) { - result.push(text.slice(lastIndex)); - } - - return result; -}; diff --git a/typescript/packages/common-html/src/html.ts b/typescript/packages/common-html/src/html.ts index e473445ea..f9bf40f50 100644 --- a/typescript/packages/common-html/src/html.ts +++ b/typescript/packages/common-html/src/html.ts @@ -1,7 +1,6 @@ -import * as hole from "./hole.js"; import * as logger from "./logger.js"; -import cid from "./cid.js"; -import { view, View } from "./view.js"; +import tid from "./tid.js"; +import { view, View, markupBinding } from "./view.js"; export const html = ( strings: TemplateStringsArray, @@ -13,7 +12,7 @@ export const html = ( // Create pairs of name/value by generating name const namedValues: Array<[string, unknown]> = values.map((value) => { - return [cid(), value]; + return [tid(), value]; }); // Flatten template string @@ -23,7 +22,7 @@ export const html = ( return result + string; } const [name] = namedValue; - return result + string + hole.markup(name); + return result + string + markupBinding(name); }, ""); logger.debug("Flattened", markup); diff --git a/typescript/packages/common-html/src/index.ts b/typescript/packages/common-html/src/index.ts index f00c78dec..9f32516e5 100644 --- a/typescript/packages/common-html/src/index.ts +++ b/typescript/packages/common-html/src/index.ts @@ -1,8 +1,16 @@ -export { view, View, Context } from "./view.js"; +export { + view, + View, + Context, + parse, + ParseError, + vnode, + VNode, + binding, + Binding, + section, + Section, +} from "./view.js"; export { html } from "./html.js"; export { render, setNodeSanitizer, setEventSanitizer } from "./render.js"; -export { VNode } from "./vnode.js"; -export { Hole } from "./hole.js"; -export { Reactive } from "./reactive.js"; export { setDebug } from "./logger.js"; -export { cancel, Cancel } from "./cancel.js"; diff --git a/typescript/packages/common-html/src/parser.ts b/typescript/packages/common-html/src/parser.ts deleted file mode 100644 index 86e12a6e6..000000000 --- a/typescript/packages/common-html/src/parser.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Parser } from "htmlparser2"; -import { parse as parseMustaches, isHole } from "./hole.js"; -import { create as createVNode, VNode, Props } from "./vnode.js"; -import * as logger from "./logger.js"; - -/** Parse a template into a simple JSON markup representation */ -export const parse = (markup: string): VNode => { - let root: VNode = createVNode("documentfragment"); - let stack: Array = [root]; - - const parser = new Parser( - { - onopentag(name, attrs) { - logger.debug("Open", name, attrs); - // We've turned off the namespace feature, so node attributes will - // contain only string values, not QualifiedAttribute objects. - const props = parseProps(attrs as { [key: string]: string }); - const next = createVNode(name, props); - const top = getTop(stack); - if (!top) { - throw new ParseError(`No parent tag for ${name}`); - } - top.children.push(next); - stack.push(next); - }, - onclosetag(name) { - const vnode = stack.pop(); - if (!vnode) { - throw new ParseError(`Unexpected closing tag ${name}`); - } - }, - ontext(text) { - const top = getTop(stack); - const parsed = parseMustaches(text.trim()); - top.children.push(...parsed); - }, - }, - { - lowerCaseTags: true, - lowerCaseAttributeNames: true, - xmlMode: false, - }, - ); - - parser.write(markup); - parser.end(); - return root; -}; - -export default parse; - -const getTop = (stack: Array): VNode | null => stack.at(-1) ?? null; - -const parseProps = (attrs: { [key: string]: string }): Props => { - const result: Props = {}; - for (const [key, value] of Object.entries(attrs)) { - const parsed = parseMustaches(value); - const first = parsed.at(0); - if (parsed.length !== 1) { - result[key] = ""; - } else if (isHole(first)) { - result[key] = first; - } else { - result[key] = `${value}`; - } - } - return result; -}; - -export class ParseError extends TypeError { - constructor(message: string) { - super(message); - this.name = this.constructor.name; - } -} diff --git a/typescript/packages/common-html/src/reactive.ts b/typescript/packages/common-html/src/reactive.ts deleted file mode 100644 index c2ca9e6ba..000000000 --- a/typescript/packages/common-html/src/reactive.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Cancel, isCancel } from "./cancel.js"; - -/** - * A reactive value is any type with a `sink()` method that can be used - * to subscribe to updates. - * - `sink()` must take a callback function that will be called with the - * updated value. - * - `sink()` must return a `Cancel` function that can be called to unsubscribe. - */ -export type Reactive = { - sink: (callback: (value: T) => void) => Cancel; -}; - -export const isReactive = (value: unknown): value is Reactive => { - return typeof (value as Reactive)?.sink === "function"; -}; - -export const effect = ( - value: unknown, - callback: (value: unknown) => Cancel | void, -) => { - if (value == null) { - return noOp; - } - - let cleanup: Cancel = noOp; - if (isReactive(value)) { - const cancelSink = value.sink((value: unknown) => { - cleanup(); - const next = callback(value); - cleanup = isCancel(next) ? next : noOp; - }); - return () => { - cancelSink(); - cleanup(); - }; - } - - const maybeCleanup = callback(value); - return isCancel(maybeCleanup) ? maybeCleanup : noOp; -}; - -const noOp = () => {}; diff --git a/typescript/packages/common-html/src/render.ts b/typescript/packages/common-html/src/render.ts index f747a9090..de065b017 100644 --- a/typescript/packages/common-html/src/render.ts +++ b/typescript/packages/common-html/src/render.ts @@ -1,24 +1,32 @@ -import { isVNode, VNode } from "./vnode.js"; -import { View, Context, isView } from "./view.js"; -import { isHole } from "./hole.js"; -import { effect } from "./reactive.js"; -import { isSendable } from "./sendable.js"; -import { useCancelGroup, Cancel } from "./cancel.js"; +import { + View, + Context, + isView, + isVNode, + VNode, + isBinding, + Props, + Child, + isSection, + getContext, +} from "./view.js"; +import { effect, isSendable } from "@commontools/common-propagator/reactive.js"; +import { + useCancelGroup, + Cancel, +} from "@commontools/common-propagator/cancel.js"; import * as logger from "./logger.js"; -export type CancellableHTMLElement = HTMLElement & { cancel?: Cancel }; - -export const render = (renderable: View): HTMLElement => { - const { template, context } = renderable; - const [cancel, addCancel] = useCancelGroup(); - const root = renderNode( - template, - context, - addCancel, - ) as CancellableHTMLElement; - root.cancel = cancel; +export const render = (parent: HTMLElement, view: View): Cancel => { + const { template, context } = view; + const [root, cancel] = renderNode(template, context); + if (!root) { + logger.warn("Could not render view", view); + return cancel; + } + parent.append(root); logger.debug("Rendered", root); - return root; + return cancel; }; export default render; @@ -26,59 +34,63 @@ export default render; const renderNode = ( node: VNode, context: Context, - addCancel: (cancel: Cancel) => void, -): HTMLElement | null => { +): [HTMLElement | null, Cancel] => { + const [cancel, addCancel] = useCancelGroup(); + const sanitizedNode = sanitizeNode(node); + if (!sanitizedNode) { - return null; + return [null, cancel]; } - const element = document.createElement(sanitizedNode.tag); - attrs: for (const [name, value] of Object.entries(sanitizedNode.props)) { - if (isHole(value)) { - const replacement = context[value.name]; - // If prop is an event, we need to add an event listener - if (isEventProp(name)) { - if (!isSendable(replacement)) { - throw new TypeError( - `Event prop "${name}" does not have a send method`, - ); - } - const key = cleanEventProp(name); - const cancel = listen(element, key, (event) => { - const sanitizedEvent = sanitizeEvent(event); - replacement.send(sanitizedEvent); - }); - addCancel(cancel); - } else { - const cancel = effect(replacement, (replacement) => { - // Replacements are set as properties not attributes to avoid - // string serialization of complex datatypes. - setProp(element, name, replacement); - }); - addCancel(cancel); - } - } else { - element.setAttribute(name, value); - } - } - for (const childNode of sanitizedNode.children) { - if (typeof childNode === "string") { - element.append(childNode); - } else if (isVNode(childNode)) { - const childElement = renderNode(childNode, context, addCancel); + + const element = document.createElement(sanitizedNode.name); + + const cancelProps = bindProps(element, sanitizedNode.props, context); + addCancel(cancelProps); + + const cancelChildren = bindChildren(element, sanitizedNode.children, context); + addCancel(cancelChildren); + + return [element, cancel]; +}; + +const bindChildren = ( + element: HTMLElement, + children: Array, + context: Context, +): Cancel => { + const [cancel, addCancel] = useCancelGroup(); + + for (const child of children) { + if (typeof child === "string") { + // Bind static content + element.append(child); + } else if (isVNode(child)) { + // Bind static VNode + const [childElement, cancel] = renderNode(child, context); + addCancel(cancel); if (childElement) { element.append(childElement); } - } else if (isHole(childNode)) { - const replacement = context[childNode.name]; + } else if (isBinding(child)) { + // Bind dynamic content + const replacement = getContext(context, child.path); // Anchor for reactive replacement let anchor: ChildNode = document.createTextNode(""); element.append(anchor); const cancel = effect(replacement, (replacement) => { if (isView(replacement)) { - const childElement = render(replacement); - anchor.replaceWith(childElement); - anchor = childElement; + const [childElement, cancel] = renderNode( + replacement.template, + replacement.context, + ); + addCancel(cancel); + if (childElement != null) { + anchor.replaceWith(childElement); + anchor = childElement; + } else { + logger.warn("Could not render view", replacement); + } } else { const text = document.createTextNode(`${replacement}`); anchor.replaceWith(text); @@ -86,9 +98,52 @@ const renderNode = ( } }); addCancel(cancel); + } else if (isSection(child)) { + logger.warn("Sections not yet implemented"); + } + } + return cancel; +}; + +const bindProps = ( + element: HTMLElement, + props: Props, + context: Context, +): Cancel => { + const [cancel, addCancel] = useCancelGroup(); + for (const [propKey, propValue] of Object.entries(props)) { + if (isBinding(propValue)) { + const replacement = getContext(context, propValue.path); + // If prop is an event, we need to add an event listener + if (isEventProp(propKey)) { + if (!isSendable(replacement)) { + throw new TypeError( + `Event prop "${propKey}" does not have a send method`, + ); + } + const key = cleanEventProp(propKey); + if (key != null) { + const cancel = listen(element, key, (event) => { + const sanitizedEvent = sanitizeEvent(event); + replacement.send(sanitizedEvent); + }); + addCancel(cancel); + } else { + logger.warn("Could not bind event", propKey, propValue); + } + } else { + const cancel = effect(replacement, (replacement) => { + // Replacements are set as properties not attributes to avoid + // string serialization of complex datatypes. + setProp(element, propKey, replacement); + }); + addCancel(cancel); + } + } else { + element.setAttribute(propKey, propValue); } } - return element; + return cancel; }; const isEventProp = (key: string) => key.startsWith("on"); @@ -121,7 +176,7 @@ const setProp = (target: T, key: string, value: unknown) => { }; const sanitizeScripts = (node: VNode): VNode | null => { - if (node.tag === "script") { + if (node.name === "script") { return null; } return node; diff --git a/typescript/packages/common-html/src/sendable.ts b/typescript/packages/common-html/src/sendable.ts deleted file mode 100644 index 00c8d852f..000000000 --- a/typescript/packages/common-html/src/sendable.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type Sendable = { - send: (value: T) => void; -}; - -export const isSendable = ( - value: unknown -): value is Sendable => { - return typeof (value as Sendable)?.send === "function"; -} \ No newline at end of file diff --git a/typescript/packages/common-html/src/state.ts b/typescript/packages/common-html/src/state.ts deleted file mode 100644 index 7426a5597..000000000 --- a/typescript/packages/common-html/src/state.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** A simple reactive state cell without any scheduling */ -export const state = (value: T) => { - let state = value; - const listeners = new Set<(value: T) => void>(); - - const get = () => state; - - const sink = (callback: (value: T) => void) => { - listeners.add(callback); - callback(state); - return () => { - listeners.delete(callback); - }; - }; - - const send = (value: T) => { - state = value; - for (const listener of listeners) { - listener(state); - } - }; - - return { - get, - sink, - send, - }; -}; - -/** A simple reactive event stream without any scheduling */ -export const stream = () => { - const listeners = new Set<(value: T) => void>(); - - const sink = (callback: (value: T) => void) => { - listeners.add(callback); - return () => { - listeners.delete(callback); - }; - }; - - const send = (value: T) => { - for (const listener of listeners) { - listener(value); - } - }; - - return { - sink, - send, - }; -}; - -export default state; diff --git a/typescript/packages/common-html/src/test-browser/render.test.ts b/typescript/packages/common-html/src/test-browser/render.test.ts index db9886aff..68f4ae17c 100644 --- a/typescript/packages/common-html/src/test-browser/render.test.ts +++ b/typescript/packages/common-html/src/test-browser/render.test.ts @@ -1,16 +1,41 @@ // import { equal as assertEqual } from "./assert.js"; import render from "../render.js"; import html from "../html.js"; -// import state from "../state.js"; +import view from "../view.js"; +import { cell } from "@commontools/common-propagator"; +import * as assert from "./assert.js"; describe("render", () => { it("renders", () => { const renderable = html` -
-

Hello world!

-
+
+

Hello world!

+
`; - const dom = render(renderable); - console.log(dom); + const parent = document.createElement("div"); + render(parent, renderable); + assert.equal(parent.firstElementChild?.className, "hello"); + assert.equal(parent.querySelector("p")?.textContent, "Hello world!"); + }); + + it("binds deep paths on variables", () => { + const a = cell({ b: { c: "Hello world!" } }); + + const renderable = view( + ` +
+

{{a.b.c}}

+
+ `, + { a }, + ); + const parent = document.createElement("div"); + render(parent, renderable); + + assert.equal(parent.querySelector("p")?.textContent, "Hello world!"); + + a.send({ b: { c: "Goodbye world!" } }); + + assert.equal(parent.querySelector("p")?.textContent, "Goodbye world!"); }); }); diff --git a/typescript/packages/common-html/src/test/hole.test.ts b/typescript/packages/common-html/src/test/hole.test.ts deleted file mode 100644 index 23fa4e4d2..000000000 --- a/typescript/packages/common-html/src/test/hole.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import * as assert from "node:assert"; -import * as hole from "../hole.js"; - -describe("hole.markup()", () => { - it("it wraps the key in curly braces", () => { - const markup = hole.markup("key"); - assert.strictEqual(markup, "{{key}}"); - }); - - it("throws if key is not alphanumeric", () => { - assert.throws(() => hole.markup("bad key with spaces")); - }); -}); - -describe("hole.create()", () => { - it("it creates a hole", () => { - const keyHole = hole.create("key"); - assert.deepStrictEqual(keyHole, { - type: "hole", - name: "key", - }); - }); - - it("throws if key is not alphanumeric", () => { - assert.throws(() => hole.create("bad key with spaces")); - }); -}); - -describe("hole.parse()", () => { - it("parses", () => { - const xml = `Hello {{world}}!`; - const result = hole.parse(xml); - assert.deepStrictEqual(result, ["Hello ", hole.create("world"), "!"]); - }); - - it("does not parse staches with non-word characters in them", () => { - const xml = `Hello {{ world }}!`; - const result = hole.parse(xml); - assert.deepStrictEqual(result, ["Hello {{ world }}!"]); - }); - - it("handles broken staches", () => { - const xml = `Hello {{world}!`; - const result = hole.parse(xml); - assert.deepStrictEqual(result, ["Hello {{world}!"]); - }); - - it("handles broken staches 2", () => { - const xml = `Hello {world}}!`; - const result = hole.parse(xml); - assert.deepStrictEqual(result, ["Hello {world}}!"]); - }); - - it("handles broken staches 3", () => { - const xml = `Hello {{wor}ld}}!`; - const result = hole.parse(xml); - assert.deepStrictEqual(result, ["Hello {{wor}ld}}!"]); - }); - - it("handles broken staches 4", () => { - const xml = `Hello {{wor}}ld}}!`; - const result = hole.parse(xml); - assert.deepStrictEqual(result, ["Hello ", hole.create("wor"), "ld}}!"]); - }); -}); diff --git a/typescript/packages/common-html/src/test/html.test.ts b/typescript/packages/common-html/src/test/html.test.ts index effae722f..e2a8c4f29 100644 --- a/typescript/packages/common-html/src/test/html.test.ts +++ b/typescript/packages/common-html/src/test/html.test.ts @@ -1,12 +1,12 @@ import * as assert from "node:assert"; import html from "../html.js"; -import * as hole from "../hole.js"; -import { state, stream } from "../state.js"; +import { isBinding } from "../view.js"; +import { cell } from "@commontools/common-propagator"; describe("html", () => { it("parses tagged template string into a Renderable", () => { - const clicks = stream(); - const text = state("Hello world!"); + const clicks = cell(null); + const text = cell("Hello world!"); const view = html`
@@ -15,9 +15,9 @@ describe("html", () => { `; // @ts-ignore - ignore for test - assert.strict(hole.isHole(view.template.children[0].props.onclick)); + assert.strict(isBinding(view.template.children[0].props.onclick)); // @ts-ignore - ignore for test - assert.strict(hole.isHole(view.template.children[0].children[0])); + assert.strict(isBinding(view.template.children[0].children[0])); }); }); diff --git a/typescript/packages/common-html/src/test/parser.test.ts b/typescript/packages/common-html/src/test/parser.test.ts deleted file mode 100644 index 38d28eadc..000000000 --- a/typescript/packages/common-html/src/test/parser.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { deepStrictEqual } from "node:assert"; -import parse from "../parser.js"; -import * as vnode from "../vnode.js"; -import * as hole from "../hole.js"; - -describe("parse", () => { - it("parses", () => { - const xml = ` - - `; - - const root = parse(xml); - - deepStrictEqual( - root, - vnode.create("documentfragment", {}, [ - vnode.create( - "div", - { class: "container", hidden: hole.create("hidden") }, - [ - vnode.create( - "button", - { id: "foo", onclick: hole.create("click") }, - ["Hello world!"], - ), - ], - ), - ]), - ); - }); -}); diff --git a/typescript/packages/common-html/src/test/reactive.test.ts b/typescript/packages/common-html/src/test/reactive.test.ts deleted file mode 100644 index 9edc07ec5..000000000 --- a/typescript/packages/common-html/src/test/reactive.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { equal as assertEqual } from "node:assert/strict"; -import state from "../state.js"; -import { effect, isReactive } from "../reactive.js"; - -describe("isReactive", () => { - it("returns true for any object with a sink method", () => { - const a = state(0); - assertEqual(isReactive(a), true); - - class B { - sink() {} - } - - assertEqual(isReactive(new B()), true); - }); - - it("returns false for objects without a sink method", () => { - assertEqual(isReactive({}), false); - }); -}); - -describe("effect", () => { - it("runs callback for nonreactive values", () => { - let calls = 0; - effect(10, (_value: number) => { - calls++; - }); - assertEqual(calls, 1); - }); - - it("subscribes callback to `sink` for reactive value", () => { - const value = state(10); - - let valueMut = 0; - effect(value, (value: number) => { - valueMut = value; - }); - - value.send(11); - value.send(12); - - assertEqual(valueMut, 12); - }); - - it("ends subscription to reactive value when cancel is called", () => { - const value = state(10); - - let valueMut = 0; - const cancel = effect(value, (value: number) => { - valueMut = value; - }); - - value.send(11); - cancel(); - value.send(12); - - assertEqual(valueMut, 11); - }); - - it("returns a cancel function", () => { - const cancel = effect(10, (_value: number) => {}); - assertEqual(typeof cancel, "function"); - }); - - it("runs any returned cleanup function when cancel is run", () => { - let calls = 0; - const cancel = effect(10, (_value: number) => { - const cleanup = () => calls++; - return cleanup; - }); - cancel(); - assertEqual(calls, 1); - }); -}); diff --git a/typescript/packages/common-html/src/test/view.test.ts b/typescript/packages/common-html/src/test/view.test.ts index 54f65b9a2..ef4be4792 100644 --- a/typescript/packages/common-html/src/test/view.test.ts +++ b/typescript/packages/common-html/src/test/view.test.ts @@ -1,5 +1,4 @@ -import { view } from "../view.js"; -import * as hole from "../hole.js"; +import { view, section, parse, binding, vnode, parsePath } from "../view.js"; import * as assert from "node:assert/strict"; describe("view()", () => { @@ -7,12 +6,7 @@ describe("view()", () => { const hello = view("
Hello world!
", {}); assert.deepStrictEqual(hello, { type: "view", - template: { - type: "vnode", - tag: "div", - props: {}, - children: ["Hello world!"], - }, + template: vnode("div", {}, ["Hello world!"]), context: {}, }); }); @@ -24,14 +18,7 @@ describe("view()", () => { }); assert.deepStrictEqual(hello, { type: "view", - template: { - type: "vnode", - tag: "div", - props: { - hidden: hole.create("hidden"), - }, - children: [hole.create("text")], - }, + template: vnode("div", { hidden: binding("hidden") }, [binding("text")]), context: { hidden: false, text: "Hello world!", @@ -39,3 +26,63 @@ describe("view()", () => { }); }); }); + +describe("parse()", () => { + it("parses", () => { + const xml = ` + + `; + + const root = parse(xml); + + assert.deepEqual( + root, + vnode("documentfragment", {}, [ + vnode("div", { class: "container", hidden: binding("hidden") }, [ + vnode("button", { id: "foo", onclick: binding("click") }, [ + "Hello world!", + ]), + ]), + ]), + ); + }); + + it("parses mustache blocks embedded in HTML", () => { + const xml = ` +
+ {{#items}} +
{{text}}
+ {{/items}} +
+ `; + + const root = parse(xml); + + assert.deepEqual( + root, + vnode("documentfragment", {}, [ + vnode("div", { class: "container" }, [ + section("items", [ + vnode("div", { class: "item" }, [binding("text")]), + ]), + ]), + ]), + ); + }); +}); + +describe("parsePath()", () => { + it("parses paths without dots", () => { + assert.deepEqual(parsePath("foo"), ["foo"]); + }); + + it("parses paths with dots", () => { + assert.deepEqual(parsePath("foo.bar.baz"), ["foo", "bar", "baz"]); + }); + + it("parses path with only a dot", () => { + assert.deepEqual(parsePath("."), []); + }); +}); diff --git a/typescript/packages/common-html/src/tid.ts b/typescript/packages/common-html/src/tid.ts new file mode 100644 index 000000000..5e05a5dc3 --- /dev/null +++ b/typescript/packages/common-html/src/tid.ts @@ -0,0 +1,6 @@ +let _tid = 0; + +/** Generate a unique client id for a template */ +export const tid = () => `tid${_tid++}`; + +export default tid; diff --git a/typescript/packages/common-html/src/view.ts b/typescript/packages/common-html/src/view.ts index b0280e1be..f3fff606f 100644 --- a/typescript/packages/common-html/src/view.ts +++ b/typescript/packages/common-html/src/view.ts @@ -1,31 +1,20 @@ -import { isVNode, VNode } from "./vnode.js"; -import parse from "./parser.js"; +import { Parser } from "htmlparser2"; import * as logger from "./logger.js"; +import { path } from "@commontools/common-propagator/path.js"; -export type Context = { [key: string]: unknown }; - -export type View = { - type: "view"; - template: VNode; - context: Context; -}; - -export const isView = (value: unknown): value is View => { - return (value as View)?.type === "view"; -}; - +/** Parse a markup string and context into a view */ export const view = (markup: string, context: Context): View => { // Parse template string to template object const root = parse(markup); if (root.children.length !== 1) { - throw TypeError("Template should have only one root node"); + throw new ParseError("Template should have only one root node"); } const template = root.children[0]; if (!isVNode(template)) { - throw TypeError("Template root must be an element"); + throw new ParseError("Template root must be an element"); } const view: View = { @@ -34,9 +23,338 @@ export const view = (markup: string, context: Context): View => { context, }; - logger.debug("View", view); + logger.debug("view", view); return view; }; export default view; + +export type View = { + type: "view"; + template: VNode; + context: Context; +}; + +export const isView = (value: unknown): value is View => { + return (value as View)?.type === "view"; +}; + +export type Context = { [key: string]: unknown }; + +export type Gettable = { + get: () => T; +}; + +export const get = (value: unknown): unknown => { + const subject = value as Gettable; + if (typeof subject?.get === "function" && subject?.get?.length === 0) { + return subject.get(); + } + return subject; +}; + +/** Get context item by key */ +export const getContext = path; + +/** + * Dynamic properties. Can either be string type (static) or a Mustache + * variable (dynamic). + */ +export type Props = { [key: string]: string | Binding }; + +/** A child in a view can be one of a few things */ +export type Child = VNode | Section | Binding | string; + +/** A "virtual view node", e.g. a virtual DOM element */ +export type VNode = { + type: "vnode"; + name: string; + props: Props; + children: Array; +}; + +/** Create a vnode */ +export const vnode = ( + name: string, + props: Props = {}, + children: Array = [], +): VNode => { + return { type: "vnode", name, props, children }; +}; + +export const isVNode = (value: unknown): value is VNode => { + return (value as VNode)?.type === "vnode"; +}; + +/** A mustache variable `{{myvar}}` */ +export type Binding = { + type: "binding"; + name: string; + path: Array; +}; + +export const binding = (name: string): Binding => { + return { type: "binding", name, path: parsePath(name) }; +}; + +export const parsePath = (pathString: string): Array => { + if (pathString === ".") { + const path: Array = []; + logger.debug("parsePath", path); + return path; + } + const path = pathString.split("."); + logger.debug("parsePath", path); + return path; +}; + +export const isBinding = (value: unknown): value is Binding => { + return (value as Binding)?.type === "binding"; +}; + +export const markupBinding = (name: string) => `{{${name}}}`; + +/** A mustache block `{{#myblock}} ... {{/myblock}}` */ +export type Section = { + type: "section"; + name: string; + path: Array; + children: Array; +}; + +export const section = (name: string, children: Array = []): Section => { + return { type: "section", name, path: parsePath(name), children }; +}; + +export const isSection = (value: unknown): value is Section => { + return (value as Section)?.type === "section"; +}; + +export type TagOpenToken = { + type: "tagopen"; + name: string; + props: Props; +}; + +export type TagCloseToken = { + type: "tagclose"; + name: string; +}; + +export type TextToken = { + type: "text"; + value: string; +}; + +export type BindingToken = { + type: "binding"; + name: string; +}; + +export type SectionOpenToken = { + type: "sectionopen"; + name: string; +}; + +export type SectionCloseToken = { + type: "sectionclose"; + name: string; +}; + +export type Token = + | TagOpenToken + | TagCloseToken + | TextToken + | BindingToken + | SectionOpenToken + | SectionCloseToken; + +/** Tokenize markup containing HTML and Mustache */ +export const tokenize = (markup: string): Array => { + const tokens: Array = []; + + const parser = new Parser( + { + onopentag(name, attrs) { + // We've turned off the namespace feature, so node attributes will + // contain only string values, not QualifiedAttribute objects. + const props = parseProps(attrs as { [key: string]: string }); + const token: TagOpenToken = { type: "tagopen", name, props }; + logger.debug("tagopen", token); + tokens.push(token); + }, + onclosetag(name) { + const token: TagCloseToken = { type: "tagclose", name }; + logger.debug("tagclose", token); + tokens.push(token); + }, + ontext(text) { + const parsed = tokenizeMustache(text.trim()); + tokens.push(...parsed); + }, + }, + { + lowerCaseTags: true, + lowerCaseAttributeNames: true, + xmlMode: false, + }, + ); + + parser.write(markup); + parser.end(); + + return tokens; +}; + +const MUSTACHE_REGEXP = /{{([^\}]+)}}/; +const MUSTACHE_REGEXP_G = new RegExp(MUSTACHE_REGEXP, "g"); + +/** Tokenize Mustache */ +export const tokenizeMustache = (text: string): Array => { + const tokens: Array = []; + MUSTACHE_REGEXP_G.lastIndex = 0; + let lastIndex = 0; + let match: RegExpMatchArray | null = null; + while ((match = MUSTACHE_REGEXP_G.exec(text)) !== null) { + if (match.index! > lastIndex) { + const token: TextToken = { + type: "text", + value: text.slice(lastIndex, match.index), + }; + logger.debug("text", token); + tokens.push(token); + } + const body = match[1]; + if (body.startsWith("#")) { + const token: SectionOpenToken = { + type: "sectionopen", + name: body.slice(1), + }; + logger.debug("sectionopen", token); + tokens.push(token); + } else if (body.startsWith("/")) { + const token: SectionCloseToken = { + type: "sectionclose", + name: body.slice(1), + }; + logger.debug("sectionclose", token); + tokens.push(token); + } else { + const token: BindingToken = { type: "binding", name: body }; + logger.debug("binding", token); + tokens.push(token); + } + lastIndex = MUSTACHE_REGEXP_G.lastIndex; + } + + if (lastIndex < text.length) { + const token: TextToken = { + type: "text", + value: text.slice(lastIndex), + }; + logger.debug("text", token); + tokens.push(token); + } + + MUSTACHE_REGEXP.lastIndex = 0; + + return tokens; +}; + +/** + * Parse a template containing HTML and Mustache into a simple JSON + * markup representation + */ +export const parse = (markup: string): VNode => { + let root: VNode = vnode("documentfragment"); + let stack: Array = [root]; + + for (const token of tokenize(markup)) { + const top = getTop(stack)!; + switch (token.type) { + case "tagopen": { + const next = vnode(token.name, token.props); + top.children.push(next); + stack.push(next); + break; + } + case "tagclose": { + const top = stack.pop(); + if (!isVNode(top) || top.name !== token.name) { + throw new ParseError( + `Unexpected closing tag ${token.name} in ${top?.name}`, + ); + } + break; + } + case "sectionopen": { + const next = section(token.name); + top.children.push(next); + stack.push(next); + break; + } + case "sectionclose": { + const top = stack.pop(); + if (!isSection(top) || top.name !== token.name) { + throw new ParseError( + `Unexpected closing block ${token.name} in ${top?.name}`, + ); + } + break; + } + case "text": { + top.children.push(token.value); + break; + } + case "binding": { + top.children.push(binding(token.name)); + break; + } + default: { + throw new ParseError(`Unexpected token ${JSON.stringify(token)}`); + } + } + } + + return root; +}; + +/** Get top of stack (last element) */ +const getTop = (stack: Array): VNode | Section | null => + stack.at(-1) ?? null; + +/** Parse a Mustache var if and only if it is the only element in a string */ +export const parseMustacheBinding = (markup: string): Binding | null => { + const match = markup.match(MUSTACHE_REGEXP); + if (match == null) { + return null; + } + const body = match[1]; + // Blocks are not allowed + if (body.startsWith("#") || body.startsWith("/")) { + throw new ParseError(`Unexpected block ${body}`); + } + return binding(body); +}; + +/** Parse view props from attrs */ +const parseProps = (attrs: { [key: string]: string }): Props => { + const result: Props = {}; + for (const [key, value] of Object.entries(attrs)) { + const parsed = parseMustacheBinding(value); + if (parsed != null) { + result[key] = parsed; + } else { + result[key] = `${value}`; + } + } + return result; +}; + +export class ParseError extends TypeError { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } +} diff --git a/typescript/packages/common-html/src/vnode.ts b/typescript/packages/common-html/src/vnode.ts deleted file mode 100644 index 7e2856713..000000000 --- a/typescript/packages/common-html/src/vnode.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Hole } from "./hole.js"; - -export type VNode = { - type: "vnode"; - tag: string; - props: Props; - children: Children; -}; - -export type Props = { [key: string]: string | Hole }; - -export type Children = Array; - -export const create = ( - tag: string, - props: Props = {}, - children: Children = [], -): VNode => ({ - type: "vnode", - tag, - props, - children, -}); - -export const isVNode = (value: unknown): value is VNode => { - return (value as VNode)?.type === "vnode"; -}; diff --git a/typescript/packages/common-html/tsconfig.json b/typescript/packages/common-html/tsconfig.json index ac93be5ee..06087d483 100644 --- a/typescript/packages/common-html/tsconfig.json +++ b/typescript/packages/common-html/tsconfig.json @@ -5,9 +5,8 @@ "outDir": "./lib", "rootDir": "./src", "strict": false, - "types": ["node", "mocha"] + "types": ["node", "mocha"], + "strictNullChecks": true }, - "include": [ - "src/**/*", - ] + "include": ["src/**/*"] } diff --git a/typescript/packages/common-propagator/package.json b/typescript/packages/common-propagator/package.json new file mode 100644 index 000000000..a41cd4344 --- /dev/null +++ b/typescript/packages/common-propagator/package.json @@ -0,0 +1,67 @@ +{ + "name": "@commontools/common-propagator", + "author": "The Common Authors", + "version": "0.0.1", + "description": "Reactive cells and propagators", + "license": "UNLICENSED", + "private": true, + "type": "module", + "scripts": { + "build": "wireit", + "clean": "wireit", + "test": "npm run build && mocha lib/test/**/*.test.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/commontoolsinc/labs.git" + }, + "bugs": { + "url": "https://github.com/commontoolsinc/labs/issues" + }, + "homepage": "https://github.com/commontoolsinc/labs#readme", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./propagator.js": { + "types": "./lib/propagator.d.ts", + "default": "./lib/propagator.js" + }, + "./cancel.js": { + "types": "./lib/cancel.d.ts", + "default": "./lib/cancel.js" + }, + "./path.js": { + "types": "./lib/path.d.ts", + "default": "./lib/path.js" + }, + "./reactive.js": { + "types": "./lib/reactive.d.ts", + "default": "./lib/reactive.js" + } + }, + "devDependencies": { + "@types/mocha": "^10.0.7", + "@types/node": "^20.14.12", + "mocha": "^10.6.0", + "tslib": "^2.6.2", + "typescript": "^5.2.2", + "wireit": "^0.14.4" + }, + "wireit": { + "build": { + "dependencies": [], + "files": [ + "./src/**/*" + ], + "output": [ + "./lib/**/*" + ], + "command": "tsc --build -f" + }, + "clean": { + "command": "rm -rf ./lib ./.wireit" + } + } +} diff --git a/typescript/packages/common-html/src/cancel.ts b/typescript/packages/common-propagator/src/cancel.ts similarity index 100% rename from typescript/packages/common-html/src/cancel.ts rename to typescript/packages/common-propagator/src/cancel.ts diff --git a/typescript/packages/common-html/src/cid.ts b/typescript/packages/common-propagator/src/cid.ts similarity index 100% rename from typescript/packages/common-html/src/cid.ts rename to typescript/packages/common-propagator/src/cid.ts diff --git a/typescript/packages/common-propagator/src/contract.ts b/typescript/packages/common-propagator/src/contract.ts new file mode 100644 index 000000000..8bdb481aa --- /dev/null +++ b/typescript/packages/common-propagator/src/contract.ts @@ -0,0 +1,3 @@ +export const isObject = (value: unknown): value is object => { + return typeof value === "object" && value !== null; +}; diff --git a/typescript/packages/common-propagator/src/index.ts b/typescript/packages/common-propagator/src/index.ts new file mode 100644 index 000000000..100a68c95 --- /dev/null +++ b/typescript/packages/common-propagator/src/index.ts @@ -0,0 +1,8 @@ +export * from "./propagator.js"; +export * as lens from "./lens.js"; +export * as lamport from "./lamport.js"; +export * as mergeable from "./mergeable.js"; +export * as path from "./path.js"; +export * as reactive from "./reactive.js"; +export * as cancel from "./cancel.js"; +export { setDebug } from "./logger.js"; diff --git a/typescript/packages/common-propagator/src/lamport.ts b/typescript/packages/common-propagator/src/lamport.ts new file mode 100644 index 000000000..44fe9f775 --- /dev/null +++ b/typescript/packages/common-propagator/src/lamport.ts @@ -0,0 +1,3 @@ +export type LamportTime = number; + +export const advanceClock = (...times: LamportTime[]) => Math.max(...times) + 1; diff --git a/typescript/packages/common-propagator/src/lens.ts b/typescript/packages/common-propagator/src/lens.ts new file mode 100644 index 000000000..b8b8ee770 --- /dev/null +++ b/typescript/packages/common-propagator/src/lens.ts @@ -0,0 +1,4 @@ +export type Lens = { + get: (big: Big) => Small; + update: (big: Big, small: Small) => Big; +}; diff --git a/typescript/packages/common-propagator/src/logger.ts b/typescript/packages/common-propagator/src/logger.ts new file mode 100644 index 000000000..207f06022 --- /dev/null +++ b/typescript/packages/common-propagator/src/logger.ts @@ -0,0 +1,25 @@ +/** Is debug logging on? */ +let isDebug = false; + +/** + * Turn on debug logging + * @example + * import { setDebug } from "curly"; + * + * setDebug(true); + */ +export const setDebug = (value: boolean) => { + isDebug = value; +}; + +/** Log warning */ +export const warn = (msg: unknown) => { + console.warn(msg); +}; + +/** Log if debugging is on */ +export const debug = (msg: object) => { + if (isDebug) { + console.debug({ ...msg }); + } +}; diff --git a/typescript/packages/common-propagator/src/mergeable.ts b/typescript/packages/common-propagator/src/mergeable.ts new file mode 100644 index 000000000..0c41a7caa --- /dev/null +++ b/typescript/packages/common-propagator/src/mergeable.ts @@ -0,0 +1,26 @@ +import { isObject } from "./contract.js"; + +/** A mergeable is a type that knows how to merge itself with itself */ +export interface Mergeable { + merge(value: this): this; +} + +export const isMergeable = (value: any): value is Mergeable => { + return ( + isObject(value) && + "merge" in value && + typeof value.merge === "function" && + value.merge.length === 1 + ); +}; + +/** + * Merge will merge prev and curr if they are mergeable, otherwise will + * return curr. + */ +export const merge = (prev: T, curr: T): T => { + if (isMergeable(prev) && isMergeable(curr)) { + return prev.merge(curr); + } + return curr; +}; diff --git a/typescript/packages/common-propagator/src/path.ts b/typescript/packages/common-propagator/src/path.ts new file mode 100644 index 000000000..1200e72d7 --- /dev/null +++ b/typescript/packages/common-propagator/src/path.ts @@ -0,0 +1,60 @@ +import { isObject } from "./contract.js"; +import * as logger from "./logger.js"; + +/** A keypath is an array of property keys */ +export type KeyPath = Array; +export type NonEmptyKeyPath = [PropertyKey, ...PropertyKey[]]; + +export type Keyable = { + key(key: K): T[K]; +}; + +export const isKeyable = (value: unknown): value is Keyable => { + return isObject(value) && "key" in value && typeof value.key === "function"; +}; + +/** Get value at prop. Returns undefined if key is not accessible. */ +export const getProp = (value: unknown, key: PropertyKey): unknown => { + if (value == null) { + return undefined; + } + return value[key as keyof typeof value] ?? undefined; +}; + +/** + * Get path on value using a keypath. + * If value is pathable, uses path method. + * Otherwise, gets properties along path. + */ +export const path = (parent: T, keyPath: Array): unknown => { + if (parent == null) { + return undefined; + } + if (keyPath.length === 0) { + return parent; + } + const key = keyPath.shift()!; + if (isKeyable(parent)) { + const child = parent.key(key); + logger.debug({ + msg: "call .key()", + fn: "path()", + parent, + key, + child, + }); + return path(child, keyPath); + } + // We checked the length, so we know this is not undefined. + const child = getProp(parent, key); + logger.debug({ + msg: "get prop", + fn: "path()", + parent, + key, + child, + }); + return path(child, keyPath); +}; + +export default path; diff --git a/typescript/packages/common-propagator/src/propagator.ts b/typescript/packages/common-propagator/src/propagator.ts new file mode 100644 index 000000000..27f51c7d3 --- /dev/null +++ b/typescript/packages/common-propagator/src/propagator.ts @@ -0,0 +1,285 @@ +import { Cancel, Cancellable, useCancelGroup } from "./cancel.js"; +import * as logger from "./logger.js"; +import { Lens } from "./lens.js"; +import cid from "./cid.js"; +import { merge } from "./mergeable.js"; +import { advanceClock, LamportTime } from "./lamport.js"; + +/** + * A cell is a reactive value that can be updated and subscribed to. + */ +export class Cell { + static get(cell: Cell) { + return cell.get(); + } + + static time(cell: Cell) { + return cell.time; + } + + #id = cid(); + #name = ""; + #neighbors = new Set<(value: Value, time: LamportTime) => void>(); + #time: LamportTime = 0; + #value: Value; + + constructor(value: Value, name = "") { + this.#name = name; + this.#value = value; + logger.debug({ + msg: "Cell created", + cell: this.id, + name: this.name, + value: this.#value, + time: this.#time, + }); + } + + get id() { + return this.#id; + } + + get name() { + return this.#name; + } + + get time() { + return this.#time; + } + + get() { + return this.#value; + } + + send(value: Value, time: LamportTime = this.#time + 1): number { + logger.debug({ + msg: "Sent value", + cell: this.id, + value, + time, + }); + + // We ignore old news. + // If times are equal, we ignore incoming value. + if (this.#time >= time) { + logger.debug({ + msg: "Value out of date. Ignoring.", + cell: this.id, + value, + time, + }); + return this.#time; + } + + const next = merge(this.#value, value); + + // We only advance clock if value changes state + if (this.#value === next) { + logger.debug({ + msg: "Value unchanged. Ignoring.", + cell: this.id, + value: next, + time, + }); + return this.#time; + } + + this.#time = advanceClock(this.#time, time); + + const prev = this.#value; + this.#value = next; + + logger.debug({ + msg: "Value updated", + cell: this.id, + prev, + value, + time: this.#time, + }); + + // Notify neighbors + for (const neighbor of this.#neighbors) { + neighbor(next, this.#time); + } + + logger.debug({ + msg: "Notified neighbors", + cell: this.id, + neighbors: this.#neighbors.size, + }); + + return this.#time; + } + + /** Disconnect all neighbors */ + disconnect() { + const size = this.#neighbors.size; + this.#neighbors.clear(); + logger.debug({ + msg: "Disconnected all neighbors", + cell: this.id, + neighbors: size, + }); + } + + sink(callback: (value: Value, time: LamportTime) => void) { + callback(this.#value, this.#time); + this.#neighbors.add(callback); + return () => { + this.#neighbors.delete(callback); + }; + } + + key(valueKey: K) { + return key(this, valueKey); + } +} + +/** Create a reactive cell for a value */ +export const cell = (value: Value, name = "") => new Cell(value, name); + +export default cell; + +export type CancellableCell = Cell & Cancellable; + +/** + * Derive a cell who's contents is transformed by a lens + */ +export const lens = ( + big: Cell, + lens: Lens, +): CancellableCell => { + const bigValue = big.get(); + + const small = cell(lens.get(bigValue)); + + const [cancel, addCancel] = useCancelGroup(); + + // Propagate writes from parent to child + const cancelBigToSmall = big.sink((bigValue, time) => { + small.send(lens.get(bigValue), time); + }); + addCancel(cancelBigToSmall); + + // Propagate writes from child to parent + const cancelSmallToBig = small.sink((smallValue, time) => { + const bigValue = big.get(); + const currSmallValue = lens.get(bigValue); + if (currSmallValue !== smallValue) { + big.send(lens.update(bigValue, smallValue), time); + } + }); + addCancel(cancelSmallToBig); + + const cancellableSmall = small as CancellableCell; + cancellableSmall.cancel = cancel; + + return cancellableSmall; +}; + +export const key = ( + big: Cell, + key: K, +): CancellableCell => + lens(big, { + get: (big) => big[key], + update: (big, small) => ({ ...big, [key]: small }), + }); + +export function lift(fn: (a: A) => B): (a: Cell, b: Cell) => Cancel; +export function lift( + fn: (a: A, b: B) => C, +): (a: Cell, b: Cell, c: Cell) => Cancel; +export function lift( + fn: (a: A, b: B, c: C) => D, +): (a: Cell, b: Cell, c: Cell, d: Cell) => Cancel; +export function lift( + fn: (a: A, b: B, c: C, d: D) => E, +): (a: Cell, b: Cell, c: Cell, d: Cell, e: Cell) => Cancel; +export function lift( + fn: (a: A, b: B, c: C, d: D, e: E) => F, +): ( + a: Cell, + b: Cell, + c: Cell, + d: Cell, + e: Cell, + f: Cell, +) => Cancel; +export function lift( + fn: (a: A, b: B, c: C, d: D, e: E, f: F) => G, +): ( + a: Cell, + b: Cell, + c: Cell, + d: Cell, + e: Cell, + f: Cell, + g: Cell, +) => Cancel; +export function lift( + fn: (a: A, b: B, c: C, d: D, e: E, f: F, g: G) => H, +): ( + a: Cell, + b: Cell, + c: Cell, + d: Cell, + e: Cell, + f: Cell, + g: Cell, + h: Cell, +) => Cancel; +export function lift( + fn: (a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H) => I, +): ( + a: Cell, + b: Cell, + c: Cell, + d: Cell, + e: Cell, + f: Cell, + g: Cell, + h: Cell, + i: Cell, +) => Cancel; +export function lift(fn: (...args: unknown[]) => unknown) { + return (...cells: Cell[]): Cancel => { + if (cells.length < 2) { + throw new TypeError("lift requires at least 2 cells"); + } + + const [cancel, addCancel] = useCancelGroup(); + + const output = cells.pop()!; + + // Create a map of cell IDs to times. + // We use this as a vector clock when updating. + // All cells are initialized to t=-1 since we will have never seen + // an update from any of the cells. This will get immediately replaced + // by the first immediate update we get from sink. + const clock = new Map(cells.map((cell) => [cell.id, -1])); + + for (const cell of cells) { + const cancel = cell.sink((_value, time) => { + // Get the last time we got an update from this cell + const lastTime = clock.get(cell.id); + if (lastTime == null) { + // This should never happen + throw Error(`Cell not found in clock: ${cell.id}`); + } + // If this cell has updated (e.g. not a "diamond problem"" update) + // then update the entry in the clock and send the calculated output. + if (time > lastTime) { + clock.set(cell.id, time); + output.send( + fn(...cells.map(Cell.get)), + advanceClock(time, output.time), + ); + } + }); + addCancel(cancel); + } + + return cancel; + }; +} diff --git a/typescript/packages/common-propagator/src/reactive.ts b/typescript/packages/common-propagator/src/reactive.ts new file mode 100644 index 000000000..0209b4746 --- /dev/null +++ b/typescript/packages/common-propagator/src/reactive.ts @@ -0,0 +1,110 @@ +import { isObject } from "./contract.js"; +import { Cancel, isCancel } from "./cancel.js"; + +/** + * A reactive value is any type with a `sink()` method that can be used + * to subscribe to updates. + * - `sink()` must take a callback function that will be called with the + * updated value. + * - `sink()` must return a `Cancel` function that can be called to unsubscribe. + */ +export type Reactive = { + sink: (callback: (value: T) => void) => Cancel; +}; + +export const isReactive = (value: unknown): value is Reactive => { + return isObject(value) && "sink" in value && typeof value.sink === "function"; +}; + +/** A gettable is any type implementing a `get()` method */ +export type Gettable = { + get(): T; +}; + +export const isGettable = (value: unknown): value is Gettable => { + return isObject(value) && "get" in value && typeof value.get === "function"; +}; + +/** A sendable is any type implementing a `send` method */ +export type Sendable = { + send: (value: T) => void; +}; + +export const isSendable = (value: unknown): value is Sendable => { + return isObject(value) && "send" in value && typeof value.send === "function"; +}; + +export const effect = ( + value: unknown, + callback: (value: unknown) => Cancel | void, +) => { + if (value == null) { + return noOp; + } + + let cleanup: Cancel = noOp; + if (isReactive(value)) { + const cancelSink = value.sink((value: unknown) => { + cleanup(); + const next = callback(value); + cleanup = isCancel(next) ? next : noOp; + }); + return () => { + cancelSink(); + cleanup(); + }; + } + + const maybeCleanup = callback(value); + return isCancel(maybeCleanup) ? maybeCleanup : noOp; +}; + +const noOp = () => {}; + +/** Wrap an effect function so that it batches on microtask */ +const batcher = (queue = queueMicrotask) => { + let isScheduled = false; + let scheduledJob = () => {}; + + const perform = () => { + isScheduled = false; + scheduledJob(); + }; + + return (job: () => void) => { + scheduledJob = job; + if (!isScheduled) { + isScheduled = true; + queue(perform); + } + }; +}; + +/** Batch effects on microtask */ +export const render = ( + value: unknown, + callback: (value: unknown) => Cancel | void, +) => { + if (value == null) { + return noOp; + } + + const queueRender = batcher(); + + let cleanup: Cancel = noOp; + if (isReactive(value)) { + const cancelSink = value.sink((value: unknown) => { + queueRender(() => { + cleanup(); + const next = callback(value); + cleanup = isCancel(next) ? next : noOp; + }); + }); + return () => { + cancelSink(); + cleanup(); + }; + } + const maybeCleanup = callback(value); + return isCancel(maybeCleanup) ? maybeCleanup : noOp; +}; diff --git a/typescript/packages/common-html/src/test/cancel.test.ts b/typescript/packages/common-propagator/src/test/cancel.test.ts similarity index 100% rename from typescript/packages/common-html/src/test/cancel.test.ts rename to typescript/packages/common-propagator/src/test/cancel.test.ts diff --git a/typescript/packages/common-propagator/src/test/path.test.ts b/typescript/packages/common-propagator/src/test/path.test.ts new file mode 100644 index 000000000..d59d42722 --- /dev/null +++ b/typescript/packages/common-propagator/src/test/path.test.ts @@ -0,0 +1,41 @@ +import { equal as assertEqual } from "node:assert/strict"; +import { path } from "../path.js"; + +describe("path", () => { + it("gets a deep path from any object", () => { + const obj = { + a: { + b: [{ c: 10 }], + }, + }; + assertEqual(path(obj, ["a", "b", 0, "c"]), 10); + }); + + it("returns undefined when any part of the path does not exist", () => { + assertEqual(path({}, ["a", "b", 0, "c"]), undefined); + }); + + it("defers to `key()` implementation for Keyable types", () => { + class Wrapper { + #subject: T; + + constructor(subject: T) { + this.#subject = subject; + } + + key(key: K): T[K] { + return this.#subject[key]; + } + } + + const obj = { + a: { + b: [{ c: 10 }], + }, + }; + + const wrapper = new Wrapper(obj); + + assertEqual(path(wrapper, ["a", "b", 0, "c"]), 10); + }); +}); diff --git a/typescript/packages/common-propagator/src/test/propagator.test.ts b/typescript/packages/common-propagator/src/test/propagator.test.ts new file mode 100644 index 000000000..40934d5c2 --- /dev/null +++ b/typescript/packages/common-propagator/src/test/propagator.test.ts @@ -0,0 +1,171 @@ +import { + equal as assertEqual, + deepEqual as assertDeepEqual, +} from "node:assert/strict"; +import { cell, lens, lift } from "../propagator.js"; + +describe("cell()", () => { + it("synchronously sets the value", () => { + const a = cell(1); + a.send(2); + assertEqual(a.get(), 2); + }); + + it("reacts synchronously when sent a new value", () => { + const a = cell(1); + + let state = 0; + a.sink((value) => { + state = value; + }); + a.send(2); + + assertEqual(state, 2); + }); + + it("has an optional name", () => { + const a = cell(1, "a"); + assertEqual(a.name, "a"); + }); +}); + +describe("lens()", () => { + it("lenses over a cell", () => { + const x = cell({ a: { b: { c: 10 } } }, "x"); + + const c = lens(x, { + get: (state) => state.a.b.c, + update: (state, next) => ({ ...state, a: { b: { c: next } } }), + }); + + assertEqual(c.get(), 10); + + c.send(20); + + assertDeepEqual(x.get(), { a: { b: { c: 20 } } }); + assertEqual(c.get(), 20); + }); +}); + +describe("cell.key()", () => { + it("returns a typesafe cell that reflects the state of the parent", () => { + const x = cell({ a: 10 }); + const a = x.key("a"); + + assertEqual(a.get(), 10); + }); + + it("reflects updates from parent to child", () => { + const x = cell({ a: 10 }); + const a = x.key("a"); + + x.send({ a: 20 }); + + assertEqual(a.get(), 20); + }); + + it("reflects updates from child to parent", () => { + const x = cell({ a: 10 }); + const a = x.key("a"); + + a.send(20); + + assertDeepEqual(x.get(), { a: 20 }); + }); + + it("it works for deep derived keys", () => { + const x = cell({ a: { b: { c: 10 } } }); + const a = x.key("a"); + const b = a.key("b"); + const c = b.key("c"); + + c.send(20); + + assertDeepEqual(x.get(), { a: { b: { c: 20 } } }); + assertDeepEqual(a.get(), { b: { c: 20 } }); + assertDeepEqual(b.get(), { c: 20 }); + assertDeepEqual(c.get(), 20); + }); +}); + +describe("lift()", () => { + it("lifts a function into a function that reads from and writes to cells", () => { + const addCells = lift((a: number, b: number) => a + b); + + const a = cell(1, "lift.a"); + const b = cell(2, "lift.b"); + const out = cell(0, "lift.out"); + + const cancel = addCells(a, b, out); + + assertEqual(typeof cancel, "function", "returns a cancel function"); + + assertEqual(out.get(), 3); + }); + + it("updates the out cell whenever an input cell updates", () => { + const addCells = lift((a: number, b: number) => a + b); + + const a = cell(1, "a"); + const b = cell(1, "b"); + const out = cell(0, "out"); + + addCells(a, b, out); + assertEqual(out.get(), 2); + + a.send(2, out.time); + assertEqual(out.get(), 3); + + b.send(2, out.time); + assertEqual(out.get(), 4); + }); + + it("solves the diamond problem", () => { + const addCells = lift((a: number, b: number) => a + b); + + const a = cell(1, "a"); + const out = cell(0, "out"); + + addCells(a, a, out); + assertEqual(out.get(), 2); + + let calls = 0; + out.sink((_value) => { + calls++; + }); + + a.send(2); + assertEqual(out.get(), 4); + + assertEqual( + calls, + 2, + "calls neighbors once per upstream output of the diamond", + ); + }); + + it("solves the diamond problem (2)", () => { + const add3 = lift((a: number, b: number, c: number) => a + b + c); + + const a = cell(1, "a"); + const b = cell(1, "b"); + const out = cell(0, "out"); + + add3(a, b, b, out); + assertEqual(out.get(), 3); + + let calls = 0; + out.sink((_value) => { + calls++; + }); + + b.send(2); + assertEqual(out.get(), 5); + + assertEqual( + calls, + 2, + "calls neighbors once per upstream output of the diamond", + ); + }); +}); diff --git a/typescript/packages/common-propagator/src/test/reactive.test.ts b/typescript/packages/common-propagator/src/test/reactive.test.ts new file mode 100644 index 000000000..973e73c32 --- /dev/null +++ b/typescript/packages/common-propagator/src/test/reactive.test.ts @@ -0,0 +1,135 @@ +import { equal as assertEqual } from "node:assert/strict"; +import { cell } from "../propagator.js"; +import { effect, render, isReactive } from "../reactive.js"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe("isReactive", () => { + it("returns true for any object with a sink method", () => { + const a = cell(0); + assertEqual(isReactive(a), true); + + class B { + sink() {} + } + + assertEqual(isReactive(new B()), true); + }); + + it("returns false for objects without a sink method", () => { + assertEqual(isReactive({}), false); + }); +}); + +describe("effect()", () => { + it("runs callback for nonreactive values", () => { + let calls = 0; + effect(10, (_value: number) => { + calls++; + }); + assertEqual(calls, 1); + }); + + it("subscribes callback to `sink` for reactive value", () => { + const value = cell(10); + + let valueMut = 0; + effect(value, (value: number) => { + valueMut = value; + }); + + value.send(11); + value.send(12); + + assertEqual(valueMut, 12); + }); + + it("ends subscription to reactive value when cancel is called", () => { + const value = cell(10); + + let valueMut = 0; + const cancel = effect(value, (value: number) => { + valueMut = value; + }); + + value.send(11); + cancel(); + value.send(12); + + assertEqual(valueMut, 11); + }); + + it("returns a cancel function", () => { + const cancel = effect(10, (_value: number) => {}); + assertEqual(typeof cancel, "function"); + }); + + it("runs any returned cleanup function when cancel is run", () => { + let calls = 0; + const cancel = effect(10, (_value: number) => { + const cleanup = () => calls++; + return cleanup; + }); + cancel(); + assertEqual(calls, 1); + }); +}); + +describe("render()", () => { + it("runs callback for nonreactive values", async () => { + let calls = 0; + render(10, (_value: number) => { + calls++; + }); + await sleep(1); + assertEqual(calls, 1); + }); + + it("subscribes callback to `sink` for reactive value", async () => { + const value = cell(10); + + let valueMut = 0; + render(value, (value: number) => { + valueMut = value; + }); + + value.send(11); + value.send(12); + + await sleep(1); + + assertEqual(valueMut, 12); + }); + + it("ends subscription to reactive value when cancel is called", async () => { + const value = cell(10); + + let valueMut = 0; + const cancel = render(value, (value: number) => { + valueMut = value; + }); + + value.send(11); + cancel(); + value.send(12); + + await sleep(1); + assertEqual(valueMut, 11); + }); + + it("returns a cancel function", () => { + const cancel = render(10, (_value: number) => {}); + assertEqual(typeof cancel, "function"); + }); + + it("runs any returned cleanup function when cancel is run", async () => { + let calls = 0; + const cancel = render(10, (_value: number) => { + const cleanup = () => calls++; + return cleanup; + }); + cancel(); + await sleep(1); + assertEqual(calls, 1); + }); +}); diff --git a/typescript/packages/common-propagator/tsconfig.json b/typescript/packages/common-propagator/tsconfig.json new file mode 100644 index 000000000..24632751d --- /dev/null +++ b/typescript/packages/common-propagator/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["es2022", "DOM"], + "outDir": "./lib", + "rootDir": "./src", + "strict": false, + "strictNullChecks": true, + "paths": { + "common:module/module@0.0.1": [ + "../../node_modules/@commontools/module/lib/index.d.ts" + ], + "common:io/state@0.0.1": [ + "../../node_modules/@commontools/io/lib/index.d.ts" + ] + }, + "types": ["node", "mocha"] + }, + "include": ["src/**/*"] +}