@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.
package/README.md CHANGED
@@ -4,7 +4,7 @@ The admin panel: the visual editor for a site's pages, collections, media, heade
4
4
 
5
5
  Its editor is built with each site's own config, so it always has the site's blocks, and runs them in frames sandboxed away from the admin panel's page, which signs in and publishes: `goodfellow build` adds the page at `/admin` and the editor at `/admin/editor/`, and Next.js sites render `GoodfellowAdmin` and `GoodfellowEditor` from `@goodfellow-cms/next` there.
6
6
 
7
- [Goodfellow](https://github.com/goodfellow-cms/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub or GitLab repository, edited in a visual editor built on [Puck](https://puckeditor.com). To start a new site, run `npm create goodfellow@latest my-site`. See the [documentation](https://goodfellow-cms.github.io/goodfellow-cms/) for everything else.
7
+ [Goodfellow](https://github.com/goodfellow-cms/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub, GitLab or Bitbucket repository, edited in a visual editor built on [Puck](https://puckeditor.com). To start a new site, run `npm create goodfellow@latest my-site`. See the [documentation](https://goodfellow-cms.github.io/goodfellow-cms/) for everything else.
8
8
 
9
9
  More in the docs: [Edit pages](https://goodfellow-cms.github.io/goodfellow-cms/docs/edit-pages).
10
10
 
package/dist/dev.d.ts CHANGED
@@ -1,8 +1,10 @@
1
- import { ContentStore } from "@goodfellow-cms/core";
1
+ import { ContentStore, PluginTasks } from "@goodfellow-cms/core";
2
2
  //#region src/dev.d.ts
3
3
  /**
4
4
  * A content store that reads and writes the site's files through
5
5
  * `goodfellow dev`. For local development only: it is never part of a built site.
6
6
  */
7
- export declare function localStore(apiBase?: string): ContentStore;
7
+ export declare function localStore(apiBase?: string): ContentStore & {
8
+ tasks: PluginTasks;
9
+ };
8
10
  //#endregion
package/dist/dev.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ConflictError, decodeBase64, encodeBase64Bytes } from "@goodfellow-cms/core";
1
+ import { ConflictError, PluginTaskError, decodeBase64, encodeBase64Bytes } from "@goodfellow-cms/core";
2
2
  //#region src/dev.ts
3
3
  /**
4
4
  * A content store that reads and writes the site's files through
@@ -53,6 +53,26 @@ function localStore(apiBase = "/__goodfellow/api") {
53
53
  expectedRevision
54
54
  })
55
55
  })).json();
56
+ },
57
+ tasks: {
58
+ async supported() {
59
+ const response = await request("task", {}, true).catch(() => void 0);
60
+ return response?.ok === true && (await response.json()).supported === true;
61
+ },
62
+ async secretsPlace() {
63
+ const response = await request("task", {}, true).catch(() => void 0);
64
+ if (!response?.ok) return void 0;
65
+ return (await response.json()).place;
66
+ },
67
+ async run(task) {
68
+ return (await (await request("task", {
69
+ method: "POST",
70
+ headers: { "content-type": "application/json" },
71
+ body: JSON.stringify(task)
72
+ }).catch((error) => {
73
+ throw new PluginTaskError("failed", error.message);
74
+ })).json()).result;
75
+ }
56
76
  }
57
77
  };
58
78
  }
package/dist/editor.js CHANGED
@@ -1,9 +1,10 @@
1
- import { A as MediaChoiceContext, Ft as editedMarkdown, It as formattedHtml, Mt as storedEntryFields, O as createMediaPreviews, Q as StringsProvider, U as createChannel, V as READY_MESSAGE, a as AdminContext, at as mediaUrl, f as useDarkMode, h as ScheduleFields, i as usePreviewStyles, j as MediaChooser, jt as storedData, k as showMediaInPreview, l as useSiteContent, rt as findPage, s as useAdmin, t as SCHEDULE_PROP, tt as useStrings, u as setThemeChoice, y as SETTINGS_SECTIONS } from "./entry-values-ocbalc9U.js";
2
- import { COLOR_SCHEME_ATTRIBUTE, MEDIA_DIR, WEEKDAYS, allPages, canFormatMarkdown, cleanEventValue, contentInLanguage, datePart, escapeStyleText, findEntry, formatDate, formatOccurrence, getPageHead, googleFontsUrl, hasLanguages, isAllDay, isEventLike, languageHome, languageLinks, mainLanguage, neverIndexed, pageView, pathLanguage, renderedProblems, shownOccurrence, sitePages, themeToCss, todayIn, wallClock, weekdayName, weekdayOf } from "@goodfellow-cms/core";
1
+ import { Ft as storedEntryFields, G as READY_MESSAGE, Pt as storedData, R as createMediaPreviews, Rt as editedMarkdown, a as AdminContext, at as findPage, b as MediaChooser, ct as mediaUrl, et as StringsProvider, f as useDarkMode, h as ScheduleFields, i as usePreviewStyles, l as useSiteContent, rt as useStrings, s as useAdmin, t as SCHEDULE_PROP, u as setThemeChoice, w as SETTINGS_SECTIONS, y as MediaChoiceContext, z as showMediaInPreview, zt as formattedHtml } from "./entry-values-BrY3L0Ai.js";
2
+ import { r as createChannel } from "./plugin-protocol-DkvBDqsF.js";
3
+ import { COLOR_SCHEME_ATTRIBUTE, DRAWN_SLOT_ELEMENT, MEDIA_DIR, WEEKDAYS, allPages, canFormatMarkdown, cleanEventValue, contentInLanguage, datePart, escapeStyleText, findEntry, formatDate, formatOccurrence, getPageHead, googleFontsUrl, hasLanguages, isAllDay, isEventLike, languageHome, languageLinks, mainLanguage, neverIndexed, pageLinks, pageView, pathLanguage, renderedProblems, shownOccurrence, sitePages, themeToCss, todayIn, wallClock, weekdayName, weekdayOf } from "@goodfellow-cms/core";
3
4
  import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
4
5
  import { createRoot } from "react-dom/client";
5
6
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
- import { PageBody, SiteProvider, adminSite, applyEntry, createPuckConfig, createPuckConfigs, cx, mediaFieldKind, preparePage, siteMetadata } from "@goodfellow-cms/react";
7
+ import { PageBody, PageContent, SiteProvider, adminSite, applyEntry, createPuckConfig, createPuckConfigs, cx, isTableField, mediaFieldKind, preparePage, siteMetadata, tableCells } from "@goodfellow-cms/react";
7
8
  import { AutoField, FieldLabel, Puck, Render, RichTextMenu, createUsePuck, legacySideBarPlugin, migrate, registerOverlayPortal, resolveAllData, useGetPuck } from "@puckeditor/core";
8
9
  import { createPortal, flushSync } from "react-dom";
9
10
  const CANVAS = `.gf-canvas`;
@@ -907,6 +908,7 @@ function EntryPreview({ collection, entry, content, templateConfig, layoutConfig
907
908
  menus: content.menus,
908
909
  path: entry.path ?? "/",
909
910
  collections: content.collections,
911
+ pages: pageLinks(content),
910
912
  collection,
911
913
  entry: current
912
914
  }), [
@@ -999,9 +1001,76 @@ function entryConfig(collection, entry, content, configs) {
999
1001
  * The site's code, run in the page that has loaded it: the frame that runs
1000
1002
  * it for the admin panel, or tests. Never imported by the admin panel's own page.
1001
1003
  */
1004
+ /** The block drawn in each slot's place, which only drawing has: its name can't be one of a site's. */
1005
+ const DRAWN_SLOT_BLOCK = "__goodfellow_drawn_slot__";
1006
+ /** A block that draws nothing but an empty `DRAWN_SLOT_ELEMENT` with its slot's name. */
1007
+ const drawnSlot = {
1008
+ fields: { slot: { type: "text" } },
1009
+ render: ({ slot }) => createElement(DRAWN_SLOT_ELEMENT, { "data-slot": String(slot ?? "") })
1010
+ };
1011
+ /** The block as it's drawn: each of its slots holding only the block that marks its place. */
1012
+ function withSlotPlaces(block, config) {
1013
+ const fields = config?.fields ?? {};
1014
+ const places = Object.entries(fields).filter(([, field]) => field.type === "slot").map(([slot]) => [slot, [{
1015
+ type: DRAWN_SLOT_BLOCK,
1016
+ props: {
1017
+ id: `${String(block.props.id)}:${slot}`,
1018
+ slot
1019
+ }
1020
+ }]]);
1021
+ return {
1022
+ ...block,
1023
+ props: {
1024
+ ...block.props,
1025
+ ...Object.fromEntries(places)
1026
+ }
1027
+ };
1028
+ }
1029
+ /** One block drawn as the site draws it, in the page at its address, as `drawBlock()` describes. */
1030
+ async function drawBlock(configs, content, { kind, path, block }) {
1031
+ const layout = kind === "header" || kind === "footer";
1032
+ const config = layout ? configs.layout : kind === "template" ? configs.template : configs.page;
1033
+ if (!config.components[block.type]) throw new Error(`The site has no block called ${block.type} here.`);
1034
+ const found = allPages(content).find((page) => page.path === path);
1035
+ const root = layout ? (kind === "header" ? content.header : content.footer).data.root : found?.content.data.root;
1036
+ const page = {
1037
+ path,
1038
+ file: found?.file ?? "",
1039
+ content: {
1040
+ ...found?.content,
1041
+ version: found?.content.version ?? 1,
1042
+ data: {
1043
+ root: root ?? { props: {} },
1044
+ content: [withSlotPlaces(block, config.components[block.type])]
1045
+ }
1046
+ }
1047
+ };
1048
+ const prepared = await preparePage({
1049
+ ...configs,
1050
+ page: config
1051
+ }, content, page);
1052
+ const container = document.createElement("div");
1053
+ const reactRoot = createRoot(container);
1054
+ try {
1055
+ flushSync(() => reactRoot.render(createElement(PageContent, {
1056
+ site: {
1057
+ ...prepared.site,
1058
+ credits: []
1059
+ },
1060
+ pageConfig: prepared.pageConfig,
1061
+ page: prepared.data
1062
+ })));
1063
+ const main = container.querySelector("main");
1064
+ const zone = main?.children.length === 1 ? main.firstElementChild : null;
1065
+ return (zone?.tagName === "DIV" && zone.attributes.length === 0 ? zone : main)?.innerHTML ?? "";
1066
+ } finally {
1067
+ reactRoot.unmount();
1068
+ }
1069
+ }
1002
1070
  /** The site's code run here, in the page that has loaded it: the frame, or tests. */
1003
1071
  function localSiteCode(config) {
1004
1072
  const configs = createPuckConfigs(config);
1073
+ let drawing;
1005
1074
  return {
1006
1075
  async sitePages(content, today) {
1007
1076
  return sitePages(content, today, config.blocks);
@@ -1039,6 +1108,17 @@ function localSiteCode(config) {
1039
1108
  root.unmount();
1040
1109
  }
1041
1110
  return problems;
1111
+ },
1112
+ async drawBlock(content, target) {
1113
+ if (typeof document === "undefined") throw new Error("Blocks are drawn only in a browser.");
1114
+ drawing ??= createPuckConfigs({
1115
+ ...config,
1116
+ blocks: {
1117
+ ...config.blocks,
1118
+ [DRAWN_SLOT_BLOCK]: drawnSlot
1119
+ }
1120
+ });
1121
+ return drawBlock(drawing, content, target);
1042
1122
  }
1043
1123
  };
1044
1124
  }
@@ -1070,11 +1150,11 @@ function useAdminClient() {
1070
1150
  return value;
1071
1151
  }
1072
1152
  /** Media from the admin panel's page, which reads files the live site doesn't have yet from the repository. */
1073
- function frameMediaPreviews(client, siteUrl) {
1153
+ function frameMediaPreviews(client, siteUrl, storage = () => void 0) {
1074
1154
  return createMediaPreviews({ readBytes: async (path) => {
1075
1155
  if (!path.startsWith(`${MEDIA_DIR}/`)) return void 0;
1076
1156
  return await client.call("media.bytes", mediaUrl(path)) ?? void 0;
1077
- } }, siteUrl);
1157
+ } }, siteUrl, storage);
1078
1158
  }
