diff --git a/cal/src/EventDetails.vue b/cal/src/EventDetails.vue
index f0123d1f..ce99c638 100644
--- a/cal/src/EventDetails.vue
+++ b/cal/src/EventDetails.vue
@@ -62,9 +62,10 @@ export default {
}
// done loading.
const page = buildPage(evt, vm.calStart, to.fullPath);
- vm.evt = evt;
+ vm.evt = evt;
// override the server's shareable with the spa's current page.
evt.shareable = window.location;
+ vm.loadUnfurl();
vm.$emit("pageLoaded", page);
});
}
@@ -81,7 +82,8 @@ export default {
const { caldaily_id, slug } = to.params;
return dataPool.getDaily(caldaily_id).then((evt) => {
const page = buildPage(evt, this.calStart, to.fullPath);
- this.evt = evt;
+ this.evt = evt;
+ this.loadUnfurl();
this.$emit("pageLoaded", page);
}).catch((error) => {
console.warn("event loading error:", error);
@@ -100,9 +102,36 @@ export default {
},
// the week we came from
calStart: null,
+ // oEmbed card html for a ridewithgps link in the details (if any)
+ unfurlHtml: null,
};
},
+ methods: {
+ // fetch an oEmbed card for the first ridewithgps link in the details, if
+ // any. mirrors the legacy calendar's loadUnfurls; fails silently on error
+ // or CORS. unlike the legacy list (collapsed events), this is a dedicated
+ // page so the link is always visible — no need to lazy-load.
+ loadUnfurl() {
+ this.unfurlHtml = null;
+ const url = helpers.getUnfurlUrl(this.evt.details);
+ // oEmbed endpoint is ridewithgps-specific; only it is allow-listed today.
+ if (!url || !/(^|\.)ridewithgps\.com$/i.test(new URL(url).hostname)) {
+ return;
+ }
+ fetch(`https://ridewithgps.com/oembed?format=json&url=${encodeURIComponent(url)}`)
+ .then((resp) => (resp.ok ? resp.json() : null))
+ .then((data) => {
+ if (data && data.html) {
+ this.unfurlHtml = data.html;
+ }
+ })
+ .catch(() => { /* fails silently, matches legacy */ });
+ },
+ },
computed: {
+ linkedDetails() {
+ return helpers.getLinkedDetails(this.evt.details);
+ },
tags() {
return calTags.buildEventTags(this.evt)
},
@@ -187,9 +216,8 @@ export default {
-
- {{evt.details}}
-
+
+
- Sharable link
- Export to calendar
@@ -230,6 +258,16 @@ export default {
margin-top: 1em;
padding-top: 1em;
}
+.rwgps-unfurl {
+ margin-top: 0.75em;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ overflow: hidden;
+}
+.rwgps-unfurl iframe {
+ display: block;
+ max-width: 100%;
+}
.c-detail-links {
display: flex;
justify-content: center;
diff --git a/cal/src/calHelpers.js b/cal/src/calHelpers.js
index a2336284..b67207bd 100644
--- a/cal/src/calHelpers.js
+++ b/cal/src/calHelpers.js
@@ -20,6 +20,42 @@ function within(a, start, end) {
const urlPattern = /^https*:\/\//;
const emailPattern = /.+@.+[.].+/;
+// domains we trust enough to auto-link inside free-text event
+// descriptions. keeps the description field from becoming an open
+// invitation for spam links while still letting people share their
+// ridewithgps.com routes. mirrors the legacy calendar's config.js. see #1072.
+const LINKABLE_DOMAINS = ['ridewithgps.com'];
+
+const linkableHostPattern = LINKABLE_DOMAINS.map(
+ (domain) => '(?:[a-z0-9-]+\\.)*' + domain.replace(/\./g, '\\.')
+).join('|');
+
+// an optional scheme/www, one of the allow-listed hosts, then an optional path.
+// the negative lookbehind keeps it from matching as a suffix of a longer
+// word/domain (eg. "evilridewithgps.com") or an email's domain part.
+const linkableUrlPattern = new RegExp(
+ '(?"\']*)?',
+ 'gi'
+);
+
+function escapeHtml(text) {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+// drop trailing sentence punctuation so it doesn't get swallowed into a link
+function trimUrl(url) {
+ return url.replace(/[.,!?;:'")\]}]+$/, '');
+}
+
+function normalizeHref(url) {
+ return /^https?:\/\//i.test(url) ? url : ('https://' + url);
+}
+
function friendlyTime(time) {
return dayjs(time, 'hh:mm:ss').format('h:mm A');
}
@@ -47,6 +83,47 @@ export default {
// if it's not a link, return nothing
},
+ // turns plain-text event details into safe html, hyperlinking any
+ // allow-listed links (eg. ridewithgps.com routes). the rest of the text is
+ // html-escaped, not left as raw html, since this is free text from event
+ // organizers. mirrors the legacy calendar's getLinkedDetails (helpers.js).
+ getLinkedDetails(details) {
+ if (!details) {
+ return details;
+ }
+
+ let html = '';
+ let lastIndex = 0;
+ let match;
+ linkableUrlPattern.lastIndex = 0;
+
+ while ((match = linkableUrlPattern.exec(details)) !== null) {
+ const url = trimUrl(match[0]);
+ const start = match.index;
+ const end = start + url.length;
+
+ html += escapeHtml(details.slice(lastIndex, start));
+ const href = normalizeHref(url);
+ html += `${escapeHtml(url)}`;
+
+ lastIndex = end;
+ linkableUrlPattern.lastIndex = end;
+ }
+ html += escapeHtml(details.slice(lastIndex));
+ return html;
+ },
+
+ // the first allow-listed url in the details (normalized with a scheme),
+ // or undefined. used to fetch an oEmbed card. first link wins.
+ getUnfurlUrl(details) {
+ if (!details) {
+ return undefined;
+ }
+ linkableUrlPattern.lastIndex = 0;
+ const match = linkableUrlPattern.exec(details);
+ return match ? normalizeHref(trimUrl(match[0])) : undefined;
+ },
+
//7:00 AM to 9:00 AM
getTimeRange(evt) {
const {endtime, time} = evt;
diff --git a/site/themes/s2b_hugo_theme/assets/css/cal/main.css b/site/themes/s2b_hugo_theme/assets/css/cal/main.css
index 2770ef3b..25dcbba5 100644
--- a/site/themes/s2b_hugo_theme/assets/css/cal/main.css
+++ b/site/themes/s2b_hugo_theme/assets/css/cal/main.css
@@ -349,6 +349,18 @@ h3 {
white-space: pre-line;
}
+.rwgps-unfurl {
+ margin-top: 0.75em;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.rwgps-unfurl iframe {
+ display: block;
+ max-width: 100%;
+}
+
.otherButtons a {
text-decoration: none;
}
diff --git a/site/themes/s2b_hugo_theme/assets/js/cal/config.js b/site/themes/s2b_hugo_theme/assets/js/cal/config.js
index 27fb7f14..b00a298f 100644
--- a/site/themes/s2b_hugo_theme/assets/js/cal/config.js
+++ b/site/themes/s2b_hugo_theme/assets/js/cal/config.js
@@ -50,3 +50,9 @@ const DEFAULT_RIDE_LENGTH = '--';
// total number of days to fetch, inclusive of start and end dates;
// minimum of 1, maximum set by server
const DEFAULT_DAYS_TO_FETCH = 10;
+
+// domains we trust enough to auto-link inside free-text event
+// descriptions. keeps the description field from becoming an open
+// invitation for spam links while still letting people share their
+// ridewithgps.com routes. see issue #1072.
+const LINKABLE_DOMAINS = ['ridewithgps.com'];
diff --git a/site/themes/s2b_hugo_theme/assets/js/cal/helpers.js b/site/themes/s2b_hugo_theme/assets/js/cal/helpers.js
index 168bf9fb..4e3ba3ec 100644
--- a/site/themes/s2b_hugo_theme/assets/js/cal/helpers.js
+++ b/site/themes/s2b_hugo_theme/assets/js/cal/helpers.js
@@ -100,6 +100,59 @@
return googleCalUrl.toString();
};
+ // LINKABLE_DOMAINS is defined in config.js
+ var linkableHostPattern = LINKABLE_DOMAINS.map(function(domain) {
+ return '(?:[a-z0-9-]+\\.)*' + domain.replace(/\./g, '\\.');
+ }).join('|');
+
+ // an optional scheme/www, one of the allow-listed hosts, then an optional path.
+ // the negative lookbehind keeps it from matching as a suffix of a longer
+ // word/domain (eg. "evilridewithgps.com") or an email's domain part.
+ var linkableUrlPattern = new RegExp(
+ '(?"\']*)?',
+ 'gi'
+ );
+
+ function escapeHtml(text) {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ // turns plain-text event details into safe html, hyperlinking any
+ // ridewithgps.com links (the rest of the text is html-escaped, not
+ // left as raw html, since this is free text from event organizers).
+ $.fn.getLinkedDetails = function(details) {
+ if (!details) {
+ return details;
+ }
+
+ var html = '';
+ var lastIndex = 0;
+ var match;
+ linkableUrlPattern.lastIndex = 0;
+
+ while ((match = linkableUrlPattern.exec(details)) !== null) {
+ // don't swallow trailing sentence punctuation into the link
+ var url = match[0].replace(/[.,!?;:'")\]}]+$/, '');
+ var start = match.index;
+ var end = start + url.length;
+
+ html += escapeHtml(details.slice(lastIndex, start));
+ var href = /^https?:\/\//i.test(url) ? url : ('https://' + url);
+ html += '' +
+ escapeHtml(url) + '';
+
+ lastIndex = end;
+ linkableUrlPattern.lastIndex = end;
+ }
+ html += escapeHtml(details.slice(lastIndex));
+ return html;
+ };
+
$.fn.truncateString = function ( str, maxLength=250 ) {
let text = str.substring(0,maxLength);
if (str.length > maxLength) {
@@ -119,4 +172,41 @@
return 0;
};
+ // fetch oEmbed cards for any ridewithgps links in the container.
+ // uses IntersectionObserver so requests only fire when a link is actually
+ // visible (i.e. after the user expands that event's details).
+ $.fn.loadUnfurls = function() {
+ var links = this.find('a[data-unfurl-url]:not([data-unfurl-observed])');
+ if (!links.length || !('IntersectionObserver' in window)) return;
+
+ links.attr('data-unfurl-observed', '1');
+
+ var observer = new IntersectionObserver(function(entries, obs) {
+ entries.forEach(function(entry) {
+ if (!entry.isIntersecting) return;
+ obs.unobserve(entry.target);
+
+ var link = $(entry.target);
+ var eventDetails = link.closest('.event-details');
+ // one card per event — first ridewithgps link wins
+ if (!eventDetails.length || eventDetails.data('unfurl-loaded')) return;
+ eventDetails.data('unfurl-loaded', true);
+
+ var url = link.data('unfurl-url');
+ $.ajax({
+ type: 'GET',
+ url: 'https://ridewithgps.com/oembed?format=json&url=' + encodeURIComponent(url),
+ dataType: 'json',
+ success: function(data) {
+ if (!data.html) return;
+ var card = $('').html(data.html);
+ link.closest('p.description').after(card);
+ }
+ });
+ });
+ });
+
+ links.each(function() { observer.observe(this); });
+ };
+
} (jQuery));
diff --git a/site/themes/s2b_hugo_theme/assets/js/cal/main.js b/site/themes/s2b_hugo_theme/assets/js/cal/main.js
index b510804a..40c2417c 100755
--- a/site/themes/s2b_hugo_theme/assets/js/cal/main.js
+++ b/site/themes/s2b_hugo_theme/assets/js/cal/main.js
@@ -34,6 +34,7 @@ $(document).ready(function() {
}
value.webLink = container.getWebLink(value.weburl);
value.contactLink = container.getContactLink(value.contact);
+ value.linkedDetails = container.getLinkedDetails(value.details);
value.addToGoogleLink = container.getAddToGoogleLink(value);
groupedByDate[date].events.push(value);
@@ -137,6 +138,7 @@ $(document).ready(function() {
getEventHTML(view, function (eventHTML) {
container.append(eventHTML);
lazyLoadEventImages();
+ container.loadUnfurls();
$(document).off('click', '#load-more')
.on('click', '#load-more', function(e) {
// if there is a user-provided enddate, use that to set the day range (and add 1 so date range is inclusive);
@@ -150,6 +152,7 @@ $(document).ready(function() {
getEventHTML(view, function(eventHTML) {
$('#load-more').before(eventHTML);
lazyLoadEventImages();
+ container.loadUnfurls();
});
return false;
});
@@ -163,6 +166,7 @@ $(document).ready(function() {
}, function (eventHTML) {
container.append(eventHTML);
lazyLoadEventImages();
+ container.loadUnfurls();
});
}
diff --git a/site/themes/s2b_hugo_theme/layouts/partials/cal/events.html b/site/themes/s2b_hugo_theme/layouts/partials/cal/events.html
index 1cc6c012..0ace5d18 100644
--- a/site/themes/s2b_hugo_theme/layouts/partials/cal/events.html
+++ b/site/themes/s2b_hugo_theme/layouts/partials/cal/events.html
@@ -208,7 +208,7 @@
[[/ridelength]]
-
[[details]]
+ [[& linkedDetails]]
[[#email]]