spark-ssr 0.2.0 → 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 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="todo.done"` + `onchange={patch}` | `PATCH /api/todos/:id` — update `done` |
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
- ## Explicit queries
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/search.html -->
71
- <h1>Results for "{q}"</h1>
72
- <template each="result in results">
73
- <p>{result.title}</p>
74
- </template>
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
- GET /api/search → SELECT * FROM posts WHERE title LIKE '%' || :q || '%' LIMIT 20
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
- ## The page owns its \<head\>
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/` (options: `"images"` in spark.json).
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 project root.
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 never
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 # dist/ with a compiled single binary (public/ flattens into dist root)
160
- bun spark-ssr start # run in production (watch + live reload off)
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 === 'build' || a === 'start' || a === 'serve') opts.cmd = 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 build [--no-compile]
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, public,
43
- // error pages, middleware, config. node_modules/dist/uploads stay behind.
44
- // public/ is FLATTENED into dist root: assets keep the same URLs they had in
45
- // dev (/style.css, /img/…), and post-build passes (spark-html-image) resolve
46
- // root-absolute <img> paths against dist directly.
47
- const SHIP_DIRS = ['pages', 'components', 'api'];
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.compile);
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.2.0",
4
- "description": "Zero-config SSR for spark-html on Bun. The HTML template infers everything: filesystem routing, <spark-ssr> declarative queries, auto CRUD APIs, sessions, uploads, middleware. No build step.",
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",
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
- * Filesystem routing, sessions, uploads, middleware no build step.
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 { extractBlocks, rewriteParams, analyze, dataPlan, singleShaped } from './parse.js';
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
- await walkChildren(document.body, scope, { maxDepth: 20, ...ctx, document }, depth);
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();