webspresso 0.0.76 → 0.0.78
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 +6 -1
- package/bin/commands/db-scaffold.js +81 -0
- package/bin/utils/model-migrations.js +211 -0
- package/bin/webspresso.js +2 -0
- package/core/content/cache.js +64 -0
- package/core/content/field-types.js +180 -0
- package/core/content/index.js +30 -0
- package/core/content/renderer.js +84 -0
- package/core/content/schema.js +75 -0
- package/core/content/service.js +400 -0
- package/core/content/types.js +59 -0
- package/index.d.ts +17 -0
- package/index.js +7 -0
- package/package.json +1 -1
- package/plugins/admin-panel/app.js +7 -7
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +41 -0
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +99 -15
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +2 -2
- package/plugins/admin-panel/field-renderers/file-upload.js +108 -27
- package/plugins/admin-panel/index.js +17 -18
- package/plugins/admin-panel/modules/menu.js +1 -0
- package/plugins/content/admin/content-entries-component.js +291 -0
- package/plugins/content/admin/content-types-component.js +250 -0
- package/plugins/content/api-handlers.js +157 -0
- package/plugins/content/client/inline-edit.css +296 -0
- package/plugins/content/client/inline-edit.js +366 -0
- package/plugins/content/helpers.js +77 -0
- package/plugins/content/index.js +231 -0
- package/plugins/content/migration-template.js +54 -0
- package/plugins/content/models/content-entry.js +45 -0
- package/plugins/content/models/content-type.js +36 -0
- package/plugins/index.js +2 -0
- package/src/file-router.js +21 -1
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -1
- package/templates/skills/webspresso-usage/SKILL.md +5 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content type schema validation
|
|
3
|
+
* @module core/content/schema
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { z } = require('zod');
|
|
7
|
+
const { isValidFieldType } = require('./field-types');
|
|
8
|
+
|
|
9
|
+
const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
10
|
+
|
|
11
|
+
const fieldDefinitionSchema = z.object({
|
|
12
|
+
name: z.string().min(1).max(64).regex(/^[a-z][a-z0-9_]*$/i, 'Field name must be alphanumeric'),
|
|
13
|
+
type: z.string().refine(isValidFieldType, 'Invalid field type'),
|
|
14
|
+
label: z.string().max(255).optional(),
|
|
15
|
+
required: z.boolean().optional(),
|
|
16
|
+
options: z.array(z.string()).optional(),
|
|
17
|
+
fields: z.lazy(() => z.array(fieldDefinitionSchema)).optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const contentTypeSchemaSchema = z.object({
|
|
21
|
+
fields: z.array(fieldDefinitionSchema).min(0),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} slug
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
function isValidSlug(slug) {
|
|
29
|
+
return typeof slug === 'string' && SLUG_REGEX.test(slug);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {unknown} schema
|
|
34
|
+
* @returns {import('./types').ContentTypeSchema}
|
|
35
|
+
*/
|
|
36
|
+
function parseContentTypeSchema(schema) {
|
|
37
|
+
const parsed = contentTypeSchemaSchema.parse(schema);
|
|
38
|
+
const names = new Set();
|
|
39
|
+
for (const field of parsed.fields) {
|
|
40
|
+
if (names.has(field.name)) {
|
|
41
|
+
throw new Error(`Duplicate field name "${field.name}"`);
|
|
42
|
+
}
|
|
43
|
+
names.add(field.name);
|
|
44
|
+
if (field.type === 'select' && (!field.options || field.options.length === 0)) {
|
|
45
|
+
throw new Error(`Select field "${field.name}" requires options`);
|
|
46
|
+
}
|
|
47
|
+
if (field.type === 'repeater') {
|
|
48
|
+
if (!field.fields || field.fields.length === 0) {
|
|
49
|
+
throw new Error(`Repeater field "${field.name}" requires nested fields`);
|
|
50
|
+
}
|
|
51
|
+
parseContentTypeSchema({ fields: field.fields });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {import('./types').ContentTypeSchema} schema
|
|
59
|
+
* @param {Record<string, unknown>} data
|
|
60
|
+
* @param {{ sanitizeRichHtml?: (v: string) => string }} [options]
|
|
61
|
+
* @returns {Record<string, unknown>}
|
|
62
|
+
*/
|
|
63
|
+
function validateEntryData(schema, data, options = {}) {
|
|
64
|
+
const { normalizeEntryData } = require('./field-types');
|
|
65
|
+
return normalizeEntryData(data, schema.fields, options);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
SLUG_REGEX,
|
|
70
|
+
isValidSlug,
|
|
71
|
+
parseContentTypeSchema,
|
|
72
|
+
validateEntryData,
|
|
73
|
+
fieldDefinitionSchema,
|
|
74
|
+
contentTypeSchemaSchema,
|
|
75
|
+
};
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content service — CRUD for content types and entries.
|
|
3
|
+
* @module core/content/service
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { isValidSlug, parseContentTypeSchema, validateEntryData } = require('./schema');
|
|
7
|
+
const { createContentCache } = require('./cache');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {import('../orm').Database} db
|
|
11
|
+
* @param {Object} [options]
|
|
12
|
+
* @param {number|null} [options.cacheTtlMs] - null = in-memory until write invalidation
|
|
13
|
+
* @param {(html: string) => string} [options.sanitizeRichHtml]
|
|
14
|
+
*/
|
|
15
|
+
function createContentService(db, options = {}) {
|
|
16
|
+
if (!db) {
|
|
17
|
+
throw new Error('createContentService requires a database instance');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const TypeRepo = () => db.getRepository('ContentType');
|
|
21
|
+
const EntryRepo = () => db.getRepository('ContentEntry');
|
|
22
|
+
const cache = createContentCache(
|
|
23
|
+
options.cacheTtlMs !== undefined ? options.cacheTtlMs : null
|
|
24
|
+
);
|
|
25
|
+
const sanitizeRichHtml = options.sanitizeRichHtml;
|
|
26
|
+
|
|
27
|
+
function entryCacheKey(typeSlug, entrySlug, locale, includeDraft) {
|
|
28
|
+
return `entry:${typeSlug}:${entrySlug}:${locale ?? ''}:${includeDraft ? 'all' : 'pub'}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function invalidateType(typeSlug, typeId) {
|
|
32
|
+
if (typeSlug) {
|
|
33
|
+
cache.invalidatePrefix(`entry:${typeSlug}:`);
|
|
34
|
+
cache.invalidatePrefix(`entries:${typeSlug}:`);
|
|
35
|
+
cache.del(`type:slug:${typeSlug}`);
|
|
36
|
+
}
|
|
37
|
+
if (typeId != null) {
|
|
38
|
+
cache.del(`type:id:${typeId}`);
|
|
39
|
+
}
|
|
40
|
+
cache.invalidatePrefix('types:');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function invalidateEntry(entry, type) {
|
|
44
|
+
if (entry?.id != null) {
|
|
45
|
+
cache.del(`entry:id:${entry.id}`);
|
|
46
|
+
}
|
|
47
|
+
if (type?.slug && entry?.slug != null) {
|
|
48
|
+
cache.invalidatePrefix(`entry:${type.slug}:${entry.slug}:`);
|
|
49
|
+
}
|
|
50
|
+
if (type?.slug) {
|
|
51
|
+
cache.invalidatePrefix(`entries:${type.slug}:`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cacheTypeRow(row) {
|
|
56
|
+
if (!row) return;
|
|
57
|
+
cache.set(`type:slug:${row.slug}`, row);
|
|
58
|
+
cache.set(`type:id:${row.id}`, row);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @returns {Promise<import('./types').ContentTypeRecord[]>}
|
|
63
|
+
*/
|
|
64
|
+
async function listTypes() {
|
|
65
|
+
const cached = cache.get('types:all');
|
|
66
|
+
if (cached) return cached;
|
|
67
|
+
|
|
68
|
+
const rows = await db.query('ContentType').orderBy('name', 'asc').list();
|
|
69
|
+
cache.set('types:all', rows);
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
cacheTypeRow(row);
|
|
72
|
+
}
|
|
73
|
+
return rows;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {number|string} id
|
|
78
|
+
*/
|
|
79
|
+
async function getTypeById(id) {
|
|
80
|
+
const key = `type:id:${id}`;
|
|
81
|
+
const cached = cache.get(key);
|
|
82
|
+
if (cached) return cached;
|
|
83
|
+
|
|
84
|
+
const row = await TypeRepo().findById(id);
|
|
85
|
+
cacheTypeRow(row);
|
|
86
|
+
return row;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} slug
|
|
91
|
+
*/
|
|
92
|
+
async function getTypeBySlug(slug) {
|
|
93
|
+
const key = `type:slug:${slug}`;
|
|
94
|
+
const cached = cache.get(key);
|
|
95
|
+
if (cached) return cached;
|
|
96
|
+
|
|
97
|
+
const row = await TypeRepo().findOne({ slug });
|
|
98
|
+
cacheTypeRow(row);
|
|
99
|
+
return row;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {Object} input
|
|
104
|
+
* @param {string} input.slug
|
|
105
|
+
* @param {string} input.name
|
|
106
|
+
* @param {string} [input.description]
|
|
107
|
+
* @param {import('./types').ContentTypeSchema} input.schema
|
|
108
|
+
* @param {Object} [input.settings]
|
|
109
|
+
*/
|
|
110
|
+
async function createType(input) {
|
|
111
|
+
if (!isValidSlug(input.slug)) {
|
|
112
|
+
throw new Error('Invalid content type slug');
|
|
113
|
+
}
|
|
114
|
+
const schema = parseContentTypeSchema(input.schema);
|
|
115
|
+
const existing = await getTypeBySlug(input.slug);
|
|
116
|
+
if (existing) {
|
|
117
|
+
throw new Error(`Content type "${input.slug}" already exists`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const row = await TypeRepo().create({
|
|
121
|
+
slug: input.slug,
|
|
122
|
+
name: input.name,
|
|
123
|
+
description: input.description ?? null,
|
|
124
|
+
schema,
|
|
125
|
+
settings: input.settings ?? null,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
cache.invalidateAll();
|
|
129
|
+
return row;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {number|string} id
|
|
134
|
+
* @param {Object} input
|
|
135
|
+
*/
|
|
136
|
+
async function updateType(id, input) {
|
|
137
|
+
const existing = await getTypeById(id);
|
|
138
|
+
if (!existing) {
|
|
139
|
+
throw new Error('Content type not found');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @type {Record<string, unknown>} */
|
|
143
|
+
const patch = {};
|
|
144
|
+
|
|
145
|
+
if (input.slug !== undefined) {
|
|
146
|
+
if (!isValidSlug(input.slug)) {
|
|
147
|
+
throw new Error('Invalid content type slug');
|
|
148
|
+
}
|
|
149
|
+
if (input.slug !== existing.slug) {
|
|
150
|
+
const conflict = await getTypeBySlug(input.slug);
|
|
151
|
+
if (conflict) {
|
|
152
|
+
throw new Error(`Content type "${input.slug}" already exists`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
patch.slug = input.slug;
|
|
156
|
+
}
|
|
157
|
+
if (input.name !== undefined) patch.name = input.name;
|
|
158
|
+
if (input.description !== undefined) patch.description = input.description;
|
|
159
|
+
if (input.settings !== undefined) patch.settings = input.settings;
|
|
160
|
+
if (input.schema !== undefined) {
|
|
161
|
+
patch.schema = parseContentTypeSchema(input.schema);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const row = await TypeRepo().update(id, patch);
|
|
165
|
+
invalidateType(existing.slug, existing.id);
|
|
166
|
+
if (patch.slug && patch.slug !== existing.slug) {
|
|
167
|
+
invalidateType(String(patch.slug), existing.id);
|
|
168
|
+
}
|
|
169
|
+
return row;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {number|string} id
|
|
174
|
+
*/
|
|
175
|
+
async function deleteType(id) {
|
|
176
|
+
const existing = await getTypeById(id);
|
|
177
|
+
if (!existing) {
|
|
178
|
+
throw new Error('Content type not found');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const entries = await db.query('ContentEntry').where('content_type_id', existing.id).list();
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
invalidateEntry(entry, existing);
|
|
184
|
+
await EntryRepo().delete(entry.id);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await TypeRepo().delete(id);
|
|
188
|
+
invalidateType(existing.slug, existing.id);
|
|
189
|
+
return { success: true };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {string} typeSlug
|
|
194
|
+
* @param {Object} [filters]
|
|
195
|
+
*/
|
|
196
|
+
async function listEntries(typeSlug, filters = {}) {
|
|
197
|
+
const status = filters.status ?? '';
|
|
198
|
+
const localeKey = filters.locale !== undefined ? String(filters.locale) : '';
|
|
199
|
+
const listKey = `entries:${typeSlug}:${status}:${localeKey}`;
|
|
200
|
+
const cached = cache.get(listKey);
|
|
201
|
+
if (cached) return cached;
|
|
202
|
+
|
|
203
|
+
const type = await getTypeBySlug(typeSlug);
|
|
204
|
+
if (!type) {
|
|
205
|
+
throw new Error(`Content type "${typeSlug}" not found`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @type {Record<string, unknown>} */
|
|
209
|
+
const where = { content_type_id: type.id };
|
|
210
|
+
if (filters.status) where.status = filters.status;
|
|
211
|
+
if (filters.locale !== undefined) where.locale = filters.locale;
|
|
212
|
+
|
|
213
|
+
const rows = await db.query('ContentEntry')
|
|
214
|
+
.where(where)
|
|
215
|
+
.orderBy('updated_at', 'desc')
|
|
216
|
+
.list();
|
|
217
|
+
|
|
218
|
+
cache.set(listKey, rows);
|
|
219
|
+
return rows;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {string} typeSlug
|
|
224
|
+
* @param {string} entrySlug
|
|
225
|
+
* @param {import('./types').GetEntryOptions} [opts]
|
|
226
|
+
* @returns {Promise<import('./types').ContentEntryResult|null>}
|
|
227
|
+
*/
|
|
228
|
+
async function getEntry(typeSlug, entrySlug, opts = {}) {
|
|
229
|
+
const locale = opts.locale ?? null;
|
|
230
|
+
const includeDraft = Boolean(opts.includeDraft);
|
|
231
|
+
const key = entryCacheKey(typeSlug, entrySlug, locale, includeDraft);
|
|
232
|
+
const cached = cache.get(key);
|
|
233
|
+
if (cached !== undefined) return cached;
|
|
234
|
+
|
|
235
|
+
const type = await getTypeBySlug(typeSlug);
|
|
236
|
+
if (!type) return null;
|
|
237
|
+
|
|
238
|
+
/** @type {Record<string, unknown>} */
|
|
239
|
+
const where = {
|
|
240
|
+
content_type_id: type.id,
|
|
241
|
+
slug: entrySlug,
|
|
242
|
+
locale,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
if (!includeDraft) {
|
|
246
|
+
where.status = 'published';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const entry = await EntryRepo().findOne(where);
|
|
250
|
+
if (!entry) {
|
|
251
|
+
cache.set(key, null);
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const result = {
|
|
256
|
+
data: entry.data || {},
|
|
257
|
+
meta: {
|
|
258
|
+
id: entry.id,
|
|
259
|
+
slug: entry.slug,
|
|
260
|
+
title: entry.title,
|
|
261
|
+
status: entry.status,
|
|
262
|
+
locale: entry.locale,
|
|
263
|
+
revision: entry.revision,
|
|
264
|
+
contentTypeId: type.id,
|
|
265
|
+
typeSlug: type.slug,
|
|
266
|
+
updatedAt: entry.updated_at,
|
|
267
|
+
},
|
|
268
|
+
type,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
cache.set(key, result);
|
|
272
|
+
cache.set(`entry:id:${entry.id}`, { entry, type });
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @param {number|string} id
|
|
278
|
+
*/
|
|
279
|
+
async function getEntryById(id) {
|
|
280
|
+
const key = `entry:id:${id}`;
|
|
281
|
+
const cached = cache.get(key);
|
|
282
|
+
if (cached) return cached;
|
|
283
|
+
|
|
284
|
+
const entry = await EntryRepo().findById(id);
|
|
285
|
+
if (!entry) return null;
|
|
286
|
+
|
|
287
|
+
const type = await getTypeById(entry.content_type_id);
|
|
288
|
+
const bundle = { entry, type };
|
|
289
|
+
cache.set(key, bundle);
|
|
290
|
+
return bundle;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* @param {string} typeSlug
|
|
295
|
+
* @param {Object} input
|
|
296
|
+
*/
|
|
297
|
+
async function createEntry(typeSlug, input) {
|
|
298
|
+
const type = await getTypeBySlug(typeSlug);
|
|
299
|
+
if (!type) {
|
|
300
|
+
throw new Error(`Content type "${typeSlug}" not found`);
|
|
301
|
+
}
|
|
302
|
+
if (!isValidSlug(input.slug)) {
|
|
303
|
+
throw new Error('Invalid entry slug');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const locale = input.locale ?? null;
|
|
307
|
+
const existing = await EntryRepo().findOne({
|
|
308
|
+
content_type_id: type.id,
|
|
309
|
+
slug: input.slug,
|
|
310
|
+
locale,
|
|
311
|
+
});
|
|
312
|
+
if (existing) {
|
|
313
|
+
throw new Error(`Entry "${input.slug}" already exists for this content type`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const data = validateEntryData(type.schema, input.data || {}, { sanitizeRichHtml });
|
|
317
|
+
|
|
318
|
+
const row = await EntryRepo().create({
|
|
319
|
+
content_type_id: type.id,
|
|
320
|
+
slug: input.slug,
|
|
321
|
+
title: input.title ?? null,
|
|
322
|
+
data,
|
|
323
|
+
status: input.status === 'draft' ? 'draft' : 'published',
|
|
324
|
+
locale,
|
|
325
|
+
revision: 1,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
invalidateType(type.slug, type.id);
|
|
329
|
+
return row;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* @param {number|string} id
|
|
334
|
+
* @param {Object} input
|
|
335
|
+
*/
|
|
336
|
+
async function updateEntry(id, input) {
|
|
337
|
+
const bundle = await getEntryById(id);
|
|
338
|
+
if (!bundle) {
|
|
339
|
+
throw new Error('Content entry not found');
|
|
340
|
+
}
|
|
341
|
+
const { entry, type } = bundle;
|
|
342
|
+
|
|
343
|
+
/** @type {Record<string, unknown>} */
|
|
344
|
+
const patch = {};
|
|
345
|
+
|
|
346
|
+
if (input.slug !== undefined) {
|
|
347
|
+
if (!isValidSlug(input.slug)) {
|
|
348
|
+
throw new Error('Invalid entry slug');
|
|
349
|
+
}
|
|
350
|
+
patch.slug = input.slug;
|
|
351
|
+
}
|
|
352
|
+
if (input.title !== undefined) patch.title = input.title;
|
|
353
|
+
if (input.status !== undefined) {
|
|
354
|
+
patch.status = input.status === 'draft' ? 'draft' : 'published';
|
|
355
|
+
}
|
|
356
|
+
if (input.locale !== undefined) patch.locale = input.locale;
|
|
357
|
+
if (input.data !== undefined && type) {
|
|
358
|
+
patch.data = validateEntryData(type.schema, input.data, { sanitizeRichHtml });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
patch.revision = (entry.revision || 1) + 1;
|
|
362
|
+
|
|
363
|
+
const row = await EntryRepo().update(id, patch);
|
|
364
|
+
invalidateEntry(entry, type);
|
|
365
|
+
if (type) invalidateType(type.slug, type.id);
|
|
366
|
+
return row;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* @param {number|string} id
|
|
371
|
+
*/
|
|
372
|
+
async function deleteEntry(id) {
|
|
373
|
+
const bundle = await getEntryById(id);
|
|
374
|
+
if (!bundle) {
|
|
375
|
+
throw new Error('Content entry not found');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
await EntryRepo().delete(id);
|
|
379
|
+
invalidateEntry(bundle.entry, bundle.type);
|
|
380
|
+
if (bundle.type) invalidateType(bundle.type.slug, bundle.type.id);
|
|
381
|
+
return { success: true };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
listTypes,
|
|
386
|
+
getTypeById,
|
|
387
|
+
getTypeBySlug,
|
|
388
|
+
createType,
|
|
389
|
+
updateType,
|
|
390
|
+
deleteType,
|
|
391
|
+
listEntries,
|
|
392
|
+
getEntry,
|
|
393
|
+
getEntryById,
|
|
394
|
+
createEntry,
|
|
395
|
+
updateEntry,
|
|
396
|
+
deleteEntry,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
module.exports = { createContentService };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {'text'|'textarea'|'rich-text'|'number'|'boolean'|'image'|'url'|'date'|'select'|'repeater'} ContentFieldType
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} ContentFieldDefinition
|
|
7
|
+
* @property {string} name
|
|
8
|
+
* @property {ContentFieldType} type
|
|
9
|
+
* @property {string} [label]
|
|
10
|
+
* @property {boolean} [required]
|
|
11
|
+
* @property {string[]} [options] - for select
|
|
12
|
+
* @property {ContentFieldDefinition[]} [fields] - for repeater
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} ContentTypeSchema
|
|
17
|
+
* @property {ContentFieldDefinition[]} fields
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} ContentTypeRecord
|
|
22
|
+
* @property {number} id
|
|
23
|
+
* @property {string} slug
|
|
24
|
+
* @property {string} name
|
|
25
|
+
* @property {string|null} [description]
|
|
26
|
+
* @property {ContentTypeSchema} schema
|
|
27
|
+
* @property {Object|null} [settings]
|
|
28
|
+
* @property {string} [created_at]
|
|
29
|
+
* @property {string} [updated_at]
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} ContentEntryRecord
|
|
34
|
+
* @property {number} id
|
|
35
|
+
* @property {number} content_type_id
|
|
36
|
+
* @property {string} slug
|
|
37
|
+
* @property {string|null} [title]
|
|
38
|
+
* @property {Record<string, unknown>} data
|
|
39
|
+
* @property {'published'|'draft'} status
|
|
40
|
+
* @property {string|null} [locale]
|
|
41
|
+
* @property {number} [revision]
|
|
42
|
+
* @property {string} [created_at]
|
|
43
|
+
* @property {string} [updated_at]
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {Object} ContentEntryResult
|
|
48
|
+
* @property {Record<string, unknown>} data
|
|
49
|
+
* @property {Object} meta
|
|
50
|
+
* @property {ContentTypeRecord|null} [type]
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {Object} GetEntryOptions
|
|
55
|
+
* @property {string|null} [locale]
|
|
56
|
+
* @property {boolean} [includeDraft]
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
module.exports = {};
|
package/index.d.ts
CHANGED
|
@@ -595,6 +595,23 @@ export interface RateLimitPluginOptions {
|
|
|
595
595
|
|
|
596
596
|
export function rateLimitPlugin(options?: RateLimitPluginOptions): WebspressoPlugin;
|
|
597
597
|
|
|
598
|
+
export interface ContentPluginOptions {
|
|
599
|
+
db: import('./index').Database;
|
|
600
|
+
adminPath?: string;
|
|
601
|
+
publicApiPath?: string;
|
|
602
|
+
inlineEdit?: boolean;
|
|
603
|
+
cacheTtlMs?: number | null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export function contentPlugin(options: ContentPluginOptions): WebspressoPlugin;
|
|
607
|
+
|
|
608
|
+
export const content: {
|
|
609
|
+
createContentService: typeof import('./core/content').createContentService;
|
|
610
|
+
parseContentTypeSchema: typeof import('./core/content').parseContentTypeSchema;
|
|
611
|
+
validateEntryData: typeof import('./core/content').validateEntryData;
|
|
612
|
+
wrapEditable: typeof import('./core/content').wrapEditable;
|
|
613
|
+
};
|
|
614
|
+
|
|
598
615
|
export interface RestResourcePluginOptions {
|
|
599
616
|
path?: string;
|
|
600
617
|
middleware?: RequestHandler[];
|
package/index.js
CHANGED
|
@@ -62,8 +62,11 @@ const {
|
|
|
62
62
|
dataExchangePlugin,
|
|
63
63
|
redirectPlugin,
|
|
64
64
|
rateLimitPlugin,
|
|
65
|
+
contentPlugin,
|
|
65
66
|
} = require('./plugins');
|
|
66
67
|
|
|
68
|
+
const content = require('./core/content');
|
|
69
|
+
|
|
67
70
|
module.exports = {
|
|
68
71
|
// Main API
|
|
69
72
|
createApp,
|
|
@@ -129,4 +132,8 @@ module.exports = {
|
|
|
129
132
|
dataExchangePlugin,
|
|
130
133
|
redirectPlugin,
|
|
131
134
|
rateLimitPlugin,
|
|
135
|
+
contentPlugin,
|
|
136
|
+
|
|
137
|
+
// Schema-driven CMS core (framework-agnostic)
|
|
138
|
+
content,
|
|
132
139
|
};
|
package/package.json
CHANGED
|
@@ -45,7 +45,7 @@ function getUserManagementModel() {
|
|
|
45
45
|
async function guardUserManagementRoutes() {
|
|
46
46
|
var isAuth = await checkAuth();
|
|
47
47
|
if (!isAuth) {
|
|
48
|
-
|
|
48
|
+
redirectToLogin();
|
|
49
49
|
return false;
|
|
50
50
|
}
|
|
51
51
|
if (!getUserManagementModel()) {
|
|
@@ -135,7 +135,7 @@ var routes = {
|
|
|
135
135
|
onmatch: async () => {
|
|
136
136
|
const isAuth = await checkAuth();
|
|
137
137
|
if (isAuth) {
|
|
138
|
-
m.route.set('/');
|
|
138
|
+
m.route.set(consumeIntendedRoute('/'));
|
|
139
139
|
return;
|
|
140
140
|
}
|
|
141
141
|
return LoginForm;
|
|
@@ -155,7 +155,7 @@ var routes = {
|
|
|
155
155
|
onmatch: async () => {
|
|
156
156
|
const isAuth = await checkAuth();
|
|
157
157
|
if (!isAuth) {
|
|
158
|
-
|
|
158
|
+
redirectToLogin();
|
|
159
159
|
return;
|
|
160
160
|
}
|
|
161
161
|
return SettingsPage;
|
|
@@ -193,7 +193,7 @@ var routes = {
|
|
|
193
193
|
onmatch: async () => {
|
|
194
194
|
const isAuth = await checkAuth();
|
|
195
195
|
if (!isAuth) {
|
|
196
|
-
|
|
196
|
+
redirectToLogin();
|
|
197
197
|
return;
|
|
198
198
|
}
|
|
199
199
|
return RecordList;
|
|
@@ -203,7 +203,7 @@ var routes = {
|
|
|
203
203
|
onmatch: async () => {
|
|
204
204
|
const isAuth = await checkAuth();
|
|
205
205
|
if (!isAuth) {
|
|
206
|
-
|
|
206
|
+
redirectToLogin();
|
|
207
207
|
return;
|
|
208
208
|
}
|
|
209
209
|
return RecordForm;
|
|
@@ -213,7 +213,7 @@ var routes = {
|
|
|
213
213
|
onmatch: async () => {
|
|
214
214
|
const isAuth = await checkAuth();
|
|
215
215
|
if (!isAuth) {
|
|
216
|
-
|
|
216
|
+
redirectToLogin();
|
|
217
217
|
return;
|
|
218
218
|
}
|
|
219
219
|
return RecordForm;
|
|
@@ -230,7 +230,7 @@ if (config && config.pages) {
|
|
|
230
230
|
onmatch: async () => {
|
|
231
231
|
const isAuth = await checkAuth();
|
|
232
232
|
if (!isAuth) {
|
|
233
|
-
|
|
233
|
+
redirectToLogin();
|
|
234
234
|
return;
|
|
235
235
|
}
|
|
236
236
|
if (window.__customPages && window.__customPages[page.id]) {
|
|
@@ -15,6 +15,9 @@ const api = {
|
|
|
15
15
|
|
|
16
16
|
if (!response.ok) {
|
|
17
17
|
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
|
18
|
+
if (response.status === 401 && path.indexOf('/auth/') !== 0) {
|
|
19
|
+
redirectToLogin();
|
|
20
|
+
}
|
|
18
21
|
throw new Error(error.error || 'Request failed');
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -101,6 +104,44 @@ const state = {
|
|
|
101
104
|
selectAllMode: false, // true = all records selected (not just current page)
|
|
102
105
|
};
|
|
103
106
|
|
|
107
|
+
var INTENDED_ROUTE_KEY = 'webspresso.admin.intendedRoute';
|
|
108
|
+
|
|
109
|
+
function isSafeIntendedRoute(path) {
|
|
110
|
+
if (!path || typeof path !== 'string') return false;
|
|
111
|
+
if (path.charAt(0) !== '/') return false;
|
|
112
|
+
if (path === '/login' || path === '/setup') return false;
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function saveIntendedRoute() {
|
|
117
|
+
try {
|
|
118
|
+
var path = m.route.get();
|
|
119
|
+
if (!isSafeIntendedRoute(path)) return;
|
|
120
|
+
sessionStorage.setItem(INTENDED_ROUTE_KEY, path);
|
|
121
|
+
} catch (e) {}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function clearIntendedRoute() {
|
|
125
|
+
try {
|
|
126
|
+
sessionStorage.removeItem(INTENDED_ROUTE_KEY);
|
|
127
|
+
} catch (e) {}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function consumeIntendedRoute(defaultPath) {
|
|
131
|
+
defaultPath = defaultPath || '/';
|
|
132
|
+
try {
|
|
133
|
+
var path = sessionStorage.getItem(INTENDED_ROUTE_KEY);
|
|
134
|
+
sessionStorage.removeItem(INTENDED_ROUTE_KEY);
|
|
135
|
+
if (isSafeIntendedRoute(path)) return path;
|
|
136
|
+
} catch (e) {}
|
|
137
|
+
return defaultPath;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function redirectToLogin() {
|
|
141
|
+
saveIntendedRoute();
|
|
142
|
+
m.route.set('/login');
|
|
143
|
+
}
|
|
144
|
+
|
|
104
145
|
// Breadcrumb Component
|
|
105
146
|
const Breadcrumb = {
|
|
106
147
|
view: (vnode) => {
|