@jsonpages/cli 3.0.74 → 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.
- package/assets/src_tenant_alpha.sh +568 -15
- package/assets/templates/alpha/src_tenant.sh +568 -15
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
|
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
|
|
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,17 +1559,11 @@ function App() {
|
|
|
1114
1559
|
siteConfig,
|
|
1115
1560
|
themeConfig,
|
|
1116
1561
|
menuConfig,
|
|
1117
|
-
themeCss: { tenant:
|
|
1562
|
+
themeCss: { tenant: `${buildThemeFontVarsCss(themeConfig)}\n${tenantCss}` },
|
|
1118
1563
|
addSection: addSectionConfig,
|
|
1119
1564
|
persistence: {
|
|
1120
1565
|
async saveToFile(state: ProjectState, slug: string): Promise<void> {
|
|
1121
|
-
//
|
|
1122
|
-
if (isCloudMode) {
|
|
1123
|
-
await runCloudSave({ state, slug }, true);
|
|
1124
|
-
return;
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
// 💻 SCENARIO B: LOCAL FILESYSTEM (Development)
|
|
1566
|
+
// 💻 LOCAL FILESYSTEM (Development / legacy fallback)
|
|
1128
1567
|
console.log(`💻 Saving ${slug} to Local Filesystem...`);
|
|
1129
1568
|
const res = await fetch('/api/save-to-file', {
|
|
1130
1569
|
method: 'POST',
|
|
@@ -1135,6 +1574,30 @@ function App() {
|
|
|
1135
1574
|
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
1136
1575
|
if (!res.ok) throw new Error(body.error ?? `Save to file failed: ${res.status}`);
|
|
1137
1576
|
},
|
|
1577
|
+
async hotSave(state: ProjectState, slug: string): Promise<void> {
|
|
1578
|
+
if (!isCloudMode || !CLOUD_API_URL || !CLOUD_API_KEY) {
|
|
1579
|
+
throw new Error('Cloud mode is not configured for hot save.');
|
|
1580
|
+
}
|
|
1581
|
+
const apiBase = CLOUD_API_URL.replace(/\/$/, '');
|
|
1582
|
+
const res = await fetch(`${apiBase}/hotSave`, {
|
|
1583
|
+
method: 'POST',
|
|
1584
|
+
headers: {
|
|
1585
|
+
'Content-Type': 'application/json',
|
|
1586
|
+
Authorization: `Bearer ${CLOUD_API_KEY}`,
|
|
1587
|
+
},
|
|
1588
|
+
body: JSON.stringify({
|
|
1589
|
+
slug,
|
|
1590
|
+
type: 'page',
|
|
1591
|
+
data: state.page,
|
|
1592
|
+
}),
|
|
1593
|
+
});
|
|
1594
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
|
|
1595
|
+
if (!res.ok) {
|
|
1596
|
+
throw new Error(body.error || body.code || `Hot save failed: ${res.status}`);
|
|
1597
|
+
}
|
|
1598
|
+
},
|
|
1599
|
+
showLegacySave: !isCloudMode,
|
|
1600
|
+
showHotSave: isCloudMode,
|
|
1138
1601
|
},
|
|
1139
1602
|
assets: {
|
|
1140
1603
|
assetsBaseUrl: '/assets',
|
|
@@ -1166,9 +1629,98 @@ function App() {
|
|
|
1166
1629
|
},
|
|
1167
1630
|
};
|
|
1168
1631
|
|
|
1632
|
+
const shouldRenderEngine = !isCloudMode || hasInitialCloudResolved;
|
|
1633
|
+
|
|
1169
1634
|
return (
|
|
1170
1635
|
<>
|
|
1171
|
-
|
|
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}
|
|
1172
1724
|
<DopaDrawer
|
|
1173
1725
|
isOpen={cloudSaveUi.isOpen}
|
|
1174
1726
|
phase={cloudSaveUi.phase}
|
|
@@ -1188,6 +1740,7 @@ export default App;
|
|
|
1188
1740
|
|
|
1189
1741
|
|
|
1190
1742
|
END_OF_FILE_CONTENT
|
|
1743
|
+
# SKIP: src/App.tsx:Zone.Identifier is binary and cannot be embedded as text.
|
|
1191
1744
|
echo "Creating src/App_.tsx..."
|
|
1192
1745
|
cat << 'END_OF_FILE_CONTENT' > "src/App_.tsx"
|
|
1193
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.
|
|
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
|
|
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
|
|
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,17 +1559,11 @@ function App() {
|
|
|
1114
1559
|
siteConfig,
|
|
1115
1560
|
themeConfig,
|
|
1116
1561
|
menuConfig,
|
|
1117
|
-
themeCss: { tenant:
|
|
1562
|
+
themeCss: { tenant: `${buildThemeFontVarsCss(themeConfig)}\n${tenantCss}` },
|
|
1118
1563
|
addSection: addSectionConfig,
|
|
1119
1564
|
persistence: {
|
|
1120
1565
|
async saveToFile(state: ProjectState, slug: string): Promise<void> {
|
|
1121
|
-
//
|
|
1122
|
-
if (isCloudMode) {
|
|
1123
|
-
await runCloudSave({ state, slug }, true);
|
|
1124
|
-
return;
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
// 💻 SCENARIO B: LOCAL FILESYSTEM (Development)
|
|
1566
|
+
// 💻 LOCAL FILESYSTEM (Development / legacy fallback)
|
|
1128
1567
|
console.log(`💻 Saving ${slug} to Local Filesystem...`);
|
|
1129
1568
|
const res = await fetch('/api/save-to-file', {
|
|
1130
1569
|
method: 'POST',
|
|
@@ -1135,6 +1574,30 @@ function App() {
|
|
|
1135
1574
|
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
1136
1575
|
if (!res.ok) throw new Error(body.error ?? `Save to file failed: ${res.status}`);
|
|
1137
1576
|
},
|
|
1577
|
+
async hotSave(state: ProjectState, slug: string): Promise<void> {
|
|
1578
|
+
if (!isCloudMode || !CLOUD_API_URL || !CLOUD_API_KEY) {
|
|
1579
|
+
throw new Error('Cloud mode is not configured for hot save.');
|
|
1580
|
+
}
|
|
1581
|
+
const apiBase = CLOUD_API_URL.replace(/\/$/, '');
|
|
1582
|
+
const res = await fetch(`${apiBase}/hotSave`, {
|
|
1583
|
+
method: 'POST',
|
|
1584
|
+
headers: {
|
|
1585
|
+
'Content-Type': 'application/json',
|
|
1586
|
+
Authorization: `Bearer ${CLOUD_API_KEY}`,
|
|
1587
|
+
},
|
|
1588
|
+
body: JSON.stringify({
|
|
1589
|
+
slug,
|
|
1590
|
+
type: 'page',
|
|
1591
|
+
data: state.page,
|
|
1592
|
+
}),
|
|
1593
|
+
});
|
|
1594
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
|
|
1595
|
+
if (!res.ok) {
|
|
1596
|
+
throw new Error(body.error || body.code || `Hot save failed: ${res.status}`);
|
|
1597
|
+
}
|
|
1598
|
+
},
|
|
1599
|
+
showLegacySave: !isCloudMode,
|
|
1600
|
+
showHotSave: isCloudMode,
|
|
1138
1601
|
},
|
|
1139
1602
|
assets: {
|
|
1140
1603
|
assetsBaseUrl: '/assets',
|
|
@@ -1166,9 +1629,98 @@ function App() {
|
|
|
1166
1629
|
},
|
|
1167
1630
|
};
|
|
1168
1631
|
|
|
1632
|
+
const shouldRenderEngine = !isCloudMode || hasInitialCloudResolved;
|
|
1633
|
+
|
|
1169
1634
|
return (
|
|
1170
1635
|
<>
|
|
1171
|
-
|
|
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}
|
|
1172
1724
|
<DopaDrawer
|
|
1173
1725
|
isOpen={cloudSaveUi.isOpen}
|
|
1174
1726
|
phase={cloudSaveUi.phase}
|
|
@@ -1188,6 +1740,7 @@ export default App;
|
|
|
1188
1740
|
|
|
1189
1741
|
|
|
1190
1742
|
END_OF_FILE_CONTENT
|
|
1743
|
+
# SKIP: src/App.tsx:Zone.Identifier is binary and cannot be embedded as text.
|
|
1191
1744
|
echo "Creating src/App_.tsx..."
|
|
1192
1745
|
cat << 'END_OF_FILE_CONTENT' > "src/App_.tsx"
|
|
1193
1746
|
import { useState, useEffect } from 'react';
|