@goodfellow-cms/admin 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,200 +1,7 @@
1
- import { n as CONNECT_MESSAGE, r as createChannel } from "./plugin-protocol-DkvBDqsF.js";
2
- import { APP_API_HEADER, COLOR_SCHEME_ATTRIBUTE, ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_REGISTRY, HEADER_FILE, MAX_UPLOAD_BYTES, MEDIA_DIR, MENUS_FILE, PluginTaskError, REDIRECTS_FILE, SHADCN_NAMESPACE, SHADCN_REGISTRY, SITE_FILE, ScheduledBuildsError, SignInError, TASK_INPUT_LIMIT, addressPatternFor, adminOrigin, allPages, checkEntrySlug, checkPageAddress, checkSite, codeFileChanges, collectionFileChange, collectionFileChange as collectionFileChange$1, customCssFileChange, deleteCollectionChanges, describedConfig, duplicatePageData, entryFileChange, entryFileChange as entryFileChange$1, entryLanguage, entryPathIn, entryTitle, escapeStyleText, extensionOf, fileName, goodfellowRegistryUrl, googleFontsUrl, isDemoStore, isStorableAddress, keptDrafts, layoutFileChange, limitsFor, loadSiteContent, markdownToHtml, mayChangeEntry, mayChangeLayout, mayChangeMedia, mayChangeMenus, mayChangePage, mediaFileChange, mediaKind, mediaKind as mediaKind$1, mediaStorageFileChange, memoryKeeper, menusFileChange, moveEntryChanges, movePageChanges, newCollectionSettings, newDraftName, newPageData, newTaskRequestId, openTaskAnswer, outsideHost, outsideLimits, pageFileChange, pageTitle as pageTitle$1, pluginFrameUrl, pluginMayList, pluginMayUse, publishProblems, redirectsFileChange, relinkSite, relinkedChanges, reviewersFor, scheduleOf, scheduleProblem, scheduleState, siteLanguages, siteSettingsFileChange, slugify, storedCss, storedData, storedEntryFields, storedMediaUrl, taskKeys, themeToCss, todayIn, trimChars, uniqueName, upcomingChanges, updateSettingsFileChange, uploadName, wallClock, withStoredFiles, withoutCredits, writeChanges } from "@goodfellow-cms/core";
1
+ import { B as siteSettingsFileChange, R as pageTitle$1, _ as mediaUsage, a as CONNECT_MESSAGE, c as MEDIA_URL_PREFIX, d as fileName$1, f as formatSize, g as mediaUrl, h as mediaPath, i as showMediaInPreview, l as allMedia, m as mediaKind$1, n as createMediaPreviews, o as createChannel, p as isStoredMedia, q as withoutCredits, r as mediaType, s as MAX_UPLOAD_BYTES, u as extensionOf, y as uploadName } from "./plugin-protocol-BgYL9WSm.js";
2
+ import { APP_API_HEADER, COLOR_SCHEME_ATTRIBUTE, ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_REGISTRY, HEADER_FILE, MEDIA_DIR, MENUS_FILE, PluginTaskError, REDIRECTS_FILE, SHADCN_NAMESPACE, SHADCN_REGISTRY, SITE_FILE, ScheduledBuildsError, SignInError, TASK_INPUT_LIMIT, adminOrigin, allPages, checkSite, describedConfig, entryTitle, escapeStyleText, fileName, goodfellowRegistryUrl, googleFontsUrl, isDemoStore, isStorableAddress, keptDrafts, limitsFor, loadSiteContent, mayChangeEntry, mayChangeLayout, mayChangeMedia, mayChangeMenus, mayChangePage, mediaStorageFileChange, memoryKeeper, newDraftName, newTaskRequestId, openTaskAnswer, outsideHost, outsideLimits, pluginFrameUrl, pluginMayList, pluginMayUse, publishProblems, reviewersFor, scheduleOf, scheduleProblem, scheduleState, siteLanguages, storedMediaUrl, taskKeys, themeToCss, todayIn, upcomingChanges, wallClock, withStoredFiles, writeChanges } from "@goodfellow-cms/core";
3
3
  import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
- import TurndownService from "turndown";
5
- import { gfm } from "turndown-plugin-gfm";
6
4
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
- //#region src/markdown-text.ts
8
- let service;
9
- /** Converts the formatted editor's HTML to Markdown, in the style most Markdown is written in. */
10
- function htmlToMarkdown(html) {
11
- if (!service) {
12
- service = new TurndownService({
13
- headingStyle: "atx",
14
- bulletListMarker: "-",
15
- codeBlockStyle: "fenced",
16
- emDelimiter: "*",
17
- strongDelimiter: "**",
18
- hr: "---"
19
- });
20
- service.use(gfm);
21
- service.addRule("underline", {
22
- filter: ["u"],
23
- replacement: (content) => content
24
- });
25
- service.addRule("strikethrough", {
26
- filter: ["del", "s"],
27
- replacement: (content) => `~~${content}~~`
28
- });
29
- service.addRule("listItem", {
30
- filter: "li",
31
- replacement(content, node) {
32
- const parent = node.parentNode;
33
- let marker = "- ";
34
- if (parent?.nodeName === "OL") marker = `${Number(parent.getAttribute("start") ?? "1") + Array.prototype.indexOf.call(parent.children, node)}. `;
35
- const indent = " ".repeat(marker.length);
36
- const text = content.replace(/^\n+/, "").replace(/\n+$/, "").replace(/\n/g, `\n${indent}`);
37
- return `${marker}${text}${node.nextSibling ? "\n" : ""}`;
38
- }
39
- });
40
- }
41
- return service.turndown(html).replace(/[ \t]+$/gm, "").trim();
42
- }
43
- /** The HTML the formatted editor starts from for some Markdown. */
44
- function formattedHtml(markdown) {
45
- return markdownToHtml(markdown);
46
- }
47
- /**
48
- * The Markdown to save after the formatted editor changed `html`. If the text
49
- * is the same as `original`'s, only written differently (the formatted editor
50
- * tidies what it loads), `original` is kept, so the file isn't rewritten.
51
- */
52
- function editedMarkdown(original, html) {
53
- const edited = htmlToMarkdown(html);
54
- return edited === htmlToMarkdown(formattedHtml(original)) ? original : edited;
55
- }
56
- //#endregion
57
- //#region src/changes.ts
58
- /**
59
- * The file changes the admin panel saves. Most are built by core's helpers,
60
- * which the Claude connector shares; converting a collection's items between
61
- * Markdown and formatted text needs the browser's HTML handling, so it's here.
62
- */
63
- /**
64
- * A stored value as it fits its collection's field now, or `undefined` if it
65
- * doesn't: the field must exist, and a choice must still be offered. Tags
66
- * keep the ones that still are.
67
- */
68
- function keptValue(fields, name, value) {
69
- const field = fields.get(name);
70
- if (!field) return void 0;
71
- const offered = (choice) => field.options?.some((option) => option.value === choice) === true;
72
- if (field.type === "select") return offered(value) ? value : void 0;
73
- if (field.type === "tags" && Array.isArray(value)) {
74
- const tags = value.filter(offered);
75
- if (tags.length === value.length) return value;
76
- return tags.length > 0 ? tags : void 0;
77
- }
78
- return value;
79
- }
80
- /** Where each item's page goes when its collection's address patterns change, in each of the site's languages. */
81
- function movedEntryPages(site, collection, settings) {
82
- if (!collection.settings.path || !settings.path) return [];
83
- return collection.entries.flatMap((entry) => {
84
- const to = entryPathIn(site, settings, entry.slug, entryLanguage(site, entry));
85
- return entry.path && to && to !== entry.path ? [{
86
- from: entry.path,
87
- to
88
- }] : [];
89
- });
90
- }
91
- /**
92
- * Saves a collection's new settings. Values of fields that were removed, and
93
- * choices that are no longer offered, are removed from every entry in the
94
- * same save, so a field added later with the same name starts empty. Given
95
- * the `site`, a new address pattern points every link to an item's page at
96
- * its new address, in the same save.
97
- */
98
- function collectionSettingsChanges(collection, settings, site) {
99
- const relinked = site ? relinkSite({
100
- ...site.content,
101
- collections: site.content.collections.map((candidate) => candidate.id === collection.id ? {
102
- ...collection,
103
- settings: {
104
- ...collection.settings,
105
- template: settings.template
106
- }
107
- } : candidate)
108
- }, movedEntryPages(site.content.settings, collection, settings), site.blocks) : void 0;
109
- const current = relinked?.content.collections.find((candidate) => candidate.id === collection.id) ?? collection;
110
- const relinkedFiles = new Set(relinked?.files);
111
- const fields = new Map(settings.fields.map((field) => [field.name, field]));
112
- const changes = [collectionFileChange(collection.id, {
113
- ...settings,
114
- template: current.settings.template
115
- })];
116
- const before = collection.settings.markdown?.body;
117
- const after = settings.markdown?.body;
118
- const reformat = before !== after;
119
- for (const entry of current.entries) {
120
- const values = Object.entries(entry.content.fields);
121
- const remaining = values.flatMap(([name, value]) => {
122
- const kept = keptValue(fields, name, value);
123
- return kept === void 0 ? [] : [[name, kept]];
124
- });
125
- if (!(remaining.length !== values.length || remaining.some(([name, value]) => value !== entry.content.fields[name])) && !reformat && !relinkedFiles.has(entry.file)) continue;
126
- const kept = Object.fromEntries(remaining);
127
- if (reformat) {
128
- if (before !== void 0 && typeof kept[before] === "string") kept[before] = formattedHtml(kept[before]);
129
- if (after !== void 0 && typeof kept[after] === "string") kept[after] = htmlToMarkdown(kept[after]);
130
- }
131
- const change = entryFileChange({
132
- id: collection.id,
133
- settings
134
- }, entry.slug, kept, entry.content);
135
- if (change.path !== entry.file) changes.push({
136
- path: entry.file,
137
- delete: true
138
- });
139
- changes.push(change);
140
- }
141
- if (relinked) changes.push(...relinkedChanges(relinked, [collection.file, ...collection.entries.map((entry) => entry.file)]));
142
- return changes;
143
- }
144
- //#endregion
145
- //#region src/media.ts
146
- /** Files in `public/media/` are served from `/media/` on the site. */
147
- const MEDIA_URL_PREFIX = "/media/";
148
- /** `public/media/photo.jpg` → `/media/photo.jpg`. */
149
- function mediaUrl(path) {
150
- return `${MEDIA_URL_PREFIX}${path.slice(MEDIA_DIR.length + 1)}`;
151
- }
152
- /** `/media/photo.jpg` → `public/media/photo.jpg`, or `undefined` for addresses outside the media folder. */
153
- function mediaPath(url) {
154
- if (!url.startsWith("/media/")) return void 0;
155
- const rest = url.slice(7).split(/[?#]/)[0] ?? "";
156
- if (!rest || rest.split("/").some((part) => part === "" || part === "." || part === "..")) return void 0;
157
- return `${MEDIA_DIR}/${decodeURIComponent(rest)}`;
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
- }
165
- /** Where a media file is used, by name, so editors know what deleting or replacing it affects. */
166
- function mediaUsage(content, url, names) {
167
- const uses = (value) => JSON.stringify(value).includes(url);
168
- const used = [];
169
- if (uses(content.settings)) used.push(names.settings);
170
- if (uses(content.header)) used.push(names.header);
171
- if (uses(content.footer)) used.push(names.footer);
172
- for (const page of content.pages) if (uses(page.content)) used.push(pageTitle$1(page));
173
- for (const collection of content.collections) {
174
- if (uses(collection.settings)) used.push(collection.settings.name);
175
- for (const entry of collection.entries) if (uses(entry.content)) used.push(entryTitle(entry));
176
- }
177
- if (content.customCss.includes(url)) used.push(names.css);
178
- return used;
179
- }
180
- /** The addresses of the site's uploaded images, such as `/media/photo.jpg`. */
181
- function siteImages(media) {
182
- return media.filter((path) => mediaKind(path) === "image").map(mediaUrl);
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
- }
197
- //#endregion
198
5
  //#region src/site-code.ts
199
6
  /**
200
7
  * What the admin panel needs the site's own code for: the pages its blocks add
@@ -348,7 +155,8 @@ function languageName$1(content, code) {
348
155
  function describeProblem(t, content, problem) {
349
156
  switch (problem.kind) {
350
157
  case "missing-page":
351
- case "missing-media": return t(`checks.${problem.kind}`, {
158
+ case "missing-media":
159
+ case "unsafe-link": return t(`checks.${problem.kind}`, {
352
160
  place: problemPlace(t, content, problem.file),
353
161
  target: problem.target
354
162
  });
@@ -408,6 +216,7 @@ const defaultStrings = {
408
216
  "nav.blocks": "Blocks",
409
217
  "nav.layout": "Header & Footer",
410
218
  "nav.settings": "Site Settings",
219
+ "nav.menu": "Menu",
411
220
  loading: "Loading…",
412
221
  "setup.noBackend": "This site's settings don't say where the site is stored, so the admin panel can't open it. A developer needs to add a backend to goodfellow.config.tsx.",
413
222
  "signIn.title": "Sign in to edit this site",
@@ -503,11 +312,14 @@ const defaultStrings = {
503
312
  "editor.plugin.problemField": "{block}, {field}: {message}",
504
313
  "editor.plugin.loading": "Opening {editor}…",
505
314
  "editor.plugin.failed": "{editor} didn't start. Choose the standard editor to keep editing.",
315
+ "editor.invalid": "The editor gave back something this site can't publish, so nothing was published. Reload the page and try again.",
506
316
  "editor.plugin.invalid": "{editor} gave back something this site can't publish, so nothing was published. Try again, or choose the standard editor.",
507
317
  "publish.publishing": "Publishing…",
508
318
  "publish.done": "Published.",
509
319
  "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.",
510
320
  "publish.error": "Your changes couldn't be published. Check your connection and try again.",
321
+ "publish.reloaded": "This is the page as the other person left it. Make your changes again, or put yours back to publish them over theirs.",
322
+ "publish.putMineBack": "Put my changes back",
511
323
  "publish.invalid": "Fix the highlighted problems before publishing.",
512
324
  "draft.label": "Draft",
513
325
  "draft.add": "Add to draft",
@@ -570,6 +382,7 @@ const defaultStrings = {
570
382
  "limits.mediaReadOnly": "An owner has limited what you can change, and the media library is outside it. You can still use its files on pages you can change.",
571
383
  "checks.missing-page": "{place} links to {target}, which isn't a page on the site.",
572
384
  "checks.missing-media": "{place} uses {target}, which isn't in the media library.",
385
+ "checks.unsafe-link": "{place} links to {target}, an address that would run code on the site. Pages leave it out: use an address that starts with https://, or one of the site's own pages.",
573
386
  "checks.no-title": "{place} has no title, so browser tabs and search results show only the site's name.",
574
387
  "checks.missing-translation": "{place} has no translation in {language} yet. Visitors reading in {language} are sent to its home page instead.",
575
388
  "checks.outdated-translation": "{place} has changed since its translation in {language} was made. Update the translation, then mark it as up to date.",
@@ -870,6 +683,11 @@ const defaultStrings = {
870
683
  "blocks.discard": "Discard changes",
871
684
  "blocks.publish": "Publish",
872
685
  "blocks.publishedNotice": "Published. Added blocks will be in the editor's list of blocks, and removed ones gone from it, once the site has been updated.",
686
+ "blocks.submit": "Submit for review",
687
+ "blocks.reviewHint": "Blocks' code runs on this site's server, which holds its secrets, so adding and removing blocks is submitted for review, for a developer to accept.",
688
+ "blocks.reviewDescription": "From the admin panel's Blocks screen. The blocks' code runs on the site's server.",
689
+ "blocks.submittedNotice": "Submitted for review. Once a developer accepts it and the site has been updated, the editor's list of blocks will change.",
690
+ "blocks.seeReview": "See it",
873
691
  "blocks.addMessage": "Add the {name} block",
874
692
  "blocks.removeMessage": "Remove the {name} block",
875
693
  "blocks.changeMessage": "Change blocks: {changes}",
@@ -907,6 +725,7 @@ const defaultStrings = {
907
725
  "media.uploaded": "Uploaded. Visitors see new files once the live site is updated.",
908
726
  "media.uploadMessage": "Add {names}",
909
727
  "media.replaceMessage": "Replace {name}",
728
+ "media.replaceKind": "{name} is a different kind of file from {target}. Replace it with a file of the same kind, or upload {name} as a new file.",
910
729
  "media.deleteMessage": "Delete {name}",
911
730
  "media.deleteTitle": "Delete {name}?",
912
731
  "media.deleteBody": "{name} will be removed from the site.",
@@ -964,6 +783,12 @@ const defaultStrings = {
964
783
  "style.allSides": "All sides",
965
784
  "style.none": "None",
966
785
  "style.radius": "Rounded corners",
786
+ "style.hiddenOn": "Hide on",
787
+ "style.hiddenOn.phones": "Phones (narrower than 640px)",
788
+ "style.hiddenOn.tablets": "Tablets (640px to 1023px)",
789
+ "style.hiddenOn.computers": "Computers (1024px and wider)",
790
+ "style.hiddenOnHint": "Choose a preview width above the page to see it as each size of screen does. A block hidden there is still in the Outline, where you can choose it.",
791
+ "style.hiddenOnAll": "This block is hidden on every size of screen, so visitors never see it.",
967
792
  "style.radius.none": "Square",
968
793
  "style.radius.sm": "Slightly rounded",
969
794
  "style.radius.md": "Rounded",
@@ -1068,10 +893,8 @@ const defaultStrings = {
1068
893
  "ai.error.answer": "The answer couldn't be used. Try again, or try a different model or service.",
1069
894
  "ai.error.service": "{service} reported a problem. Try again in a moment.",
1070
895
  "settings.title": "Site Settings",
1071
- "appearance.choice": "Colors of the admin panel",
1072
- "appearance.system": "Match my computer",
1073
- "appearance.light": "Light",
1074
- "appearance.dark": "Dark",
896
+ "appearance.darkMode": "Dark mode",
897
+ "adminLanguage.label": "Language of the admin panel",
1075
898
  "settings.tab.general": "General",
1076
899
  "settings.tab.languages": "Languages",
1077
900
  "settings.tab.theme": "Colors & fonts",
@@ -1187,6 +1010,7 @@ const defaultStrings = {
1187
1010
  "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.",
1188
1011
  "security.broker.title": "Sign-in page",
1189
1012
  "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.",
1013
+ "security.exposed": "Editors sign in at the site's own address, where its pages could reach their sign-in: code added on the Code tab, or by a block, could act as anyone signed in. A sign-in page of its own, below, keeps it out of their reach, and needs no domain on GitLab, Cloudflare Pages or Vercel. Where you have a domain and the host allows it, an admin address of its own, on the Domain screen, does too.",
1190
1014
  "security.broker.useTitle": "Sign in at a sign-in page that's online",
1191
1015
  "security.add.title": "Add a token broker",
1192
1016
  "security.add.intro": "Makes a project on {host} for the sign-in page, from Goodfellow's released files and a file naming this site, and puts it online where you choose. It needs a token that can create projects, used only now and kept only until you leave this screen.",
@@ -1259,6 +1083,74 @@ const defaultStrings = {
1259
1083
  "settings.tab.editors": "Editors",
1260
1084
  "settings.tab.updates": "Updates",
1261
1085
  "settings.tab.history": "Site history",
1086
+ "settings.tab.comments": "Comments",
1087
+ "comments.intro": "Visitors' comments are kept on GitHub, in a public project of their own, and shown on pages with the Comments block through Giscus. Visitors sign in with a GitHub account to comment.",
1088
+ "comments.demo": "Comments can't be set up in the demo.",
1089
+ "comments.local": "Comments are set up from the admin panel on the live site, which signs in to GitHub. Pages here show them once they are.",
1090
+ "comments.unavailable": "The admin panel can set comments up only where it signs in to GitHub itself. Set them up on GitHub as the documentation's Comments page says, then paste Giscus's code into the Comments block.",
1091
+ "comments.setup.title": "Set up comments",
1092
+ "comments.setup.new": "Make a new project for comments",
1093
+ "comments.setup.existing": "Use a project I already have",
1094
+ "comments.setup.name": "Name of the new project",
1095
+ "comments.setup.nameHint": "It's made beside the site's own on GitHub. Anyone can read it, since visitors read the comments from there.",
1096
+ "comments.setup.existingName": "The project, as owner/name or its address on GitHub",
1097
+ "comments.setup.existingHint": "It must be public. Its Discussions are turned on if they're off.",
1098
+ "comments.setup.button": "Set up comments",
1099
+ "comments.setup.working": "Setting up…",
1100
+ "comments.setup.description": "Visitors' comments on {site}",
1101
+ "comments.setup.message": "Set up comments",
1102
+ "comments.install.title": "Last step: install Giscus",
1103
+ "comments.install.intro": "Giscus is the GitHub app that shows comments on pages and posts what visitors write. Install it on {project}, choosing only that project.",
1104
+ "comments.install.button": "Install Giscus",
1105
+ "comments.install.then": "Then add the Comments block to a page, or to a collection's template. Pages show comments once the site has been rebuilt.",
1106
+ "comments.place.title": "Where comments are kept",
1107
+ "comments.place.kept": "Comments are kept as {category} discussions in",
1108
+ "comments.place.install": "If pages don't show comments, check that Giscus is installed on the project:",
1109
+ "comments.place.giscus": "Giscus on GitHub",
1110
+ "comments.stop.button": "Stop using comments",
1111
+ "comments.stop.title": "Stop using comments?",
1112
+ "comments.stop.body": "Pages stop showing comments once the site has been rebuilt. Everything visitors wrote stays in {project} on GitHub, so you can set comments up with it again later.",
1113
+ "comments.stop.message": "Stop using comments",
1114
+ "comments.moderate.title": "What visitors have written",
1115
+ "comments.moderate.empty": "No one has commented yet.",
1116
+ "comments.moderate.more": "Show more pages",
1117
+ "comments.moderate.block": "To stop someone commenting at all, block them on GitHub.",
1118
+ "comments.moderate.blockLink": "How to block someone",
1119
+ "comments.thread.count": "Comments: {count}",
1120
+ "comments.thread.locked": "Closed to new comments",
1121
+ "comments.thread.lock": "Close to new comments",
1122
+ "comments.thread.unlock": "Open to new comments",
1123
+ "comments.openOnGitHub": "Open on GitHub",
1124
+ "comments.comment.hidden": "Hidden",
1125
+ "comments.comment.hiddenAs": "Hidden as {reason}",
1126
+ "comments.comment.hide": "Hide",
1127
+ "comments.comment.show": "Show again",
1128
+ "comments.comment.delete": "Delete",
1129
+ "comments.hide.title": "Hide this comment?",
1130
+ "comments.hide.body": "It stays on the page, folded away and marked with why it was hidden, until you show it again.",
1131
+ "comments.hide.reason": "Why",
1132
+ "comments.reason.spam": "spam",
1133
+ "comments.reason.abuse": "abusive",
1134
+ "comments.reason.off-topic": "off topic",
1135
+ "comments.delete.title": "Delete this comment?",
1136
+ "comments.delete.body": "It's removed for good.",
1137
+ "comments.delete.bodyReplies": "It's removed for good, with the replies to it.",
1138
+ "comments.token.title": "Use a GitHub token for comments",
1139
+ "comments.token.intro": "The admin panel's sign-in can reach only the site's own project on GitHub. To set up and moderate comments, use a GitHub token of your own. It's kept only in this tab, until you sign out.",
1140
+ "comments.token.create": "Create a token on GitHub",
1141
+ "comments.token.scope": "It's filled in with what comments need: leave the choices as they are.",
1142
+ "comments.token.paste": "Copy the token and paste it here:",
1143
+ "comments.token.label": "GitHub token",
1144
+ "comments.token.active": "This tab is using a GitHub token of your own for comments.",
1145
+ "comments.error.no-access": "GitHub didn't let this sign-in do that. Use a GitHub token of your own, made by someone who can manage the project.",
1146
+ "comments.error.not-found": "GitHub has no project with that name, or this sign-in can't see it.",
1147
+ "comments.error.not-public": "That project is private, so visitors couldn't read its comments. Make it public on GitHub, then try again.",
1148
+ "comments.error.name-taken": "There's already a project with that name. Choose another name, or use it as a project you already have.",
1149
+ "comments.error.name-invalid": "A project's name can have only letters, numbers, hyphens, underscores and dots.",
1150
+ "comments.error.no-discussions": "Discussions are off in that project, and this sign-in can't turn them on. Turn them on in the project's settings on GitHub, then try again.",
1151
+ "comments.error.no-category": "That project's Discussions have no categories yet. Open its Discussions on GitHub once, then try again.",
1152
+ "comments.error.other": "Something went wrong on GitHub. Try again in a moment.",
1153
+ "comments.error.fix": "Open it on GitHub",
1262
1154
  "settings.tab.import": "WordPress",
1263
1155
  "settings.tab.themes": "Themes",
1264
1156
  "plugins.notFound": "The site has no plugin screen at this address.",
@@ -1729,6 +1621,25 @@ const defaultStrings = {
1729
1621
  "forms.accessKeyLink": "Get an access key from Web3Forms",
1730
1622
  "forms.captcha": "Ask visitors to show they aren't robots (hCaptcha)",
1731
1623
  "forms.captchaHint": "Adds Web3Forms' hCaptcha and its script to pages with a form. To turn away answers without it, also turn hCaptcha on for the form in a Web3Forms account.",
1624
+ "consent.title": "Visitors' consent",
1625
+ "consent.ask": "Ask visitors before showing content from other sites",
1626
+ "consent.hint": "Videos from YouTube and Vimeo, Google forms and maps, hCaptcha and the code under Code wait until visitors agree, with a notice at the bottom of the page that asks them. Their answer is kept in their browser. Links to #consent, such as in a menu, open the notice again.",
1627
+ "consent.message": "What the notice says",
1628
+ "consent.accept": "Button that agrees",
1629
+ "consent.decline": "Button that doesn't",
1630
+ "consent.held": "What's shown in a video's or form's place",
1631
+ "consent.heldHint": "{site} is where it comes from, such as youtube-nocookie.com.",
1632
+ "consent.show": "Button there that shows it",
1633
+ "consent.policy": "Privacy page",
1634
+ "consent.policyHint": "The page that says what the site and the sites it shows content from do, such as /privacy.",
1635
+ "consent.policyLabel": "Words of the link to it",
1636
+ "consent.choose": "Button that shows the choices",
1637
+ "consent.save": "Button that keeps the choices",
1638
+ "consent.kind.content": "Choice for content from other sites",
1639
+ "consent.kind.statistics": "Choice for statistics",
1640
+ "consent.kind.marketing": "Choice for advertising",
1641
+ "consent.days": "Ask again after this many days",
1642
+ "consent.daysHint": "Visitors' answers last this long, then the notice asks again. 180 days, about six months, is common; regulators in Europe accept up to 395, 13 months.",
1732
1643
  "forms.formspree": "Formspree form address",
1733
1644
  "forms.formspreeHint": "Make a form in Formspree and copy its address, such as https://formspree.io/f/xyzabcde. On Formspree's free plan, visitors see Formspree's own thank-you page.",
1734
1645
  "forms.address": "Address forms send answers to",
@@ -1847,13 +1758,23 @@ const defaultStrings = {
1847
1758
  "css.label": "Custom CSS",
1848
1759
  "code.editorHint": "Tab indents, and suggestions appear as you type. Press Escape, then Tab, to leave the editor. Ctrl+F finds and replaces.",
1849
1760
  "code.intro": "Code from services such as analytics or site verification, added to every page of the site. It never runs in the admin panel, so check it on the site once it's published. Paste only code you trust: it can change anything on the site.",
1761
+ "code.exposed": "Editors sign in at the same address as the site's pages, so code here runs where it could act as anyone signed in to the admin panel, and publish changes as them.",
1762
+ "code.exposedFix": "A sign-in page or an admin address of its own, on the Security tab, keeps their sign-in out of its reach.",
1850
1763
  "code.head": "In the page head",
1851
1764
  "code.headHint": "Script, style, link, meta and noscript tags, such as the ones Google Analytics gives you.",
1765
+ "code.purpose.head": "What the code in the page head is for",
1766
+ "code.purpose.body": "What the code at the end of the page is for",
1767
+ "code.purposeHint": "Visitors choose which kinds to allow. Code needed for the site to work loads for everyone without asking, so choose it only for code that doesn't follow visitors.",
1768
+ "code.purpose.needed": "Needed for the site to work: loads without asking",
1769
+ "code.purpose.content": "Content from other sites, such as a chat widget",
1770
+ "code.purpose.statistics": "Statistics, such as Google Analytics",
1771
+ "code.purpose.marketing": "Advertising",
1852
1772
  "code.body": "At the end of the page",
1853
1773
  "code.bodyHint": "Any HTML, such as a chat widget, added just before the end of every page.",
1854
1774
  "code.error.text": "The code in the page head has text outside a tag: \"{text}\". Only tags such as <script> and <meta> can go there.",
1855
1775
  "code.error.tag": "A <{tag}> tag can't go in the page head. Put it in \"At the end of the page\" instead.",
1856
- "code.error.unclosed": "A {tag} tag in the page head isn't closed."
1776
+ "code.error.unclosed": "A {tag} tag in the page head isn't closed.",
1777
+ "code.error.template": "This code has </template> in it. While the site asks visitors before loading other sites' content, that would let the code run before they agree. Take it out."
1857
1778
  };
1858
1779
  const StringsContext = createContext(defaultStrings);
1859
1780
  function StringsProvider({ strings, children }) {
@@ -2115,6 +2036,16 @@ function draftStarted(name) {
2115
2036
  });
2116
2037
  }
2117
2038
  //#endregion
2039
+ //#region src/editor-text.ts
2040
+ const EditorTextContext = createContext({
2041
+ puck: {},
2042
+ labels: {}
2043
+ });
2044
+ /** Puck's text and blocks' labels in the admin panel's language, which the page gives its frames. */
2045
+ function useEditorText() {
2046
+ return useContext(EditorTextContext);
2047
+ }
2048
+ //#endregion
2118
2049
  //#region src/frame/protocol.ts
2119
2050
  /** A frame sends this to the admin panel's page once it's listening, and the page answers with a port. */
2120
2051
  const READY_MESSAGE = "goodfellow:frame-ready";
@@ -2449,140 +2380,18 @@ function useMayChange(what) {
2449
2380
  return state.status !== "ready" || mayChange(state.content, limits, what);
2450
2381
  }
2451
2382
  //#endregion
2452
- //#region src/media-previews.ts
2453
- const TYPES = {
2454
- jpg: "image/jpeg",
2455
- jpeg: "image/jpeg",
2456
- png: "image/png",
2457
- gif: "image/gif",
2458
- webp: "image/webp",
2459
- avif: "image/avif",
2460
- svg: "image/svg+xml",
2461
- ico: "image/x-icon",
2462
- pdf: "application/pdf",
2463
- mp3: "audio/mpeg",
2464
- m4a: "audio/mp4",
2465
- mp4: "video/mp4",
2466
- webm: "video/webm",
2467
- vtt: "text/vtt"
2468
- };
2469
- /** The type to show a file as, from its name. Needed so browsers display SVGs from memory. */
2470
- function mediaType(name) {
2471
- return TYPES[extensionOf(name)] ?? "application/octet-stream";
2472
- }
2473
- function createMediaPreviews(store, siteUrl, storage = () => void 0) {
2474
- const local = /* @__PURE__ */ new Map();
2475
- const remembered = /* @__PURE__ */ new Map();
2476
- const listeners = /* @__PURE__ */ new Set();
2477
- const loading = /* @__PURE__ */ new Map();
2478
- const base = trimChars(siteUrl, "/", { start: false });
2479
- const remember = (url, bytes) => {
2480
- const previous = local.get(url);
2481
- if (previous) URL.revokeObjectURL(previous);
2482
- remembered.set(url, bytes);
2483
- for (const listener of listeners) listener(url, bytes);
2484
- local.set(url, URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: mediaType(url) })));
2485
- };
2486
- return {
2487
- resolve(url) {
2488
- if (!url.startsWith("/media/")) return url;
2489
- const end = url.search(/[?#]/);
2490
- const stored = local.has(url) ? void 0 : storedMediaUrl(storage(), end < 0 ? url : url.slice(0, end));
2491
- return stored ? `${stored}${end < 0 ? "" : url.slice(end)}` : local.get(url) ?? `${base}${url}`;
2492
- },
2493
- remember,
2494
- onRemember(listener) {
2495
- for (const [url, bytes] of remembered) listener(url, bytes);
2496
- listeners.add(listener);
2497
- return () => listeners.delete(listener);
2498
- },
2499
- async bytes(url) {
2500
- const known = remembered.get(url);
2501
- if (known) return known;
2502
- const path = mediaPath(url);
2503
- return path ? await store.readBytes(path).catch(() => void 0) : void 0;
2504
- },
2505
- load(url) {
2506
- const known = local.get(url);
2507
- if (known) return Promise.resolve(known);
2508
- const path = mediaPath(url);
2509
- if (!path) return Promise.resolve(void 0);
2510
- let pending = loading.get(url);
2511
- if (!pending) {
2512
- pending = store.readBytes(path).then((bytes) => {
2513
- if (!bytes) return void 0;
2514
- remember(url, bytes);
2515
- return local.get(url);
2516
- }).catch(() => void 0);
2517
- loading.set(url, pending);
2518
- }
2519
- return pending;
2520
- }
2521
- };
2522
- }
2523
- const URL_ATTRIBUTES = [
2524
- "src",
2525
- "href",
2526
- "poster"
2527
- ];
2528
- const MEDIA_IN_STYLE = /url\((['"]?)(\/media\/[^'")]+)\1\)/g;
2383
+ //#region src/plugin-server.ts
2529
2384
  /**
2530
- * Keeps media in a preview document showing: rewrites `/media/…` addresses in
2531
- * images, links and inline styles as the preview changes, and if an image
2532
- * still doesn't load, reads it from the repository instead.
2385
+ * The address a plugin's frame asks its server module at, or `undefined` where
2386
+ * `path` would leave the plugin's own addresses: `..` in any spelling (browsers
2387
+ * read `%2e%2e` as `..`), encoded slashes, backslashes, and other origins.
2533
2388
  */
2534
- function showMediaInPreview(doc, previews) {
2535
- const fix = (element) => {
2536
- for (const attribute of URL_ATTRIBUTES) {
2537
- const value = element.getAttribute(attribute);
2538
- if (!value?.startsWith("/media/")) continue;
2539
- const resolved = previews.resolve(value);
2540
- if (resolved !== value) {
2541
- element.setAttribute(`data-gf-${attribute}`, value);
2542
- element.setAttribute(attribute, resolved);
2543
- }
2544
- }
2545
- const style = element.getAttribute("style");
2546
- if (style?.includes("/media/")) {
2547
- const resolved = style.replace(MEDIA_IN_STYLE, (_match, quote, url) => `url(${quote}${previews.resolve(url)}${quote})`);
2548
- if (resolved !== style) element.setAttribute("style", resolved);
2549
- }
2550
- };
2551
- const fixTree = (root) => {
2552
- fix(root);
2553
- for (const element of root.querySelectorAll("[src], [href], [poster], [style]")) fix(element);
2554
- };
2555
- const observer = new MutationObserver((records) => {
2556
- for (const record of records) {
2557
- if (record.type === "attributes" && record.target.nodeType === 1) fix(record.target);
2558
- for (const node of record.addedNodes) if (node.nodeType === 1) fixTree(node);
2559
- }
2560
- });
2561
- observer.observe(doc.documentElement, {
2562
- subtree: true,
2563
- childList: true,
2564
- attributes: true,
2565
- attributeFilter: [...URL_ATTRIBUTES, "style"]
2566
- });
2567
- fixTree(doc.documentElement);
2568
- const onError = (event) => {
2569
- const image = event.target;
2570
- if (image?.tagName !== "IMG") return;
2571
- const original = image.getAttribute("data-gf-src") ?? image.getAttribute("src") ?? "";
2572
- if (!original.startsWith("/media/") || image.hasAttribute("data-gf-loaded")) return;
2573
- image.setAttribute("data-gf-loaded", "");
2574
- previews.load(original).then((url) => {
2575
- if (url) {
2576
- image.setAttribute("data-gf-src", original);
2577
- image.setAttribute("src", url);
2578
- }
2579
- });
2580
- };
2581
- doc.addEventListener("error", onError, true);
2582
- return () => {
2583
- observer.disconnect();
2584
- doc.removeEventListener("error", onError, true);
2585
- };
2389
+ function pluginServerAddress(base, id, path, relativeTo = location.href) {
2390
+ if (/%2e|%2f|%5c|\\/i.test(path) || /^[a-z][a-z\d+.-]*:/i.test(path)) return void 0;
2391
+ if ((path.split(/[?#]/, 1)[0]?.split("/") ?? []).some((segment) => segment === "." || segment === "..")) return void 0;
2392
+ const prefix = new URL(`${base.replace(/\/+$/, "")}/${encodeURIComponent(id)}/`, relativeTo);
2393
+ const url = new URL(path.replace(/^\/+/, ""), prefix);
2394
+ return url.origin === prefix.origin && url.pathname.startsWith(prefix.pathname) ? url.href : void 0;
2586
2395
  }
2587
2396
  //#endregion
2588
2397
  //#region src/plugin-frames.tsx
@@ -2669,10 +2478,11 @@ function pluginHandlers(plugin, host, onResize, editor) {
2669
2478
  const base = host().server;
2670
2479
  if (!plugin.server || !base) throw new RefusedError(`${plugin.title} has no server here.`);
2671
2480
  const path = params?.path;
2672
- if (typeof path !== "string" || path.includes("..") || /^[a-z]+:/i.test(path)) throw new RefusedError("That isn't one of the plugin's addresses.");
2481
+ const address = typeof path === "string" ? pluginServerAddress(base, plugin.id, path) : void 0;
2482
+ if (!address) throw new RefusedError("That isn't one of the plugin's addresses.");
2673
2483
  const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
2674
2484
  const headers = Object.fromEntries(Object.entries(params.headers ?? {}).filter(([name, value]) => typeof value === "string" && /^(content-type|accept|x-goodfellow-plugin-[\w-]+)$/i.test(name)));
2675
- const response = await fetch(`${base.replace(/\/+$/, "")}/${encodeURIComponent(plugin.id)}/${path.replace(/^\/+/, "")}`, {
2485
+ const response = await fetch(address, {
2676
2486
  method,
2677
2487
  headers: {
2678
2488
  ...headers,
@@ -2733,6 +2543,11 @@ function pluginHandlers(plugin, host, onResize, editor) {
2733
2543
  return hooks.draw(block);
2734
2544
  },
2735
2545
  "editor.styles": () => editing("read the site's styles").styles(),
2546
+ "media.bytes": async (url) => {
2547
+ const hooks = editing("show media");
2548
+ if (typeof url !== "string" || !url.startsWith("/media/") || url.includes("..")) return null;
2549
+ return await hooks.mediaBytes(url) ?? null;
2550
+ },
2736
2551
  task: async (params) => {
2737
2552
  const id = params?.id;
2738
2553
  if (typeof id !== "string" || !plugin.tasks?.some((task) => task.id === id)) throw new RefusedError(`${plugin.title} has no task called ${String(id)}.`);
@@ -2773,10 +2588,13 @@ function usePluginFrame(plugin, host, mode, screen, editor) {
2773
2588
  let current;
2774
2589
  let connections = 0;
2775
2590
  let stopped = false;
2591
+ let stopRemembering;
2776
2592
  const onMessage = (event) => {
2777
2593
  const frame = element.current?.contentWindow;
2778
2594
  if (!frame || event.source !== frame || event.data !== "goodfellow:plugin-ready") return;
2779
2595
  current?.close();
2596
+ stopRemembering?.();
2597
+ stopRemembering = void 0;
2780
2598
  setChannel(void 0);
2781
2599
  connections += 1;
2782
2600
  if (connections > MAX_CONNECTIONS) {
@@ -2798,6 +2616,8 @@ function usePluginFrame(plugin, host, mode, screen, editor) {
2798
2616
  siteUrl: latest.current.siteUrl,
2799
2617
  theme: latest.current.theme
2800
2618
  };
2619
+ const storage = mode === "editor" ? latestEditor.current?.mediaStorage() : void 0;
2620
+ if (storage) init.mediaStorage = storage;
2801
2621
  next.call("init", init).then((opened) => {
2802
2622
  if (stopped || current !== next) return;
2803
2623
  setChannel(next);
@@ -2807,6 +2627,12 @@ function usePluginFrame(plugin, host, mode, screen, editor) {
2807
2627
  set: said.set === true,
2808
2628
  undo: said.undo === true
2809
2629
  }, next);
2630
+ stopRemembering = latestEditor.current?.onRemember((url, bytes) => {
2631
+ next.call("media.remember", {
2632
+ url,
2633
+ bytes
2634
+ }).catch(() => {});
2635
+ });
2810
2636
  }
2811
2637
  }, () => {
2812
2638
  if (!stopped && current === next) setFailed(true);
@@ -2816,6 +2642,7 @@ function usePluginFrame(plugin, host, mode, screen, editor) {
2816
2642
  return () => {
2817
2643
  stopped = true;
2818
2644
  window.removeEventListener("message", onMessage);
2645
+ stopRemembering?.();
2819
2646
  current?.close();
2820
2647
  };
2821
2648
  }, [
@@ -3052,28 +2879,147 @@ async function resized(file, extension) {
3052
2879
  const blob = await canvasBlob(canvas, type);
3053
2880
  return blob && (scale < 1 || blob.size < file.size) ? blob : void 0;
3054
2881
  }
3055
- const UNSAFE_SVG_ELEMENTS = [
3056
- "script",
3057
- "foreignObject",
3058
- "iframe",
3059
- "embed",
3060
- "object",
3061
- "audio",
3062
- "video"
3063
- ];
2882
+ const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
2883
+ /** Namespaces an SVG's attributes may be in, besides none. */
2884
+ const SVG_ATTRIBUTE_NAMESPACES = /* @__PURE__ */ new Set([
2885
+ "http://www.w3.org/1999/xlink",
2886
+ "http://www.w3.org/XML/1998/namespace",
2887
+ "http://www.w3.org/2000/xmlns/"
2888
+ ]);
2889
+ /** The SVG elements uploads keep: drawing, text, links, styles and animation, but nothing that loads a page or runs code. */
2890
+ const SVG_ELEMENTS = /* @__PURE__ */ new Set([
2891
+ "svg",
2892
+ "g",
2893
+ "defs",
2894
+ "symbol",
2895
+ "use",
2896
+ "title",
2897
+ "desc",
2898
+ "switch",
2899
+ "view",
2900
+ "path",
2901
+ "rect",
2902
+ "circle",
2903
+ "ellipse",
2904
+ "line",
2905
+ "polyline",
2906
+ "polygon",
2907
+ "text",
2908
+ "tspan",
2909
+ "textPath",
2910
+ "image",
2911
+ "a",
2912
+ "style",
2913
+ "clipPath",
2914
+ "mask",
2915
+ "pattern",
2916
+ "marker",
2917
+ "linearGradient",
2918
+ "radialGradient",
2919
+ "stop",
2920
+ "filter",
2921
+ "feBlend",
2922
+ "feColorMatrix",
2923
+ "feComponentTransfer",
2924
+ "feComposite",
2925
+ "feConvolveMatrix",
2926
+ "feDiffuseLighting",
2927
+ "feDisplacementMap",
2928
+ "feDistantLight",
2929
+ "feDropShadow",
2930
+ "feFlood",
2931
+ "feFuncA",
2932
+ "feFuncB",
2933
+ "feFuncG",
2934
+ "feFuncR",
2935
+ "feGaussianBlur",
2936
+ "feImage",
2937
+ "feMerge",
2938
+ "feMergeNode",
2939
+ "feMorphology",
2940
+ "feOffset",
2941
+ "fePointLight",
2942
+ "feSpecularLighting",
2943
+ "feSpotLight",
2944
+ "feTile",
2945
+ "feTurbulence",
2946
+ "animate",
2947
+ "animateMotion",
2948
+ "animateTransform",
2949
+ "mpath"
2950
+ ]);
2951
+ /** Addresses links and images may have: within the file, on the web, relative ones, and pictures. */
2952
+ const SAFE_SVG_ADDRESS = /^(#|https?:|data:image\/(png|jpeg|gif|webp)[;,]|(?![a-z][a-z\d+.-]*:))/;
2953
+ /**
2954
+ * Addresses an SVG loads as it draws (images, filters' images, `use` and CSS's
2955
+ * `url()`): within the file, the site's own, and pictures in the file, so an
2956
+ * SVG opened on its own never asks another site for anything.
2957
+ */
2958
+ const LOADED_SVG_ADDRESS = /^(#|data:image\/(png|jpeg|gif|webp)[;,]|(?![a-z][a-z\d+.-]*:)(?!\/\/))/;
3064
2959
  /**
3065
- * Removes anything from an SVG that could run code if someone opens the
3066
- * file directly: scripts, embedded pages, event handlers, and javascript:, vbscript:
3067
- * and data: links other than images.
2960
+ * CSS with what would load from another site taken out: `@import`, and
2961
+ * `url()`s other than the file's own and the site's. Escapes could spell
2962
+ * either some other way, so CSS with any is left out whole (`undefined`).
2963
+ */
2964
+ function cssWithoutOutsideAddresses(css) {
2965
+ if (css.includes("\\")) return void 0;
2966
+ return css.replace(/@import[^;]*;?/gi, "").replace(/url\(\s*(['"]?)(.*?)\1\s*\)/gi, (whole, _quote, address) => LOADED_SVG_ADDRESS.test(normalized(address)) ? whole : "none");
2967
+ }
2968
+ /** An attribute's value as a browser reads an address: without the spaces and control characters it skips. */
2969
+ function normalized(value) {
2970
+ return value.replace(/[\s\p{Cc}]/gu, "").toLowerCase();
2971
+ }
2972
+ function isLink(name) {
2973
+ return name === "href" || name === "xlink:href";
2974
+ }
2975
+ /** Whether an element can stay, once its attributes are cleaned. */
2976
+ function keepsElement(element) {
2977
+ if (element.namespaceURI !== SVG_NAMESPACE || !SVG_ELEMENTS.has(element.localName)) return false;
2978
+ if (element.localName.startsWith("animate")) {
2979
+ const name = normalized(element.getAttribute("attributeName") ?? "");
2980
+ if (isLink(name) || name.startsWith("on")) return false;
2981
+ }
2982
+ return true;
2983
+ }
2984
+ function cleanElement(element) {
2985
+ const loads = element.localName !== "a";
2986
+ for (const attribute of [...element.attributes]) {
2987
+ const value = normalized(attribute.value);
2988
+ if (attribute.value.includes("url(") || attribute.localName === "style" && attribute.value.includes("\\")) {
2989
+ const css = cssWithoutOutsideAddresses(attribute.value);
2990
+ if (css === void 0) {
2991
+ element.removeAttributeNode(attribute);
2992
+ continue;
2993
+ }
2994
+ attribute.value = css;
2995
+ }
2996
+ if (attribute.namespaceURI !== null && !SVG_ATTRIBUTE_NAMESPACES.has(attribute.namespaceURI) || attribute.name.includes(":") && !/^(xlink|xml|xmlns):/.test(attribute.name) || attribute.localName.toLowerCase().startsWith("on") || value.includes("javascript:") || value.includes("vbscript:") || isLink(attribute.name.toLowerCase()) && !(loads ? LOADED_SVG_ADDRESS : SAFE_SVG_ADDRESS).test(value)) element.removeAttributeNode(attribute);
2997
+ }
2998
+ for (const child of [...element.childNodes]) if (child.nodeType === Node.ELEMENT_NODE) {
2999
+ if (keepsElement(child)) cleanElement(child);
3000
+ else child.remove();
3001
+ } else if (child.nodeType !== Node.TEXT_NODE && child.nodeType !== Node.CDATA_SECTION_NODE) child.remove();
3002
+ if (element.localName === "style") {
3003
+ const css = cssWithoutOutsideAddresses(element.textContent ?? "");
3004
+ if (css === void 0) element.remove();
3005
+ else if (css !== element.textContent) element.textContent = css;
3006
+ }
3007
+ }
3008
+ /**
3009
+ * Keeps only what an SVG needs to draw itself, so it can't run code if someone
3010
+ * opens the file directly: SVG's own drawing elements, with no scripts,
3011
+ * embedded pages, elements from other namespaces, event handlers, processing
3012
+ * instructions (an XSL stylesheet could write a page), or javascript:, vbscript:
3013
+ * and other addresses than the web's, the file's own and pictures. What it
3014
+ * loads as it draws comes only from the file itself or the site, so opening it
3015
+ * tells no one else.
3068
3016
  */
3069
3017
  function cleanSvg(text) {
3070
3018
  const doc = new DOMParser().parseFromString(text, "image/svg+xml");
3071
- if (doc.querySelector("parsererror") || doc.documentElement.nodeName.toLowerCase() !== "svg") return void 0;
3072
- for (const name of UNSAFE_SVG_ELEMENTS) for (const element of [...doc.getElementsByTagName(name)]) element.remove();
3073
- for (const element of [doc.documentElement, ...doc.documentElement.querySelectorAll("*")]) for (const attribute of [...element.attributes]) {
3074
- const value = attribute.value.trim().toLowerCase();
3075
- if (attribute.name.toLowerCase().startsWith("on") || value.startsWith("javascript:") || value.startsWith("vbscript:") || value.startsWith("data:") && !value.startsWith("data:image/")) element.removeAttribute(attribute.name);
3076
- }
3019
+ const root = doc.documentElement;
3020
+ if (doc.querySelector("parsererror") || root.namespaceURI !== SVG_NAMESPACE || root.localName !== "svg") return;
3021
+ for (const child of [...doc.childNodes]) if (child !== root) child.remove();
3022
+ cleanElement(root);
3077
3023
  return new XMLSerializer().serializeToString(doc);
3078
3024
  }
3079
3025
  /** A file checked and made ready, as a `Blob`: photos made web-sized, SVGs made safe, other files as they are. */
@@ -3127,6 +3073,29 @@ async function prepareStoredUpload(file, maxBytes) {
3127
3073
  ...await imageSize(blob, file.name)
3128
3074
  };
3129
3075
  }
3076
+ /**
3077
+ * Changes as they're published: a file into the media library must be a kind
3078
+ * the site can use by the name it's saved under, whatever it was chosen as,
3079
+ * and an SVG keeps only what draws it (`cleanSvg()`), however it got there,
3080
+ * from an upload, a replaced file or a plugin. Throws `UploadError` for a file
3081
+ * that can't be saved there.
3082
+ */
3083
+ function safeMediaChanges(changes) {
3084
+ return changes.map((change) => {
3085
+ if (!change.path.startsWith(`${MEDIA_DIR}/`) || "delete" in change) return change;
3086
+ const name = fileName(change.path);
3087
+ if (!mediaKind$1(name)) throw new UploadError("type", name, `${name} isn't a kind of file the site can use.`);
3088
+ if (extensionOf(name) !== "svg") return change;
3089
+ const text = "bytes" in change ? new TextDecoder().decode(change.bytes) : change.content;
3090
+ const cleaned = cleanSvg(text);
3091
+ if (cleaned === void 0) throw new UploadError("unreadable", name, `${name} isn't a valid SVG image.`);
3092
+ if (cleaned === new XMLSerializer().serializeToString(new DOMParser().parseFromString(text, "image/svg+xml"))) return change;
3093
+ return {
3094
+ path: change.path,
3095
+ bytes: new TextEncoder().encode(cleaned)
3096
+ };
3097
+ });
3098
+ }
3130
3099
  //#endregion
3131
3100
  //#region src/router.tsx
3132
3101
  function parseHash(hash) {
@@ -3175,8 +3144,13 @@ function navigate(to, confirmLeave) {
3175
3144
  /** Sections of Site Settings' General tab that links can scroll to, as `#/settings/general?section=forms`. */
3176
3145
  const SETTINGS_SECTIONS = {
3177
3146
  contact: "contact",
3178
- forms: "forms"
3147
+ forms: "forms",
3148
+ consent: "consent"
3179
3149
  };
3150
+ /** The id of a General tab section's heading, which links scroll to. */
3151
+ function sectionId(section) {
3152
+ return `gfa-settings-${section}`;
3153
+ }
3180
3154
  function pageEditorHref(path) {
3181
3155
  return `#/pages/edit?path=${encodeURIComponent(path)}`;
3182
3156
  }
@@ -3343,7 +3317,7 @@ function useUploadMedia() {
3343
3317
  if (problem) return problem;
3344
3318
  const { plugin } = client;
3345
3319
  const maxBytes = plugin.mediaStorage?.maxBytes ?? MAX_UPLOAD_BYTES;
3346
- const taken = allMedia(content, media).map(fileName);
3320
+ const taken = allMedia(content, media).map(fileName$1);
3347
3321
  const added = {};
3348
3322
  const batch = [];
3349
3323
  for (const file of files) {
@@ -3365,7 +3339,7 @@ function useUploadMedia() {
3365
3339
  };
3366
3340
  }
3367
3341
  const url = replace ? mediaUrl(replace) : `${MEDIA_URL_PREFIX}${uploadName(file.name, taken)}`;
3368
- taken.push(fileName(url));
3342
+ taken.push(fileName$1(url));
3369
3343
  batch.push({
3370
3344
  address: url,
3371
3345
  type: ready.type,
@@ -3377,7 +3351,7 @@ function useUploadMedia() {
3377
3351
  try {
3378
3352
  await client.upload(batch);
3379
3353
  } catch (error) {
3380
- const name = batch.map((file) => fileName(file.address)).join(", ");
3354
+ const name = batch.map((file) => fileName$1(file.address)).join(", ");
3381
3355
  return {
3382
3356
  ok: false,
3383
3357
  message: t("media.storage.failed", {
@@ -3395,12 +3369,20 @@ function useUploadMedia() {
3395
3369
  });
3396
3370
  const credits = replace ? withoutCredits(content.credits, [mediaUrl(replace)]) : void 0;
3397
3371
  if (credits) changes.push(credits);
3398
- const names = urls.map(fileName).join(", ");
3372
+ const names = urls.map(fileName$1).join(", ");
3399
3373
  return published(changes, replace ? t("media.replaceMessage", { name: names }) : t("media.uploadMessage", { names }), urls);
3400
3374
  };
3401
3375
  return async (files, replace) => {
3376
+ const different = replace && files.find((file) => sameExtension(file.name) !== sameExtension(replace));
3377
+ if (replace && different) return {
3378
+ ok: false,
3379
+ message: t("media.replaceKind", {
3380
+ name: different.name,
3381
+ target: fileName$1(replace)
3382
+ })
3383
+ };
3402
3384
  if (storage && client && (!replace || isStorableAddress(mediaUrl(replace)))) return toStorage(files, replace);
3403
- const taken = allMedia(content, media).map(fileName);
3385
+ const taken = allMedia(content, media).map(fileName$1);
3404
3386
  const changes = [];
3405
3387
  const uploads = [];
3406
3388
  for (const file of files) {
@@ -3414,7 +3396,7 @@ function useUploadMedia() {
3414
3396
  };
3415
3397
  }
3416
3398
  const path = replace ?? `${MEDIA_DIR}/${uploadName(file.name, taken)}`;
3417
- taken.push(fileName(path));
3399
+ taken.push(fileName$1(path));
3418
3400
  changes.push({
3419
3401
  path,
3420
3402
  bytes: prepared.bytes
@@ -3425,7 +3407,7 @@ function useUploadMedia() {
3425
3407
  });
3426
3408
  }
3427
3409
  for (const upload of uploads) mediaPreviews.remember(upload.url, upload.bytes);
3428
- const names = changes.map((change) => fileName(change.path)).join(", ");
3410
+ const names = changes.map((change) => fileName$1(change.path)).join(", ");
3429
3411
  const credits = replace ? withoutCredits(content.credits, [mediaUrl(replace)]) : void 0;
3430
3412
  const unstored = replace && storage && isStoredMedia(content, mediaUrl(replace)) ? mediaStorageFileChange(withStoredFiles(storage, {}, [mediaUrl(replace)])) : void 0;
3431
3413
  return published([
@@ -3441,7 +3423,7 @@ function DeleteMediaDialog({ url, onClose }) {
3441
3423
  const { content } = useSiteContent();
3442
3424
  const [busy, setBusy] = useState(false);
3443
3425
  const [failure, setFailure] = useState(null);
3444
- const name = fileName(url);
3426
+ const name = fileName$1(url);
3445
3427
  const used = mediaUsage(content, url, {
3446
3428
  header: t("layout.header"),
3447
3429
  footer: t("layout.footer"),
@@ -3611,7 +3593,7 @@ function MoveToStorageDialog({ client, paths, onClose }) {
3611
3593
  if (batch.length > 0) await client.upload(batch);
3612
3594
  Object.assign(added, sizes);
3613
3595
  } catch (error) {
3614
- const name = batch.map((file) => fileName(file.address)).join(", ");
3596
+ const name = batch.map((file) => fileName$1(file.address)).join(", ");
3615
3597
  failed = {
3616
3598
  ok: false,
3617
3599
  message: t("media.storage.failed", {
@@ -3706,7 +3688,7 @@ function MoveBackDialog({ plugin, onClose }) {
3706
3688
  const [problem, setProblem] = useState();
3707
3689
  const [failure, setFailure] = useState(null);
3708
3690
  const files = Object.entries(storage?.files ?? {});
3709
- const large = files.filter(([, file]) => file.size > MAX_UPLOAD_BYTES).map(([url]) => fileName(url));
3691
+ const large = files.filter(([, file]) => file.size > MAX_UPLOAD_BYTES).map(([url]) => fileName$1(url));
3710
3692
  const moveBack = async () => {
3711
3693
  if (!storage) return;
3712
3694
  setBusy(true);
@@ -3730,7 +3712,7 @@ function MoveBackDialog({ plugin, onClose }) {
3730
3712
  setBusy(false);
3731
3713
  return setProblem({
3732
3714
  message: t("media.back.failed", {
3733
- name: fileName(url),
3715
+ name: fileName$1(url),
3734
3716
  plugin
3735
3717
  }),
3736
3718
  error
@@ -3833,6 +3815,15 @@ function StorageSection({ onDone }) {
3833
3815
  ]
3834
3816
  });
3835
3817
  }
3818
+ /** A file's extension as names are saved: lowercase, with `.jpeg` as `.jpg`. */
3819
+ function sameExtension(name) {
3820
+ return extensionOf(name).replace(/^jpeg$/, "jpg");
3821
+ }
3822
+ /** What the file chooser offers when replacing a file: files of its kind. */
3823
+ function replacementAccept(url) {
3824
+ const extension = sameExtension(url);
3825
+ return extension === "jpg" ? ".jpg,.jpeg" : `.${extension}`;
3826
+ }
3836
3827
  /** Every uploaded image and file, with buttons to upload, replace and delete them. */
3837
3828
  /** Cloudflare's terms for its CDN, which limit serving video on its free plans. */
3838
3829
  const CLOUDFLARE_TERMS = "https://www.cloudflare.com/service-specific-terms-application-services/";
@@ -3859,7 +3850,7 @@ function MediaScreen() {
3859
3850
  };
3860
3851
  }, []);
3861
3852
  const cloudflareVideos = useOutsideHost() === "cloudflare-pages" && media.some((path) => mediaKind$1(mediaUrl(path)) === "video");
3862
- const urls = useMemo(() => media.map(mediaUrl).filter((url) => fileName(url).toLowerCase().includes(query.trim().toLowerCase())).sort((a, b) => fileName(a).localeCompare(fileName(b))), [media, query]);
3853
+ const urls = useMemo(() => media.map(mediaUrl).filter((url) => fileName$1(url).toLowerCase().includes(query.trim().toLowerCase())).sort((a, b) => fileName$1(a).localeCompare(fileName$1(b))), [media, query]);
3863
3854
  return /* @__PURE__ */ jsxs("div", {
3864
3855
  className: "gfa-screen gfa-screen-wide",
3865
3856
  children: [
@@ -3926,7 +3917,7 @@ function MediaScreen() {
3926
3917
  /* @__PURE__ */ jsx("span", {
3927
3918
  className: "gfa-media-name",
3928
3919
  title: url,
3929
- children: fileName(url)
3920
+ children: fileName$1(url)
3930
3921
  }),
3931
3922
  content.credits?.[url] && /* @__PURE__ */ jsx(MediaCredit, { credit: content.credits[url] }),
3932
3923
  isStoredMedia(content, url) && /* @__PURE__ */ jsx("span", {
@@ -3943,6 +3934,7 @@ function MediaScreen() {
3943
3934
  label: t("media.replace"),
3944
3935
  variant: "secondary",
3945
3936
  multiple: false,
3937
+ accept: replacementAccept(url),
3946
3938
  disabled: status.type === "uploading",
3947
3939
  onFiles: (files) => void run(files.slice(0, 1), mediaPath(url))
3948
3940
  }), /* @__PURE__ */ jsx(Button, {
@@ -3986,7 +3978,7 @@ function MediaPickerDialog({ kind, onSelect, onClose }) {
3986
3978
  const { content, media: own } = useSiteContent();
3987
3979
  const media = allMedia(content, own);
3988
3980
  const { status, run } = useUploads();
3989
- const urls = media.map(mediaUrl).filter((url) => kind === "file" || mediaKind$1(url) === kind).sort((a, b) => fileName(a).localeCompare(fileName(b)));
3981
+ const urls = media.map(mediaUrl).filter((url) => kind === "file" || mediaKind$1(url) === kind).sort((a, b) => fileName$1(a).localeCompare(fileName$1(b)));
3990
3982
  return /* @__PURE__ */ jsx(Dialog, {
3991
3983
  title: t(PICKER_TITLES[kind]),
3992
3984
  onClose,
@@ -4022,7 +4014,7 @@ function MediaPickerDialog({ kind, onSelect, onClose }) {
4022
4014
  },
4023
4015
  children: [/* @__PURE__ */ jsx(MediaThumb, { url }), /* @__PURE__ */ jsx("span", {
4024
4016
  className: "gfa-media-name",
4025
- children: fileName(url)
4017
+ children: fileName$1(url)
4026
4018
  })]
4027
4019
  }) }, url))
4028
4020
  }),
@@ -4182,8 +4174,13 @@ function pluginSecretsHelp(place, plugin, t) {
4182
4174
  }
4183
4175
  //#endregion
4184
4176
  //#region src/registry-version.ts
4185
- /** The release of Goodfellow's block registry this admin panel was built with, so a site gets blocks that match it. */
4186
- const REGISTRY_VERSION = "0.7.0";
4177
+ /**
4178
+ * The release of Goodfellow's block registry this admin panel was built with,
4179
+ * so a site gets blocks that match it, never whatever is newest. Every
4180
+ * Goodfellow package is released with the same version, so where a build
4181
+ * bundled the admin panel's source without saying, it's core's.
4182
+ */
4183
+ const REGISTRY_VERSION = "0.8.0";
4187
4184
  //#endregion
4188
4185
  //#region src/schedule.tsx
4189
4186
  /** The computer's own time zone, which a site without one takes when something is first scheduled. */
@@ -4460,6 +4457,15 @@ function subscribe(listener) {
4460
4457
  media.removeEventListener("change", listener);
4461
4458
  };
4462
4459
  }
4460
+ /**
4461
+ * Makes the admin panel dark or light, as its dark mode switch does. Choosing
4462
+ * what the computer's setting already gives follows that setting again, so
4463
+ * the admin panel changes with the computer, as it did before anyone chose.
4464
+ */
4465
+ function setDarkMode(dark) {
4466
+ const computer = matchMedia("(prefers-color-scheme: dark)").matches;
4467
+ setThemeChoice(dark === computer ? "system" : dark ? "dark" : "light");
4468
+ }
4463
4469
  /** The choice, kept in this browser. */
4464
4470
  function useThemeChoice() {
4465
4471
  return useSyncExternalStore(subscribe, choice, () => "system");
@@ -4542,7 +4548,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4542
4548
  const mediaPreviews = useMemo(() => createMediaPreviews(content, siteUrl, () => storage.current), [content, siteUrl]);
4543
4549
  const outOfReach = registry !== void 0 || atAdminAddress(site.admin) || site.broker !== void 0;
4544
4550
  const registries = useMemo(() => ({
4545
- [GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.7.0"),
4551
+ [GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.8.0"),
4546
4552
  ...outOfReach && {
4547
4553
  [SHADCN_NAMESPACE]: SHADCN_REGISTRY,
4548
4554
  ...site.registries
@@ -4575,6 +4581,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4575
4581
  const versions = isGitBackend(store) ? store : void 0;
4576
4582
  const changedPaths = useMemo(() => isGitBackend(store) ? (from, to) => store.changedPaths(from, to) : void 0, [store]);
4577
4583
  const editors = isGitBackend(store) ? store.editors : void 0;
4584
+ const comments = isGitBackend(store) ? store.comments : void 0;
4578
4585
  const updates = isGitBackend(store) ? store.updates : void 0;
4579
4586
  const contentOnly = isGitBackend(store) && store.contentOnly === true;
4580
4587
  const scheduledBuilds = isGitBackend(store) ? store.scheduledBuilds : void 0;
@@ -4686,12 +4693,22 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4686
4693
  ...submit && { submit },
4687
4694
  done
4688
4695
  })), []);
4689
- const publish = useCallback(async (changes, message, options) => {
4696
+ const publish = useCallback(async (given, message, options) => {
4690
4697
  if (state.status !== "ready") return {
4691
4698
  ok: false,
4692
4699
  reason: "error",
4693
4700
  error: /* @__PURE__ */ new Error("The site isn't loaded.")
4694
4701
  };
4702
+ let changes;
4703
+ try {
4704
+ changes = safeMediaChanges(given);
4705
+ } catch (error) {
4706
+ return {
4707
+ ok: false,
4708
+ reason: "error",
4709
+ error
4710
+ };
4711
+ }
4695
4712
  const outside = draft || options?.draft ? [] : outsideLimits(state.content, limits, changes);
4696
4713
  if (outside.length > 0) {
4697
4714
  const from = state.revision;
@@ -5034,6 +5051,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
5034
5051
  changedPaths,
5035
5052
  editors,
5036
5053
  ownerAccess,
5054
+ comments,
5037
5055
  updates,
5038
5056
  contentOnly,
5039
5057
  scheduledBuilds,
@@ -5067,6 +5085,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
5067
5085
  changedPaths,
5068
5086
  editors,
5069
5087
  ownerAccess,
5088
+ comments,
5070
5089
  updates,
5071
5090
  contentOnly,
5072
5091
  scheduledBuilds,
@@ -5129,6 +5148,7 @@ function useSiteCss(stylesheets) {
5129
5148
  function SiteFrames({ frames, state, onCodeClient, children }) {
5130
5149
  const t = useStrings();
5131
5150
  const strings = useStringTable();
5151
+ const { puck, labels } = useEditorText();
5132
5152
  const theme = useThemeChoice();
5133
5153
  const { site, preview, siteUrl, mediaPreviews, account, scheduledBuilds, repositoryVisibility, scheduleSyncProblem, readFile, readBytes, listFiles, publish, tasks } = useAdmin();
5134
5154
  const { timing, visibility } = useScheduleInfoOf(scheduledBuilds, repositoryVisibility);
@@ -5190,6 +5210,8 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
5190
5210
  siteUrl,
5191
5211
  preview,
5192
5212
  strings,
5213
+ puck,
5214
+ labels,
5193
5215
  theme,
5194
5216
  hostName: account?.hostName ?? ""
5195
5217
  },
@@ -5213,6 +5235,8 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
5213
5235
  siteUrl,
5214
5236
  preview,
5215
5237
  strings,
5238
+ puck,
5239
+ labels,
5216
5240
  theme,
5217
5241
  account,
5218
5242
  siteCss,
@@ -5411,4 +5435,4 @@ function formValues(collection, entry) {
5411
5435
  return values;
5412
5436
  }
5413
5437
  //#endregion
5414
- export { PublishedNotice as $, useRoute as A, pageFileChange as At, mayChange as B, editedMarkdown as Bt, AppLink as C, layoutFileChange as Ct, hasUnsavedChanges as D, movePageChanges as Dt, entryEditorHref as E, moveEntryChanges as Et, PluginScreenFrame as F, storedCss as Ft, useFrameServices as G, FrameServicesProvider as H, usePluginFrame as I, storedData as It, draftStarted as J, useFrameUpdates as K, usePluginHost as L, storedEntryFields as Lt, prepareUpload as M, redirectsFileChange as Mt, useOutsideHost as N, siteSettingsFileChange as Nt, navigate as O, newCollectionSettings as Ot, PublishFailure as P, slugify as Pt, Field as Q, createMediaPreviews as R, uniqueName as Rt, MediaScreen as S, entryFileChange$1 as St, collectionHref as T, menusFileChange as Tt, reloadFrames as U, useMayChange as V, formattedHtml as Vt, useFrame as W, Dialog as X, Button as Y, ErrorMessage as Z, scheduleSettingsChanges as _, collectionFileChange$1 as _t, AdminContext as a, useStrings as at, MediaChooser as b, deleteCollectionChanges as bt, useCanChangeSettings as c, allMedia as ct, useApplyThemeChoice as d, siteImages as dt, TextField as et, useDarkMode as f, uploadName as ft, ScheduleStatus as g, codeFileChanges as gt, ScheduleFields as h, checkPageAddress as ht, usePreviewStyles as i, useStringTable as it, useUnsavedChanges as j, pageTitle$1 as jt, pageEditorHref as k, newPageData as kt, useSiteContent as l, fileName as lt, ComingUp as m, checkEntrySlug as mt, formValues as n, StringsProvider as nt, AdminProvider as o, describeContrast as ot, useThemeChoice as p, addressPatternFor as pt, READY_MESSAGE as q, PreviewFrame as r, defaultStrings as rt, useAdmin as s, findPage as st, SCHEDULE_PROP as t, useDebounced as tt, setThemeChoice as u, mediaUrl as ut, REGISTRY_VERSION as v, collectionSettingsChanges as vt, SETTINGS_SECTIONS as w, mediaFileChange as wt, MediaField as x, duplicatePageData as xt, MediaChoiceContext as y, customCssFileChange as yt, showMediaInPreview as z, updateSettingsFileChange as zt };
5438
+ export { ErrorMessage as $, pageEditorHref as A, mayChange as B, MediaScreen as C, entryEditorHref as D, collectionHref as E, useOutsideHost as F, useFrameServices as G, FrameServicesProvider as H, PublishFailure as I, EditorTextContext as J, useFrameUpdates as K, PluginScreenFrame as L, useRoute as M, useUnsavedChanges as N, hasUnsavedChanges as O, prepareUpload as P, Dialog as Q, usePluginFrame as R, MediaField as S, SETTINGS_SECTIONS as T, reloadFrames as U, useMayChange as V, useFrame as W, draftStarted as X, useEditorText as Y, Button as Z, ScheduleStatus as _, AdminContext as a, defaultStrings as at, MediaChoiceContext as b, useCanChangeSettings as c, describeContrast as ct, setThemeChoice as d, Field as et, useApplyThemeChoice as f, ScheduleFields as g, ComingUp as h, usePreviewStyles as i, StringsProvider as it, sectionId as j, navigate as k, useSiteContent as l, findPage as lt, useThemeChoice as m, formValues as n, TextField as nt, AdminProvider as o, useStringTable as ot, useDarkMode as p, READY_MESSAGE as q, PreviewFrame as r, useDebounced as rt, useAdmin as s, useStrings as st, SCHEDULE_PROP as t, PublishedNotice as tt, setDarkMode as u, scheduleSettingsChanges as v, AppLink as w, MediaChooser as x, REGISTRY_VERSION as y, usePluginHost as z };