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,83 @@
1
+ import type { ClientConfig } from "../core/api.js";
2
+ import type { PublicUser, Role } from "../core/roles.js";
3
+ import type { AssetItem, EntryDetail, EntrySummary, SaveEntryInput } from "../core/service.js";
4
+ export type { ClientConfig, AssetItem, EntryDetail, EntrySummary, SaveEntryInput, PublicUser, Role, };
5
+ /** Fired on `window` when the API answers 401, so the app can return to sign-in. */
6
+ export declare const UNAUTHORIZED_EVENT = "vestry:unauthorized";
7
+ export declare class ApiError extends Error {
8
+ status: number;
9
+ code: string;
10
+ constructor(message: string, status: number, code: string);
11
+ }
12
+ export declare function createClient(basePath: string): {
13
+ prefix: string;
14
+ fileUrl: (path: string, sha?: string) => string;
15
+ config: () => Promise<ClientConfig>;
16
+ status: () => Promise<{
17
+ head: string;
18
+ }>;
19
+ listUsers: () => Promise<{
20
+ users: PublicUser[];
21
+ }>;
22
+ inviteUser: (input: {
23
+ email: string;
24
+ name?: string;
25
+ role: Role;
26
+ }) => Promise<{
27
+ user: PublicUser;
28
+ mailError?: string;
29
+ }>;
30
+ updateUser: (id: string, patch: {
31
+ name?: string;
32
+ role?: Role;
33
+ }) => Promise<{
34
+ user: PublicUser;
35
+ }>;
36
+ removeUser: (id: string) => Promise<{
37
+ ok: true;
38
+ }>;
39
+ resendInvite: (id: string) => Promise<{
40
+ user: PublicUser;
41
+ }>;
42
+ listEntries: (collection: string) => Promise<{
43
+ entries: EntrySummary[];
44
+ head: string;
45
+ }>;
46
+ getEntry: (collection: string, slug: string) => Promise<EntryDetail>;
47
+ createEntry: (collection: string, input: SaveEntryInput) => Promise<{
48
+ slug: string;
49
+ sha: string;
50
+ commit: string;
51
+ path: string;
52
+ }>;
53
+ updateEntry: (collection: string, slug: string, input: SaveEntryInput) => Promise<{
54
+ slug: string;
55
+ sha: string;
56
+ commit: string;
57
+ path: string;
58
+ }>;
59
+ deleteEntry: (collection: string, slug: string) => Promise<{
60
+ commit: string;
61
+ }>;
62
+ getSingleton: (key: string) => Promise<EntryDetail>;
63
+ saveSingleton: (key: string, input: SaveEntryInput) => Promise<{
64
+ sha: string;
65
+ commit: string;
66
+ }>;
67
+ listAssets: (container: string, dir?: string) => Promise<{
68
+ items: AssetItem[];
69
+ folders: string[];
70
+ }>;
71
+ uploadAssets: (container: string, dir: string, files: {
72
+ name: string;
73
+ base64: string;
74
+ }[]) => Promise<{
75
+ commit: string;
76
+ paths: string[];
77
+ }>;
78
+ deleteAsset: (container: string, path: string) => Promise<{
79
+ commit: string;
80
+ }>;
81
+ };
82
+ export type ApiClient = ReturnType<typeof createClient>;
83
+ export declare function fileToBase64(file: File): Promise<string>;
package/dist/ui/api.js ADDED
@@ -0,0 +1,97 @@
1
+ /** Fired on `window` when the API answers 401, so the app can return to sign-in. */
2
+ export const UNAUTHORIZED_EVENT = "vestry:unauthorized";
3
+ export class ApiError extends Error {
4
+ status;
5
+ code;
6
+ constructor(message, status, code) {
7
+ super(message);
8
+ this.status = status;
9
+ this.code = code;
10
+ this.name = "ApiError";
11
+ }
12
+ }
13
+ export function createClient(basePath) {
14
+ const prefix = `${basePath.replace(/\/$/, "")}/api`;
15
+ async function request(path, init = {}) {
16
+ const res = await fetch(`${prefix}${path}`, {
17
+ ...init,
18
+ headers: {
19
+ Accept: "application/json",
20
+ ...(init.body ? { "Content-Type": "application/json" } : {}),
21
+ ...(init.headers ?? {}),
22
+ },
23
+ credentials: "same-origin",
24
+ });
25
+ if (res.status === 204)
26
+ return undefined;
27
+ let data = null;
28
+ const text = await res.text();
29
+ try {
30
+ data = text ? JSON.parse(text) : null;
31
+ }
32
+ catch {
33
+ data = null;
34
+ }
35
+ if (!res.ok) {
36
+ const err = data?.error;
37
+ if (res.status === 401 && err?.code === "unauthorized") {
38
+ window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
39
+ }
40
+ throw new ApiError(err?.message ?? `Request failed (${res.status})`, res.status, err?.code ?? "error");
41
+ }
42
+ return data;
43
+ }
44
+ const enc = (s) => s.split("/").map(encodeURIComponent).join("/");
45
+ return {
46
+ prefix,
47
+ fileUrl: (path, sha) => `${prefix}/file?path=${encodeURIComponent(path)}${sha ? `&v=${sha}` : ""}`,
48
+ config: () => request("/config"),
49
+ status: () => request("/status"),
50
+ listUsers: () => request("/users"),
51
+ inviteUser: (input) => request("/users", {
52
+ method: "POST",
53
+ body: JSON.stringify(input),
54
+ }),
55
+ updateUser: (id, patch) => request(`/users/${enc(id)}`, {
56
+ method: "PATCH",
57
+ body: JSON.stringify(patch),
58
+ }),
59
+ removeUser: (id) => request(`/users/${enc(id)}`, { method: "DELETE" }),
60
+ resendInvite: (id) => request(`/users/${enc(id)}/invite`, {
61
+ method: "POST",
62
+ }),
63
+ listEntries: (collection) => request(`/collections/${enc(collection)}`),
64
+ getEntry: (collection, slug) => request(`/collections/${enc(collection)}/entries/${enc(slug)}`),
65
+ createEntry: (collection, input) => request(`/collections/${enc(collection)}/entries`, {
66
+ method: "POST",
67
+ body: JSON.stringify(input),
68
+ }),
69
+ updateEntry: (collection, slug, input) => request(`/collections/${enc(collection)}/entries/${enc(slug)}`, { method: "PUT", body: JSON.stringify(input) }),
70
+ deleteEntry: (collection, slug) => request(`/collections/${enc(collection)}/entries/${enc(slug)}`, { method: "DELETE" }),
71
+ getSingleton: (key) => request(`/singletons/${enc(key)}`),
72
+ saveSingleton: (key, input) => request(`/singletons/${enc(key)}`, {
73
+ method: "PUT",
74
+ body: JSON.stringify(input),
75
+ }),
76
+ listAssets: (container, dir = "") => request(`/assets/${enc(container)}?dir=${encodeURIComponent(dir)}`),
77
+ uploadAssets: (container, dir, files) => request(`/assets/${enc(container)}`, {
78
+ method: "POST",
79
+ body: JSON.stringify({ dir, files }),
80
+ }),
81
+ deleteAsset: (container, path) => request(`/assets/${enc(container)}`, {
82
+ method: "DELETE",
83
+ body: JSON.stringify({ path }),
84
+ }),
85
+ };
86
+ }
87
+ export function fileToBase64(file) {
88
+ return new Promise((resolve, reject) => {
89
+ const reader = new FileReader();
90
+ reader.onload = () => {
91
+ const result = String(reader.result);
92
+ resolve(result.slice(result.indexOf(",") + 1));
93
+ };
94
+ reader.onerror = () => reject(reader.error);
95
+ reader.readAsDataURL(file);
96
+ });
97
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Better Auth's browser client, mounted where the API serves `auth/*`. Only
3
+ * sign-in and sign-out go through it; users are managed via Vestry's API.
4
+ */
5
+ export declare function createVestryAuthClient(basePath: string): import("better-auth/client").AuthClient<{
6
+ baseURL: string;
7
+ plugins: {
8
+ id: "email-otp";
9
+ version: string;
10
+ $InferServerPlugin: ReturnType<typeof import("better-auth/plugins").emailOTP>;
11
+ atomListeners: {
12
+ matcher: (path: string) => path is "/email-otp/verify-email" | "/sign-in/email-otp" | "/email-otp/request-email-change";
13
+ signal: "$sessionSignal";
14
+ }[];
15
+ $ERROR_CODES: {
16
+ OTP_EXPIRED: import("better-auth").RawError<"OTP_EXPIRED">;
17
+ INVALID_OTP: import("better-auth").RawError<"INVALID_OTP">;
18
+ TOO_MANY_ATTEMPTS: import("better-auth").RawError<"TOO_MANY_ATTEMPTS">;
19
+ };
20
+ }[];
21
+ }>;
22
+ export type VestryAuthClient = ReturnType<typeof createVestryAuthClient>;
23
+ /** Turns a Better Auth client error into something to show the user. */
24
+ export declare function authErrorMessage(error: {
25
+ code?: string;
26
+ message?: string;
27
+ } | null | undefined, fallback?: string): string;
@@ -0,0 +1,29 @@
1
+ import { createAuthClient } from "better-auth/client";
2
+ import { emailOTPClient } from "better-auth/client/plugins";
3
+ /**
4
+ * Better Auth's browser client, mounted where the API serves `auth/*`. Only
5
+ * sign-in and sign-out go through it; users are managed via Vestry's API.
6
+ */
7
+ export function createVestryAuthClient(basePath) {
8
+ return createAuthClient({
9
+ baseURL: `${window.location.origin}${basePath.replace(/\/$/, "")}/api/auth`,
10
+ plugins: [emailOTPClient()],
11
+ });
12
+ }
13
+ /** Turns a Better Auth client error into something to show the user. */
14
+ export function authErrorMessage(error, fallback = "Something went wrong. Please try again.") {
15
+ switch (error?.code) {
16
+ case "INVALID_OTP":
17
+ return "That code isn't right. Check the email and try again.";
18
+ case "OTP_EXPIRED":
19
+ return "That code has expired. Request a new one.";
20
+ case "TOO_MANY_ATTEMPTS":
21
+ return "Too many incorrect codes. Request a new one.";
22
+ case "INVALID_EMAIL":
23
+ return "Enter a valid email address.";
24
+ default:
25
+ if (error?.message?.toLowerCase().includes("too many requests"))
26
+ return "Too many sign-in attempts. Please wait a minute and try again.";
27
+ return error?.message || fallback;
28
+ }
29
+ }
@@ -0,0 +1,27 @@
1
+ import type { AssetFieldOptions } from "../../config/types.js";
2
+ /**
3
+ * Browse (and upload to) a repository directory and pick a file. Used by
4
+ * image/file fields and the editor's image button.
5
+ */
6
+ export declare function AssetPicker({ directory, accept, allowUpload, onPick, onClose, title,
7
+ /** Instead of committing immediately, hand uploads back to the caller (saved with the entry). */
8
+ deferUploads, }: {
9
+ directory: string;
10
+ accept?: string[];
11
+ allowUpload?: boolean;
12
+ onPick: (path: string, pending?: {
13
+ path: string;
14
+ base64: string;
15
+ previewUrl: string;
16
+ }) => void;
17
+ onClose: () => void;
18
+ title?: string;
19
+ deferUploads?: boolean;
20
+ }): import("react").JSX.Element;
21
+ export declare function safeName(name: string): string;
22
+ export type PendingUpload = {
23
+ path: string;
24
+ base64: string;
25
+ previewUrl: string;
26
+ };
27
+ export type AssetOptions = AssetFieldOptions;
@@ -0,0 +1,97 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { ChevronRight, File as FileIcon, Folder, Upload } from "lucide-react";
4
+ import { useRef, useState } from "react";
5
+ import { fileToBase64 } from "../api.js";
6
+ import { useVestry } from "../context.js";
7
+ import { cx, formatBytes } from "../lib/utils.js";
8
+ import { Alert, Button, Loading, Modal, Spinner, useToast } from "./ui.js";
9
+ /**
10
+ * Browse (and upload to) a repository directory and pick a file. Used by
11
+ * image/file fields and the editor's image button.
12
+ */
13
+ export function AssetPicker({ directory, accept, allowUpload = true, onPick, onClose, title = "Choose a file",
14
+ /** Instead of committing immediately, hand uploads back to the caller (saved with the entry). */
15
+ deferUploads, }) {
16
+ const { api, config } = useVestry();
17
+ const toast = useToast();
18
+ const qc = useQueryClient();
19
+ const [subdir, setSubdir] = useState("");
20
+ const [selected, setSelected] = useState(null);
21
+ const [uploading, setUploading] = useState(false);
22
+ const [over, setOver] = useState(false);
23
+ const fileInput = useRef(null);
24
+ // Find a configured container whose directory contains this one so the
25
+ // API authorises the listing; fall back to an ad-hoc listing by directory.
26
+ const containerKey = Object.entries(config.assets ?? {}).find(([, c]) => directory === c.directory || directory.startsWith(`${c.directory}/`))?.[0] ?? null;
27
+ const containerDir = containerKey
28
+ ? config.assets[containerKey].directory
29
+ : directory;
30
+ const baseSub = directory.slice(containerDir.length).replace(/^\//, "");
31
+ const dir = [baseSub, subdir].filter(Boolean).join("/");
32
+ const query = useQuery({
33
+ queryKey: ["assets", containerKey ?? directory, dir],
34
+ queryFn: () => api.listAssets(containerKey ?? "", dir),
35
+ enabled: containerKey !== null,
36
+ });
37
+ async function upload(files) {
38
+ const list = Array.from(files).filter((f) => !accept || accept.includes(f.name.split(".").pop().toLowerCase()));
39
+ if (!list.length) {
40
+ toast("error", "That file type isn't accepted here.");
41
+ return;
42
+ }
43
+ setUploading(true);
44
+ try {
45
+ if (deferUploads) {
46
+ const f = list[0];
47
+ const base64 = await fileToBase64(f);
48
+ const name = safeName(f.name);
49
+ const path = `${[containerDir, dir].filter(Boolean).join("/")}/${name}`;
50
+ onPick(path, { path, base64, previewUrl: URL.createObjectURL(f) });
51
+ return;
52
+ }
53
+ const payload = await Promise.all(list.map(async (f) => ({
54
+ name: f.name,
55
+ base64: await fileToBase64(f),
56
+ })));
57
+ const res = await api.uploadAssets(containerKey, dir, payload);
58
+ await qc.invalidateQueries({
59
+ queryKey: ["assets", containerKey ?? directory],
60
+ });
61
+ toast("success", `Uploaded ${res.paths.length} file${res.paths.length === 1 ? "" : "s"}`);
62
+ if (res.paths.length === 1) {
63
+ onPick(res.paths[0]);
64
+ }
65
+ }
66
+ catch (e) {
67
+ toast("error", e.message);
68
+ }
69
+ finally {
70
+ setUploading(false);
71
+ }
72
+ }
73
+ const crumbs = subdir ? subdir.split("/") : [];
74
+ return (_jsxs(Modal, { title: title, onClose: onClose, wide: true, footer: _jsxs(_Fragment, { children: [_jsx(Button, { onClick: onClose, children: "Cancel" }), _jsx(Button, { variant: "primary", disabled: !selected, onClick: () => selected && onPick(selected), children: "Choose" })] }), children: [!containerKey && (_jsxs(Alert, { kind: "warning", children: ["No asset container covers ", _jsx("code", { children: directory }), ". Add one under", " ", _jsx("code", { children: "assets" }), " in the Vestry config to browse it."] })), containerKey && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "v-row", style: { marginBottom: 14 }, children: [_jsxs("div", { className: "v-breadcrumbs", children: [_jsx("button", { type: "button", onClick: () => setSubdir(""), children: directory }), crumbs.map((c, i) => (_jsxs("span", { className: "v-row", style: { gap: 6 }, children: [_jsx(ChevronRight, { size: 14 }), _jsx("button", { type: "button", onClick: () => setSubdir(crumbs.slice(0, i + 1).join("/")), children: c })] }, i)))] }), _jsx("div", { className: "v-grow" }), allowUpload && (_jsxs(_Fragment, { children: [_jsx("input", { ref: fileInput, type: "file", hidden: true, multiple: !deferUploads, accept: accept?.map((a) => `.${a}`).join(","), onChange: (e) => e.target.files && upload(e.target.files) }), _jsxs(Button, { size: "sm", onClick: () => fileInput.current?.click(), disabled: uploading, children: [uploading ? _jsx(Spinner, {}) : _jsx(Upload, {}), " Upload"] })] }))] }), allowUpload && (_jsxs("div", { className: cx("v-dropzone", over && "is-over"), onDragOver: (e) => {
75
+ e.preventDefault();
76
+ setOver(true);
77
+ }, onDragLeave: () => setOver(false), onDrop: (e) => {
78
+ e.preventDefault();
79
+ setOver(false);
80
+ if (e.dataTransfer.files.length)
81
+ upload(e.dataTransfer.files);
82
+ }, children: ["Drop files here to upload", accept ? ` (${accept.join(", ")})` : ""] })), query.isLoading && _jsx(Loading, {}), query.error && (_jsx(Alert, { kind: "error", children: query.error.message })), query.data && (_jsxs(_Fragment, { children: [query.data.folders.length > 0 && (_jsx("div", { className: "v-asset-grid", style: { marginBottom: 14 }, children: query.data.folders.map((f) => (_jsxs("button", { type: "button", className: "v-folder", onClick: () => setSubdir([subdir, f].filter(Boolean).join("/")), children: [_jsx(Folder, {}), " ", f] }, f))) })), _jsx("div", { className: "v-asset-grid", children: query.data.items
83
+ .filter((i) => !accept || accept.includes(i.ext))
84
+ .map((item) => (_jsxs("button", { type: "button", className: cx("v-asset", selected === item.path && "is-selected"), onClick: () => setSelected(item.path), onDoubleClick: () => onPick(item.path), children: [_jsx("div", { className: "v-asset__preview", children: item.kind === "image" ? (_jsx("img", { src: api.fileUrl(item.path, item.sha), alt: "", loading: "lazy" })) : (_jsx(FileIcon, {})) }), _jsx("div", { className: "v-asset__name", title: item.name, children: item.name }), _jsx("div", { className: "v-asset__meta", children: formatBytes(item.size) })] }, item.path))) }), query.data.items.length === 0 &&
85
+ query.data.folders.length === 0 && (_jsx("p", { className: "v-muted", style: { textAlign: "center", padding: 24 }, children: "This folder is empty." }))] }))] }))] }));
86
+ }
87
+ export function safeName(name) {
88
+ const i = name.lastIndexOf(".");
89
+ const ext = i === -1 ? "" : name.slice(i + 1).toLowerCase();
90
+ const stem = (i === -1 ? name : name.slice(0, i))
91
+ .normalize("NFKD")
92
+ .replace(/[̀-ͯ]/g, "")
93
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
94
+ .replace(/^-+|-+$/g, "")
95
+ .toLowerCase();
96
+ return ext ? `${stem || "file"}.${ext}` : stem || "file";
97
+ }
@@ -0,0 +1,11 @@
1
+ import { type LucideProps } from "lucide-react";
2
+ type IconComponent = React.ComponentType<LucideProps>;
3
+ /**
4
+ * Icons available by name in config (`icon: "book-open"`). A curated set
5
+ * keeps the control panel bundle small; unknown names fall back to a file.
6
+ */
7
+ export declare const ICONS: Record<string, IconComponent>;
8
+ export declare function Icon({ name, ...props }: {
9
+ name?: string;
10
+ } & LucideProps): import("react").JSX.Element;
11
+ export {};
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { AlignLeft, AppWindow, BookOpen, Box, Clapperboard, Columns2, File, FileText, Files, Folder, GalleryHorizontal, Heading, Image, Images, LayoutGrid, Link, List, ListOrdered, Mail, Map, MapPin, Menu, MousePointerClick, Newspaper, Palette, Quote, Rows3, ScrollText, Settings2, Sparkles, Table, Tag, Users, Video, } from "lucide-react";
3
+ /**
4
+ * Icons available by name in config (`icon: "book-open"`). A curated set
5
+ * keeps the control panel bundle small; unknown names fall back to a file.
6
+ */
7
+ export const ICONS = {
8
+ "align-left": AlignLeft,
9
+ "app-window": AppWindow,
10
+ "book-open": BookOpen,
11
+ box: Box,
12
+ clapperboard: Clapperboard,
13
+ "columns-2": Columns2,
14
+ file: File,
15
+ "file-text": FileText,
16
+ files: Files,
17
+ folder: Folder,
18
+ "gallery-horizontal": GalleryHorizontal,
19
+ heading: Heading,
20
+ image: Image,
21
+ images: Images,
22
+ "layout-grid": LayoutGrid,
23
+ link: Link,
24
+ list: List,
25
+ "list-ordered": ListOrdered,
26
+ mail: Mail,
27
+ map: Map,
28
+ "map-pin": MapPin,
29
+ menu: Menu,
30
+ "mouse-pointer-click": MousePointerClick,
31
+ newspaper: Newspaper,
32
+ palette: Palette,
33
+ quote: Quote,
34
+ "rows-3": Rows3,
35
+ "scroll-text": ScrollText,
36
+ "settings-2": Settings2,
37
+ sparkles: Sparkles,
38
+ table: Table,
39
+ tag: Tag,
40
+ users: Users,
41
+ video: Video,
42
+ };
43
+ export function Icon({ name, ...props }) {
44
+ const Cmp = (name && ICONS[name]) || File;
45
+ return _jsx(Cmp, { strokeWidth: 1.9, ...props });
46
+ }
@@ -0,0 +1,15 @@
1
+ import { type ReactNode } from "react";
2
+ export interface Crumb {
3
+ label: string;
4
+ to?: string;
5
+ }
6
+ export declare function Shell({ children }: {
7
+ children: ReactNode;
8
+ }): import("react").JSX.Element;
9
+ export declare function TopBar({ title, crumbs, actions, status, }: {
10
+ title: ReactNode;
11
+ crumbs?: Crumb[];
12
+ actions?: ReactNode;
13
+ /** Shows a status dot before the title. */
14
+ status?: "published" | "draft" | null;
15
+ }): import("react").JSX.Element;
@@ -0,0 +1,87 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ExternalLink, GitBranch, LayoutDashboard, LogOut, Users, } from "lucide-react";
3
+ import { createContext, useContext, useEffect, useState, } from "react";
4
+ import { Link, NavLink, useNavigate } from "react-router";
5
+ import { useVestry } from "../context.js";
6
+ import { Icon } from "./Icon.js";
7
+ import { Menu } from "./ui.js";
8
+ import { initials } from "../lib/utils.js";
9
+ /** Lets pages publish their breadcrumb trail into the dark top bar. */
10
+ const CrumbsContext = createContext(() => { });
11
+ export function Shell({ children }) {
12
+ const { client, config, name, api, authClient, refresh } = useVestry();
13
+ const navigate = useNavigate();
14
+ const [crumbs, setCrumbs] = useState([]);
15
+ const collections = Object.entries(config.collections ?? {});
16
+ const singletons = Object.entries(config.singletons ?? {});
17
+ const assets = Object.entries(config.assets ?? {});
18
+ const groups = config.navigation ??
19
+ [
20
+ collections.length && {
21
+ label: "Collections",
22
+ items: collections.map(([k]) => `collections.${k}`),
23
+ },
24
+ singletons.length && {
25
+ label: "Globals",
26
+ items: singletons.map(([k]) => `singletons.${k}`),
27
+ },
28
+ assets.length && {
29
+ label: "Assets",
30
+ items: assets.map(([k]) => `assets.${k}`),
31
+ },
32
+ ].filter(Boolean);
33
+ const itemFor = (ref) => {
34
+ const [kind, key] = ref.split(".");
35
+ if (kind === "collections" && config.collections?.[key]) {
36
+ return {
37
+ to: `/collections/${key}`,
38
+ label: config.collections[key].label,
39
+ icon: config.collections[key].icon ?? "files",
40
+ };
41
+ }
42
+ if (kind === "singletons" && config.singletons?.[key]) {
43
+ return {
44
+ to: `/singletons/${key}`,
45
+ label: config.singletons[key].label,
46
+ icon: config.singletons[key].icon ?? "settings-2",
47
+ };
48
+ }
49
+ if (kind === "assets" && config.assets?.[key]) {
50
+ return {
51
+ to: `/assets/${key}`,
52
+ label: config.assets[key].label,
53
+ icon: config.assets[key].icon ?? "image",
54
+ };
55
+ }
56
+ return null;
57
+ };
58
+ return (_jsxs("div", { className: "v-app", children: [_jsxs("header", { className: "v-chrome", children: [_jsx(Link, { to: "/", className: "v-chrome__brand", title: name, children: config.logo ? (_jsx("img", { className: "v-chrome__logo", src: /^https?:\/\//.test(config.logo)
59
+ ? config.logo
60
+ : api.fileUrl(config.logo), alt: name })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "v-chrome__mark", children: "V" }), _jsx("span", { className: "v-chrome__name", children: name })] })) }), crumbs.map((c, i) => (_jsxs("span", { className: "v-chrome__crumb", children: [_jsx("span", { className: "v-chrome__sep", children: "/" }), c.to ? _jsx(Link, { to: c.to, children: c.label }) : _jsx("span", { children: c.label })] }, i))), _jsx("div", { className: "v-chrome__spacer" }), client.source.url ? (_jsxs("a", { className: "v-chrome__chip", href: client.source.url, target: "_blank", rel: "noreferrer", title: client.source.label, children: [_jsx(GitBranch, {}), _jsx("span", { children: client.source.branch ?? client.source.label })] })) : (_jsxs("span", { className: "v-chrome__chip", title: client.source.label, children: [_jsx(GitBranch, {}), _jsx("span", { children: client.mode === "local" ? "Local files" : client.source.label })] })), config.siteUrl && (_jsxs("a", { className: "v-chrome__chip", href: config.siteUrl, target: "_blank", rel: "noreferrer", title: "Open the site", children: [_jsx(ExternalLink, {}), _jsx("span", { children: "View site" })] })), client.user && (_jsx(Menu, { align: "right", trigger: ({ toggle }) => (_jsx("button", { type: "button", className: "v-chrome__avatar", onClick: toggle, title: client.user.name, children: initials(client.user.name || client.user.email || "?") })), children: (close) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "v-menu__heading", children: [client.user.name, client.user.email && (_jsx("span", { className: "v-menu__sub", children: client.user.email }))] }), _jsxs("div", { className: "v-menu__item v-menu__item--static", children: [_jsx(GitBranch, {}), _jsx("span", { children: client.mode === "local"
61
+ ? "Editing local files"
62
+ : client.source.label })] }), client.auth && (_jsxs("button", { className: "v-menu__item", onClick: async () => {
63
+ close();
64
+ await authClient.signOut();
65
+ await refresh();
66
+ navigate("/login");
67
+ }, children: [_jsx(LogOut, {}), _jsx("span", { children: "Sign out" })] }))] })) }))] }), _jsxs("div", { className: "v-shell", children: [_jsx("aside", { className: "v-sidebar", children: _jsxs("nav", { className: "v-nav", children: [_jsx("div", { className: "v-nav__group", children: _jsxs(NavLink, { to: "/", end: true, className: ({ isActive }) => `v-nav__link${isActive ? " is-active" : ""}`, children: [_jsx(LayoutDashboard, {}), " Dashboard"] }) }), groups.map((g) => (_jsxs("div", { className: "v-nav__group", children: [_jsx("h3", { className: "v-nav__heading", children: g.label }), g.items.map((ref) => {
68
+ const item = itemFor(ref);
69
+ if (!item)
70
+ return null;
71
+ return (_jsxs(NavLink, { to: item.to, className: ({ isActive }) => `v-nav__link${isActive ? " is-active" : ""}`, children: [_jsx(Icon, { name: item.icon }), " ", item.label] }, ref));
72
+ })] }, g.label))), client.auth && client.user?.role === "admin" && (_jsxs("div", { className: "v-nav__group", children: [_jsx("h3", { className: "v-nav__heading", children: "System" }), _jsxs(NavLink, { to: "/users", className: ({ isActive }) => `v-nav__link${isActive ? " is-active" : ""}`, children: [_jsx(Users, {}), " Users"] })] }))] }) }), _jsx("div", { className: "v-main", children: _jsx(CrumbsContext.Provider, { value: setCrumbs, children: children }) })] })] }));
73
+ }
74
+ export function TopBar({ title, crumbs, actions, status, }) {
75
+ const setCrumbs = useContext(CrumbsContext);
76
+ const key = JSON.stringify(crumbs ?? []);
77
+ useEffect(() => {
78
+ setCrumbs(crumbs ?? []);
79
+ return () => setCrumbs([]);
80
+ // eslint-disable-next-line react-hooks/exhaustive-deps
81
+ }, [key, setCrumbs]);
82
+ return (_jsxs("header", { className: "v-topbar", children: [_jsxs("h1", { className: "v-topbar__title", children: [status && (_jsx("span", { className: `v-status-dot v-status-dot--${status}`, title: status === "published" ? "Published" : "Draft" })), _jsx("span", { style: {
83
+ overflow: "hidden",
84
+ textOverflow: "ellipsis",
85
+ whiteSpace: "nowrap",
86
+ }, children: title })] }), actions && _jsx("div", { className: "v-topbar__actions", children: actions })] }));
87
+ }
@@ -0,0 +1,15 @@
1
+ import type { ComponentConfig } from "../../../config/types.js";
2
+ export interface EditorEnv {
3
+ /** Components that may be inserted, keyed by name. */
4
+ components: Record<string, ComponentConfig>;
5
+ /** Resolve an image/file reference in the document to a repository path. */
6
+ toRepoPath: (src: string) => string | null;
7
+ /** Repository path → reference to write into the document. */
8
+ toDocPath: (repoPath: string) => string;
9
+ /** Image directory for uploads from the body editor. */
10
+ imageDirectory?: string;
11
+ /** Resolve a document image src to a URL the browser can display. */
12
+ resolveImage?: (src: string) => string;
13
+ }
14
+ export declare const EditorEnvContext: import("react").Context<EditorEnv>;
15
+ export declare function useEditorEnv(): EditorEnv;
@@ -0,0 +1,9 @@
1
+ import { createContext, useContext } from "react";
2
+ export const EditorEnvContext = createContext({
3
+ components: {},
4
+ toRepoPath: () => null,
5
+ toDocPath: (p) => p,
6
+ });
7
+ export function useEditorEnv() {
8
+ return useContext(EditorEnvContext);
9
+ }
@@ -0,0 +1,4 @@
1
+ import { type ReactNodeViewProps } from "@tiptap/react";
2
+ export declare function ComponentNodeView({ node, updateAttributes, deleteNode, selected, }: ReactNodeViewProps): import("react").JSX.Element;
3
+ export declare function InlineComponentNodeView({ node, updateAttributes, deleteNode, }: ReactNodeViewProps): import("react").JSX.Element;
4
+ export declare function RawBlockNodeView({ node, updateAttributes, deleteNode, selected, }: ReactNodeViewProps): import("react").JSX.Element;