// A minimal, self-owned Builder.io renderer. Given a Builder block tree, it // compiles each block's responsiveStyles into CSS and assembles the exact HTML, // with NO @builder.io runtime. Reproduces the original page 1:1. // // Breakpoints follow Builder's defaults: medium <= 991px, small <= 640px. import fs from "node:fs"; import path from "node:path"; // ---------------------------------------------------------------- media URLs const ASSET_DIR = "builder-export/assets"; const manifest = JSON.parse(fs.readFileSync(path.join(ASSET_DIR, "_manifest.json"), "utf8")); const EXT = { "svg+xml": "svg", png: "png", jpeg: "jpg", jpg: "jpg", webp: "webp", gif: "gif" }; // builder cdn url (any width) -> local /images/home/., copying on demand const builderCopied = new Set(); function builderLocal(url) { const base = url.split("?")[0]; let rel = manifest[url] || manifest[base]; if (!rel) { const hit = Object.keys(manifest).find((k) => k.split("?")[0] === base); if (hit) rel = manifest[hit]; } if (!rel) return null; const rawExt = rel.split(".").slice(1).join("."); const ext = EXT[rawExt] || rawExt; const cleanName = path.basename(rel).replace(/\.(svg\+xml|jpeg|png|webp|gif|jpg)$/i, "") + "." + ext; const dest = path.join("public/images/home", cleanName); if (!builderCopied.has(cleanName)) { fs.mkdirSync("public/images/home", { recursive: true }); try { fs.copyFileSync(path.join("builder-export", rel), dest); } catch {} builderCopied.add(cleanName); } return `/images/home/${cleanName}`; } // Rewrite any media URL (shopify or builder) to a local path. Shopify media is // not in the export (added manually under public/media), but we still point to it. export function rewriteUrl(url) { if (!url || typeof url !== "string") return url; // shopify videos let m = url.match(/https?:\/\/cdn\.shopify\.com\/[^\s"'\\)]+?\.(webm|mp4)(\?[^\s"')]*)?/i); if (m) return `/media/video/${m[0].split("?")[0].split("/").pop()}`; // shopify images m = url.match(/https?:\/\/cdn\.shopify\.com\/[^\s"'\\)]+?\.(png|jpe?g|webp|gif|svg)(\?[^\s"')]*)?/i); if (m) return `/media/img/${m[0].split("?")[0].split("/").pop()}`; // builder image cdn if (/cdn\.builder\.io\/api\/v1\/image/.test(url)) { const local = builderLocal(url); if (local) return local; } return url; } // Rewrite all media URLs found inside an HTML/CSS string. export function rewriteHtmlUrls(html) { if (!html) return html; return html .replace(/https?:\/\/cdn\.shopify\.com\/[^\s"'\\)]+?\.(?:webm|mp4|png|jpe?g|webp|gif|svg)(?:\?[^\s"')]*)?/gi, (u) => rewriteUrl(u)) .replace(/https?:\/\/cdn\.builder\.io\/api\/v1\/image\/[^\s"'\\)]+/gi, (u) => rewriteUrl(u) || u); } // ---------------------------------------------------------------- styles const DEFAULT_BLOCK_STYLE = "display:flex;flex-direction:column;align-items:stretch;box-sizing:border-box;position:relative;"; function camelToKebab(s) { return s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); } function styleObjToCss(obj) { if (!obj) return ""; return Object.entries(obj) .map(([k, v]) => `${camelToKebab(k)}:${String(v).trim()}`) .join(";"); } // Compile a block's responsiveStyles into CSS rules for class .b-. // Returns a CSS string accumulated into ctx.css. function blockCss(block, ctx) { const id = block.id; const rs = block.responsiveStyles || {}; if (rs.large && Object.keys(rs.large).length) { ctx.css.push(`.b-${id}{${styleObjToCss(rs.large)}}`); } if (rs.medium && Object.keys(rs.medium).length) { ctx.mq991.push(`.b-${id}{${styleObjToCss(rs.medium)}}`); } if (rs.small && Object.keys(rs.small).length) { ctx.mq640.push(`.b-${id}{${styleObjToCss(rs.small)}}`); } } // ---------------------------------------------------------------- localization function resolveLoc(value, locale) { if (value && typeof value === "object" && value["@type"] === "@builder.io/core:LocalizedValue") { if (locale === "ja" && value.jp != null && String(value.jp).trim() !== "") return value.jp; return value.Default ?? ""; } return value; } // ---------------------------------------------------------------- script extraction // Pull out of raw HTML; return {html, scripts[]}. function extractScripts(html) { const scripts = []; const out = html.replace(/]*>([\s\S]*?)<\/script>/gi, (_, body) => { if (body && body.trim()) scripts.push(body); return ""; }); return { html: out, scripts }; } // ---------------------------------------------------------------- escaping function esc(s) { return String(s).replace(/&/g, "&").replace(/"/g, """); } // ---------------------------------------------------------------- block render // ctx: { css:[], mq991:[], mq640:[], scripts:[], symbols:Map, locale } export function renderBlock(block, ctx) { if (!block) return ""; const comp = block.component?.name; const opts = block.component?.options || {}; const id = block.id; blockCss(block, ctx); const cls = `b-${id}`; const childrenHtml = (block.children || []).map((c) => renderBlock(c, ctx)).join(""); switch (comp) { case "Symbol": { const entry = opts.symbol?.entry; const sym = ctx.symbols.get(entry); if (!sym) return `
`; const inner = (sym.data?.blocks || []).map((b) => renderBlock(b, ctx)).join(""); return `
${inner}
`; } case "Core:Section": { const maxWidth = opts.maxWidth ?? 1200; return `
${childrenHtml}
`; } case "Text": { const raw = resolveLoc(opts.text, ctx.locale) ?? ""; return `
${rewriteHtmlUrls(String(raw))}
`; } case "Image": case "Core:Image": { const src = rewriteUrl(resolveLoc(opts.image, ctx.locale)); const fit = opts.backgroundSize || "cover"; const pos = opts.backgroundPosition || "center"; const alt = esc(resolveLoc(opts.altText || opts.alt || "", ctx.locale) || ""); const sizes = opts.sizes ? ` sizes="${esc(opts.sizes)}"` : ""; const img = src ? `${alt}` : ""; return `
${img}${childrenHtml}
`; } case "Custom Code": { const code = rewriteHtmlUrls(String(resolveLoc(opts.code, ctx.locale) ?? "")); const { html, scripts } = extractScripts(code); scripts.forEach((s) => ctx.scripts.push(s)); return `
${html}
`; } case "Core:Button": { const text = esc(resolveLoc(opts.text, ctx.locale) ?? ""); const href = opts.link ? ` href="${esc(rewriteUrl(opts.link))}"` : ""; const tag = opts.link ? "a" : "button"; return `<${tag} class="${cls} bg-button"${href}>${text}`; } default: { // Plain layout div (or unknown component) — render children. return `
${childrenHtml}
`; } } } // Assemble the full page for one locale. export function renderPage(pageBlocks, symbols, locale) { const ctx = { css: [], mq991: [], mq640: [], scripts: [], symbols, locale }; const html = pageBlocks.map((b) => renderBlock(b, ctx)).join(""); const css = `.bg-section{width:100%;display:flex;flex-direction:column;align-items:stretch;box-sizing:border-box}` + `[class^="b-"],[class*=" b-"]{${DEFAULT_BLOCK_STYLE}}` + ctx.css.join("\n") + `\n@media (max-width:991px){${ctx.mq991.join("\n")}}` + `\n@media (max-width:640px){${ctx.mq640.join("\n")}}`; return { html, css, scripts: ctx.scripts }; }