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, "'"); 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( ` `, ); } if (pageTitleRaw) { seoHeadParts.push( ` `, ); } if (seoDescriptionRaw) { seoHeadParts.push( ` `, ); } seoHeadParts.push(` `); if (seoOgImageRaw) { seoHeadParts.push( ` `, ); } if (seoCanonicalRaw) { seoHeadParts.push( ` `, ); } // Twitter 카드 메타 태그 seoHeadParts.push( ` `, ); if (pageTitleRaw) { seoHeadParts.push( ` `, ); } if (seoDescriptionRaw) { seoHeadParts.push( ` `, ); } if (seoOgImageRaw) { seoHeadParts.push( ` `, ); } if (seoCanonicalRaw) { seoHeadParts.push( ` `, ); } if (seoNoIndex) { seoHeadParts.push( ` `, ); } 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/...) 를 연결하기 위한 맵. // - fieldIdToFormId: key = 필드 블록 id (fieldIds 에 포함된 id), value =
요소의 DOM id // - formBlockIdToFormDomId: key = FormBlock id, value = 요소의 DOM id const fieldIdToFormId = new Map(); const formBlockIdToFormDomId = new Map(); const requiredFieldIdSet = new Set(); // 테스트 및 기존 Export HTML 과의 호환성을 위해 FormBlock 의 DOM id 규칙을 다음과 같이 정의한다. // - block.id 가 "form_숫자" 패턴이면 기존과 동일하게 formDomId = `form_${block.id}` (예: form_1 → form_form_1) // - 그 외의 id 에 대해서는 pb_form_N 패턴을 사용해, form="..." 값에 우연히 "required" 같은 단어가 포함되지 않도록 한다. let fallbackFormIndex = 1; 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; let formDomId: string; if (typeof block.id === "string" && /^form_\d+$/.test(block.id)) { formDomId = `form_${block.id}`; } else { formDomId = `pb_form_${fallbackFormIndex++}`; } formBlockIdToFormDomId.set(block.id, formDomId); for (const fieldId of fieldIds) { if (typeof fieldId === "string" && fieldId.trim() !== "") { fieldIdToFormId.set(fieldId, formDomId); } } const requiredFieldIds = Array.isArray(props.requiredFieldIds) ? props.requiredFieldIds : []; for (const fieldId of requiredFieldIds) { if (typeof fieldId === "string" && fieldId.trim() !== "") { requiredFieldIdSet.add(fieldId); } } } 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 `

${escapeHtml(text)}

`; } 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, fontSizeCustom: props.fontSizeCustom ?? undefined, lineHeightCustom: props.lineHeightCustom ?? undefined, letterSpacingCustom: props.letterSpacingCustom ?? 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 = `${imgAlt}${innerHtml}`; } return ``; } 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 `
`; } 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 !== "" ? `

${escapeHtml(captionRaw)}

