diff --git a/layouts/_default/_markup/render-link.html b/layouts/_default/_markup/render-link.html index eb8714db..3f43e657 100644 --- a/layouts/_default/_markup/render-link.html +++ b/layouts/_default/_markup/render-link.html @@ -1 +1,15 @@ -{{ .Text | safeHTML }} +{{- /* リンクテキストとURLが同じ場合はblogcardとして表示 */ -}} +{{- $text := .Text -}} +{{- $dest := .Destination -}} + +{{- /* URLの正規化(プロトコルとトレイリングスラッシュを除去して比較) */ -}} +{{- $normalizedText := $text | replaceRE "^https?://" "" | strings.TrimSuffix "/" -}} +{{- $normalizedDest := $dest | replaceRE "^https?://" "" | strings.TrimSuffix "/" -}} + +{{- if eq $normalizedText $normalizedDest -}} + {{- /* blogcardとして表示 */ -}} + {{- partial "blogcard.html" (dict "url" $dest "autoFetch" "true") -}} +{{- else -}} + {{- /* 通常のリンクとして表示 */ -}} + {{ .Text | safeHTML }} +{{- end -}} diff --git a/layouts/partials/blogcard.html b/layouts/partials/blogcard.html new file mode 100644 index 00000000..b7021d6d --- /dev/null +++ b/layouts/partials/blogcard.html @@ -0,0 +1,34 @@ +{{- $url := .url -}} +{{- $title := .title -}} +{{- $description := .description -}} +{{- $image := .image -}} +{{- $autoFetch := .autoFetch | default "true" -}} + +{{- /* title/description/imageが指定されていない場合は自動取得を有効化 */ -}} +{{- if and (not $title) (not $description) (not $image) -}} + {{- $autoFetch = "true" -}} +{{- end -}} + +
diff --git a/layouts/partials/extended_footer.html b/layouts/partials/extended_footer.html index 1f37fd05..18cca56a 100644 --- a/layouts/partials/extended_footer.html +++ b/layouts/partials/extended_footer.html @@ -5,3 +5,6 @@ Cookies can be disabled in your browser. + + + diff --git a/layouts/shortcodes/blogcard.html b/layouts/shortcodes/blogcard.html new file mode 100644 index 00000000..d26762f9 --- /dev/null +++ b/layouts/shortcodes/blogcard.html @@ -0,0 +1,11 @@ +{{- $url := .Get "url" -}} +{{- $title := .Get "title" -}} +{{- $description := .Get "description" -}} +{{- $image := .Get "image" -}} +{{- $autoFetch := .Get "auto-fetch" | default "true" -}} + +{{- if not $url -}} + {{- errorf "blogcard shortcode requires 'url' parameter" -}} +{{- end -}} + +{{- partial "blogcard.html" (dict "url" $url "title" $title "description" $description "image" $image "autoFetch" $autoFetch) -}} diff --git a/static/blogcard.js b/static/blogcard.js new file mode 100644 index 00000000..5ffb9ef5 --- /dev/null +++ b/static/blogcard.js @@ -0,0 +1,217 @@ +// ブログカード - OGP情報の自動取得機能 +// data-auto-fetch="true" 属性を持つブログカードに対してOGP情報を自動取得します + +(function() { + 'use strict'; + + const DEBUG = true; // デバッグモード + + function log(...args) { + if (DEBUG) { + console.log('[BlogCard]', ...args); + } + } + + // タイムアウト付きfetch + async function fetchWithTimeout(url, timeout = 5000) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + return response; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } + } + + // 複数のプロキシを試す + const PROXY_SERVICES = [ + // corsproxy.io - 高速で信頼性が高い + (url) => `https://corsproxy.io/?${encodeURIComponent(url)}`, + // allOrigins - バックアップ + (url) => `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`, + ]; + + // OGP情報を取得する関数 + async function fetchOGPData(url) { + log('Fetching OGP data for:', url); + + for (let i = 0; i < PROXY_SERVICES.length; i++) { + const proxyUrl = PROXY_SERVICES[i](url); + log(`Trying proxy ${i + 1}/${PROXY_SERVICES.length}:`, proxyUrl); + + try { + const response = await fetchWithTimeout(proxyUrl, 5000); + + if (!response.ok) { + log(`Proxy ${i + 1} failed with status:`, response.status); + continue; + } + + let html; + if (i === 0) { + // corsproxy.io returns HTML directly + html = await response.text(); + } else if (i === 1) { + // allOrigins returns JSON + const data = await response.json(); + html = data.contents; + } + + log('HTML fetched, length:', html.length); + + // HTMLパーサーを使用してOGPメタタグを抽出 + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + // OGPメタタグから情報を取得 + const getMetaContent = (property) => { + const element = doc.querySelector(`meta[property="${property}"]`) || + doc.querySelector(`meta[name="${property}"]`); + return element ? element.getAttribute('content') : null; + }; + + // タイトルを取得(OGP > title要素の順) + const title = getMetaContent('og:title') || + doc.querySelector('title')?.textContent || + url; + + // 説明を取得 + const description = getMetaContent('og:description') || + getMetaContent('description') || + ''; + + // 画像を取得 + let image = getMetaContent('og:image') || ''; + log('Raw image URL:', image); + + // 相対URLを絶対URLに変換 + if (image && !image.startsWith('http')) { + const urlObj = new URL(url); + if (image.startsWith('//')) { + image = urlObj.protocol + image; + } else if (image.startsWith('/')) { + image = urlObj.origin + image; + } else { + image = urlObj.origin + '/' + image; + } + log('Converted image URL:', image); + } + + const result = { + title: title.trim(), + description: description.trim(), + image: image + }; + + log('OGP data fetched successfully:', result); + return result; + } catch (error) { + log(`Proxy ${i + 1} error:`, error.message); + // Try next proxy + continue; + } + } + + // All proxies failed + console.error('[BlogCard] All proxies failed to fetch OGP data for:', url); + return null; + } + + // ブログカードを更新する関数 + function updateBlogCard(card, ogpData) { + if (!ogpData) { + log('No OGP data to update'); + return; + } + + log('Updating blog card with:', ogpData); + + const link = card.querySelector('.blogcard-link'); + const thumbnail = card.querySelector('.blogcard-thumbnail'); + const title = card.querySelector('.blogcard-title'); + const description = card.querySelector('.blogcard-description'); + + // タイトルを更新 + if (ogpData.title && title) { + title.textContent = ogpData.title; + log('Title updated:', ogpData.title); + } + + // 説明を更新(存在しない場合は作成) + if (ogpData.description) { + if (description) { + description.textContent = ogpData.description; + } else { + const descElement = document.createElement('div'); + descElement.className = 'blogcard-description'; + descElement.textContent = ogpData.description; + title.parentNode.insertBefore(descElement, title.nextSibling); + } + log('Description updated:', ogpData.description); + } + + // 画像を更新 + if (ogpData.image && thumbnail) { + thumbnail.classList.remove('blogcard-thumbnail-placeholder'); + // 既存の画像を削除 + while (thumbnail.firstChild) { + thumbnail.removeChild(thumbnail.firstChild); + } + // 新しいimg要素を安全に作成 + const img = document.createElement('img'); + img.setAttribute('src', ogpData.image); + img.setAttribute('alt', ogpData.title || ''); + img.setAttribute('loading', 'lazy'); + thumbnail.appendChild(img); + log('Image updated:', ogpData.image); + } else { + log('No image to update, ogpData.image:', ogpData.image); + } + } + + // ページ読み込み時に自動取得設定のあるブログカードを処理 + async function initBlogCards() { + log('Initializing blog cards...'); + const cards = document.querySelectorAll('.blogcard[data-auto-fetch="true"]'); + log('Found', cards.length, 'blog cards with auto-fetch'); + + await Promise.all(Array.from(cards).map(async (card) => { + const url = card.getAttribute('data-url'); + log('Processing card for URL:', url); + + if (!url) { + log('No URL found, skipping'); + return; + } + + // ローディング状態を追加 + card.classList.add('loading'); + + // OGP情報を取得して更新 + const startTime = Date.now(); + const ogpData = await fetchOGPData(url); + const elapsedTime = Date.now() - startTime; + log(`Fetch completed in ${elapsedTime}ms`); + + // ローディング状態を解除 + card.classList.remove('loading'); + + if (ogpData) { + updateBlogCard(card, ogpData); + } + })); + } + + // DOMContentLoaded後に初期化 + if (document.readyState === 'loading') { + log('Waiting for DOMContentLoaded...'); + document.addEventListener('DOMContentLoaded', initBlogCards); + } else { + log('DOM already loaded, initializing immediately'); + initBlogCards(); + } +})(); diff --git a/static/style.css b/static/style.css index 8e559dcd..7cef4c67 100644 --- a/static/style.css +++ b/static/style.css @@ -170,3 +170,123 @@ blockquote { blockquote::before { display: none; } + +/* ブログカード */ +.blogcard { + margin: 24px 0; + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.blogcard.loading .blogcard-thumbnail-placeholder { + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.blogcard:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 168, 106, 0.2); +} + +.blogcard-link { + display: flex; + border-radius: 8px; + overflow: hidden; + text-decoration: none; + color: inherit; + background-color: rgba(99, 110, 123, 0.2); + transition: background-color 0.2s ease; +} + +.blogcard-link:hover { + background-color: rgba(99, 110, 123, 0.3); +} + +.blogcard-thumbnail { + flex-shrink: 0; + width: 240px; + height: 144px; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(99, 110, 123, 0.4); + overflow: hidden; +} + +.blogcard-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + border: none; + margin: 0; +} + +.blogcard-thumbnail-placeholder { + color: var(--color-gray); +} + +.blogcard-thumbnail-placeholder svg { + width: 48px; + height: 48px; +} + +.blogcard-content { + flex: 1; + padding: 16px 16px 8px; + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; +} + +.blogcard-title { + font-size: 16px; + font-weight: bold; + color: var(--foreground); + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.blogcard-description { + font-size: 14px; + color: var(--color-gray); + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.blogcard-url { + font-size: 12px; + color: var(--accent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* レスポンシブ対応 */ +@media (max-width: 600px) { + .blogcard-link { + flex-direction: column; + } + + .blogcard-thumbnail { + width: 100%; + height: 200px; + } +}