al_ext_posts 1.0.0 → 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 +13 -0
  3. data/README.md +13 -25
  4. data/lib/al_ext_posts.rb +144 -13
  5. metadata +8 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5ab66f82088899d5caa67f1247c948635fcc3b7b43e73706f9433352430d771
4
- data.tar.gz: 5cadace8daa8f365709696ca53d164c9fa3cafbd694651c294a402662c964075
3
+ metadata.gz: 0f90ec27d26faf8b995829c3a53b194c03e4911226367cd8da1528cbeedeba30
4
+ data.tar.gz: 23ed5c58215230bcbc7ffcedee2c4371e148138e15e10396cf8812b15f716f26
5
5
  SHA512:
6
- metadata.gz: 9f6756b08533749353a98c29212a4b8b5337c5fb9f0b75020428b99ff5691cb7dd902c00e8f03c670bfb93f652733112d4bbe1eff2378efc463804b862dec651
7
- data.tar.gz: 33e31dcc2d7362ef5e9aac0687e8cce67d0b4209511365675c9f82f40a9341152b9a27dd53705b3a9ea28a3d8b9041a13c8d630fc68b2f240cd4993c37bea7ed
6
+ metadata.gz: 17caacb0ed2a7b3995f7b99085dd7712e3c2b8e7ca7e59a68db00773ed2668bccd757fa722c5ff33bf1bac6e0ca525185ef39e8fe1d74bd2fdbbd104e2596555
7
+ data.tar.gz: 63d2fbe672b0a3d2d95901e67fde4ef272e3851d1ea47cd0b9af5b6ff654c4b60cde850bb60212e4d265fe863670d4d08cf2fa4d90b4cdc7ca2d9edf2dd3f183
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
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
+
11
+ ## 1.0.1 - 2026-05-24
12
+
13
+ - Preserved source-level categories/tags while allowing RSS entries and explicit external posts to override them.
14
+ - Hardened tests against local timezone differences and current Minitest releases.
15
+
3
16
  ## 0.1.0 - 2026-02-07
4
17
 
5
18
  - Initial gem release.
data/README.md CHANGED
@@ -1,31 +1,21 @@
1
- # Al-Ext-Posts
1
+ # al-ext-posts
2
2
 
3
- A Jekyll plugin that allows you to fetch and display external blog posts from RSS feeds and specific URLs in your Jekyll site.
3
+ `al_ext_posts` imports and renders external posts for `al-folio` v1.x and compatible Jekyll sites.
4
4
 
5
5
  ## Installation
6
6
 
7
- Add this line to your Jekyll site's Gemfile:
8
-
9
7
  ```ruby
10
8
  gem 'al_ext_posts'
11
9
  ```
12
10
 
13
- And then execute:
14
-
15
- ```bash
16
- $ bundle install
17
- ```
18
-
19
- ## Usage
20
-
21
- 1. Add the plugin to your site's `_config.yml`:
22
-
23
11
  ```yaml
24
12
  plugins:
25
13
  - al_ext_posts
26
14
  ```
27
15
 
28
- 2. Configure your external sources in `_config.yml`:
16
+ ## Usage
17
+
18
+ Configure external sources in `_config.yml`:
29
19
 
30
20
  ```yaml
31
21
  external_sources:
@@ -43,20 +33,18 @@ external_sources:
43
33
  published_date: "2024-03-21"
44
34
  ```
45
35
 
46
- The plugin supports two types of sources:
47
-
48
- - RSS feeds: Provide the `rss_url` parameter
49
- - Direct URLs: Provide a list of `posts` with `url` and `published_date`
36
+ Supported source types:
50
37
 
51
- Optional per-source defaults:
38
+ - RSS feeds (`rss_url`)
39
+ - Manual URL entries (`posts` with `url` + `published_date`)
52
40
 
53
- - `categories`: array of categories automatically applied to imported posts
54
- - `tags`: array of tags automatically applied to imported posts
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.
55
42
 
