From 703892bdb9354fb9e2e9801e1ae70bec5cbc6b97 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Mon, 15 Jul 2024 18:54:34 -0400 Subject: [PATCH 01/32] WIP: do a lexing pass This will let us create block structures. --- typescript/packages/common-html/src/parser.ts | 161 +++++++++++++++--- 1 file changed, 140 insertions(+), 21 deletions(-) diff --git a/typescript/packages/common-html/src/parser.ts b/typescript/packages/common-html/src/parser.ts index 86e12a6e6..71b7f20a1 100644 --- a/typescript/packages/common-html/src/parser.ts +++ b/typescript/packages/common-html/src/parser.ts @@ -3,36 +3,66 @@ 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]; +export type TagOpenToken = { + type: "tagopen"; + tag: string; + props: Props; +}; + +export type TagCloseToken = { + type: "tagclose"; + tag: string; +}; + +export type TextToken = { + type: "text"; + value: string; +}; + +export type VarToken = { + type: "var"; + name: string; +}; + +export type BlockOpenToken = { + type: "blockopen"; + name: string; +}; + +export type BlockCloseToken = { + type: "blockclose"; + name: string; +}; + +export type Token = + | TagOpenToken + | TagCloseToken + | TextToken + | VarToken + | BlockOpenToken + | BlockCloseToken; + +export const tokenize = (markup: string): Array => { + const tokens: Array = []; const parser = new Parser( { - onopentag(name, attrs) { - logger.debug("Open", name, attrs); + onopentag(tag, 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); + const token: TagOpenToken = { type: "tagopen", tag, props }; + logger.debug("tagopen", token); + tokens.push(token); }, - onclosetag(name) { - const vnode = stack.pop(); - if (!vnode) { - throw new ParseError(`Unexpected closing tag ${name}`); - } + onclosetag(tag) { + const token: TagCloseToken = { type: "tagclose", tag }; + logger.debug("tagopen", token); + tokens.push(token); }, ontext(text) { - const top = getTop(stack); - const parsed = parseMustaches(text.trim()); - top.children.push(...parsed); + const parsed = tokenizeMustaches(text.trim()); + tokens.push(...parsed); }, }, { @@ -44,6 +74,95 @@ export const parse = (markup: string): VNode => { parser.write(markup); parser.end(); + + return tokens; +}; + +const mustacheRegex = /{{(\w+)}}/g; + +const tokenizeMustaches = (text: string): Array => { + const tokens: Array = []; + let lastIndex = 0; + let match: RegExpMatchArray | null = null; + + while ((match = mustacheRegex.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 stash = match[1]; + if (stash.startsWith("#")) { + const token: BlockOpenToken = { type: "blockopen", name: stash.slice(1) }; + logger.debug("blockopen", token); + tokens.push(token); + } else if (stash.startsWith("/")) { + const token: BlockCloseToken = { + type: "blockclose", + name: stash.slice(1), + }; + logger.debug("blockclose", token); + tokens.push(token); + } else { + const token: VarToken = { type: "var", name: stash }; + logger.debug("var", token); + tokens.push(token); + } + lastIndex = mustacheRegex.lastIndex; + } + + if (lastIndex < text.length) { + const token: TextToken = { + type: "text", + value: text.slice(lastIndex, match.index), + }; + logger.debug("text", token); + tokens.push(token); + } + + mustacheRegex.lastIndex = 0; + + return tokens; +}; + +/** Parse a template into a simple JSON markup representation */ +export const parse = (markup: string): VNode => { + let root: VNode = createVNode("documentfragment"); + let stack: Array = [root]; + + for (const token of tokenize(markup)) { + const top = getTop(stack); + switch (token.type) { + case "tagopen": { + const next = createVNode(token.tag, token.props); + top.children.push(next); + stack.push(next); + break; + } + case "tagclose": { + const vnode = stack.pop(); + if (vnode.tag !== token.tag) { + throw new ParseError(`Unexpected closing tag ${token.tag}`); + } + break; + } + case "text": { + top.children.push(token.value); + break; + } + case "var": { + top.children.push(token); + break; + } + default: { + throw new ParseError(`Unexpected token ${JSON.stringify(token)}`); + } + } + } + return root; }; From fb206a931387978eaa5eb455e2e83e5571d99cc5 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Mon, 15 Jul 2024 19:09:31 -0400 Subject: [PATCH 02/32] Proof-of-concept lexing -> parsing --- typescript/packages/common-html/src/parser.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/typescript/packages/common-html/src/parser.ts b/typescript/packages/common-html/src/parser.ts index 71b7f20a1..03383ccab 100644 --- a/typescript/packages/common-html/src/parser.ts +++ b/typescript/packages/common-html/src/parser.ts @@ -1,6 +1,7 @@ import { Parser } from "htmlparser2"; -import { parse as parseMustaches, isHole } from "./hole.js"; -import { create as createVNode, VNode, Props } from "./vnode.js"; +import * as hole from "./hole.js"; +import * as vnode from "./vnode.js"; +import { VNode, Props } from "./vnode.js"; import * as logger from "./logger.js"; export type TagOpenToken = { @@ -57,7 +58,7 @@ export const tokenize = (markup: string): Array => { }, onclosetag(tag) { const token: TagCloseToken = { type: "tagclose", tag }; - logger.debug("tagopen", token); + logger.debug("tagclose", token); tokens.push(token); }, ontext(text) { @@ -117,7 +118,7 @@ const tokenizeMustaches = (text: string): Array => { if (lastIndex < text.length) { const token: TextToken = { type: "text", - value: text.slice(lastIndex, match.index), + value: text.slice(lastIndex), }; logger.debug("text", token); tokens.push(token); @@ -130,14 +131,14 @@ const tokenizeMustaches = (text: string): Array => { /** Parse a template into a simple JSON markup representation */ export const parse = (markup: string): VNode => { - let root: VNode = createVNode("documentfragment"); + let root: VNode = vnode.create("documentfragment"); let stack: Array = [root]; for (const token of tokenize(markup)) { const top = getTop(stack); switch (token.type) { case "tagopen": { - const next = createVNode(token.tag, token.props); + const next = vnode.create(token.tag, token.props); top.children.push(next); stack.push(next); break; @@ -154,7 +155,7 @@ export const parse = (markup: string): VNode => { break; } case "var": { - top.children.push(token); + top.children.push(hole.create(token.name)); break; } default: { @@ -173,11 +174,11 @@ 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 parsed = hole.parse(value); const first = parsed.at(0); if (parsed.length !== 1) { result[key] = ""; - } else if (isHole(first)) { + } else if (hole.isHole(first)) { result[key] = first; } else { result[key] = `${value}`; From a2541076085b694bd67defd4abffb7bc480d0c43 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Mon, 15 Jul 2024 19:37:21 -0400 Subject: [PATCH 03/32] WIP: nest tags with blocks --- typescript/packages/common-html/src/parser.ts | 114 ++++++++++++++---- 1 file changed, 89 insertions(+), 25 deletions(-) diff --git a/typescript/packages/common-html/src/parser.ts b/typescript/packages/common-html/src/parser.ts index 03383ccab..d5f6af71e 100644 --- a/typescript/packages/common-html/src/parser.ts +++ b/typescript/packages/common-html/src/parser.ts @@ -1,18 +1,68 @@ import { Parser } from "htmlparser2"; -import * as hole from "./hole.js"; -import * as vnode from "./vnode.js"; -import { VNode, Props } from "./vnode.js"; import * as logger from "./logger.js"; +export type Props = { [key: string]: string | Var }; + +export type Node = VNode | Block | Var | string; + +export type VNode = { + type: "vnode"; + name: string; + props: Props; + children: Array; +}; + +export const createVNode = ( + 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"; +}; + +export type Var = { + type: "var"; + name: string; +}; + +export const createVar = (name: string): Var => { + return { type: "var", name }; +}; + +export const isVar = (value: unknown): value is Var => { + return (value as Var)?.type === "var"; +}; + +export type Block = { + type: "block"; + name: string; + children: Array; +}; + +export const createBlock = ( + name: string, + children: Array = [], +): Block => { + return { type: "block", name, children }; +}; + +export const isBlock = (value: unknown): value is Block => { + return (value as Block)?.type === "block"; +}; + export type TagOpenToken = { type: "tagopen"; - tag: string; + name: string; props: Props; }; export type TagCloseToken = { type: "tagclose"; - tag: string; + name: string; }; export type TextToken = { @@ -48,16 +98,16 @@ export const tokenize = (markup: string): Array => { const parser = new Parser( { - onopentag(tag, attrs) { + 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", tag, props }; + const token: TagOpenToken = { type: "tagopen", name, props }; logger.debug("tagopen", token); tokens.push(token); }, - onclosetag(tag) { - const token: TagCloseToken = { type: "tagclose", tag }; + onclosetag(name) { + const token: TagCloseToken = { type: "tagclose", name }; logger.debug("tagclose", token); tokens.push(token); }, @@ -95,20 +145,20 @@ const tokenizeMustaches = (text: string): Array => { logger.debug("text", token); tokens.push(token); } - const stash = match[1]; - if (stash.startsWith("#")) { - const token: BlockOpenToken = { type: "blockopen", name: stash.slice(1) }; + const body = match[1]; + if (body.startsWith("#")) { + const token: BlockOpenToken = { type: "blockopen", name: body.slice(1) }; logger.debug("blockopen", token); tokens.push(token); - } else if (stash.startsWith("/")) { + } else if (body.startsWith("/")) { const token: BlockCloseToken = { type: "blockclose", - name: stash.slice(1), + name: body.slice(1), }; logger.debug("blockclose", token); tokens.push(token); } else { - const token: VarToken = { type: "var", name: stash }; + const token: VarToken = { type: "var", name: body }; logger.debug("var", token); tokens.push(token); } @@ -131,22 +181,35 @@ const tokenizeMustaches = (text: string): Array => { /** Parse a template into a simple JSON markup representation */ export const parse = (markup: string): VNode => { - let root: VNode = vnode.create("documentfragment"); - let stack: Array = [root]; + let root: VNode = createVNode("documentfragment"); + let stack: Array = [root]; for (const token of tokenize(markup)) { const top = getTop(stack); switch (token.type) { case "tagopen": { - const next = vnode.create(token.tag, token.props); + const next = createVNode(token.name, token.props); top.children.push(next); stack.push(next); break; } case "tagclose": { - const vnode = stack.pop(); - if (vnode.tag !== token.tag) { - throw new ParseError(`Unexpected closing tag ${token.tag}`); + const top = stack.pop(); + if (!isVNode(top) || top.name !== token.name) { + throw new ParseError(`Unexpected closing tag ${token.name}`); + } + break; + } + case "blockopen": { + const next = createBlock(token.name); + top.children.push(next); + stack.push(next); + break; + } + case "blockclose": { + const top = stack.pop(); + if (!isBlock(top) || top.name !== token.name) { + throw new ParseError(`Unexpected closing block ${token.name}`); } break; } @@ -155,7 +218,7 @@ export const parse = (markup: string): VNode => { break; } case "var": { - top.children.push(hole.create(token.name)); + top.children.push(createVar(token.name)); break; } default: { @@ -169,16 +232,17 @@ export const parse = (markup: string): VNode => { export default parse; -const getTop = (stack: Array): VNode | null => stack.at(-1) ?? null; +const getTop = (stack: Array): VNode | Block | 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 = hole.parse(value); + const parsed = tokenizeMustaches(value); const first = parsed.at(0); if (parsed.length !== 1) { result[key] = ""; - } else if (hole.isHole(first)) { + } else if (isVar(first)) { result[key] = first; } else { result[key] = `${value}`; From 2315fe83e72f87b6b11d493091401c6cdb0f37aa Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 16 Jul 2024 11:39:41 -0400 Subject: [PATCH 04/32] Move vdom, hole, block, parser, into view These types are all interdependent upon each other. Easier to have them in a single module. --- typescript/packages/common-html/src/hole.ts | 54 --- typescript/packages/common-html/src/html.ts | 5 +- typescript/packages/common-html/src/index.ts | 4 +- typescript/packages/common-html/src/parser.ts | 259 -------------- typescript/packages/common-html/src/render.ts | 12 +- .../common-html/src/test/hole.test.ts | 65 ---- .../common-html/src/test/html.test.ts | 6 +- .../common-html/src/test/parser.test.ts | 33 -- .../common-html/src/test/view.test.ts | 48 ++- typescript/packages/common-html/src/view.ts | 316 +++++++++++++++++- typescript/packages/common-html/src/vnode.ts | 27 -- 11 files changed, 342 insertions(+), 487 deletions(-) delete mode 100644 typescript/packages/common-html/src/hole.ts delete mode 100644 typescript/packages/common-html/src/parser.ts delete mode 100644 typescript/packages/common-html/src/test/hole.test.ts delete mode 100644 typescript/packages/common-html/src/test/parser.test.ts delete mode 100644 typescript/packages/common-html/src/vnode.ts 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..db63e293a 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 { view, View, markupVar } from "./view.js"; export const html = ( strings: TemplateStringsArray, @@ -23,7 +22,7 @@ export const html = ( return result + string; } const [name] = namedValue; - return result + string + hole.markup(name); + return result + string + markupVar(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..d6898f4a9 100644 --- a/typescript/packages/common-html/src/index.ts +++ b/typescript/packages/common-html/src/index.ts @@ -1,8 +1,6 @@ -export { view, View, Context } from "./view.js"; +export { view, View, Context, VNode, Var } 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 d5f6af71e..000000000 --- a/typescript/packages/common-html/src/parser.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { Parser } from "htmlparser2"; -import * as logger from "./logger.js"; - -export type Props = { [key: string]: string | Var }; - -export type Node = VNode | Block | Var | string; - -export type VNode = { - type: "vnode"; - name: string; - props: Props; - children: Array; -}; - -export const createVNode = ( - 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"; -}; - -export type Var = { - type: "var"; - name: string; -}; - -export const createVar = (name: string): Var => { - return { type: "var", name }; -}; - -export const isVar = (value: unknown): value is Var => { - return (value as Var)?.type === "var"; -}; - -export type Block = { - type: "block"; - name: string; - children: Array; -}; - -export const createBlock = ( - name: string, - children: Array = [], -): Block => { - return { type: "block", name, children }; -}; - -export const isBlock = (value: unknown): value is Block => { - return (value as Block)?.type === "block"; -}; - -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 VarToken = { - type: "var"; - name: string; -}; - -export type BlockOpenToken = { - type: "blockopen"; - name: string; -}; - -export type BlockCloseToken = { - type: "blockclose"; - name: string; -}; - -export type Token = - | TagOpenToken - | TagCloseToken - | TextToken - | VarToken - | BlockOpenToken - | BlockCloseToken; - -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 = tokenizeMustaches(text.trim()); - tokens.push(...parsed); - }, - }, - { - lowerCaseTags: true, - lowerCaseAttributeNames: true, - xmlMode: false, - }, - ); - - parser.write(markup); - parser.end(); - - return tokens; -}; - -const mustacheRegex = /{{(\w+)}}/g; - -const tokenizeMustaches = (text: string): Array => { - const tokens: Array = []; - let lastIndex = 0; - let match: RegExpMatchArray | null = null; - - while ((match = mustacheRegex.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: BlockOpenToken = { type: "blockopen", name: body.slice(1) }; - logger.debug("blockopen", token); - tokens.push(token); - } else if (body.startsWith("/")) { - const token: BlockCloseToken = { - type: "blockclose", - name: body.slice(1), - }; - logger.debug("blockclose", token); - tokens.push(token); - } else { - const token: VarToken = { type: "var", name: body }; - logger.debug("var", token); - tokens.push(token); - } - lastIndex = mustacheRegex.lastIndex; - } - - if (lastIndex < text.length) { - const token: TextToken = { - type: "text", - value: text.slice(lastIndex), - }; - logger.debug("text", token); - tokens.push(token); - } - - mustacheRegex.lastIndex = 0; - - return tokens; -}; - -/** Parse a template into a simple JSON markup representation */ -export const parse = (markup: string): VNode => { - let root: VNode = createVNode("documentfragment"); - let stack: Array = [root]; - - for (const token of tokenize(markup)) { - const top = getTop(stack); - switch (token.type) { - case "tagopen": { - const next = createVNode(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}`); - } - break; - } - case "blockopen": { - const next = createBlock(token.name); - top.children.push(next); - stack.push(next); - break; - } - case "blockclose": { - const top = stack.pop(); - if (!isBlock(top) || top.name !== token.name) { - throw new ParseError(`Unexpected closing block ${token.name}`); - } - break; - } - case "text": { - top.children.push(token.value); - break; - } - case "var": { - top.children.push(createVar(token.name)); - break; - } - default: { - throw new ParseError(`Unexpected token ${JSON.stringify(token)}`); - } - } - } - - return root; -}; - -export default parse; - -const getTop = (stack: Array): VNode | Block | 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 = tokenizeMustaches(value); - const first = parsed.at(0); - if (parsed.length !== 1) { - result[key] = ""; - } else if (isVar(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/render.ts b/typescript/packages/common-html/src/render.ts index f747a9090..e56f03aa4 100644 --- a/typescript/packages/common-html/src/render.ts +++ b/typescript/packages/common-html/src/render.ts @@ -1,6 +1,4 @@ -import { isVNode, VNode } from "./vnode.js"; -import { View, Context, isView } from "./view.js"; -import { isHole } from "./hole.js"; +import { View, Context, isView, isVNode, VNode, isVar } from "./view.js"; import { effect } from "./reactive.js"; import { isSendable } from "./sendable.js"; import { useCancelGroup, Cancel } from "./cancel.js"; @@ -32,9 +30,9 @@ const renderNode = ( if (!sanitizedNode) { return null; } - const element = document.createElement(sanitizedNode.tag); + const element = document.createElement(sanitizedNode.name); attrs: for (const [name, value] of Object.entries(sanitizedNode.props)) { - if (isHole(value)) { + if (isVar(value)) { const replacement = context[value.name]; // If prop is an event, we need to add an event listener if (isEventProp(name)) { @@ -69,7 +67,7 @@ const renderNode = ( if (childElement) { element.append(childElement); } - } else if (isHole(childNode)) { + } else if (isVar(childNode)) { const replacement = context[childNode.name]; // Anchor for reactive replacement let anchor: ChildNode = document.createTextNode(""); @@ -121,7 +119,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/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..76e606800 100644 --- a/typescript/packages/common-html/src/test/html.test.ts +++ b/typescript/packages/common-html/src/test/html.test.ts @@ -1,6 +1,6 @@ import * as assert from "node:assert"; import html from "../html.js"; -import * as hole from "../hole.js"; +import { isVar } from "../view.js"; import { state, stream } from "../state.js"; describe("html", () => { @@ -15,9 +15,9 @@ describe("html", () => { `; // @ts-ignore - ignore for test - assert.strict(hole.isHole(view.template.children[0].props.onclick)); + assert.strict(isVar(view.template.children[0].props.onclick)); // @ts-ignore - ignore for test - assert.strict(hole.isHole(view.template.children[0].children[0])); + assert.strict(isVar(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/view.test.ts b/typescript/packages/common-html/src/test/view.test.ts index 54f65b9a2..7c31e6261 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, parse, createVar, createVNode } 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: createVNode("div", {}, ["Hello world!"]), context: {}, }); }); @@ -24,14 +18,9 @@ describe("view()", () => { }); assert.deepStrictEqual(hello, { type: "view", - template: { - type: "vnode", - tag: "div", - props: { - hidden: hole.create("hidden"), - }, - children: [hole.create("text")], - }, + template: createVNode("div", { hidden: createVar("hidden") }, [ + createVar("text"), + ]), context: { hidden: false, text: "Hello world!", @@ -39,3 +28,30 @@ describe("view()", () => { }); }); }); + +describe("parse()", () => { + it("parses", () => { + const xml = ` + + `; + + const root = parse(xml); + + assert.deepEqual( + root, + createVNode("documentfragment", {}, [ + createVNode( + "div", + { class: "container", hidden: createVar("hidden") }, + [ + createVNode("button", { id: "foo", onclick: createVar("click") }, [ + "Hello world!", + ]), + ], + ), + ]), + ); + }); +}); diff --git a/typescript/packages/common-html/src/view.ts b/typescript/packages/common-html/src/view.ts index b0280e1be..0bea2d698 100644 --- a/typescript/packages/common-html/src/view.ts +++ b/typescript/packages/common-html/src/view.ts @@ -1,31 +1,19 @@ -import { isVNode, VNode } from "./vnode.js"; -import parse from "./parser.js"; +import { Parser } from "htmlparser2"; import * as logger from "./logger.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 +22,303 @@ 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 }; + +/** + * Dynamic properties. Can either be string type (static) or a Mustache + * variable (dynamic). + */ +export type Props = { [key: string]: string | Var }; + +/** A child in a view can be one of a few things */ +export type Child = VNode | Block | Var | 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 createVNode = ( + 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 Var = { + type: "var"; + name: string; +}; + +export const createVar = (name: string): Var => { + return { type: "var", name }; +}; + +export const isVar = (value: unknown): value is Var => { + return (value as Var)?.type === "var"; +}; + +export const markupVar = (name: string) => `{{${name}}}`; + +/** A mustache block `{{#myblock}} ... {{/myblock}}` */ +export type Block = { + type: "block"; + name: string; + children: Array; +}; + +export const createBlock = ( + name: string, + children: Array = [], +): Block => { + return { type: "block", name, children }; +}; + +export const isBlock = (value: unknown): value is Block => { + return (value as Block)?.type === "block"; +}; + +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 VarToken = { + type: "var"; + name: string; +}; + +export type BlockOpenToken = { + type: "blockopen"; + name: string; +}; + +export type BlockCloseToken = { + type: "blockclose"; + name: string; +}; + +export type Token = + | TagOpenToken + | TagCloseToken + | TextToken + | VarToken + | BlockOpenToken + | BlockCloseToken; + +/** 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_REGEX_G = /{{(\w+)}}/g; + +/** Tokenize Mustache */ +export const tokenizeMustache = (text: string): Array => { + const tokens: Array = []; + MUSTACHE_REGEX_G.lastIndex = 0; + let lastIndex = 0; + let match: RegExpMatchArray | null = null; + while ((match = MUSTACHE_REGEX_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: BlockOpenToken = { type: "blockopen", name: body.slice(1) }; + logger.debug("blockopen", token); + tokens.push(token); + } else if (body.startsWith("/")) { + const token: BlockCloseToken = { + type: "blockclose", + name: body.slice(1), + }; + logger.debug("blockclose", token); + tokens.push(token); + } else { + const token: VarToken = { type: "var", name: body }; + logger.debug("var", token); + tokens.push(token); + } + lastIndex = MUSTACHE_REGEX_G.lastIndex; + } + + if (lastIndex < text.length) { + const token: TextToken = { + type: "text", + value: text.slice(lastIndex), + }; + logger.debug("text", token); + tokens.push(token); + } + + MUSTACHE_REGEX_G.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 = createVNode("documentfragment"); + let stack: Array = [root]; + + for (const token of tokenize(markup)) { + const top = getTop(stack); + switch (token.type) { + case "tagopen": { + const next = createVNode(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}`); + } + break; + } + case "blockopen": { + const next = createBlock(token.name); + top.children.push(next); + stack.push(next); + break; + } + case "blockclose": { + const top = stack.pop(); + if (!isBlock(top) || top.name !== token.name) { + throw new ParseError(`Unexpected closing block ${token.name}`); + } + break; + } + case "text": { + top.children.push(token.value); + break; + } + case "var": { + top.children.push(createVar(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 | Block | null => + stack.at(-1) ?? null; + +const MUSTACHE_VAR_REGEX = /^{{(\w+)}}$/; + +/** Parse a Mustache var if and only if it is the only element in a string */ +export const parseMustacheVar = (markup: string): Var | null => { + const match = markup.match(MUSTACHE_VAR_REGEX); + if (match == null) { + return null; + } + const body = match[1]; + return createVar(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 = parseMustacheVar(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"; -}; From 21db53829b050c9258c3613a9a10cc8f4bf226dc Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 16 Jul 2024 11:45:06 -0400 Subject: [PATCH 05/32] Rename var to binding ...and change factory naming convention to just vnode, binding, block, etc. --- typescript/packages/common-html/src/html.ts | 4 +- typescript/packages/common-html/src/index.ts | 2 +- typescript/packages/common-html/src/render.ts | 6 +-- .../common-html/src/test/html.test.ts | 6 +-- .../common-html/src/test/view.test.ts | 24 +++++------- typescript/packages/common-html/src/view.ts | 37 +++++++++---------- 6 files changed, 35 insertions(+), 44 deletions(-) diff --git a/typescript/packages/common-html/src/html.ts b/typescript/packages/common-html/src/html.ts index db63e293a..d03d84378 100644 --- a/typescript/packages/common-html/src/html.ts +++ b/typescript/packages/common-html/src/html.ts @@ -1,6 +1,6 @@ import * as logger from "./logger.js"; import cid from "./cid.js"; -import { view, View, markupVar } from "./view.js"; +import { view, View, markupBinding } from "./view.js"; export const html = ( strings: TemplateStringsArray, @@ -22,7 +22,7 @@ export const html = ( return result + string; } const [name] = namedValue; - return result + string + markupVar(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 d6898f4a9..7a0264b62 100644 --- a/typescript/packages/common-html/src/index.ts +++ b/typescript/packages/common-html/src/index.ts @@ -1,4 +1,4 @@ -export { view, View, Context, VNode, Var } from "./view.js"; +export { view, View, Context, VNode, Binding } from "./view.js"; export { html } from "./html.js"; export { render, setNodeSanitizer, setEventSanitizer } from "./render.js"; export { Reactive } from "./reactive.js"; diff --git a/typescript/packages/common-html/src/render.ts b/typescript/packages/common-html/src/render.ts index e56f03aa4..9774d9681 100644 --- a/typescript/packages/common-html/src/render.ts +++ b/typescript/packages/common-html/src/render.ts @@ -1,4 +1,4 @@ -import { View, Context, isView, isVNode, VNode, isVar } from "./view.js"; +import { View, Context, isView, isVNode, VNode, isBinding } from "./view.js"; import { effect } from "./reactive.js"; import { isSendable } from "./sendable.js"; import { useCancelGroup, Cancel } from "./cancel.js"; @@ -32,7 +32,7 @@ const renderNode = ( } const element = document.createElement(sanitizedNode.name); attrs: for (const [name, value] of Object.entries(sanitizedNode.props)) { - if (isVar(value)) { + if (isBinding(value)) { const replacement = context[value.name]; // If prop is an event, we need to add an event listener if (isEventProp(name)) { @@ -67,7 +67,7 @@ const renderNode = ( if (childElement) { element.append(childElement); } - } else if (isVar(childNode)) { + } else if (isBinding(childNode)) { const replacement = context[childNode.name]; // Anchor for reactive replacement let anchor: ChildNode = document.createTextNode(""); diff --git a/typescript/packages/common-html/src/test/html.test.ts b/typescript/packages/common-html/src/test/html.test.ts index 76e606800..4be209fea 100644 --- a/typescript/packages/common-html/src/test/html.test.ts +++ b/typescript/packages/common-html/src/test/html.test.ts @@ -1,6 +1,6 @@ import * as assert from "node:assert"; import html from "../html.js"; -import { isVar } from "../view.js"; +import { isBinding } from "../view.js"; import { state, stream } from "../state.js"; describe("html", () => { @@ -15,9 +15,9 @@ describe("html", () => { `; // @ts-ignore - ignore for test - assert.strict(isVar(view.template.children[0].props.onclick)); + assert.strict(isBinding(view.template.children[0].props.onclick)); // @ts-ignore - ignore for test - assert.strict(isVar(view.template.children[0].children[0])); + assert.strict(isBinding(view.template.children[0].children[0])); }); }); diff --git a/typescript/packages/common-html/src/test/view.test.ts b/typescript/packages/common-html/src/test/view.test.ts index 7c31e6261..a27cee138 100644 --- a/typescript/packages/common-html/src/test/view.test.ts +++ b/typescript/packages/common-html/src/test/view.test.ts @@ -1,4 +1,4 @@ -import { view, parse, createVar, createVNode } from "../view.js"; +import { view, parse, binding, vnode } from "../view.js"; import * as assert from "node:assert/strict"; describe("view()", () => { @@ -6,7 +6,7 @@ describe("view()", () => { const hello = view("
Hello world!
", {}); assert.deepStrictEqual(hello, { type: "view", - template: createVNode("div", {}, ["Hello world!"]), + template: vnode("div", {}, ["Hello world!"]), context: {}, }); }); @@ -18,9 +18,7 @@ describe("view()", () => { }); assert.deepStrictEqual(hello, { type: "view", - template: createVNode("div", { hidden: createVar("hidden") }, [ - createVar("text"), - ]), + template: vnode("div", { hidden: binding("hidden") }, [binding("text")]), context: { hidden: false, text: "Hello world!", @@ -41,16 +39,12 @@ describe("parse()", () => { assert.deepEqual( root, - createVNode("documentfragment", {}, [ - createVNode( - "div", - { class: "container", hidden: createVar("hidden") }, - [ - createVNode("button", { id: "foo", onclick: createVar("click") }, [ - "Hello world!", - ]), - ], - ), + vnode("documentfragment", {}, [ + vnode("div", { class: "container", hidden: binding("hidden") }, [ + vnode("button", { id: "foo", onclick: binding("click") }, [ + "Hello world!", + ]), + ]), ]), ); }); diff --git a/typescript/packages/common-html/src/view.ts b/typescript/packages/common-html/src/view.ts index 0bea2d698..cf85cd4c6 100644 --- a/typescript/packages/common-html/src/view.ts +++ b/typescript/packages/common-html/src/view.ts @@ -45,10 +45,10 @@ export type Context = { [key: string]: unknown }; * Dynamic properties. Can either be string type (static) or a Mustache * variable (dynamic). */ -export type Props = { [key: string]: string | Var }; +export type Props = { [key: string]: string | Binding }; /** A child in a view can be one of a few things */ -export type Child = VNode | Block | Var | string; +export type Child = VNode | Block | Binding | string; /** A "virtual view node", e.g. a virtual DOM element */ export type VNode = { @@ -59,7 +59,7 @@ export type VNode = { }; /** Create a vnode */ -export const createVNode = ( +export const vnode = ( name: string, props: Props = {}, children: Array = [], @@ -72,20 +72,20 @@ export const isVNode = (value: unknown): value is VNode => { }; /** A mustache variable `{{myvar}}` */ -export type Var = { - type: "var"; +export type Binding = { + type: "binding"; name: string; }; -export const createVar = (name: string): Var => { - return { type: "var", name }; +export const binding = (name: string): Binding => { + return { type: "binding", name }; }; -export const isVar = (value: unknown): value is Var => { - return (value as Var)?.type === "var"; +export const isBinding = (value: unknown): value is Binding => { + return (value as Binding)?.type === "binding"; }; -export const markupVar = (name: string) => `{{${name}}}`; +export const markupBinding = (name: string) => `{{${name}}}`; /** A mustache block `{{#myblock}} ... {{/myblock}}` */ export type Block = { @@ -94,10 +94,7 @@ export type Block = { children: Array; }; -export const createBlock = ( - name: string, - children: Array = [], -): Block => { +export const block = (name: string, children: Array = []): Block => { return { type: "block", name, children }; }; @@ -237,14 +234,14 @@ export const tokenizeMustache = (text: string): Array => { * markup representation */ export const parse = (markup: string): VNode => { - let root: VNode = createVNode("documentfragment"); + 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 = createVNode(token.name, token.props); + const next = vnode(token.name, token.props); top.children.push(next); stack.push(next); break; @@ -257,7 +254,7 @@ export const parse = (markup: string): VNode => { break; } case "blockopen": { - const next = createBlock(token.name); + const next = block(token.name); top.children.push(next); stack.push(next); break; @@ -274,7 +271,7 @@ export const parse = (markup: string): VNode => { break; } case "var": { - top.children.push(createVar(token.name)); + top.children.push(binding(token.name)); break; } default: { @@ -293,13 +290,13 @@ const getTop = (stack: Array): VNode | Block | null => const MUSTACHE_VAR_REGEX = /^{{(\w+)}}$/; /** Parse a Mustache var if and only if it is the only element in a string */ -export const parseMustacheVar = (markup: string): Var | null => { +export const parseMustacheVar = (markup: string): Binding | null => { const match = markup.match(MUSTACHE_VAR_REGEX); if (match == null) { return null; } const body = match[1]; - return createVar(body); + return binding(body); }; /** Parse view props from attrs */ From 7ede002e2883fd27f1cd5757dba7140fdade8eb6 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 16 Jul 2024 11:47:01 -0400 Subject: [PATCH 06/32] Export block, etc from index --- typescript/packages/common-html/src/index.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/typescript/packages/common-html/src/index.ts b/typescript/packages/common-html/src/index.ts index 7a0264b62..3255eeb87 100644 --- a/typescript/packages/common-html/src/index.ts +++ b/typescript/packages/common-html/src/index.ts @@ -1,4 +1,16 @@ -export { view, View, Context, VNode, Binding } from "./view.js"; +export { + view, + View, + Context, + parse, + ParseError, + vnode, + VNode, + binding, + Binding, + block, + Block, +} from "./view.js"; export { html } from "./html.js"; export { render, setNodeSanitizer, setEventSanitizer } from "./render.js"; export { Reactive } from "./reactive.js"; From 746d12f07275f213d756423bb3bccd3e138c3e0f Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 16 Jul 2024 12:06:42 -0400 Subject: [PATCH 07/32] Add test for block parsing --- .../common-html/src/test/view.test.ts | 23 ++++++++++- typescript/packages/common-html/src/view.ts | 41 +++++++++++-------- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/typescript/packages/common-html/src/test/view.test.ts b/typescript/packages/common-html/src/test/view.test.ts index a27cee138..8b474df21 100644 --- a/typescript/packages/common-html/src/test/view.test.ts +++ b/typescript/packages/common-html/src/test/view.test.ts @@ -1,4 +1,4 @@ -import { view, parse, binding, vnode } from "../view.js"; +import { view, block, parse, binding, vnode } from "../view.js"; import * as assert from "node:assert/strict"; describe("view()", () => { @@ -48,4 +48,25 @@ describe("parse()", () => { ]), ); }); + + 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" }, [ + block("items", [vnode("div", { class: "item" }, [binding("text")])]), + ]), + ]), + ); + }); }); diff --git a/typescript/packages/common-html/src/view.ts b/typescript/packages/common-html/src/view.ts index cf85cd4c6..c8ccaa095 100644 --- a/typescript/packages/common-html/src/view.ts +++ b/typescript/packages/common-html/src/view.ts @@ -118,8 +118,8 @@ export type TextToken = { value: string; }; -export type VarToken = { - type: "var"; +export type BindingToken = { + type: "binding"; name: string; }; @@ -137,7 +137,7 @@ export type Token = | TagOpenToken | TagCloseToken | TextToken - | VarToken + | BindingToken | BlockOpenToken | BlockCloseToken; @@ -178,15 +178,16 @@ export const tokenize = (markup: string): Array => { return tokens; }; -const MUSTACHE_REGEX_G = /{{(\w+)}}/g; +const MUSTACHE_REGEXP = /{{([^\}]+)}}/; +const MUSTACHE_REGEXP_G = new RegExp(MUSTACHE_REGEXP, "g"); /** Tokenize Mustache */ export const tokenizeMustache = (text: string): Array => { const tokens: Array = []; - MUSTACHE_REGEX_G.lastIndex = 0; + MUSTACHE_REGEXP_G.lastIndex = 0; let lastIndex = 0; let match: RegExpMatchArray | null = null; - while ((match = MUSTACHE_REGEX_G.exec(text)) !== null) { + while ((match = MUSTACHE_REGEXP_G.exec(text)) !== null) { if (match.index > lastIndex) { const token: TextToken = { type: "text", @@ -208,11 +209,11 @@ export const tokenizeMustache = (text: string): Array => { logger.debug("blockclose", token); tokens.push(token); } else { - const token: VarToken = { type: "var", name: body }; + const token: BindingToken = { type: "binding", name: body }; logger.debug("var", token); tokens.push(token); } - lastIndex = MUSTACHE_REGEX_G.lastIndex; + lastIndex = MUSTACHE_REGEXP_G.lastIndex; } if (lastIndex < text.length) { @@ -224,7 +225,7 @@ export const tokenizeMustache = (text: string): Array => { tokens.push(token); } - MUSTACHE_REGEX_G.lastIndex = 0; + MUSTACHE_REGEXP.lastIndex = 0; return tokens; }; @@ -249,7 +250,9 @@ export const parse = (markup: string): VNode => { case "tagclose": { const top = stack.pop(); if (!isVNode(top) || top.name !== token.name) { - throw new ParseError(`Unexpected closing tag ${token.name}`); + throw new ParseError( + `Unexpected closing tag ${token.name} in ${top.name}`, + ); } break; } @@ -262,7 +265,9 @@ export const parse = (markup: string): VNode => { case "blockclose": { const top = stack.pop(); if (!isBlock(top) || top.name !== token.name) { - throw new ParseError(`Unexpected closing block ${token.name}`); + throw new ParseError( + `Unexpected closing block ${token.name} in ${top.name}`, + ); } break; } @@ -270,7 +275,7 @@ export const parse = (markup: string): VNode => { top.children.push(token.value); break; } - case "var": { + case "binding": { top.children.push(binding(token.name)); break; } @@ -287,15 +292,17 @@ export const parse = (markup: string): VNode => { const getTop = (stack: Array): VNode | Block | null => stack.at(-1) ?? null; -const MUSTACHE_VAR_REGEX = /^{{(\w+)}}$/; - /** Parse a Mustache var if and only if it is the only element in a string */ -export const parseMustacheVar = (markup: string): Binding | null => { - const match = markup.match(MUSTACHE_VAR_REGEX); +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); }; @@ -303,7 +310,7 @@ export const parseMustacheVar = (markup: string): Binding | null => { const parseProps = (attrs: { [key: string]: string }): Props => { const result: Props = {}; for (const [key, value] of Object.entries(attrs)) { - const parsed = parseMustacheVar(value); + const parsed = parseMustacheBinding(value); if (parsed != null) { result[key] = parsed; } else { From 37ef017c4e38ce872193a200855ea7b6127e9a30 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 16 Jul 2024 12:20:05 -0400 Subject: [PATCH 08/32] Rename block to section ... mustache calls {{#these}}{{/these}} sections. --- typescript/packages/common-html/src/index.ts | 4 +- .../common-html/src/test/view.test.ts | 6 ++- typescript/packages/common-html/src/view.ts | 54 ++++++++++--------- 3 files changed, 36 insertions(+), 28 deletions(-) diff --git a/typescript/packages/common-html/src/index.ts b/typescript/packages/common-html/src/index.ts index 3255eeb87..1efee092a 100644 --- a/typescript/packages/common-html/src/index.ts +++ b/typescript/packages/common-html/src/index.ts @@ -8,8 +8,8 @@ export { VNode, binding, Binding, - block, - Block, + section, + Section, } from "./view.js"; export { html } from "./html.js"; export { render, setNodeSanitizer, setEventSanitizer } from "./render.js"; diff --git a/typescript/packages/common-html/src/test/view.test.ts b/typescript/packages/common-html/src/test/view.test.ts index 8b474df21..2dce9e5cc 100644 --- a/typescript/packages/common-html/src/test/view.test.ts +++ b/typescript/packages/common-html/src/test/view.test.ts @@ -1,4 +1,4 @@ -import { view, block, parse, binding, vnode } from "../view.js"; +import { view, section, parse, binding, vnode } from "../view.js"; import * as assert from "node:assert/strict"; describe("view()", () => { @@ -64,7 +64,9 @@ describe("parse()", () => { root, vnode("documentfragment", {}, [ vnode("div", { class: "container" }, [ - block("items", [vnode("div", { class: "item" }, [binding("text")])]), + section("items", [ + vnode("div", { class: "item" }, [binding("text")]), + ]), ]), ]), ); diff --git a/typescript/packages/common-html/src/view.ts b/typescript/packages/common-html/src/view.ts index c8ccaa095..3adda4b3a 100644 --- a/typescript/packages/common-html/src/view.ts +++ b/typescript/packages/common-html/src/view.ts @@ -48,7 +48,7 @@ export type Context = { [key: string]: unknown }; export type Props = { [key: string]: string | Binding }; /** A child in a view can be one of a few things */ -export type Child = VNode | Block | Binding | string; +export type Child = VNode | Section | Binding | string; /** A "virtual view node", e.g. a virtual DOM element */ export type VNode = { @@ -88,18 +88,18 @@ export const isBinding = (value: unknown): value is Binding => { export const markupBinding = (name: string) => `{{${name}}}`; /** A mustache block `{{#myblock}} ... {{/myblock}}` */ -export type Block = { - type: "block"; +export type Section = { + type: "section"; name: string; children: Array; }; -export const block = (name: string, children: Array = []): Block => { - return { type: "block", name, children }; +export const section = (name: string, children: Array = []): Section => { + return { type: "section", name, children }; }; -export const isBlock = (value: unknown): value is Block => { - return (value as Block)?.type === "block"; +export const isSection = (value: unknown): value is Section => { + return (value as Section)?.type === "section"; }; export type TagOpenToken = { @@ -123,13 +123,13 @@ export type BindingToken = { name: string; }; -export type BlockOpenToken = { - type: "blockopen"; +export type SectionOpenToken = { + type: "sectionopen"; name: string; }; -export type BlockCloseToken = { - type: "blockclose"; +export type SectionCloseToken = { + type: "sectionclose"; name: string; }; @@ -138,8 +138,8 @@ export type Token = | TagCloseToken | TextToken | BindingToken - | BlockOpenToken - | BlockCloseToken; + | SectionOpenToken + | SectionCloseToken; /** Tokenize markup containing HTML and Mustache */ export const tokenize = (markup: string): Array => { @@ -198,15 +198,18 @@ export const tokenizeMustache = (text: string): Array => { } const body = match[1]; if (body.startsWith("#")) { - const token: BlockOpenToken = { type: "blockopen", name: body.slice(1) }; - logger.debug("blockopen", token); + const token: SectionOpenToken = { + type: "sectionopen", + name: body.slice(1), + }; + logger.debug("sectionopen", token); tokens.push(token); } else if (body.startsWith("/")) { - const token: BlockCloseToken = { - type: "blockclose", + const token: SectionCloseToken = { + type: "sectionclose", name: body.slice(1), }; - logger.debug("blockclose", token); + logger.debug("sectionclose", token); tokens.push(token); } else { const token: BindingToken = { type: "binding", name: body }; @@ -236,7 +239,7 @@ export const tokenizeMustache = (text: string): Array => { */ export const parse = (markup: string): VNode => { let root: VNode = vnode("documentfragment"); - let stack: Array = [root]; + let stack: Array = [root]; for (const token of tokenize(markup)) { const top = getTop(stack); @@ -256,15 +259,15 @@ export const parse = (markup: string): VNode => { } break; } - case "blockopen": { - const next = block(token.name); + case "sectionopen": { + const next = section(token.name); top.children.push(next); stack.push(next); break; } - case "blockclose": { + case "sectionclose": { const top = stack.pop(); - if (!isBlock(top) || top.name !== token.name) { + if (!isSection(top) || top.name !== token.name) { throw new ParseError( `Unexpected closing block ${token.name} in ${top.name}`, ); @@ -289,9 +292,12 @@ export const parse = (markup: string): VNode => { }; /** Get top of stack (last element) */ -const getTop = (stack: Array): VNode | Block | null => +const getTop = (stack: Array): VNode | Section | null => stack.at(-1) ?? null; +export const parseMustacheBody = (body: string): Array => + body.split("."); + /** 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); From 2466cb078ce1e2258c4ea8c287f58c29b1f94eb3 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 16 Jul 2024 12:33:13 -0400 Subject: [PATCH 09/32] Include parsed path on binding and section --- typescript/packages/common-html/src/view.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/typescript/packages/common-html/src/view.ts b/typescript/packages/common-html/src/view.ts index 3adda4b3a..b42c81aa7 100644 --- a/typescript/packages/common-html/src/view.ts +++ b/typescript/packages/common-html/src/view.ts @@ -75,10 +75,17 @@ export const isVNode = (value: unknown): value is VNode => { export type Binding = { type: "binding"; name: string; + path: Array; }; export const binding = (name: string): Binding => { - return { type: "binding", name }; + return { type: "binding", name, path: parsePath(name) }; +}; + +export const parsePath = (pathString: string): Array => { + const path = pathString.split("."); + logger.debug("parsePath", path); + return path; }; export const isBinding = (value: unknown): value is Binding => { @@ -91,11 +98,12 @@ export const markupBinding = (name: string) => `{{${name}}}`; export type Section = { type: "section"; name: string; + path: Array; children: Array; }; export const section = (name: string, children: Array = []): Section => { - return { type: "section", name, children }; + return { type: "section", name, path: parsePath(name), children }; }; export const isSection = (value: unknown): value is Section => { @@ -213,7 +221,7 @@ export const tokenizeMustache = (text: string): Array => { tokens.push(token); } else { const token: BindingToken = { type: "binding", name: body }; - logger.debug("var", token); + logger.debug("binding", token); tokens.push(token); } lastIndex = MUSTACHE_REGEXP_G.lastIndex; @@ -295,9 +303,6 @@ export const parse = (markup: string): VNode => { const getTop = (stack: Array): VNode | Section | null => stack.at(-1) ?? null; -export const parseMustacheBody = (body: string): Array => - body.split("."); - /** 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); From aafb5f8bda7f0367c7029643a15e045c0ad0eb33 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 16 Jul 2024 12:36:28 -0400 Subject: [PATCH 10/32] Parse path with only . --- .../packages/common-html/src/test/view.test.ts | 16 +++++++++++++++- typescript/packages/common-html/src/view.ts | 5 +++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/typescript/packages/common-html/src/test/view.test.ts b/typescript/packages/common-html/src/test/view.test.ts index 2dce9e5cc..ef4be4792 100644 --- a/typescript/packages/common-html/src/test/view.test.ts +++ b/typescript/packages/common-html/src/test/view.test.ts @@ -1,4 +1,4 @@ -import { view, section, parse, binding, vnode } from "../view.js"; +import { view, section, parse, binding, vnode, parsePath } from "../view.js"; import * as assert from "node:assert/strict"; describe("view()", () => { @@ -72,3 +72,17 @@ describe("parse()", () => { ); }); }); + +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/view.ts b/typescript/packages/common-html/src/view.ts index b42c81aa7..653f48d6d 100644 --- a/typescript/packages/common-html/src/view.ts +++ b/typescript/packages/common-html/src/view.ts @@ -83,6 +83,11 @@ export const binding = (name: string): Binding => { }; 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; From 8d41ffe6b3bb58665f2338d7348bd2f35c4b698a Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 16 Jul 2024 15:40:54 -0400 Subject: [PATCH 11/32] Factor out bindProps --- typescript/packages/common-html/src/render.ts | 79 ++++++++++++------- 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/typescript/packages/common-html/src/render.ts b/typescript/packages/common-html/src/render.ts index 9774d9681..3cc746e4c 100644 --- a/typescript/packages/common-html/src/render.ts +++ b/typescript/packages/common-html/src/render.ts @@ -1,4 +1,12 @@ -import { View, Context, isView, isVNode, VNode, isBinding } from "./view.js"; +import { + View, + Context, + isView, + isVNode, + VNode, + isBinding, + Props, +} from "./view.js"; import { effect } from "./reactive.js"; import { isSendable } from "./sendable.js"; import { useCancelGroup, Cancel } from "./cancel.js"; @@ -31,34 +39,10 @@ const renderNode = ( return null; } const element = document.createElement(sanitizedNode.name); - attrs: for (const [name, value] of Object.entries(sanitizedNode.props)) { - if (isBinding(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); - } - } + + const cancel = bindProps(element, sanitizedNode.props, context); + addCancel(cancel); + for (const childNode of sanitizedNode.children) { if (typeof childNode === "string") { element.append(childNode); @@ -89,6 +73,43 @@ const renderNode = ( return element; }; +const bindProps = ( + element: HTMLElement, + props: Props, + context: Context, +): Cancel => { + const [cancel, addCancel] = useCancelGroup(); + for (const [name, value] of Object.entries(props)) { + if (isBinding(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); + } + } + return cancel; +}; + const isEventProp = (key: string) => key.startsWith("on"); const cleanEventProp = (key: string) => { From 10215d3ddc19ac83d76005acac80bbaf7e862a9f Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 18 Jul 2024 17:02:45 -0400 Subject: [PATCH 12/32] WIP --- typescript/packages/common-html/src/render.ts | 60 ++++++++++++------- .../src/test-browser/render.test.ts | 12 ++-- typescript/packages/common-html/src/view.ts | 24 ++++++++ 3 files changed, 69 insertions(+), 27 deletions(-) diff --git a/typescript/packages/common-html/src/render.ts b/typescript/packages/common-html/src/render.ts index 3cc746e4c..44cc7d99f 100644 --- a/typescript/packages/common-html/src/render.ts +++ b/typescript/packages/common-html/src/render.ts @@ -6,25 +6,21 @@ import { VNode, isBinding, Props, + Child, + isSection, } from "./view.js"; import { effect } from "./reactive.js"; import { isSendable } from "./sendable.js"; import { useCancelGroup, Cancel } from "./cancel.js"; import * as logger from "./logger.js"; -export type CancellableHTMLElement = HTMLElement & { cancel?: Cancel }; - -export const render = (renderable: View): HTMLElement => { +export const render = (parent: HTMLElement, renderable: View): Cancel => { const { template, context } = renderable; const [cancel, addCancel] = useCancelGroup(); - const root = renderNode( - template, - context, - addCancel, - ) as CancellableHTMLElement; - root.cancel = cancel; + const root = renderNode(template, context, addCancel); + parent.append(root); logger.debug("Rendered", root); - return root; + return cancel; }; export default render; @@ -38,27 +34,47 @@ const renderNode = ( if (!sanitizedNode) { return null; } + const element = document.createElement(sanitizedNode.name); - const cancel = bindProps(element, sanitizedNode.props, context); - addCancel(cancel); + const cancelProps = bindProps(element, sanitizedNode.props, context); + addCancel(cancelProps); + + const cancelChildren = bindChildren(element, sanitizedNode.children, context); + addCancel(cancelChildren); + + return element; +}; - for (const childNode of sanitizedNode.children) { - if (typeof childNode === "string") { - element.append(childNode); - } else if (isVNode(childNode)) { - const childElement = renderNode(childNode, context, addCancel); +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 = renderNode(child, context, addCancel); if (childElement) { element.append(childElement); } - } else if (isBinding(childNode)) { - const replacement = context[childNode.name]; + } else if (isBinding(child)) { + // Bind dynamic content + const replacement = context[child.name]; // Anchor for reactive replacement let anchor: ChildNode = document.createTextNode(""); element.append(anchor); const cancel = effect(replacement, (replacement) => { if (isView(replacement)) { - const childElement = render(replacement); + const childElement = renderNode( + replacement.template, + replacement.context, + addCancel, + ); anchor.replaceWith(childElement); anchor = childElement; } else { @@ -68,9 +84,11 @@ const renderNode = ( } }); addCancel(cancel); + } else if (isSection(child)) { + logger.warn("Sections not yet implemented"); } } - return element; + return cancel; }; const bindProps = ( 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..348597582 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,16 @@ // import { equal as assertEqual } from "./assert.js"; import render from "../render.js"; import html from "../html.js"; -// import state from "../state.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); + console.log(parent); }); }); diff --git a/typescript/packages/common-html/src/view.ts b/typescript/packages/common-html/src/view.ts index 653f48d6d..8b3d0c29f 100644 --- a/typescript/packages/common-html/src/view.ts +++ b/typescript/packages/common-html/src/view.ts @@ -41,6 +41,30 @@ export const isView = (value: unknown): value is 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 = (context: Context, path: Array): unknown => { + let subject = context as unknown; + for (const key of path) { + subject = subject[key as keyof typeof subject] as unknown; + if (subject == null) { + return null; + } + } + return subject; +}; + /** * Dynamic properties. Can either be string type (static) or a Mustache * variable (dynamic). From e5865134b01c28be5f850412d404ffac6c80540c Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Fri, 19 Jul 2024 13:51:31 -0400 Subject: [PATCH 13/32] Fix example --- typescript/packages/common-html/example/main.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/typescript/packages/common-html/example/main.ts b/typescript/packages/common-html/example/main.ts index 5d14f7021..9ced51f04 100644 --- a/typescript/packages/common-html/example/main.ts +++ b/typescript/packages/common-html/example/main.ts @@ -13,7 +13,6 @@ 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) { @@ -44,6 +43,4 @@ const container = html`
${timeView} ${titleGroup}
`; -const dom = render(container); - -document.body.appendChild(dom); +const _cancel = render(document.body, container); From ca4ba73e9906bdf377cb3ba846a67d9f91aa29ed Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Fri, 19 Jul 2024 13:51:44 -0400 Subject: [PATCH 14/32] Add pathable Lets us get deep properties on objects or classes via either the `path` method, or by deep property access. --- .../packages/common-html/src/pathable.ts | 48 +++++++++++++++++++ typescript/packages/common-html/src/util.ts | 3 ++ 2 files changed, 51 insertions(+) create mode 100644 typescript/packages/common-html/src/pathable.ts create mode 100644 typescript/packages/common-html/src/util.ts diff --git a/typescript/packages/common-html/src/pathable.ts b/typescript/packages/common-html/src/pathable.ts new file mode 100644 index 000000000..09d4fadec --- /dev/null +++ b/typescript/packages/common-html/src/pathable.ts @@ -0,0 +1,48 @@ +import { isObject } from "./util.js"; + +/** A keypath is an array of property keys */ +export type KeyPath = Array; + +export type Pathable = { + path(keyPath: KeyPath): unknown; +}; + +/** Does value have a path method? */ +export const isPathable = (value: unknown): value is Pathable => { + return isObject(value) && "get" in value && typeof value.get === "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 deep value using a key path. + * Follows property path. Returns undefined if any key is not found. + */ +export const get = (value: T, keyPath: KeyPath): unknown => { + let subject = value as unknown; + for (const key of keyPath) { + subject = getProp(subject, key); + if (subject == null) { + return undefined; + } + } + return subject; +}; + +/** + * Get path on value using a keypath. + * If value is pathable, uses path method. + * Otherwise, gets properties along path. + */ +export const path = (value: T, keyPath: KeyPath): unknown => { + if (isPathable(value)) { + return value.path(keyPath); + } + return get(value, keyPath); +}; diff --git a/typescript/packages/common-html/src/util.ts b/typescript/packages/common-html/src/util.ts new file mode 100644 index 000000000..8bdb481aa --- /dev/null +++ b/typescript/packages/common-html/src/util.ts @@ -0,0 +1,3 @@ +export const isObject = (value: unknown): value is object => { + return typeof value === "object" && value !== null; +}; From 9230574b26c4a6416714517710113ffba24f8171 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Fri, 19 Jul 2024 14:39:28 -0400 Subject: [PATCH 15/32] Fix strict null type errors ...and factor cancel out of renderNode. Make it part of the return signature. --- .../common-html/src/{pathable.ts => path.ts} | 2 + typescript/packages/common-html/src/render.ts | 51 ++++++++++++------- typescript/packages/common-html/src/view.ts | 20 +++----- typescript/packages/common-html/tsconfig.json | 7 ++- 4 files changed, 45 insertions(+), 35 deletions(-) rename typescript/packages/common-html/src/{pathable.ts => path.ts} (98%) diff --git a/typescript/packages/common-html/src/pathable.ts b/typescript/packages/common-html/src/path.ts similarity index 98% rename from typescript/packages/common-html/src/pathable.ts rename to typescript/packages/common-html/src/path.ts index 09d4fadec..4a968487d 100644 --- a/typescript/packages/common-html/src/pathable.ts +++ b/typescript/packages/common-html/src/path.ts @@ -46,3 +46,5 @@ export const path = (value: T, keyPath: KeyPath): unknown => { } return get(value, keyPath); }; + +export default path; diff --git a/typescript/packages/common-html/src/render.ts b/typescript/packages/common-html/src/render.ts index 44cc7d99f..7711fed84 100644 --- a/typescript/packages/common-html/src/render.ts +++ b/typescript/packages/common-html/src/render.ts @@ -14,10 +14,13 @@ import { isSendable } from "./sendable.js"; import { useCancelGroup, Cancel } from "./cancel.js"; import * as logger from "./logger.js"; -export const render = (parent: HTMLElement, renderable: View): Cancel => { - const { template, context } = renderable; - const [cancel, addCancel] = useCancelGroup(); - const root = renderNode(template, context, addCancel); +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 cancel; @@ -28,11 +31,13 @@ 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.name); @@ -43,7 +48,7 @@ const renderNode = ( const cancelChildren = bindChildren(element, sanitizedNode.children, context); addCancel(cancelChildren); - return element; + return [element, cancel]; }; const bindChildren = ( @@ -52,13 +57,15 @@ const bindChildren = ( 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 = renderNode(child, context, addCancel); + const [childElement, cancel] = renderNode(child, context); + addCancel(cancel); if (childElement) { element.append(childElement); } @@ -70,13 +77,17 @@ const bindChildren = ( element.append(anchor); const cancel = effect(replacement, (replacement) => { if (isView(replacement)) { - const childElement = renderNode( + const [childElement, cancel] = renderNode( replacement.template, replacement.context, - addCancel, ); - anchor.replaceWith(childElement); - anchor = childElement; + 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); @@ -108,11 +119,15 @@ const bindProps = ( ); } const key = cleanEventProp(name); - const cancel = listen(element, key, (event) => { - const sanitizedEvent = sanitizeEvent(event); - replacement.send(sanitizedEvent); - }); - addCancel(cancel); + 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", name, value); + } } else { const cancel = effect(replacement, (replacement) => { // Replacements are set as properties not attributes to avoid diff --git a/typescript/packages/common-html/src/view.ts b/typescript/packages/common-html/src/view.ts index 8b3d0c29f..1feecf182 100644 --- a/typescript/packages/common-html/src/view.ts +++ b/typescript/packages/common-html/src/view.ts @@ -1,5 +1,6 @@ import { Parser } from "htmlparser2"; import * as logger from "./logger.js"; +import { path, KeyPath } from "./path.js"; /** Parse a markup string and context into a view */ export const view = (markup: string, context: Context): View => { @@ -54,15 +55,8 @@ export const get = (value: unknown): unknown => { }; /** Get context item by key */ -export const getContext = (context: Context, path: Array): unknown => { - let subject = context as unknown; - for (const key of path) { - subject = subject[key as keyof typeof subject] as unknown; - if (subject == null) { - return null; - } - } - return subject; +export const getContext = (context: Context, keyPath: KeyPath): unknown => { + return path(context, keyPath); }; /** @@ -225,7 +219,7 @@ export const tokenizeMustache = (text: string): Array => { let lastIndex = 0; let match: RegExpMatchArray | null = null; while ((match = MUSTACHE_REGEXP_G.exec(text)) !== null) { - if (match.index > lastIndex) { + if (match.index! > lastIndex) { const token: TextToken = { type: "text", value: text.slice(lastIndex, match.index), @@ -279,7 +273,7 @@ export const parse = (markup: string): VNode => { let stack: Array = [root]; for (const token of tokenize(markup)) { - const top = getTop(stack); + const top = getTop(stack)!; switch (token.type) { case "tagopen": { const next = vnode(token.name, token.props); @@ -291,7 +285,7 @@ export const parse = (markup: string): VNode => { const top = stack.pop(); if (!isVNode(top) || top.name !== token.name) { throw new ParseError( - `Unexpected closing tag ${token.name} in ${top.name}`, + `Unexpected closing tag ${token.name} in ${top?.name}`, ); } break; @@ -306,7 +300,7 @@ export const parse = (markup: string): VNode => { const top = stack.pop(); if (!isSection(top) || top.name !== token.name) { throw new ParseError( - `Unexpected closing block ${token.name} in ${top.name}`, + `Unexpected closing block ${token.name} in ${top?.name}`, ); } break; 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/**/*"] } From 8586c1da460d450d136a16671fe6bb13d9c44fca Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Fri, 19 Jul 2024 14:47:40 -0400 Subject: [PATCH 16/32] Add tests for path --- typescript/packages/common-html/src/path.ts | 2 +- .../common-html/src/test/path.test.ts | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 typescript/packages/common-html/src/test/path.test.ts diff --git a/typescript/packages/common-html/src/path.ts b/typescript/packages/common-html/src/path.ts index 4a968487d..04e039a28 100644 --- a/typescript/packages/common-html/src/path.ts +++ b/typescript/packages/common-html/src/path.ts @@ -9,7 +9,7 @@ export type Pathable = { /** Does value have a path method? */ export const isPathable = (value: unknown): value is Pathable => { - return isObject(value) && "get" in value && typeof value.get === "function"; + return isObject(value) && "path" in value && typeof value.path === "function"; }; /** Get value at prop. Returns undefined if key is not accessible. */ diff --git a/typescript/packages/common-html/src/test/path.test.ts b/typescript/packages/common-html/src/test/path.test.ts new file mode 100644 index 000000000..c886d8734 --- /dev/null +++ b/typescript/packages/common-html/src/test/path.test.ts @@ -0,0 +1,41 @@ +import { equal as assertEqual } from "node:assert/strict"; +import { path, KeyPath, Pathable } 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 `path()` implementation for Pathable types", () => { + class Wrapper implements Pathable { + #subject: T; + + constructor(subject: T) { + this.#subject = subject; + } + + path(keyPath: KeyPath): unknown { + return path(this.#subject, keyPath); + } + } + + const obj = { + a: { + b: [{ c: 10 }], + }, + }; + + const wrapper = new Wrapper(obj); + + assertEqual(path(wrapper, ["a", "b", 0, "c"]), 10); + }); +}); From 9faf00f5820810db4ed81e83a6fedf7c16a0119d Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Fri, 19 Jul 2024 18:01:54 -0400 Subject: [PATCH 17/32] Basic {{dotted.mustaches}} working --- .../packages/common-html/example/main.ts | 18 +-- .../common-html/src/{util.ts => contract.ts} | 0 typescript/packages/common-html/src/path.ts | 39 +++--- .../packages/common-html/src/reactive.ts | 4 + typescript/packages/common-html/src/render.ts | 21 +-- typescript/packages/common-html/src/state.ts | 121 ++++++++++++------ typescript/packages/common-html/src/view.ts | 6 +- 7 files changed, 127 insertions(+), 82 deletions(-) rename typescript/packages/common-html/src/{util.ts => contract.ts} (100%) diff --git a/typescript/packages/common-html/example/main.ts b/typescript/packages/common-html/example/main.ts index 9ced51f04..641bdb311 100644 --- a/typescript/packages/common-html/example/main.ts +++ b/typescript/packages/common-html/example/main.ts @@ -9,14 +9,14 @@ setDebug(true); // setNodeSanitizer(...); // setEventSanitizer(...); -const text = state("Hello, world!"); -const input = stream(); +const inputState = state({ text: "Hello, world!" }); +const inputEvents = stream(); -input.sink((event) => { +inputEvents.sink((event) => { const target = event.target as HTMLInputElement | null; - const value = target?.value ?? null; - if (value !== null) { - text.send(value); + const value = target?.value; + if (value != null) { + inputState.send({ text: value }); } }); @@ -32,11 +32,11 @@ const timeView = view(`
{{time}}
`, { time }); const titleGroup = view( `
-

{{text}}

- +

{{input.text}}

+
`, - { text, input }, + { input: inputState, oninput: inputEvents }, ); const container = html` diff --git a/typescript/packages/common-html/src/util.ts b/typescript/packages/common-html/src/contract.ts similarity index 100% rename from typescript/packages/common-html/src/util.ts rename to typescript/packages/common-html/src/contract.ts diff --git a/typescript/packages/common-html/src/path.ts b/typescript/packages/common-html/src/path.ts index 04e039a28..36cf973be 100644 --- a/typescript/packages/common-html/src/path.ts +++ b/typescript/packages/common-html/src/path.ts @@ -1,10 +1,12 @@ -import { isObject } from "./util.js"; +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 Pathable = { - path(keyPath: KeyPath): unknown; + path(keyPath: NonEmptyKeyPath): unknown; }; /** Does value have a path method? */ @@ -20,31 +22,28 @@ export const getProp = (value: unknown, key: PropertyKey): unknown => { return value[key as keyof typeof value] ?? undefined; }; -/** - * Get deep value using a key path. - * Follows property path. Returns undefined if any key is not found. - */ -export const get = (value: T, keyPath: KeyPath): unknown => { - let subject = value as unknown; - for (const key of keyPath) { - subject = getProp(subject, key); - if (subject == null) { - return undefined; - } - } - return subject; -}; - /** * Get path on value using a keypath. * If value is pathable, uses path method. * Otherwise, gets properties along path. */ -export const path = (value: T, keyPath: KeyPath): unknown => { +export const path = (value: unknown, keyPath: Array): unknown => { + if (value == null) { + return undefined; + } + if (keyPath.length === 0) { + return value; + } if (isPathable(value)) { - return value.path(keyPath); + const part = value.path(keyPath as NonEmptyKeyPath); + logger.debug("path: call path()", value, keyPath, part); + return part; } - return get(value, keyPath); + const [key, ...restPath] = keyPath; + // We checked the length, so we know this is not undefined. + const part = getProp(value, key); + logger.debug("path: get prop", value, key, part); + return path(part, restPath); }; export default path; diff --git a/typescript/packages/common-html/src/reactive.ts b/typescript/packages/common-html/src/reactive.ts index c2ca9e6ba..9ae369431 100644 --- a/typescript/packages/common-html/src/reactive.ts +++ b/typescript/packages/common-html/src/reactive.ts @@ -11,6 +11,10 @@ export type Reactive = { sink: (callback: (value: T) => void) => Cancel; }; +export type ReactiveValue = Reactive & { + get(): T; +}; + export const isReactive = (value: unknown): value is Reactive => { return typeof (value as Reactive)?.sink === "function"; }; diff --git a/typescript/packages/common-html/src/render.ts b/typescript/packages/common-html/src/render.ts index 7711fed84..a651ee247 100644 --- a/typescript/packages/common-html/src/render.ts +++ b/typescript/packages/common-html/src/render.ts @@ -8,6 +8,7 @@ import { Props, Child, isSection, + getContext, } from "./view.js"; import { effect } from "./reactive.js"; import { isSendable } from "./sendable.js"; @@ -71,7 +72,7 @@ const bindChildren = ( } } else if (isBinding(child)) { // Bind dynamic content - const replacement = context[child.name]; + const replacement = getContext(context, child.path); // Anchor for reactive replacement let anchor: ChildNode = document.createTextNode(""); element.append(anchor); @@ -108,17 +109,17 @@ const bindProps = ( context: Context, ): Cancel => { const [cancel, addCancel] = useCancelGroup(); - for (const [name, value] of Object.entries(props)) { - if (isBinding(value)) { - const replacement = context[value.name]; + 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(name)) { + if (isEventProp(propKey)) { if (!isSendable(replacement)) { throw new TypeError( - `Event prop "${name}" does not have a send method`, + `Event prop "${propKey}" does not have a send method`, ); } - const key = cleanEventProp(name); + const key = cleanEventProp(propKey); if (key != null) { const cancel = listen(element, key, (event) => { const sanitizedEvent = sanitizeEvent(event); @@ -126,18 +127,18 @@ const bindProps = ( }); addCancel(cancel); } else { - logger.warn("Could not bind event", name, value); + 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, name, replacement); + setProp(element, propKey, replacement); }); addCancel(cancel); } } else { - element.setAttribute(name, value); + element.setAttribute(propKey, propValue); } } return cancel; diff --git a/typescript/packages/common-html/src/state.ts b/typescript/packages/common-html/src/state.ts index 7426a5597..c0abb0dc6 100644 --- a/typescript/packages/common-html/src/state.ts +++ b/typescript/packages/common-html/src/state.ts @@ -1,53 +1,96 @@ -/** A simple reactive state cell without any scheduling */ -export const state = (value: T) => { - let state = value; - const listeners = new Set<(value: T) => void>(); +import { NonEmptyKeyPath, path } from "./path.js"; +import { ReactiveValue } from "./reactive.js"; + +/** A one-to-many typed event publisher */ +export class Publisher { + #listeners = new Set<(value: T) => void>(); - const get = () => state; + send(value: T) { + for (const listener of this.#listeners) { + listener(value); + } + } - const sink = (callback: (value: T) => void) => { + sink(callback: (value: T) => void) { + const listeners = this.#listeners; listeners.add(callback); - callback(state); return () => { listeners.delete(callback); }; - }; + } +} - const send = (value: T) => { - state = value; - for (const listener of listeners) { - listener(state); - } - }; +class State { + #publisher = new Publisher(); + #state: T; - return { - get, - sink, - send, - }; -}; + constructor(value: T) { + this.#state = value; + } -/** A simple reactive event stream without any scheduling */ -export const stream = () => { - const listeners = new Set<(value: T) => void>(); + get() { + return this.#state; + } - const sink = (callback: (value: T) => void) => { - listeners.add(callback); - return () => { - listeners.delete(callback); - }; - }; + send(value: T) { + this.#state = value; + this.#publisher.send(value); + } - const send = (value: T) => { - for (const listener of listeners) { - listener(value); - } - }; + sink(callback: (value: T) => void) { + callback(this.#state); + return this.#publisher.sink(callback); + } + + path(keyPath: NonEmptyKeyPath) { + return scope(this, (value) => path(value, keyPath)); + } +} + +/** A simple reactive state cell without any scheduling */ +export const state = (value: T) => new State(value); + +/** + * A scoped cell that represents some transformation of a state. + * ScopedState is a "cold" reactive value. It only does work when you subscribe + * to it with sink. Each sink performs computed transformation of the source + * state and returns a cancel function to unsubscribe. + */ +export class ScopedState { + #source: ReactiveValue; + #transform: (value: T) => U; + + constructor(source: ReactiveValue, transform: (value: T) => U) { + this.#transform = transform; + this.#source = source; + } - return { - sink, - send, - }; -}; + get() { + return this.#transform(this.#source.get()); + } + + sink(callback: (value: U) => void) { + let state: U | null = null; + return this.#source.sink((value) => { + const next = this.#transform(value); + if (state !== next) { + state = next; + callback(next); + } + }); + } + + path(keyPath: NonEmptyKeyPath) { + return scope(this, (value) => path(value, keyPath)); + } +} + +export const scope = ( + source: ReactiveValue, + transform: (value: T) => U, +) => new ScopedState(source, transform); + +/** A simple reactive event stream without any scheduling */ +export const stream = () => new Publisher(); export default state; diff --git a/typescript/packages/common-html/src/view.ts b/typescript/packages/common-html/src/view.ts index 1feecf182..3d0980e5b 100644 --- a/typescript/packages/common-html/src/view.ts +++ b/typescript/packages/common-html/src/view.ts @@ -1,6 +1,6 @@ import { Parser } from "htmlparser2"; import * as logger from "./logger.js"; -import { path, KeyPath } from "./path.js"; +import { path } from "./path.js"; /** Parse a markup string and context into a view */ export const view = (markup: string, context: Context): View => { @@ -55,9 +55,7 @@ export const get = (value: unknown): unknown => { }; /** Get context item by key */ -export const getContext = (context: Context, keyPath: KeyPath): unknown => { - return path(context, keyPath); -}; +export const getContext = path; /** * Dynamic properties. Can either be string type (static) or a Mustache From 2049630fbf1036aab922d74283874ca154d0d0db Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Fri, 19 Jul 2024 21:33:04 -0400 Subject: [PATCH 18/32] Scope on source and compose transform fns --- typescript/packages/common-html/src/state.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/typescript/packages/common-html/src/state.ts b/typescript/packages/common-html/src/state.ts index c0abb0dc6..5e5f5dac1 100644 --- a/typescript/packages/common-html/src/state.ts +++ b/typescript/packages/common-html/src/state.ts @@ -50,11 +50,14 @@ class State { /** A simple reactive state cell without any scheduling */ export const state = (value: T) => new State(value); +export default state; + /** * A scoped cell that represents some transformation of a state. * ScopedState is a "cold" reactive value. It only does work when you subscribe * to it with sink. Each sink performs computed transformation of the source - * state and returns a cancel function to unsubscribe. + * state separately, and returns a cancel function to unsubscribe that + * particular sink. There are no intermediate subscriptions to cancel. */ export class ScopedState { #source: ReactiveValue; @@ -81,7 +84,9 @@ export class ScopedState { } path(keyPath: NonEmptyKeyPath) { - return scope(this, (value) => path(value, keyPath)); + return scope(this.#source, (value) => + path(this.#transform(value), keyPath), + ); } } @@ -92,5 +97,3 @@ export const scope = ( /** A simple reactive event stream without any scheduling */ export const stream = () => new Publisher(); - -export default state; From f8f4d7484c948cb71b8cc511543c387d5d79ad65 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 23 Jul 2024 21:39:50 -0400 Subject: [PATCH 19/32] Base path on .key() --- typescript/packages/common-html/src/path.ts | 31 +++++++++---------- typescript/packages/common-html/src/state.ts | 29 +++++++---------- .../common-html/src/test/path.test.ts | 10 +++--- 3 files changed, 31 insertions(+), 39 deletions(-) diff --git a/typescript/packages/common-html/src/path.ts b/typescript/packages/common-html/src/path.ts index 36cf973be..f7b7a8d45 100644 --- a/typescript/packages/common-html/src/path.ts +++ b/typescript/packages/common-html/src/path.ts @@ -5,13 +5,12 @@ import * as logger from "./logger.js"; export type KeyPath = Array; export type NonEmptyKeyPath = [PropertyKey, ...PropertyKey[]]; -export type Pathable = { - path(keyPath: NonEmptyKeyPath): unknown; +export type Keyable = { + key(key: K): T[K]; }; -/** Does value have a path method? */ -export const isPathable = (value: unknown): value is Pathable => { - return isObject(value) && "path" in value && typeof value.path === "function"; +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. */ @@ -27,23 +26,23 @@ export const getProp = (value: unknown, key: PropertyKey): unknown => { * If value is pathable, uses path method. * Otherwise, gets properties along path. */ -export const path = (value: unknown, keyPath: Array): unknown => { - if (value == null) { +export const path = (parent: T, keyPath: Array): unknown => { + if (parent == null) { return undefined; } if (keyPath.length === 0) { - return value; + return parent; } - if (isPathable(value)) { - const part = value.path(keyPath as NonEmptyKeyPath); - logger.debug("path: call path()", value, keyPath, part); - return part; + const key = keyPath.shift()!; + if (isKeyable(parent)) { + const child = parent.key(key); + logger.debug("path: call .key()", parent, key, child); + return path(child, keyPath); } - const [key, ...restPath] = keyPath; // We checked the length, so we know this is not undefined. - const part = getProp(value, key); - logger.debug("path: get prop", value, key, part); - return path(part, restPath); + const child = getProp(parent, key); + logger.debug("path: get prop", parent, key, child); + return path(child, keyPath); }; export default path; diff --git a/typescript/packages/common-html/src/state.ts b/typescript/packages/common-html/src/state.ts index 5e5f5dac1..ddd9c9310 100644 --- a/typescript/packages/common-html/src/state.ts +++ b/typescript/packages/common-html/src/state.ts @@ -1,6 +1,3 @@ -import { NonEmptyKeyPath, path } from "./path.js"; -import { ReactiveValue } from "./reactive.js"; - /** A one-to-many typed event publisher */ export class Publisher { #listeners = new Set<(value: T) => void>(); @@ -20,7 +17,7 @@ export class Publisher { } } -class State { +export class State { #publisher = new Publisher(); #state: T; @@ -42,8 +39,8 @@ class State { return this.#publisher.sink(callback); } - path(keyPath: NonEmptyKeyPath) { - return scope(this, (value) => path(value, keyPath)); + key(key: K) { + return new ScopedState(this, (value) => value[key]); } } @@ -60,15 +57,15 @@ export default state; * particular sink. There are no intermediate subscriptions to cancel. */ export class ScopedState { - #source: ReactiveValue; + #source: State; #transform: (value: T) => U; - constructor(source: ReactiveValue, transform: (value: T) => U) { + constructor(source: State, transform: (value: T) => U) { this.#transform = transform; this.#source = source; } - get() { + get(): U { return this.#transform(this.#source.get()); } @@ -83,17 +80,13 @@ export class ScopedState { }); } - path(keyPath: NonEmptyKeyPath) { - return scope(this.#source, (value) => - path(this.#transform(value), keyPath), - ); + key(key: K) { + return new ScopedState(this.#source, (value) => { + const scoped = this.#transform(value); + return scoped[key]; + }); } } -export const scope = ( - source: ReactiveValue, - transform: (value: T) => U, -) => new ScopedState(source, transform); - /** A simple reactive event stream without any scheduling */ export const stream = () => new Publisher(); diff --git a/typescript/packages/common-html/src/test/path.test.ts b/typescript/packages/common-html/src/test/path.test.ts index c886d8734..d59d42722 100644 --- a/typescript/packages/common-html/src/test/path.test.ts +++ b/typescript/packages/common-html/src/test/path.test.ts @@ -1,5 +1,5 @@ import { equal as assertEqual } from "node:assert/strict"; -import { path, KeyPath, Pathable } from "../path.js"; +import { path } from "../path.js"; describe("path", () => { it("gets a deep path from any object", () => { @@ -15,16 +15,16 @@ describe("path", () => { assertEqual(path({}, ["a", "b", 0, "c"]), undefined); }); - it("defers to `path()` implementation for Pathable types", () => { - class Wrapper implements Pathable { + it("defers to `key()` implementation for Keyable types", () => { + class Wrapper { #subject: T; constructor(subject: T) { this.#subject = subject; } - path(keyPath: KeyPath): unknown { - return path(this.#subject, keyPath); + key(key: K): T[K] { + return this.#subject[key]; } } From 9ba28c121078bbaa26c0116e98e4cdb94b8111d0 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 23 Jul 2024 21:40:22 -0400 Subject: [PATCH 20/32] Add cell / propagator --- .../packages/common-html/src/propagator.ts | 190 ++++++++++++++++++ .../common-html/src/test/propagator.test.ts | 83 ++++++++ 2 files changed, 273 insertions(+) create mode 100644 typescript/packages/common-html/src/propagator.ts create mode 100644 typescript/packages/common-html/src/test/propagator.test.ts diff --git a/typescript/packages/common-html/src/propagator.ts b/typescript/packages/common-html/src/propagator.ts new file mode 100644 index 000000000..c0bad9b0a --- /dev/null +++ b/typescript/packages/common-html/src/propagator.ts @@ -0,0 +1,190 @@ +import cid from "./cid.js"; +import { Cancel, Cancellable, useCancelGroup } from "./cancel.js"; +import * as logger from "./logger.js"; + +const advanceClock = (a: number, b: number) => Math.max(a, b) + 1; + +export const lww = (_state: T, next: T) => next; + +export type LamportTime = number; + +export class Cell { + #id = cid(); + #neighbors = new Set<(value: Value, time: LamportTime) => void>(); + #time: LamportTime = 0; + #update: (state: Value, next: Value) => Value; + #value: Value; + + constructor({ + value, + update = lww, + }: { + value: Value; + update?: (state: Value, next: Value) => Value; + }) { + this.#update = update; + this.#value = value; + } + + get id() { + return this.#id; + } + + get() { + return this.#value; + } + + send(value: Value, time: LamportTime = this.#time + 1): number { + logger.debug(`cell#${this.id}`, "Message", value, time); + + // We ignore old news + if (this.#time >= time) { + logger.debug( + `cell#${this.id}`, + "Message out of date. Ignoring.", + value, + time, + ); + return this.#time; + } + + this.#time = advanceClock(this.#time, time); + logger.debug(`cell#${this.id}`, "Advanced clock", this.#time); + + const next = this.#update(this.#value, value); + + // We only advance clock if msg changed state + if (this.#value === next) { + logger.debug(`cell#${this.id}`, "Value unchanged.", this.#value); + return this.#time; + } + + const prev = this.#value; + this.#value = next; + logger.debug(`cell#${this.id}`, "Value updated", prev, next); + + // Notify neighbors + for (const neighbor of this.#neighbors) { + neighbor(next, this.#time); + } + logger.debug(`cell#${this.id}`, "Notified neighbors", this.#neighbors.size); + + return this.#time; + } + + /** Disconnect all neighbors */ + disconnect() { + this.#neighbors.clear(); + logger.debug( + `cell#${this.id}`, + "Disconnected all neighbors", + this.#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); + } +} + +/** A simple reactive state cell without any scheduling */ +export const cell = ({ + value, + update = lww, +}: { + value: Value; + update?: (state: Value, next: Value) => Value; +}) => new Cell({ value, update }); + +export default cell; + +export type CancellableCell = Cell & Cancellable; + +export const lens = ({ + cell: big, + get, + update, +}: { + cell: Cell; + get: (big: B) => S; + update: (big: B, small: S) => B; +}): CancellableCell => { + const bigValue = big.get(); + + const small = cell({ value: get(bigValue) }); + + const [cancel, addCancel] = useCancelGroup(); + + // Propagate writes from parent to child + const cancelBigToSmall = big.sink((parentValue, time) => { + small.send(get(parentValue), time); + }); + addCancel(cancelBigToSmall); + + // Propagate writes from child to parent + const cancelSmallToBig = small.sink((value, time) => { + big.send(update(big.get(), value), time); + }); + addCancel(cancelSmallToBig); + + const cancellableSmall = small as CancellableCell; + cancellableSmall.cancel = cancel; + + return cancellableSmall; +}; + +export const key = ( + big: Cell, + key: K, +): CancellableCell => + lens({ + cell: big, + get: (big) => big[key], + update: (big, small) => ({ ...big, [key]: small }), + }); + +/** 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) => { + if (isScheduled) return; + scheduledJob = job; + isScheduled = true; + queue(perform); + }; +}; + +/** Batch updates on animatinoframe */ +export const render = ( + cell: Cell, + callback: (state: State) => Cancel | null, +) => { + const [cancel, addCancel] = useCancelGroup(); + + const batch = batcher(requestAnimationFrame); + + const cancelSink = cell.sink((state) => { + batch(() => { + const cancel = callback(state); + addCancel(cancel); + }); + }); + + addCancel(cancelSink); + return cancel; +}; diff --git a/typescript/packages/common-html/src/test/propagator.test.ts b/typescript/packages/common-html/src/test/propagator.test.ts new file mode 100644 index 000000000..4ae705cd2 --- /dev/null +++ b/typescript/packages/common-html/src/test/propagator.test.ts @@ -0,0 +1,83 @@ +import { + equal as assertEqual, + deepEqual as assertDeepEqual, +} from "node:assert/strict"; +import cell, { lens } from "../propagator.js"; + +describe("cell()", () => { + it("synchronously sets the value", () => { + const a = cell({ value: 1 }); + a.send(2); + assertEqual(a.get(), 2); + }); + + it("reacts synchronously when sent a new value", () => { + const a = cell({ value: 1 }); + + let state = 0; + a.sink((value) => { + state = value; + }); + a.send(2); + + assertEqual(state, 2); + }); +}); + +describe("lens()", () => { + it("lenses over a cell", () => { + const x = cell({ value: { a: { b: { c: 10 } } } }); + + const c = lens({ + cell: x, + get: (state) => state.a.b.c, + update: (state, next) => ({ ...state, a: { b: { c: next } } }), + }); + + 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({ value: { a: 10 } }); + const a = x.key("a"); + + assertEqual(a.get(), 10); + }); + + it("reflects updates from parent to child", () => { + const x = cell({ value: { 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({ value: { 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({ value: { 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); + }); +}); From 9de216293ca8ffeb14bc299d09ff3a9d09442bd7 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 24 Jul 2024 10:54:32 -0400 Subject: [PATCH 21/32] Move render into reactive - Add tests - Batch on microtask instead of animationframe - Add name to cell - Remove ID from cell --- .../packages/common-html/src/propagator.ts | 81 +++++++------------ .../packages/common-html/src/reactive.ts | 48 +++++++++++ .../common-html/src/test/propagator.test.ts | 7 +- .../common-html/src/test/reactive.test.ts | 65 ++++++++++++++- 4 files changed, 144 insertions(+), 57 deletions(-) diff --git a/typescript/packages/common-html/src/propagator.ts b/typescript/packages/common-html/src/propagator.ts index c0bad9b0a..e417bd856 100644 --- a/typescript/packages/common-html/src/propagator.ts +++ b/typescript/packages/common-html/src/propagator.ts @@ -1,5 +1,4 @@ -import cid from "./cid.js"; -import { Cancel, Cancellable, useCancelGroup } from "./cancel.js"; +import { Cancellable, useCancelGroup } from "./cancel.js"; import * as logger from "./logger.js"; const advanceClock = (a: number, b: number) => Math.max(a, b) + 1; @@ -8,8 +7,11 @@ export const lww = (_state: T, next: T) => next; export type LamportTime = number; +/** + * A cell is a reactive value that can be updated and subscribed to. + */ export class Cell { - #id = cid(); + #name: string = ""; #neighbors = new Set<(value: Value, time: LamportTime) => void>(); #time: LamportTime = 0; #update: (state: Value, next: Value) => Value; @@ -17,17 +19,20 @@ export class Cell { constructor({ value, + name = "", update = lww, }: { value: Value; + name?: string; update?: (state: Value, next: Value) => Value; }) { + this.#name = name; this.#update = update; this.#value = value; } - get id() { - return this.#id; + get name() { + return this.#name; } get() { @@ -35,12 +40,12 @@ export class Cell { } send(value: Value, time: LamportTime = this.#time + 1): number { - logger.debug(`cell#${this.id}`, "Message", value, time); + logger.debug(`cell#${this.name}`, "Message", value, time); // We ignore old news if (this.#time >= time) { logger.debug( - `cell#${this.id}`, + `cell#${this.name}`, "Message out of date. Ignoring.", value, time, @@ -49,25 +54,29 @@ export class Cell { } this.#time = advanceClock(this.#time, time); - logger.debug(`cell#${this.id}`, "Advanced clock", this.#time); + logger.debug(`cell#${this.name}`, "Advanced clock", this.#time); const next = this.#update(this.#value, value); // We only advance clock if msg changed state if (this.#value === next) { - logger.debug(`cell#${this.id}`, "Value unchanged.", this.#value); + logger.debug(`cell#${this.name}`, "Value unchanged.", this.#value); return this.#time; } const prev = this.#value; this.#value = next; - logger.debug(`cell#${this.id}`, "Value updated", prev, next); + logger.debug(`cell#${this.name}`, "Value updated", prev, next); // Notify neighbors for (const neighbor of this.#neighbors) { neighbor(next, this.#time); } - logger.debug(`cell#${this.id}`, "Notified neighbors", this.#neighbors.size); + logger.debug( + `cell#${this.name}`, + "Notified neighbors", + this.#neighbors.size, + ); return this.#time; } @@ -76,7 +85,7 @@ export class Cell { disconnect() { this.#neighbors.clear(); logger.debug( - `cell#${this.id}`, + `cell#${this.name}`, "Disconnected all neighbors", this.#neighbors.size, ); @@ -95,16 +104,18 @@ export class Cell { } } -/** A simple reactive state cell without any scheduling */ +/** + * Create a reactive cell for a value + */ export const cell = ({ value, + name = "", update = lww, }: { value: Value; + name?: string; update?: (state: Value, next: Value) => Value; -}) => new Cell({ value, update }); - -export default cell; +}) => new Cell({ value, name, update }); export type CancellableCell = Cell & Cancellable; @@ -150,41 +161,3 @@ export const key = ( get: (big) => big[key], update: (big, small) => ({ ...big, [key]: small }), }); - -/** 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) => { - if (isScheduled) return; - scheduledJob = job; - isScheduled = true; - queue(perform); - }; -}; - -/** Batch updates on animatinoframe */ -export const render = ( - cell: Cell, - callback: (state: State) => Cancel | null, -) => { - const [cancel, addCancel] = useCancelGroup(); - - const batch = batcher(requestAnimationFrame); - - const cancelSink = cell.sink((state) => { - batch(() => { - const cancel = callback(state); - addCancel(cancel); - }); - }); - - addCancel(cancelSink); - return cancel; -}; diff --git a/typescript/packages/common-html/src/reactive.ts b/typescript/packages/common-html/src/reactive.ts index 9ae369431..51c1856c0 100644 --- a/typescript/packages/common-html/src/reactive.ts +++ b/typescript/packages/common-html/src/reactive.ts @@ -45,3 +45,51 @@ export const effect = ( }; 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/propagator.test.ts b/typescript/packages/common-html/src/test/propagator.test.ts index 4ae705cd2..29f767be7 100644 --- a/typescript/packages/common-html/src/test/propagator.test.ts +++ b/typescript/packages/common-html/src/test/propagator.test.ts @@ -2,7 +2,7 @@ import { equal as assertEqual, deepEqual as assertDeepEqual, } from "node:assert/strict"; -import cell, { lens } from "../propagator.js"; +import { cell, lens } from "../propagator.js"; describe("cell()", () => { it("synchronously sets the value", () => { @@ -22,6 +22,11 @@ describe("cell()", () => { assertEqual(state, 2); }); + + it("has an optional name", () => { + const a = cell({ value: 1, name: "a" }); + assertEqual(a.name, "a"); + }); }); describe("lens()", () => { diff --git a/typescript/packages/common-html/src/test/reactive.test.ts b/typescript/packages/common-html/src/test/reactive.test.ts index 9edc07ec5..acca0d00c 100644 --- a/typescript/packages/common-html/src/test/reactive.test.ts +++ b/typescript/packages/common-html/src/test/reactive.test.ts @@ -1,6 +1,8 @@ import { equal as assertEqual } from "node:assert/strict"; import state from "../state.js"; -import { effect, isReactive } from "../reactive.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", () => { @@ -19,7 +21,7 @@ describe("isReactive", () => { }); }); -describe("effect", () => { +describe("effect()", () => { it("runs callback for nonreactive values", () => { let calls = 0; effect(10, (_value: number) => { @@ -72,3 +74,62 @@ describe("effect", () => { 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 = state(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 = state(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); + }); +}); From f5567ac127682220c83c8fbe2342e24f47ad2c8a Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 24 Jul 2024 10:59:38 -0400 Subject: [PATCH 22/32] Update HTML to use propagator cell ...instead of bespoke State class. --- .../packages/common-html/src/propagator.ts | 9 +- typescript/packages/common-html/src/state.ts | 92 ------------------- .../common-html/src/test/html.test.ts | 4 +- .../common-html/src/test/reactive.test.ts | 2 +- 4 files changed, 9 insertions(+), 98 deletions(-) delete mode 100644 typescript/packages/common-html/src/state.ts diff --git a/typescript/packages/common-html/src/propagator.ts b/typescript/packages/common-html/src/propagator.ts index e417bd856..20761220a 100644 --- a/typescript/packages/common-html/src/propagator.ts +++ b/typescript/packages/common-html/src/propagator.ts @@ -104,9 +104,7 @@ export class Cell { } } -/** - * Create a reactive cell for a value - */ +/** Create a reactive cell for a value */ export const cell = ({ value, name = "", @@ -117,6 +115,11 @@ export const cell = ({ update?: (state: Value, next: Value) => Value; }) => new Cell({ value, name, update }); +export default cell; + +/** Create a cell with last-write-wins update semantics */ +export const state = (value: T, name = "") => cell({ value, name }); + export type CancellableCell = Cell & Cancellable; export const lens = ({ diff --git a/typescript/packages/common-html/src/state.ts b/typescript/packages/common-html/src/state.ts deleted file mode 100644 index ddd9c9310..000000000 --- a/typescript/packages/common-html/src/state.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** A one-to-many typed event publisher */ -export class Publisher { - #listeners = new Set<(value: T) => void>(); - - send(value: T) { - for (const listener of this.#listeners) { - listener(value); - } - } - - sink(callback: (value: T) => void) { - const listeners = this.#listeners; - listeners.add(callback); - return () => { - listeners.delete(callback); - }; - } -} - -export class State { - #publisher = new Publisher(); - #state: T; - - constructor(value: T) { - this.#state = value; - } - - get() { - return this.#state; - } - - send(value: T) { - this.#state = value; - this.#publisher.send(value); - } - - sink(callback: (value: T) => void) { - callback(this.#state); - return this.#publisher.sink(callback); - } - - key(key: K) { - return new ScopedState(this, (value) => value[key]); - } -} - -/** A simple reactive state cell without any scheduling */ -export const state = (value: T) => new State(value); - -export default state; - -/** - * A scoped cell that represents some transformation of a state. - * ScopedState is a "cold" reactive value. It only does work when you subscribe - * to it with sink. Each sink performs computed transformation of the source - * state separately, and returns a cancel function to unsubscribe that - * particular sink. There are no intermediate subscriptions to cancel. - */ -export class ScopedState { - #source: State; - #transform: (value: T) => U; - - constructor(source: State, transform: (value: T) => U) { - this.#transform = transform; - this.#source = source; - } - - get(): U { - return this.#transform(this.#source.get()); - } - - sink(callback: (value: U) => void) { - let state: U | null = null; - return this.#source.sink((value) => { - const next = this.#transform(value); - if (state !== next) { - state = next; - callback(next); - } - }); - } - - key(key: K) { - return new ScopedState(this.#source, (value) => { - const scoped = this.#transform(value); - return scoped[key]; - }); - } -} - -/** A simple reactive event stream without any scheduling */ -export const stream = () => new Publisher(); diff --git a/typescript/packages/common-html/src/test/html.test.ts b/typescript/packages/common-html/src/test/html.test.ts index 4be209fea..d16583b1f 100644 --- a/typescript/packages/common-html/src/test/html.test.ts +++ b/typescript/packages/common-html/src/test/html.test.ts @@ -1,11 +1,11 @@ import * as assert from "node:assert"; import html from "../html.js"; import { isBinding } from "../view.js"; -import { state, stream } from "../state.js"; +import { state } from "../propagator.js"; describe("html", () => { it("parses tagged template string into a Renderable", () => { - const clicks = stream(); + const clicks = state(null); const text = state("Hello world!"); const view = html` diff --git a/typescript/packages/common-html/src/test/reactive.test.ts b/typescript/packages/common-html/src/test/reactive.test.ts index acca0d00c..9efc29507 100644 --- a/typescript/packages/common-html/src/test/reactive.test.ts +++ b/typescript/packages/common-html/src/test/reactive.test.ts @@ -1,5 +1,5 @@ import { equal as assertEqual } from "node:assert/strict"; -import state from "../state.js"; +import { state } from "../propagator.js"; import { effect, render, isReactive } from "../reactive.js"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); From 40fd992e90beda213534492d0329314e7ed36e98 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 24 Jul 2024 11:10:40 -0400 Subject: [PATCH 23/32] Add lens type - Define propagator lens in terms of lens type. --- typescript/packages/common-html/src/lens.ts | 12 ++++++++++ .../packages/common-html/src/propagator.ts | 24 +++++++++---------- 2 files changed, 23 insertions(+), 13 deletions(-) create mode 100644 typescript/packages/common-html/src/lens.ts diff --git a/typescript/packages/common-html/src/lens.ts b/typescript/packages/common-html/src/lens.ts new file mode 100644 index 000000000..1c39b350b --- /dev/null +++ b/typescript/packages/common-html/src/lens.ts @@ -0,0 +1,12 @@ +export type Lens = { + get: (big: Big) => Small; + update: (big: Big, small: Small) => Big; +}; + +export const lens = ({ get, update }: Lens) => + Object.freeze({ + get, + update, + }); + +export default lens; diff --git a/typescript/packages/common-html/src/propagator.ts b/typescript/packages/common-html/src/propagator.ts index 20761220a..8ab6bd6c4 100644 --- a/typescript/packages/common-html/src/propagator.ts +++ b/typescript/packages/common-html/src/propagator.ts @@ -1,5 +1,6 @@ import { Cancellable, useCancelGroup } from "./cancel.js"; import * as logger from "./logger.js"; +import { Lens } from "./lens.js"; const advanceClock = (a: number, b: number) => Math.max(a, b) + 1; @@ -122,18 +123,16 @@ export const state = (value: T, name = "") => cell({ value, name }); export type CancellableCell = Cell & Cancellable; -export const lens = ({ - cell: big, - get, - update, -}: { - cell: Cell; - get: (big: B) => S; - update: (big: B, small: S) => B; -}): CancellableCell => { +/** + * 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({ value: get(bigValue) }); + const small = cell({ value: lens.get(bigValue) }); const [cancel, addCancel] = useCancelGroup(); @@ -145,7 +144,7 @@ export const lens = ({ // Propagate writes from child to parent const cancelSmallToBig = small.sink((value, time) => { - big.send(update(big.get(), value), time); + big.send(lens.update(big.get(), value), time); }); addCancel(cancelSmallToBig); @@ -159,8 +158,7 @@ export const key = ( big: Cell, key: K, ): CancellableCell => - lens({ - cell: big, + lens(big, { get: (big) => big[key], update: (big, small) => ({ ...big, [key]: small }), }); From 48c9cdc28a6dfc7f65cf4f831f71d8a117f5bc60 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 24 Jul 2024 11:14:19 -0400 Subject: [PATCH 24/32] Fix lens accessor --- typescript/packages/common-html/src/propagator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/packages/common-html/src/propagator.ts b/typescript/packages/common-html/src/propagator.ts index 8ab6bd6c4..3f7148ee9 100644 --- a/typescript/packages/common-html/src/propagator.ts +++ b/typescript/packages/common-html/src/propagator.ts @@ -138,7 +138,7 @@ export const lens = ( // Propagate writes from parent to child const cancelBigToSmall = big.sink((parentValue, time) => { - small.send(get(parentValue), time); + small.send(lens.get(parentValue), time); }); addCancel(cancelBigToSmall); From 511adcd8625a12ee3edd71f907eca7f3d6dc3df4 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 24 Jul 2024 14:40:52 -0400 Subject: [PATCH 25/32] Add lift function that solves diamond problem Uses vector clock to solve diamond problem. --- .../packages/common-html/src/propagator.ts | 213 +++++++++++++++--- .../common-html/src/test/propagator.test.ts | 91 +++++++- 2 files changed, 266 insertions(+), 38 deletions(-) diff --git a/typescript/packages/common-html/src/propagator.ts b/typescript/packages/common-html/src/propagator.ts index 3f7148ee9..6bee20256 100644 --- a/typescript/packages/common-html/src/propagator.ts +++ b/typescript/packages/common-html/src/propagator.ts @@ -1,18 +1,28 @@ -import { Cancellable, useCancelGroup } from "./cancel.js"; +import { Cancel, Cancellable, useCancelGroup } from "./cancel.js"; import * as logger from "./logger.js"; import { Lens } from "./lens.js"; +import cid from "./cid.js"; -const advanceClock = (a: number, b: number) => Math.max(a, b) + 1; +export type LamportTime = number; -export const lww = (_state: T, next: T) => next; +const advanceClock = (...times: LamportTime[]) => Math.max(...times) + 1; -export type LamportTime = number; +export const lww = (_state: T, next: T) => next; /** * A cell is a reactive value that can be updated and subscribed to. */ export class Cell { - #name: string = ""; + 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; #update: (state: Value, next: Value) => Value; @@ -30,66 +40,99 @@ export class Cell { this.#name = name; this.#update = update; 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(`cell#${this.name}`, "Message", value, time); + logger.debug({ + msg: "Sent value", + cell: this.id, + value, + time, + }); - // We ignore old news + // We ignore old news. + // If times are equal, we ignore incoming value. if (this.#time >= time) { - logger.debug( - `cell#${this.name}`, - "Message out of date. Ignoring.", + logger.debug({ + msg: "Value out of date. Ignoring.", + cell: this.id, value, time, - ); + }); return this.#time; } - this.#time = advanceClock(this.#time, time); - logger.debug(`cell#${this.name}`, "Advanced clock", this.#time); - - const next = this.#update(this.#value, value); - - // We only advance clock if msg changed state - if (this.#value === next) { - logger.debug(`cell#${this.name}`, "Value unchanged.", this.#value); + // We only advance clock if value changes state + if (this.#value === value) { + logger.debug({ + msg: "Value unchanged. Ignoring.", + cell: this.id, + value, + time, + }); return this.#time; } + this.#time = advanceClock(this.#time, time); + + const next = this.#update(this.#value, value); const prev = this.#value; this.#value = next; - logger.debug(`cell#${this.name}`, "Value updated", prev, 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( - `cell#${this.name}`, - "Notified neighbors", - this.#neighbors.size, - ); + + 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( - `cell#${this.name}`, - "Disconnected all neighbors", - this.#neighbors.size, - ); + logger.debug({ + msg: "Disconnected all neighbors", + cell: this.id, + neighbors: size, + }); } sink(callback: (value: Value, time: LamportTime) => void) { @@ -137,14 +180,18 @@ export const lens = ( const [cancel, addCancel] = useCancelGroup(); // Propagate writes from parent to child - const cancelBigToSmall = big.sink((parentValue, time) => { - small.send(lens.get(parentValue), time); + const cancelBigToSmall = big.sink((bigValue, time) => { + small.send(lens.get(bigValue), time); }); addCancel(cancelBigToSmall); // Propagate writes from child to parent - const cancelSmallToBig = small.sink((value, time) => { - big.send(lens.update(big.get(), value), time); + 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); @@ -162,3 +209,101 @@ export const key = ( 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-html/src/test/propagator.test.ts b/typescript/packages/common-html/src/test/propagator.test.ts index 29f767be7..682c68943 100644 --- a/typescript/packages/common-html/src/test/propagator.test.ts +++ b/typescript/packages/common-html/src/test/propagator.test.ts @@ -2,7 +2,7 @@ import { equal as assertEqual, deepEqual as assertDeepEqual, } from "node:assert/strict"; -import { cell, lens } from "../propagator.js"; +import { cell, state, lens, lift } from "../propagator.js"; describe("cell()", () => { it("synchronously sets the value", () => { @@ -31,14 +31,15 @@ describe("cell()", () => { describe("lens()", () => { it("lenses over a cell", () => { - const x = cell({ value: { a: { b: { c: 10 } } } }); + const x = cell({ name: "x", value: { a: { b: { c: 10 } } } }); - const c = lens({ - cell: 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 } } }); @@ -86,3 +87,85 @@ describe("cell.key()", () => { 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 = state(1, "lift.a"); + const b = state(2, "lift.b"); + const out = state(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 = state(1, "a"); + const b = state(1, "b"); + const out = state(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 = state(1, "a"); + const out = state(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 = state(1, "a"); + const b = state(1, "b"); + const out = state(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", + ); + }); +}); From 529dfa4f4ab2a67147ffe8fb4e25ab202cf4f666 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 24 Jul 2024 16:13:37 -0400 Subject: [PATCH 26/32] Perform update *before* rejecting same value --- typescript/packages/common-html/src/propagator.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/typescript/packages/common-html/src/propagator.ts b/typescript/packages/common-html/src/propagator.ts index 6bee20256..4a83cb692 100644 --- a/typescript/packages/common-html/src/propagator.ts +++ b/typescript/packages/common-html/src/propagator.ts @@ -85,12 +85,14 @@ export class Cell { return this.#time; } + const next = this.#update(this.#value, value); + // We only advance clock if value changes state - if (this.#value === value) { + if (this.#value === next) { logger.debug({ msg: "Value unchanged. Ignoring.", cell: this.id, - value, + value: next, time, }); return this.#time; @@ -98,7 +100,6 @@ export class Cell { this.#time = advanceClock(this.#time, time); - const next = this.#update(this.#value, value); const prev = this.#value; this.#value = next; From ec171352351fd3c854bcd0b5b829552b2c2af957 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 24 Jul 2024 16:56:22 -0400 Subject: [PATCH 27/32] Remove cell update, use mergeable interface - a type is mergeable if it implements merge(value: this): this - function `merge(prev: T, curr: T): T` will take any two values and merge them using merge method if mergeable, or deferring to curr if not mergeable - now we can remove the update function in the cell signature. The type knows how to update itself. - This lets us simplify the cell signature - We can drop state, which was just a wrapper for cell that had LWW semantics. Now all non-mergeable values have LWW semantics. - Mergeable will let us implement things like accumulating explanations on the datatype about what changed and why. We can use these during generation. --- .../packages/common-html/src/propagator.ts | 55 ++++++++++--------- .../common-html/src/test/html.test.ts | 6 +- .../common-html/src/test/propagator.test.ts | 40 +++++++------- .../common-html/src/test/reactive.test.ts | 12 ++-- 4 files changed, 57 insertions(+), 56 deletions(-) diff --git a/typescript/packages/common-html/src/propagator.ts b/typescript/packages/common-html/src/propagator.ts index 4a83cb692..b886311ab 100644 --- a/typescript/packages/common-html/src/propagator.ts +++ b/typescript/packages/common-html/src/propagator.ts @@ -3,12 +3,34 @@ import * as logger from "./logger.js"; import { Lens } from "./lens.js"; import cid from "./cid.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 ( + typeof value === "object" && + typeof value.merge === "function" && + value.merge.length === 1 + ); +}; + +/** + * Merge will merge prev and curr if they are mergeable, otherwise will + * return curr. + */ +const merge = (prev: T, curr: T): T => { + if (isMergeable(prev) && isMergeable(curr)) { + return prev.merge(curr); + } + return curr; +}; + export type LamportTime = number; const advanceClock = (...times: LamportTime[]) => Math.max(...times) + 1; -export const lww = (_state: T, next: T) => next; - /** * A cell is a reactive value that can be updated and subscribed to. */ @@ -25,20 +47,10 @@ export class Cell { #name = ""; #neighbors = new Set<(value: Value, time: LamportTime) => void>(); #time: LamportTime = 0; - #update: (state: Value, next: Value) => Value; #value: Value; - constructor({ - value, - name = "", - update = lww, - }: { - value: Value; - name?: string; - update?: (state: Value, next: Value) => Value; - }) { + constructor(value: Value, name = "") { this.#name = name; - this.#update = update; this.#value = value; logger.debug({ msg: "Cell created", @@ -85,7 +97,7 @@ export class Cell { return this.#time; } - const next = this.#update(this.#value, value); + const next = merge(this.#value, value); // We only advance clock if value changes state if (this.#value === next) { @@ -150,21 +162,10 @@ export class Cell { } /** Create a reactive cell for a value */ -export const cell = ({ - value, - name = "", - update = lww, -}: { - value: Value; - name?: string; - update?: (state: Value, next: Value) => Value; -}) => new Cell({ value, name, update }); +export const cell = (value: Value, name = "") => new Cell(value, name); export default cell; -/** Create a cell with last-write-wins update semantics */ -export const state = (value: T, name = "") => cell({ value, name }); - export type CancellableCell = Cell & Cancellable; /** @@ -176,7 +177,7 @@ export const lens = ( ): CancellableCell => { const bigValue = big.get(); - const small = cell({ value: lens.get(bigValue) }); + const small = cell(lens.get(bigValue)); const [cancel, addCancel] = useCancelGroup(); diff --git a/typescript/packages/common-html/src/test/html.test.ts b/typescript/packages/common-html/src/test/html.test.ts index d16583b1f..3eab76033 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 { isBinding } from "../view.js"; -import { state } from "../propagator.js"; +import { cell } from "../propagator.js"; describe("html", () => { it("parses tagged template string into a Renderable", () => { - const clicks = state(null); - const text = state("Hello world!"); + const clicks = cell(null); + const text = cell("Hello world!"); const view = html`
diff --git a/typescript/packages/common-html/src/test/propagator.test.ts b/typescript/packages/common-html/src/test/propagator.test.ts index 682c68943..40934d5c2 100644 --- a/typescript/packages/common-html/src/test/propagator.test.ts +++ b/typescript/packages/common-html/src/test/propagator.test.ts @@ -2,17 +2,17 @@ import { equal as assertEqual, deepEqual as assertDeepEqual, } from "node:assert/strict"; -import { cell, state, lens, lift } from "../propagator.js"; +import { cell, lens, lift } from "../propagator.js"; describe("cell()", () => { it("synchronously sets the value", () => { - const a = cell({ value: 1 }); + const a = cell(1); a.send(2); assertEqual(a.get(), 2); }); it("reacts synchronously when sent a new value", () => { - const a = cell({ value: 1 }); + const a = cell(1); let state = 0; a.sink((value) => { @@ -24,14 +24,14 @@ describe("cell()", () => { }); it("has an optional name", () => { - const a = cell({ value: 1, name: "a" }); + const a = cell(1, "a"); assertEqual(a.name, "a"); }); }); describe("lens()", () => { it("lenses over a cell", () => { - const x = cell({ name: "x", value: { a: { b: { c: 10 } } } }); + const x = cell({ a: { b: { c: 10 } } }, "x"); const c = lens(x, { get: (state) => state.a.b.c, @@ -49,14 +49,14 @@ describe("lens()", () => { describe("cell.key()", () => { it("returns a typesafe cell that reflects the state of the parent", () => { - const x = cell({ value: { a: 10 } }); + const x = cell({ a: 10 }); const a = x.key("a"); assertEqual(a.get(), 10); }); it("reflects updates from parent to child", () => { - const x = cell({ value: { a: 10 } }); + const x = cell({ a: 10 }); const a = x.key("a"); x.send({ a: 20 }); @@ -65,7 +65,7 @@ describe("cell.key()", () => { }); it("reflects updates from child to parent", () => { - const x = cell({ value: { a: 10 } }); + const x = cell({ a: 10 }); const a = x.key("a"); a.send(20); @@ -74,7 +74,7 @@ describe("cell.key()", () => { }); it("it works for deep derived keys", () => { - const x = cell({ value: { a: { b: { c: 10 } } } }); + const x = cell({ a: { b: { c: 10 } } }); const a = x.key("a"); const b = a.key("b"); const c = b.key("c"); @@ -92,9 +92,9 @@ 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 = state(1, "lift.a"); - const b = state(2, "lift.b"); - const out = state(0, "lift.out"); + const a = cell(1, "lift.a"); + const b = cell(2, "lift.b"); + const out = cell(0, "lift.out"); const cancel = addCells(a, b, out); @@ -106,9 +106,9 @@ describe("lift()", () => { it("updates the out cell whenever an input cell updates", () => { const addCells = lift((a: number, b: number) => a + b); - const a = state(1, "a"); - const b = state(1, "b"); - const out = state(0, "out"); + const a = cell(1, "a"); + const b = cell(1, "b"); + const out = cell(0, "out"); addCells(a, b, out); assertEqual(out.get(), 2); @@ -123,8 +123,8 @@ describe("lift()", () => { it("solves the diamond problem", () => { const addCells = lift((a: number, b: number) => a + b); - const a = state(1, "a"); - const out = state(0, "out"); + const a = cell(1, "a"); + const out = cell(0, "out"); addCells(a, a, out); assertEqual(out.get(), 2); @@ -147,9 +147,9 @@ describe("lift()", () => { it("solves the diamond problem (2)", () => { const add3 = lift((a: number, b: number, c: number) => a + b + c); - const a = state(1, "a"); - const b = state(1, "b"); - const out = state(0, "out"); + const a = cell(1, "a"); + const b = cell(1, "b"); + const out = cell(0, "out"); add3(a, b, b, out); assertEqual(out.get(), 3); diff --git a/typescript/packages/common-html/src/test/reactive.test.ts b/typescript/packages/common-html/src/test/reactive.test.ts index 9efc29507..973e73c32 100644 --- a/typescript/packages/common-html/src/test/reactive.test.ts +++ b/typescript/packages/common-html/src/test/reactive.test.ts @@ -1,12 +1,12 @@ import { equal as assertEqual } from "node:assert/strict"; -import { state } from "../propagator.js"; +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 = state(0); + const a = cell(0); assertEqual(isReactive(a), true); class B { @@ -31,7 +31,7 @@ describe("effect()", () => { }); it("subscribes callback to `sink` for reactive value", () => { - const value = state(10); + const value = cell(10); let valueMut = 0; effect(value, (value: number) => { @@ -45,7 +45,7 @@ describe("effect()", () => { }); it("ends subscription to reactive value when cancel is called", () => { - const value = state(10); + const value = cell(10); let valueMut = 0; const cancel = effect(value, (value: number) => { @@ -86,7 +86,7 @@ describe("render()", () => { }); it("subscribes callback to `sink` for reactive value", async () => { - const value = state(10); + const value = cell(10); let valueMut = 0; render(value, (value: number) => { @@ -102,7 +102,7 @@ describe("render()", () => { }); it("ends subscription to reactive value when cancel is called", async () => { - const value = state(10); + const value = cell(10); let valueMut = 0; const cancel = render(value, (value: number) => { From 43b01f45baa63c31ee04d08d575ed3d414ed4663 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 24 Jul 2024 18:27:50 -0400 Subject: [PATCH 28/32] Factor propagators out into common-propagator --- typescript/package-lock.json | 164 +++++++++++++++--- typescript/package.json | 2 + typescript/packages/common-html/package.json | 8 +- typescript/packages/common-html/src/html.ts | 4 +- typescript/packages/common-html/src/index.ts | 2 - typescript/packages/common-html/src/lens.ts | 12 -- typescript/packages/common-html/src/render.ts | 8 +- .../packages/common-html/src/sendable.ts | 9 - .../common-html/src/test/html.test.ts | 2 +- typescript/packages/common-html/src/tid.ts | 6 + typescript/packages/common-html/src/view.ts | 2 +- .../packages/common-propagator/package.json | 67 +++++++ .../src/cancel.ts | 0 .../src/cid.ts | 0 .../src/contract.ts | 0 .../packages/common-propagator/src/index.ts | 8 + .../packages/common-propagator/src/lamport.ts | 3 + .../packages/common-propagator/src/lens.ts | 4 + .../packages/common-propagator/src/logger.ts | 25 +++ .../common-propagator/src/mergeable.ts | 23 +++ .../src/path.ts | 0 .../src/propagator.ts | 30 +--- .../src/reactive.ts | 21 ++- .../src/test/cancel.test.ts | 0 .../src/test/path.test.ts | 0 .../src/test/propagator.test.ts | 0 .../src/test/reactive.test.ts | 0 .../packages/common-propagator/tsconfig.json | 20 +++ 28 files changed, 330 insertions(+), 90 deletions(-) delete mode 100644 typescript/packages/common-html/src/lens.ts delete mode 100644 typescript/packages/common-html/src/sendable.ts create mode 100644 typescript/packages/common-html/src/tid.ts create mode 100644 typescript/packages/common-propagator/package.json rename typescript/packages/{common-html => common-propagator}/src/cancel.ts (100%) rename typescript/packages/{common-html => common-propagator}/src/cid.ts (100%) rename typescript/packages/{common-html => common-propagator}/src/contract.ts (100%) create mode 100644 typescript/packages/common-propagator/src/index.ts create mode 100644 typescript/packages/common-propagator/src/lamport.ts create mode 100644 typescript/packages/common-propagator/src/lens.ts create mode 100644 typescript/packages/common-propagator/src/logger.ts create mode 100644 typescript/packages/common-propagator/src/mergeable.ts rename typescript/packages/{common-html => common-propagator}/src/path.ts (100%) rename typescript/packages/{common-html => common-propagator}/src/propagator.ts (90%) rename typescript/packages/{common-html => common-propagator}/src/reactive.ts (76%) rename typescript/packages/{common-html => common-propagator}/src/test/cancel.test.ts (100%) rename typescript/packages/{common-html => common-propagator}/src/test/path.test.ts (100%) rename typescript/packages/{common-html => common-propagator}/src/test/propagator.test.ts (100%) rename typescript/packages/{common-html => common-propagator}/src/test/reactive.test.ts (100%) create mode 100644 typescript/packages/common-propagator/tsconfig.json diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 4f344680b..0c700113a 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -643,6 +643,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 @@ -751,10 +755,12 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" ], + "peer": true, "engines": { "node": ">=12" } @@ -766,10 +772,12 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=12" } @@ -781,10 +789,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=12" } @@ -796,10 +806,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=12" } @@ -811,10 +823,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=12" } @@ -826,10 +840,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=12" } @@ -841,10 +857,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -856,10 +874,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -871,10 +891,12 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -886,10 +908,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -901,10 +925,12 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -916,10 +942,12 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -931,10 +959,12 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -946,10 +976,12 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -961,10 +993,12 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -976,10 +1010,12 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -991,10 +1027,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -1006,10 +1044,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -1021,10 +1061,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -1036,10 +1078,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=12" } @@ -1051,10 +1095,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=12" } @@ -1066,10 +1112,12 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=12" } @@ -1081,10 +1129,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=12" } @@ -1234,7 +1284,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" @@ -1798,10 +1848,12 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.18.0", @@ -1810,10 +1862,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.18.0", @@ -1822,10 +1876,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.18.0", @@ -1834,10 +1890,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.18.0", @@ -1846,10 +1904,12 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.18.0", @@ -1858,10 +1918,12 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.18.0", @@ -1870,10 +1932,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.18.0", @@ -1882,10 +1946,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "version": "4.18.0", @@ -1894,10 +1960,12 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.18.0", @@ -1906,10 +1974,12 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.18.0", @@ -1918,10 +1988,12 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.18.0", @@ -1930,10 +2002,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.18.0", @@ -1942,10 +2016,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.18.0", @@ -1954,10 +2030,12 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.18.0", @@ -1966,10 +2044,12 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.18.0", @@ -1978,10 +2058,12 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rushstack/node-core-library": { "version": "4.0.2", @@ -2251,7 +2333,8 @@ "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true }, "node_modules/@types/express": { "version": "4.17.21", @@ -3089,7 +3172,7 @@ "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "devOptional": true, + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -3434,7 +3517,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", @@ -4549,6 +4632,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" @@ -4999,6 +5083,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": [ @@ -6821,6 +6906,7 @@ "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, "funding": [ { "type": "github", @@ -7321,6 +7407,7 @@ "version": "8.4.39", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -7758,6 +7845,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" }, @@ -8059,6 +8147,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -8067,7 +8156,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" @@ -8077,7 +8166,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" } @@ -8393,7 +8482,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", @@ -8411,7 +8500,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", @@ -8631,6 +8720,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz", "integrity": "sha512-NPQdeCU0Dv2z5fu+ULotpuq5yfCS1BzKUIPhNbP3YBfAMGJXbt2nS+sbTFu+qchaqWTD+H3JK++nRwr6XIcp6A==", + "dev": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.39", @@ -9237,8 +9327,7 @@ "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", @@ -9248,6 +9337,7 @@ "mocha": "^10.6.0", "tslib": "^2.6.2", "typescript": "^5.2.2", + "vite": "^5.3.3", "wireit": "^0.14.4" } }, @@ -9275,6 +9365,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 e62e26adc..33ab9e9bf 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/package.json b/typescript/packages/common-html/package.json index 56c729d4a..7f8435cbf 100644 --- a/typescript/packages/common-html/package.json +++ b/typescript/packages/common-html/package.json @@ -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/html.ts b/typescript/packages/common-html/src/html.ts index d03d84378..f9bf40f50 100644 --- a/typescript/packages/common-html/src/html.ts +++ b/typescript/packages/common-html/src/html.ts @@ -1,5 +1,5 @@ import * as logger from "./logger.js"; -import cid from "./cid.js"; +import tid from "./tid.js"; import { view, View, markupBinding } from "./view.js"; export const html = ( @@ -12,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 diff --git a/typescript/packages/common-html/src/index.ts b/typescript/packages/common-html/src/index.ts index 1efee092a..9f32516e5 100644 --- a/typescript/packages/common-html/src/index.ts +++ b/typescript/packages/common-html/src/index.ts @@ -13,6 +13,4 @@ export { } from "./view.js"; export { html } from "./html.js"; export { render, setNodeSanitizer, setEventSanitizer } from "./render.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/lens.ts b/typescript/packages/common-html/src/lens.ts deleted file mode 100644 index 1c39b350b..000000000 --- a/typescript/packages/common-html/src/lens.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type Lens = { - get: (big: Big) => Small; - update: (big: Big, small: Small) => Big; -}; - -export const lens = ({ get, update }: Lens) => - Object.freeze({ - get, - update, - }); - -export default lens; diff --git a/typescript/packages/common-html/src/render.ts b/typescript/packages/common-html/src/render.ts index a651ee247..de065b017 100644 --- a/typescript/packages/common-html/src/render.ts +++ b/typescript/packages/common-html/src/render.ts @@ -10,9 +10,11 @@ import { isSection, getContext, } from "./view.js"; -import { effect } from "./reactive.js"; -import { isSendable } from "./sendable.js"; -import { useCancelGroup, Cancel } from "./cancel.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 const render = (parent: HTMLElement, view: View): Cancel => { 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/test/html.test.ts b/typescript/packages/common-html/src/test/html.test.ts index 3eab76033..e2a8c4f29 100644 --- a/typescript/packages/common-html/src/test/html.test.ts +++ b/typescript/packages/common-html/src/test/html.test.ts @@ -1,7 +1,7 @@ import * as assert from "node:assert"; import html from "../html.js"; import { isBinding } from "../view.js"; -import { cell } from "../propagator.js"; +import { cell } from "@commontools/common-propagator"; describe("html", () => { it("parses tagged template string into a Renderable", () => { 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 3d0980e5b..f3fff606f 100644 --- a/typescript/packages/common-html/src/view.ts +++ b/typescript/packages/common-html/src/view.ts @@ -1,6 +1,6 @@ import { Parser } from "htmlparser2"; import * as logger from "./logger.js"; -import { path } from "./path.js"; +import { path } from "@commontools/common-propagator/path.js"; /** Parse a markup string and context into a view */ export const view = (markup: string, context: Context): View => { 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-html/src/contract.ts b/typescript/packages/common-propagator/src/contract.ts similarity index 100% rename from typescript/packages/common-html/src/contract.ts rename to typescript/packages/common-propagator/src/contract.ts 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..6cc464340 --- /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 = (...args: unknown[]) => { + console.warn(...args); +}; + +/** Log if debugging is on */ +export const debug = (...args: unknown[]) => { + if (isDebug) { + console.debug(...args); + } +}; diff --git a/typescript/packages/common-propagator/src/mergeable.ts b/typescript/packages/common-propagator/src/mergeable.ts new file mode 100644 index 000000000..11389369c --- /dev/null +++ b/typescript/packages/common-propagator/src/mergeable.ts @@ -0,0 +1,23 @@ +/** 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 ( + typeof value === "object" && + 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-html/src/path.ts b/typescript/packages/common-propagator/src/path.ts similarity index 100% rename from typescript/packages/common-html/src/path.ts rename to typescript/packages/common-propagator/src/path.ts diff --git a/typescript/packages/common-html/src/propagator.ts b/typescript/packages/common-propagator/src/propagator.ts similarity index 90% rename from typescript/packages/common-html/src/propagator.ts rename to typescript/packages/common-propagator/src/propagator.ts index b886311ab..27f51c7d3 100644 --- a/typescript/packages/common-html/src/propagator.ts +++ b/typescript/packages/common-propagator/src/propagator.ts @@ -2,34 +2,8 @@ import { Cancel, Cancellable, useCancelGroup } from "./cancel.js"; import * as logger from "./logger.js"; import { Lens } from "./lens.js"; import cid from "./cid.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 ( - typeof value === "object" && - typeof value.merge === "function" && - value.merge.length === 1 - ); -}; - -/** - * Merge will merge prev and curr if they are mergeable, otherwise will - * return curr. - */ -const merge = (prev: T, curr: T): T => { - if (isMergeable(prev) && isMergeable(curr)) { - return prev.merge(curr); - } - return curr; -}; - -export type LamportTime = number; - -const advanceClock = (...times: LamportTime[]) => Math.max(...times) + 1; +import { merge } from "./mergeable.js"; +import { advanceClock, LamportTime } from "./lamport.js"; /** * A cell is a reactive value that can be updated and subscribed to. diff --git a/typescript/packages/common-html/src/reactive.ts b/typescript/packages/common-propagator/src/reactive.ts similarity index 76% rename from typescript/packages/common-html/src/reactive.ts rename to typescript/packages/common-propagator/src/reactive.ts index 51c1856c0..0209b4746 100644 --- a/typescript/packages/common-html/src/reactive.ts +++ b/typescript/packages/common-propagator/src/reactive.ts @@ -1,3 +1,4 @@ +import { isObject } from "./contract.js"; import { Cancel, isCancel } from "./cancel.js"; /** @@ -11,12 +12,26 @@ export type Reactive = { sink: (callback: (value: T) => void) => Cancel; }; -export type ReactiveValue = Reactive & { +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 isReactive = (value: unknown): value is Reactive => { - return typeof (value as Reactive)?.sink === "function"; +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 = ( 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-html/src/test/path.test.ts b/typescript/packages/common-propagator/src/test/path.test.ts similarity index 100% rename from typescript/packages/common-html/src/test/path.test.ts rename to typescript/packages/common-propagator/src/test/path.test.ts diff --git a/typescript/packages/common-html/src/test/propagator.test.ts b/typescript/packages/common-propagator/src/test/propagator.test.ts similarity index 100% rename from typescript/packages/common-html/src/test/propagator.test.ts rename to typescript/packages/common-propagator/src/test/propagator.test.ts diff --git a/typescript/packages/common-html/src/test/reactive.test.ts b/typescript/packages/common-propagator/src/test/reactive.test.ts similarity index 100% rename from typescript/packages/common-html/src/test/reactive.test.ts rename to typescript/packages/common-propagator/src/test/reactive.test.ts 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/**/*"] +} From ae7cada10d9420ae5c03cd5f3f333727607b6725 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 24 Jul 2024 20:39:30 -0400 Subject: [PATCH 29/32] JSON logging --- .../packages/common-propagator/src/logger.ts | 8 ++++---- .../packages/common-propagator/src/path.ts | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/typescript/packages/common-propagator/src/logger.ts b/typescript/packages/common-propagator/src/logger.ts index 6cc464340..207f06022 100644 --- a/typescript/packages/common-propagator/src/logger.ts +++ b/typescript/packages/common-propagator/src/logger.ts @@ -13,13 +13,13 @@ export const setDebug = (value: boolean) => { }; /** Log warning */ -export const warn = (...args: unknown[]) => { - console.warn(...args); +export const warn = (msg: unknown) => { + console.warn(msg); }; /** Log if debugging is on */ -export const debug = (...args: unknown[]) => { +export const debug = (msg: object) => { if (isDebug) { - console.debug(...args); + console.debug({ ...msg }); } }; diff --git a/typescript/packages/common-propagator/src/path.ts b/typescript/packages/common-propagator/src/path.ts index f7b7a8d45..1200e72d7 100644 --- a/typescript/packages/common-propagator/src/path.ts +++ b/typescript/packages/common-propagator/src/path.ts @@ -36,12 +36,24 @@ export const path = (parent: T, keyPath: Array): unknown => { const key = keyPath.shift()!; if (isKeyable(parent)) { const child = parent.key(key); - logger.debug("path: call .key()", parent, key, child); + 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("path: get prop", parent, key, child); + logger.debug({ + msg: "get prop", + fn: "path()", + parent, + key, + child, + }); return path(child, keyPath); }; From 669f3f686ad44c4ca2d78d7447f59e029b87a974 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 25 Jul 2024 10:10:57 -0400 Subject: [PATCH 30/32] Add browser test for binding deep pathed variables --- .../src/test-browser/render.test.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) 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 348597582..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,6 +1,9 @@ // import { equal as assertEqual } from "./assert.js"; import render from "../render.js"; import html from "../html.js"; +import view from "../view.js"; +import { cell } from "@commontools/common-propagator"; +import * as assert from "./assert.js"; describe("render", () => { it("renders", () => { @@ -11,6 +14,28 @@ describe("render", () => { `; const parent = document.createElement("div"); render(parent, renderable); - console.log(parent); + 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!"); }); }); From e5af4f915b82b9362939a806818e635e13825703 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 25 Jul 2024 10:22:09 -0400 Subject: [PATCH 31/32] Fix isMergeable for null case --- typescript/packages/common-propagator/src/mergeable.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/typescript/packages/common-propagator/src/mergeable.ts b/typescript/packages/common-propagator/src/mergeable.ts index 11389369c..0c41a7caa 100644 --- a/typescript/packages/common-propagator/src/mergeable.ts +++ b/typescript/packages/common-propagator/src/mergeable.ts @@ -1,3 +1,5 @@ +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; @@ -5,7 +7,8 @@ export interface Mergeable { export const isMergeable = (value: any): value is Mergeable => { return ( - typeof value === "object" && + isObject(value) && + "merge" in value && typeof value.merge === "function" && value.merge.length === 1 ); From 65561090ca05a2b8953472c4d8285b7aa26e331e Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 25 Jul 2024 10:22:32 -0400 Subject: [PATCH 32/32] Fix example --- typescript/packages/common-html/example/main.ts | 15 ++++++--------- typescript/packages/common-html/package.json | 2 +- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/typescript/packages/common-html/example/main.ts b/typescript/packages/common-html/example/main.ts index 641bdb311..24bccb69e 100644 --- a/typescript/packages/common-html/example/main.ts +++ b/typescript/packages/common-html/example/main.ts @@ -1,26 +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 = state({ text: "Hello, world!" }); -const inputEvents = stream(); +const inputState = cell({ text: "Hello, world!" }); +const inputEvents = cell(null); inputEvents.sink((event) => { - const target = event.target as HTMLInputElement | null; + 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()); diff --git a/typescript/packages/common-html/package.json b/typescript/packages/common-html/package.json index 7f8435cbf..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",