Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6782da49d4 | |||
| 75dd9ca997 | |||
| 60ab628270 | |||
| a0e1ef046a | |||
| 4545852327 | |||
| cd39842ac0 | |||
| 6d99a39190 | |||
| 8b692a2e88 | |||
| b8ec457818 |
4 changed files with 293 additions and 157 deletions
|
|
@ -1,6 +1,4 @@
|
|||
import ICAL from "./vendor/ical.js";
|
||||
|
||||
const icsUrl = "/calendars/all.ics";
|
||||
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
|
||||
|
||||
// The club is in Berlin, so the calendar shows Berlin days and Berlin times,
|
||||
// no matter which time zone the browser of the visitor is set to.
|
||||
|
|
@ -51,29 +49,59 @@ function dayKey(date) {
|
|||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The day an occurrence time falls on in Berlin.
|
||||
*
|
||||
* A date has neither a time nor a zone, its digits are the day itself.
|
||||
* toJSDate() would read them as midnight in the zone of the browser, which
|
||||
* far enough east or west of Berlin lands on the day before or after.
|
||||
*
|
||||
* @param {ICAL.Time} time The time
|
||||
* @returns {string} The day as "YYYY-MM-DD"
|
||||
*/
|
||||
function timeDayKey(time) {
|
||||
if (time.isDate) {
|
||||
const month = String(time.month).padStart(2, "0");
|
||||
const day = String(time.day).padStart(2, "0");
|
||||
|
||||
return `${time.year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
return dayKey(time.toJSDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* The days an event covers, so that an event running over several days is
|
||||
* shown on each of them and not only on the day it starts.
|
||||
*
|
||||
* @param {Date} start Start of the event
|
||||
* @param {Date} end End of the event
|
||||
* @param {ICAL.Time} start Start of the event
|
||||
* @param {ICAL.Time} end End of the event
|
||||
* @returns {string[]} The days as "YYYY-MM-DD"
|
||||
*/
|
||||
function daysCovered(start, end) {
|
||||
// The end is not part of the event: one ending at midnight belongs to the day
|
||||
// before, and an all day event ends on the day before its DTEND.
|
||||
const lastInstant = new Date(Math.max(start.getTime(), end.getTime() - 1));
|
||||
const lastKey = dayKey(lastInstant);
|
||||
const last = end.clone();
|
||||
|
||||
if (end.isDate) {
|
||||
last.adjust(-1, 0, 0, 0);
|
||||
} else {
|
||||
last.adjust(0, 0, 0, -1);
|
||||
}
|
||||
|
||||
const lastKey = timeDayKey(last);
|
||||
const days = [];
|
||||
|
||||
let key = dayKey(start);
|
||||
let key = timeDayKey(start);
|
||||
|
||||
// The guard keeps a broken event from looping forever, a year of dots on the
|
||||
// same event is well past the point where the calendar is still useful.
|
||||
while (days.length <= 366) {
|
||||
days.push(key);
|
||||
|
||||
if (key === lastKey) {
|
||||
// The keys sort as the days do, so this also stops an event whose end lies
|
||||
// before its start after the day it starts on.
|
||||
if (key >= lastKey) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -86,25 +114,13 @@ function daysCovered(start, end) {
|
|||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the URL of an event.
|
||||
*
|
||||
* ICAL.Event does not expose the URL property, so read it from the component.
|
||||
*
|
||||
* @param {ICAL.Event} event The event to read the URL of
|
||||
* @returns {string} The URL, empty when the event has none
|
||||
*/
|
||||
function eventUrl(event) {
|
||||
return event.component.getFirstPropertyValue("url") ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce one occurrence of an event to what the calendar displays.
|
||||
*
|
||||
* @param {ICAL.Event} event The event the occurrence belongs to
|
||||
* @param {ICAL.Time} startDate Start of this occurrence
|
||||
* @param {ICAL.Time} endDate End of this occurrence
|
||||
* @returns {{summary: string, description: string, url: string, start: Date, end: Date, allDay: boolean}}
|
||||
* @returns {{summary: string, description: string, url: string, start: Date, end: Date, allDay: boolean, days: string[]}}
|
||||
*/
|
||||
function toOccurrence(event, startDate, endDate) {
|
||||
return {
|
||||
|
|
@ -114,6 +130,9 @@ function toOccurrence(event, startDate, endDate) {
|
|||
start: startDate.toJSDate(),
|
||||
end: endDate.toJSDate(),
|
||||
allDay: startDate.isDate,
|
||||
// Taken from the ICAL times, which still know whether they name a day or a
|
||||
// point in time; the JS dates above no longer do.
|
||||
days: daysCovered(startDate, endDate),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +162,7 @@ function occurrencesOfMonth(year, month) {
|
|||
const monthPrefix = `${year}-${String(month + 1).padStart(2, "0")}-`;
|
||||
|
||||
const add = (occurrence) => {
|
||||
for (const key of daysCovered(occurrence.start, occurrence.end)) {
|
||||
for (const key of occurrence.days) {
|
||||
if (!key.startsWith(monthPrefix)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -156,42 +175,20 @@ function occurrencesOfMonth(year, month) {
|
|||
}
|
||||
};
|
||||
|
||||
for (const component of calendar.getAllSubcomponents("vevent")) {
|
||||
const event = new ICAL.Event(component);
|
||||
|
||||
// Occurrences modified via RECURRENCE-ID are reached through the event they
|
||||
// belong to, handling them here as well would show them twice.
|
||||
if (event.isRecurrenceException() || !event.startDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.isRecurring()) {
|
||||
const iterator = event.iterator();
|
||||
|
||||
while (true) {
|
||||
const occurrence = iterator.next();
|
||||
|
||||
// Recurrences are chronological, so we are done once one starts after
|
||||
// the month.
|
||||
if (!occurrence || occurrence.toJSDate() >= to) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Details resolve time, name and URL of an occurrence that was
|
||||
// modified via RECURRENCE-ID.
|
||||
const details = event.getOccurrenceDetails(occurrence);
|
||||
|
||||
if (details.endDate.toJSDate() > from) {
|
||||
add(toOccurrence(details.item, details.startDate, details.endDate));
|
||||
}
|
||||
}
|
||||
} else if (event.startDate.toJSDate() < to && event.endDate.toJSDate() > from) {
|
||||
add(toOccurrence(event, event.startDate, event.endDate));
|
||||
}
|
||||
for (const { event, startDate, endDate } of occurrencesBetween(calendar, from, to)) {
|
||||
add(toOccurrence(event, startDate, endDate));
|
||||
}
|
||||
|
||||
for (const occurrences of Object.values(byDate)) {
|
||||
occurrences.sort((a, b) => a.start - b.start);
|
||||
occurrences.sort((a, b) => {
|
||||
// An all day event has no time of day to sort by, the JS date of its
|
||||
// start is midnight in the zone of the browser. Put it first instead.
|
||||
if (a.allDay !== b.allDay) {
|
||||
return a.allDay ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.start - b.start;
|
||||
});
|
||||
}
|
||||
|
||||
return byDate;
|
||||
|
|
@ -366,7 +363,7 @@ function formatTimeRange(occurrence) {
|
|||
const start = timeOfDayFormat.format(occurrence.start);
|
||||
const end = timeOfDayFormat.format(occurrence.end);
|
||||
|
||||
return `Start: ${start}, End: ${end}`;
|
||||
return `Beginn: ${start}, Ende: ${end}`;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
|
|
@ -400,18 +397,9 @@ document.addEventListener("DOMContentLoaded", function() {
|
|||
currentMonth = today.getMonth();
|
||||
updateEventsForMonth(currentYear, currentMonth);
|
||||
|
||||
fetch(icsUrl)
|
||||
.then(response => {
|
||||
// Without this an error page would be handed to the parser below, which
|
||||
// then fails with a confusing complaint about the calendar syntax.
|
||||
if (!response.ok) {
|
||||
throw new Error(`${icsUrl}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
})
|
||||
.then(icsText => {
|
||||
calendar = new ICAL.Component(ICAL.parse(icsText));
|
||||
loadCalendar()
|
||||
.then(loaded => {
|
||||
calendar = loaded;
|
||||
updateEventsForMonth(currentYear, currentMonth);
|
||||
})
|
||||
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err));
|
||||
|
|
|
|||
194
assets/js/events.js
Normal file
194
assets/js/events.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import ICAL from "./vendor/ical.js";
|
||||
|
||||
// The calendar is published next to the site and is not built from this
|
||||
// repository, see the README.
|
||||
const icsUrl = "/calendars/all.ics";
|
||||
|
||||
/**
|
||||
* Read the URL of an event.
|
||||
*
|
||||
* ICAL.Event does not expose the URL property, so read it from the component.
|
||||
*
|
||||
* The value ends up in the href of a link, and the calendar is exported from a
|
||||
* CalDAV server, so whoever may write to it decides what that value is. A
|
||||
* "javascript:" URL there would run on our page as soon as a visitor clicks
|
||||
* the event, so hand on nothing but http and https.
|
||||
*
|
||||
* @param {ICAL.Event} event The event to read the URL of
|
||||
* @returns {string} The URL, empty when the event has none or it is not http(s)
|
||||
*/
|
||||
export function eventUrl(event) {
|
||||
const url = event.component.getFirstPropertyValue("url") ?? "";
|
||||
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
// A relative URL is resolved against the page and keeps its scheme.
|
||||
const { protocol } = new URL(url, document.baseURI);
|
||||
|
||||
return protocol === "http:" || protocol === "https:" ? url : "";
|
||||
} catch {
|
||||
// Not a URL at all.
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group the occurrences that were modified on their own by the UID of the event
|
||||
* they belong to.
|
||||
*
|
||||
* Unless it is told which exceptions belong to an event, ICAL.Event relates
|
||||
* every VEVENT with a RECURRENCE-ID in the file to every recurring event, and
|
||||
* it keys them by the recurrence id alone. Two series that meet at the same
|
||||
* time would therefore take over each other's modifications.
|
||||
*
|
||||
* @param {ICAL.Component[]} components The VEVENTs of the calendar
|
||||
* @returns {Map<string, ICAL.Component[]>} The exceptions per UID
|
||||
*/
|
||||
function exceptionsByUid(components) {
|
||||
const exceptions = new Map();
|
||||
|
||||
for (const component of components) {
|
||||
if (!component.hasProperty("recurrence-id")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const uid = component.getFirstPropertyValue("uid");
|
||||
const ofEvent = exceptions.get(uid);
|
||||
|
||||
if (ofEvent) {
|
||||
ofEvent.push(component);
|
||||
} else {
|
||||
exceptions.set(uid, [component]);
|
||||
}
|
||||
}
|
||||
|
||||
return exceptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the recurrences of an event have to be iterated.
|
||||
*
|
||||
* The iteration walks the unmodified recurrence times, so an occurrence that
|
||||
* was moved to an earlier time is only reached through the time it originally
|
||||
* had, which can lie past the end of the window. Keep going for as long as the
|
||||
* largest move towards the past can still carry an occurrence into it.
|
||||
*
|
||||
* @param {ICAL.Event} event The event whose recurrences are iterated
|
||||
* @param {Date} to End of the window
|
||||
* @returns {Date} The recurrence time to stop at
|
||||
*/
|
||||
function iterationEnd(event, to) {
|
||||
let last = to.getTime();
|
||||
|
||||
for (const exception of Object.values(event.exceptions)) {
|
||||
const movedBy = exception.recurrenceId.toJSDate().getTime()
|
||||
- exception.startDate.toJSDate().getTime();
|
||||
|
||||
if (movedBy > 0) {
|
||||
last = Math.max(last, to.getTime() + movedBy);
|
||||
}
|
||||
}
|
||||
|
||||
return new Date(last);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an occurrence touches a window.
|
||||
*
|
||||
* @param {ICAL.Time} startDate Start of the occurrence
|
||||
* @param {ICAL.Time} endDate End of the occurrence
|
||||
* @param {Date} from Start of the window
|
||||
* @param {Date} to End of the window
|
||||
* @returns {boolean} True when the two overlap
|
||||
*/
|
||||
function touches(startDate, endDate, from, to) {
|
||||
return startDate.toJSDate() <= to && endDate.toJSDate() > from;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse the calendar of the club.
|
||||
*
|
||||
* @returns {Promise<ICAL.Component>} The calendar
|
||||
*/
|
||||
export async function loadCalendar() {
|
||||
const response = await fetch(icsUrl);
|
||||
|
||||
// Without this an error page would be handed to the parser below, which then
|
||||
// fails with a confusing complaint about the calendar syntax.
|
||||
if (!response.ok) {
|
||||
throw new Error(`${icsUrl}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return new ICAL.Component(ICAL.parse(await response.text()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every occurrence of the calendar that touches the given window.
|
||||
*
|
||||
* A recurring event is expanded, and an occurrence that was modified on its own
|
||||
* is reported with the time, the name and the URL of that modification.
|
||||
*
|
||||
* @param {ICAL.Component} calendar The parsed calendar
|
||||
* @param {Date} from An occurrence has to still be running at this time
|
||||
* @param {Date} to An occurrence has to have started by this time
|
||||
* @yields {{event: ICAL.Event, startDate: ICAL.Time, endDate: ICAL.Time}}
|
||||
*/
|
||||
export function* occurrencesBetween(calendar, from, to) {
|
||||
const components = calendar.getAllSubcomponents("vevent");
|
||||
const exceptions = exceptionsByUid(components);
|
||||
|
||||
for (const component of components) {
|
||||
// Occurrences modified via RECURRENCE-ID are reached through the event they
|
||||
// belong to, handling them here as well would report them twice.
|
||||
if (component.hasProperty("recurrence-id")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = new ICAL.Event(component, {
|
||||
exceptions: exceptions.get(component.getFirstPropertyValue("uid")) ?? [],
|
||||
});
|
||||
|
||||
if (!event.startDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!event.isRecurring()) {
|
||||
if (touches(event.startDate, event.endDate, from, to)) {
|
||||
yield { event, startDate: event.startDate, endDate: event.endDate };
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const iterator = event.iterator();
|
||||
const iterateUntil = iterationEnd(event, to);
|
||||
|
||||
while (true) {
|
||||
const occurrence = iterator.next();
|
||||
|
||||
// Recurrences are chronological, so we are done once one starts after the
|
||||
// window, and after the occurrences a modification can still move back
|
||||
// into it.
|
||||
if (!occurrence || occurrence.toJSDate() > iterateUntil) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Details resolve time, name and URL of an occurrence that was modified
|
||||
// via RECURRENCE-ID.
|
||||
const details = event.getOccurrenceDetails(occurrence);
|
||||
|
||||
// A modification may have moved the occurrence out of the window, so
|
||||
// judge it by the time it really takes place at.
|
||||
if (touches(details.startDate, details.endDate, from, to)) {
|
||||
yield {
|
||||
event: details.item,
|
||||
startDate: details.startDate,
|
||||
endDate: details.endDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +1,49 @@
|
|||
import ICAL from "./vendor/ical.js";
|
||||
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
|
||||
|
||||
/**
|
||||
* Read the URL of an event.
|
||||
* When an occurrence starts, as a point in time.
|
||||
*
|
||||
* ICAL.Event does not expose the URL property, so read it from the component.
|
||||
* A date has no time and no zone, its digits are the day itself. toJSDate()
|
||||
* reads them as midnight in the zone of the browser, which moves an all day
|
||||
* event by the offset that zone has to Berlin and, far enough east or west,
|
||||
* onto the day before or after. Keep the digits and read them as UTC instead,
|
||||
* the table prints an all day event in UTC as well.
|
||||
*
|
||||
* @param {ICAL.Event} event The event to read the URL of
|
||||
* @returns {string} The URL, empty when the event has none
|
||||
* @param {ICAL.Time} time Start of the occurrence
|
||||
* @returns {Date} The point in time to sort and print by
|
||||
*/
|
||||
function eventUrl(event) {
|
||||
return event.component.getFirstPropertyValue("url") ?? "";
|
||||
function startOf(time) {
|
||||
if (time.isDate) {
|
||||
return new Date(Date.UTC(time.year, time.month - 1, time.day));
|
||||
}
|
||||
|
||||
return time.toJSDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an ICS calendar and return upcoming event occurrences.
|
||||
* The upcoming occurrences of a calendar.
|
||||
*
|
||||
* @param {string} icsText The contents of the .ics file
|
||||
* @param {ICAL.Component} calendar The parsed calendar
|
||||
* @param {Date} now Events must still be running at this date
|
||||
* @param {number} maxEvents Maximum number of events to return
|
||||
* @param {number} maxDays Maximum number of days into the future
|
||||
* @returns {{start: Date, name: string, url: string}[]} url is empty when the event has no URL
|
||||
* @returns {{start: Date, allDay: boolean, name: string, url: string}[]} url is empty when the event has no URL
|
||||
*/
|
||||
function getUpcomingEvents(icsText, now, maxEvents, maxDays) {
|
||||
const jcal = ICAL.parse(icsText);
|
||||
const calendar = new ICAL.Component(jcal);
|
||||
|
||||
function getUpcomingEvents(calendar, now, maxEvents, maxDays) {
|
||||
const end = new Date(now.getTime());
|
||||
end.setDate(end.getDate() + maxDays);
|
||||
|
||||
const events = [];
|
||||
|
||||
for (const component of calendar.getAllSubcomponents("vevent")) {
|
||||
const event = new ICAL.Event(component);
|
||||
|
||||
// Occurrences modified via RECURRENCE-ID are reached through the event they
|
||||
// belong to, listing them here as well would show them twice.
|
||||
if (event.isRecurrenceException()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!event.startDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.isRecurring()) {
|
||||
const iterator = event.iterator();
|
||||
|
||||
while (true) {
|
||||
const occurrence = iterator.next();
|
||||
|
||||
if (!occurrence) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Recurrences are chronological, so we're done
|
||||
// once we pass the end of our search window.
|
||||
if (occurrence.toJSDate() > end) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Details resolve time, name and URL of an occurrence that was
|
||||
// modified via RECURRENCE-ID.
|
||||
const details = event.getOccurrenceDetails(occurrence);
|
||||
|
||||
// A running event stays listed until it is over, so filter on its end.
|
||||
if (details.endDate.toJSDate() > now) {
|
||||
events.push({
|
||||
start: details.startDate.toJSDate(),
|
||||
name: details.item.summary ?? "",
|
||||
url: eventUrl(details.item),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const start = event.startDate.toJSDate();
|
||||
|
||||
if (start <= end && event.endDate.toJSDate() > now) {
|
||||
events.push({
|
||||
start,
|
||||
name: event.summary ?? "",
|
||||
url: eventUrl(event),
|
||||
});
|
||||
}
|
||||
}
|
||||
// A running event stays listed until it is over, so the window starts at now
|
||||
// and the walk keeps everything that has not ended yet.
|
||||
for (const { event, startDate } of occurrencesBetween(calendar, now, end)) {
|
||||
events.push({
|
||||
start: startOf(startDate),
|
||||
allDay: startDate.isDate,
|
||||
name: event.summary ?? "",
|
||||
url: eventUrl(event),
|
||||
});
|
||||
}
|
||||
|
||||
// We have occurrences from multiple events, so sort them
|
||||
|
|
@ -93,41 +54,34 @@ function getUpcomingEvents(icsText, now, maxEvents, maxDays) {
|
|||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const ics = "/calendars/all.ics";
|
||||
const max_days = 20;
|
||||
const max_items = 5;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const table = document.getElementById("upcoming");
|
||||
fetch(ics)
|
||||
.then(response => {
|
||||
// Without this an error page would be handed to the parser below, which
|
||||
// then fails with a confusing complaint about the calendar syntax.
|
||||
if (!response.ok) {
|
||||
throw new Error(`${ics}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
})
|
||||
.then(icsText => {
|
||||
getUpcomingEvents(icsText, now, max_items, max_days).forEach(event => {
|
||||
loadCalendar()
|
||||
.then(calendar => {
|
||||
getUpcomingEvents(calendar, now, max_items, max_days).forEach(event => {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const colBegin = document.createElement("td");
|
||||
|
||||
// The events take place in Berlin, so name their time in Berlin time
|
||||
// instead of in the time zone the visitor happens to be in. An all day
|
||||
// event has no time of day and carries its date in UTC, see startOf().
|
||||
const whenFormat = event.allDay
|
||||
? { timeZone: "UTC" }
|
||||
: { timeZone: "Europe/Berlin", hour: "2-digit", minute: "2-digit" };
|
||||
|
||||
const formattedStart = event.start.toLocaleString("de-DE", {
|
||||
// The events take place in Berlin, so name their time in Berlin time
|
||||
// instead of in the time zone the visitor happens to be in.
|
||||
timeZone: "Europe/Berlin",
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...whenFormat,
|
||||
});
|
||||
|
||||
colBegin.innerText = `${formattedStart} Uhr`;
|
||||
colBegin.innerText = event.allDay ? formattedStart : `${formattedStart} Uhr`;
|
||||
row.appendChild(colBegin);
|
||||
|
||||
const colName = document.createElement("td");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{{ $js := resources.Get "js/calendar.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint }}
|
||||
{{ $css := resources.Get "css/calendar.css" }}
|
||||
{{ $css := resources.Get "css/calendar.css" | minify | fingerprint }}
|
||||
|
||||
<div class="calendar-container">
|
||||
{{ with $css }}
|
||||
<link rel="stylesheet" href="{{ .RelPermalink }}">
|
||||
<link rel="stylesheet" href="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}">
|
||||
{{ end }}
|
||||
{{ with $js }}
|
||||
<script type="module" src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}"></script>
|
||||
|
|
|
|||
Loading…
Reference in a new issue