Skip to content
Merged
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
63 changes: 62 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ Image: https://www.example.com/article-image.jpg

#### Open Graph

Based on [Open Graph protocol](https://ogp.me), the SEO plugin implements required properties and some aditionnals ones:
Based on [Open Graph protocol](https://ogp.me), the SEO plugin implements required properties and some additional ones:

```
<meta property="og:site_name" content=":sitename:" />
Expand Down Expand Up @@ -305,6 +305,52 @@ If `Description` is not defined, a plain text version of `Summary` will be used
```
`:language:`: The site language as defined in `LOCALE` Pelican setting. If not filled, it will try to get the default system locale.

If the content is an article, Open Graph tags for article-specific properties are added:

```
<meta property="article:published_time" content=":publication_date:" />
```
`:publication_date:`: The article publication date, from the `date` metadata field.

```
<meta property="article:modified_time" content=":modification_date:" />
```
`:modification_date:`: The date from the `modified` metadata field, if it exists.

```
<meta property="article:section" content=":category:" />
```
`:category:`: The article category, as determined by Pelican (either from the file location or from the `category` metadata field).

```
<meta property="article:tag" content=":tag1:" />
<meta property="article:tag" content=":tag2:" />
...
<meta property="article:tag" content=":tagN:" />
```
`:tag1:`, `:tag2:`, ..., `:tagN:`: The article tags, from the `tags` metadata field.

```
<meta property="article:author" content=":author:" />
```
`:author:`: The article author, from the `fb_profile` or `author` metadata field.

The `article:author` should contain a link to the Facebook account of the article author. SEO Enhancer
will use the `fb_profile` metadata from the article to fill its value. If `fb_profile`
is not defined, SEO Enhancer will use the `SEO_ENHANCER_AUTHOR_FACEBOOK_PROFILES` dictionary
from the Pelican configuration file to map the `author` of the article to their Facebook account.
The dictionary should be defined as follows:

```python
SEO_ENHANCER_AUTHOR_FACEBOOK_PROFILES = {
"Author Name 1": "https://www.facebook.com/author1profile",
"Author Name 2": "https://www.facebook.com/author2profile",
}
```

If neither `fb_profile` metadata nor `SEO_ENHANCER_AUTHOR_FACEBOOK_PROFILES` mapping is defined, the `article:author` tag will not be added.


#### Twitter Cards

The Twitter Cards feature requires Open Graph feature to be functional. To avoid the duplication of similar tags, [Twitter falls back on some Open Graph tags](https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/getting-started#opengraph) if Twitter's are not present.
Expand All @@ -320,6 +366,21 @@ Based on [Twitter guide](https://developer.twitter.com/en/docs/twitter-for-websi
```
`:tw_account:`: The Twitter @account to link to the card. You can fill in the file metadata, but it's not a required property.

This tags should contain a link to Twitter/X account of the article author. SEO Enhancer
will use the `tw_account` metadata from the article to fill its value.
If `tw_account` is not defined, SEO Enhancer will use the `SEO_ENHANCER_AUTHOR_TWITTER_PROFILES` dictionary
from the Pelican configuration file to map the `author` of the article to its Twitter/X account.
The dictionary should be defined as follows:

```python
SEO_ENHANCER_AUTHOR_TWITTER_PROFILES = {
"Author Name 1": "https://x.com/author1profile",
"Author Name 2": "https://x.com/author2profile",
}
```

If neither `tw_account` metadata nor `SEO_ENHANCER_AUTHOR_TWITTER_PROFILES` mapping is defined, the `twitter:site` tag will not be added.

The other properties required by Twitter are created thanks to Open Graph feature.

## Contributing
Expand Down
17 changes: 17 additions & 0 deletions pelican/plugins/seo/seo_enhancer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ def launch_html_enhancer(
if open_graph:
html_enhancements["open_graph"] = html_enhancer.open_graph.create_tags()

if open_graph and hasattr(html_enhancer, "open_graph_article"):
html_enhancements["open_graph_article"] = (
html_enhancer.open_graph_article.create_tags()
)

if twitter_cards:
html_enhancements["twitter_cards"] = (
html_enhancer.twitter_cards.create_tags()
Expand Down Expand Up @@ -125,6 +130,18 @@ def add_html_to_file(self, enhancements, path):
for og_property, og_content in enhancements["open_graph"].items():
self._add_meta_tag(soup, "property", "og", og_property, og_content)

if "open_graph_article" in enhancements:
for og_property, og_content in enhancements["open_graph_article"].items():
if isinstance(og_content, str):
self._add_meta_tag(
soup, "property", "article", og_property, og_content
)
elif isinstance(og_content, list):
for element in og_content:
self._add_meta_tag(
soup, "property", "article", og_property, element
)

with open(path, "w", encoding="utf8") as html_file:
html_file.write(str(soup))

Expand Down
22 changes: 20 additions & 2 deletions pelican/plugins/seo/seo_enhancer/html_enhancer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from .article_schema_creator import ArticleSchemaCreator
from .breadcrumb_schema_creator import BreadcrumbSchemaCreator
from .canonical_url_creator import CanonicalURLCreator
from .open_graph import OpenGraph
from .open_graph import OpenGraph, OpenGraphArticle
from .twitter_cards import TwitterCards


Expand Down Expand Up @@ -88,7 +88,25 @@ def __init__(self, file, output_path, path, open_graph=False, twitter_cards=Fals
locale=_settings.get("LOCALE"),
)

if isinstance(file, Article):
_modified = getattr(file, "modified", None)
_author_profiles = _settings.get(
"SEO_ENHANCER_AUTHOR_FACEBOOK_PROFILES", {}
)
_author_profile = _author_profiles.get(_author.name, None)
self.open_graph_article = OpenGraphArticle(
date=_date,
modified=_modified,
category=_category.name,
tags=getattr(file, "tags", []),
author=_metadata.get("fb_profile") or _author_profile,
)

if twitter_cards:
_author_profiles = _settings.get(
"SEO_ENHANCER_AUTHOR_TWITTER_PROFILES", {}
)
_author_profile = _author_profiles.get(_author.name, None)
self.twitter_cards = TwitterCards(
tw_account=_metadata.get("tw_account"),
tw_account=_metadata.get("tw_account") or _author_profile,
)
33 changes: 33 additions & 0 deletions pelican/plugins/seo/seo_enhancer/html_enhancer/open_graph.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import locale
from urllib import parse

from pelican.utils import strftime


class OpenGraph:
"""
Expand Down Expand Up @@ -78,3 +80,34 @@ def create_tags(self) -> dict:
open_graph_tags["locale"] = locale

return open_graph_tags


class OpenGraphArticle:
"""
Get all Open Graph elements for an Article according to
https://ogp.me/?#no_vertical.
"""

def __init__(self, date, modified, category, tags, author) -> None:
self.date = date
self.modified = modified
self.category = category
self.tags = tags
self.author = author

def create_tags(self) -> dict:
"""
Compute all Open Graph elements for Article and return them in a ready-to-use
dict.
"""
tags = {}
tags["published_time"] = strftime(self.date, "%Y-%m-%d")
if self.modified is not None:
tags["modified_time"] = strftime(self.modified, "%Y-%m-%d")
if self.category is not None:
tags["section"] = self.category
if self.tags is not None and self.tags:
tags["tags"] = self.tags
if self.author is not None:
tags["author"] = self.author
return tags
61 changes: 61 additions & 0 deletions pelican/plugins/seo/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"""Mocks Pelican objects required for the units tests."""

from datetime import datetime

import pytest

from pelican.contents import Article
from pelican.settings import DEFAULT_CONFIG

from seo.seo_enhancer import SEOEnhancer
from seo.seo_report import SEOReport

Expand Down Expand Up @@ -57,6 +62,60 @@ def __init__(self, name):
self.name = name


@pytest.fixture()
def pelican_article():
"""Create a Pelican article."""

settings = DEFAULT_CONFIG.copy()
settings.update(
{
"SITEURL": "https://www.fakesite.com",
"SITENAME": "Fake Site Name",
"LOGO": "https://www.fakesite.com/fake-logo.jpg",
"LOCALE": ["fr_FR"],
}
)

metadata = {
"noindex": True,
"disallow": True,
"image": "https://www.fakesite.com/fake-image.jpg",
"og_title": "OG Title",
"og_description": "OG Description",
"og_image": "https://www.fakesite.com/og-image.jpg",
"tw_account": "@TestTWCards",
"summary": "Fake summary",
"modified": datetime(2019, 7, 3, 23, 49),
"tags": ["Fake tag 1", "Fake tag 2"],
"title": "Fake Title",
"description": "Fake description",
"url": "fake-title.html",
"date": datetime(2019, 4, 3, 23, 49),
"author": FakeAuthor(name="Fake author"),
"category": FakeCategory(name="Fake category"),
}

content = """<html>
<head>
<title>Fake Title</title>
<meta name='description' content='Fake description' />
</head>
<body>
<h1>Fake content title</h1>
<p>Fake content 🙃</p>
<a href='https://www.fakesite.com'>Fake internal link</a>
<p>Fake content with <code>inline code</code></p>
<p>Fake content with "<a href="https://www.fakesite.com">Fake inline internal link</a>"</p>
</body>
</html>"""

return Article(
content=content,
metadata=metadata,
settings=settings,
)


@pytest.fixture()
def fake_article():
"""Create a fake article."""
Expand All @@ -76,6 +135,8 @@ def fake_article():
"og_image": "https://www.fakesite.com/og-image.jpg",
"tw_account": "@TestTWCards",
"summary": "Fake summary",
"modified": FakeDate("2019", "07", "03", "23", "49"),
"tags": ["Fake tag 1", "Fake tag 2"],
}
title = "Fake Title"
description = "Fake description"
Expand Down
34 changes: 33 additions & 1 deletion pelican/plugins/seo/tests/test_open_graph.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Units tests for OpenGraph."""

import locale
from datetime import datetime

import pytest

from seo.seo_enhancer.html_enhancer import OpenGraph
from seo.seo_enhancer.html_enhancer import OpenGraph, OpenGraphArticle


class TestOpenGraph:
Expand Down Expand Up @@ -109,6 +110,37 @@ def test_create_tags(self, fake_article):
assert og_tags["image"] == "https://www.fakesite.com/og-image.jpg"
assert og_tags["locale"] == "fr_FR"

def test_create_article_tags(self, fake_article):
"""
Test that create_tags() returns all OG tags for an Article
if all elements are filled.
"""

def fake_date_to_date(fake_date):
return datetime(
fake_date.year,
fake_date.month,
fake_date.day,
fake_date.hour,
fake_date.minute,
)

og = OpenGraphArticle(
date=fake_date_to_date(fake_article.date),
modified=fake_date_to_date(fake_article.metadata["modified"]),
category=fake_article.category.name,
tags=fake_article.metadata["tags"],
author=fake_article.author.name,
)

og_tags = og.create_tags()

assert og_tags["published_time"] == "2019-04-03"
assert og_tags["modified_time"] == "2019-07-03"
assert og_tags["section"] == "Fake category"
assert og_tags["tags"] == ["Fake tag 1", "Fake tag 2"]
assert og_tags["author"] == "Fake author"

def test_create_tags_missing_elements(self, fake_article_missing_elements):
"""
Test that create_tags() without specific elements
Expand Down
Loading