@olonjs/cli 3.0.153 → 3.0.154

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.
@@ -1817,9 +1817,9 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
1817
1817
  "@tiptap/extension-link": "^2.11.5",
1818
1818
  "@tiptap/react": "^2.11.5",
1819
1819
  "@tiptap/starter-kit": "^2.11.5",
1820
- "@olonjs/core": "^1.1.20",
1821
- "@olonjs/react": "^0.1.3",
1822
- "@olonjs/studio": "^0.1.3",
1820
+ "@olonjs/core": "^1.1.21",
1821
+ "@olonjs/react": "^0.1.4",
1822
+ "@olonjs/studio": "^0.1.4",
1823
1823
  "class-variance-authority": "^0.7.1",
1824
1824
  "clsx": "^2.1.1",
1825
1825
  "lucide-react": "^0.474.0",
@@ -2641,6 +2641,371 @@ if (fs.existsSync(sourceSiteConfigPath)) {
2641
2641
 
2642
2642
  console.log('[sync-pages-to-public] Synced pages, collections, and site config to public/');
2643
2643
 
2644
+ END_OF_FILE_CONTENT
2645
+ mkdir -p "scripts/vite"
2646
+ echo "Creating scripts/vite/tenantDevApiPlugin.ts..."
2647
+ cat << 'END_OF_FILE_CONTENT' > "scripts/vite/tenantDevApiPlugin.ts"
2648
+ /**
2649
+ * Dev-only Vite middleware: local CMS APIs + WebMCP / page JSON parity.
2650
+ * Not used in production builds (configureServer only).
2651
+ */
2652
+ import type { Plugin, ViteDevServer } from 'vite';
2653
+ import path from 'path';
2654
+ import fs from 'fs';
2655
+ import type { IncomingMessage, ServerResponse } from 'http';
2656
+
2657
+ const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.avif']);
2658
+ const IMAGE_MIMES = new Set([
2659
+ 'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml', 'image/avif',
2660
+ ]);
2661
+ const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
2662
+
2663
+ export type TenantDevApiPluginOptions = {
2664
+ rootDir: string;
2665
+ };
2666
+
2667
+ function safeFilename(original: string, mimeType: string | undefined) {
2668
+ const base = (original.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9-_]/g, '_').slice(0, 128)) || 'image';
2669
+ const ext = original.includes('.')
2670
+ ? path.extname(original).toLowerCase()
2671
+ : (mimeType?.startsWith('image/')
2672
+ ? `.${(mimeType.split('/')[1] || 'png').replace('jpeg', 'jpg')}`
2673
+ : '.png');
2674
+ return `${Date.now()}-${base}${IMAGE_EXT.has(ext) ? ext : '.png'}`;
2675
+ }
2676
+
2677
+ function listImagesInDir(dir: string, urlPrefix: string) {
2678
+ const list: Array<{ id: string; url: string; alt: string; tags: string[] }> = [];
2679
+ if (!fs.existsSync(dir)) return list;
2680
+ for (const name of fs.readdirSync(dir)) {
2681
+ if (IMAGE_EXT.has(path.extname(name).toLowerCase())) {
2682
+ list.push({ id: name, url: `${urlPrefix}/${name}`, alt: name, tags: [] });
2683
+ }
2684
+ }
2685
+ return list;
2686
+ }
2687
+
2688
+ function sendJson(res: ServerResponse, status: number, body: unknown) {
2689
+ res.writeHead(status, { 'Content-Type': 'application/json' });
2690
+ res.end(JSON.stringify(body));
2691
+ }
2692
+
2693
+ function safeDataSlugPath(rootDir: string, rawSlug: string, fallback: string) {
2694
+ const slug = String(rawSlug || fallback)
2695
+ .replace(/\\/g, '/')
2696
+ .replace(/^\/+|\/+$/g, '');
2697
+ const segments = slug
2698
+ .split('/')
2699
+ .map((segment) => segment.replace(/[^a-zA-Z0-9_[\]-]/g, '_'))
2700
+ .filter(Boolean);
2701
+ const candidate = path.resolve(rootDir, `${segments.join(path.sep) || fallback}.json`);
2702
+ const isInsideRoot = candidate.startsWith(`${rootDir}${path.sep}`) || candidate === rootDir;
2703
+ if (!isInsideRoot) throw new Error('Invalid data path');
2704
+ return candidate;
2705
+ }
2706
+
2707
+ function isTenantPageJsonRequest(req: IncomingMessage, pathname: string) {
2708
+ if (req.method !== 'GET' || !pathname.endsWith('.json')) return false;
2709
+ const viteOrStaticPrefixes = ['/api/', '/assets/', '/src/', '/node_modules/', '/public/', '/@'];
2710
+ return !viteOrStaticPrefixes.some((prefix) => pathname.startsWith(prefix));
2711
+ }
2712
+
2713
+ function normalizeManifestSlug(raw: string) {
2714
+ return decodeURIComponent(raw || '')
2715
+ .replace(/^\/+|\/+$/g, '')
2716
+ .replace(/\\/g, '/')
2717
+ .replace(/(\.schema)?\.json$/i, '');
2718
+ }
2719
+
2720
+ function applyDevSliceFilters(
2721
+ page: { sections?: Array<{ data?: Record<string, unknown> }> },
2722
+ authored: { sections?: Array<{ data?: Record<string, unknown> }> } | undefined,
2723
+ params: Record<string, string>,
2724
+ ) {
2725
+ if (!authored?.sections || !page?.sections) return page;
2726
+ const at = (o: unknown, p: string) =>
2727
+ p.split('.').reduce<unknown>((a, k) => (a as Record<string, unknown> | undefined)?.[k], o);
2728
+ return {
2729
+ ...page,
2730
+ sections: page.sections.map((section, i) => {
2731
+ const src = authored.sections?.[i]?.data as Record<string, { $sliceFilter?: Record<string, unknown> }> | undefined;
2732
+ if (!src || !section.data) return section;
2733
+ const data = { ...section.data };
2734
+ for (const [key, ref] of Object.entries(src)) {
2735
+ if (!ref?.$sliceFilter || typeof data[key] !== 'object' || data[key] === null) continue;
2736
+ const filter = Object.fromEntries(
2737
+ Object.entries(ref.$sliceFilter)
2738
+ .map(([k, v]) => [
2739
+ k,
2740
+ typeof v === 'string' ? v : params[(v as { $routeParam?: string })?.$routeParam ?? ''] ?? '',
2741
+ ])
2742
+ .filter(([, v]) => v),
2743
+ );
2744
+ data[key] = Object.fromEntries(
2745
+ Object.entries(data[key] as Record<string, unknown>).filter(([, item]) =>
2746
+ Object.entries(filter).every(([p, v]) => String(at(item, p) ?? '') === v),
2747
+ ),
2748
+ );
2749
+ }
2750
+ return { ...section, data };
2751
+ }),
2752
+ };
2753
+ }
2754
+
2755
+ async function loadWebMcpBuilders() {
2756
+ const moduleUrl = import.meta.resolve('@olonjs/core');
2757
+ return import(moduleUrl);
2758
+ }
2759
+
2760
+ export function tenantDevApiPlugin(options: TenantDevApiPluginOptions): Plugin {
2761
+ const { rootDir } = options;
2762
+ const ASSETS_IMAGES_DIR = path.resolve(rootDir, 'public', 'assets', 'images');
2763
+ const DATA_CONFIG_DIR = path.resolve(rootDir, 'src', 'data', 'config');
2764
+ const DATA_PAGES_DIR = path.resolve(rootDir, 'src', 'data', 'pages');
2765
+ const DATA_COLLECTIONS_DIR = path.resolve(rootDir, 'src', 'data', 'collections');
2766
+
2767
+ return {
2768
+ name: 'upload-asset-api',
2769
+ configureServer(server: ViteDevServer) {
2770
+ server.middlewares.use((req, res, next) => {
2771
+ const pathname = (req.url || '').split('?')[0];
2772
+ const isPageJsonRequest = isTenantPageJsonRequest(req, pathname);
2773
+
2774
+ const handleManifestRequest = async () => {
2775
+ const core = await loadWebMcpBuilders();
2776
+ const { buildPageContract, buildPageManifest, buildSiteManifest, buildLlmsTxt } = core.webmcp;
2777
+ const runtime = await server.ssrLoadModule('/src/runtime.ts');
2778
+ const buildState = runtime.getWebMcpBuildState();
2779
+
2780
+ if (req.method === 'GET' && pathname === '/llms.txt') {
2781
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
2782
+ res.end(buildLlmsTxt({
2783
+ pages: buildState.pages,
2784
+ schemas: buildState.schemas,
2785
+ siteConfig: buildState.siteConfig,
2786
+ }));
2787
+ return true;
2788
+ }
2789
+
2790
+ if (req.method === 'GET' && pathname === '/mcp-manifest.json') {
2791
+ sendJson(res, 200, buildSiteManifest({
2792
+ pages: buildState.pages,
2793
+ schemas: buildState.schemas,
2794
+ submissionSchemas: buildState.submissionSchemas,
2795
+ siteConfig: buildState.siteConfig,
2796
+ }));
2797
+ return true;
2798
+ }
2799
+
2800
+ const pageManifestMatch = pathname.match(/^\/mcp-manifests\/(.+)\.json$/i);
2801
+ if (pageManifestMatch && req.method === 'GET') {
2802
+ const slug = normalizeManifestSlug(pageManifestMatch[1]);
2803
+ const resolved = core.resolvePublicPageDocument({
2804
+ slug,
2805
+ pages: buildState.pages,
2806
+ siteConfig: buildState.siteConfig,
2807
+ themeConfig: buildState.themeConfig,
2808
+ menuConfig: buildState.menuConfig,
2809
+ collections: buildState.collections,
2810
+ collectionSchemas: buildState.collectionSchemas,
2811
+ refDocuments: buildState.refDocuments,
2812
+ });
2813
+ if (!resolved) {
2814
+ sendJson(res, 404, { error: 'Page manifest not found' });
2815
+ return true;
2816
+ }
2817
+
2818
+ sendJson(res, 200, buildPageManifest({
2819
+ slug,
2820
+ pageConfig: resolved.page,
2821
+ schemas: buildState.schemas,
2822
+ submissionSchemas: buildState.submissionSchemas,
2823
+ siteConfig: buildState.siteConfig,
2824
+ }));
2825
+ return true;
2826
+ }
2827
+
2828
+ const schemaMatch = pathname.match(/^\/schemas\/(.+)\.schema\.json$/i);
2829
+ if (schemaMatch && req.method === 'GET') {
2830
+ const slug = normalizeManifestSlug(schemaMatch[1]);
2831
+ const resolved = core.resolvePublicPageDocument({
2832
+ slug,
2833
+ pages: buildState.pages,
2834
+ siteConfig: buildState.siteConfig,
2835
+ themeConfig: buildState.themeConfig,
2836
+ menuConfig: buildState.menuConfig,
2837
+ collections: buildState.collections,
2838
+ collectionSchemas: buildState.collectionSchemas,
2839
+ refDocuments: buildState.refDocuments,
2840
+ });
2841
+ if (!resolved) {
2842
+ sendJson(res, 404, { error: 'Schema contract not found' });
2843
+ return true;
2844
+ }
2845
+
2846
+ sendJson(res, 200, buildPageContract({
2847
+ slug,
2848
+ pageConfig: resolved.page,
2849
+ schemas: buildState.schemas,
2850
+ submissionSchemas: buildState.submissionSchemas,
2851
+ siteConfig: buildState.siteConfig,
2852
+ }));
2853
+ return true;
2854
+ }
2855
+ return false;
2856
+ };
2857
+
2858
+ if (
2859
+ req.method === 'GET' &&
2860
+ (
2861
+ pathname === '/mcp-manifest.json'
2862
+ || pathname === '/llms.txt'
2863
+ || /^\/mcp-manifests\/.+\.json$/i.test(pathname)
2864
+ || /^\/schemas\/.+\.schema\.json$/i.test(pathname)
2865
+ )
2866
+ ) {
2867
+ void handleManifestRequest()
2868
+ .then((handled) => {
2869
+ if (!handled) {
2870
+ // Avoid Vite SPA fallback to index.html for known manifest URLs.
2871
+ sendJson(res, 404, { error: 'Manifest or schema not found' });
2872
+ }
2873
+ })
2874
+ .catch((error) => {
2875
+ sendJson(res, 500, { error: error?.message || 'Manifest generation failed' });
2876
+ });
2877
+ return;
2878
+ }
2879
+
2880
+ if (isPageJsonRequest) {
2881
+ const normalizedPath = decodeURIComponent(pathname).replace(/\\/g, '/');
2882
+ // Optional "/pages/" prefix mirrors production public paths.
2883
+ const slug = normalizedPath
2884
+ .replace(/^\/+/, '')
2885
+ .replace(/^pages\//i, '')
2886
+ .replace(/\.json$/i, '')
2887
+ .replace(/^\/+|\/+$/g, '');
2888
+ if (!slug) {
2889
+ sendJson(res, 404, { error: 'Page JSON not found' });
2890
+ return;
2891
+ }
2892
+ void (async () => {
2893
+ const core = await loadWebMcpBuilders();
2894
+ const runtime = await server.ssrLoadModule('/src/runtime.ts');
2895
+ const buildState = runtime.getWebMcpBuildState();
2896
+ const resolved = core.resolvePublicPageDocument({
2897
+ slug,
2898
+ pages: buildState.pages,
2899
+ siteConfig: buildState.siteConfig,
2900
+ themeConfig: buildState.themeConfig,
2901
+ menuConfig: buildState.menuConfig,
2902
+ collections: buildState.collections,
2903
+ collectionSchemas: buildState.collectionSchemas,
2904
+ refDocuments: buildState.refDocuments,
2905
+ });
2906
+ if (!resolved) {
2907
+ sendJson(res, 404, { error: 'Page JSON not found' });
2908
+ return;
2909
+ }
2910
+ const authored = buildState.pages[resolved.pageMatch.registrySlug];
2911
+ sendJson(res, 200, applyDevSliceFilters(resolved.page, authored, resolved.pageMatch.params ?? {}));
2912
+ })().catch((error) => {
2913
+ sendJson(res, 500, { error: error?.message || 'Page JSON resolution failed' });
2914
+ });
2915
+ return;
2916
+ }
2917
+ if (req.method === 'GET' && req.url === '/api/list-assets') {
2918
+ try {
2919
+ sendJson(res, 200, listImagesInDir(ASSETS_IMAGES_DIR, '/assets/images'));
2920
+ } catch (e) {
2921
+ sendJson(res, 500, { error: (e as Error)?.message || 'List failed' });
2922
+ }
2923
+ return;
2924
+ }
2925
+ if (req.method === 'POST' && pathname === '/api/save-to-file') {
2926
+ const chunks: Buffer[] = [];
2927
+ req.on('data', (chunk) => chunks.push(chunk));
2928
+ req.on('end', () => {
2929
+ try {
2930
+ const raw = Buffer.concat(chunks).toString('utf8');
2931
+ if (!raw.trim()) {
2932
+ sendJson(res, 400, { error: 'Empty body' });
2933
+ return;
2934
+ }
2935
+ const body = JSON.parse(raw);
2936
+ const { projectState, slug } = body;
2937
+ if (!projectState || typeof slug !== 'string') {
2938
+ sendJson(res, 400, { error: 'Missing projectState or slug' });
2939
+ return;
2940
+ }
2941
+ if (!fs.existsSync(DATA_CONFIG_DIR)) fs.mkdirSync(DATA_CONFIG_DIR, { recursive: true });
2942
+ if (!fs.existsSync(DATA_PAGES_DIR)) fs.mkdirSync(DATA_PAGES_DIR, { recursive: true });
2943
+ if (!fs.existsSync(DATA_COLLECTIONS_DIR)) fs.mkdirSync(DATA_COLLECTIONS_DIR, { recursive: true });
2944
+ if (projectState.site != null) {
2945
+ fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'site.json'), JSON.stringify(projectState.site, null, 2), 'utf8');
2946
+ }
2947
+ if (projectState.menu != null) {
2948
+ fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'menu.json'), JSON.stringify(projectState.menu, null, 2), 'utf8');
2949
+ }
2950
+ if (projectState.theme != null) {
2951
+ fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'theme.json'), JSON.stringify(projectState.theme, null, 2), 'utf8');
2952
+ }
2953
+ if (projectState.page != null) {
2954
+ const pagePath = safeDataSlugPath(DATA_PAGES_DIR, slug, 'page');
2955
+ fs.mkdirSync(path.dirname(pagePath), { recursive: true });
2956
+ fs.writeFileSync(pagePath, JSON.stringify(projectState.page, null, 2), 'utf8');
2957
+ }
2958
+ if (projectState.collections && typeof projectState.collections === 'object') {
2959
+ for (const [source, collection] of Object.entries(projectState.collections)) {
2960
+ const sourceSlug = String(source).replace(/[^a-zA-Z0-9-_]/g, '_');
2961
+ if (!sourceSlug) continue;
2962
+ const collectionDir = path.join(DATA_COLLECTIONS_DIR, sourceSlug);
2963
+ fs.mkdirSync(collectionDir, { recursive: true });
2964
+ fs.writeFileSync(path.join(collectionDir, `${sourceSlug}.json`), JSON.stringify(collection, null, 2), 'utf8');
2965
+ }
2966
+ }
2967
+ sendJson(res, 200, { ok: true });
2968
+ } catch (e) {
2969
+ sendJson(res, 500, { error: (e as Error)?.message || 'Save to file failed' });
2970
+ }
2971
+ });
2972
+ req.on('error', () => sendJson(res, 500, { error: 'Request error' }));
2973
+ return;
2974
+ }
2975
+ if (req.method !== 'POST' || pathname !== '/api/upload-asset') return next();
2976
+ const chunks: Buffer[] = [];
2977
+ req.on('data', (chunk) => chunks.push(chunk));
2978
+ req.on('end', () => {
2979
+ try {
2980
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
2981
+ const { filename, mimeType, data } = body;
2982
+ if (!filename || typeof data !== 'string') {
2983
+ sendJson(res, 400, { error: 'Missing filename or data' });
2984
+ return;
2985
+ }
2986
+ const buf = Buffer.from(data, 'base64');
2987
+ if (buf.length > MAX_FILE_SIZE_BYTES) {
2988
+ sendJson(res, 413, { error: 'File too large. Max 5MB.' });
2989
+ return;
2990
+ }
2991
+ if (mimeType && !IMAGE_MIMES.has(mimeType)) {
2992
+ sendJson(res, 400, { error: 'Invalid file type' });
2993
+ return;
2994
+ }
2995
+ const name = safeFilename(filename, mimeType);
2996
+ if (!fs.existsSync(ASSETS_IMAGES_DIR)) fs.mkdirSync(ASSETS_IMAGES_DIR, { recursive: true });
2997
+ fs.writeFileSync(path.join(ASSETS_IMAGES_DIR, name), buf);
2998
+ sendJson(res, 200, { url: `/assets/images/${name}` });
2999
+ } catch (e) {
3000
+ sendJson(res, 500, { error: (e as Error)?.message || 'Upload failed' });
3001
+ }
3002
+ });
3003
+ req.on('error', () => sendJson(res, 500, { error: 'Request error' }));
3004
+ });
3005
+ },
3006
+ };
3007
+ }
3008
+
2644
3009
  END_OF_FILE_CONTENT
