diff --git a/misago/context_processors/metatags.py b/misago/context_processors/metatags.py new file mode 100644 index 0000000000..daada6f5a7 --- /dev/null +++ b/misago/context_processors/metatags.py @@ -0,0 +1,7 @@ +from django.http import HttpRequest + +from ..metatags.metatags import get_default_metatags + + +def default_metatags(request: HttpRequest) -> dict: + return {"default_metatags": get_default_metatags(request)} diff --git a/misago/metatags/__init__.py b/misago/metatags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/misago/metatags/apps.py b/misago/metatags/apps.py new file mode 100644 index 0000000000..c195c092db --- /dev/null +++ b/misago/metatags/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class MisagoMetatagsConfig(AppConfig): + name = "misago.metatags" + label = "misago_metatags" + verbose_name = "Misago Metatags" diff --git a/misago/metatags/hooks/__init__.py b/misago/metatags/hooks/__init__.py new file mode 100644 index 0000000000..dc15c52464 --- /dev/null +++ b/misago/metatags/hooks/__init__.py @@ -0,0 +1,2 @@ +from .get_default_metatags import get_default_metatags_hook +from .get_forum_index_metatags import get_forum_index_metatags_hook diff --git a/misago/metatags/hooks/get_default_metatags.py b/misago/metatags/hooks/get_default_metatags.py new file mode 100644 index 0000000000..d8184e4c93 --- /dev/null +++ b/misago/metatags/hooks/get_default_metatags.py @@ -0,0 +1,95 @@ +from typing import Protocol + +from django.http import HttpRequest + +from ...plugins.hooks import FilterHook +from ..metatag import MetaTag + + +class GetDefaultMetatagsHookAction(Protocol): + """ + A standard Misago function used to get default metatags for all pages. + + # Arguments + + ## `request: HttpRequest` + + The request object. + + # Return value + + A Python `dict` with metatags to include in the response HTML. + """ + + def __call__(self, request: HttpRequest) -> dict[str, MetaTag]: ... + + +class GetDefaultMetatagsHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: GetDefaultMetatagsHookAction` + + A standard Misago function used to get default metatags for all pages. + + See the [action](#action) section for details. + + ## `request: HttpRequest` + + The request object. + + # Return value + + A Python `dict` with metatags to include in the response HTML. + """ + + def __call__( + self, + action: GetDefaultMetatagsHookAction, + request: HttpRequest, + ) -> dict[str, MetaTag]: ... + + +class GetDefaultMetatagsHook( + FilterHook[GetDefaultMetatagsHookAction, GetDefaultMetatagsHookFilter] +): + """ + This hook wraps the standard function that Misago uses to get default + metatags for all pages. + + # Example + + The code below implements a custom filter function that adds a custom + metatag to all pages: + + ```python + from django.http import HttpRequest + from misago.metatags.hooks import get_default_metatags_hook + + + @get_default_metatags_hook.append_filter + def include_custom_metatag(action, request: HttpRequest) -> dict[str, MetaTag]: + metatags = action(request) + metatags["custom"] = MetaTag( + name="og:custom", + property="twitter:custom", + itemprop="custom", + content="custom content", + ) + return metatags + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: GetDefaultMetatagsHookAction, + request: HttpRequest, + ) -> dict[str, MetaTag]: + return super().__call__(action, request) + + +get_default_metatags_hook = GetDefaultMetatagsHook() diff --git a/misago/metatags/hooks/get_forum_index_metatags.py b/misago/metatags/hooks/get_forum_index_metatags.py new file mode 100644 index 0000000000..76f4c27750 --- /dev/null +++ b/misago/metatags/hooks/get_forum_index_metatags.py @@ -0,0 +1,96 @@ +from typing import Protocol + +from django.http import HttpRequest + +from ...plugins.hooks import FilterHook +from ..metatag import MetaTag + + +class GetForumIndexMetatagsHookAction(Protocol): + """ + A standard Misago function used to get metatags for the forum index page. + + # Arguments + + ## `request: HttpRequest` + + The request object. + + # Return value + + A Python `dict` with metatags to include in the response HTML. + """ + + def __call__(self, request: HttpRequest) -> dict[str, MetaTag]: ... + + +class GetForumIndexMetatagsHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: GetForumIndexMetatagsHookAction` + + A standard Misago function used to get metatags for the forum index page. + + See the [action](#action) section for details. + + ## `request: HttpRequest` + + The request object. + + # Return value + + A Python `dict` with metatags to include in the response HTML. + """ + + def __call__( + self, + action: GetForumIndexMetatagsHookAction, + request: HttpRequest, + ) -> dict[str, MetaTag]: ... + + +class GetForumIndexMetatagsHook( + FilterHook[GetForumIndexMetatagsHookAction, GetForumIndexMetatagsHookFilter] +): + """ + This hook wraps the standard function that Misago uses to get metatags for + the forum index page. + + # Example + + The code below implements a custom filter function that adds a custom + metatag to the forum index page: + + ```python + from django.http import HttpRequest + from misago.metatags.hooks import get_forum_index_metatags_hook + from misago.metatags.metatag import MetaTag + + + @get_forum_index_metatags_hook.append_filter + def include_custom_metatag(action, request: HttpRequest) -> dict[str, MetaTag]: + metatags = action(request) + metatags["custom"] = MetaTag( + name="og:custom", + property="twitter:custom", + itemprop="custom", + content="custom content", + ) + return metatags + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: GetForumIndexMetatagsHookAction, + request: HttpRequest, + ) -> dict[str, MetaTag]: + return super().__call__(action, request) + + +get_forum_index_metatags_hook = GetForumIndexMetatagsHook() diff --git a/misago/metatags/metatag.py b/misago/metatags/metatag.py new file mode 100644 index 0000000000..0d713d98f0 --- /dev/null +++ b/misago/metatags/metatag.py @@ -0,0 +1,23 @@ +import html +from dataclasses import dataclass + + +@dataclass(frozen=True) +class MetaTag: + content: str | int + name: str | None = None + property: str | None = None + itemprop: str | None = None + + def as_html(self): + attrs: dict[str, str] = {} + if self.name: + attrs["name"] = str(self.name) + if self.property: + attrs["property"] = str(self.property) + if self.itemprop: + attrs["itemprop"] = str(self.itemprop) + attrs["content"] = str(self.content) + + attrs_html = [f'{name}="{html.escape(value)}"' for name, value in attrs.items()] + return f"" diff --git a/misago/metatags/metatags.py b/misago/metatags/metatags.py new file mode 100644 index 0000000000..8d955ff762 --- /dev/null +++ b/misago/metatags/metatags.py @@ -0,0 +1,95 @@ +from django.conf import settings as dj_settings +from django.http import HttpRequest +from django.templatetags.static import static + +from .hooks import get_default_metatags_hook, get_forum_index_metatags_hook +from .metatag import MetaTag + + +def get_forum_index_metatags(request: HttpRequest) -> dict[str, MetaTag]: + return get_forum_index_metatags_hook(_get_forum_index_metatags_action, request) + + +def _get_forum_index_metatags_action(request: HttpRequest) -> dict[str, MetaTag]: + metatags = get_default_metatags(request) + + if request.settings.index_title: + metatags["title"] = MetaTag( + property="og:title", + name="twitter:title", + content=request.settings.index_title, + ) + + if request.settings.index_meta_description: + metatags["description"] = MetaTag( + property="og:description", + name="twitter:description", + content=request.settings.index_meta_description, + ) + + if request.settings.forum_address: + metatags["url"] = ( + MetaTag( + property="og:url", + name="twitter:url", + content=request.settings.forum_address, + ), + ) + + return metatags + + +def get_default_metatags(request: HttpRequest) -> dict[str, MetaTag]: + return get_default_metatags_hook(_get_default_metatags_action, request) + + +def _get_default_metatags_action(request: HttpRequest) -> dict[str, MetaTag]: + settings = request.settings + + metatags = { + "og:site_name": MetaTag(property="og:site_name", content=settings.forum_name), + "og:type": MetaTag(property="og:type", content="website"), + "twitter:card": MetaTag(name="twitter:card", content="summary"), + } + + og_image = request.settings.get("og_image") + if og_image["value"]: + metatags.update( + { + "image": MetaTag( + property="og:image", + name="twitter:image", + content=request.build_absolute_uri(og_image["value"]), + ), + "image:width": MetaTag( + property="og:image:width", content=og_image["width"] + ), + "image:height": MetaTag( + property="og:image:height", content=og_image["height"] + ), + } + ) + else: + og_image_url = request.build_absolute_uri( + static(dj_settings.MISAGO_DEFAULT_OG_IMAGE) + ) + + metatags.update( + { + "image": MetaTag( + property="og:image", + name="twitter:image", + content=og_image_url, + ), + "image:width": MetaTag( + property="og:image:width", + content=dj_settings.MISAGO_DEFAULT_OG_IMAGE_WIDTH, + ), + "image:height": MetaTag( + property="og:image:height", + content=dj_settings.MISAGO_DEFAULT_OG_IMAGE_HEIGHT, + ), + } + ) + + return metatags diff --git a/misago/metatags/tests/__init__.py b/misago/metatags/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/misago/settings.py b/misago/settings.py index 6ef61f1c8e..cfca007a23 100644 --- a/misago/settings.py +++ b/misago/settings.py @@ -3,6 +3,9 @@ __all__ = [ "INSTALLED_APPS", "INSTALLED_PLUGINS", + "MISAGO_DEFAULT_OG_IMAGE", + "MISAGO_DEFAULT_OG_IMAGE_WIDTH", + "MISAGO_DEFAULT_OG_IMAGE_HEIGHT", "MISAGO_EMAIL_CHANGE_TOKEN_EXPIRES", "MISAGO_FORUM_ADDRESS_HISTORY", "MISAGO_MIDDLEWARE", @@ -50,6 +53,7 @@ "misago.themes", "misago.markup", "misago.menus", + "misago.metatags", "misago.middleware", "misago.notifications", "misago.oauth2", @@ -82,6 +86,7 @@ "misago.context_processors.categories.categories", "misago.context_processors.forumindex.main_menu", "misago.context_processors.htmx.is_request_htmx", + "misago.context_processors.metatags.default_metatags", "misago.context_processors.permissions.user_permissions", "misago.acl.context_processors.user_acl", "misago.conf.context_processors.conf", @@ -132,12 +137,16 @@ "misago.threads.middleware.UnreadThreadsCountMiddleware", ] +MISAGO_DEFAULT_OG_IMAGE = "misago/img/og-image.jpg" +MISAGO_DEFAULT_OG_IMAGE_WIDTH = 1200 +MISAGO_DEFAULT_OG_IMAGE_HEIGHT = 630 + +MISAGO_EMAIL_CHANGE_TOKEN_EXPIRES = 48 # Hours + +MISAGO_FORUM_ADDRESS_HISTORY = [] + MISAGO_NOTIFICATIONS_RETRY_DELAY = 5 # Seconds MISAGO_PARSER_MAX_ATTACHMENTS = 30 MISAGO_PARSER_MAX_POSTS = 20 MISAGO_PARSER_MAX_USERS = 25 - -MISAGO_FORUM_ADDRESS_HISTORY = [] - -MISAGO_EMAIL_CHANGE_TOKEN_EXPIRES = 48 # Hours diff --git a/misago/templates/misago/base.html b/misago/templates/misago/base.html index 35d64089ac..ac6fbc7669 100644 --- a/misago/templates/misago/base.html +++ b/misago/templates/misago/base.html @@ -8,32 +8,15 @@ {% spaceless %}{% block title %}{{ settings.forum_name }}{% endblock %}{% endspaceless %} {% spaceless %} - {% block meta-extra %}{% endblock meta-extra %} - {% block og-tags %} - - - - - - - - - - {% block og-image %} - {% if og_image %} - - - - - {% else %} - {% static "misago/img/og-image.jpg" as og_image_url %} - - - - - {% endif %} - {% endblock og-image %} - {% endblock og-tags %} + {% if metatags %} + {% for metatag in metatags.values %} + {{ metatag.as_html|safe }} + {% endfor %} + {% elif default_metatags %} + {% for metatag in default_metatags.values %} + {{ metatag.as_html|safe }} + {% endfor %} + {% endif %} {% if theme.include_defaults %} {% endif %} diff --git a/misago/templates/misago/categories/index.html b/misago/templates/misago/categories/index.html index fdd4c559ea..fc78a34411 100644 --- a/misago/templates/misago/categories/index.html +++ b/misago/templates/misago/categories/index.html @@ -3,23 +3,23 @@ {% block title %} - {% if THREADS_ON_INDEX %} - {% trans "Categories" context "categories page" %} | {{ block.super }} - {% else %} + {% if is_index %} {{ settings.index_title|default:settings.forum_name }} + {% else %} + {% trans "Categories" context "categories page" %} | {{ block.super }} {% endif %} {% endblock title %} {% block meta-description %} - {% if not THREADS_ON_INDEX and settings.index_meta_description %} - {{ settings.index_meta_description }} - {% else %} + {% if is_index %} {% blocktrans trimmed count categories=categories_list|length with forum_name=settings.forum_name context "categories page" %} There is {{ categories }} main category currenty available on the {{ forum_name }}. {% plural %} There are {{ categories }} main categories currenty available on the {{ forum_name }}. {% endblocktrans %} + {% elif settings.index_meta_description %} + {{ settings.index_meta_description }} {% endif %} {% endblock meta-description %} diff --git a/misago/templates/misago/category/index.html b/misago/templates/misago/category/index.html index 33691eb144..1c48d69ddd 100644 --- a/misago/templates/misago/category/index.html +++ b/misago/templates/misago/category/index.html @@ -22,34 +22,43 @@ {% endfor %} - {% if threads.paginator.has_previous %} - - Start - - {% else %} - - {% endif %} - {% if threads.paginator.has_previous %} - - Previous - - {% else %} - - {% endif %} - {% if threads.paginator.has_next %} - - Next - - {% else %} - - {% endif %} - {% endif %} +
+
+
+
+ {% if threads.paginator.has_previous %} + + Start + + {% else %} + + {% endif %} + {% if threads.paginator.has_previous %} + + Previous + + {% else %} + + {% endif %} + {% if threads.paginator.has_next %} + + Next + + {% else %} + + {% endif %} + {% endif %} +
+
+
+
+