Add Craft.js site builder (v2) - complete rebuild from GrapesJS
Rebuilt the visual site builder from scratch using Craft.js, React 18, and TypeScript. The new editor renders directly in the DOM (no iframe), supports 40+ components, multi-page with shared header/footer, 16 templates, full-spectrum color/gradient controls, custom head code injection, save/publish workflow, and auto-save. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
243
craft/src/utils/html-export.ts
Normal file
243
craft/src/utils/html-export.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { componentResolver } from '../components/resolver';
|
||||
import { cssPropsToString } from './style-helpers';
|
||||
|
||||
export interface ExportOptions {
|
||||
title?: string;
|
||||
includeFonts?: boolean;
|
||||
minifyCss?: boolean;
|
||||
headCode?: string;
|
||||
}
|
||||
|
||||
interface ResolverMap {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const resolver: ResolverMap = componentResolver;
|
||||
|
||||
/**
|
||||
* Build data attribute string for responsive visibility and animations.
|
||||
*/
|
||||
function buildDataAttrs(props: Record<string, any>): string {
|
||||
let attrs = '';
|
||||
if (props.hideOnDesktop) attrs += ' data-hide-desktop';
|
||||
if (props.hideOnTablet) attrs += ' data-hide-tablet';
|
||||
if (props.hideOnMobile) attrs += ' data-hide-mobile';
|
||||
if (props.animation && props.animation !== 'none') {
|
||||
attrs += ` data-animation="${props.animation}"`;
|
||||
if (props.animationDelay && props.animationDelay !== '0') {
|
||||
attrs += ` data-animation-delay="${props.animationDelay}"`;
|
||||
}
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject data attributes into the first HTML opening tag of a rendered string.
|
||||
*/
|
||||
function injectAttrs(html: string, attrs: string): string {
|
||||
if (!attrs) return html;
|
||||
// Find the first > of the opening tag and inject before it
|
||||
const idx = html.indexOf('>');
|
||||
if (idx === -1) return html;
|
||||
return html.slice(0, idx) + attrs + html.slice(idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively render a Craft.js node tree to HTML.
|
||||
*/
|
||||
function renderNode(nodes: Record<string, any>, nodeId: string): { html: string } {
|
||||
const node = nodes[nodeId];
|
||||
if (!node) return { html: '' };
|
||||
|
||||
const typeName = node.type?.resolvedName || node.type;
|
||||
const props = node.props || {};
|
||||
|
||||
// Collect children HTML
|
||||
const childNodeIds: string[] = node.nodes || [];
|
||||
const linkedNodes: Record<string, string> = node.linkedNodes || {};
|
||||
|
||||
// Render direct child nodes
|
||||
let childrenHtml = childNodeIds
|
||||
.map((childId: string) => renderNode(nodes, childId).html)
|
||||
.join('');
|
||||
|
||||
// Render linked nodes (e.g., Section's inner container)
|
||||
const linkedHtml = Object.values(linkedNodes)
|
||||
.map((linkedId: string) => renderNode(nodes, linkedId).html)
|
||||
.join('');
|
||||
|
||||
// For linked nodes, the component's toHtml should handle them via childrenHtml
|
||||
// We prioritize linked nodes if direct children are empty
|
||||
const allChildrenHtml = childrenHtml + linkedHtml;
|
||||
|
||||
// Build data attributes for responsive visibility and animations
|
||||
const dataAttrs = buildDataAttrs(props);
|
||||
|
||||
// Look up component in resolver and call toHtml
|
||||
const component = resolver[typeName];
|
||||
if (component && typeof component.toHtml === 'function') {
|
||||
const result = component.toHtml(props, allChildrenHtml);
|
||||
const html = result.html || '';
|
||||
return { html: injectAttrs(html, dataAttrs) };
|
||||
}
|
||||
|
||||
// Fallback: wrap children in a div with inline styles
|
||||
if (typeName === 'Container' || typeName === 'div') {
|
||||
const styleStr = cssPropsToString(props.style);
|
||||
const tag = props.tag || 'div';
|
||||
return {
|
||||
html: `<${tag}${dataAttrs}${styleStr ? ` style="${styleStr}"` : ''}>${allChildrenHtml}</${tag}>`,
|
||||
};
|
||||
}
|
||||
|
||||
// For unrecognized types, just return children
|
||||
return { html: allChildrenHtml };
|
||||
}
|
||||
|
||||
const CSS_RESET = `*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}`;
|
||||
|
||||
const CSS_RESET_PRETTY = `*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1f2937;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}`;
|
||||
|
||||
const GOOGLE_FONTS_LINK = `<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap" rel="stylesheet">`;
|
||||
|
||||
const RESPONSIVE_CSS = `
|
||||
@media (max-width: 768px) {
|
||||
[style*="display: flex"][style*="flex-direction: row"],
|
||||
[style*="display:flex"][style*="flex-direction:row"] {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
}`;
|
||||
|
||||
const RESPONSIVE_CSS_MINIFIED = `@media(max-width:768px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}}`;
|
||||
|
||||
const VISIBILITY_CSS = `
|
||||
@media (min-width: 992px) { [data-hide-desktop] { display: none !important; } }
|
||||
@media (min-width: 768px) and (max-width: 991px) { [data-hide-tablet] { display: none !important; } }
|
||||
@media (max-width: 767px) { [data-hide-mobile] { display: none !important; } }`;
|
||||
|
||||
const VISIBILITY_CSS_MINIFIED = `@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}`;
|
||||
|
||||
const ANIMATION_CSS = `
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes slideLeft { from { opacity: 0; transform: translateX(-30px); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes slideRight { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes zoomIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
|
||||
@keyframes bounce { 0% { opacity: 0; transform: translateY(30px); } 60% { opacity: 1; transform: translateY(-5px); } 100% { transform: translateY(0); } }
|
||||
|
||||
[data-animation] { opacity: 0; }
|
||||
[data-animation].animated { animation-duration: 0.6s; animation-fill-mode: both; }
|
||||
[data-animation="fade-in"].animated { animation-name: fadeIn; }
|
||||
[data-animation="slide-up"].animated { animation-name: slideUp; }
|
||||
[data-animation="slide-left"].animated { animation-name: slideLeft; }
|
||||
[data-animation="slide-right"].animated { animation-name: slideRight; }
|
||||
[data-animation="zoom-in"].animated { animation-name: zoomIn; }
|
||||
[data-animation="bounce"].animated { animation-name: bounce; }`;
|
||||
|
||||
const ANIMATION_CSS_MINIFIED = `@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(30px)}60%{opacity:1;transform:translateY(-5px)}100%{transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation="fade-in"].animated{animation-name:fadeIn}[data-animation="slide-up"].animated{animation-name:slideUp}[data-animation="slide-left"].animated{animation-name:slideLeft}[data-animation="slide-right"].animated{animation-name:slideRight}[data-animation="zoom-in"].animated{animation-name:zoomIn}[data-animation="bounce"].animated{animation-name:bounce}`;
|
||||
|
||||
const ANIMATION_SCRIPT = `<script>
|
||||
document.querySelectorAll('[data-animation]').forEach(function(el) {
|
||||
var delay = el.getAttribute('data-animation-delay');
|
||||
if (delay) el.style.animationDelay = delay;
|
||||
new IntersectionObserver(function(entries) {
|
||||
entries.forEach(function(e) { if (e.isIntersecting) { el.classList.add('animated'); } });
|
||||
}, { threshold: 0.1 }).observe(el);
|
||||
});
|
||||
</script>`;
|
||||
|
||||
function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
||||
const title = options.title || 'Untitled Page';
|
||||
const minify = options.minifyCss !== false;
|
||||
const reset = minify ? CSS_RESET : CSS_RESET_PRETTY;
|
||||
const responsive = minify ? RESPONSIVE_CSS_MINIFIED : RESPONSIVE_CSS;
|
||||
const visibility = minify ? VISIBILITY_CSS_MINIFIED : VISIBILITY_CSS;
|
||||
const animation = minify ? ANIMATION_CSS_MINIFIED : ANIMATION_CSS;
|
||||
const fonts = options.includeFonts !== false ? `\n ${GOOGLE_FONTS_LINK}` : '';
|
||||
const headCode = options.headCode ? `\n ${options.headCode}` : '';
|
||||
|
||||
// Only include animation CSS + script if body contains data-animation
|
||||
const hasAnimations = bodyHtml.includes('data-animation');
|
||||
const animationBlock = hasAnimations ? animation : '';
|
||||
const animationScript = hasAnimations ? `\n${ANIMATION_SCRIPT}` : '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(title)}</title>${fonts}
|
||||
<style>${reset}${responsive}${visibility}${animationBlock}</style>${headCode}
|
||||
</head>
|
||||
<body>
|
||||
${bodyHtml}${animationScript}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export serialized Craft.js state to standalone HTML.
|
||||
*/
|
||||
/**
|
||||
* Export as a full HTML document (for preview).
|
||||
*/
|
||||
export function exportToHtml(
|
||||
serializedState: string,
|
||||
options: ExportOptions = {},
|
||||
): { html: string; css: string } {
|
||||
try {
|
||||
const nodes = JSON.parse(serializedState);
|
||||
const { html: bodyHtml } = renderNode(nodes, 'ROOT');
|
||||
const fullHtml = wrapInDocument(bodyHtml, options);
|
||||
return { html: fullHtml, css: '' };
|
||||
} catch (e) {
|
||||
console.error('Export to HTML failed:', e);
|
||||
return {
|
||||
html: wrapInDocument('<p>Export failed. Please try again.</p>', options),
|
||||
css: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export just the body HTML + CSS (for WHP API save -- PHP wraps in document).
|
||||
*/
|
||||
export function exportBodyHtml(
|
||||
serializedState: string,
|
||||
): { html: string; css: string } {
|
||||
try {
|
||||
const nodes = JSON.parse(serializedState);
|
||||
const { html } = renderNode(nodes, 'ROOT');
|
||||
return { html, css: '' };
|
||||
} catch (e) {
|
||||
console.error('Body export failed:', e);
|
||||
return { html: '', css: '' };
|
||||
}
|
||||
}
|
||||
16
craft/src/utils/style-helpers.ts
Normal file
16
craft/src/utils/style-helpers.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
const camelToKebab = (str: string): string =>
|
||||
str.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
|
||||
|
||||
export function cssPropsToString(style: CSSProperties | undefined): string {
|
||||
if (!style) return '';
|
||||
return Object.entries(style)
|
||||
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||
.map(([k, v]) => `${camelToKebab(k)}:${v}`)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
export function mergeStyles(...styles: (CSSProperties | undefined)[]): CSSProperties {
|
||||
return Object.assign({}, ...styles.filter(Boolean));
|
||||
}
|
||||
Reference in New Issue
Block a user