2645
3010
  echo "Creating scripts/webmcp-feature-check.mjs..."
2646
3011
  cat << 'END_OF_FILE_CONTENT' > "scripts/webmcp-feature-check.mjs"
@@ -2951,33 +3316,45 @@ cat << 'END_OF_FILE_CONTENT' > "src/App.tsx"
2951
3316
  * Thin Entry Point (Tenant).
2952
3317
  * Bootstrap, persistence, and engine wiring — logic lives in lib/hooks.
2953
3318
  */
2954
- import { useEffect, useMemo } from 'react';
2955
- import { JsonPagesEngine, OlonFormsContext, createHotSaveHandler } from '@olonjs/react';
3319
+ import { lazy, Suspense, useEffect, useMemo } from 'react';
2956
3320
  import type { JsonPagesConfig, ProjectState } from '@olonjs/core';
2957
3321
  import { withBasePath } from '@olonjs/core';
2958
- import { ComponentRegistry } from '@/lib/ComponentRegistry';
3322
+ import { JsonPagesEngine, OlonFormsContext, createHotSaveHandler } from '@olonjs/react';
3323
+
3324
+ import type { MenuConfig, SiteConfig, ThemeConfig } from '@/types';
3325
+
3326
+ // Protocol (tenant-authored)
3327
+ import { addSectionConfig } from '@/lib/addSectionConfig';
2959
3328
  import { CollectionRegistry } from '@/lib/CollectionRegistry';
