Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions buzz/page_meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Copyright (c) 2025, BWH Studios and contributors
# For license information, please see license.txt

import frappe
from frappe import _
from frappe.utils.data import get_url

from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES

DEFAULT_TITLE = "Buzz Dashboard"
TITLE_SEPARATOR = " - "
MAX_DESCRIPTION_LENGTH = 160


def get_page_meta(app_path: str | None) -> dict:
"""Return the browser title and social meta tags for a dashboard route.

The whole dashboard is one SPA served from a single Jinja shell
(www/dashboard.html), so the route has to be resolved here for crawlers and
link previews to see anything other than the default title.
"""
segments = [segment for segment in (app_path or "").split("/") if segment]
try:
meta = resolve_meta(segments)
except Exception:
# This renders the shell for every dashboard route, so a bad title must
# never take the whole SPA down with it.
frappe.log_error("Failed to resolve dashboard page meta")
meta = {}

title = meta.get("title") or DEFAULT_TITLE
image = meta.get("image")
return {
"title": title,
"description": clean_description(meta.get("description")) or title,
"image": get_url(image) if image else "",
}


def resolve_meta(segments: list[str]) -> dict:
if len(segments) == 2 and segments[0] == "register":
event = get_published_event(segments[1])
return get_event_meta(event, _("Register")) if event else {}

if segments == ["event-proposal"]:
# Not tied to an event: this is the form for proposing a new one.
banner_title = frappe.db.get_single_value("Buzz Settings", "event_proposal_banner_title")
return {"title": banner_title or _("Propose an Event")}

# Mirrors the vue-router catch-all /<event>/<form>, matched last there too.
if len(segments) == 2 and segments[0] not in RESERVED_EVENT_ROUTES:
event = get_published_event(segments[0])
if not event:
return {}
form_doctype = get_published_form_doctype(event.name, segments[1])
return get_event_meta(event, _(form_doctype)) if form_doctype else {}

return {}


def get_event_meta(event: frappe._dict, suffix: str) -> dict:
return {
"title": f"{event.title}{TITLE_SEPARATOR}{suffix}",
"description": event.short_description,
"image": event.meta_image or event.banner_image,
}


def get_published_event(event_route: str) -> frappe._dict | None:
# Filtering on is_published matters: this runs for Guests and db reads
# bypass permissions, so an unpublished event would leak its title.
return frappe.db.get_value(
"Buzz Event",
{"route": event_route, "is_published": 1},
["name", "title", "short_description", "meta_image", "banner_image"],
as_dict=True,
)


def get_published_form_doctype(event: str, form_route: str) -> str | None:
return frappe.db.get_value(
"Buzz Event Form",
{"parent": event, "parenttype": "Buzz Event", "route": form_route, "publish": 1},
"form_doctype",
)


def clean_description(description: str | None) -> str:
text = " ".join((description or "").split())
if len(text) > MAX_DESCRIPTION_LENGTH:
text = text[:MAX_DESCRIPTION_LENGTH].rstrip() + "..."
return text
157 changes: 157 additions & 0 deletions buzz/test_page_meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Copyright (c) 2025, BWH Studios and contributors
# For license information, please see license.txt

import frappe
from frappe.tests import IntegrationTestCase

from buzz.page_meta import DEFAULT_TITLE, get_page_meta


def ensure_prompt_named_record(doctype, name):
# Event Category / Event Host use autoname "prompt" -> name set explicitly.
if frappe.db.exists(doctype, name):
return name
doc = frappe.new_doc(doctype)
doc.name = name
doc.insert(ignore_permissions=True)
return doc.name


