domma-cms 0.8.10 → 0.9.1

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,59 @@
1
+ {
2
+ "slug": "todos",
3
+ "title": "Todos",
4
+ "description": "Todo items managed by the Todo plugin.",
5
+ "plugin": "todo",
6
+ "fields": [
7
+ {
8
+ "name": "text",
9
+ "label": "Task",
10
+ "type": "text",
11
+ "required": true
12
+ },
13
+ {
14
+ "name": "status",
15
+ "label": "Status",
16
+ "type": "text",
17
+ "required": false
18
+ },
19
+ {
20
+ "name": "priority",
21
+ "label": "Priority",
22
+ "type": "text",
23
+ "required": false
24
+ },
25
+ {
26
+ "name": "dueAt",
27
+ "label": "Due Date",
28
+ "type": "text",
29
+ "required": false
30
+ },
31
+ {
32
+ "name": "userId",
33
+ "label": "User ID",
34
+ "type": "text",
35
+ "required": false
36
+ }
37
+ ],
38
+ "api": {
39
+ "create": {
40
+ "enabled": true,
41
+ "access": "admin"
42
+ },
43
+ "read": {
44
+ "enabled": true,
45
+ "access": "admin"
46
+ },
47
+ "update": {
48
+ "enabled": false,
49
+ "access": "admin"
50
+ },
51
+ "delete": {
52
+ "enabled": false,
53
+ "access": "admin"
54
+ }
55
+ },
56
+ "storage": {
57
+ "adapter": "file"
58
+ }
59
+ }
@@ -4,44 +4,10 @@ import {
4
4
  createEntry,
5
5
  updateEntry,
6
6
  deleteEntry,
7
- getEntry,
8
- getCollection,
9
- createCollection
7
+ getEntry
10
8
  } from '../../server/services/collections.js';
11
9
 
12
10
  const SLUG = 'todos';
13
- const STORAGE = defaultConfig.storage ?? {adapter: 'file'};
14
-
15
- const FIELDS = [
16
- {name: 'text', label: 'Task', type: 'text', required: true},
17
- {name: 'status', label: 'Status', type: 'text', required: false},
18
- {name: 'priority', label: 'Priority', type: 'text', required: false},
19
- {name: 'dueAt', label: 'Due Date', type: 'text', required: false},
20
- {name: 'userId', label: 'User ID', type: 'text', required: false}
21
- ];
22
-
23
- /**
24
- * Lifecycle: create the todos collection (MongoDB-backed) on plugin enable.
25
- */
26
- export async function onEnable({services: {collections}}) {
27
- const existing = await collections.getCollection(SLUG).catch(() => null);
28
- if (existing) return;
29
- await collections.createCollection({
30
- title: 'Todos',
31
- slug: SLUG,
32
- description: 'Todo items managed by the Todo plugin.',
33
- fields: FIELDS,
34
- storage: STORAGE
35
- });
36
- }
37
-
38
- /**
39
- * Lifecycle: remove the todos collection on plugin disable.
40
- */
41
- export async function onDisable({services: {collections}}) {
42
- await collections.deleteCollection(SLUG).catch(() => {
43
- });
44
- }
45
11
 
46
12
  /** Flatten a collection entry into the shape the admin view expects. */