3329
+ import { ComponentRegistry } from '@/lib/ComponentRegistry';
3330
+ import { iconMap } from '@/lib/IconResolver';
2960
3331
  import { SECTION_SCHEMAS } from '@/lib/schemas';
2961
- import { addSectionConfig } from '@/lib/addSectionConfig';
2962
- import type { MenuConfig, SiteConfig, ThemeConfig } from '@/types';
3332
+
3333
+ // Data seeds + local loaders
2963
3334
  import siteData from '@/data/config/site.json';
2964
3335
  import themeData from '@/data/config/theme.json';
2965
3336
  import menuData from '@/data/config/menu.json';
2966
3337
  import { getFileCollections } from '@/lib/loaders/getFileCollections';
2967
3338
  import { getFilePages } from '@/lib/loaders/getFilePages';
2968
- import { DopaDrawer } from '@/components/save-drawer/DopaDrawer';
3339
+
3340
+ // Shell UI
2969
3341
  import { EmptyTenantView } from '@/components/empty-tenant';
2970
3342
  import { TenantBootstrapChrome } from '@/components/TenantBootstrapChrome';
2971
3343
  import { ThemeProvider } from '@/components/ThemeProvider';
2972
- import { useOlonForms } from '@/lib/forms/useOlonForms';
2973
- import { iconMap } from '@/lib/IconResolver';
3344
+
3345
+ // Harness
2974
3346
  import { uploadTenantAsset } from '@/lib/assets/assetUpload';
