jekyll-documents 0.3.0 → 0.3.3

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: 3865d4ab22aa0cf85e53ad31d222e4e4d025f7f53fb1271a949d326ee8ad60a3
4
- data.tar.gz: 483721501a072d99ade0088e958b52e02583b4d13de7ee26ecd74bc7141b9c87
3
+ metadata.gz: b2e673dc309679ea3bc1ce25687bb9fbf52901f27b7b29517c63ef9337291e82
4
+ data.tar.gz: f89cef0e3d22c85665b516bd52da7f137b612c62c21f24e22ef33dc7a10170a8
5
5
  SHA512:
6
- metadata.gz: c923ebb2b6e801ab1449b76ed999e8408d234ddb03c03c5b6bf21086c54a49a6fbc7d74b258049248ae53c159c81814d7f23ee44e1f590d9c89f49814bf56630
7
- data.tar.gz: 639afd3c1abce1196a31b7fb7e34acc197d681f5dc863c53b480f7d127b104db0e27c9adb02c88889dcc62977f4d86162bfade3a077ea269f811e0f24a6da3da
6
+ metadata.gz: 956c33415793dd9e285f9e81874b612278c188d0dd39237a1bbdeea7964af1d784655b171d7efbf485b37dfa948c02ade3703b4e82e1b411a90d9c2049cb0f65
7
+ data.tar.gz: c6069125baef1f06acbd58124f5993881b2e595a60b02e470fb0a8a3a94a764f62b0bbd8ad53320b325d614590aa494fb469f83674145f1924db945e1afc0e46
data/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.3.3] - 2026-08-12
6
+
7
+ ### Added
8
+ - `{% doc_link "title" %}` Liquid tag for linking to documents by partial title or slug match (case-insensitive); renders file icon, title, and human-readable file size; supports `text:"custom label"`, `icon:false`, and `size:false` options
9
+ - `{% doc_category "name" %}` Liquid tag for linking to category pages or listing documents by category; supports `text:"label"`, `list:true`, and `limit:N` options
10
+ - `file_size` attribute baked into each document's data at generation time
11
+ - `file_url` and `file_size` fields included in the generated `/documents.json` index
12
+ - 30 RSpec examples covering `DocLinkTag` and `DocCategoryTag` (exact/partial matching, slug matching, icon/size rendering, custom text, baseurl, HTML escaping, list mode, limit, sorting, empty collection handling)
13
+
14
+ ## [0.3.2] - 2026-08-12
15
+
16
+ ### Added
17
+ - Auto-registration of gem-packaged `_layouts` and `_includes` via a `:site, :after_init` hook so Jekyll discovers the `document` layout and all includes without the gem being declared as a theme; user-provided files always take precedence
18
+ - 7 RSpec examples for `LayoutRegistrar` covering copy, override, idempotency, and hook registration
19
+
20
+ ### Fixed
21
+ - Corrected publish workflow Ruby version from 3.2 to 3.3 to match the gemspec `required_ruby_version` so the gem builds and publishes to RubyGems successfully
22
+
23
+ ## [0.3.1] - 2026-08-03
24
+
25
+ ### Added
26
+ - Added the context-aware `{% document_icon page %}` Liquid tag for simple icon rendering
27
+ - Added installed-gem system coverage for all supported document types and icon themes
28
+ - Added Playwright browser coverage for search interaction, links, icons, baseurl, and network errors
29
+
30
+ ### Changed
31
+ - Minimum Ruby version raised from `>= 3.2` to `>= 3.3`
32
+ - Updated RuboCop target Ruby version to 3.3
33
+ - Updated compatible development gems, including RuboCop, Reek, RSpec, and Playwright tooling
34
+ - Test and quality tasks now rebuild and force-install the gem before testing
35
+ - Added package-content checks to verify the built gem contains runtime templates, icons, JavaScript, and Liquid tags
36
+
37
+ ### Fixed
38
+ - Corrected non-color icon mappings to reference packaged SVG assets
39
+ - Registered packaged icons and search JavaScript for copying during Jekyll builds
40
+ - Made document search respect the configured index path and site base URL
41
+ - Hardened document search index validation and disabled the search include when JSON indexing is disabled
42
+ - Escaped search-result URLs, icon URLs, titles, categories, and dates before rendering
43
+ - Added the missing date conversion required for Jekyll 4.4 document publishing
44
+ - Removed duplicate search scripts from the example site
45
+ - Made Reek failures fail the quality task consistently
46
+
47
+ ### Verification
48
+ - 186 Ruby/system examples passing with 100% Ruby line coverage
49
+ - 3 Playwright browser tests passing
50
+ - 0 RuboCop offenses, 0 Reek warnings, and 0 Bundler Audit vulnerabilities
3
51
 
4
52
  ## [0.3.0] - 2026-07-23
5
53
 
@@ -13,8 +61,8 @@
13
61
  - **System test for full build pipeline**: End-to-end test covering generator → JSON index → tag rendering
14
62
 
15
63
  ### Changed
16
- - **Minimum Ruby version**: Bumped from `>= 2.7` to `>= 3.1` (Ruby 2.7 EOL March 2023, 3.0 EOL April 2024)
17
- - **RuboCop `TargetRubyVersion`**: Updated from `2.7` to `3.1` to match gemspec
64
+ - **Minimum Ruby version**: Bumped from `>= 2.7` to `>= 3.2` (zeitwerk 2.7+ a Jekyll dependency requires Ruby >= 3.2)
65
+ - **RuboCop `TargetRubyVersion`**: Updated from `2.7` to `3.2` to match gemspec
18
66
  - **Templates use baked icon data**: `latest_documents.html`, `documents_list.html`, `document.html` now use `{{ doc.icon_url }}` instead of the broken `file_type_icon_tag` filter
19
67
  - **`category_list.html` icon set**: Now reads `icon_set` from first document's baked data instead of non-functional `site.documents.icon_set`
20
68
  - **`latest_documents.html` count fallback**: Simplified to `| default: 5` since `site.config` is inaccessible in Liquid
data/README.md CHANGED
@@ -1,9 +1,20 @@
1
1
  # jekyll-documents