47
13
  function toTodo(entry) {
@@ -57,19 +23,6 @@ export default async function todoPlugin(fastify, options) {
57
23
  const { authenticate } = options.auth;
58
24
  const config = {...defaultConfig, ...(options.settings || {})};
59
25
  const scope = config.scope ?? 'user';
60
- const storage = config.storage ?? STORAGE;
61
-
62
- // Auto-create the collection if it doesn't exist yet.
63
- const existing = await getCollection(SLUG).catch(() => null);
64
- if (!existing) {
65
- await createCollection({
66
- title: 'Todos',
67
- slug: SLUG,
68
- description: 'Todo items managed by the Todo plugin.',
69
- fields: FIELDS,
70
- storage
71
- }).catch(err => fastify.log.warn(`[todo] Collection setup: ${err.message}`));
72
- }
73
26
 
74
27
  function userId(request) {
75
28
  return scope === 'user' ? (request.user?.id ?? request.user?.sub ?? null) : null;
@@ -34,11 +34,11 @@ export async function blocksRoutes(fastify) {
34
34
  // Create or update block
35
35
  fastify.put('/blocks/:name', canUpdate, async (request, reply) => {
36
36
  const {name} = request.params;
37
- const {content} = request.body || {};
37
+ const {content, bundled} = request.body || {};
38
38
  if (typeof content !== 'string') return reply.status(400).send({error: 'content (string) is required'});
39
39
 
40
40
  try {
41
- return await saveBlock(name, content);
41
+ return await saveBlock(name, content, {bundled: !!bundled});
42
42
  } catch (err) {
43
43
  if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
44
44
  throw err;
@@ -180,10 +180,17 @@ export async function listBlocks() {
180
180
  for (const file of files.filter(f => f.endsWith('.html'))) {
181
181
  const name = file.slice(0, -5);
182
182
  const fileStat = await fs.stat(path.join(BLOCKS_DIR, file)).catch(() => null);
183
+ let bundled = false;
184
+ try {
185
+ const meta = JSON.parse(await fs.readFile(path.join(BLOCKS_DIR, `${name}.meta.json`), 'utf8'));
186
+ bundled = !!meta.bundled;
187
+ } catch { /* no meta file */
188
+ }
183
189
  blocks.push({
184
190
  name,
185
191
  size: fileStat?.size ?? 0,
186
192
  updatedAt: fileStat?.mtime?.toISOString() ?? null,
193
+ bundled,
187
194
  });
188
195
  }
189
196
  return blocks.sort((a, b) => a.name.localeCompare(b.name));
@@ -200,7 +207,13 @@ export async function getBlock(name) {
200
207
  assertValidName(name);
201
208
  try {
202
209
  const content = await fs.readFile(blockFilePath(name), 'utf8');
203
- return {name, content};
210
+ let bundled = false;
211
+ try {
212
+ const meta = JSON.parse(await fs.readFile(path.join(BLOCKS_DIR, `${name}.meta.json`), 'utf8'));
213
+ bundled = !!meta.bundled;
214
+ } catch { /* no meta file */
215
+ }
216
+ return {name, content, bundled};
204
217
  } catch (err) {
205
218
  if (err.code === 'ENOENT') {
206
219
  const notFound = new Error('Block not found');
@@ -219,10 +232,17 @@ export async function getBlock(name) {
219
232
  * @returns {Promise<{success: boolean, name: string}>}
220
233
  * @throws {Error} With code INVALID_NAME on bad name
221
234
  */
222
- export async function saveBlock(name, content) {
235
+ export async function saveBlock(name, content, {bundled} = {}) {
223
236
  assertValidName(name);
224
237
  await fs.mkdir(BLOCKS_DIR, {recursive: true});
225
238
  await fs.writeFile(blockFilePath(name), content, 'utf8');
239
+ const metaPath = path.join(BLOCKS_DIR, `${name}.meta.json`);
240
+ if (bundled) {
241
+ await fs.writeFile(metaPath, JSON.stringify({bundled: true}, null, 2) + '\n', 'utf8');
242
+ } else {
243
+ await fs.unlink(metaPath).catch(() => {
244
+ });
245
+ }
226
246
  return {success: true, name};
227
247
  }
228
248
 
@@ -36,7 +36,13 @@ function slugify(str) {
36
36
 
37
37
  async function readSchema(slug) {
38
38
  const raw = await fs.readFile(schemaPath(slug), 'utf8');
39
- return JSON.parse(raw);
39
+ const schema = JSON.parse(raw);
40
+ // Normalise legacy 'preset' flag — both mean "bundled with fresh installs"
41
+ if (schema.preset && !schema.bundled) {
42
+ schema.bundled = true;
43
+ delete schema.preset;
44
+ }
45
+ return schema;
40
46
  }
41
47
 
42
48
  async function writeSchema(schema) {
@@ -115,7 +121,7 @@ export async function getCollection(slug) {
115
121
  * @returns {Promise<object>} Created schema
116
122
  * @throws {Error} If a collection with that slug already exists
117
123
  */
118
- export async function createCollection({title, slug, description = '', fields = [], api = {}, storage}) {
124
+ export async function createCollection({title, slug, description = '', fields = [], api = {}, storage, bundled, plugin}) {
119
125
  await ensureDir();
120
126
  const finalSlug = slug ? slugify(slug) : slugify(title);
121
127
  if (!finalSlug) throw new Error('Could not derive a slug from the title');
@@ -133,6 +139,8 @@ export async function createCollection({title, slug, description = '', fields =
133
139
  slug: finalSlug,
134
140
  title: title.trim(),
135
141
  description: description.trim(),
142
+ ...(bundled ? {bundled: true} : {}),
143
+ ...(plugin ? {plugin} : {}),
136
144
  fields,
137
145
  api: { ...defaultApiAccess(), ...api },
138
146
  storage: storage || {adapter: 'file'},
@@ -157,14 +165,20 @@ export async function updateCollection(slug, updates) {
157
165
  const schema = await getCollection(slug);
158
166
  if (!schema) throw new Error(`Collection "${slug}" not found`);
159
167
 
160
- const { slug: _ignore, createdAt, ...rest } = updates;
168
+ const { slug: _ignore, createdAt, plugin: _plugin, bundled: _bundled, ...rest } = updates;
161
169
  const updated = {
162
170
  ...schema,
163
171
  ...rest,
172
+ // bundled is user-editable — set from update, omit if falsy
173
+ ...(updates.bundled ? {bundled: true} : {}),
174
+ // plugin is ownership metadata — never overwrite from updates
175
+ ...(schema.plugin ? {plugin: schema.plugin} : {}),
164
176
  slug,
165
177
  createdAt: schema.createdAt,
166
178
  updatedAt: new Date().toISOString()
167
179
  };
180
+ // Clear bundled from schema if it was unchecked (schema spread may have preserved old value)
181
+ if (!updates.bundled) delete updated.bundled;
168
182
 
169
183
  await writeSchema(updated);
170
184
 
@@ -115,7 +115,7 @@ export async function getForm(slug) {
115
115
  * @throws {Error} If title is missing, slug cannot be derived, or a form with the slug already exists.
116
116
  */
117
117
  export async function createForm(data) {
118
- const { title, slug: rawSlug, description = '', fields = [], settings = {}, actions = {} } = data || {};
118
+ const { title, slug: rawSlug, description = '', fields = [], settings = {}, actions = {}, plugin } = data || {};
119
119
 
120
120
  if (!title?.trim() && !rawSlug?.trim()) {
121
121
  throw new Error('A title or slug is required to create a form.');
@@ -144,6 +144,7 @@ export async function createForm(data) {
144
144
  slug,
145
145
  title: trimmedTitle,
146
146
  description,
147
+ ...(plugin ? {plugin} : {}),
147
148
  fields: Array.isArray(fields) ? fields : [],
148
149
  settings: {
149
150
  submitText: 'Submit',
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import fs from 'fs/promises';
14
14
  import path from 'path';
15
+ import matter from 'gray-matter';
15
16
  import {getConfig, saveConfig} from '../config.js';
16
17
  import {authenticate, requireAdmin, requireRole} from '../middleware/auth.js';
17
18
  import {hooks, registerSanitizeRules, registerShortcode, registerTransform} from './hooks.js';
@@ -231,6 +232,154 @@ export async function getInjectionSnippets() {
231
232
  return {head, headLate, bodyEnd};
232
233
  }
233
234
 
235
+ /**
236
+ * Recursively collect all .md files under a directory.
237
+ * Returns an empty array if the directory does not exist.
238
+ *
239
+ * @param {string} dir
240
+ * @returns {Promise<string[]>}
241
+ */
242
+ async function walkMdFiles(dir) {
243
+ const results = [];
244
+ let entries;
245
+ try {
246
+ entries = await fs.readdir(dir, {withFileTypes: true});
247
+ } catch {
248
+ return results;
249
+ }
250
+ for (const entry of entries) {
251
+ const full = path.join(dir, entry.name);
252
+ if (entry.isDirectory()) results.push(...await walkMdFiles(full));
253
+ else if (entry.name.endsWith('.md')) results.push(full);
254
+ }
255
+ return results;
256
+ }
257
+
258
+ /**
259
+ * Set up all resources owned by a plugin: collections, forms, and pages.
260
+ * Each resource type is created only if it does not already exist (idempotent).
261
+ * Called automatically before `onEnable`.
262
+ *
263
+ * @param {string} pluginName
264
+ * @param {object} services - Service map from runLifecycleHook
265
+ * @returns {Promise<{collections: string[], forms: string[], pages: string[]}>}
266
+ */
267
+ export async function setupPlugin(pluginName, services) {
268
+ const pluginDir = path.join(PLUGINS_DIR, pluginName);
269
+ const result = {collections: [], forms: [], pages: []};
270
+
271
+ // Collections
272
+ if (services.collections) {
273
+ let entries = [];
274
+ try {
275
+ entries = await fs.readdir(path.join(pluginDir, 'collections'), {withFileTypes: true});
276
+ } catch { /* no collections dir */ }
277
+ for (const entry of entries.filter(e => e.isDirectory())) {
278
+ const schemaPath = path.join(pluginDir, 'collections', entry.name, 'schema.json');
279
+ let schema;
280
+ try {
281
+ schema = JSON.parse(await fs.readFile(schemaPath, 'utf8'));
282
+ } catch {
283
+ continue;
284
+ }
285
+ schema.plugin = pluginName;
286
+ const existing = await services.collections.getCollection(schema.slug).catch(() => null);
287
+ if (!existing) {
288
+ await services.collections.createCollection(schema);
289
+ result.collections.push(schema.slug);
290
+ }
291
+ }
292
+ }
293
+
294
+ // Forms
295
+ if (services.forms) {
296
+ let entries = [];
297
+ try {
298
+ entries = await fs.readdir(path.join(pluginDir, 'forms'));
299
+ } catch { /* no forms dir */ }
300
+ for (const file of entries.filter(e => e.endsWith('.json'))) {
301
+ let data;
302
+ try {
303
+ data = JSON.parse(await fs.readFile(path.join(pluginDir, 'forms', file), 'utf8'));
304
+ } catch {
305
+ continue;
306
+ }
307
+ data.plugin = pluginName;
308
+ try {
309
+ await services.forms.createForm(data);
310
+ result.forms.push(data.slug || data.title);
311
+ } catch (err) {
312
+ if (err.code !== 'FORM_ALREADY_EXISTS') throw err;
313
+ }
314
+ }
315
+ }
316
+
317
+ // Pages
318
+ if (services.content) {
319
+ const pagesDir = path.join(pluginDir, 'pages');
320
+ const pageFiles = await walkMdFiles(pagesDir);
321
+ for (const filePath of pageFiles) {
322
+ let raw;
323
+ try {
324
+ raw = await fs.readFile(filePath, 'utf8');
325
+ } catch {
326
+ continue;
327
+ }
328
+ const parsed = matter(raw);
329
+ const rel = path.relative(pagesDir, filePath).replace(/\\/g, '/');
330
+ const urlPath = '/' + rel.replace(/\.md$/, '').replace(/\/index$/, '').replace(/^index$/, '');
331
+ parsed.data.plugin = pluginName;
332
+ const existing = await services.content.getPage(urlPath);
333
+ if (!existing) {
334
+ await services.content.createPage(urlPath, parsed.data, parsed.content.trim());
335
+ result.pages.push(urlPath);
336
+ }
337
+ }
338
+ }
339
+
340
+ return result;
341
+ }
342
+
343
+ /**
344
+ * Remove all pages and forms owned by a plugin.
345
+ * Collections are intentionally preserved to protect stored data.
346
+ * Called automatically before `onDisable`.
347
+ *
348
+ * @param {string} pluginName
349
+ * @param {object} services - Service map from runLifecycleHook
350
+ * @param {import('fastify').FastifyInstance} [fastify]
351
+ * @returns {Promise<{forms: string[], pages: string[]}>}
352
+ */
353
+ export async function teardownPlugin(pluginName, services, fastify) {
354
+ const removed = {forms: [], pages: []};
355
+
356
+ if (services.content) {
357
+ try {
358
+ const pages = await services.content.listPages();
359
+ for (const page of pages.filter(p => p.plugin === pluginName)) {
360
+ await services.content.deletePage(page.urlPath);
361
+ removed.pages.push(page.urlPath);
362
+ }
363
+ } catch (err) {
364
+ fastify?.log.warn(`[plugins] Could not remove pages for "${pluginName}": ${err.message}`);
365
+ }
366
+ }
367
+
368
+ if (services.forms) {
369
+ try {
370
+ const forms = await services.forms.listForms();
371
+ for (const form of forms.filter(f => f.plugin === pluginName)) {
372
+ await services.forms.deleteForm(form.slug);
373
+ removed.forms.push(form.slug);
374
+ }
375
+ } catch (err) {
376
+ fastify?.log.warn(`[plugins] Could not remove forms for "${pluginName}": ${err.message}`);
377
+ }
378
+ }
379
+
380
+ return removed;
381
+ }
382
+
234
383
  /**
235
384
  * Run a lifecycle hook (onEnable or onDisable) for a plugin if it exports one.
236
385
  * Dynamically imports plugin.js and calls the named export with a context object.
@@ -241,6 +390,7 @@ export async function getInjectionSnippets() {
241
390
  * @param {import('fastify').FastifyInstance} fastify
242
391
  * @returns {Promise<void>}
243
392
  */
393
+
244
394
  export async function runLifecycleHook(name, hook, fastify) {
245
395
  // Validate hook name
246
396
  if (!['onEnable', 'onDisable'].includes(hook)) return;
@@ -285,7 +435,22 @@ export async function runLifecycleHook(name, hook, fastify) {
285
435
  }
286
436
  });
287
437
 
288
- await mod[hook]({ fastify, services });
438
+ if (hook === 'onEnable') {
439
+ const setup = await setupPlugin(name, services);
440
+ if (setup.collections.length) fastify.log.info(`[plugins] Created collections for "${name}": ${setup.collections.join(', ')}`);
441
+ if (setup.forms.length) fastify.log.info(`[plugins] Created forms for "${name}": ${setup.forms.join(', ')}`);
442
+ if (setup.pages.length) fastify.log.info(`[plugins] Created pages for "${name}": ${setup.pages.join(', ')}`);
443
+ }
444
+
445
+ if (hook === 'onDisable') {
446
+ const torn = await teardownPlugin(name, services, fastify);
447
+ if (torn.pages.length) fastify.log.info(`[plugins] Removed pages for "${name}": ${torn.pages.join(', ')}`);
448
+ if (torn.forms.length) fastify.log.info(`[plugins] Removed forms for "${name}": ${torn.forms.join(', ')}`);
449
+ }
450
+
451
+ if (typeof mod[hook] === 'function') {
452
+ await mod[hook]({ fastify, services });
453
+ }
289
454
  } catch (err) {
290
455
  fastify.log.error(`Plugin "${name}" lifecycle hook "${hook}" failed: ${err.message}`);
291
456
  }