3347
+ import { useAssetsManifest } from '@/lib/assets/useAssetsManifest';
3348
+ import { useTenantBootstrap } from '@/lib/bootstrap/useTenantBootstrap';
2975
3349
  import {
2976
3350
  cloudFingerprintFromUrl,
2977
3351
  normalizeSlugForCache,
2978
3352
  readCachedCloudContent,
2979
3353
  writeCachedCloudContent,
2980
3354
  } from '@/lib/cloud/cloudCache';
3355
+ import { hydrateLocalProjectState } from '@/lib/cloud/hydrateLocalProjectState';
3356
+ import { useAdminStudioContent } from '@/lib/cloud/useAdminStudioContent';
3357
+ import { useCloudSave } from '@/lib/cloud/useCloudSave';
2981
3358
  import {
2982
3359
  extractLeadingRemoteCssImports,
2983
3360
  setTenantPreviewReady,
@@ -2986,14 +3363,15 @@ import {
2986
3363
  warnIfThemeFontsMissing,
2987
3364
  } from '@/lib/css/tenantCss';
2988
3365
  import { APP_BASE_PATH, TENANT_ID, cloudPolicy } from '@/lib/env/tenantEnv';
2989
- import { useAssetsManifest } from '@/lib/assets/useAssetsManifest';
2990
- import { useCloudSave } from '@/lib/cloud/useCloudSave';
2991
- import { useTenantBootstrap } from '@/lib/bootstrap/useTenantBootstrap';
2992
- import { useAdminStudioContent } from '@/lib/cloud/useAdminStudioContent';
2993
- import { hydrateLocalProjectState } from '@/lib/cloud/hydrateLocalProjectState';
3366
+ import { useOlonForms } from '@/lib/forms/useOlonForms';
2994
3367
 
2995
3368
  import tenantCss from './index.css?inline';
2996
3369
 
3370
+ /** Cold-save UI — admin/Save2Repo only; keep out of the visitor main chunk. */
3371
+ const DopaDrawer = lazy(() =>
3372
+ import('@/components/save-drawer/DopaDrawer').then((m) => ({ default: m.DopaDrawer })),
3373
+ );
3374
+
2997
3375
  const themeConfigSeed = themeData as unknown as ThemeConfig;
2998
3376
  const menuConfigSeed = menuData as unknown as MenuConfig;
2999
3377
  const fileSiteConfig = siteData as unknown as SiteConfig;
@@ -3019,6 +3397,7 @@ function App() {
3019
3397
  });
