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

Commit 3a6aca9

Browse files
authored
Merge pull request #307 from wheels-dev/feature/link-embeding
Feature/link embeding
2 parents 57fbc29 + d42fc64 commit 3a6aca9

5 files changed

Lines changed: 141 additions & 28 deletions

File tree

app/models/BlogTag.cfc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ component extends="app.Models.Model" {
88
property(name="updatedAt", column="updatedat", dataType="datetime", defaultValue = "");
99
property(name="deletedAt", column="deletedat", dataType="datetime", defaultValue = "");
1010

11-
property(name="blogId", column="blog_id", dataType="integer");
11+
property(name="blogId", column="blog_id", dataType="string");
1212

1313
// Associations
14-
belongsTo(name="Blog", foreignKey="blogId");
14+
belongsTo(name="Blog", foreignKey="blogId");
1515
belongsTo(name="Tag", foreignKey="tagId");
1616
}
1717

app/views/helpers.cfm

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,115 @@
4141
var emailHash = lcase(hash(lcase(trim(arguments.email)), "MD5"));
4242
return "https://www.gravatar.com/avatar/" & emailHash & "?s=" & arguments.size & "&d=404";
4343
}
44-
</cfscript>
44+
45+
/**
46+
* Extracts YouTube video ID from various YouTube URL formats
47+
* Supports: https://youtube.com/watch?v=xyz, https://youtu.be/xyz, https://www.youtube.com/embed/xyz
48+
*/
49+
string function extractYouTubeId(required string url) {
50+
var id = "";
51+
52+
// youtu.be format
53+
if (findNoCase("youtu.be/", arguments.url)) {
54+
id = listLast(arguments.url, "/");
55+
// Remove any query parameters
56+
if (findNoCase("?", id)) {
57+
id = listFirst(id, "?");
58+
}
59+
}
60+
// youtube.com/watch?v= format
61+
else if (findNoCase("youtube.com", arguments.url) && findNoCase("v=", arguments.url)) {
62+
var params = listLast(arguments.url, "?");
63+
var paramList = listToArray(params, "&");
64+
for (var param in paramList) {
65+
if (findNoCase("v=", param)) {
66+
id = listLast(param, "=");
67+
break;
68+
}
69+
}
70+
}
71+
// Already embed format
72+
else if (findNoCase("youtube.com/embed/", arguments.url)) {
73+
id = listLast(listFirst(arguments.url, "?"), "/");
74+
}
75+
76+
return trim(id);
77+
}
78+
79+
/**
80+
* Detects if a URL is embeddable and returns embed HTML
81+
* Supports: YouTube, Twitter
82+
*/
83+
string function getEmbedHtml(required string url, string width="100%", string height="400") {
84+
var embedHtml = "";
85+
var youtubeId = "";
86+
var vimeoId = "";
87+
var trimmedUrl = trim(arguments.url);
88+
89+
// YouTube
90+
if (findNoCase("youtube.com", trimmedUrl) || findNoCase("youtu.be", trimmedUrl)) {
91+
youtubeId = extractYouTubeId(trimmedUrl);
92+
if (len(youtubeId)) {
93+
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>';
94+
}
95+
}
96+
// Twitter/X
97+
else if (findNoCase("twitter.com", trimmedUrl) || findNoCase("x.com", trimmedUrl)) {
98+
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>';
99+
}
100+
101+
return embedHtml;
102+
}
103+
104+
/**
105+
* Checks if a URL is embeddable
106+
*/
107+
boolean function isEmbeddableUrl(required string url) {
108+
var embeddableDomains = ["youtube.com", "youtu.be", "twitter.com", "x.com"];
109+
var trimmedUrl = lcase(trim(arguments.url));
110+
111+
for (var domain in embeddableDomains) {
112+
if (findNoCase(domain, trimmedUrl)) {
113+
return true;
114+
}
115+
}
116+
return false;
117+
}
118+
119+
/**
120+
* Converts plain text URLs and embeddable links into HTML
121+
* For embeddable URLs (YouTube, Vimeo, etc.), creates embed iframes
122+
* For other URLs, creates anchor tags
123+
*/
124+
string function embedAndAutoLink(required string content, string class="text--primary", string target="_blank") {
125+
var result = arguments.content;
126+
var urlPattern = "(https?://[^\s<""'`]+)";
127+
var matches = reMatch(urlPattern, result);
128+
129+
// Remove duplicates
130+
var uniqueUrls = {};
131+
for (var match in matches) {
132+
var cleanUrl = trim(match);
133+
// Skip if it's already part of an href or src
134+
if (!findNoCase("href='#cleanUrl#", result) && !findNoCase('href="' & cleanUrl & '"', result) && !findNoCase("src='#cleanUrl#", result) && !findNoCase('src="' & cleanUrl & '"', result)) {
135+
uniqueUrls[cleanUrl] = cleanUrl;
136+
}
137+
}
138+
139+
// Replace each unique URL
140+
for (var link in uniqueUrls) {
141+
if (isEmbeddableUrl(link)) {
142+
var embedCode = getEmbedHtml(link);
143+
if (len(embedCode)) {
144+
result = replace(result, link, embedCode, "all");
145+
}
146+
} else {
147+
// Regular link
148+
var linkHtml = '<a href="' & link & '" class="' & arguments.class & '" target="' & arguments.target & '">' & link & '</a>';
149+
result = replace(result, link, linkHtml, "all");
150+
}
151+
}
152+
153+
return result;
154+
}
155+
</cfscript>

