For a variety of reasons, text-based filter systems like Terra can be really annoying since HTML is not a string, it is structured data.
If you are familiar with jsx (or web development with clojure/a lisp), having easy access to the structure of the data is really great. I’ve messed around a little bit with embedding janet inside zig for this purpose, and I found it to be quite fun and flexible with minimal work.
I’ve also used some fun comptime shenanigans to generate xml directly from normal zig structs:
// Self-contained zig file which generates SVG diagrams explaining isometric
// perspective.
// USAGE:
// zig run isometric.zig -- output_file.svg
// LICENSE: MPL2.0
const std = @import("std");
const Io = std.Io;
const indent_space_count = 2;
const viewbox = [4]f32{ -75.0, -75.0, 150.0, 150.0 };
const grid = .{
.line_count = 8,
.extents = .{ .min = -50.0, .max = 50.0 },
};
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator);
const allocator = arena.allocator();
const file_path = parse_args: {
var args = std.process.args();
// First arg is path to executable
std.debug.assert(args.skip());
break :parse_args args.next() orelse {
std.log.err("Must provide an output file name", .{});
std.process.exit(1);
};
};
std.debug.assert(file_path.len > 0);
const file = try std.fs.cwd().createFile(file_path, .{});
var file_buffer: [4096]u8 = .{0} ** 4096;
var file_writer = file.writer(&file_buffer);
defer file_writer.interface.flush() catch {};
_ = try file_writer.interface.write(
\\<?xml version="1.0" encoding="UTF-8" standalone="no"?>
\\<!-- isometric perspective -->
);
var svg: Xml = .{ .writer = &file_writer.interface };
try svg.open("svg", .{
.xmlns = "http://www.w3.org/2000/svg",
.@"xmlns:svg" = "http://www.w3.org/2000/svg",
.viewBox = viewbox,
});
defer svg.close("svg") catch {};
{
try svg.open("defs", .{});
defer svg.close("defs") catch {};
var grid_path =
try Io.Writer.Allocating.initCapacity(allocator, 4096);
defer grid_path.deinit();
const width: f32 = grid.extents.max - grid.extents.min;
for (0..grid.line_count + 1) |line| {
const percent: f32 = @as(f32, @floatFromInt(line)) /
@as(f32, @floatFromInt(grid.line_count));
const line_pos = percent * width + grid.extents.min;
_ = try grid_path.writer
.print(
"M {1},{0} h {2} M {0},{1} v {2}",
.{ line_pos, grid.extents.min, width },
);
}
try svg.voidTag("path", .{
.id = "grid",
.d = grid_path.written(),
.stroke = "var(--grid-color, black)",
.style = "stroke-width:1px",
.@"vector-effect" = "non-scaling-stroke",
});
try svg.voidTag("path", .{
.id = "unit-cube",
.d = "M 0,0 l 0,1 1,0 Z",
.stroke = "var(--cube-color, black)",
.style = "stroke-width:1px",
.@"vector-effect" = "non-scaling-stroke",
});
try svg.voidTag("circle", .{
.id = "point",
.fill = "var(--point-color, red)",
.r = 4,
.cx = 25 + 12.5,
.cy = 25,
});
{
try svg.open("marker", .{
.id = "arrowhead",
.orient = "auto",
.makerUnits = "strokeWidth",
.refX = 0,
.refY = 0,
.markerWidth = 4,
.markerHeight = 6,
.viewBox = [4]f32{ -3, -3, 4, 6 },
});
defer svg.close("marker") catch {};
try svg.voidTag("path", .{
.stroke = "context-stroke",
.fill = "none",
.d = "M-3 -3 L 0 0 L -3 3",
});
}
{
try svg.open("g", .{ .id = "basis" });
defer svg.close("g") catch {};
try svg.voidTag("line", .{
.stroke = "var(--x-basis-color, red)",
.style = "stroke-width:2px",
.@"vector-effect" = "non-scaling-stroke",
.@"marker-end" = "url(#arrowhead)",
.x1 = 0,
.y1 = 0,
.x2 = 45,
.y2 = 0,
});
try svg.voidTag("line", .{
.stroke = "var(--y-basis-color, green)",
.style = "stroke-width:2px",
.@"vector-effect" = "non-scaling-stroke",
.@"marker-end" = "url(#arrowhead)",
.x1 = 0,
.y1 = 0,
.x2 = 0,
.y2 = 45,
});
}
}
}
const Xml = struct {
writer: *Io.Writer,
indent: u16 = 0,
fn open(
xml: *Xml,
name: [:0]const u8,
attr: anytype,
) std.Io.Writer.Error!void {
_ = try xml.writer.writeByte('\n');
_ = try xml.writer.splatByte(' ', xml.indent * indent_space_count);
_ = try xml.writer.writeByte('<');
_ = try xml.writer.write(name);
try xml.attributes(attr);
_ = try xml.writer.writeByte('>');
xml.indent += 1;
}
fn voidTag(
xml: *Xml,
name: [:0]const u8,
attr: anytype,
) std.Io.Writer.Error!void {
_ = try xml.writer.writeByte('\n');
_ = try xml.writer.splatByte(' ', xml.indent * indent_space_count);
_ = try xml.writer.writeByte('<');
_ = try xml.writer.write(name);
try xml.attributes(attr);
_ = try xml.writer.write(" />");
}
fn attributes(
xml: *Xml,
attr: anytype,
) std.Io.Writer.Error!void {
const attr_info: std.builtin.Type = @typeInfo(@TypeOf(attr));
inline for (attr_info.@"struct".fields) |field| {
try xml.writer.print(" {s}=\"", .{field.name});
const attr_value = @field(attr, field.name);
// If the value is a string slice, print it
if (comptime isString(field.type)) {
_ = try xml.writer.print("{s}", .{attr_value});
} else switch (@typeInfo(field.type)) {
// TODO: wanted sub-struct to be CSS, but comptime'd too close
// to the sun
.@"struct" => @compileError("No struct attributes"),
.void => {},
// Assume u8 is string, otherwise print values
.array => try xml.writeArray(@field(attr, field.name)),
.pointer => |p| switch (p.size) {
.slice => _ = try xml.writeArray(attr_value),
else => switch (@typeInfo(p.child)) {
.array => try xml.writeArray(
attr_value[0..],
),
else => _ = try xml.writer.print("{any}", .{
@field(attr, field.name),
}),
},
},
else => {
_ = try xml.writer.print("{}", .{@field(attr, field.name)});
},
}
_ = try xml.writer.writeByte('"');
}
}
fn writeArray(xml: *Xml, array: anytype) Io.Writer.Error!void {
for (array, 0..) |item, i| {
if (i > 0) _ = try xml.writer.writeByte(' ');
_ = try xml.writer.print("{any}", .{item});
}
}
fn close(xml: *Xml, name: [:0]const u8) std.Io.Writer.Error!void {
// Must open before close to prevent underflow
std.debug.assert(xml.indent > 0);
xml.indent -= 1;
_ = try xml.writer.writeByte('\n');
_ = try xml.writer.splatByte(' ', xml.indent * indent_space_count);
_ = try xml.writer.print("</{s}>", .{name});
}
};
fn isString(t: anytype) bool {
switch (@typeInfo(t)) {
.pointer => |info| switch (info.size) {
.slice => return info.child == u8,
.one => switch (@typeInfo(info.child)) {
.array => |array| return array.child == u8,
else => return false,
},
else => return false,
},
.array => |array| return array.child == u8,
else => return false,
}
}
test isString {
try std.testing.expect(isString(@TypeOf("a string literal")));
try std.testing.expect(isString([:0]const u8));
try std.testing.expect(isString([]const u8));
try std.testing.expect(isString([]u8));
try std.testing.expect(isString(*const [4]u8));
try std.testing.expect(isString(*const [4:0]u8));
try std.testing.expect(isString(*[8:0]u8));
}
It could be fun to extend this into full HTML to avoid really needing to use a template language at all and use normal zig, kinda like a “zsx”, but maybe not a great fit for a SSG.
If you are set on filter style templates, something like Terra/Jinja2, in my opinion, the filter syntax should probably take some inspiration from functional languages like haskell which is really built around the data in data out paradigm that suits templates well.
As an aside, I think for stuff like the above zig is unironically my favorite scripting language. It compiles so fast it even feels like running a script.