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