spark-ssr 0.3.4 → 0.4.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
@@ -142,6 +142,13 @@ answers 303, `status="401"` sets the code, default 403. An `is_admin` (or
142
142
  `role`) column on the auth table rides into the session, so
143
143
  `guard="session.is_admin"` works — and admins read scoped tables unscoped.
144
144
 
145
+ **You rarely write the 404, though.** A `[param]` page whose single-row lookup
146
+ comes back empty **404s automatically** — the `<template else status="404">`
147
+ above is only for a custom message. And every error status renders a **styled
148
+ default page** with no file to write; drop a `pages/404.html` (or `500.html`,
149
+ any `<status>.html`) to override it. With `auth` configured, a bare
150
+ `guard="session"` defaults to redirecting to `/login`.
151
+
145
152
  ## Forms without JavaScript
146
153
 
147
154
  The auto-CRUD endpoints answer a browser like a browser:
@@ -290,12 +297,23 @@ scope; the return value becomes the JSON response).
290
297
  - **Auth** — built-in email/password sessions (`POST /api/users?auth` logs in,
291
298
  passwords hash on insert), or a plugin (`auth.plugin` in spark.json) for
292
299
  OAuth/magic-link flows — the plugin answers "who is this person?", the
293
- framework still does sessions and cookies.
300
+ framework still does sessions and cookies. Configuring `auth` also gives you
301
+ working **`/login`, `/signup` and `/logout` pages** with zero UI written —
302
+ drop a `pages/login.html` (etc.) to override them.
303
+ - **Relations** — `<template each="c in post.comments">` declares a `comments`
304
+ table with a `post_id` foreign key and joins it onto the post. Nested data,
305
+ no SQL join in the template.
306
+ - **Flash messages** — `flash="Saved"` on a form sets a one-shot message that
307
+ survives the redirect; render it with `{flash}` or the default `<spark-flash>`
308
+ toast. `{session}` and `{path}` are ambient on every page too.
309
+ - **List UI** — `<spark-pager for="posts"/>` and `<spark-search/>` are drop-in,
310
+ no-JS `?page`/`?sort`/`?q` controls over the list conventions below.
294
311
  - **Middleware** — `middleware.html` runs on every request (`req`, `res`,
295
312
  `rateLimit`, `state` in scope; return `{ status, body }` to short-circuit).
296
313
  - **Uploads** — multipart bodies stream to `uploads/`; `:file.url` binds the
297
314
  stored URL into your INSERT.
298
- - **Error pages** — `404.html` / `500.html` (any `<status>.html`) at the
315
+ - **Error pages** — a styled default for every status out of the box; override
316
+ with `pages/404.html` / `500.html` (any `<status>.html`), or one at the
299
317
  project root.
300
318
  - **Static assets** — `public/` plus co-located page assets, served as-is.
301
319
  Project internals (spark.json, package.json, `*.db`, seeds, dotfiles) are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spark-ssr",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
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",
package/src/parse.js CHANGED
Binary file
package/src/render.js CHANGED
@@ -94,18 +94,24 @@ async function walkNode(node, scope, ctx, depth) {
94
94
 
95
95
  if (node.hasAttribute('import')) return renderImport(node, scope, ctx, depth);
96
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);
97
+ // No-JS forms (§5): redirect="…" and flash="…" attributes on a form posting
98
+ // to /api/* become hidden _redirect / _flash fields, so the plain-browser 303
99
+ // knows where to land and what one-shot message to show. The attributes
100
+ // themselves never reach the browser.
101
+ if (tag === 'form' && (node.hasAttribute('redirect') || node.hasAttribute('flash'))) {
102
+ const isApi = (node.getAttribute('action') || '').startsWith('/api/');
103
+ const addHidden = (name, value) => {
104
+ const h = ctx.document.createElement('input');
105
+ h.setAttribute('type', 'hidden');
106
+ h.setAttribute('name', name);
107
+ h.setAttribute('value', value);
108
+ node.appendChild(h);
109
+ };
110
+ for (const [attr, field] of [['redirect', '_redirect'], ['flash', '_flash']]) {
111
+ if (!node.hasAttribute(attr)) continue;
112
+ const v = node.getAttribute(attr);
113
+ node.removeAttribute(attr);
114
+ if (isApi) addHidden(field, v);
109
115
  }
110
116
  }
