Skip to content

Commit 9a95d46

Browse files
authored
Merge pull request #50 from kodingwarrior/feature/hackerspub-integration
Add HackersPub integration
2 parents a2c730c + 041b1f1 commit 9a95d46

File tree

10 files changed

+344
-35
lines changed

10 files changed

+344
-35
lines changed

bridgetown.config.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ collections:
3535
future: true
3636
sort_by: date
3737
sort_direction: descending
38+
hackerspub_posts:
39+
output: true
40+
sort_by: date
41+
sort_direction: descending
3842

3943
feed:
4044
path: rss.xml
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
require_relative "../utils/hackerspub/client"
2+
3+
module Builders
4+
class HackerspubPostsBuilder < SiteBuilder
5+
def build
6+
return if ENV["BRIDGETOWN__DISABLE_BUILDERS"] == "true"
7+
return if ENV["HACKERSPUB_DISABLE"].to_s == "1"
8+
9+
handle = ENV.fetch("HACKERSPUB_HANDLE", "@kodingwarrior")
10+
endpoint = ENV.fetch(
11+
"HACKERSPUB_GRAPHQL_URL",
12+
Utils::Hackerspub::Client::DEFAULT_ENDPOINT
13+
)
14+
base_url = ENV.fetch("HACKERSPUB_BASE_URL", "https://hackers.pub")
15+
16+
client = Utils::Hackerspub::Client.new(
17+
handle:,
18+
endpoint:,
19+
base_url:,
20+
)
21+
22+
posts = client.fetch_posts
23+
actor = client.fetch_actor_bio
24+
25+
site.data["hackerspub_actor"] = actor if actor
26+
site.data["hackerspub_handle"] = handle
27+
28+
visible_posts = posts.select do |post|
29+
visibility = post[:visibility].to_s.downcase
30+
visibility.empty? || visibility == "public"
31+
end
32+
33+
unique_posts = visible_posts.uniq { |post| [post[:year], post[:encoded_slug] || post[:slug]] }
34+
35+
unique_posts.sort_by { |post| post[:published_at] || Time.at(0) }.each do |post|
36+
path_slug = post[:encoded_slug] || post[:slug]
37+
add_resource :hackerspub_posts, "#{post[:year]}/#{path_slug}.html" do
38+
layout "hackerspub_post"
39+
title post[:name].to_s.strip.empty? ? post[:slug] : post[:name]
40+
date post[:published_at] if post[:published_at]
41+
published post[:published_raw]
42+
year post[:year]
43+
slug post[:slug]
44+
encoded_slug post[:encoded_slug]
45+
language post[:language]
46+
summary post[:summary]
47+
original_url post[:url]
48+
visibility post[:visibility]
49+
content post[:content]
50+
permalink "/hackerspub/#{post[:year]}/#{path_slug}/"
51+
end
52+
end
53+
rescue Utils::Hackerspub::Error => e
54+
Bridgetown.logger.error("HackerspubPostsBuilder:", e.message)
55+
return
56+
rescue StandardError => e
57+
Bridgetown.logger.error(
58+
"HackerspubPostsBuilder:",
59+
"Unexpected error while building Hackerspub posts — #{e.class}: #{e.message}"
60+
)
61+
Bridgetown.logger.debug(e.backtrace.join("\n")) if Bridgetown.logger.debug?
62+
return
63+
end
64+
end
65+
end

