meno-core 1.0.48 → 1.0.50

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 (89) hide show
  1. package/build-astro.ts +6 -2
  2. package/dist/build-static.js +7 -7
  3. package/dist/chunks/{chunk-D5E3OKSL.js → chunk-56EUSC6D.js} +5 -5
  4. package/dist/chunks/{chunk-3FHJUHAS.js → chunk-7NIC4I3V.js} +300 -43
  5. package/dist/chunks/chunk-7NIC4I3V.js.map +7 -0
  6. package/dist/chunks/{chunk-B2RTLDXY.js → chunk-AZQYF6KE.js} +132 -1
  7. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  8. package/dist/chunks/{chunk-TPQ7APVQ.js → chunk-CVLFID6V.js} +473 -73
  9. package/dist/chunks/chunk-CVLFID6V.js.map +7 -0
  10. package/dist/chunks/{chunk-NP76N4HQ.js → chunk-EDQSMAMP.js} +13 -2
  11. package/dist/chunks/{chunk-NP76N4HQ.js.map → chunk-EDQSMAMP.js.map} +2 -2
  12. package/dist/chunks/{chunk-RQSTH2BS.js → chunk-H4JSCDNW.js} +2 -2
  13. package/dist/chunks/{chunk-EK4KESLU.js → chunk-J23ZX5AP.js} +8 -2
  14. package/dist/chunks/{chunk-EK4KESLU.js.map → chunk-J23ZX5AP.js.map} +2 -2
  15. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-LPVETICS.js} +156 -8
  16. package/dist/chunks/chunk-LPVETICS.js.map +7 -0
  17. package/dist/chunks/{chunk-BJRKEPMP.js → chunk-PQ2HRXDR.js} +5 -2
  18. package/dist/chunks/chunk-PQ2HRXDR.js.map +7 -0
  19. package/dist/chunks/{chunk-NKUV77SR.js → chunk-YWJJD5D6.js} +133 -37
  20. package/dist/chunks/chunk-YWJJD5D6.js.map +7 -0
  21. package/dist/chunks/{configService-IGJEC3MC.js → configService-VOY2MY2K.js} +3 -3
  22. package/dist/entries/server-router.js +9 -9
  23. package/dist/entries/server-router.js.map +2 -2
  24. package/dist/lib/client/index.js +92 -32
  25. package/dist/lib/client/index.js.map +3 -3
  26. package/dist/lib/server/index.js +14 -12
  27. package/dist/lib/server/index.js.map +2 -2
  28. package/dist/lib/shared/index.js +46 -10
  29. package/dist/lib/shared/index.js.map +3 -3
  30. package/entries/server-router.tsx +6 -2
  31. package/lib/client/core/ComponentBuilder.test.ts +34 -0
  32. package/lib/client/core/ComponentBuilder.ts +33 -4
  33. package/lib/client/core/builders/embedBuilder.ts +28 -7
  34. package/lib/client/core/builders/linkNodeBuilder.ts +28 -7
  35. package/lib/client/core/builders/localeListBuilder.ts +30 -11
  36. package/lib/client/styles/StyleInjector.ts +3 -2
  37. package/lib/client/templateEngine.ts +24 -0
  38. package/lib/client/theme.ts +4 -4
  39. package/lib/server/cssGenerator.test.ts +64 -1
  40. package/lib/server/cssGenerator.ts +48 -9
  41. package/lib/server/fileWatcher.test.ts +134 -0
  42. package/lib/server/fileWatcher.ts +100 -32
  43. package/lib/server/jsonLoader.ts +1 -0
  44. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  45. package/lib/server/providers/fileSystemCMSProvider.ts +240 -19
  46. package/lib/server/routes/index.ts +1 -1
  47. package/lib/server/routes/pages.ts +23 -1
  48. package/lib/server/services/cmsService.test.ts +246 -0
  49. package/lib/server/services/cmsService.ts +122 -5
  50. package/lib/server/services/configService.ts +6 -0
  51. package/lib/server/services/fileWatcherService.ts +17 -0
  52. package/lib/server/ssr/attributeBuilder.ts +41 -0
  53. package/lib/server/ssr/htmlGenerator.test.ts +113 -0
  54. package/lib/server/ssr/htmlGenerator.ts +62 -7
  55. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  56. package/lib/server/ssr/ssrRenderer.test.ts +564 -0
  57. package/lib/server/ssr/ssrRenderer.ts +228 -49
  58. package/lib/server/webflow/buildWebflow.ts +1 -1
  59. package/lib/server/websocketManager.test.ts +61 -6
  60. package/lib/server/websocketManager.ts +25 -1
  61. package/lib/shared/cssGeneration.test.ts +267 -1
  62. package/lib/shared/cssGeneration.ts +240 -18
  63. package/lib/shared/cssProperties.test.ts +275 -1
  64. package/lib/shared/cssProperties.ts +223 -7
  65. package/lib/shared/interfaces/contentProvider.ts +39 -6
  66. package/lib/shared/pathSecurity.ts +16 -0
  67. package/lib/shared/responsiveScaling.test.ts +143 -0
  68. package/lib/shared/responsiveScaling.ts +253 -2
  69. package/lib/shared/themeDefaults.test.ts +3 -3
  70. package/lib/shared/themeDefaults.ts +3 -3
  71. package/lib/shared/types/api.ts +10 -1
  72. package/lib/shared/types/cms.ts +46 -12
  73. package/lib/shared/types/index.ts +1 -0
  74. package/lib/shared/utilityClassConfig.ts +3 -0
  75. package/lib/shared/utilityClassMapper.test.ts +123 -0
  76. package/lib/shared/utilityClassMapper.ts +179 -8
  77. package/lib/shared/validation/schemas.test.ts +93 -0
  78. package/lib/shared/validation/schemas.ts +71 -16
  79. package/lib/shared/validation/validators.ts +26 -1
  80. package/package.json +1 -1
  81. package/dist/chunks/chunk-3FHJUHAS.js.map +0 -7
  82. package/dist/chunks/chunk-B2RTLDXY.js.map +0 -7
  83. package/dist/chunks/chunk-BJRKEPMP.js.map +0 -7
  84. package/dist/chunks/chunk-NKUV77SR.js.map +0 -7
  85. package/dist/chunks/chunk-TPQ7APVQ.js.map +0 -7
  86. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  87. /package/dist/chunks/{chunk-D5E3OKSL.js.map → chunk-56EUSC6D.js.map} +0 -0
  88. /package/dist/chunks/{chunk-RQSTH2BS.js.map → chunk-H4JSCDNW.js.map} +0 -0
  89. /package/dist/chunks/{configService-IGJEC3MC.js.map → configService-VOY2MY2K.js.map} +0 -0
