@goodfellow-cms/admin 0.5.0 → 0.6.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.
@@ -0,0 +1,119 @@
1
+ //#region src/frame/channel.ts
2
+ /**
3
+ * Requests and events between the admin panel and a frame running the site's
4
+ * code, over one `MessagePort`. The admin panel's side treats everything a
5
+ * frame sends as untrusted: the site's blocks run there, and could send anything.
6
+ */
7
+ /** The message the admin panel's page sends a frame once it has loaded, with the port to talk on. */
8
+ const CONNECT_MESSAGE = "goodfellow:connect";
9
+ /** A request the other side refused or couldn't answer. */
10
+ var ChannelError = class extends Error {
11
+ name = "ChannelError";
12
+ };
13
+ function isMessage(data) {
14
+ if (typeof data !== "object" || data === null) return false;
15
+ const type = data.type;
16
+ return type === "request" || type === "response" || type === "event";
17
+ }
18
+ /** What an error says, as text: errors can't cross the port as they are. */
19
+ function errorText(error) {
20
+ return error instanceof Error ? error.message : String(error);
21
+ }
22
+ function createChannel(port, handlers = {}) {
23
+ const own = { ...handlers };
24
+ const pending = /* @__PURE__ */ new Map();
25
+ const listeners = /* @__PURE__ */ new Map();
26
+ let next = 1;
27
+ let closed = false;
28
+ port.onmessage = async (event) => {
29
+ const message = event.data;
30
+ if (!isMessage(message)) return;
31
+ if (message.type === "response") {
32
+ const waiting = pending.get(message.id);
33
+ if (!waiting) return;
34
+ pending.delete(message.id);
35
+ if (message.ok) waiting.resolve(message.result);
36
+ else waiting.reject(new ChannelError(message.error));
37
+ return;
38
+ }
39
+ if (message.type === "event") {
40
+ for (const listener of listeners.get(message.name) ?? []) listener(message.payload);
41
+ return;
42
+ }
43
+ const handler = typeof message.method === "string" ? Object.hasOwn(own, message.method) && own[message.method] : void 0;
44
+ if (!handler) {
45
+ port.postMessage({
46
+ type: "response",
47
+ id: message.id,
48
+ ok: false,
49
+ error: `No such request: ${message.method}`
50
+ });
51
+ return;
52
+ }
53
+ try {
54
+ const result = await handler(message.params);
55
+ if (!closed) port.postMessage({
56
+ type: "response",
57
+ id: message.id,
58
+ ok: true,
59
+ result
60
+ });
61
+ } catch (error) {
62
+ if (!closed) port.postMessage({
63
+ type: "response",
64
+ id: message.id,
65
+ ok: false,
66
+ error: errorText(error)
67
+ });
68
+ }
69
+ };
70
+ return {
71
+ call(method, params, transfer = []) {
72
+ if (closed) return Promise.reject(new ChannelError("The frame has closed."));
73
+ const id = next++;
74
+ return new Promise((resolve, reject) => {
75
+ pending.set(id, {
76
+ resolve,
77
+ reject
78
+ });
79
+ port.postMessage({
80
+ type: "request",
81
+ id,
82
+ method,
83
+ params
84
+ }, transfer);
85
+ });
86
+ },
87
+ emit(name, payload) {
88
+ if (!closed) port.postMessage({
89
+ type: "event",
90
+ name,
91
+ payload
92
+ });
93
+ },
94
+ on(name, listener) {
95
+ const set = listeners.get(name) ?? /* @__PURE__ */ new Set();
96
+ listeners.set(name, set);
97
+ set.add(listener);
98
+ return () => set.delete(listener);
99
+ },
100
+ handle(method, handler) {
101
+ own[method] = handler;
102
+ return () => {
103
+ if (own[method] === handler) delete own[method];
104
+ };
105
+ },
106
+ close() {
107
+ closed = true;
108
+ port.close();
109
+ for (const waiting of pending.values()) waiting.reject(new ChannelError("The frame has closed."));
110
+ pending.clear();
111
+ }
112
+ };
113
+ }
114
+ //#endregion
115
+ //#region src/frame/plugin-protocol.ts
116
+ /** A plugin's frame sends this to the admin panel's page once it's listening, and the page answers with a port. */
117
+ const PLUGIN_READY_MESSAGE = "goodfellow:plugin-ready";
118
+ //#endregion
119
+ export { CONNECT_MESSAGE as n, createChannel as r, PLUGIN_READY_MESSAGE as t };
@@ -0,0 +1,5 @@
1
+ import { PluginAdmin } from "@goodfellow-cms/core";
2
+ //#region src/plugin.d.ts
3
+ /** Starts a plugin's admin frame with its admin module's default export. */
4
+ export declare function mountPluginFrame(admin: PluginAdmin): void;
5
+ //#endregion
package/dist/plugin.js ADDED
@@ -0,0 +1,153 @@
1
+ import { r as createChannel, t as PLUGIN_READY_MESSAGE } from "./plugin-protocol-DkvBDqsF.js";
2
+ //#region src/plugin.ts
3
+ /** Shows the admin panel's light or dark choice, as the admin panel's stylesheet reads it. */
4
+ function applyTheme(choice) {
5
+ const root = document.documentElement;
6
+ if (choice === "system") delete root.dataset.gfaTheme;
7
+ else root.dataset.gfaTheme = choice;
8
+ }
9
+ function adminApi(channel, init) {
10
+ return {
11
+ plugin: init.plugin,
12
+ siteUrl: init.siteUrl,
13
+ read: (paths) => channel.call("read", { paths }),
14
+ readBytes: (path) => channel.call("readBytes", { path }),
15
+ list: (folder) => channel.call("list", { folder }),
16
+ publish: (changes, message) => channel.call("publish", {
17
+ changes,
18
+ message
19
+ }),
20
+ server: (path, request = {}) => channel.call("server", {
21
+ path,
22
+ ...request
23
+ }),
24
+ navigate: (href) => {
25
+ channel.call("navigate", href).catch(() => {});
26
+ },
27
+ task: (id, input) => channel.call("task", {
28
+ id,
29
+ input: input ?? null
30
+ }),
31
+ secretsHelp: () => channel.call("secrets.help"),
32
+ openSecretsLink: (url) => {
33
+ channel.call("secrets.open", { url }).catch(() => {});
34
+ }
35
+ };
36
+ }
37
+ /** Opens an editor plugin's editor in the frame, giving the admin panel's page what it can do. */
38
+ async function openEditor(admin, channel, init, element, api) {
39
+ const open = init.editor;
40
+ if (!admin.editor || !open) throw new Error(`${init.plugin.title} has no page editor.`);
41
+ let latest = open.data;
42
+ const session = {
43
+ ...open,
44
+ change: (data, state) => {
45
+ latest = data;
46
+ channel.call("editor.change", {
47
+ data,
48
+ ...state && { state }
49
+ }).catch(() => {});
50
+ },
51
+ chooseMedia: (kind, value) => channel.call("media.choose", {
52
+ kind,
53
+ value: value ?? ""
54
+ }),
55
+ sharePreview: (root) => channel.call("editor.sharePreview", { root }),
56
+ draw: (block) => channel.call("editor.draw", { block }),
57
+ styles: () => channel.call("editor.styles")
58
+ };
59
+ document.documentElement.classList.add("gfa-plugin-editor");
60
+ const controls = await admin.editor.mount(element, session, api) ?? {};
61
+ window.addEventListener("pagehide", () => controls.destroy?.());
62
+ return {
63
+ opened: {
64
+ set: typeof controls.set === "function",
65
+ undo: Boolean(controls.undo && controls.redo)
66
+ },
67
+ controls,
68
+ latest: () => latest
69
+ };
70
+ }
71
+ /** Starts a plugin's admin frame with its admin module's default export. */
72
+ function mountPluginFrame(admin) {
73
+ document.documentElement.classList.add("gfa-plugin");
74
+ const element = document.getElementById("goodfellow-plugin") ?? document.body;
75
+ let connected = false;
76
+ const onMessage = (event) => {
77
+ const port = event.ports[0];
78
+ if (connected || event.source !== window.parent || !port) return;
79
+ if (event.data?.type !== "goodfellow:connect") return;
80
+ connected = true;
81
+ window.removeEventListener("message", onMessage);
82
+ const channel = createChannel(port);
83
+ let api;
84
+ let init;
85
+ let editor;
86
+ channel.handle("init", async (given) => {
87
+ init = given;
88
+ applyTheme(given.theme);
89
+ api = adminApi(channel, given);
90
+ if (given.mode === "editor") {
91
+ editor = await openEditor(admin, channel, given, element, api);
92
+ return editor.opened;
93
+ }
94
+ if (given.mode !== "screen") return;
95
+ const mount = given.screen && admin.screens && Object.hasOwn(admin.screens, given.screen) && admin.screens[given.screen];
96
+ if (!mount) throw new Error(`${given.plugin.title} has no screen called "${given.screen}".`);
97
+ const body = document.body;
98
+ let reported = 0;
99
+ new ResizeObserver(() => {
100
+ const height = Math.ceil(body.getBoundingClientRect().height);
101
+ if (height === reported) return;
102
+ reported = height;
103
+ channel.call("resize", { height }).catch(() => {});
104
+ }).observe(body);
105
+ const cleanup = await mount(element, api);
106
+ if (typeof cleanup === "function") window.addEventListener("pagehide", () => cleanup());
107
+ });
108
+ channel.handle("theme", (choice) => applyTheme(choice));
109
+ channel.handle("call", ({ method, args }) => {
110
+ const service = admin.services && Object.hasOwn(admin.services, method) && admin.services[method];
111
+ if (!service || !api) throw new Error(`No such service: ${method}`);
112
+ return service(Array.isArray(args) ? args : [], api);
113
+ });
114
+ const media = () => {
115
+ if (!admin.media || !api) throw new Error(`${init?.plugin.title ?? "This plugin"} doesn't keep media.`);
116
+ return {
117
+ storage: admin.media,
118
+ api
119
+ };
120
+ };
121
+ channel.handle("media.ready", () => {
122
+ const { storage, api } = media();
123
+ return storage.ready(api);
124
+ });
125
+ channel.handle("media.upload", ({ files }) => {
126
+ const { storage, api } = media();
127
+ return storage.upload(files, api);
128
+ });
129
+ channel.handle("media.remove", ({ addresses }) => {
130
+ const { storage, api } = media();
131
+ return storage.remove(addresses, api);
132
+ });
133
+ const opened = () => {
134
+ if (!editor) throw new Error(`${init?.plugin.title ?? "This plugin"}'s editor isn't open.`);
135
+ return editor;
136
+ };
137
+ channel.handle("editor.data", async () => {
138
+ const { controls, latest } = opened();
139
+ return controls.data ? await controls.data() : latest();
140
+ });
141
+ channel.handle("editor.set", async (data) => {
142
+ const { controls } = opened();
143
+ if (!controls.set) throw new Error("This editor doesn't take changes from outside.");
144
+ await controls.set(data);
145
+ });
146
+ channel.handle("editor.undo", () => opened().controls.undo?.());
147
+ channel.handle("editor.redo", () => opened().controls.redo?.());
148
+ };
149
+ window.addEventListener("message", onMessage);
150
+ window.parent.postMessage(PLUGIN_READY_MESSAGE, "*");
151
+ }
152
+ //#endregion
153
+ export { mountPluginFrame };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/admin",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -35,12 +35,18 @@
35
35
  "types": "./dist/editor.d.ts",