3020
3398
  const { assetsManifest, loadAssetsManifest, cloudApiCandidates } = useAssetsManifest(cloudPolicy.isCloudMode);
3021
3399
  const { cloudSaveUi, runCloudSave, closeCloudDrawer, retryCloudSave } = useCloudSave();
3400
+ const mountDopaDrawer = cloudSaveUi.isOpen || cloudSaveUi.phase !== 'idle';
3022
3401
 
3023
3402
  const hotSave = useMemo(
3024
3403
  () =>
@@ -3174,17 +3553,21 @@ function App() {
3174
3553
  <JsonPagesEngine config={config} />
3175
3554
  )
3176
3555
  ) : null}
3177
- <DopaDrawer
3178
- isOpen={cloudSaveUi.isOpen}
3179
- phase={cloudSaveUi.phase}
3180
- currentStepId={cloudSaveUi.currentStepId}
3181
- doneSteps={cloudSaveUi.doneSteps}
3182
- progress={cloudSaveUi.progress}
3183
- errorMessage={cloudSaveUi.errorMessage}
3184
- deployUrl={cloudSaveUi.deployUrl}
3185
- onClose={closeCloudDrawer}
3186
- onRetry={retryCloudSave}
3187
- />
3556
+ {mountDopaDrawer ? (
3557
+ <Suspense fallback={null}>
3558
+ <DopaDrawer
3559
+ isOpen={cloudSaveUi.isOpen}
3560
+ phase={cloudSaveUi.phase}
3561
+ currentStepId={cloudSaveUi.currentStepId}
3562
+ doneSteps={cloudSaveUi.doneSteps}
3563
+ progress={cloudSaveUi.progress}
3564
+ errorMessage={cloudSaveUi.errorMessage}
3565
+ deployUrl={cloudSaveUi.deployUrl}
3566
+ onClose={closeCloudDrawer}
3567
+ onRetry={retryCloudSave}
3568
+ />
3569
+ </Suspense>
3570
+ ) : null}
3188
3571
  </OlonFormsContext.Provider>
3189
3572
  </ThemeProvider>
3190
3573
  );
@@ -14291,7 +14674,7 @@ cat << 'END_OF_FILE_CONTENT' > "tsconfig.node.json"
14291
14674
  "allowSyntheticDefaultImports": true,
14292
14675
  "strict": true
14293
14676
  },
14294
- "include": ["vite.config.ts"]
14677
+ "include": ["vite.config.ts", "scripts/vite/**/*.ts"]
14295
14678
  }
14296
14679
 
14297
14680
 
@@ -14323,331 +14706,23 @@ END_OF_FILE_CONTENT
14323
14706
  echo "Creating vite.config.ts..."
14324
14707
  cat << 'END_OF_FILE_CONTENT' > "vite.config.ts"
14325
14708
  /**
14326
- * Generated by @jsonpages/cli. Dev server API: /api/save-to-file, /api/upload-asset, /api/list-assets.
14709
+ * Generated by @olonjs/cli. Dev APIs live in scripts/vite/tenantDevApiPlugin.ts
14710
+ * (/api/save-to-file, /api/upload-asset, /api/list-assets, WebMCP manifests).
14327
14711
  */
14328
14712
  import { defineConfig } from 'vite';
