@olonjs/cli 3.0.148 → 3.0.150

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,11 +1817,14 @@ 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.15",
1820
+ "@olonjs/core": "^1.1.17",
1821
+ "@olonjs/react": "^0.1.0",
1822
+ "@olonjs/studio": "^0.1.0",
1821
1823
  "class-variance-authority": "^0.7.1",
1822
1824
  "clsx": "^2.1.1",
1823
1825
  "lucide-react": "^0.474.0",
1824
1826
  "motion": "^12.23.24",
1827
+ "radix-ui": "^1.4.3",
1825
1828
  "react": "^19.0.0",
1826
1829
  "react-markdown": "^9.0.1",
1827
1830
  "react-dom": "^19.0.0",
@@ -2191,8 +2194,6 @@ async function readJsonFile(filePath) {
2191
2194
  }
2192
2195
 
2193
2196
  async function expandCollectionTarget(slug, pageFilePath) {
2194
- if (!/\[[^\]]+\]/.test(slug)) return [slug];
2195
-
2196
2197
  let pageConfig;
2197
2198
  try {
2198
2199
  pageConfig = await readJsonFile(pageFilePath);
@@ -2206,7 +2207,14 @@ async function expandCollectionTarget(slug, pageFilePath) {
2206
2207
  }
2207
2208
 
2208
2209
  const token = `[${binding.paramKey}]`;
2209
- if (!slug.includes(token)) return [slug];
2210
+ // Prefer authored route pattern (page.slug) when the file path is not parametric.
2211
+ const authoredSlug =
2212
+ typeof pageConfig.slug === 'string'
2213
+ ? pageConfig.slug.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
2214
+ : '';
2215
+ const routePattern =
2216
+ authoredSlug.includes(token) ? authoredSlug : slug.includes(token) ? slug : '';
2217
+ if (!routePattern) return [slug];
2210
2218
 
2211
2219
  const collectionPath = path.resolve(collectionsDir, binding.source, `${binding.source}.json`);
2212
2220
  let collection;
@@ -2218,7 +2226,9 @@ async function expandCollectionTarget(slug, pageFilePath) {
2218
2226
 
2219
2227
  if (!collection || typeof collection !== 'object' || Array.isArray(collection)) return [slug];
2220
2228
  const itemIds = Object.keys(collection).sort((a, b) => a.localeCompare(b));
2221
- return itemIds.length > 0 ? itemIds.map((itemId) => slug.replace(token, itemId)) : [slug];
2229
+ return itemIds.length > 0
2230
+ ? itemIds.map((itemId) => routePattern.replace(token, itemId))
2231
+ : [slug];
2222
2232
  }
2223
2233
 
2224
2234
  async function listJsonFilesRecursive(dir) {
@@ -2942,10 +2952,9 @@ cat << 'END_OF_FILE_CONTENT' > "src/App.tsx"
2942
2952
  * Bootstrap, persistence, and engine wiring — logic lives in lib/hooks.
2943
2953
  */
2944
2954
  import { useEffect, useMemo } from 'react';
2945
- import { JsonPagesEngine } from '@olonjs/core';
2955
+ import { JsonPagesEngine, OlonFormsContext } from '@olonjs/react';
2946
2956
  import type { JsonPagesConfig, ProjectState } from '@olonjs/core';
2947
2957
  import { withBasePath } from '@olonjs/core';
2948
- import { OlonFormsContext } from '@olonjs/core';
2949
2958
  import { ComponentRegistry } from '@/lib/ComponentRegistry';
2950
2959
  import { CollectionRegistry } from '@/lib/CollectionRegistry';
2951
2960
  import { SECTION_SCHEMAS } from '@/lib/schemas';
@@ -2981,6 +2990,7 @@ import { useAssetsManifest } from '@/lib/useAssetsManifest';
2981
2990
  import { useCloudSave } from '@/lib/useCloudSave';
2982
2991
  import { useTenantBootstrap } from '@/lib/useTenantBootstrap';
2983
2992
  import { useAdminStudioContent } from '@/lib/cloud/useAdminStudioContent';
2993
+ import { hydrateLocalProjectState } from '@/lib/hydrateLocalProjectState';
2984
2994
 
2985
2995
  import tenantCss from './index.css?inline';
2986
2996
 
@@ -3047,7 +3057,10 @@ function App() {
3047
3057
  };
3048
3058
  }, [canPaintVisitor, bootstrap.enginePages, bootstrap.siteConfig]);
3049
3059
 
