diff --git a/apps/web/src/lib/components/CreateEventModal.svelte b/apps/web/src/lib/components/CreateEventModal.svelte
index 3e6543f..66a6986 100644
--- a/apps/web/src/lib/components/CreateEventModal.svelte
+++ b/apps/web/src/lib/components/CreateEventModal.svelte
@@ -181,7 +181,7 @@
Import event from somewhere else
- Paste a Luma, Meetup, Eventbrite, Partiful or Resident Advisor link.
+ Paste a Luma, Meetup, Eventbrite, Partiful, guild.host or Resident Advisor link.
diff --git a/apps/web/src/lib/import/guild.ts b/apps/web/src/lib/import/guild.ts
new file mode 100644
index 0000000..87c7291
--- /dev/null
+++ b/apps/web/src/lib/import/guild.ts
@@ -0,0 +1,84 @@
+import { FETCH_HEADERS, fetchImageAsDataUrl } from './http';
+import type { EventImporter } from './types';
+import * as v from 'valibot';
+
+const GUILD_API_BASE = 'https://guild.host/api/next';
+const GUILD_EVENT_REGEX = /^\/events\/([^/]+)/;
+
+const GuildEventSchema = v.object({
+ slug: v.string(),
+ fullUrl: v.pipe(v.string(), v.url()),
+ name: v.string(),
+ description: v.string(),
+ startAt: v.string(),
+ endAt: v.string(),
+ timeZone: v.string(),
+ hasVenue: v.boolean(),
+ hasExternalUrl: v.boolean(),
+ createdAt: v.pipe(v.string(), v.toDate()),
+ uploadedSocialCard: v.optional(
+ v.object({
+ url: v.pipe(v.string(), v.url())
+ })
+ ),
+ generatedSocialCardURL: v.pipe(v.string(), v.url())
+});
+
+function extractGuildEventSlug(rawUrl: string) {
+ const url = URL.parse(rawUrl);
+ if (!url || (url.hostname !== 'guild.host' && url.hostname !== 'www.guild.host')) return null;
+ const m = url.pathname.match(GUILD_EVENT_REGEX);
+ if (!m) return null;
+ return m[1];
+}
+
+/**
+ * Importer for Guild (guild.host). Events are fetched via the Guild API
+ * at https://guild.host/api/next/events/.
+ *
+ * Note: The Guild API does not expose venue/location data. If the user wants
+ * to accept RSVPs on atmo, they will need to add the location manually after
+ * import.
+ */
+export const guildImporter: EventImporter = {
+ name: 'guild',
+ accept(ctx) {
+ return !!extractGuildEventSlug(ctx.url);
+ },
+ async parseData(ctx) {
+ const slug = extractGuildEventSlug(ctx.url);
+ if (!slug) return null;
+
+ const res = await fetch(`${GUILD_API_BASE}/events/${slug}`, {
+ headers: {
+ ...FETCH_HEADERS,
+ Accept: 'application/json'
+ }
+ });
+
+ if (!res.ok) {
+ throw new Error('failed to fetch guild event', { cause: res });
+ }
+
+ const data = v.parse(GuildEventSchema, await res.json());
+
+ return {
+ source: data.fullUrl,
+ name: data.name,
+ description: data.description,
+ timezone: data.timeZone,
+ startsAt: data.startAt,
+ endsAt: data.endAt,
+ imageDataUrl: await fetchImageAsDataUrl(
+ data.uploadedSocialCard?.url ?? data.generatedSocialCardURL
+ ),
+ links: [{ uri: data.fullUrl, name: 'Event page' }],
+ mode:
+ data.hasExternalUrl && data.hasVenue
+ ? 'hybrid'
+ : data.hasExternalUrl
+ ? 'virtual'
+ : 'inperson'
+ };
+ }
+};
diff --git a/apps/web/src/lib/import/index.ts b/apps/web/src/lib/import/index.ts
index 7d344f0..3c1a644 100644
--- a/apps/web/src/lib/import/index.ts
+++ b/apps/web/src/lib/import/index.ts
@@ -4,13 +4,19 @@ import { FETCH_HEADERS, MAX_BYTES, readLimited } from './http';
import { racoImporter } from './raco';
import { icalImporter } from './ical';
import { webpageImporter } from './webpage';
+import { guildImporter } from './guild';
/**
* Registry of source-specific importers, tried in order. Put the most specific
* (host-matched, no fetch) first and the generic HTML fallback last. To support
* a new platform, add a module exporting an `EventImporter` and list it here.
*/
-export const importers: EventImporter[] = [racoImporter, icalImporter, webpageImporter];
+export const importers: EventImporter[] = [
+ racoImporter,
+ icalImporter,
+ guildImporter,
+ webpageImporter
+];
/**
* Run the import pipeline for a URL. The first importer whose `accept()` returns
--
2.51.2
From 50d93dba40a30c11f5c12528a8c3f3bd843d8e64 Mon Sep 17 00:00:00 2001
From: "Willow (GHOST)"
Date: Sun, 5 Jul 2026 04:44:00 +0100
Subject: [PATCH 2/5] fix: re-order importers
Co-authored-by: Tom Scanlan <3408465+tompscanlan@users.noreply.github.com>
---
apps/web/src/lib/import/index.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/lib/import/index.ts b/apps/web/src/lib/import/index.ts
index 3c1a644..c44caf4 100644
--- a/apps/web/src/lib/import/index.ts
+++ b/apps/web/src/lib/import/index.ts
@@ -13,8 +13,8 @@ import { guildImporter } from './guild';
*/
export const importers: EventImporter[] = [
racoImporter,
- icalImporter,
guildImporter,
+ icalImporter,
webpageImporter
];
--
2.51.2
From 57017a74b8e0b131904027b8f68919da05f18ca8 Mon Sep 17 00:00:00 2001
From: "Willow (GHOST)"
Date: Sun, 5 Jul 2026 04:47:36 +0100
Subject: [PATCH 3/5] fix: description is optional
Co-authored-by: Tom Scanlan <3408465+tompscanlan@users.noreply.github.com>
---
apps/web/src/lib/import/guild.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/lib/import/guild.ts b/apps/web/src/lib/import/guild.ts
index 87c7291..43c21ec 100644
--- a/apps/web/src/lib/import/guild.ts
+++ b/apps/web/src/lib/import/guild.ts
@@ -9,7 +9,7 @@ const GuildEventSchema = v.object({
slug: v.string(),
fullUrl: v.pipe(v.string(), v.url()),
name: v.string(),
- description: v.string(),
+ description: v.optional(v.string()),
startAt: v.string(),
endAt: v.string(),
timeZone: v.string(),
--
2.51.2
From f5cbb0747c57f2f1de2462b5095b959589223797 Mon Sep 17 00:00:00 2001
From: Tom Scanlan
Date: Tue, 7 Jul 2026 11:38:38 -0400
Subject: [PATCH 4/5] fix(import): accept null uploadedSocialCard from the
Guild API
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Guild returns `uploadedSocialCard: null` (not absent) for events with no
uploaded card — e.g. the live event London GraphQL Spring 2026 — which
`v.optional` rejects, throwing and 502-ing the whole import. `v.nullish`
accepts null and undefined; the existing `?? generatedSocialCardURL`
fallback then covers it.
---
apps/web/src/lib/import/guild.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/lib/import/guild.ts b/apps/web/src/lib/import/guild.ts
index 43c21ec..605a79b 100644
--- a/apps/web/src/lib/import/guild.ts
+++ b/apps/web/src/lib/import/guild.ts
@@ -16,7 +16,10 @@ const GuildEventSchema = v.object({
hasVenue: v.boolean(),
hasExternalUrl: v.boolean(),
createdAt: v.pipe(v.string(), v.toDate()),
- uploadedSocialCard: v.optional(
+ // The API sends `null` (not absent) for events with no uploaded card, so use
+ // nullish (accepts null and undefined); parseData then falls back to
+ // generatedSocialCardURL.
+ uploadedSocialCard: v.nullish(
v.object({
url: v.pipe(v.string(), v.url())
})
--
2.51.2
From 43a8cecba5b80b61a88bc0b87493a9e088fda55b Mon Sep 17 00:00:00 2001
From: Tom Scanlan
Date: Tue, 7 Jul 2026 17:02:28 -0400
Subject: [PATCH 5/5] test(import): guild.host importer coverage
Guild-specific tests on top of the shared fetch-mock harness (test-support.ts,
landed in #60): guild.test.ts drives the importer against three captured Guild
API fixtures, and pipeline.test.ts pins guild ahead of the fetch-based
importers so a guild paste routes straight to the JSON API. No live network.
---
.../__fixtures__/guild-london-graphql.json | 1 +
.../__fixtures__/guild-react-summit.json | 1 +
.../__fixtures__/guild-svelte-chicago.json | 1 +
apps/web/src/lib/import/guild.test.ts | 119 ++++++++++++++++++
apps/web/src/lib/import/pipeline.test.ts | 37 ++++++
5 files changed, 159 insertions(+)
create mode 100644 apps/web/src/lib/import/__fixtures__/guild-london-graphql.json
create mode 100644 apps/web/src/lib/import/__fixtures__/guild-react-summit.json
create mode 100644 apps/web/src/lib/import/__fixtures__/guild-svelte-chicago.json
create mode 100644 apps/web/src/lib/import/guild.test.ts
create mode 100644 apps/web/src/lib/import/pipeline.test.ts
diff --git a/apps/web/src/lib/import/__fixtures__/guild-london-graphql.json b/apps/web/src/lib/import/__fixtures__/guild-london-graphql.json
new file mode 100644
index 0000000..5798b87
--- /dev/null
+++ b/apps/web/src/lib/import/__fixtures__/guild-london-graphql.json
@@ -0,0 +1 @@
+{"id":"WyJldmVudHMiLCI5MzA3NTM5Yy0zYTgzLTRlZjMtOTRkMC1mMDUxZjI1MjYzNjkiXQ==","slug":"tujgnp","prettyUrl":"london-graphql-spring-tujgnp","fullUrl":"https://guild.host/events/london-graphql-spring-tujgnp","shortUrl":"https://guild.host/e-tujgnp","name":"London GraphQL Spring 2026","description":"Come and join us for the latest edition of London GraphQL; Two talks on the most up-to-date GraphQL topics interspersed with pizza and networking, and a casual get together afterwards at a local pub.\nApproximate Schedule:\n\n6:00pm - Arrival, networking, pizzas 🍕\n\n6:30pm - Greeting, event overview, code of conduct\n\n6:40pm - **Stitch Happens**, Sam Ransom, Lendable\n\n6:50pm - Pizza and networking break\n\n7:30pm - **Celebrating ten years of PostGraphile**, Benjie Gillam, Graphile\n\n8:00pm - Short break\n\n8:15pm - **Discussion with Benjie,** hosted by Taz Singh, Guild.host \n\n8:30pm - Closing notes, Benjie Gillam, Graphile\n\n9:00pm - Continued networking over drinks 🍻 at a local pub\n\nHosted by Lendable\n\n**NEW Location** Lendable office, Telephone House, 69-77 Paul St, London, EC2A 4NW\n\nCall for Speakers\n\nShow off a project, discuss a proposed feature, share your patterns and solutions\n\nNew and seasoned speakers welcome\n\nMentoring, help and slides review available\n\nFlexible talk format\n\nhttp://tinyurl.com/LondonGraphQLcfp\n\nAll events ran by the GraphQL Foundation adhere to the code of conduct: https://graphql.org/codeofconduct/\n\n(Code of Conduct contact for this event is the MC Benjie, team@graphile.com or any member of the GraphQL TSC)","startAt":"2026-04-14T17:00:00+00:00","endAt":"2026-04-14T20:00:00+00:00","timeZone":"Europe/London","visibility":"LISTED","hasVenue":true,"hasExternalUrl":false,"owner":{"__typename":"Guild","id":"WyJndWlsZHMiLCIyOWVkZjYwMi0xMWFlLTRmMDktODA1NS04OWZiNjI1Y2I5NDYiXQ==","name":"London GraphQL","__isNode":"Guild"},"uploadedSocialCard":null,"generatedSocialCardURL":"https://guild.host/social/event/london-graphql-spring-tujgnp/card.svg","presentations":{"edges":[{"node":{"id":"WyJwcmVzZW50YXRpb25zIiwiZTBmODY3ZjEtMGQ5My00OWVkLWI0NWItZjE4NmVmMWU2N2JlIl0=","slug":"xzztm9","prettyUrl":"stitch-happens-graphql-xzztm9","presenter":null,"presenterFirstName":"Sam","presenterLastName":"Ransom","title":"Stitch Happens (GraphQL Schema Stitching) ","description":"Demonstrating how GraphQL schema stitching can combine two independent services into one cohesive API, enabling clients to query data across both systems through a single schema","videoSourceUrl":""},"cursor":"WyJuYXR1cmFsIiwxXQ=="},{"node":{"id":"WyJwcmVzZW50YXRpb25zIiwiYmIwNTM1NzAtMTExMy00MDhlLWFkOTAtNTQyNjc1Yjk1MmUxIl0=","slug":"cyhe4w","prettyUrl":"discussion-with-benjie-cyhe4w","presenter":{"id":"WyJ1c2VycyIsImE3NDk3ZjY2LTBlMzktNGE1Yy1iMzE4LThhZDUwMjQ5MDY1OCJd","firstName":"Taz","lastName":"Singh"},"presenterFirstName":null,"presenterLastName":null,"title":"Discussion with Benjie","description":"A relaxed discussion and Q&A with GraphQL TSC member Benjie Gillam. There is excitement in the air as Benjie celebrates the 10th anniversary of his open source project PostGraphile. This is your chance to ask Benjie about GraphQL, what are his preferred workflows, PostGraphile V5, the new GraphQL Golden Path or anything at all including his favourite colour or what he had for breakfast!\n\nThe session will be conducted by Taz Singh with participation from the audience.","videoSourceUrl":""},"cursor":"WyJuYXR1cmFsIiwyXQ=="},{"node":{"id":"WyJwcmVzZW50YXRpb25zIiwiNjRiZmJlODUtYjY0Zi00ZDUyLTlkY2MtZDlkMGUxMzA5OGExIl0=","slug":"l462yp","prettyUrl":"celebrating-ten-years-l462yp","presenter":{"id":"WyJ1c2VycyIsIjUwZTQ0NzQzLWRjNDctNDU5MS1hYzlhLWUwZjkxZDZkMzRmNSJd","firstName":"Benjie","lastName":"Gillam"},"presenterFirstName":null,"presenterLastName":null,"title":"Celebrating ten years of PostGraphile","description":"GraphQL celebrated it's 10 year birthday last year, today it's PostGraphile's turn! Hear from maintainer Benjie about the origins of the project, the lessons learned along the way, and how the project has evolved to meet the needs of its users despite being MIT-licensed OSS.","videoSourceUrl":""},"cursor":"WyJuYXR1cmFsIiwzXQ=="}]},"venue":{"address":{"location":{"__typename":"GeometryPoint","geojson":{"type":"Point","coordinates":[-0.084633,51.5249615]}},"id":"WyJhZGRyZXNzZXMiLCJlYWYzNTMxYy02ZmExLTRjYzMtYjJmNi1lZjgzY2IwZTUyYTAiXQ=="},"id":"WyJ2ZW51ZXMiLCI4M2Q2OTM3NS1jNmQ3LTQ3MDItOTMyMy03NzYxOGQxODVkMDIiXQ=="},"createdAt":"2026-03-18T13:54:48.664502","updatedAt":"2026-04-14T11:45:44.244134"}
\ No newline at end of file
diff --git a/apps/web/src/lib/import/__fixtures__/guild-react-summit.json b/apps/web/src/lib/import/__fixtures__/guild-react-summit.json
new file mode 100644
index 0000000..52c470b
--- /dev/null
+++ b/apps/web/src/lib/import/__fixtures__/guild-react-summit.json
@@ -0,0 +1 @@
+{"id":"WyJldmVudHMiLCIyNWY4MzJmNi02YzQwLTRjY2ItYTk0OC1mODdhNmU1MDBjYjUiXQ==","slug":"0kzjnm","prettyUrl":"react-summit-amsterdam-0kzjnm","fullUrl":"https://guild.host/events/react-summit-amsterdam-0kzjnm","shortUrl":"https://guild.host/e-0kzjnm","name":"React Summit Amsterdam 2025","description":"**Join us at the biggest React conference on June 13 & 17, 2025 in Amsterdam & Online!** \n\n[React Summit](https://reactsummit.com/) — the annual conference dedicated to all things React, bringing together thousands of Front-end and Full-stack engineers from around the globe.\n\n**🚨 Important! To secure your spot, make sure to get your ticket through this [LINK](https://reactsummit.com/).**\n\n🎙 **Call for Proposals**\nAre you ready to speak in front of the world’s largest React audience? Submit your talk proposal via our [CFP form](https://docs.google.com/forms/d/e/1FAIpQLSf3hJVEYAj1auSk3lWCke-JSsJbiD0DdKTgQYWHsReadPQAyQ/viewform) and take the stage at the upcoming conference!\n\n**What to expect at the conference:**\n\n🌍 New Venue: Circa Amsterdam – a creative and sustainable space.\n🗣️ 60+ React Experts: Hear from top OSS contributors and industry rising stars.\n🏆 React OSS Awards: Celebrating open-source achievements and collaboration.\n🚤 Amsterdam Vibes: Connect with fellow developers while exploring the city by boat or on foot.\n🎉 React’s Biggest Party: Enjoy a festival atmosphere with activities, music, and more!\n\nThis year we expect the authors and core teams of **Next.js, React 19, React Compiler, Cursor, TypeScript, Tanstack Start, Zustand, React Server Components, Tailwind, Remix & more.**\n\nDon’t miss out on this celebration of React!\n\n👉 Follow us on social media: [Bluesky,](https://bsky.app/profile/reactsummit.gitnation.org) [Twitter (X)](https://twitter.com/reactsummit), [Instagram](https://www.instagram.com/reactsummit/), [Linkedin](https://www.linkedin.com/showcase/reactsummit), [Facebook](https://www.facebook.com/reactsummit/)","startAt":"2025-06-13T06:00:00+00:00","endAt":"2025-06-17T19:00:00+00:00","timeZone":"Europe/Warsaw","visibility":"LISTED","hasVenue":true,"hasExternalUrl":false,"owner":{"__typename":"Guild","id":"WyJndWlsZHMiLCIyMjA4ODQ3Mi05ZmRkLTRjMjgtODdiYy1lNzA0ZTM3Yjc4YjUiXQ==","name":"ReactJS Dublin","__isNode":"Guild"},"uploadedSocialCard":{"url":"https://guild.host/i/523df140-2900-4030-9cc4-290990b0ba1a/png","id":"WyJpbWFnZXMiLCI1MjNkZjE0MC0yOTAwLTQwMzAtOWNjNC0yOTA5OTBiMGJhMWEiXQ=="},"generatedSocialCardURL":"https://guild.host/social/event/react-summit-amsterdam-0kzjnm/card.svg","presentations":{"edges":[]},"venue":{"address":{"location":{"__typename":"GeometryPoint","geojson":{"type":"Point","coordinates":[4.8168455,52.3908459]}},"id":"WyJhZGRyZXNzZXMiLCI2Njk1Y2NiNi04ZGEwLTQzNGItYjMxMi1hOWUxM2M2N2Q1Y2IiXQ=="},"id":"WyJ2ZW51ZXMiLCI0Nzk0YTE0ZC1lNzZjLTQ4MmUtYmU4ZC01NDY5YzBjMDgzMWEiXQ=="},"createdAt":"2025-01-09T12:38:17.386347","updatedAt":"2026-03-09T11:20:44.593302"}
\ No newline at end of file
diff --git a/apps/web/src/lib/import/__fixtures__/guild-svelte-chicago.json b/apps/web/src/lib/import/__fixtures__/guild-svelte-chicago.json
new file mode 100644
index 0000000..726ace3
--- /dev/null
+++ b/apps/web/src/lib/import/__fixtures__/guild-svelte-chicago.json
@@ -0,0 +1 @@
+{"id":"WyJldmVudHMiLCJlNmJhOTk5Ny00YzJmLTQwNzEtOTM4MS1lYjAwYjcwZWI5ZjAiXQ==","slug":"nbdhmo","prettyUrl":"svelte-chicago-march-nbdhmo","fullUrl":"https://guild.host/events/svelte-chicago-march-nbdhmo","shortUrl":"https://guild.host/e-nbdhmo","name":"Svelte Chicago - March 2026","description":"🏙️ **Come hang out with us at Svelte Chicago!**\n\nInterested in Svelte? Join us for pizza, beverages, and networking at the [M1 Finance](https://m1.com) office for the next meeting of the Chicago chapter of Svelte Society!\n\n📄 Web Serialization and SvelteKit: JSON.stringify, Devalue, and Beyond\n\nDevalue is the client-server serialization library used by SvelteKit. In this talk, [Robert Ismo](https://www.linkedin.com/in/robertismo/) will go over why we want serialization libraries, the what and why of devalue, and the current limitations of devalue and other solutions.\n\n🕖 **Agenda**\n\n- Pizza and networking at 5:30pm\n- Introductions at 6:15pm\n- Web Serialization and SvelteKit at 6:30pm\n- More networking\n- At 8pm, you don't have to go home, but you can't stay there\n\nSpecial thanks to [M1 Finance](https://m1.com) for hosting and [Storyblok](https://www.storyblok.com/) for sponsoring!","startAt":"2026-03-24T22:30:00+00:00","endAt":"2026-03-25T01:00:00+00:00","timeZone":"America/Chicago","visibility":"LISTED","hasVenue":true,"hasExternalUrl":false,"owner":{"__typename":"Guild","id":"WyJndWlsZHMiLCI4MWY2YzE1Ni0yNjdjLTRjMmEtOTUyMC05YTEwYTdlMWJiMzYiXQ==","name":"Svelte Chicago","__isNode":"Guild"},"uploadedSocialCard":{"url":"https://guild.host/i/1d9d947b-6f1a-4695-8dc3-f54b90c5aaa9/jpeg","id":"WyJpbWFnZXMiLCIxZDlkOTQ3Yi02ZjFhLTQ2OTUtOGRjMy1mNTRiOTBjNWFhYTkiXQ=="},"generatedSocialCardURL":"https://guild.host/social/event/svelte-chicago-march-nbdhmo/card.svg","presentations":{"edges":[{"node":{"id":"WyJwcmVzZW50YXRpb25zIiwiZDIwZTQ1YjctMDgzYy00NGJiLTgxYjUtNjViZTA4NGU2MDUzIl0=","slug":"d8vnoi","prettyUrl":"web-serialization-and-d8vnoi","presenter":null,"presenterFirstName":"Robert","presenterLastName":"Ismo","title":"Web Serialization and SvelteKit: JSON.stringify, Devalue, and Beyond","description":"Devalue is the client-server serialization library used by SvelteKit. In this talk, I will go over why we want serialization libraries, the what and why of devalue, and the current limitations of devalue and other solutions.\n","videoSourceUrl":""},"cursor":"WyJuYXR1cmFsIiwxXQ=="}]},"venue":{"address":{"location":{"__typename":"GeometryPoint","geojson":{"type":"Point","coordinates":[-87.6332791,41.8860378]}},"id":"WyJhZGRyZXNzZXMiLCJmN2E3MThmZC0zYjBiLTQ5NDEtYThjOC0xOTg4NTY0NWMzZWUiXQ=="},"id":"WyJ2ZW51ZXMiLCI1ZmNkYTdjOS1lNDliLTQ1NWQtYjVjNC1jMGI1OWYzNzFjMzMiXQ=="},"createdAt":"2026-03-06T14:18:54.435146","updatedAt":"2026-03-09T11:20:11.5769"}
\ No newline at end of file
diff --git a/apps/web/src/lib/import/guild.test.ts b/apps/web/src/lib/import/guild.test.ts
new file mode 100644
index 0000000..3a93b99
--- /dev/null
+++ b/apps/web/src/lib/import/guild.test.ts
@@ -0,0 +1,119 @@
+import { describe, it, expect } from 'vitest';
+import { guildImporter } from './guild';
+import {
+ importContext,
+ stubFetch,
+ jsonReply,
+ imageReply,
+ blockRealFetch,
+ type FetchRoute
+} from './test-support';
+
+// Real API responses captured from https://guild.host/api/next/events/.
+import svelteChicago from './__fixtures__/guild-svelte-chicago.json';
+import reactSummit from './__fixtures__/guild-react-summit.json';
+import londonGraphql from './__fixtures__/guild-london-graphql.json';
+
+const API = '/api/next/events/';
+
+/** Route table for one Guild event: the JSON API plus whatever cover image it points at. */
+function guildRoutes(event: unknown): FetchRoute[] {
+ return [
+ { when: API, reply: () => jsonReply(event) },
+ {
+ when: /card\.svg(\?|$)/,
+ reply: () => imageReply('image/svg+xml', new Uint8Array([0x3c, 0x73, 0x76, 0x67]))
+ },
+ { when: '/i/', reply: () => imageReply('image/png') } // uploadedSocialCard lives at guild.host/i//
+ ];
+}
+
+blockRealFetch();
+
+describe('guildImporter.accept', () => {
+ it.each([
+ ['https://guild.host/events/svelte-chicago-march-nbdhmo', true],
+ ['https://www.guild.host/events/react-summit-amsterdam-0kzjnm', true],
+ ['https://guild.host/events/foo/tickets', true], // still an event path
+ ['https://guild.host/', false], // no event
+ ['https://guild.host/some-community', false], // community page, not an event
+ ['https://guild.host/events', false], // listing, no slug
+ ['https://example.com/events/x', false], // wrong host
+ ['https://notguild.host/events/x', false], // host must match exactly
+ ['not a url', false]
+ ])('%s -> %s', (url, expected) => {
+ expect(guildImporter.accept(importContext(url))).toBe(expected);
+ });
+});
+
+describe('guildImporter.parseData maps real captured events', () => {
+ it('maps an in-person event with an uploaded social card (Svelte Chicago)', async () => {
+ stubFetch(guildRoutes(svelteChicago));
+ const url = 'https://guild.host/events/svelte-chicago-march-nbdhmo';
+
+ const result = await guildImporter.parseData(importContext(url));
+
+ expect(result).toMatchObject({
+ source: 'https://guild.host/events/svelte-chicago-march-nbdhmo',
+ name: 'Svelte Chicago - March 2026',
+ timezone: 'America/Chicago',
+ startsAt: '2026-03-24T22:30:00+00:00',
+ endsAt: '2026-03-25T01:00:00+00:00',
+ mode: 'inperson', // hasVenue: true, hasExternalUrl: false
+ links: [{ uri: 'https://guild.host/events/svelte-chicago-march-nbdhmo', name: 'Event page' }]
+ });
+ expect(result?.description).toContain('Svelte Chicago');
+ // Cover image was fetched from the uploaded card and inlined as a data URL.
+ expect(result?.imageDataUrl).toMatch(/^data:image\//);
+ });
+
+ it('maps a multi-day event (React Summit Amsterdam)', async () => {
+ stubFetch(guildRoutes(reactSummit));
+ const url = 'https://guild.host/events/react-summit-amsterdam-0kzjnm';
+
+ const result = await guildImporter.parseData(importContext(url));
+
+ expect(result).toMatchObject({
+ name: 'React Summit Amsterdam 2025',
+ timezone: 'Europe/Warsaw',
+ startsAt: '2025-06-13T06:00:00+00:00',
+ endsAt: '2025-06-17T19:00:00+00:00',
+ mode: 'inperson'
+ });
+ expect(result?.imageDataUrl).toMatch(/^data:image\//);
+ });
+});
+
+describe('guildImporter robustness', () => {
+ it('imports an event whose description is absent (optional field degrades)', async () => {
+ const { description, ...noDescription } = svelteChicago as Record;
+ void description;
+ stubFetch(guildRoutes(noDescription));
+ const result = await guildImporter.parseData(
+ importContext('https://guild.host/events/svelte-chicago-march-nbdhmo')
+ );
+ expect(result?.name).toBe('Svelte Chicago - March 2026');
+ expect(result?.description).toBeUndefined();
+ });
+
+ it('throws on a non-OK Guild API response so /api/import-event surfaces a 502', async () => {
+ stubFetch([{ when: API, reply: () => jsonReply({ error: 'nope' }, 503) }]);
+ await expect(
+ guildImporter.parseData(importContext('https://guild.host/events/whatever'))
+ ).rejects.toThrow(/failed to fetch guild event/);
+ });
+
+ // The Guild API returns `uploadedSocialCard: null` (not absent) for events with
+ // no uploaded card — e.g. the real event London GraphQL Spring 2026. The schema
+ // accepts that (v.nullish) and the importer falls back to generatedSocialCardURL,
+ // rather than throwing and 502-ing the whole import. Fixture is the real,
+ // unmodified API response.
+ it('imports an event whose uploadedSocialCard is null, via the generated-card fallback (London GraphQL)', async () => {
+ stubFetch(guildRoutes(londonGraphql));
+ const result = await guildImporter.parseData(
+ importContext('https://guild.host/events/london-graphql-spring-tujgnp')
+ );
+ expect(result?.name).toBe('London GraphQL Spring 2026');
+ expect(result?.imageDataUrl).toMatch(/^data:image\//);
+ });
+});
diff --git a/apps/web/src/lib/import/pipeline.test.ts b/apps/web/src/lib/import/pipeline.test.ts
new file mode 100644
index 0000000..aad26cb
--- /dev/null
+++ b/apps/web/src/lib/import/pipeline.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect } from 'vitest';
+import { importFromUrl, importers } from './index';
+import { stubFetch, jsonReply, imageReply, blockRealFetch } from './test-support';
+import svelteChicago from './__fixtures__/guild-svelte-chicago.json';
+
+blockRealFetch();
+
+describe('import registry order', () => {
+ it('registers the host-matched guild importer before the fetch-based ical importer', () => {
+ // guildImporter.accept() is a cheap URL match; icalImporter.accept() calls
+ // getPage() and downloads the whole source page. If guild came after ical,
+ // every guild paste would fetch (and throw away) the ~170 KB guild.host HTML
+ // page first — and fail outright if that heavy page rate-limits while the
+ // small JSON API is fine. Order is the guarantee; pin it.
+ const names = importers.map((i) => i.name);
+ expect(names.indexOf('guild')).toBeGreaterThanOrEqual(0);
+ expect(names.indexOf('guild')).toBeLessThan(names.indexOf('ical'));
+ expect(names.indexOf('guild')).toBeLessThan(names.indexOf('webpage'));
+ });
+
+ it('routes a guild.host URL straight to the Guild API without fetching the HTML page', async () => {
+ const { urls } = stubFetch([
+ { when: '/api/next/events/', reply: () => jsonReply(svelteChicago) },
+ { when: '/i/', reply: () => imageReply('image/png') }
+ ]);
+
+ const result = await importFromUrl('https://guild.host/events/svelte-chicago-march-nbdhmo');
+
+ expect(result?.name).toBe('Svelte Chicago - March 2026');
+ // It must have hit the JSON API...
+ expect(urls.some((u) => u.includes('/api/next/events/'))).toBe(true);
+ // ...and never the guild.host event *page* (which would mean a wasted getPage()
+ // fetch by an earlier fetch-based importer). Note the API path is
+ // /api/next/events/, which is intentionally excluded by this pattern.
+ expect(urls.some((u) => /guild\.host\/events\//.test(u))).toBe(false);
+ });
+});