@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.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 (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. package/src/ui/MobileCarousel.tsx +289 -0
@@ -9,14 +9,19 @@
9
9
 
10
10
  import {
11
11
  EMPTY_BUNDLE,
12
+ MISSING_CREATED_ID,
12
13
  mergeBundles,
13
14
  normalizeHostname,
14
15
  selectProfiles,
15
16
  type InjectionBundle,
16
17
  type InjectionProfile,
17
18
  } from "../tracking/injections";
19
+ import { resolveRequestLocale } from "../site/locale-domains";
18
20
  import { actorEmail, assertRole, type PlatformAuthContext } from "./guards";
19
21
 
22
+ /** Re-exported so hosts that already import it from here keep working. */
23
+ export { MISSING_CREATED_ID };
24
+
20
25
  /* eslint-disable @typescript-eslint/no-explicit-any */
21
26
  /** Structural subset of a Supabase-shaped client used by this core. */
22
27
  export type InjectionsClient = { from: (table: string) => any };
@@ -89,11 +94,76 @@ function toRow(input: InjectionInput) {
89
94
  };
90
95
  }
91
96
 
97
+ /** Where a failure gets reported. Defaults to `console.error`. */
98
+ export type InjectionsLogger = (message: string, error: unknown) => void;
99
+
100
+ const defaultLogger: InjectionsLogger = (message, error) => {
101
+ console.error(message, error);
102
+ };
103
+
104
+ /**
105
+ * Report a failure without letting the report become one.
106
+ *
107
+ * Everywhere this is called the outcome has already been decided: the row is
108
+ * written, or the request is about to be served an empty bundle so the page
109
+ * still renders. A logger that throws would undo that decision at the very
110
+ * last step — an injected reporter whose transport is down turns a completed
111
+ * create into a rejection the screen reads as "press Save again", and a
112
+ * degraded render into a 500 on a public page. The report is worth less than
113
+ * either.
114
+ *
115
+ * So the supplied logger is tried, then `console.error` on its own, and then
116
+ * the reporting failure is dropped, because at that point there is nothing
117
+ * left that can be told.
118
+ */
119
+ function reportBestEffort(logger: InjectionsLogger, message: string, error: unknown): void {
120
+ try {
121
+ logger(message, error);
122
+ } catch (loggerError) {
123
+ try {
124
+ console.error(message, error);
125
+ console.error("[cms] injection logger failed", loggerError);
126
+ } catch {
127
+ // The console is gone too. Nothing here is worth the caller's outcome.
128
+ }
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Write the audit entry, and let the mutation stand if that write fails.
134
+ *
135
+ * By the time this runs the row is already inserted, updated or deleted. A
136
+ * rejection here would hand the caller an ordinary failure for a change that
137
+ * did happen, and the obvious response to an ordinary failure — press Save
138
+ * again — inserts a second profile. Auditing is best-effort across this
139
+ * package for exactly that reason: the trail is worth less than the
140
+ * correctness of what it describes.
141
+ *
142
+ * The failure is reported rather than swallowed, so a host that has lost its
143
+ * audit table finds out from its logs instead of from the empty table.
144
+ */
145
+ async function auditBestEffort(
146
+ audit: AuditWriter,
147
+ entry: Parameters<AuditWriter>[0],
148
+ logger: InjectionsLogger,
149
+ ): Promise<void> {
150
+ try {
151
+ await audit(entry);
152
+ } catch (error) {
153
+ reportBestEffort(
154
+ logger,
155
+ `[cms] injection audit failed for ${entry.action} ${entry.entityId}`,
156
+ error,
157
+ );
158
+ }
159
+ }
160
+
92
161
  /** Admin-only create/update of one profile, with an audit entry. */
93
162
  export async function saveInjectionCore(
94
163
  context: PlatformAuthContext & { supabase: InjectionsClient },
95
164
  input: InjectionInput,
96
165
  audit: AuditWriter,
166
+ logger: InjectionsLogger = defaultLogger,
97
167
  ): Promise<{ id: string }> {
98
168
  await assertRole(context, "admin");
99
169
  const row = toRow(input);
@@ -105,14 +175,19 @@ export async function saveInjectionCore(
105
175
  .update(row)
106
176
  .eq("id", input.id);
107
177
  if (error) throw new Error(error.message);
108
- await audit({
109
- actorId: context.userId,
110
- actorEmail: actorEmail(context),
111
- action: "injection.update",
112
- entity: "site_injections",
113
- entityId: input.id,
114
- meta: { name: input.name, scope, enabled: input.enabled },
115
- });
178
+ await auditBestEffort(
179
+ audit,
180
+ {
181
+ actorId: context.userId,
182
+ actorEmail: actorEmail(context),
183
+ action: "injection.update",
184
+ entity: "site_injections",
185
+ entityId: input.id,
186
+ meta: { name: input.name, scope, enabled: input.enabled },
187
+ },
188
+ logger,
189
+ );
190
+ invalidateInjectionCache();
116
191
  return { id: input.id };
117
192
  }
118
193
 
@@ -122,15 +197,35 @@ export async function saveInjectionCore(
122
197
  .select("id")
123
198
  .single();
124
199
  if (error) throw new Error(error.message);
125
- const id = String(inserted?.id ?? "");
126
- await audit({
127
- actorId: context.userId,
128
- actorEmail: actorEmail(context),
129
- action: "injection.create",
130
- entity: "site_injections",
131
- entityId: id || null,
132
- meta: { name: input.name, scope },
133
- });
200
+ const id = String(inserted?.id ?? "").trim();
201
+ /*
202
+ * No id means the insert cannot be reported as a success.
203
+ *
204
+ * Whether the row exists is now unknown, and everything below this line
205
+ * states that it does: an audit entry naming no entity, a cache invalidation
206
+ * announcing a change, and a `{ id: "" }` the caller reads as "created".
207
+ * Throwing before any of them leaves one indeterminate insert to reconcile
208
+ * rather than a trail of confident records about a row nobody can find.
209
+ */
210
+ if (!id) throw new Error(MISSING_CREATED_ID);
211
+ /*
212
+ * The row exists and its id is in hand. Nothing after this point may take
213
+ * that away from the caller: a create reported as failed is a create the
214
+ * administrator will send again, and the second one is a duplicate.
215
+ */
216
+ await auditBestEffort(
217
+ audit,
218
+ {
219
+ actorId: context.userId,
220
+ actorEmail: actorEmail(context),
221
+ action: "injection.create",
222
+ entity: "site_injections",
223
+ entityId: id,
224
+ meta: { name: input.name, scope },
225
+ },
226
+ logger,
227
+ );
228
+ invalidateInjectionCache();
134
229
  return { id };
135
230
  }
136
231
 
@@ -139,29 +234,76 @@ export async function deleteInjectionCore(
139
234
  context: PlatformAuthContext & { supabase: InjectionsClient },
140
235
  id: string,
141
236
  audit: AuditWriter,
237
+ logger: InjectionsLogger = defaultLogger,
142
238
  ): Promise<{ ok: true }> {
143
239
  await assertRole(context, "admin");
144
240
  const { error } = await context.supabase.from("site_injections").delete().eq("id", id);
145
241
  if (error) throw new Error(error.message);
146
- await audit({
147
- actorId: context.userId,
148
- actorEmail: actorEmail(context),
149
- action: "injection.delete",
150
- entity: "site_injections",
151
- entityId: id,
152
- meta: {},
153
- });
242
+ await auditBestEffort(
243
+ audit,
244
+ {
245
+ actorId: context.userId,
246
+ actorEmail: actorEmail(context),
247
+ action: "injection.delete",
248
+ entity: "site_injections",
249
+ entityId: id,
250
+ meta: {},
251
+ },
252
+ logger,
253
+ );
254
+ invalidateInjectionCache();
154
255
  return { ok: true };
155
256
  }
156
257
 
157
258
  /* ---------------- Request-time resolution ---------------- */
158
259
 
159
260
  const CACHE_TTL_MS = 30_000;
261
+ /**
262
+ * Bound on distinct hosts held at once. The cache key is a request header, so
263
+ * an unbounded map is something a stranger can grow; past the cap the oldest
264
+ * entries go, which for a real site is never reached.
265
+ */
266
+ const CACHE_MAX_ENTRIES = 64;
160
267
  const cache = new Map<string, { at: number; bundle: InjectionBundle }>();
161
268
 
269
+ /** Drop cached bundles — all of them, or just one host's. */
270
+ export function invalidateInjectionCache(host?: string): void {
271
+ if (host === undefined) {
272
+ cache.clear();
273
+ return;
274
+ }
275
+ cache.delete(normalizeHostname(host));
276
+ }
277
+
278
+ function remember(key: string, bundle: InjectionBundle): void {
279
+ const now = Date.now();
280
+ for (const [host, entry] of cache) {
281
+ if (now - entry.at >= CACHE_TTL_MS) cache.delete(host);
282
+ }
283
+ while (cache.size >= CACHE_MAX_ENTRIES) {
284
+ const oldest = cache.keys().next();
285
+ if (oldest.done) break;
286
+ cache.delete(oldest.value);
287
+ }
288
+ cache.set(key, { at: now, bundle });
289
+ }
290
+
291
+ export type ResolveInjectionsOptions = {
292
+ defaultLocale?: string;
293
+ /** Reports a backend failure. Without one, failures reach `console.error`. */
294
+ logger?: InjectionsLogger;
295
+ };
296
+
162
297
  /**
163
- * Resolve the merged bundle for a request host. Never throws: a backend hiccup
164
- * degrades to "no injection" so the site still renders.
298
+ * Resolve the merged bundle for a request host.
299
+ *
300
+ * Never throws: a backend hiccup degrades to "no injection" so the site still
301
+ * renders. It is not, however, allowed to do that *quietly* — a failed read is
302
+ * reported and is NOT cached, so the next request retries instead of serving
303
+ * a fabricated empty bundle for the rest of the TTL. A query that returns an
304
+ * error rather than throwing counts as a failure; that is the shape this
305
+ * backend uses, and treating it as "no profiles" is what made a broken
306
+ * deployment indistinguishable from an unconfigured one.
165
307
  *
166
308
  * `client` must be a privileged (service-role) client — `site_injections` and
167
309
  * `locale_domains` are not readable anonymously.
@@ -169,55 +311,94 @@ const cache = new Map<string, { at: number; bundle: InjectionBundle }>();
169
311
  export async function resolveInjectionsCore(
170
312
  client: InjectionsClient,
171
313
  host: string,
172
- options: { defaultLocale?: string } = {},
314
+ options: ResolveInjectionsOptions = {},
173
315
  ): Promise<InjectionBundle> {
174
316
  const key = normalizeHostname(host);
175
317
  const hit = cache.get(key);
176
318
  if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.bundle;
177
319
 
178
- let bundle = EMPTY_BUNDLE;
320
+ const log = options.logger ?? defaultLogger;
179
321
  try {
180
- const [{ data: rows }, { data: domains }] = await Promise.all([
322
+ const [profileResult, domainResult] = await Promise.all([
181
323
  client.from("site_injections").select("*").eq("enabled", true),
182
324
  client.from("locale_domains").select("locale, domain, is_default"),
183
325
  ]);
184
326
 
185
- if (rows && rows.length > 0) {
186
- const list = (domains ?? []) as { locale: string; domain: string; is_default: boolean }[];
187
- const match = list.find((d) => normalizeHostname(d.domain) === key);
188
- const fallback = list.find((d) => d.is_default);
189
- const locale = match?.locale ?? fallback?.locale ?? options.defaultLocale ?? "en";
190
- const profiles = (rows as Record<string, unknown>[]).map(rowToInjectionProfile);
191
- bundle = mergeBundles(selectProfiles(profiles, locale, host));
327
+ if (profileResult?.error) throw new Error(String(profileResult.error.message ?? profileResult.error));
328
+ if (domainResult?.error) throw new Error(String(domainResult.error.message ?? domainResult.error));
329
+
330
+ const rows = (profileResult?.data ?? []) as Record<string, unknown>[];
331
+ if (rows.length === 0) {
332
+ remember(key, EMPTY_BUNDLE);
333
+ return EMPTY_BUNDLE;
192
334
  }
193
- } catch {
194
- bundle = EMPTY_BUNDLE;
335
+
336
+ const list = ((domainResult?.data ?? []) as {
337
+ locale: string;
338
+ domain: string;
339
+ is_default: boolean;
340
+ }[]).map((d) => ({ locale: d.locale, domain: d.domain, isDefault: d.is_default }));
341
+
342
+ const { locale } = resolveRequestLocale(list, host, options.defaultLocale ?? "en");
343
+ const bundle = mergeBundles(selectProfiles(rows.map(rowToInjectionProfile), locale, host));
344
+ remember(key, bundle);
345
+ return bundle;
346
+ } catch (error) {
347
+ reportBestEffort(log, "site code injection: could not resolve tracking profiles", error);
348
+ return EMPTY_BUNDLE;
195
349
  }
350
+ }
196
351
 
197
- cache.set(key, { at: Date.now(), bundle });
198
- return bundle;
352
+ /** A path reduced to `/a/b`: leading slash, no trailing one, no query. */
353
+ function normalizePath(value: string): string {
354
+ const path = (value ?? "").split(/[?#]/)[0] ?? "";
355
+ const withSlash = path.startsWith("/") ? path : `/${path}`;
356
+ return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
357
+ }
358
+
359
+ /**
360
+ * Whether `pathname` is `prefix` itself or something beneath it.
361
+ *
362
+ * Compared segment by segment rather than by string prefix, because
363
+ * `/administrator` is a public page and `startsWith("/admin")` called it part
364
+ * of the backoffice — a site whose backoffice lives at `/admin` then silently
365
+ * lost tracking on every page whose path merely began with those letters.
366
+ */
367
+ function isPathWithin(pathname: string, prefix: string): boolean {
368
+ const path = normalizePath(pathname);
369
+ const base = normalizePath(prefix);
370
+ if (base === "/") return true;
371
+ return path === base || path.startsWith(`${base}/`);
199
372
  }
200
373
 
201
374
  /** True for paths that must never receive tracking code. */
202
375
  export function isInjectablePath(pathname: string, backofficePath = "/kontoret"): boolean {
203
- if (pathname.startsWith(backofficePath)) return false;
204
- if (pathname.startsWith("/preview")) return false;
205
- if (pathname.startsWith("/api")) return false;
206
- if (pathname.startsWith("/_serverFn")) return false;
207
- return true;
376
+ const excluded = [backofficePath, "/preview", "/api", "/_serverFn"];
377
+ return !excluded.some((prefix) => isPathWithin(pathname, prefix));
208
378
  }
209
379
 
210
- /** Insert the bundle's three chunks into a rendered HTML document. */
380
+ /**
381
+ * Insert the bundle's three chunks into a rendered HTML document.
382
+ *
383
+ * Every insertion goes through a replacement *function*, never a replacement
384
+ * string. A string replacement is not a literal: `String.prototype.replace`
385
+ * reads `$&`, `` $` ``, `$'` and `$$` out of it and substitutes parts of the
386
+ * match. Administrator code is raw by design — a snippet containing `$&`
387
+ * (jQuery, a currency template, a regex in a tracker) would be silently
388
+ * rewritten into the matched tag, shipping something the administrator never
389
+ * wrote. A function's return value is used verbatim, so the injected bytes are
390
+ * exactly the bytes that were saved.
391
+ */
211
392
  export function applyInjections(html: string, bundle: InjectionBundle): string {
212
393
  let out = html;
213
394
  if (bundle.head && out.includes("</head>")) {
214
- out = out.replace("</head>", `${bundle.head}</head>`);
395
+ out = out.replace("</head>", () => `${bundle.head}</head>`);
215
396
  }
216
397
  if (bundle.bodyStart) {
217
398
  out = out.replace(/<body([^>]*)>/i, (m) => `${m}${bundle.bodyStart}`);
218
399
  }
219
400
  if (bundle.bodyEnd && out.includes("</body>")) {
220
- out = out.replace("</body>", `${bundle.bodyEnd}</body>`);
401
+ out = out.replace("</body>", () => `${bundle.bodyEnd}</body>`);
221
402
  }
222
403
  return out;
223
404
  }
@@ -14,6 +14,12 @@ import type { SupabaseClient } from "@supabase/supabase-js";
14
14
  import { getMediaStorage } from "../media/storage";
15
15
  import { isSecondaryVariant, variantSiblings } from "../media/srcset";
16
16
  import { uniqueFileName } from "../media/filename";
17
+ import {
18
+ assertMediaFolder,
19
+ joinMediaPath,
20
+ mediaBaseName,
21
+ monthFolder,
22
+ } from "../media/folders";
17
23
  import { auditWriter } from "./audit-core";
18
24
  import type { PlatformAuthContext } from "./guards";
19
25
  import {
@@ -331,10 +337,22 @@ export function signMediaUrlCore(
331
337
  */
332
338
  export async function uploadMediaBundleCore(
333
339
  deps: MediaLibraryDeps,
334
- input: { files: UploadMediaFile[]; upsert?: boolean },
335
- ): Promise<{ urls: string[] }> {
340
+ input: {
341
+ files: UploadMediaFile[];
342
+ upsert?: boolean;
343
+ /**
344
+ * Destination folder. Omitted or empty means the automatic `YYYY-MM`
345
+ * folder; a value is sanitized here (never trusted from the browser) and
346
+ * rejected outright if nothing safe is left of it.
347
+ */
348
+ folder?: string | null;
349
+ /** Site timezone, so the automatic folder rolls over at local midnight. */
350
+ timezone?: string | null;
351
+ },
352
+ ): Promise<{ urls: string[]; folder: string }> {
336
353
  const storage = getMediaStorage(deps.context.supabase);
337
354
  const upsert = input.upsert ?? false;
355
+ const folder = assertMediaFolder(input.folder) || monthFolder(Date.now(), input.timezone);
338
356
 
339
357
  const withSuffix = (name: string, n: number) => {
340
358
  if (n <= 1) return name;
@@ -348,9 +366,11 @@ export async function uploadMediaBundleCore(
348
366
  uploaded = [];
349
367
  try {
350
368
  for (const f of input.files) {
351
- const name = withSuffix(f.name, attempt);
352
- await storage.upload(name, new Uint8Array(f.bytes), f.contentType, { upsert });
353
- uploaded.push(name);
369
+ // Any directory part in the client-supplied name is dropped: the folder
370
+ // is the only thing that decides where a file lands.
371
+ const path = joinMediaPath(folder, withSuffix(mediaBaseName(f.name), attempt));
372
+ await storage.upload(path, new Uint8Array(f.bytes), f.contentType, { upsert });
373
+ uploaded.push(path);
354
374
  }
355
375
  break;
356
376
  } catch (err) {
@@ -365,8 +385,9 @@ export async function uploadMediaBundleCore(
365
385
  await audit(deps, "media.upload", uploaded[0] ?? "upload", {
366
386
  paths: uploaded,
367
387
  count: uploaded.length,
388
+ folder,
368
389
  });
369
- return { urls: uploaded.map((n) => storage.publicUrl(n)) };
390
+ return { urls: uploaded.map((n) => storage.publicUrl(n)), folder };
370
391
  }
371
392
 
372
393
  /**