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.
Files changed (35) hide show
  1. package/README.md +6 -1
  2. package/bin/commands/db-scaffold.js +81 -0
  3. package/bin/utils/model-migrations.js +211 -0
  4. package/bin/webspresso.js +2 -0
  5. package/core/content/cache.js +64 -0
  6. package/core/content/field-types.js +180 -0
  7. package/core/content/index.js +30 -0
  8. package/core/content/renderer.js +84 -0
  9. package/core/content/schema.js +75 -0
  10. package/core/content/service.js +400 -0
  11. package/core/content/types.js +59 -0
  12. package/index.d.ts +17 -0
  13. package/index.js +7 -0
  14. package/package.json +1 -1
  15. package/plugins/admin-panel/app.js +7 -7
  16. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +41 -0
  17. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +99 -15
  18. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +2 -2
  19. package/plugins/admin-panel/field-renderers/file-upload.js +108 -27
  20. package/plugins/admin-panel/index.js +17 -18
  21. package/plugins/admin-panel/modules/menu.js +1 -0
  22. package/plugins/content/admin/content-entries-component.js +291 -0
  23. package/plugins/content/admin/content-types-component.js +250 -0
  24. package/plugins/content/api-handlers.js +157 -0
  25. package/plugins/content/client/inline-edit.css +296 -0
  26. package/plugins/content/client/inline-edit.js +366 -0
  27. package/plugins/content/helpers.js +77 -0
  28. package/plugins/content/index.js +231 -0
  29. package/plugins/content/migration-template.js +54 -0
  30. package/plugins/content/models/content-entry.js +45 -0
  31. package/plugins/content/models/content-type.js +36 -0
  32. package/plugins/index.js +2 -0
  33. package/src/file-router.js +21 -1
  34. package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -1
  35. 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, '&amp;')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ .replace(/"/g, '&quot;');
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
+ };