@jsonpages/cli 3.0.27 → 3.0.29

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.
@@ -29,7 +29,8 @@ echo "Creating src/App.tsx..."
29
29
  cat << 'END_OF_FILE_CONTENT' > "src/App.tsx"
30
30
  /**
31
31
  * Thin Entry Point (Tenant).
32
- * Loads from localStorage draft when present (same key as Core autosave), else from file.
32
+ * Data from getHydratedData (file-backed or draft); assets from public/assets/images.
33
+ * Persistence: Export JSON (download) + optional Salva su repo (POST /api/persist → git push).
33
34
  */
34
35
  import { useState, useEffect } from 'react';
35
36
  import { JsonPagesEngine } from '@jsonpages/core';
@@ -40,14 +41,13 @@ import { addSectionConfig } from '@/lib/addSectionConfig';
40
41
  import { getHydratedData } from '@/lib/draftStorage';
41
42
  import type { JsonPagesConfig } from '@jsonpages/core';
42
43
  import type { PageConfig, SiteConfig, ThemeConfig, MenuConfig } from '@/types';
44
+ import type { ProjectState } from '@jsonpages/core';
43
45
 
44
- // Tenant data (file-backed fallback)
45
46
  import siteData from '@/data/config/site.json';
46
47
  import themeData from '@/data/config/theme.json';
47
48
  import menuData from '@/data/config/menu.json';
48
49
  import homeData from '@/data/pages/home.json';
49
50
 
50
- // Tenant CSS
51
51
  import tenantCss from './index.css?inline';
52
52
 
53
53
  const themeConfig = themeData as unknown as ThemeConfig;
@@ -59,6 +59,8 @@ const filePages: Record<string, PageConfig> = {
59
59
  };
60
60
  const fileSiteConfig = siteData as unknown as SiteConfig;
61
61
 
62
+ const MAX_UPLOAD_SIZE_BYTES = 5 * 1024 * 1024; // 5MB, must match server
63
+
62
64
  function getInitialData() {
63
65
  return getHydratedData(TENANT_ID, filePages, fileSiteConfig);
64
66
  }