app/views/web/BlogController/show.cfm

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@
2424
<cfif isLoggedInUser() AND (isUserAdmin() OR session.userID EQ blog.createdBy)>
2525
<a href="/blog/edit/#blog.id#" class="btn bg--primary text-white rounded-3" id="editBlogBtn">
2626
<i class="bi bi-pencil"></i> Edit
27-
</a>
27+
</a>
2828
<cfif len(trim(blog.publishedAt)) AND blog.publishedAt LTE now()>
29-
<button
29+
<button
3030
class="btn btn-danger rounded-3"
3131
hx-post="/blog/unpublish"
3232
hx-vals='{"id": "#blog.id#"}'
@@ -61,25 +61,25 @@
6161
<!-- Post status + Categories -->
6262
<cfif categories.recordCount GT 0>
6363
<p class="fw-medium fs-12 text--lightGray mb-0">
64-
#blog.PostStatus.name# in
64+
#blog.PostStatus.name# in
6565
<cfoutput query="categories">
66-
<strong
66+
<strong
6767
class="text--primary"
6868
style="cursor: pointer;"
6969
hx-get="#urlFor(route="blogsFilter", filterType="category", filterValue="#REReplace(name, '\.', '-', 'all')#")#"
70-
hx-target="body"
71-
hx-swap="outerHTML"
70+
hx-target="body"
71+
hx-swap="outerHTML"
7272
hx-push-url="true"
7373
>#name#</strong><cfif currentrow LT recordcount>, </cfif>
7474
</cfoutput>
7575
</p>
7676
</cfif>
7777
<p class="fw-medium fs-12 text--lightGray mb-0">
78-
Posted By:
79-
<strong
78+
Posted By:
79+
<strong
8080
class="text--primary"
8181
style="cursor: pointer;"
82-
hx-get="/blog/author/#blog.userusername#"
82+
hx-get="/blog/author/#blog.userusername#"
8383
hx-target="body"
8484
hx-push-url="true"
8585
hx-swap="outerHTML"
@@ -88,12 +88,12 @@
8888
<!-- Tags -->
8989
<cfif tags.recordCount GT 0>
9090
<p class="fw-medium fs-12 text--lightGray mb-0">
91-
Tags:
91+
Tags:
9292
<cfoutput query="tags">
93-
<strong
93+
<strong
9494
class="text--primary"
9595
style="cursor: pointer;"
96-
hx-get="#urlFor(route="blogsFilter", filterType="tag", filterValue="#REReplace(name, '\.', '-', 'all')#")#"
96+
hx-get="#urlFor(route="blogsFilter", filterType="tag", filterValue="#REReplace(name, '\.', '-', 'all')#")#"
9797
hx-target="body"
9898
hx-push-url="true"
9999
hx-swap="outerHTML"
@@ -116,7 +116,7 @@
116116
<cfoutput>#encodeForHTML(blog.content)#</cfoutput>
117117
</div>
118118
<cfelse>
119-
#this.autoLink(blog.content,"text--primary")#
119+
#embedAndAutoLink(blog.content,"text--primary")#
120120
</cfif>
121121
</div>
122122
</div>
@@ -125,8 +125,8 @@
125125
<div id="comment">
126126
<cfoutput query="comments">
127127
<div class="mt-4">
128-
<div class="position-relative">
129-
<cfif commentParentId eq '' or commentParentId eq 0>
128+
<div class="position-relative">
129+
<cfif commentParentId eq '' or commentParentId eq 0>
130130
<div class="d-flex align-items-start gap-3">
131131
<div>
132132
<img src="#gravatarUrl(email, 96)#&d=404"
@@ -138,7 +138,7 @@
138138
style="width:3rem;height:3rem;">
139139
#ucase(left(listLast(fullName, " "), 1))#
140140
</div>
141-
</div>
141+
</div>
142142
<div class="p-3 rounded-4 flex-grow-1 bg-light">
143143
<h6 class="fs-16 fw-bold">#fullName#</h6>
144144
<cfif findNoCase("```", content) OR findNoCase("##", content) OR findNoCase("**", content) OR findNoCase("__", content) OR findNoCase(">", content)>
@@ -151,7 +151,7 @@
151151
<div class="d-flex cursor-pointer align-items-center gap-2">
152152
<a onclick="handleReply(#Id#)" class="fs-14 text--primary mb-0" data-commentid="#Id#" data-blogid="#blog.Id#">Reply</a>
153153

