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
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- Add technology badges to home page and experiences
- Add sub-projects to experiences
- Add a footer note with a link to the GitHub repository
- Add SEO improvements
- Add MIT license
- Bump python and javascript dependencies

Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ dependencies = [
"django-modeltranslation>=0.19.12,<1.0",
"django-solo>=2.4.0,<3.0",
"markdown>=3.7,<4.0",
"beautifulsoup4>=4.13.3,<5.0",
"django-stubs-ext>=5.2.7",
]

[dependency-groups]
Expand All @@ -24,7 +26,6 @@ dev = [
"django-stubs[compatible-mypy]>=5.1.3,<6.0",
"djlint>=1.36.4,<2.0",
"types-markdown>=3.7.0.20241204,<4.0",
"beautifulsoup4>=4.13.3,<5.0",
"types-beautifulsoup4>=4.12.0.20250204,<5.0",
]
prod = ["gunicorn>=23.0.0,<24.0", "psycopg2-binary>=2.9.10,<3.0"]
Expand Down
46 changes: 35 additions & 11 deletions src/base/templates/cotton/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,59 @@
{% load cooco %}

{% get_cooco_manager request as cooco_manager %}
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}

<!DOCTYPE html>
<html lang="{{ CURRENT_LANGUAGE }}" data-theme="portfolio">
<html lang="{{ LANGUAGE_CODE }}" data-theme="portfolio">
<head>
<c-google-analytics />

<meta charset="UTF-8">
<meta name="keywords" content="Portfolio, CV, Biography, Career">
<meta name="keywords"
content="{% translate "portfolio, CV, biography, career" %}{% if page_metadata.page_keywords %}, {{ page_metadata.page_keywords }}{% endif %}">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

<title>{{ name }}</title>
<title>{{ page_metadata.page_title }}</title>
<link rel="shortcut icon" href="/media/favicon.ico" type="image/x-icon">

<!-- Canonical URL -->
<link rel="canonical"
href="{{ request.scheme }}://{{ request.get_host }}{{ request.path }}" />

<!-- Hreflang tags for multilanguage support -->
{% for lang_code, lang_name in LANGUAGES %}
{% language lang_code %}
<link rel="alternate"
hreflang="{{ lang_code }}"
href="{{ request.scheme }}://{{ request.get_host }}{% url request.resolver_match.url_name %}" />
{% endlanguage %}
{% endfor %}
{% language 'en' %}
<link rel="alternate"
hreflang="x-default"
href="{{ request.scheme }}://{{ request.get_host }}{% url request.resolver_match.url_name %}" />
{% endlanguage %}

<!-- Meta description -->
<meta name="description" content="{{ page_metadata.page_description }}">

<!-- Open Graph Cards -->
<meta name="description"
content="{% translate "Personal web of" %} {{ name }}">
<meta property="og:title" content="{{ name }}" />
<meta property="og:title" content="{{ page_metadata.page_title }}" />
<meta property="og:description"
content="{% translate "Personal web of" %} {{ name }}" />
content="{{ page_metadata.page_description }}" />
<meta property="og:image"
itemprop="image"
content="{{ request.scheme }}://{{ request.get_host }}/media/background_preview.jpg">
<meta property="og:url"
content="{{ request.scheme }}://{{ request.get_host }}" />
content="{{ request.scheme }}://{{ request.get_host }}{{ request.path }}" />
<meta property="og:type" content="profile" />

<!-- Twitter Cards -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{ name }}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ page_metadata.page_title }}" />
<meta name="twitter:description"
content="{% translate "Personal web of" %} {{ name }}" />
content="{{ page_metadata.page_description }}" />
<meta name="twitter:image"
content="{{ request.scheme }}://{{ request.get_host }}/media/background_preview.jpg" />

Expand All @@ -48,6 +71,7 @@
document.body.classList.remove('js-loading');
});
</script>
<script type="application/ld+json">{{ page_metadata.json_ld }}</script>

