domma-cms 0.93.0 → 0.94.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.
Files changed (64) hide show
  1. package/admin/css/admin.css +1 -1
  2. package/admin/js/app.js +2 -2
  3. package/admin/js/templates/docs/api-actions.html +86 -64
  4. package/admin/js/templates/docs/api-authentication.html +159 -123
  5. package/admin/js/templates/docs/api-builder.html +197 -0
  6. package/admin/js/templates/docs/api-collections.html +199 -259
  7. package/admin/js/templates/docs/api-external.html +225 -0
  8. package/admin/js/templates/docs/api-forms.html +268 -0
  9. package/admin/js/templates/docs/api-layouts.html +70 -45
  10. package/admin/js/templates/docs/api-media.html +57 -80
  11. package/admin/js/templates/docs/api-navigation.html +66 -22
  12. package/admin/js/templates/docs/api-pages.html +109 -129
  13. package/admin/js/templates/docs/api-plugins.html +123 -61
  14. package/admin/js/templates/docs/api-scaffold.html +185 -0
  15. package/admin/js/templates/docs/api-settings.html +72 -64
  16. package/admin/js/templates/docs/api-users.html +74 -107
  17. package/admin/js/templates/docs/api-views.html +68 -54
  18. package/admin/js/templates/docs/components-howto.html +20 -17
  19. package/admin/js/templates/docs/components-reference.html +13 -16
  20. package/admin/js/templates/docs/components-rules.html +7 -6
  21. package/admin/js/templates/docs/components-walkthrough.html +19 -19
  22. package/admin/js/templates/docs/tutorial-crud.html +68 -38
  23. package/admin/js/templates/docs/tutorial-forms.html +51 -35
  24. package/admin/js/templates/docs/tutorial-plugin.html +132 -56
  25. package/admin/js/templates/docs/usage-actions.html +55 -14
  26. package/admin/js/templates/docs/usage-collections.html +108 -0
  27. package/admin/js/templates/docs/usage-cta-shortcode.html +14 -3
  28. package/admin/js/templates/docs/usage-dconfig.html +0 -3
  29. package/admin/js/templates/docs/usage-editions.html +213 -0
  30. package/admin/js/templates/docs/usage-media.html +22 -6
  31. package/admin/js/templates/docs/usage-navigation.html +74 -18
  32. package/admin/js/templates/docs/usage-pages.html +60 -20
  33. package/admin/js/templates/docs/usage-plugins.html +89 -17
  34. package/admin/js/templates/docs/usage-shortcodes.html +123 -70
  35. package/admin/js/templates/docs/usage-site-settings.html +50 -18
  36. package/admin/js/templates/docs/usage-tools.html +73 -0
  37. package/admin/js/templates/docs/usage-users-roles.html +99 -20
  38. package/admin/js/templates/docs/usage-views.html +36 -19
  39. package/admin/js/templates/documentation.html +153 -32
  40. package/admin/js/templates/plugin-guide.html +15 -0
  41. package/admin/js/templates/plugin-guides.html +21 -0
  42. package/admin/js/templates/pro-docs.html +53 -234
  43. package/admin/js/templates/tutorials.html +5 -4
  44. package/admin/js/views/doc-pages.js +1 -1
  45. package/admin/js/views/index.js +1 -1
  46. package/admin/js/views/plugin-guides.js +5 -0
  47. package/bin/cli.js +6 -6
  48. package/package.json +1 -1
  49. package/plugins/blog/docs/guide.md +205 -0
  50. package/plugins/blog/plugin.json +1 -1
  51. package/plugins/feedback/docs/guide.md +95 -0
  52. package/plugins/feedback/plugin.json +1 -1
  53. package/plugins/free-tier.lock.json +16 -11
  54. package/plugins/mail-reader/docs/guide.md +147 -0
  55. package/plugins/mail-reader/plugin.json +1 -1
  56. package/plugins/security/docs/guide.md +170 -0
  57. package/plugins/security/plugin.json +1 -1
  58. package/plugins/shopping-cart/docs/guide.md +191 -0
  59. package/plugins/shopping-cart/plugin.json +1 -1
  60. package/server/routes/api/documentation.js +42 -0
  61. package/server/server.js +12 -0
  62. package/server/services/docs.js +13 -2
  63. package/server/services/pluginGuides.js +255 -0
  64. package/server/services/plugins.js +8 -0
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Plugin guides - user documentation a plugin ships for the admin's
3
+ * Documentation folder.
4
+ *
5
+ * A plugin puts Markdown files in `plugins/<name>/docs/*.md`. When the plugin
6
+ * is loaded (enabled, licensed, not superseded), each file becomes a page under
7
+ * Documentation > Plugins in the admin sidebar (`#/docs/plugins/<name>`), shown
8
+ * to users who can use the plugin: the manifest's `docs.permission`, else the
9
+ * permission on its first `admin.sidebar` entry that names one, else anyone
10
+ * signed in to the admin.
11
+ *
12
+ * Files are plain Markdown with optional frontmatter `title` (the tab label)
13
+ * and `order` (a number, lowest first; `guide.md` comes first by default).
14
+ * Shortcodes are NOT run - a guide shows `[collection ...]` examples literally,
15
+ * which is what a guide wants. The HTML is sanitised like any other page body.
16
+ *
17
+ * Pure helpers are exported for tests; the registry is module state filled once
18
+ * per boot by registerPluginGuides() (plugins load at boot only).
19
+ */
20
+ import fs from 'fs/promises';
21
+ import path from 'path';
22
+ import matter from 'gray-matter';
23
+ import {marked} from 'marked';
24
+ import sanitizeHtml from 'sanitize-html';
25
+ import {registerSidebarItem} from './hooks.js';
26
+
27
+ /** The Documentation folder's key in the admin-sidebar menu. */
28
+ export const DOCS_FOLDER_KEY = 'documentation';
29
+ /** Identity (url) of the Plugins sub-folder and its landing page. */
30
+ export const GUIDES_URL = '#/docs/plugins';
31
+
32
+ const SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/i;
33
+
34
+ /** name -> {name, displayName, description, icon, version, permission, toolUrl, dir, pages: [{slug, title, order, file}]} */
35
+ let _guides = new Map();
36
+
37
+ /**
38
+ * Who may read a plugin's guides: `docs.permission`, else the first sidebar
39
+ * entry's permission, else null (anyone signed in to the admin).
40
+ *
41
+ * @param {object} manifest
42
+ * @returns {string|null}
43
+ */
44
+ export function guidePermission(manifest) {
45
+ const explicit = manifest?.docs?.permission;
46
+ if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
47
+ const entry = (manifest?.admin?.sidebar || []).find(s => typeof s?.permission === 'string' && s.permission);
48
+ return entry ? entry.permission : null;
49
+ }
50
+
51
+ /**
52
+ * Does this permission list cover `resource`? Same rule as the sidebar's canAny:
53
+ * the bare resource or any of its actions.
54
+ *
55
+ * @param {string[]} permissions
56
+ * @param {string|null} resource
57
+ * @returns {boolean}
58
+ */
59
+ export function canUse(permissions, resource) {
60
+ if (!resource) return true;
61
+ if (!Array.isArray(permissions) || !permissions.length) return false;
62
+ if (permissions.includes(resource)) return true;
63
+ return ['read', 'create', 'update', 'delete'].some(a => permissions.includes(`${resource}.${a}`));
64
+ }
65
+
66
+ /** "shipping-zones" -> "Shipping zones"; "guide" -> "Guide". */
67
+ export function titleFromSlug(slug) {
68
+ const words = String(slug || '').replace(/^\d+[-_]/, '').replace(/[-_]+/g, ' ').trim();
69
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Guide';
70
+ }
71
+
72
+ /**
73
+ * Parse one guide file's source into its page meta.
74
+ *
75
+ * @param {string} slug - file name without `.md`
76
+ * @param {string} source
77
+ * @returns {{slug: string, title: string, order: number}}
78
+ */
79
+ export function pageMeta(slug, source) {
80
+ let data = {};
81
+ try { data = matter(source).data || {}; } catch { data = {}; }
82
+ const title = typeof data.title === 'string' && data.title.trim() ? data.title.trim() : titleFromSlug(slug);
83
+ const order = Number.isFinite(Number(data.order)) && data.order !== null && data.order !== ''
84
+ ? Number(data.order)
85
+ : (slug === 'guide' ? 0 : 100);
86
+ return {slug, title, order};
87
+ }
88
+
89
+ /** Pages sorted by order, then slug. */
90
+ export function sortPages(pages) {
91
+ return [...pages].sort((a, b) => (a.order - b.order) || a.slug.localeCompare(b.slug));
92
+ }
93
+
94
+ const SANITIZE = {
95
+ allowedTags: [...sanitizeHtml.defaults.allowedTags, 'img', 'h1', 'h2', 'del', 'details', 'summary'],
96
+ allowedAttributes: {
97
+ a: ['href', 'title', 'target', 'rel'],
98
+ img: ['src', 'alt', 'title', 'width', 'height'],
99
+ code: ['class'],
100
+ table: ['class'],
101
+ th: ['align', 'style'],
102
+ td: ['align', 'style'],
103
+ ol: ['start']
104
+ },
105
+ allowedStyles: {th: {'text-align': [/^(left|right|center)$/]}, td: {'text-align': [/^(left|right|center)$/]}},
106
+ allowedSchemes: ['http', 'https', 'mailto'],
107
+ transformTags: {
108
+ // Links leaving the admin open in a new tab; hash links stay in the SPA.
109
+ a: (tagName, attribs) => {
110
+ const href = attribs.href || '';
111
+ if (/^https?:\/\//i.test(href)) return {tagName, attribs: {...attribs, target: '_blank', rel: 'noopener noreferrer'}};
112
+ return {tagName, attribs};
113
+ },
114
+ table: () => ({tagName: 'table', attribs: {class: 'table table-sm'}})
115
+ }
116
+ };
117
+
118
+ /**
119
+ * Markdown -> sanitised HTML. Frontmatter is dropped; shortcodes are not run.
120
+ *
121
+ * @param {string} source
122
+ * @returns {string}
123
+ */
124
+ export function renderGuide(source) {
125
+ let body = source;
126
+ try { body = matter(source).content; } catch { /* no frontmatter */ }
127
+ return sanitizeHtml(marked.parse(body, {gfm: true, async: false}), SANITIZE);
128
+ }
129
+
130
+ /**
131
+ * Read a plugin's `docs/` folder into page metas. Missing folder = no pages.
132
+ *
133
+ * @param {string} pluginDir
134
+ * @returns {Promise<Array<{slug, title, order, file}>>}
135
+ */
136
+ export async function readGuidePages(pluginDir) {
137
+ const dir = path.join(pluginDir, 'docs');
138
+ let names;
139
+ try { names = await fs.readdir(dir); } catch { return []; }
140
+ const pages = [];
141
+ for (const name of names) {
142
+ if (!name.toLowerCase().endsWith('.md')) continue;
143
+ const slug = name.slice(0, -3);
144
+ if (!SLUG_RE.test(slug)) continue;
145
+ const file = path.join(dir, name);
146
+ try {
147
+ const source = await fs.readFile(file, 'utf8');
148
+ pages.push({...pageMeta(slug, source), file});
149
+ } catch { /* unreadable - skip */ }
150
+ }
151
+ return sortPages(pages);
152
+ }
153
+
154
+ /**
155
+ * The sidebar node for Documentation > Plugins: a folder of one link per
156
+ * plugin, each gated by that plugin's permission (the renderer drops the ones
157
+ * a user cannot use).
158
+ *
159
+ * @param {Array<object>} guides - registry values
160
+ * @returns {object|null}
161
+ */
162
+ export function guidesSidebarItem(guides) {
163
+ if (!guides.length) return null;
164
+ return {
165
+ source: 'Built in',
166
+ text: 'Plugins',
167
+ url: GUIDES_URL,
168
+ icon: 'package',
169
+ items: [...guides]
170
+ .sort((a, b) => a.displayName.localeCompare(b.displayName))
171
+ .map(g => ({
172
+ text: g.displayName,
173
+ url: `${GUIDES_URL}/${g.name}`,
174
+ icon: g.icon || 'package',
175
+ ...(g.permission && {permission: g.permission})
176
+ }))
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Collect the guides of the plugins that loaded and add the sidebar folder.
182
+ * Called once at boot, after registerPlugins() has loaded them.
183
+ *
184
+ * @param {object[]} manifests - manifests of LOADED plugins
185
+ * @param {string} pluginsDir
186
+ * @param {{register?: Function}} [opts] - sidebar registration (tests pass a stub)
187
+ * @returns {Promise<number>} how many plugins have guides
188
+ */
189
+ export async function registerPluginGuides(manifests, pluginsDir, {register = registerSidebarItem} = {}) {
190
+ const next = new Map();
191
+ for (const manifest of manifests || []) {
192
+ if (!manifest?.name) continue;
193
+ const dir = path.join(pluginsDir, manifest.name);
194
+ const pages = await readGuidePages(dir);
195
+ if (!pages.length) continue;
196
+ next.set(manifest.name, {
197
+ name: manifest.name,
198
+ displayName: manifest.displayName || manifest.name,
199
+ description: manifest.description || '',
200
+ icon: manifest.icon || 'package',
201
+ version: manifest.version || '',
202
+ tier: manifest.tier || '',
203
+ permission: guidePermission(manifest),
204
+ toolUrl: (manifest.admin?.sidebar || []).find(s => s?.url)?.url || null,
205
+ pages
206
+ });
207
+ }
208
+ _guides = next;
209
+ const item = guidesSidebarItem([...next.values()]);
210
+ if (item) register({folder: DOCS_FOLDER_KEY, item});
211
+ return next.size;
212
+ }
213
+
214
+ /** Public shape of a guide (no file paths). */
215
+ function publicGuide(g) {
216
+ const {pages, ...rest} = g;
217
+ return {...rest, pages: pages.map(({slug, title}) => ({slug, title}))};
218
+ }
219
+
220
+ /**
221
+ * Every guide this permission list may read.
222
+ *
223
+ * @param {string[]} permissions
224
+ * @returns {object[]}
225
+ */
226
+ export function listGuides(permissions) {
227
+ return [...(_guides.values())]
228
+ .filter(g => canUse(permissions, g.permission))
229
+ .sort((a, b) => a.displayName.localeCompare(b.displayName))
230
+ .map(publicGuide);
231
+ }
232
+
233
+ /**
234
+ * One guide with one page rendered, or a reason it cannot be shown.
235
+ *
236
+ * @param {string} name
237
+ * @param {string|undefined} pageSlug - defaults to the first page
238
+ * @param {string[]} permissions
239
+ * @returns {Promise<{status: 200|403|404, guide?: object}>}
240
+ */
241
+ export async function getGuide(name, pageSlug, permissions) {
242
+ const g = _guides.get(name);
243
+ if (!g) return {status: 404};
244
+ if (!canUse(permissions, g.permission)) return {status: 403};
245
+ const page = pageSlug ? g.pages.find(p => p.slug === pageSlug) : g.pages[0];
246
+ if (!page) return {status: 404};
247
+ let source;
248
+ try { source = await fs.readFile(page.file, 'utf8'); } catch { return {status: 404}; }
249
+ return {status: 200, guide: {...publicGuide(g), page: {slug: page.slug, title: page.title, html: renderGuide(source)}}};
250
+ }
251
+
252
+ /** Test seam: replace the registry. */
253
+ export function _setGuidesForTest(list) {
254
+ _guides = new Map((list || []).map(g => [g.name, g]));
255
+ }
@@ -606,6 +606,14 @@ export async function registerPlugins(fastify) {
606
606
  }
607
607
  }
608
608
 
609
+ // User guides (docs/*.md) of the plugins that loaded, under Documentation > Plugins.
610
+ try {
611
+ const {registerPluginGuides} = await import('./pluginGuides.js');
612
+ await registerPluginGuides(manifests.filter(m => loaded.includes(m.name)), PLUGINS_DIR);
613
+ } catch (err) {
614
+ fastify.log.warn(`[plugins] Plugin guides skipped: ${err.message}`);
615
+ }
616
+
609
617
  if (loaded.length) {
610
618
  fastify.log.info(`[plugins] Loaded ${loaded.length} plugin${loaded.length === 1 ? '' : 's'}: ${loaded.join(', ')}`);
611
619
  } else {