@aphexcms/cms-core 0.1.0 → 0.1.3

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 (98) hide show
  1. package/package.json +22 -5
  2. package/src/api/assets.ts +0 -75
  3. package/src/api/client.ts +0 -150
  4. package/src/api/documents.ts +0 -102
  5. package/src/api/index.ts +0 -7
  6. package/src/api/organizations.ts +0 -154
  7. package/src/api/types.ts +0 -34
  8. package/src/app.d.ts +0 -19
  9. package/src/auth/MULTI_TENANCY_PLAN.md +0 -1183
  10. package/src/auth/auth-errors.ts +0 -23
  11. package/src/auth/auth-hooks.ts +0 -132
  12. package/src/auth/provider.ts +0 -25
  13. package/src/client/index.ts +0 -47
  14. package/src/components/AdminApp.svelte +0 -1078
  15. package/src/components/admin/AdminLayout.svelte +0 -115
  16. package/src/components/admin/DocumentEditor.svelte +0 -795
  17. package/src/components/admin/DocumentTypesList.svelte +0 -97
  18. package/src/components/admin/ObjectModal.svelte +0 -135
  19. package/src/components/admin/SchemaField.svelte +0 -171
  20. package/src/components/admin/fields/ArrayField.svelte +0 -266
  21. package/src/components/admin/fields/BooleanField.svelte +0 -35
  22. package/src/components/admin/fields/ImageField.svelte +0 -284
  23. package/src/components/admin/fields/NumberField.svelte +0 -82
  24. package/src/components/admin/fields/ReferenceField.svelte +0 -260
  25. package/src/components/admin/fields/SlugField.svelte +0 -74
  26. package/src/components/admin/fields/StringField.svelte +0 -40
  27. package/src/components/admin/fields/TextareaField.svelte +0 -40
  28. package/src/components/fields/index.ts +0 -9
  29. package/src/components/index.ts +0 -16
  30. package/src/components/layout/OrganizationSwitcher.svelte +0 -218
  31. package/src/components/layout/Sidebar.svelte +0 -88
  32. package/src/components/layout/sidebar/AppSidebar.svelte +0 -63
  33. package/src/components/layout/sidebar/NavMain.svelte +0 -95
  34. package/src/components/layout/sidebar/NavSecondary.svelte +0 -69
  35. package/src/components/layout/sidebar/NavUser.svelte +0 -85
  36. package/src/config.ts +0 -18
  37. package/src/db/adapters/index.ts +0 -3
  38. package/src/db/index.ts +0 -5
  39. package/src/db/interfaces/asset.ts +0 -61
  40. package/src/db/interfaces/document.ts +0 -53
  41. package/src/db/interfaces/index.ts +0 -98
  42. package/src/db/interfaces/organization.ts +0 -51
  43. package/src/db/interfaces/schema.ts +0 -13
  44. package/src/db/interfaces/user.ts +0 -16
  45. package/src/db/utils/reference-resolver.ts +0 -119
  46. package/src/define.ts +0 -7
  47. package/src/email/index.ts +0 -5
  48. package/src/email/interfaces/email.ts +0 -45
  49. package/src/engine.ts +0 -85
  50. package/src/field-validation/rule.ts +0 -287
  51. package/src/field-validation/utils.ts +0 -91
  52. package/src/hooks.ts +0 -142
  53. package/src/index.ts +0 -5
  54. package/src/lib/is-mobile.svelte.ts +0 -9
  55. package/src/lib/utils.ts +0 -13
  56. package/src/plugins/README.md +0 -154
  57. package/src/routes/assets-by-id.ts +0 -161
  58. package/src/routes/assets-cdn.ts +0 -185
  59. package/src/routes/assets.ts +0 -116
  60. package/src/routes/documents-by-id.ts +0 -188
  61. package/src/routes/documents-publish.ts +0 -211
  62. package/src/routes/documents.ts +0 -172
  63. package/src/routes/index.ts +0 -13
  64. package/src/routes/organizations-by-id.ts +0 -258
  65. package/src/routes/organizations-invitations.ts +0 -183
  66. package/src/routes/organizations-members.ts +0 -301
  67. package/src/routes/organizations-switch.ts +0 -74
  68. package/src/routes/organizations.ts +0 -146
  69. package/src/routes/schemas-by-type.ts +0 -35
  70. package/src/routes/schemas.ts +0 -19
  71. package/src/routes-exports.ts +0 -42
  72. package/src/schema-context.svelte.ts +0 -24
  73. package/src/schema-utils/cleanup.ts +0 -116
  74. package/src/schema-utils/index.ts +0 -4
  75. package/src/schema-utils/utils.ts +0 -47
  76. package/src/schema-utils/validator.ts +0 -58
  77. package/src/server/index.ts +0 -40
  78. package/src/services/asset-service.ts +0 -256
  79. package/src/services/index.ts +0 -6
  80. package/src/storage/adapters/index.ts +0 -2
  81. package/src/storage/adapters/local-storage-adapter.ts +0 -215
  82. package/src/storage/index.ts +0 -8
  83. package/src/storage/interfaces/index.ts +0 -2
  84. package/src/storage/interfaces/storage.ts +0 -114
  85. package/src/storage/providers/storage.ts +0 -83
  86. package/src/types/asset.ts +0 -81
  87. package/src/types/auth.ts +0 -80
  88. package/src/types/config.ts +0 -45
  89. package/src/types/document.ts +0 -38
  90. package/src/types/index.ts +0 -8
  91. package/src/types/organization.ts +0 -119
  92. package/src/types/schemas.ts +0 -151
  93. package/src/types/sidebar.ts +0 -37
  94. package/src/types/user.ts +0 -17
  95. package/src/utils/content-hash.ts +0 -75
  96. package/src/utils/image-url.ts +0 -204
  97. package/src/utils/index.ts +0 -12
  98. package/src/utils/slug.ts +0 -33
