All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
Combines shared, blog, market, cart, events, federation, and account into a single repository. Eliminates submodule sync, sibling model copying at build time, and per-app CI orchestration. Changes: - Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs - Remove stale sibling model copies from each app - Update all 6 Dockerfiles for monorepo build context (root = .) - Add build directives to docker-compose.yml - Add single .gitea/workflows/ci.yml with change detection - Add .dockerignore for monorepo build context - Create __init__.py for federation and account (cross-app imports)
669 lines
22 KiB
Python
669 lines
22 KiB
Python
"""
|
||
Lexical JSON → HTML renderer.
|
||
|
||
Produces HTML matching Ghost's ``kg-*`` class conventions so the existing
|
||
``cards.css`` stylesheet works unchanged.
|
||
|
||
Public API
|
||
----------
|
||
render_lexical(doc) – Lexical JSON (dict or string) → HTML string
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import html
|
||
import json
|
||
from typing import Callable
|
||
|
||
import mistune
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Registry
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_RENDERERS: dict[str, Callable[[dict], str]] = {}
|
||
|
||
|
||
def _renderer(node_type: str):
|
||
"""Decorator — register a function as the renderer for *node_type*."""
|
||
def decorator(fn: Callable[[dict], str]) -> Callable[[dict], str]:
|
||
_RENDERERS[node_type] = fn
|
||
return fn
|
||
return decorator
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public entry point
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def render_lexical(doc: dict | str) -> str:
|
||
"""Render a Lexical JSON document to an HTML string."""
|
||
if isinstance(doc, str):
|
||
doc = json.loads(doc)
|
||
root = doc.get("root", doc)
|
||
return _render_children(root.get("children", []))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Core dispatch
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _render_node(node: dict) -> str:
|
||
node_type = node.get("type", "")
|
||
renderer = _RENDERERS.get(node_type)
|
||
if renderer:
|
||
return renderer(node)
|
||
return ""
|
||
|
||
|
||
def _render_children(children: list[dict]) -> str:
|
||
return "".join(_render_node(c) for c in children)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Text formatting
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Lexical format bitmask
|
||
_FORMAT_BOLD = 1
|
||
_FORMAT_ITALIC = 2
|
||
_FORMAT_STRIKETHROUGH = 4
|
||
_FORMAT_UNDERLINE = 8
|
||
_FORMAT_CODE = 16
|
||
_FORMAT_SUBSCRIPT = 32
|
||
_FORMAT_SUPERSCRIPT = 64
|
||
_FORMAT_HIGHLIGHT = 128
|
||
|
||
_FORMAT_TAGS: list[tuple[int, str, str]] = [
|
||
(_FORMAT_BOLD, "<strong>", "</strong>"),
|
||
(_FORMAT_ITALIC, "<em>", "</em>"),
|
||
(_FORMAT_STRIKETHROUGH, "<s>", "</s>"),
|
||
(_FORMAT_UNDERLINE, "<u>", "</u>"),
|
||
(_FORMAT_CODE, "<code>", "</code>"),
|
||
(_FORMAT_SUBSCRIPT, "<sub>", "</sub>"),
|
||
(_FORMAT_SUPERSCRIPT, "<sup>", "</sup>"),
|
||
(_FORMAT_HIGHLIGHT, "<mark>", "</mark>"),
|
||
]
|
||
|
||
# Element-level alignment from ``format`` field
|
||
_ALIGN_MAP = {
|
||
1: "text-align: left",
|
||
2: "text-align: center",
|
||
3: "text-align: right",
|
||
4: "text-align: justify",
|
||
}
|
||
|
||
|
||
def _align_style(node: dict) -> str:
|
||
fmt = node.get("format")
|
||
if isinstance(fmt, int) and fmt in _ALIGN_MAP:
|
||
return f' style="{_ALIGN_MAP[fmt]}"'
|
||
if isinstance(fmt, str) and fmt:
|
||
return f' style="text-align: {fmt}"'
|
||
return ""
|
||
|
||
|
||
def _wrap_format(text: str, fmt: int) -> str:
|
||
for mask, open_tag, close_tag in _FORMAT_TAGS:
|
||
if fmt & mask:
|
||
text = f"{open_tag}{text}{close_tag}"
|
||
return text
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tier 1 — text nodes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@_renderer("text")
|
||
def _text(node: dict) -> str:
|
||
text = html.escape(node.get("text", ""))
|
||
fmt = node.get("format", 0)
|
||
if isinstance(fmt, int) and fmt:
|
||
text = _wrap_format(text, fmt)
|
||
return text
|
||
|
||
|
||
@_renderer("linebreak")
|
||
def _linebreak(_node: dict) -> str:
|
||
return "<br>"
|
||
|
||
|
||
@_renderer("tab")
|
||
def _tab(_node: dict) -> str:
|
||
return "\t"
|
||
|
||
|
||
@_renderer("paragraph")
|
||
def _paragraph(node: dict) -> str:
|
||
inner = _render_children(node.get("children", []))
|
||
if not inner:
|
||
inner = "<br>"
|
||
style = _align_style(node)
|
||
return f"<p{style}>{inner}</p>"
|
||
|
||
|
||
@_renderer("extended-text")
|
||
def _extended_text(node: dict) -> str:
|
||
return _paragraph(node)
|
||
|
||
|
||
@_renderer("heading")
|
||
def _heading(node: dict) -> str:
|
||
tag = node.get("tag", "h2")
|
||
inner = _render_children(node.get("children", []))
|
||
style = _align_style(node)
|
||
return f"<{tag}{style}>{inner}</{tag}>"
|
||
|
||
|
||
@_renderer("extended-heading")
|
||
def _extended_heading(node: dict) -> str:
|
||
return _heading(node)
|
||
|
||
|
||
@_renderer("quote")
|
||
def _quote(node: dict) -> str:
|
||
inner = _render_children(node.get("children", []))
|
||
return f"<blockquote>{inner}</blockquote>"
|
||
|
||
|
||
@_renderer("extended-quote")
|
||
def _extended_quote(node: dict) -> str:
|
||
return _quote(node)
|
||
|
||
|
||
@_renderer("aside")
|
||
def _aside(node: dict) -> str:
|
||
inner = _render_children(node.get("children", []))
|
||
return f"<aside>{inner}</aside>"
|
||
|
||
|
||
@_renderer("link")
|
||
def _link(node: dict) -> str:
|
||
href = html.escape(node.get("url", ""), quote=True)
|
||
target = node.get("target", "")
|
||
rel = node.get("rel", "")
|
||
inner = _render_children(node.get("children", []))
|
||
attrs = f' href="{href}"'
|
||
if target:
|
||
attrs += f' target="{html.escape(target, quote=True)}"'
|
||
if rel:
|
||
attrs += f' rel="{html.escape(rel, quote=True)}"'
|
||
return f"<a{attrs}>{inner}</a>"
|
||
|
||
|
||
@_renderer("autolink")
|
||
def _autolink(node: dict) -> str:
|
||
return _link(node)
|
||
|
||
|
||
@_renderer("at-link")
|
||
def _at_link(node: dict) -> str:
|
||
return _link(node)
|
||
|
||
|
||
@_renderer("list")
|
||
def _list(node: dict) -> str:
|
||
tag = "ol" if node.get("listType") == "number" else "ul"
|
||
start = node.get("start")
|
||
inner = _render_children(node.get("children", []))
|
||
attrs = ""
|
||
if tag == "ol" and start and start != 1:
|
||
attrs = f' start="{start}"'
|
||
return f"<{tag}{attrs}>{inner}</{tag}>"
|
||
|
||
|
||
@_renderer("listitem")
|
||
def _listitem(node: dict) -> str:
|
||
inner = _render_children(node.get("children", []))
|
||
return f"<li>{inner}</li>"
|
||
|
||
|
||
@_renderer("horizontalrule")
|
||
def _horizontalrule(_node: dict) -> str:
|
||
return "<hr>"
|
||
|
||
|
||
@_renderer("code")
|
||
def _code(node: dict) -> str:
|
||
# Inline code nodes from Lexical — just render inner text
|
||
inner = _render_children(node.get("children", []))
|
||
return f"<code>{inner}</code>"
|
||
|
||
|
||
@_renderer("codeblock")
|
||
def _codeblock(node: dict) -> str:
|
||
lang = node.get("language", "")
|
||
code = html.escape(node.get("code", ""))
|
||
cls = f' class="language-{html.escape(lang)}"' if lang else ""
|
||
return f'<pre><code{cls}>{code}</code></pre>'
|
||
|
||
|
||
@_renderer("code-highlight")
|
||
def _code_highlight(node: dict) -> str:
|
||
text = html.escape(node.get("text", ""))
|
||
highlight_type = node.get("highlightType", "")
|
||
if highlight_type:
|
||
return f'<span class="token {html.escape(highlight_type)}">{text}</span>'
|
||
return text
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tier 2 — common cards
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@_renderer("image")
|
||
def _image(node: dict) -> str:
|
||
src = node.get("src", "")
|
||
alt = node.get("alt", "")
|
||
caption = node.get("caption", "")
|
||
width = node.get("cardWidth", "") or node.get("width", "")
|
||
href = node.get("href", "")
|
||
|
||
width_class = ""
|
||
if width == "wide":
|
||
width_class = " kg-width-wide"
|
||
elif width == "full":
|
||
width_class = " kg-width-full"
|
||
|
||
img_tag = f'<img src="{html.escape(src, quote=True)}" alt="{html.escape(alt, quote=True)}" loading="lazy">'
|
||
if href:
|
||
img_tag = f'<a href="{html.escape(href, quote=True)}">{img_tag}</a>'
|
||
|
||
parts = [f'<figure class="kg-card kg-image-card{width_class}">']
|
||
parts.append(img_tag)
|
||
if caption:
|
||
parts.append(f"<figcaption>{caption}</figcaption>")
|
||
parts.append("</figure>")
|
||
return "".join(parts)
|
||
|
||
|
||
@_renderer("gallery")
|
||
def _gallery(node: dict) -> str:
|
||
images = node.get("images", [])
|
||
if not images:
|
||
return ""
|
||
|
||
rows = []
|
||
for i in range(0, len(images), 3):
|
||
row_imgs = images[i:i + 3]
|
||
row_cls = f"kg-gallery-row" if len(row_imgs) <= 3 else "kg-gallery-row"
|
||
imgs_html = []
|
||
for img in row_imgs:
|
||
src = img.get("src", "")
|
||
alt = img.get("alt", "")
|
||
caption = img.get("caption", "")
|
||
img_tag = f'<img src="{html.escape(src, quote=True)}" alt="{html.escape(alt, quote=True)}" loading="lazy">'
|
||
fig = f'<figure class="kg-gallery-image">{img_tag}'
|
||
if caption:
|
||
fig += f"<figcaption>{caption}</figcaption>"
|
||
fig += "</figure>"
|
||
imgs_html.append(fig)
|
||
rows.append(f'<div class="{row_cls}">{"".join(imgs_html)}</div>')
|
||
|
||
caption = node.get("caption", "")
|
||
caption_html = f"<figcaption>{caption}</figcaption>" if caption else ""
|
||
return (
|
||
f'<figure class="kg-card kg-gallery-card kg-width-wide">'
|
||
f'<div class="kg-gallery-container">{"".join(rows)}</div>'
|
||
f"{caption_html}</figure>"
|
||
)
|
||
|
||
|
||
@_renderer("html")
|
||
def _html_card(node: dict) -> str:
|
||
raw = node.get("html", "")
|
||
return f"<!--kg-card-begin: html-->{raw}<!--kg-card-end: html-->"
|
||
|
||
|
||
@_renderer("markdown")
|
||
def _markdown(node: dict) -> str:
|
||
md_text = node.get("markdown", "")
|
||
rendered = mistune.html(md_text)
|
||
return f"<!--kg-card-begin: markdown-->{rendered}<!--kg-card-end: markdown-->"
|
||
|
||
|
||
@_renderer("embed")
|
||
def _embed(node: dict) -> str:
|
||
embed_html = node.get("html", "")
|
||
caption = node.get("caption", "")
|
||
url = node.get("url", "")
|
||
caption_html = f"<figcaption>{caption}</figcaption>" if caption else ""
|
||
return (
|
||
f'<figure class="kg-card kg-embed-card">'
|
||
f"{embed_html}{caption_html}</figure>"
|
||
)
|
||
|
||
|
||
@_renderer("bookmark")
|
||
def _bookmark(node: dict) -> str:
|
||
url = node.get("url", "")
|
||
title = html.escape(node.get("metadata", {}).get("title", "") or node.get("title", ""))
|
||
description = html.escape(node.get("metadata", {}).get("description", "") or node.get("description", ""))
|
||
icon = node.get("metadata", {}).get("icon", "") or node.get("icon", "")
|
||
author = html.escape(node.get("metadata", {}).get("author", "") or node.get("author", ""))
|
||
publisher = html.escape(node.get("metadata", {}).get("publisher", "") or node.get("publisher", ""))
|
||
thumbnail = node.get("metadata", {}).get("thumbnail", "") or node.get("thumbnail", "")
|
||
caption = node.get("caption", "")
|
||
|
||
icon_html = f'<img class="kg-bookmark-icon" src="{html.escape(icon, quote=True)}" alt="">' if icon else ""
|
||
thumbnail_html = (
|
||
f'<div class="kg-bookmark-thumbnail">'
|
||
f'<img src="{html.escape(thumbnail, quote=True)}" alt=""></div>'
|
||
) if thumbnail else ""
|
||
|
||
meta_parts = []
|
||
if icon_html:
|
||
meta_parts.append(icon_html)
|
||
if author:
|
||
meta_parts.append(f'<span class="kg-bookmark-author">{author}</span>')
|
||
if publisher:
|
||
meta_parts.append(f'<span class="kg-bookmark-publisher">{publisher}</span>')
|
||
metadata_html = f'<span class="kg-bookmark-metadata">{"".join(meta_parts)}</span>' if meta_parts else ""
|
||
|
||
caption_html = f"<figcaption>{caption}</figcaption>" if caption else ""
|
||
|
||
return (
|
||
f'<figure class="kg-card kg-bookmark-card">'
|
||
f'<a class="kg-bookmark-container" href="{html.escape(url, quote=True)}">'
|
||
f'<div class="kg-bookmark-content">'
|
||
f'<div class="kg-bookmark-title">{title}</div>'
|
||
f'<div class="kg-bookmark-description">{description}</div>'
|
||
f'{metadata_html}'
|
||
f'</div>'
|
||
f'{thumbnail_html}'
|
||
f'</a>'
|
||
f'{caption_html}'
|
||
f'</figure>'
|
||
)
|
||
|
||
|
||
@_renderer("callout")
|
||
def _callout(node: dict) -> str:
|
||
color = node.get("backgroundColor", "grey")
|
||
emoji = node.get("calloutEmoji", "")
|
||
inner = _render_children(node.get("children", []))
|
||
|
||
emoji_html = f'<div class="kg-callout-emoji">{emoji}</div>' if emoji else ""
|
||
return (
|
||
f'<div class="kg-card kg-callout-card kg-callout-card-{html.escape(color)}">'
|
||
f'{emoji_html}'
|
||
f'<div class="kg-callout-text">{inner}</div>'
|
||
f'</div>'
|
||
)
|
||
|
||
|
||
@_renderer("button")
|
||
def _button(node: dict) -> str:
|
||
text = html.escape(node.get("buttonText", ""))
|
||
url = html.escape(node.get("buttonUrl", ""), quote=True)
|
||
alignment = node.get("alignment", "center")
|
||
return (
|
||
f'<div class="kg-card kg-button-card kg-align-{alignment}">'
|
||
f'<a href="{url}" class="kg-btn kg-btn-accent">{text}</a>'
|
||
f'</div>'
|
||
)
|
||
|
||
|
||
@_renderer("toggle")
|
||
def _toggle(node: dict) -> str:
|
||
heading = node.get("heading", "")
|
||
# Toggle content is in children
|
||
inner = _render_children(node.get("children", []))
|
||
return (
|
||
f'<div class="kg-card kg-toggle-card" data-kg-toggle-state="close">'
|
||
f'<div class="kg-toggle-heading">'
|
||
f'<h4 class="kg-toggle-heading-text">{heading}</h4>'
|
||
f'<button class="kg-toggle-card-icon">'
|
||
f'<svg viewBox="0 0 14 14"><path d="M7 0a.5.5 0 0 1 .5.5v6h6a.5.5 0 1 1 0 1h-6v6a.5.5 0 1 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 7 0Z" fill="currentColor"/></svg>'
|
||
f'</button>'
|
||
f'</div>'
|
||
f'<div class="kg-toggle-content">{inner}</div>'
|
||
f'</div>'
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tier 3 — media & remaining cards
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@_renderer("audio")
|
||
def _audio(node: dict) -> str:
|
||
src = node.get("src", "")
|
||
title = html.escape(node.get("title", ""))
|
||
duration = node.get("duration", 0)
|
||
thumbnail = node.get("thumbnailSrc", "")
|
||
|
||
duration_min = int(duration) // 60
|
||
duration_sec = int(duration) % 60
|
||
duration_str = f"{duration_min}:{duration_sec:02d}"
|
||
|
||
if thumbnail:
|
||
thumb_html = (
|
||
f'<img src="{html.escape(thumbnail, quote=True)}" alt="audio-thumbnail" '
|
||
f'class="kg-audio-thumbnail">'
|
||
)
|
||
else:
|
||
thumb_html = (
|
||
'<div class="kg-audio-thumbnail placeholder">'
|
||
'<svg viewBox="0 0 24 24"><path d="M2 12C2 6.48 6.48 2 12 2s10 4.48 10 10-4.48 10-10 10S2 17.52 2 12zm7.5 5.25L16 12 9.5 6.75v10.5z" fill="currentColor"/></svg>'
|
||
'</div>'
|
||
)
|
||
|
||
return (
|
||
f'<div class="kg-card kg-audio-card">'
|
||
f'{thumb_html}'
|
||
f'<div class="kg-audio-player-container">'
|
||
f'<div class="kg-audio-title">{title}</div>'
|
||
f'<div class="kg-audio-player">'
|
||
f'<button class="kg-audio-play-icon"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z" fill="currentColor"/></svg></button>'
|
||
f'<div class="kg-audio-current-time">0:00</div>'
|
||
f'<div class="kg-audio-time">/ {duration_str}</div>'
|
||
f'<input type="range" class="kg-audio-seek-slider" max="100" value="0">'
|
||
f'<button class="kg-audio-playback-rate">1×</button>'
|
||
f'<button class="kg-audio-unmute-icon"><svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" fill="currentColor"/></svg></button>'
|
||
f'<input type="range" class="kg-audio-volume-slider" max="100" value="100">'
|
||
f'</div>'
|
||
f'</div>'
|
||
f'<audio src="{html.escape(src, quote=True)}" preload="metadata"></audio>'
|
||
f'</div>'
|
||
)
|
||
|
||
|
||
@_renderer("video")
|
||
def _video(node: dict) -> str:
|
||
src = node.get("src", "")
|
||
caption = node.get("caption", "")
|
||
width = node.get("cardWidth", "")
|
||
thumbnail = node.get("thumbnailSrc", "") or node.get("customThumbnailSrc", "")
|
||
loop = node.get("loop", False)
|
||
|
||
width_class = ""
|
||
if width == "wide":
|
||
width_class = " kg-width-wide"
|
||
elif width == "full":
|
||
width_class = " kg-width-full"
|
||
|
||
loop_attr = " loop" if loop else ""
|
||
poster_attr = f' poster="{html.escape(thumbnail, quote=True)}"' if thumbnail else ""
|
||
caption_html = f"<figcaption>{caption}</figcaption>" if caption else ""
|
||
|
||
return (
|
||
f'<figure class="kg-card kg-video-card{width_class}">'
|
||
f'<div class="kg-video-container">'
|
||
f'<video src="{html.escape(src, quote=True)}" controls preload="metadata"{poster_attr}{loop_attr}></video>'
|
||
f'</div>'
|
||
f'{caption_html}'
|
||
f'</figure>'
|
||
)
|
||
|
||
|
||
@_renderer("file")
|
||
def _file(node: dict) -> str:
|
||
src = node.get("src", "")
|
||
title = html.escape(node.get("fileName", "") or node.get("title", ""))
|
||
caption = node.get("caption", "")
|
||
file_size = node.get("fileSize", 0)
|
||
file_name = html.escape(node.get("fileName", ""))
|
||
|
||
# Format size
|
||
if file_size:
|
||
kb = file_size / 1024
|
||
if kb < 1024:
|
||
size_str = f"{kb:.0f} KB"
|
||
else:
|
||
size_str = f"{kb / 1024:.1f} MB"
|
||
else:
|
||
size_str = ""
|
||
|
||
caption_html = f'<div class="kg-file-card-caption">{caption}</div>' if caption else ""
|
||
size_html = f'<div class="kg-file-card-filesize">{size_str}</div>' if size_str else ""
|
||
|
||
return (
|
||
f'<div class="kg-card kg-file-card">'
|
||
f'<a class="kg-file-card-container" href="{html.escape(src, quote=True)}" download="{file_name}">'
|
||
f'<div class="kg-file-card-contents">'
|
||
f'<div class="kg-file-card-title">{title}</div>'
|
||
f'{size_html}'
|
||
f'</div>'
|
||
f'<div class="kg-file-card-icon">'
|
||
f'<svg viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" fill="currentColor"/></svg>'
|
||
f'</div>'
|
||
f'</a>'
|
||
f'{caption_html}'
|
||
f'</div>'
|
||
)
|
||
|
||
|
||
@_renderer("paywall")
|
||
def _paywall(_node: dict) -> str:
|
||
return "<!--members-only-->"
|
||
|
||
|
||
@_renderer("header")
|
||
def _header(node: dict) -> str:
|
||
heading = node.get("heading", "")
|
||
subheading = node.get("subheading", "")
|
||
size = node.get("size", "small")
|
||
style = node.get("style", "dark")
|
||
bg_image = node.get("backgroundImageSrc", "")
|
||
button_text = node.get("buttonText", "")
|
||
button_url = node.get("buttonUrl", "")
|
||
|
||
bg_style = f' style="background-image: url({html.escape(bg_image, quote=True)})"' if bg_image else ""
|
||
heading_html = f"<h2>{heading}</h2>" if heading else ""
|
||
subheading_html = f"<p>{subheading}</p>" if subheading else ""
|
||
button_html = (
|
||
f'<a class="kg-header-card-button" href="{html.escape(button_url, quote=True)}">{html.escape(button_text)}</a>'
|
||
if button_text and button_url else ""
|
||
)
|
||
|
||
return (
|
||
f'<div class="kg-card kg-header-card kg-style-{html.escape(style)} kg-size-{html.escape(size)}"{bg_style}>'
|
||
f'{heading_html}{subheading_html}{button_html}'
|
||
f'</div>'
|
||
)
|
||
|
||
|
||
@_renderer("signup")
|
||
def _signup(node: dict) -> str:
|
||
heading = node.get("heading", "")
|
||
subheading = node.get("subheading", "")
|
||
disclaimer = node.get("disclaimer", "")
|
||
button_text = html.escape(node.get("buttonText", "Subscribe"))
|
||
button_color = node.get("buttonColor", "")
|
||
bg_color = node.get("backgroundColor", "")
|
||
bg_image = node.get("backgroundImageSrc", "")
|
||
style = node.get("style", "dark")
|
||
|
||
bg_style_parts = []
|
||
if bg_color:
|
||
bg_style_parts.append(f"background-color: {bg_color}")
|
||
if bg_image:
|
||
bg_style_parts.append(f"background-image: url({html.escape(bg_image, quote=True)})")
|
||
style_attr = f' style="{"; ".join(bg_style_parts)}"' if bg_style_parts else ""
|
||
|
||
heading_html = f"<h2>{heading}</h2>" if heading else ""
|
||
subheading_html = f"<p>{subheading}</p>" if subheading else ""
|
||
disclaimer_html = f'<p class="kg-signup-card-disclaimer">{disclaimer}</p>' if disclaimer else ""
|
||
btn_style = f' style="background-color: {button_color}"' if button_color else ""
|
||
|
||
return (
|
||
f'<div class="kg-card kg-signup-card kg-style-{html.escape(style)}"{style_attr}>'
|
||
f'{heading_html}{subheading_html}'
|
||
f'<form class="kg-signup-card-form" data-members-form>'
|
||
f'<input type="email" placeholder="Your email" required>'
|
||
f'<button type="submit" class="kg-signup-card-button"{btn_style}>{button_text}</button>'
|
||
f'</form>'
|
||
f'{disclaimer_html}'
|
||
f'</div>'
|
||
)
|
||
|
||
|
||
@_renderer("product")
|
||
def _product(node: dict) -> str:
|
||
title = html.escape(node.get("productTitle", "") or node.get("title", ""))
|
||
description = node.get("productDescription", "") or node.get("description", "")
|
||
img_src = node.get("productImageSrc", "")
|
||
button_text = html.escape(node.get("buttonText", ""))
|
||
button_url = node.get("buttonUrl", "")
|
||
rating = node.get("rating", 0)
|
||
|
||
img_html = (
|
||
f'<img class="kg-product-card-image" src="{html.escape(img_src, quote=True)}" alt="">'
|
||
if img_src else ""
|
||
)
|
||
button_html = (
|
||
f'<a class="kg-product-card-button kg-btn kg-btn-accent" href="{html.escape(button_url, quote=True)}">{button_text}</a>'
|
||
if button_text and button_url else ""
|
||
)
|
||
stars = ""
|
||
if rating:
|
||
active = int(rating)
|
||
stars_html = []
|
||
for i in range(5):
|
||
cls = "kg-product-card-rating-active" if i < active else ""
|
||
stars_html.append(
|
||
f'<svg class="kg-product-card-rating-star {cls}" viewBox="0 0 24 24">'
|
||
f'<path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279L0 9.306l8.332-1.151z" fill="currentColor"/>'
|
||
f'</svg>'
|
||
)
|
||
stars = f'<div class="kg-product-card-rating">{"".join(stars_html)}</div>'
|
||
|
||
return (
|
||
f'<div class="kg-card kg-product-card">'
|
||
f'{img_html}'
|
||
f'<div class="kg-product-card-container">'
|
||
f'<h4 class="kg-product-card-title">{title}</h4>'
|
||
f'{stars}'
|
||
f'<div class="kg-product-card-description">{description}</div>'
|
||
f'{button_html}'
|
||
f'</div>'
|
||
f'</div>'
|
||
)
|
||
|
||
|
||
@_renderer("email")
|
||
def _email(node: dict) -> str:
|
||
raw_html = node.get("html", "")
|
||
return f"<!--kg-card-begin: email-->{raw_html}<!--kg-card-end: email-->"
|
||
|
||
|
||
@_renderer("email-cta")
|
||
def _email_cta(node: dict) -> str:
|
||
raw_html = node.get("html", "")
|
||
return f"<!--kg-card-begin: email-cta-->{raw_html}<!--kg-card-end: email-cta-->"
|
||
|
||
|
||
@_renderer("call-to-action")
|
||
def _call_to_action(node: dict) -> str:
|
||
raw_html = node.get("html", "")
|
||
sponsor_label = node.get("sponsorLabel", "")
|
||
label_html = (
|
||
f'<span class="kg-cta-sponsor-label">{html.escape(sponsor_label)}</span>'
|
||
if sponsor_label else ""
|
||
)
|
||
return (
|
||
f'<div class="kg-card kg-cta-card">'
|
||
f'{label_html}{raw_html}'
|
||
f'</div>'
|
||
)
|