sitesmith: show uploaded images in the editor canvas and Preview
Sitesmith writes uploads as assets/<file>, which the editor (panel origin) cannot load. After the repair + allow-list cleaner, apply now converts every real-upload reference to the same panel proxy URL the AssetPicker stores (publish rewrites it back to assets/<file>); canvas summaries sent to the model show assets/<file> again. Covers apply, self-review patches and regenerate-section. Adds unit, Craft integration and a Chromium+Firefox browser suite (test:browser:sitesmith-editor-assets) incl. the real PHP publish rewrite. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185ukXgk6UunFCKyXqLe2Cq
This commit is contained in:
1 parent
bf12520d01
commit
c6809293c4
13 files changed
+609
-6
No files matched your search
@@ -21,6 +21,7 @@
|
||||
"test:browser:capture-fidelity": "node scripts/e2e-capture-fidelity.mjs",
|
||||
"test:browser:capture-security": "node scripts/e2e-capture-security.mjs",
|
||||
"test:browser:sitesmith": "node scripts/e2e-sitesmith.mjs",
|
||||
"test:browser:sitesmith-editor-assets": "node scripts/e2e-sitesmith-editor-assets.mjs",
|
||||
"test:browser:sitesmith-cleaned": "node scripts/e2e-sitesmith-cleaned.mjs",
|
||||
"test:unit:watch": "vitest"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* Used by scripts/e2e-sitesmith-editor-assets.mjs: reads {html,css} JSON on stdin and prints what the publish handler
|
||||
* (api/site-builder.php) sends on to generateCompiledHTML: the body and CSS after sb_rewrite_asset_urls(), then the compiled
|
||||
* document. Usage: php e2e-sitesmith-editor-assets-backend.php <backend repo dir>
|
||||
*/
|
||||
ob_start();
|
||||
define('SB_API_LIBRARY_ONLY', true);
|
||||
define('AUTH_USER', 'alice');
|
||||
define('HOME_DIR', '/docker/users/alice');
|
||||
require rtrim($argv[1], '/') . '/web-files/api/site-builder.php';
|
||||
ob_end_clean();
|
||||
$in = json_decode(stream_get_contents(STDIN), true);
|
||||
$body = sb_rewrite_asset_urls((string)$in['html']);
|
||||
$css = sb_rewrite_asset_urls((string)$in['css']);
|
||||
$U = 'https://example.com/';
|
||||
echo json_encode(['body' => $body, 'doc' => generateCompiledHTML('Home', $css, $body, '', [], '', [], $U, sb_canonical_url($U, 'index.html'))]);
|
||||
@@ -0,0 +1,48 @@
|
||||
// Bundled (esbuild) by scripts/e2e-sitesmith-editor-assets.mjs: one Sitesmith response that places uploads as
|
||||
// `assets/<file>`, run through the apply-time order (cleaner, then the editor-URL conversion) and exported the way the
|
||||
// editor canvas / Preview and a publish do. Three variants: as the model wrote it (the bug), converted (the fix), and
|
||||
// the same page as a manual AssetPicker insertion (the reference the fix must equal).
|
||||
import { cleanAiResponse } from '../src/sitesmith/clean-ai-response';
|
||||
import { toEditorAssetUrls, editorProxyUrl } from '../src/sitesmith/editor-asset-urls';
|
||||
import { flattenTreeForCraft } from '../src/utils/craft-tree';
|
||||
import { exportBodyHtml, exportComposedPreviewHtml } from '../src/utils/html-export';
|
||||
import { DEFAULT_SITE_DESIGN } from '../src/state/SiteDesignContext';
|
||||
import type { SitesmithResponse } from '../src/types/sitesmith';
|
||||
|
||||
export const SITE_ID = 7;
|
||||
/** Real upload names. Upload() sanitises to [A-Za-z0-9_.-]; the other two arrive via FTP / stock import. */
|
||||
export const FILES = ['1700000000_aa11bb22_hero.png', '1700000001_bb22cc33_logo+v2.png', 'my photo.png', 'café.png'];
|
||||
export const MISSING = 'assets/does-not-exist.png';
|
||||
export const ASSETS = FILES.map((f) => ({ filename: f, relUrl: `assets/${f}`, url: editorProxyUrl(SITE_ID, f), width: 800, height: 600 }));
|
||||
// what the model writes: relUrl verbatim, but the cleaner only accepts [A-Za-z0-9._%~+-], so a space / unicode name arrives %-encoded
|
||||
const rel = (i: number) => 'assets/' + FILES[i].split('').map((c) => (/[A-Za-z0-9._~+-]/.test(c) ? c : encodeURIComponent(c))).join('');
|
||||
const n = (type: string, props: Record<string, unknown>, nodes?: any[]) => ({ type: { resolvedName: type }, props, ...(nodes ? { nodes } : {}) });
|
||||
|
||||
function page(src: (i: number) => string): SitesmithResponse {
|
||||
return { type: 'replace', scope: 'page', message: 'm', pages: [{ name: 'Home', tree: n('Container', { node_id: 'ROOT', tag: 'div' }, [
|
||||
n('Navbar', { node_id: 'nav', logoType: 'image', logoImage: src(1), logoUrl: '/', links: [{ text: 'Home', href: '/' }] }),
|
||||
n('BackgroundSection', { node_id: 'bg', bgImage: src(0), overlayOpacity: 0.2 }, [n('Heading', { node_id: 'h', text: 'Hello', level: 'h1' })]),
|
||||
n('ImageBlock', { node_id: 'i1', src: src(0), alt: 'plain' }),
|
||||
n('ImageBlock', { node_id: 'i2', src: src(1), alt: 'plus' }),
|
||||
n('ImageBlock', { node_id: 'i3', src: src(2), alt: 'space' }),
|
||||
n('ImageBlock', { node_id: 'i4', src: src(3), alt: 'unicode' }),
|
||||
n('Container', { node_id: 'c', tag: 'div', style: { minHeight: '60px', backgroundImage: `url(${src(0)})` } }),
|
||||
n('HtmlBlock', { node_id: 'html', code: `<img id="rich" src="${src(0)}" alt="rich">` }),
|
||||
n('ImageBlock', { node_id: 'i5', src: MISSING, alt: 'missing' }),
|
||||
]) }] };
|
||||
}
|
||||
|
||||
type Variant = 'bug' | 'fix' | 'manual';
|
||||
function tree(v: Variant) {
|
||||
// manual: the proxy URL is what the picker stores (no cleaner in that path); bug / fix: the model's `assets/<file>`, cleaned
|
||||
let resp: SitesmithResponse = v === 'manual' ? page((i) => ASSETS[i].url) : cleanAiResponse(page(rel));
|
||||
if (v === 'fix') resp = toEditorAssetUrls(resp, ASSETS, { siteId: SITE_ID }).resp;
|
||||
if (resp.type !== 'replace') throw new Error('unexpected');
|
||||
return JSON.stringify(flattenTreeForCraft(resp.pages[0].tree).nodes);
|
||||
}
|
||||
export function body(v: Variant): string { return exportBodyHtml(tree(v)).html; }
|
||||
/** The editor Preview / canvas document. */
|
||||
export function editorDoc(v: Variant): string {
|
||||
const state = tree(v);
|
||||
return exportComposedPreviewHtml(state, exportBodyHtml(state).html, { title: 'Home', includeFonts: false, design: DEFAULT_SITE_DESIGN });
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Real-browser check (Chromium AND Firefox, headless) that uploads Sitesmith places as `assets/<file>` actually LOAD in the
|
||||
* editor canvas / Preview (a srcdoc iframe on the panel origin, like Canvas.tsx), and that publishing still yields only
|
||||
* `assets/<file>`:
|
||||
* 1. control (the model's page as written): every upload <img> is broken (naturalWidth 0) -- the bug this guards against;
|
||||
* 2. fixed (cleaner then toEditorAssetUrls, the apply order): every upload <img> loads from the stub proxy for THIS site,
|
||||
* the background and CSS url() uploads are requested, a file that does not exist is left alone;
|
||||
* 3. publish: the fixed page run through the REAL backend sb_rewrite_asset_urls + generateCompiledHTML contains
|
||||
* `assets/<file>` only (no proxy URL, no site_id), is byte-identical to the same page inserted manually with the
|
||||
* AssetPicker, and the published document's images load from the site's own /assets/.
|
||||
* Nothing leaves the machine (page.route answers or aborts every request).
|
||||
* npm run test:browser:sitesmith-editor-assets Env: WHP_BACKEND_DIR (a whp worktree root), CHROMIUM_PATH / FIREFOX_PATH
|
||||
*/
|
||||
import { chromium, firefox } from '@playwright/test';
|
||||
import { build as esbuild } from 'esbuild';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const BACKEND_DIR = process.env.WHP_BACKEND_DIR || '/workspace/whp-wt-editor-asset-urls';
|
||||
const out = await esbuild({
|
||||
entryPoints: [path.join(root, 'scripts/e2e-sitesmith-editor-assets-entry.ts')],
|
||||
bundle: true, write: false, format: 'esm', platform: 'node', jsx: 'automatic', logLevel: 'error',
|
||||
define: { 'process.env.NODE_ENV': '"production"', __EDITOR_BUILD__: '"test"' },
|
||||
banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" },
|
||||
});
|
||||
const tmp = path.join(os.tmpdir(), `wsb-ss-editor-assets-${process.pid}.mjs`);
|
||||
fs.writeFileSync(tmp, out.outputFiles[0].text);
|
||||
// HtmlBlock sanitising (DOMPurify) needs a DOM; a bare jsdom window is enough for the exporter. Set before the bundle loads.
|
||||
const { JSDOM } = await import('jsdom');
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>');
|
||||
globalThis.window = dom.window; globalThis.document = dom.window.document;
|
||||
const ent = await import(tmp);
|
||||
fs.rmSync(tmp, { force: true });
|
||||
|
||||
let pass = 0; let fail = 0; let skip = 0;
|
||||
const ok = (c, m, d = '') => { if (c) { pass++; console.log('PASS ', m); } else { fail++; console.log('FAIL ', m, d); } };
|
||||
|
||||
const PANEL = 'http://panel.test';
|
||||
const SITE = 'http://site.test';
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
|
||||
const known = new Set(ent.FILES);
|
||||
|
||||
/** Load `doc` inside a srcdoc iframe on the panel origin (as the canvas does); answer the proxy like api/assets-proxy. */
|
||||
async function loadEditor(browser, doc) {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const reqs = [];
|
||||
await page.route('**/*', async (route) => {
|
||||
const u = new URL(route.request().url());
|
||||
reqs.push(u.pathname + u.search);
|
||||
if (u.origin !== PANEL) return route.abort();
|
||||
if (u.pathname === '/site-builder/') return route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><iframe id="f" style="width:900px;height:900px"></iframe>' });
|
||||
if (u.pathname === '/api/assets-proxy/') {
|
||||
// URLSearchParams decodes '+' as a space, exactly like PHP's $_GET
|
||||
const name = u.searchParams.get('filename');
|
||||
if (u.searchParams.get('site_id') === String(ent.SITE_ID) && known.has(name)) return route.fulfill({ status: 200, contentType: 'image/png', body: PNG });
|
||||
}
|
||||
return route.fulfill({ status: 404, body: '' });
|
||||
});
|
||||
await page.goto(PANEL + '/site-builder/', { waitUntil: 'load' });
|
||||
await page.evaluate((d) => { document.getElementById('f').srcdoc = d; }, doc);
|
||||
const frame = await (await page.waitForSelector('#f')).contentFrame();
|
||||
await frame.waitForSelector('img');
|
||||
await page.waitForTimeout(500);
|
||||
const imgs = await frame.evaluate(() => [...document.images].map((i) => ({ alt: i.alt, id: i.id, ok: i.complete && i.naturalWidth > 0 })));
|
||||
const bgs = await frame.evaluate(() => [...document.querySelectorAll('body *')].map((e) => getComputedStyle(e).backgroundImage).filter((b) => b && b !== 'none'));
|
||||
await ctx.close();
|
||||
return { imgs, reqs, bgs };
|
||||
}
|
||||
|
||||
/** Load the PUBLISHED document at the site root; /assets/<file> is served by decoded path. */
|
||||
async function loadPublished(browser, doc) {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.route('**/*', async (route) => {
|
||||
const u = new URL(route.request().url());
|
||||
if (u.origin !== SITE) return route.abort();
|
||||
if (u.pathname === '/') return route.fulfill({ status: 200, contentType: 'text/html', body: doc });
|
||||
if (u.pathname.startsWith('/assets/') && known.has(decodeURIComponent(u.pathname.slice(8)))) return route.fulfill({ status: 200, contentType: 'image/png', body: PNG });
|
||||
return route.fulfill({ status: 404, body: '' });
|
||||
});
|
||||
await page.goto(SITE + '/', { waitUntil: 'load' });
|
||||
await page.waitForTimeout(500);
|
||||
const imgs = await page.evaluate(() => [...document.images].map((i) => ({ alt: i.alt, ok: i.complete && i.naturalWidth > 0 })));
|
||||
await ctx.close();
|
||||
return imgs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- publish through the real backend
|
||||
const haveBackend = fs.existsSync(path.join(BACKEND_DIR, 'web-files/api/site-builder.php'));
|
||||
function publish(variant) {
|
||||
const raw = execFileSync('php', [path.join(root, 'scripts/e2e-sitesmith-editor-assets-backend.php'), BACKEND_DIR], { input: JSON.stringify({ html: ent.body(variant), css: '' }), maxBuffer: 1 << 26 }).toString();
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
const WANT_REL = ['assets/1700000000_aa11bb22_hero.png', 'assets/1700000001_bb22cc33_logo%2Bv2.png', 'assets/my%20photo.png', 'assets/caf%C3%A9.png'];
|
||||
|
||||
// static checks first (no browser needed)
|
||||
const fixedBody = ent.body('fix');
|
||||
ok(!/src="assets\//.test(fixedBody) || /does-not-exist/.test(fixedBody), 'fixed editor HTML has no upload left as a relative assets/ src');
|
||||
ok(fixedBody.includes('assets/does-not-exist.png'), 'a file that is not an upload is left as written (asset-repair owns it)');
|
||||
ok(ent.body('bug').includes('assets/1700000000_aa11bb22_hero.png'), 'control still carries the relative reference (the bug exists without the conversion)');
|
||||
|
||||
let pub = null;
|
||||
if (haveBackend) {
|
||||
pub = publish('fix');
|
||||
const man = publish('manual');
|
||||
ok(WANT_REL.every((r) => pub.body.includes(r)), 'published body references every upload as assets/<rawurlencoded file>', pub.body.match(/assets\/[^"')]*/g)?.join(' '));
|
||||
ok(!/assets-proxy|site_id|serve_asset/.test(pub.body) && !/assets-proxy|site_id|serve_asset/.test(pub.doc), 'no editor-only URL leaks into the published HTML (body or document)');
|
||||
ok(pub.body === man.body, 'published body is byte-identical to a manual AssetPicker insertion');
|
||||
ok(pub.doc === man.doc, 'published document is byte-identical to a manual AssetPicker insertion');
|
||||
ok(pub.body.includes('does-not-exist.png'), 'published page still has the non-upload reference untouched');
|
||||
} else {
|
||||
skip++; console.log(`SKIP published-output checks: no backend at WHP_BACKEND_DIR=${BACKEND_DIR}`);
|
||||
}
|
||||
|
||||
for (const [name, launcher, exe] of [['chromium', chromium, process.env.CHROMIUM_PATH], ['firefox', firefox, process.env.FIREFOX_PATH]]) {
|
||||
let browser;
|
||||
try { browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) }); }
|
||||
catch (e) { skip++; console.log(`SKIP [${name}] browser not available: ${String(e.message).split('\n')[0]}`); continue; }
|
||||
const control = await loadEditor(browser, ent.editorDoc('bug'));
|
||||
const upl = (r) => r.imgs.filter((i) => ['plain', 'plus', 'space', 'unicode', 'rich'].includes(i.alt));
|
||||
ok(upl(control).length === 5 && upl(control).every((i) => !i.ok), `[${name}] control (as the model wrote it): all 5 upload images are BROKEN in the editor`, JSON.stringify(control.imgs));
|
||||
const fixed = await loadEditor(browser, ent.editorDoc('fix'));
|
||||
ok(upl(fixed).length === 5 && upl(fixed).every((i) => i.ok), `[${name}] fixed: all 5 upload images (plain, + , space, unicode, HtmlBlock) load in the editor (naturalWidth>0)`, JSON.stringify(fixed.imgs));
|
||||
const reqs = fixed.reqs.filter((r) => r.startsWith('/api/assets-proxy/'));
|
||||
ok(reqs.length >= 4 && reqs.every((r) => r.includes('site_id=' + ent.SITE_ID)), `[${name}] fixed: the proxy is requested for the CURRENT site only`, JSON.stringify(reqs));
|
||||
const proxyBgs = fixed.bgs.filter((b) => b.includes('/api/assets-proxy/?site_id=' + ent.SITE_ID));
|
||||
ok(proxyBgs.length >= 2, `[${name}] fixed: BackgroundSection bgImage and a CSS url() background both resolve to the proxy`, JSON.stringify(fixed.bgs));
|
||||
ok(!control.bgs.some((b) => b.includes('assets-proxy')) && control.bgs.length >= 2, `[${name}] control: those backgrounds point at the broken relative path`, JSON.stringify(control.bgs));
|
||||
ok(!fixed.reqs.some((r) => r.startsWith('/site-builder/assets/') && !r.includes('does-not-exist')), `[${name}] fixed: nothing asks the panel for /site-builder/assets/<upload>`, JSON.stringify(fixed.reqs));
|
||||
if (pub) {
|
||||
const p = await loadPublished(browser, pub.doc);
|
||||
const u = p.filter((i) => ['plain', 'plus', 'space', 'unicode', 'rich'].includes(i.alt));
|
||||
ok(u.length === 5 && u.every((i) => i.ok), `[${name}] published document: every upload image loads from the site's own /assets/`, JSON.stringify(p));
|
||||
}
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`\n${pass} passed, ${fail} failed, ${skip} skipped`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -234,7 +234,7 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
|
||||
}),
|
||||
applyResult: async (res) => {
|
||||
if (!res.ok) return { ok: false, message: res.message };
|
||||
const a = await apply(tokenizeReviewOps(res.response, design).resp);
|
||||
const a = await apply(tokenizeReviewOps(res.response, design).resp, undefined, { editorAssets: assets });
|
||||
return { ok: a.ok, message: a.message, changed: a.changed, ops: a.ops, dropped: a.dropped, summary: a.adjustments?.length ? summarizeAdjustments(a.adjustments, a.notChecked) : undefined };
|
||||
},
|
||||
removeImage: (id) => ss.deleteRefImage(id),
|
||||
@@ -341,8 +341,9 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
|
||||
const canvas = summarizeCanvas(query.getSerializedNodes());
|
||||
const result = await send('Regenerate this section for my business.', canvas, targetPayload, { intent: 'regenerate_section', sitePages: buildSitePagesPayload(pageCtx.pages), design: buildDesignPayload(design), useAssets: useAssetsFlag, assetFilenames: assetFilter });
|
||||
if (!result.ok) { setError(failureMessage(result)); return; }
|
||||
const checked = repairAssetUse(result.response, { assets: await freshAssets() }); // round 9: never publish a broken upload reference
|
||||
const r = applyRegeneratedSection(actions, query, repairForCanvas(checked.resp, design, query).response, target.nodeId);
|
||||
const fresh = await freshAssets();
|
||||
const checked = repairAssetUse(result.response, { assets: fresh }); // round 9: never publish a broken upload reference
|
||||
const r = applyRegeneratedSection(actions, query, repairForCanvas(checked.resp, design, query).response, target.nodeId, fresh); // fresh: editor shows uploads via the panel proxy
|
||||
if (!r.ok) { setError(r.message || 'Apply failed'); return; }
|
||||
notify({
|
||||
kind: 'success',
|
||||
|
||||
@@ -20,6 +20,8 @@ import { IMAGE_KEYS } from './clean-ai-response';
|
||||
export interface RepairAsset {
|
||||
filename: string;
|
||||
relUrl?: string;
|
||||
/** The editor-facing panel proxy URL (SitesmithAsset.url / previewUrl); used only by editor-asset-urls.ts. */
|
||||
url?: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
caption?: string | null;
|
||||
|
||||
@@ -42,7 +42,7 @@ export const AI_FORBIDDEN_PROPS: ReadonlySet<string> = new Set([
|
||||
]);
|
||||
|
||||
export const IMAGE_KEYS = new Set(['src', 'image', 'imageSrc', 'imageUrl', 'logoImage', 'poster', 'bgImage', 'backgroundImageUrl', 'avatar', 'avatarUrl', 'photo', 'thumbnail', 'images']);
|
||||
const VIDEO_KEYS = new Set(['videoUrl', 'bgVideo', 'embedUrl']);
|
||||
export const VIDEO_KEYS = new Set(['videoUrl', 'bgVideo', 'embedUrl']);
|
||||
const LINK_KEYS = new Set(['href', 'link', 'url', 'logoUrl', 'buttonHref', 'buttonUrl', 'secondaryButtonHref', 'allReviewsUrl', 'directionsUrl']);
|
||||
const ID_KEYS = new Set(['anchorId', 'id', 'htmlId']);
|
||||
const CSS_VALUE_KEY = /(color|colour|bg|background|gradientfrom|gradientto|bgvalue|shadow)$/i;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { toEditorAssetUrls, editorUrlsToRel, editorProxyUrl, phpUrlencode } from './editor-asset-urls';
|
||||
import { repairAssetUse } from './asset-repair';
|
||||
import { cleanAiResponse } from './clean-ai-response';
|
||||
import type { SitesmithResponse } from '../types/sitesmith';
|
||||
|
||||
const A = (filename: string, extra: Record<string, unknown> = {}) => ({ filename, relUrl: `assets/${filename}`, width: 2000, height: 1200, ...extra });
|
||||
const n = (type: string, props: Record<string, unknown>, nodes?: any[]) => ({ type: { resolvedName: type }, props, ...(nodes ? { nodes } : {}) });
|
||||
const page = (...kids: any[]): SitesmithResponse => ({ type: 'replace', scope: 'page', message: 'm', pages: [{ name: 'Home', tree: n('Container', {}, kids) }] });
|
||||
const px = (f: string, site = 7) => `/api/assets-proxy/?site_id=${site}&filename=${phpUrlencode(f)}`;
|
||||
const kid = (r: SitesmithResponse, i = 0): any => (r as any).pages[0].tree.nodes[i];
|
||||
const A1 = [A('1_aa_dining-room.jpg'), A('2_bb_My Logo+v2.png'), A('3_cc_café.webp')];
|
||||
|
||||
beforeEach(() => { (window as any).WHP_CONFIG = { siteId: 7 }; });
|
||||
afterEach(() => { delete (window as any).WHP_CONFIG; });
|
||||
|
||||
describe('phpUrlencode / editorProxyUrl match PHP urlencode()', () => {
|
||||
test.each([
|
||||
['plain.jpg', 'plain.jpg'], ['My Logo.png', 'My+Logo.png'], ['a+b.png', 'a%2Bb.png'], ['café.webp', 'caf%C3%A9.webp'],
|
||||
['a~b(1)!*\'.png', 'a%7Eb%281%29%21%2A%27.png'], ['100%.png', '100%25.png'], ['a&b=c.png', 'a%26b%3Dc.png'],
|
||||
])('%s', (i, o) => expect(phpUrlencode(i)).toBe(o));
|
||||
test('proxy url shape equals handleUploadAsset', () => expect(editorProxyUrl(7, 'My Logo.png')).toBe('/api/assets-proxy/?site_id=7&filename=My+Logo.png'));
|
||||
});
|
||||
|
||||
describe('toEditorAssetUrls: every asset reference the cleaner lets through', () => {
|
||||
test('image props, nested items, arrays, backgrounds, CSS url(), video', () => {
|
||||
const r = cleanAiResponse(page(
|
||||
n('ImageBlock', { src: 'assets/1_aa_dining-room.jpg', alt: 'x' }),
|
||||
n('HeroSimple', { bgImage: 'assets/1_aa_dining-room.jpg', style: { backgroundImage: 'url(assets/1_aa_dining-room.jpg)', color: '#fff' } }),
|
||||
n('CallToAction', { bgType: 'image', bgValue: 'assets/1_aa_dining-room.jpg' }),
|
||||
n('Gallery', { images: ['assets/1_aa_dining-room.jpg', 'https://placehold.co/10x10'] }),
|
||||
n('Gallery', { images: [{ src: 'assets/3_cc_caf%C3%A9.webp', alt: 'c' }] }),
|
||||
n('Navbar', { logoType: 'image', logoImage: 'assets/2_bb_My Logo+v2.png'.replace(' ', '%20') }),
|
||||
n('VideoBlock', { url: 'assets/1_aa_dining-room.jpg' }),
|
||||
n('HeroSimple', { bgVideo: 'assets/1_aa_dining-room.jpg', poster: 'assets/1_aa_dining-room.jpg' }),
|
||||
n('Testimonials', { items: [{ avatar: 'assets/3_cc_caf%C3%A9.webp', name: 'n' }] }),
|
||||
));
|
||||
const { resp, converted } = toEditorAssetUrls(r, A1);
|
||||
expect(converted).toBeGreaterThanOrEqual(10);
|
||||
const k = (i: number) => kid(resp, i).props;
|
||||
expect(k(0).src).toBe(px('1_aa_dining-room.jpg'));
|
||||
expect(k(1).bgImage).toBe(px('1_aa_dining-room.jpg'));
|
||||
expect(k(1).style.backgroundImage).toBe(`url(${px('1_aa_dining-room.jpg')})`);
|
||||
expect(k(2).bgValue).toBe(px('1_aa_dining-room.jpg'));
|
||||
expect(k(3).images).toEqual([px('1_aa_dining-room.jpg'), 'https://placehold.co/10x10']);
|
||||
expect(k(4).images[0].src).toBe(px('3_cc_café.webp'));
|
||||
expect(k(5).logoImage).toBe(px('2_bb_My Logo+v2.png'));
|
||||
expect(k(5).logoImage).toContain('filename=2_bb_My+Logo%2Bv2.png');
|
||||
expect(k(6).url).toBe(px('1_aa_dining-room.jpg'));
|
||||
expect(k(7).bgVideo).toBe(px('1_aa_dining-room.jpg'));
|
||||
expect(k(7).poster).toBe(px('1_aa_dining-room.jpg'));
|
||||
expect(k(8).items[0].avatar).toBe(px('3_cc_café.webp'));
|
||||
});
|
||||
test('quoted url() keeps its quotes; HtmlBlock / rich text markup src, poster and url() are converted with &', () => {
|
||||
const r = page(
|
||||
n('Container', { style: { backgroundImage: 'url("assets/1_aa_dining-room.jpg")' } }),
|
||||
n('HtmlBlock', { code: '<img src="assets/1_aa_dining-room.jpg"><video poster=\'assets/1_aa_dining-room.jpg\'></video><div style="background:url(assets/1_aa_dining-room.jpg)"></div>' }),
|
||||
);
|
||||
const out = toEditorAssetUrls(r, A1).resp;
|
||||
expect(kid(out, 0).props.style.backgroundImage).toBe(`url("${px('1_aa_dining-room.jpg')}")`);
|
||||
const code = kid(out, 1).props.code as string;
|
||||
expect(code).toContain('src="/api/assets-proxy/?site_id=7&filename=1_aa_dining-room.jpg"');
|
||||
expect(code).toContain("poster='/api/assets-proxy/?site_id=7&filename=1_aa_dining-room.jpg'");
|
||||
expect(code).toContain('url(/api/assets-proxy/?site_id=7&filename=1_aa_dining-room.jpg)');
|
||||
});
|
||||
test('left alone: unknown file, plain text mentioning a path, already-proxy URL, external URL, non-url key', () => {
|
||||
const r = page(
|
||||
n('ImageBlock', { src: 'assets/ghost.jpg' }),
|
||||
n('TextBlock', { text: 'assets/1_aa_dining-room.jpg' }),
|
||||
n('ImageBlock', { src: px('1_aa_dining-room.jpg') }),
|
||||
n('ImageBlock', { src: 'https://placehold.co/600x400' }),
|
||||
n('TextBlock', { html: '<p>see assets/1_aa_dining-room.jpg</p>' }),
|
||||
);
|
||||
const { resp, converted } = toEditorAssetUrls(r, A1);
|
||||
expect(converted).toBe(0);
|
||||
expect(resp).toBe(r);
|
||||
});
|
||||
test('case-insensitive name match like asset-repair; input never mutated', () => {
|
||||
const r = page(n('ImageBlock', { src: 'assets/1_AA_Dining-Room.jpg' }));
|
||||
const before = JSON.stringify(r);
|
||||
expect(kid(toEditorAssetUrls(r, A1).resp).props.src).toBe(px('1_aa_dining-room.jpg'));
|
||||
expect(JSON.stringify(r)).toBe(before);
|
||||
});
|
||||
test('uses the list url when it is this file on this origin and site; rebuilds when it is another site', () => {
|
||||
const own = A('1_aa_dining-room.jpg', { url: px('1_aa_dining-room.jpg', 7) });
|
||||
expect(kid(toEditorAssetUrls(page(n('ImageBlock', { src: 'assets/1_aa_dining-room.jpg' })), [own]).resp).props.src).toBe(px('1_aa_dining-room.jpg', 7));
|
||||
const other = A('1_aa_dining-room.jpg', { url: px('1_aa_dining-room.jpg', 99) });
|
||||
expect(kid(toEditorAssetUrls(page(n('ImageBlock', { src: 'assets/1_aa_dining-room.jpg' })), [other]).resp).props.src).toBe(px('1_aa_dining-room.jpg', 7));
|
||||
const foreign = A('1_aa_dining-room.jpg', { url: 'https://evil.example/api/assets-proxy/?site_id=7&filename=1_aa_dining-room.jpg' });
|
||||
expect(kid(toEditorAssetUrls(page(n('ImageBlock', { src: 'assets/1_aa_dining-room.jpg' })), [foreign]).resp).props.src).toBe(px('1_aa_dining-room.jpg', 7));
|
||||
});
|
||||
test('no current site id and no usable list url: nothing is converted (never guess a site)', () => {
|
||||
delete (window as any).WHP_CONFIG;
|
||||
const r = page(n('ImageBlock', { src: 'assets/1_aa_dining-room.jpg' }));
|
||||
expect(toEditorAssetUrls(r, A1).resp).toBe(r);
|
||||
});
|
||||
test('patch ops: update_props, replace_node, insert_after', () => {
|
||||
const r: SitesmithResponse = { type: 'patch', message: 'm', ops: [
|
||||
{ op: 'update_props', node_id: 'a', props: { src: 'assets/1_aa_dining-room.jpg' } },
|
||||
{ op: 'replace_node', node_id: 'b', tree: n('ImageBlock', { src: 'assets/1_aa_dining-room.jpg' }) },
|
||||
{ op: 'insert_after', node_id: 'c', tree: n('ImageBlock', { src: 'assets/3_cc_café.webp' }) },
|
||||
{ op: 'delete_node', node_id: 'd' },
|
||||
] } as any;
|
||||
const o: any = toEditorAssetUrls(r, A1).resp;
|
||||
expect(o.ops[0].props.src).toBe(px('1_aa_dining-room.jpg'));
|
||||
expect(o.ops[1].tree.props.src).toBe(px('1_aa_dining-room.jpg'));
|
||||
expect(o.ops[2].tree.props.src).toBe(px('3_cc_café.webp'));
|
||||
expect(o.ops[3]).toEqual({ op: 'delete_node', node_id: 'd' });
|
||||
});
|
||||
test('null / empty asset lists and ask responses change nothing', () => {
|
||||
const r = page(n('ImageBlock', { src: 'assets/1_aa_dining-room.jpg' }));
|
||||
expect(toEditorAssetUrls(r, null).resp).toBe(r);
|
||||
expect(toEditorAssetUrls(r, []).resp).toBe(r);
|
||||
const ask = { type: 'ask', question: 'q' } as any;
|
||||
expect(toEditorAssetUrls(ask, A1).resp).toBe(ask);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the way back: editorUrlsToRel and asset-repair take BOTH forms', () => {
|
||||
test('editorUrlsToRel', () => {
|
||||
expect(editorUrlsToRel(px('2_bb_My Logo+v2.png'))).toBe('assets/2_bb_My Logo+v2.png');
|
||||
expect(editorUrlsToRel(`url(${px('1_aa_dining-room.jpg')})`)).toBe('url(assets/1_aa_dining-room.jpg)');
|
||||
expect(editorUrlsToRel(`https://panel.test:8443${px('3_cc_café.webp')}`)).toBe('assets/3_cc_café.webp');
|
||||
expect(editorUrlsToRel('<img src="/api/assets-proxy/?site_id=7&filename=a.png">')).toBe('<img src="assets/a.png">');
|
||||
expect(editorUrlsToRel('/api/site-builder.php?action=serve_asset&site_id=7&filename=a.png')).toBe('assets/a.png');
|
||||
expect(editorUrlsToRel('/api/assets-proxy/?site_id=7&filename=..%2F..%2Fx')).toBe('assets/x');
|
||||
expect(editorUrlsToRel('/api/other?x=1')).toBe('/api/other?x=1');
|
||||
expect(editorUrlsToRel('plain assets/a.png')).toBe('plain assets/a.png');
|
||||
});
|
||||
test('round trip: convert then back equals the model form', () => {
|
||||
for (const f of ['1_aa_dining-room.jpg', '2_bb_My Logo+v2.png', '3_cc_café.webp']) expect(editorUrlsToRel(editorProxyUrl(7, f))).toBe(`assets/${f}`);
|
||||
});
|
||||
test('asset-repair normalises a proxy URL of a real upload to assets/<file> (existing saved pages, both origins)', () => {
|
||||
for (const src of [px('2_bb_My Logo+v2.png'), `https://panel.test:8443${px('2_bb_My Logo+v2.png')}`]) {
|
||||
const { resp } = repairAssetUse(page(n('ImageBlock', { src })), { assets: A1 });
|
||||
expect(kid(resp).props.src).toBe('assets/2_bb_My Logo+v2.png');
|
||||
}
|
||||
});
|
||||
test('repair then clean then convert (the real order) does not double-convert and survives a second pass', () => {
|
||||
const r0 = page(n('ImageBlock', { src: 'assets/1_aa_dining-room.jpg' }));
|
||||
const once = toEditorAssetUrls(cleanAiResponse(repairAssetUse(r0, { assets: A1 }).resp), A1).resp;
|
||||
const twice = toEditorAssetUrls(once, A1).resp;
|
||||
expect(kid(twice).props.src).toBe(px('1_aa_dining-room.jpg'));
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Editor-side form of Sitesmith's upload references.
|
||||
*
|
||||
* Contract with the model: a page refers to an upload as `assets/<file>` (the published, site-relative form;
|
||||
* asset-repair.ts and the allow-list cleaner only know this form). The EDITOR cannot show that form: the canvas
|
||||
* and Preview run on the panel origin, where `assets/<file>` resolves to `/site-builder/assets/<file>` (404).
|
||||
* What the AssetPicker stores for an upload is the same-origin panel proxy URL
|
||||
* `/api/assets-proxy/?site_id=N&filename=<php urlencode(file)>`, and publish rewrites that back to
|
||||
* `assets/<rawurlencode(file)>` (web-files/libs/SiteBuilderAssetUrls.php, sb_rewrite_asset_urls).
|
||||
*
|
||||
* So, AFTER repair + cleaning and just before a Sitesmith result reaches the canvas, {@link toEditorAssetUrls}
|
||||
* turns every `assets/<real upload>` into that proxy URL (identical to a manual AssetPicker insertion, so publish
|
||||
* output is the same), and {@link editorUrlsToRel} does the reverse for anything sent BACK to the model.
|
||||
*
|
||||
* Fields converted (everything the cleaner lets carry an upload):
|
||||
* - URL props: IMAGE_KEYS (src, image, imageSrc, imageUrl, logoImage, poster, bgImage, backgroundImageUrl, avatar,
|
||||
* avatarUrl, photo, thumbnail, images[]), VIDEO_KEYS (videoUrl, bgVideo, embedUrl), plus `url`, `src`, `bgValue`
|
||||
* (VideoBlock.url, CallToAction.bgValue); also inside arrays / nested objects (Gallery items, slides, ...).
|
||||
* - `url(assets/<file>)` inside ANY string (style objects, CSS-valued props such as backgroundImage / bgValue).
|
||||
* - Rich text / HtmlBlock markup: the `src` / `poster` attribute and url() inside strings that contain markup.
|
||||
* DECISION: converted, because the editor renders those strings too and would show a broken image; publish
|
||||
* rewrites the proxy form across the whole page HTML, so published output is unchanged.
|
||||
* Never converted: a name that is not a real upload (asset-repair already replaced or placeholdered those), a plain
|
||||
* text prop that merely mentions "assets/x.png", an off-origin or other-site proxy URL.
|
||||
*/
|
||||
import type { SerializedTreeNode, SitesmithResponse } from '../types/sitesmith';
|
||||
import { assetFileOf, type RepairAsset } from './asset-repair';
|
||||
import { IMAGE_KEYS, VIDEO_KEYS } from './clean-ai-response';
|
||||
|
||||
const URL_KEYS = new Set<string>([...IMAGE_KEYS, ...VIDEO_KEYS, 'url', 'src', 'bgValue']);
|
||||
const WHOLE_REL = /^assets\/[^\s"'()<>?#]+$/;
|
||||
const CSS_URL = /url\(\s*(['"]?)(assets\/[^'")\s]+)\1\s*\)/gi;
|
||||
const HTML_ATTR = /(\b(?:src|poster)\s*=\s*)(["'])(assets\/[^"']+)\2/gi;
|
||||
const PROXY_PATH = '/api/assets-proxy/';
|
||||
|
||||
/** PHP urlencode(): space -> '+', everything but [A-Za-z0-9_.-] percent-encoded (including `~`). */
|
||||
export function phpUrlencode(s: string): string {
|
||||
return encodeURIComponent(s).replace(/[!'()*~]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/%20/g, '+');
|
||||
}
|
||||
|
||||
/** The proxy URL the AssetPicker stores for an upload (handleUploadAsset / handleListAssets in api/site-builder.php). */
|
||||
export function editorProxyUrl(siteId: number | string, filename: string): string {
|
||||
return `${PROXY_PATH}?site_id=${encodeURIComponent(String(siteId))}&filename=${phpUrlencode(filename)}`;
|
||||
}
|
||||
|
||||
function currentSiteId(): string | null {
|
||||
try {
|
||||
const id = (window as any)?.WHP_CONFIG?.siteId;
|
||||
return id === undefined || id === null || id === '' ? null : String(id);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor URL for one upload: the list's own `url` when it is the same-origin proxy for THIS file (and this
|
||||
* site), else one built from the current site id; null when neither is possible (then the reference is left alone).
|
||||
*/
|
||||
function proxyFor(a: RepairAsset, siteId: string | null): string | null {
|
||||
if (a.url) {
|
||||
try {
|
||||
const u = new URL(a.url, typeof location !== 'undefined' ? location.href : 'http://localhost/');
|
||||
const same = typeof location === 'undefined' || u.origin === location.origin;
|
||||
if (same && u.pathname === PROXY_PATH && u.searchParams.get('filename') === a.filename
|
||||
&& (siteId === null || u.searchParams.get('site_id') === siteId)) return u.pathname + u.search;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
return siteId !== null ? editorProxyUrl(siteId, a.filename) : null;
|
||||
}
|
||||
|
||||
interface Ctx { byName: Map<string, RepairAsset>; lower: Map<string, RepairAsset>; siteId: string | null; n: number }
|
||||
|
||||
function convertRef(ref: string, ctx: Ctx, htmlAttr = false): string {
|
||||
const file = assetFileOf(ref);
|
||||
if (!file) return ref;
|
||||
const real = ctx.byName.get(file) ?? ctx.lower.get(file.toLowerCase());
|
||||
const url = real ? proxyFor(real, ctx.siteId) : null;
|
||||
if (!url) return ref;
|
||||
ctx.n++;
|
||||
return htmlAttr ? url.replace(/&/g, '&') : url;
|
||||
}
|
||||
|
||||
function convertString(v: string, urlCtx: boolean, ctx: Ctx): string {
|
||||
if (urlCtx && WHOLE_REL.test(v.trim())) return convertRef(v.trim(), ctx);
|
||||
let out = v;
|
||||
if (/url\(/i.test(out)) out = out.replace(CSS_URL, (m, q: string, ref: string) => { const c = convertRef(ref, ctx); return c === ref ? m : `url(${q}${c}${q})`; });
|
||||
if (out.includes('<')) out = out.replace(HTML_ATTR, (m, pre: string, q: string, ref: string) => { const c = convertRef(ref, ctx, true); return c === ref ? m : `${pre}${q}${c}${q}`; });
|
||||
return out;
|
||||
}
|
||||
|
||||
function walk(v: unknown, key: string, urlCtx: boolean, ctx: Ctx, depth: number): unknown {
|
||||
if (depth > 10) return v;
|
||||
if (typeof v === 'string') return convertString(v, urlCtx, ctx);
|
||||
if (Array.isArray(v)) return v.map((x) => walk(x, key, urlCtx, ctx, depth + 1));
|
||||
if (v && typeof v === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, x] of Object.entries(v as Record<string, unknown>)) out[k] = walk(x, k, URL_KEYS.has(k), ctx, depth + 1);
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function convertTree(t: SerializedTreeNode, ctx: Ctx): SerializedTreeNode {
|
||||
const out: SerializedTreeNode = { ...t, props: walk(t.props ?? {}, '', false, ctx, 0) as SerializedTreeNode['props'] };
|
||||
if (Array.isArray(t.nodes)) out.nodes = t.nodes.map((k) => convertTree(k, ctx));
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface EditorUrlOptions {
|
||||
/** Current site id; default window.WHP_CONFIG.siteId. Without one, only the list's own proxy URLs are used. */
|
||||
siteId?: number | string | null;
|
||||
}
|
||||
|
||||
/** Convert `assets/<real upload>` to the editor proxy URL across a (cleaned) response. Input is not mutated. */
|
||||
export function toEditorAssetUrls(resp: SitesmithResponse, assets: RepairAsset[] | null | undefined, opts: EditorUrlOptions = {}): { resp: SitesmithResponse; converted: number } {
|
||||
if (!assets || !assets.length || resp.type === 'ask') return { resp, converted: 0 };
|
||||
const siteId = opts.siteId !== undefined && opts.siteId !== null ? String(opts.siteId) : currentSiteId();
|
||||
const ctx: Ctx = { byName: new Map(assets.map((a) => [a.filename, a])), lower: new Map(assets.map((a) => [a.filename.toLowerCase(), a])), siteId, n: 0 };
|
||||
let out: SitesmithResponse = resp;
|
||||
if (resp.type === 'replace') {
|
||||
out = {
|
||||
...resp,
|
||||
pages: resp.pages.map((p) => ({ ...p, tree: convertTree(p.tree, ctx) })),
|
||||
...(resp.header ? { header: { ...resp.header, tree: convertTree(resp.header.tree, ctx) } } : {}),
|
||||
...(resp.footer ? { footer: { ...resp.footer, tree: convertTree(resp.footer.tree, ctx) } } : {}),
|
||||
};
|
||||
} else if (resp.type === 'patch') {
|
||||
out = {
|
||||
...resp,
|
||||
ops: (resp.ops ?? []).map((op) => {
|
||||
if (op.op === 'update_props') return { ...op, props: walk(op.props ?? {}, '', false, ctx, 0) as Record<string, unknown> } as typeof op;
|
||||
if ('tree' in op && op.tree) return { ...op, tree: convertTree(op.tree, ctx) } as typeof op;
|
||||
return op;
|
||||
}),
|
||||
};
|
||||
}
|
||||
return ctx.n ? { resp: out, converted: ctx.n } : { resp, converted: 0 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the way back (to the model)
|
||||
|
||||
const PROXY_URL = /(?:(?:https?:)?\/\/[^/"'\s()<>]+)?\/api\/(?:assets-proxy\/\?|site-builder\.php\?action=serve_asset(?:&|&))site_id=[^&"'\s)]+(?:&|&)filename=([^&"'\s)]+)[^"'\s)<>]*/g;
|
||||
|
||||
/** Editor proxy URLs inside a string, shown to the model as `assets/<file>` again (unreadable ones are left alone). */
|
||||
export function editorUrlsToRel(s: string): string {
|
||||
if (!s.includes('/api/')) return s;
|
||||
return s.replace(PROXY_URL, (m, enc: string) => {
|
||||
let name: string;
|
||||
try { name = decodeURIComponent(enc.replace(/\+/g, ' ')); } catch { return m; }
|
||||
name = name.split(/[\\/]/).pop() ?? '';
|
||||
return name && name !== '.' && name !== '..' ? `assets/${name}` : m;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, test, expect, afterEach, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../editorHarness';
|
||||
import { useApplyAiResponse } from '../../utils/apply-ai-response';
|
||||
import { applyRegeneratedSection } from '../../utils/sitesmith-profile';
|
||||
import { exportBodyHtml } from '../../utils/html-export';
|
||||
import { summarizeCanvas } from '../../utils/canvas-summary';
|
||||
|
||||
const INITIAL = JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
|
||||
});
|
||||
let harness: EditorHarness | null = null;
|
||||
beforeEach(() => { (window as any).WHP_CONFIG = { siteId: 12 }; });
|
||||
afterEach(() => { harness?.unmount(); harness = null; delete (window as any).WHP_CONFIG; });
|
||||
|
||||
const px = (f: string) => `/api/assets-proxy/?site_id=12&filename=${f}`;
|
||||
const ASSETS = [
|
||||
{ filename: '1_aa_dining-room.jpg', relUrl: 'assets/1_aa_dining-room.jpg', url: px('1_aa_dining-room.jpg'), width: 3000, height: 2000 },
|
||||
{ filename: '2_bb_logo+v2~x.png', relUrl: 'assets/2_bb_logo+v2~x.png', url: px('2_bb_logo%2Bv2%7Ex.png'), width: 400, height: 200 },
|
||||
];
|
||||
const img = (src: string) => ({ type: { resolvedName: 'ImageBlock' }, props: { src, alt: 'a', node_id: 'img1' } });
|
||||
const section = (...nodes: any[]) => ({ type: { resolvedName: 'Section' }, props: {}, nodes });
|
||||
const resp = (...sections: any[]): any => ({ type: 'replace', scope: 'section', message: 'x', pages: [{ name: 'Home', tree: { type: { resolvedName: 'Container' }, props: {}, nodes: sections } }] });
|
||||
|
||||
async function mount() {
|
||||
harness = renderEditorHarness({ initialState: INITIAL });
|
||||
const ref: { current: ReturnType<typeof useApplyAiResponse> | null } = { current: null };
|
||||
harness.mountChild(React.createElement(() => { ref.current = useApplyAiResponse(); return null; }));
|
||||
return ref;
|
||||
}
|
||||
const exported = () => String((exportBodyHtml(harness!.query.serialize()) as any).html);
|
||||
|
||||
describe('apply(): uploads are shown in the editor via the panel proxy, byte-for-byte what the AssetPicker stores', () => {
|
||||
test('a repaired + cleaned assets/<file> lands in Craft state and the canvas export as the proxy URL', async () => {
|
||||
const ref = await mount();
|
||||
await harness!.act(async () => { await ref.current!(resp(section(img('assets/1_aa_dining-room.jpg'))), undefined, { assets: ASSETS, homePage: false, userText: 'x' }); });
|
||||
const html = exported();
|
||||
expect(html).toContain(px('1_aa_dining-room.jpg').replace(/&/g, '&'));
|
||||
expect(html).not.toMatch(/src="assets\//);
|
||||
const stored = JSON.stringify(harness!.query.serialize());
|
||||
expect(stored).toContain(px('1_aa_dining-room.jpg'));
|
||||
});
|
||||
test('a name with + and ~ (valid for the cleaner) maps to the urlencoded proxy form from the list', async () => {
|
||||
const ref = await mount();
|
||||
await harness!.act(async () => { await ref.current!(resp(section(img('assets/2_bb_logo+v2~x.png'))), undefined, { assets: ASSETS, userText: 'x' }); });
|
||||
expect(JSON.stringify(harness!.query.serialize())).toContain(px('2_bb_logo%2Bv2%7Ex.png'));
|
||||
});
|
||||
test('without an asset list (null / omitted) nothing is converted; a missing file stays for asset-repair', async () => {
|
||||
const ref = await mount();
|
||||
await harness!.act(async () => { await ref.current!(resp(section(img('assets/1_aa_dining-room.jpg'))), undefined, { assets: null }); });
|
||||
expect(exported()).toContain('assets/1_aa_dining-room.jpg');
|
||||
expect(exported()).not.toContain('assets-proxy');
|
||||
});
|
||||
test('review patches: editorAssets converts without turning on repair', async () => {
|
||||
const ref = await mount();
|
||||
await harness!.act(async () => { await ref.current!(resp(section(img('assets/ghost.jpg'))), undefined, { assets: null }); });
|
||||
const ops = { type: 'patch', message: 'm', ops: [{ op: 'update_props', node_id: 'img1', props: { src: 'assets/1_aa_dining-room.jpg' } }] };
|
||||
let out: any;
|
||||
await harness!.act(async () => { out = await ref.current!(ops as any, undefined, { editorAssets: ASSETS }); });
|
||||
expect(out.ok).toBe(true);
|
||||
expect(JSON.stringify(harness!.query.serialize())).toContain(px('1_aa_dining-room.jpg'));
|
||||
});
|
||||
test('back to the model: summarizeCanvas shows assets/<file> for a proxy URL in any summarised value', async () => {
|
||||
const ref = await mount();
|
||||
await harness!.act(async () => { await ref.current!({ type: 'replace', scope: 'section', message: 'x', pages: [{ name: 'H', tree: { type: { resolvedName: 'Container' }, props: {}, nodes: [section({ type: { resolvedName: 'Heading' }, props: { node_id: 'h', text: 'Hi', url: px('1_aa_dining-room.jpg') } })] } }] } as any, undefined, { assets: null }); });
|
||||
const s = summarizeCanvas(harness!.query.getSerializedNodes());
|
||||
expect(s).not.toContain('assets-proxy');
|
||||
expect(s).toContain('url="assets/1_aa_dining-room.jpg"');
|
||||
});
|
||||
test('regenerate-section path converts after its own cleaner pass', async () => {
|
||||
const ref = await mount();
|
||||
await harness!.act(async () => { await ref.current!(resp(section(img('assets/ghost.jpg'))), undefined, { assets: null }); });
|
||||
const target = Object.keys(harness!.query.getSerializedNodes()).find((id) => (harness!.query.getSerializedNodes() as any)[id].type.resolvedName === 'Section')!;
|
||||
let r: any;
|
||||
await harness!.act(async () => { r = applyRegeneratedSection(harness!.actions, harness!.query, resp(section(img('assets/1_aa_dining-room.jpg'))) as any, target, ASSETS); });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(JSON.stringify(harness!.query.serialize())).toContain(px('1_aa_dining-room.jpg'));
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { useSiteDesign, type SiteDesign } from '../state/SiteDesignContext';
|
||||
import { effectiveBackgroundOf } from './effective-background';
|
||||
import { checkPrimaryCtaAccent, type CtaCheck } from '../sitesmith/cta-check';
|
||||
import { repairAssetUse, type RepairAsset } from '../sitesmith/asset-repair';
|
||||
import { toEditorAssetUrls } from '../sitesmith/editor-asset-urls';
|
||||
import { repairResponseContrast, type ContrastAdjustment, type PatchContext } from './contrast-repair';
|
||||
|
||||
// Re-exported for existing callers/tests that import sanitizeAiTree/
|
||||
@@ -125,7 +126,9 @@ export function useApplyAiResponse() {
|
||||
/** Round 9: the site's real uploads (fresh list; null = could not be loaded, nothing is checked; absent = no image repair). */
|
||||
assets?: RepairAsset[] | null;
|
||||
/** Filenames the owner left ticked (hero substitutions pick only from these). */ usableAssets?: string[];
|
||||
/** scope=page: the page being replaced is the home page. */ homePage?: boolean } = {},
|
||||
/** scope=page: the page being replaced is the home page. */ homePage?: boolean;
|
||||
/** Uploads to show in the editor as panel proxy URLs (defaults to `assets`; pass it alone to convert without repairing). */
|
||||
editorAssets?: RepairAsset[] | null } = {},
|
||||
): Promise<ApplyOutcome> {
|
||||
// 'ask' type = AI wants clarification, nothing to apply
|
||||
if (resp.type === 'ask') return { ok: true };
|
||||
@@ -149,6 +152,10 @@ export function useApplyAiResponse() {
|
||||
return typeof name === 'string' ? name : undefined;
|
||||
});
|
||||
|
||||
// Editor canvas/Preview cannot load `assets/<file>`: show uploads as the same proxy URL the AssetPicker stores
|
||||
// (publish rewrites it back to assets/<file>). After the cleaner, which only knows the assets/ form.
|
||||
resp = toEditorAssetUrls(resp, opts.editorAssets !== undefined ? opts.editorAssets : opts.assets).resp;
|
||||
|
||||
// AI-built pages are full-bleed (no 20px white frame from the Container default) -- new replaces only.
|
||||
resp = fullBleedPageRoots(resp);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SerializedNodes } from '@craftjs/core';
|
||||
import { editorUrlsToRel } from '../sitesmith/editor-asset-urls';
|
||||
|
||||
/**
|
||||
* The canvas as Sitesmith reads it: one line per node, nested by indentation,
|
||||
@@ -37,7 +38,8 @@ type Detail = 0 | 1 | 2 | 3; // 0 full, 1 text + short styles, 2 text only, 3
|
||||
const flat = (t: string) => t.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const quoted = (v: unknown, max: number): string | null => {
|
||||
if (v == null || typeof v === 'object' || typeof v === 'function') return null;
|
||||
let s = flat(String(v)).replace(/"/g, "'");
|
||||
// uploads placed in the editor are stored as panel proxy URLs; the model only knows `assets/<file>`
|
||||
let s = editorUrlsToRel(flat(String(v))).replace(/"/g, "'");
|
||||
if (s === '') return null;
|
||||
if (s.length > max) s = s.slice(0, max - 1) + '…';
|
||||
return `"${s}"`;
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { SitesmithResponse, SerializedTreeNode } from '../types/sitesmith';
|
||||
import { isInsideInstance } from './global-sections';
|
||||
import { buildNodeTree } from './craft-tree';
|
||||
import { cleanAiResponse } from '../sitesmith/clean-ai-response';
|
||||
import { toEditorAssetUrls } from '../sitesmith/editor-asset-urls';
|
||||
import type { RepairAsset } from '../sitesmith/asset-repair';
|
||||
|
||||
/**
|
||||
* Business profile gathered by the Sitesmith onboarding wizard. Stored with the
|
||||
@@ -116,8 +118,11 @@ export function applyRegeneratedSection(
|
||||
query: any,
|
||||
resp: SitesmithResponse,
|
||||
targetId: string,
|
||||
/** The site's uploads: `assets/<file>` is then shown as the editor's panel proxy URL (sitesmith/editor-asset-urls.ts). */
|
||||
editorAssets?: RepairAsset[] | null,
|
||||
): { ok: boolean; message?: string } {
|
||||
resp = cleanAiResponse(resp); // G8: same allow-list cleaner as useApplyAiResponse().apply
|
||||
resp = toEditorAssetUrls(resp, editorAssets).resp;
|
||||
let tree: SerializedTreeNode | undefined;
|
||||
if (resp.type === 'patch') {
|
||||
const op = resp.ops.find((o) => o.op === 'replace_node');
|
||||
|
||||
Reference in new issue
Block a user