Skip to content
This repository was archived by the owner on Apr 21, 2026. It is now read-only.

Commit 5071878

Browse files
authored
Merge pull request #311 from wheels-dev/feature/link-embeding
Support embed/autolink in blog content
2 parents 039a3d1 + 85e52d4 commit 5071878

3 files changed

Lines changed: 113 additions & 1 deletion

File tree

app/controllers/Controller.cfc

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,4 +751,113 @@ component extends="wheels.Controller" {
751751
rethrow;
752752
}
753753
}
754+
755+
/**
756+
* Extracts YouTube video ID from various YouTube URL formats
757+
* Supports: https://youtube.com/watch?v=xyz, https://youtu.be/xyz, https://www.youtube.com/embed/xyz
758+
*/
759+
string function extractYouTubeId(required string url) {
760+
var id = "";
761+
// youtu.be format
762+
if (findNoCase("youtu.be/", arguments.url)) {
763+
id = listLast(arguments.url, "/");
764+
// Remove any query parameters
765+
if (findNoCase("?", id)) {
766+
id = listFirst(id, "?");
767+
}
768+
}
769+
// youtube.com/watch?v= format
770+
else if (findNoCase("youtube.com", arguments.url) && findNoCase("v=", arguments.url)) {
771+
var params = listLast(arguments.url, "?");
772+
var paramList = listToArray(params, "&");
773+
for (var param in paramList) {
774+
if (findNoCase("v=", param)) {
775+
id = listLast(param, "=");
776+
break;
777+
}
778+
}
779+
}
780+
// Already embed format
781+
else if (findNoCase("youtube.com/embed/", arguments.url)) {
782+
id = listLast(listFirst(arguments.url, "?"), "/");
783+
}
784+
return trim(id);
785+
}
786+
787+
/**
788+
* Detects if a URL is embeddable and returns embed HTML
789+
* Supports: YouTube, Twitter
790+
*/
791+
string function getEmbedHtml(required string url, string width="100%", string height="400") {
792+
var embedHtml = "";
793+
var youtubeId = "";
794+
var vimeoId = "";
795+
var trimmedUrl = trim(arguments.url);
796+
797+
// YouTube
798+
if (findNoCase("youtube.com", trimmedUrl) || findNoCase("youtu.be", trimmedUrl)) {
799+
youtubeId = extractYouTubeId(trimmedUrl);
800+
if (len(youtubeId)) {
801+
embedHtml = '<iframe width="#arguments.width#" height="#arguments.height#" src="https://www.youtube.com/embed/#youtubeId#?rel=0" frameborder="0" allowfullscreen style="max-width: 100%; margin: 1rem 0; border-radius: 0.5rem;"></iframe>';
802+
}
803+
}
804+
// Twitter/X
805+
else if (findNoCase("twitter.com", trimmedUrl) || findNoCase("x.com", trimmedUrl)) {
806+
embedHtml = '<blockquote class="twitter-tweet" style="margin: 1rem 0;"><a href="#trimmedUrl#"></a></blockquote><script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>';
807+
}
808+
809+
return embedHtml;
810+
}
811+
812+
/**
813+
* Checks if a URL is embeddable
814+
*/
815+
boolean function isEmbeddableUrl(required string url) {
816+
var embeddableDomains = ["youtube.com", "youtu.be", "twitter.com", "x.com"];
817+
var trimmedUrl = lcase(trim(arguments.url));
818+
819+
for (var domain in embeddableDomains) {
820+
if (findNoCase(domain, trimmedUrl)) {
821+
return true;
822+
}
823+
}
824+
return false;
825+
}
826+
827+
/**
828+
* Converts plain text URLs and embeddable links into HTML
829+
* For embeddable URLs (YouTube, Vimeo, etc.), creates embed iframes
830+
* For other URLs, creates anchor tags
831+
*/
832+
string function embedAndAutoLink(required string content, string class="text--primary", string target="_blank") {
833+
var result = arguments.content;
834+
var urlPattern = "(https?://[^\s<""'`]+)";
835+
var matches = reMatch(urlPattern, result);
836+
837+
// Remove duplicates
838+
var uniqueUrls = {};
839+
for (var match in matches) {
840+
var cleanUrl = trim(match);
841+
// Skip if it's already part of an href or src
842+
if (!findNoCase("href='#cleanUrl#", result) && !findNoCase('href="' & cleanUrl & '"', result) && !findNoCase("src='#cleanUrl#", result) && !findNoCase('src="' & cleanUrl & '"', result)) {
843+
uniqueUrls[cleanUrl] = cleanUrl;
844+
}
845+
}
846+
847+
// Replace each unique URL
848+
for (var link in uniqueUrls) {
849+
if (isEmbeddableUrl(link)) {
850+
var embedCode = getEmbedHtml(link);
851+
if (len(embedCode)) {
852+
result = replace(result, link, embedCode, "all");
853+
}
854+
} else {
855+
// Regular link
856+
var linkHtml = '<a href="' & link & '" class="' & arguments.class & '" target="' & arguments.target & '">' & link & '</a>';
857+
result = replace(result, link, linkHtml, "all");
858+
}
859+
}
860+
861+
return result;
862+
}
754863
}

app/controllers/web/BlogController.cfc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,9 @@ component extends="app.Controllers.Controller" {
546546
Throw("Blog not found");
547547
}
548548

549+
// Process embeds in content
550+
blog.content = embedAndAutoLink(blog.content);
551+
549552
// Set blog post data for layout meta tags (avoids DB query in view)
550553
request.blogPostForMeta = blog;
551554

public/javascripts/createBlog.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)