/** * # 组件 Elementor 内容渲染器 * 将 WordPress Elementor 导出的 JSON(contents.meta.elementor / products.meta.elementor) * 渲染为 React 元素。支持 section/column/container 结构 + 常用 widget。 * 样式以内联 style 复现视觉效果(背景、内边距、对齐、列宽百分比), * 未识别的 widgetType 优雅降级:渲染 heading_title / editor 文本或忽略。 */ import { Component, createContext, useContext, Fragment, useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { getLatestPosts, getLatestProducts, type LatestItem, type PostCardItem } from "~/server/data"; import TickerBar from "~/components/website/TickerBar"; import { Cf7Form604 } from "~/components/website/Cf7Form604"; import { localSiteUrl } from "~/utils/media"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; /** # 类型 Elementor 元素结构(section/column/widget 通用) */ export type ElElement = { elType?: string; widgetType?: string; id?: string; settings?: Record; elements?: ElElement[]; }; /** # 类型 Elementor 文档(数组或 {elements:[]} 包装) */ export type ElDocument = ElElement[] | { elements?: ElElement[] } | unknown; /** # 类型 尺寸值 {size, unit} */ type Size = { size?: number | string; unit?: string }; /** # 函数 判断字符串是否主要是 emoji/unicode 图标(不可作为图片图标渲染) */ function isEmojiOrPictograph(s: unknown): boolean { if (typeof s !== "string" || !s.trim()) return true; // 含有 emoji 区段字符(U+1F000+ 或 U+2600-U+27BF 等)视为图标字符 return /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}\u{E000}-\u{F8FF}]/u.test(s); } /** # 函数 读取 settings 中字符串字段 */ function s(v: unknown): string { return typeof v === "string" ? v : ""; } /** # 函数 把 Elementor 尺寸对象/数字转为 CSS 值 */ function sizeCss(v: unknown, fallbackUnit = "px"): string | undefined { if (v == null) return undefined; if (typeof v === "number") return `${v}${fallbackUnit}`; if (typeof v === "object") { const o = v as Size; if (o.size != null) return `${o.size}${o.unit || fallbackUnit}`; } if (typeof v === "string" && v.trim()) return v; return undefined; } /** # 函数 剥离内容中的文档结构级标签(WP 导入的原始 HTML 可能带 等, * 浏览器解析会提前关闭页面骨架标签,导致水合不匹配与布局重排) */ function stripStructuralTags(html: string): string { return html.replace(/<\/?(?:main|body|html|head)[^>]*>/gi, ""); } /** # 函数 CSS 长度折半(Elementor icon-list 间距:space/2 落在 padding/margin) * 参数 v CSS 长度(如 "5px" / "1rem") * 返回值 折半后的长度;无法解析返回 undefined */ function halfLength(v: string | undefined): string | undefined { if (!v) return undefined; const m = /^(-?[\d.]+)(px|rem|em|%)$/.exec(v.trim()); if (!m) return undefined; const n = Number(m[1]) / 2; return `${n}${m[2]}`; } /** # 函数 折叠 CSS 盒模型四值简写为最短等价形式(1/2/4 值) * React 19 水合按 CSSOM 归一化后的字符串比对样式,"4rem 0rem 4rem 0rem" * 会被浏览器归一为 "4rem 0rem",不折叠会导致水合不匹配。 */ function collapseBoxValues(v: string): string { const p = v.trim().split(/\s+/); if (p.length === 4 && p[0] === p[2] && p[1] === p[3]) return `${p[0]} ${p[1]}`; if (p.length === 4 && p[0] === p[1] && p[1] === p[2] && p[2] === p[3]) return p[0]!; if (p.length === 3 && p[0] === p[2]) return `${p[0]} ${p[1]}`; if (p.length === 2 && p[0] === p[1]) return p[0]!; return v; } /** # 函数 Elementor 背景位置词转 CSS 百分比(bottom center → 50% 100%) */ function mapBgPosition(v: string): string { const parts = v.trim().split(/\s+/).filter(Boolean); const to = (w: string): string | null => { switch (w) { case "top": return "0%"; case "center": return "50%"; case "bottom": return "100%"; case "left": return "0%"; case "right": return "100%"; default: return null; } }; // 顺序统一为 [x, y] const [xs, ys] = parts.length === 2 && ["top", "bottom"].includes(parts[0]!) ? [parts[1]!, parts[0]!] : [parts[0]!, parts[1]!]; const x = to(xs) ?? xs; const y = to(ys) ?? ys; return `${x} ${y}`; } /** # 函数 解析 Typography 组合样式(typography_typography=custom 时的 typography_* 字段) * 覆盖 font-family/size/weight/line-height/letter-spacing/word-spacing/text-transform/ * font-style/text-decoration(凡 settings 中有定义的值写入内联样式), * 供 heading / text-editor / image-box 等按原站 kit 逐字重渲染。 * 参数 settings 元素 settings;prefix 通常为 "typography" * 返回值 CSSProperties(无 custom 时为空对象) */ function typographyCss(settings: Record, prefix = "typography"): CSSProperties { const style: CSSProperties = {}; if (s(settings[`${prefix}_typography`]) !== "custom") return style; const fam = s(settings[`${prefix}_font_family`]); if (fam) style.fontFamily = `"${fam}", sans-serif`; const size = sizeCss(settings[`${prefix}_font_size`]); if (size) style.fontSize = size; const weight = s(settings[`${prefix}_font_weight`]); if (weight) style.fontWeight = weight as CSSProperties["fontWeight"]; const lh = sizeCss(settings[`${prefix}_line_height`]); if (lh) style.lineHeight = lh; const ls = sizeCss(settings[`${prefix}_letter_spacing`]); if (ls) style.letterSpacing = ls; const ws = sizeCss(settings[`${prefix}_word_spacing`]); if (ws) style.wordSpacing = ws; const tt = s(settings[`${prefix}_text_transform`]); if (tt) style.textTransform = tt as CSSProperties["textTransform"]; const fs = s(settings[`${prefix}_font_style`]); if (fs) style.fontStyle = fs as CSSProperties["fontStyle"]; const td = s(settings[`${prefix}_text_decoration`]); if (td) style.textDecoration = td as CSSProperties["textDecoration"]; return style; } /** # 函数 解析 padding(Elementor 可能是对象/字符串/数组) */ function paddingCss(v: unknown): string | undefined { if (!v) return undefined; if (typeof v === "string") return v; if (typeof v === "number") return `${v}px`; if (Array.isArray(v)) return collapseBoxValues(v.map((x) => (typeof x === "number" ? `${x}px` : String(x ?? "0"))).join(" ")); if (typeof v === "object") { const o = v as Record; const unit = s(o.unit) || "px"; const t = o.top, r = o.right, b = o.bottom, l = o.left; if (t == null && r == null && b == null && l == null) return undefined; const px = (x: unknown) => `${x ?? 0}${unit}`; return collapseBoxValues(`${px(t)} ${px(r)} ${px(b)} ${px(l)}`); } return undefined; } /** # 成员变量 Elementor 全局色映射(Default Kit:_id → 十六进制) **/ const ELEMENTOR_GLOBAL_COLORS: Record = { primary: "#ED6C00", secondary: "#FFA11B", text: "#000000", accent: "#474DFF", "4d5c430": "#FFFFFF", e4e3ba3: "#4A75FF", "282ef76": "#5C5E66", "6fba50b": "#444444", "78696cf": "#00C1BC", a6f03ac: "#E9E9E9", ee1917e: "#E0E5F9", "26331af": "#F4F5F8", }; /** # 函数 递归解析 settings 中的全局色引用(globals/colors?id=x → 十六进制), * 并把 __globals__ 里的引用合并到对应设置键(Elementor 全局色实际存放在 __globals__) */ function resolveGlobals(settings: T): T { if (Array.isArray(settings)) return settings.map((v) => resolveGlobals(v)) as unknown as T; if (settings && typeof settings === "object" && settings.constructor === Object) { const src = settings as Record; const out: Record = {}; for (const [k, v] of Object.entries(src)) { out[k] = typeof v === "string" ? resolveGlobalsStr(v) : resolveGlobals(v); } const globals = src.__globals__; if (globals && typeof globals === "object" && globals.constructor === Object) { for (const [key, ref] of Object.entries(globals as Record)) { if (typeof ref !== "string") continue; const resolved = resolveGlobalsStr(ref); if (resolved !== ref) { const cur = out[key]; // 仅当原设置为空(空串/未定义)时采用全局色 if (cur == null || cur === "") out[key] = resolved; } } } return out as T; } return settings; } /** # 函数 单值全局色引用解析(键名含非十六进制 id:text/primary/accent 等) */ function resolveGlobalsStr(v: string): string { const m = /^globals\/colors\?id=([\w-]+)$/.exec(v.trim()); if (m) return ELEMENTOR_GLOBAL_COLORS[m[1]!] ?? v; return v; } /** # 函数 响应式可见性类名(Elementor hide_* 设置 → elementor-hidden-*,配 app.css 媒体查询) * 参数 settings 元素设置 * 返回值 空格分隔的类名(无隐藏设置则空串) */ function visibilityClass(settings: Record): string { const cls: string[] = []; if (s(settings.hide_desktop) === "hidden-desktop") cls.push("elementor-hidden-desktop"); if (s(settings.hide_tablet) === "hidden-tablet") cls.push("elementor-hidden-tablet"); if (s(settings.hide_mobile) === "hidden-mobile") cls.push("elementor-hidden-mobile"); return cls.join(" "); } /** # 函数 section 的自定义 HTML 标签(Elementor html_tag 设置;默认 section) * 参数 settings 元素设置 * 返回值 合法标签名 */ function sectionTag(settings: Record): "section" | "div" | "footer" | "header" | "aside" | "article" { const tag = s(settings.html_tag); return (["section", "div", "footer", "header", "aside", "article"] as const).includes(tag as never) ? (tag as "section" | "div" | "footer" | "header" | "aside" | "article") : "section"; } /** # 函数 section 的背景样式(纯色 / 图片) */ function sectionBgStyle(settings: Record): CSSProperties { const style: CSSProperties = {}; const bgType = s(settings.background_background); if (bgType === "classic") { const color = s(settings.background_color); if (color) style.backgroundColor = color; const img = (settings.background_image as { url?: string } | undefined)?.url; if (img) { style.backgroundImage = `url("${img}")`; // 位置/重复取设置值(H11 底图 1920×240 条图:bottom center + no-repeat, // 只在节底部铺一条,尺寸 auto 不缩放——写死 cover/center 会平铺满节,实测对不上) const posRaw = s(settings.background_position); style.backgroundPosition = posRaw ? mapBgPosition(posRaw) : "center center"; const rep = s(settings.background_repeat); if (rep) style.backgroundRepeat = rep; // 尺寸:initial → 100% auto(H13 楼体大图 bf3d0ba 生成规则实测; // cover/contain/auto 原样输出) const sizeRaw = s(settings.background_size); if (sizeRaw) style.backgroundSize = sizeRaw === "initial" ? "100% auto" : sizeRaw; } } else if (bgType === "gradient") { const c1 = s(settings.background_color) || s(settings.color_a) || "#ffffff"; const c2 = s(settings.background_color_b) || s(settings.color_b) || s(settings.background_color_stop_b) || "#f4f5f8"; style.backgroundImage = `linear-gradient(${s(settings.gradient_angle) || "180"}deg, ${c1}, ${c2})`; } else if (s(settings.background_color)) { style.backgroundColor = s(settings.background_color); } // padding 由调用方(SectionNode 写到内容容器 / ColumnNode 写到列)负责,不在此叠加避免双份 return style; } /** # 函数 归一化 Elementor 文档为元素数组 */ function normalizeDoc(doc: ElDocument): ElElement[] { if (Array.isArray(doc)) return doc as ElElement[]; if (doc && typeof doc === "object") { const el = (doc as { elements?: ElElement[] }).elements; if (Array.isArray(el)) return el; } return []; } /** # 成员变量 posts widget 文章数据上下文(loader 预取,经 widget id 分发) **/ const PostsDataContext = createContext>({}); /** # 成员变量 WordPress 短代码渲染上下文(widget id → 预渲染节点;页脚 menu-2/gtranslate) **/ const ShortcodesContext = createContext>({}); /** # 类型 渲染器动态数据(面包屑等 WP 动态 widget 的 SSR 上下文) */ export type RendererVars = { /** # 成员变量 面包屑路径(末项无 url 表示当前页) **/ breadcrumb?: { label: string; url?: string }[]; }; /** # 成员变量 动态数据上下文(woocommerce-breadcrumb 等 widget 读取) **/ const VarsContext = createContext({}); /** # 组件 Elementor 渲染器入口 * 参数 shortcodes 短代码 widget 的预渲染内容(键为 widget id;缺省渲染为空) * 参数 vars 动态数据(面包屑路径等) */ export function ElementorRenderer({ doc, postsData, shortcodes, vars, }: { doc: ElDocument; postsData?: Record; shortcodes?: Record; vars?: RendererVars; }) { const elements = normalizeDoc(doc); if (!elements.length) return null; const css = collectCustomCss(elements) + collectResponsiveCss(elements); return (
{css ?
); } /** # 组件 button widget */ /** # 组件 Elementor button widget:样式全部来自设置(背景/文字色/边框/圆角/内边距/排版, * globals 已由 resolveGlobals 解析为实色;text_padding/typography 等实测 H11 按钮 12px 48px/18px)。 * 基础对齐 widget-button 默认:inline-block、line-height 1、圆角 3px;字重走 kit 链接 400 * (.content-html a),不在此硬编码。悬停态(button_background_hover_color/hover_color) * 经节点级 ) : null} {content} {/* 原站 wrapper 内含空白文本节点:inline-block 按钮所在行盒会叠加上正文 strut, 父级高度 28(按钮 26 + 2px descender)。JSX 需显式补一个空格复现。 */} {" "} ); } /** # 组件 image-box widget:图 + 标题 + 描述(忠实复刻原站 Elementor DOM) * 结构(image-box.php render 对应,见 legacy 镜像): * .el-imagebox > .el-imagebox-wrapper(text-align center) > * figure.el-imagebox-img(≥768 position-top 时 margin auto 居中;≤767 margin-bottom 15px) * > [a(tabindex -1) > img] * + .el-imagebox-content > title +

description

; * figure 内的 a 与标题内 a 是两个独立链接(原站同一 link 设置、各自 target=_blank); * title 标签由 title_size 决定(默认 h3);颜色继承 body(原站 .elementor-image-box-title a * color:inherit → 实测 #333),hover 提亮等由页面级规则([data-el] 范围)定义。 * 复用:首页 H2 六卡(图上标题下)与 H13/Difference Maker 六卡同源。 */ function ImageBoxWidget({ settings }: { settings: Record }) { const url = (settings.image as { url?: string } | undefined)?.url; const title = s(settings.title_text); const desc = s(settings.description_text); if (!url && !title && !desc) return null; const link = (settings.link as { url?: string } | undefined)?.url; const isExternal = (settings.link as { is_external?: string } | undefined)?.is_external === "on"; const pos = s(settings.position); // 原站未设置时渲染 elementor-position-top(控件默认 top,与原站 HTML 一致) const posCls = " el-imagebox-pos-" + (pos || "top"); const alt = title.replace(/<[^>]*>/g, "").trim(); // link 通用属性(is_external → target=_blank,与原站 elementor link_attributes 一致) const linkProps = link ? { href: link, ...(isExternal ? { target: "_blank" as const } : {}) } : undefined; // title 标签:title_size 合法值校验(原站 Utils::validate_html_tag),默认 h3 const tagOpt = s(settings.title_size) || "h3"; const tag = (["h1", "h2", "h3", "h4", "h5", "h6", "div", "span", "p"].includes(tagOpt) ? tagOpt : "h3") as "div"; const Tag = tag as "div"; // position left/right:wrapper 为 flex 行(widget-image-box.min.css .elementor-position-left // .elementor-image-box-wrapper{display:flex},实测 H11 卡内图标行); // 图宽 image_size%(post-18 由 image_size 控件生成 .elementor-image-box-img{width:18%})、 // 左侧布局 fig 右距 15px;标题底距 title_bottom_space(H11 实测 4px) const isRow = pos === "left" || pos === "right"; const imgW = sizeCss(settings.image_size); const titleSpace = sizeCss(settings.title_bottom_space); // 图与标题间距 image_space(H13.5 counter 条实测 6px,生成规则 margin-bottom:6px) const imgSpace = sizeCss(settings.image_space); // 内容垂直对齐(content_vertical_alignment 控件,仅 left/right 行布局生效): // 由 per-widget 生成规则输出 align-items(514e513 middle → center 实测;否则 flex 默认 stretch) const vaMap: Record = { top: "flex-start", middle: "center", bottom: "flex-end" }; const va = isRow ? vaMap[s(settings.content_vertical_alignment)] : undefined; // 文本对齐 text_align(H13.5 counter 条 left)与标题/描述排版 // (title_typography_* / description_typography_* 组合字段,counter 条 36px/700 橙 + 18px 描述) const textAlign = s(settings.text_align) as CSSProperties["textAlign"] | undefined; const titleStyle: CSSProperties = { ...(titleSpace ? { marginBottom: titleSpace } : {}), ...typographyCss(settings, "title_typography"), }; // 标题色 title_color(resolveGlobals 已解析 globals/colors?id=primary → #ED6C00) if (s(settings.title_color)) titleStyle.color = s(settings.title_color) as string; const descStyle = typographyCss(settings, "description_typography"); // flex/边距在 app.css 的 .el-imagebox-pos-* 规则(带 768 断点),这里只带 per-widget 图宽与垂直对齐 const figStyle: CSSProperties | undefined = isRow && imgW ? { width: imgW } : imgSpace ? { marginBottom: imgSpace } : undefined; return ( ); } /** # 组件 icon-box / eael-info-box:图标可跳过(emoji 不渲染),只保留标题与描述 */ function IconBoxWidget({ settings }: { settings: Record }) { const title = s((settings as Record).title_text) || s((settings as Record).title); const desc = s((settings as Record).description_text) || s((settings as Record).description); const icon = (settings as Record).selected_icon as { value?: string } | undefined; const titleColor = s((settings as Record).title_color) || s((settings as Record).button_text_color); // 图标是 emoji/unicode 字符或字体图标类:一律不渲染为图标,跳过 void icon; if (!title && !desc) return null; return (
{title ?

: null} {desc ?
: null}
); } /** # 组件 eael-info-box(EAEL Infobox 皮肤):标题(.title,custom_css 提供左侧图标背景) + 富文本描述(.infobox-content) * 与原生 icon-box 的键不同:内容在 eael_infobox_title / eael_infobox_text。 */ /** # 组件 eael-info-box(EAEL Infobox,忠实复刻 info-box.min.css + 原站渲染 DOM): * eael-infobox(icon-on-left / 默认上下) > .infobox-icon(img,eael_infobox_img_margin) * + .infobox-content > .infobox-title-section > .title(标题标签 eael_infobox_title_tag) * [+ .sub_title][+ p 描述];图在左时为 flex 行(content padding-left 15px)。 * 标题/描述样式(颜色/字号/外距)由页面级规则与 eael_infobox_title_margin 提供。 */ function EaelInfoBoxWidget({ settings }: { settings: Record }) { const title = s(settings.eael_infobox_title) || s(settings.title_text) || s(settings.title); const desc = s(settings.eael_infobox_text) || s(settings.description_text) || s(settings.description); const img = settings.eael_infobox_image as { url?: string; alt?: string } | undefined; const icon = settings.eael_infobox_icon_new as { value?: string; library?: string } | undefined; const iconSize = sizeCss(settings.eael_infobox_icon_size) || "26px"; if (!title && !desc && !img?.url) return null; const tag = s(settings.eael_infobox_title_tag); const Tag = (["h1", "h2", "h3", "h4", "h5", "h6", "div"].includes(tag) ? tag : "div") as "div"; const left = s(settings.eael_infobox_img_type) === "img-on-left" || s(settings.eael_infobox_img_type) === "icon-on-left"; // 原站 EAEL DOM:.eael-infobox.icon-on-top / .icon-on-left(配图上方/左侧布局) const cls = "eael-infobox " + (left ? "icon-on-left" : "icon-on-top"); // 标题排版/颜色(H14 实测 fs 1.25rem/700/#0D152E;内容排版 hover 键原站未生效,不采用) const titleStyle: CSSProperties = { ...typographyCss(settings, "eael_infobox_title_typography"), ...(s(settings.eael_infobox_title_color) ? { color: s(settings.eael_infobox_title_color) as string } : {}), }; // 标题外距(EAEL:设置非空时覆盖默认 8px 0 16px;570df98 显式 0 0 0 0,02956ef 走默认) const tmRaw = settings.eael_infobox_title_margin as Record | undefined; const tmSet = !!tmRaw && ["top", "right", "bottom", "left"].some((k) => tmRaw[k] !== "" && tmRaw[k] != null); if (tmSet) { const tm = paddingCss(tmRaw); if (tm) titleStyle.margin = tm; } // 内容段落外距/颜色(EAEL 生成规则:eael_infobox_content_margin → p margin, // eael_infobox_content_color → p color;570df98 实测 margin 10px 0 0、#969696) const cmRaw = settings.eael_infobox_content_margin as Record | undefined; const cmSet = !!cmRaw && ["top", "right", "bottom", "left"].some((k) => cmRaw[k] !== "" && cmRaw[k] != null); const contentVars: CSSProperties = {}; if (cmSet) { const cm = paddingCss(cmRaw); if (cm) { const [t, r2, b, l] = cm.split(" "); Object.assign(contentVars, { "--ib-p-top": t ?? "0", "--ib-p-right": r2 ?? "0", "--ib-p-bottom": b ?? "0", "--ib-p-left": l ?? "0", }); } } const contentColor = s(settings.eael_infobox_content_color); if (contentColor) Object.assign(contentVars, { "--ib-p-color": contentColor }); return (
{img?.url ? (
{s(img.alt)}
) : icon?.value ? ( // 图标型 info-box(df5b95b:fas fa-check-circle 26px,左侧图标 + 标题)