fix(builder): Preview includes site headCode and is \$-safe (D8)

Two bugs in TopBar's Preview handler:

1. exportToHtml() was called without a headCode option, so Preview
   silently ignored SiteDesign.headCode (custom <head> code the user
   set via the Code modal). Pull `design.headCode` from useSiteDesign()
   and pass it through, matching exportToHtml's ExportOptions.headCode
   signature.

2. html.replace(bodyMatch[1], composedBody) used the string-replacer
   overload of String.prototype.replace, which treats `$&`, `$1`,
   `$$`, etc. in the replacement string as special patterns -- any
   user content containing a literal `$` sequence would corrupt the
   preview HTML. Switch to a function replacer
   (`.replace(bodyMatch[1], () => composedBody)`) so the composed body
   is inserted literally.

Verified by reading + `npm run build`; no existing TopBar test harness
to extend (dynamic import + window.open/Blob in the click handler).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 13:29:02 -07:00
parent d4ee09e54c
commit e1d381dc8a
+8 -2
View File
@@ -3,6 +3,7 @@ import { useEditor } from '@craftjs/core';
import { useEditorConfig } from '../../state/EditorConfigContext'; import { useEditorConfig } from '../../state/EditorConfigContext';
import { useWhpApi } from '../../hooks/useWhpApi'; import { useWhpApi } from '../../hooks/useWhpApi';
import { usePages } from '../../state/PageContext'; import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { DeviceMode } from '../../types'; import { DeviceMode } from '../../types';
import { TemplateModal } from './TemplateModal'; import { TemplateModal } from './TemplateModal';
import { HeadCodeModal } from './HeadCodeModal'; import { HeadCodeModal } from './HeadCodeModal';
@@ -22,6 +23,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
})); }));
const { save, publish, load } = useWhpApi(); const { save, publish, load } = useWhpApi();
const { headerPage, footerPage } = usePages(); const { headerPage, footerPage } = usePages();
const { design } = useSiteDesign();
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle'); const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle');
@@ -189,13 +191,17 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
const result = exportToHtml(serialized, { const result = exportToHtml(serialized, {
title: whpConfig?.siteName || 'Preview', title: whpConfig?.siteName || 'Preview',
includeFonts: true, includeFonts: true,
headCode: design.headCode,
}); });
// Replace the body in the full document with our composed version // Replace the body in the full document with our composed version.
// Use a function replacer -- a plain string replacer treats
// `$&`/`$1`/`$$` sequences in user content as replacement
// patterns, silently corrupting the output.
let html = result.html; let html = result.html;
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i); const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
if (bodyMatch) { if (bodyMatch) {
html = html.replace(bodyMatch[1], composedBody); html = html.replace(bodyMatch[1], () => composedBody);
} }
// Make proxy URLs absolute so they work from the blob: context // Make proxy URLs absolute so they work from the blob: context