709 lines
28 KiB
TypeScript
709 lines
28 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { promises as fs } from "fs";
|
|
import path from "path";
|
|
import JSZip from "jszip";
|
|
import type {
|
|
Block,
|
|
ProjectConfig,
|
|
FormBlockProps,
|
|
FormSelectOption,
|
|
TextBlockProps,
|
|
ButtonBlockProps,
|
|
SectionBlockProps,
|
|
VideoBlockProps,
|
|
ListBlockProps,
|
|
} from "@/features/editor/state/editorStore";
|
|
import {
|
|
normalizeVideoSourceUrl,
|
|
resolveVideoPlatform,
|
|
buildVideoEmbedUrl,
|
|
computeVideoExportTokens,
|
|
} from "@/features/editor/utils/videoHelpers";
|
|
import { computeTextPbTokens } from "@/features/editor/utils/textHelpers";
|
|
import { computeButtonPbTokens } from "@/features/editor/utils/buttonHelpers";
|
|
import { computeImageExportStyles } from "@/features/editor/utils/imageHelpers";
|
|
import { computeSectionExportTokens } from "@/features/editor/utils/sectionHelpers";
|
|
import { computeListExportTokens } from "@/features/editor/utils/listHelpers";
|
|
import {
|
|
computeFormInputExportTokens,
|
|
computeFormSelectExportTokens,
|
|
computeFormCheckboxExportTokens,
|
|
computeFormRadioExportTokens,
|
|
computeFormBlockExportTokens,
|
|
} from "@/features/editor/utils/formHelpers";
|
|
import { computeDividerExportTokens } from "@/features/editor/utils/dividerHelpers";
|
|
|
|
type ExportRequestBody = {
|
|
blocks: Block[];
|
|
projectConfig?: ProjectConfig;
|
|
};
|
|
|
|
const BUILDER_CSS_PATH = path.join(process.cwd(), "src", "styles", "builder.css");
|
|
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
|
|
|
const escapeHtml = (value: string): string =>
|
|
value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
|
|
const escapeAttr = (value: string): string => escapeHtml(value);
|
|
|
|
export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): string => {
|
|
const baseTitleRaw = (projectConfig?.title ?? "").trim() || "Page Builder Export";
|
|
const seoTitleRaw = (projectConfig?.seoTitle ?? "").trim();
|
|
const pageTitleRaw = seoTitleRaw || baseTitleRaw;
|
|
const pageTitle = escapeHtml(pageTitleRaw);
|
|
|
|
const headExtraRaw = (projectConfig?.headHtml ?? "").trim();
|
|
const headExtra = headExtraRaw ? `\n${headExtraRaw}\n` : "";
|
|
|
|
const preset = projectConfig?.canvasPreset ?? "full";
|
|
let maxWidth: string | null = null;
|
|
const widthPx =
|
|
typeof projectConfig?.canvasWidthPx === "number" && projectConfig.canvasWidthPx > 0
|
|
? projectConfig.canvasWidthPx
|
|
: null;
|
|
if (widthPx != null) {
|
|
maxWidth = `${widthPx}px`;
|
|
} else if (preset === "mobile") {
|
|
maxWidth = "390px";
|
|
} else if (preset === "tablet") {
|
|
maxWidth = "768px";
|
|
} else if (preset === "desktop") {
|
|
maxWidth = "1200px";
|
|
}
|
|
const bgColor = (projectConfig?.canvasBgColorHex ?? "").trim();
|
|
const widthPart = maxWidth != null ? `max-width:${maxWidth};` : "";
|
|
const bgPart = bgColor ? `background-color:${bgColor};` : "";
|
|
const basePart = "margin:0 auto;padding:24px;box-sizing:border-box;";
|
|
const canvasStyle = `${widthPart}${bgPart}${basePart}`;
|
|
|
|
const bodyBgRaw = (projectConfig?.bodyBgColorHex ?? "#020617").trim() || "#020617";
|
|
const bodyStyle = `background-color:${bodyBgRaw};margin:0;padding:0;`;
|
|
const trackingRaw = (projectConfig?.trackingScript ?? "").trim();
|
|
const trackingHtml = trackingRaw ? `\n${trackingRaw}\n` : "";
|
|
|
|
const seoDescriptionRaw = (projectConfig?.seoDescription ?? "").trim();
|
|
const seoOgImageRaw = (projectConfig?.seoOgImageUrl ?? "").trim();
|
|
const seoCanonicalRaw = (projectConfig?.seoCanonicalUrl ?? "").trim();
|
|
const seoNoIndex = projectConfig?.seoNoIndex === true;
|
|
|
|
const seoHeadParts: string[] = [];
|
|
|
|
if (seoDescriptionRaw) {
|
|
seoHeadParts.push(
|
|
` <meta name="description" content="${escapeAttr(seoDescriptionRaw)}" />`,
|
|
);
|
|
}
|
|
|
|
if (pageTitleRaw) {
|
|
seoHeadParts.push(
|
|
` <meta property="og:title" content="${escapeAttr(pageTitleRaw)}" />`,
|
|
);
|
|
}
|
|
if (seoDescriptionRaw) {
|
|
seoHeadParts.push(
|
|
` <meta property="og:description" content="${escapeAttr(seoDescriptionRaw)}" />`,
|
|
);
|
|
}
|
|
seoHeadParts.push(` <meta property="og:type" content="website" />`);
|
|
|
|
if (seoOgImageRaw) {
|
|
seoHeadParts.push(
|
|
` <meta property="og:image" content="${escapeAttr(seoOgImageRaw)}" />`,
|
|
);
|
|
}
|
|
|
|
if (seoCanonicalRaw) {
|
|
seoHeadParts.push(
|
|
` <meta property="og:url" content="${escapeAttr(seoCanonicalRaw)}" />`,
|
|
);
|
|
}
|
|
|
|
// Twitter 카드 메타 태그
|
|
seoHeadParts.push(
|
|
` <meta name="twitter:card" content="summary_large_image" />`,
|
|
);
|
|
if (pageTitleRaw) {
|
|
seoHeadParts.push(
|
|
` <meta name="twitter:title" content="${escapeAttr(pageTitleRaw)}" />`,
|
|
);
|
|
}
|
|
if (seoDescriptionRaw) {
|
|
seoHeadParts.push(
|
|
` <meta name="twitter:description" content="${escapeAttr(seoDescriptionRaw)}" />`,
|
|
);
|
|
}
|
|
if (seoOgImageRaw) {
|
|
seoHeadParts.push(
|
|
` <meta name="twitter:image" content="${escapeAttr(seoOgImageRaw)}" />`,
|
|
);
|
|
}
|
|
|
|
if (seoCanonicalRaw) {
|
|
seoHeadParts.push(
|
|
` <link rel="canonical" href="${escapeAttr(seoCanonicalRaw)}" />`,
|
|
);
|
|
}
|
|
|
|
if (seoNoIndex) {
|
|
seoHeadParts.push(
|
|
` <meta name="robots" content="noindex, nofollow" />`,
|
|
);
|
|
}
|
|
|
|
const seoHeadHtml = seoHeadParts.length > 0 ? `\n${seoHeadParts.join("\n")}` : "";
|
|
|
|
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
|
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
|
|
|
|
// Form 컨트롤러(FormBlock)와 개별 필드 블록(formInput/formSelect/...) 를 연결하기 위한 맵.
|
|
// - key: 필드 블록 id (fieldIds 에 포함된 id)
|
|
// - value: 해당 필드가 속한 <form> 요소의 DOM id (예: form_form_1)
|
|
const fieldIdToFormId = new Map<string, string>();
|
|
for (const block of blocks) {
|
|
if (block.type !== "form") continue;
|
|
const props = (block.props ?? {}) as FormBlockProps;
|
|
const fieldIds = Array.isArray(props.fieldIds) ? props.fieldIds : [];
|
|
if (fieldIds.length === 0) continue;
|
|
const formDomId = `form_${block.id}`;
|
|
for (const fieldId of fieldIds) {
|
|
if (typeof fieldId === "string" && fieldId.trim() !== "") {
|
|
fieldIdToFormId.set(fieldId, formDomId);
|
|
}
|
|
}
|
|
}
|
|
|
|
const renderBlock = (block: Block): string => {
|
|
if (block.type === "text") {
|
|
const props = (block.props ?? {}) as TextBlockProps;
|
|
const text = typeof props.text === "string" ? props.text : "";
|
|
|
|
const tokens = computeTextPbTokens({
|
|
text,
|
|
align: props.align ?? "left",
|
|
size: props.size ?? "base",
|
|
fontSizeMode: props.fontSizeMode ?? "scale",
|
|
fontSizeScale: props.fontSizeScale ?? undefined,
|
|
fontSizeCustom: props.fontSizeCustom ?? undefined,
|
|
lineHeightMode: props.lineHeightMode ?? "scale",
|
|
lineHeightScale: props.lineHeightScale ?? undefined,
|
|
lineHeightCustom: props.lineHeightCustom ?? undefined,
|
|
fontWeightMode: props.fontWeightMode ?? "scale",
|
|
fontWeightScale: props.fontWeightScale ?? undefined,
|
|
fontWeightCustom: props.fontWeightCustom ?? undefined,
|
|
colorMode: props.colorMode ?? undefined,
|
|
colorPalette: props.colorPalette ?? undefined,
|
|
colorCustom: props.colorCustom ?? undefined,
|
|
backgroundColorCustom: (props as any).backgroundColorCustom ?? undefined,
|
|
maxWidthMode: props.maxWidthMode ?? "scale",
|
|
maxWidthScale: props.maxWidthScale ?? undefined,
|
|
underline: props.underline ?? false,
|
|
strike: props.strike ?? false,
|
|
italic: props.italic ?? false,
|
|
});
|
|
|
|
const classes = [
|
|
tokens.alignClass,
|
|
tokens.sizeClass,
|
|
tokens.leadingClass,
|
|
tokens.weightClass,
|
|
tokens.colorClass,
|
|
tokens.maxWidthClass,
|
|
...tokens.extraClasses,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
|
|
const styleAttr = tokens.inlineStyles.length > 0 ? ` style="${tokens.inlineStyles.join(";")}"` : "";
|
|
|
|
return `<p class="${classes}"${styleAttr}>${escapeHtml(text)}</p>`;
|
|
}
|
|
|
|
if (block.type === "button") {
|
|
const props = (block.props ?? {}) as ButtonBlockProps;
|
|
const href = typeof props.href === "string" ? props.href : "#";
|
|
const label = typeof props.label === "string" ? props.label : "";
|
|
|
|
const tokens = computeButtonPbTokens({
|
|
align: props.align ?? "left",
|
|
size: props.size ?? "md",
|
|
variant: props.variant ?? "solid",
|
|
colorPalette: props.colorPalette ?? "primary",
|
|
borderRadius: props.borderRadius ?? "md",
|
|
fullWidth: props.fullWidth ?? false,
|
|
widthMode: props.widthMode ?? undefined,
|
|
widthPx: props.widthPx ?? undefined,
|
|
paddingX: props.paddingX ?? undefined,
|
|
paddingY: props.paddingY ?? undefined,
|
|
fillColorCustom: props.fillColorCustom ?? undefined,
|
|
strokeColorCustom: props.strokeColorCustom ?? undefined,
|
|
textColorCustom: props.textColorCustom ?? undefined,
|
|
});
|
|
|
|
const styleAttr = tokens.inlineStyles.length > 0 ? ` style="${tokens.inlineStyles.join(";")}"` : "";
|
|
|
|
const btnClasses = ["pb-btn-base", tokens.sizeClass, tokens.variantClass, tokens.radiusClass]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
|
|
let innerHtml = escapeHtml(label);
|
|
|
|
if (typeof (props as any).imageSrc === "string" && (props as any).imageSrc.trim() !== "") {
|
|
const imgSrc = escapeAttr((props as any).imageSrc.trim());
|
|
const altRaw = (props as any).imageAlt as string | undefined;
|
|
const altValue = altRaw && altRaw.trim() !== "" ? altRaw.trim() : label;
|
|
const imgAlt = escapeAttr(altValue);
|
|
innerHtml = `<img src="${imgSrc}" alt="${imgAlt}" />${innerHtml}`;
|
|
}
|
|
|
|
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
|
|
href,
|
|
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</a></div>`;
|
|
}
|
|
|
|
if (block.type === "video") {
|
|
const props: any = block.props ?? {};
|
|
const rawUrl = normalizeVideoSourceUrl(props.sourceUrl ?? "");
|
|
|
|
const platform = resolveVideoPlatform(rawUrl, props.platform ?? "auto");
|
|
const embedUrl = buildVideoEmbedUrl(rawUrl, platform, { enableVimeoEmbed: true });
|
|
|
|
const tokens = computeVideoExportTokens(props, { escapeAttr });
|
|
|
|
const outerStyleAttr =
|
|
tokens.outerStyleParts.length > 0 ? ` style="${tokens.outerStyleParts.join(";")}"` : "";
|
|
const wrapperStyleAttr =
|
|
tokens.wrapperStyleParts.length > 0 ? ` style="${tokens.wrapperStyleParts.join(";")}"` : "";
|
|
const videoStyleAttr =
|
|
tokens.videoStyleParts.length > 0 ? ` style="${tokens.videoStyleParts.join(";")}"` : "";
|
|
|
|
const isEmbed = platform === "youtube" || platform === "vimeo";
|
|
|
|
if (isEmbed) {
|
|
const titleRaw = typeof (props as any).titleText === "string" ? (props as any).titleText.trim() : "";
|
|
const iframeTitle = titleRaw !== "" ? titleRaw : "비디오";
|
|
|
|
return `<div${outerStyleAttr}><div class="pb-video-wrapper${tokens.aspectClass}"${wrapperStyleAttr}><iframe title="${escapeAttr(
|
|
iframeTitle,
|
|
)}" src="${escapeAttr(
|
|
embedUrl,
|
|
)}" class="pb-video-frame" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe></div></div>`;
|
|
}
|
|
|
|
const controlsAttr = props.controls === false ? "" : " controls";
|
|
const autoplayAttr = props.autoplay ? " autoplay" : "";
|
|
const loopAttr = props.loop ? " loop" : "";
|
|
const mutedAttr = props.muted ? " muted" : "";
|
|
const posterRaw = typeof (props as any).posterImageSrc === "string" ? (props as any).posterImageSrc.trim() : "";
|
|
const posterAttr = posterRaw !== "" ? ` poster="${escapeAttr(posterRaw)}"` : "";
|
|
const ariaRaw = typeof (props as any).ariaLabel === "string" ? (props as any).ariaLabel.trim() : "";
|
|
const ariaAttr = ariaRaw !== "" ? ` aria-label="${escapeAttr(ariaRaw)}"` : "";
|
|
const startDataAttr =
|
|
typeof (props as any).startTimeSec === "number" && (props as any).startTimeSec >= 0
|
|
? ` data-start-seconds="${(props as any).startTimeSec}"`
|
|
: "";
|
|
const endDataAttr =
|
|
typeof (props as any).endTimeSec === "number" && (props as any).endTimeSec >= 0
|
|
? ` data-end-seconds="${(props as any).endTimeSec}"`
|
|
: "";
|
|
const captionRaw = typeof (props as any).captionText === "string" ? (props as any).captionText.trim() : "";
|
|
const captionHtml = captionRaw !== "" ? `<p class="pb-video-caption">${escapeHtml(captionRaw)}</p>` : "";
|
|
|
|
return `<div${outerStyleAttr}><div class="pb-video-wrapper${tokens.aspectClass}"${wrapperStyleAttr}><video class="pb-video-frame" src="${escapeAttr(
|
|
rawUrl,
|
|
)}"${posterAttr}${ariaAttr}${startDataAttr}${endDataAttr}${videoStyleAttr}${controlsAttr}${autoplayAttr}${loopAttr}${mutedAttr}></video></div>${captionHtml}</div>`;
|
|
}
|
|
|
|
if (block.type === "image") {
|
|
const props: any = block.props ?? {};
|
|
const src = typeof props.src === "string" ? props.src : "";
|
|
const alt = typeof props.alt === "string" ? props.alt : "";
|
|
|
|
const styles = computeImageExportStyles({
|
|
backgroundColorCustom:
|
|
typeof props.backgroundColorCustom === "string" ? props.backgroundColorCustom : undefined,
|
|
widthMode: props.widthMode ?? "auto",
|
|
widthPx: typeof props.widthPx === "number" ? props.widthPx : undefined,
|
|
borderRadius: props.borderRadius ?? "md",
|
|
borderRadiusPx: typeof props.borderRadiusPx === "number" ? props.borderRadiusPx : undefined,
|
|
});
|
|
|
|
const wrapperStyleAttr =
|
|
styles.wrapperStyleParts.length > 0 ? ` style="${styles.wrapperStyleParts.join(";")}"` : "";
|
|
const imgStyleAttr = styles.imgStyleParts.length > 0 ? ` style="${styles.imgStyleParts.join(";")}"` : "";
|
|
|
|
return `<div${wrapperStyleAttr}><img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${imgStyleAttr} /></div>`;
|
|
}
|
|
|
|
if (block.type === "form") {
|
|
const props = (block.props ?? {}) as FormBlockProps;
|
|
const tokens = computeFormBlockExportTokens(block, blocks);
|
|
const controllerFields = tokens.hasControllerFields ? tokens.controllerFields : [];
|
|
|
|
const hasControllerFields = controllerFields.length > 0;
|
|
|
|
// FormBlock 이 컨트롤러 필드 정보를 전혀 가지고 있지 않으면
|
|
// Export 레이어에서는 아무 것도 렌더하지 않는다 (fallback fields 기반 pb-form 도 생성하지 않음).
|
|
if (!hasControllerFields) {
|
|
return "";
|
|
}
|
|
|
|
// 컨트롤러 전용 FormBlock: fieldIds 기반으로 개별 필드 블록과 연결되는 순수 컨트롤러 폼만 생성한다.
|
|
// - Export 에서는 레이아웃/스타일 대신 id/메서드/액션만 가지는 최소한의 <form> 요소만 만든다.
|
|
// - action 은 퍼블릭 페이지에서 우리 서버의 forms/submit 엔드포인트로 전송하기 위한 고정 경로다.
|
|
const formId = `form_${block.id}`;
|
|
const method = props.method ?? "POST";
|
|
const methodAttr = ` method="${method.toLowerCase()}"`;
|
|
const actionAttr = ` action="/api/forms/submit"`;
|
|
|
|
// 정적 Export 에서도 FormBlock 설정(전송 대상/웹훅 설정 등)을 함께 전달할 수 있도록
|
|
// preview 렌더러와 동일하게 __config hidden 필드에 FormBlockProps 전체를 JSON 으로 담아둔다.
|
|
const configJson = JSON.stringify(props ?? {});
|
|
const escapedConfig = escapeAttr(configJson);
|
|
|
|
const projectSlugRaw = (projectConfig?.slug ?? "").trim();
|
|
const projectSlugValue = projectSlugRaw !== "" ? projectSlugRaw : "";
|
|
|
|
const parts: string[] = [];
|
|
parts.push(
|
|
`<form id="${escapeAttr(formId)}" class="pb-form-controller"${methodAttr}${actionAttr}>`,
|
|
);
|
|
parts.push(`<input type="hidden" name="__config" value="${escapedConfig}" />`);
|
|
if (projectSlugValue) {
|
|
parts.push(
|
|
`<input type="hidden" name="__projectSlug" value="${escapeAttr(projectSlugValue)}" />`,
|
|
);
|
|
}
|
|
parts.push("</form>");
|
|
return parts.join("");
|
|
}
|
|
|
|
if (block.type === "divider") {
|
|
const props: any = block.props ?? {};
|
|
const tokens = computeDividerExportTokens(props, { escapeAttr });
|
|
return `<hr class="pb-divider" style="${tokens.style}" />`;
|
|
}
|
|
|
|
if (block.type === "list") {
|
|
const props = (block.props ?? {}) as ListBlockProps;
|
|
|
|
const tokens = computeListExportTokens(props);
|
|
|
|
if (tokens.items.length === 0) {
|
|
return "";
|
|
}
|
|
|
|
const lis = tokens.items.map((text) => `<li>${escapeHtml(text)}</li>`).join("");
|
|
const listStyleAttr = tokens.listStyleParts.join(";");
|
|
|
|
return `<${tokens.Tag} class="pb-list" style="${listStyleAttr}">${lis}</${tokens.Tag}>`;
|
|
}
|
|
|
|
if (block.type === "formInput") {
|
|
const props: any = block.props ?? {};
|
|
const name = typeof props.formFieldName === "string" ? props.formFieldName : block.id;
|
|
const label =
|
|
typeof props.label === "string" && props.label.trim() !== "" ? props.label : name;
|
|
const type = props.inputType === "email" ? "email" : "text";
|
|
const required = props.required ? " required" : "";
|
|
|
|
const tokens = computeFormInputExportTokens(props, { escapeAttr });
|
|
const formId = fieldIdToFormId.get(block.id);
|
|
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
|
|
|
return [
|
|
'<div class="pb-form-field">',
|
|
`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`,
|
|
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
|
label,
|
|
)}"${required}${tokens.inputStyleAttr}${formAttr} />`,
|
|
"</div>",
|
|
].join("");
|
|
}
|
|
|
|
if (block.type === "formSelect") {
|
|
const props: any = block.props ?? {};
|
|
const name = typeof props.formFieldName === "string" ? props.formFieldName : block.id;
|
|
const label =
|
|
typeof props.label === "string" && props.label.trim() !== "" ? props.label : name;
|
|
const required = props.required ? " required" : "";
|
|
const options: FormSelectOption[] = Array.isArray(props.options) ? props.options : [];
|
|
|
|
const tokens = computeFormSelectExportTokens(props, { escapeAttr });
|
|
const formId = fieldIdToFormId.get(block.id);
|
|
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
|
|
|
const parts: string[] = [];
|
|
parts.push('<div class="pb-form-field">');
|
|
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
|
parts.push(
|
|
`<select class="pb-select" name="${escapeAttr(name)}"${required}${tokens.selectStyleAttr}${formAttr}>`,
|
|
);
|
|
for (const opt of options) {
|
|
parts.push(
|
|
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
|
|
);
|
|
}
|
|
parts.push("</select>");
|
|
parts.push("</div>");
|
|
|
|
return parts.join("");
|
|
}
|
|
|
|
if (block.type === "formCheckbox") {
|
|
const props: any = block.props ?? {};
|
|
const name = typeof props.formFieldName === "string" ? props.formFieldName : block.id;
|
|
const label =
|
|
typeof props.groupLabel === "string" && props.groupLabel.trim() !== ""
|
|
? props.groupLabel
|
|
: name;
|
|
const options = Array.isArray(props.options) ? props.options : [];
|
|
|
|
const tokens = computeFormCheckboxExportTokens(props, { escapeAttr });
|
|
const formId = fieldIdToFormId.get(block.id);
|
|
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
|
|
|
const parts: string[] = [];
|
|
parts.push('<div class="pb-form-field">');
|
|
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
|
for (const opt of options) {
|
|
parts.push(
|
|
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="checkbox" name="${escapeAttr(
|
|
name,
|
|
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
|
|
);
|
|
}
|
|
parts.push("</div>");
|
|
|
|
return parts.join("");
|
|
}
|
|
|
|
if (block.type === "formRadio") {
|
|
const props: any = block.props ?? {};
|
|
const name = typeof props.formFieldName === "string" ? props.formFieldName : block.id;
|
|
const label =
|
|
typeof props.groupLabel === "string" && props.groupLabel.trim() !== ""
|
|
? props.groupLabel
|
|
: name;
|
|
const options = Array.isArray(props.options) ? props.options : [];
|
|
|
|
const tokens = computeFormRadioExportTokens(props, { escapeAttr });
|
|
const formId = fieldIdToFormId.get(block.id);
|
|
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
|
|
|
const parts: string[] = [];
|
|
parts.push('<div class="pb-form-field">');
|
|
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
|
for (const opt of options) {
|
|
parts.push(
|
|
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="radio" name="${escapeAttr(
|
|
name,
|
|
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
|
|
);
|
|
}
|
|
parts.push("</div>");
|
|
|
|
return parts.join("");
|
|
}
|
|
|
|
return "";
|
|
};
|
|
|
|
const bodyParts: string[] = [];
|
|
|
|
if (rootBlocks.length > 0) {
|
|
bodyParts.push('<section class="pb-root"><div class="pb-root-inner">');
|
|
for (const block of rootBlocks) {
|
|
bodyParts.push(renderBlock(block));
|
|
}
|
|
bodyParts.push("</div></section>");
|
|
}
|
|
|
|
for (const section of sectionBlocks) {
|
|
const props = (section.props ?? {}) as SectionBlockProps;
|
|
const columns =
|
|
Array.isArray(props.columns) && props.columns.length > 0
|
|
? props.columns
|
|
: [{ id: `${section.id}_col`, span: 12 }];
|
|
|
|
const tokens = computeSectionExportTokens(props);
|
|
|
|
const sectionStyleAttr =
|
|
tokens.sectionStyleParts.length > 0 ? ` style="${tokens.sectionStyleParts.join(";")}"` : "";
|
|
|
|
bodyParts.push(
|
|
`<section class="${tokens.sectionClasses}"${sectionStyleAttr}>${tokens.backgroundVideoHtml}<div class="pb-section-inner"><div class="pb-section-columns">`,
|
|
);
|
|
|
|
for (const column of columns) {
|
|
const columnBlocks = blocks.filter(
|
|
(b) => b.sectionId === section.id && b.columnId === column.id,
|
|
);
|
|
bodyParts.push('<div class="pb-section-column">');
|
|
for (const block of columnBlocks) {
|
|
bodyParts.push(renderBlock(block));
|
|
}
|
|
bodyParts.push("</div>");
|
|
}
|
|
|
|
bodyParts.push("</div></div></section>");
|
|
}
|
|
|
|
const bodyContent = bodyParts.join("\n");
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>${pageTitle}</title>
|
|
<link rel="stylesheet" href="./builder.css" />${seoHeadHtml}${headExtra}
|
|
</head>
|
|
<body style="${bodyStyle}">
|
|
<main>
|
|
<div id="app-root" style="${canvasStyle}">
|
|
${bodyContent}
|
|
</div>
|
|
</main>
|
|
<script src="./main.js"></script>${trackingHtml}
|
|
</body>
|
|
</html>`;
|
|
};
|
|
|
|
export async function POST(request: Request) {
|
|
let body: ExportRequestBody;
|
|
try {
|
|
body = (await request.json()) as ExportRequestBody;
|
|
} catch {
|
|
return NextResponse.json({ message: "잘못된 JSON 요청입니다." }, { status: 400 });
|
|
}
|
|
|
|
const blocks = Array.isArray(body.blocks) ? body.blocks : [];
|
|
const projectConfig = body.projectConfig;
|
|
|
|
let html = buildStaticHtml(blocks, projectConfig);
|
|
|
|
const zip = new JSZip();
|
|
|
|
const imageIds = new Set<string>();
|
|
const imageRegex = /\/api\/image\/([^"'>\s)]+)/g;
|
|
let match: RegExpExecArray | null;
|
|
// eslint-disable-next-line no-cond-assign
|
|
while ((match = imageRegex.exec(html)) !== null) {
|
|
if (match[1]) {
|
|
imageIds.add(match[1]);
|
|
}
|
|
}
|
|
|
|
for (const id of imageIds) {
|
|
const filePath = path.join(UPLOAD_DIR, id);
|
|
try {
|
|
const bytes = await fs.readFile(filePath);
|
|
zip.file(`images/${id}`, bytes);
|
|
|
|
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const srcRegex = new RegExp(`/api/image/${escapedId}`, "g");
|
|
html = html.replace(srcRegex, `./images/${id}`);
|
|
} catch {
|
|
// 업로드 디렉터리에 파일이 없는 경우에는 조용히 건너뛴다.
|
|
}
|
|
}
|
|
|
|
const videoIds = new Set<string>();
|
|
const videoRegex = /\/api\/video\/([^"'>\s]+)/g;
|
|
let videoMatch: RegExpExecArray | null;
|
|
// eslint-disable-next-line no-cond-assign
|
|
while ((videoMatch = videoRegex.exec(html)) !== null) {
|
|
if (videoMatch[1]) {
|
|
videoIds.add(videoMatch[1]);
|
|
}
|
|
}
|
|
|
|
for (const id of videoIds) {
|
|
const filePath = path.join(UPLOAD_DIR, id);
|
|
try {
|
|
const bytes = await fs.readFile(filePath);
|
|
zip.file(`videos/${id}`, bytes);
|
|
|
|
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const srcRegex = new RegExp(`/api/video/${escapedId}`, "g");
|
|
html = html.replace(srcRegex, `./videos/${id}`);
|
|
} catch {
|
|
// 업로드 디렉터리에 파일이 없는 경우에는 조용히 건너뛴다.
|
|
}
|
|
}
|
|
|
|
zip.file("index.html", html);
|
|
|
|
try {
|
|
const cssContent = await fs.readFile(BUILDER_CSS_PATH, "utf8");
|
|
zip.file("builder.css", cssContent);
|
|
} catch {
|
|
// CSS 파일이 없어도 내보내기가 가능하도록 한다.
|
|
zip.file("builder.css", "/* builder.css 를 찾을 수 없습니다. */\n");
|
|
}
|
|
|
|
const mainJs = [
|
|
"(function(){",
|
|
" function pbInitFormControllers(){",
|
|
" var forms = document.querySelectorAll(\"form.pb-form-controller\");",
|
|
" if (!forms || !forms.length) { return; }",
|
|
" forms.forEach(function(form){",
|
|
" form.addEventListener(\"submit\", function(event){",
|
|
" if (!form) { return; }",
|
|
" if (event && typeof event.preventDefault === 'function') { event.preventDefault(); }",
|
|
" var formData = new FormData(form);",
|
|
" var configInput = form.querySelector('input[name=\"__config\"]');",
|
|
" var config = null;",
|
|
" if (configInput && configInput.value) {",
|
|
" try { config = JSON.parse(configInput.value); } catch (e) { config = null; }",
|
|
" }",
|
|
" var action = form.getAttribute('action') || '/api/forms/submit';",
|
|
" fetch(action, { method: 'POST', body: formData })",
|
|
" .then(function(res){ return res.json().catch(function(){ return {}; }); })",
|
|
" .then(function(data){",
|
|
" var ok = data && data.ok;",
|
|
" var message = data && data.message;",
|
|
" if (ok) {",
|
|
" var success = message || (config && config.successMessage) || '성공적으로 전송되었습니다.';",
|
|
" if (typeof alert === 'function') { alert(success); }",
|
|
" try { form.reset(); } catch (e) {}",
|
|
" } else {",
|
|
" var errorMsg = message || (config && config.errorMessage) || '전송 중 오류가 발생했습니다.';",
|
|
" if (typeof alert === 'function') { alert(errorMsg); }",
|
|
" }",
|
|
" })",
|
|
" .catch(function(){",
|
|
" var errorMsg = (config && config.errorMessage) || '전송 중 오류가 발생했습니다.';",
|
|
" if (typeof alert === 'function') { alert(errorMsg); }",
|
|
" });",
|
|
" });",
|
|
" });",
|
|
" }",
|
|
" if (typeof document !== 'undefined') {",
|
|
" if (document.readyState === 'loading') {",
|
|
" document.addEventListener('DOMContentLoaded', pbInitFormControllers);",
|
|
" } else {",
|
|
" pbInitFormControllers();",
|
|
" }",
|
|
" }",
|
|
" console.log('Page Builder static export loaded');",
|
|
"})();",
|
|
].join("\n");
|
|
zip.file("main.js", mainJs);
|
|
|
|
const zipArrayBuffer = await zip.generateAsync({ type: "arraybuffer" });
|
|
|
|
return new NextResponse(zipArrayBuffer, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "application/zip",
|
|
"Content-Disposition": `attachment; filename=page-builder-export.zip`,
|
|
},
|
|
});
|
|
}
|