@olonjs/cli 3.0.147 → 3.0.149

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,7 +1817,7 @@ 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.13",
1820
+ "@olonjs/core": "^1.1.16",
1821
1821
  "class-variance-authority": "^0.7.1",
1822
1822
  "clsx": "^2.1.1",
1823
1823
  "lucide-react": "^0.474.0",
@@ -2981,6 +2981,7 @@ import { useAssetsManifest } from '@/lib/useAssetsManifest';
2981
2981
  import { useCloudSave } from '@/lib/useCloudSave';
2982
2982
  import { useTenantBootstrap } from '@/lib/useTenantBootstrap';
2983
2983
  import { useAdminStudioContent } from '@/lib/cloud/useAdminStudioContent';
2984
+ import { hydrateLocalProjectState } from '@/lib/hydrateLocalProjectState';
2984
2985
 
2985
2986
  import tenantCss from './index.css?inline';
2986
2987
 
@@ -3047,7 +3048,10 @@ function App() {
3047
3048
  };
3048
3049
  }, [canPaintVisitor, bootstrap.enginePages, bootstrap.siteConfig]);
3049
3050
 
3050
- const engineCollections = bootstrap.isHotSaveMode ? bootstrap.collections : fileCollections;
3051
+ const engineCollections =
3052
+ bootstrap.isHotSaveMode || Object.keys(bootstrap.collections).length > 0
3053
+ ? bootstrap.collections
3054
+ : fileCollections;
3051
3055
  const engineRefDocuments = useMemo(
3052
3056
  () => ({
3053
3057
  'menu.json': bootstrap.menuConfig,
@@ -3085,6 +3089,15 @@ function App() {
3085
3089
  });
3086
3090
  const body = (await res.json().catch(() => ({}))) as { error?: string };
3087
3091
  if (!res.ok) throw new Error(body.error ?? `Save to file failed: ${res.status}`);
3092
+ hydrateLocalProjectState({
3093
+ state,
3094
+ slug,
3095
+ setPages: bootstrap.setPages,
3096
+ setSiteConfig: bootstrap.setSiteConfig,
3097
+ setMenuConfig: bootstrap.setMenuConfig,
3098
+ setThemeConfig: bootstrap.setThemeConfig,
3099
+ setCollections: bootstrap.setCollections,
3100
+ });
3088
3101
  },
3089
3102
  async hotSave(state: ProjectState, slug: string): Promise<void> {
3090
3103
  if (!bootstrap.isCloudMode || !CLOUD_API_URL || !CLOUD_API_KEY) {
@@ -11790,6 +11803,47 @@ export function getFilePages(): Record<string, PageConfig> {
11790
11803
  return record;
11791
11804
  }
11792
11805
 
11806
+ END_OF_FILE_CONTENT
11807
+ echo "Creating src/lib/hydrateLocalProjectState.ts..."
11808
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/hydrateLocalProjectState.ts"
11809
+ import type { JsonPagesConfig, ProjectState } from '@olonjs/core';
11810
+ import type { MenuConfig, PageConfig, SiteConfig, ThemeConfig } from '@/types';
11811
+
11812
+ type HydrateLocalProjectStateArgs = {
11813
+ state: ProjectState;
11814
+ slug: string;
11815
+ setPages: (updater: (prev: Record<string, PageConfig>) => Record<string, PageConfig>) => void;
11816
+ setSiteConfig: (site: SiteConfig) => void;
11817
+ setMenuConfig: (menu: MenuConfig) => void;
11818
+ setThemeConfig: (theme: ThemeConfig) => void;
11819
+ setCollections: (collections: NonNullable<JsonPagesConfig['collections']>) => void;
11820
+ };
11821
+
11822
+ /**
11823
+ * Write-through hydrate after local `/api/save-to-file`.
11824
+ * Pushes only the slices present in the saved ProjectState into bootstrap state —
11825
+ * no disk re-read, no full remount. Brand-agnostic.
11826
+ */
11827
+ export function hydrateLocalProjectState({
11828
+ state,
11829
+ slug,
11830
+ setPages,
11831
+ setSiteConfig,
11832
+ setMenuConfig,
11833
+ setThemeConfig,
11834
+ setCollections,
11835
+ }: HydrateLocalProjectStateArgs): void {
11836
+ if (state.menu != null) setMenuConfig(state.menu);
11837
+ if (state.site != null) setSiteConfig(state.site);
11838
+ if (state.theme != null) setThemeConfig(state.theme);
11839
+ if (state.page != null) {
11840
+ setPages((prev) => ({ ...prev, [slug]: state.page }));
11841
+ }
11842
+ if (state.collections != null) {
11843
+ setCollections(state.collections as NonNullable<JsonPagesConfig['collections']>);
11844
+ }
11845
+ }
11846
+
11793
11847
  END_OF_FILE_CONTENT
11794
11848
  echo "Creating src/lib/schemas.ts..."
11795
11849
  cat << 'END_OF_FILE_CONTENT' > "src/lib/schemas.ts"
@@ -12862,10 +12916,18 @@ export function buildThemeFontVarsCss(input: unknown): string {
12862
12916
  const tokens = isObjectRecord(input.tokens) ? input.tokens : null;
12863
12917
  const typography = tokens && isObjectRecord(tokens.typography) ? tokens.typography : null;
12864
12918
  const fontFamily = typography && isObjectRecord(typography.fontFamily) ? typography.fontFamily : null;
12865
- const primary = typeof fontFamily?.primary === 'string' ? fontFamily.primary : "'Instrument Sans', system-ui, sans-serif";
12866
- const serif = typeof fontFamily?.serif === 'string' ? fontFamily.serif : "'Instrument Serif', Georgia, serif";
12919
+ const primary =
12920
+ typeof fontFamily?.primary === 'string'
12921
+ ? fontFamily.primary
12922
+ : "'Instrument Sans', system-ui, sans-serif";
12923
+ const display =
12924
+ typeof fontFamily?.display === 'string'
12925
+ ? fontFamily.display
12926
+ : typeof fontFamily?.serif === 'string'
12927
+ ? fontFamily.serif
12928
+ : "'Instrument Serif', Georgia, serif";
12867
12929
  const mono = typeof fontFamily?.mono === 'string' ? fontFamily.mono : "'JetBrains Mono', monospace";
12868
- return `:root{--theme-font-primary:${primary};--theme-font-serif:${serif};--theme-font-mono:${mono};}`;
12930
+ return `:root{--theme-font-primary:${primary};--theme-font-display:${display};--theme-font-mono:${mono};}`;
12869
12931
  }
12870
12932
 
12871
12933
  const REMOTE_CSS_LINK_ATTR = 'data-jp-tenant-remote-css';
@@ -13094,7 +13156,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/lib/useCloudSave.ts"
13094
13156
  import { useCallback, useEffect, useRef, useState } from 'react';
13095
13157
  import type { DeployPhase, ProjectState, StepId } from '@olonjs/core';
13096
13158
  import { DEPLOY_STEPS, startCloudSaveStream } from '@olonjs/core';
13097
- import { CLOUD_API_KEY, CLOUD_API_URL } from '@/lib/tenantEnv';
13159
+ import { APP_BASE_PATH, CLOUD_API_KEY, CLOUD_API_URL } from '@/lib/tenantEnv';
13098
13160
 
13099
13161
  interface CloudSaveUiState {
13100
13162
  isOpen: boolean;
@@ -13120,6 +13182,100 @@ function stepProgress(doneSteps: StepId[]): number {
13120
13182
  return Math.round((doneSteps.length / DEPLOY_STEPS.length) * 100);
13121
13183
  }
13122
13184
 
13185
+ function isRecord(value: unknown): value is Record<string, unknown> {
13186
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
13187
+ }
13188
+
13189
+ function cloneJson<T>(value: T): T {
13190
+ return JSON.parse(JSON.stringify(value)) as T;
13191
+ }
13192
+
13193
+ function normalizePathSegments(value: string): string {
13194
+ return value
13195
+ .split('/')
13196
+ .map((segment) => segment.trim())
13197
+ .filter(Boolean)
13198
+ .join('/');
13199
+ }
13200
+
13201
+ function resolveAdminContentSlug(): string | null {
13202
+ if (typeof window === 'undefined') return null;
13203
+
13204
+ const normalizedBase = APP_BASE_PATH.replace(/\/+$/, '');
13205
+ let path = window.location.pathname;
13206
+ if (normalizedBase && normalizedBase !== '/' && path.startsWith(normalizedBase)) {
13207
+ path = path.slice(normalizedBase.length) || '/';
13208
+ }
13209
+
13210
+ const slug = normalizePathSegments(path.replace(/^\/admin\/?/, ''));
13211
+ return slug || null;
13212
+ }
13213
+
13214
+ function resolveTemplateParamValue(templateSlug: string, concreteSlug: string, paramKey: string): string | null {
13215
+ const templateSegments = normalizePathSegments(templateSlug).split('/').filter(Boolean);
13216
+ const concreteSegments = normalizePathSegments(concreteSlug).split('/').filter(Boolean);
13217
+ if (templateSegments.length !== concreteSegments.length) return null;
13218
+
13219
+ for (let index = 0; index < templateSegments.length; index += 1) {
13220
+ const templateSegment = templateSegments[index];
13221
+ const concreteSegment = concreteSegments[index];
13222
+ const paramMatch = templateSegment.match(/^\[([A-Za-z0-9_-]+)\]$/);
13223
+ if (paramMatch?.[1] === paramKey) return concreteSegment;
13224
+ if (!paramMatch && templateSegment !== concreteSegment) return null;
13225
+ }
13226
+
13227
+ return null;
13228
+ }
13229
+
13230
+ function hasCollectionCurrentRef(value: unknown): boolean {
13231
+ if (Array.isArray(value)) return value.some(hasCollectionCurrentRef);
13232
+ if (!isRecord(value)) return false;
13233
+ if (value.$ref === 'collection:current') return true;
13234
+ return Object.values(value).some(hasCollectionCurrentRef);
13235
+ }
13236
+
13237
+ function replaceCollectionCurrentRefs(value: unknown, currentItem: unknown): unknown {
13238
+ if (Array.isArray(value)) return value.map((item) => replaceCollectionCurrentRefs(item, currentItem));
13239
+ if (!isRecord(value)) return value;
13240
+ if (value.$ref === 'collection:current') return cloneJson(currentItem);
13241
+ return Object.fromEntries(
13242
+ Object.entries(value).map(([key, entryValue]) => [key, replaceCollectionCurrentRefs(entryValue, currentItem)]),
13243
+ );
13244
+ }
13245
+
13246
+ function buildSaveStreamPagePayload(state: ProjectState, fallbackSlug: string): { slug: string; page: ProjectState['page'] } {
13247
+ const page = state.page;
13248
+ const collection = page.collection;
13249
+ if (!collection || !hasCollectionCurrentRef(page)) {
13250
+ return { slug: fallbackSlug, page };
13251
+ }
13252
+
13253
+ const concreteSlug = resolveAdminContentSlug();
13254
+ if (!concreteSlug) {
13255
+ throw new Error('Cannot resolve concrete admin route for collection page save.');
13256
+ }
13257
+
13258
+ const paramValue = resolveTemplateParamValue(page.slug, concreteSlug, collection.paramKey);
13259
+ if (!paramValue) {
13260
+ throw new Error(`Cannot resolve collection param "${collection.paramKey}" from route "${concreteSlug}".`);
13261
+ }
13262
+
13263
+ const collectionDocument = state.collections?.[collection.source];
13264
+ const currentItem = isRecord(collectionDocument) ? collectionDocument[paramValue] : undefined;
13265
+ if (!currentItem) {
13266
+ throw new Error(`Cannot resolve collection item "${collection.source}/${paramValue}" for save.`);
13267
+ }
13268
+
13269
+ const resolvedPage = replaceCollectionCurrentRefs(page, currentItem) as ProjectState['page'];
13270
+ return {
13271
+ slug: concreteSlug,
13272
+ page: {
13273
+ ...resolvedPage,
13274
+ slug: concreteSlug,
13275
+ },
13276
+ };
13277
+ }
13278
+
13123
13279
  export function useCloudSave() {
13124
13280
  const [cloudSaveUi, setCloudSaveUi] = useState<CloudSaveUiState>(getInitialCloudSaveUiState);
13125
13281
  const activeCloudSaveController = useRef<AbortController | null>(null);
@@ -13153,12 +13309,18 @@ export function useCloudSave() {
13153
13309
  });
13154
13310
 
13155
13311
  try {
13312
+ const savePage = buildSaveStreamPagePayload(payload.state, payload.slug);
13156
13313
  await startCloudSaveStream({
13157
13314
  apiBaseUrl: CLOUD_API_URL,
13158
13315
  apiKey: CLOUD_API_KEY,
13159
- path: `src/data/pages/${payload.slug}.json`,
13160
- content: payload.state.page,
13161
- message: `Content update for ${payload.slug} via Visual Editor`,
13316
+ path: `src/data/pages/${savePage.slug}.json`,
13317
+ content: savePage.page,
13318
+ additionalFiles: [
13319
+ { path: 'src/data/config/site.json', content: payload.state.site },
13320
+ { path: 'src/data/config/menu.json', content: payload.state.menu },
13321
+ ],
13322
+ changedScopes: ['page', 'site', 'menu'],
13323
+ message: `Content update for ${savePage.slug} via Visual Editor`,
13162
13324
  signal: controller.signal,
13163
13325
  onStep: (event) => {
13164
13326
  setCloudSaveUi((prev) => {
@@ -13598,7 +13760,7 @@ export function useTenantBootstrap({
13598
13760
 
13599
13761
  const [menuConfig, setMenuConfig] = useState<MenuConfig>(menuConfigSeed);
13600
13762
 
13601
- const [themeConfig] = useState<ThemeConfig>(themeConfigSeed);
13763
+ const [themeConfig, setThemeConfig] = useState<ThemeConfig>(themeConfigSeed);
13602
13764
 
13603
13765
  const [collections, setCollections] = useState<NonNullable<JsonPagesConfig['collections']>>(EMPTY_COLLECTIONS);
13604
13766
 
@@ -14114,6 +14276,10 @@ export function useTenantBootstrap({
14114
14276
 
14115
14277
  setSiteConfig,
14116
14278
 
14279
+ setMenuConfig,
14280
+
14281
+ setThemeConfig,
14282
+
14117
14283
  setCollections,
14118
14284
 
14119
14285
  cloudApiCandidates,
@@ -1817,7 +1817,7 @@ 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.13",
1820
+ "@olonjs/core": "^1.1.16",
1821
1821
  "class-variance-authority": "^0.7.1",
1822
1822
  "clsx": "^2.1.1",
1823
1823
  "lucide-react": "^0.474.0",
@@ -2981,6 +2981,7 @@ import { useAssetsManifest } from '@/lib/useAssetsManifest';
2981
2981
  import { useCloudSave } from '@/lib/useCloudSave';
2982
2982
  import { useTenantBootstrap } from '@/lib/useTenantBootstrap';
2983
2983
  import { useAdminStudioContent } from '@/lib/cloud/useAdminStudioContent';
2984
+ import { hydrateLocalProjectState } from '@/lib/hydrateLocalProjectState';
2984
2985
 
2985
2986
  import tenantCss from './index.css?inline';
2986
2987
 
@@ -3047,7 +3048,10 @@ function App() {
3047
3048
  };
3048
3049
  }, [canPaintVisitor, bootstrap.enginePages, bootstrap.siteConfig]);
3049
3050
 
3050
- const engineCollections = bootstrap.isHotSaveMode ? bootstrap.collections : fileCollections;
3051
+ const engineCollections =
3052
+ bootstrap.isHotSaveMode || Object.keys(bootstrap.collections).length > 0
3053
+ ? bootstrap.collections
3054
+ : fileCollections;
3051
3055
  const engineRefDocuments = useMemo(
3052
3056
  () => ({
3053
3057
  'menu.json': bootstrap.menuConfig,
@@ -3085,6 +3089,15 @@ function App() {
3085
3089
  });
3086
3090
  const body = (await res.json().catch(() => ({}))) as { error?: string };
3087
3091
  if (!res.ok) throw new Error(body.error ?? `Save to file failed: ${res.status}`);
3092
+ hydrateLocalProjectState({
3093
+ state,
3094
+ slug,
3095
+ setPages: bootstrap.setPages,
3096
+ setSiteConfig: bootstrap.setSiteConfig,
3097
+ setMenuConfig: bootstrap.setMenuConfig,
3098
+ setThemeConfig: bootstrap.setThemeConfig,
3099
+ setCollections: bootstrap.setCollections,
3100
+ });
3088
3101
  },
3089
3102
  async hotSave(state: ProjectState, slug: string): Promise<void> {
3090
3103
  if (!bootstrap.isCloudMode || !CLOUD_API_URL || !CLOUD_API_KEY) {
@@ -11790,6 +11803,47 @@ export function getFilePages(): Record<string, PageConfig> {
11790
11803
  return record;
11791
11804
  }
11792
11805
 
11806
+ END_OF_FILE_CONTENT
11807
+ echo "Creating src/lib/hydrateLocalProjectState.ts..."
11808
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/hydrateLocalProjectState.ts"
11809
+ import type { JsonPagesConfig, ProjectState } from '@olonjs/core';
11810
+ import type { MenuConfig, PageConfig, SiteConfig, ThemeConfig } from '@/types';
11811
+
11812
+ type HydrateLocalProjectStateArgs = {
11813
+ state: ProjectState;
11814
+ slug: string;
11815
+ setPages: (updater: (prev: Record<string, PageConfig>) => Record<string, PageConfig>) => void;
11816
+ setSiteConfig: (site: SiteConfig) => void;
11817
+ setMenuConfig: (menu: MenuConfig) => void;
11818
+ setThemeConfig: (theme: ThemeConfig) => void;
11819
+ setCollections: (collections: NonNullable<JsonPagesConfig['collections']>) => void;
11820
+ };
11821
+
11822
+ /**
11823
+ * Write-through hydrate after local `/api/save-to-file`.
11824
+ * Pushes only the slices present in the saved ProjectState into bootstrap state —
11825
+ * no disk re-read, no full remount. Brand-agnostic.
11826
+ */
11827
+ export function hydrateLocalProjectState({
11828
+ state,
11829
+ slug,
11830
+ setPages,
11831
+ setSiteConfig,
11832
+ setMenuConfig,
11833
+ setThemeConfig,
11834
+ setCollections,
11835
+ }: HydrateLocalProjectStateArgs): void {
11836
+ if (state.menu != null) setMenuConfig(state.menu);
11837
+ if (state.site != null) setSiteConfig(state.site);
11838
+ if (state.theme != null) setThemeConfig(state.theme);
11839
+ if (state.page != null) {
11840
+ setPages((prev) => ({ ...prev, [slug]: state.page }));
11841
+ }
11842
+ if (state.collections != null) {
11843
+ setCollections(state.collections as NonNullable<JsonPagesConfig['collections']>);
11844
+ }
11845
+ }
11846
+
11793
11847
  END_OF_FILE_CONTENT
11794
11848
  echo "Creating src/lib/schemas.ts..."
11795
11849
  cat << 'END_OF_FILE_CONTENT' > "src/lib/schemas.ts"
@@ -12862,10 +12916,18 @@ export function buildThemeFontVarsCss(input: unknown): string {
12862
12916
  const tokens = isObjectRecord(input.tokens) ? input.tokens : null;
12863
12917
  const typography = tokens && isObjectRecord(tokens.typography) ? tokens.typography : null;
12864
12918
  const fontFamily = typography && isObjectRecord(typography.fontFamily) ? typography.fontFamily : null;
12865
- const primary = typeof fontFamily?.primary === 'string' ? fontFamily.primary : "'Instrument Sans', system-ui, sans-serif";
12866
- const serif = typeof fontFamily?.serif === 'string' ? fontFamily.serif : "'Instrument Serif', Georgia, serif";
12919
+ const primary =
12920
+ typeof fontFamily?.primary === 'string'
12921
+ ? fontFamily.primary
12922
+ : "'Instrument Sans', system-ui, sans-serif";
12923
+ const display =
12924
+ typeof fontFamily?.display === 'string'
12925
+ ? fontFamily.display
12926
+ : typeof fontFamily?.serif === 'string'
12927
+ ? fontFamily.serif
12928
+ : "'Instrument Serif', Georgia, serif";
12867
12929
  const mono = typeof fontFamily?.mono === 'string' ? fontFamily.mono : "'JetBrains Mono', monospace";
12868
- return `:root{--theme-font-primary:${primary};--theme-font-serif:${serif};--theme-font-mono:${mono};}`;
12930
+ return `:root{--theme-font-primary:${primary};--theme-font-display:${display};--theme-font-mono:${mono};}`;
12869
12931
  }
12870
12932
 
12871
12933
  const REMOTE_CSS_LINK_ATTR = 'data-jp-tenant-remote-css';
@@ -13094,7 +13156,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/lib/useCloudSave.ts"
13094
13156
  import { useCallback, useEffect, useRef, useState } from 'react';
13095
13157
  import type { DeployPhase, ProjectState, StepId } from '@olonjs/core';
13096
13158
  import { DEPLOY_STEPS, startCloudSaveStream } from '@olonjs/core';
13097
- import { CLOUD_API_KEY, CLOUD_API_URL } from '@/lib/tenantEnv';
13159
+ import { APP_BASE_PATH, CLOUD_API_KEY, CLOUD_API_URL } from '@/lib/tenantEnv';
13098
13160
 
13099
13161
  interface CloudSaveUiState {
13100
13162
  isOpen: boolean;
@@ -13120,6 +13182,100 @@ function stepProgress(doneSteps: StepId[]): number {
13120
13182
  return Math.round((doneSteps.length / DEPLOY_STEPS.length) * 100);
13121
13183
  }
13122
13184
 
13185
+ function isRecord(value: unknown): value is Record<string, unknown> {
13186
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
13187
+ }
13188
+
13189
+ function cloneJson<T>(value: T): T {
13190
+ return JSON.parse(JSON.stringify(value)) as T;
13191
+ }
13192
+
13193
+ function normalizePathSegments(value: string): string {
13194
+ return value
13195
+ .split('/')
13196
+ .map((segment) => segment.trim())
13197
+ .filter(Boolean)
13198
+ .join('/');
13199
+ }
13200
+
13201
+ function resolveAdminContentSlug(): string | null {
13202
+ if (typeof window === 'undefined') return null;
13203
+
13204
+ const normalizedBase = APP_BASE_PATH.replace(/\/+$/, '');
13205
+ let path = window.location.pathname;
13206
+ if (normalizedBase && normalizedBase !== '/' && path.startsWith(normalizedBase)) {
13207
+ path = path.slice(normalizedBase.length) || '/';
13208
+ }
13209
+
13210
+ const slug = normalizePathSegments(path.replace(/^\/admin\/?/, ''));
13211
+ return slug || null;
13212
+ }
13213
+
13214
+ function resolveTemplateParamValue(templateSlug: string, concreteSlug: string, paramKey: string): string | null {
13215
+ const templateSegments = normalizePathSegments(templateSlug).split('/').filter(Boolean);
13216
+ const concreteSegments = normalizePathSegments(concreteSlug).split('/').filter(Boolean);
13217
+ if (templateSegments.length !== concreteSegments.length) return null;
13218
+
13219
+ for (let index = 0; index < templateSegments.length; index += 1) {
13220
+ const templateSegment = templateSegments[index];
13221
+ const concreteSegment = concreteSegments[index];
13222
+ const paramMatch = templateSegment.match(/^\[([A-Za-z0-9_-]+)\]$/);
13223
+ if (paramMatch?.[1] === paramKey) return concreteSegment;
13224
+ if (!paramMatch && templateSegment !== concreteSegment) return null;
13225
+ }
13226
+
13227
+ return null;
13228
+ }
13229
+
13230
+ function hasCollectionCurrentRef(value: unknown): boolean {
13231
+ if (Array.isArray(value)) return value.some(hasCollectionCurrentRef);
13232
+ if (!isRecord(value)) return false;
13233
+ if (value.$ref === 'collection:current') return true;
13234
+ return Object.values(value).some(hasCollectionCurrentRef);
13235
+ }
13236
+
13237
+ function replaceCollectionCurrentRefs(value: unknown, currentItem: unknown): unknown {
13238
+ if (Array.isArray(value)) return value.map((item) => replaceCollectionCurrentRefs(item, currentItem));
13239
+ if (!isRecord(value)) return value;
13240
+ if (value.$ref === 'collection:current') return cloneJson(currentItem);
13241
+ return Object.fromEntries(
13242
+ Object.entries(value).map(([key, entryValue]) => [key, replaceCollectionCurrentRefs(entryValue, currentItem)]),
13243
+ );
13244
+ }
13245
+
13246
+ function buildSaveStreamPagePayload(state: ProjectState, fallbackSlug: string): { slug: string; page: ProjectState['page'] } {
13247
+ const page = state.page;
13248
+ const collection = page.collection;
13249
+ if (!collection || !hasCollectionCurrentRef(page)) {
13250
+ return { slug: fallbackSlug, page };
13251
+ }
13252
+
13253
+ const concreteSlug = resolveAdminContentSlug();
13254
+ if (!concreteSlug) {
13255
+ throw new Error('Cannot resolve concrete admin route for collection page save.');
13256
+ }
13257
+
13258
+ const paramValue = resolveTemplateParamValue(page.slug, concreteSlug, collection.paramKey);
13259
+ if (!paramValue) {
13260
+ throw new Error(`Cannot resolve collection param "${collection.paramKey}" from route "${concreteSlug}".`);
13261
+ }
13262
+
13263
+ const collectionDocument = state.collections?.[collection.source];
13264
+ const currentItem = isRecord(collectionDocument) ? collectionDocument[paramValue] : undefined;
13265
+ if (!currentItem) {
13266
+ throw new Error(`Cannot resolve collection item "${collection.source}/${paramValue}" for save.`);
13267
+ }
13268
+
13269
+ const resolvedPage = replaceCollectionCurrentRefs(page, currentItem) as ProjectState['page'];
13270
+ return {
13271
+ slug: concreteSlug,
13272
+ page: {
13273
+ ...resolvedPage,
13274
+ slug: concreteSlug,
13275
+ },
13276
+ };
13277
+ }
13278
+
13123
13279
  export function useCloudSave() {
13124
13280
  const [cloudSaveUi, setCloudSaveUi] = useState<CloudSaveUiState>(getInitialCloudSaveUiState);
13125
13281
  const activeCloudSaveController = useRef<AbortController | null>(null);
@@ -13153,12 +13309,18 @@ export function useCloudSave() {
13153
13309
  });
13154
13310
 
13155
13311
  try {
13312
+ const savePage = buildSaveStreamPagePayload(payload.state, payload.slug);
13156
13313
  await startCloudSaveStream({
13157
13314
  apiBaseUrl: CLOUD_API_URL,
13158
13315
  apiKey: CLOUD_API_KEY,
13159
- path: `src/data/pages/${payload.slug}.json`,
13160
- content: payload.state.page,
13161
- message: `Content update for ${payload.slug} via Visual Editor`,
13316
+ path: `src/data/pages/${savePage.slug}.json`,
13317
+ content: savePage.page,
13318
+ additionalFiles: [
13319
+ { path: 'src/data/config/site.json', content: payload.state.site },
13320
+ { path: 'src/data/config/menu.json', content: payload.state.menu },
13321
+ ],
13322
+ changedScopes: ['page', 'site', 'menu'],
13323
+ message: `Content update for ${savePage.slug} via Visual Editor`,
13162
13324
  signal: controller.signal,
13163
13325
  onStep: (event) => {
13164
13326
  setCloudSaveUi((prev) => {
@@ -13598,7 +13760,7 @@ export function useTenantBootstrap({
13598
13760
 
13599
13761
  const [menuConfig, setMenuConfig] = useState<MenuConfig>(menuConfigSeed);
13600
13762
 
13601
- const [themeConfig] = useState<ThemeConfig>(themeConfigSeed);
13763
+ const [themeConfig, setThemeConfig] = useState<ThemeConfig>(themeConfigSeed);
13602
13764
 
13603
13765
  const [collections, setCollections] = useState<NonNullable<JsonPagesConfig['collections']>>(EMPTY_COLLECTIONS);
13604
13766
 
@@ -14114,6 +14276,10 @@ export function useTenantBootstrap({
14114
14276
 
14115
14277
  setSiteConfig,
14116
14278
 
14279
+ setMenuConfig,
14280
+
14281
+ setThemeConfig,
14282
+
14117
14283
  setCollections,
14118
14284
 
14119
14285
  cloudApiCandidates,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olonjs/cli",
3
- "version": "3.0.147",
3
+ "version": "3.0.149",
4
4
  "description": "The Sovereign CLI Engine for OlonJS.",
5
5
  "type": "module",
6
6
  "bin": {