@goodfellow-cms/admin 0.5.0 → 0.7.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,4 +1,5 @@
1
- import { COLOR_SCHEME_ATTRIBUTE, ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_REGISTRY, HEADER_FILE, MAX_UPLOAD_BYTES, MEDIA_DIR, MENUS_FILE, REDIRECTS_FILE, SHADCN_NAMESPACE, SHADCN_REGISTRY, SITE_FILE, ScheduledBuildsError, SignInError, addressPatternFor, adminOrigin, allPages, checkEntrySlug, checkPageAddress, checkSite, codeFileChanges, collectionFileChange, collectionFileChange as collectionFileChange$1, customCssFileChange, deleteCollectionChanges, describedConfig, duplicatePageData, entryFileChange, entryFileChange as entryFileChange$1, entryLanguage, entryPathIn, entryTitle, escapeStyleText, extensionOf, fileName, goodfellowRegistryUrl, googleFontsUrl, isDemoStore, keptDrafts, layoutFileChange, loadSiteContent, markdownToHtml, mediaFileChange, mediaKind, mediaKind as mediaKind$1, memoryKeeper, menusFileChange, moveEntryChanges, movePageChanges, newCollectionSettings, newDraftName, newPageData, outsideHost, pageFileChange, pageTitle, publishProblems, redirectsFileChange, relinkSite, relinkedChanges, scheduleOf, scheduleProblem, scheduleState, siteLanguages, siteSettingsFileChange, slugify, storedCss, storedData, storedEntryFields, themeToCss, todayIn, trimChars, uniqueName, upcomingChanges, updateSettingsFileChange, uploadName, wallClock, withoutCredits, writeChanges } from "@goodfellow-cms/core";
1
+ import { n as CONNECT_MESSAGE, r as createChannel } from "./plugin-protocol-DkvBDqsF.js";
2
+ import { APP_API_HEADER, COLOR_SCHEME_ATTRIBUTE, ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_REGISTRY, HEADER_FILE, MAX_UPLOAD_BYTES, MEDIA_DIR, MENUS_FILE, PluginTaskError, REDIRECTS_FILE, SHADCN_NAMESPACE, SHADCN_REGISTRY, SITE_FILE, ScheduledBuildsError, SignInError, TASK_INPUT_LIMIT, addressPatternFor, adminOrigin, allPages, checkEntrySlug, checkPageAddress, checkSite, codeFileChanges, collectionFileChange, collectionFileChange as collectionFileChange$1, customCssFileChange, deleteCollectionChanges, describedConfig, duplicatePageData, entryFileChange, entryFileChange as entryFileChange$1, entryLanguage, entryPathIn, entryTitle, escapeStyleText, extensionOf, fileName, goodfellowRegistryUrl, googleFontsUrl, isDemoStore, isStorableAddress, keptDrafts, layoutFileChange, limitsFor, loadSiteContent, markdownToHtml, mayChangeEntry, mayChangeLayout, mayChangeMedia, mayChangeMenus, mayChangePage, mediaFileChange, mediaKind, mediaKind as mediaKind$1, mediaStorageFileChange, memoryKeeper, menusFileChange, moveEntryChanges, movePageChanges, newCollectionSettings, newDraftName, newPageData, newTaskRequestId, openTaskAnswer, outsideHost, outsideLimits, pageFileChange, pageTitle as pageTitle$1, pluginFrameUrl, pluginMayList, pluginMayUse, publishProblems, redirectsFileChange, relinkSite, relinkedChanges, reviewersFor, scheduleOf, scheduleProblem, scheduleState, siteLanguages, siteSettingsFileChange, slugify, storedCss, storedData, storedEntryFields, storedMediaUrl, taskKeys, themeToCss, todayIn, trimChars, uniqueName, upcomingChanges, updateSettingsFileChange, uploadName, wallClock, withStoredFiles, withoutCredits, writeChanges } from "@goodfellow-cms/core";
2
3
  import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
4
  import TurndownService from "turndown";
4
5
  import { gfm } from "turndown-plugin-gfm";
@@ -155,6 +156,12 @@ function mediaPath(url) {
155
156
  if (!rest || rest.split("/").some((part) => part === "" || part === "." || part === "..")) return void 0;
156
157
  return `${MEDIA_DIR}/${decodeURIComponent(rest)}`;
157
158
  }
159
+ /** A file size in words: `2.4 MB`. */
160
+ function formatSize(bytes) {
161
+ if (bytes < 1024) return `${bytes} B`;
162
+ if (bytes < 1048576) return `${Math.round(bytes / 1024)} KB`;
163
+ return `${(bytes / 1048576).toFixed(1)} MB`;
164
+ }
158
165
  /** Where a media file is used, by name, so editors know what deleting or replacing it affects. */