14329
14713
  import react from '@vitejs/plugin-react';
14330
14714
  import tailwindcss from '@tailwindcss/vite';
14331
14715
  import path from 'path';
14332
- import fs from 'fs';
14333
14716
  import { fileURLToPath } from 'url';
14717
+ import { tenantDevApiPlugin } from './scripts/vite/tenantDevApiPlugin';
14334
14718
 
14335
14719
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14336
14720
 
14337
- const ASSETS_IMAGES_DIR = path.resolve(__dirname, 'public', 'assets', 'images');
14338
- const DATA_CONFIG_DIR = path.resolve(__dirname, 'src', 'data', 'config');
14339
- const DATA_PAGES_DIR = path.resolve(__dirname, 'src', 'data', 'pages');
14340
- const DATA_COLLECTIONS_DIR = path.resolve(__dirname, 'src', 'data', 'collections');
14341
- const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.avif']);
14342
- const IMAGE_MIMES = new Set([
14343
- 'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml', 'image/avif',
14344
- ]);
14345
- const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
14346
-
14347
- function safeFilename(original, mimeType) {
14348
- const base = (original.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9-_]/g, '_').slice(0, 128)) || 'image';
14349
- const ext = original.includes('.') ? path.extname(original).toLowerCase() : (mimeType?.startsWith('image/') ? `.${(mimeType.split('/')[1] || 'png').replace('jpeg', 'jpg')}` : '.png');
14350
- return `${Date.now()}-${base}${IMAGE_EXT.has(ext) ? ext : '.png'}`;
14351
- }
14352
-
14353
- function listImagesInDir(dir, urlPrefix) {
14354
- const list = [];
14355
- if (!fs.existsSync(dir)) return list;
14356
- for (const name of fs.readdirSync(dir)) {
14357
- if (IMAGE_EXT.has(path.extname(name).toLowerCase())) list.push({ id: name, url: `${urlPrefix}/${name}`, alt: name, tags: [] });
14358
- }
14359
- return list;
14360
- }
14361
-
14362
- function sendJson(res, status, body) {
14363
- res.writeHead(status, { 'Content-Type': 'application/json' });
14364
- res.end(JSON.stringify(body));
14365
- }
14366
-
14367
- function sendJsonFile(res, filePath) {
14368
- try {
14369
- const content = fs.readFileSync(filePath, 'utf8');
14370
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
14371
- res.end(content);
14372
- } catch (e) {
14373
- sendJson(res, 500, { error: e?.message || 'Read failed' });
14374
- }
14375
- }
14376
-
14377
- function safeDataSlugPath(rootDir, rawSlug, fallback) {
14378
- const slug = String(rawSlug || fallback)
14379
- .replace(/\\/g, '/')
14380
- .replace(/^\/+|\/+$/g, '');
14381
- const segments = slug
14382
- .split('/')
14383
- .map((segment) => segment.replace(/[^a-zA-Z0-9_[\]-]/g, '_'))
14384
- .filter(Boolean);
14385
- const candidate = path.resolve(rootDir, `${segments.join(path.sep) || fallback}.json`);
14386
- const isInsideRoot = candidate.startsWith(`${rootDir}${path.sep}`) || candidate === rootDir;
14387
- if (!isInsideRoot) throw new Error('Invalid data path');
14388
- return candidate;
14389
- }
14390
-
14391
- function isTenantPageJsonRequest(req, pathname) {
14392
- if (req.method !== 'GET' || !pathname.endsWith('.json')) return false;
14393
- const viteOrStaticPrefixes = ['/api/', '/assets/', '/src/', '/node_modules/', '/public/', '/@'];
14394
- return !viteOrStaticPrefixes.some((prefix) => pathname.startsWith(prefix));
14395
- }
14396
-
14397
- function normalizeManifestSlug(raw) {
14398
- return decodeURIComponent(raw || '')
14399
- .replace(/^\/+|\/+$/g, '')
14400
- .replace(/\\/g, '/')
14401
- .replace(/(\.schema)?\.json$/i, '');
14402
- }
14403
-
14404
- function applyDevSliceFilters(page, authored, params) {
14405
- if (!authored?.sections || !page?.sections) return page;
14406
- const at = (o, p) => p.split('.').reduce((a, k) => a?.[k], o);
14407
- return {
14408
- ...page,
14409
- sections: page.sections.map((section, i) => {
14410
- const src = authored.sections[i]?.data;
14411
- if (!src || !section.data) return section;
14412
- const data = { ...section.data };
14413
- for (const [key, ref] of Object.entries(src)) {
14414
- if (!ref?.$sliceFilter || typeof data[key] !== 'object') continue;
14415
- const filter = Object.fromEntries(
14416
- Object.entries(ref.$sliceFilter)
14417
- .map(([k, v]) => [k, typeof v === 'string' ? v : params[v?.$routeParam] ?? ''])
14418
- .filter(([, v]) => v),
14419
- );
14420
- data[key] = Object.fromEntries(
14421
- Object.entries(data[key]).filter(([, item]) =>
14422
- Object.entries(filter).every(([p, v]) => String(at(item, p) ?? '') === v),
14423
- ),
14424
- );
14425
- }
14426
- return { ...section, data };
14427
- }),
14428
- };
14429
- }
14430
-
14431
- async function loadWebMcpBuilders() {
14432
- const moduleUrl = import.meta.resolve('@olonjs/core');
14433
- return import(moduleUrl);
14434
- }
14435
14721
  export default defineConfig({
14436
14722
  plugins: [
14437
14723
  react(),
14438
14724
  tailwindcss(),
14439
- {
14440
- name: 'upload-asset-api',
14441
- configureServer(server) {
14442
- server.middlewares.use((req, res, next) => {
14443
- const pathname = (req.url || '').split('?')[0];
14444
- const isPageJsonRequest = isTenantPageJsonRequest(req, pathname);
14445
-
14446
- const handleManifestRequest = async () => {
14447
- const core = await loadWebMcpBuilders();
14448
- const { buildPageContract, buildPageManifest, buildSiteManifest, buildLlmsTxt } = core.webmcp;
14449
- const runtime = await server.ssrLoadModule('/src/runtime.ts');
14450
- const buildState = runtime.getWebMcpBuildState();
14451
-
14452
- if (req.method === 'GET' && pathname === '/llms.txt') {
14453
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
14454
- res.end(buildLlmsTxt({
14455
- pages: buildState.pages,
14456
- schemas: buildState.schemas,
14457
- siteConfig: buildState.siteConfig,
14458
- }));
14459
- return true;
14460
- }
14461
-
14462
- if (req.method === 'GET' && pathname === '/mcp-manifest.json') {
14463
- sendJson(res, 200, buildSiteManifest({
14464
- pages: buildState.pages,
14465
- schemas: buildState.schemas,
14466
- submissionSchemas: buildState.submissionSchemas,
14467
- siteConfig: buildState.siteConfig,
14468
- }));
14469
- return true;
14470
- }
14471
-
14472
- const pageManifestMatch = pathname.match(/^\/mcp-manifests\/(.+)\.json$/i);
14473
- if (pageManifestMatch && req.method === 'GET') {
14474
- const slug = normalizeManifestSlug(pageManifestMatch[1]);
14475
- const resolved = core.resolvePublicPageDocument({
14476
- slug,
14477
- pages: buildState.pages,
14478
- siteConfig: buildState.siteConfig,
14479
- themeConfig: buildState.themeConfig,
14480
- menuConfig: buildState.menuConfig,
14481
- collections: buildState.collections,
14482
- collectionSchemas: buildState.collectionSchemas,
14483
- refDocuments: buildState.refDocuments,
14484
- });
14485
- if (!resolved) {
14486
- sendJson(res, 404, { error: 'Page manifest not found' });
14487
- return true;
14488
- }
14489
-
14490
- sendJson(res, 200, buildPageManifest({
14491
- slug,
14492
- pageConfig: resolved.page,
14493
- schemas: buildState.schemas,
14494
- submissionSchemas: buildState.submissionSchemas,
14495
- siteConfig: buildState.siteConfig,
14496
- }));
14497
- return true;
14498
- }
14499
-
14500
- const schemaMatch = pathname.match(/^\/schemas\/(.+)\.schema\.json$/i);
14501
- if (schemaMatch && req.method === 'GET') {
14502
- const slug = normalizeManifestSlug(schemaMatch[1]);
14503
- const resolved = core.resolvePublicPageDocument({
14504
- slug,
14505
- pages: buildState.pages,
14506
- siteConfig: buildState.siteConfig,
14507
- themeConfig: buildState.themeConfig,
14508
- menuConfig: buildState.menuConfig,
14509
- collections: buildState.collections,
14510
- collectionSchemas: buildState.collectionSchemas,
14511
- refDocuments: buildState.refDocuments,
14512
- });
14513
- if (!resolved) {
14514
- sendJson(res, 404, { error: 'Schema contract not found' });
14515
- return true;
14516
- }
14517
-
14518
- sendJson(res, 200, buildPageContract({
14519
- slug,
14520
- pageConfig: resolved.page,
14521
- schemas: buildState.schemas,
14522
- submissionSchemas: buildState.submissionSchemas,
14523
- siteConfig: buildState.siteConfig,
14524
- }));
14525
- return true;
14526
- }
14527
- return false;
14528
- };
14529
-
14530
- if (
14531
- req.method === 'GET' &&
14532
- (
14533
- pathname === '/mcp-manifest.json'
14534
- || pathname === '/llms.txt'
14535
- || /^\/mcp-manifests\/.+\.json$/i.test(pathname)
14536
- || /^\/schemas\/.+\.schema\.json$/i.test(pathname)
14537
- )
14538
- ) {
14539
- void handleManifestRequest()
14540
- .then((handled) => {
14541
- if (!handled) {
14542
- // Se handleManifestRequest fallisce a trovare qualcosa ma l'URL era giusto,
14543
- // forziamo un 404 JSON per evitare che Vite serva l'index.html
14544
- sendJson(res, 404, { error: 'Manifest or schema not found' });
14545
- }
14546
- })
14547
- .catch((error) => {
14548
- sendJson(res, 500, { error: error?.message || 'Manifest generation failed' });
14549
- });
14550
- return;
14551
- }
14552
-
14553
- if (isPageJsonRequest) {
14554
- const normalizedPath = decodeURIComponent(pathname).replace(/\\/g, '/');
14555
- // Rimuoviamo la root folder opzionale "/pages/" introdotta per matchare la prod e il file extension
14556
- const slug = normalizedPath
14557
- .replace(/^\/+/, '')
14558
- .replace(/^pages\//i, '')
14559
- .replace(/\.json$/i, '')
14560
- .replace(/^\/+|\/+$/g, '');
14561
- if (!slug) {
14562
- sendJson(res, 404, { error: 'Page JSON not found' });
14563
- return;
14564
- }
14565
- void (async () => {
14566
- const core = await loadWebMcpBuilders();
14567
- const runtime = await server.ssrLoadModule('/src/runtime.ts');
14568
- const buildState = runtime.getWebMcpBuildState();
14569
- const resolved = core.resolvePublicPageDocument({
14570
- slug,
14571
- pages: buildState.pages,
14572
- siteConfig: buildState.siteConfig,
14573
- themeConfig: buildState.themeConfig,
14574
- menuConfig: buildState.menuConfig,
14575
- collections: buildState.collections,
14576
- collectionSchemas: buildState.collectionSchemas,
14577
- refDocuments: buildState.refDocuments,
14578
- });
14579
- if (!resolved) {
14580
- sendJson(res, 404, { error: 'Page JSON not found' });
14581
- return;
14582
- }
14583
- const authored = buildState.pages[resolved.pageMatch.registrySlug];
14584
- sendJson(res, 200, applyDevSliceFilters(resolved.page, authored, resolved.pageMatch.params ?? {}));
14585
- })().catch((error) => {
14586
- sendJson(res, 500, { error: error?.message || 'Page JSON resolution failed' });
14587
- });
14588
- return;
14589
- }
14590
- if (req.method === 'GET' && req.url === '/api/list-assets') {
14591
- try { sendJson(res, 200, listImagesInDir(ASSETS_IMAGES_DIR, '/assets/images')); } catch (e) { sendJson(res, 500, { error: e?.message || 'List failed' }); }
14592
- return;
14593
- }
14594
- if (req.method === 'POST' && pathname === '/api/save-to-file') {
14595
- const chunks = [];
14596
- req.on('data', (chunk) => chunks.push(chunk));
14597
- req.on('end', () => {
14598
- try {
14599
- const raw = Buffer.concat(chunks).toString('utf8');
14600
- if (!raw.trim()) { sendJson(res, 400, { error: 'Empty body' }); return; }
14601
- const body = JSON.parse(raw);
14602
- const { projectState, slug } = body;
14603
- if (!projectState || typeof slug !== 'string') { sendJson(res, 400, { error: 'Missing projectState or slug' }); return; }
14604
- if (!fs.existsSync(DATA_CONFIG_DIR)) fs.mkdirSync(DATA_CONFIG_DIR, { recursive: true });
14605
- if (!fs.existsSync(DATA_PAGES_DIR)) fs.mkdirSync(DATA_PAGES_DIR, { recursive: true });
14606
- if (!fs.existsSync(DATA_COLLECTIONS_DIR)) fs.mkdirSync(DATA_COLLECTIONS_DIR, { recursive: true });
14607
- if (projectState.site != null) fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'site.json'), JSON.stringify(projectState.site, null, 2), 'utf8');
14608
- if (projectState.menu != null) fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'menu.json'), JSON.stringify(projectState.menu, null, 2), 'utf8');
14609
- if (projectState.theme != null) fs.writeFileSync(path.join(DATA_CONFIG_DIR, 'theme.json'), JSON.stringify(projectState.theme, null, 2), 'utf8');
14610
- if (projectState.page != null) {
14611
- const pagePath = safeDataSlugPath(DATA_PAGES_DIR, slug, 'page');
14612
- fs.mkdirSync(path.dirname(pagePath), { recursive: true });
14613
- fs.writeFileSync(pagePath, JSON.stringify(projectState.page, null, 2), 'utf8');
14614
- }
14615
- if (projectState.collections && typeof projectState.collections === 'object') {
14616
- for (const [source, collection] of Object.entries(projectState.collections)) {
14617
- const sourceSlug = String(source).replace(/[^a-zA-Z0-9-_]/g, '_');
14618
- if (!sourceSlug) continue;
14619
- const collectionDir = path.join(DATA_COLLECTIONS_DIR, sourceSlug);
14620
- fs.mkdirSync(collectionDir, { recursive: true });
14621
- fs.writeFileSync(path.join(collectionDir, `${sourceSlug}.json`), JSON.stringify(collection, null, 2), 'utf8');
14622
- }
14623
- }
14624
- sendJson(res, 200, { ok: true });
14625
- } catch (e) { sendJson(res, 500, { error: e?.message || 'Save to file failed' }); }
14626
- });
14627
- req.on('error', () => sendJson(res, 500, { error: 'Request error' }));
14628
- return;
14629
- }
14630
- if (req.method !== 'POST' || pathname !== '/api/upload-asset') return next();
14631
- const chunks = [];
14632
- req.on('data', (chunk) => chunks.push(chunk));
14633
- req.on('end', () => {
14634
- try {
14635
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
14636
- const { filename, mimeType, data } = body;
14637
- if (!filename || typeof data !== 'string') { sendJson(res, 400, { error: 'Missing filename or data' }); return; }
14638
- const buf = Buffer.from(data, 'base64');
14639
- if (buf.length > MAX_FILE_SIZE_BYTES) { sendJson(res, 413, { error: 'File too large. Max 5MB.' }); return; }
14640
- if (mimeType && !IMAGE_MIMES.has(mimeType)) { sendJson(res, 400, { error: 'Invalid file type' }); return; }
14641
- const name = safeFilename(filename, mimeType);
14642
- if (!fs.existsSync(ASSETS_IMAGES_DIR)) fs.mkdirSync(ASSETS_IMAGES_DIR, { recursive: true });
14643
- fs.writeFileSync(path.join(ASSETS_IMAGES_DIR, name), buf);
14644
- sendJson(res, 200, { url: `/assets/images/${name}` });
14645
- } catch (e) { sendJson(res, 500, { error: e?.message || 'Upload failed' }); }
14646
- });
14647
- req.on('error', () => sendJson(res, 500, { error: 'Request error' }));
14648
- });
14649
- },
14650
- },
14725
+ tenantDevApiPlugin({ rootDir: __dirname }),
14651
14726
  ],
14652
14727
  resolve: {
14653
14728
  alias: {
@@ -14668,5 +14743,4 @@ export default defineConfig({
14668
14743
  },
14669
14744
  });
14670
14745
 
14671
-
14672
14746
  END_OF_FILE_CONTENT