@goodfellow-cms/admin 0.6.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, 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";
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",
@@ -493,13 +302,24 @@ const defaultStrings = {
493
302
  "editor.failed": "The editor stopped working. Reload the page to start it again.",
494
303
  "editor.frame": "Editing {title}",
495
304
  "editor.choose": "Editor",
305
+ "editor.blockSearch": "Search blocks",
306
+ "editor.blockSearchNone": "No blocks match. Try another word, such as \"picture\" or \"columns\".",
307
+ "editor.blockSearchFound": "Blocks found",
496
308
  "editor.standard": "Standard editor",
309
+ "editor.plugin.switchLoses": "Some of what you changed in {editor} can't be kept in the other editor, so switching now leaves behind everything you changed since you opened {editor}. Switch anyway?\n\n{problems}",
310
+ "editor.plugin.pageSettings": "The page's settings",
311
+ "editor.plugin.problem": "{block}: {message}",
312
+ "editor.plugin.problemField": "{block}, {field}: {message}",
313
+ "editor.plugin.loading": "Opening {editor}…",
497
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.",
498
316
  "editor.plugin.invalid": "{editor} gave back something this site can't publish, so nothing was published. Try again, or choose the standard editor.",
499
317
  "publish.publishing": "Publishing…",
500
318
  "publish.done": "Published.",
501
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.",
502
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",
503
323
  "publish.invalid": "Fix the highlighted problems before publishing.",
504
324
  "draft.label": "Draft",
505
325
  "draft.add": "Add to draft",
@@ -522,6 +342,9 @@ const defaultStrings = {
522
342
  "draft.leave": "Back to the live site",
523
343
  "draft.waiting": "A draft started {date} is waiting to be published.",
524
344
  "draft.waitingMany": "{count} drafts are waiting to be published.",
345
+ "draft.reviewWaiting": "A draft is waiting for review: {title}.",
346
+ "draft.reviewWaitingMany": "{count} drafts are waiting for review.",
347
+ "draft.inReview": "Submitted for review.",
525
348
  "draft.choose": "Draft to open",
526
349
  "draft.started": "Started {date}",
527
350
  "draft.open": "Open",
@@ -541,8 +364,25 @@ const defaultStrings = {
541
364
  "checks.alsoWarnings": "These could be better too:",
542
365
  "checks.publishAnyway": "Publish anyway",
543
366
  "checks.goBack": "Go back",
367
+ "limits.title": "Some of this is outside what you can change",
368
+ "limits.intro": "An owner has limited what you can change on this site, and these changes are outside it:",
369
+ "limits.review": "Submit them for review instead: the site's reviewers are asked to look, and can publish them for you.",
370
+ "limits.noReview": "Ask an owner to make these changes for you, or to let you make them on Site Settings' Editors screen.",
371
+ "limits.goBack": "Go back",
372
+ "limits.submitting": "Submitting for review…",
373
+ "limits.submitted": "Submitted for review. The live site stays as it is until a reviewer publishes it.",
374
+ "limits.submitError": "The changes couldn't be submitted for review. Check your connection and try again.",
375
+ "limits.page": "The page {name}",
376
+ "limits.entry": "{name}, in {collection}",
377
+ "limits.media": "The media library",
378
+ "limits.layout": "The header and footer",
379
+ "limits.menus": "The menus",
380
+ "limits.other": "The site's settings, or other things only owners change",
381
+ "limits.readOnly": "You can look at this, but an owner has limited what you can change, and it's outside it.",
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.",
544
383
  "checks.missing-page": "{place} links to {target}, which isn't a page on the site.",
545
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.",
546
386
  "checks.no-title": "{place} has no title, so browser tabs and search results show only the site's name.",
547
387
  "checks.missing-translation": "{place} has no translation in {language} yet. Visitors reading in {language} are sent to its home page instead.",
548
388
  "checks.outdated-translation": "{place} has changed since its translation in {language} was made. Update the translation, then mark it as up to date.",
@@ -572,6 +412,7 @@ const defaultStrings = {
572
412
  "pages.empty": "This site has no pages yet.",
573
413
  "pages.column.title": "Title",
574
414
  "pages.column.address": "Address",
415
+ "pages.look": "Look",
575
416
  "pages.edit": "Edit",
576
417
  "pages.move": "Change address",
577
418
  "pages.duplicate": "Duplicate",
@@ -633,7 +474,7 @@ const defaultStrings = {
633
474
  "deletePage.submit": "Delete page",
634
475
  "deletePage.message": "Delete page \"{title}\"",
635
476
  "address.invalid": "Use lowercase letters, numbers and hyphens, with / between parts, such as /about-us.",
636
- "address.reserved": "This address is used by the admin panel. Choose another.",
477
+ "address.reserved": "This address can't be used: the admin panel uses it, or it ends in /index, which is the page above it. Choose another.",
637
478
  "address.taken": "Another page already uses this address.",
638
479
  "field.required": "This can't be empty.",
639
480
  "editPage.message": "Update page \"{title}\"",
@@ -711,7 +552,7 @@ const defaultStrings = {
711
552
  "newEntry.addressHint": "The last part of the page's address, as in {example}.",
712
553
  "newEntry.message": "Add {entry} \"{title}\"",
713
554
  "entryName.invalid": "Use lowercase letters, numbers and hyphens only, such as easter-vigil.",
714
- "entryName.reserved": "This address is used by the admin panel. Choose another.",
555
+ "entryName.reserved": "This address can't be used: the admin panel uses it, or it ends in /index, which is the page above it. Choose another.",
715
556
  "entryName.taken": "Something else on the site already uses this address.",
716
557
  "moveEntry.message": "Move {entry} \"{title}\" from {from} to {to}",
717
558
  "deleteEntry.body": "{title} will be removed from {name}.",
@@ -842,6 +683,11 @@ const defaultStrings = {
842
683
  "blocks.discard": "Discard changes",
843
684
  "blocks.publish": "Publish",
844
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",
845
691
  "blocks.addMessage": "Add the {name} block",
846
692
  "blocks.removeMessage": "Remove the {name} block",
847
693
  "blocks.changeMessage": "Change blocks: {changes}",
@@ -879,6 +725,7 @@ const defaultStrings = {
879
725
  "media.uploaded": "Uploaded. Visitors see new files once the live site is updated.",
880
726
  "media.uploadMessage": "Add {names}",
881
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.",
882
729
  "media.deleteMessage": "Delete {name}",
883
730
  "media.deleteTitle": "Delete {name}?",
884
731
  "media.deleteBody": "{name} will be removed from the site.",
@@ -904,6 +751,57 @@ const defaultStrings = {
904
751
  "table.removeColumn": "Remove this column",
905
752
  "table.removeColumnNumber": "Remove column {column}",
906
753
  "table.cell": "Row {row}, column {column}",
754
+ "style.align": "Text alignment",
755
+ "style.align.default": "As the block has it",
756
+ "style.align.left": "Left",
757
+ "style.align.center": "Centered",
758
+ "style.align.right": "Right",
759
+ "style.align.justify": "Justified",
760
+ "style.fontSize": "Text size",
761
+ "style.fontSize.xs": "Extra small",
762
+ "style.fontSize.sm": "Small",
763
+ "style.fontSize.base": "Normal",
764
+ "style.fontSize.lg": "Large",
765
+ "style.fontSize.xl": "Extra large",
766
+ "style.fontSize.2xl": "Very large",
767
+ "style.fontSize.3xl": "Huge",
768
+ "style.fontSize.4xl": "Very huge",
769
+ "style.fontSize.5xl": "Giant",
770
+ "style.fontSize.6xl": "Enormous",
771
+ "style.color": "Text color",
772
+ "style.background": "Background color",
773
+ "style.colorDefault": "As the block has it",
774
+ "style.colorCustom": "Choose a color",
775
+ "style.contrast": "This text may be hard to read on this background. Try colors further apart.",
776
+ "style.padding": "Space inside",
777
+ "style.margin": "Space around",
778
+ "style.eachSide": "Set each side",
779
+ "style.side.top": "Top",
780
+ "style.side.right": "Right",
781
+ "style.side.bottom": "Bottom",
782
+ "style.side.left": "Left",
783
+ "style.allSides": "All sides",
784
+ "style.none": "None",
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.",
792
+ "style.radius.none": "Square",
793
+ "style.radius.sm": "Slightly rounded",
794
+ "style.radius.md": "Rounded",
795
+ "style.radius.lg": "The theme's rounding",
796
+ "style.radius.xl": "Very rounded",
797
+ "style.radius.full": "Round",
798
+ "style.default": "As the block has it",
799
+ "style.custom": "Your own…",
800
+ "style.customLength": "Your own size, such as 12px or 1.5rem",
801
+ "style.customLengthInvalid": "Write a number with px, rem, em or %, such as 12px.",
802
+ "style.other": "Other CSS classes",
803
+ "style.otherHint": "Tailwind classes, or your own from Custom CSS, separated by spaces.",
804
+ "style.reset": "Back to the block's own look",
907
805
  "media.credit": "Credit: {credit}",
908
806
  "media.stored": "Kept by {plugin}",
909
807
  "media.error.storageSize": "{name} is too large: {plugin} takes files up to {size}.",
@@ -995,10 +893,8 @@ const defaultStrings = {
995
893
  "ai.error.answer": "The answer couldn't be used. Try again, or try a different model or service.",
996
894
  "ai.error.service": "{service} reported a problem. Try again in a moment.",
997
895
  "settings.title": "Site Settings",
998
- "appearance.choice": "Colors of the admin panel",
999
- "appearance.system": "Match my computer",
1000
- "appearance.light": "Light",
1001
- "appearance.dark": "Dark",
896
+ "appearance.darkMode": "Dark mode",
897
+ "adminLanguage.label": "Language of the admin panel",
1002
898
  "settings.tab.general": "General",
1003
899
  "settings.tab.languages": "Languages",
1004
900
  "settings.tab.theme": "Colors & fonts",
@@ -1014,6 +910,7 @@ const defaultStrings = {
1014
910
  "settings.previewEmpty": "Add a home page to see a preview here.",
1015
911
  "domain.intro": "Connect a domain you own, such as example.org, so the site is at that address.",
1016
912
  "domain.current": "The site is at {address} now.",
913
+ "domain.copy": "This admin panel is a copy of the site's, such as a staging site, at {address}. That address is set where the copy is built, so the domain this screen connects is the main site's.",
1017
914
  "domain.connected": "Connected to {domain}.",
1018
915
  "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.",
1019
916
  "domain.local": "Domains are connected in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
@@ -1113,6 +1010,7 @@ const defaultStrings = {
1113
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.",
1114
1011
  "security.broker.title": "Sign-in page",
1115
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.",
1116
1014
  "security.broker.useTitle": "Sign in at a sign-in page that's online",
1117
1015
  "security.add.title": "Add a token broker",
1118
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.",
@@ -1185,6 +1083,74 @@ const defaultStrings = {
1185
1083
  "settings.tab.editors": "Editors",
1186
1084
  "settings.tab.updates": "Updates",
1187
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",
1188
1154
  "settings.tab.import": "WordPress",
1189
1155
  "settings.tab.themes": "Themes",
1190
1156
  "plugins.notFound": "The site has no plugin screen at this address.",
@@ -1540,7 +1506,7 @@ const defaultStrings = {
1540
1506
  "updates.error.save": "The change couldn't be saved. Try again in a moment.",
1541
1507
  "updates.error.other": "Something went wrong. Try again in a moment.",
1542
1508
  "editors.intro": "The people who can edit this site.",
1543
- "editors.roles": "Editors can change and publish everything in the admin panel. Owners can also invite and remove editors, and connect a domain.",
1509
+ "editors.roles": "Editors can change and publish everything in the admin panel, unless their limits below say otherwise. Owners can also invite and remove editors, limit what they can change, and connect a domain.",
1544
1510
  "editors.local": "Editors are invited in the published site's admin panel, where you sign in with GitHub, GitLab or Bitbucket.",
1545
1511
  "editors.demo": "Editors can't be invited in a demo.",
1546
1512
  "editors.loading": "Loading editors…",
@@ -1576,6 +1542,33 @@ const defaultStrings = {
1576
1542
  "editors.error.invalid": "That isn't a username or email address that {host} accepts.",
1577
1543
  "editors.error.already": "They already edit the site, or have been invited.",
1578
1544
  "editors.error.other": "Something went wrong. Try again in a moment.",
1545
+ "permissions.title": "Limits",
1546
+ "permissions.intro": "Limit what someone can change: some pages, some collections, the media library, the header and footer, or the menus. Anyone not listed here can change everything their role allows. Changes outside someone's limits go to a reviewer, who publishes them or not.",
1547
+ "permissions.where": "The admin panel keeps people to their limits, so nobody changes the wrong page by mistake. People who can publish to the site on {host} could still change other files there, so only a site with its own server holds everyone to them.",
1548
+ "permissions.whereServer": "The site's server holds everyone to their limits, whatever they try.",
1549
+ "permissions.host": "the site's host",
1550
+ "permissions.person": "Their username",
1551
+ "permissions.personHint": "The name they sign in with, as the list above shows it.",
1552
+ "permissions.add": "Limit what they can change",
1553
+ "permissions.change": "Change",
1554
+ "permissions.remove": "Remove limits",
1555
+ "permissions.nothing": "Nothing yet",
1556
+ "permissions.allPages": "Every page",
1557
+ "permissions.editTitle": "What {name} can change",
1558
+ "permissions.editIntro": "Tick what they can change. They can look at everything else, and submit changes to it for review.",
1559
+ "permissions.pages": "Pages",
1560
+ "permissions.pagesHint": "Each with the pages under it, and their translations.",
1561
+ "permissions.collections": "Collections' items",
1562
+ "permissions.more": "Also",
1563
+ "permissions.languages": "Languages",
1564
+ "permissions.everyLanguage": "Every language",
1565
+ "permissions.save": "Publish limits",
1566
+ "permissions.message": "Change what {name} can change",
1567
+ "permissions.removeMessage": "Let {name} change everything",
1568
+ "permissions.reviewers": "Who reviews changes outside someone's limits",
1569
+ "permissions.reviewersHint": "Usernames, separated by commas. Leave it empty for every owner.",
1570
+ "permissions.saveReviewers": "Publish reviewers",
1571
+ "permissions.reviewersMessage": "Change who reviews changes outside editors' limits",
1579
1572
  "general.siteTitle": "Site name",
1580
1573
  "general.description": "Description",
1581
1574
  "general.descriptionHint": "Shown by search engines when a page doesn't have its own description.",
@@ -1628,6 +1621,25 @@ const defaultStrings = {
1628
1621
  "forms.accessKeyLink": "Get an access key from Web3Forms",
1629
1622
  "forms.captcha": "Ask visitors to show they aren't robots (hCaptcha)",
1630
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.",
1631
1643
  "forms.formspree": "Formspree form address",
1632
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.",
1633
1645
  "forms.address": "Address forms send answers to",
@@ -1746,13 +1758,23 @@ const defaultStrings = {
1746
1758
  "css.label": "Custom CSS",
1747
1759
  "code.editorHint": "Tab indents, and suggestions appear as you type. Press Escape, then Tab, to leave the editor. Ctrl+F finds and replaces.",
1748
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.",
1749
1763
  "code.head": "In the page head",
1750
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",
1751
1772
  "code.body": "At the end of the page",
1752
1773
  "code.bodyHint": "Any HTML, such as a chat widget, added just before the end of every page.",
1753
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.",
1754
1775
  "code.error.tag": "A <{tag}> tag can't go in the page head. Put it in \"At the end of the page\" instead.",
1755
- "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."
1756
1778
  };
1757
1779
  const StringsContext = createContext(defaultStrings);
1758
1780
  function StringsProvider({ strings, children }) {
@@ -2014,6 +2036,16 @@ function draftStarted(name) {
2014
2036
  });
2015
2037
  }
2016
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
2017
2049
  //#region src/frame/protocol.ts
2018
2050
  /** A frame sends this to the admin panel's page once it's listening, and the page answers with a port. */
2019
2051
  const READY_MESSAGE = "goodfellow:frame-ready";
@@ -2235,140 +2267,131 @@ function SiteCodeFrame({ onClient }) {
2235
2267
  });
2236
2268
  }
2237
2269
  //#endregion
2238
- //#region src/media-previews.ts
2239
- const TYPES = {
2240
- jpg: "image/jpeg",
2241
- jpeg: "image/jpeg",
2242
- png: "image/png",
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"
2254
- };
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";
2258
- }
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;
2270
+ //#region src/limits-dialog.tsx
2271
+ /** What a change outside someone's limits touches, in words. Files that are none of those are named once, together. */
2272
+ function describeLimitProblems(t, content, problems) {
2273
+ const described = /* @__PURE__ */ new Set();
2274
+ const pages = allPages(content);
2275
+ for (const problem of problems) if (problem.kind === "page") {
2276
+ const title = (pages.find((found) => found.path === problem.address)?.content.data.root.props)?.title;
2277
+ described.add(t("limits.page", { name: typeof title === "string" && title ? title : problem.address }));
2278
+ } else if (problem.kind === "entry") {
2279
+ const collection = content.collections.find((found) => found.id === problem.collection);
2280
+ const title = (collection?.entries.find((found) => found.slug === problem.slug))?.content.fields.title;
2281
+ described.add(t("limits.entry", {
2282
+ name: typeof title === "string" && title ? title : problem.slug,
2283
+ collection: collection?.settings.name ?? problem.collection
2284
+ }));
2285
+ } else described.add(t(`limits.${problem.kind}`));
2286
+ return [...described];
2287
+ }
2288
+ /**
2289
+ * Changes outside what someone may change: listed, with what they can do
2290
+ * instead, submitting them for review where the site can take one.
2291
+ */
2292
+ function LimitsDialog({ problems, content, onSubmit, onClose }) {
2293
+ const t = useStrings();
2294
+ const [state, setState] = useState({ type: "asking" });
2295
+ const submit = async () => {
2296
+ if (!onSubmit) return;
2297
+ setState({ type: "submitting" });
2298
+ try {
2299
+ setState({
2300
+ type: "submitted",
2301
+ ...await onSubmit()
2302
+ });
2303
+ } catch (error) {
2304
+ setState({
2305
+ type: "error",
2306
+ error
2307
+ });
2306
2308
  }
2307
2309
  };
2310
+ return /* @__PURE__ */ jsxs(Dialog, {
2311
+ title: t("limits.title"),
2312
+ onClose,
2313
+ children: [
2314
+ /* @__PURE__ */ jsx("p", { children: t("limits.intro") }),
2315
+ /* @__PURE__ */ jsx("ul", {
2316
+ className: "gfa-check-list",
2317
+ children: describeLimitProblems(t, content, problems).map((line) => /* @__PURE__ */ jsx("li", { children: line }, line))
2318
+ }),
2319
+ state.type === "submitted" ? /* @__PURE__ */ jsxs("p", {
2320
+ role: "status",
2321
+ children: [
2322
+ t("limits.submitted"),
2323
+ " ",
2324
+ state.url && /* @__PURE__ */ jsx("a", {
2325
+ href: state.url,
2326
+ target: "_blank",
2327
+ rel: "noreferrer",
2328
+ children: t("draft.seeReview")
2329
+ })
2330
+ ]
2331
+ }) : /* @__PURE__ */ jsx("p", { children: t(onSubmit ? "limits.review" : "limits.noReview") }),
2332
+ state.type === "error" && /* @__PURE__ */ jsx(ErrorMessage, {
2333
+ message: t("limits.submitError"),
2334
+ error: state.error
2335
+ }),
2336
+ /* @__PURE__ */ jsxs("div", {
2337
+ className: "gfa-dialog-actions",
2338
+ children: [/* @__PURE__ */ jsx(Button, {
2339
+ onClick: onClose,
2340
+ children: t(state.type === "submitted" ? "action.close" : "limits.goBack")
2341
+ }), onSubmit && state.type !== "submitted" && /* @__PURE__ */ jsx(Button, {
2342
+ variant: "primary",
2343
+ disabled: state.type === "submitting",
2344
+ onClick: () => void submit(),
2345
+ children: t(state.type === "submitting" ? "limits.submitting" : "draft.submit")
2346
+ })]
2347
+ })
2348
+ ]
2349
+ });
2308
2350
  }
2309
- const URL_ATTRIBUTES = [
2310
- "src",
2311
- "href",
2312
- "poster"
2313
- ];
2314
- const MEDIA_IN_STYLE = /url\((['"]?)(\/media\/[^'")]+)\1\)/g;
2351
+ //#endregion
2352
+ //#region src/limits.ts
2315
2353
  /**
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.
2354
+ * What the signed-in person may change, for screens that show the rest
2355
+ * read-only. Publishing checks every change again (`outsideLimits()`), so
2356
+ * these only spare people changes they couldn't publish.
2319
2357
  */
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);
2358
+ /** Whether someone with `limits` may change what's open. Collections' templates are their settings, which limits never cover. */
2359
+ function mayChange(content, limits, what) {
2360
+ if (!limits) return true;
2361
+ switch (what.kind) {
2362
+ case "page": {
2363
+ const page = content.pages.find((found) => found.path === what.path);
2364
+ return mayChangePage(content, limits, page ?? { path: what.path });
2335
2365
  }
2336
- };
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);
2366
+ case "header":
2367
+ case "footer": return mayChangeLayout(content, limits, what.language);
2368
+ case "template": return false;
2369
+ case "entry": {
2370
+ const entry = content.collections.find((found) => found.id === what.collection)?.entries.find((found) => found.slug === what.entry);
2371
+ return entry ? mayChangeEntry(content, limits, entry) : (limits.collections ?? []).includes(what.collection);
2345
2372
  }
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
- };
2373
+ case "media": return mayChangeMedia(limits);
2374
+ case "menus": return mayChangeMenus(content, limits, what.language);
2375
+ }
2376
+ }
2377
+ /** Whether the signed-in person may change what's open, once the site has loaded. */
2378
+ function useMayChange(what) {
2379
+ const { state, limits } = useAdmin();
2380
+ return state.status !== "ready" || mayChange(state.content, limits, what);
2381
+ }
2382
+ //#endregion
2383
+ //#region src/plugin-server.ts
2384
+ /**
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.
2388
+ */
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;
2372
2395
  }
2373
2396
  //#endregion
2374
2397
  //#region src/plugin-frames.tsx
@@ -2455,10 +2478,11 @@ function pluginHandlers(plugin, host, onResize, editor) {
2455
2478
  const base = host().server;
2456
2479
  if (!plugin.server || !base) throw new RefusedError(`${plugin.title} has no server here.`);
2457
2480
  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.");
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.");
2459
2483
  const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
2460
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)));
2461
- const response = await fetch(`${base.replace(/\/+$/, "")}/${encodeURIComponent(plugin.id)}/${path.replace(/^\/+/, "")}`, {
2485
+ const response = await fetch(address, {
2462
2486
  method,
2463
2487
  headers: {
2464
2488
  ...headers,
@@ -2519,6 +2543,11 @@ function pluginHandlers(plugin, host, onResize, editor) {
2519
2543
  return hooks.draw(block);
2520
2544
  },
2521
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
+ },
2522
2551
  task: async (params) => {
2523
2552
  const id = params?.id;
2524
2553
  if (typeof id !== "string" || !plugin.tasks?.some((task) => task.id === id)) throw new RefusedError(`${plugin.title} has no task called ${String(id)}.`);
@@ -2559,10 +2588,13 @@ function usePluginFrame(plugin, host, mode, screen, editor) {
2559
2588
  let current;
2560
2589
  let connections = 0;
2561
2590
  let stopped = false;
2591
+ let stopRemembering;
2562
2592
  const onMessage = (event) => {
2563
2593
  const frame = element.current?.contentWindow;
2564
2594
  if (!frame || event.source !== frame || event.data !== "goodfellow:plugin-ready") return;
2565
2595
  current?.close();
2596
+ stopRemembering?.();
2597
+ stopRemembering = void 0;
2566
2598
  setChannel(void 0);
2567
2599
  connections += 1;
2568
2600
  if (connections > MAX_CONNECTIONS) {
@@ -2584,6 +2616,8 @@ function usePluginFrame(plugin, host, mode, screen, editor) {
2584
2616
  siteUrl: latest.current.siteUrl,
2585
2617
  theme: latest.current.theme
2586
2618
  };
2619
+ const storage = mode === "editor" ? latestEditor.current?.mediaStorage() : void 0;
2620
+ if (storage) init.mediaStorage = storage;
2587
2621
  next.call("init", init).then((opened) => {
2588
2622
  if (stopped || current !== next) return;
2589
2623
  setChannel(next);
@@ -2593,6 +2627,12 @@ function usePluginFrame(plugin, host, mode, screen, editor) {
2593
2627
  set: said.set === true,
2594
2628
  undo: said.undo === true
2595
2629
  }, next);
2630
+ stopRemembering = latestEditor.current?.onRemember((url, bytes) => {
2631
+ next.call("media.remember", {
2632
+ url,
2633
+ bytes
2634
+ }).catch(() => {});
2635
+ });
2596
2636
  }
2597
2637
  }, () => {
2598
2638
  if (!stopped && current === next) setFailed(true);
@@ -2602,6 +2642,7 @@ function usePluginFrame(plugin, host, mode, screen, editor) {
2602
2642
  return () => {
2603
2643
  stopped = true;
2604
2644
  window.removeEventListener("message", onMessage);
2645
+ stopRemembering?.();
2605
2646
  current?.close();
2606
2647
  };
2607
2648
  }, [
@@ -2838,28 +2879,147 @@ async function resized(file, extension) {
2838
2879
  const blob = await canvasBlob(canvas, type);
2839
2880
  return blob && (scale < 1 || blob.size < file.size) ? blob : void 0;
2840
2881
  }
2841
- const UNSAFE_SVG_ELEMENTS = [
2842
- "script",
2843
- "foreignObject",
2844
- "iframe",
2845
- "embed",
2846
- "object",
2847
- "audio",
2848
- "video"
2849
- ];
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+.-]*:)(?!\/\/))/;
2850
2959
  /**
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.
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.
2854
3016
  */
2855
3017
  function cleanSvg(text) {
2856
3018
  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
- }
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);
2863
3023
  return new XMLSerializer().serializeToString(doc);
2864
3024
  }
2865
3025
  /** A file checked and made ready, as a `Blob`: photos made web-sized, SVGs made safe, other files as they are. */
@@ -2913,6 +3073,29 @@ async function prepareStoredUpload(file, maxBytes) {
2913
3073
  ...await imageSize(blob, file.name)
2914
3074
  };
2915
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
+ }
2916
3099
  //#endregion
2917
3100
  //#region src/router.tsx
2918
3101
  function parseHash(hash) {
@@ -2961,8 +3144,13 @@ function navigate(to, confirmLeave) {
2961
3144
  /** Sections of Site Settings' General tab that links can scroll to, as `#/settings/general?section=forms`. */
2962
3145
  const SETTINGS_SECTIONS = {
2963
3146
  contact: "contact",
2964
- forms: "forms"
3147
+ forms: "forms",
3148
+ consent: "consent"
2965
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
+ }
2966
3154
  function pageEditorHref(path) {
2967
3155
  return `#/pages/edit?path=${encodeURIComponent(path)}`;
2968
3156
  }
@@ -3129,7 +3317,7 @@ function useUploadMedia() {
3129
3317
  if (problem) return problem;
3130
3318
  const { plugin } = client;
3131
3319
  const maxBytes = plugin.mediaStorage?.maxBytes ?? MAX_UPLOAD_BYTES;
3132
- const taken = allMedia(content, media).map(fileName);
3320
+ const taken = allMedia(content, media).map(fileName$1);
3133
3321
  const added = {};
3134
3322
  const batch = [];
3135
3323
  for (const file of files) {
@@ -3151,7 +3339,7 @@ function useUploadMedia() {
3151
3339
  };
3152
3340
  }
3153
3341
  const url = replace ? mediaUrl(replace) : `${MEDIA_URL_PREFIX}${uploadName(file.name, taken)}`;
3154
- taken.push(fileName(url));
3342
+ taken.push(fileName$1(url));
3155
3343
  batch.push({
3156
3344
  address: url,
3157
3345
  type: ready.type,
@@ -3163,7 +3351,7 @@ function useUploadMedia() {
3163
3351
  try {
3164
3352
  await client.upload(batch);
3165
3353
  } catch (error) {
3166
- const name = batch.map((file) => fileName(file.address)).join(", ");
3354
+ const name = batch.map((file) => fileName$1(file.address)).join(", ");
3167
3355
  return {
3168
3356
  ok: false,
3169
3357
  message: t("media.storage.failed", {
@@ -3181,12 +3369,20 @@ function useUploadMedia() {
3181
3369
  });
3182
3370
  const credits = replace ? withoutCredits(content.credits, [mediaUrl(replace)]) : void 0;
3183
3371
  if (credits) changes.push(credits);
3184
- const names = urls.map(fileName).join(", ");
3372
+ const names = urls.map(fileName$1).join(", ");
3185
3373
  return published(changes, replace ? t("media.replaceMessage", { name: names }) : t("media.uploadMessage", { names }), urls);
3186
3374
  };
3187
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
+ };
3188
3384
  if (storage && client && (!replace || isStorableAddress(mediaUrl(replace)))) return toStorage(files, replace);
3189
- const taken = allMedia(content, media).map(fileName);
3385
+ const taken = allMedia(content, media).map(fileName$1);
3190
3386
  const changes = [];
3191
3387
  const uploads = [];
3192
3388
  for (const file of files) {
@@ -3200,7 +3396,7 @@ function useUploadMedia() {
3200
3396
  };
3201
3397
  }
3202
3398
  const path = replace ?? `${MEDIA_DIR}/${uploadName(file.name, taken)}`;
3203
- taken.push(fileName(path));
3399
+ taken.push(fileName$1(path));
3204
3400
  changes.push({
3205
3401
  path,
3206
3402
  bytes: prepared.bytes
@@ -3211,7 +3407,7 @@ function useUploadMedia() {
3211
3407
  });
3212
3408
  }
3213
3409
  for (const upload of uploads) mediaPreviews.remember(upload.url, upload.bytes);
3214
- const names = changes.map((change) => fileName(change.path)).join(", ");
3410
+ const names = changes.map((change) => fileName$1(change.path)).join(", ");
3215
3411
  const credits = replace ? withoutCredits(content.credits, [mediaUrl(replace)]) : void 0;
3216
3412
  const unstored = replace && storage && isStoredMedia(content, mediaUrl(replace)) ? mediaStorageFileChange(withStoredFiles(storage, {}, [mediaUrl(replace)])) : void 0;
3217
3413
  return published([
@@ -3227,7 +3423,7 @@ function DeleteMediaDialog({ url, onClose }) {
3227
3423
  const { content } = useSiteContent();
3228
3424
  const [busy, setBusy] = useState(false);
3229
3425
  const [failure, setFailure] = useState(null);
3230
- const name = fileName(url);
3426
+ const name = fileName$1(url);
3231
3427
  const used = mediaUsage(content, url, {
3232
3428
  header: t("layout.header"),
3233
3429
  footer: t("layout.footer"),
@@ -3397,7 +3593,7 @@ function MoveToStorageDialog({ client, paths, onClose }) {
3397
3593
  if (batch.length > 0) await client.upload(batch);
3398
3594
  Object.assign(added, sizes);
3399
3595
  } catch (error) {
3400
- const name = batch.map((file) => fileName(file.address)).join(", ");
3596
+ const name = batch.map((file) => fileName$1(file.address)).join(", ");
3401
3597
  failed = {
3402
3598
  ok: false,
3403
3599
  message: t("media.storage.failed", {
@@ -3492,7 +3688,7 @@ function MoveBackDialog({ plugin, onClose }) {
3492
3688
  const [problem, setProblem] = useState();
3493
3689
  const [failure, setFailure] = useState(null);
3494
3690
  const files = Object.entries(storage?.files ?? {});
3495
- 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));
3496
3692
  const moveBack = async () => {
3497
3693
  if (!storage) return;
3498
3694
  setBusy(true);
@@ -3516,7 +3712,7 @@ function MoveBackDialog({ plugin, onClose }) {
3516
3712
  setBusy(false);
3517
3713
  return setProblem({
3518
3714
  message: t("media.back.failed", {
3519
- name: fileName(url),
3715
+ name: fileName$1(url),
3520
3716
  plugin
3521
3717
  }),
3522
3718
  error
@@ -3619,6 +3815,15 @@ function StorageSection({ onDone }) {
3619
3815
  ]
3620
3816
  });
3621
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
+ }
3622
3827
  /** Every uploaded image and file, with buttons to upload, replace and delete them. */
3623
3828
  /** Cloudflare's terms for its CDN, which limit serving video on its free plans. */
3624
3829
  const CLOUDFLARE_TERMS = "https://www.cloudflare.com/service-specific-terms-application-services/";
@@ -3632,6 +3837,7 @@ function MediaScreen() {
3632
3837
  const [copied, setCopied] = useState(null);
3633
3838
  const [moved, setMoved] = useState();
3634
3839
  const searchId = useId();
3840
+ const mayChange = useMayChange({ kind: "media" });
3635
3841
  useEffect(() => {
3636
3842
  const stop = (event) => {
3637
3843
  if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
@@ -3644,13 +3850,13 @@ function MediaScreen() {
3644
3850
  };
3645
3851
  }, []);
3646
3852
  const cloudflareVideos = useOutsideHost() === "cloudflare-pages" && media.some((path) => mediaKind$1(mediaUrl(path)) === "video");
3647
- 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]);
3648
3854
  return /* @__PURE__ */ jsxs("div", {
3649
3855
  className: "gfa-screen gfa-screen-wide",
3650
3856
  children: [
3651
3857
  /* @__PURE__ */ jsxs("div", {
3652
3858
  className: "gfa-screen-header",
3653
- children: [/* @__PURE__ */ jsx("h1", { children: t("media.title") }), /* @__PURE__ */ jsx(UploadButton, {
3859
+ children: [/* @__PURE__ */ jsx("h1", { children: t("media.title") }), mayChange && /* @__PURE__ */ jsx(UploadButton, {
3654
3860
  label: t("media.upload"),
3655
3861
  disabled: status.type === "uploading",
3656
3862
  onFiles: (files) => void run(files)
@@ -3660,6 +3866,11 @@ function MediaScreen() {
3660
3866
  className: "gfa-hint",
3661
3867
  children: t("media.intro")
3662
3868
  }),
3869
+ !mayChange && /* @__PURE__ */ jsx("p", {
3870
+ className: "gfa-notice",
3871
+ role: "note",
3872
+ children: t("limits.mediaReadOnly")
3873
+ }),
3663
3874
  cloudflareVideos && /* @__PURE__ */ jsxs("p", {
3664
3875
  className: "gfa-notice",
3665
3876
  role: "note",
@@ -3674,7 +3885,7 @@ function MediaScreen() {
3674
3885
  })
3675
3886
  ]
3676
3887
  }),
3677
- /* @__PURE__ */ jsx(StorageSection, { onDone: setMoved }),
3888
+ mayChange && /* @__PURE__ */ jsx(StorageSection, { onDone: setMoved }),
3678
3889
  moved && /* @__PURE__ */ jsx("p", {
3679
3890
  className: "gfa-notice gfa-notice-success",
3680
3891
  role: "status",
@@ -3696,7 +3907,7 @@ function MediaScreen() {
3696
3907
  })]
3697
3908
  }),
3698
3909
  /* @__PURE__ */ jsx(DropZone, {
3699
- onFiles: (files) => void run(files),
3910
+ onFiles: (files) => void (mayChange && run(files)),
3700
3911
  children: media.length === 0 ? /* @__PURE__ */ jsx("p", { children: t("media.empty") }) : urls.length === 0 ? /* @__PURE__ */ jsx("p", { children: t("media.noMatches") }) : /* @__PURE__ */ jsx("ul", {
3701
3912
  className: "gfa-media-grid",
3702
3913
  children: urls.map((url) => /* @__PURE__ */ jsxs("li", {
@@ -3706,7 +3917,7 @@ function MediaScreen() {
3706
3917
  /* @__PURE__ */ jsx("span", {
3707
3918
  className: "gfa-media-name",
3708
3919
  title: url,
3709
- children: fileName(url)
3920
+ children: fileName$1(url)
3710
3921
  }),
3711
3922
  content.credits?.[url] && /* @__PURE__ */ jsx(MediaCredit, { credit: content.credits[url] }),
3712
3923
  isStoredMedia(content, url) && /* @__PURE__ */ jsx("span", {
@@ -3715,25 +3926,22 @@ function MediaScreen() {
3715
3926
  }),
3716
3927
  /* @__PURE__ */ jsxs("div", {
3717
3928
  className: "gfa-media-actions",
3718
- children: [
3719
- /* @__PURE__ */ jsx(Button, {
3720
- variant: "ghost",
3721
- onClick: () => void navigator.clipboard?.writeText(url).then(() => setCopied(url), () => setCopied(null)),
3722
- children: copied === url ? t("media.copied") : t("media.copy")
3723
- }),
3724
- /* @__PURE__ */ jsx(UploadButton, {
3725
- label: t("media.replace"),
3726
- variant: "secondary",
3727
- multiple: false,
3728
- disabled: status.type === "uploading",
3729
- onFiles: (files) => void run(files.slice(0, 1), mediaPath(url))
3730
- }),
3731
- /* @__PURE__ */ jsx(Button, {
3732
- variant: "ghost",
3733
- onClick: () => setDeleting(url),
3734
- children: t("media.delete")
3735
- })
3736
- ]
3929
+ children: [/* @__PURE__ */ jsx(Button, {
3930
+ variant: "ghost",
3931
+ onClick: () => void navigator.clipboard?.writeText(url).then(() => setCopied(url), () => setCopied(null)),
3932
+ children: copied === url ? t("media.copied") : t("media.copy")
3933
+ }), mayChange && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(UploadButton, {
3934
+ label: t("media.replace"),
3935
+ variant: "secondary",
3936
+ multiple: false,
3937
+ accept: replacementAccept(url),
3938
+ disabled: status.type === "uploading",
3939
+ onFiles: (files) => void run(files.slice(0, 1), mediaPath(url))
3940
+ }), /* @__PURE__ */ jsx(Button, {
3941
+ variant: "ghost",
3942
+ onClick: () => setDeleting(url),
3943
+ children: t("media.delete")
3944
+ })] })]
3737
3945
  })
3738
3946
  ]
3739
3947
  }, url))
@@ -3770,7 +3978,7 @@ function MediaPickerDialog({ kind, onSelect, onClose }) {
3770
3978
  const { content, media: own } = useSiteContent();
3771
3979
  const media = allMedia(content, own);
3772
3980
  const { status, run } = useUploads();
3773
- 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)));
3774
3982
  return /* @__PURE__ */ jsx(Dialog, {
3775
3983
  title: t(PICKER_TITLES[kind]),
3776
3984
  onClose,
@@ -3806,7 +4014,7 @@ function MediaPickerDialog({ kind, onSelect, onClose }) {
3806
4014
  },
3807
4015
  children: [/* @__PURE__ */ jsx(MediaThumb, { url }), /* @__PURE__ */ jsx("span", {
3808
4016
  className: "gfa-media-name",
3809
- children: fileName(url)
4017
+ children: fileName$1(url)
3810
4018
  })]
3811
4019
  }) }, url))
3812
4020
  }),
@@ -3966,8 +4174,13 @@ function pluginSecretsHelp(place, plugin, t) {
3966
4174
  }
3967
4175
  //#endregion
3968
4176
  //#region src/registry-version.ts
3969
- /** The release of Goodfellow's block registry this admin panel was built with, so a site gets blocks that match it. */
3970
- const REGISTRY_VERSION = "0.6.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";
3971
4184
  //#endregion
3972
4185
  //#region src/schedule.tsx
3973
4186
  /** The computer's own time zone, which a site without one takes when something is first scheduled. */
@@ -4244,6 +4457,15 @@ function subscribe(listener) {
4244
4457
  media.removeEventListener("change", listener);
4245
4458
  };
4246
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
+ }
4247
4469
  /** The choice, kept in this browser. */
4248
4470
  function useThemeChoice() {
4249
4471
  return useSyncExternalStore(subscribe, choice, () => "system");
@@ -4318,13 +4540,15 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4318
4540
  const [draftPaths, setDraftPaths] = useState([]);
4319
4541
  const content = draft?.store ?? store;
4320
4542
  const [checking, setChecking] = useState();
4543
+ const [limiting, setLimiting] = useState();
4544
+ const [draftReviews, setDraftReviews] = useState([]);
4321
4545
  const blocks = useMemo(() => describedConfig(site.description, "template").components, [site.description]);
4322
4546
  const storage = useRef(void 0);
4323
4547
  storage.current = state.status === "ready" ? state.content.mediaStorage : void 0;
4324
4548
  const mediaPreviews = useMemo(() => createMediaPreviews(content, siteUrl, () => storage.current), [content, siteUrl]);
4325
4549
  const outOfReach = registry !== void 0 || atAdminAddress(site.admin) || site.broker !== void 0;
4326
4550
  const registries = useMemo(() => ({
4327
- [GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.6.0"),
4551
+ [GOODFELLOW_REGISTRY]: registry ?? goodfellowRegistryUrl("0.8.0"),
4328
4552
  ...outOfReach && {
4329
4553
  [SHADCN_NAMESPACE]: SHADCN_REGISTRY,
4330
4554
  ...site.registries
@@ -4357,6 +4581,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4357
4581
  const versions = isGitBackend(store) ? store : void 0;
4358
4582
  const changedPaths = useMemo(() => isGitBackend(store) ? (from, to) => store.changedPaths(from, to) : void 0, [store]);
4359
4583
  const editors = isGitBackend(store) ? store.editors : void 0;
4584
+ const comments = isGitBackend(store) ? store.comments : void 0;
4360
4585
  const updates = isGitBackend(store) ? store.updates : void 0;
4361
4586
  const contentOnly = isGitBackend(store) && store.contentOnly === true;
4362
4587
  const scheduledBuilds = isGitBackend(store) ? store.scheduledBuilds : void 0;
@@ -4422,9 +4647,14 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4422
4647
  reload
4423
4648
  ]);
4424
4649
  const refreshDrafts = useCallback(async (current) => {
4425
- const [names, paths] = await Promise.all([siteDrafts.list().catch(() => []), current ? current.paths().catch(() => []) : []]);
4650
+ const [names, paths, reviews] = await Promise.all([
4651
+ siteDrafts.list().catch(() => []),
4652
+ current ? current.paths().catch(() => []) : [],
4653
+ siteDrafts.reviews().catch(() => [])
4654
+ ]);
4426
4655
  setDraftNames(names);
4427
4656
  setDraftPaths(paths);
4657
+ setDraftReviews(reviews);
4428
4658
  }, [siteDrafts]);
4429
4659
  useEffect(() => {
4430
4660
  if (draftFound) refreshDrafts(draft);
@@ -4444,12 +4674,74 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4444
4674
  setChecking(void 0);
4445
4675
  return go;
4446
4676
  }, []);
4447
- const publish = useCallback(async (changes, message, options) => {
4677
+ const limits = useMemo(() => state.status === "ready" ? limitsFor(state.content.permissions, account?.user.login) : void 0, [state, account]);
4678
+ /** Who's asked to review: those the permissions name, else every owner, never the one asking. */
4679
+ const reviewers = useCallback(async () => {
4680
+ const owners = await editors?.list().then((list) => list.editors.flatMap((editor) => editor.role === "owner" && editor.login ? [editor.login] : [])).catch(() => []);
4681
+ const self = account?.user.login.toLowerCase();
4682
+ const permissions = state.status === "ready" ? state.content.permissions : void 0;
4683
+ return reviewersFor(permissions, owners ?? []).filter((login) => login.toLowerCase() !== self);
4684
+ }, [
4685
+ editors,
4686
+ account,
4687
+ state
4688
+ ]);
4689
+ /** Shows what's outside someone's limits, and lets them submit it for review where the site can take one. */
4690
+ const askLimits = useCallback((problems, shown, submit) => new Promise((done) => setLimiting({
4691
+ problems,
4692
+ content: shown,
4693
+ ...submit && { submit },
4694
+ done
4695
+ })), []);
4696
+ const publish = useCallback(async (given, message, options) => {
4448
4697
  if (state.status !== "ready") return {
4449
4698
  ok: false,
4450
4699
  reason: "error",
4451
4700
  error: /* @__PURE__ */ new Error("The site isn't loaded.")
4452
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
+ }
4712
+ const outside = draft || options?.draft ? [] : outsideLimits(state.content, limits, changes);
4713
+ if (outside.length > 0) {
4714
+ const from = state.revision;
4715
+ const description = [
4716
+ t("draft.reviewDescription"),
4717
+ "",
4718
+ ...changes.map((change) => `- \`${change.path}\``)
4719
+ ].join("\n");
4720
+ const submit = backendDrafts ? async () => {
4721
+ const review = await siteDrafts.create(newDraftName(), { from });
4722
+ await writeChanges(review.store, changes, {
4723
+ message,
4724
+ expectedRevision: await review.store.revision()
4725
+ });
4726
+ const { url } = await review.submitForReview({
4727
+ title: message,
4728
+ description,
4729
+ reviewers: await reviewers()
4730
+ });
4731
+ refreshDrafts(void 0);
4732
+ return { url };
4733
+ } : account?.review ? () => account.review?.(changes, {
4734
+ title: message,
4735
+ description,
4736
+ from
4737
+ }) ?? Promise.resolve({}) : void 0;
4738
+ await askLimits(outside, state.content, submit);
4739
+ return {
4740
+ ok: false,
4741
+ reason: "checks",
4742
+ error: void 0
4743
+ };
4744
+ }
4453
4745
  const checks = options?.checks === false ? void 0 : await checkPublish(content, state, changes, {
4454
4746
  blocks,
4455
4747
  code
@@ -4504,7 +4796,13 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4504
4796
  confirmChecks,
4505
4797
  refreshDrafts,
4506
4798
  loadFrom,
4507
- onSignInError
4799
+ onSignInError,
4800
+ limits,
4801
+ backendDrafts,
4802
+ account,
4803
+ askLimits,
4804
+ reviewers,
4805
+ t
4508
4806
  ]);
4509
4807
  const openDraft = useCallback(async (name) => {
4510
4808
  if (name === draft?.name) return true;
@@ -4548,6 +4846,29 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4548
4846
  reason: "error",
4549
4847
  error: live.status === "error" && live.error
4550
4848
  };
4849
+ const outside = outsideLimits(live.content, limitsFor(live.content.permissions, account?.user.login), changes);
4850
+ if (outside.length > 0) {
4851
+ const current = draft;
4852
+ const submit = backendDrafts ? async () => {
4853
+ const paths = changes.map((change) => change.path);
4854
+ return current.submitForReview({
4855
+ title: t("draft.reviewTitle", { name: current.name }),
4856
+ description: [
4857
+ t("draft.reviewDescription"),
4858
+ "",
4859
+ ...paths.map((path) => `- \`${path}\``)
4860
+ ].join("\n"),
4861
+ reviewers: await reviewers()
4862
+ });
4863
+ } : void 0;
4864
+ await askLimits(outside, live.content, submit);
4865
+ refreshDrafts(current);
4866
+ return {
4867
+ ok: false,
4868
+ reason: "checks",
4869
+ error: void 0
4870
+ };
4871
+ }
4551
4872
  if (!await confirmChecks(await checkPublish(store, live, changes, {
4552
4873
  blocks,
4553
4874
  code
@@ -4584,7 +4905,12 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4584
4905
  key,
4585
4906
  t,
4586
4907
  confirmChecks,
4587
- onSignInError
4908
+ onSignInError,
4909
+ account,
4910
+ backendDrafts,
4911
+ askLimits,
4912
+ reviewers,
4913
+ refreshDrafts
4588
4914
  ]);
4589
4915
  const submitDraft = useCallback(async () => {
4590
4916
  if (!draft) return {
@@ -4599,8 +4925,10 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4599
4925
  t("draft.reviewDescription"),
4600
4926
  "",
4601
4927
  ...paths.map((path) => `- \`${path}\``)
4602
- ].join("\n")
4928
+ ].join("\n"),
4929
+ reviewers: await reviewers()
4603
4930
  });
4931
+ refreshDrafts(draft);
4604
4932
  return {
4605
4933
  ok: true,
4606
4934
  url
@@ -4611,7 +4939,12 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4611
4939
  error
4612
4940
  };
4613
4941
  }
4614
- }, [draft, t]);
4942
+ }, [
4943
+ draft,
4944
+ t,
4945
+ reviewers,
4946
+ refreshDrafts
4947
+ ]);
4615
4948
  const discardDraft = useCallback(async () => {
4616
4949
  if (!draft) return { ok: true };
4617
4950
  try {
@@ -4630,6 +4963,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4630
4963
  const drafts = useMemo(() => ({
4631
4964
  kept: backendDrafts === void 0,
4632
4965
  names: draftNames,
4966
+ reviews: draftReviews,
4633
4967
  ...draft && { current: {
4634
4968
  name: draft.name,
4635
4969
  paths: draftPaths
@@ -4641,6 +4975,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4641
4975
  }), [
4642
4976
  backendDrafts,
4643
4977
  draftNames,
4978
+ draftReviews,
4644
4979
  draft,
4645
4980
  draftPaths,
4646
4981
  openDraft,
@@ -4692,6 +5027,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4692
5027
  const pluginInstalls = demo || contentOnly ? void 0 : account?.review ? "review" : atAdminAddress(site.admin) || site.broker !== void 0 ? "publish" : void 0;
4693
5028
  const value = useMemo(() => ({
4694
5029
  site,
5030
+ ...limits && { limits },
4695
5031
  blocks,
4696
5032
  code,
4697
5033
  preview,
@@ -4715,6 +5051,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4715
5051
  changedPaths,
4716
5052
  editors,
4717
5053
  ownerAccess,
5054
+ comments,
4718
5055
  updates,
4719
5056
  contentOnly,
4720
5057
  scheduledBuilds,
@@ -4724,6 +5061,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4724
5061
  tasks
4725
5062
  }), [
4726
5063
  site,
5064
+ limits,
4727
5065
  blocks,
4728
5066
  code,
4729
5067
  preview,
@@ -4747,6 +5085,7 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4747
5085
  changedPaths,
4748
5086
  editors,
4749
5087
  ownerAccess,
5088
+ comments,
4750
5089
  updates,
4751
5090
  contentOnly,
4752
5091
  scheduledBuilds,
@@ -4766,16 +5105,28 @@ function AdminProvider({ site, frames, store, account, preview, siteUrl, registr
4766
5105
  value,
4767
5106
  children: /* @__PURE__ */ jsxs(StringsProvider, {
4768
5107
  strings,
4769
- children: [state.status === "ready" ? /* @__PURE__ */ jsx(SiteFrames, {
4770
- frames,
4771
- state,
4772
- onCodeClient,
4773
- children
4774
- }) : children, checking && /* @__PURE__ */ jsx(ChecksDialog, {
4775
- checks: checking.checks,
4776
- content: checking.content,
4777
- onDecide: checking.decide
4778
- })]
5108
+ children: [
5109
+ state.status === "ready" ? /* @__PURE__ */ jsx(SiteFrames, {
5110
+ frames,
5111
+ state,
5112
+ onCodeClient,
5113
+ children
5114
+ }) : children,
5115
+ checking && /* @__PURE__ */ jsx(ChecksDialog, {
5116
+ checks: checking.checks,
5117
+ content: checking.content,
5118
+ onDecide: checking.decide
5119
+ }),
5120
+ limiting && /* @__PURE__ */ jsx(LimitsDialog, {
5121
+ problems: limiting.problems,
5122
+ content: limiting.content,
5123
+ ...limiting.submit && { onSubmit: limiting.submit },
5124
+ onClose: () => {
5125
+ setLimiting(void 0);
5126
+ limiting.done();
5127
+ }
5128
+ })
5129
+ ]
4779
5130
  })
4780
5131
  });
4781
5132
  }
@@ -4797,6 +5148,7 @@ function useSiteCss(stylesheets) {
4797
5148
  function SiteFrames({ frames, state, onCodeClient, children }) {
4798
5149
  const t = useStrings();
4799
5150
  const strings = useStringTable();
5151
+ const { puck, labels } = useEditorText();
4800
5152
  const theme = useThemeChoice();
4801
5153
  const { site, preview, siteUrl, mediaPreviews, account, scheduledBuilds, repositoryVisibility, scheduleSyncProblem, readFile, readBytes, listFiles, publish, tasks } = useAdmin();
4802
5154
  const { timing, visibility } = useScheduleInfoOf(scheduledBuilds, repositoryVisibility);
@@ -4858,6 +5210,8 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
4858
5210
  siteUrl,
4859
5211
  preview,
4860
5212
  strings,
5213
+ puck,
5214
+ labels,
4861
5215
  theme,
4862
5216
  hostName: account?.hostName ?? ""
4863
5217
  },
@@ -4881,6 +5235,8 @@ function SiteFrames({ frames, state, onCodeClient, children }) {
4881
5235
  siteUrl,
4882
5236
  preview,
4883
5237
  strings,
5238
+ puck,
5239
+ labels,
4884
5240
  theme,
4885
5241
  account,
4886
5242
  siteCss,
@@ -5079,4 +5435,4 @@ function formValues(collection, entry) {
5079
5435
  return values;
5080
5436
  }
5081
5437
  //#endregion
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 };
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 };