blob: ab2fb719f9c314f3b51b3e7841a2915982c529a3 [file]
/**
* The embeddable agenda.
*
* A stripped-down list of upcoming events, meant to be dropped into another
* site in an iframe: no header, no filter panel, no editing. Everything it
* shows is driven by query parameters, so a site can ask for "the next ten
* httpd events" without any JavaScript of its own.
*
* Session cookies are SameSite=Strict, so a cross-site iframe never carries
* one. An embed is always an anonymous view, which means public events only.
* That is the intended behaviour rather than a limitation to work around: a
* page on someone else's site should not be able to show private events.
*/
import type { Category } from "./types";
import { CATEGORIES } from "./types";
import { LOCAL, isKnownZone, normaliseZone } from "./timezone";
import type { DisplayZone } from "./timezone";
import { withoutBase } from "./base";
/** The route the embed is served from, relative to the app's mount point. */
export const EMBED_PATH = "/embed/agenda";
export const DEFAULT_DAYS = 60;
export const DEFAULT_LIMIT = 20;
const MAX_DAYS = 366;
const MAX_LIMIT = 100;
export type EmbedTheme = "auto" | "light" | "dark";
export interface EmbedOptions {
/** Categories to include. Empty means all of them. */
categories: Category[];
/** Projects to include. Empty means all the viewer can see. */
projects: string[];
/** How far ahead to look, in days. */
days: number;
/** How many events to show at most. */
limit: number;
search: string;
zone: DisplayZone;
/** Heading above the list, or null for none. */
title: string | null;
/** Whether to say which clock the times are on. */
showZone: boolean;
/** Drop the panel background so the host page shows through. */
transparent: boolean;
theme: EmbedTheme;
/** Whether to show the "ASF Community Calendar" link at the foot. */
showCredit: boolean;
}
export function isEmbedRoute(pathname: string): boolean {
const route = withoutBase(pathname);
return route === EMBED_PATH || route === `${EMBED_PATH}/`;
}
function clamp(value: number, low: number, high: number): number {
return Math.min(high, Math.max(low, value));
}
function numberParam(params: URLSearchParams, name: string, fallback: number, low: number, high: number): number {
const raw = params.get(name);
if (raw === null || raw.trim() === "") return fallback;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return fallback;
return clamp(Math.trunc(parsed), low, high);
}
function booleanParam(params: URLSearchParams, name: string, fallback: boolean): boolean {
const raw = params.get(name);
if (raw === null) return fallback;
if (raw === "") return true; // ?transparent with no value means yes
return !["0", "false", "no", "off"].includes(raw.trim().toLowerCase());
}
/** Collects a filter that may be repeated or comma-separated. */
function listParam(params: URLSearchParams, ...names: string[]): string[] {
const collected: string[] = [];
for (const name of names) {
for (const raw of params.getAll(name)) {
collected.push(...raw.split(",").map((piece) => piece.trim()));
}
}
return collected.filter(Boolean);
}
export function parseEmbedOptions(search: string): EmbedOptions {
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
const categories = listParam(params, "category", "categories")
.map((name) => name.toLowerCase())
.filter((name): name is Category => (CATEGORIES as readonly string[]).includes(name));
const themeRaw = (params.get("theme") ?? "auto").toLowerCase();
const theme: EmbedTheme = themeRaw === "light" || themeRaw === "dark" ? themeRaw : "auto";
const zoneRaw = params.get("zone");
const zone = zoneRaw && (zoneRaw.toLowerCase() === LOCAL || isKnownZone(zoneRaw))
? normaliseZone(zoneRaw, LOCAL)
: LOCAL;
const title = params.get("title");
return {
categories,
projects: listParam(params, "project", "projects").map((name) => name.toLowerCase()),
days: numberParam(params, "days", DEFAULT_DAYS, 1, MAX_DAYS),
limit: numberParam(params, "limit", DEFAULT_LIMIT, 1, MAX_LIMIT),
search: params.get("q")?.trim() ?? "",
zone,
title: title === null ? null : title.trim() || null,
showZone: booleanParam(params, "showzone", true),
transparent: booleanParam(params, "transparent", false),
theme,
showCredit: booleanParam(params, "credit", true),
};
}
/**
* The message the embed posts to whatever page is framing it, so the host can
* size the iframe to its content. Sent with a "*" target origin because we do
* not know who is embedding us; it carries nothing but a pixel height.
*/
export const HEIGHT_MESSAGE = "asf-calendar-embed:height";
export interface HeightMessage {
type: typeof HEIGHT_MESSAGE;
height: number;
}
export function heightMessage(height: number): HeightMessage {
return { type: HEIGHT_MESSAGE, height: Math.ceil(height) };
}