meno-core 1.0.26 → 1.0.28
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/bin/cli.ts +10 -14
- package/build-static.ts +88 -98
- package/entries/server-router.tsx +1 -1
- package/lib/client/routing/Router.tsx +18 -3
- package/lib/server/__integration__/test-helpers.ts +1 -1
- package/lib/server/createServer.ts +67 -74
- package/lib/server/fileWatcher.ts +29 -2
- package/lib/server/index.ts +6 -0
- package/lib/server/jsonLoader.ts +48 -52
- package/lib/server/projectContext.ts +4 -1
- package/lib/server/providers/fileSystemCMSProvider.ts +22 -13
- package/lib/server/providers/fileSystemPageProvider.ts +13 -9
- package/lib/server/routes/api/functions.ts +5 -7
- package/lib/server/routes/index.ts +3 -5
- package/lib/server/routes/pages.ts +8 -11
- package/lib/server/routes/static.ts +20 -49
- package/lib/server/runtime/bundler.ts +285 -0
- package/lib/server/runtime/fs.ts +232 -0
- package/lib/server/runtime/httpServer.ts +228 -0
- package/lib/server/runtime/index.ts +36 -0
- package/lib/server/scriptCache.ts +3 -1
- package/lib/server/services/ColorService.ts +2 -1
- package/lib/server/services/EnumService.ts +18 -13
- package/lib/server/services/VariableService.ts +2 -1
- package/lib/server/services/componentService.ts +7 -9
- package/lib/server/services/configService.ts +3 -3
- package/lib/server/services/fileWatcherService.ts +3 -1
- package/lib/server/ssr/htmlGenerator.test.ts +2 -1
- package/lib/server/ssr/htmlGenerator.ts +2 -2
- package/lib/server/ssr/imageMetadata.ts +3 -3
- package/lib/server/ssr/jsCollector.ts +3 -39
- package/lib/server/ssr/ssrRenderer.ts +20 -3
- package/lib/server/websocketManager.ts +7 -13
- package/lib/shared/cssGeneration.test.ts +10 -0
- package/lib/shared/cssGeneration.ts +1 -1
- package/lib/shared/fontLoader.ts +3 -3
- package/lib/shared/libraryLoader.test.ts +91 -0
- package/lib/shared/libraryLoader.ts +43 -0
- package/lib/shared/utilityClassMapper.test.ts +5 -0
- package/lib/shared/utilityClassMapper.ts +6 -0
- package/package.json +20 -3
- package/scripts/build-for-publish.mjs +43 -0
package/bin/cli.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* meno CLI
|
|
4
4
|
* Commands: dev, build, init
|
|
@@ -8,6 +8,7 @@ import { resolve, join } from 'path';
|
|
|
8
8
|
import { existsSync, mkdirSync, writeFileSync, cpSync, readFileSync } from 'fs';
|
|
9
9
|
import { setProjectRoot } from '../lib/server/projectContext';
|
|
10
10
|
import { generateBuildErrorPage, type BuildErrorsData } from '../lib/server/ssr/buildErrorOverlay';
|
|
11
|
+
import { createRuntimeServer, serveFile, fileExists as runtimeFileExists } from '../lib/server/runtime';
|
|
11
12
|
|
|
12
13
|
const args = process.argv.slice(2);
|
|
13
14
|
const command = args[0];
|
|
@@ -17,7 +18,7 @@ function printHelp() {
|
|
|
17
18
|
meno - Visual editor for JSON-based pages
|
|
18
19
|
|
|
19
20
|
Requirements:
|
|
20
|
-
- Bun
|
|
21
|
+
- Bun or Node.js 18+
|
|
21
22
|
|
|
22
23
|
Usage:
|
|
23
24
|
meno <command> [options]
|
|
@@ -100,7 +101,7 @@ async function startStaticServer(distPath: string) {
|
|
|
100
101
|
// Parse _headers file once on startup
|
|
101
102
|
const headersMap = parseHeadersFile(distPath);
|
|
102
103
|
|
|
103
|
-
const server =
|
|
104
|
+
const server = await createRuntimeServer({
|
|
104
105
|
port: SERVE_PORT,
|
|
105
106
|
hostname: 'localhost',
|
|
106
107
|
async fetch(req: Request) {
|
|
@@ -134,25 +135,20 @@ async function startStaticServer(distPath: string) {
|
|
|
134
135
|
const filePath = join(distPath, pathname);
|
|
135
136
|
|
|
136
137
|
// Check if file exists and serve it
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
if (await file.exists()) {
|
|
140
|
-
return new Response(file, { headers: customHeaders });
|
|
141
|
-
}
|
|
138
|
+
if (await runtimeFileExists(filePath)) {
|
|
139
|
+
return await serveFile(filePath, customHeaders);
|
|
142
140
|
}
|
|
143
141
|
|
|
144
142
|
// Try with .html extension for clean URLs
|
|
145
143
|
const htmlPath = filePath.endsWith('.html') ? filePath : `${filePath}.html`;
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
return new Response(file, { headers: customHeaders });
|
|
144
|
+
if (await runtimeFileExists(htmlPath)) {
|
|
145
|
+
return await serveFile(htmlPath, customHeaders);
|
|
149
146
|
}
|
|
150
147
|
|
|
151
148
|
// Try index.html in directory
|
|
152
149
|
const indexPath = join(filePath, 'index.html');
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
return new Response(file, { headers: customHeaders });
|
|
150
|
+
if (await runtimeFileExists(indexPath)) {
|
|
151
|
+
return await serveFile(indexPath, customHeaders);
|
|
156
152
|
}
|
|
157
153
|
|
|
158
154
|
return new Response('Not Found', { status: 404 });
|
package/build-static.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { writeFile, readFile } from "fs/promises";
|
|
|
9
9
|
import { join } from "path";
|
|
10
10
|
import type { BuildError, BuildErrorsData } from "./lib/server/ssr/buildErrorOverlay";
|
|
11
11
|
import { createHash } from "crypto";
|
|
12
|
+
import { inspect, minifyJS as runtimeMinifyJS } from './lib/server/runtime';
|
|
12
13
|
import {
|
|
13
14
|
loadJSONFile,
|
|
14
15
|
loadComponentDirectory,
|
|
@@ -31,6 +32,8 @@ import { buildItemUrl } from "./lib/shared/itemTemplateUtils";
|
|
|
31
32
|
import { generateMiddleware, generateTrackFunction, generateResultsFunction } from "./lib/server/ab/generateFunctions";
|
|
32
33
|
import { generateTrackingScript } from "./lib/server/ab/trackingScript";
|
|
33
34
|
import { migrateTemplatesDirectory } from "./lib/server/migrateTemplates";
|
|
35
|
+
import { extractLibraryOrigins, collectComponentLibraries } from "./lib/shared/libraryLoader";
|
|
36
|
+
import type { LibrariesConfig } from "./lib/shared/types/libraries";
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
39
|
* Collect build errors for error overlay
|
|
@@ -78,65 +81,15 @@ export function formatBunLog(log: any): string {
|
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
/**
|
|
81
|
-
* Minify JavaScript code using
|
|
84
|
+
* Minify JavaScript code using runtime bundler
|
|
82
85
|
* Throws on error instead of silently failing
|
|
83
86
|
*/
|
|
84
87
|
async function minifyJS(code: string): Promise<string> {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// Use throw: true to get detailed error information
|
|
90
|
-
const result = await Bun.build({
|
|
91
|
-
entrypoints: [tempFile],
|
|
92
|
-
minify: true,
|
|
93
|
-
throw: true, // This makes Bun throw with detailed error info
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
if (result.outputs.length > 0) {
|
|
97
|
-
return await result.outputs[0].text();
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
throw new Error('JavaScript minification produced no output');
|
|
101
|
-
} catch (err: any) {
|
|
102
|
-
// Re-throw with formatted message that includes all details
|
|
103
|
-
let details = '';
|
|
104
|
-
|
|
105
|
-
// Bun's BuildError has a logs array with detailed info
|
|
106
|
-
if (err.logs && Array.isArray(err.logs)) {
|
|
107
|
-
details = err.logs.map((log: any) => {
|
|
108
|
-
const parts: string[] = [];
|
|
109
|
-
if (log.position?.line) {
|
|
110
|
-
parts.push(`Line ${log.position.line}:${log.position.column || 0}`);
|
|
111
|
-
}
|
|
112
|
-
if (log.position?.lineText) {
|
|
113
|
-
parts.push(log.position.lineText);
|
|
114
|
-
}
|
|
115
|
-
if (log.message) {
|
|
116
|
-
parts.push(log.message);
|
|
117
|
-
}
|
|
118
|
-
return parts.length > 0 ? parts.join('\n') : String(log);
|
|
119
|
-
}).join('\n\n');
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// If no logs, try Bun.inspect for full error details
|
|
123
|
-
if (!details) {
|
|
124
|
-
try {
|
|
125
|
-
details = Bun.inspect(err);
|
|
126
|
-
} catch {
|
|
127
|
-
details = err.stack || err.message || String(err);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
throw new Error(`JavaScript minification failed:\n${details}`);
|
|
132
|
-
} finally {
|
|
133
|
-
// Clean up temp file
|
|
134
|
-
try {
|
|
135
|
-
rmSync(tempFile, { force: true });
|
|
136
|
-
} catch {
|
|
137
|
-
// Ignore cleanup errors
|
|
138
|
-
}
|
|
88
|
+
const result = await runtimeMinifyJS(code);
|
|
89
|
+
if (result.success) {
|
|
90
|
+
return result.code;
|
|
139
91
|
}
|
|
92
|
+
throw new Error(`JavaScript minification failed:\n${result.errors.join('\n\n')}`);
|
|
140
93
|
}
|
|
141
94
|
|
|
142
95
|
/**
|
|
@@ -554,11 +507,10 @@ async function buildCMSTemplates(
|
|
|
554
507
|
errorMessage = String(error);
|
|
555
508
|
}
|
|
556
509
|
|
|
557
|
-
// If we still just have "Bundle failed", try to get more from
|
|
510
|
+
// If we still just have "Bundle failed", try to get more from inspect
|
|
558
511
|
if (errorMessage === 'Bundle failed' || errorMessage.includes('Bundle failed\n')) {
|
|
559
512
|
try {
|
|
560
|
-
|
|
561
|
-
const inspected = Bun.inspect(error);
|
|
513
|
+
const inspected = inspect(error);
|
|
562
514
|
if (inspected && inspected !== '[object Object]' && inspected.length > errorMessage.length) {
|
|
563
515
|
errorMessage = inspected;
|
|
564
516
|
}
|
|
@@ -723,34 +675,9 @@ export async function buildStaticPages(): Promise<void> {
|
|
|
723
675
|
}
|
|
724
676
|
}
|
|
725
677
|
|
|
726
|
-
//
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
const cspConfig = configService.getCSP();
|
|
730
|
-
if (cspConfig && Object.keys(cspConfig).length > 0) {
|
|
731
|
-
const extraScripts = cspConfig.scriptSrc?.join(' ') || '';
|
|
732
|
-
const extraStyles = cspConfig.styleSrc?.join(' ') || '';
|
|
733
|
-
const extraConnect = cspConfig.connectSrc?.join(' ') || '';
|
|
734
|
-
const extraFrames = cspConfig.frameSrc?.join(' ') || '';
|
|
735
|
-
const extraFonts = cspConfig.fontSrc?.join(' ') || '';
|
|
736
|
-
const extraImgs = cspConfig.imgSrc?.join(' ') || '';
|
|
737
|
-
|
|
738
|
-
const cspDirectives = [
|
|
739
|
-
"default-src 'self'",
|
|
740
|
-
`script-src 'self' 'unsafe-inline' https://f.vimeocdn.com https://player.vimeo.com https://www.youtube.com https://s.ytimg.com ${extraScripts}`.trim(),
|
|
741
|
-
`style-src 'self' 'unsafe-inline' https://f.vimeocdn.com ${extraStyles}`.trim(),
|
|
742
|
-
`img-src 'self' data: https: ${extraImgs}`.trim(),
|
|
743
|
-
`connect-src 'self' https://vimeo.com https://*.vimeocdn.com ${extraConnect}`.trim(),
|
|
744
|
-
`frame-src https://player.vimeo.com https://vimeo.com https://www.youtube.com https://www.youtube-nocookie.com ${extraFrames}`.trim(),
|
|
745
|
-
`font-src 'self' data: ${extraFonts}`.trim(),
|
|
746
|
-
"media-src 'self' https: blob:"
|
|
747
|
-
].join('; ');
|
|
748
|
-
|
|
749
|
-
const headersContent = `/*\n Content-Security-Policy: ${cspDirectives}\n`;
|
|
750
|
-
writeFileSync(join(distDir, '_headers'), headersContent);
|
|
751
|
-
hostingFiles.push('_headers (generated from csp config)');
|
|
752
|
-
}
|
|
753
|
-
}
|
|
678
|
+
// _headers generation is deferred until after components and pages are loaded
|
|
679
|
+
// so that library CDN domains can be auto-added to CSP
|
|
680
|
+
const hasCustomHeaders = hostingFiles.includes('_headers');
|
|
754
681
|
|
|
755
682
|
// Copy .well-known directory if exists
|
|
756
683
|
const wellKnownDir = join(projectPaths.project, '.well-known');
|
|
@@ -786,7 +713,19 @@ export async function buildStaticPages(): Promise<void> {
|
|
|
786
713
|
process.exit(1);
|
|
787
714
|
}
|
|
788
715
|
|
|
789
|
-
|
|
716
|
+
// Recursively collect all .json page files (supports nested folders like pages/a/b.json)
|
|
717
|
+
const pageFiles: string[] = [];
|
|
718
|
+
function scanPagesDir(dir: string, prefix: string): void {
|
|
719
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
720
|
+
for (const entry of entries) {
|
|
721
|
+
if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
722
|
+
pageFiles.push(prefix ? `${prefix}/${entry.name}` : entry.name);
|
|
723
|
+
} else if (entry.isDirectory()) {
|
|
724
|
+
scanPagesDir(join(dir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
scanPagesDir(pagesDir, '');
|
|
790
729
|
|
|
791
730
|
if (pageFiles.length === 0) {
|
|
792
731
|
console.warn("⚠️ No pages found in ./pages directory");
|
|
@@ -795,8 +734,9 @@ export async function buildStaticPages(): Promise<void> {
|
|
|
795
734
|
|
|
796
735
|
console.log(`📄 Found ${pageFiles.length} page(s) to build\n`);
|
|
797
736
|
|
|
798
|
-
// First pass: collect all slug mappings
|
|
737
|
+
// First pass: collect all slug mappings and page-level libraries
|
|
799
738
|
const slugMappings: SlugMap[] = [];
|
|
739
|
+
const allPageLibraries: LibrariesConfig[] = [];
|
|
800
740
|
for (const file of pageFiles) {
|
|
801
741
|
const pageName = file.replace(".json", "");
|
|
802
742
|
const basePath = mapPageNameToPath(pageName);
|
|
@@ -808,10 +748,68 @@ export async function buildStaticPages(): Promise<void> {
|
|
|
808
748
|
const pageId = basePath === '/' ? 'index' : basePath.substring(1);
|
|
809
749
|
slugMappings.push({ pageId, slugs: pageData.meta.slugs });
|
|
810
750
|
}
|
|
751
|
+
if (pageData.meta?.libraries) {
|
|
752
|
+
allPageLibraries.push(pageData.meta.libraries as LibrariesConfig);
|
|
753
|
+
}
|
|
811
754
|
} catch { /* ignore parse errors in first pass */ }
|
|
812
755
|
}
|
|
813
756
|
}
|
|
814
757
|
|
|
758
|
+
// Generate _headers from CSP config, auto-including library CDN domains
|
|
759
|
+
if (!hasCustomHeaders) {
|
|
760
|
+
await configService.load();
|
|
761
|
+
const cspConfig = configService.getCSP();
|
|
762
|
+
if (cspConfig && Object.keys(cspConfig).length > 0) {
|
|
763
|
+
// Collect all library sources: global + component + page-level
|
|
764
|
+
// Note: we concatenate (not mergeLibraries) because _headers applies globally —
|
|
765
|
+
// we need the union of ALL origins, regardless of per-page merge modes
|
|
766
|
+
const globalLibraries = configService.getLibraries();
|
|
767
|
+
const componentLibraries = collectComponentLibraries(globalComponents);
|
|
768
|
+
const allJs = [
|
|
769
|
+
...(globalLibraries.js || []),
|
|
770
|
+
...(componentLibraries.js || []),
|
|
771
|
+
...allPageLibraries.flatMap(p => p.js || []),
|
|
772
|
+
];
|
|
773
|
+
const allCss = [
|
|
774
|
+
...(globalLibraries.css || []),
|
|
775
|
+
...(componentLibraries.css || []),
|
|
776
|
+
...allPageLibraries.flatMap(p => p.css || []),
|
|
777
|
+
];
|
|
778
|
+
const allLibs: LibrariesConfig = { js: allJs, css: allCss };
|
|
779
|
+
|
|
780
|
+
// Extract CDN origins from all library URLs
|
|
781
|
+
const { scriptOrigins, styleOrigins } = extractLibraryOrigins(allLibs);
|
|
782
|
+
|
|
783
|
+
const extraScripts = [
|
|
784
|
+
...(cspConfig.scriptSrc || []),
|
|
785
|
+
...Array.from(scriptOrigins),
|
|
786
|
+
].join(' ');
|
|
787
|
+
const extraStyles = [
|
|
788
|
+
...(cspConfig.styleSrc || []),
|
|
789
|
+
...Array.from(styleOrigins),
|
|
790
|
+
].join(' ');
|
|
791
|
+
const extraConnect = cspConfig.connectSrc?.join(' ') || '';
|
|
792
|
+
const extraFrames = cspConfig.frameSrc?.join(' ') || '';
|
|
793
|
+
const extraFonts = cspConfig.fontSrc?.join(' ') || '';
|
|
794
|
+
const extraImgs = cspConfig.imgSrc?.join(' ') || '';
|
|
795
|
+
|
|
796
|
+
const cspDirectives = [
|
|
797
|
+
"default-src 'self'",
|
|
798
|
+
`script-src 'self' 'unsafe-inline' https://f.vimeocdn.com https://player.vimeo.com https://www.youtube.com https://s.ytimg.com ${extraScripts}`.trim(),
|
|
799
|
+
`style-src 'self' 'unsafe-inline' https://f.vimeocdn.com ${extraStyles}`.trim(),
|
|
800
|
+
`img-src 'self' data: https: ${extraImgs}`.trim(),
|
|
801
|
+
`connect-src 'self' https://vimeo.com https://*.vimeocdn.com ${extraConnect}`.trim(),
|
|
802
|
+
`frame-src https://player.vimeo.com https://vimeo.com https://www.youtube.com https://www.youtube-nocookie.com ${extraFrames}`.trim(),
|
|
803
|
+
`font-src 'self' data: ${extraFonts}`.trim(),
|
|
804
|
+
"media-src 'self' https: blob:"
|
|
805
|
+
].join('; ');
|
|
806
|
+
|
|
807
|
+
const headersContent = `/*\n Content-Security-Policy: ${cspDirectives}\n`;
|
|
808
|
+
writeFileSync(join(distDir, '_headers'), headersContent);
|
|
809
|
+
console.log(`✅ Generated _headers with CSP (auto-included ${scriptOrigins.size + styleOrigins.size} library origin(s))\n`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
815
813
|
let successCount = 0;
|
|
816
814
|
let errorCount = 0;
|
|
817
815
|
|
|
@@ -930,11 +928,10 @@ export async function buildStaticPages(): Promise<void> {
|
|
|
930
928
|
errorMessage = String(error);
|
|
931
929
|
}
|
|
932
930
|
|
|
933
|
-
// If we still just have "Bundle failed", try to get more from
|
|
931
|
+
// If we still just have "Bundle failed", try to get more from inspect
|
|
934
932
|
if (errorMessage === 'Bundle failed' || errorMessage.includes('Bundle failed\n')) {
|
|
935
933
|
try {
|
|
936
|
-
|
|
937
|
-
const inspected = Bun.inspect(error);
|
|
934
|
+
const inspected = inspect(error);
|
|
938
935
|
if (inspected && inspected !== '[object Object]' && inspected.length > errorMessage.length) {
|
|
939
936
|
errorMessage = inspected;
|
|
940
937
|
}
|
|
@@ -1044,11 +1041,4 @@ export async function buildStaticPages(): Promise<void> {
|
|
|
1044
1041
|
}
|
|
1045
1042
|
}
|
|
1046
1043
|
|
|
1047
|
-
// Run build only when executed directly (CLI), not when imported as a module
|
|
1048
|
-
if (import.meta.main) {
|
|
1049
|
-
await buildStaticPages().catch((error) => {
|
|
1050
|
-
console.error("❌ Build failed:", error);
|
|
1051
|
-
process.exit(1);
|
|
1052
|
-
});
|
|
1053
|
-
}
|
|
1054
1044
|
|
|
@@ -142,6 +142,10 @@ export function Router(props: RouterProps = {}): ReactElement {
|
|
|
142
142
|
// Store CMS template path for HMR reloads (so we don't lose it after initial load)
|
|
143
143
|
const [cmsTemplatePath, setCmsTemplatePath] = useState<string | null>(null);
|
|
144
144
|
|
|
145
|
+
// Grace period: skip HMR reloads shortly after receiving committed data via postMessage
|
|
146
|
+
const lastCommitTimestampRef = useRef(0);
|
|
147
|
+
const COMMIT_GRACE_MS = 1000;
|
|
148
|
+
|
|
145
149
|
// Track if initial mount used SSR CMS context (to skip redundant path-based load)
|
|
146
150
|
const ssrCmsHandledRef = useRef(false);
|
|
147
151
|
// Track if initial load is done (to prevent currentPath effect from firing on mount)
|
|
@@ -292,6 +296,7 @@ export function Router(props: RouterProps = {}): ReactElement {
|
|
|
292
296
|
setPreviewComponentTree(null);
|
|
293
297
|
} else if (event.data?.type === IFRAME_MESSAGE_TYPES.PAGE_DATA_COMMITTED) {
|
|
294
298
|
// Update the real tree with committed editor mutations (instant preview)
|
|
299
|
+
lastCommitTimestampRef.current = Date.now();
|
|
295
300
|
const pageData = event.data.pageData;
|
|
296
301
|
if (pageData?.root) {
|
|
297
302
|
setComponentTree(pageData.root);
|
|
@@ -299,6 +304,7 @@ export function Router(props: RouterProps = {}): ReactElement {
|
|
|
299
304
|
componentTreeRef.current = pageData.root;
|
|
300
305
|
}
|
|
301
306
|
} else if (event.data?.type === IFRAME_MESSAGE_TYPES.COMPONENT_DEFINITION_COMMITTED) {
|
|
307
|
+
lastCommitTimestampRef.current = Date.now();
|
|
302
308
|
const { componentName, definition } = event.data;
|
|
303
309
|
if (componentName && definition) {
|
|
304
310
|
services.componentRegistry.register(componentName, definition);
|
|
@@ -332,7 +338,7 @@ export function Router(props: RouterProps = {}): ReactElement {
|
|
|
332
338
|
// Clear registry when tree is cleared (no elements to register)
|
|
333
339
|
elementRegistry.clear();
|
|
334
340
|
}
|
|
335
|
-
}, [previewComponentTree, componentTree, services, cmsContext]);
|
|
341
|
+
}, [previewComponentTree, componentTree, services, cmsContext, registryVersion]);
|
|
336
342
|
|
|
337
343
|
// Post-paint: send interactive styles to parent and execute scripts
|
|
338
344
|
useEffect(() => {
|
|
@@ -361,7 +367,7 @@ export function Router(props: RouterProps = {}): ReactElement {
|
|
|
361
367
|
};
|
|
362
368
|
}
|
|
363
369
|
}
|
|
364
|
-
}, [previewComponentTree, componentTree, services, disableScripts, cmsContext]);
|
|
370
|
+
}, [previewComponentTree, componentTree, services, disableScripts, cmsContext, registryVersion]);
|
|
365
371
|
|
|
366
372
|
// Prefetch all internal links when using 'load' strategy
|
|
367
373
|
useEffect(() => {
|
|
@@ -391,6 +397,15 @@ export function Router(props: RouterProps = {}): ReactElement {
|
|
|
391
397
|
await routeLoader.loadComponents(pathToLoad);
|
|
392
398
|
}, [cmsTemplatePath]);
|
|
393
399
|
|
|
400
|
+
// Grace-aware version for HMR: skip reload if iframe just received committed data via postMessage
|
|
401
|
+
const loadComponentsForHMR = useCallback(async (path: string) => {
|
|
402
|
+
if (Date.now() - lastCommitTimestampRef.current < COMMIT_GRACE_MS) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const pathToLoad = cmsTemplatePath || path;
|
|
406
|
+
await routeLoader.loadComponents(pathToLoad);
|
|
407
|
+
}, [cmsTemplatePath]);
|
|
408
|
+
|
|
394
409
|
// Ref for loadComponents to avoid recreating effects when cmsTemplatePath changes
|
|
395
410
|
const loadComponentsRef = useRef(loadComponents);
|
|
396
411
|
useEffect(() => { loadComponentsRef.current = loadComponents; }, [loadComponents]);
|
|
@@ -626,7 +641,7 @@ export function Router(props: RouterProps = {}): ReactElement {
|
|
|
626
641
|
// Render HMRManager once at the top level with page content
|
|
627
642
|
return h('div', null,
|
|
628
643
|
h(HMRManager, {
|
|
629
|
-
onReload:
|
|
644
|
+
onReload: loadComponentsForHMR,
|
|
630
645
|
onCMSUpdate: handleCMSUpdate,
|
|
631
646
|
currentPath: currentPath,
|
|
632
647
|
}),
|
|
@@ -162,7 +162,7 @@ export async function createTestServer(options: {
|
|
|
162
162
|
cmsProvider: options.cmsProvider,
|
|
163
163
|
};
|
|
164
164
|
|
|
165
|
-
const { server, port: actualPort } = createServer(config);
|
|
165
|
+
const { server, port: actualPort } = await createServer(config);
|
|
166
166
|
const baseUrl = `http://localhost:${actualPort}`;
|
|
167
167
|
|
|
168
168
|
return {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Server Factory
|
|
3
|
-
* Creates a
|
|
3
|
+
* Creates a runtime-agnostic server for meno-core
|
|
4
4
|
* Can be extended by @meno/studio for editor functionality
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -11,16 +11,10 @@ import type { CMSProvider } from '../shared/interfaces/contentProvider';
|
|
|
11
11
|
import { WebSocketManager } from './websocketManager';
|
|
12
12
|
import { handleRoutes, type RouteContext } from './routes';
|
|
13
13
|
import { SERVER_PORT, MAX_PORT_ATTEMPTS, HMR_ROUTE } from '../shared/constants';
|
|
14
|
-
|
|
15
|
-
// Bun WebSocket type
|
|
16
|
-
type BunWebSocket = {
|
|
17
|
-
send(data: string | ArrayBuffer | Uint8Array): void;
|
|
18
|
-
readyState: number;
|
|
19
|
-
close(code?: number, reason?: string): void;
|
|
20
|
-
};
|
|
14
|
+
import { createRuntimeServer, type RuntimeServer, type RuntimeWSClient } from './runtime';
|
|
21
15
|
|
|
22
16
|
// Custom message handler type
|
|
23
|
-
export type WSMessageHandler = (ws:
|
|
17
|
+
export type WSMessageHandler = (ws: RuntimeWSClient, data: unknown) => void;
|
|
24
18
|
|
|
25
19
|
/**
|
|
26
20
|
* Server configuration
|
|
@@ -54,14 +48,14 @@ export type AdditionalRouteHandler = (
|
|
|
54
48
|
* Server factory result
|
|
55
49
|
*/
|
|
56
50
|
export interface ServerResult {
|
|
57
|
-
server:
|
|
51
|
+
server: RuntimeServer;
|
|
58
52
|
port: number;
|
|
59
53
|
}
|
|
60
54
|
|
|
61
55
|
/**
|
|
62
|
-
* Create a
|
|
56
|
+
* Create a server with the given configuration
|
|
63
57
|
*/
|
|
64
|
-
export function createServer(config: ServerConfig): ServerResult {
|
|
58
|
+
export async function createServer(config: ServerConfig): Promise<ServerResult> {
|
|
65
59
|
const {
|
|
66
60
|
port: requestedPort = SERVER_PORT,
|
|
67
61
|
pageService,
|
|
@@ -82,74 +76,77 @@ export function createServer(config: ServerConfig): ServerResult {
|
|
|
82
76
|
injectLiveReload,
|
|
83
77
|
};
|
|
84
78
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
port,
|
|
88
|
-
hostname: 'localhost',
|
|
89
|
-
development: {
|
|
90
|
-
hmr: true,
|
|
91
|
-
},
|
|
92
|
-
|
|
93
|
-
async fetch(req: Request, server: any) {
|
|
94
|
-
const url = new URL(req.url);
|
|
95
|
-
|
|
96
|
-
// Check additional routes first (allows studio to inject routes)
|
|
97
|
-
for (const handler of additionalRoutes) {
|
|
98
|
-
const response = await handler(req, url, routeContext);
|
|
99
|
-
if (response) {
|
|
100
|
-
return response;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
79
|
+
// Try to start server, incrementing port if busy
|
|
80
|
+
let lastError: unknown;
|
|
103
81
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return response || new Response('Not Found', { status: 404 });
|
|
107
|
-
},
|
|
82
|
+
for (let portAttempt = 0; portAttempt < MAX_PORT_ATTEMPTS; portAttempt++) {
|
|
83
|
+
const currentPort = requestedPort + portAttempt;
|
|
108
84
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
85
|
+
try {
|
|
86
|
+
const server = await createRuntimeServer({
|
|
87
|
+
port: currentPort,
|
|
88
|
+
hostname: 'localhost',
|
|
89
|
+
wsPath: HMR_ROUTE,
|
|
90
|
+
|
|
91
|
+
async fetch(req: Request, upgradeWebSocket: (req: Request) => boolean) {
|
|
92
|
+
const url = new URL(req.url);
|
|
93
|
+
|
|
94
|
+
// Check additional routes first (allows studio to inject routes)
|
|
95
|
+
for (const handler of additionalRoutes) {
|
|
96
|
+
const response = await handler(req, url, routeContext);
|
|
97
|
+
if (response) {
|
|
98
|
+
return response;
|
|
121
99
|
}
|
|
100
|
+
}
|
|
122
101
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
102
|
+
// WebSocket upgrade for HMR
|
|
103
|
+
if (url.pathname === HMR_ROUTE) {
|
|
104
|
+
const success = upgradeWebSocket(req);
|
|
105
|
+
return success
|
|
106
|
+
? undefined
|
|
107
|
+
: new Response('WebSocket upgrade failed', { status: 500 });
|
|
129
108
|
}
|
|
130
|
-
},
|
|
131
|
-
close(ws: BunWebSocket) {
|
|
132
|
-
wsManager.removeClient(ws);
|
|
133
|
-
},
|
|
134
|
-
},
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
109
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
110
|
+
// Handle all routes through centralized router
|
|
111
|
+
const response = await handleRoutes(req, url, null, routeContext);
|
|
112
|
+
return response || new Response('Not Found', { status: 404 });
|
|
113
|
+
},
|
|
141
114
|
|
|
142
|
-
|
|
143
|
-
|
|
115
|
+
websocket: {
|
|
116
|
+
open(ws: RuntimeWSClient) {
|
|
117
|
+
wsManager.addClient(ws);
|
|
118
|
+
},
|
|
119
|
+
message(ws: RuntimeWSClient, message: string) {
|
|
120
|
+
try {
|
|
121
|
+
const data = JSON.parse(message);
|
|
122
|
+
|
|
123
|
+
// Respond to ping with pong (for heartbeat)
|
|
124
|
+
if (data.type === 'ping') {
|
|
125
|
+
ws.send('pong');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Allow custom message handlers
|
|
130
|
+
if (onWSMessage) {
|
|
131
|
+
onWSMessage(ws, data);
|
|
132
|
+
}
|
|
133
|
+
} catch (error) {
|
|
134
|
+
// Ignore non-JSON messages
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
close(ws: RuntimeWSClient) {
|
|
138
|
+
wsManager.removeClient(ws);
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
});
|
|
144
142
|
|
|
145
|
-
try {
|
|
146
|
-
server = Bun.serve(createBunConfig(currentPort));
|
|
147
|
-
actualPort = server.port ?? currentPort;
|
|
148
143
|
if (portAttempt > 0) {
|
|
149
|
-
console.log(`Warning: Port ${requestedPort} was busy, using port ${
|
|
144
|
+
console.log(`Warning: Port ${requestedPort} was busy, using port ${server.port} instead`);
|
|
150
145
|
}
|
|
151
|
-
|
|
146
|
+
|
|
147
|
+
return { server, port: server.port };
|
|
152
148
|
} catch (error: unknown) {
|
|
149
|
+
lastError = error;
|
|
153
150
|
const err = error as { code?: string };
|
|
154
151
|
if (err?.code === 'EADDRINUSE') {
|
|
155
152
|
if (portAttempt >= MAX_PORT_ATTEMPTS - 1) {
|
|
@@ -164,11 +161,7 @@ export function createServer(config: ServerConfig): ServerResult {
|
|
|
164
161
|
}
|
|
165
162
|
}
|
|
166
163
|
|
|
167
|
-
|
|
168
|
-
throw new Error('Failed to start server');
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return { server, port: actualPort };
|
|
164
|
+
throw lastError || new Error('Failed to start server');
|
|
172
165
|
}
|
|
173
166
|
|
|
174
167
|
/**
|
|
@@ -16,6 +16,7 @@ export interface FileWatchCallbacks {
|
|
|
16
16
|
onVariablesChange?: () => Promise<void>;
|
|
17
17
|
onEnumsChange?: () => Promise<void>;
|
|
18
18
|
onCMSChange?: (collection: string) => Promise<void>;
|
|
19
|
+
onImageAdded?: (filename: string) => Promise<void>;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export class FileWatcher {
|
|
@@ -26,7 +27,8 @@ export class FileWatcher {
|
|
|
26
27
|
private variablesWatcher: FSWatcher | null = null;
|
|
27
28
|
private enumsWatcher: FSWatcher | null = null;
|
|
28
29
|
private cmsWatcher: FSWatcher | null = null;
|
|
29
|
-
|
|
30
|
+
private imagesWatcher: FSWatcher | null = null;
|
|
31
|
+
|
|
30
32
|
constructor(private callbacks: FileWatchCallbacks) {}
|
|
31
33
|
|
|
32
34
|
/**
|
|
@@ -183,6 +185,25 @@ export class FileWatcher {
|
|
|
183
185
|
);
|
|
184
186
|
}
|
|
185
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Start watching images directory for new source images
|
|
190
|
+
*/
|
|
191
|
+
watchImages(dirPath: string = projectPaths.images()): void {
|
|
192
|
+
if (!existsSync(dirPath)) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.imagesWatcher = watch(
|
|
197
|
+
dirPath,
|
|
198
|
+
{ recursive: false },
|
|
199
|
+
async (event, filename) => {
|
|
200
|
+
if (event === 'rename' && filename && this.callbacks.onImageAdded) {
|
|
201
|
+
await this.callbacks.onImageAdded(filename);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
186
207
|
/**
|
|
187
208
|
* Start watching all directories
|
|
188
209
|
*/
|
|
@@ -194,6 +215,7 @@ export class FileWatcher {
|
|
|
194
215
|
this.watchVariables();
|
|
195
216
|
this.watchEnums();
|
|
196
217
|
this.watchCMS();
|
|
218
|
+
this.watchImages();
|
|
197
219
|
}
|
|
198
220
|
|
|
199
221
|
/**
|
|
@@ -234,13 +256,18 @@ export class FileWatcher {
|
|
|
234
256
|
this.cmsWatcher.close();
|
|
235
257
|
this.cmsWatcher = null;
|
|
236
258
|
}
|
|
259
|
+
|
|
260
|
+
if (this.imagesWatcher) {
|
|
261
|
+
this.imagesWatcher.close();
|
|
262
|
+
this.imagesWatcher = null;
|
|
263
|
+
}
|
|
237
264
|
}
|
|
238
265
|
|
|
239
266
|
/**
|
|
240
267
|
* Check if watchers are active
|
|
241
268
|
*/
|
|
242
269
|
isWatching(): boolean {
|
|
243
|
-
return this.componentsWatcher !== null || this.pagesWatcher !== null || this.cmsWatcher !== null;
|
|
270
|
+
return this.componentsWatcher !== null || this.pagesWatcher !== null || this.cmsWatcher !== null || this.imagesWatcher !== null;
|
|
244
271
|
}
|
|
245
272
|
}
|
|
246
273
|
|