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`
-
+
`;
- 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 =