Compare commits

...

21 Commits

Author SHA1 Message Date
e82f7224c5 Report errors to Glitchtip 2025-02-26 01:10:52 +01:00
cdeca9498c Handle HTTP errors gracefully 2025-02-23 17:18:56 +01:00
2d90b57235 fix docker image build by using python 3.13 base image
I'm not sure why this fixes the image build, but I found others on the internet suggesting that using "the current" version image fixes this kind of issue and it indeed fixed the issue and I was able to build the image with this adjustment.
2025-02-07 23:27:24 +01:00
53cb75f1d5 Add configurable legend to footer 2025-02-06 01:01:45 +01:00
54222eb8cc Support overwriting details for all events
Useful for calendars which contain events of the same kind and the same location only but need some adjustments to improve the usability of the calendar.
2025-02-05 23:40:05 +01:00
af33203932 Add support for feeds from reparatur-initiativen.de 2025-02-05 23:07:48 +01:00
93b0a314d4 Make app easier to run in debug mode 2025-02-05 23:07:15 +01:00
d8fa4e1dcb Disable poetry's package-mode 2025-02-03 23:48:51 +01:00
e8b8150dd5 Update dependencies 2023-09-18 16:15:21 +02:00
527d9642bc Fix date/time localization 2023-04-20 03:01:24 +02:00
f2b25ce868 Mount configuration read-only 2023-04-20 03:01:24 +02:00
691f9181e1 Fix config file caching 2023-02-19 03:20:08 +01:00
b432503ac9 Update Docker config 2023-02-18 13:32:06 +01:00
00b74e8782 Move towards a more object-oriented API 2023-02-18 04:21:28 +01:00
70a069fe95 Support multiple calendars
This implied switching to a different caching approach.
2023-02-18 04:21:28 +01:00
5d58ba89c4 Serve CSS dependencies from local webserver
Privacy improvement.
2022-11-22 02:20:48 +01:00
eab91b7272 Better highlight entries that pop open 2022-11-21 14:03:44 +01:00
276110b2a2 Fix rendering when max_days is not specified 2022-11-21 14:00:52 +01:00
bb2f840cec Add sidebar demo endpoint 2022-11-21 12:02:16 +01:00
42153f2e27 Add max_days query parameter 2022-11-21 11:44:38 +01:00
3dc8a7f232 Fix template context 2022-11-21 11:44:10 +01:00
20 changed files with 2035 additions and 1287 deletions

3
.gitignore vendored
View File

@ -4,3 +4,6 @@ __pycache__/
*.*swp*
docker-compose.yml
dist/
node_modules/
yarn*.log
*.yml

View File

@ -1,18 +1,20 @@
FROM python:3.8-alpine
FROM python:3.13-alpine
RUN adduser -S fabcal
RUN apk add --no-cache build-base libuv-dev libffi-dev && \
pip3 install poetry
RUN apk add --no-cache build-base libuv-dev libffi-dev yarn && \
pip3 install -U poetry
RUN install -d -o fabcal /app
USER fabcal
COPY poetry.lock pyproject.toml /app/
COPY poetry.lock pyproject.toml package.json yarn.lock /app/
WORKDIR /app/
RUN pip install -U poetry && \
poetry install --only main
RUN poetry install --only main && \
yarn install
COPY fabcal /app/fabcal/
COPY static/ /app/static/

7
config.yml.example Normal file
View File

@ -0,0 +1,7 @@
calendars:
- name: "Events"
url: "https://example.org/events.ics"
- name: "More events"
url: "https://example.com/more-events.ics"
default_color: "#aabbcc"

View File

@ -7,7 +7,11 @@ services:
ports:
- 5000:5000
environment:
- CALENDAR_URL=webcal://some.host/somecal
- FABCAL_CACHE_EXPIRE=120
- LANG=de
- LC_ALL=de_DE.UTF-8
volumes:
- ./config.yml:/app/config.yml:ro
# use the line below when running this behind reverse proxy (only if it sets the proxy headers, though)
#command: --proxy-headers --forwarded-allow-ips="*"

View File