plugins/utils/hackerspub/client.rb

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
require 'json'
2+
require 'net/http'
3+
require 'cgi'
4+
require 'time'
5+
require 'uri'
6+
7+
module Utils
8+
module Hackerspub
9+
class Error < StandardError; end
10+
11+
class Client
12+
DEFAULT_ENDPOINT = 'https://hackers.pub/graphql'
13+
14+
ARTICLES_QUERY = <<~GRAPHQL
15+
query Articles($handle: String!, $allowLocalHandle: Boolean!) {
16+
actorByHandle(handle: $handle, allowLocalHandle: $allowLocalHandle) {
17+
articles {
18+
edges {
19+
node {
20+
id
21+
language
22+
name
23+
published
24+
summary
25+
content
26+
url
27+
visibility
28+
}
29+
}
30+
}
31+
}
32+
}
33+
GRAPHQL
34+
35+
ACTOR_BIO_QUERY = <<~GRAPHQL
36+
query ActorBio($handle: String!, $allowLocalHandle: Boolean!) {
37+
actorByHandle(handle: $handle, allowLocalHandle: $allowLocalHandle) {
38+
account {
39+
bio
40+
avatarUrl
41+
}
42+
}
43+
}
44+
GRAPHQL
45+
46+
def initialize(handle:, endpoint: DEFAULT_ENDPOINT, base_url: 'https://hackers.pub')
47+
@handle = handle
48+
@endpoint = URI.parse(endpoint)
49+
@base_url = base_url
50+
end
51+
52+
def fetch_posts
53+
data = execute(ARTICLES_QUERY)
54+
edges = data.dig('actorByHandle', 'articles', 'edges') || []
55+
edges.filter_map { |edge| normalize_post(edge['node']) }
56+
end
57+
58+
def fetch_actor_bio
59+
data = execute(ACTOR_BIO_QUERY)
60+
data['actorByHandle']&.fetch('account', nil)
61+
end
62+
63+
private
64+
65+
attr_reader :handle, :endpoint, :base_url
66+
67+
def execute(query)
68+
payload = {
69+
query: query,
70+
variables: {
71+
handle: handle,
72+
allowLocalHandle: true
73+
}
74+
}
75+
76+
response = post_json(payload)
77+
parsed = JSON.parse(response.body)
78+
79+
if parsed['errors']&.any?
80+
message = parsed['errors'].map { |error| error['message'] }.join(', ')
81+
raise Error, "Hackerspub GraphQL error: #{message}"
82+
end
83+
84+
parsed['data']
85+
rescue JSON::ParserError => e
86+
raise Error, "Unable to parse Hackerspub response: #{e.message}"
87+
end
88+
89+
def post_json(payload)
90+
http = Net::HTTP.new(endpoint.host, endpoint.port)
91+
http.use_ssl = endpoint.scheme == 'https'
92+
93+
request = Net::HTTP::Post.new(endpoint)
94+
request['Content-Type'] = 'application/json'
95+
request.body = JSON.dump(payload)
96+
97+
response = http.request(request)
98+
unless response.is_a?(Net::HTTPSuccess)
99+
raise Error,
100+
"Hackerspub request failed (#{response.code} #{response.message})"
101+
end
102+
103+
response
104+
rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e
105+
raise Error, "Unable to connect to Hackerspub API: #{e.message}"
106+
end
107+
108+
def normalize_post(node) # rubocop:disable Metrics/AbcSize
109+
return unless node
110+
111+
url = node['url']
112+
return unless url.is_a?(String)
113+
114+
profile_base = profile_url_base
115+
return unless url.start_with?(profile_base)
116+
117+
path_segments = URI.parse(url).path.split('/').reject(&:empty?)
118+
return unless path_segments.length >= 3
119+
120+
year = path_segments[-2]
121+
encoded_slug = path_segments[-1]
122+
slug = CGI.unescape(encoded_slug)
123+
124+
{
125+
id: node['id'],
126+
name: node['name'],
127+
summary: node['summary'],
128+
content: node['content'],
129+
url: url,
130+
language: node['language'],
131+
visibility: node['visibility'],
132+
year: year,
133+
slug: slug,
134+
encoded_slug: encoded_slug,
135+
published_at: parse_time(node['published']),
136+
published_raw: node['published']
137+
}
138+
rescue URI::InvalidURIError
139+
nil
140+
end
141+
142+
def parse_time(value)
143+
return if value.nil?
144+
145+
case value
146+
when Time
147+
value
148+
when DateTime
149+
value.to_time
150+
when String
151+
Time.parse(value)
152+
end
153+
rescue ArgumentError
154+
nil
155+
end
156+
157+
def profile_url_base
158+
@profile_url_base ||= begin
159+
normalized_base = base_url.to_s.chomp('/')
160+
normalized_handle = handle.to_s
161+
normalized_handle = "@#{normalized_handle.delete_prefix('@')}"
162+
"#{normalized_base}/#{normalized_handle}/"
163+
end
164+
end
165+
end
166+
end
167+
end

