vestrycms 0.0.0-stage → 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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -2
  3. package/dist/config/fields.d.ts +30 -0
  4. package/dist/config/fields.js +75 -0
  5. package/dist/config/index.d.ts +9 -0
  6. package/dist/config/index.js +18 -0
  7. package/dist/config/types.d.ts +412 -0
  8. package/dist/config/types.js +10 -0
  9. package/dist/core/api.d.ts +67 -0
  10. package/dist/core/api.js +543 -0
  11. package/dist/core/auth.d.ts +1597 -0
  12. package/dist/core/auth.js +264 -0
  13. package/dist/core/cache.d.ts +27 -0
  14. package/dist/core/cache.js +52 -0
  15. package/dist/core/entries.d.ts +19 -0
  16. package/dist/core/entries.js +127 -0
  17. package/dist/core/index-types.d.ts +20 -0
  18. package/dist/core/index-types.js +1 -0
  19. package/dist/core/index.d.ts +13 -0
  20. package/dist/core/index.js +13 -0
  21. package/dist/core/mail.d.ts +28 -0
  22. package/dist/core/mail.js +62 -0
  23. package/dist/core/paths.d.ts +35 -0
  24. package/dist/core/paths.js +148 -0
  25. package/dist/core/roles.d.ts +24 -0
  26. package/dist/core/roles.js +15 -0
  27. package/dist/core/service.d.ts +102 -0
  28. package/dist/core/service.js +478 -0
  29. package/dist/core/source.d.ts +82 -0
  30. package/dist/core/source.js +75 -0
  31. package/dist/core/sources/github-app.d.ts +26 -0
  32. package/dist/core/sources/github-app.js +161 -0
  33. package/dist/core/sources/github.d.ts +40 -0
  34. package/dist/core/sources/github.js +278 -0
  35. package/dist/core/sources/local.d.ts +16 -0
  36. package/dist/core/sources/local.js +98 -0
  37. package/dist/core/values.d.ts +22 -0
  38. package/dist/core/values.js +123 -0
  39. package/dist/env.d.ts +19 -0
  40. package/dist/index.d.ts +18 -0
  41. package/dist/index.js +167 -0
  42. package/dist/integration/build-index.d.ts +8 -0
  43. package/dist/integration/build-index.js +67 -0
  44. package/dist/integration/client-deps.d.ts +10 -0
  45. package/dist/integration/client-deps.js +43 -0
  46. package/dist/integration/dev-middleware.d.ts +6 -0
  47. package/dist/integration/dev-middleware.js +48 -0
  48. package/dist/integration/formatter.d.ts +7 -0
  49. package/dist/integration/formatter.js +56 -0
  50. package/dist/mdx/expressions.d.ts +14 -0
  51. package/dist/mdx/expressions.js +141 -0
  52. package/dist/mdx/index.d.ts +4 -0
  53. package/dist/mdx/index.js +4 -0
  54. package/dist/mdx/parse.d.ts +6 -0
  55. package/dist/mdx/parse.js +401 -0
  56. package/dist/mdx/serialize.d.ts +9 -0
  57. package/dist/mdx/serialize.js +485 -0
  58. package/dist/mdx/types.d.ts +42 -0
  59. package/dist/mdx/types.js +6 -0
  60. package/dist/pages/admin.astro +27 -0
  61. package/dist/pages/api.d.ts +3 -0
  62. package/dist/pages/api.js +31 -0
  63. package/dist/ui/App.d.ts +4 -0
  64. package/dist/ui/App.js +70 -0
  65. package/dist/ui/api.d.ts +83 -0
  66. package/dist/ui/api.js +97 -0
  67. package/dist/ui/auth-client.d.ts +27 -0
  68. package/dist/ui/auth-client.js +29 -0
  69. package/dist/ui/components/AssetPicker.d.ts +27 -0
  70. package/dist/ui/components/AssetPicker.js +97 -0
  71. package/dist/ui/components/Icon.d.ts +11 -0
  72. package/dist/ui/components/Icon.js +46 -0
  73. package/dist/ui/components/Layout.d.ts +15 -0
  74. package/dist/ui/components/Layout.js +87 -0
  75. package/dist/ui/components/editor/EditorContext.d.ts +15 -0
  76. package/dist/ui/components/editor/EditorContext.js +9 -0
  77. package/dist/ui/components/editor/NodeViews.d.ts +4 -0
  78. package/dist/ui/components/editor/NodeViews.js +127 -0
  79. package/dist/ui/components/editor/RichTextEditor.d.ts +24 -0
  80. package/dist/ui/components/editor/RichTextEditor.js +230 -0
  81. package/dist/ui/components/editor/extensions.d.ts +30 -0
  82. package/dist/ui/components/editor/extensions.js +248 -0
  83. package/dist/ui/components/editor/schema.d.ts +12 -0
  84. package/dist/ui/components/editor/schema.js +37 -0
  85. package/dist/ui/components/fields/CodeInput.d.ts +10 -0
  86. package/dist/ui/components/fields/CodeInput.js +8 -0
  87. package/dist/ui/components/fields/CodeMirrorInput.d.ts +2 -0
  88. package/dist/ui/components/fields/CodeMirrorInput.js +44 -0
  89. package/dist/ui/components/fields/FieldRenderer.d.ts +41 -0
  90. package/dist/ui/components/fields/FieldRenderer.js +299 -0
  91. package/dist/ui/components/fields/ImageTools.d.ts +31 -0
  92. package/dist/ui/components/fields/ImageTools.js +296 -0
  93. package/dist/ui/components/fields/MarkdownField.d.ts +7 -0
  94. package/dist/ui/components/fields/MarkdownField.js +16 -0
  95. package/dist/ui/components/fields/TreeInput.d.ts +13 -0
  96. package/dist/ui/components/fields/TreeInput.js +221 -0
  97. package/dist/ui/components/ui.d.ts +63 -0
  98. package/dist/ui/components/ui.js +113 -0
  99. package/dist/ui/context.d.ts +17 -0
  100. package/dist/ui/context.js +13 -0
  101. package/dist/ui/lib/format.d.ts +5 -0
  102. package/dist/ui/lib/format.js +11 -0
  103. package/dist/ui/lib/utils.d.ts +24 -0
  104. package/dist/ui/lib/utils.js +255 -0
  105. package/dist/ui/main.d.ts +1 -0
  106. package/dist/ui/main.js +11 -0
  107. package/dist/ui/pages/Assets.d.ts +1 -0
  108. package/dist/ui/pages/Assets.js +69 -0
  109. package/dist/ui/pages/CollectionList.d.ts +6 -0
  110. package/dist/ui/pages/CollectionList.js +115 -0
  111. package/dist/ui/pages/Dashboard.d.ts +1 -0
  112. package/dist/ui/pages/Dashboard.js +57 -0
  113. package/dist/ui/pages/EntryEditor.d.ts +3 -0
  114. package/dist/ui/pages/EntryEditor.js +313 -0
  115. package/dist/ui/pages/Login.d.ts +6 -0
  116. package/dist/ui/pages/Login.js +88 -0
  117. package/dist/ui/pages/Users.d.ts +1 -0
  118. package/dist/ui/pages/Users.js +102 -0
  119. package/dist/ui/styles.css +2554 -0
  120. package/package.json +123 -3
