You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
New helper module pcd-website/src/lib/schema.ts.
Event JSON-LD on canonical event pages.
@graph (Organization / WebSite / ItemList) on the homepage.
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.
importtype{Node}from'./nodes';import{canonicalEventId}from'./nodes';exportinterfaceSchemaContext{siteUrl: string;// 'https://day.processing.org' (no trailing slash)base: string;// '' for base '/'}exportfunctioneventPageUrl(node: Node,ctx: SchemaContext): stringexportfunctioneventJsonLd(node: Node,ctx: SchemaContext,opts: {description: string;imageUrl: string}): Record<string,unknown>exportfunctionsiteJsonLd(nodes: Node[],ctx: SchemaContext): Record<string,unknown>exportfunctionjsonLdScript(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.
{ '@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.)
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)
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):
exportconstPCD_SITE_NAME="Processing Community Day 2026";exportconstPCD_ORG_NAME="Processing Foundation";exportconstPCD_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.
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.
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 buildcd .. && node --test .github/scripts/schema-org.test.mjs
./scripts/run-tests.sh # full battery, confirms nothing regressed
Then spot-check output by hand:
grep -A2 'ld+json' pcd-website/dist/event/pcd-tokyo-2026-be2e88a/index.html — dateless event,
expect an Event with location + geo and nostartDate.
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.
Confirm pcd-website/dist/event/pcd-tokyo-2026/index.html (redirect variant) has no JSON-LD.
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.
Activity
SableRaf commentedon Jul 31, 2026
🤖 Generated by Claude Opus 5
Schema.org
Eventstructured 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
Eventneeds is already on theNodeinterface 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:
Eventblock. 34 of 53 events currently haveevent_date: "".They get an
Eventwith nostartDate: valid schema.org, machine-readable, and it fills inautomatically 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.
offers/isAccessibleForFree. Nothing in the event metadata records ticketing, soasserting free admission across all events would be a claim we cannot back.
Organization+WebSite+ anItemListof events.Scope
pcd-website/src/lib/schema.ts.EventJSON-LD on canonical event pages.@graph(Organization / WebSite / ItemList) on the homepage.1.
pcd-website/src/lib/schema.ts(new)Pure serialization module, no Astro imports, mirroring the style of
format.ts. Reuses
canonicalEventIdfromnodes.ts — do not re-derive the
<slug>-<uid>shape.jsonLdScript— required for safetyset:htmldoes not escape. Event descriptions originate from GitHub issue bodies, so a literal</script>indetails_textwould break out of the tag.Every JSON-LD block on the site goes through this function. No exceptions.
eventJsonLdfield mapping@type"Event"@ideventPageUrl+#eventnamenode.event_nameurleventPageUrl(node, ctx)/event/<slug>-<uid>/description<meta name="description">— do not recomputeimage[ogImageUrl](array form)eventStatushttps://schema.org/EventScheduledeventAttendanceModenode.online_eventOnlineEventAttendanceMode/OfflineEventAttendanceModestartDate/endDatelocationorganizersuperEvent{ '@type': 'Event', name: 'Processing Community Day 2026', url: siteUrl }Dates. Times are plain
HH:MMwall-clock (verified across the 6 events that set them) and themetadata carries no timezone. Emit local time with no offset — schema.org and Google both allow
this when the zone is unknown.
date_tbd→ omitstartDateandendDateentirely.startDate: "2026-10-24";endDate: event_end_dateif set, else omit.event_start_time→startDate: "2026-10-24T10:00".event_end_timeset →endDate: "<event_end_date ?? event_date>T<event_end_time>"./^\d{1,2}:\d{2}$/and zero-pad the hour; fall back to date-only if amalformed 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(...) }Place:name:location_name ?? city ?? event_nameaddress:PostalAddresswithstreetAddress(node.address),addressLocality(node.city),addressCountry(node.country) — drop keys whose value is undefined, never emit"".location_tbdevents still get a valid city/country address, which satisfies Google's"physical events need
location.address" requirement.geo:GeoCoordinatesfromnode.lat/node.lng(already decoded from the plus code).Organizer.
organization_nameset →{ '@type': 'Organization', name, url: organization_url }(urlomitted if absent)organizers.length→ array of{ '@type': 'Person', name: o.name }name, nevername_html(markdown-rendered HTML must not leak into JSON-LD).primary_contact— same privacy rule the/data.jsonfeed follows.Strip undefined/empty values before returning so the emitted JSON stays clean.
siteJsonLdReturns
{ '@context': 'https://schema.org', '@graph': [Organization, WebSite, ItemList] }:Organization— Processing Foundation,urlfrom newconfig.tsconstants.WebSite—name: 'Processing Community Day 2026',url: siteUrl,publisher→ the Organization@id.ItemList—itemListElementofListItem { position, url: eventPageUrl(node), name: node.event_name }for every
!node.placeholderevent, inloadNodes()order (already date-then-name sorted).URL form, not inlined
Eventobjects: the homepage detail panel is client-rendered, so crawlersonly ever see the links.
2.
pcd-website/src/config.tsAdd project-wide constants (per the AGENTS.md rule about what belongs here):
3.
pcd-website/src/pages/event/[id].astroInside the canonical branch only (
redirectUrl === null) — redirect variants arenoindexandmust stay bare. Also skip when
node.placeholderis true, matching the "confirmed events only" rulethat
/data.jsonfollows.siteUrl,base,description, andogImageUrlare all already computed at[id].astro:25-35 — pass them through, don't duplicate the logic.
4.
pcd-website/src/pages/index.astroOne
<script type="application/ld+json" set:html={jsonLdScript(siteJsonLd(nodes, { siteUrl, base }))} />in
<head>.nodes,siteUrl, andbasealready exist atindex.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.dist/event/<slug>-<uid>/index.htmlJSON.parsesucceeds,@type === 'Event'/event/<slug>/,/event/<uid>/)name,urlequal tohttps://day.processing.org/event/<id>-<uid>/,locationpresentlocation.@type === 'Place'withgeo.latitude/geo.longitudefinitedist/data.json)startDatestarts with the event'sevent_datestartDatekey absent (notnull, not"")primary_contactkey and no@-bearing email from anymetadata.jsonanywhere in the JSON-LD</script>inside any block (parse success is the practical assertion)dist/index.html@graph;ItemList.itemListElement.lengthequalsdata.json'sevent_count; positions are 1..n contiguousWire it into scripts/run-tests.sh alongside
data-json.test.mjs(post-buildsection — it needs
dist/).6. Docs
that it needs
npm run buildfirst.src/lib/schema.tsto the component-roles table and a short"Structured data" subsection under Architecture covering: canonical pages only, the
jsonLdScriptescaping requirement, the no-timezone limitation, and the no-offers/no-
primary_contactrules.Verification
Then spot-check output by hand:
grep -A2 'ld+json' pcd-website/dist/event/pcd-tokyo-2026-be2e88a/index.html— dateless event,expect an
Eventwithlocation+geoand nostartDate.event_start_time) and confirmstartDatereadsYYYY-MM-DDTHH:MMandendDatematches the end time.pcd-website/dist/event/pcd-tokyo-2026/index.html(redirect variant) has no JSON-LD.npm run preview, then pastehttp://localhost:4321/event/<slug>-<uid>/output intovalidator.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-
startDatewarning.