@@ -4,11 +4,49 @@
4
4
  */
5
5
 
6
6
  import { watch, existsSync } from 'fs';
7
- import { dirname } from 'path';
7
+ import { basename, dirname } from 'path';
8
8
  import type { FSWatcher } from 'fs';
9
9
  import { mapPageNameToPath } from './jsonLoader';
10
10
  import { projectPaths, getProjectRoot } from './projectContext';
11
11
 
12
+ /**
13
+ * Attach a recursive watcher to `dirPath`. If the directory doesn't exist
14
+ * yet, watch the parent directory and defer the attach until the target is
15
+ * created (e.g. a blank project that doesn't ship with `templates/` or `cms/`
16
+ * gets one added later by an external tool).
17
+ *
18
+ * Returns the currently active FSWatcher (which may be the deferred parent
19
+ * watcher until the target appears, then the proper recursive watcher).
20
+ */
21
+ function attachWhenDirExists(
22
+ dirPath: string,
23
+ attach: () => FSWatcher | null,
24
+ setWatcher: (w: FSWatcher | null) => void
25
+ ): void {
26
+ if (existsSync(dirPath)) {
27
+ setWatcher(attach());
28
+ return;
29
+ }
30
+
31
+ const parentDir = dirname(dirPath);
32
+ const targetName = basename(dirPath);
33
+ if (!existsSync(parentDir)) {
34
+ // Parent doesn't exist either — give up; nothing reasonable to watch.
35
+ return;
36
+ }
37
+
38
+ let parentWatcher: FSWatcher | null = null;
39
+ parentWatcher = watch(parentDir, (_event, filename) => {
40
+ if (filename !== targetName) return;
41
+ if (!existsSync(dirPath)) return;
42
+ // Target exists now — tear down the parent watcher and attach the real one.
43
+ parentWatcher?.close();
44
+ parentWatcher = null;
45
+ setWatcher(attach());
46
+ });
47
+ setWatcher(parentWatcher);
48
+ }
49
+
12
50
  export interface FileWatchCallbacks {
13
51
  onComponentChange?: () => Promise<void>;
14
52
  onPageChange?: (pagePath: string) => Promise<void>;
@@ -18,6 +56,7 @@ export interface FileWatchCallbacks {
18
56
  onCMSChange?: (collection: string) => Promise<void>;
19
57
  onImageAdded?: (filename: string) => Promise<void>;
20
58
  onLibraryChange?: () => Promise<void>;
59
+ onProjectConfigChange?: () => Promise<void>;
21
60
  }
22
61
 
23
62
  export class FileWatcher {
@@ -30,6 +69,7 @@ export class FileWatcher {
30
69
  private cmsWatcher: FSWatcher | null = null;
31
70
  private imagesWatcher: FSWatcher | null = null;
32
71
  private librariesWatcher: FSWatcher | null = null;
72
+ private projectConfigWatcher: FSWatcher | null = null;
33
73
 
34
74
  constructor(private callbacks: FileWatchCallbacks) {}
35
75
 
@@ -85,29 +125,31 @@ export class FileWatcher {
85
125
  }
86
126
 
87
127
  /**
88
- * Start watching root templates directory for CMS template changes
128
+ * Start watching root templates directory for CMS template changes.
129
+ * Falls back to a deferred-attach watcher on the project root when
130
+ * `templates/` doesn't exist yet (blank projects, projects that have never
131
+ * had a CMS collection).
89
132
  */
90
133
  watchTemplates(dirPath: string = projectPaths.templates()): void {
91
- if (!existsSync(dirPath)) {
92
- return;
93
- }
94
-
95
- this.templatesWatcher = watch(
134
+ attachWhenDirExists(
96
135
  dirPath,
97
- { recursive: true },
98
- async (event, filename) => {
99
- if (filename && filename.endsWith('.json')) {
100
- // Prefix with templates/ so the path is /templates/...
101
- const pageName = `templates/${filename.replace('.json', '')}`;
102
- const pagePath = mapPageNameToPath(pageName);
136
+ () => watch(
137
+ dirPath,
138
+ { recursive: true },
139
+ async (event, filename) => {
140
+ if (filename && filename.endsWith('.json')) {
141
+ // Prefix with templates/ so the path is /templates/...
142
+ const pageName = `templates/${filename.replace('.json', '')}`;
143
+ const pagePath = mapPageNameToPath(pageName);
103
144
 
104
- if (this.callbacks.onPageChange) {
105
- await this.callbacks.onPageChange(pagePath);
145
+ if (this.callbacks.onPageChange) {
146
+ await this.callbacks.onPageChange(pagePath);
147
+ }
106
148
  }
107
149
  }
108
- }
150
+ ),
151
+ (w) => { this.templatesWatcher = w; }
109
152
  );
110
-
111
153
  }
112
154
 
113
155
  /**
@@ -164,26 +206,27 @@ export class FileWatcher {
164
206
  }
165
207
 
166
208
  /**
167
- * Start watching CMS directory
168
- * Watches for changes in CMS content files (cms/{collection}/*.json)
209
+ * Start watching CMS directory.
210
+ * Watches for changes in CMS content files (cms/{collection}/*.json).
211
+ * Falls back to a deferred-attach watcher when `cms/` doesn't exist yet.
169
212
  */
170
213
  watchCMS(dirPath: string = projectPaths.cms()): void {
171
- if (!existsSync(dirPath)) {
172
- return;
173
- }
174
-
175
- this.cmsWatcher = watch(
214
+ attachWhenDirExists(
176
215
  dirPath,
177
- { recursive: true },
178
- async (event, filename) => {
179
- if (filename && filename.endsWith('.json')) {
180
- // Extract collection from path: "blog/my-post.json" -> "blog"
181
- const collection = filename.split('/')[0];
182
- if (this.callbacks.onCMSChange) {
183
- await this.callbacks.onCMSChange(collection);
216
+ () => watch(
217
+ dirPath,
218
+ { recursive: true },
219
+ async (event, filename) => {
220
+ if (filename && filename.endsWith('.json')) {
221
+ // Extract collection from path: "blog/my-post.json" -> "blog"
222
+ const collection = filename.split('/')[0];
223
+ if (this.callbacks.onCMSChange) {
224
+ await this.callbacks.onCMSChange(collection);
225
+ }
184
226
  }
185
227
  }
186
- }
228
+ ),
229
+ (w) => { this.cmsWatcher = w; }
187
230
  );
188
231
  }
189
232
 
@@ -206,6 +249,25 @@ export class FileWatcher {
206
249
  );
207
250
  }
208
251
 
252
+ /**
253
+ * Start watching project.config.json for changes.
254
+ * Picks up edits to i18n locales, breakpoints, libraries, icons, etc. so the
255
+ * studio reflects external writes (e.g. an AI tool adding a new locale) without
256
+ * a dev-server restart.
257
+ */
258
+ watchProjectConfig(): void {
259
+ const dirPath = getProjectRoot();
260
+ this.projectConfigWatcher = watch(
261
+ dirPath,
262
+ { recursive: false },
263
+ async (_event, filename) => {
264
+ if (filename === 'project.config.json' && this.callbacks.onProjectConfigChange) {
265
+ await this.callbacks.onProjectConfigChange();
266
+ }
267
+ }
268
+ );
269
+ }
270
+
209
271
  /**
210
272
  * Start watching libraries directory for CSS/JS changes
211
273
  */
@@ -240,6 +302,7 @@ export class FileWatcher {
240
302
  this.watchCMS();
241
303
  this.watchImages();
242
304
  this.watchLibraries();
305
+ this.watchProjectConfig();
243
306
  }
244
307
 
245
308
  /**
@@ -290,6 +353,11 @@ export class FileWatcher {
290
353
  this.librariesWatcher.close();
291
354
  this.librariesWatcher = null;
292
355
  }
356
+
357
+ if (this.projectConfigWatcher) {
358
+ this.projectConfigWatcher.close();
359
+ this.projectConfigWatcher = null;
360
+ }
293
361
  }
294
362
 
295
363
  /**
@@ -423,6 +423,7 @@ export function setI18nConfig(config: I18nConfig): void {
423
423
  */
424
424
  export interface IconsConfig {
425
425
  favicon?: string;
426
+ faviconDark?: string;
426
427
  appleTouchIcon?: string;
427
428
  }
428
429
 
@@ -341,5 +341,168 @@ describe('FileSystemCMSProvider', () => {
341
341
  const items = await provider.getItems('valid-collection');
342
342
  expect(items).toEqual([]);
343
343
  });
344
+
345
+ it('should reject filenames ending in reserved .draft suffix', async () => {
346
+ const item = { _id: '99', _filename: 'foo.draft', title: 'Sneaky', slug: 'foo' };
347
+ await expect(provider.saveItem('blog-posts', item))
348
+ .rejects.toThrow(/\.draft.*reserved/);
349
+ await expect(provider.deleteItem('blog-posts', 'foo.draft'))
350
+ .rejects.toThrow(/\.draft.*reserved/);
351
+ await expect(provider.getItemByFilename('blog-posts', 'foo.draft'))
352
+ .rejects.toThrow(/\.draft.*reserved/);
353
+ });
344
354
  });
355
+
356
+ describe('drafts', () => {
357
+ it('saveDraft writes a sibling .draft.json file and getDraft reads it back', async () => {
358
+ const draft = {
359
+ _id: '1',
360
+ _filename: 'hello-world',
361
+ title: 'Hello World — WIP edits',
362
+ slug: 'hello-world',
363
+ content: 'Updated content (not yet published)',
364
+ };
365
+
366
+ await provider.saveDraft('blog-posts', draft);
367
+
368
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'hello-world.draft.json'))).toBe(true);
369
+
370
+ const read = await provider.getDraft('blog-posts', 'hello-world');
371
+ expect(read?.title).toBe('Hello World — WIP edits');
372
+ expect(read?._isDraft).toBe(true);
373
+ });
374
+
375
+ it('drafts do not appear in published-only queries (getItems, getItemByFilename)', async () => {
376
+ // Pre-existing item: hello-world (published). Add a draft for it.
377
+ await provider.saveDraft('blog-posts', {
378
+ _id: '1',
379
+ _filename: 'hello-world',
380
+ title: 'Draft title',
381
+ slug: 'hello-world',
382
+ });
383
+
384
+ // Add a draft-only item (no published file)
385
+ await provider.saveDraft('blog-posts', {
386
+ _id: 'new-1',
387
+ _filename: 'brand-new',
388
+ title: 'Brand new draft',
389
+ slug: 'brand-new',
390
+ });
391
+
392
+ const items = await provider.getItems('blog-posts');
393
+ // Still only the two original published items
394
+ expect(items).toHaveLength(2);
395
+ expect(items.find(i => i._filename === 'brand-new')).toBeUndefined();
396
+ // hello-world's published title is unchanged
397
+ expect(items.find(i => i._filename === 'hello-world')?.title).toBe('Hello World');
398
+
399
+ const single = await provider.getItemByFilename('blog-posts', 'brand-new');
400
+ expect(single).toBeNull();
401
+ });
402
+
403
+ it('getAllDrafts returns drafts with _isDraft transient flag', async () => {
404
+ await provider.saveDraft('blog-posts', {
405
+ _id: '1', _filename: 'hello-world', title: 'WIP', slug: 'hello-world',
406
+ });
407
+ await provider.saveDraft('blog-posts', {
408
+ _id: 'new', _filename: 'brand-new', title: 'New', slug: 'brand-new',
409
+ });
410
+
411
+ const drafts = await provider.getAllDrafts('blog-posts');
412
+ expect(drafts).toHaveLength(2);
413
+ expect(drafts.every(d => d._isDraft === true)).toBe(true);
414
+ expect(drafts.map(d => d._filename).sort()).toEqual(['brand-new', 'hello-world']);
415
+ });
416
+
417
+ it('hasDraft reflects whether a draft file exists', async () => {
418
+ expect(await provider.hasDraft('blog-posts', 'hello-world')).toBe(false);
419
+ await provider.saveDraft('blog-posts', {
420
+ _id: '1', _filename: 'hello-world', title: 'WIP', slug: 'hello-world',
421
+ });
422
+ expect(await provider.hasDraft('blog-posts', 'hello-world')).toBe(true);
423
+ });
424
+
425
+ it('discardDraft removes the draft file and is a no-op when none exists', async () => {
426
+ await expect(provider.discardDraft('blog-posts', 'hello-world')).resolves.toBeUndefined();
427
+
428
+ await provider.saveDraft('blog-posts', {
429
+ _id: '1', _filename: 'hello-world', title: 'WIP', slug: 'hello-world',
430
+ });
431
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'hello-world.draft.json'))).toBe(true);
432
+
433
+ await provider.discardDraft('blog-posts', 'hello-world');
434
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'hello-world.draft.json'))).toBe(false);
435
+ // Published file is untouched
436
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'hello-world.json'))).toBe(true);
437
+ });
438
+
439
+ it('publishDraft promotes draft to published and removes the draft file', async () => {
440
+ await provider.saveDraft('blog-posts', {
441
+ _id: '1',
442
+ _filename: 'hello-world',
443
+ title: 'Updated Title',
444
+ slug: 'hello-world',
445
+ content: 'New content',
446
+ });
447
+
448
+ const result = await provider.publishDraft('blog-posts', 'hello-world');
449
+ expect(result.title).toBe('Updated Title');
450
+
451
+ // Published file now has the new content
452
+ const item = await provider.getItemByFilename('blog-posts', 'hello-world');
453
+ expect(item?.title).toBe('Updated Title');
454
+ expect(item?.content).toBe('New content');
455
+
456
+ // Draft file is gone
457
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'hello-world.draft.json'))).toBe(false);
458
+ });
459
+
460
+ it('publishDraft of a draft-only item creates the published file', async () => {
461
+ await provider.saveDraft('blog-posts', {
462
+ _id: 'new', _filename: 'brand-new', title: 'New post', slug: 'brand-new',
463
+ });
464
+
465
+ const result = await provider.publishDraft('blog-posts', 'brand-new');
466
+ expect(result._filename).toBe('brand-new');
467
+
468
+ const items = await provider.getItems('blog-posts');
469
+ expect(items.find(i => i._filename === 'brand-new')?.title).toBe('New post');
470
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'brand-new.draft.json'))).toBe(false);
471
+ });
472
+
473
+ it('publishDraft throws when no draft exists', async () => {
474
+ await expect(provider.publishDraft('blog-posts', 'hello-world'))
475
+ .rejects.toThrow(/No draft to publish/);
476
+ });
477
+
478
+ it('deleteItem removes both published and draft files', async () => {
479
+ await provider.saveDraft('blog-posts', {
480
+ _id: '1', _filename: 'hello-world', title: 'WIP', slug: 'hello-world',
481
+ });
482
+
483
+ await provider.deleteItem('blog-posts', 'hello-world');
484
+
485
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'hello-world.json'))).toBe(false);
486
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'hello-world.draft.json'))).toBe(false);
487
+ });
488
+
489
+ it('saveItem strips transient _isDraft / _hasDraft fields before writing', async () => {
490
+ const item = {
491
+ _id: '1',
492
+ _filename: 'hello-world',
493
+ title: 'Hello',
494
+ slug: 'hello-world',
495
+ _isDraft: true,
496
+ _hasDraft: true,
497
+ };
498
+ await provider.saveItem('blog-posts', item as any);
499
+
500
+ const { readFile } = await import('fs/promises');
501
+ const raw = await readFile(join(CMS_DIR, 'blog-posts', 'hello-world.json'), 'utf-8');
502
+ const parsed = JSON.parse(raw);
503
+ expect(parsed._isDraft).toBeUndefined();
504
+ expect(parsed._hasDraft).toBeUndefined();
505
+ });
506
+ });
507
+
345
508
  });