@goodfellow-cms/admin 0.5.0 → 0.6.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, loadSiteContent, markdownToHtml, mediaFileChange, mediaKind, mediaKind as mediaKind$1, mediaStorageFileChange, memoryKeeper, menusFileChange, moveEntryChanges, movePageChanges, newCollectionSettings, newDraftName, newPageData, newTaskRequestId, openTaskAnswer, outsideHost, pageFileChange, pageTitle as pageTitle$1, pluginFrameUrl, pluginMayList, pluginMayUse, publishProblems, redirectsFileChange, relinkSite, relinkedChanges, 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,10 @@ 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.standard": "Standard editor",
497
+ "editor.plugin.failed": "{editor} didn't start. Choose the standard editor to keep editing.",
498
+ "editor.plugin.invalid": "{editor} gave back something this site can't publish, so nothing was published. Try again, or choose the standard editor.",
474
499
  "publish.publishing": "Publishing…",
475
500
  "publish.done": "Published.",
476
501
  "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.",
@@ -619,7 +644,7 @@ const defaultStrings = {
619
644
  "versions.back": "Back to editing",
620
645
  "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
646
  "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.",
647
+ "versions.local": "Version history is in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
623
648
  "versions.demo": "Version history isn't kept in a demo.",
624
649
  "versions.loading": "Loading versions…",
625
650
  "versions.loadingVersion": "Loading this version…",
@@ -863,14 +888,51 @@ const defaultStrings = {
863
888
  "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
889
  "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
890
  "media.choose": "Choose image",
891
+ "media.chooseAudio": "Choose sound file",
866
892
  "media.chooseFile": "Choose file",
867
893
  "media.chooseVideo": "Choose video",
868
894
  "media.pickerTitle": "Choose an image",
895
+ "media.pickerTitleAudio": "Choose a sound file",
869
896
  "media.pickerTitleFile": "Choose a file",
870
897
  "media.pickerTitleVideo": "Choose a video",
871
898
  "media.pickerEmpty": "Nothing has been uploaded yet. Upload a file to use it here.",
872
899
  "media.clear": "Remove",
900
+ "table.addRow": "Add a row",
901
+ "table.addColumn": "Add a column",
902
+ "table.removeRow": "Remove this row",
903
+ "table.removeRowNumber": "Remove row {row}",
904
+ "table.removeColumn": "Remove this column",
905
+ "table.removeColumnNumber": "Remove column {column}",
906
+ "table.cell": "Row {row}, column {column}",
873
907
  "media.credit": "Credit: {credit}",
908
+ "media.stored": "Kept by {plugin}",
909
+ "media.error.storageSize": "{name} is too large: {plugin} takes files up to {size}.",
910
+ "media.storage.title": "Where media is kept",
911
+ "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.",
912
+ "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.",
913
+ "media.storage.notReady": "{plugin} can't take files yet. {message}",
914
+ "media.storage.unavailable": "{plugin} didn't answer. Reload the admin panel and try again.",
915
+ "media.storage.settings": "Open {plugin}'s settings",
916
+ "media.storage.failed": "{plugin} couldn't keep {name}.",
917
+ "media.storage.uploading": "Uploading to {plugin}… This takes about a minute.",
918
+ "media.storage.wait": "Each group of files takes about a minute.",
919
+ "media.move.button": "Move {count} files to {plugin}",
920
+ "media.move.title": "Move the site's media to {plugin}?",
921
+ "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.",
922
+ "media.move.moving": "Moved {done} of {total}…",
923
+ "media.move.busy": "Moving…",
924
+ "media.move.nothing": "No files were moved.",
925
+ "media.move.some": "Moved {count} files to {plugin}, then stopped.",
926
+ "media.move.done": "Moved {count} files to {plugin}.",
927
+ "media.move.message": "Move media to {plugin}",
928
+ "media.back.button": "Move back to the site",
929
+ "media.back.title": "Move the media back from {plugin}?",
930
+ "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.",
931
+ "media.back.empty": "{plugin} keeps no files now. New files will go into the site's own files.",
932
+ "media.back.large": "These are larger than 25 MB, so {plugin} keeps them: {names}",
933
+ "media.back.failed": "{name} couldn't be copied back from {plugin}'s storage, which may not allow it.",
934
+ "media.back.done": "Moved {count} files back from {plugin}.",
935
+ "media.back.message": "Move media back from {plugin}",
874
936
  "ai.label": "AI",
875
937
  "ai.title": "Write with AI",
876
938
  "ai.mode.add": "Describe what to add to this page, and AI will write it at the end of the page.",
@@ -954,7 +1016,7 @@ const defaultStrings = {
954
1016
  "domain.current": "The site is at {address} now.",
955
1017
  "domain.connected": "Connected to {domain}.",
956
1018
  "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.",
1019
+ "domain.local": "Domains are connected in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
958
1020
  "domain.admin.title": "Admin address",
959
1021
  "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
1022
  "domain.admin.unsupported": "{host} serves each site at one address, so the admin panel can't have one of its own here.",
@@ -1047,7 +1109,7 @@ const defaultStrings = {
1047
1109
  "owner.token.paste": "Copy the token and paste it here:",
1048
1110
  "settings.tab.security": "Security",
1049
1111
  "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.",
1112
+ "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
1113
  "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
1114
  "security.broker.title": "Sign-in page",
1053
1115
  "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 +1186,186 @@ const defaultStrings = {
1124
1186
  "settings.tab.updates": "Updates",
1125
1187
  "settings.tab.history": "Site history",
1126
1188
  "settings.tab.import": "WordPress",
1189
+ "settings.tab.themes": "Themes",
1190
+ "plugins.notFound": "The site has no plugin screen at this address.",
1191
+ "plugins.failed": "{plugin}'s screen couldn't start. If it keeps happening, the plugin may need updating.",
1192
+ "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.",
1193
+ "themes.installed": "Installed theme",
1194
+ "themes.noneInstalled": "No theme is installed. Your site uses its own colors and fonts.",
1195
+ "themes.installedBadge": "Installed",
1196
+ "themes.release": "version {version}",
1197
+ "themes.updateAvailable": "Version {version} is out",
1198
+ "themes.updateTo": "Update to {version}",
1199
+ "themes.seeVersion": "See version {version}",
1200
+ "themes.upToDate": "Up to date",
1201
+ "themes.show": "Show",
1202
+ "themes.showLabel": "Show {name}",
1203
+ "themes.find": "Find a theme",
1204
+ "themes.search": "Search themes",
1205
+ "themes.searchButton": "Search",
1206
+ "themes.loading": "Looking for themes…",
1207
+ "themes.noneFound": "No themes were found.",
1208
+ "themes.byPublisher": "{name}, published by {publisher}",
1209
+ "themes.releaseDetails": "{name}, version {version}, published {date}",
1210
+ "themes.provenance": "Built from {source}, which npm checked when it was published.",
1211
+ "themes.noProvenance": "There's no record of where this version was built, so nobody has checked it came from its publisher's own code.",
1212
+ "themes.waiting": "Version {version} can be installed once it's {days} days old, since most harmful versions are found by then.",
1213
+ "themes.incompatible": "Version {version} doesn't say it works with Goodfellow {goodfellow}.",
1214
+ "themes.tooNew": "This theme's versions are too new to install. Versions can be installed once they're {days} days old.",
1215
+ "themes.notCompatible": "No version of this theme says it works with Goodfellow {version}.",
1216
+ "themes.imageAlt": "A site with the {title} theme",
1217
+ "themes.loads": "This theme asks visitors' browsers for these files from other sites, which can tell those sites who visits yours:",
1218
+ "themes.useLayout": "Use the theme's header and footer in place of yours. Your menus, logo and contact details stay as they are.",
1219
+ "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.",
1220
+ "themes.kept": "You changed these since the version before, so they stay as you set them: {list}.",
1221
+ "themes.replaced": "You changed these, and installing this theme replaces your changes: {list}.",
1222
+ "themes.changed.css": "the theme's CSS",
1223
+ "themes.preview": "Your site with {title}",
1224
+ "themes.previewPage": "Page to look at",
1225
+ "themes.install": "Install and publish",
1226
+ "themes.installDemo": "Install",
1227
+ "themes.installMessage": "Install the {title} theme, version {version}",
1228
+ "themes.updateMessage": "Update the {title} theme to version {version}",
1229
+ "themes.installedNotice": "{title} is installed. Your site will show it once it's rebuilt.",
1230
+ "themes.updated": "{title} is updated to version {version}.",
1231
+ "themes.remove": "Remove",
1232
+ "themes.removeTitle": "Remove {title}?",
1233
+ "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.",
1234
+ "themes.removeReplaced": "Removing it also removes your changes to: {list}.",
1235
+ "themes.removeMessage": "Remove the {title} theme",
1236
+ "themes.removed": "{title} is removed.",
1237
+ "themes.error.record": "The record of which theme is installed can't be read.",
1238
+ "themes.error.unreachable": "Themes can't be looked up right now. Check your internet connection and try again.",
1239
+ "themes.error.badFile": "This theme has a problem in its files, so it can't be installed. Its publisher needs to fix it.",
1240
+ "themes.error.notATheme": "This package isn't a Goodfellow theme.",
1241
+ "settings.tab.plugins": "Plugins",
1242
+ "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.",
1243
+ "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.",
1244
+ "plugins.where.contentOnly": "This site's code is kept apart from its content, so a developer adds plugins there.",
1245
+ "plugins.where.demo": "Plugins can't be installed in a demo.",
1246
+ "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.",
1247
+ "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.",
1248
+ "plugins.installed": "Installed plugins",
1249
+ "plugins.noneInstalled": "No plugins are installed.",
1250
+ "plugins.inCode": "Added by a developer",
1251
+ "plugins.installedBadge": "Installed",
1252
+ "plugins.release": "version {version}",
1253
+ "plugins.updateAvailable": "Version {version} is out",
1254
+ "plugins.updateTo": "Update to {version}",
1255
+ "plugins.seeVersion": "See version {version}",
1256
+ "plugins.upToDate": "Up to date",
1257
+ "plugins.show": "Show",
1258
+ "plugins.showLabel": "Show {name}",
1259
+ "plugins.find": "Find a plugin",
1260
+ "plugins.search": "Search plugins",
1261
+ "plugins.searchButton": "Search",
1262
+ "plugins.loading": "Looking for plugins…",
1263
+ "plugins.noneFound": "No plugins were found.",
1264
+ "plugins.byPublisher": "{name}, published by {publisher}",
1265
+ "plugins.releaseDetails": "{name}, version {version}, published {date}",
1266
+ "plugins.provenance": "Built from {source}, which npm checked when it was published.",
1267
+ "plugins.noProvenance": "There's no record of where this version was built, so nobody has checked it came from its publisher's own code.",
1268
+ "plugins.waiting": "Version {version} can be installed once it's {days} days old, since most harmful versions are found by then.",
1269
+ "plugins.incompatible": "Version {version} doesn't say it works with Goodfellow {goodfellow}.",
1270
+ "plugins.tooNew": "This plugin's versions are too new to install. Versions can be installed once they're {days} days old.",
1271
+ "plugins.notCompatible": "No version of this plugin says it works with Goodfellow {version}.",
1272
+ "plugins.asks": "What it asks to do",
1273
+ "plugins.asks.connect": "Reach these addresses from the admin panel: {list}",
1274
+ "plugins.asks.files": "Read and publish these files: {list}",
1275
+ "plugins.asks.secrets": "Get these secrets when your site is built: {list}",
1276
+ "plugins.asks.read": "Read these parts of your site when it's built: {list}",
1277
+ "plugins.asks.write": "Write these parts of the built site: {list}",
1278
+ "plugins.asks.blocks": "Add these blocks: {list}",
1279
+ "plugins.asks.admin": "Run in the admin panel, in a frame of its own that can't reach your sign-in",
1280
+ "plugins.asks.screens": "Add these screens to Site Settings: {list}",
1281
+ "plugins.asks.build": "Run these steps when your site is built: {list}",
1282
+ "plugins.asks.tasks": "Run these tasks with its secrets when the admin panel asks, each taking about a minute: {list}",
1283
+ "plugins.secrets.github.title": "Add them on GitHub",
1284
+ "plugins.secrets.github.link": "Open GitHub's form for a new secret",
1285
+ "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.",
1286
+ "plugins.secrets.github.add": "For {name}: type {name} in Name, paste its value in Secret, and choose Add secret.",
1287
+ "plugins.secrets.github.who": "Only the site's owners on GitHub can open the form.",
1288
+ "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.",
1289
+ "plugins.secrets.gitlab.title": "Add them on GitLab",
1290
+ "plugins.secrets.gitlab.link": "Open the site's CI/CD settings on GitLab",
1291
+ "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.",
1292
+ "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.",
1293
+ "plugins.secrets.gitlab.who": "Only the site's owners (Maintainers) on GitLab can change its CI/CD settings.",
1294
+ "plugins.secrets.bitbucket.title": "Add them on Bitbucket",
1295
+ "plugins.secrets.bitbucket.link": "Open the site's Pipelines variables on Bitbucket",
1296
+ "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.",
1297
+ "plugins.secrets.bitbucket.add": "For {name}: type {name} in Name, paste its value in Value, tick Secured, and choose Add.",
1298
+ "plugins.secrets.bitbucket.who": "Only the site's owners (with Admin permission) on Bitbucket can change its variables.",
1299
+ "plugins.secrets.development.title": "Add them on this computer",
1300
+ "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}:",
1301
+ "plugins.secrets.development.values": "Put each value after its =, save the file, and try again. There's no need to restart the development server.",
1302
+ "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.",
1303
+ "plugins.secrets.server.title": "Add them to the site's server",
1304
+ "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.",
1305
+ "plugins.secrets.server.deploy": "Then deploy the site again so the server has them.",
1306
+ "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.",
1307
+ "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.",
1308
+ "plugins.task.not-allowed": "Your sign-in can't run plugins' tasks. Ask the site's owner to let you publish.",
1309
+ "plugins.task.timeout": "The site's build service didn't answer in time. Try again in a few minutes.",
1310
+ "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.",
1311
+ "plugins.asks.server": "Answer requests on your site's server, with everything the server can do",
1312
+ "plugins.asks.mediaStorage": "Keep your site's media in a storage service, outside the site's own files",
1313
+ "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",
1314
+ "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.",
1315
+ "plugins.asks.new": "This version asks for more than the one installed:",
1316
+ "plugins.code": "What its code does",
1317
+ "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.",
1318
+ "plugins.code.reading": "Reading its code…",
1319
+ "plugins.code.via": "needed by {via}",
1320
+ "plugins.code.none": "Nothing to look at was found.",
1321
+ "plugins.code.summary": "Its code: {list}.",
1322
+ "plugins.code.nothing": "Nothing to look at was found in its code.",
1323
+ "plugins.code.unreadSummary": "{count} of its packages couldn't be read in full.",
1324
+ "plugins.note.network": "makes network requests",
1325
+ "plugins.note.programs": "runs other programs on the computer that builds your site",
1326
+ "plugins.note.eval": "runs code made from text, which can't be read beforehand",
1327
+ "plugins.note.native": "includes native code, which can't be read",
1328
+ "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",
1329
+ "plugins.unread.too-large": "Too large to read in full.",
1330
+ "plugins.unread.too-many": "Not read: the plugin needs too many packages to read them all.",
1331
+ "plugins.unread.no-release": "No version of it is old enough yet, so your site couldn't be built with this plugin.",
1332
+ "plugins.unread.range": "Comes from somewhere other than npm, so it wasn't read.",
1333
+ "plugins.unread.unavailable": "Couldn't be read.",
1334
+ "plugins.install": "Install",
1335
+ "plugins.confirmTitle": "Install {title}?",
1336
+ "plugins.confirmIntro": "{name} version {version} asks to:",
1337
+ "plugins.confirmConfig": "Your site's settings file is changed too, to list the plugins installed here, since it didn't yet.",
1338
+ "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.",
1339
+ "plugins.confirmReview": "Your site's server holds its secrets, so installing a plugin is submitted for review, for a developer to accept.",
1340
+ "plugins.confirmInstall": "Install and publish",
1341
+ "plugins.submit": "Submit for review",
1342
+ "plugins.reviewIntro": "Installs {name} version {version}, from the admin panel's Plugins screen. It asks to:",
1343
+ "plugins.installMessage": "Install the {title} plugin, version {version}",
1344
+ "plugins.updateMessage": "Update the {title} plugin to version {version}",
1345
+ "plugins.installedNotice": "{title} is installed. Your site will have it once it's rebuilt, in a few minutes.",
1346
+ "plugins.updated": "{title} is updated to version {version}.",
1347
+ "plugins.submitted": "The change to {title} is submitted for review.",
1348
+ "plugins.seeReview": "See it",
1349
+ "plugins.remove": "Remove",
1350
+ "plugins.removeReview": "Submit for review",
1351
+ "plugins.removeTitle": "Remove {title}?",
1352
+ "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.",
1353
+ "plugins.removeUsed": "Pages use its blocks ({blocks}), so it can't be removed. Take them off those pages first.",
1354
+ "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.",
1355
+ "plugins.removeMessage": "Remove the {title} plugin",
1356
+ "plugins.removed": "{title} is removed.",
1357
+ "plugins.error.record": "The record of which plugins are installed can't be read.",
1358
+ "plugins.error.unreachable": "Plugins can't be looked up right now. Check your internet connection and try again.",
1359
+ "plugins.error.badFile": "This plugin has a problem in its files, so it can't be installed. Its publisher needs to fix it.",
1360
+ "plugins.error.notAPlugin": "This package isn't a Goodfellow plugin.",
1361
+ "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.",
1362
+ "plugins.error.inCode": "A developer added this plugin to the site's code, so it's changed there.",
1363
+ "plugins.error.blockClash": "This plugin's blocks have the same names as ones your site has: {blocks}.",
1364
+ "plugins.error.notInstalled": "This plugin wasn't installed here.",
1365
+ "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.",
1366
+ "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
1367
  "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.",
1368
+ "siteHistory.local": "Site history is in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
1129
1369
  "siteHistory.demo": "Site history isn't kept in a demo.",
1130
1370
  "siteHistory.error": "The site's history couldn't be loaded. Check your connection and try again.",
1131
1371
  "siteHistory.current": "The site now",
@@ -1153,6 +1393,7 @@ const defaultStrings = {
1153
1393
  "siteHistory.subject.redirects": "Redirects",
1154
1394
  "siteHistory.subject.credits": "Photo credits",
1155
1395
  "siteHistory.subject.css": "Custom CSS",
1396
+ "siteHistory.subject.theme": "Which theme is installed",
1156
1397
  "siteHistory.subject.code": "Code added to every page",
1157
1398
  "import.title": "Import from WordPress",
1158
1399
  "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 +1431,7 @@ const defaultStrings = {
1190
1431
  "import.note.embed": "Something from {name} is now a link to it: {items}.",
1191
1432
  "import.note.form": "A form was left out. Add a Form block in its place: {items}.",
1192
1433
  "import.note.script": "A script was left out: {items}.",
1193
- "import.note.table": "A table became text, since pages have no tables: {items}.",
1434
+ "import.note.table": "A table became text, since the site has no Table block: {items}.",
1194
1435
  "import.note.posts": "A list of the latest posts was left out of a post: {items}.",
1195
1436
  "import.note.video": "A video is now a link to it, since posts show videos as links: {items}.",
1196
1437
  "import.note.oldLinks": "Links that still go to the WordPress site, to pages that weren't imported: {count}, such as {url}.",
@@ -1232,7 +1473,7 @@ const defaultStrings = {
1232
1473
  "updates.code.link": "See it on {host}",
1233
1474
  "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
1475
  "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.",
1476
+ "updates.local": "Updates are shown in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
1236
1477
  "updates.demo": "Updates aren't installed in a demo.",
1237
1478
  "updates.loading": "Checking for updates…",
1238
1479
  "updates.current": "This site uses Goodfellow {version}.",
@@ -1300,7 +1541,7 @@ const defaultStrings = {
1300
1541
  "updates.error.other": "Something went wrong. Try again in a moment.",
1301
1542
  "editors.intro": "The people who can edit this site.",
1302
1543
  "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.",
1544
+ "editors.local": "Editors are invited in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
1304
1545
  "editors.demo": "Editors can't be invited in a demo.",
1305
1546
  "editors.loading": "Loading editors…",
1306
1547
  "editors.onlyOwners": "Only the site's owners can invite and remove editors.",
@@ -1325,6 +1566,7 @@ const defaultStrings = {
1325
1566
  "editors.invite.title": "Invite someone",
1326
1567
  "editors.invite.field": "Their {host} username",
1327
1568
  "editors.invite.hint": "They need a {host} account. {host} emails them an invitation, which they accept before they can sign in here.",
1569
+ "editors.invite.hintMembers": "They need to be in the site's {host} workspace already. They can edit as soon as you add them.",
1328
1570
  "editors.invite.fieldEmail": "Their email address or {host} username",
1329
1571
  "editors.invite.hintEmail": "With an email address, {host} emails them an invitation, and they make an account if they don't have one.",
1330
1572
  "editors.invite.submit": "Invite",
@@ -1411,6 +1653,12 @@ const defaultStrings = {
1411
1653
  "theme.bodyFont": "Text",
1412
1654
  "theme.radius": "Corner rounding",
1413
1655
  "theme.radiusHint": "Such as 0.5rem, or 0 for square corners.",
1656
+ "theme.parts": "Blocks' parts",
1657
+ "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.",
1658
+ "theme.partsBlock": "Block",
1659
+ "theme.partsBlockStyled": "{block} ({count} styled)",
1660
+ "theme.partsName": "Its name is {name}, for custom CSS.",
1661
+ "theme.partsNone": "This site's blocks have no named parts.",
1414
1662
  "theme.color.background": "Background",
1415
1663
  "theme.color.foreground": "Text",
1416
1664
  "theme.color.primary": "Main color",
@@ -1766,120 +2014,6 @@ function draftStarted(name) {
1766
2014
  });
1767
2015
  }
1768
2016
  //#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
2017
  //#region src/frame/protocol.ts
1884
2018
  /** A frame sends this to the admin panel's page once it's listening, and the page answers with a port. */
1885
2019
  const READY_MESSAGE = "goodfellow:frame-ready";
@@ -1894,7 +2028,7 @@ function frameClient(channel) {
1894
2028
  };
1895
2029
  }
1896
2030
  /** 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;
2031
+ const MAX_CONNECTIONS$1 = 5;
1898
2032
  /** Each frame's way of loading its page again, for `reloadFrames()`. */
1899
2033
  const reloaders = /* @__PURE__ */ new Set();
1900
2034
  /**
@@ -1919,7 +2053,7 @@ function useFrameServices() {
1919
2053
  return value;
1920
2054
  }
1921
2055
  /** Whether a frame asked to go somewhere it may: one of the admin panel's own screens. */
1922
- function isAdminScreen(href) {
2056
+ function isAdminScreen$1(href) {
1923
2057
  return typeof href === "string" && /^#\/[\w\-/?=&%.]*$/.test(href);
1924
2058
  }
1925
2059
  /** Makes a frame of the site's code and connects to it. */
@@ -1945,13 +2079,17 @@ function useFrame({ mode, onConnect, media }) {
1945
2079
  let stopped = false;
1946
2080
  const handlers = {
1947
2081
  navigate: (href) => {
1948
- if (isAdminScreen(href)) latest.current.services.navigate(href);
2082
+ if (isAdminScreen$1(href)) latest.current.services.navigate(href);
1949
2083
  },
1950
2084
  "media.choose": (params) => {
1951
2085
  const kind = params?.kind;
1952
- if (kind !== "image" && kind !== "video" && kind !== "file") return null;
2086
+ if (kind !== "image" && kind !== "video" && kind !== "audio" && kind !== "file") return null;
1953
2087
  return latest.current.services.chooseMedia(kind, typeof params.value === "string" ? params.value : "");
1954
2088
  },
2089
+ "plugin.call": (params) => {
2090
+ if (mode !== "editor" || typeof params?.plugin !== "string" || typeof params.method !== "string") return null;
2091
+ return latest.current.services.callPlugin(params.plugin, params.method, Array.isArray(params.args) ? params.args : []);
2092
+ },
1955
2093
  "media.bytes": async (url) => {
1956
2094
  if (typeof url !== "string" || !url.startsWith("/media/") || url.includes("..")) return null;
1957
2095
  return await (latest.current.media ?? latest.current.services.mediaPreviews).bytes(url) ?? null;
@@ -1963,7 +2101,7 @@ function useFrame({ mode, onConnect, media }) {
1963
2101
  current?.close();
1964
2102
  setClient(void 0);
1965
2103
  connections += 1;
1966
- if (connections > MAX_CONNECTIONS) {
2104
+ if (connections > MAX_CONNECTIONS$1) {
1967
2105
  setFailed(true);
1968
2106
  return;
1969
2107
  }
@@ -2065,10 +2203,16 @@ function frameSiteCode(client) {
2065
2203
  targets,
2066
2204
  today
2067
2205
  });
2206
+ },
2207
+ async drawBlock(content, target) {
2208
+ return (await client()).call("code.drawBlock", {
2209
+ content,
2210
+ target
2211
+ });
2068
2212
  }
2069
2213
  };
2070
2214
  }
2071
- const HIDDEN = {
2215
+ const HIDDEN$1 = {
2072
2216
  position: "absolute",
2073
2217
  width: 0,
2074
2218
  height: 0,
@@ -2086,150 +2230,783 @@ function SiteCodeFrame({ onClient }) {
2086
2230
  title: "",
2087
2231
  "aria-hidden": "true",
2088
2232
  tabIndex: -1,
2089
- style: HIDDEN,
2233
+ style: HIDDEN$1,
2090
2234
  ...frameProps
2091
2235
  });
2092
2236
  }
2093
2237
  //#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 }) {
2097
- 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
2102
- });
2103
- }
2104
- //#endregion
2105
- //#region src/site-host.ts
2106
- /**
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.
2111
- */
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;
2128
- }
2129
- //#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 = {
2238
+ //#region src/media-previews.ts
2239
+ const TYPES = {
2144
2240
  jpg: "image/jpeg",
2145
2241
  jpeg: "image/jpeg",
2146
2242
  png: "image/png",
2147
- webp: "image/webp"
2243
+ gif: "image/gif",
2244
+ webp: "image/webp",
2245
+ avif: "image/avif",
2246
+ svg: "image/svg+xml",
2247
+ ico: "image/x-icon",
2248
+ pdf: "application/pdf",
2249
+ mp3: "audio/mpeg",
2250
+ m4a: "audio/mp4",
2251
+ mp4: "video/mp4",
2252
+ webm: "video/webm",
2253
+ vtt: "text/vtt"
2148
2254
  };
2149
- async function canvasBlob(canvas, type) {
2150
- return new Promise((resolve) => canvas.toBlob(resolve, type, .85));
2255
+ /** The type to show a file as, from its name. Needed so browsers display SVGs from memory. */
2256
+ function mediaType(name) {
2257
+ return TYPES[extensionOf(name)] ?? "application/octet-stream";
2151
2258
  }
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;
2259
+ function createMediaPreviews(store, siteUrl, storage = () => void 0) {
2260
+ const local = /* @__PURE__ */ new Map();
2261
+ const remembered = /* @__PURE__ */ new Map();
2262
+ const listeners = /* @__PURE__ */ new Set();
2263
+ const loading = /* @__PURE__ */ new Map();
2264
+ const base = trimChars(siteUrl, "/", { start: false });
2265
+ const remember = (url, bytes) => {
2266
+ const previous = local.get(url);
2267
+ if (previous) URL.revokeObjectURL(previous);
2268
+ remembered.set(url, bytes);
2269
+ for (const listener of listeners) listener(url, bytes);
2270
+ local.set(url, URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: mediaType(url) })));
2271
+ };
2272
+ return {
2273
+ resolve(url) {
2274
+ if (!url.startsWith("/media/")) return url;
2275
+ const end = url.search(/[?#]/);
2276
+ const stored = local.has(url) ? void 0 : storedMediaUrl(storage(), end < 0 ? url : url.slice(0, end));
2277
+ return stored ? `${stored}${end < 0 ? "" : url.slice(end)}` : local.get(url) ?? `${base}${url}`;
2278
+ },
2279
+ remember,
2280
+ onRemember(listener) {
2281
+ for (const [url, bytes] of remembered) listener(url, bytes);
2282
+ listeners.add(listener);
2283
+ return () => listeners.delete(listener);
2284
+ },
2285
+ async bytes(url) {
2286
+ const known = remembered.get(url);
2287
+ if (known) return known;
2288
+ const path = mediaPath(url);
2289
+ return path ? await store.readBytes(path).catch(() => void 0) : void 0;
2290
+ },
2291
+ load(url) {
2292
+ const known = local.get(url);
2293
+ if (known) return Promise.resolve(known);
2294
+ const path = mediaPath(url);
2295
+ if (!path) return Promise.resolve(void 0);
2296
+ let pending = loading.get(url);
2297
+ if (!pending) {
2298
+ pending = store.readBytes(path).then((bytes) => {
2299
+ if (!bytes) return void 0;
2300
+ remember(url, bytes);
2301
+ return local.get(url);
2302
+ }).catch(() => void 0);
2303
+ loading.set(url, pending);
2304
+ }
2305
+ return pending;
2306
+ }
2307
+ };
2178
2308
  }
2179
- const UNSAFE_SVG_ELEMENTS = [
2180
- "script",
2181
- "foreignObject",
2182
- "iframe",
2183
- "embed",
2184
- "object",
2185
- "audio",
2186
- "video"
2309
+ const URL_ATTRIBUTES = [
2310
+ "src",
2311
+ "href",
2312
+ "poster"
2187
2313
  ];
2314
+ const MEDIA_IN_STYLE = /url\((['"]?)(\/media\/[^'")]+)\1\)/g;
2188
2315
  /**
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.
2316
+ * Keeps media in a preview document showing: rewrites `/media/…` addresses in
2317
+ * images, links and inline styles as the preview changes, and if an image
2318
+ * still doesn't load, reads it from the repository instead.
2192
2319
  */
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
2320
+ function showMediaInPreview(doc, previews) {
2321
+ const fix = (element) => {
2322
+ for (const attribute of URL_ATTRIBUTES) {
2323
+ const value = element.getAttribute(attribute);
2324
+ if (!value?.startsWith("/media/")) continue;
2325
+ const resolved = previews.resolve(value);
2326
+ if (resolved !== value) {
2327
+ element.setAttribute(`data-gf-${attribute}`, value);
2328
+ element.setAttribute(attribute, resolved);
2329
+ }
2330
+ }
2331
+ const style = element.getAttribute("style");
2332
+ if (style?.includes("/media/")) {
2333
+ const resolved = style.replace(MEDIA_IN_STYLE, (_match, quote, url) => `url(${quote}${previews.resolve(url)}${quote})`);
2334
+ if (resolved !== style) element.setAttribute("style", resolved);
2335
+ }
2218
2336
  };
2219
- }
2220
- //#endregion
2221
- //#region src/media-library.tsx
2222
- /** A media file's picture: the image itself, or its kind of file for anything else. */
2223
- function MediaThumb({ url, className }) {
2224
- const { mediaPreviews } = useAdmin();
2225
- const [src, setSrc] = useState(() => mediaPreviews.resolve(url));
2226
- const tried = useRef(false);
2227
- if (mediaKind$1(url) !== "image") return /* @__PURE__ */ jsx("span", {
2228
- className: `gfa-media-thumb gfa-media-file ${className ?? ""}`,
2229
- "aria-hidden": "true",
2230
- children: extensionOf(url).toUpperCase()
2231
- });
2232
- return /* @__PURE__ */ jsx("img", {
2337
+ const fixTree = (root) => {
2338
+ fix(root);
2339
+ for (const element of root.querySelectorAll("[src], [href], [poster], [style]")) fix(element);
2340
+ };
2341
+ const observer = new MutationObserver((records) => {
2342
+ for (const record of records) {
2343
+ if (record.type === "attributes" && record.target.nodeType === 1) fix(record.target);
2344
+ for (const node of record.addedNodes) if (node.nodeType === 1) fixTree(node);
2345
+ }
2346
+ });
2347
+ observer.observe(doc.documentElement, {
2348
+ subtree: true,
2349
+ childList: true,
2350
+ attributes: true,
2351
+ attributeFilter: [...URL_ATTRIBUTES, "style"]
2352
+ });
2353
+ fixTree(doc.documentElement);
2354
+ const onError = (event) => {
2355
+ const image = event.target;
2356
+ if (image?.tagName !== "IMG") return;
2357
+ const original = image.getAttribute("data-gf-src") ?? image.getAttribute("src") ?? "";
2358
+ if (!original.startsWith("/media/") || image.hasAttribute("data-gf-loaded")) return;
2359
+ image.setAttribute("data-gf-loaded", "");
2360
+ previews.load(original).then((url) => {
2361
+ if (url) {
2362
+ image.setAttribute("data-gf-src", original);
2363
+ image.setAttribute("src", url);
2364
+ }
2365
+ });
2366
+ };
2367
+ doc.addEventListener("error", onError, true);
2368
+ return () => {
2369
+ observer.disconnect();
2370
+ doc.removeEventListener("error", onError, true);
2371
+ };
2372
+ }
2373
+ //#endregion
2374
+ //#region src/plugin-frames.tsx
2375
+ /**
2376
+ * The admin panel's side of plugins' admin frames: making them, connecting to
2377
+ * them, and answering only for what each plugin declared. Everything a frame
2378
+ * sends is treated as the plugin's code may have written it.
2379
+ */
2380
+ /** A plugin's frame asked for something it didn't declare, or that isn't there. */
2381
+ var RefusedError = class extends Error {
2382
+ name = "RefusedError";
2383
+ };
2384
+ /** How many times a plugin's frame may start again before it's given up on. */
2385
+ const MAX_CONNECTIONS = 5;
2386
+ /** How long a service call waits for the plugin's frame to start. */
2387
+ const START_TIMEOUT = 2e4;
2388
+ function isAdminScreen(href) {
2389
+ return typeof href === "string" && /^#\/[\w\-/?=&%.]*$/.test(href);
2390
+ }
2391
+ function strings(value, what) {
2392
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new RefusedError(`Say which ${what}.`);
2393
+ return value;
2394
+ }
2395
+ /** A plugin's file change, as the admin panel publishes it, if the plugin declared its file. */
2396
+ function toFileChange(plugin, value) {
2397
+ if (typeof value !== "object" || value === null) throw new RefusedError("That isn't a change.");
2398
+ const change = value;
2399
+ if (typeof change.path !== "string" || !pluginMayUse(plugin.permissions, change.path)) throw new RefusedError(`${plugin.title} didn't ask to change ${String(change.path)}.`);
2400
+ if (change.delete === true) return {
2401
+ path: change.path,
2402
+ delete: true
2403
+ };
2404
+ if (typeof change.text === "string") return {
2405
+ path: change.path,
2406
+ content: change.text
2407
+ };
2408
+ if (change.bytes instanceof Uint8Array) return {
2409
+ path: change.path,
2410
+ bytes: change.bytes
2411
+ };
2412
+ throw new RefusedError("That isn't a change.");
2413
+ }
2414
+ /** The tallest a screen's frame is made, whatever it says. */
2415
+ const MAX_SCREEN_HEIGHT = 2e4;
2416
+ const MEDIA_KINDS = /* @__PURE__ */ new Set([
2417
+ "image",
2418
+ "video",
2419
+ "audio",
2420
+ "file"
2421
+ ]);
2422
+ /** The answers a plugin's frame gets, each checked against what the plugin declared. */
2423
+ function pluginHandlers(plugin, host, onResize, editor) {
2424
+ /** The page edited with the plugin's editor, for requests only its editor frame may make. */
2425
+ const editing = (what) => {
2426
+ const hooks = editor();
2427
+ if (!hooks) throw new RefusedError(`Only an editor's frame may ${what}.`);
2428
+ return hooks;
2429
+ };
2430
+ const secretsLinks = /* @__PURE__ */ new Set();
2431
+ return {
2432
+ read: async (params) => {
2433
+ const paths = strings(params?.paths, "files");
2434
+ const refused = paths.find((path) => !pluginMayUse(plugin.permissions, path));
2435
+ if (refused) throw new RefusedError(`${plugin.title} didn't ask to read ${refused}.`);
2436
+ const read = await Promise.all(paths.map(async (path) => [path, await host().read(path) ?? null]));
2437
+ return Object.fromEntries(read);
2438
+ },
2439
+ readBytes: async (params) => {
2440
+ const path = params?.path;
2441
+ if (typeof path !== "string" || !pluginMayUse(plugin.permissions, path)) throw new RefusedError(`${plugin.title} didn't ask to read ${String(path)}.`);
2442
+ return await host().readBytes(path) ?? null;
2443
+ },
2444
+ list: async (params) => {
2445
+ const folder = params?.folder;
2446
+ if (typeof folder !== "string" || !pluginMayList(plugin.permissions, folder)) throw new RefusedError(`${plugin.title} didn't ask to list ${String(folder)}.`);
2447
+ return (await host().list(folder.replace(/\/+$/, ""))).filter((path) => pluginMayUse(plugin.permissions, path));
2448
+ },
2449
+ publish: async (params) => {
2450
+ if (!Array.isArray(params?.changes) || params.changes.length === 0 || typeof params.message !== "string") throw new RefusedError("That isn't a publish.");
2451
+ const changes = params.changes.map((change) => toFileChange(plugin, change));
2452
+ return { published: await host().publish(changes, params.message.slice(0, 500)) };
2453
+ },
2454
+ server: async (params) => {
2455
+ const base = host().server;
2456
+ if (!plugin.server || !base) throw new RefusedError(`${plugin.title} has no server here.`);
2457
+ const path = params?.path;
2458
+ if (typeof path !== "string" || path.includes("..") || /^[a-z]+:/i.test(path)) throw new RefusedError("That isn't one of the plugin's addresses.");
2459
+ const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
2460
+ const headers = Object.fromEntries(Object.entries(params.headers ?? {}).filter(([name, value]) => typeof value === "string" && /^(content-type|accept|x-goodfellow-plugin-[\w-]+)$/i.test(name)));
2461
+ const response = await fetch(`${base.replace(/\/+$/, "")}/${encodeURIComponent(plugin.id)}/${path.replace(/^\/+/, "")}`, {
2462
+ method,
2463
+ headers: {
2464
+ ...headers,
2465
+ [APP_API_HEADER]: "1"
2466
+ },
2467
+ ...typeof params.body === "string" && method !== "GET" && method !== "HEAD" && { body: params.body },
2468
+ credentials: "same-origin",
2469
+ cache: "no-store"
2470
+ });
2471
+ return {
2472
+ status: response.status,
2473
+ body: await response.text()
2474
+ };
2475
+ },
2476
+ navigate: (href) => {
2477
+ if (isAdminScreen(href)) host().navigate(href);
2478
+ },
2479
+ "secrets.help": async () => {
2480
+ const help = await host().secretsHelp?.(plugin) ?? [];
2481
+ for (const { link } of help) if (link) secretsLinks.add(link.url);
2482
+ return help;
2483
+ },
2484
+ "secrets.open": (params) => {
2485
+ const url = params?.url;
2486
+ if (typeof url !== "string" || !secretsLinks.has(url)) throw new RefusedError("That isn't one of the links given.");
2487
+ window.open(url, "_blank", "noopener,noreferrer");
2488
+ },
2489
+ resize: (params) => {
2490
+ const height = params?.height;
2491
+ if (typeof height === "number" && Number.isFinite(height)) onResize(Math.min(Math.max(0, Math.ceil(height)), MAX_SCREEN_HEIGHT));
2492
+ },
2493
+ "editor.change": (params) => {
2494
+ const hooks = editing("say what's edited");
2495
+ if (typeof params !== "object" || params === null || typeof params.data !== "object" || params.data === null) throw new RefusedError("That isn't what's edited.");
2496
+ const state = typeof params.state === "object" && params.state !== null ? params.state : {};
2497
+ hooks.onChange({
2498
+ data: params.data,
2499
+ state: {
2500
+ past: state.past === true,
2501
+ future: state.future === true,
2502
+ selected: typeof state.selected === "string" ? state.selected : null
2503
+ }
2504
+ });
2505
+ },
2506
+ "media.choose": (params) => {
2507
+ const hooks = editing("choose media");
2508
+ const kind = params?.kind;
2509
+ if (typeof kind !== "string" || !MEDIA_KINDS.has(kind)) throw new RefusedError("That isn't a kind of media.");
2510
+ return hooks.chooseMedia(kind, typeof params.value === "string" ? params.value : "");
2511
+ },
2512
+ "editor.sharePreview": (params) => {
2513
+ return editing("preview a shared link").sharePreview(params?.root);
2514
+ },
2515
+ "editor.draw": (params) => {
2516
+ const hooks = editing("draw blocks");
2517
+ const block = params?.block;
2518
+ 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.");
2519
+ return hooks.draw(block);
2520
+ },
2521
+ "editor.styles": () => editing("read the site's styles").styles(),
2522
+ task: async (params) => {
2523
+ const id = params?.id;
2524
+ if (typeof id !== "string" || !plugin.tasks?.some((task) => task.id === id)) throw new RefusedError(`${plugin.title} has no task called ${String(id)}.`);
2525
+ const run = host().runTask;
2526
+ if (!run) throw new RefusedError(`${plugin.title}'s tasks can't run here.`);
2527
+ let input;
2528
+ try {
2529
+ input = JSON.stringify(params.input ?? null);
2530
+ } catch {
2531
+ throw new RefusedError("A task's input must be plain data.");
2532
+ }
2533
+ if (input.length > TASK_INPUT_LIMIT) throw new RefusedError("That's too much for one task.");
2534
+ const keys = await taskKeys();
2535
+ const sealed = await run({
2536
+ id: newTaskRequestId(),
2537
+ plugin: plugin.id,
2538
+ task: id,
2539
+ input,
2540
+ key: keys.publicKey
2541
+ });
2542
+ const answer = await openTaskAnswer(sealed, keys.privateKey);
2543
+ if (!answer.ok) throw new Error(answer.error.slice(0, 2e3));
2544
+ return answer.output;
2545
+ }
2546
+ };
2547
+ }
2548
+ /** Makes one of a plugin's frames and connects to it; an editor's frame with `editor`. */
2549
+ function usePluginFrame(plugin, host, mode, screen, editor) {
2550
+ const element = useRef(null);
2551
+ const [channel, setChannel] = useState();
2552
+ const [failed, setFailed] = useState(false);
2553
+ const [height, setHeight] = useState();
2554
+ const latest = useRef(host);
2555
+ latest.current = host;
2556
+ const latestEditor = useRef(editor);
2557
+ latestEditor.current = editor;
2558
+ useEffect(() => {
2559
+ let current;
2560
+ let connections = 0;
2561
+ let stopped = false;
2562
+ const onMessage = (event) => {
2563
+ const frame = element.current?.contentWindow;
2564
+ if (!frame || event.source !== frame || event.data !== "goodfellow:plugin-ready") return;
2565
+ current?.close();
2566
+ setChannel(void 0);
2567
+ connections += 1;
2568
+ if (connections > MAX_CONNECTIONS) {
2569
+ setFailed(true);
2570
+ return;
2571
+ }
2572
+ const { port1, port2 } = new MessageChannel();
2573
+ const next = createChannel(port1, pluginHandlers(plugin, () => latest.current, (next) => {
2574
+ if (mode === "screen") setHeight(next);
2575
+ }, () => mode === "editor" ? latestEditor.current : void 0));
2576
+ current = next;
2577
+ frame.postMessage({ type: CONNECT_MESSAGE }, "*", [port2]);
2578
+ const opening = mode === "editor" ? latestEditor.current?.open() : void 0;
2579
+ const init = {
2580
+ mode,
2581
+ ...screen && { screen },
2582
+ ...opening && { editor: opening },
2583
+ plugin,
2584
+ siteUrl: latest.current.siteUrl,
2585
+ theme: latest.current.theme
2586
+ };
2587
+ next.call("init", init).then((opened) => {
2588
+ if (stopped || current !== next) return;
2589
+ setChannel(next);
2590
+ if (mode === "editor") {
2591
+ const said = opened ?? {};
2592
+ latestEditor.current?.onOpened({
2593
+ set: said.set === true,
2594
+ undo: said.undo === true
2595
+ }, next);
2596
+ }
2597
+ }, () => {
2598
+ if (!stopped && current === next) setFailed(true);
2599
+ });
2600
+ };
2601
+ window.addEventListener("message", onMessage);
2602
+ return () => {
2603
+ stopped = true;
2604
+ window.removeEventListener("message", onMessage);
2605
+ current?.close();
2606
+ };
2607
+ }, [
2608
+ plugin,
2609
+ mode,
2610
+ screen
2611
+ ]);
2612
+ useEffect(() => {
2613
+ channel?.call("theme", host.theme).catch(() => {});
2614
+ }, [channel, host.theme]);
2615
+ return {
2616
+ channel,
2617
+ failed,
2618
+ frameProps: {
2619
+ ref: useCallback((frame) => {
2620
+ element.current = frame;
2621
+ }, []),
2622
+ sandbox: FRAME_SANDBOX,
2623
+ src: pluginFrameUrl(host.editorUrl, plugin.id),
2624
+ ...height !== void 0 && { style: { height } }
2625
+ }
2626
+ };
2627
+ }
2628
+ const HIDDEN = {
2629
+ position: "absolute",
2630
+ width: 0,
2631
+ height: 0,
2632
+ border: 0,
2633
+ visibility: "hidden"
2634
+ };
2635
+ /** One plugin's hidden frame for its services, which tells `onChannel` once it has started. */
2636
+ function ServiceFrame({ plugin, host, onChannel }) {
2637
+ const { channel, frameProps } = usePluginFrame(plugin, host, "service");
2638
+ useEffect(() => onChannel(plugin.id, channel), [
2639
+ plugin.id,
2640
+ channel,
2641
+ onChannel
2642
+ ]);
2643
+ return /* @__PURE__ */ jsx("iframe", {
2644
+ title: "",
2645
+ "aria-hidden": "true",
2646
+ tabIndex: -1,
2647
+ ...frameProps,
2648
+ style: HIDDEN
2649
+ });
2650
+ }
2651
+ const PluginHostContext = createContext(null);
2652
+ const PluginMediaContext = createContext(null);
2653
+ /**
2654
+ * The plugin keeping the site's media, by its name in
2655
+ * `content/media-storage.json`, where the site has it and it says it keeps
2656
+ * media. Outside `PluginServices`, such as in the site's frames, none.
2657
+ */
2658
+ function usePluginMedia(name) {
2659
+ const media = useContext(PluginMediaContext);
2660
+ return name && media ? media(name) : void 0;
2661
+ }
2662
+ /** What a plugin's frame said about being ready, as the admin panel shows it: its message is the plugin's own words. */
2663
+ function readyAnswer(plugin, value) {
2664
+ const answer = value ?? {};
2665
+ if (answer.ready === true) return { ready: true };
2666
+ const screen = typeof answer.screen === "string" && plugin.screens?.some((own) => own.id === answer.screen) ? answer.screen : void 0;
2667
+ return {
2668
+ ready: false,
2669
+ message: typeof answer.message === "string" ? answer.message.slice(0, 500) : "",
2670
+ ...screen && { screen }
2671
+ };
2672
+ }
2673
+ /** What plugins' screens are given, inside `PluginServices`. */
2674
+ function usePluginHost() {
2675
+ const value = useContext(PluginHostContext);
2676
+ if (!value) throw new Error("Plugins' frames need the admin panel around them.");
2677
+ return value;
2678
+ }
2679
+ /**
2680
+ * Plugins' services for the editor's blocks: each plugin's hidden frame is
2681
+ * made the first time something asks it. `onCall` gets the function that
2682
+ * asks, `(plugin name, method, arguments)`.
2683
+ */
2684
+ function PluginServices({ plugins, host, onCall, children }) {
2685
+ const [started, setStarted] = useState([]);
2686
+ const ready = useRef(/* @__PURE__ */ new Map());
2687
+ const waiting = useRef(/* @__PURE__ */ new Map());
2688
+ const onChannel = useCallback((id, channel) => {
2689
+ if (!channel) {
2690
+ ready.current.delete(id);
2691
+ return;
2692
+ }
2693
+ ready.current.set(id, channel);
2694
+ for (const resolve of waiting.current.get(id)?.splice(0) ?? []) resolve(channel);
2695
+ }, []);
2696
+ const channelFor = useCallback(async (plugin) => {
2697
+ const channel = ready.current.get(plugin.id);
2698
+ if (channel) return channel;
2699
+ setStarted((current) => current.includes(plugin.id) ? current : [...current, plugin.id]);
2700
+ return await new Promise((resolve, reject) => {
2701
+ const list = waiting.current.get(plugin.id) ?? [];
2702
+ list.push(resolve);
2703
+ waiting.current.set(plugin.id, list);
2704
+ setTimeout(() => reject(/* @__PURE__ */ new Error(`${plugin.title} didn't start.`)), START_TIMEOUT);
2705
+ });
2706
+ }, []);
2707
+ const call = useCallback(async (name, method, args) => {
2708
+ const plugin = plugins.find((candidate) => candidate.name === name && candidate.admin);
2709
+ if (!plugin) throw new Error(`The site has no plugin called ${name} with services.`);
2710
+ return (await channelFor(plugin)).call("call", {
2711
+ method,
2712
+ args
2713
+ });
2714
+ }, [plugins, channelFor]);
2715
+ useEffect(() => onCall(call), [call, onCall]);
2716
+ const media = useCallback((name) => {
2717
+ const plugin = plugins.find((candidate) => candidate.name === name && candidate.admin && candidate.mediaStorage);
2718
+ if (!plugin) return void 0;
2719
+ return {
2720
+ plugin,
2721
+ ready: async () => readyAnswer(plugin, await (await channelFor(plugin)).call("media.ready")),
2722
+ upload: async (files) => {
2723
+ await (await channelFor(plugin)).call("media.upload", { files });
2724
+ },
2725
+ remove: async (addresses) => {
2726
+ await (await channelFor(plugin)).call("media.remove", { addresses });
2727
+ }
2728
+ };
2729
+ }, [plugins, channelFor]);
2730
+ return /* @__PURE__ */ jsxs(PluginHostContext.Provider, {
2731
+ value: host,
2732
+ children: [/* @__PURE__ */ jsx(PluginMediaContext.Provider, {
2733
+ value: media,
2734
+ children
2735
+ }), plugins.filter((plugin) => started.includes(plugin.id)).map((plugin) => /* @__PURE__ */ jsx(ServiceFrame, {
2736
+ plugin,
2737
+ host,
2738
+ onChannel
2739
+ }, plugin.id))]
2740
+ });
2741
+ }
2742
+ /** One of a plugin's screens, drawn by its admin module in a frame of its own. */
2743
+ function PluginScreenFrame({ plugin, screen, failedText }) {
2744
+ const { failed, frameProps } = usePluginFrame(plugin, usePluginHost(), "screen", screen.id);
2745
+ if (failed) return /* @__PURE__ */ jsx("p", {
2746
+ className: "gfa-error",
2747
+ children: failedText
2748
+ });
2749
+ return /* @__PURE__ */ jsx("iframe", {
2750
+ title: screen.title,
2751
+ className: "gfa-plugin-screen",
2752
+ ...frameProps
2753
+ });
2754
+ }
2755
+ //#endregion
2756
+ //#region src/publish-failure.tsx
2757
+ /** Explains why a publish from a dialog or form didn't go through. */
2758
+ function PublishFailure({ failure }) {
2759
+ const t = useStrings();
2760
+ if (!failure || failure.reason === "checks") return null;
2761
+ return /* @__PURE__ */ jsx(ErrorMessage, {
2762
+ message: t(failure.reason === "conflict" ? "publish.conflict" : "publish.error"),
2763
+ error: failure.error
2764
+ });
2765
+ }
2766
+ //#endregion
2767
+ //#region src/site-host.ts
2768
+ /**
2769
+ * The host outside GitHub, GitLab and Bitbucket that builds the site, as its setup files
2770
+ * say, such as Cloudflare Pages: `undefined` while they're read, `null` for none.
2771
+ * They're outside the folders the site's files are read from, so they're read
2772
+ * as version history reads files, at the loaded revision.
2773
+ */
2774
+ function useOutsideHost() {
2775
+ const { readFile, versions, state } = useAdmin();
2776
+ const revision = state.status === "ready" ? state.revision : void 0;
2777
+ const [host, setHost] = useState();
2778
+ useEffect(() => {
2779
+ let cancelled = false;
2780
+ outsideHost(versions && revision ? (path) => versions.readAt(path, revision) : readFile).then((found) => !cancelled && setHost(found ?? null), () => !cancelled && setHost(null));
2781
+ return () => {
2782
+ cancelled = true;
2783
+ };
2784
+ }, [
2785
+ readFile,
2786
+ versions,
2787
+ revision
2788
+ ]);
2789
+ return host;
2790
+ }
2791
+ //#endregion
2792
+ //#region src/upload.ts
2793
+ /** Photos are made no larger than this on their longest side, which is plenty for any screen. */
2794
+ const MAX_IMAGE_SIDE = 2400;
2795
+ var UploadError = class extends Error {
2796
+ problem;
2797
+ file;
2798
+ name = "UploadError";
2799
+ constructor(problem, file, message) {
2800
+ super(message);
2801
+ this.problem = problem;
2802
+ this.file = file;
2803
+ }
2804
+ };
2805
+ const REENCODED = {
2806
+ jpg: "image/jpeg",
2807
+ jpeg: "image/jpeg",
2808
+ png: "image/png",
2809
+ webp: "image/webp"
2810
+ };
2811
+ async function canvasBlob(canvas, type) {
2812
+ return new Promise((resolve) => canvas.toBlob(resolve, type, .85));
2813
+ }
2814
+ /**
2815
+ * Re-saves a photo: smaller if it's larger than any screen needs, and always
2816
+ * for JPEGs, which drops details hidden in the file such as where it was taken.
2817
+ * Returns `undefined` to keep the file as it is.
2818
+ */
2819
+ async function resized(file, extension) {
2820
+ const type = REENCODED[extension];
2821
+ if (!type || typeof createImageBitmap !== "function") return void 0;
2822
+ let bitmap;
2823
+ try {
2824
+ bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
2825
+ } catch {
2826
+ throw new UploadError("unreadable", file.name, `${file.name} couldn't be read as an image.`);
2827
+ }
2828
+ const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(bitmap.width, bitmap.height));
2829
+ if (scale === 1 && type !== "image/jpeg") {
2830
+ bitmap.close();
2831
+ return;
2832
+ }
2833
+ const canvas = document.createElement("canvas");
2834
+ canvas.width = Math.round(bitmap.width * scale);
2835
+ canvas.height = Math.round(bitmap.height * scale);
2836
+ canvas.getContext("2d")?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
2837
+ bitmap.close();
2838
+ const blob = await canvasBlob(canvas, type);
2839
+ return blob && (scale < 1 || blob.size < file.size) ? blob : void 0;
2840
+ }
2841
+ const UNSAFE_SVG_ELEMENTS = [
2842
+ "script",
2843
+ "foreignObject",
2844
+ "iframe",
2845
+ "embed",
2846
+ "object",
2847
+ "audio",
2848
+ "video"
2849
+ ];
2850
+ /**
2851
+ * Removes anything from an SVG that could run code if someone opens the
2852
+ * file directly: scripts, embedded pages, event handlers, and javascript:, vbscript:
2853
+ * and data: links other than images.
2854
+ */
2855
+ function cleanSvg(text) {
2856
+ const doc = new DOMParser().parseFromString(text, "image/svg+xml");
2857
+ if (doc.querySelector("parsererror") || doc.documentElement.nodeName.toLowerCase() !== "svg") return void 0;
2858
+ for (const name of UNSAFE_SVG_ELEMENTS) for (const element of [...doc.getElementsByTagName(name)]) element.remove();
2859
+ for (const element of [doc.documentElement, ...doc.documentElement.querySelectorAll("*")]) for (const attribute of [...element.attributes]) {
2860
+ const value = attribute.value.trim().toLowerCase();
2861
+ if (attribute.name.toLowerCase().startsWith("on") || value.startsWith("javascript:") || value.startsWith("vbscript:") || value.startsWith("data:") && !value.startsWith("data:image/")) element.removeAttribute(attribute.name);
2862
+ }
2863
+ return new XMLSerializer().serializeToString(doc);
2864
+ }
2865
+ /** A file checked and made ready, as a `Blob`: photos made web-sized, SVGs made safe, other files as they are. */
2866
+ async function prepared(file, maxBytes) {
2867
+ const extension = extensionOf(file.name);
2868
+ if (!mediaKind$1(file.name)) throw new UploadError("type", file.name, `${file.name} isn't a kind of file the site can use.`);
2869
+ let blob = file;
2870
+ if (extension === "svg") {
2871
+ const cleaned = cleanSvg(await file.text());
2872
+ if (cleaned === void 0) throw new UploadError("unreadable", file.name, `${file.name} isn't a valid SVG image.`);
2873
+ blob = new Blob([cleaned], { type: "image/svg+xml" });
2874
+ } else if (mediaKind$1(file.name) === "image") blob = await resized(file, extension) ?? file;
2875
+ if (blob.size > maxBytes) throw new UploadError("size", file.name, `${file.name} is ${blob.size} bytes, over the ${maxBytes}-byte limit.`);
2876
+ return blob;
2877
+ }
2878
+ /** Checks a file chosen for upload and gets it ready: photos made web-sized, SVGs made safe. */
2879
+ async function prepareUpload(file) {
2880
+ const blob = await prepared(file, MAX_UPLOAD_BYTES);
2881
+ return {
2882
+ name: file.name,
2883
+ bytes: new Uint8Array(await blob.arrayBuffer()),
2884
+ type: blob.type || file.type
2885
+ };
2886
+ }
2887
+ /** Reads a picture's size in pixels, or nothing where the browser can't. */
2888
+ async function imageSize(blob, name) {
2889
+ if (mediaKind$1(name) !== "image" || extensionOf(name) === "svg" || typeof createImageBitmap !== "function") return;
2890
+ try {
2891
+ const bitmap = await createImageBitmap(blob);
2892
+ const size = {
2893
+ width: bitmap.width,
2894
+ height: bitmap.height
2895
+ };
2896
+ bitmap.close();
2897
+ return size.width > 0 && size.height > 0 ? size : void 0;
2898
+ } catch {
2899
+ return;
2900
+ }
2901
+ }
2902
+ /**
2903
+ * Checks a file for media storage and gets it ready as `prepareUpload()`
2904
+ * does, up to the storage's largest file rather than the site's own 25 MB.
2905
+ */
2906
+ async function prepareStoredUpload(file, maxBytes) {
2907
+ const blob = await prepared(file, maxBytes);
2908
+ const type = blob.type || file.type || mediaType(file.name);
2909
+ return {
2910
+ name: file.name,
2911
+ blob,
2912
+ type,
2913
+ ...await imageSize(blob, file.name)
2914
+ };
2915
+ }
2916
+ //#endregion
2917
+ //#region src/router.tsx
2918
+ function parseHash(hash) {
2919
+ const [path = "", query = ""] = hash.replace(/^#\/?/, "").split("?");
2920
+ return {
2921
+ segments: path.split("/").filter(Boolean),
2922
+ params: new URLSearchParams(query)
2923
+ };
2924
+ }
2925
+ function subscribe$1(onChange) {
2926
+ window.addEventListener("hashchange", onChange);
2927
+ return () => window.removeEventListener("hashchange", onChange);
2928
+ }
2929
+ function useRoute() {
2930
+ return parseHash(useSyncExternalStore(subscribe$1, () => window.location.hash, () => ""));
2931
+ }
2932
+ const guards = /* @__PURE__ */ new Set();
2933
+ /** Whether any screen has unpublished changes. */
2934
+ function hasUnsavedChanges() {
2935
+ return [...guards].some((isDirty) => isDirty());
2936
+ }
2937
+ /**
2938
+ * Registers a screen's unpublished changes, so leaving it (inside the admin
2939
+ * panel or by closing the tab) asks for confirmation first.
2940
+ */
2941
+ function useUnsavedChanges(isDirty) {
2942
+ useEffect(() => {
2943
+ if (!isDirty) return;
2944
+ const guard = () => true;
2945
+ guards.add(guard);
2946
+ const onBeforeUnload = (event) => event.preventDefault();
2947
+ window.addEventListener("beforeunload", onBeforeUnload);
2948
+ return () => {
2949
+ guards.delete(guard);
2950
+ window.removeEventListener("beforeunload", onBeforeUnload);
2951
+ };
2952
+ }, [isDirty]);
2953
+ }
2954
+ /** Goes to another screen, asking first if there are unpublished changes. Returns whether it navigated. */
2955
+ function navigate(to, confirmLeave) {
2956
+ if (hasUnsavedChanges() && !confirmLeave()) return false;
2957
+ guards.clear();
2958
+ window.location.hash = to;
2959
+ return true;
2960
+ }
2961
+ /** Sections of Site Settings' General tab that links can scroll to, as `#/settings/general?section=forms`. */
2962
+ const SETTINGS_SECTIONS = {
2963
+ contact: "contact",
2964
+ forms: "forms"
2965
+ };
2966
+ function pageEditorHref(path) {
2967
+ return `#/pages/edit?path=${encodeURIComponent(path)}`;
2968
+ }
2969
+ /** An in-app link that asks before leaving a screen with unpublished changes. */
2970
+ function Link({ href, confirmLeave, ...props }) {
2971
+ return /* @__PURE__ */ jsx("a", {
2972
+ ...props,
2973
+ href,
2974
+ onClick: (event) => {
2975
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) return;
2976
+ event.preventDefault();
2977
+ navigate(href, confirmLeave);
2978
+ }
2979
+ });
2980
+ }
2981
+ function collectionHref(id, tab = "entries") {
2982
+ return tab === "entries" ? `#/collections/${id}` : `#/collections/${id}/${tab}`;
2983
+ }
2984
+ function entryEditorHref(collection, slug) {
2985
+ return `#/collections/${collection}/edit?slug=${encodeURIComponent(slug)}`;
2986
+ }
2987
+ //#endregion
2988
+ //#region src/use-link.tsx
2989
+ /** `Link`, with the standard "leave without publishing?" confirmation. */
2990
+ function AppLink(props) {
2991
+ const t = useStrings();
2992
+ return /* @__PURE__ */ jsx(Link, {
2993
+ ...props,
2994
+ confirmLeave: () => window.confirm(t("unsaved.confirm"))
2995
+ });
2996
+ }
2997
+ //#endregion
2998
+ //#region src/media-library.tsx
2999
+ /** A media file's picture: the image itself, or its kind of file for anything else. */
3000
+ function MediaThumb({ url, className }) {
3001
+ const { mediaPreviews } = useAdmin();
3002
+ const [src, setSrc] = useState(() => mediaPreviews.resolve(url));
3003
+ const tried = useRef(false);
3004
+ if (mediaKind$1(url) !== "image") return /* @__PURE__ */ jsx("span", {
3005
+ className: `gfa-media-thumb gfa-media-file ${className ?? ""}`,
3006
+ "aria-hidden": "true",
3007
+ children: extensionOf(url).toUpperCase()
3008
+ });
3009
+ return /* @__PURE__ */ jsx("img", {
2233
3010
  className: `gfa-media-thumb ${className ?? ""}`,
2234
3011
  src,
2235
3012
  alt: "",
@@ -2268,16 +3045,148 @@ const uploadProblems = {
2268
3045
  size: "media.error.size",
2269
3046
  unreadable: "media.error.unreadable"
2270
3047
  };
3048
+ /** The plugin keeping the site's media, where the site has one and it's there to ask. */
3049
+ function useMediaStorage() {
3050
+ const { content } = useSiteContent();
3051
+ const storage = content.mediaStorage;
3052
+ const client = usePluginMedia(storage?.plugin);
3053
+ return {
3054
+ storage,
3055
+ ...client && { client }
3056
+ };
3057
+ }
3058
+ /** How many files moving media hands the plugin at once. */
3059
+ const MOVE_BATCH = 20;
3060
+ /** Whether the storage plugin can take files now, or why not, with a link to where that's fixed. */
3061
+ async function storageReady(t, client, tasks) {
3062
+ const { plugin } = client;
3063
+ if (plugin.tasks?.length && !await tasks?.supported().catch(() => false)) return {
3064
+ ok: false,
3065
+ message: t("plugins.task.missing")
3066
+ };
3067
+ let ready;
3068
+ try {
3069
+ ready = await client.ready();
3070
+ } catch (error) {
3071
+ return {
3072
+ ok: false,
3073
+ message: t("media.storage.unavailable", { plugin: plugin.title }),
3074
+ error
3075
+ };
3076
+ }
3077
+ if (ready.ready) return void 0;
3078
+ return {
3079
+ ok: false,
3080
+ message: t("media.storage.notReady", {
3081
+ plugin: plugin.title,
3082
+ message: ready.message
3083
+ }),
3084
+ ...ready.screen && { action: {
3085
+ href: `#/settings/plugin/${encodeURIComponent(plugin.id)}/${encodeURIComponent(ready.screen)}`,
3086
+ label: t("media.storage.settings", { plugin: plugin.title })
3087
+ } }
3088
+ };
3089
+ }
3090
+ /** A file's size for content's list of stored media, with a picture's size where it's known. */
3091
+ function storedFile(size, picture) {
3092
+ return {
3093
+ size,
3094
+ ...picture.width && picture.height && {
3095
+ width: picture.width,
3096
+ height: picture.height
3097
+ }
3098
+ };
3099
+ }
2271
3100
  /**
2272
3101
  * Uploads files to the media folder in one publish, and shows them in the
2273
3102
  * admin panel straight away. `replace` overwrites one existing file instead.
3103
+ * Where a plugin keeps the site's media, it keeps the files, larger ones
3104
+ * too, and the publish lists them in `content/media-storage.json`.
2274
3105
  */
2275
3106
  function useUploadMedia() {
2276
3107
  const t = useStrings();
2277
- const { publish, mediaPreviews } = useAdmin();
3108
+ const { publish, mediaPreviews, tasks } = useAdmin();
2278
3109
  const { content, media } = useSiteContent();
3110
+ const { storage, client } = useMediaStorage();
3111
+ const published = async (changes, message, urls) => {
3112
+ const result = await publish(changes, message, { checks: false });
3113
+ if (!result.ok) return {
3114
+ ok: false,
3115
+ message: t(result.reason === "conflict" ? "publish.conflict" : "publish.error"),
3116
+ failure: result
3117
+ };
3118
+ return {
3119
+ ok: true,
3120
+ urls
3121
+ };
3122
+ };
3123
+ const toStorage = async (files, replace) => {
3124
+ if (!storage || !client) return {
3125
+ ok: false,
3126
+ message: t("publish.error")
3127
+ };
3128
+ const problem = await storageReady(t, client, tasks);
3129
+ if (problem) return problem;
3130
+ const { plugin } = client;
3131
+ const maxBytes = plugin.mediaStorage?.maxBytes ?? MAX_UPLOAD_BYTES;
3132
+ const taken = allMedia(content, media).map(fileName);
3133
+ const added = {};
3134
+ const batch = [];
3135
+ for (const file of files) {
3136
+ let ready;
3137
+ try {
3138
+ ready = await prepareStoredUpload(file, maxBytes);
3139
+ } catch (error) {
3140
+ if (error instanceof UploadError && error.problem === "size") return {
3141
+ ok: false,
3142
+ message: t("media.error.storageSize", {
3143
+ name: file.name,
3144
+ size: formatSize(maxBytes),
3145
+ plugin: plugin.title
3146
+ })
3147
+ };
3148
+ return {
3149
+ ok: false,
3150
+ message: error instanceof UploadError ? t(uploadProblems[error.problem], { name: error.file }) : String(error)
3151
+ };
3152
+ }
3153
+ const url = replace ? mediaUrl(replace) : `${MEDIA_URL_PREFIX}${uploadName(file.name, taken)}`;
3154
+ taken.push(fileName(url));
3155
+ batch.push({
3156
+ address: url,
3157
+ type: ready.type,
3158
+ size: ready.blob.size,
3159
+ data: ready.blob
3160
+ });
3161
+ added[url] = storedFile(ready.blob.size, ready);
3162
+ }
3163
+ try {
3164
+ await client.upload(batch);
3165
+ } catch (error) {
3166
+ const name = batch.map((file) => fileName(file.address)).join(", ");
3167
+ return {
3168
+ ok: false,
3169
+ message: t("media.storage.failed", {
3170
+ name,
3171
+ plugin: plugin.title
3172
+ }),
3173
+ error
3174
+ };
3175
+ }
3176
+ const urls = Object.keys(added);
3177
+ const changes = [mediaStorageFileChange(withStoredFiles(storage, added))];
3178
+ if (replace && media.includes(replace)) changes.push({
3179
+ path: replace,
3180
+ delete: true
3181
+ });
3182
+ const credits = replace ? withoutCredits(content.credits, [mediaUrl(replace)]) : void 0;
3183
+ if (credits) changes.push(credits);
3184
+ const names = urls.map(fileName).join(", ");
3185
+ return published(changes, replace ? t("media.replaceMessage", { name: names }) : t("media.uploadMessage", { names }), urls);
3186
+ };
2279
3187
  return async (files, replace) => {
2280
- const taken = media.map(fileName);
3188
+ if (storage && client && (!replace || isStorableAddress(mediaUrl(replace)))) return toStorage(files, replace);
3189
+ const taken = allMedia(content, media).map(fileName);
2281
3190
  const changes = [];
2282
3191
  const uploads = [];
2283
3192
  for (const file of files) {
@@ -2304,16 +3213,12 @@ function useUploadMedia() {
2304
3213
  for (const upload of uploads) mediaPreviews.remember(upload.url, upload.bytes);
2305
3214
  const names = changes.map((change) => fileName(change.path)).join(", ");
2306
3215
  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
- };
3216
+ const unstored = replace && storage && isStoredMedia(content, mediaUrl(replace)) ? mediaStorageFileChange(withStoredFiles(storage, {}, [mediaUrl(replace)])) : void 0;
3217
+ return published([
3218
+ ...changes,
3219
+ ...credits ? [credits] : [],
3220
+ ...unstored ? [unstored] : []
3221
+ ], replace ? t("media.replaceMessage", { name: names }) : t("media.uploadMessage", { names }), uploads.map((upload) => upload.url));
2317
3222
  };
2318
3223
  }
2319
3224
  function DeleteMediaDialog({ url, onClose }) {
@@ -2329,18 +3234,21 @@ function DeleteMediaDialog({ url, onClose }) {
2329
3234
  settings: t("nav.settings"),
2330
3235
  css: t("settings.tab.css")
2331
3236
  });
3237
+ const { storage, client } = useMediaStorage();
2332
3238
  const onDelete = async () => {
2333
3239
  const path = mediaPath(url);
2334
3240
  if (!path) return;
2335
3241
  setBusy(true);
2336
3242
  const credits = withoutCredits(content.credits, [url]);
2337
- const result = await publish([{
3243
+ const stored = storage && isStoredMedia(content, url);
3244
+ const result = await publish([stored ? mediaStorageFileChange(withStoredFiles(storage, {}, [url])) : {
2338
3245
  path,
2339
3246
  delete: true
2340
3247
  }, ...credits ? [credits] : []], t("media.deleteMessage", { name }));
2341
3248
  setBusy(false);
2342
- if (result.ok) onClose();
2343
- else setFailure(result);
3249
+ if (!result.ok) return setFailure(result);
3250
+ if (stored) client?.remove([url]).catch(() => {});
3251
+ onClose();
2344
3252
  };
2345
3253
  return /* @__PURE__ */ jsxs(Dialog, {
2346
3254
  title: t("media.deleteTitle", { name }),
@@ -2408,26 +3316,41 @@ function UploadStatus({ status }) {
2408
3316
  const t = useStrings();
2409
3317
  if (status.type === "uploading") return /* @__PURE__ */ jsx("p", {
2410
3318
  className: "gfa-notice",
2411
- children: t("media.uploading")
3319
+ role: "status",
3320
+ children: status.plugin ? t("media.storage.uploading", { plugin: status.plugin }) : t("media.uploading")
2412
3321
  });
2413
3322
  if (status.type === "done") return /* @__PURE__ */ jsx("p", {
2414
3323
  className: "gfa-notice gfa-notice-success",
2415
3324
  role: "status",
2416
3325
  children: t("media.uploaded")
2417
3326
  });
2418
- if (status.type === "failed") return status.failure ? /* @__PURE__ */ jsx(PublishFailure, { failure: status.failure }) : /* @__PURE__ */ jsx(ErrorMessage, { message: status.message });
3327
+ if (status.type === "failed") {
3328
+ if (status.failure) return /* @__PURE__ */ jsx(PublishFailure, { failure: status.failure });
3329
+ return /* @__PURE__ */ jsx(ErrorMessage, {
3330
+ message: status.message,
3331
+ error: status.error,
3332
+ action: status.action && /* @__PURE__ */ jsx(AppLink, {
3333
+ href: status.action.href,
3334
+ children: status.action.label
3335
+ })
3336
+ });
3337
+ }
2419
3338
  return null;
2420
3339
  }
2421
3340
  function useUploads() {
2422
3341
  const upload = useUploadMedia();
3342
+ const { storage, client } = useMediaStorage();
2423
3343
  const [status, setStatus] = useState({ type: "idle" });
2424
3344
  const run = async (files, replace) => {
2425
- setStatus({ type: "uploading" });
3345
+ const waits = storage && client?.plugin.tasks?.length && (!replace || isStorableAddress(mediaUrl(replace)));
3346
+ setStatus({
3347
+ type: "uploading",
3348
+ ...waits && { plugin: client.plugin.title }
3349
+ });
2426
3350
  const result = await upload(files, replace);
2427
3351
  setStatus(result.ok ? { type: "done" } : {
2428
3352
  type: "failed",
2429
- message: result.message,
2430
- failure: result.failure
3353
+ ...result
2431
3354
  });
2432
3355
  return result;
2433
3356
  };
@@ -2436,16 +3359,278 @@ function useUploads() {
2436
3359
  run
2437
3360
  };
2438
3361
  }
3362
+ /** Moves the site's own media files to the plugin that keeps media, in one publish once each is kept. */
3363
+ function MoveToStorageDialog({ client, paths, onClose }) {
3364
+ const t = useStrings();
3365
+ const { publish, readBytes, tasks } = useAdmin();
3366
+ const { storage } = useMediaStorage();
3367
+ const [progress, setProgress] = useState();
3368
+ const [problem, setProblem] = useState();
3369
+ const [failure, setFailure] = useState(null);
3370
+ const plugin = client.plugin.title;
3371
+ const move = async () => {
3372
+ if (!storage) return;
3373
+ setProblem(void 0);
3374
+ setFailure(null);
3375
+ const unready = await storageReady(t, client, tasks);
3376
+ if (unready) return setProblem(unready);
3377
+ setProgress({ done: 0 });
3378
+ const added = {};
3379
+ let failed;
3380
+ for (let start = 0; start < paths.length && !failed; start += MOVE_BATCH) {
3381
+ const batch = [];
3382
+ const sizes = {};
3383
+ try {
3384
+ for (const path of paths.slice(start, start + MOVE_BATCH)) {
3385
+ const url = mediaUrl(path);
3386
+ const bytes = await readBytes(path);
3387
+ if (!bytes) continue;
3388
+ const blob = new Blob([new Uint8Array(bytes)], { type: mediaType(path) });
3389
+ batch.push({
3390
+ address: url,
3391
+ type: blob.type,
3392
+ size: blob.size,
3393
+ data: blob
3394
+ });
3395
+ sizes[url] = storedFile(blob.size, await imageSize(blob, path) ?? {});
3396
+ }
3397
+ if (batch.length > 0) await client.upload(batch);
3398
+ Object.assign(added, sizes);
3399
+ } catch (error) {
3400
+ const name = batch.map((file) => fileName(file.address)).join(", ");
3401
+ failed = {
3402
+ ok: false,
3403
+ message: t("media.storage.failed", {
3404
+ name,
3405
+ plugin
3406
+ }),
3407
+ error
3408
+ };
3409
+ }
3410
+ setProgress({ done: Math.min(start + MOVE_BATCH, paths.length) });
3411
+ }
3412
+ const moved = Object.keys(added);
3413
+ if (moved.length === 0) {
3414
+ setProgress(void 0);
3415
+ return setProblem(failed ?? {
3416
+ ok: false,
3417
+ message: t("media.move.nothing")
3418
+ });
3419
+ }
3420
+ const result = await publish([mediaStorageFileChange(withStoredFiles(storage, added)), ...moved.flatMap((url) => {
3421
+ const path = mediaPath(url);
3422
+ return path ? [{
3423
+ path,
3424
+ delete: true
3425
+ }] : [];
3426
+ })], t("media.move.message", { plugin }), { checks: false });
3427
+ setProgress(void 0);
3428
+ if (!result.ok) return setFailure(result);
3429
+ if (failed) return setProblem({
3430
+ ...failed,
3431
+ message: `${t("media.move.some", {
3432
+ count: String(moved.length),
3433
+ plugin
3434
+ })} ${failed.message}`
3435
+ });
3436
+ onClose(t("media.move.done", {
3437
+ count: String(moved.length),
3438
+ plugin
3439
+ }));
3440
+ };
3441
+ return /* @__PURE__ */ jsxs(Dialog, {
3442
+ title: t("media.move.title", { plugin }),
3443
+ onClose: () => onClose(),
3444
+ children: [
3445
+ /* @__PURE__ */ jsx("p", { children: t("media.move.body", { plugin }) }),
3446
+ progress && /* @__PURE__ */ jsxs("p", {
3447
+ className: "gfa-notice",
3448
+ role: "status",
3449
+ children: [t("media.move.moving", {
3450
+ done: String(progress.done),
3451
+ total: String(paths.length)
3452
+ }), client.plugin.tasks?.length ? ` ${t("media.storage.wait")}` : ""]
3453
+ }),
3454
+ problem && /* @__PURE__ */ jsx(ErrorMessage, {
3455
+ message: problem.message,
3456
+ error: problem.error,
3457
+ action: problem.action && /* @__PURE__ */ jsx(AppLink, {
3458
+ href: problem.action.href,
3459
+ children: problem.action.label
3460
+ })
3461
+ }),
3462
+ /* @__PURE__ */ jsx(PublishFailure, { failure }),
3463
+ /* @__PURE__ */ jsxs("div", {
3464
+ className: "gfa-dialog-actions",
3465
+ children: [/* @__PURE__ */ jsx(Button, {
3466
+ onClick: () => onClose(),
3467
+ children: t("action.cancel")
3468
+ }), /* @__PURE__ */ jsx(Button, {
3469
+ variant: "primary",
3470
+ disabled: progress !== void 0,
3471
+ onClick: () => void move(),
3472
+ children: progress ? t("media.move.busy") : t("media.move.button", {
3473
+ count: String(paths.length),
3474
+ plugin
3475
+ })
3476
+ })]
3477
+ })
3478
+ ]
3479
+ });
3480
+ }
3481
+ /**
3482
+ * Copies the files a plugin keeps back into the site's own files, from their
3483
+ * public addresses, up to the site's own 25 MB each, which needs no plugin.
3484
+ * Once none are left, the site stops keeping media outside its files.
3485
+ */
3486
+ function MoveBackDialog({ plugin, onClose }) {
3487
+ const t = useStrings();
3488
+ const { publish } = useAdmin();
3489
+ const { content } = useSiteContent();
3490
+ const { storage, client } = useMediaStorage();
3491
+ const [busy, setBusy] = useState(false);
3492
+ const [problem, setProblem] = useState();
3493
+ const [failure, setFailure] = useState(null);
3494
+ const files = Object.entries(storage?.files ?? {});
3495
+ const large = files.filter(([, file]) => file.size > MAX_UPLOAD_BYTES).map(([url]) => fileName(url));
3496
+ const moveBack = async () => {
3497
+ if (!storage) return;
3498
+ setBusy(true);
3499
+ setProblem(void 0);
3500
+ setFailure(null);
3501
+ const changes = [];
3502
+ const moved = [];
3503
+ for (const [url, file] of files) {
3504
+ const path = mediaPath(url);
3505
+ const from = storedMediaUrl(storage, url);
3506
+ if (!path || !from || file.size > MAX_UPLOAD_BYTES) continue;
3507
+ try {
3508
+ const response = await fetch(from, { cache: "no-store" });
3509
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
3510
+ changes.push({
3511
+ path,
3512
+ bytes: new Uint8Array(await response.arrayBuffer())
3513
+ });
3514
+ moved.push(url);
3515
+ } catch (error) {
3516
+ setBusy(false);
3517
+ return setProblem({
3518
+ message: t("media.back.failed", {
3519
+ name: fileName(url),
3520
+ plugin
3521
+ }),
3522
+ error
3523
+ });
3524
+ }
3525
+ }
3526
+ const left = withStoredFiles(storage, {}, moved);
3527
+ changes.push(mediaStorageFileChange(Object.keys(left.files).length > 0 ? left : void 0));
3528
+ const result = await publish(changes, t("media.back.message", { plugin }), { checks: false });
3529
+ setBusy(false);
3530
+ if (!result.ok) return setFailure(result);
3531
+ if (moved.length > 0) client?.remove(moved).catch(() => {});
3532
+ onClose(t("media.back.done", {
3533
+ count: String(moved.length),
3534
+ plugin
3535
+ }));
3536
+ };
3537
+ return /* @__PURE__ */ jsxs(Dialog, {
3538
+ title: t("media.back.title", { plugin }),
3539
+ onClose: () => onClose(),
3540
+ children: [
3541
+ /* @__PURE__ */ jsx("p", { children: files.length > 0 ? t("media.back.body", { plugin }) : t("media.back.empty", { plugin }) }),
3542
+ large.length > 0 && /* @__PURE__ */ jsx("p", { children: t("media.back.large", {
3543
+ names: large.join(", "),
3544
+ plugin
3545
+ }) }),
3546
+ content.mediaStorage && problem && /* @__PURE__ */ jsx(ErrorMessage, {
3547
+ message: problem.message,
3548
+ error: problem.error
3549
+ }),
3550
+ /* @__PURE__ */ jsx(PublishFailure, { failure }),
3551
+ /* @__PURE__ */ jsxs("div", {
3552
+ className: "gfa-dialog-actions",
3553
+ children: [/* @__PURE__ */ jsx(Button, {
3554
+ onClick: () => onClose(),
3555
+ children: t("action.cancel")
3556
+ }), /* @__PURE__ */ jsx(Button, {
3557
+ variant: "primary",
3558
+ disabled: busy,
3559
+ onClick: () => void moveBack(),
3560
+ children: busy ? t("media.move.busy") : t("media.back.button")
3561
+ })]
3562
+ })
3563
+ ]
3564
+ });
3565
+ }
3566
+ /** Where a plugin keeps the site's media: what it keeps, and moving the site's own files there or back. */
3567
+ function StorageSection({ onDone }) {
3568
+ const t = useStrings();
3569
+ const { content, media } = useSiteContent();
3570
+ const { storage, client } = useMediaStorage();
3571
+ const [moving, setMoving] = useState();
3572
+ if (!storage) return null;
3573
+ const plugin = client?.plugin.title ?? storage.plugin;
3574
+ const count = String(Object.keys(storage.files).length);
3575
+ const movable = media.filter((path) => isStorableAddress(mediaUrl(path)) && !isStoredMedia(content, mediaUrl(path)));
3576
+ const close = (message) => {
3577
+ setMoving(void 0);
3578
+ if (message) onDone(message);
3579
+ };
3580
+ return /* @__PURE__ */ jsxs("section", {
3581
+ className: "gfa-media-storage",
3582
+ "aria-label": t("media.storage.title"),
3583
+ children: [
3584
+ /* @__PURE__ */ jsx("p", {
3585
+ className: "gfa-notice",
3586
+ role: "note",
3587
+ children: client ? t("media.storage.intro", {
3588
+ plugin,
3589
+ count
3590
+ }) : t("media.storage.missing", {
3591
+ plugin,
3592
+ count
3593
+ })
3594
+ }),
3595
+ /* @__PURE__ */ jsxs("div", {
3596
+ className: "gfa-dialog-actions",
3597
+ children: [client && movable.length > 0 && /* @__PURE__ */ jsx(Button, {
3598
+ variant: "primary",
3599
+ onClick: () => setMoving("to"),
3600
+ children: t("media.move.button", {
3601
+ count: String(movable.length),
3602
+ plugin
3603
+ })
3604
+ }), /* @__PURE__ */ jsx(Button, {
3605
+ variant: "ghost",
3606
+ onClick: () => setMoving("back"),
3607
+ children: t("media.back.button")
3608
+ })]
3609
+ }),
3610
+ moving === "to" && client && /* @__PURE__ */ jsx(MoveToStorageDialog, {
3611
+ client,
3612
+ paths: movable,
3613
+ onClose: close
3614
+ }),
3615
+ moving === "back" && /* @__PURE__ */ jsx(MoveBackDialog, {
3616
+ plugin,
3617
+ onClose: close
3618
+ })
3619
+ ]
3620
+ });
3621
+ }
2439
3622
  /** Every uploaded image and file, with buttons to upload, replace and delete them. */
2440
3623
  /** Cloudflare's terms for its CDN, which limit serving video on its free plans. */
2441
3624
  const CLOUDFLARE_TERMS = "https://www.cloudflare.com/service-specific-terms-application-services/";
2442
3625
  function MediaScreen() {
2443
3626
  const t = useStrings();
2444
- const { content, media } = useSiteContent();
3627
+ const { content, media: own } = useSiteContent();
3628
+ const media = useMemo(() => allMedia(content, own), [content, own]);
2445
3629
  const { status, run } = useUploads();
2446
3630
  const [query, setQuery] = useState("");
2447
3631
  const [deleting, setDeleting] = useState(null);
2448
3632
  const [copied, setCopied] = useState(null);
3633
+ const [moved, setMoved] = useState();
2449
3634
  const searchId = useId();
2450
3635
  useEffect(() => {
2451
3636
  const stop = (event) => {
@@ -2489,6 +3674,12 @@ function MediaScreen() {
2489
3674
  })
2490
3675
  ]
2491
3676
  }),
3677
+ /* @__PURE__ */ jsx(StorageSection, { onDone: setMoved }),
3678
+ moved && /* @__PURE__ */ jsx("p", {
3679
+ className: "gfa-notice gfa-notice-success",
3680
+ role: "status",
3681
+ children: moved
3682
+ }),
2492
3683
  /* @__PURE__ */ jsx(UploadStatus, { status }),
2493
3684
  media.length > 0 && /* @__PURE__ */ jsxs("div", {
2494
3685
  className: "gfa-field gfa-media-search",
@@ -2518,6 +3709,10 @@ function MediaScreen() {
2518
3709
  children: fileName(url)
2519
3710
  }),
2520
3711
  content.credits?.[url] && /* @__PURE__ */ jsx(MediaCredit, { credit: content.credits[url] }),
3712
+ isStoredMedia(content, url) && /* @__PURE__ */ jsx("span", {
3713
+ className: "gfa-badge",
3714
+ children: t("media.stored", { plugin: content.mediaStorage?.plugin ?? "" })
3715
+ }),
2521
3716
  /* @__PURE__ */ jsxs("div", {
2522
3717
  className: "gfa-media-actions",
2523
3718
  children: [
@@ -2554,22 +3749,26 @@ function MediaScreen() {
2554
3749
  const PICKER_TITLES = {
2555
3750
  image: "media.pickerTitle",
2556
3751
  video: "media.pickerTitleVideo",
3752
+ audio: "media.pickerTitleAudio",
2557
3753
  file: "media.pickerTitleFile"
2558
3754
  };
2559
3755
  const CHOOSE_LABELS = {
2560
3756
  image: "media.choose",
2561
3757
  video: "media.chooseVideo",
3758
+ audio: "media.chooseAudio",
2562
3759
  file: "media.chooseFile"
2563
3760
  };
2564
3761
  const ACCEPT = {
2565
3762
  image: "image/*",
2566
3763
  video: "video/mp4,video/webm",
3764
+ audio: "audio/mpeg,audio/mp4,.mp3,.m4a",
2567
3765
  file: void 0
2568
3766
  };
2569
3767
  /** Picks a file from the media library, or uploads a new one, for a field. */
2570
3768
  function MediaPickerDialog({ kind, onSelect, onClose }) {
2571
3769
  const t = useStrings();
2572
- const { media } = useSiteContent();
3770
+ const { content, media: own } = useSiteContent();
3771
+ const media = allMedia(content, own);
2573
3772
  const { status, run } = useUploads();
2574
3773
  const urls = media.map(mediaUrl).filter((url) => kind === "file" || mediaKind$1(url) === kind).sort((a, b) => fileName(a).localeCompare(fileName(b)));
2575
3774
  return /* @__PURE__ */ jsx(Dialog, {
@@ -2684,214 +3883,91 @@ function MediaField({ label, hint, error, value, kind = "image", onChange }) {
2684
3883
  });
2685
3884
  }
2686
3885
  //#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";
3886
+ //#region src/plugin-secrets.ts
3887
+ /** The lines a plugin's secrets take in `.env.local`, each waiting for its value after the `=`. */
3888
+ function envLines(names) {
3889
+ return names.map((name) => `${name}=`).join("\n");
2707
3890
  }
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
- };
3891
+ /** How to add a plugin's secrets where a site keeps them, as its screens show it, in the admin panel's words. */
3892
+ function helpFor(place, names, t) {
3893
+ const list = names.join(", ");
3894
+ switch (place.kind) {
3895
+ case "github": return {
3896
+ where: "github",
3897
+ title: t("plugins.secrets.github.title"),
3898
+ link: {
3899
+ url: place.url,
3900
+ label: t("plugins.secrets.github.link")
3901
+ },
3902
+ steps: [{ text: t("plugins.secrets.github.open") }, ...names.map((name) => ({
3903
+ text: t("plugins.secrets.github.add", { name }),
3904
+ code: name
3905
+ }))],
3906
+ notes: [t("plugins.secrets.github.who"), t("plugins.secrets.github.kind")]
3907
+ };
3908
+ case "gitlab": return {
3909
+ where: "gitlab",
3910
+ title: t("plugins.secrets.gitlab.title"),
3911
+ link: {
3912
+ url: place.url,
3913
+ label: t("plugins.secrets.gitlab.link")
3914
+ },
3915
+ steps: [{ text: t("plugins.secrets.gitlab.open") }, ...names.map((name) => ({
3916
+ text: t("plugins.secrets.gitlab.add", { name }),
3917
+ code: name
3918
+ }))],
3919
+ notes: [t("plugins.secrets.gitlab.who")]
3920
+ };
3921
+ case "bitbucket": return {
3922
+ where: "bitbucket",
3923
+ title: t("plugins.secrets.bitbucket.title"),
3924
+ link: {
3925
+ url: place.url,
3926
+ label: t("plugins.secrets.bitbucket.link")
3927
+ },
3928
+ steps: [{ text: t("plugins.secrets.bitbucket.open") }, ...names.map((name) => ({
3929
+ text: t("plugins.secrets.bitbucket.add", { name }),
3930
+ code: name
3931
+ }))],
3932
+ notes: [t("plugins.secrets.bitbucket.who")]
3933
+ };
3934
+ case "goodfellow-dev":
3935
+ case "next-dev": return {
3936
+ where: "development",
3937
+ title: t("plugins.secrets.development.title"),
3938
+ steps: [{
3939
+ text: t("plugins.secrets.development.file", { names: list }),
3940
+ code: envLines(names)
3941
+ }, { text: t("plugins.secrets.development.values") }],
3942
+ notes: [t("plugins.secrets.development.local")]
3943
+ };
3944
+ case "server": return {
3945
+ where: "server",
3946
+ title: t("plugins.secrets.server.title"),
3947
+ steps: [{ text: t("plugins.secrets.server.add", { names: list }) }, { text: t("plugins.secrets.server.deploy") }],
3948
+ notes: [t("plugins.secrets.server.who")]
3949
+ };
3950
+ }
2755
3951
  }
2756
- const URL_ATTRIBUTES = [
2757
- "src",
2758
- "href",
2759
- "poster"
2760
- ];
2761
- const MEDIA_IN_STYLE = /url\((['"]?)(\/media\/[^'")]+)\1\)/g;
2762
3952
  /**
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.
3953
+ * How the site's owner adds the secrets a plugin declared, for this site as
3954
+ * it's set up: where its tasks get them, and, on a full-stack site whose
3955
+ * plugin has a server module, the server first, with the CI as well.
2766
3956
  */
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
- };
3957
+ function pluginSecretsHelp(place, plugin, t) {
3958
+ const names = plugin.permissions.secrets ?? [];
3959
+ if (!place || names.length === 0) return [];
3960
+ if (place.kind === "server") {
3961
+ const ci = place.ci && helpFor(place.ci, names, t);
3962
+ return [plugin.server ? helpFor(place, names, t) : void 0, ci].filter((help) => help !== void 0);
3963
+ }
3964
+ const help = helpFor(place, names, t);
3965
+ return help ? [help] : [];
2819
3966
  }
2820
3967
  //#endregion
2821
3968
  //#region src/registry-version.ts
2822
3969
  /** 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
- }
3970
+ const REGISTRY_VERSION = "0.6.0";
2895
3971
  //#endregion
2896
3972
  //#region src/schedule.tsx
2897
3973
  /** The computer's own time zone, which a site without one takes when something is first scheduled. */
@@ -3243,10 +4319,12 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3243
4319
  const content = draft?.store ?? store;
3244
4320
  const [checking, setChecking] = useState();
3245
4321
  const blocks = useMemo(() => describedConfig(site.description, "template").components, [site.description]);
3246
- const mediaPreviews = useMemo(() => createMediaPreviews(content, siteUrl), [content, siteUrl]);
4322
+ const storage = useRef(void 0);
4323
+ storage.current = state.status === "ready" ? state.content.mediaStorage : void 0;
4324
+ const mediaPreviews = useMemo(() => createMediaPreviews(content, siteUrl, () => storage.current), [content, siteUrl]);
3247
4325
  const outOfReach = registry !== void 0 || atAdminAddress(site.admin) || site.broker !== void 0;
3248
4326
  const registries = useMemo(() => ({
3249
- [GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.5.0"),
4327
+ [GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.6.0"),
3250
4328
  ...outOfReach && {
3251
4329
  [SHADCN_NAMESPACE]: SHADCN_REGISTRY,
3252
4330
  ...site.registries
@@ -3273,6 +4351,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3273
4351
  useEffect(() => favicon ? showSiteIcon(document, favicon, mediaPreviews) : void 0, [favicon, mediaPreviews]);
3274
4352
  const readFile = useCallback((path) => content.read(path), [content]);
3275
4353
  const listFiles = useCallback((dir) => content.list(dir), [content]);
4354
+ const readBytes = useCallback((path) => content.readBytes(path), [content]);
3276
4355
  const demo = isDemoStore(store) ? store : void 0;
3277
4356
  const pages = isGitBackend(store) ? store.pages : void 0;
3278
4357
  const versions = isGitBackend(store) ? store : void 0;
@@ -3281,6 +4360,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3281
4360
  const updates = isGitBackend(store) ? store.updates : void 0;
3282
4361
  const contentOnly = isGitBackend(store) && store.contentOnly === true;
3283
4362
  const scheduledBuilds = isGitBackend(store) ? store.scheduledBuilds : void 0;
4363
+ const tasks = "tasks" in store ? store.tasks : void 0;
3284
4364
  const repositoryVisibility = useMemo(() => isGitBackend(store) ? store.repositoryVisibility?.bind(store) : void 0, [store]);
3285
4365
  const [scheduleSyncProblem, setScheduleSyncProblem] = useState();
3286
4366
  const backendOwner = isGitBackend(store) ? store.ownerAccess : void 0;
@@ -3609,6 +4689,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3609
4689
  state: "building"
3610
4690
  });
3611
4691
  }, [state, store]);
4692
+ const pluginInstalls = demo || contentOnly ? void 0 : account?.review ? "review" : atAdminAddress(site.admin) || site.broker !== void 0 ? "publish" : void 0;
3612
4693
  const value = useMemo(() => ({
3613
4694
  site,
3614
4695
  blocks,
@@ -3624,8 +4705,10 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3624
4705
  drafts,
3625
4706
  readFile,
3626
4707
  listFiles,
4708
+ readBytes,
3627
4709
  registries,
3628
4710
  outOfReach,
4711
+ pluginInstalls,
3629
4712
  demo,
3630
4713
  pages,
3631
4714
  versions,
@@ -3637,7 +4720,8 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3637
4720
  scheduledBuilds,
3638
4721
  repositoryVisibility,
3639
4722
  scheduleSyncProblem,
3640
- followBuild
4723
+ followBuild,
4724
+ tasks
3641
4725
  }), [
3642
4726
  site,
3643
4727
  blocks,
@@ -3653,8 +4737,10 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3653
4737
  drafts,
3654
4738
  readFile,
3655
4739
  listFiles,
4740
+ readBytes,
3656
4741
  registries,
3657
4742
  outOfReach,
4743
+ pluginInstalls,
3658
4744
  demo,
3659
4745
  pages,
3660
4746
  versions,
@@ -3666,7 +4752,8 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
3666
4752
  scheduledBuilds,
3667
4753
  repositoryVisibility,
3668
4754
  scheduleSyncProblem,
3669
- followBuild
4755
+ followBuild,
4756
+ tasks
3670
4757
  ]);
3671
4758
  const table = useStringTable();
3672
4759
  const strings = useMemo(() => draft ? {
@@ -3711,7 +4798,7 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
3711
4798
  const t = useStrings();
3712
4799
  const strings = useStringTable();
3713
4800
  const theme = useThemeChoice();
3714
- const { preview, siteUrl, mediaPreviews, account, scheduledBuilds, repositoryVisibility, scheduleSyncProblem } = useAdmin();
4801
+ const { site, preview, siteUrl, mediaPreviews, account, scheduledBuilds, repositoryVisibility, scheduleSyncProblem, readFile, readBytes, listFiles, publish, tasks } = useAdmin();
3715
4802
  const { timing, visibility } = useScheduleInfoOf(scheduledBuilds, repositoryVisibility);
3716
4803
  const schedule = useMemo(() => ({
3717
4804
  ...timing && { timing },
@@ -3725,11 +4812,48 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
3725
4812
  const siteCss = useSiteCss(preview.stylesheets);
3726
4813
  const [choosing, setChoosing] = useState();
3727
4814
  const confirmLeave = t("unsaved.confirm");
4815
+ const plugins = useMemo(() => site.description.plugins ?? [], [site.description.plugins]);
4816
+ const pluginHost = useMemo(() => ({
4817
+ editorUrl: frames.url,
4818
+ siteUrl,
4819
+ theme,
4820
+ read: readFile,
4821
+ readBytes,
4822
+ list: listFiles,
4823
+ publish: async (changes, message) => (await publish(changes, message)).ok,
4824
+ ...account?.plugins && { server: account.plugins },
4825
+ navigate: (href) => navigate(href, () => window.confirm(confirmLeave)),
4826
+ secretsHelp: async (plugin) => pluginSecretsHelp(await tasks?.secretsPlace().catch(() => void 0), plugin, t),
4827
+ ...tasks && { runTask: async (request) => {
4828
+ try {
4829
+ return await tasks.run(request);
4830
+ } catch (error) {
4831
+ if (error instanceof PluginTaskError) throw new Error(t(`plugins.task.${error.problem}`));
4832
+ throw error;
4833
+ }
4834
+ } }
4835
+ }), [
4836
+ frames.url,
4837
+ siteUrl,
4838
+ theme,
4839
+ readFile,
4840
+ readBytes,
4841
+ listFiles,
4842
+ publish,
4843
+ account,
4844
+ confirmLeave,
4845
+ tasks,
4846
+ t
4847
+ ]);
4848
+ const callPlugin = useRef(void 0);
4849
+ const onPluginCall = useCallback((call) => {
4850
+ callPlugin.current = call;
4851
+ }, []);
3728
4852
  const services = useMemo(() => ({
3729
4853
  source: frames,
3730
4854
  init: {
3731
4855
  content: state.content,
3732
- media: state.media,
4856
+ media: allMedia(state.content, state.media),
3733
4857
  schedule,
3734
4858
  siteUrl,
3735
4859
  preview,
@@ -3748,7 +4872,8 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
3748
4872
  };
3749
4873
  });
3750
4874
  }),
3751
- navigate: (href) => navigate(href, () => window.confirm(confirmLeave))
4875
+ navigate: (href) => navigate(href, () => window.confirm(confirmLeave)),
4876
+ callPlugin: (plugin, method, args) => callPlugin.current ? callPlugin.current(plugin, method, args) : Promise.reject(/* @__PURE__ */ new Error("Plugins haven't started."))
3752
4877
  }), [
3753
4878
  frames,
3754
4879
  state,
@@ -3765,7 +4890,12 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
3765
4890
  return /* @__PURE__ */ jsxs(FrameServicesProvider, {
3766
4891
  value: services,
3767
4892
  children: [
3768
- children,
4893
+ /* @__PURE__ */ jsx(PluginServices, {
4894
+ plugins,
4895
+ host: pluginHost,
4896
+ onCall: onPluginCall,
4897
+ children
4898
+ }),
3769
4899
  /* @__PURE__ */ jsx(SiteCodeFrame, { onClient: onCodeClient }),
3770
4900
  choosing && /* @__PURE__ */ jsx(MediaPickerDialog, {
3771
4901
  kind: choosing.kind,
@@ -3949,4 +5079,4 @@ function formValues(collection, entry) {
3949
5079
  return values;
3950
5080
  }
3951
5081
  //#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 };
5082
+ export { useDebounced as $, useRoute as A, redirectsFileChange as At, FrameServicesProvider as B, AppLink as C, menusFileChange as Ct, hasUnsavedChanges as D, newPageData as Dt, entryEditorHref as E, newCollectionSettings as Et, PluginScreenFrame as F, storedEntryFields as Ft, READY_MESSAGE as G, useFrame as H, usePluginFrame as I, uniqueName as It, Dialog as J, draftStarted as K, usePluginHost as L, updateSettingsFileChange as Lt, prepareUpload as M, slugify as Mt, useOutsideHost as N, storedCss as Nt, navigate as O, pageFileChange as Ot, PublishFailure as P, storedData as Pt, TextField as Q, createMediaPreviews as R, editedMarkdown as Rt, MediaScreen as S, mediaFileChange as St, collectionHref as T, movePageChanges as Tt, useFrameServices as U, reloadFrames as V, useFrameUpdates as W, Field as X, ErrorMessage as Y, PublishedNotice as Z, scheduleSettingsChanges as _, customCssFileChange as _t, AdminContext as a, findPage as at, MediaChooser as b, entryFileChange$1 as bt, useCanChangeSettings as c, mediaUrl as ct, useApplyThemeChoice as d, addressPatternFor as dt, StringsProvider as et, useDarkMode as f, checkEntrySlug as ft, ScheduleStatus as g, collectionSettingsChanges as gt, ScheduleFields as h, collectionFileChange$1 as ht, usePreviewStyles as i, describeContrast as it, useUnsavedChanges as j, siteSettingsFileChange as jt, pageEditorHref as k, pageTitle$1 as kt, useSiteContent as l, siteImages as lt, ComingUp as m, codeFileChanges as mt, formValues as n, useStringTable as nt, AdminProvider as o, allMedia as ot, useThemeChoice as p, checkPageAddress as pt, Button as q, PreviewFrame as r, useStrings as rt, useAdmin as s, fileName as st, SCHEDULE_PROP as t, defaultStrings as tt, setThemeChoice as u, uploadName as ut, REGISTRY_VERSION as v, deleteCollectionChanges as vt, SETTINGS_SECTIONS as w, moveEntryChanges as wt, MediaField as x, layoutFileChange as xt, MediaChoiceContext as y, duplicatePageData as yt, showMediaInPreview as z, formattedHtml as zt };