Todo app
A classic interactive UI without a frontend framework. sitelo builds the page shell; event attributes call import('/js/todo.js').then(…) so the module loads only when needed. Full source in examples/todo.
What you get
- Static HTML with
onsubmit/onload(and list item) handlers src/js/todo.js— exportedhydrate,handleSubmit,handleChange,handleRemove- sitelo discovers literal
import('/…')in the HTML and bundles the file intodist/(see Assets)
Project layout
my-todo/
sitelo.config.js
package.json
src/
index.ht.js # static shell + inline import() handlers
css/
styles.css
js/
todo.js # exported handlers (loaded on demand)1. Inline imports in the page
No <script type="module" src>. Handlers are HTML attributes that dynamically import the module and call an export, passing this (the element). That keeps page modules free of browser APIs (see JSX limitations).
export default () => `
<html lang="en">
<head>
<title>Todos — sitelo</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body onload="import('/js/todo.js').then((m) => m.hydrate())">
<main>
<h1>Todos</h1>
<form
id="todo-form"
autocomplete="off"
onsubmit="event.preventDefault(); import('/js/todo.js').then((m) => m.handleSubmit(this))"
>
<input id="todo-input" name="title" type="text" placeholder="What needs doing?" required>
<button type="submit">Add</button>
</form>
<ul id="todo-list"></ul>
<p id="todo-empty" class="empty" hidden>Nothing here yet.</p>
<p class="meta"><span id="todo-count">0</span> left</p>
</main>
</body>
</html>
`import {
html, head, title, meta, link, body, main, h1, form, input, button, ul, p, span,
} from 'javascript-to-html'
export default () =>
html({ lang: 'en' },
head(
title('Todos — sitelo'),
meta({ name: 'viewport', content: 'width=device-width, initial-scale=1' }),
link({ rel: 'stylesheet', href: '/styles.css' }),
),
body(
{ onload: "import('/js/todo.js').then((m) => m.hydrate())" },
main(
h1('Todos'),
form(
{
id: 'todo-form',
autocomplete: 'off',
onsubmit:
"event.preventDefault(); import('/js/todo.js').then((m) => m.handleSubmit(this))",
},
input({
id: 'todo-input',
name: 'title',
type: 'text',
placeholder: 'What needs doing?',
required: '',
}),
button({ type: 'submit' }, 'Add'),
),
ul({ id: 'todo-list' }),
p({ id: 'todo-empty', class: 'empty', hidden: '' }, 'Nothing here yet.'),
p({ class: 'meta' }, span({ id: 'todo-count' }, '0'), ' left'),
),
),
)export default function Todos() {
return (
<html lang="en">
<head>
<title>Todos — sitelo</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body
{...{
// React maps onLoad → synthetic event; spread lets us output raw onload=""
onload: "import('/js/todo.js').then((m) => m.hydrate())",
}}
>
<main>
<h1>Todos</h1>
<form
id="todo-form"
autoComplete="off"
{...{
// same reason — raw onsubmit="" not React's onSubmit
onsubmit:
"event.preventDefault(); import('/js/todo.js').then((m) => m.handleSubmit(this))",
}}
>
<input
id="todo-input"
name="title"
type="text"
placeholder="What needs doing?"
required
/>
<button type="submit">Add</button>
</form>
<ul id="todo-list" />
<p id="todo-empty" className="empty" hidden>
Nothing here yet.
</p>
<p className="meta">
<span id="todo-count">0</span> left
</p>
</main>
</body>
</html>
)
}2. Exported handlers
The module is a normal ES file under src/js/. List items created at runtime use the same import('/js/todo.js').then(…) pattern for onchange / onclick.
import { button, input, label, li, span } from 'javascript-to-html'
const STORAGE_KEY = 'sitelo-todo-example'
function loadTodos() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function saveTodos(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos))
}
function createId() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
const IMPORT_CHANGE = "import('/js/todo.js').then((m) => m.handleChange(this))"
const IMPORT_REMOVE = "import('/js/todo.js').then((m) => m.handleRemove(this))"
function render() {
const list = document.querySelector('#todo-list')
const empty = document.querySelector('#todo-empty')
const count = document.querySelector('#todo-count')
if (!list || !empty || !count) return
const todos = loadTodos()
list.innerHTML = todos
.map((todo) =>
li(
{
class: todo.done ? 'todo is-done' : 'todo',
'data-id': todo.id,
},
label(
input({
type: 'checkbox',
onchange: IMPORT_CHANGE,
'aria-label': `Mark "${todo.title}" complete`,
}),
span({ class: 'todo-title' }, todo.title),
),
button(
{
type: 'button',
class: 'todo-remove',
onclick: IMPORT_REMOVE,
'aria-label': `Remove "${todo.title}"`,
},
'Remove',
),
),
)
.join('')
for (const cb of list.querySelectorAll('input[type="checkbox"]')) {
const item = cb.closest('[data-id]')
const todo = todos.find((t) => t.id === item?.dataset.id)
if (todo) cb.checked = todo.done
}
count.textContent = String(todos.filter((t) => !t.done).length)
empty.hidden = todos.length > 0
}
export function hydrate() {
render()
}
export function handleSubmit(form) {
const input = form.elements.namedItem('title')
if (!(input instanceof HTMLInputElement)) return
const title = input.value.trim()
if (!title) return
saveTodos([{ id: createId(), title, done: false }, ...loadTodos()])
input.value = ''
input.focus()
render()
}
export function handleChange(checkbox) {
const item = checkbox.closest('[data-id]')
if (!(item instanceof HTMLElement) || !item.dataset.id) return
saveTodos(
loadTodos().map((todo) =>
todo.id === item.dataset.id ? { ...todo, done: checkbox.checked } : todo,
),
)
render()
}
export function handleRemove(button) {
const item = button.closest('[data-id]')
if (!(item instanceof HTMLElement) || !item.dataset.id) return
saveTodos(loadTodos().filter((todo) => todo.id !== item.dataset.id))
render()
}3. Run
npm install
npm run devOr npm run build and host dist/ anywhere static files are served.