101 lines
3.9 KiB
TypeScript
101 lines
3.9 KiB
TypeScript
/* ============================================================================
|
|||
|
|
EVENT -> iCALENDAR (resource route)
|
||
|
|
----------------------------------------------------------------------------
|
||
|
|
/{locale}/events-calendar/{slug}.ics
|
||
|
|
|
||
|
|
This module exports a `loader` and nothing else - no component, no
|
||
|
|
ErrorBoundary - which is what makes React Router treat it as a resource
|
||
|
|
route and hand the loader's Response straight to the browser.
|
||
|
|
|
||
|
|
Because a resource route is served on its own, the ":lang" layout above it
|
||
|
|
does NOT run: its loader is not called for this request, so the locale
|
||
|
|
segment is validated here as well. `requireLocale` throws the same 404 the
|
||
|
|
layout would have.
|
||
|
|
|
||
|
|
The file itself is built by ./_events-opportunities/ics.ts, which follows
|
||
|
|
RFC 5545 rather than one client's tolerance - CRLF endings, 75-octet folding
|
||
|
|
on character boundaries (an Arabic title is two bytes per letter), escaped
|
||
|
|
TEXT values and an exclusive DTEND for an all-day event.
|
||
|
|
========================================================================= */
|
||
|
|
|
||
|
|
import { redirect } from "react-router";
|
||
|
|
|
||
|
|
import { isLocale, sectionPath, translate } from "~/i18n";
|
||
|
|
import { getSiteUrl } from "~/lib/env.server";
|
||
|
|
import { canonicalUrl } from "~/lib/seo";
|
||
|
|
import { requireEntity } from "~/lib/strapi";
|
||
|
|
|
||
|
|
import type { Route } from "./+types/events-calendar.$slug.ics";
|
||
|
|
import { getRouteCopy } from "./_events-opportunities/copy";
|
||
|
|
import { fetchEvent } from "./_events-opportunities/events.server";
|
||
|
|
import { buildIcsCalendar, icsResponse } from "./_events-opportunities/ics";
|
||
|
|
import { requireLocale } from "./_shared/listing";
|
||
|
|
|
||
|
|
/** Only used to name the file; the CMS slug supplies the rest. */
|
||
|
|
const FILENAME_PREFIX = "sharp-er";
|
||
|
|
|
||
|
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||
|
|
/* The ":lang" layout normally 301s "/EN/..." to "/en/...", but its loader
|
||
|
|
does not run for a resource route, so the same normalisation happens here
|
||
|
|
rather than answering a 404 for a URL the rest of the site accepts. */
|
||
|
|
const segment = params.lang;
|
||
|
|
const normalized = segment?.toLowerCase();
|
||
|
|
if (segment && normalized !== segment && isLocale(normalized)) {
|
||
|
|
const url = new URL(request.url);
|
||
|
|
throw redirect(
|
||
|
|
`${url.pathname.replace(/^\/[^/]+/, `/${normalized}`)}${url.search}`,
|
||
|
|
301,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const locale = requireLocale(normalized);
|
||
|
|
const slug = params.slug ?? "";
|
||
|
|
|
||
|
|
const { event } = requireEntity(await fetchEvent(slug, locale));
|
||
|
|
|
||
|
|
const siteUrl = getSiteUrl(request);
|
||
|
|
const pathname = `${sectionPath("events", locale)}/${encodeURIComponent(slug)}`;
|
||
|
|
const url = canonicalUrl(siteUrl, pathname);
|
||
|
|
const copy = getRouteCopy(locale);
|
||
|
|
|
||
|
|
const location =
|
||
|
|
event.place ??
|
||
|
|
(event.format === "online" ? translate(locale, "events.online") : undefined);
|
||
|
|
|
||
|
|
/* The joining link belongs in the calendar entry, not only on the page: by
|
||
|
|
the time the reminder fires, the page is not what is in front of anyone. */
|
||
|
|
const description = [
|
||
|
|
event.summary,
|
||
|
|
event.onlineUrl
|
||
|
|
? `${copy.eventDetail.joinOnlineLabel}: ${event.onlineUrl}`
|
||
|
|
: undefined,
|
||
|
|
url,
|
||
|
|
]
|
||
|
|
.filter(Boolean)
|
||
|
|
.join("\n\n");
|
||
|
|
|
||
|
|
const calendar = buildIcsCalendar(
|
||
|
|
[
|
||
|
|
{
|
||
|
|
/* documentId is shared by an entry's translations in Strapi 5, so the
|
||
|
|
locale is part of the UID - importing the Arabic file must not
|
||
|
|
overwrite the English entry already in someone's calendar. */
|
||
|
|
uid: `${event.documentId}.${locale}@sharper.ly`,
|
||
|
|
start: event.start,
|
||
|
|
// Only an explicit end is written: inventing a duration would put a
|
||
|
|
// wrong-length block in a reader's calendar.
|
||
|
|
end: event.end,
|
||
|
|
allDay: event.allDay,
|
||
|
|
summary: event.title,
|
||
|
|
description,
|
||
|
|
location,
|
||
|
|
url,
|
||
|
|
status: "CONFIRMED",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
{ name: translate(locale, "nav.sections.events") },
|
||
|
|
);
|
||
|
|
|
||
|
|
return icsResponse(calendar, `${FILENAME_PREFIX}-${slug}.ics`);
|
||
|
|
}
|