pandorga 1.11.0 → 1.13.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.
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "fileutils"
5
+ require "open3"
6
+ require "pathname"
7
+ require "set"
8
+ require "time"
9
+ require "yaml"
10
+
11
+ require_relative "registry"
12
+
13
+ module Pandorga
14
+ # Generates 16:9 center-cropped width variants for local project thumbnails
15
+ # and computes their srcset strings.
16
+ # Refs: PLT-AC-44
17
+ module ThumbnailVariants
18
+ WIDTHS = [480, 960, 1440].freeze
19
+ IMAGE_EXTENSIONS = %w[.jpg .jpeg .png .webp].freeze
20
+ QUALITY = 82
21
+
22
+ module_function
23
+
24
+ def generate(site_root)
25
+ root = Pathname.new(site_root).expand_path
26
+ content_root = resolve_content_root(root)
27
+ sources = project_thumbnail_sources(root, content_root)
28
+
29
+ summary = { sources: sources.length, written: 0, skipped: 0, failed: 0 }
30
+ return summary if sources.empty?
31
+
32
+ bins = resolve_imagemagick
33
+ unless bins
34
+ warn "warning: ImageMagick (magick or convert) not found; skipping project thumbnail variants"
35
+ return summary
36
+ end
37
+
38
+ sources.each do |src|
39
+ src_width = identify_width(bins[:identify], src)
40
+ unless src_width && src_width.positive?
41
+ # An unreadable image keeps its original and must not stop the export.
42
+ warn "warning: cannot read #{src}; skipping its thumbnail variants"
43
+ summary[:failed] += 1
44
+ next
45
+ end
46
+
47
+ stem = src.basename(src.extname).to_s
48
+ variants_dir = src.dirname.join("variants")
49
+ src_mtime = src.mtime
50
+
51
+ WIDTHS.each do |width|
52
+ if width > src_width
53
+ summary[:skipped] += 1
54
+ next
55
+ end
56
+
57
+ dest = variants_dir.join("#{stem}-#{width}.jpg")
58
+ if dest.file? && dest.mtime >= src_mtime
59
+ summary[:skipped] += 1
60
+ next
61
+ end
62
+
63
+ begin
64
+ write_variant(bins[:convert], src, dest, width)
65
+ summary[:written] += 1
66
+ rescue StandardError => e
67
+ warn "warning: #{e.message.lines.first.to_s.strip}; keeping the original only"
68
+ summary[:failed] += 1
69
+ end
70
+ end
71
+ end
72
+
73
+ summary
74
+ end
75
+
76
+ def srcset_for(site_root, thumbnail_path)
77
+ media_rel = normalize_local_media_path(thumbnail_path)
78
+ return nil unless media_rel
79
+
80
+ ext = File.extname(media_rel).downcase
81
+ return nil unless IMAGE_EXTENSIONS.include?(ext)
82
+
83
+ root = Pathname.new(site_root).expand_path
84
+ content_root = resolve_content_root(root)
85
+ src = content_root.join(media_rel.delete_prefix("/"))
86
+ return nil unless src.file?
87
+
88
+ stem = src.basename(src.extname).to_s
89
+ variants_dir = src.dirname.join("variants")
90
+ return nil unless variants_dir.directory?
91
+
92
+ url_dir = File.dirname(media_rel)
93
+ parts = WIDTHS.filter_map do |width|
94
+ variant = variants_dir.join("#{stem}-#{width}.jpg")
95
+ next unless variant.file?
96
+
97
+ "#{url_dir}/variants/#{stem}-#{width}.jpg #{width}w"
98
+ end
99
+ return nil if parts.empty?
100
+
101
+ parts.join(", ")
102
+ end
103
+
104
+ def resolve_content_root(root)
105
+ candidate = root.join("content")
106
+ return candidate if candidate.directory?
107
+ return root if root.join("collections").directory? || root.join("media").directory?
108
+
109
+ candidate
110
+ end
111
+
112
+ def project_collections(root)
113
+ config_path = root.join("_config.yml")
114
+ config = if config_path.file?
115
+ YAML.safe_load(config_path.read, permitted_classes: [Date, Time], aliases: true) || {}
116
+ else
117
+ {}
118
+ end
119
+ pages = PandorgaRegistry.pages(config)
120
+ names = pages.filter_map do |page|
121
+ next unless page.is_a?(Hash) && page["template"].to_s == "portfolio"
122
+
123
+ col = page["collection"].to_s.strip
124
+ col = page.dig("collections", "projects").to_s.strip if col.empty? && page["collections"].is_a?(Hash)
125
+ col unless col.empty?
126
+ end.uniq
127
+ names.empty? ? ["projects"] : names
128
+ rescue StandardError
129
+ ["projects"]
130
+ end
131
+
132
+ def project_thumbnail_sources(root, content_root)
133
+ seen = Set.new
134
+ sources = []
135
+
136
+ project_collections(root).each do |collection|
137
+ dir = content_root.join("collections", collection)
138
+ next unless dir.directory?
139
+
140
+ dir.glob("**/*.{md,markdown}").sort.each do |md_path|
141
+ fm = read_front_matter(md_path)
142
+ next if fm["published"] == false && ENV["CONTENT_EXPORT_INCLUDE_UNPUBLISHED"] != "1"
143
+
144
+ media_rel = normalize_local_media_path(fm["thumbnail"])
145
+ next unless media_rel
146
+ next unless IMAGE_EXTENSIONS.include?(File.extname(media_rel).downcase)
147
+
148
+ src = content_root.join(media_rel.delete_prefix("/"))
149
+ next unless src.file?
150
+
151
+ key = src.expand_path.to_s
152
+ next if seen.include?(key)
153
+
154
+ seen << key
155
+ sources << src
156
+ end
157
+ end
158
+
159
+ sources
160
+ end
161
+
162
+ def normalize_local_media_path(path)
163
+ value = path.to_s.strip
164
+ return nil if value.empty?
165
+ return nil if value.match?(%r{\Ahttps?://}i)
166
+
167
+ if value.start_with?("/content/media/")
168
+ "/media/#{value.delete_prefix("/content/media/")}"
169
+ elsif value.start_with?("content/media/")
170
+ "/media/#{value.delete_prefix("content/media/")}"
171
+ elsif value.start_with?("/media/")
172
+ value
173
+ elsif value.start_with?("media/")
174
+ "/#{value}"
175
+ end
176
+ end
177
+
178
+ def read_front_matter(path)
179
+ text = path.read.sub(/\A\xEF\xBB\xBF/, "")
180
+ return {} unless text.start_with?("---\n") || text.start_with?("---\r\n")
181
+
182
+ parts = text.split(/^---\s*$\r?\n?/, 3)
183
+ return {} unless parts.length == 3
184
+
185
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time], aliases: true) || {}
186
+ rescue StandardError
187
+ {}
188
+ end
189
+
190
+ def resolve_imagemagick
191
+ override = ENV["PANDORGA_IMAGEMAGICK"].to_s.strip
192
+ unless override.empty?
193
+ return nil unless executable_available?(override)
194
+
195
+ return {
196
+ convert: [override],
197
+ identify: File.basename(override) == "magick" ? [override, "identify"] : [ resolve_companion_identify(override) ]
198
+ }
199
+ end
200
+
201
+ if executable_available?("magick")
202
+ { convert: ["magick"], identify: ["magick", "identify"] }
203
+ elsif executable_available?("convert") && executable_available?("identify")
204
+ { convert: ["convert"], identify: ["identify"] }
205
+ end
206
+ end
207
+
208
+ def resolve_companion_identify(convert_bin)
209
+ sibling = File.join(File.dirname(convert_bin), "identify")
210
+ return sibling if File.executable?(sibling) && !File.directory?(sibling)
211
+
212
+ "identify"
213
+ end
214
+
215
+ def executable_available?(bin)
216
+ return false if bin.to_s.empty?
217
+
218
+ if bin.include?("/")
219
+ File.executable?(bin) && !File.directory?(bin)
220
+ else
221
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
222
+ next false if dir.empty?
223
+
224
+ candidate = File.join(dir, bin)
225
+ File.executable?(candidate) && !File.directory?(candidate)
226
+ end
227
+ end
228
+ end
229
+
230
+ def identify_width(identify_cmd, src)
231
+ cmd = [*identify_cmd, "-ping", "-format", "%w", "#{src}[0]"]
232
+ out, _err, status = Open3.capture3(*cmd)
233
+ return nil unless status.success?
234
+
235
+ out.to_s.strip.to_i
236
+ rescue StandardError
237
+ nil
238
+ end
239
+
240
+ def write_variant(convert_cmd, src, dest, width)
241
+ height = (width * 9) / 16
242
+ FileUtils.mkdir_p(dest.dirname)
243
+ tmp = dest.dirname.join(".#{dest.basename}.tmp-#{Process.pid}")
244
+ cmd = [
245
+ *convert_cmd,
246
+ "#{src}[0]",
247
+ "-auto-orient",
248
+ "-resize", "#{width}x#{height}^",
249
+ "-gravity", "center",
250
+ "-extent", "#{width}x#{height}",
251
+ "-strip",
252
+ "-interlace", "JPEG",
253
+ "-sampling-factor", "4:2:0",
254
+ "-quality", QUALITY.to_s,
255
+ "jpeg:#{tmp}"
256
+ ]
257
+ out, err, status = Open3.capture3(*cmd)
258
+ unless status.success? && tmp.file?
259
+ FileUtils.rm_f(tmp)
260
+ raise "ImageMagick failed for #{src} (#{width}w): #{out}\n#{err}"
261
+ end
262
+ File.rename(tmp.to_s, dest.to_s)
263
+ ensure
264
+ FileUtils.rm_f(tmp) if tmp && tmp.exist?
265
+ end
266
+ end
267
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "1.11.0"
4
+ VERSION = "1.13.0"
5
5
  end
data/lib/pandorga.rb CHANGED
@@ -14,6 +14,7 @@ module Pandorga
14
14
  end
15
15
 
16
16
  require_relative "pandorga/registry"
17
+ require_relative "pandorga/thumbnail_variants"
17
18
 
18
19
  # Jekyll loads this file as a plugin when `plugins: [pandorga]`.
19
20
  require_relative "pandorga/jekyll" if defined?(Jekyll)
@@ -21,6 +21,7 @@ require_relative "../lib/content_license"
21
21
  require_relative "../lib/writing_index_contract"
22
22
  require_relative "band_art"
23
23
  require_relative "hero_portraits"
24
+ require_relative "../../lib/pandorga/thumbnail_variants"
24
25
 
25
26
  REPO_ROOT = Pathname.new(ENV.fetch("PANDORGA_SITE_ROOT", Dir.pwd)).expand_path
26
27
  CONTENT_ROOT = Pathname.new(ARGV[0] || "content").expand_path
@@ -121,6 +122,16 @@ def rewrite_media_references(text)
121
122
  rewrite_media_attribute_paths(rewrite_markdown_media_paths(text))
122
123
  end
123
124
 
125
+ def rewrite_srcset_media_urls(srcset)
126
+ return nil unless srcset.is_a?(String) && !srcset.strip.empty?
127
+
128
+ srcset.split(",").map do |candidate|
129
+ url, descriptor = candidate.strip.split(/\s+/, 2)
130
+ resolved = resolve_media_url(url)
131
+ descriptor ? "#{resolved} #{descriptor}" : resolved
132
+ end.join(", ")
133
+ end
134
+
124
135
  def rewrite_front_matter_media!(front_matter)
125
136
  return front_matter unless front_matter.is_a?(Hash)
126
137
 
@@ -360,6 +371,11 @@ def export_markdown_tree(source_dir, output_dir, type)
360
371
  end
361
372
 
362
373
  apply_git_chronology!(front_matter, repo_relative_path, type)
374
+ if type == "project"
375
+ raw_srcset = Pandorga::ThumbnailVariants.srcset_for(REPO_ROOT, front_matter["thumbnail"])
376
+ rewritten_srcset = rewrite_srcset_media_urls(raw_srcset)
377
+ front_matter["thumbnail_srcset"] = rewritten_srcset if rewritten_srcset
378
+ end
363
379
  rewrite_front_matter_media!(front_matter)
364
380
 
365
381
  if ContentKeys::KEYED_TYPES.include?(type)
@@ -594,7 +610,7 @@ def writing_index_entry_from_export(payload, content_root, collection)
594
610
  end
595
611
 
596
612
  legacy = File.basename(source_path, File.extname(source_path))
597
- {
613
+ entry = {
598
614
  "slug" => slug,
599
615
  "key" => key,
600
616
  "title" => title,
@@ -630,6 +646,9 @@ def writing_index_entry_from_export(payload, content_root, collection)
630
646
  "path_segment" => slug
631
647
  )
632
648
  }
649
+ exported_srcset = payload.dig("front_matter", "thumbnail_srcset").to_s
650
+ entry["thumbnail_srcset"] = exported_srcset unless exported_srcset.empty?
651
+ entry
633
652
  end
