@jsonpages/cli 3.0.75 → 3.0.76

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.
@@ -596,7 +596,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
596
596
  "@tiptap/extension-link": "^2.11.5",
597
597
  "@tiptap/react": "^2.11.5",
598
598
  "@tiptap/starter-kit": "^2.11.5",
599
- "@jsonpages/core": "^1.0.62",
599
+ "@jsonpages/core": "^1.0.63",
600
600
  "clsx": "^2.1.1",
601
601
  "lucide-react": "^0.474.0",
602
602
  "react": "^19.0.0",
@@ -925,14 +925,14 @@ cat << 'END_OF_FILE_CONTENT' > "src/App.tsx"
925
925
  * Data from getHydratedData (file-backed or draft); assets from public/assets/images.
926
926
  * Supports Hybrid Persistence: Local Filesystem (Dev) or Cloud Bridge (Prod).
927
927
  */
928
- import { useCallback, useEffect, useRef, useState } from 'react';
928
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
929
929
  import { JsonPagesEngine } from '@jsonpages/core';
930
930
  import type { JsonPagesConfig, LibraryImageEntry, ProjectState } from '@jsonpages/core';
931
931
  import { ComponentRegistry } from '@/lib/ComponentRegistry';
932
932
  import { SECTION_SCHEMAS } from '@/lib/schemas';
933
933
  import { addSectionConfig } from '@/lib/addSectionConfig';
934
934
  import { getHydratedData } from '@/lib/draftStorage';
935
- import type { SiteConfig, ThemeConfig, MenuConfig } from '@/types';
935
+ import type { SiteConfig, ThemeConfig, MenuConfig, PageConfig } from '@/types';
936
936
  import type { DeployPhase, StepId } from '@/types/deploy';
937
937
  import { DEPLOY_STEPS } from '@/lib/deploySteps';
938
938
  import { startCloudSaveStream } from '@/lib/cloudSaveStream';
@@ -942,7 +942,6 @@ import menuData from '@/data/config/menu.json';
942
942
  import { getFilePages } from '@/lib/getFilePages';
943
943
  import { DopaDrawer } from '@/components/save-drawer/DopaDrawer';
944
944
 
945
- import fontsCss from './fonts.css?inline';
946
945
  import tenantCss from './index.css?inline';
947
946
 
948
947
  // Cloud Configuration (Injected by Vercel/Netlify Env Vars)
@@ -967,6 +966,44 @@ interface CloudSaveUiState {
967
966
  deployUrl?: string;
968
967
  }
969
968
 
969
+ type ContentMode = 'cloud' | 'error';
970
+ type ContentStatus = 'ok' | 'empty_namespace' | 'legacy_fallback';
971
+
972
+ type ContentResponse = {
973
+ ok?: boolean;
974
+ siteConfig?: unknown;
975
+ pages?: unknown;
976
+ items?: unknown;
977
+ error?: string;
978
+ code?: string;
979
+ correlationId?: string;
980
+ contentStatus?: ContentStatus;
981
+ usedUnscopedFallback?: boolean;
982
+ namespace?: string;
983
+ namespaceMatchedKeys?: number;
984
+ };
985
+
986
+ type CachedCloudContent = {
987
+ keyFingerprint: string;
988
+ savedAt: number;
989
+ siteConfig: unknown | null;
990
+ pages: Record<string, unknown>;
991
+ };
992
+
993
+ const CLOUD_CACHE_KEY = 'jp_cloud_content_cache_v1';
994
+ const CLOUD_CACHE_TTL_MS = 5 * 60 * 1000;
995
+
996
+ function normalizeApiBase(raw: string): string {
997
+ return raw.trim().replace(/\/+$/, '');
998
+ }
999
+
1000
+ function buildApiCandidates(raw: string): string[] {
1001
+ const base = normalizeApiBase(raw);
1002
+ const withApi = /\/api\/v1$/i.test(base) ? base : `${base}/api/v1`;
1003
+ const candidates = [withApi, base];
1004
+ return Array.from(new Set(candidates.filter(Boolean)));
1005
+ }
1006
+
970
1007
  function getInitialData() {
971
1008
  return getHydratedData(TENANT_ID, filePages, fileSiteConfig);
972
1009
  }
@@ -985,13 +1022,235 @@ function stepProgress(doneSteps: StepId[]): number {
985
1022
  return Math.round((doneSteps.length / DEPLOY_STEPS.length) * 100);
986
1023
  }
987
1024
 
