jekyll-documents 0.6.0 → 0.6.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 989b1372181a7ca00444d25b5fab29751a077d9818c41cca1fe8c14208050214
4
- data.tar.gz: 8215c9234df6eba090b83a296acd0c058a73ae3bd16b0d11fd2ce69e539d788e
3
+ metadata.gz: be2955da22172cc24f7aed4e3dca0ac968bf131040a36db5fe2ba048a64bca90
4
+ data.tar.gz: 2b3d0544fa7c86739e2d83cd9228ee24c64f6bc4ff7691211aefbc7f3533f837
5
5
  SHA512:
6
- metadata.gz: ba352213f38733179f053f989cfe5f363dee453176f4d301629fa0d5ab34b5d4f3aad443e8f46bc9b4ee9106626d09cbc255544862b12ba05850259a5b8879e9
7
- data.tar.gz: 911cb909b005316a55d1978127935cf1bbf6da093c5dd0a9b6ec9e66112a9aa88d094e82c318f8c7312450ec970592caf506a62b392b47e3f0f35380be5be9d5
6
+ metadata.gz: 1f2c7c21a5cdd8c5e996379467b2ceb38d69e368aa9f8bdae882a657366911fb1262f66a7c8ff549d848b1304fd90a8e56e9f37efe1b856f572fab550899d3d5
7
+ data.tar.gz: fce8b514c14788e75405a026dcd6e7eabf25955a12ac16445380f995728cf6f2c65099ab42af18c51c8fb2c1361f3f3bb9cdfa1845a3642e4495e532640a7d16
data/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.6.1] - 2026-09-03
6
+
7
+ ### Changed
8
+ - Text extraction cache keys now include the source digest, extraction configuration, Plaintext version, and cache schema version so configuration or extractor changes invalidate stale results
9
+ - Extracted document content now retains searchable metadata for title, category, file type, and date
10
+ - Text cache cleanup now preserves shared digest files and removes orphaned files safely
11
+ - Text extraction now enforces `text_max_bytes` as a UTF-8 byte limit and closes source files after reading
12
+ - PDF extraction now discovers `pdftotext` from `PATH`; added macOS and Debian/Ubuntu setup instructions and CI coverage with `poppler-utils`
13
+ - Configured text cache directories are excluded from Jekyll output regardless of the `extract_text` setting
14
+ - Rake packaging and test tasks no longer perform duplicate work and install only the current package artifact
15
+ - Removed redundant package metadata and duplicate development dependency declarations
16
+
17
+ ### Fixed
18
+ - Replaced use of the private ActiveSupport deprecation API with the public `ActiveSupport.deprecator` API
19
+ - Empty extraction results now fall back to metadata-only searchable content
20
+ - Manifest loading now validates entries, logs filesystem read failures, and resets dirty state after successful saves
21
+ - Updated stale CI, development, cache-cleanup, and package-lock documentation
22
+
23
+ ### Tests
24
+ - Added real PDF extraction coverage and stronger cache-hit/persistence assertions
25
+ - Added tests for cache configuration invalidation, UTF-8 byte truncation, shared cache references, orphan cleanup, and conditional cache exclusion
26
+
5
27
  ## [0.6.0] - 2026-08-27
6
28
 
7
29
  ### Added
data/README.md CHANGED
@@ -257,7 +257,7 @@ documents:
257
257
  - Cache invalidation uses **SHA-256 file digests** — only changed files
258
258
  are re-extracted
259
259
  - Stale cache entries for deleted files are cleaned up automatically
260
- at the start of each build
260
+ at the end of each build
261
261
  - Falls back to metadata-only content (title, category, file type, date)
262
262
  if the `plaintext` gem is missing or extraction fails
263
263
 
@@ -287,6 +287,20 @@ tools needed). PDF extraction shells out to a system command:
287
287
  | DOCX/PPTX/XLSX | rubyzip (Ruby gem, no CLI needed) |
288
288
  | ODT/ODS/ODP | rubyzip (Ruby gem, no CLI needed) |
289
289
 
290
+ Install `pdftotext` for PDF extraction:
291
+
292
+ ```bash
293
+ # macOS
294
+ brew install poppler
295
+
296
+ # Debian/Ubuntu
297
+ sudo apt-get install poppler-utils
298
+ ```
299
+
300
+ On macOS and other non-standard installations, the executable is discovered
301
+ from `PATH`. You can also configure a custom `pdftotext` command through the
302
+ `plaintext` gem configuration.
303
+
290
304
  ## Development
