@goodfellow-cms/admin 0.8.0 → 0.9.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
@@ -6,6 +6,8 @@ Its editor is built with each site's own config, so it always has the site's blo
6
6
 
7
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
+ Language packs' own checks read the admin panel's English from `@goodfellow-cms/admin/text`, which draws nothing, as [Goodfellow's French pack](https://github.com/goodfellow-cms/goodfellow-language-fr) does.
10
+
9
11
  More in the docs: [Edit pages](https://goodfellow-cms.github.io/goodfellow-cms/docs/edit-pages).
10
12
 
11
13
  ## License
@@ -0,0 +1,273 @@
1
+ import { n as defaultStrings } from "./strings-D94noZrv.js";
2
+ import { adminTextProblem, englishHash } from "@goodfellow-cms/core";
3
+ //#region src/languages/en-puck.ts
4
+ /**
5
+ * Puck's own English for the text Goodfellow translates, as its built-in
6
+ * dictionary has it (Puck doesn't export it), so the admin panel can have
7
+ * Puck's text translated for a site's own language, and check placeholders
8
+ * against it. A test keeps its keys the same as the Spanish table's.
9
+ */
10
+ const PUCK_ENGLISH = {
11
+ "header-publish": "Publish",
12
+ "header-undo": "undo",
13
+ "header-redo": "redo",
14
+ "header-toggle-leftsidebar": "Toggle left sidebar",
15
+ "header-toggle-rightsidebar": "Toggle right sidebar",
16
+ "header-toggle-menubar": "Toggle menu bar",
17
+ "action-selectparent": "Select parent",
18
+ "action-duplicate": "Duplicate",
19
+ "action-delete": "Delete",
20
+ "label-page": "Page",
21
+ "label-component": "Component",
22
+ "outline-empty": "No items",
23
+ "outline-item-collapse": "Collapse",
24
+ "outline-item-expand": "Expand",
25
+ "outline-header-title": "Outline",
26
+ "outline-header-collapseall": "Collapse all",
27
+ "outline-item-duplicate": "Duplicate",
28
+ "outline-item-delete": "Delete",
29
+ "drawer-category-collapse": "Collapse {title}",
30
+ "drawer-category-expand": "Expand {title}",
31
+ "drawer-category-other": "Other",
32
+ "canvas-noconfig": "No configuration for {type}",
33
+ "field-readonly": "Read-only",
34
+ "field-arrayitem-summary": "Item #{index}",
35
+ "field-arrayitem-duplicate": "Duplicate",
36
+ "field-arrayitem-delete": "Delete",
37
+ "field-external-selectdata": "Select data",
38
+ "field-external-search": "Search",
39
+ "field-external-togglefilters": "Toggle filters",
40
+ "field-external-item": "External item",
41
+ "field-external-result-singular": "{count} result",
42
+ "field-external-result-plural": "{count} results",
43
+ "field-richtext-bold": "Bold",
44
+ "field-richtext-italic": "Italic",
45
+ "field-richtext-underline": "Underline",
46
+ "field-richtext-strikethrough": "Strikethrough",
47
+ "field-richtext-blockquote": "Blockquote",
48
+ "field-richtext-code-inline": "Inline code",
49
+ "field-richtext-code-block": "Code block",
50
+ "field-richtext-list-bullet": "Bullet list",
51
+ "field-richtext-list-ordered": "Ordered list",
52
+ "field-richtext-horizontalrule": "Horizontal rule",
53
+ "field-richtext-align-left": "Align left",
54
+ "field-richtext-align-center": "Align center",
55
+ "field-richtext-align-right": "Align right",
56
+ "field-richtext-align-justify": "Justify",
57
+ "field-richtext-select": "Select",
58
+ "field-richtext-headingselect-1": "Heading 1",
59
+ "field-richtext-headingselect-2": "Heading 2",
60
+ "field-richtext-headingselect-3": "Heading 3",
61
+ "field-richtext-headingselect-4": "Heading 4",
62
+ "field-richtext-headingselect-5": "Heading 5",
63
+ "field-richtext-headingselect-6": "Heading 6",
64
+ "field-richtext-alignselect-left": "Left",
65
+ "field-richtext-alignselect-center": "Center",
66
+ "field-richtext-alignselect-right": "Right",
67
+ "field-richtext-alignselect-justify": "Justify",
68
+ "field-richtext-listselect-bullet": "Bullet list",
69
+ "field-richtext-listselect-ordered": "Ordered list",
70
+ "viewport-zoom-in": "Zoom viewport in",
71
+ "viewport-zoom-out": "Zoom viewport out",
72
+ "viewport-zoom-auto": "{zoom}% (Auto)",
73
+ "viewport-toggle-menu": "Toggle viewport menu",
74
+ "viewport-switch": "Switch to {label} viewport",
75
+ "viewport-switch-default": "Switch viewport",
76
+ "plugin-blocks": "Blocks",
77
+ "plugin-outline": "Outline",
78
+ "plugin-fields": "Fields",
79
+ "plugin-components": "Components",
80
+ "layout-maximize": "maximize",
81
+ "layout-minimize": "minimize",
82
+ "loader-loading": "loading"
83
+ };
84
+ //#endregion
85
+ //#region src/admin-text-sources.ts
86
+ const TABLES = [
87
+ "strings",
88
+ "puck",
89
+ "labels"
90
+ ];
91
+ /** A source's key in one list of every table's: the table, a colon, then its own key, since labels' keys are words. */
92
+ function sourceKey(table, key) {
93
+ return `${table}:${key}`;
94
+ }
95
+ function splitKey(key) {
96
+ const colon = key.indexOf(":");
97
+ const table = key.slice(0, colon);
98
+ return TABLES.includes(table) ? {
99
+ table,
100
+ key: key.slice(colon + 1)
101
+ } : void 0;
102
+ }
103
+ /** Every label a block's fields show, as `withLabels()` puts them in another language: settings, choices and placeholders. */
104
+ function fieldLabels(fields, found) {
105
+ for (const field of Object.values(fields)) {
106
+ const described = field ?? {};
107
+ for (const name of ["label", "placeholder"]) {
108
+ const value = described[name];
109
+ if (typeof value === "string" && value.trim()) found.add(value);
110
+ }
111
+ if (Array.isArray(described.options)) {
112
+ for (const option of described.options) if (typeof option?.label === "string" && option.label.trim()) found.add(option.label);
113
+ }
114
+ for (const nested of ["arrayFields", "objectFields"]) {
115
+ const inner = described[nested];
116
+ if (inner && typeof inner === "object") fieldLabels(inner, found);
117
+ }
118
+ }
119
+ }
120
+ /** The labels of the site's blocks and its page settings, from its description, with any more given (such as groups'). */
121
+ function labelSources(description, more = []) {
122
+ const found = new Set(more);
123
+ for (const block of Object.values(description.blocks)) {
124
+ if (block.label?.trim()) found.add(block.label);
125
+ fieldLabels(block.fields, found);
126
+ }
127
+ fieldLabels(description.root.fields, found);
128
+ return [...found].sort((a, b) => a.localeCompare(b));
129
+ }
130
+ /** Everything the admin panel says in English, from every table, keyed with `sourceKey()`. */
131
+ function adminTextSources(labels) {
132
+ return [
133
+ ...Object.entries(defaultStrings).map(([key, english]) => ({
134
+ key: sourceKey("strings", key),
135
+ english,
136
+ table: "strings"
137
+ })),
138
+ ...Object.entries(PUCK_ENGLISH).map(([key, english]) => ({
139
+ key: sourceKey("puck", key),
140
+ english,
141
+ table: "puck"
142
+ })),
143
+ ...labels.map((english) => ({
144
+ key: sourceKey("labels", english),
145
+ english,
146
+ table: "labels"
147
+ }))
148
+ ];
149
+ }
150
+ /** A string's entry in a language's file, by `sourceKey()`. */
151
+ function entryOf(file, key) {
152
+ const split = splitKey(key);
153
+ if (!file || !split) return void 0;
154
+ const table = file[split.table];
155
+ return Object.hasOwn(table, split.key) ? table[split.key] : void 0;
156
+ }
157
+ /**
158
+ * Where a string stands in a language's file: not there, there but not
159
+ * usable (`adminTextProblem()`), made from English that has changed since,
160
+ * or current. Labels are keyed by their English, so they can't be outdated.
161
+ */
162
+ function sourceState(file, source) {
163
+ const entry = entryOf(file, source.key);
164
+ if (!entry) return "missing";
165
+ if (adminTextProblem(entry.text, source.english)) return "unusable";
166
+ if (source.table !== "labels" && entry.source !== void 0 && entry.source !== englishHash(source.english)) return "outdated";
167
+ return "current";
168
+ }
169
+ /** How much of the admin panel a language's file says. */
170
+ function languageStatus(file, sources) {
171
+ const status = {
172
+ total: sources.length,
173
+ current: 0,
174
+ outdated: 0,
175
+ missing: 0,
176
+ unusable: 0,
177
+ reviewed: 0
178
+ };
179
+ for (const source of sources) {
180
+ const state = sourceState(file, source);
181
+ status[state] += 1;
182
+ if (state === "current" && entryOf(file, source.key)?.reviewed) status.reviewed += 1;
183
+ }
184
+ return status;
185
+ }
186
+ /** The strings to translate: those missing or unusable, and with `outdated`, those made from English that has changed. */
187
+ function sourcesToTranslate(file, sources, options = {}) {
188
+ return sources.filter((source) => {
189
+ const state = sourceState(file, source);
190
+ return state === "missing" || state === "unusable" || options.outdated === true && state === "outdated";
191
+ });
192
+ }
193
+ /**
194
+ * A language's file with new translations in it, by `sourceKey()`, each with
195
+ * the hash of the English it was made from, and not marked reviewed, since
196
+ * no one has read it yet. What the file had besides is kept as it was.
197
+ */
198
+ function withTranslations(file, name, translated, sources) {
199
+ const next = {
200
+ version: 1,
201
+ name: file?.name ?? name,
202
+ strings: { ...file?.strings },
203
+ puck: { ...file?.puck },
204
+ labels: { ...file?.labels },
205
+ ...file?.pack && { pack: file.pack }
206
+ };
207
+ const english = new Map(sources.map((source) => [source.key, source.english]));
208
+ for (const [key, text] of translated) {
209
+ const split = splitKey(key);
210
+ const source = english.get(key);
211
+ if (!split || source === void 0) continue;
212
+ const pack = entryOf(file, key)?.pack;
213
+ next[split.table][split.key] = {
214
+ text,
215
+ source: englishHash(source),
216
+ ...pack && { pack }
217
+ };
218
+ }
219
+ return next;
220
+ }
221
+ /**
222
+ * The strings an editor of a language's file shows: those the filter keeps
223
+ * whose English, key or text in the file holds every word searched for,
224
+ * ignoring case.
225
+ */
226
+ function filterSources(file, sources, options = {}) {
227
+ const words = (options.query ?? "").toLocaleLowerCase().split(/\s+/).filter(Boolean);
228
+ const show = options.show ?? "all";
229
+ return sources.filter((source) => {
230
+ const state = sourceState(file, source);
231
+ const entry = entryOf(file, source.key);
232
+ if (show === "unreviewed" ? state !== "current" || entry?.reviewed : show !== "all" && state !== show) return false;
233
+ const haystack = `${source.key} ${source.english} ${entry?.text ?? ""}`.toLocaleLowerCase();
234
+ return words.every((word) => haystack.includes(word));
235
+ });
236
+ }
237
+ /**
238
+ * A language's file with an owner's edits in it, by `sourceKey()`. Text
239
+ * written or read against today's English is current, so it gets today's
240
+ * English's hash; empty text takes the string out of the file, so it shows
241
+ * the default again.
242
+ */
243
+ function withEdits(file, name, edits, sources) {
244
+ const next = {
245
+ version: 1,
246
+ name: file?.name ?? name,
247
+ strings: { ...file?.strings },
248
+ puck: { ...file?.puck },
249
+ labels: { ...file?.labels },
250
+ ...file?.pack && { pack: file.pack }
251
+ };
252
+ const english = new Map(sources.map((source) => [source.key, source.english]));
253
+ for (const [key, edit] of edits) {
254
+ const split = splitKey(key);
255
+ const source = english.get(key);
256
+ if (!split || source === void 0) continue;
257
+ const text = edit.text.trim();
258
+ if (!text) {
259
+ delete next[split.table][split.key];
260
+ continue;
261
+ }
262
+ const pack = entryOf(file, key)?.pack;
263
+ next[split.table][split.key] = {
264
+ text,
265
+ source: englishHash(source),
266
+ ...edit.reviewed && { reviewed: true },
267
+ ...pack && { pack }
268
+ };
269
+ }
270
+ return next;
271
+ }
272
+ //#endregion
273
+ export { languageStatus as a, sourcesToTranslate as c, PUCK_ENGLISH as d, labelSources as i, withEdits as l, entryOf as n, sourceKey as o, filterSources as r, sourceState as s, adminTextSources as t, withTranslations as u };
@@ -1,4 +1,4 @@
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";
1
+ import { MAX_UPLOAD_BYTES, MEDIA_DIR, addressPatternFor, checkEntrySlug, checkPageAddress, cleanEntryValues, codeFileChanges, collectionFileChange, collectionFileChange as collectionFileChange$1, customCssFileChange, deleteCollectionChanges, deleteSyncedBlockChanges, duplicatePageData, entryFileChange, entryFileChange as entryFileChange$1, entryLanguage, entryPathIn, entryTitle, extensionOf, fileName as fileName$1, itemLinkRemovalChanges, layoutFileChange, linkedItems, 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, syncedBlockFileChange, trimChars, uniqueName, updateSettingsFileChange, uploadName, withoutMediaDetails } from "@goodfellow-cms/core";
2
2
  import TurndownService from "turndown";
3
3
  import { gfm } from "turndown-plugin-gfm";
4
4
  //#region src/markdown-text.ts
@@ -72,6 +72,15 @@ function keptValue(fields, name, value) {
72
72
  if (tags.length === value.length) return value;
73
73
  return tags.length > 0 ? tags : void 0;
74
74
  }
75
+ if (field.type === "item") {
76
+ const names = linkedItems(field, value);
77
+ if (field.multiple) return Array.isArray(value) ? value : names.length > 0 ? names : void 0;
78
+ return typeof value === "string" ? value : names[0];
79
+ }
80
+ if (field.type === "group" && Array.isArray(value)) {
81
+ const rows = cleanEntryValues([field], { [name]: value })[name];
82
+ return JSON.stringify(rows) === JSON.stringify(value) ? value : rows;
83
+ }
75
84
  return value;
76
85
  }
77
86
  /** Where each item's page goes when its collection's address patterns change, in each of the site's languages. */
@@ -166,17 +175,25 @@ function formatSize(bytes) {
166
175
  }
167
176
  /** Where a media file is used, by name, so editors know what deleting or replacing it affects. */
168
177
  function mediaUsage(content, url, names) {
169
- const uses = (value) => JSON.stringify(value).includes(url);
178
+ const escaped = url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
179
+ const address = new RegExp(`(?<![\\w.~%/:-])${escaped}(?![\\w~%-]|[./][\\w])`);
180
+ const uses = (value) => value !== void 0 && address.test(JSON.stringify(value));
170
181
  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);
182
+ const add = (name, value) => {
183
+ if (name && !used.includes(name) && uses(value)) used.push(name);
184
+ };
185
+ add(names.settings, content.settings);
186
+ add(names.menus, [content.menus, content.languageMenus]);
187
+ add(names.header, [content.header, Object.values(content.languageLayouts ?? {}).map((layout) => layout.header)]);
188
+ add(names.footer, [content.footer, Object.values(content.languageLayouts ?? {}).map((layout) => layout.footer)]);
174
189
  for (const page of content.pages) if (uses(page.content)) used.push(pageTitle$1(page));
175
190
  for (const collection of content.collections) {
176
191
  if (uses(collection.settings)) used.push(collection.settings.name);
177
192
  for (const entry of collection.entries) if (uses(entry.content)) used.push(entryTitle(entry));
178
193
  }
179
- if (content.customCss.includes(url)) used.push(names.css);
194
+ add(names.css, content.customCss);
195
+ add(names.code, content.code);
196
+ add(names.redirects, content.redirects);
180
197
  return used;
181
198
  }