@ -1,18 +1,36 @@
import locale
import os
import sentry_sdk
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi_cache import FastAPICache
from fastapi_cache.backends.inmemory import InMemoryBackend
from fabcal.routers import api_v1, frontend
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN"),
# Add data like request headers and IP for users, if applicable;
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
send_default_pii=True,
# Set traces_sample_rate to 1.0 to capture 100%
# of transactions for tracing.
traces_sample_rate=1.0,
# Set profiles_sample_rate to 1.0 to profile 100%
# of sampled transactions.
# We recommend adjusting this value in production.
profiles_sample_rate=1.0,
)
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
for framework in ["normalize.css", "milligram", "@fortawesome/fontawesome-free"]:
app.mount(f"/assets/{framework}", StaticFiles(directory=f"node_modules/{framework}"), name=framework)
app.include_router(api_v1.router, prefix="/api/v1")
app.include_router(frontend.router, prefix="")
@ -28,6 +46,6 @@ app.add_middleware(
locale.setlocale(locale.LC_TIME, locale.getlocale())
@app.on_event("startup")
async def startup():
FastAPICache.init(InMemoryBackend())
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, debug=True)

View File

@ -1,53 +1,148 @@
import asyncio
import logging
import os
from collections import OrderedDict
from datetime import date, datetime, timedelta, timezone
from typing import List
from typing import Dict, List, NamedTuple
import aiohttp
import pytz
import recurring_ical_events
import yaml
from fastapi_cache.decorator import cache
from icalendar import Calendar, vText
from asyncache import cached
from cachetools import TTLCache, FIFOCache
from icalendar import Calendar
from fabcal.models import CalendarEvent
def sanitize(data: str):
# sanitize input from random source
cal = Calendar.from_ical(data)
# name needs to be fixed
cal["X-WR-CALNAME"] = vText(b"FabLab-Termine")
return cal.to_ical()
class ConfiguredCalendar(NamedTuple):
name: str
url: str
default_color: str = None
overwrite_summary: str = None
overwrite_description: str = None
overwrite_location: str = None
def get_calendar_url():
url = os.environ["CALENDAR_URL"]
class CalendarClient:
def __init__(self, url: str):
self.url = url
# convenience feature
url = url.replace("webcal://", "https://")
return url
# caching strings works better than caching calendar objects
@cache(expire=120)
async def get_data() -> str:
async with aiohttp.ClientSession() as session:
async with session.get(get_calendar_url()) as response:
async def get(self, session):
async with session.get(self.url) as response:
response.raise_for_status()
assert response.content_type.lower() == "text/calendar"
return sanitize(await response.text())
return await response.text()
# this will be cached permanently, i.e., the server process needs to be restarted to apply config changes
# note that caching doesn't work at all with iterators (for obvious reasons)
@cached(FIFOCache(1))
def read_calendars_from_config_file() -> List[ConfiguredCalendar]:
rv = []
with open("config.yml") as f:
data = yaml.safe_load(f)
for calendar in data["calendars"]:
rv.append(ConfiguredCalendar(**calendar))
return rv
class CombinedCalendarClient:
def __init__(self, configured_calendars: List[ConfiguredCalendar]):
self.configured_calendars = configured_calendars
async def fetch_calendars(self) -> Dict[ConfiguredCalendar, str]:
async with aiohttp.ClientSession(headers={
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
}) as session:
# need to make sure we return the calendar and the gathered values together
async def coro(calendar):
print("argh", calendar)
return calendar, await CalendarClient(calendar.url).get(session)
rv = {}
for coro in asyncio.as_completed([coro(calendar) for calendar in self.configured_calendars]):
try:
cal, response = await coro
rv[cal] = response
except aiohttp.client_exceptions.ClientResponseError:
logging.exception("Failed to fetch data for calendar, skipping")
return rv
@staticmethod
def combine_calendars(data: Dict[ConfiguredCalendar, str]) -> Calendar:
combined_calendar = Calendar()
combined_calendar.add("prodid", "-//FabCal//NONSGML//EN")
combined_calendar.add("version", "2.0")
combined_calendar.add("x-wr-calname", "FabLab Altmühlfranken e.V.")
# TODO: normalize timezones of calendar events
for configured_calendar, ical_str in data.items():
cal = Calendar.from_ical(ical_str)
# check for a calendar color (e.g., Nextcloud has such a feature)
# events that don't have a color assigned will be assigned this color unless a color was configured
default_color = configured_calendar.default_color
if not default_color:
try:
# note: for some reason, getattr doesn't work
default_color = cal["x-apple-calendar-color"]
except KeyError:
default_color = None
# if the user wishes to overwrite some data on every item in a calendar, we should do so
overwrite_summary = configured_calendar.overwrite_summary
overwrite_description = configured_calendar.overwrite_description
overwrite_location = configured_calendar.overwrite_location
# we don't copy anything but events from the
for event in cal.walk("VEVENT"):
# if no color has been configured in the event, we
if "color" not in event:
event["color"] = default_color
if overwrite_summary:
event["SUMMARY"] = overwrite_summary
if overwrite_description:
event["DESCRIPTION"] = overwrite_description
if overwrite_location:
event["LOCATION"] = overwrite_location
combined_calendar.add_component(event)
return combined_calendar
async def fetch_and_combine_calendars(self) -> Calendar:
return self.combine_calendars(await self.fetch_calendars())
@cached(TTLCache(maxsize=100, ttl=int(os.environ.get("FABCAL_CACHE_EXPIRE", 120))))
async def get_data() -> Calendar:
client = CombinedCalendarClient(read_calendars_from_config_file())
combined_calendar = await client.fetch_and_combine_calendars()
return combined_calendar
def get_tzinfo():
return timezone(timedelta(hours=1))
def get_events_from_calendar_string(cal: Calendar) -> List[CalendarEvent]:
def get_events_from_calendar(cal: Calendar) -> List[CalendarEvent]:
"""
Generate list of events from calendar vevents.
Expands recurring events for +- one year.
@ -75,6 +170,8 @@ def get_events_from_calendar_string(cal: Calendar) -> List[CalendarEvent]:
all_day_event = True
start = datetime.combine(start, datetime.min.time(), tzinfo=get_tzinfo())
start = start.astimezone(pytz.timezone("Europe/Berlin"))
end = vevent.get("DTEND", None)
if end is not None:
end = end.dt
@ -83,6 +180,8 @@ def get_events_from_calendar_string(cal: Calendar) -> List[CalendarEvent]:
all_day_event = True
end = datetime.combine(end, datetime.max.time(), tzinfo=get_tzinfo())
end = end.astimezone(pytz.timezone("Europe/Berlin"))
def get_str(key: str):
value = vevent.get(key, None)
@ -106,9 +205,7 @@ def get_events_from_calendar_string(cal: Calendar) -> List[CalendarEvent]:
async def get_future_events():
cal = Calendar.from_ical(await get_data())
events = get_events_from_calendar_string(cal)
events = get_events_from_calendar(await get_data())
today = datetime.today().date()

26
fabcal/legend.py Normal file
View File

@ -0,0 +1,26 @@
from typing import NamedTuple, List
import yaml
from asyncache import cached
from cachetools import FIFOCache
class LegendItem(NamedTuple):
color: str
description: str
link: str = None
# this will be cached permanently, i.e., the server process needs to be restarted to apply config changes
# note that caching doesn't work at all with iterators (for obvious reasons)
@cached(FIFOCache(1))
def read_legend_from_config_file() -> List[LegendItem]:
rv = []
with open("config.yml") as f:
data = yaml.safe_load(f)
for item in data.get("legend", []):
rv.append(LegendItem(**item))
return rv

View File

@ -1,5 +1,6 @@
import re
from babel.dates import format_datetime
from jinja2 import pass_eval_context
from markupsafe import Markup, escape
from starlette.templating import Jinja2Templates
@ -7,6 +8,9 @@ from starlette.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
# custom filters
templates.env.filters["format_datetime"] = lambda value, format: format_datetime(value, format) #, locale="de_DE")
@pass_eval_context
def nl2br(eval_ctx, value):

View File

@ -10,7 +10,7 @@ router = APIRouter()
@router.get("/events.ics")
async def events_ics():
return Response(
await get_data(),
(await get_data()).to_ical(True),
headers={
"content-type": "text/calendar",
},

View File

@ -1,5 +1,3 @@
import base64
from datetime import datetime
import babel.dates
@ -9,6 +7,7 @@ from fastapi.requests import Request
from fastapi.responses import HTMLResponse
from fabcal.calendar_client import get_future_events, group_by_date
from fabcal.legend import read_legend_from_config_file
from fabcal.routers import templates
@ -20,41 +19,32 @@ async def generate_response(request: Request, template_name: str, **additional_c
grouped_events = list(group_by_date(events).items())
# couple of helpers
def localized_abbreviated_month(dt: datetime):
return babel.dates.format_datetime(dt, format="%b", locale="de_DE")
# couple of helpers
def localized_abbreviated_weekday(dt: datetime):
return babel.dates.format_datetime(dt, format="%b", locale="de_DE")
def base64_encode(s: str):
return base64.b64encode(s.encode()).decode()
context = {
"request": request,
"grouped_events": grouped_events,
"dir": dir,
"localized_abbreviated_month": localized_abbreviated_month,
"localized_abbreviated_weekday": localized_abbreviated_weekday,
"base64_encode": base64_encode,
"legend": read_legend_from_config_file(),
}
context.update(additional_context)
return templates.TemplateResponse(
template_name,
context={
"request": request,
"grouped_events": grouped_events,
"dir": dir,
"localized_abbreviated_month": localized_abbreviated_month,
"localized_abbreviated_weekday": localized_abbreviated_weekday,
"base64_encode": base64_encode,
},
)
return templates.TemplateResponse(template_name, context=context)
@router.get("/sidebar/embed.html", response_class=HTMLResponse)
async def embed_sidebar(request: Request):
return await generate_response(request, "sidebar/embed.html")
async def embed_sidebar(request: Request, max_days: int = None):
try:
max_days = int(max_days)
except TypeError:
pass
return await generate_response(request, "sidebar/embed.html", max_days=max_days)
@router.get("/sidebar/demo.html", response_class=HTMLResponse)
async def sidebar_demo(request: Request, max_days: int = None):
try:
max_days = int(max_days)
except TypeError:
pass
return await generate_response(request, "sidebar/demo.html", max_days=max_days)

10
package.json Normal file
View File

@ -0,0 +1,10 @@
{
"name": "fabcal",
"version": "0.0.1",
"author": "Fabian Müller <fabian@fablab-altmuehlfranken.de>",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.2.1",
"milligram": "^1.4.1"
}
}

2830
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +1,26 @@
[tool.poetry]
name = "fabcal"
version = "0.0.1"
description = ""
authors = ["Fabian Müller <fabian@fablab-altmuehlfranken.de>"]
include = [
{ path = "static", format = ["sdist", "wheel"] },
{ path = "templates", format = ["sdist", "wheel"] },
]
package-mode = false
[tool.poetry.dependencies]
python = "^3.8"
python = "^3.10"
icalendar = "^4.0.9"
fastapi = "^0.75.0"
fastapi = "^0.115.0"
uvicorn = {extras = ["standard"], version = "^0.17.6"}
Jinja2 = "^3.1.0"
Babel = "^2.9.1"
fastapi-cache2 = "^0.1.8"
aiohttp = {extras = ["speedups"], version = "^3.8.1"}
recurring-ical-events = "^1.0.1-beta.0"
pyyaml = "^6.0"
asyncache = "^0.3.1"
pytz = "^2025.1"
sentry-sdk = {extras = ["fastapi"], version = "^2.22.0"}
[tool.poetry.dev-dependencies]
black = "^22.1.0"

View File

@ -45,7 +45,7 @@ body {
padding: 3px 0;
}
.calendar-date:nth-of-type(2n) .calendar-date-month {
/*background-color: var(--calendar-fablab-blue);*/
background-color: var(--calendar-fablab-blue);
}
.calendar-date-day {
font-size: 13pt;
@ -145,6 +145,7 @@ body {
details.calendar-event-details > summary {
text-decoration: underline dotted black;
cursor: pointer;
}
.calendar-popover-content > * {
margin: 5px 0;
@ -166,3 +167,44 @@ details.calendar-event-details[open] summary ~ * {
0% {opacity: 0; transform: translateY(-10px)}
100% {opacity: 1; transform: translateY(0)}
}
.calendar-legend {
padding: 5px;
border: var(--calendar-border);
border-radius: 5px;
background-color: #eee;
margin: 12px 2px 2px 2px;
}
.calendar-legend-list {
margin: 0;
}
.calendar-legend-heading {
font-size: 18px;
margin-bottom: 6px;
text-decoration: underline black;
}
.calendar-legend-item {
margin-bottom: 4px;
list-style: none;
font-size: 13px;
display: flex;
flex-direction: row;
justify-content: left;
}
.calendar-legend-item-color {
display: inline-block;
width: 18px;
height: 18px;
margin-right: 4px;
}
.calendar-legend-item-description {
word-break: break-word;
word-wrap: break-word;
hyphens: auto;
display: flex;
flex-direction: column;
justify-content: center;
}
.calendar-legend-item-link {
display: flex;
}

View File

@ -2,10 +2,9 @@
<html lang="de">
<head>
{% block header %}
{#- TODO: replace with locally served files #}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('normalize.css', path='normalize.css') }}">
<link rel="stylesheet" href="{{ url_for('milligram', path='dist/milligram.min.css') }}">
<link rel="stylesheet" href="{{ url_for('@fortawesome/fontawesome-free', path='css/all.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}" type="text/css"/>
<link rel="stylesheet" href="{{ url_for('static', path='roboto.css') }}" type="text/css"/>

View File

@ -0,0 +1,64 @@
{% extends "sidebar/base.html" %}
{% block header %}
{{ super() }}
<style>
html {
display: flex;
}
body {
max-width: 1200px;
display: flex;
flex-direction: row;
margin: 0 auto;
}
.fake-content {
margin: 50px 30px 0 30px;
padding: 0;
background-color: white;
font-size: 18px;
}
.calendar {
min-width: 250px;
margin: 50px 30px 0 30px;
border: 1px solid red;
}
@media only screen and (max-width: 600px) {
body {
flex-wrap: wrap-reverse;
}
}
</style>
{% endblock %}
{% block body %}
<div class="fake-content">
<h2>Fake content</h2>
<p>
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et
dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet
clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet,
consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat,
sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea
takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed
diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et
accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum
dolor sit amet.
</p>
<p>
Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu
feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril
delenit augue duis dolore te feugait nulla facilisi.
</p>
</div>
<div class="calendar">
{% include "sidebar/includes/header.html" %}
{% include "sidebar/includes/events-list.html" %}
{% include "sidebar/includes/footer.html" %}
</div>
{% endblock %}

View File

@ -1,10 +1,9 @@
<!DOCTYPE html>
<html lang="de">
<head>
{#- TODO: replace with locally served files #}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('normalize.css', path='normalize.css') }}">
<link rel="stylesheet" href="{{ url_for('milligram', path='dist/milligram.min.css') }}">
<link rel="stylesheet" href="{{ url_for('@fortawesome/fontawesome-free', path='css/all.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}" type="text/css"/>
<link rel="stylesheet" href="{{ url_for('static', path='roboto.css') }}" type="text/css"/>

View File

@ -1,23 +1,27 @@
{% if max_days %}
{% set grouped_events = grouped_events[:max_days] %}
{% endif %}
{% for start_date, events in grouped_events %}
<div class="calendar-date">
<div class="calendar-date-date">
<div class="calendar-date-month">
{{ start_date.strftime("%b") }}
{{ start_date | format_datetime("MMM") }}
</div>
<div class="calendar-date-day">
{{ start_date.strftime("%d") }}
{{ start_date | format_datetime("d") }}
</div>
<div class="calendar-date-weekday">
{{ start_date.strftime("%a") }}
{{ start_date | format_datetime("EEE") }}
</div>
</div>
<div class="calendar-events">
{% for event in events %}
<div class="calendar-event" title="{{ event.summary }}{% if event.description %} &mdash; {{ event.description }}{% endif %}">
<div class="calendar-event-time">
<div class="calendar-event-starttime">{{ event.start.strftime("%H:%M") }}</div>
<div class="calendar-event-starttime">{{ event.start | format_datetime("HH:mm") }}</div>
<div class="calendar-event-timesep"></div>
<div class="calendar-event-endtime">{{ event.end.strftime("%H:%M") }}</div>
<div class="calendar-event-endtime">{{ event.end | format_datetime("HH:mm") }}</div>
</div>
<div class="calendar-event-description">
{% if event.description or event.location %}

View File

@ -1,3 +1,23 @@
<div class="calendar-subscription-buttons">
<a href="{{ url_for('events_ics') }}"><i class="fa-solid fa-calendar"></i> Kalender abonnieren</a>
</div>
{% if legend %}
<div class="calendar-legend">
<p class="calendar-legend-heading">Legende:</p>
<ul class="calendar-legend-list">
{% for item in legend %}
<li class="calendar-legend-item">
{% if item.link %}
<a class="calendar-legend-item-link" href="{{ item.link }}">
{% endif %}
<span class="calendar-legend-item-color" style="background-color: {{ item.color }};"></span>
<span class="calendar-legend-item-description">{{ item.description }}</span>
{% if item.link %}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>

20
yarn.lock Normal file
View File

@ -0,0 +1,20 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@fortawesome/fontawesome-free@^6.2.1":
version "6.2.1"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.2.1.tgz#344baf6ff9eaad7a73cff067d8c56bfc11ae5304"
integrity sha512-viouXhegu/TjkvYQoiRZK3aax69dGXxgEjpvZW81wIJdxm5Fnvp3VVIP4VHKqX4SvFw6qpmkILkD4RJWAdrt7A==
milligram@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/milligram/-/milligram-1.4.1.tgz#6c8c781541b0d994ccca784c60f0aca1f7104b42"
integrity sha512-RCgh/boHhcXWOUfKJWm3RJRoUeaEguoipDg0mJ31G0tFfvcpWMUlO1Zlqqr12K4kAXfDlllaidu0x7PaL2PTFg==
dependencies:
normalize.css "~8.0.1"
normalize.css@~8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/normalize.css/-/normalize.css-8.0.1.tgz#9b98a208738b9cc2634caacbc42d131c97487bf3"
integrity sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==