1079
1159
  function refused() {
1080
1160
  throw new Error("The site's frame can't do this.");
@@ -1089,7 +1169,9 @@ function FrameProviders({ config, client, init, site, children }) {
1089
1169
  code: localSiteCode(config)
1090
1170
  }), [config]);
1091
1171
  const described = useMemo(() => adminSite(config), [config]);
1092
- const mediaPreviews = useMemo(() => frameMediaPreviews(client, init.siteUrl), [client, init.siteUrl]);
1172
+ const storage = useRef(site.content.mediaStorage);
1173
+ storage.current = site.content.mediaStorage;
1174
+ const mediaPreviews = useMemo(() => frameMediaPreviews(client, init.siteUrl, () => storage.current), [client, init.siteUrl]);
1093
1175
  useEffect(() => client.channel.handle("media.remember", (file) => {
1094
1176
  if (typeof file?.url === "string" && file.bytes instanceof Uint8Array) mediaPreviews.remember(file.url, file.bytes);
1095
1177
  }), [client, mediaPreviews]);
@@ -1124,6 +1206,7 @@ function FrameProviders({ config, client, init, site, children }) {
1124
1206
  },
1125
1207
  readFile: async () => refused(),
1126
1208
  listFiles: async () => refused(),
1209
+ readBytes: async () => refused(),
1127
1210
  registries: {},
