jekyll-documents 0.4.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: 76640be0741d32137905f8281bc4e83e4e895971a9ad39e7f360bccf06ba5eef
4
- data.tar.gz: 93ac35f0606b84b474ef6aff260e21c7dab78ce5e4a8f7f80b4b97493755ae8d
3
+ metadata.gz: be2955da22172cc24f7aed4e3dca0ac968bf131040a36db5fe2ba048a64bca90
4
+ data.tar.gz: 2b3d0544fa7c86739e2d83cd9228ee24c64f6bc4ff7691211aefbc7f3533f837
5
5
  SHA512:
6
- metadata.gz: db8fa1ad5fd90e910238d8b3b41427d8eeb8451ba70ca095cd610e26b56db88ae564e32856b6890c8c1e8af7b4a294fa8a9a9e7219fdd157bc7b0d80aa0c4181
7
- data.tar.gz: a19a1da88694fd8282f3040a3bdad01ca5627667a116a984d16d916a875a346bb156bb371374b6b0ae241b6cbe2a97c8ecfc8cb40a1728960243ce1359e60e97
6
+ metadata.gz: 1f2c7c21a5cdd8c5e996379467b2ceb38d69e368aa9f8bdae882a657366911fb1262f66a7c8ff549d848b1304fd90a8e56e9f37efe1b856f572fab550899d3d5
7
+ data.tar.gz: fce8b514c14788e75405a026dcd6e7eabf25955a12ac16445380f995728cf6f2c65099ab42af18c51c8fb2c1361f3f3bb9cdfa1845a3642e4495e532640a7d16
data/CHANGELOG.md CHANGED
@@ -2,6 +2,65 @@
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
+
27
+ ## [0.6.0] - 2026-08-27
28
+
29
+ ### Added
30
+ - Auto-inject `file_type`, `icon_url`, and `icon_set` into jekyll-client-search's `passthrough_fields` config when `documents` is in the search collections — zero extra config needed for icons in search results
31
+ - Documented field renaming in jekyll-client-search for integration with other search conventions
32
+ - Framework-agnostic CSS file (`assets/css/documents.css`) with icon scaling utility classes (`icon-x1` through `icon-x9`: 16px to 512px) — no dependency on Bulma, Bootstrap, or Tailwind
33
+ - Icons default to `1em` (line-height) so they scale with surrounding text
34
+
35
+ ### Changed
36
+ - Renamed icon CSS class from `file-icon` to `document-file-icon` to avoid collision with Bulma's `.file-icon` (which uses `display: flex` and breaks inline SVG icons onto a separate line)
37
+ - Inline `1em` sizing added to JS-rendered and folder icons as a fallback when the CSS file is not included
38
+
39
+ ## [0.5.0] - 2026-08-27
40
+
41
+ ### Added
42
+ - Text extraction from PDF, DOCX, XLSX, PPTX, ODT, ODS, and ODP files via the optional `plaintext` gem, enabling full-text search of document contents through `jekyll-client-search`
43
+ - `extract_text` configuration option to enable text extraction (disabled by default)
44
+ - `text_max_bytes` configuration option to control extracted text truncation (default 500KB)
45
+ - `text_cache_dir` configuration option for the persistent cache directory (default `.cache/jekyll-documents`)
46
+ - `TextExtractionManifest` class with SHA-256 content-based cache invalidation, atomic writes, sharded text file storage, and automatic cleanup of entries for deleted source files
47
+ - Cache directory excluded from Jekyll build output via a `:site, :after_init` hook
48
+ - 9 integration tests with real DOCX and ODT fixtures validating extraction, caching, manifest persistence, and cleanup
49
+ - `plaintext` as a development dependency for testing extraction
50
+
51
+ ### Changed
52
+ - Minimum Ruby version raised from 3.3 to 3.4 (tested on Ruby 3.4.10)
53
+ - RuboCop target version updated from 3.3 to 3.4
54
+ - Updated development dependencies: `rubocop` ~> 1.90, `rubocop-performance` ~> 1.27, `simplecov` ~> 1.1, `rake` ~> 13.4
55
+ - Updated Gemfile pins: `google-protobuf` ~> 4.36, `sass-embedded` ~> 1.103
56
+ - CI workflow matrix simplified to Ruby 3.4 only (dropped 3.3)
57
+ - `documentation_uri` in gemspec metadata now points to DeepWiki instead of duplicating the GitHub repo URL
58
+ - Removed redundant `homepage_uri` from gemspec metadata (already covered by `spec.homepage`)
59
+ - Suppressed ActiveSupport deprecation warnings from the `plaintext` gem (`String#mb_chars`, deprecated in Rails 8.2)
60
+
61
+ ### Fixed
62
+ - `CHANGELOG.md` file permissions corrected to be world-readable (was `600`, now `644`)
63
+
5
64
  ## [0.4.0] - 2026-08-26
