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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +13 -25
- data/lib/al_ext_posts.rb +144 -13
- metadata +8 -8
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,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
|
-
#
|
|
1
|
+
# al-ext-posts
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
+
- RSS feeds (`rss_url`)
|
|
39
|
+
- Manual URL entries (`posts` with `url` + `published_date`)
|
|
52
40
|
|
|
53
|
-
|
|
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
|
-
##
|
|
43
|
+
## Ecosystem context
|
|
57
44
|
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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,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
|
|
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.
|
|
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-
|
|
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: '
|
|
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: '
|
|
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.
|
|
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: []
|