1128
1211
  outOfReach: false,
1129
1212
  contentOnly: false,
@@ -1221,6 +1304,119 @@ function shownSettings(original, path) {
1221
1304
  };
1222
1305
  }
1223
1306
  //#endregion
1307
+ //#region src/frame/table-field.tsx
1308
+ /**
1309
+ * The grid of cells a table's field is edited in (`tableField()` in
1310
+ * `@goodfellow-cms/react`), which Puck's editor shows in place of the field's
1311
+ * own render, since blocks can't bring their own editing code.
1312
+ */
1313
+ const MAX_ROWS = 100;
1314
+ const MAX_COLUMNS = 12;
1315
+ function TableCellsField({ value, onChange, readOnly, label }) {
1316
+ const t = useStrings();
1317
+ const rows = tableCells(value);
1318
+ const width = rows[0]?.length ?? 1;
1319
+ const set = (row, column, text) => onChange(rows.map((cells, r) => r === row ? cells.map((cell, c) => c === column ? text : cell) : cells));
1320
+ return /* @__PURE__ */ jsxs("div", {
1321
+ className: "gfa-table-field",
1322
+ children: [
1323
+ label && /* @__PURE__ */ jsx("div", {
1324
+ className: "gfa-table-field-label",
1325
+ children: label
1326
+ }),
1327
+ /* @__PURE__ */ jsxs("div", {
1328
+ className: "gfa-table-field-grid",
1329
+ style: { gridTemplateColumns: `repeat(${width}, minmax(6rem, 1fr)) auto` },
1330
+ children: [rows.map((cells, row) => /* @__PURE__ */ jsxs("div", {
1331
+ className: "gfa-table-field-row",
1332
+ children: [cells.map((cell, column) => /* @__PURE__ */ jsx("input", {
1333
+ className: "gfa-input gfa-table-field-cell",
1334
+ value: cell,
1335
+ readOnly,
1336
+ "aria-label": t("table.cell", {
1337
+ row: String(row + 1),
1338
+ column: String(column + 1)
1339
+ }),
1340
+ onChange: (event) => set(row, column, event.target.value)
1341
+ }, column)), /* @__PURE__ */ jsx("button", {
1342
+ type: "button",
1343
+ className: "gfa-table-field-remove",
1344
+ disabled: readOnly || rows.length <= 1,
1345
+ title: t("table.removeRow"),
1346
+ "aria-label": t("table.removeRowNumber", { row: String(row + 1) }),
1347
+ onClick: () => onChange(rows.filter((_, r) => r !== row)),
1348
+ children: "×"
1349
+ })]
1350
+ }, row)), /* @__PURE__ */ jsx("div", {
1351
+ className: "gfa-table-field-row",
1352
+ children: Array.from({ length: width }, (_, column) => /* @__PURE__ */ jsx("button", {
1353
+ type: "button",
1354
+ className: "gfa-table-field-remove",
1355
+ disabled: readOnly || width <= 1,
1356
+ title: t("table.removeColumn"),
1357
+ "aria-label": t("table.removeColumnNumber", { column: String(column + 1) }),
1358
+ onClick: () => onChange(rows.map((cells) => cells.filter((_, c) => c !== column))),
1359
+ children: "×"
1360
+ }, column))
1361
+ })]
1362
+ }),
1363
+ /* @__PURE__ */ jsxs("div", {
1364
+ className: "gfa-table-field-actions",
1365
+ children: [/* @__PURE__ */ jsx("button", {
1366
+ type: "button",
1367
+ className: "gfa-button gfa-button-secondary",
1368
+ disabled: readOnly || rows.length >= MAX_ROWS,
1369
+ onClick: () => onChange([...rows, Array(width).fill("")]),
1370
+ children: t("table.addRow")
1371
+ }), /* @__PURE__ */ jsx("button", {
1372
+ type: "button",
1373
+ className: "gfa-button gfa-button-secondary",
1374
+ disabled: readOnly || width >= MAX_COLUMNS,
1375
+ onClick: () => onChange(rows.map((cells) => [...cells, ""])),
1376
+ children: t("table.addColumn")
1377
+ })]
1378
+ })
1379
+ ]
1380
+ });
1381
+ }
1382
+ function withTableField(field) {
1383
+ if (field.type === "custom" && isTableField(field)) {
1384
+ const render = ({ value, onChange, readOnly, field: own }) => /* @__PURE__ */ jsx(TableCellsField, {
1385
+ value,
1386
+ onChange,
1387
+ readOnly,
1388
+ label: own.label
1389
+ });
1390
+ return {
1391
+ ...field,
1392
+ render
1393
+ };
1394
+ }
1395
+ if (field.type === "array") return {
1396
+ ...field,
1397
+ arrayFields: withTableFieldsIn(field.arrayFields)
1398
+ };
1399
+ if (field.type === "object") return {
1400
+ ...field,
1401
+ objectFields: withTableFieldsIn(field.objectFields)
1402
+ };
1403
+ return field;
1404
+ }
1405
+ function withTableFieldsIn(fields) {
1406
+ return Object.fromEntries(Object.entries(fields ?? {}).map(([name, field]) => [name, withTableField(field)]));
1407
+ }
1408
+ /** The config with every table's field edited as a grid of cells. */
1409
+ function withTableFields(config) {
1410
+ const components = Object.fromEntries(Object.entries(config.components).map(([name, component]) => [name, component.fields ? {
1411
+ ...component,
1412
+ fields: withTableFieldsIn(component.fields)
1413
+ } : component]));
1414
+ return {
1415
+ ...config,
1416
+ components
1417
+ };
1418
+ }
1419
+ //#endregion
1224
1420
  //#region src/frame/editor.tsx
