I wanted to learn how to write lexers, parsers, interpreters and compilers. So I wrote everything from scratch. No libraries, no parser generators, and especially no AI. I didn't want to just have a new template language, I wanted to understand how it functions and how all the different parts come together to go from a plain text file to a working program.
The main work I am doing day to day is writing frontend code. For the past few years, my professional roles consisted of building up reusable custom component libraries for companies to use as their design system.
I can write backend code and some jobs also had me do that on a regular basis, but in my heart, I always was and always will be a frontend developer.
So I have worked with a lot of template languages in my past, but I was never really satisfied by one.
With some of them its really difficult and sometimes even impossible to express any kind of logic for transforming data.
Others don't run in the browser, making you round trip to the server, just to dynamically render or update templates.
A lot of them don't let you compose different components in a way that does not lead to heaps of spaghetti code long term.
And pretty much all of them put the template text in the forefront, making it difficult to see the data requirements for a component at a glance.
For the last point you could say: "Well, its a template language. It should put the template in front", and I would agree to a certain extend. I just think, that the data requirements are at least equally, if not sometimes more important to a template. Yet, they do not get the same real estate.
So how does my template language fix these problems? Let's look at a few examples.
Separation of data and template
component Link {
let class? = #String;
let text = #String;
let href = #String;
let newTab = #Boolean;
let target = if (newTab) "_blank" else "_self";
<a
class="Link $(class)"
href={href}
target={target}
>
{text}
</a>
}This first example is a pretty boring link component. But at a glance, you can see a few things:
- 1.
It declares four different data requirements:
"class", "text", "href" and "newTab" - 2.
You can immediately see, which data type the different attributes should have.
- 3.
The class is optional, indicated by the ? behind its name.
- 4.
A new local variable "target" is computed based on the "newTab" input.
- 5.
The template is separated from the data and does not have any meaningful logic inside.
This component could now be used in other templates like this:
<Link
text="Go somewhere else"
href="https://www.somewhere-else.com"
newTab={true}
/>Logic and data transformation
We already do a little bit of data transformation in the above example, but thats nothing you couldn't do in any other template language.
Let's look at a component where you want to put in a semicolon separated string of tags and get them out uppercased as an unordered list. (You could of course also have your input already be an array of strings, but for the sake of the example, we assume we can only get a string.)
component TagList {
let class? = #String;
let tags = #String;
let tags = tags
|> Base_String.split(";")
|> Base_Array.map(fn (tag) -> {
let tag = tag
|> Base_String.trim
|> Base_String.uppercase_ascii;
<li class="TagList-tag">{tag}</li>
});
<ul class="TagList $(class)">
{tags}
</ul>
}Let's go through what happens here.
- 1.
We declare two data requirements: class and tags. Nothing new here.
- 2.
We declare a new variable, also called tags, which transforms and shadows1 the input string.
- 3.
The input string gets split on ";" and the resulting array is mapped over to transform each element in the array.
To do this, we create an anonymous function, which takes in a string, trims the whitespace at start and end, transforms it to uppercase characters and puts the result in a <li> tag. - 4.
The result is an array of <li> tags, which gets wrapped in a <ul>.
So there are functions in my template language and they can do anything you would expect them to do from a normal programming language. They don't even have to return a template.
In fact: The standard library, where "split", "map", "trim" and "uppercase_ascii" in the above example come from, is itself written in my template language. And you could write your own library of useful functions to your project:
library Helpers {
let split_and_uppercase_string = fn (str, delimiter) -> {
str
|> Base_String.split(";")
|> Base_Array.map(fn (segment) -> {
segment
|> Base_String.trim
|> Base_String.uppercase_ascii
})
};
}Which you could use to simplify the above example to this:
component TagList {
let class? = #String;
let tags = #String;
let tags = Helpers.split_and_uppercase_string(tags, ";");
<ul class="TagList $(class)">
{for (tag in tags) {
<li class="TagList-tag">{tag}</li>
}}
</ul>
}Composing components
I have already shown you, how to statically use components in other templates. But oftentimes you want to allow a set of components to be placed in another component.
Think of a media-text component, where you have text and up to two button or link components in any order on one side, and an image or video on the other.
I would argue, that this would be quite cumbersome to achieve in a lot of template languages.
Let's see how you would do that in my template language:
component MediaText {
let text = #String;
let links? = #Slot(
max: 2,
constraints: [Link, Button],
);
let media = #Slot(
min: 1,
max: 1,
constraints: [Image, Video],
);
<section class="MediaText">
<div class="MediaText-content">
{text}
{links}
</div>
<div class="MediaText-media">
{media}
</div>
</section>
}And thats it! You have slots with constraints. They are of course inspired by the slots found in web-components. The constraints of slots can also be inverted to allow anything but a specific component.
The MediaText component could now be used like this:
<MediaText text="Some plain text">
<Image slot="media" src="..." alt="..." />
<Button slot="links" text="Click me!" />
<Link slot="links" text="More info..." href="..." />
</MediaText>Now our text is of course pretty plain. In a real component we would probably want to allow richtext to be placed inside our MediaText component. So let's do that:
component MediaText {
let text = #Slot(key: "");
// ...
<section class="MediaText">
<div class="MediaText-content">
{text}
{links}
</div>
<div class="MediaText-media">
{media}
</div>
</section>
}A slot with an empty key is the default slot, where everything thats not assigned to a specific slot is placed into.
There is currently no way to restrict a slot to only a specific set of html tags, but there is no technical constraint preventing anything like that to be put into the language in the future.
Running in the browser
My template language is written in OCaml, so the interpreter transforming components to html runs as a native binary. The advantage of that is not only speed, but that the compiler can be used with any programming language that can call a cli. You just need to write a small wrapper in your favorite programming language running a cli command and returning the output.
The Browser of course is a different story. It can't run a cli command. But OCaml has a few tools23 that can compile OCaml code to JavaScript or WebAssembly.
So the interpreter for my template language is also compiled to JavaScript, letting you evaluate templates in the browser.
Each component defined in my template language currently compiles to its own function, receiving data as an input and returning a component that can be rendered to string or placed in a slot.
import render from '@pinc-official/pincjs';
import MediaText from './compiled/MediaText.pi.mjs';
import Image from './compiled/Image.pi.mjs';
import Button from './compiled/Button.pi.mjs';
const component = MediaText({
text: 'String',
links: [
Button({ text: "Click me!" }),
],
media: [
Image({ src: "...", alt: "..." }),
],
});
document.body.innerHTML = render(component);
The JavaScript compilation is the topic I am currently working on. So this is very much work in progress and does not support every feature yet. The render function is currently also pretty big (~188kb), which I want to reduce a lot.
Whats next?
The features outlined in this text are actually just a small fraction of all the features available in my template language. There are also portals, context, fragments, block-expressions, conditional data requirements, a formatter for your code, and a lot more already in place. I may be writing about these in the future.
As I said, I am currently mainly working on reducing the JavaScript runtime to a reasonable size. This will be done by compiling the templates itself to bytecode, which will reduce the size of the interpreter a lot.
However, the biggest goal on my roadmap is a static type checker / type inference for the language. You are already declaring the type of your data in the template, but the validation of the types and constraints currently happen at runtime. This leads to errors being thrown only when the template is rendered. I want this to be caught while compiling the templates to bytecode, so errors get caught before they go to production.
Thats all I have to say for now. If you want, you can write me on BlueSky or follow me on GitHub (though I am not sure for how long I will be on GitHub still).
Disclaimer:
There is currently no documentation or any info about the language. This post is the first thing I have put out to the public. This is intentional, as the language is not yet production ready and should not be used for any project you are not ok with completely rewriting in the future. You can play around with it, but please do not use it for anything even a little important.
Unimportant side note: The language is called pinc and you can look at it on GitHub (https://github.com/pinc-official/pinc-lang).