diff --git a/buzz/page_meta.py b/buzz/page_meta.py new file mode 100644 index 0000000..fd7e211 --- /dev/null +++ b/buzz/page_meta.py @@ -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 //
, 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 diff --git a/buzz/test_page_meta.py b/buzz/test_page_meta.py new file mode 100644 index 0000000..c1a3022 --- /dev/null +++ b/buzz/test_page_meta.py @@ -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 /. + 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"], "") diff --git a/buzz/www/dashboard.py b/buzz/www/dashboard.py index 93a243b..1c1f854 100644 --- a/buzz/www/dashboard.py +++ b/buzz/www/dashboard.py @@ -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 @@ -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 diff --git a/dashboard/index.html b/dashboard/index.html index ffb213a..745702e 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -4,7 +4,19 @@ - Buzz Dashboard + + {{ title | e }} + + + + + + + + +
diff --git a/dashboard/src/components/BaseCustomEventForm.vue b/dashboard/src/components/BaseCustomEventForm.vue index ce76f0c..43f702b 100644 --- a/dashboard/src/components/BaseCustomEventForm.vue +++ b/dashboard/src/components/BaseCustomEventForm.vue @@ -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"; @@ -202,6 +202,13 @@ const props = defineProps({ }); const formData = ref(null); + +usePageMeta(() => { + const eventTitle = formData.value?.event?.title; + const formTitle = formData.value?.form_title; + return eventTitle && formTitle ? { title: `${eventTitle} - ${formTitle}` } : null; +}); + const formValues = reactive>({}); const customFieldValues = ref>({}); const submitted = ref(false); diff --git a/dashboard/src/pages/BookTickets.vue b/dashboard/src/pages/BookTickets.vue index 48c7359..1db2241 100644 --- a/dashboard/src/pages/BookTickets.vue +++ b/dashboard/src/pages/BookTickets.vue @@ -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"; @@ -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 = "/"; }; diff --git a/dashboard/src/pages/EventProposalForm.vue b/dashboard/src/pages/EventProposalForm.vue index 2e59ebb..f766527 100644 --- a/dashboard/src/pages/EventProposalForm.vue +++ b/dashboard/src/pages/EventProposalForm.vue @@ -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"; @@ -101,6 +101,11 @@ const submitted = ref(false); const login_required = ref(false); const load_error = ref(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 ""; diff --git a/dashboard/src/router.ts b/dashboard/src/router.ts index c55c2e6..21df19a 100644 --- a/dashboard/src/router.ts +++ b/dashboard/src/router.ts @@ -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[] = [ { @@ -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