Skip to content

Server-side rendering for <ote-events>: framework-agnostic HTML renderer with h-event and JSON-LD #58

Description

@hhkaos

Problem

<ote-events> renders entirely at runtime into a Shadow DOM
(apps/embed/src/element.ts), from a feed fetched by JavaScript. The raw HTML a
host page serves contains nothing but the empty custom element:

<ote-events feed="https://example.org/feed.json"></ote-events>

Googlebot executes JavaScript and flattens shadow trees, so it does index the
events. Nothing else reliably does: microformats2 parsers, social unfurlers, LLM
crawlers and other search engines fetch the raw HTML and see an empty element.

For an organizer who embeds the widget on their own site — the case that
actually matters for SEO — their events are invisible to every consumer except
Google.

Non-goals

Do not break Shadow DOM encapsulation. A render-mode="light" variant was
considered and rejected: it would expose the widget to the host page's CSS (and
vice versa), forcing every selector in apps/embed/src/theme.css.ts (~1000
lines) to be prefixed, and it still wouldn't help raw-HTML consumers because the
content would remain JavaScript-generated.

This issue is not about the fork's own GitHub Pages site. The ote-template
dashboard (docs/index.html) is a navigation hub — it links to the feed formats
and the central tools, and does not list events at all. Emitting structured data
there would mean structured data with no matching visible content. The SEO
target is the organizer's real website.

Proposal: one framework-agnostic package

A React component plus a Vue component plus a Svelte component means N packages
duplicating the render logic, with a version matrix that drifts — the same
argument DESIGN.md uses to reject connectors that ship their own UI.

Instead, a single package with zero framework dependencies that returns an
HTML string:

// @opentechevents/embed-ssr (name TBD)
renderEventsHtml(feed, options): string;

Consumers:

Environment Integration
Next.js dangerouslySetInnerHTML in a Server Component
Nuxt v-html
Astro set:html
11ty / Hugo / any static build write the string into the template
Plain HTML keep using <script type="module"> + <ote-events> as today

The existing web component stays as progressive enhancement on top: it hydrates
the server-rendered markup — modal, layout="calendar", custom event actions —
rather than replacing it.

What this buys, concretely

  • h-event microformats in the server-rendered output: h-event, p-name,
    dt-start/dt-end on <time datetime>, p-location, e-description,
    u-url. These are only worth adding once the markup exists in the raw HTML —
    mf2 parsers do not execute JavaScript, and do not descend into shadow trees or
    <template> elements.
  • schema.org/Event JSON-LD emitted alongside the markup, so the structured
    data always matches visible content.

Relationship to the JSON-LD converter

OpenTechEvents/opentechevents-spec#11 defines an OTE → schema.org/Event
JSON-LD converter producing a ready-to-paste <script type="application/ld+json">
block, surfaced as a dashboard tool for manual copy-paste.

That converter and this renderer want the same code. Suggested split, following
DESIGN.md's "connectors are pure npm packages, no UI" convention (and matching
the existing packages/import-jsonld in the inverse direction):

  • packages/export-jsonld — pure OTE → JSON-LD function. Consumed by the
    dashboard copy-paste tool and by the SSR renderer.
  • the SSR renderer — HTML markup with h-event, embedding the JSON-LD from above.

The copy-paste tool covers organizers who cannot run a build step; the SSR
renderer covers those on Next/Nuxt/Astro. Neither replaces the other.

Open questions

  1. How to reuse the existing renderer. apps/embed/src/render.ts's
    renderWidget() builds DOM nodes via the el() helper in src/dom.ts, not
    strings. Either refactor to a string-emitting renderer shared by both paths,
    or run the current renderer server-side under linkedom/jsdom. The first is
    cleaner and dependency-free; the second is far less work up front. This is the
    main technical decision of the issue.
  2. Hydration contract. How does the web component detect pre-rendered markup
    and adopt it instead of re-fetching the feed and re-rendering? Does the server
    output carry the feed payload, or does the client re-fetch and diff?
  3. Styling of the server-rendered pass. The pre-hydration HTML lives in the
    light DOM by necessity. Does it ship unstyled (content-first, styled once the
    component hydrates), or with a minimal inline style baseline?
  4. Package naming and scope@opentechevents/embed-ssr vs something else,
    and whether it is published or workspace-internal.
  5. Versioning. This is public API surface for apps/embed; the SemVer and
    release checklist in apps/embed/CLAUDE.md applies.

Acceptance criteria (draft)

  • A framework-agnostic package renders an OTE feed to an HTML string with no
    framework dependency and no DOM requirement.
  • Output includes valid h-event microformats and a matching
    schema.org/Event JSON-LD block.
  • Output validates against Google's Rich Results Test and an mf2 parser.
  • <ote-events> hydrates that output without a visible re-render flash, and
    without regressing any behavior covered by apps/embed/test/element.test.ts.
  • A worked Next.js or Astro example is documented.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesttool:embedEmbeddable widget (apps/embed)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions