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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +2 -0
  4. data/lib/al_ext_posts.rb +125 -11
  5. metadata +8 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7854d59bf4f472e37e31cb9c397c45154a66c1803c785cbae6be5e0a6fe52936
4
- data.tar.gz: 2552fdbeb58639b0eedd78589fca65518bde5bbf283fd483565543efbb77f8ad
3
+ metadata.gz: 0f90ec27d26faf8b995829c3a53b194c03e4911226367cd8da1528cbeedeba30
4
+ data.tar.gz: 23ed5c58215230bcbc7ffcedee2c4371e148138e15e10396cf8812b15f716f26
5
5
  SHA512:
6
- metadata.gz: 49733ec7e66503e0ef3326333495cad2b246a657e3780e06d3d8211d4e962293bd80ead5a524e0c0e92ea47b0d13f231c9fdf392c1268ae486bdc06d2f67552b
7
- data.tar.gz: 3dbc5c1e74595e275acf15c892cc432963e953d1656899ff47a284f4b6258eaa8920fc39d50ed560f68452a1922cfb33ebdab8f00e4425181f59569e107ae123
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
- # check if title is composed only of whitespace or foreign characters
51
- if content[:title].gsub(/[^\w]/, '').strip.empty?
52
- # use the source name and last url segment as fallback
53
- slug = "#{source_name.downcase.strip.gsub(' ', '-').gsub(/[^\w-]/, '')}-#{url.split('/').last}"
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'] = content[: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.strip || ''
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.1
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: 1980-01-02 00:00:00.000000000 Z
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: '3.0'
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: '3.0'
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: 4.0.6
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: []