{{ extra_head }}
</head>
Expand Down
6 changes: 6 additions & 0 deletions src/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,14 @@
from pathlib import Path

import dj_database_url
import django_stubs_ext
import environ # type: ignore[import-untyped]
from django.utils.translation import gettext_lazy as _

# Monkeypatching Django, so stubs will work for all generics,
# see: https://github.com/typeddjango/django-stubs
django_stubs_ext.monkeypatch()

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent

Expand Down Expand Up @@ -51,6 +56,7 @@
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sitemaps",
"solo",
"modeltranslation",
"django_cotton",
Expand Down
31 changes: 31 additions & 0 deletions src/core/sitemaps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Sitemap configuration for the portfolio website."""

from __future__ import annotations

from typing import TYPE_CHECKING

from django.contrib.sitemaps import Sitemap
from django.urls import reverse

if TYPE_CHECKING:
from typing import Iterable


class StaticViewSitemap(Sitemap[str]):
"""Sitemap for static pages with multilanguage support."""

priority = 1.0
changefreq = "monthly"
protocol = "https"
i18n = True

def items(self) -> Iterable[str]:
"""Return list of URL names to include in sitemap."""
return (
"home",
"my-career",
)

def location(self, item: str) -> str:
"""Return the URL path for the given item."""
return reverse(item)
Empty file added src/core/tests/__init__.py
Empty file.
96 changes: 96 additions & 0 deletions src/core/tests/test_seo_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Tests for SEO-related views (robots.txt, sitemap.xml)."""

from __future__ import annotations

from xml.etree import ElementTree as ET

from django.test import TestCase

from home.models import PersonalInfo


class TestRobotsTxt(TestCase):
def test_robots_txt_accessible(self) -> None:
"""Test that robots.txt is accessible and returns correct content type."""
response = self.client.get("/robots.txt")
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/plain")

def test_robots_txt_content(self) -> None:
"""Test that robots.txt contains expected directives."""
response = self.client.get("/robots.txt")
content = response.content.decode("utf-8")

# Check for standard directives
self.assertIn("User-agent: *", content)
self.assertIn("Allow: /", content)

# Check for sitemap reference
self.assertIn("Sitemap: http://testserver/sitemap.xml", content)


class TestSitemapXml(TestCase):
@classmethod
def setUpTestData(cls) -> None:
# Create minimal data needed for sitemap
PersonalInfo.objects.create(
name="Test User",
title="Test Developer",
introduction="Test intro",
biography="Test bio",
)

def test_sitemap_accessible(self) -> None:
"""Test that sitemap.xml is accessible."""
response = self.client.get("/sitemap.xml")
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/xml")

def test_sitemap_is_valid_xml(self) -> None:
"""Test that sitemap.xml is valid XML."""
response = self.client.get("/sitemap.xml")
try:
ET.fromstring(response.content)
except ET.ParseError as e:
self.fail(f"Sitemap is not valid XML: {e}")

def test_sitemap_contains_expected_urls(self) -> None:
"""Test that sitemap contains all expected URLs."""
response = self.client.get("/sitemap.xml")
root = ET.fromstring(response.content)

# Get namespace
namespace = {"ns": "http://www.sitemaps.org/schemas/sitemap/0.9"}

# Get all location URLs
locations = [loc.text for loc in root.findall(".//ns:loc", namespace)]

# Check that we have entries for both languages
self.assertEqual(len(locations), 4, "Sitemap should contain 4 URLs")
self.assertIn("https://testserver/en/", locations)
self.assertIn("https://testserver/es/", locations)
self.assertIn("https://testserver/en/my-career/", locations)
self.assertIn("https://testserver/es/my-career/", locations)

def test_sitemap_structure(self) -> None:
"""Test that sitemap has correct structure."""
response = self.client.get("/sitemap.xml")
root = ET.fromstring(response.content)

namespace = {"ns": "http://www.sitemaps.org/schemas/sitemap/0.9"}

