Declarative, composable, concise & fast HTML & CSS components in C++
webxx
Declarative, composable, concise & fast HTML & CSS in C++.
#include "webxx.h"
using namespace Webxx;
std::string html = render(h1{"Hello ", i{"world!"}});
// <h1>Hello <i>world</i></h1>
std::string_view
), minimally stdlib dependent.You can simply download and include include/webxx.h
into your project, or clone this repository (e.g. using CMake FetchContent
).
# Define webxx as an interface library target:
add_library(webxx INTERFACE)
target_sources(webxx INTERFACE your/path/to/webxx.h)
# Link and include it in your target:
add_executable(yourApp main.cpp)
target_link_libraries(yourApp PRIVATE webxx)
target_include_directories(yourApp PRIVATE your/path/to/webxx)
// main.cpp
#include <iostream>
#include <list>
#include <string>
#include "webxx.h"
int main () {
using namespace Webxx;
// Bring your own data model:
bool is1MVisit = true;
std::list<std::string> toDoItems {
"Water plants",
"Plug (memory) leaks",
"Get back to that other project",
};
// Create modular components which include scoped CSS:
struct ToDoItem : component<ToDoItem> {
ToDoItem (const std::string &toDoText) : component<ToDoItem> {
li { toDoText },
} {}
};
struct ToDoList : component<ToDoList> {
ToDoList (std::list<std::string> &&toDoItems) : component<ToDoList>{
// Styles apply only to the component's elements:
{
{"ul",
// Hyphenated properties are camelCased:
listStyle{ "none" },
},
},
// Component HTML:
dv {
h1 { "To-do:" },
// Iterate over iterators:
ul {
each<ToDoItem>(std::move(toDoItems))
},
},
} {}
};
// Compose a full page:
doc page {
html {
head {
title { "Hello world!" },
script { "alert('Howdy!');" },
// Define global (unscoped) styles:
style {
{"a",
textDecoration{"none"},
},
},
// Styles from components are gathered up here:
styleTarget {},
},
body {
// Set attributes:
{_class{"dark", is1MVisit ? "party" : ""}},
// Combine components, elements and text:
ToDoList{std::move(toDoItems)},
hr {},
// Optionally include fragments:
maybe(is1MVisit, [] () {
return fragment {
h1 {
"Congratulations you are the 1 millionth visitor!",
},
a { { _href{"/prize" } },
"Click here to claim your prize",
},
};
}),
"© Me 2022",
},
},
};
// Render to a string:
std::cout << render(page) << std::endl;
}
webxx
won't doWebxx
namespace - use it considerately._
(e.g. href
-> _href
).kebab-case
tags, properties and attributes are translated to camelCase
(e.g. line-height
-> lineHeight
).@*
rules are renamed to at*
(e.g. @import
-> atImport
).div
-> dv
template
-> template_
continue
-> continue_
float
-> float_
[const] char*
[const] std::string_view
[const] std::string&&
[const] std::string
std::move
to move variables into the components where they are needed, both for performance and to ensure their lifetimes are extended to that of the webxx document.std::string_view
s to the document. While performant, you must ensure the underlying string has not been destroyed.A component is any C++ struct/class that inherits from Webxx::component
. It is made
up of HTML, along with optional parameters & CSS styles. The CSS is "scoped": Any CSS styles defined in a component apply only to the HTML elements that belong to that component:
using namespace Webxx;
// Components can work with whatever data model you want:
struct TodoItemData {
std::string description;
bool isCompleted;
};
struct TodoItem : component<TodoItem> {
// Paramters are defined in the constructor:
TodoItem (TodoItemData &&todoItem) : component<TodoItem> {
// CSS (optional, can be omitted):
{
{"li.completed",
textDecoration{"line-through"},
},
},
// HTML:
li {
// Element attributes appear first...
{_class{todoItem.isCompleted ? "completed" : ""}},
// ...followed by content:
todoItem.description,
},
// 'Head elements' - to add to <head> (optional, can be omitted):
{
// Useful for e.g. preloading assets that this component might use:
link{{_rel{"preload"}, _href{"/background.gif"}, _as{"image"}, _type{"image/gif"}}},
}
} {}
};
It is encouraged to move variables into the components where they are needed, to avoid any risk of them falling out of scope:
TodoItem generateTodoItem () {
TodoItemData item{"Thing to do!", false};
// If we did not use std::move, description would fall
// out of scope and be destroyed before being rendered:
return TodoItem{std::move(item)};
}
auto todoItem = generateTodoItem();
auto html = render(todoItem); // <li>Thing to do!</li>
It is straightforwards to repeat components using the each
helper function, or optionally include them using maybe
:
struct TodoList : component<TodoList> {
TodoList (std::list<TodoItemData> &&todoItems) : component<TodoList> {
ul {
// Show each item in the list:
each<TodoItem>(std::move(todoItems)),
// Show a message if the list is empty:
maybe(todoItems.empty(), [] () {
return li{"You're all done!"};
}),
},
} {}
};
Components and other nodes can be composed arbitrarily. For example this allows you to create structural components with slots into which other components can be inserted:
struct TodoPage : component<TodoPage> {
TodoPage (node &&titleEl, node &&mainEl) : component<TodoPage> {
doc { // Creates the <doctype>
html{ // Creates the <html>
head {
title{"Todo"},
// Special element to collect all component CSS:
styleTarget{},
// Special element to collect all component head elements:
headTarget{},
},
body{
std::move(titleEl),
main {
std::move(mainEl),
}
},
},
},
} {}
};
auto pageHtml = render(TodoPage{
h1{"My todo list"},
TodoList{{
{"Clean the car", false},
{"Clean the dog", false},
{"Clean the browser history", true},
}},
});
The styleTarget
element must appear somewhere in the HTML, in order for the CSS defined in each component to work. Likewise for the headTarget
and component 'head elements'.
The each
function can be used to generate elements, and supports two approaches that can produce equivalent outputs:
std::vector<std::string> letters{"a", "b", "c"};
// Using a lambda (or other callable) allows arbitrary complexity:
fragment byLambda = each(letters, [] (std::string letter) {
return li { letter };
});
// Using the template approach is best for concise simplicity:
fragment byTemplate = each<li>(letters);
auto isSame = render(byLambda) == render(byTemplate); // is true
The loop
function behaves the same as each
, but additionally provides a second parameter to the callback with information about the loop:
loop(letters, [] (const std::string& letter, const Loop& loop) {
return li { std::to_string(loop.index), ": ", letter };
});
A fragment
contains all the generated elements for each item. A fragment
is an "invisible" element; it will not show up in the rendered output (but its children will).
They can be used to pass around multiple elements without wrapping them in a containing div
or similar. For example they let you produce multiple elements for each item in a loop:
auto html = render(each(letters, [] (std::string letter) {
return fragment {
p{letter},
hr{},
};
}));
// html = "<p>a</p><hr/><p>b</p><hr/><p>c</p><hr/>"
Placeholders enable you to perform post-processing of the document at render time. This can be useful for tasks such as internationalization.
You can define a "populator" function, which is called for every placeholder that is encountered while rendering the document.
std::unordered_map<std::string_view,std::string_view> translations {
{"Hello", "Hej"},
{"world", "värld"},
};
h1 title {_{"Hello"}, _{"world"}, "!"};
auto translatedHtml = render(title, {
false,
[&translations] (
const std::string_view key,
const std::string_view
) -> const std::string_view {
return translations.at(key);
}
});
// translatedHtml = "<h1>Hey värld!</h1>"
You can define your own elements and attributes in the same way that webxx does internally:
constexpr static char customElTag[] = "custom-el";
using customEl = Webxx::el<customElTag>;
constexpr static char customDataThingAttr[] = "data-thing";
using dataThing = Webxx::attr<customDataThingAttr>;
render(customElTag{
{
dataThing{"value"},
},
"Hi",
}); // <custom-el data-thing="value">Hi</custom-el>
By default the render
function appends everything to an internal string buffer, which it returns. However if you want to get that first byte out before rendering the whole doc, you can hook in with a function to stream the output while the rendering is still in progress:
#include <sstream>
std::stringstream out;
size_t chunkSize{256};
// Our hook function takes rendered data, and a reference to the internal buffer:
void onRenderData (const std::string_view data, std::string &buffer) {
// In this case we are going to still use the internal buffer...
buffer.append(data);
if (buffer.size() >= chunkSize) {
// ...and then stream it out every 256 characters:
out << buffer;
buffer.clear();
}
}
doc myDoc {
head {},
body {"Hello world"},
};
// Start the render:
std::string leftovers = render(myDoc, {
nullptr, // We're not using a placeholder populator.
onRenderData, // Provide our render output receiver.
chunkSize // Preset the size of the internal buffer.
});
// Some data might be left in the buffer - this is returned so we can stream that too:
out << leftovers;
You can also defer work until calling render
by using lazy
. However lazy blocks are still executed in a pass before the first bytes are rendered.
std::string text{"Hello"};
dv myDiv{
// Evaluated now:
h1{std::string{text}},
// Lazy block takes a function:
lazy{[&text] () {
// Only evaluated after render() is called below:
return p{text};
}},
};
text = "world";
render(myDiv); // <div><h1>Hello</h1><p>world</p></div>
Some basic benchmarks are built at build/test/benchmark/webxx_benchmark
using google-benchmark. Webxx appears to be ~5-30x faster than using a template language like inja.
# clang-14 on macOS Ventura:
Running build/test/benchmark/webxx_benchmark
--------------------------------------------------------------------
Benchmark Time CPU Iterations
--------------------------------------------------------------------
singleElementInja 6442 ns 6438 ns 85931
singleElementWebxx 228 ns 227 ns 2939620
singleElementSprintf 70.3 ns 70.2 ns 9009705
singleElementStringAppend 26.8 ns 26.8 ns 25017959
multiElementInja 9208 ns 9206 ns 65686
multiElementWebxx 990 ns 990 ns 640756
multiElementSprintf 177 ns 176 ns 3711972
multiElementStringAppend 224 ns 224 ns 2844603
loop1kInja 1456063 ns 1454982 ns 455
loop1kWebxx 871208 ns 870924 ns 656
loop1kStringAppend 108399 ns 108362 ns 5607
# gcc-13 on macOS Ventura:
Running build/test/benchmark/webxx_benchmark
--------------------------------------------------------------------
Benchmark Time CPU Iterations
--------------------------------------------------------------------
singleElementInja 6804 ns 6787 ns 95385
singleElementWebxx 240 ns 239 ns 2579599
singleElementSprintf 74.8 ns 74.7 ns 8870416
singleElementStringAppend 27.6 ns 27.5 ns 25333039
multiElementInja 9630 ns 9616 ns 65712
multiElementWebxx 1015 ns 1013 ns 622698
multiElementSprintf 177 ns 177 ns 3706096
multiElementStringAppend 211 ns 210 ns 3207111
loop1kInja 1537252 ns 1519808 ns 426
loop1kWebxx 927711 ns 926574 ns 659
loop1kStringAppend 113185 ns 113055 ns 5656
Contributions are super welcome, in the form of pull requests from Github forks. Please ensure you are able to make your contribution under the terms of the project license (MIT). New features may be rejected to limit scope creep.
The library is sectioned into several modules: