create-spark-html-app 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template-ssr/404.html +9 -0
- package/template-ssr/README.md +49 -17
- package/template-ssr/components/login-form.html +38 -0
- package/template-ssr/components/logout-button.html +8 -0
- package/template-ssr/components/nav.html +10 -3
- package/template-ssr/components/post-card.html +7 -0
- package/template-ssr/components/post-editor.html +68 -0
- package/template-ssr/components/theme-toggle.html +9 -0
- package/template-ssr/components/todo-list.html +51 -0
- package/template-ssr/middleware.html +8 -0
- package/template-ssr/package.json +8 -3
- package/template-ssr/pages/about.html +22 -0
- package/template-ssr/pages/admin/index.html +30 -0
- package/template-ssr/pages/blog/[slug].html +35 -0
- package/template-ssr/pages/index.html +30 -16
- package/template-ssr/public/app.js +8 -0
- package/template-ssr/public/img/avatar.png +0 -0
- package/template-ssr/public/style.css +116 -0
- package/template-ssr/setup.js +64 -3
- package/template-ssr/spark.json +3 -1
- package/template-ssr/pages/index.css +0 -6
package/package.json
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<meta charset="utf-8">
|
|
2
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3
|
+
<title>Not found</title>
|
|
4
|
+
<link rel="stylesheet" href="/style.css">
|
|
5
|
+
<article class="post" style="text-align:center; margin-top:4rem">
|
|
6
|
+
<h1>404</h1>
|
|
7
|
+
<p class="lede">This page took the day off.</p>
|
|
8
|
+
<p><a href="/">← Back to the blog</a></p>
|
|
9
|
+
</article>
|
package/template-ssr/README.md
CHANGED
|
@@ -1,29 +1,61 @@
|
|
|
1
|
-
# ⚡ Spark
|
|
1
|
+
# ⚡ Spark Blog — a full app on spark-ssr
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
A complete blog: public posts with SEO titles/meta, dynamic routes, an
|
|
4
|
+
about page, and an auth-gated admin panel where the author manages posts —
|
|
5
|
+
and a private todo list. All of it is a handful of `.html` files, one SQLite
|
|
6
|
+
database, and **zero build steps**.
|
|
7
7
|
|
|
8
|
-
##
|
|
8
|
+
## Run it
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
bun install
|
|
12
|
-
bun run dev #
|
|
12
|
+
bun run dev # seeds dev.db, serves on :3000, live-reloads on every edit
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
`pages/blog/[slug].html` → `/blog/:slug`
|
|
15
|
+
Sign in at [/admin](http://localhost:3000/admin) with
|
|
16
|
+
**me@spark-html.com** / **spark**.
|
|
17
|
+
|
|
18
|
+
## What's demonstrated where
|
|
19
|
+
|
|
20
|
+
| Feature | Where |
|
|
21
|
+
|---|---|
|
|
22
|
+
| Filesystem routing | `pages/index.html` → `/`, `pages/about.html` → `/about` |
|
|
23
|
+
| Dynamic routes | `pages/blog/[slug].html` → `/blog/:slug` — `:slug` binds into the SQL |
|
|
24
|
+
| Per-page `<title>`/`<meta>` | literal tags at the top of each page, `{expr}`-interpolated (spark-html-head/ssr) |
|
|
25
|
+
| Declarative data | `<spark-ssr>` blocks — the query names feed the template variables |
|
|
26
|
+
| Server → component props | `index.html` passes each whole `post` row into `post-card.html` |
|
|
27
|
+
| Pure-UI components | `nav.html`, `post-card.html` — no `<script>`, props are the scope |
|
|
28
|
+
| Client components | `login-form`, `post-editor`, `todo-list` — their `<script>` runs in the browser |
|
|
29
|
+
| Auth | `spark.json` `"auth"` → sessions, `POST /api/users?auth` login, hashed passwords |
|
|
30
|
+
| Auth-scoped CRUD | `posts` and `todos` carry `user_id` → their APIs are session-scoped (401 anonymous) |
|
|
31
|
+
| Draft privacy | the `[slug]` query: `published = 1 OR :session.id IS NOT NULL` — authors preview drafts |
|
|
32
|
+
| Middleware | `middleware.html` disables public signups (single-author blog) |
|
|
33
|
+
| Aggregates | `about.html`'s `COUNT(*)` serves an object → `{stats.n}` |
|
|
34
|
+
| Custom error page | `404.html` |
|
|
35
|
+
| Dark/light theme | spark-html-theme — `app.js` one-liner + `theme-toggle.html`; no-flash init is auto-inlined |
|
|
36
|
+
| Fonts | spark.json `"fonts"` → spark-html-font tags in every `<head>` (preload, no-shift fallback) |
|
|
37
|
+
| Images | `bun run build` runs spark-html-image over `dist/` — webp + srcset for every `<img>` |
|
|
38
|
+
|
|
39
|
+
## The mental model
|
|
40
|
+
|
|
41
|
+
- A **page**'s plain `<script>` runs on the **server** (it's the escape
|
|
42
|
+
hatch); `<script type="module">`/`src` scripts ship to the browser.
|
|
43
|
+
- A **component**'s `<script>` runs in the **browser**. Components are
|
|
44
|
+
otherwise pure UI: they render the props they're given.
|
|
45
|
+
- `<spark-ssr>` declares data. A `table="…"` gives you scoped REST CRUD;
|
|
46
|
+
a `GET /api/x → SELECT …` line is both an endpoint and page data.
|
|
47
|
+
- Everything hot-reloads — pages, components, queries, middleware — no
|
|
48
|
+
restart, the browser refreshes itself.
|
|
24
49
|
|
|
25
50
|
## Deploy
|
|
26
51
|
|
|
27
52
|
```bash
|
|
28
|
-
bun run build # dist/
|
|
53
|
+
bun run build # dist/ + compiled binary; images optimized if sharp installs
|
|
54
|
+
PORT=3000 ./dist/app
|
|
29
55
|
```
|
|
56
|
+
|
|
57
|
+
Set a fixed session secret in production so logins survive restarts:
|
|
58
|
+
`"auth": { …, "secret": "ENV.SESSION_SECRET" }` in spark.json.
|
|
59
|
+
|
|
60
|
+
Swap SQLite for Postgres by changing one line in spark.json:
|
|
61
|
+
`"db": "postgres://…"`.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!-- A component <script> runs in the BROWSER (pages' plain <script> runs on
|
|
2
|
+
the server — that's the split). POST /api/users?auth is the login
|
|
3
|
+
endpoint spark-ssr derives from the auth config. -->
|
|
4
|
+
<form class="panel login" onsubmit={login}>
|
|
5
|
+
<h2>Author sign-in</h2>
|
|
6
|
+
<label>Email
|
|
7
|
+
<input type="email" bind:value="email" autocomplete="username" required>
|
|
8
|
+
</label>
|
|
9
|
+
<label>Password
|
|
10
|
+
<input type="password" bind:value="password" autocomplete="current-password" required>
|
|
11
|
+
</label>
|
|
12
|
+
<button :disabled="pending">{pending ? 'Signing in…' : 'Sign in'}</button>
|
|
13
|
+
<template if="error"><p class="error">{error}</p></template>
|
|
14
|
+
<p class="hint">Seeded account: me@spark-html.com / spark</p>
|
|
15
|
+
</form>
|
|
16
|
+
|
|
17
|
+
<script>
|
|
18
|
+
let email = '';
|
|
19
|
+
let password = '';
|
|
20
|
+
let error = '';
|
|
21
|
+
let pending = false;
|
|
22
|
+
|
|
23
|
+
async function login() {
|
|
24
|
+
pending = true;
|
|
25
|
+
error = '';
|
|
26
|
+
const res = await fetch('/api/users?auth', {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'content-type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({ email, password }),
|
|
30
|
+
});
|
|
31
|
+
if (res.ok) {
|
|
32
|
+
location.reload();
|
|
33
|
+
} else {
|
|
34
|
+
error = 'Wrong email or password.';
|
|
35
|
+
pending = false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
<!-- Pure UI — no <script>. Props (blog, active) come from whichever page
|
|
2
|
+
imports it, rendered on the server and again after client mount. -->
|
|
3
|
+
<nav class="nav">
|
|
4
|
+
<a class="brand" href="/">⚡ {blog}</a>
|
|
5
|
+
<span class="links">
|
|
6
|
+
<a href="/" :class="active === 'home' ? 'on' : ''">Posts</a>
|
|
7
|
+
<a href="/about" :class="active === 'about' ? 'on' : ''">About</a>
|
|
8
|
+
<a href="/admin" :class="active === 'admin' ? 'on' : ''">Manage</a>
|
|
9
|
+
<span import="/components/theme-toggle"></span>
|
|
10
|
+
</span>
|
|
4
11
|
</nav>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<!-- Pure UI — receives the WHOLE post row as one prop from the page's
|
|
2
|
+
each-loop: <div import="/components/post-card" post="{post}">. -->
|
|
3
|
+
<article class="card">
|
|
4
|
+
<h2><a href="/blog/{post.slug}">{post.title}</a></h2>
|
|
5
|
+
<p>{post.excerpt}</p>
|
|
6
|
+
<p class="meta">{post.created_at}</p>
|
|
7
|
+
</article>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!-- Manage posts through the auto-CRUD API the admin page declares with
|
|
2
|
+
<spark-ssr table="posts" />. posts has a user_id column, so every call
|
|
3
|
+
is scoped to the session — anonymous requests get 401. -->
|
|
4
|
+
<section class="panel">
|
|
5
|
+
<h2>Posts</h2>
|
|
6
|
+
<template each="p in posts" key="p.id">
|
|
7
|
+
<div class="row">
|
|
8
|
+
<span class="grow">
|
|
9
|
+
<strong>{p.title}</strong>
|
|
10
|
+
<span :class="p.published ? 'pill live' : 'pill draft'">{p.published ? 'live' : 'draft'}</span>
|
|
11
|
+
</span>
|
|
12
|
+
<a href="/blog/{p.slug}">view</a>
|
|
13
|
+
<button onclick={toggle(p)}>{p.published ? 'Unpublish' : 'Publish'}</button>
|
|
14
|
+
<button class="danger" onclick={remove(p)}>Delete</button>
|
|
15
|
+
</div>
|
|
16
|
+
</template>
|
|
17
|
+
<template if="loaded && posts.length === 0">
|
|
18
|
+
<p class="empty">No posts yet — write one below.</p>
|
|
19
|
+
</template>
|
|
20
|
+
|
|
21
|
+
<h3>New post</h3>
|
|
22
|
+
<input bind:value="title" placeholder="Title">
|
|
23
|
+
<input bind:value="excerpt" placeholder="One-line excerpt">
|
|
24
|
+
<textarea bind:value="body" rows="6" placeholder="Write…"></textarea>
|
|
25
|
+
<button onclick={create} :disabled="!title">Save draft</button>
|
|
26
|
+
</section>
|
|
27
|
+
|
|
28
|
+
<script>
|
|
29
|
+
let posts = [];
|
|
30
|
+
let loaded = false;
|
|
31
|
+
let title = '';
|
|
32
|
+
let excerpt = '';
|
|
33
|
+
let body = '';
|
|
34
|
+
|
|
35
|
+
async function api(path, method, data) {
|
|
36
|
+
return fetch(path, {
|
|
37
|
+
method,
|
|
38
|
+
headers: { 'content-type': 'application/json' },
|
|
39
|
+
body: JSON.stringify(data),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function refresh() {
|
|
44
|
+
posts = await (await fetch('/api/posts')).json();
|
|
45
|
+
loaded = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function create() {
|
|
49
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
50
|
+
await api('/api/posts', 'POST', { slug, title, excerpt, body });
|
|
51
|
+
title = '';
|
|
52
|
+
excerpt = '';
|
|
53
|
+
body = '';
|
|
54
|
+
refresh();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function toggle(p) {
|
|
58
|
+
await api(`/api/posts/${p.id}`, 'PATCH', { published: p.published ? 0 : 1 });
|
|
59
|
+
refresh();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function remove(p) {
|
|
63
|
+
await api(`/api/posts/${p.id}`, 'DELETE');
|
|
64
|
+
refresh();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
refresh();
|
|
68
|
+
</script>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- The spark-html-theme store, as a button. theme() itself boots in
|
|
2
|
+
/app.js; the server also inlines the no-flash init snippet in <head>. -->
|
|
3
|
+
<button class="ghost toggle" onclick={theme.toggle} aria-label="Toggle theme">
|
|
4
|
+
{theme.resolved === 'dark' ? '☀️' : '🌙'}
|
|
5
|
+
</button>
|
|
6
|
+
|
|
7
|
+
<script>
|
|
8
|
+
const theme = useStore('theme');
|
|
9
|
+
</script>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<!-- The author's private todos. Same auto-CRUD pattern as posts — the
|
|
2
|
+
user_id column scopes every row to the session, so nobody else can
|
|
3
|
+
read or touch them (try /api/todos signed out: 401). -->
|
|
4
|
+
<section class="panel">
|
|
5
|
+
<h2>Private todos</h2>
|
|
6
|
+
<div class="row">
|
|
7
|
+
<input class="grow" bind:value="draft" placeholder="Something to do">
|
|
8
|
+
<button onclick={add} :disabled="!draft">Add</button>
|
|
9
|
+
</div>
|
|
10
|
+
<template each="t in todos" key="t.id">
|
|
11
|
+
<label class="row">
|
|
12
|
+
<input type="checkbox" bind:checked="t.done" onchange={toggle(t)}>
|
|
13
|
+
<span :class="t.done ? 'grow done' : 'grow'">{t.title}</span>
|
|
14
|
+
<button class="danger" onclick={remove(t)}>✕</button>
|
|
15
|
+
</label>
|
|
16
|
+
</template>
|
|
17
|
+
</section>
|
|
18
|
+
|
|
19
|
+
<script>
|
|
20
|
+
let todos = [];
|
|
21
|
+
let draft = '';
|
|
22
|
+
|
|
23
|
+
async function api(path, method, data) {
|
|
24
|
+
return fetch(path, {
|
|
25
|
+
method,
|
|
26
|
+
headers: { 'content-type': 'application/json' },
|
|
27
|
+
body: JSON.stringify(data),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function refresh() {
|
|
32
|
+
todos = await (await fetch('/api/todos')).json();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function add() {
|
|
36
|
+
await api('/api/todos', 'POST', { title: draft });
|
|
37
|
+
draft = '';
|
|
38
|
+
refresh();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function toggle(t) {
|
|
42
|
+
await api(`/api/todos/${t.id}`, 'PATCH', { done: t.done ? 1 : 0 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function remove(t) {
|
|
46
|
+
await api(`/api/todos/${t.id}`, 'DELETE');
|
|
47
|
+
refresh();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
refresh();
|
|
51
|
+
</script>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<!-- Runs on EVERY request, before anything else. This blog is single-author:
|
|
2
|
+
the seeded account is the only one, so public signups are switched off —
|
|
3
|
+
only the ?auth login may POST to the users API. -->
|
|
4
|
+
<script>
|
|
5
|
+
if (req.path.startsWith('/api/users') && !(req.method === 'POST' && 'auth' in req.query)) {
|
|
6
|
+
return { status: 403, body: 'Single-author blog — signups are disabled.' };
|
|
7
|
+
}
|
|
8
|
+
</script>
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "spark-
|
|
2
|
+
"name": "spark-blog",
|
|
3
3
|
"private": true,
|
|
4
4
|
"version": "0.0.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "bun setup.js && bun spark-ssr",
|
|
8
|
-
"start": "bun spark-ssr",
|
|
8
|
+
"start": "bun spark-ssr start",
|
|
9
9
|
"build": "bun spark-ssr build"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"spark-html": "latest",
|
|
13
|
-
"spark-ssr": "latest"
|
|
13
|
+
"spark-ssr": "latest",
|
|
14
|
+
"spark-html-theme": "latest",
|
|
15
|
+
"spark-html-font": "latest"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"spark-html-image": "latest"
|
|
14
19
|
}
|
|
15
20
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!-- /about — profile straight from the users table, plus an aggregate:
|
|
2
|
+
a COUNT(*) query serves an object, so {stats.n} just works. -->
|
|
3
|
+
<title>About {author.name}</title>
|
|
4
|
+
<meta name="description" content="Who is {author.name} — {author.bio}">
|
|
5
|
+
<link rel="stylesheet" href="/style.css">
|
|
6
|
+
<script type="module" src="/app.js"></script>
|
|
7
|
+
|
|
8
|
+
<div import="/components/nav" blog="{author.name}" active="about"></div>
|
|
9
|
+
|
|
10
|
+
<article class="post">
|
|
11
|
+
<img class="avatar" src="/img/avatar.png" alt="{author.name}" width="88" height="88">
|
|
12
|
+
<h1>About me</h1>
|
|
13
|
+
<p class="lede">{author.bio}</p>
|
|
14
|
+
<p>I've published <strong>{stats.n}</strong> {stats.n === 1 ? 'post' : 'posts'} on this blog so far,
|
|
15
|
+
and I manage all of it from the <a href="/admin">admin panel</a> — which is just another page.</p>
|
|
16
|
+
<p>Say hi: <a href="mailto:{author.email}">{author.email}</a></p>
|
|
17
|
+
</article>
|
|
18
|
+
|
|
19
|
+
<spark-ssr>
|
|
20
|
+
GET /api/author → SELECT id, name, email, bio FROM users LIMIT 1
|
|
21
|
+
GET /api/stats → SELECT COUNT(*) AS n FROM posts WHERE published = 1
|
|
22
|
+
</spark-ssr>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!-- /admin — the author's private side. `session` is always in page scope:
|
|
2
|
+
signed out you get the login form, signed in the management panels.
|
|
3
|
+
The two table declarations give the components their scoped REST APIs
|
|
4
|
+
(posts + todos both carry user_id → session-scoped, 401 for anyone else). -->
|
|
5
|
+
<title>Manage · {author.name}</title>
|
|
6
|
+
<meta name="robots" content="noindex">
|
|
7
|
+
<link rel="stylesheet" href="/style.css">
|
|
8
|
+
<script type="module" src="/app.js"></script>
|
|
9
|
+
|
|
10
|
+
<div import="/components/nav" blog="{author.name}" active="admin"></div>
|
|
11
|
+
|
|
12
|
+
<template if="session">
|
|
13
|
+
<main class="admin">
|
|
14
|
+
<header class="admin-head">
|
|
15
|
+
<p>Signed in as <strong>{session.email}</strong></p>
|
|
16
|
+
<div import="/components/logout-button"></div>
|
|
17
|
+
</header>
|
|
18
|
+
<div import="/components/post-editor"></div>
|
|
19
|
+
<div import="/components/todo-list"></div>
|
|
20
|
+
</main>
|
|
21
|
+
</template>
|
|
22
|
+
<template else>
|
|
23
|
+
<div import="/components/login-form"></div>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<spark-ssr table="posts" />
|
|
27
|
+
<spark-ssr table="todos" />
|
|
28
|
+
<spark-ssr>
|
|
29
|
+
GET /api/author → SELECT id, name, email, bio FROM users LIMIT 1
|
|
30
|
+
</spark-ssr>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<!-- /blog/:slug — the filename IS the route; :slug binds into the query.
|
|
2
|
+
Drafts stay invisible to anonymous readers, but the signed-in author
|
|
3
|
+
gets a live preview (the OR :session.id clause). -->
|
|
4
|
+
<title>{post ? post.title : 'Not found'} · {author.name}</title>
|
|
5
|
+
<meta name="description" content="{post ? post.excerpt : 'This post does not exist.'}">
|
|
6
|
+
<link rel="stylesheet" href="/style.css">
|
|
7
|
+
<script type="module" src="/app.js"></script>
|
|
8
|
+
|
|
9
|
+
<div import="/components/nav" blog="{author.name}"></div>
|
|
10
|
+
|
|
11
|
+
<template if="post">
|
|
12
|
+
<article class="post">
|
|
13
|
+
<h1>{post.title}</h1>
|
|
14
|
+
<p class="meta">
|
|
15
|
+
By {author.name} · {post.created_at}
|
|
16
|
+
<template if="!post.published"><span class="pill draft">draft preview</span></template>
|
|
17
|
+
</p>
|
|
18
|
+
<p class="lede">{post.excerpt}</p>
|
|
19
|
+
<div class="body">{post.body}</div>
|
|
20
|
+
</article>
|
|
21
|
+
</template>
|
|
22
|
+
<template else>
|
|
23
|
+
<article class="post">
|
|
24
|
+
<h1>Not found</h1>
|
|
25
|
+
<p>That post doesn't exist — or it's a draft and you're not signed in.</p>
|
|
26
|
+
<p><a href="/">← Back to all posts</a></p>
|
|
27
|
+
</article>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<spark-ssr>
|
|
31
|
+
GET /api/author → SELECT id, name, email, bio FROM users LIMIT 1
|
|
32
|
+
GET /api/blog/[slug] → SELECT * FROM posts
|
|
33
|
+
WHERE slug = :slug AND (published = 1 OR :session.id IS NOT NULL)
|
|
34
|
+
LIMIT 1
|
|
35
|
+
</spark-ssr>
|
|
@@ -1,19 +1,33 @@
|
|
|
1
|
-
|
|
1
|
+
<!-- The homepage. Everything the page needs is declared at the bottom in
|
|
2
|
+
<spark-ssr>; the title/meta lift into <head> (spark-html-head/ssr);
|
|
3
|
+
app.js is a client module (theme toggle). -->
|
|
4
|
+
<title>{author.name} — a spark blog</title>
|
|
5
|
+
<meta name="description" content="{author.bio}">
|
|
6
|
+
<link rel="stylesheet" href="/style.css">
|
|
7
|
+
<script type="module" src="/app.js"></script>
|
|
2
8
|
|
|
3
|
-
<
|
|
9
|
+
<div import="/components/nav" blog="{author.name}" active="home"></div>
|
|
4
10
|
|
|
5
|
-
<
|
|
6
|
-
<
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
{todo.title}
|
|
13
|
-
<button onclick={remove}>✕</button>
|
|
14
|
-
</li>
|
|
15
|
-
</template>
|
|
16
|
-
</ul>
|
|
17
|
-
</template>
|
|
11
|
+
<header class="hero">
|
|
12
|
+
<img class="avatar" src="/img/avatar.png" alt="{author.name}" width="88" height="88">
|
|
13
|
+
<div>
|
|
14
|
+
<h1>{author.name}</h1>
|
|
15
|
+
<p class="lede">{author.bio}</p>
|
|
16
|
+
</div>
|
|
17
|
+
</header>
|
|
18
18
|
|
|
19
|
-
<
|
|
19
|
+
<main>
|
|
20
|
+
<!-- Server → component data passing: each row travels whole into the
|
|
21
|
+
card as a prop. post-card.html has no <script> — its props ARE it. -->
|
|
22
|
+
<template each="post in posts">
|
|
23
|
+
<div import="/components/post-card" post="{post}"></div>
|
|
24
|
+
</template>
|
|
25
|
+
<template if="posts.length === 0">
|
|
26
|
+
<p class="empty">Nothing published yet — sign in at <a href="/admin">/admin</a> and write the first post.</p>
|
|
27
|
+
</template>
|
|
28
|
+
</main>
|
|
29
|
+
|
|
30
|
+
<spark-ssr>
|
|
31
|
+
GET /api/author → SELECT id, name, email, bio FROM users LIMIT 1
|
|
32
|
+
GET /api/published → SELECT * FROM posts WHERE published = 1 ORDER BY created_at DESC
|
|
33
|
+
</spark-ssr>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The client side of the blog — plain browser modules, no build step.
|
|
2
|
+
// spark-ssr serves each family package at /@modules/* and puts them in the
|
|
3
|
+
// page importmap, so bare imports just work.
|
|
4
|
+
import { theme } from 'spark-html-theme';
|
|
5
|
+
|
|
6
|
+
// Dark/light/system with persistence; the server already inlined the
|
|
7
|
+
// no-flash snippet in <head>, this wires the reactive store + toggle.
|
|
8
|
+
theme();
|
|
Binary file
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/* One stylesheet for the whole blog. Fonts come from spark.json ("fonts" →
|
|
2
|
+
spark-html-font injects @font-face + the --font-inter stack); theming is
|
|
3
|
+
the data-theme attribute spark-html-theme maintains. */
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
--bg: #fdfcf9;
|
|
7
|
+
--panel: #ffffff;
|
|
8
|
+
--text: #1c1917;
|
|
9
|
+
--muted: #78716c;
|
|
10
|
+
--line: #e7e5e4;
|
|
11
|
+
--accent: #b45309;
|
|
12
|
+
--spark: #f59e0b;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
[data-theme="dark"] {
|
|
16
|
+
--bg: #131110;
|
|
17
|
+
--panel: #1c1917;
|
|
18
|
+
--text: #e7e5e4;
|
|
19
|
+
--muted: #a8a29e;
|
|
20
|
+
--line: #292524;
|
|
21
|
+
--accent: #fbbf24;
|
|
22
|
+
--spark: #f59e0b;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
* { box-sizing: border-box; }
|
|
26
|
+
|
|
27
|
+
body {
|
|
28
|
+
margin: 0 auto;
|
|
29
|
+
max-width: 680px;
|
|
30
|
+
padding: 0 1.25rem 4rem;
|
|
31
|
+
font-family: var(--font-inter, system-ui, sans-serif);
|
|
32
|
+
line-height: 1.6;
|
|
33
|
+
background: var(--bg);
|
|
34
|
+
color: var(--text);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
a { color: var(--accent); text-decoration: none; }
|
|
38
|
+
a:hover { text-decoration: underline; }
|
|
39
|
+
h1, h2, h3 { line-height: 1.2; }
|
|
40
|
+
|
|
41
|
+
/* ── nav ── */
|
|
42
|
+
.nav {
|
|
43
|
+
display: flex;
|
|
44
|
+
justify-content: space-between;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: 1rem;
|
|
47
|
+
padding: 1.25rem 0;
|
|
48
|
+
}
|
|
49
|
+
.nav .brand { font-weight: 700; color: var(--text); }
|
|
50
|
+
.nav .links { display: flex; align-items: center; gap: 1rem; }
|
|
51
|
+
.nav .links a.on { color: var(--text); font-weight: 700; }
|
|
52
|
+
|
|
53
|
+
/* ── home ── */
|
|
54
|
+
.hero { display: flex; gap: 1.25rem; align-items: center; margin: 2rem 0 2.5rem; }
|
|
55
|
+
.hero h1 { margin: 0 0 0.25rem; }
|
|
56
|
+
.avatar { border-radius: 50%; }
|
|
57
|
+
.lede { color: var(--muted); margin: 0; }
|
|
58
|
+
|
|
59
|
+
.card {
|
|
60
|
+
background: var(--panel);
|
|
61
|
+
border: 1px solid var(--line);
|
|
62
|
+
border-radius: 10px;
|
|
63
|
+
padding: 1rem 1.25rem;
|
|
64
|
+
margin: 0 0 1rem;
|
|
65
|
+
}
|
|
66
|
+
.card h2 { margin: 0 0 0.35rem; font-size: 1.15rem; }
|
|
67
|
+
.card p { margin: 0 0 0.35rem; color: var(--text); }
|
|
68
|
+
.meta { color: var(--muted); font-size: 0.85rem; }
|
|
69
|
+
.empty { color: var(--muted); }
|
|
70
|
+
|
|
71
|
+
/* ── post page ── */
|
|
72
|
+
.post { margin-top: 2rem; }
|
|
73
|
+
.post .body { white-space: pre-line; margin-top: 1.5rem; }
|
|
74
|
+
.pill {
|
|
75
|
+
font-size: 0.72rem;
|
|
76
|
+
font-weight: 700;
|
|
77
|
+
padding: 0.1rem 0.5rem;
|
|
78
|
+
border-radius: 999px;
|
|
79
|
+
vertical-align: middle;
|
|
80
|
+
}
|
|
81
|
+
.pill.draft { background: var(--spark); color: #1c1917; }
|
|
82
|
+
.pill.live { background: #16a34a; color: #fff; }
|
|
83
|
+
|
|
84
|
+
/* ── admin ── */
|
|
85
|
+
.admin-head { display: flex; justify-content: space-between; align-items: center; }
|
|
86
|
+
.panel {
|
|
87
|
+
background: var(--panel);
|
|
88
|
+
border: 1px solid var(--line);
|
|
89
|
+
border-radius: 10px;
|
|
90
|
+
padding: 1.25rem;
|
|
91
|
+
margin: 0 0 1.25rem;
|
|
92
|
+
}
|
|
93
|
+
.panel h2 { margin-top: 0; }
|
|
94
|
+
.row { display: flex; align-items: center; gap: 0.6rem; margin: 0.4rem 0; }
|
|
95
|
+
.grow { flex: 1; }
|
|
96
|
+
.done { text-decoration: line-through; color: var(--muted); }
|
|
97
|
+
.hint { color: var(--muted); font-size: 0.85rem; }
|
|
98
|
+
.error { color: #dc2626; }
|
|
99
|
+
|
|
100
|
+
input, textarea, button {
|
|
101
|
+
font: inherit;
|
|
102
|
+
color: inherit;
|
|
103
|
+
background: var(--bg);
|
|
104
|
+
border: 1px solid var(--line);
|
|
105
|
+
border-radius: 8px;
|
|
106
|
+
padding: 0.45rem 0.7rem;
|
|
107
|
+
}
|
|
108
|
+
.login { max-width: 380px; margin: 3rem auto; display: grid; gap: 0.75rem; }
|
|
109
|
+
.login label { display: grid; gap: 0.25rem; font-size: 0.9rem; }
|
|
110
|
+
.panel input, .panel textarea { width: 100%; margin: 0.25rem 0; }
|
|
111
|
+
.row input, .row button { width: auto; margin: 0; }
|
|
112
|
+
button { cursor: pointer; background: var(--panel); }
|
|
113
|
+
button:hover { border-color: var(--accent); }
|
|
114
|
+
button:disabled { opacity: 0.5; cursor: default; }
|
|
115
|
+
button.danger:hover { border-color: #dc2626; color: #dc2626; }
|
|
116
|
+
button.ghost, button.toggle { border: none; background: none; padding: 0.2rem; }
|
package/template-ssr/setup.js
CHANGED
|
@@ -3,13 +3,74 @@
|
|
|
3
3
|
import { Database } from 'bun:sqlite';
|
|
4
4
|
|
|
5
5
|
const db = new Database('./dev.db', { create: true });
|
|
6
|
+
|
|
7
|
+
db.run(`CREATE TABLE IF NOT EXISTS users (
|
|
8
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
9
|
+
email TEXT NOT NULL UNIQUE,
|
|
10
|
+
password TEXT NOT NULL,
|
|
11
|
+
name TEXT NOT NULL,
|
|
12
|
+
bio TEXT DEFAULT ''
|
|
13
|
+
)`);
|
|
14
|
+
|
|
15
|
+
db.run(`CREATE TABLE IF NOT EXISTS posts (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
user_id INTEGER NOT NULL,
|
|
18
|
+
slug TEXT NOT NULL UNIQUE,
|
|
19
|
+
title TEXT NOT NULL,
|
|
20
|
+
excerpt TEXT DEFAULT '',
|
|
21
|
+
body TEXT DEFAULT '',
|
|
22
|
+
published INTEGER DEFAULT 0,
|
|
23
|
+
created_at TEXT DEFAULT (date('now'))
|
|
24
|
+
)`);
|
|
25
|
+
|
|
6
26
|
db.run(`CREATE TABLE IF NOT EXISTS todos (
|
|
7
27
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
user_id INTEGER NOT NULL,
|
|
8
29
|
title TEXT NOT NULL,
|
|
9
30
|
done INTEGER DEFAULT 0
|
|
10
31
|
)`);
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
32
|
+
|
|
33
|
+
if (db.query('SELECT COUNT(*) AS n FROM users').get().n === 0) {
|
|
34
|
+
// The author account — sign in at /admin to manage the blog.
|
|
35
|
+
const hash = await Bun.password.hash('spark');
|
|
36
|
+
db.run('INSERT INTO users (email, password, name, bio) VALUES (?, ?, ?, ?)', [
|
|
37
|
+
'me@spark-html.com',
|
|
38
|
+
hash,
|
|
39
|
+
'Ada Spark',
|
|
40
|
+
'I write HTML that reacts. No compiler, no virtual DOM, no build step — just the platform, with a spark.',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const posts = [
|
|
44
|
+
['hello-spark', 'Hello, Spark',
|
|
45
|
+
'Why this blog runs on HTML that infers its own backend.',
|
|
46
|
+
'This site is a handful of .html files and one SQLite database.\n\nEach page declares the data it needs in a <spark-ssr> block, and the server infers the rest: routes from filenames, APIs from SQL, auth from a user_id column.\n\nView source on any page — what you see is what I wrote.',
|
|
47
|
+
1, '2026-06-21'],
|
|
48
|
+
['html-that-reacts', 'HTML that reacts',
|
|
49
|
+
'Templates, loops and conditionals — in plain HTML attributes.',
|
|
50
|
+
'A <template each="post in posts"> renders a list. A <template if> branches. {curly} braces interpolate.\n\nOn the server they render to static HTML; in the browser the same syntax comes alive as components. One mental model, both sides of the wire.',
|
|
51
|
+
1, '2026-06-27'],
|
|
52
|
+
['zero-config-ssr', 'Zero-config SSR',
|
|
53
|
+
'Filesystem routing, sessions, uploads and CRUD — from the template.',
|
|
54
|
+
'pages/blog/[slug].html serves /blog/:slug, and :slug binds straight into the page query.\n\nDeclare <spark-ssr table="todos"> and the REST endpoints exist. Add a user_id column and they are scoped to the signed-in user. Delete the file and it all goes away.',
|
|
55
|
+
1, '2026-07-02'],
|
|
56
|
+
['drafts-are-private', 'Drafts are private',
|
|
57
|
+
'This post is unpublished — only the signed-in author sees it.',
|
|
58
|
+
'The page query keeps drafts out of anonymous requests:\n\nWHERE slug = :slug AND (published = 1 OR :session.id IS NOT NULL)\n\nSign in at /admin and this post appears on the homepage preview and here. Publish it from the admin panel when it is ready.',
|
|
59
|
+
0, '2026-07-04'],
|
|
60
|
+
];
|
|
61
|
+
for (const [slug, title, excerpt, body, published, at] of posts) {
|
|
62
|
+
db.run(
|
|
63
|
+
'INSERT INTO posts (user_id, slug, title, excerpt, body, published, created_at) VALUES (1, ?, ?, ?, ?, ?, ?)',
|
|
64
|
+
[slug, title, excerpt, body, published, at],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
db.run(`INSERT INTO todos (user_id, title, done) VALUES
|
|
69
|
+
(1, 'Finish the drafts post', 1),
|
|
70
|
+
(1, 'Write about companion packages', 0),
|
|
71
|
+
(1, 'Reply to reader mail', 0)`);
|
|
72
|
+
|
|
73
|
+
console.log('⚡ seeded dev.db — sign in at /admin with me@spark-html.com / spark');
|
|
14
74
|
}
|
|
75
|
+
|
|
15
76
|
db.close();
|
package/template-ssr/spark.json
CHANGED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 3rem auto; padding: 0 1.5rem; }
|
|
2
|
-
nav { opacity: 0.7; margin-bottom: 2rem; }
|
|
3
|
-
ul { padding: 0; }
|
|
4
|
-
li { list-style: none; display: flex; gap: 0.5rem; align-items: center; padding: 0.35rem 0; }
|
|
5
|
-
li button { margin-left: auto; }
|
|
6
|
-
input[type="text"], input:not([type]) { padding: 0.4rem 0.6rem; }
|