""" Copyright 2024-2026 ChatterMate 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. SEO surface of the public help center: the values (title, description, canonical, Open Graph) and the schema.org JSON-LD graph. Kept out of the route module so every page derives its metadata by the same rules. Two URL spaces are in play and must not be mixed up: - ABSOLUTE (live_url-based) for canonical, og:*, sitemap and every JSON-LD @id/url — crawlers and social scrapers need a fetchable address. - RELATIVE (base_path-prefixed) for links the page actually renders, so the same markup works on a subdomain, a custom domain and /help/{slug}. Breadcrumbs carry both, which is how the BreadcrumbList markup is guaranteed to match the visible trail (a Google requirement). """ from datetime import datetime from typing import List, Optional, Sequence from urllib.parse import quote, urlsplit from app.models.faq import FAQ from app.models.help_center import HelpCenterSettings from app.services.help_center_content import excerpt, to_plain_text from app.services.help_center_settings import live_url HOME_CRUMB = "Home" # Meta descriptions get truncated by search engines around here; the excerpt # helper adds an ellipsis rather than cutting mid-word. META_DESCRIPTION_CHARS = 200 def site_url(row: HelpCenterSettings) -> str: """The help center's public base URL, never trailing-slashed.""" return live_url(row).rstrip("/") def asset_origin(site: str) -> str: """Scheme + host the site's uploads are served from. Same origin as the site in every mode — but NOT the same prefix in path mode, where the site is {backend}/help/{slug} while uploads stay at {backend}/api/v1/uploads/…""" parts = urlsplit(site) return f"{parts.scheme}://{parts.netloc}" def absolute_asset_url(stored_url: Optional[str], site: str) -> Optional[str]: """A stored logo/image URL made absolute for og:image and JSON-LD. S3 URLs are already absolute and pass through; local uploads are relative and get anchored to the serving origin.""" if not stored_url: return None if stored_url.startswith("http://") or stored_url.startswith("https://"): return stored_url return f"{asset_origin(site)}{stored_url}" def _isoformat(value: Optional[datetime]) -> Optional[str]: return value.isoformat() if value else None def article_title(row: HelpCenterSettings, faq: FAQ) -> str: """The article's : the org's override, else question + site name.""" return faq.meta_title or f"{faq.question} · {row.organization.name} Help Center" def article_description(faq: FAQ) -> str: """The article's meta description: the org's override, else an excerpt of the answer (falling back to the question for answers with no prose).""" return faq.meta_description or excerpt(faq.answer, META_DESCRIPTION_CHARS) or faq.question def index_title(row: HelpCenterSettings) -> str: return row.title or f"{row.organization.name} Help Center" def index_description(row: HelpCenterSettings) -> str: return row.description or f"Answers to common questions about {row.organization.name}." def category_url(site: str, category: str) -> str: """Absolute URL of the index filtered to one category — the middle breadcrumb. Mirrors the ?topic= link the templates render.""" return f"{site}/?topic={quote(category)}" def article_path(faq: FAQ) -> str: """The root-relative path an article is served at, percent-DECODED. An article migrated in with "preserve original URLs" keeps the path it had on the old help center; everything else lives at /a/{slug}. Exactly one of the two is canonical — /a/{slug} 301s to the preserved path when there is one — so this is the single answer to "where does this article live?" and every URL the site emits derives from it.""" return faq.url_path or f"/a/{faq.slug}" def article_href(faq: FAQ, base_path: str = "") -> str: """What a page renders in href=: the serving prefix plus the ENCODED path.""" return f"{base_path}{quote(article_path(faq), safe='/')}" def article_url(site: str, faq: FAQ) -> str: """The article's ABSOLUTE URL, for canonical/og:url/sitemap/JSON-LD.""" return f"{site}{quote(article_path(faq), safe='/')}" def breadcrumbs( row: HelpCenterSettings, faq: FAQ, base_path: str, site: str ) -> List[dict]: """Home › Category › Article. Each crumb carries `href` (what the page links to) and `url` (what BreadcrumbList advertises); the last crumb is the current page and is rendered as text, so it has no href.""" return [ {"label": HOME_CRUMB, "href": f"{base_path}/", "url": f"{site}/"}, { "label": faq.category, "href": f"{base_path}/?topic={quote(faq.category)}", "url": category_url(site, faq.category), }, {"label": faq.question, "href": None, "url": article_url(site, faq)}, ] def _organization_node(row: HelpCenterSettings, site: str, logo_url: Optional[str]) -> dict: node = { "@type": "Organization", "@id": f"{site}/#organization", "name": row.organization.name, "url": f"{site}/", } if logo_url: node["logo"] = {"@type": "ImageObject", "url": logo_url} return node def _website_node(row: HelpCenterSettings, site: str) -> dict: return { "@type": "WebSite", "@id": f"{site}/#website", "url": f"{site}/", "name": index_title(row), "publisher": {"@id": f"{site}/#organization"}, } def _question_entity(faq: FAQ) -> dict: """One schema.org Question + acceptedAnswer, from the plain-text form of the (Markdown) answer.""" return { "@type": "Question", "name": faq.question, "acceptedAnswer": {"@type": "Answer", "text": to_plain_text(faq.answer)}, } def _breadcrumb_node(crumbs: Sequence[dict], page_url: str) -> dict: return { "@type": "BreadcrumbList", "@id": f"{page_url}#breadcrumb", "itemListElement": [ {"@type": "ListItem", "position": i, "name": crumb["label"], "item": crumb["url"]} for i, crumb in enumerate(crumbs, start=1) ], } def index_json_ld( row: HelpCenterSettings, faqs: Sequence[FAQ], site: str, logo_url: Optional[str] ) -> dict: """The landing page graph: Organization + WebSite + a CollectionPage that is also the FAQPage carrying every published Q&A. FAQPage - not QAPage - is the correct type for these owner-authored answers. QAPage models community/forum pages where users submit competing answers and requires answerCount/upvoteCount, which don't apply here. Google retired FAQ rich results, but the markup still describes the page for other consumers. """ page_url = f"{site}/" page: dict = { "@type": ["CollectionPage", "FAQPage"], "@id": f"{page_url}#webpage", "url": page_url, "name": index_title(row), "description": index_description(row), "isPartOf": {"@id": f"{site}/#website"}, "about": {"@id": f"{site}/#organization"}, } if faqs: page["mainEntity"] = [_question_entity(faq) for faq in faqs] return { "@context": "https://schema.org", "@graph": [ _organization_node(row, site, logo_url), _website_node(row, site), page, ], } def article_json_ld( row: HelpCenterSettings, faq: FAQ, site: str, crumbs: Sequence[dict], logo_url: Optional[str], image_url: Optional[str], ) -> dict: """The article page graph: Organization + WebSite + WebPage + BreadcrumbList + TechArticle, wired together by @id. TechArticle (not FAQPage) is what a single how-to/help article is: Google dropped FAQ rich results, while BreadcrumbList still renders in search. """ page_url = article_url(site, faq) description = article_description(faq) article: dict = { "@type": "TechArticle", "@id": f"{page_url}#article", "headline": faq.question, "description": description, "articleSection": faq.category, "inLanguage": "en", "mainEntityOfPage": {"@id": f"{page_url}#webpage"}, "isPartOf": {"@id": f"{page_url}#webpage"}, "author": {"@id": f"{site}/#organization"}, "publisher": {"@id": f"{site}/#organization"}, } published_at = _isoformat(faq.created_at) # updated_at stays NULL until the first edit, so fall back to the creation # date rather than claiming a modification date older than publication. modified_at = _isoformat(faq.updated_at) or published_at if published_at: article["datePublished"] = published_at if modified_at: article["dateModified"] = modified_at if image_url: article["image"] = image_url web_page = { "@type": "WebPage", "@id": f"{page_url}#webpage", "url": page_url, "name": article_title(row, faq), "description": description, "isPartOf": {"@id": f"{site}/#website"}, "breadcrumb": {"@id": f"{page_url}#breadcrumb"}, } return { "@context": "https://schema.org", "@graph": [ _organization_node(row, site, logo_url), _website_node(row, site), web_page, _breadcrumb_node(crumbs, page_url), article, ], }