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 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
 
@@ -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
- ## 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:
65
76
 
66
77
  ```html
67
- <!-- pages/search.html -->
68
- <h1>Results for "{q}"</h1>
69
- <template each="result in results">
70
- <p>{result.title}</p>
71
- </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>
72
84
 
73
85
  <spark-ssr>
74
- GET /api/search SELECT * FROM posts WHERE title LIKE '%' || :q || '%' LIMIT 20
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 project root.
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 # dist/ with a compiled single binary
114
- bun spark-ssr start # run in production
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 === '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,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 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
- const SHIP_DIRS = ['pages', 'components', 'api', 'public'];
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.compile);
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.1.1",
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",
@@ -30,7 +30,8 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "linkedom": "^0.18.12",
33
- "spark-html": "^0.27.7"
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
- * 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
@@ -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
- 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)
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 clean = String(source)
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);