One word everywhere: ReplyPier in UI copy, package and image names, the replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the --rp-* design tokens. - public widget API is window.ReplyPier with replypierId / replypierBaseUrl / replypierConfig / replypierToken, loader at webclient/replypier.min.js; both bundles rebuilt from source - NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime config rewritten; the demo config ships no third-party keys - migration adds the ReplyPier managed model value to the AI model enum - local compose project, database, role, volume and Docker network carry the same name
132 lines
5.3 KiB
Python
132 lines
5.3 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
|
|
FAQ answers are authored and stored as Markdown so a single Text column can hold
|
|
rich help-center articles (headings, images, links, ordered steps, callouts).
|
|
This module turns that Markdown into HTML for the public page, and into plain
|
|
text for previews, meta descriptions and FAQPage structured data.
|
|
|
|
Security: the rendered HTML is dropped onto the *public* help center, so an org
|
|
admin's Markdown is untrusted input for visitors. Everything is run through an
|
|
nh3 (ammonia) allowlist clean — the template must mark this output `| safe`, so
|
|
this is the ONLY place org content is ever trusted as HTML.
|
|
"""
|
|
|
|
import re
|
|
from typing import Mapping, Optional
|
|
|
|
import markdown as _markdown
|
|
import nh3
|
|
from bs4 import BeautifulSoup
|
|
|
|
# `extra` enables tables/fenced code/etc; `sane_lists` fixes mixed list quirks;
|
|
# `nl2br` treats single newlines as <br> so plain-text FAQ answers (the common
|
|
# case) keep their line breaks without the author needing blank lines.
|
|
_MD_EXTENSIONS = ["extra", "sane_lists", "nl2br"]
|
|
|
|
# Allowlist of tags a help-center article may contain. Anything else (script,
|
|
# style, iframe, form, event handlers, …) is stripped by nh3.
|
|
_ALLOWED_TAGS = {
|
|
"p", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
"strong", "b", "em", "i", "u", "s", "del", "code", "pre", "blockquote",
|
|
"ul", "ol", "li", "a", "img",
|
|
"table", "thead", "tbody", "tr", "th", "td",
|
|
"figure", "figcaption", "span",
|
|
}
|
|
_ALLOWED_ATTRS = {
|
|
"a": {"href", "title"},
|
|
"img": {"src", "alt", "title"},
|
|
}
|
|
# Block javascript:/data: URLs; images and links may only point at real hosts.
|
|
_URL_SCHEMES = {"http", "https", "mailto"}
|
|
|
|
_WHITESPACE_RE = re.compile(r"\s+")
|
|
|
|
# Intra-help-center links are stored ROOT-RELATIVE (/a/{slug}, or / for the home)
|
|
# so they survive a move between path mode and a subdomain/custom domain. The
|
|
# serving prefix is added here at render time. `/a/` (with the trailing slash)
|
|
# never matches an image path like /api/v1/uploads/...
|
|
_INTERNAL_HREF_RE = re.compile(r'(href=")(/a/[^"]*|/)(")')
|
|
|
|
|
|
def render_article_html(
|
|
text: str, base_path: str = "", link_paths: Optional[Mapping[str, str]] = None
|
|
) -> str:
|
|
"""Markdown -> sanitized HTML for the public article body. Safe to render
|
|
with Jinja's `| safe` because nh3 strips every tag/attr/scheme off-allowlist
|
|
and rewrites link rel to neutralise tab-nabbing.
|
|
|
|
`base_path` is the serving prefix ("" at a domain root, "/help/{slug}" in path
|
|
mode) prepended to root-relative intra-help-center links so cross-article links
|
|
resolve under whichever mode/domain serves the page.
|
|
|
|
`link_paths` maps slug -> the path that article is actually served at, for
|
|
the articles whose original URL was preserved on a help-center migration.
|
|
Cross-article links stay STORED as /a/{slug} (stable when a path is later
|
|
edited, and the only form the regex above can prefix) and are resolved here,
|
|
so a link lands directly on the preserved URL instead of via its 301."""
|
|
if not text or not text.strip():
|
|
return ""
|
|
raw = _markdown.markdown(text, extensions=_MD_EXTENSIONS, output_format="html")
|
|
html = nh3.clean(
|
|
raw,
|
|
tags=_ALLOWED_TAGS,
|
|
attributes=_ALLOWED_ATTRS,
|
|
url_schemes=_URL_SCHEMES,
|
|
link_rel="noopener noreferrer nofollow",
|
|
)
|
|
if base_path or link_paths:
|
|
html = _INTERNAL_HREF_RE.sub(
|
|
lambda m: f"{m.group(1)}{base_path}{_resolve_internal_href(m.group(2), link_paths)}{m.group(3)}",
|
|
html,
|
|
)
|
|
return html
|
|
|
|
|
|
def _resolve_internal_href(href: str, link_paths: Optional[Mapping[str, str]]) -> str:
|
|
"""A stored /a/{slug} link swapped for that article's preserved path, if it
|
|
has one. Unknown slugs (and the "/" home link) pass straight through."""
|
|
if not link_paths or not href.startswith("/a/"):
|
|
return href
|
|
return link_paths.get(href[len("/a/"):], href)
|
|
|
|
|
|
def to_plain_text(text: str) -> str:
|
|
"""Strip Markdown/HTML to a single line of readable text (previews, meta
|
|
descriptions, JSON-LD answer text)."""
|
|
if not text or not text.strip():
|
|
return ""
|
|
html = _markdown.markdown(text, extensions=_MD_EXTENSIONS, output_format="html")
|
|
plain = BeautifulSoup(html, "html.parser").get_text(" ")
|
|
return _WHITESPACE_RE.sub(" ", plain).strip()
|
|
|
|
|
|
def excerpt(text: str, length: int = 150) -> str:
|
|
"""A short one-line preview of an answer, cut on a word boundary."""
|
|
plain = to_plain_text(text)
|
|
if len(plain) <= length:
|
|
return plain
|
|
return plain[:length].rsplit(" ", 1)[0].rstrip() + "…"
|
|
|
|
|
|
def read_time_minutes(text: str) -> int:
|
|
"""Estimated read time in whole minutes (>= 1), ~200 words/min."""
|
|
words = len(to_plain_text(text).split())
|
|
return max(1, round(words / 200))
|
|
|
|
|
|
def read_time_label(text: str) -> str:
|
|
return f"{read_time_minutes(text)} min read"
|