The upcoming events table pulled its ICS parser straight from a CDN with
`import ICAL from "https://unpkg.com/ical.js/dist/ical.min.js"`. That sends
every visitor of the start page to unpkg.com, which hands their IP address and
user agent to a third party before any of our own code runs. The URL is not
even pinned to a version, so whatever ical.js publishes next is executed on our
site without anybody looking at it, and the start page silently breaks when the
CDN is unreachable.
Check the parser into `assets/js/vendor/` and let Hugo bundle it. This is what
`js.Build` is for: it runs the esbuild that is built into Hugo, so it resolves
the import at build time and needs no node_modules and no extra tooling in the
build environment. The result is minified and fingerprinted like the other
scripts of the site, and the script tag carries a subresource integrity hash.
Since the bundle now has a content hash in its name, its URL cannot be written
by hand in the markdown any more. Move the table and the script tag into an
`upcoming` shortcode, which is the same pattern `calendar.html` already uses,
and move `upcoming.js` from `static/` to `assets/` so Hugo can process it.
The vendored file is the unminified `dist/ical.js` of the pinned release: it
carries the MPL-2.0 header and is the source form of what we ship, and Hugo
minifies it for delivery anyway. `assets/js/vendor/README.md` records the
version, where it came from and how to update it.
The built bundle renders the same table as before, checked against
https://berlin.ccc.de/calendars/all.ics.
Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
151 lines
4.4 KiB
JavaScript
151 lines
4.4 KiB
JavaScript
import ICAL from "./vendor/ical.js";
|
|
|
|
/**
|
|
* Read the URL of an event.
|
|
*
|
|
* ICAL.Event does not expose the URL property, so read it from the component.
|
|
*
|
|
* @param {ICAL.Event} event The event to read the URL of
|
|
* @returns {string} The URL, empty when the event has none
|
|
*/
|
|
function eventUrl(event) {
|
|
return event.component.getFirstPropertyValue("url") ?? "";
|
|
}
|
|
|
|
/**
|
|
* Parse an ICS calendar and return upcoming event occurrences.
|
|
*
|
|
* @param {string} icsText The contents of the .ics file
|
|
* @param {Date} now Events must still be running at this date
|
|
* @param {number} maxEvents Maximum number of events to return
|
|
* @param {number} maxDays Maximum number of days into the future
|
|
* @returns {{start: Date, name: string, url: string}[]} url is empty when the event has no URL
|
|
*/
|
|
function getUpcomingEvents(icsText, now, maxEvents, maxDays) {
|
|
const jcal = ICAL.parse(icsText);
|
|
const calendar = new ICAL.Component(jcal);
|
|
|
|
const end = new Date(now.getTime());
|
|
end.setDate(end.getDate() + maxDays);
|
|
|
|
const events = [];
|
|
|
|
for (const component of calendar.getAllSubcomponents("vevent")) {
|
|
const event = new ICAL.Event(component);
|
|
|
|
// Occurrences modified via RECURRENCE-ID are reached through the event they
|
|
// belong to, listing them here as well would show them twice.
|
|
if (event.isRecurrenceException()) {
|
|
continue;
|
|
}
|
|
|
|
if (!event.startDate) {
|
|
continue;
|
|
}
|
|
|
|
if (event.isRecurring()) {
|
|
const iterator = event.iterator();
|
|
|
|
while (true) {
|
|
const occurrence = iterator.next();
|
|
|
|
if (!occurrence) {
|
|
break;
|
|
}
|
|
|
|
// Recurrences are chronological, so we're done
|
|
// once we pass the end of our search window.
|
|
if (occurrence.toJSDate() > end) {
|
|
break;
|
|
}
|
|
|
|
// Details resolve time, name and URL of an occurrence that was
|
|
// modified via RECURRENCE-ID.
|
|
const details = event.getOccurrenceDetails(occurrence);
|
|
|
|
// A running event stays listed until it is over, so filter on its end.
|
|
if (details.endDate.toJSDate() > now) {
|
|
events.push({
|
|
start: details.startDate.toJSDate(),
|
|
name: details.item.summary ?? "",
|
|
url: eventUrl(details.item),
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
const start = event.startDate.toJSDate();
|
|
|
|
if (start <= end && event.endDate.toJSDate() > now) {
|
|
events.push({
|
|
start,
|
|
name: event.summary ?? "",
|
|
url: eventUrl(event),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// We have occurrences from multiple events, so sort them
|
|
// before applying the maximum event count.
|
|
events.sort((a, b) => a.start - b.start);
|
|
|
|
return events.slice(0, maxEvents);
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const ics = "/calendars/all.ics";
|
|
const max_days = 20;
|
|
const max_items = 5;
|
|
|
|
const now = new Date();
|
|
|
|
const table = document.getElementById("upcoming");
|
|
fetch(ics)
|
|
.then(response => {
|
|
// Without this an error page would be handed to the parser below, which
|
|
// then fails with a confusing complaint about the calendar syntax.
|
|
if (!response.ok) {
|
|
throw new Error(`${ics}: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
return response.text();
|
|
})
|
|
.then(icsText => {
|
|
getUpcomingEvents(icsText, now, max_items, max_days).forEach(event => {
|
|
const row = document.createElement("tr");
|
|
|
|
const colBegin = document.createElement("td");
|
|
|
|
const formattedStart = event.start.toLocaleString("de-DE", {
|
|
// The events take place in Berlin, so name their time in Berlin time
|
|
// instead of in the time zone the visitor happens to be in.
|
|
timeZone: "Europe/Berlin",
|
|
weekday: "long",
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
|
|
colBegin.innerText = `${formattedStart} Uhr`;
|
|
row.appendChild(colBegin);
|
|
|
|
const colName = document.createElement("td");
|
|
|
|
if (event.url) {
|
|
const a = document.createElement("a");
|
|
a.href = event.url;
|
|
a.text = event.name;
|
|
|
|
colName.appendChild(a);
|
|
} else {
|
|
colName.innerText = event.name;
|
|
}
|
|
|
|
row.appendChild(colName);
|
|
|
|
table.appendChild(row);
|
|
});
|
|
})
|
|
.catch(err => console.error("Fehler beim Laden der Termine:", err));
|
|
});
|