class TestPageMeta(IntegrationTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.category = ensure_prompt_named_record("Event Category", "Test Page Meta Category")
cls.host = ensure_prompt_named_record("Event Host", "Test Page Meta Host")

def make_event(self, is_published=1, route=None, form_route=None, publish_form=1, **kwargs):
event = frappe.new_doc("Buzz Event")
event.update(
{
"title": f"Test Event {frappe.generate_hash(length=6)}",
"start_date": "2030-01-01",
"end_date": "2030-01-01",
"start_time": "10:00:00",
"end_time": "18:00:00",
"medium": "Online",
"category": self.category,
"host": self.host,
"is_published": is_published,
}
)
if route:
event.route = route
event.update(kwargs)
event.set("custom_forms", [])
if form_route:
event.append(
"custom_forms",
{"form_doctype": "Talk Proposal", "route": form_route, "publish": publish_form},
)
event.insert(ignore_permissions=True)
event.reload()
return event


class TestRegisterPageMeta(TestPageMeta):
def test_title_has_event_name(self):
event = self.make_event()
meta = get_page_meta(f"register/{event.route}")
self.assertIn(event.title, meta["title"])
self.assertIn("Register", meta["title"])

def test_short_description_used_as_description(self):
event = self.make_event(short_description="Three days of Frappe talks.")
meta = get_page_meta(f"register/{event.route}")
self.assertEqual(meta["description"], "Three days of Frappe talks.")

def test_description_falls_back_to_title(self):
event = self.make_event()
meta = get_page_meta(f"register/{event.route}")
self.assertEqual(meta["description"], meta["title"])

def test_image_is_absolute_url(self):
event = self.make_event(meta_image="/files/meta.png")
meta = get_page_meta(f"register/{event.route}")
self.assertTrue(meta["image"].startswith("http"))
self.assertTrue(meta["image"].endswith("/files/meta.png"))

def test_banner_image_used_when_no_meta_image(self):
event = self.make_event(banner_image="/files/banner.png")
meta = get_page_meta(f"register/{event.route}")
self.assertTrue(meta["image"].endswith("/files/banner.png"))

def test_no_image_returns_empty_string(self):
event = self.make_event()
meta = get_page_meta(f"register/{event.route}")
self.assertEqual(meta["image"], "")

def test_unpublished_event_title_does_not_leak(self):
event = self.make_event(is_published=0, route="unpublished-page-meta-event")
meta = get_page_meta(f"register/{event.route}")
self.assertEqual(meta["title"], DEFAULT_TITLE)
self.assertNotIn(event.title, meta["title"])

def test_unknown_event_route_returns_default(self):
meta = get_page_meta("register/no-such-event")
self.assertEqual(meta["title"], DEFAULT_TITLE)


class TestCustomFormPageMeta(TestPageMeta):
def test_title_has_event_name_and_form(self):
event = self.make_event(form_route="talks")
meta = get_page_meta(f"{event.route}/talks")
self.assertIn(event.title, meta["title"])
self.assertIn("Talk Proposal", meta["title"])

def test_unpublished_form_returns_default(self):
event = self.make_event(form_route="talks", publish_form=0)
meta = get_page_meta(f"{event.route}/talks")
self.assertEqual(meta["title"], DEFAULT_TITLE)

def test_unknown_form_route_returns_default(self):
event = self.make_event(form_route="talks")
meta = get_page_meta(f"{event.route}/no-such-form")
self.assertEqual(meta["title"], DEFAULT_TITLE)

def test_unpublished_event_form_returns_default(self):
event = self.make_event(is_published=0, route="unpublished-form-page-meta-event", form_route="talks")
meta = get_page_meta(f"{event.route}/talks")
self.assertEqual(meta["title"], DEFAULT_TITLE)

def test_reserved_route_is_not_treated_as_event(self):
# /b/account/bookings is an app route, not <event>/<form>.
meta = get_page_meta("account/bookings")
self.assertEqual(meta["title"], DEFAULT_TITLE)


class TestEventProposalPageMeta(TestPageMeta):
def set_banner_title(self, title):
# set_single_value over save(): Buzz Settings has unrelated mandatory
# fields, and this clears the single-value cache get_page_meta reads.
frappe.db.set_single_value("Buzz Settings", "event_proposal_banner_title", title)

def test_banner_title_from_settings(self):
self.set_banner_title("Host a Buzz Event")
meta = get_page_meta("event-proposal")
self.assertEqual(meta["title"], "Host a Buzz Event")

def test_falls_back_when_banner_title_blank(self):
self.set_banner_title("")
meta = get_page_meta("event-proposal")
self.assertEqual(meta["title"], "Propose an Event")


class TestFallbackPageMeta(TestPageMeta):
def test_no_app_path_returns_default(self):
for app_path in (None, "", "/"):
self.assertEqual(get_page_meta(app_path)["title"], DEFAULT_TITLE)

def test_account_route_returns_default(self):
self.assertEqual(get_page_meta("account/tickets/TKT-0001")["title"], DEFAULT_TITLE)

def test_default_meta_is_never_empty_title(self):
meta = get_page_meta("check-in")
self.assertEqual(meta["title"], DEFAULT_TITLE)
self.assertEqual(meta["description"], DEFAULT_TITLE)
self.assertEqual(meta["image"], "")
7 changes: 7 additions & 0 deletions buzz/www/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from frappe import _
from frappe.utils import get_system_timezone

from buzz.page_meta import get_page_meta

no_cache = 1


Expand All @@ -14,6 +16,11 @@ def get_context():
context = frappe._dict()
context.boot = get_boot()
context.boot.csrf_token = csrf_token

meta = get_page_meta(frappe.form_dict.get("app_path"))
context.title = meta["title"]
context.meta_description = meta["description"]
context.meta_image = meta["image"]
return context


Expand Down
14 changes: 13 additions & 1 deletion dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,19 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Buzz Dashboard</title>
<!-- Values come from buzz/www/dashboard.py; this file is built to buzz/www/dashboard.html
and rendered by jinja. Keep jinja inside attributes and <title> only: the dev server
serves this file raw, and a stray {{ }} text node in <head> ends up shown on the page. -->
<title>{{ title | e }}</title>
<meta name="description" content="{{ meta_description | e }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="{{ title | e }}" />
<meta property="og:description" content="{{ meta_description | e }}" />
<meta property="og:image" content="{{ meta_image | e }}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ title | e }}" />
<meta name="twitter:description" content="{{ meta_description | e }}" />
<meta name="twitter:image" content="{{ meta_image | e }}" />
</head>
<body class="bg-surface-white">
<div id="app"></div>
Expand Down
9 changes: 8 additions & 1 deletion dashboard/src/components/BaseCustomEventForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ import EventDetailsHeader from "@/components/EventDetailsHeader.vue";
import FormFieldSections from "@/components/FormFieldSections.vue";
import LoginRequired from "@/components/LoginRequired.vue";
import type { FrappeError } from "@/types";
import { Button, Dialog, Spinner, createResource, toast } from "frappe-ui";
import { Button, Dialog, Spinner, createResource, toast, usePageMeta } from "frappe-ui";
import { marked } from "marked";
import { computed, reactive, ref } from "vue";
import LucideAlertCircle from "~icons/lucide/alert-circle";
Expand Down Expand Up @@ -202,6 +202,13 @@ const props = defineProps({
});

const formData = ref<CustomFormData | null>(null);

usePageMeta(() => {
const eventTitle = formData.value?.event?.title;
const formTitle = formData.value?.form_title;
return eventTitle && formTitle ? { title: `${eventTitle} - ${formTitle}` } : null;
});

const formValues = reactive<Record<string, any>>({});
const customFieldValues = ref<Record<string, any>>({});
const submitted = ref(false);
Expand Down
7 changes: 6 additions & 1 deletion dashboard/src/pages/BookTickets.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import type {
OfflineMethod,
} from "@/types";
import { session } from "@/data/session";
import { Spinner, createResource } from "frappe-ui";
import { Spinner, createResource, usePageMeta } from "frappe-ui";
import { computed, reactive, ref, watch } from "vue";
import BookingForm from "../components/BookingForm.vue";

Expand Down Expand Up @@ -105,6 +105,11 @@ const props = defineProps({

const isGuest = computed(() => !session.isLoggedIn);

usePageMeta(() => {
const eventTitle = eventBookingData.eventDetails?.title;
return eventTitle ? { title: `${eventTitle} - ${__("Register")}` } : null;
});

const goToHome = () => {
window.location.href = "/";
};
Expand Down
7 changes: 6 additions & 1 deletion dashboard/src/pages/EventProposalForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ import CustomFieldInput from "@/components/CustomFieldInput.vue";
import FormFieldSections from "@/components/FormFieldSections.vue";
import LoginRequired from "@/components/LoginRequired.vue";
import type { FrappeError, FrappeField } from "@/types";
import { Button, Spinner, createResource, toast } from "frappe-ui";
import { Button, Spinner, createResource, toast, usePageMeta } from "frappe-ui";
import { marked } from "marked";
import { computed, reactive, ref } from "vue";
import LucideAlertCircle from "~icons/lucide/alert-circle";
Expand All @@ -101,6 +101,11 @@ const submitted = ref(false);
const login_required = ref(false);
const load_error = ref<string | null>(null);

usePageMeta(() => {
const bannerTitle = form_data.value?.banner_title;
return bannerTitle ? { title: bannerTitle } : null;
});

const rendered_success_message = computed(() => {
const msg = form_data.value?.success_message;
if (!msg) return "";
Expand Down
14 changes: 13 additions & 1 deletion dashboard/src/router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { userResource } from "@/data/user"
import { type RouteRecordRaw, createRouter, createWebHistory } from "vue-router"
import { type RouteRecordRaw, START_LOCATION, createRouter, createWebHistory } from "vue-router"

// Mirrors DEFAULT_TITLE in buzz/page_meta.py, which renders the initial title.
const DEFAULT_TITLE = "Buzz Dashboard"

const routes: RouteRecordRaw[] = [
{
Expand Down Expand Up @@ -147,4 +150,13 @@ router.beforeEach(async (to, from, next) => {
next()
})

router.afterEach((to, from) => {
// Pages set their own title via usePageMeta. Reset on client-side navigation
// so a page without one doesn't keep the previous page's title. The first
// load is skipped to preserve the server-rendered title.
if (from !== START_LOCATION) {
document.title = DEFAULT_TITLE
}
})

export default router
Loading