Vendor ical.js instead of loading it from unpkg.com

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>
This commit is contained in:
Hauke Mehrtens 2026-08-22 19:48:49 +02:00
commit 3d2231d7ec
6 changed files with 9760 additions and 6 deletions

151
assets/js/upcoming.js Normal file
View file

@ -0,0 +1,151 @@
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));
});

19
assets/js/vendor/README.md vendored Normal file
View file

@ -0,0 +1,19 @@
# Vendored JavaScript
Third party code is checked in here instead of being loaded from a CDN, so that the website does not make the visitor's
browser fetch anything from an external server.
Hugo bundles and minifies these files into the scripts that reference them, so the unminified source is checked in.
## ical.js
- Version: 2.2.1
- Source: <https://unpkg.com/ical.js@2.2.1/dist/ical.js>
- Upstream: <https://github.com/kewisch/ical.js>
- License: MPL-2.0 (see the header of `ical.js`)
To update, download the `dist/ical.js` of the wanted release and replace the file, keeping the version above in sync:
```shell
curl -o assets/js/vendor/ical.js https://unpkg.com/ical.js@<version>/dist/ical.js
```

9732
assets/js/vendor/ical.js vendored Normal file

File diff suppressed because it is too large Load diff