Skip to content

Commit 6f40e78

Browse files
hhkaosclaude
andcommitted
feat(publish): add the Broadcast console and @opentechevents/export-jsonld
Implements the schema.org channel of the publish tool (OpenTechEvents/opentechevents-spec#11) inside the wider distribution assistant that spec#12 describes. packages/export-jsonld converts valid OTE events to schema.org/Event JSON-LD: a standalone event, a feed as a @graph, a listing as an ItemList, and the pasteable <script type="application/ld+json"> block, plus an ote-export-jsonld CLI. Pure and deterministic, no network or clock, browser-safe. Wall clock + IANA zone become an ISO 8601 offset via Intl (a tz lookup, not invented data); `tentative` emits no eventStatus because schema.org has no equivalent and claiming EventScheduled would advertise an unconfirmed event as confirmed; a `venue` that is a URL becomes a VirtualLocation rather than a Place whose address is a link. apps/publish presents twelve channels grouped by destination, three of them working today (SEO snippet, embeddable widget, calendar/RSS links). The planned ones state what they will produce, what they will never do, and which of the organizer's own fields are already filled — no fake previews, no dead buttons. isOnlineOnly drives an explicit note that Google's event rich results need a physical location, so an online-only event is never eligible however it is marked up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 717386f commit 6f40e78

36 files changed

Lines changed: 4218 additions & 6 deletions

.github/workflows/deploy-tools.yml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Deploys the central tools site to THIS repo's GitHub Pages: the editor,
2-
# feed previewer and embeddable widget are served under /editor, /preview
3-
# and /embed (matching the tools.opentechevents.org/<tool>?repo=… URLs from
4-
# DESIGN.md; /import and /publish will join them in later phases).
2+
# feed previewer, embeddable widget and publish tool are served under
3+
# /editor, /preview, /embed and /publish (matching the
4+
# tools.opentechevents.org/<tool>?repo=… URLs from DESIGN.md; /import will
5+
# join them in a later phase).
56
#
67
# One-time prerequisite: Settings → Pages → Source = "GitHub Actions".
78
# Custom domain (tools.opentechevents.org) is configured there too, later —
@@ -37,9 +38,10 @@ jobs:
3738

3839
- name: Assemble site
3940
run: |
40-
mkdir -p _site/editor _site/preview _site/embed _site/embed/latest
41+
mkdir -p _site/editor _site/preview _site/publish _site/embed _site/embed/latest
4142
cp -R apps/editor/dist/. _site/editor/
4243
cp -R apps/preview/dist/. _site/preview/
44+
cp -R apps/publish/dist/. _site/publish/
4345
if [ -d apps/embed/versions ]; then
4446
cp -R apps/embed/versions/. _site/embed/
4547
fi
@@ -59,6 +61,7 @@ jobs:
5961
<ul>
6062
<li><a href="./editor/">Event editor</a> — create and edit OTE events without writing JSON</li>
6163
<li><a href="./preview/">Feed previewer</a> — inspect generated JSON, ICS and RSS exports</li>
64+
<li><a href="./publish/">Broadcast</a> — publish your events everywhere: structured data, widget, directories, posts</li>
6265
<li><a href="./embed/">Embeddable widget</a> — &lt;ote-events&gt;: drop an OTE feed into any website</li>
6366
</ul>
6467
</html>

CONTRIBUTING.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ that depends on another. Build first and it goes away — nothing is broken.
2727
| --- | --- | --- |
2828
| `packages/validate` | Event/Feed validation against the OTE JSON Schema | vitest + fixtures |
2929
| `packages/build-feed` | Assembles `events/*.json` + `ote.config.json``feed.json` | vitest + fixtures |
30-
| `packages/export-ics` / `export-rss` | `feed.json` → ICS / RSS | vitest + fixtures |
30+
| `packages/export-ics` / `export-rss` / `export-jsonld` | `feed.json` → ICS / RSS / schema.org JSON-LD | vitest + fixtures |
3131
| `apps/editor` | Static web editor (form → event JSON → issue/PR links) | vitest for `src/lib/`; UI by hand |
3232
| `apps/preview` | Static feed previewer (`feed.json`, `feed.ics`, `feed.xml` → readable tabs) | typecheck + UI by hand |
33+
| `apps/publish` | "Broadcast" console (feed → per-channel output; schema.org, widget and subscribe links so far) | vitest for `src/lib/`; UI by hand |
3334
| `.github/workflows` | CI, reusable workflows for forks, deploys, npm publish | see below |
3435

3536
## Everyday commands (repo root)
@@ -59,6 +60,7 @@ node packages/validate/dist/bin.js packages/build-feed/fixtures/valid
5960
node packages/build-feed/dist/bin.js packages/build-feed/fixtures/valid --out /tmp/ote-out
6061
node packages/export-ics/dist/bin.js /tmp/ote-out/feed.json
6162
node packages/export-rss/dist/bin.js /tmp/ote-out/feed.json
63+
node packages/export-jsonld/dist/bin.js /tmp/ote-out/feed.json
6264
```
6365

6466
### Regenerating the embedded schemas (`packages/validate`)

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Design rationale lives in [DESIGN.md](DESIGN.md); the spec lives in
1919
| [`@opentechevents/validate`](packages/validate/) | Validates OTE Event/Feed documents against the vendored v0.3 JSON Schema. |
2020
| [`@opentechevents/export-ics`](packages/export-ics/) | Valid OTE Feed → iCalendar (RFC 5545). |
2121
| [`@opentechevents/export-rss`](packages/export-rss/) | Valid OTE Feed → RSS 2.0. |
22+
| [`@opentechevents/export-jsonld`](packages/export-jsonld/) | Valid OTE Feed/Event → schema.org `Event` JSON-LD (SEO structured data). |
2223
| [`@opentechevents/import-ics`](packages/import-ics/) | iCalendar (`.ics`) → partial OTE event documents (review-and-complete). |
2324
| [`@opentechevents/import-jsonld`](packages/import-jsonld/) | schema.org Event JSON-LD in an HTML page → partial OTE event documents. |
2425
| [`@opentechevents/build-feed`](packages/build-feed/) | `events/*.json` + `ote.config.json` → validated `feed.json` + `feed.ics` + `feed.xml`. |
@@ -36,10 +37,11 @@ improvements can be traced package by package.
3637
| --- | --- |
3738
| [`editor`](apps/editor/) | Static web editor for OTE events: form → event JSON → prefilled issue or direct edit. |
3839
| [`preview`](apps/preview/) | Static feed previewer for OTE organizer forks. |
40+
| [`publish`](apps/publish/) | "Broadcast" console: one event → every channel it can be published to. schema.org snippet, widget and subscribe links work today; directories, newsletters and social posts are declared and unbuilt. |
3941
| [`embed`](apps/embed/) | Embeddable `<ote-events>` web component: drop an OTE feed into any website. |
4042
| [`dashboard-checks`](apps/dashboard-checks/) | Client-side setup checks + template-update banner for OTE organizer dashboards. |
4143

42-
`editor`, `preview` and `embed` are built and deployed together by
44+
`editor`, `preview`, `publish` and `embed` are built and deployed together by
4345
`deploy-tools.yml`; `dashboard-checks.js` is served as a standalone file.
4446
Once the `tools.opentechevents.org` custom domain is configured (see
4547
`.github/workflows/deploy-tools.yml`), they're reachable at

apps/publish/CLAUDE.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# apps/publish
2+
3+
The `publish` tool from DESIGN.md's phase 3, presented as **Broadcast**: one
4+
event (or a whole feed) turned into whatever every other platform wants. Its
5+
scope is
6+
[opentechevents-spec#12](https://github.com/OpenTechEvents/opentechevents-spec/issues/12)
7+
— GitHub directories (prefilled issues and PR drafts), newsletters, contact
8+
forms, social posts, platforms with no open API — plus
9+
[spec#11](https://github.com/OpenTechEvents/opentechevents-spec/issues/11),
10+
the schema.org snippet, which is the first channel that actually works.
11+
12+
Same build shape as `apps/editor`/`apps/preview`: vanilla TypeScript, one
13+
esbuild entry point, static `index.html`/`styles.css` copied into `dist/`
14+
**once** at `pnpm dev` startup (re-run the copy after editing either).
15+
16+
Non-goals inherited from spec#12, worth keeping in view because every future
17+
channel will be tempted by them: no automatic background publishing, no
18+
direct Meetup/LinkedIn/Eventbrite integrations, no third-party credentials.
19+
The organizer reviews and submits every output by hand.
20+
21+
## The page is a channel rail plus a stage, and the rail is the pitch
22+
23+
Most channels do not exist yet. The page still shows all twelve, grouped, with
24+
a status pill each (`ready` / `planned` / `idea`) — because an organizer
25+
deciding whether OTE is worth adopting is really asking "where can this take
26+
my events?", and a page showing only the one finished channel answers that
27+
question wrongly.
28+
29+
That makes the honesty of the placeholders load-bearing. `src/lib/channels.ts`
30+
is plain data; a `planned` card states what it will produce, what it will
31+
never do, and offers two real actions (follow the issue, build it). It never
32+
renders a fake preview or a disabled button that looks live. **If you add a
33+
channel, add it as data first** — status, summary, `produces`, `accepts`
34+
and only give it a panel in `main.ts` when it genuinely produces something.
35+
36+
## Placeholders earn their space with the organizer's own data
37+
38+
Under "What your event already has", a planned channel lists the fields every
39+
destination asks for and whether this event carries them
40+
(`src/lib/event-readiness.ts`). That turns a promise into work the organizer
41+
can do **today**, in the editor, before any channel ships — and it is the
42+
honest version of a preview: real data, no invented output.
43+
44+
## The widget previews run the real widget, not a picture
45+
46+
The "Embeddable widget" and "Calendar & RSS links" panels mount a live
47+
`<ote-events>` / `<ote-subscribe>` **loaded from the same versioned asset the
48+
snippet names** (`src/lib/preview.ts`): the sibling path on this deployment
49+
first (`deploy-tools.yml` publishes both apps to one Pages site, so it is the
50+
identical file, same origin), the absolute tools URL second. Deliberately not
51+
a bundled copy of the widget source — that could drift from what a visitor's
52+
browser will actually fetch, and "this is exactly what you are embedding" is
53+
the entire value of the pane.
54+
55+
Both candidates can fail (offline, or a version not deployed yet). The pane
56+
then says so in words; it never leaves an empty rectangle that reads as a
57+
broken widget. `pnpm dev` copies `apps/embed/versions/v<version>/` into
58+
`dist/embed/` so previews work offline — **dev only**, because production
59+
already serves the canonical `/embed/` assets and a second copy would be one
60+
more thing to go stale.
61+
62+
The subscribe panel also HEAD-checks `feed.ics` and `feed.xml`. A fork whose
63+
export step has not run has a perfectly valid `feed.json` and two dead links;
64+
better found here than by the first person who subscribes.
65+
66+
## The widget panels are a playground, in place
67+
68+
Layout, theme, card width, a cap on how many events, past events — each
69+
control re-renders **both** the live preview and the snippet, so what an
70+
organizer tunes is exactly what they copy. Only non-default attributes are
71+
emitted (`src/lib/site-snippets.ts`): a snippet that spells out every default
72+
reads as configuration to maintain, and pins behaviour nobody asked to pin.
73+
The full attribute surface still lives in the embed playground, linked at the
74+
bottom of the panel — this is the short path, not a second implementation of
75+
it.
76+
77+
Two traps worth keeping in mind, both found by an organizer on a real feed:
78+
79+
- **`<ote-subscribe>` takes `feed-ics`/`feed-rss`/`feed-json`, not `feed`.**
80+
It never fetches anything; with no URL attributes there is nothing to link
81+
to, and the trigger renders an empty menu that looks exactly like a
82+
disabled button. There is a test for it.
83+
- **The widget renders a feed, not an event** — unless `event-id` says
84+
otherwise. With an event selected in the header, the panel offers
85+
“Only «that event»” (on by default) and emits
86+
`event-id="<the event's OTE id>"`, added in embed 0.7.0 for exactly this.
87+
It still points at the feed URL, so the card follows later edits, and the
88+
widget keeps rendering it after the date passes — which is what an event's
89+
own page needs. Unchecked, it is the whole feed again and `limit` is the
90+
answer to "show fewer".
91+
92+
## Meetup vs conference is a hint, never a gate
93+
94+
Directories are picky in exactly this dimension: confs.tech and
95+
developers.events take conferences, not a monthly meetup. `guessProfile`
96+
(`src/lib/event-profile.ts`) reads signals from the event itself — a CFP,
97+
more than one day, paid tickets — since OTE has no `type` field and inventing
98+
one is not this tool's call.
99+
100+
The guess is **shown with its reasons**, overridable from the header, and only
101+
ever reorders and annotates: an unfit channel sinks to the bottom of its group
102+
and says "conferences only", but it is never hidden and never disabled. An
103+
organizer learning that confs.tech will not take their meetup is useful; a
104+
channel silently vanishing is not.
105+
106+
## An invalid feed stops everything, before any channel
107+
108+
`main.ts` runs `validateFeed` first and shows the errors *instead of* the
109+
console. Channels are pure mappings, not validators: broadcasting from a
110+
broken feed would carry the same errors to every destination at once. Don't
111+
"improve" this into best-effort output with a warning.
112+
113+
## Online-only events are not eligible for Google rich results — say so
114+
115+
Google requires a physical location for event rich results ("Virtual
116+
experiences that have no real-world component aren't supported"), so an
117+
online-only event always comes back from the Rich Results Test as "no
118+
eligible item", however good the markup is. The note under that link
119+
(`eligibilityNote` in `src/lib/snippet.ts`, backed by `isOnlineOnly` in
120+
`@opentechevents/export-jsonld`) exists because the link itself sets an
121+
expectation this tool would otherwise break — an organizer seeing red there
122+
reads it as our bug, not as Google policy. Keep the note next to whatever
123+
sends them to a validator.
124+
125+
## Mapping logic lives in packages, not here
126+
127+
`schema-org` is a thin call into `@opentechevents/export-jsonld`; the widget
128+
and subscribe snippets are string building in `src/lib/site-snippets.ts`
129+
against assets `deploy-tools.yml` already publishes. Any question about how
130+
an OTE field becomes someone else's field belongs in the package that owns
131+
the mapping — the same code has to serve the SSR renderer (issue #58) and
132+
every future channel, not just this page.
133+
134+
`site-snippets.ts` pins the widget version from `apps/embed/package.json`,
135+
injected by `build.mjs` as `__EMBED_VERSION__`. Pinned, never `/latest/`: a
136+
widget that changes behaviour on someone else's site without them touching
137+
anything is a bad trade (see `apps/embed/CLAUDE.md`). `vitest.config.ts`
138+
defines the same global for tests, which don't go through esbuild.
139+
140+
## Feed source: `?repo=` first, `?feed=` as the escape hatch
141+
142+
`?repo=owner/name` is the DESIGN.md convention every central tool follows
143+
(the fork's dashboard already links here that way, at
144+
`/publish?repo=…`). For a repo, the fork's GitHub Pages URL is tried first
145+
and `raw.githubusercontent.com` on the default branch second — Pages may not
146+
be enabled yet while `feed.json` is already committed. `?feed=<url>` covers
147+
feeds published anywhere else; non-http(s) URLs are rejected.

apps/publish/build.mjs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2+
3+
import * as esbuild from "esbuild";
4+
5+
const serve = process.argv.includes("--serve");
6+
7+
// The embed widget's version, read from the app that owns it. The publish
8+
// tool hands out `<script src=".../embed/v<version>/…">` snippets, and those
9+
// must point at a version that deploy-tools.yml actually publishes — reading
10+
// it here means the snippet can never drift from the deployed assets.
11+
const embedVersion = JSON.parse(readFileSync("../embed/package.json", "utf8")).version;
12+
13+
const options = {
14+
entryPoints: ["src/main.ts"],
15+
bundle: true,
16+
format: "esm",
17+
platform: "browser",
18+
target: "es2022",
19+
outfile: "dist/main.js",
20+
sourcemap: true,
21+
minify: !serve,
22+
logLevel: "info",
23+
define: { __EMBED_VERSION__: JSON.stringify(embedVersion) },
24+
};
25+
26+
mkdirSync("dist", { recursive: true });
27+
for (const file of ["index.html", "styles.css"]) {
28+
copyFileSync(file, `dist/${file}`);
29+
}
30+
31+
// Dev-only: the preview panes load the widget from `../embed/v<version>/`,
32+
// which exists on the deployed Pages site but not next to a local dev
33+
// server. Copying the built widget there makes `pnpm dev` show real
34+
// previews offline. Never done for a real build — production serves the
35+
// canonical /embed/ assets, and a second copy would be one more thing to
36+
// go stale.
37+
if (serve) {
38+
const embedDist = `../embed/versions/v${embedVersion}`;
39+
if (existsSync(embedDist)) {
40+
cpSync(embedDist, `dist/embed/v${embedVersion}`, { recursive: true });
41+
}
42+
const ctx = await esbuild.context(options);
43+
await ctx.watch();
44+
const port = Number(process.env.PORT) || undefined;
45+
const server = await ctx.serve({
46+
servedir: "dist",
47+
...(port !== undefined && { port }),
48+
});
49+
console.log(`Publish tool running at http://localhost:${server.port}/`);
50+
} else {
51+
await esbuild.build(options);
52+
}

apps/publish/index.html

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>OTE publish — broadcast your events</title>
7+
<link rel="stylesheet" href="./styles.css" />
8+
</head>
9+
<body>
10+
<header>
11+
<div class="title-row">
12+
<h1>Broadcast</h1>
13+
<span class="pill pill-beta">Work in progress</span>
14+
</div>
15+
<p class="tagline">
16+
Your events are already written once, as open data. This turns them
17+
into everything every other platform wants — snippets, listings,
18+
posts, submissions — without retyping any of it.
19+
</p>
20+
<p id="source-banner"></p>
21+
</header>
22+
23+
<section id="message" class="panel" hidden></section>
24+
25+
<main id="tool" hidden>
26+
<section class="context panel">
27+
<div class="context-field">
28+
<label for="event-select">Publishing</label>
29+
<select id="event-select"></select>
30+
</div>
31+
<div class="context-field">
32+
<label for="profile-select">Treat as</label>
33+
<select id="profile-select">
34+
<option value="auto">Detect automatically</option>
35+
<option value="meetup">Meetup</option>
36+
<option value="conference">Conference</option>
37+
</select>
38+
</div>
39+
<p id="profile-reason" class="context-reason"></p>
40+
</section>
41+
42+
<div class="console">
43+
<nav id="rail" class="rail" aria-label="Channels"></nav>
44+
<section id="stage" class="stage" aria-live="polite"></section>
45+
</div>
46+
</main>
47+
48+
<footer>
49+
<a
50+
href="https://github.com/OpenTechEvents/ote-tools/issues/new"
51+
target="_blank"
52+
rel="noopener"
53+
>
54+
Report a problem
55+
</a>
56+
</footer>
57+
58+
<script type="module" src="./main.js"></script>
59+
</body>
60+
</html>

apps/publish/package.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@opentechevents/publish",
3+
"version": "0.1.0",
4+
"description": "Static publish tool: turn an OTE feed into a pasteable schema.org JSON-LD snippet",
5+
"license": "MIT",
6+
"private": true,
7+
"type": "module",
8+
"scripts": {
9+
"build": "node build.mjs",
10+
"dev": "node build.mjs --serve",
11+
"test": "vitest run",
12+
"typecheck": "tsc -p tsconfig.json --noEmit"
13+
},
14+
"dependencies": {
15+
"@opentechevents/export-jsonld": "workspace:*",
16+
"@opentechevents/validate": "workspace:*"
17+
},
18+
"devDependencies": {
19+
"@types/node": "^22.0.0",
20+
"esbuild": "^0.28.1",
21+
"typescript": "^6.0.3",
22+
"vitest": "^4.1.10"
23+
}
24+
}

apps/publish/src/globals.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Injected by build.mjs from apps/embed/package.json's `version`, so the
3+
* widget snippets this tool hands out always point at a version that is
4+
* actually deployed under /embed/v<version>/.
5+
*/
6+
declare const __EMBED_VERSION__: string;

0 commit comments

Comments
 (0)