@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.
- package/README.md +1 -1
- package/dist/dev.d.ts +4 -2
- package/dist/dev.js +21 -1
- package/dist/editor.js +215 -10
- package/dist/{entry-values-ocbalc9U.js → entry-values-BrY3L0Ai.js} +1623 -493
- package/dist/index.d.ts +236 -8
- package/dist/index.js +4797 -2878
- package/dist/plugin-protocol-DkvBDqsF.js +119 -0
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.js +153 -0
- package/package.json +12 -6
- package/plugin.css +215 -0
- package/styles.css +142 -6
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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),
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
2095
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2150
|
-
|
|
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
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
*/
|
|
2157
|
-
|
|
2158
|
-
const
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
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
|
|
2180
|
-
"
|
|
2181
|
-
"
|
|
2182
|
-
"
|
|
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
|
-
*
|
|
2190
|
-
*
|
|
2191
|
-
*
|
|
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
|
|
2194
|
-
const
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
}
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
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
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
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
|
-
|
|
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
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
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
|
|
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)
|
|
2343
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
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
|
-
|
|
2709
|
-
|
|
2710
|
-
const
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
}
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
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
|
-
*
|
|
2764
|
-
*
|
|
2765
|
-
*
|
|
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
|
|
2768
|
-
const
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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 };
|