291
305
 
292
306
  ```bash
@@ -28,6 +28,7 @@ module Jekyll
28
28
  def generate(site)
29
29
  @site = site
30
30
  @config = Configuration.read(site)
31
+ @text_manifest = nil
31
32
 
32
33
  root = File.join(site.source, @config["root"])
33
34
  unless Dir.exist?(root)
@@ -128,18 +129,19 @@ module Jekyll
128
129
  manifest = text_manifest
129
130
  rel_path = info[:rel_path]
130
131
  digest = ::Digest::SHA256.file(info[:path]).hexdigest
132
+ metadata = extraction_cache_metadata
131
133
 
132
- cached = manifest.get(rel_path, digest)
134
+ cached = manifest.get(rel_path, digest, metadata)
133
135
  return cached if cached
134
136
 
135
- # Suppress ActiveSupport deprecation warnings from the plaintext gem
136
- # (it uses String#mb_chars, deprecated in Rails 8.2).
137
- # We apply our own truncation below, so the gem's internal limit is redundant.
138
- text = ActiveSupport::Deprecation._instance.silence do
139
- ::Plaintext::Resolver.new(File.open(info[:path]), content_type).text
137
+ configure_pdf_extractor(content_type)
138
+ text = ActiveSupport.deprecator.silence do
139
+ File.open(info[:path], "rb") do |file|
140
+ ::Plaintext::Resolver.new(file, content_type).text
141
+ end
140
142
  end
141
- text = text&.truncate(@config["text_max_bytes"]) if text
142
- manifest.set(rel_path, digest, text) if text
143
+ text = truncate_bytes(text, @config["text_max_bytes"]) if text
144
+ manifest.set(rel_path, digest, text, metadata) if text
143
145
  text
144
146
  rescue StandardError => e
145
147
  ::Jekyll.logger.warn "jekyll-documents",
@@ -147,6 +149,19 @@ module Jekyll
147
149
  nil
148
150
  end
149
151
 
152
+ def configure_pdf_extractor(content_type)
153
+ return unless content_type == "application/pdf"
154
+ return unless ::Plaintext::Configuration["pdftotext"].nil?
155
+
156
+ executable = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
157
+ .map { |directory| File.join(directory, "pdftotext") }
158
+ .find { |path| File.executable?(path) }
159
+ return unless executable
160
+
161
+ ::Plaintext::Configuration.config["pdftotext"] =
162
+ [executable, "-enc", "UTF-8", "__FILE__", "-"]
163
+ end
164
+
150
165
  def load_plaintext
151
166
  return true if defined?(::Plaintext)
152
167
 
@@ -163,6 +178,26 @@ module Jekyll
163
178
  @text_manifest ||= TextExtractionManifest.new(@site, @config["text_cache_dir"])
164
179
  end
165
180
 
181
+ def extraction_cache_metadata
182
+ {
183
+ "schema_version" => 1,
184
+ "text_max_bytes" => @config["text_max_bytes"],
185
+ "plaintext_version" => if ::Plaintext.const_defined?(:VERSION)
186
+ ::Plaintext::VERSION.to_s
187
+ else
188
+ "unknown"
189
+ end
190
+ }
191
+ end
192
+
193
+ def truncate_bytes(text, max_bytes)
194
+ bytes = text.to_s.encode("UTF-8", invalid: :replace, undef: :replace)
195
+ .byteslice(0, max_bytes)
196
+ bytes = bytes.to_s.force_encoding("UTF-8")
197
+ bytes = bytes.byteslice(0, bytes.bytesize - 1) until bytes.valid_encoding?
198
+ bytes
199
+ end
200
+
166
201
  def cleanup_manifest(current_rel_paths)
167
202
  text_manifest.cleanup_deleted(current_rel_paths)
168
203
  text_manifest.save
@@ -187,11 +222,16 @@ module Jekyll
187
222
  data["permalink"] = @config["permalink"]
188
223
  .gsub(":category", category.to_s)
189
224
  .gsub(":slug", info[:slug])
225
+ metadata_content = searchable_content(info[:title], data, info[:file_type])
190
226
  doc.content = if @config["extract_text"]
191
- extract_file_content(info) || searchable_content(info[:title], data,
192
- info[:file_type])
227
+ extracted_content = extract_file_content(info)
228
+ if extracted_content && !extracted_content.empty?
229
+ "#{metadata_content} #{extracted_content}"
230
+ else
231
+ metadata_content
232
+ end
193
233
  else
194
- searchable_content(info[:title], data, info[:file_type])
234
+ metadata_content
195
235
  end
196
236
  end
197
237
 
@@ -60,7 +60,7 @@ Jekyll::Hooks.register :site, :after_init do |site|
60
60
 
61
61
  # Exclude the text extraction cache directory from Jekyll output
62
62
  config = Jekyll::Documents::Configuration.read(site)
63
- if config["extract_text"] && config["text_cache_dir"]
63
+ if config["text_cache_dir"]
64
64
  site.config["exclude"] = Array(site.config["exclude"])
65
65
  unless site.config["exclude"].include?(config["text_cache_dir"])
66
66
  site.config["exclude"] << config["text_cache_dir"]
@@ -6,6 +6,61 @@ require "digest"
6
6
 
7
7
  module Jekyll
8
8
  module Documents
9
+ module TextExtractionManifestSupport
10
+ private
11
+
12
+ def load_manifest
13
+ return {} unless File.file?(@manifest_path)
14
+
15
+ data = JSON.parse(File.read(@manifest_path, encoding: "UTF-8"))
16
+ data.is_a?(Hash) ? data.select { |_path, entry| valid_entry?(entry) } : {}
17
+ rescue JSON::ParserError => e
18
+ ::Jekyll.logger.warn "jekyll-documents",
19
+ "Corrupt text extraction manifest, starting fresh: #{e.message}"
20
+ {}
21
+ rescue StandardError => e
22
+ ::Jekyll.logger.warn "jekyll-documents",
23
+ "Text extraction manifest read failed: #{e.message}"
24
+ {}
25
+ end
26
+
27
+ def valid_entry?(entry)
28
+ entry.is_a?(Hash) && entry.values_at("digest", "cache_key", "text_file").all?(String)
29
+ end
30
+
31
+ def cache_key(digest, metadata)
32
+ Digest::SHA256.hexdigest(JSON.generate("digest" => digest, "metadata" => metadata))
33
+ end
34
+
35
+ def text_file_path(text_file)
36
+ return unless text_file.is_a?(String)
37
+
38
+ path = File.expand_path(File.join(@text_dir, text_file))
39
+ root = File.expand_path(@text_dir)
40
+ path.start_with?("#{root}#{File::SEPARATOR}") ? path : nil
41
+ end
42
+
43
+ def write_text_file(digest, text)
44
+ shard = digest[0, 2]
45
+ subdir = File.join(@text_dir, shard)
46
+ FileUtils.mkdir_p(subdir)
47
+ filename = "#{digest}.txt"
48
+ File.write(File.join(subdir, filename), text, encoding: "UTF-8")
49
+ File.join(shard, filename)
50
+ end
51
+
52
+ def cleanup_unreferenced_text_files
53
+ referenced = @manifest.values.filter_map do |entry|
54
+ text_file_path(entry["text_file"]) if valid_entry?(entry)
55
+ end.to_set
56
+ Dir.glob(File.join(@text_dir, "**", "*.txt")).each do |path|
57
+ File.delete(path) unless referenced.include?(File.expand_path(path))
58
+ end
59
+ rescue StandardError
60
+ nil
61
+ end
62
+ end
63
+
9
64
  # Manages the text extraction manifest — a persistent JSON file that tracks
10
65
  # which documents have had their text extracted, keyed by SHA-256 digest.
11
66
  #
@@ -19,6 +74,8 @@ module Jekyll
19
74
  # - Cleanup of entries for deleted source files
20
75
  # - Survives `jekyll clean` (stored in site source, not .jekyll-cache/)
21
76
  class TextExtractionManifest
77
+ include TextExtractionManifestSupport
78
+
22
79
  MANIFEST_FILENAME = "text-extraction-manifest.json"
23
80
  TEXT_SUBDIR = "text"
24
81
 
@@ -40,13 +97,13 @@ module Jekyll
40
97
  # @param rel_path [String] relative path of the source document
41
98
  # @param digest [String] SHA-256 hex digest of the source file
42
99
  # @return [String, nil] extracted text if cache hit, nil otherwise
43
- def get(rel_path, digest)
100
+ def get(rel_path, digest, metadata = {})
44
101
  entry = @manifest[rel_path]
45
- return nil unless entry
46
- return nil unless entry["digest"] == digest
102
+ return nil unless valid_entry?(entry)
103
+ return nil unless entry["cache_key"] == cache_key(digest, metadata)
47
104
 
48
105
  text_file = text_file_path(entry["text_file"])
49
- return nil unless File.file?(text_file)
106
+ return nil unless text_file && File.file?(text_file)
50
107
 
51
108
  File.read(text_file, encoding: "UTF-8")
52
109
  rescue StandardError => e
@@ -60,13 +117,15 @@ module Jekyll
60
117
  # @param digest [String] SHA-256 hex digest of the source file
61
118
  # @param text [String] the extracted text
62
119
  # @return [void]
63
- def set(rel_path, digest, text)
120
+ def set(rel_path, digest, text, metadata = {})
64
121
  text_file = write_text_file(digest, text)
65
122
  @manifest[rel_path] = {
66
123
  "digest" => digest,
124
+ "cache_key" => cache_key(digest, metadata),
67
125
  "text_file" => text_file,
68
126
  "extracted_at" => Time.now.to_i
69
127
  }
128
+ cleanup_unreferenced_text_files
70
129
  @dirty = true
71
130
  end
72
131
 
@@ -81,12 +140,11 @@ module Jekyll
81
140
  @manifest.each_key do |rel_path|
82
141
  next if current_set.include?(rel_path)
83
142
 
84
- entry = @manifest[rel_path]
85
- delete_text_file(entry["text_file"]) if entry
86
143
  @manifest.delete(rel_path)
87
144
  removed += 1
88
145
  @dirty = true
89
146
  end
147
+ cleanup_unreferenced_text_files
90
148
 
91
149
  removed
92
150
  end
@@ -98,7 +156,10 @@ module Jekyll
98
156
  return unless @dirty
99
157
 
100
158
  content = JSON.pretty_generate(@manifest)
101
- return if File.exist?(@manifest_path) && File.binread(@manifest_path) == content
159
+ if File.exist?(@manifest_path) && File.binread(@manifest_path) == content
160
+ @dirty = false
161
+ return
162
+ end
102
163
 
103
164
  FileUtils.mkdir_p(@cache_root)
104
165
  temporary_path = "#{@manifest_path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
@@ -108,6 +169,7 @@ module Jekyll
108
169
  file.fsync
109
170
  end
110
171
  File.rename(temporary_path, @manifest_path)
172
+ @dirty = false
111
173
  ensure
112
174
  FileUtils.rm_f(temporary_path) if defined?(temporary_path) && temporary_path
113
175
  end
@@ -116,9 +178,9 @@ module Jekyll
116
178
  # @param rel_path [String] relative path of the source document
117
179
  # @param digest [String] SHA-256 hex digest of the source file
118
180
  # @return [Boolean]
119
- def cached?(rel_path, digest)
181
+ def cached?(rel_path, digest, metadata = {})
120
182
  entry = @manifest[rel_path]
121
- !!(entry && entry["digest"] == digest)
183
+ !!(valid_entry?(entry) && entry["cache_key"] == cache_key(digest, metadata))
122
184
  end
123
185
 
124
186
  # Number of entries in the manifest.
@@ -126,44 +188,6 @@ module Jekyll
126
188
  def size
127
189
  @manifest.size
128
190
  end
129
-
130
- private
131
-
132
- def load_manifest
133
- return {} unless File.file?(@manifest_path)
134
-
135
- data = JSON.parse(File.read(@manifest_path, encoding: "UTF-8"))
136
- data.is_a?(Hash) ? data : {}
137
- rescue JSON::ParserError => e
138
- ::Jekyll.logger.warn "jekyll-documents",
139
- "Corrupt text extraction manifest, starting fresh: #{e.message}"
140
- {}
141
- rescue StandardError
142
- {}
143
- end
144
-
145
- def text_file_path(text_file)
146
- File.join(@text_dir, text_file)
147
- end
148
-
149
- def write_text_file(digest, text)
150
- shard = digest[0, 2]
151
- subdir = File.join(@text_dir, shard)
152
- FileUtils.mkdir_p(subdir)
153
- filename = "#{digest}.txt"
154
- path = File.join(subdir, filename)
155
- File.write(path, text, encoding: "UTF-8")
156
- File.join(shard, filename)
157
- end
158
-
159
- def delete_text_file(text_file)
160
- return unless text_file
161
-
162
- path = text_file_path(text_file)
163
- File.delete(path) if File.file?(path)
164
- rescue StandardError
165
- nil
166
- end
167
191
  end
168
192
  end
169
193
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Jekyll
4
4
  module Documents
5
- VERSION = "0.6.0"
5
+ VERSION = "0.6.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-documents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svend Gundestrup