foundrycms 0.1.0
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/LICENSE +21 -0
- package/README.md +152 -0
- package/dist/__tests__/foundry.test.d.ts +2 -0
- package/dist/__tests__/foundry.test.d.ts.map +1 -0
- package/dist/__tests__/foundry.test.js +1013 -0
- package/dist/__tests__/foundry.test.js.map +1 -0
- package/dist/config-manager.d.ts +33 -0
- package/dist/config-manager.d.ts.map +1 -0
- package/dist/config-manager.js +169 -0
- package/dist/config-manager.js.map +1 -0
- package/dist/hook-system.d.ts +61 -0
- package/dist/hook-system.d.ts.map +1 -0
- package/dist/hook-system.js +114 -0
- package/dist/hook-system.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +82 -0
- package/dist/index.js.map +1 -0
- package/dist/page-builder/element-registry.d.ts +47 -0
- package/dist/page-builder/element-registry.d.ts.map +1 -0
- package/dist/page-builder/element-registry.js +98 -0
- package/dist/page-builder/element-registry.js.map +1 -0
- package/dist/page-builder/elements/index.d.ts +22 -0
- package/dist/page-builder/elements/index.d.ts.map +1 -0
- package/dist/page-builder/elements/index.js +770 -0
- package/dist/page-builder/elements/index.js.map +1 -0
- package/dist/page-builder/renderer.d.ts +14 -0
- package/dist/page-builder/renderer.d.ts.map +1 -0
- package/dist/page-builder/renderer.js +240 -0
- package/dist/page-builder/renderer.js.map +1 -0
- package/dist/page-builder/serializer.d.ts +1220 -0
- package/dist/page-builder/serializer.d.ts.map +1 -0
- package/dist/page-builder/serializer.js +111 -0
- package/dist/page-builder/serializer.js.map +1 -0
- package/dist/page-builder/template-studio.d.ts +37 -0
- package/dist/page-builder/template-studio.d.ts.map +1 -0
- package/dist/page-builder/template-studio.js +923 -0
- package/dist/page-builder/template-studio.js.map +1 -0
- package/dist/page-builder/types.d.ts +99 -0
- package/dist/page-builder/types.d.ts.map +1 -0
- package/dist/page-builder/types.js +5 -0
- package/dist/page-builder/types.js.map +1 -0
- package/dist/plugin-system.d.ts +128 -0
- package/dist/plugin-system.d.ts.map +1 -0
- package/dist/plugin-system.js +252 -0
- package/dist/plugin-system.js.map +1 -0
- package/dist/plugins/communication.d.ts +6 -0
- package/dist/plugins/communication.d.ts.map +1 -0
- package/dist/plugins/communication.js +922 -0
- package/dist/plugins/communication.js.map +1 -0
- package/dist/plugins/core.d.ts +6 -0
- package/dist/plugins/core.d.ts.map +1 -0
- package/dist/plugins/core.js +675 -0
- package/dist/plugins/core.js.map +1 -0
- package/dist/plugins/growth.d.ts +6 -0
- package/dist/plugins/growth.d.ts.map +1 -0
- package/dist/plugins/growth.js +668 -0
- package/dist/plugins/growth.js.map +1 -0
- package/dist/plugins/index.d.ts +8 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +43 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/operations.d.ts +7 -0
- package/dist/plugins/operations.d.ts.map +1 -0
- package/dist/plugins/operations.js +930 -0
- package/dist/plugins/operations.js.map +1 -0
- package/dist/theme/presets.d.ts +8 -0
- package/dist/theme/presets.d.ts.map +1 -0
- package/dist/theme/presets.js +257 -0
- package/dist/theme/presets.js.map +1 -0
- package/dist/theme/types.d.ts +83 -0
- package/dist/theme/types.d.ts.map +1 -0
- package/dist/theme/types.js +5 -0
- package/dist/theme/types.js.map +1 -0
- package/package.json +38 -0
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import {
|
|
4
|
+
// Plugin system
|
|
5
|
+
PluginRegistry,
|
|
6
|
+
// Hook system
|
|
7
|
+
HookEngine,
|
|
8
|
+
// Config manager
|
|
9
|
+
ConfigManager,
|
|
10
|
+
// Element registry
|
|
11
|
+
ElementRegistry,
|
|
12
|
+
// Template studio
|
|
13
|
+
TemplateStudio, registerDefaultTemplates,
|
|
14
|
+
// Page serializer
|
|
15
|
+
serializePage, deserializePage, validatePageData,
|
|
16
|
+
// Page renderer
|
|
17
|
+
renderPage, renderRow, renderColumn, renderElement, escapeHtml, escapeAttr, registerDefaultElements,
|
|
18
|
+
// Factory functions
|
|
19
|
+
createFoundry, createFoundryWithDefaults, } from '../index.js';
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
function makePlugin(overrides = {}) {
|
|
24
|
+
return {
|
|
25
|
+
id: overrides.id ?? 'test-plugin',
|
|
26
|
+
name: overrides.name ?? 'Test Plugin',
|
|
27
|
+
version: overrides.version ?? '1.0.0',
|
|
28
|
+
description: overrides.description ?? 'A test plugin',
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function makeElement(overrides = {}) {
|
|
33
|
+
return {
|
|
34
|
+
type: overrides.type ?? 'test-element',
|
|
35
|
+
name: overrides.name ?? 'Test Element',
|
|
36
|
+
category: overrides.category ?? 'content',
|
|
37
|
+
icon: overrides.icon ?? 'T',
|
|
38
|
+
description: overrides.description ?? 'A test element',
|
|
39
|
+
defaultSettings: overrides.defaultSettings ?? { text: 'hello' },
|
|
40
|
+
settingsSchema: overrides.settingsSchema ?? z.object({ text: z.string() }),
|
|
41
|
+
render: overrides.render ?? ((s) => `<p>${s.text}</p>`),
|
|
42
|
+
keywords: overrides.keywords,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function makeMinimalPageData(overrides = {}) {
|
|
46
|
+
return {
|
|
47
|
+
id: 'page-1',
|
|
48
|
+
title: 'Test Page',
|
|
49
|
+
slug: 'test-page',
|
|
50
|
+
status: 'draft',
|
|
51
|
+
template: 'default',
|
|
52
|
+
content: [],
|
|
53
|
+
meta: {},
|
|
54
|
+
createdAt: new Date('2025-01-01'),
|
|
55
|
+
updatedAt: new Date('2025-01-02'),
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function makeTemplate(overrides = {}) {
|
|
60
|
+
return {
|
|
61
|
+
id: overrides.id ?? 'test-template',
|
|
62
|
+
name: overrides.name ?? 'Test Template',
|
|
63
|
+
category: overrides.category ?? 'hero',
|
|
64
|
+
description: overrides.description ?? 'A test section template',
|
|
65
|
+
content: overrides.content ?? [
|
|
66
|
+
{
|
|
67
|
+
id: 'row-1',
|
|
68
|
+
type: 'row',
|
|
69
|
+
settings: { layout: 'boxed' },
|
|
70
|
+
columns: [],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// ===========================================================================
|
|
76
|
+
// 1. Plugin System
|
|
77
|
+
// ===========================================================================
|
|
78
|
+
describe('PluginRegistry', () => {
|
|
79
|
+
let hooks;
|
|
80
|
+
let config;
|
|
81
|
+
let plugins;
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
hooks = new HookEngine();
|
|
84
|
+
config = new ConfigManager(hooks);
|
|
85
|
+
plugins = new PluginRegistry(hooks, config);
|
|
86
|
+
});
|
|
87
|
+
describe('register', () => {
|
|
88
|
+
it('registers a plugin and reports it via has/get', () => {
|
|
89
|
+
const p = makePlugin();
|
|
90
|
+
plugins.register(p);
|
|
91
|
+
expect(plugins.has('test-plugin')).toBe(true);
|
|
92
|
+
expect(plugins.get('test-plugin')).toBe(p);
|
|
93
|
+
expect(plugins.getState('test-plugin')).toBe('registered');
|
|
94
|
+
});
|
|
95
|
+
it('throws when registering a duplicate plugin id', () => {
|
|
96
|
+
plugins.register(makePlugin({ id: 'dup' }));
|
|
97
|
+
expect(() => plugins.register(makePlugin({ id: 'dup' }))).toThrowError(/already registered/);
|
|
98
|
+
});
|
|
99
|
+
it('calls onRegister lifecycle hook during registration', () => {
|
|
100
|
+
const onRegister = vi.fn();
|
|
101
|
+
plugins.register(makePlugin({ onRegister }));
|
|
102
|
+
expect(onRegister).toHaveBeenCalledOnce();
|
|
103
|
+
expect(onRegister).toHaveBeenCalledWith(expect.objectContaining({ hooks, config, plugins }));
|
|
104
|
+
});
|
|
105
|
+
it('registers plugin hook handlers on the hook engine', () => {
|
|
106
|
+
const handler = vi.fn();
|
|
107
|
+
plugins.register(makePlugin({
|
|
108
|
+
hooks: [{ hook: 'page:before_save', handler, priority: 5 }],
|
|
109
|
+
}));
|
|
110
|
+
expect(hooks.listenerCount('page:before_save')).toBe(1);
|
|
111
|
+
});
|
|
112
|
+
it('registers a config section when settingsSchema is provided', () => {
|
|
113
|
+
const schema = z.object({ apiKey: z.string() });
|
|
114
|
+
plugins.register(makePlugin({ id: 'with-settings', settingsSchema: schema }));
|
|
115
|
+
expect(config.has('plugins.with-settings')).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
it('emits plugin:registered hook', async () => {
|
|
118
|
+
const handler = vi.fn();
|
|
119
|
+
hooks.on('plugin:registered', handler);
|
|
120
|
+
plugins.register(makePlugin());
|
|
121
|
+
// emit is async, give it a tick
|
|
122
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
123
|
+
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ plugin: expect.objectContaining({ id: 'test-plugin' }) }));
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe('activate / deactivate', () => {
|
|
127
|
+
it('activates a registered plugin', async () => {
|
|
128
|
+
const onActivate = vi.fn();
|
|
129
|
+
plugins.register(makePlugin({ onActivate }));
|
|
130
|
+
await plugins.activate('test-plugin');
|
|
131
|
+
expect(plugins.getState('test-plugin')).toBe('active');
|
|
132
|
+
expect(plugins.isActive('test-plugin')).toBe(true);
|
|
133
|
+
expect(onActivate).toHaveBeenCalledOnce();
|
|
134
|
+
});
|
|
135
|
+
it('throws when activating an unregistered plugin', async () => {
|
|
136
|
+
await expect(plugins.activate('nope')).rejects.toThrowError(/not registered/);
|
|
137
|
+
});
|
|
138
|
+
it('is a no-op if already active', async () => {
|
|
139
|
+
const onActivate = vi.fn();
|
|
140
|
+
plugins.register(makePlugin({ onActivate }));
|
|
141
|
+
await plugins.activate('test-plugin');
|
|
142
|
+
await plugins.activate('test-plugin'); // second call
|
|
143
|
+
expect(onActivate).toHaveBeenCalledOnce();
|
|
144
|
+
});
|
|
145
|
+
it('deactivates an active plugin', async () => {
|
|
146
|
+
const onDeactivate = vi.fn();
|
|
147
|
+
plugins.register(makePlugin({ onDeactivate }));
|
|
148
|
+
await plugins.activate('test-plugin');
|
|
149
|
+
await plugins.deactivate('test-plugin');
|
|
150
|
+
expect(plugins.getState('test-plugin')).toBe('inactive');
|
|
151
|
+
expect(plugins.isActive('test-plugin')).toBe(false);
|
|
152
|
+
expect(onDeactivate).toHaveBeenCalledOnce();
|
|
153
|
+
});
|
|
154
|
+
it('throws when deactivating an unregistered plugin', async () => {
|
|
155
|
+
await expect(plugins.deactivate('nope')).rejects.toThrowError(/not registered/);
|
|
156
|
+
});
|
|
157
|
+
it('prevents deactivation when other active plugins depend on it', async () => {
|
|
158
|
+
plugins.register(makePlugin({ id: 'base' }));
|
|
159
|
+
plugins.register(makePlugin({ id: 'child', requires: ['base'] }));
|
|
160
|
+
await plugins.activate('base');
|
|
161
|
+
await plugins.activate('child');
|
|
162
|
+
await expect(plugins.deactivate('base')).rejects.toThrowError(/depend on it/);
|
|
163
|
+
});
|
|
164
|
+
it('sets state to error when onActivate throws', async () => {
|
|
165
|
+
plugins.register(makePlugin({
|
|
166
|
+
onActivate: async () => {
|
|
167
|
+
throw new Error('boom');
|
|
168
|
+
},
|
|
169
|
+
}));
|
|
170
|
+
await expect(plugins.activate('test-plugin')).rejects.toThrowError(/boom/);
|
|
171
|
+
expect(plugins.getState('test-plugin')).toBe('error');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
describe('dependency resolution', () => {
|
|
175
|
+
it('activates dependencies before the dependent plugin', async () => {
|
|
176
|
+
const order = [];
|
|
177
|
+
plugins.register(makePlugin({
|
|
178
|
+
id: 'dep',
|
|
179
|
+
onActivate: async () => {
|
|
180
|
+
order.push('dep');
|
|
181
|
+
},
|
|
182
|
+
}));
|
|
183
|
+
plugins.register(makePlugin({
|
|
184
|
+
id: 'main',
|
|
185
|
+
requires: ['dep'],
|
|
186
|
+
onActivate: async () => {
|
|
187
|
+
order.push('main');
|
|
188
|
+
},
|
|
189
|
+
}));
|
|
190
|
+
await plugins.activate('main');
|
|
191
|
+
expect(order).toEqual(['dep', 'main']);
|
|
192
|
+
});
|
|
193
|
+
it('throws when a required dependency is not registered', async () => {
|
|
194
|
+
plugins.register(makePlugin({ id: 'orphan', requires: ['missing'] }));
|
|
195
|
+
await expect(plugins.activate('orphan')).rejects.toThrowError(/not registered/);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
describe('circular dependency detection', () => {
|
|
199
|
+
it('detects circular deps during activateAll', async () => {
|
|
200
|
+
plugins.register(makePlugin({ id: 'a', requires: ['b'] }));
|
|
201
|
+
plugins.register(makePlugin({ id: 'b', requires: ['a'] }));
|
|
202
|
+
await expect(plugins.activateAll()).rejects.toThrowError(/Circular dependency/);
|
|
203
|
+
});
|
|
204
|
+
it('detects transitive circular deps', async () => {
|
|
205
|
+
plugins.register(makePlugin({ id: 'x', requires: ['z'] }));
|
|
206
|
+
plugins.register(makePlugin({ id: 'y', requires: ['x'] }));
|
|
207
|
+
plugins.register(makePlugin({ id: 'z', requires: ['y'] }));
|
|
208
|
+
await expect(plugins.activateAll()).rejects.toThrowError(/Circular dependency/);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
describe('activateAll', () => {
|
|
212
|
+
it('activates all plugins in dependency order and returns results', async () => {
|
|
213
|
+
plugins.register(makePlugin({ id: 'core' }));
|
|
214
|
+
plugins.register(makePlugin({ id: 'addon', requires: ['core'] }));
|
|
215
|
+
const result = await plugins.activateAll();
|
|
216
|
+
expect(result.activated).toContain('core');
|
|
217
|
+
expect(result.activated).toContain('addon');
|
|
218
|
+
expect(result.failed).toHaveLength(0);
|
|
219
|
+
expect(result.activated.indexOf('core')).toBeLessThan(result.activated.indexOf('addon'));
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
describe('list / listActive / getByCapability', () => {
|
|
223
|
+
it('lists all registered plugins', () => {
|
|
224
|
+
plugins.register(makePlugin({ id: 'a' }));
|
|
225
|
+
plugins.register(makePlugin({ id: 'b' }));
|
|
226
|
+
expect(plugins.list()).toHaveLength(2);
|
|
227
|
+
});
|
|
228
|
+
it('listActive returns only active plugins', async () => {
|
|
229
|
+
plugins.register(makePlugin({ id: 'a' }));
|
|
230
|
+
plugins.register(makePlugin({ id: 'b' }));
|
|
231
|
+
await plugins.activate('a');
|
|
232
|
+
expect(plugins.listActive()).toHaveLength(1);
|
|
233
|
+
expect(plugins.listActive()[0].id).toBe('a');
|
|
234
|
+
});
|
|
235
|
+
it('getByCapability finds plugins with adminPages', async () => {
|
|
236
|
+
plugins.register(makePlugin({
|
|
237
|
+
id: 'admin-plugin',
|
|
238
|
+
adminPages: [{ path: 'settings', label: 'Settings', component: 'SettingsPage' }],
|
|
239
|
+
}));
|
|
240
|
+
await plugins.activate('admin-plugin');
|
|
241
|
+
expect(plugins.getByCapability('adminPages')).toHaveLength(1);
|
|
242
|
+
expect(plugins.getAllAdminPages()).toHaveLength(1);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
// ===========================================================================
|
|
247
|
+
// 2. Hook System
|
|
248
|
+
// ===========================================================================
|
|
249
|
+
describe('HookEngine', () => {
|
|
250
|
+
let hooks;
|
|
251
|
+
beforeEach(() => {
|
|
252
|
+
hooks = new HookEngine();
|
|
253
|
+
});
|
|
254
|
+
describe('on / emit', () => {
|
|
255
|
+
it('calls the handler when the hook is emitted', async () => {
|
|
256
|
+
const handler = vi.fn();
|
|
257
|
+
hooks.on('page:before_save', handler);
|
|
258
|
+
await hooks.emit('page:before_save', { content: { foo: 'bar' } });
|
|
259
|
+
expect(handler).toHaveBeenCalledWith({ content: { foo: 'bar' } });
|
|
260
|
+
});
|
|
261
|
+
it('supports multiple handlers on the same hook', async () => {
|
|
262
|
+
const h1 = vi.fn();
|
|
263
|
+
const h2 = vi.fn();
|
|
264
|
+
hooks.on('page:before_save', h1);
|
|
265
|
+
hooks.on('page:before_save', h2);
|
|
266
|
+
await hooks.emit('page:before_save', { content: {} });
|
|
267
|
+
expect(h1).toHaveBeenCalledOnce();
|
|
268
|
+
expect(h2).toHaveBeenCalledOnce();
|
|
269
|
+
});
|
|
270
|
+
it('does nothing when emitting a hook with no handlers', async () => {
|
|
271
|
+
// Should not throw
|
|
272
|
+
await hooks.emit('page:before_save', { content: {} });
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
describe('once', () => {
|
|
276
|
+
it('fires the handler only once', async () => {
|
|
277
|
+
const handler = vi.fn();
|
|
278
|
+
hooks.once('plugin:activated', handler);
|
|
279
|
+
await hooks.emit('plugin:activated', { plugin: {} });
|
|
280
|
+
await hooks.emit('plugin:activated', { plugin: {} });
|
|
281
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
282
|
+
});
|
|
283
|
+
it('removes itself after first invocation (listenerCount drops)', async () => {
|
|
284
|
+
const handler = vi.fn();
|
|
285
|
+
hooks.once('plugin:activated', handler);
|
|
286
|
+
expect(hooks.listenerCount('plugin:activated')).toBe(1);
|
|
287
|
+
await hooks.emit('plugin:activated', { plugin: {} });
|
|
288
|
+
expect(hooks.listenerCount('plugin:activated')).toBe(0);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
describe('off', () => {
|
|
292
|
+
it('removes a specific handler', async () => {
|
|
293
|
+
const handler = vi.fn();
|
|
294
|
+
hooks.on('config:changed', handler);
|
|
295
|
+
hooks.off('config:changed', handler);
|
|
296
|
+
await hooks.emit('config:changed', { key: 'a', value: 1, previous: 0 });
|
|
297
|
+
expect(handler).not.toHaveBeenCalled();
|
|
298
|
+
});
|
|
299
|
+
it('does nothing when removing a handler that was not registered', () => {
|
|
300
|
+
// Should not throw
|
|
301
|
+
hooks.off('config:changed', vi.fn());
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
describe('unsubscribe function', () => {
|
|
305
|
+
it('the return value of on() unsubscribes the handler', async () => {
|
|
306
|
+
const handler = vi.fn();
|
|
307
|
+
const unsub = hooks.on('config:changed', handler);
|
|
308
|
+
unsub();
|
|
309
|
+
await hooks.emit('config:changed', { key: 'x', value: 1, previous: 0 });
|
|
310
|
+
expect(handler).not.toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
describe('filter', () => {
|
|
314
|
+
it('passes the payload through each handler which can transform it', async () => {
|
|
315
|
+
hooks.on('page:after_render', ((payload) => {
|
|
316
|
+
return { ...payload, html: payload.html + ' [modified]' };
|
|
317
|
+
}));
|
|
318
|
+
const result = await hooks.filter('page:after_render', {
|
|
319
|
+
page: {},
|
|
320
|
+
html: '<p>Hello</p>',
|
|
321
|
+
});
|
|
322
|
+
expect(result.html).toBe('<p>Hello</p> [modified]');
|
|
323
|
+
});
|
|
324
|
+
it('chains multiple filter handlers', async () => {
|
|
325
|
+
hooks.on('page:after_render', ((p) => ({ ...p, html: p.html + ' A' })));
|
|
326
|
+
hooks.on('page:after_render', ((p) => ({ ...p, html: p.html + ' B' })));
|
|
327
|
+
const result = await hooks.filter('page:after_render', {
|
|
328
|
+
page: {},
|
|
329
|
+
html: 'start',
|
|
330
|
+
});
|
|
331
|
+
expect(result.html).toBe('start A B');
|
|
332
|
+
});
|
|
333
|
+
it('returns payload unchanged if no handlers are registered', async () => {
|
|
334
|
+
const payload = { page: {}, html: '<p>hi</p>' };
|
|
335
|
+
const result = await hooks.filter('page:after_render', payload);
|
|
336
|
+
expect(result).toBe(payload);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
describe('priority ordering', () => {
|
|
340
|
+
it('runs lower-priority handlers first', async () => {
|
|
341
|
+
const order = [];
|
|
342
|
+
hooks.on('page:before_save', () => { order.push(20); }, { priority: 20 });
|
|
343
|
+
hooks.on('page:before_save', () => { order.push(5); }, { priority: 5 });
|
|
344
|
+
hooks.on('page:before_save', () => { order.push(10); }, { priority: 10 });
|
|
345
|
+
await hooks.emit('page:before_save', { content: {} });
|
|
346
|
+
expect(order).toEqual([5, 10, 20]);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
describe('removeAll', () => {
|
|
350
|
+
it('removes all handlers for a specific hook', () => {
|
|
351
|
+
hooks.on('config:changed', vi.fn());
|
|
352
|
+
hooks.on('config:changed', vi.fn());
|
|
353
|
+
hooks.removeAll('config:changed');
|
|
354
|
+
expect(hooks.listenerCount('config:changed')).toBe(0);
|
|
355
|
+
});
|
|
356
|
+
it('removes all handlers for all hooks when called without args', () => {
|
|
357
|
+
hooks.on('config:changed', vi.fn());
|
|
358
|
+
hooks.on('page:before_save', vi.fn());
|
|
359
|
+
hooks.removeAll();
|
|
360
|
+
expect(hooks.listenerCount('config:changed')).toBe(0);
|
|
361
|
+
expect(hooks.listenerCount('page:before_save')).toBe(0);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
describe('listenerCount', () => {
|
|
365
|
+
it('returns 0 for hooks with no listeners', () => {
|
|
366
|
+
expect(hooks.listenerCount('config:changed')).toBe(0);
|
|
367
|
+
});
|
|
368
|
+
it('returns the correct count', () => {
|
|
369
|
+
hooks.on('config:changed', vi.fn());
|
|
370
|
+
hooks.on('config:changed', vi.fn());
|
|
371
|
+
expect(hooks.listenerCount('config:changed')).toBe(2);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
// ===========================================================================
|
|
376
|
+
// 3. Config Manager
|
|
377
|
+
// ===========================================================================
|
|
378
|
+
describe('ConfigManager', () => {
|
|
379
|
+
let config;
|
|
380
|
+
let hooks;
|
|
381
|
+
beforeEach(() => {
|
|
382
|
+
hooks = new HookEngine();
|
|
383
|
+
config = new ConfigManager(hooks);
|
|
384
|
+
});
|
|
385
|
+
describe('get / set with dot notation', () => {
|
|
386
|
+
it('sets and gets a simple value', () => {
|
|
387
|
+
config.set('site.name', 'My Site');
|
|
388
|
+
expect(config.get('site.name')).toBe('My Site');
|
|
389
|
+
});
|
|
390
|
+
it('sets and gets deeply nested values', () => {
|
|
391
|
+
config.set('a.b.c.d', 42);
|
|
392
|
+
expect(config.get('a.b.c.d')).toBe(42);
|
|
393
|
+
});
|
|
394
|
+
it('returns undefined for missing paths', () => {
|
|
395
|
+
expect(config.get('nonexistent.path')).toBeUndefined();
|
|
396
|
+
});
|
|
397
|
+
it('has() returns true for existing paths and false for missing', () => {
|
|
398
|
+
config.set('x.y', 'value');
|
|
399
|
+
expect(config.has('x.y')).toBe(true);
|
|
400
|
+
expect(config.has('x.z')).toBe(false);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
describe('Zod validation', () => {
|
|
404
|
+
it('allows setting valid data for a registered section', () => {
|
|
405
|
+
const schema = z.object({ name: z.string(), port: z.number() });
|
|
406
|
+
config.registerSection({ key: 'server', schema, defaults: { name: 'localhost', port: 3000 } });
|
|
407
|
+
// Setting a sub-key should not throw (validation runs on the section root)
|
|
408
|
+
config.set('server.name', 'prod-host');
|
|
409
|
+
expect(config.get('server.name')).toBe('prod-host');
|
|
410
|
+
});
|
|
411
|
+
it('throws when setSection is called with invalid data', () => {
|
|
412
|
+
const schema = z.object({ name: z.string(), port: z.number() });
|
|
413
|
+
config.registerSection({ key: 'server', schema, defaults: { name: 'localhost', port: 3000 } });
|
|
414
|
+
expect(() => config.setSection('server', { name: 123, port: 'bad' })).toThrowError(/validation failed/i);
|
|
415
|
+
});
|
|
416
|
+
it('populates defaults on registerSection', () => {
|
|
417
|
+
const schema = z.object({ theme: z.string() });
|
|
418
|
+
config.registerSection({ key: 'ui', schema, defaults: { theme: 'dark' } });
|
|
419
|
+
expect(config.get('ui.theme')).toBe('dark');
|
|
420
|
+
});
|
|
421
|
+
it('does not overwrite existing values when registering a section', () => {
|
|
422
|
+
config.set('ui', { theme: 'light' });
|
|
423
|
+
const schema = z.object({ theme: z.string() });
|
|
424
|
+
config.registerSection({ key: 'ui', schema, defaults: { theme: 'dark' } });
|
|
425
|
+
expect(config.get('ui.theme')).toBe('light');
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
describe('deep merge loading', () => {
|
|
429
|
+
it('merges loaded data into existing config', () => {
|
|
430
|
+
config.set('a.x', 1);
|
|
431
|
+
config.set('a.y', 2);
|
|
432
|
+
config.load({ a: { y: 99, z: 3 } });
|
|
433
|
+
expect(config.get('a.x')).toBe(1);
|
|
434
|
+
expect(config.get('a.y')).toBe(99);
|
|
435
|
+
expect(config.get('a.z')).toBe(3);
|
|
436
|
+
});
|
|
437
|
+
it('replaces arrays rather than merging them', () => {
|
|
438
|
+
config.set('tags', ['a', 'b']);
|
|
439
|
+
config.load({ tags: ['c'] });
|
|
440
|
+
expect(config.get('tags')).toEqual(['c']);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
describe('delete', () => {
|
|
444
|
+
it('removes a value and returns true', () => {
|
|
445
|
+
config.set('foo.bar', 'baz');
|
|
446
|
+
expect(config.delete('foo.bar')).toBe(true);
|
|
447
|
+
expect(config.get('foo.bar')).toBeUndefined();
|
|
448
|
+
});
|
|
449
|
+
it('returns false when the path does not exist', () => {
|
|
450
|
+
expect(config.delete('no.such.path')).toBe(false);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
describe('toJSON', () => {
|
|
454
|
+
it('returns a deep clone of the store', () => {
|
|
455
|
+
config.set('data', { nested: true });
|
|
456
|
+
const json = config.toJSON();
|
|
457
|
+
json.data = 'modified';
|
|
458
|
+
// original should not be affected
|
|
459
|
+
expect(config.get('data')).toEqual({ nested: true });
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
describe('config:changed hook', () => {
|
|
463
|
+
it('emits config:changed on set', async () => {
|
|
464
|
+
const handler = vi.fn();
|
|
465
|
+
hooks.on('config:changed', handler);
|
|
466
|
+
config.set('site.title', 'New Title');
|
|
467
|
+
// emit is async, let microtask run
|
|
468
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
469
|
+
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ key: 'site.title', value: 'New Title' }));
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
// ===========================================================================
|
|
474
|
+
// 4. Element Registry
|
|
475
|
+
// ===========================================================================
|
|
476
|
+
describe('ElementRegistry', () => {
|
|
477
|
+
let registry;
|
|
478
|
+
beforeEach(() => {
|
|
479
|
+
registry = new ElementRegistry();
|
|
480
|
+
});
|
|
481
|
+
describe('register / get', () => {
|
|
482
|
+
it('registers an element and retrieves it by type', () => {
|
|
483
|
+
const el = makeElement();
|
|
484
|
+
registry.register(el);
|
|
485
|
+
expect(registry.get('test-element')).toBe(el);
|
|
486
|
+
expect(registry.has('test-element')).toBe(true);
|
|
487
|
+
});
|
|
488
|
+
it('throws on duplicate type registration', () => {
|
|
489
|
+
registry.register(makeElement());
|
|
490
|
+
expect(() => registry.register(makeElement())).toThrowError(/already registered/);
|
|
491
|
+
});
|
|
492
|
+
it('returns undefined for unregistered types', () => {
|
|
493
|
+
expect(registry.get('nope')).toBeUndefined();
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
describe('list', () => {
|
|
497
|
+
it('returns all registered elements', () => {
|
|
498
|
+
registry.register(makeElement({ type: 'a', name: 'A' }));
|
|
499
|
+
registry.register(makeElement({ type: 'b', name: 'B' }));
|
|
500
|
+
expect(registry.list()).toHaveLength(2);
|
|
501
|
+
expect(registry.count).toBe(2);
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
describe('listByCategory', () => {
|
|
505
|
+
it('groups elements by category', () => {
|
|
506
|
+
registry.register(makeElement({ type: 'h1', category: 'content' }));
|
|
507
|
+
registry.register(makeElement({ type: 'img', category: 'media' }));
|
|
508
|
+
registry.register(makeElement({ type: 'p', category: 'content' }));
|
|
509
|
+
const grouped = registry.listByCategory();
|
|
510
|
+
expect(grouped.get('content')).toHaveLength(2);
|
|
511
|
+
expect(grouped.get('media')).toHaveLength(1);
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
describe('search', () => {
|
|
515
|
+
it('finds elements by name', () => {
|
|
516
|
+
registry.register(makeElement({ type: 'heading', name: 'Heading' }));
|
|
517
|
+
registry.register(makeElement({ type: 'button', name: 'Button' }));
|
|
518
|
+
const results = registry.search('head');
|
|
519
|
+
expect(results).toHaveLength(1);
|
|
520
|
+
expect(results[0].type).toBe('heading');
|
|
521
|
+
});
|
|
522
|
+
it('finds elements by type', () => {
|
|
523
|
+
registry.register(makeElement({ type: 'text-block', name: 'Text Block' }));
|
|
524
|
+
const results = registry.search('text-block');
|
|
525
|
+
expect(results).toHaveLength(1);
|
|
526
|
+
});
|
|
527
|
+
it('finds elements by keywords', () => {
|
|
528
|
+
registry.register(makeElement({ type: 'cta', name: 'CTA', keywords: ['call-to-action', 'banner'] }));
|
|
529
|
+
const results = registry.search('banner');
|
|
530
|
+
expect(results).toHaveLength(1);
|
|
531
|
+
expect(results[0].type).toBe('cta');
|
|
532
|
+
});
|
|
533
|
+
it('returns empty array for no matches', () => {
|
|
534
|
+
registry.register(makeElement({ type: 'a' }));
|
|
535
|
+
expect(registry.search('zzzzz')).toHaveLength(0);
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
describe('validateSettings', () => {
|
|
539
|
+
it('returns success for valid settings', () => {
|
|
540
|
+
registry.register(makeElement());
|
|
541
|
+
const result = registry.validateSettings('test-element', { text: 'hello' });
|
|
542
|
+
expect(result.success).toBe(true);
|
|
543
|
+
});
|
|
544
|
+
it('returns error for invalid settings', () => {
|
|
545
|
+
registry.register(makeElement());
|
|
546
|
+
const result = registry.validateSettings('test-element', { text: 123 });
|
|
547
|
+
expect(result.success).toBe(false);
|
|
548
|
+
expect(result.error).toBeDefined();
|
|
549
|
+
});
|
|
550
|
+
it('returns error for unknown element type', () => {
|
|
551
|
+
const result = registry.validateSettings('unknown', {});
|
|
552
|
+
expect(result.success).toBe(false);
|
|
553
|
+
expect(result.error).toMatch(/Unknown element type/);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
describe('unregister', () => {
|
|
557
|
+
it('removes a registered element', () => {
|
|
558
|
+
registry.register(makeElement());
|
|
559
|
+
expect(registry.unregister('test-element')).toBe(true);
|
|
560
|
+
expect(registry.has('test-element')).toBe(false);
|
|
561
|
+
});
|
|
562
|
+
it('returns false for non-existent elements', () => {
|
|
563
|
+
expect(registry.unregister('nope')).toBe(false);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
describe('getDefaultSettings', () => {
|
|
567
|
+
it('returns a copy of default settings', () => {
|
|
568
|
+
const el = makeElement({ defaultSettings: { text: 'default', size: 16 } });
|
|
569
|
+
registry.register(el);
|
|
570
|
+
const settings = registry.getDefaultSettings('test-element');
|
|
571
|
+
expect(settings).toEqual({ text: 'default', size: 16 });
|
|
572
|
+
// Should be a copy, not the same reference
|
|
573
|
+
settings.text = 'changed';
|
|
574
|
+
expect(registry.getDefaultSettings('test-element').text).toBe('default');
|
|
575
|
+
});
|
|
576
|
+
it('returns undefined for unknown types', () => {
|
|
577
|
+
expect(registry.getDefaultSettings('nope')).toBeUndefined();
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
describe('default elements', () => {
|
|
581
|
+
it('registerDefaultElements populates the registry with 15 elements', () => {
|
|
582
|
+
registerDefaultElements(registry);
|
|
583
|
+
expect(registry.count).toBe(15);
|
|
584
|
+
});
|
|
585
|
+
it('all default elements have a working render function', () => {
|
|
586
|
+
registerDefaultElements(registry);
|
|
587
|
+
for (const el of registry.list()) {
|
|
588
|
+
const html = el.render(el.defaultSettings);
|
|
589
|
+
expect(typeof html).toBe('string');
|
|
590
|
+
expect(html.length).toBeGreaterThan(0);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
// ===========================================================================
|
|
596
|
+
// 5. Template Studio
|
|
597
|
+
// ===========================================================================
|
|
598
|
+
describe('TemplateStudio', () => {
|
|
599
|
+
let studio;
|
|
600
|
+
beforeEach(() => {
|
|
601
|
+
studio = new TemplateStudio();
|
|
602
|
+
});
|
|
603
|
+
describe('register / get', () => {
|
|
604
|
+
it('registers and retrieves a template', () => {
|
|
605
|
+
const t = makeTemplate();
|
|
606
|
+
studio.register(t);
|
|
607
|
+
expect(studio.get('test-template')).toBe(t);
|
|
608
|
+
expect(studio.has('test-template')).toBe(true);
|
|
609
|
+
});
|
|
610
|
+
it('throws on duplicate id', () => {
|
|
611
|
+
studio.register(makeTemplate());
|
|
612
|
+
expect(() => studio.register(makeTemplate())).toThrowError(/already registered/);
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
describe('list', () => {
|
|
616
|
+
it('returns all templates', () => {
|
|
617
|
+
studio.register(makeTemplate({ id: 'a' }));
|
|
618
|
+
studio.register(makeTemplate({ id: 'b' }));
|
|
619
|
+
expect(studio.list()).toHaveLength(2);
|
|
620
|
+
expect(studio.count).toBe(2);
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
describe('listByCategory', () => {
|
|
624
|
+
it('filters templates by category', () => {
|
|
625
|
+
studio.register(makeTemplate({ id: 'hero1', category: 'hero' }));
|
|
626
|
+
studio.register(makeTemplate({ id: 'about1', category: 'about' }));
|
|
627
|
+
studio.register(makeTemplate({ id: 'hero2', category: 'hero' }));
|
|
628
|
+
expect(studio.listByCategory('hero')).toHaveLength(2);
|
|
629
|
+
expect(studio.listByCategory('about')).toHaveLength(1);
|
|
630
|
+
expect(studio.listByCategory('pricing')).toHaveLength(0);
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
describe('search', () => {
|
|
634
|
+
it('searches by name', () => {
|
|
635
|
+
studio.register(makeTemplate({ id: 'h1', name: 'Hero Banner', category: 'hero' }));
|
|
636
|
+
studio.register(makeTemplate({ id: 'a1', name: 'About Section', category: 'about' }));
|
|
637
|
+
const results = studio.search('banner');
|
|
638
|
+
expect(results).toHaveLength(1);
|
|
639
|
+
expect(results[0].id).toBe('h1');
|
|
640
|
+
});
|
|
641
|
+
it('searches by description', () => {
|
|
642
|
+
studio.register(makeTemplate({ id: 't1', description: 'A beautiful pricing table' }));
|
|
643
|
+
const results = studio.search('pricing');
|
|
644
|
+
expect(results).toHaveLength(1);
|
|
645
|
+
});
|
|
646
|
+
it('searches by category', () => {
|
|
647
|
+
studio.register(makeTemplate({ id: 'g1', category: 'gallery', name: 'Photos' }));
|
|
648
|
+
const results = studio.search('gallery');
|
|
649
|
+
expect(results).toHaveLength(1);
|
|
650
|
+
});
|
|
651
|
+
it('searches by id', () => {
|
|
652
|
+
studio.register(makeTemplate({ id: 'cta-banner', name: 'Banner' }));
|
|
653
|
+
const results = studio.search('cta-banner');
|
|
654
|
+
expect(results).toHaveLength(1);
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
describe('unregister', () => {
|
|
658
|
+
it('removes a registered template', () => {
|
|
659
|
+
studio.register(makeTemplate());
|
|
660
|
+
expect(studio.unregister('test-template')).toBe(true);
|
|
661
|
+
expect(studio.has('test-template')).toBe(false);
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
describe('default templates', () => {
|
|
665
|
+
it('registerDefaultTemplates populates the studio with 10 templates', () => {
|
|
666
|
+
registerDefaultTemplates(studio);
|
|
667
|
+
expect(studio.count).toBe(10);
|
|
668
|
+
});
|
|
669
|
+
it('default templates cover multiple categories', () => {
|
|
670
|
+
registerDefaultTemplates(studio);
|
|
671
|
+
const categories = new Set(studio.list().map((t) => t.category));
|
|
672
|
+
expect(categories.size).toBeGreaterThanOrEqual(5);
|
|
673
|
+
});
|
|
674
|
+
it('each default template has non-empty content', () => {
|
|
675
|
+
registerDefaultTemplates(studio);
|
|
676
|
+
for (const template of studio.list()) {
|
|
677
|
+
expect(template.content.length).toBeGreaterThan(0);
|
|
678
|
+
expect(template.content[0].type).toBe('row');
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
// ===========================================================================
|
|
684
|
+
// 6. Page Serializer
|
|
685
|
+
// ===========================================================================
|
|
686
|
+
describe('Page Serializer', () => {
|
|
687
|
+
const samplePage = {
|
|
688
|
+
id: 'page-123',
|
|
689
|
+
title: 'Home Page',
|
|
690
|
+
slug: 'home',
|
|
691
|
+
status: 'published',
|
|
692
|
+
template: 'default',
|
|
693
|
+
content: [
|
|
694
|
+
{
|
|
695
|
+
id: 'row-1',
|
|
696
|
+
type: 'row',
|
|
697
|
+
settings: { layout: 'boxed' },
|
|
698
|
+
columns: [
|
|
699
|
+
{
|
|
700
|
+
id: 'col-1',
|
|
701
|
+
width: '1/1',
|
|
702
|
+
settings: {},
|
|
703
|
+
elements: [
|
|
704
|
+
{ id: 'el-1', type: 'heading', settings: { text: 'Hello' } },
|
|
705
|
+
],
|
|
706
|
+
},
|
|
707
|
+
],
|
|
708
|
+
},
|
|
709
|
+
],
|
|
710
|
+
meta: { description: 'Welcome to our site' },
|
|
711
|
+
createdAt: new Date('2025-06-01T00:00:00.000Z'),
|
|
712
|
+
updatedAt: new Date('2025-06-02T00:00:00.000Z'),
|
|
713
|
+
};
|
|
714
|
+
describe('serializePage', () => {
|
|
715
|
+
it('returns a valid JSON string', () => {
|
|
716
|
+
const json = serializePage(samplePage);
|
|
717
|
+
expect(() => JSON.parse(json)).not.toThrow();
|
|
718
|
+
});
|
|
719
|
+
it('converts Date objects to ISO strings', () => {
|
|
720
|
+
const json = serializePage(samplePage);
|
|
721
|
+
const parsed = JSON.parse(json);
|
|
722
|
+
expect(parsed.createdAt).toBe('2025-06-01T00:00:00.000Z');
|
|
723
|
+
expect(parsed.updatedAt).toBe('2025-06-02T00:00:00.000Z');
|
|
724
|
+
});
|
|
725
|
+
it('preserves page structure', () => {
|
|
726
|
+
const json = serializePage(samplePage);
|
|
727
|
+
const parsed = JSON.parse(json);
|
|
728
|
+
expect(parsed.id).toBe('page-123');
|
|
729
|
+
expect(parsed.content).toHaveLength(1);
|
|
730
|
+
expect(parsed.content[0].columns[0].elements[0].settings.text).toBe('Hello');
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
describe('deserializePage', () => {
|
|
734
|
+
it('round-trips through serialize/deserialize', () => {
|
|
735
|
+
const json = serializePage(samplePage);
|
|
736
|
+
const restored = deserializePage(json);
|
|
737
|
+
expect(restored.id).toBe(samplePage.id);
|
|
738
|
+
expect(restored.title).toBe(samplePage.title);
|
|
739
|
+
expect(restored.slug).toBe(samplePage.slug);
|
|
740
|
+
expect(restored.status).toBe(samplePage.status);
|
|
741
|
+
expect(restored.createdAt).toBeInstanceOf(Date);
|
|
742
|
+
expect(restored.content).toHaveLength(1);
|
|
743
|
+
});
|
|
744
|
+
it('parses ISO date strings back to Date objects', () => {
|
|
745
|
+
const json = serializePage(samplePage);
|
|
746
|
+
const restored = deserializePage(json);
|
|
747
|
+
expect(restored.createdAt).toBeInstanceOf(Date);
|
|
748
|
+
expect(restored.createdAt.toISOString()).toBe('2025-06-01T00:00:00.000Z');
|
|
749
|
+
});
|
|
750
|
+
it('throws on invalid JSON', () => {
|
|
751
|
+
expect(() => deserializePage('not json')).toThrow();
|
|
752
|
+
});
|
|
753
|
+
it('throws on valid JSON but invalid page structure', () => {
|
|
754
|
+
expect(() => deserializePage('{"foo": "bar"}')).toThrow();
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
describe('validatePageData', () => {
|
|
758
|
+
it('validates correct page data', () => {
|
|
759
|
+
const data = {
|
|
760
|
+
id: 'p1',
|
|
761
|
+
title: 'T',
|
|
762
|
+
slug: 's',
|
|
763
|
+
status: 'draft',
|
|
764
|
+
template: 'default',
|
|
765
|
+
content: [],
|
|
766
|
+
meta: {},
|
|
767
|
+
createdAt: '2025-01-01T00:00:00.000Z',
|
|
768
|
+
updatedAt: '2025-01-01T00:00:00.000Z',
|
|
769
|
+
};
|
|
770
|
+
const result = validatePageData(data);
|
|
771
|
+
expect(result.id).toBe('p1');
|
|
772
|
+
expect(result.createdAt).toBeInstanceOf(Date);
|
|
773
|
+
});
|
|
774
|
+
it('throws on missing required fields', () => {
|
|
775
|
+
expect(() => validatePageData({})).toThrow();
|
|
776
|
+
});
|
|
777
|
+
it('throws on invalid status', () => {
|
|
778
|
+
expect(() => validatePageData({
|
|
779
|
+
id: 'p',
|
|
780
|
+
title: 'T',
|
|
781
|
+
slug: 's',
|
|
782
|
+
status: 'deleted',
|
|
783
|
+
template: 'default',
|
|
784
|
+
content: [],
|
|
785
|
+
meta: {},
|
|
786
|
+
createdAt: '2025-01-01',
|
|
787
|
+
updatedAt: '2025-01-01',
|
|
788
|
+
})).toThrow();
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
// ===========================================================================
|
|
793
|
+
// 7. Factory Functions
|
|
794
|
+
// ===========================================================================
|
|
795
|
+
describe('Factory Functions', () => {
|
|
796
|
+
describe('createFoundry', () => {
|
|
797
|
+
it('returns an instance with all subsystems', () => {
|
|
798
|
+
const foundry = createFoundry();
|
|
799
|
+
expect(foundry.hooks).toBeInstanceOf(HookEngine);
|
|
800
|
+
expect(foundry.config).toBeInstanceOf(ConfigManager);
|
|
801
|
+
expect(foundry.plugins).toBeInstanceOf(PluginRegistry);
|
|
802
|
+
expect(foundry.elements).toBeInstanceOf(ElementRegistry);
|
|
803
|
+
expect(foundry.templates).toBeInstanceOf(TemplateStudio);
|
|
804
|
+
});
|
|
805
|
+
it('loads initial config when provided', () => {
|
|
806
|
+
const foundry = createFoundry({ config: { site: { title: 'My CMS' } } });
|
|
807
|
+
expect(foundry.config.get('site.title')).toBe('My CMS');
|
|
808
|
+
});
|
|
809
|
+
it('starts with empty registries', () => {
|
|
810
|
+
const foundry = createFoundry();
|
|
811
|
+
expect(foundry.elements.count).toBe(0);
|
|
812
|
+
expect(foundry.templates.count).toBe(0);
|
|
813
|
+
expect(foundry.plugins.list()).toHaveLength(0);
|
|
814
|
+
});
|
|
815
|
+
it('allows registering and activating plugins', async () => {
|
|
816
|
+
const foundry = createFoundry();
|
|
817
|
+
foundry.plugins.register(makePlugin());
|
|
818
|
+
await foundry.plugins.activate('test-plugin');
|
|
819
|
+
expect(foundry.plugins.isActive('test-plugin')).toBe(true);
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
describe('createFoundryWithDefaults', () => {
|
|
823
|
+
it('pre-registers 15 default elements', () => {
|
|
824
|
+
const foundry = createFoundryWithDefaults();
|
|
825
|
+
expect(foundry.elements.count).toBe(15);
|
|
826
|
+
});
|
|
827
|
+
it('pre-registers 10 default templates', () => {
|
|
828
|
+
const foundry = createFoundryWithDefaults();
|
|
829
|
+
expect(foundry.templates.count).toBe(10);
|
|
830
|
+
});
|
|
831
|
+
it('includes heading, text-block, and button elements', () => {
|
|
832
|
+
const foundry = createFoundryWithDefaults();
|
|
833
|
+
expect(foundry.elements.has('heading')).toBe(true);
|
|
834
|
+
expect(foundry.elements.has('text-block')).toBe(true);
|
|
835
|
+
expect(foundry.elements.has('button')).toBe(true);
|
|
836
|
+
});
|
|
837
|
+
it('accepts initial config just like createFoundry', () => {
|
|
838
|
+
const foundry = createFoundryWithDefaults({
|
|
839
|
+
config: { app: { debug: true } },
|
|
840
|
+
});
|
|
841
|
+
expect(foundry.config.get('app.debug')).toBe(true);
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
// ===========================================================================
|
|
846
|
+
// 8. Page Renderer
|
|
847
|
+
// ===========================================================================
|
|
848
|
+
describe('Page Renderer', () => {
|
|
849
|
+
let registry;
|
|
850
|
+
beforeEach(() => {
|
|
851
|
+
registry = new ElementRegistry();
|
|
852
|
+
registerDefaultElements(registry);
|
|
853
|
+
});
|
|
854
|
+
describe('escapeHtml', () => {
|
|
855
|
+
it('escapes ampersands', () => {
|
|
856
|
+
expect(escapeHtml('a & b')).toBe('a & b');
|
|
857
|
+
});
|
|
858
|
+
it('escapes angle brackets', () => {
|
|
859
|
+
expect(escapeHtml('<script>alert("xss")</script>')).toBe('<script>alert("xss")</script>');
|
|
860
|
+
});
|
|
861
|
+
it('escapes quotes', () => {
|
|
862
|
+
expect(escapeHtml('"hello" \'world\'')).toBe('"hello" 'world'');
|
|
863
|
+
});
|
|
864
|
+
it('leaves safe strings unchanged', () => {
|
|
865
|
+
expect(escapeHtml('Hello World')).toBe('Hello World');
|
|
866
|
+
});
|
|
867
|
+
});
|
|
868
|
+
describe('escapeAttr', () => {
|
|
869
|
+
it('escapes double quotes for attribute values', () => {
|
|
870
|
+
expect(escapeAttr('he said "hi"')).toBe('he said "hi"');
|
|
871
|
+
});
|
|
872
|
+
it('escapes single quotes', () => {
|
|
873
|
+
expect(escapeAttr("it's")).toBe('it's');
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
describe('renderElement', () => {
|
|
877
|
+
it('renders a known element type', () => {
|
|
878
|
+
const html = renderElement({ id: 'el-1', type: 'heading', settings: { text: 'Hello', level: 'h1' } }, registry);
|
|
879
|
+
expect(html).toContain('foundry-element');
|
|
880
|
+
expect(html).toContain('data-element-id="el-1"');
|
|
881
|
+
expect(html).toContain('data-element-type="heading"');
|
|
882
|
+
});
|
|
883
|
+
it('renders an HTML comment for unknown element types', () => {
|
|
884
|
+
const html = renderElement({ id: 'el-x', type: 'nonexistent', settings: {} }, registry);
|
|
885
|
+
expect(html).toContain('Unknown element type');
|
|
886
|
+
expect(html).toContain('nonexistent');
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
describe('renderColumn', () => {
|
|
890
|
+
it('renders a column with child elements', () => {
|
|
891
|
+
const html = renderColumn({
|
|
892
|
+
id: 'col-1',
|
|
893
|
+
width: '1/2',
|
|
894
|
+
settings: {},
|
|
895
|
+
elements: [
|
|
896
|
+
{ id: 'el-1', type: 'heading', settings: { text: 'Test' } },
|
|
897
|
+
],
|
|
898
|
+
}, registry);
|
|
899
|
+
expect(html).toContain('foundry-column');
|
|
900
|
+
expect(html).toContain('w-1/2');
|
|
901
|
+
expect(html).toContain('foundry-element');
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
describe('renderRow', () => {
|
|
905
|
+
it('renders a row with columns', () => {
|
|
906
|
+
const html = renderRow({
|
|
907
|
+
id: 'row-1',
|
|
908
|
+
type: 'row',
|
|
909
|
+
settings: { layout: 'boxed' },
|
|
910
|
+
columns: [
|
|
911
|
+
{
|
|
912
|
+
id: 'col-1',
|
|
913
|
+
width: '1/1',
|
|
914
|
+
settings: {},
|
|
915
|
+
elements: [],
|
|
916
|
+
},
|
|
917
|
+
],
|
|
918
|
+
}, registry);
|
|
919
|
+
expect(html).toContain('foundry-row');
|
|
920
|
+
expect(html).toContain('<section');
|
|
921
|
+
expect(html).toContain('max-w-7xl');
|
|
922
|
+
});
|
|
923
|
+
it('applies full_width layout class', () => {
|
|
924
|
+
const html = renderRow({
|
|
925
|
+
id: 'row-1',
|
|
926
|
+
type: 'row',
|
|
927
|
+
settings: { layout: 'full_width' },
|
|
928
|
+
columns: [],
|
|
929
|
+
}, registry);
|
|
930
|
+
expect(html).toContain('w-full');
|
|
931
|
+
});
|
|
932
|
+
it('applies narrow layout class', () => {
|
|
933
|
+
const html = renderRow({
|
|
934
|
+
id: 'row-1',
|
|
935
|
+
type: 'row',
|
|
936
|
+
settings: { layout: 'narrow' },
|
|
937
|
+
columns: [],
|
|
938
|
+
}, registry);
|
|
939
|
+
expect(html).toContain('max-w-3xl');
|
|
940
|
+
});
|
|
941
|
+
it('renders anchor id when provided', () => {
|
|
942
|
+
const html = renderRow({
|
|
943
|
+
id: 'row-1',
|
|
944
|
+
type: 'row',
|
|
945
|
+
settings: { layout: 'boxed', anchorId: 'about-us' },
|
|
946
|
+
columns: [],
|
|
947
|
+
}, registry);
|
|
948
|
+
expect(html).toContain('id="about-us"');
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
describe('renderPage', () => {
|
|
952
|
+
it('produces valid HTML with page wrapper', () => {
|
|
953
|
+
const page = makeMinimalPageData();
|
|
954
|
+
const html = renderPage(page, registry);
|
|
955
|
+
expect(html).toContain('foundry-page');
|
|
956
|
+
expect(html).toContain('data-page-id="page-1"');
|
|
957
|
+
expect(html).toContain('data-page-slug="test-page"');
|
|
958
|
+
});
|
|
959
|
+
it('renders rows and their elements', () => {
|
|
960
|
+
const page = makeMinimalPageData({
|
|
961
|
+
content: [
|
|
962
|
+
{
|
|
963
|
+
id: 'row-1',
|
|
964
|
+
type: 'row',
|
|
965
|
+
settings: { layout: 'boxed' },
|
|
966
|
+
columns: [
|
|
967
|
+
{
|
|
968
|
+
id: 'col-1',
|
|
969
|
+
width: '1/1',
|
|
970
|
+
settings: {},
|
|
971
|
+
elements: [
|
|
972
|
+
{
|
|
973
|
+
id: 'el-1',
|
|
974
|
+
type: 'heading',
|
|
975
|
+
settings: { text: 'Welcome', level: 'h1' },
|
|
976
|
+
},
|
|
977
|
+
],
|
|
978
|
+
},
|
|
979
|
+
],
|
|
980
|
+
},
|
|
981
|
+
],
|
|
982
|
+
});
|
|
983
|
+
const html = renderPage(page, registry);
|
|
984
|
+
expect(html).toContain('foundry-row');
|
|
985
|
+
expect(html).toContain('foundry-column');
|
|
986
|
+
expect(html).toContain('foundry-element--heading');
|
|
987
|
+
expect(html).toContain('Welcome');
|
|
988
|
+
});
|
|
989
|
+
it('renders an empty page without errors', () => {
|
|
990
|
+
const page = makeMinimalPageData({ content: [] });
|
|
991
|
+
const html = renderPage(page, registry);
|
|
992
|
+
expect(html).toContain('foundry-page');
|
|
993
|
+
});
|
|
994
|
+
it('handles background styles on rows', () => {
|
|
995
|
+
const page = makeMinimalPageData({
|
|
996
|
+
content: [
|
|
997
|
+
{
|
|
998
|
+
id: 'row-bg',
|
|
999
|
+
type: 'row',
|
|
1000
|
+
settings: {
|
|
1001
|
+
layout: 'boxed',
|
|
1002
|
+
background: { type: 'color', color: '#ff0000' },
|
|
1003
|
+
},
|
|
1004
|
+
columns: [],
|
|
1005
|
+
},
|
|
1006
|
+
],
|
|
1007
|
+
});
|
|
1008
|
+
const html = renderPage(page, registry);
|
|
1009
|
+
expect(html).toContain('background-color: #ff0000');
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
});
|
|
1013
|
+
//# sourceMappingURL=foundry.test.js.map
|