spark-ssr 0.1.1 → 0.3.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 +219 -18
- package/bin/cli.js +86 -12
- package/package.json +4 -3
- package/src/config.js +5 -0
- 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 +27 -3
- package/src/schema.js +226 -0
- package/src/server.js +784 -86
- 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
|
|
|
@@ -47,13 +52,16 @@ No `<script>`. No SQL. No ORM. No server file. No build step.
|
|
|
47
52
|
{
|
|
48
53
|
"db": "postgres://localhost:5432/myapp",
|
|
49
54
|
"auth": { "table": "users", "identity": "email", "secret": "ENV.SESSION_SECRET" },
|
|
50
|
-
"cors": true
|
|
55
|
+
"cors": true,
|
|
56
|
+
"fonts": [{ "family": "Inter", "google": true, "weights": [400, 700] }]
|
|
51
57
|
}
|
|
52
58
|
```
|
|
53
59
|
|
|
54
60
|
`sqlite://./dev.db` works too (Bun ships both drivers). `ENV.*` values resolve
|
|
55
61
|
from the environment at startup. `cors: true` allows all origins on `/api/*`;
|
|
56
|
-
an array allows specific ones.
|
|
62
|
+
an array allows specific ones. `fonts` renders spark-html-font's head tags
|
|
63
|
+
(preloads, `@font-face` with a size-adjusted no-shift fallback, a
|
|
64
|
+
`--font-<slug>` var) into every page — same shapes as its build-pipeline step.
|
|
57
65
|
|
|
58
66
|
## Routing
|
|
59
67
|
|
|
@@ -61,24 +69,207 @@ The filesystem is the router. `pages/index.html` → `/`,
|
|
|
61
69
|
`pages/blog/[slug].html` → `/blog/:slug` (`:slug` binds into queries).
|
|
62
70
|
Without a `pages/` folder, `*.html` at the project root serve the same way.
|
|
63
71
|
|
|
64
|
-
##
|
|
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:
|
|
65
76
|
|
|
66
77
|
```html
|
|
67
|
-
<!-- pages/
|
|
68
|
-
<
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
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>
|
|
72
84
|
|
|
73
85
|
<spark-ssr>
|
|
74
|
-
|
|
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.
|
|
92
|
+
|
|
93
|
+
## Named data — the block says what it feeds
|
|
94
|
+
|
|
95
|
+
```html
|
|
96
|
+
<spark-ssr>
|
|
97
|
+
posts = SELECT * FROM posts WHERE published = 1 ORDER BY created_at DESC
|
|
98
|
+
author = SELECT id, name, bio FROM users LIMIT 1
|
|
75
99
|
</spark-ssr>
|
|
76
100
|
```
|
|
77
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
|
+
|
|
78
107
|
`:` params inject automatically: path params (`:slug`), query string (`:q`),
|
|
79
|
-
JSON body (`:body.title`), session (`:session.id`), headers
|
|
108
|
+
form/JSON body (`:body.title`), session (`:session.id`), headers
|
|
80
109
|
(`:header.x-forwarded-for`), uploads (`:file.url`).
|
|
81
110
|
|
|
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
|
|
238
|
+
|
|
239
|
+
Literal `<title>`/`<meta>`/`<link>` tags at the top of a page lift into the
|
|
240
|
+
document head, `{expr}`-interpolated against the page's data
|
|
241
|
+
(spark-html-head's `/ssr` module does the lifting):
|
|
242
|
+
|
|
243
|
+
```html
|
|
244
|
+
<title>{post.title} · My Blog</title>
|
|
245
|
+
<meta name="description" content="{post.excerpt}">
|
|
246
|
+
```
|
|
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
|
+
|
|
253
|
+
## Client scripts and the family
|
|
254
|
+
|
|
255
|
+
A page's plain `<script>` runs on the **server** (the escape hatch).
|
|
256
|
+
`<script type="module">` and `<script src>` are **client** scripts — they lift
|
|
257
|
+
into `<head>` after an auto-generated importmap, so bare imports of the Spark
|
|
258
|
+
family just work, no build:
|
|
259
|
+
|
|
260
|
+
```js
|
|
261
|
+
// public/app.js
|
|
262
|
+
import { theme } from 'spark-html-theme';
|
|
263
|
+
theme();
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Every `spark-html-*` package in your dependencies is importmap-mapped and
|
|
267
|
+
served at `/@modules/<name>/…`. Depend on **spark-html-theme** and the
|
|
268
|
+
no-flash init snippet is inlined in every head automatically; depend on
|
|
269
|
+
**spark-html-image** and `spark-ssr build` runs its webp/srcset pass over
|
|
270
|
+
`dist/` — and uploads get a webp variant at write time (`:file.url` points
|
|
271
|
+
at it; `:file.original` keeps the source file).
|
|
272
|
+
|
|
82
273
|
## Custom endpoints — api/
|
|
83
274
|
|
|
84
275
|
`api/stats.html` auto-serves as `GET /api/stats`:
|
|
@@ -97,21 +288,31 @@ scope; the return value becomes the JSON response).
|
|
|
97
288
|
- **Components** — `<div import="/components/card" title="{post.title}">`
|
|
98
289
|
inlines at render time; components are pure UI.
|
|
99
290
|
- **Auth** — built-in email/password sessions (`POST /api/users?auth` logs in,
|
|
100
|
-
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.
|
|
101
294
|
- **Middleware** — `middleware.html` runs on every request (`req`, `res`,
|
|
102
295
|
`rateLimit`, `state` in scope; return `{ status, body }` to short-circuit).
|
|
103
296
|
- **Uploads** — multipart bodies stream to `uploads/`; `:file.url` binds the
|
|
104
297
|
stored URL into your INSERT.
|
|
105
|
-
- **Error pages** — `404.html` / `500.html` at the
|
|
298
|
+
- **Error pages** — `404.html` / `500.html` (any `<status>.html`) at the
|
|
299
|
+
project root.
|
|
106
300
|
- **Static assets** — `public/` plus co-located page assets, served as-is.
|
|
301
|
+
Project internals (spark.json, package.json, `*.db`, seeds, dotfiles) are
|
|
302
|
+
never served.
|
|
107
303
|
- **Hydration** — interactive pages ship fully-rendered HTML plus a generated
|
|
108
304
|
client component; `mount()` takes over with the same spark-html runtime.
|
|
305
|
+
- **Auth-table hygiene** — the auth table's auto CRUD never returns password
|
|
306
|
+
hashes, and PATCH/DELETE are own-account only. Configuring `auth` registers
|
|
307
|
+
the table (login/signup endpoints) without any page declaring it; disable
|
|
308
|
+
public signup in `middleware.html` if the app is invite-only.
|
|
109
309
|
|
|
110
310
|
## Deploy
|
|
111
311
|
|
|
112
312
|
```bash
|
|
113
|
-
bun spark-ssr build
|
|
114
|
-
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)
|
|
115
316
|
```
|
|
116
317
|
|
|
117
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 } 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,22 +43,32 @@ 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
|
-
|
|
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'];
|
|
45
58
|
const SHIP_FILES = ['404.html', '500.html', 'middleware.html', 'spark.json', 'package.json'];
|
|
46
59
|
|
|
47
|
-
async function build(root, compile) {
|
|
60
|
+
async function build(root, { compile, docker }) {
|
|
48
61
|
const dist = join(root, 'dist');
|
|
49
62
|
rmSync(dist, { recursive: true, force: true });
|
|
50
63
|
mkdirSync(dist, { recursive: true });
|
|
51
64
|
for (const d of SHIP_DIRS) {
|
|
52
65
|
if (existsSync(join(root, d))) cpSync(join(root, d), join(dist, d), { recursive: true });
|
|
53
66
|
}
|
|
67
|
+
if (existsSync(join(root, 'public'))) {
|
|
68
|
+
for (const f of readdirSync(join(root, 'public'))) {
|
|
69
|
+
cpSync(join(root, 'public', f), join(dist, f), { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
54
72
|
for (const f of SHIP_FILES) {
|
|
55
73
|
if (existsSync(join(root, f))) cpSync(join(root, f), join(dist, f));
|
|
56
74
|
}
|
|
@@ -59,7 +77,7 @@ async function build(root, compile) {
|
|
|
59
77
|
for (const f of readdirSync(root)) {
|
|
60
78
|
if (f === 'dist' || f === 'node_modules' || f.startsWith('.')) continue;
|
|
61
79
|
const full = join(root, f);
|
|
62
|
-
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)
|
|
63
81
|
&& !SHIP_FILES.includes(f)) {
|
|
64
82
|
cpSync(full, join(dist, f));
|
|
65
83
|
}
|
|
@@ -67,8 +85,16 @@ async function build(root, compile) {
|
|
|
67
85
|
}
|
|
68
86
|
writeFileSync(join(dist, '__server.js'),
|
|
69
87
|
"import { serve } from 'spark-ssr';\n" +
|
|
70
|
-
'serve({ root: process.cwd(), port: Number(process.env.PORT) || 3000 });\n');
|
|
88
|
+
'serve({ root: process.cwd(), port: Number(process.env.PORT) || 3000, watch: false });\n');
|
|
71
89
|
console.log(`✓ assembled dist/`);
|
|
90
|
+
// spark-html-image, when the app depends on it: the same pass a
|
|
91
|
+
// spark-html-bun pipeline runs — webp variants + srcset for every local
|
|
92
|
+
// <img> in the assembled pages and components. Options: spark.json "images".
|
|
93
|
+
try {
|
|
94
|
+
const image = (await import('spark-html-image')).default;
|
|
95
|
+
await image(loadConfig(root).images || {}).run({ outDir: dist });
|
|
96
|
+
console.log('✓ images optimized (spark-html-image)');
|
|
97
|
+
} catch { /* not installed — plain assets ship as-is */ }
|
|
72
98
|
if (compile) {
|
|
73
99
|
const r = Bun.spawnSync(
|
|
74
100
|
['bun', 'build', '--compile', join(dist, '__server.js'), '--outfile', join(dist, 'app')],
|
|
@@ -77,6 +103,52 @@ async function build(root, compile) {
|
|
|
77
103
|
if (r.exitCode !== 0) process.exit(r.exitCode);
|
|
78
104
|
console.log('✓ compiled dist/app — run it from dist/ (reads pages/ next to it)');
|
|
79
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();
|
|
80
152
|
}
|
|
81
153
|
|
|
82
154
|
const opts = parseArgs(process.argv.slice(2));
|
|
@@ -86,10 +158,12 @@ const root = resolve(opts.root || process.cwd());
|
|
|
86
158
|
const port = opts.port ?? (Number(process.env.PORT) || 3000);
|
|
87
159
|
|
|
88
160
|
if (opts.cmd === 'build') {
|
|
89
|
-
await build(root, opts
|
|
161
|
+
await build(root, opts);
|
|
162
|
+
} else if (opts.cmd === 'db') {
|
|
163
|
+
await dbCmd(root, opts);
|
|
90
164
|
} else if (opts.cmd === 'start') {
|
|
91
165
|
const dist = join(root, 'dist');
|
|
92
|
-
await serve({ root: existsSync(join(dist, '__server.js')) ? dist : root, port });
|
|
166
|
+
await serve({ root: existsSync(join(dist, '__server.js')) ? dist : root, port, watch: false });
|
|
93
167
|
} else {
|
|
94
168
|
await serve({ root, port });
|
|
95
169
|
}
|
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.0",
|
|
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",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"linkedom": "^0.18.12",
|
|
33
|
-
"spark-html": "^0.27.
|
|
33
|
+
"spark-html": "^0.27.8",
|
|
34
|
+
"spark-html-head": "^0.3.0"
|
|
34
35
|
},
|
|
35
36
|
"keywords": [
|
|
36
37
|
"spark-html",
|
package/src/config.js
CHANGED
|
@@ -36,5 +36,10 @@ export function loadConfig(root) {
|
|
|
36
36
|
auth: cfg.auth || null,
|
|
37
37
|
cors: cfg.cors ?? false,
|
|
38
38
|
uploads: cfg.uploads || 'uploads',
|
|
39
|
+
// Companion-package config, same shapes their build-pipeline steps take:
|
|
40
|
+
// "fonts" → spark-html-font tags in every <head>; "images" → options for
|
|
41
|
+
// the spark-html-image pass `spark-ssr build` runs when it's installed.
|
|
42
|
+
fonts: cfg.fonts || null,
|
|
43
|
+
images: cfg.images || null,
|
|
39
44
|
};
|
|
40
45
|
}
|
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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* re-attaches them client-side; static pages don't need them).
|
|
7
7
|
*/
|
|
8
8
|
import { parseHTML } from 'linkedom';
|
|
9
|
+
import { maskComments } from './parse.js';
|
|
9
10
|
|
|
10
11
|
const FN_CACHE = new Map();
|
|
11
12
|
function compile(expr) {
|
|
@@ -61,7 +62,9 @@ const interpolate = (text, scope) =>
|
|
|
61
62
|
*/
|
|
62
63
|
export async function renderFragment(html, scope, ctx = {}, depth = 0) {
|
|
63
64
|
const { document } = parseHTML('<!doctype html><html><body>' + html + '</body></html>');
|
|
64
|
-
|
|
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)
|
|
65
68
|
return document.body.innerHTML;
|
|
66
69
|
}
|
|
67
70
|
|
|
@@ -91,6 +94,21 @@ async function walkNode(node, scope, ctx, depth) {
|
|
|
91
94
|
|
|
92
95
|
if (node.hasAttribute('import')) return renderImport(node, scope, ctx, depth);
|
|
93
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
|
+
|
|
94
112
|
renderAttrs(node, scope);
|
|
95
113
|
await walkChildren(node, scope, ctx, depth);
|
|
96
114
|
}
|
|
@@ -192,6 +210,10 @@ async function renderIfChain(node, scope, ctx, depth) {
|
|
|
192
210
|
if (link.expr === null || evalExpr(link.expr, scope)) { winner = link; break; }
|
|
193
211
|
}
|
|
194
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;
|
|
195
217
|
await insertRendered(kids(winner.node), winner.node, scope, ctx, depth);
|
|
196
218
|
}
|
|
197
219
|
for (const link of chain) link.node.remove();
|
|
@@ -260,10 +282,12 @@ async function renderImport(node, scope, ctx, depth) {
|
|
|
260
282
|
if (source == null) { node.innerHTML = ''; return; }
|
|
261
283
|
// Components are pure UI on the server: strip <spark-ssr>/<script> from the
|
|
262
284
|
// output, but read literal script defaults so {count} renders as 0.
|
|
285
|
+
// Comments are masked so prose mentioning those tags never truncates one.
|
|
263
286
|
let script = '';
|
|
264
|
-
const
|
|
287
|
+
const { masked, restore } = maskComments(source);
|
|
288
|
+
const clean = restore(masked
|
|
265
289
|
.replace(/<spark-ssr\b[^>]*?\/>|<spark-ssr\b[^>]*>[\s\S]*?<\/spark-ssr>/gi, '')
|
|
266
|
-
.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (m, body) => { script += body + '\n'; return ''; });
|
|
290
|
+
.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (m, body) => { script += body + '\n'; return ''; }));
|
|
267
291
|
node.innerHTML = clean;
|
|
268
292
|
const compScope = Object.assign(Object.create(null), scriptLiterals(script), props);
|
|
269
293
|
await walkChildren(node, compScope, ctx, depth + 1);
|