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,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-scoped template helpers for content inline editing.
|
|
3
|
+
* @module plugins/content/helpers
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { wrapEditable, wrapEntryBlock } = require('../../core/content');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {import('express').Request} req
|
|
10
|
+
* @param {string} [adminPath='/_admin']
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
function isAdminSession(req, adminPath = '/_admin') {
|
|
14
|
+
return Boolean(req.session && req.session.adminUser);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {Object} options
|
|
19
|
+
* @param {string} [options.adminPath='/_admin']
|
|
20
|
+
* @returns {(req: import('express').Request) => Object}
|
|
21
|
+
*/
|
|
22
|
+
function createRequestContentHelpers(options = {}) {
|
|
23
|
+
const adminPath = options.adminPath || '/_admin';
|
|
24
|
+
|
|
25
|
+
return function buildContentHelpers(req) {
|
|
26
|
+
const isAdmin = isAdminSession(req);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
isAdmin() {
|
|
30
|
+
return isAdmin;
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
adminPath() {
|
|
34
|
+
return adminPath;
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {import('../../core/content/types').ContentEntryResult|null|undefined} entry
|
|
39
|
+
*/
|
|
40
|
+
raw(entry) {
|
|
41
|
+
return entry?.data ?? {};
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {unknown} value
|
|
46
|
+
* @param {Object} meta
|
|
47
|
+
* @param {number|string} meta.entryId
|
|
48
|
+
* @param {string} meta.typeSlug
|
|
49
|
+
* @param {string} [meta.field]
|
|
50
|
+
* @param {string} [meta.label]
|
|
51
|
+
* @param {boolean} [meta.safeHtml]
|
|
52
|
+
*/
|
|
53
|
+
editable(value, meta = {}) {
|
|
54
|
+
return wrapEditable(value, {
|
|
55
|
+
...meta,
|
|
56
|
+
isAdmin,
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} innerHtml
|
|
62
|
+
* @param {Object} meta
|
|
63
|
+
*/
|
|
64
|
+
block(innerHtml, meta = {}) {
|
|
65
|
+
return wrapEntryBlock(innerHtml, {
|
|
66
|
+
...meta,
|
|
67
|
+
isAdmin,
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
isAdminSession,
|
|
76
|
+
createRequestContentHelpers,
|
|
77
|
+
};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-driven content CMS plugin for Webspresso.
|
|
3
|
+
* @module plugins/content
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { createContentService } = require('../../core/content');
|
|
9
|
+
const { createContentTypeModel } = require('./models/content-type');
|
|
10
|
+
const { createContentEntryModel } = require('./models/content-entry');
|
|
11
|
+
const { createContentApiHandlers } = require('./api-handlers');
|
|
12
|
+
const { createRequestContentHelpers, isAdminSession } = require('./helpers');
|
|
13
|
+
const { generateContentTypesComponent } = require('./admin/content-types-component');
|
|
14
|
+
const { generateContentEntriesComponent } = require('./admin/content-entries-component');
|
|
15
|
+
const { generateContentMigrations } = require('./migration-template');
|
|
16
|
+
|
|
17
|
+
const INLINE_EDIT_JS = fs.readFileSync(path.join(__dirname, 'client/inline-edit.js'), 'utf8');
|
|
18
|
+
const INLINE_EDIT_CSS = fs.readFileSync(path.join(__dirname, 'client/inline-edit.css'), 'utf8');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {Object} options
|
|
22
|
+
* @param {import('../../core/orm').Database} options.db
|
|
23
|
+
* @param {string} [options.adminPath='/_admin']
|
|
24
|
+
* @param {string} [options.publicApiPath='/api/content']
|
|
25
|
+
* @param {boolean} [options.inlineEdit=true]
|
|
26
|
+
* @param {number|null} [options.cacheTtlMs] - In-memory cache TTL; null (default) = until write invalidation
|
|
27
|
+
*/
|
|
28
|
+
function contentPlugin(options = {}) {
|
|
29
|
+
const {
|
|
30
|
+
db,
|
|
31
|
+
adminPath: adminPathOpt,
|
|
32
|
+
publicApiPath = '/api/content',
|
|
33
|
+
inlineEdit = true,
|
|
34
|
+
cacheTtlMs,
|
|
35
|
+
} = options;
|
|
36
|
+
|
|
37
|
+
if (!db) {
|
|
38
|
+
throw new Error('content plugin requires a database instance. Pass `db` in options.');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @type {ReturnType<typeof createContentService>|null} */
|
|
42
|
+
let contentService = null;
|
|
43
|
+
let adminPath = adminPathOpt || '/_admin';
|
|
44
|
+
|
|
45
|
+
function getService(database) {
|
|
46
|
+
if (!contentService) {
|
|
47
|
+
let sanitizeRichHtml;
|
|
48
|
+
try {
|
|
49
|
+
({ sanitizeRichHtml } = require('../admin-panel/lib/sanitize-rich-html'));
|
|
50
|
+
} catch {
|
|
51
|
+
sanitizeRichHtml = (v) => v;
|
|
52
|
+
}
|
|
53
|
+
contentService = createContentService(database || db, { cacheTtlMs, sanitizeRichHtml });
|
|
54
|
+
}
|
|
55
|
+
return contentService;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function maybeInjectInlineEdit(req, html) {
|
|
59
|
+
if (!inlineEdit || !isAdminSession(req, adminPath)) {
|
|
60
|
+
return html;
|
|
61
|
+
}
|
|
62
|
+
const configScript =
|
|
63
|
+
'<script>window.__WS_CONTENT__=' +
|
|
64
|
+
JSON.stringify({ enabled: true, adminPath }) +
|
|
65
|
+
';</script>';
|
|
66
|
+
const styleTag = '<style id="ws-content-inline-css">' + INLINE_EDIT_CSS + '</style>';
|
|
67
|
+
const scriptTag = '<script id="ws-content-inline-edit">' + INLINE_EDIT_JS + '</script>';
|
|
68
|
+
const bundle = configScript + styleTag + scriptTag;
|
|
69
|
+
if (html.includes('</body>')) {
|
|
70
|
+
return html.replace('</body>', bundle + '</body>');
|
|
71
|
+
}
|
|
72
|
+
return html + bundle;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
name: 'content',
|
|
77
|
+
version: '1.0.0',
|
|
78
|
+
description: 'Schema-driven SQLite CMS with inline admin editing',
|
|
79
|
+
dependencies: { 'admin-panel': '*' },
|
|
80
|
+
|
|
81
|
+
csp: {
|
|
82
|
+
styleSrc: ["'unsafe-inline'"],
|
|
83
|
+
scriptSrc: ["'unsafe-inline'"],
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
api: {
|
|
87
|
+
getContentService: getService,
|
|
88
|
+
createRequestHelpers: () => createRequestContentHelpers({ adminPath }),
|
|
89
|
+
maybeInjectInlineEdit,
|
|
90
|
+
getMigrationTemplate: generateContentMigrations,
|
|
91
|
+
_options: { adminPath },
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
register(ctx) {
|
|
95
|
+
const { hasModel, getModel } = require('../../core/orm/model');
|
|
96
|
+
|
|
97
|
+
if (!hasModel('ContentType')) {
|
|
98
|
+
db.registerModel(createContentTypeModel());
|
|
99
|
+
} else if (!db.hasModel('ContentType')) {
|
|
100
|
+
db.registerModel(getModel('ContentType'));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!hasModel('ContentEntry')) {
|
|
104
|
+
db.registerModel(createContentEntryModel());
|
|
105
|
+
} else if (!db.hasModel('ContentEntry')) {
|
|
106
|
+
db.registerModel(getModel('ContentEntry'));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
getService(db);
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
onRoutesReady(ctx) {
|
|
113
|
+
adminPath = adminPathOpt || '/_admin';
|
|
114
|
+
this.api._options = { adminPath };
|
|
115
|
+
|
|
116
|
+
const service = getService(ctx.db || db);
|
|
117
|
+
const handlers = createContentApiHandlers({ contentService: service });
|
|
118
|
+
|
|
119
|
+
const publicBase = publicApiPath.replace(/\/$/, '');
|
|
120
|
+
ctx.addRoute(
|
|
121
|
+
'get',
|
|
122
|
+
`${publicBase}/:typeSlug/:entrySlug`,
|
|
123
|
+
handlers.publicGetEntryHandler
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const adminApi = ctx.usePlugin('admin-panel');
|
|
127
|
+
if (!adminApi) {
|
|
128
|
+
console.warn('[content] admin-panel plugin not found, skipping admin UI');
|
|
129
|
+
const { requireAuth } = require('../admin-panel/auth');
|
|
130
|
+
const base = `${adminPath}/api/content`;
|
|
131
|
+
ctx.addRoute('get', `${base}/types`, requireAuth, handlers.listTypesHandler);
|
|
132
|
+
ctx.addRoute('post', `${base}/types`, requireAuth, handlers.createTypeHandler);
|
|
133
|
+
ctx.addRoute('get', `${base}/types/:id`, requireAuth, handlers.getTypeHandler);
|
|
134
|
+
ctx.addRoute('put', `${base}/types/:id`, requireAuth, handlers.updateTypeHandler);
|
|
135
|
+
ctx.addRoute('delete', `${base}/types/:id`, requireAuth, handlers.deleteTypeHandler);
|
|
136
|
+
ctx.addRoute('get', `${base}/types/:typeSlug/schema`, requireAuth, handlers.getTypeSchemaHandler);
|
|
137
|
+
ctx.addRoute('get', `${base}/types/:typeSlug/entries`, requireAuth, handlers.listEntriesHandler);
|
|
138
|
+
ctx.addRoute('post', `${base}/types/:typeSlug/entries`, requireAuth, handlers.createEntryHandler);
|
|
139
|
+
ctx.addRoute('get', `${base}/entries/:id`, requireAuth, handlers.getEntryHandler);
|
|
140
|
+
ctx.addRoute('put', `${base}/entries/:id`, requireAuth, handlers.updateEntryHandler);
|
|
141
|
+
ctx.addRoute('delete', `${base}/entries/:id`, requireAuth, handlers.deleteEntryHandler);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const apiPrefix = '/content';
|
|
146
|
+
adminApi.registerModule({
|
|
147
|
+
id: 'content',
|
|
148
|
+
|
|
149
|
+
menuGroups: {
|
|
150
|
+
cms: {
|
|
151
|
+
label: 'CMS',
|
|
152
|
+
icon: 'database',
|
|
153
|
+
order: -1,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
menu: [
|
|
158
|
+
{
|
|
159
|
+
id: 'content-types',
|
|
160
|
+
label: 'Content Types',
|
|
161
|
+
path: '/content/types',
|
|
162
|
+
icon: 'database',
|
|
163
|
+
group: 'cms',
|
|
164
|
+
order: 0,
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
|
|
168
|
+
pages: [
|
|
169
|
+
{
|
|
170
|
+
id: 'content-types',
|
|
171
|
+
title: 'Content Types',
|
|
172
|
+
path: '/content/types',
|
|
173
|
+
icon: 'database',
|
|
174
|
+
component: generateContentTypesComponent({ apiPrefix }),
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: 'content-types-new',
|
|
178
|
+
title: 'New Content Type',
|
|
179
|
+
path: '/content/types/new',
|
|
180
|
+
component: generateContentTypesComponent({ apiPrefix }),
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 'content-types-edit',
|
|
184
|
+
title: 'Edit Content Type',
|
|
185
|
+
path: '/content/types/edit/:id',
|
|
186
|
+
component: generateContentTypesComponent({ apiPrefix }),
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: 'content-entries',
|
|
190
|
+
title: 'Content Entries',
|
|
191
|
+
path: '/content/types/:typeSlug/entries',
|
|
192
|
+
component: generateContentEntriesComponent({ apiPrefix }),
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: 'content-entries-new',
|
|
196
|
+
title: 'New Content Entry',
|
|
197
|
+
path: '/content/types/:typeSlug/entries/new',
|
|
198
|
+
component: generateContentEntriesComponent({ apiPrefix }),
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 'content-entries-edit',
|
|
202
|
+
title: 'Edit Content Entry',
|
|
203
|
+
path: '/content/types/:typeSlug/entries/edit/:id',
|
|
204
|
+
component: generateContentEntriesComponent({ apiPrefix }),
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
|
|
208
|
+
api: {
|
|
209
|
+
prefix: apiPrefix,
|
|
210
|
+
routes: [
|
|
211
|
+
{ method: 'get', path: '/types', handler: handlers.listTypesHandler },
|
|
212
|
+
{ method: 'post', path: '/types', handler: handlers.createTypeHandler },
|
|
213
|
+
{ method: 'get', path: '/types/:typeSlug/schema', handler: handlers.getTypeSchemaHandler },
|
|
214
|
+
{ method: 'get', path: '/types/:typeSlug/entries', handler: handlers.listEntriesHandler },
|
|
215
|
+
{ method: 'post', path: '/types/:typeSlug/entries', handler: handlers.createEntryHandler },
|
|
216
|
+
{ method: 'get', path: '/types/:id', handler: handlers.getTypeHandler },
|
|
217
|
+
{ method: 'put', path: '/types/:id', handler: handlers.updateTypeHandler },
|
|
218
|
+
{ method: 'delete', path: '/types/:id', handler: handlers.deleteTypeHandler },
|
|
219
|
+
{ method: 'get', path: '/entries/:id', handler: handlers.getEntryHandler },
|
|
220
|
+
{ method: 'put', path: '/entries/:id', handler: handlers.updateEntryHandler },
|
|
221
|
+
{ method: 'delete', path: '/entries/:id', handler: handlers.deleteEntryHandler },
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = contentPlugin;
|
|
230
|
+
module.exports.contentPlugin = contentPlugin;
|
|
231
|
+
module.exports.generateContentMigrations = generateContentMigrations;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration template for content plugin tables.
|
|
3
|
+
* @module plugins/content/migration-template
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} [typesTable='content_types']
|
|
8
|
+
* @param {string} [entriesTable='content_entries']
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
function generateContentMigrations(typesTable = 'content_types', entriesTable = 'content_entries') {
|
|
12
|
+
return `/**
|
|
13
|
+
* Migration: Create content_types and content_entries tables
|
|
14
|
+
* Generated by content plugin — customize table names if needed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
exports.up = async function (knex) {
|
|
18
|
+
await knex.schema.createTable('${typesTable}', (table) => {
|
|
19
|
+
table.increments('id').primary();
|
|
20
|
+
table.string('slug', 128).notNullable().unique();
|
|
21
|
+
table.string('name', 255).notNullable();
|
|
22
|
+
table.text('description').nullable();
|
|
23
|
+
table.json('schema').notNullable();
|
|
24
|
+
table.json('settings').nullable();
|
|
25
|
+
table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable();
|
|
26
|
+
table.timestamp('updated_at').defaultTo(knex.fn.now()).notNullable();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await knex.schema.createTable('${entriesTable}', (table) => {
|
|
30
|
+
table.increments('id').primary();
|
|
31
|
+
table.integer('content_type_id').unsigned().notNullable()
|
|
32
|
+
.references('id').inTable('${typesTable}').onDelete('CASCADE');
|
|
33
|
+
table.string('slug', 128).notNullable();
|
|
34
|
+
table.string('title', 255).nullable();
|
|
35
|
+
table.json('data').notNullable();
|
|
36
|
+
table.string('status', 32).notNullable().defaultTo('published');
|
|
37
|
+
table.string('locale', 16).nullable();
|
|
38
|
+
table.integer('revision').notNullable().defaultTo(1);
|
|
39
|
+
table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable();
|
|
40
|
+
table.timestamp('updated_at').defaultTo(knex.fn.now()).notNullable();
|
|
41
|
+
|
|
42
|
+
table.unique(['content_type_id', 'slug', 'locale']);
|
|
43
|
+
table.index(['content_type_id', 'status']);
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
exports.down = async function (knex) {
|
|
48
|
+
await knex.schema.dropTableIfExists('${entriesTable}');
|
|
49
|
+
await knex.schema.dropTableIfExists('${typesTable}');
|
|
50
|
+
};
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { generateContentMigrations };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContentEntry model — content entries with JSON field data.
|
|
3
|
+
* @module plugins/content/models/content-entry
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { defineModel } = require('../../../core/orm/model');
|
|
7
|
+
const { zdb } = require('../../../core/orm');
|
|
8
|
+
|
|
9
|
+
const ContentEntrySchema = zdb.schema({
|
|
10
|
+
id: zdb.id(),
|
|
11
|
+
content_type_id: zdb.foreignKey('content_types', { index: true }),
|
|
12
|
+
slug: zdb.string({ maxLength: 128, index: true }),
|
|
13
|
+
title: zdb.string({ maxLength: 255, nullable: true }),
|
|
14
|
+
data: zdb.json(),
|
|
15
|
+
status: zdb.enum(['published', 'draft'], { default: 'published', index: true }),
|
|
16
|
+
locale: zdb.string({ maxLength: 16, nullable: true, index: true }),
|
|
17
|
+
revision: zdb.integer({ default: 1 }),
|
|
18
|
+
created_at: zdb.timestamp({ auto: 'create' }),
|
|
19
|
+
updated_at: zdb.timestamp({ auto: 'update' }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @returns {import('../../../core/orm/types').ModelDefinition}
|
|
24
|
+
*/
|
|
25
|
+
function createContentEntryModel() {
|
|
26
|
+
return defineModel({
|
|
27
|
+
name: 'ContentEntry',
|
|
28
|
+
table: 'content_entries',
|
|
29
|
+
schema: ContentEntrySchema,
|
|
30
|
+
scopes: { timestamps: true },
|
|
31
|
+
admin: { enabled: false },
|
|
32
|
+
relations: {
|
|
33
|
+
contentType: {
|
|
34
|
+
type: 'belongsTo',
|
|
35
|
+
model: () => require('../../../core/orm/model').getModel('ContentType'),
|
|
36
|
+
foreignKey: 'content_type_id',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = {
|
|
43
|
+
createContentEntryModel,
|
|
44
|
+
ContentEntrySchema,
|
|
45
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContentType model — schema-driven content type definitions.
|
|
3
|
+
* @module plugins/content/models/content-type
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { defineModel } = require('../../../core/orm/model');
|
|
7
|
+
const { zdb } = require('../../../core/orm');
|
|
8
|
+
|
|
9
|
+
const ContentTypeSchema = zdb.schema({
|
|
10
|
+
id: zdb.id(),
|
|
11
|
+
slug: zdb.string({ unique: true, maxLength: 128, index: true }),
|
|
12
|
+
name: zdb.string({ maxLength: 255 }),
|
|
13
|
+
description: zdb.text({ nullable: true }),
|
|
14
|
+
schema: zdb.json(),
|
|
15
|
+
settings: zdb.json({ nullable: true }),
|
|
16
|
+
created_at: zdb.timestamp({ auto: 'create' }),
|
|
17
|
+
updated_at: zdb.timestamp({ auto: 'update' }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @returns {import('../../../core/orm/types').ModelDefinition}
|
|
22
|
+
*/
|
|
23
|
+
function createContentTypeModel() {
|
|
24
|
+
return defineModel({
|
|
25
|
+
name: 'ContentType',
|
|
26
|
+
table: 'content_types',
|
|
27
|
+
schema: ContentTypeSchema,
|
|
28
|
+
scopes: { timestamps: true },
|
|
29
|
+
admin: { enabled: false },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
createContentTypeModel,
|
|
35
|
+
ContentTypeSchema,
|
|
36
|
+
};
|
package/plugins/index.js
CHANGED
|
@@ -21,6 +21,7 @@ const { uploadPlugin, createLocalFileProvider } = require('./upload');
|
|
|
21
21
|
const { dataExchangePlugin } = require('./data-exchange');
|
|
22
22
|
const { redirectPlugin } = require('./redirect');
|
|
23
23
|
const { rateLimitPlugin } = require('./rate-limit');
|
|
24
|
+
const contentPlugin = require('./content');
|
|
24
25
|
|
|
25
26
|
module.exports = {
|
|
26
27
|
sitemapPlugin,
|
|
@@ -41,5 +42,6 @@ module.exports = {
|
|
|
41
42
|
dataExchangePlugin,
|
|
42
43
|
redirectPlugin,
|
|
43
44
|
rateLimitPlugin,
|
|
45
|
+
contentPlugin,
|
|
44
46
|
};
|
|
45
47
|
|
package/src/file-router.js
CHANGED
|
@@ -810,6 +810,13 @@ function mountPages(app, options) {
|
|
|
810
810
|
// Create context with plugin helpers merged
|
|
811
811
|
const baseHelpers = createHelpers({ req, res, locale });
|
|
812
812
|
const pluginHelpers = pluginManager ? pluginManager.getHelpers() : {};
|
|
813
|
+
if (pluginManager) {
|
|
814
|
+
const contentApi = pluginManager.getPluginAPI('content');
|
|
815
|
+
if (contentApi?.createRequestHelpers) {
|
|
816
|
+
const buildHelpers = contentApi.createRequestHelpers();
|
|
817
|
+
pluginHelpers.content = buildHelpers(req);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
813
820
|
|
|
814
821
|
const njkTpl = loadNjkRouteTemplate(route.fullPath, isDev);
|
|
815
822
|
|
|
@@ -863,6 +870,12 @@ function mountPages(app, options) {
|
|
|
863
870
|
await executeHook(routeHooks, 'afterMiddleware', ctx);
|
|
864
871
|
|
|
865
872
|
// Execute hooks: beforeLoad
|
|
873
|
+
if (pluginManager && db) {
|
|
874
|
+
const contentApi = pluginManager.getPluginAPI('content');
|
|
875
|
+
if (contentApi?.getContentService) {
|
|
876
|
+
ctx.content = contentApi.getContentService(db);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
866
879
|
await executeHook(globalHooks, 'beforeLoad', ctx);
|
|
867
880
|
await executeHook(routeHooks, 'beforeLoad', ctx);
|
|
868
881
|
|
|
@@ -908,10 +921,17 @@ function mountPages(app, options) {
|
|
|
908
921
|
|
|
909
922
|
// Render the template
|
|
910
923
|
const templatePath = route.file.split(path.sep).join('/');
|
|
911
|
-
|
|
924
|
+
let html =
|
|
912
925
|
njkTpl.useStringRender && njkTpl.templateBody != null
|
|
913
926
|
? nunjucks.renderString(njkTpl.templateBody, renderContext, { path: route.fullPath })
|
|
914
927
|
: nunjucks.render(templatePath, renderContext);
|
|
928
|
+
|
|
929
|
+
if (pluginManager) {
|
|
930
|
+
const contentApi = pluginManager.getPluginAPI('content');
|
|
931
|
+
if (contentApi?.maybeInjectInlineEdit) {
|
|
932
|
+
html = contentApi.maybeInjectInlineEdit(req, html);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
915
935
|
|
|
916
936
|
// Execute hooks: afterRender
|
|
917
937
|
ctx.html = html;
|
|
@@ -178,7 +178,7 @@ Analytics plugin adds `fsy.analyticsHead`, `fsy.verificationTags`, etc., when co
|
|
|
178
178
|
|
|
179
179
|
**Query builder:** `UserRepo.query().where(...).with('relation').orderBy(...).list()` / `.first()` / `.paginate()` / `.count()`. **`with()`** eager-loads relations; **`count()`** ignores builder `.limit`/`.offset` for total; see ORM docs for edge cases.
|
|
180
180
|
|
|
181
|
-
**Migrations:** `webspresso db:migrate`, `db:rollback`, `db:status`, `db:make
|
|
181
|
+
**Migrations:** `webspresso db:migrate`, `db:rollback`, `db:status`, `db:make`, `db:scaffold` (all models → create migrations).
|
|
182
182
|
|
|
183
183
|
**Transactions:** `db.transaction(async (trx) => { trx.getRepository('User') })`.
|
|
184
184
|
|
|
@@ -21,6 +21,7 @@ There are **three** files in this folder; open the reference that matches the ta
|
|
|
21
21
|
| Greenfield project, `webspresso new`, `dev`, `db:migrate`, `doctor` | [`REFERENCE-framework.md`](./REFERENCE-framework.md) § CLI | Full command table there. |
|
|
22
22
|
| Event bus, `kernel.createApp`, `defineFlow`, `definePlugin`, simulated `BaseRepository`, namespaced minimal views | [`REFERENCE-kernel.md`](./REFERENCE-kernel.md) | **Not** SSR **`createApp`** — [`doc/index.html#application-kernel`](../../../doc/index.html#application-kernel). |
|
|
23
23
|
| Long-form narrative, all options | [`README.md`](../../../README.md), [`doc/index.html`](../../../doc/index.html) | Single-page HTML docs. |
|
|
24
|
+
| System structure, feature sets, C4 map | [`docs/ARCHITECTURE.md`](../../../docs/ARCHITECTURE.md) | **Update when adding plugins, CLI commands, or extension points.** |
|
|
24
25
|
|
|
25
26
|
## CLI cheat sheet
|
|
26
27
|
|
|
@@ -40,3 +41,7 @@ There are **three** files in this folder; open the reference that matches the ta
|
|
|
40
41
|
- **`REFERENCE-kernel.md`:** `kernel`, domain events, `dispatch` / `publish`, `registerFlow`, `definePlugin` inside the kernel.
|
|
41
42
|
|
|
42
43
|
They are independent: most apps only need SSR **`createApp`**; the kernel is optional.
|
|
44
|
+
|
|
45
|
+
## Architecture maintenance
|
|
46
|
+
|
|
47
|
+
When you add or materially change a **plugin**, **CLI command**, **extension point**, or **request-pipeline rule**, update [`docs/ARCHITECTURE.md`](../../../docs/ARCHITECTURE.md) (feature-set section + changelog).
|