159
166
  function mediaUsage(content, url, names) {
160
167
  const uses = (value) => JSON.stringify(value).includes(url);
@@ -162,7 +169,7 @@ function mediaUsage(content, url, names) {
162
169
  if (uses(content.settings)) used.push(names.settings);
163
170
  if (uses(content.header)) used.push(names.header);
164
171
  if (uses(content.footer)) used.push(names.footer);
165
- for (const page of content.pages) if (uses(page.content)) used.push(pageTitle(page));
172
+ for (const page of content.pages) if (uses(page.content)) used.push(pageTitle$1(page));
166
173
  for (const collection of content.collections) {
167
174
  if (uses(collection.settings)) used.push(collection.settings.name);
168
175
  for (const entry of collection.entries) if (uses(entry.content)) used.push(entryTitle(entry));
@@ -174,12 +181,25 @@ function mediaUsage(content, url, names) {
174
181
  function siteImages(media) {
175
182
  return media.filter((path) => mediaKind(path) === "image").map(mediaUrl);
176
183
  }
184
+ /**
185
+ * Every media file the site has, as `public/media/…` paths: those in the
186
+ * site's own files and those a plugin keeps outside them
187
+ * (`content/media-storage.json`), which content refers to the same way.
188
+ */
189
+ function allMedia(content, media) {
190
+ const stored = Object.keys(content.mediaStorage?.files ?? {}).flatMap((url) => mediaPath(url) ?? []);
191
+ return [.../* @__PURE__ */ new Set([...media, ...stored])];
192
+ }
193
+ /** Whether a plugin keeps a media file outside the site's own files. */
194
+ function isStoredMedia(content, url) {
195
+ return Object.hasOwn(content.mediaStorage?.files ?? {}, url);
196
+ }
177
197
  //#endregion
178
198
  //#region src/site-code.ts
179
199
  /**
180
200
  * What the admin panel needs the site's own code for: the pages its blocks add
181
- * (a calendar's months, a list's later pages), and pages rendered to check a
182
- * publish. The admin panel's page never runs that code: it asks the frame that
201
+ * (a calendar's months, a list's later pages), pages rendered to check a
202
+ * publish, and blocks drawn for editor plugins. The admin panel's page never runs that code: it asks the frame that
183
203
  * does (`frameSiteCode()` in `frame/host.tsx`), which answers with `localSiteCode()` (`frame/local-code.ts`).
184
204
  */
185
205
  /** The page at an address, as `findSitePage()` gives it: a page a build writes, or "Page not found". */
@@ -301,7 +321,7 @@ function problemPlace(t, content, file) {
301
321
  if (file === SITE_FILE) return t("checks.place.settings");
302
322
  if (file === REDIRECTS_FILE) return t("checks.place.redirects");
303
323
  const page = content.pages.find((candidate) => candidate.file === file);
304
- if (page) return t("checks.place.page", { title: pageTitle(page) });
324
+ if (page) return t("checks.place.page", { title: pageTitle$1(page) });
305
325
  for (const collection of content.collections) {
306
326
  if (collection.file === file) return t("checks.place.design", { collection: collection.settings.name });
307
327
  const entry = collection.entries.find((candidate) => candidate.file === file);
@@ -399,6 +419,7 @@ const defaultStrings = {
399
419
  "signIn.token.create": "Create a token on {host}",
400
420
  "signIn.token.createBroad": "Create a broader token instead",
401
421
  "signIn.token.createBroadHint": "Use this if the site belongs to someone else and the first link doesn't let you choose it. This kind of token can change everything your account can.",
422
+ "signIn.token.apiTokenHint": "Use this if you can't create a token for the site itself. Paste your Atlassian account's email address, a colon, then the token, such as name@example.org:ATATT3x…, and give the token permission to read and write repositories, pull requests and pipelines.",
402
423
  "signIn.token.chooseSite": "If {host} asks what the token can access, choose this site.",
403
424
  "signIn.token.step2": "Copy the token and paste it here:",
404
425
  "signIn.token.label": "Access token",
@@ -471,6 +492,18 @@ const defaultStrings = {
471
492
  "editor.viewport.phone": "Phone",
472
493
  "editor.failed": "The editor stopped working. Reload the page to start it again.",
473
494
  "editor.frame": "Editing {title}",
495
+ "editor.choose": "Editor",
496
+ "editor.blockSearch": "Search blocks",
497
+ "editor.blockSearchNone": "No blocks match. Try another word, such as \"picture\" or \"columns\".",
498
+ "editor.blockSearchFound": "Blocks found",
499
+ "editor.standard": "Standard editor",
500
+ "editor.plugin.switchLoses": "Some of what you changed in {editor} can't be kept in the other editor, so switching now leaves behind everything you changed since you opened {editor}. Switch anyway?\n\n{problems}",
501
+ "editor.plugin.pageSettings": "The page's settings",
502
+ "editor.plugin.problem": "{block}: {message}",
503
+ "editor.plugin.problemField": "{block}, {field}: {message}",
504
+ "editor.plugin.loading": "Opening {editor}…",
505
+ "editor.plugin.failed": "{editor} didn't start. Choose the standard editor to keep editing.",
506
+ "editor.plugin.invalid": "{editor} gave back something this site can't publish, so nothing was published. Try again, or choose the standard editor.",
474
507
  "publish.publishing": "Publishing…",
475
508
  "publish.done": "Published.",
476
509
  "publish.conflict": "Someone else changed the site since you opened this page, so your changes weren't published. Reload to see their changes, then make yours again.",
@@ -497,6 +530,9 @@ const defaultStrings = {
497
530
  "draft.leave": "Back to the live site",
498
531
  "draft.waiting": "A draft started {date} is waiting to be published.",
499
532
  "draft.waitingMany": "{count} drafts are waiting to be published.",
533
+ "draft.reviewWaiting": "A draft is waiting for review: {title}.",
534
+ "draft.reviewWaitingMany": "{count} drafts are waiting for review.",
535
+ "draft.inReview": "Submitted for review.",
500
536
  "draft.choose": "Draft to open",
501
537
  "draft.started": "Started {date}",
502
538
  "draft.open": "Open",
@@ -516,6 +552,22 @@ const defaultStrings = {
516
552
  "checks.alsoWarnings": "These could be better too:",
517
553
  "checks.publishAnyway": "Publish anyway",
518
554
  "checks.goBack": "Go back",
555
+ "limits.title": "Some of this is outside what you can change",
556
+ "limits.intro": "An owner has limited what you can change on this site, and these changes are outside it:",
557
+ "limits.review": "Submit them for review instead: the site's reviewers are asked to look, and can publish them for you.",
558
+ "limits.noReview": "Ask an owner to make these changes for you, or to let you make them on Site Settings' Editors screen.",
559
+ "limits.goBack": "Go back",
560
+ "limits.submitting": "Submitting for review…",
561
+ "limits.submitted": "Submitted for review. The live site stays as it is until a reviewer publishes it.",
562
+ "limits.submitError": "The changes couldn't be submitted for review. Check your connection and try again.",
563
+ "limits.page": "The page {name}",
564
+ "limits.entry": "{name}, in {collection}",
565
+ "limits.media": "The media library",
566
+ "limits.layout": "The header and footer",
567
+ "limits.menus": "The menus",
568
+ "limits.other": "The site's settings, or other things only owners change",
569
+ "limits.readOnly": "You can look at this, but an owner has limited what you can change, and it's outside it.",
570
+ "limits.mediaReadOnly": "An owner has limited what you can change, and the media library is outside it. You can still use its files on pages you can change.",
519
571
  "checks.missing-page": "{place} links to {target}, which isn't a page on the site.",
520
572
  "checks.missing-media": "{place} uses {target}, which isn't in the media library.",
521
573
  "checks.no-title": "{place} has no title, so browser tabs and search results show only the site's name.",
@@ -547,6 +599,7 @@ const defaultStrings = {
547
599
  "pages.empty": "This site has no pages yet.",
548
600
  "pages.column.title": "Title",
549
601
  "pages.column.address": "Address",
602
+ "pages.look": "Look",
550
603
  "pages.edit": "Edit",
551
604
  "pages.move": "Change address",
552
605
  "pages.duplicate": "Duplicate",
@@ -608,7 +661,7 @@ const defaultStrings = {
608
661
  "deletePage.submit": "Delete page",
609
662
  "deletePage.message": "Delete page \"{title}\"",
610
663
  "address.invalid": "Use lowercase letters, numbers and hyphens, with / between parts, such as /about-us.",
611
- "address.reserved": "This address is used by the admin panel. Choose another.",
664
+ "address.reserved": "This address can't be used: the admin panel uses it, or it ends in /index, which is the page above it. Choose another.",
612
665
  "address.taken": "Another page already uses this address.",
613
666
  "field.required": "This can't be empty.",
614
667
  "editPage.message": "Update page \"{title}\"",
@@ -619,7 +672,7 @@ const defaultStrings = {
619
672
  "versions.back": "Back to editing",
620
673
  "versions.intro": "Every version that's been published, newest first. Choose one to see it, then restore it to put it back. Something that was moved to a new address starts a new history there.",
621
674
  "versions.notFound": "There's no history to show here. It may have been moved or deleted.",
622
- "versions.local": "Version history is in the published site's admin panel, where you sign in with GitHub or GitLab.",
675
+ "versions.local": "Version history is in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
623
676
  "versions.demo": "Version history isn't kept in a demo.",
624
677
  "versions.loading": "Loading versions…",
625
678
  "versions.loadingVersion": "Loading this version…",
@@ -686,7 +739,7 @@ const defaultStrings = {
686
739
  "newEntry.addressHint": "The last part of the page's address, as in {example}.",
687
740
  "newEntry.message": "Add {entry} \"{title}\"",
688
741
  "entryName.invalid": "Use lowercase letters, numbers and hyphens only, such as easter-vigil.",
689
- "entryName.reserved": "This address is used by the admin panel. Choose another.",
742
+ "entryName.reserved": "This address can't be used: the admin panel uses it, or it ends in /index, which is the page above it. Choose another.",
690
743
  "entryName.taken": "Something else on the site already uses this address.",
691
744
  "moveEntry.message": "Move {entry} \"{title}\" from {from} to {to}",
692
745
  "deleteEntry.body": "{title} will be removed from {name}.",
@@ -863,14 +916,96 @@ const defaultStrings = {
863
916
  "media.error.size": "{name} is too large: files can be up to 25 MB. For long videos, upload them to YouTube or Vimeo and link to them instead.",
864
917
  "media.error.unreadable": "{name} couldn't be read. It may be damaged, or in a format browsers can't open, such as HEIC. Save it as a JPEG and try again.",
865
918
  "media.choose": "Choose image",
919
+ "media.chooseAudio": "Choose sound file",
866
920
  "media.chooseFile": "Choose file",
867
921
  "media.chooseVideo": "Choose video",
868
922
  "media.pickerTitle": "Choose an image",
923
+ "media.pickerTitleAudio": "Choose a sound file",
869
924
  "media.pickerTitleFile": "Choose a file",
870
925
  "media.pickerTitleVideo": "Choose a video",
871
926
  "media.pickerEmpty": "Nothing has been uploaded yet. Upload a file to use it here.",
872
927
  "media.clear": "Remove",
928
+ "table.addRow": "Add a row",
929
+ "table.addColumn": "Add a column",
930
+ "table.removeRow": "Remove this row",
931
+ "table.removeRowNumber": "Remove row {row}",
932
+ "table.removeColumn": "Remove this column",
933
+ "table.removeColumnNumber": "Remove column {column}",
934
+ "table.cell": "Row {row}, column {column}",
935
+ "style.align": "Text alignment",
936
+ "style.align.default": "As the block has it",
937
+ "style.align.left": "Left",
938
+ "style.align.center": "Centered",
939
+ "style.align.right": "Right",
940
+ "style.align.justify": "Justified",
941
+ "style.fontSize": "Text size",
942
+ "style.fontSize.xs": "Extra small",
943
+ "style.fontSize.sm": "Small",
944
+ "style.fontSize.base": "Normal",
945
+ "style.fontSize.lg": "Large",
946
+ "style.fontSize.xl": "Extra large",
947
+ "style.fontSize.2xl": "Very large",
948
+ "style.fontSize.3xl": "Huge",
949
+ "style.fontSize.4xl": "Very huge",
950
+ "style.fontSize.5xl": "Giant",
951
+ "style.fontSize.6xl": "Enormous",
952
+ "style.color": "Text color",
953
+ "style.background": "Background color",
954
+ "style.colorDefault": "As the block has it",
955
+ "style.colorCustom": "Choose a color",
956
+ "style.contrast": "This text may be hard to read on this background. Try colors further apart.",
957
+ "style.padding": "Space inside",
958
+ "style.margin": "Space around",
959
+ "style.eachSide": "Set each side",
960
+ "style.side.top": "Top",
961
+ "style.side.right": "Right",
962
+ "style.side.bottom": "Bottom",
963
+ "style.side.left": "Left",
964
+ "style.allSides": "All sides",
965
+ "style.none": "None",
966
+ "style.radius": "Rounded corners",
967
+ "style.radius.none": "Square",
968
+ "style.radius.sm": "Slightly rounded",
969
+ "style.radius.md": "Rounded",
970
+ "style.radius.lg": "The theme's rounding",
971
+ "style.radius.xl": "Very rounded",
972
+ "style.radius.full": "Round",
973
+ "style.default": "As the block has it",
974
+ "style.custom": "Your own…",
975
+ "style.customLength": "Your own size, such as 12px or 1.5rem",
976
+ "style.customLengthInvalid": "Write a number with px, rem, em or %, such as 12px.",
977
+ "style.other": "Other CSS classes",
978
+ "style.otherHint": "Tailwind classes, or your own from Custom CSS, separated by spaces.",
979
+ "style.reset": "Back to the block's own look",
873
980
  "media.credit": "Credit: {credit}",
981
+ "media.stored": "Kept by {plugin}",
982
+ "media.error.storageSize": "{name} is too large: {plugin} takes files up to {size}.",
983
+ "media.storage.title": "Where media is kept",
984
+ "media.storage.intro": "{plugin} keeps the site's media in its storage, including large files such as long videos. It keeps {count} files now. Pages show them from there, at the same addresses.",
985
+ "media.storage.missing": "{count} files are kept in storage by {plugin}, which the site no longer has. Pages still show them, but new files go into the site's own files.",
986
+ "media.storage.notReady": "{plugin} can't take files yet. {message}",
987
+ "media.storage.unavailable": "{plugin} didn't answer. Reload the admin panel and try again.",
988
+ "media.storage.settings": "Open {plugin}'s settings",
989
+ "media.storage.failed": "{plugin} couldn't keep {name}.",
990
+ "media.storage.uploading": "Uploading to {plugin}… This takes about a minute.",
991
+ "media.storage.wait": "Each group of files takes about a minute.",
992
+ "media.move.button": "Move {count} files to {plugin}",
993
+ "media.move.title": "Move the site's media to {plugin}?",
994
+ "media.move.body": "{plugin} will keep these files, and pages will show them from its storage at the same addresses, so nothing on them changes. They're taken out of the site's own files; Version history keeps them.",
995
+ "media.move.moving": "Moved {done} of {total}…",
996
+ "media.move.busy": "Moving…",
997
+ "media.move.nothing": "No files were moved.",
998
+ "media.move.some": "Moved {count} files to {plugin}, then stopped.",
999
+ "media.move.done": "Moved {count} files to {plugin}.",
1000
+ "media.move.message": "Move media to {plugin}",
1001
+ "media.back.button": "Move back to the site",
1002
+ "media.back.title": "Move the media back from {plugin}?",
1003
+ "media.back.body": "The files {plugin} keeps are copied back into the site's own files, at the same addresses, and new files go there too. Each can be up to 25 MB.",
1004
+ "media.back.empty": "{plugin} keeps no files now. New files will go into the site's own files.",
1005
+ "media.back.large": "These are larger than 25 MB, so {plugin} keeps them: {names}",
1006
+ "media.back.failed": "{name} couldn't be copied back from {plugin}'s storage, which may not allow it.",
1007
+ "media.back.done": "Moved {count} files back from {plugin}.",
1008
+ "media.back.message": "Move media back from {plugin}",
874
1009
  "ai.label": "AI",
875
1010
  "ai.title": "Write with AI",
876
1011
  "ai.mode.add": "Describe what to add to this page, and AI will write it at the end of the page.",
@@ -952,9 +1087,10 @@ const defaultStrings = {
952
1087
  "settings.previewEmpty": "Add a home page to see a preview here.",
953
1088
  "domain.intro": "Connect a domain you own, such as example.org, so the site is at that address.",
954
1089
  "domain.current": "The site is at {address} now.",
1090
+ "domain.copy": "This admin panel is a copy of the site's, such as a staging site, at {address}. That address is set where the copy is built, so the domain this screen connects is the main site's.",
955
1091
  "domain.connected": "Connected to {domain}.",
956
1092
  "domain.signIn": "Editors sign in by going to {host} and coming back here. Add {address} to the redirect URIs of the application they sign in with on {host}, or signing in will stop working once the site moves.",
957
- "domain.local": "Domains are connected in the published site's admin panel, where you sign in with GitHub or GitLab.",
1093
+ "domain.local": "Domains are connected in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
958
1094
  "domain.admin.title": "Admin address",
959
1095
  "domain.admin.intro": "An address of its own for the admin panel, such as admin.example.org, keeps the site's pages from reaching what the admin panel keeps, such as editors' sign-in. Editors then sign in there. Blocks from registries other than Goodfellow's can be added only once the admin panel has one.",
960
1096
  "domain.admin.unsupported": "{host} serves each site at one address, so the admin panel can't have one of its own here.",
@@ -1047,7 +1183,7 @@ const defaultStrings = {
1047
1183
  "owner.token.paste": "Copy the token and paste it here:",
1048
1184
  "settings.tab.security": "Security",
1049
1185
  "security.demo": "A demo has no sign-in to keep safe.",
1050
- "security.local": "The sign-in page is set in the published site's admin panel, where you sign in with GitHub or GitLab.",
1186
+ "security.local": "The sign-in page is set in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
1051
1187
  "security.contentOnly": "The sign-in page is set in the site's code, which is kept apart from its content, so a developer sets it there.",
1052
1188
  "security.broker.title": "Sign-in page",
1053
1189
  "security.broker.intro": "A sign-in page of its own, called a token broker, keeps editors' sign-in and AI keys at an address that holds nothing else, and shows the admin panel from there, so nothing on the site's pages can reach them. It asks the editor before publishing the site's code. It needs an address of its own, such as signin.example.org, that serves nothing else.",
@@ -1124,8 +1260,186 @@ const defaultStrings = {
1124
1260
  "settings.tab.updates": "Updates",
1125
1261
  "settings.tab.history": "Site history",
1126
1262
  "settings.tab.import": "WordPress",
1263
+ "settings.tab.themes": "Themes",
1264
+ "plugins.notFound": "The site has no plugin screen at this address.",
1265
+ "plugins.failed": "{plugin}'s screen couldn't start. If it keeps happening, the plugin may need updating.",
1266
+ "themes.intro": "A theme changes your site's look: its colors, fonts and the styles of its blocks, and sometimes its header and footer. Themes are only settings and styles, with no code, and you can change anything a theme sets afterwards in Colors & fonts and Custom CSS.",
1267
+ "themes.installed": "Installed theme",
1268
+ "themes.noneInstalled": "No theme is installed. Your site uses its own colors and fonts.",
1269
+ "themes.installedBadge": "Installed",
1270
+ "themes.release": "version {version}",
1271
+ "themes.updateAvailable": "Version {version} is out",
1272
+ "themes.updateTo": "Update to {version}",
1273
+ "themes.seeVersion": "See version {version}",
1274
+ "themes.upToDate": "Up to date",
1275
+ "themes.show": "Show",
1276
+ "themes.showLabel": "Show {name}",
1277
+ "themes.find": "Find a theme",
1278
+ "themes.search": "Search themes",
1279
+ "themes.searchButton": "Search",
1280
+ "themes.loading": "Looking for themes…",
1281
+ "themes.noneFound": "No themes were found.",
1282
+ "themes.byPublisher": "{name}, published by {publisher}",
1283
+ "themes.releaseDetails": "{name}, version {version}, published {date}",
1284
+ "themes.provenance": "Built from {source}, which npm checked when it was published.",
1285
+ "themes.noProvenance": "There's no record of where this version was built, so nobody has checked it came from its publisher's own code.",
1286
+ "themes.waiting": "Version {version} can be installed once it's {days} days old, since most harmful versions are found by then.",
1287
+ "themes.incompatible": "Version {version} doesn't say it works with Goodfellow {goodfellow}.",
1288
+ "themes.tooNew": "This theme's versions are too new to install. Versions can be installed once they're {days} days old.",
1289
+ "themes.notCompatible": "No version of this theme says it works with Goodfellow {version}.",
1290
+ "themes.imageAlt": "A site with the {title} theme",
1291
+ "themes.loads": "This theme asks visitors' browsers for these files from other sites, which can tell those sites who visits yours:",
1292
+ "themes.useLayout": "Use the theme's header and footer in place of yours. Your menus, logo and contact details stay as they are.",
1293
+ "themes.layoutUnknown": "The theme's header and footer use blocks a theme can't add, or that your site doesn't have ({blocks}), so they can't be used.",
1294
+ "themes.kept": "You changed these since the version before, so they stay as you set them: {list}.",
1295
+ "themes.replaced": "You changed these, and installing this theme replaces your changes: {list}.",
1296
+ "themes.changed.css": "the theme's CSS",
1297
+ "themes.preview": "Your site with {title}",
1298
+ "themes.previewPage": "Page to look at",
1299
+ "themes.install": "Install and publish",
1300
+ "themes.installDemo": "Install",
1301
+ "themes.installMessage": "Install the {title} theme, version {version}",
1302
+ "themes.updateMessage": "Update the {title} theme to version {version}",
1303
+ "themes.installedNotice": "{title} is installed. Your site will show it once it's rebuilt.",
1304
+ "themes.updated": "{title} is updated to version {version}.",
1305
+ "themes.remove": "Remove",
1306
+ "themes.removeTitle": "Remove {title}?",
1307
+ "themes.removeBody": "Colors, fonts and styles the theme set go back to Goodfellow's defaults, and its CSS is removed. Anything you changed yourself, and your header and footer, stay as they are.",
1308
+ "themes.removeReplaced": "Removing it also removes your changes to: {list}.",
1309
+ "themes.removeMessage": "Remove the {title} theme",
1310
+ "themes.removed": "{title} is removed.",
1311
+ "themes.error.record": "The record of which theme is installed can't be read.",
1312
+ "themes.error.unreachable": "Themes can't be looked up right now. Check your internet connection and try again.",
1313
+ "themes.error.badFile": "This theme has a problem in its files, so it can't be installed. Its publisher needs to fix it.",
1314
+ "themes.error.notATheme": "This package isn't a Goodfellow theme.",
1315
+ "settings.tab.plugins": "Plugins",
1316
+ "plugins.intro": "Plugins add to what your site and this admin panel can do, such as blocks, screens here, and steps when your site is built. Unlike themes, they have code, which is included when your site is built and runs on its pages, so install only plugins you trust. Before you install one, this screen shows what it asks to do and reads its code for things to look at.",
1317
+ "plugins.where.none": "Plugins can be installed here once the admin panel has an address of its own, or a token broker, where your site's pages can't reach it: see the Domain and Security tabs. Until then, a developer can add plugins to the site's code.",
1318
+ "plugins.where.contentOnly": "This site's code is kept apart from its content, so a developer adds plugins there.",
1319
+ "plugins.where.demo": "Plugins can't be installed in a demo.",
1320
+ "plugins.where.local": "On this computer, a developer adds plugins with npm and lists them in goodfellow.config.tsx. The published admin panel installs them.",
1321
+ "plugins.where.setup": "Your site's build doesn't write the list of its packages' versions yet, which plugins installed here need. A developer needs to update its build setup to the starter's first.",
1322
+ "plugins.installed": "Installed plugins",
1323
+ "plugins.noneInstalled": "No plugins are installed.",
1324
+ "plugins.inCode": "Added by a developer",
1325
+ "plugins.installedBadge": "Installed",
1326
+ "plugins.release": "version {version}",
1327
+ "plugins.updateAvailable": "Version {version} is out",
1328
+ "plugins.updateTo": "Update to {version}",
1329
+ "plugins.seeVersion": "See version {version}",
1330
+ "plugins.upToDate": "Up to date",
1331
+ "plugins.show": "Show",
1332
+ "plugins.showLabel": "Show {name}",
1333
+ "plugins.find": "Find a plugin",
1334
+ "plugins.search": "Search plugins",
1335
+ "plugins.searchButton": "Search",
1336
+ "plugins.loading": "Looking for plugins…",
1337
+ "plugins.noneFound": "No plugins were found.",
1338
+ "plugins.byPublisher": "{name}, published by {publisher}",
1339
+ "plugins.releaseDetails": "{name}, version {version}, published {date}",
1340
+ "plugins.provenance": "Built from {source}, which npm checked when it was published.",
1341
+ "plugins.noProvenance": "There's no record of where this version was built, so nobody has checked it came from its publisher's own code.",
1342
+ "plugins.waiting": "Version {version} can be installed once it's {days} days old, since most harmful versions are found by then.",
1343
+ "plugins.incompatible": "Version {version} doesn't say it works with Goodfellow {goodfellow}.",
1344
+ "plugins.tooNew": "This plugin's versions are too new to install. Versions can be installed once they're {days} days old.",
1345
+ "plugins.notCompatible": "No version of this plugin says it works with Goodfellow {version}.",
1346
+ "plugins.asks": "What it asks to do",
1347
+ "plugins.asks.connect": "Reach these addresses from the admin panel: {list}",
1348
+ "plugins.asks.files": "Read and publish these files: {list}",
1349
+ "plugins.asks.secrets": "Get these secrets when your site is built: {list}",
1350
+ "plugins.asks.read": "Read these parts of your site when it's built: {list}",
1351
+ "plugins.asks.write": "Write these parts of the built site: {list}",
1352
+ "plugins.asks.blocks": "Add these blocks: {list}",
1353
+ "plugins.asks.admin": "Run in the admin panel, in a frame of its own that can't reach your sign-in",
1354
+ "plugins.asks.screens": "Add these screens to Site Settings: {list}",
1355
+ "plugins.asks.build": "Run these steps when your site is built: {list}",
1356
+ "plugins.asks.tasks": "Run these tasks with its secrets when the admin panel asks, each taking about a minute: {list}",
1357
+ "plugins.secrets.github.title": "Add them on GitHub",
1358
+ "plugins.secrets.github.link": "Open GitHub's form for a new secret",
1359
+ "plugins.secrets.github.open": "Open GitHub's form for a new secret with the link below. Add each key there, one at a time, opening the form again for the next.",
1360
+ "plugins.secrets.github.add": "For {name}: type {name} in Name, paste its value in Secret, and choose Add secret.",
1361
+ "plugins.secrets.github.who": "Only the site's owners on GitHub can open the form.",
1362
+ "plugins.secrets.github.kind": "This form adds secrets for this site alone, which is all it needs. GitHub's environment and organization secrets are for other things, so you can leave them alone.",
1363
+ "plugins.secrets.gitlab.title": "Add them on GitLab",
1364
+ "plugins.secrets.gitlab.link": "Open the site's CI/CD settings on GitLab",
1365
+ "plugins.secrets.gitlab.open": "Open the site's CI/CD settings on GitLab with the link below, and expand Variables. Add each key there, one at a time.",
1366
+ "plugins.secrets.gitlab.add": "For {name}: choose Add variable, choose Masked under Visibility, keep Protect variable ticked, type {name} in Key, paste its value in Value, and choose Add variable.",
1367
+ "plugins.secrets.gitlab.who": "Only the site's owners (Maintainers) on GitLab can change its CI/CD settings.",
1368
+ "plugins.secrets.bitbucket.title": "Add them on Bitbucket",
1369
+ "plugins.secrets.bitbucket.link": "Open the site's Pipelines variables on Bitbucket",
1370
+ "plugins.secrets.bitbucket.open": "Open the site's Pipelines variables on Bitbucket with the link below. Add each key there, one at a time. If Bitbucket says Pipelines isn't turned on, turn it on under Settings first.",
1371
+ "plugins.secrets.bitbucket.add": "For {name}: type {name} in Name, paste its value in Value, tick Secured, and choose Add.",
1372
+ "plugins.secrets.bitbucket.who": "Only the site's owners (with Admin permission) on Bitbucket can change its variables.",
1373
+ "plugins.secrets.development.title": "Add them on this computer",
1374
+ "plugins.secrets.development.file": "In the site's folder, beside package.json, open the file .env.local, or make it, and add a line for each of {names}:",
1375
+ "plugins.secrets.development.values": "Put each value after its =, save the file, and try again. There's no need to restart the development server.",
1376
+ "plugins.secrets.development.local": "This is only for trying the site on this computer: .env.local is never published. The live site needs them in its own secrets too.",
1377
+ "plugins.secrets.server.title": "Add them to the site's server",
1378
+ "plugins.secrets.server.add": "Add {names} as environment variables where the site's server runs, such as the project's Settings → Environment Variables on Vercel.",
1379
+ "plugins.secrets.server.deploy": "Then deploy the site again so the server has them.",
1380
+ "plugins.secrets.server.who": "Whoever set up the site's server knows where its environment variables are. Without them there, the build service's secrets below are used instead, which is slower.",
1381
+ "plugins.task.missing": "Your site's setup can't run plugins' tasks yet. A developer needs to copy the starter's plugin-task setup into the site.",
1382
+ "plugins.task.not-allowed": "Your sign-in can't run plugins' tasks. Ask the site's owner to let you publish.",
1383
+ "plugins.task.timeout": "The site's build service didn't answer in time. Try again in a few minutes.",
1384
+ "plugins.task.failed": "The site's build service couldn't run the plugin's task. Try again, or ask a developer to look at the site's builds.",
1385
+ "plugins.asks.server": "Answer requests on your site's server, with everything the server can do",
1386
+ "plugins.asks.mediaStorage": "Keep your site's media in a storage service, outside the site's own files",
1387
+ "plugins.asks.editor": "Offer its own page editor, {title}, which anyone editing the site can choose in place of the usual one, and which is given what they edit there",
1388
+ "plugins.asks.always": "Like any plugin, its code is included when your site is built, and its blocks run on your site's pages, where nothing holds them to what it asks.",
1389
+ "plugins.asks.new": "This version asks for more than the one installed:",
1390
+ "plugins.code": "What its code does",
1391
+ "plugins.code.intro": "The plugin's code, and the code of the packages it needs, read as text for things to look at. Code can hide what it does, so this is a place to start, not a promise.",
1392
+ "plugins.code.reading": "Reading its code…",
1393
+ "plugins.code.via": "needed by {via}",
1394
+ "plugins.code.none": "Nothing to look at was found.",
1395
+ "plugins.code.summary": "Its code: {list}.",
1396
+ "plugins.code.nothing": "Nothing to look at was found in its code.",
1397
+ "plugins.code.unreadSummary": "{count} of its packages couldn't be read in full.",
1398
+ "plugins.note.network": "makes network requests",
1399
+ "plugins.note.programs": "runs other programs on the computer that builds your site",
1400
+ "plugins.note.eval": "runs code made from text, which can't be read beforehand",
1401
+ "plugins.note.native": "includes native code, which can't be read",
1402
+ "plugins.note.install-script": "has a script npm would run when installing it, which your site's build doesn't run, since it installs packages with scripts turned off",
1403
+ "plugins.unread.too-large": "Too large to read in full.",
1404
+ "plugins.unread.too-many": "Not read: the plugin needs too many packages to read them all.",
1405
+ "plugins.unread.no-release": "No version of it is old enough yet, so your site couldn't be built with this plugin.",
1406
+ "plugins.unread.range": "Comes from somewhere other than npm, so it wasn't read.",
1407
+ "plugins.unread.unavailable": "Couldn't be read.",
1408
+ "plugins.install": "Install",
1409
+ "plugins.confirmTitle": "Install {title}?",
1410
+ "plugins.confirmIntro": "{name} version {version} asks to:",
1411
+ "plugins.confirmConfig": "Your site's settings file is changed too, to list the plugins installed here, since it didn't yet.",
1412
+ "plugins.confirmBuild": "Your site's build then writes down the versions of the packages it needs, without running any of their code, and rebuilds your site with it, which takes a few minutes.",
1413
+ "plugins.confirmReview": "Your site's server holds its secrets, so installing a plugin is submitted for review, for a developer to accept.",
1414
+ "plugins.confirmInstall": "Install and publish",
1415
+ "plugins.submit": "Submit for review",
1416
+ "plugins.reviewIntro": "Installs {name} version {version}, from the admin panel's Plugins screen. It asks to:",
1417
+ "plugins.installMessage": "Install the {title} plugin, version {version}",
1418
+ "plugins.updateMessage": "Update the {title} plugin to version {version}",
1419
+ "plugins.installedNotice": "{title} is installed. Your site will have it once it's rebuilt, in a few minutes.",
1420
+ "plugins.updated": "{title} is updated to version {version}.",
1421
+ "plugins.submitted": "The change to {title} is submitted for review.",
1422
+ "plugins.seeReview": "See it",
1423
+ "plugins.remove": "Remove",
1424
+ "plugins.removeReview": "Submit for review",
1425
+ "plugins.removeTitle": "Remove {title}?",
1426
+ "plugins.removeBody": "Its code is taken out of your site when it's next built. Files it kept in your site's content stay where they are.",
1427
+ "plugins.removeUsed": "Pages use its blocks ({blocks}), so it can't be removed. Take them off those pages first.",
1428
+ "plugins.removeKeepsMedia": "{title} keeps your site's media, which pages show from its storage. Move the media back into the site's own files on the Media screen first.",
1429
+ "plugins.removeMessage": "Remove the {title} plugin",
1430
+ "plugins.removed": "{title} is removed.",
1431
+ "plugins.error.record": "The record of which plugins are installed can't be read.",
1432
+ "plugins.error.unreachable": "Plugins can't be looked up right now. Check your internet connection and try again.",
1433
+ "plugins.error.badFile": "This plugin has a problem in its files, so it can't be installed. Its publisher needs to fix it.",
1434
+ "plugins.error.notAPlugin": "This package isn't a Goodfellow plugin.",
1435
+ "plugins.error.config": "Your site's settings file lists plugins of its own, so a developer needs to add the ones installed here to it first.",
1436
+ "plugins.error.inCode": "A developer added this plugin to the site's code, so it's changed there.",
1437
+ "plugins.error.blockClash": "This plugin's blocks have the same names as ones your site has: {blocks}.",
1438
+ "plugins.error.notInstalled": "This plugin wasn't installed here.",
1439
+ "updates.pluginRange": "{title}, a plugin installed here, says it works with Goodfellow {range}, so your site may not build with Goodfellow {version} until the plugin has a newer version.",
1440
+ "updates.themeRange": "Your theme, {title}, says it works with Goodfellow {range}. Goodfellow {version} may change how it looks, so check Themes for a newer version of it after updating.",
1127
1441
  "siteHistory.intro": "Every publish that changed the site's pages, items, settings or media, newest first. Choose one to see how the site looked after it and what has changed since, then restore the whole site as it was.",
1128
- "siteHistory.local": "Site history is in the published site's admin panel, where you sign in with GitHub or GitLab.",
1442
+ "siteHistory.local": "Site history is in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
1129
1443
  "siteHistory.demo": "Site history isn't kept in a demo.",
1130
1444
  "siteHistory.error": "The site's history couldn't be loaded. Check your connection and try again.",
1131
1445
  "siteHistory.current": "The site now",
@@ -1153,6 +1467,7 @@ const defaultStrings = {
1153
1467
  "siteHistory.subject.redirects": "Redirects",
1154
1468
  "siteHistory.subject.credits": "Photo credits",
1155
1469
  "siteHistory.subject.css": "Custom CSS",
1470
+ "siteHistory.subject.theme": "Which theme is installed",
1156
1471
  "siteHistory.subject.code": "Code added to every page",
1157
1472
  "import.title": "Import from WordPress",
1158
1473
  "import.intro": "Bring a WordPress site's pages, posts, categories, tags, menus and media into this site. Pages keep their addresses, posts become a collection, and old addresses send visitors on to the new ones. You'll see what comes across before anything changes.",
@@ -1190,7 +1505,7 @@ const defaultStrings = {
1190
1505
  "import.note.embed": "Something from {name} is now a link to it: {items}.",
1191
1506
  "import.note.form": "A form was left out. Add a Form block in its place: {items}.",
1192
1507
  "import.note.script": "A script was left out: {items}.",
1193
- "import.note.table": "A table became text, since pages have no tables: {items}.",
1508
+ "import.note.table": "A table became text, since the site has no Table block: {items}.",
1194
1509
  "import.note.posts": "A list of the latest posts was left out of a post: {items}.",
1195
1510
  "import.note.video": "A video is now a link to it, since posts show videos as links: {items}.",
1196
1511
  "import.note.oldLinks": "Links that still go to the WordPress site, to pages that weren't imported: {count}, such as {url}.",
@@ -1232,7 +1547,7 @@ const defaultStrings = {
1232
1547
  "updates.code.link": "See it on {host}",
1233
1548
  "updates.noAccess": "Your sign-in can't see the site's code, at {place}, where its updates are installed. Ask the site's developer to give you access to it. On GitHub, an access token needs to reach it too, or with Sign in with GitHub, Goodfellow's app needs to be installed there.",
1234
1549
  "updates.back.skip": "Don't install Goodfellow {version} again",
1235
- "updates.local": "Updates are shown in the published site's admin panel, where you sign in with GitHub or GitLab.",
1550
+ "updates.local": "Updates are shown in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
1236
1551
  "updates.demo": "Updates aren't installed in a demo.",
1237
1552
  "updates.loading": "Checking for updates…",
1238
1553
  "updates.current": "This site uses Goodfellow {version}.",
@@ -1299,8 +1614,8 @@ const defaultStrings = {
1299
1614
  "updates.error.save": "The change couldn't be saved. Try again in a moment.",
1300
1615
  "updates.error.other": "Something went wrong. Try again in a moment.",
1301
1616
  "editors.intro": "The people who can edit this site.",
1302
- "editors.roles": "Editors can change and publish everything in the admin panel. Owners can also invite and remove editors, and connect a domain.",
1303
- "editors.local": "Editors are invited in the published site's admin panel, where you sign in with GitHub or GitLab.",
1617
+ "editors.roles": "Editors can change and publish everything in the admin panel, unless their limits below say otherwise. Owners can also invite and remove editors, limit what they can change, and connect a domain.",
1618
+ "editors.local": "Editors are invited in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
1304
1619
  "editors.demo": "Editors can't be invited in a demo.",
1305
1620
  "editors.loading": "Loading editors…",
1306
1621
  "editors.onlyOwners": "Only the site's owners can invite and remove editors.",
@@ -1325,6 +1640,7 @@ const defaultStrings = {
1325
1640
  "editors.invite.title": "Invite someone",
1326
1641
  "editors.invite.field": "Their {host} username",
1327
1642
  "editors.invite.hint": "They need a {host} account. {host} emails them an invitation, which they accept before they can sign in here.",
1643
+ "editors.invite.hintMembers": "They need to be in the site's {host} workspace already. They can edit as soon as you add them.",
1328
1644
  "editors.invite.fieldEmail": "Their email address or {host} username",
1329
1645
  "editors.invite.hintEmail": "With an email address, {host} emails them an invitation, and they make an account if they don't have one.",
1330
1646
  "editors.invite.submit": "Invite",
@@ -1334,6 +1650,33 @@ const defaultStrings = {
1334
1650
  "editors.error.invalid": "That isn't a username or email address that {host} accepts.",
1335
1651
  "editors.error.already": "They already edit the site, or have been invited.",
1336
1652
  "editors.error.other": "Something went wrong. Try again in a moment.",
1653
+ "permissions.title": "Limits",
1654
+ "permissions.intro": "Limit what someone can change: some pages, some collections, the media library, the header and footer, or the menus. Anyone not listed here can change everything their role allows. Changes outside someone's limits go to a reviewer, who publishes them or not.",
1655
+ "permissions.where": "The admin panel keeps people to their limits, so nobody changes the wrong page by mistake. People who can publish to the site on {host} could still change other files there, so only a site with its own server holds everyone to them.",
1656
+ "permissions.whereServer": "The site's server holds everyone to their limits, whatever they try.",
1657
+ "permissions.host": "the site's host",
1658
+ "permissions.person": "Their username",
1659
+ "permissions.personHint": "The name they sign in with, as the list above shows it.",
1660
+ "permissions.add": "Limit what they can change",
1661
+ "permissions.change": "Change",
1662
+ "permissions.remove": "Remove limits",
1663
+ "permissions.nothing": "Nothing yet",
1664
+ "permissions.allPages": "Every page",
1665
+ "permissions.editTitle": "What {name} can change",
1666
+ "permissions.editIntro": "Tick what they can change. They can look at everything else, and submit changes to it for review.",
1667
+ "permissions.pages": "Pages",
1668
+ "permissions.pagesHint": "Each with the pages under it, and their translations.",
1669
+ "permissions.collections": "Collections' items",
1670
+ "permissions.more": "Also",
1671
+ "permissions.languages": "Languages",
1672
+ "permissions.everyLanguage": "Every language",
1673
+ "permissions.save": "Publish limits",
1674
+ "permissions.message": "Change what {name} can change",
1675
+ "permissions.removeMessage": "Let {name} change everything",
1676
+ "permissions.reviewers": "Who reviews changes outside someone's limits",
1677
+ "permissions.reviewersHint": "Usernames, separated by commas. Leave it empty for every owner.",
1678
+ "permissions.saveReviewers": "Publish reviewers",
1679
+ "permissions.reviewersMessage": "Change who reviews changes outside editors' limits",
1337
1680
  "general.siteTitle": "Site name",
1338
1681
  "general.description": "Description",
1339
1682
  "general.descriptionHint": "Shown by search engines when a page doesn't have its own description.",
@@ -1411,6 +1754,12 @@ const defaultStrings = {
1411
1754
  "theme.bodyFont": "Text",
1412
1755
  "theme.radius": "Corner rounding",
1413
1756
  "theme.radiusHint": "Such as 0.5rem, or 0 for square corners.",
1757
+ "theme.parts": "Blocks' parts",
1758
+ "theme.partsHint": "Give a part of every block of one kind classes, such as rounded-xl shadow-sm, to change how they all look. They come after the block's own classes and before what's chosen for each block.",
1759
+ "theme.partsBlock": "Block",
1760
+ "theme.partsBlockStyled": "{block} ({count} styled)",
1761
+ "theme.partsName": "Its name is {name}, for custom CSS.",
1762
+ "theme.partsNone": "This site's blocks have no named parts.",
1414
1763
  "theme.color.background": "Background",
1415
1764
  "theme.color.foreground": "Text",
1416
1765
  "theme.color.primary": "Main color",
@@ -1766,120 +2115,6 @@ function draftStarted(name) {
1766
2115
  });
1767
2116
  }
1768
2117
  //#endregion
1769
- //#region src/frame/channel.ts
1770
- /**
1771
- * Requests and events between the admin panel and a frame running the site's
1772
- * code, over one `MessagePort`. The admin panel's side treats everything a
1773
- * frame sends as untrusted: the site's blocks run there, and could send anything.
1774
- */
1775
- /** The message the admin panel's page sends a frame once it has loaded, with the port to talk on. */
1776
- const CONNECT_MESSAGE = "goodfellow:connect";
1777
- /** A request the other side refused or couldn't answer. */
1778
- var ChannelError = class extends Error {
1779
- name = "ChannelError";
1780
- };
1781
- function isMessage(data) {
1782
- if (typeof data !== "object" || data === null) return false;
1783
- const type = data.type;
1784
- return type === "request" || type === "response" || type === "event";
1785
- }
1786
- /** What an error says, as text: errors can't cross the port as they are. */
1787
- function errorText(error) {
1788
- return error instanceof Error ? error.message : String(error);
1789
- }
1790
- function createChannel(port, handlers = {}) {
1791
- const own = { ...handlers };
1792
- const pending = /* @__PURE__ */ new Map();
1793
- const listeners = /* @__PURE__ */ new Map();
1794
- let next = 1;
1795
- let closed = false;
1796
- port.onmessage = async (event) => {
1797
- const message = event.data;
1798
- if (!isMessage(message)) return;
1799
- if (message.type === "response") {
1800
- const waiting = pending.get(message.id);
1801
- if (!waiting) return;
1802
- pending.delete(message.id);
1803
- if (message.ok) waiting.resolve(message.result);
1804
- else waiting.reject(new ChannelError(message.error));
1805
- return;
1806
- }
1807
- if (message.type === "event") {
1808
- for (const listener of listeners.get(message.name) ?? []) listener(message.payload);
1809
- return;
1810
- }
1811
- const handler = typeof message.method === "string" ? Object.hasOwn(own, message.method) && own[message.method] : void 0;
1812
- if (!handler) {
1813
- port.postMessage({
1814
- type: "response",
1815
- id: message.id,
1816
- ok: false,
1817
- error: `No such request: ${message.method}`
1818
- });
1819
- return;
1820
- }
1821
- try {
1822
- const result = await handler(message.params);
1823
- if (!closed) port.postMessage({
1824
- type: "response",
1825
- id: message.id,
1826
- ok: true,
1827
- result
1828
- });
1829
- } catch (error) {
1830
- if (!closed) port.postMessage({
1831
- type: "response",
1832
- id: message.id,
1833
- ok: false,
1834
- error: errorText(error)
1835
- });
1836
- }
1837
- };
1838
- return {
1839
- call(method, params, transfer = []) {
1840
- if (closed) return Promise.reject(new ChannelError("The frame has closed."));
1841
- const id = next++;
1842
- return new Promise((resolve, reject) => {
1843
- pending.set(id, {
1844
- resolve,
1845
- reject
1846
- });
1847
- port.postMessage({
1848
- type: "request",
1849
- id,
1850
- method,
1851
- params
1852
- }, transfer);
1853
- });
1854
- },
1855
- emit(name, payload) {
1856
- if (!closed) port.postMessage({
1857
- type: "event",
1858
- name,
1859
- payload
1860
- });
1861
- },
1862
- on(name, listener) {
1863
- const set = listeners.get(name) ?? /* @__PURE__ */ new Set();
1864
- listeners.set(name, set);
1865
- set.add(listener);
1866
- return () => set.delete(listener);
1867
- },
1868
- handle(method, handler) {
1869
- own[method] = handler;
1870
- return () => {
1871
- if (own[method] === handler) delete own[method];
1872
- };
1873
- },
1874
- close() {
1875
- closed = true;
1876
- port.close();
1877
- for (const waiting of pending.values()) waiting.reject(new ChannelError("The frame has closed."));
1878
- pending.clear();
1879
- }
1880
- };
1881
- }
1882
- //#endregion
1883
2118
  //#region src/frame/protocol.ts
1884
2119
  /** A frame sends this to the admin panel's page once it's listening, and the page answers with a port. */
1885
2120
  const READY_MESSAGE = "goodfellow:frame-ready";
@@ -1894,7 +2129,7 @@ function frameClient(channel) {
1894
2129
  };
1895
2130
  }
1896
2131
  /** How many times a frame may start again, such as after the development server reloads it, before it's given up on. */
1897
- const MAX_CONNECTIONS = 5;
2132
+ const MAX_CONNECTIONS$1 = 5;
1898
2133
  /** Each frame's way of loading its page again, for `reloadFrames()`. */
1899
2134
  const reloaders = /* @__PURE__ */ new Set();
1900
2135
  /**
@@ -1919,7 +2154,7 @@ function useFrameServices() {
1919
2154
  return value;
1920
2155
  }
1921
2156
  /** Whether a frame asked to go somewhere it may: one of the admin panel's own screens. */
1922
- function isAdminScreen(href) {
2157
+ function isAdminScreen$1(href) {
1923
2158
  return typeof href === "string" && /^#\/[\w\-/?=&%.]*$/.test(href);
1924
2159
  }
1925
2160
  /** Makes a frame of the site's code and connects to it. */
@@ -1945,13 +2180,17 @@ function useFrame({ mode, onConnect, media }) {
1945
2180
  let stopped = false;
1946
2181
  const handlers = {
1947
2182
  navigate: (href) => {
1948
- if (isAdminScreen(href)) latest.current.services.navigate(href);
2183
+ if (isAdminScreen$1(href)) latest.current.services.navigate(href);
1949
2184
  },
1950
2185
  "media.choose": (params) => {
1951
2186
  const kind = params?.kind;
1952
- if (kind !== "image" && kind !== "video" && kind !== "file") return null;
2187
+ if (kind !== "image" && kind !== "video" && kind !== "audio" && kind !== "file") return null;
1953
2188
  return latest.current.services.chooseMedia(kind, typeof params.value === "string" ? params.value : "");
1954
2189
  },
2190
+ "plugin.call": (params) => {
2191
+ if (mode !== "editor" || typeof params?.plugin !== "string" || typeof params.method !== "string") return null;
2192
+ return latest.current.services.callPlugin(params.plugin, params.method, Array.isArray(params.args) ? params.args : []);
2193
+ },
1955
2194
  "media.bytes": async (url) => {
1956
2195
  if (typeof url !== "string" || !url.startsWith("/media/") || url.includes("..")) return null;
1957
2196
  return await (latest.current.media ?? latest.current.services.mediaPreviews).bytes(url) ?? null;
@@ -1963,7 +2202,7 @@ function useFrame({ mode, onConnect, media }) {
1963
2202
  current?.close();
1964
2203
  setClient(void 0);
1965
2204
  connections += 1;
1966
- if (connections > MAX_CONNECTIONS) {
2205
+ if (connections > MAX_CONNECTIONS$1) {
1967
2206
  setFailed(true);
1968
2207
  return;
1969
2208
  }
@@ -2065,10 +2304,16 @@ function frameSiteCode(client) {
2065
2304
  targets,
2066
2305
  today
2067
2306
  });
2307
+ },
2308
+ async drawBlock(content, target) {
2309
+ return (await client()).call("code.drawBlock", {
2310
+ content,
2311
+ target
2312
+ });
2068
2313
  }
2069
2314
  };
2070
2315
  }
2071
- const HIDDEN = {
2316
+ const HIDDEN$1 = {
2072
2317
  position: "absolute",
2073
2318
  width: 0,
2074
2319
  height: 0,
@@ -2086,138 +2331,884 @@ function SiteCodeFrame({ onClient }) {
2086
2331
  title: "",
2087
2332
  "aria-hidden": "true",
2088
2333
  tabIndex: -1,
2089
- style: HIDDEN,
2334
+ style: HIDDEN$1,
2090
2335
  ...frameProps
2091
2336
  });
2092
2337
  }
2093
2338
  //#endregion
2094
- //#region src/publish-failure.tsx
2095
- /** Explains why a publish from a dialog or form didn't go through. */
2096
- function PublishFailure({ failure }) {
2339
+ //#region src/limits-dialog.tsx
2340
+ /** What a change outside someone's limits touches, in words. Files that are none of those are named once, together. */
2341
+ function describeLimitProblems(t, content, problems) {
2342
+ const described = /* @__PURE__ */ new Set();
2343
+ const pages = allPages(content);
2344
+ for (const problem of problems) if (problem.kind === "page") {
2345
+ const title = (pages.find((found) => found.path === problem.address)?.content.data.root.props)?.title;
2346
+ described.add(t("limits.page", { name: typeof title === "string" && title ? title : problem.address }));
2347
+ } else if (problem.kind === "entry") {
2348
+ const collection = content.collections.find((found) => found.id === problem.collection);
2349
+ const title = (collection?.entries.find((found) => found.slug === problem.slug))?.content.fields.title;
2350
+ described.add(t("limits.entry", {
2351
+ name: typeof title === "string" && title ? title : problem.slug,
2352
+ collection: collection?.settings.name ?? problem.collection
2353
+ }));
2354
+ } else described.add(t(`limits.${problem.kind}`));
2355
+ return [...described];
2356
+ }
2357
+ /**
2358
+ * Changes outside what someone may change: listed, with what they can do
2359
+ * instead, submitting them for review where the site can take one.
2360
+ */
2361
+ function LimitsDialog({ problems, content, onSubmit, onClose }) {
2097
2362
  const t = useStrings();
2098
- if (!failure || failure.reason === "checks") return null;
2099
- return /* @__PURE__ */ jsx(ErrorMessage, {
2100
- message: t(failure.reason === "conflict" ? "publish.conflict" : "publish.error"),
2101
- error: failure.error
2363
+ const [state, setState] = useState({ type: "asking" });
2364
+ const submit = async () => {
2365
+ if (!onSubmit) return;
2366
+ setState({ type: "submitting" });
2367
+ try {
2368
+ setState({
2369
+ type: "submitted",
2370
+ ...await onSubmit()
2371
+ });
2372
+ } catch (error) {
2373
+ setState({
2374
+ type: "error",
2375
+ error
2376
+ });
2377
+ }
2378
+ };
2379
+ return /* @__PURE__ */ jsxs(Dialog, {
2380
+ title: t("limits.title"),
2381
+ onClose,
2382
+ children: [
2383
+ /* @__PURE__ */ jsx("p", { children: t("limits.intro") }),
2384
+ /* @__PURE__ */ jsx("ul", {
2385
+ className: "gfa-check-list",
2386
+ children: describeLimitProblems(t, content, problems).map((line) => /* @__PURE__ */ jsx("li", { children: line }, line))
2387
+ }),
2388
+ state.type === "submitted" ? /* @__PURE__ */ jsxs("p", {
2389
+ role: "status",
2390
+ children: [
2391
+ t("limits.submitted"),
2392
+ " ",
2393
+ state.url && /* @__PURE__ */ jsx("a", {
2394
+ href: state.url,
2395
+ target: "_blank",
2396
+ rel: "noreferrer",
2397
+ children: t("draft.seeReview")
2398
+ })
2399
+ ]
2400
+ }) : /* @__PURE__ */ jsx("p", { children: t(onSubmit ? "limits.review" : "limits.noReview") }),
2401
+ state.type === "error" && /* @__PURE__ */ jsx(ErrorMessage, {
2402
+ message: t("limits.submitError"),
2403
+ error: state.error
2404
+ }),
2405
+ /* @__PURE__ */ jsxs("div", {
2406
+ className: "gfa-dialog-actions",
2407
+ children: [/* @__PURE__ */ jsx(Button, {
2408
+ onClick: onClose,
2409
+ children: t(state.type === "submitted" ? "action.close" : "limits.goBack")
2410
+ }), onSubmit && state.type !== "submitted" && /* @__PURE__ */ jsx(Button, {
2411
+ variant: "primary",
2412
+ disabled: state.type === "submitting",
2413
+ onClick: () => void submit(),
2414
+ children: t(state.type === "submitting" ? "limits.submitting" : "draft.submit")
2415
+ })]
2416
+ })
2417
+ ]
2102
2418
  });
2103
2419
  }
2104
2420
  //#endregion
2105
- //#region src/site-host.ts
2421
+ //#region src/limits.ts
2106
2422
  /**
2107
- * The host outside GitHub and GitLab that builds the site, as its setup files
2108
- * say, such as Cloudflare Pages: `undefined` while they're read, `null` for none.
2109
- * They're outside the folders the site's files are read from, so they're read
2110
- * as version history reads files, at the loaded revision.
2423
+ * What the signed-in person may change, for screens that show the rest
2424
+ * read-only. Publishing checks every change again (`outsideLimits()`), so
2425
+ * these only spare people changes they couldn't publish.
2111
2426
  */
2112
- function useOutsideHost() {
2113
- const { readFile, versions, state } = useAdmin();
2114
- const revision = state.status === "ready" ? state.revision : void 0;
2115
- const [host, setHost] = useState();
2116
- useEffect(() => {
2117
- let cancelled = false;
2118
- outsideHost(versions && revision ? (path) => versions.readAt(path, revision) : readFile).then((found) => !cancelled && setHost(found ?? null), () => !cancelled && setHost(null));
2119
- return () => {
2120
- cancelled = true;
2121
- };
2122
- }, [
2123
- readFile,
2124
- versions,
2125
- revision
2126
- ]);
2127
- return host;
2427
+ /** Whether someone with `limits` may change what's open. Collections' templates are their settings, which limits never cover. */
2428
+ function mayChange(content, limits, what) {
2429
+ if (!limits) return true;
2430
+ switch (what.kind) {
2431
+ case "page": {
2432
+ const page = content.pages.find((found) => found.path === what.path);
2433
+ return mayChangePage(content, limits, page ?? { path: what.path });
2434
+ }
2435
+ case "header":
2436
+ case "footer": return mayChangeLayout(content, limits, what.language);
2437
+ case "template": return false;
2438
+ case "entry": {
2439
+ const entry = content.collections.find((found) => found.id === what.collection)?.entries.find((found) => found.slug === what.entry);
2440
+ return entry ? mayChangeEntry(content, limits, entry) : (limits.collections ?? []).includes(what.collection);
2441
+ }
2442
+ case "media": return mayChangeMedia(limits);
2443
+ case "menus": return mayChangeMenus(content, limits, what.language);
2444
+ }
2445
+ }
2446
+ /** Whether the signed-in person may change what's open, once the site has loaded. */
2447
+ function useMayChange(what) {
2448
+ const { state, limits } = useAdmin();
2449
+ return state.status !== "ready" || mayChange(state.content, limits, what);
2128
2450
  }
2129
2451
  //#endregion
2130
- //#region src/upload.ts
2131
- /** Photos are made no larger than this on their longest side, which is plenty for any screen. */
2132
- const MAX_IMAGE_SIDE = 2400;
2133
- var UploadError = class extends Error {
2134
- problem;
2135
- file;
2136
- name = "UploadError";
2137
- constructor(problem, file, message) {
2138
- super(message);
2139
- this.problem = problem;
2140
- this.file = file;
2141
- }
2142
- };
2143
- const REENCODED = {
2452
+ //#region src/media-previews.ts
2453
+ const TYPES = {
2144
2454
  jpg: "image/jpeg",
2145
2455
  jpeg: "image/jpeg",
2146
2456
  png: "image/png",
2147
- webp: "image/webp"
2457
+ gif: "image/gif",
2458
+ webp: "image/webp",
2459
+ avif: "image/avif",
2460
+ svg: "image/svg+xml",
2461
+ ico: "image/x-icon",
2462
+ pdf: "application/pdf",
2463
+ mp3: "audio/mpeg",
2464
+ m4a: "audio/mp4",
2465
+ mp4: "video/mp4",
2466
+ webm: "video/webm",
2467
+ vtt: "text/vtt"
2148
2468
  };
2149
- async function canvasBlob(canvas, type) {
2150
- return new Promise((resolve) => canvas.toBlob(resolve, type, .85));
2469
+ /** The type to show a file as, from its name. Needed so browsers display SVGs from memory. */
2470
+ function mediaType(name) {
2471
+ return TYPES[extensionOf(name)] ?? "application/octet-stream";
2151
2472
  }
2152
- /**
2153
- * Re-saves a photo: smaller if it's larger than any screen needs, and always
2154
- * for JPEGs, which drops details hidden in the file such as where it was taken.
2155
- * Returns `undefined` to keep the file as it is.
2156
- */
2157
- async function resized(file, extension) {
2158
- const type = REENCODED[extension];
2159
- if (!type || typeof createImageBitmap !== "function") return void 0;
2160
- let bitmap;
2161
- try {
2162
- bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
2163
- } catch {
2164
- throw new UploadError("unreadable", file.name, `${file.name} couldn't be read as an image.`);
2165
- }
2166
- const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(bitmap.width, bitmap.height));
2167
- if (scale === 1 && type !== "image/jpeg") {
2168
- bitmap.close();
2169
- return;
2170
- }
2171
- const canvas = document.createElement("canvas");
2172
- canvas.width = Math.round(bitmap.width * scale);
2173
- canvas.height = Math.round(bitmap.height * scale);
2174
- canvas.getContext("2d")?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
2175
- bitmap.close();
2176
- const blob = await canvasBlob(canvas, type);
2177
- return blob && (scale < 1 || blob.size < file.size) ? blob : void 0;
2473
+ function createMediaPreviews(store, siteUrl, storage = () => void 0) {
2474
+ const local = /* @__PURE__ */ new Map();
2475
+ const remembered = /* @__PURE__ */ new Map();
2476
+ const listeners = /* @__PURE__ */ new Set();
2477
+ const loading = /* @__PURE__ */ new Map();
2478
+ const base = trimChars(siteUrl, "/", { start: false });
2479
+ const remember = (url, bytes) => {
2480
+ const previous = local.get(url);
2481
+ if (previous) URL.revokeObjectURL(previous);
2482
+ remembered.set(url, bytes);
2483
+ for (const listener of listeners) listener(url, bytes);
2484
+ local.set(url, URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: mediaType(url) })));
2485
+ };
2486
+ return {
2487
+ resolve(url) {
2488
+ if (!url.startsWith("/media/")) return url;
2489
+ const end = url.search(/[?#]/);
2490
+ const stored = local.has(url) ? void 0 : storedMediaUrl(storage(), end < 0 ? url : url.slice(0, end));
2491
+ return stored ? `${stored}${end < 0 ? "" : url.slice(end)}` : local.get(url) ?? `${base}${url}`;
2492
+ },
2493
+ remember,
2494
+ onRemember(listener) {
2495
+ for (const [url, bytes] of remembered) listener(url, bytes);
2496
+ listeners.add(listener);
2497
+ return () => listeners.delete(listener);
2498
+ },
2499
+ async bytes(url) {
2500
+ const known = remembered.get(url);
2501
+ if (known) return known;
2502
+ const path = mediaPath(url);
2503
+ return path ? await store.readBytes(path).catch(() => void 0) : void 0;
2504
+ },
2505
+ load(url) {
2506
+ const known = local.get(url);
2507
+ if (known) return Promise.resolve(known);
2508
+ const path = mediaPath(url);
2509
+ if (!path) return Promise.resolve(void 0);
2510
+ let pending = loading.get(url);
2511
+ if (!pending) {
2512
+ pending = store.readBytes(path).then((bytes) => {
2513
+ if (!bytes) return void 0;
2514
+ remember(url, bytes);
2515
+ return local.get(url);
2516
+ }).catch(() => void 0);
2517
+ loading.set(url, pending);
2518
+ }
2519
+ return pending;
2520
+ }
2521
+ };
2178
2522
  }
2179
- const UNSAFE_SVG_ELEMENTS = [
2180
- "script",
2181
- "foreignObject",
2182
- "iframe",
2183
- "embed",
2184
- "object",
2185
- "audio",
2186
- "video"
2523
+ const URL_ATTRIBUTES = [
2524
+ "src",
2525
+ "href",
2526
+ "poster"
2187
2527
  ];
2528
+ const MEDIA_IN_STYLE = /url\((['"]?)(\/media\/[^'")]+)\1\)/g;
2188
2529
  /**
2189
- * Removes anything from an SVG that could run code if someone opens the
2190
- * file directly: scripts, embedded pages, event handlers, and javascript:, vbscript:
2191
- * and data: links other than images.
2530
+ * Keeps media in a preview document showing: rewrites `/media/…` addresses in
2531
+ * images, links and inline styles as the preview changes, and if an image
2532
+ * still doesn't load, reads it from the repository instead.
2192
2533
  */
2193
- function cleanSvg(text) {
2194
- const doc = new DOMParser().parseFromString(text, "image/svg+xml");
2195
- if (doc.querySelector("parsererror") || doc.documentElement.nodeName.toLowerCase() !== "svg") return void 0;
2196
- for (const name of UNSAFE_SVG_ELEMENTS) for (const element of [...doc.getElementsByTagName(name)]) element.remove();
2197
- for (const element of [doc.documentElement, ...doc.documentElement.querySelectorAll("*")]) for (const attribute of [...element.attributes]) {
2198
- const value = attribute.value.trim().toLowerCase();
2199
- if (attribute.name.toLowerCase().startsWith("on") || value.startsWith("javascript:") || value.startsWith("vbscript:") || value.startsWith("data:") && !value.startsWith("data:image/")) element.removeAttribute(attribute.name);
2200
- }
2201
- return new XMLSerializer().serializeToString(doc);
2202
- }
2203
- /** Checks a file chosen for upload and gets it ready: photos made web-sized, SVGs made safe. */
2204
- async function prepareUpload(file) {
2205
- const extension = extensionOf(file.name);
2206
- if (!mediaKind$1(file.name)) throw new UploadError("type", file.name, `${file.name} isn't a kind of file the site can use.`);
2207
- let blob = file;
2208
- if (extension === "svg") {
2209
- const cleaned = cleanSvg(await file.text());
2210
- if (cleaned === void 0) throw new UploadError("unreadable", file.name, `${file.name} isn't a valid SVG image.`);
2211
- blob = new Blob([cleaned], { type: "image/svg+xml" });
2212
- } else blob = await resized(file, extension) ?? file;
2213
- if (blob.size > MAX_UPLOAD_BYTES) throw new UploadError("size", file.name, `${file.name} is ${blob.size} bytes, over the ${MAX_UPLOAD_BYTES}-byte limit.`);
2214
- return {
2215
- name: file.name,
2216
- bytes: new Uint8Array(await blob.arrayBuffer()),
2217
- type: blob.type || file.type
2218
- };
2219
- }
2220
- //#endregion
2534
+ function showMediaInPreview(doc, previews) {
2535
+ const fix = (element) => {
2536
+ for (const attribute of URL_ATTRIBUTES) {
2537
+ const value = element.getAttribute(attribute);
2538
+ if (!value?.startsWith("/media/")) continue;
2539
+ const resolved = previews.resolve(value);
2540
+ if (resolved !== value) {
2541
+ element.setAttribute(`data-gf-${attribute}`, value);
2542
+ element.setAttribute(attribute, resolved);
2543
+ }
2544
+ }
2545
+ const style = element.getAttribute("style");
2546
+ if (style?.includes("/media/")) {
2547
+ const resolved = style.replace(MEDIA_IN_STYLE, (_match, quote, url) => `url(${quote}${previews.resolve(url)}${quote})`);
2548
+ if (resolved !== style) element.setAttribute("style", resolved);
2549
+ }
2550
+ };
2551
+ const fixTree = (root) => {
2552
+ fix(root);
2553
+ for (const element of root.querySelectorAll("[src], [href], [poster], [style]")) fix(element);
2554
+ };
2555
+ const observer = new MutationObserver((records) => {
2556
+ for (const record of records) {
2557
+ if (record.type === "attributes" && record.target.nodeType === 1) fix(record.target);
2558
+ for (const node of record.addedNodes) if (node.nodeType === 1) fixTree(node);
2559
+ }
2560
+ });
2561
+ observer.observe(doc.documentElement, {
2562
+ subtree: true,
2563
+ childList: true,
2564
+ attributes: true,
2565
+ attributeFilter: [...URL_ATTRIBUTES, "style"]
2566
+ });
2567
+ fixTree(doc.documentElement);
2568
+ const onError = (event) => {
2569
+ const image = event.target;
2570
+ if (image?.tagName !== "IMG") return;
2571
+ const original = image.getAttribute("data-gf-src") ?? image.getAttribute("src") ?? "";
2572
+ if (!original.startsWith("/media/") || image.hasAttribute("data-gf-loaded")) return;
2573
+ image.setAttribute("data-gf-loaded", "");
2574
+ previews.load(original).then((url) => {
2575
+ if (url) {
2576
+ image.setAttribute("data-gf-src", original);
2577
+ image.setAttribute("src", url);
2578
+ }
2579
+ });
2580
+ };
2581
+ doc.addEventListener("error", onError, true);
2582
+ return () => {
2583
+ observer.disconnect();
2584
+ doc.removeEventListener("error", onError, true);
2585
+ };
2586
+ }
2587
+ //#endregion
2588
+ //#region src/plugin-frames.tsx
2589
+ /**
2590
+ * The admin panel's side of plugins' admin frames: making them, connecting to
2591
+ * them, and answering only for what each plugin declared. Everything a frame
2592
+ * sends is treated as the plugin's code may have written it.
2593
+ */
2594
+ /** A plugin's frame asked for something it didn't declare, or that isn't there. */
2595
+ var RefusedError = class extends Error {
2596
+ name = "RefusedError";
2597
+ };
2598
+ /** How many times a plugin's frame may start again before it's given up on. */
2599
+ const MAX_CONNECTIONS = 5;
2600
+ /** How long a service call waits for the plugin's frame to start. */
2601
+ const START_TIMEOUT = 2e4;
2602
+ function isAdminScreen(href) {
2603
+ return typeof href === "string" && /^#\/[\w\-/?=&%.]*$/.test(href);
2604
+ }
2605
+ function strings(value, what) {
2606
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new RefusedError(`Say which ${what}.`);
2607
+ return value;
2608
+ }
2609
+ /** A plugin's file change, as the admin panel publishes it, if the plugin declared its file. */
2610
+ function toFileChange(plugin, value) {
2611
+ if (typeof value !== "object" || value === null) throw new RefusedError("That isn't a change.");
2612
+ const change = value;
2613
+ if (typeof change.path !== "string" || !pluginMayUse(plugin.permissions, change.path)) throw new RefusedError(`${plugin.title} didn't ask to change ${String(change.path)}.`);
2614
+ if (change.delete === true) return {
2615
+ path: change.path,
2616
+ delete: true
2617
+ };
2618
+ if (typeof change.text === "string") return {
2619
+ path: change.path,
2620
+ content: change.text
2621
+ };
2622
+ if (change.bytes instanceof Uint8Array) return {
2623
+ path: change.path,
2624
+ bytes: change.bytes
2625
+ };
2626
+ throw new RefusedError("That isn't a change.");
2627
+ }
2628
+ /** The tallest a screen's frame is made, whatever it says. */
2629
+ const MAX_SCREEN_HEIGHT = 2e4;
2630
+ const MEDIA_KINDS = /* @__PURE__ */ new Set([
2631
+ "image",
2632
+ "video",
2633
+ "audio",
2634
+ "file"
2635
+ ]);
2636
+ /** The answers a plugin's frame gets, each checked against what the plugin declared. */
2637
+ function pluginHandlers(plugin, host, onResize, editor) {
2638
+ /** The page edited with the plugin's editor, for requests only its editor frame may make. */
2639
+ const editing = (what) => {
2640
+ const hooks = editor();
2641
+ if (!hooks) throw new RefusedError(`Only an editor's frame may ${what}.`);
2642
+ return hooks;
2643
+ };
2644
+ const secretsLinks = /* @__PURE__ */ new Set();
2645
+ return {
2646
+ read: async (params) => {
2647
+ const paths = strings(params?.paths, "files");
2648
+ const refused = paths.find((path) => !pluginMayUse(plugin.permissions, path));
2649
+ if (refused) throw new RefusedError(`${plugin.title} didn't ask to read ${refused}.`);
2650
+ const read = await Promise.all(paths.map(async (path) => [path, await host().read(path) ?? null]));
2651
+ return Object.fromEntries(read);
2652
+ },
2653
+ readBytes: async (params) => {
2654
+ const path = params?.path;
2655
+ if (typeof path !== "string" || !pluginMayUse(plugin.permissions, path)) throw new RefusedError(`${plugin.title} didn't ask to read ${String(path)}.`);
2656
+ return await host().readBytes(path) ?? null;
2657
+ },
2658
+ list: async (params) => {
2659
+ const folder = params?.folder;
2660
+ if (typeof folder !== "string" || !pluginMayList(plugin.permissions, folder)) throw new RefusedError(`${plugin.title} didn't ask to list ${String(folder)}.`);
2661
+ return (await host().list(folder.replace(/\/+$/, ""))).filter((path) => pluginMayUse(plugin.permissions, path));
2662
+ },
2663
+ publish: async (params) => {
2664
+ if (!Array.isArray(params?.changes) || params.changes.length === 0 || typeof params.message !== "string") throw new RefusedError("That isn't a publish.");
2665
+ const changes = params.changes.map((change) => toFileChange(plugin, change));
2666
+ return { published: await host().publish(changes, params.message.slice(0, 500)) };
2667
+ },
2668
+ server: async (params) => {
2669
+ const base = host().server;
2670
+ if (!plugin.server || !base) throw new RefusedError(`${plugin.title} has no server here.`);
2671
+ const path = params?.path;
2672
+ if (typeof path !== "string" || path.includes("..") || /^[a-z]+:/i.test(path)) throw new RefusedError("That isn't one of the plugin's addresses.");
2673
+ const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
2674
+ const headers = Object.fromEntries(Object.entries(params.headers ?? {}).filter(([name, value]) => typeof value === "string" && /^(content-type|accept|x-goodfellow-plugin-[\w-]+)$/i.test(name)));
2675
+ const response = await fetch(`${base.replace(/\/+$/, "")}/${encodeURIComponent(plugin.id)}/${path.replace(/^\/+/, "")}`, {
2676
+ method,
2677
+ headers: {
2678
+ ...headers,
2679
+ [APP_API_HEADER]: "1"
2680
+ },
2681
+ ...typeof params.body === "string" && method !== "GET" && method !== "HEAD" && { body: params.body },
2682
+ credentials: "same-origin",
2683
+ cache: "no-store"
2684
+ });
2685
+ return {
2686
+ status: response.status,
2687
+ body: await response.text()
2688
+ };
2689
+ },
2690
+ navigate: (href) => {
2691
+ if (isAdminScreen(href)) host().navigate(href);
2692
+ },
2693
+ "secrets.help": async () => {
2694
+ const help = await host().secretsHelp?.(plugin) ?? [];
2695
+ for (const { link } of help) if (link) secretsLinks.add(link.url);
2696
+ return help;
2697
+ },
2698
+ "secrets.open": (params) => {
2699
+ const url = params?.url;
2700
+ if (typeof url !== "string" || !secretsLinks.has(url)) throw new RefusedError("That isn't one of the links given.");
2701
+ window.open(url, "_blank", "noopener,noreferrer");
2702
+ },
2703
+ resize: (params) => {
2704
+ const height = params?.height;
2705
+ if (typeof height === "number" && Number.isFinite(height)) onResize(Math.min(Math.max(0, Math.ceil(height)), MAX_SCREEN_HEIGHT));
2706
+ },
2707
+ "editor.change": (params) => {
2708
+ const hooks = editing("say what's edited");
2709
+ if (typeof params !== "object" || params === null || typeof params.data !== "object" || params.data === null) throw new RefusedError("That isn't what's edited.");
2710
+ const state = typeof params.state === "object" && params.state !== null ? params.state : {};
2711
+ hooks.onChange({
2712
+ data: params.data,
2713
+ state: {
2714
+ past: state.past === true,
2715
+ future: state.future === true,
2716
+ selected: typeof state.selected === "string" ? state.selected : null
2717
+ }
2718
+ });
2719
+ },
2720
+ "media.choose": (params) => {
2721
+ const hooks = editing("choose media");
2722
+ const kind = params?.kind;
2723
+ if (typeof kind !== "string" || !MEDIA_KINDS.has(kind)) throw new RefusedError("That isn't a kind of media.");
2724
+ return hooks.chooseMedia(kind, typeof params.value === "string" ? params.value : "");
2725
+ },
2726
+ "editor.sharePreview": (params) => {
2727
+ return editing("preview a shared link").sharePreview(params?.root);
2728
+ },
2729
+ "editor.draw": (params) => {
2730
+ const hooks = editing("draw blocks");
2731
+ const block = params?.block;
2732
+ if (typeof block !== "object" || block === null || typeof block.type !== "string" || typeof block.props !== "object" || block.props === null) throw new RefusedError("That isn't a block.");
2733
+ return hooks.draw(block);
2734
+ },
2735
+ "editor.styles": () => editing("read the site's styles").styles(),
2736
+ task: async (params) => {
2737
+ const id = params?.id;
2738
+ if (typeof id !== "string" || !plugin.tasks?.some((task) => task.id === id)) throw new RefusedError(`${plugin.title} has no task called ${String(id)}.`);
2739
+ const run = host().runTask;
2740
+ if (!run) throw new RefusedError(`${plugin.title}'s tasks can't run here.`);
2741
+ let input;
2742
+ try {
2743
+ input = JSON.stringify(params.input ?? null);
2744
+ } catch {
2745
+ throw new RefusedError("A task's input must be plain data.");
2746
+ }
2747
+ if (input.length > TASK_INPUT_LIMIT) throw new RefusedError("That's too much for one task.");
2748
+ const keys = await taskKeys();
2749
+ const sealed = await run({
2750
+ id: newTaskRequestId(),
2751
+ plugin: plugin.id,
2752
+ task: id,
2753
+ input,
2754
+ key: keys.publicKey
2755
+ });
2756
+ const answer = await openTaskAnswer(sealed, keys.privateKey);
2757
+ if (!answer.ok) throw new Error(answer.error.slice(0, 2e3));
2758
+ return answer.output;
2759
+ }
2760
+ };
2761
+ }
2762
+ /** Makes one of a plugin's frames and connects to it; an editor's frame with `editor`. */
2763
+ function usePluginFrame(plugin, host, mode, screen, editor) {
2764
+ const element = useRef(null);
2765
+ const [channel, setChannel] = useState();
2766
+ const [failed, setFailed] = useState(false);
2767
+ const [height, setHeight] = useState();
2768
+ const latest = useRef(host);
2769
+ latest.current = host;
2770
+ const latestEditor = useRef(editor);
2771
+ latestEditor.current = editor;
2772
+ useEffect(() => {
2773
+ let current;
2774
+ let connections = 0;
2775
+ let stopped = false;
2776
+ const onMessage = (event) => {
2777
+ const frame = element.current?.contentWindow;
2778
+ if (!frame || event.source !== frame || event.data !== "goodfellow:plugin-ready") return;
2779
+ current?.close();
2780
+ setChannel(void 0);
2781
+ connections += 1;
2782
+ if (connections > MAX_CONNECTIONS) {
2783
+ setFailed(true);
2784
+ return;
2785
+ }
2786
+ const { port1, port2 } = new MessageChannel();
2787
+ const next = createChannel(port1, pluginHandlers(plugin, () => latest.current, (next) => {
2788
+ if (mode === "screen") setHeight(next);
2789
+ }, () => mode === "editor" ? latestEditor.current : void 0));
2790
+ current = next;
2791
+ frame.postMessage({ type: CONNECT_MESSAGE }, "*", [port2]);
2792
+ const opening = mode === "editor" ? latestEditor.current?.open() : void 0;
2793
+ const init = {
2794
+ mode,
2795
+ ...screen && { screen },
2796
+ ...opening && { editor: opening },
2797
+ plugin,
2798
+ siteUrl: latest.current.siteUrl,
2799
+ theme: latest.current.theme
2800
+ };
2801
+ next.call("init", init).then((opened) => {
2802
+ if (stopped || current !== next) return;
2803
+ setChannel(next);
2804
+ if (mode === "editor") {
2805
+ const said = opened ?? {};
2806
+ latestEditor.current?.onOpened({
2807
+ set: said.set === true,
2808
+ undo: said.undo === true
2809
+ }, next);
2810
+ }
2811
+ }, () => {
2812
+ if (!stopped && current === next) setFailed(true);
2813
+ });
2814
+ };
2815
+ window.addEventListener("message", onMessage);
2816
+ return () => {
2817
+ stopped = true;
2818
+ window.removeEventListener("message", onMessage);
2819
+ current?.close();
2820
+ };
2821
+ }, [
2822
+ plugin,
2823
+ mode,
2824
+ screen
2825
+ ]);
2826
+ useEffect(() => {
2827
+ channel?.call("theme", host.theme).catch(() => {});
2828
+ }, [channel, host.theme]);
2829
+ return {
2830
+ channel,
2831
+ failed,
2832
+ frameProps: {
2833
+ ref: useCallback((frame) => {
2834
+ element.current = frame;
2835
+ }, []),
2836
+ sandbox: FRAME_SANDBOX,
2837
+ src: pluginFrameUrl(host.editorUrl, plugin.id),
2838
+ ...height !== void 0 && { style: { height } }
2839
+ }
2840
+ };
2841
+ }
2842
+ const HIDDEN = {
2843
+ position: "absolute",
2844
+ width: 0,
2845
+ height: 0,
2846
+ border: 0,
2847
+ visibility: "hidden"
2848
+ };
2849
+ /** One plugin's hidden frame for its services, which tells `onChannel` once it has started. */
2850
+ function ServiceFrame({ plugin, host, onChannel }) {
2851
+ const { channel, frameProps } = usePluginFrame(plugin, host, "service");
2852
+ useEffect(() => onChannel(plugin.id, channel), [
2853
+ plugin.id,
2854
+ channel,
2855
+ onChannel
2856
+ ]);
2857
+ return /* @__PURE__ */ jsx("iframe", {
2858
+ title: "",
2859
+ "aria-hidden": "true",
2860
+ tabIndex: -1,
2861
+ ...frameProps,
2862
+ style: HIDDEN
2863
+ });
2864
+ }
2865
+ const PluginHostContext = createContext(null);
2866
+ const PluginMediaContext = createContext(null);
2867
+ /**
2868
+ * The plugin keeping the site's media, by its name in
2869
+ * `content/media-storage.json`, where the site has it and it says it keeps
2870
+ * media. Outside `PluginServices`, such as in the site's frames, none.
2871
+ */
2872
+ function usePluginMedia(name) {
2873
+ const media = useContext(PluginMediaContext);
2874
+ return name && media ? media(name) : void 0;
2875
+ }
2876
+ /** What a plugin's frame said about being ready, as the admin panel shows it: its message is the plugin's own words. */
2877
+ function readyAnswer(plugin, value) {
2878
+ const answer = value ?? {};
2879
+ if (answer.ready === true) return { ready: true };
2880
+ const screen = typeof answer.screen === "string" && plugin.screens?.some((own) => own.id === answer.screen) ? answer.screen : void 0;
2881
+ return {
2882
+ ready: false,
2883
+ message: typeof answer.message === "string" ? answer.message.slice(0, 500) : "",
2884
+ ...screen && { screen }
2885
+ };
2886
+ }
2887
+ /** What plugins' screens are given, inside `PluginServices`. */
2888
+ function usePluginHost() {
2889
+ const value = useContext(PluginHostContext);
2890
+ if (!value) throw new Error("Plugins' frames need the admin panel around them.");
2891
+ return value;
2892
+ }
2893
+ /**
2894
+ * Plugins' services for the editor's blocks: each plugin's hidden frame is
2895
+ * made the first time something asks it. `onCall` gets the function that
2896
+ * asks, `(plugin name, method, arguments)`.
2897
+ */
2898
+ function PluginServices({ plugins, host, onCall, children }) {
2899
+ const [started, setStarted] = useState([]);
2900
+ const ready = useRef(/* @__PURE__ */ new Map());
2901
+ const waiting = useRef(/* @__PURE__ */ new Map());
2902
+ const onChannel = useCallback((id, channel) => {
2903
+ if (!channel) {
2904
+ ready.current.delete(id);
2905
+ return;
2906
+ }
2907
+ ready.current.set(id, channel);
2908
+ for (const resolve of waiting.current.get(id)?.splice(0) ?? []) resolve(channel);
2909
+ }, []);
2910
+ const channelFor = useCallback(async (plugin) => {
2911
+ const channel = ready.current.get(plugin.id);
2912
+ if (channel) return channel;
2913
+ setStarted((current) => current.includes(plugin.id) ? current : [...current, plugin.id]);
2914
+ return await new Promise((resolve, reject) => {
2915
+ const list = waiting.current.get(plugin.id) ?? [];
2916
+ list.push(resolve);
2917
+ waiting.current.set(plugin.id, list);
2918
+ setTimeout(() => reject(/* @__PURE__ */ new Error(`${plugin.title} didn't start.`)), START_TIMEOUT);
2919
+ });
2920
+ }, []);
2921
+ const call = useCallback(async (name, method, args) => {
2922
+ const plugin = plugins.find((candidate) => candidate.name === name && candidate.admin);
2923
+ if (!plugin) throw new Error(`The site has no plugin called ${name} with services.`);
2924
+ return (await channelFor(plugin)).call("call", {
2925
+ method,
2926
+ args
2927
+ });
2928
+ }, [plugins, channelFor]);
2929
+ useEffect(() => onCall(call), [call, onCall]);
2930
+ const media = useCallback((name) => {
2931
+ const plugin = plugins.find((candidate) => candidate.name === name && candidate.admin && candidate.mediaStorage);
2932
+ if (!plugin) return void 0;
2933
+ return {
2934
+ plugin,
2935
+ ready: async () => readyAnswer(plugin, await (await channelFor(plugin)).call("media.ready")),
2936
+ upload: async (files) => {
2937
+ await (await channelFor(plugin)).call("media.upload", { files });
2938
+ },
2939
+ remove: async (addresses) => {
2940
+ await (await channelFor(plugin)).call("media.remove", { addresses });
2941
+ }
2942
+ };
2943
+ }, [plugins, channelFor]);
2944
+ return /* @__PURE__ */ jsxs(PluginHostContext.Provider, {
2945
+ value: host,
2946
+ children: [/* @__PURE__ */ jsx(PluginMediaContext.Provider, {
2947
+ value: media,
2948
+ children
2949
+ }), plugins.filter((plugin) => started.includes(plugin.id)).map((plugin) => /* @__PURE__ */ jsx(ServiceFrame, {
2950
+ plugin,
2951
+ host,
2952
+ onChannel
2953
+ }, plugin.id))]
2954
+ });
2955
+ }
2956
+ /** One of a plugin's screens, drawn by its admin module in a frame of its own. */
2957
+ function PluginScreenFrame({ plugin, screen, failedText }) {
2958
+ const { failed, frameProps } = usePluginFrame(plugin, usePluginHost(), "screen", screen.id);
2959
+ if (failed) return /* @__PURE__ */ jsx("p", {
2960
+ className: "gfa-error",
2961
+ children: failedText
2962
+ });
2963
+ return /* @__PURE__ */ jsx("iframe", {
2964
+ title: screen.title,
2965
+ className: "gfa-plugin-screen",
2966
+ ...frameProps
2967
+ });
2968
+ }
2969
+ //#endregion
2970
+ //#region src/publish-failure.tsx
2971
+ /** Explains why a publish from a dialog or form didn't go through. */
2972
+ function PublishFailure({ failure }) {
2973
+ const t = useStrings();
2974
+ if (!failure || failure.reason === "checks") return null;
2975
+ return /* @__PURE__ */ jsx(ErrorMessage, {
2976
+ message: t(failure.reason === "conflict" ? "publish.conflict" : "publish.error"),
2977
+ error: failure.error
2978
+ });
2979
+ }
2980
+ //#endregion
2981
+ //#region src/site-host.ts
2982
+ /**
2983
+ * The host outside GitHub, GitLab and Bitbucket that builds the site, as its setup files
2984
+ * say, such as Cloudflare Pages: `undefined` while they're read, `null` for none.
2985
+ * They're outside the folders the site's files are read from, so they're read
2986
+ * as version history reads files, at the loaded revision.
2987
+ */
2988
+ function useOutsideHost() {
2989
+ const { readFile, versions, state } = useAdmin();
2990
+ const revision = state.status === "ready" ? state.revision : void 0;
2991
+ const [host, setHost] = useState();
2992
+ useEffect(() => {
2993
+ let cancelled = false;
2994
+ outsideHost(versions && revision ? (path) => versions.readAt(path, revision) : readFile).then((found) => !cancelled && setHost(found ?? null), () => !cancelled && setHost(null));
2995
+ return () => {
2996
+ cancelled = true;
2997
+ };
2998
+ }, [
2999
+ readFile,
3000
+ versions,
3001
+ revision
3002
+ ]);
3003
+ return host;
3004
+ }
3005
+ //#endregion
3006
+ //#region src/upload.ts
3007
+ /** Photos are made no larger than this on their longest side, which is plenty for any screen. */
3008
+ const MAX_IMAGE_SIDE = 2400;
3009
+ var UploadError = class extends Error {
3010
+ problem;
3011
+ file;
3012
+ name = "UploadError";
3013
+ constructor(problem, file, message) {
3014
+ super(message);
3015
+ this.problem = problem;
3016
+ this.file = file;
3017
+ }
3018
+ };
3019
+ const REENCODED = {
3020
+ jpg: "image/jpeg",
3021
+ jpeg: "image/jpeg",
3022
+ png: "image/png",
3023
+ webp: "image/webp"
3024
+ };
3025
+ async function canvasBlob(canvas, type) {
3026
+ return new Promise((resolve) => canvas.toBlob(resolve, type, .85));
3027
+ }
3028
+ /**
3029
+ * Re-saves a photo: smaller if it's larger than any screen needs, and always
3030
+ * for JPEGs, which drops details hidden in the file such as where it was taken.
3031
+ * Returns `undefined` to keep the file as it is.
3032
+ */
3033
+ async function resized(file, extension) {
3034
+ const type = REENCODED[extension];
3035
+ if (!type || typeof createImageBitmap !== "function") return void 0;
3036
+ let bitmap;
3037
+ try {
3038
+ bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
3039
+ } catch {
3040
+ throw new UploadError("unreadable", file.name, `${file.name} couldn't be read as an image.`);
3041
+ }
3042
+ const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(bitmap.width, bitmap.height));
3043
+ if (scale === 1 && type !== "image/jpeg") {
3044
+ bitmap.close();
3045
+ return;
3046
+ }
3047
+ const canvas = document.createElement("canvas");
3048
+ canvas.width = Math.round(bitmap.width * scale);
3049
+ canvas.height = Math.round(bitmap.height * scale);
3050
+ canvas.getContext("2d")?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
3051
+ bitmap.close();
3052
+ const blob = await canvasBlob(canvas, type);
3053
+ return blob && (scale < 1 || blob.size < file.size) ? blob : void 0;
3054
+ }
3055
+ const UNSAFE_SVG_ELEMENTS = [
3056
+ "script",
3057
+ "foreignObject",
3058
+ "iframe",
3059
+ "embed",
3060
+ "object",
3061
+ "audio",
3062
+ "video"
3063
+ ];
3064
+ /**
3065
+ * Removes anything from an SVG that could run code if someone opens the
3066
+ * file directly: scripts, embedded pages, event handlers, and javascript:, vbscript:
3067
+ * and data: links other than images.
3068
+ */
3069
+ function cleanSvg(text) {
3070
+ const doc = new DOMParser().parseFromString(text, "image/svg+xml");
3071
+ if (doc.querySelector("parsererror") || doc.documentElement.nodeName.toLowerCase() !== "svg") return void 0;
3072
+ for (const name of UNSAFE_SVG_ELEMENTS) for (const element of [...doc.getElementsByTagName(name)]) element.remove();
3073
+ for (const element of [doc.documentElement, ...doc.documentElement.querySelectorAll("*")]) for (const attribute of [...element.attributes]) {
3074
+ const value = attribute.value.trim().toLowerCase();
3075
+ if (attribute.name.toLowerCase().startsWith("on") || value.startsWith("javascript:") || value.startsWith("vbscript:") || value.startsWith("data:") && !value.startsWith("data:image/")) element.removeAttribute(attribute.name);
3076
+ }
3077
+ return new XMLSerializer().serializeToString(doc);
3078
+ }
3079
+ /** A file checked and made ready, as a `Blob`: photos made web-sized, SVGs made safe, other files as they are. */
3080
+ async function prepared(file, maxBytes) {
3081
+ const extension = extensionOf(file.name);
3082
+ if (!mediaKind$1(file.name)) throw new UploadError("type", file.name, `${file.name} isn't a kind of file the site can use.`);
3083
+ let blob = file;
3084
+ if (extension === "svg") {
3085
+ const cleaned = cleanSvg(await file.text());
3086
+ if (cleaned === void 0) throw new UploadError("unreadable", file.name, `${file.name} isn't a valid SVG image.`);
3087
+ blob = new Blob([cleaned], { type: "image/svg+xml" });
3088
+ } else if (mediaKind$1(file.name) === "image") blob = await resized(file, extension) ?? file;
3089
+ if (blob.size > maxBytes) throw new UploadError("size", file.name, `${file.name} is ${blob.size} bytes, over the ${maxBytes}-byte limit.`);
3090
+ return blob;
3091
+ }
3092
+ /** Checks a file chosen for upload and gets it ready: photos made web-sized, SVGs made safe. */
3093
+ async function prepareUpload(file) {
3094
+ const blob = await prepared(file, MAX_UPLOAD_BYTES);
3095
+ return {
3096
+ name: file.name,
3097
+ bytes: new Uint8Array(await blob.arrayBuffer()),
3098
+ type: blob.type || file.type
3099
+ };
3100
+ }
3101
+ /** Reads a picture's size in pixels, or nothing where the browser can't. */
3102
+ async function imageSize(blob, name) {
3103
+ if (mediaKind$1(name) !== "image" || extensionOf(name) === "svg" || typeof createImageBitmap !== "function") return;
3104
+ try {
3105
+ const bitmap = await createImageBitmap(blob);
3106
+ const size = {
3107
+ width: bitmap.width,
3108
+ height: bitmap.height
3109
+ };
3110
+ bitmap.close();
3111
+ return size.width > 0 && size.height > 0 ? size : void 0;
3112
+ } catch {
3113
+ return;
3114
+ }
3115
+ }
3116
+ /**
3117
+ * Checks a file for media storage and gets it ready as `prepareUpload()`
3118
+ * does, up to the storage's largest file rather than the site's own 25 MB.
3119
+ */
3120
+ async function prepareStoredUpload(file, maxBytes) {
3121
+ const blob = await prepared(file, maxBytes);
3122
+ const type = blob.type || file.type || mediaType(file.name);
3123
+ return {
3124
+ name: file.name,
3125
+ blob,
3126
+ type,
3127
+ ...await imageSize(blob, file.name)
3128
+ };
3129
+ }
3130
+ //#endregion
3131
+ //#region src/router.tsx
3132
+ function parseHash(hash) {
3133
+ const [path = "", query = ""] = hash.replace(/^#\/?/, "").split("?");
3134
+ return {
3135
+ segments: path.split("/").filter(Boolean),
3136
+ params: new URLSearchParams(query)
3137
+ };
3138
+ }
3139
+ function subscribe$1(onChange) {
3140
+ window.addEventListener("hashchange", onChange);
3141
+ return () => window.removeEventListener("hashchange", onChange);
3142
+ }
3143
+ function useRoute() {
3144
+ return parseHash(useSyncExternalStore(subscribe$1, () => window.location.hash, () => ""));
3145
+ }
3146
+ const guards = /* @__PURE__ */ new Set();
3147
+ /** Whether any screen has unpublished changes. */
3148
+ function hasUnsavedChanges() {
3149
+ return [...guards].some((isDirty) => isDirty());
3150
+ }
3151
+ /**
3152
+ * Registers a screen's unpublished changes, so leaving it (inside the admin
3153
+ * panel or by closing the tab) asks for confirmation first.
3154
+ */
3155
+ function useUnsavedChanges(isDirty) {
3156
+ useEffect(() => {
3157
+ if (!isDirty) return;
3158
+ const guard = () => true;
3159
+ guards.add(guard);
3160
+ const onBeforeUnload = (event) => event.preventDefault();
3161
+ window.addEventListener("beforeunload", onBeforeUnload);
3162
+ return () => {
3163
+ guards.delete(guard);
3164
+ window.removeEventListener("beforeunload", onBeforeUnload);
3165
+ };
3166
+ }, [isDirty]);
3167
+ }
3168
+ /** Goes to another screen, asking first if there are unpublished changes. Returns whether it navigated. */
3169
+ function navigate(to, confirmLeave) {
3170
+ if (hasUnsavedChanges() && !confirmLeave()) return false;
3171
+ guards.clear();
3172
+ window.location.hash = to;
3173
+ return true;
3174
+ }
3175
+ /** Sections of Site Settings' General tab that links can scroll to, as `#/settings/general?section=forms`. */
3176
+ const SETTINGS_SECTIONS = {
3177
+ contact: "contact",
3178
+ forms: "forms"
3179
+ };
3180
+ function pageEditorHref(path) {
3181
+ return `#/pages/edit?path=${encodeURIComponent(path)}`;
3182
+ }
3183
+ /** An in-app link that asks before leaving a screen with unpublished changes. */
3184
+ function Link({ href, confirmLeave, ...props }) {
3185
+ return /* @__PURE__ */ jsx("a", {
3186
+ ...props,
3187
+ href,
3188
+ onClick: (event) => {
3189
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) return;
3190
+ event.preventDefault();
3191
+ navigate(href, confirmLeave);
3192
+ }
3193
+ });
3194
+ }
3195
+ function collectionHref(id, tab = "entries") {
3196
+ return tab === "entries" ? `#/collections/${id}` : `#/collections/${id}/${tab}`;
3197
+ }
3198
+ function entryEditorHref(collection, slug) {
3199
+ return `#/collections/${collection}/edit?slug=${encodeURIComponent(slug)}`;
3200
+ }
3201
+ //#endregion
3202
+ //#region src/use-link.tsx
3203
+ /** `Link`, with the standard "leave without publishing?" confirmation. */
3204
+ function AppLink(props) {
3205
+ const t = useStrings();
3206
+ return /* @__PURE__ */ jsx(Link, {
3207
+ ...props,
3208
+ confirmLeave: () => window.confirm(t("unsaved.confirm"))
3209
+ });
3210
+ }
3211
+ //#endregion
2221
3212
  //#region src/media-library.tsx
2222
3213
  /** A media file's picture: the image itself, or its kind of file for anything else. */
2223
3214
  function MediaThumb({ url, className }) {
@@ -2268,16 +3259,148 @@ const uploadProblems = {
2268
3259
  size: "media.error.size",
2269
3260
  unreadable: "media.error.unreadable"
2270
3261
  };
3262
+ /** The plugin keeping the site's media, where the site has one and it's there to ask. */
3263
+ function useMediaStorage() {
3264
+ const { content } = useSiteContent();
3265
+ const storage = content.mediaStorage;
3266
+ const client = usePluginMedia(storage?.plugin);
3267
+ return {
3268
+ storage,
3269
+ ...client && { client }
3270
+ };
3271
+ }
3272
+ /** How many files moving media hands the plugin at once. */
3273
+ const MOVE_BATCH = 20;
3274
+ /** Whether the storage plugin can take files now, or why not, with a link to where that's fixed. */
3275
+ async function storageReady(t, client, tasks) {
3276
+ const { plugin } = client;
3277
+ if (plugin.tasks?.length && !await tasks?.supported().catch(() => false)) return {
3278
+ ok: false,
3279
+ message: t("plugins.task.missing")
3280
+ };
3281
+ let ready;
3282
+ try {
3283
+ ready = await client.ready();
3284
+ } catch (error) {
3285
+ return {
3286
+ ok: false,
3287
+ message: t("media.storage.unavailable", { plugin: plugin.title }),
3288
+ error
3289
+ };
3290
+ }
3291
+ if (ready.ready) return void 0;
3292
+ return {
3293
+ ok: false,
3294
+ message: t("media.storage.notReady", {
3295
+ plugin: plugin.title,
3296
+ message: ready.message
3297
+ }),
3298
+ ...ready.screen && { action: {
3299
+ href: `#/settings/plugin/${encodeURIComponent(plugin.id)}/${encodeURIComponent(ready.screen)}`,
3300
+ label: t("media.storage.settings", { plugin: plugin.title })
3301
+ } }
3302
+ };
3303
+ }
3304
+ /** A file's size for content's list of stored media, with a picture's size where it's known. */
3305
+ function storedFile(size, picture) {
3306
+ return {
3307
+ size,
3308
+ ...picture.width && picture.height && {
3309
+ width: picture.width,
3310
+ height: picture.height
3311
+ }
3312
+ };
3313
+ }
2271
3314
  /**
2272
3315
  * Uploads files to the media folder in one publish, and shows them in the
2273
3316
  * admin panel straight away. `replace` overwrites one existing file instead.
3317
+ * Where a plugin keeps the site's media, it keeps the files, larger ones
3318
+ * too, and the publish lists them in `content/media-storage.json`.
2274
3319
  */
2275
3320
  function useUploadMedia() {
2276
3321
  const t = useStrings();
2277
- const { publish, mediaPreviews } = useAdmin();
3322
+ const { publish, mediaPreviews, tasks } = useAdmin();
2278
3323
  const { content, media } = useSiteContent();
3324
+ const { storage, client } = useMediaStorage();
3325
+ const published = async (changes, message, urls) => {
3326
+ const result = await publish(changes, message, { checks: false });
3327
+ if (!result.ok) return {
3328
+ ok: false,
3329
+ message: t(result.reason === "conflict" ? "publish.conflict" : "publish.error"),
3330
+ failure: result
3331
+ };
3332
+ return {
3333
+ ok: true,
3334
+ urls
3335
+ };
3336
+ };
3337
+ const toStorage = async (files, replace) => {
3338
+ if (!storage || !client) return {
3339
+ ok: false,
3340
+ message: t("publish.error")
3341
+ };
3342
+ const problem = await storageReady(t, client, tasks);
3343
+ if (problem) return problem;
3344
+ const { plugin } = client;
3345
+ const maxBytes = plugin.mediaStorage?.maxBytes ?? MAX_UPLOAD_BYTES;
3346
+ const taken = allMedia(content, media).map(fileName);
3347
+ const added = {};
3348
+ const batch = [];
3349
+ for (const file of files) {
3350
+ let ready;
3351
+ try {
3352
+ ready = await prepareStoredUpload(file, maxBytes);
3353
+ } catch (error) {
3354
+ if (error instanceof UploadError && error.problem === "size") return {
3355
+ ok: false,
3356
+ message: t("media.error.storageSize", {
3357
+ name: file.name,
3358
+ size: formatSize(maxBytes),
3359
+ plugin: plugin.title
3360
+ })
3361
+ };
3362
+ return {
3363
+ ok: false,
3364
+ message: error instanceof UploadError ? t(uploadProblems[error.problem], { name: error.file }) : String(error)
3365
+ };
3366
+ }
3367
+ const url = replace ? mediaUrl(replace) : `${MEDIA_URL_PREFIX}${uploadName(file.name, taken)}`;
3368
+ taken.push(fileName(url));
3369
+ batch.push({
3370
+ address: url,
3371
+ type: ready.type,
3372
+ size: ready.blob.size,
3373
+ data: ready.blob
3374
+ });
3375
+ added[url] = storedFile(ready.blob.size, ready);
3376
+ }
3377
+ try {
3378
+ await client.upload(batch);
3379
+ } catch (error) {
3380
+ const name = batch.map((file) => fileName(file.address)).join(", ");
3381
+ return {
3382
+ ok: false,
3383
+ message: t("media.storage.failed", {
3384
+ name,
3385
+ plugin: plugin.title
3386
+ }),
3387
+ error
3388
+ };
3389
+ }
3390
+ const urls = Object.keys(added);
3391
+ const changes = [mediaStorageFileChange(withStoredFiles(storage, added))];
3392
+ if (replace && media.includes(replace)) changes.push({
3393
+ path: replace,
3394
+ delete: true
3395
+ });
3396
+ const credits = replace ? withoutCredits(content.credits, [mediaUrl(replace)]) : void 0;
3397
+ if (credits) changes.push(credits);
3398
+ const names = urls.map(fileName).join(", ");
3399
+ return published(changes, replace ? t("media.replaceMessage", { name: names }) : t("media.uploadMessage", { names }), urls);
3400
+ };
2279
3401
  return async (files, replace) => {
2280
- const taken = media.map(fileName);
3402
+ if (storage && client && (!replace || isStorableAddress(mediaUrl(replace)))) return toStorage(files, replace);
3403
+ const taken = allMedia(content, media).map(fileName);
2281
3404
  const changes = [];
2282
3405
  const uploads = [];
2283
3406
  for (const file of files) {
@@ -2304,16 +3427,12 @@ function useUploadMedia() {
2304
3427
  for (const upload of uploads) mediaPreviews.remember(upload.url, upload.bytes);
2305
3428
  const names = changes.map((change) => fileName(change.path)).join(", ");
2306
3429
  const credits = replace ? withoutCredits(content.credits, [mediaUrl(replace)]) : void 0;
2307
- const result = await publish(credits ? [...changes, credits] : changes, replace ? t("media.replaceMessage", { name: names }) : t("media.uploadMessage", { names }), { checks: false });
2308
- if (!result.ok) return {
2309
- ok: false,
2310
- message: t(result.reason === "conflict" ? "publish.conflict" : "publish.error"),
2311
- failure: result
2312
- };
2313
- return {
2314
- ok: true,
2315
- urls: uploads.map((upload) => upload.url)
2316
- };
3430
+ const unstored = replace && storage && isStoredMedia(content, mediaUrl(replace)) ? mediaStorageFileChange(withStoredFiles(storage, {}, [mediaUrl(replace)])) : void 0;
3431
+ return published([
3432
+ ...changes,
3433
+ ...credits ? [credits] : [],
3434
+ ...unstored ? [unstored] : []
3435
+ ], replace ? t("media.replaceMessage", { name: names }) : t("media.uploadMessage", { names }), uploads.map((upload) => upload.url));
2317
3436
  };
2318
3437
  }
2319
3438
  function DeleteMediaDialog({ url, onClose }) {
@@ -2329,18 +3448,21 @@ function DeleteMediaDialog({ url, onClose }) {
2329
3448
  settings: t("nav.settings"),
2330
3449
  css: t("settings.tab.css")
2331
3450
  });
3451
+ const { storage, client } = useMediaStorage();
2332
3452
  const onDelete = async () => {
2333
3453
  const path = mediaPath(url);
2334
3454
  if (!path) return;
2335
3455
  setBusy(true);
2336
3456
  const credits = withoutCredits(content.credits, [url]);
2337
- const result = await publish([{
3457
+ const stored = storage && isStoredMedia(content, url);
3458
+ const result = await publish([stored ? mediaStorageFileChange(withStoredFiles(storage, {}, [url])) : {
2338
3459
  path,
2339
3460
  delete: true
2340
3461
  }, ...credits ? [credits] : []], t("media.deleteMessage", { name }));
2341
3462
  setBusy(false);
2342
- if (result.ok) onClose();
2343
- else setFailure(result);
3463
+ if (!result.ok) return setFailure(result);
3464
+ if (stored) client?.remove([url]).catch(() => {});
3465
+ onClose();
2344
3466
  };
2345
3467
  return /* @__PURE__ */ jsxs(Dialog, {
2346
3468
  title: t("media.deleteTitle", { name }),
@@ -2408,26 +3530,41 @@ function UploadStatus({ status }) {
2408
3530
  const t = useStrings();
2409
3531
  if (status.type === "uploading") return /* @__PURE__ */ jsx("p", {
2410
3532
  className: "gfa-notice",
2411
- children: t("media.uploading")
3533
+ role: "status",
3534
+ children: status.plugin ? t("media.storage.uploading", { plugin: status.plugin }) : t("media.uploading")
2412
3535
  });
2413
3536
  if (status.type === "done") return /* @__PURE__ */ jsx("p", {
2414
3537
  className: "gfa-notice gfa-notice-success",
2415
3538
  role: "status",
2416
3539
  children: t("media.uploaded")
2417
3540
  });
2418
- if (status.type === "failed") return status.failure ? /* @__PURE__ */ jsx(PublishFailure, { failure: status.failure }) : /* @__PURE__ */ jsx(ErrorMessage, { message: status.message });
3541
+ if (status.type === "failed") {
3542
+ if (status.failure) return /* @__PURE__ */ jsx(PublishFailure, { failure: status.failure });
3543
+ return /* @__PURE__ */ jsx(ErrorMessage, {
3544
+ message: status.message,
3545
+ error: status.error,
3546
+ action: status.action && /* @__PURE__ */ jsx(AppLink, {
3547
+ href: status.action.href,
3548
+ children: status.action.label
3549
+ })
3550
+ });
3551
+ }
2419
3552
  return null;
2420
3553
  }
2421
3554
  function useUploads() {
2422
3555
  const upload = useUploadMedia();
3556
+ const { storage, client } = useMediaStorage();
2423
3557
  const [status, setStatus] = useState({ type: "idle" });
2424
3558
  const run = async (files, replace) => {
2425
- setStatus({ type: "uploading" });
3559
+ const waits = storage && client?.plugin.tasks?.length && (!replace || isStorableAddress(mediaUrl(replace)));
3560
+ setStatus({
3561
+ type: "uploading",
3562
+ ...waits && { plugin: client.plugin.title }
3563
+ });
2426
3564
  const result = await upload(files, replace);
2427
3565
  setStatus(result.ok ? { type: "done" } : {
2428
3566
  type: "failed",
2429
- message: result.message,
2430
- failure: result.failure
3567
+ ...result
2431
3568
  });
2432
3569
  return result;
2433
3570
  };
@@ -2436,17 +3573,280 @@ function useUploads() {
2436
3573
  run
2437
3574
  };
2438
3575
  }
3576
+ /** Moves the site's own media files to the plugin that keeps media, in one publish once each is kept. */
3577
+ function MoveToStorageDialog({ client, paths, onClose }) {
3578
+ const t = useStrings();
3579
+ const { publish, readBytes, tasks } = useAdmin();
3580
+ const { storage } = useMediaStorage();
3581
+ const [progress, setProgress] = useState();
3582
+ const [problem, setProblem] = useState();
3583
+ const [failure, setFailure] = useState(null);
3584
+ const plugin = client.plugin.title;
3585
+ const move = async () => {
3586
+ if (!storage) return;
3587
+ setProblem(void 0);
3588
+ setFailure(null);
3589
+ const unready = await storageReady(t, client, tasks);
3590
+ if (unready) return setProblem(unready);
3591
+ setProgress({ done: 0 });
3592
+ const added = {};
3593
+ let failed;
3594
+ for (let start = 0; start < paths.length && !failed; start += MOVE_BATCH) {
3595
+ const batch = [];
3596
+ const sizes = {};
3597
+ try {
3598
+ for (const path of paths.slice(start, start + MOVE_BATCH)) {
3599
+ const url = mediaUrl(path);
3600
+ const bytes = await readBytes(path);
3601
+ if (!bytes) continue;
3602
+ const blob = new Blob([new Uint8Array(bytes)], { type: mediaType(path) });
3603
+ batch.push({
3604
+ address: url,
3605
+ type: blob.type,
3606
+ size: blob.size,
3607
+ data: blob
3608
+ });
3609
+ sizes[url] = storedFile(blob.size, await imageSize(blob, path) ?? {});
3610
+ }
3611
+ if (batch.length > 0) await client.upload(batch);
3612
+ Object.assign(added, sizes);
3613
+ } catch (error) {
3614
+ const name = batch.map((file) => fileName(file.address)).join(", ");
3615
+ failed = {
3616
+ ok: false,
3617
+ message: t("media.storage.failed", {
3618
+ name,
3619
+ plugin
3620
+ }),
3621
+ error
3622
+ };
3623
+ }
3624
+ setProgress({ done: Math.min(start + MOVE_BATCH, paths.length) });
3625
+ }
3626
+ const moved = Object.keys(added);
3627
+ if (moved.length === 0) {
3628
+ setProgress(void 0);
3629
+ return setProblem(failed ?? {
3630
+ ok: false,
3631
+ message: t("media.move.nothing")
3632
+ });
3633
+ }
3634
+ const result = await publish([mediaStorageFileChange(withStoredFiles(storage, added)), ...moved.flatMap((url) => {
3635
+ const path = mediaPath(url);
3636
+ return path ? [{
3637
+ path,
3638
+ delete: true
3639
+ }] : [];
3640
+ })], t("media.move.message", { plugin }), { checks: false });
3641
+ setProgress(void 0);
3642
+ if (!result.ok) return setFailure(result);
3643
+ if (failed) return setProblem({
3644
+ ...failed,
3645
+ message: `${t("media.move.some", {
3646
+ count: String(moved.length),
3647
+ plugin
3648
+ })} ${failed.message}`
3649
+ });
3650
+ onClose(t("media.move.done", {
3651
+ count: String(moved.length),
3652
+ plugin
3653
+ }));
3654
+ };
3655
+ return /* @__PURE__ */ jsxs(Dialog, {
3656
+ title: t("media.move.title", { plugin }),
3657
+ onClose: () => onClose(),
3658
+ children: [
3659
+ /* @__PURE__ */ jsx("p", { children: t("media.move.body", { plugin }) }),
3660
+ progress && /* @__PURE__ */ jsxs("p", {
3661
+ className: "gfa-notice",
3662
+ role: "status",
3663
+ children: [t("media.move.moving", {
3664
+ done: String(progress.done),
3665
+ total: String(paths.length)
3666
+ }), client.plugin.tasks?.length ? ` ${t("media.storage.wait")}` : ""]
3667
+ }),
3668
+ problem && /* @__PURE__ */ jsx(ErrorMessage, {
3669
+ message: problem.message,
3670
+ error: problem.error,
3671
+ action: problem.action && /* @__PURE__ */ jsx(AppLink, {
3672
+ href: problem.action.href,
3673
+ children: problem.action.label
3674
+ })
3675
+ }),
3676
+ /* @__PURE__ */ jsx(PublishFailure, { failure }),
3677
+ /* @__PURE__ */ jsxs("div", {
3678
+ className: "gfa-dialog-actions",
3679
+ children: [/* @__PURE__ */ jsx(Button, {
3680
+ onClick: () => onClose(),
3681
+ children: t("action.cancel")
3682
+ }), /* @__PURE__ */ jsx(Button, {
3683
+ variant: "primary",
3684
+ disabled: progress !== void 0,
3685
+ onClick: () => void move(),
3686
+ children: progress ? t("media.move.busy") : t("media.move.button", {
3687
+ count: String(paths.length),
3688
+ plugin
3689
+ })
3690
+ })]
3691
+ })
3692
+ ]
3693
+ });
3694
+ }
3695
+ /**
3696
+ * Copies the files a plugin keeps back into the site's own files, from their
3697
+ * public addresses, up to the site's own 25 MB each, which needs no plugin.
3698
+ * Once none are left, the site stops keeping media outside its files.
3699
+ */
3700
+ function MoveBackDialog({ plugin, onClose }) {
3701
+ const t = useStrings();
3702
+ const { publish } = useAdmin();
3703
+ const { content } = useSiteContent();
3704
+ const { storage, client } = useMediaStorage();
3705
+ const [busy, setBusy] = useState(false);
3706
+ const [problem, setProblem] = useState();
3707
+ const [failure, setFailure] = useState(null);
3708
+ const files = Object.entries(storage?.files ?? {});
3709
+ const large = files.filter(([, file]) => file.size > MAX_UPLOAD_BYTES).map(([url]) => fileName(url));
3710
+ const moveBack = async () => {
3711
+ if (!storage) return;
3712
+ setBusy(true);
3713
+ setProblem(void 0);
3714
+ setFailure(null);
3715
+ const changes = [];
3716
+ const moved = [];
3717
+ for (const [url, file] of files) {
3718
+ const path = mediaPath(url);
3719
+ const from = storedMediaUrl(storage, url);
3720
+ if (!path || !from || file.size > MAX_UPLOAD_BYTES) continue;
3721
+ try {
3722
+ const response = await fetch(from, { cache: "no-store" });
3723
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
3724
+ changes.push({
3725
+ path,
3726
+ bytes: new Uint8Array(await response.arrayBuffer())
3727
+ });
3728
+ moved.push(url);
3729
+ } catch (error) {
3730
+ setBusy(false);
3731
+ return setProblem({
3732
+ message: t("media.back.failed", {
3733
+ name: fileName(url),
3734
+ plugin
3735
+ }),
3736
+ error
3737
+ });
3738
+ }
3739
+ }
3740
+ const left = withStoredFiles(storage, {}, moved);
3741
+ changes.push(mediaStorageFileChange(Object.keys(left.files).length > 0 ? left : void 0));
3742
+ const result = await publish(changes, t("media.back.message", { plugin }), { checks: false });
3743
+ setBusy(false);
3744
+ if (!result.ok) return setFailure(result);
3745
+ if (moved.length > 0) client?.remove(moved).catch(() => {});
3746
+ onClose(t("media.back.done", {
3747
+ count: String(moved.length),
3748
+ plugin
3749
+ }));
3750
+ };
3751
+ return /* @__PURE__ */ jsxs(Dialog, {
3752
+ title: t("media.back.title", { plugin }),
3753
+ onClose: () => onClose(),
3754
+ children: [
3755
+ /* @__PURE__ */ jsx("p", { children: files.length > 0 ? t("media.back.body", { plugin }) : t("media.back.empty", { plugin }) }),
3756
+ large.length > 0 && /* @__PURE__ */ jsx("p", { children: t("media.back.large", {
3757
+ names: large.join(", "),
3758
+ plugin
3759
+ }) }),
3760
+ content.mediaStorage && problem && /* @__PURE__ */ jsx(ErrorMessage, {
3761
+ message: problem.message,
3762
+ error: problem.error
3763
+ }),
3764
+ /* @__PURE__ */ jsx(PublishFailure, { failure }),
3765
+ /* @__PURE__ */ jsxs("div", {
3766
+ className: "gfa-dialog-actions",
3767
+ children: [/* @__PURE__ */ jsx(Button, {
3768
+ onClick: () => onClose(),
3769
+ children: t("action.cancel")
3770
+ }), /* @__PURE__ */ jsx(Button, {
3771
+ variant: "primary",
3772
+ disabled: busy,
3773
+ onClick: () => void moveBack(),
3774
+ children: busy ? t("media.move.busy") : t("media.back.button")
3775
+ })]
3776
+ })
3777
+ ]
3778
+ });
3779
+ }
3780
+ /** Where a plugin keeps the site's media: what it keeps, and moving the site's own files there or back. */
3781
+ function StorageSection({ onDone }) {
3782
+ const t = useStrings();
3783
+ const { content, media } = useSiteContent();
3784
+ const { storage, client } = useMediaStorage();
3785
+ const [moving, setMoving] = useState();
3786
+ if (!storage) return null;
3787
+ const plugin = client?.plugin.title ?? storage.plugin;
3788
+ const count = String(Object.keys(storage.files).length);
3789
+ const movable = media.filter((path) => isStorableAddress(mediaUrl(path)) && !isStoredMedia(content, mediaUrl(path)));
3790
+ const close = (message) => {
3791
+ setMoving(void 0);
3792
+ if (message) onDone(message);
3793
+ };
3794
+ return /* @__PURE__ */ jsxs("section", {
3795
+ className: "gfa-media-storage",
3796
+ "aria-label": t("media.storage.title"),
3797
+ children: [
3798
+ /* @__PURE__ */ jsx("p", {
3799
+ className: "gfa-notice",
3800
+ role: "note",
3801
+ children: client ? t("media.storage.intro", {
3802
+ plugin,
3803
+ count
3804
+ }) : t("media.storage.missing", {
3805
+ plugin,
3806
+ count
3807
+ })
3808
+ }),
3809
+ /* @__PURE__ */ jsxs("div", {
3810
+ className: "gfa-dialog-actions",
3811
+ children: [client && movable.length > 0 && /* @__PURE__ */ jsx(Button, {
3812
+ variant: "primary",
3813
+ onClick: () => setMoving("to"),
3814
+ children: t("media.move.button", {
3815
+ count: String(movable.length),
3816
+ plugin
3817
+ })
3818
+ }), /* @__PURE__ */ jsx(Button, {
3819
+ variant: "ghost",
3820
+ onClick: () => setMoving("back"),
3821
+ children: t("media.back.button")
3822
+ })]
3823
+ }),
3824
+ moving === "to" && client && /* @__PURE__ */ jsx(MoveToStorageDialog, {
3825
+ client,
3826
+ paths: movable,
3827
+ onClose: close
3828
+ }),
3829
+ moving === "back" && /* @__PURE__ */ jsx(MoveBackDialog, {
3830
+ plugin,
3831
+ onClose: close
3832
+ })
3833
+ ]
3834
+ });
3835
+ }
2439
3836
  /** Every uploaded image and file, with buttons to upload, replace and delete them. */
2440
3837
  /** Cloudflare's terms for its CDN, which limit serving video on its free plans. */
2441
3838
  const CLOUDFLARE_TERMS = "https://www.cloudflare.com/service-specific-terms-application-services/";
2442
3839
  function MediaScreen() {
2443
3840
  const t = useStrings();
2444
- const { content, media } = useSiteContent();
3841
+ const { content, media: own } = useSiteContent();
3842
+ const media = useMemo(() => allMedia(content, own), [content, own]);
2445
3843
  const { status, run } = useUploads();
2446
3844
  const [query, setQuery] = useState("");
2447
3845
  const [deleting, setDeleting] = useState(null);
2448
3846
  const [copied, setCopied] = useState(null);
3847
+ const [moved, setMoved] = useState();
2449
3848
  const searchId = useId();
3849
+ const mayChange = useMayChange({ kind: "media" });
2450
3850
  useEffect(() => {
2451
3851
  const stop = (event) => {
2452
3852
  if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
@@ -2465,7 +3865,7 @@ function MediaScreen() {
2465
3865
  children: [
2466
3866
  /* @__PURE__ */ jsxs("div", {
2467
3867
  className: "gfa-screen-header",
2468
- children: [/* @__PURE__ */ jsx("h1", { children: t("media.title") }), /* @__PURE__ */ jsx(UploadButton, {
3868
+ children: [/* @__PURE__ */ jsx("h1", { children: t("media.title") }), mayChange && /* @__PURE__ */ jsx(UploadButton, {
2469
3869
  label: t("media.upload"),
2470
3870
  disabled: status.type === "uploading",
2471
3871
  onFiles: (files) => void run(files)
@@ -2475,6 +3875,11 @@ function MediaScreen() {
2475
3875
  className: "gfa-hint",
2476
3876
  children: t("media.intro")
2477
3877
  }),
3878
+ !mayChange && /* @__PURE__ */ jsx("p", {
3879
+ className: "gfa-notice",
3880
+ role: "note",
3881
+ children: t("limits.mediaReadOnly")
3882
+ }),
2478
3883
  cloudflareVideos && /* @__PURE__ */ jsxs("p", {
2479
3884
  className: "gfa-notice",
2480
3885
  role: "note",
@@ -2489,6 +3894,12 @@ function MediaScreen() {
2489
3894
  })
2490
3895
  ]
2491
3896
  }),
3897
+ mayChange && /* @__PURE__ */ jsx(StorageSection, { onDone: setMoved }),
3898
+ moved && /* @__PURE__ */ jsx("p", {
3899
+ className: "gfa-notice gfa-notice-success",
3900
+ role: "status",
3901
+ children: moved
3902
+ }),
2492
3903
  /* @__PURE__ */ jsx(UploadStatus, { status }),
2493
3904
  media.length > 0 && /* @__PURE__ */ jsxs("div", {
2494
3905
  className: "gfa-field gfa-media-search",
@@ -2505,7 +3916,7 @@ function MediaScreen() {
2505
3916
  })]
2506
3917
  }),
2507
3918
  /* @__PURE__ */ jsx(DropZone, {
2508
- onFiles: (files) => void run(files),
3919
+ onFiles: (files) => void (mayChange && run(files)),
2509
3920
  children: media.length === 0 ? /* @__PURE__ */ jsx("p", { children: t("media.empty") }) : urls.length === 0 ? /* @__PURE__ */ jsx("p", { children: t("media.noMatches") }) : /* @__PURE__ */ jsx("ul", {
2510
3921
  className: "gfa-media-grid",
2511
3922
  children: urls.map((url) => /* @__PURE__ */ jsxs("li", {
@@ -2518,27 +3929,27 @@ function MediaScreen() {
2518
3929
  children: fileName(url)
2519
3930
  }),
2520
3931
  content.credits?.[url] && /* @__PURE__ */ jsx(MediaCredit, { credit: content.credits[url] }),
3932
+ isStoredMedia(content, url) && /* @__PURE__ */ jsx("span", {
3933
+ className: "gfa-badge",
3934
+ children: t("media.stored", { plugin: content.mediaStorage?.plugin ?? "" })
3935
+ }),
2521
3936
  /* @__PURE__ */ jsxs("div", {
2522
3937
  className: "gfa-media-actions",
2523
- children: [
2524
- /* @__PURE__ */ jsx(Button, {
2525
- variant: "ghost",
2526
- onClick: () => void navigator.clipboard?.writeText(url).then(() => setCopied(url), () => setCopied(null)),
2527
- children: copied === url ? t("media.copied") : t("media.copy")
2528
- }),
2529
- /* @__PURE__ */ jsx(UploadButton, {
2530
- label: t("media.replace"),
2531
- variant: "secondary",
2532
- multiple: false,
2533
- disabled: status.type === "uploading",
2534
- onFiles: (files) => void run(files.slice(0, 1), mediaPath(url))
2535
- }),
2536
- /* @__PURE__ */ jsx(Button, {
2537
- variant: "ghost",
2538
- onClick: () => setDeleting(url),
2539
- children: t("media.delete")
2540
- })
2541
- ]
3938
+ children: [/* @__PURE__ */ jsx(Button, {
3939
+ variant: "ghost",
3940
+ onClick: () => void navigator.clipboard?.writeText(url).then(() => setCopied(url), () => setCopied(null)),
3941
+ children: copied === url ? t("media.copied") : t("media.copy")
3942
+ }), mayChange && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(UploadButton, {
3943
+ label: t("media.replace"),
3944
+ variant: "secondary",
3945
+ multiple: false,
3946
+ disabled: status.type === "uploading",
3947
+ onFiles: (files) => void run(files.slice(0, 1), mediaPath(url))
3948
+ }), /* @__PURE__ */ jsx(Button, {
3949
+ variant: "ghost",
3950
+ onClick: () => setDeleting(url),
3951
+ children: t("media.delete")
3952
+ })] })]
2542
3953
  })
2543
3954
  ]
2544
3955
  }, url))
@@ -2554,22 +3965,26 @@ function MediaScreen() {
2554
3965
  const PICKER_TITLES = {
2555
3966
  image: "media.pickerTitle",
2556
3967
  video: "media.pickerTitleVideo",
3968
+ audio: "media.pickerTitleAudio",
2557
3969
  file: "media.pickerTitleFile"
2558
3970
  };
2559
3971
  const CHOOSE_LABELS = {
2560
3972
  image: "media.choose",
2561
3973
  video: "media.chooseVideo",
3974
+ audio: "media.chooseAudio",
2562
3975
  file: "media.chooseFile"
2563
3976
  };
2564
3977
  const ACCEPT = {
2565
3978
  image: "image/*",
2566
3979
  video: "video/mp4,video/webm",
3980
+ audio: "audio/mpeg,audio/mp4,.mp3,.m4a",
2567
3981
  file: void 0
2568
3982
  };
2569
3983
  /** Picks a file from the media library, or uploads a new one, for a field. */
2570
3984
  function MediaPickerDialog({ kind, onSelect, onClose }) {
2571
3985
  const t = useStrings();
2572
- const { media } = useSiteContent();
3986
+ const { content, media: own } = useSiteContent();
3987
+ const media = allMedia(content, own);
2573
3988
  const { status, run } = useUploads();
2574
3989
  const urls = media.map(mediaUrl).filter((url) => kind === "file" || mediaKind$1(url) === kind).sort((a, b) => fileName(a).localeCompare(fileName(b)));
2575
3990
  return /* @__PURE__ */ jsx(Dialog, {
@@ -2684,214 +4099,91 @@ function MediaField({ label, hint, error, value, kind = "image", onChange }) {
2684
4099
  });
2685
4100
  }
2686
4101
  //#endregion
2687
- //#region src/media-previews.ts
2688
- const TYPES = {
2689
- jpg: "image/jpeg",
2690
- jpeg: "image/jpeg",
2691
- png: "image/png",
2692
- gif: "image/gif",
2693
- webp: "image/webp",
2694
- avif: "image/avif",
2695
- svg: "image/svg+xml",
2696
- ico: "image/x-icon",
2697
- pdf: "application/pdf",
2698
- mp3: "audio/mpeg",
2699
- m4a: "audio/mp4",
2700
- mp4: "video/mp4",
2701
- webm: "video/webm",
2702
- vtt: "text/vtt"
2703
- };
2704
- /** The type to show a file as, from its name. Needed so browsers display SVGs from memory. */
2705
- function mediaType(name) {
2706
- return TYPES[extensionOf(name)] ?? "application/octet-stream";
4102
+ //#region src/plugin-secrets.ts
4103
+ /** The lines a plugin's secrets take in `.env.local`, each waiting for its value after the `=`. */
4104
+ function envLines(names) {
4105
+ return names.map((name) => `${name}=`).join("\n");
2707
4106
  }
2708
- function createMediaPreviews(store, siteUrl) {
2709
- const local = /* @__PURE__ */ new Map();
2710
- const remembered = /* @__PURE__ */ new Map();
2711
- const listeners = /* @__PURE__ */ new Set();
2712
- const loading = /* @__PURE__ */ new Map();
2713
- const base = trimChars(siteUrl, "/", { start: false });
2714
- const remember = (url, bytes) => {
2715
- const previous = local.get(url);
2716
- if (previous) URL.revokeObjectURL(previous);
2717
- remembered.set(url, bytes);
2718
- for (const listener of listeners) listener(url, bytes);
2719
- local.set(url, URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: mediaType(url) })));
2720
- };
2721
- return {
2722
- resolve(url) {
2723
- if (!url.startsWith("/media/")) return url;
2724
- return local.get(url) ?? `${base}${url}`;
2725
- },
2726
- remember,
2727
- onRemember(listener) {
2728
- for (const [url, bytes] of remembered) listener(url, bytes);
2729
- listeners.add(listener);
2730
- return () => listeners.delete(listener);
2731
- },
2732
- async bytes(url) {
2733
- const known = remembered.get(url);
2734
- if (known) return known;
2735
- const path = mediaPath(url);
2736
- return path ? await store.readBytes(path).catch(() => void 0) : void 0;
2737
- },
2738
- load(url) {
2739
- const known = local.get(url);
2740
- if (known) return Promise.resolve(known);
2741
- const path = mediaPath(url);
2742
- if (!path) return Promise.resolve(void 0);
2743
- let pending = loading.get(url);
2744
- if (!pending) {
2745
- pending = store.readBytes(path).then((bytes) => {
2746
- if (!bytes) return void 0;
2747
- remember(url, bytes);
2748
- return local.get(url);
2749
- }).catch(() => void 0);
2750
- loading.set(url, pending);
2751
- }
2752
- return pending;
2753
- }
2754
- };
4107
+ /** How to add a plugin's secrets where a site keeps them, as its screens show it, in the admin panel's words. */
4108
+ function helpFor(place, names, t) {
4109
+ const list = names.join(", ");
4110
+ switch (place.kind) {
4111
+ case "github": return {
4112
+ where: "github",
4113
+ title: t("plugins.secrets.github.title"),
4114
+ link: {
4115
+ url: place.url,
4116
+ label: t("plugins.secrets.github.link")
4117
+ },
4118
+ steps: [{ text: t("plugins.secrets.github.open") }, ...names.map((name) => ({
4119
+ text: t("plugins.secrets.github.add", { name }),
4120
+ code: name
4121
+ }))],
4122
+ notes: [t("plugins.secrets.github.who"), t("plugins.secrets.github.kind")]
4123
+ };
4124
+ case "gitlab": return {
4125
+ where: "gitlab",
4126
+ title: t("plugins.secrets.gitlab.title"),
4127
+ link: {
4128
+ url: place.url,
4129
+ label: t("plugins.secrets.gitlab.link")
4130
+ },
4131
+ steps: [{ text: t("plugins.secrets.gitlab.open") }, ...names.map((name) => ({
4132
+ text: t("plugins.secrets.gitlab.add", { name }),
4133
+ code: name
4134
+ }))],
4135
+ notes: [t("plugins.secrets.gitlab.who")]
4136
+ };
4137
+ case "bitbucket": return {
4138
+ where: "bitbucket",
4139
+ title: t("plugins.secrets.bitbucket.title"),
4140
+ link: {
4141
+ url: place.url,
4142
+ label: t("plugins.secrets.bitbucket.link")
4143
+ },
4144
+ steps: [{ text: t("plugins.secrets.bitbucket.open") }, ...names.map((name) => ({
4145
+ text: t("plugins.secrets.bitbucket.add", { name }),
4146
+ code: name
4147
+ }))],
4148
+ notes: [t("plugins.secrets.bitbucket.who")]
4149
+ };
4150
+ case "goodfellow-dev":
4151
+ case "next-dev": return {
4152
+ where: "development",
4153
+ title: t("plugins.secrets.development.title"),
4154
+ steps: [{
4155
+ text: t("plugins.secrets.development.file", { names: list }),
4156
+ code: envLines(names)
4157
+ }, { text: t("plugins.secrets.development.values") }],
4158
+ notes: [t("plugins.secrets.development.local")]
4159
+ };
4160
+ case "server": return {
4161
+ where: "server",
4162
+ title: t("plugins.secrets.server.title"),
4163
+ steps: [{ text: t("plugins.secrets.server.add", { names: list }) }, { text: t("plugins.secrets.server.deploy") }],
4164
+ notes: [t("plugins.secrets.server.who")]
4165
+ };
4166
+ }
2755
4167
  }
2756
- const URL_ATTRIBUTES = [
2757
- "src",
2758
- "href",
2759
- "poster"
2760
- ];
2761
- const MEDIA_IN_STYLE = /url\((['"]?)(\/media\/[^'")]+)\1\)/g;
2762
4168
  /**
2763
- * Keeps media in a preview document showing: rewrites `/media/…` addresses in
2764
- * images, links and inline styles as the preview changes, and if an image
2765
- * still doesn't load, reads it from the repository instead.
4169
+ * How the site's owner adds the secrets a plugin declared, for this site as
4170
+ * it's set up: where its tasks get them, and, on a full-stack site whose
4171
+ * plugin has a server module, the server first, with the CI as well.
2766
4172
  */
2767
- function showMediaInPreview(doc, previews) {
2768
- const fix = (element) => {
2769
- for (const attribute of URL_ATTRIBUTES) {
2770
- const value = element.getAttribute(attribute);
2771
- if (!value?.startsWith("/media/")) continue;
2772
- const resolved = previews.resolve(value);
2773
- if (resolved !== value) {
2774
- element.setAttribute(`data-gf-${attribute}`, value);
2775
- element.setAttribute(attribute, resolved);
2776
- }
2777
- }
2778
- const style = element.getAttribute("style");
2779
- if (style?.includes("/media/")) {
2780
- const resolved = style.replace(MEDIA_IN_STYLE, (_match, quote, url) => `url(${quote}${previews.resolve(url)}${quote})`);
2781
- if (resolved !== style) element.setAttribute("style", resolved);
2782
- }
2783
- };
2784
- const fixTree = (root) => {
2785
- fix(root);
2786
- for (const element of root.querySelectorAll("[src], [href], [poster], [style]")) fix(element);
2787
- };
2788
- const observer = new MutationObserver((records) => {
2789
- for (const record of records) {
2790
- if (record.type === "attributes" && record.target.nodeType === 1) fix(record.target);
2791
- for (const node of record.addedNodes) if (node.nodeType === 1) fixTree(node);
2792
- }
2793
- });
2794
- observer.observe(doc.documentElement, {
2795
- subtree: true,
2796
- childList: true,
2797
- attributes: true,
2798
- attributeFilter: [...URL_ATTRIBUTES, "style"]
2799
- });
2800
- fixTree(doc.documentElement);
2801
- const onError = (event) => {
2802
- const image = event.target;
2803
- if (image?.tagName !== "IMG") return;
2804
- const original = image.getAttribute("data-gf-src") ?? image.getAttribute("src") ?? "";
2805
- if (!original.startsWith("/media/") || image.hasAttribute("data-gf-loaded")) return;
2806
- image.setAttribute("data-gf-loaded", "");
2807
- previews.load(original).then((url) => {
2808
- if (url) {
2809
- image.setAttribute("data-gf-src", original);
2810
- image.setAttribute("src", url);
2811
- }
2812
- });
2813
- };
2814
- doc.addEventListener("error", onError, true);
2815
- return () => {
2816
- observer.disconnect();
2817
- doc.removeEventListener("error", onError, true);
2818
- };
4173
+ function pluginSecretsHelp(place, plugin, t) {
4174
+ const names = plugin.permissions.secrets ?? [];
4175
+ if (!place || names.length === 0) return [];
4176
+ if (place.kind === "server") {
4177
+ const ci = place.ci && helpFor(place.ci, names, t);
4178
+ return [plugin.server ? helpFor(place, names, t) : void 0, ci].filter((help) => help !== void 0);
4179
+ }
4180
+ const help = helpFor(place, names, t);
4181
+ return help ? [help] : [];
2819
4182
  }
2820
4183
  //#endregion
2821
4184
  //#region src/registry-version.ts
2822
4185
  /** The release of Goodfellow's block registry this admin panel was built with, so a site gets blocks that match it. */
2823
- const REGISTRY_VERSION = "0.5.0";
2824
- //#endregion
2825
- //#region src/router.tsx
2826
- function parseHash(hash) {
2827
- const [path = "", query = ""] = hash.replace(/^#\/?/, "").split("?");
2828
- return {
2829
- segments: path.split("/").filter(Boolean),
2830
- params: new URLSearchParams(query)
2831
- };
2832
- }
2833
- function subscribe$1(onChange) {
2834
- window.addEventListener("hashchange", onChange);
2835
- return () => window.removeEventListener("hashchange", onChange);
2836
- }
2837
- function useRoute() {
2838
- return parseHash(useSyncExternalStore(subscribe$1, () => window.location.hash, () => ""));
2839
- }
2840
- const guards = /* @__PURE__ */ new Set();
2841
- /** Whether any screen has unpublished changes. */
2842
- function hasUnsavedChanges() {
2843
- return [...guards].some((isDirty) => isDirty());
2844
- }
2845
- /**
2846
- * Registers a screen's unpublished changes, so leaving it (inside the admin
2847
- * panel or by closing the tab) asks for confirmation first.
2848
- */
2849
- function useUnsavedChanges(isDirty) {
2850
- useEffect(() => {
2851
- if (!isDirty) return;
2852
- const guard = () => true;
2853
- guards.add(guard);
2854
- const onBeforeUnload = (event) => event.preventDefault();
2855
- window.addEventListener("beforeunload", onBeforeUnload);
2856
- return () => {
2857
- guards.delete(guard);
2858
- window.removeEventListener("beforeunload", onBeforeUnload);
2859
- };
2860
- }, [isDirty]);
2861
- }
2862
- /** Goes to another screen, asking first if there are unpublished changes. Returns whether it navigated. */
2863
- function navigate(to, confirmLeave) {
2864
- if (hasUnsavedChanges() && !confirmLeave()) return false;
2865
- guards.clear();
2866
- window.location.hash = to;
2867
- return true;
2868
- }
2869
- /** Sections of Site Settings' General tab that links can scroll to, as `#/settings/general?section=forms`. */
2870
- const SETTINGS_SECTIONS = {
2871
- contact: "contact",
2872
- forms: "forms"
2873
- };
2874
- function pageEditorHref(path) {
2875
- return `#/pages/edit?path=${encodeURIComponent(path)}`;
2876
- }
2877
- /** An in-app link that asks before leaving a screen with unpublished changes. */
2878
- function Link({ href, confirmLeave, ...props }) {
2879
- return /* @__PURE__ */ jsx("a", {
2880
- ...props,
2881
- href,
2882
- onClick: (event) => {
2883
- if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) return;
2884
- event.preventDefault();
2885
- navigate(href, confirmLeave);
2886
- }
2887
- });
2888
- }
2889
- function collectionHref(id, tab = "entries") {
2890
- return tab === "entries" ? `#/collections/${id}` : `#/collections/${id}/${tab}`;
2891
- }
2892
- function entryEditorHref(collection, slug) {
2893
- return `#/collections/${collection}/edit?slug=${encodeURIComponent(slug)}`;
2894
- }
4186
+ const REGISTRY_VERSION = "0.7.0";
2895
4187
  //#endregion
2896
4188
  //#region src/schedule.tsx
2897
4189
  /** The computer's own time zone, which a site without one takes when something is first scheduled. */
@@ -3242,11 +4534,15 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3242
4534
  const [draftPaths, setDraftPaths] = useState([]);
3243
4535
  const content = draft?.store ?? store;
3244
4536
  const [checking, setChecking] = useState();
4537
+ const [limiting, setLimiting] = useState();
4538
+ const [draftReviews, setDraftReviews] = useState([]);
3245
4539
  const blocks = useMemo(() => describedConfig(site.description, "template").components, [site.description]);
3246
- const mediaPreviews = useMemo(() => createMediaPreviews(content, siteUrl), [content, siteUrl]);
4540
+ const storage = useRef(void 0);
4541
+ storage.current = state.status === "ready" ? state.content.mediaStorage : void 0;
4542
+ const mediaPreviews = useMemo(() => createMediaPreviews(content, siteUrl, () => storage.current), [content, siteUrl]);
3247
4543
  const outOfReach = registry !== void 0 || atAdminAddress(site.admin) || site.broker !== void 0;
3248
4544
  const registries = useMemo(() => ({
3249
- [GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.5.0"),
4545
+ [GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.7.0"),
3250
4546
  ...outOfReach && {
3251
4547
  [SHADCN_NAMESPACE]: SHADCN_REGISTRY,
3252
4548
  ...site.registries
@@ -3273,6 +4569,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3273
4569
  useEffect(() => favicon ? showSiteIcon(document, favicon, mediaPreviews) : void 0, [favicon, mediaPreviews]);
3274
4570
  const readFile = useCallback((path) => content.read(path), [content]);
3275
4571
  const listFiles = useCallback((dir) => content.list(dir), [content]);
4572
+ const readBytes = useCallback((path) => content.readBytes(path), [content]);
3276
4573
  const demo = isDemoStore(store) ? store : void 0;
3277
4574
  const pages = isGitBackend(store) ? store.pages : void 0;
3278
4575
  const versions = isGitBackend(store) ? store : void 0;
@@ -3281,6 +4578,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3281
4578
  const updates = isGitBackend(store) ? store.updates : void 0;
3282
4579
  const contentOnly = isGitBackend(store) && store.contentOnly === true;
3283
4580
  const scheduledBuilds = isGitBackend(store) ? store.scheduledBuilds : void 0;
4581
+ const tasks = "tasks" in store ? store.tasks : void 0;
3284
4582
  const repositoryVisibility = useMemo(() => isGitBackend(store) ? store.repositoryVisibility?.bind(store) : void 0, [store]);
3285
4583
  const [scheduleSyncProblem, setScheduleSyncProblem] = useState();
3286
4584
  const backendOwner = isGitBackend(store) ? store.ownerAccess : void 0;
@@ -3342,9 +4640,14 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3342
4640
  reload
3343
4641
  ]);
3344
4642
  const refreshDrafts = useCallback(async (current) => {
3345
- const [names, paths] = await Promise.all([siteDrafts.list().catch(() => []), current ? current.paths().catch(() => []) : []]);
4643
+ const [names, paths, reviews] = await Promise.all([
4644
+ siteDrafts.list().catch(() => []),
4645
+ current ? current.paths().catch(() => []) : [],
4646
+ siteDrafts.reviews().catch(() => [])
4647
+ ]);
3346
4648
  setDraftNames(names);
3347
4649
  setDraftPaths(paths);
4650
+ setDraftReviews(reviews);
3348
4651
  }, [siteDrafts]);
3349
4652
  useEffect(() => {
3350
4653
  if (draftFound) refreshDrafts(draft);
@@ -3364,12 +4667,64 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3364
4667
  setChecking(void 0);
3365
4668
  return go;
3366
4669
  }, []);
4670
+ const limits = useMemo(() => state.status === "ready" ? limitsFor(state.content.permissions, account?.user.login) : void 0, [state, account]);
4671
+ /** Who's asked to review: those the permissions name, else every owner, never the one asking. */
4672
+ const reviewers = useCallback(async () => {
4673
+ const owners = await editors?.list().then((list) => list.editors.flatMap((editor) => editor.role === "owner" && editor.login ? [editor.login] : [])).catch(() => []);
4674
+ const self = account?.user.login.toLowerCase();
4675
+ const permissions = state.status === "ready" ? state.content.permissions : void 0;
4676
+ return reviewersFor(permissions, owners ?? []).filter((login) => login.toLowerCase() !== self);
4677
+ }, [
4678
+ editors,
4679
+ account,
4680
+ state
4681
+ ]);
4682
+ /** Shows what's outside someone's limits, and lets them submit it for review where the site can take one. */
4683
+ const askLimits = useCallback((problems, shown, submit) => new Promise((done) => setLimiting({
4684
+ problems,
4685
+ content: shown,
4686
+ ...submit && { submit },
4687
+ done
4688
+ })), []);
3367
4689
  const publish = useCallback(async (changes, message, options) => {
3368
4690
  if (state.status !== "ready") return {
3369
4691
  ok: false,
3370
4692
  reason: "error",
3371
4693
  error: /* @__PURE__ */ new Error("The site isn't loaded.")
3372
4694
  };
4695
+ const outside = draft || options?.draft ? [] : outsideLimits(state.content, limits, changes);
4696
+ if (outside.length > 0) {
4697
+ const from = state.revision;
4698
+ const description = [
4699
+ t("draft.reviewDescription"),
4700
+ "",
4701
+ ...changes.map((change) => `- \`${change.path}\``)
4702
+ ].join("\n");
4703
+ const submit = backendDrafts ? async () => {
4704
+ const review = await siteDrafts.create(newDraftName(), { from });
4705
+ await writeChanges(review.store, changes, {
4706
+ message,
4707
+ expectedRevision: await review.store.revision()
4708
+ });
4709
+ const { url } = await review.submitForReview({
4710
+ title: message,
4711
+ description,
4712
+ reviewers: await reviewers()
4713
+ });
4714
+ refreshDrafts(void 0);
4715
+ return { url };
4716
+ } : account?.review ? () => account.review?.(changes, {
4717
+ title: message,
4718
+ description,
4719
+ from
4720
+ }) ?? Promise.resolve({}) : void 0;
4721
+ await askLimits(outside, state.content, submit);
4722
+ return {
4723
+ ok: false,
4724
+ reason: "checks",
4725
+ error: void 0
4726
+ };
4727
+ }
3373
4728
  const checks = options?.checks === false ? void 0 : await checkPublish(content, state, changes, {
3374
4729
  blocks,
3375
4730
  code
@@ -3424,7 +4779,13 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3424
4779
  confirmChecks,
3425
4780
  refreshDrafts,
3426
4781
  loadFrom,
3427
- onSignInError
4782
+ onSignInError,
4783
+ limits,
4784
+ backendDrafts,
4785
+ account,
4786
+ askLimits,
4787
+ reviewers,
4788
+ t
3428
4789
  ]);
3429
4790
  const openDraft = useCallback(async (name) => {
3430
4791
  if (name === draft?.name) return true;
@@ -3468,6 +4829,29 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3468
4829
  reason: "error",
3469
4830
  error: live.status === "error" && live.error
3470
4831
  };
4832
+ const outside = outsideLimits(live.content, limitsFor(live.content.permissions, account?.user.login), changes);
4833
+ if (outside.length > 0) {
4834
+ const current = draft;
4835
+ const submit = backendDrafts ? async () => {
4836
+ const paths = changes.map((change) => change.path);
4837
+ return current.submitForReview({
4838
+ title: t("draft.reviewTitle", { name: current.name }),
4839
+ description: [
4840
+ t("draft.reviewDescription"),
4841
+ "",
4842
+ ...paths.map((path) => `- \`${path}\``)
4843
+ ].join("\n"),
4844
+ reviewers: await reviewers()
4845
+ });
4846
+ } : void 0;
4847
+ await askLimits(outside, live.content, submit);
4848
+ refreshDrafts(current);
4849
+ return {
4850
+ ok: false,
4851
+ reason: "checks",
4852
+ error: void 0
4853
+ };
4854
+ }
3471
4855
  if (!await confirmChecks(await checkPublish(store, live, changes, {
3472
4856
  blocks,
3473
4857
  code
@@ -3504,7 +4888,12 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3504
4888
  key,
3505
4889
  t,
3506
4890
  confirmChecks,
3507
- onSignInError
4891
+ onSignInError,
4892
+ account,
4893
+ backendDrafts,
4894
+ askLimits,
4895
+ reviewers,
4896
+ refreshDrafts
3508
4897
  ]);
3509
4898
  const submitDraft = useCallback(async () => {
3510
4899
  if (!draft) return {
@@ -3519,8 +4908,10 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3519
4908
  t("draft.reviewDescription"),
3520
4909
  "",
3521
4910
  ...paths.map((path) => `- \`${path}\``)
3522
- ].join("\n")
4911
+ ].join("\n"),
4912
+ reviewers: await reviewers()
3523
4913
  });
4914
+ refreshDrafts(draft);
3524
4915
  return {
3525
4916
  ok: true,
3526
4917
  url
@@ -3531,7 +4922,12 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3531
4922
  error
3532
4923
  };
3533
4924
  }
3534
- }, [draft, t]);
4925
+ }, [
4926
+ draft,
4927
+ t,
4928
+ reviewers,
4929
+ refreshDrafts
4930
+ ]);
3535
4931
  const discardDraft = useCallback(async () => {
3536
4932
  if (!draft) return { ok: true };
3537
4933
  try {
@@ -3550,6 +4946,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3550
4946
  const drafts = useMemo(() => ({
3551
4947
  kept: backendDrafts === void 0,
3552
4948
  names: draftNames,
4949
+ reviews: draftReviews,
3553
4950
  ...draft && { current: {
3554
4951
  name: draft.name,
3555
4952
  paths: draftPaths
@@ -3561,6 +4958,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3561
4958
  }), [
3562
4959
  backendDrafts,
3563
4960
  draftNames,
4961
+ draftReviews,
3564
4962
  draft,
3565
4963
  draftPaths,
3566
4964
  openDraft,
@@ -3609,8 +5007,10 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3609
5007
  state: "building"
3610
5008
  });
3611
5009
  }, [state, store]);
5010
+ const pluginInstalls = demo || contentOnly ? void 0 : account?.review ? "review" : atAdminAddress(site.admin) || site.broker !== void 0 ? "publish" : void 0;
3612
5011
  const value = useMemo(() => ({
3613
5012
  site,
5013
+ ...limits && { limits },
3614
5014
  blocks,
3615
5015
  code,
3616
5016
  preview,
@@ -3624,8 +5024,10 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3624
5024
  drafts,
3625
5025
  readFile,
3626
5026
  listFiles,
5027
+ readBytes,
3627
5028
  registries,
3628
5029
  outOfReach,
5030
+ pluginInstalls,
3629
5031
  demo,
3630
5032
  pages,
3631
5033
  versions,
@@ -3637,9 +5039,11 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3637
5039
  scheduledBuilds,
3638
5040
  repositoryVisibility,
3639
5041
  scheduleSyncProblem,
3640
- followBuild
5042
+ followBuild,
5043
+ tasks
3641
5044
  }), [
3642
5045
  site,
5046
+ limits,
3643
5047
  blocks,
3644
5048
  code,
3645
5049
  preview,
@@ -3653,8 +5057,10 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3653
5057
  drafts,
3654
5058
  readFile,
3655
5059
  listFiles,
5060
+ readBytes,
3656
5061
  registries,
3657
5062
  outOfReach,
5063
+ pluginInstalls,
3658
5064
  demo,
3659
5065
  pages,
3660
5066
  versions,
@@ -3666,7 +5072,8 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3666
5072
  scheduledBuilds,
3667
5073
  repositoryVisibility,
3668
5074
  scheduleSyncProblem,
3669
- followBuild
5075
+ followBuild,
5076
+ tasks
3670
5077
  ]);
3671
5078
  const table = useStringTable();
3672
5079
  const strings = useMemo(() => draft ? {
@@ -3679,16 +5086,28 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3679
5086
  value,
3680
5087
  children: /* @__PURE__ */ jsxs(StringsProvider, {
3681
5088
  strings,
3682
- children: [state.status === "ready" ? /* @__PURE__ */ jsx(SiteFrames, {
3683
- frames,
3684
- state,
3685
- onCodeClient,
3686
- children
3687
- }) : children, checking && /* @__PURE__ */ jsx(ChecksDialog, {
3688
- checks: checking.checks,
3689
- content: checking.content,
3690
- onDecide: checking.decide
3691
- })]
5089
+ children: [
5090
+ state.status === "ready" ? /* @__PURE__ */ jsx(SiteFrames, {
5091
+ frames,
5092
+ state,
5093
+ onCodeClient,
5094
+ children
5095
+ }) : children,
5096
+ checking && /* @__PURE__ */ jsx(ChecksDialog, {
5097
+ checks: checking.checks,
5098
+ content: checking.content,
5099
+ onDecide: checking.decide
5100
+ }),
5101
+ limiting && /* @__PURE__ */ jsx(LimitsDialog, {
5102
+ problems: limiting.problems,
5103
+ content: limiting.content,
5104
+ ...limiting.submit && { onSubmit: limiting.submit },
5105
+ onClose: () => {
5106
+ setLimiting(void 0);
5107
+ limiting.done();
5108
+ }
5109
+ })
5110
+ ]
3692
5111
  })
3693
5112
  });
3694
5113
  }
@@ -3711,7 +5130,7 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
3711
5130
  const t = useStrings();
3712
5131
  const strings = useStringTable();
3713
5132
  const theme = useThemeChoice();
3714
- const { preview, siteUrl, mediaPreviews, account, scheduledBuilds, repositoryVisibility, scheduleSyncProblem } = useAdmin();
5133
+ const { site, preview, siteUrl, mediaPreviews, account, scheduledBuilds, repositoryVisibility, scheduleSyncProblem, readFile, readBytes, listFiles, publish, tasks } = useAdmin();
3715
5134
  const { timing, visibility } = useScheduleInfoOf(scheduledBuilds, repositoryVisibility);
3716
5135
  const schedule = useMemo(() => ({
3717
5136
  ...timing && { timing },
@@ -3725,11 +5144,48 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
3725
5144
  const siteCss = useSiteCss(preview.stylesheets);
3726
5145
  const [choosing, setChoosing] = useState();
3727
5146
  const confirmLeave = t("unsaved.confirm");
5147
+ const plugins = useMemo(() => site.description.plugins ?? [], [site.description.plugins]);
5148
+ const pluginHost = useMemo(() => ({
5149
+ editorUrl: frames.url,
5150
+ siteUrl,
5151
+ theme,
5152
+ read: readFile,
5153
+ readBytes,
5154
+ list: listFiles,
5155
+ publish: async (changes, message) => (await publish(changes, message)).ok,
5156
+ ...account?.plugins && { server: account.plugins },
5157
+ navigate: (href) => navigate(href, () => window.confirm(confirmLeave)),
5158
+ secretsHelp: async (plugin) => pluginSecretsHelp(await tasks?.secretsPlace().catch(() => void 0), plugin, t),
5159
+ ...tasks && { runTask: async (request) => {
5160
+ try {
5161
+ return await tasks.run(request);
5162
+ } catch (error) {
5163
+ if (error instanceof PluginTaskError) throw new Error(t(`plugins.task.${error.problem}`));
5164
+ throw error;
5165
+ }
5166
+ } }
5167
+ }), [
5168
+ frames.url,
5169
+ siteUrl,
5170
+ theme,
5171
+ readFile,
5172
+ readBytes,
5173
+ listFiles,
5174
+ publish,
5175
+ account,
5176
+ confirmLeave,
5177
+ tasks,
5178
+ t
5179
+ ]);
5180
+ const callPlugin = useRef(void 0);
5181
+ const onPluginCall = useCallback((call) => {
5182
+ callPlugin.current = call;
5183
+ }, []);
3728
5184
  const services = useMemo(() => ({
3729
5185
  source: frames,
3730
5186
  init: {
3731
5187
  content: state.content,
3732
- media: state.media,
5188
+ media: allMedia(state.content, state.media),
3733
5189
  schedule,
3734
5190
  siteUrl,
3735
5191
  preview,
@@ -3748,7 +5204,8 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
3748
5204
  };
3749
5205
  });
3750
5206
  }),
3751
- navigate: (href) => navigate(href, () => window.confirm(confirmLeave))
5207
+ navigate: (href) => navigate(href, () => window.confirm(confirmLeave)),
5208
+ callPlugin: (plugin, method, args) => callPlugin.current ? callPlugin.current(plugin, method, args) : Promise.reject(/* @__PURE__ */ new Error("Plugins haven't started."))
3752
5209
  }), [
3753
5210
  frames,
3754
5211
  state,
@@ -3765,7 +5222,12 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
3765
5222
  return /* @__PURE__ */ jsxs(FrameServicesProvider, {
3766
5223
  value: services,
3767
5224
  children: [
3768
- children,
5225
+ /* @__PURE__ */ jsx(PluginServices, {
5226
+ plugins,
5227
+ host: pluginHost,
5228
+ onCall: onPluginCall,
5229
+ children
5230
+ }),
3769
5231
  /* @__PURE__ */ jsx(SiteCodeFrame, { onClient: onCodeClient }),
3770
5232
  choosing && /* @__PURE__ */ jsx(MediaPickerDialog, {
3771
5233
  kind: choosing.kind,
@@ -3949,4 +5411,4 @@ function formValues(collection, entry) {
3949
5411
  return values;
3950
5412
  }
3951
5413
  //#endregion
3952
- export { defaultStrings as $, MediaChoiceContext as A, storedCss as At, useFrameUpdates as B, navigate as C, newCollectionSettings as Ct, REGISTRY_VERSION as D, redirectsFileChange as Dt, useUnsavedChanges as E, pageTitle as Et, useOutsideHost as F, editedMarkdown as Ft, Button as G, CONNECT_MESSAGE as H, PublishFailure as I, formattedHtml as It, Field as J, Dialog as K, FrameServicesProvider as L, MediaField as M, storedEntryFields as Mt, MediaScreen as N, uniqueName as Nt, createMediaPreviews as O, siteSettingsFileChange as Ot, prepareUpload as P, updateSettingsFileChange as Pt, StringsProvider as Q, reloadFrames as R, hasUnsavedChanges as S, movePageChanges as St, useRoute as T, pageFileChange as Tt, createChannel as U, READY_MESSAGE as V, draftStarted as W, TextField as X, PublishedNotice as Y, useDebounced as Z, scheduleSettingsChanges as _, entryFileChange$1 as _t, AdminContext as a, mediaUrl as at, collectionHref as b, menusFileChange as bt, useCanChangeSettings as c, addressPatternFor as ct, useApplyThemeChoice as d, codeFileChanges as dt, useStringTable as et, useDarkMode as f, collectionFileChange$1 as ft, ScheduleStatus as g, duplicatePageData as gt, ScheduleFields as h, deleteCollectionChanges as ht, usePreviewStyles as i, fileName as it, MediaChooser as j, storedData as jt, showMediaInPreview as k, slugify as kt, useSiteContent as l, checkEntrySlug as lt, ComingUp as m, customCssFileChange as mt, formValues as n, describeContrast as nt, AdminProvider as o, siteImages as ot, useThemeChoice as p, collectionSettingsChanges as pt, ErrorMessage as q, PreviewFrame as r, findPage as rt, useAdmin as s, uploadName as st, SCHEDULE_PROP as t, useStrings as tt, setThemeChoice as u, checkPageAddress as ut, Link as v, layoutFileChange as vt, pageEditorHref as w, newPageData as wt, entryEditorHref as x, moveEntryChanges as xt, SETTINGS_SECTIONS as y, mediaFileChange as yt, useFrame as z };
5414
+ export { PublishedNotice as $, useRoute as A, pageFileChange as At, mayChange as B, editedMarkdown as Bt, AppLink as C, layoutFileChange as Ct, hasUnsavedChanges as D, movePageChanges as Dt, entryEditorHref as E, moveEntryChanges as Et, PluginScreenFrame as F, storedCss as Ft, useFrameServices as G, FrameServicesProvider as H, usePluginFrame as I, storedData as It, draftStarted as J, useFrameUpdates as K, usePluginHost as L, storedEntryFields as Lt, prepareUpload as M, redirectsFileChange as Mt, useOutsideHost as N, siteSettingsFileChange as Nt, navigate as O, newCollectionSettings as Ot, PublishFailure as P, slugify as Pt, Field as Q, createMediaPreviews as R, uniqueName as Rt, MediaScreen as S, entryFileChange$1 as St, collectionHref as T, menusFileChange as Tt, reloadFrames as U, useMayChange as V, formattedHtml as Vt, useFrame as W, Dialog as X, Button as Y, ErrorMessage as Z, scheduleSettingsChanges as _, collectionFileChange$1 as _t, AdminContext as a, useStrings as at, MediaChooser as b, deleteCollectionChanges as bt, useCanChangeSettings as c, allMedia as ct, useApplyThemeChoice as d, siteImages as dt, TextField as et, useDarkMode as f, uploadName as ft, ScheduleStatus as g, codeFileChanges as gt, ScheduleFields as h, checkPageAddress as ht, usePreviewStyles as i, useStringTable as it, useUnsavedChanges as j, pageTitle$1 as jt, pageEditorHref as k, newPageData as kt, useSiteContent as l, fileName as lt, ComingUp as m, checkEntrySlug as mt, formValues as n, StringsProvider as nt, AdminProvider as o, describeContrast as ot, useThemeChoice as p, addressPatternFor as pt, READY_MESSAGE as q, PreviewFrame as r, defaultStrings as rt, useAdmin as s, findPage as st, SCHEDULE_PROP as t, useDebounced as tt, setThemeChoice as u, mediaUrl as ut, REGISTRY_VERSION as v, collectionSettingsChanges as vt, SETTINGS_SECTIONS as w, mediaFileChange as wt, MediaField as x, duplicatePageData as xt, MediaChoiceContext as y, customCssFileChange as yt, showMediaInPreview as z, updateSettingsFileChange as zt };