diff --git a/bridgetown.config.yml b/bridgetown.config.yml index c2a1044b..4bd87122 100644 --- a/bridgetown.config.yml +++ b/bridgetown.config.yml @@ -35,6 +35,10 @@ collections: future: true sort_by: date sort_direction: descending + hackerspub_posts: + output: true + sort_by: date + sort_direction: descending feed: path: rss.xml diff --git a/plugins/builders/hackerspub_posts_builder.rb b/plugins/builders/hackerspub_posts_builder.rb new file mode 100644 index 00000000..c7bcfe6d --- /dev/null +++ b/plugins/builders/hackerspub_posts_builder.rb @@ -0,0 +1,65 @@ +require_relative "../utils/hackerspub/client" + +module Builders + class HackerspubPostsBuilder < SiteBuilder + def build + return if ENV["BRIDGETOWN__DISABLE_BUILDERS"] == "true" + return if ENV["HACKERSPUB_DISABLE"].to_s == "1" + + handle = ENV.fetch("HACKERSPUB_HANDLE", "@kodingwarrior") + endpoint = ENV.fetch( + "HACKERSPUB_GRAPHQL_URL", + Utils::Hackerspub::Client::DEFAULT_ENDPOINT + ) + base_url = ENV.fetch("HACKERSPUB_BASE_URL", "https://hackers.pub") + + client = Utils::Hackerspub::Client.new( + handle:, + endpoint:, + base_url:, + ) + + posts = client.fetch_posts + actor = client.fetch_actor_bio + + site.data["hackerspub_actor"] = actor if actor + site.data["hackerspub_handle"] = handle + + visible_posts = posts.select do |post| + visibility = post[:visibility].to_s.downcase + visibility.empty? || visibility == "public" + end + + unique_posts = visible_posts.uniq { |post| [post[:year], post[:encoded_slug] || post[:slug]] } + + unique_posts.sort_by { |post| post[:published_at] || Time.at(0) }.each do |post| + path_slug = post[:encoded_slug] || post[:slug] + add_resource :hackerspub_posts, "#{post[:year]}/#{path_slug}.html" do + layout "hackerspub_post" + title post[:name].to_s.strip.empty? ? post[:slug] : post[:name] + date post[:published_at] if post[:published_at] + published post[:published_raw] + year post[:year] + slug post[:slug] + encoded_slug post[:encoded_slug] + language post[:language] + summary post[:summary] + original_url post[:url] + visibility post[:visibility] + content post[:content] + permalink "/hackerspub/#{post[:year]}/#{path_slug}/" + end + end + rescue Utils::Hackerspub::Error => e + Bridgetown.logger.error("HackerspubPostsBuilder:", e.message) + return + rescue StandardError => e + Bridgetown.logger.error( + "HackerspubPostsBuilder:", + "Unexpected error while building Hackerspub posts — #{e.class}: #{e.message}" + ) + Bridgetown.logger.debug(e.backtrace.join("\n")) if Bridgetown.logger.debug? + return + end + end +end diff --git a/plugins/utils/hackerspub/client.rb b/plugins/utils/hackerspub/client.rb new file mode 100644 index 00000000..c418cb93 --- /dev/null +++ b/plugins/utils/hackerspub/client.rb @@ -0,0 +1,167 @@ +require 'json' +require 'net/http' +require 'cgi' +require 'time' +require 'uri' + +module Utils + module Hackerspub + class Error < StandardError; end + + class Client + DEFAULT_ENDPOINT = 'https://hackers.pub/graphql' + + ARTICLES_QUERY = <<~GRAPHQL + query Articles($handle: String!, $allowLocalHandle: Boolean!) { + actorByHandle(handle: $handle, allowLocalHandle: $allowLocalHandle) { + articles { + edges { + node { + id + language + name + published + summary + content + url + visibility + } + } + } + } + } + GRAPHQL + + ACTOR_BIO_QUERY = <<~GRAPHQL + query ActorBio($handle: String!, $allowLocalHandle: Boolean!) { + actorByHandle(handle: $handle, allowLocalHandle: $allowLocalHandle) { + account { + bio + avatarUrl + } + } + } + GRAPHQL + + def initialize(handle:, endpoint: DEFAULT_ENDPOINT, base_url: 'https://hackers.pub') + @handle = handle + @endpoint = URI.parse(endpoint) + @base_url = base_url + end + + def fetch_posts + data = execute(ARTICLES_QUERY) + edges = data.dig('actorByHandle', 'articles', 'edges') || [] + edges.filter_map { |edge| normalize_post(edge['node']) } + end + + def fetch_actor_bio + data = execute(ACTOR_BIO_QUERY) + data['actorByHandle']&.fetch('account', nil) + end + + private + + attr_reader :handle, :endpoint, :base_url + + def execute(query) + payload = { + query: query, + variables: { + handle: handle, + allowLocalHandle: true + } + } + + response = post_json(payload) + parsed = JSON.parse(response.body) + + if parsed['errors']&.any? + message = parsed['errors'].map { |error| error['message'] }.join(', ') + raise Error, "Hackerspub GraphQL error: #{message}" + end + + parsed['data'] + rescue JSON::ParserError => e + raise Error, "Unable to parse Hackerspub response: #{e.message}" + end + + def post_json(payload) + http = Net::HTTP.new(endpoint.host, endpoint.port) + http.use_ssl = endpoint.scheme == 'https' + + request = Net::HTTP::Post.new(endpoint) + request['Content-Type'] = 'application/json' + request.body = JSON.dump(payload) + + response = http.request(request) + unless response.is_a?(Net::HTTPSuccess) + raise Error, + "Hackerspub request failed (#{response.code} #{response.message})" + end + + response + rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e + raise Error, "Unable to connect to Hackerspub API: #{e.message}" + end + + def normalize_post(node) # rubocop:disable Metrics/AbcSize + return unless node + + url = node['url'] + return unless url.is_a?(String) + + profile_base = profile_url_base + return unless url.start_with?(profile_base) + + path_segments = URI.parse(url).path.split('/').reject(&:empty?) + return unless path_segments.length >= 3 + + year = path_segments[-2] + encoded_slug = path_segments[-1] + slug = CGI.unescape(encoded_slug) + + { + id: node['id'], + name: node['name'], + summary: node['summary'], + content: node['content'], + url: url, + language: node['language'], + visibility: node['visibility'], + year: year, + slug: slug, + encoded_slug: encoded_slug, + published_at: parse_time(node['published']), + published_raw: node['published'] + } + rescue URI::InvalidURIError + nil + end + + def parse_time(value) + return if value.nil? + + case value + when Time + value + when DateTime + value.to_time + when String + Time.parse(value) + end + rescue ArgumentError + nil + end + + def profile_url_base + @profile_url_base ||= begin + normalized_base = base_url.to_s.chomp('/') + normalized_handle = handle.to_s + normalized_handle = "@#{normalized_handle.delete_prefix('@')}" + "#{normalized_base}/#{normalized_handle}/" + end + end + end + end +end diff --git a/src/_layouts/hackerspub_post.erb b/src/_layouts/hackerspub_post.erb new file mode 100644 index 00000000..0f0ed9fe --- /dev/null +++ b/src/_layouts/hackerspub_post.erb @@ -0,0 +1,39 @@ +--- +layout: default +--- + +<% title = resource.data.title || resource.data.slug %> +<% published_at = resource.data.date %> +<% language = resource.data.language %> +<% formatted_date = published_at&.strftime("%B %d, %Y") %> + +
+

