create-spark-html-app 0.8.3 → 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/README.md +19 -1
- package/bin/index.js +40 -12
- package/package.json +5 -3
- package/template/README.md +2 -2
- package/template/public/components/footer.html +1 -1
- package/template-prerender/README.md +13 -0
- package/template-prerender/_gitignore +2 -0
- package/template-prerender/index.html +13 -0
- package/template-prerender/package.json +18 -0
- package/template-prerender/public/components/hero.html +14 -0
- package/template-prerender/spark.config.js +9 -0
- package/template-prerender/src/main.js +2 -0
- package/template-prerender/src/style.css +3 -0
- package/template-ssr/404.html +9 -0
- package/template-ssr/README.md +61 -0
- package/template-ssr/_gitignore +4 -0
- package/template-ssr/components/login-form.html +38 -0
- package/template-ssr/components/logout-button.html +8 -0
- package/template-ssr/components/nav.html +11 -0
- 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 +20 -0
- 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 +33 -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/setup.js +76 -0
- package/template-ssr/spark.json +5 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# create-spark-html-app
|
|
2
2
|
|
|
3
|
-
Scaffold a [Spark](https://github.com/wilkinnovo/spark) app in seconds — a
|
|
3
|
+
Scaffold a [Spark](https://github.com/wilkinnovo/spark-html) app in seconds — a
|
|
4
4
|
Bun-powered project (dev / build / preview via `spark-html-bun`) wired to
|
|
5
5
|
`spark-html` with live, reactive **Spark** components.
|
|
6
6
|
|
|
@@ -24,6 +24,24 @@ Run it with no name to be prompted:
|
|
|
24
24
|
bunx create-spark-html-app@latest
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
## Project types — SSR & Prerender
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bunx create-spark-html-app@latest myapp # client-only (default)
|
|
31
|
+
bunx create-spark-html-app@latest myapp --ssr # SSR with spark-ssr
|
|
32
|
+
bunx create-spark-html-app@latest myapp --prerender # static site with spark-prerender
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Without a flag (on a TTY) an interactive picker asks which one you want.
|
|
36
|
+
|
|
37
|
+
`--ssr` scaffolds the three-tier pattern — **pages** declare data with
|
|
38
|
+
`<spark-ssr>`, **components** are pure UI via `<div import>`, **spark.json**
|
|
39
|
+
holds the DB connection — plus a seeded SQLite dev database. No build step:
|
|
40
|
+
`bun run dev` and it serves.
|
|
41
|
+
|
|
42
|
+
`--prerender` scaffolds a minimal static site whose build writes
|
|
43
|
+
fully-rendered HTML into `dist/` via `spark-prerender`.
|
|
44
|
+
|
|
27
45
|
## What you get
|
|
28
46
|
|
|
29
47
|
The scaffold comes with the **whole Spark ecosystem pre-wired** — you delete
|
package/bin/index.js
CHANGED
|
@@ -27,7 +27,8 @@ import { createInterface } from 'node:readline/promises';
|
|
|
27
27
|
import { stdin, stdout, argv, exit } from 'node:process';
|
|
28
28
|
|
|
29
29
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
|
-
const
|
|
30
|
+
const templateFor = (type) =>
|
|
31
|
+
resolve(here, '..', type === 'client' ? 'template' : `template-${type}`);
|
|
31
32
|
|
|
32
33
|
// ── tiny ANSI palette (no chalk; one less thing to install) ───────────
|
|
33
34
|
const supportsColor = stdout.isTTY && process.env.NO_COLOR === undefined;
|
|
@@ -99,6 +100,28 @@ async function prompt(question, fallback) {
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
// ── project type ────────────────────────────────────────────────────────
|
|
104
|
+
// Client-only (the existing default), SSR (spark-ssr), or a prerendered
|
|
105
|
+
// static site (spark-prerender). Flags: --ssr / --prerender; otherwise an
|
|
106
|
+
// interactive picker on a TTY.
|
|
107
|
+
const TYPES = [
|
|
108
|
+
{ key: 'client', label: 'Client-only (default)' },
|
|
109
|
+
{ key: 'ssr', label: 'SSR (spark-ssr)' },
|
|
110
|
+
{ key: 'prerender', label: 'Prerender (spark-prerender)' },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
async function pickType() {
|
|
114
|
+
const flags = argv.slice(2);
|
|
115
|
+
if (flags.includes('--ssr')) return 'ssr';
|
|
116
|
+
if (flags.includes('--prerender')) return 'prerender';
|
|
117
|
+
if (flags.includes('--client')) return 'client';
|
|
118
|
+
if (!stdin.isTTY) return 'client';
|
|
119
|
+
stdout.write(`${c.accent('?')} Project type:\n`);
|
|
120
|
+
TYPES.forEach((t, i) => stdout.write(` ${c.dim(String(i + 1) + ')')} ${t.label}\n`));
|
|
121
|
+
const a = (await prompt(`${c.accent('?')} Pick one ${c.dim('(1)')} `, '1')).trim();
|
|
122
|
+
return (TYPES[Number(a) - 1] || TYPES[0]).key;
|
|
123
|
+
}
|
|
124
|
+
|
|
102
125
|
// ── optional features ──────────────────────────────────────────────────
|
|
103
126
|
// The template ships with EVERYTHING wired; excluded features are stripped
|
|
104
127
|
// out of the copied files via `@spark:<name>` … `@spark:end` marker blocks
|
|
@@ -185,7 +208,7 @@ async function main() {
|
|
|
185
208
|
stdout.write(`${c.dim(' HTML that reacts. Built for humans — no compiler, no virtual DOM.')}\n\n`);
|
|
186
209
|
|
|
187
210
|
// 1 ─ figure out the target directory ────────────────────────────────
|
|
188
|
-
let targetArg = argv
|
|
211
|
+
let targetArg = argv.slice(2).find((a) => !a.startsWith('-'));
|
|
189
212
|
if (!targetArg) {
|
|
190
213
|
if (!stdin.isTTY) bail('Please pass a project name: create-spark-html-app <name>');
|
|
191
214
|
targetArg = await prompt(
|
|
@@ -207,11 +230,12 @@ async function main() {
|
|
|
207
230
|
if (!/^y(es)?$/i.test(ok)) bail('Aborted — nothing was written.');
|
|
208
231
|
}
|
|
209
232
|
|
|
210
|
-
// 3 ─ pick features, copy the template
|
|
211
|
-
const
|
|
233
|
+
// 3 ─ pick the project type + features, copy the template ────────────
|
|
234
|
+
const type = await pickType();
|
|
235
|
+
const features = type === 'client' ? await pickFeatures() : {};
|
|
212
236
|
mkdirSync(targetDir, { recursive: true });
|
|
213
|
-
cpSync(
|
|
214
|
-
applyFeatures(targetDir, features);
|
|
237
|
+
cpSync(templateFor(type), targetDir, { recursive: true });
|
|
238
|
+
if (type === 'client') applyFeatures(targetDir, features);
|
|
215
239
|
|
|
216
240
|
// npm renames/strips dotfiles on publish, so the template ships them
|
|
217
241
|
// with safe underscore prefixes. Restore the real names here.
|
|
@@ -229,7 +253,7 @@ async function main() {
|
|
|
229
253
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
230
254
|
pkg.name = projectName;
|
|
231
255
|
// Drop dependencies that belong to excluded features.
|
|
232
|
-
for (const f of FEATURES) {
|
|
256
|
+
for (const f of type === 'client' ? FEATURES : []) {
|
|
233
257
|
if (features[f.key]) continue;
|
|
234
258
|
for (const dep of f.deps) {
|
|
235
259
|
if (pkg.dependencies) delete pkg.dependencies[dep];
|
|
@@ -248,7 +272,8 @@ async function main() {
|
|
|
248
272
|
const deps = pkg[group];
|
|
249
273
|
if (!deps) continue;
|
|
250
274
|
for (const name of Object.keys(deps)) {
|
|
251
|
-
if (name !== 'spark-html' && !name.startsWith('spark-html-')
|
|
275
|
+
if (name !== 'spark-html' && !name.startsWith('spark-html-')
|
|
276
|
+
&& name !== 'spark-prerender' && name !== 'spark-ssr') continue;
|
|
252
277
|
const range = await latestRange(name);
|
|
253
278
|
if (range) {
|
|
254
279
|
deps[name] = range;
|
|
@@ -260,13 +285,16 @@ async function main() {
|
|
|
260
285
|
|
|
261
286
|
// 5 ─ celebrate + print next steps ───────────────────────────────────
|
|
262
287
|
const rel = relative(process.cwd(), targetDir) || '.';
|
|
263
|
-
const
|
|
264
|
-
|
|
288
|
+
const flavor = type === 'ssr' ? 'SSR — spark-ssr, zero config, no build'
|
|
289
|
+
: type === 'prerender' ? 'prerendered static site — spark-prerender'
|
|
290
|
+
: `head, persist, prerender, devtools + ${FEATURES.filter((f) => features[f.key]).map((f) => f.key).join(', ') || 'core only'}`;
|
|
291
|
+
stdout.write(`\n${c.green('✔')} Scaffolded ${c.bold(projectName)} in ${c.cyan(rel)} ${c.dim(`(${flavor})`)}\n\n`);
|
|
265
292
|
stdout.write(`${c.bold('Next steps:')}\n`);
|
|
266
293
|
if (rel !== '.') stdout.write(` ${c.dim('1.')} cd ${rel}\n`);
|
|
267
294
|
stdout.write(` ${c.dim(rel !== '.' ? '2.' : '1.')} bun install\n`);
|
|
268
|
-
stdout.write(` ${c.dim(rel !== '.' ? '3.' : '2.')} bun dev\n\n`);
|
|
269
|
-
|
|
295
|
+
stdout.write(` ${c.dim(rel !== '.' ? '3.' : '2.')} bun run dev\n\n`);
|
|
296
|
+
const editHint = type === 'ssr' ? 'pages/index.html' : 'public/components/hero.html';
|
|
297
|
+
stdout.write(`${BOLT} Then open the dev server and edit ${c.cyan(editHint)}.\n\n`);
|
|
270
298
|
}
|
|
271
299
|
|
|
272
300
|
main().catch((err) => bail(err?.message || String(err)));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-spark-html-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Scaffold a spark-html app — dev/build/preview on Bun",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,14 +8,16 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
|
-
"template"
|
|
11
|
+
"template",
|
|
12
|
+
"template-ssr",
|
|
13
|
+
"template-prerender"
|
|
12
14
|
],
|
|
13
15
|
"engines": {
|
|
14
16
|
"node": ">=18"
|
|
15
17
|
},
|
|
16
18
|
"repository": {
|
|
17
19
|
"type": "git",
|
|
18
|
-
"url": "git+https://github.com/wilkinnovo/spark.git",
|
|
20
|
+
"url": "git+https://github.com/wilkinnovo/spark-html.git",
|
|
19
21
|
"directory": "packages/create-spark-html-app"
|
|
20
22
|
},
|
|
21
23
|
"keywords": [
|
package/template/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ⚡ Spark App
|
|
2
2
|
|
|
3
|
-
A starter built with [spark-html](https://github.com/wilkinnovo/spark) — single-file
|
|
3
|
+
A starter built with [spark-html](https://github.com/wilkinnovo/spark-html) — single-file
|
|
4
4
|
HTML components with built-in reactivity. No compiler, no virtual DOM, no build step.
|
|
5
5
|
|
|
6
6
|
The scaffold is a **multi-page SPA** with client-side routing, live demos, and a
|
|
@@ -72,5 +72,5 @@ Derive values with `$:`, share state across components with `useStore(name)`, us
|
|
|
72
72
|
`bind:value` for two-way binds, and pass props as attributes on the `import`
|
|
73
73
|
placeholder.
|
|
74
74
|
|
|
75
|
-
See the [full docs](https://wilkinnovo.github.io/spark/docs) for the complete
|
|
75
|
+
See the [full docs](https://wilkinnovo.github.io/spark-html/docs) for the complete
|
|
76
76
|
template syntax reference.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<footer class="foot">
|
|
2
2
|
Edit any file in <code>public/components/</code> and save — the page updates
|
|
3
3
|
instantly. Built with
|
|
4
|
-
<a href="https://github.com/wilkinnovo/spark" target="_blank" rel="noopener">Spark</a>.
|
|
4
|
+
<a href="https://github.com/wilkinnovo/spark-html" target="_blank" rel="noopener">Spark</a>.
|
|
5
5
|
</footer>
|
|
6
6
|
|
|
7
7
|
<style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# ⚡ Spark Static App
|
|
2
|
+
|
|
3
|
+
A prerendered static site built with
|
|
4
|
+
[spark-html](https://github.com/wilkinnovo/spark-html) + `spark-prerender`.
|
|
5
|
+
Components stay single-file HTML; the build writes fully-rendered pages into
|
|
6
|
+
`dist/` (plus sitemap/robots hooks) and the browser hydrates over them.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
bun install
|
|
10
|
+
bun run dev # dev server with HMR
|
|
11
|
+
bun run build # prerendered static output → dist/, deploy anywhere
|
|
12
|
+
bun run preview # preview the production build
|
|
13
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Spark Static App</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div import="components/hero"></div>
|
|
11
|
+
<script type="module" src="/src/main.js"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spark-static-app",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "spark dev",
|
|
8
|
+
"build": "spark build",
|
|
9
|
+
"preview": "spark preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"spark-html": "latest"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"spark-html-bun": "latest",
|
|
16
|
+
"spark-prerender": "latest"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<main>
|
|
2
|
+
<h1>⚡ {greeting}</h1>
|
|
3
|
+
<p>This page is prerendered at build time — view-source on the deployed
|
|
4
|
+
site and the content is right there. Edit this component and save.</p>
|
|
5
|
+
<button onclick={cheer}>Clicked {count} times</button>
|
|
6
|
+
</main>
|
|
7
|
+
|
|
8
|
+
<script>
|
|
9
|
+
let greeting = 'HTML that reacts';
|
|
10
|
+
let count = 0;
|
|
11
|
+
let pageTitle = 'Spark Static App';
|
|
12
|
+
let pageDescription = 'A prerendered spark-html starter — SEO-ready static HTML that hydrates into a reactive app.';
|
|
13
|
+
function cheer() { count++; }
|
|
14
|
+
</script>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import prerender from 'spark-prerender/bun';
|
|
2
|
+
|
|
3
|
+
// Static site, the Spark way: `spark dev` serves components raw with HMR;
|
|
4
|
+
// `spark build` copies public/, bundles the entry, then prerender() runs the
|
|
5
|
+
// REAL app at build time and writes fully-rendered HTML into dist/ — crawlers
|
|
6
|
+
// and AI tools read real content, the browser hydrates over it.
|
|
7
|
+
export default {
|
|
8
|
+
pipeline: [prerender({ pages: ['index.html'] })],
|
|
9
|
+
};
|
|
@@ -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>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# ⚡ Spark Blog — a full app on spark-ssr
|
|
2
|
+
|
|
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
|
+
|
|
8
|
+
## Run it
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bun install
|
|
12
|
+
bun run dev # seeds dev.db, serves on :3000, live-reloads on every edit
|
|
13
|
+
```
|
|
14
|
+
|
|
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.
|
|
49
|
+
|
|
50
|
+
## Deploy
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
bun run build # dist/ + compiled binary; images optimized if sharp installs
|
|
54
|
+
PORT=3000 ./dist/app
|
|
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>
|
|
@@ -0,0 +1,11 @@
|
|
|
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>
|
|
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>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spark-blog",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "bun setup.js && bun spark-ssr",
|
|
8
|
+
"start": "bun spark-ssr start",
|
|
9
|
+
"build": "bun spark-ssr build"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"spark-html": "latest",
|
|
13
|
+
"spark-ssr": "latest",
|
|
14
|
+
"spark-html-theme": "latest",
|
|
15
|
+
"spark-html-font": "latest"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"spark-html-image": "latest"
|
|
19
|
+
}
|
|
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>
|
|
@@ -0,0 +1,33 @@
|
|
|
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>
|
|
8
|
+
|
|
9
|
+
<div import="/components/nav" blog="{author.name}" active="home"></div>
|
|
10
|
+
|
|
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
|
+
|
|
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; }
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
|
|
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
|
+
|
|
26
|
+
db.run(`CREATE TABLE IF NOT EXISTS todos (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
user_id INTEGER NOT NULL,
|
|
29
|
+
title TEXT NOT NULL,
|
|
30
|
+
done INTEGER DEFAULT 0
|
|
31
|
+
)`);
|
|
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');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
db.close();
|