Skip to content

SEO data ​

Search engines and social-preview crawlers don't run JavaScript widgets the way a browser tab does, so a page that only ever renders its fixtures inside a <goma-events-horizontal> Shadow DOM has nothing a crawler can read. goma:seo-data closes that gap on the data side: eight widgets emit a plain, serialisable snapshot of whatever sporting events or casino games they currently have resolved, so your page can turn that into meta tags, <title> text, or JSON-LD. Whatever your SEO stack already does with structured content.

The boundary, stated plainly

GomaPulse supplies data. Your page owns markup, meta tags and crawler delivery. No widget writes a <script type="application/ld+json"> tag, touches document.head, or injects anything into the page on your behalf: it only dispatches an event with the data in it. Turning that data into structured markup, and making sure a crawler actually sees it, is entirely your side of the contract.

The event ​

Every emitting widget dispatches the same event shape, bubbles: true and composed: true, so it crosses the Shadow DOM boundary exactly like every other goma:* event described in Cross-widget wiring. One listener on document catches it from every instance of every emitting widget on the page:

js
document.addEventListener('goma:seo-data', (e) => {
  const { widget, type, items } = e.detail
  console.log(widget, type, items)
  // e.target is the <goma-...> host element that emitted it.
})

detail is always { widget, type, items }:

FieldTypeMeaning
widgetstringWhich widget emitted it: the element's tag name with the goma- prefix stripped, e.g. 'events-horizontal', 'casino-games'.
typestring'sports-event' or 'casino-game', which of the two item shapes below items holds.
itemsobject[]The resolved records. Always non-empty when the event fires: see the emission contract below.

The two item shapes ​

'sports-event' ​

Every field except id is omitted rather than sent as null when the underlying data doesn't have it: see Absent fields below.

ts
type SeoSportsEvent = {
  id: string                              // always present
  homeTeam?: string
  awayTeam?: string
  competition?: string
  competitionId?: string
  sportId?: number
  venueId?: string
  venueName?: string
  startTime?: string                      // ISO 8601
  isLive?: boolean
  markets?: Array<{
    id?: string
    name: string
    bettingTypeId?: string
    outcomes?: Array<{
      id?: string
      label: string
      odds?: number
    }>
  }>
}

markets and its nested outcomes carry only what a structured-data schema would use: identity, a display name or label, and a price. Place-bet plumbing that rides along on the live betting shapes (bettingOfferId, eventPartId, mainLine, isAvailable) is deliberately not part of this contract. markets is present only when the widget actually has priced markets to report; an outcome's odds is present only when it resolved to a real number.

markets is best-effort, not guaranteed

Whether markets shows up at all depends on the emitting widget and its timing, and a host must not depend on it being there. <goma-highlight-card> and <goma-pros-choice> never include it: their SEO item is built from a small synthesized event with no market data attached. <goma-game-details> includes it only when its separate markets subscription lands within the 120ms debounce window the emitter coalesces around; a slower dump ships the event without it. The host-fed path (event on a banner, hostEvent on <goma-hero-card>) carries outcomeIds rather than nested outcome objects, so even when markets is present its outcomes key is typically absent. Build your JSON-LD (or whatever you do with the payload) so a missing markets, or a markets entry with no outcomes, per the absent fields rule: degrades gracefully rather than being treated as an error.

There is no score or clock field on this shape, and that is deliberate, not an oversight: a live score is a rendering concern the cards derive elsewhere, the betting store's own score fields hold only what a host sent rather than the live truth, and SportsEvent structured data has no natural slot for either one.

'casino-game' ​

The only widget that emits this type is <goma-casino-games>, mapped straight off whatever it was fed in its games property:

ts
type SeoCasinoGame = {
  id: string        // always present
  name?: string
  provider?: string
  url?: string       // the game's ctaUrl, when supplied
}

Emission contract ​

The emitter is deliberately conservative about when it fires, because a host builds meta tags and JSON-LD from identity (teams, competition, kick-off) never from a live price. The behavior, as implemented:

  • Once, when a widget's data first resolves to at least one item.
  • Again only when an item's identity changes: a new fixture appears, a fixture's teams/competition/start time change, or the set of items changes shape. The signature that decides "did anything change" is every top-level field on an item except markets: excluded on purpose, so odds ticking never re-fires the event. markets still rides along in the payload as a snapshot taken at the moment the event fires; it just doesn't drive whether it fires.
  • Debounced by 120ms. A burst of individual item changes inside that window collapses into one emission, re-read at fire time so it reflects whatever the data settled on, not an intermediate state.
  • Never with an empty items array. If a widget's underlying data goes to zero items (nothing has resolved yet, or a feed's answer is genuinely empty) the widget stays silent rather than emitting { items: [] }. That means "no fixtures" and "not loaded yet" are indistinguishable to a listener, and that is deliberate: an empty emission would force every host to special-case it, and there is nothing an empty snapshot would let you write into a page's <head> that omitting it doesn't already achieve. Don't wire a fallback state off the absence of this event: build your fallback off your own page's own loading state instead.

Field rules ​