56
- ## Development
43
+ ## Ecosystem context
57
44
 
58
- After checking out the repo, run `bundle install` to install dependencies.
45
+ - Starter demo content/wiring lives in `al-folio`.
46
+ - External post ingestion logic is owned here.
59
47
 
60
48
  ## Contributing
61
49
 
62
- Bug reports and pull requests are welcome on GitHub.
50
+ Parser/source behavior changes should be contributed in this repository.
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|
@@ -42,27 +50,22 @@ module AlExtPosts
42
50
  content: e.content,
43
51
  summary: e.summary,
44
52
  published: e.published
45
- }, src)
53
+ }, metadata_for_post(src, e))
46
54
  end
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,12 +83,140 @@ 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']}"
86
200
  content = fetch_content_from_url(post['url'])
87
201
  content[:published] = parse_published_date(post['published_date'])
88
- create_document(site, src['name'], post['url'], content, src)
202
+ create_document(site, src['name'], post['url'], content, metadata_for_post(src, post))
203
+ end
204
+ end
205
+
206
+ def metadata_for_post(src, post)
207
+ metadata = src.dup
208
+ %w[categories tags].each do |key|
209
+ value = metadata_value(post, key)
210
+ metadata[key] = value if value && !(value.respond_to?(:empty?) && value.empty?)
211
+ end
212
+ metadata
213
+ end
214
+
215
+ def metadata_value(post, key)
216
+ if post.respond_to?(:key?)
217
+ post[key] || post[key.to_sym]
218
+ elsif post.respond_to?(key)
219
+ post.public_send(key)
89
220
  end
90
221
  end
91
222
 
@@ -104,7 +235,7 @@ module AlExtPosts
104
235
  html = HTTParty.get(url).body
105
236
  parsed_html = Nokogiri::HTML(html)
106
237
 
107
- title = parsed_html.at('head title')&.text.strip || ''
238
+ title = parsed_html.at('head title')&.text&.strip || ''
108
239
 
109
240
  description = parsed_html.at('head meta[name="description"]')&.attr('content')
110
241
  description ||= parsed_html.at('head meta[name="og:description"]')&.attr('content')
metadata CHANGED
@@ -1,14 +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.0
4
+ version: 1.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - al-org
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-16 00:00:00.000000000 Z
11
+ date: 2026-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jekyll
@@ -99,7 +99,7 @@ dependencies:
99
99
  version: '2.0'
100
100
  - - "<"
101
101
  - !ruby/object:Gem::Version
102
- version: '3.0'
102
+ version: '5.0'
103
103
  type: :development
104
104
  prerelease: false
105
105
  version_requirements: !ruby/object:Gem::Requirement
@@ -109,7 +109,7 @@ dependencies:
109
109
  version: '2.0'
110
110
  - - "<"
111
111
  - !ruby/object:Gem::Version
112
- version: '3.0'
112
+ version: '5.0'
113
113
  - !ruby/object:Gem::Dependency
114
114
  name: rake
115
115
  requirement: !ruby/object:Gem::Requirement
@@ -143,7 +143,7 @@ metadata:
143
143
  allowed_push_host: https://rubygems.org
144
144
  homepage_uri: https://github.com/al-org-dev/al-ext-posts
145
145
  source_code_uri: https://github.com/al-org-dev/al-ext-posts
146
- post_install_message:
146
+ post_install_message:
147
147
  rdoc_options: []
148
148
  require_paths:
149
149
  - lib
@@ -158,8 +158,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
158
158
  - !ruby/object:Gem::Version
159
159
  version: '0'
160
160
  requirements: []
161
- rubygems_version: 3.0.3.1
162
- signing_key:
161
+ rubygems_version: 3.5.22
162
+ signing_key:
163
163
  specification_version: 4
164
164
  summary: Import external posts from RSS feeds and URLs
165
165
  test_files: []