meno-core 1.0.52 → 1.0.53

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 (135) hide show
  1. package/build-astro.ts +183 -13
  2. package/build-next.ts +1361 -0
  3. package/build-static.ts +7 -5
  4. package/dist/bin/cli.js +2 -2
  5. package/dist/build-static.js +6 -6
  6. package/dist/chunks/{chunk-HNLUO36W.js → chunk-GZHGVVW3.js} +2 -2
  7. package/dist/chunks/chunk-GZHGVVW3.js.map +7 -0
  8. package/dist/chunks/{chunk-LPVETICS.js → chunk-H3GJ4H2U.js} +185 -1
  9. package/dist/chunks/chunk-H3GJ4H2U.js.map +7 -0
  10. package/dist/chunks/{chunk-CXCBV2M7.js → chunk-IGYR22T6.js} +76 -270
  11. package/dist/chunks/chunk-IGYR22T6.js.map +7 -0
  12. package/dist/chunks/{chunk-LHLHPYSP.js → chunk-JGP5A3Y5.js} +12 -11
  13. package/dist/chunks/chunk-JGP5A3Y5.js.map +7 -0
  14. package/dist/chunks/{chunk-7NIC4I3V.js → chunk-JGWFTO6P.js} +167 -21
  15. package/dist/chunks/chunk-JGWFTO6P.js.map +7 -0
  16. package/dist/chunks/{chunk-EDQSMAMP.js → chunk-O3NAGJP4.js} +85 -4
  17. package/dist/chunks/chunk-O3NAGJP4.js.map +7 -0
  18. package/dist/chunks/{chunk-H4JSCDNW.js → chunk-QB2LNO4W.js} +24 -1
  19. package/dist/chunks/chunk-QB2LNO4W.js.map +7 -0
  20. package/dist/chunks/{chunk-A725KYFK.js → chunk-R6XHAFBF.js} +561 -112
  21. package/dist/chunks/chunk-R6XHAFBF.js.map +7 -0
  22. package/dist/chunks/{chunk-J23ZX5AP.js → chunk-X754AHS5.js} +277 -1
  23. package/dist/chunks/chunk-X754AHS5.js.map +7 -0
  24. package/dist/chunks/{chunk-2QK6U5UK.js → chunk-YBLHKYFF.js} +12 -2
  25. package/dist/chunks/chunk-YBLHKYFF.js.map +7 -0
  26. package/dist/chunks/{constants-GWBAD66U.js → constants-STK2YBIW.js} +2 -2
  27. package/dist/entries/server-router.js +7 -7
  28. package/dist/lib/client/index.js +354 -59
  29. package/dist/lib/client/index.js.map +4 -4
  30. package/dist/lib/server/index.js +1458 -190
  31. package/dist/lib/server/index.js.map +4 -4
  32. package/dist/lib/shared/index.js +202 -34
  33. package/dist/lib/shared/index.js.map +4 -4
  34. package/dist/lib/test-utils/index.js +1 -1
  35. package/entries/client-router.tsx +5 -165
  36. package/lib/client/ErrorBoundary.test.tsx +27 -25
  37. package/lib/client/ErrorBoundary.tsx +34 -19
  38. package/lib/client/core/ComponentBuilder.ts +19 -2
  39. package/lib/client/core/builders/embedBuilder.ts +8 -4
  40. package/lib/client/core/builders/listBuilder.ts +23 -4
  41. package/lib/client/fontFamiliesService.test.ts +76 -0
  42. package/lib/client/fontFamiliesService.ts +69 -0
  43. package/lib/client/hmrCssReload.ts +160 -0
  44. package/lib/client/hooks/useColorVariables.ts +2 -0
  45. package/lib/client/index.ts +4 -0
  46. package/lib/client/meno-filter/ui.ts +2 -0
  47. package/lib/client/routing/RouteLoader.test.ts +2 -2
  48. package/lib/client/routing/RouteLoader.ts +8 -2
  49. package/lib/client/routing/Router.tsx +81 -15
  50. package/lib/client/scripts/ScriptExecutor.test.ts +143 -0
  51. package/lib/client/scripts/ScriptExecutor.ts +56 -2
  52. package/lib/client/styles/StyleInjector.ts +20 -5
  53. package/lib/client/styles/UtilityClassCollector.ts +7 -1
  54. package/lib/client/styles/cspNonce.test.ts +67 -0
  55. package/lib/client/styles/cspNonce.ts +63 -0
  56. package/lib/client/templateEngine.test.ts +80 -0
  57. package/lib/client/templateEngine.ts +5 -0
  58. package/lib/server/astro/cmsPageEmitter.ts +35 -5
  59. package/lib/server/astro/componentEmitter.ts +61 -5
  60. package/lib/server/astro/nodeToAstro.ts +149 -11
  61. package/lib/server/astro/normalizeOrphanTemplateProps.test.ts +264 -0
  62. package/lib/server/astro/normalizeOrphanTemplateProps.ts +184 -0
  63. package/lib/server/createServer.ts +11 -0
  64. package/lib/server/draftPageStore.ts +49 -0
  65. package/lib/server/fileWatcher.ts +62 -2
  66. package/lib/server/index.ts +13 -1
  67. package/lib/server/providers/fileSystemPageProvider.ts +8 -0
  68. package/lib/server/routes/api/components.ts +9 -4
  69. package/lib/server/routes/api/core-routes.ts +2 -2
  70. package/lib/server/routes/api/pages.ts +14 -22
  71. package/lib/server/routes/api/shared.ts +56 -0
  72. package/lib/server/routes/index.ts +90 -0
  73. package/lib/server/routes/pages.ts +13 -6
  74. package/lib/server/services/componentService.test.ts +199 -2
  75. package/lib/server/services/componentService.ts +354 -49
  76. package/lib/server/services/fileWatcherService.ts +4 -24
  77. package/lib/server/services/pageService.test.ts +23 -0
  78. package/lib/server/services/pageService.ts +124 -6
  79. package/lib/server/ssr/attributeBuilder.ts +8 -2
  80. package/lib/server/ssr/buildErrorOverlay.ts +1 -1
  81. package/lib/server/ssr/errorOverlay.test.ts +21 -2
  82. package/lib/server/ssr/errorOverlay.ts +38 -11
  83. package/lib/server/ssr/htmlGenerator.test.ts +53 -13
  84. package/lib/server/ssr/htmlGenerator.ts +71 -27
  85. package/lib/server/ssr/liveReloadIntegration.test.ts +123 -2
  86. package/lib/server/ssr/ssrRenderer.test.ts +67 -0
  87. package/lib/server/ssr/ssrRenderer.ts +94 -9
  88. package/lib/server/websocketManager.ts +0 -1
  89. package/lib/shared/componentRefs.ts +45 -0
  90. package/lib/shared/constants.ts +8 -0
  91. package/lib/shared/cssGeneration.ts +2 -0
  92. package/lib/shared/cssProperties.ts +184 -0
  93. package/lib/shared/expressionEvaluator.ts +54 -0
  94. package/lib/shared/fontCss.ts +101 -0
  95. package/lib/shared/fontLoader.ts +8 -86
  96. package/lib/shared/friendlyError.test.ts +87 -0
  97. package/lib/shared/friendlyError.ts +121 -0
  98. package/lib/shared/hrefRefs.test.ts +130 -0
  99. package/lib/shared/hrefRefs.ts +100 -0
  100. package/lib/shared/index.ts +52 -0
  101. package/lib/shared/inlineSvgStyleRules.test.ts +108 -0
  102. package/lib/shared/inlineSvgStyleRules.ts +134 -0
  103. package/lib/shared/interfaces/contentProvider.ts +13 -0
  104. package/lib/shared/itemTemplateUtils.test.ts +14 -0
  105. package/lib/shared/itemTemplateUtils.ts +4 -1
  106. package/lib/shared/registry/NodeTypeDefinition.ts +1 -1
  107. package/lib/shared/registry/nodeTypes/LinkNodeType.ts +1 -1
  108. package/lib/shared/slugTranslator.test.ts +24 -0
  109. package/lib/shared/slugTranslator.ts +24 -0
  110. package/lib/shared/styleNodeUtils.ts +4 -1
  111. package/lib/shared/tree/PathBuilder.test.ts +128 -1
  112. package/lib/shared/tree/PathBuilder.ts +83 -31
  113. package/lib/shared/types/comment.ts +99 -0
  114. package/lib/shared/types/index.ts +12 -0
  115. package/lib/shared/types/rendering.ts +8 -0
  116. package/lib/shared/utilityClassConfig.ts +4 -2
  117. package/lib/shared/utilityClassMapper.test.ts +24 -0
  118. package/lib/shared/validation/commentValidators.ts +69 -0
  119. package/lib/shared/validation/index.ts +1 -0
  120. package/lib/shared/viewportUnits.integration.test.ts +42 -0
  121. package/lib/shared/viewportUnits.test.ts +103 -0
  122. package/lib/shared/viewportUnits.ts +63 -0
  123. package/lib/test-utils/dom-setup.ts +6 -0
  124. package/package.json +1 -1
  125. package/dist/chunks/chunk-2QK6U5UK.js.map +0 -7
  126. package/dist/chunks/chunk-7NIC4I3V.js.map +0 -7
  127. package/dist/chunks/chunk-A725KYFK.js.map +0 -7
  128. package/dist/chunks/chunk-CXCBV2M7.js.map +0 -7
  129. package/dist/chunks/chunk-EDQSMAMP.js.map +0 -7
  130. package/dist/chunks/chunk-H4JSCDNW.js.map +0 -7
  131. package/dist/chunks/chunk-HNLUO36W.js.map +0 -7
  132. package/dist/chunks/chunk-J23ZX5AP.js.map +0 -7
  133. package/dist/chunks/chunk-LHLHPYSP.js.map +0 -7
  134. package/dist/chunks/chunk-LPVETICS.js.map +0 -7
  135. /package/dist/chunks/{constants-GWBAD66U.js.map → constants-STK2YBIW.js.map} +0 -0
