feat: render webapp preview metadata
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||||
<title>__PAGE_TITLE__</title>
|
<title>__PAGE_TITLE__</title>
|
||||||
<style nonce="__NONCE__">
|
<style nonce="__NONCE__">
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
|
|||||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
|
||||||
if not raw_logo_url:
|
if not raw_logo_url:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -1145,6 +1145,72 @@ async def i18n_route(request: web.Request) -> web.Response:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _webapp_page_title(settings: Settings, suffix: str = "") -> str:
|
||||||
|
base = str(getattr(settings, "WEBAPP_TITLE", "") or "").strip() or "Subscription"
|
||||||
|
suffix = str(suffix or "").strip()
|
||||||
|
return f"{base} - {suffix}" if suffix else base
|
||||||
|
|
||||||
|
|
||||||
|
def _webapp_preview_meta_markup(page_title: str) -> str:
|
||||||
|
escaped_title = html.escape(str(page_title or ""), quote=True)
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
f'<meta name="application-name" content="{escaped_title}">',
|
||||||
|
f'<meta name="apple-mobile-web-app-title" content="{escaped_title}">',
|
||||||
|
f'<meta property="og:title" content="{escaped_title}">',
|
||||||
|
'<meta property="og:type" content="website">',
|
||||||
|
f'<meta property="og:site_name" content="{escaped_title}">',
|
||||||
|
'<meta name="twitter:card" content="summary">',
|
||||||
|
f'<meta name="twitter:title" content="{escaped_title}">',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_webapp_title(html_text: str, page_title: str) -> str:
|
||||||
|
escaped_title = html.escape(str(page_title or ""), quote=False)
|
||||||
|
next_title = f"<title>{escaped_title}</title>"
|
||||||
|
replaced = re.sub(
|
||||||
|
r"<title\b[^>]*>.*?</title>",
|
||||||
|
next_title,
|
||||||
|
html_text,
|
||||||
|
count=1,
|
||||||
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
if replaced != html_text:
|
||||||
|
return replaced
|
||||||
|
return html_text.replace("</head>", f"{next_title}\n</head>", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_webapp_favicon(html_text: str, favicon_markup: str) -> str:
|
||||||
|
markup = str(favicon_markup or "").strip()
|
||||||
|
if not markup:
|
||||||
|
return html_text
|
||||||
|
replaced = re.sub(
|
||||||
|
r"<link\b(?=[^>]*\bid=[\"']app-favicon[\"'])[^>]*>",
|
||||||
|
markup,
|
||||||
|
html_text,
|
||||||
|
count=1,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if replaced != html_text:
|
||||||
|
return replaced
|
||||||
|
return html_text.replace("</head>", f"{markup}\n</head>", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_webapp_head_metadata(html_text: str, page_title: str, favicon_url: str = "") -> str:
|
||||||
|
html_text = _replace_webapp_title(html_text, page_title)
|
||||||
|
if 'property="og:title"' not in html_text and "property='og:title'" not in html_text:
|
||||||
|
meta_markup = _webapp_preview_meta_markup(page_title)
|
||||||
|
html_text = re.sub(
|
||||||
|
r"(<title\b[^>]*>.*?</title>)",
|
||||||
|
lambda match: f"{match.group(1)}\n{meta_markup}",
|
||||||
|
html_text,
|
||||||
|
count=1,
|
||||||
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
return _replace_webapp_favicon(html_text, _favicon_head_markup(favicon_url))
|
||||||
|
|
||||||
|
|
||||||
async def index_route(request: web.Request) -> web.Response:
|
async def index_route(request: web.Request) -> web.Response:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
if not settings.WEBAPP_ENABLED:
|
if not settings.WEBAPP_ENABLED:
|
||||||
@@ -1166,6 +1232,7 @@ async def index_route(request: web.Request) -> web.Response:
|
|||||||
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
|
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
|
||||||
if initial_theme_markup:
|
if initial_theme_markup:
|
||||||
html = html.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
|
html = html.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
|
||||||
|
html = _apply_webapp_head_metadata(html, _webapp_page_title(settings), cached["favicon_url"])
|
||||||
i18n_payload = bootstrap["i18n"]
|
i18n_payload = bootstrap["i18n"]
|
||||||
nonce = request.get("csp_nonce", "")
|
nonce = request.get("csp_nonce", "")
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
@@ -1188,12 +1255,6 @@ async def index_route(request: web.Request) -> web.Response:
|
|||||||
WEBAPP_JS_PLACEHOLDER,
|
WEBAPP_JS_PLACEHOLDER,
|
||||||
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
|
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
|
||||||
)
|
)
|
||||||
favicon_markup = _favicon_head_markup(cached["favicon_url"])
|
|
||||||
if favicon_markup:
|
|
||||||
html = html.replace(
|
|
||||||
'<link id="app-favicon" rel="icon" href="data:," sizes="any">',
|
|
||||||
favicon_markup,
|
|
||||||
)
|
|
||||||
brand_asset_url = cached["logo_url"]
|
brand_asset_url = cached["logo_url"]
|
||||||
if (
|
if (
|
||||||
not brand_asset_url
|
not brand_asset_url
|
||||||
@@ -1224,22 +1285,21 @@ async def app_deeplink_route(request: web.Request) -> web.Response:
|
|||||||
query = getattr(request, "query", {}) or {}
|
query = getattr(request, "query", {}) or {}
|
||||||
lang = _normalize_language(query.get("lang") or getattr(settings, "DEFAULT_LANGUAGE", "ru"))
|
lang = _normalize_language(query.get("lang") or getattr(settings, "DEFAULT_LANGUAGE", "ru"))
|
||||||
messages = _app_deeplink_i18n_payload(request, lang)
|
messages = _app_deeplink_i18n_payload(request, lang)
|
||||||
page_title = html.escape(
|
page_title = _webapp_page_title(settings, messages["title"])
|
||||||
f"{getattr(settings, 'WEBAPP_TITLE', '') or 'Subscription'} - {messages['title']}",
|
|
||||||
quote=False,
|
|
||||||
)
|
|
||||||
messages_json = json.dumps(
|
messages_json = json.dumps(
|
||||||
messages,
|
messages,
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
separators=(",", ":"),
|
separators=(",", ":"),
|
||||||
).replace("</", "<\\/")
|
).replace("</", "<\\/")
|
||||||
|
favicon_url = _resolve_webapp_favicon_url(settings, _resolve_webapp_logo_url(settings))
|
||||||
html_text = (
|
html_text = (
|
||||||
_read_template_text_cached(APP_DEEPLINK_TEMPLATE_PATH)
|
_read_template_text_cached(APP_DEEPLINK_TEMPLATE_PATH)
|
||||||
.replace("__LANG__", html.escape(lang, quote=True))
|
.replace("__LANG__", html.escape(lang, quote=True))
|
||||||
.replace("__PAGE_TITLE__", page_title)
|
.replace("__PAGE_TITLE__", html.escape(page_title, quote=False))
|
||||||
.replace("__NONCE__", nonce)
|
.replace("__NONCE__", nonce)
|
||||||
.replace("__MESSAGES_JSON__", messages_json)
|
.replace("__MESSAGES_JSON__", messages_json)
|
||||||
)
|
)
|
||||||
|
html_text = _apply_webapp_head_metadata(html_text, page_title, favicon_url)
|
||||||
response = web.Response(text=html_text, content_type="text/html", charset="utf-8")
|
response = web.Response(text=html_text, content_type="text/html", charset="utf-8")
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
applyFavicon,
|
applyFavicon,
|
||||||
|
applyDocumentTitle,
|
||||||
normalizeBrand,
|
normalizeBrand,
|
||||||
readJsonScript,
|
readJsonScript,
|
||||||
structuredCloneSafe,
|
structuredCloneSafe,
|
||||||
@@ -463,6 +464,7 @@
|
|||||||
? t("wa_auth_telegram_not_configured")
|
? t("wa_auth_telegram_not_configured")
|
||||||
: "";
|
: "";
|
||||||
$: applyFavicon(faviconBrand);
|
$: applyFavicon(faviconBrand);
|
||||||
|
$: applyDocumentTitle(brandTitle);
|
||||||
$: syncBodyScrollLock(
|
$: syncBodyScrollLock(
|
||||||
paymentModalOpen ||
|
paymentModalOpen ||
|
||||||
changeModalOpen ||
|
changeModalOpen ||
|
||||||
|
|||||||
@@ -80,3 +80,10 @@ export function applyFavicon(brand = {}) {
|
|||||||
favicon.removeAttribute("type");
|
favicon.removeAttribute("type");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyDocumentTitle(title) {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const nextTitle = String(title || "").trim();
|
||||||
|
if (!nextTitle || document.title === nextTitle) return;
|
||||||
|
document.title = nextTitle;
|
||||||
|
}
|
||||||
|
|||||||
@@ -176,6 +176,23 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn('rel="apple-touch-icon"', markup)
|
self.assertIn('rel="apple-touch-icon"', markup)
|
||||||
self.assertIn("/webapp-favicon/abcdef1234567890/icon-32.png", markup)
|
self.assertIn("/webapp-favicon/abcdef1234567890/icon-32.png", markup)
|
||||||
|
|
||||||
|
def test_webapp_head_metadata_replaces_static_title_and_favicon(self):
|
||||||
|
html = (
|
||||||
|
'<html><head><link id="app-favicon" rel="icon" href="data:," sizes="any" />'
|
||||||
|
"<title>/minishop</title></head><body></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered = subscription_webapp._apply_webapp_head_metadata(
|
||||||
|
html,
|
||||||
|
"Brand & <VPN>",
|
||||||
|
"/webapp-favicon/abcdef1234567890/icon-180.png",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("<title>Brand & <VPN></title>", rendered)
|
||||||
|
self.assertIn('property="og:title" content="Brand & <VPN>"', rendered)
|
||||||
|
self.assertIn("/webapp-favicon/abcdef1234567890/icon-32.png", rendered)
|
||||||
|
self.assertNotIn('href="data:,"', rendered)
|
||||||
|
|
||||||
def test_favicon_set_generation_writes_common_icon_sizes(self):
|
def test_favicon_set_generation_writes_common_icon_sizes(self):
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
Image.new("RGBA", (2, 2), (0, 254, 122, 255)).save(buffer, format="PNG")
|
Image.new("RGBA", (2, 2), (0, 254, 122, 255)).save(buffer, format="PNG")
|
||||||
|
|||||||
Reference in New Issue
Block a user