634
653
 
635
654
  def export_derived_data(content_root, output_root)
@@ -710,6 +729,8 @@ def export_collection_trees
710
729
  trees
711
730
  end
712
731
 
732
+ Pandorga::ThumbnailVariants.generate(REPO_ROOT)
733
+
713
734
  files = export_collection_trees
714
735
  files["page_fragments"] = export_page_fragments(CONTENT_ROOT.join("pages"), OUTPUT_ROOT.join("pages"))
715
736
  files["page_headers"] = export_page_headers(CONTENT_ROOT, OUTPUT_ROOT) << export_hero_portraits(CONTENT_ROOT, OUTPUT_ROOT)
@@ -0,0 +1,9 @@
1
+ pandorga:
2
+ pages:
3
+ - key: articles
4
+ template: articles
5
+ collection: articles
6
+ path: /pages/articles/
7
+ taxonomy:
8
+ tags: [Notes]
9
+ max: 1
@@ -0,0 +1,9 @@
1
+ pandorga:
2
+ taxonomies:
3
+ articles:
4
+ tags: [Design, Organizations]
5
+ max: 2
6
+ min: 1
7
+ posts:
8
+ tags: [alpha, beta, gamma]
9
+ max: 3
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ // Refs: PLT-AC-44
3
+ // Executes the real renderPosterMedia sliced out of
4
+ // _includes/content-runtime/43-blocks.html rather than a copy of it.
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
11
+ const utilsSource = fs.readFileSync(
12
+ path.join(root, '_includes/content-runtime/10-utils.html'),
13
+ 'utf8'
14
+ );
15
+ const blocksSource = fs.readFileSync(
16
+ path.join(root, '_includes/content-runtime/43-blocks.html'),
17
+ 'utf8'
18
+ );
19
+
20
+ const escStart = utilsSource.indexOf('function escapeHtml');
21
+ const escEnd = utilsSource.indexOf('function shellOrigin');
22
+ if (escStart < 0 || escEnd <= escStart) {
23
+ console.error('FAIL test-poster-srcset: escapeHtml/escapeAttr missing from 10-utils.html');
24
+ process.exit(1);
25
+ }
26
+ const escSlice = utilsSource.slice(escStart, escEnd);
27
+
28
+ const posterStart = blocksSource.indexOf('function renderPosterMedia');
29
+ const posterEnd = blocksSource.indexOf('function twoDigits');
30
+ if (posterStart < 0 || posterEnd <= posterStart) {
31
+ console.error('FAIL test-poster-srcset: renderPosterMedia missing from 43-blocks.html');
32
+ process.exit(1);
33
+ }
34
+ const posterSlice = blocksSource.slice(posterStart, posterEnd);
35
+
36
+ const renderPosterMedia = new Function(
37
+ `${escSlice}\n${posterSlice}\nreturn renderPosterMedia;`
38
+ )();
39
+
40
+ let failed = 0;
41
+
42
+ function check(condition, message) {
43
+ if (!condition) {
44
+ console.error(`FAIL test-poster-srcset: ${message}`);
45
+ failed += 1;
46
+ }
47
+ }
48
+
49
+ function same(got, expected, message) {
50
+ check(got === expected, `${message}: got ${JSON.stringify(got)}, want ${JSON.stringify(expected)}`);
51
+ }
52
+
53
+ // 1. Without srcset/sizes: byte-identical to today's output
54
+ const expectedPlain = [
55
+ '<div class="ledger-poster">',
56
+ '<img src="/media/images/covers/wide.jpg" alt="Wide Project" loading="lazy" decoding="async"',
57
+ ' onerror="var p=this.closest(\'.ledger-poster\'); if (p) p.remove();">',
58
+ '</div>'
59
+ ].join('');
60
+
61
+ same(
62
+ renderPosterMedia('/media/images/covers/wide.jpg', 'Wide Project'),
63
+ expectedPlain,
64
+ 'plain image without options'
65
+ );
66
+ same(
67
+ renderPosterMedia('/media/images/covers/wide.jpg', 'Wide Project', {}),
68
+ expectedPlain,
69
+ 'plain image with empty options'
70
+ );
71
+ same(
72
+ renderPosterMedia('/media/images/covers/wide.jpg', 'Wide Project', {
73
+ srcset: ' ',
74
+ sizes: '(min-width: 900px) 35vw, 100vw'
75
+ }),
76
+ expectedPlain,
77
+ 'empty srcset omits both srcset and sizes'
78
+ );
79
+
80
+ // 2. With srcset + sizes: img has src = original, srcset and sizes attributes (escaped)
81
+ const srcsetVal = '/media/images/covers/variants/wide-480.jpg 480w, /media/images/covers/variants/wide-960.jpg 960w';
82
+ const sizesVal = '(min-width: 900px) 35vw, 100vw';
83
+ const withSrcset = renderPosterMedia('/media/images/covers/wide.jpg', 'Wide "Quote"', {
84
+ srcset: srcsetVal,
85
+ sizes: sizesVal
86
+ });
87
+
88
+ const expectedWithSrcset = [
89
+ '<div class="ledger-poster">',
90
+ '<img src="/media/images/covers/wide.jpg"',
91
+ ' srcset="/media/images/covers/variants/wide-480.jpg 480w, /media/images/covers/variants/wide-960.jpg 960w"',
92
+ ' sizes="(min-width: 900px) 35vw, 100vw"',
93
+ ' alt="Wide &quot;Quote&quot;" loading="lazy" decoding="async"',
94
+ ' onerror="var p=this.closest(\'.ledger-poster\'); if (p) p.remove();">',
95
+ '</div>'
96
+ ].join('');
97
+
98
+ same(withSrcset, expectedWithSrcset, 'image with srcset and sizes');
99
+
100
+ // Escaping inside srcset and sizes
101
+ const escaped = renderPosterMedia('/media/a.jpg', 'Alt', {
102
+ srcset: '/media/a.jpg?x=1&y="2" 480w',
103
+ sizes: '(max-width: 600px) 100vw, "50vw"'
104
+ });
105
+ check(
106
+ escaped.includes('srcset="/media/a.jpg?x=1&amp;y=&quot;2&quot; 480w"'),
107
+ 'srcset attribute is escaped'
108
+ );
109
+ check(
110
+ escaped.includes('sizes="(max-width: 600px) 100vw, &quot;50vw&quot;"'),
111
+ 'sizes attribute is escaped'
112
+ );
113
+
114
+ // 3. Video URLs ignore srcset and sizes
115
+ const expectedVideo = [
116
+ '<div class="ledger-poster">',
117
+ '<video src="/media/clips/demo.mp4" controls preload="none" ',
118
+ 'playsinline aria-label="Demo"></video>',
119
+ '</div>'
120
+ ].join('');
121
+
122
+ same(
123
+ renderPosterMedia('/media/clips/demo.mp4', 'Demo', {
124
+ srcset: srcsetVal,
125
+ sizes: sizesVal
126
+ }),
127
+ expectedVideo,
128
+ 'video URL ignores srcset and sizes'
129
+ );
130
+
131
+ // 4. Inspect 43-blocks.html call sites for renderSystemCard and renderProjectSpecimen
132
+ const sysCardStart = blocksSource.indexOf('function renderSystemCard');
133
+ const sysCardEnd = blocksSource.indexOf('function renderProjectLinks');
134
+ const specStart = blocksSource.indexOf('function renderProjectSpecimen');
135
+ check(sysCardStart > 0 && sysCardEnd > sysCardStart, 'renderSystemCard found in 43-blocks.html');
136
+ check(specStart > 0, 'renderProjectSpecimen found in 43-blocks.html');
137
+
138
+ const sysCardBody = blocksSource.slice(sysCardStart, sysCardEnd);
139
+ const specBody = blocksSource.slice(specStart);
140
+
141
+ check(
142
+ sysCardBody.includes('renderPosterMedia') &&
143
+ sysCardBody.includes('fm.thumbnail_srcset') &&
144
+ sysCardBody.includes('sizes'),
145
+ 'renderSystemCard passes fm.thumbnail_srcset and sizes to renderPosterMedia'
146
+ );
147
+
148
+ check(
149
+ specBody.includes('renderPosterMedia') &&
150
+ specBody.includes('fm.thumbnail_srcset') &&
151
+ specBody.includes('sizes'),
152
+ 'renderProjectSpecimen passes fm.thumbnail_srcset and sizes to renderPosterMedia'
153
+ );
154
+
155
+ if (failed) {
156
+ console.error(`test-poster-srcset: ${failed} failure(s)`);
157
+ process.exit(1);
158
+ }
159
+ console.log('PASS test-poster-srcset');