36
36
  "import": "./dist/editor.js"
37
37
  },
38
+ "./plugin": {
39
+ "types": "./dist/plugin.d.ts",
40
+ "import": "./dist/plugin.js"
41
+ },
38
42
  "./styles.css": "./styles.css",
43
+ "./plugin.css": "./plugin.css",
39
44
  "./package.json": "./package.json"
40
45
  },
41
46
  "files": [
42
47
  "dist",
43
- "styles.css"
48
+ "styles.css",
49
+ "plugin.css"
44
50
  ],
45
51
  "dependencies": {
46
52
  "@codemirror/commands": "6.11.1",
@@ -50,10 +56,10 @@
50
56
  "@codemirror/state": "6.7.6",
51
57
  "@codemirror/theme-one-dark": "6.1.3",
52
58
  "@codemirror/view": "6.43.14",
53
- "@goodfellow-cms/ai": "0.5.0",
54
- "@goodfellow-cms/core": "0.5.0",
55
- "@goodfellow-cms/react": "0.5.0",
56
- "@goodfellow-cms/registry": "0.5.0",
59
+ "@goodfellow-cms/ai": "0.6.0",
60
+ "@goodfellow-cms/core": "0.6.0",
61
+ "@goodfellow-cms/react": "0.6.0",
62
+ "@goodfellow-cms/registry": "0.6.0",
57
63
  "codemirror": "6.0.2",
58
64
  "lucide-react": "1.52.0",
59
65
  "turndown": "7.2.4",
package/plugin.css ADDED
@@ -0,0 +1,215 @@
1
+ /*
2
+ * The base look of a plugin's admin frame: the admin panel's colors, as
3
+ * `--gfa-*` custom properties that follow its light or dark choice, its
4
+ * font, and plain buttons, fields and headings. A plugin's screens can use
5
+ * the properties in styles of their own.
6
+ */
7
+ :root {
8
+ color-scheme: light dark;
9
+ }
10
+
11
+ :root {
12
+ --gfa-bg: light-dark(#f8fafc, #0b1120);
13
+ --gfa-surface: light-dark(#ffffff, #111827);
14
+ --gfa-text: light-dark(#0f172a, #e2e8f0);
15
+ --gfa-muted: light-dark(#64748b, #94a3b8);
16
+ --gfa-border: light-dark(#e2e8f0, #1f2937);
17
+ --gfa-input-border: light-dark(#cbd5e1, #475569);
18
+ --gfa-accent: light-dark(#2563eb, #60a5fa);
19
+ --gfa-accent-text: light-dark(#ffffff, #0b1120);
20
+ --gfa-current-bg: light-dark(#eff6ff, #172554);
21
+ --gfa-danger: light-dark(#dc2626, #f87171);
22
+ --gfa-danger-border: light-dark(#fecaca, #7f1d1d);
23
+ --gfa-success-bg: light-dark(#ecfdf5, #052e1c);
24
+ --gfa-success-text: light-dark(#065f46, #6ee7b7);
25
+ --gfa-success-border: light-dark(#a7f3d0, #065f46);
26
+ --gfa-published-bg: light-dark(#047857, #34d399);
27
+ --gfa-published-text: light-dark(#ffffff, #022c22);
28
+ --gfa-error-bg: light-dark(#fef2f2, #3b0d0d);
29
+ --gfa-error-text: light-dark(#991b1b, #fca5a5);
30
+ --gfa-warning-bg: light-dark(#fef3c7, #451a03);
31
+ --gfa-warning-text: light-dark(#92400e, #fde68a);
32
+ --gfa-demo-bg: light-dark(#fef9c3, #422006);
33
+ --gfa-demo-border: light-dark(#fde68a, #854d0e);
34
+ --gfa-demo-text: light-dark(#713f12, #fde68a);
35
+ --gfa-draft-bg: light-dark(#eff6ff, #172554);
36
+ --gfa-draft-border: light-dark(#bfdbfe, #1e40af);
37
+ --gfa-draft-text: light-dark(#1e3a8a, #dbeafe);
38
+ --gfa-checker: light-dark(#f1f5f9, #1e293b);
39
+ --gfa-checker-alt: light-dark(#ffffff, #0f172a);
40
+ --gfa-tooltip-bg: light-dark(#0f172a, #e2e8f0);
41
+ --gfa-tooltip-text: light-dark(#f8fafc, #0b1120);
42
+ --gfa-radius: 6px;
43
+ --gfa-topbar-height: 52px;
44
+ --gfa-code-active-line: light-dark(rgb(37 99 235 / 0.06), rgb(96 165 250 / 0.08));
45
+ --gfa-code-selection: light-dark(rgb(37 99 235 / 0.2), rgb(96 165 250 / 0.3));
46
+ }
47
+
48
+ :root[data-gfa-theme="light"] {
49
+ color-scheme: light;
50
+ }
51
+
52
+ :root[data-gfa-theme="dark"] {
53
+ color-scheme: dark;
54
+ }
55
+
56
+ html,
57
+ body {
58
+ margin: 0;
59
+ background: var(--gfa-bg);
60
+ color: var(--gfa-text);
61
+ font:
62
+ 15px / 1.5 ui-sans-serif,
63
+ system-ui,
64
+ -apple-system,
65
+ "Segoe UI",
66
+ sans-serif;
67
+ }
68
+
69
+ /* Spaced as the admin panel's other screens, since a screen's frame fills the screen. */
70
+ body {
71
+ padding: 24px;
72
+ max-width: 960px;
73
+ }
74
+
75
+ /* Nothing in a screen's frame scrolls on its own: the frame grows with it. */
76
+ html {
77
+ overflow: hidden;
78
+ }
79
+
80
+ *,
81
+ *::before,
82
+ *::after {
83
+ box-sizing: border-box;
84
+ }
85
+
86
+ h1,
87
+ h2,
88
+ h3 {
89
+ line-height: 1.25;
90
+ margin: 0 0 0.75rem;
91
+ }
92
+
93
+ h1 {
94
+ font-size: 24px;
95
+ margin-bottom: 16px;
96
+ }
97
+
98
+ h2 {
99
+ font-size: 17px;
100
+ }
101
+
102
+ h3 {
103
+ font-size: 15px;
104
+ margin-top: 1.25rem;
105
+ }
106
+
107
+ section {
108
+ margin-top: 2rem;
109
+ }
110
+
111
+ p {
112
+ margin: 0 0 0.75rem;
113
+ }
114
+
115
+ a {
116
+ color: var(--gfa-accent);
117
+ }
118
+
119
+ label {
120
+ display: block;
121
+ font-weight: 600;
122
+ margin-bottom: 0.25rem;
123
+ }
124
+
125
+ input,
126
+ select,
127
+ textarea {
128
+ font: inherit;
129
+ color: inherit;
130
+ background: var(--gfa-surface);
131
+ border: 1px solid var(--gfa-input-border);
132
+ border-radius: var(--gfa-radius);
133
+ padding: 0.4rem 0.6rem;
134
+ width: 100%;
135
+ max-width: 32rem;
136
+ }
137
+
138
+ button {
139
+ font: inherit;
140
+ font-weight: 600;
141
+ cursor: pointer;
142
+ color: var(--gfa-accent-text);
143
+ background: var(--gfa-accent);
144
+ border: 1px solid var(--gfa-accent);
145
+ border-radius: var(--gfa-radius);
146
+ padding: 0.4rem 0.9rem;
147
+ }
148
+
149
+ button:disabled {
150
+ cursor: default;
151
+ opacity: 0.6;
152
+ }
153
+
154
+ :focus-visible {
155
+ outline: 2px solid var(--gfa-accent);
156
+ outline-offset: 2px;
157
+ }
158
+
159
+ input[type="checkbox"],
160
+ input[type="radio"] {
161
+ width: auto;
162
+ margin: 0 0.4rem 0 0;
163
+ }
164
+
165
+ small,
166
+ .gfa-hint {
167
+ display: block;
168
+ color: var(--gfa-muted);
169
+ font-size: 13px;
170
+ margin-top: 0.25rem;
171
+ max-width: 40rem;
172
+ }
173
+
174
+ ol,
175
+ ul {
176
+ padding-left: 1.25rem;
177
+ margin: 0 0 0.75rem;
178
+ max-width: 44rem;
179
+ }
180
+
181
+ li {
182
+ margin-bottom: 0.5rem;
183
+ }
184
+
185
+ code,
186
+ pre {
187
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
188
+ font-size: 13px;
189
+ }
190
+
191
+ pre {
192
+ margin: 0.4rem 0 0;
193
+ padding: 0.5rem 0.75rem;
194
+ max-width: 40rem;
195
+ overflow-x: auto;
196
+ background: var(--gfa-surface);
197
+ border: 1px solid var(--gfa-border);
198
+ border-radius: var(--gfa-radius);
199
+ }
200
+
201
+ details {
202
+ margin: 0.75rem 0;
203
+ }
204
+
205
+ summary {
206
+ cursor: pointer;
207
+ font-weight: 600;
208
+ }
209
+
210
+ /* An editor plugin's frame: its editor fills the frame, which the admin panel sizes. */
211
+ html.gfa-plugin-editor,
212
+ html.gfa-plugin-editor body,
213
+ html.gfa-plugin-editor #goodfellow-plugin {
214
+ height: 100%;
215
+ }