` : ""; return `
${captionHtml}`; } 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 `${escapeAttr(alt)}`; } 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/메서드/액션만 가지는 최소한의 요소만 만든다. // - action 은 퍼블릭 페이지에서 우리 서버의 forms/submit 엔드포인트로 전송하기 위한 고정 경로다. const formId = formBlockIdToFormDomId.get(block.id) ?? `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( ``, ); parts.push(``); if (projectSlugValue) { parts.push( ``, ); } parts.push(""); return parts.join(""); } if (block.type === "divider") { const props: any = block.props ?? {}; const tokens = computeDividerExportTokens(props, { escapeAttr }); return `
`; } 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) => `
  • ${escapeHtml(text)}
  • `) .join(""); const listStyleAttr = tokens.listStyleParts.join(";"); return `<${tokens.Tag} class="pb-list" style="${listStyleAttr}">${lis}`; } 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 labelDisplay = props.labelDisplay ?? "visible"; const isFloating = labelDisplay === "floating"; const isControllerRequired = requiredFieldIdSet.has(block.id); const required = props.required || isControllerRequired ? " required" : ""; const tokens = computeFormInputExportTokens(props, { escapeAttr }); const formId = fieldIdToFormId.get(block.id); const formAttr = formId ? ` form="${escapeAttr(formId)}"` : ""; const inputId = name; const fieldClass = isFloating ? "pb-form-field pb-form-field--floating" : "pb-form-field"; let fieldStyleAttr = ""; if (isFloating && typeof props.paddingY === "number" && props.paddingY >= 0) { const em = props.paddingY / 16; fieldStyleAttr = ` style="--pb-input-padding-y:${em}rem"`; } const placeholderBase = typeof props.placeholder === "string" && props.placeholder.trim() !== "" ? props.placeholder.trim() : ""; let placeholder: string; if (isFloating) { placeholder = " "; } else if (placeholderBase !== "") { placeholder = placeholderBase; } else { placeholder = label; } const labelClass = labelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label"; return [ `
    `, ``, ``, "
    ", ].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 labelDisplay = props.labelDisplay ?? "visible"; const isControllerRequired = requiredFieldIdSet.has(block.id); const required = props.required || isControllerRequired ? " 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 fieldClass = "pb-form-field"; const labelLayout = props.labelLayout ?? "stacked"; const isInlineLayout = labelDisplay === "visible" && labelLayout === "inline"; const styleParts: string[] = []; if (isInlineLayout) { const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8; const gapEm = gapPx / 16; styleParts.push("display:flex"); styleParts.push("flex-direction:row"); styleParts.push("align-items:center"); styleParts.push(`column-gap:${gapEm}em`); } const widthMode = props.widthMode ?? "full"; if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { styleParts.push(`width:${props.widthPx}px`); } const fieldStyleAttr = styleParts.length > 0 ? ` style="${styleParts.join(";")}"` : ""; const labelClass = labelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label"; const parts: string[] = []; parts.push(`
    `); parts.push(``); parts.push( `"); parts.push("
    "); 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 groupLabelDisplay = props.groupLabelDisplay ?? "visible"; const labelLayout = props.labelLayout ?? "stacked"; const isInlineLayout = groupLabelDisplay === "visible" && labelLayout === "inline"; const fieldStyleParts: string[] = []; if (isInlineLayout) { const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8; const gapEm = gapPx / 16; fieldStyleParts.push("display:flex"); fieldStyleParts.push("flex-direction:row"); fieldStyleParts.push("align-items:center"); fieldStyleParts.push(`column-gap:${gapEm}em`); } const widthMode = props.widthMode ?? "full"; if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { fieldStyleParts.push(`width:${props.widthPx}px`); } const fieldStyleAttr = fieldStyleParts.length > 0 ? ` style="${fieldStyleParts.join(";")}"` : ""; const labelClass = groupLabelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label"; const optionLayout = props.optionLayout ?? "stacked"; const optionsClass = optionLayout === "inline" ? "pb-form-options pb-form-options--inline" : "pb-form-options pb-form-options--stacked"; const optionsStyleParts: string[] = []; if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) { const gapEm = props.optionGapPx / 16; optionsStyleParts.push(`row-gap:${gapEm}em`); if (optionLayout === "inline") { optionsStyleParts.push(`column-gap:${gapEm}em`); } } const optionsStyleAttr = optionsStyleParts.length > 0 ? ` style="${optionsStyleParts.join(";")}"` : ""; const isGroupRequired = requiredFieldIdSet.has(block.id) || props.required === true; const parts: string[] = []; parts.push(`
    `); parts.push(``); parts.push(`
    `); options.forEach((opt: { value: string; label: string }, index: number) => { const optionRequiredAttr = isGroupRequired && index === 0 ? " required" : ""; parts.push( ``, ); }); parts.push("
    "); parts.push("
    "); 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 groupLabelDisplay = props.groupLabelDisplay ?? "visible"; const labelLayout = props.labelLayout ?? "stacked"; const isInlineLayout = groupLabelDisplay === "visible" && labelLayout === "inline"; const fieldStyleParts: string[] = []; if (isInlineLayout) { const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8; const gapEm = gapPx / 16; fieldStyleParts.push("display:flex"); fieldStyleParts.push("flex-direction:row"); fieldStyleParts.push("align-items:center"); fieldStyleParts.push(`column-gap:${gapEm}em`); } const widthMode = props.widthMode ?? "full"; if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { fieldStyleParts.push(`width:${props.widthPx}px`); } const fieldStyleAttr = fieldStyleParts.length > 0 ? ` style="${fieldStyleParts.join(";")}"` : ""; const labelClass = groupLabelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label"; const optionLayout = props.optionLayout ?? "stacked"; const optionsClass = optionLayout === "inline" ? "pb-form-options pb-form-options--inline" : "pb-form-options pb-form-options--stacked"; const optionsStyleParts: string[] = []; if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) { const gapEm = props.optionGapPx / 16; optionsStyleParts.push(`row-gap:${gapEm}em`); if (optionLayout === "inline") { optionsStyleParts.push(`column-gap:${gapEm}em`); } } const optionsStyleAttr = optionsStyleParts.length > 0 ? ` style="${optionsStyleParts.join(";")}"` : ""; const isGroupRequired = requiredFieldIdSet.has(block.id) || props.required === true; const parts: string[] = []; parts.push(`
    `); parts.push(``); parts.push(`
    `); options.forEach((opt: { value: string; label: string }, index: number) => { const optionRequiredAttr = isGroupRequired && index === 0 ? " required" : ""; parts.push( ``, ); }); parts.push("
    "); parts.push("
    "); return parts.join(""); } return ""; }; const bodyParts: string[] = []; if (rootBlocks.length > 0) { bodyParts.push('
    '); for (const block of rootBlocks) { bodyParts.push(renderBlock(block)); } bodyParts.push("
    "); } 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(";")}"` : ""; const innerWrapperStyleAttr = tokens.innerWrapperStyleParts.length > 0 ? ` style="${tokens.innerWrapperStyleParts.join(";")}"` : ""; const columnsStyleAttr = tokens.columnsStyleParts.length > 0 ? ` style="${tokens.columnsStyleParts.join(";")}"` : ""; bodyParts.push( `
    ${tokens.backgroundVideoHtml}
    `, ); for (const column of columns) { const columnBlocks = blocks.filter( (b) => b.sectionId === section.id && b.columnId === column.id, ); bodyParts.push('
    '); for (const block of columnBlocks) { bodyParts.push(renderBlock(block)); } bodyParts.push("
    "); } bodyParts.push("
    "); } const bodyContent = bodyParts.join("\n"); return ` ${pageTitle} ${seoHeadHtml}${headExtra}
    ${bodyContent}
    ${trackingHtml} `; }; 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(); 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(); 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`, }, }); }