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 +4 -4
- data/CHANGELOG.md +6 -0
- data/_includes/content-runtime/43-blocks.html +26 -4
- data/_includes/content-runtime/60-fragments.html +1 -0
- data/_layouts/projects.html +7 -1
- data/lib/pandorga/cli.rb +4 -1
- data/lib/pandorga/commands/new.rb +1 -1
- data/lib/pandorga/commands/thumbnails.rb +19 -0
- data/lib/pandorga/thumbnail_variants.rb +267 -0
- data/lib/pandorga/version.rb +1 -1
- data/lib/pandorga.rb +1 -0
- data/scripts/content/export-content-json.rb +22 -1
- data/scripts/test/test-poster-srcset.mjs +159 -0
- data/scripts/test/test-thumbnail-variants.rb +380 -0
- data/scripts/validate.sh +6 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c03095737ae31747d865ba734912bb725e2d88586405dd847c63d340b42bbfa3
|
|
4
|
+
data.tar.gz: 764bc60f9ef00c00e33391511a7cb5680438660dd5a622af0801add004670f31
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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), '"
|
|
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">' +
|
data/_layouts/projects.html
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
data/lib/pandorga/version.rb
CHANGED
data/lib/pandorga.rb
CHANGED
|
@@ -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 "Quote"" 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&y="2" 480w"'),
|
|
107
|
+
'srcset attribute is escaped'
|
|
108
|
+
);
|
|
109
|
+
check(
|
|
110
|
+
escaped.includes('sizes="(max-width: 600px) 100vw, "50vw""'),
|
|
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.
|
|
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
|