@natilon/cms-server 0.1.1 → 0.3.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.
@@ -1,3 +1,6 @@
1
+ import { createGitHubApi } from "./github-api.mjs";
2
+ import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue, commitMsg } from "./_shared.mjs";
3
+
1
4
  /**
2
5
  * GitHub Contents API adapter — serverless content backend.
3
6
  *
@@ -28,48 +31,10 @@
28
31
  * @returns {import('./types.mjs').ContentAdapter}
29
32
  */
30
33
  export function createGitHubContent({ token, owner, repo, branch, pagesDir, commitMessage }) {
31
- const BASE = `https://api.github.com/repos/${owner}/${repo}`;
34
+ const { apiGet, apiPut, apiDelete, apiPost, apiPatch, graphql } = createGitHubApi({ token, owner, repo });
32
35
  // SHA cache: avoid extra GET before every PUT. Invalidated on write.
33
36
  const shaCache = new Map();
34
37
 
35
- const headers = () => ({
36
- Authorization: `Bearer ${token}`,
37
- Accept: "application/vnd.github+json",
38
- "X-GitHub-Api-Version": "2022-11-28",
39
- "User-Agent": "natilon-cms",
40
- "Content-Type": "application/json",
41
- });
42
-
43
- async function apiGet(path) {
44
- const r = await fetch(`${BASE}${path}`, { headers: headers() });
45
- if (r.status === 404) return null;
46
- if (!r.ok) throw new Error(`GitHub API ${r.status} GET ${path}`);
47
- return r.json();
48
- }
49
-
50
- async function apiPut(path, body) {
51
- const r = await fetch(`${BASE}${path}`, {
52
- method: "PUT",
53
- headers: headers(),
54
- body: JSON.stringify(body),
55
- });
56
- if (!r.ok) {
57
- const err = await r.json().catch(() => ({}));
58
- throw new Error(`GitHub API ${r.status} PUT ${path}: ${err.message || ""}`);
59
- }
60
- return r.json();
61
- }
62
-
63
- async function apiDelete(path, body) {
64
- const r = await fetch(`${BASE}${path}`, {
65
- method: "DELETE",
66
- headers: headers(),
67
- body: JSON.stringify(body),
68
- });
69
- if (!r.ok) throw new Error(`GitHub API ${r.status} DELETE ${path}`);
70
- return r.json();
71
- }
72
-
73
38
  function contentPath(collection, file) {
74
39
  return `${pagesDir}/${collection}/${file}`;
75
40
  }
@@ -91,15 +56,6 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
91
56
  return Buffer.from(JSON.stringify(obj, null, 2), "utf-8").toString("base64");
92
57
  }
93
58
 
94
- function commitMsg() {
95
- const ts = new Date().toISOString().replace("T", " ").slice(0, 19);
96
- return commitMessage ? commitMessage(ts) : `Content updated ${ts}`;
97
- }
98
-
99
- function sanitize(p) {
100
- return String(p).replace(/[^a-zA-Z0-9._-]/g, "");
101
- }
102
-
103
59
  return {
104
60
  async listCollections() {
105
61
  const items = await apiGet(`/contents/${pagesDir}?ref=${branch}`);
@@ -114,17 +70,22 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
114
70
  );
115
71
  },
116
72
 
117
- async listPages(collection, sortConfig = null) {
73
+ async _listPagesRest(collection, sortConfig) {
118
74
  const items = await apiGet(`/contents/${pagesDir}/${collection}?ref=${branch}`);
119
75
  if (!Array.isArray(items)) return null;
120
76
  const jsonFiles = items.filter((i) => i.type === "file" && i.name.endsWith(".json"));
121
77
 
122
78
  const pages = await Promise.all(
123
79
  jsonFiles.map(async (item) => {
124
- const fileData = await apiGet(`/contents/${item.path}?ref=${branch}`);
125
- if (!fileData) return null;
126
- shaCache.set(item.path, fileData.sha);
127
- const data = decodeContent(fileData);
80
+ shaCache.set(item.path, item.sha);
81
+ const r = await fetch(item.download_url, {
82
+ headers: {
83
+ Authorization: `Bearer ${token}`,
84
+ "User-Agent": "natilon-cms",
85
+ },
86
+ });
87
+ if (!r.ok) return null;
88
+ const data = await r.json();
128
89
  return {
129
90
  id: data.id,
130
91
  slug: data.slug,
@@ -138,22 +99,83 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
138
99
  );
139
100
 
140
101
  const valid = pages.filter(Boolean);
141
- if (sortConfig) {
142
- const dir = sortConfig.direction === "desc" ? -1 : 1;
143
- valid.sort((a, b) => {
144
- const av = a.meta?.[sortConfig.field] ?? "";
145
- const bv = b.meta?.[sortConfig.field] ?? "";
146
- if (av < bv) return -1 * dir;
147
- if (av > bv) return 1 * dir;
148
- return 0;
149
- });
150
- } else {
151
- valid.sort((a, b) => a.title.localeCompare(b.title));
102
+ return sortPages(valid, sortConfig);
103
+ },
104
+
105
+ async listPages(collection, sortConfig = null) {
106
+ // Primary path: single GraphQL request fetches all blob contents at once.
107
+ // Falls back to the REST N+1 path if GraphQL throws.
108
+ const safeCollection = sanitize(collection);
109
+ const expression = `${branch}:${pagesDir}/${safeCollection}`;
110
+ const QUERY = `
111
+ query($owner: String!, $repo: String!, $expr: String!) {
112
+ repository(owner: $owner, name: $repo) {
113
+ object(expression: $expr) {
114
+ ... on Tree {
115
+ entries {
116
+ name
117
+ oid
118
+ type
119
+ object {
120
+ ... on Blob {
121
+ text
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ `;
130
+
131
+ let useRest = false;
132
+ let gqlData = null;
133
+ try {
134
+ gqlData = await graphql(QUERY, { owner, repo, expr: expression });
135
+ } catch {
136
+ useRest = true;
137
+ }
138
+
139
+ if (!useRest) {
140
+ const treeObj = gqlData?.repository?.object;
141
+ // collection directory doesn't exist
142
+ if (treeObj === null || treeObj === undefined) {
143
+ // If GraphQL returned data but object is null, collection is missing
144
+ if (gqlData?.repository !== undefined) return null;
145
+ // Otherwise fall back
146
+ useRest = true;
147
+ } else {
148
+ const entries = treeObj.entries || [];
149
+ const pages = [];
150
+ for (const entry of entries) {
151
+ if (entry.type !== "blob" || !entry.name.endsWith(".json")) continue;
152
+ const path = `${pagesDir}/${safeCollection}/${entry.name}`;
153
+ shaCache.set(path, entry.oid);
154
+ let data;
155
+ try {
156
+ data = JSON.parse(entry.object.text);
157
+ } catch {
158
+ continue;
159
+ }
160
+ pages.push({
161
+ id: data.id,
162
+ slug: data.slug,
163
+ lang: data.lang,
164
+ collection: data.collection,
165
+ title: data.meta?.title || data.meta?.name || entry.name,
166
+ file: entry.name,
167
+ meta: data.meta || {},
168
+ });
169
+ }
170
+ return sortPages(pages, sortConfig);
171
+ }
152
172
  }
153
- return valid;
173
+
174
+ return this._listPagesRest(collection, sortConfig);
154
175
  },
155
176
 
156
177
  async readPage(collection, file) {
178
+ if (!safeFileName(file)) return null;
157
179
  const path = contentPath(collection, sanitize(file));
158
180
  const data = await apiGet(`/contents/${path}?ref=${branch}`);
159
181
  if (!data || Array.isArray(data)) return null;
@@ -165,7 +187,7 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
165
187
  const path = contentPath(collection, sanitize(file));
166
188
  const sha = await getFileSha(path);
167
189
  const result = await apiPut(`/contents/${path}`, {
168
- message: commitMsg(),
190
+ message: commitMsg(commitMessage),
169
191
  content: encodeContent(data),
170
192
  branch,
171
193
  ...(sha ? { sha } : {}),
@@ -178,7 +200,7 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
178
200
  const fileName = `${sanitize(data.lang || "en")}-${sanitize(slug)}.json`;
179
201
  const path = contentPath(collection, fileName);
180
202
  const result = await apiPut(`/contents/${path}`, {
181
- message: commitMsg(),
203
+ message: commitMsg(commitMessage),
182
204
  content: encodeContent(data),
183
205
  branch,
184
206
  });
@@ -187,37 +209,20 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
187
209
  },
188
210
 
189
211
  async deletePage(collection, file) {
212
+ if (!safeFileName(file)) return null;
190
213
  const path = contentPath(collection, sanitize(file));
191
214
  const sha = await getFileSha(path);
192
215
  if (!sha) return false;
193
- await apiDelete(`/contents/${path}`, { message: commitMsg(), sha, branch });
216
+ await apiDelete(`/contents/${path}`, { message: commitMsg(commitMessage), sha, branch });
194
217
  shaCache.delete(path);
195
218
  return true;
196
219
  },
197
220
 
198
221
  async duplicatePage(collection, file) {
222
+ if (!safeFileName(file)) return null;
199
223
  const src = await this.readPage(collection, file);
200
224
  if (!src) return null;
201
- const lang = src.lang || "en";
202
- const baseSlug = (src.slug || "page").replace(/-copy(-\d+)?$/, "");
203
- const newSlug = `${baseSlug}-copy-${Math.floor(Date.now() / 1000)}`;
204
-
205
- function reid(blocks) {
206
- if (!Array.isArray(blocks)) return blocks;
207
- return blocks.map((b) => {
208
- const next = { ...b, id: "b-" + Math.random().toString(36).slice(2, 9) };
209
- if (Array.isArray(b.children)) next.children = b.children.map((col) => reid(col));
210
- return next;
211
- });
212
- }
213
-
214
- const data = {
215
- ...src,
216
- id: `${lang}/${newSlug}`,
217
- slug: newSlug,
218
- meta: { ...(src.meta || {}), title: `${src.meta?.title || newSlug} (Copy)` },
219
- blocks: reid(src.blocks || []),
220
- };
225
+ const { data, fileName } = buildDuplicateData(src);
221
226
  return this.createPage(collection, data);
222
227
  },
223
228
 
@@ -232,6 +237,7 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
232
237
  },
233
238
 
234
239
  async listHistory(collection, file) {
240
+ if (!safeFileName(file)) return null;
235
241
  const path = contentPath(collection, sanitize(file));
236
242
  const commits = await apiGet(`/commits?path=${encodeURIComponent(path)}&per_page=20&sha=${branch}`);
237
243
  if (!Array.isArray(commits)) return [];
@@ -245,6 +251,7 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
245
251
  },
246
252
 
247
253
  async restoreHistory(collection, file, commitSha) {
254
+ if (!safeFileName(file)) return null;
248
255
  const path = contentPath(collection, sanitize(file));
249
256
  const fileData = await apiGet(`/contents/${path}?ref=${commitSha}`);
250
257
  if (!fileData || Array.isArray(fileData)) return false;
@@ -253,20 +260,54 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
253
260
  return true;
254
261
  },
255
262
 
256
- async listScheduled() {
257
- const collections = await this.listCollections();
258
- const now = new Date();
259
- const due = [];
260
- for (const { name } of collections) {
261
- const pages = await this.listPages(name);
262
- for (const page of pages || []) {
263
- if (page.meta?.publishAt && new Date(page.meta.publishAt) <= now) {
264
- const data = await this.readPage(name, page.file);
265
- if (data?.meta?.publishAt) due.push({ collection: name, file: page.file, data });
266
- }
267
- }
263
+ async writeBatch(items, message) {
264
+ if (!items.length) return { ok: true, commitCount: 0 };
265
+
266
+ // 1. Get current ref → base commit SHA
267
+ const refData = await apiGet(`/git/ref/heads/${branch}`);
268
+ const baseCommitSha = refData.object.sha;
269
+
270
+ // 2. Get base commit base tree SHA
271
+ const baseCommit = await apiGet(`/git/commits/${baseCommitSha}`);
272
+ const baseTreeSha = baseCommit.tree.sha;
273
+
274
+ // 3. Create one blob per file
275
+ const treeItems = await Promise.all(
276
+ items.map(async ({ collection, file, data }) => {
277
+ const filePath = contentPath(sanitize(collection), sanitize(file));
278
+ const content = Buffer.from(JSON.stringify(data, null, 2), "utf-8").toString("base64");
279
+ const blob = await apiPost(`/git/blobs`, { content, encoding: "base64" });
280
+ return { path: filePath, mode: "100644", type: "blob", sha: blob.sha };
281
+ }),
282
+ );
283
+
284
+ // 4. Create tree
285
+ const newTree = await apiPost(`/git/trees`, {
286
+ base_tree: baseTreeSha,
287
+ tree: treeItems,
288
+ });
289
+
290
+ // 5. Create commit
291
+ const msg = message || commitMsg(commitMessage, "Batch content update");
292
+ const newCommit = await apiPost(`/git/commits`, {
293
+ message: msg,
294
+ tree: newTree.sha,
295
+ parents: [baseCommitSha],
296
+ });
297
+
298
+ // 6. Update ref
299
+ await apiPatch(`/git/refs/heads/${branch}`, { sha: newCommit.sha });
300
+
301
+ // 7. Invalidate shaCache for all written paths
302
+ for (const { collection, file } of items) {
303
+ shaCache.delete(contentPath(sanitize(collection), sanitize(file)));
268
304
  }
269
- return due;
305
+
306
+ return { ok: true, sha: newCommit.sha, commitCount: 1 };
307
+ },
308
+
309
+ async listScheduled() {
310
+ return listScheduledDue(this);
270
311
  },
271
312
  };
272
313
  }
@@ -1,9 +1,5 @@
1
1
  import crypto from "crypto";
2
2
 
3
- const GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize";
4
- const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
5
- const GITHUB_USER_URL = "https://api.github.com/user";
6
-
7
3
  /**
8
4
  * GitHub OAuth adapter — drop-in replacement for createBasicAuth.
9
5
  *
@@ -73,7 +69,26 @@ export function createGitHubOAuth({
73
69
  const parts = (tokenStr || "").split(".");
74
70
  if (parts.length !== 3) return null;
75
71
  const [header, payload, sig] = parts;
76
- if (sign(header, payload) !== sig) return null;
72
+
73
+ // Decode and validate header
74
+ try {
75
+ const headerData = JSON.parse(Buffer.from(header, "base64url").toString());
76
+ if (headerData.alg !== "HS256" || headerData.typ !== "JWT") return null;
77
+ } catch {
78
+ return null;
79
+ }
80
+
81
+ // Timing-safe signature comparison
82
+ const expectedSig = sign(header, payload);
83
+ const sigBuf = Buffer.from(sig, "base64url");
84
+ const expectedBuf = Buffer.from(expectedSig, "base64url");
85
+ if (sigBuf.length !== expectedBuf.length) return null;
86
+ try {
87
+ if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) return null;
88
+ } catch {
89
+ return null;
90
+ }
91
+
77
92
  try {
78
93
  const data = JSON.parse(Buffer.from(payload, "base64url").toString());
79
94
  if (data.exp < Math.floor(Date.now() / 1000)) return null;
@@ -98,57 +113,6 @@ export function createGitHubOAuth({
98
113
  };
99
114
  }
100
115
 
101
- // OAuth route handlers — mounted by server.mjs
102
- const oauthRoutes = {
103
- login(req, res) {
104
- const state = crypto.randomBytes(16).toString("hex");
105
- const callbackUrl = `${req.protocol}://${req.get("host")}/admin/oauth/callback`;
106
- const params = new URLSearchParams({
107
- client_id: clientId,
108
- redirect_uri: callbackUrl,
109
- scope: "read:user",
110
- state,
111
- });
112
- // state is stateless here — for production add a cookie/session store
113
- res.redirect(`${GITHUB_AUTH_URL}?${params.toString()}`);
114
- },
115
-
116
- async callback(req, res) {
117
- const { code } = req.query;
118
- if (!code) return res.status(400).send("Missing code");
119
-
120
- try {
121
- const callbackUrl = `${req.protocol}://${req.get("host")}/admin/oauth/callback`;
122
-
123
- // Exchange code for GitHub access token
124
- const tokenRes = await fetch(GITHUB_TOKEN_URL, {
125
- method: "POST",
126
- headers: { Accept: "application/json", "Content-Type": "application/json" },
127
- body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: callbackUrl }),
128
- });
129
- const tokenData = await tokenRes.json();
130
- if (!tokenData.access_token) {
131
- return res.status(401).send("GitHub OAuth failed: " + (tokenData.error_description || tokenData.error || "unknown"));
132
- }
133
-
134
- // Get GitHub user identity
135
- const userRes = await fetch(GITHUB_USER_URL, {
136
- headers: { Authorization: `Bearer ${tokenData.access_token}`, "User-Agent": "natilon-cms" },
137
- });
138
- const user = await userRes.json();
139
-
140
- if (allowedLogins.length > 0 && !allowedLogins.includes(user.login)) {
141
- return res.status(403).send(`GitHub user "${user.login}" is not authorised for this CMS.`);
142
- }
143
-
144
- const sessionToken = issueToken({ sub: user.login, type: "session", name: user.name || user.login });
145
- res.redirect(`/admin/?token=${encodeURIComponent(sessionToken)}`);
146
- } catch (err) {
147
- res.status(500).send("OAuth error: " + err.message);
148
- }
149
- },
150
- };
151
-
152
116
  function verify(authHeader) {
153
117
  if (!authHeader?.startsWith("Bearer ")) return null;
154
118
  const claims = verifyToken(authHeader.slice(7));
@@ -160,6 +124,24 @@ export function createGitHubOAuth({
160
124
  return issueToken({ sub: login, type: "session", name });
161
125
  }
162
126
 
127
+ function issueOAuthState() {
128
+ if (!jwtSecret) throw new Error("JWT_SECRET not set");
129
+ const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
130
+ const now = Math.floor(Date.now() / 1000);
131
+ const payload = Buffer.from(JSON.stringify({
132
+ type: "oauth-state",
133
+ nonce: crypto.randomBytes(16).toString("hex"),
134
+ iat: now,
135
+ exp: now + 600,
136
+ })).toString("base64url");
137
+ return `${header}.${payload}.${sign(header, payload)}`;
138
+ }
139
+
140
+ function verifyOAuthState(state) {
141
+ const claims = verifyToken(state);
142
+ return Boolean(claims && claims.type === "oauth-state");
143
+ }
144
+
163
145
  return {
164
146
  configured,
165
147
  middleware: middlewareWith(null),
@@ -169,6 +151,7 @@ export function createGitHubOAuth({
169
151
  },
170
152
  verify,
171
153
  issueSessionToken,
172
- oauthRoutes,
154
+ issueOAuthState,
155
+ verifyOAuthState,
173
156
  };
174
157
  }
@@ -0,0 +1,100 @@
1
+ import { createGitHubApi } from "./github-api.mjs";
2
+ import { sanitize, safeFileName, commitMsg } from "./_shared.mjs";
3
+
4
+ /**
5
+ * GitHub Contents API-backed block-template adapter.
6
+ *
7
+ * Templates are stored under `<templatesDir>/<slug>.json` on a configurable
8
+ * branch. Safe to run on any serverless runtime — no local filesystem.
9
+ *
10
+ * @param {Object} opts
11
+ * @param {string} opts.token GitHub PAT with repo scope
12
+ * @param {string} opts.owner
13
+ * @param {string} opts.repo
14
+ * @param {string} opts.branch
15
+ * @param {string} [opts.templatesDir=".cms-templates"]
16
+ * @param {(ts: string) => string} [opts.commitMessage]
17
+ * @returns {import('./types.mjs').TemplatesAdapter}
18
+ */
19
+ export function createGitHubTemplates({
20
+ token,
21
+ owner,
22
+ repo,
23
+ branch,
24
+ templatesDir = ".cms-templates",
25
+ commitMessage,
26
+ }) {
27
+ const { apiGet, apiPut, apiDelete } = createGitHubApi({ token, owner, repo });
28
+ const shaCache = new Map();
29
+
30
+ function filePath(slug) {
31
+ return `${templatesDir}/${sanitize(slug)}.json`;
32
+ }
33
+
34
+ function msg() {
35
+ return commitMsg(commitMessage, "Template updated");
36
+ }
37
+
38
+ return {
39
+ async list() {
40
+ const items = await apiGet(`/contents/${templatesDir}?ref=${branch}`);
41
+ if (!items || !Array.isArray(items)) return [];
42
+ const jsonFiles = items.filter((i) => i.type === "file" && i.name.endsWith(".json"));
43
+ return Promise.all(
44
+ jsonFiles.map(async (item) => {
45
+ shaCache.set(item.path, item.sha);
46
+ const r = await fetch(item.download_url, {
47
+ headers: { Authorization: `Bearer ${token}`, "User-Agent": "natilon-cms" },
48
+ });
49
+ if (!r.ok) return null;
50
+ const data = await r.json();
51
+ return {
52
+ name: data.name,
53
+ slug: item.name.replace(".json", ""),
54
+ blockCount: (data.blocks || []).length,
55
+ };
56
+ }),
57
+ ).then((rs) => rs.filter(Boolean));
58
+ },
59
+
60
+ async get(slug) {
61
+ if (!safeFileName(slug)) return null;
62
+ const path = filePath(slug);
63
+ const data = await apiGet(`/contents/${path}?ref=${branch}`);
64
+ if (!data || Array.isArray(data)) return null;
65
+ shaCache.set(path, data.sha);
66
+ return JSON.parse(Buffer.from(data.content, "base64").toString("utf-8"));
67
+ },
68
+
69
+ async put(slug, data) {
70
+ if (!safeFileName(slug)) return null;
71
+ const path = filePath(slug);
72
+ let sha = shaCache.get(path);
73
+ if (!sha) {
74
+ const existing = await apiGet(`/contents/${path}?ref=${branch}`);
75
+ if (existing && !Array.isArray(existing)) sha = existing.sha;
76
+ }
77
+ const result = await apiPut(`/contents/${path}`, {
78
+ message: msg(),
79
+ content: Buffer.from(JSON.stringify(data, null, 2), "utf-8").toString("base64"),
80
+ branch,
81
+ ...(sha ? { sha } : {}),
82
+ });
83
+ shaCache.set(path, result.content?.sha);
84
+ },
85
+
86
+ async delete(slug) {
87
+ if (!safeFileName(slug)) return null;
88
+ const path = filePath(slug);
89
+ let sha = shaCache.get(path);
90
+ if (!sha) {
91
+ const existing = await apiGet(`/contents/${path}?ref=${branch}`);
92
+ if (!existing || Array.isArray(existing)) return false;
93
+ sha = existing.sha;
94
+ }
95
+ await apiDelete(`/contents/${path}`, { message: msg(), sha, branch });
96
+ shaCache.delete(path);
97
+ return true;
98
+ },
99
+ };
100
+ }
@@ -1,5 +1,7 @@
1
1
  export { createFsJsonContent } from "./fs-json-content.mjs";
2
+ export { createFsTemplates } from "./fs-templates.mjs";
2
3
  export { createGitHubContent } from "./github-content.mjs";
4
+ export { createGitHubTemplates } from "./github-templates.mjs";
3
5
  export { createLocalAssetsMedia } from "./local-assets-media.mjs";
4
6
  export { createCdnProxyMedia } from "./cdn-proxy-media.mjs";
5
7
  export { createBasicAuth } from "./basic-auth.mjs";
@@ -1,5 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
+ import { sanitize } from "./_shared.mjs";
3
4
 
4
5
  const IMAGE_RE = /\.(png|jpg|jpeg|svg|webp|gif)$/i;
5
6
 
@@ -16,10 +17,6 @@ export function createLocalAssetsMedia({ rootDir, assetsDir }) {
16
17
  const ROOT = path.join(rootDir, assetsDir);
17
18
  const URL_PREFIX = `/${assetsDir.replace(/^\/+/, "")}`;
18
19
 
19
- function sanitize(p) {
20
- return String(p).replace(/[^a-zA-Z0-9._-]/g, "");
21
- }
22
-
23
20
  function listImages(dir, prefix) {
24
21
  const out = [];
25
22
  const entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -52,6 +52,7 @@
52
52
  * @property {(collection: string, file: string) => Promise<{file: string}|null>} duplicatePage
53
53
  * @property {() => Promise<PendingChanges>} pendingChanges
54
54
  * @property {() => Promise<PublishResult>} publish
55
+ * @property {(items: Array<{collection: string, file: string, data: object}>, message?: string) => Promise<{ok: boolean, sha?: string, commitCount: number}>} writeBatch
55
56
  */
56
57
 
57
58
  /**
@@ -75,4 +76,19 @@
75
76
  * @property {(opts?: {branch?: string, sha?: string}) => Promise<object>} getDeployStatus
76
77
  */
77
78
 
79
+ /**
80
+ * @typedef {Object} TemplateSummary
81
+ * @property {string} name
82
+ * @property {string} slug
83
+ * @property {number} blockCount
84
+ */
85
+
86
+ /**
87
+ * @typedef {Object} TemplatesAdapter
88
+ * @property {() => Promise<TemplateSummary[]>} list
89
+ * @property {(slug: string) => Promise<object|null>} get
90
+ * @property {(slug: string, data: object) => Promise<void>} put
91
+ * @property {(slug: string) => Promise<boolean>} delete
92
+ */
93
+
78
94
  export {};
@@ -7,18 +7,21 @@
7
7
  * Used automatically by createCmsServer when the consumer does not supply
8
8
  * their own publicConfig function.
9
9
  *
10
- * @param {Object} config Full cms.config object
11
- * @returns {Object} Safe subset for the browser
10
+ * @param {Object} config Full cms.config object
11
+ * @param {Object} [overrides={}] Extra fields merged in at the end (shallow)
12
+ * @returns {Object} Safe subset for the browser
12
13
  */
13
- export function defaultPublicConfig(config) {
14
- return {
15
- mountPath: config.mountPath,
16
- locales: config.locales,
17
- defaultLocale: config.defaultLocale,
18
- previewUrlPattern: config.previewUrlPattern,
19
- media: config.media,
20
- collections: config.collections,
21
- blocks: config.blocks,
22
- content: { provider: config.content?.provider || "fs" },
14
+ export function defaultPublicConfig(config, overrides = {}) {
15
+ const cfg = {
16
+ mountPath: config.mountPath,
17
+ locales: config.locales,
18
+ defaultLocale: config.defaultLocale,
19
+ previewUrlPattern: config.previewUrlPattern,
20
+ media: config.media,
21
+ collections: config.collections,
22
+ blocks: config.blocks,
23
+ content: { provider: config.content?.provider || "fs" },
24
+ auth: { provider: config.auth?.provider || "basic" },
23
25
  };
26
+ return Object.assign(cfg, overrides);
24
27
  }