3050
- const engineCollections = bootstrap.isHotSaveMode ? bootstrap.collections : fileCollections;
3060
+ const engineCollections =
3061
+ bootstrap.isHotSaveMode || Object.keys(bootstrap.collections).length > 0
3062
+ ? bootstrap.collections
3063
+ : fileCollections;
3051
3064
  const engineRefDocuments = useMemo(
3052
3065
  () => ({
3053
3066
  'menu.json': bootstrap.menuConfig,
@@ -3085,6 +3098,15 @@ function App() {
3085
3098
  });
3086
3099
  const body = (await res.json().catch(() => ({}))) as { error?: string };
3087
3100
  if (!res.ok) throw new Error(body.error ?? `Save to file failed: ${res.status}`);
3101
+ hydrateLocalProjectState({
3102
+ state,
3103
+ slug,
3104
+ setPages: bootstrap.setPages,
3105
+ setSiteConfig: bootstrap.setSiteConfig,
3106
+ setMenuConfig: bootstrap.setMenuConfig,
3107
+ setThemeConfig: bootstrap.setThemeConfig,
3108
+ setCollections: bootstrap.setCollections,
3109
+ });
3088
3110
  },
3089
3111
  async hotSave(state: ProjectState, slug: string): Promise<void> {
3090
3112
  if (!bootstrap.isCloudMode || !CLOUD_API_URL || !CLOUD_API_KEY) {
@@ -4064,7 +4086,7 @@ END_OF_FILE_CONTENT
4064
4086
  echo "Creating src/components/form-demo/View.tsx..."
4065
4087
  cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/View.tsx"
4066
4088
  import { Icon } from '@/lib/IconResolver';
4067
- import { useFormState } from '@olonjs/core';
4089
+ import { useFormState } from '@olonjs/react';
4068
4090
  import type { FormDemoData } from './types';
4069
4091
 
4070
4092
  type FormDemoViewProps = {
@@ -5609,7 +5631,8 @@ import {
5609
5631
  Code2, Quote, SquareCode,
5610
5632
  Link2, Unlink2, ImagePlus, Eraser,
5611
5633
  } from 'lucide-react';
5612
- import { STUDIO_EVENTS, useConfig, useStudio } from '@olonjs/core';
5634
+ import { STUDIO_EVENTS } from '@olonjs/core';
5635
+ import { useConfig, useStudio } from '@olonjs/react';
5613
5636
  import type { TiptapData, TiptapSettings } from './types';
5614
5637
 
5615
5638
  // ── UI primitives ─────────────────────────────────────────────────
@@ -8569,7 +8592,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/collections/libri/libri.json"
8569
8592
  "id": "1984",
8570
8593
  "title": "1984",
8571
8594
  "author": {
8572
- "$ref": "../autori/autori.json#/alessandro-baricco"
8595
+ "$ref": "../autori/autori.json#/george-orwell"
8573
8596
  },
8574
8597
  "year": 1949,
8575
8598
  "genre": "Distopia",
@@ -9673,14 +9696,8 @@ echo "Creating src/entry-ssg.tsx..."
9673
9696
  cat << 'END_OF_FILE_CONTENT' > "src/entry-ssg.tsx"
9674
9697
  import { renderToString } from 'react-dom/server';
9675
9698
  import { StaticRouter } from 'react-router-dom/server';
9676
- import {
9677
- ConfigProvider,
9678
- PageRenderer,
9679
- StudioProvider,
9680
- contract,
9681
- resolvePageMatchFromRegistry,
9682
- resolveRuntimeConfig,
9683
- } from '@olonjs/core';
9699
+ import { ConfigProvider, PageRenderer, StudioProvider } from '@olonjs/react';
9700
+ import { contract, resolvePageMatchFromRegistry, resolveRuntimeConfig } from '@olonjs/core';
9684
9701
  import type { JsonPagesConfig, PageConfig, SiteConfig, ThemeConfig } from '@/types';
9685
9702
  import { ThemeProvider } from '@/components/ThemeProvider';
9686
9703
  import { ComponentRegistry } from '@/lib/ComponentRegistry';
@@ -11432,7 +11449,6 @@ export function coerceSiteConfig(value: unknown): SiteConfig | null {
11432
11449
  }
11433
11450
  if (!isObjectRecord(input)) return null;
11434
11451
  if (!isObjectRecord(input.identity)) return null;
11435
- if (!Array.isArray(input.pages)) return null;
11436
11452
 
11437
11453
  return input as unknown as SiteConfig;
11438
11454
  }
@@ -11699,7 +11715,6 @@ export function getHydratedData(
11699
11715
  }
11700
11716
 
11701
11717
  END_OF_FILE_CONTENT
11702
- mkdir -p "src/lib/editorial"
11703
11718
  echo "Creating src/lib/getFileCollections.ts..."
11704
11719
  cat << 'END_OF_FILE_CONTENT' > "src/lib/getFileCollections.ts"
11705
11720
  import type { JsonPagesConfig } from '@/types';
@@ -11790,6 +11805,47 @@ export function getFilePages(): Record<string, PageConfig> {
11790
11805
  return record;
11791
11806
  }
11792
11807
 
11808
+ END_OF_FILE_CONTENT
11809
+ echo "Creating src/lib/hydrateLocalProjectState.ts..."
11810
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/hydrateLocalProjectState.ts"
11811
+ import type { JsonPagesConfig, ProjectState } from '@olonjs/core';
11812
+ import type { MenuConfig, PageConfig, SiteConfig, ThemeConfig } from '@/types';
11813
+
11814
+ type HydrateLocalProjectStateArgs = {
11815
+ state: ProjectState;
11816
+ slug: string;
11817
+ setPages: (updater: (prev: Record<string, PageConfig>) => Record<string, PageConfig>) => void;
11818
+ setSiteConfig: (site: SiteConfig) => void;
11819
+ setMenuConfig: (menu: MenuConfig) => void;
11820
+ setThemeConfig: (theme: ThemeConfig) => void;
11821
+ setCollections: (collections: NonNullable<JsonPagesConfig['collections']>) => void;
11822
+ };
11823
+
11824
+ /**
11825
+ * Write-through hydrate after local `/api/save-to-file`.
11826
+ * Pushes only the slices present in the saved ProjectState into bootstrap state —
11827
+ * no disk re-read, no full remount. Brand-agnostic.
11828
+ */
11829
+ export function hydrateLocalProjectState({
11830
+ state,
11831
+ slug,
11832
+ setPages,
11833
+ setSiteConfig,
11834
+ setMenuConfig,
11835
+ setThemeConfig,
11836
+ setCollections,
11837
+ }: HydrateLocalProjectStateArgs): void {
11838
+ if (state.menu != null) setMenuConfig(state.menu);
11839
+ if (state.site != null) setSiteConfig(state.site);
11840
+ if (state.theme != null) setThemeConfig(state.theme);
11841
+ if (state.page != null) {
11842
+ setPages((prev) => ({ ...prev, [slug]: state.page }));
11843
+ }
11844
+ if (state.collections != null) {
11845
+ setCollections(state.collections as NonNullable<JsonPagesConfig['collections']>);
11846
+ }
11847
+ }
11848
+
11793
11849
  END_OF_FILE_CONTENT
11794
11850
  echo "Creating src/lib/schemas.ts..."
11795
11851
  cat << 'END_OF_FILE_CONTENT' > "src/lib/schemas.ts"
@@ -12862,10 +12918,18 @@ export function buildThemeFontVarsCss(input: unknown): string {
12862
12918
  const tokens = isObjectRecord(input.tokens) ? input.tokens : null;
12863
12919
  const typography = tokens && isObjectRecord(tokens.typography) ? tokens.typography : null;
12864
12920
  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";
12921
+ const primary =
12922
+ typeof fontFamily?.primary === 'string'
12923
+ ? fontFamily.primary
12924
+ : "'Instrument Sans', system-ui, sans-serif";
12925
+ const display =
12926
+ typeof fontFamily?.display === 'string'
12927
+ ? fontFamily.display
12928
+ : typeof fontFamily?.serif === 'string'
12929
+ ? fontFamily.serif
12930
+ : "'Instrument Serif', Georgia, serif";
12867
12931
  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};}`;
12932
+ return `:root{--theme-font-primary:${primary};--theme-font-display:${display};--theme-font-mono:${mono};}`;
12869
12933
  }
12870
12934
 
12871
12935
  const REMOTE_CSS_LINK_ATTR = 'data-jp-tenant-remote-css';
@@ -12878,8 +12942,9 @@ function isRemoteStylesheetHref(value: string): boolean {
12878
12942
  export function extractLeadingRemoteCssImports(cssText: string): { hrefs: string[]; rest: string } {
12879
12943
  const hrefs = new Set<string>();
12880
12944
  const leadingTriviaPattern = /^(?:\s+|\/\*[\s\S]*?\*\/)*/;
12945
+ // Vite/Tailwind may emit `@import url("...")` or compacted `@import"https://..."`.
12881
12946
  const importPattern =
12882
- /^@import\s+url\(\s*(?:'([^']+)'|"([^"]+)"|([^'")\s][^)]*))\s*\)\s*([^;]*);/i;
12947
+ /^@import(?:\s*url\(\s*(?:'([^']+)'|"([^"]+)"|([^'")\s][^)]*))\s*\)|\s*(['"])([^'"]+)\4)\s*([^;]*);/i;
12883
12948
  let rest = cssText;
12884
12949
 
12885
12950
  for (;;) {
@@ -12891,8 +12956,8 @@ export function extractLeadingRemoteCssImports(cssText: string): { hrefs: string
12891
12956
  const match = rest.match(importPattern);
12892
12957
  if (!match) break;
12893
12958
 
12894
- const href = (match[1] ?? match[2] ?? match[3] ?? '').trim();
12895
- const trailingDirectives = (match[4] ?? '').trim();
12959
+ const href = (match[1] ?? match[2] ?? match[3] ?? match[5] ?? '').trim();
12960
+ const trailingDirectives = (match[6] ?? '').trim();
12896
12961
 
12897
12962
  if (!isRemoteStylesheetHref(href) || trailingDirectives.length > 0) {
12898
12963
  break;
@@ -13094,7 +13159,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/lib/useCloudSave.ts"
13094
13159
  import { useCallback, useEffect, useRef, useState } from 'react';
13095
13160
  import type { DeployPhase, ProjectState, StepId } from '@olonjs/core';
13096
13161
  import { DEPLOY_STEPS, startCloudSaveStream } from '@olonjs/core';
13097
- import { CLOUD_API_KEY, CLOUD_API_URL } from '@/lib/tenantEnv';
13162
+ import { APP_BASE_PATH, CLOUD_API_KEY, CLOUD_API_URL } from '@/lib/tenantEnv';
13098
13163
 
13099
13164
  interface CloudSaveUiState {
13100
13165
  isOpen: boolean;
@@ -13120,6 +13185,100 @@ function stepProgress(doneSteps: StepId[]): number {
13120
13185
  return Math.round((doneSteps.length / DEPLOY_STEPS.length) * 100);
13121
13186
  }
13122
13187
 
13188
+ function isRecord(value: unknown): value is Record<string, unknown> {
13189
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
13190
+ }
13191
+
13192
+ function cloneJson<T>(value: T): T {
13193
+ return JSON.parse(JSON.stringify(value)) as T;
13194
+ }
13195
+
13196
+ function normalizePathSegments(value: string): string {
13197
+ return value
13198
+ .split('/')
13199
+ .map((segment) => segment.trim())
13200
+ .filter(Boolean)
13201
+ .join('/');
13202
+ }
13203
+
13204
+ function resolveAdminContentSlug(): string | null {
13205
+ if (typeof window === 'undefined') return null;
13206
+
13207
+ const normalizedBase = APP_BASE_PATH.replace(/\/+$/, '');
13208
+ let path = window.location.pathname;
13209
+ if (normalizedBase && normalizedBase !== '/' && path.startsWith(normalizedBase)) {
13210
+ path = path.slice(normalizedBase.length) || '/';
13211
+ }
13212
+
13213
+ const slug = normalizePathSegments(path.replace(/^\/admin\/?/, ''));
13214
+ return slug || null;
13215
+ }
13216
+
13217
+ function resolveTemplateParamValue(templateSlug: string, concreteSlug: string, paramKey: string): string | null {
13218
+ const templateSegments = normalizePathSegments(templateSlug).split('/').filter(Boolean);
13219
+ const concreteSegments = normalizePathSegments(concreteSlug).split('/').filter(Boolean);
13220
+ if (templateSegments.length !== concreteSegments.length) return null;
13221
+
13222
+ for (let index = 0; index < templateSegments.length; index += 1) {
13223
+ const templateSegment = templateSegments[index];
13224
+ const concreteSegment = concreteSegments[index];
13225
+ const paramMatch = templateSegment.match(/^\[([A-Za-z0-9_-]+)\]$/);
13226
+ if (paramMatch?.[1] === paramKey) return concreteSegment;
13227
+ if (!paramMatch && templateSegment !== concreteSegment) return null;
13228
+ }
13229
+
13230
+ return null;
13231
+ }
13232
+
13233
+ function hasCollectionCurrentRef(value: unknown): boolean {
13234
+ if (Array.isArray(value)) return value.some(hasCollectionCurrentRef);
13235
+ if (!isRecord(value)) return false;
13236
+ if (value.$ref === 'collection:current') return true;
13237
+ return Object.values(value).some(hasCollectionCurrentRef);
13238
+ }
13239
+
13240
+ function replaceCollectionCurrentRefs(value: unknown, currentItem: unknown): unknown {
13241
+ if (Array.isArray(value)) return value.map((item) => replaceCollectionCurrentRefs(item, currentItem));
13242
+ if (!isRecord(value)) return value;
13243
+ if (value.$ref === 'collection:current') return cloneJson(currentItem);
13244
+ return Object.fromEntries(
13245
+ Object.entries(value).map(([key, entryValue]) => [key, replaceCollectionCurrentRefs(entryValue, currentItem)]),
13246
+ );
13247
+ }
13248
+
13249
+ function buildSaveStreamPagePayload(state: ProjectState, fallbackSlug: string): { slug: string; page: ProjectState['page'] } {
13250
+ const page = state.page;
13251
+ const collection = page.collection;
13252
+ if (!collection || !hasCollectionCurrentRef(page)) {
13253
+ return { slug: fallbackSlug, page };
13254
+ }
13255
+
13256
+ const concreteSlug = resolveAdminContentSlug();
13257
+ if (!concreteSlug) {
13258
+ throw new Error('Cannot resolve concrete admin route for collection page save.');
13259
+ }
13260
+
13261
+ const paramValue = resolveTemplateParamValue(page.slug, concreteSlug, collection.paramKey);
13262
+ if (!paramValue) {
13263
+ throw new Error(`Cannot resolve collection param "${collection.paramKey}" from route "${concreteSlug}".`);
13264
+ }
13265
+
13266
+ const collectionDocument = state.collections?.[collection.source];
13267
+ const currentItem = isRecord(collectionDocument) ? collectionDocument[paramValue] : undefined;
13268
+ if (!currentItem) {
13269
+ throw new Error(`Cannot resolve collection item "${collection.source}/${paramValue}" for save.`);
13270
+ }
13271
+
13272
+ const resolvedPage = replaceCollectionCurrentRefs(page, currentItem) as ProjectState['page'];
13273
+ return {
13274
+ slug: concreteSlug,
13275
+ page: {
13276
+ ...resolvedPage,
13277
+ slug: concreteSlug,
13278
+ },
13279
+ };
13280
+ }
13281
+
13123
13282
  export function useCloudSave() {
13124
13283
  const [cloudSaveUi, setCloudSaveUi] = useState<CloudSaveUiState>(getInitialCloudSaveUiState);
13125
13284
  const activeCloudSaveController = useRef<AbortController | null>(null);
@@ -13153,12 +13312,18 @@ export function useCloudSave() {
13153
13312
  });
13154
13313
 
13155
13314
  try {
13315
+ const savePage = buildSaveStreamPagePayload(payload.state, payload.slug);
13156
13316
  await startCloudSaveStream({
13157
13317
  apiBaseUrl: CLOUD_API_URL,
13158
13318
  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`,
13319
+ path: `src/data/pages/${savePage.slug}.json`,
13320
+ content: savePage.page,
13321
+ additionalFiles: [
13322
+ { path: 'src/data/config/site.json', content: payload.state.site },
13323
+ { path: 'src/data/config/menu.json', content: payload.state.menu },
13324
+ ],
13325
+ changedScopes: ['page', 'site', 'menu'],
13326
+ message: `Content update for ${savePage.slug} via Visual Editor`,
13162
13327
  signal: controller.signal,
13163
13328
  onStep: (event) => {
13164
13329
  setCloudSaveUi((prev) => {
@@ -13322,7 +13487,7 @@ END_OF_FILE_CONTENT
13322
13487
  echo "Creating src/lib/useOlonForms.ts..."
13323
13488
  cat << 'END_OF_FILE_CONTENT' > "src/lib/useOlonForms.ts"
13324
13489
  import { useCallback, useEffect, useState } from 'react';
13325
- import type { FormState } from '@olonjs/core';
13490
+ import type { FormState } from '@olonjs/react';
13326
13491
 
13327
13492
  const API_BASE =
13328
13493
  (import.meta.env.VITE_OLONJS_CLOUD_URL as string | undefined) ??
@@ -13598,7 +13763,7 @@ export function useTenantBootstrap({
13598
13763
 
13599
13764
  const [menuConfig, setMenuConfig] = useState<MenuConfig>(menuConfigSeed);
13600
13765
 
13601
- const [themeConfig] = useState<ThemeConfig>(themeConfigSeed);
13766
+ const [themeConfig, setThemeConfig] = useState<ThemeConfig>(themeConfigSeed);
13602
13767
 
13603
13768
  const [collections, setCollections] = useState<NonNullable<JsonPagesConfig['collections']>>(EMPTY_COLLECTIONS);
13604
13769
 
@@ -14114,6 +14279,10 @@ export function useTenantBootstrap({
14114
14279
 
14115
14280
  setSiteConfig,
14116
14281
 
14282
+ setMenuConfig,
14283
+
14284
+ setThemeConfig,
14285
+
14117
14286
  setCollections,
14118
14287
 
14119
14288
  cloudApiCandidates,
@@ -1817,11 +1817,14 @@ 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.15",
1820
+ "@olonjs/core": "^1.1.17",
1821
+ "@olonjs/react": "^0.1.0",
1822
+ "@olonjs/studio": "^0.1.0",
1821
1823
  "class-variance-authority": "^0.7.1",
1822
1824
  "clsx": "^2.1.1",
1823
1825
  "lucide-react": "^0.474.0",
1824
1826
  "motion": "^12.23.24",
1827
+ "radix-ui": "^1.4.3",
1825
1828
  "react": "^19.0.0",
1826
1829
  "react-markdown": "^9.0.1",
1827
1830
  "react-dom": "^19.0.0",
@@ -2191,8 +2194,6 @@ async function readJsonFile(filePath) {
2191
2194
  }
2192
2195
 
2193
2196
  async function expandCollectionTarget(slug, pageFilePath) {
2194
- if (!/\[[^\]]+\]/.test(slug)) return [slug];
2195
-
2196
2197
  let pageConfig;
2197
2198
  try {
2198
2199
  pageConfig = await readJsonFile(pageFilePath);
@@ -2206,7 +2207,14 @@ async function expandCollectionTarget(slug, pageFilePath) {
2206
2207
  }
2207
2208
 
2208
2209
  const token = `[${binding.paramKey}]`;
2209
- if (!slug.includes(token)) return [slug];
2210
+ // Prefer authored route pattern (page.slug) when the file path is not parametric.
2211
+ const authoredSlug =
2212
+ typeof pageConfig.slug === 'string'
2213
+ ? pageConfig.slug.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
2214
+ : '';
2215
+ const routePattern =
2216
+ authoredSlug.includes(token) ? authoredSlug : slug.includes(token) ? slug : '';
2217
+ if (!routePattern) return [slug];
2210
2218
 
2211
2219
  const collectionPath = path.resolve(collectionsDir, binding.source, `${binding.source}.json`);
2212
2220
  let collection;
@@ -2218,7 +2226,9 @@ async function expandCollectionTarget(slug, pageFilePath) {
2218
2226
 
2219
2227
  if (!collection || typeof collection !== 'object' || Array.isArray(collection)) return [slug];
2220
2228
  const itemIds = Object.keys(collection).sort((a, b) => a.localeCompare(b));
2221
- return itemIds.length > 0 ? itemIds.map((itemId) => slug.replace(token, itemId)) : [slug];
2229
+ return itemIds.length > 0
2230
+ ? itemIds.map((itemId) => routePattern.replace(token, itemId))
2231
+ : [slug];
2222
2232
  }
2223
2233
 
2224
2234
  async function listJsonFilesRecursive(dir) {
@@ -2942,10 +2952,9 @@ cat << 'END_OF_FILE_CONTENT' > "src/App.tsx"
2942
2952
  * Bootstrap, persistence, and engine wiring — logic lives in lib/hooks.
2943
2953
  */
2944
2954
  import { useEffect, useMemo } from 'react';
2945
- import { JsonPagesEngine } from '@olonjs/core';
2955
+ import { JsonPagesEngine, OlonFormsContext } from '@olonjs/react';
2946
2956
  import type { JsonPagesConfig, ProjectState } from '@olonjs/core';
2947
2957
  import { withBasePath } from '@olonjs/core';
2948
- import { OlonFormsContext } from '@olonjs/core';
2949
2958
  import { ComponentRegistry } from '@/lib/ComponentRegistry';
2950
2959
  import { CollectionRegistry } from '@/lib/CollectionRegistry';
2951
2960
  import { SECTION_SCHEMAS } from '@/lib/schemas';
@@ -2981,6 +2990,7 @@ import { useAssetsManifest } from '@/lib/useAssetsManifest';
2981
2990
  import { useCloudSave } from '@/lib/useCloudSave';
2982
2991
  import { useTenantBootstrap } from '@/lib/useTenantBootstrap';
2983
2992
  import { useAdminStudioContent } from '@/lib/cloud/useAdminStudioContent';
2993
+ import { hydrateLocalProjectState } from '@/lib/hydrateLocalProjectState';
2984
2994
 
2985
2995
  import tenantCss from './index.css?inline';
2986
2996
 
@@ -3047,7 +3057,10 @@ function App() {
3047
3057
  };
3048
3058
  }, [canPaintVisitor, bootstrap.enginePages, bootstrap.siteConfig]);
3049
3059
 
3050
- const engineCollections = bootstrap.isHotSaveMode ? bootstrap.collections : fileCollections;
3060
+ const engineCollections =
3061
+ bootstrap.isHotSaveMode || Object.keys(bootstrap.collections).length > 0
3062
+ ? bootstrap.collections
3063
+ : fileCollections;
3051
3064
  const engineRefDocuments = useMemo(
3052
3065
  () => ({
3053
3066
  'menu.json': bootstrap.menuConfig,
@@ -3085,6 +3098,15 @@ function App() {
3085
3098
  });
3086
3099
  const body = (await res.json().catch(() => ({}))) as { error?: string };
3087
3100
  if (!res.ok) throw new Error(body.error ?? `Save to file failed: ${res.status}`);
3101
+ hydrateLocalProjectState({
3102
+ state,
3103
+ slug,
3104
+ setPages: bootstrap.setPages,
3105
+ setSiteConfig: bootstrap.setSiteConfig,
3106
+ setMenuConfig: bootstrap.setMenuConfig,
3107
+ setThemeConfig: bootstrap.setThemeConfig,
3108
+ setCollections: bootstrap.setCollections,
3109
+ });
3088
3110
  },
3089
3111
  async hotSave(state: ProjectState, slug: string): Promise<void> {
3090
3112
  if (!bootstrap.isCloudMode || !CLOUD_API_URL || !CLOUD_API_KEY) {
@@ -4064,7 +4086,7 @@ END_OF_FILE_CONTENT
4064
4086
  echo "Creating src/components/form-demo/View.tsx..."
4065
4087
  cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/View.tsx"
4066
4088
  import { Icon } from '@/lib/IconResolver';
4067
- import { useFormState } from '@olonjs/core';
4089
+ import { useFormState } from '@olonjs/react';
4068
4090
  import type { FormDemoData } from './types';
4069
4091
 
4070
4092
  type FormDemoViewProps = {
@@ -5609,7 +5631,8 @@ import {
5609
5631
  Code2, Quote, SquareCode,
5610
5632
  Link2, Unlink2, ImagePlus, Eraser,
5611
5633
  } from 'lucide-react';
5612
- import { STUDIO_EVENTS, useConfig, useStudio } from '@olonjs/core';
5634
+ import { STUDIO_EVENTS } from '@olonjs/core';
5635
+ import { useConfig, useStudio } from '@olonjs/react';
5613
5636
  import type { TiptapData, TiptapSettings } from './types';
5614
5637
 
5615
5638
  // ── UI primitives ─────────────────────────────────────────────────
@@ -8569,7 +8592,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/collections/libri/libri.json"
8569
8592
  "id": "1984",
8570
8593
  "title": "1984",
8571
8594
  "author": {
8572
- "$ref": "../autori/autori.json#/alessandro-baricco"
8595
+ "$ref": "../autori/autori.json#/george-orwell"
8573
8596
  },
8574
8597
  "year": 1949,
8575
8598
  "genre": "Distopia",
@@ -9673,14 +9696,8 @@ echo "Creating src/entry-ssg.tsx..."
9673
9696
  cat << 'END_OF_FILE_CONTENT' > "src/entry-ssg.tsx"
9674
9697
  import { renderToString } from 'react-dom/server';
9675
9698
  import { StaticRouter } from 'react-router-dom/server';
9676
- import {
9677
- ConfigProvider,
9678
- PageRenderer,
9679
- StudioProvider,
9680
- contract,
9681
- resolvePageMatchFromRegistry,
9682
- resolveRuntimeConfig,
9683
- } from '@olonjs/core';
9699
+ import { ConfigProvider, PageRenderer, StudioProvider } from '@olonjs/react';
9700
+ import { contract, resolvePageMatchFromRegistry, resolveRuntimeConfig } from '@olonjs/core';
9684
9701
  import type { JsonPagesConfig, PageConfig, SiteConfig, ThemeConfig } from '@/types';
9685
9702
  import { ThemeProvider } from '@/components/ThemeProvider';
9686
9703
  import { ComponentRegistry } from '@/lib/ComponentRegistry';
@@ -11432,7 +11449,6 @@ export function coerceSiteConfig(value: unknown): SiteConfig | null {
11432
11449
  }
11433
11450
  if (!isObjectRecord(input)) return null;
11434
11451
  if (!isObjectRecord(input.identity)) return null;
11435
- if (!Array.isArray(input.pages)) return null;
11436
11452
 
11437
11453
  return input as unknown as SiteConfig;
11438
11454
  }
@@ -11699,7 +11715,6 @@ export function getHydratedData(
11699
11715
  }
11700
11716
 
11701
11717
  END_OF_FILE_CONTENT
11702
- mkdir -p "src/lib/editorial"
11703
11718
  echo "Creating src/lib/getFileCollections.ts..."
11704
11719
  cat << 'END_OF_FILE_CONTENT' > "src/lib/getFileCollections.ts"
11705
11720
  import type { JsonPagesConfig } from '@/types';
@@ -11790,6 +11805,47 @@ export function getFilePages(): Record<string, PageConfig> {
11790
11805
  return record;
11791
11806
  }
11792
11807
 
11808
+ END_OF_FILE_CONTENT
11809
+ echo "Creating src/lib/hydrateLocalProjectState.ts..."
11810
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/hydrateLocalProjectState.ts"
11811
+ import type { JsonPagesConfig, ProjectState } from '@olonjs/core';
11812
+ import type { MenuConfig, PageConfig, SiteConfig, ThemeConfig } from '@/types';
11813
+
11814
+ type HydrateLocalProjectStateArgs = {
11815
+ state: ProjectState;
11816
+ slug: string;
11817
+ setPages: (updater: (prev: Record<string, PageConfig>) => Record<string, PageConfig>) => void;
11818
+ setSiteConfig: (site: SiteConfig) => void;
11819
+ setMenuConfig: (menu: MenuConfig) => void;
11820
+ setThemeConfig: (theme: ThemeConfig) => void;
11821
+ setCollections: (collections: NonNullable<JsonPagesConfig['collections']>) => void;
11822
+ };
11823
+
11824
+ /**
11825
+ * Write-through hydrate after local `/api/save-to-file`.
11826
+ * Pushes only the slices present in the saved ProjectState into bootstrap state —
11827
+ * no disk re-read, no full remount. Brand-agnostic.
11828
+ */
11829
+ export function hydrateLocalProjectState({
11830
+ state,
11831
+ slug,
11832
+ setPages,
11833
+ setSiteConfig,
11834
+ setMenuConfig,
11835
+ setThemeConfig,
11836
+ setCollections,
11837
+ }: HydrateLocalProjectStateArgs): void {
11838
+ if (state.menu != null) setMenuConfig(state.menu);
11839
+ if (state.site != null) setSiteConfig(state.site);
11840
+ if (state.theme != null) setThemeConfig(state.theme);
11841
+ if (state.page != null) {
11842
+ setPages((prev) => ({ ...prev, [slug]: state.page }));
11843
+ }
11844
+ if (state.collections != null) {
11845
+ setCollections(state.collections as NonNullable<JsonPagesConfig['collections']>);
11846
+ }
11847
+ }
11848
+
11793
11849
  END_OF_FILE_CONTENT
11794
11850
  echo "Creating src/lib/schemas.ts..."
11795
11851
  cat << 'END_OF_FILE_CONTENT' > "src/lib/schemas.ts"
@@ -12862,10 +12918,18 @@ export function buildThemeFontVarsCss(input: unknown): string {
12862
12918
  const tokens = isObjectRecord(input.tokens) ? input.tokens : null;
12863
12919
  const typography = tokens && isObjectRecord(tokens.typography) ? tokens.typography : null;
12864
12920
  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";
12921
+ const primary =
12922
+ typeof fontFamily?.primary === 'string'
12923
+ ? fontFamily.primary
12924
+ : "'Instrument Sans', system-ui, sans-serif";
12925
+ const display =
12926
+ typeof fontFamily?.display === 'string'
12927
+ ? fontFamily.display
12928
+ : typeof fontFamily?.serif === 'string'
12929
+ ? fontFamily.serif
12930
+ : "'Instrument Serif', Georgia, serif";
12867
12931
  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};}`;
12932
+ return `:root{--theme-font-primary:${primary};--theme-font-display:${display};--theme-font-mono:${mono};}`;
12869
12933
  }
12870
12934
 
12871
12935
  const REMOTE_CSS_LINK_ATTR = 'data-jp-tenant-remote-css';
@@ -12878,8 +12942,9 @@ function isRemoteStylesheetHref(value: string): boolean {
12878
12942
  export function extractLeadingRemoteCssImports(cssText: string): { hrefs: string[]; rest: string } {
12879
12943
  const hrefs = new Set<string>();
12880
12944
  const leadingTriviaPattern = /^(?:\s+|\/\*[\s\S]*?\*\/)*/;
12945
+ // Vite/Tailwind may emit `@import url("...")` or compacted `@import"https://..."`.
12881
12946
  const importPattern =
12882
- /^@import\s+url\(\s*(?:'([^']+)'|"([^"]+)"|([^'")\s][^)]*))\s*\)\s*([^;]*);/i;
12947
+ /^@import(?:\s*url\(\s*(?:'([^']+)'|"([^"]+)"|([^'")\s][^)]*))\s*\)|\s*(['"])([^'"]+)\4)\s*([^;]*);/i;
12883
12948
  let rest = cssText;
12884
12949
 
12885
12950
  for (;;) {
@@ -12891,8 +12956,8 @@ export function extractLeadingRemoteCssImports(cssText: string): { hrefs: string
12891
12956
  const match = rest.match(importPattern);
12892
12957
  if (!match) break;
12893
12958
 
12894
- const href = (match[1] ?? match[2] ?? match[3] ?? '').trim();
12895
- const trailingDirectives = (match[4] ?? '').trim();
12959
+ const href = (match[1] ?? match[2] ?? match[3] ?? match[5] ?? '').trim();
12960
+ const trailingDirectives = (match[6] ?? '').trim();
12896
12961
 
12897
12962
  if (!isRemoteStylesheetHref(href) || trailingDirectives.length > 0) {
12898
12963
  break;
@@ -13094,7 +13159,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/lib/useCloudSave.ts"
13094
13159
  import { useCallback, useEffect, useRef, useState } from 'react';
13095
13160
  import type { DeployPhase, ProjectState, StepId } from '@olonjs/core';
13096
13161
  import { DEPLOY_STEPS, startCloudSaveStream } from '@olonjs/core';
13097
- import { CLOUD_API_KEY, CLOUD_API_URL } from '@/lib/tenantEnv';
13162
+ import { APP_BASE_PATH, CLOUD_API_KEY, CLOUD_API_URL } from '@/lib/tenantEnv';
13098
13163
 
13099
13164
  interface CloudSaveUiState {
13100
13165
  isOpen: boolean;
@@ -13120,6 +13185,100 @@ function stepProgress(doneSteps: StepId[]): number {
13120
13185
  return Math.round((doneSteps.length / DEPLOY_STEPS.length) * 100);
13121
13186
  }
13122
13187
 
13188
+ function isRecord(value: unknown): value is Record<string, unknown> {
13189
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
13190
+ }
13191
+
13192
+ function cloneJson<T>(value: T): T {
13193
+ return JSON.parse(JSON.stringify(value)) as T;
13194
+ }
13195
+
13196
+ function normalizePathSegments(value: string): string {
13197
+ return value
13198
+ .split('/')
13199
+ .map((segment) => segment.trim())
13200
+ .filter(Boolean)
13201
+ .join('/');
13202
+ }
13203
+
13204
+ function resolveAdminContentSlug(): string | null {
13205
+ if (typeof window === 'undefined') return null;
13206
+
13207
+ const normalizedBase = APP_BASE_PATH.replace(/\/+$/, '');
13208
+ let path = window.location.pathname;
13209
+ if (normalizedBase && normalizedBase !== '/' && path.startsWith(normalizedBase)) {
13210
+ path = path.slice(normalizedBase.length) || '/';
13211
+ }
13212
+
13213
+ const slug = normalizePathSegments(path.replace(/^\/admin\/?/, ''));
13214
+ return slug || null;
13215
+ }
13216
+
13217
+ function resolveTemplateParamValue(templateSlug: string, concreteSlug: string, paramKey: string): string | null {
13218
+ const templateSegments = normalizePathSegments(templateSlug).split('/').filter(Boolean);
13219
+ const concreteSegments = normalizePathSegments(concreteSlug).split('/').filter(Boolean);
13220
+ if (templateSegments.length !== concreteSegments.length) return null;
13221
+
13222
+ for (let index = 0; index < templateSegments.length; index += 1) {
13223
+ const templateSegment = templateSegments[index];
13224
+ const concreteSegment = concreteSegments[index];
13225
+ const paramMatch = templateSegment.match(/^\[([A-Za-z0-9_-]+)\]$/);
13226
+ if (paramMatch?.[1] === paramKey) return concreteSegment;
13227
+ if (!paramMatch && templateSegment !== concreteSegment) return null;
13228
+ }
13229
+
13230
+ return null;
13231
+ }
13232
+
13233
+ function hasCollectionCurrentRef(value: unknown): boolean {
13234
+ if (Array.isArray(value)) return value.some(hasCollectionCurrentRef);
13235
+ if (!isRecord(value)) return false;
13236
+ if (value.$ref === 'collection:current') return true;
13237
+ return Object.values(value).some(hasCollectionCurrentRef);
13238
+ }
13239
+
13240
+ function replaceCollectionCurrentRefs(value: unknown, currentItem: unknown): unknown {
13241
+ if (Array.isArray(value)) return value.map((item) => replaceCollectionCurrentRefs(item, currentItem));
13242
+ if (!isRecord(value)) return value;
13243
+ if (value.$ref === 'collection:current') return cloneJson(currentItem);
13244
+ return Object.fromEntries(
13245
+ Object.entries(value).map(([key, entryValue]) => [key, replaceCollectionCurrentRefs(entryValue, currentItem)]),
13246
+ );
13247
+ }
13248
+
13249
+ function buildSaveStreamPagePayload(state: ProjectState, fallbackSlug: string): { slug: string; page: ProjectState['page'] } {
13250
+ const page = state.page;
13251
+ const collection = page.collection;
13252
+ if (!collection || !hasCollectionCurrentRef(page)) {
13253
+ return { slug: fallbackSlug, page };
13254
+ }
13255
+
13256
+ const concreteSlug = resolveAdminContentSlug();
13257
+ if (!concreteSlug) {
13258
+ throw new Error('Cannot resolve concrete admin route for collection page save.');
13259
+ }
13260
+
13261
+ const paramValue = resolveTemplateParamValue(page.slug, concreteSlug, collection.paramKey);
13262
+ if (!paramValue) {
13263
+ throw new Error(`Cannot resolve collection param "${collection.paramKey}" from route "${concreteSlug}".`);
13264
+ }
13265
+
13266
+ const collectionDocument = state.collections?.[collection.source];
13267
+ const currentItem = isRecord(collectionDocument) ? collectionDocument[paramValue] : undefined;
13268
+ if (!currentItem) {
13269
+ throw new Error(`Cannot resolve collection item "${collection.source}/${paramValue}" for save.`);
13270
+ }
13271
+
13272
+ const resolvedPage = replaceCollectionCurrentRefs(page, currentItem) as ProjectState['page'];
13273
+ return {
13274
+ slug: concreteSlug,
13275
+ page: {
13276
+ ...resolvedPage,
13277
+ slug: concreteSlug,
13278
+ },
13279
+ };
13280
+ }
13281
+
13123
13282
  export function useCloudSave() {
13124
13283
  const [cloudSaveUi, setCloudSaveUi] = useState<CloudSaveUiState>(getInitialCloudSaveUiState);
13125
13284
  const activeCloudSaveController = useRef<AbortController | null>(null);
@@ -13153,12 +13312,18 @@ export function useCloudSave() {
13153
13312
  });
13154
13313
 
13155
13314
  try {
13315
+ const savePage = buildSaveStreamPagePayload(payload.state, payload.slug);
13156
13316
  await startCloudSaveStream({
13157
13317
  apiBaseUrl: CLOUD_API_URL,
13158
13318
  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`,
13319
+ path: `src/data/pages/${savePage.slug}.json`,
13320
+ content: savePage.page,
13321
+ additionalFiles: [
13322
+ { path: 'src/data/config/site.json', content: payload.state.site },
13323
+ { path: 'src/data/config/menu.json', content: payload.state.menu },
13324
+ ],
13325
+ changedScopes: ['page', 'site', 'menu'],
13326
+ message: `Content update for ${savePage.slug} via Visual Editor`,
13162
13327
  signal: controller.signal,
13163
13328
  onStep: (event) => {
13164
13329
  setCloudSaveUi((prev) => {
@@ -13322,7 +13487,7 @@ END_OF_FILE_CONTENT
13322
13487
  echo "Creating src/lib/useOlonForms.ts..."
13323
13488
  cat << 'END_OF_FILE_CONTENT' > "src/lib/useOlonForms.ts"
13324
13489
  import { useCallback, useEffect, useState } from 'react';
13325
- import type { FormState } from '@olonjs/core';
13490
+ import type { FormState } from '@olonjs/react';
13326
13491
 
13327
13492
  const API_BASE =
13328
13493
  (import.meta.env.VITE_OLONJS_CLOUD_URL as string | undefined) ??
@@ -13598,7 +13763,7 @@ export function useTenantBootstrap({
13598
13763
 
13599
13764
  const [menuConfig, setMenuConfig] = useState<MenuConfig>(menuConfigSeed);
13600
13765
 
13601
- const [themeConfig] = useState<ThemeConfig>(themeConfigSeed);
13766
+ const [themeConfig, setThemeConfig] = useState<ThemeConfig>(themeConfigSeed);
13602
13767
 
13603
13768
  const [collections, setCollections] = useState<NonNullable<JsonPagesConfig['collections']>>(EMPTY_COLLECTIONS);
13604
13769
 
@@ -14114,6 +14279,10 @@ export function useTenantBootstrap({
14114
14279
 
14115
14280
  setSiteConfig,
14116
14281
 
14282
+ setMenuConfig,
14283
+
14284
+ setThemeConfig,
14285
+
14117
14286
  setCollections,
14118
14287
 
14119
14288
  cloudApiCandidates,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olonjs/cli",
3
- "version": "3.0.148",
3
+ "version": "3.0.150",
4
4
  "description": "The Sovereign CLI Engine for OlonJS.",
5
5
  "type": "module",
6
6
  "bin": {