pandorga 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/DESIGN.md +6 -8
- 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/install_functions.rb +36 -1
- data/lib/pandorga/commands/new.rb +1 -1
- data/lib/pandorga/commands/studio_schema.rb +162 -54
- data/lib/pandorga/commands/thumbnails.rb +19 -0
- data/lib/pandorga/registry/schema.yml +1 -1
- 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/fixtures/studio-taxonomy/page-local/_config.yml +9 -0
- data/scripts/test/fixtures/studio-taxonomy/top-level/_config.yml +9 -0
- data/scripts/test/test-poster-srcset.mjs +159 -0
- data/scripts/test/test-studio-taxonomy.rb +246 -0
- data/scripts/test/test-thumbnail-variants.rb +380 -0
- data/scripts/validate.sh +7 -0
- data/studio/schema.yml +4 -17
- metadata +8 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Refs: PLT-AC-43
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "yaml"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require "tmpdir"
|
|
9
|
+
require "open3"
|
|
10
|
+
require "date"
|
|
11
|
+
|
|
12
|
+
ROOT = Pathname.new(__dir__).join("../..").expand_path
|
|
13
|
+
EXE = ROOT.join("exe/pandorga")
|
|
14
|
+
GEM_SCHEMA = ROOT.join("studio/schema.yml")
|
|
15
|
+
FIXTURES = ROOT.join("scripts/test/fixtures/studio-taxonomy")
|
|
16
|
+
TOP_LEVEL_CONFIG = FIXTURES.join("top-level/_config.yml")
|
|
17
|
+
PAGE_LOCAL_CONFIG = FIXTURES.join("page-local/_config.yml")
|
|
18
|
+
|
|
19
|
+
LEFTOVER_TAGS = %w[
|
|
20
|
+
Aesthetics
|
|
21
|
+
Social
|
|
22
|
+
Mathematics
|
|
23
|
+
Robotics
|
|
24
|
+
DevOps
|
|
25
|
+
brasilidade
|
|
26
|
+
engraçadinho
|
|
27
|
+
devaneio
|
|
28
|
+
provocação
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
def fail!(message)
|
|
32
|
+
warn "FAIL test-studio-taxonomy: #{message}"
|
|
33
|
+
exit 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run_install_functions(dest)
|
|
37
|
+
env = { "RUBYOPT" => "-I#{ROOT.join('lib')}" }
|
|
38
|
+
Open3.capture3(env, "ruby", EXE.to_s, "install-functions", dest.to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def load_yaml(path)
|
|
42
|
+
YAML.safe_load(path.read, permitted_classes: [Date, Time], aliases: true) || {}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def find_content_collection(schema, name)
|
|
46
|
+
Array(schema["content"]).each do |group|
|
|
47
|
+
next unless group.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
Array(group["items"]).each do |item|
|
|
50
|
+
next unless item.is_a?(Hash)
|
|
51
|
+
next unless item["type"].to_s == "collection"
|
|
52
|
+
|
|
53
|
+
return item if item["name"].to_s == name.to_s
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def find_tags_field(entry)
|
|
60
|
+
return nil unless entry.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
Array(entry["fields"]).find { |f| f.is_a?(Hash) && f["name"].to_s == "tags" }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# a, b, e, d. Top-level fixture, no leftovers, overrides, and config change.
|
|
66
|
+
Dir.mktmpdir("pandorga-studio-tax-top-") do |dir|
|
|
67
|
+
site = Pathname.new(dir)
|
|
68
|
+
FileUtils.cp(TOP_LEVEL_CONFIG, site.join("_config.yml"))
|
|
69
|
+
|
|
70
|
+
stdout, stderr, status = run_install_functions(site)
|
|
71
|
+
fail!("case a CLI failed: #{stdout}\n#{stderr}") unless status.success?
|
|
72
|
+
|
|
73
|
+
installed_path = site.join("studio/schema.yml")
|
|
74
|
+
fail!("installed schema missing at #{installed_path}") unless installed_path.file?
|
|
75
|
+
|
|
76
|
+
schema = load_yaml(installed_path)
|
|
77
|
+
[
|
|
78
|
+
["content.articles", find_content_collection(schema, "articles")],
|
|
79
|
+
["collections.articles", schema.dig("collections", "articles")]
|
|
80
|
+
].each do |label, entry|
|
|
81
|
+
# The gem's schema has no collections index; the installer must not add one.
|
|
82
|
+
next if label.start_with?("collections.") && !schema.key?("collections")
|
|
83
|
+
|
|
84
|
+
fail!("missing #{label}") unless entry.is_a?(Hash)
|
|
85
|
+
opts = find_tags_field(entry)&.fetch("options", nil)
|
|
86
|
+
fail!("missing tags options on #{label}") unless opts.is_a?(Hash)
|
|
87
|
+
unless opts["values"] == %w[Design Organizations]
|
|
88
|
+
fail!("#{label} values expected [Design, Organizations], got #{opts['values'].inspect}")
|
|
89
|
+
end
|
|
90
|
+
fail!("#{label} max expected 2, got #{opts['max'].inspect}") unless opts["max"] == 2
|
|
91
|
+
fail!("#{label} min expected 1, got #{opts['min'].inspect}") unless opts["min"] == 1
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
[
|
|
95
|
+
["content.posts", find_content_collection(schema, "posts")],
|
|
96
|
+
["collections.posts", schema.dig("collections", "posts")]
|
|
97
|
+
].each do |label, entry|
|
|
98
|
+
# The gem's schema has no collections index; the installer must not add one.
|
|
99
|
+
next if label.start_with?("collections.") && !schema.key?("collections")
|
|
100
|
+
|
|
101
|
+
fail!("missing #{label}") unless entry.is_a?(Hash)
|
|
102
|
+
opts = find_tags_field(entry)&.fetch("options", nil)
|
|
103
|
+
fail!("missing tags options on #{label}") unless opts.is_a?(Hash)
|
|
104
|
+
unless opts["values"] == %w[alpha beta gamma]
|
|
105
|
+
fail!("#{label} values expected [alpha, beta, gamma], got #{opts['values'].inspect}")
|
|
106
|
+
end
|
|
107
|
+
fail!("#{label} max expected 3, got #{opts['max'].inspect}") unless opts["max"] == 3
|
|
108
|
+
if opts.key?("min")
|
|
109
|
+
fail!("#{label} should not have min key, got #{opts['min'].inspect}")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
fail!("installer must not add a collections index") if schema.key?("collections")
|
|
114
|
+
|
|
115
|
+
# Free-text tags (type string, list) are not pickers and stay untouched.
|
|
116
|
+
projects_tags = find_tags_field(find_content_collection(schema, "projects"))
|
|
117
|
+
gem_projects_tags = find_tags_field(find_content_collection(load_yaml(GEM_SCHEMA), "projects"))
|
|
118
|
+
unless projects_tags && projects_tags == gem_projects_tags
|
|
119
|
+
fail!("free-text projects tags changed: #{projects_tags.inspect}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# b. No leftover site vocabulary in installed schema.
|
|
123
|
+
raw_text = installed_path.read
|
|
124
|
+
LEFTOVER_TAGS.each do |word|
|
|
125
|
+
if raw_text.include?(word)
|
|
126
|
+
fail!("installed schema still contains leftover tag #{word.inspect}")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# e. Overrides applied after taxonomies.
|
|
131
|
+
site.join("studio.overrides.yml").write(<<~YAML)
|
|
132
|
+
collections:
|
|
133
|
+
articles:
|
|
134
|
+
label: Essays
|
|
135
|
+
YAML
|
|
136
|
+
stdout, stderr, status = run_install_functions(site)
|
|
137
|
+
fail!("case e CLI failed: #{stdout}\n#{stderr}") unless status.success?
|
|
138
|
+
|
|
139
|
+
overridden = load_yaml(installed_path)
|
|
140
|
+
articles_item = find_content_collection(overridden, "articles")
|
|
141
|
+
unless articles_item&.fetch("label", nil) == "Essays"
|
|
142
|
+
fail!("expected overridden articles label 'Essays', got #{articles_item&.fetch('label', nil).inspect}")
|
|
143
|
+
end
|
|
144
|
+
articles_vals = find_tags_field(articles_item)&.dig("options", "values")
|
|
145
|
+
unless articles_vals == %w[Design Organizations]
|
|
146
|
+
fail!("expected overridden articles tags [Design, Organizations], got #{articles_vals.inspect}")
|
|
147
|
+
end
|
|
148
|
+
FileUtils.rm_f(site.join("studio.overrides.yml"))
|
|
149
|
+
|
|
150
|
+
# d. Changing _config.yml changes the output.
|
|
151
|
+
site.join("_config.yml").write(<<~YAML)
|
|
152
|
+
pandorga:
|
|
153
|
+
taxonomies:
|
|
154
|
+
articles:
|
|
155
|
+
tags: [Zeta]
|
|
156
|
+
max: 2
|
|
157
|
+
posts:
|
|
158
|
+
tags: [alpha, beta, gamma]
|
|
159
|
+
max: 3
|
|
160
|
+
YAML
|
|
161
|
+
stdout, stderr, status = run_install_functions(site)
|
|
162
|
+
fail!("case d CLI failed: #{stdout}\n#{stderr}") unless status.success?
|
|
163
|
+
|
|
164
|
+
updated = load_yaml(installed_path)
|
|
165
|
+
updated_vals = find_tags_field(find_content_collection(updated, "articles"))&.dig("options", "values")
|
|
166
|
+
unless updated_vals == %w[Zeta]
|
|
167
|
+
fail!("expected updated articles tags [Zeta], got #{updated_vals.inspect}")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# c. Page-local fixture: articles falls back to pages[].taxonomy; posts stays [] with a warning.
|
|
172
|
+
Dir.mktmpdir("pandorga-studio-tax-page-") do |dir|
|
|
173
|
+
site = Pathname.new(dir)
|
|
174
|
+
FileUtils.cp(PAGE_LOCAL_CONFIG, site.join("_config.yml"))
|
|
175
|
+
|
|
176
|
+
stdout, stderr, status = run_install_functions(site)
|
|
177
|
+
fail!("case c CLI failed: #{stdout}\n#{stderr}") unless status.success?
|
|
178
|
+
|
|
179
|
+
schema = load_yaml(site.join("studio/schema.yml"))
|
|
180
|
+
articles_opts = find_tags_field(find_content_collection(schema, "articles"))&.fetch("options", nil)
|
|
181
|
+
fail!("case c missing articles tags options") unless articles_opts.is_a?(Hash)
|
|
182
|
+
unless articles_opts["values"] == %w[Notes]
|
|
183
|
+
fail!("case c articles values expected [Notes], got #{articles_opts['values'].inspect}")
|
|
184
|
+
end
|
|
185
|
+
unless articles_opts["max"] == 1
|
|
186
|
+
fail!("case c articles max expected 1, got #{articles_opts['max'].inspect}")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
posts_opts = find_tags_field(find_content_collection(schema, "posts"))&.fetch("options", nil)
|
|
190
|
+
fail!("case c missing posts tags options") unless posts_opts.is_a?(Hash)
|
|
191
|
+
unless posts_opts["values"] == []
|
|
192
|
+
fail!("case c posts values expected [], got #{posts_opts['values'].inspect}")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
combined = "#{stdout}\n#{stderr}"
|
|
196
|
+
unless combined.match?(/warning:.*posts/i)
|
|
197
|
+
fail!("case c expected warning mentioning posts, got:\n#{combined}")
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# f. Gem source: studio/schema.yml has values: [] on every select tags field.
|
|
202
|
+
gem_schema = load_yaml(GEM_SCHEMA)
|
|
203
|
+
gem_tag_fields = 0
|
|
204
|
+
Array(gem_schema["content"]).each do |group|
|
|
205
|
+
next unless group.is_a?(Hash)
|
|
206
|
+
|
|
207
|
+
Array(group["items"]).each do |item|
|
|
208
|
+
next unless item.is_a?(Hash) && item["type"].to_s == "collection"
|
|
209
|
+
|
|
210
|
+
field = find_tags_field(item)
|
|
211
|
+
next unless field && (field["type"].to_s == "select" || field["options"].is_a?(Hash))
|
|
212
|
+
|
|
213
|
+
gem_tag_fields += 1
|
|
214
|
+
vals = field.dig("options", "values")
|
|
215
|
+
unless vals == []
|
|
216
|
+
fail!("gem studio/schema.yml #{item['name']} tags values expected [], got #{vals.inspect}")
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
if gem_schema["collections"].is_a?(Hash)
|
|
221
|
+
gem_schema["collections"].each do |name, item|
|
|
222
|
+
field = find_tags_field(item)
|
|
223
|
+
next unless field && (field["type"].to_s == "select" || field["options"].is_a?(Hash))
|
|
224
|
+
|
|
225
|
+
vals = field.dig("options", "values")
|
|
226
|
+
unless vals == []
|
|
227
|
+
fail!("gem studio/schema.yml collections.#{name} tags values expected [], got #{vals.inspect}")
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
fail!("expected select tags fields in gem studio/schema.yml") if gem_tag_fields.zero?
|
|
232
|
+
|
|
233
|
+
# g. No _config.yml in destination: command succeeds and copies schema unchanged.
|
|
234
|
+
Dir.mktmpdir("pandorga-studio-tax-noconfig-") do |dir|
|
|
235
|
+
site = Pathname.new(dir)
|
|
236
|
+
stdout, stderr, status = run_install_functions(site)
|
|
237
|
+
fail!("case g CLI failed: #{stdout}\n#{stderr}") unless status.success?
|
|
238
|
+
|
|
239
|
+
installed_path = site.join("studio/schema.yml")
|
|
240
|
+
fail!("case g missing studio/schema.yml") unless installed_path.file?
|
|
241
|
+
unless installed_path.read == GEM_SCHEMA.read
|
|
242
|
+
fail!("case g expected studio/schema.yml to be copied unchanged when _config.yml is absent")
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
puts "PASS test-studio-taxonomy"
|
|
@@ -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
|
@@ -44,6 +44,7 @@ TESTS=(
|
|
|
44
44
|
scripts/test/test-studio-api-contract.rb
|
|
45
45
|
scripts/test/test-studio-rewrite-api-contract.rb
|
|
46
46
|
scripts/test/test-studio-schema-composition.rb
|
|
47
|
+
scripts/test/test-studio-taxonomy.rb
|
|
47
48
|
scripts/test/test-text-excerpt.rb
|
|
48
49
|
scripts/test/test-git-chronology.rb
|
|
49
50
|
scripts/test/test-math-row-breaks.rb
|
|
@@ -70,6 +71,7 @@ TESTS=(
|
|
|
70
71
|
scripts/test/test-css-delivery.rb
|
|
71
72
|
scripts/test/test-discovery.rb
|
|
72
73
|
scripts/test/test-hero-portraits.rb
|
|
74
|
+
scripts/test/test-thumbnail-variants.rb
|
|
73
75
|
)
|
|
74
76
|
|
|
75
77
|
failed=0
|
|
@@ -90,6 +92,11 @@ if ! node scripts/test/test-hero-portraits.mjs; then
|
|
|
90
92
|
failed=1
|
|
91
93
|
fi
|
|
92
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
|
+
|
|
93
100
|
echo "-- scripts/test/test-studio-pipeline-config.mjs"
|
|
94
101
|
if ! node scripts/test/test-studio-pipeline-config.mjs; then
|
|
95
102
|
failed=1
|