@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.
- package/dist/editor.js +723 -23
- package/dist/{entry-values-BrY3L0Ai.js → entry-values-Bu8ljnoX.js} +765 -409
- package/dist/es-Dxyy-khR.js +2122 -0
- package/dist/index.d.ts +222 -8
- package/dist/index.js +4703 -3043
- package/dist/plugin-protocol-BgYL9WSm.js +458 -0
- package/dist/plugin.js +38 -3
- package/package.json +6 -5
- package/plugin.css +43 -39
- package/styles.css +483 -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",
|
|
@@ -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
|
|
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
|
|
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.
|
|
999
|
-
"
|
|
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/
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
const
|
|
2262
|
-
const
|
|
2263
|
-
const
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
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
|
-
|
|
2310
|
-
|
|
2311
|
-
"href",
|
|
2312
|
-
"poster"
|
|
2313
|
-
];
|
|
2314
|
-
const MEDIA_IN_STYLE = /url\((['"]?)(\/media\/[^'")]+)\1\)/g;
|
|
2351
|
+
//#endregion
|
|
2352
|
+
//#region src/limits.ts
|
|
2315
2353
|
/**
|
|
2316
|
-
*
|
|
2317
|
-
*
|
|
2318
|
-
*
|
|
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
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
const
|
|
2326
|
-
|
|
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
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
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
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
});
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
"
|
|
2845
|
-
"
|
|
2846
|
-
"
|
|
2847
|
-
|
|
2848
|
-
|
|
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
|
-
*
|
|
2852
|
-
*
|
|
2853
|
-
*
|
|
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
|
-
|
|
2858
|
-
|
|
2859
|
-
for (const
|
|
2860
|
-
|
|
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
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
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
|
-
/**
|
|
3970
|
-
|
|
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.
|
|
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([
|
|
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
|
|
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
|
-
}, [
|
|
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: [
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
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 {
|
|
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 };
|