Post

リンクカードを導入した

本当に react でいい

これを導入した1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
require 'net/http'
require 'uri'
require 'cgi'
require 'json'

module Jekyll
  class LinkCardProcessor
    LINK_PATTERN = /^https?:\/\/[^\s]+$/

    def self.process_content(content)
      content.lines.map do |line|
        trimmed_line = line.strip
        if trimmed_line.match?(LINK_PATTERN)
          convert_url_to_card(trimmed_line)
        else
          line.chomp
        end
      end.join("\n")
    end

    private

    def self.convert_url_to_card(url)
      begin
        metadata = fetch_page_metadata(url)
        generate_link_card_html(url, metadata)
      rescue => e
        # フォールバック: シンプルなリンクに戻す
        "<p><a href=\"#{url}\">#{url}</a></p>"
      end
    end

    def self.fetch_page_metadata(url)
      uri = URI.parse(url)

      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = (uri.scheme == 'https')
      http.read_timeout = 5
      http.open_timeout = 5

      request = Net::HTTP::Get.new(uri.path.empty? ? '/' : uri.path)
      request['User-Agent'] = 'Mozilla/5.0 (compatible; Jekyll Link Card)'

      response = http.request(request)
      if response.code == '200'
        body = response.body.force_encoding('UTF-8')
        parse_html_metadata(body, url)
      else
        default_metadata(url)
      end
    rescue
      default_metadata(url)
    end

    def self.parse_html_metadata(html, url)
      title = nil
      description = nil
      favicon = nil

      # Title
      if html.match(/<title[^>]*>([^<]+)<\/title>/im)
        title = $1.strip
      elsif html.match(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["'][^>]*>/im)
        title = $1.strip
      end

      # Description
      if html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["'][^>]*>/im)
        description = $1.strip
      elsif html.match(/<meta[^>]*property=["']og:description["'][^>]*content=["']([^"']+)["'][^>]*>/im)
        description = $1.strip
      end

      # Favicon
      if html.match(/<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["'][^>]*>/im)
        favicon = $1.strip
        unless favicon.start_with?('http')
          uri = URI.parse(url)
          favicon = "#{uri.scheme}://#{uri.host}#{favicon.start_with?('/') ? '' : '/'}#{favicon}"
        end
      end

      {
        title: title || url,
        description: description || '',
        favicon: favicon || default_favicon(url)
      }
    end

    def self.default_metadata(url)
      uri = URI.parse(url)
      {
        title: uri.host,
        description: '',
        favicon: default_favicon(url)
      }
    end

    def self.default_favicon(url)
      uri = URI.parse(url)
      "#{uri.scheme}://#{uri.host}/favicon.ico"
    end

    def self.generate_link_card_html(url, metadata)
      # 説明文があれば p タグを生成
      description_html = if metadata[:description] && !metadata[:description].empty?
        "<p class=\"link-card-description\">#{CGI.escapeHTML(metadata[:description])}</p>"
      else
        ""
      end

      # favicon は <div> 要素として出力
      favicon_html = if metadata[:favicon] && !metadata[:favicon].empty?
        "<div class=\"link-card-favicon\" style=\"background-image: url('#{metadata[:favicon]}'); background-size: contain; background-repeat: no-repeat; background-position: center;\"></div>"
      else
        ""
      end

      html = <<HTML
    <div class="link-card-wrapper">
      <a href="#{url}" class="link-card">
        <div class="link-card-content">
          <div class="link-card-text">
            <div class="link-card-title">#{CGI.escapeHTML(metadata[:title])}</div>
            #{description_html}
            <p class="link-card-url">#{CGI.escapeHTML(URI.parse(url).host)}</p>
          </div>
          #{favicon_html}
        </div>
      </a>
    </div>
HTML

      html.strip
    end
  end

  module Hooks
    Jekyll::Hooks.register :posts, :pre_render do |post|
      post.content = LinkCardProcessor.process_content(post.content)
    end

    Jekyll::Hooks.register :pages, :pre_render do |page|
      page.content = LinkCardProcessor.process_content(page.content)
    end

    Jekyll::Hooks.register :documents, :pre_render do |doc|
      if doc.collection.label == 'tabs'
        doc.content = LinkCardProcessor.process_content(doc.content)
      end
    end
  end
end


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
/* Link Cards Styles */
.link-card-wrapper {
  margin: 1rem 0;
}

.link-card {
  display: block;
  padding: 1rem;
  border: 1px solid var(--border-color);
  border-radius: 0.5rem;
  text-decoration: none;
  color: inherit;
  transition: all 0.3s ease;
  background-color: var(--card-bg);
  
  &:hover {
    transform: translateY(-2px);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
    border-color: var(--link-color);
    text-decoration: none;
    color: inherit;
  }
  
  .link-card-content {
    display: flex;
    align-items: flex-start;
    gap: 1rem;
  }
  
  .link-card-favicon {
    width: 20px;
    height: 20px;
    flex-shrink: 0;
    border-radius: 2px;
    background-size: contain;
    background-repeat: no-repeat;
    background-position: center;
  }
  
  .link-card-text {
    flex-grow: 1;
    min-width: 0;
  }
  
  .link-card-title {
    font-size: 1.15rem;
    font-weight: 700;
    margin: 0 0 0.5rem 0;
    line-height: 1.3;
    color: var(--heading-color);
    display: block;
  }
  
  .link-card-description {
    font-size: 0.9rem;
    color: var(--text-muted-color);
    margin: 0 0 0.5rem 0;
    line-height: 1.4;
  }
  
  .link-card-url {
    font-size: 0.8rem;
    color: var(--link-color);
    margin: 0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  
  .link-card-image {
    width: 100px;
    height: 60px;
    object-fit: cover;
    border-radius: 4px;
    flex-shrink: 0;
  }
  
  @media (max-width: 576px) {
    .link-card-content {
      flex-direction: column;
    }
    
    .link-card-image {
      width: 100%;
      height: 120px;
    }
  }
}

/* Dark theme adjustments */
[data-mode="dark"] .link-card {
  background-color: var(--card-bg);
  border-color: var(--border-color);
  
  &:hover {
    box-shadow: 0 4px 12px rgba(255, 255, 255, 0.1);
  }
}

将来的にはサムネイルの追加も行いたい、気が乗ったらやる。

  1. 本当はこのブログを Astro に移行する予定だったが、思ったよりもレイアウトの移行に手こずったので実装に舵を切った。 ↩︎

This post is licensed under CC BY 4.0 by the author.

Trending Tags