154-
<svg width="18" height="14" viewBox="0 0 18 14" fill="none"
154+
<svg width="18" height="14" viewBox="0 0 18 14" fill="none"
155155
xmlns="http://www.w3.org/2000/svg">
156156
<path
157157
d="M1.42013 6.05185L5.66612 10.2979C5.76346 10.3952 5.81446 10.5099 5.81912 10.6419C5.82379 10.7745 5.77079 10.8959 5.66013 11.0059C5.55013 11.1125 5.43246 11.1665 5.30712 11.1679C5.18112 11.1699 5.06313 11.1159 4.95312 11.0059L0.565125 6.61685C0.477792 6.53019 0.416458 6.44119 0.381125 6.34985C0.345792 6.25919 0.328125 6.15985 0.328125 6.05185C0.328125 5.94385 0.345792 5.84452 0.381125 5.75385C0.416458 5.66319 0.477792 5.57419 0.565125 5.48685L4.95312 1.09785C5.04646 1.00452 5.16013 0.954521 5.29413 0.947854C5.42812 0.941187 5.55046 0.991187 5.66112 1.09785C5.77112 1.20785 5.82612 1.32685 5.82612 1.45485C5.82612 1.58285 5.77112 1.70185 5.66112 1.81185L1.42013 6.05185ZM6.03612 6.55185L9.78213 10.2979C9.87946 10.3952 9.93046 10.5099 9.93513 10.6419C9.93913 10.7745 9.88612 10.8959 9.77612 11.0059C9.66613 11.1125 9.54813 11.1665 9.42212 11.1679C9.29612 11.1692 9.17812 11.1152 9.06812 11.0059L4.68013 6.61685C4.59279 6.53019 4.53146 6.44119 4.49613 6.34985C4.46079 6.25919 4.44312 6.15985 4.44312 6.05185C4.44312 5.94385 4.46079 5.84452 4.49613 5.75385C4.53146 5.66319 4.59279 5.57419 4.68013 5.48685L9.06812 1.09785C9.16146 1.00452 9.27512 0.954521 9.40912 0.947854C9.54379 0.941187 9.66613 0.991187 9.77612 1.09785C9.88612 1.20785 9.94113 1.32685 9.94113 1.45485C9.94113 1.58285 9.88612 1.70185 9.77612 1.81185L6.03612 5.55185H13.4991C14.7418 5.55185 15.8025 5.99119 16.6811 6.86985C17.5598 7.74852 17.9991 8.80919 17.9991 10.0519V12.5519C17.9991 12.6945 17.9515 12.8135 17.8561 12.9089C17.7608 13.0042 17.6418 13.0519 17.4991 13.0519C17.3565 13.0519 17.2375 13.0042 17.1421 12.9089C17.0468 12.8135 16.9991 12.6945 16.9991 12.5519V10.0519C16.9991 9.09052 16.6561 8.26685 15.9701 7.58085C15.2841 6.89485 14.4605 6.55185 13.4991 6.55185H6.03612Z"
@@ -195,7 +195,7 @@
195195
style="width:3rem;height:3rem;">
196196
#ucase(left(listLast(session.username, " "), 1))#
197197
</div>
198-
</div>
198+
</div>
199199
<div class="p-3 rounded-4 flex-grow-1 bg-light">
200200
<h6 class="fs-16 fw-bold">#session.username#</h6>
201201