@@ -0,0 +1,478 @@
1
+ import { applyAutoFields, toEditorValues, toStoredValues } from "./values.js";
2
+ import { parseEntry, parseEntryList, serializeEntry, serializeEntryList, splitFrontmatter, } from "./entries.js";
3
+ import { basename, collectionFormat, dirname, entryPath, extname, fillTemplate, isSafeRepoPath, isSafeSlug, joinPath, normalizePath, parsePathPattern, slugFromPath, slugify, } from "./paths.js";
4
+ import { base64Decode, gitBlobSha, SourceError, toText, } from "./source.js";
5
+ const IMAGE_EXTS = new Set([
6
+ "jpg",
7
+ "jpeg",
8
+ "png",
9
+ "gif",
10
+ "webp",
11
+ "avif",
12
+ "svg",
13
+ ]);
14
+ export class ContentService {
15
+ config;
16
+ source;
17
+ index;
18
+ constructor(config, source, index) {
19
+ this.config = config;
20
+ this.source = source;
21
+ this.index = index;
22
+ }
23
+ collection(key) {
24
+ const c = this.config.collections?.[key];
25
+ if (!c)
26
+ throw new SourceError(`Unknown collection "${key}"`, 404, "not_found");
27
+ return c;
28
+ }
29
+ singleton(key) {
30
+ const s = this.config.singletons?.[key];
31
+ if (!s)
32
+ throw new SourceError(`Unknown singleton "${key}"`, 404, "not_found");
33
+ return s;
34
+ }
35
+ assetContainer(key) {
36
+ const a = this.config.assets?.[key];
37
+ if (!a)
38
+ throw new SourceError(`Unknown asset container "${key}"`, 404, "not_found");
39
+ return a;
40
+ }
41
+ // ---------------------------------------------------------------- entries
42
+ async listEntries(key) {
43
+ const col = this.collection(key);
44
+ const pattern = parsePathPattern(col.path);
45
+ const format = collectionFormat(col);
46
+ if (pattern.single) {
47
+ const file = await this.source.read(pattern.prefix);
48
+ if (!file)
49
+ return [];
50
+ const items = parseEntryList(format, toText(file.bytes));
51
+ const idField = col.idField ?? "id";
52
+ return items.map((data) => {
53
+ const slug = String(data[idField] ?? "");
54
+ return {
55
+ slug,
56
+ path: pattern.prefix,
57
+ sha: file.sha,
58
+ title: this.titleOf(col, data, slug),
59
+ data: toEditorValues(col.schema, data, pattern.prefix),
60
+ status: statusOf(col, data),
61
+ };
62
+ });
63
+ }
64
+ const files = (await this.source.list(pattern.directory)).filter((f) => slugFromPath(pattern, f.path) !== null);
65
+ const summaries = await Promise.all(files.map(async (f) => {
66
+ const { data, excerpt } = await this.frontmatterFor(f, format);
67
+ const slug = slugFromPath(pattern, f.path);
68
+ return {
69
+ slug,
70
+ path: f.path,
71
+ sha: f.sha,
72
+ title: this.titleOf(col, data, slug),
73
+ data: toEditorValues(col.schema, data, f.path),
74
+ status: statusOf(col, data),
75
+ excerpt,
76
+ };
77
+ }));
78
+ return sortEntries(col, summaries);
79
+ }
80
+ /**
81
+ * Frontmatter for a file: served from the build-time index when the blob
82
+ * SHA still matches, otherwise read from the source.
83
+ */
84
+ async frontmatterFor(file, format) {
85
+ const indexed = this.index.files[file.path];
86
+ if (indexed && indexed.sha === file.sha) {
87
+ return { data: indexed.data, excerpt: indexed.excerpt };
88
+ }
89
+ const data = await this.source.read(file.path);
90
+ if (!data)
91
+ return { data: {} };
92
+ const text = toText(data.bytes);
93
+ const parsed = parseEntry(format, text);
94
+ return { data: parsed.data, excerpt: makeExcerpt(parsed.body) };
95
+ }
96
+ async getEntry(key, slug) {
97
+ const col = this.collection(key);
98
+ const pattern = parsePathPattern(col.path);
99
+ const format = collectionFormat(col);
100
+ const head = await this.source.head();
101
+ if (pattern.single) {
102
+ const file = await this.source.read(pattern.prefix);
103
+ if (!file)
104
+ return null;
105
+ const items = parseEntryList(format, toText(file.bytes));
106
+ const idField = col.idField ?? "id";
107
+ const data = items.find((i) => String(i[idField]) === slug);
108
+ if (!data)
109
+ return null;
110
+ return {
111
+ slug,
112
+ path: pattern.prefix,
113
+ sha: file.sha,
114
+ title: this.titleOf(col, data, slug),
115
+ data: toEditorValues(col.schema, data, pattern.prefix),
116
+ status: statusOf(col, data),
117
+ body: "",
118
+ head,
119
+ };
120
+ }
121
+ if (!isSafeSlug(slug))
122
+ throw new SourceError("Invalid slug", 400, "bad_slug");
123
+ const path = entryPath(pattern, slug);
124
+ const file = await this.source.read(path);
125
+ if (!file)
126
+ return null;
127
+ const parsed = parseEntry(format, toText(file.bytes));
128
+ return {
129
+ slug,
130
+ path,
131
+ sha: file.sha,
132
+ title: this.titleOf(col, parsed.data, slug),
133
+ data: toEditorValues(col.schema, parsed.data, path),
134
+ status: statusOf(col, parsed.data),
135
+ body: parsed.body,
136
+ head,
137
+ };
138
+ }
139
+ async saveEntry(key, slug, input) {
140
+ const col = this.collection(key);
141
+ const pattern = parsePathPattern(col.path);
142
+ const format = collectionFormat(col);
143
+ const isNew = slug === null;
144
+ let data = applyAutoFields(col.schema, { ...input.data });
145
+ if (pattern.single) {
146
+ const idField = col.idField ?? "id";
147
+ const file = await this.source.read(pattern.prefix);
148
+ const items = file ? parseEntryList(format, toText(file.bytes)) : [];
149
+ if (file && input.sha && input.sha !== file.sha) {
150
+ throw new SourceError("This file changed since you opened it.", 409, "conflict");
151
+ }
152
+ const id = String(data[idField] ?? slug ?? this.newSlug(col, data));
153
+ if (!id)
154
+ throw new SourceError(`Entries need an "${idField}"`, 400, "validation");
155
+ data[idField] = id;
156
+ const stored = toStoredValues(col.schema, data, pattern.prefix);
157
+ const existingIdx = items.findIndex((i) => String(i[idField]) === (slug ?? id));
158
+ if (isNew && existingIdx !== -1) {
159
+ throw new SourceError(`An entry with id "${id}" already exists`, 409, "exists");
160
+ }
161
+ if (existingIdx === -1)
162
+ items.push(stored);
163
+ else
164
+ items[existingIdx] = stored;
165
+ const text = serializeEntryList(format, items);
166
+ const changes = [
167
+ { path: pattern.prefix, content: text },
168
+ ...uploadChanges(input.uploads),
169
+ ];
170
+ const commit = await this.source.commit(changes, {
171
+ message: this.commitMessage(col, isNew ? "Create" : "Update", id, this.titleOf(col, data, id), key, input.message),
172
+ });
173
+ return {
174
+ slug: id,
175
+ sha: await gitBlobSha(text),
176
+ commit: commit.sha,
177
+ path: pattern.prefix,
178
+ };
179
+ }
180
+ const finalSlug = slug ?? (input.slug?.trim() || this.newSlug(col, data));
181
+ if (!isSafeSlug(finalSlug))
182
+ throw new SourceError(`Invalid slug "${finalSlug}"`, 400, "bad_slug");
183
+ const path = entryPath(pattern, finalSlug);
184
+ const existing = await this.source.read(path);
185
+ if (isNew && existing) {
186
+ throw new SourceError(`An entry at "${path}" already exists`, 409, "exists");
187
+ }
188
+ if (!isNew && existing && input.sha && input.sha !== existing.sha) {
189
+ throw new SourceError("This entry changed since you opened it.", 409, "conflict");
190
+ }
191
+ const stored = toStoredValues(col.schema, data, path);
192
+ const text = input.raw ??
193
+ serializeEntry(format, { data: stored, body: input.body ?? "" });
194
+ const changes = [
195
+ { path, content: text },
196
+ ...uploadChanges(input.uploads),
197
+ ];
198
+ const commit = await this.source.commit(changes, {
199
+ message: this.commitMessage(col, isNew ? "Create" : "Update", finalSlug, this.titleOf(col, data, finalSlug), key, input.message),
200
+ });
201
+ return {
202
+ slug: finalSlug,
203
+ sha: await gitBlobSha(text),
204
+ commit: commit.sha,
205
+ path,
206
+ };
207
+ }
208
+ async deleteEntry(key, slug) {
209
+ const col = this.collection(key);
210
+ if (col.delete === false)
211
+ throw new SourceError("Deleting is disabled for this collection", 403, "forbidden");
212
+ const pattern = parsePathPattern(col.path);
213
+ const format = collectionFormat(col);
214
+ if (pattern.single) {
215
+ const idField = col.idField ?? "id";
216
+ const file = await this.source.read(pattern.prefix);
217
+ const items = file ? parseEntryList(format, toText(file.bytes)) : [];
218
+ const next = items.filter((i) => String(i[idField]) !== slug);
219
+ if (next.length === items.length)
220
+ throw new SourceError("Entry not found", 404, "not_found");
221
+ const commit = await this.source.commit([{ path: pattern.prefix, content: serializeEntryList(format, next) }], { message: this.commitMessage(col, "Delete", slug, slug, key) });
222
+ return { commit: commit.sha };
223
+ }
224
+ if (!isSafeSlug(slug))
225
+ throw new SourceError("Invalid slug", 400, "bad_slug");
226
+ const path = entryPath(pattern, slug);
227
+ const commit = await this.source.commit([{ path, delete: true }], {
228
+ message: this.commitMessage(col, "Delete", slug, slug, key),
229
+ });
230
+ return { commit: commit.sha };
231
+ }
232
+ // -------------------------------------------------------------- singletons
233
+ async getSingleton(key) {
234
+ const s = this.singleton(key);
235
+ const format = collectionFormat(s);
236
+ const path = normalizePath(s.path);
237
+ const head = await this.source.head();
238
+ const file = await this.source.read(path);
239
+ const parsed = file
240
+ ? parseEntry(format, toText(file.bytes))
241
+ : { data: {}, body: "" };
242
+ return {
243
+ slug: key,
244
+ path,
245
+ sha: file?.sha ?? "",
246
+ title: s.label,
247
+ data: toEditorValues(s.schema, parsed.data, path),
248
+ status: null,
249
+ body: parsed.body,
250
+ head,
251
+ };
252
+ }
253
+ async saveSingleton(key, input) {
254
+ const s = this.singleton(key);
255
+ const format = collectionFormat(s);
256
+ const path = normalizePath(s.path);
257
+ const existing = await this.source.read(path);
258
+ if (existing && input.sha && input.sha !== existing.sha) {
259
+ throw new SourceError("This file changed since you opened it.", 409, "conflict");
260
+ }
261
+ const data = applyAutoFields(s.schema, { ...input.data });
262
+ const stored = toStoredValues(s.schema, data, path);
263
+ const text = input.raw ??
264
+ serializeEntry(format, { data: stored, body: input.body ?? "" });
265
+ const commit = await this.source.commit([{ path, content: text }, ...uploadChanges(input.uploads)], {
266
+ message: input.message ??
267
+ fillTemplate(s.commitMessage ?? this.config.commitMessage ?? "Update {title}", {
268
+ action: "Update",
269
+ title: s.label,
270
+ slug: key,
271
+ collection: key,
272
+ }),
273
+ });
274
+ return { sha: await gitBlobSha(text), commit: commit.sha };
275
+ }
276
+ // ------------------------------------------------------------------ assets
277
+ async listAssets(containerKey, subdir = "") {
278
+ const container = this.assetContainer(containerKey);
279
+ const dir = joinPath(container.directory, subdir);
280
+ if (!isSafeRepoPath(dir) ||
281
+ !dir.startsWith(normalizePath(container.directory))) {
282
+ throw new SourceError("Invalid directory", 400, "bad_path");
283
+ }
284
+ const files = await this.source.list(dir);
285
+ const items = [];
286
+ const folders = new Set();
287
+ const prefix = `${dir}/`;
288
+ for (const f of files) {
289
+ const rest = f.path.slice(prefix.length);
290
+ const slash = rest.indexOf("/");
291
+ if (slash !== -1) {
292
+ folders.add(rest.slice(0, slash));
293
+ continue;
294
+ }
295
+ if (rest.startsWith("."))
296
+ continue;
297
+ const ext = extname(rest);
298
+ items.push({
299
+ name: rest,
300
+ path: f.path,
301
+ sha: f.sha,
302
+ size: f.size,
303
+ ext,
304
+ kind: IMAGE_EXTS.has(ext) ? "image" : "file",
305
+ });
306
+ }
307
+ items.sort((a, b) => a.name.localeCompare(b.name));
308
+ return { items, folders: [...folders].sort() };
309
+ }
310
+ async uploadAssets(containerKey, subdir, files) {
311
+ const container = this.assetContainer(containerKey);
312
+ const dir = joinPath(container.directory, subdir);
313
+ const changes = [];
314
+ const paths = [];
315
+ for (const f of files) {
316
+ const name = safeFileName(f.name);
317
+ const ext = extname(name);
318
+ const accept = container.accept;
319
+ if (accept && !accept.includes(ext)) {
320
+ throw new SourceError(`"${name}" is not an accepted file type`, 400, "validation");
321
+ }
322
+ const path = joinPath(dir, name);
323
+ if (!isSafeRepoPath(path))
324
+ throw new SourceError("Invalid file name", 400, "bad_path");
325
+ changes.push({ path, content: base64Decode(f.base64) });
326
+ paths.push(path);
327
+ }
328
+ const commit = await this.source.commit(changes, {
329
+ message: files.length === 1
330
+ ? `Upload ${basename(paths[0])} to ${dir}`
331
+ : `Upload ${files.length} files to ${dir}`,
332
+ });
333
+ return { commit: commit.sha, paths };
334
+ }
335
+ async deleteAsset(containerKey, path) {
336
+ const container = this.assetContainer(containerKey);
337
+ const n = normalizePath(path);
338
+ if (!n.startsWith(`${normalizePath(container.directory)}/`) ||
339
+ !isSafeRepoPath(n)) {
340
+ throw new SourceError("Path is outside the asset container", 400, "bad_path");
341
+ }
342
+ const commit = await this.source.commit([{ path: n, delete: true }], {
343
+ message: `Delete ${basename(n)}`,
344
+ });
345
+ return { commit: commit.sha };
346
+ }
347
+ /** Raw file read restricted to configured asset containers and entry directories. */
348
+ async readFile(path) {
349
+ const n = normalizePath(path);
350
+ if (!isSafeRepoPath(n))
351
+ throw new SourceError("Invalid path", 400, "bad_path");
352
+ const allowed = this.allowedDirectories().some((d) => n.startsWith(`${d}/`));
353
+ if (!allowed)
354
+ throw new SourceError("Path is not readable through Vestry", 403, "forbidden");
355
+ return this.source.read(n);
356
+ }
357
+ allowedDirectories() {
358
+ const dirs = new Set();
359
+ if (this.config.logo && !/^https?:\/\//.test(this.config.logo)) {
360
+ dirs.add(dirname(normalizePath(this.config.logo)));
361
+ }
362
+ for (const a of Object.values(this.config.assets ?? {}))
363
+ dirs.add(normalizePath(a.directory));
364
+ const addSchemaDirs = (schema) => {
365
+ for (const f of Object.values(schema)) {
366
+ if (f.type === "image" || f.type === "file")
367
+ dirs.add(normalizePath(f.directory));
368
+ if (f.type === "object")
369
+ addSchemaDirs(f.fields);
370
+ if (f.type === "array")
371
+ addSchemaDirs({ item: f.of });
372
+ }
373
+ };
374
+ for (const c of Object.values(this.config.collections ?? {}))
375
+ addSchemaDirs(c.schema);
376
+ for (const s of Object.values(this.config.singletons ?? {}))
377
+ addSchemaDirs(s.schema);
378
+ for (const c of Object.values(this.config.components ?? {}))
379
+ addSchemaDirs(c.props ?? {});
380
+ return [...dirs];
381
+ }
382
+ // ----------------------------------------------------------------- helpers
383
+ titleOf(col, data, slug) {
384
+ const v = data[col.titleField ?? "title"];
385
+ return typeof v === "string" && v.trim() ? v : slug;
386
+ }
387
+ newSlug(col, data) {
388
+ const template = col.slug?.template;
389
+ if (template)
390
+ return fillTemplate(template, data, { slug: true });
391
+ const from = col.slug?.from ?? col.titleField ?? "title";
392
+ const v = data[from];
393
+ const s = v instanceof Date ? v.toISOString().slice(0, 10) : String(v ?? "");
394
+ const slug = slugify(s);
395
+ if (!slug)
396
+ throw new SourceError(`Cannot derive a slug from "${from}"`, 400, "validation");
397
+ return slug;
398
+ }
399
+ commitMessage(col, action, slug, title, collection, override) {
400
+ if (override?.trim())
401
+ return override.trim();
402
+ const template = col.commitMessage ??
403
+ this.config.commitMessage ??
404
+ "{action} {singular}: {title}";
405
+ return fillTemplate(template, {
406
+ action,
407
+ slug,
408
+ title,
409
+ collection,
410
+ label: col.label,
411
+ singular: col.singular ?? col.label,
412
+ });
413
+ }
414
+ }
415
+ function statusOf(col, data) {
416
+ if (!col.status)
417
+ return null;
418
+ const publishedWhen = col.status.publishedWhen ?? true;
419
+ const field = col.schema[col.status.field];
420
+ const fallback = field?.type === "boolean" ? (field.default ?? false) : false;
421
+ const v = data[col.status.field];
422
+ const bool = typeof v === "boolean"
423
+ ? v
424
+ : v === undefined || v === null
425
+ ? fallback
426
+ : Boolean(v);
427
+ return bool === publishedWhen ? "published" : "draft";
428
+ }
429
+ function sortEntries(col, entries) {
430
+ const sort = col.sort;
431
+ const dir = sort?.direction === "asc" ? 1 : -1;
432
+ const field = sort?.field;
433
+ return [...entries].sort((a, b) => {
434
+ const av = field ? sortable(a.data[field]) : a.slug;
435
+ const bv = field ? sortable(b.data[field]) : b.slug;
436
+ if (av === bv)
437
+ return a.slug < b.slug ? -dir : dir;
438
+ return av < bv ? -dir : dir;
439
+ });
440
+ }
441
+ function sortable(v) {
442
+ if (v instanceof Date)
443
+ return v.getTime();
444
+ if (typeof v === "number")
445
+ return v;
446
+ if (typeof v === "boolean")
447
+ return v ? 1 : 0;
448
+ return String(v ?? "");
449
+ }
450
+ function makeExcerpt(body) {
451
+ const text = body
452
+ .replace(/^import .*$/gm, "")
453
+ .replace(/<[^>]+>/g, " ")
454
+ .replace(/[#*_>`[\]]/g, "")
455
+ .replace(/\s+/g, " ")
456
+ .trim();
457
+ return text ? text.slice(0, 200) : undefined;
458
+ }
459
+ function uploadChanges(uploads) {
460
+ return (uploads ?? []).map((u) => {
461
+ if (!isSafeRepoPath(u.path))
462
+ throw new SourceError(`Invalid upload path "${u.path}"`, 400, "bad_path");
463
+ return { path: normalizePath(u.path), content: base64Decode(u.base64) };
464
+ });
465
+ }
466
+ export function safeFileName(name) {
467
+ const base = basename(name.replace(/\\/g, "/"));
468
+ const ext = extname(base);
469
+ const stem = base.slice(0, ext ? base.length - ext.length - 1 : undefined);
470
+ const cleanStem = stem
471
+ .normalize("NFKD")
472
+ .replace(/[̀-ͯ]/g, "")
473
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
474
+ .replace(/^-+|-+$/g, "")
475
+ .toLowerCase();
476
+ return ext ? `${cleanStem || "file"}.${ext}` : cleanStem || "file";
477
+ }
478
+ export { splitFrontmatter };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The content source abstraction. Vestry reads and writes files through one
3
+ * of these; the control panel never touches storage directly.
4
+ *
5
+ * Two implementations ship today:
6
+ * - `LocalSource` – the project directory on disk (used by `astro dev`).
7
+ * - `GitHubSource` – the GitHub REST (Git Data) API.
8
+ *
9
+ * Other git hosts (GitLab, Gitea, a bare git remote) would implement the same
10
+ * interface.
11
+ */
12
+ export interface TreeEntry {
13
+ /** Repository-relative path, posix separators. */
14
+ path: string;
15
+ /** Git blob SHA (or a content hash for sources without git). */
16
+ sha: string;
17
+ size?: number;
18
+ }
19
+ export interface FileData {
20
+ bytes: Uint8Array;
21
+ sha: string;
22
+ }
23
+ export type FileChange = {
24
+ path: string;
25
+ content: string | Uint8Array;
26
+ encoding?: "utf-8" | "binary";
27
+ } | {
28
+ path: string;
29
+ delete: true;
30
+ };
31
+ export interface CommitInfo {
32
+ /** The resulting commit (or head) identifier. */
33
+ sha: string;
34
+ /** Link to the commit, if the source has one. */
35
+ url?: string;
36
+ }
37
+ export interface CommitOptions {
38
+ message: string;
39
+ /** Who made the change; sources that commit as a service account credit them (e.g. as a co-author). */
40
+ author?: {
41
+ name: string;
42
+ email: string;
43
+ };
44
+ /** Required for optimistic concurrency: the head the client last saw. */
45
+ expectedHead?: string;
46
+ }
47
+ export interface SourceInfo {
48
+ kind: "local" | "github";
49
+ /** Human description, e.g. `iBelieve/mluther.org@master` or the local directory. */
50
+ label: string;
51
+ branch?: string;
52
+ /** Link to browse the source, if any. */
53
+ url?: string;
54
+ }
55
+ export interface ContentSource {
56
+ info(): SourceInfo;
57
+ /** Current head commit SHA. Local sources return a synthetic value. */
58
+ head(): Promise<string>;
59
+ /** All files under `directory` (recursive). */
60
+ list(directory: string): Promise<TreeEntry[]>;
61
+ /** Read a file, or null if it doesn't exist. */
62
+ read(path: string): Promise<FileData | null>;
63
+ /** Apply a set of changes as one commit. */
64
+ commit(changes: FileChange[], options: CommitOptions): Promise<CommitInfo>;
65
+ }
66
+ export declare class SourceError extends Error {
67
+ status: number;
68
+ code: string;
69
+ constructor(message: string, status?: number, code?: string);
70
+ }
71
+ export declare function toBytes(content: string | Uint8Array): Uint8Array;
72
+ export declare function toText(bytes: Uint8Array): string;
73
+ /** Git blob SHA-1 of the given content: `sha1("blob <len>\0" + bytes)`. */
74
+ export declare function gitBlobSha(content: string | Uint8Array): Promise<string>;
75
+ export declare function hex(bytes: Uint8Array): string;
76
+ export declare function base64Encode(bytes: Uint8Array): string;
77
+ export declare function base64Decode(b64: string): Uint8Array;
78
+ /** Wraps a source so every commit carries `author` unless the caller set one. */
79
+ export declare function withAuthor(source: ContentSource, author: {
80
+ name: string;
81
+ email: string;
82
+ }): ContentSource;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The content source abstraction. Vestry reads and writes files through one
3
+ * of these; the control panel never touches storage directly.
4
+ *
5
+ * Two implementations ship today:
6
+ * - `LocalSource` – the project directory on disk (used by `astro dev`).
7
+ * - `GitHubSource` – the GitHub REST (Git Data) API.
8
+ *
9
+ * Other git hosts (GitLab, Gitea, a bare git remote) would implement the same
10
+ * interface.
11
+ */
12
+ export class SourceError extends Error {
13
+ status;
14
+ code;
15
+ constructor(message, status = 500, code = "source_error") {
16
+ super(message);
17
+ this.status = status;
18
+ this.code = code;
19
+ this.name = "SourceError";
20
+ }
21
+ }
22
+ const encoder = new TextEncoder();
23
+ const decoder = new TextDecoder();
24
+ export function toBytes(content) {
25
+ return typeof content === "string" ? encoder.encode(content) : content;
26
+ }
27
+ export function toText(bytes) {
28
+ return decoder.decode(bytes);
29
+ }
30
+ /** Git blob SHA-1 of the given content: `sha1("blob <len>\0" + bytes)`. */
31
+ export async function gitBlobSha(content) {
32
+ const bytes = toBytes(content);
33
+ const header = encoder.encode(`blob ${bytes.byteLength}\0`);
34
+ const buf = new Uint8Array(header.byteLength + bytes.byteLength);
35
+ buf.set(header, 0);
36
+ buf.set(bytes, header.byteLength);
37
+ const digest = await crypto.subtle.digest("SHA-1", buf);
38
+ return hex(new Uint8Array(digest));
39
+ }
40
+ export function hex(bytes) {
41
+ let s = "";
42
+ for (const b of bytes)
43
+ s += b.toString(16).padStart(2, "0");
44
+ return s;
45
+ }
46
+ export function base64Encode(bytes) {
47
+ if (typeof Buffer !== "undefined")
48
+ return Buffer.from(bytes).toString("base64");
49
+ let bin = "";
50
+ const chunk = 0x8000;
51
+ for (let i = 0; i < bytes.length; i += chunk) {
52
+ bin += String.fromCharCode(...bytes.subarray(i, i + chunk));
53
+ }
54
+ return btoa(bin);
55
+ }
56
+ export function base64Decode(b64) {
57
+ const clean = b64.replace(/\s+/g, "");
58
+ if (typeof Buffer !== "undefined")
59
+ return new Uint8Array(Buffer.from(clean, "base64"));
60
+ const bin = atob(clean);
61
+ const out = new Uint8Array(bin.length);
62
+ for (let i = 0; i < bin.length; i++)
63
+ out[i] = bin.charCodeAt(i);
64
+ return out;
65
+ }
66
+ /** Wraps a source so every commit carries `author` unless the caller set one. */
67
+ export function withAuthor(source, author) {
68
+ return {
69
+ info: () => source.info(),
70
+ head: () => source.head(),
71
+ list: (directory) => source.list(directory),
72
+ read: (path) => source.read(path),
73
+ commit: (changes, options) => source.commit(changes, { author, ...options }),
74
+ };
75
+ }
@@ -0,0 +1,26 @@
1
+ export interface GitHubAppCredentials {
2
+ appId: string;
3
+ installationId: string;
4
+ /** PEM private key (PKCS#1 as GitHub issues it, or PKCS#8), `\n`-escaped or base64-encoded PEM also accepted. */
5
+ privateKey: string;
6
+ apiBase?: string;
7
+ }
8
+ export type TokenProvider = (() => Promise<string>) & {
9
+ /** Forget the cached token, e.g. after GitHub rejected it. */
10
+ invalidate(): void;
11
+ };
12
+ interface CachedToken {
13
+ token: string;
14
+ expiresAt: number;
15
+ }
16
+ /**
17
+ * Returns a provider that mints (and caches) installation tokens for the
18
+ * app. Concurrent callers share one in-flight mint; a failed mint is
19
+ * forgotten so the next caller retries; tokens are renewed a minute early.
20
+ */
21
+ export declare function installationTokenProvider(creds: GitHubAppCredentials): TokenProvider;
22
+ export declare function mintInstallationToken(creds: GitHubAppCredentials): Promise<CachedToken>;
23
+ /** A GitHub App JWT: issued a minute ago (clock skew), valid for nine more. */
24
+ export declare function signAppJwt(appId: string, privateKey: string): Promise<string>;
25
+ export declare function importRsaPrivateKey(pem: string): Promise<CryptoKey>;
26
+ export {};