diff --git a/splitsh.json b/splitsh.json
index 6cec132cefc..013dd9e3580 100644
--- a/splitsh.json
+++ b/splitsh.json
@@ -2,6 +2,7 @@
"subtrees": {
"stimulus-bundle": "src/StimulusBundle",
"ux-autocomplete": "src/Autocomplete",
+ "ux-calendar-link": "src/CalendarLink",
"ux-dropzone": "src/Dropzone",
"ux-chartjs": "src/Chartjs",
"ux-cropperjs": "src/Cropperjs",
diff --git a/src/CalendarLink/.gitattributes b/src/CalendarLink/.gitattributes
new file mode 100644
index 00000000000..db1844b9e9f
--- /dev/null
+++ b/src/CalendarLink/.gitattributes
@@ -0,0 +1,5 @@
+/.git* export-ignore
+/.symfony.bundle.yaml export-ignore
+/doc export-ignore
+/phpunit.xml.dist export-ignore
+/tests export-ignore
diff --git a/src/CalendarLink/.github/PULL_REQUEST_TEMPLATE.md b/src/CalendarLink/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 00000000000..887284ecea1
--- /dev/null
+++ b/src/CalendarLink/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,7 @@
+## Please do not submit any Pull Requests here. They will be closed.
+
+Please submit your PR here instead:
+https://github.com/symfony/ux
+
+This repository is what we call a "subtree split": a read-only subset of that main repository.
+We're looking forward to your PR there!
diff --git a/src/CalendarLink/.github/workflows/close-pull-request.yml b/src/CalendarLink/.github/workflows/close-pull-request.yml
new file mode 100644
index 00000000000..8150a6a6a7e
--- /dev/null
+++ b/src/CalendarLink/.github/workflows/close-pull-request.yml
@@ -0,0 +1,20 @@
+name: Close Pull Request
+
+on:
+ pull_request_target:
+ types: [opened]
+
+jobs:
+ run:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: superbrothers/close-pull-request@v3
+ with:
+ comment: |
+ Thanks for your Pull Request! We love contributions.
+
+ However, you should instead open your PR on the main repository:
+ https://github.com/symfony/ux
+
+ This repository is what we call a "subtree split": a read-only subset of that main repository.
+ We're looking forward to your PR there!
diff --git a/src/CalendarLink/.gitignore b/src/CalendarLink/.gitignore
new file mode 100644
index 00000000000..43da50110b8
--- /dev/null
+++ b/src/CalendarLink/.gitignore
@@ -0,0 +1,6 @@
+/vendor/
+/composer.lock
+/phpunit.xml
+/.phpunit.result.cache
+
+/var
\ No newline at end of file
diff --git a/src/CalendarLink/.symfony.bundle.yaml b/src/CalendarLink/.symfony.bundle.yaml
new file mode 100644
index 00000000000..9e3566394b9
--- /dev/null
+++ b/src/CalendarLink/.symfony.bundle.yaml
@@ -0,0 +1,3 @@
+branches: ['3.x']
+maintained_branches: ['3.x']
+doc_dir: 'doc'
diff --git a/src/CalendarLink/CHANGELOG.md b/src/CalendarLink/CHANGELOG.md
new file mode 100644
index 00000000000..b0411ae89a7
--- /dev/null
+++ b/src/CalendarLink/CHANGELOG.md
@@ -0,0 +1,5 @@
+# CHANGELOG
+
+## 3.1
+
+- Add component
diff --git a/src/CalendarLink/LICENSE b/src/CalendarLink/LICENSE
new file mode 100644
index 00000000000..94b768f8ae8
--- /dev/null
+++ b/src/CalendarLink/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2026-present Fabien Potencier
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/src/CalendarLink/README.md b/src/CalendarLink/README.md
new file mode 100644
index 00000000000..091b049ae56
--- /dev/null
+++ b/src/CalendarLink/README.md
@@ -0,0 +1,18 @@
+# Symfony UX Calendar Link
+
+**EXPERIMENTAL** This component is currently experimental and is
+likely to change, or even change drastically.
+
+Symfony UX Calendar Link generates "Add to calendar" links for Google
+Calendar, Outlook.com, Office 365 and iCalendar (`.ics`), the format
+consumed by Apple Calendar, Outlook desktop, Thunderbird and every native
+calendar client. It is part of [the Symfony UX initiative](https://symfony.com/ux).
+
+**This repository is a READ-ONLY sub-tree split**. See
+https://github.com/symfony/ux to create issues or submit pull requests.
+
+## Resources
+
+- [Documentation](doc/index.rst)
+- Report issues and send Pull Requests in the
+ [main Symfony UX repository](https://github.com/symfony/ux)
diff --git a/src/CalendarLink/composer.json b/src/CalendarLink/composer.json
new file mode 100644
index 00000000000..384966a2006
--- /dev/null
+++ b/src/CalendarLink/composer.json
@@ -0,0 +1,54 @@
+{
+ "name": "symfony/ux-calendar-link",
+ "type": "symfony-bundle",
+ "description": "Generates 'Add to calendar' links for Google, Outlook, Office 365 and iCalendar (.ics) in Twig templates.",
+ "keywords": [
+ "symfony-ux",
+ "calendar",
+ "ics"
+ ],
+ "homepage": "https://symfony.com",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Imad ZAIRIG",
+ "email": "imadzairig@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "autoload": {
+ "psr-4": {
+ "Symfony\\UX\\CalendarLink\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Symfony\\UX\\CalendarLink\\Tests\\": "tests/"
+ }
+ },
+ "require": {
+ "php": ">=8.4",
+ "symfony/framework-bundle": "^7.4|^8.0",
+ "symfony/twig-bundle": "^7.4|^8.0",
+ "symfony/uid": "^7.4|^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.1|^12.0"
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "conflict": {
+ "symfony/flex": "<1.13"
+ },
+ "extra": {
+ "thanks": {
+ "name": "symfony/ux",
+ "url": "https://github.com/symfony/ux"
+ }
+ },
+ "minimum-stability": "dev"
+}
diff --git a/src/CalendarLink/config/services.php b/src/CalendarLink/config/services.php
new file mode 100644
index 00000000000..0b06b738b1b
--- /dev/null
+++ b/src/CalendarLink/config/services.php
@@ -0,0 +1,55 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+
+use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
+use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator;
+
+use Symfony\UX\CalendarLink\Ics\IcsBuilder;
+use Symfony\UX\CalendarLink\Provider\GoogleCalendarLinkProvider;
+use Symfony\UX\CalendarLink\Provider\IcsCalendarLinkProvider;
+use Symfony\UX\CalendarLink\Provider\Office365CalendarLinkProvider;
+use Symfony\UX\CalendarLink\Provider\OutlookCalendarLinkProvider;
+use Symfony\UX\CalendarLink\Registry\CalendarLinkProviderRegistry;
+use Symfony\UX\CalendarLink\Twig\UXCalendarLinkExtension;
+use Symfony\UX\CalendarLink\Twig\UXCalendarLinkRuntime;
+
+return static function (ContainerConfigurator $container): void {
+ $services = $container->services();
+
+ $services->set('ux_calendar_link.ics.builder', IcsBuilder::class);
+
+ $services->set('ux_calendar_link.provider.google', GoogleCalendarLinkProvider::class)
+ ->tag('ux_calendar_link.provider');
+
+ $services->set('ux_calendar_link.provider.outlook', OutlookCalendarLinkProvider::class)
+ ->tag('ux_calendar_link.provider');
+
+ $services->set('ux_calendar_link.provider.office365', Office365CalendarLinkProvider::class)
+ ->tag('ux_calendar_link.provider');
+
+ $services->set('ux_calendar_link.provider.ics', IcsCalendarLinkProvider::class)
+ ->args([service('ux_calendar_link.ics.builder')])
+ ->tag('ux_calendar_link.provider');
+
+ $services->set('ux_calendar_link.registry', CalendarLinkProviderRegistry::class)
+ ->args([tagged_iterator('ux_calendar_link.provider')]);
+
+ $services->alias(CalendarLinkProviderRegistry::class, 'ux_calendar_link.registry');
+
+ $services->set('ux_calendar_link.twig.runtime', UXCalendarLinkRuntime::class)
+ ->args([service('ux_calendar_link.registry')])
+ ->tag('twig.runtime');
+
+ $services->set('ux_calendar_link.twig.extension', UXCalendarLinkExtension::class)
+ ->tag('twig.extension');
+};
diff --git a/src/CalendarLink/doc/index.rst b/src/CalendarLink/doc/index.rst
new file mode 100644
index 00000000000..385fdd2626e
--- /dev/null
+++ b/src/CalendarLink/doc/index.rst
@@ -0,0 +1,141 @@
+Symfony UX Calendar Link
+========================
+
+**EXPERIMENTAL** This component is currently experimental and is likely
+to change, or even change drastically.
+
+Symfony UX CalendarLink is a Symfony bundle that allows generation of "Add to calendar" links for Google
+Calendar, Outlook.com, Office 365 and iCalendar (``.ics``), the format
+consumed by Apple Calendar, Outlook desktop, Thunderbird and every native
+calendar client.
+
+Installation
+------------
+
+Install the bundle using Composer and Symfony Flex:
+
+.. code-block:: terminal
+
+ $ composer require symfony/ux-calendar-link
+
+Usage
+-----
+
+Start by creating a ``CalendarEvent`` object in your controller. Once populated with your event details, pass it to Twig to render the calendar links::
+
+ // src/Controller/EventController.php
+ namespace App\Controller;
+
+ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+ use Symfony\Component\HttpFoundation\Response;
+ use Symfony\Component\Routing\Attribute\Route;
+ use Symfony\UX\CalendarLink\CalendarEvent;
+
+ final class EventController extends AbstractController
+ {
+ #[Route('/event/{id}', name: 'app_event_show')]
+ public function show(): Response
+ {
+ // Build from your database, API, etc.
+ $event = new CalendarEvent(
+ title: 'Symfony Live Paris',
+ start: new \DateTimeImmutable('2026-05-14 09:00'),
+ end: new \DateTimeImmutable('2026-05-15 18:00'),
+ location: 'Cité Universitaire Paris',
+ description: 'Annual Symfony conference in France',
+ );
+
+ return $this->render('event/show.html.twig', ['event' => $event]);
+ }
+ }
+
+Then use the two Twig functions to render links:
+
+* ``ux_calendar_link(event, provider)``: generates a link for one provider
+* ``ux_calendar_links(event)``: generates links for every registered provider
+
+.. code-block:: html+twig
+
+ {# templates/event/show.html.twig #}
+ Add to Google Calendar
+
+
+ {% for link in ux_calendar_links(event) %}
+ {{ link.label }}
+ {% endfor %}
+
+
+Supported providers
+-------------------
+
+========================= =========== ========== ============= =========
+Field Google Outlook Office 365 ICS
+========================= =========== ========== ============= =========
+title yes yes yes yes
+start / end yes yes yes yes
+description yes yes yes yes
+location yes yes yes yes
+all-day yes yes yes yes
+reminders (VALARM) — — — yes
+recurrence (RRULE) yes — — yes
+========================= =========== ========== ============= =========
+
+Fields marked "—" are silently ignored when generating a link for that
+provider, because the provider's URL scheme does not support them. For example,
+reminders are honoured only in the ``.ics`` output since Google and Outlook
+deeplink URLs cannot carry VALARM data.
+
+Reminders and recurrence
+------------------------
+
+Both recurrence rules and reminders can be attached when constructing the event::
+
+ use Symfony\UX\CalendarLink\CalendarEvent;
+ use Symfony\UX\CalendarLink\CalendarRecurrence;
+ use Symfony\UX\CalendarLink\CalendarReminder;
+
+ $event = new CalendarEvent(
+ title: 'Weekly sync',
+ start: new \DateTimeImmutable('2026-05-14 10:00'),
+ end: new \DateTimeImmutable('2026-05-14 10:30'),
+ recurrence: CalendarRecurrence::weekly(count: 10),
+ reminders: [new CalendarReminder(minutesBefore: 15)],
+ );
+
+``CalendarRecurrence`` exposes one static factory per RFC 5545 frequency:
+``minutely()``, ``daily()``, ``weekly()``, ``monthly()``, ``yearly()``, each
+accepting the usual ``interval``, ``count`` and ``until`` named arguments. For example::
+
+ // every other day
+ CalendarRecurrence::daily(interval: 2);
+
+ // six monthly occurrences
+ CalendarRecurrence::monthly(count: 6);
+
+ // yearly until 2030
+ CalendarRecurrence::yearly(until: new \DateTimeImmutable('2030-01-01'));
+
+.. note::
+
+ ``CalendarRecurrence::minutely()`` produces a valid RFC 5545 RRULE, but
+ Google Calendar and Outlook deeplink URLs do not support ``FREQ=MINUTELY``
+ and will silently ignore it. Use ``minutely()`` only when targeting the
+ ``ics`` provider.
+
+All-day events
+--------------
+
+Pass ``allDay: true`` to create an event with no specific start or end time::
+
+ $holiday = new CalendarEvent(
+ title: 'Bastille Day',
+ start: new \DateTimeImmutable('2026-07-14'),
+ end: new \DateTimeImmutable('2026-07-14'),
+ allDay: true,
+ );
+
+Backward compatibility promise
+------------------------------
+
+This bundle follows the same backward-compatibility promise as
+`Symfony itself `_.
diff --git a/src/CalendarLink/phpunit.xml.dist b/src/CalendarLink/phpunit.xml.dist
new file mode 100644
index 00000000000..95007776710
--- /dev/null
+++ b/src/CalendarLink/phpunit.xml.dist
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+ ./tests
+
+
+
+
+ src
+
+
+ trigger_deprecation
+
+
+
diff --git a/src/CalendarLink/src/CalendarEvent.php b/src/CalendarLink/src/CalendarEvent.php
new file mode 100644
index 00000000000..8bbf35d8278
--- /dev/null
+++ b/src/CalendarLink/src/CalendarEvent.php
@@ -0,0 +1,49 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink;
+
+use Symfony\UX\CalendarLink\Exception\InvalidArgumentException;
+
+/**
+ * @author Imad ZAIRIG
+ */
+final class CalendarEvent
+{
+ public readonly \DateTimeImmutable $start;
+ public readonly \DateTimeImmutable $end;
+
+ /**
+ * @param list $reminders
+ */
+ public function __construct(
+ public readonly string $title,
+ \DateTimeInterface $start,
+ \DateTimeInterface $end,
+ public readonly ?string $description = null,
+ public readonly ?string $location = null,
+ public readonly bool $allDay = false,
+ public readonly ?string $url = null,
+ public readonly ?CalendarRecurrence $recurrence = null,
+ public readonly array $reminders = [],
+ ) {
+ if ('' === trim($title)) {
+ throw new InvalidArgumentException('Event title must not be empty.');
+ }
+
+ $this->start = \DateTimeImmutable::createFromInterface($start);
+ $this->end = \DateTimeImmutable::createFromInterface($end);
+
+ if ($this->end < $this->start) {
+ throw new InvalidArgumentException(\sprintf('Event end "%s" must be on or after start "%s".', $this->end->format('c'), $this->start->format('c')));
+ }
+ }
+}
diff --git a/src/CalendarLink/src/CalendarLink.php b/src/CalendarLink/src/CalendarLink.php
new file mode 100644
index 00000000000..93f6bbe2db6
--- /dev/null
+++ b/src/CalendarLink/src/CalendarLink.php
@@ -0,0 +1,30 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink;
+
+/**
+ * @author Imad ZAIRIG
+ */
+final class CalendarLink implements \Stringable
+{
+ public function __construct(
+ public readonly string $provider,
+ public readonly string $label,
+ public readonly string $url,
+ ) {
+ }
+
+ public function __toString(): string
+ {
+ return $this->url;
+ }
+}
diff --git a/src/CalendarLink/src/CalendarRecurrence.php b/src/CalendarLink/src/CalendarRecurrence.php
new file mode 100644
index 00000000000..640410c3c1a
--- /dev/null
+++ b/src/CalendarLink/src/CalendarRecurrence.php
@@ -0,0 +1,83 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink;
+
+use Symfony\UX\CalendarLink\Exception\InvalidArgumentException;
+
+/**
+ * @author Imad ZAIRIG
+ */
+final class CalendarRecurrence
+{
+ private function __construct(
+ public readonly string $rrule,
+ ) {
+ }
+
+ public static function minutely(int $interval = 1, ?int $count = null, ?\DateTimeInterface $until = null): self
+ {
+ return self::fromParts('MINUTELY', $interval, $count, $until);
+ }
+
+ public static function daily(int $interval = 1, ?int $count = null, ?\DateTimeInterface $until = null): self
+ {
+ return self::fromParts('DAILY', $interval, $count, $until);
+ }
+
+ public static function weekly(int $interval = 1, ?int $count = null, ?\DateTimeInterface $until = null): self
+ {
+ return self::fromParts('WEEKLY', $interval, $count, $until);
+ }
+
+ public static function monthly(int $interval = 1, ?int $count = null, ?\DateTimeInterface $until = null): self
+ {
+ return self::fromParts('MONTHLY', $interval, $count, $until);
+ }
+
+ public static function yearly(int $interval = 1, ?int $count = null, ?\DateTimeInterface $until = null): self
+ {
+ return self::fromParts('YEARLY', $interval, $count, $until);
+ }
+
+ private static function fromParts(string $frequency, int $interval, ?int $count, ?\DateTimeInterface $until): self
+ {
+ if ($interval < 1) {
+ throw new InvalidArgumentException(\sprintf('Recurrence "interval" must be >= 1, got %d.', $interval));
+ }
+
+ if (null !== $count && null !== $until) {
+ throw new InvalidArgumentException('Recurrence "count" and "until" are mutually exclusive.');
+ }
+
+ if (null !== $count && $count < 1) {
+ throw new InvalidArgumentException(\sprintf('Recurrence "count" must be >= 1, got %d.', $count));
+ }
+
+ $parts = ['FREQ='.$frequency];
+
+ if (1 !== $interval) {
+ $parts[] = 'INTERVAL='.$interval;
+ }
+
+ if (null !== $count) {
+ $parts[] = 'COUNT='.$count;
+ }
+
+ if (null !== $until) {
+ $parts[] = 'UNTIL='.\DateTimeImmutable::createFromInterface($until)
+ ->setTimezone(new \DateTimeZone('UTC'))
+ ->format('Ymd\THis\Z');
+ }
+
+ return new self(implode(';', $parts));
+ }
+}
diff --git a/src/CalendarLink/src/CalendarReminder.php b/src/CalendarLink/src/CalendarReminder.php
new file mode 100644
index 00000000000..91c058272c7
--- /dev/null
+++ b/src/CalendarLink/src/CalendarReminder.php
@@ -0,0 +1,29 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink;
+
+use Symfony\UX\CalendarLink\Exception\InvalidArgumentException;
+
+/**
+ * @author Imad ZAIRIG
+ */
+final class CalendarReminder
+{
+ public function __construct(
+ public readonly int $minutesBefore,
+ public readonly string $description = '',
+ ) {
+ if ($minutesBefore < 0) {
+ throw new InvalidArgumentException(\sprintf('Reminder "minutesBefore" must be >= 0, got %d.', $minutesBefore));
+ }
+ }
+}
diff --git a/src/CalendarLink/src/Exception/ExceptionInterface.php b/src/CalendarLink/src/Exception/ExceptionInterface.php
new file mode 100644
index 00000000000..764fb0a3f8f
--- /dev/null
+++ b/src/CalendarLink/src/Exception/ExceptionInterface.php
@@ -0,0 +1,19 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Exception;
+
+/**
+ * @author Imad ZAIRIG
+ */
+interface ExceptionInterface extends \Throwable
+{
+}
diff --git a/src/CalendarLink/src/Exception/InvalidArgumentException.php b/src/CalendarLink/src/Exception/InvalidArgumentException.php
new file mode 100644
index 00000000000..f4688eada2a
--- /dev/null
+++ b/src/CalendarLink/src/Exception/InvalidArgumentException.php
@@ -0,0 +1,19 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Exception;
+
+/**
+ * @author Imad ZAIRIG
+ */
+class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
+{
+}
diff --git a/src/CalendarLink/src/Exception/UnknownProviderException.php b/src/CalendarLink/src/Exception/UnknownProviderException.php
new file mode 100644
index 00000000000..31362101a95
--- /dev/null
+++ b/src/CalendarLink/src/Exception/UnknownProviderException.php
@@ -0,0 +1,30 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Exception;
+
+/**
+ * @author Imad ZAIRIG
+ */
+class UnknownProviderException extends InvalidArgumentException
+{
+ /**
+ * @param list $available
+ */
+ public function __construct(string $name, array $available)
+ {
+ parent::__construct(\sprintf(
+ 'Unknown calendar link provider "%s". Available providers: %s.',
+ $name,
+ $available ? '"'.implode('", "', $available).'"' : '(none registered)',
+ ));
+ }
+}
diff --git a/src/CalendarLink/src/Ics/IcsBuilder.php b/src/CalendarLink/src/Ics/IcsBuilder.php
new file mode 100644
index 00000000000..b20d146b482
--- /dev/null
+++ b/src/CalendarLink/src/Ics/IcsBuilder.php
@@ -0,0 +1,144 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Ics;
+
+use Symfony\Component\Uid\Factory\UuidFactory;
+use Symfony\UX\CalendarLink\CalendarEvent;
+use Symfony\UX\CalendarLink\CalendarReminder;
+
+/**
+ * @author Imad ZAIRIG
+ */
+final class IcsBuilder
+{
+ private const CRLF = "\r\n";
+ private const PRODID = '-//Symfony//UX Calendar Link//EN';
+
+ private readonly UuidFactory $uuidFactory;
+
+ public function __construct(?UuidFactory $uuidFactory = null)
+ {
+ $this->uuidFactory = $uuidFactory ?? new UuidFactory();
+ }
+
+ public function build(CalendarEvent $event): string
+ {
+ $lines = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:'.self::PRODID,
+ 'CALSCALE:GREGORIAN',
+ 'METHOD:PUBLISH',
+ 'BEGIN:VEVENT',
+ 'UID:'.$this->uuidFactory->create()->toRfc4122(),
+ 'DTSTAMP:'.$this->formatUtc(new \DateTimeImmutable('now', new \DateTimeZone('UTC'))),
+ ...$this->formatDateLines($event),
+ 'SUMMARY:'.$this->escape($event->title),
+ ];
+
+ if (null !== $event->description && '' !== $event->description) {
+ $lines[] = 'DESCRIPTION:'.$this->escape($event->description);
+ }
+
+ if (null !== $event->location && '' !== $event->location) {
+ $lines[] = 'LOCATION:'.$this->escape($event->location);
+ }
+
+ if (null !== $event->url && '' !== $event->url) {
+ $lines[] = 'URL:'.$event->url;
+ }
+
+ if (null !== $event->recurrence) {
+ $lines[] = 'RRULE:'.$event->recurrence->rrule;
+ }
+
+ foreach ($event->reminders as $reminder) {
+ array_push($lines, ...$this->formatAlarm($reminder));
+ }
+
+ $lines[] = 'END:VEVENT';
+ $lines[] = 'END:VCALENDAR';
+
+ $folded = array_map([$this, 'fold'], $lines);
+
+ return implode(self::CRLF, $folded).self::CRLF;
+ }
+
+ /**
+ * @return list
+ */
+ private function formatDateLines(CalendarEvent $event): array
+ {
+ if ($event->allDay) {
+ // DTEND is exclusive for all-day events: the user-visible last day
+ // must be incremented by one.
+ $start = $event->start->setTime(0, 0, 0);
+ $end = $event->end->setTime(0, 0, 0)->modify('+1 day');
+
+ return [
+ 'DTSTART;VALUE=DATE:'.$start->format('Ymd'),
+ 'DTEND;VALUE=DATE:'.$end->format('Ymd'),
+ ];
+ }
+
+ return [
+ 'DTSTART:'.$this->formatUtc($event->start),
+ 'DTEND:'.$this->formatUtc($event->end),
+ ];
+ }
+
+ /**
+ * @return list
+ */
+ private function formatAlarm(CalendarReminder $reminder): array
+ {
+ return [
+ 'BEGIN:VALARM',
+ 'ACTION:DISPLAY',
+ 'TRIGGER:-PT'.$reminder->minutesBefore.'M',
+ 'DESCRIPTION:'.$this->escape('' !== $reminder->description ? $reminder->description : 'Reminder'),
+ 'END:VALARM',
+ ];
+ }
+
+ private function formatUtc(\DateTimeInterface $dt): string
+ {
+ return \DateTimeImmutable::createFromInterface($dt)
+ ->setTimezone(new \DateTimeZone('UTC'))
+ ->format('Ymd\THis\Z');
+ }
+
+ private function escape(string $text): string
+ {
+ $text = str_replace(['\\', ',', ';'], ['\\\\', '\\,', '\\;'], $text);
+
+ return str_replace(["\r\n", "\r", "\n"], '\\n', $text);
+ }
+
+ private function fold(string $line): string
+ {
+ if (\strlen($line) <= 75) {
+ return $line;
+ }
+
+ $result = mb_strcut($line, 0, 75, 'UTF-8');
+ $offset = \strlen($result);
+
+ while ($offset < \strlen($line)) {
+ $chunk = mb_strcut($line, $offset, 74, 'UTF-8');
+ $result .= self::CRLF.' '.$chunk;
+ $offset += \strlen($chunk);
+ }
+
+ return $result;
+ }
+}
diff --git a/src/CalendarLink/src/Provider/AbstractOutlookCalendarLinkProvider.php b/src/CalendarLink/src/Provider/AbstractOutlookCalendarLinkProvider.php
new file mode 100644
index 00000000000..4be95120e8b
--- /dev/null
+++ b/src/CalendarLink/src/Provider/AbstractOutlookCalendarLinkProvider.php
@@ -0,0 +1,59 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Provider;
+
+use Symfony\UX\CalendarLink\CalendarEvent;
+use Symfony\UX\CalendarLink\CalendarLink;
+
+/**
+ * @author Imad ZAIRIG
+ */
+abstract class AbstractOutlookCalendarLinkProvider implements CalendarLinkProviderInterface
+{
+ abstract protected function getBaseUrl(): string;
+
+ public function generate(CalendarEvent $event): CalendarLink
+ {
+ $params = [
+ 'path' => '/calendar/action/compose',
+ 'rru' => 'addevent',
+ 'subject' => $event->title,
+ 'startdt' => $this->formatDate($event->start, $event->allDay),
+ 'enddt' => $this->formatDate($event->end, $event->allDay),
+ ];
+
+ if ($event->allDay) {
+ $params['allday'] = 'true';
+ }
+
+ if (null !== $event->description && '' !== $event->description) {
+ $params['body'] = $event->description;
+ }
+
+ if (null !== $event->location && '' !== $event->location) {
+ $params['location'] = $event->location;
+ }
+
+ $url = $this->getBaseUrl().'?'.http_build_query($params, '', '&', \PHP_QUERY_RFC3986);
+
+ return new CalendarLink($this->getName(), $this->getLabel(), $url);
+ }
+
+ private function formatDate(\DateTimeImmutable $dt, bool $allDay): string
+ {
+ if ($allDay) {
+ return $dt->format('Y-m-d');
+ }
+
+ return $dt->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z');
+ }
+}
diff --git a/src/CalendarLink/src/Provider/CalendarLinkProviderInterface.php b/src/CalendarLink/src/Provider/CalendarLinkProviderInterface.php
new file mode 100644
index 00000000000..af1141c7b6f
--- /dev/null
+++ b/src/CalendarLink/src/Provider/CalendarLinkProviderInterface.php
@@ -0,0 +1,43 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Provider;
+
+use Symfony\UX\CalendarLink\CalendarEvent;
+use Symfony\UX\CalendarLink\CalendarLink;
+
+/**
+ * Produces an "Add to calendar" {@see CalendarLink} for a given target (Google Calendar, Outlook, .ics file, ...).
+ *
+ * Implementations are registered as services tagged `ux_calendar_link.provider` and picked up by the
+ * {@see \Symfony\UX\CalendarLink\Registry\CalendarLinkProviderRegistry}.
+ *
+ * @author Imad ZAIRIG
+ */
+interface CalendarLinkProviderInterface
+{
+ /**
+ * Returns the unique machine name identifying this provider (e.g. "google", "outlook", "ics").
+ *
+ * The name is the key used to select a provider in Twig and in the registry.
+ */
+ public function getName(): string;
+
+ /**
+ * Returns a human-readable label suitable for display in UI (e.g. "Google Calendar").
+ */
+ public function getLabel(): string;
+
+ /**
+ * Builds a {@see CalendarLink} for the given event according to this provider's URL scheme.
+ */
+ public function generate(CalendarEvent $event): CalendarLink;
+}
diff --git a/src/CalendarLink/src/Provider/GoogleCalendarLinkProvider.php b/src/CalendarLink/src/Provider/GoogleCalendarLinkProvider.php
new file mode 100644
index 00000000000..fd2196a8509
--- /dev/null
+++ b/src/CalendarLink/src/Provider/GoogleCalendarLinkProvider.php
@@ -0,0 +1,75 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Provider;
+
+use Symfony\UX\CalendarLink\CalendarEvent;
+use Symfony\UX\CalendarLink\CalendarLink;
+
+/**
+ * @author Imad ZAIRIG
+ */
+final class GoogleCalendarLinkProvider implements CalendarLinkProviderInterface
+{
+ private const BASE_URL = 'https://calendar.google.com/calendar/render';
+
+ public function getName(): string
+ {
+ return 'google';
+ }
+
+ public function getLabel(): string
+ {
+ return 'Google Calendar';
+ }
+
+ public function generate(CalendarEvent $event): CalendarLink
+ {
+ $params = [
+ 'action' => 'TEMPLATE',
+ 'text' => $event->title,
+ 'dates' => $this->formatDates($event),
+ ];
+
+ if (null !== $event->description && '' !== $event->description) {
+ $params['details'] = $event->description;
+ }
+
+ if (null !== $event->location && '' !== $event->location) {
+ $params['location'] = $event->location;
+ }
+
+ if (null !== $event->recurrence) {
+ // Google expects the literal "RRULE:" prefix.
+ $params['recur'] = 'RRULE:'.$event->recurrence->rrule;
+ }
+
+ $url = self::BASE_URL.'?'.http_build_query($params, '', '&', \PHP_QUERY_RFC3986);
+
+ return new CalendarLink($this->getName(), $this->getLabel(), $url);
+ }
+
+ private function formatDates(CalendarEvent $event): string
+ {
+ if ($event->allDay) {
+ // Google all-day format: YYYYMMDD/YYYYMMDD (end exclusive).
+ $start = $event->start->format('Ymd');
+ $end = $event->end->modify('+1 day')->format('Ymd');
+
+ return $start.'/'.$end;
+ }
+
+ $start = $event->start->setTimezone(new \DateTimeZone('UTC'))->format('Ymd\THis\Z');
+ $end = $event->end->setTimezone(new \DateTimeZone('UTC'))->format('Ymd\THis\Z');
+
+ return $start.'/'.$end;
+ }
+}
diff --git a/src/CalendarLink/src/Provider/IcsCalendarLinkProvider.php b/src/CalendarLink/src/Provider/IcsCalendarLinkProvider.php
new file mode 100644
index 00000000000..92dbf817fa0
--- /dev/null
+++ b/src/CalendarLink/src/Provider/IcsCalendarLinkProvider.php
@@ -0,0 +1,48 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Provider;
+
+use Symfony\UX\CalendarLink\CalendarEvent;
+use Symfony\UX\CalendarLink\CalendarLink;
+use Symfony\UX\CalendarLink\Ics\IcsBuilder;
+
+/**
+ * @author Imad ZAIRIG
+ */
+final class IcsCalendarLinkProvider implements CalendarLinkProviderInterface
+{
+ public function __construct(
+ private readonly IcsBuilder $icsBuilder,
+ ) {
+ }
+
+ public function getName(): string
+ {
+ return 'ics';
+ }
+
+ public function getLabel(): string
+ {
+ return 'iCalendar (.ics)';
+ }
+
+ public function generate(CalendarEvent $event): CalendarLink
+ {
+ $ics = $this->icsBuilder->build($event);
+
+ return new CalendarLink(
+ $this->getName(),
+ $this->getLabel(),
+ 'data:text/calendar;charset=utf-8;base64,'.base64_encode($ics),
+ );
+ }
+}
diff --git a/src/CalendarLink/src/Provider/Office365CalendarLinkProvider.php b/src/CalendarLink/src/Provider/Office365CalendarLinkProvider.php
new file mode 100644
index 00000000000..8c5ab652c52
--- /dev/null
+++ b/src/CalendarLink/src/Provider/Office365CalendarLinkProvider.php
@@ -0,0 +1,33 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Provider;
+
+/**
+ * @author Imad ZAIRIG
+ */
+final class Office365CalendarLinkProvider extends AbstractOutlookCalendarLinkProvider
+{
+ public function getName(): string
+ {
+ return 'office365';
+ }
+
+ public function getLabel(): string
+ {
+ return 'Office 365';
+ }
+
+ protected function getBaseUrl(): string
+ {
+ return 'https://outlook.office.com/calendar/0/deeplink/compose';
+ }
+}
diff --git a/src/CalendarLink/src/Provider/OutlookCalendarLinkProvider.php b/src/CalendarLink/src/Provider/OutlookCalendarLinkProvider.php
new file mode 100644
index 00000000000..f813e65045f
--- /dev/null
+++ b/src/CalendarLink/src/Provider/OutlookCalendarLinkProvider.php
@@ -0,0 +1,33 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Provider;
+
+/**
+ * @author Imad ZAIRIG
+ */
+final class OutlookCalendarLinkProvider extends AbstractOutlookCalendarLinkProvider
+{
+ public function getName(): string
+ {
+ return 'outlook';
+ }
+
+ public function getLabel(): string
+ {
+ return 'Outlook.com';
+ }
+
+ protected function getBaseUrl(): string
+ {
+ return 'https://outlook.live.com/calendar/0/deeplink/compose';
+ }
+}
diff --git a/src/CalendarLink/src/Registry/CalendarLinkProviderRegistry.php b/src/CalendarLink/src/Registry/CalendarLinkProviderRegistry.php
new file mode 100644
index 00000000000..0a506ebfffe
--- /dev/null
+++ b/src/CalendarLink/src/Registry/CalendarLinkProviderRegistry.php
@@ -0,0 +1,76 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Registry;
+
+use Symfony\UX\CalendarLink\CalendarEvent;
+use Symfony\UX\CalendarLink\CalendarLink;
+use Symfony\UX\CalendarLink\Exception\UnknownProviderException;
+use Symfony\UX\CalendarLink\Provider\CalendarLinkProviderInterface;
+
+/**
+ * @author Imad ZAIRIG
+ */
+final class CalendarLinkProviderRegistry
+{
+ /** @var array */
+ private array $providers = [];
+
+ /**
+ * @param iterable $providers
+ */
+ public function __construct(iterable $providers)
+ {
+ foreach ($providers as $provider) {
+ $this->providers[$provider->getName()] = $provider;
+ }
+ }
+
+ public function has(string $name): bool
+ {
+ return isset($this->providers[$name]);
+ }
+
+ public function get(string $name): CalendarLinkProviderInterface
+ {
+ if (!isset($this->providers[$name])) {
+ throw new UnknownProviderException($name, array_keys($this->providers));
+ }
+
+ return $this->providers[$name];
+ }
+
+ /**
+ * @return array
+ */
+ public function all(): array
+ {
+ return $this->providers;
+ }
+
+ public function generate(CalendarEvent $event, string $provider): CalendarLink
+ {
+ return $this->get($provider)->generate($event);
+ }
+
+ /**
+ * @return array
+ */
+ public function generateAll(CalendarEvent $event): array
+ {
+ $links = [];
+ foreach ($this->providers as $name => $provider) {
+ $links[$name] = $provider->generate($event);
+ }
+
+ return $links;
+ }
+}
diff --git a/src/CalendarLink/src/Twig/UXCalendarLinkExtension.php b/src/CalendarLink/src/Twig/UXCalendarLinkExtension.php
new file mode 100644
index 00000000000..e78df060455
--- /dev/null
+++ b/src/CalendarLink/src/Twig/UXCalendarLinkExtension.php
@@ -0,0 +1,29 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Twig;
+
+use Twig\Extension\AbstractExtension;
+use Twig\TwigFunction;
+
+/**
+ * @author Imad ZAIRIG
+ */
+final class UXCalendarLinkExtension extends AbstractExtension
+{
+ public function getFunctions(): array
+ {
+ return [
+ new TwigFunction('ux_calendar_link', [UXCalendarLinkRuntime::class, 'link']),
+ new TwigFunction('ux_calendar_links', [UXCalendarLinkRuntime::class, 'links']),
+ ];
+ }
+}
diff --git a/src/CalendarLink/src/Twig/UXCalendarLinkRuntime.php b/src/CalendarLink/src/Twig/UXCalendarLinkRuntime.php
new file mode 100644
index 00000000000..c34be414648
--- /dev/null
+++ b/src/CalendarLink/src/Twig/UXCalendarLinkRuntime.php
@@ -0,0 +1,41 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Twig;
+
+use Symfony\UX\CalendarLink\CalendarEvent;
+use Symfony\UX\CalendarLink\CalendarLink;
+use Symfony\UX\CalendarLink\Registry\CalendarLinkProviderRegistry;
+use Twig\Extension\RuntimeExtensionInterface;
+
+/**
+ * @author Imad ZAIRIG
+ */
+final class UXCalendarLinkRuntime implements RuntimeExtensionInterface
+{
+ public function __construct(
+ private readonly CalendarLinkProviderRegistry $registry,
+ ) {
+ }
+
+ public function link(CalendarEvent $event, string $provider): CalendarLink
+ {
+ return $this->registry->generate($event, $provider);
+ }
+
+ /**
+ * @return array
+ */
+ public function links(CalendarEvent $event): array
+ {
+ return $this->registry->generateAll($event);
+ }
+}
diff --git a/src/CalendarLink/src/UXCalendarLinkBundle.php b/src/CalendarLink/src/UXCalendarLinkBundle.php
new file mode 100644
index 00000000000..e07b1083ef1
--- /dev/null
+++ b/src/CalendarLink/src/UXCalendarLinkBundle.php
@@ -0,0 +1,34 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink;
+
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
+
+/**
+ * @author Imad ZAIRIG
+ */
+final class UXCalendarLinkBundle extends AbstractBundle
+{
+ protected string $extensionAlias = 'ux_calendar_link';
+
+ public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
+ {
+ $container->import('../config/services.php');
+ }
+
+ public function getPath(): string
+ {
+ return \dirname(__DIR__);
+ }
+}
diff --git a/src/CalendarLink/tests/CalendarEventTest.php b/src/CalendarLink/tests/CalendarEventTest.php
new file mode 100644
index 00000000000..12d8e139de2
--- /dev/null
+++ b/src/CalendarLink/tests/CalendarEventTest.php
@@ -0,0 +1,41 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Tests;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\CalendarLink\CalendarEvent;
+use Symfony\UX\CalendarLink\Exception\InvalidArgumentException;
+
+final class CalendarEventTest extends TestCase
+{
+ public function testRejectsEmptyTitle()
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ new CalendarEvent(
+ title: ' ',
+ start: new \DateTimeImmutable('2026-05-14 09:00'),
+ end: new \DateTimeImmutable('2026-05-14 10:00'),
+ );
+ }
+
+ public function testRejectsEndBeforeStart()
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ new CalendarEvent(
+ title: 'Demo',
+ start: new \DateTimeImmutable('2026-05-14 10:00'),
+ end: new \DateTimeImmutable('2026-05-14 09:00'),
+ );
+ }
+}
diff --git a/src/CalendarLink/tests/CalendarRecurrenceTest.php b/src/CalendarLink/tests/CalendarRecurrenceTest.php
new file mode 100644
index 00000000000..293bb4da44b
--- /dev/null
+++ b/src/CalendarLink/tests/CalendarRecurrenceTest.php
@@ -0,0 +1,66 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Tests;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\CalendarLink\CalendarRecurrence;
+use Symfony\UX\CalendarLink\Exception\InvalidArgumentException;
+
+final class CalendarRecurrenceTest extends TestCase
+{
+ /**
+ * @return iterable
+ */
+ public static function factoryProvider(): iterable
+ {
+ yield 'minutely default' => [CalendarRecurrence::minutely(), 'FREQ=MINUTELY'];
+ yield 'daily default' => [CalendarRecurrence::daily(), 'FREQ=DAILY'];
+ yield 'weekly default' => [CalendarRecurrence::weekly(), 'FREQ=WEEKLY'];
+ yield 'monthly default' => [CalendarRecurrence::monthly(), 'FREQ=MONTHLY'];
+ yield 'yearly default' => [CalendarRecurrence::yearly(), 'FREQ=YEARLY'];
+ yield 'weekly count' => [CalendarRecurrence::weekly(count: 10), 'FREQ=WEEKLY;COUNT=10'];
+ yield 'daily interval' => [CalendarRecurrence::daily(interval: 2), 'FREQ=DAILY;INTERVAL=2'];
+ yield 'monthly interval + count' => [CalendarRecurrence::monthly(interval: 3, count: 4), 'FREQ=MONTHLY;INTERVAL=3;COUNT=4'];
+ }
+
+ #[DataProvider('factoryProvider')]
+ public function testFactoriesProduceExpectedRrule(CalendarRecurrence $recurrence, string $expected)
+ {
+ $this->assertSame($expected, $recurrence->rrule);
+ }
+
+ public function testUntilIsFormattedAsUtc()
+ {
+ $until = new \DateTimeImmutable('2026-12-31 09:00:00', new \DateTimeZone('Europe/Paris'));
+
+ $this->assertSame('FREQ=WEEKLY;UNTIL=20261231T080000Z', CalendarRecurrence::weekly(until: $until)->rrule);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function invalidArgumentsProvider(): iterable
+ {
+ yield 'count and until together' => [static fn () => CalendarRecurrence::weekly(count: 5, until: new \DateTimeImmutable('2026-12-31'))];
+ yield 'non-positive interval' => [static fn () => CalendarRecurrence::daily(interval: 0)];
+ yield 'non-positive count' => [static fn () => CalendarRecurrence::daily(count: 0)];
+ }
+
+ #[DataProvider('invalidArgumentsProvider')]
+ public function testRejectsInvalidArguments(callable $build)
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $build();
+ }
+}
diff --git a/src/CalendarLink/tests/Fixtures/TestKernel.php b/src/CalendarLink/tests/Fixtures/TestKernel.php
new file mode 100644
index 00000000000..4c035edaadc
--- /dev/null
+++ b/src/CalendarLink/tests/Fixtures/TestKernel.php
@@ -0,0 +1,62 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Tests\Fixtures;
+
+use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
+use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
+use Symfony\Bundle\TwigBundle\TwigBundle;
+use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+use Symfony\Component\HttpKernel\Kernel;
+use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
+use Symfony\UX\CalendarLink\UXCalendarLinkBundle;
+
+final class TestKernel extends Kernel
+{
+ use MicroKernelTrait;
+
+ public function registerBundles(): iterable
+ {
+ yield new FrameworkBundle();
+ yield new TwigBundle();
+ yield new UXCalendarLinkBundle();
+ }
+
+ protected function configureContainer(ContainerConfigurator $container): void
+ {
+ $container->extension('framework', [
+ 'test' => true,
+ 'secret' => 'test',
+ 'http_method_override' => false,
+ 'handle_all_throwables' => true,
+ 'php_errors' => ['log' => true],
+ 'router' => ['utf8' => true],
+ ]);
+
+ $container->extension('twig', [
+ 'default_path' => __DIR__.'/templates',
+ ]);
+ }
+
+ protected function configureRoutes(RoutingConfigurator $routes): void
+ {
+ }
+
+ public function getCacheDir(): string
+ {
+ return sys_get_temp_dir().'/ux-calendar-link/'.spl_object_hash($this).'/cache';
+ }
+
+ public function getLogDir(): string
+ {
+ return sys_get_temp_dir().'/ux-calendar-link/'.spl_object_hash($this).'/log';
+ }
+}
diff --git a/src/CalendarLink/tests/Ics/IcsBuilderTest.php b/src/CalendarLink/tests/Ics/IcsBuilderTest.php
new file mode 100644
index 00000000000..9655c885a90
--- /dev/null
+++ b/src/CalendarLink/tests/Ics/IcsBuilderTest.php
@@ -0,0 +1,143 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Tests\Ics;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Uid\Factory\MockUuidFactory;
+use Symfony\UX\CalendarLink\CalendarEvent;
+use Symfony\UX\CalendarLink\CalendarRecurrence;
+use Symfony\UX\CalendarLink\CalendarReminder;
+use Symfony\UX\CalendarLink\Ics\IcsBuilder;
+
+final class IcsBuilderTest extends TestCase
+{
+ private IcsBuilder $builder;
+
+ protected function setUp(): void
+ {
+ $this->builder = new IcsBuilder(new MockUuidFactory(['0192a5d2-7c6f-7000-8000-000000000000']));
+ }
+
+ public function testUidIsUniquePerBuild()
+ {
+ $builder = new IcsBuilder();
+
+ $event = new CalendarEvent(
+ title: 'Demo',
+ start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')),
+ );
+
+ preg_match('/^UID:(.+)$/m', $builder->build($event), $first);
+ preg_match('/^UID:(.+)$/m', $builder->build($event), $second);
+
+ $this->assertNotSame($first[1], $second[1]);
+ }
+
+ public function testMinimalTimedEventStructure()
+ {
+ $event = new CalendarEvent(
+ title: 'Demo',
+ start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')),
+ );
+
+ $ics = $this->builder->build($event);
+
+ $this->assertStringContainsString(
+ "DTSTART:20260514T090000Z\r\n"
+ ."DTEND:20260514T100000Z\r\n"
+ ."SUMMARY:Demo\r\n"
+ ."END:VEVENT\r\n"
+ ."END:VCALENDAR\r\n",
+ $ics,
+ );
+ }
+
+ public function testAllDayEventUsesValueDateAndIncrementsEnd()
+ {
+ $event = new CalendarEvent(
+ title: 'Conf',
+ start: new \DateTimeImmutable('2026-05-14', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-05-15', new \DateTimeZone('UTC')),
+ allDay: true,
+ );
+
+ $ics = $this->builder->build($event);
+
+ $this->assertStringContainsString("DTSTART;VALUE=DATE:20260514\r\n", $ics);
+ $this->assertStringContainsString("DTEND;VALUE=DATE:20260516\r\n", $ics);
+ }
+
+ public function testTextEscaping()
+ {
+ $event = new CalendarEvent(
+ title: 'Symfony, UX; test',
+ start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')),
+ description: "Line 1\nLine 2",
+ location: 'A\\B',
+ );
+
+ $ics = $this->builder->build($event);
+
+ $this->assertStringContainsString("SUMMARY:Symfony\\, UX\\; test\r\n", $ics);
+ $this->assertStringContainsString('DESCRIPTION:Line 1\nLine 2', $ics);
+ $this->assertStringContainsString('LOCATION:A\\\\B', $ics);
+ }
+
+ public function testLineFoldingAtSeventyFiveOctets()
+ {
+ $event = new CalendarEvent(
+ title: 'Demo',
+ start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')),
+ description: str_repeat('a', 300),
+ );
+
+ $ics = $this->builder->build($event);
+
+ foreach (explode("\r\n", $ics) as $line) {
+ $this->assertLessThanOrEqual(75, \strlen($line), \sprintf('Line "%s" exceeds 75 octets.', $line));
+ }
+ }
+
+ public function testValarmBlockFromReminders()
+ {
+ $event = new CalendarEvent(
+ title: 'Demo',
+ start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')),
+ reminders: [new CalendarReminder(15, 'Stand-up')],
+ );
+
+ $ics = $this->builder->build($event);
+
+ $this->assertStringContainsString("BEGIN:VALARM\r\n", $ics);
+ $this->assertStringContainsString("TRIGGER:-PT15M\r\n", $ics);
+ $this->assertStringContainsString("END:VALARM\r\n", $ics);
+ }
+
+ public function testRrulePassthrough()
+ {
+ $event = new CalendarEvent(
+ title: 'Weekly',
+ start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')),
+ recurrence: CalendarRecurrence::weekly(count: 10),
+ );
+
+ $ics = $this->builder->build($event);
+
+ $this->assertStringContainsString("RRULE:FREQ=WEEKLY;COUNT=10\r\n", $ics);
+ }
+}
diff --git a/src/CalendarLink/tests/Integration/BundleIntegrationTest.php b/src/CalendarLink/tests/Integration/BundleIntegrationTest.php
new file mode 100644
index 00000000000..24ff372e824
--- /dev/null
+++ b/src/CalendarLink/tests/Integration/BundleIntegrationTest.php
@@ -0,0 +1,50 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Tests\Integration;
+
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\UX\CalendarLink\CalendarEvent;
+use Symfony\UX\CalendarLink\Provider\CalendarLinkProviderInterface;
+use Symfony\UX\CalendarLink\Registry\CalendarLinkProviderRegistry;
+
+final class BundleIntegrationTest extends KernelTestCase
+{
+ public function testContainerCompilesAndRegistersAllProviders()
+ {
+ $container = self::getContainer();
+
+ /** @var CalendarLinkProviderRegistry $registry */
+ $registry = $container->get(CalendarLinkProviderRegistry::class);
+
+ $this->assertSame(['google', 'outlook', 'office365', 'ics'], array_keys($registry->all()));
+
+ foreach ($registry->all() as $provider) {
+ $this->assertInstanceOf(CalendarLinkProviderInterface::class, $provider);
+ }
+ }
+
+ public function testTwigRendersCalendarLinks()
+ {
+ $event = new CalendarEvent(
+ title: 'Demo',
+ start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')),
+ );
+
+ $rendered = self::getContainer()->get('twig')
+ ->createTemplate('{{- ux_calendar_link(event, \'google\').url }}')
+ ->render(['event' => $event]);
+
+ $this->assertStringStartsWith('https://calendar.google.com/calendar/render?', $rendered);
+ $this->assertStringContainsString('text=Demo', $rendered);
+ }
+}
diff --git a/src/CalendarLink/tests/Provider/GoogleCalendarLinkProviderTest.php b/src/CalendarLink/tests/Provider/GoogleCalendarLinkProviderTest.php
new file mode 100644
index 00000000000..bbf64c068a8
--- /dev/null
+++ b/src/CalendarLink/tests/Provider/GoogleCalendarLinkProviderTest.php
@@ -0,0 +1,58 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Tests\Provider;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\CalendarLink\CalendarEvent;
+use Symfony\UX\CalendarLink\Provider\GoogleCalendarLinkProvider;
+
+final class GoogleCalendarLinkProviderTest extends TestCase
+{
+ public function testTimedEvent()
+ {
+ $provider = new GoogleCalendarLinkProvider();
+ $event = new CalendarEvent(
+ title: 'Symfony Live',
+ start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-05-14 18:00', new \DateTimeZone('UTC')),
+ description: 'Annual conference',
+ location: 'Paris',
+ );
+
+ $link = $provider->generate($event);
+
+ $this->assertSame('google', $link->provider);
+ $this->assertStringStartsWith('https://calendar.google.com/calendar/render?', $link->url);
+
+ parse_str(parse_url($link->url, \PHP_URL_QUERY), $params);
+
+ $this->assertSame('Symfony Live', $params['text']);
+ $this->assertSame('20260514T090000Z/20260514T180000Z', $params['dates']);
+ $this->assertSame('Paris', $params['location']);
+ }
+
+ public function testAllDayEventEndIsExclusive()
+ {
+ $provider = new GoogleCalendarLinkProvider();
+ $event = new CalendarEvent(
+ title: 'Holiday',
+ start: new \DateTimeImmutable('2026-07-14', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-07-14', new \DateTimeZone('UTC')),
+ allDay: true,
+ );
+
+ $link = $provider->generate($event);
+ parse_str(parse_url($link->url, \PHP_URL_QUERY), $params);
+
+ $this->assertSame('20260714/20260715', $params['dates']);
+ }
+}
diff --git a/src/CalendarLink/tests/Provider/IcsCalendarLinkProviderTest.php b/src/CalendarLink/tests/Provider/IcsCalendarLinkProviderTest.php
new file mode 100644
index 00000000000..feeead10faa
--- /dev/null
+++ b/src/CalendarLink/tests/Provider/IcsCalendarLinkProviderTest.php
@@ -0,0 +1,40 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Tests\Provider;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\CalendarLink\CalendarEvent;
+use Symfony\UX\CalendarLink\Ics\IcsBuilder;
+use Symfony\UX\CalendarLink\Provider\IcsCalendarLinkProvider;
+
+final class IcsCalendarLinkProviderTest extends TestCase
+{
+ public function testReturnsDataUri()
+ {
+ $provider = new IcsCalendarLinkProvider(new IcsBuilder());
+ $event = new CalendarEvent(
+ title: 'Demo',
+ start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')),
+ );
+
+ $link = $provider->generate($event);
+
+ $this->assertSame('ics', $link->provider);
+ $this->assertStringStartsWith('data:text/calendar;charset=utf-8;base64,', $link->url);
+
+ $payload = base64_decode(substr($link->url, \strlen('data:text/calendar;charset=utf-8;base64,')), true);
+ $this->assertIsString($payload);
+ $this->assertStringContainsString('BEGIN:VCALENDAR', $payload);
+ $this->assertStringContainsString('SUMMARY:Demo', $payload);
+ }
+}
diff --git a/src/CalendarLink/tests/Provider/Office365CalendarLinkProviderTest.php b/src/CalendarLink/tests/Provider/Office365CalendarLinkProviderTest.php
new file mode 100644
index 00000000000..ce448202a44
--- /dev/null
+++ b/src/CalendarLink/tests/Provider/Office365CalendarLinkProviderTest.php
@@ -0,0 +1,61 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Tests\Provider;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\CalendarLink\CalendarEvent;
+use Symfony\UX\CalendarLink\Provider\Office365CalendarLinkProvider;
+
+final class Office365CalendarLinkProviderTest extends TestCase
+{
+ public function testOffice365TimedEvent()
+ {
+ $provider = new Office365CalendarLinkProvider();
+ $event = new CalendarEvent(
+ title: 'Standup',
+ start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-05-14 09:15', new \DateTimeZone('UTC')),
+ location: 'Paris',
+ );
+
+ $link = $provider->generate($event);
+
+ $this->assertSame('office365', $link->provider);
+ $this->assertStringStartsWith('https://outlook.office.com/calendar/0/deeplink/compose?', $link->url);
+
+ parse_str(parse_url($link->url, \PHP_URL_QUERY), $params);
+
+ $this->assertSame('Standup', $params['subject']);
+ $this->assertSame('2026-05-14T09:00:00Z', $params['startdt']);
+ $this->assertSame('2026-05-14T09:15:00Z', $params['enddt']);
+ $this->assertSame('Paris', $params['location']);
+ }
+
+ public function testAllDayEvent()
+ {
+ $provider = new Office365CalendarLinkProvider();
+ $event = new CalendarEvent(
+ title: 'Bastille Day',
+ start: new \DateTimeImmutable('2026-07-14', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-07-14', new \DateTimeZone('UTC')),
+ allDay: true,
+ );
+
+ $link = $provider->generate($event);
+
+ parse_str(parse_url($link->url, \PHP_URL_QUERY), $params);
+
+ $this->assertSame('true', $params['allday']);
+ $this->assertSame('2026-07-14', $params['startdt']);
+ $this->assertSame('2026-07-14', $params['enddt']);
+ }
+}
diff --git a/src/CalendarLink/tests/Provider/OutlookCalendarLinkProviderTest.php b/src/CalendarLink/tests/Provider/OutlookCalendarLinkProviderTest.php
new file mode 100644
index 00000000000..beb6b3ba5f8
--- /dev/null
+++ b/src/CalendarLink/tests/Provider/OutlookCalendarLinkProviderTest.php
@@ -0,0 +1,60 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\CalendarLink\Tests\Provider;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\CalendarLink\CalendarEvent;
+use Symfony\UX\CalendarLink\Provider\OutlookCalendarLinkProvider;
+
+final class OutlookCalendarLinkProviderTest extends TestCase
+{
+ public function testOutlookComTimedEvent()
+ {
+ $provider = new OutlookCalendarLinkProvider();
+ $event = new CalendarEvent(
+ title: 'Symfony Live',
+ start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-05-14 18:00', new \DateTimeZone('UTC')),
+ location: 'Paris',
+ );
+
+ $link = $provider->generate($event);
+
+ $this->assertSame('outlook', $link->provider);
+ $this->assertStringStartsWith('https://outlook.live.com/calendar/0/deeplink/compose?', $link->url);
+
+ parse_str(parse_url($link->url, \PHP_URL_QUERY), $params);
+
+ $this->assertSame('Symfony Live', $params['subject']);
+ $this->assertSame('2026-05-14T09:00:00Z', $params['startdt']);
+ $this->assertSame('2026-05-14T18:00:00Z', $params['enddt']);
+ }
+
+ public function testAllDayEvent()
+ {
+ $provider = new OutlookCalendarLinkProvider();
+ $event = new CalendarEvent(
+ title: 'Bastille Day',
+ start: new \DateTimeImmutable('2026-07-14', new \DateTimeZone('UTC')),
+ end: new \DateTimeImmutable('2026-07-14', new \DateTimeZone('UTC')),
+ allDay: true,
+ );
+
+ $link = $provider->generate($event);
+
+ parse_str(parse_url($link->url, \PHP_URL_QUERY), $params);
+
+ $this->assertSame('true', $params['allday']);
+ $this->assertSame('2026-07-14', $params['startdt']);
+ $this->assertSame('2026-07-14', $params['enddt']);
+ }
+}
diff --git a/src/CalendarLink/tests/bootstrap.php b/src/CalendarLink/tests/bootstrap.php
new file mode 100644
index 00000000000..89a23684510
--- /dev/null
+++ b/src/CalendarLink/tests/bootstrap.php
@@ -0,0 +1,17 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+use Symfony\Component\ErrorHandler\ErrorHandler;
+
+require __DIR__.'/../vendor/autoload.php';
+
+// @see https://github.com/symfony/symfony/issues/53812
+ErrorHandler::register(null, false);