@@ -216,7 +216,7 @@
216216
</div>
217217
</cfoutput>
218218
</div>
219-
219+
220220
<cfif isLoggedInUser() AND canUserComment()>
221221
<form hx-target="##comment" hx-on:htmx:after-request="handleClear()" hx-swap="beforeend" id="commentForm" hx-post="/blog/comment" class="pt-3 px-1 needs-validation" novalidate hx-validate="true">
222222
<div class="d-flex gap-3 align-items-start">
@@ -262,7 +262,7 @@
262262
</div>
263263
</div>
264264
</cfoutput>
265-
265+
266266
<div class="pt-5 blog-main px-2">
267267
<div class="d-flex align-items-center justify-content-between swiper-buttons position-relative">
268268
<!-- Left Button -->
@@ -277,12 +277,12 @@
277277

278278
<div class="swiper py-5 blogSwiper h-max">
279279
<div class="swiper-wrapper" id="blogs-container" hx-get="/home/loadBlogs" hx-trigger="load" hx-target="#blogs-container" hx-swap="innerHTML">
280-
280+
281281
</div>
282282
</div>
283283
</div>
284284
<!-- Scroll to Top Button -->
285-
<button id="scrollToTopBtn" class="position-fixed bottom-0 end-0 m-4 btn bg--primary text-white rounded-circle"
285+
<button id="scrollToTopBtn" class="position-fixed bottom-0 end-0 m-4 btn bg--primary text-white rounded-circle"
286286
style="width: 50px; height: 50px; display: none; z-index: 99; border: none; padding: 0;">
287287
<i class="bi bi-arrow-up fs-20"></i>
288288
</button>
@@ -316,7 +316,7 @@
316316
}
317317
}
318318
}
319-
319+
320320
// Show/hide scroll to top button
321321
if (window.scrollY > 300) {
322322
scrollToTopBtn.style.display = 'block';
@@ -334,4 +334,5 @@
334334
});
335335
336336
</script>
337-
<script src="/js/showBlog.js"></script>
337+
<script src="/js/embedHelper.js"></script>
338+
<script src="/js/showBlog.js"></script>

public/javascripts/embedHelper.js

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

0 commit comments

Comments
 (0)