src/_layouts/hackerspub_post.erb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
layout: default
3+
---
4+
5+
<% title = resource.data.title || resource.data.slug %>
6+
<% published_at = resource.data.date %>
7+
<% language = resource.data.language %>
8+
<% formatted_date = published_at&.strftime("%B %d, %Y") %>
9+
10+
<article class="">
11+
<h1 class="text-4xl font-bold pt-5 pb-3"><%= title %></h1>
12+
13+
<div class="flex items-center text-sm text-gray-600 pb-5 border-b mb-8">
14+
<% if formatted_date %>
15+
<span><%= formatted_date %></span>
16+
<% end %>
17+
<% if language %>
18+
<span class="mx-3 block font-bold text-slate-500">.</span>
19+
<span><%= language %></span>
20+
<% end %>
21+
</div>
22+
23+
<% if resource.content %>
24+
<div class="prose prose-lg max-w-none">
25+
<%= helpers.raw resource.content %>
26+
</div>
27+
<% end %>
28+
29+
<div class="mt-8 pt-6 border-t">
30+
<a
31+
href="<%= resource.data.original_url %>"
32+
target="_blank"
33+
rel="noopener noreferrer"
34+
class="inline-block bg-purple-600 hover:bg-purple-700 text-white font-semibold px-6 py-3 rounded-lg transition-colors"
35+
>
36+
hackers.pub에서 보기 ->
37+
</a>
38+
</div>
39+
</article>

src/_partials/_navbar.erb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
<li><a href="/about">/about</a></li>
66
<li><a href="/posts">/posts</a></li>
77
<li><a href="/wiki">/wiki</a></li>
8+
<li><a href="/hackerspub">/hackerspub</a></li>
89
</ul>
910
</nav>
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
1-
2-
<pre class="mx-0 md:mx-8 mb-8 rounded-xl py-6 px-6 bg-lime-100 text-gray-900 overflow-visible break-words"><div class="flex flex-col md:flex-row justify-start items-start md:items-center"><i class="bi w-16 text-4xl mb-2 md:mb-0 bi-info-circle font-extrabold"></i><p class="m-0 p-0 leading-6 md:leading-8 text-sm md:text-lg">혹시 이 글을 읽고 Vim에 관심을 가지게 되셨나요?<br/>Vim에 관심있는 사람들을 위한 Discord 커뮤니티 <br/><a href="https://vim.kr">vim.kr</a> 바로 여러분들을 위한 커뮤니티입니다.</p></div></pre>
3-
1+
<%# -*- coding: utf-8 -*- %>
2+
<pre class="mx-0 md:mx-8 mb-8 rounded-xl py-6 px-6 bg-lime-100 text-gray-900 overflow-visible break-words"><div class="flex flex-col md:flex-row justify-start items-start md:items-center"><i class="bi w-16 text-4xl mb-2 md:mb-0 bi-info-circle font-extrabold"></i><p class="m-0 p-0 leading-6 md:leading-8 text-sm md:text-lg">&#xD639;&#xC2DC; &#xC774; &#xAE00;&#xC744; &#xC77D;&#xACE0; Vim&#xC5D0; &#xAD00;&#xC2EC;&#xC744; &#xAC00;&#xC9C0;&#xAC8C; &#xB418;&#xC168;&#xB098;&#xC694;?<br/>Vim&#xC5D0; &#xAD00;&#xC2EC;&#xC788;&#xB294; &#xC0AC;&#xB78C;&#xB4E4;&#xC744; &#xC704;&#xD55C; Discord &#xCEE4;&#xBBA4;&#xB2C8;&#xD2F0 <br/><a href="https://vim.kr">vim.kr</a> &#xBC14;&#xB85C; &#xC5EC;&#xB7EC;&#xBD84;&#xB4E4;&#xC744; &#xC704;&#xD55C; &#xCEE4;&#xBBA4;&#xB2C8;&#xD2F0;&#xC785;&#xB2C8;&#xB2E4;.</p></div></pre>

