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
- 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.
- 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?
- 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?
- Package naming and scope —
@opentechevents/embed-ssr vs something else,
and whether it is published or workspace-internal.
- 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.
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 ahost page serves contains nothing but the empty custom element:
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 wasconsidered 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(~1000lines) 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-templatedashboard (
docs/index.html) is a navigation hub — it links to the feed formatsand 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.mduses to reject connectors that ship their own UI.Instead, a single package with zero framework dependencies that returns an
HTML string:
Consumers:
dangerouslySetInnerHTMLin a Server Componentv-htmlset:html<script type="module">+<ote-events>as todayThe 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,p-name,dt-start/dt-endon<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.data always matches visible content.
Relationship to the JSON-LD converter
OpenTechEvents/opentechevents-spec#11defines an OTE →schema.org/EventJSON-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 matchingthe existing
packages/import-jsonldin the inverse direction):packages/export-jsonld— pure OTE → JSON-LD function. Consumed by thedashboard copy-paste tool and by the SSR renderer.
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
apps/embed/src/render.ts'srenderWidget()builds DOM nodes via theel()helper insrc/dom.ts, notstrings. Either refactor to a string-emitting renderer shared by both paths,
or run the current renderer server-side under
linkedom/jsdom. The first iscleaner and dependency-free; the second is far less work up front. This is the
main technical decision of the issue.
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?
light DOM by necessity. Does it ship unstyled (content-first, styled once the
component hydrates), or with a minimal inline style baseline?
@opentechevents/embed-ssrvs something else,and whether it is published or workspace-internal.
apps/embed; the SemVer andrelease checklist in
apps/embed/CLAUDE.mdapplies.Acceptance criteria (draft)
framework dependency and no DOM requirement.
schema.org/EventJSON-LD block.<ote-events>hydrates that output without a visible re-render flash, andwithout regressing any behavior covered by
apps/embed/test/element.test.ts.