ENTRY
[ESC]I'm building an application framework in zig that takes in a custom hypermedia format (similar to html).
The first central idea is to keep it incredibly basic, the fundamental building block is user-defined handlers. These are native zig functions that can be called on an event from hypermedia.
Here's an example of the calling convention I came up with:
<!-- this button will call 'my_function' with a url argument and a count variable on click -->
<button onclick="my_function"
onclick:my_function:url="https://example.com"
onclick:my_function:count="button_data.count">
Click me
</button>
From here, it might already be easy to see how basic interactivity can be achieved.
<data id="app_state">
<field type="i32" name="count" value="0" />
</data>
<button onclick="increment"
onclick:increment:target="app_state.count">
Click me
</button>
Here <data/> is a special tag used for storing application state.
back to the calling convention, calling multiple functions is easy if they have different names
<button onclick="my_function my_second_function"
onclick:my_function:url="https://example.com"
onclick:my_function:count="button_data.count"
onclick:my_second_function:url="https://example.com"
onclick:my_second_function:count="button_data.count">
Click me
</button>
For the case where functions don't have different names, like applying increment twice or on two different targets, we have to get a little more creative.
The concept I came up with is function aliases.
<button onclick="task_one task_two"
onclick:task_one="my_function"
onclick:task_one:url="https://example.com"
onclick:task_one:count="button_data.count"
onclick:task_two="my_function"
onclick:task_two:url="https://example.com"
onclick:task_two:count="button_data.count">
Click me
</button>
task_one and task_two are just strings, they only mean something in the context of these two function calls. I agree this is a little convoluted, and I don't think a serious application would make use of this feature much, but it's good to have for completeness sake.
Templating
<template/> tags can be used for client-side rendering, and are essential to have. I personally don't like how limited they are in HTML, so I decided to use a full featured templating engine.
I personally like jinja2-like templating languages.
<template id="my_template"><![CDATA[
{% if count > 5 %}
<text>Hell yeah...</text>
{% else %}
<text>Oh no..., it's {{ count }}</text>
{% endif %}
]]></template>
I decided to use CDATA because it allows my spec to stay a valid xml 1.0 document, which can simplify writing a parser.
Rendering a template can be done in two ways, using a <render/> tag, or using some render function. Both will require the presence of a <data/> tag.
<!-- this onchange handler will make sure the template
rerenders whenever it's backing data changes -->
<data id="some_data"
onchange="render"
onchange:render:template="my_template"
onchange:render:target="content"
onchange:render:data="some_data">
<field type="i32" name="count" value="5" />
</data>
<!-- this element will get replaced with
<text>Hell yeah...</text> as soon as it loads in.
That handles the initial render. -->
<view id="content">
<render template="my_template" data="some_data" />
</view>
<button onclick="increment"
onclick:increment:target="app_state.count">
Click me to increment
</button>
<button onclick="decrement"
onclick:decrement:target="app_state.count">
Click me to decrement
</button>
Basic components
Before we can build the infamous counter component, we'll need to get rid of the need to refer to exact ids in our handler arguments. I've decided to solve this by defining two keywords
- 'self'. when this is used as an argument to a function like
render, it'll always refer to the element on which the event occurring. We could for example replaceonchange:render:data="some_data"withonchange:render:data="self" - 'component' will refer to the closest component instance. This will be useful later.
Let's start with some definition of a component.
A component is a reusable definition that can be instantiated.
An instance of a component
- Manages it's own state using
<data/>tags. - re-renders it's own content when necessary
- accepts properties in the form of attributes
Properties are the easiest to define, take the following component definition
<component id="counter">
<component-properties>
<property name="initial_count" type="i32" />
</component-properties>
</component>
This component can be instantiated with the <counter initial_count="1" /> tag.
That <counter /> tag can then be expanded into this when it's loaded in by the application framework:
<counter>
<data name="properties">
<field name="initial_count" type="i32" value="1" />
</data>
</counter>
and that's all we really need for properties. What about state derived from properties.
for simple derived state, we can make use of an init attribute on the current <field/> tag.
This init attribute would instruct the application framework to look into the DOM at the supplied path, and copy over the current value from there.
<component id="counter">
<component-properties>
<property name="initial_count" type="i32" />
</component-properties>
<!-- 'component-data' turns into a regular 'data' tag when instantiated
Before the component is instantiated none of the contents of this
tag are initialized. The `init` attribute only runs after instantiation. -->
<component-data>
<field name="count" type="i32" init="component.properties.initial_count" />
</component-data>
</component>
That expands to the following on initialization
<counter>
<data name="properties">
<field name="initial_count" type="i32" value="1" />
</data>
<data>
<!-- when this field got loaded in, "component.properties.initial_count"
meant the field above, causing the 'count' field to instantiate with "1" -->
<field name="count" type="i32" value="1" />
</data>
</counter>
More complex derived state would require specialized functions
<component id="counter">
<component-properties>
<property name="initial_count" type="i32" />
</component-properties>
<component-data oninit="counter_derive_state"></component-data>
</component>
counter_derive_state would be a function that does the following
- look at
component.propertiesfor my expected inputs - calculate complex derived state
- append
<field/>tags containing derived state toself(the<data/>tag)
Now let's tackle rendering
<component id="counter">
<component-properties>
<property name="initial_count" type="i32" />
</component-properties>
<component-data name="counter_state"
onchange="render"
onchange:render:target="component.root"
onchange:render:template="counter.main_template"
onchange:render:data="self">
<field name="count" type="i32" init="component.properties.initial_count" />
</component-data>
<!-- this `component-template` tag is identical to the previously discussed
`template` tag, except that on instantiation it doesnt get carried over. -->
<!-- the rest of the component definition refers to it through the
absolute "counter.main_template" path instead of the relative
"component.main_template" path. -->
<component-template name="main_template"><![CDATA[
<button onclick="increment"
onclick:increment:target="component.counter_state.count">
Count {{ count }}
</button>
]]></component-template>
<!-- component-root turns into <view name="root"></view> on instantiation
Before instantiation, it's children dont get initialized. -->
<component-root>
<!-- this handles the initial render -->
<render template="counter.main_template" data="component.counter_state" />
</component-root>
</component>
And that's it, a fully re-usable declarative component.
It'll expand to the following
<counter>
<data name="properties">
<field name="initial_count" type="i32" value="1" />
</data>
<data name="counter_state"
onchange="render"
onchange:render:target="component.root"
onchange:render:template="counter.main_template"
onchange:render:data="self">
<field name="count" type="i32" value="1" />
</data>
<!-- the template only has to exist in the counter component definition, it's static -->
<view name="root">
<button onclick="increment"
onclick:increment:target="component.counter_state.count">
Count 1
</button>
</view>
</counter>
Log in to read the replies and join the conversation