@@ -8,6 +8,7 @@ import type { PageService } from './services/pageService';
8
8
  import type { ComponentService } from './services/componentService';
9
9
  import type { CMSService } from './services/cmsService';
10
10
  import type { CMSProvider } from '../shared/interfaces/contentProvider';
11
+ import type { DraftPageStore } from './draftPageStore';
11
12
  import { WebSocketManager } from './websocketManager';
12
13
  import { handleRoutes, type RouteContext } from './routes';
13
14
  import { SERVER_PORT, MAX_PORT_ATTEMPTS, HMR_ROUTE } from '../shared/constants';
@@ -28,6 +29,13 @@ export interface ServerConfig {
28
29
  wsManager: WebSocketManager;
29
30
  cmsService?: CMSService;
30
31
  cmsProvider?: CMSProvider;
32
+ /**
33
+ * Optional in-memory draft page store. When provided, the page route
34
+ * reads a draft (if one exists for the requested path) before falling
35
+ * back to disk. Enables live preview in external browser tabs without
36
+ * persisting unsaved edits.
37
+ */
38
+ draftPageStore?: DraftPageStore;
31
39
  additionalRoutes?: AdditionalRouteHandler[];
32
40
  onWSMessage?: WSMessageHandler;
33
41
  /**
@@ -77,6 +85,7 @@ export async function createServer(config: ServerConfig): Promise<ServerResult>
77
85
  wsManager,
78
86
  cmsService,
79
87
  cmsProvider,
88
+ draftPageStore,
80
89
  additionalRoutes = [],
81
90
  onWSMessage,
82
91
  injectLiveReload,
@@ -96,6 +105,8 @@ export async function createServer(config: ServerConfig): Promise<ServerResult>
96
105
  cmsProvider,
97
106
  injectLiveReload,
98
107
  isEditor,
108
+ wsManager,
109
+ draftPageStore,
99
110
  };
100
111
 
101
112
  // Will be set after server starts to inject actual bound port into live reload script
@@ -0,0 +1,49 @@
1
+ /**
2
+ * DraftPageStore
3
+ *
4
+ * Holds ephemeral, unsaved versions of pages that the studio is currently
5
+ * editing. The SSR page route consults this store before falling back to
6
+ * the on-disk content, which lets external browser tabs see the editor's
7
+ * in-flight edits without anything being persisted.
8
+ *
9
+ * Drafts are cleared when the user saves the page to disk (so the next
10
+ * request reads the fresh disk version), and otherwise live only as long
11
+ * as the dev-server process.
12
+ */
13
+ export class DraftPageStore {
14
+ private drafts = new Map<string, string>();
15
+
16
+ /**
17
+ * Store a draft for a page path. The value is the raw JSON string the
18
+ * SSR pipeline expects, matching PageService.getPage()'s contract.
19
+ */
20
+ set(path: string, content: string): void {
21
+ this.drafts.set(path, content);
22
+ }
23
+
24
+ /**
25
+ * Get the current draft string for a page path, if any.
26
+ */
27
+ get(path: string): string | undefined {
28
+ return this.drafts.get(path);
29
+ }
30
+
31
+ /**
32
+ * Drop the draft for a specific path. Called when the page is saved to
33
+ * disk so subsequent renders read the persisted version.
34
+ */
35
+ clear(path: string): void {
36
+ this.drafts.delete(path);
37
+ }
38
+
39
+ /**
40
+ * Drop every draft. Used on shutdown / project switch.
41
+ */
42
+ clearAll(): void {
43
+ this.drafts.clear();
44
+ }
45
+
46
+ has(path: string): boolean {
47
+ return this.drafts.has(path);
48
+ }
49
+ }
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { watch, existsSync } from 'fs';
7
- import { basename, dirname } from 'path';
7
+ import { basename, dirname, join } from 'path';
8
8
  import type { FSWatcher } from 'fs';
9
9
  import { mapPageNameToPath } from './jsonLoader';
10
10
  import { projectPaths, getProjectRoot } from './projectContext';
@@ -70,6 +70,10 @@ export class FileWatcher {
70
70
  private imagesWatcher: FSWatcher | null = null;
71
71
  private librariesWatcher: FSWatcher | null = null;
72
72
  private projectConfigWatcher: FSWatcher | null = null;
73
+ // Astro-format projects keep pages/components/CMS under src/ as .astro/.json.
74
+ private astroPagesWatcher: FSWatcher | null = null;
75
+ private astroComponentsWatcher: FSWatcher | null = null;
76
+ private astroContentWatcher: FSWatcher | null = null;
73
77
 
74
78
  constructor(private callbacks: FileWatchCallbacks) {}
75
79
 
@@ -289,6 +293,43 @@ export class FileWatcher {
289
293
  );
290
294
  }
291
295
 
296
+ /**
297
+ * Astro-format projects keep pages/components/CMS under src/ as .astro/.json.
298
+ * The onPageChange/onComponentChange/onCMSChange callbacks are format-agnostic
299
+ * (reload goes through the active provider), so file→editor live reload works
300
+ * for `.astro` the same way it does for JSON. These are no-ops in JSON projects
301
+ * (the src/ dirs don't exist).
302
+ */
303
+ watchAstroPages(dirPath: string = join(getProjectRoot(), 'src', 'pages')): void {
304
+ if (!existsSync(dirPath)) return;
305
+ this.astroPagesWatcher = watch(dirPath, { recursive: true }, async (_event, filename) => {
306
+ // Skip dynamic CMS routes ([slug].astro) — those are CMS templates, not pages.
307
+ if (filename && filename.endsWith('.astro') && !filename.includes('[')) {
308
+ const pagePath = mapPageNameToPath(filename.replace(/\.astro$/, ''));
309
+ if (this.callbacks.onPageChange) await this.callbacks.onPageChange(pagePath);
310
+ }
311
+ });
312
+ }
313
+
314
+ watchAstroComponents(dirPath: string = join(getProjectRoot(), 'src', 'components')): void {
315
+ if (!existsSync(dirPath)) return;
316
+ this.astroComponentsWatcher = watch(dirPath, { recursive: true }, async (_event, filename) => {
317
+ if (filename && filename.endsWith('.astro') && this.callbacks.onComponentChange) {
318
+ await this.callbacks.onComponentChange();
319
+ }
320
+ });
321
+ }
322
+
323
+ watchAstroContent(dirPath: string = join(getProjectRoot(), 'src', 'content')): void {
324
+ if (!existsSync(dirPath)) return;
325
+ this.astroContentWatcher = watch(dirPath, { recursive: true }, async (_event, filename) => {
326
+ if (filename && filename.endsWith('.json') && this.callbacks.onCMSChange) {
327
+ const collection = filename.split('/')[0];
328
+ await this.callbacks.onCMSChange(collection);
329
+ }
330
+ });
331
+ }
332
+
292
333
  /**
293
334
  * Start watching all directories
294
335
  */
@@ -303,6 +344,10 @@ export class FileWatcher {
303
344
  this.watchImages();
304
345
  this.watchLibraries();
305
346
  this.watchProjectConfig();
347
+ // Astro-format dirs (no-ops in JSON projects).
348
+ this.watchAstroPages();
349
+ this.watchAstroComponents();
350
+ this.watchAstroContent();
306
351
  }
307
352
 
308
353
  /**
@@ -358,8 +403,23 @@ export class FileWatcher {
358
403
  this.projectConfigWatcher.close();
359
404
  this.projectConfigWatcher = null;
360
405
  }
406
+
407
+ if (this.astroPagesWatcher) {
408
+ this.astroPagesWatcher.close();
409
+ this.astroPagesWatcher = null;
410
+ }
411
+
412
+ if (this.astroComponentsWatcher) {
413
+ this.astroComponentsWatcher.close();
414
+ this.astroComponentsWatcher = null;
415
+ }
416
+
417
+ if (this.astroContentWatcher) {
418
+ this.astroContentWatcher.close();
419
+ this.astroContentWatcher = null;
420
+ }
361
421
  }
362
-
422
+
363
423
  /**
364
424
  * Check if watchers are active
365
425
  */
@@ -15,7 +15,13 @@ export { generateBuildErrorPage, type BuildError, type BuildErrorsData } from '.
15
15
 
16
16
  // Services
17
17
  export { PageService } from './services/pageService';
18
- export { ComponentService, type ComponentInfo } from './services/componentService';
18
+ export {
19
+ ComponentService,
20
+ type ComponentInfo,
21
+ type ComponentLoader,
22
+ type ComponentWriter,
23
+ type ComponentServiceFs,
24
+ } from './services/componentService';
19
25
  export { CMSService, type ReferenceLocation } from './services/cmsService';
20
26
  export { configService, ConfigService } from './services/configService';
21
27
  export { ColorService, colorService } from './services/ColorService';
@@ -50,6 +56,9 @@ export { WebSocketManager } from './websocketManager';
50
56
  // Page cache
51
57
  export { PageCache } from './pageCache';
52
58
 
59
+ // Draft page store (in-memory unsaved page versions for live preview)
60
+ export { DraftPageStore } from './draftPageStore';
61
+
53
62
  // Project context
54
63
  export * from './projectContext';
55
64
 
@@ -65,6 +74,9 @@ export { buildStaticPages } from '../../build-static';
65
74
  // Astro export
66
75
  export { buildAstroProject } from '../../build-astro';
67
76
 
77
+ // Next.js export
78
+ export { buildNextProject } from '../../build-next';
79
+
68
80
  // Webflow export
69
81
  export { buildWebflowPayload, wrapInWebflowTemplate } from './webflow';
70
82
  export type {
@@ -149,4 +149,12 @@ export class FileSystemPageProvider implements PageProvider {
149
149
  const filePath = this.resolveFilePath(path);
150
150
  return existsSync(filePath);
151
151
  }
152
+
153
+ baseDir(): string {
154
+ return this.pagesDir;
155
+ }
156
+
157
+ extension(): string {
158
+ return '.json';
159
+ }
152
160
  }
@@ -7,14 +7,19 @@ import type { ComponentService } from '../../services/componentService';
7
7
  import type { PageService } from '../../services/pageService';
8
8
  import type { ComponentNode } from '../../../shared/types';
9
9
  import { createCorsHeaders } from '../../middleware/cors';
10
- import { jsonResponse } from './shared';
10
+ import { jsonResponse, cachedJsonResponse } from './shared';
11
11
  import { formatJsonErrorMessage } from '../../../shared/jsonRepair';
12
12
 
13
13
  /**
14
14
  * Handle components API endpoint - GET /api/components
15
- * Returns components with category metadata derived from folder structure
15
+ * Returns components with category metadata derived from folder structure.
16
+ * Uses ETag revalidation so repeat page navigations get a 304 when the
17
+ * registry hasn't changed.
16
18
  */
17
- export function handleComponentsRoute(componentService: ComponentService): Response {
19
+ export function handleComponentsRoute(
20
+ req: Request,
21
+ componentService: ComponentService,
22
+ ): Response {
18
23
  const componentsWithCategories = componentService.getAllComponentsWithCategories();
19
24
 
20
25
  // Transform to API format: { name: { ...definition, _category } }
@@ -32,7 +37,7 @@ export function handleComponentsRoute(componentService: ComponentService): Respo
32
37
  result._diagnostics = diagnostics;
33
38
  }
34
39
 
35
- return jsonResponse(result);
40
+ return cachedJsonResponse(req, JSON.stringify(result));
36
41
  }
37
42
 
38
43
  /**
@@ -68,7 +68,7 @@ export async function handleCoreApiRoutes(
68
68
 
69
69
  if (url.pathname === API_ROUTES.PAGE_CONTENT && req.method === 'GET') {
70
70
  return await handleRouteError(
71
- () => Promise.resolve(pagesRoutes.handlePageContentRoute(url, pageService)),
71
+ () => Promise.resolve(pagesRoutes.handlePageContentRoute(req, url, pageService)),
72
72
  'Failed to fetch page content'
73
73
  );
74
74
  }
@@ -76,7 +76,7 @@ export async function handleCoreApiRoutes(
76
76
  // Components API routes (read-only)
77
77
  if (url.pathname === API_ROUTES.COMPONENTS && req.method === 'GET') {
78
78
  return await handleRouteError(
79
- () => Promise.resolve(componentsRoutes.handleComponentsRoute(componentService)),
79
+ () => Promise.resolve(componentsRoutes.handleComponentsRoute(req, componentService)),
80
80
  'Failed to fetch components list'
81
81
  );
82
82
  }
@@ -7,7 +7,7 @@ import type { PageService } from '../../services/pageService';
7
7
  import { API_ROUTES } from '../../../shared/constants';
8
8
  import { parseJSON } from '../../jsonLoader';
9
9
  import { createCorsHeaders } from '../../middleware/cors';
10
- import { jsonResponse } from './shared';
10
+ import { jsonResponse, cachedJsonResponse } from './shared';
11
11
 
12
12
  /**
13
13
  * Handle pages API endpoint - GET /api/pages
@@ -53,37 +53,29 @@ export function handlePageDataRoute(
53
53
  /**
54
54
  * Handle page content API endpoint - GET /api/page-content?page=...
55
55
  * Returns raw JSON text for client routing, prefetching, and studio editor.
56
+ * Uses ETag revalidation so repeat navigations to the same page get a 304
57
+ * instead of re-transmitting an unchanged body.
56
58
  */
57
59
  export function handlePageContentRoute(
60
+ req: Request,
58
61
  url: URL,
59
62
  pageService: PageService
60
63
  ): Response {
61
64
  const page = url.searchParams.get('page') || '/';
62
65
  const content = pageService.getPage(page);
63
- const corsHeaders = createCorsHeaders();
64
66
 
65
67
  if (content) {
66
- return new Response(content, {
67
- headers: {
68
- 'Content-Type': 'application/json',
69
- 'Cache-Control': 'no-store, max-age=0',
70
- 'Pragma': 'no-cache',
71
- 'Expires': '0',
72
- ...corsHeaders,
73
- },
74
- });
75
- } else {
76
- return new Response('Page not found', {
77
- status: 404,
78
- headers: {
79
- 'Content-Type': 'application/json',
80
- 'Cache-Control': 'no-store, max-age=0',
81
- 'Pragma': 'no-cache',
82
- 'Expires': '0',
83
- ...corsHeaders,
84
- },
85
- });
68
+ return cachedJsonResponse(req, content);
86
69
  }
70
+ const corsHeaders = createCorsHeaders();
71
+ return new Response('Page not found', {
72
+ status: 404,
73
+ headers: {
74
+ 'Content-Type': 'application/json',
75
+ 'Cache-Control': 'no-store, max-age=0',
76
+ ...corsHeaders,
77
+ },
78
+ });
87
79
  }
88
80
 
89
81
  /**
@@ -29,3 +29,59 @@ export function errorResponse(message: string, status: number = 500): Response {
29
29
  return jsonResponse({ error: message }, { status });
30
30
  }
31
31
 
32
+ /**
33
+ * Cheap, fast 32-bit string hash (FNV-1a). Used to build ETag values for
34
+ * read endpoints — collision chance is tiny and a false match only delays
35
+ * propagation of a change by one revalidation cycle.
36
+ */
37
+ function fnv1a(str: string): string {
38
+ let hash = 0x811c9dc5;
39
+ for (let i = 0; i < str.length; i++) {
40
+ hash ^= str.charCodeAt(i);
41
+ hash = Math.imul(hash, 0x01000193);
42
+ }
43
+ return (hash >>> 0).toString(36);
44
+ }
45
+
46
+ /**
47
+ * JSON response variant that lets the browser keep the body cached and
48
+ * revalidates cheaply via `ETag` / `If-None-Match`. Returns 304 with no
49
+ * body when the client already has the same version. Used by the
50
+ * high-frequency read endpoints (`/api/page-content`, `/api/components`)
51
+ * so iframe page navigations don't re-download identical bodies.
52
+ */
53
+ export function cachedJsonResponse(
54
+ req: Request,
55
+ body: string,
56
+ options: { status?: number; contentType?: string } = {},
57
+ ): Response {
58
+ const corsHeaders = createCorsHeaders();
59
+ const contentType = options.contentType ?? 'application/json';
60
+ const etag = `W/"${fnv1a(body)}"`;
61
+ const ifNoneMatch = req.headers.get('if-none-match');
62
+
63
+ // 304 path: identical body, skip transmission and parsing on the client.
64
+ if (ifNoneMatch && ifNoneMatch === etag) {
65
+ return new Response(null, {
66
+ status: 304,
67
+ headers: {
68
+ 'ETag': etag,
69
+ 'Cache-Control': 'no-cache',
70
+ ...corsHeaders,
71
+ },
72
+ });
73
+ }
74
+
75
+ return new Response(body, {
76
+ status: options.status ?? 200,
77
+ headers: {
78
+ 'Content-Type': contentType,
79
+ 'ETag': etag,
80
+ // `no-cache` (not `no-store`) — browser may keep the body but must
81
+ // revalidate every time. Pairs with the 304 branch above.
82
+ 'Cache-Control': 'no-cache',
83
+ ...corsHeaders,
84
+ },
85
+ });
86
+ }
87
+
@@ -7,6 +7,8 @@ import type { PageService } from '../services/pageService';
7
7
  import type { ComponentService } from '../services/componentService';
8
8
  import type { CMSService } from '../services/cmsService';
9
9
  import type { CMSProvider } from '../../shared/interfaces/contentProvider';
10
+ import type { WebSocketManager } from '../websocketManager';
11
+ import type { DraftPageStore } from '../draftPageStore';
10
12
  import { HMR_ROUTE } from '../../shared/constants';
11
13
  import { handleCorsPreflight } from '../middleware/cors';
12
14
  import { handleRouteError } from '../middleware/errorHandler';
@@ -32,6 +34,19 @@ export interface RouteContext {
32
34
  isEditor?: boolean;
33
35
  /** Actual bound server port, used to connect live reload WS directly to SSR server */
34
36
  serverPort?: number;
37
+ /**
38
+ * WebSocket manager for this server. Used by the /__draft-page endpoint
39
+ * to nudge live-reload clients (external browser tabs) to refetch after a
40
+ * draft pageData has been stored on the server.
41
+ */
42
+ wsManager?: WebSocketManager;
43
+ /**
44
+ * In-memory draft page store. When the page route finds a draft for the
45
+ * requested path it renders from that instead of disk, so external
46
+ * browser tabs see the editor's in-flight edits without anything being
47
+ * persisted.
48
+ */
49
+ draftPageStore?: DraftPageStore;
35
50
  }
36
51
 
37
52
  /**
@@ -64,6 +79,69 @@ export async function handleRoutes(
64
79
  return undefined;
65
80
  }
66
81
 
82
+ // Ephemeral draft-page channel for external browser tabs.
83
+ // POST /__draft-page body: { path, content } — store draft + nudge clients
84
+ // POST /__draft-page/clear body: { path } — drop draft (e.g. after save)
85
+ // GET /__draft-page/clients — probe connected-client count
86
+ //
87
+ // The studio fire-and-forgets POSTs here while the user edits. We bail
88
+ // before parsing the body when no external tab is listening, so this
89
+ // path costs ~zero in the common case.
90
+ if (url.pathname === '/__draft-page' && req.method === 'POST') {
91
+ const corsHeaders = { 'Access-Control-Allow-Origin': '*' };
92
+ const store = context.draftPageStore;
93
+ const ws = context.wsManager;
94
+ if (!store || !ws || ws.getClientCount() === 0) {
95
+ logResponseTime(startTime, req);
96
+ return new Response(null, { status: 204, headers: corsHeaders });
97
+ }
98
+ try {
99
+ const body = await req.json() as { path?: string; content?: string };
100
+ if (typeof body.path === 'string' && typeof body.content === 'string' && body.path.startsWith('/')) {
101
+ store.set(body.path, body.content);
102
+ // Nudge connected tabs to refetch. The existing live-reload
103
+ // script does a smart-diff hotReload off this message and will
104
+ // pull the draft-rendered HTML (classes + utility CSS together).
105
+ ws.broadcastUpdate(body.path);
106
+ }
107
+ } catch {
108
+ // Drop silently — studio retries on next commit.
109
+ }
110
+ logResponseTime(startTime, req);
111
+ return new Response(null, { status: 204, headers: corsHeaders });
112
+ }
113
+
114
+ if (url.pathname === '/__draft-page/clear' && req.method === 'POST') {
115
+ const corsHeaders = { 'Access-Control-Allow-Origin': '*' };
116
+ const store = context.draftPageStore;
117
+ if (store) {
118
+ try {
119
+ const body = await req.json() as { path?: string };
120
+ if (typeof body.path === 'string') {
121
+ store.clear(body.path);
122
+ // External tabs viewing this path should refetch the disk
123
+ // version now that the draft is gone.
124
+ context.wsManager?.broadcastUpdate(body.path);
125
+ }
126
+ } catch { /* ignore */ }
127
+ }
128
+ logResponseTime(startTime, req);
129
+ return new Response(null, { status: 204, headers: corsHeaders });
130
+ }
131
+
132
+ if (url.pathname === '/__draft-page/clients' && req.method === 'GET') {
133
+ const count = context.wsManager?.getClientCount() ?? 0;
134
+ logResponseTime(startTime, req);
135
+ return new Response(JSON.stringify({ count }), {
136
+ status: 200,
137
+ headers: {
138
+ 'Content-Type': 'application/json',
139
+ 'Cache-Control': 'no-store',
140
+ 'Access-Control-Allow-Origin': '*',
141
+ },
142
+ });
143
+ }
144
+
67
145
  // Build CMS context if CMS service and provider are available