@@ -67,17 +69,6 @@ function App() {
67
69
  const [{ pages, siteConfig }] = useState(getInitialData);
68
70
  const [assetsManifest, setAssetsManifest] = useState<LibraryImageEntry[]>([]);
69
71
 
70
- // #region agent log
71
- useEffect(() => {
72
- fetch('http://127.0.0.1:7588/ingest/86d71502-47e1-433c-9b6d-5a1390d00813',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'34bba5'},body:JSON.stringify({sessionId:'34bba5',location:'App.tsx',message:'App mounted',data:{},timestamp:Date.now(),hypothesisId:'H1,H3'})}).catch(()=>{});
73
- const onSubmit = (e: Event) => {
74
- fetch('http://127.0.0.1:7588/ingest/86d71502-47e1-433c-9b6d-5a1390d00813',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'34bba5'},body:JSON.stringify({sessionId:'34bba5',location:'App.tsx:submit',message:'form submit captured',data:{targetTag:(e.target as HTMLElement)?.tagName},timestamp:Date.now(),hypothesisId:'H4'})}).catch(()=>{});
75
- };
76
- document.addEventListener('submit', onSubmit, true);
77
- return () => document.removeEventListener('submit', onSubmit, true);
78
- }, []);
79
- // #endregion
80
-
81
72
  useEffect(() => {
82
73
  fetch('/api/list-assets')
83
74
  .then((r) => (r.ok ? r.json() : []))
@@ -96,50 +87,59 @@ function App() {
96
87
  themeCss: { tenant: tenantCss },
97
88
  addSection: addSectionConfig,
98
89
  persistence: {
99
- async flushUploadedAssets(urls: string[]): Promise<Record<string, string>> {
100
- const res = await fetch('/api/flush-uploaded-assets', {
90
+ async saveToRepo(state: ProjectState, slug: string) {
91
+ const res = await fetch('/api/persist', {
101
92
  method: 'POST',
102
93
  headers: { 'Content-Type': 'application/json' },
103
- body: JSON.stringify({ urls }),
94
+ body: JSON.stringify({ projectState: state, slug }),
104
95
  });
105
- if (!res.ok) throw new Error(`Flush failed: ${res.status}`);
106
- const { urlMap } = (await res.json()) as { urlMap?: Record<string, string> };
107
- return urlMap ?? {};
96
+ const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
97
+ if (!res.ok) {
98
+ const msg = body.error ?? `Save failed: ${res.status}`;
99
+ alert(msg);
100
+ throw new Error(msg);
101
+ }
108
102
  },
109
103
  },
110
104
  assets: {
111
105
  assetsBaseUrl: '/assets',
112
106
  assetsManifest,
113
107
  async onAssetUpload(file: File): Promise<string> {
114
- // #region agent log
115
- fetch('http://127.0.0.1:7588/ingest/86d71502-47e1-433c-9b6d-5a1390d00813',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'34bba5'},body:JSON.stringify({sessionId:'34bba5',location:'App.tsx:onAssetUpload',message:'onAssetUpload entry',data:{fileName:file.name},timestamp:Date.now(),hypothesisId:'H2,H5'})}).catch(()=>{});
116
- // #endregion
117
- const base64 = await new Promise<string>((resolve, reject) => {
118
- const reader = new FileReader();
119
- reader.onload = () => {
120
- const dataUrl = reader.result as string;
121
- const base64 = dataUrl.includes(',') ? dataUrl.split(',')[1] : '';
122
- resolve(base64 || '');
123
- };
124
- reader.onerror = () => reject(reader.error);
125
- reader.readAsDataURL(file);
126
- });
127
- const res = await fetch('/api/upload-asset', {
128
- method: 'POST',
129
- headers: { 'Content-Type': 'application/json' },
130
- body: JSON.stringify({ filename: file.name, mimeType: file.type || undefined, data: base64 }),
131
- });
132
- if (!res.ok) {
133
- const err = await res.json().catch(() => ({}));
134
- throw new Error((err as { error?: string }).error || `Upload failed: ${res.status}`);
135
- }
136
- const { url } = (await res.json()) as { url: string };
137
- // In-memory upload only: do not update assetsManifest here to avoid reload.
138
- // Preview in Upload tab uses this URL; Library tab will get new items on next list-assets load.
139
- // Disk write can be added later on Save.
140
- return url;
108
+ if (!file.type.startsWith('image/')) {
109
+ throw new Error('Invalid file type. Allowed: JPEG, PNG, WebP, GIF, SVG, AVIF.');
110
+ }
111
+ if (file.size > MAX_UPLOAD_SIZE_BYTES) {
112
+ throw new Error(`File too large. Maximum size is ${MAX_UPLOAD_SIZE_BYTES / 1024 / 1024}MB.`);
113
+ }
114
+ const base64 = await new Promise<string>((resolve, reject) => {
115
+ const reader = new FileReader();
116
+ reader.onload = () => {
117
+ const dataUrl = reader.result as string;
118
+ const b64 = dataUrl.includes(',') ? dataUrl.split(',')[1] : '';
119
+ resolve(b64 || '');
120
+ };
121
+ reader.onerror = () => reject(reader.error);
122
+ reader.readAsDataURL(file);
123
+ });
124
+ const res = await fetch('/api/upload-asset', {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json' },
127
+ body: JSON.stringify({
128
+ filename: file.name,
129
+ mimeType: file.type || undefined,
130
+ data: base64,
131
+ }),
132
+ });
133
+ const body = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
134
+ if (!res.ok) {
135
+ throw new Error(body.error || `Upload failed: ${res.status}`);
136
+ }
137
+ if (typeof body.url !== 'string') {
138
+ throw new Error('Invalid server response: missing url');
139
+ }
140
+ return body.url;
141
+ },
141
142
  },
142
- },
143
143
  };
144
144
 
145
145
  return <JsonPagesEngine config={config} />;
@@ -1070,11 +1070,6 @@ export const ImageBreak: React.FC<{ data: ImageBreakData; settings?: ImageBreakS
1070
1070
  </div>
1071
1071
  )}
1072
1072
  </div>
1073
- {data.label && (
1074
- <div className="jp-section-label sr-only" data-jp-field="label">
1075
- {data.label}
1076
- </div>
1077
- )}
1078
1073
  </>
1079
1074
  ) : (
1080
1075
  <div className="flex flex-col items-center justify-center py-24 text-[var(--local-text-muted)]">
@@ -1097,19 +1092,16 @@ END_OF_FILE_CONTENT
1097
1092
  echo "Creating src/components/image-break/schema.ts..."
1098
1093
  cat << 'END_OF_FILE_CONTENT' > "src/components/image-break/schema.ts"
1099
1094
  import { z } from 'zod';
1100
- import { BaseSectionData } from '@/lib/base-schemas';
1101
-
1102
- const ImageSelectionSchema = z.object({
1103
- url: z.string(),
1104
- alt: z.string().optional(),
1105
- }).describe('ui:image-picker');
1095
+ import { BaseSectionData, ImageSelectionSchema } from '@/lib/base-schemas';
1106
1096
 
1107
1097
  export const ImageBreakSchema = BaseSectionData.extend({
1108
- label: z.string().optional().describe('ui:text'),
1109
1098
  image: ImageSelectionSchema.default({ url: '', alt: '' }),
1110
- caption: z.string().optional().describe('ui:textarea'),
1099
+ caption: z.string().optional().describe('ui:text'),
1111
1100
  });
1112
1101
 
1102
+ export const ImageBreakSettingsSchema = z.object({
1103
+ height: z.enum(['sm', 'md', 'lg', 'full']).default('md').describe('ui:select'),
1104
+ });
1113
1105
  END_OF_FILE_CONTENT
1114
1106
  echo "Creating src/components/image-break/types.ts..."
1115
1107
  cat << 'END_OF_FILE_CONTENT' > "src/components/image-break/types.ts"
@@ -2503,18 +2495,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/home.json"
2503
2495
  },
