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,161 @@
1
+ /**
2
+ * GitHub App authentication: a short-lived RS256 JWT signed with the app's
3
+ * private key is exchanged for an installation access token (valid for an
4
+ * hour), which the GitHub source then uses like any other token. WebCrypto
5
+ * only, so it works in workerd as well as Node.
6
+ */
7
+ import { base64Decode, base64Encode, SourceError } from "../source.js";
8
+ const tokenCache = new Map();
9
+ /**
10
+ * Returns a provider that mints (and caches) installation tokens for the
11
+ * app. Concurrent callers share one in-flight mint; a failed mint is
12
+ * forgotten so the next caller retries; tokens are renewed a minute early.
13
+ */
14
+ export function installationTokenProvider(creds) {
15
+ const cacheKey = `${creds.apiBase ?? ""}|${creds.appId}:${creds.installationId}`;
16
+ const provider = async () => {
17
+ const entry = tokenCache.get(cacheKey);
18
+ if (entry && entry.expiresAt - Date.now() > 60_000) {
19
+ return (await entry.promise).token;
20
+ }
21
+ const fresh = {
22
+ expiresAt: Infinity,
23
+ promise: mintInstallationToken(creds),
24
+ };
25
+ tokenCache.set(cacheKey, fresh);
26
+ try {
27
+ const minted = await fresh.promise;
28
+ fresh.expiresAt = minted.expiresAt;
29
+ return minted.token;
30
+ }
31
+ catch (e) {
32
+ if (tokenCache.get(cacheKey) === fresh)
33
+ tokenCache.delete(cacheKey);
34
+ throw e;
35
+ }
36
+ };
37
+ provider.invalidate = () => {
38
+ tokenCache.delete(cacheKey);
39
+ };
40
+ return provider;
41
+ }
42
+ export async function mintInstallationToken(creds) {
43
+ const apiBase = (creds.apiBase ?? "https://api.github.com").replace(/\/$/, "");
44
+ const jwt = await signAppJwt(creds.appId, creds.privateKey);
45
+ const res = await fetch(`${apiBase}/app/installations/${encodeURIComponent(creds.installationId)}/access_tokens`, {
46
+ method: "POST",
47
+ headers: {
48
+ Authorization: `Bearer ${jwt}`,
49
+ Accept: "application/vnd.github+json",
50
+ "X-GitHub-Api-Version": "2022-11-28",
51
+ "User-Agent": "vestry",
52
+ },
53
+ });
54
+ if (!res.ok) {
55
+ let detail = `${res.status}`;
56
+ try {
57
+ const body = (await res.json());
58
+ if (body.message)
59
+ detail = `${body.message} (${res.status})`;
60
+ }
61
+ catch {
62
+ /* ignore */
63
+ }
64
+ throw new SourceError(`Could not get a GitHub App installation token: ${detail}`, 502, "github_app");
65
+ }
66
+ const data = (await res.json());
67
+ return { token: data.token, expiresAt: Date.parse(data.expires_at) };
68
+ }
69
+ /** A GitHub App JWT: issued a minute ago (clock skew), valid for nine more. */
70
+ export async function signAppJwt(appId, privateKey) {
71
+ const now = Math.floor(Date.now() / 1000);
72
+ const header = base64Url(new TextEncoder().encode(JSON.stringify({ alg: "RS256", typ: "JWT" })));
73
+ const payload = base64Url(new TextEncoder().encode(JSON.stringify({ iat: now - 60, exp: now + 9 * 60, iss: appId })));
74
+ const key = await importRsaPrivateKey(privateKey);
75
+ const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(`${header}.${payload}`));
76
+ return `${header}.${payload}.${base64Url(new Uint8Array(signature))}`;
77
+ }
78
+ // ----------------------------------------------------------- key handling
79
+ export async function importRsaPrivateKey(pem) {
80
+ const der = pemToPkcs8(normalizePem(pem));
81
+ try {
82
+ return await crypto.subtle.importKey("pkcs8", der, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"]);
83
+ }
84
+ catch (e) {
85
+ throw new SourceError(`The GitHub App private key could not be imported: ${e instanceof Error ? e.message : String(e)}`, 500, "github_app_key");
86
+ }
87
+ }
88
+ /** Accepts a PEM, a PEM with literal `\n`, or a base64-encoded PEM (handy for env vars). */
89
+ function normalizePem(value) {
90
+ let pem = value.trim().replace(/\\n/g, "\n");
91
+ if (!pem.includes("-----BEGIN")) {
92
+ try {
93
+ pem = new TextDecoder().decode(base64Decode(pem)).trim();
94
+ }
95
+ catch {
96
+ /* fall through to the error below */
97
+ }
98
+ }
99
+ if (!pem.includes("-----BEGIN")) {
100
+ throw new SourceError("The GitHub App private key is not a PEM-encoded key.", 500, "github_app_key");
101
+ }
102
+ return pem;
103
+ }
104
+ /** PKCS#8 DER for the key, wrapping PKCS#1 (`RSA PRIVATE KEY`) when needed. */
105
+ function pemToPkcs8(pem) {
106
+ const match = pem.match(/-----BEGIN ([A-Z ]+)-----([\s\S]+?)-----END \1-----/);
107
+ if (!match) {
108
+ throw new SourceError("The GitHub App private key PEM is malformed.", 500, "github_app_key");
109
+ }
110
+ const label = match[1];
111
+ const body = base64Decode(match[2]);
112
+ if (label === "PRIVATE KEY")
113
+ return body;
114
+ if (label === "RSA PRIVATE KEY")
115
+ return wrapPkcs1(body);
116
+ throw new SourceError(`Unsupported private key type "${label}"; expected an RSA private key.`, 500, "github_app_key");
117
+ }
118
+ /**
119
+ * PrivateKeyInfo ::= SEQUENCE { version INTEGER 0, algorithm
120
+ * AlgorithmIdentifier { rsaEncryption, NULL }, privateKey OCTET STRING }
121
+ */
122
+ function wrapPkcs1(pkcs1) {
123
+ const version = new Uint8Array([0x02, 0x01, 0x00]);
124
+ const algorithm = new Uint8Array([
125
+ 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01,
126
+ 0x01, 0x05, 0x00,
127
+ ]);
128
+ const octetString = der(0x04, pkcs1);
129
+ return der(0x30, concat(version, algorithm, octetString));
130
+ }
131
+ function der(tag, content) {
132
+ const len = content.length;
133
+ let lengthBytes;
134
+ if (len < 0x80)
135
+ lengthBytes = [len];
136
+ else {
137
+ const bytes = [];
138
+ let n = len;
139
+ while (n > 0) {
140
+ bytes.unshift(n & 0xff);
141
+ n >>= 8;
142
+ }
143
+ lengthBytes = [0x80 | bytes.length, ...bytes];
144
+ }
145
+ return concat(new Uint8Array([tag, ...lengthBytes]), content);
146
+ }
147
+ function concat(...parts) {
148
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
149
+ let offset = 0;
150
+ for (const p of parts) {
151
+ out.set(p, offset);
152
+ offset += p.length;
153
+ }
154
+ return out;
155
+ }
156
+ function base64Url(bytes) {
157
+ return base64Encode(bytes)
158
+ .replace(/\+/g, "-")
159
+ .replace(/\//g, "_")
160
+ .replace(/=+$/, "");
161
+ }
@@ -0,0 +1,40 @@
1
+ import { type CommitInfo, type CommitOptions, type ContentSource, type FileChange, type FileData, type SourceInfo, type TreeEntry } from "../source.js";
2
+ import { type Cache } from "../cache.js";
3
+ import type { TokenProvider } from "./github-app.js";
4
+ export interface GitHubSourceOptions {
5
+ owner: string;
6
+ repo: string;
7
+ /** Branch to read from and commit to. Resolved to the default branch when omitted. */
8
+ branch?: string;
9
+ /** A token with contents read/write access, or a provider that mints one (e.g. a GitHub App installation token). */
10
+ token: string | TokenProvider | (() => Promise<string>);
11
+ cache?: Cache;
12
+ apiBase?: string;
13
+ /** How long (seconds) to trust a cached branch head before re-checking. */
14
+ headTtl?: number;
15
+ }
16
+ /**
17
+ * GitHub-backed source using the Git Data API.
18
+ *
19
+ * Reads: the branch head is cached briefly (and revalidated with ETags, which
20
+ * don't count against the rate limit); the recursive tree is cached per tree
21
+ * SHA; blobs are cached per blob SHA (immutable). After one of our own commits
22
+ * the head cache is updated in place so the next read sees the new state
23
+ * without waiting for the TTL.
24
+ *
25
+ * Writes: blobs → tree (on top of the base tree) → commit → ref update, which
26
+ * yields a single commit per save even when assets are uploaded alongside.
27
+ */
28
+ export declare class GitHubSource implements ContentSource {
29
+ #private;
30
+ private opts;
31
+ constructor(opts: GitHubSourceOptions);
32
+ info(): SourceInfo;
33
+ get repoPath(): string;
34
+ branch(): Promise<string>;
35
+ head(): Promise<string>;
36
+ list(directory: string): Promise<TreeEntry[]>;
37
+ readBlob(sha: string): Promise<Uint8Array>;
38
+ read(p: string): Promise<FileData | null>;
39
+ commit(changes: FileChange[], options: CommitOptions): Promise<CommitInfo>;
40
+ }
@@ -0,0 +1,278 @@
1
+ import { base64Decode, base64Encode, SourceError, toBytes, } from "../source.js";
2
+ import { sharedMemoryCache } from "../cache.js";
3
+ import { isSafeRepoPath, normalizePath } from "../paths.js";
4
+ /**
5
+ * GitHub-backed source using the Git Data API.
6
+ *
7
+ * Reads: the branch head is cached briefly (and revalidated with ETags, which
8
+ * don't count against the rate limit); the recursive tree is cached per tree
9
+ * SHA; blobs are cached per blob SHA (immutable). After one of our own commits
10
+ * the head cache is updated in place so the next read sees the new state
11
+ * without waiting for the TTL.
12
+ *
13
+ * Writes: blobs → tree (on top of the base tree) → commit → ref update, which
14
+ * yields a single commit per save even when assets are uploaded alongside.
15
+ */
16
+ export class GitHubSource {
17
+ opts;
18
+ #branch;
19
+ #cache;
20
+ #headTtl;
21
+ #apiBase;
22
+ constructor(opts) {
23
+ this.opts = opts;
24
+ this.#branch = opts.branch;
25
+ this.#cache = opts.cache ?? sharedMemoryCache();
26
+ this.#headTtl = opts.headTtl ?? 20;
27
+ this.#apiBase = (opts.apiBase ?? "https://api.github.com").replace(/\/$/, "");
28
+ }
29
+ info() {
30
+ return {
31
+ kind: "github",
32
+ label: `${this.opts.owner}/${this.opts.repo}${this.#branch ? `@${this.#branch}` : ""}`,
33
+ branch: this.#branch,
34
+ url: `https://github.com/${this.opts.owner}/${this.opts.repo}`,
35
+ };
36
+ }
37
+ get repoPath() {
38
+ return `${this.opts.owner}/${this.opts.repo}`;
39
+ }
40
+ async #token() {
41
+ return typeof this.opts.token === "function"
42
+ ? this.opts.token()
43
+ : this.opts.token;
44
+ }
45
+ async #request(path, init = {}) {
46
+ const headers = new Headers(init.headers);
47
+ headers.set("Authorization", `Bearer ${await this.#token()}`);
48
+ headers.set("Accept", init.raw
49
+ ? "application/vnd.github.raw+json"
50
+ : "application/vnd.github+json");
51
+ headers.set("X-GitHub-Api-Version", "2022-11-28");
52
+ headers.set("User-Agent", "vestry");
53
+ if (typeof init.body === "string")
54
+ headers.set("Content-Type", "application/json");
55
+ if (init.etag)
56
+ headers.set("If-None-Match", init.etag);
57
+ let res = await fetch(`${this.#apiBase}${path}`, { ...init, headers });
58
+ if (res.status === 401 &&
59
+ typeof this.opts.token === "function" &&
60
+ "invalidate" in this.opts.token) {
61
+ // A cached installation token GitHub no longer accepts (app reinstalled,
62
+ // key rotated): mint a fresh one and retry once.
63
+ this.opts.token.invalidate();
64
+ headers.set("Authorization", `Bearer ${await this.#token()}`);
65
+ res = await fetch(`${this.#apiBase}${path}`, { ...init, headers });
66
+ }
67
+ if (res.status === 304) {
68
+ return { status: 304, data: undefined, etag: init.etag };
69
+ }
70
+ if (!res.ok) {
71
+ let message = `GitHub API ${res.status} for ${path}`;
72
+ try {
73
+ const body = (await res.json());
74
+ if (body.message)
75
+ message = `${body.message} (${res.status} ${path})`;
76
+ }
77
+ catch {
78
+ /* ignore */
79
+ }
80
+ const code = res.status === 401
81
+ ? "unauthorized"
82
+ : res.status === 403
83
+ ? "forbidden"
84
+ : res.status === 404
85
+ ? "not_found"
86
+ : res.status === 409
87
+ ? "conflict"
88
+ : "github_error";
89
+ throw new SourceError(message, res.status === 404 ? 404 : res.status, code);
90
+ }
91
+ const data = (init.raw ? new Uint8Array(await res.arrayBuffer()) : await res.json());
92
+ return {
93
+ status: res.status,
94
+ data,
95
+ etag: res.headers.get("etag") ?? undefined,
96
+ };
97
+ }
98
+ async branch() {
99
+ if (this.#branch)
100
+ return this.#branch;
101
+ const key = `gh:${this.repoPath}:default-branch`;
102
+ const cached = await this.#cache.get(key);
103
+ if (cached)
104
+ return (this.#branch = cached);
105
+ const { data } = await this.#request(`/repos/${this.repoPath}`);
106
+ this.#branch = data.default_branch;
107
+ await this.#cache.set(key, this.#branch, 3600);
108
+ return this.#branch;
109
+ }
110
+ #headKey(branch) {
111
+ return `gh:${this.repoPath}:head:${branch}`;
112
+ }
113
+ async #resolveHead() {
114
+ const branch = await this.branch();
115
+ const key = this.#headKey(branch);
116
+ const fresh = await this.#cache.get(key);
117
+ if (fresh && Date.now() - fresh.checked < this.#headTtl * 1000)
118
+ return fresh;
119
+ const { status, data, etag } = await this.#request(`/repos/${this.repoPath}/git/ref/heads/${encodeURIComponent(branch)}`, {
120
+ etag: fresh?.etag,
121
+ });
122
+ if (status === 304 && fresh) {
123
+ const bumped = { ...fresh, checked: Date.now() };
124
+ await this.#cache.set(key, bumped, 3600);
125
+ return bumped;
126
+ }
127
+ const sha = data.object.sha;
128
+ const treeSha = await this.#treeShaForCommit(sha);
129
+ const head = { sha, treeSha, etag, checked: Date.now() };
130
+ await this.#cache.set(key, head, 3600);
131
+ return head;
132
+ }
133
+ async #treeShaForCommit(commitSha) {
134
+ const key = `gh:${this.repoPath}:commit:${commitSha}`;
135
+ const cached = await this.#cache.get(key);
136
+ if (cached)
137
+ return cached;
138
+ const { data } = await this.#request(`/repos/${this.repoPath}/git/commits/${commitSha}`);
139
+ await this.#cache.set(key, data.tree.sha);
140
+ return data.tree.sha;
141
+ }
142
+ async head() {
143
+ return (await this.#resolveHead()).sha;
144
+ }
145
+ async #tree(treeSha) {
146
+ const key = `gh:${this.repoPath}:tree:${treeSha}`;
147
+ const cached = await this.#cache.get(key);
148
+ if (cached)
149
+ return cached;
150
+ const { data } = await this.#request(`/repos/${this.repoPath}/git/trees/${treeSha}?recursive=1`);
151
+ if (data.truncated) {
152
+ throw new SourceError("Repository tree is too large for the GitHub API to return in one request", 500, "tree_truncated");
153
+ }
154
+ const entries = data.tree
155
+ .filter((e) => e.type === "blob")
156
+ .map((e) => ({ path: e.path, sha: e.sha, size: e.size }));
157
+ await this.#cache.set(key, entries);
158
+ return entries;
159
+ }
160
+ async list(directory) {
161
+ const head = await this.#resolveHead();
162
+ const dir = normalizePath(directory);
163
+ const all = await this.#tree(head.treeSha);
164
+ if (!dir)
165
+ return all;
166
+ const prefix = `${dir}/`;
167
+ return all.filter((e) => e.path.startsWith(prefix));
168
+ }
169
+ async #findBlob(p) {
170
+ const head = await this.#resolveHead();
171
+ const all = await this.#tree(head.treeSha);
172
+ const n = normalizePath(p);
173
+ return all.find((e) => e.path === n);
174
+ }
175
+ async readBlob(sha) {
176
+ const key = `gh:${this.repoPath}:blob:${sha}`;
177
+ const cached = await this.#cache.get(key);
178
+ if (cached !== undefined)
179
+ return base64Decode(cached);
180
+ const { data } = await this.#request(`/repos/${this.repoPath}/git/blobs/${sha}`, { raw: true });
181
+ // Cache up to ~2 MB blobs; bigger files are re-fetched on demand.
182
+ if (data.byteLength <= 2 * 1024 * 1024) {
183
+ await this.#cache.set(key, base64Encode(data));
184
+ }
185
+ return data;
186
+ }
187
+ async read(p) {
188
+ if (!isSafeRepoPath(p))
189
+ throw new SourceError(`Invalid path "${p}"`, 400, "bad_path");
190
+ const entry = await this.#findBlob(p);
191
+ if (!entry)
192
+ return null;
193
+ return { bytes: await this.readBlob(entry.sha), sha: entry.sha };
194
+ }
195
+ async commit(changes, options) {
196
+ const branch = await this.branch();
197
+ const head = await this.#resolveHead();
198
+ if (options.expectedHead && options.expectedHead !== head.sha) {
199
+ // Someone else pushed; the caller decides whether that's a conflict for
200
+ // the specific files it touched. We still build on the latest head.
201
+ }
202
+ const tree = [];
203
+ for (const change of changes) {
204
+ if (!isSafeRepoPath(change.path)) {
205
+ throw new SourceError(`Invalid path "${change.path}"`, 400, "bad_path");
206
+ }
207
+ if ("delete" in change) {
208
+ tree.push({
209
+ path: normalizePath(change.path),
210
+ mode: "100644",
211
+ type: "blob",
212
+ sha: null,
213
+ });
214
+ continue;
215
+ }
216
+ const bytes = toBytes(change.content);
217
+ const { data: blob } = await this.#request(`/repos/${this.repoPath}/git/blobs`, {
218
+ method: "POST",
219
+ body: JSON.stringify({
220
+ content: base64Encode(bytes),
221
+ encoding: "base64",
222
+ }),
223
+ });
224
+ await this.#cache.set(`gh:${this.repoPath}:blob:${blob.sha}`, base64Encode(bytes));
225
+ tree.push({
226
+ path: normalizePath(change.path),
227
+ mode: "100644",
228
+ type: "blob",
229
+ sha: blob.sha,
230
+ });
231
+ }
232
+ const { data: newTree } = await this.#request(`/repos/${this.repoPath}/git/trees`, {
233
+ method: "POST",
234
+ body: JSON.stringify({ base_tree: head.treeSha, tree }),
235
+ });
236
+ const { data: commit } = await this.#request(`/repos/${this.repoPath}/git/commits`, {
237
+ method: "POST",
238
+ body: JSON.stringify({
239
+ // Author and committer default to the token's identity (the GitHub
240
+ // App's bot). Setting `author` would make GitHub copy it into the
241
+ // committer too, so the editor is credited as a co-author instead.
242
+ message: withCoAuthor(options.message, options.author),
243
+ tree: newTree.sha,
244
+ parents: [head.sha],
245
+ }),
246
+ });
247
+ try {
248
+ await this.#request(`/repos/${this.repoPath}/git/refs/heads/${encodeURIComponent(branch)}`, {
249
+ method: "PATCH",
250
+ body: JSON.stringify({ sha: commit.sha, force: false }),
251
+ });
252
+ }
253
+ catch (e) {
254
+ if (e instanceof SourceError && (e.status === 422 || e.status === 409)) {
255
+ // Not a fast-forward: the branch moved while we were committing.
256
+ await this.#cache.delete(this.#headKey(branch));
257
+ throw new SourceError("The branch changed while saving. Reload and try again.", 409, "conflict");
258
+ }
259
+ throw e;
260
+ }
261
+ const newHead = {
262
+ sha: commit.sha,
263
+ treeSha: newTree.sha,
264
+ etag: undefined,
265
+ checked: Date.now(),
266
+ };
267
+ await this.#cache.set(this.#headKey(branch), newHead, 3600);
268
+ await this.#cache.set(`gh:${this.repoPath}:commit:${commit.sha}`, newTree.sha);
269
+ return { sha: commit.sha, url: commit.html_url };
270
+ }
271
+ }
272
+ /** Appends a `Co-authored-by` trailer, which GitHub shows as a second author. */
273
+ function withCoAuthor(message, author) {
274
+ if (!author?.email)
275
+ return message;
276
+ const trailer = `Co-authored-by: ${author.name || author.email} <${author.email}>`;
277
+ return `${message.trimEnd()}\n\n${trailer}`;
278
+ }
@@ -0,0 +1,16 @@
1
+ import { type CommitInfo, type CommitOptions, type ContentSource, type FileChange, type FileData, type SourceInfo, type TreeEntry } from "../source.js";
2
+ /**
3
+ * Reads and writes the project directory directly. Used by `astro dev` so
4
+ * editing feels instant and shows up in HMR; the developer commits with git
5
+ * as usual.
6
+ */
7
+ export declare class LocalSource implements ContentSource {
8
+ #private;
9
+ private root;
10
+ constructor(root: string);
11
+ info(): SourceInfo;
12
+ head(): Promise<string>;
13
+ list(directory: string): Promise<TreeEntry[]>;
14
+ read(p: string): Promise<FileData | null>;
15
+ commit(changes: FileChange[], _options: CommitOptions): Promise<CommitInfo>;
16
+ }
@@ -0,0 +1,98 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { gitBlobSha, toBytes, SourceError, } from "../source.js";
4
+ import { isSafeRepoPath, normalizePath } from "../paths.js";
5
+ const IGNORED_DIRS = new Set([
6
+ "node_modules",
7
+ ".git",
8
+ "dist",
9
+ ".astro",
10
+ ".wrangler",
11
+ ]);
12
+ /**
13
+ * Reads and writes the project directory directly. Used by `astro dev` so
14
+ * editing feels instant and shows up in HMR; the developer commits with git
15
+ * as usual.
16
+ */
17
+ export class LocalSource {
18
+ root;
19
+ constructor(root) {
20
+ this.root = root;
21
+ }
22
+ info() {
23
+ return { kind: "local", label: this.root };
24
+ }
25
+ async head() {
26
+ // Not a commit, but stable within a process so the UI can use it as a cache key.
27
+ return "local";
28
+ }
29
+ #abs(p) {
30
+ const n = normalizePath(p);
31
+ if (!isSafeRepoPath(n))
32
+ throw new SourceError(`Invalid path "${p}"`, 400, "bad_path");
33
+ const abs = path.resolve(this.root, n);
34
+ if (!abs.startsWith(path.resolve(this.root) + path.sep) &&
35
+ abs !== path.resolve(this.root)) {
36
+ throw new SourceError(`Path escapes project root: "${p}"`, 400, "bad_path");
37
+ }
38
+ return abs;
39
+ }
40
+ async list(directory) {
41
+ const base = this.#abs(directory);
42
+ const out = [];
43
+ const walk = async (dir, rel) => {
44
+ let entries;
45
+ try {
46
+ entries = await fs.readdir(dir, { withFileTypes: true });
47
+ }
48
+ catch (e) {
49
+ if (e.code === "ENOENT")
50
+ return;
51
+ throw e;
52
+ }
53
+ for (const entry of entries) {
54
+ if (IGNORED_DIRS.has(entry.name))
55
+ continue;
56
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
57
+ if (entry.isDirectory()) {
58
+ await walk(path.join(dir, entry.name), relPath);
59
+ }
60
+ else if (entry.isFile()) {
61
+ const abs = path.join(dir, entry.name);
62
+ const bytes = await fs.readFile(abs);
63
+ out.push({
64
+ path: normalizePath(`${directory}/${relPath}`),
65
+ sha: await gitBlobSha(new Uint8Array(bytes)),
66
+ size: bytes.byteLength,
67
+ });
68
+ }
69
+ }
70
+ };
71
+ await walk(base, "");
72
+ return out;
73
+ }
74
+ async read(p) {
75
+ try {
76
+ const buf = await fs.readFile(this.#abs(p));
77
+ const bytes = new Uint8Array(buf);
78
+ return { bytes, sha: await gitBlobSha(bytes) };
79
+ }
80
+ catch (e) {
81
+ if (e.code === "ENOENT")
82
+ return null;
83
+ throw e;
84
+ }
85
+ }
86
+ async commit(changes, _options) {
87
+ for (const change of changes) {
88
+ const abs = this.#abs(change.path);
89
+ if ("delete" in change) {
90
+ await fs.rm(abs, { force: true });
91
+ continue;
92
+ }
93
+ await fs.mkdir(path.dirname(abs), { recursive: true });
94
+ await fs.writeFile(abs, toBytes(change.content));
95
+ }
96
+ return { sha: "local" };
97
+ }
98
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Value transforms shared by the server and the control panel: asset path
3
+ * normalisation and automatic fields. Pure functions, no I/O.
4
+ */
5
+ import type { Field, Schema } from "../config/types.js";
6
+ export declare function walkValues(schema: Schema, data: Record<string, unknown>, fn: (field: Field, value: unknown) => unknown): Record<string, unknown>;
7
+ export type AssetField = Extract<Field, {
8
+ type: "image" | "file";
9
+ }>;
10
+ export type AssetLike = {
11
+ directory: string;
12
+ storeAs?: AssetField["storeAs"];
13
+ };
14
+ export declare function defaultStorage(field: AssetLike): NonNullable<AssetField["storeAs"]>;
15
+ /** Stored reference (relative path, bare filename, public URL) → repository path. */
16
+ export declare function assetToRepoPath(field: AssetLike, value: string, filePath: string): string;
17
+ /** Repository path → the reference written into the content file. */
18
+ export declare function repoPathToAsset(field: AssetLike, repoPath: string, filePath: string): string;
19
+ /** Convert stored values to the editor's canonical representation (repo paths, ISO strings). */
20
+ export declare function toEditorValues(schema: Schema, data: Record<string, unknown>, filePath: string): Record<string, unknown>;
21
+ export declare function toStoredValues(schema: Schema, data: Record<string, unknown>, filePath: string): Record<string, unknown>;
22
+ export declare function applyAutoFields(schema: Schema, data: Record<string, unknown>, now?: Date): Record<string, unknown>;