jekyll-documents 0.6.0 → 0.7.0

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: 34e9456980c65a0eab294a17100d6de0bcf522bc3652afcadc3c2521c8c9d48b
4
+ data.tar.gz: be4ff590ed8c3e251fb7c56a1e57a33e2650d60a63c44113bb2a4c48402fe906
5
5
  SHA512:
6
- metadata.gz: ba352213f38733179f053f989cfe5f363dee453176f4d301629fa0d5ab34b5d4f3aad443e8f46bc9b4ee9106626d09cbc255544862b12ba05850259a5b8879e9
7
- data.tar.gz: 911cb909b005316a55d1978127935cf1bbf6da093c5dd0a9b6ec9e66112a9aa88d094e82c318f8c7312450ec970592caf506a62b392b47e3f0f35380be5be9d5
6
+ metadata.gz: 4c456c2e5ff0b55a6d51bc777770bed926b6cfa4c8a1393fda110f5d0a8d3e0e49af5c5399e1a0bdf2f4c8f5632e19ec8490975722842a5657b9f6e737c4412a
7
+ data.tar.gz: 5ff47c4204e58ad1e49bd48cd146d4f51ccf44254f1244530b0fd39f8fa2b5817531cfa293409a3f6fbad7063bc3425599e2c1bf37d094b5dbb45bba5cc13dc8
data/CHANGELOG.md CHANGED
@@ -1,6 +1,38 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.7.0] - 2026-09-05
4
+
5
+ ### Added
6
+ - Added stable `source_path`, `category_path`, and `category_slug` document metadata and JSON index fields
7
+ - Added exact `path:` resolution for document and category tags plus explicit category aggregation
8
+ - Added path/date permalink placeholders and full-path-first category mappings
9
+ - Added configurable `warn` or `strict` handling for unresolved and ambiguous tag references
10
+
11
+ ### Fixed
12
+ - Ambiguous tags no longer silently select the first match
13
+ - Duplicate generated permalinks now abort the build and identify every conflicting source file
14
+
15
+ ## [0.6.1] - 2026-09-03
16
+
17
+ ### Changed
18
+ - 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
19
+ - Extracted document content now retains searchable metadata for title, category, file type, and date
20
+ - Text cache cleanup now preserves shared digest files and removes orphaned files safely
21
+ - Text extraction now enforces `text_max_bytes` as a UTF-8 byte limit and closes source files after reading
22
+ - PDF extraction now discovers `pdftotext` from `PATH`; added macOS and Debian/Ubuntu setup instructions and CI coverage with `poppler-utils`
23
+ - Configured text cache directories are excluded from Jekyll output regardless of the `extract_text` setting
24
+ - Rake packaging and test tasks no longer perform duplicate work and install only the current package artifact
25
+ - Removed redundant package metadata and duplicate development dependency declarations
26
+
27
+ ### Fixed
28
+ - Replaced use of the private ActiveSupport deprecation API with the public `ActiveSupport.deprecator` API
29
+ - Empty extraction results now fall back to metadata-only searchable content
30
+ - Manifest loading now validates entries, logs filesystem read failures, and resets dirty state after successful saves
31
+ - Updated stale CI, development, cache-cleanup, and package-lock documentation
32
+
33
+ ### Tests
34
+ - Added real PDF extraction coverage and stronger cache-hit/persistence assertions
35
+ - Added tests for cache configuration invalidation, UTF-8 byte truncation, shared cache references, orphan cleanup, and conditional cache exclusion
4
36
 
5
37
  ## [0.6.0] - 2026-08-27
6
38
 
data/README.md CHANGED
@@ -90,24 +90,27 @@ The tag applies the configured icon set and site `baseurl` automatically. The `f
90
90
  {% doc_link "Annual Report" %}
91
91
  {% doc_link "annual" text:"Read the report" %}
92
92
  {% doc_link "board-meeting" icon:false size:false %}
93
+ {% doc_link path:"Reports/2026-03-01_Annual_Report.pdf" %}
93
94
  ```
94
95
 
95
96
  Renders an `<a>` tag with the file type icon, title, and human-readable file size.
96
97
  Use `text:"..."` to override the link text, `icon:false` to hide the icon,
