pandorga 1.12.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0650221add1248fcfa882ccf2557c9d73d1de757239e6e396e8cec1ddefa512
4
- data.tar.gz: 2c6502ac5c6fdd2d6153490b88925e465b7c66f315502e61819b6541e530831b
3
+ metadata.gz: c03095737ae31747d865ba734912bb725e2d88586405dd847c63d340b42bbfa3
4
+ data.tar.gz: 764bc60f9ef00c00e33391511a7cb5680438660dd5a622af0801add004670f31
5
5
  SHA512:
6
- metadata.gz: 9123d4ada63311688340fa8da4076a898dfb362764d15030da03cd62776def8172e9be28fc9d7fe47005930c004565860f8997ca46050c7149eb76332c94fa6d
7
- data.tar.gz: 6dbf48af21a4d6e56a0b21e070a951f8180424de1efbb906830d435200d736b4955781d0f04b3f7677be32cf1ce20a916bdad761faf41c6fd836ac48f06e1aa4
6
+ metadata.gz: 392a4fbde9ea59d1b7dfea8206f45c81f6d104d3b70efcb9a6c67afa21fc9738eefc589b537c8e045bdf7cdb13527ade3ec8c3a1d5683ff0c4f5134a72743dbc
7
+ data.tar.gz: 44b0d25b56e010e2ce1d2106c3a2c688a5b3ee61c322b55f39c89beca4d9821c31f5942de335f351289bab176a6752c809205ae7aae97f517351003eb71ce0a5
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.13.0] — 2026-10-19
6
+
7
+ ### Added
8
+
9
+ - `pandorga export` and the new `pandorga thumbnails [root]` command write 16:9 center-cropped width variants (`variants/<stem>-{480,960,1440}.jpg`, progressive JPEG at quality 82, stripped of metadata, never upscaled) beside each local `/media/...` project thumbnail when ImageMagick is installed (logging one warning otherwise), export `thumbnail_srcset` on project front matter and `writing_index.json`, and render `srcset` and `sizes` on project cards and the home specimen (PLT-AC-44).
10
+
5
11
  ## [1.12.0] — 2026-10-19
6
12
 
7
13
  ### Changed
@@ -300,7 +300,7 @@
300
300
  *
301
301
  * A video URL still renders rather than vanishing, but it downloads
302
302
  * nothing until the reader asks for it. */
303
- function renderPosterMedia(src, alt) {
303
+ function renderPosterMedia(src, alt, options) {
304
304
  var url = String(src || '').trim();
305
305
  if (!url) return '';
306
306
  var isVideo = /\.(mp4|webm|mov)(\?|#|$)/i.test(url);
@@ -312,9 +312,15 @@
312
312
  '</div>'
313
313
  ].join('');
314
314
  }
315
+ var opts = options || {};
316
+ var srcset = String(opts.srcset || '').trim();
317
+ var sizes = String(opts.sizes || '').trim();
315
318
  return [
316
319
  '<div class="ledger-poster">',
317
- '<img src="', escapeAttr(url), '" alt="', escapeAttr(alt || ''),
320
+ '<img src="', escapeAttr(url), '"',
321
+ srcset ? ' srcset="' + escapeAttr(srcset) + '"' : '',
322
+ (srcset && sizes) ? ' sizes="' + escapeAttr(sizes) + '"' : '',
323
+ ' alt="', escapeAttr(alt || ''),
318
324
  '" loading="lazy" decoding="async"',
319
325
  // Thumbnails are frequently hotlinked to third-party hosts. When one
320
326
  // dies, remove the frame rather than leaving an empty plate behind.
@@ -324,6 +330,16 @@
324
330
  ].join('');
325
331
  }
326
332
 