182
199
  /** The addresses of the site's uploaded images, such as `/media/photo.jpg`. */
@@ -455,4 +472,55 @@ function showMediaInPreview(doc, previews, { keep } = {}) {
455
472
  /** A plugin's frame sends this to the admin panel's page once it's listening, and the page answers with a port. */
456
473
  const PLUGIN_READY_MESSAGE = "goodfellow:plugin-ready";
457
474
  //#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 };
475
+ //#region src/custom-font-faces.ts
476
+ const added = /* @__PURE__ */ new WeakMap();
477
+ function faceKey(family, file) {
478
+ return [
479
+ family,
480
+ file.src,
481
+ file.weight,
482
+ file.weightTo ?? "",
483
+ file.style ?? "normal"
484
+ ].join("|");
485
+ }
486
+ /**
487
+ * Adds the site's own fonts to a document from their bytes, and takes away
488
+ * those no longer among them. A file that can't be read or isn't a font is
489
+ * left out, so text shows in the fallback fonts, as it would on the site.
490
+ */
491
+ function installCustomFonts(doc, fonts, read) {
492
+ const FontFaceClass = doc.defaultView?.FontFace;
493
+ if (!FontFaceClass || !doc.fonts) return;
494
+ let faces = added.get(doc);
495
+ if (!faces) {
496
+ faces = /* @__PURE__ */ new Map();
497
+ added.set(doc, faces);
498
+ }
499
+ const wanted = /* @__PURE__ */ new Set();
500
+ for (const font of fonts) for (const file of font.files) {
501
+ const key = faceKey(font.family, file);
502
+ wanted.add(key);
503
+ if (faces.has(key)) continue;
504
+ const placeholder = { loading: true };
505
+ faces.set(key, placeholder);
506
+ read(file.src).then(async (bytes) => {
507
+ if (!bytes || faces?.get(key) !== placeholder) return;
508
+ const face = new FontFaceClass(font.family, bytes.slice().buffer, {
509
+ weight: file.weightTo ? `${file.weight} ${file.weightTo}` : String(file.weight),
510
+ style: file.style ?? "normal",
511
+ display: "swap"
512
+ });
513
+ await face.load();
514
+ if (faces?.get(key) !== placeholder) return;
515
+ faces.set(key, face);
516
+ doc.fonts.add(face);
517
+ }).catch(() => {});
518
+ }
519
+ for (const [key, face] of faces) {
520
+ if (wanted.has(key)) continue;
521
+ if (!("loading" in face)) doc.fonts.delete(face);
522
+ faces.delete(key);
523
+ }
524
+ }
525
+ //#endregion
526
+ export { formattedHtml as $, duplicatePageData as A, pageFileChange as B, checkPageAddress as C, customCssFileChange as D, collectionSettingsChanges as E, menusFileChange as F, storedCss as G, redirectsFileChange as H, moveEntryChanges as I, syncedBlockFileChange as J, storedData as K, movePageChanges as L, itemLinkRemovalChanges as M, layoutFileChange as N, deleteCollectionChanges as O, mediaFileChange as P, editedMarkdown as Q, newCollectionSettings as R, checkEntrySlug as S, collectionFileChange$1 as T, siteSettingsFileChange as U, pageTitle$1 as V, slugify as W, updateSettingsFileChange as X, uniqueName as Y, withoutMediaDetails as Z, mediaUrl as _, showMediaInPreview as a, uploadName as b, MAX_UPLOAD_BYTES as c, extensionOf as d, fileName$1 as f, mediaPath as g, mediaKind$1 as h, mediaType as i, entryFileChange$1 as j, deleteSyncedBlockChanges as k, MEDIA_URL_PREFIX as l, isStoredMedia as m, PLUGIN_READY_MESSAGE as n, CONNECT_MESSAGE as o, formatSize as p, storedEntryFields as q, createMediaPreviews as r, createChannel as s, installCustomFonts as t, allMedia as u, mediaUsage as v, codeFileChanges as w, addressPatternFor as x, siteImages as y, newPageData as z };