2504
2496
  "settings": {}
2505
2497
  },
2506
- {
2507
- "id": "image-break-1",
2508
- "type": "image-break",
2509
- "data": {
2510
- "image": {
2511
- "url": "https://images.unsplash.com/photo-1464226184884-fa280b87c399?w=800",
2512
- "alt": "Verdure fresche dall'orto"
2513
- },
2514
- "caption": "Il nostro orto a pochi passi dalla cucina — raccolto ogni mattina all'alba"
2515
- },
2516
- "settings": {}
2517
- },
2498
+
2518
2499
  {
2519
2500
  "id": "problem-statement",
2520
2501
  "type": "problem-statement",
@@ -3268,6 +3249,17 @@ echo "Creating src/lib/base-schemas.ts..."
3268
3249
  cat << 'END_OF_FILE_CONTENT' > "src/lib/base-schemas.ts"
3269
3250
  import { z } from 'zod';
3270
3251
 
3252
+ /**
3253
+ * Image picker field: object { url, alt? } with ui:image-picker for Form Factory.
3254
+ * Use in section data and resolve with resolveAssetUrl(url, tenantId) in View.
3255
+ */
3256
+ export const ImageSelectionSchema = z
3257
+ .object({
3258
+ url: z.string(),
3259
+ alt: z.string().optional(),
3260
+ })
3261
+ .describe('ui:image-picker');
3262
+
3271
3263
  /**
3272
3264
  * Base schemas shared by section capsules (CIP governance).
3273
3265
  * Capsules extend these for consistent anchorId, array items, and settings.
@@ -3436,6 +3428,176 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
3436
3428
 
3437
3429
  END_OF_FILE_CONTENT
3438
3430
  # SKIP: src/registry-types.ts è un file binario e non può essere convertito in testo.
3431
+ mkdir -p "src/server"
3432
+ echo "Creating src/server/persistToRepo.ts..."
3433
+ cat << 'END_OF_FILE_CONTENT' > "src/server/persistToRepo.ts"
3434
+ /**
3435
+ * Enterprise-grade persist: write JSON to disk and git add/commit/push.
3436
+ * Used by POST /api/persist. Credentials via env (GITHUB_TOKEN); no secrets in code.
3437
+ */
3438
+ import fs from 'fs';
3439
+ import path from 'path';
3440
+ import { simpleGit, type SimpleGit } from 'simple-git';
3441
+
3442
+ /** Minimal ProjectState shape for validation (matches Core kernel). */
3443
+ export interface PersistBody {
3444
+ projectState: {
3445
+ page: unknown;
3446
+ site: unknown;
3447
+ menu: unknown;
3448
+ theme: unknown;
3449
+ };
3450
+ slug: string;
3451
+ }
3452
+
3453
+ const SLUG_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
3454
+
3455
+ function isNonEmptyObject(v: unknown): v is Record<string, unknown> {
3456
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
3457
+ }
3458
+
3459
+ export function validatePersistBody(body: unknown): { ok: true; data: PersistBody } | { ok: false; status: number; error: string } {
3460
+ if (!isNonEmptyObject(body) || !isNonEmptyObject(body.projectState)) {
3461
+ return { ok: false, status: 400, error: 'Missing or invalid projectState' };
3462
+ }
3463
+ const { projectState, slug } = body as PersistBody;
3464
+ if (typeof slug !== 'string' || !SLUG_REGEX.test(slug)) {
3465
+ return { ok: false, status: 400, error: 'Invalid slug (use lowercase alphanumeric and hyphens)' };
3466
+ }
3467
+ if (
3468
+ projectState.page == null ||
3469
+ projectState.site == null ||
3470
+ projectState.menu == null ||
3471
+ projectState.theme == null
3472
+ ) {
3473
+ return { ok: false, status: 400, error: 'projectState must include page, site, menu, theme' };
3474
+ }
3475
+ return { ok: true, data: { projectState, slug } };
3476
+ }
3477
+
3478
+ export interface PersistOptions {
3479
+ /** Absolute path to data dir (e.g. .../src/data). */
3480
+ dataDir: string;
3481
+ /** Git working directory (repo root). Defaults to dataDir's app root. */
3482
+ gitDir: string;
3483
+ /** Optional: inject into origin for push (e.g. GITHUB_TOKEN). */
3484
+ token?: string;
3485
+ }
3486
+
3487
+ function writeFiles(data: PersistBody, dataDir: string): string[] {
3488
+ const configDir = path.join(dataDir, 'config');
3489
+ const pagesDir = path.join(dataDir, 'pages');
3490
+ const written: string[] = [];
3491
+
3492
+ [configDir, pagesDir].forEach((dir) => {
3493
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
3494
+ });
3495
+
3496
+ const { projectState, slug } = data;
3497
+ const sitePath = path.join(configDir, 'site.json');
3498
+ const themePath = path.join(configDir, 'theme.json');
3499
+ const menuPath = path.join(configDir, 'menu.json');
3500
+ const pagePath = path.join(pagesDir, `${slug}.json`);
3501
+
3502
+ fs.writeFileSync(sitePath, JSON.stringify(projectState.site, null, 2), 'utf8');
3503
+ written.push(sitePath);
3504
+ fs.writeFileSync(themePath, JSON.stringify(projectState.theme, null, 2), 'utf8');
3505
+ written.push(themePath);
3506
+ fs.writeFileSync(menuPath, JSON.stringify(projectState.menu, null, 2), 'utf8');
3507
+ written.push(menuPath);
3508
+ fs.writeFileSync(pagePath, JSON.stringify(projectState.page, null, 2), 'utf8');
3509
+ written.push(pagePath);
3510
+
3511
+ return written;
3512
+ }
3513
+
3514
+ /** Inject token into HTTPS origin for push (no token stored in config). */
3515
+ function originWithToken(origin: string, token: string): string {
3516
+ if (origin.startsWith('https://')) {
3517
+ return origin.replace(/^https:\/\/([^@]*@)?/, `https://${token}@`);
3518
+ }
3519
+ return origin;
3520
+ }
3521
+
3522
+ async function gitAddCommitPush(
3523
+ gitDir: string,
3524
+ relativePaths: string[],
3525
+ commitMessage: string,
3526
+ token?: string
3527
+ ): Promise<{ success: true } | { success: false; error: string }> {
3528
+ const git: SimpleGit = simpleGit(gitDir);
3529
+ let originalOrigin: string | null = null;
3530
+
3531
+ try {
3532
+ const status = await git.status();
3533
+ if (status.isBare()) {
3534
+ return { success: false, error: 'Repository is bare; cannot commit' };
3535
+ }
3536
+
3537
+ if (token) {
3538
+ const remotes = await git.getRemotes(true);
3539
+ const origin = remotes.origin?.refs?.fetch ?? remotes.origin?.refs?.push;
3540
+ if (origin) {
3541
+ originalOrigin = origin;
3542
+ const authUrl = originWithToken(origin, token);
3543
+ await git.raw(['remote', 'set-url', 'origin', authUrl]);
3544
+ }
3545
+ }
3546
+
3547
+ await git.add(relativePaths);
3548
+ const commitResult = await git.commit(commitMessage);
3549
+ if (commitResult.commit === null && relativePaths.length > 0) {
3550
+ return { success: false, error: 'Nothing to commit (files unchanged or not in repo)' };
3551
+ }
3552
+ await git.push();
3553
+ return { success: true };
3554
+ } catch (e) {
3555
+ const msg = e instanceof Error ? e.message : String(e);
3556
+ return { success: false, error: msg };
3557
+ } finally {
3558
+ if (originalOrigin) {
3559
+ try {
3560
+ await git.raw(['remote', 'set-url', 'origin', originalOrigin]);
3561
+ } catch {
3562
+ // best-effort restore; do not mask original error
3563
+ }
3564
+ }
3565
+ }
3566
+ }
3567
+
3568
+ /**
3569
+ * Write project state to disk and push to remote.
3570
+ * File paths are resolved from dataDir; git paths are relative to gitDir.
3571
+ */
3572
+ export async function runPersist(
3573
+ data: PersistBody,
3574
+ options: PersistOptions
3575
+ ): Promise<{ success: true; message: string } | { success: false; status: number; error: string }> {
3576
+ const { dataDir, gitDir, token } = options;
3577
+
3578
+ if (!fs.existsSync(dataDir)) {
3579
+ return { success: false, status: 500, error: 'Data directory does not exist' };
3580
+ }
3581
+
3582
+ const written = writeFiles(data, dataDir);
3583
+ const relativePaths = written.map((p) => path.relative(gitDir, p)).filter((p) => !p.startsWith('..'));
3584
+ if (relativePaths.length === 0) {
3585
+ return { success: false, status: 500, error: 'Computed paths are outside git directory' };
3586
+ }
3587
+
3588
+ const commitMessage = `jsonpages: persist ${data.slug} @ ${new Date().toISOString().slice(0, 19)}Z`;
3589
+ const result = await gitAddCommitPush(gitDir, relativePaths, commitMessage, token);
3590
+
3591
+ if (result.success) {
3592
+ return { success: true, message: 'Saved and pushed to remote' };
3593
+ }
3594
+ if (result.error.toLowerCase().includes('failed to push') || result.error.toLowerCase().includes('authentication')) {
3595
+ return { success: false, status: 502, error: result.error };
3596
+ }
3597
+ return { success: false, status: 500, error: result.error };
3598
+ }
3599
+
3600
+ END_OF_FILE_CONTENT
3439
3601
  echo "Creating src/types.ts..."
3440
3602
  cat << 'END_OF_FILE_CONTENT' > "src/types.ts"
3441
3603
  import type { MenuItem } from '@jsonpages/core';
package/package.json CHANGED
@@ -1,29 +1,29 @@
1
- {
2
- "name": "@jsonpages/cli",
3
- "version": "3.0.27",
4
- "description": "JsonPages CLI - Sovereign Projection Engine",
5
- "type": "module",
6
- "bin": {
7
- "jsonpages": "./src/index.js"
8
- },
9
- "files": [
10
- "src",
11
- "assets/src_tenant_alpha.sh"
12
- ],
13
- "scripts": {
14
- "build": "tsc -p ."
15
- },
16
- "dependencies": {
17
- "@jsonpages/stack": "^1.0.0",
18
- "chalk": "^5.3.0",
19
- "commander": "^12.1.0",
20
- "execa": "^9.0.2",
21
- "fs-extra": "^11.2.0",
22
- "ora": "^8.0.1"
23
- },
24
- "devDependencies": {
25
- "@types/fs-extra": "^11.0.4",
26
- "@types/node": "^22.13.1",
27
- "typescript": "^5.7.3"
28
- }
29
- }
1
+ {
2
+ "name": "@jsonpages/cli",
3
+ "version": "3.0.29",
4
+ "description": "JsonPages CLI - Sovereign Projection Engine",
5
+ "type": "module",
6
+ "bin": {
7
+ "jsonpages": "./src/index.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "assets/src_tenant_alpha.sh"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc -p ."
15
+ },
16
+ "dependencies": {
17
+ "@jsonpages/stack": "^1.0.0",
18
+ "chalk": "^5.3.0",
19
+ "commander": "^12.1.0",
20
+ "execa": "^9.0.2",
21
+ "fs-extra": "^11.2.0",
22
+ "ora": "^8.0.1"
23
+ },
24
+ "devDependencies": {
25
+ "@types/fs-extra": "^11.0.4",
26
+ "@types/node": "^22.13.1",
27
+ "typescript": "^5.7.3"
28
+ }
29
+ }