al_ext_posts 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +2 -0
- data/lib/al_ext_posts.rb +125 -11
- metadata +8 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f90ec27d26faf8b995829c3a53b194c03e4911226367cd8da1528cbeedeba30
|
|
4
|
+
data.tar.gz: 23ed5c58215230bcbc7ffcedee2c4371e148138e15e10396cf8812b15f716f26
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 17caacb0ed2a7b3995f7b99085dd7712e3c2b8e7ca7e59a68db00773ed2668bccd757fa722c5ff33bf1bac6e0ca525185ef39e8fe1d74bd2fdbbd104e2596555
|
|
7
|
+
data.tar.gz: 63d2fbe672b0a3d2d95901e67fde4ef272e3851d1ea47cd0b9af5b6ff654c4b60cde850bb60212e4d265fe863670d4d08cf2fa4d90b4cdc7ca2d9edf2dd3f183
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.3 - 2026-07-27
|
|
4
|
+
|
|
5
|
+
- Fixed external posts being published with an empty title when a fetch degraded (an unreachable page, a page with no `<title>`, or an RSS item with a blank one), which rendered as a blank but clickable row in the blog index and produced a stream of Jekyll ``Empty `slug` generated`` warnings. A readable title is now derived from the URL's last meaningful path segment, and a warning naming the URL is logged. Post slugs, and therefore post URLs, are unchanged.
|
|
6
|
+
|
|
7
|
+
## 1.0.2 - 2026-07-27
|
|
8
|
+
|
|
9
|
+
- Optimized external-post slug generation: the title check no longer allocates a stripped copy of the title, character filtering runs in a single regexp pass instead of two, and the source-name fallback is only built when it is actually needed. Slug output is unchanged.
|
|
10
|
+
|
|
3
11
|
## 1.0.1 - 2026-05-24
|
|
4
12
|
|
|
5
13
|
- Preserved source-level categories/tags while allowing RSS entries and explicit external posts to override them.
|
data/README.md
CHANGED
|
@@ -38,6 +38,8 @@ Supported source types:
|
|
|
38
38
|
- RSS feeds (`rss_url`)
|
|
39
39
|
- Manual URL entries (`posts` with `url` + `published_date`)
|
|
40
40
|
|
|
41
|
+
When a fetch degrades and an entry arrives without a usable title, the title is derived from the last meaningful segment of its URL and a warning naming that URL is logged, so the entry stays readable in the post listing instead of rendering as a blank row.
|
|
42
|
+
|
|
41
43
|
## Ecosystem context
|
|
42
44
|
|
|
43
45
|
- Starter demo content/wiring lives in `al-folio`.
|
data/lib/al_ext_posts.rb
CHANGED
|
@@ -3,12 +3,20 @@ require 'httparty'
|
|
|
3
3
|
require 'jekyll'
|
|
4
4
|
require 'nokogiri'
|
|
5
5
|
require 'time'
|
|
6
|
+
require 'uri'
|
|
6
7
|
|
|
7
8
|
module AlExtPosts
|
|
8
9
|
class ExternalPostsGenerator < Jekyll::Generator
|
|
9
10
|
safe true
|
|
10
11
|
priority :high
|
|
11
12
|
|
|
13
|
+
# Extensions dropped from a URL segment before it is turned into a title,
|
|
14
|
+
# so `.../my-post.html` reads as "My Post" rather than "My Post Html".
|
|
15
|
+
PAGE_EXTENSIONS = %w[html htm xhtml shtml php asp aspx jsp cfm md markdown txt].freeze
|
|
16
|
+
|
|
17
|
+
# Last-resort title for a URL with neither a usable path nor a host.
|
|
18
|
+
FALLBACK_TITLE = 'External post'.freeze
|
|
19
|
+
|
|
12
20
|
def generate(site)
|
|
13
21
|
if site.config['external_sources'] != nil
|
|
14
22
|
site.config['external_sources'].each do |src|
|
|
@@ -47,22 +55,17 @@ module AlExtPosts
|
|
|
47
55
|
end
|
|
48
56
|
|
|
49
57
|
def create_document(site, source_name, url, content, src = {})
|
|
50
|
-
#
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
else
|
|
55
|
-
# parse title from the post or use the source name and last url segment as fallback
|
|
56
|
-
slug = content[:title].downcase.strip.gsub(' ', '-').gsub(/[^\w-]/, '')
|
|
57
|
-
slug = "#{source_name.downcase.strip.gsub(' ', '-').gsub(/[^\w-]/, '')}-#{url.split('/').last}" if slug.empty?
|
|
58
|
-
end
|
|
58
|
+
# The slug is still derived from the raw title so existing post URLs keep
|
|
59
|
+
# their source-name fallback; only the published title is filled in.
|
|
60
|
+
slug = build_slug(source_name, url, content[:title])
|
|
61
|
+
title = resolve_title(content[:title], url)
|
|
59
62
|
|
|
60
63
|
path = site.in_source_dir("_posts/#{slug}.md")
|
|
61
64
|
doc = Jekyll::Document.new(
|
|
62
65
|
path, { :site => site, :collection => site.collections['posts'] }
|
|
63
66
|
)
|
|
64
67
|
doc.data['external_source'] = source_name
|
|
65
|
-
doc.data['title'] =
|
|
68
|
+
doc.data['title'] = title
|
|
66
69
|
doc.data['feed_content'] = content[:content]
|
|
67
70
|
doc.data['description'] = content[:summary]
|
|
68
71
|
doc.data['date'] = content[:published]
|
|
@@ -80,6 +83,117 @@ module AlExtPosts
|
|
|
80
83
|
site.collections['posts'].docs << doc
|
|
81
84
|
end
|
|
82
85
|
|
|
86
|
+
# Build a filesystem-safe post slug from the title, falling back to the
|
|
87
|
+
# source name + last URL segment when the title is missing, blank, or made
|
|
88
|
+
# up entirely of non-word characters. Guards against a nil title (e.g. an
|
|
89
|
+
# RSS entry or fetched page with no <title>), which previously raised a
|
|
90
|
+
# NoMethodError and aborted the whole build.
|
|
91
|
+
def build_slug(source_name, url, title)
|
|
92
|
+
return fallback_slug(source_name, url) unless title.to_s.match?(/\w/)
|
|
93
|
+
|
|
94
|
+
slug = slugify(title)
|
|
95
|
+
slug.empty? ? fallback_slug(source_name, url) : slug
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Fallback slug built from the source name and the last URL segment. Only
|
|
99
|
+
# computed on the fallback path so the common (titled) case does no extra
|
|
100
|
+
# string work.
|
|
101
|
+
def fallback_slug(source_name, url)
|
|
102
|
+
"#{slugify(source_name)}-#{url.split('/').last}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Drop every character that is not a word character, space, or hyphen in a
|
|
106
|
+
# single pass, then translate the remaining spaces to hyphens. Equivalent to
|
|
107
|
+
# the previous `gsub(' ', '-').gsub(/[^\w-]/, '')` pair, but avoids the
|
|
108
|
+
# intermediate string and the second regexp scan.
|
|
109
|
+
def slugify(value)
|
|
110
|
+
value.to_s.downcase.strip.gsub(/[^\w -]/, '').tr(' ', '-')
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Title to publish for an ingested item. Fetches degrade in ways that leave
|
|
114
|
+
# no title at all (an unreachable host, a page without <title>, an RSS item
|
|
115
|
+
# with a blank one), and publishing that empty string produced a blank but
|
|
116
|
+
# clickable row in the blog index plus a stream of Jekyll "Empty `slug`
|
|
117
|
+
# generated" warnings. The source is listed in _config.yml by the user, so
|
|
118
|
+
# the entry is kept: derive a readable title from the URL and warn that the
|
|
119
|
+
# fetch degraded.
|
|
120
|
+
def resolve_title(title, url)
|
|
121
|
+
return title if title.to_s.match?(/[[:word:]]/)
|
|
122
|
+
|
|
123
|
+
derived = title_from_url(url)
|
|
124
|
+
Jekyll.logger.warn('ExternalPosts:', "No title found for #{url} - using #{derived.inspect} derived from the URL.")
|
|
125
|
+
derived
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Turn the last meaningful path segment of a URL into a human-readable
|
|
129
|
+
# title: "https://blog.google/technology/ai/gemini-update-2024/" becomes
|
|
130
|
+
# "Gemini Update 2024". Trailing slashes, query strings and fragments are
|
|
131
|
+
# ignored, percent-escapes are decoded, and a page extension is dropped.
|
|
132
|
+
# Segments carrying no letters on their own (ids, /2024/05/ date parts) are
|
|
133
|
+
# skipped in favour of an earlier one, and a URL left with no wordy segment
|
|
134
|
+
# at all - a bare domain, an all-numeric path - falls back to its host.
|
|
135
|
+
# Never raises and never returns an empty string.
|
|
136
|
+
def title_from_url(url)
|
|
137
|
+
segments = url_path_segments(url)
|
|
138
|
+
segments[-1] = strip_page_extension(segments[-1]) unless segments.empty?
|
|
139
|
+
|
|
140
|
+
title = humanize_segment(segments.reverse.find { |segment| segment.match?(/[[:alpha:]]/) })
|
|
141
|
+
return title unless title.empty?
|
|
142
|
+
|
|
143
|
+
host = url_host(url)
|
|
144
|
+
host.empty? ? FALLBACK_TITLE : host
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Path segments of a URL, percent-decoded and stripped of blanks. Falls
|
|
148
|
+
# back to trimming the query/fragment by hand for inputs URI cannot parse.
|
|
149
|
+
def url_path_segments(url)
|
|
150
|
+
path = begin
|
|
151
|
+
URI.parse(url.to_s).path.to_s
|
|
152
|
+
rescue URI::Error
|
|
153
|
+
url.to_s.scrub('').split('#', 2).first.to_s.split('?', 2).first.to_s
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
path.split('/').map { |segment| decode_url_segment(segment) }.reject { |segment| segment.strip.empty? }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Host of a URL, without a leading "www.". Empty when there is none.
|
|
160
|
+
def url_host(url)
|
|
161
|
+
host = begin
|
|
162
|
+
URI.parse(url.to_s).host
|
|
163
|
+
rescue URI::Error
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
host.to_s.sub(/\Awww\./i, '')
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Percent-decode a single URL segment, keeping the raw text whenever the
|
|
171
|
+
# escapes are malformed or decode to invalid bytes. Scrubbed either way so
|
|
172
|
+
# the callers below can match against it without raising.
|
|
173
|
+
def decode_url_segment(segment)
|
|
174
|
+
decoded = URI.decode_www_form_component(segment)
|
|
175
|
+
(decoded.valid_encoding? ? decoded : segment).scrub('')
|
|
176
|
+
rescue ArgumentError
|
|
177
|
+
segment.scrub('')
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Drop a trailing web-page extension, keeping the segment untouched when
|
|
181
|
+
# the extension is unknown (a version number, say) or is all there is.
|
|
182
|
+
def strip_page_extension(segment)
|
|
183
|
+
extension = File.extname(segment)
|
|
184
|
+
return segment unless PAGE_EXTENSIONS.include?(extension.delete_prefix('.').downcase)
|
|
185
|
+
|
|
186
|
+
stripped = segment.chomp(extension)
|
|
187
|
+
stripped.empty? ? segment : stripped
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Split a URL segment on its separators and capitalize each word, leaving
|
|
191
|
+
# words that are already cased alone: "google-gemini-io-2024" becomes
|
|
192
|
+
# "Google Gemini Io 2024".
|
|
193
|
+
def humanize_segment(segment)
|
|
194
|
+
segment.to_s.gsub(/[^[:alnum:]]+/, ' ').split.map { |word| word.sub(/\A[[:lower:]]/, &:upcase) }.join(' ')
|
|
195
|
+
end
|
|
196
|
+
|
|
83
197
|
def fetch_from_urls(site, src)
|
|
84
198
|
src['posts'].each do |post|
|
|
85
199
|
puts "...fetching #{post['url']}"
|
|
@@ -121,7 +235,7 @@ module AlExtPosts
|
|
|
121
235
|
html = HTTParty.get(url).body
|
|
122
236
|
parsed_html = Nokogiri::HTML(html)
|
|
123
237
|
|
|
124
|
-
title = parsed_html.at('head title')&.text
|
|
238
|
+
title = parsed_html.at('head title')&.text&.strip || ''
|
|
125
239
|
|
|
126
240
|
description = parsed_html.at('head meta[name="description"]')&.attr('content')
|
|
127
241
|
description ||= parsed_html.at('head meta[name="og:description"]')&.attr('content')
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: al_ext_posts
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- al-org
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-07-29 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: jekyll
|
|
@@ -98,7 +99,7 @@ dependencies:
|
|
|
98
99
|
version: '2.0'
|
|
99
100
|
- - "<"
|
|
100
101
|
- !ruby/object:Gem::Version
|
|
101
|
-
version: '
|
|
102
|
+
version: '5.0'
|
|
102
103
|
type: :development
|
|
103
104
|
prerelease: false
|
|
104
105
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -108,7 +109,7 @@ dependencies:
|
|
|
108
109
|
version: '2.0'
|
|
109
110
|
- - "<"
|
|
110
111
|
- !ruby/object:Gem::Version
|
|
111
|
-
version: '
|
|
112
|
+
version: '5.0'
|
|
112
113
|
- !ruby/object:Gem::Dependency
|
|
113
114
|
name: rake
|
|
114
115
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -142,6 +143,7 @@ metadata:
|
|
|
142
143
|
allowed_push_host: https://rubygems.org
|
|
143
144
|
homepage_uri: https://github.com/al-org-dev/al-ext-posts
|
|
144
145
|
source_code_uri: https://github.com/al-org-dev/al-ext-posts
|
|
146
|
+
post_install_message:
|
|
145
147
|
rdoc_options: []
|
|
146
148
|
require_paths:
|
|
147
149
|
- lib
|
|
@@ -156,7 +158,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
156
158
|
- !ruby/object:Gem::Version
|
|
157
159
|
version: '0'
|
|
158
160
|
requirements: []
|
|
159
|
-
rubygems_version:
|
|
161
|
+
rubygems_version: 3.5.22
|
|
162
|
+
signing_key:
|
|
160
163
|
specification_version: 4
|
|
161
164
|
summary: Import external posts from RSS feeds and URLs
|
|
162
165
|
test_files: []
|