Skip to content

Commit 123f367

Browse files
kyu08claudeCopilotCopilot
authored
Implement blog card link preview feature (#246)
* Add blog card (link preview) feature Implemented a blog card shortcode that displays rich link previews with: - Automatic OGP metadata fetching (title, description, image) - Manual parameter specification support - Responsive design with dark theme styling - Hover animations and effects - CORS-friendly fetching via proxy service Usage: {{< blogcard url="https://example.com" >}} * Fix blogcard: remove border and improve OGP fetching Changes: - Remove border from blogcard for cleaner look - Add debug logging to blogcard.js for troubleshooting - Update documentation to recommend manual parameter specification - Add working examples for immediate testing - Improve error handling in OGP fetch logic The manual specification method is now recommended as the most reliable approach, with auto-fetch as a bonus feature. * Improve blogcard performance with faster proxies and timeout Performance improvements: - Add multiple proxy fallback (corsproxy.io as primary, allOrigins as backup) - Implement 5-second timeout per proxy (max 10 seconds total) - Add loading animation while fetching OGP data - Log elapsed time for debugging This should significantly reduce the wait time from 10+ seconds to under 5 seconds in most cases. * update * test * delete * Revert "test" This reverts commit b79a35f. * Update static/blogcard.js Co-authored-by: Copilot <[email protected]> * Fix async forEach pattern in blog card OGP fetching (#247) * Initial plan * Fix async forEach issue - use Promise.all with map pattern Co-authored-by: kyu08 <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: kyu08 <[email protected]> * 独立したリンクはブログカードとして表示する --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent cf7be43 commit 123f367

File tree

6 files changed

+400
-1
lines changed

6 files changed

+400
-1
lines changed
Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,15 @@
1-
<a href="{{ .Destination | safeURL }}"{{ with .Title}} title="{{ . }}"{{ end }}{{ if or (strings.HasPrefix .Destination "http") (strings.HasPrefix .Destination "https") }} target="_blank"{{ end }} >{{ .Text | safeHTML }}</a>
1+
{{- /* リンクテキストとURLが同じ場合はblogcardとして表示 */ -}}
2+
{{- $text := .Text -}}
3+
{{- $dest := .Destination -}}
4+
5+
{{- /* URLの正規化(プロトコルとトレイリングスラッシュを除去して比較) */ -}}
6+
{{- $normalizedText := $text | replaceRE "^https?://" "" | strings.TrimSuffix "/" -}}
7+
{{- $normalizedDest := $dest | replaceRE "^https?://" "" | strings.TrimSuffix "/" -}}
8+
9+
{{- if eq $normalizedText $normalizedDest -}}
10+
{{- /* blogcardとして表示 */ -}}
11+
{{- partial "blogcard.html" (dict "url" $dest "autoFetch" "true") -}}
12+
{{- else -}}
13+
{{- /* 通常のリンクとして表示 */ -}}
14+
<a href="{{ .Destination | safeURL }}"{{ with .Title}} title="{{ . }}"{{ end }}{{ if or (strings.HasPrefix .Destination "http") (strings.HasPrefix .Destination "https") }} target="_blank"{{ end }} >{{ .Text | safeHTML }}</a>
15+
{{- end -}}

layouts/partials/blogcard.html

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{{- $url := .url -}}
2+
{{- $title := .title -}}
3+
{{- $description := .description -}}
4+
{{- $image := .image -}}
5+
{{- $autoFetch := .autoFetch | default "true" -}}
6+
7+
{{- /* title/description/imageが指定されていない場合は自動取得を有効化 */ -}}
8+
{{- if and (not $title) (not $description) (not $image) -}}
9+
{{- $autoFetch = "true" -}}
10+
{{- end -}}
11+
12+
<div class="blogcard" data-url="{{ $url }}" data-auto-fetch="{{ $autoFetch }}">
13+
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer" class="blogcard-link">
14+
{{- if $image -}}
15+
<div class="blogcard-thumbnail">
16+
<img src="{{ $image }}" alt="{{ $title | default $url }}" loading="lazy">
17+
</div>
18+
{{- else -}}
19+
<div class="blogcard-thumbnail blogcard-thumbnail-placeholder">
20+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
21+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
22+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
23+
</svg>
24+
</div>
25+
{{- end -}}
26+
<div class="blogcard-content">
27+
<div class="blogcard-title">{{ $title | default $url }}</div>
28+
{{- if $description -}}
29+
<div class="blogcard-description">{{ $description }}</div>
30+
{{- end -}}
31+
<div class="blogcard-url">{{ $url }}</div>
32+
</div>
33+
</a>
34+
</div>

layouts/partials/extended_footer.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@
55
Cookies can be disabled in your browser.
66
</span>
77
</font>
8+
9+
<!-- ブログカード -->
10+
<script src="{{ "blogcard.js" | absURL }}"></script>

layouts/shortcodes/blogcard.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{{- $url := .Get "url" -}}
2+
{{- $title := .Get "title" -}}
3+
{{- $description := .Get "description" -}}
4+
{{- $image := .Get "image" -}}
5+
{{- $autoFetch := .Get "auto-fetch" | default "true" -}}
6+
7+
{{- if not $url -}}
8+
{{- errorf "blogcard shortcode requires 'url' parameter" -}}
9+
{{- end -}}
10+
11+
{{- partial "blogcard.html" (dict "url" $url "title" $title "description" $description "image" $image "autoFetch" $autoFetch) -}}

static/blogcard.js

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// ブログカード - OGP情報の自動取得機能
2+
// data-auto-fetch="true" 属性を持つブログカードに対してOGP情報を自動取得します
3+
4+
(function() {
5+
'use strict';
6+
7+
const DEBUG = true; // デバッグモード
8+
9+
function log(...args) {
10+
if (DEBUG) {
11+
console.log('[BlogCard]', ...args);
12+
}
13+
}
14+
15+
// タイムアウト付きfetch
16+
async function fetchWithTimeout(url, timeout = 5000) {
17+
const controller = new AbortController();
18+
const timeoutId = setTimeout(() => controller.abort(), timeout);
19+
20+
try {
21+
const response = await fetch(url, { signal: controller.signal });
22+
clearTimeout(timeoutId);
23+
return response;
24+
} catch (error) {
25+
clearTimeout(timeoutId);
26+
throw error;
27+
}
28+
}
29+
30+
// 複数のプロキシを試す
31+
const PROXY_SERVICES = [
32+
// corsproxy.io - 高速で信頼性が高い
33+
(url) => `https://corsproxy.io/?${encodeURIComponent(url)}`,
34+
// allOrigins - バックアップ
35+
(url) => `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`,
36+
];
37+
38+
// OGP情報を取得する関数
39+
async function fetchOGPData(url) {
40+
log('Fetching OGP data for:', url);
41+
42+
for (let i = 0; i < PROXY_SERVICES.length; i++) {
43+
const proxyUrl = PROXY_SERVICES[i](url);
44+
log(`Trying proxy ${i + 1}/${PROXY_SERVICES.length}:`, proxyUrl);
45+
46+
try {
47+
const response = await fetchWithTimeout(proxyUrl, 5000);
48+
49+
if (!response.ok) {
50+
log(`Proxy ${i + 1} failed with status:`, response.status);
51+
continue;
52+
}
53+
54+
let html;
55+
if (i === 0) {
56+
// corsproxy.io returns HTML directly
57+
html = await response.text();
58+
} else if (i === 1) {
59+
// allOrigins returns JSON
60+
const data = await response.json();
61+
html = data.contents;
62+
}
63+
64+
log('HTML fetched, length:', html.length);
65+
66+
// HTMLパーサーを使用してOGPメタタグを抽出
67+
const parser = new DOMParser();
68+
const doc = parser.parseFromString(html, 'text/html');
69+
70+
// OGPメタタグから情報を取得
71+
const getMetaContent = (property) => {
72+
const element = doc.querySelector(`meta[property="${property}"]`) ||
73+
doc.querySelector(`meta[name="${property}"]`);
74+
return element ? element.getAttribute('content') : null;
75+
};
76+
77+
// タイトルを取得(OGP > title要素の順)
78+
const title = getMetaContent('og:title') ||
79+
doc.querySelector('title')?.textContent ||
80+
url;
81+
82+
// 説明を取得
83+
const description = getMetaContent('og:description') ||
84+
getMetaContent('description') ||
85+
'';
86+
87+
// 画像を取得
88+
let image = getMetaContent('og:image') || '';
89+
log('Raw image URL:', image);
90+
91+
// 相対URLを絶対URLに変換
92+
if (image && !image.startsWith('http')) {
93+
const urlObj = new URL(url);
94+
if (image.startsWith('//')) {
95+
image = urlObj.protocol + image;
96+
} else if (image.startsWith('/')) {
97+
image = urlObj.origin + image;
98+
} else {
99+
image = urlObj.origin + '/' + image;
100+
}
101+
log('Converted image URL:', image);
102+
}
103+
104+
const result = {
105+
title: title.trim(),
106+
description: description.trim(),
107+
image: image
108+
};
109+
110+
log('OGP data fetched successfully:', result);
111+
return result;
112+
} catch (error) {
113+
log(`Proxy ${i + 1} error:`, error.message);
114+
// Try next proxy
115+
continue;
116+
}
117+
}
118+
119+
// All proxies failed
120+
console.error('[BlogCard] All proxies failed to fetch OGP data for:', url);
121+
return null;
122+
}
123+
124+
// ブログカードを更新する関数
125+
function updateBlogCard(card, ogpData) {
126+
if (!ogpData) {
127+
log('No OGP data to update');
128+
return;
129+
}
130+
131+
log('Updating blog card with:', ogpData);
132+
133+
const link = card.querySelector('.blogcard-link');
134+
const thumbnail = card.querySelector('.blogcard-thumbnail');
135+
const title = card.querySelector('.blogcard-title');
136+
const description = card.querySelector('.blogcard-description');
137+
138+
// タイトルを更新
139+
if (ogpData.title && title) {
140+
title.textContent = ogpData.title;
141+
log('Title updated:', ogpData.title);
142+
}
143+
144+
// 説明を更新(存在しない場合は作成)
145+
if (ogpData.description) {
146+
if (description) {
147+
description.textContent = ogpData.description;
148+
} else {
149+
const descElement = document.createElement('div');
150+
descElement.className = 'blogcard-description';
151+
descElement.textContent = ogpData.description;
152+
title.parentNode.insertBefore(descElement, title.nextSibling);
153+
}
154+
log('Description updated:', ogpData.description);
155+
}
156+
157+
// 画像を更新
158+
if (ogpData.image && thumbnail) {
159+
thumbnail.classList.remove('blogcard-thumbnail-placeholder');
160+
// 既存の画像を削除
161+
while (thumbnail.firstChild) {
162+
thumbnail.removeChild(thumbnail.firstChild);
163+
}
164+
// 新しいimg要素を安全に作成
165+
const img = document.createElement('img');
166+
img.setAttribute('src', ogpData.image);
167+
img.setAttribute('alt', ogpData.title || '');
168+
img.setAttribute('loading', 'lazy');
169+
thumbnail.appendChild(img);
170+
log('Image updated:', ogpData.image);
171+
} else {
172+
log('No image to update, ogpData.image:', ogpData.image);
173+
}
174+
}
175+
176+
// ページ読み込み時に自動取得設定のあるブログカードを処理
177+
async function initBlogCards() {
178+
log('Initializing blog cards...');
179+
const cards = document.querySelectorAll('.blogcard[data-auto-fetch="true"]');
180+
log('Found', cards.length, 'blog cards with auto-fetch');
181+
182+
await Promise.all(Array.from(cards).map(async (card) => {
183+
const url = card.getAttribute('data-url');
184+
log('Processing card for URL:', url);
185+
186+
if (!url) {
187+
log('No URL found, skipping');
188+
return;
189+
}
190+
191+
// ローディング状態を追加
192+
card.classList.add('loading');
193+
194+
// OGP情報を取得して更新
195+
const startTime = Date.now();
196+
const ogpData = await fetchOGPData(url);
197+
const elapsedTime = Date.now() - startTime;
198+
log(`Fetch completed in ${elapsedTime}ms`);
199+
200+
// ローディング状態を解除
201+
card.classList.remove('loading');
202+
203+
if (ogpData) {
204+
updateBlogCard(card, ogpData);
205+
}
206+
}));
207+
}
208+
209+
// DOMContentLoaded後に初期化
210+
if (document.readyState === 'loading') {
211+
log('Waiting for DOMContentLoaded...');
212+
document.addEventListener('DOMContentLoaded', initBlogCards);
213+
} else {
214+
log('DOM already loaded, initializing immediately');
215+
initBlogCards();
216+
}
217+
})();

0 commit comments

Comments
 (0)