Making my own custom site generator using Jai metaprogramming magic
2026-7-25
I created my previous portfolio website using Astro, but when it came time to add new stuff to it, npm refused to run the build command because after downloading 623 dependencies, it found security risks in some of the packages. The next problem was that the project was using Astro 4. It started on Astro 3 and I upgraded it to v4 painfully. But now the most recent version is v7.1!
After all this frustration, being inspired by gingerBill's video, I decided to reinvent the wheel and make my own static site generator (SSG). Except that instead of having my code copy and paste the header/footer by hand, I thought it would be fun to try to make a templating system using Jai's powerful metaprogramming features. The approach I chose was inspired by the work presented in Tsoding's livestream where he does exactly that. I then pushed the concept further and also added a handmade Markdown parser.
In this post, I will present how I made this Jai templating system, and then present my Markdown parser.
The Jai templating system
So what do I mean by that? Well, I wanted something where I could reuse some HTML and put some logic inside of it to, for example, display a list of blog posts. My goal was to make something that would specifically work for my needs, and not something hyper robust and generalized. I decided to call it Jaitml and it looks like this:
// index.jaitml
<?jai include("header.jaitml"); ?>
<main>
<p>Hi! My name is Fabian Huber. I am a Game & Graphics Programmer.</p>
<ul>
<?jai for post: posts { ?>
<li><?jai echo("%", post.title); ?></li>
<?jai } ?>
</ul>
</main>
<?jai include("footer.jaitml"); ?>As you can see, it allows me to write some Jai code inside of HTML to copy the content of other files and basically write any logic I might want when I generate my website.
This is done in a generator.jai file that handles this. Since it relies on the compiler to run this code, I decided to do everything at compile-time.
Something that is easy to do in Jai, using the #run directive where I call set_build_options_dc({do_output = false}); to tell the compiler to not create an executable.
I then have a render_jaitml :: (path: string) -> string procedure that takes my .jaitml file and converts it to Jai code. A simple file like this:
<?jai include("header.jaitml"); ?>
<main>
<p>Hi.</p>
</main>
<?jai include("footer.jaitml"); ?>Would be converted to :
include("header.jaitml");
echo("<main>\n<p>Hi.</p>\n</main>\n");
include("footer.jaitml");Then to execute this code, I need to get the code string as a constant, meaning that it has to go through another #run directive (so this is at compile-compile-time).
It can then be #insert-ed.
code :: #run render_jaitml(path);
#insert,scope() code;The templating system mostly works with two macros: include and echo. These are declared just above the call to render_jaitml like so:
jaitml_string_builder: String_Builder;
echo :: (format_string: string, args: ..Any) #expand {
print_to_builder(*jaitml_string_builder, format_string, ..args);
}
include :: (include_path: string) #expand {
sub_file_path_string :: #run -> string {
sub_file_path := parse_path(path);
sub_file_path.words[sub_file_path.words.count - 1] = include_path;
return path_to_string(sub_file_path);
}
sub_code :: #run render_jaitml(sub_file_path_string);
#insert,scope() sub_code;
}At the end, jaitml_string_builder contains the final HTML code and is written to an output file.
Now, to read the posts, I simply declared a global constant posts variable that is filled up in a #run block (more on this later).
To generate one page per post, it was a bit more tricky.
First, when generating the page, I needed to know which post to generate it for.
This required me to add a $extra_code: Code block parameter to my macro where I could pass the post index like that:
execute_jaitml("src/post.html.jaitml", tprint("%posts/slug/index.html", BUILD_DIR), #code { POST_INDEX :: index; });However, to make this work, I had to put the code that calls render_jaitml in a #code block too,
merge them into one, and insert that because it is not possible to access the variables from one block in another.
My final execute_jaitml procedure looks like this:
execute_jaitml :: (
$path: string,
output_path: string,
$extra_code: Code = #code {}
) #expand {
jaitml_string_builder: String_Builder;
echo :: (format_string: string, args: ..Any) #expand {
print_to_builder(*jaitml_string_builder, format_string, ..args);
}
include :: (include_path: string) #expand {
sub_file_path_string :: #run -> string {
sub_file_path := parse_path(path);
sub_file_path.words[sub_file_path.words.count - 1] = include_path;
return path_to_string(sub_file_path);
}
sub_code :: #run render_jaitml(sub_file_path_string);
#insert,scope() sub_code;
}
#insert,scope() -> Code {
statements_to_insert: [..] *Code_Node;
extra_code_nodes := compiler_get_nodes(extra_code);
if extra_code_nodes.kind == .BLOCK {
block := extra_code_nodes.(*Code_Block);
array_add(*statements_to_insert, ..block.statements);
}
file_code_nodes := compiler_get_nodes(#code {
code :: #run render_jaitml(path);
#insert,scope() code;
write_entire_file(output_path, *jaitml_string_builder);
});
array_add(*statements_to_insert, file_code_nodes);
block_to_insert := New(Code_Block);
block_to_insert.block_type = .IMPERATIVE;
block_to_insert.statements = statements_to_insert;
return compiler_get_code(block_to_insert);
}
}One other problem is that any file included through include is also not able to read this information.
This was a problem when setting the title of the page since it had to be set in the header.
To fix that, I declared a global title := ""; variable that I change before calling execute_jaitml.
This means I could also do that for the post indices, and get rid of the complication with the $extra_code,
but I am not sure of an ideal solution for now, as I'd like to limit global data as much as possible.
Then, another problem appeared when it was time to iterate over the posts array and generate the pages.
This is because execute_jaitml needs to be executed with the #run, so all the parameters must be constants.
The problem is that when you iterate over a constant array, the iterator is not itself constant.
To go around that, I used some more metaprogramming magic and generated the code that would result from an unrolled loop:
#insert -> string {
sb: String_Builder;
for posts {
print_to_builder(*sb, "title = \"% - Fabian Huber\";", it.title);
print_to_builder(*sb, "execute_jaitml(\"src/post.html.jaitml\", tprint(\"\%posts/%/index.html\", BUILD_DIR), #code { POST_INDEX :: %; });", it.slug, it_index);
}
return builder_to_string(*sb);
};And with that, the template system is complete!
Markdown parsing
Now to parse Markdown, I needed a way to first get an abstract syntax tree (AST) that would then be used to generate an HTML output. Each node stores pointers to other nodes to be able to make the tree and some data that is stored in a discriminated union. There are two kinds of node types, block types (headings, paragraphs, etc.) and inline types (text, strong, etc.).
Node_Kind :: enum u8 {
Document;
// Blocks
Heading;
Paragraph;
// Inline
Text;
Strong;
// ...
}
Node_Data :: union kind: Node_Kind {
level: u8;
content: string;
}
Ast_Node :: struct {
parent: *Ast_Node;
first_child: *Ast_Node;
last_child: *Ast_Node;
next_sibling: *Ast_Node;
using data: Node_Data;
}There isn't a lot in Node_Data because more is not needed. Just level is used for headings, then content for everything else.
For example, images have an Image node that stores the url in content, then a child Text node that stores the alt.
For the document kind, content stores the frontmatter, and it's the responsibility of the code using the library to parse this.
Since this program only runs shortly and terminates, I never manually free any memory and let the OS take care of that.
However, I still want allocations to be fast.
Since I will be allocating a lot of small nodes, I decided to use the arena allocator provided by Jai called Flat_Pool.
It works by using the virtual address space of the machine.
It reserves a big chunk of memory (256MB by default) in the address space that gets committed each time the currently committed memory runs out.
The node allocation code looks like this:
#import "Flat_Pool";
pool: Flat_Pool;
a: Allocator;
// a is set in another procedure
// a.proc = flat_pool_allocator_proc;
// a.data = *pool;
make_node :: (kind: Node_Kind, parent: *Ast_Node = null) -> *Ast_Node {
node := New(Ast_Node,, a);
node.kind = kind;
if parent then add_child(parent, node);
return node;
}The parsing is done in two phases:
- Block parsing: Iterates over the Markdown document to extract the block nodes.
- Inline parsing: Iterates over text nodes and breaks them up to add things like images, bold text, etc.
Parsing is done using an ad-hoc line parser, so the code is not the prettiest, but most importantly it is not CommonMark compliant. My goal was to only implement the strict minimum to make my current Markdown documents work. The only thing I had to change was the frontmatter and tables, which I transformed into HTML ones. If I ever need more in the future, I can still add it later.
To emit HTML, I iterate over the tree in a depth-first manner using a stackless tree traversal and print the tags in a String_Builder.
emit_html :: (root: *Ast_Node) -> string {
if !root return "";
sb: String_Builder;
current := root;
going_down := true;
while current {
if going_down {
print_open_tag(*sb, current);
if current.first_child {
current = current.first_child;
continue;
}
}
print_close_tag(*sb, current);
if current.next_sibling {
current = current.next_sibling;
going_down = true;
} else {
current = current.parent;
going_down = false;
}
}
return builder_to_string(*sb);
}I also simplified the frontmatter of my documents to make it easier to parse than YAML.
Each line has one key-value pair that is separated by : .
Finally, I can create the posts array by getting every file in a folder, parsing them, and then sorting the array to have them chronologically ordered.
posts :: #run -> [] Post {
posts_arr: [..] Post;
visit_files("src/posts", false, *posts_arr, (info: *File_Visit_Info, posts_arr: *[..] Post) {
if info.is_directory then return;
ext, ok := path_extension(info.short_name);
if !ok then return;
if ext != "md" then return;
log("Parsing %", info.short_name);
markdown_file_contents := read_entire_file(info.full_name);
node := Markdown.parse(markdown_file_contents);
html := Markdown.emit_html(node);
post := parse_post_frontmatter(node.content);
post.html = html;
make_directory_if_it_does_not_exist(tprint("%posts/%", BUILD_DIR, post.slug));
array_add(posts_arr, post);
});
sorted := quick_sort(posts_arr, (post_a: Post, post_b: Post) -> int {
time_a := calendar_to_apollo(post_a.pub_date_time);
time_b := calendar_to_apollo(post_b.pub_date_time);
return compare_apollo_times(time_b, time_a);
});
return sorted;
}Conclusion
If you are reading this, it means that it worked! Now since everything runs at compile time, meaning that it is "interpreted", you might wonder how performant it is. Well, on my AMD Ryzen 9 7900X3D, the entire execution of the program (measured using Nushell's timeit) is ~685ms. Most of the time is spent parsing the Markdown, which I'm sure would be wayyy faster if compiled and optimized, but I think this is good enough for me and being able to write my HTML templates using Jai is fun enough to accept this tradeoff. I hope I can find a satisfying solution for the global state / per page state problem, but for now the current solution works.
If you're curious, you can read the code of this website on GitHub.
Join the conversation by replying on Bluesky.