The build's renderer
Turns a template value into an HTML string. Each place a value can go means what the HTML
tokenizer makes of it there. The rules are in
elementz.md, and render.test.ts holds a case for each.
import { isRaw, isTemplate, raw } from "./html.ts";A template the renderer can't write. The renderer sees only the value, so the element's renderer
(elements.ts) catches this and names the file.
export class TemplateError extends Error {
override name = "TemplateError";
}Renders a template, or any value as text. Rendering is synchronous, so a promise anywhere in it
fails (elementz.md). Data reaches an element through its
data attribute, read before it renders.
export function render(value: unknown): string {
if (isTemplate(value)) return renderTemplate(value);
return text(resolve(value));
}
function renderTemplate({
strings,
values,
}: {
strings: readonly string[];
values: readonly unknown[];
}): string {
const resolved = values.map(resolve);
const scan = new Scanner();
for (let i = 0; i < strings.length; i++) {
scan.read(strings[i]!);
if (i < values.length) scan.value(resolved[i], strings[i + 1]!);
}
return scan.out;
}
/** A value with its templates rendered, as `Raw`. */
function resolve(value: unknown): unknown {
if (typeof (value as { then?: unknown } | null)?.then === "function") {
throw new TemplateError(
"a template holds a promise, and rendering doesn't wait. Read data with a data attribute, or compute the value without await.",
);
}
if (isTemplate(value)) return raw(renderTemplate(value));
if (Array.isArray(value)) return value.map(resolve);
return value;
}
/** A value in text, where `false` is nothing (elementz.md says why). */
function text(value: unknown): string {
if (value === null || value === undefined || value === false) return "";
if (isRaw(value)) return value.value;
if (Array.isArray(value)) return value.map(text).join("");
return escape(stringOf(value));
}
/** A value in an attribute is text, so a template there fails. */
function attribute(value: unknown): string {
if (isRaw(value) || (Array.isArray(value) && value.some((item) => isRaw(item)))) {
throw new TemplateError("a template can't go in an attribute, which holds only text.");
}
if (Array.isArray(value)) value.forEach(attribute);
return stringOf(value ?? "");
}A value as text, wherever it goes. A function fails, and so does a plain object, since
[object Object] is never what a page means. Arrays, dates and numbers write as String does.
function stringOf(value: unknown): string {
if (typeof value === "function") throw functionError();
if (isPlainObject(value)) {
throw new TemplateError("a template holds an object where text goes. Write one of its fields.");
}
return String(value as string);
}
const functionError = () =>
new TemplateError(
"a template holds a function, which can't run at build time. An event handler belongs in the element's <script>, which runs in the browser.",
);
const isPlainObject = (value: unknown): boolean =>
typeof value === "object" &&
value !== null &&
[Object.prototype, null].includes(Object.getPrototypeOf(value));
type State =
| "data"
| "tag-open"
| "end-tag-open"
| "tag-name"
| "before-attribute-name"
| "attribute-name"
| "after-attribute-name"
| "before-attribute-value"
| "double-quoted"
| "single-quoted"
| "unquoted"
| "after-quoted"
| "self-closing"
| "markup-declaration"
| "comment-start"
| "comment"
| "bogus-comment"
| "rawtext";#Where a value is
The scanner follows the HTML tokenizer through the template's static strings, so it knows
where each value lands. It writes the output as it goes, because a value can take back
what came before it. A null after name= removes the name, and true removes the =.
A value right after name= is the whole attribute only when a space or > follows it. That's
htl's test, where these rules began. So href=${base}/x is part of an unquoted value, escaped as entities.
class Scanner {
out = "";
private state: State = "data";
private tagStart = 0;
private tagName = "";
private attributeStart = 0;
private dashes = 0;
value(value: unknown, next: string): void {
switch (this.state) {
case "data":
this.out += text(value);
return;
case "before-attribute-value": {
this.state = "unquoted";
if (/^[\s>]/.test(next)) {
if (value === null || value === undefined || value === false) {
this.out = this.out.slice(0, this.attributeStart).trimEnd();
this.state = "before-attribute-name";
} else if (value === true) {
this.out = this.out.replace(/\s*=\s*$/, "");
this.state = "after-attribute-name";
} else {
this.out += `"${escape(attribute(value))}"`;
this.state = "after-quoted";
}
return;
}
this.out += attribute(value).replace(/[\s>&"'<=`]/g, entity);
return;
}
case "unquoted":
this.out += attribute(value).replace(/[\s>&"'<=`]/g, entity);
return;
case "double-quoted":
case "single-quoted":
this.out += escape(attribute(value));
return;
case "rawtext": {
if (value === null || value === undefined) return;
if (typeof value === "function") throw functionError();
const content = isRaw(value) ? value.value : stringOf(value);
if (new RegExp(`</${this.tagName}[\\s/>]`, "i").test(content + " ")) {
throw new TemplateError(
`a value in <${this.tagName}> holds </${this.tagName}>, which would end it early.`,
);
}
this.out += content;
return;
}
case "comment":
return;
default:
throw new TemplateError(
"a template holds a value where a tag's name or an attribute's name goes. A value can only be an attribute's value, written name=${value} or name=\"${value}\".",
);
}
}
read(input: string): void {
for (let i = 0; i < input.length; i++) {
const c = input[i]!;
const at = this.out.length;
this.out += c;
switch (this.state) {
case "data":
if (c === "<") this.state = "tag-open";
break;
case "tag-open":
if (c === "!") this.state = "markup-declaration";
else if (c === "/") this.state = "end-tag-open";
else if (/[a-zA-Z]/.test(c)) [this.state, this.tagStart] = ["tag-name", at];
else if (c === "?") this.state = "bogus-comment";
else this.state = c === "<" ? "tag-open" : "data";
break;
case "end-tag-open":
if (/[a-zA-Z]/.test(c)) [this.state, this.tagStart] = ["tag-name", at];
else this.state = c === ">" ? "data" : "bogus-comment";
break;
case "tag-name":
if (/[\s/>]/.test(c)) {
this.tagName = this.out.slice(this.tagStart, at).toLowerCase();
this.state =
c === ">" ? this.afterTag() : c === "/" ? "self-closing" : "before-attribute-name";
}
break;
case "before-attribute-name":
case "after-attribute-name":
case "after-quoted":
if (/\s/.test(c))
this.state = this.state === "after-quoted" ? "before-attribute-name" : this.state;
else if (c === "/") this.state = "self-closing";
else if (c === ">") this.state = this.afterTag();
else if (c === "=" && this.state === "after-attribute-name")
this.state = "before-attribute-value";
else [this.state, this.attributeStart] = ["attribute-name", at];
break;
case "attribute-name":
if (/\s/.test(c)) this.state = "after-attribute-name";
else if (c === "/") this.state = "self-closing";
else if (c === ">") this.state = this.afterTag();
else if (c === "=") this.state = "before-attribute-value";
break;
case "before-attribute-value":
if (c === '"') this.state = "double-quoted";
else if (c === "'") this.state = "single-quoted";
else if (c === ">") this.state = this.afterTag();
else if (!/\s/.test(c)) this.state = "unquoted";
break;
case "double-quoted":
case "single-quoted":
if (c === (this.state === "double-quoted" ? '"' : "'")) this.state = "after-quoted";
break;
case "unquoted":
if (/\s/.test(c)) this.state = "before-attribute-name";
else if (c === ">") this.state = this.afterTag();
break;
case "self-closing":
if (c === ">") this.state = "data";
else if (!/\s/.test(c)) [this.state, this.attributeStart] = ["attribute-name", at];
break;
case "markup-declaration":
this.state = c === "-" ? "comment-start" : c === ">" ? "data" : "bogus-comment";
break;
case "comment-start":
[this.state, this.dashes] = c === "-" ? ["comment", 0] : ["bogus-comment", 0];
break;
case "comment":
if (c === ">" && this.dashes >= 2) this.state = "data";
this.dashes = c === "-" ? this.dashes + 1 : 0;
break;
case "bogus-comment":
if (c === ">") this.state = "data";
break;
case "rawtext": {
const end = `</${this.tagName}`;
if (c === "<" && input.slice(i, i + end.length).toLowerCase() === end) {
this.out += input.slice(i + 1, i + end.length);
i += end.length - 1;
this.state = "tag-name";
this.tagStart = at + 2;
}
break;
}
}
}
}
/** What follows a start tag's `>`. `<script>` and `<style>` hold raw text up to their end tag. */
private afterTag(): State {
const start = this.out.lastIndexOf("<", this.tagStart);
const isEnd = this.out[start + 1] === "/";
return !isEnd && (this.tagName === "script" || this.tagName === "style") ? "rawtext" : "data";
}
}
const ESCAPES: Record<string, string> = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
};
/** Escapes text for HTML, in text and in a quoted attribute alike. */
export const escape = (s: string): string => s.replace(/[&<>"']/g, (c) => ESCAPES[c]!);
const entity = (c: string): string => `&#${c.charCodeAt(0)};`;