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
package/README.md
CHANGED
|
@@ -1792,6 +1792,11 @@ webspresso db:make create_posts_table
|
|
|
1792
1792
|
# Create migration from model (scaffolding)
|
|
1793
1793
|
webspresso db:make create_users_table --model User
|
|
1794
1794
|
|
|
1795
|
+
# Scaffold create-table migrations for all models in models/
|
|
1796
|
+
webspresso db:scaffold
|
|
1797
|
+
webspresso db:scaffold --only User,Post
|
|
1798
|
+
webspresso db:scaffold --dry-run
|
|
1799
|
+
|
|
1795
1800
|
# Admin Panel Setup
|
|
1796
1801
|
webspresso admin:setup # Create admin_users migration
|
|
1797
1802
|
webspresso admin:list # List all admin users
|
|
@@ -2165,7 +2170,7 @@ const { app } = createApp({
|
|
|
2165
2170
|
```
|
|
2166
2171
|
|
|
2167
2172
|
- **ORM:** `zdb.file({ maxLength: 2048, nullable: true })` — string column for the stored public URL or path; migrations use `table.string(..., maxLength)`.
|
|
2168
|
-
- **Admin forms:** columns with `zdb.file()` automatically render a drag-and-drop upload widget (or a manual URL text field when `uploadUrl` is not configured). Optional `ui: { label, hint, accept, maxBytes }` on the column customizes the widget. For existing `zdb.string()` columns you can use `admin.customFields: { columnName: { type: 'file-upload' } }` instead of changing the schema type.
|
|
2173
|
+
- **Admin forms:** columns with `zdb.file()` automatically render a drag-and-drop upload widget (or a manual URL text field when `uploadUrl` is not configured). Image URLs show an inline thumbnail preview; use `ui.accept: 'image/*'` for image-only fields. Optional `ui: { label, hint, accept, maxBytes }` on the column customizes the widget. For existing `zdb.string()` columns you can use `admin.customFields: { columnName: { type: 'file-upload' } }` instead of changing the schema type.
|
|
2169
2174
|
- **Admin:** the panel reads **`settings.uploadUrl`** from the registry (set automatically when `uploadPlugin` is registered **before** `adminPanelPlugin`, or pass **`adminPanelPlugin({ uploadUrl: '/api/upload' })`**). File fields (`type: 'file'` or `customFields` type `file-upload`) POST to that URL with credentials; the saved record stores the returned **`url`** / **`publicUrl`** string.
|
|
2170
2175
|
- **Response:** `{ url, publicUrl, key? }` — clients typically persist **`url`** / **`publicUrl`** in the model.
|
|
2171
2176
|
- **Custom storage:** `uploadPlugin({ provider: { async put({ buffer, originalName, mimeType, size, req }) { return { publicUrl: '...' }; } } })`.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DB Scaffold Command
|
|
3
|
+
* Generate migration files from all models in models/
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { loadDbConfig } = require('../utils/db');
|
|
8
|
+
const { scaffoldMigrationsFromModels } = require('../utils/model-migrations');
|
|
9
|
+
|
|
10
|
+
function registerCommand(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('db:scaffold')
|
|
13
|
+
.description('Generate create-table migrations from models/*.js')
|
|
14
|
+
.option('-c, --config <path>', 'Path to database config file (webspresso.db.js or knexfile.js)')
|
|
15
|
+
.option('-m, --models <path>', 'Models directory (overrides config.models)')
|
|
16
|
+
.option('--migrations <path>', 'Migrations directory (overrides config.migrations.directory)')
|
|
17
|
+
.option('--only <names>', 'Comma-separated model names (e.g. User,Post)')
|
|
18
|
+
.option('-f, --force', 'Create migration even if createTable for table already exists')
|
|
19
|
+
.option('--dry-run', 'List files that would be created without writing')
|
|
20
|
+
.action(async (options) => {
|
|
21
|
+
try {
|
|
22
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
23
|
+
console.log(`\n📦 Using config: ${configPath}\n`);
|
|
24
|
+
|
|
25
|
+
const modelsDir = options.models || config.models || './models';
|
|
26
|
+
const migrationDir = path.resolve(
|
|
27
|
+
process.cwd(),
|
|
28
|
+
options.migrations || config.migrations?.directory || './migrations'
|
|
29
|
+
);
|
|
30
|
+
const only = options.only
|
|
31
|
+
? options.only.split(',').map((s) => s.trim()).filter(Boolean)
|
|
32
|
+
: null;
|
|
33
|
+
|
|
34
|
+
const result = scaffoldMigrationsFromModels({
|
|
35
|
+
modelsDir,
|
|
36
|
+
migrationDir,
|
|
37
|
+
force: Boolean(options.force),
|
|
38
|
+
dryRun: Boolean(options.dryRun),
|
|
39
|
+
only,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (result.created.length > 0) {
|
|
43
|
+
const verb = options.dryRun ? 'Would create' : 'Created';
|
|
44
|
+
console.log(`✅ ${verb} ${result.created.length} migration(s):\n`);
|
|
45
|
+
for (const row of result.created) {
|
|
46
|
+
console.log(` - ${row.model} → ${row.file}`);
|
|
47
|
+
}
|
|
48
|
+
console.log();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (result.skipped.length > 0) {
|
|
52
|
+
console.log(`⏭️ Skipped ${result.skipped.length} (migration already exists, use --force):\n`);
|
|
53
|
+
for (const row of result.skipped) {
|
|
54
|
+
console.log(` - ${row.model} (${row.table}) — ${row.file}`);
|
|
55
|
+
}
|
|
56
|
+
console.log();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (result.errors.length > 0) {
|
|
60
|
+
console.error(`❌ Failed for ${result.errors.length} model(s):\n`);
|
|
61
|
+
for (const row of result.errors) {
|
|
62
|
+
console.error(` - ${row.model}: ${row.message}`);
|
|
63
|
+
}
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (result.created.length === 0 && result.skipped.length > 0 && !options.force) {
|
|
68
|
+
console.log('ℹ️ No new migrations written. Use --force to regenerate.\n');
|
|
69
|
+
} else if (result.created.length === 0) {
|
|
70
|
+
console.log('ℹ️ Nothing to scaffold.\n');
|
|
71
|
+
} else if (!options.dryRun) {
|
|
72
|
+
console.log('📝 Next step: webspresso db:migrate\n');
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error('❌ Error:', err.message);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { registerCommand };
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate Knex migrations from ORM model definitions
|
|
3
|
+
* @module bin/utils/model-migrations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { clearRegistry, getAllModels } = require('../../core/orm/model');
|
|
9
|
+
const {
|
|
10
|
+
scaffoldMigration,
|
|
11
|
+
generateMigrationName,
|
|
12
|
+
} = require('../../core/orm/migrations/scaffold');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {Date} [date]
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function migrationTimestamp(date = new Date()) {
|
|
19
|
+
return [
|
|
20
|
+
date.getFullYear(),
|
|
21
|
+
String(date.getMonth() + 1).padStart(2, '0'),
|
|
22
|
+
String(date.getDate()).padStart(2, '0'),
|
|
23
|
+
'_',
|
|
24
|
+
String(date.getHours()).padStart(2, '0'),
|
|
25
|
+
String(date.getMinutes()).padStart(2, '0'),
|
|
26
|
+
String(date.getSeconds()).padStart(2, '0'),
|
|
27
|
+
].join('');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {import('../../core/orm/types').ModelDefinition} model
|
|
32
|
+
* @returns {Set<string>}
|
|
33
|
+
*/
|
|
34
|
+
function getReferencedTables(model) {
|
|
35
|
+
const deps = new Set();
|
|
36
|
+
for (const meta of model.columns.values()) {
|
|
37
|
+
if (meta.references) {
|
|
38
|
+
deps.add(meta.references);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return deps;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Topological sort so parent tables migrate before dependents.
|
|
46
|
+
* @param {import('../../core/orm/types').ModelDefinition[]} models
|
|
47
|
+
* @returns {import('../../core/orm/types').ModelDefinition[]}
|
|
48
|
+
*/
|
|
49
|
+
function sortModelsByDependencies(models) {
|
|
50
|
+
const byTable = new Map(models.map((m) => [m.table, m]));
|
|
51
|
+
const sorted = [];
|
|
52
|
+
const visiting = new Set();
|
|
53
|
+
const done = new Set();
|
|
54
|
+
|
|
55
|
+
function visit(model) {
|
|
56
|
+
if (done.has(model.table)) return;
|
|
57
|
+
if (visiting.has(model.table)) {
|
|
58
|
+
throw new Error(`Circular table dependency involving "${model.table}"`);
|
|
59
|
+
}
|
|
60
|
+
visiting.add(model.table);
|
|
61
|
+
for (const depTable of getReferencedTables(model)) {
|
|
62
|
+
const dep = byTable.get(depTable);
|
|
63
|
+
if (dep && dep.table !== model.table) {
|
|
64
|
+
visit(dep);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
visiting.delete(model.table);
|
|
68
|
+
done.add(model.table);
|
|
69
|
+
sorted.push(model);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const model of models) {
|
|
73
|
+
visit(model);
|
|
74
|
+
}
|
|
75
|
+
return sorted;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {string} migrationDir
|
|
80
|
+
* @param {string} table
|
|
81
|
+
* @returns {string|null} existing filename
|
|
82
|
+
*/
|
|
83
|
+
function findExistingCreateMigration(migrationDir, table) {
|
|
84
|
+
if (!fs.existsSync(migrationDir)) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const needle = `createTable('${table}'`;
|
|
88
|
+
for (const file of fs.readdirSync(migrationDir)) {
|
|
89
|
+
if (!file.endsWith('.js')) continue;
|
|
90
|
+
const content = fs.readFileSync(path.join(migrationDir, file), 'utf8');
|
|
91
|
+
if (content.includes(needle)) {
|
|
92
|
+
return file;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {string} modelsDir
|
|
100
|
+
* @returns {import('../../core/orm/types').ModelDefinition[]}
|
|
101
|
+
*/
|
|
102
|
+
function loadModelsFromDirectory(modelsDir) {
|
|
103
|
+
const absoluteModelsDir = path.resolve(process.cwd(), modelsDir);
|
|
104
|
+
if (!fs.existsSync(absoluteModelsDir)) {
|
|
105
|
+
throw new Error(`Models directory not found: ${absoluteModelsDir}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
clearRegistry();
|
|
109
|
+
|
|
110
|
+
const files = fs
|
|
111
|
+
.readdirSync(absoluteModelsDir)
|
|
112
|
+
.filter((file) => file.endsWith('.js') && !file.startsWith('_'))
|
|
113
|
+
.sort();
|
|
114
|
+
|
|
115
|
+
const errors = [];
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
try {
|
|
118
|
+
const modelPath = path.join(absoluteModelsDir, file);
|
|
119
|
+
delete require.cache[require.resolve(modelPath)];
|
|
120
|
+
require(modelPath);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
errors.push({ file, message: err.message });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const models = [...getAllModels().values()];
|
|
127
|
+
|
|
128
|
+
if (errors.length > 0) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Failed to load models from ${absoluteModelsDir}:\n` +
|
|
131
|
+
errors.map((e) => ` - ${e.file}: ${e.message}`).join('\n')
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return sortModelsByDependencies(models);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {object} options
|
|
140
|
+
* @param {string} options.modelsDir
|
|
141
|
+
* @param {string} options.migrationDir
|
|
142
|
+
* @param {boolean} [options.force]
|
|
143
|
+
* @param {boolean} [options.dryRun]
|
|
144
|
+
* @param {string[]} [options.only] model names
|
|
145
|
+
* @returns {{ created: object[], skipped: object[], errors: object[] }}
|
|
146
|
+
*/
|
|
147
|
+
function scaffoldMigrationsFromModels(options) {
|
|
148
|
+
const { modelsDir, migrationDir, force = false, dryRun = false, only = null } = options;
|
|
149
|
+
let models = loadModelsFromDirectory(modelsDir);
|
|
150
|
+
|
|
151
|
+
if (only && only.length > 0) {
|
|
152
|
+
const allow = new Set(only);
|
|
153
|
+
models = models.filter((m) => allow.has(m.name));
|
|
154
|
+
const missing = only.filter((name) => !models.some((m) => m.name === name));
|
|
155
|
+
if (missing.length > 0) {
|
|
156
|
+
throw new Error(`Model(s) not found: ${missing.join(', ')}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (models.length === 0) {
|
|
161
|
+
throw new Error(`No models found in ${path.resolve(process.cwd(), modelsDir)}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const created = [];
|
|
165
|
+
const skipped = [];
|
|
166
|
+
const errors = [];
|
|
167
|
+
|
|
168
|
+
if (!dryRun && !fs.existsSync(migrationDir)) {
|
|
169
|
+
fs.mkdirSync(migrationDir, { recursive: true });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
models.forEach((model, index) => {
|
|
173
|
+
const existing = findExistingCreateMigration(migrationDir, model.table);
|
|
174
|
+
if (existing && !force) {
|
|
175
|
+
skipped.push({ model: model.name, table: model.table, file: existing });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const migrationName = generateMigrationName(model, 'create');
|
|
181
|
+
const stamp = migrationTimestamp(new Date(Date.now() + index * 1000));
|
|
182
|
+
const filename = `${stamp}_${migrationName}.js`;
|
|
183
|
+
const filepath = path.join(migrationDir, filename);
|
|
184
|
+
const content = scaffoldMigration(model);
|
|
185
|
+
|
|
186
|
+
if (!dryRun) {
|
|
187
|
+
fs.writeFileSync(filepath, content);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
created.push({
|
|
191
|
+
model: model.name,
|
|
192
|
+
table: model.table,
|
|
193
|
+
file: filename,
|
|
194
|
+
path: filepath,
|
|
195
|
+
});
|
|
196
|
+
} catch (err) {
|
|
197
|
+
errors.push({ model: model.name, table: model.table, message: err.message });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return { created, skipped, errors };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
migrationTimestamp,
|
|
206
|
+
getReferencedTables,
|
|
207
|
+
sortModelsByDependencies,
|
|
208
|
+
findExistingCreateMigration,
|
|
209
|
+
loadModelsFromDirectory,
|
|
210
|
+
scaffoldMigrationsFromModels,
|
|
211
|
+
};
|
package/bin/webspresso.js
CHANGED
|
@@ -23,6 +23,7 @@ const { registerCommand: registerDbMigrate } = require('./commands/db-migrate');
|
|
|
23
23
|
const { registerCommand: registerDbRollback } = require('./commands/db-rollback');
|
|
24
24
|
const { registerCommand: registerDbStatus } = require('./commands/db-status');
|
|
25
25
|
const { registerCommand: registerDbMake } = require('./commands/db-make');
|
|
26
|
+
const { registerCommand: registerDbScaffold } = require('./commands/db-scaffold');
|
|
26
27
|
const { registerCommand: registerSeed } = require('./commands/seed');
|
|
27
28
|
const { registerCommand: registerAdminSetup } = require('./commands/admin-setup');
|
|
28
29
|
const { registerCommand: registerAdminPassword } = require('./commands/admin-password');
|
|
@@ -43,6 +44,7 @@ registerDbMigrate(program);
|
|
|
43
44
|
registerDbRollback(program);
|
|
44
45
|
registerDbStatus(program);
|
|
45
46
|
registerDbMake(program);
|
|
47
|
+
registerDbScaffold(program);
|
|
46
48
|
registerSeed(program);
|
|
47
49
|
registerAdminSetup(program);
|
|
48
50
|
registerAdminPassword(program);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple in-memory TTL cache for content reads.
|
|
3
|
+
* @module core/content/cache
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {number|null|undefined} [ttlMs] - Expiry in ms; null/undefined = no TTL (invalidate only on writes)
|
|
8
|
+
*/
|
|
9
|
+
function createContentCache(ttlMs = null) {
|
|
10
|
+
/** @type {Map<string, { value: unknown, expires: number }>} */
|
|
11
|
+
const store = new Map();
|
|
12
|
+
const noExpiry = ttlMs == null || ttlMs === Infinity;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} key
|
|
16
|
+
* @returns {unknown|undefined}
|
|
17
|
+
*/
|
|
18
|
+
function get(key) {
|
|
19
|
+
const entry = store.get(key);
|
|
20
|
+
if (!entry) return undefined;
|
|
21
|
+
if (!noExpiry && Date.now() > entry.expires) {
|
|
22
|
+
store.delete(key);
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return entry.value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} key
|
|
30
|
+
* @param {unknown} value
|
|
31
|
+
*/
|
|
32
|
+
function set(key, value) {
|
|
33
|
+
store.set(key, {
|
|
34
|
+
value,
|
|
35
|
+
expires: noExpiry ? Number.POSITIVE_INFINITY : Date.now() + ttlMs,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} key
|
|
41
|
+
*/
|
|
42
|
+
function del(key) {
|
|
43
|
+
store.delete(key);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function invalidateAll() {
|
|
47
|
+
store.clear();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} prefix
|
|
52
|
+
*/
|
|
53
|
+
function invalidatePrefix(prefix) {
|
|
54
|
+
for (const key of store.keys()) {
|
|
55
|
+
if (key.startsWith(prefix)) {
|
|
56
|
+
store.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { get, set, del, invalidateAll, invalidatePrefix };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { createContentCache };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content field type registry — validate and normalize values per field type.
|
|
3
|
+
* @module core/content/field-types
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { z } = require('zod');
|
|
7
|
+
|
|
8
|
+
/** @type {Set<string>} */
|
|
9
|
+
const FIELD_TYPES = new Set([
|
|
10
|
+
'text',
|
|
11
|
+
'textarea',
|
|
12
|
+
'rich-text',
|
|
13
|
+
'number',
|
|
14
|
+
'boolean',
|
|
15
|
+
'image',
|
|
16
|
+
'url',
|
|
17
|
+
'date',
|
|
18
|
+
'select',
|
|
19
|
+
'repeater',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} type
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
function isValidFieldType(type) {
|
|
27
|
+
return FIELD_TYPES.has(type);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {unknown} value
|
|
32
|
+
* @param {import('./types').ContentFieldDefinition} field
|
|
33
|
+
* @param {{ sanitizeRichHtml?: (v: string) => string }} [options]
|
|
34
|
+
* @returns {unknown}
|
|
35
|
+
*/
|
|
36
|
+
function normalizeFieldValue(value, field, options = {}) {
|
|
37
|
+
if (value === undefined || value === null) {
|
|
38
|
+
if (field.required) {
|
|
39
|
+
throw new Error(`Field "${field.name}" is required`);
|
|
40
|
+
}
|
|
41
|
+
return field.type === 'repeater' ? [] : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
switch (field.type) {
|
|
45
|
+
case 'text':
|
|
46
|
+
case 'textarea':
|
|
47
|
+
if (typeof value !== 'string') {
|
|
48
|
+
throw new Error(`Field "${field.name}" must be a string`);
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
|
|
52
|
+
case 'rich-text': {
|
|
53
|
+
if (typeof value !== 'string') {
|
|
54
|
+
throw new Error(`Field "${field.name}" must be a string`);
|
|
55
|
+
}
|
|
56
|
+
return options.sanitizeRichHtml ? options.sanitizeRichHtml(value) : value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
case 'number': {
|
|
60
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
61
|
+
if (Number.isNaN(num)) {
|
|
62
|
+
throw new Error(`Field "${field.name}" must be a number`);
|
|
63
|
+
}
|
|
64
|
+
return num;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
case 'boolean':
|
|
68
|
+
if (typeof value === 'boolean') return value;
|
|
69
|
+
if (value === 'true' || value === '1' || value === 1) return true;
|
|
70
|
+
if (value === 'false' || value === '0' || value === 0) return false;
|
|
71
|
+
throw new Error(`Field "${field.name}" must be a boolean`);
|
|
72
|
+
|
|
73
|
+
case 'image':
|
|
74
|
+
case 'url': {
|
|
75
|
+
if (typeof value !== 'string') {
|
|
76
|
+
throw new Error(`Field "${field.name}" must be a URL string`);
|
|
77
|
+
}
|
|
78
|
+
const trimmed = value.trim();
|
|
79
|
+
if (field.required && !trimmed) {
|
|
80
|
+
throw new Error(`Field "${field.name}" is required`);
|
|
81
|
+
}
|
|
82
|
+
if (trimmed && field.type === 'url') {
|
|
83
|
+
try {
|
|
84
|
+
// eslint-disable-next-line no-new
|
|
85
|
+
new URL(trimmed);
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error(`Field "${field.name}" must be a valid URL`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return trimmed || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
case 'date': {
|
|
94
|
+
if (typeof value !== 'string') {
|
|
95
|
+
throw new Error(`Field "${field.name}" must be a date string`);
|
|
96
|
+
}
|
|
97
|
+
const parsed = z.string().date().safeParse(value);
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
throw new Error(`Field "${field.name}" must be a valid date (YYYY-MM-DD)`);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
case 'select': {
|
|
105
|
+
if (typeof value !== 'string') {
|
|
106
|
+
throw new Error(`Field "${field.name}" must be a string`);
|
|
107
|
+
}
|
|
108
|
+
const optionsList = field.options || [];
|
|
109
|
+
if (optionsList.length > 0 && !optionsList.includes(value)) {
|
|
110
|
+
throw new Error(`Field "${field.name}" must be one of: ${optionsList.join(', ')}`);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
case 'repeater': {
|
|
116
|
+
if (!Array.isArray(value)) {
|
|
117
|
+
throw new Error(`Field "${field.name}" must be an array`);
|
|
118
|
+
}
|
|
119
|
+
const nestedFields = field.fields || [];
|
|
120
|
+
return value.map((item, index) => {
|
|
121
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
122
|
+
throw new Error(`Field "${field.name}" item ${index} must be an object`);
|
|
123
|
+
}
|
|
124
|
+
return normalizeEntryData(item, nestedFields, options);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
default:
|
|
129
|
+
throw new Error(`Unknown field type "${field.type}"`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {Record<string, unknown>} data
|
|
135
|
+
* @param {import('./types').ContentFieldDefinition[]} fields
|
|
136
|
+
* @param {{ sanitizeRichHtml?: (v: string) => string }} [options]
|
|
137
|
+
* @returns {Record<string, unknown>}
|
|
138
|
+
*/
|
|
139
|
+
function normalizeEntryData(data, fields, options = {}) {
|
|
140
|
+
const input = data && typeof data === 'object' ? data : {};
|
|
141
|
+
/** @type {Record<string, unknown>} */
|
|
142
|
+
const result = {};
|
|
143
|
+
|
|
144
|
+
for (const field of fields) {
|
|
145
|
+
const raw = Object.prototype.hasOwnProperty.call(input, field.name)
|
|
146
|
+
? input[field.name]
|
|
147
|
+
: undefined;
|
|
148
|
+
result[field.name] = normalizeFieldValue(raw, field, options);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Map content field types to admin panel customField types.
|
|
156
|
+
* @param {import('./types').ContentFieldDefinition} field
|
|
157
|
+
* @returns {Object|null}
|
|
158
|
+
*/
|
|
159
|
+
function toAdminCustomField(field) {
|
|
160
|
+
switch (field.type) {
|
|
161
|
+
case 'rich-text':
|
|
162
|
+
return { type: 'rich-text' };
|
|
163
|
+
case 'image':
|
|
164
|
+
return { type: 'file-upload' };
|
|
165
|
+
case 'textarea':
|
|
166
|
+
return { type: 'text' };
|
|
167
|
+
case 'select':
|
|
168
|
+
return { type: 'enum', options: field.options || [] };
|
|
169
|
+
default:
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
FIELD_TYPES,
|
|
176
|
+
isValidFieldType,
|
|
177
|
+
normalizeFieldValue,
|
|
178
|
+
normalizeEntryData,
|
|
179
|
+
toAdminCustomField,
|
|
180
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-driven content system — core module (framework-agnostic).
|
|
3
|
+
* @module core/content
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { createContentService } = require('./service');
|
|
7
|
+
const { createContentCache } = require('./cache');
|
|
8
|
+
const { parseContentTypeSchema, validateEntryData, isValidSlug } = require('./schema');
|
|
9
|
+
const { wrapEditable, wrapEntryBlock, escapeHtml } = require('./renderer');
|
|
10
|
+
const {
|
|
11
|
+
FIELD_TYPES,
|
|
12
|
+
isValidFieldType,
|
|
13
|
+
normalizeEntryData,
|
|
14
|
+
toAdminCustomField,
|
|
15
|
+
} = require('./field-types');
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
createContentService,
|
|
19
|
+
createContentCache,
|
|
20
|
+
parseContentTypeSchema,
|
|
21
|
+
validateEntryData,
|
|
22
|
+
isValidSlug,
|
|
23
|
+
wrapEditable,
|
|
24
|
+
wrapEntryBlock,
|
|
25
|
+
escapeHtml,
|
|
26
|
+
FIELD_TYPES,
|
|
27
|
+
isValidFieldType,
|
|
28
|
+
normalizeEntryData,
|
|
29
|
+
toAdminCustomField,
|
|
30
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content renderer — wraps values with inline-edit data attributes for admins.
|
|
3
|
+
* @module core/content/renderer
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Escape HTML for text nodes (not for rich-text which uses | safe separately).
|
|
8
|
+
* @param {unknown} value
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
function escapeHtml(value) {
|
|
12
|
+
if (value === null || value === undefined) return '';
|
|
13
|
+
return String(value)
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {unknown} value
|
|
22
|
+
* @param {Object} meta
|
|
23
|
+
* @param {number|string} meta.entryId
|
|
24
|
+
* @param {string} meta.typeSlug
|
|
25
|
+
* @param {string} [meta.field]
|
|
26
|
+
* @param {string} [meta.label]
|
|
27
|
+
* @param {boolean} [meta.isAdmin]
|
|
28
|
+
* @param {boolean} [meta.safeHtml]
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function wrapEditable(value, meta) {
|
|
32
|
+
const display = meta.safeHtml ? String(value ?? '') : escapeHtml(value);
|
|
33
|
+
|
|
34
|
+
if (!meta.isAdmin) {
|
|
35
|
+
return display;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const attrs = [
|
|
39
|
+
'class="ws-content-editable"',
|
|
40
|
+
`data-ws-content-entry="${meta.entryId}"`,
|
|
41
|
+
`data-ws-content-type="${meta.typeSlug}"`,
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
if (meta.field) {
|
|
45
|
+
attrs.push(`data-ws-content-field="${meta.field}"`);
|
|
46
|
+
}
|
|
47
|
+
if (meta.label) {
|
|
48
|
+
attrs.push(`data-ws-content-label="${escapeHtml(meta.label)}"`);
|
|
49
|
+
}
|
|
50
|
+
if (meta.safeHtml) {
|
|
51
|
+
attrs.push('data-ws-content-html="true"');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return `<span ${attrs.join(' ')}>${display}</span>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wrap an entire content entry block for entry-level edit button.
|
|
59
|
+
* @param {string} innerHtml
|
|
60
|
+
* @param {Object} meta
|
|
61
|
+
* @param {number|string} meta.entryId
|
|
62
|
+
* @param {string} meta.typeSlug
|
|
63
|
+
* @param {string} [meta.label]
|
|
64
|
+
* @param {boolean} meta.isAdmin
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function wrapEntryBlock(innerHtml, meta) {
|
|
68
|
+
if (!meta.isAdmin) {
|
|
69
|
+
return innerHtml;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const label = meta.label ? escapeHtml(meta.label) : meta.typeSlug;
|
|
73
|
+
return (
|
|
74
|
+
`<div class="ws-content-block" data-ws-content-entry="${meta.entryId}" ` +
|
|
75
|
+
`data-ws-content-type="${meta.typeSlug}" data-ws-content-label="${label}">` +
|
|
76
|
+
`${innerHtml}</div>`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
escapeHtml,
|
|
82
|
+
wrapEditable,
|
|
83
|
+
wrapEntryBlock,
|
|
84
|
+
};
|