From c102edf4542ac907f821f30e3ed6c94aa9aca222 Mon Sep 17 00:00:00 2001 From: Andrew Kreps Date: Wed, 24 Jun 2026 11:43:53 -0700 Subject: [PATCH 1/4] Hyperlink ridewithgps.com links in event descriptions The legacy /calendar/ page rendered the event description as escaped plain text, so route links were just unclickable text. Auto-link any ridewithgps.com mentions (with or without scheme/www) while HTML-escaping the rest of the description, keeping a domain allow-list so this doesn't become an open door for spam links. Fixes #1072 --- .../s2b_hugo_theme/assets/js/cal/helpers.js | 58 +++++++++++++++++++ .../s2b_hugo_theme/assets/js/cal/main.js | 1 + .../layouts/partials/cal/events.html | 2 +- 3 files changed, 60 insertions(+), 1 deletion(-) 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..c0eda02c 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,64 @@ return googleCalUrl.toString(); }; + // 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. + var LINKABLE_DOMAINS = ['ridewithgps.com']; + + 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) { 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..597b74a2 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); 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]]

From 55900868d7ba4b8aa72606c463f0c66ae0973278 Mon Sep 17 00:00:00 2001 From: Andrew Kreps Date: Wed, 24 Jun 2026 11:49:43 -0700 Subject: [PATCH 2/4] Move LINKABLE_DOMAINS allow-list into cal config.js Keeps the ridewithgps.com allow-list alongside the other calendar JS constants (AREA, AUDIENCE, etc.) instead of buried in helpers.js, so it's the obvious place to add another trusted domain later. --- site/themes/s2b_hugo_theme/assets/js/cal/config.js | 6 ++++++ site/themes/s2b_hugo_theme/assets/js/cal/helpers.js | 7 +------ 2 files changed, 7 insertions(+), 6 deletions(-) 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 c0eda02c..fd600cde 100644 --- a/site/themes/s2b_hugo_theme/assets/js/cal/helpers.js +++ b/site/themes/s2b_hugo_theme/assets/js/cal/helpers.js @@ -100,12 +100,7 @@ return googleCalUrl.toString(); }; - // 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. - var LINKABLE_DOMAINS = ['ridewithgps.com']; - + // LINKABLE_DOMAINS is defined in config.js var linkableHostPattern = LINKABLE_DOMAINS.map(function(domain) { return '(?:[a-z0-9-]+\\.)*' + domain.replace(/\./g, '\\.'); }).join('|'); From 41f9b3dcf788e0760d1dde331854845ed3d31dae Mon Sep 17 00:00:00 2001 From: Andrew Kreps Date: Thu, 25 Jun 2026 17:23:30 -0700 Subject: [PATCH 3/4] Inline oEmbed card for ridewithgps.com links in event descriptions When an organizer includes a ridewithgps.com route link in their event description, fetch the oEmbed embed HTML from ridewithgps and inject an interactive map card directly below the description. Requests are lazy: the IntersectionObserver only fires when the event is expanded and the link is actually visible, so collapsed events never trigger a fetch. One card per event (first ridewithgps URL wins); fails silently if the request errors or CORS blocks it. --- .../s2b_hugo_theme/assets/css/cal/main.css | 12 ++++++ .../s2b_hugo_theme/assets/js/cal/helpers.js | 39 ++++++++++++++++++- .../s2b_hugo_theme/assets/js/cal/main.js | 3 ++ 3 files changed, 53 insertions(+), 1 deletion(-) 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/helpers.js b/site/themes/s2b_hugo_theme/assets/js/cal/helpers.js index fd600cde..4e3ba3ec 100644 --- a/site/themes/s2b_hugo_theme/assets/js/cal/helpers.js +++ b/site/themes/s2b_hugo_theme/assets/js/cal/helpers.js @@ -143,7 +143,7 @@ html += escapeHtml(details.slice(lastIndex, start)); var href = /^https?:\/\//i.test(url) ? url : ('https://' + url); - html += '' + + html += '' + escapeHtml(url) + ''; lastIndex = end; @@ -172,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 597b74a2..40c2417c 100755 --- a/site/themes/s2b_hugo_theme/assets/js/cal/main.js +++ b/site/themes/s2b_hugo_theme/assets/js/cal/main.js @@ -138,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); @@ -151,6 +152,7 @@ $(document).ready(function() { getEventHTML(view, function(eventHTML) { $('#load-more').before(eventHTML); lazyLoadEventImages(); + container.loadUnfurls(); }); return false; }); @@ -164,6 +166,7 @@ $(document).ready(function() { }, function (eventHTML) { container.append(eventHTML); lazyLoadEventImages(); + container.loadUnfurls(); }); } From 4c5e6c4957a3cd39beaeaabf681fe33a1f03fbab Mon Sep 17 00:00:00 2001 From: Andrew Kreps Date: Thu, 25 Jun 2026 17:50:28 -0700 Subject: [PATCH 4/4] Hyperlink ridewithgps.com links on the event details page Ports the linkify + inline oEmbed card behavior from the legacy /calendar/ page to the Vue SPA's event details (shareable link) view, so the permalink page renders ridewithgps.com route links as clickable links and shows the interactive map card instead of plain text. getLinkedDetails/getUnfurlUrl mirror the legacy helpers.js (same LINKABLE_DOMAINS allow-list and trailing-punctuation handling). Since this is a dedicated, always-visible page rather than a collapsible list, it fetches the oEmbed card directly on load (no IntersectionObserver) and gates the fetch on the ridewithgps.com host. Refs #1072 --- cal/src/EventDetails.vue | 48 ++++++++++++++++++++++--- cal/src/calHelpers.js | 77 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 5 deletions(-) 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}} -

+

+