6
65
 
7
66
  ### Added
data/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  Turn files in `assets/documents/` into browsable document pages.
11
11
 
12
- **Requirements**: Ruby 3.3+ • Jekyll 4.4+
12
+ **Requirements**: Ruby 3.4+ • Jekyll 4.4+
13
13
 
14
14
  **Features**: Auto-collection • File icons • Categories • Search
15
15
 
@@ -120,6 +120,187 @@ documents:
120
120
 
121
121
  See [configuration.rb](lib/jekyll/documents/configuration.rb) for all options.
122
122
 
123
+ ## Icon sizing
124
+
125
+ Icons default to `1em` (line-height) so they scale with surrounding text.
126
+ A framework-agnostic CSS file with fixed-size utility classes is included:
127
+
128
+ ```html
129
+ <link rel="stylesheet" href="{{ '/assets/css/documents.css' | relative_url }}">
130
+ ```
131
+
132
+ | Class | Size |
133
+ | --- | --- |
134
+ | (default) | `1em` — scales with line height |
135
+ | `icon-x1` | 16px |
136
+ | `icon-x2` | 32px |
137
+ | `icon-x3` | 48px |
138
+ | `icon-x4` | 64px |
139
+ | `icon-x5` | 96px |
140
+ | `icon-x6` | 128px |
141
+ | `icon-x7` | 150px |
142
+ | `icon-x8` | 256px |
143
+ | `icon-x9` | 512px |
144
+
145
+ Usage with the `document_icon` tag:
146
+
147
+ ```liquid
148
+ {% document_icon page class:"document-file-icon icon-x2" %}
149
+ ```
150
+
151
+ Or with any `<img>`:
152
+
153
+ ```html
154
+ <img src="..." class="document-file-icon icon-x3" />
155
+ ```
156
+
157
+ The CSS uses `display: inline-block` and `vertical-align: middle` — no
158
+ dependency on Bulma, Bootstrap, Tailwind, or any other framework.
159
+
160
+ ## Search Integration
161
+
162
+ Documents are compatible with [jekyll-client-search](https://github.com/gundestrup/jekyll-client-search)
163
+ for client-side search. Each document has `categories` (plural array) and
164
+ searchable `content` baked in at generation time, so search plugins can
165
+ index uploaded files alongside posts.
166
+
167
+ To include documents in the search index, add `documents` to the
168
+ `collections` list in `_config.yml`:
169
+
170
+ ```yaml
171
+ client_search:
172
+ collections:
173
+ - posts
174
+ - documents
175
+ ```
176
+
177
+ ### Icons and file-type metadata in search results
178
+
179
+ jekyll-documents bakes `file_type`, `icon_url`, and `icon_set` into each
180
+ document's data at generation time. When jekyll-client-search is installed
181
+ and `documents` is in the search collections, these fields are
182
+ **auto-injected** into the search index — no extra configuration needed:
183
+
184
+ ```yaml
185
+ client_search:
186
+ collections:
187
+ - posts
188
+ - documents
189
+ ```
190
+
191
+ This makes jekyll-client-search render:
192
+
193
+ - `data-file-type`, `data-icon-set` attributes on each result `<article>`
194
+ (for CSS-based badges and theme-aware styling)
195
+ - An `<img class="client-search-result-icon">` before the title, using the
196
+ icon from the configured `icon_set` (color, lines, minimal, or ultra-minimal)
197
+
198
+ The icon automatically matches the `icon_set` configured in your `documents`
199
+ section — no extra configuration needed for theme consistency.
200
+
201
+ **Field renaming** (for integration with other search conventions):
202
+
203
+ ```yaml
204
+ client_search:
205
+ passthrough_fields:
206
+ - file_type: doctype # rename in the search index
207
+ - icon_url: thumbnail
208
+ icon_field: thumbnail
209
+ ```
210
+
211
+ **CSS examples:**
212
+
213
+ ```css
214
+ /* File-type badge */
215
+ .client-search-result[data-file-type="pdf"]::before {
216
+ content: "PDF";
217
+ background: #e74c3c; color: white;
218
+ padding: 0 0.3em; font-size: 0.7em; margin-right: 0.3em;
219
+ }
220
+
221
+ /* Theme-aware icon sizing */
222
+ .client-search-result[data-icon-set="color"] .client-search-result-icon { width: 2em; }
223
+ .client-search-result[data-icon-set="ultra-minimal"] .client-search-result-icon { width: 1em; }
224
+ ```
225
+
226
+ See the [jekyll-client-search README](https://github.com/gundestrup/jekyll-client-search#customizing-search-results-with-css)
227
+ for all CSS customization options.
228
+
229
+ ## Text Extraction
230
+
231
+ Extract text from PDF/DOCX/XLSX/PPTX/ODT/ODS/ODP files so search engines
232
+ can index document contents, not just metadata.
233
+
234
+ ### Setup
235
+
236
+ Add the optional [`plaintext`](https://github.com/planio-gmbh/plaintext) gem
237
+ to your Gemfile:
238
+
239
+ ```ruby
240
+ # Gemfile
241
+ gem "plaintext", group: :jekyll_plugins
242
+ ```
243
+
244
+ Enable extraction in `_config.yml`:
245
+
246
+ ```yaml
247
+ documents:
248
+ extract_text: true
249
+ ```
250
+
251
+ ### How it works
252
+
253
+ - Extracted text is stored in `doc.content`, which `jekyll-client-search`
254
+ indexes automatically — no extra configuration needed
255
+ - Text is cached in `.cache/jekyll-documents/` (in your site source,
256
+ not `.jekyll-cache/`), so it **survives `jekyll clean`**
257
+ - Cache invalidation uses **SHA-256 file digests** — only changed files
258
+ are re-extracted
259
+ - Stale cache entries for deleted files are cleaned up automatically
260
+ at the end of each build
261
+ - Falls back to metadata-only content (title, category, file type, date)
262
+ if the `plaintext` gem is missing or extraction fails
263
+
264
+ ### Configuration
265
+
266
+ ```yaml
267
+ documents:
268
+ extract_text: true # Enable text extraction
269
+ text_max_bytes: 500000 # Truncate extracted text (default 500KB)
270
+ text_cache_dir: ".cache/jekyll-documents" # Cache directory in site source
271
+ ```
272
+
273
+ Add the cache directory to `.gitignore`:
274
+
275
+ ```
276
+ .cache/jekyll-documents/
277
+ ```
278
+
279
+ ### CLI tool dependencies
280
+
281
+ The `plaintext` gem uses the `rubyzip` Ruby gem for Office formats (no CLI
282
+ tools needed). PDF extraction shells out to a system command:
283
+
284
+ | Format | Tool |
285
+ |--------|------|
286
+ | PDF | `pdftotext` (poppler-utils) |
287
+ | DOCX/PPTX/XLSX | rubyzip (Ruby gem, no CLI needed) |
288
+ | ODT/ODS/ODP | rubyzip (Ruby gem, no CLI needed) |
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
+
123
304
  ## Development
124
305
 
125
306
  ```bash
@@ -4,7 +4,7 @@
4
4
  <ul class="documents-category-list">
5
5
  {% for cat in cats %}
6
6
  <li>
7
- <img src="{{ '/assets/icons/' | append: icon_set | append: '/folder-svgrepo-com.svg' | relative_url }}" alt="Folder" class="folder-icon" />
7
+ <img src="{{ '/assets/icons/' | append: icon_set | append: '/folder-svgrepo-com.svg' | relative_url }}" alt="Folder" class="folder-icon" style="width:1em;height:1em;vertical-align:middle;" />
8
8
  <span class="category-name">{{ cat }}</span>
9
9
  <span class="category-count">({{ site.documents | where: "category", cat | size }})</span>
10
10
  </li>
@@ -0,0 +1,34 @@
1
+ /*
2
+ * jekyll-documents — icon sizing utilities
3
+ *
4
+ * Framework-agnostic: no dependency on Bulma, Bootstrap, Tailwind, etc.
5
+ * Icons default to 1em (line-height) so they scale with surrounding text.
6
+ * Override with .icon-x{N} classes for fixed pixel sizes.
7
+ *
8
+ * Usage:
9
+ * {% document_icon page class:"document-file-icon icon-x2" %}
10
+ * <img src="..." class="document-file-icon icon-x3" />
11
+ *
12
+ * Include this stylesheet in your site:
13
+ * <link rel="stylesheet" href="{{ '/assets/css/documents.css' | relative_url }}">
14
+ */
15
+
16
+ /* Default: icon scales with line height */
17
+ .document-file-icon,
18
+ .folder-icon {
19
+ width: 1em;
20
+ height: 1em;
21
+ vertical-align: middle;
22
+ display: inline-block;
23
+ }
24
+
25
+ /* Fixed pixel sizes */
26
+ .icon-x1 { width: 16px; height: 16px; }
27
+ .icon-x2 { width: 32px; height: 32px; }
28
+ .icon-x3 { width: 48px; height: 48px; }
29
+ .icon-x4 { width: 64px; height: 64px; }
30
+ .icon-x5 { width: 96px; height: 96px; }
31
+ .icon-x6 { width: 128px; height: 128px; }
32
+ .icon-x7 { width: 150px; height: 150px; }
33
+ .icon-x8 { width: 256px; height: 256px; }
34
+ .icon-x9 { width: 512px; height: 512px; }
@@ -32,7 +32,7 @@
32
32
  if (!iconUrl) return "";
33
33
  const altText = `${String(fileType || "file").toUpperCase()} file`;
34
34
  const url = escapeHtml(withBaseurl(iconUrl));
35
- return `<img src="${url}" alt="${escapeHtml(altText)}" class="file-icon" />`;
35
+ return `<img src="${url}" alt="${escapeHtml(altText)}" class="document-file-icon" style="width:1em;height:1em;vertical-align:middle;" />`;
36
36
  }
37
37
 
38
38
  function render(matches) {
@@ -20,12 +20,11 @@ Gem::Specification.new do |spec|
20
20
  "source_code_uri" => "https://github.com/gundestrup/jekyll-documents",
21
21
  "bug_tracker_uri" => "https://github.com/gundestrup/jekyll-documents/issues",
22
22
  "changelog_uri" => "https://github.com/gundestrup/jekyll-documents/blob/main/CHANGELOG.md",
23
- "documentation_uri" => "https://github.com/gundestrup/jekyll-documents",
24
- "homepage_uri" => "https://github.com/gundestrup/jekyll-documents",
23
+ "documentation_uri" => "https://deepwiki.com/gundestrup/jekyll-documents",
25
24
  "rubygems_mfa_required" => "true"
26
25
  }
27
26
 
28
- spec.required_ruby_version = ">= 3.3"
27
+ spec.required_ruby_version = ">= 3.4"
29
28
 
30
29
  spec.files = Dir.glob("{lib,assets,_includes,_layouts}/**/*") +
31
30
  ["README.md", "CHANGELOG.md", "LICENSE", "jekyll-documents.gemspec"]
@@ -34,10 +33,11 @@ Gem::Specification.new do |spec|
34
33
  spec.add_dependency "jekyll", ">= 4.4", "< 5.0"
35
34
 
36
35
  spec.add_development_dependency "bundler-audit", "~> 0.9"
37
- spec.add_development_dependency "rake", "~> 13.0"
36
+ spec.add_development_dependency "plaintext", "~> 0.3"
37
+ spec.add_development_dependency "rake", "~> 13.4"
38
38
  spec.add_development_dependency "rspec", "~> 3.13"
39
- spec.add_development_dependency "rubocop", "~> 1.88"
40
- spec.add_development_dependency "rubocop-performance", "~> 1.26"
41
- spec.add_development_dependency "simplecov", "~> 1.0"
39
+ spec.add_development_dependency "rubocop", "~> 1.90"
40
+ spec.add_development_dependency "rubocop-performance", "~> 1.27"
41
+ spec.add_development_dependency "simplecov", "~> 1.1"
42
42
  spec.add_development_dependency "yard", "~> 0.9"
43
43
  end
@@ -24,6 +24,11 @@ module Jekyll
24
24
  "json_index" => true,
25
25
  "json_index_path" => "/documents.json",
26
26
 
27
+ # Text extraction for search indexing (requires optional 'plaintext' gem)
28
+ "extract_text" => false,
29
+ "text_max_bytes" => 500_000,
30
+ "text_cache_dir" => ".cache/jekyll-documents",
31
+
27
32
  # Optional category mapping
28
33
  "category_map" => {}
29
34
  }.freeze
@@ -154,11 +154,13 @@ module Jekyll
154
154
  # @return [String] HTML img tag
155
155
  # @example
156
156
  # file_type_icon_tag('pdf') #=> \
157
- # '<img src="/assets/icons/color/pdf.svg" alt="PDF file" class="file-icon" />'
158
- def file_type_icon_tag(file_type, css_class: "file-icon", alt: nil, context: nil)
157
+ # '<img src="..." alt="PDF file" class="document-file-icon" ... />'
158
+ def file_type_icon_tag(file_type, css_class: "document-file-icon", alt: nil, context: nil)
159
159
  url = file_type_icon(file_type, context)
160
160
  alt_text = alt || "#{file_type.to_s.upcase} file"
161
- %(<img src="#{url}" alt="#{alt_text}" class="#{css_class}" />)
161
+ style = "width:1em;height:1em;vertical-align:middle;"
162
+ "<img src=\"#{url}\" alt=\"#{alt_text}\" " \
163
+ "class=\"#{css_class}\" style=\"#{style}\" />"
162
164
  end
163
165
 
164
166
  private
@@ -10,12 +10,25 @@ module Jekyll
10
10
  safe true
11
11
  priority :normal
12
12
 
13
+ CONTENT_TYPES = {
14
+ ".pdf" => "application/pdf",
15
+ ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
16
+ ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
17
+ ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
18
+ ".odt" => "application/vnd.oasis.opendocument.text",
19
+ ".ods" => "application/vnd.oasis.opendocument.spreadsheet",
20
+ ".odp" => "application/vnd.oasis.opendocument.presentation"
21
+ }.freeze
22
+
23
+ private_constant :CONTENT_TYPES
24
+
13
25
  # Generates document collection from files in configured root directory
14
26
  # @param site [Jekyll::Site] the Jekyll site instance
15
27
  # @return [void]
16
28
  def generate(site)
17
29
  @site = site
18
30
  @config = Configuration.read(site)
31
+ @text_manifest = nil
19
32
 
20
33
  root = File.join(site.source, @config["root"])
21
34
  unless Dir.exist?(root)
@@ -25,6 +38,8 @@ module Jekyll
25
38
 
26
39
  collection = ensure_collection(site, "documents")
27
40
 
41
+ current_paths = []
42
+
28
43
  Dir.glob("#{root}/**/*").each do |path|
29
44
  next unless File.file?(path)
30
45
 
@@ -36,6 +51,7 @@ module Jekyll
36
51
  next unless @config["include_extensions"].include?(ext)
37
52
 
38
53
  rel_path = path.delete_prefix("#{site.source}/")
54
+ current_paths << rel_path
39
55
  category = infer_category_from(rel_path)
40
56
  basename = File.basename(path, ext)
41
57
 
@@ -61,10 +77,31 @@ module Jekyll
61
77
  bake_document_data(doc, file_info)
62
78
  collection.docs << doc
63
79
  end
80
+
81
+ cleanup_manifest(current_paths) if @config["extract_text"]
82
+ configure_client_search(site)
64
83
  end
65
84
 
66
85
  private
67
86
 
87
+ # Auto-injects passthrough_fields into client_search config when the
88
+ # documents collection is indexed. Only acts if client_search is already
89
+ # configured with +documents+ in its collections list — does nothing if
90
+ # jekyll-client-search is not installed or not used.
91
+ def configure_client_search(site)
92
+ search_config = site.config["client_search"]
93
+ return unless search_config.is_a?(Hash)
94
+ return unless Array(search_config["collections"]).include?("documents")
95
+
96
+ fields = search_config["passthrough_fields"] || []
97
+ existing = fields.flat_map { |f| f.is_a?(Hash) ? f.keys : [f.to_s] }
98
+ %w[file_type icon_url icon_set].each do |field|
99
+ fields << field unless existing.include?(field)
100
+ end
101
+ search_config["passthrough_fields"] = fields
102
+ search_config["icon_field"] = "icon_url" unless search_config.key?("icon_field")
103
+ end
104
+
68
105
  # Ensures a collection exists and is configured for output
69
106
  # @param site [Jekyll::Site] the Jekyll site instance
70
107
  # @param label [String] the collection name
@@ -83,6 +120,89 @@ module Jekyll
83
120
  "#{title} #{data['category']} #{file_type} #{date_str}"
84
121
  end
85
122
 
123
+ def extract_file_content(info)
124
+ return nil unless load_plaintext
125
+
126
+ content_type = CONTENT_TYPES[info[:ext]]
127
+ return nil unless content_type
128
+
129
+ manifest = text_manifest
130
+ rel_path = info[:rel_path]
131
+ digest = ::Digest::SHA256.file(info[:path]).hexdigest
132
+ metadata = extraction_cache_metadata
133
+
134
+ cached = manifest.get(rel_path, digest, metadata)
135
+ return cached if cached
136
+
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
142
+ end
143
+ text = truncate_bytes(text, @config["text_max_bytes"]) if text
144
+ manifest.set(rel_path, digest, text, metadata) if text
145
+ text
146
+ rescue StandardError => e
147
+ ::Jekyll.logger.warn "jekyll-documents",
148
+ "Text extraction failed for #{info[:path]}: #{e.message}"
149
+ nil
150
+ end
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
+
165
+ def load_plaintext
166
+ return true if defined?(::Plaintext)
167
+
168
+ require "plaintext"
169
+ true
170
+ rescue LoadError
171
+ ::Jekyll.logger.warn "jekyll-documents",
172
+ "extract_text is enabled but the 'plaintext' gem is not installed. " \
173
+ "Run: gem install plaintext"
174
+ false
175
+ end
176
+
177
+ def text_manifest
178
+ @text_manifest ||= TextExtractionManifest.new(@site, @config["text_cache_dir"])
179
+ end
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
+
201
+ def cleanup_manifest(current_rel_paths)
202
+ text_manifest.cleanup_deleted(current_rel_paths)
203
+ text_manifest.save
204
+ end
205
+
86
206
  def bake_document_data(doc, info)
87
207
  data = doc.data
88
208
  category = remap_category(info[:category])
@@ -102,7 +222,17 @@ module Jekyll
102
222
  data["permalink"] = @config["permalink"]
103
223
  .gsub(":category", category.to_s)
104
224
  .gsub(":slug", info[:slug])
105
- doc.content = searchable_content(info[:title], data, info[:file_type])
225
+ metadata_content = searchable_content(info[:title], data, info[:file_type])
226
+ doc.content = if @config["extract_text"]
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
233
+ else
234
+ metadata_content
235
+ end
106
236
  end
107
237
 
108
238
  # Creates a virtual source path for the document
@@ -57,4 +57,13 @@ end
57
57
 
58
58
  Jekyll::Hooks.register :site, :after_init do |site|
59
59
  Jekyll::Documents::LayoutRegistrar.register(site)
60
+
61
+ # Exclude the text extraction cache directory from Jekyll output
62
+ config = Jekyll::Documents::Configuration.read(site)
63
+ if config["text_cache_dir"]
64
+ site.config["exclude"] = Array(site.config["exclude"])
65
+ unless site.config["exclude"].include?(config["text_cache_dir"])
66
+ site.config["exclude"] << config["text_cache_dir"]
67
+ end
68
+ end
60
69
  end
@@ -63,7 +63,9 @@ module Jekyll
63
63
 
64
64
  url = relative_url(icon_url)
65
65
  file_type = doc.data["file_type"].to_s.upcase
66
- "<img src=\"#{escape_html(url)}\" alt=\"#{file_type}\" class=\"file-icon doc-link-icon\" />"
66
+ "<img src=\"#{escape_html(url)}\" alt=\"#{file_type}\" " \
67
+ "class=\"document-file-icon doc-link-icon\" " \
68
+ "style=\"width:1em;height:1em;vertical-align:middle;\" />"
67
69
  end
68
70
 
69
71
  def size_html(doc)
@@ -21,10 +21,11 @@ module Jekyll
21
21
  url = relative_url(icon_url)
22
22
  file_type = document_value(document, "file_type").to_s
23
23
  alt = @options["alt"] || "#{file_type.upcase} file"
24
- css_class = @options["class"] || "file-icon"
24
+ css_class = @options["class"] || "document-file-icon"
25
25
 
26
- %(<img src="#{escape_html(url)}" alt="#{escape_html(alt)}" ) \
27
- + %(class="#{escape_html(css_class)}" />)
26
+ style = "width:1em;height:1em;vertical-align:middle;"
27
+ "<img src=\"#{escape_html(url)}\" alt=\"#{escape_html(alt)}\" " \
28
+ "class=\"#{escape_html(css_class)}\" style=\"#{style}\" />"
28
29
  end
29
30
 
30
31
  private
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "digest"
6
+
7
+ module Jekyll
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
+
64
+ # Manages the text extraction manifest — a persistent JSON file that tracks
65
+ # which documents have had their text extracted, keyed by SHA-256 digest.
66
+ #
67
+ # The manifest lives in <site.source>/<cache_dir>/text-extraction-manifest.json
68
+ # and extracted text is stored in separate files under <cache_dir>/text/
69
+ # (sharded by the first 2 hex chars of the digest to avoid huge directories).
70
+ #
71
+ # This design borrows from jekyll-imgflow's ManifestManager:
72
+ # - Atomic writes (temp file + rename)
73
+ # - SHA-256 content digests for cache invalidation
74
+ # - Cleanup of entries for deleted source files
75
+ # - Survives `jekyll clean` (stored in site source, not .jekyll-cache/)
76
+ class TextExtractionManifest
77
+ include TextExtractionManifestSupport
78
+
79
+ MANIFEST_FILENAME = "text-extraction-manifest.json"
80
+ TEXT_SUBDIR = "text"
81
+
82
+ attr_reader :manifest_path, :cache_dir
83
+
84
+ # @param site [Jekyll::Site] the Jekyll site instance
85
+ # @param cache_dir [String] relative path from site source for cache directory
86
+ def initialize(site, cache_dir)
87
+ @site = site
88
+ @cache_dir = cache_dir
89
+ @cache_root = File.join(site.source, cache_dir)
90
+ @manifest_path = File.join(@cache_root, MANIFEST_FILENAME)
91
+ @text_dir = File.join(@cache_root, TEXT_SUBDIR)
92
+ @manifest = load_manifest
93
+ @dirty = false
94
+ end
95
+
96
+ # Get cached text for a document if the digest matches.
97
+ # @param rel_path [String] relative path of the source document
98
+ # @param digest [String] SHA-256 hex digest of the source file
99
+ # @return [String, nil] extracted text if cache hit, nil otherwise
100
+ def get(rel_path, digest, metadata = {})
101
+ entry = @manifest[rel_path]
102
+ return nil unless valid_entry?(entry)
103
+ return nil unless entry["cache_key"] == cache_key(digest, metadata)
104
+
105
+ text_file = text_file_path(entry["text_file"])
106
+ return nil unless text_file && File.file?(text_file)
107
+
108
+ File.read(text_file, encoding: "UTF-8")
109
+ rescue StandardError => e
110
+ ::Jekyll.logger.warn "jekyll-documents",
111
+ "Manifest read failed for #{rel_path}: #{e.message}"
112
+ nil
113
+ end
114
+
115
+ # Store extracted text for a document.
116
+ # @param rel_path [String] relative path of the source document
117
+ # @param digest [String] SHA-256 hex digest of the source file
118
+ # @param text [String] the extracted text
119
+ # @return [void]
120
+ def set(rel_path, digest, text, metadata = {})
121
+ text_file = write_text_file(digest, text)
122
+ @manifest[rel_path] = {
123
+ "digest" => digest,
124
+ "cache_key" => cache_key(digest, metadata),
125
+ "text_file" => text_file,
126
+ "extracted_at" => Time.now.to_i
127
+ }
128
+ cleanup_unreferenced_text_files
129
+ @dirty = true
130
+ end
131
+
132
+ # Remove manifest entries and their text files for source documents
133
+ # that no longer exist.
134
+ # @param current_rel_paths [Array<String>] relative paths of current source files
135
+ # @return [Integer] number of entries removed
136
+ def cleanup_deleted(current_rel_paths)
137
+ current_set = current_rel_paths.to_set
138
+ removed = 0
139
+
140
+ @manifest.each_key do |rel_path|
141
+ next if current_set.include?(rel_path)
142
+
143
+ @manifest.delete(rel_path)
144
+ removed += 1
145
+ @dirty = true
146
+ end
147
+ cleanup_unreferenced_text_files
148
+
149
+ removed
150
+ end
151
+
152
+ # Save the manifest to disk if it has changed.
153
+ # Uses atomic write (temp file + rename) to prevent corruption.
154
+ # @return [void]
155
+ def save
156
+ return unless @dirty
157
+
158
+ content = JSON.pretty_generate(@manifest)
159
+ if File.exist?(@manifest_path) && File.binread(@manifest_path) == content
160
+ @dirty = false
161
+ return
162
+ end
163
+
164
+ FileUtils.mkdir_p(@cache_root)
165
+ temporary_path = "#{@manifest_path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
166
+ File.open(temporary_path, "wb") do |file|
167
+ file.write(content)
168
+ file.flush
169
+ file.fsync
170
+ end
171
+ File.rename(temporary_path, @manifest_path)
172
+ @dirty = false
173
+ ensure
174
+ FileUtils.rm_f(temporary_path) if defined?(temporary_path) && temporary_path
175
+ end
176
+
177
+ # Check if a document is in the manifest with a matching digest.
178
+ # @param rel_path [String] relative path of the source document
179
+ # @param digest [String] SHA-256 hex digest of the source file
180
+ # @return [Boolean]
181
+ def cached?(rel_path, digest, metadata = {})
182
+ entry = @manifest[rel_path]
183
+ !!(valid_entry?(entry) && entry["cache_key"] == cache_key(digest, metadata))
184
+ end
185
+
186
+ # Number of entries in the manifest.
187
+ # @return [Integer]
188
+ def size
189
+ @manifest.size
190
+ end
191
+ end
192
+ end
193
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Jekyll
4
4
  module Documents
5
- VERSION = "0.4.0"
5
+ VERSION = "0.6.1"
6
6
  end
7
7
  end
@@ -7,6 +7,7 @@ require_relative "jekyll/documents/utils"
7
7
  require_relative "jekyll/documents/filters"
8
8
  require_relative "jekyll/documents/file_type_icons"
9
9
  require_relative "jekyll/documents/generator"
10
+ require_relative "jekyll/documents/text_extraction_manifest"
10
11
  require_relative "jekyll/documents/assets_generator"
11
12
  require_relative "jekyll/documents/json_index_generator"
12
13
  require_relative "jekyll/documents/layout_registrar"
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.4.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svend Gundestrup
@@ -43,20 +43,34 @@ dependencies:
43
43
  - - "~>"
44
44
  - !ruby/object:Gem::Version
45
45
  version: '0.9'
46
+ - !ruby/object:Gem::Dependency
47
+ name: plaintext
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '0.3'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '0.3'
46
60
  - !ruby/object:Gem::Dependency
47
61
  name: rake
48
62
  requirement: !ruby/object:Gem::Requirement
49
63
  requirements:
50
64
  - - "~>"
51
65
  - !ruby/object:Gem::Version
52
- version: '13.0'
66
+ version: '13.4'
53
67
  type: :development
54
68
  prerelease: false
55
69
  version_requirements: !ruby/object:Gem::Requirement
56
70
  requirements:
57
71
  - - "~>"
58
72
  - !ruby/object:Gem::Version
59
- version: '13.0'
73
+ version: '13.4'
60
74
  - !ruby/object:Gem::Dependency
61
75
  name: rspec
62
76
  requirement: !ruby/object:Gem::Requirement
@@ -77,42 +91,42 @@ dependencies:
77
91
  requirements:
78
92
  - - "~>"
79
93
  - !ruby/object:Gem::Version
80
- version: '1.88'
94
+ version: '1.90'
81
95
  type: :development
82
96
  prerelease: false
83
97
  version_requirements: !ruby/object:Gem::Requirement
84
98
  requirements:
85
99
  - - "~>"
86
100
  - !ruby/object:Gem::Version
87
- version: '1.88'
101
+ version: '1.90'
88
102
  - !ruby/object:Gem::Dependency
89
103
  name: rubocop-performance
90
104
  requirement: !ruby/object:Gem::Requirement
91
105
  requirements:
92
106
  - - "~>"
93
107
  - !ruby/object:Gem::Version
94
- version: '1.26'
108
+ version: '1.27'
95
109
  type: :development
96
110
  prerelease: false
97
111
  version_requirements: !ruby/object:Gem::Requirement
98
112
  requirements:
99
113
  - - "~>"
100
114
  - !ruby/object:Gem::Version
101
- version: '1.26'
115
+ version: '1.27'
102
116
  - !ruby/object:Gem::Dependency
103
117
  name: simplecov
104
118
  requirement: !ruby/object:Gem::Requirement
105
119
  requirements:
106
120
  - - "~>"
107
121
  - !ruby/object:Gem::Version
108
- version: '1.0'
122
+ version: '1.1'
109
123
  type: :development
110
124
  prerelease: false
111
125
  version_requirements: !ruby/object:Gem::Requirement
112
126
  requirements:
113
127
  - - "~>"
114
128
  - !ruby/object:Gem::Version
115
- version: '1.0'
129
+ version: '1.1'
116
130
  - !ruby/object:Gem::Dependency
117
131
  name: yard
118
132
  requirement: !ruby/object:Gem::Requirement
@@ -144,6 +158,7 @@ files:
144
158
  - _includes/documents_search.html
145
159
  - _includes/latest_documents.html
146
160
  - _layouts/document.html
161
+ - assets/css/documents.css
147
162
  - assets/icons/color/ai-document-svgrepo-com.svg
148
163
  - assets/icons/color/attachment-document-svgrepo-com.svg
149
164
  - assets/icons/color/audio-document-svgrepo-com.svg
@@ -255,6 +270,7 @@ files:
255
270
  - lib/jekyll/documents/tags/doc_link.rb
256
271
  - lib/jekyll/documents/tags/document_icon.rb
257
272
  - lib/jekyll/documents/tags/latest_documents.rb
273
+ - lib/jekyll/documents/text_extraction_manifest.rb
258
274
  - lib/jekyll/documents/utils.rb
259
275
  - lib/jekyll/documents/version.rb
260
276
  homepage: https://github.com/gundestrup/jekyll-documents
@@ -264,8 +280,7 @@ metadata:
264
280
  source_code_uri: https://github.com/gundestrup/jekyll-documents
265
281
  bug_tracker_uri: https://github.com/gundestrup/jekyll-documents/issues
266
282
  changelog_uri: https://github.com/gundestrup/jekyll-documents/blob/main/CHANGELOG.md
267
- documentation_uri: https://github.com/gundestrup/jekyll-documents
268
- homepage_uri: https://github.com/gundestrup/jekyll-documents
283
+ documentation_uri: https://deepwiki.com/gundestrup/jekyll-documents
269
284
  rubygems_mfa_required: 'true'
270
285
  rdoc_options: []
271
286
  require_paths:
@@ -274,7 +289,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
274
289
  requirements:
275
290
  - - ">="
276
291
  - !ruby/object:Gem::Version
277
- version: '3.3'
292
+ version: '3.4'
278
293
  required_rubygems_version: !ruby/object:Gem::Requirement
279
294
  requirements:
280
295
  - - ">="