1025
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
1026
+ return typeof value === 'object' && value !== null;
1027
+ }
1028
+
1029
+ function asString(value: unknown, fallback: string): string {
1030
+ return typeof value === 'string' && value.trim() ? value : fallback;
1031
+ }
1032
+
1033
+ function coercePageConfig(slug: string, value: unknown): PageConfig | null {
1034
+ let input = value;
1035
+ if (typeof input === 'string') {
1036
+ try {
1037
+ input = JSON.parse(input) as unknown;
1038
+ } catch {
1039
+ return null;
1040
+ }
1041
+ }
1042
+ if (!isObjectRecord(input) || !Array.isArray(input.sections)) return null;
1043
+
1044
+ const inputMeta = isObjectRecord(input.meta) ? input.meta : {};
1045
+ const normalizedSlug = asString(input.slug, slug);
1046
+ const normalizedId = asString(input.id, `${normalizedSlug}-page`);
1047
+ const title = asString(inputMeta.title, normalizedSlug);
1048
+ const description = asString(inputMeta.description, '');
1049
+
1050
+ return {
1051
+ id: normalizedId,
1052
+ slug: normalizedSlug,
1053
+ meta: { title, description },
1054
+ sections: input.sections as PageConfig['sections'],
1055
+ };
1056
+ }
1057
+
1058
+ function coerceSiteConfig(value: unknown): SiteConfig | null {
1059
+ let input = value;
1060
+ if (typeof input === 'string') {
1061
+ try {
1062
+ input = JSON.parse(input) as unknown;
1063
+ } catch {
1064
+ return null;
1065
+ }
1066
+ }
1067
+ if (!isObjectRecord(input)) return null;
1068
+ if (!isObjectRecord(input.identity)) return null;
1069
+ if (!Array.isArray(input.pages)) return null;
1070
+
1071
+ return input as unknown as SiteConfig;
1072
+ }
1073
+
1074
+ function toPagesRecord(value: unknown): Record<string, PageConfig> | null {
1075
+ const directPage = coercePageConfig('home', value);
1076
+ if (directPage) {
1077
+ const directSlug = asString(directPage.slug, 'home')
1078
+ .toLowerCase()
1079
+ .replace(/[^a-z0-9/_-]/g, '-')
1080
+ .replace(/^\/+|\/+$/g, '') || 'home';
1081
+ return { [directSlug]: directPage };
1082
+ }
1083
+
1084
+ if (!isObjectRecord(value)) return null;
1085
+ const next: Record<string, PageConfig> = {};
1086
+ for (const [rawKey, payload] of Object.entries(value)) {
1087
+ const rawKeyTrimmed = rawKey.trim();
1088
+ const slugFromNamespacedKey = rawKeyTrimmed.match(/^t_[a-z0-9-]+_page_(.+)$/i)?.[1];
1089
+ const normalizedSlug = (slugFromNamespacedKey ?? rawKeyTrimmed)
1090
+ .toLowerCase()
1091
+ .replace(/[^a-z0-9/_-]/g, '-')
1092
+ .replace(/^\/+|\/+$/g, '');
1093
+ const slug = normalizedSlug || 'home';
1094
+ const page = coercePageConfig(slug, payload);
1095
+ if (!page) continue;
1096
+ next[slug] = page;
1097
+ }
1098
+ return next;
1099
+ }
1100
+
1101
+ function normalizePageRegistry(value: unknown): Record<string, PageConfig> {
1102
+ if (!isObjectRecord(value)) return {};
1103
+ const normalized: Record<string, PageConfig> = {};
1104
+
1105
+ for (const [registrySlug, rawPageValue] of Object.entries(value)) {
1106
+ const direct = coercePageConfig(registrySlug, rawPageValue);
1107
+ if (direct) {
1108
+ normalized[direct.slug || registrySlug] = direct;
1109
+ continue;
1110
+ }
1111
+
1112
+ const nested = toPagesRecord(rawPageValue);
1113
+ if (nested && Object.keys(nested).length > 0) {
1114
+ Object.assign(normalized, nested);
1115
+ }
1116
+ }
1117
+
1118
+ return normalized;
1119
+ }
1120
+
1121
+ function extractContentSources(payload: ContentResponse | Record<string, unknown>): {
1122
+ pagesSource: unknown;
1123
+ siteSource: unknown;
1124
+ } {
1125
+ // Canonical contract: { pages, siteConfig }
1126
+ if (isObjectRecord(payload) && isObjectRecord(payload.pages)) {
1127
+ return { pagesSource: payload.pages, siteSource: payload.siteConfig };
1128
+ }
1129
+
1130
+ // Edge public JSON contract: { digest, updatedAt, items: { ... } }
1131
+ if (isObjectRecord(payload) && isObjectRecord(payload.items)) {
1132
+ const items = payload.items;
1133
+ let siteSource: unknown = null;
1134
+ const pageEntries: Record<string, unknown> = {};
1135
+ for (const [key, value] of Object.entries(items)) {
1136
+ if (/(_config_site|config_site|config:site)$/i.test(key)) {
1137
+ siteSource = value;
1138
+ continue;
1139
+ }
1140
+ if (/(_page_|^page_|page:)/i.test(key)) {
1141
+ pageEntries[key] = value;
1142
+ }
1143
+ }
1144
+ return { pagesSource: pageEntries, siteSource };
1145
+ }
1146
+
1147
+ // Raw map fallback: treat payload object itself as page map.
1148
+ return { pagesSource: payload, siteSource: null };
1149
+ }
1150
+
1151
+ type CloudLoadFailure = {
1152
+ reasonCode: string;
1153
+ message: string;
1154
+ correlationId?: string;
1155
+ };
1156
+
1157
+ function isCloudLoadFailure(value: unknown): value is CloudLoadFailure {
1158
+ return (
1159
+ isObjectRecord(value) &&
1160
+ typeof value.reasonCode === 'string' &&
1161
+ typeof value.message === 'string'
1162
+ );
1163
+ }
1164
+
1165
+ function toCloudLoadFailure(value: unknown): CloudLoadFailure {
1166
+ if (isCloudLoadFailure(value)) return value;
1167
+ if (value instanceof Error) {
1168
+ return { reasonCode: 'CLOUD_LOAD_FAILED', message: value.message };
1169
+ }
1170
+ return { reasonCode: 'CLOUD_LOAD_FAILED', message: 'Cloud content unavailable.' };
1171
+ }
1172
+
1173
+ function sleep(ms: number): Promise<void> {
1174
+ return new Promise((resolve) => setTimeout(resolve, ms));
1175
+ }
1176
+
1177
+ function isRetryableStatus(status: number): boolean {
1178
+ return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
1179
+ }
1180
+
1181
+ function backoffDelayMs(attempt: number): number {
1182
+ const base = 250 * Math.pow(2, attempt);
1183
+ const jitter = Math.floor(Math.random() * 120);
1184
+ return base + jitter;
1185
+ }
1186
+
1187
+ function logBootstrapEvent(event: string, details: Record<string, unknown>) {
1188
+ console.info('[boot]', { event, at: new Date().toISOString(), ...details });
1189
+ }
1190
+
1191
+ function cloudFingerprint(apiBase: string, apiKey: string): string {
1192
+ return `${normalizeApiBase(apiBase)}::${apiKey.slice(-8)}`;
1193
+ }
1194
+
1195
+ function readCachedCloudContent(fingerprint: string): CachedCloudContent | null {
1196
+ try {
1197
+ const raw = localStorage.getItem(CLOUD_CACHE_KEY);
1198
+ if (!raw) return null;
1199
+ const parsed = JSON.parse(raw) as CachedCloudContent;
1200
+ if (!parsed || parsed.keyFingerprint !== fingerprint) return null;
1201
+ if (!parsed.savedAt || Date.now() - parsed.savedAt > CLOUD_CACHE_TTL_MS) return null;
1202
+ return parsed;
1203
+ } catch {
1204
+ return null;
1205
+ }
1206
+ }
1207
+
1208
+ function writeCachedCloudContent(entry: CachedCloudContent): void {
1209
+ try {
1210
+ localStorage.setItem(CLOUD_CACHE_KEY, JSON.stringify(entry));
1211
+ } catch {
1212
+ // non-blocking cache path
1213
+ }
1214
+ }
1215
+
1216
+ function buildThemeFontVarsCss(input: unknown): string {
1217
+ if (!isObjectRecord(input)) return '';
1218
+ const tokens = isObjectRecord(input.tokens) ? input.tokens : null;
1219
+ const typography = tokens && isObjectRecord(tokens.typography) ? tokens.typography : null;
1220
+ const fontFamily = typography && isObjectRecord(typography.fontFamily) ? typography.fontFamily : null;
1221
+ const primary = typeof fontFamily?.primary === 'string' ? fontFamily.primary : "'Instrument Sans', system-ui, sans-serif";
1222
+ const serif = typeof fontFamily?.serif === 'string' ? fontFamily.serif : "'Instrument Serif', Georgia, serif";
1223
+ const mono = typeof fontFamily?.mono === 'string' ? fontFamily.mono : "'JetBrains Mono', monospace";
1224
+ return `:root{--theme-font-primary:${primary};--theme-font-serif:${serif};--theme-font-mono:${mono};}`;
1225
+ }
1226
+
988
1227
  function App() {
989
- const [{ pages, siteConfig }] = useState(getInitialData);
1228
+ const isCloudMode = Boolean(CLOUD_API_URL && CLOUD_API_KEY);
1229
+ const localInitialData = useMemo(() => (isCloudMode ? null : getInitialData()), [isCloudMode]);
1230
+ const localInitialPages = useMemo(() => {
1231
+ if (!localInitialData) return {};
1232
+ const normalized = normalizePageRegistry(localInitialData.pages as unknown);
1233
+ return Object.keys(normalized).length > 0 ? normalized : localInitialData.pages;
1234
+ }, [localInitialData]);
1235
+ const [pages, setPages] = useState<Record<string, PageConfig>>(localInitialPages);
1236
+ const [siteConfig, setSiteConfig] = useState<SiteConfig>(
1237
+ localInitialData?.siteConfig ?? fileSiteConfig
1238
+ );
990
1239
  const [assetsManifest, setAssetsManifest] = useState<LibraryImageEntry[]>([]);
991
1240
  const [cloudSaveUi, setCloudSaveUi] = useState<CloudSaveUiState>(getInitialCloudSaveUiState);
1241
+ const [contentMode, setContentMode] = useState<ContentMode>('cloud');
1242
+ const [contentFallback, setContentFallback] = useState<CloudLoadFailure | null>(null);
1243
+ const [showTopProgress, setShowTopProgress] = useState(false);
1244
+ const [hasInitialCloudResolved, setHasInitialCloudResolved] = useState(!isCloudMode);
1245
+ const [bootstrapRunId, setBootstrapRunId] = useState(0);
992
1246
  const activeCloudSaveController = useRef<AbortController | null>(null);
1247
+ const contentLoadInFlight = useRef<Promise<void> | null>(null);
993
1248
  const pendingCloudSave = useRef<{ state: ProjectState; slug: string } | null>(null);
994
- const isCloudMode = Boolean(CLOUD_API_URL && CLOUD_API_KEY);
1249
+ const warmBootFromCloudCache = useRef(false);
1250
+ const cloudApiCandidates = useMemo(
1251
+ () => (isCloudMode && CLOUD_API_URL ? buildApiCandidates(CLOUD_API_URL) : []),
1252
+ [isCloudMode, CLOUD_API_URL]
1253
+ );
995
1254
 
996
1255
  useEffect(() => {
997
1256
  // In Cloud mode, listing assets might be different or disabled for MVP
@@ -1008,6 +1267,192 @@ function App() {
1008
1267
  };
1009
1268
  }, []);
1010
1269
 
1270
+ useEffect(() => {
1271
+ if (!isCloudMode || !CLOUD_API_URL || !CLOUD_API_KEY) {
1272
+ setContentMode('cloud');
1273
+ setContentFallback(null);
1274
+ setShowTopProgress(false);
1275
+ setHasInitialCloudResolved(true);
1276
+ logBootstrapEvent('boot.local.ready', { mode: 'local' });
1277
+ return;
1278
+ }
1279
+ if (contentLoadInFlight.current) {
1280
+ return;
1281
+ }
1282
+
1283
+ const controller = new AbortController();
1284
+ const maxRetryAttempts = 2;
1285
+ const startedAt = Date.now();
1286
+ const primaryApiBase = cloudApiCandidates[0] ?? normalizeApiBase(CLOUD_API_URL);
1287
+ const fingerprint = cloudFingerprint(primaryApiBase, CLOUD_API_KEY);
1288
+ const cached = readCachedCloudContent(fingerprint);
1289
+ if (cached) {
1290
+ const cachedPages = toPagesRecord(cached.pages);
1291
+ const cachedSite = coerceSiteConfig(cached.siteConfig);
1292
+ if (cachedPages && Object.keys(cachedPages).length > 0) {
1293
+ setPages(cachedPages);
1294
+ }
1295
+ if (cachedSite) {
1296
+ setSiteConfig(cachedSite);
1297
+ }
1298
+ setContentMode('cloud');
1299
+ setContentFallback(null);
1300
+ warmBootFromCloudCache.current = true;
1301
+ setShowTopProgress(false);
1302
+ setHasInitialCloudResolved(true);
1303
+ logBootstrapEvent('boot.cloud.cache_hit', { ageMs: Date.now() - cached.savedAt });
1304
+ } else {
1305
+ warmBootFromCloudCache.current = false;
1306
+ setShowTopProgress(true);
1307
+ setHasInitialCloudResolved(false);
1308
+ }
1309
+ logBootstrapEvent('boot.start', { mode: 'cloud', apiCandidates: cloudApiCandidates.length });
1310
+
1311
+ const loadCloudContent = async () => {
1312
+ try {
1313
+ let payload: ContentResponse | null = null;
1314
+ let lastFailure: CloudLoadFailure | null = null;
1315
+
1316
+ for (const apiBase of cloudApiCandidates) {
1317
+ for (let attempt = 0; attempt <= maxRetryAttempts; attempt += 1) {
1318
+ try {
1319
+ const res = await fetch(`${apiBase}/content`, {
1320
+ method: 'GET',
1321
+ headers: {
1322
+ Authorization: `Bearer ${CLOUD_API_KEY}`,
1323
+ },
1324
+ signal: controller.signal,
1325
+ });
1326
+
1327
+ const contentType = (res.headers.get('content-type') || '').toLowerCase();
1328
+ if (!contentType.includes('application/json')) {
1329
+ lastFailure = {
1330
+ reasonCode: 'NON_JSON_RESPONSE',
1331
+ message: `Non-JSON response from ${apiBase}/content`,
1332
+ };
1333
+ break;
1334
+ }
1335
+
1336
+ const parsed = (await res.json().catch(() => ({}))) as ContentResponse;
1337
+ if (!res.ok) {
1338
+ lastFailure = {
1339
+ reasonCode: parsed.code || `HTTP_${res.status}`,
1340
+ message: parsed.error || `Cloud content read failed: ${res.status} (${apiBase}/content)`,
1341
+ correlationId: parsed.correlationId,
1342
+ };
1343
+ if (isRetryableStatus(res.status) && attempt < maxRetryAttempts) {
1344
+ await sleep(backoffDelayMs(attempt));
1345
+ continue;
1346
+ }
1347
+ break;
1348
+ }
1349
+
1350
+ payload = parsed;
1351
+ break;
1352
+ } catch (error: unknown) {
1353
+ if (controller.signal.aborted) throw error;
1354
+ const message = error instanceof Error ? error.message : 'Network error';
1355
+ lastFailure = {
1356
+ reasonCode: 'NETWORK_TRANSIENT',
1357
+ message: `${message} (${apiBase}/content)`,
1358
+ };
1359
+ if (attempt < maxRetryAttempts) {
1360
+ await sleep(backoffDelayMs(attempt));
1361
+ continue;
1362
+ }
1363
+ }
1364
+ }
1365
+ if (payload) {
1366
+ break;
1367
+ }
1368
+ }
1369
+
1370
+ if (!payload) {
1371
+ throw (
1372
+ lastFailure || {
1373
+ reasonCode: 'CLOUD_ENDPOINT_UNREACHABLE',
1374
+ message: 'Cloud content endpoint not reachable as JSON.',
1375
+ }
1376
+ );
1377
+ }
1378
+
1379
+ const { pagesSource, siteSource } = extractContentSources(payload);
1380
+ const remotePages = toPagesRecord(pagesSource);
1381
+ const remoteSite = coerceSiteConfig(siteSource);
1382
+ const remotePageCount = remotePages ? Object.keys(remotePages).length : 0;
1383
+ if (remotePageCount === 0 && !remoteSite) {
1384
+ throw {
1385
+ reasonCode: payload.contentStatus === 'empty_namespace' ? 'EMPTY_NAMESPACE' : 'EMPTY_PAYLOAD',
1386
+ message: 'Cloud payload is empty for this tenant namespace.',
1387
+ correlationId: payload.correlationId,
1388
+ } satisfies CloudLoadFailure;
1389
+ }
1390
+ if (import.meta.env.DEV) {
1391
+ console.info('[content] cloud diagnostics', {
1392
+ contentStatus: payload.contentStatus ?? 'ok',
1393
+ namespace: payload.namespace,
1394
+ namespaceMatchedKeys: payload.namespaceMatchedKeys,
1395
+ usedUnscopedFallback: payload.usedUnscopedFallback,
1396
+ correlationId: payload.correlationId,
1397
+ });
1398
+ }
1399
+ if (remotePages && remotePageCount > 0) {
1400
+ setPages(remotePages);
1401
+ }
1402
+ if (remoteSite) {
1403
+ setSiteConfig(remoteSite);
1404
+ }
1405
+ writeCachedCloudContent({
1406
+ keyFingerprint: fingerprint,
1407
+ savedAt: Date.now(),
1408
+ siteConfig: remoteSite ?? null,
1409
+ pages: (remotePages ?? {}) as Record<string, unknown>,
1410
+ });
1411
+ setContentMode('cloud');
1412
+ setContentFallback(null);
1413
+ setHasInitialCloudResolved(true);
1414
+ logBootstrapEvent('boot.cloud.success', {
1415
+ mode: 'cloud',
1416
+ elapsedMs: Date.now() - startedAt,
1417
+ contentStatus: payload.contentStatus ?? 'ok',
1418
+ correlationId: payload.correlationId ?? null,
1419
+ });
1420
+ } catch (error: unknown) {
1421
+ if (controller.signal.aborted) return;
1422
+ const failure = toCloudLoadFailure(error);
1423
+ if (warmBootFromCloudCache.current) {
1424
+ setContentMode('cloud');
1425
+ setContentFallback({
1426
+ reasonCode: 'CLOUD_REFRESH_FAILED',
1427
+ message: failure.message,
1428
+ correlationId: failure.correlationId,
1429
+ });
1430
+ setHasInitialCloudResolved(true);
1431
+ } else {
1432
+ setContentMode('error');
1433
+ setContentFallback(failure);
1434
+ setHasInitialCloudResolved(true);
1435
+ }
1436
+ logBootstrapEvent('boot.cloud.error', {
1437
+ mode: 'cloud',
1438
+ elapsedMs: Date.now() - startedAt,
1439
+ reasonCode: failure.reasonCode,
1440
+ correlationId: failure.correlationId ?? null,
1441
+ });
1442
+ }
1443
+ };
1444
+
1445
+ let inFlight: Promise<void> | null = null;
1446
+ inFlight = loadCloudContent().finally(() => {
1447
+ setShowTopProgress(false);
1448
+ if (contentLoadInFlight.current === inFlight) {
1449
+ contentLoadInFlight.current = null;
1450
+ }
1451
+ });
1452
+ contentLoadInFlight.current = inFlight;
1453
+ return () => controller.abort();
1454
+ }, [isCloudMode, CLOUD_API_KEY, CLOUD_API_URL, cloudApiCandidates, bootstrapRunId]);
1455
+
1011
1456
  const runCloudSave = useCallback(
1012
1457
  async (
1013
1458
  payload: { state: ProjectState; slug: string },
@@ -1114,7 +1559,7 @@ function App() {
1114
1559
  siteConfig,
1115
1560
  themeConfig,
1116
1561
  menuConfig,
1117
- themeCss: { tenant: fontsCss + '\n' + tenantCss },
1562
+ themeCss: { tenant: `${buildThemeFontVarsCss(themeConfig)}\n${tenantCss}` },
1118
1563
  addSection: addSectionConfig,
1119
1564
  persistence: {
1120
1565
  async saveToFile(state: ProjectState, slug: string): Promise<void> {
@@ -1134,7 +1579,7 @@ function App() {
1134
1579
  throw new Error('Cloud mode is not configured for hot save.');
1135
1580
  }
1136
1581
  const apiBase = CLOUD_API_URL.replace(/\/$/, '');
1137
- const res = await fetch(`${apiBase}/save2edge`, {
1582
+ const res = await fetch(`${apiBase}/hotSave`, {
1138
1583
  method: 'POST',
1139
1584
  headers: {
1140
1585
  'Content-Type': 'application/json',
@@ -1184,9 +1629,98 @@ function App() {
1184
1629
  },
1185
1630
  };
1186
1631
 
1632
+ const shouldRenderEngine = !isCloudMode || hasInitialCloudResolved;
1633
+
1187
1634
  return (
1188
1635
  <>
1189
- <JsonPagesEngine config={config} />
1636
+ {isCloudMode && showTopProgress ? (
1637
+ <>
1638
+ <style>
1639
+ {`@keyframes jp-top-progress-slide { 0% { transform: translateX(-120%); } 100% { transform: translateX(320%); } }`}
1640
+ </style>
1641
+ <div
1642
+ role="status"
1643
+ aria-live="polite"
1644
+ aria-label="Cloud loading progress"
1645
+ style={{
1646
+ position: 'fixed',
1647
+ top: 0,
1648
+ left: 0,
1649
+ right: 0,
1650
+ height: 2,
1651
+ zIndex: 1300,
1652
+ background: 'rgba(255,255,255,0.08)',
1653
+ overflow: 'hidden',
1654
+ }}
1655
+ >
1656
+ <div
1657
+ style={{
1658
+ width: '32%',
1659
+ height: '100%',
1660
+ background: 'linear-gradient(90deg, rgba(88,166,255,0.15) 0%, rgba(88,166,255,0.85) 50%, rgba(88,166,255,0.15) 100%)',
1661
+ animation: 'jp-top-progress-slide 1.15s ease-in-out infinite',
1662
+ willChange: 'transform',
1663
+ }}
1664
+ />
1665
+ </div>
1666
+ </>
1667
+ ) : null}
1668
+ {shouldRenderEngine ? <JsonPagesEngine config={config} /> : null}
1669
+ {isCloudMode && (contentMode === 'error' || contentFallback?.reasonCode === 'CLOUD_REFRESH_FAILED') ? (
1670
+ <div
1671
+ role="status"
1672
+ aria-live="polite"
1673
+ style={{
1674
+ position: 'fixed',
1675
+ top: 12,
1676
+ right: 12,
1677
+ zIndex: 1200,
1678
+ background: 'rgba(179, 65, 24, 0.92)',
1679
+ border: '1px solid rgba(255,255,255,0.18)',
1680
+ color: '#fff',
1681
+ padding: '8px 12px',
1682
+ borderRadius: 10,
1683
+ fontSize: 12,
1684
+ maxWidth: 360,
1685
+ boxShadow: '0 8px 24px rgba(0,0,0,0.25)',
1686
+ }}
1687
+ >
1688
+ {contentMode === 'error' ? 'Cloud content unavailable.' : 'Cloud refresh failed, showing cached content.'}
1689
+ {contentFallback ? (
1690
+ <div style={{ opacity: 0.85, marginTop: 4 }}>
1691
+ <div>{contentFallback.message}</div>
1692
+ <div style={{ marginTop: 2 }}>
1693
+ Reason: {contentFallback.reasonCode}
1694
+ {contentFallback.correlationId ? ` | Correlation: ${contentFallback.correlationId}` : ''}
1695
+ </div>
1696
+ <div style={{ marginTop: 8 }}>
1697
+ <button
1698
+ type="button"
1699
+ onClick={() => {
1700
+ contentLoadInFlight.current = null;
1701
+ setContentMode('cloud');
1702
+ setContentFallback(null);
1703
+ setHasInitialCloudResolved(false);
1704
+ setShowTopProgress(true);
1705
+ setBootstrapRunId((prev) => prev + 1);
1706
+ }}
1707
+ style={{
1708
+ border: '1px solid rgba(255,255,255,0.3)',
1709
+ borderRadius: 8,
1710
+ padding: '4px 10px',
1711
+ background: 'transparent',
1712
+ color: '#fff',
1713
+ cursor: 'pointer',
1714
+ fontSize: 12,
1715
+ }}
1716
+ >
1717
+ Retry
1718
+ </button>
1719
+ </div>
1720
+ </div>
1721
+ ) : null}
1722
+ </div>
1723
+ ) : null}
1190
1724
  <DopaDrawer
1191
1725
  isOpen={cloudSaveUi.isOpen}
1192
1726
  phase={cloudSaveUi.phase}
@@ -1206,6 +1740,7 @@ export default App;
1206
1740
 
1207
1741
 
1208
1742
  END_OF_FILE_CONTENT
1743
+ # SKIP: src/App.tsx:Zone.Identifier is binary and cannot be embedded as text.
1209
1744
  echo "Creating src/App_.tsx..."
1210
1745
  cat << 'END_OF_FILE_CONTENT' > "src/App_.tsx"
1211
1746
  import { useState, useEffect } from 'react';
@@ -596,7 +596,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
596
596
  "@tiptap/extension-link": "^2.11.5",
597
597
  "@tiptap/react": "^2.11.5",
598
598
  "@tiptap/starter-kit": "^2.11.5",
599
- "@jsonpages/core": "^1.0.62",
599
+ "@jsonpages/core": "^1.0.63",
600
600
  "clsx": "^2.1.1",
601
601
  "lucide-react": "^0.474.0",
602
602
  "react": "^19.0.0",
@@ -925,14 +925,14 @@ cat << 'END_OF_FILE_CONTENT' > "src/App.tsx"
925
925
  * Data from getHydratedData (file-backed or draft); assets from public/assets/images.
926
926
  * Supports Hybrid Persistence: Local Filesystem (Dev) or Cloud Bridge (Prod).
927
927
  */
928
- import { useCallback, useEffect, useRef, useState } from 'react';
928
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
929
929
  import { JsonPagesEngine } from '@jsonpages/core';
930
930
  import type { JsonPagesConfig, LibraryImageEntry, ProjectState } from '@jsonpages/core';
931
931
  import { ComponentRegistry } from '@/lib/ComponentRegistry';
932
932
  import { SECTION_SCHEMAS } from '@/lib/schemas';
933
933
  import { addSectionConfig } from '@/lib/addSectionConfig';
934
934
  import { getHydratedData } from '@/lib/draftStorage';
935
- import type { SiteConfig, ThemeConfig, MenuConfig } from '@/types';
935
+ import type { SiteConfig, ThemeConfig, MenuConfig, PageConfig } from '@/types';
936
936
  import type { DeployPhase, StepId } from '@/types/deploy';
937
937
  import { DEPLOY_STEPS } from '@/lib/deploySteps';
938
938
  import { startCloudSaveStream } from '@/lib/cloudSaveStream';
@@ -942,7 +942,6 @@ import menuData from '@/data/config/menu.json';
942
942
  import { getFilePages } from '@/lib/getFilePages';
943
943
  import { DopaDrawer } from '@/components/save-drawer/DopaDrawer';
944
944
 
945
- import fontsCss from './fonts.css?inline';
946
945
  import tenantCss from './index.css?inline';
947
946
 
948
947
  // Cloud Configuration (Injected by Vercel/Netlify Env Vars)
@@ -967,6 +966,44 @@ interface CloudSaveUiState {
967
966
  deployUrl?: string;
968
967
  }
969
968
 
969
+ type ContentMode = 'cloud' | 'error';
970
+ type ContentStatus = 'ok' | 'empty_namespace' | 'legacy_fallback';
971
+
972
+ type ContentResponse = {
973
+ ok?: boolean;
974
+ siteConfig?: unknown;
975
+ pages?: unknown;
976
+ items?: unknown;
977
+ error?: string;
978
+ code?: string;
979
+ correlationId?: string;
980
+ contentStatus?: ContentStatus;
981
+ usedUnscopedFallback?: boolean;
982
+ namespace?: string;
983
+ namespaceMatchedKeys?: number;
984
+ };
985
+
986
+ type CachedCloudContent = {
987
+ keyFingerprint: string;
988
+ savedAt: number;
989
+ siteConfig: unknown | null;
990
+ pages: Record<string, unknown>;
991
+ };
992
+
993
+ const CLOUD_CACHE_KEY = 'jp_cloud_content_cache_v1';
994
+ const CLOUD_CACHE_TTL_MS = 5 * 60 * 1000;
995
+
996
+ function normalizeApiBase(raw: string): string {
997
+ return raw.trim().replace(/\/+$/, '');
998
+ }
999
+
1000
+ function buildApiCandidates(raw: string): string[] {
1001
+ const base = normalizeApiBase(raw);
1002
+ const withApi = /\/api\/v1$/i.test(base) ? base : `${base}/api/v1`;
1003
+ const candidates = [withApi, base];
1004
+ return Array.from(new Set(candidates.filter(Boolean)));
1005
+ }
1006
+
970
1007
  function getInitialData() {
971
1008
  return getHydratedData(TENANT_ID, filePages, fileSiteConfig);
972
1009
  }
@@ -985,13 +1022,235 @@ function stepProgress(doneSteps: StepId[]): number {
985
1022
  return Math.round((doneSteps.length / DEPLOY_STEPS.length) * 100);
986
1023
  }
987
1024
 
1025
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
1026
+ return typeof value === 'object' && value !== null;
1027
+ }
1028
+
1029
+ function asString(value: unknown, fallback: string): string {
1030
+ return typeof value === 'string' && value.trim() ? value : fallback;
1031
+ }
1032
+
1033
+ function coercePageConfig(slug: string, value: unknown): PageConfig | null {
1034
+ let input = value;
1035
+ if (typeof input === 'string') {
1036
+ try {
1037
+ input = JSON.parse(input) as unknown;
1038
+ } catch {
1039
+ return null;
1040
+ }
1041
+ }
1042
+ if (!isObjectRecord(input) || !Array.isArray(input.sections)) return null;
1043
+
1044
+ const inputMeta = isObjectRecord(input.meta) ? input.meta : {};
1045
+ const normalizedSlug = asString(input.slug, slug);
1046
+ const normalizedId = asString(input.id, `${normalizedSlug}-page`);
1047
+ const title = asString(inputMeta.title, normalizedSlug);
1048
+ const description = asString(inputMeta.description, '');
1049
+
1050
+ return {
1051
+ id: normalizedId,
1052
+ slug: normalizedSlug,
1053
+ meta: { title, description },
1054
+ sections: input.sections as PageConfig['sections'],
1055
+ };
1056
+ }
1057
+
1058
+ function coerceSiteConfig(value: unknown): SiteConfig | null {
1059
+ let input = value;
1060
+ if (typeof input === 'string') {
1061
+ try {
1062
+ input = JSON.parse(input) as unknown;
1063
+ } catch {
1064
+ return null;
1065
+ }
1066
+ }
1067
+ if (!isObjectRecord(input)) return null;
1068
+ if (!isObjectRecord(input.identity)) return null;
1069
+ if (!Array.isArray(input.pages)) return null;
1070
+
1071
+ return input as unknown as SiteConfig;
1072
+ }
1073
+
1074
+ function toPagesRecord(value: unknown): Record<string, PageConfig> | null {
1075
+ const directPage = coercePageConfig('home', value);
1076
+ if (directPage) {
1077
+ const directSlug = asString(directPage.slug, 'home')
1078
+ .toLowerCase()
1079
+ .replace(/[^a-z0-9/_-]/g, '-')
1080
+ .replace(/^\/+|\/+$/g, '') || 'home';
1081
+ return { [directSlug]: directPage };
1082
+ }
1083
+
1084
+ if (!isObjectRecord(value)) return null;
1085
+ const next: Record<string, PageConfig> = {};
1086
+ for (const [rawKey, payload] of Object.entries(value)) {
1087
+ const rawKeyTrimmed = rawKey.trim();
1088
+ const slugFromNamespacedKey = rawKeyTrimmed.match(/^t_[a-z0-9-]+_page_(.+)$/i)?.[1];
1089
+ const normalizedSlug = (slugFromNamespacedKey ?? rawKeyTrimmed)
1090
+ .toLowerCase()
1091
+ .replace(/[^a-z0-9/_-]/g, '-')
1092
+ .replace(/^\/+|\/+$/g, '');
1093
+ const slug = normalizedSlug || 'home';
1094
+ const page = coercePageConfig(slug, payload);
1095
+ if (!page) continue;
1096
+ next[slug] = page;
1097
+ }
1098
+ return next;
1099
+ }
1100
+
1101
+ function normalizePageRegistry(value: unknown): Record<string, PageConfig> {
1102
+ if (!isObjectRecord(value)) return {};
1103
+ const normalized: Record<string, PageConfig> = {};
1104
+
1105
+ for (const [registrySlug, rawPageValue] of Object.entries(value)) {
1106
+ const direct = coercePageConfig(registrySlug, rawPageValue);
1107
+ if (direct) {
1108
+ normalized[direct.slug || registrySlug] = direct;
1109
+ continue;
1110
+ }
1111
+
1112
+ const nested = toPagesRecord(rawPageValue);
1113
+ if (nested && Object.keys(nested).length > 0) {
1114
+ Object.assign(normalized, nested);
1115
+ }
1116
+ }
1117
+
1118
+ return normalized;
1119
+ }
1120
+
1121
+ function extractContentSources(payload: ContentResponse | Record<string, unknown>): {
1122
+ pagesSource: unknown;
1123
+ siteSource: unknown;
1124
+ } {
1125
+ // Canonical contract: { pages, siteConfig }
1126
+ if (isObjectRecord(payload) && isObjectRecord(payload.pages)) {
1127
+ return { pagesSource: payload.pages, siteSource: payload.siteConfig };
1128
+ }
1129
+
1130
+ // Edge public JSON contract: { digest, updatedAt, items: { ... } }
1131
+ if (isObjectRecord(payload) && isObjectRecord(payload.items)) {
1132
+ const items = payload.items;
1133
+ let siteSource: unknown = null;
1134
+ const pageEntries: Record<string, unknown> = {};
1135
+ for (const [key, value] of Object.entries(items)) {
1136
+ if (/(_config_site|config_site|config:site)$/i.test(key)) {
1137
+ siteSource = value;
1138
+ continue;
1139
+ }
1140
+ if (/(_page_|^page_|page:)/i.test(key)) {
1141
+ pageEntries[key] = value;
1142
+ }
1143
+ }
1144
+ return { pagesSource: pageEntries, siteSource };
1145
+ }
1146
+
1147
+ // Raw map fallback: treat payload object itself as page map.
1148
+ return { pagesSource: payload, siteSource: null };
1149
+ }
1150
+
1151
+ type CloudLoadFailure = {
1152
+ reasonCode: string;
1153
+ message: string;
1154
+ correlationId?: string;
1155
+ };
1156
+
1157
+ function isCloudLoadFailure(value: unknown): value is CloudLoadFailure {
1158
+ return (
1159
+ isObjectRecord(value) &&
1160
+ typeof value.reasonCode === 'string' &&
1161
+ typeof value.message === 'string'
1162
+ );
1163
+ }
1164
+
1165
+ function toCloudLoadFailure(value: unknown): CloudLoadFailure {
1166
+ if (isCloudLoadFailure(value)) return value;
1167
+ if (value instanceof Error) {
1168
+ return { reasonCode: 'CLOUD_LOAD_FAILED', message: value.message };
1169
+ }
1170
+ return { reasonCode: 'CLOUD_LOAD_FAILED', message: 'Cloud content unavailable.' };
1171
+ }
1172
+
1173
+ function sleep(ms: number): Promise<void> {
1174
+ return new Promise((resolve) => setTimeout(resolve, ms));
1175
+ }
1176
+
1177
+ function isRetryableStatus(status: number): boolean {
1178
+ return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
1179
+ }
1180
+
1181
+ function backoffDelayMs(attempt: number): number {
1182
+ const base = 250 * Math.pow(2, attempt);
1183
+ const jitter = Math.floor(Math.random() * 120);
1184
+ return base + jitter;
1185
+ }
1186
+
1187
+ function logBootstrapEvent(event: string, details: Record<string, unknown>) {
1188
+ console.info('[boot]', { event, at: new Date().toISOString(), ...details });
1189
+ }
1190
+
1191
+ function cloudFingerprint(apiBase: string, apiKey: string): string {
1192
+ return `${normalizeApiBase(apiBase)}::${apiKey.slice(-8)}`;
1193
+ }
1194
+
1195
+ function readCachedCloudContent(fingerprint: string): CachedCloudContent | null {
1196
+ try {
1197
+ const raw = localStorage.getItem(CLOUD_CACHE_KEY);
1198
+ if (!raw) return null;
1199
+ const parsed = JSON.parse(raw) as CachedCloudContent;
1200
+ if (!parsed || parsed.keyFingerprint !== fingerprint) return null;
1201
+ if (!parsed.savedAt || Date.now() - parsed.savedAt > CLOUD_CACHE_TTL_MS) return null;
1202
+ return parsed;
1203
+ } catch {
1204
+ return null;
1205
+ }
1206
+ }
1207
+
1208
+ function writeCachedCloudContent(entry: CachedCloudContent): void {
1209
+ try {
1210
+ localStorage.setItem(CLOUD_CACHE_KEY, JSON.stringify(entry));
1211
+ } catch {
1212
+ // non-blocking cache path
1213
+ }
1214
+ }
1215
+
1216
+ function buildThemeFontVarsCss(input: unknown): string {
1217
+ if (!isObjectRecord(input)) return '';
1218
+ const tokens = isObjectRecord(input.tokens) ? input.tokens : null;
1219
+ const typography = tokens && isObjectRecord(tokens.typography) ? tokens.typography : null;
1220
+ const fontFamily = typography && isObjectRecord(typography.fontFamily) ? typography.fontFamily : null;
1221
+ const primary = typeof fontFamily?.primary === 'string' ? fontFamily.primary : "'Instrument Sans', system-ui, sans-serif";
1222
+ const serif = typeof fontFamily?.serif === 'string' ? fontFamily.serif : "'Instrument Serif', Georgia, serif";
1223
+ const mono = typeof fontFamily?.mono === 'string' ? fontFamily.mono : "'JetBrains Mono', monospace";
1224
+ return `:root{--theme-font-primary:${primary};--theme-font-serif:${serif};--theme-font-mono:${mono};}`;
1225
+ }
1226
+
988
1227
  function App() {
989
- const [{ pages, siteConfig }] = useState(getInitialData);
1228
+ const isCloudMode = Boolean(CLOUD_API_URL && CLOUD_API_KEY);
1229
+ const localInitialData = useMemo(() => (isCloudMode ? null : getInitialData()), [isCloudMode]);
1230
+ const localInitialPages = useMemo(() => {
1231
+ if (!localInitialData) return {};
1232
+ const normalized = normalizePageRegistry(localInitialData.pages as unknown);
1233
+ return Object.keys(normalized).length > 0 ? normalized : localInitialData.pages;
1234
+ }, [localInitialData]);
1235
+ const [pages, setPages] = useState<Record<string, PageConfig>>(localInitialPages);
1236
+ const [siteConfig, setSiteConfig] = useState<SiteConfig>(
1237
+ localInitialData?.siteConfig ?? fileSiteConfig
1238
+ );
990
1239
  const [assetsManifest, setAssetsManifest] = useState<LibraryImageEntry[]>([]);
991
1240
  const [cloudSaveUi, setCloudSaveUi] = useState<CloudSaveUiState>(getInitialCloudSaveUiState);
1241
+ const [contentMode, setContentMode] = useState<ContentMode>('cloud');
1242
+ const [contentFallback, setContentFallback] = useState<CloudLoadFailure | null>(null);
1243
+ const [showTopProgress, setShowTopProgress] = useState(false);
1244
+ const [hasInitialCloudResolved, setHasInitialCloudResolved] = useState(!isCloudMode);
1245
+ const [bootstrapRunId, setBootstrapRunId] = useState(0);
992
1246
  const activeCloudSaveController = useRef<AbortController | null>(null);
1247
+ const contentLoadInFlight = useRef<Promise<void> | null>(null);
993
1248
  const pendingCloudSave = useRef<{ state: ProjectState; slug: string } | null>(null);
994
- const isCloudMode = Boolean(CLOUD_API_URL && CLOUD_API_KEY);
1249
+ const warmBootFromCloudCache = useRef(false);
1250
+ const cloudApiCandidates = useMemo(
1251
+ () => (isCloudMode && CLOUD_API_URL ? buildApiCandidates(CLOUD_API_URL) : []),
1252
+ [isCloudMode, CLOUD_API_URL]
1253
+ );
995
1254
 
996
1255
  useEffect(() => {
997
1256
  // In Cloud mode, listing assets might be different or disabled for MVP
@@ -1008,6 +1267,192 @@ function App() {
1008
1267
  };
1009
1268
  }, []);
1010
1269
 
1270
+ useEffect(() => {
1271
+ if (!isCloudMode || !CLOUD_API_URL || !CLOUD_API_KEY) {
1272
+ setContentMode('cloud');
1273
+ setContentFallback(null);
1274
+ setShowTopProgress(false);
1275
+ setHasInitialCloudResolved(true);
1276
+ logBootstrapEvent('boot.local.ready', { mode: 'local' });
1277
+ return;
1278
+ }
1279
+ if (contentLoadInFlight.current) {
1280
+ return;
1281
+ }
1282
+
1283
+ const controller = new AbortController();
1284
+ const maxRetryAttempts = 2;
1285
+ const startedAt = Date.now();
1286
+ const primaryApiBase = cloudApiCandidates[0] ?? normalizeApiBase(CLOUD_API_URL);
1287
+ const fingerprint = cloudFingerprint(primaryApiBase, CLOUD_API_KEY);
1288
+ const cached = readCachedCloudContent(fingerprint);
1289
+ if (cached) {
1290
+ const cachedPages = toPagesRecord(cached.pages);
1291
+ const cachedSite = coerceSiteConfig(cached.siteConfig);
1292
+ if (cachedPages && Object.keys(cachedPages).length > 0) {
1293
+ setPages(cachedPages);
1294
+ }
1295
+ if (cachedSite) {
1296
+ setSiteConfig(cachedSite);
1297
+ }
1298
+ setContentMode('cloud');
1299
+ setContentFallback(null);
1300
+ warmBootFromCloudCache.current = true;
1301
+ setShowTopProgress(false);
1302
+ setHasInitialCloudResolved(true);
1303
+ logBootstrapEvent('boot.cloud.cache_hit', { ageMs: Date.now() - cached.savedAt });
1304
+ } else {
1305
+ warmBootFromCloudCache.current = false;
1306
+ setShowTopProgress(true);
1307
+ setHasInitialCloudResolved(false);
1308
+ }
1309
+ logBootstrapEvent('boot.start', { mode: 'cloud', apiCandidates: cloudApiCandidates.length });
1310
+
1311
+ const loadCloudContent = async () => {
1312
+ try {
1313
+ let payload: ContentResponse | null = null;
1314
+ let lastFailure: CloudLoadFailure | null = null;
1315
+
1316
+ for (const apiBase of cloudApiCandidates) {
1317
+ for (let attempt = 0; attempt <= maxRetryAttempts; attempt += 1) {
1318
+ try {
1319
+ const res = await fetch(`${apiBase}/content`, {
1320
+ method: 'GET',
1321
+ headers: {
1322
+ Authorization: `Bearer ${CLOUD_API_KEY}`,
1323
+ },
1324
+ signal: controller.signal,
1325
+ });
1326
+
1327
+ const contentType = (res.headers.get('content-type') || '').toLowerCase();
1328
+ if (!contentType.includes('application/json')) {
1329
+ lastFailure = {
1330
+ reasonCode: 'NON_JSON_RESPONSE',
1331
+ message: `Non-JSON response from ${apiBase}/content`,
1332
+ };
1333
+ break;
1334
+ }
1335
+
1336
+ const parsed = (await res.json().catch(() => ({}))) as ContentResponse;
1337
+ if (!res.ok) {
1338
+ lastFailure = {
1339
+ reasonCode: parsed.code || `HTTP_${res.status}`,
1340
+ message: parsed.error || `Cloud content read failed: ${res.status} (${apiBase}/content)`,
1341
+ correlationId: parsed.correlationId,
1342
+ };
1343
+ if (isRetryableStatus(res.status) && attempt < maxRetryAttempts) {
1344
+ await sleep(backoffDelayMs(attempt));
1345
+ continue;
1346
+ }
1347
+ break;
1348
+ }
1349
+
1350
+ payload = parsed;
1351
+ break;
1352
+ } catch (error: unknown) {
1353
+ if (controller.signal.aborted) throw error;
1354
+ const message = error instanceof Error ? error.message : 'Network error';
1355
+ lastFailure = {
1356
+ reasonCode: 'NETWORK_TRANSIENT',
1357
+ message: `${message} (${apiBase}/content)`,
1358
+ };
1359
+ if (attempt < maxRetryAttempts) {
1360
+ await sleep(backoffDelayMs(attempt));
1361
+ continue;
1362
+ }
1363
+ }
1364
+ }
1365
+ if (payload) {
1366
+ break;
1367
+ }
1368
+ }
1369
+
1370
+ if (!payload) {
1371
+ throw (
1372
+ lastFailure || {
1373
+ reasonCode: 'CLOUD_ENDPOINT_UNREACHABLE',
1374
+ message: 'Cloud content endpoint not reachable as JSON.',
1375
+ }
1376
+ );
1377
+ }
1378
+
1379
+ const { pagesSource, siteSource } = extractContentSources(payload);
1380
+ const remotePages = toPagesRecord(pagesSource);
1381
+ const remoteSite = coerceSiteConfig(siteSource);
1382
+ const remotePageCount = remotePages ? Object.keys(remotePages).length : 0;
1383
+ if (remotePageCount === 0 && !remoteSite) {
1384
+ throw {
1385
+ reasonCode: payload.contentStatus === 'empty_namespace' ? 'EMPTY_NAMESPACE' : 'EMPTY_PAYLOAD',
1386
+ message: 'Cloud payload is empty for this tenant namespace.',
1387
+ correlationId: payload.correlationId,
1388
+ } satisfies CloudLoadFailure;
1389
+ }
1390
+ if (import.meta.env.DEV) {
1391
+ console.info('[content] cloud diagnostics', {
1392
+ contentStatus: payload.contentStatus ?? 'ok',
1393
+ namespace: payload.namespace,
1394
+ namespaceMatchedKeys: payload.namespaceMatchedKeys,
1395
+ usedUnscopedFallback: payload.usedUnscopedFallback,
1396
+ correlationId: payload.correlationId,
1397
+ });
1398
+ }
1399
+ if (remotePages && remotePageCount > 0) {
1400
+ setPages(remotePages);
1401
+ }
1402
+ if (remoteSite) {
1403
+ setSiteConfig(remoteSite);
1404
+ }
1405
+ writeCachedCloudContent({
1406
+ keyFingerprint: fingerprint,
1407
+ savedAt: Date.now(),
1408
+ siteConfig: remoteSite ?? null,
1409
+ pages: (remotePages ?? {}) as Record<string, unknown>,
1410
+ });
1411
+ setContentMode('cloud');
1412
+ setContentFallback(null);
1413
+ setHasInitialCloudResolved(true);
1414
+ logBootstrapEvent('boot.cloud.success', {
1415
+ mode: 'cloud',
1416
+ elapsedMs: Date.now() - startedAt,
1417
+ contentStatus: payload.contentStatus ?? 'ok',
1418
+ correlationId: payload.correlationId ?? null,
1419
+ });
1420
+ } catch (error: unknown) {
1421
+ if (controller.signal.aborted) return;
1422
+ const failure = toCloudLoadFailure(error);
1423
+ if (warmBootFromCloudCache.current) {
1424
+ setContentMode('cloud');
1425
+ setContentFallback({
1426
+ reasonCode: 'CLOUD_REFRESH_FAILED',
1427
+ message: failure.message,
1428
+ correlationId: failure.correlationId,
1429
+ });
1430
+ setHasInitialCloudResolved(true);
1431
+ } else {
1432
+ setContentMode('error');
1433
+ setContentFallback(failure);
1434
+ setHasInitialCloudResolved(true);
1435
+ }
1436
+ logBootstrapEvent('boot.cloud.error', {
1437
+ mode: 'cloud',
1438
+ elapsedMs: Date.now() - startedAt,
1439
+ reasonCode: failure.reasonCode,
1440
+ correlationId: failure.correlationId ?? null,
1441
+ });
1442
+ }
1443
+ };
1444
+
1445
+ let inFlight: Promise<void> | null = null;
1446
+ inFlight = loadCloudContent().finally(() => {
1447
+ setShowTopProgress(false);
1448
+ if (contentLoadInFlight.current === inFlight) {
1449
+ contentLoadInFlight.current = null;
1450
+ }
1451
+ });
1452
+ contentLoadInFlight.current = inFlight;
1453
+ return () => controller.abort();
1454
+ }, [isCloudMode, CLOUD_API_KEY, CLOUD_API_URL, cloudApiCandidates, bootstrapRunId]);
1455
+
1011
1456
  const runCloudSave = useCallback(
1012
1457
  async (
1013
1458
  payload: { state: ProjectState; slug: string },
@@ -1114,7 +1559,7 @@ function App() {
1114
1559
  siteConfig,
1115
1560
  themeConfig,
1116
1561
  menuConfig,
1117
- themeCss: { tenant: fontsCss + '\n' + tenantCss },
1562
+ themeCss: { tenant: `${buildThemeFontVarsCss(themeConfig)}\n${tenantCss}` },
1118
1563
  addSection: addSectionConfig,
1119
1564
  persistence: {
1120
1565
  async saveToFile(state: ProjectState, slug: string): Promise<void> {
@@ -1134,7 +1579,7 @@ function App() {
1134
1579
  throw new Error('Cloud mode is not configured for hot save.');
1135
1580
  }
1136
1581
  const apiBase = CLOUD_API_URL.replace(/\/$/, '');
1137
- const res = await fetch(`${apiBase}/save2edge`, {
1582
+ const res = await fetch(`${apiBase}/hotSave`, {
1138
1583
  method: 'POST',
1139
1584
  headers: {
1140
1585
  'Content-Type': 'application/json',
@@ -1184,9 +1629,98 @@ function App() {
1184
1629
  },
1185
1630
  };
1186
1631
 
1632
+ const shouldRenderEngine = !isCloudMode || hasInitialCloudResolved;
1633
+
1187
1634
  return (
1188
1635
  <>
1189
- <JsonPagesEngine config={config} />
1636
+ {isCloudMode && showTopProgress ? (
1637
+ <>
1638
+ <style>
1639
+ {`@keyframes jp-top-progress-slide { 0% { transform: translateX(-120%); } 100% { transform: translateX(320%); } }`}
1640
+ </style>
1641
+ <div
1642
+ role="status"
1643
+ aria-live="polite"
1644
+ aria-label="Cloud loading progress"
1645
+ style={{
1646
+ position: 'fixed',
1647
+ top: 0,
1648
+ left: 0,
1649
+ right: 0,
1650
+ height: 2,
1651
+ zIndex: 1300,
1652
+ background: 'rgba(255,255,255,0.08)',
1653
+ overflow: 'hidden',
1654
+ }}
1655
+ >
1656
+ <div
1657
+ style={{
1658
+ width: '32%',
1659
+ height: '100%',
1660
+ background: 'linear-gradient(90deg, rgba(88,166,255,0.15) 0%, rgba(88,166,255,0.85) 50%, rgba(88,166,255,0.15) 100%)',
1661
+ animation: 'jp-top-progress-slide 1.15s ease-in-out infinite',
1662
+ willChange: 'transform',
1663
+ }}
1664
+ />
1665
+ </div>
1666
+ </>
1667
+ ) : null}
1668
+ {shouldRenderEngine ? <JsonPagesEngine config={config} /> : null}
1669
+ {isCloudMode && (contentMode === 'error' || contentFallback?.reasonCode === 'CLOUD_REFRESH_FAILED') ? (
1670
+ <div
1671
+ role="status"
1672
+ aria-live="polite"
1673
+ style={{
1674
+ position: 'fixed',
1675
+ top: 12,
1676
+ right: 12,
1677
+ zIndex: 1200,
1678
+ background: 'rgba(179, 65, 24, 0.92)',
1679
+ border: '1px solid rgba(255,255,255,0.18)',
1680
+ color: '#fff',
1681
+ padding: '8px 12px',
1682
+ borderRadius: 10,
1683
+ fontSize: 12,
1684
+ maxWidth: 360,
1685
+ boxShadow: '0 8px 24px rgba(0,0,0,0.25)',
1686
+ }}
1687
+ >
1688
+ {contentMode === 'error' ? 'Cloud content unavailable.' : 'Cloud refresh failed, showing cached content.'}
1689
+ {contentFallback ? (
1690
+ <div style={{ opacity: 0.85, marginTop: 4 }}>
1691
+ <div>{contentFallback.message}</div>
1692
+ <div style={{ marginTop: 2 }}>
1693
+ Reason: {contentFallback.reasonCode}
1694
+ {contentFallback.correlationId ? ` | Correlation: ${contentFallback.correlationId}` : ''}
1695
+ </div>
1696
+ <div style={{ marginTop: 8 }}>
1697
+ <button
1698
+ type="button"
1699
+ onClick={() => {
1700
+ contentLoadInFlight.current = null;
1701
+ setContentMode('cloud');
1702
+ setContentFallback(null);
1703
+ setHasInitialCloudResolved(false);
1704
+ setShowTopProgress(true);
1705
+ setBootstrapRunId((prev) => prev + 1);
1706
+ }}
1707
+ style={{
1708
+ border: '1px solid rgba(255,255,255,0.3)',
1709
+ borderRadius: 8,
1710
+ padding: '4px 10px',
1711
+ background: 'transparent',
1712
+ color: '#fff',
1713
+ cursor: 'pointer',
1714
+ fontSize: 12,
1715
+ }}
1716
+ >
1717
+ Retry
1718
+ </button>
1719
+ </div>
1720
+ </div>
1721
+ ) : null}
1722
+ </div>
1723
+ ) : null}
1190
1724
  <DopaDrawer
1191
1725
  isOpen={cloudSaveUi.isOpen}
1192
1726
  phase={cloudSaveUi.phase}
@@ -1206,6 +1740,7 @@ export default App;
1206
1740
 
1207
1741
 
1208
1742
  END_OF_FILE_CONTENT
1743
+ # SKIP: src/App.tsx:Zone.Identifier is binary and cannot be embedded as text.
1209
1744
  echo "Creating src/App_.tsx..."
1210
1745
  cat << 'END_OF_FILE_CONTENT' > "src/App_.tsx"
1211
1746
  import { useState, useEffect } from 'react';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonpages/cli",
3
- "version": "3.0.75",
3
+ "version": "3.0.76",
4
4
  "description": "The Sovereign CLI Engine for JsonPages.",
5
5
  "type": "module",
6
6
  "bin": {