@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
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { MAX_UPLOAD_BYTES, MEDIA_DIR, addressPatternFor, checkEntrySlug, checkPageAddress, codeFileChanges, collectionFileChange, collectionFileChange as collectionFileChange$1, customCssFileChange, deleteCollectionChanges, duplicatePageData, entryFileChange, entryFileChange as entryFileChange$1, entryLanguage, entryPathIn, entryTitle, extensionOf, fileName as fileName$1, layoutFileChange, markdownToHtml, mediaFileChange, mediaKind, mediaKind as mediaKind$1, menusFileChange, moveEntryChanges, movePageChanges, newCollectionSettings, newPageData, pageFileChange, pageTitle as pageTitle$1, redirectsFileChange, relinkSite, relinkedChanges, siteSettingsFileChange, slugify, storedCss, storedData, storedEntryFields, storedMediaUrl, trimChars, uniqueName, updateSettingsFileChange, uploadName, withoutCredits } from "@goodfellow-cms/core";
|
|
2
|
+
import TurndownService from "turndown";
|
|
3
|
+
import { gfm } from "turndown-plugin-gfm";
|
|
4
|
+
//#region src/markdown-text.ts
|
|
5
|
+
let service;
|
|
6
|
+
/** Converts the formatted editor's HTML to Markdown, in the style most Markdown is written in. */
|
|
7
|
+
function htmlToMarkdown(html) {
|
|
8
|
+
if (!service) {
|
|
9
|
+
service = new TurndownService({
|
|
10
|
+
headingStyle: "atx",
|
|
11
|
+
bulletListMarker: "-",
|
|
12
|
+
codeBlockStyle: "fenced",
|
|
13
|
+
emDelimiter: "*",
|
|
14
|
+
strongDelimiter: "**",
|
|
15
|
+
hr: "---"
|
|
16
|
+
});
|
|
17
|
+
service.use(gfm);
|
|
18
|
+
service.addRule("underline", {
|
|
19
|
+
filter: ["u"],
|
|
20
|
+
replacement: (content) => content
|
|
21
|
+
});
|
|
22
|
+
service.addRule("strikethrough", {
|
|
23
|
+
filter: ["del", "s"],
|
|
24
|
+
replacement: (content) => `~~${content}~~`
|
|
25
|
+
});
|
|
26
|
+
service.addRule("listItem", {
|
|
27
|
+
filter: "li",
|
|
28
|
+
replacement(content, node) {
|
|
29
|
+
const parent = node.parentNode;
|
|
30
|
+
let marker = "- ";
|
|
31
|
+
if (parent?.nodeName === "OL") marker = `${Number(parent.getAttribute("start") ?? "1") + Array.prototype.indexOf.call(parent.children, node)}. `;
|
|
32
|
+
const indent = " ".repeat(marker.length);
|
|
33
|
+
const text = content.replace(/^\n+/, "").replace(/\n+$/, "").replace(/\n/g, `\n${indent}`);
|
|
34
|
+
return `${marker}${text}${node.nextSibling ? "\n" : ""}`;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return service.turndown(html).replace(/[ \t]+$/gm, "").trim();
|
|
39
|
+
}
|
|
40
|
+
/** The HTML the formatted editor starts from for some Markdown. */
|
|
41
|
+
function formattedHtml(markdown) {
|
|
42
|
+
return markdownToHtml(markdown);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The Markdown to save after the formatted editor changed `html`. If the text
|
|
46
|
+
* is the same as `original`'s, only written differently (the formatted editor
|
|
47
|
+
* tidies what it loads), `original` is kept, so the file isn't rewritten.
|
|
48
|
+
*/
|
|
49
|
+
function editedMarkdown(original, html) {
|
|
50
|
+
const edited = htmlToMarkdown(html);
|
|
51
|
+
return edited === htmlToMarkdown(formattedHtml(original)) ? original : edited;
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/changes.ts
|
|
55
|
+
/**
|
|
56
|
+
* The file changes the admin panel saves. Most are built by core's helpers,
|
|
57
|
+
* which the Claude connector shares; converting a collection's items between
|
|
58
|
+
* Markdown and formatted text needs the browser's HTML handling, so it's here.
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* A stored value as it fits its collection's field now, or `undefined` if it
|
|
62
|
+
* doesn't: the field must exist, and a choice must still be offered. Tags
|
|
63
|
+
* keep the ones that still are.
|
|
64
|
+
*/
|
|
65
|
+
function keptValue(fields, name, value) {
|
|
66
|
+
const field = fields.get(name);
|
|
67
|
+
if (!field) return void 0;
|
|
68
|
+
const offered = (choice) => field.options?.some((option) => option.value === choice) === true;
|
|
69
|
+
if (field.type === "select") return offered(value) ? value : void 0;
|
|
70
|
+
if (field.type === "tags" && Array.isArray(value)) {
|
|
71
|
+
const tags = value.filter(offered);
|
|
72
|
+
if (tags.length === value.length) return value;
|
|
73
|
+
return tags.length > 0 ? tags : void 0;
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
/** Where each item's page goes when its collection's address patterns change, in each of the site's languages. */
|
|
78
|
+
function movedEntryPages(site, collection, settings) {
|
|
79
|
+
if (!collection.settings.path || !settings.path) return [];
|
|
80
|
+
return collection.entries.flatMap((entry) => {
|
|
81
|
+
const to = entryPathIn(site, settings, entry.slug, entryLanguage(site, entry));
|
|
82
|
+
return entry.path && to && to !== entry.path ? [{
|
|
83
|
+
from: entry.path,
|
|
84
|
+
to
|
|
85
|
+
}] : [];
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Saves a collection's new settings. Values of fields that were removed, and
|
|
90
|
+
* choices that are no longer offered, are removed from every entry in the
|
|
91
|
+
* same save, so a field added later with the same name starts empty. Given
|
|
92
|
+
* the `site`, a new address pattern points every link to an item's page at
|
|
93
|
+
* its new address, in the same save.
|
|
94
|
+
*/
|
|
95
|
+
function collectionSettingsChanges(collection, settings, site) {
|
|
96
|
+
const relinked = site ? relinkSite({
|
|
97
|
+
...site.content,
|
|
98
|
+
collections: site.content.collections.map((candidate) => candidate.id === collection.id ? {
|
|
99
|
+
...collection,
|
|
100
|
+
settings: {
|
|
101
|
+
...collection.settings,
|
|
102
|
+
template: settings.template
|
|
103
|
+
}
|
|
104
|
+
} : candidate)
|
|
105
|
+
}, movedEntryPages(site.content.settings, collection, settings), site.blocks) : void 0;
|
|
106
|
+
const current = relinked?.content.collections.find((candidate) => candidate.id === collection.id) ?? collection;
|
|
107
|
+
const relinkedFiles = new Set(relinked?.files);
|
|
108
|
+
const fields = new Map(settings.fields.map((field) => [field.name, field]));
|
|
109
|
+
const changes = [collectionFileChange(collection.id, {
|
|
110
|
+
...settings,
|
|
111
|
+
template: current.settings.template
|
|
112
|
+
})];
|
|
113
|
+
const before = collection.settings.markdown?.body;
|
|
114
|
+
const after = settings.markdown?.body;
|
|
115
|
+
const reformat = before !== after;
|
|
116
|
+
for (const entry of current.entries) {
|
|
117
|
+
const values = Object.entries(entry.content.fields);
|
|
118
|
+
const remaining = values.flatMap(([name, value]) => {
|
|
119
|
+
const kept = keptValue(fields, name, value);
|
|
120
|
+
return kept === void 0 ? [] : [[name, kept]];
|
|
121
|
+
});
|
|
122
|
+
if (!(remaining.length !== values.length || remaining.some(([name, value]) => value !== entry.content.fields[name])) && !reformat && !relinkedFiles.has(entry.file)) continue;
|
|
123
|
+
const kept = Object.fromEntries(remaining);
|
|
124
|
+
if (reformat) {
|
|
125
|
+
if (before !== void 0 && typeof kept[before] === "string") kept[before] = formattedHtml(kept[before]);
|
|
126
|
+
if (after !== void 0 && typeof kept[after] === "string") kept[after] = htmlToMarkdown(kept[after]);
|
|
127
|
+
}
|
|
128
|
+
const change = entryFileChange({
|
|
129
|
+
id: collection.id,
|
|
130
|
+
settings
|
|
131
|
+
}, entry.slug, kept, entry.content);
|
|
132
|
+
if (change.path !== entry.file) changes.push({
|
|
133
|
+
path: entry.file,
|
|
134
|
+
delete: true
|
|
135
|
+
});
|
|
136
|
+
changes.push(change);
|
|
137
|
+
}
|
|
138
|
+
if (relinked) changes.push(...relinkedChanges(relinked, [collection.file, ...collection.entries.map((entry) => entry.file)]));
|
|
139
|
+
return changes;
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/media.ts
|
|
143
|
+
/** Files in `public/media/` are served from `/media/` on the site. */
|
|
144
|
+
const MEDIA_URL_PREFIX = "/media/";
|
|
145
|
+
/** `public/media/photo.jpg` → `/media/photo.jpg`. */
|
|
146
|
+
function mediaUrl(path) {
|
|
147
|
+
return `${MEDIA_URL_PREFIX}${path.slice(MEDIA_DIR.length + 1)}`;
|
|
148
|
+
}
|
|
149
|
+
/** `/media/photo.jpg` → `public/media/photo.jpg`, or `undefined` for addresses outside the media folder. */
|
|
150
|
+
function mediaPath(url) {
|
|
151
|
+
if (!url.startsWith("/media/")) return void 0;
|
|
152
|
+
let rest;
|
|
153
|
+
try {
|
|
154
|
+
rest = decodeURIComponent(url.slice(7).split(/[?#]/)[0] ?? "");
|
|
155
|
+
} catch {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!rest || rest.split(/[/\\]/).some((part) => part === "" || part === "." || part === "..")) return void 0;
|
|
159
|
+
return `${MEDIA_DIR}/${rest}`;
|
|
160
|
+
}
|
|
161
|
+
/** A file size in words: `2.4 MB`. */
|
|
162
|
+
function formatSize(bytes) {
|
|
163
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
164
|
+
if (bytes < 1048576) return `${Math.round(bytes / 1024)} KB`;
|
|
165
|
+
return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
166
|
+
}
|
|
167
|
+
/** Where a media file is used, by name, so editors know what deleting or replacing it affects. */
|
|
168
|
+
function mediaUsage(content, url, names) {
|
|
169
|
+
const uses = (value) => JSON.stringify(value).includes(url);
|
|
170
|
+
const used = [];
|
|
171
|
+
if (uses(content.settings)) used.push(names.settings);
|
|
172
|
+
if (uses(content.header)) used.push(names.header);
|
|
173
|
+
if (uses(content.footer)) used.push(names.footer);
|
|
174
|
+
for (const page of content.pages) if (uses(page.content)) used.push(pageTitle$1(page));
|
|
175
|
+
for (const collection of content.collections) {
|
|
176
|
+
if (uses(collection.settings)) used.push(collection.settings.name);
|
|
177
|
+
for (const entry of collection.entries) if (uses(entry.content)) used.push(entryTitle(entry));
|
|
178
|
+
}
|
|
179
|
+
if (content.customCss.includes(url)) used.push(names.css);
|
|
180
|
+
return used;
|
|
181
|
+
}
|
|
182
|
+
/** The addresses of the site's uploaded images, such as `/media/photo.jpg`. */
|
|
183
|
+
function siteImages(media) {
|
|
184
|
+
return media.filter((path) => mediaKind(path) === "image").map(mediaUrl);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Every media file the site has, as `public/media/…` paths: those in the
|
|
188
|
+
* site's own files and those a plugin keeps outside them
|
|
189
|
+
* (`content/media-storage.json`), which content refers to the same way.
|
|
190
|
+
*/
|
|
191
|
+
function allMedia(content, media) {
|
|
192
|
+
const stored = Object.keys(content.mediaStorage?.files ?? {}).flatMap((url) => mediaPath(url) ?? []);
|
|
193
|
+
return [.../* @__PURE__ */ new Set([...media, ...stored])];
|
|
194
|
+
}
|
|
195
|
+
/** Whether a plugin keeps a media file outside the site's own files. */
|
|
196
|
+
function isStoredMedia(content, url) {
|
|
197
|
+
return Object.hasOwn(content.mediaStorage?.files ?? {}, url);
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/frame/channel.ts
|
|
201
|
+
/**
|
|
202
|
+
* Requests and events between the admin panel and a frame running the site's
|
|
203
|
+
* code, over one `MessagePort`. The admin panel's side treats everything a
|
|
204
|
+
* frame sends as untrusted: the site's blocks run there, and could send anything.
|
|
205
|
+
*/
|
|
206
|
+
/** The message the admin panel's page sends a frame once it has loaded, with the port to talk on. */
|
|
207
|
+
const CONNECT_MESSAGE = "goodfellow:connect";
|
|
208
|
+
/** A request the other side refused or couldn't answer. */
|
|
209
|
+
var ChannelError = class extends Error {
|
|
210
|
+
name = "ChannelError";
|
|
211
|
+
};
|
|
212
|
+
function isMessage(data) {
|
|
213
|
+
if (typeof data !== "object" || data === null) return false;
|
|
214
|
+
const type = data.type;
|
|
215
|
+
return type === "request" || type === "response" || type === "event";
|
|
216
|
+
}
|
|
217
|
+
/** What an error says, as text: errors can't cross the port as they are. */
|
|
218
|
+
function errorText(error) {
|
|
219
|
+
return error instanceof Error ? error.message : String(error);
|
|
220
|
+
}
|
|
221
|
+
function createChannel(port, handlers = {}) {
|
|
222
|
+
const own = { ...handlers };
|
|
223
|
+
const pending = /* @__PURE__ */ new Map();
|
|
224
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
225
|
+
let next = 1;
|
|
226
|
+
let closed = false;
|
|
227
|
+
port.onmessage = async (event) => {
|
|
228
|
+
const message = event.data;
|
|
229
|
+
if (!isMessage(message)) return;
|
|
230
|
+
if (message.type === "response") {
|
|
231
|
+
const waiting = pending.get(message.id);
|
|
232
|
+
if (!waiting) return;
|
|
233
|
+
pending.delete(message.id);
|
|
234
|
+
if (message.ok) waiting.resolve(message.result);
|
|
235
|
+
else waiting.reject(new ChannelError(message.error));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (message.type === "event") {
|
|
239
|
+
for (const listener of listeners.get(message.name) ?? []) listener(message.payload);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const handler = typeof message.method === "string" ? Object.hasOwn(own, message.method) && own[message.method] : void 0;
|
|
243
|
+
if (!handler) {
|
|
244
|
+
port.postMessage({
|
|
245
|
+
type: "response",
|
|
246
|
+
id: message.id,
|
|
247
|
+
ok: false,
|
|
248
|
+
error: `No such request: ${message.method}`
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const result = await handler(message.params);
|
|
254
|
+
if (!closed) port.postMessage({
|
|
255
|
+
type: "response",
|
|
256
|
+
id: message.id,
|
|
257
|
+
ok: true,
|
|
258
|
+
result
|
|
259
|
+
});
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (!closed) port.postMessage({
|
|
262
|
+
type: "response",
|
|
263
|
+
id: message.id,
|
|
264
|
+
ok: false,
|
|
265
|
+
error: errorText(error)
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
return {
|
|
270
|
+
call(method, params, transfer = []) {
|
|
271
|
+
if (closed) return Promise.reject(new ChannelError("The frame has closed."));
|
|
272
|
+
const id = next++;
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
pending.set(id, {
|
|
275
|
+
resolve,
|
|
276
|
+
reject
|
|
277
|
+
});
|
|
278
|
+
port.postMessage({
|
|
279
|
+
type: "request",
|
|
280
|
+
id,
|
|
281
|
+
method,
|
|
282
|
+
params
|
|
283
|
+
}, transfer);
|
|
284
|
+
});
|
|
285
|
+
},
|
|
286
|
+
emit(name, payload) {
|
|
287
|
+
if (!closed) port.postMessage({
|
|
288
|
+
type: "event",
|
|
289
|
+
name,
|
|
290
|
+
payload
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
on(name, listener) {
|
|
294
|
+
const set = listeners.get(name) ?? /* @__PURE__ */ new Set();
|
|
295
|
+
listeners.set(name, set);
|
|
296
|
+
set.add(listener);
|
|
297
|
+
return () => set.delete(listener);
|
|
298
|
+
},
|
|
299
|
+
handle(method, handler) {
|
|
300
|
+
own[method] = handler;
|
|
301
|
+
return () => {
|
|
302
|
+
if (own[method] === handler) delete own[method];
|
|
303
|
+
};
|
|
304
|
+
},
|
|
305
|
+
close() {
|
|
306
|
+
closed = true;
|
|
307
|
+
port.close();
|
|
308
|
+
for (const waiting of pending.values()) waiting.reject(new ChannelError("The frame has closed."));
|
|
309
|
+
pending.clear();
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
//#endregion
|
|
314
|
+
//#region src/media-previews.ts
|
|
315
|
+
const TYPES = {
|
|
316
|
+
jpg: "image/jpeg",
|
|
317
|
+
jpeg: "image/jpeg",
|
|
318
|
+
png: "image/png",
|
|
319
|
+
gif: "image/gif",
|
|
320
|
+
webp: "image/webp",
|
|
321
|
+
avif: "image/avif",
|
|
322
|
+
svg: "image/svg+xml",
|
|
323
|
+
ico: "image/x-icon",
|
|
324
|
+
pdf: "application/pdf",
|
|
325
|
+
mp3: "audio/mpeg",
|
|
326
|
+
m4a: "audio/mp4",
|
|
327
|
+
mp4: "video/mp4",
|
|
328
|
+
webm: "video/webm",
|
|
329
|
+
vtt: "text/vtt"
|
|
330
|
+
};
|
|
331
|
+
/** The type to show a file as, from its name. Needed so browsers display SVGs from memory. */
|
|
332
|
+
function mediaType(name) {
|
|
333
|
+
return TYPES[extensionOf(name)] ?? "application/octet-stream";
|
|
334
|
+
}
|
|
335
|
+
function createMediaPreviews(store, siteUrl, storage = () => void 0) {
|
|
336
|
+
const local = /* @__PURE__ */ new Map();
|
|
337
|
+
const remembered = /* @__PURE__ */ new Map();
|
|
338
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
339
|
+
const loading = /* @__PURE__ */ new Map();
|
|
340
|
+
const base = trimChars(siteUrl, "/", { start: false });
|
|
341
|
+
const remember = (url, bytes) => {
|
|
342
|
+
const previous = local.get(url);
|
|
343
|
+
if (previous) URL.revokeObjectURL(previous);
|
|
344
|
+
remembered.set(url, bytes);
|
|
345
|
+
for (const listener of listeners) listener(url, bytes);
|
|
346
|
+
local.set(url, URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: mediaType(url) })));
|
|
347
|
+
};
|
|
348
|
+
return {
|
|
349
|
+
resolve(url) {
|
|
350
|
+
if (!url.startsWith("/media/")) return url;
|
|
351
|
+
const end = url.search(/[?#]/);
|
|
352
|
+
const stored = local.has(url) ? void 0 : storedMediaUrl(storage(), end < 0 ? url : url.slice(0, end));
|
|
353
|
+
return stored ? `${stored}${end < 0 ? "" : url.slice(end)}` : local.get(url) ?? `${base}${url}`;
|
|
354
|
+
},
|
|
355
|
+
remember,
|
|
356
|
+
onRemember(listener) {
|
|
357
|
+
for (const [url, bytes] of remembered) listener(url, bytes);
|
|
358
|
+
listeners.add(listener);
|
|
359
|
+
return () => listeners.delete(listener);
|
|
360
|
+
},
|
|
361
|
+
async bytes(url) {
|
|
362
|
+
const known = remembered.get(url);
|
|
363
|
+
if (known) return known;
|
|
364
|
+
const path = mediaPath(url);
|
|
365
|
+
return path ? await store.readBytes(path).catch(() => void 0) : void 0;
|
|
366
|
+
},
|
|
367
|
+
load(url) {
|
|
368
|
+
const known = local.get(url);
|
|
369
|
+
if (known) return Promise.resolve(known);
|
|
370
|
+
const path = mediaPath(url);
|
|
371
|
+
if (!path) return Promise.resolve(void 0);
|
|
372
|
+
let pending = loading.get(url);
|
|
373
|
+
if (!pending) {
|
|
374
|
+
pending = store.readBytes(path).then((bytes) => {
|
|
375
|
+
if (!bytes) return void 0;
|
|
376
|
+
remember(url, bytes);
|
|
377
|
+
return local.get(url);
|
|
378
|
+
}).catch(() => void 0);
|
|
379
|
+
loading.set(url, pending);
|
|
380
|
+
}
|
|
381
|
+
return pending;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
const URL_ATTRIBUTES = [
|
|
386
|
+
"src",
|
|
387
|
+
"href",
|
|
388
|
+
"poster"
|
|
389
|
+
];
|
|
390
|
+
const MEDIA_IN_STYLE = /url\((['"]?)(\/media\/[^'")]+)\1\)/g;
|
|
391
|
+
/**
|
|
392
|
+
* Keeps media in a preview document showing: rewrites `/media/…` addresses in
|
|
393
|
+
* images, links and inline styles as the preview changes, and if an image
|
|
394
|
+
* still doesn't load, reads it from the repository instead. Elements inside
|
|
395
|
+
* `keep` (a selector) are left as they are, such as text edited in place,
|
|
396
|
+
* whose HTML is read back into content.
|
|
397
|
+
*/
|
|
398
|
+
function showMediaInPreview(doc, previews, { keep } = {}) {
|
|
399
|
+
const fix = (element) => {
|
|
400
|
+
if (keep && element.closest(keep)) return;
|
|
401
|
+
for (const attribute of URL_ATTRIBUTES) {
|
|
402
|
+
const value = element.getAttribute(attribute);
|
|
403
|
+
if (!value?.startsWith("/media/")) continue;
|
|
404
|
+
const resolved = previews.resolve(value);
|
|
405
|
+
if (resolved !== value) {
|
|
406
|
+
element.setAttribute(`data-gf-${attribute}`, value);
|
|
407
|
+
element.setAttribute(attribute, resolved);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const style = element.getAttribute("style");
|
|
411
|
+
if (style?.includes("/media/")) {
|
|
412
|
+
const resolved = style.replace(MEDIA_IN_STYLE, (_match, quote, url) => `url(${quote}${previews.resolve(url)}${quote})`);
|
|
413
|
+
if (resolved !== style) element.setAttribute("style", resolved);
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
const fixTree = (root) => {
|
|
417
|
+
fix(root);
|
|
418
|
+
for (const element of root.querySelectorAll("[src], [href], [poster], [style]")) fix(element);
|
|
419
|
+
};
|
|
420
|
+
const observer = new MutationObserver((records) => {
|
|
421
|
+
for (const record of records) {
|
|
422
|
+
if (record.type === "attributes" && record.target.nodeType === 1) fix(record.target);
|
|
423
|
+
for (const node of record.addedNodes) if (node.nodeType === 1) fixTree(node);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
observer.observe(doc.documentElement, {
|
|
427
|
+
subtree: true,
|
|
428
|
+
childList: true,
|
|
429
|
+
attributes: true,
|
|
430
|
+
attributeFilter: [...URL_ATTRIBUTES, "style"]
|
|
431
|
+
});
|
|
432
|
+
fixTree(doc.documentElement);
|
|
433
|
+
const onError = (event) => {
|
|
434
|
+
const image = event.target;
|
|
435
|
+
if (image?.tagName !== "IMG") return;
|
|
436
|
+
const original = image.getAttribute("data-gf-src") ?? image.getAttribute("src") ?? "";
|
|
437
|
+
if (!original.startsWith("/media/") || image.hasAttribute("data-gf-loaded")) return;
|
|
438
|
+
if (keep && image.closest(keep)) return;
|
|
439
|
+
image.setAttribute("data-gf-loaded", "");
|
|
440
|
+
previews.load(original).then((url) => {
|
|
441
|
+
if (url) {
|
|
442
|
+
image.setAttribute("data-gf-src", original);
|
|
443
|
+
image.setAttribute("src", url);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
};
|
|
447
|
+
doc.addEventListener("error", onError, true);
|
|
448
|
+
return () => {
|
|
449
|
+
observer.disconnect();
|
|
450
|
+
doc.removeEventListener("error", onError, true);
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
//#endregion
|
|
454
|
+
//#region src/frame/plugin-protocol.ts
|
|
455
|
+
/** A plugin's frame sends this to the admin panel's page once it's listening, and the page answers with a port. */
|
|
456
|
+
const PLUGIN_READY_MESSAGE = "goodfellow:plugin-ready";
|
|
457
|
+
//#endregion
|
|
458
|
+
export { layoutFileChange as A, siteSettingsFileChange as B, codeFileChanges as C, deleteCollectionChanges as D, customCssFileChange as E, newCollectionSettings as F, uniqueName as G, storedCss as H, newPageData as I, editedMarkdown as J, updateSettingsFileChange as K, pageFileChange as L, menusFileChange as M, moveEntryChanges as N, duplicatePageData as O, movePageChanges as P, pageTitle$1 as R, checkPageAddress as S, collectionSettingsChanges as T, storedData as U, slugify as V, storedEntryFields as W, formattedHtml as Y, mediaUsage as _, CONNECT_MESSAGE as a, addressPatternFor as b, MEDIA_URL_PREFIX as c, fileName$1 as d, formatSize as f, mediaUrl as g, mediaPath as h, showMediaInPreview as i, mediaFileChange as j, entryFileChange$1 as k, allMedia as l, mediaKind$1 as m, createMediaPreviews as n, createChannel as o, isStoredMedia as p, withoutCredits as q, mediaType as r, MAX_UPLOAD_BYTES as s, PLUGIN_READY_MESSAGE as t, extensionOf as u, siteImages as v, collectionFileChange$1 as w, checkEntrySlug as x, uploadName as y, redirectsFileChange as z };
|
package/dist/plugin.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { g as mediaUrl, i as showMediaInPreview, n as createMediaPreviews, o as createChannel, t as PLUGIN_READY_MESSAGE } from "./plugin-protocol-BgYL9WSm.js";
|
|
2
|
+
import { MEDIA_DIR } from "@goodfellow-cms/core";
|
|
2
3
|
//#region src/plugin.ts
|
|
4
|
+
/**
|
|
5
|
+
* A plugin's admin frame: runs the plugin's admin module, sandboxed away from
|
|
6
|
+
* the admin panel's own page, and answers for it. Builds bundle each plugin's
|
|
7
|
+
* admin module with this into a page of its own (`pluginFrameEntry()` in
|
|
8
|
+
* `@goodfellow-cms/core`), whose Content Security Policy reaches only the
|
|
9
|
+
* addresses the plugin declared.
|
|
10
|
+
*/
|
|
3
11
|
/** Shows the admin panel's light or dark choice, as the admin panel's stylesheet reads it. */
|
|
4
12
|
function applyTheme(choice) {
|
|
5
13
|
const root = document.documentElement;
|
|
@@ -34,6 +42,28 @@ function adminApi(channel, init) {
|
|
|
34
42
|
}
|
|
35
43
|
};
|
|
36
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Shows the site's media in an editor's frame as Puck's frame does: `/media/…`
|
|
47
|
+
* addresses from the site's folder, from memory for uploads the live site
|
|
48
|
+
* doesn't have yet (which the page sends, or reads from the repository when
|
|
49
|
+
* an image doesn't load), or from the storage a plugin keeps them in. Text
|
|
50
|
+
* edited in place keeps its addresses, since editors read its HTML back into
|
|
51
|
+
* content.
|
|
52
|
+
*/
|
|
53
|
+
function showEditorMedia(channel, init) {
|
|
54
|
+
let storage = init.mediaStorage;
|
|
55
|
+
const previews = createMediaPreviews({ readBytes: async (path) => {
|
|
56
|
+
if (!path.startsWith(`${MEDIA_DIR}/`)) return void 0;
|
|
57
|
+
return await channel.call("media.bytes", mediaUrl(path)) ?? void 0;
|
|
58
|
+
} }, init.siteUrl, () => storage);
|
|
59
|
+
channel.handle("media.remember", (file) => {
|
|
60
|
+
if (typeof file?.url === "string" && file.bytes instanceof Uint8Array) previews.remember(file.url, file.bytes);
|
|
61
|
+
});
|
|
62
|
+
channel.handle("media.storage", (given) => {
|
|
63
|
+
storage = given ?? void 0;
|
|
64
|
+
});
|
|
65
|
+
return showMediaInPreview(document, previews, { keep: "[contenteditable]:not([contenteditable=\"false\"])" });
|
|
66
|
+
}
|
|
37
67
|
/** Opens an editor plugin's editor in the frame, giving the admin panel's page what it can do. */
|
|
38
68
|
async function openEditor(admin, channel, init, element, api) {
|
|
39
69
|
const open = init.editor;
|
|
@@ -54,11 +84,16 @@ async function openEditor(admin, channel, init, element, api) {
|
|
|
54
84
|
}),
|
|
55
85
|
sharePreview: (root) => channel.call("editor.sharePreview", { root }),
|
|
56
86
|
draw: (block) => channel.call("editor.draw", { block }),
|
|
57
|
-
styles: () => channel.call("editor.styles")
|
|
87
|
+
styles: () => channel.call("editor.styles"),
|
|
88
|
+
showsMedia: true
|
|
58
89
|
};
|
|
59
90
|
document.documentElement.classList.add("gfa-plugin-editor");
|
|
91
|
+
const stopShowingMedia = showEditorMedia(channel, init);
|
|
60
92
|
const controls = await admin.editor.mount(element, session, api) ?? {};
|
|
61
|
-
window.addEventListener("pagehide", () =>
|
|
93
|
+
window.addEventListener("pagehide", () => {
|
|
94
|
+
controls.destroy?.();
|
|
95
|
+
stopShowingMedia();
|
|
96
|
+
});
|
|
62
97
|
return {
|
|
63
98
|
opened: {
|
|
64
99
|
set: typeof controls.set === "function",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Goodfellow's admin panel: a visual editor for a site's pages, layout and settings.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"goodfellow",
|
|
@@ -56,10 +56,10 @@
|
|
|
56
56
|
"@codemirror/state": "6.7.6",
|
|
57
57
|
"@codemirror/theme-one-dark": "6.1.3",
|
|
58
58
|
"@codemirror/view": "6.43.14",
|
|
59
|
-
"@goodfellow-cms/ai": "0.
|
|
60
|
-
"@goodfellow-cms/core": "0.
|
|
61
|
-
"@goodfellow-cms/react": "0.
|
|
62
|
-
"@goodfellow-cms/registry": "0.
|
|
59
|
+
"@goodfellow-cms/ai": "0.8.0",
|
|
60
|
+
"@goodfellow-cms/core": "0.8.0",
|
|
61
|
+
"@goodfellow-cms/react": "0.8.0",
|
|
62
|
+
"@goodfellow-cms/registry": "0.8.0",
|
|
63
63
|
"codemirror": "6.0.2",
|
|
64
64
|
"lucide-react": "1.52.0",
|
|
65
65
|
"turndown": "7.2.4",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"react-dom": "^19.0.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
|
+
"@goodfellow-cms/blocks": "0.8.0",
|
|
74
75
|
"@puckeditor/core": "0.23.0",
|
|
75
76
|
"@types/react": "^19.2.0",
|
|
76
77
|
"@types/react-dom": "^19.2.0",
|