I do recommend doing it by hand, using HTML5+CSS for the layout and text content, and embedded SVG for the graphs. This is because an embedded SVG graph is pure text, and is easy to generate on the fly. Also, use https://html5.validator.nu (https://html5.validator.nu/) or some other HTML5 validator, to make sure the template page (and then the pages you generate) do not have any errors. (That one validates embedded SVG 1.1 as well.)
Consider the following example HTML. Copy and save it to a file ending with .html, and open it in your browser.
<!DOCTYPE html>
<html>
<head>
<title> Example Page </title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<style type="text/css">
html, body {
outline: 0px none;
margin: 0 0 0 0;
border: 0px none;
padding: 0 0 0 0;
background: #f7f7f7;
color: #000000;
font-family: Times New Roman, serif;
font-weight: normal;
font-size: 100%;
}
.box {
float: left;
outline: 0.25em transparent;
margin: 0.25em;
border: 0.05em solid #000000;
padding: 0.5em 1em 0.5em 1em;
background: #ffffff;
}
</style>
</head>
<body>
<div class="box"> Example text box </div>
<div class="box"> Example second text box </div>
<div class="box">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 512 256" width="6em" height="3em">
<rect x="0" y="0" width="512" height="256" fill="#ffffff" stroke="none" />
<path d="M 0,128 L 512,128 M 256,0 L 256,256" fill="none" stroke="#000000" stroke-width="1.0" />
<path d="M 24,128 L 31,137 38,147 46,156 53,165 60,173 68,181 75,189 82,196 89,202 96,208 104,213 111,217 118,220 126,222 133,224 140,224 147,224 154,222 162,220 169,217 176,213 184,208 191,202 198,196 205,189 212,181 220,173 227,165 234,156 242,147 249,137 256,128 263,119 270,109 278,100 285,91 292,83 300,75 307,67 314,60 321,54 328,48 336,43 343,39 350,36 358,34 365,32 372,32 379,32 386,34 394,36 401,39 408,43 416,48 423,54 430,60 437,67 444,75 452,83 459,91 466,100 474,109 481,119 488,128"
fill="none" stroke="#990000" stroke-width="3.0" />
</svg>
</div>
</body>
</html>
In the above snippet, all whitespace is equivalent, and the current indentation is just an effort to make it more readable. (That is, you can replace every sequence of spaces and newlines with a single space, and it will render the exact same way.)
The boxes are in a floating layout, meaning the browser will pack them left-to-right, moving to a new line when the next box won't fit on the same line. Change the size of your browser window to see the effect. The two first boxes contain just example text, and the third box is an SVG graph containing one full sine wave centered at zero.
(The SVG 1.1 path element d="" attribute commands are listed here (https://www.w3.org/TR/SVG11/paths.html#PathDataMovetoCommands), but M x,y is MoveTo, L x,y is lineto, and C x1,y1 x2,y2 x,y is CubicBeziérCurveTo, to x,y (via control points x1,y1 and x2,y2). You do not need to repeat the letter command for a continuous second element of the same type. Lower case letters use relative coordinates, upper case letters absolute coordinates.
For filled shapes, z closes the polygon/shape (with a final line segment to the starting point, i.e. the last MoveTo point), so that it can be filled. Typically, I only use rect and path elements in embedded SVG 1.1. (The exception is logo stuff, as seen on my home page (https://www.nominal-animal.net/). That embedded SVG was drawn in Inkscape, then finessed a bit by hand (removing unneeded attributes and so on, although Inkscapes "Save as Optimized SVG" is pretty darn good by itself).
All the HTML editors I have had to work with emit a lot of extraneous stuff. (So do Inkscape and even Gimp, so it's to be expected, I guess.)
However, using any editor and then cleaning up the output by hand is a valid approach, as long as the initial HTML validates correctly. Some editors don't bother, which causes all sorts of layout wonkiness down the road, as browser parsers change; validation is your only hope of future-proofing the output format.
For form-type layouts, I like to use something that web designers see as a sacrilege: table-based layout. The reason is that automatic table layout seems to behave better across display devices and fonts used. I am not certain if using traditional HTML table elements, or if div blocks that have the appropriate display table element roles would work better, though; I do not actively do web design anymore.
Consider the following modified example, that replaces the second box with a table-based form layout:
<!DOCTYPE html>
<html>
<head>
<title> Example Page </title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<style type="text/css">
html, body {
outline: 0px none;
margin: 0 0 0 0;
border: 0px none;
padding: 0 0 0 0;
background: #f7f7f7;
color: #000000;
font-family: Times New Roman, serif;
font-weight: normal;
font-size: 100%;
}
table {
outline: 0px none;
margin: 0 0 0 0;
border: 1px transparent;
padding: 0.25em 1em 0.25em 1em;
border-collapse: collapse;
}
.name {
text-align: right;
vertical-align: text-top;
color: #666666;
}
.value {
text-align: left;
vertical-align: text-top;
}
.box {
float: left;
outline: 0.25em transparent;
margin: 0.25em;
border: 0.05em solid #000000;
padding: 0.5em 1em 0.5em 1em;
background: #ffffff;
}
</style>
</head>
<body>
<div class="box"> Example text box </div>
<div class="box">
<table>
<tr> <td class="name"> Foo: </td> <td class="value"> 5 </td></tr>
<tr> <td class="name"> Bar: </td> <td class="value"> True </td></tr>
<tr> <td class="name"> Wlargh: </td> <td class="value"> non-root </td></tr>
</table>
</div>
<div class="box">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 512 256" width="6em" height="3em">
<rect x="0" y="0" width="512" height="256" fill="#ffffff" stroke="none" />
<path d="M 0,128 L 512,128 M 256,0 L 256,256" fill="none" stroke="#000000" stroke-width="1.0" />
<path d="M 24,128 L 31,137 38,147 46,156 53,165 60,173 68,181 75,189 82,196 89,202 96,208 104,213 111,217 118,220 126,222 133,224 140,224 147,224 154,222 162,220 169,217 176,213 184,208 191,202 198,196 205,189 212,181 220,173 227,165 234,156 242,147 249,137 256,128 263,119 270,109 278,100 285,91 292,83 300,75 307,67 314,60 321,54 328,48 336,43 343,39 350,36 358,34 365,32 372,32 379,32 386,34 394,36 401,39 408,43 416,48 423,54 430,60 437,67 444,75 452,83 459,91 466,100 474,109 481,119 488,128"
fill="none" stroke="#990000" stroke-width="3.0" />
</svg>
</div>
</body>
</html>
Finally, we should discuss inline versus external files. External files can reduce the server load, if the client knows the files can be cached (i.e. do not change). If the external files change on every load, they cannot be cached, and putting them inline will both reduce server load/resource use and make the page load faster.
If TLS (https protocol instead of http) is used, then each connection has a relatively large overhead. On embedded devices, this overhead is so large that inserting even unchanging resources inline instead of external files can reduce overall load times and resource use (on both the server and client ends). Since browsers nowadays basically enforce TLS use, you'll very likely use TLS; and that is exactly why I do recommend including all resources inline to the HTML page emitted.
C/C++ code-wise, I would recommend generating the pages using an array or a singly linked list of static buckets and callbacks. A static bucket is emitted as-is to the client, and a callback is a function that generates the HTML for the inline element, say SVG graph, or a form. This way, if you end up deciding to switch between inline and external resources, you only move things between page chains. (Also, I would not bother trying to find out the content length at all. Leaving it out just means the browser shows the indeterminate-progress bar instead of the determinate-progress bar; and for the server to be useful, it should be faster than a human can get exasperated; it does not need to be instant.)
A simplified example:
struct snippet {
void *const generator(); /* Parameters to the generator function, so it can emit to the client? */
const char *const content; /* NUL/'\0'-terminated string in ROM/Flash */
};
const struct snippet index_page[] =
{
{ .generator = NULL, content = html_headers },
{ .generator = NULL, content = index_title },
{ .generator = NULL, content = common_prefix },
{ .generator = index_content(), content = NULL },
{ .generator = NULL, content = common_suffix },
{ .generator = NULL, content = NULL } /* Terminator */
};
These structures are assumed to be in Flash, not RAM. If the generator function pointer is non-NULL, it is called first; then the content (which is a string, i.e. terminated with a nul '\0' byte). I recommend you store your strings in UTF-8, for maximal compatibility with, well, everything, as the HTML snippets above declare they do. The array is terminated with a (NULL, NULL) pair. Note that the generator function needs parameters, so it can emit content to the client; the parameters depend on the HTTP server library you use – basically identify the client connection somehow, allowing the function to "print" further content to the client – so I omitted those. The same generator() function and content strings can obviously be used in multiple buckets in multiple chains; on a 32-bit MCU, each snippet structure takes 8 bytes, so any content longer than that, if reused, is a candidate for a snippet.
Of course, instead of a single index_content() that generates all of the index page contents, you would split it to whatever menubars etc. you use. Also, each box in my earlier example in this post (if you'd like that sort of layout) would be a good candidate for a generator() function to emit. Of course, a generator() function should be able to call other generator() functions, too, so that you can have different pages with subsets of the useful information.
(I personally dislike the "dashboard" layouts with dozens of small windows; I'd rather have them logically grouped. I can always open multiple tabs or windows if I want to see them at the same time.)
(If you use GCC for compiling, it is possible to use a single pointer, and automatic ELF section variable addresses to determine whether the pointer points to rodata (read-only data) section, or text (code) section. Pointers outside either section can be rejected, so only generator() functions can emit variable data.)