contact messages: site settings fields, localized strings, enabled toggle

Data gains contact_enabled/contact_label/contact_email/contact_subject.
The label and subject localize via the chunk-keyed trans store
(i18n.site_string), ride the page asset config as pagerite:contact-*
(absent when disabled), and default the {contact:} button label; a
per-button {contact: Label} arg still wins. Disabled messaging renders
no buttons and rejects visitor sends; admins keep the inbox.
This commit is contained in:
LeoVasanko committed 2026-09-28 06:24:44 +00:00
1 parent 1796942892
commit 2adafd215b
5 files changed
+97 -7

No files matched your search

+3 -1
View File
@@ -553,7 +553,9 @@ async def editor_ws(ws: WebSocket) -> None:
# shows real cards, not the literal tag. No translation
# context: the preview has no lang of its own, so cards
# render in their originals.
directives: dict = {"contact": views._contact_tag}
directives: dict = {
"contact": lambda args, _env: views._contact_tag(args, _env, data)
}
if node is not None and has_cards_tag:
directives["cards"] = (
lambda args, _env, node=node, path=path: views._cards_tag(
+15
View File
@@ -210,6 +210,21 @@ class Data(msgspec.Struct):
#: Visitor contact threads (docs/messages.md), keyed by the blake3
#: hash of the visitor's session token (never the token itself).
contact: dict[bytes, ContactThread] = {}
#: Messaging on/off: when False, {contact:} buttons do not render,
#: visitors get no corner ✉️ icon and sends are rejected (admins keep
#: the inbox for the existing threads).
contact_enabled: bool = True
#: Site-wide default label for the {contact:} button and the visitor
#: dialog title (a per-button {contact: Label} arg wins). Empty = the
#: built-in "Contact us". Translated per language in Data.trans, keyed
#: by chunk hash like titles (i18n.site_string).
contact_label: str = ""
#: Contact email shown as a mailto link in the visitor dialog (with
#: the subject prefilled). Empty = no link.
contact_email: str = ""
#: Subject for the mailto link; empty = the (localized) label.
#: Translated like contact_label.
contact_subject: str = ""
def node_markdown(data: Data, node: Node) -> str | None:
+30
View File
@@ -407,6 +407,36 @@ def set_title_translation(data: Data, node: Node, lang: str, title: str) -> bool
return True
def site_string(data: Data, lang: str, text: str) -> str:
"""A site-level settings string (not article content — the contact
label/subject) in ``lang``: its translation from the trans store,
chunk-keyed like titles (the translator service picks these up via
pending_items), falling back to the original. The site default
language always gets the original."""
if not text or not lang or lang == primary_lang(data.menu, ""):
return text
return data.trans.get(chunk_key(text), {}).get(lang) or text
def set_string_translation(data: Data, lang: str, original: str, text: str) -> bool:
"""Record (or drop) a site-string translation (i18n.site_string): a
fragment in ``Data.trans`` keyed by the original's chunk hash — the
same storage machine translations use, overriding them. Empty or the
original's text drops the override. Returns True when anything changed.
Pure data ops — the caller wraps in a transaction and invalidates."""
key = chunk_key(original)
current = data.trans.get(key, {}).get(lang)
if not text or text == original:
if current is None:
return False
del data.trans[key][lang]
return True
if current == text:
return False
data.trans.setdefault(key, {})[lang] = text
return True
def clear_translations(data: Data) -> None:
"""Drop all machine translations (``Data.trans``) and rebuild the
availability index (``node.langs``) from the surviving user overrides —
+5
View File
@@ -242,6 +242,11 @@ async def visitor_frame(ws: WebSocket, raw: dict) -> None:
if len(text) > MAX_MESSAGE:
await ws.send_json({"type": "contact-error", "detail": "message too long"})
return
if not data.contact_enabled:
# Messaging off in the site settings: sends are rejected (the UI
# is already hidden); existing threads and admin replies stay.
await ws.send_json({"type": "contact-error", "detail": "messaging is disabled"})
return
# The first message must introduce the visitor (name; email optional,
# unvalidated). The stored identity is fixed from then on.
thread = data.contact.get(key)
+44 -6
View File
@@ -337,6 +337,7 @@ def _layout(
canonical: str = "",
alternates: list[tuple[str, str]] = (),
markdown_url: str = "",
contact: dict[str, str] | None = None,
) -> Template:
"""Page layout template with standard assets and ES-module scripts.
@@ -369,6 +370,9 @@ def _layout(
``alternates`` ((hreflang, href) pairs) are the page's language URLs
(see docs/localization.md), emitted right after the viewport and before
the social tags: canonical first, then the hreflang alternates.
``contact`` carries the localized contact-messaging strings
(_contact_config; None = messaging disabled) into the asset config.
"""
doc = Document(
E.Title, lang=lang, dir="rtl" if lang in i18n.RTL_LANGUAGES else "ltr"
@@ -428,6 +432,15 @@ def _layout(
config["pagerite:contact-css"] = ",".join(contact_css)
if editor_css:
config["pagerite:editor-css"] = editor_css
# Localized contact-messaging strings (label = the {contact:} buttons'
# default and the visitor dialog's title, mailto address + subject).
# Absent entirely when messaging is disabled: pagerite.js then shows
# the corner ✉️ icon to admins only.
if contact is not None:
config["pagerite:contact-label"] = contact["label"]
config["pagerite:contact-subject"] = contact["subject"]
if contact["email"]:
config["pagerite:contact-email"] = contact["email"]
if vite_url:
for key, value in config.items():
doc.meta(name=key, content=value)
@@ -904,7 +917,14 @@ def page_content(
# A {cards} tag places the card rows itself (possibly several);
# without one the children are appended after the content as before.
has_cards_tag = _CARDS_TAG_RE.search(content) is not None
directives = {"contact": _contact_tag}
# Messaging disabled: {contact:} tags render nothing at all.
directives = {
"contact": (
(lambda args, _env: _contact_tag(args, _env, data, lang))
if data.contact_enabled
else (lambda _args, _env: "")
)
}
if has_cards_tag:
directives["cards"] = lambda args, _env: _cards_tag(
menu, data, node, path, args, translation, link_lang, lang
@@ -986,15 +1006,31 @@ def _cards(
_CARDS_TAG_RE = re.compile(r"^\{cards(?::[^{}\n]*)?\}[ \t]*$", re.MULTILINE)
def _contact_tag(args: str, _env: dict) -> str:
def _contact_config(data: Data, lang: str) -> dict[str, str]:
"""The localized contact-messaging strings for the page's asset config
(pagerite:contact-*): the {contact:} buttons' default label doubling as
the visitor dialog's title, the mailto address and its subject (the
label when no separate subject is configured). Translations come from
the chunk-keyed trans store (i18n.site_string)."""
label = i18n.site_string(data, lang, data.contact_label) or "Contact us"
return {
"label": label,
"email": data.contact_email,
"subject": i18n.site_string(data, lang, data.contact_subject) or label,
}
def _contact_tag(args: str, _env: dict, data: Data | None = None, lang: str = "") -> str:
"""Expand a ``{contact: Label}`` directive: the button that opens the
visitor contact panel (docs/messages.md). The args are the button
label ("Contact us" when empty). pagerite.js binds the click and
lazy-loads the panel bundle; in the editor preview it stays inert."""
visitor contact panel (docs/messages.md). The args are a per-button
label overriding the configured site label (Data.contact_label,
localized here). pagerite.js binds the click and lazy-loads the panel
bundle; in the editor preview it stays inert."""
return str(
E.button(
E.span("✉️", class_="icon"),
args.strip() or "Contact us",
args.strip()
or (_contact_config(data, lang)["label"] if data else "Contact us"),
type="button",
class_="contact-trigger",
)
@@ -1420,6 +1456,7 @@ def render_page(
alternates,
# The page's Markdown counterpart (pages.show_page's .md mode).
f"/{path}.md",
contact=_contact_config(data, lang) if data.contact_enabled else None,
)(
Title=f"{title} – {brand}" if brand else title,
Brand=_brand_link(brand, brand_html, link_lang),
@@ -1483,6 +1520,7 @@ def render_category(
favicon,
lang=lang,
alternates=alternates,
contact=_contact_config(data, lang) if data.contact_enabled else None,
)(
Title=f"{title} – {brand}" if brand else title,
Brand=_brand_link(brand, brand_html, link_lang),