@@ -1,287 +0,0 @@
1
- // Sanity-style validation Rule implementation
2
- export interface ValidationMarker {
3
- level: 'error' | 'warning' | 'info';
4
- message: string;
5
- path?: string[];
6
- }
7
-
8
- export interface ValidationContext {
9
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
- document?: any;
11
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
- parent?: any;
13
- path?: string[];
14
- }
15
-
16
- export interface CustomValidator<T = unknown> {
17
- (
18
- value: T,
19
- context: ValidationContext
20
- ): ValidationMarker[] | string | boolean | Promise<ValidationMarker[] | string | boolean>;
21
- }
22
-
23
- export interface FieldReference {
24
- __fieldReference: true;
25
- path: string | string[];
26
- }
27
-
28
- export class Rule {
29
- private _required: boolean = false;
30
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
- private _rules: Array<{ type: string; constraint?: any; message?: string }> = [];
32
- private _level: 'error' | 'warning' | 'info' = 'error';
33
- private _message?: string;
34
-
35
- static FIELD_REF = Symbol('fieldReference');
36
-
37
- static valueOfField(path: string | string[]): FieldReference {
38
- return {
39
- __fieldReference: true,
40
- path
41
- };
42
- }
43
-
44
- valueOfField(path: string | string[]): FieldReference {
45
- return Rule.valueOfField(path);
46
- }
47
-
48
- required(): Rule {
49
- const newRule = this.clone();
50
- newRule._required = true;
51
- return newRule;
52
- }
53
-
54
- optional(): Rule {
55
- const newRule = this.clone();
56
- newRule._required = false;
57
- return newRule;
58
- }
59
-
60
- min(len: number | string | FieldReference): Rule {
61
- const newRule = this.clone();
62
- newRule._rules.push({ type: 'min', constraint: len });
63
- return newRule;
64
- }
65
-
66
- max(len: number | string | FieldReference): Rule {
67
- const newRule = this.clone();
68
- newRule._rules.push({ type: 'max', constraint: len });
69
- return newRule;
70
- }
71
-
72
- length(len: number | FieldReference): Rule {
73
- const newRule = this.clone();
74
- newRule._rules.push({ type: 'length', constraint: len });
75
- return newRule;
76
- }
77
-
78
- email(): Rule {
79
- const newRule = this.clone();
80
- newRule._rules.push({ type: 'email' });
81
- return newRule;
82
- }
83
-
84
- uri(options?: { scheme?: RegExp[]; allowRelative?: boolean }): Rule {
85
- const newRule = this.clone();
86
- newRule._rules.push({ type: 'uri', constraint: options });
87
- return newRule;
88
- }
89
-
90
- regex(pattern: RegExp, name?: string): Rule {
91
- const newRule = this.clone();
92
- newRule._rules.push({ type: 'regex', constraint: { pattern, name } });
93
- return newRule;
94
- }
95
-
96
- positive(): Rule {
97
- const newRule = this.clone();
98
- newRule._rules.push({ type: 'positive' });
99
- return newRule;
100
- }
101
-
102
- negative(): Rule {
103
- const newRule = this.clone();
104
- newRule._rules.push({ type: 'negative' });
105
- return newRule;
106
- }
107
-
108
- integer(): Rule {
109
- const newRule = this.clone();
110
- newRule._rules.push({ type: 'integer' });
111
- return newRule;
112
- }
113
-
114
- greaterThan(num: number | FieldReference): Rule {
115
- const newRule = this.clone();
116
- newRule._rules.push({ type: 'greaterThan', constraint: num });
117
- return newRule;
118
- }
119
-
120
- lessThan(num: number | FieldReference): Rule {
121
- const newRule = this.clone();
122
- newRule._rules.push({ type: 'lessThan', constraint: num });
123
- return newRule;
124
- }
125
-
126
- custom<T = unknown>(fn: CustomValidator<T>): Rule {
127
- const newRule = this.clone();
128
- newRule._rules.push({ type: 'custom', constraint: fn });
129
- return newRule;
130
- }
131
-
132
- error(message?: string): Rule {
133
- const newRule = this.clone();
134
- newRule._level = 'error';
135
- newRule._message = message;
136
- return newRule;
137
- }
138
-
139
- warning(message?: string): Rule {
140
- const newRule = this.clone();
141
- newRule._level = 'warning';
142
- newRule._message = message;
143
- return newRule;
144
- }
145
-
146
- info(message?: string): Rule {
147
- const newRule = this.clone();
148
- newRule._level = 'info';
149
- newRule._message = message;
150
- return newRule;
151
- }
152
-
153
- clone(): Rule {
154
- const newRule = new Rule();
155
- newRule._required = this._required;
156
- newRule._rules = [...this._rules];
157
- newRule._level = this._level;
158
- newRule._message = this._message;
159
- return newRule;
160
- }
161
-
162
- async validate(value: unknown, context: ValidationContext = {}): Promise<ValidationMarker[]> {
163
- const markers: ValidationMarker[] = [];
164
-
165
- // Check required
166
- if (this._required && (value === undefined || value === null || value === '')) {
167
- markers.push({
168
- level: this._level,
169
- message: this._message || 'Required',
170
- path: context.path
171
- });
172
- }
173
-
174
- // If value is empty and not required, skip other validations
175
- if (!this._required && (value === undefined || value === null || value === '')) {
176
- return markers;
177
- }
178
-
179
- // Run other validations
180
- for (const rule of this._rules) {
181
- try {
182
- const result = await this.validateRule(rule, value, context);
183
- if (result) {
184
- markers.push({
185
- level: this._level,
186
- message: this._message || result,
187
- path: context.path
188
- });
189
- }
190
- } catch (error) {
191
- markers.push({
192
- level: 'error',
193
- message: `Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`,
194
- path: context.path
195
- });
196
- }
197
- }
198
-
199
- return markers;
200
- }
201
-
202
- private async validateRule(
203
- rule: { type: string; constraint?: any },
204
- value: unknown,
205
- context: ValidationContext
206
- ): Promise<string | null> {
207
- switch (rule.type) {
208
- case 'min':
209
- if (typeof value === 'string' && value.length < rule.constraint) {
210
- return `Must be at least ${rule.constraint} characters`;
211
- }
212
- if (typeof value === 'number' && value < rule.constraint) {
213
- return `Must be at least ${rule.constraint}`;
214
- }
215
- break;
216
-
217
- case 'max':
218
- if (typeof value === 'string' && value.length > rule.constraint) {
219
- return `Must be at most ${rule.constraint} characters`;
220
- }
221
- if (typeof value === 'number' && value > rule.constraint) {
222
- return `Must be at most ${rule.constraint}`;
223
- }
224
- break;
225
-
226
- case 'email':
227
- if (typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
228
- return 'Must be a valid email address';
229
- }
230
- break;
231
-
232
- case 'uri':
233
- if (typeof value === 'string') {
234
- try {
235
- new URL(value);
236
- } catch {
237
- return 'Must be a valid URL';
238
- }
239
- }
240
- break;
241
-
242
- case 'regex':
243
- if (typeof value === 'string' && !rule.constraint.pattern.test(value)) {
244
- return `Must match pattern${rule.constraint.name ? ` (${rule.constraint.name})` : ''}`;
245
- }
246
- break;
247
-
248
- case 'positive':
249
- if (typeof value === 'number' && value <= 0) {
250
- return 'Must be positive';
251
- }
252
- break;
253
-
254
- case 'negative':
255
- if (typeof value === 'number' && value >= 0) {
256
- return 'Must be negative';
257
- }
258
- break;
259
-
260
- case 'integer':
261
- if (typeof value === 'number' && !Number.isInteger(value)) {
262
- return 'Must be an integer';
263
- }
264
- break;
265
-
266
- case 'custom': {
267
- const customResult = await rule.constraint(value, context);
268
- if (customResult === false) {
269
- return 'Validation failed';
270
- }
271
- if (typeof customResult === 'string') {
272
- return customResult;
273
- }
274
- if (Array.isArray(customResult) && customResult.length > 0) {
275
- return customResult[0].message;
276
- }
277
- break;
278
- }
279
- }
280
-
281
- return null;
282
- }
283
-
284
- isRequired(): boolean {
285
- return this._required;
286
- }
287
- }
@@ -1,91 +0,0 @@
1
- import type { Field } from '../types';
2
- import { Rule } from './rule.js';
3
-
4
- export interface ValidationError {
5
- level: 'error' | 'warning' | 'info';
6
- message: string;
7
- }
8
-
9
- /**
10
- * Check if a field is required based on its validation rules
11
- */
12
- export function isFieldRequired(field: Field): boolean {
13
- if (!field.validation) return false;
14
- try {
15
- const validationFn = Array.isArray(field.validation) ? field.validation[0] : field.validation;
16
- if (!validationFn) return false;
17
- const rule = validationFn(new Rule());
18
- return rule.isRequired();
19
- } catch {
20
- return false;
21
- }
22
- }
23
-
24
- /**
25
- * Validate a field value against its validation rules
26
- */
27
- export async function validateField(
28
- field: Field,
29
- value: any,
30
- context: any = {}
31
- ): Promise<{
32
- isValid: boolean;
33
- errors: ValidationError[];
34
- }> {
35
- if (!field.validation) {
36
- return { isValid: true, errors: [] };
37
- }
38
-
39
- try {
40
- const validationFunctions = Array.isArray(field.validation)
41
- ? field.validation
42
- : [field.validation];
43
-
44
- const allErrors: ValidationError[] = [];
45
-
46
- for (const validationFn of validationFunctions) {
47
- const rule = validationFn(new Rule());
48
-
49
- if (!(rule instanceof Rule)) {
50
- console.error(
51
- `Validation function for field "${field.name}" did not return a Rule object. Make sure you are chaining validation methods and returning the result.`
52
- );
53
- continue;
54
- }
55
-
56
- const markers = await rule.validate(value, {
57
- path: [field.name],
58
- ...context
59
- });
60
-
61
- allErrors.push(
62
- ...markers.map((marker) => ({
63
- level: marker.level,
64
- message: marker.message
65
- }))
66
- );
67
- }
68
-
69
- const isValid = allErrors.filter((e) => e.level === 'error').length === 0;
70
-
71
- return { isValid, errors: allErrors };
72
- } catch (error) {
73
- console.error('Validation error:', error);
74
- return {
75
- isValid: false,
76
- errors: [{ level: 'error', message: 'Validation failed' }]
77
- };
78
- }
79
- }
80
-
81
- /**
82
- * Get validation CSS classes for input styling
83
- */
84
- export function getValidationClasses(hasErrors: boolean): string {
85
- if (hasErrors) {
86
- return 'border-destructive border-2';
87
- }
88
-
89
- // No green styling for success - only show red for errors
90
- return '';
91
- }
package/src/hooks.ts DELETED
@@ -1,142 +0,0 @@
1
- import type { Handle } from '@sveltejs/kit';
2
- import type { CMSConfig } from './types/index.js';
3
- import type { DatabaseAdapter } from './db/index.js';
4
- import type { AssetService } from './services/asset-service.js';
5
- import type { StorageAdapter } from './storage/interfaces/storage.js';
6
- import type { EmailAdapter } from './email/index.js';
7
- import type { AuthProvider } from './auth/provider.js';
8
- import { handleAuthHook } from './auth/auth-hooks.js';
9
- import { createStorageAdapter as createStorageAdapterProvider } from './storage/providers/storage.js';
10
- import { AssetService as AssetServiceClass } from './services/asset-service.js';
11
- import { createCMS, CMSEngine } from './engine.js';
12
-
13
- // Singleton instances - created once per application lifecycle
14
- export interface CMSInstances {
15
- config: CMSConfig;
16
- assetService: AssetService;
17
- storageAdapter: StorageAdapter;
18
- databaseAdapter: DatabaseAdapter;
19
- emailAdapter?: EmailAdapter | null;
20
- cmsEngine: CMSEngine;
21
- auth?: AuthProvider;
22
- pluginRoutes?: Map<
23
- string,
24
- { handler: (event: any) => Promise<Response> | Response; pluginName: string }
25
- >;
26
- }
27
-
28
- let cmsInstances: CMSInstances | null = null;
29
- let lastConfigHash: string | null = null;
30
-
31
- // Helper to generate a simple hash of schema types for change detection
32
- function getConfigHash(config: CMSConfig): string {
33
- const schemaNames = config.schemaTypes
34
- .map((s) => `${s.name}:${s.fields.length}:${JSON.stringify(s.fields)}`)
35
- .join(',');
36
- return schemaNames;
37
- }
38
-
39
- // Factory function to create the default local storage adapter
40
- function createDefaultStorageAdapter(): StorageAdapter {
41
- return createStorageAdapterProvider('local', {
42
- basePath: './storage/assets', // Private storage - not in static/, not served in production
43
- baseUrl: '' // No direct URL - all access through /assets/{id}/{filename}
44
- });
45
- }
46
-
47
- export function createCMSHook(config: CMSConfig): Handle {
48
- return async ({ event, resolve }) => {
49
- // Note: In dev mode, /storage/ might be accessible via Vite dev server
50
- // In production, only /static/ folder is served - /storage/ is private
51
-
52
- const currentConfigHash = getConfigHash(config);
53
- const configChanged = lastConfigHash !== null && currentConfigHash !== lastConfigHash;
54
-
55
- // Initialize CMS instances once at application startup
56
- if (!cmsInstances) {
57
- console.log('🚀 Initializing CMS...');
58
- const databaseAdapter = config.database;
59
- // Use the storage adapter from config, or create the default local one.
60
- const storageAdapter = config.storage ?? createDefaultStorageAdapter();
61
- const emailAdapter = config.email ?? null;
62
- const assetService = new AssetServiceClass(storageAdapter, databaseAdapter);
63
- const cmsEngine = createCMS(config, databaseAdapter);
64
-
65
- await cmsEngine.initialize();
66
-
67
- // Build plugin route map (do this ONCE at startup)
68
- const pluginRoutes = new Map<
69
- string,
70
- { handler: (event: any) => Promise<Response> | Response; pluginName: string }
71
- >();
72
- if (config.plugins && config.plugins.length > 0) {
73
- for (const plugin of config.plugins) {
74
- if (plugin.routes) {
75
- for (const [path, handler] of Object.entries(plugin.routes)) {
76
- pluginRoutes.set(path, { handler, pluginName: plugin.name });
77
- }
78
- }
79
- }
80
- }
81
-
82
- cmsInstances = {
83
- config,
84
- databaseAdapter: databaseAdapter,
85
- assetService: assetService,
86
- storageAdapter: storageAdapter,
87
- emailAdapter: emailAdapter,
88
- cmsEngine: cmsEngine,
89
- auth: config.auth?.provider,
90
- pluginRoutes
91
- };
92
-
93
- // Install plugins
94
- if (config.plugins && config.plugins.length > 0) {
95
- for (const plugin of config.plugins) {
96
- console.log(`🔌 Installing plugin: ${plugin.name}`);
97
- await plugin.install(cmsInstances);
98
- }
99
- }
100
-
101
- lastConfigHash = currentConfigHash;
102
- } else if (configChanged) {
103
- // HMR: Config changed, re-sync schemas
104
- console.log('🔄 Schema types changed, re-syncing...');
105
- console.log('Old hash:', lastConfigHash?.substring(0, 100) + '...');
106
- console.log('New hash:', currentConfigHash.substring(0, 100) + '...');
107
- console.log(
108
- 'Schema types being updated:',
109
- config.schemaTypes.map((s) => s.name)
110
- );
111
- cmsInstances.cmsEngine.updateConfig(config);
112
- await cmsInstances.cmsEngine.initialize();
113
- lastConfigHash = currentConfigHash;
114
- console.log('✅ Schema sync complete');
115
- }
116
-
117
- // Inject shared CMS services into locals (reuse singleton instances)
118
- event.locals.aphexCMS = cmsInstances;
119
-
120
- // Auth protection if configured
121
- if (cmsInstances.auth) {
122
- const authResponse = await handleAuthHook(
123
- event,
124
- config,
125
- cmsInstances.auth,
126
- cmsInstances.databaseAdapter
127
- );
128
- if (authResponse) return authResponse;
129
- }
130
-
131
- // Check if a plugin handles this route (O(1) lookup)
132
- if (cmsInstances.pluginRoutes && cmsInstances.pluginRoutes.size > 0) {
133
- const pluginRoute = cmsInstances.pluginRoutes.get(event.url.pathname);
134
- if (pluginRoute) {
135
- console.log(`🔌 Plugin ${pluginRoute.pluginName} handling route: ${event.url.pathname}`);
136
- return pluginRoute.handler(event);
137
- }
138
- }
139
-
140
- return resolve(event);
141
- };
142
- }
package/src/index.ts DELETED
@@ -1,5 +0,0 @@
1
- // Aphex CMS Core - Main exports (client-safe only)
2
- // For server-side functionality, import from '@aphex/cms-core/server'
3
-
4
- // Re-export all client-safe functionality
5
- export * from './client/index.js';
@@ -1,9 +0,0 @@
1
- import { MediaQuery } from 'svelte/reactivity';
2
-
3
- const DEFAULT_MOBILE_BREAKPOINT = 768;
4
-
5
- export class IsMobile extends MediaQuery {
6
- constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
7
- super(`max-width: ${breakpoint - 1}px`);
8
- }
9
- }
package/src/lib/utils.ts DELETED
@@ -1,13 +0,0 @@
1
- import { clsx, type ClassValue } from 'clsx';
2
- import { twMerge } from 'tailwind-merge';
3
-
4
- export function cn(...inputs: ClassValue[]) {
5
- return twMerge(clsx(inputs));
6
- }
7
-
8
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
- export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
10
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
- export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
12
- export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
13
- export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
@@ -1,154 +0,0 @@
1
- # Plugin Architecture: A Sanity-like "Parts" System
2
-
3
- This document outlines a vision for a highly extensible, decoupled plugin architecture for Aphex CMS, inspired by the "parts" system used in Sanity Studio.
4
-
5
- ## Core Concept: The "Parts" System
6
-
7
- The goal is to move away from a specialized plugin system, where the core application knows about specific plugin properties like `routes` or `adminUI`, to a generic one.
8
-
9
- The core concept is a **"parts" system**. A "part" is a formally defined, named extension point in the application that a plugin can provide an implementation for. The core application doesn't know what a "GraphQL Plugin" is; it only knows how to find and render a "tool" in the admin bar, or how to register a "server route".
10
-
11
- ---
12
-
13
- ## The Plugin Contract
14
-
15
- We would redefine the `CMSPlugin` interface to be centered around this "parts" concept.
16
-
17
- ```typescript
18
- /**
19
- * A generic definition for any piece of functionality a plugin can provide.
20
- */
21
- export interface PluginPart {
22
- /**
23
- * The name of the part this plugin implements.
24
- * @example 'aphex/admin/tool', 'aphex/server/route'
25
- */
26
- implements: string;
27
-
28
- /** The actual implementation (can be a component, a function, etc.) */
29
- component?: any; // In practice, a SvelteComponent constructor
30
-
31
- /** The handler function for a route part */
32
- handler?: (event: import('@sveltejs/kit').RequestEvent) => Response | Promise<Response>;
33
-
34
- /** Other metadata the part might need for rendering or execution */
35
- [key: string]: any;
36
- }
37
-
38
- /**
39
- * The new plugin contract. A plugin is a collection of parts.
40
- */
41
- export interface CMSPlugin {
42
- name: string;
43
- version: string;
44
- parts?: PluginPart[];
45
- /** `install` can still be used for complex, one-time setup logic */
46
- install?: (cms: any) => Promise<void>;
47
- }
48
- ```
49
-
50
- ---
51
-
52
- ## Defining Core Parts
53
-
54
- We would define a set of core "parts" for Aphex CMS. This list can grow over time as more extension points are needed.
55
-
56
- - `aphex/server/route`: Implemented by plugins that need to add a server-side API endpoint. The part would include a `path` and a `handler`.
57
- - `aphex/admin/tool`: For adding a top-level, navigable tool (like a tab) to the main admin UI. The part would include an `id`, `title`, and a `component` to render.
58
- - `aphex/field/component`: For registering a custom Svelte component to use for a specific schema field type (e.g., a special string input, a map selector, etc.).
59
- - `aphex/document/action`: For adding a custom action button to the document editor (e.g., "Duplicate", "Translate", "Preview").
60
-
61
- ---
62
-
63
- ## Implementation with SvelteKit and Svelte 5
64
-
65
- This architecture fits beautifully with SvelteKit's and Svelte 5's features.
66
-
67
- ### The Part Resolver Service
68
-
69
- A singleton service, the "Part Resolver," would be the heart of the system.
70
-
71
- 1. **Initialization:** At application startup, the main `hooks.server.ts` would process the `cmsConfig.plugins` array once. It would loop through every plugin and every part, organizing them into a `Map<string, PluginPart[]>`. For example, the key `'aphex/admin/tool'` would hold an array of all tool parts from all installed plugins.
72
- 2. **Injection:** This resolver instance would be attached to SvelteKit's `event.locals`, making it universally available in `load` functions, server-side hooks, and API routes without needing global variables.
73
-
74
- ### Rendering UI Parts with Svelte 5
75
-
76
- Dynamically rendering plugin components becomes trivial and efficient with Svelte 5.
77
-
78
- 1. **Data Loading:** In the `+page.server.ts` for the admin UI, the `load` function would use the resolver from `locals` to fetch the necessary parts.
79
- ```typescript
80
- // in /routes/admin/+page.server.ts
81
- export async function load({ locals }) {
82
- const { partResolver } = locals.aphexCMS;
83
- const adminTools = partResolver.getParts('aphex/admin/tool');
84
- return { adminTools };
85
- }
86
- ```
87
- 2. **Dynamic Rendering:** The `AdminApp.svelte` component would receive `adminTools` as a prop. It can then dynamically render the tabs and their content using `<svelte:component>`.
88
-
89
- ```svelte
90
- <!-- AdminApp.svelte -->
91
- <script lang="ts">
92
- let { adminTools = [] } = $props();
93
- </script>
94
-
95
- <Tabs.Root>
96
- <Tabs.List>
97
- <!-- Render a trigger for each tool -->
98
- {#each adminTools as tool}
99
- <Tabs.Trigger value={tool.id}>{tool.title}</Tabs.Trigger>
100
- {/each}
101
- </Tabs.List>
102
-
103
- <!-- Render the content for each tool -->
104
- {#each adminTools as tool}
105
- <Tabs.Content value={tool.id}>
106
- <svelte:component this={tool.component} />
107
- </Tabs.Content>
108
- {/each}
109
- </Tabs.Root>
110
- ```
111
-
112
- ### Handling Server Parts in SvelteKit
113
-
114
- The main `hooks.server.ts` would use the resolver to handle non-UI parts. For example, it would get all `aphex/server/route` parts and register their handlers in a dynamic routing map, similar to how `pluginRoutes` works now but in a fully generic way.
115
-
116
- ---
117
-
118
- ## Example: The GraphQL Plugin Revisited
119
-
120
- Under this system, the GraphQL plugin becomes a clean, declarative manifest of its parts.
121
-
122
- ```typescript
123
- // In packages/graphql-plugin/src/index.ts
124
- import GraphQLTabComponent from './GraphQLTab.svelte';
125
- import { createYoga } from 'graphql-yoga';
126
-
127
- // ... (yoga setup)
128
-
129
- export function createGraphQLPlugin(config: GraphQLPluginConfig = {}): CMSPlugin {
130
- const endpoint = config.endpoint ?? '/api/graphql';
131
- const yogaApp = /* ... create yoga instance ... */;
132
-
133
- return {
134
- name: '@aphex/graphql-plugin',
135
- parts: [
136
- // Part 1: The API endpoint
137
- {
138
- implements: 'aphex/server/route',
139
- path: endpoint,
140
- handler: (event) => yogaApp.fetch(event.request, event)
141
- },
142
- // Part 2: The UI tab in the admin interface
143
- {
144
- implements: 'aphex/admin/tool',
145
- id: 'graphql',
146
- title: 'GraphQL',
147
- component: GraphQLTabComponent
148
- }
149
- ]
150
- };
151
- }
152
- ```
153
-
154
- With this, the core system remains completely agnostic. It doesn't know what "GraphQL" is, it simply knows how to render a "tool" and route a "server route". This is the foundation of a truly configurable and powerful plugin architecture.