Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,7 @@ jobs:
--gc \
--minify \
--destination public

- name: Validate iCalendar feed
run: node scripts/validate-calendar.mjs public/calendar/calendar.ics

1 change: 1 addition & 0 deletions assets/css/fontawesome-subset.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,6 @@
.fa-twitter:before{content:"\f099"}
.fa-user-circle:before{content:"\f2bd"}
.fa-users:before{content:"\f0c0"}
.fa-video:before{content:"\f03d"}
.fa-windows:before{content:"\f17a"}
.fa-youtube:before{content:"\f167"}
2 changes: 1 addition & 1 deletion assets/css/tailwind.css

Large diffs are not rendered by default.

Binary file modified assets/fonts/fa-solid-subset.woff2
Binary file not shown.
2 changes: 1 addition & 1 deletion hugo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ params:
branch: "main"

ical:
timezone: UTC
timezone: Etc/UTC

module:
imports:
Expand Down
21 changes: 21 additions & 0 deletions layouts/list.calendar.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{{- $calendar := "BEGIN:VCALENDAR\n" -}}
{{- $calendar = printf "%s%s" $calendar (partial "header.ics" .) -}}
{{- $timezones := slice -}}
{{- range .Pages -}}
{{- if .Params.startDate -}}
{{- $timezone := partial "ical/get_timezone.ics" . -}}
{{- if not (in $timezones $timezone) -}}
{{- $timezones = $timezones | append $timezone -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- range $timezones -}}
{{- $calendar = printf "%s%s" $calendar (partial "timezone.ics" .) -}}
{{- end -}}
{{- range .Pages -}}
{{- if .Params.startDate -}}
{{- $calendar = printf "%s%s" $calendar (partial "event.ics" .) -}}
{{- end -}}
{{- end -}}
{{- $calendar = printf "%sEND:VCALENDAR\n" $calendar -}}
{{- replace $calendar "\n" "\r\n" -}}
105 changes: 105 additions & 0 deletions scripts/validate-calendar.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { readFile } from 'node:fs/promises';

const input = process.argv[2];

if (!input) {
throw new Error('Usage: node scripts/validate-calendar.mjs <calendar.ics|URL>');
}

const calendar = new URL(input, 'file:').protocol === 'file:'
? await readFile(input, 'utf8')
: await (await fetch(input)).text();

const errors = [];
const fail = (message) => errors.push(message);

if (/(^|[^\r])\n/.test(calendar)) {
fail('Content lines must use CRLF line endings.');
}

const lines = calendar.replace(/\r?\n[ \t]/g, '').split(/\r?\n/).filter(Boolean);
const components = [];
const events = [];
const timezones = new Set();
const timezoneReferences = new Set();
let event;

for (const line of lines) {
if (line.startsWith('BEGIN:')) {
const component = line.slice('BEGIN:'.length);
components.push(component);
if (component === 'VEVENT') {
event = new Map();
events.push(event);
}
continue;
}

if (line.startsWith('END:')) {
const component = line.slice('END:'.length);
if (components.pop() !== component) {
fail(`Mismatched component terminator: ${line}`);
}
if (component === 'VEVENT') {
event = undefined;
}
continue;
}

const separator = line.indexOf(':');
if (separator < 1) {
fail(`Malformed content line: ${line}`);
continue;
}

const declaration = line.slice(0, separator);
const [name, ...parameters] = declaration.split(';');
const value = line.slice(separator + 1);

for (const parameter of parameters) {
if (parameter.startsWith('TZID=')) {
timezoneReferences.add(parameter.slice('TZID='.length));
}
}

if (components.at(-1) === 'VTIMEZONE' && name === 'TZID') {
timezones.add(value);
}

if (event) {
event.set(name, [...(event.get(name) ?? []), value]);
}
}

if (components.length) {
fail(`Unclosed component: ${components.at(-1)}`);
}

if (lines[0] !== 'BEGIN:VCALENDAR' || lines.at(-1) !== 'END:VCALENDAR') {
fail('Calendar must be wrapped in BEGIN:VCALENDAR and END:VCALENDAR.');
}

for (const [index, properties] of events.entries()) {
for (const property of ['UID', 'DTSTAMP', 'DTSTART']) {
if (properties.get(property)?.length !== 1) {
fail(`VEVENT ${index + 1} must contain exactly one ${property}.`);
}
}

const uid = properties.get('UID')?.[0];
if (uid && events.some((other) => other !== properties && other.get('UID')?.[0] === uid)) {
fail(`VEVENT ${index + 1} reuses UID ${uid}.`);
}
}

for (const timezone of timezoneReferences) {
if (!timezones.has(timezone)) {
fail(`TZID=${timezone} has no matching VTIMEZONE component.`);
}
}

if (errors.length) {
throw new Error(`Invalid RFC 5545 calendar:\n- ${errors.join('\n- ')}`);
}

console.log(`Validated ${events.length} VEVENT components.`);
2 changes: 1 addition & 1 deletion themes/powershell-community/layouts/_default/calendar.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ <h2 class="text-3xl font-bold text-gray-900">Upcoming Events</h2>
<!-- Event Details -->
<div class="p-6 flex-1">
<h3 class="text-xl font-bold text-gray-900 mb-2">{{ .Title }}</h3>
<p class="text-gray-600 mb-3">{{ .Summary }}</p>
<p class="text-gray-600 mb-3">{{ .Summary | plainify | truncate 240 }}</p>
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500">
<div class="flex items-center">
<i class="fas fa-map-marker-alt text-purple-600 mr-2"></i>
Expand Down
Loading