web app experiment

This commit is contained in:
2026-04-01 22:04:15 -07:00
parent 52c78aa6f6
commit ae7fb5da6f
9 changed files with 280 additions and 0 deletions

1
web/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist/main.js

12
web/README.md Normal file
View 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
View File

@@ -0,0 +1 @@
export default {}

11
web/dist/index.html vendored Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
../../src/Prelude.newt

31
web/src/Spruce.newt Normal file
View 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
View 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