spark-ssr 0.2.0 → 0.3.1
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 +182 -27
- package/bin/cli.js +71 -13
- package/package.json +2 -2
- package/src/db.js +4 -1
- package/src/hydrate.js +10 -3
- package/src/index.js +11 -4
- package/src/parse.js +0 -0
- package/src/render.js +22 -1
- package/src/schema.js +226 -0
- package/src/server.js +587 -65
- package/src/sources.js +131 -0
package/README.md
CHANGED
|
@@ -20,26 +20,31 @@ The framework reads the HTML template and infers everything:
|
|
|
20
20
|
</template>
|
|
21
21
|
</template>
|
|
22
22
|
|
|
23
|
-
<spark-ssr table="todos" />
|
|
23
|
+
<spark-ssr table="todos" live />
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
bun spark-ssr
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
That's it
|
|
30
|
+
That's it — the table is **created for you** (the template is the schema),
|
|
31
|
+
the REST API exists, the page is server-rendered and hydrated, and `live`
|
|
32
|
+
keeps every open tab in sync.
|
|
31
33
|
|
|
32
34
|
| The template says | The framework knows |
|
|
33
35
|
| ----------------------------------------------- | -------------------------------------------- |
|
|
34
36
|
| `<template each="todo in todos">` | You need data called `todos` |
|
|
35
37
|
| `table="todos"` | It's backed by the `todos` table |
|
|
38
|
+
| `{todo.title}` interpolation | `title` is a TEXT column |
|
|
39
|
+
| `bind:checked="todo.done"` | `done` is a boolean column |
|
|
36
40
|
| `onclick={add}` (outside the loop) | `POST /api/todos` — insert |
|
|
37
|
-
| `bind:checked
|
|
41
|
+
| `bind:checked` + `onchange={patch}` (in loop) | `PATCH /api/todos/:id` — update `done` |
|
|
38
42
|
| `onclick={remove}` (inside the loop) | `DELETE /api/todos/:id` — remove the row |
|
|
39
43
|
| `bind:value="draft"` | Local state variable `draft` |
|
|
40
44
|
| `user_id` column in the table | Auth — `WHERE user_id = :session.id` |
|
|
45
|
+
| `required maxlength="120"` on a form input | The server validates the same rules |
|
|
41
46
|
|
|
42
|
-
No `<script>`. No SQL. No ORM. No server file. No build step.
|
|
47
|
+
No `<script>`. No SQL. No ORM. No server file. No setup.js. No build step.
|
|
43
48
|
|
|
44
49
|
## Config (spark.json)
|
|
45
50
|
|
|
@@ -64,36 +69,187 @@ The filesystem is the router. `pages/index.html` → `/`,
|
|
|
64
69
|
`pages/blog/[slug].html` → `/blog/:slug` (`:slug` binds into queries).
|
|
65
70
|
Without a `pages/` folder, `*.html` at the project root serve the same way.
|
|
66
71
|
|
|
67
|
-
##
|
|
72
|
+
## Layouts — folders own their chrome
|
|
73
|
+
|
|
74
|
+
`pages/_layout.html` wraps every page in the folder (nested folders nest
|
|
75
|
+
their layouts). A layout is a component; `<slot>` is the page:
|
|
68
76
|
|
|
69
77
|
```html
|
|
70
|
-
<!-- pages/
|
|
71
|
-
<
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
<!-- pages/_layout.html -->
|
|
79
|
+
<link rel="stylesheet" href="/style.css">
|
|
80
|
+
<script type="module" src="/app.js"></script>
|
|
81
|
+
<div import="/components/nav" blog="{author.name}"></div>
|
|
82
|
+
<slot></slot>
|
|
83
|
+
<footer>© {author.name}</footer>
|
|
84
|
+
|
|
85
|
+
<spark-ssr>
|
|
86
|
+
author = SELECT id, name, bio FROM users LIMIT 1
|
|
87
|
+
</spark-ssr>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The layout's `<spark-ssr>` vars are in scope for every page it wraps. Head
|
|
91
|
+
tags lift from layout AND page; the page wins on conflicts.
|
|
75
92
|
|
|
93
|
+
## Named data — the block says what it feeds
|
|
94
|
+
|
|
95
|
+
```html
|
|
76
96
|
<spark-ssr>
|
|
77
|
-
|
|
97
|
+
posts = SELECT * FROM posts WHERE published = 1 ORDER BY created_at DESC
|
|
98
|
+
author = SELECT id, name, bio FROM users LIMIT 1
|
|
78
99
|
</spark-ssr>
|
|
79
100
|
```
|
|
80
101
|
|
|
102
|
+
`var = SELECT …` is page data with no endpoint exposed — what most pages
|
|
103
|
+
actually want. Write `GET /api/posts → posts = SELECT …` when you also want
|
|
104
|
+
a public endpoint. Name/singular/fallback matching still works for
|
|
105
|
+
`table="…"` blocks and unnamed routes — nothing breaks.
|
|
106
|
+
|
|
81
107
|
`:` params inject automatically: path params (`:slug`), query string (`:q`),
|
|
82
|
-
JSON body (`:body.title`), session (`:session.id`), headers
|
|
108
|
+
form/JSON body (`:body.title`), session (`:session.id`), headers
|
|
83
109
|
(`:header.x-forwarded-for`), uploads (`:file.url`).
|
|
84
110
|
|
|
85
|
-
##
|
|
111
|
+
## Sources beyond SQL — same block, more worlds
|
|
112
|
+
|
|
113
|
+
```html
|
|
114
|
+
<spark-ssr>
|
|
115
|
+
repo = https://api.github.com/repos/wilkinnovo/spark-html
|
|
116
|
+
posts = ./content/posts/*.md
|
|
117
|
+
weather = ./lib/weather.js
|
|
118
|
+
</spark-ssr>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- **URL** — server-side fetch, JSON parsed; `:slug`-style params interpolate.
|
|
122
|
+
- **Glob** — files become rows: front-matter → columns, body → `.body`,
|
|
123
|
+
filename → `.slug`. A blog with no database at all. (`{post.body}` renders
|
|
124
|
+
text; markdown rendering is a companion package's job.)
|
|
125
|
+
- **Module** — default export `(req, db) => value`. The escape hatch gets a
|
|
126
|
+
declarative front door.
|
|
127
|
+
|
|
128
|
+
## Status, redirect, and guard
|
|
129
|
+
|
|
130
|
+
```html
|
|
131
|
+
<!-- blog/[slug].html -->
|
|
132
|
+
<template if="post"> … </template>
|
|
133
|
+
<template else status="404"><h1>Not found</h1></template>
|
|
134
|
+
|
|
135
|
+
<!-- admin/index.html -->
|
|
136
|
+
<spark-ssr guard="session" redirect="/login" />
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`status="…"` on a rendered branch sets the response status — crawlers stop
|
|
140
|
+
indexing a 200-that-means-404. `guard="expr"`: when falsy, `redirect="…"`
|
|
141
|
+
answers 303, `status="401"` sets the code, default 403. An `is_admin` (or
|
|
142
|
+
`role`) column on the auth table rides into the session, so
|
|
143
|
+
`guard="session.is_admin"` works — and admins read scoped tables unscoped.
|
|
144
|
+
|
|
145
|
+
## Forms without JavaScript
|
|
146
|
+
|
|
147
|
+
The auto-CRUD endpoints answer a browser like a browser:
|
|
148
|
+
|
|
149
|
+
```html
|
|
150
|
+
<form action="/api/posts" method="post" redirect="/admin">
|
|
151
|
+
<input name="title" required maxlength="120">
|
|
152
|
+
<textarea name="body"></textarea>
|
|
153
|
+
<button>Save draft</button>
|
|
154
|
+
</form>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
A plain form post that succeeds 303s back to the referrer (or the form's
|
|
158
|
+
`redirect="…"`); the page re-renders with fresh data. **The app works with
|
|
159
|
+
JavaScript disabled.** Login and logout are forms too:
|
|
160
|
+
|
|
161
|
+
```html
|
|
162
|
+
<form action="/api/users?auth" method="post">
|
|
163
|
+
<input type="email" name="email" required>
|
|
164
|
+
<input type="password" name="password" required>
|
|
165
|
+
<button>Sign in</button>
|
|
166
|
+
</form>
|
|
167
|
+
<form action="/api/logout" method="post"><button>Sign out</button></form>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### The form is the validator
|
|
171
|
+
|
|
172
|
+
`required`, `maxlength`, `min`/`max`, `type="email"`, `pattern` — the
|
|
173
|
+
constraint attributes you already wrote for the browser run again on the
|
|
174
|
+
server before the matching auto-CRUD write. Violations answer `422` with
|
|
175
|
+
`{ errors: { title: "…" } }` (JSON) or re-render the page with `{errors.title}`
|
|
176
|
+
and `{values.title}` in scope (form post).
|
|
177
|
+
|
|
178
|
+
## The template is the schema
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
bun spark-ssr db # show inferred schema vs live DB (a diff)
|
|
182
|
+
bun spark-ssr db push # create/alter tables to match the templates
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Column inference: `{todo.title}` → TEXT, `bind:checked` → boolean,
|
|
186
|
+
`type="number"` inputs → numeric, `id`/`created_at` always, `user_id` when
|
|
187
|
+
auth is configured and the page reads the session. Seed rows sharpen types:
|
|
188
|
+
|
|
189
|
+
```html
|
|
190
|
+
<spark-ssr table="todos" seed="./seed/todos.json" live />
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Seeds apply once, idempotently (only into an empty table; auth-table
|
|
194
|
+
passwords hash on the way in). `serve` runs the safe half automatically —
|
|
195
|
+
missing tables are created and seeds applied at startup, so a fresh clone
|
|
196
|
+
runs on `bun spark-ssr` alone. Columns are never dropped without
|
|
197
|
+
`db push --force`. Seed files are never served as static assets.
|
|
198
|
+
|
|
199
|
+
## `live` — HTML that reacts across the wire
|
|
200
|
+
|
|
201
|
+
```html
|
|
202
|
+
<spark-ssr table="todos" live />
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Every write through the server pings `/__spark/live` (SSE) with the table
|
|
206
|
+
name; hydrated pages refetch through their own session — every open tab
|
|
207
|
+
updates, scoping intact. No socket code, no pub/sub. spark-html-websocket
|
|
208
|
+
stays for custom protocols; `live` is the zero-config 90%.
|
|
209
|
+
|
|
210
|
+
## Lists — `?page`, `?sort`, `?q`
|
|
211
|
+
|
|
212
|
+
```html
|
|
213
|
+
<spark-ssr table="recipes" limit="20" search="title,ingredients" />
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- `?page=2` → `LIMIT/OFFSET`, plus `{recipes.total}` and `{recipes.pages}`.
|
|
217
|
+
- `?sort=created_at:desc` → validated against real columns, then `ORDER BY`.
|
|
218
|
+
- `?q=…` → `LIKE` across the block's `search="…"` columns.
|
|
219
|
+
|
|
220
|
+
`cache="60"` on any block adds a per-source TTL, invalidated automatically
|
|
221
|
+
when a table it reads from changes.
|
|
222
|
+
|
|
223
|
+
## Dev tools
|
|
224
|
+
|
|
225
|
+
- **Error overlay** — a failing query or throwing page script puts the real
|
|
226
|
+
error (message, stack) on the page instead of a bare 500.
|
|
227
|
+
- **Unresolved-var banner** — "this page reads `{posts}` but no source
|
|
228
|
+
provides it — nearest source: `published`". The silent-blank bug becomes
|
|
229
|
+
a sentence.
|
|
230
|
+
- **`/__spark/plan`** — every route, table, endpoint, and each page's
|
|
231
|
+
var → source bindings. "View source" for the inferred backend.
|
|
232
|
+
- **Live reload** — every edit (pages, layouts, components, queries,
|
|
233
|
+
middleware, css, markdown) refreshes the browser via SSE.
|
|
234
|
+
|
|
235
|
+
All dev-only; `start` and compiled builds ship none of it.
|
|
236
|
+
|
|
237
|
+
## The page owns its \<head\> — and SEO comes free
|
|
86
238
|
|
|
87
239
|
Literal `<title>`/`<meta>`/`<link>` tags at the top of a page lift into the
|
|
88
240
|
document head, `{expr}`-interpolated against the page's data
|
|
89
241
|
(spark-html-head's `/ssr` module does the lifting):
|
|
90
242
|
|
|
91
243
|
```html
|
|
92
|
-
<!-- pages/blog/[slug].html -->
|
|
93
244
|
<title>{post.title} · My Blog</title>
|
|
94
245
|
<meta name="description" content="{post.excerpt}">
|
|
95
246
|
```
|
|
96
247
|
|
|
248
|
+
`og:title`/`og:description` derive from those unless overridden.
|
|
249
|
+
`/sitemap.xml` is generated from the pages — `[param]` routes enumerate
|
|
250
|
+
their bound query. `/robots.txt` honors `<meta name="robots" content="noindex">`
|
|
251
|
+
and guards. Author your own files in `public/` to override any of it.
|
|
252
|
+
|
|
97
253
|
## Client scripts and the family
|
|
98
254
|
|
|
99
255
|
A page's plain `<script>` runs on the **server** (the escape hatch).
|
|
@@ -101,10 +257,6 @@ A page's plain `<script>` runs on the **server** (the escape hatch).
|
|
|
101
257
|
into `<head>` after an auto-generated importmap, so bare imports of the Spark
|
|
102
258
|
family just work, no build:
|
|
103
259
|
|
|
104
|
-
```html
|
|
105
|
-
<script type="module" src="/app.js"></script>
|
|
106
|
-
```
|
|
107
|
-
|
|
108
260
|
```js
|
|
109
261
|
// public/app.js
|
|
110
262
|
import { theme } from 'spark-html-theme';
|
|
@@ -115,7 +267,8 @@ Every `spark-html-*` package in your dependencies is importmap-mapped and
|
|
|
115
267
|
served at `/@modules/<name>/…`. Depend on **spark-html-theme** and the
|
|
116
268
|
no-flash init snippet is inlined in every head automatically; depend on
|
|
117
269
|
**spark-html-image** and `spark-ssr build` runs its webp/srcset pass over
|
|
118
|
-
`dist/`
|
|
270
|
+
`dist/` — and uploads get a webp variant at write time (`:file.url` points
|
|
271
|
+
at it; `:file.original` keeps the source file).
|
|
119
272
|
|
|
120
273
|
## Custom endpoints — api/
|
|
121
274
|
|
|
@@ -135,19 +288,20 @@ scope; the return value becomes the JSON response).
|
|
|
135
288
|
- **Components** — `<div import="/components/card" title="{post.title}">`
|
|
136
289
|
inlines at render time; components are pure UI.
|
|
137
290
|
- **Auth** — built-in email/password sessions (`POST /api/users?auth` logs in,
|
|
138
|
-
passwords hash on insert), or a plugin (`auth.plugin` in spark.json)
|
|
291
|
+
passwords hash on insert), or a plugin (`auth.plugin` in spark.json) for
|
|
292
|
+
OAuth/magic-link flows — the plugin answers "who is this person?", the
|
|
293
|
+
framework still does sessions and cookies.
|
|
139
294
|
- **Middleware** — `middleware.html` runs on every request (`req`, `res`,
|
|
140
295
|
`rateLimit`, `state` in scope; return `{ status, body }` to short-circuit).
|
|
141
296
|
- **Uploads** — multipart bodies stream to `uploads/`; `:file.url` binds the
|
|
142
297
|
stored URL into your INSERT.
|
|
143
|
-
- **Error pages** — `404.html` / `500.html` at the
|
|
298
|
+
- **Error pages** — `404.html` / `500.html` (any `<status>.html`) at the
|
|
299
|
+
project root.
|
|
144
300
|
- **Static assets** — `public/` plus co-located page assets, served as-is.
|
|
145
|
-
Project internals (spark.json, package.json, `*.db`, dotfiles) are
|
|
146
|
-
served.
|
|
301
|
+
Project internals (spark.json, package.json, `*.db`, seeds, dotfiles) are
|
|
302
|
+
never served.
|
|
147
303
|
- **Hydration** — interactive pages ship fully-rendered HTML plus a generated
|
|
148
304
|
client component; `mount()` takes over with the same spark-html runtime.
|
|
149
|
-
- **Live reload** — in dev, every edit (pages, components, queries,
|
|
150
|
-
middleware, css) refreshes the browser via SSE. No restart, no flags.
|
|
151
305
|
- **Auth-table hygiene** — the auth table's auto CRUD never returns password
|
|
152
306
|
hashes, and PATCH/DELETE are own-account only. Configuring `auth` registers
|
|
153
307
|
the table (login/signup endpoints) without any page declaring it; disable
|
|
@@ -156,8 +310,9 @@ scope; the return value becomes the JSON response).
|
|
|
156
310
|
## Deploy
|
|
157
311
|
|
|
158
312
|
```bash
|
|
159
|
-
bun spark-ssr build
|
|
160
|
-
bun spark-ssr
|
|
313
|
+
bun spark-ssr build # dist/ with a compiled single binary
|
|
314
|
+
bun spark-ssr build --docker # …plus a Dockerfile: copy, run
|
|
315
|
+
bun spark-ssr start # run in production (watch + live reload off)
|
|
161
316
|
```
|
|
162
317
|
|
|
163
318
|
MIT
|
package/bin/cli.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* bun spark-ssr serve the current directory (default)
|
|
6
6
|
* bun spark-ssr --port 3000 pick a port
|
|
7
|
+
* bun spark-ssr db show the inferred schema vs the live DB
|
|
8
|
+
* bun spark-ssr db push create/alter tables to match the templates
|
|
7
9
|
* bun spark-ssr build assemble dist/ (+ compiled binary)
|
|
8
10
|
* bun spark-ssr start serve dist/ if built, else the project
|
|
9
11
|
*
|
|
@@ -11,21 +13,27 @@
|
|
|
11
13
|
* --port <n> Port (default 3000, or PORT env).
|
|
12
14
|
* --root <dir> Project root (default cwd).
|
|
13
15
|
* --no-compile build: skip the single-binary compile, copy only.
|
|
16
|
+
* --docker build: also emit a Dockerfile next to the binary.
|
|
17
|
+
* --force db push: drop columns the templates no longer name.
|
|
14
18
|
* -h, --help Show this help.
|
|
15
19
|
*/
|
|
16
20
|
import { join, resolve } from 'node:path';
|
|
17
21
|
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
|
18
|
-
import { serve, loadConfig } from '../src/index.js';
|
|
22
|
+
import { serve, loadConfig, projectSchema } from '../src/index.js';
|
|
23
|
+
import { diffSchema, pushSchema, seedTables } from '../src/schema.js';
|
|
19
24
|
|
|
20
25
|
function parseArgs(argv) {
|
|
21
|
-
const opts = { cmd: 'serve', compile: true };
|
|
26
|
+
const opts = { cmd: 'serve', compile: true, force: false, docker: false };
|
|
22
27
|
for (let i = 0; i < argv.length; i++) {
|
|
23
28
|
const a = argv[i];
|
|
24
29
|
if (a === '-h' || a === '--help') opts.help = true;
|
|
25
30
|
else if (a === '--port') opts.port = Number(argv[++i]);
|
|
26
31
|
else if (a === '--root') opts.root = argv[++i];
|
|
27
32
|
else if (a === '--no-compile') opts.compile = false;
|
|
28
|
-
else if (a === '
|
|
33
|
+
else if (a === '--docker') opts.docker = true;
|
|
34
|
+
else if (a === '--force') opts.force = true;
|
|
35
|
+
else if (a === 'build' || a === 'start' || a === 'serve' || a === 'db') opts.cmd = a;
|
|
36
|
+
else if (a === 'push' && opts.cmd === 'db') opts.push = true;
|
|
29
37
|
else if (a.startsWith('--')) { console.error(`Unknown option: ${a}`); process.exit(2); }
|
|
30
38
|
}
|
|
31
39
|
return opts;
|
|
@@ -35,19 +43,21 @@ const HELP = `spark-ssr — zero-config SSR for spark-html on Bun
|
|
|
35
43
|
|
|
36
44
|
Usage:
|
|
37
45
|
bun spark-ssr [serve] [--port <n>] [--root <dir>]
|
|
38
|
-
bun spark-ssr
|
|
46
|
+
bun spark-ssr db [push] [--force]
|
|
47
|
+
bun spark-ssr build [--no-compile] [--docker]
|
|
39
48
|
bun spark-ssr start
|
|
40
49
|
`;
|
|
41
50
|
|
|
42
|
-
// The project files a deployment needs — pages, components, api,
|
|
43
|
-
// error pages, middleware, config.
|
|
44
|
-
// public/ is FLATTENED into dist
|
|
45
|
-
// dev (/style.css, /img/…), and
|
|
46
|
-
// root-absolute <img> paths
|
|
47
|
-
|
|
51
|
+
// The project files a deployment needs — pages, components, api, seeds,
|
|
52
|
+
// content, modules, public, error pages, middleware, config.
|
|
53
|
+
// node_modules/dist/uploads stay behind. public/ is FLATTENED into dist
|
|
54
|
+
// root: assets keep the same URLs they had in dev (/style.css, /img/…), and
|
|
55
|
+
// post-build passes (spark-html-image) resolve root-absolute <img> paths
|
|
56
|
+
// against dist directly.
|
|
57
|
+
const SHIP_DIRS = ['pages', 'components', 'api', 'seed', 'content', 'lib'];
|
|
48
58
|
const SHIP_FILES = ['404.html', '500.html', 'middleware.html', 'spark.json', 'package.json'];
|
|
49
59
|
|
|
50
|
-
async function build(root, compile) {
|
|
60
|
+
async function build(root, { compile, docker }) {
|
|
51
61
|
const dist = join(root, 'dist');
|
|
52
62
|
rmSync(dist, { recursive: true, force: true });
|
|
53
63
|
mkdirSync(dist, { recursive: true });
|
|
@@ -67,7 +77,7 @@ async function build(root, compile) {
|
|
|
67
77
|
for (const f of readdirSync(root)) {
|
|
68
78
|
if (f === 'dist' || f === 'node_modules' || f.startsWith('.')) continue;
|
|
69
79
|
const full = join(root, f);
|
|
70
|
-
if (statSync(full).isFile() && /\.(html|css|js|json|png|jpg|svg|ico|webp)$/.test(f)
|
|
80
|
+
if (statSync(full).isFile() && /\.(html|css|js|json|png|jpg|svg|ico|webp|md)$/.test(f)
|
|
71
81
|
&& !SHIP_FILES.includes(f)) {
|
|
72
82
|
cpSync(full, join(dist, f));
|
|
73
83
|
}
|
|
@@ -93,6 +103,52 @@ async function build(root, compile) {
|
|
|
93
103
|
if (r.exitCode !== 0) process.exit(r.exitCode);
|
|
94
104
|
console.log('✓ compiled dist/app — run it from dist/ (reads pages/ next to it)');
|
|
95
105
|
}
|
|
106
|
+
if (docker) {
|
|
107
|
+
// The deploy story: copy, run. The compiled binary needs only libc.
|
|
108
|
+
writeFileSync(join(dist, 'Dockerfile'), compile
|
|
109
|
+
? 'FROM debian:stable-slim\nCOPY . /app\nWORKDIR /app\nEXPOSE 3000\nCMD ["/app/app"]\n'
|
|
110
|
+
: 'FROM oven/bun:1\nCOPY . /app\nWORKDIR /app\nRUN bun install --production\nEXPOSE 3000\nCMD ["bun", "__server.js"]\n');
|
|
111
|
+
console.log('✓ wrote dist/Dockerfile');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The template is the schema (§7): show the diff, or make it so.
|
|
116
|
+
async function dbCmd(root, { push, force }) {
|
|
117
|
+
const { db, config, schema } = await projectSchema(root);
|
|
118
|
+
if (!db) {
|
|
119
|
+
console.error('No "db" configured in spark.json.');
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
const names = Object.keys(schema);
|
|
123
|
+
if (!names.length) {
|
|
124
|
+
console.log('No tables declared — add <spark-ssr table="…"> to a page.');
|
|
125
|
+
await db.close();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (push) {
|
|
129
|
+
await pushSchema(db, schema, { force, log: (m) => console.log(`✓ ${m}`) });
|
|
130
|
+
await seedTables(db, schema, config, root, (m) => console.log(`✓ ${m}`));
|
|
131
|
+
console.log('✓ database matches the templates');
|
|
132
|
+
} else {
|
|
133
|
+
const diff = await diffSchema(db, schema);
|
|
134
|
+
for (const t of names) {
|
|
135
|
+
const spec = schema[t];
|
|
136
|
+
const cols = ['id', ...(spec.scoped ? ['user_id'] : []), ...Object.keys(spec.columns), 'created_at'];
|
|
137
|
+
console.log(`${t}: ${cols.join(', ')}${spec.seed ? ` (seed: ${spec.seed})` : ''}`);
|
|
138
|
+
}
|
|
139
|
+
if (!diff.length) {
|
|
140
|
+
console.log('\n✓ live database already matches');
|
|
141
|
+
} else {
|
|
142
|
+
console.log('');
|
|
143
|
+
for (const d of diff) {
|
|
144
|
+
if (d.create) console.log(`will create ${d.table}`);
|
|
145
|
+
for (const c of d.add) console.log(`will add ${d.table}.${c.name} ${c.type}`);
|
|
146
|
+
for (const c of d.extra) console.log(`extra column ${d.table}.${c} (kept; --force drops)`);
|
|
147
|
+
}
|
|
148
|
+
console.log('\nrun `bun spark-ssr db push` to apply');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
await db.close();
|
|
96
152
|
}
|
|
97
153
|
|
|
98
154
|
const opts = parseArgs(process.argv.slice(2));
|
|
@@ -102,7 +158,9 @@ const root = resolve(opts.root || process.cwd());
|
|
|
102
158
|
const port = opts.port ?? (Number(process.env.PORT) || 3000);
|
|
103
159
|
|
|
104
160
|
if (opts.cmd === 'build') {
|
|
105
|
-
await build(root, opts
|
|
161
|
+
await build(root, opts);
|
|
162
|
+
} else if (opts.cmd === 'db') {
|
|
163
|
+
await dbCmd(root, opts);
|
|
106
164
|
} else if (opts.cmd === 'start') {
|
|
107
165
|
const dist = join(root, 'dist');
|
|
108
166
|
await serve({ root: existsSync(join(dist, '__server.js')) ? dist : root, port, watch: false });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spark-ssr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Zero-config SSR for spark-html on Bun. The HTML template infers everything: filesystem routing, <spark-ssr> declarative
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Zero-config SSR for spark-html on Bun. The HTML template infers everything: filesystem routing, layouts, <spark-ssr> declarative data (SQL, URLs, globs, modules), auto CRUD with validation, guards, no-JS forms, schema + seeds, live updates, SEO. No build step.",
|
|
5
5
|
"homepage": "https://wilkinnovo.github.io/spark-html",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
package/src/db.js
CHANGED
|
@@ -18,13 +18,16 @@ const bindable = (v) =>
|
|
|
18
18
|
: typeof v === 'boolean' ? (v ? 1 : 0)
|
|
19
19
|
: JSON.stringify(v);
|
|
20
20
|
|
|
21
|
-
export async function connect(url) {
|
|
21
|
+
export async function connect(url, root) {
|
|
22
22
|
if (!url) return null;
|
|
23
23
|
|
|
24
24
|
if (url.startsWith('sqlite:')) {
|
|
25
25
|
const { Database } = await import('bun:sqlite');
|
|
26
|
+
const { isAbsolute, join } = await import('node:path');
|
|
26
27
|
let path = url.slice('sqlite:'.length).replace(/^\/\//, '');
|
|
27
28
|
if (path === '' || path === ':memory:') path = ':memory:';
|
|
29
|
+
// A relative file lives in the PROJECT, not wherever the process started.
|
|
30
|
+
else if (root && !isAbsolute(path)) path = join(root, path);
|
|
28
31
|
const db = new Database(path, { create: true });
|
|
29
32
|
return {
|
|
30
33
|
kind: 'sqlite',
|
package/src/hydrate.js
CHANGED
|
@@ -41,7 +41,7 @@ export function primaryColumn(cols) {
|
|
|
41
41
|
* - loop handlers get their row argument.
|
|
42
42
|
* - the synthesized <script> is appended.
|
|
43
43
|
*/
|
|
44
|
-
export function clientComponent({ html, analysis, plan, table, cols, key }) {
|
|
44
|
+
export function clientComponent({ html, analysis, plan, table, cols, key, live }) {
|
|
45
45
|
const { document } = parseHTML('<!doctype html><html><body>' + html + '</body></html>');
|
|
46
46
|
|
|
47
47
|
const kids = templateKids;
|
|
@@ -96,12 +96,12 @@ export function clientComponent({ html, analysis, plan, table, cols, key }) {
|
|
|
96
96
|
for (const c of [...node.childNodes]) transform(c, loopVar);
|
|
97
97
|
})(document.body, null);
|
|
98
98
|
|
|
99
|
-
return document.body.innerHTML + '\n<script>\n' + clientScript({ analysis, plan, table, cols, key }) + '</script>\n';
|
|
99
|
+
return document.body.innerHTML + '\n<script>\n' + clientScript({ analysis, plan, table, cols, key, live }) + '</script>\n';
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
// The synthesized component script. Plain functions, no template literals,
|
|
103
103
|
// no code-shaped strings (the runtime's script rewriter is not string-aware).
|
|
104
|
-
export function clientScript({ analysis, plan, table, cols, key }) {
|
|
104
|
+
export function clientScript({ analysis, plan, table, cols, key, live }) {
|
|
105
105
|
const L = [];
|
|
106
106
|
L.push(`import __init from '/__spark/data/${key}.js';`);
|
|
107
107
|
for (const p of plan) {
|
|
@@ -152,6 +152,13 @@ export function clientScript({ analysis, plan, table, cols, key }) {
|
|
|
152
152
|
if (listVar) L.push(' await __refresh();');
|
|
153
153
|
L.push('}');
|
|
154
154
|
}
|
|
155
|
+
// live (§9): every write the server sees on this table pings the channel;
|
|
156
|
+
// every open tab refetches through its own session. Realtime as one
|
|
157
|
+
// attribute — no socket code, no pub/sub setup.
|
|
158
|
+
if (live && listVar) {
|
|
159
|
+
L.push(`const __live = new EventSource('/__spark/live');`);
|
|
160
|
+
L.push(`__live.onmessage = (e) => { if (e.data === '${table}') __refresh(); };`);
|
|
161
|
+
}
|
|
155
162
|
return L.join('\n') + '\n';
|
|
156
163
|
}
|
|
157
164
|
|
package/src/index.js
CHANGED
|
@@ -3,12 +3,19 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The HTML template infers everything: <template each="todo in todos"> means
|
|
5
5
|
* you need `todos`; <spark-ssr table="todos"> backs it with a table and the
|
|
6
|
-
* REST endpoints the handlers imply; a user_id column means auth scoping
|
|
7
|
-
*
|
|
6
|
+
* REST endpoints the handlers imply; a user_id column means auth scoping;
|
|
7
|
+
* the form constraints are the validation; the template is the schema.
|
|
8
|
+
* Filesystem routing, layouts, sessions, uploads, middleware, live updates —
|
|
9
|
+
* no build step.
|
|
8
10
|
*/
|
|
9
|
-
export { serve } from './server.js';
|
|
11
|
+
export { serve, scanPages, projectSchema } from './server.js';
|
|
10
12
|
export { loadConfig } from './config.js';
|
|
11
13
|
export { connect } from './db.js';
|
|
12
|
-
export {
|
|
14
|
+
export {
|
|
15
|
+
extractBlocks, rewriteParams, analyze, mergeAnalyses, dataPlan, singleShaped,
|
|
16
|
+
extractForms, validateFields, sqlTables,
|
|
17
|
+
} from './parse.js';
|
|
13
18
|
export { renderFragment, evalExpr } from './render.js';
|
|
14
19
|
export { clientComponent, clientScript, initModule, handlerRoles, primaryColumn } from './hydrate.js';
|
|
20
|
+
export { urlSource, globSource, moduleSource, parseFrontMatter, makeSourceCache } from './sources.js';
|
|
21
|
+
export { inferSchema, diffSchema, pushSchema, seedTables } from './schema.js';
|
package/src/parse.js
CHANGED
|
Binary file
|
package/src/render.js
CHANGED
|
@@ -62,7 +62,9 @@ const interpolate = (text, scope) =>
|
|
|
62
62
|
*/
|
|
63
63
|
export async function renderFragment(html, scope, ctx = {}, depth = 0) {
|
|
64
64
|
const { document } = parseHTML('<!doctype html><html><body>' + html + '</body></html>');
|
|
65
|
-
|
|
65
|
+
const inner = { maxDepth: 20, ...ctx, document };
|
|
66
|
+
await walkChildren(document.body, scope, inner, depth);
|
|
67
|
+
if (inner.status) ctx.status = inner.status; // a rendered branch set it (§3)
|
|
66
68
|
return document.body.innerHTML;
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -92,6 +94,21 @@ async function walkNode(node, scope, ctx, depth) {
|
|
|
92
94
|
|
|
93
95
|
if (node.hasAttribute('import')) return renderImport(node, scope, ctx, depth);
|
|
94
96
|
|
|
97
|
+
// No-JS forms (§5): a redirect="…" attribute on a form posting to /api/*
|
|
98
|
+
// becomes a hidden _redirect field, so the plain-browser 303 knows where
|
|
99
|
+
// to land. The attribute itself never reaches the browser.
|
|
100
|
+
if (tag === 'form' && node.hasAttribute('redirect')) {
|
|
101
|
+
const to = node.getAttribute('redirect');
|
|
102
|
+
node.removeAttribute('redirect');
|
|
103
|
+
if ((node.getAttribute('action') || '').startsWith('/api/')) {
|
|
104
|
+
const hidden = ctx.document.createElement('input');
|
|
105
|
+
hidden.setAttribute('type', 'hidden');
|
|
106
|
+
hidden.setAttribute('name', '_redirect');
|
|
107
|
+
hidden.setAttribute('value', to);
|
|
108
|
+
node.appendChild(hidden);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
95
112
|
renderAttrs(node, scope);
|
|
96
113
|
await walkChildren(node, scope, ctx, depth);
|
|
97
114
|
}
|
|
@@ -193,6 +210,10 @@ async function renderIfChain(node, scope, ctx, depth) {
|
|
|
193
210
|
if (link.expr === null || evalExpr(link.expr, scope)) { winner = link; break; }
|
|
194
211
|
}
|
|
195
212
|
if (winner) {
|
|
213
|
+
// Declarative status (§3): the rendered branch sets the response status —
|
|
214
|
+
// <template else status="404"> stops being a 200-that-means-404.
|
|
215
|
+
const st = Number(winner.node.getAttribute('status'));
|
|
216
|
+
if (st) ctx.status = st;
|
|
196
217
|
await insertRendered(kids(winner.node), winner.node, scope, ctx, depth);
|
|
197
218
|
}
|
|
198
219
|
for (const link of chain) link.node.remove();
|