333
+ /* Project thumbnail sizes (PLT-AC-44):
334
+ * - Home specimen (home.css PLT-AC-41): >= 900px column is max(35%, 20rem)
335
+ * inside max-width 80rem (1408px at html 110%), minus card padding =>
336
+ * ~431 CSS px at 1440px viewport (31vw = 446px -> 480w at DPR 1, 960w at
337
+ * DPR 2); below 900px full band width (390px at DPR 2 = 780px -> 960w).
338
+ * - Projects grid card (projects.css): 3 cols >= 1024px (~384px -> 28vw),
339
+ * 2 cols >= 700px (~46vw), 1 col below (92vw). */
340
+ var PROJECT_CARD_SIZES = '(min-width: 1024px) 28vw, (min-width: 700px) 46vw, 92vw';
341
+ var HOME_SPECIMEN_SIZES = '(min-width: 900px) 31vw, 92vw';
342
+
327
343
  function twoDigits(value) {
328
344
  var label = String(Number(value));
329
345
  return label.length < 2 ? '0' + label : label;
@@ -418,7 +434,10 @@
418
434
  if (options.frameCorners) {
419
435
  html += '<span class="ledger-frame-corners" aria-hidden="true"></span>';
420
436
  }
421
- var poster = renderPosterMedia(fm.thumbnail, name);
437
+ var poster = renderPosterMedia(fm.thumbnail, name, {
438
+ srcset: fm.thumbnail_srcset,
439
+ sizes: options.sizes || PROJECT_CARD_SIZES
440
+ });
422
441
  if (poster) {
423
442
  html += poster;
424
443
  } else if (options.glyphFallback && fm.icon) {
@@ -501,7 +520,10 @@
501
520
  var mark = fm.icon
502
521
  ? { cls: 'material-symbols-outlined', text: fm.icon }
503
522
  : { cls: 'is-initial', text: String(name || '').charAt(0).toUpperCase() };
504
- var poster = renderPosterMedia(fm.thumbnail, name);
523
+ var poster = renderPosterMedia(fm.thumbnail, name, {
524
+ srcset: fm.thumbnail_srcset,
525
+ sizes: options.sizes || HOME_SPECIMEN_SIZES
526
+ });
505
527
  html += '<div class="home-specimen-figure">';
506
528
  if (poster) {
507
529
  html += poster + '<span class="home-specimen-badge ' + mark.cls + '" aria-hidden="true">' +
@@ -140,6 +140,7 @@
140
140
  description: entry.description,
141
141
  excerpt: entry.excerpt,
142
142
  thumbnail: entry.thumbnail,
143
+ thumbnail_srcset: entry.thumbnail_srcset,
143
144
  language: entry.language,
144
145
  date: entry.date,
145
146
  last_updated: entry.last_updated,
@@ -154,7 +154,13 @@ layout: shell
154
154
  imagery still reads as finished rather than as broken. */
155
155
  function plateHtml(item, modifier) {
156
156
  var fm = item.front_matter || {};
157
- var poster = runtime.renderPosterMedia(fm.thumbnail, projectName(item));
157
+ var sizes = modifier === 'projects-plate--card'
158
+ ? '(min-width: 1024px) 28vw, (min-width: 700px) 46vw, 92vw'
159
+ : '(min-width: 900px) 38vw, 92vw';
160
+ var poster = runtime.renderPosterMedia(fm.thumbnail, projectName(item), {
161
+ srcset: fm.thumbnail_srcset,
162
+ sizes: sizes
163
+ });
158
164
  var cls = 'projects-plate' + (modifier ? ' ' + modifier : '');
159
165
  if (poster) return '<div class="' + cls + '">' + poster + '</div>';
160
166
  if (!fm.icon) return '';
data/lib/pandorga/cli.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "commands/publish"
7
7
  require_relative "commands/install_functions"
8
8
  require_relative "commands/studio_schema"
9
9
  require_relative "commands/new"
10
+ require_relative "commands/thumbnails"
10
11
 
11
12
  module Pandorga
12
13
  # Dispatch for `exe/pandorga`.
@@ -18,7 +19,8 @@ module Pandorga
18
19
  "serve" => Commands::Serve,
19
20
  "publish" => Commands::Publish,
20
21
  "install-functions" => Commands::InstallFunctions,
21
- "studio-schema" => Commands::StudioSchema
22
+ "studio-schema" => Commands::StudioSchema,
23
+ "thumbnails" => Commands::Thumbnails
22
24
  }.freeze
23
25
 
24
26
  module_function
@@ -52,6 +54,7 @@ module Pandorga
52
54
  pandorga new PATH Scaffold a site from the minimal example
53
55
  pandorga doctor Check site config and environment
54
56
  pandorga export [src] [dest] Export content collections to JSON
57
+ pandorga thumbnails [root] Write 16:9 width variants for project thumbnails
55
58
  pandorga serve Local Jekyll serve with JSON re-export
56
59
  pandorga publish Sync content JSON to R2
57
60
  pandorga install-functions Copy functions/ and studio/ into site root
@@ -24,7 +24,7 @@ module Pandorga
24
24
 
25
25
  gem "pandorga", "~> 1.3"
26
26
  # Local checkout: gem "pandorga", path: "../pandorga"
27
- # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.12.0"
27
+ # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.13.0"
28
28
  gem "csv"
29
29
  gem "base64"
30
30
  RUBY
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "../thumbnail_variants"
5
+
6
+ module Pandorga
7
+ module Commands
8
+ # Generate 16:9 center-cropped width variants for local project thumbnails.
9
+ # Refs: PLT-AC-44
10
+ class Thumbnails
11
+ def run(argv)
12
+ site_root = Pathname.new(argv[0] || Dir.pwd).expand_path
13
+ summary = Pandorga::ThumbnailVariants.generate(site_root)
14
+ puts "pandorga thumbnails: #{summary[:written]} written, #{summary[:skipped]} skipped (#{summary[:sources]} sources in #{site_root})"
15
+ 0
16
+ end
17
+ end
18
+ end
19
+ end
@@ -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.12.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,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');
@@ -0,0 +1,380 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Refs: PLT-AC-44
5
+ require "fileutils"
6
+ require "json"
7
+ require "open3"
8
+ require "pathname"
9
+ require "tmpdir"
10
+ require "yaml"
11
+
12
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
13
+ EXE = ROOT.join("exe/pandorga")
14
+ EXPORT = ROOT.join("scripts/content/export-content-json.rb")
15
+
16
+ def fail!(message)
17
+ warn "FAIL test-thumbnail-variants: #{message}"
18
+ exit 1
19
+ end
20
+
21
+ def find_imagemagick
22
+ %w[magick convert].find do |bin|
23
+ _out, _err, status = Open3.capture3("which", bin)
24
+ status.success?
25
+ rescue Errno::ENOENT
26
+ false
27
+ end
28
+ end
29
+
30
+ magick_bin = find_imagemagick
31
+ unless magick_bin
32
+ if ENV["CI"]
33
+ fail!("ImageMagick is missing on CI")
34
+ else
35
+ puts "SKIP test-thumbnail-variants: ImageMagick not installed"
36
+ exit 0
37
+ end
38
+ end
39
+
40
+ def run_magick(magick_bin, *args)
41
+ cmd = magick_bin == "magick" ? ["magick", *args] : [args.first == "identify" ? "identify" : "convert", *(args.first == "identify" ? args.drop(1) : args)]
42
+ out, err, status = Open3.capture3(*cmd)
43
+ fail!("ImageMagick failed (#{cmd.join(' ')}): #{out}\n#{err}") unless status.success?
44
+ out
45
+ end
46
+
47
+ def create_image(magick_bin, path, size, color = "steelblue")
48
+ FileUtils.mkdir_p(path.dirname)
49
+ run_magick(magick_bin, "-size", size, "xc:#{color}", path.to_s)
50
+ end
51
+
52
+ def inject_minimal_exif!(jpeg_path)
53
+ data = File.binread(jpeg_path)
54
+ return unless data.start_with?("\xFF\xD8".b)
55
+
56
+ # Minimal big-endian TIFF header with Artist tag (0x013B) = "Test"
57
+ tiff = "MM\x00*\x00\x00\x00\x08\x00\x01\x01\x3B\x00\x02\x00\x00\x00\x05\x00\x00\x00\x1ATest\x00".b
58
+ payload = "Exif\x00\x00".b + tiff
59
+ app1 = "\xFF\xE1".b + [payload.bytesize + 2].pack("n") + payload
60
+ File.binwrite(jpeg_path, data[0, 2] + app1 + data[2..])
61
+ end
62
+
63
+ def identify_info(magick_bin, path)
64
+ raw = run_magick(magick_bin, "identify", "-format", "%w %h %m|%[EXIF:*]", path.to_s)
65
+ dims, exif = raw.split("|", 2)
66
+ w, h, fmt = dims.to_s.strip.split(/\s+/, 3)
67
+ { width: w.to_i, height: h.to_i, format: fmt.to_s, exif: exif.to_s.strip }
68
+ end
69
+
70
+ def write_fixture_site(site, magick_bin)
71
+ site.join("_config.yml").write(<<~YAML)
72
+ title: "Test Site"
73
+ url: "https://example.com"
74
+ pandorga:
75
+ identity:
76
+ name: "Ada Example"
77
+ url: "https://example.com"
78
+ content:
79
+ backend: static
80
+ pages:
81
+ - key: projects
82
+ template: portfolio
83
+ collection: projects
84
+ path: /pages/projects/
85
+ detail: /projects/:slug/
86
+ YAML
87
+
88
+ covers = site.join("content/media/images/covers")
89
+ create_image(magick_bin, covers.join("wide.jpg"), "3000x2000", "navy")
90
+ create_image(magick_bin, covers.join("tall.png"), "1200x1600", "darkgreen")
91
+ create_image(magick_bin, covers.join("small.jpg"), "800x450", "darkred")
92
+ FileUtils.mkdir_p(covers)
93
+ covers.join("clip.mp4").write("fake video bytes")
94
+
95
+ projects_dir = site.join("content/collections/projects")
96
+ FileUtils.mkdir_p(projects_dir)
97
+
98
+ entries = [
99
+ ["wide-a", "Wide Alpha", "/media/images/covers/wide.jpg", 1],
100
+ ["wide-b", "Wide Beta", "/media/images/covers/wide.jpg", 2],
101
+ ["tall-p", "Tall Project", "/media/images/covers/tall.png", 3],
102
+ ["small-p", "Small Project", "/media/images/covers/small.jpg", 4],
103
+ ["ext-url", "External Thumbnail", "https://example.com/x.jpg", 5],
104
+ ["video-p", "Video Thumbnail", "/media/images/covers/clip.mp4", 6],
105
+ ["missing-p", "Missing Thumbnail", "/media/images/covers/nope.jpg", 7]
106
+ ]
107
+
108
+ entries.each do |key, title, thumb, order|
109
+ projects_dir.join("#{key}.md").write(<<~MD)
110
+ ---
111
+ key: #{key}
112
+ project: #{title}
113
+ title: #{title}
114
+ tagline: "Summary for #{title}."
115
+ start_date: "2025-01"
116
+ status: ongoing
117
+ label: Ruby
118
+ icon: terminal
119
+ sort_order: #{order}
120
+ thumbnail: #{thumb}
121
+ tags:
122
+ - Ruby
123
+ ---
124
+
125
+ Body for #{title}.
126
+ MD
127
+ end
128
+ end
129
+
130
+ begin
131
+ require "pandorga/thumbnail_variants"
132
+ rescue LoadError => e
133
+ fail!("cannot load pandorga/thumbnail_variants: #{e.message}")
134
+ end
135
+
136
+ # 1. Library entry point + idempotency + regeneration when original is newer.
137
+ Dir.mktmpdir("pandorga-thumb-lib-") do |dir|
138
+ site = Pathname.new(dir)
139
+ write_fixture_site(site, magick_bin)
140
+
141
+ covers = site.join("content/media/images/covers")
142
+ variants = covers.join("variants")
143
+
144
+ summary = Pandorga::ThumbnailVariants.generate(site)
145
+ fail!("expected generate to return a Hash, got #{summary.inspect}") unless summary.is_a?(Hash)
146
+
147
+ # Check wide.jpg variants: 480x270, 960x540, 1440x810
148
+ [[480, 270], [960, 540], [1440, 810]].each do |w, h|
149
+ vpath = variants.join("wide-#{w}.jpg")
150
+ fail!("missing #{vpath}") unless vpath.file?
151
+ info = identify_info(magick_bin, vpath)
152
+ fail!("wide-#{w}.jpg expected #{w}x#{h}, got #{info[:width]}x#{info[:height]}") unless info[:width] == w && info[:height] == h
153
+ fail!("wide-#{w}.jpg expected JPEG, got #{info[:format]}") unless info[:format] == "JPEG"
154
+ fail!("wide-#{w}.jpg expected empty EXIF, got #{info[:exif].inspect}") unless info[:exif].empty?
155
+ end
156
+
157
+ # Check tall.png (1200x1600): 480 and 960 exist, 1440 skipped
158
+ [[480, 270], [960, 540]].each do |w, h|
159
+ vpath = variants.join("tall-#{w}.jpg")
160
+ fail!("missing #{vpath}") unless vpath.file?
161
+ info = identify_info(magick_bin, vpath)
162
+ fail!("tall-#{w}.jpg expected #{w}x#{h}, got #{info[:width]}x#{info[:height]}") unless info[:width] == w && info[:height] == h
163
+ fail!("tall-#{w}.jpg expected JPEG, got #{info[:format]}") unless info[:format] == "JPEG"
164
+ fail!("tall-#{w}.jpg expected empty EXIF, got #{info[:exif].inspect}") unless info[:exif].empty?
165
+ end
166
+ fail!("tall-1440.jpg must be skipped (no upscaling)") if variants.join("tall-1440.jpg").exist?
167
+
168
+ # Check small.jpg (800x450): only 480 exists, 960 and 1440 skipped
169
+ v480 = variants.join("small-480.jpg")
170
+ fail!("missing #{v480}") unless v480.file?
171
+ info480 = identify_info(magick_bin, v480)
172
+ fail!("small-480.jpg expected 480x270, got #{info480[:width]}x#{info480[:height]}") unless info480[:width] == 480 && info480[:height] == 270
173
+ fail!("small-480.jpg expected JPEG, got #{info480[:format]}") unless info480[:format] == "JPEG"
174
+ fail!("small-480.jpg expected empty EXIF, got #{info480[:exif].inspect}") unless info480[:exif].empty?
175
+ fail!("small-960.jpg must be skipped") if variants.join("small-960.jpg").exist?
176
+ fail!("small-1440.jpg must be skipped") if variants.join("small-1440.jpg").exist?
177
+
178
+ # Nothing written for URL, video, missing
179
+ written_names = variants.children.map { |p| p.basename.to_s }.sort
180
+ expected_names = %w[
181
+ small-480.jpg
182
+ tall-480.jpg
183
+ tall-960.jpg
184
+ wide-1440.jpg
185
+ wide-480.jpg
186
+ wide-960.jpg
187
+ ]
188
+ fail!("unexpected files in variants/: #{written_names.inspect}") unless written_names == expected_names
189
+
190
+ # Check srcset_for helper
191
+ wide_srcset = Pandorga::ThumbnailVariants.srcset_for(site, "/media/images/covers/wide.jpg")
192
+ fail!("unexpected wide srcset: #{wide_srcset.inspect}") unless wide_srcset ==
193
+ "/media/images/covers/variants/wide-480.jpg 480w, /media/images/covers/variants/wide-960.jpg 960w, /media/images/covers/variants/wide-1440.jpg 1440w"
194
+
195
+ tall_srcset = Pandorga::ThumbnailVariants.srcset_for(site, "/media/images/covers/tall.png")
196
+ fail!("unexpected tall srcset: #{tall_srcset.inspect}") unless tall_srcset ==
197
+ "/media/images/covers/variants/tall-480.jpg 480w, /media/images/covers/variants/tall-960.jpg 960w"
198
+
199
+ small_srcset = Pandorga::ThumbnailVariants.srcset_for(site, "/media/images/covers/small.jpg")
200
+ fail!("unexpected small srcset: #{small_srcset.inspect}") unless small_srcset ==
201
+ "/media/images/covers/variants/small-480.jpg 480w"
202
+
203
+ fail!("expected nil srcset for external URL") unless Pandorga::ThumbnailVariants.srcset_for(site, "https://example.com/x.jpg").nil?
204
+ fail!("expected nil srcset for video") unless Pandorga::ThumbnailVariants.srcset_for(site, "/media/images/covers/clip.mp4").nil?
205
+ fail!("expected nil srcset for missing file") unless Pandorga::ThumbnailVariants.srcset_for(site, "/media/images/covers/nope.jpg").nil?
206
+
207
+ # Idempotent rerun: set variant mtimes in the future of original, verify rerun keeps mtimes
208
+ past = Time.now - 120
209
+ variant_time = Time.now - 60
210
+ [covers.join("wide.jpg"), covers.join("tall.png"), covers.join("small.jpg")].each do |orig|
211
+ File.utime(past, past, orig.to_s)
212
+ end
213
+ variants.children.each { |v| File.utime(variant_time, variant_time, v.to_s) }
214
+ mtimes_before = variants.children.to_h { |v| [v.basename.to_s, v.mtime] }
215
+
216
+ Pandorga::ThumbnailVariants.generate(site)
217
+ mtimes_after = variants.children.to_h { |v| [v.basename.to_s, v.mtime] }
218
+ fail!("rerun modified up-to-date variants: #{mtimes_before.inspect} vs #{mtimes_after.inspect}") unless mtimes_before == mtimes_after
219
+
220
+ # Touch original newer: only that image's variant set is regenerated
221
+ newer = Time.now
222
+ File.utime(newer, newer, covers.join("small.jpg").to_s)
223
+ Pandorga::ThumbnailVariants.generate(site)
224
+ fail!("expected small-480.jpg to be regenerated when small.jpg is newer") unless variants.join("small-480.jpg").mtime > mtimes_before["small-480.jpg"]
225
+ fail!("expected wide-480.jpg mtime unchanged") unless variants.join("wide-480.jpg").mtime == mtimes_before["wide-480.jpg"]
226
+ end
227
+
228
+ # 2. CLI entry point `exe/pandorga thumbnails <root>`
229
+ Dir.mktmpdir("pandorga-thumb-cli-") do |dir|
230
+ site = Pathname.new(dir)
231
+ write_fixture_site(site, magick_bin)
232
+
233
+ env = { "RUBYOPT" => "-I#{ROOT.join('lib')}" }
234
+ stdout, stderr, status = Open3.capture3(env, "ruby", EXE.to_s, "thumbnails", site.to_s)
235
+ fail!("CLI pandorga thumbnails failed:\n#{stdout}\n#{stderr}") unless status.success?
236
+
237
+ variants = site.join("content/media/images/covers/variants")
238
+ %w[wide-480.jpg wide-960.jpg wide-1440.jpg tall-480.jpg tall-960.jpg small-480.jpg].each do |name|
239
+ fail!("CLI did not write #{name}") unless variants.join(name).file?
240
+ end
241
+ end
242
+
243
+ # 3. Full gem export runs generation itself and rewrites thumbnail_srcset URLs
244
+ Dir.mktmpdir("pandorga-thumb-export-") do |dir|
245
+ site = Pathname.new(dir)
246
+ write_fixture_site(site, magick_bin)
247
+ out = site.join("_content_json")
248
+ variants = site.join("content/media/images/covers/variants")
249
+ fail!("variants must not exist before export") if variants.exist?
250
+
251
+ env = {
252
+ "RUBYOPT" => "-I#{ROOT.join('lib')}",
253
+ "PANDORGA_SITE_ROOT" => site.to_s,
254
+ "CONTENT_API_BASE_URL" => "https://cdn.example.com",
255
+ "CONTENT_EXPORT_SKIP_VALIDATE" => "1"
256
+ }
257
+ stdout, stderr, status = Open3.capture3(
258
+ env, "ruby", EXPORT.to_s,
259
+ site.join("content").to_s, out.to_s,
260
+ chdir: site.to_s
261
+ )
262
+ fail!("export failed:\n#{stdout}\n#{stderr}") unless status.success?
263
+ fail!("export did not generate variants/") unless variants.join("wide-480.jpg").file?
264
+
265
+ wide_json = JSON.parse(out.join("collections/projects/wide-a.json").read)
266
+ wide_fm = wide_json.fetch("front_matter")
267
+ expected_wide_srcset = [
268
+ "https://cdn.example.com/media/images/covers/variants/wide-480.jpg 480w",
269
+ "https://cdn.example.com/media/images/covers/variants/wide-960.jpg 960w",
270
+ "https://cdn.example.com/media/images/covers/variants/wide-1440.jpg 1440w"
271
+ ].join(", ")
272
+ unless wide_fm["thumbnail"] == "https://cdn.example.com/media/images/covers/wide.jpg"
273
+ fail!("wide-a thumbnail unexpected: #{wide_fm['thumbnail'].inspect}")
274
+ end
275
+ unless wide_fm["thumbnail_srcset"] == expected_wide_srcset
276
+ fail!("wide-a thumbnail_srcset unexpected: #{wide_fm['thumbnail_srcset'].inspect}")
277
+ end
278
+
279
+ tall_fm = JSON.parse(out.join("collections/projects/tall-p.json").read).fetch("front_matter")
280
+ expected_tall_srcset = [
281
+ "https://cdn.example.com/media/images/covers/variants/tall-480.jpg 480w",
282
+ "https://cdn.example.com/media/images/covers/variants/tall-960.jpg 960w"
283
+ ].join(", ")
284
+ unless tall_fm["thumbnail_srcset"] == expected_tall_srcset
285
+ fail!("tall-p thumbnail_srcset unexpected: #{tall_fm['thumbnail_srcset'].inspect}")
286
+ end
287
+
288
+ small_fm = JSON.parse(out.join("collections/projects/small-p.json").read).fetch("front_matter")
289
+ expected_small_srcset = "https://cdn.example.com/media/images/covers/variants/small-480.jpg 480w"
290
+ unless small_fm["thumbnail_srcset"] == expected_small_srcset
291
+ fail!("small-p thumbnail_srcset unexpected: #{small_fm['thumbnail_srcset'].inspect}")
292
+ end
293
+
294
+ %w[ext-url video-p missing-p].each do |slug|
295
+ fm = JSON.parse(out.join("collections/projects/#{slug}.json").read).fetch("front_matter")
296
+ if fm.key?("thumbnail_srcset")
297
+ fail!("#{slug} must not have thumbnail_srcset, got #{fm['thumbnail_srcset'].inspect}")
298
+ end
299
+ end
300
+
301
+ # Also check writing_index.json carries thumbnail_srcset for the home specimen
302
+ index = JSON.parse(out.join("data/writing_index.json").read)
303
+ wide_idx = index.find { |e| e["slug"] == "wide-a" }
304
+ fail!("wide-a missing from writing_index.json") unless wide_idx
305
+ unless wide_idx["thumbnail_srcset"] == expected_wide_srcset
306
+ fail!("writing_index wide-a thumbnail_srcset unexpected: #{wide_idx['thumbnail_srcset'].inspect}")
307
+ end
308
+ %w[ext-url video-p missing-p].each do |slug|
309
+ entry = index.find { |e| e["slug"] == slug }
310
+ fail!("#{slug} missing from writing_index.json") unless entry
311
+ if entry.key?("thumbnail_srcset")
312
+ fail!("writing_index #{slug} must not have thumbnail_srcset, got #{entry['thumbnail_srcset'].inspect}")
313
+ end
314
+ end
315
+ end
316
+
317
+ # 4. Without ImageMagick: export succeeds, prints one warning line, writes no variants and no thumbnail_srcset
318
+ Dir.mktmpdir("pandorga-thumb-nomagick-") do |dir|
319
+ site = Pathname.new(dir)
320
+ write_fixture_site(site, magick_bin)
321
+ out = site.join("_content_json")
322
+
323
+ env = {
324
+ "RUBYOPT" => "-I#{ROOT.join('lib')}",
325
+ "PANDORGA_SITE_ROOT" => site.to_s,
326
+ "CONTENT_API_BASE_URL" => "",
327
+ "CONTENT_EXPORT_SKIP_VALIDATE" => "1",
328
+ "PANDORGA_IMAGEMAGICK" => "/nonexistent/magick"
329
+ }
330
+ stdout, stderr, status = Open3.capture3(
331
+ env, "ruby", EXPORT.to_s,
332
+ site.join("content").to_s, out.to_s,
333
+ chdir: site.to_s
334
+ )
335
+ fail!("export without ImageMagick failed:\n#{stdout}\n#{stderr}") unless status.success?
336
+
337
+ combined_lines = "#{stdout}\n#{stderr}".lines.map(&:strip).reject(&:empty?)
338
+ warnings = combined_lines.select { |l| l.match?(/warning.*imagemagick|imagemagick.*warning/i) }
339
+ unless warnings.length == 1
340
+ fail!("expected exactly 1 ImageMagick warning line, got #{warnings.length}: #{warnings.inspect}\nAll output:\n#{stdout}\n#{stderr}")
341
+ end
342
+
343
+ variants = site.join("content/media/images/covers/variants")
344
+ if variants.exist? && variants.children.any?
345
+ fail!("expected no variants written when ImageMagick is unavailable")
346
+ end
347
+
348
+ wide_fm = JSON.parse(out.join("collections/projects/wide-a.json").read).fetch("front_matter")
349
+ if wide_fm.key?("thumbnail_srcset")
350
+ fail!("expected no thumbnail_srcset when ImageMagick is unavailable, got #{wide_fm['thumbnail_srcset'].inspect}")
351
+ end
352
+ end
353
+
354
+ # 5. An unreadable image warns, keeps its original, and does not stop the export.
355
+ Dir.mktmpdir("pandorga-thumb-corrupt-") do |dir|
356
+ site = Pathname.new(dir)
357
+ write_fixture_site(site, magick_bin)
358
+ site.join("content/media/images/covers/small.jpg").write("not a jpeg")
359
+ out = site.join("_content_json")
360
+ env = {
361
+ "RUBYOPT" => "-I#{ROOT.join('lib')}",
362
+ "PANDORGA_SITE_ROOT" => site.to_s,
363
+ "CONTENT_API_BASE_URL" => "",
364
+ "CONTENT_EXPORT_SKIP_VALIDATE" => "1"
365
+ }
366
+ stdout, stderr, status = Open3.capture3(
367
+ env, "ruby", EXPORT.to_s,
368
+ site.join("content").to_s, out.to_s,
369
+ chdir: site.to_s
370
+ )
371
+ fail!("export with an unreadable image failed:\n#{stdout}\n#{stderr}") unless status.success?
372
+ fail!("expected a warning naming small.jpg") unless "#{stdout}\n#{stderr}".match?(/warning.*small\.jpg/)
373
+ variants = site.join("content/media/images/covers/variants")
374
+ fail!("unreadable image must not get variants") if variants.join("small-480.jpg").exist?
375
+ fail!("readable images must still get variants") unless variants.join("wide-480.jpg").file?
376
+ small_fm = JSON.parse(out.join("collections/projects/small-p.json").read).fetch("front_matter")
377
+ fail!("unreadable image must not get thumbnail_srcset") if small_fm.key?("thumbnail_srcset")
378
+ end
379
+
380
+ puts "PASS test-thumbnail-variants"
data/scripts/validate.sh CHANGED
@@ -71,6 +71,7 @@ TESTS=(
71
71
  scripts/test/test-css-delivery.rb
72
72
  scripts/test/test-discovery.rb
73
73
  scripts/test/test-hero-portraits.rb
74
+ scripts/test/test-thumbnail-variants.rb
74
75
  )
75
76
 
76
77
  failed=0
@@ -91,6 +92,11 @@ if ! node scripts/test/test-hero-portraits.mjs; then
91
92
  failed=1
92
93
  fi
93
94
 
95
+ echo "-- scripts/test/test-poster-srcset.mjs"
96
+ if ! node scripts/test/test-poster-srcset.mjs; then
97
+ failed=1
98
+ fi
99
+
94
100
  echo "-- scripts/test/test-studio-pipeline-config.mjs"
95
101
  if ! node scripts/test/test-studio-pipeline-config.mjs; then
96
102
  failed=1
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pandorga
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.12.0
4
+ version: 1.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Pandorga contributors
@@ -287,6 +287,7 @@ files:
287
287
  - lib/pandorga/commands/publish.rb
288
288
  - lib/pandorga/commands/serve.rb
289
289
  - lib/pandorga/commands/studio_schema.rb
290
+ - lib/pandorga/commands/thumbnails.rb
290
291
  - lib/pandorga/detail_routes.rb
291
292
  - lib/pandorga/discovery.rb
292
293
  - lib/pandorga/fonts.rb
@@ -325,6 +326,7 @@ files:
325
326
  - lib/pandorga/object_store_env.rb
326
327
  - lib/pandorga/registry.rb
327
328
  - lib/pandorga/registry/schema.yml
329
+ - lib/pandorga/thumbnail_variants.rb
328
330
  - lib/pandorga/version.rb
329
331
  - scripts/cdn-allowlist.json
330
332
  - scripts/check_home_lists.mjs
@@ -449,6 +451,7 @@ files:
449
451
  - scripts/test/test-math-row-breaks.rb
450
452
  - scripts/test/test-new.rb
451
453
  - scripts/test/test-no-personal-data.rb
454
+ - scripts/test/test-poster-srcset.mjs
452
455
  - scripts/test/test-public-cv-export.rb
453
456
  - scripts/test/test-publish-site-root.rb
454
457
  - scripts/test/test-pwa-icons.rb
@@ -473,6 +476,7 @@ files:
473
476
  - scripts/test/test-template-packages.rb
474
477
  - scripts/test/test-text-excerpt.rb
475
478
  - scripts/test/test-theme-contrast.rb
479
+ - scripts/test/test-thumbnail-variants.rb
476
480
  - scripts/test/test-unwrap-media-src.mjs
477
481
  - scripts/test/test-unwrap-media-src.rb
478
482
  - scripts/test/test-validate-allowlist.rb