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()