Skip to content

Use Schema.org event data type #173

Open

Description

@SableRaf

See: https://schema.org/Event

Include schema.org context for event pages. This improves SEO and integration with Google (see https://developers.google.com/search/docs/appearance/structured-data/event)

Activity

  1. SableRaf commented on Jul 31, 2026

    @SableRaf
    CollaboratorAuthor

    🤖 Generated by Claude Opus 5

    Schema.org Event structured data (issue #173)

    Context

    Issue #173 asks for schema.org
    context on event pages so Google can surface PCD events as event rich
    results
    and so other
    tools can consume the event graph.

    The site already emits per-event static pages at /event/<slug>-<uid>/ with full OG/Twitter tags
    ([id].astro), and already exposes a machine-readable feed at
    /data.json. What is missing is JSON-LD. Every field an
    Event needs is already on the Node interface in nodes.ts — name,
    dates, times, decoded lat/lng, city/country, address, organizers, online flag — so this is a
    serialization layer, not a data-model change.

    Decisions taken with the user:

    • Dateless events still emit an Event block. 34 of 53 events currently have event_date: "".
      They get an Event with no startDate: valid schema.org, machine-readable, and it fills in
      automatically as organizers set dates. Trade-off accepted: Google Search Console will report
      "Missing field startDate" for those pages until a date lands, and they are not rich-result eligible.
    • No offers / isAccessibleForFree. Nothing in the event metadata records ticketing, so
      asserting free admission across all events would be a claim we cannot back.
    • Homepage gets structured data too — Organization + WebSite + an ItemList of events.

    Scope

    1. New helper module pcd-website/src/lib/schema.ts.
    2. Event JSON-LD on canonical event pages.
    3. @graph (Organization / WebSite / ItemList) on the homepage.
    4. Build-output test + docs.

    1. pcd-website/src/lib/schema.ts (new)

    Pure serialization module, no Astro imports, mirroring the style of
    format.ts. Reuses canonicalEventId from
    nodes.ts — do not re-derive the <slug>-<uid> shape.

    import type { Node } from './nodes';
    import { canonicalEventId } from './nodes';
    
    export interface SchemaContext {
      siteUrl: string;   // 'https://day.processing.org' (no trailing slash)
      base: string;      // '' for base '/'
    }
    
    export function eventPageUrl(node: Node, ctx: SchemaContext): string
    export function eventJsonLd(node: Node, ctx: SchemaContext, opts: { description: string; imageUrl: string }): Record<string, unknown>
    export function siteJsonLd(nodes: Node[], ctx: SchemaContext): Record<string, unknown>
    export function jsonLdScript(data: unknown): string

    jsonLdScript — required for safety

    set:html does not escape. Event descriptions originate from GitHub issue bodies, so a literal
    </script> in details_text would break out of the tag.

    export function jsonLdScript(data: unknown): string {
      return JSON.stringify(data).replace(/</g, '\\u003c');
    }

    Every JSON-LD block on the site goes through this function. No exceptions.

    eventJsonLd field mapping

    JSON-LD Source Rule
    @type — "Event"
    @id eventPageUrl + #event stable identifier
    name node.event_name always
    url eventPageUrl(node, ctx) canonical /event/<slug>-<uid>/
    description passed in from the page reuse the exact string already computed for <meta name="description"> — do not recompute
    image passed in [ogImageUrl] (array form)
    eventStatus — https://schema.org/EventScheduled
    eventAttendanceMode node.online_event OnlineEventAttendanceMode / OfflineEventAttendanceMode
    startDate / endDate dates + times see below
    location see below always present
    organizer see below omitted when unknown
    superEvent — { '@type': 'Event', name: 'Processing Community Day 2026', url: siteUrl }

    Dates. Times are plain HH:MM wall-clock (verified across the 6 events that set them) and the
    metadata carries no timezone. Emit local time with no offset — schema.org and Google both allow
    this when the zone is unknown.

    • date_tbd → omit startDate and endDate entirely.
    • date only → startDate: "2026-10-24"; endDate: event_end_date if set, else omit.
    • date + event_start_time → startDate: "2026-10-24T10:00".
    • event_end_time set → endDate: "<event_end_date ?? event_date>T<event_end_time>".
    • Guard time values with /^\d{1,2}:\d{2}$/ and zero-pad the hour; fall back to date-only if a
      malformed time ever lands in metadata rather than emitting a broken ISO string.

    (Known limitation, worth a code comment: no offset means Google interprets the time as local to the
    venue. A future improvement could derive the zone from lat/lng; out of scope here.)

    Location.

    • online_event → { '@type': 'VirtualLocation', url: node.event_url ?? eventPageUrl(...) }
    • otherwise Place:
      • name: location_name ?? city ?? event_name
      • address: PostalAddress with streetAddress (node.address), addressLocality (node.city),
        addressCountry (node.country) — drop keys whose value is undefined, never emit "".
        location_tbd events still get a valid city/country address, which satisfies Google's
        "physical events need location.address" requirement.
      • geo: GeoCoordinates from node.lat / node.lng (already decoded from the plus code).

    Organizer.

    • organization_name set → { '@type': 'Organization', name, url: organization_url } (url omitted if absent)
    • else organizers.length → array of { '@type': 'Person', name: o.name }
    • else → omit the key. Do not fall back to Processing Foundation; local events are locally organized.
    • Use the plain name, never name_html (markdown-rendered HTML must not leak into JSON-LD).
    • Never emit primary_contact — same privacy rule the /data.json feed follows.

    Strip undefined/empty values before returning so the emitted JSON stays clean.

    siteJsonLd

    Returns { '@context': 'https://schema.org', '@graph': [Organization, WebSite, ItemList] }:

    • Organization — Processing Foundation, url from new config.ts constants.
    • WebSite — name: 'Processing Community Day 2026', url: siteUrl, publisher → the Organization @id.
    • ItemList — itemListElement of ListItem { position, url: eventPageUrl(node), name: node.event_name }
      for every !node.placeholder event, in loadNodes() order (already date-then-name sorted).
      URL form, not inlined Event objects: the homepage detail panel is client-rendered, so crawlers
      only ever see the links.

    2. pcd-website/src/config.ts

    Add project-wide constants (per the AGENTS.md rule about what belongs here):

    export const PCD_SITE_NAME = "Processing Community Day 2026";
    export const PCD_ORG_NAME = "Processing Foundation";
    export const PCD_ORG_URL = "https://processingfoundation.org/";

    3. pcd-website/src/pages/event/[id].astro

    Inside the canonical branch only (redirectUrl === null) — redirect variants are noindex and
    must stay bare. Also skip when node.placeholder is true, matching the "confirmed events only" rule
    that /data.json follows.

    {!redirectUrl && !node.placeholder && (
      <script type="application/ld+json" set:html={jsonLdScript(
        eventJsonLd(node, { siteUrl, base }, { description, imageUrl: ogImageUrl })
      )} />
    )}

    siteUrl, base, description, and ogImageUrl are all already computed at
    [id].astro:25-35 — pass them through, don't duplicate the logic.

    4. pcd-website/src/pages/index.astro

    One <script type="application/ld+json" set:html={jsonLdScript(siteJsonLd(nodes, { siteUrl, base }))} />
    in <head>. nodes, siteUrl, and base already exist at
    index.astro:8-13.

    5. Tests — .github/scripts/schema-org.test.mjs (new)

    Follows the data-json.test.mjs pattern: reads built output from
    pcd-website/dist/, node:test + node:assert/strict, no new dependencies. Extract blocks with
    /<script type="application\/ld\+json">([\s\S]*?)<\/script>/g.

    Case Expected
    Every canonical dist/event/<slug>-<uid>/index.html exactly one JSON-LD block, JSON.parse succeeds, @type === 'Event'
    Redirect pages (/event/<slug>/, /event/<uid>/) zero JSON-LD blocks
    Every Event non-empty name, url equal to https://day.processing.org/event/<id>-<uid>/, location present
    Physical events location.@type === 'Place' with geo.latitude/geo.longitude finite
    Dated events (cross-checked against dist/data.json) startDate starts with the event's event_date
    Dateless events startDate key absent (not null, not "")
    Privacy no primary_contact key and no @ -bearing email from any metadata.json anywhere in the JSON-LD
    Escaping no raw </script> inside any block (parse success is the practical assertion)
    dist/index.html one block with @graph; ItemList.itemListElement.length equals data.json's event_count; positions are 1..n contiguous

    Wire it into scripts/run-tests.sh alongside data-json.test.mjs (post-build
    section — it needs dist/).

    6. Docs

    • TEST.md — new "Schema.org build output" section in the same table format, plus a note
      that it needs npm run build first.
    • AGENTS.md — add src/lib/schema.ts to the component-roles table and a short
      "Structured data" subsection under Architecture covering: canonical pages only, the
      jsonLdScript escaping requirement, the no-timezone limitation, and the no-offers /
      no-primary_contact rules.

    Verification

    cd pcd-website && npm run build          # must succeed; plus-code validation still gates the build
    cd .. && node --test .github/scripts/schema-org.test.mjs
    ./scripts/run-tests.sh                   # full battery, confirms nothing regressed

    Then spot-check output by hand:

    1. grep -A2 'ld+json' pcd-website/dist/event/pcd-tokyo-2026-be2e88a/index.html — dateless event,
      expect an Event with location + geo and no startDate.
    2. Pick a dated event with times (e.g. one of the six with event_start_time) and confirm
      startDate reads YYYY-MM-DDTHH:MM and endDate matches the end time.
    3. Confirm pcd-website/dist/event/pcd-tokyo-2026/index.html (redirect variant) has no JSON-LD.
    4. npm run preview, then paste http://localhost:4321/event/<slug>-<uid>/ output into
      validator.schema.org and Google's
      Rich Results Test (paste the HTML directly —
      localhost isn't fetchable). Dated events should validate clean; dateless ones will report the
      expected missing-startDate warning.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions