diff --git a/pagerite/api.py b/pagerite/api.py index 3b387c5..c02fb1b 100644 --- a/pagerite/api.py +++ b/pagerite/api.py @@ -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( diff --git a/pagerite/data.py b/pagerite/data.py index 1f3fd4b..53e5c8b 100644 --- a/pagerite/data.py +++ b/pagerite/data.py @@ -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: diff --git a/pagerite/i18n.py b/pagerite/i18n.py index 55d7c92..e2dd6b0 100644 --- a/pagerite/i18n.py +++ b/pagerite/i18n.py @@ -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 — diff --git a/pagerite/messages.py b/pagerite/messages.py index 92062e1..2e9f0c5 100644 --- a/pagerite/messages.py +++ b/pagerite/messages.py @@ -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) diff --git a/pagerite/views.py b/pagerite/views.py index f3dd843..515c0b8 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -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),