111
117
 
@@ -184,12 +190,35 @@ async function renderAwait(node, scope, ctx, depth) {
184
190
  const as = node.getAttribute('as');
185
191
  if (as) branchScope[as] = failed || value;
186
192
  // Resolved: the then-branch when declared, otherwise the direct content
187
- // (the doc's `<template await="todos">…</template>` shorthand).
193
+ // (the doc's `<template await="todos">…</template>` shorthand). Failed: the
194
+ // catch-branch if written, otherwise a default inline error boundary — so an
195
+ // await that throws degrades to a message, never a silently blank section.
196
+ if (failed && !catchNodes.length) {
197
+ node.parentNode?.insertBefore(defaultAwaitError(node, failed, ctx), node);
198
+ node.remove();
199
+ return;
200
+ }
188
201
  const branch = failed ? catchNodes : thenNodes.length ? thenNodes : direct;
189
202
  await insertRendered(branch, node, branchScope, ctx, depth);
190
203
  node.remove();
191
204
  }
192
205
 
206
+ // The zero-config error boundary for a failed <template await> with no catch.
207
+ function defaultAwaitError(node, failed, ctx) {
208
+ const doc = node.ownerDocument || (ctx && ctx.document);
209
+ const el = doc.createElement('div');
210
+ el.setAttribute('role', 'alert');
211
+ el.setAttribute('data-spark-await-error', '');
212
+ el.setAttribute('style',
213
+ 'border:1px solid #ff6b6b;background:rgba(255,107,107,.1);color:#ff6b6b;'
214
+ + 'border-radius:8px;padding:.6rem .8rem;font-size:.85rem');
215
+ // Dev shows the real reason; production stays generic.
216
+ el.textContent = ctx && ctx.dev
217
+ ? '⚠ Failed to load: ' + (failed && (failed.message || String(failed)))
218
+ : '⚠ This section could not be loaded.';
219
+ return el;
220
+ }
221
+
193
222
  async function renderIfChain(node, scope, ctx, depth) {
194
223
  // Collect the chain: this template plus adjacent else-if / else templates
195
224
  // (whitespace between them is fine).
package/src/schema.js CHANGED
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { join, resolve } from 'node:path';
22
22
  import { existsSync, readFileSync } from 'node:fs';
23
+ import { singular } from './parse.js';
23
24
 
24
25
  const INPUT_TYPE = {
25
26
  checkbox: 'INTEGER', number: 'REAL', range: 'REAL',
@@ -87,6 +88,18 @@ export function inferSchema(pagesData, config, root) {
87
88
 
88
89
  }
89
90
 
91
+ // Relations (§): each="c in post.comments" declares a child table `comments`
92
+ // with a `post_id` foreign key. The loop var's read fields ({c.body}) type
93
+ // its columns — nested data with no JOIN written by hand.
94
+ for (const pd of pagesData) {
95
+ for (const r of (pd.analysis && pd.analysis.relations) || []) {
96
+ const t = ensure(r.rel);
97
+ const fk = singular(r.parent) + '_id';
98
+ if (!t.columns[fk]) t.columns[fk] = 'INTEGER';
99
+ for (const f of (pd.analysis.memberFields.get(r.loopVar) || [])) setCol(t, f, 'TEXT');
100
+ }
101
+ }
102
+
90
103
  // The auth table always exists once auth is configured: its identity
91
104
  // column and a password.
92
105
  if (authTable) {
package/src/server.js CHANGED
@@ -14,7 +14,7 @@ import { loadConfig } from './config.js';
14
14
  import { connect } from './db.js';
15
15
  import {
16
16
  extractBlocks, analyze, mergeAnalyses, dataPlan, rewriteParams, singleShaped,
17
- maskComments, extractForms, validateFields, sqlTables,
17
+ maskComments, extractForms, validateFields, sqlTables, singular,
18
18
  } from './parse.js';
19
19
  import { renderFragment, evalExpr } from './render.js';
20
20
  import { clientComponent, initModule } from './hydrate.js';
@@ -241,6 +241,28 @@ function readSession(cookieHeader, secret) {
241
241
  const SESSION_COOKIE = (value, clear = false) =>
242
242
  `spark_session=${clear ? '' : value}; Path=/; HttpOnly; SameSite=Lax${clear ? '; Max-Age=0' : ''}`;
243
243
 
244
+ // One-shot flash messages: a signed, read-once cookie. Set on a form's success
245
+ // 303 (flash="…") and exposed as ambient {flash} on the very next page, then
246
+ // cleared — the "Saved!" / "Signed out" toast every app needs, no state store.
247
+ function signFlash(msg, secret) {
248
+ const data = b64(String(msg));
249
+ return data + '.' + createHmac('sha256', secret).update(data).digest('base64url');
250
+ }
251
+ function readFlash(cookieHeader, secret) {
252
+ const raw = String(cookieHeader || '').split(/;\s*/)
253
+ .map((p) => p.split('=')).find((kv) => kv[0].trim() === 'spark_flash');
254
+ if (!raw || !raw[1]) return null;
255
+ const [data, mac] = raw[1].split('.');
256
+ if (!data || !mac) return null;
257
+ const expect = createHmac('sha256', secret).update(data).digest('base64url');
258
+ try {
259
+ if (!timingSafeEqual(Buffer.from(mac), Buffer.from(expect))) return null;
260
+ return Buffer.from(data, 'base64url').toString('utf8');
261
+ } catch { return null; }
262
+ }
263
+ const FLASH_COOKIE = (value, clear = false) =>
264
+ `spark_flash=${clear ? '' : value}; Path=/; SameSite=Lax${clear ? '; Max-Age=0' : ''}`;
265
+
244
266
  // Roles in one column: an is_admin (or role) column on the auth table
245
267
  // unlocks guard="session.is_admin" and unscoped reads for admins.
246
268
  const isAdmin = (s) => !!s && (s.is_admin === 1 || s.is_admin === true || s.role === 'admin');
@@ -1049,14 +1071,31 @@ export async function serve(options = {}) {
1049
1071
  }
1050
1072
 
1051
1073
  async function buildScope(pd, req) {
1052
- // `path` is ambient like `session` — the layout's nav highlights the
1053
- // current page with it. Query/params may shadow it deliberately.
1054
- const scope = { path: req.path, ...req.query, ...req.params, session: req.session };
1074
+ // `path`, `session` and `flash` are ambient no query declares them. The
1075
+ // layout reads {session} for the signed-in user and {flash} (or the
1076
+ // <spark-flash> toast) for the one-shot message from the last redirect.
1077
+ const scope = { path: req.path, flash: readFlash(req.headers.cookie, secret), ...req.query, ...req.params, session: req.session };
1055
1078
  if (pd.code) Object.assign(scope, await runPageScript(pd.code, req));
1056
1079
  for (const p of pd.plan) {
1057
1080
  if (scope[p.var] !== undefined) continue; // the page <script> won
1058
1081
  scope[p.var] = await resolveSource(p, req);
1059
1082
  }
1083
+ // Relations (§): each="c in post.comments" attaches the child rows onto the
1084
+ // parent object(s) via the inferred foreign key — one small query per
1085
+ // parent, no JOIN in the template. Identifiers come from the parsed
1086
+ // template (word chars only), so they're safe to interpolate.
1087
+ for (const r of pd.analysis.relations || []) {
1088
+ const parent = scope[r.parent];
1089
+ if (parent == null) continue;
1090
+ const fk = singular(r.parent) + '_id';
1091
+ const attach = async (obj) => {
1092
+ if (!obj || obj.id == null || obj[r.rel] !== undefined) return;
1093
+ try { obj[r.rel] = await db.query(`SELECT * FROM ${r.rel} WHERE ${fk} = ?`, [obj.id]); }
1094
+ catch { obj[r.rel] = []; }
1095
+ };
1096
+ if (Array.isArray(parent)) { for (const o of parent) await attach(o); }
1097
+ else await attach(parent);
1098
+ }
1060
1099
  return scope;
1061
1100
  }
1062
1101
 
@@ -1075,22 +1114,61 @@ export async function serve(options = {}) {
1075
1114
  const scope = await buildScope(pd, req);
1076
1115
  if (extra) Object.assign(scope, extra.scope || {});
1077
1116
 
1078
- // Declarative guard (§3): <spark-ssr guard="session" redirect="/login" />
1117
+ // Declarative guard (§3): <spark-ssr guard="session" redirect="/login" />.
1118
+ // With auth configured, a bare `guard="session"` (no redirect, no status)
1119
+ // defaults to sending the visitor to /login with a ?next back to here —
1120
+ // the built-in login form returns them once they sign in.
1079
1121
  for (const b of pd.blocks) {
1080
1122
  if (!b.guard) continue;
1081
1123
  if (!evalExpr(b.guard, scope)) {
1082
1124
  if (b.redirect) return new Response(null, { status: 303, headers: { location: b.redirect } });
1125
+ if (config.auth && !b.status) {
1126
+ return new Response(null, { status: 303, headers: { location: '/login?next=' + encodeURIComponent(req.path) } });
1127
+ }
1083
1128
  return errorPage(b.status || 403);
1084
1129
  }
1085
1130
  }
1086
1131
 
1132
+ // Auto-404 (§3): a dynamic [param] page that looks up one row and finds
1133
+ // nothing IS a 404 — no need to hand-write <template else status="404">.
1134
+ // Only fires when the page reads that row as an object ({post.title}); an
1135
+ // explicit if/else branch in the page opts out (it renders its own answer),
1136
+ // and form re-renders (extra.status) are left alone.
1137
+ if (!extra && page.segs.some((s) => s.startsWith('['))
1138
+ && !/<template\b[^>]*\b(?:else|else-if)\b/i.test(pd.html)) {
1139
+ for (const p of pd.plan) {
1140
+ if (p.shape === 'row' && pd.analysis.memberRoots.has(p.var) && scope[p.var] == null) {
1141
+ return errorPage(404);
1142
+ }
1143
+ }
1144
+ }
1145
+
1087
1146
  const hydrate = shouldHydrate(pd);
1088
1147
  // Component imports keep their host (import + name + props) on pages the
1089
1148
  // page host won't rebuild wholesale, so a client mount re-resolves them
1090
1149
  // and their own <script> comes alive (counters, demos, …).
1091
1150
  const hasComponents = /\bimport\s*=\s*"/.test(pd.html);
1092
- const rctx = { loadComponent, keepImports: !hydrate };
1093
- const body = await renderFragment(pd.html, scope, rctx);
1151
+ const rctx = { loadComponent, keepImports: !hydrate, dev: live };
1152
+ let body = await renderFragment(pd.html, scope, rctx);
1153
+ // <spark-flash/> — a drop-in styled toast that shows the one-shot {flash}
1154
+ // message and nothing when there isn't one. Layout writes it once.
1155
+ if (/<spark-flash\b/i.test(body)) {
1156
+ body = body.replace(/<spark-flash\b[^>]*>(?:\s*<\/spark-flash>)?/gi, () => flashToast(scope.flash));
1157
+ }
1158
+ // <spark-pager for="posts"/> and <spark-search/> — the default UI over the
1159
+ // list conventions (§10): ?page/?sort links and a ?q search box, no wiring.
1160
+ if (/<spark-pager\b/i.test(body)) {
1161
+ body = body.replace(/<spark-pager\b([^>]*)>(?:\s*<\/spark-pager>)?/gi, (_m, attrs) => {
1162
+ const name = (attrs.match(/\bfor\s*=\s*"([^"]*)"/) || [])[1];
1163
+ return pagerHtml(name ? scope[name] : null, req.query);
1164
+ });
1165
+ }
1166
+ if (/<spark-search\b/i.test(body)) {
1167
+ body = body.replace(/<spark-search\b([^>]*)>(?:\s*<\/spark-search>)?/gi, (_m, attrs) => {
1168
+ const ph = (attrs.match(/\bplaceholder\s*=\s*"([^"]*)"/) || [])[1] || 'Search…';
1169
+ return searchHtml(req.query, ph);
1170
+ });
1171
+ }
1094
1172
  let headExtra = pd.head ? renderHead(pd.head, (e) => evalExpr(e, scope)) : '';
1095
1173
  if (headExtra) headExtra = withOgTags(headExtra);
1096
1174
  let html = shell(page, body, {
@@ -1099,21 +1177,207 @@ export async function serve(options = {}) {
1099
1177
  if (live && pd.plan.unresolved && pd.plan.unresolved.length) {
1100
1178
  html = html.replace('</body>', unresolvedBanner(pd.plan.unresolved) + '\n</body>');
1101
1179
  }
1180
+ const headers = { 'content-type': 'text/html; charset=utf-8' };
1181
+ // A shown flash is consumed — clear the cookie so it appears exactly once.
1182
+ if (scope.flash) headers['set-cookie'] = FLASH_COOKIE('', true);
1102
1183
  return new Response(html, {
1103
1184
  status: (extra && extra.status) || rctx.status || 200,
1104
- headers: { 'content-type': 'text/html; charset=utf-8' },
1185
+ headers,
1105
1186
  });
1106
1187
  }
1107
1188
 
1108
- const STATUS_TEXT = { 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not found' };
1189
+ // The default flash toast (self-contained, design-system styled). Empty when
1190
+ // there's no message, so <spark-flash/> can live permanently in the layout.
1191
+ function flashToast(msg) {
1192
+ if (!msg) return '';
1193
+ return '<div role="status" style="position:fixed;left:50%;bottom:1.25rem;transform:translateX(-50%);'
1194
+ + 'z-index:9999;max-width:90vw;background:#ffd24a;color:#000;font-weight:700;'
1195
+ + 'font-family:inherit;font-size:.85rem;padding:.6rem 1rem;border-radius:10px;'
1196
+ + 'box-shadow:0 6px 24px rgba(0,0,0,.35)">' + escapeHtml(msg) + '</div>';
1197
+ }
1198
+
1199
+ // <spark-pager for="posts"/> — numbered prev/next links over a list source's
1200
+ // .page/.pages, preserving the current ?q/?sort. Renders nothing for a single
1201
+ // page. Server-side only; a plain <a> nav, so it works with JS disabled.
1202
+ function pagerHtml(list, query) {
1203
+ if (!list || !(list.pages > 1)) return '';
1204
+ const cur = Number(list.page) || 1;
1205
+ const last = Number(list.pages);
1206
+ const base = { ...query };
1207
+ delete base.page;
1208
+ const href = (p) => {
1209
+ const q = new URLSearchParams(base);
1210
+ q.set('page', String(p));
1211
+ return '?' + q.toString();
1212
+ };
1213
+ const cell = 'min-width:2rem;text-align:center;padding:.35rem .55rem;border-radius:8px;'
1214
+ + 'border:1px solid #333;font-size:.85rem;text-decoration:none;color:inherit';
1215
+ const item = (p, label, { on, off } = {}) => off
1216
+ ? `<span style="${cell};opacity:.35">${label}</span>`
1217
+ : on
1218
+ ? `<span aria-current="page" style="${cell};background:#ffd24a;color:#000;border-color:#ffd24a;font-weight:700">${label}</span>`
1219
+ : `<a href="${href(p)}" style="${cell}">${label}</a>`;
1220
+ const nums = [];
1221
+ for (let p = 1; p <= last; p++) {
1222
+ if (p === 1 || p === last || Math.abs(p - cur) <= 1) nums.push(p);
1223
+ else if (nums[nums.length - 1] !== '…') nums.push('…');
1224
+ }
1225
+ const parts = [item(cur - 1, '‹', { off: cur <= 1 })];
1226
+ for (const n of nums) {
1227
+ parts.push(n === '…' ? `<span style="${cell};border-color:transparent">…</span>` : item(n, String(n), { on: n === cur }));
1228
+ }
1229
+ parts.push(item(cur + 1, '›', { off: cur >= last }));
1230
+ return '<nav class="spark-pager" role="navigation" aria-label="Pagination" '
1231
+ + 'style="display:flex;gap:.35rem;justify-content:center;align-items:center;flex-wrap:wrap;margin:1.25rem 0">'
1232
+ + parts.join('') + '</nav>';
1233
+ }
1234
+
1235
+ // <spark-search placeholder="Search…"/> — a no-JS GET search box bound to ?q,
1236
+ // carrying the current ?sort so a search doesn't drop the sort order.
1237
+ function searchHtml(query, placeholder) {
1238
+ const sort = query.sort ? `<input type="hidden" name="sort" value="${escapeHtml(query.sort)}">` : '';
1239
+ return '<form method="get" role="search" class="spark-search" style="margin:0 0 1.25rem">'
1240
+ + sort
1241
+ + `<input type="search" name="q" value="${escapeHtml(query.q || '')}" placeholder="${escapeHtml(placeholder)}" `
1242
+ + 'style="width:100%;font:inherit;color:inherit;background:transparent;border:1px solid #333;'
1243
+ + 'border-radius:8px;padding:.5rem .7rem"></form>';
1244
+ }
1245
+
1246
+ // Human copy for the built-in default error screen (used when the app ships
1247
+ // no <status>.html of its own).
1248
+ const STATUS_INFO = {
1249
+ 400: ['Bad request', 'That request could not be understood.'],
1250
+ 401: ['Sign in required', 'You need to sign in to view this page.'],
1251
+ 403: ['Forbidden', "You don't have access to this page."],
1252
+ 404: ['Page not found', "The page you're looking for doesn't exist — it may have moved."],
1253
+ 500: ['Server error', 'Something went wrong on our end. Try again in a moment.'],
1254
+ };
1255
+
1256
+ // Zero-config error screen: a styled, self-contained page in the Spark design
1257
+ // system (dark default, gold ⚡, monospace) — no dependency on the app's
1258
+ // layout or data, so it renders even when those are what failed. Apps override
1259
+ // it by dropping a <status>.html in pages/ (or the project root).
1260
+ function defaultErrorPage(status) {
1261
+ const [title, blurb] = STATUS_INFO[status] || ['Error', 'Something went wrong.'];
1262
+ const body = `<!doctype html>
1263
+ <html lang="en"><head><meta charset="utf-8">
1264
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1265
+ <meta name="robots" content="noindex">
1266
+ <title>${status} · ${escapeHtml(title)}</title>
1267
+ <style>
1268
+ :root{color-scheme:dark light}
1269
+ *{box-sizing:border-box}
1270
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
1271
+ background:#000;color:#fff;text-align:center;padding:2rem;
1272
+ font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;line-height:1.6}
1273
+ @media(prefers-color-scheme:light){body{background:#fff;color:#1a1a1a}}
1274
+ .bolt{font-size:2.25rem;filter:drop-shadow(0 0 16px rgba(255,210,74,.45))}
1275
+ .code{font-size:clamp(3.5rem,14vw,6rem);font-weight:800;letter-spacing:-.04em;margin:.25rem 0 0;
1276
+ background:linear-gradient(110deg,currentColor,#ffd24a);-webkit-background-clip:text;background-clip:text;color:transparent}
1277
+ h1{font-size:1.15rem;font-weight:700;margin:.25rem 0 .5rem}
1278
+ p{color:#888;max-width:32rem;margin:0 auto 1.5rem;font-size:.95rem}
1279
+ @media(prefers-color-scheme:light){p{color:#666}}
1280
+ a{display:inline-block;color:#000;background:#ffd24a;text-decoration:none;font-weight:700;
1281
+ padding:.6rem 1.2rem;border-radius:8px;font-size:.9rem}
1282
+ a:active{transform:scale(.97)}
1283
+ </style></head>
1284
+ <body><main>
1285
+ <div class="bolt">⚡</div>
1286
+ <div class="code">${status}</div>
1287
+ <h1>${escapeHtml(title)}</h1>
1288
+ <p>${escapeHtml(blurb)}</p>
1289
+ <a href="/">← Back home</a>
1290
+ </main></body></html>`;
1291
+ return body;
1292
+ }
1293
+
1109
1294
  function errorPage(status) {
1110
- const file = join(root, `${status}.html`);
1111
- if (existsSync(file)) {
1112
- // The reload client rides along so fixing the page un-sticks the browser.
1113
- const body = readFileSync(file, 'utf8') + (live ? '\n' + RELOAD_CLIENT : '');
1114
- return new Response(body, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });
1295
+ // Override precedence: pages/<status>.html (filesystem convention)
1296
+ // <root>/<status>.html (back-compat) → the built-in default.
1297
+ for (const dir of new Set([pagesDir, root])) {
1298
+ const file = join(dir, `${status}.html`);
1299
+ if (existsSync(file)) {
1300
+ // The reload client rides along so fixing the page un-sticks the browser.
1301
+ const custom = readFileSync(file, 'utf8') + (live ? '\n' + RELOAD_CLIENT : '');
1302
+ return new Response(custom, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });
1303
+ }
1115
1304
  }
1116
- return new Response(STATUS_TEXT[status] || 'Server error', { status });
1305
+ const body = defaultErrorPage(status) + (live ? '\n' + RELOAD_CLIENT : '');
1306
+ return new Response(body, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });
1307
+ }
1308
+
1309
+ // Built-in, overridable auth screens. Configuring `auth` in spark.json is
1310
+ // enough to get working /login, /logout and /signup — no page to write. Drop
1311
+ // a pages/login.html (etc.) to override; a user page always wins the route.
1312
+ // These are self-contained (design system inline) so they render before any
1313
+ // layout or data exists — same robustness contract as the error pages.
1314
+ function authScreen(kind, { next, error } = {}) {
1315
+ const identity = (config.auth && config.auth.identity) || 'email';
1316
+ const table = config.auth && config.auth.table;
1317
+ const idType = /email/i.test(identity) ? 'email' : 'text';
1318
+ const nextField = next && String(next).startsWith('/') ? escapeHtml(next) : '';
1319
+ const isSignup = kind === 'signup';
1320
+ const action = isSignup ? `/api/${table}` : `/api/${table}?auth`;
1321
+ const title = isSignup ? 'Create account' : 'Sign in';
1322
+ const errMsg = error
1323
+ ? (isSignup ? 'Could not create that account — it may already exist.' : 'Wrong ' + identity + ' or password.')
1324
+ : '';
1325
+ const alt = isSignup
1326
+ ? `Already have an account? <a href="/login${nextField ? '?next=' + encodeURIComponent(nextField) : ''}">Sign in</a>`
1327
+ : `Need an account? <a href="/signup${nextField ? '?next=' + encodeURIComponent(nextField) : ''}">Create one</a>`;
1328
+ return `<!doctype html>
1329
+ <html lang="en"><head><meta charset="utf-8">
1330
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1331
+ <meta name="robots" content="noindex">
1332
+ <title>${title}</title>
1333
+ <style>
1334
+ :root{color-scheme:dark light}*{box-sizing:border-box}
1335
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
1336
+ background:#000;color:#fff;padding:2rem;
1337
+ font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;line-height:1.6}
1338
+ @media(prefers-color-scheme:light){body{background:#fff;color:#1a1a1a}}
1339
+ form{width:100%;max-width:22rem;background:#0a0a0a;border:1px solid #1a1a1a;border-radius:12px;padding:1.75rem}
1340
+ @media(prefers-color-scheme:light){form{background:#fafafa;border-color:#ededed}}
1341
+ .bolt{font-size:1.5rem;text-align:center;filter:drop-shadow(0 0 14px rgba(255,210,74,.45))}
1342
+ h1{font-size:1.15rem;font-weight:700;text-align:center;margin:.25rem 0 1.25rem}
1343
+ label{display:block;font-size:.8rem;color:#888;margin:0 0 .9rem}
1344
+ @media(prefers-color-scheme:light){label{color:#666}}
1345
+ input{width:100%;margin-top:.3rem;font:inherit;color:inherit;background:transparent;
1346
+ border:1px solid #333;border-radius:8px;padding:.55rem .7rem}
1347
+ @media(prefers-color-scheme:light){input{border-color:#d4d4d4}}
1348
+ input:focus{outline:none;border-color:#ffd24a}
1349
+ button{width:100%;margin-top:.5rem;font:inherit;font-weight:700;cursor:pointer;color:#000;
1350
+ background:#ffd24a;border:0;border-radius:8px;padding:.6rem}
1351
+ button:active{transform:scale(.99)}
1352
+ .err{background:rgba(255,107,107,.12);border:1px solid #ff6b6b;color:#ff6b6b;
1353
+ border-radius:8px;padding:.5rem .7rem;font-size:.82rem;margin:0 0 1rem}
1354
+ .alt{text-align:center;font-size:.82rem;color:#888;margin:1rem 0 0}
1355
+ .alt a{color:#ffd24a}@media(prefers-color-scheme:light){.alt a{color:#9a6a00}}
1356
+ </style></head>
1357
+ <body>
1358
+ <form method="post" action="${action}">
1359
+ <div class="bolt">⚡</div>
1360
+ <h1>${title}</h1>
1361
+ ${errMsg ? `<p class="err">${escapeHtml(errMsg)}</p>` : ''}
1362
+ ${nextField ? `<input type="hidden" name="_redirect" value="${nextField}">` : (isSignup ? '<input type="hidden" name="_redirect" value="/login">' : '')}
1363
+ <label>${escapeHtml(identity[0].toUpperCase() + identity.slice(1))}
1364
+ <input name="${escapeHtml(identity)}" type="${idType}" autocomplete="username" required autofocus></label>
1365
+ <label>Password
1366
+ <input name="password" type="password" autocomplete="${isSignup ? 'new-password' : 'current-password'}" required></label>
1367
+ <button>${title}</button>
1368
+ <p class="alt">${alt}</p>
1369
+ </form>
1370
+ ${live ? RELOAD_CLIENT : ''}
1371
+ </body></html>`;
1372
+ }
1373
+
1374
+ // Which built-in auth screen a path maps to (only when auth is configured and
1375
+ // the app ships no page of its own for it — a user page always wins first).
1376
+ function builtinAuthKind(pathname) {
1377
+ if (!config.auth) return null;
1378
+ if (pathname === '/login') return 'login';
1379
+ if (pathname === '/signup') return 'signup';
1380
+ return null;
1117
1381
  }
1118
1382
 
1119
1383
  // Dev-only error overlay (§4): the real error — SQL, file, line — on the
@@ -1407,6 +1671,10 @@ code{color:#fdba74}em{color:#a8a29e}</style></head><body>
1407
1671
  const headers = new Headers({ location: back });
1408
1672
  const sc = res.headers.get('set-cookie');
1409
1673
  if (sc) headers.set('set-cookie', sc);
1674
+ // flash="…" on the form → a one-shot message on the next page.
1675
+ if (typeof fields._flash === 'string' && fields._flash) {
1676
+ headers.append('set-cookie', FLASH_COOKIE(signFlash(fields._flash, secret)));
1677
+ }
1410
1678
  return finish(new Response(null, { status: 303, headers }));
1411
1679
  }
1412
1680
  let errors = null;
@@ -1424,6 +1692,14 @@ code{color:#fdba74}em{color:#a8a29e}</style></head><body>
1424
1692
  scope: { errors, values: fields }, status: res.status,
1425
1693
  }));
1426
1694
  }
1695
+ // No page owns the referer — but a built-in auth screen might.
1696
+ // Bounce back to it with ?error so the form shows the message.
1697
+ const kind = builtinAuthKind(r.pathname);
1698
+ if (kind) {
1699
+ const nx = r.searchParams.get('next');
1700
+ const q = 'error=1' + (nx ? '&next=' + encodeURIComponent(nx) : '');
1701
+ return finish(new Response(null, { status: 303, headers: { location: `${r.pathname}?${q}` } }));
1702
+ }
1427
1703
  } catch { /* fall through to the raw response */ }
1428
1704
  }
1429
1705
  }
