What if, instead of starting with HTML-like syntax and using script-blocks, we flipped the whole thing on it's head: start with JSX and use HTML blocks?
Let's walk through the examples in the Malina REPL.
Starting with "Hello World":
<script>
let name = 'world';
</script>
<h1>Hello {name.toUpperCase()}!</h1>
And porting this to TypeScript:
let name = 'world';
<html>
<h1>Hello {name.toUpperCase()}!</h1>
</html>
If you're used to "normal" JSX (React, etc.) this looks immediately weird - it's actually valid TypeScript, it just isn't meaningful without a compiler to pick up the <html>
JSX expression. The static coupling between the name
variable and the reference in the {name.toUpperCase()}
expression is there though.
Let's look at the "Binding" example:
<script>
let name = 'world';
let active = false;
</script>
Enter name: <input type="text" bind:value={name} />
Hello {name}!
<br/>
<input type="checkbox" bind:checked={active} /> {active}
And porting this to TypeScript:
let name = 'world';
let active = false;
<html>
Enter name: <input type="text" bind:value={name} />
Hello {name}!
<br/>
<input type="checkbox" bind:checked={active} /> {active}
</html>
Again, completely valid, and the static bindings are there. I've kept the bind:value
and bind:checked
syntax from Malina - these are valid JSX attribute-names, but of course the compiler has to do some magic, same as it does in Malina today.
On to the "Events" example:
<script>
let name = '';
let event = '';
const click = (e) => {
event = e.x + 'x' + e.y;
}
</script>
Enter word and press enter:
<input bind:value={name} on:keydown|enter={name=''} /> {name}
<br />
<button on:click={click($event)}>Click!</button>
<a href on:click|preventDefault={event=$element.textContent}>Click</a>
{event}
This one will look a little different in TypeScript:
import { preventDefault, onKeyDown } from "malina";
let name = '';
let event = '';
const click = (e) => {
event = e.x + 'x' + e.y;
}
<html>
Enter word and press enter:
<input bind:value={name} {...onKeyDown("enter", () => name='')} /> {name}
<br />
<button on:click={click}>Click!</button>
<a href on:click={preventDefault(e => event = e.target.textContent)}>Click</a>
{event}
</html>
Here, I introduced a couple of runtime helper-functions: preventDefault
augments an event-handler, and onKeyDown
generates a filtered keydown
event-listener.
I used JSX {...}
spread syntax for the onKeyDown
call, just to avoid something more verbose - alternatively, we could have used something like on:keydown={filterKey("enter", () => name='')}
, where filterKey
would just wrap the event-handler on a key filter. (I'm using the {...onKeyDown(...)}
pattern/function in the rest of these examples.)
Next, the "Class and Style" example:
<script>
let value;
</script>
<input type="checkbox" bind:checked={value} /> switch
<div class:blue={value} class:red={!value}>Linux</div>
<div class="{value?'red':'blue'}">MacOS</div>
<b style="color: {value?'green':'red'};">Windows</b>
<style>
.blue {background-color: cornflowerblue;}
.red {background-color: tomato;}
</style>
And the TypeScript version:
import { css } from "malina";
let value;
<html>
<input type="checkbox" bind:checked={value} /> switch
<div class:blue={value} class:red={!value}>Linux</div>
<div class={value?'red':'blue'}>MacOS</div>
<b style:color={value?'green':'red'}>Windows</b>
</html>
css`
.blue {background-color: cornflowerblue;}
.red {background-color: tomato;}
`
Here, I chose a template literal function - and, like the <html>
element, this is something the compiler has to pick up.
Because of the curly braces in CSS declarations, something like <style>...</style>
wouldn't work - and, in addition, there are already code editors/plugins that can syntax highlight CSS in template literal strings, as this is a popular choice for CSS-in-JS frameworks.
JSX has a bit of an advantage here - where Malina uses quoted string syntax like <div class="{value?'red':'blue'}">
, with JSX, we don't have the somewhat confusing "code in strings" syntax.
Now the "If-else Block Example":
<script>
let value;
</script>
<input type="checkbox" bind:checked={value} /> switch
<br/>
{#if value}
checkbox is on
{:else}
[else] block, switch checkbox
{/if}
And the TypeScript version:
let value;
<html>
<input type="checkbox" bind:checked={value} /> switch
<br/>
{value
? "checkbox is on"
: "[else] block, switch checkbox"}
</html>
Here, JSX loses out a bit - with expression support only, we need to use the arguably less-readable ternary operator.
The same goes for the "Each/Repeat Block Example":
<script>
let items = [{name: 'first task'}, {name: 'second task'}];
let name = '';
const add = () => {
items.push({name: name});
name = '';
};
const remove = i => {
items.splice(i, 1);
}
const reverse = (item) => {
item.name = item.name.split('').reverse().join('');
}
</script>
Enter text and press enter:
<input type="text" bind:value={name} on:keydown|enter={add()} />
<ul>
{#each items as item}
<li>
{$index}: {item.name}
<a href on:click|preventDefault={reverse(item)}>reverse</a>
<a href on:click|preventDefault={remove($index)}>remove</a>
</li>
{/each}
</ul>
Where the TypeScript version looks like this:
import { preventDefault, onKeyDown } from "malina";
let items = [{name: 'first task'}, {name: 'second task'}];
let name = '';
const add = () => {
items.push({name: name});
name = '';
};
const remove = i => {
items.splice(i, 1);
}
const reverse = (item) => {
item.name = item.name.split('').reverse().join('');
}
<html>
Enter text and press enter:
<input type="text" bind:value={name} {...onKeyDown("enter", add)} />
<ul>
{items.map((item, index) =>
<li>
{index}: {item.name}
<a href on:click={preventDefault(() => reverse(item))}>reverse</a>
<a href on:click={preventDefault(() => remove(index))}>remove</a>
</li>
)}
</ul>
</html>
So yeah, here we have to use items.map
with extra parens and curly braces.
On the up-side though, the magical $index
and $element
variables have been replaced by nice, explicit callback parameters, with type-safety and static coupling to the expressions that use them.
Moving on the "Directive 'use'" example:
<script>
let visible = false;
const setStyle = el => {
el.style.color = 'red';
}
let boldElement;
</script>
<input type="checkbox" bind:checked={visible} />
<span use={setStyle($element)}>set focus input on render</span>
<div>
{#if visible}
<input type="text" use={$element.focus()} />
{/if}
</div>
save element in <b use={boldElement=$element}>variable</b>
And the TypeScript version:
let visible = false;
const setStyle = el => {
el.style.color = 'red';
}
let boldElement;
<html>
<input type="checkbox" bind:checked={visible} />
<span use={setStyle}>set focus input on render</span>
<div>
{ visible && <input type="text" use={el => el.focus()} /> }
</div>
save element in <b use={el => boldElement = el}>variable</b>
</html>
This seems intuitive enough, and I might even suggest considering a rename from use
to ref
, as this works just like ref
in React and some other JSX based frameworks.
JSX again has a small advantage here, with Malina's <span use={setStyle($element)}>
being simplified as merely <span use={setStyle}>
. (It's actually not clear to me why this wouldn't work just the same in Malina? At the time of writing, it silenty failed to do anything.)
And finally, the "Todo app" example:
<script>
let name = '';
let todos = [{name: 'first task'}, {name: 'second task', done: true}];
let active;
function add() {
if(!name) return;
todos.push({name: name});
name = '';
}
const remove = i => todos.splice(i, 1);
const numDone = () => todos.filter(t => t.done).length;
</script>
{#if active}
Edit: <input type="text" on:keydown|enter={active=null} bind:value={active.name} use={$element.focus()} />
{:else}
<input type="text" on:keydown|enter={add()} bind:value={name} />
{/if}
<ul>
{#each todos as todo}
<li class:active={todo == active} class:inactive={todo.done}>
<input type="checkbox" bind:checked={todo.done} />
<span on:click={active=todo}>{$index}: {todo.name}</span>
<a href on:click|preventDefault={remove($index)}>remove</a>
</li>
{/each}
</ul>
Total done: {numDone()} of {todos.length}
<style>
li {cursor: pointer;}
.active {background-color: #cfc;}
.inactive {text-decoration-line: line-through; color: gray;}
</style>
And the TypeScript port:
import { css, onKeyDown, preventDefault } from "malina";
let name = '';
let todos = [{name: 'first task'}, {name: 'second task', done: true}];
let active;
function add() {
if(!name) return;
todos.push({name: name});
name = '';
}
const remove = i => todos.splice(i, 1);
const numDone = () => todos.filter(t => t.done).length;
<html>
{active
? <>Edit: <input type="text" {...onKeyDown("enter", () => active=null)} bind:value={active.name} use={el => el.focus()} /></>
: <input type="text" {...onKeyDown("enter", add)} bind:value={name} /> }
<ul>
{todos.map((todo, index) =>
<li class:active={todo == active} class:inactive={todo.done}>
<input type="checkbox" bind:checked={todo.done} />
<span on:click={() => active=todo}>{index}: {todo.name}</span>
<a href on:click={preventDefault(() => remove(index))}>remove</a>
</li>
)}
</ul>
Total done: {numDone()} of {todos.length}
</html>
css`
li {cursor: pointer;}
.active {background-color: #cfc;}
.inactive {text-decoration-line: line-through; color: gray;}
`
There's not much to say about this example, it just shows everything else put together. I didn't like having to add <>
and </>
around the "Edit" label and input, but this is JSX, so everything is an expression. I do like having the static coupling between todo
in the loop, and type-checking for todo.name
and todo.done
is definitely a plus.
There is much more ground to cover, of course - this is just a quick thought experiment, but seems like it might be an avenue worth exploring.
I wish this was happening.