I echo the sentiment of a few others about not reinventing the wheel and simply using a library; you'll probably end writing your own in achieving your objective. There are several minimalistic libraries that would moderately address your concern about bloat. If your question is a purely academic question, then that's another thing.
Given the constraint of "small app", Option 1 would be the option that I would go with. It requires the least amount of extra design to achieve. Going one step further, similar to the helper functions that Tommy Hodgins described, you could generalize that approach even more into a simple node builder framework (similar to XmlBuilder in other languages or some of the compiled output of React). You essentially need builder functions for elements and text nodes (for extra credit you could add builders for the other node types like comments, CDATA sections, etc.)
An example in TypeScript. (I just threw this together, so don't critique it too much. But it does work):
type BuilderFunction = (parent?: Node) => void;
function elementBuilder(
tagName: string,
children: BuilderFunction[] = [],
attributes: any = {},
events: { [key: string]: Function } = {}
): BuilderFunction {
return (parent: Node) => {
const element = document.createElement(tagName);
Object.keys(attributes).forEach((name) => {
element.setAttribute(name, attributes[name]);
});
Object.keys(events).forEach((name) => {
element.addEventListener(name, (evt) => events[name](evt));
});
children.forEach((child) => {
const childNode = child(element);
});
if (parent !== undefined) {
parent.appendChild(element);
}
};
}
function textBuilder(text: string): BuilderFunction {
return (parent: Node) => {
parent.appendChild(document.createTextNode(text));
};
}
const builder = elementBuilder('div', [
elementBuilder('form', [
elementBuilder('input', [], { type: 'text' }),
elementBuilder(
'button',
[textBuilder('Submit Button')],
{ type: 'submit' },
{ click: (evt) => { alert('Clicked') } }
)
])
]);
builder(document.body);
Give these functions some aliases, and you've got yourself a very simple and terse domain-specific language.
Options 2, 3, and 4 each have the advantage of being defined with markup but also have the disadvantage of being unable to wire up event handlers or any other type of dynamic linkage. At this point you're verging into territory where frameworks really shine. If you still have the constraint of not using a framework, you could take a few approaches:
v-on:click="handler" or data-bind="click: handler") in markup that your engine will scan for after it has created a DOM subtree. This option too will probably require a custom grammar but a more simplistic one.React's JSX and Angular's AOT effectively take care of these steps at compile-time and generate builder-type code to be executed at runtime.
Bonus option, one that I have not explored much yet but is supposed to have a lot of potential: HyperHTML. It uses tagged template literals to achieve the change detection features of a virtual DOM without the overhead.