src/_wiki/templates/daily.md

Lines changed: 0 additions & 6 deletions
This file was deleted.

src/_wiki/templates/new_note.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

src/_wiki/templates/weekly.md

Lines changed: 0 additions & 21 deletions
This file was deleted.

src/hackerspub/index.erb

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
layout: page
3+
title: Posts from Hackers' Pub
4+
permalink: /hackerspub/
5+
---
6+
7+
<% actor = site.data["hackerspub_actor"] %>
8+
<% posts = collections.hackerspub_posts.resources.sort_by { |resource| resource.data.date || Time.at(0) }.reverse %>
9+
10+
<% if actor %>
11+
<div class="bg-slate-100 border border-slate-200 rounded-lg p-5 mb-8 flex items-start gap-4">
12+
<% if actor["avatarUrl"] %>
13+
<img src="<%= actor["avatarUrl"] %>" alt="Hackers.pub avatar" class="w-16 h-16 rounded-full shadow" loading="lazy" />
14+
<% end %>
15+
<div>
16+
<h2 class="text-xl font-semibold mb-1"><%= actor["bio"] ? "About" : "Hackers.pub" %></h2>
17+
<% if actor["bio"] %>
18+
<p class="text-sm text-slate-600 whitespace-pre-line"><%= actor["bio"] %></p>
19+
<% elsif site.data["hackerspub_handle"] %>
20+
<p class="text-sm text-slate-600">
21+
Posts syndicated from <code><%= site.data["hackerspub_handle"] %></code>.
22+
</p>
23+
<% else %>
24+
<p class="text-sm text-slate-600">
25+
Posts syndicated from Hackers.pub.
26+
</p>
27+
<% end %>
28+
</div>
29+
</div>
30+
<% end %>
31+
32+
<% if posts.empty? %>
33+
<p>No Hackers.pub posts are available. Run the build with network access enabled to fetch articles.</p>
34+
<% else %>
35+
<ul class="space-y-6 list-none">
36+
<% posts.each do |post| %>
37+
<li class="border-b border-slate-200 pb-6 list-none">
38+
<h2 class="text-2xl font-semibold">
39+
<a href="<%= post.relative_url %>" class="hover:text-purple-600 transition-colors">
40+
<%= post.data.title %>
41+
</a>
42+
</h2>
43+
<div class="text-sm text-slate-500 mt-1 flex items-center gap-2">
44+
<% if post.data.date %>
45+
<span><%= post.data.date.strftime("%B %d, %Y") %></span>
46+
<% end %>
47+
<% if post.data.language %>
48+
<span aria-hidden="true"></span>
49+
<span><%= post.data.language %></span>
50+
<% end %>
51+
</div>
52+
<% if post.data.summary %>
53+
<p class="text-slate-600 mt-3"><%= post.data.summary %></p>
54+
<% end %>
55+
<a
56+
href="<%= post.data.original_url %>"
57+
class="inline-block text-purple-700 hover:text-purple-900 text-sm font-medium mt-3"
58+
target="_blank"
59+
rel="noopener noreferrer"
60+
>
61+
View on Hackers.pub ->
62+
</a>
63+
</li>
64+
<% end %>
65+
</ul>
66+
<% end %>

0 commit comments

Comments
 (0)