# Check for urlset root element
self.assertEqual(root.tag, f"{{{namespace['ns']}}}urlset")

# Check that each url has required elements
for url in root.findall("ns:url", namespace):
loc = url.find("ns:loc", namespace)
assert loc is not None, "URL entry missing <loc> element"
assert loc.text is not None, "<loc> element has no text"
self.assertTrue(loc.text.startswith("https://"), "URL does not start with https://")

changefreq = url.find("ns:changefreq", namespace)
self.assertIsNotNone(changefreq, "URL entry missing <changefreq> element")

priority = url.find("ns:priority", namespace)
self.assertIsNotNone(priority, "URL entry missing <priority> element")
9 changes: 9 additions & 0 deletions src/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,25 @@
from django.conf.urls.i18n import i18n_patterns
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.sitemaps.views import sitemap
from django.urls import include, path

from core import settings
from core.sitemaps import StaticViewSitemap
from core.views import RobotsTxtView

sitemaps = {
"static": StaticViewSitemap,
}

urlpatterns = (
*i18n_patterns(
path("admin/", admin.site.urls),
path("", include("home.urls")),
path("i18n/", include("django.conf.urls.i18n")),
),
path("robots.txt", RobotsTxtView.as_view(), name="robots_txt"),
path("sitemap.xml", sitemap, {"sitemaps": sitemaps}, name="django.contrib.sitemaps.views.sitemap"),
path("cookie-consent/", include("django_cooco.urls")),
)

Expand Down
30 changes: 30 additions & 0 deletions src/core/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from django.http import HttpResponse
from django.views import View

if TYPE_CHECKING:
from django.http import HttpRequest


class RobotsTxtView(View):
"""Serve robots.txt file dynamically."""

def get(self, request: HttpRequest) -> HttpResponse:
"""Return robots.txt content.

Args:
request: The HTTP request object.

Returns:
An HttpResponse containing the robots.txt content.
"""
lines = [
"User-agent: *",
"Allow: /",
"",
f"Sitemap: {request.scheme}://{request.get_host()}/sitemap.xml",
]
return HttpResponse("\n".join(lines), content_type="text/plain")
28 changes: 28 additions & 0 deletions src/home/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,37 @@ class PersonalInfo(SingletonModel): # type: ignore[django-manager-missing] # ht
biography = models.TextField()
technologies = models.ManyToManyField(Technology, blank=True, related_name="personal_info")

@cached_property
def technology_names(self) -> tuple[str, ...]:
"""Return a tuple of technology names associated with this personal info."""
return tuple(tech.name for tech in self.technologies.all())

def __str__(self) -> str:
"""Return the string representation of the PersonalInfo."""
return self.name

def get_page_title(self) -> str:
"""Return the page title for SEO purposes."""
return f"{self.name} | {self.title}"

def get_page_description(self) -> str:
"""Return the page description for SEO purposes."""
description = gettext("Personal web of %(name)s. %(title)s") % {
"name": self.name,
"title": self.title,
}

if self.technologies.exists():
description += " " + gettext("specialized in %(tech)s") % {
"tech": ", ".join(self.technology_names[:3]),
}

return description

def get_page_keywords(self) -> str:
"""Return the page keywords for SEO purposes."""
return ", ".join(tech.lower() for tech in self.technology_names)


class DatedModel(models.Model):
start_date = models.DateField()
Expand Down
3 changes: 2 additions & 1 deletion src/home/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
<figure class="lg:rounded-xl lg:h-full">
<img class="h-full w-full object-cover"
src="/media/background.jpg"
alt="{{ personal_info.name }}" />
alt="{% translate "Professional profile of" %} {{ personal_info.name }} - {{ personal_info.title }}"
loading="eager" />
</figure>
<div class="card-body lg:absolute lg:right-1/16 lg:bottom-1/8 lg:bg-base-300/70 lg:rounded-xl">
<h1 id="personal-info-name"
Expand Down
Loading