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.
- package/admin/css/admin.css +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/templates/docs/api-actions.html +86 -64
- package/admin/js/templates/docs/api-authentication.html +159 -123
- package/admin/js/templates/docs/api-builder.html +197 -0
- package/admin/js/templates/docs/api-collections.html +199 -259
- package/admin/js/templates/docs/api-external.html +225 -0
- package/admin/js/templates/docs/api-forms.html +268 -0
- package/admin/js/templates/docs/api-layouts.html +70 -45
- package/admin/js/templates/docs/api-media.html +57 -80
- package/admin/js/templates/docs/api-navigation.html +66 -22
- package/admin/js/templates/docs/api-pages.html +109 -129
- package/admin/js/templates/docs/api-plugins.html +123 -61
- package/admin/js/templates/docs/api-scaffold.html +185 -0
- package/admin/js/templates/docs/api-settings.html +72 -64
- package/admin/js/templates/docs/api-users.html +74 -107
- package/admin/js/templates/docs/api-views.html +68 -54
- package/admin/js/templates/docs/components-howto.html +20 -17
- package/admin/js/templates/docs/components-reference.html +13 -16
- package/admin/js/templates/docs/components-rules.html +7 -6
- package/admin/js/templates/docs/components-walkthrough.html +19 -19
- package/admin/js/templates/docs/tutorial-crud.html +68 -38
- package/admin/js/templates/docs/tutorial-forms.html +51 -35
- package/admin/js/templates/docs/tutorial-plugin.html +132 -56
- package/admin/js/templates/docs/usage-actions.html +55 -14
- package/admin/js/templates/docs/usage-collections.html +108 -0
- package/admin/js/templates/docs/usage-cta-shortcode.html +14 -3
- package/admin/js/templates/docs/usage-dconfig.html +0 -3
- package/admin/js/templates/docs/usage-editions.html +213 -0
- package/admin/js/templates/docs/usage-media.html +22 -6
- package/admin/js/templates/docs/usage-navigation.html +74 -18
- package/admin/js/templates/docs/usage-pages.html +60 -20
- package/admin/js/templates/docs/usage-plugins.html +89 -17
- package/admin/js/templates/docs/usage-shortcodes.html +123 -70
- package/admin/js/templates/docs/usage-site-settings.html +50 -18
- package/admin/js/templates/docs/usage-tools.html +73 -0
- package/admin/js/templates/docs/usage-users-roles.html +99 -20
- package/admin/js/templates/docs/usage-views.html +36 -19
- package/admin/js/templates/documentation.html +153 -32
- package/admin/js/templates/plugin-guide.html +15 -0
- package/admin/js/templates/plugin-guides.html +21 -0
- package/admin/js/templates/pro-docs.html +53 -234
- package/admin/js/templates/tutorials.html +5 -4
- package/admin/js/views/doc-pages.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/plugin-guides.js +5 -0
- package/bin/cli.js +6 -6
- package/package.json +1 -1
- package/plugins/blog/docs/guide.md +205 -0
- package/plugins/blog/plugin.json +1 -1
- package/plugins/feedback/docs/guide.md +95 -0
- package/plugins/feedback/plugin.json +1 -1
- package/plugins/free-tier.lock.json +16 -11
- package/plugins/mail-reader/docs/guide.md +147 -0
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/security/docs/guide.md +170 -0
- package/plugins/security/plugin.json +1 -1
- package/plugins/shopping-cart/docs/guide.md +191 -0
- package/plugins/shopping-cart/plugin.json +1 -1
- package/server/routes/api/documentation.js +42 -0
- package/server/server.js +12 -0
- package/server/services/docs.js +13 -2
- package/server/services/pluginGuides.js +255 -0
- 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 {
|