@@ -1444,6 +1720,22 @@ code{color:#fdba74}em{color:#a8a29e}</style></head><body>
1444
1720
  return finish(await servePage(hit.page, req));
1445
1721
  }
1446
1722
 
1723
+ // Built-in auth screens (only if auth is configured and no user page
1724
+ // claimed the route above). /logout clears the session and 303s home.
1725
+ if (config.auth && (pathname === '/logout')) {
1726
+ return finish(new Response(null, {
1727
+ status: 303,
1728
+ headers: { location: '/', 'set-cookie': SESSION_COOKIE('', true) },
1729
+ }));
1730
+ }
1731
+ const authKind = builtinAuthKind(pathname);
1732
+ if (authKind && request.method === 'GET') {
1733
+ // A signed-in visitor never needs the login/signup form.
1734
+ if (session) return finish(new Response(null, { status: 303, headers: { location: '/' } }));
1735
+ return finish(new Response(authScreen(authKind, { next: url.searchParams.get('next'), error: url.searchParams.get('error') }),
1736
+ { headers: { 'content-type': 'text/html; charset=utf-8' } }));
1737
+ }
1738
+
1447
1739
  return finish(errorPage(404));
1448
1740
  } catch (e) {
1449
1741
  if (!quiet) console.error(`[spark-ssr] ${request.method} ${pathname} — ${e.stack || e.message}`);