Repository: coderedcorp/wagtail-seo (language: python)

Issue:
Feature idea: front-end keyword / structure checker
SEO is often equivalent to alchemy - there is no exact science or no one standard to follow. However, tools like Yoast have a robust set of checks to ensure the basics.

We should be providing a simple visual check of basic metrics on the page, such as:
* Check that correct meta elements are present.
* Check that meta contents are of an appropriate length.
* Check the heading and paragraph structure.
* Word counts / estimated read time of page.
* Show a simple Google search preview (visual replication of what it might look like in search results).

These metrics can be grouped by who is responsible - for example if meta elements are completely missing, it is a developer problem. If meta elements are present but empty, it is a content editor problem.

Example of Yoast on a WordPress site:
![Web capture_17-11-2023_141435](https://github.com/coderedcorp/wagtail-seo/assets/13453401/ff2e0e0f-b19f-43a2-af41-7a38ed88a647)




Discussion / hints:

Fix patch:
diff --git a/wagtailseo/models.py b/wagtailseo/models.py
index 914f35d..f39352a 100644
--- a/wagtailseo/models.py
+++ b/wagtailseo/models.py
@@ -1,9 +1,11 @@
 import json
+import re
 from datetime import datetime
 from enum import Enum
 from functools import cached_property
 from typing import Optional
 
+from bs4 import BeautifulSoup
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from wagtail import VERSION as WAG_VERSION
@@ -336,6 +338,71 @@ class SeoMixin(SeoMetaFields, Page):
     class Meta:
         abstract = True
 
+    # -- Wagtail property overloads -------------------------------------------
+
+    @property
+    def preview_modes(self):
+        return super().preview_modes + [
+            ("wagtail-seo", _("SEO Preview")),
+        ]
+
+    def get_preview_context(self, request, mode_name):
+        ctx = super().get_preview_context(request, mode_name)
+        if mode_name != "wagtail-seo":
+            return ctx
+
+        # Render a normal preview, so we can parse some stuff from the HTML.
+        pre = self.serve_preview(request, self.default_preview_mode)
+        pre.render()
+        soup = BeautifulSoup(pre.content)
+        icon_link = soup.find("link", rel="icon")
+        if icon_link:
+            icon_href = icon_link["href"]
+        else:
+            icon_href = None
+
+        # Extract the base domain from the full URL.
+        match = re.search(
+            r"https?://(?:www\.)?([^/]+)",
+            self.seo_canonical_url,
+        )
+        if match:
+            site_domain = match.group(1)
+        else:
+            site_domain = self.seo_canonical_url
+
+        # Check for warnings.
+        warnings = []
+        if not self.seo_sitename:
+            warnings.append(_("Set site name in Settings > Sites."))
+        if len(self.seo_title) > 60:
+            warnings.append(
+                _(
+                    "Title tag should be shorter than 60 characters (including spaces)."
+                )
+            )
+        if len(self.seo_description) < 50 or len(self.seo_description) > 160:
+            warnings.append(
+                _(
+                    "Meta description should be between 50 to 160 characters (including spaces)."
+                )
+            )
+        ctx.update(
+            {
+                "seo_favicon": icon_href,
+                "seo_site_domain": site_domain,
+                "seo_warnings": warnings,
+            }
+        )
+        return ctx
+
+    def get_preview_template(self, request, mode_name):
+        if mode_name == "wagtail-seo":
+            return "wagtailseo/search-preview.html"
+        return super().get_preview_template(request, mode_name)
+
+    # -- SEO properties -------------------------------------------------------
+
     @property
     def seo_author(self) -> str:
         """
diff --git a/wagtailseo/templates/wagtailseo/search-preview.html b/wagtailseo/templates/wagtailseo/search-preview.html
new file mode 100644
index 0000000..6da1009
--- /dev/null
+++ b/wagtailseo/templates/wagtailseo/search-preview.html
@@ -0,0 +1,193 @@
+{% load wagtailimages_tags %}
+
+<!doctype html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <style>
+    * {
+      box-sizing: border-box;
+    }
+    body {
+      background-color: #f6f6f8;
+      font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,Roboto,"Helvetica Neue",Arial,sans-serif,Apple Color Emoji,"Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
+      margin: 0 auto;
+      padding: 0 20px 20px 20px;
+    }
+    h2 {
+      font-size: 20px;
+      margin: 20px 0;
+    }
+
+    /* Warnings and other UI */
+    .warning {
+      background-color: #f8d7da;
+      border: 1px solid #f1aeb5;
+      border-radius: 8px;
+      color: ##58151c;
+      padding: 10px;
+      margin: 20px 0;
+    }
+    .warning svg {
+      color: #dc3545;
+    }
+
+    /* Emulate a Google search result */
+    .seo-search {
+      background-color: #fff;
+      border-radius: 8px;
+      box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
+      font-family: "Roboto", "Arial", sans-serif;
+      max-width: 600px;
+      overflow: hidden;
+      padding: 15px;
+    }
+    .seo-favicon {
+      display: inline-block;
+    }
+    .seo-favicon img,
+    .seo-favicon svg {
+      border-radius: 100%;
+      border: 1px solid #ccc;
+      height: auto;
+      margin-right: 4px;
+      width: 30px;
+    }
+    .seo-favicon svg {
+      background-color: #e4eefe;
+      color: #3563ec;
+      padding: 6px;
+    }
+    .seo-site {
+      display: inline-block;
+      margin-bottom: 5px;
+    }
+    .seo-sitename {
+      color: #202124;
+      font-size: 14px;
+    }
+    .seo-url {
+      color: #4d5156;
+      font-size: 13px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    .seo-title {
+      color: #1a0dab;
+      font-size: 20px;
+      line-height: 26px;
+    }
+    .seo-desc {
+      color: #4d5156;
+      font-size: 14px;
+      line-height: 22px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      display: -webkit-box;
+      -webkit-line-clamp: 2;
+      -webkit-box-orient: vertical;
+    }
+
+    /* Emulate a Facebook/LinkedIn/Twitter card */
+    .seo-social {
+      border-radius: 8px;
+      backgorund-color: #fff;
+      box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
+      font-family: "Roboto", "Arial", sans-serif;
+      max-width: 600px;
+      overflow: hidden;
+    }
+    .seo-social-img {
+      aspect-ratio: 16/9;
+    }
+    .seo-social-img img {
+      object-fit: cover;
+      width: 100%;
+      height: 100%;
+    }
+    .seo-social-siteinfo {
+      padding: 15px;
+    }
+    .seo-social-url {
+      color: #999;
+      font-size: 13px;
+      text-transform: uppercase;
+    }
+    .seo-social-title {
+      font-size: 16px;
+      font-weight: bold;
+    }
+    .seo-social-desc {
+      color: #606770;
+      font-size: 14px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+  </style>
+</head>
+<body>
+  {% for w in seo_warnings %}
+  <div class="warning">
+    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-circle-fill" viewBox="0 0 16 16">
+      <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M8 4a.905.905 0 0 0-.9.995l.35 3.507a.552.552 0 0 0 1.1 0l.35-3.507A.905.905 0 0 0 8 4m.002 6a1 1 0 1 0 0 2 1 1 0 0 0 0-2"/>
+    </svg>
+    {{ w }}
+  </div>
+  {% endfor %}
+
+  <h2>Search Preview</h2>
+
+  <div class="seo-search">
+    <div class="seo-favicon">
+      {% if seo_favicon %}
+      <img src="{{ seo_favicon }}">
+      {% else %}
+      <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-globe-americas" viewBox="0 0 16 16">
+        <path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0M2.04 4.326c.325 1.329 2.532 2.54 3.717 3.19.48.263.793.434.743.484q-.121.12-.242.234c-.416.396-.787.749-.758 1.266.035.634.618.824 1.214 1.017.577.188 1.168.38 1.286.983.082.417-.075.988-.22 1.52-.215.782-.406 1.48.22 1.48 1.5-.5 3.798-3.186 4-5 .138-1.243-2-2-3.5-2.5-.478-.16-.755.081-.99.284-.172.15-.322.279-.51.216-.445-.148-2.5-2-1.5-2.5.78-.39.952-.171 1.227.182.078.099.163.208.273.318.609.304.662-.132.723-.633.039-.322.081-.671.277-.867.434-.434 1.265-.791 2.028-1.12.712-.306 1.365-.587 1.579-.88A7 7 0 1 1 2.04 4.327Z"/>
+      </svg>
+      {% endif %}
+    </div>
+    <div class="seo-site">
+      <div class="seo-sitename">
+        {{ page.seo_sitename }}
+      </div>
+      <div class="seo-url">
+        {{ page.seo_canonical_url }}
+      </div>
+    </div>
+    <div class="seo-title">
+      {{ page.seo_pagetitle }}
+    </div>
+    <div class="seo-desc">
+      {{ page.seo_description }}
+    </div>
+  </div>
+
+  <h2>Social Media Preview</h2>
+
+  <div class="seo-social">
+    {% with page.seo_image_url as url %}
+    {% if url %}
+    <div class="seo-social-img">
+      <img src="{{ url }}" alt="If image is not loading, check hostname and port in Settings > Sites.">
+    </div>
+    {% endif %}
+    {% endwith %}
+    <div class="seo-social-siteinfo">
+      <div class="seo-social-url">
+        {{ seo_site_domain }}
+      </div>
+      <div class="seo-social-title">
+        {{ page.seo_pagetitle }}
+      </div>
+      <div class="seo-social-desc">
+        {{ page.seo_description }}
+      </div>
+    </div>
+  </div>
+
+</body>
+</html>


Test patch:
diff --git a/testproject/home/tests.py b/testproject/home/tests.py
index 6f4867e..ad251cd 100644
--- a/testproject/home/tests.py
+++ b/testproject/home/tests.py
@@ -95,9 +95,7 @@ def setUpClass(cls):
         site.save()
 
         # Turn on all SEO settings.
-        cls.seo_set = SeoSettings.for_site(
-            cls.page_home.get_site()
-        )
+        cls.seo_set = SeoSettings.for_site(cls.page_home.get_site())
         cls.seo_set.og_meta = True
         cls.seo_set.twitter_meta = True
         cls.seo_set.struct_meta = True
@@ -344,6 +342,14 @@ def test_custom_sep(self):
             response.content.decode("utf8"),
         )
 
+    def test_preview(self):
+        """
+        Tests the wagtail page preview, in SEO mode.
+        """
+        page = self.page_fullseo
+        response = page.make_preview_request(preview_mode="wagtail-seo")
+        self.assertEqual(response.status_code, 200)
+
 
 class TestSettingMenu(WagtailTestUtils, TestCase):
     """