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.
- package/build-astro.ts +183 -13
- package/build-next.ts +1361 -0
- package/build-static.ts +7 -5
- package/dist/bin/cli.js +2 -2
- package/dist/build-static.js +6 -6
- package/dist/chunks/{chunk-HNLUO36W.js → chunk-GZHGVVW3.js} +2 -2
- package/dist/chunks/chunk-GZHGVVW3.js.map +7 -0
- package/dist/chunks/{chunk-LPVETICS.js → chunk-H3GJ4H2U.js} +185 -1
- package/dist/chunks/chunk-H3GJ4H2U.js.map +7 -0
- package/dist/chunks/{chunk-CXCBV2M7.js → chunk-IGYR22T6.js} +76 -270
- package/dist/chunks/chunk-IGYR22T6.js.map +7 -0
- package/dist/chunks/{chunk-LHLHPYSP.js → chunk-JGP5A3Y5.js} +12 -11
- package/dist/chunks/chunk-JGP5A3Y5.js.map +7 -0
- package/dist/chunks/{chunk-7NIC4I3V.js → chunk-JGWFTO6P.js} +167 -21
- package/dist/chunks/chunk-JGWFTO6P.js.map +7 -0
- package/dist/chunks/{chunk-EDQSMAMP.js → chunk-O3NAGJP4.js} +85 -4
- package/dist/chunks/chunk-O3NAGJP4.js.map +7 -0
- package/dist/chunks/{chunk-H4JSCDNW.js → chunk-QB2LNO4W.js} +24 -1
- package/dist/chunks/chunk-QB2LNO4W.js.map +7 -0
- package/dist/chunks/{chunk-A725KYFK.js → chunk-R6XHAFBF.js} +561 -112
- package/dist/chunks/chunk-R6XHAFBF.js.map +7 -0
- package/dist/chunks/{chunk-J23ZX5AP.js → chunk-X754AHS5.js} +277 -1
- package/dist/chunks/chunk-X754AHS5.js.map +7 -0
- package/dist/chunks/{chunk-2QK6U5UK.js → chunk-YBLHKYFF.js} +12 -2
- package/dist/chunks/chunk-YBLHKYFF.js.map +7 -0
- package/dist/chunks/{constants-GWBAD66U.js → constants-STK2YBIW.js} +2 -2
- package/dist/entries/server-router.js +7 -7
- package/dist/lib/client/index.js +354 -59
- package/dist/lib/client/index.js.map +4 -4
- package/dist/lib/server/index.js +1458 -190
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +202 -34
- package/dist/lib/shared/index.js.map +4 -4
- package/dist/lib/test-utils/index.js +1 -1
- package/entries/client-router.tsx +5 -165
- package/lib/client/ErrorBoundary.test.tsx +27 -25
- package/lib/client/ErrorBoundary.tsx +34 -19
- package/lib/client/core/ComponentBuilder.ts +19 -2
- package/lib/client/core/builders/embedBuilder.ts +8 -4
- package/lib/client/core/builders/listBuilder.ts +23 -4
- package/lib/client/fontFamiliesService.test.ts +76 -0
- package/lib/client/fontFamiliesService.ts +69 -0
- package/lib/client/hmrCssReload.ts +160 -0
- package/lib/client/hooks/useColorVariables.ts +2 -0
- package/lib/client/index.ts +4 -0
- package/lib/client/meno-filter/ui.ts +2 -0
- package/lib/client/routing/RouteLoader.test.ts +2 -2
- package/lib/client/routing/RouteLoader.ts +8 -2
- package/lib/client/routing/Router.tsx +81 -15
- package/lib/client/scripts/ScriptExecutor.test.ts +143 -0
- package/lib/client/scripts/ScriptExecutor.ts +56 -2
- package/lib/client/styles/StyleInjector.ts +20 -5
- package/lib/client/styles/UtilityClassCollector.ts +7 -1
- package/lib/client/styles/cspNonce.test.ts +67 -0
- package/lib/client/styles/cspNonce.ts +63 -0
- package/lib/client/templateEngine.test.ts +80 -0
- package/lib/client/templateEngine.ts +5 -0
- package/lib/server/astro/cmsPageEmitter.ts +35 -5
- package/lib/server/astro/componentEmitter.ts +61 -5
- package/lib/server/astro/nodeToAstro.ts +149 -11
- package/lib/server/astro/normalizeOrphanTemplateProps.test.ts +264 -0
- package/lib/server/astro/normalizeOrphanTemplateProps.ts +184 -0
- package/lib/server/createServer.ts +11 -0
- package/lib/server/draftPageStore.ts +49 -0
- package/lib/server/fileWatcher.ts +62 -2
- package/lib/server/index.ts +13 -1
- package/lib/server/providers/fileSystemPageProvider.ts +8 -0
- package/lib/server/routes/api/components.ts +9 -4
- package/lib/server/routes/api/core-routes.ts +2 -2
- package/lib/server/routes/api/pages.ts +14 -22
- package/lib/server/routes/api/shared.ts +56 -0
- package/lib/server/routes/index.ts +90 -0
- package/lib/server/routes/pages.ts +13 -6
- package/lib/server/services/componentService.test.ts +199 -2
- package/lib/server/services/componentService.ts +354 -49
- package/lib/server/services/fileWatcherService.ts +4 -24
- package/lib/server/services/pageService.test.ts +23 -0
- package/lib/server/services/pageService.ts +124 -6
- package/lib/server/ssr/attributeBuilder.ts +8 -2
- package/lib/server/ssr/buildErrorOverlay.ts +1 -1
- package/lib/server/ssr/errorOverlay.test.ts +21 -2
- package/lib/server/ssr/errorOverlay.ts +38 -11
- package/lib/server/ssr/htmlGenerator.test.ts +53 -13
- package/lib/server/ssr/htmlGenerator.ts +71 -27
- package/lib/server/ssr/liveReloadIntegration.test.ts +123 -2
- package/lib/server/ssr/ssrRenderer.test.ts +67 -0
- package/lib/server/ssr/ssrRenderer.ts +94 -9
- package/lib/server/websocketManager.ts +0 -1
- package/lib/shared/componentRefs.ts +45 -0
- package/lib/shared/constants.ts +8 -0
- package/lib/shared/cssGeneration.ts +2 -0
- package/lib/shared/cssProperties.ts +184 -0
- package/lib/shared/expressionEvaluator.ts +54 -0
- package/lib/shared/fontCss.ts +101 -0
- package/lib/shared/fontLoader.ts +8 -86
- package/lib/shared/friendlyError.test.ts +87 -0
- package/lib/shared/friendlyError.ts +121 -0
- package/lib/shared/hrefRefs.test.ts +130 -0
- package/lib/shared/hrefRefs.ts +100 -0
- package/lib/shared/index.ts +52 -0
- package/lib/shared/inlineSvgStyleRules.test.ts +108 -0
- package/lib/shared/inlineSvgStyleRules.ts +134 -0
- package/lib/shared/interfaces/contentProvider.ts +13 -0
- package/lib/shared/itemTemplateUtils.test.ts +14 -0
- package/lib/shared/itemTemplateUtils.ts +4 -1
- package/lib/shared/registry/NodeTypeDefinition.ts +1 -1
- package/lib/shared/registry/nodeTypes/LinkNodeType.ts +1 -1
- package/lib/shared/slugTranslator.test.ts +24 -0
- package/lib/shared/slugTranslator.ts +24 -0
- package/lib/shared/styleNodeUtils.ts +4 -1
- package/lib/shared/tree/PathBuilder.test.ts +128 -1
- package/lib/shared/tree/PathBuilder.ts +83 -31
- package/lib/shared/types/comment.ts +99 -0
- package/lib/shared/types/index.ts +12 -0
- package/lib/shared/types/rendering.ts +8 -0
- package/lib/shared/utilityClassConfig.ts +4 -2
- package/lib/shared/utilityClassMapper.test.ts +24 -0
- package/lib/shared/validation/commentValidators.ts +69 -0
- package/lib/shared/validation/index.ts +1 -0
- package/lib/shared/viewportUnits.integration.test.ts +42 -0
- package/lib/shared/viewportUnits.test.ts +103 -0
- package/lib/shared/viewportUnits.ts +63 -0
- package/lib/test-utils/dom-setup.ts +6 -0
- package/package.json +1 -1
- package/dist/chunks/chunk-2QK6U5UK.js.map +0 -7
- package/dist/chunks/chunk-7NIC4I3V.js.map +0 -7
- package/dist/chunks/chunk-A725KYFK.js.map +0 -7
- package/dist/chunks/chunk-CXCBV2M7.js.map +0 -7
- package/dist/chunks/chunk-EDQSMAMP.js.map +0 -7
- package/dist/chunks/chunk-H4JSCDNW.js.map +0 -7
- package/dist/chunks/chunk-HNLUO36W.js.map +0 -7
- package/dist/chunks/chunk-J23ZX5AP.js.map +0 -7
- package/dist/chunks/chunk-LHLHPYSP.js.map +0 -7
- package/dist/chunks/chunk-LPVETICS.js.map +0 -7
- /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
|
*/
|
package/lib/server/index.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
|
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
|
|
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, ...)
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
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
|
-
|
|
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 {
|