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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +15 -1
- data/lib/jekyll/documents/generator.rb +51 -11
- data/lib/jekyll/documents/layout_registrar.rb +1 -1
- data/lib/jekyll/documents/text_extraction_manifest.rb +72 -48
- data/lib/jekyll/documents/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be2955da22172cc24f7aed4e3dca0ac968bf131040a36db5fe2ba048a64bca90
|
|
4
|
+
data.tar.gz: 2b3d0544fa7c86739e2d83cd9228ee24c64f6bc4ff7691211aefbc7f3533f837
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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)
|
|
192
|
-
|
|
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
|
-
|
|
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["
|
|
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["
|
|
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
|
-
|
|
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["
|
|
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
|