2
+ [![Status: Active](https://img.shields.io/badge/status-active-success)](https://github.com/gundestrup/jekyll-documents)
3
+ [![Tests](https://github.com/gundestrup/jekyll-documents/actions/workflows/test.yml/badge.svg)](https://github.com/gundestrup/jekyll-documents/actions/workflows/test.yml)
4
+ [![Codecov](https://codecov.io/gh/gundestrup/jekyll-documents/graph/badge.svg)](https://codecov.io/gh/gundestrup/jekyll-documents)
5
+ [![Gem Version](https://img.shields.io/gem/v/jekyll-documents)](https://rubygems.org/gems/jekyll-documents)
6
+ [![Gem Downloads](https://img.shields.io/gem/dt/jekyll-documents)](https://rubygems.org/gems/jekyll-documents)
7
+ [![License: AGPL-3.0-only](https://img.shields.io/badge/license-AGPL--3.0--only-blue)](LICENSE)
8
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/gundestrup/jekyll-documents)
2
9
 
3
10
  Turn files in `assets/documents/` into browsable document pages.
4
11
 
12
+ **Requirements**: Ruby 3.3+ • Jekyll 4.4+
13
+
5
14
  **Features**: Auto-collection • File icons • Categories • Search
6
15
 
16
+ [![DeepWiki](https://img.shields.io/badge/DeepWiki-docs-blue)](https://deepwiki.com/gundestrup/jekyll-documents)
17
+
7
18
  ## Quick Start
8
19
 
9
20
  ```ruby
@@ -52,18 +63,52 @@ documents:
52
63
  icon_set: "color"
53
64
  ```
54
65
 
66
+ Render a document icon in templates with the context-aware tag:
67
+
55
68
  ```liquid
56
- {{ page.file_type | file_type_icon_tag }}
69
+ {% document_icon page %}
57
70
  ```
58
71
 
72
+ The tag applies the configured icon set and site `baseurl` automatically. The `file_type_icon` and
73
+ `file_type_icon_tag` filters remain available for lower-level or Ruby-side use.
74
+
59
75
  ## Usage
60
76
 
77
+ ### Includes
78
+
61
79
  ```liquid
62
80
  {% include latest_documents.html count=5 %}
63
81
  {% include documents_list.html %}
64
82
  {% include documents_search.html %}
65
83
  ```
66
84
 
85
+ ### Liquid Tags
86
+
87
+ **Link to a document** by title or slug (partial match, case-insensitive):
88
+
89
+ ```liquid
90
+ {% doc_link "Annual Report" %}
91
+ {% doc_link "annual" text:"Read the report" %}
92
+ {% doc_link "board-meeting" icon:false size:false %}
93
+ ```
94
+
95
+ Renders an `<a>` tag with the file type icon, title, and human-readable file size.
96
+ Use `text:"..."` to override the link text, `icon:false` to hide the icon,
97
+ or `size:false` to hide the file size.
98
+
99
+ **Link to or list a category**:
100
+
101
+ ```liquid
102
+ {% doc_category "reports" %}
103
+ {% doc_category "reports" text:"All reports" %}
104
+ {% doc_category "reports" list:true %}
105
+ {% doc_category "reports" list:true limit:5 %}
106
+ ```
107
+
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
+
67
112
  ## Configuration
68
113
 
69
114
  ```yaml
@@ -78,15 +123,19 @@ See [configuration.rb](lib/jekyll/documents/configuration.rb) for all options.
78
123
  ## Development
79
124
 
80
125
  ```bash
81
- rake # All quality checks
82
- rake quick # Fast check
126
+ rake # Ruby quality checks
127
+ rake test # Rebuild/install gem + Ruby and browser tests
128
+ rake browser_test # Browser search tests (Node.js + Chromium required)
129
+ rake quick # Fast Ruby check
83
130
  rake help # Show commands
84
131
  ```
85
132
 
86
- **Quality**: 98.99% coverage • 0 vulnerabilities • RuboCopReek
133
+ **Quality**: 100% coverage • 0 vulnerabilities • 0 offenses 0 warnings
87
134
 
88
135
  See [README.Development.md](README.Development.md) for details.
89
136
 
137
+ **DeepWiki**: [https://deepwiki.com/gundestrup/jekyll-documents](https://deepwiki.com/gundestrup/jekyll-documents)
138
+
90
139
  ## Release
91
140
 
92
141
  ```bash
@@ -2,7 +2,7 @@
2
2
  <ul class="documents-list">
3
3
  {% for doc in docs %}
4
4
  <li>
5
- <img src="{{ doc.icon_url | relative_url }}" alt="{{ doc.file_type | upcase }} file" class="file-icon" />
5
+ {% document_icon doc %}
6
6
  <a href="{{ doc.url | relative_url }}">{{ doc.title }}</a>
7
7
  <small>({{ doc.date | date: "%Y-%m-%d" }}, {{ doc.category }})</small>
8
8
  </li>
@@ -1,6 +1,14 @@
1
+ {% assign documents_config = site.data.jekyll_documents %}
2
+ {% assign index_path = documents_config.json_index_path | default: '/documents.json' %}
3
+ {% if documents_config.json_index != false %}
1
4
  <div class="documents-search">
2
5
  <input id="doc-search-input" type="search" placeholder="Search documents…" aria-label="Search documents" />
3
6
  <div id="doc-search-results" class="documents-search-results"></div>
4
7
  </div>
8
+ <script>
9
+ window.__jekyllDocumentsIndexPath = {{ index_path | relative_url | jsonify }};
10
+ window.__jekyllDocumentsBaseurl = {{ site.baseurl | jsonify }};
11
+ </script>
5
12
  <script src="https://unpkg.com/lunr/lunr.js"></script>
6
13
  <script src="{{ '/assets/js/documents-search.js' | relative_url }}"></script>
14
+ {% endif %}
@@ -3,7 +3,7 @@
3
3
  <ul class="latest-documents">
4
4
  {% for doc in docs %}
5
5
  <li>
6
- <img src="{{ doc.icon_url | relative_url }}" alt="{{ doc.file_type | upcase }} file" class="file-icon" />
6
+ {% document_icon doc %}
7
7
  <a href="{{ doc.url | relative_url }}">{{ doc.title }}</a>
8
8
  <small>({{ doc.date | date: "%Y-%m-%d" }}, {{ doc.category }})</small>
9
9
  </li>
@@ -13,7 +13,7 @@ layout: default
13
13
 
14
14
  <p>
15
15
  <a href="{{ page.file_url | relative_url }}" class="document-download">
16
- <img src="{{ page.icon_url | relative_url }}" alt="{{ page.file_type | upcase }} file" class="file-icon" />
16
+ {% document_icon page %}
17
17
  Download ({{ page.extension | remove: "." | upcase }})
18
18
  </a>
19
19
  </p>
@@ -1,80 +1,38 @@
1
- /* Minimal client-side search using Lunr and /documents.json (generated by the plugin).
1
+ /* Minimal client-side search using Lunr and the generated document index.
2
2
  Usage on any page:
3
3
  {% include documents_search.html %}
4
- <script src="https://unpkg.com/lunr/lunr.js"></script>
5
- <script src="{{ '/assets/js/documents-search.js' | relative_url }}"></script>
6
4
  */
7
5
  (function () {
8
6
  const input = document.getElementById("doc-search-input");
9
7
  const resultsEl = document.getElementById("doc-search-results");
10
8
  if (!input || !resultsEl) return;
11
9
 
12
- const indexUrl = (window.__jekyllDocumentsIndexPath || "/documents.json");
10
+ const indexUrl = window.__jekyllDocumentsIndexPath || "/documents.json";
13
11
  let idx, docs = [];
14
12
 
15
- function escapeHtml(s) {
13
+ function escapeHtml(value) {
16
14
  const div = document.createElement("div");
17
- div.appendChild(document.createTextNode(s));
18
- return div.innerHTML;
15
+ div.appendChild(document.createTextNode(String(value)));
16
+ return div.innerHTML
17
+ .replace(/"/g, "&quot;")
18
+ .replace(/'/g, "&#39;");
19
19
  }
20
20
 
21
- function getIconPath(fileType, iconSet) {
22
- iconSet = iconSet || "color";
23
- const iconMap = {
24
- "color": {
25
- "pdf": "/assets/icons/color/pdf-document-svgrepo-com.svg",
26
- "docx": "/assets/icons/color/word-document-svgrepo-com.svg",
27
- "doc": "/assets/icons/color/word-document-svgrepo-com.svg",
28
- "pptx": "/assets/icons/color/ppt-document-svgrepo-com.svg",
29
- "ppt": "/assets/icons/color/ppt-document-svgrepo-com.svg",
30
- "xlsx": "/assets/icons/color/excel-document-svgrepo-com.svg",
31
- "xls": "/assets/icons/color/excel-document-svgrepo-com.svg",
32
- "txt": "/assets/icons/color/txt-document-svgrepo-com.svg",
33
- "zip": "/assets/icons/color/zip-document-svgrepo-com.svg"
34
- },
35
- "lines": {
36
- "pdf": "/assets/icons/lines/pdf-svgrepo-com.svg",
37
- "doc": "/assets/icons/lines/doc-svgrepo-com.svg",
38
- "docx": "/assets/icons/lines/doc-svgrepo-com.svg",
39
- "ppt": "/assets/icons/lines/ppt-svgrepo-com.svg",
40
- "pptx": "/assets/icons/lines/ppt-svgrepo-com.svg",
41
- "xls": "/assets/icons/lines/xls-svgrepo-com.svg",
42
- "xlsx": "/assets/icons/lines/xls-svgrepo-com.svg",
43
- "txt": "/assets/icons/lines/txt-svgrepo-com.svg",
44
- "zip": "/assets/icons/lines/zip-svgrepo-com.svg"
45
- },
46
- "minimal": {
47
- "pdf": "/assets/icons/minimal/pdf-svgrepo-com.svg",
48
- "doc": "/assets/icons/minimal/doc-svgrepo-com.svg",
49
- "docx": "/assets/icons/minimal/doc-svgrepo-com.svg",
50
- "ppt": "/assets/icons/minimal/ppt-svgrepo-com.svg",
51
- "pptx": "/assets/icons/minimal/ppt-svgrepo-com.svg",
52
- "xls": "/assets/icons/minimal/xls-svgrepo-com.svg",
53
- "xlsx": "/assets/icons/minimal/xls-svgrepo-com.svg",
54
- "txt": "/assets/icons/minimal/txt-svgrepo-com.svg",
55
- "zip": "/assets/icons/minimal/zip-svgrepo-com.svg"
56
- },
57
- "ultra-minimal": {
58
- "pdf": "/assets/icons/ultra-minimal/pdf.svg",
59
- "doc": "/assets/icons/ultra-minimal/doc.svg",
60
- "docx": "/assets/icons/ultra-minimal/doc.svg",
61
- "ppt": "/assets/icons/ultra-minimal/ppt.svg",
62
- "pptx": "/assets/icons/ultra-minimal/ppt.svg",
63
- "xls": "/assets/icons/ultra-minimal/xls.svg",
64
- "xlsx": "/assets/icons/ultra-minimal/xls.svg",
65
- "txt": "/assets/icons/ultra-minimal/txt.svg",
66
- "zip": "/assets/icons/ultra-minimal/zip.svg"
67
- }
68
- };
69
- const icons = iconMap[iconSet] || iconMap["color"];
70
- return icons[fileType] || `/assets/icons/${iconSet}/unknown-document-svgrepo-com.svg`;
21
+ function withBaseurl(url) {
22
+ const value = String(url || "");
23
+ if (!value || /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(value)) return value;
24
+
25
+ const baseurl = String(window.__jekyllDocumentsBaseurl || "")
26
+ .replace(/^\/+|\/+$/g, "");
27
+ const path = value.replace(/^\/+/, "");
28
+ return `/${[baseurl, path].filter(Boolean).join("/")}`;
71
29
  }
72
30
 
73
- function getIconTag(fileType) {
74
- const iconSet = window.__jekyllDocumentsIconSet || "color";
75
- const iconPath = getIconPath(fileType, iconSet);
76
- const altText = (fileType || "file").toUpperCase() + " file";
77
- return `<img src="${iconPath}" alt="${escapeHtml(altText)}" class="file-icon" />`;
31
+ function getIconTag(fileType, iconUrl) {
32
+ if (!iconUrl) return "";
33
+ const altText = `${String(fileType || "file").toUpperCase()} file`;
34
+ const url = escapeHtml(withBaseurl(iconUrl));
35
+ return `<img src="${url}" alt="${escapeHtml(altText)}" class="file-icon" />`;
78
36
  }
79
37
 
80
38
  function render(matches) {
@@ -82,21 +40,27 @@
82
40
  resultsEl.innerHTML = "<p>No results.</p>";
83
41
  return;
84
42
  }
85
- const items = matches.slice(0, 20).map(m => {
86
- const d = docs.find(x => x.url === m.ref);
87
- if (!d) return "";
88
- const title = escapeHtml(d.title || "Untitled");
89
- const category = escapeHtml(d.category || "uncategorized");
90
- const date = d.date ? `<small>${escapeHtml(d.date)}</small>` : "";
91
- const icon = d.file_type ? getIconTag(d.file_type) : "";
92
- return `<li>${icon}<a href="${d.url}">${title}</a> <small>[${category}]</small> ${date}</li>`;
43
+
44
+ const items = matches.slice(0, 20).map(match => {
45
+ const document = docs.find(item => item.url === match.ref);
46
+ if (!document) return "";
47
+
48
+ const title = escapeHtml(document.title || "Untitled");
49
+ const category = escapeHtml(document.category || "uncategorized");
50
+ const date = document.date ? `<small>${escapeHtml(document.date)}</small>` : "";
51
+ const icon = getIconTag(document.file_type, document.icon_url);
52
+ const url = escapeHtml(withBaseurl(document.url));
53
+ return `<li>${icon}<a href="${url}">${title}</a> ` +
54
+ `<small>[${category}]</small> ${date}</li>`;
93
55
  });
94
56
  resultsEl.innerHTML = `<ul class="documents-search-list">${items.join("")}</ul>`;
95
57
  }
96
58
 
97
59
  function debounce(fn, wait) {
98
- let t; return function (...args) {
99
- clearTimeout(t); t = setTimeout(() => fn.apply(this, args), wait);
60
+ let timer;
61
+ return function (...args) {
62
+ clearTimeout(timer);
63
+ timer = setTimeout(() => fn.apply(this, args), wait);
100
64
  };
101
65
  }
102
66
 
@@ -106,34 +70,48 @@
106
70
  this.field("title", { boost: 10 });
107
71
  this.field("category", { boost: 5 });
108
72
  this.field("slug");
109
- docs.forEach(doc => this.add(doc));
73
+ docs.forEach(document => this.add(document));
110
74
  });
111
75
  }
112
76
 
113
- function doSearch(q) {
114
- if (!idx || !q || q.trim().length === 0) {
77
+ function doSearch(query) {
78
+ if (!idx || !query || query.trim().length === 0) {
115
79
  resultsEl.innerHTML = "";
116
80
  return;
117
81
  }
82
+
118
83
  try {
119
- const res = idx.search(q);
120
- render(res);
121
- } catch (e) {
122
- // If Lunr query syntax fails, fallback to simple contains filter
123
- const qlc = q.toLowerCase();
124
- const res = docs.filter(d =>
125
- (d.title || "").toLowerCase().includes(qlc) ||
126
- (d.category || "").toLowerCase().includes(qlc) ||
127
- (d.slug || "").toLowerCase().includes(qlc)
128
- ).map(d => ({ ref: d.url }));
129
- render(res);
84
+ render(idx.search(query));
85
+ } catch (error) {
86
+ // If Lunr query syntax fails, fall back to a simple contains filter.
87
+ const normalizedQuery = query.toLowerCase();
88
+ const matches = docs.filter(document =>
89
+ [document.title, document.category, document.slug].some(value =>
90
+ String(value || "").toLowerCase().includes(normalizedQuery)
91
+ )
92
+ ).map(document => ({ ref: document.url }));
93
+ render(matches);
130
94
  }
131
95
  }
132
96
 
133
97
  fetch(indexUrl)
134
- .then(r => r.json())
135
- .then(json => { docs = json; buildIndex(); })
136
- .catch(err => { console.error("Documents search index failed:", err); });
98
+ .then(response => {
99
+ if (!response.ok) {
100
+ throw new Error(`Search index request failed: ${response.status}`);
101
+ }
102
+ return response.json();
103
+ })
104
+ .then(json => {
105
+ if (!Array.isArray(json)) {
106
+ throw new Error("Search index must be a JSON array");
107
+ }
108
+ docs = json;
109
+ buildIndex();
110
+ })
111
+ .catch(error => {
112
+ resultsEl.innerHTML = "<p>Document search is currently unavailable.</p>";
113
+ console.error("Documents search index failed:", error);
114
+ });
137
115
 
138
- input.addEventListener("input", debounce(e => doSearch(e.target.value), 150));
116
+ input.addEventListener("input", debounce(event => doSearch(event.target.value), 150));
139
117
  })();
@@ -25,20 +25,20 @@ Gem::Specification.new do |spec|
25
25
  "rubygems_mfa_required" => "true"
26
26
  }
27
27
 
28
- spec.required_ruby_version = ">= 3.1"
28
+ spec.required_ruby_version = ">= 3.3"
29
29
 
30
30
  spec.files = Dir.glob("{lib,assets,_includes,_layouts}/**/*") +
31
31
  ["README.md", "CHANGELOG.md", "LICENSE", "jekyll-documents.gemspec"]
32
32
  spec.require_paths = ["lib"]
33
33
 
34
- spec.add_dependency "jekyll", ">= 4.0"
34
+ spec.add_dependency "jekyll", ">= 4.4", "< 5.0"
35
35
 
36
36
  spec.add_development_dependency "bundler-audit", "~> 0.9"
37
37
  spec.add_development_dependency "rake", "~> 13.0"
38
- spec.add_development_dependency "reek", "~> 6.1"
39
- spec.add_development_dependency "rspec", "~> 3.12"
40
- spec.add_development_dependency "rubocop", "~> 1.60"
41
- spec.add_development_dependency "rubocop-performance", "~> 1.20"
38
+ spec.add_development_dependency "reek", "~> 6.5"
39
+ spec.add_development_dependency "rspec", "~> 3.13"
40
+ spec.add_development_dependency "rubocop", "~> 1.88"
41
+ spec.add_development_dependency "rubocop-performance", "~> 1.26"
42
42
  spec.add_development_dependency "simplecov", "~> 1.0"
43
43
  spec.add_development_dependency "yard", "~> 0.9"
44
44
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Documents
5
+ # Registers gem-packaged frontend assets for copying into the generated site.
6
+ class AssetsGenerator < ::Jekyll::Generator
7
+ safe true
8
+ priority :normal
9
+
10
+ def generate(site)
11
+ package_root = File.expand_path("../../..", __dir__)
12
+ site.data["jekyll_documents"] = configuration(site)
13
+ register_assets(site, package_root)
14
+ end
15
+
16
+ private
17
+
18
+ def configuration(site)
19
+ config = Configuration.read(site)
20
+ {
21
+ "json_index" => config["json_index"],
22
+ "json_index_path" => config["json_index_path"]
23
+ }
24
+ end
25
+
26
+ def register_assets(site, package_root)
27
+ assets_root = File.join(package_root, "assets")
28
+ Dir.glob(File.join(assets_root, "**", "*")).each do |path|
29
+ register_asset(site, package_root, path)
30
+ end
31
+ end
32
+
33
+ def register_asset(site, package_root, path)
34
+ return unless File.file?(path)
35
+
36
+ relative_path = path.delete_prefix("#{package_root}/")
37
+ return if site.static_files.any? { |file| file.relative_path == relative_path }
38
+
39
+ site.static_files << ::Jekyll::StaticFile.new(
40
+ site,
41
+ package_root,
42
+ File.dirname(relative_path),
43
+ File.basename(relative_path)
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
@@ -13,6 +13,9 @@ module Jekyll
13
13
  # Lines: https://www.svgrepo.com/collection/simple-file-types-line-vectors/
14
14
  # Minimal: https://www.svgrepo.com/collection/file-type-minimal-icons/
15
15
  # Ultra-minimal: Based on simple document icons
16
+ LINES_ICON_PATH = "/assets/icons/lines/"
17
+ MINIMAL_ICON_PATH = "/assets/icons/minimal/"
18
+
16
19
  ICON_MAP = {
17
20
  "color" => {
18
21
  "pdf" => "/assets/icons/color/pdf-document-svgrepo-com.svg",
@@ -38,42 +41,59 @@ module Jekyll
38
41
  "rtf" => "/assets/icons/color/rtf-document-svgrepo-com.svg"
39
42
  },
40
43
  "lines" => {
41
- "pdf" => "/assets/icons/lines/pdf-svgrepo-com.svg",
42
- "docx" => "/assets/icons/lines/doc-svgrepo-com.svg",
43
- "doc" => "/assets/icons/lines/doc-svgrepo-com.svg",
44
- "pptx" => "/assets/icons/lines/ppt-svgrepo-com.svg",
45
- "ppt" => "/assets/icons/lines/ppt-svgrepo-com.svg",
46
- "xlsx" => "/assets/icons/lines/xls-svgrepo-com.svg",
47
- "xls" => "/assets/icons/lines/xls-svgrepo-com.svg",
48
- "odt" => "/assets/icons/lines/doc-svgrepo-com.svg",
49
- "ods" => "/assets/icons/lines/xls-svgrepo-com.svg",
50
- "odp" => "/assets/icons/lines/ppt-svgrepo-com.svg",
51
- "txt" => "/assets/icons/lines/txt-svgrepo-com.svg",
52
- "zip" => "/assets/icons/lines/zip-svgrepo-com.svg",
53
- "mp3" => "/assets/icons/lines/mp3-svgrepo-com.svg",
54
- "mp4" => "/assets/icons/lines/mp4-svgrepo-com.svg",
55
- "jpg" => "/assets/icons/lines/jpg-svgrepo-com.svg",
56
- "jpeg" => "/assets/icons/lines/jpg-svgrepo-com.svg",
57
- "png" => "/assets/icons/lines/png-svgrepo-com.svg"
44
+ "pdf" => "#{LINES_ICON_PATH}pdf-file-type-svgrepo-com.svg",
45
+ "docx" => "#{LINES_ICON_PATH}word-file-type-svgrepo-com.svg",
46
+ "doc" => "#{LINES_ICON_PATH}word-file-type-svgrepo-com.svg",
47
+ "pptx" => "#{LINES_ICON_PATH}ppt-file-type-svgrepo-com.svg",
48
+ "ppt" => "#{LINES_ICON_PATH}ppt-file-type-svgrepo-com.svg",
49
+ "xlsx" => "#{LINES_ICON_PATH}excel-file-type-svgrepo-com.svg",
50
+ "xls" => "#{LINES_ICON_PATH}excel-file-type-svgrepo-com.svg",
51
+ "odt" => "#{LINES_ICON_PATH}word-file-type-svgrepo-com.svg",
52
+ "ods" => "#{LINES_ICON_PATH}excel-file-type-svgrepo-com.svg",
53
+ "odp" => "#{LINES_ICON_PATH}ppt-file-type-svgrepo-com.svg",
54
+ "txt" => "#{LINES_ICON_PATH}txt-file-type-svgrepo-com.svg",
55
+ "zip" => "#{LINES_ICON_PATH}zip-file-type-svgrepo-com.svg",
56
+ "mp3" => "#{LINES_ICON_PATH}mp3-file-type-svgrepo-com.svg",
57
+ "mp4" => "#{LINES_ICON_PATH}other-file-type-svgrepo-com.svg",
58
+ "jpg" => "#{LINES_ICON_PATH}jpg-file-type-svgrepo-com.svg",
59
+ "jpeg" => "#{LINES_ICON_PATH}jpg-file-type-svgrepo-com.svg",
60
+ "png" => "#{LINES_ICON_PATH}png-file-type-svgrepo-com.svg"
58
61
  },
59
62
  "minimal" => {
60
- "pdf" => "/assets/icons/minimal/pdf-svgrepo-com.svg",
61
- "docx" => "/assets/icons/minimal/doc-svgrepo-com.svg",
62
- "doc" => "/assets/icons/minimal/doc-svgrepo-com.svg",
63
- "pptx" => "/assets/icons/minimal/ppt-svgrepo-com.svg",
64
- "ppt" => "/assets/icons/minimal/ppt-svgrepo-com.svg",
65
- "xlsx" => "/assets/icons/minimal/xls-svgrepo-com.svg",
66
- "xls" => "/assets/icons/minimal/xls-svgrepo-com.svg",
67
- "odt" => "/assets/icons/minimal/doc-svgrepo-com.svg",
68
- "ods" => "/assets/icons/minimal/xls-svgrepo-com.svg",
69
- "odp" => "/assets/icons/minimal/ppt-svgrepo-com.svg",
70
- "txt" => "/assets/icons/minimal/txt-svgrepo-com.svg",
71
- "zip" => "/assets/icons/minimal/zip-svgrepo-com.svg",
72
- "mp3" => "/assets/icons/minimal/mp3-svgrepo-com.svg",
73
- "mp4" => "/assets/icons/minimal/mp4-svgrepo-com.svg",
74
- "jpg" => "/assets/icons/minimal/jpg-svgrepo-com.svg",
75
- "jpeg" => "/assets/icons/minimal/jpg-svgrepo-com.svg",
76
- "png" => "/assets/icons/minimal/png-svgrepo-com.svg"
63
+ "pdf" => "#{MINIMAL_ICON_PATH}" \
64
+ "extension-file-format-pdf-document-file-format-svgrepo-com.svg",
65
+ "docx" => "#{MINIMAL_ICON_PATH}" \
66
+ "extension-file-format-document-file-format-svgrepo-com.svg",
67
+ "doc" => "#{MINIMAL_ICON_PATH}" \
68
+ "extension-file-format-document-file-format-svgrepo-com.svg",
69
+ "pptx" => "#{MINIMAL_ICON_PATH}" \
70
+ "extension-file-format-document-file-format-2-svgrepo-com.svg",
71
+ "ppt" => "#{MINIMAL_ICON_PATH}" \
72
+ "extension-file-format-document-file-format-2-svgrepo-com.svg",
73
+ "xlsx" => "#{MINIMAL_ICON_PATH}" \
74
+ "extension-file-format-document-file-format-2-svgrepo-com.svg",
75
+ "xls" => "#{MINIMAL_ICON_PATH}" \
76
+ "extension-file-format-document-file-format-2-svgrepo-com.svg",
77
+ "odt" => "#{MINIMAL_ICON_PATH}" \
78
+ "extension-file-format-document-file-format-svgrepo-com.svg",
79
+ "ods" => "#{MINIMAL_ICON_PATH}" \
80
+ "extension-file-format-document-file-format-2-svgrepo-com.svg",
81
+ "odp" => "#{MINIMAL_ICON_PATH}" \
82
+ "extension-file-format-document-file-format-2-svgrepo-com.svg",
83
+ "txt" => "#{MINIMAL_ICON_PATH}" \
84
+ "extension-file-format-txt-document-file-format-svgrepo-com.svg",
85
+ "zip" => "#{MINIMAL_ICON_PATH}" \
86
+ "extension-file-format-document-file-format-2-svgrepo-com.svg",
87
+ "mp3" => "#{MINIMAL_ICON_PATH}" \
88
+ "extension-file-format-document-file-format-2-svgrepo-com.svg",
89
+ "mp4" => "#{MINIMAL_ICON_PATH}" \
90
+ "extension-file-format-document-file-format-2-svgrepo-com.svg",
91
+ "jpg" => "#{MINIMAL_ICON_PATH}" \
92
+ "extension-file-format-jpg-document-file-format-svgrepo-com.svg",
93
+ "jpeg" => "#{MINIMAL_ICON_PATH}" \
94
+ "file-format-jpeg-document-extension-file-format-svgrepo-com.svg",
95
+ "png" => "#{MINIMAL_ICON_PATH}" \
96
+ "extension-file-format-document-file-format-2-svgrepo-com.svg"
77
97
  },
78
98
  "ultra-minimal" => {
79
99
  "pdf" => "/assets/icons/ultra-minimal/pdf.svg",
@@ -95,16 +115,25 @@ module Jekyll
95
115
  }
96
116
  }.freeze
97
117
 
118
+ UNKNOWN_ICON_MAP = {
119
+ "color" => "/assets/icons/color/unknown-document-svgrepo-com.svg",
120
+ "lines" => "#{LINES_ICON_PATH}other-file-type-svgrepo-com.svg",
121
+ "minimal" => "#{MINIMAL_ICON_PATH}" \
122
+ "extension-file-format-document-file-format-2-svgrepo-com.svg",
123
+ "ultra-minimal" => "/assets/icons/ultra-minimal/file.svg"
124
+ }.freeze
125
+
98
126
  # Returns the icon URL for a given file type and explicit icon set
99
127
  # @param file_type [String] the file extension (e.g., 'pdf', 'docx')
100
128
  # @param icon_set [String] the icon set name (defaults to 'color')
101
129
  # @return [String] the icon file URL
102
130
  # @example
103
- # FileTypeIcons.icon_for('pdf', 'lines') #=> "/assets/icons/lines/pdf-svgrepo-com.svg"
131
+ # FileTypeIcons.icon_for('pdf', 'lines') #=>
132
+ # "/assets/icons/lines/pdf-file-type-svgrepo-com.svg"
104
133
  def self.icon_for(file_type, icon_set = "color")
105
- icons = ICON_MAP[icon_set] || ICON_MAP["color"]
106
- icons[file_type.to_s.downcase] ||
107
- "/assets/icons/#{icon_set}/unknown-document-svgrepo-com.svg"
134
+ selected_set = ICON_MAP.key?(icon_set) ? icon_set : "color"
135
+ icons = ICON_MAP[selected_set]
136
+ icons[file_type.to_s.downcase] || UNKNOWN_ICON_MAP[selected_set]
108
137
  end
109
138
 
110
139
  # Returns the icon URL for a given file type using configured icon set
@@ -58,13 +58,14 @@ module Jekyll
58
58
  data = doc.data
59
59
  data["layout"] = @config["layout"]
60
60
  data["title"] = title
61
- data["date"] = date || File.mtime(path)
61
+ data["date"] = date ? date.to_time : File.mtime(path)
62
62
  data["category"] = remap_category(category)
63
63
  data["file_url"] = "/#{rel_path}"
64
64
  data["extension"] = ext
65
65
  data["file_type"] = file_type
66
66
  data["icon_set"] = icon_set
67
67
  data["icon_url"] = Jekyll::Documents::FileTypeIcons.icon_for(file_type, icon_set)
68
+ data["file_size"] = File.size(path)
68
69
  data["slug"] = slug
69
70
  data["permalink"] = @config["permalink"]
70
71
  .gsub(":category", data["category"].to_s)
@@ -25,7 +25,10 @@ module Jekyll
25
25
  "date" => (data["date"] || Time.at(0)).strftime("%Y-%m-%d"),
26
26
  "slug" => data["slug"],
27
27
  "file_type" => data["file_type"],
28
- "extension" => data["extension"]
28
+ "extension" => data["extension"],
29
+ "icon_url" => data["icon_url"],
30
+ "file_url" => data["file_url"],
31
+ "file_size" => data["file_size"]
29
32
  }
30
33
  end
31
34
 
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Jekyll
6
+ module Documents
7
+ # Registers gem-packaged _layouts and _includes with the site so Jekyll can
8
+ # discover them without the gem being declared as a theme.
9
+ #
10
+ # Jekyll only auto-discovers _layouts and _includes from theme gems (set via
11
+ # the +theme:+ config key). Plugin gems loaded via +group :jekyll_plugins+
12
+ # do not get their template directories added to Jekyll's lookup paths.
13
+ #
14
+ # This hook copies any gem-packaged layouts and includes into the site's
15
+ # source directories when the user has not provided their own. Files already
16
+ # present in the site take precedence (user overrides are never clobbered).
17
+ module LayoutRegistrar
18
+ GEM_ROOT = File.expand_path("../../..", __dir__)
19
+ TEMPLATE_DIRS = %w[_layouts _includes].freeze
20
+
21
+ class << self
22
+ # Copies gem-packaged template files into the site source.
23
+ # @param site [Jekyll::Site] the Jekyll site instance
24
+ # @return [void]
25
+ def register(site)
26
+ TEMPLATE_DIRS.each do |dir|
27
+ gem_dir = File.join(GEM_ROOT, dir)
28
+ next unless Dir.exist?(gem_dir)
29
+
30
+ copy_templates(gem_dir, File.join(site.source, dir))
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Recursively copies files from +source_dir+ to +dest_dir+, skipping any
37
+ # file that already exists in the destination (user overrides win).
38
+ # @param source_dir [String] gem template directory
39
+ # @param dest_dir [String] site template directory
40
+ # @return [void]
41
+ def copy_templates(source_dir, dest_dir)
42
+ Dir.glob(File.join(source_dir, "**", "*")).each do |path|
43
+ next unless File.file?(path)
44
+
45
+ relative = path.delete_prefix("#{source_dir}/")
46
+ destination = File.join(dest_dir, relative)
47
+ next if File.exist?(destination)
48
+
49
+ FileUtils.mkdir_p(File.dirname(destination))
50
+ FileUtils.cp(path, destination)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ Jekyll::Hooks.register :site, :after_init do |site|
59
+ Jekyll::Documents::LayoutRegistrar.register(site)
60
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Documents
5
+ class DocCategoryTag < Liquid::Tag
6
+ public_class_method :new
7
+
8
+ include Jekyll::Filters::URLFilters
9
+
10
+ def initialize(tag_name, markup, tokens)
11
+ super
12
+ @category, @options = parse_markup(markup)
13
+ end
14
+
15
+ def render(context)
16
+ docs = documents_from(context)
17
+ return "" if docs.empty?
18
+
19
+ categories = available_categories(docs)
20
+ category = resolve_category(@category, categories)
21
+ return "" unless category
22
+
23
+ @context = context
24
+ return render_list(docs, category) if @options["list"] == "true"
25
+
26
+ render_link(category)
27
+ end
28
+
29
+ private
30
+
31
+ def documents_from(context)
32
+ site = context.registers[:site]
33
+ site.collections["documents"]&.docs || []
34
+ end
35
+
36
+ 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) }
46
+ end
47
+
48
+ def render_link(category)
49
+ url = relative_url("/documents/#{category}/")
50
+ text = @options["text"] || category
51
+ %(<a href="#{escape_html(url)}">#{escape_html(text)}</a>)
52
+ end
53
+
54
+ def render_list(docs, category)
55
+ cat_docs = sorted_category_docs(docs, category)
56
+ limit = @options["limit"]&.to_i
57
+ cat_docs = cat_docs.first(limit) if limit&.positive?
58
+
59
+ out = %(<ul class="doc-category-list" data-category="#{escape_html(category)}">\n)
60
+ out << list_items(cat_docs)
61
+ out << "</ul>\n"
62
+ end
63
+
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
67
+ end
68
+
69
+ def list_items(cat_docs)
70
+ cat_docs.each_with_object(+"") do |doc, out|
71
+ title = escape_html(doc.data["title"])
72
+ url = escape_html(relative_url(doc.url))
73
+ date = (doc.data["date"] || Time.at(0)).strftime("%Y-%m-%d")
74
+ out << %(<li><a href="#{url}">#{title}</a> <small>(#{date})</small></li>\n)
75
+ end
76
+ end
77
+
78
+ def parse_markup(markup)
79
+ text = markup.to_s
80
+ category, remainder = extract_category(text)
81
+ options = extract_options(remainder)
82
+ [category, options]
83
+ end
84
+
85
+ def extract_category(text)
86
+ quoted = text.match(/\A["']([^"']+)["']/)
87
+ return [quoted[1], text[quoted.end(0)..]] if quoted
88
+
89
+ bare = text.strip.match(/\A([^\s]+)/)
90
+ return [nil, ""] unless bare
91
+
92
+ [bare[1], text[bare.end(0)..]]
93
+ end
94
+
95
+ def extract_options(text)
96
+ options = {}
97
+ text.to_s.scan(
98
+ /(\w+)\s*:\s*(?:'([^']*)'|"([^"]*)"|([^\s]+))/
99
+ ) do |key, single, double, bare|
100
+ options[key] = single || double || bare
101
+ end
102
+ options
103
+ end
104
+
105
+ def escape_html(value)
106
+ value.to_s.gsub(/[&<>"']/,
107
+ "&" => "&amp;",
108
+ "<" => "&lt;",
109
+ ">" => "&gt;",
110
+ '"' => "&quot;",
111
+ "'" => "&#39;")
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ Liquid::Template.register_tag("doc_category", Jekyll::Documents::DocCategoryTag)
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Documents
5
+ class DocLinkTag < Liquid::Tag
6
+ public_class_method :new
7
+
8
+ include Jekyll::Filters::URLFilters
9
+
10
+ SIZE_UNITS = %w[B KB MB GB].freeze
11
+
12
+ def initialize(tag_name, markup, tokens)
13
+ super
14
+ @query, @options = parse_markup(markup)
15
+ end
16
+
17
+ def render(context)
18
+ docs = documents_from(context)
19
+ return "" if docs.empty?
20
+
21
+ match = find_document(docs, @query)
22
+ return "" unless match
23
+
24
+ @context = context
25
+ url = relative_url(match.url)
26
+ text = @options["text"] || match.data["title"]
27
+
28
+ build_link(match, url, text)
29
+ end
30
+
31
+ private
32
+
33
+ def documents_from(context)
34
+ site = context.registers[:site]
35
+ site.collections["documents"]&.docs || []
36
+ end
37
+
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
+ def build_link(doc, url, text)
53
+ inner = +""
54
+ inner << icon_html(doc) if @options["icon"] != "false"
55
+ inner << %(<span class="doc-link-title">#{escape_html(text)}</span>)
56
+ inner << size_html(doc) if @options["size"] != "false"
57
+ %(<a href="#{escape_html(url)}" class="doc-link">#{inner}</a>)
58
+ end
59
+
60
+ def icon_html(doc)
61
+ icon_url = doc.data["icon_url"]
62
+ return "" unless icon_url
63
+
64
+ url = relative_url(icon_url)
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\" />"
67
+ end
68
+
69
+ def size_html(doc)
70
+ size = doc.data["file_size"]
71
+ return "" unless size
72
+
73
+ "<span class=\"doc-link-size has-text-grey is-size-7\">#{format_size(size)}</span>"
74
+ end
75
+
76
+ def format_size(bytes)
77
+ return "0 B" unless bytes&.positive?
78
+
79
+ value, unit = compute_size(bytes)
80
+ unit == "B" ? "#{value.to_i} B" : format("%<value>.1f %<unit>s", value:, unit:)
81
+ end
82
+
83
+ def compute_size(bytes)
84
+ value = bytes.to_f
85
+ SIZE_UNITS.each do |unit|
86
+ return [value, unit] if value < 1024 || unit == SIZE_UNITS.last
87
+
88
+ value /= 1024
89
+ end
90
+ end
91
+
92
+ def parse_markup(markup)
93
+ text = markup.to_s
94
+ query, remainder = extract_query(text)
95
+ options = extract_options(remainder)
96
+ [query, options]
97
+ end
98
+
99
+ def extract_query(text)
100
+ quoted = text.match(/\A["']([^"']+)["']/)
101
+ return [quoted[1], text[quoted.end(0)..]] if quoted
102
+
103
+ bare = text.strip.match(/\A([^\s]+)/)
104
+ return [nil, ""] unless bare
105
+
106
+ [bare[1], text[bare.end(0)..]]
107
+ end
108
+
109
+ def extract_options(text)
110
+ options = {}
111
+ text.to_s.scan(
112
+ /(\w+)\s*:\s*(?:'([^']*)'|"([^"]*)"|([^\s]+))/
113
+ ) do |key, single, double, bare|
114
+ options[key] = single || double || bare
115
+ end
116
+ options
117
+ end
118
+
119
+ def escape_html(value)
120
+ value.to_s.gsub(/[&<>"']/,
121
+ "&" => "&amp;",
122
+ "<" => "&lt;",
123
+ ">" => "&gt;",
124
+ '"' => "&quot;",
125
+ "'" => "&#39;")
126
+ end
127
+ end
128
+ end
129
+ end
130
+
131
+ Liquid::Template.register_tag("doc_link", Jekyll::Documents::DocLinkTag)
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Documents
5
+ class DocumentIconTag < Liquid::Tag
6
+ public_class_method :new
7
+
8
+ include Jekyll::Filters::URLFilters
9
+
10
+ def initialize(tag_name, markup, tokens)
11
+ super
12
+ @document_expression, @options = parse_markup(markup)
13
+ end
14
+
15
+ def render(context)
16
+ document = context.find_variable(@document_expression)
17
+ return "" unless document
18
+
19
+ @context = context
20
+ icon_url = document_value(document, "icon_url") || fallback_icon(document, context)
21
+ url = relative_url(icon_url)
22
+ file_type = document_value(document, "file_type").to_s
23
+ alt = @options["alt"] || "#{file_type.upcase} file"
24
+ css_class = @options["class"] || "file-icon"
25
+
26
+ %(<img src="#{escape_html(url)}" alt="#{escape_html(alt)}" ) \
27
+ + %(class="#{escape_html(css_class)}" />)
28
+ end
29
+
30
+ private
31
+
32
+ def document_value(document, key)
33
+ return document.data[key] if document.respond_to?(:data)
34
+ return document[key] if document.respond_to?(:[])
35
+
36
+ nil
37
+ end
38
+
39
+ def fallback_icon(document, context)
40
+ file_type = document_value(document, "file_type")
41
+ icon_set = Configuration.read(context.registers[:site])["icon_set"]
42
+ FileTypeIcons.icon_for(file_type, icon_set)
43
+ end
44
+
45
+ def parse_markup(markup)
46
+ expression = markup.to_s.strip[/\A[^\s]+/]
47
+ options = {}
48
+ markup.to_s.scan(
49
+ /(\w+)\s*:\s*(?:'([^']*)'|"([^"]*)"|([^\s]+))/
50
+ ) do |key, single, double, bare|
51
+ options[key] = single || double || bare
52
+ end
53
+ [expression, options]
54
+ end
55
+
56
+ def escape_html(value)
57
+ value.to_s.gsub(/[&<>"']/,
58
+ "&" => "&amp;",
59
+ "<" => "&lt;",
60
+ ">" => "&gt;",
61
+ '"' => "&quot;",
62
+ "'" => "&#39;")
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ Liquid::Template.register_tag("document_icon", Jekyll::Documents::DocumentIconTag)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Jekyll
4
4
  module Documents
5
- VERSION = "0.3.0"
5
+ VERSION = "0.3.3"
6
6
  end
7
7
  end
@@ -7,10 +7,15 @@ 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/assets_generator"
10
11
  require_relative "jekyll/documents/json_index_generator"
12
+ require_relative "jekyll/documents/layout_registrar"
11
13
 
12
14
  # Liquid tag(s)
13
15
  require_relative "jekyll/documents/tags/latest_documents"
16
+ require_relative "jekyll/documents/tags/document_icon"
17
+ require_relative "jekyll/documents/tags/doc_link"
18
+ require_relative "jekyll/documents/tags/doc_category"
14
19
 
15
20
  module Jekyll
16
21
  module Documents
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-documents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svend Gundestrup
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-30 00:00:00.000000000 Z
11
+ date: 2026-08-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jekyll
@@ -16,14 +16,20 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '4.0'
19
+ version: '4.4'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '5.0'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
27
  - - ">="
25
28
  - !ruby/object:Gem::Version
26
- version: '4.0'
29
+ version: '4.4'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: bundler-audit
29
35
  requirement: !ruby/object:Gem::Requirement
@@ -58,56 +64,56 @@ dependencies:
58
64
  requirements:
59
65
  - - "~>"
60
66
  - !ruby/object:Gem::Version
61
- version: '6.1'
67
+ version: '6.5'
62
68
  type: :development
63
69
  prerelease: false
64
70
  version_requirements: !ruby/object:Gem::Requirement
65
71
  requirements:
66
72
  - - "~>"
67
73
  - !ruby/object:Gem::Version
68
- version: '6.1'
74
+ version: '6.5'
69
75
  - !ruby/object:Gem::Dependency
70
76
  name: rspec
71
77
  requirement: !ruby/object:Gem::Requirement
72
78
  requirements:
73
79
  - - "~>"
74
80
  - !ruby/object:Gem::Version
75
- version: '3.12'
81
+ version: '3.13'
76
82
  type: :development
77
83
  prerelease: false
78
84
  version_requirements: !ruby/object:Gem::Requirement
79
85
  requirements:
80
86
  - - "~>"
81
87
  - !ruby/object:Gem::Version
82
- version: '3.12'
88
+ version: '3.13'
83
89
  - !ruby/object:Gem::Dependency
84
90
  name: rubocop
85
91
  requirement: !ruby/object:Gem::Requirement
86
92
  requirements:
87
93
  - - "~>"
88
94
  - !ruby/object:Gem::Version
89
- version: '1.60'
95
+ version: '1.88'
90
96
  type: :development
91
97
  prerelease: false
92
98
  version_requirements: !ruby/object:Gem::Requirement
93
99
  requirements:
94
100
  - - "~>"
95
101
  - !ruby/object:Gem::Version
96
- version: '1.60'
102
+ version: '1.88'
97
103
  - !ruby/object:Gem::Dependency
98
104
  name: rubocop-performance
99
105
  requirement: !ruby/object:Gem::Requirement
100
106
  requirements:
101
107
  - - "~>"
102
108
  - !ruby/object:Gem::Version
103
- version: '1.20'
109
+ version: '1.26'
104
110
  type: :development
105
111
  prerelease: false
106
112
  version_requirements: !ruby/object:Gem::Requirement
107
113
  requirements:
108
114
  - - "~>"
109
115
  - !ruby/object:Gem::Version
110
- version: '1.20'
116
+ version: '1.26'
111
117
  - !ruby/object:Gem::Dependency
112
118
  name: simplecov
113
119
  requirement: !ruby/object:Gem::Requirement
@@ -253,11 +259,16 @@ files:
253
259
  - assets/js/documents-search.js
254
260
  - jekyll-documents.gemspec
255
261
  - lib/jekyll-documents.rb
262
+ - lib/jekyll/documents/assets_generator.rb
256
263
  - lib/jekyll/documents/configuration.rb
257
264
  - lib/jekyll/documents/file_type_icons.rb
258
265
  - lib/jekyll/documents/filters.rb
259
266
  - lib/jekyll/documents/generator.rb
260
267
  - lib/jekyll/documents/json_index_generator.rb
268
+ - lib/jekyll/documents/layout_registrar.rb
269
+ - lib/jekyll/documents/tags/doc_category.rb
270
+ - lib/jekyll/documents/tags/doc_link.rb
271
+ - lib/jekyll/documents/tags/document_icon.rb
261
272
  - lib/jekyll/documents/tags/latest_documents.rb
262
273
  - lib/jekyll/documents/utils.rb
263
274
  - lib/jekyll/documents/version.rb
@@ -279,14 +290,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
279
290
  requirements:
280
291
  - - ">="
281
292
  - !ruby/object:Gem::Version
282
- version: '3.1'
293
+ version: '3.3'
283
294
  required_rubygems_version: !ruby/object:Gem::Requirement
284
295
  requirements:
285
296
  - - ">="
286
297
  - !ruby/object:Gem::Version
287
298
  version: '0'
288
299
  requirements: []
289
- rubygems_version: 3.4.19
300
+ rubygems_version: 3.5.22
290
301
  signing_key:
291
302
  specification_version: 4
292
303
  summary: Auto-generate Jekyll pages for documents (PDF/DOCX/...) with category/date/title