97
- or `size:false` to hide the file size.
98
+ or `size:false` to hide the file size. A `path:` lookup is exact and case-sensitive after
99
+ normalizing `/` and `\` separators; use it when a title or slug matches multiple documents.
98
100
 
99
101
  **Link to or list a category**:
100
102
 
101
103
  ```liquid
102
104
  {% doc_category "reports" %}
103
105
  {% doc_category "reports" text:"All reports" %}
104
- {% doc_category "reports" list:true %}
105
106
  {% doc_category "reports" list:true limit:5 %}
107
+ {% doc_category path:"Departments/Europe/Reports" list:true %}
108
+ {% doc_category "reports" aggregate:true list:true %}
106
109
  ```
107
110
 
108
- Link mode renders an `<a>` tag to the category page.
109
- List mode renders a `<ul>` of all documents in the category, sorted by date descending.
110
- Use `limit:N` to cap the number of items.
111
+ Link mode renders an `<a>` tag to the category page. List mode renders a `<ul>` sorted by date.
112
+ Use `path:` for an exact, case-sensitive category path, `limit:N` to cap the list, and
113
+ `aggregate:true list:true` to explicitly combine repeated short category names.
111
114
 
112
115
  ## Configuration
113
116
 
@@ -116,8 +119,29 @@ documents:
116
119
  root: "assets/documents"
117
120
  icon_set: "color"
118
121
  strict_filename: true
122
+ resolution_mode: "warn"
119
123
  ```
120
124
 
125
+ `resolution_mode` controls ambiguous or unknown path references. The default `warn` mode logs
126
+ all matching candidates and renders nothing. Set it to `strict` to log the same diagnostic and
127
+ abort the build.
128
+
129
+ Generated documents expose `source_path`, `category_path`, and `category_slug`. Category mappings
130
+ check the complete `category_path` before falling back to its final directory component.
131
+ Permalinks support `:category`, `:category_path`, `:slug`, `:source_path`, `:date`, `:year`,
132
+ `:month`, and `:day`. For example:
133
+
134
+ ```yaml
135
+ documents:
136
+ permalink: "/documents/:category_path/:date/:slug/"
137
+ category_map:
138
+ Departments/Europe/Research: "European Research"
139
+ Research: "Research"
140
+ ```
141
+
142
+ The default permalink remains backward compatible. Any duplicate final permalink is fatal and
143
+ the build error lists every conflicting `source_path`.
144
+
121
145
  See [configuration.rb](lib/jekyll/documents/configuration.rb) for all options.
122
146
 
123
147
  ## Icon sizing
@@ -257,7 +281,7 @@ documents:
257
281
  - Cache invalidation uses **SHA-256 file digests** — only changed files
258
282
  are re-extracted
259
283
  - Stale cache entries for deleted files are cleaned up automatically
260
- at the start of each build
284
+ at the end of each build
261
285
  - Falls back to metadata-only content (title, category, file type, date)
262
286
  if the `plaintext` gem is missing or extraction fails
263
287
 
@@ -287,6 +311,20 @@ tools needed). PDF extraction shells out to a system command:
287
311
  | DOCX/PPTX/XLSX | rubyzip (Ruby gem, no CLI needed) |
288
312
  | ODT/ODS/ODP | rubyzip (Ruby gem, no CLI needed) |
289
313
 
314
+ Install `pdftotext` for PDF extraction:
315
+
316
+ ```bash
317
+ # macOS
318
+ brew install poppler
319
+
320
+ # Debian/Ubuntu
321
+ sudo apt-get install poppler-utils
322
+ ```
323
+
324
+ On macOS and other non-standard installations, the executable is discovered
325
+ from `PATH`. You can also configure a custom `pdftotext` command through the
326
+ `plaintext` gem configuration.
327
+
290
328
  ## Development
291
329
 
292
330
  ```bash
@@ -2,7 +2,19 @@
2
2
 
3
3
  module Jekyll
4
4
  module Documents
5
+ module ResolutionReporter
6
+ private
7
+
8
+ def report_resolution_issue(site, message)
9
+ method = Configuration.read(site)["resolution_mode"].to_s == "strict" ? :abort_with : :warn
10
+ ::Jekyll.logger.public_send(method, "jekyll-documents", message)
11
+ end
12
+ end
13
+
5
14
  class Configuration
15
+ RESOLUTION_MODES = %w[warn strict].freeze
16
+ private_constant :RESOLUTION_MODES
17
+
6
18
  DEFAULTS = {
7
19
  "root" => "assets/documents",
8
20
  "permalink" => "/documents/:category/:slug/",
@@ -19,6 +31,7 @@ module Jekyll
19
31
  # Validation
20
32
  "strict_filename" => true,
21
33
  "strict_extensions" => true,
34
+ "resolution_mode" => "warn",
22
35
 
23
36
  # JSON Index for Lunr
24
37
  "json_index" => true,
@@ -34,8 +47,13 @@ module Jekyll
34
47
  }.freeze
35
48
 
36
49
  def self.read(site)
37
- cfg = site.config["documents"] || {}
38
- DEFAULTS.merge(cfg)
50
+ cfg = DEFAULTS.merge(site.config["documents"] || {})
51
+ mode = cfg["resolution_mode"].to_s
52
+ unless RESOLUTION_MODES.include?(mode)
53
+ ::Jekyll.logger.abort_with "jekyll-documents",
54
+ "resolution_mode must be warn or strict, got #{mode.inspect}"
55
+ end
56
+ cfg
39
57
  end
40
58
  end
41
59
  end
@@ -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)
@@ -36,23 +37,26 @@ module Jekyll
36
37
  end
37
38
 
38
39
  collection = ensure_collection(site, "documents")
39
-
40
40
  current_paths = []
41
+ generated_docs = []
41
42
 
42
43
  Dir.glob("#{root}/**/*").each do |path|
43
44
  next unless File.file?(path)
44
45
 
45
- ext = File.extname(path).downcase
46
+ source_extension = File.extname(path)
47
+ ext = source_extension.downcase
46
48
  if @config["strict_extensions"] && !@config["include_extensions"].include?(ext)
47
49
  ::Jekyll.logger.abort_with "jekyll-documents",
48
50
  "Unsupported file type: #{path} (#{ext})"
49
51
  end
50
52
  next unless @config["include_extensions"].include?(ext)
51
53
 
52
- rel_path = path.delete_prefix("#{site.source}/")
54
+ source_path = source_path_for(path, root)
55
+ category_path = document_category_path(source_path)
56
+ category = remap_category(category_path)
57
+ rel_path = normalize_path(File.join(@config["root"], source_path))
53
58
  current_paths << rel_path
54
- category = infer_category_from(rel_path)
55
- basename = File.basename(path, ext)
59
+ basename = File.basename(path, source_extension)
56
60
 
57
61
  date, title, valid = parse_filename(basename)
58
62
  if !valid && @config["strict_filename"]
@@ -60,23 +64,23 @@ module Jekyll
60
64
  "Filename must be 'YYYY-MM-DD_Title.ext' → #{rel_path}"
61
65
  end
62
66
 
63
- slug = build_slug(basename)
64
- file_type = ext.sub(".", "").downcase
65
- icon_set = @config["icon_set"]
66
-
67
67
  doc = ::Jekyll::Document.new(
68
- source_stub_for(basename, category),
68
+ source_stub_for(source_path),
69
69
  site: site,
70
70
  collection: collection
71
71
  )
72
-
73
- file_info = { title: title, date: date, category: category,
74
- rel_path: rel_path, ext: ext, file_type: file_type,
75
- icon_set: icon_set, slug: slug, path: path }
72
+ file_info = {
73
+ title: title, date: date, category: category, category_path: category_path,
74
+ category_slug: slugify(category, "uncategorized"), source_path: source_path,
75
+ rel_path: rel_path, ext: ext, file_type: ext.delete_prefix("."),
76
+ icon_set: @config["icon_set"], slug: build_slug(basename), path: path
77
+ }
76
78
  bake_document_data(doc, file_info)
77
79
  collection.docs << doc
80
+ generated_docs << doc
78
81
  end
79
82
 
83
+ detect_permalink_collisions(generated_docs)
80
84
  cleanup_manifest(current_paths) if @config["extract_text"]
81
85
  configure_client_search(site)
82
86
  end
@@ -128,18 +132,19 @@ module Jekyll
128
132
  manifest = text_manifest
129
133
  rel_path = info[:rel_path]
130
134
  digest = ::Digest::SHA256.file(info[:path]).hexdigest
135
+ metadata = extraction_cache_metadata
131
136
 
132
- cached = manifest.get(rel_path, digest)
137
+ cached = manifest.get(rel_path, digest, metadata)
133
138
  return cached if cached
134
139
 
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
140
+ configure_pdf_extractor(content_type)
141
+ text = ActiveSupport.deprecator.silence do
142
+ File.open(info[:path], "rb") do |file|
143
+ ::Plaintext::Resolver.new(file, content_type).text
144
+ end
140
145
  end
141
- text = text&.truncate(@config["text_max_bytes"]) if text
142
- manifest.set(rel_path, digest, text) if text
146
+ text = truncate_bytes(text, @config["text_max_bytes"]) if text
147
+ manifest.set(rel_path, digest, text, metadata) if text
143
148
  text
144
149
  rescue StandardError => e
145
150
  ::Jekyll.logger.warn "jekyll-documents",
@@ -147,6 +152,19 @@ module Jekyll
147
152
  nil
148
153
  end
149
154
 
155
+ def configure_pdf_extractor(content_type)
156
+ return unless content_type == "application/pdf"
157
+ return unless ::Plaintext::Configuration["pdftotext"].nil?
158
+
159
+ executable = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
160
+ .map { |directory| File.join(directory, "pdftotext") }
161
+ .find { |path| File.executable?(path) }
162
+ return unless executable
163
+
164
+ ::Plaintext::Configuration.config["pdftotext"] =
165
+ [executable, "-enc", "UTF-8", "__FILE__", "-"]
166
+ end
167
+
150
168
  def load_plaintext
151
169
  return true if defined?(::Plaintext)
152
170
 
@@ -163,6 +181,26 @@ module Jekyll
163
181
  @text_manifest ||= TextExtractionManifest.new(@site, @config["text_cache_dir"])
164
182
  end
165
183
 
184
+ def extraction_cache_metadata
185
+ {
186
+ "schema_version" => 1,
187
+ "text_max_bytes" => @config["text_max_bytes"],
188
+ "plaintext_version" => if ::Plaintext.const_defined?(:VERSION)
189
+ ::Plaintext::VERSION.to_s
190
+ else
191
+ "unknown"
192
+ end
193
+ }
194
+ end
195
+
196
+ def truncate_bytes(text, max_bytes)
197
+ bytes = text.to_s.encode("UTF-8", invalid: :replace, undef: :replace)
198
+ .byteslice(0, max_bytes)
199
+ bytes = bytes.to_s.force_encoding("UTF-8")
200
+ bytes = bytes.byteslice(0, bytes.bytesize - 1) until bytes.valid_encoding?
201
+ bytes
202
+ end
203
+
166
204
  def cleanup_manifest(current_rel_paths)
167
205
  text_manifest.cleanup_deleted(current_rel_paths)
168
206
  text_manifest.save
@@ -170,37 +208,40 @@ module Jekyll
170
208
 
171
209
  def bake_document_data(doc, info)
172
210
  data = doc.data
173
- category = remap_category(info[:category])
174
- data["layout"] = @config["layout"]
175
- data["title"] = info[:title]
176
- data["date"] = info[:date] ? info[:date].to_time : File.mtime(info[:path])
177
- data["category"] = category
178
- data["categories"] = [category] if category
179
- data["file_url"] = "/#{info[:rel_path]}"
180
- data["extension"] = info[:ext]
181
- data["file_type"] = info[:file_type]
182
- data["icon_set"] = info[:icon_set]
183
- icon = FileTypeIcons.icon_for(info[:file_type], info[:icon_set])
184
- data["icon_url"] = icon
185
- data["file_size"] = File.size(info[:path])
186
- data["slug"] = info[:slug]
187
- data["permalink"] = @config["permalink"]
188
- .gsub(":category", category.to_s)
189
- .gsub(":slug", info[:slug])
211
+ data["layout"] = @config["layout"]
212
+ data["title"] = info[:title]
213
+ data["date"] = info[:date] ? info[:date].to_time : File.mtime(info[:path])
214
+ data["category"] = info[:category]
215
+ data["category_path"] = info[:category_path]
216
+ data["category_slug"] = info[:category_slug]
217
+ data["categories"] = [info[:category]] if info[:category]
218
+ data["source_path"] = info[:source_path]
219
+ data["file_url"] = "/#{info[:rel_path]}"
220
+ data["extension"] = info[:ext]
221
+ data["file_type"] = info[:file_type]
222
+ data["icon_set"] = info[:icon_set]
223
+ data["icon_url"] = FileTypeIcons.icon_for(info[:file_type], info[:icon_set])
224
+ data["file_size"] = File.size(info[:path])
225
+ data["slug"] = info[:slug]
226
+ data["permalink"] = expand_permalink(data)
227
+ metadata_content = searchable_content(info[:title], data, info[:file_type])
190
228
  doc.content = if @config["extract_text"]
191
- extract_file_content(info) || searchable_content(info[:title], data,
192
- info[:file_type])
229
+ extracted_content = extract_file_content(info)
230
+ if extracted_content && !extracted_content.empty?
231
+ "#{metadata_content} #{extracted_content}"
232
+ else
233
+ metadata_content
234
+ end
193
235
  else
194
- searchable_content(info[:title], data, info[:file_type])
236
+ metadata_content
195
237
  end
196
238
  end
197
239
 
198
240
  # Creates a virtual source path for the document
199
- # @param basename [String] the file basename
200
- # @param category [String] the document category
241
+ # @param source_path [String] the unique path below the documents root
201
242
  # @return [String] virtual source path
202
- def source_stub_for(basename, category)
203
- File.join("_documents", "#{category}-#{basename}.md")
243
+ def source_stub_for(source_path)
244
+ File.join("_documents", "#{normalize_path(source_path)}.md")
204
245
  end
205
246
 
206
247
  # Infers category from the file's directory path
@@ -209,16 +250,80 @@ module Jekyll
209
250
  def infer_category_from(rel_path)
210
251
  return "uncategorized" unless @config["categories_from_path"]
211
252
 
212
- category_dir = File.dirname(rel_path).sub(@config["root"].to_s, "")
213
- category_dir.split("/").reject(&:empty?).last || "uncategorized"
253
+ source_path = source_path_for(rel_path, @config["root"])
254
+ category_path_for(source_path).split("/").last || "uncategorized"
214
255
  end
215
256
 
216
257
  # Remaps category name using category_map configuration
217
258
  # @param cat [String] the original category
218
- # @return [String] the remapped category (lowercased)
259
+ # @return [String] the mapped display category or lowercased directory name
219
260
  def remap_category(cat)
220
261
  map = @config["category_map"] || {}
221
- (map[cat] || cat).to_s.downcase
262
+ leaf = cat.to_s.split("/").last || "uncategorized"
263
+ mapped = map[cat] || map[leaf]
264
+ mapped ? mapped.to_s : leaf.downcase
265
+ end
266
+
267
+ def normalize_path(path)
268
+ path.to_s.tr("\\", "/").squeeze("/").delete_prefix("/").delete_suffix("/")
269
+ end
270
+
271
+ def source_path_for(path, root)
272
+ normalized_path = normalize_path(path)
273
+ normalized_root = normalize_path(root)
274
+ normalized_path.delete_prefix("#{normalized_root}/")
275
+ end
276
+
277
+ def category_path_for(source_path)
278
+ path = normalize_path(source_path)
279
+ path.include?("/") ? path.rpartition("/").first : "uncategorized"
280
+ end
281
+
282
+ def document_category_path(source_path)
283
+ @config["categories_from_path"] ? category_path_for(source_path) : "uncategorized"
284
+ end
285
+
286
+ def expand_permalink(data)
287
+ values = permalink_values(data)
288
+ @config["permalink"].gsub(/:([a-z_]+)/) do |placeholder|
289
+ values.fetch(Regexp.last_match(1), placeholder)
290
+ end
291
+ end
292
+
293
+ def permalink_values(data)
294
+ date = data["date"]
295
+ {
296
+ "category" => data["category_slug"],
297
+ "category_path" => slugify_path(data["category_path"]),
298
+ "slug" => data["slug"],
299
+ "date" => date.strftime("%Y-%m-%d"),
300
+ "year" => date.strftime("%Y"),
301
+ "month" => date.strftime("%m"),
302
+ "day" => date.strftime("%d"),
303
+ "source_path" => source_path_url(data["source_path"])
304
+ }
305
+ end
306
+
307
+ def slugify_path(path)
308
+ normalize_path(path).split("/").map { |segment| slugify(segment, "untitled") }.join("/")
309
+ end
310
+
311
+ def source_path_url(source_path)
312
+ extension = File.extname(source_path)
313
+ stem = source_path.delete_suffix(extension)
314
+ "#{slugify_path(stem)}#{extension.downcase}"
315
+ end
316
+
317
+ def detect_permalink_collisions(docs)
318
+ collisions = docs.group_by(&:url).select { |_url, matches| matches.size > 1 }
319
+ return if collisions.empty?
320
+
321
+ details = collisions.sort.map do |url, matches|
322
+ paths = matches.map { |doc| doc.data["source_path"] }.sort.join(", ")
323
+ "#{url}: #{paths}"
324
+ end
325
+ ::Jekyll.logger.abort_with "jekyll-documents",
326
+ "Permalink collision detected:\n#{details.join("\n")}"
222
327
  end
223
328
 
224
329
  # Parses filename to extract date and title
@@ -243,7 +348,11 @@ module Jekyll
243
348
  # @param basename [String] the filename without extension
244
349
  # @return [String] the generated slug
245
350
  def build_slug(basename)
246
- slug = basename.sub(/^\d{4}-\d{2}-\d{2}_/, "")
351
+ slugify(basename.sub(/^\d{4}-\d{2}-\d{2}_/, ""), "untitled")
352
+ end
353
+
354
+ def slugify(value, fallback)
355
+ slug = value.to_s
247
356
  if @config["slug_danish_map"]
248
357
  slug = slug.gsub(/[æøåÆØÅ]/,
249
358
  { "æ" => "ae", "ø" => "oe", "å" => "aa", "Æ" => "Ae", "Ø" => "Oe",
@@ -252,7 +361,7 @@ module Jekyll
252
361
  slug = slug.downcase if @config["slug_downcase"]
253
362
  slug = slug.gsub(/[^\p{Alnum}\-_\s]/u, "").tr("_ ", "--").squeeze("-")
254
363
  slug = slug.sub(/^-+/, "").sub(/-+$/, "")
255
- slug.empty? ? "untitled" : slug
364
+ slug.empty? ? fallback : slug
256
365
  end
257
366
  end
258
367
  end
@@ -22,6 +22,9 @@ module Jekyll
22
22
  "url" => doc.url,
23
23
  "title" => data["title"],
24
24
  "category" => data["category"],
25
+ "category_path" => data["category_path"],
26
+ "category_slug" => data["category_slug"],
27
+ "source_path" => data["source_path"],
25
28
  "date" => (data["date"] || Time.at(0)).strftime("%Y-%m-%d"),
26
29
  "slug" => data["slug"],
27
30
  "file_type" => data["file_type"],
@@ -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"]
@@ -2,9 +2,63 @@
2
2
 
3
3
  module Jekyll
4
4
  module Documents
5
+ module CategoryResolver
6
+ include ResolutionReporter
7
+
8
+ private
9
+
10
+ def resolve_category(query, options, categories, site)
11
+ return resolve_category_path(options["path"], categories, site) if options["path"]
12
+
13
+ normalized = query.to_s.strip.downcase
14
+ return nil if normalized.empty?
15
+
16
+ exact = categories.select do |category|
17
+ category_values(category).include?(normalized)
18
+ end
19
+ matches = exact.empty? ? partial_categories(categories, normalized) : exact
20
+ aggregate = options["aggregate"] == "true" && options["list"] == "true"
21
+ return matches if matches.one? || (aggregate && matches.any?)
22
+ return nil if matches.empty?
23
+
24
+ paths = matches.map { |category| category["path"] }.sort.join(", ")
25
+ message = "Ambiguous doc_category #{query.inspect}; matches: #{paths}. Rendering nothing."
26
+ report_resolution_issue(site, message)
27
+ nil
28
+ end
29
+
30
+ def resolve_category_path(path, categories, site)
31
+ normalized = normalize_category_path(path)
32
+ matches = categories.select { |category| category["path"] == normalized }
33
+ return matches if matches.one?
34
+
35
+ issue = matches.empty? ? "Unknown" : "Ambiguous"
36
+ report_resolution_issue(site,
37
+ "#{issue} doc_category path #{path.inspect}: #{normalized}")
38
+ nil
39
+ end
40
+
41
+ def partial_categories(categories, query)
42
+ categories.select do |category|
43
+ category_values(category).any? { |value| value.include?(query) }
44
+ end
45
+ end
46
+
47
+ def category_values(category)
48
+ leaf = category["path"].split("/").last.to_s.downcase
49
+ [category["category"], category["slug"], leaf].map { |value| value.to_s.downcase }.uniq
50
+ end
51
+
52
+ def normalize_category_path(path)
53
+ path.to_s.strip.tr("\\", "/").squeeze("/").delete_prefix("./").delete_prefix("/")
54
+ .delete_suffix("/")
55
+ end
56
+ end
57
+
5
58
  class DocCategoryTag < Liquid::Tag
6
59
  public_class_method :new
7
60
 
61
+ include CategoryResolver
8
62
  include Jekyll::Filters::URLFilters
9
63
 
10
64
  def initialize(tag_name, markup, tokens)
@@ -17,13 +71,13 @@ module Jekyll
17
71
  return "" if docs.empty?
18
72
 
19
73
  categories = available_categories(docs)
20
- category = resolve_category(@category, categories)
21
- return "" unless category
74
+ matches = resolve_category(@category, @options, categories, context.registers[:site])
75
+ return "" unless matches
22
76
 
23
77
  @context = context
24
- return render_list(docs, category) if @options["list"] == "true"
78
+ return render_list(docs, matches) if @options["list"] == "true"
25
79
 
26
- render_link(category)
80
+ render_link(matches)
27
81
  end
28
82
 
29
83
  private
@@ -34,36 +88,39 @@ module Jekyll
34
88
  end
35
89
 
36
90
  def available_categories(docs)
37
- docs.map { |doc| doc.data["category"].to_s }.uniq.sort
38
- end
39
-
40
- def resolve_category(query, categories)
41
- normalized = query.to_s.strip.downcase
42
- return nil if normalized.empty?
43
-
44
- categories.find { |cat| cat.downcase == normalized } ||
45
- categories.find { |cat| cat.downcase.include?(normalized) }
91
+ categories = docs.group_by { |doc| doc.data["category_path"] || doc.data["category"] }
92
+ .map do |path, matches|
93
+ data = matches.first.data
94
+ { "path" => path.to_s, "category" => data["category"],
95
+ "slug" => data["category_slug"] || data["category"] }
96
+ end
97
+ categories.sort_by { |category| category["path"] }
46
98
  end
47
99
 
48
- def render_link(category)
49
- url = relative_url("/documents/#{category}/")
50
- text = @options["text"] || category
100
+ def render_link(matches)
101
+ category = matches.first
102
+ url = relative_url("/documents/#{category['slug']}/")
103
+ text = @options["text"] || category["category"]
51
104
  %(<a href="#{escape_html(url)}">#{escape_html(text)}</a>)
52
105
  end
53
106
 
54
- def render_list(docs, category)
55
- cat_docs = sorted_category_docs(docs, category)
107
+ def render_list(docs, matches)
108
+ paths = matches.map { |category| category["path"] }
109
+ cat_docs = sorted_category_docs(docs, paths)
56
110
  limit = @options["limit"]&.to_i
57
111
  cat_docs = cat_docs.first(limit) if limit&.positive?
58
112
 
113
+ category = paths.join(",")
59
114
  out = %(<ul class="doc-category-list" data-category="#{escape_html(category)}">\n)
60
115
  out << list_items(cat_docs)
61
116
  out << "</ul>\n"
62
117
  end
63
118
 
64
- def sorted_category_docs(docs, category)
65
- docs.select { |doc| doc.data["category"].to_s == category }
66
- .sort_by { |doc| doc.data["date"] || Time.at(0) }.reverse
119
+ def sorted_category_docs(docs, paths)
120
+ matches = docs.select do |doc|
121
+ paths.include?((doc.data["category_path"] || doc.data["category"]).to_s)
122
+ end
123
+ matches.sort_by { |doc| doc.data["date"] || Time.at(0) }.reverse
67
124
  end
68
125
 
69
126
  def list_items(cat_docs)
@@ -83,6 +140,8 @@ module Jekyll
83
140
  end
84
141
 
85
142
  def extract_category(text)
143
+ return [nil, text] if text.strip.match?(/\Apath\s*:/)
144
+
86
145
  quoted = text.match(/\A["']([^"']+)["']/)
87
146
  return [quoted[1], text[quoted.end(0)..]] if quoted
88
147
 
@@ -2,9 +2,60 @@
2
2
 
3
3
  module Jekyll
4
4
  module Documents
5
+ module DocumentResolver
6
+ include ResolutionReporter
7
+
8
+ private
9
+
10
+ def find_document(docs, query, options, site)
11
+ return find_document_by_path(docs, options["path"], site) if options["path"]
12
+
13
+ normalized = query.to_s.strip.downcase
14
+ return nil if normalized.empty?
15
+
16
+ exact = docs.select { |doc| document_values(doc).include?(normalized) }
17
+ matches = if exact.empty?
18
+ docs.select do |doc|
19
+ document_values(doc).any? { |value| value.include?(normalized) }
20
+ end
21
+ else
22
+ exact
23
+ end
24
+ return matches.first if matches.one?
25
+ return nil if matches.empty?
26
+
27
+ paths = matches.map { |doc| doc.data["source_path"] || doc.path }.sort.join(", ")
28
+ message = "Ambiguous doc_link #{query.inspect}; matches: #{paths}. Rendering nothing."
29
+ report_resolution_issue(site, message)
30
+ nil
31
+ end
32
+
33
+ def find_document_by_path(docs, path, site)
34
+ normalized = normalize_identifier_path(path)
35
+ matches = docs.select { |doc| doc.data["source_path"].to_s == normalized }
36
+ return matches.first if matches.one?
37
+
38
+ issue = matches.empty? ? "Unknown" : "Ambiguous"
39
+ candidates = matches.map { |doc| doc.data["source_path"] }.sort.join(", ")
40
+ detail = candidates.empty? ? normalized : candidates
41
+ report_resolution_issue(site, "#{issue} doc_link path #{path.inspect}: #{detail}")
42
+ nil
43
+ end
44
+
45
+ def normalize_identifier_path(path)
46
+ path.to_s.strip.tr("\\", "/").squeeze("/").delete_prefix("./").delete_prefix("/")
47
+ .delete_suffix("/")
48
+ end
49
+
50
+ def document_values(doc)
51
+ [doc.data["title"], doc.data["slug"]].map { |value| value.to_s.downcase }
52
+ end
53
+ end
54
+
5
55
  class DocLinkTag < Liquid::Tag
6
56
  public_class_method :new
7
57
 
58
+ include DocumentResolver
8
59
  include Jekyll::Filters::URLFilters
9
60
 
10
61
  SIZE_UNITS = %w[B KB MB GB].freeze
@@ -18,7 +69,7 @@ module Jekyll
18
69
  docs = documents_from(context)
19
70
  return "" if docs.empty?
20
71
 
21
- match = find_document(docs, @query)
72
+ match = find_document(docs, @query, @options, context.registers[:site])
22
73
  return "" unless match
23
74
 
24
75
  @context = context
@@ -35,20 +86,6 @@ module Jekyll
35
86
  site.collections["documents"]&.docs || []
36
87
  end
37
88
 
38
- def find_document(docs, query)
39
- normalized = query.to_s.strip.downcase
40
- return nil if normalized.empty?
41
-
42
- docs.find { |doc| matches?(doc, normalized) }
43
- end
44
-
45
- def matches?(doc, query)
46
- title = doc.data["title"].to_s.downcase
47
- slug = doc.data["slug"].to_s.downcase
48
- title == query || slug == query ||
49
- title.include?(query) || slug.include?(query)
50
- end
51
-
52
89
  def build_link(doc, url, text)
53
90
  inner = +""
54
91
  inner << icon_html(doc) if @options["icon"] != "false"
@@ -99,6 +136,8 @@ module Jekyll
99
136
  end
100
137
 
101
138
  def extract_query(text)
139
+ return [nil, text] if text.strip.match?(/\Apath\s*:/)
140
+
102
141
  quoted = text.match(/\A["']([^"']+)["']/)
103
142
  return [quoted[1], text[quoted.end(0)..]] if quoted
104
143
 
@@ -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.7.0"
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.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svend Gundestrup