From 6eceb15f18828998fafb94e39ac540d2db98a55a Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sat, 17 Oct 2026 01:51:02 +0200 Subject: [PATCH] Add calendar shortcode --- README.md | 20 ++++++++ assets/css/_custom.css | 8 +++ i18n/de.yaml | 3 ++ i18n/en.yaml | 3 ++ layouts/_partials/ics-line.html | 22 ++++++++ layouts/_shortcodes/calendar.html | 84 +++++++++++++++++++++++++++++++ 6 files changed, 140 insertions(+) create mode 100644 layouts/_partials/ics-line.html create mode 100644 layouts/_shortcodes/calendar.html diff --git a/README.md b/README.md index 8d52f59..6303c4a 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,25 @@ For a different event than the page's section, pass its name: `{{< schedule 39c3 Without a data file, or without any tours in it, the shortcode renders nothing and the build logs a warning. To silence it, for example for an upcoming event, add `warning-schedule-empty` to `ignoreLogs` in `hugo.yaml`. +## Shortcode: Calendar entry + +`{{< calendar >}}` adds two links after a tour date: a calendar file (`.ics`) and a link that adds the date in Google Calendar. Hackertours-Admin puts it into the list of dates on each tour page: + +```markdown +* Tag 1 (27.12.) **17:30 Uhr** {{< calendar date="2026-12-27" time="17:30" duration="210" >}} +``` + +| Param | Required | Content | +|---|---|---| +| `date` | yes | `YYYY-MM-DD` | +| `time` | yes | Start time `HH:MM`, local time in Hamburg | +| `duration` | no | Length in minutes. Without it, `duration` of the tour in `tours` in the schedule file is used (see "Map data in the schedule file"); without either, the calendar entry has no end time and the build logs a warning with the ID `warning-calendar-duration`. | +| `title` | no | Title of the calendar entry. Without it, the `title` of this date in the schedule file is used, then the page title. | + +The `.ics` file is in the iCalendar format (RFC 5545), which Apple Calendar, Outlook, Thunderbird and most other calendar apps open directly, as does Google Calendar when importing a file. The extra Google Calendar link is there because the Google Calendar app on Android doesn't open downloaded `.ics` files. + +Each entry starts at the time given, in Hamburg time, at the Hackertours desk (with `map.origin` from the schedule file as the address, if there is one). Its description is the tour summary and a link to the tour page. The files are generated next to the tour page, one per date and language, e.g. `/de/40c3/tour-of-the-giants/tour-of-the-giants-2026-12-27-1730.ics`. + ## Shortcode: Tour map `{{< tour-map >}}` on an event's overview page shows a map of the tour locations, with the routes from the Congress, on OpenStreetMap. For a different event than the page's section, pass its name: `{{< tour-map 39c3 >}}`. It uses [Leaflet](https://leafletjs.com), which is kept in [assets/vendor/leaflet/](assets/vendor/leaflet/). @@ -101,6 +120,7 @@ days: | `icon` | no | Name of an icon for the marker, see "Map icons" below. Without it, or with an unknown name, the marker is a plain coloured circle; an unknown name also logs a build warning. | | `directions` | no | How to get there from the origin, per language (`de`, `en`) as Markdown, usually a short list of steps. | | `route` | no | The way from `map.origin` to `geo` as a list of legs in travel order. Without it the tour only gets a marker. | +| `duration` | no | Length of the tour in minutes, used by the calendar entries when the `calendar` shortcode doesn't pass one. | Each leg in `route` has: diff --git a/assets/css/_custom.css b/assets/css/_custom.css index 6c63a8f..a05ca18 100644 --- a/assets/css/_custom.css +++ b/assets/css/_custom.css @@ -106,3 +106,11 @@ span.content-image { font-size: var(--fs-xs); color: var(--color-text-meta); } + +/* Calendar links after a tour date, see layouts/_shortcodes/calendar.html. */ +.tour-calendar { + margin-inline-start: 0.5em; + font-family: var(--ff-alt); + font-size: var(--fs-s); + white-space: nowrap; +} diff --git a/i18n/de.yaml b/i18n/de.yaml index 8858304..678c763 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -5,3 +5,6 @@ tour_map_osm: "auf OpenStreetMap" skip_to_content: "Zum Inhalt springen" logo_alt: "Hamburg Hackertours – Startseite" map_39c3_title: "Karte der 39C3-Touren mit Wegen vom CCH (uMap)" +calendar_meeting_point: "Hackertours-Desk" +calendar_ics: "Kalendereintrag (.ics)" +calendar_google: "Google Kalender" diff --git a/i18n/en.yaml b/i18n/en.yaml index ef3ea3c..fd3c772 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -5,3 +5,6 @@ tour_map_osm: "on OpenStreetMap" skip_to_content: "Skip to content" logo_alt: "Hamburg Hackertours – home page" map_39c3_title: "Map of the 39C3 tours with routes from the CCH (uMap)" +calendar_meeting_point: "Hackertours Desk" +calendar_ics: "Calendar entry (.ics)" +calendar_google: "Google Calendar" diff --git a/layouts/_partials/ics-line.html b/layouts/_partials/ics-line.html new file mode 100644 index 0000000..ea661b1 --- /dev/null +++ b/layouts/_partials/ics-line.html @@ -0,0 +1,22 @@ +{{- /* +One iCalendar (RFC 5545) content line: "NAME:value" plus CRLF, folded so that no line is +longer than 75 bytes (continuation lines start with a space). Params: name, value, text +(true to escape the value as TEXT: backslash, semicolon, comma and newlines). +*/ -}} +{{- $value := .value | default "" -}} +{{- if .text -}} + {{- $value = $value | replaceRE `\\` `\\` | replaceRE `;` `\;` | replaceRE `,` `\,` | replaceRE `\r?\n` `\n` -}} +{{- end -}} +{{- $out := "" -}} +{{- $line := "" -}} +{{- $bytes := 0 -}} +{{- range split (printf "%s:%s" .name $value) "" -}} + {{- if gt (add $bytes (len .)) 75 -}} + {{- $out = printf "%s%s\r\n " $out $line -}} + {{- $line = "" -}} + {{- $bytes = 1 -}} + {{- end -}} + {{- $line = printf "%s%s" $line . -}} + {{- $bytes = add $bytes (len .) -}} +{{- end -}} +{{- return printf "%s%s\r\n" $out $line -}} diff --git a/layouts/_shortcodes/calendar.html b/layouts/_shortcodes/calendar.html new file mode 100644 index 0000000..96abc85 --- /dev/null +++ b/layouts/_shortcodes/calendar.html @@ -0,0 +1,84 @@ +{{- /* +Calendar links for one date of a tour: an .ics file (iCalendar, for Apple Calendar, Outlook, +Thunderbird and most other apps) and a link that adds the event in Google Calendar. +Meant for the list of dates on a tour page: + + * Tag 1 (27.12.) **17:30 Uhr** {{< calendar date="2026-12-27" time="17:30" duration="210" >}} + +Params: date (YYYY-MM-DD), time (HH:MM, local time in Hamburg), duration in minutes +(optional, else tours..duration in data/schedule/.yaml), title (optional, else the +title of this date in the schedule data, else the page title). +*/ -}} +{{- $page := .Page -}} +{{- $lang := $page.Language.Name -}} +{{- $other := cond (eq $lang "de") "en" "de" -}} +{{- $event := $page.Section -}} +{{- $slug := $page.File.ContentBaseName -}} +{{- $date := .Get "date" -}} +{{- $time := .Get "time" -}} +{{- if not (and (findRE `^\d{4}-\d{2}-\d{2}$` $date) (findRE `^\d{2}:\d{2}$` $time)) -}} + {{- errorf "calendar: date must be YYYY-MM-DD and time HH:MM, got %q %q: %s" $date $time .Position -}} +{{- end -}} + +{{- $data := dict -}} +{{- with hugo.Data.schedule }}{{ with index . $event }}{{ $data = . }}{{ end }}{{ end -}} +{{- $tourData := dict -}} +{{- with $data.tours }}{{ with index . $slug }}{{ $tourData = . }}{{ end }}{{ end -}} +{{- /* This date in the schedule, for its title. */ -}} +{{- $slot := dict -}} +{{- range $data.days -}} + {{- if eq .date $date -}} + {{- range .tours }}{{ if and (eq .tour $slug) (eq .time $time) }}{{ $slot = . }}{{ end }}{{ end -}} + {{- end -}} +{{- end -}} + +{{- $title := .Get "title" -}} +{{- if not $title }}{{ with $slot.title }}{{ $title = index . $lang | default (index . $other) }}{{ end }}{{ end -}} +{{- $title = $title | default $page.Title -}} + +{{- $minutes := int (.Get "duration" | default ($tourData.duration | default 0)) -}} +{{- if not $minutes -}} + {{- warnidf "warning-calendar-duration" "calendar: no duration for %s on %s %s, the calendar entry has no end time: %s" $slug $date $time .Position -}} +{{- end -}} + +{{- $start := time.AsTime (printf "%sT%s:00" $date $time) "Europe/Berlin" -}} +{{- $end := $start.Add (time.ParseDuration (printf "%dm" $minutes)) -}} +{{- $utc := "20060102T150405Z" -}} + +{{- /* Everyone meets at the Hackertours desk, which is where the routes on the map start. */ -}} +{{- $location := i18n "calendar_meeting_point" -}} +{{- with $data.map }}{{ with .origin }}{{ with .name -}} + {{- $location = printf "%s, %s" $location (index . $lang | default (index . $other)) -}} +{{- end }}{{ end }}{{ end -}} +{{- /* The front matter summary: .Summary would render this page again from inside its own shortcode. */ -}} +{{- $summary := $page.Params.summary | default $page.Description | default "" -}} +{{- $description := printf "%s\n\n%s" ($page.RenderString $summary | plainify | htmlUnescape | strings.TrimSpace) $page.Permalink -}} + +{{- $ics := printf "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//CCC Hansestadt Hamburg//Hackertours//%s\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\nBEGIN:VEVENT\r\n" (upper $lang) -}} +{{- $ics = printf "%s%s" $ics (partial "ics-line.html" (dict "name" "UID" "value" (printf "%s-%s-%s-%s@%s" $event $slug $date (replace $time ":" "") (urls.Parse site.BaseURL).Host))) -}} +{{- $ics = printf "%s%s" $ics (partial "ics-line.html" (dict "name" "DTSTAMP" "value" ($page.Lastmod.UTC.Format $utc))) -}} +{{- $ics = printf "%s%s" $ics (partial "ics-line.html" (dict "name" "DTSTART" "value" ($start.UTC.Format $utc))) -}} +{{- if $minutes }}{{ $ics = printf "%s%s" $ics (partial "ics-line.html" (dict "name" "DTEND" "value" ($end.UTC.Format $utc))) }}{{ end -}} +{{- $ics = printf "%s%s" $ics (partial "ics-line.html" (dict "name" "SUMMARY" "value" $title "text" true)) -}} +{{- $ics = printf "%s%s" $ics (partial "ics-line.html" (dict "name" "LOCATION" "value" $location "text" true)) -}} +{{- with $data.map }}{{ with .origin }}{{ with .geo -}} + {{- $ics = printf "%s%s" $ics (partial "ics-line.html" (dict "name" "GEO" "value" (printf "%v;%v" .lat .lon))) -}} +{{- end }}{{ end }}{{ end -}} +{{- $ics = printf "%s%s" $ics (partial "ics-line.html" (dict "name" "DESCRIPTION" "value" $description "text" true)) -}} +{{- $ics = printf "%s%s" $ics (partial "ics-line.html" (dict "name" "URL" "value" $page.Permalink)) -}} +{{- $ics = printf "%sEND:VEVENT\r\nEND:VCALENDAR\r\n" $ics -}} + +{{- $file := printf "%s-%s-%s.ics" $slug $date (replace $time ":" "") -}} +{{- $icsFile := resources.FromString (path.Join (strings.TrimPrefix "/" $page.RelPermalink) $file) $ics -}} +{{- $google := printf "https://calendar.google.com/calendar/render?%s" (querify + "action" "TEMPLATE" + "text" $title + "dates" (printf "%s/%s" ($start.UTC.Format $utc) ($end.UTC.Format $utc)) + "details" $description + "location" $location + "ctz" "Europe/Berlin" +) -}} +{{- /* The visible link texts repeat for every date; the hidden part says which date it is. */ -}} +{{- $when := printf "%s %s" ($start | time.Format "Mon 2.1.") $time -}} +{{ i18n "calendar_ics" }} {{ $when }} · {{ i18n "calendar_google" }} {{ $when }} +{{- /* chomp */ -}}