diff --git a/makeabilitylab/settings.py b/makeabilitylab/settings.py
index f5e50f96..aaaa86c0 100644
--- a/makeabilitylab/settings.py
+++ b/makeabilitylab/settings.py
@@ -267,6 +267,7 @@
'django.contrib.messages.context_processors.messages',
'website.context_processors.recent_news',
'website.context_processors.admin_version_info',
+ 'website.context_processors.site_scheme',
],
},
},
diff --git a/website/admin/person_admin.py b/website/admin/person_admin.py
index 2c00409e..ebd432f7 100644
--- a/website/admin/person_admin.py
+++ b/website/admin/person_admin.py
@@ -142,7 +142,7 @@ class PersonAdmin(ImageCroppingMixin, admin.ModelAdmin):
fieldsets = [
(None, {'fields': ['first_name', 'middle_name', 'last_name', 'image', 'cropping', 'easter_egg', 'easter_egg_crop', 'easter_egg_starwars_choice']}),
('Bio', {'fields': ['bio', 'personal_website', 'github']}),
- ('Socials', {'fields': ['twitter', 'threads', 'mastodon', 'linkedin']}),
+ ('Socials', {'fields': ['twitter', 'bluesky', 'threads', 'mastodon', 'linkedin', 'google_scholar', 'orcid']}),
('For Alumni (Next Position)', {'fields': ['next_position', 'next_position_url']}),
]
diff --git a/website/context_processors.py b/website/context_processors.py
index 64a26216..b2290390 100644
--- a/website/context_processors.py
+++ b/website/context_processors.py
@@ -17,6 +17,31 @@ def recent_news(request):
return { 'recent_news': news_items, }
+def site_scheme(request):
+ """
+ Expose the canonical URL scheme (``http`` / ``https``) to every template.
+
+ The site runs behind UW CSE's Apache TLS-terminating proxy, which talks to
+ the Django container over plain HTTP. Because ``SECURE_PROXY_SSL_HEADER`` is
+ not (yet) configured, ``request.scheme`` reports ``http`` in production/test
+ even though visitors arrive over HTTPS. Building absolute URLs (canonical,
+ Open Graph ``og:url``/``og:image``, Twitter Card images) from
+ ``request.scheme`` therefore advertises ``http://`` links to crawlers and
+ social scrapers — the root cause of issue #1236.
+
+ This processor pins the scheme to ``https`` whenever the site is not in
+ DEBUG (i.e. on the test/prod servers), while leaving local dev on whatever
+ ``request.scheme`` reports (``http`` over localhost). Templates should build
+ absolute URLs as ``{{ site_scheme }}://{{ request.get_host }}{{ path }}``.
+
+ NOTE: This is the in-repo workaround. The cleaner long-term fix is for IT to
+ set ``SECURE_PROXY_SSL_HEADER`` on the proxy (tracked in #1329); once that
+ lands, ``request.scheme`` will be correct and this can fall back to it.
+ """
+ return {
+ 'site_scheme': request.scheme if settings.DEBUG else 'https',
+ }
+
def admin_version_info(request):
"""
Make version and debug info available to all templates.
diff --git a/website/models/person.py b/website/models/person.py
index 5ef6cec7..f671117c 100644
--- a/website/models/person.py
+++ b/website/models/person.py
@@ -138,7 +138,11 @@ def get_director(cls):
threads = models.URLField(blank=True, null=True)
mastodon = models.URLField(blank=True, null=True)
linkedin = models.URLField(blank=True, null=True)
-
+ orcid = models.URLField(blank=True, null=True)
+ orcid.help_text = 'Full ORCID profile URL. For example, https://orcid.org/0000-0001-8291-3353'
+ google_scholar = models.URLField(blank=True, null=True)
+ google_scholar.help_text = 'Full Google Scholar profile URL. For example, https://scholar.google.com/citations?user=nExKrpsAAAAJ&hl=en&oi=ao'
+
# If a bio is not added, the member view page will auto-generate one
bio = models.TextField(blank=True, null=True)
bio.help_text = "You can use HTML markup here. If a bio is not added, the member view page will auto-generate one."
@@ -172,8 +176,11 @@ def get_director(cls):
but you can set it to anything you want and crop it appropriately here")
def has_website_links(self):
- """Returns True if person has a personal website, github, or twitter, etc. False otherwise."""
- return self.personal_website or self.github or self.twitter
+ """Returns True if person has any external profile/website link set
+ (personal site, github, twitter, ORCID, Google Scholar, etc.)."""
+ return (self.personal_website or self.github or self.twitter
+ or self.bluesky or self.mastodon or self.threads
+ or self.linkedin or self.orcid or self.google_scholar)
@cached_property
def is_graduated_phd_student(self):
diff --git a/website/templates/website/base.html b/website/templates/website/base.html
index 97a51737..e532c686 100644
--- a/website/templates/website/base.html
+++ b/website/templates/website/base.html
@@ -56,17 +56,54 @@
-
-
- {% block opengraph %}
-
-
-
-
-
- {% endblock %}
+ {% comment %}
+ ============================================================================
+ PAGE METADATA — single source of truth for SEO + social sharing.
+
+ Per-page values come from the optional `page_meta` context dict set by the
+ view (keys: title, description, canonical_path, og_type). Anything not
+ provided falls back to the lab-wide defaults below, so every page gets a
+ complete, valid set of tags. The meta description is computed once and reused
+ by , og:description, and twitter:description (no
+ duplication).
+
+ Absolute URLs use {{ site_scheme }} (https on the servers, http in local dev)
+ so they don't advertise http:// behind the TLS proxy — see #1236.
+
+ Detail templates customize via:
+ {% block social_image %} — og:image + twitter:image (default: lab logo)
+ {% block meta_extra %} — structural OG tags (og:profile:*, og:article:*)
+ {% block jsonld %} — schema.org JSON-LD structured data
+ ============================================================================
+ {% endcomment %}
+ {% with meta_title=page_meta.title|default:"Makeability Lab" og_type=page_meta.og_type|default:"website" canonical_path=page_meta.canonical_path|default:request.path %}
+ {% firstof page_meta.description "The Makeability Lab is an advanced research lab in Human-Computer Interaction and AI directed by Professor Jon E. Froehlich at University of Washington's Allen School of Computer Science." as meta_description %}
+
+
+
+
+
+
+
+
+
+
+ {% block social_image %}
+
+
+
+ {% endblock social_image %}
+ {% block meta_extra %}{% endblock %}
+
+
+
+
+
+
+ {% block jsonld %}{% if jsonld %}{% endif %}{% endblock %}
+ {% endwith %}
diff --git a/website/templates/website/member.html b/website/templates/website/member.html
index 68637a85..bc06eb94 100644
--- a/website/templates/website/member.html
+++ b/website/templates/website/member.html
@@ -50,22 +50,23 @@
{% load thumbnail %}
{% load ml_tags %}
-{% block opengraph %}
-
-
-{% if person.bio %}
-
-{% elif auto_generated_bio %}
-
+{# Title, description, og:type=profile, and canonical come from page_meta (see
+ website/views/member.py). Here we override the share image and add the
+ profile-specific structural OG tags. #}
+{% block social_image %}
+{% thumbnail person.image '1200x630' box=person.cropping crop=True upscale=True as og_img %}
+{% if og_img %}
+
+
+
+
+
{% else %}
- {% if position %}
-
- {% else %}
-
- {% endif %}
+{{ block.super }}
{% endif %}
-
-
+{% endblock social_image %}
+
+{% block meta_extra %}
{% endblock %}
@@ -196,13 +197,29 @@
{{ person.get_full_name }}
{% endif %}
{% if person.linkedin %}
- LinkedIn
{% endif %}
+ {% if person.google_scholar %}
+
+
+ Google Scholar
+
+ {% endif %}
+ {% if person.orcid %}
+
+
+ ORCID
+
+ {% endif %}
{% endif %}
diff --git a/website/templates/website/news_item.html b/website/templates/website/news_item.html
index b1e66209..17173c7d 100644
--- a/website/templates/website/news_item.html
+++ b/website/templates/website/news_item.html
@@ -42,20 +42,25 @@
{% load thumbnail %}
{% load ml_tags %}
-{% block opengraph %}
-
-
-{% if news_item.content %}
-
+{# Title, description, og:type=article, and canonical come from page_meta (see
+ website/views/news_item.py). Here we override the share image and add the
+ article-specific structural OG tags. #}
+{% block social_image %}
+{% if news_item.image %}{% thumbnail news_item.image '1200x630' box=news_item.cropping crop=True upscale=True as og_img %}{% endif %}
+{% if og_img %}
+
+
+
+
+
{% else %}
-
+
+
+
{% endif %}
-{% if news_item.image %}
-
-{% else %}
-
-{% endif %}
-
+{% endblock social_image %}
+
+{% block meta_extra %}
{% if news_item.author %}
diff --git a/website/templates/website/project.html b/website/templates/website/project.html
index 47d961b3..9c3c7099 100644
--- a/website/templates/website/project.html
+++ b/website/templates/website/project.html
@@ -65,17 +65,20 @@
{% load thumbnail %}
{% load ml_tags %}
-{% block opengraph %}
-
-
-{% if project.summary %}
-
+{# Title, description, og:type, and canonical come from page_meta (see
+ website/views/project.py). Here we only override the social share image. #}
+{% block social_image %}
+{% thumbnail project.gallery_image 1200x630 box=project.cropping crop=True upscale=True as og_img %}
+{% if og_img %}
+
+
+
+
+
{% else %}
-
+{{ block.super }}
{% endif %}
-
-
-{% endblock %}
+{% endblock social_image %}
{% block stylesheets %}
diff --git a/website/tests/test_page_metadata.py b/website/tests/test_page_metadata.py
new file mode 100644
index 00000000..62a7565c
--- /dev/null
+++ b/website/tests/test_page_metadata.py
@@ -0,0 +1,213 @@
+"""
+Regression tests for per-page SEO / social-sharing metadata
+(issues #1142, #1236, #1324).
+
+These exercise the URL -> view -> template stack so a regression in the
+centralized metadata block in ``base.html`` (or the ``page_meta`` a view feeds
+it) is caught. They pin three things:
+
+ * canonical + Open Graph + Twitter Card tags are present on every page type;
+ * absolute URLs use ``https`` behind the proxy (the #1236 fix), driven by the
+ ``site_scheme`` context processor — verified by toggling ``DEBUG``;
+ * detail pages emit distinct, per-page titles/descriptions/types rather than
+ the single generic site description.
+
+See website/templates/website/base.html, website/context_processors.py, and
+website/utils/metadata.py.
+"""
+
+import json
+import re
+from datetime import date
+
+from django.test import override_settings
+from django.urls import reverse
+
+from website.tests.base import DatabaseTestCase
+
+
+def _extract_jsonld(test, resp):
+ """Pull the JSON-LD block out of a response and parse it (fails the test if
+ it's missing or not valid JSON)."""
+ test.assertEqual(resp.status_code, 200)
+ m = re.search(r'',
+ resp.content.decode(), re.DOTALL)
+ test.assertIsNotNone(m, "expected a JSON-LD must not break out of the ld+json tag."""
+ item = self.make_news_item(title="Pwn x", content="x")
+ data, block = _extract_jsonld(self, self.client.get(
+ reverse("website:news_item_by_id", kwargs={"id": item.id})))
+ self.assertNotIn("", block) # escaped, not literal
+ self.assertIn("\\u003c", block)
+ self.assertEqual(data["headline"], "Pwn x") # round-trips
+
+
+class PageMetadataSchemeTests(DatabaseTestCase):
+
+ @override_settings(DEBUG=True)
+ def test_local_dev_uses_request_scheme(self):
+ """In DEBUG (local dev over http) the absolute URLs follow request.scheme."""
+ resp = self.client.get(reverse("website:index"))
+ self.assertContains(resp, '')
+ self.assertContains(resp, '')
diff --git a/website/tests/test_views.py b/website/tests/test_views.py
index ec007ba8..b924fe2f 100644
--- a/website/tests/test_views.py
+++ b/website/tests/test_views.py
@@ -165,3 +165,49 @@ def test_query_count_does_not_grow_with_pub_count(self):
"with 20 publications — prefetch_related likely regressed"
),
)
+
+
+# --- Member ORCID / Google Scholar profile links (#1324) -----------------
+
+
+class MemberSocialLinkTests(DatabaseTestCase):
+ """
+ Pins the ORCID + Google Scholar fields added to Person: that
+ has_website_links() recognizes them and that the member page renders the
+ links (academicons icons) when set.
+ """
+
+ def _give_position(self, person):
+ from datetime import date
+ from website.models import Position
+ from website.models.position import Title
+ Position.objects.create(person=person, start_date=date(2020, 1, 1),
+ title=Title.PHD_STUDENT)
+
+ def test_has_website_links_true_with_only_scholar_or_orcid(self):
+ p = self.make_person(first_name="Onlyorcid", last_name="Person",
+ orcid="https://orcid.org/0000-0002-1853-9710")
+ self.assertTrue(p.has_website_links())
+ p2 = self.make_person(first_name="Onlyscholar", last_name="Person",
+ google_scholar="https://scholar.google.com/citations?user=lFn1Oz0AAAAJ")
+ self.assertTrue(p2.has_website_links())
+
+ def test_has_website_links_false_with_no_links(self):
+ p = self.make_person(first_name="Nolinks", last_name="Person")
+ self.assertFalse(p.has_website_links())
+
+ def test_member_page_renders_orcid_and_scholar(self):
+ person = self.make_person(
+ first_name="Linked", last_name="Person",
+ orcid="https://orcid.org/0000-0002-1853-9710",
+ google_scholar="https://scholar.google.com/citations?user=lFn1Oz0AAAAJ",
+ )
+ self._give_position(person)
+ resp = self.client.get(
+ reverse("website:member_by_name", kwargs={"member_name": person.url_name})
+ )
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, 'href="https://orcid.org/0000-0002-1853-9710"')
+ self.assertContains(resp, 'ai ai-orcid')
+ self.assertContains(resp, "scholar.google.com/citations?user=lFn1Oz0AAAAJ")
+ self.assertContains(resp, 'ai ai-google-scholar')
diff --git a/website/utils/metadata.py b/website/utils/metadata.py
new file mode 100644
index 00000000..a7052c83
--- /dev/null
+++ b/website/utils/metadata.py
@@ -0,0 +1,83 @@
+"""
+Helpers for building per-page SEO / social-sharing metadata.
+
+These are used by views to populate the optional ``page_meta`` context dict
+consumed by ``base.html`` (meta description, Open Graph, Twitter Card, and
+canonical tags). Keeping the truncation/stripping logic here means each view
+sets a clean string once and the template stays presentation-only.
+
+See ``website/templates/website/base.html`` for how ``page_meta`` is rendered.
+"""
+
+import json
+
+from django.conf import settings
+from django.utils.html import strip_tags
+from django.utils.safestring import mark_safe
+from django.utils.text import Truncator
+
+# Recommended upper bound for a meta description; Google typically renders
+# ~150-160 chars in a snippet. og:description can run longer but we keep one
+# value for both for simplicity and consistency.
+META_DESCRIPTION_MAX_CHARS = 160
+
+
+def meta_description(html, max_chars=META_DESCRIPTION_MAX_CHARS):
+ """
+ Turn (possibly HTML) body text into a clean, length-bounded meta description.
+
+ Strips tags, collapses surrounding whitespace, and truncates on a word
+ boundary with an ellipsis. Returns ``None`` for empty/whitespace-only input
+ so callers can fall back to a page-type default (``base.html`` substitutes
+ the lab-wide description when ``page_meta.description`` is falsy).
+
+ Example:
+ >>> meta_description("
Hello world
")
+ 'Hello world'
+ """
+ if not html:
+ return None
+ text = " ".join(strip_tags(html).split())
+ if not text:
+ return None
+ return Truncator(text).chars(max_chars)
+
+
+def site_scheme(request):
+ """
+ The canonical scheme for absolute URLs built in views (https on the servers,
+ request.scheme in local dev). Mirrors the ``site_scheme`` context processor
+ (website/context_processors.py) so view-built JSON-LD URLs match the
+ template-built canonical/OG URLs. See #1236 (and #1329 for the IT-side
+ SECURE_PROXY_SSL_HEADER follow-up that would let this fall back to
+ request.scheme).
+ """
+ return request.scheme if settings.DEBUG else 'https'
+
+
+def absolute_url(request, path):
+ """Build an absolute, scheme-correct URL from a root-relative path."""
+ if not path:
+ return None
+ return f"{site_scheme(request)}://{request.get_host()}{path}"
+
+
+def render_jsonld(data):
+ """
+ Serialize a dict (or list of dicts) to a JSON string safe to embed inside a
+ ```` (e.g. in a bio) could otherwise break out of the
+ script element. We escape those three characters as ``\\uXXXX`` — still valid
+ JSON, but inert in HTML — then mark the result safe so the template emits it
+ verbatim. Returns ``None`` for falsy input so callers/templates can skip the
+ tag entirely.
+ """
+ if not data:
+ return None
+ json_str = json.dumps(data, ensure_ascii=False)
+ json_str = (json_str.replace('<', '\\u003c')
+ .replace('>', '\\u003e')
+ .replace('&', '\\u0026'))
+ return mark_safe(json_str)
diff --git a/website/views/awards.py b/website/views/awards.py
index f68ea55b..af42173b 100644
--- a/website/views/awards.py
+++ b/website/views/awards.py
@@ -52,6 +52,14 @@ def awards(request):
'navbar_white': True,
}
+ # Per-page SEO / social metadata (see base.html). #1142/#1324.
+ context['page_meta'] = {
+ 'title': 'Awards',
+ 'description': "Awards and honors earned by Makeability Lab members and "
+ "projects, including best paper awards in HCI and "
+ "accessibility research.",
+ }
+
render_response = render(request, 'website/awards.html', context)
func_end_time = time.perf_counter()
diff --git a/website/views/index.py b/website/views/index.py
index d27710ed..c8807a19 100644
--- a/website/views/index.py
+++ b/website/views/index.py
@@ -1,6 +1,8 @@
from django.conf import settings # for access to settings variables, see https://docs.djangoproject.com/en/4.0/topics/settings/#using-settings-in-python-code
from website.models import Banner, Publication, Talk, Video, Project, Person, News, Sponsor
-import website.utils.ml_utils as ml_utils
+import website.utils.ml_utils as ml_utils
+from website.utils.metadata import absolute_url, render_jsonld
+from django.templatetags.static import static
from django.shortcuts import render # for render https://docs.djangoproject.com/en/4.0/topics/http/shortcuts/#render
from django.db.models import OuterRef, Subquery, F
@@ -76,7 +78,35 @@ def index(request):
'projects': active_projects,
'sponsors': sponsors,
'debug': settings.DEBUG}
-
+
+ # schema.org Organization JSON-LD (home page) — helps Google build a
+ # knowledge panel for "Makeability Lab" (#1142/#1324). Rendered by the
+ # jsonld block in base.html.
+ context['jsonld'] = render_jsonld({
+ "@context": "https://schema.org",
+ "@type": "Organization",
+ "name": "Makeability Lab",
+ "url": absolute_url(request, "/"),
+ "logo": absolute_url(request, static(
+ "website/img/logos/makelab_logo_v3_white_with_colors_and_text_og_image_ratio_1200w.png")),
+ "description": ("The Makeability Lab is an advanced research lab in "
+ "Human-Computer Interaction and AI directed by Professor "
+ "Jon E. Froehlich at the University of Washington's Allen "
+ "School of Computer Science."),
+ "parentOrganization": {
+ "@type": "CollegeOrUniversity",
+ "name": ("Paul G. Allen School of Computer Science & Engineering, "
+ "University of Washington"),
+ "url": "https://www.cs.washington.edu/",
+ },
+ "sameAs": [
+ "https://www.linkedin.com/company/makeabilitylab",
+ "https://bsky.app/profile/makeabilitylab.bsky.social",
+ "https://twitter.com/makeabilitylab",
+ "https://github.com/makeabilitylab",
+ ],
+ })
+
# Render is a Django shortcut (aka helper function). It combines a given template—in this case
# index.html—with a context dictionary and returns an HttpResponse object with that rendered text.
# See: https://docs.djangoproject.com/en/4.0/topics/http/shortcuts/#render
diff --git a/website/views/member.py b/website/views/member.py
index 3657b673..cb8a0e58 100644
--- a/website/views/member.py
+++ b/website/views/member.py
@@ -2,6 +2,8 @@
from website.models import Person, News, Video
import website.utils.ml_utils as ml_utils
from website.utils.bio_utils import auto_generate_bio
+from website.utils.metadata import meta_description, absolute_url, render_jsonld
+from django.urls import reverse
from django.shortcuts import render, get_object_or_404, redirect
from django.db.models import Q
from django.core.exceptions import MultipleObjectsReturned
@@ -149,7 +151,51 @@ def member(request, member_name=None, member_id=None):
'debug': settings.DEBUG,
'navbar_white': True,
'page_title': person.get_full_name()}
-
+
+ # Per-page SEO / social metadata (see base.html + #1142/#1236/#1324). Mirror
+ # the previous og:description precedence: bio -> auto-generated bio ->
+ # position sentence -> lab default (base.html supplies the default when None).
+ if person.bio:
+ person_description = meta_description(person.bio)
+ elif auto_generated_bio:
+ person_description = meta_description(auto_generated_bio)
+ elif latest_position:
+ person_description = (f"{person.get_full_name()} is a {latest_position.title} "
+ "at the Makeability Lab, an advanced research lab in "
+ "Human-Computer Interaction at University of Washington.")
+ else:
+ person_description = None
+
+ member_path = reverse('website:member_by_name', kwargs={'member_name': person.url_name})
+ context['page_meta'] = {
+ 'title': f"{person.get_full_name()} - Makeability Lab",
+ 'description': person_description,
+ 'og_type': 'profile',
+ 'canonical_path': member_path,
+ }
+
+ # schema.org Person JSON-LD (member page). sameAs links the profile to the
+ # person's external scholarly/social identities (ORCID, Scholar, GitHub, …),
+ # which strengthens " Makeability Lab" search results (#1142/#1324).
+ person_same_as = [u for u in (person.personal_website, person.github,
+ person.twitter, person.bluesky, person.mastodon,
+ person.threads, person.linkedin, person.orcid,
+ person.google_scholar) if u]
+ person_jsonld = {
+ "@context": "https://schema.org",
+ "@type": "Person",
+ "name": person.get_full_name(),
+ "url": absolute_url(request, member_path),
+ "affiliation": {"@type": "Organization", "name": "Makeability Lab"},
+ }
+ if latest_position:
+ person_jsonld["jobTitle"] = str(latest_position.title)
+ if person.image:
+ person_jsonld["image"] = absolute_url(request, person.image.url)
+ if person_same_as:
+ person_jsonld["sameAs"] = person_same_as
+ context['jsonld'] = render_jsonld(person_jsonld)
+
# Render is a Django shortcut (aka helper function). It combines a given template—in this case
# member.html—with a context dictionary and returns an HttpResponse object with that rendered text.
# See: https://docs.djangoproject.com/en/4.0/topics/http/shortcuts/#render
diff --git a/website/views/news_item.py b/website/views/news_item.py
index 2ac8db0e..b64d38a8 100644
--- a/website/views/news_item.py
+++ b/website/views/news_item.py
@@ -2,7 +2,9 @@
from django.db.models import Prefetch # fore prefetching
from website.models import News, Project
-import website.utils.ml_utils as ml_utils
+import website.utils.ml_utils as ml_utils
+from website.utils.metadata import meta_description, absolute_url, render_jsonld
+from django.urls import reverse
from django.shortcuts import render, get_object_or_404
from django.http import Http404
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
@@ -85,6 +87,38 @@ def news_item(request, slug=None, id=None):
'recent_news_posts_by_author': recent_news_posts_by_author,
'navbar_white': True,
'debug': settings.DEBUG}
+
+ # Per-page SEO / social metadata (see base.html + #1142/#1236/#1324). Prefer
+ # the slug URL as canonical (the human-readable, indexable form); fall back
+ # to the numeric-id route for items without a slug yet.
+ if cur_news_item.slug:
+ news_canonical = reverse('website:news_item_by_slug', kwargs={'slug': cur_news_item.slug})
+ else:
+ news_canonical = reverse('website:news_item_by_id', kwargs={'id': cur_news_item.id})
+
+ context['page_meta'] = {
+ 'title': cur_news_item.title,
+ 'description': meta_description(cur_news_item.content),
+ 'og_type': 'article',
+ 'canonical_path': news_canonical,
+ }
+
+ # schema.org NewsArticle JSON-LD (news detail). #1324.
+ news_jsonld = {
+ "@context": "https://schema.org",
+ "@type": "NewsArticle",
+ "headline": cur_news_item.title,
+ "url": absolute_url(request, news_canonical),
+ "publisher": {"@type": "Organization", "name": "Makeability Lab"},
+ }
+ if cur_news_item.date:
+ news_jsonld["datePublished"] = cur_news_item.date.isoformat()
+ if cur_news_item.author:
+ news_jsonld["author"] = {"@type": "Person",
+ "name": cur_news_item.author.get_full_name()}
+ if cur_news_item.image:
+ news_jsonld["image"] = absolute_url(request, cur_news_item.image.url)
+ context['jsonld'] = render_jsonld(news_jsonld)
diff --git a/website/views/news_listing.py b/website/views/news_listing.py
index da846c83..858470f9 100644
--- a/website/views/news_listing.py
+++ b/website/views/news_listing.py
@@ -44,6 +44,14 @@ def news_listing(request):
# Render is a Django helper function. It combines a given template—in this case news-listing.html—with
# a context dictionary and returns an HttpResponse object with that rendered text.
# See: https://docs.djangoproject.com/en/4.0/topics/http/shortcuts/#render
+ # Per-page SEO / social metadata (see base.html). #1142/#1324.
+ context['page_meta'] = {
+ 'title': 'News',
+ 'description': "News from the Makeability Lab — new papers, awards, lab "
+ "members, and project milestones in HCI and accessibility "
+ "at the University of Washington.",
+ }
+
render_func_start_time = time.perf_counter()
render_response = render(request, 'website/news_listing.html', context)
render_func_end_time = time.perf_counter()
diff --git a/website/views/people.py b/website/views/people.py
index 7e9f8dc0..44887aa1 100644
--- a/website/views/people.py
+++ b/website/views/people.py
@@ -99,6 +99,15 @@ def people(request):
# Render is a Django helper function. It combines a given template—in this case people.html—with
# a context dictionary and returns an HttpResponse object with that rendered text.
# See: https://docs.djangoproject.com/en/4.0/topics/http/shortcuts/#render
+ # Per-page SEO / social metadata (see base.html). A distinct description per
+ # listing page is the direct antidote to "crawled, not indexed" (#1142/#1324).
+ context['page_meta'] = {
+ 'title': 'People',
+ 'description': "Meet the faculty, students, postdocs, and alumni of the "
+ "Makeability Lab — HCI, accessibility, and AI researchers "
+ "at the University of Washington.",
+ }
+
render_func_start_time = time.perf_counter()
render_response = render(request, 'website/people.html', context)
render_func_end_time = time.perf_counter()
diff --git a/website/views/project.py b/website/views/project.py
index 5a49962b..4a6d6682 100644
--- a/website/views/project.py
+++ b/website/views/project.py
@@ -2,7 +2,9 @@
from website.models import Project, Position, ProjectRole, Grant
from website.models.project_role import LeadProjectRoleTypes
from website.models.position import MemberClassification
-import website.utils.ml_utils as ml_utils
+import website.utils.ml_utils as ml_utils
+from website.utils.metadata import meta_description
+from django.urls import reverse
from django.shortcuts import render, get_object_or_404, redirect
from operator import attrgetter
from django.template.loader import render_to_string
@@ -112,6 +114,17 @@ def project(request, project_name):
'has_videos_beyond_featured_video': has_videos_beyond_featured_video,
'debug': settings.DEBUG}
+ # Per-page SEO / social metadata (see base.html + #1142/#1236/#1324). Build
+ # the canonical with reverse('project', ...) so it's byte-for-byte identical
+ # to the URL the sitemap advertises (website/sitemaps.py uses the same
+ # reverse) — that resolves to /project//, collapsing the
+ # /projects/ alias to one indexable URL.
+ context['page_meta'] = {
+ 'title': project.name,
+ 'description': meta_description(project.summary),
+ 'canonical_path': reverse('website:project', args=[project.short_name]),
+ }
+
context['view_prep_time'] = time.perf_counter() - func_start_time
_logger.debug(f"Setup view for '{project.name}' (sans render) in {context['view_prep_time']:0.4f} seconds")
diff --git a/website/views/project_listing.py b/website/views/project_listing.py
index 37167b72..eaca89a3 100644
--- a/website/views/project_listing.py
+++ b/website/views/project_listing.py
@@ -66,6 +66,14 @@ def project_listing(request):
# Render is a Django helper function. It combines a given template—in this case projects.html—with
# a context dictionary and returns an HttpResponse object with that rendered text.
# See: https://docs.djangoproject.com/en/4.0/topics/http/shortcuts/#render
+ # Per-page SEO / social metadata (see base.html). #1142/#1324.
+ context['page_meta'] = {
+ 'title': 'Projects',
+ 'description': "Explore Makeability Lab research projects in accessibility, "
+ "human-computer interaction, AI, and urban computing at the "
+ "University of Washington.",
+ }
+
render_func_start_time = time.perf_counter()
render_response = render(request, 'website/project_listing.html', context)
render_func_end_time = time.perf_counter()
diff --git a/website/views/publications.py b/website/views/publications.py
index b00eec12..495f3507 100644
--- a/website/views/publications.py
+++ b/website/views/publications.py
@@ -47,6 +47,14 @@ def publications(request):
# Render is a Django shortcut (aka helper function). It combines a given template with a
# context dictionary and returns an HttpResponse object with that rendered text.
# See: https://docs.djangoproject.com/en/4.0/topics/http/shortcuts/#render
+ # Per-page SEO / social metadata (see base.html). #1142/#1324.
+ context['page_meta'] = {
+ 'title': 'Publications',
+ 'description': "Peer-reviewed Makeability Lab publications in human-computer "
+ "interaction, accessibility, and AI, directed by Prof. Jon E. "
+ "Froehlich at the University of Washington.",
+ }
+
render_func_start_time = time.perf_counter()
render_response = render(request, 'website/publications.html', context)
render_func_end_time = time.perf_counter()