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,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');
|