web app experiment
This commit is contained in:
1
web/.gitignore
vendored
Normal file
1
web/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dist/main.js
|
||||
12
web/README.md
Normal file
12
web/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
|
||||
|
||||
# Web framework experiment
|
||||
|
||||
Work in progress of a DOM-patching web framework, like Elm.
|
||||
|
||||
In the root, you can do:
|
||||
|
||||
```sh
|
||||
newt src/Todo.newt -o dist/main.js
|
||||
esbuild dist/main.js --bundle --servedir=web --outdir=web --alias:fs=./dist/empty.js --watch
|
||||
```
|
||||
1
web/dist/empty.js
vendored
Normal file
1
web/dist/empty.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export default {}
|
||||
11
web/dist/index.html
vendored
Normal file
11
web/dist/index.html
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Counter</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="main"></div>
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
126
web/dist/spruce.ts
vendored
Normal file
126
web/dist/spruce.ts
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
// We're going to do the VDom thing, directly off of newt data
|
||||
|
||||
type Attrib<Msg> =
|
||||
| { tag: 0; h1: string; h2: string }
|
||||
| { tag: 1; h1: string; h2: Msg }
|
||||
| { tag: 2; h1: string; h2: (_: string) => Msg };
|
||||
|
||||
type List<A> = { tag: 0 } | { tag: 1; h1: A; h2: List<A> };
|
||||
|
||||
type VNode<Msg> =
|
||||
| { tag: 0; h1: string } // TNode
|
||||
| { tag: 1; h1: string; h2: List<Attrib<Msg>>; h3: List<VNode<Msg>> };
|
||||
|
||||
type FancyElement<Msg> = Element & { events?: Record<string, Attrib<Msg> > };
|
||||
|
||||
// the pfunc will have two dummy arguments in front, and I dunno no node?
|
||||
export function runapp<Model, Msg>(
|
||||
node: Node,
|
||||
init: Model,
|
||||
update: (_: Msg) => (_: Model) => Model,
|
||||
view: (_: Model) => VNode<Msg>,
|
||||
) {
|
||||
function replace(parent: Node, node: Node | undefined, child: Node) {
|
||||
if (node) {
|
||||
parent.insertBefore(node, child);
|
||||
parent.removeChild(node);
|
||||
} else {
|
||||
parent.appendChild(child);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
// patch node, possibly returning a new node
|
||||
function patch<Msg>(
|
||||
parentNode: Node,
|
||||
node: Node | undefined,
|
||||
vnode: VNode<Msg>,
|
||||
): Node {
|
||||
if (vnode.tag == 0) {
|
||||
if (node && node.nodeType == 3) {
|
||||
node.nodeValue = vnode.h1;
|
||||
return node;
|
||||
}
|
||||
return replace(parentNode, node, document.createTextNode(vnode.h1));
|
||||
}
|
||||
let el: FancyElement<Msg>;
|
||||
if (node instanceof Element && node.tagName.toLowerCase() == vnode.h1) {
|
||||
el = node;
|
||||
} else {
|
||||
el = document.createElement(vnode.h1);
|
||||
}
|
||||
// update node here
|
||||
let has: Record<string, boolean> = {};
|
||||
for (let attrs = vnode.h2; attrs.tag == 1; attrs = attrs.h2) {
|
||||
let attr = attrs.h1;
|
||||
if (attr.tag == 0) {
|
||||
let key = attr.h1;
|
||||
has[key] = true;
|
||||
if (key in el)
|
||||
(el as any)[key] = attr.h2
|
||||
else
|
||||
el.setAttribute(key, attr.h2);
|
||||
} else {
|
||||
// onBlah
|
||||
let key = attr.h1.slice(2).toLowerCase();
|
||||
has[key] = true;
|
||||
let events = el.events || (el.events = {});
|
||||
if (!events[key]) el.addEventListener(key, listener);
|
||||
events[key] = attr;
|
||||
}
|
||||
}
|
||||
// remove attrs, not efficient..
|
||||
for (let i = 0; i < el.attributes.length; ) {
|
||||
let attr = el.attributes[i];
|
||||
if (!has[attr.name]) {
|
||||
el.removeAttribute(attr.name);
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (el.events) {
|
||||
for (let key of Object.keys(el.events)) {
|
||||
if (!has[key]) delete el.events[key];
|
||||
}
|
||||
}
|
||||
let i = 0;
|
||||
for (let kids = vnode.h3; kids.tag == 1; kids = kids.h2) {
|
||||
patch(el, el.childNodes[i++], kids.h1);
|
||||
}
|
||||
while (el.childNodes[i]) el.removeChild(el.childNodes[i]);
|
||||
|
||||
return node == el ? node : replace(parentNode, node, el);
|
||||
}
|
||||
let model = init;
|
||||
let vdom = view(model);
|
||||
let listener = (ev: Event) => {
|
||||
let target = ev.target as FancyElement<Msg>;
|
||||
if (!target.events) return;
|
||||
const attr = target.events[ev.type]
|
||||
let action
|
||||
if (attr.tag === 2) {
|
||||
// probably need to pass back the event
|
||||
// we'll want to deal with keypress, change, ...
|
||||
let action = attr.h2(ev.target!.value)
|
||||
model = update(action)(model);
|
||||
} else if (attr.tag === 1) {
|
||||
model = update(attr.h2)(model);
|
||||
}
|
||||
vdom = view(model);
|
||||
node = patch(node.parentNode!, node, vdom);
|
||||
};
|
||||
node = patch(node.parentNode!, node, vdom);
|
||||
}
|
||||
[{
|
||||
"resource": "/Users/dunham/prj/newt/src/Web/spruce.ts",
|
||||
"owner": "typescript",
|
||||
"code": "2349",
|
||||
"severity": 8,
|
||||
"message": "This expression is not callable.\n Not all constituents of type '((_: string) => Msg) | (Msg & Function)' are callable.\n Type 'Msg & Function' has no call signatures.",
|
||||
"source": "ts",
|
||||
"startLineNumber": 101,
|
||||
"startColumn": 16,
|
||||
"endLineNumber": 101,
|
||||
"endColumn": 19,
|
||||
"modelVersionId": 289,
|
||||
"origin": "extHost1"
|
||||
}]
|
||||
13
web/dist/style.css
vendored
Normal file
13
web/dist/style.css
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
.item {
|
||||
display: flex;
|
||||
}
|
||||
.item > * {
|
||||
vertical-align: middle;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.item > div {
|
||||
display: inline;
|
||||
width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
1
web/src/Prelude.newt
Symbolic link
1
web/src/Prelude.newt
Symbolic link
@@ -0,0 +1 @@
|
||||
../../src/Prelude.newt
|
||||
31
web/src/Spruce.newt
Normal file
31
web/src/Spruce.newt
Normal file
@@ -0,0 +1,31 @@
|
||||
module Spruce
|
||||
|
||||
-- Spruce is not Elm
|
||||
|
||||
import Prelude
|
||||
|
||||
ptype Element
|
||||
pfunc getElementById : String → Element := `(id) => document.getElementById(id)`
|
||||
|
||||
-- Make this align with spruce.ts
|
||||
data Attr msg = SAttr String String | MAttr String msg | VAttr String (String → msg)
|
||||
data VNode msg = TNode String | ENode String (List $ Attr msg) (List $ VNode msg)
|
||||
|
||||
text : ∀ msg. String → VNode msg
|
||||
text = TNode
|
||||
|
||||
tag : ∀ msg. String → List (Attr msg)→ List (VNode msg) → VNode msg
|
||||
tag = ENode
|
||||
|
||||
tag_ : ∀ msg. String → List (VNode msg)→ VNode msg
|
||||
tag_ tag es = ENode tag Nil es
|
||||
|
||||
-- TODO better story for import..
|
||||
-- also, we'll probably want to expose something with subscriptions / dispatch / ...
|
||||
pfunc runApp : ∀ msg model. Element → model → (update : msg → model → model) → (view : model → VNode msg) → Unit :=
|
||||
`(_msg, _model, el, init, update, view) => {
|
||||
require('./spruce').runapp(el,init,update,view);
|
||||
return 0;
|
||||
}`
|
||||
|
||||
-- App
|
||||
84
web/src/Todo.newt
Normal file
84
web/src/Todo.newt
Normal file
@@ -0,0 +1,84 @@
|
||||
module Todo
|
||||
|
||||
import Prelude
|
||||
import Spruce
|
||||
|
||||
data Msg
|
||||
= Toggle Nat
|
||||
| Remove Nat
|
||||
| Change String
|
||||
|
||||
record Item where
|
||||
checked : Bool
|
||||
text : String
|
||||
|
||||
record Model where
|
||||
items : List Item
|
||||
edit : String
|
||||
|
||||
ElCon : U
|
||||
ElCon = List (Attr Msg) → List (VNode Msg) → VNode Msg
|
||||
|
||||
-- Attributes
|
||||
|
||||
onChange : Msg → Attr Msg
|
||||
onChange = MAttr "onChange"
|
||||
|
||||
onClick : Msg → Attr Msg
|
||||
onClick action = MAttr "OnClick" action
|
||||
|
||||
className : String → Attr Msg
|
||||
className v = SAttr "class" v
|
||||
|
||||
checkbox : Bool → Nat → VNode Msg
|
||||
checkbox checked ix =
|
||||
tag "input" [ SAttr "checked" (ite checked "true" ""), onChange (Toggle ix), SAttr "type" "checkbox" ] []
|
||||
|
||||
-- Elements
|
||||
|
||||
div : ElCon
|
||||
div = tag "div"
|
||||
|
||||
button : String → Msg → VNode Msg
|
||||
button label action = tag "button" [ onClick action ] [ text label ]
|
||||
|
||||
|
||||
itemView : Nat × Item → VNode Msg
|
||||
itemView (ix , item) =
|
||||
div [ className "item" ]
|
||||
[ checkbox item.checked ix
|
||||
, div [] [text item.text]
|
||||
, button "x" (Remove ix)
|
||||
]
|
||||
|
||||
view : Model → VNode Msg
|
||||
view model =
|
||||
div []
|
||||
[ div [] (map itemView $ enumerate model.items)
|
||||
, tag "input" [ SAttr "value" model.edit, VAttr "onChange" Change ] []
|
||||
]
|
||||
|
||||
update : Msg → Model → Model
|
||||
update (Toggle ix) model = { items $= toggle ix } model
|
||||
where
|
||||
toggleItem : Item → Item
|
||||
toggleItem item = { checked := not item.checked } item
|
||||
|
||||
toggle : Nat → List Item → List Item
|
||||
toggle _ Nil = Nil
|
||||
toggle Z (item :: rest) = toggleItem item :: rest
|
||||
toggle (S k) (item :: rest) = item :: toggle k rest
|
||||
|
||||
update (Remove ix) model = {items $= delete ix } model
|
||||
where
|
||||
delete : Nat → List Item → List Item
|
||||
delete _ Nil = Nil
|
||||
delete Z (item :: rest) = rest
|
||||
delete (S k) (item :: rest) = item :: delete k rest
|
||||
|
||||
update (Change text) model = { items := (snoc model.items $ MkItem False text) } model
|
||||
|
||||
main : IO Unit
|
||||
main = pure $ runApp (getElementById "main") (MkModel [] "") update view
|
||||
|
||||
|
||||
Reference in New Issue
Block a user