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 +20 -2
- package/package.json +1 -1
- package/src/parse.js +0 -0
- package/src/render.js +42 -13
- package/src/schema.js +13 -0
- package/src/server.js +307 -15
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** —
|
|
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
|
+
"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):
|
|
98
|
-
//
|
|
99
|
-
// to land
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
node.
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
node.appendChild(
|
|
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`
|
|
1053
|
-
//
|
|
1054
|
-
|
|
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
|
-
|
|
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
|
|
1185
|
+
headers,
|
|
1105
1186
|
});
|
|
1106
1187
|
}
|
|
1107
1188
|
|
|
1108
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
const
|
|
1114
|
-
|
|
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
|
-
|
|
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}`);
|