Three rules apply across every item this contract produces, sports-event or casino-game alike:

  • Ids are strings, always. Provider event, competition and game ids are large enough (18-digit values) to exceed Number.MAX_SAFE_INTEGER: never call Number() on one. Compare and store them as strings end to end, the same rule every other identifier in GomaPulse follows.
  • Team and participant names are the full name, not an abbreviation: the same homeTeam / awayTeam values a card renders, not a short form chosen for a narrow layout.
  • startTime is ISO 8601 on the way out, regardless of whatever shape the underlying feed carried internally (a live feed's timestamp can be seconds or milliseconds; a host-fed event may already be ISO 8601). Parse it as a normal Date on your side.

Absent fields are omitted, not null ​

A field the widget has no value for is left off the object entirely rather than set to null. That means you can spread an item straight into a JSON-LD record without a pass to strip null keys first:

js
const jsonLd = { '@type': 'SportsEvent', ...item }

A worked example: SportsEvent JSON-LD ​

This listener turns every 'sports-event' emission into SportsEventJSON-LD and writes it into <head>. The part that matters most is the replace, not the mapping: see the warning right after it.

js
function toJsonLd(item) {
  const entry = {
    '@context': 'https://schema.org',
    '@type': 'SportsEvent',
    identifier: item.id,
  }

  if (item.homeTeam && item.awayTeam) {
    entry.name = `${item.homeTeam} vs ${item.awayTeam}`
    entry.competitor = [
      { '@type': 'SportsTeam', name: item.homeTeam },
      { '@type': 'SportsTeam', name: item.awayTeam },
    ]
  }
  if (item.competition) {
    entry.superEvent = { '@type': 'SportsEvent', name: item.competition }
  }
  if (item.startTime) entry.startDate = item.startTime
  if (item.venueName) entry.location = { '@type': 'Place', name: item.venueName }
  if (item.isLive != null) {
    entry.eventStatus = item.isLive
      ? 'https://schema.org/EventMovedOnline' // pick whatever status your schema needs
      : 'https://schema.org/EventScheduled'
  }

  return entry
}

// Key structured data on the EMITTING ELEMENT (`e.target`), never on
// `widget` alone: several instances of the same widget on one page is
// normal (a "Live" and an "Upcoming" <goma-events-horizontal>, say), and
// every instance reports the same `widget` string. Keying on that string
// alone means whichever instance resolves second wipes the first
// instance's tags, silently dropping half the page's fixtures. A WeakMap
// hands out a stable per-element key without needing the host to have
// assigned the element an id.
let seoKeySeq = 0
const seoKeys = new WeakMap()
function keyFor(el) {
  if (!seoKeys.has(el)) seoKeys.set(el, `seo-${++seoKeySeq}`)
  return seoKeys.get(el)
}

document.addEventListener('goma:seo-data', (e) => {
  const { type, items } = e.detail
  if (type !== 'sports-event') return

  const key = keyFor(e.target)

  // REPLACE this element's structured data: never append to it.
  document
    .querySelectorAll(`script[data-goma-seo="${key}"]`)
    .forEach((el) => el.remove())

  items.forEach((item, i) => {
    const script = document.createElement('script')
    script.type = 'application/ld+json'
    script.dataset.gomaSeo = key
    script.dataset.gomaSeoIndex = String(i)
    script.textContent = JSON.stringify(toJsonLd(item))
    document.head.appendChild(script)
  })
})

Replace on every event. Never append

This is the one rule in this document that will bite you if you skip it. goma:seo-data fires again whenever an item's identity changes, and. By design. It can also fire again with the same payload: closing and reopening <goma-competitions-list>'s drill-in detail for the same competition tears the emitter down and rebuilds it with no memory of what it already sent, so the same snapshot goes out a second time. A listener that only ever appends new <script> tags (or pushes into an array it never clears) accumulates duplicate, and eventually stale: structured data on every remount. Always clear what you wrote for a given element before writing its next snapshot, the way the listener above removes its own <script> tags first.

Which widgets emit it ​

Eight widgets emit goma:seo-data:

WidgettypeNotes
<goma-events-horizontal>'sports-event'One item per fixture currently in the feed.
<goma-game-details>'sports-event'One item: the event currently shown.
<goma-hero-card>'sports-event'One item: the featured event, from any of its intake modes.
<goma-highlight-card>'sports-event'One item: suppressed while auto mode is still resolving the event off the feed.
<goma-pros-choice>'sports-event'One item: same auto-mode suppression as <goma-highlight-card>.
<goma-multi-banners>'sports-event'One item per sport banner that has a resolved event; casino and info banners never contribute.
<goma-competitions-list>'sports-event'Only from the drill-in detail view (drillInOnSelect): the region-accordion list itself shows competition taxonomy, not fixtures, which this contract doesn't cover.
<goma-casino-games>'casino-game'One item per game in the host-fed games array.

What deliberately does not emit it ​

Every other widget in the catalogue is either not identity-bearing content (a sport picker, a region pill row, a time slider, nothing a crawler would index) or carries a signed-in user's own data. The second group is a deliberate boundary, not a gap to be filled later: <goma-betslip-floating> and <goma-betslip-sidebar> hold one person's active selections, <goma-favorites> holds one person's saved competitions and events, <goma-my-bets> holds one person's bet history, and <goma-live-chat> and <goma-messenger> hold private conversation content. None of that belongs in a public search index, so none of it is on this contract's surface: publishing it would be the wrong outcome, not an oversight.

goma:seo-data gets your page the data it needs to write correct structured markup. It does not, by itself, make that markup visible to a crawler: these widgets render inside Shadow DOM after the page's own JavaScript runs, and a crawler that doesn't execute JavaScript, or gives up before your widgets finish resolving their data, never sees a <script> tag you added at runtime either. If search visibility matters for a page built on GomaPulse widgets, you still need your own answer to that half of the problem, whether that's server-side rendering, a prerendering step in front of your CDN, or static markup you maintain independently for the pages that matter most. This event removes the "where does the data come from" obstacle; it doesn't remove the "how does a crawler see it" one.

  • Cross-widget wiring: the full goma:* event catalogue this event is part of, and the mechanism (composed: true, a single document/window listener) it shares with every other event on the page.
  • Host-fed data: the closest sibling contract: a plain object shape a host feeds in to a widget, the same way this page's payload comes back out.
  • Widget reference: each emitting widget's own page has an Events-section entry for goma:seo-data alongside its other events.