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