From 59e55e103231ee832276be68ddcf83246f5992c6 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Thu, 18 Jun 2026 05:57:51 -0700 Subject: [PATCH 1/6] feat(seo): centralize page metadata; fix http OG/canonical behind proxy (#1236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the duplicated, per-template Open Graph blocks with a single source of truth in base.html driven by an optional `page_meta` context dict (title, description, og_type, canonical_path) with lab-wide defaults. Every page now emits a self-referential canonical link, a complete Open Graph block (og:site_name, og:locale, og:image:width/height/alt, …), and Twitter Card tags (previously absent) — with the meta description computed once and reused. Fixes #1236: absolute URLs (og:url, og:image, canonical) were built from request.scheme, which is http behind UW CSE's TLS-terminating Apache proxy, so social/canonical URLs advertised http://. A new `site_scheme` context processor pins https when not DEBUG (local dev stays http). The cleaner long-term fix (SECURE_PROXY_SSL_HEADER) is left to a separate IT-facing issue. Detail views (project/member/news) populate page_meta and the templates override only the share image + structural tags (og:profile:*, og:article:*). The project canonical is built with reverse('website:project', …) so it is byte-for-byte identical to the URL website/sitemaps.py advertises (singular /project//), collapsing the /projects/ alias to one indexable URL. Lays the groundwork for #1142 (indexing) and #1324 (metadata audit). Adds website/tests/test_page_metadata.py (canonical/OG/Twitter presence, the #1236 https behavior via DEBUG toggling, and per-page title/description/type). Co-Authored-By: Claude Opus 4.8 (1M context) --- makeabilitylab/settings.py | 1 + website/context_processors.py | 25 +++++ website/templates/website/base.html | 55 +++++++++-- website/templates/website/member.html | 29 +++--- website/templates/website/news_item.html | 29 +++--- website/templates/website/project.html | 21 ++-- website/tests/test_page_metadata.py | 119 +++++++++++++++++++++++ website/utils/metadata.py | 39 ++++++++ website/views/member.py | 25 ++++- website/views/news_item.py | 19 +++- website/views/project.py | 15 ++- 11 files changed, 330 insertions(+), 47 deletions(-) create mode 100644 website/tests/test_page_metadata.py create mode 100644 website/utils/metadata.py 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/context_processors.py b/website/context_processors.py index 64a26216..b7a903d6 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 separately); 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/templates/website/base.html b/website/templates/website/base.html index 97a51737..f258e7fc 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 %}{% endblock %} + {% endwith %} diff --git a/website/templates/website/member.html b/website/templates/website/member.html index 68637a85..d96f1e41 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 %} 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..a6b333f5 --- /dev/null +++ b/website/tests/test_page_metadata.py @@ -0,0 +1,119 @@ +""" +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. +""" + +from datetime import date + +from django.test import override_settings +from django.urls import reverse + +from website.tests.base import DatabaseTestCase + + +def _position(person, title=None): + """Give a Person a Position so its member page is fully populated.""" + from website.models import Position + from website.models.position import Title + return Position.objects.create( + person=person, start_date=date(2020, 1, 1), + title=title or Title.PHD_STUDENT, + ) + + +# On the servers DEBUG is False, so site_scheme pins https — assert against that +# (the test client's host is "testserver", auto-added to ALLOWED_HOSTS). +@override_settings(DEBUG=False) +class PageMetadataHttpsTests(DatabaseTestCase): + + def test_home_has_core_metadata(self): + resp = self.client.get(reverse("website:index")) + self.assertEqual(resp.status_code, 200) + # Canonical present and https. + self.assertContains(resp, '') + # Open Graph essentials. + self.assertContains(resp, '') + self.assertContains(resp, '') + self.assertContains(resp, '') + # Twitter Card. + self.assertContains(resp, '') + + def test_no_http_scheme_in_social_urls(self): + """#1236: og:url / og:image / canonical must never advertise http://.""" + resp = self.client.get(reverse("website:index")) + self.assertNotContains(resp, 'property="og:url" content="http://') + self.assertNotContains(resp, 'property="og:image" content="http://') + self.assertNotContains(resp, 'rel="canonical" href="http://') + + def test_project_detail_metadata(self): + project = self.make_project( + name="Sound Watch", short_name="soundwatch", is_visible=True, + start_date=date(2020, 1, 1), + summary="SoundWatch is a smartwatch system for sound awareness.", + ) + resp = self.client.get(reverse("website:project", args=[project.short_name])) + self.assertEqual(resp.status_code, 200) + # Canonical matches the sitemap's reverse()-built URL exactly. + canonical = "https://testserver" + reverse("website:project", args=[project.short_name]) + self.assertContains(resp, f'') + self.assertContains(resp, f'') + self.assertContains(resp, '') + self.assertContains(resp, '') + # Per-page description derived from the project summary (not the default). + self.assertContains(resp, "smartwatch system for sound awareness") + + def test_member_detail_metadata(self): + person = self.make_person(first_name="Ada", last_name="Lovelace", + bio="Ada researches accessible computing.") + _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, '') + self.assertContains(resp, '') + self.assertContains(resp, '') + self.assertContains(resp, '') + canonical = "https://testserver" + reverse( + "website:member_by_name", kwargs={"member_name": person.url_name}) + self.assertContains(resp, f'') + # Description comes from the bio, not the generic site default. + self.assertContains(resp, "Ada researches accessible computing") + + def test_news_detail_metadata(self): + item = self.make_news_item( + title="Lab wins best paper", + content="The Makeability Lab won a best paper award at CHI.", + ) + resp = self.client.get( + reverse("website:news_item_by_id", kwargs={"id": item.id}) + ) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, '') + self.assertContains(resp, '') + self.assertContains(resp, '') + self.assertContains(resp, '') diff --git a/website/utils/metadata.py b/website/utils/metadata.py new file mode 100644 index 00000000..9cbaa1d3 --- /dev/null +++ b/website/utils/metadata.py @@ -0,0 +1,39 @@ +""" +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. +""" + +from django.utils.html import strip_tags +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) diff --git a/website/views/member.py b/website/views/member.py index 3657b673..76d4adf8 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 +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,28 @@ 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 + + context['page_meta'] = { + 'title': f"{person.get_full_name()} - Makeability Lab", + 'description': person_description, + 'og_type': 'profile', + 'canonical_path': reverse('website:member_by_name', kwargs={'member_name': person.url_name}), + } + # 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..9667ed16 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 +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,21 @@ 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, + } 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") From ada73f2ddc54785f4fcba6e347687350ed6ad19d Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Thu, 18 Jun 2026 05:59:58 -0700 Subject: [PATCH 2/6] feat(seo): distinct per-page meta descriptions + og:titles for listing pages (#1142, #1324) Every page previously shipped the same generic , a classic driver of Google's "Crawled - currently not indexed". Give the People, Projects, Publications, Awards, and News listing pages each a hand-written, distinct description and og:title via the page_meta context dict introduced in the prior commit. (The home page already uses the canonical lab description, so it's left on the default.) Extends website/tests/test_page_metadata.py to pin each listing page's distinct description/og:title and that none falls back to the generic default. Co-Authored-By: Claude Opus 4.8 (1M context) --- website/tests/test_page_metadata.py | 42 +++++++++++++++++++++++++++++ website/views/awards.py | 8 ++++++ website/views/news_listing.py | 8 ++++++ website/views/people.py | 9 +++++++ website/views/project_listing.py | 8 ++++++ website/views/publications.py | 8 ++++++ 6 files changed, 83 insertions(+) diff --git a/website/tests/test_page_metadata.py b/website/tests/test_page_metadata.py index a6b333f5..f8f466e5 100644 --- a/website/tests/test_page_metadata.py +++ b/website/tests/test_page_metadata.py @@ -109,6 +109,48 @@ def test_news_detail_metadata(self): self.assertContains(resp, "won a best paper award") +class ListPageDescriptionTests(DatabaseTestCase): + """Each listing page should ship a distinct, hand-written meta description + and og:title rather than the single generic site description (#1142/#1324).""" + + GENERIC = "advanced research lab in Human-Computer Interaction and AI" + + def _assert_distinct(self, url_name, og_title, description_fragment): + resp = self.client.get(reverse(url_name)) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, f'') + self.assertContains(resp, description_fragment) + # The per-page description should appear in the description meta tag. + self.assertContains(resp, f' Date: Thu, 18 Jun 2026 06:03:22 -0700 Subject: [PATCH 3/6] feat(people): add ORCID + Google Scholar to Person; surface on member pages (#1324) Add `orcid` and `google_scholar` URLField(blank, null) to Person, expose them in the admin "Socials" fieldset, and render them on the member page as academicons icons (ai-orcid / ai-google-scholar; academicons is already loaded in base.html) following the existing per-link a11y pattern (aria-label + text). These also enrich the Person JSON-LD `sameAs` planned in the next commit. While here, fix two latent gaps in the same surfaces: - has_website_links() only checked personal_website/github/twitter, so a person whose only link was bluesky/mastodon/threads/linkedin (and now orcid/scholar) had their entire profile-links nav hidden. It now returns True for any link. - the admin "Socials" fieldset was missing `bluesky`, so it couldn't be edited even though the field existed and rendered on the page. Migrations are gitignored / generated at container start, so no migration file is committed; the test DB is built from models via the settings_test shim. Adds MemberSocialLinkTests to website/tests/test_views.py. Co-Authored-By: Claude Opus 4.8 (1M context) --- website/admin/person_admin.py | 2 +- website/models/person.py | 13 ++++++-- website/templates/website/member.html | 18 ++++++++++- website/tests/test_views.py | 46 +++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) 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/models/person.py b/website/models/person.py index 5ef6cec7..d23ab504 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-0002-1853-9710' + 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=lFn1Oz0AAAAJ' + # 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/member.html b/website/templates/website/member.html index d96f1e41..bc06eb94 100644 --- a/website/templates/website/member.html +++ b/website/templates/website/member.html @@ -197,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/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') From 8c9047f34e35835c3e9994a3d30b87569245f6de Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Thu, 18 Jun 2026 06:07:29 -0700 Subject: [PATCH 4/6] feat(seo): add schema.org JSON-LD (Organization, Person, NewsArticle) (#1324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit structured data on the highest-value page types: - Organization on the home page (name, url, logo, description, parentOrganization = Allen School/UW, sameAs = lab social profiles) to help Google build a knowledge panel for "Makeability Lab". - Person on member pages, with sameAs linking the profile to the person's external identities (ORCID, Google Scholar, GitHub, …) — strengthens " Makeability Lab" queries. - NewsArticle on news detail (headline, datePublished, author, publisher). Dicts are built in the views and serialized by a new render_jsonld() helper that escapes <, >, & as \uXXXX so a value containing literal "" can't break out of the tag (covered by a regression test). base.html renders the optional `jsonld` context var inside {% endif %}{% endblock %} {% endwith %} diff --git a/website/tests/test_page_metadata.py b/website/tests/test_page_metadata.py index f8f466e5..62a7565c 100644 --- a/website/tests/test_page_metadata.py +++ b/website/tests/test_page_metadata.py @@ -16,6 +16,8 @@ website/utils/metadata.py. """ +import json +import re from datetime import date from django.test import override_settings @@ -24,6 +26,16 @@ 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) diff --git a/website/utils/metadata.py b/website/utils/metadata.py index 9cbaa1d3..b8110e1f 100644 --- a/website/utils/metadata.py +++ b/website/utils/metadata.py @@ -9,7 +9,11 @@ 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 @@ -37,3 +41,41 @@ def meta_description(html, max_chars=META_DESCRIPTION_MAX_CHARS): 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. + """ + 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/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 76d4adf8..cb8a0e58 100644 --- a/website/views/member.py +++ b/website/views/member.py @@ -2,7 +2,7 @@ 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 +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 @@ -166,13 +166,36 @@ def member(request, member_name=None, member_id=None): 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': reverse('website:member_by_name', kwargs={'member_name': person.url_name}), + '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 9667ed16..b64d38a8 100644 --- a/website/views/news_item.py +++ b/website/views/news_item.py @@ -3,7 +3,7 @@ from website.models import News, Project import website.utils.ml_utils as ml_utils -from website.utils.metadata import meta_description +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 @@ -102,6 +102,23 @@ def news_item(request, slug=None, id=None): '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) From 8e30d863ef1914dfea1a84926cd55ab41ce0206f Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Thu, 18 Jun 2026 06:08:29 -0700 Subject: [PATCH 5/6] docs(seo): link the site_scheme workaround to IT follow-up issue #1329 Reference #1329 (enable SECURE_PROXY_SSL_HEADER) from the in-app https workaround so the temporary code points at the durable fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- website/context_processors.py | 2 +- website/utils/metadata.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/website/context_processors.py b/website/context_processors.py index b7a903d6..b2290390 100644 --- a/website/context_processors.py +++ b/website/context_processors.py @@ -35,7 +35,7 @@ def site_scheme(request): 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 separately); once that + 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 { diff --git a/website/utils/metadata.py b/website/utils/metadata.py index b8110e1f..a7052c83 100644 --- a/website/utils/metadata.py +++ b/website/utils/metadata.py @@ -48,7 +48,9 @@ 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. + 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' From 03990fc25a15a6649a97c545ce4e64ee92244cf7 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Thu, 18 Jun 2026 06:25:25 -0700 Subject: [PATCH 6/6] docs(people): use real ORCID + Google Scholar profiles in field help_text examples Replace the placeholder ORCID/Scholar example URLs in the Person admin help_text with Jon Froehlich's actual profiles so editors have a concrete, working example to mirror. (& encoded as & in the Scholar URL for valid help_text HTML.) Co-Authored-By: Claude Opus 4.8 (1M context) --- website/models/person.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/models/person.py b/website/models/person.py index d23ab504..f671117c 100644 --- a/website/models/person.py +++ b/website/models/person.py @@ -139,9 +139,9 @@ def get_director(cls): 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-0002-1853-9710' + 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=lFn1Oz0AAAAJ' + 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)