vestrycms 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +348 -2
- package/dist/config/fields.d.ts +30 -0
- package/dist/config/fields.js +75 -0
- package/dist/config/index.d.ts +9 -0
- package/dist/config/index.js +18 -0
- package/dist/config/types.d.ts +403 -0
- package/dist/config/types.js +10 -0
- package/dist/core/api.d.ts +67 -0
- package/dist/core/api.js +538 -0
- package/dist/core/auth.d.ts +1561 -0
- package/dist/core/auth.js +234 -0
- package/dist/core/cache.d.ts +27 -0
- package/dist/core/cache.js +52 -0
- package/dist/core/entries.d.ts +19 -0
- package/dist/core/entries.js +127 -0
- package/dist/core/index-types.d.ts +20 -0
- package/dist/core/index-types.js +1 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +13 -0
- package/dist/core/mail.d.ts +28 -0
- package/dist/core/mail.js +62 -0
- package/dist/core/paths.d.ts +35 -0
- package/dist/core/paths.js +148 -0
- package/dist/core/roles.d.ts +24 -0
- package/dist/core/roles.js +15 -0
- package/dist/core/service.d.ts +102 -0
- package/dist/core/service.js +478 -0
- package/dist/core/source.d.ts +82 -0
- package/dist/core/source.js +75 -0
- package/dist/core/sources/github-app.d.ts +26 -0
- package/dist/core/sources/github-app.js +161 -0
- package/dist/core/sources/github.d.ts +40 -0
- package/dist/core/sources/github.js +278 -0
- package/dist/core/sources/local.d.ts +16 -0
- package/dist/core/sources/local.js +98 -0
- package/dist/core/values.d.ts +22 -0
- package/dist/core/values.js +123 -0
- package/dist/env.d.ts +19 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +167 -0
- package/dist/integration/build-index.d.ts +8 -0
- package/dist/integration/build-index.js +67 -0
- package/dist/integration/client-deps.d.ts +10 -0
- package/dist/integration/client-deps.js +43 -0
- package/dist/integration/dev-middleware.d.ts +6 -0
- package/dist/integration/dev-middleware.js +48 -0
- package/dist/integration/formatter.d.ts +7 -0
- package/dist/integration/formatter.js +56 -0
- package/dist/mdx/expressions.d.ts +14 -0
- package/dist/mdx/expressions.js +141 -0
- package/dist/mdx/index.d.ts +4 -0
- package/dist/mdx/index.js +4 -0
- package/dist/mdx/parse.d.ts +6 -0
- package/dist/mdx/parse.js +378 -0
- package/dist/mdx/serialize.d.ts +9 -0
- package/dist/mdx/serialize.js +396 -0
- package/dist/mdx/types.d.ts +40 -0
- package/dist/mdx/types.js +6 -0
- package/dist/pages/admin.astro +27 -0
- package/dist/pages/api.d.ts +3 -0
- package/dist/pages/api.js +31 -0
- package/dist/ui/App.d.ts +4 -0
- package/dist/ui/App.js +70 -0
- package/dist/ui/api.d.ts +83 -0
- package/dist/ui/api.js +97 -0
- package/dist/ui/auth-client.d.ts +27 -0
- package/dist/ui/auth-client.js +29 -0
- package/dist/ui/components/AssetPicker.d.ts +27 -0
- package/dist/ui/components/AssetPicker.js +97 -0
- package/dist/ui/components/Icon.d.ts +11 -0
- package/dist/ui/components/Icon.js +46 -0
- package/dist/ui/components/Layout.d.ts +15 -0
- package/dist/ui/components/Layout.js +87 -0
- package/dist/ui/components/editor/EditorContext.d.ts +15 -0
- package/dist/ui/components/editor/EditorContext.js +9 -0
- package/dist/ui/components/editor/NodeViews.d.ts +4 -0
- package/dist/ui/components/editor/NodeViews.js +127 -0
- package/dist/ui/components/editor/RichTextEditor.d.ts +24 -0
- package/dist/ui/components/editor/RichTextEditor.js +253 -0
- package/dist/ui/components/editor/extensions.d.ts +20 -0
- package/dist/ui/components/editor/extensions.js +156 -0
- package/dist/ui/components/fields/CodeInput.d.ts +10 -0
- package/dist/ui/components/fields/CodeInput.js +8 -0
- package/dist/ui/components/fields/CodeMirrorInput.d.ts +2 -0
- package/dist/ui/components/fields/CodeMirrorInput.js +44 -0
- package/dist/ui/components/fields/FieldRenderer.d.ts +41 -0
- package/dist/ui/components/fields/FieldRenderer.js +299 -0
- package/dist/ui/components/fields/ImageTools.d.ts +31 -0
- package/dist/ui/components/fields/ImageTools.js +296 -0
- package/dist/ui/components/fields/MarkdownField.d.ts +7 -0
- package/dist/ui/components/fields/MarkdownField.js +16 -0
- package/dist/ui/components/fields/TreeInput.d.ts +13 -0
- package/dist/ui/components/fields/TreeInput.js +221 -0
- package/dist/ui/components/ui.d.ts +63 -0
- package/dist/ui/components/ui.js +113 -0
- package/dist/ui/context.d.ts +17 -0
- package/dist/ui/context.js +13 -0
- package/dist/ui/lib/format.d.ts +5 -0
- package/dist/ui/lib/format.js +11 -0
- package/dist/ui/lib/utils.d.ts +24 -0
- package/dist/ui/lib/utils.js +255 -0
- package/dist/ui/main.d.ts +1 -0
- package/dist/ui/main.js +11 -0
- package/dist/ui/pages/Assets.d.ts +1 -0
- package/dist/ui/pages/Assets.js +69 -0
- package/dist/ui/pages/CollectionList.d.ts +6 -0
- package/dist/ui/pages/CollectionList.js +115 -0
- package/dist/ui/pages/Dashboard.d.ts +1 -0
- package/dist/ui/pages/Dashboard.js +57 -0
- package/dist/ui/pages/EntryEditor.d.ts +3 -0
- package/dist/ui/pages/EntryEditor.js +313 -0
- package/dist/ui/pages/Login.d.ts +6 -0
- package/dist/ui/pages/Login.js +88 -0
- package/dist/ui/pages/Users.d.ts +1 -0
- package/dist/ui/pages/Users.js +102 -0
- package/dist/ui/styles.css +2536 -0
- package/package.json +121 -3
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-in and users, on Better Auth. Editors sign in with a one-time code
|
|
3
|
+
* emailed to them (the email OTP plugin); admins manage users through the
|
|
4
|
+
* admin plugin. Vestry adds: config-listed bootstrap admins, invitations,
|
|
5
|
+
* and a `lastLoginAt` field.
|
|
6
|
+
*
|
|
7
|
+
* Storage is whatever Better Auth accepts: a Cloudflare D1 binding in
|
|
8
|
+
* production, a `node:sqlite` database in development, the memory adapter
|
|
9
|
+
* in tests. Tables are created on first use with Better Auth's migrator.
|
|
10
|
+
*/
|
|
11
|
+
import { betterAuth } from "better-auth";
|
|
12
|
+
import { createAuthMiddleware } from "better-auth/api";
|
|
13
|
+
import { getMigrations } from "better-auth/db/migration";
|
|
14
|
+
import { admin, emailOTP } from "better-auth/plugins";
|
|
15
|
+
import { createAccessControl } from "better-auth/plugins/access";
|
|
16
|
+
import { defaultStatements } from "better-auth/plugins/admin/access";
|
|
17
|
+
import { isRole } from "./roles.js";
|
|
18
|
+
/** Vestry's two roles in Better Auth's access-control terms: admins manage users, editors don't. */
|
|
19
|
+
const ac = createAccessControl(defaultStatements);
|
|
20
|
+
const roles = {
|
|
21
|
+
// Only what the Users page needs: no impersonation, bans or passwords.
|
|
22
|
+
admin: ac.newRole({
|
|
23
|
+
user: ["create", "list", "get", "update", "set-role", "delete"],
|
|
24
|
+
}),
|
|
25
|
+
editor: ac.newRole({ user: [] }),
|
|
26
|
+
};
|
|
27
|
+
export const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
|
|
28
|
+
export const CODE_TTL_SECONDS = 10 * 60;
|
|
29
|
+
const CODE_TTL_MINUTES = Math.round(CODE_TTL_SECONDS / 60);
|
|
30
|
+
export function normalizeEmail(email) {
|
|
31
|
+
return email.trim().toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
/** Display name fallback: the part before the `@`, title-cased a little. */
|
|
34
|
+
export function nameFromEmail(email) {
|
|
35
|
+
const local = email.split("@")[0] ?? email;
|
|
36
|
+
const pretty = local
|
|
37
|
+
.split(/[._-]+/)
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.map((p) => p[0].toUpperCase() + p.slice(1))
|
|
40
|
+
.join(" ");
|
|
41
|
+
// Commit authors need a non-empty name; fall back to the address itself.
|
|
42
|
+
return pretty || local || email;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Migrations run once per database per isolate, whichever origin asked
|
|
46
|
+
* first (the instances per origin share the database).
|
|
47
|
+
*/
|
|
48
|
+
const migratedByDatabase = new WeakMap();
|
|
49
|
+
export function createVestryAuth(opts) {
|
|
50
|
+
const admins = (opts.admins ?? []).map(normalizeEmail);
|
|
51
|
+
const isConfigAdmin = (email) => admins.includes(normalizeEmail(email));
|
|
52
|
+
const auth = betterAuth({
|
|
53
|
+
appName: opts.siteName,
|
|
54
|
+
secret: opts.secret,
|
|
55
|
+
baseURL: opts.baseURL,
|
|
56
|
+
basePath: opts.basePath,
|
|
57
|
+
database: opts.database,
|
|
58
|
+
session: {
|
|
59
|
+
expiresIn: SESSION_TTL_SECONDS,
|
|
60
|
+
updateAge: 60 * 60 * 24,
|
|
61
|
+
},
|
|
62
|
+
rateLimit: {
|
|
63
|
+
enabled: !opts.dev,
|
|
64
|
+
storage: "database",
|
|
65
|
+
},
|
|
66
|
+
advanced: {
|
|
67
|
+
cookiePrefix: "vestry",
|
|
68
|
+
useSecureCookies: opts.baseURL.startsWith("https://"),
|
|
69
|
+
defaultCookieAttributes: { path: opts.cookiePath ?? "/" },
|
|
70
|
+
// Cloudflare puts the client address in cf-connecting-ip; the default
|
|
71
|
+
// x-forwarded-for is absent or client-controlled there, which would
|
|
72
|
+
// collapse the rate limiter into one shared bucket.
|
|
73
|
+
ipAddress: {
|
|
74
|
+
ipAddressHeaders: ["cf-connecting-ip", "x-forwarded-for"],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
user: {
|
|
78
|
+
additionalFields: {
|
|
79
|
+
lastLoginAt: { type: "date", required: false, input: false },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
databaseHooks: {
|
|
83
|
+
session: {
|
|
84
|
+
create: {
|
|
85
|
+
after: async (session, ctx) => {
|
|
86
|
+
if (!ctx)
|
|
87
|
+
return;
|
|
88
|
+
if (session.impersonatedBy)
|
|
89
|
+
return;
|
|
90
|
+
await ctx.context.internalAdapter.updateUser(session.userId, {
|
|
91
|
+
lastLoginAt: new Date(),
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
hooks: {
|
|
98
|
+
// The first admin has nobody to invite them. Addresses listed in the
|
|
99
|
+
// config are created on their first sign-in attempt, so the OTP plugin
|
|
100
|
+
// (which refuses unknown addresses) finds them.
|
|
101
|
+
before: createAuthMiddleware(async (ctx) => {
|
|
102
|
+
if (ctx.path !== "/email-otp/send-verification-otp")
|
|
103
|
+
return;
|
|
104
|
+
const email = normalizeEmail(String(ctx.body?.email ?? ""));
|
|
105
|
+
if (!isConfigAdmin(email))
|
|
106
|
+
return;
|
|
107
|
+
const existing = await ctx.context.internalAdapter.findUserByEmail(email);
|
|
108
|
+
if (existing) {
|
|
109
|
+
const role = existing.user.role;
|
|
110
|
+
if (role !== "admin") {
|
|
111
|
+
await ctx.context.internalAdapter.updateUser(existing.user.id, {
|
|
112
|
+
role: "admin",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await ctx.context.internalAdapter.createUser({
|
|
118
|
+
email,
|
|
119
|
+
name: nameFromEmail(email),
|
|
120
|
+
emailVerified: false,
|
|
121
|
+
role: "admin",
|
|
122
|
+
}, { method: "email-otp" });
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
plugins: [
|
|
126
|
+
emailOTP({
|
|
127
|
+
otpLength: 6,
|
|
128
|
+
expiresIn: CODE_TTL_SECONDS,
|
|
129
|
+
allowedAttempts: 5,
|
|
130
|
+
// Encrypted with the secret, so a database dump alone can't be
|
|
131
|
+
// brute-forced against a six-digit code.
|
|
132
|
+
storeOTP: "encrypted",
|
|
133
|
+
// Only existing users (and config admins, created above) may sign in.
|
|
134
|
+
disableSignUp: true,
|
|
135
|
+
async sendVerificationOTP({ email, otp, type }) {
|
|
136
|
+
if (type !== "sign-in")
|
|
137
|
+
return;
|
|
138
|
+
await opts.mailer.send({
|
|
139
|
+
to: email,
|
|
140
|
+
...loginCodeEmail(opts.siteName, otp),
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
admin({
|
|
145
|
+
ac,
|
|
146
|
+
roles,
|
|
147
|
+
defaultRole: "editor",
|
|
148
|
+
adminRoles: ["admin"],
|
|
149
|
+
}),
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
const ensureMigrated = () => {
|
|
153
|
+
if (opts.migrate === false)
|
|
154
|
+
return Promise.resolve();
|
|
155
|
+
const key = opts.database;
|
|
156
|
+
const pending = migratedByDatabase.get(key);
|
|
157
|
+
if (pending)
|
|
158
|
+
return pending;
|
|
159
|
+
const run = (async () => {
|
|
160
|
+
const { runMigrations, unsafeChanges } = await getMigrations(auth.options, { throwOnUnsafe: false });
|
|
161
|
+
for (const change of unsafeChanges ?? []) {
|
|
162
|
+
console.warn(`[vestry] auth schema needs a manual migration: ${change}`);
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
await runMigrations();
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
// Another isolate created the same table or index a moment earlier.
|
|
169
|
+
if (!/already exists/i.test(e instanceof Error ? e.message : String(e)))
|
|
170
|
+
throw e;
|
|
171
|
+
}
|
|
172
|
+
})().catch((e) => {
|
|
173
|
+
migratedByDatabase.delete(key);
|
|
174
|
+
throw e;
|
|
175
|
+
});
|
|
176
|
+
migratedByDatabase.set(key, run);
|
|
177
|
+
return run;
|
|
178
|
+
};
|
|
179
|
+
/** The `PublicUser` view of a Better Auth user record. */
|
|
180
|
+
const toPublicUser = (user) => ({
|
|
181
|
+
id: user.id,
|
|
182
|
+
email: user.email,
|
|
183
|
+
name: user.name,
|
|
184
|
+
role: isRole(user.role) ? user.role : "editor",
|
|
185
|
+
// Better Auth marks the address verified on the first successful code.
|
|
186
|
+
status: user.emailVerified ? "active" : "invited",
|
|
187
|
+
origin: isConfigAdmin(user.email) ? "config" : "invite",
|
|
188
|
+
createdAt: toIso(user.createdAt),
|
|
189
|
+
...(user.lastLoginAt ? { lastLoginAt: toIso(user.lastLoginAt) } : {}),
|
|
190
|
+
});
|
|
191
|
+
const sendInvite = (by, user) => opts.mailer.send({
|
|
192
|
+
to: user.email,
|
|
193
|
+
...inviteEmail(opts.siteName, opts.adminUrl, by.name, user),
|
|
194
|
+
});
|
|
195
|
+
return { auth, ensureMigrated, toPublicUser, isConfigAdmin, sendInvite };
|
|
196
|
+
}
|
|
197
|
+
function toIso(value) {
|
|
198
|
+
return value instanceof Date ? value.toISOString() : String(value);
|
|
199
|
+
}
|
|
200
|
+
// ------------------------------------------------------------------ emails
|
|
201
|
+
function loginCodeEmail(siteName, code) {
|
|
202
|
+
const pretty = `${code.slice(0, 3)} ${code.slice(3)}`;
|
|
203
|
+
return {
|
|
204
|
+
subject: `${code} is your ${siteName} sign-in code`,
|
|
205
|
+
text: [
|
|
206
|
+
`Your sign-in code for ${siteName} is:`,
|
|
207
|
+
"",
|
|
208
|
+
` ${pretty}`,
|
|
209
|
+
"",
|
|
210
|
+
`It expires in ${CODE_TTL_MINUTES} minutes. If you didn't request it, you can ignore this email.`,
|
|
211
|
+
].join("\n"),
|
|
212
|
+
html: emailHtml(`Your sign-in code for <strong>${escapeHtml(siteName)}</strong> is:`, `<p style="font-size:32px;letter-spacing:6px;font-weight:700;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;margin:20px 0">${escapeHtml(pretty)}</p>`, `It expires in ${CODE_TTL_MINUTES} minutes. If you didn't request it, you can ignore this email.`),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function inviteEmail(siteName, adminUrl, inviterName, user) {
|
|
216
|
+
return {
|
|
217
|
+
subject: `You've been invited to edit ${siteName}`,
|
|
218
|
+
text: [
|
|
219
|
+
`${inviterName} invited you to edit ${siteName} as ${roleLabel(user.role)}.`,
|
|
220
|
+
"",
|
|
221
|
+
`Sign in at ${adminUrl} with this email address (${user.email}). We'll email you a code each time you sign in; there's no password.`,
|
|
222
|
+
].join("\n"),
|
|
223
|
+
html: emailHtml(`${escapeHtml(inviterName)} invited you to edit <strong>${escapeHtml(siteName)}</strong> as ${roleLabel(user.role)}.`, `<p style="margin:20px 0"><a href="${escapeHtml(adminUrl)}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:10px 18px;border-radius:8px;font-weight:600">Open ${escapeHtml(siteName)}</a></p>`, `Sign in with this email address (${escapeHtml(user.email)}). We'll email you a code each time you sign in; there's no password.`),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function roleLabel(role) {
|
|
227
|
+
return role === "admin" ? "an admin" : "an editor";
|
|
228
|
+
}
|
|
229
|
+
function emailHtml(lead, middle, trailer) {
|
|
230
|
+
return `<!doctype html><html><body style="margin:0;padding:24px;background:#f4f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1f2937"><div style="max-width:480px;margin:0 auto;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:28px 32px"><p style="margin:0 0 4px;font-size:16px">${lead}</p>${middle}<p style="margin:0;color:#6b7280;font-size:14px">${trailer}</p></div></body></html>`;
|
|
231
|
+
}
|
|
232
|
+
function escapeHtml(s) {
|
|
233
|
+
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
234
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny cache abstraction so the GitHub source can be snappy. The default is
|
|
3
|
+
* an in-memory LRU with TTLs; hosts can plug in something durable (e.g. a
|
|
4
|
+
* Cloudflare KV namespace) by implementing the same three methods.
|
|
5
|
+
*/
|
|
6
|
+
export interface Cache {
|
|
7
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
8
|
+
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
|
9
|
+
delete(key: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare class MemoryCache implements Cache {
|
|
12
|
+
#private;
|
|
13
|
+
private maxEntries;
|
|
14
|
+
constructor(maxEntries?: number);
|
|
15
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
16
|
+
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
|
17
|
+
delete(key: string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/** Adapter for a Cloudflare KV-style namespace (`get`/`put`/`delete` with JSON). */
|
|
20
|
+
export declare function kvCache(namespace: {
|
|
21
|
+
get(key: string, type: "json"): Promise<unknown>;
|
|
22
|
+
put(key: string, value: string, opts?: {
|
|
23
|
+
expirationTtl?: number;
|
|
24
|
+
}): Promise<void>;
|
|
25
|
+
delete(key: string): Promise<void>;
|
|
26
|
+
}): Cache;
|
|
27
|
+
export declare function sharedMemoryCache(): MemoryCache;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export class MemoryCache {
|
|
2
|
+
maxEntries;
|
|
3
|
+
#map = new Map();
|
|
4
|
+
constructor(maxEntries = 2000) {
|
|
5
|
+
this.maxEntries = maxEntries;
|
|
6
|
+
}
|
|
7
|
+
async get(key) {
|
|
8
|
+
const e = this.#map.get(key);
|
|
9
|
+
if (!e)
|
|
10
|
+
return undefined;
|
|
11
|
+
if (e.expires !== null && e.expires < Date.now()) {
|
|
12
|
+
this.#map.delete(key);
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
// LRU bump
|
|
16
|
+
this.#map.delete(key);
|
|
17
|
+
this.#map.set(key, e);
|
|
18
|
+
return e.value;
|
|
19
|
+
}
|
|
20
|
+
async set(key, value, ttlSeconds) {
|
|
21
|
+
if (this.#map.size >= this.maxEntries) {
|
|
22
|
+
const oldest = this.#map.keys().next().value;
|
|
23
|
+
if (oldest !== undefined)
|
|
24
|
+
this.#map.delete(oldest);
|
|
25
|
+
}
|
|
26
|
+
this.#map.set(key, {
|
|
27
|
+
value,
|
|
28
|
+
expires: ttlSeconds ? Date.now() + ttlSeconds * 1000 : null,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async delete(key) {
|
|
32
|
+
this.#map.delete(key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Adapter for a Cloudflare KV-style namespace (`get`/`put`/`delete` with JSON). */
|
|
36
|
+
export function kvCache(namespace) {
|
|
37
|
+
return {
|
|
38
|
+
async get(key) {
|
|
39
|
+
return ((await namespace.get(key, "json")) ?? undefined);
|
|
40
|
+
},
|
|
41
|
+
async set(key, value, ttlSeconds) {
|
|
42
|
+
await namespace.put(key, JSON.stringify(value), ttlSeconds ? { expirationTtl: Math.max(60, ttlSeconds) } : undefined);
|
|
43
|
+
},
|
|
44
|
+
async delete(key) {
|
|
45
|
+
await namespace.delete(key);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
let shared;
|
|
50
|
+
export function sharedMemoryCache() {
|
|
51
|
+
return (shared ??= new MemoryCache());
|
|
52
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { EntryFormat } from "../config/types.js";
|
|
2
|
+
export interface ParsedEntry {
|
|
3
|
+
data: Record<string, unknown>;
|
|
4
|
+
body: string;
|
|
5
|
+
}
|
|
6
|
+
/** Split a Markdown/MDX file into YAML frontmatter and body. */
|
|
7
|
+
export declare function splitFrontmatter(text: string): {
|
|
8
|
+
data: Record<string, unknown>;
|
|
9
|
+
body: string;
|
|
10
|
+
hasFrontmatter: boolean;
|
|
11
|
+
};
|
|
12
|
+
export declare function parseEntry(format: EntryFormat, text: string): ParsedEntry;
|
|
13
|
+
export declare function needsYamlQuotes(value: string): boolean;
|
|
14
|
+
export declare function stringifyFrontmatter(data: Record<string, unknown>): string;
|
|
15
|
+
export declare function serializeEntry(format: EntryFormat, entry: ParsedEntry): string;
|
|
16
|
+
/** Single-file collections hold an array of records. */
|
|
17
|
+
export declare function parseEntryList(format: EntryFormat, text: string): Record<string, unknown>[];
|
|
18
|
+
export declare function serializeEntryList(format: EntryFormat, items: Record<string, unknown>[]): string;
|
|
19
|
+
export declare function stripUndefined<T>(value: T): T;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Document as YamlDocument, parse as parseYaml, stringify as stringifyYaml, visit as visitYaml, } from "yaml";
|
|
2
|
+
const FRONTMATTER_RE = /^(?:)?---\r?\n([\s\S]*?)\r?\n?---(?:\r?\n|$)/;
|
|
3
|
+
/** Split a Markdown/MDX file into YAML frontmatter and body. */
|
|
4
|
+
export function splitFrontmatter(text) {
|
|
5
|
+
const m = text.match(FRONTMATTER_RE);
|
|
6
|
+
if (!m)
|
|
7
|
+
return { data: {}, body: text, hasFrontmatter: false };
|
|
8
|
+
const yaml = m[1];
|
|
9
|
+
const body = text.slice(m[0].length);
|
|
10
|
+
let data = {};
|
|
11
|
+
if (yaml.trim()) {
|
|
12
|
+
data = parseYaml(yaml, { strict: false });
|
|
13
|
+
}
|
|
14
|
+
if (data === null || typeof data !== "object" || Array.isArray(data))
|
|
15
|
+
data = {};
|
|
16
|
+
return { data: data, body, hasFrontmatter: true };
|
|
17
|
+
}
|
|
18
|
+
export function parseEntry(format, text) {
|
|
19
|
+
switch (format) {
|
|
20
|
+
case "md":
|
|
21
|
+
case "mdx": {
|
|
22
|
+
const { data, body } = splitFrontmatter(text);
|
|
23
|
+
return { data, body: body.replace(/^\r?\n/, "") };
|
|
24
|
+
}
|
|
25
|
+
case "yaml": {
|
|
26
|
+
const data = parseYaml(text) ?? {};
|
|
27
|
+
return { data: asRecord(data), body: "" };
|
|
28
|
+
}
|
|
29
|
+
case "json": {
|
|
30
|
+
const data = text.trim() ? JSON.parse(text) : {};
|
|
31
|
+
return { data: asRecord(data), body: "" };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function asRecord(v) {
|
|
36
|
+
return v && typeof v === "object" && !Array.isArray(v)
|
|
37
|
+
? v
|
|
38
|
+
: {};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Strings that YAML 1.1 parsers (js-yaml, which Astro uses for frontmatter)
|
|
42
|
+
* would turn into something other than a string: timestamps become Dates,
|
|
43
|
+
* `y`/`n`/`yes`/`no`/`on`/`off` become booleans, leading-zero digits become
|
|
44
|
+
* octal numbers. These are written double-quoted so they survive a round trip.
|
|
45
|
+
*/
|
|
46
|
+
const YAML11_UNSAFE = [
|
|
47
|
+
/^\d{4}-\d{1,2}-\d{1,2}([Tt ]|$)/,
|
|
48
|
+
/^(y|n|yes|no|on|off|true|false|null|~)$/i,
|
|
49
|
+
/^0\d+$/,
|
|
50
|
+
/^[+-]?\d+(:\d+)+(\.\d+)?$/,
|
|
51
|
+
/^[+-]?\.(inf|nan)$/i,
|
|
52
|
+
];
|
|
53
|
+
export function needsYamlQuotes(value) {
|
|
54
|
+
return YAML11_UNSAFE.some((re) => re.test(value));
|
|
55
|
+
}
|
|
56
|
+
export function stringifyFrontmatter(data) {
|
|
57
|
+
const clean = stripUndefined(data);
|
|
58
|
+
if (Object.keys(clean).length === 0)
|
|
59
|
+
return "";
|
|
60
|
+
const doc = new YamlDocument(clean);
|
|
61
|
+
visitYaml(doc, {
|
|
62
|
+
Scalar(_key, node) {
|
|
63
|
+
if (typeof node.value === "string" && needsYamlQuotes(node.value))
|
|
64
|
+
node.type = "QUOTE_DOUBLE";
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
return doc
|
|
68
|
+
.toString({
|
|
69
|
+
lineWidth: 0,
|
|
70
|
+
defaultStringType: "PLAIN",
|
|
71
|
+
defaultKeyType: "PLAIN",
|
|
72
|
+
})
|
|
73
|
+
.replace(/\n$/, "");
|
|
74
|
+
}
|
|
75
|
+
export function serializeEntry(format, entry) {
|
|
76
|
+
switch (format) {
|
|
77
|
+
case "md":
|
|
78
|
+
case "mdx": {
|
|
79
|
+
const fm = stringifyFrontmatter(entry.data);
|
|
80
|
+
const body = entry.body.replace(/\s+$/, "");
|
|
81
|
+
if (!fm)
|
|
82
|
+
return body ? `${body}\n` : "";
|
|
83
|
+
return body ? `---\n${fm}\n---\n\n${body}\n` : `---\n${fm}\n---\n`;
|
|
84
|
+
}
|
|
85
|
+
case "yaml":
|
|
86
|
+
return stringifyYaml(stripUndefined(entry.data), { lineWidth: 0 });
|
|
87
|
+
case "json":
|
|
88
|
+
return `${JSON.stringify(stripUndefined(entry.data), null, 2)}\n`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Single-file collections hold an array of records. */
|
|
92
|
+
export function parseEntryList(format, text) {
|
|
93
|
+
const value = format === "json"
|
|
94
|
+
? text.trim()
|
|
95
|
+
? JSON.parse(text)
|
|
96
|
+
: []
|
|
97
|
+
: (parseYaml(text) ?? []);
|
|
98
|
+
if (Array.isArray(value))
|
|
99
|
+
return value.filter((v) => v && typeof v === "object");
|
|
100
|
+
if (value && typeof value === "object") {
|
|
101
|
+
// Allow `{ id: {...}, id2: {...} }` keyed objects too.
|
|
102
|
+
return Object.entries(value).map(([id, v]) => ({
|
|
103
|
+
id,
|
|
104
|
+
...v,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
export function serializeEntryList(format, items) {
|
|
110
|
+
const clean = items.map(stripUndefined);
|
|
111
|
+
return format === "json"
|
|
112
|
+
? `${JSON.stringify(clean, null, 2)}\n`
|
|
113
|
+
: stringifyYaml(clean, { lineWidth: 0 });
|
|
114
|
+
}
|
|
115
|
+
export function stripUndefined(value) {
|
|
116
|
+
if (Array.isArray(value))
|
|
117
|
+
return value.map(stripUndefined);
|
|
118
|
+
if (value && typeof value === "object" && !(value instanceof Date)) {
|
|
119
|
+
const out = {};
|
|
120
|
+
for (const [k, v] of Object.entries(value)) {
|
|
121
|
+
if (v !== undefined)
|
|
122
|
+
out[k] = stripUndefined(v);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The build-time index. The integration scans every collection at build time
|
|
3
|
+
* and records each entry file's git blob SHA alongside its parsed frontmatter.
|
|
4
|
+
* At runtime the GitHub source compares the live tree against this index and
|
|
5
|
+
* only fetches files that changed since the deploy, so listings are fast even
|
|
6
|
+
* on a cold worker.
|
|
7
|
+
*/
|
|
8
|
+
export interface IndexedFile {
|
|
9
|
+
sha: string;
|
|
10
|
+
data: Record<string, unknown>;
|
|
11
|
+
/** First ~200 characters of the body, for list previews. */
|
|
12
|
+
excerpt?: string;
|
|
13
|
+
/** Whether the body had any content. */
|
|
14
|
+
hasBody?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface BuildIndex {
|
|
17
|
+
builtAt: string;
|
|
18
|
+
files: Record<string, IndexedFile>;
|
|
19
|
+
}
|
|
20
|
+
export declare const EMPTY_INDEX: BuildIndex;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const EMPTY_INDEX = { builtAt: "", files: {} };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from "./source.js";
|
|
2
|
+
export * from "./cache.js";
|
|
3
|
+
export * from "./entries.js";
|
|
4
|
+
export * from "./paths.js";
|
|
5
|
+
export * from "./service.js";
|
|
6
|
+
export * from "./api.js";
|
|
7
|
+
export * from "./auth.js";
|
|
8
|
+
export * from "./roles.js";
|
|
9
|
+
export * from "./mail.js";
|
|
10
|
+
export * from "./index-types.js";
|
|
11
|
+
export * from "./values.js";
|
|
12
|
+
export { GitHubSource } from "./sources/github.js";
|
|
13
|
+
export * from "./sources/github-app.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from "./source.js";
|
|
2
|
+
export * from "./cache.js";
|
|
3
|
+
export * from "./entries.js";
|
|
4
|
+
export * from "./paths.js";
|
|
5
|
+
export * from "./service.js";
|
|
6
|
+
export * from "./api.js";
|
|
7
|
+
export * from "./auth.js";
|
|
8
|
+
export * from "./roles.js";
|
|
9
|
+
export * from "./mail.js";
|
|
10
|
+
export * from "./index-types.js";
|
|
11
|
+
export * from "./values.js";
|
|
12
|
+
export { GitHubSource } from "./sources/github.js";
|
|
13
|
+
export * from "./sources/github-app.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound email, used for login codes and invitations. Vestry ships a
|
|
3
|
+
* Resend adapter and a console mailer for development; anything with an HTTP
|
|
4
|
+
* API is a few lines behind the same interface.
|
|
5
|
+
*/
|
|
6
|
+
export interface MailMessage {
|
|
7
|
+
to: string;
|
|
8
|
+
subject: string;
|
|
9
|
+
text: string;
|
|
10
|
+
html?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface Mailer {
|
|
13
|
+
send(message: MailMessage): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export declare class MailError extends Error {
|
|
16
|
+
constructor(message: string);
|
|
17
|
+
}
|
|
18
|
+
export declare function resendMailer(opts: {
|
|
19
|
+
apiKey: string;
|
|
20
|
+
from: string;
|
|
21
|
+
apiBase?: string;
|
|
22
|
+
}): Mailer;
|
|
23
|
+
/** Prints messages instead of sending them (development). */
|
|
24
|
+
export declare function consoleMailer(log?: (line: string) => void): Mailer;
|
|
25
|
+
/** Collects messages for tests. */
|
|
26
|
+
export declare function memoryMailer(): Mailer & {
|
|
27
|
+
sent: MailMessage[];
|
|
28
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export class MailError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "MailError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export function resendMailer(opts) {
|
|
8
|
+
const base = (opts.apiBase ?? "https://api.resend.com").replace(/\/$/, "");
|
|
9
|
+
return {
|
|
10
|
+
async send(message) {
|
|
11
|
+
const res = await fetch(`${base}/emails`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: {
|
|
14
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
},
|
|
17
|
+
body: JSON.stringify({
|
|
18
|
+
from: opts.from,
|
|
19
|
+
to: [message.to],
|
|
20
|
+
subject: message.subject,
|
|
21
|
+
text: message.text,
|
|
22
|
+
...(message.html ? { html: message.html } : {}),
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
let detail = `${res.status}`;
|
|
27
|
+
try {
|
|
28
|
+
const body = (await res.json());
|
|
29
|
+
if (body.message)
|
|
30
|
+
detail = `${body.message} (${res.status})`;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* ignore */
|
|
34
|
+
}
|
|
35
|
+
throw new MailError(`Resend rejected the email: ${detail}`);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Prints messages instead of sending them (development). */
|
|
41
|
+
export function consoleMailer(log = (line) => console.log(line)) {
|
|
42
|
+
return {
|
|
43
|
+
async send(message) {
|
|
44
|
+
log([
|
|
45
|
+
"",
|
|
46
|
+
`[vestry] Email to ${message.to}: ${message.subject}`,
|
|
47
|
+
...message.text.split("\n").map((l) => ` ${l}`),
|
|
48
|
+
"",
|
|
49
|
+
].join("\n"));
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Collects messages for tests. */
|
|
54
|
+
export function memoryMailer() {
|
|
55
|
+
const sent = [];
|
|
56
|
+
return {
|
|
57
|
+
sent,
|
|
58
|
+
async send(message) {
|
|
59
|
+
sent.push(message);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CollectionConfig, EntryFormat, SingletonConfig } from "../config/types.js";
|
|
2
|
+
export declare function normalizePath(p: string): string;
|
|
3
|
+
export declare function extname(p: string): string;
|
|
4
|
+
export declare function basename(p: string): string;
|
|
5
|
+
export declare function dirname(p: string): string;
|
|
6
|
+
export declare function joinPath(...parts: string[]): string;
|
|
7
|
+
/** Resolve `../x` style segments against a directory (repo-relative). */
|
|
8
|
+
export declare function resolveRelative(fromDir: string, rel: string): string;
|
|
9
|
+
/** Relative path from one directory to a file (repo-relative, posix). */
|
|
10
|
+
export declare function relativePath(fromDir: string, toFile: string): string;
|
|
11
|
+
export declare function formatFromPath(p: string): EntryFormat;
|
|
12
|
+
export interface PathPattern {
|
|
13
|
+
/** Literal path prefix before the wildcard, e.g. `src/content/sermons/`. */
|
|
14
|
+
prefix: string;
|
|
15
|
+
/** Literal suffix after the wildcard, e.g. `.mdx`. */
|
|
16
|
+
suffix: string;
|
|
17
|
+
/** Whether the wildcard was `**` (slugs may contain `/`). */
|
|
18
|
+
deep: boolean;
|
|
19
|
+
/** No wildcard: a single file containing all entries. */
|
|
20
|
+
single: boolean;
|
|
21
|
+
/** Directory that holds the entries (prefix without trailing slash). */
|
|
22
|
+
directory: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function parsePathPattern(pattern: string): PathPattern;
|
|
25
|
+
export declare function entryPath(pattern: PathPattern, slug: string): string;
|
|
26
|
+
/** Returns the slug for a file path if it matches the pattern, else null. */
|
|
27
|
+
export declare function slugFromPath(pattern: PathPattern, path: string): string | null;
|
|
28
|
+
export declare function collectionFormat(c: CollectionConfig | SingletonConfig): EntryFormat;
|
|
29
|
+
export declare function slugify(input: string): string;
|
|
30
|
+
/** Fill `{field}` placeholders from a record. Values are slugified when `slug` is true. */
|
|
31
|
+
export declare function fillTemplate(template: string, values: Record<string, unknown>, opts?: {
|
|
32
|
+
slug?: boolean;
|
|
33
|
+
}): string;
|
|
34
|
+
export declare function isSafeSlug(slug: string): boolean;
|
|
35
|
+
export declare function isSafeRepoPath(path: string): boolean;
|