De auteur beschrijft de ontwikkeling van zt, een templating-bibliotheek voor de programmeertaal Zig. Na jaren van webontwikkeling in diverse talen, zocht de auteur naar een oplossing voor Zig die type-veiligheid, snelheid en standaard XSS-beveiliging bood.
Geïnspireerd door Ziex en Templ (voor Go), transpileert zt templates tijdens zig build naar Zig-code die direct naar een std.Io.Writer schrijft. De belangrijkste kenmerken zijn:
- Type-veiligheid: Fouten in templates worden direct opgemerkt door de Zig-compiler, waardoor runtime-fouten worden voorkomen.
- Performance: De gegenereerde code is zeer efficiënt en maakt geen onnodige allocaties.
- Beveiliging: Dynamische waarden worden standaard HTML-escaped om XSS-aanvallen te voorkomen.
- Flexibiliteit: Ondersteuning voor herbruikbare componenten, layouts via
@children en standaard Zig control flow (if, for, switch).
De auteur sluit af met een vraag om feedback over de syntaxis van block-level control flow om ambiguïteiten in de parsing te verminderen.
Type-safe HTML-templating in Zig
Twee jaar geleden begon ik met Zig, en ik had me nooit kunnen voorstellen dat ik webontwikkeling in zo'n low-level taal zou overwegen. Maar terwijl ik mijn eigen ecosysteem bouwde met zio en Dusty, realiseerde ik me dat het niet zo vergezocht is. Ik bouwde deze bibliotheken voor API-backends, waarbij de output JSON of MessagePack is, maar toen zag ik dat webontwikkeling met het juiste gebruik van arena allocators eigenlijk beheersbaar is, omdat je niet hoeft te onthouden om alle strings die typisch betrokken zijn te dealloceren.
Dankzij de comptime van Zig kun je zeer high-level interfaces bouwen zonder de prestatievoordelen te verliezen. En met de hulp van htmx of Datastar kun je zelfs SPA's bouwen zonder JavaScript/TypeScript aan te raken, iets wat ik mezelf had beloofd nooit te doen.
Eisen voor een templating-bibliotheek
Toch ontbrak er nog één ding: templating. String-concatenatie was geen optie; ik wilde niet terug naar de CGI-dagen van vóór 2000. Ik had een paar eisen voor een templating-bibliotheek:
- Type-safe: Alle inputs moeten getypeerd zijn en tijdens het compileren worden gecontroleerd. Ik heb genoeg met dynamische talen gewerkt en wil geen runtime-templatefouten meer ervaren.
- Snel: Geen allocaties, geen tussenliggende status, maar de output direct wegschrijven naar
std.Io.Writer.
- Veilige standaardinstellingen: Ik wil niet langer te maken hebben met XSS, dus alle inputs moeten standaard worden ge-escaped.
- HTML-gevoel: Het moet nog steeds voelen als het schrijven van HTML. Ik wilde geen Zig-gebaseerde DSL waar ik constant tegenaan zou moeten vechten.
Ik kon geen oplossing vinden die aan al deze eisen volde. Toen zag ik Ziex en ik was versteld. Het was me nooit eens occurred dat de JSX-benadering buiten JavaScript mogelijk was. De developer experience die Nurul Huda in Zig heeft gebouwd, was onvoorstelbaar voor mij. Het paste niet direct in mijn behoeften, omdat ik geen WASM-framework wilde, maar de syntaxis en zijn aanpak om Zig-code in templates te integreren gaven me ideeën. Na verder onderzoek ontdekte ik Templ voor Go, en het werd me duidelijk wat ik wilde bouwen.
Introductie van zt
Hierbij presenteer ik zt (ik ben niet goed in het bedenken van namen, sorry). Het is een adaptatie van Templ voor Zig. De templates worden geparsed en getranspileerd naar Zig-code tijdens zig build. Dit betekent dat je ze direct vanuit je app-code kunt importeren, maar — en dat is wellicht belangrijker — dat de templates ook jouw app-code kunnen importeren.
Als resultaat kunnen alle parameters volledig getypeerd zijn. Als iets niet gerenderd kan worden, zal de Zig-compiler dit melden. De gegenereerde code is vrij eenvoudig: deze schrijft enkel naar een std.Io.Writer. Je kunt direct naar een socket schrijven of de output in een buffer verzamelen, wat jij wilt.
Hier is hoe een template eruitziet:
const User = @import("../models.zig").User;
pub templ Layout(title: []const u8) {
<!DOCTYPE html>
<html>
<head>
<title>{title}</title>
</head>
<body>
@children
</body>
</html>
}
pub templ HomePage(user: ?User) {
@Layout("Home") {
if (user) |u| {
<h1>Welcome back, {u.name}!</h1>
<a href="/users/{u.id}">Your profile</a>
} else {
<h1>Welcome, guest!</h1>
<a href="/login">Log in</a>
}
}
}
En dit is hoe je het vanuit Zig-code gebruikt:
const std = @import("std");
const User = @import("models.zig").User;
const pages = @import("templates/pages.zig");
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var stdout = std.Io.File.stdout().writer(init.io, &buf);
const w = &stdout.interface;
const user: User = .{ .id = 1, .name = "Lukas" };
try pages.HomePage.render(.{user}, w);
try w.flush();
}
Als je het binnen Dusty wilt gebruiken om een HTML-pagina te renderen, doe je dit:
try res.render(.html, pages.HomePage, .{user});
Ter zijlijn: het bovenstaande hoofdfragment bevat een zeer onduidelijke bug in de foutafhandeling als je hetzelfde patroon gebruikt op plekken waar std.Io annulering van belang is. Ik schrijf hier waarschijnlijk een blogpost over, maar als je geïnteresseerd bent, kun je deze Ziggit-thread bekijken. De render-functie in Dusty die de std.Io.Writer-afhandeling omhult, lost dit op.
Beveiliging en XSS
Alle dynamische waarden worden standaard HTML-escaped, zowel in tekst als in attributen. Waarden in URL-attributen zoals href of src worden daarnaast gecontroleerd op gevaarlijke schema's. Als je echt rauwe HTML wilt outputten, moet je daar expliciet om vragen met {!value}, waardoor dit in de template gemakkelijk te spotten is.
De parser vereist daarnaast dat alle elementen correct worden gesloten, zodat je niet per ongeluk kapotte markup produceert. Het beschermt je niet tegen alles, maar het verwijdert de meest voorkomende bronnen van XSS.
Compilerfouten en Type-veiligheid
Wat ik het meeste waardeer, is dat templatefouten simpelweg Zig-compilerfouten zijn. Als je een typefout maakt in de bovenstaande template en {u.nmae} schrijft in plaats van {u.name}, dan meldt zig build het volgende:
src/templates/pages.zig:45:43: error: no field named 'nmae' in struct 'models.User'
try zt.writeEscaped(writer, u.nmae); // pages.zt:18:31
^~~~
src/models.zig:1:18: note: struct declared here
pub const User = struct {
^~~~~~
De fout wijst naar de gegenereerde code, maar elke regel bevat een commentaar met de locatie in de originele template. Hetzelfde geldt voor de aanroepende kant: als je probeert een string door te geven waar HomePage een ?User verwacht, zal het niet compileren.
Geavanceerde functies en controle
Naast de basis die hier getoond wordt, kun je herbruikbare componenten bouwen en deze als waarden doorgeven. Met @children kun je layouts maken die andere templates omhullen, wat hetzelfde effect heeft als template-overerving in andere talen.
Control flow is gewoon Zig, dus if, for en switch werken zoals verwacht, inclusief payload captures. Alles hiervan wordt gecompileerd naar een reeks schrijfacties, zonder enige allocaties.
Je kunt kijken naar het TODO-voorbeeld in Dusty. Dit is een eenvoudige, op htmx gebaseerde webapp die deze bibliotheek demonstreert, samen met andere onderdelen van Dusty die nodig zijn voor het bouwen van een dynamische SPA.
Openstaande punten en feedback
Eén deel van de syntaxis is nog niet definitief. Block-level control flow wordt geschreven als gewone Zig, naar voorbeeld van Templ; een regel die begint met if, for of switch wordt dus als code geparsed. Dit betekent dat tekst zoals "if you forgot your password..." op een eigen regel een parse-fout veroorzaakt, en dat je dit in een element moet wrappen of als een string-expressie moet schrijven.
Ik overweeg om over te stappen naar @if, @for en @switch, vergelijkbaar met Razor of Twirl. Dit zou de ambiguïteit wegnemen, ten koste van iets "ruiziger" templates. Als je hier een mening over hebt, laat dan graag een reactie achter in deze GitHub-issue.
Hoewel er al meerdere mensen gebruik van maken, is het project nog niet erg volwassen, dus ik verwelkom alle feedback. Je kunt een GitHub-issue openen als je problemen vindt, of me pingen op de Zig Discord.
Type-safe HTML-templating in Zig
Twee jaar geleden begon ik met Zig, en ik had me nooit kunnen voorstellen dat ik webontwikkeling in zo'n low-level taal zou overwegen. Maar terwijl ik mijn eigen ecosysteem bouwde met zio en Dusty, realiseerde ik me dat het niet zo vergezocht is. Ik bouwde deze bibliotheken voor API-backends, waarbij de output JSON of MessagePack is, maar toen zag ik dat webontwikkeling met het juiste gebruik van arena allocators eigenlijk beheersbaar is, omdat je niet hoeft te onthouden om alle strings die typisch betrokken zijn te dealloceren.
Dankzij de comptime van Zig kun je zeer high-level interfaces bouwen zonder de prestatievoordelen te verliezen. En met de hulp van htmx of Datastar kun je zelfs SPA's bouwen zonder JavaScript/TypeScript aan te raken, iets wat ik mezelf had beloofd nooit te doen.
Eisen voor een templating-bibliotheek
Toch ontbrak er nog één ding: templating. String-concatenatie was geen optie; ik wilde niet terug naar de CGI-dagen van vóór 2000. Ik had een paar eisen voor een templating-bibliotheek:
- Type-safe: Alle inputs moeten getypeerd zijn en tijdens het compileren worden gecontroleerd. Ik heb genoeg met dynamische talen gewerkt en wil geen runtime-templatefouten meer ervaren.
- Snel: Geen allocaties, geen tussenliggende status, maar de output direct wegschrijven naar
std.Io.Writer.
- Veilige standaardinstellingen: Ik wil niet langer te maken hebben met XSS, dus alle inputs moeten standaard worden ge-escaped.
- HTML-gevoel: Het moet nog steeds voelen als het schrijven van HTML. Ik wilde geen Zig-gebaseerde DSL waar ik constant tegenaan zou moeten vechten.
Ik kon geen oplossing vinden die aan al deze eisen volde. Toen zag ik Ziex en ik was versteld. Het was me nooit eens occurred dat de JSX-benadering buiten JavaScript mogelijk was. De developer experience die Nurul Huda in Zig heeft gebouwd, was onvoorstelbaar voor mij. Het paste niet direct in mijn behoeften, omdat ik geen WASM-framework wilde, maar de syntaxis en zijn aanpak om Zig-code in templates te integreren gaven me ideeën. Na verder onderzoek ontdekte ik Templ voor Go, en het werd me duidelijk wat ik wilde bouwen.
Introductie van zt
Hierbij presenteer ik zt (ik ben niet goed in het bedenken van namen, sorry). Het is een adaptatie van Templ voor Zig. De templates worden geparsed en getranspileerd naar Zig-code tijdens zig build. Dit betekent dat je ze direct vanuit je app-code kunt importeren, maar — en dat is wellicht belangrijker — dat de templates ook jouw app-code kunnen importeren.
Als resultaat kunnen alle parameters volledig getypeerd zijn. Als iets niet gerenderd kan worden, zal de Zig-compiler dit melden. De gegenereerde code is vrij eenvoudig: deze schrijft enkel naar een std.Io.Writer. Je kunt direct naar een socket schrijven of de output in een buffer verzamelen, wat jij wilt.
Hier is hoe een template eruitziet:
const User = @import("../models.zig").User;
pub templ Layout(title: []const u8) {
<!DOCTYPE html>
<html>
<head>
<title>{title}</title>
</head>
<body>
@children
</body>
</html>
}
pub templ HomePage(user: ?User) {
@Layout("Home") {
if (user) |u| {
<h1>Welcome back, {u.name}!</h1>
<a href="/users/{u.id}">Your profile</a>
} else {
<h1>Welcome, guest!</h1>
<a href="/login">Log in</a>
}
}
}
En dit is hoe je het vanuit Zig-code gebruikt:
const std = @import("std");
const User = @import("models.zig").User;
const pages = @import("templates/pages.zig");
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var stdout = std.Io.File.stdout().writer(init.io, &buf);
const w = &stdout.interface;
const user: User = .{ .id = 1, .name = "Lukas" };
try pages.HomePage.render(.{user}, w);
try w.flush();
}
Als je het binnen Dusty wilt gebruiken om een HTML-pagina te renderen, doe je dit:
try res.render(.html, pages.HomePage, .{user});
Ter zijlijn: het bovenstaande hoofdfragment bevat een zeer onduidelijke bug in de foutafhandeling als je hetzelfde patroon gebruikt op plekken waar std.Io annulering van belang is. Ik schrijf hier waarschijnlijk een blogpost over, maar als je geïnteresseerd bent, kun je deze Ziggit-thread bekijken. De render-functie in Dusty die de std.Io.Writer-afhandeling omhult, lost dit op.
Beveiliging en XSS
Alle dynamische waarden worden standaard HTML-escaped, zowel in tekst als in attributen. Waarden in URL-attributen zoals href of src worden daarnaast gecontroleerd op gevaarlijke schema's. Als je echt rauwe HTML wilt outputten, moet je daar expliciet om vragen met {!value}, waardoor dit in de template gemakkelijk te spotten is.
De parser vereist daarnaast dat alle elementen correct worden gesloten, zodat je niet per ongeluk kapotte markup produceert. Het beschermt je niet tegen alles, maar het verwijdert de meest voorkomende bronnen van XSS.
Compilerfouten en Type-veiligheid
Wat ik het meeste waardeer, is dat templatefouten simpelweg Zig-compilerfouten zijn. Als je een typefout maakt in de bovenstaande template en {u.nmae} schrijft in plaats van {u.name}, dan meldt zig build het volgende:
src/templates/pages.zig:45:43: error: no field named 'nmae' in struct 'models.User'
try zt.writeEscaped(writer, u.nmae); // pages.zt:18:31
^~~~
src/models.zig:1:18: note: struct declared here
pub const User = struct {
^~~~~~
De fout wijst naar de gegenereerde code, maar elke regel bevat een commentaar met de locatie in de originele template. Hetzelfde geldt voor de aanroepende kant: als je probeert een string door te geven waar HomePage een ?User verwacht, zal het niet compileren.
Geavanceerde functies en controle
Naast de basis die hier getoond wordt, kun je herbruikbare componenten bouwen en deze als waarden doorgeven. Met @children kun je layouts maken die andere templates omhullen, wat hetzelfde effect heeft als template-overerving in andere talen.
Control flow is gewoon Zig, dus if, for en switch werken zoals verwacht, inclusief payload captures. Alles hiervan wordt gecompileerd naar een reeks schrijfacties, zonder enige allocaties.
Je kunt kijken naar het TODO-voorbeeld in Dusty. Dit is een eenvoudige, op htmx gebaseerde webapp die deze bibliotheek demonstreert, samen met andere onderdelen van Dusty die nodig zijn voor het bouwen van een dynamische SPA.
Openstaande punten en feedback
Eén deel van de syntaxis is nog niet definitief. Block-level control flow wordt geschreven als gewone Zig, naar voorbeeld van Templ; een regel die begint met if, for of switch wordt dus als code geparsed. Dit betekent dat tekst zoals "if you forgot your password..." op een eigen regel een parse-fout veroorzaakt, en dat je dit in een element moet wrappen of als een string-expressie moet schrijven.
Ik overweeg om over te stappen naar @if, @for en @switch, vergelijkbaar met Razor of Twirl. Dit zou de ambiguïteit wegnemen, ten koste van iets "ruiziger" templates. Als je hier een mening over hebt, laat dan graag een reactie achter in deze GitHub-issue.
Hoewel er al meerdere mensen gebruik van maken, is het project nog niet erg volwassen, dus ik verwelkom alle feedback. Je kunt een GitHub-issue openen als je problemen vindt, of me pingen op de Zig Discord.