Skip to content

Commit c741a2e

Browse files
committed
Rough out html tagged template literal
1 parent e7a3121 commit c741a2e

File tree

6 files changed

+150
-9
lines changed

6 files changed

+150
-9
lines changed

typescript/packages/common-html/src/hole.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ export const isHole = (value: unknown): value is Hole => {
1717
(value as Hole).type === "hole"
1818
);
1919
};
20+
21+
export const markup = (name: string) => `{{${name}}}`
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import parse from "./parser.js";
2+
import { Node, isNode } from "./node.js";
3+
import * as hole from "./hole.js";
4+
5+
export const html = (
6+
strings: TemplateStringsArray,
7+
...values: Named[]
8+
): Renderable => {
9+
const templateMarkup = flattenTemplateStrings(strings, values);
10+
const root = parse(templateMarkup);
11+
12+
if (root.children.length !== 1) {
13+
throw TypeError("Template have one root node");
14+
}
15+
16+
const template = root.children[0];
17+
18+
if (!isNode(template)) {
19+
throw TypeError("Template root must be an element");
20+
}
21+
22+
const context = Object.freeze(indexContext(values));
23+
24+
return Object.freeze({
25+
type: "renderable",
26+
template,
27+
context,
28+
})
29+
};
30+
31+
export default html;
32+
33+
export type Renderable = {
34+
type: "renderable";
35+
template: Node;
36+
context: Context;
37+
};
38+
39+
export type Context = { [key: string]: Named };
40+
41+
export type Named = {
42+
name: string;
43+
};
44+
45+
const indexContext = (items: Named[]): Context => {
46+
return Object.fromEntries(items.map((item) => [item.name, item]));
47+
}
48+
49+
const flattenTemplateStrings = (
50+
strings: TemplateStringsArray,
51+
values: Named[]
52+
): string => {
53+
return strings.reduce((result, string, i) => {
54+
const value = values[i];
55+
return result + string + (value ? hole.markup(value.name) : "");
56+
}, "");
57+
}

typescript/packages/common-html/src/node.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Hole } from "./hole.js";
22

33
export type Node = {
4-
type: "node",
4+
type: "node";
55
tag: string;
66
attrs: Attrs;
77
children: Children;
@@ -20,4 +20,14 @@ export const create = (
2020
tag,
2121
attrs,
2222
children,
23-
});
23+
});
24+
25+
export const isNode = (value: unknown): value is Node => {
26+
return (value as Node)?.type === "node";
27+
};
28+
29+
export const freezeNode = (node: Node): Node => {
30+
Object.freeze(node.attrs);
31+
Object.freeze(node.children);
32+
return Object.freeze(node);
33+
};

typescript/packages/common-html/src/parser.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import sax from "sax";
22
import parseMustaches from "./stache.js";
33
import { isHole } from "./hole.js";
4-
import { create as createNode, Node, Attrs } from "./node.js";
4+
import { create as createNode, freezeNode, Node, Attrs } from "./node.js";
55

6-
/** Parse a template into a simple object representation */
7-
export const parse = (xml: string): Node => {
6+
/** Parse a template into a simple JSON markup representation */
7+
export const parse = (markup: string): Node => {
88
const strict = false;
99
const parser = sax.parser(strict, {
1010
trim: true,
@@ -32,8 +32,12 @@ export const parse = (xml: string): Node => {
3232
stack.push(next);
3333
};
3434

35-
parser.onclosetag = (_tagName) => {
36-
stack.pop();
35+
parser.onclosetag = (tagName) => {
36+
const node = stack.pop();
37+
if (!node) {
38+
throw new ParseError(`Unexpected closing tag ${tagName}`);
39+
}
40+
freezeNode(node);
3741
};
3842

3943
parser.ontext = (text) => {
@@ -42,13 +46,13 @@ export const parse = (xml: string): Node => {
4246
top.children.push(...parsed);
4347
};
4448

45-
parser.write(xml).close();
49+
parser.write(markup).close();
4650

4751
if (getTop(stack) !== root) {
4852
throw new ParseError(`Unexpected root node ${root.tag}`);
4953
}
5054

51-
return root;
55+
return freezeNode(root);
5256
};
5357

5458
export default parse;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/** A simple reactive state cell without any scheduling */
2+
export const state = <T>(name: string, value: T) => {
3+
let state = value;
4+
const listeners = new Set<(value: T) => void>();
5+
6+
const get = () => state;
7+
8+
const sink = (callback: (value: T) => void) => {
9+
listeners.add(callback);
10+
callback(state);
11+
return () => {
12+
listeners.delete(callback);
13+
};
14+
}
15+
16+
const send = (value: T) => {
17+
state = value;
18+
for (const listener of listeners) {
19+
listener(state);
20+
}
21+
}
22+
23+
return {
24+
get name() {
25+
return name;
26+
},
27+
get,
28+
sink,
29+
send
30+
};
31+
};
32+
33+
export default state;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { deepStrictEqual } from "node:assert";
2+
import html from "../html.js";
3+
import * as node from "../node.js";
4+
import * as hole from "../hole.js";
5+
import state from "../state.js";
6+
7+
describe("html", () => {
8+
it("parses tagged template string into a Renderable", () => {
9+
const clicks = state<Event | null>('clicks', null);
10+
const text = state('text', 'Hello world!');
11+
12+
const renderable = html`
13+
<div class="container" hidden={{hidden}}>
14+
<button id="foo" onclick=${clicks}>${text}</button>
15+
</div>
16+
`;
17+
18+
deepStrictEqual(
19+
renderable.template,
20+
node.create(
21+
"div",
22+
{ "class": "container", hidden: hole.create("hidden") },
23+
[
24+
node.create(
25+
"button",
26+
{ id: "foo", onclick: hole.create("clicks") },
27+
[
28+
hole.create("text"),
29+
]
30+
),
31+
],
32+
)
33+
);
34+
});
35+
});

0 commit comments

Comments
 (0)