Files
rose-ash/blog/bp/blog/ghost/lexical_renderer.py
giles f42042ccb7
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
Monorepo: consolidate 7 repos into one
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)
2026-02-24 19:44:17 +00:00

669 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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&#215;</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>'
)