68
146
  const cmsContext: CMSRouteContext | undefined =
69
147
  cmsService && cmsProvider
@@ -139,6 +217,18 @@ export async function handleRoutes(
139
217
  }
140
218
  }
141
219
 
220
+ // Unknown /api/* — return a real 404 instead of falling through to the
221
+ // SPA shell. Without this guard, an /api/foo that no handler claimed gets
222
+ // rendered as a 200 HTML page (the catch-all below), which makes broken
223
+ // route wiring look like a working endpoint to API clients.
224
+ if (url.pathname.startsWith('/api/')) {
225
+ logResponseTime(startTime, req);
226
+ return new Response(
227
+ JSON.stringify({ error: 'Not Found', path: url.pathname }),
228
+ { status: 404, headers: { 'Content-Type': 'application/json' } }
229
+ );
230
+ }
231
+
142
232
  // Page routes (SSR)
143
233
  if (url.pathname === '/' || (url.pathname.startsWith('/') && !url.pathname.includes('.'))) {
144
234
  const response = await handlePageRoute(url, context, req);
@@ -47,10 +47,15 @@ export async function handlePageRoute(
47
47
  ): Promise<Response | undefined> {
48
48
  const { pageService, componentService, cmsService, injectLiveReload, isEditor, serverPort } = context;
49
49
  const pagePath = url.pathname;
50
- // Editor selection attributes (data-element-path, data-cms-context, ...) are emitted
51
- // only when the request is proxied from the studio editor (it sets `x-meno-editor: 1`).
52
- // Direct access to the SSR preview server (e.g., http://localhost:8082/) gets clean output.
53
- const injectEditorAttrs = req?.headers.get(EDITOR_HEADER) === '1';
50
+ // Editor selection attributes (data-element-path, data-cms-context, ...).
51
+ // Emitted whenever live-reload is on (dev SSR preview) so the inline
52
+ // smart-diff script can identify elements when external browser tabs
53
+ // refetch on draft pushes — without these attrs the diff only syncs
54
+ // text nodes and className changes get silently dropped. Also still
55
+ // emitted when the studio's /__static__/ proxy sets `x-meno-editor: 1`
56
+ // (back-compat). Static/production builds keep clean output because
57
+ // injectLiveReload is false there.
58
+ const injectEditorAttrs = injectLiveReload === true || req?.headers.get(EDITOR_HEADER) === '1';
54
59
 
55
60
  // Fresh CSP nonce per request: stamped onto every inline <script> emitted
56
61
  // by the SSR pipeline AND sent back to the Electron main process via the
@@ -222,8 +227,10 @@ export async function handlePageRoute(
222
227
  }
223
228
  }
224
229
 
225
- // Development mode: Always use fresh SSR from JSON files (no caching)
226
- const pageContent = pageService.getPage(lookupPath);
230
+ // Development mode: Always use fresh SSR from JSON files (no caching).
231
+ // Studio-pushed draft (if any) wins over the on-disk version so external
232
+ // browser tabs reflect unsaved edits.
233
+ const pageContent = context.draftPageStore?.get(lookupPath) ?? pageService.getPage(lookupPath);
227
234
 
228
235
  if (pageContent) {
229
236
  try {