forked from cccb-website-team/www
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:
parent
4545852327
commit
a0e1ef046a
6 changed files with 9760 additions and 6 deletions
|
|
@ -30,7 +30,7 @@ This is the website of the CCCB.
|
||||||
Every change you make on the project will be reflected in your browser as long as `hugo serve` is running.
|
Every change you make on the project will be reflected in your browser as long as `hugo serve` is running.
|
||||||
|
|
||||||
The *"Nächste Veranstaltungen"* table on the home page and the calendar under `/verein/calendar/` are rendered in the
|
The *"Nächste Veranstaltungen"* table on the home page and the calendar under `/verein/calendar/` are rendered in the
|
||||||
browser by `static/js/upcoming.js` and `assets/js/calendar.js`. Both fetch `/calendars/all.ics`, which is **not**
|
browser by `assets/js/upcoming.js` and `assets/js/calendar.js`. Both fetch `/calendars/all.ics`, which is **not**
|
||||||
generated by Hugo and is not part of this repo — it is published separately on the web server. Without it both tables
|
generated by Hugo and is not part of this repo — it is published separately on the web server. Without it both tables
|
||||||
stay empty. Download the published calendar into `static/`, which Hugo serves under the same path, and they work
|
stay empty. Download the published calendar into `static/`, which Hugo serves under the same path, and they work
|
||||||
locally, under `hugo serve` as well as in a built site:
|
locally, under `hugo serve` as well as in a built site:
|
||||||
|
|
@ -46,6 +46,9 @@ are currently published.
|
||||||
Hugo does generate an `.ics` feed per section (for example `/veranstaltungen/index.ics`) from the `dtstart`, `dtend`
|
Hugo does generate an `.ics` feed per section (for example `/veranstaltungen/index.ics`) from the `dtstart`, `dtend`
|
||||||
and `rrule` front matter of the pages in `content/veranstaltungen/`, using the `.ics` templates under `layouts/`.
|
and `rrule` front matter of the pages in `content/veranstaltungen/`, using the `.ics` templates under `layouts/`.
|
||||||
|
|
||||||
|
The site must not make the browser load anything from an external server, so third party JavaScript is checked into
|
||||||
|
`assets/js/vendor/` and bundled in by Hugo instead of being pulled from a CDN. See the README there before updating it.
|
||||||
|
|
||||||
To build the site for upload, run:
|
To build the site for upload, run:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
|
|
|
||||||
259
assets/js/upcoming.js
Normal file
259
assets/js/upcoming.js
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* The value ends up in the href of a link, and the calendar is exported from a
|
||||||
|
* CalDAV server, so whoever may write to it decides what that value is. A
|
||||||
|
* "javascript:" URL there would run on our page as soon as a visitor clicks
|
||||||
|
* the event, so hand on nothing but http and https.
|
||||||
|
*
|
||||||
|
* @param {ICAL.Event} event The event to read the URL of
|
||||||
|
* @returns {string} The URL, empty when the event has none or it is not http(s)
|
||||||
|
*/
|
||||||
|
function eventUrl(event) {
|
||||||
|
const url = event.component.getFirstPropertyValue("url") ?? "";
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// A relative URL is resolved against the page and keeps its scheme.
|
||||||
|
const { protocol } = new URL(url, document.baseURI);
|
||||||
|
|
||||||
|
return protocol === "http:" || protocol === "https:" ? url : "";
|
||||||
|
} catch {
|
||||||
|
// Not a URL at all.
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group the occurrences that were modified on their own by the UID of the event
|
||||||
|
* they belong to.
|
||||||
|
*
|
||||||
|
* Unless it is told which exceptions belong to an event, ICAL.Event relates
|
||||||
|
* every VEVENT with a RECURRENCE-ID in the file to every recurring event, and
|
||||||
|
* it keys them by the recurrence id alone. Two series that meet at the same
|
||||||
|
* time would therefore take over each other's modifications.
|
||||||
|
*
|
||||||
|
* @param {ICAL.Component[]} components The VEVENTs of the calendar
|
||||||
|
* @returns {Map<string, ICAL.Component[]>} The exceptions per UID
|
||||||
|
*/
|
||||||
|
function exceptionsByUid(components) {
|
||||||
|
const exceptions = new Map();
|
||||||
|
|
||||||
|
for (const component of components) {
|
||||||
|
if (!component.hasProperty("recurrence-id")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = component.getFirstPropertyValue("uid");
|
||||||
|
const ofEvent = exceptions.get(uid);
|
||||||
|
|
||||||
|
if (ofEvent) {
|
||||||
|
ofEvent.push(component);
|
||||||
|
} else {
|
||||||
|
exceptions.set(uid, [component]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return exceptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When an occurrence starts, as a point in time.
|
||||||
|
*
|
||||||
|
* A date has no time and no zone, its digits are the day itself. toJSDate()
|
||||||
|
* reads them as midnight in the zone of the browser, which moves an all day
|
||||||
|
* event by the offset that zone has to Berlin and, far enough east or west,
|
||||||
|
* onto the day before or after. Keep the digits and read them as UTC instead,
|
||||||
|
* the table prints an all day event in UTC as well.
|
||||||
|
*
|
||||||
|
* @param {ICAL.Time} time Start of the occurrence
|
||||||
|
* @returns {Date} The point in time to sort and print by
|
||||||
|
*/
|
||||||
|
function startOf(time) {
|
||||||
|
if (time.isDate) {
|
||||||
|
return new Date(Date.UTC(time.year, time.month - 1, time.day));
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.toJSDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the recurrences of an event have to be iterated.
|
||||||
|
*
|
||||||
|
* The iteration walks the unmodified recurrence times, so an occurrence that
|
||||||
|
* was moved to an earlier time is only reached through the time it originally
|
||||||
|
* had, which can lie past the end of the window. Keep going for as long as the
|
||||||
|
* largest move towards the past can still carry an occurrence into it.
|
||||||
|
*
|
||||||
|
* @param {ICAL.Event} event The event whose recurrences are iterated
|
||||||
|
* @param {Date} end End of the window
|
||||||
|
* @returns {Date} The recurrence time to stop at
|
||||||
|
*/
|
||||||
|
function iterationEnd(event, end) {
|
||||||
|
let last = end.getTime();
|
||||||
|
|
||||||
|
for (const exception of Object.values(event.exceptions)) {
|
||||||
|
const movedBy = exception.recurrenceId.toJSDate().getTime()
|
||||||
|
- exception.startDate.toJSDate().getTime();
|
||||||
|
|
||||||
|
if (movedBy > 0) {
|
||||||
|
last = Math.max(last, end.getTime() + movedBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(last);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, allDay: boolean, 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 = [];
|
||||||
|
|
||||||
|
const components = calendar.getAllSubcomponents("vevent");
|
||||||
|
const exceptions = exceptionsByUid(components);
|
||||||
|
|
||||||
|
for (const component of components) {
|
||||||
|
// Occurrences modified via RECURRENCE-ID are reached through the event they
|
||||||
|
// belong to, listing them here as well would show them twice.
|
||||||
|
if (component.hasProperty("recurrence-id")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = new ICAL.Event(component, {
|
||||||
|
exceptions: exceptions.get(component.getFirstPropertyValue("uid")) ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event.startDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.isRecurring()) {
|
||||||
|
const iterator = event.iterator();
|
||||||
|
const iterateUntil = iterationEnd(event, end);
|
||||||
|
|
||||||
|
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() > iterateUntil) {
|
||||||
|
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.
|
||||||
|
// A modification may have moved the occurrence out of the window, so
|
||||||
|
// judge it by the time it really takes place at.
|
||||||
|
if (details.startDate.toJSDate() <= end && details.endDate.toJSDate() > now) {
|
||||||
|
events.push({
|
||||||
|
start: startOf(details.startDate),
|
||||||
|
allDay: details.startDate.isDate,
|
||||||
|
name: details.item.summary ?? "",
|
||||||
|
url: eventUrl(details.item),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (event.startDate.toJSDate() <= end && event.endDate.toJSDate() > now) {
|
||||||
|
events.push({
|
||||||
|
start: startOf(event.startDate),
|
||||||
|
allDay: event.startDate.isDate,
|
||||||
|
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");
|
||||||
|
|
||||||
|
// 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. An all day
|
||||||
|
// event has no time of day and carries its date in UTC, see startOf().
|
||||||
|
const whenFormat = event.allDay
|
||||||
|
? { timeZone: "UTC" }
|
||||||
|
: { timeZone: "Europe/Berlin", hour: "2-digit", minute: "2-digit" };
|
||||||
|
|
||||||
|
const formattedStart = event.start.toLocaleString("de-DE", {
|
||||||
|
weekday: "long",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
...whenFormat,
|
||||||
|
});
|
||||||
|
|
||||||
|
colBegin.innerText = event.allDay ? formattedStart : `${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
19
assets/js/vendor/README.md
vendored
Normal 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
9732
assets/js/vendor/ical.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -25,9 +25,6 @@ description: "Startseite CCCB mit Kurzkalender"
|
||||||
|
|
||||||
### Nächste Veranstaltungen
|
### Nächste Veranstaltungen
|
||||||
|
|
||||||
<table id="upcoming" class="table table-condensed">
|
{{< upcoming >}}
|
||||||
</table>
|
|
||||||
|
|
||||||
Weitere Termine findest du im [Veranstaltungskalender](/verein/calendar/).
|
Weitere Termine findest du im [Veranstaltungskalender](/verein/calendar/).
|
||||||
|
|
||||||
<script type="module" src="/js/upcoming.js"></script>
|
|
||||||
|
|
|
||||||
3
layouts/shortcodes/upcoming.html
Normal file
3
layouts/shortcodes/upcoming.html
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{{- $js := resources.Get "js/upcoming.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint -}}
|
||||||
|
<table id="upcoming" class="table table-condensed"></table>
|
||||||
|
<script type="module" src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>
|
||||||
Loading…
Reference in a new issue