create-spark-html-app 0.9.0 → 0.11.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 +63 -15
- 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 +56 -0
- package/template-ssr/middleware.html +8 -0
- package/template-ssr/package.json +10 -4
- package/template-ssr/pages/_layout.html +14 -0
- package/template-ssr/pages/about.html +17 -0
- package/template-ssr/pages/admin/index.html +25 -0
- package/template-ssr/pages/blog/[slug].html +31 -0
- package/template-ssr/pages/index.html +25 -16
- package/template-ssr/pages/login.html +19 -0
- 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/seed/posts.json +38 -0
- package/template-ssr/seed/todos.json +5 -0
- package/template-ssr/seed/users.json +8 -0
- package/template-ssr/spark.json +3 -1
- package/template-ssr/pages/index.css +0 -6
- package/template-ssr/setup.js +0 -15
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,77 @@
|
|
|
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 live-updating private todo list. All of it is a handful of `.html`
|
|
6
|
+
files and one SQLite database. **No build step. No setup script. No fetch
|
|
7
|
+
calls for the basics.**
|
|
7
8
|
|
|
8
|
-
##
|
|
9
|
+
## Run it
|
|
9
10
|
|
|
10
11
|
```bash
|
|
11
12
|
bun install
|
|
12
|
-
bun run dev # creates + seeds
|
|
13
|
+
bun run dev # creates + seeds the DB from the templates, serves on :3000
|
|
13
14
|
```
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
Sign in at [/login](http://localhost:3000/login) with
|
|
17
|
+
**me@spark-html.com** / **spark** — a plain form; it works with JavaScript
|
|
18
|
+
disabled.
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
- **Component** — `components/nav.html` is pure UI via `<div import>`
|
|
19
|
-
- **Config** — `spark.json` holds the DB connection (swap sqlite → postgres
|
|
20
|
-
by changing one line)
|
|
20
|
+
## What's demonstrated where
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
| Feature | Where |
|
|
23
|
+
|---|---|
|
|
24
|
+
| Layouts | `pages/_layout.html` — nav, styles, `app.js` and the `author` query, once for every page |
|
|
25
|
+
| Filesystem routing | `pages/index.html` → `/`, `pages/about.html` → `/about` |
|
|
26
|
+
| Dynamic routes | `pages/blog/[slug].html` → `/blog/:slug` — `:slug` binds into the SQL |
|
|
27
|
+
| Named page data | `posts = SELECT …` — the variable is named in the block, no endpoint exposed |
|
|
28
|
+
| Real 404s | `<template else status="404">` on the missing-post branch |
|
|
29
|
+
| Guards | `/admin`: `<spark-ssr guard="session" redirect="/login" />` — one line |
|
|
30
|
+
| No-JS forms | `pages/login.html` and the logout button — plain forms, 303 back |
|
|
31
|
+
| Form validation | the login form's `required`/`type="email"` run server-side too |
|
|
32
|
+
| The template is the schema | `seed/*.json` + `<spark-ssr table="…" seed="…">` — tables created and seeded at startup; `bun run db` shows the diff |
|
|
33
|
+
| Live updates | `<spark-ssr table="todos" … live />` — open /admin in two tabs |
|
|
34
|
+
| Per-page `<title>`/`<meta>` | literal tags at the top of each page, `{expr}`-interpolated |
|
|
35
|
+
| SEO | og:title/og:description derive from the head; `/sitemap.xml` enumerates `/blog/:slug` from its query; `/robots.txt` honors the admin page's `noindex` |
|
|
36
|
+
| Server → component props | `index.html` passes each whole `post` row into `post-card.html` |
|
|
37
|
+
| Client components | `post-editor`, `todo-list` — their `<script>` runs in the browser |
|
|
38
|
+
| Auth | `spark.json` `"auth"` → sessions, `POST /api/users?auth` login, hashed passwords |
|
|
39
|
+
| Auth-scoped CRUD | `posts` and `todos` carry `user_id` → their APIs are session-scoped (401 anonymous) |
|
|
40
|
+
| Draft privacy | the `[slug]` query: `published = 1 OR :session.id IS NOT NULL` — authors preview drafts |
|
|
41
|
+
| Middleware | `middleware.html` disables public signups (single-author blog) |
|
|
42
|
+
| Aggregates | `about.html`'s `COUNT(*)` serves an object → `{stats.n}` |
|
|
43
|
+
| Dark/light theme | spark-html-theme — `app.js` one-liner + `theme-toggle.html`; no-flash init is auto-inlined |
|
|
44
|
+
| Fonts | spark.json `"fonts"` → spark-html-font tags in every `<head>` |
|
|
45
|
+
| Images | `bun run build` runs spark-html-image over `dist/` — webp + srcset |
|
|
46
|
+
|
|
47
|
+
## The mental model
|
|
48
|
+
|
|
49
|
+
- `pages/_layout.html` wraps every page in the folder; `<slot>` is the page.
|
|
50
|
+
Its `<spark-ssr>` vars are in scope everywhere it wraps.
|
|
51
|
+
- A **page**'s plain `<script>` runs on the **server** (it's the escape
|
|
52
|
+
hatch); `<script type="module">`/`src` scripts ship to the browser.
|
|
53
|
+
- A **component**'s `<script>` runs in the **browser**. Components are
|
|
54
|
+
otherwise pure UI: they render the props they're given.
|
|
55
|
+
- `<spark-ssr>` declares data. `var = SELECT …` names page data;
|
|
56
|
+
`table="…"` gives you scoped REST CRUD (+ `seed`, `live`, `limit`,
|
|
57
|
+
`search`); `guard="…"` protects the page.
|
|
58
|
+
- Plain forms to `/api/*` work without JavaScript — success 303s back,
|
|
59
|
+
the markup's constraint attributes validate on the server.
|
|
60
|
+
- Everything hot-reloads — pages, layouts, components, queries, middleware
|
|
61
|
+
— and dev errors land on the page, not in a bare 500. Open
|
|
62
|
+
[/__spark/plan](http://localhost:3000/__spark/plan) to see the inferred
|
|
63
|
+
backend.
|
|
24
64
|
|
|
25
65
|
## Deploy
|
|
26
66
|
|
|
27
67
|
```bash
|
|
28
|
-
bun run build
|
|
68
|
+
bun run build # dist/ + compiled binary; images optimized
|
|
69
|
+
bun spark-ssr build --docker # …plus a Dockerfile
|
|
70
|
+
PORT=3000 ./dist/app
|
|
29
71
|
```
|
|
72
|
+
|
|
73
|
+
Set a fixed session secret in production so logins survive restarts:
|
|
74
|
+
`"auth": { …, "secret": "ENV.SESSION_SECRET" }` in spark.json.
|
|
75
|
+
|
|
76
|
+
Swap SQLite for Postgres by changing one line in spark.json:
|
|
77
|
+
`"db": "postgres://…"`.
|
|
@@ -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,56 @@
|
|
|
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). The todos block
|
|
4
|
+
declares `live`, so writes ping /__spark/live: the two lines at the
|
|
5
|
+
bottom keep every open tab (or the author's phone) in sync. -->
|
|
6
|
+
<section class="panel">
|
|
7
|
+
<h2>Private todos</h2>
|
|
8
|
+
<div class="row">
|
|
9
|
+
<input class="grow" bind:value="draft" placeholder="Something to do">
|
|
10
|
+
<button onclick={add} :disabled="!draft">Add</button>
|
|
11
|
+
</div>
|
|
12
|
+
<template each="t in todos" key="t.id">
|
|
13
|
+
<label class="row">
|
|
14
|
+
<input type="checkbox" bind:checked="t.done" onchange={toggle(t)}>
|
|
15
|
+
<span :class="t.done ? 'grow done' : 'grow'">{t.title}</span>
|
|
16
|
+
<button class="danger" onclick={remove(t)}>✕</button>
|
|
17
|
+
</label>
|
|
18
|
+
</template>
|
|
19
|
+
</section>
|
|
20
|
+
|
|
21
|
+
<script>
|
|
22
|
+
let todos = [];
|
|
23
|
+
let draft = '';
|
|
24
|
+
|
|
25
|
+
async function api(path, method, data) {
|
|
26
|
+
return fetch(path, {
|
|
27
|
+
method,
|
|
28
|
+
headers: { 'content-type': 'application/json' },
|
|
29
|
+
body: JSON.stringify(data),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function refresh() {
|
|
34
|
+
todos = await (await fetch('/api/todos')).json();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function add() {
|
|
38
|
+
await api('/api/todos', 'POST', { title: draft });
|
|
39
|
+
draft = '';
|
|
40
|
+
refresh();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function toggle(t) {
|
|
44
|
+
await api(`/api/todos/${t.id}`, 'PATCH', { done: t.done ? 1 : 0 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function remove(t) {
|
|
48
|
+
await api(`/api/todos/${t.id}`, 'DELETE');
|
|
49
|
+
refresh();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
refresh();
|
|
53
|
+
|
|
54
|
+
const es = new EventSource('/__spark/live');
|
|
55
|
+
es.onmessage = (e) => { if (e.data === 'todos') refresh(); };
|
|
56
|
+
</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,21 @@
|
|
|
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
|
-
"dev": "bun
|
|
8
|
-
"
|
|
7
|
+
"dev": "bun spark-ssr",
|
|
8
|
+
"db": "bun spark-ssr db",
|
|
9
|
+
"start": "bun spark-ssr start",
|
|
9
10
|
"build": "bun spark-ssr build"
|
|
10
11
|
},
|
|
11
12
|
"dependencies": {
|
|
12
13
|
"spark-html": "latest",
|
|
13
|
-
"spark-ssr": "latest"
|
|
14
|
+
"spark-ssr": "latest",
|
|
15
|
+
"spark-html-theme": "latest",
|
|
16
|
+
"spark-html-font": "latest"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"spark-html-image": "latest"
|
|
14
20
|
}
|
|
15
21
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!-- The layout: every page in pages/ renders inside it — <slot> is the page.
|
|
2
|
+
Its <spark-ssr> vars (author) are in scope for every page it wraps, and
|
|
3
|
+
its head tags lift into <head> too (a page's own tags win on conflict).
|
|
4
|
+
The three lines every page used to repeat live here, once. -->
|
|
5
|
+
<link rel="stylesheet" href="/style.css">
|
|
6
|
+
<script type="module" src="/app.js"></script>
|
|
7
|
+
|
|
8
|
+
<div import="/components/nav" blog="{author.name}"></div>
|
|
9
|
+
|
|
10
|
+
<slot></slot>
|
|
11
|
+
|
|
12
|
+
<spark-ssr>
|
|
13
|
+
author = SELECT id, name, email, bio FROM users LIMIT 1
|
|
14
|
+
</spark-ssr>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!-- /about — {author} comes from the layout; this page adds one 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
|
+
|
|
6
|
+
<article class="post">
|
|
7
|
+
<img class="avatar" src="/img/avatar.png" alt="{author.name}" width="88" height="88">
|
|
8
|
+
<h1>About me</h1>
|
|
9
|
+
<p class="lede">{author.bio}</p>
|
|
10
|
+
<p>I've published <strong>{stats.n}</strong> {stats.n === 1 ? 'post' : 'posts'} on this blog so far,
|
|
11
|
+
and I manage all of it from the <a href="/admin">admin panel</a> — which is just another page.</p>
|
|
12
|
+
<p>Say hi: <a href="mailto:{author.email}">{author.email}</a></p>
|
|
13
|
+
</article>
|
|
14
|
+
|
|
15
|
+
<spark-ssr>
|
|
16
|
+
stats = SELECT COUNT(*) AS n FROM posts WHERE published = 1
|
|
17
|
+
</spark-ssr>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!-- /admin — one guard line protects the whole page: signed out → 303 to
|
|
2
|
+
/login. The table blocks give the components their scoped REST APIs
|
|
3
|
+
(posts + todos carry user_id → session-scoped, 401 for anyone else);
|
|
4
|
+
the seed="…" files define AND fill the tables on first run — there is
|
|
5
|
+
no setup.js — and `live` keeps every open todos tab in sync. -->
|
|
6
|
+
<title>Manage · {author.name}</title>
|
|
7
|
+
<meta name="robots" content="noindex">
|
|
8
|
+
|
|
9
|
+
<spark-ssr guard="session" redirect="/login" />
|
|
10
|
+
|
|
11
|
+
<main class="admin">
|
|
12
|
+
<header class="admin-head">
|
|
13
|
+
<p>Signed in as <strong>{session.email}</strong></p>
|
|
14
|
+
<!-- Logout is a plain form too — works with JavaScript disabled. -->
|
|
15
|
+
<form action="/api/logout" method="post" redirect="/">
|
|
16
|
+
<button class="ghost">Sign out</button>
|
|
17
|
+
</form>
|
|
18
|
+
</header>
|
|
19
|
+
<div import="/components/post-editor"></div>
|
|
20
|
+
<div import="/components/todo-list"></div>
|
|
21
|
+
</main>
|
|
22
|
+
|
|
23
|
+
<spark-ssr table="users" seed="./seed/users.json" />
|
|
24
|
+
<spark-ssr table="posts" seed="./seed/posts.json" />
|
|
25
|
+
<spark-ssr table="todos" seed="./seed/todos.json" live />
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<!-- /blog/:slug — the filename IS the route; :slug binds into the query.
|
|
2
|
+
A missing row is a REAL 404 (the status= on the rendered branch), and
|
|
3
|
+
drafts stay invisible to anonymous readers while the signed-in author
|
|
4
|
+
gets a live preview (the OR :session.id clause). -->
|
|
5
|
+
<title>{post ? post.title : 'Not found'} · {author.name}</title>
|
|
6
|
+
<meta name="description" content="{post ? post.excerpt : 'This post does not exist.'}">
|
|
7
|
+
|
|
8
|
+
<template if="post">
|
|
9
|
+
<article class="post">
|
|
10
|
+
<h1>{post.title}</h1>
|
|
11
|
+
<p class="meta">
|
|
12
|
+
By {author.name} · {post.created_at}
|
|
13
|
+
<template if="!post.published"><span class="pill draft">draft preview</span></template>
|
|
14
|
+
</p>
|
|
15
|
+
<p class="lede">{post.excerpt}</p>
|
|
16
|
+
<div class="body">{post.body}</div>
|
|
17
|
+
</article>
|
|
18
|
+
</template>
|
|
19
|
+
<template else status="404">
|
|
20
|
+
<article class="post">
|
|
21
|
+
<h1>Not found</h1>
|
|
22
|
+
<p>That post doesn't exist — or it's a draft and you're not signed in.</p>
|
|
23
|
+
<p><a href="/">← Back to all posts</a></p>
|
|
24
|
+
</article>
|
|
25
|
+
</template>
|
|
26
|
+
|
|
27
|
+
<spark-ssr>
|
|
28
|
+
post = SELECT * FROM posts
|
|
29
|
+
WHERE slug = :slug AND (published = 1 OR :session.id IS NOT NULL)
|
|
30
|
+
LIMIT 1
|
|
31
|
+
</spark-ssr>
|
|
@@ -1,19 +1,28 @@
|
|
|
1
|
-
|
|
1
|
+
<!-- The homepage. The layout owns the chrome and provides {author}; this
|
|
2
|
+
page declares only what's unique to it. `posts = SELECT …` is page data
|
|
3
|
+
with NO public endpoint — name the variable, skip the route. -->
|
|
4
|
+
<title>{author.name} — a spark blog</title>
|
|
5
|
+
<meta name="description" content="{author.bio}">
|
|
2
6
|
|
|
3
|
-
<
|
|
7
|
+
<header class="hero">
|
|
8
|
+
<img class="avatar" src="/img/avatar.png" alt="{author.name}" width="88" height="88">
|
|
9
|
+
<div>
|
|
10
|
+
<h1>{author.name}</h1>
|
|
11
|
+
<p class="lede">{author.bio}</p>
|
|
12
|
+
</div>
|
|
13
|
+
</header>
|
|
4
14
|
|
|
5
|
-
<
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
<
|
|
9
|
-
<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
</template>
|
|
16
|
-
</ul>
|
|
17
|
-
</template>
|
|
15
|
+
<main>
|
|
16
|
+
<!-- Server → component data passing: each row travels whole into the
|
|
17
|
+
card as a prop. post-card.html has no <script> — its props ARE it. -->
|
|
18
|
+
<template each="post in posts">
|
|
19
|
+
<div import="/components/post-card" post="{post}"></div>
|
|
20
|
+
</template>
|
|
21
|
+
<template if="posts.length === 0">
|
|
22
|
+
<p class="empty">Nothing published yet — sign in at <a href="/admin">/admin</a> and write the first post.</p>
|
|
23
|
+
</template>
|
|
24
|
+
</main>
|
|
18
25
|
|
|
19
|
-
<spark-ssr
|
|
26
|
+
<spark-ssr>
|
|
27
|
+
posts = SELECT * FROM posts WHERE published = 1 ORDER BY created_at DESC
|
|
28
|
+
</spark-ssr>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!-- Sign in with a PLAIN FORM — no JavaScript required. The server answers
|
|
2
|
+
a browser like a browser: success 303s to redirect="/admin"; a wrong
|
|
3
|
+
password re-renders this page with {errors} in scope. The `required`
|
|
4
|
+
attributes are enforced server-side too — the form is the validator. -->
|
|
5
|
+
<title>Sign in · {author.name}</title>
|
|
6
|
+
<meta name="robots" content="noindex">
|
|
7
|
+
|
|
8
|
+
<form class="panel login" action="/api/users?auth" method="post" redirect="/admin">
|
|
9
|
+
<h2>Author sign-in</h2>
|
|
10
|
+
<label>Email
|
|
11
|
+
<input type="email" name="email" autocomplete="username" required>
|
|
12
|
+
</label>
|
|
13
|
+
<label>Password
|
|
14
|
+
<input type="password" name="password" autocomplete="current-password" required>
|
|
15
|
+
</label>
|
|
16
|
+
<button>Sign in</button>
|
|
17
|
+
<template if="errors"><p class="error">Wrong email or password.</p></template>
|
|
18
|
+
<p class="hint">Seeded account: me@spark-html.com / spark</p>
|
|
19
|
+
</form>
|
|
@@ -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; }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"user_id": 1,
|
|
4
|
+
"slug": "hello-spark",
|
|
5
|
+
"title": "Hello, Spark",
|
|
6
|
+
"excerpt": "Why this blog runs on HTML that infers its own backend.",
|
|
7
|
+
"body": "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 — even these tables and rows came from seed files the templates point at.\n\nView source on any page — what you see is what I wrote.",
|
|
8
|
+
"published": 1,
|
|
9
|
+
"created_at": "2026-06-21"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"user_id": 1,
|
|
13
|
+
"slug": "html-that-reacts",
|
|
14
|
+
"title": "HTML that reacts",
|
|
15
|
+
"excerpt": "Templates, loops and conditionals — in plain HTML attributes.",
|
|
16
|
+
"body": "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.",
|
|
17
|
+
"published": 1,
|
|
18
|
+
"created_at": "2026-06-27"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"user_id": 1,
|
|
22
|
+
"slug": "zero-config-ssr",
|
|
23
|
+
"title": "Zero-config SSR",
|
|
24
|
+
"excerpt": "Filesystem routing, layouts, sessions, live updates — from the template.",
|
|
25
|
+
"body": "pages/blog/[slug].html serves /blog/:slug, and :slug binds straight into the page query. pages/_layout.html wraps every page — nav, styles, footer, once.\n\nDeclare <spark-ssr table=\"todos\" live /> and the REST endpoints exist, the table is created from the template, and every open tab stays in sync. Delete the file and it all goes away.",
|
|
26
|
+
"published": 1,
|
|
27
|
+
"created_at": "2026-07-02"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"user_id": 1,
|
|
31
|
+
"slug": "drafts-are-private",
|
|
32
|
+
"title": "Drafts are private",
|
|
33
|
+
"excerpt": "This post is unpublished — only the signed-in author sees it.",
|
|
34
|
+
"body": "The page query keeps drafts out of anonymous requests:\n\nWHERE slug = :slug AND (published = 1 OR :session.id IS NOT NULL)\n\nAnd a missing row is a REAL 404 now — <template else status=\"404\"> sets the response status, so crawlers never index an empty page. Sign in at /admin and this post appears.",
|
|
35
|
+
"published": 0,
|
|
36
|
+
"created_at": "2026-07-04"
|
|
37
|
+
}
|
|
38
|
+
]
|
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; }
|
package/template-ssr/setup.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
// One-time (idempotent) dev database setup — `bun run dev` runs it for you.
|
|
2
|
-
// Swap spark.json's db to postgres:// any time; no code changes needed.
|
|
3
|
-
import { Database } from 'bun:sqlite';
|
|
4
|
-
|
|
5
|
-
const db = new Database('./dev.db', { create: true });
|
|
6
|
-
db.run(`CREATE TABLE IF NOT EXISTS todos (
|
|
7
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
8
|
-
title TEXT NOT NULL,
|
|
9
|
-
done INTEGER DEFAULT 0
|
|
10
|
-
)`);
|
|
11
|
-
if (db.query('SELECT COUNT(*) AS n FROM todos').get().n === 0) {
|
|
12
|
-
db.run("INSERT INTO todos (title) VALUES ('Try spark-ssr'), ('Edit pages/index.html'), ('Ship it')");
|
|
13
|
-
console.log('⚡ seeded dev.db with a few todos');
|
|
14
|
-
}
|
|
15
|
-
db.close();
|