<%= title %>

+ +
+ <% if formatted_date %> + <%= formatted_date %> + <% end %> + <% if language %> + . + <%= language %> + <% end %> +
+ + <% if resource.content %> +
+ <%= helpers.raw resource.content %> +
+ <% end %> + +
+ + hackers.pub에서 보기 -> + +
+
diff --git a/src/_partials/_navbar.erb b/src/_partials/_navbar.erb index 1858f7ab..0940141c 100644 --- a/src/_partials/_navbar.erb +++ b/src/_partials/_navbar.erb @@ -5,5 +5,6 @@
  • /about
  • /posts
  • /wiki
  • +
  • /hackerspub
  • diff --git a/src/_partials/_vim_kr_advertisement.erb b/src/_partials/_vim_kr_advertisement.erb index aa97948e..1b263673 100644 --- a/src/_partials/_vim_kr_advertisement.erb +++ b/src/_partials/_vim_kr_advertisement.erb @@ -1,3 +1,2 @@ - -

    혹시 이 글을 읽고 Vim에 관심을 가지게 되셨나요?
    Vim에 관심있는 사람들을 위한 Discord 커뮤니티
    vim.kr 바로 여러분들을 위한 커뮤니티입니다.

    - +<%# -*- coding: utf-8 -*- %> +

    혹시 이 글을 읽고 Vim에 관심을 가지게 되셨나요?
    Vim에 관심있는 사람들을 위한 Discord 커뮤니티
    vim.kr 바로 여러분들을 위한 커뮤니티입니다.

    diff --git a/src/_wiki/templates/daily.md b/src/_wiki/templates/daily.md deleted file mode 100644 index 3fd99f44..00000000 --- a/src/_wiki/templates/daily.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: {{date}} -date: {{date}} -layout: wiki ---- - diff --git a/src/_wiki/templates/new_note.md b/src/_wiki/templates/new_note.md deleted file mode 100644 index 75b4f012..00000000 --- a/src/_wiki/templates/new_note.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: {{title}} -date: {{date}} -layout: wiki ---- diff --git a/src/_wiki/templates/weekly.md b/src/_wiki/templates/weekly.md deleted file mode 100644 index f193de26..00000000 --- a/src/_wiki/templates/weekly.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: {{year}}-W{{week}} -date: {{sunday}} -layout: wiki ---- - -# Review Week {{week}} / {{year}} - ---- - -## Highlights -- **this**! -- that! - -## Monday ......... [[daily/{{monday}}]]{{{monday}}} -## Tuesday ......... [[daily/{{tuesday}}]]{{{tuesday}}} -## Wednesday ......... [[daily{{wednesday}}]]{{{wednesday}}} -## Thursday ......... [[daily/{{thursday}}]]{{{thursday}}} -## Friday ......... [[daily/{{friday}}]]{{{friday}}} -## Saturday ......... [[daily/{{saturday}}]]{{{saturday}}} -## Sunday ......... [[daily/{{sunday}}]]{{{sunday}}} diff --git a/src/hackerspub/index.erb b/src/hackerspub/index.erb new file mode 100644 index 00000000..8c1c431d --- /dev/null +++ b/src/hackerspub/index.erb @@ -0,0 +1,66 @@ +--- +layout: page +title: Posts from Hackers' Pub +permalink: /hackerspub/ +--- + +<% actor = site.data["hackerspub_actor"] %> +<% posts = collections.hackerspub_posts.resources.sort_by { |resource| resource.data.date || Time.at(0) }.reverse %> + +<% if actor %> +
    + <% if actor["avatarUrl"] %> + " alt="Hackers.pub avatar" class="w-16 h-16 rounded-full shadow" loading="lazy" /> + <% end %> +
    +

    <%= actor["bio"] ? "About" : "Hackers.pub" %>

    + <% if actor["bio"] %> +

    <%= actor["bio"] %>

    + <% elsif site.data["hackerspub_handle"] %> +

    + Posts syndicated from <%= site.data["hackerspub_handle"] %>. +

    + <% else %> +

    + Posts syndicated from Hackers.pub. +

    + <% end %> +
    +
    +<% end %> + +<% if posts.empty? %> +

    No Hackers.pub posts are available. Run the build with network access enabled to fetch articles.

    +<% else %> + +<% end %>