@velastack/cms 0.1.0 → 0.2.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 +1 -1
- package/dist/backend/auth/editors.d.ts +51 -0
- package/dist/backend/auth/editors.js +157 -0
- package/dist/backend/auth/local-editors.d.ts +22 -0
- package/dist/backend/auth/local-editors.js +44 -0
- package/dist/backend/auth/scrypt.d.ts +20 -0
- package/dist/backend/auth/scrypt.js +80 -0
- package/dist/backend/factory.d.ts +25 -0
- package/dist/backend/factory.js +202 -0
- package/dist/backend/http.d.ts +34 -0
- package/dist/backend/http.js +79 -0
- package/dist/backend/index.d.ts +45 -0
- package/dist/backend/index.js +39 -0
- package/dist/backend/router.d.ts +52 -0
- package/dist/backend/router.js +57 -0
- package/dist/backend/routes/context.d.ts +46 -0
- package/dist/backend/routes/context.js +30 -0
- package/dist/backend/routes/docs.d.ts +2 -0
- package/dist/backend/routes/docs.js +150 -0
- package/dist/backend/routes/login.d.ts +19 -0
- package/dist/backend/routes/login.js +155 -0
- package/dist/backend/routes/media.d.ts +22 -0
- package/dist/backend/routes/media.js +119 -0
- package/dist/backend/routes/pages.d.ts +19 -0
- package/dist/backend/routes/pages.js +221 -0
- package/dist/backend/routes/release.d.ts +10 -0
- package/dist/backend/routes/release.js +147 -0
- package/dist/backend/routes/site.d.ts +2 -0
- package/dist/backend/routes/site.js +40 -0
- package/dist/backend/routes/table.d.ts +11 -0
- package/dist/backend/routes/table.js +72 -0
- package/dist/backend/routes/test-reset.d.ts +15 -0
- package/dist/backend/routes/test-reset.js +112 -0
- package/dist/backend/routes/user.d.ts +14 -0
- package/dist/backend/routes/user.js +21 -0
- package/dist/backend/storage.d.ts +18 -0
- package/dist/backend/storage.js +45 -0
- package/dist/backend/store/cache.d.ts +34 -0
- package/dist/backend/store/cache.js +38 -0
- package/dist/backend/store/db.d.ts +10 -0
- package/dist/backend/store/db.js +29 -0
- package/dist/backend/store/migrate.d.ts +10 -0
- package/dist/backend/store/migrate.js +30 -0
- package/dist/backend/store/migrations/0001-init.d.ts +1 -0
- package/dist/backend/store/migrations/0001-init.js +129 -0
- package/dist/backend/store/migrations/0002-site.d.ts +1 -0
- package/dist/backend/store/migrations/0002-site.js +86 -0
- package/dist/backend/store/migrations/0003-editors.d.ts +1 -0
- package/dist/backend/store/migrations/0003-editors.js +44 -0
- package/dist/backend/store/migrations/index.d.ts +15 -0
- package/dist/backend/store/migrations/index.js +18 -0
- package/dist/backend/store/queries.d.ts +67 -0
- package/dist/backend/store/queries.js +1207 -0
- package/dist/backend/store/sqlite.d.ts +22 -0
- package/dist/backend/store/sqlite.js +29 -0
- package/dist/backend/testing/fixtures.d.ts +39 -0
- package/dist/backend/testing/fixtures.js +61 -0
- package/dist/backend/testing/harness.d.ts +35 -0
- package/dist/backend/testing/harness.js +149 -0
- package/dist/backend/types.d.ts +102 -0
- package/dist/cms.d.ts +3 -3
- package/dist/cms.js +1 -1
- package/dist/components/admin-bar/publish-dialog.svelte +1 -1
- package/dist/components/cms/cms-image-editable.svelte +1 -1
- package/dist/components/cms/cms-store.svelte.d.ts +3 -68
- package/dist/components/cms/cms-store.svelte.js +25 -10
- package/dist/components/cms/locale-merge.d.ts +1 -1
- package/dist/components/cms/locale-merge.js +1 -1
- package/dist/components/cms/scope.d.ts +1 -1
- package/dist/core/page-entry.d.ts +43 -0
- package/dist/core/page-entry.js +21 -0
- package/dist/{components/cms → core}/path.d.ts +13 -0
- package/dist/{components/cms → core}/path.js +36 -0
- package/dist/core/wire.d.ts +182 -0
- package/dist/core/wire.js +1 -0
- package/dist/{srv → server}/api-adapter.js +2 -2
- package/dist/{srv → server}/load-cms.js +1 -1
- package/dist/{srv → server}/mock-adapter.d.ts +3 -24
- package/dist/{srv → server}/mock-adapter.js +5 -22
- package/dist/{srv → server}/types.d.ts +2 -6
- package/dist/server/types.js +1 -0
- package/package.json +13 -4
- package/plugin/dist/index.js +7 -0
- package/plugin/dist/index.js.map +1 -1
- package/plugin/dist/media.d.ts +16 -7
- package/plugin/dist/media.js +23 -10
- package/plugin/dist/media.js.map +1 -1
- /package/dist/{srv → backend}/types.js +0 -0
- /package/dist/{srv → server}/README.md +0 -0
- /package/dist/{srv → server}/api-adapter.d.ts +0 -0
- /package/dist/{srv → server}/cms.d.ts +0 -0
- /package/dist/{srv → server}/cms.js +0 -0
- /package/dist/{srv → server}/index.d.ts +0 -0
- /package/dist/{srv → server}/index.js +0 -0
- /package/dist/{srv → server}/load-cms.d.ts +0 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
2
|
+
const STYLE = `
|
|
3
|
+
:root { color-scheme: light dark; }
|
|
4
|
+
* { box-sizing: border-box; }
|
|
5
|
+
body {
|
|
6
|
+
margin: 0;
|
|
7
|
+
min-height: 100vh;
|
|
8
|
+
display: grid;
|
|
9
|
+
place-items: center;
|
|
10
|
+
padding: 1rem;
|
|
11
|
+
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
12
|
+
background: #fff;
|
|
13
|
+
color: #0a0a0a;
|
|
14
|
+
}
|
|
15
|
+
.card {
|
|
16
|
+
width: 100%;
|
|
17
|
+
max-width: 22rem;
|
|
18
|
+
border: 1px solid #e5e5e5;
|
|
19
|
+
border-radius: 0.75rem;
|
|
20
|
+
padding: 1.5rem;
|
|
21
|
+
background: #fff;
|
|
22
|
+
}
|
|
23
|
+
h1 { margin: 0 0 0.25rem; font-size: 1.125rem; font-weight: 600; text-align: center; }
|
|
24
|
+
p.sub { margin: 0 0 1.25rem; color: #737373; text-align: center; }
|
|
25
|
+
label { display: block; font-weight: 500; margin-bottom: 0.375rem; }
|
|
26
|
+
input {
|
|
27
|
+
width: 100%;
|
|
28
|
+
padding: 0.5rem 0.75rem;
|
|
29
|
+
border: 1px solid #d4d4d4;
|
|
30
|
+
border-radius: 0.5rem;
|
|
31
|
+
background: #fff;
|
|
32
|
+
color: inherit;
|
|
33
|
+
font: inherit;
|
|
34
|
+
}
|
|
35
|
+
input:focus-visible { outline: 2px solid #0a0a0a; outline-offset: 1px; border-color: transparent; }
|
|
36
|
+
.field { margin-bottom: 1rem; }
|
|
37
|
+
button {
|
|
38
|
+
width: 100%;
|
|
39
|
+
padding: 0.5rem 0.75rem;
|
|
40
|
+
border: 0;
|
|
41
|
+
border-radius: 0.5rem;
|
|
42
|
+
background: #0a0a0a;
|
|
43
|
+
color: #fafafa;
|
|
44
|
+
font: inherit;
|
|
45
|
+
font-weight: 500;
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
}
|
|
48
|
+
button:hover { background: #262626; }
|
|
49
|
+
.error {
|
|
50
|
+
margin: 0 0 1rem;
|
|
51
|
+
padding: 0.5rem 0.75rem;
|
|
52
|
+
border-radius: 0.5rem;
|
|
53
|
+
background: #fef2f2;
|
|
54
|
+
color: #b91c1c;
|
|
55
|
+
}
|
|
56
|
+
@media (prefers-color-scheme: dark) {
|
|
57
|
+
body { background: #0a0a0a; color: #fafafa; }
|
|
58
|
+
.card { background: #0a0a0a; border-color: #262626; }
|
|
59
|
+
input { background: #0a0a0a; border-color: #404040; }
|
|
60
|
+
input:focus-visible { outline-color: #fafafa; }
|
|
61
|
+
button { background: #fafafa; color: #0a0a0a; }
|
|
62
|
+
button:hover { background: #e5e5e5; }
|
|
63
|
+
p.sub { color: #a3a3a3; }
|
|
64
|
+
.error { background: #2a1215; color: #fca5a5; }
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
67
|
+
const page = (title, body) => `<!doctype html>
|
|
68
|
+
<html lang="en">
|
|
69
|
+
<head>
|
|
70
|
+
<meta charset="utf-8">
|
|
71
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
72
|
+
<meta name="robots" content="noindex">
|
|
73
|
+
<title>${escapeHtml(title)}</title>
|
|
74
|
+
<style>${STYLE}</style>
|
|
75
|
+
</head>
|
|
76
|
+
<body>
|
|
77
|
+
<main class="card">
|
|
78
|
+
${body}
|
|
79
|
+
</main>
|
|
80
|
+
</body>
|
|
81
|
+
</html>`;
|
|
82
|
+
const loginForm = (error) => page('Sign in to edit', `<h1>Sign in to edit</h1>
|
|
83
|
+
<p class="sub">Use your email and password</p>
|
|
84
|
+
${error ? `<p class="error" role="alert">${escapeHtml(error)}</p>` : ''}
|
|
85
|
+
<form method="POST">
|
|
86
|
+
<div class="field">
|
|
87
|
+
<label for="email">Email</label>
|
|
88
|
+
<input id="email" name="email" type="email" autocomplete="email" required autofocus>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="field">
|
|
91
|
+
<label for="password">Password</label>
|
|
92
|
+
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
93
|
+
</div>
|
|
94
|
+
<button type="submit">Sign in</button>
|
|
95
|
+
</form>`);
|
|
96
|
+
const successPage = () => page('Signed in', `<h1>Signed in</h1>
|
|
97
|
+
<p class="sub">You can close this window.</p>
|
|
98
|
+
<script>
|
|
99
|
+
// The admin bar listens for this and re-fetches /user. It checks the
|
|
100
|
+
// origin on receipt, which is why posting to "*" here is safe: the
|
|
101
|
+
// message carries nothing secret.
|
|
102
|
+
window.parent?.postMessage({ type: 'velastack-cms-login-success' }, '*');
|
|
103
|
+
</script>`);
|
|
104
|
+
const html = (body, ctx, status = 200) => new Response(body, {
|
|
105
|
+
status,
|
|
106
|
+
headers: {
|
|
107
|
+
'content-type': 'text/html; charset=utf-8',
|
|
108
|
+
// The bar embeds this cross-origin, so the host decides who may frame it.
|
|
109
|
+
'content-security-policy': `frame-ancestors ${ctx.frameAncestors}`,
|
|
110
|
+
'cache-control': 'no-store'
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
export const getLogin = (ctx) => {
|
|
114
|
+
if (!ctx.auth.login)
|
|
115
|
+
return new Response(null, { status: 404 });
|
|
116
|
+
// Already signed in and authorized for this project — skip straight to the
|
|
117
|
+
// success screen. A session bound to a project the caller can't reach leaves
|
|
118
|
+
// `user` null, so they land on the form and can sign in as someone else.
|
|
119
|
+
if (ctx.user)
|
|
120
|
+
return html(successPage(), ctx);
|
|
121
|
+
return html(loginForm(), ctx);
|
|
122
|
+
};
|
|
123
|
+
export const postLogin = async (ctx) => {
|
|
124
|
+
if (!ctx.auth.login)
|
|
125
|
+
return new Response(null, { status: 404 });
|
|
126
|
+
let form;
|
|
127
|
+
try {
|
|
128
|
+
form = await ctx.event.request.formData();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return html(loginForm('Could not read the submitted form.'), ctx, 400);
|
|
132
|
+
}
|
|
133
|
+
const email = form.get('email');
|
|
134
|
+
const password = form.get('password');
|
|
135
|
+
if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
|
|
136
|
+
return html(loginForm('Enter both an email and a password.'), ctx, 400);
|
|
137
|
+
}
|
|
138
|
+
const grant = await ctx.auth.login(email, password, ctx.authCtx);
|
|
139
|
+
// One message either way — which half was wrong is not the caller's business.
|
|
140
|
+
if (!grant)
|
|
141
|
+
return html(loginForm('Invalid email or password.'), ctx, 400);
|
|
142
|
+
ctx.setSessionCookie(grant.token, grant.expiresAt);
|
|
143
|
+
return html(successPage(), ctx);
|
|
144
|
+
};
|
|
145
|
+
/** Kept so the admin bar's historical `/iframe/login/success` URL still
|
|
146
|
+
* resolves; the POST above renders the same screen inline. */
|
|
147
|
+
export const getLoginSuccess = (ctx) => {
|
|
148
|
+
if (!ctx.user) {
|
|
149
|
+
return new Response(null, {
|
|
150
|
+
status: 303,
|
|
151
|
+
headers: { location: `${ctx.mountPath}/iframe/login` }
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return html(successPage(), ctx);
|
|
155
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type CmsStorage } from '../storage.js';
|
|
2
|
+
import { type RouteCtx } from './context.js';
|
|
3
|
+
export declare const getMedia: (ctx: RouteCtx) => Response;
|
|
4
|
+
/**
|
|
5
|
+
* Multipart upload. Generates a random slug, preserves the original extension
|
|
6
|
+
* (or derives one from the MIME type), writes it into the configured upload
|
|
7
|
+
* directory, and records metadata.
|
|
8
|
+
*/
|
|
9
|
+
export declare const postMedia: (ctx: RouteCtx) => Promise<Response>;
|
|
10
|
+
export declare const deleteMediaItem: (ctx: RouteCtx) => Promise<Response>;
|
|
11
|
+
/**
|
|
12
|
+
* Serve an uploaded file's bytes. Public: these are referenced from published
|
|
13
|
+
* pages. `nosniff` plus the extension allowlist means an unrecognised type
|
|
14
|
+
* downloads rather than executing.
|
|
15
|
+
*
|
|
16
|
+
* Takes just a storage binding and a filename rather than a full `RouteCtx`, so
|
|
17
|
+
* a host can also mount it at its own `/uploads/[filename]` route — which is
|
|
18
|
+
* what keeps every absolute media URL already sitting in published content
|
|
19
|
+
* resolving after the migration.
|
|
20
|
+
*/
|
|
21
|
+
export declare const serveUploadBytes: (storage: CmsStorage, filename: string | undefined) => Promise<Response>;
|
|
22
|
+
export declare const getUpload: (ctx: RouteCtx) => Promise<Response>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The media library: list, upload, delete, and byte serving.
|
|
3
|
+
*
|
|
4
|
+
* The metadata index is editor-only — anonymous callers cannot enumerate what a
|
|
5
|
+
* project has uploaded — while the files themselves are public, since they are
|
|
6
|
+
* embedded in published pages.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, writeFile, unlink, readFile } from 'node:fs/promises';
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import { extname } from 'node:path';
|
|
11
|
+
import { isSafeUploadFilename, uploadContentType } from '../storage.js';
|
|
12
|
+
import { requireUser } from './context.js';
|
|
13
|
+
const MAX_BYTES = 10 * 1024 * 1024;
|
|
14
|
+
const ALLOWED_MIME = /^image\//;
|
|
15
|
+
const MIME_TO_EXT = {
|
|
16
|
+
'image/jpeg': '.jpg',
|
|
17
|
+
'image/png': '.png',
|
|
18
|
+
'image/webp': '.webp',
|
|
19
|
+
'image/gif': '.gif',
|
|
20
|
+
'image/svg+xml': '.svg',
|
|
21
|
+
'image/avif': '.avif'
|
|
22
|
+
};
|
|
23
|
+
const sanitizeExt = (e) => (/^\.[a-z0-9]{1,8}$/i.test(e) ? e.toLowerCase() : '');
|
|
24
|
+
export const getMedia = (ctx) => {
|
|
25
|
+
requireUser(ctx);
|
|
26
|
+
const url = ctx.event.url;
|
|
27
|
+
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0) | 0);
|
|
28
|
+
const limitRaw = Number(url.searchParams.get('limit') ?? 50) | 0;
|
|
29
|
+
const limit = Math.min(200, Math.max(1, limitRaw));
|
|
30
|
+
return Response.json(ctx.store.listMediaItems(ctx.projectId, { offset, limit }));
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Multipart upload. Generates a random slug, preserves the original extension
|
|
34
|
+
* (or derives one from the MIME type), writes it into the configured upload
|
|
35
|
+
* directory, and records metadata.
|
|
36
|
+
*/
|
|
37
|
+
export const postMedia = async (ctx) => {
|
|
38
|
+
const user = requireUser(ctx);
|
|
39
|
+
let formData;
|
|
40
|
+
try {
|
|
41
|
+
formData = await ctx.event.request.formData();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return new Response('expected multipart form-data', { status: 400 });
|
|
45
|
+
}
|
|
46
|
+
const file = formData.get('file');
|
|
47
|
+
if (!(file instanceof File))
|
|
48
|
+
return new Response('file field required', { status: 400 });
|
|
49
|
+
if (!ALLOWED_MIME.test(file.type)) {
|
|
50
|
+
return new Response('only images are supported', { status: 415 });
|
|
51
|
+
}
|
|
52
|
+
if (file.size > MAX_BYTES)
|
|
53
|
+
return new Response('file too large', { status: 413 });
|
|
54
|
+
const ext = sanitizeExt(extname(file.name)) || MIME_TO_EXT[file.type] || '';
|
|
55
|
+
const slug = randomBytes(8).toString('hex');
|
|
56
|
+
const filename = `${slug}${ext}`;
|
|
57
|
+
await mkdir(ctx.storage.dir(), { recursive: true });
|
|
58
|
+
await writeFile(ctx.storage.path(filename), Buffer.from(await file.arrayBuffer()));
|
|
59
|
+
const item = ctx.store.createMediaItem(ctx.projectId, {
|
|
60
|
+
filename,
|
|
61
|
+
originalName: file.name || filename,
|
|
62
|
+
mime: file.type,
|
|
63
|
+
size: file.size,
|
|
64
|
+
url: ctx.mediaUrl(filename),
|
|
65
|
+
uploadedBy: user.id
|
|
66
|
+
});
|
|
67
|
+
return Response.json(item, { status: 201 });
|
|
68
|
+
};
|
|
69
|
+
export const deleteMediaItem = async (ctx) => {
|
|
70
|
+
requireUser(ctx);
|
|
71
|
+
const id = ctx.params.id;
|
|
72
|
+
if (!id)
|
|
73
|
+
return new Response(null, { status: 404 });
|
|
74
|
+
const removed = ctx.store.deleteMediaItem(ctx.projectId, id);
|
|
75
|
+
if (!removed)
|
|
76
|
+
return new Response(null, { status: 404 });
|
|
77
|
+
// Tolerate filesystem drift — the database record is the source of truth.
|
|
78
|
+
await unlink(ctx.storage.path(removed.filename)).catch(() => { });
|
|
79
|
+
return new Response(null, { status: 204 });
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Serve an uploaded file's bytes. Public: these are referenced from published
|
|
83
|
+
* pages. `nosniff` plus the extension allowlist means an unrecognised type
|
|
84
|
+
* downloads rather than executing.
|
|
85
|
+
*
|
|
86
|
+
* Takes just a storage binding and a filename rather than a full `RouteCtx`, so
|
|
87
|
+
* a host can also mount it at its own `/uploads/[filename]` route — which is
|
|
88
|
+
* what keeps every absolute media URL already sitting in published content
|
|
89
|
+
* resolving after the migration.
|
|
90
|
+
*/
|
|
91
|
+
export const serveUploadBytes = async (storage, filename) => {
|
|
92
|
+
if (!filename || !isSafeUploadFilename(filename)) {
|
|
93
|
+
return new Response(null, { status: 404 });
|
|
94
|
+
}
|
|
95
|
+
let bytes;
|
|
96
|
+
try {
|
|
97
|
+
bytes = await readFile(storage.path(filename));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return new Response(null, { status: 404 });
|
|
101
|
+
}
|
|
102
|
+
return new Response(new Uint8Array(bytes), {
|
|
103
|
+
status: 200,
|
|
104
|
+
headers: {
|
|
105
|
+
'content-type': uploadContentType(filename),
|
|
106
|
+
'content-length': String(bytes.byteLength),
|
|
107
|
+
// Names are random slugs whose content never changes, which is what
|
|
108
|
+
// lets the build plugin skip a file it has already downloaded.
|
|
109
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
110
|
+
// An SVG is a document, and this one is served from the origin the
|
|
111
|
+
// editor signs in to. The sandbox stops an uploaded file running
|
|
112
|
+
// script against that origin; `nosniff` stops a browser deciding
|
|
113
|
+
// some other upload is one.
|
|
114
|
+
'content-security-policy': "default-src 'none'; sandbox",
|
|
115
|
+
'x-content-type-options': 'nosniff'
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
export const getUpload = (ctx) => serveUploadBytes(ctx.storage, ctx.params.filename);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type RouteCtx } from './context.js';
|
|
2
|
+
export declare const getPages: (ctx: RouteCtx) => Response;
|
|
3
|
+
export declare const postPages: (ctx: RouteCtx) => Promise<Response>;
|
|
4
|
+
/**
|
|
5
|
+
* Remove a page from the editor's view. A draft (a page item in the caller's
|
|
6
|
+
* open release with nothing published behind it) is dropped outright — it never
|
|
7
|
+
* reached publish, so no history is kept. A published page is staged instead;
|
|
8
|
+
* the deletion and its history entry happen at publish time.
|
|
9
|
+
*/
|
|
10
|
+
export declare const deletePages: (ctx: RouteCtx) => Promise<Response>;
|
|
11
|
+
/**
|
|
12
|
+
* Change a page's params — a slug rename.
|
|
13
|
+
*
|
|
14
|
+
* The admin bar has called this since it shipped; there was never a handler
|
|
15
|
+
* behind it, so renaming silently failed. `toUrl` is the resolved path for the
|
|
16
|
+
* new params, used as the redirect target so the old URL keeps working once the
|
|
17
|
+
* release is published.
|
|
18
|
+
*/
|
|
19
|
+
export declare const postPagesRename: (ctx: RouteCtx) => Promise<Response>;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { DEFAULT_LOCALE } from '../store/queries.js';
|
|
2
|
+
import { badRequest, isObjectRecord, isStringRecord, readJson, requireUser } from './context.js';
|
|
3
|
+
const parseOutcome = (v) => {
|
|
4
|
+
if (v == null)
|
|
5
|
+
return undefined;
|
|
6
|
+
if (!isObjectRecord(v))
|
|
7
|
+
return 'invalid';
|
|
8
|
+
if (v.kind === 'gone')
|
|
9
|
+
return { kind: 'gone' };
|
|
10
|
+
if (v.kind === 'redirect') {
|
|
11
|
+
if (typeof v.to !== 'string' || v.to === '')
|
|
12
|
+
return 'invalid';
|
|
13
|
+
return { kind: 'redirect', to: v.to };
|
|
14
|
+
}
|
|
15
|
+
return 'invalid';
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* SvelteKit param segments: `[name]`, `[name=matcher]`, `[...rest]`,
|
|
19
|
+
* `[[optional]]`. Group folders like `(marketing)` use parens, not brackets,
|
|
20
|
+
* and contribute no params.
|
|
21
|
+
*/
|
|
22
|
+
const ownedParamsFromRouteId = (routeId) => {
|
|
23
|
+
const out = [];
|
|
24
|
+
const re = /\[(\.{3}|\[)?([^\]=]+?)(?:=[^\]]+)?\]?\]/g;
|
|
25
|
+
let m;
|
|
26
|
+
while ((m = re.exec(routeId)) !== null)
|
|
27
|
+
out.push(m[2]);
|
|
28
|
+
return out;
|
|
29
|
+
};
|
|
30
|
+
const paramsKey = (params) => {
|
|
31
|
+
const keys = Object.keys(params).sort();
|
|
32
|
+
return keys.map((k) => `${k}=${params[k]}`).join('&');
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* The page-route list, derived from published rows plus any routeIds the
|
|
36
|
+
* caller's open release references. There is no authoritative route registry —
|
|
37
|
+
* the backend never sees the app's route tree.
|
|
38
|
+
*/
|
|
39
|
+
const collectPageRoutes = (ctx, userId) => {
|
|
40
|
+
const ids = new Set(ctx.store.allPageRouteIds(ctx.projectId));
|
|
41
|
+
if (userId) {
|
|
42
|
+
const release = ctx.store.getOpenRelease(ctx.projectId, userId);
|
|
43
|
+
if (release) {
|
|
44
|
+
for (const item of release.items) {
|
|
45
|
+
if (item.kind === 'page' || item.kind === 'page-delete')
|
|
46
|
+
ids.add(item.routeId);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
51
|
+
};
|
|
52
|
+
const buildRoutes = (ctx, userId, locale) => {
|
|
53
|
+
const fromUser = ctx.store.listAllPages(ctx.projectId, userId, locale);
|
|
54
|
+
const byRouteId = new Map();
|
|
55
|
+
for (const r of fromUser)
|
|
56
|
+
byRouteId.set(r.routeId, r.entries);
|
|
57
|
+
return collectPageRoutes(ctx, userId).map((routeId) => ({
|
|
58
|
+
routeId,
|
|
59
|
+
ownedParams: ownedParamsFromRouteId(routeId),
|
|
60
|
+
entries: byRouteId.get(routeId) ?? []
|
|
61
|
+
}));
|
|
62
|
+
};
|
|
63
|
+
export const getPages = (ctx) => {
|
|
64
|
+
const { event, projectId, store, respond } = ctx;
|
|
65
|
+
const locale = event.url.searchParams.get('locale') ?? DEFAULT_LOCALE;
|
|
66
|
+
const versionKey = event.url.searchParams.get('version');
|
|
67
|
+
if (versionKey) {
|
|
68
|
+
const release = store.findPublishedReleaseByPreviewKey(projectId, versionKey);
|
|
69
|
+
if (!release)
|
|
70
|
+
return new Response('release not found', { status: 404 });
|
|
71
|
+
const snap = store.buildStateAtRelease(projectId, release.id);
|
|
72
|
+
if (!snap)
|
|
73
|
+
return new Response('release not found', { status: 404 });
|
|
74
|
+
const localeBucket = snap.pageSnap[locale] ?? {};
|
|
75
|
+
const routes = Object.keys(localeBucket)
|
|
76
|
+
.sort()
|
|
77
|
+
.map((routeId) => {
|
|
78
|
+
const entries = (localeBucket[routeId] ?? [])
|
|
79
|
+
.map((e) => {
|
|
80
|
+
const meta = e.published?.metadata;
|
|
81
|
+
const metadata = meta && typeof meta === 'object' && !Array.isArray(meta)
|
|
82
|
+
? { ...meta }
|
|
83
|
+
: {};
|
|
84
|
+
const entry = {
|
|
85
|
+
params: { ...e.params },
|
|
86
|
+
metadata,
|
|
87
|
+
isDraft: false,
|
|
88
|
+
isDeletePending: false
|
|
89
|
+
};
|
|
90
|
+
if (e.tombstone?.kind === 'redirect')
|
|
91
|
+
entry.redirectTo = e.tombstone.to;
|
|
92
|
+
else if (e.tombstone?.kind === 'gone')
|
|
93
|
+
entry.gone = true;
|
|
94
|
+
return entry;
|
|
95
|
+
})
|
|
96
|
+
.sort((a, b) => {
|
|
97
|
+
const ak = paramsKey(a.params);
|
|
98
|
+
const bk = paramsKey(b.params);
|
|
99
|
+
return ak < bk ? -1 : ak > bk ? 1 : 0;
|
|
100
|
+
});
|
|
101
|
+
return { routeId, ownedParams: ownedParamsFromRouteId(routeId), entries };
|
|
102
|
+
});
|
|
103
|
+
return respond.serveImmutableJson(event, { routes });
|
|
104
|
+
}
|
|
105
|
+
const userId = ctx.user?.id ?? '';
|
|
106
|
+
if (!userId) {
|
|
107
|
+
const cacheCheck = respond.tryPublicCache(event, projectId, `pages:${locale}`);
|
|
108
|
+
if (cacheCheck.hit)
|
|
109
|
+
return cacheCheck.response;
|
|
110
|
+
return respond.servePublicJson(event, projectId, `pages:${locale}`, cacheCheck, {
|
|
111
|
+
routes: buildRoutes(ctx, '', locale)
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return Response.json({ routes: buildRoutes(ctx, userId, locale) });
|
|
115
|
+
};
|
|
116
|
+
export const postPages = async (ctx) => {
|
|
117
|
+
const user = requireUser(ctx);
|
|
118
|
+
const body = await readJson(ctx.event);
|
|
119
|
+
if (body === undefined)
|
|
120
|
+
return badRequest('invalid json');
|
|
121
|
+
if (!isObjectRecord(body))
|
|
122
|
+
return badRequest('object body required');
|
|
123
|
+
const { routeId, locale, params, metadata } = body;
|
|
124
|
+
if (typeof routeId !== 'string' || routeId === '')
|
|
125
|
+
return badRequest('routeId required');
|
|
126
|
+
if (typeof locale !== 'string' || locale === '')
|
|
127
|
+
return badRequest('locale required');
|
|
128
|
+
if (!isStringRecord(params)) {
|
|
129
|
+
return badRequest('params must be a string-keyed object of strings');
|
|
130
|
+
}
|
|
131
|
+
if (metadata != null && !isObjectRecord(metadata)) {
|
|
132
|
+
return badRequest('metadata must be an object');
|
|
133
|
+
}
|
|
134
|
+
const result = ctx.store.createPage(ctx.projectId, user.id, routeId, locale, params, metadata);
|
|
135
|
+
if (!result.ok)
|
|
136
|
+
return Response.json({ error: 'page-exists' }, { status: 409 });
|
|
137
|
+
return Response.json({ ok: true });
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Remove a page from the editor's view. A draft (a page item in the caller's
|
|
141
|
+
* open release with nothing published behind it) is dropped outright — it never
|
|
142
|
+
* reached publish, so no history is kept. A published page is staged instead;
|
|
143
|
+
* the deletion and its history entry happen at publish time.
|
|
144
|
+
*/
|
|
145
|
+
export const deletePages = async (ctx) => {
|
|
146
|
+
const user = requireUser(ctx);
|
|
147
|
+
const body = await readJson(ctx.event);
|
|
148
|
+
if (body === undefined)
|
|
149
|
+
return badRequest('invalid json');
|
|
150
|
+
if (!isObjectRecord(body))
|
|
151
|
+
return badRequest('object body required');
|
|
152
|
+
const { routeId, locale, params, outcome: rawOutcome } = body;
|
|
153
|
+
if (typeof routeId !== 'string' || routeId === '')
|
|
154
|
+
return badRequest('routeId required');
|
|
155
|
+
if (typeof locale !== 'string' || locale === '')
|
|
156
|
+
return badRequest('locale required');
|
|
157
|
+
if (!isStringRecord(params)) {
|
|
158
|
+
return badRequest('params must be a string-keyed object of strings');
|
|
159
|
+
}
|
|
160
|
+
const outcome = parseOutcome(rawOutcome);
|
|
161
|
+
if (outcome === 'invalid') {
|
|
162
|
+
return badRequest('outcome must be { kind: "gone" } or { kind: "redirect", to: string }');
|
|
163
|
+
}
|
|
164
|
+
const draftRemoved = ctx.store.discardReleaseItem(ctx.projectId, user.id, {
|
|
165
|
+
kind: 'page',
|
|
166
|
+
routeId,
|
|
167
|
+
locale,
|
|
168
|
+
params
|
|
169
|
+
});
|
|
170
|
+
if (draftRemoved)
|
|
171
|
+
return Response.json({ ok: true, mode: 'draft-discarded' });
|
|
172
|
+
const staged = ctx.store.stagePageDelete(ctx.projectId, user.id, routeId, locale, params, outcome);
|
|
173
|
+
if (!staged.ok) {
|
|
174
|
+
if (staged.reason === 'not-published')
|
|
175
|
+
return new Response(null, { status: 404 });
|
|
176
|
+
return Response.json({ error: staged.reason }, { status: 409 });
|
|
177
|
+
}
|
|
178
|
+
return Response.json({ ok: true, mode: 'delete-staged' });
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* Change a page's params — a slug rename.
|
|
182
|
+
*
|
|
183
|
+
* The admin bar has called this since it shipped; there was never a handler
|
|
184
|
+
* behind it, so renaming silently failed. `toUrl` is the resolved path for the
|
|
185
|
+
* new params, used as the redirect target so the old URL keeps working once the
|
|
186
|
+
* release is published.
|
|
187
|
+
*/
|
|
188
|
+
export const postPagesRename = async (ctx) => {
|
|
189
|
+
const user = requireUser(ctx);
|
|
190
|
+
const body = await readJson(ctx.event);
|
|
191
|
+
if (body === undefined)
|
|
192
|
+
return badRequest('invalid json');
|
|
193
|
+
if (!isObjectRecord(body))
|
|
194
|
+
return badRequest('object body required');
|
|
195
|
+
const { routeId, fromParams, toParams, locale, toUrl } = body;
|
|
196
|
+
if (typeof routeId !== 'string' || routeId === '')
|
|
197
|
+
return badRequest('routeId required');
|
|
198
|
+
if (typeof locale !== 'string' || locale === '')
|
|
199
|
+
return badRequest('locale required');
|
|
200
|
+
if (!isStringRecord(fromParams))
|
|
201
|
+
return badRequest('fromParams must be a string-keyed object');
|
|
202
|
+
if (!isStringRecord(toParams))
|
|
203
|
+
return badRequest('toParams must be a string-keyed object');
|
|
204
|
+
if (typeof toUrl !== 'string' || toUrl === '')
|
|
205
|
+
return badRequest('toUrl required');
|
|
206
|
+
const result = ctx.store.renamePage(ctx.projectId, user.id, {
|
|
207
|
+
routeId,
|
|
208
|
+
locale,
|
|
209
|
+
fromParams,
|
|
210
|
+
toParams,
|
|
211
|
+
toUrl
|
|
212
|
+
});
|
|
213
|
+
if (!result.ok) {
|
|
214
|
+
if (result.reason === 'not-found')
|
|
215
|
+
return new Response(null, { status: 404 });
|
|
216
|
+
if (result.reason === 'same-params')
|
|
217
|
+
return badRequest('toParams matches fromParams');
|
|
218
|
+
return Response.json({ error: 'page-exists' }, { status: 409 });
|
|
219
|
+
}
|
|
220
|
+
return Response.json({ release: result.release });
|
|
221
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type RouteCtx } from './context.js';
|
|
2
|
+
export declare const getRelease: (ctx: RouteCtx) => Response;
|
|
3
|
+
export declare const postReleaseItems: (ctx: RouteCtx) => Promise<Response>;
|
|
4
|
+
export declare const deleteReleaseItems: (ctx: RouteCtx) => Promise<Response>;
|
|
5
|
+
export declare const postReleasePublish: (ctx: RouteCtx) => Promise<Response>;
|
|
6
|
+
export declare const postReleaseDiscard: (ctx: RouteCtx) => Response;
|
|
7
|
+
export declare const postReleasePreviewKey: (ctx: RouteCtx) => Response;
|
|
8
|
+
export declare const getReleaseHistory: (ctx: RouteCtx) => Response;
|
|
9
|
+
export declare const postReleaseRevert: (ctx: RouteCtx) => Response;
|
|
10
|
+
export declare const postReleaseHistoryPreviewKey: (ctx: RouteCtx) => Response;
|