diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..498599b
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+tests/fixtures/mergephp.ical eol=crlf
diff --git a/composer.json b/composer.json
index 9b31ad9..6e936b3 100644
--- a/composer.json
+++ b/composer.json
@@ -19,6 +19,7 @@
"ext-mbstring": "*",
"ext-readline": "*",
"ext-simplexml": "*",
+ "eluceo/ical": "^2.15",
"league/commonmark": "^2.3",
"nette/php-generator": "^4.0.0",
"psr/log": "^3.0.0",
diff --git a/composer.lock b/composer.lock
index 9c399a7..37d6057 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "1ec222f64774a4befe980582feaa8022",
+ "content-hash": "d33d218d32b95dedb4e3f40905ecdeb3",
"packages": [
{
"name": "dflydev/dot-access-data",
@@ -81,6 +81,70 @@
},
"time": "2024-07-08T12:26:09+00:00"
},
+ {
+ "name": "eluceo/ical",
+ "version": "2.15.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/markuspoerschke/iCal.git",
+ "reference": "1324190463b502f9aaad64333b737071c0107ab1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/markuspoerschke/iCal/zipball/1324190463b502f9aaad64333b737071c0107ab1",
+ "reference": "1324190463b502f9aaad64333b737071c0107ab1",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": "~8.3.0 || ~8.4.0",
+ "symfony/deprecation-contracts": "^2.1 || ^3.0"
+ },
+ "conflict": {
+ "php": "7.4.6"
+ },
+ "require-dev": {
+ "ergebnis/composer-normalize": "^2.23.1",
+ "friendsofphp/php-cs-fixer": "^3.75",
+ "infection/infection": "^0.27 || ^0.29",
+ "phpmd/phpmd": "^2.13",
+ "phpunit/phpunit": "^10.5",
+ "vimeo/psalm": "^5.0 || ^6.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Eluceo\\iCal\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Markus Poerschke",
+ "email": "markus@poerschke.nrw",
+ "role": "Developer"
+ }
+ ],
+ "description": "The eluceo/iCal package offers an abstraction layer for creating iCalendars. You can easily create iCal files by using PHP objects instead of typing your *.ics file by hand. The output will follow RFC 5545 as best as possible.",
+ "homepage": "https://github.com/markuspoerschke/iCal",
+ "keywords": [
+ "calendar",
+ "iCalendar",
+ "ical",
+ "ics",
+ "php calendar"
+ ],
+ "support": {
+ "docs": "https://ical.poerschke.nrw",
+ "forum": "https://github.com/markuspoerschke/iCal/discussions",
+ "issues": "https://github.com/markuspoerschke/iCal/issues",
+ "source": "https://github.com/markuspoerschke/iCal"
+ },
+ "time": "2025-08-15T18:19:43+00:00"
+ },
{
"name": "lcobucci/clock",
"version": "3.3.1",
diff --git a/src/Builder/MeetupCollection.php b/src/Builder/MeetupCollection.php
index 1c3716e..b2c151c 100644
--- a/src/Builder/MeetupCollection.php
+++ b/src/Builder/MeetupCollection.php
@@ -4,12 +4,14 @@
namespace MergePHP\Website\Builder;
+use ArrayAccess;
use Countable;
use DateTimeImmutable;
use DateTimeZone;
use Iterator;
+use MergePHP\Website\Exception\NotImplementedException;
-class MeetupCollection implements Iterator, Countable
+class MeetupCollection implements Iterator, Countable, ArrayAccess
{
/** @var MeetupEntry[] */
private array $array = [];
@@ -87,4 +89,24 @@ public function withOnlyFuture(): array
return $meetupEntry->instance->getDateTime() > $NOW;
}));
}
+
+ public function offsetExists(mixed $offset): bool
+ {
+ return array_key_exists($offset, $this->array);
+ }
+
+ public function offsetGet(mixed $offset): ?MeetupEntry
+ {
+ return $this->array[$offset] ?? null;
+ }
+
+ public function offsetSet(mixed $offset, mixed $value): void
+ {
+ throw new NotImplementedException('Setting values directly is not allowed');
+ }
+
+ public function offsetUnset(mixed $offset): void
+ {
+ throw new NotImplementedException('Unsetting values directly is not allowed');
+ }
}
diff --git a/src/Builder/Processor/ICalProcessor.php b/src/Builder/Processor/ICalProcessor.php
new file mode 100644
index 0000000..e7f2f34
--- /dev/null
+++ b/src/Builder/Processor/ICalProcessor.php
@@ -0,0 +1,113 @@
+outputDirectory);
+ }
+
+ public function run(): void
+ {
+ $this->logger->info('Building iCal feed');
+ $converter = new CommonMarkConverter();
+
+ $calendar = new Calendar($this->generateEvents($converter));
+
+ $this->meetups->sort();
+ $oldestTimestamp = $this->meetups[0]->instance->getDateTime();
+ $newestTimestamp = $this->meetups[count($this->meetups) - 1]->instance->getDateTime();
+ $calendar->addTimeZone(
+ TimeZone::createFromPhpDateTimeZone(
+ new DateTimeZone('America/New_York'),
+ $oldestTimestamp,
+ $newestTimestamp,
+ )
+ );
+
+ $componentFactory = new CalendarFactory();
+ $calendar = $componentFactory->createCalendar($calendar);
+
+ $outputFilename = "$this->outputDirectory/mergephp.ical";
+ $bytes = file_put_contents($outputFilename, (string)$calendar);
+
+ if ($bytes === false) {
+ throw new RuntimeException("Could not write $outputFilename");
+ }
+ $this->logger->info("Saved $bytes bytes to $outputFilename");
+ }
+
+ protected function generateEvents(CommonMarkConverter $converter): Generator
+ {
+ foreach ($this->meetups as $meetup) {
+ $this->logger->debug("Generating iCal event for {$meetup->instance->getTitle()}");
+
+ $permalink = 'https://www.mergephp.com' . $meetup->instance->getPermalink();
+ $description = $converter->convert($meetup->instance->getDescription());
+ $description = trim(strip_tags((string)$description));
+
+ yield (new Event(
+ new UniqueIdentifier($permalink),
+ ))
+ ->setSummary(
+ $meetup->instance->getTitle(),
+ )
+ ->setDescription(
+ $description,
+ )
+ ->setOccurrence(
+ new TimeSpan(
+ new DateTime($meetup->instance->getDateTime(), true),
+ new DateTime($meetup->instance->getDateTime()->add(new DateInterval('PT1H')), true),
+ ),
+ )
+ ->setLocation(
+ new Location($meetup->instance->getYouTubeLink() ?: 'https://www.mergephp.com/'),
+ )
+ ->setOrganizer(
+ new Organizer(new EmailAddress('nobody@mergephp.com'), $meetup->instance->getSpeakerName()),
+ )
+ ->setUrl(
+ new Uri($permalink),
+ )
+ ->setLastModified(
+ new Timestamp($meetup->modifiedTimestamp),
+ )
+ ->touch(
+ new Timestamp($meetup->modifiedTimestamp),
+ )
+ ->setStatus(
+ (new EventStatus())->CONFIRMED(),
+ )
+ ;
+ }
+ }
+}
diff --git a/src/Builder/SiteBuilderService.php b/src/Builder/SiteBuilderService.php
index ed01529..362f663 100644
--- a/src/Builder/SiteBuilderService.php
+++ b/src/Builder/SiteBuilderService.php
@@ -8,6 +8,7 @@
use FilesystemIterator;
use Lcobucci\Clock\SystemClock;
use MergePHP\Website\Builder\Processor\ArchiveProcessor;
+use MergePHP\Website\Builder\Processor\ICalProcessor;
use MergePHP\Website\Builder\Processor\MeetupProcessor;
use MergePHP\Website\Builder\Processor\HomepageProcessor;
use MergePHP\Website\Builder\Processor\MissingLinkProcessor;
@@ -61,6 +62,7 @@ public function build(): void
(new ArchiveProcessor($this->logger, $this->outputDirectory, $collection, $this->twig, $twigData))->run();
(new SitemapProcessor($this->logger, $this->outputDirectory, $clock))->run();
(new RSSFeedProcessor($this->logger, $this->outputDirectory, $collection))->run();
+ (new ICalProcessor($this->logger, $this->outputDirectory, $collection))->run();
(new MissingLinkProcessor($this->logger, $this->outputDirectory, $collection))->run();
$this->logger->info('Finished successfully');
}
diff --git a/src/Exception/NotImplementedException.php b/src/Exception/NotImplementedException.php
new file mode 100644
index 0000000..d747b61
--- /dev/null
+++ b/src/Exception/NotImplementedException.php
@@ -0,0 +1,9 @@
+
+
diff --git a/tests/Builder/Processor/ICalProcessorTest.php b/tests/Builder/Processor/ICalProcessorTest.php
new file mode 100644
index 0000000..a0647cf
--- /dev/null
+++ b/tests/Builder/Processor/ICalProcessorTest.php
@@ -0,0 +1,69 @@
+directory = vfsStream::setup();
+ parent::setUp();
+ }
+
+ public function testItGeneratesAnIcalFeed(): void
+ {
+ $collection = new MeetupCollection();
+ $collection->append(
+ new MeetupEntry(
+ new class extends AbstractMeetup
+ {
+ public function getTitle(): string
+ {
+ return 'Meetup Title';
+ }
+
+ public function getDescription(): string
+ {
+ return <<run();
+
+ $this->assertFileEquals(__DIR__ . '/../../fixtures/mergephp.ical', 'vfs://root/mergephp.ical');
+ }
+}
diff --git a/tests/fixtures/mergephp.ical b/tests/fixtures/mergephp.ical
new file mode 100644
index 0000000..0a4228a
--- /dev/null
+++ b/tests/fixtures/mergephp.ical
@@ -0,0 +1,28 @@
+BEGIN:VCALENDAR
+PRODID:-//eluceo/ical//2.0/EN
+VERSION:2.0
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:https://www.mergephp.com/meetups/2000/01/01/meetup-title.html
+DTSTAMP:20010101T060101Z
+LAST-MODIFIED:20010101T060101Z
+SUMMARY:Meetup Title
+DESCRIPTION:This description spans multiple lines and uses the heredoc synt
+ ax\nThis description spans multiple lines and uses the heredoc syntax
+URL:https://www.mergephp.com/meetups/2000/01/01/meetup-title.html
+DTSTART;TZID=America/New_York:20000101T000000
+DTEND;TZID=America/New_York:20000101T010000
+LOCATION:https://www.mergephp.com/
+ORGANIZER;CN=Speaker Name:mailto:nobody@mergephp.com
+STATUS:CONFIRMED
+END:VEVENT
+BEGIN:VTIMEZONE
+TZID:America/New_York
+BEGIN:STANDARD
+DTSTART:20000101T000000
+TZNAME:EST
+TZOFFSETTO:-0500
+TZOFFSETFROM:-0500
+END:STANDARD
+END:VTIMEZONE
+END:VCALENDAR