Skip to content

Give races real URLs and a per-race build-time OG image #136

Description

@shaug

Depends on: #135 (land first -- this reuses the summary_large_image + full
OG/Twitter tag pattern it establishes, so race pages aren't built against the
old inconsistent pattern).

Outcome

Sharing a specific race (via the existing "Share link" button on each race
card) produces a link that unfurls with that race's own title, description,
and a build-time-generated image showing the race name, consensus pick/grade,
and source count -- not the site-wide brand card every link shares today.

Why this needs more than an image

Investigated the current "Share link" flow: it copies window.location.href
with link.hash set to race-<id> (guide.html.j2:1284), producing
.../e/<election_id>/#race-<id>. URL fragments are never sent as part of the
HTTP request, so link-unfurling crawlers (Slack, iMessage, Twitter/X,
Facebook) fetch the same guide page and get the same site-wide og:image/
og:title no matter which race was "shared." A distinct per-race preview
requires a distinct, path-based, crawlable URL per race -- this is a routing
change, not just an image-generation change.

Scope

  1. New static route per race: /e/<election_id>/races/<race_id>/index.html,
    generated at hosting stage time (pages.py stage_pages_site /
    _stage_verified_bundles) alongside the existing per-election index.html
    and sources/index.html. One page per race in the current bundle's
    inventory.
  2. Page content: a server-rendered summary of that one race (name,
    consensus grade/pick, source count, notable alternatives) sufficient to
    stand alone for crawlers and no-JS visitors, plus a prominent link back to
    the full guide (/e/<id>/#race-<id>) that also opens the existing
    race-detail dialog for JS visitors. Reuses the data already computed for
    the dialog (e.g. race_detail_accessible_summary, guide.html.j2:291) --
    no new data source.
  3. Per-race OG image: generated at build time (not per-request -- no
    server compute added), using Pillow (pyproject.toml:15, already a
    dependency) to render race name + consensus pick + grade onto the existing
    brand template. One PNG per race, written into that race's output
    directory.
  4. Meta tags: each race page gets its own og:title (" --
    Seattle Elections Guide"), og:description (a consensus summary sentence),
    og:url (the canonical race URL), and og:image pointing at its own PNG,
    following the summary_large_image + full tag-set pattern from Unfurling quick wins: summary_large_image + complete OG/Twitter tags per page #135.
  5. Update "Share link": the copy-link button (guide.html.j2:1279-1308)
    points at the new canonical race URL instead of the fragment-only link.

Preserved behavior

  • The single-page guide with its race-detail dialog is unchanged for anyone
    landing on /e/<id>/ directly -- this adds new pages, it doesn't replace
    the existing dialog UX.
  • No scoring/consensus logic changes; race pages render already-computed
    data.
  • Everything remains static: no Cloudflare Pages Function, no per-request
    rendering.

Non-goals

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: publicationHTML, PDF, exports, or release packagingtype: featureProduct or pipeline capability

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions