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 +4 -4
- data/CHANGELOG.md +33 -1
- data/README.md +44 -6
- data/lib/jekyll/documents/configuration.rb +20 -2
- data/lib/jekyll/documents/generator.rb +161 -52
- data/lib/jekyll/documents/json_index_generator.rb +3 -0
- data/lib/jekyll/documents/layout_registrar.rb +1 -1
- data/lib/jekyll/documents/tags/doc_category.rb +80 -21
- data/lib/jekyll/documents/tags/doc_link.rb +54 -15
- 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: 34e9456980c65a0eab294a17100d6de0bcf522bc3652afcadc3c2521c8c9d48b
|
|
4
|
+
data.tar.gz: be4ff590ed8c3e251fb7c56a1e57a33e2650d60a63c44113bb2a4c48402fe906
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4c456c2e5ff0b55a6d51bc777770bed926b6cfa4c8a1393fda110f5d0a8d3e0e49af5c5399e1a0bdf2f4c8f5632e19ec8490975722842a5657b9f6e737c4412a
|
|
7
|
+
data.tar.gz: 5ff47c4204e58ad1e49bd48cd146d4f51ccf44254f1244530b0fd39f8fa2b5817531cfa293409a3f6fbad7063bc3425599e2c1bf37d094b5dbb45bba5cc13dc8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
68
|
+
source_stub_for(source_path),
|
|
69
69
|
site: site,
|
|
70
70
|
collection: collection
|
|
71
71
|
)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
174
|
-
data["
|
|
175
|
-
data["
|
|
176
|
-
data["
|
|
177
|
-
data["
|
|
178
|
-
data["
|
|
179
|
-
data["
|
|
180
|
-
data["
|
|
181
|
-
data["
|
|
182
|
-
data["
|
|
183
|
-
|
|
184
|
-
data["
|
|
185
|
-
data["
|
|
186
|
-
data["
|
|
187
|
-
data["
|
|
188
|
-
|
|
189
|
-
|
|
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)
|
|
192
|
-
|
|
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
|
-
|
|
236
|
+
metadata_content
|
|
195
237
|
end
|
|
196
238
|
end
|
|
197
239
|
|
|
198
240
|
# Creates a virtual source path for the document
|
|
199
|
-
# @param
|
|
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(
|
|
203
|
-
File.join("_documents", "#{
|
|
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
|
-
|
|
213
|
-
|
|
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
|
|
259
|
+
# @return [String] the mapped display category or lowercased directory name
|
|
219
260
|
def remap_category(cat)
|
|
220
261
|
map = @config["category_map"] || {}
|
|
221
|
-
|
|
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
|
-
|
|
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? ?
|
|
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["
|
|
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
|
-
|
|
21
|
-
return "" unless
|
|
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,
|
|
78
|
+
return render_list(docs, matches) if @options["list"] == "true"
|
|
25
79
|
|
|
26
|
-
render_link(
|
|
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.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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(
|
|
49
|
-
|
|
50
|
-
|
|
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,
|
|
55
|
-
|
|
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,
|
|
65
|
-
docs.select
|
|
66
|
-
|
|
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["
|
|
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
|