1225
1421
  /**
1226
1422
  * Puck in the site's frame, for one page, header, footer, template or item.
@@ -1489,18 +1685,25 @@ function FrameEditor({ open, onOpened }) {
1489
1685
  }
1490
1686
  };
1491
1687
  }, [client]);
1688
+ const callPlugin = useCallback((plugin, method, ...args) => client.call("plugin.call", {
1689
+ plugin,
1690
+ method,
1691
+ args
1692
+ }), [client]);
1492
1693
  const site = useMemo(() => ({
1493
1694
  settings: content.settings,
1494
1695
  menus: content.menus,
1495
1696
  path,
1496
1697
  collections: content.collections,
1698
+ pages: pageLinks(content),
1497
1699
  collection,
1498
1700
  ...view && { view },
1499
1701
  ...languages && {
1500
1702
  languages,
1501
1703
  home: languageHome(loaded, language)
1502
1704
  },
1503
- adminLink
1705
+ adminLink,
1706
+ callPlugin
1504
1707
  }), [
1505
1708
  content,
1506
1709
  loaded,
@@ -1509,7 +1712,8 @@ function FrameEditor({ open, onOpened }) {
1509
1712
  collection,
1510
1713
  view,
1511
1714
  languages,
1512
- adminLink
1715
+ adminLink,
1716
+ callPlugin
1513
1717
  ]);
1514
1718
  const entry = kind === "entry" && collection && open.entry ? findEntry(loaded, collection.id, open.entry) : void 0;
1515
1719
  const config = useMemo(() => {
@@ -1520,7 +1724,7 @@ function FrameEditor({ open, onOpened }) {
1520
1724
  layoutConfig
1521
1725
  });
1522
1726
  }
1523
- return canvasConfig(kind, kind === "page" ? pageConfig : kind === "template" ? templateConfig : layoutConfig, layoutConfig, content.header.data, content.footer.data, site);
1727
+ return withTableFields(canvasConfig(kind, kind === "page" ? pageConfig : kind === "template" ? templateConfig : layoutConfig, layoutConfig, content.header.data, content.footer.data, site));
1524
1728
  }, [
1525
1729
  kind,
1526
1730
  collection,
@@ -1676,7 +1880,8 @@ function CodeService({ channel }) {
1676
1880
  const stops = [
1677
1881
  channel.handle("code.sitePages", ({ content, today }) => code.sitePages(content, today)),
1678
1882
  channel.handle("code.pageView", async ({ page, content, today }) => await code.pageView(page, content, today) ?? null),
1679
- channel.handle("code.renderedProblems", ({ content, targets, today }) => code.renderedProblems(content, targets, today))
1883
+ channel.handle("code.renderedProblems", ({ content, targets, today }) => code.renderedProblems(content, targets, today)),
1884
+ channel.handle("code.drawBlock", ({ content, target }) => code.drawBlock(content, target))
1680
1885
  ];
1681
1886
  return () => {
1682
1887
  for (const stop of stops) stop();